Add Rain V0 for MeInput full-range chat analysis and delivery tooling.
Memind CI / Test, build, and release guards (push) Has been cancelled
Memind CI / Test, build, and release guards (push) Has been cancelled
Introduce rain-service orchestration, browser-safe chat skill filtering, MeInput adapter helpers, and verify/deploy scripts so Rain mode can summarize recent input without Memory V2 pollution. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user