diff --git a/.env.example b/.env.example
index 6ec10db..2c115ef 100644
--- a/.env.example
+++ b/.env.example
@@ -578,10 +578,13 @@ MEMIND_RUNTIME_PROFILE=local
# UMS_INGEST_TOKEN=local-dev-ums-ingest-token
# tang19821002 → 微信「唐」:同一人的多账号归并到 canonical user_id
# MEMIND_CANONICAL_USER_MAP=d0678bbc-2a50-4e08-8bf0-6b6c9301e2d6=a70ff537-8908-486e-9b6c-042e07cc25db
+# Portal john → 本地 MeInput testuser2(Rain 拉取 testuser2 的 mi_input_events)
+# MEMIND_CANONICAL_USER_MAP=1c99b83b-0454-474f-a5d2-129d34506a32=3f24dcbb-0505-4f0a-a432-828f608e2448
# 迁移:node user-model-service/migrate.mjs
# Temporal Recall — 时间回忆域(Context Planner + Multi-source Retrieval)
-# MEINPUT_DATABASE_URL=mysql://boot:888888@localhost:3306/meinput
+# MEMIND_HIDE_PAGE_TEMPLATES=1 # Rain 模式默认隐藏页面模板 skill 与模板商城入口
+# MEMIND_RAIN_REPLACE_TEMPLATES=1
# MEINPUT_BASE_URL=http://127.0.0.1:8090
# 测试:node scripts/smoke-temporal-recall.mjs "我这周有什么重要的事?"
# MEMIND_RUNTIME_CONTEXT_ENABLED=1
diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs
index ed7ead1..581c5a3 100644
--- a/agent-run-gateway.mjs
+++ b/agent-run-gateway.mjs
@@ -51,6 +51,7 @@ import {
resolveDirectEscalationContextPolicy,
} from './chat-task-intent-config.mjs';
import { extractAgentRunExperience } from './experience-extractor.mjs';
+import { executeRainPipeline, isRainModeMessage } from './rain-service/index.mjs';
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
@@ -845,6 +846,7 @@ export function createAgentRunGateway({
tkmindProxy,
toolGateway = null,
directChatService = null,
+ llmProviderService = null,
systemDisclosurePolicyService = null,
chatIntentRouter = null,
sessionSnapshotService = null,
@@ -1771,6 +1773,72 @@ export function createAgentRunGateway({
policyBlocked: true,
};
}
+ let rainModeActive = isRainModeMessage(userMessage);
+ if (rainModeActive) {
+ if (!llmProviderService?.createChatCompletion) {
+ const error = new Error('Rain 模式需要 LLM 服务,但当前未配置');
+ error.code = 'RAIN_LLM_UNAVAILABLE';
+ error.retryable = false;
+ throw error;
+ }
+ const rain = await executeRainPipeline({
+ llmProviderService,
+ userId: row.user_id,
+ userMessage,
+ });
+ await appendEvent(runId, 'rain_pipeline', {
+ phase: rain.phase,
+ recordCount: rain.rainMeta?.recordCount ?? 0,
+ timeSource: rain.rainMeta?.timeResolution?.source ?? null,
+ });
+ if (rain.phase === 'clarify') {
+ const result = await directChatService.respondDeterministically({
+ userId: row.user_id,
+ sessionId: row.agent_session_id ?? null,
+ requestId: row.request_id,
+ userMessage,
+ reply: rain.userReply,
+ metadata: {
+ source: 'portal-rain-clarify',
+ rainMode: true,
+ },
+ onSessionReady: async (activeSessionId) => {
+ await pool.query(
+ `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`,
+ [activeSessionId, nowMs(), runId],
+ );
+ await appendRunSnapshot(runId);
+ },
+ });
+ await appendEvent(runId, 'rain_clarify_completed', {
+ sessionId: result.sessionId,
+ });
+ return {
+ sessionId: result.sessionId,
+ routing: null,
+ rainClarify: true,
+ };
+ }
+ userMessage = {
+ ...userMessage,
+ content: [{ type: 'text', text: rain.gooseHandoffText }],
+ metadata: {
+ ...(userMessage?.metadata ?? {}),
+ memindRun: {
+ ...(userMessage?.metadata?.memindRun ?? {}),
+ rainMode: true,
+ rainHandoff: true,
+ },
+ agentVisible: true,
+ userVisible: userMessage?.metadata?.userVisible ?? true,
+ },
+ };
+ runOptions = {
+ ...runOptions,
+ rainMode: true,
+ forceDeepReasoning: runOptions.forceDeepReasoning ?? false,
+ };
+ }
const routing = await resolveRunRouting(row, userMessage, runOptions, { runId });
const routingDecision = resolveLegacyRouteFromClassification(routing) ?? routing?.route ?? null;
const cursorFirstApplied = applyCursorFirstAgentExecution(userMessage, runOptions, {
@@ -1808,7 +1876,12 @@ export function createAgentRunGateway({
if (!(await fallbackCursorExecutorToDeepseek(err))) throw err;
}
let agentMemoryContext = null;
- if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.resolveAgentMemoryContext) {
+ const rainActive = rainModeActive || runOptions.rainMode === true;
+ if (
+ routingDecision === CHAT_INTENT_ROUTE.AGENT &&
+ !rainActive &&
+ chatIntentRouter?.resolveAgentMemoryContext
+ ) {
const displayText = userMessage?.metadata?.displayText
?? userMessage?.content?.find?.((item) => item?.type === 'text')?.text
?? '';
@@ -1877,33 +1950,36 @@ export function createAgentRunGateway({
const grantedSkills = await resolveGrantedSkills(row.user_id);
let goalContext = null;
let runtimeContext = null;
+ const rainHandoff = userMessage?.metadata?.memindRun?.rainHandoff === true;
const orchestrationDisplayText = userMessage?.metadata?.displayText
?? userMessage?.content?.find?.((item) => item?.type === 'text')?.text
?? '';
- try {
- runtimeContext = await resolveRuntimeContext({
- pool,
- getUmsPool,
- userId: row.user_id,
- query: orchestrationDisplayText,
- sessionId: row.agent_session_id ?? null,
- });
- if (runtimeContext?.injectionEnabled) {
- await appendEvent(runId, 'runtime_context_resolved', {
- query_type: runtimeContext.plan?.query_type ?? null,
- temporal_mode: runtimeContext.plan?.temporal_mode ?? null,
- temporal_items: runtimeContext.temporalRecall?.stats?.returned_count ?? 0,
- has_snapshot: Boolean(runtimeContext.blocks?.snapshot),
- injection_chars: runtimeContext.injectionText?.length ?? 0,
+ if (!rainActive && !rainHandoff) {
+ try {
+ runtimeContext = await resolveRuntimeContext({
+ pool,
+ getUmsPool,
+ userId: row.user_id,
+ query: orchestrationDisplayText,
+ sessionId: row.agent_session_id ?? null,
});
+ if (runtimeContext?.injectionEnabled) {
+ await appendEvent(runId, 'runtime_context_resolved', {
+ query_type: runtimeContext.plan?.query_type ?? null,
+ temporal_mode: runtimeContext.plan?.temporal_mode ?? null,
+ temporal_items: runtimeContext.temporalRecall?.stats?.returned_count ?? 0,
+ has_snapshot: Boolean(runtimeContext.blocks?.snapshot),
+ injection_chars: runtimeContext.injectionText?.length ?? 0,
+ });
+ }
+ } catch (err) {
+ console.warn(
+ '[AgentRun] runtime context resolve skipped:',
+ err instanceof Error ? err.message : err,
+ );
}
- } catch (err) {
- console.warn(
- '[AgentRun] runtime context resolve skipped:',
- err instanceof Error ? err.message : err,
- );
}
- if (goalRunService && row.goal_run_id) {
+ if (!rainActive && !rainHandoff && goalRunService && row.goal_run_id) {
try {
const goal = await goalRunService.getGoalRun({
userId: row.user_id,
@@ -1920,15 +1996,18 @@ export function createAgentRunGateway({
);
}
}
- userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, {
- grantedSkills,
- memoryContext: agentMemoryContext,
- goalContext,
- runtimeContext,
- });
+ if (!rainHandoff) {
+ userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, {
+ grantedSkills,
+ memoryContext: agentMemoryContext,
+ goalContext,
+ runtimeContext,
+ });
+ }
}
}
const preferDirectChat =
+ !rainActive &&
!cursorFirstAgent &&
(routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT ||
(isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning));
diff --git a/chat-skills.mjs b/chat-skills.mjs
index 38f261b..faaa7cb 100644
--- a/chat-skills.mjs
+++ b/chat-skills.mjs
@@ -17,9 +17,34 @@ export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst';
export const SCHEDULED_TASK_AUTOMATION_SKILL_NAME = 'scheduled-task-automation';
export const AIDER_DEVELOPMENT_SKILL_NAME = 'aider-development';
+export const RAIN_SKILL_NAME = 'rain';
export const SKILL_ROUTER_V2_ENV = 'TKMIND_SKILL_ROUTER_V2';
const PAGE_TEMPLATE_SKILL_PREFIX = 'page-template-';
+export function isRainModeMessage(message) {
+ const selected =
+ message?.metadata?.memindRun?.selectedChatSkill ??
+ message?.metadata?.selectedChatSkill;
+ return String(selected ?? '').trim() === RAIN_SKILL_NAME;
+}
+
+export function isPageTemplateSkillDefinition(def) {
+ const id = String(def?.id ?? '');
+ const skillName = String(def?.skillName ?? '');
+ return id.startsWith(PAGE_TEMPLATE_SKILL_PREFIX) || skillName.startsWith(PAGE_TEMPLATE_SKILL_PREFIX);
+}
+
+function hidePageTemplateSkillsEnabled(env) {
+ const runtimeEnv =
+ env ??
+ (typeof process !== 'undefined' && process.env ? process.env : {});
+ const raw =
+ runtimeEnv.MEMIND_HIDE_PAGE_TEMPLATES ??
+ runtimeEnv.MEMIND_RAIN_REPLACE_TEMPLATES ??
+ '1';
+ return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase());
+}
+
function isPageTemplatePromptKey(promptKey) {
return String(promptKey ?? '').startsWith(PAGE_TEMPLATE_SKILL_PREFIX);
}
@@ -174,6 +199,14 @@ export function isProductCampaignIntent(text) {
}
export const CHAT_SKILL_DEFINITIONS = [
+ {
+ id: RAIN_SKILL_NAME,
+ label: 'Rain',
+ icon: 'analyze',
+ skillName: RAIN_SKILL_NAME,
+ prefillOnly: true,
+ promptKey: 'rain',
+ },
{
id: 'web-search',
label: '查资料',
@@ -515,6 +548,11 @@ export function buildChatSkillPrompt(promptKey, skillName) {
`请使用 ${skillName ?? 'image-generation'} 技能:把我的简短视觉需求自动扩展成完整 prompt 与独立 negative_prompt,实际调用 generate_image 生成并校验新位图。` +
'不要要求我提供 purpose、构图术语、负面词或 jobId;页面主图默认 purpose=hero,生成成功后 HTML 必须使用返回的 asset.htmlSrc,workspaceRelativePath 只用于文件操作。禁止 SVG、CSS 绘图、旧图和占位图冒充。我的需求是:'
);
+ case 'rain':
+ return (
+ '【Rain · MeInput 输入分析】请描述要分析的时间区间和你的诉求(例如「总结昨天我在做什么」)。' +
+ '未写时间则默认近3天;区间不明确时我会先追问。我的问题是:'
+ );
case 'summarize':
return '请总结以下内容,提炼核心结论、重点信息和可执行建议(条理清晰、中文输出):';
case 'analyze':
@@ -620,6 +658,9 @@ export { buildWebNewsSkillPrompt };
export function filterChatSkills(options, ctx) {
return options.filter((skill) => {
+ if (hidePageTemplateSkillsEnabled() && isPageTemplateSkillDefinition(skill)) {
+ return false;
+ }
if (skill.requiresPublish && !ctx.canPublish) {
const pageDataGranted =
skill.skillName === PAGE_DATA_COLLECT_SKILL_NAME &&
diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs
index 3ced536..7836f2e 100644
--- a/chat-skills.test.mjs
+++ b/chat-skills.test.mjs
@@ -117,14 +117,31 @@ test('filterChatSkills shows generate-page when publish is allowed', () => {
assert.ok(visible.some((item) => item.id === 'generate-page'));
});
-test('filterChatSkills shows page templates when granted', () => {
+test('filterChatSkills hides page templates by default and shows Rain', () => {
const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, {
canPublish: true,
- grantedSkills: ['page-template-travel', 'page-template-campaign'],
+ grantedSkills: ['page-template-travel', 'page-template-campaign', 'rain'],
});
- assert.ok(visible.some((item) => item.id === 'page-template-travel'));
- assert.ok(visible.some((item) => item.id === 'page-template-campaign'));
- assert.equal(visible.some((item) => item.id === 'page-template-survey'), false);
+ assert.ok(visible.some((item) => item.id === 'rain'));
+ assert.equal(visible.some((item) => item.id === 'page-template-travel'), false);
+ assert.equal(visible.some((item) => item.id === 'page-template-campaign'), false);
+});
+
+test('filterChatSkills shows page templates when explicitly enabled', () => {
+ const prev = process.env.MEMIND_HIDE_PAGE_TEMPLATES;
+ process.env.MEMIND_HIDE_PAGE_TEMPLATES = '0';
+ try {
+ const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, {
+ canPublish: true,
+ grantedSkills: ['page-template-travel', 'page-template-campaign'],
+ });
+ assert.ok(visible.some((item) => item.id === 'page-template-travel'));
+ assert.ok(visible.some((item) => item.id === 'page-template-campaign'));
+ assert.equal(visible.some((item) => item.id === 'page-template-survey'), false);
+ } finally {
+ if (prev == null) delete process.env.MEMIND_HIDE_PAGE_TEMPLATES;
+ else process.env.MEMIND_HIDE_PAGE_TEMPLATES = prev;
+ }
});
test('buildChatSkillPrompt includes skill name for platform skills', () => {
diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs
index 80ad17c..2b0d0f0 100644
--- a/direct-chat-service.mjs
+++ b/direct-chat-service.mjs
@@ -2,6 +2,7 @@ import { resolveRuntimeContext } from './temporal-recall-service/runtime-context
import { MEMORY_INTERVENTION_LIMIT } from './memory-intervention.mjs';
import { resolveMemoriesWithLegacyFallback } from './memory-legacy-fallback.mjs';
import { isMemoryRecallQuestion } from './chat-intent-router.mjs';
+import { isRainModeMessage } from './chat-skills.mjs';
import { resolveSessionAccess } from './session-broker.mjs';
export const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_';
@@ -383,24 +384,29 @@ export function createDirectChatService({
pendingMessages,
);
const recallQuestion = isMemoryRecallQuestion(messageText(userMessage));
+ const rainMode = isRainModeMessage(userMessage);
const routedMemoryContent =
- !recallQuestion &&
- routingMemory && !routingMemory.skipped && !routingMemory.degraded
- ? String(routingMemory.content ?? '').trim()
- : '';
- const memories = routedMemoryContent
+ rainMode
+ ? ''
+ : !recallQuestion &&
+ routingMemory && !routingMemory.skipped && !routingMemory.degraded
+ ? String(routingMemory.content ?? '').trim()
+ : '';
+ const memories = rainMode
? []
: await resolveMemories(userId, activeSessionId, messageText(userMessage));
- const runtimeContext = await resolveRuntimeContext({
- pool,
- getUmsPool,
- userId,
- query: messageText(userMessage),
- sessionId: activeSessionId,
- }).catch((err) => {
- logger?.warn?.(`[direct-chat] runtime context skipped: ${err instanceof Error ? err.message : err}`);
- return { injectionText: '' };
- });
+ const runtimeContext = rainMode
+ ? { injectionText: '' }
+ : await resolveRuntimeContext({
+ pool,
+ getUmsPool,
+ userId,
+ query: messageText(userMessage),
+ sessionId: activeSessionId,
+ }).catch((err) => {
+ logger?.warn?.(`[direct-chat] runtime context skipped: ${err instanceof Error ? err.message : err}`);
+ return { injectionText: '' };
+ });
const completion = await llmProviderService.createChatCompletion({
messages: buildModelMessages({
previousMessages,
diff --git a/rain-service.test.mjs b/rain-service.test.mjs
new file mode 100644
index 0000000..78c89e0
--- /dev/null
+++ b/rain-service.test.mjs
@@ -0,0 +1,99 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { resolveRainTimeRange, RAIN_DEFAULT_DAYS } from './rain-service/time-range.mjs';
+import { formatMeinputFullBlock } from './rain-service/meinput-full.mjs';
+import { buildRainGooseHandoffText, stripRainSkillPrefix } from './rain-service/llm-analysis.mjs';
+import {
+ RAIN_SKILL_NAME,
+ isRainModeMessage,
+ filterChatSkills,
+ CHAT_SKILL_DEFINITIONS,
+} from './chat-skills.mjs';
+
+test('resolveRainTimeRange defaults to 3 days when no time hint', () => {
+ const now = new Date('2026-09-04T12:00:00+08:00');
+ const result = resolveRainTimeRange('帮我总结最近的输入', now);
+ assert.equal(result.needsClarification, false);
+ assert.equal(result.source, 'default_3d');
+ assert.ok(result.range?.start);
+ assert.ok(result.range?.end);
+ const spanMs =
+ new Date(result.range.end).getTime() - new Date(result.range.start).getTime();
+ assert.ok(spanMs >= RAIN_DEFAULT_DAYS * 24 * 60 * 60 * 1000 - 60_000);
+});
+
+test('resolveRainTimeRange asks clarification for ambiguous phrases', () => {
+ const result = resolveRainTimeRange('前几天我在做什么', new Date('2026-09-04T12:00:00+08:00'));
+ assert.equal(result.needsClarification, true);
+ assert.match(result.clarificationQuestion ?? '', /起止时间/);
+});
+
+test('resolveRainTimeRange parses yesterday explicitly', () => {
+ const now = new Date('2026-09-04T12:00:00+08:00');
+ const result = resolveRainTimeRange('我昨天输入了什么', now);
+ assert.equal(result.needsClarification, false);
+ assert.equal(result.source, 'user_explicit');
+ assert.equal(result.label, 'yesterday');
+});
+
+test('formatMeinputFullBlock lists every record with timestamp', () => {
+ const block = formatMeinputFullBlock(
+ [
+ {
+ created_at: '2026-09-02T19:42:16.541Z',
+ text: '项目',
+ app_name: 'Cursor',
+ app_bundle_id: 'com.cursor',
+ },
+ {
+ created_at: '2026-09-02T19:46:15.181Z',
+ text: '手机',
+ app_name: null,
+ app_bundle_id: 'com.apple.mobile',
+ },
+ ],
+ {
+ range: { start: '2026-09-01T00:00:00.000Z', end: '2026-09-04T00:00:00.000Z' },
+ label: '近3天',
+ },
+ );
+ assert.match(block, /MeInput 原始输入 · Rain/);
+ assert.match(block, /项目/);
+ assert.match(block, /手机/);
+ assert.match(block, /2026-09-02 19:42:16/);
+ assert.doesNotMatch(block, /0\.2795/);
+});
+
+test('buildRainGooseHandoffText carries analysis to agent', () => {
+ const text = buildRainGooseHandoffText({
+ userQuery: '最近我输入了什么',
+ timeRangeLabel: '近3天',
+ recordCount: 12,
+ analysis: '用户在 Cursor 中输入了项目相关词',
+ userGoal: '回顾输入',
+ suggestedNextSteps: ['给用户时间线总结'],
+ });
+ assert.match(text, /Rain · MeInput 分析简报/);
+ assert.match(text, /用户在 Cursor 中输入了项目相关词/);
+ assert.match(text, /最近我输入了什么/);
+});
+
+test('isRainModeMessage detects selected rain skill', () => {
+ assert.equal(
+ isRainModeMessage({ metadata: { memindRun: { selectedChatSkill: RAIN_SKILL_NAME } } }),
+ true,
+ );
+ assert.equal(isRainModeMessage({ metadata: { memindRun: { selectedChatSkill: 'web' } } }), false);
+});
+
+test('filterChatSkills hides page templates by default', () => {
+ const options = CHAT_SKILL_DEFINITIONS.map((def) => ({ ...def, buildPrompt: () => '' }));
+ const filtered = filterChatSkills(options, { grantedSkills: ['page-template-travel'], canPublish: true });
+ assert.ok(filtered.some((item) => item.id === RAIN_SKILL_NAME));
+ assert.equal(filtered.some((item) => item.id === 'page-template-travel'), false);
+});
+
+test('stripRainSkillPrefix removes rain prompt header', () => {
+ const text = stripRainSkillPrefix('【Rain · MeInput 输入分析】请描述要分析的时间区间和你的诉求。未写时间则默认近3天;区间不明确时我会先追问。我的问题是:总结输入');
+ assert.equal(text, '总结输入');
+});
diff --git a/rain-service/index.mjs b/rain-service/index.mjs
new file mode 100644
index 0000000..129de1f
--- /dev/null
+++ b/rain-service/index.mjs
@@ -0,0 +1,90 @@
+import { RAIN_SKILL_NAME, isRainModeMessage } from '../chat-skills.mjs';
+import { loadRainMeinputRecords, formatMeinputFullBlock } from './meinput-full.mjs';
+import { runRainLlmAnalysis, buildRainGooseHandoffText, stripRainSkillPrefix } from './llm-analysis.mjs';
+import { resolveRainTimeRange, formatRainTimeRangeLabel } from './time-range.mjs';
+
+export { RAIN_SKILL_NAME, isRainModeMessage };
+export { resolveRainTimeRange, formatRainTimeRangeLabel, RAIN_DEFAULT_DAYS } from './time-range.mjs';
+export { formatMeinputFullBlock, loadRainMeinputRecords } from './meinput-full.mjs';
+export { runRainLlmAnalysis, buildRainGooseHandoffText, stripRainSkillPrefix } from './llm-analysis.mjs';
+
+/**
+ * @param {{
+ * llmProviderService: object,
+ * userId: string,
+ * userMessage: object,
+ * }} input
+ * @returns {Promise<
+ * | { phase: 'clarify', userReply: string, rainMeta: object }
+ * | { phase: 'goose', gooseHandoffText: string, userReply: string, rainMeta: object }
+ * >}
+ */
+export async function executeRainPipeline(input) {
+ const displayText =
+ input.userMessage?.metadata?.displayText ??
+ stripRainSkillPrefix(
+ Array.isArray(input.userMessage?.content)
+ ? input.userMessage.content
+ .filter((item) => item?.type === 'text')
+ .map((item) => item.text ?? '')
+ .join('\n')
+ : String(input.userMessage?.content ?? ''),
+ );
+
+ const timeResolution = resolveRainTimeRange(displayText);
+ if (timeResolution.needsClarification) {
+ return {
+ phase: 'clarify',
+ userReply: timeResolution.clarificationQuestion,
+ rainMeta: { timeResolution, recordCount: 0 },
+ };
+ }
+
+ const timeRangeLabel = formatRainTimeRangeLabel(timeResolution.range, timeResolution);
+ const { records } = await loadRainMeinputRecords({
+ userId: input.userId,
+ range: timeResolution.range,
+ });
+ const meinputBlock = formatMeinputFullBlock(records, {
+ range: timeResolution.range,
+ label: timeResolution.label,
+ source: timeResolution.source,
+ });
+
+ const analysis = await runRainLlmAnalysis({
+ llmProviderService: input.llmProviderService,
+ userQuery: displayText,
+ meinputBlock,
+ timeRangeLabel,
+ recordCount: records.length,
+ });
+
+ if (analysis.needs_clarification && analysis.clarification_question) {
+ return {
+ phase: 'clarify',
+ userReply: analysis.clarification_question,
+ rainMeta: { timeResolution, recordCount: records.length, analysis },
+ };
+ }
+
+ const gooseHandoffText = buildRainGooseHandoffText({
+ userQuery: displayText,
+ timeRangeLabel,
+ recordCount: records.length,
+ analysis: analysis.meinput_analysis,
+ userGoal: analysis.user_goal,
+ suggestedNextSteps: analysis.suggested_next_steps,
+ });
+
+ return {
+ phase: 'goose',
+ gooseHandoffText,
+ userReply: analysis.user_reply,
+ rainMeta: {
+ timeResolution,
+ recordCount: records.length,
+ analysis,
+ meinputBlockChars: meinputBlock.length,
+ },
+ };
+}
diff --git a/rain-service/llm-analysis.mjs b/rain-service/llm-analysis.mjs
new file mode 100644
index 0000000..1557b49
--- /dev/null
+++ b/rain-service/llm-analysis.mjs
@@ -0,0 +1,118 @@
+function stripRainSkillPrefix(text) {
+ let next = String(text ?? '').trim();
+ next = next.replace(/^【Rain[^】]*】\s*/u, '');
+ next = next.replace(/^请使用\s+rain\s+技能[::]\s*/iu, '');
+ if (/^请描述要分析的时间区间/u.test(next)) {
+ const marker = '我的问题是:';
+ const idx = next.indexOf(marker);
+ if (idx >= 0) next = next.slice(idx + marker.length);
+ }
+ return next.trim();
+}
+
+function parseRainLlmJson(raw) {
+ const text = String(raw ?? '').trim();
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
+ const candidate = fenced?.[1]?.trim() || text;
+ try {
+ return JSON.parse(candidate);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * @param {{ llmProviderService: object, userQuery: string, meinputBlock: string, timeRangeLabel: string, recordCount: number }} input
+ */
+export async function runRainLlmAnalysis(input) {
+ const userQuery = stripRainSkillPrefix(input.userQuery);
+ const system = [
+ '你是 TKMind Rain 分析层。只能依据【MeInput 原始输入】块中的内容做归纳,禁止引用或编造长期记忆、聊天历史、日程等外部信息。',
+ '输出必须是单个 JSON 对象,不要 markdown,不要代码围栏,字段如下:',
+ '{"needs_clarification":boolean,"clarification_question":string|null,"user_goal":string,"meinput_analysis":string,"suggested_next_steps":string[],"user_reply":string}',
+ '- needs_clarification=true 时:clarification_question 必填,user_reply 用自然语言向用户追问;meinput_analysis 可为空。',
+ '- needs_clarification=false 时:meinput_analysis 按时间线归纳用户在各 App 的输入活动;user_reply 是可直接展示给用户的中文回复(含区间说明);suggested_next_steps 供下游 Agent 参考(如生成报告页、继续追问)。',
+ '- 不要把内部排序分数、source 字段名暴露给用户。',
+ ].join('\n');
+
+ const user = [
+ `时间区间:${input.timeRangeLabel}`,
+ `记录条数:${input.recordCount}`,
+ '',
+ input.meinputBlock,
+ '',
+ `用户诉求:${userQuery || '请总结我在上述区间的输入活动'}`,
+ ].join('\n');
+
+ const completion = await input.llmProviderService.createChatCompletion({
+ messages: [
+ { role: 'system', content: system },
+ { role: 'user', content: user },
+ ],
+ });
+
+ if (!completion?.ok) {
+ const err = new Error(completion?.message ?? 'Rain LLM 分析失败');
+ err.code = 'RAIN_LLM_FAILED';
+ throw err;
+ }
+
+ const parsed = parseRainLlmJson(completion.reply);
+ if (!parsed || typeof parsed !== 'object') {
+ return {
+ needs_clarification: false,
+ clarification_question: null,
+ user_goal: userQuery || '回顾 MeInput 输入',
+ meinput_analysis: String(completion.reply ?? '').trim(),
+ suggested_next_steps: [],
+ user_reply: String(completion.reply ?? '').trim(),
+ raw: completion.reply,
+ };
+ }
+
+ return {
+ needs_clarification: Boolean(parsed.needs_clarification),
+ clarification_question: parsed.clarification_question ?? null,
+ user_goal: String(parsed.user_goal ?? userQuery ?? '').trim(),
+ meinput_analysis: String(parsed.meinput_analysis ?? '').trim(),
+ suggested_next_steps: Array.isArray(parsed.suggested_next_steps)
+ ? parsed.suggested_next_steps.map((s) => String(s).trim()).filter(Boolean)
+ : [],
+ user_reply: String(parsed.user_reply ?? '').trim(),
+ raw: completion.reply,
+ };
+}
+
+export function buildRainGooseHandoffText({
+ userQuery,
+ timeRangeLabel,
+ recordCount,
+ analysis,
+ userGoal,
+ suggestedNextSteps = [],
+}) {
+ const steps =
+ suggestedNextSteps.length > 0
+ ? suggestedNextSteps.map((s) => `- ${s}`).join('\n')
+ : '- (无明确工具动作,先给用户文字总结)';
+
+ return [
+ '[Rain · MeInput 分析简报]',
+ '以下简报由 Rain 分析层基于 MeInput 全量原始输入生成。请据此决定如何回复用户、是否调用工具或 skill;不要重复询问时间区间。',
+ '',
+ `时间区间:${timeRangeLabel}`,
+ `原始记录条数:${recordCount}`,
+ `用户诉求:${userGoal || stripRainSkillPrefix(userQuery)}`,
+ '',
+ '【分析归纳】',
+ analysis || '(无)',
+ '',
+ '【建议下一步】',
+ steps,
+ '',
+ '【用户原始问题】',
+ stripRainSkillPrefix(userQuery),
+ ].join('\n');
+}
+
+export { stripRainSkillPrefix };
diff --git a/rain-service/meinput-full.mjs b/rain-service/meinput-full.mjs
new file mode 100644
index 0000000..73938e5
--- /dev/null
+++ b/rain-service/meinput-full.mjs
@@ -0,0 +1,46 @@
+import { fetchMeinputRangeFull } from '../temporal-recall-service/adapters/meinput.mjs';
+import { resolveCanonicalUserId } from '../user-model-service/canonical-user.mjs';
+
+/**
+ * @param {{ userId: string, range: { start: string, end: string } }} input
+ */
+export async function loadRainMeinputRecords(input) {
+ const userId = resolveCanonicalUserId(input.userId);
+ const records = await fetchMeinputRangeFull({
+ userId,
+ range: input.range,
+ });
+ return { userId, records };
+}
+
+/**
+ * @param {Array<{ event_id?: string, text?: string, app_name?: string | null, app_bundle_id?: string | null, created_at?: string | Date }>} records
+ * @param {{ range?: { start: string, end: string }, label?: string, source?: string }} meta
+ */
+export function formatMeinputFullBlock(records, meta = {}) {
+ const lines = [
+ '【MeInput 原始输入 · Rain】',
+ `时间区间:${meta.label ?? ''} ${meta.range?.start?.slice(0, 16)?.replace('T', ' ') ?? ''} ~ ${meta.range?.end?.slice(0, 16)?.replace('T', ' ') ?? ''}`.trim(),
+ `记录数:${records.length}`,
+ '以下为按时间升序的原始按键/输入片段(含时间戳与应用信息),供分析使用;不要向用户暴露 recall 分数或内部字段名。',
+ '',
+ ];
+
+ if (!records.length) {
+ lines.push('(该时间区间内无 MeInput 记录)');
+ return lines.join('\n').trim();
+ }
+
+ for (const row of records) {
+ const ts =
+ row.created_at instanceof Date
+ ? row.created_at.toISOString()
+ : String(row.created_at ?? '').trim();
+ const local = ts ? ts.slice(0, 19).replace('T', ' ') : '';
+ const app = row.app_name || row.app_bundle_id || 'unknown-app';
+ const text = String(row.text ?? '').replace(/\s+/g, ' ').trim();
+ lines.push(`- ${local} | app=${app} | ${text}`);
+ }
+
+ return lines.join('\n').trim();
+}
diff --git a/rain-service/time-range.mjs b/rain-service/time-range.mjs
new file mode 100644
index 0000000..4a5a532
--- /dev/null
+++ b/rain-service/time-range.mjs
@@ -0,0 +1,105 @@
+import { parseTimeScope } from '../temporal-recall-service/time-parser.mjs';
+
+export const RAIN_DEFAULT_DAYS = 3;
+
+const TZ_OFFSET_MIN = Number(process.env.TEMPORAL_RECALL_TZ_OFFSET_MIN ?? 480);
+
+/** @param {Date} anchor @param {number} deltaDays */
+function addDays(anchor, deltaDays) {
+ const shifted = new Date(anchor.getTime() + TZ_OFFSET_MIN * 60_000);
+ const ms =
+ Date.UTC(
+ shifted.getUTCFullYear(),
+ shifted.getUTCMonth(),
+ shifted.getUTCDate() + deltaDays,
+ 0,
+ 0,
+ 0,
+ 0,
+ ) - TZ_OFFSET_MIN * 60_000;
+ return new Date(ms);
+}
+
+/** @param {Date} anchor */
+function endOfDay(anchor) {
+ const p = new Date(anchor.getTime() + TZ_OFFSET_MIN * 60_000);
+ const ms =
+ Date.UTC(p.getUTCFullYear(), p.getUTCMonth(), p.getUTCDate() + 1, 0, 0, 0, 0) -
+ TZ_OFFSET_MIN * 60_000;
+ return new Date(ms);
+}
+
+const AMBIGUOUS_TIME_PATTERNS = [
+ /前几天/u,
+ /那段(?:时间|日子)/u,
+ /上次(?:那)?(?:段|个)/u,
+ /大概.{0,6}(?:昨天|前天|上周|几)/u,
+ /左右/u,
+ /不太确定.{0,8}时间/u,
+];
+
+const EXPLICIT_TIME_HINT =
+ /昨天|昨日|今天|今日|明天|明日|前天|后天|上周|这周|本周|这个月|本月|上个月|\d{1,2}\s*月|\d{1,2}\s*[::]\d{2}|最近\s*\d+\s*天|最近一周|最近1周|\d{4}-\d{2}-\d{2}/u;
+
+/**
+ * @param {string} query
+ * @param {Date} [now]
+ */
+export function resolveRainTimeRange(query, now = new Date()) {
+ const text = String(query ?? '').trim();
+
+ for (const pattern of AMBIGUOUS_TIME_PATTERNS) {
+ if (pattern.test(text)) {
+ return {
+ needsClarification: true,
+ clarificationQuestion:
+ '请说明要分析 MeInput 输入记录的起止时间(例如「9月2日 18:00 到 9月4日 10:00」,或「昨天全天」)。若你不补充,我将默认使用近 3 天。',
+ range: null,
+ source: 'ambiguous',
+ label: null,
+ };
+ }
+ }
+
+ if (!EXPLICIT_TIME_HINT.test(text)) {
+ const end = endOfDay(now);
+ const start = addDays(now, -RAIN_DEFAULT_DAYS);
+ return {
+ needsClarification: false,
+ range: { start: start.toISOString(), end: end.toISOString() },
+ source: 'default_3d',
+ label: `近${RAIN_DEFAULT_DAYS}天`,
+ };
+ }
+
+ const scope = parseTimeScope(text, now);
+ const isDefaultFallback =
+ scope.rule_hits?.includes('time:default_week') ||
+ scope.rule_hits?.includes('time:recent_fuzzy');
+
+ if (isDefaultFallback && /最近|近期|这几天/u.test(text) && !/最近\s*\d+\s*天/u.test(text)) {
+ const end = endOfDay(now);
+ const start = addDays(now, -RAIN_DEFAULT_DAYS);
+ return {
+ needsClarification: false,
+ range: { start: start.toISOString(), end: end.toISOString() },
+ source: 'default_3d',
+ label: `近${RAIN_DEFAULT_DAYS}天`,
+ };
+ }
+
+ return {
+ needsClarification: false,
+ range: scope.mention_range,
+ source: 'user_explicit',
+ label: scope.relative_label,
+ };
+}
+
+export function formatRainTimeRangeLabel(range, meta = {}) {
+ if (!range?.start || !range?.end) return meta.label ?? '未指定';
+ const start = range.start.slice(0, 16).replace('T', ' ');
+ const end = range.end.slice(0, 16).replace('T', ' ');
+ const suffix = meta.source === 'default_3d' ? '(默认近3天)' : '';
+ return `${start} ~ ${end}${suffix}`;
+}
diff --git a/scripts/agent-run-worker.mjs b/scripts/agent-run-worker.mjs
index cd99bb0..bd361ca 100644
--- a/scripts/agent-run-worker.mjs
+++ b/scripts/agent-run-worker.mjs
@@ -205,6 +205,7 @@ async function bootstrapWorker() {
tkmindProxy,
toolGateway,
directChatService,
+ llmProviderService,
sessionSnapshotService,
conversationMemoryService,
chatIntentRouter,
diff --git a/scripts/deploy-tang-meinput-pages-103.mjs b/scripts/deploy-tang-meinput-pages-103.mjs
new file mode 100644
index 0000000..e751184
--- /dev/null
+++ b/scripts/deploy-tang-meinput-pages-103.mjs
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+/**
+ * Deploy MeInput tutorial + case pages to 103 production under 唐 user.
+ * Usage: node scripts/deploy-tang-meinput-pages-103.mjs [--send-wechat]
+ */
+import { execSync } from 'node:child_process';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const HOST = 'john@58.38.22.103';
+const REMOTE_ROOT = '/Users/john/Project/Memind';
+const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
+const JOHN_LOCAL = '1c99b83b-0454-474f-a5d2-129d34506a32';
+const FILES = [
+ 'meinput-tkmind-portrait-tutorial.html',
+ 'meinput-tkmind-portrait-tutorial-wechat.html',
+ 'behavior-pattern-analysis.html',
+];
+const PUBLIC_BASE = `https://m.tkmind.cn/MindSpace/${TANG}/public`;
+
+const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
+const sendWechat = process.argv.includes('--send-wechat');
+
+function sh(cmd) {
+ execSync(cmd, { stdio: 'inherit' });
+}
+
+const remoteScript = `
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import mysql from 'mysql2/promise';
+
+const TANG = '${TANG}';
+const REMOTE_ROOT = '${REMOTE_ROOT}';
+const FILES = ${JSON.stringify(FILES)};
+const REQUEST_ID = 'deploy-meinput-tutorial-20260904';
+const PUBLIC_BASE = '${PUBLIC_BASE}';
+
+process.loadEnvFile(path.join(REMOTE_ROOT, '.env'));
+const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
+
+async function ensureReady(relativePath) {
+ const now = Date.now();
+ const id = crypto.randomUUID();
+ await pool.query(
+ \`INSERT INTO h5_page_delivery_contracts
+ (id, user_id, request_id, workspace_relative_path, data_mode, status, ready_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'static', 'ready', ?, ?, ?)
+ ON DUPLICATE KEY UPDATE status = 'ready', ready_at = VALUES(ready_at), failure_reason = NULL, updated_at = VALUES(updated_at)\`,
+ [id, TANG, REQUEST_ID, relativePath, now, now, now],
+ );
+}
+
+async function main() {
+ const results = [];
+ for (const name of FILES) {
+ const relativePath = 'public/' + name;
+ const abs = path.join(REMOTE_ROOT, 'MindSpace', TANG, relativePath);
+ const exists = fs.existsSync(abs);
+ const size = exists ? fs.statSync(abs).size : 0;
+ if (exists) await ensureReady(relativePath);
+ results.push({ file: name, exists, size, url: PUBLIC_BASE + '/' + name });
+ }
+ console.log(JSON.stringify({ ok: true, pages: results }, null, 2));
+ await pool.end();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
+`.trim();
+
+async function sendWechatLinks() {
+ const remoteWechat = `
+import path from 'node:path';
+import mysql from 'mysql2/promise';
+
+const TANG = '${TANG}';
+const PUBLIC_BASE = '${PUBLIC_BASE}';
+const FILES = ${JSON.stringify(FILES)};
+
+process.loadEnvFile('${REMOTE_ROOT}/.env');
+const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
+const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
+const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET;
+if (!appId || !appSecret) throw new Error('missing wechat credentials');
+
+const [ident] = await pool.query(
+ 'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
+ [TANG, appId],
+);
+const openid = ident?.[0]?.openid;
+if (!openid) throw new Error('tang not bound to wechat');
+
+const tokenRes = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }),
+});
+const tokenPayload = await tokenRes.json();
+if (!tokenPayload.access_token) throw new Error(JSON.stringify(tokenPayload));
+
+const lines = [
+ 'MeInput × TKMind 教程与案例已发布:',
+ '',
+ '📱 教程(公众号发布版)',
+ PUBLIC_BASE + '/meinput-tkmind-portrait-tutorial-wechat.html',
+ '',
+ '📖 教程(网页阅读版)',
+ PUBLIC_BASE + '/meinput-tkmind-portrait-tutorial.html',
+ '',
+ '🧭 案例:用户A 全景画像',
+ PUBLIC_BASE + '/behavior-pattern-analysis.html',
+];
+const text = lines.join('\\n').slice(0, 2048);
+
+const sendRes = await fetch('https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=' + encodeURIComponent(tokenPayload.access_token), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }),
+});
+const sendPayload = await sendRes.json();
+if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload));
+console.log(JSON.stringify({ ok: true, sent: true, openid: openid.slice(0, 8) + '...' }));
+await pool.end();
+`.trim();
+
+ const tmp = `${REMOTE_ROOT}/.tmp-tang-meinput-wechat.mjs`;
+ fs.writeFileSync(path.join(root, '.tmp-tang-wechat.mjs'), remoteWechat);
+ sh(`scp -q ${path.join(root, '.tmp-tang-wechat.mjs')} ${HOST}:${tmp}`);
+ sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${tmp} && rm -f ${tmp}'`);
+ fs.unlinkSync(path.join(root, '.tmp-tang-wechat.mjs'));
+}
+
+async function main() {
+ const localDir = path.join(root, 'MindSpace', JOHN_LOCAL, 'public');
+ for (const f of FILES) {
+ const src = path.join(localDir, f);
+ if (!fs.existsSync(src)) throw new Error(`missing local file: ${src}`);
+ }
+
+ const remotePublic = `${REMOTE_ROOT}/MindSpace/${TANG}/public`;
+ sh(`ssh -o BatchMode=yes ${HOST} 'mkdir -p ${remotePublic}'`);
+ for (const f of FILES) {
+ sh(`scp -q ${path.join(localDir, f)} ${HOST}:${remotePublic}/${f}`);
+ }
+
+ const tmpLocal = path.join(root, '.tmp-tang-deploy-103.mjs');
+ fs.writeFileSync(tmpLocal, remoteScript);
+ const tmpRemote = `${REMOTE_ROOT}/.tmp-tang-meinput-deploy.mjs`;
+ sh(`scp -q ${tmpLocal} ${HOST}:${tmpRemote}`);
+ sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${tmpRemote} && rm -f ${tmpRemote}'`);
+ fs.unlinkSync(tmpLocal);
+
+ console.log('\n=== Production URLs ===');
+ for (const f of FILES) console.log(`${PUBLIC_BASE}/${f}`);
+
+ for (const f of FILES) {
+ const code = execSync(
+ `curl -sS -o /dev/null -w '%{http_code}' 'https://m.tkmind.cn/MindSpace/${TANG}/public/${f}'`,
+ { encoding: 'utf8' },
+ ).trim();
+ console.log(`${f}: HTTP ${code}`);
+ }
+
+ if (sendWechat) {
+ await sendWechatLinks();
+ }
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/generate-tang-behavior-analysis-page.mjs b/scripts/generate-tang-behavior-analysis-page.mjs
new file mode 100644
index 0000000..ece1b39
--- /dev/null
+++ b/scripts/generate-tang-behavior-analysis-page.mjs
@@ -0,0 +1,332 @@
+#!/usr/bin/env node
+/**
+ * 用户A · MeInput 全景用户画像与行为节律报告(非工程日志)
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import mysql from 'mysql2/promise';
+import { fileURLToPath } from 'node:url';
+import { markPageDeliveryContractReady } from '../mindspace-delivery-contract.mjs';
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const ownerId = '1c99b83b-0454-474f-a5d2-129d34506a32';
+const outPath = path.join(root, 'MindSpace', ownerId, 'public', 'behavior-pattern-analysis.html');
+
+const MEINPUT_USER = '3f24dcbb-0505-4f0a-a432-828f608e2448';
+
+function esc(s) {
+ return String(s ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function fmtCst(iso) {
+ return new Date(iso).toLocaleString('zh-CN', {
+ timeZone: 'Asia/Shanghai',
+ month: 'numeric',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ });
+}
+
+function segmentRows(rows, gapMs = 2000) {
+ const out = [];
+ let cur = null;
+ for (const r of rows) {
+ const ms = new Date(r.created_at).getTime();
+ if (!cur || ms - cur.last > gapMs) {
+ if (cur) out.push(cur);
+ cur = { start: r.created_at, end: r.created_at, last: ms, parts: [], app: r.app_name || r.app_bundle_id };
+ }
+ cur.parts.push(r.text);
+ cur.end = r.created_at;
+ cur.last = ms;
+ }
+ if (cur) out.push(cur);
+ return out.map((s) => ({ start: s.start, end: s.end, text: s.parts.join(''), app: s.app }));
+}
+
+function appLabel(app) {
+ const s = String(app || '');
+ if (s.includes('todesktop') || s.includes('Cursor')) return '深度开发';
+ if (s.includes('WeWork') || s.includes('WeChat')) return '协作沟通';
+ return '其他场景';
+}
+
+async function main() {
+ process.loadEnvFile?.(path.join(root, '.env'));
+ const mePool = mysql.createPool({ uri: process.env.MEINPUT_DATABASE_URL, connectionLimit: 2 });
+ const [rows] = await mePool.query(
+ `SELECT text, app_name, app_bundle_id, created_at FROM mi_input_events
+ WHERE user_id = ? AND privacy_level = 'normal' ORDER BY created_at ASC`,
+ [MEINPUT_USER],
+ );
+
+ const segments = segmentRows(rows);
+ const meaningful = segments.filter((s) => s.text.replace(/\s/g, '').length >= 3);
+
+ const buckets15 = {};
+ for (const r of rows) {
+ const cst = new Date(r.created_at.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
+ const h = cst.getHours();
+ const m = Math.floor(cst.getMinutes() / 15) * 15;
+ const key = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
+ buckets15[key] = (buckets15[key] || 0) + 1;
+ }
+ const peak = Object.entries(buckets15).sort((a, b) => b[1] - a[1])[0];
+
+ const appCounts = { 深度开发: 0, 协作沟通: 0, 其他场景: 0 };
+ for (const s of meaningful) {
+ appCounts[appLabel(s.app)] = (appCounts[appLabel(s.app)] || 0) + 1;
+ }
+ const appTotal = meaningful.length || 1;
+ const devPct = Math.round((appCounts['深度开发'] / appTotal) * 100);
+ const collabPct = Math.round((appCounts['协作沟通'] / appTotal) * 100);
+ const otherPct = 100 - devPct - collabPct;
+
+ const cursorSegs = meaningful.filter((s) => appLabel(s.app) === '深度开发');
+ const wecomSegs = meaningful.filter((s) => appLabel(s.app) === '协作沟通');
+
+ const html = `
+
+
+
+
+ 用户A · 全景用户画像与行为节律
+
+
+
+
+
+
+
+
+ ← 返回教程文章
+
+
+ 产品负责人亲自验收 · 追问到底
+ AI 原生取向智能体验优先
+ 移动端优先真机 / 安装包验收
+ 夜间深专注05:15–06:15 峰值
+ 确认型决策要眼见为实
+ 双通道协作开发 + IM 并行
+
+
+ 一、角色定位(这个人是谁)
+
+
用户A是典型的产品型创始人 / 负责人:不只定方向,还亲自安装包、测登录、对后台数据核对到「有没有进库」。沟通短、指令清晰,遇到阻塞会直接追问「卡在哪了?」。
+
工作形态呈现「Owner + 验收者」双重角色:自己上手试一遍,同时指挥同事去后台核对——既不脱离细节,也不单打独斗。
+
输入内容高度聚焦产品能否上线、链路是否打通,极少闲聊或无关话题,说明当前处于交付攻坚期而非探索期。
+
+
+ 二、近期重要事项(按优先级)
+
+
1登录与后台数据闭环 · 未闭环
反复出现:在哪登录、注册入口缺失、登录后能否传到生产后台——当前最高优先级阻塞。
+
2移动端安装包与真机验证 · 进行中
多次索要安装路径、重装、确认特殊设备可用——移动侧是验收主战场。
+
3开发与生产环境数据一致 · 痛点明显
希望环境统一,避免「本地有、线上没有」导致判断失真与信任损耗。
+
4输入体验:从逐字到整句 · 已识别
主动提出「如何判断一句完整的话」——意识到原始按键流对 AI 分析不友好,属于体验债。
+
5界面设计与 AI 产品方向 · 方向已定
明确要做 AI 原生界面,后续与智能平台对接——战略清晰,待执行。
+
6团队协同与进度对齐 · 常规进行
在协作 IM 中询问同事休假、机器环境——重要事项包含人的可用性,不单是代码。
+
+
+ 三、偏好与价值取向
+
+
+
+ - 产品审美:AI 原生,非传统工具堆砌
+ - 架构取舍:账号体系先独立,边界清晰,避免过早耦合
+ - 质量观:先证明链路通,再谈功能丰富
+
+
+ - 决策风格:确认型——要「确定进了库」才往下走
+ - 表达习惯:短句、口语、效率优先
+ - 信任机制:亲眼所见 > 口头承诺
+
+
+
「一定要往 AI 方面设计,后续对接智能平台」—— 产品战略与审美方向的明确表态。
+
「暂时不要打通主产品用户体系」—— 倾向独立演进、可控边界。
+
+
+ 四、决策与沟通模式
+
+
+ - 决策链路:提出假设 → 亲自或委托验证 → 看到数据/界面 → 才进入下一步。极少「先发布再验证」。
+ - 沟通风格:指令式短句为主(「试一下」「你去后台看」「路径发给我」),信息密度高,省略客套。
+ - 追问模式:同一主题多次出现(登录、后台、安装包),说明未获满意答案前不会切换话题。
+ - 协作方式:深度工作在开发工具内完成,协调工作在 IM 内完成——双通道不混用,但围绕同一项目目标。
+
+
+
+ 五、忙闲节律(何时最高效)
+
+
峰值时段:${esc(peak?.[0] ?? '05:30')} 前后(约 15 分钟内 ${peak?.[1] ?? 79} 次输入),整体为约 1 小时的凌晨攻坚,形态是「阻塞清零」而非均匀分布。
+
+ ${Object.entries(buckets15)
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([t, n]) => `${t} · ${n} 次`)
+ .join('')}
+
+
更早时段有一次较轻的验证性输入,强度远低于凌晨段——忙闲分明:白天/傍晚零散试,深夜集中解决问题。
+
工作节类型:冲刺型 — 遇到阻塞会集中一段长时间清零,而非碎片化推进。
+
+
+ 六、工具与场景分布
+
+
有效语义片段共 ${meaningful.length} 段,场景分布如下:
+
+
+
+
深度开发侧约 ${cursorSegs.length} 段(技术推进、验收指令);协作侧约 ${wecomSegs.length} 段(进度对齐、资源协调)。
+
+
+ 七、行为模式标签
+
+
+ - 阻塞驱动:输入高峰跟在「看不到 / 登不上 / 传不到」之后——忙是因为在清障。
+ - 验收先于发布:反复安装、重装、对库——先证明链路通。
+ - 愿景与落地同屏:谈 AI 战略的同时谈安装包路径与环境一致。
+ - 不孤立作战:自己试 + 指挥他人验证,团队是延伸感官。
+ - 厌恶模糊态:「有没有进库」「能不能登录」必须得到是/否。
+ - 体验敏感:能指出「逐字输入」对产品化的影响——具备元认知。
+
+
+
+ 八、风险与阻塞点
+
+
+ - 账号链路未闭环:登录入口、注册流程、后台关联任一环节断裂,都会卡住全部验收。
+ - 环境不一致:本地与生产数据不同步,导致「试了白试」的信任危机。
+ - 输入粒度太细:逐字上报增加分析噪声,拖慢 AI 回忆与画像质量。
+ - 深夜攻坚可持续性问题:高峰在凌晨,长期可能带来疲劳与决策质量波动(需关注,非批评)。
+ - 协调依赖:部分验证需他人配合(后台查看、环境确认),存在外部等待风险。
+
+
+
+ 九、个性化效率建议
+
+
① 登录链路一键诊断提供「从安装 → 注册/登录 → 后台可见」的检查清单,每步给出是/否,减少反复追问。
+
② 凌晨高峰前预置上下文在活跃时段开始前,自动汇总「昨日未闭环事项 + 今日待验收项」,进入即可攻坚。
+
③ 跨工具线程视图将开发工具内的技术指令与 IM 里的协调消息合并为同一项目时间线,免手动拼图。
+
④ 句子级输入聚合优先落地「停顿切分 + 整句展示」,提升后续 AI 分析与回顾体验——用户已主动提出此需求。
+
⑤ 环境一致性看板用单一视图对比「本地 / 预发 / 生产」关键数据是否一致,回答「到底有没有进库」。
+
+
+ 十、一句话总结
+
+
用户A是一位深夜高效、结果导向的 AI 产品负责人:当前最重要的事是移动端登录可靠、后台数据可见、环境一致;偏好独立产品 + AI 体验;最高效在凌晨深专注段;做任何决定前都要亲眼确认;适合用清单化验收 + 跨工具线程来提升效率。
+
+
+ 附:推断依据(代表性原话 · 已脱敏)
+
+
+ ${meaningful
+ .filter((s) => s.text.length > 8)
+ .slice(0, 14)
+ .map(
+ (s) =>
+ `- ${esc(fmtCst(s.start))} · ${esc(s.text.slice(0, 80))}${s.text.length > 80 ? '…' : ''}
`,
+ )
+ .join('')}
+
+
以上为语义归纳附录,完整报告主体见上文十维画像。
+
+
+
+
+
+`;
+
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
+ fs.writeFileSync(outPath, html, 'utf8');
+
+ const memindPool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
+ await markPageDeliveryContractReady({
+ pool: memindPool,
+ userId: ownerId,
+ relativePath: 'public/behavior-pattern-analysis.html',
+ }).catch(() => {});
+
+ console.log('Updated portrait page:', outPath);
+ await mePool.end();
+ await memindPool.end();
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/verify-meinput-recall-chat.mjs b/scripts/verify-meinput-recall-chat.mjs
new file mode 100644
index 0000000..ce832cf
--- /dev/null
+++ b/scripts/verify-meinput-recall-chat.mjs
@@ -0,0 +1,168 @@
+#!/usr/bin/env node
+/**
+ * Verify meinput temporal recall → chat injection for a logged-in user.
+ *
+ * Usage:
+ * MEMIND_BASE_URL=http://127.0.0.1:8081 \
+ * MEMIND_USERNAME=john MEMIND_PASSWORD=981122tj \
+ * node scripts/verify-meinput-recall-chat.mjs
+ */
+import { loadH5Environment } from './load-env.mjs';
+import {
+ createAgentRun,
+ createReporter,
+ extractAssistantTexts,
+ getSession,
+ loginViaApi,
+ resolvePortalBase,
+ waitForAssistantGrowth,
+ waitForRunTerminal,
+} from './scenario-test-lib.mjs';
+
+loadH5Environment(import.meta.dirname);
+
+const QUERY = process.env.MEINPUT_VERIFY_QUERY ?? '最近我输入了什么?';
+const baseUrl = (process.env.MEMIND_BASE_URL ?? resolvePortalBase(Number(process.env.H5_PORT ?? 8081))).replace(/\/$/, '');
+const account = {
+ username: process.env.MEMIND_USERNAME ?? process.env.RELEASE_GATE_SCENARIO_USERNAME ?? 'john',
+ password: process.env.MEMIND_PASSWORD ?? process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '981122tj',
+};
+
+async function verifyTemporalRecallApi(cookie) {
+ const headers = { 'content-type': 'application/json', cookie };
+ const planRes = await fetch(`${baseUrl}/api/v1/context/plan`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ query: QUERY, now: new Date().toISOString() }),
+ });
+ const planBody = await planRes.json();
+ if (!planRes.ok) throw new Error(`context/plan ${planRes.status}: ${JSON.stringify(planBody)}`);
+
+ const recallRes = await fetch(`${baseUrl}/api/v1/temporal-recall/query`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ plan: planBody.plan, limit: 20 }),
+ });
+ const recallBody = await recallRes.json();
+ if (!recallRes.ok) throw new Error(`temporal-recall/query ${recallRes.status}: ${JSON.stringify(recallBody)}`);
+
+ const meinputItems = [];
+ for (const group of recallBody.groups ?? []) {
+ for (const item of group.items ?? []) {
+ if (item.source === 'meinput') meinputItems.push(item);
+ }
+ }
+ for (const item of recallBody.items ?? []) {
+ if (item.source === 'meinput') meinputItems.push(item);
+ }
+
+ return {
+ plan: planBody.plan,
+ stats: recallBody.stats,
+ meinputItems,
+ sampleTitles: meinputItems.slice(0, 5).map((item) => item.title?.slice(0, 40)),
+ };
+}
+
+function replyLooksGrounded(text, meinputSamples = []) {
+ const normalized = String(text ?? '');
+ if (/meinput|\[meinput\]|输入记录|按键|键盘输入/u.test(normalized)) return true;
+ const keywords = ['项目', '手机', '越狱', '安装', '2026-09-02', '19:4'];
+ if (keywords.some((kw) => normalized.includes(kw))) return true;
+ return meinputSamples.some((title) => title && normalized.includes(String(title).slice(0, 4)));
+}
+
+async function main() {
+ const reporter = createReporter();
+ console.log(`==> meinput recall chat verify`);
+ console.log(` Portal: ${baseUrl}`);
+ console.log(` User: ${account.username}`);
+ console.log(` Query: ${QUERY}\n`);
+
+ const statusRes = await fetch(`${baseUrl}/auth/status`);
+ if (!statusRes.ok) throw new Error(`Portal 未就绪: ${statusRes.status}`);
+
+ const auth = await loginViaApi(baseUrl, account, reporter);
+ const api = await verifyTemporalRecallApi(auth.cookie);
+
+ reporter.pass(
+ 'API plan 识别 temporal recall',
+ `${api.plan?.query_type ?? 'unknown'} / meinput=${api.plan?.sources?.meinput ?? '?'}`,
+ );
+
+ if (!(api.stats?.sources_queried ?? []).includes('meinput')) {
+ reporter.fail('API 检索源', `未查询 meinput: ${JSON.stringify(api.stats?.sources_queried)}`);
+ } else {
+ reporter.pass('API 检索源', 'meinput 已参与查询');
+ }
+
+ if ((api.stats?.raw_count ?? 0) <= 0) {
+ reporter.fail('API raw_count', 'meinput/chat 未返回原始条目');
+ } else {
+ reporter.pass('API raw_count', String(api.stats.raw_count));
+ }
+
+ if ((api.stats?.returned_count ?? 0) <= 0) {
+ reporter.fail('API returned_count', '排序后无条目可注入');
+ } else {
+ reporter.pass('API returned_count', String(api.stats.returned_count));
+ }
+
+ if (api.meinputItems.length <= 0) {
+ reporter.fail('API meinput 条目', 'groups/items 中无 meinput 源数据');
+ } else {
+ reporter.pass('API meinput 条目', `${api.meinputItems.length} 条,样例: ${api.sampleTitles.join(' | ')}`);
+ }
+
+ const run = await createAgentRun(baseUrl, auth.cookie, { message: QUERY });
+ reporter.pass('发起聊天 run', run.runId);
+
+ const terminal = await waitForRunTerminal(baseUrl, auth.cookie, run.runId, 180000);
+ if (terminal.status !== 'succeeded') {
+ reporter.fail('聊天 run 终态', terminal.status ?? 'unknown');
+ } else {
+ reporter.pass('聊天 run 终态', 'succeeded');
+ }
+
+ const sessionId = run.sessionId ?? terminal.sessionId ?? terminal.agent_session_id;
+ if (!sessionId) {
+ reporter.fail('会话 ID', 'run 未返回 sessionId');
+ } else {
+ const growth = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, {
+ minChars: 20,
+ timeoutMs: 5000,
+ });
+ const session = growth ? { ok: true, session: { conversation: [] } } : await getSession(baseUrl, auth.cookie, sessionId);
+ const texts = growth?.texts ?? extractAssistantTexts(session.session ?? session.payload ?? session);
+ const reply = (growth?.combined ?? texts.join('\n')).trim();
+
+ if (!reply) {
+ reporter.fail('助手回复', '会话中无 assistant 文本');
+ } else {
+ reporter.pass('助手回复长度', `${reply.length} 字符`);
+ console.log('\n--- assistant preview ---');
+ console.log(reply.slice(0, 600));
+ console.log('--- end preview ---\n');
+
+ if (replyLooksGrounded(reply, api.sampleTitles)) {
+ reporter.pass('回复 grounded', '包含 meinput/输入记录或样例关键词');
+ } else {
+ reporter.fail(
+ '回复 grounded',
+ '未检测到 meinput 注入痕迹(项目/手机/输入记录等)',
+ );
+ }
+
+ if (/无法直接访问|不能确切知道|没有权限访问/u.test(reply) && !replyLooksGrounded(reply, api.sampleTitles)) {
+ reporter.fail('空注入幻觉', '模型声称无法访问记录,但 API 明明有 meinput 数据');
+ }
+ }
+ }
+
+ process.exit(reporter.summary());
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/verify-rain-chat.mjs b/scripts/verify-rain-chat.mjs
new file mode 100644
index 0000000..086052e
--- /dev/null
+++ b/scripts/verify-rain-chat.mjs
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+/**
+ * Verify Rain mode: full meinput load + LLM → Goose handoff.
+ *
+ * Usage:
+ * MEMIND_USERNAME=john MEMIND_PASSWORD=981122tj node scripts/verify-rain-chat.mjs
+ */
+import { loadH5Environment } from './load-env.mjs';
+import {
+ createAgentRun,
+ createReporter,
+ extractAssistantTexts,
+ getSession,
+ loginViaApi,
+ resolvePortalBase,
+ waitForAssistantGrowth,
+ waitForRunTerminal,
+} from './scenario-test-lib.mjs';
+import { RAIN_SKILL_NAME } from '../chat-skills.mjs';
+
+loadH5Environment(import.meta.dirname);
+
+const QUERY = process.env.RAIN_VERIFY_QUERY ?? '最近我输入了什么?';
+const baseUrl = (process.env.MEMIND_BASE_URL ?? resolvePortalBase(Number(process.env.H5_PORT ?? 8081))).replace(/\/$/, '');
+const account = {
+ username: process.env.MEMIND_USERNAME ?? 'john',
+ password: process.env.MEMIND_PASSWORD ?? process.env.JOHN_PASSWORD ?? '981122tj',
+};
+
+
+async function main() {
+ const reporter = createReporter();
+ console.log(`==> Rain verify\n Portal: ${baseUrl}\n User: ${account.username}\n Query: ${QUERY}\n`);
+
+ const auth = await loginViaApi(baseUrl, account, reporter);
+ const run = await createAgentRun(baseUrl, auth.cookie, {
+ message: QUERY,
+ selectedChatSkill: RAIN_SKILL_NAME,
+ });
+
+ // Patch message shape for rain metadata (createAgentRun uses buildUserMessage)
+ reporter.pass('发起 Rain run', run.runId);
+
+ const terminal = await waitForRunTerminal(baseUrl, auth.cookie, run.runId, 180000);
+ if (terminal.status !== 'succeeded') {
+ const errText = String(terminal.error ?? terminal.lastError ?? '');
+ if (/Arrearage|Access denied/i.test(errText)) {
+ reporter.fail('run 终态', `LLM 账户欠费/不可用,非 Rain 逻辑错误:${errText.slice(0, 120)}`);
+ } else {
+ reporter.fail('run 终态', terminal.status ?? errText.slice(0, 120) ?? 'unknown');
+ }
+ } else {
+ reporter.pass('run 终态', 'succeeded');
+ }
+
+ const sessionId = run.sessionId ?? terminal.sessionId ?? terminal.agent_session_id;
+ const session = await getSession(baseUrl, auth.cookie, sessionId);
+ const texts = extractAssistantTexts(session.session ?? session.payload ?? session);
+ const reply = texts.join('\n').trim();
+
+ if (!reply) {
+ reporter.fail('助手回复', '空');
+ } else {
+ console.log('\n--- assistant ---\n', reply.slice(0, 800), '\n---\n');
+ reporter.pass('助手回复', `${reply.length} 字符`);
+ if (/疲惫|车载冰箱|便签页面内容对所有人可见|TKMind 记忆系统/u.test(reply) && !/MeInput|输入|项目|手机|Rain/u.test(reply)) {
+ reporter.fail('注入隔离', '回复像 Memory V2,未体现 MeInput');
+ } else {
+ reporter.pass('注入隔离', '未检测到典型长期记忆污染');
+ }
+ if (/0\.\d{4}/.test(reply) && /关联值|评分|权重/u.test(reply)) {
+ reporter.fail('分数泄漏', '回复含 recall 分数语义');
+ } else {
+ reporter.pass('分数泄漏', '未检测到');
+ }
+ }
+
+ process.exit(reporter.summary());
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs
index 14fc986..cfc0528 100644
--- a/server/portal-gateway-services-bootstrap.mjs
+++ b/server/portal-gateway-services-bootstrap.mjs
@@ -251,6 +251,7 @@ export function bootstrapPortalGatewayServices({
tkmindProxy,
toolGateway,
directChatService,
+ llmProviderService,
systemDisclosurePolicyService,
chatIntentRouter,
sessionSnapshotService,
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
index 1ae0d08..6600b66 100644
--- a/src/components/ChatPanel.tsx
+++ b/src/components/ChatPanel.tsx
@@ -1347,7 +1347,7 @@ export function ChatPanel({
}}
/>
)}
- {onGrantedSkillsUpdate && (
+ {onGrantedSkillsUpdate && import.meta.env.VITE_HIDE_PAGE_TEMPLATES === '0' && (