Add User Model Service and Temporal Recall for MeMind V0.1.

Introduce UMS ingest/snapshot pipeline, Context Planner with multi-source recall, runtime context injection, canonical user mapping, and session snapshot loading on auth/me.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-03 23:25:18 +08:00
parent bf66fdf493
commit 212ff3ff80
47 changed files with 4534 additions and 6 deletions
@@ -0,0 +1,87 @@
import crypto from 'node:crypto';
import { createScheduleService } from '../../schedule-service.mjs';
import { extractEventTime, itemMatchesTimeWindow } from '../event-time-extract.mjs';
function envEnabled(name, fallback = true) {
const raw = String(process.env[name] ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function newTimelineId(sourceRef) {
const hash = crypto.createHash('sha256').update(`timeline-v1|${sourceRef}`).digest('hex');
return [
hash.slice(0, 8),
hash.slice(8, 12),
`4${hash.slice(13, 16)}`,
hash.slice(16, 20),
hash.slice(20, 32),
].join('-');
}
/**
* @param {ReturnType<import('../../schedule-service.mjs').createScheduleService> extends { listItems: infer _ } ? object : never>} item
*/
export function scheduleItemToTimelineItem(item, ctx) {
const eventMs = item.startAt ?? item.dueAt ?? null;
const eventIso = eventMs != null ? new Date(eventMs).toISOString() : null;
const observedIso = new Date(item.createdAt).toISOString();
const sourceRef = `calendar:schedule:${item.id}`;
const text = [item.title, item.description].filter(Boolean).join(' — ');
const extracted = eventIso ? null : extractEventTime(text, observedIso);
const timelineItem = {
timeline_item_id: newTimelineId(sourceRef),
user_id: item.userId,
source: 'calendar',
type: item.kind === 'event' ? 'event' : 'todo',
event_time: eventIso ?? extracted?.event_time ?? null,
observed_time: observedIso,
title: String(item.title ?? '').slice(0, 80),
content: String(text).slice(0, 8192),
importance: item.kind === 'event' ? 0.9 : 0.82,
confidence: 0.98,
source_ref: sourceRef,
participants: [],
status: 'planned',
metadata: {
location: item.location ?? null,
timezone: item.timezone ?? null,
schedule_kind: item.kind,
},
};
if (ctx?.time && !itemMatchesTimeWindow(timelineItem, ctx.time, ctx.temporalMode)) {
return null;
}
return timelineItem;
}
/**
* @param {{ pool?: import('mysql2/promise').Pool, userId: string, retrieval: object, time: object, temporalMode: string }} ctx
*/
export async function searchCalendar(ctx) {
if (!envEnabled('MEMIND_TEMPORAL_RECALL_CALENDAR_ENABLED', true)) return [];
if (!ctx.pool?.query || !ctx.userId) return [];
const scheduleService = createScheduleService(ctx.pool);
const rangeStart = ctx.time?.event_range?.start ?? ctx.time?.mention_range?.start;
const rangeEnd = ctx.time?.event_range?.end ?? ctx.time?.mention_range?.end;
if (!rangeStart || !rangeEnd) return [];
const from = new Date(rangeStart).getTime();
const to = new Date(rangeEnd).getTime();
if (Number.isNaN(from) || Number.isNaN(to)) return [];
const items = await scheduleService.listItems({
userId: ctx.userId,
from,
to,
status: 'active',
limit: 100,
});
return items
.map((item) => scheduleItemToTimelineItem(item, ctx))
.filter(Boolean);
}
+107
View File
@@ -0,0 +1,107 @@
import crypto from 'node:crypto';
import {
expandObservedFetchRange,
extractEventTime,
itemMatchesTimeWindow,
} from '../event-time-extract.mjs';
function newTimelineId(sourceRef) {
const hash = crypto.createHash('sha256').update(`timeline-v1|${sourceRef}`).digest('hex');
return [
hash.slice(0, 8),
hash.slice(8, 12),
`4${hash.slice(13, 16)}`,
hash.slice(16, 20),
hash.slice(20, 32),
].join('-');
}
function parseUserMessage(row) {
try {
return typeof row.user_message_json === 'string'
? JSON.parse(row.user_message_json)
: row.user_message_json;
} catch {
return null;
}
}
function extractText(message) {
if (!message) return '';
if (typeof message === 'string') return message.trim();
if (typeof message.content === 'string') return message.content.trim();
if (Array.isArray(message.content)) {
return message.content
.map((part) => (typeof part === 'string' ? part : part?.text ?? ''))
.join('')
.trim();
}
if (typeof message.text === 'string') return message.text.trim();
return '';
}
function scoreTextImportance(text, expandedQueries = []) {
let score = 0.5;
const t = String(text ?? '');
if (/重要|紧急|安排|会议|待办|记得|跟进|截止|确认/.test(t)) score += 0.18;
for (const q of expandedQueries) {
if (q && t.includes(q)) score += 0.05;
}
return Math.min(0.95, score);
}
function matchesExpanded(text, expandedQueries) {
if (!expandedQueries?.length) return true;
return expandedQueries.some((q) => q && text.includes(q));
}
/**
* @param {import('mysql2/promise').Pool} pool
* @param {{ userId: string, retrieval: object, time: object, sessionId?: string }} ctx
*/
export async function searchChat(pool, ctx) {
if (!pool?.query) return [];
let sql = `
SELECT id, user_message_json, created_at, agent_session_id
FROM h5_agent_runs
WHERE user_id = ? AND created_at >= ? AND created_at < ?`;
const fetchRange = expandObservedFetchRange(ctx.time, ctx.temporalMode);
const params = [ctx.userId, new Date(fetchRange.start).getTime(), new Date(fetchRange.end).getTime()];
if (ctx.sessionId) {
sql += ' AND agent_session_id = ?';
params.push(ctx.sessionId);
}
sql += ' ORDER BY created_at ASC LIMIT 300';
const [rows] = await pool.query(sql, params);
const items = [];
for (const row of rows) {
const message = parseUserMessage(row);
const text = extractText(message);
if (text.length < 2) continue;
if (!matchesExpanded(text, ctx.retrieval.expanded_queries) && text.length < 12) continue;
const observed = new Date(Number(row.created_at)).toISOString();
const extracted = extractEventTime(text, observed);
const sourceRef = `chat:run:${row.id}`;
const item = {
timeline_item_id: newTimelineId(sourceRef),
user_id: ctx.userId,
source: 'chat',
type: /待办|记得|别忘了|跟进|截止|安排/.test(text) ? 'commitment' : 'mention',
event_time: extracted.event_time,
observed_time: observed,
title: text.slice(0, 80),
content: text.slice(0, 8192),
importance: scoreTextImportance(text, ctx.retrieval.expanded_queries),
confidence: extracted.event_time ? extracted.confidence : 0.88,
source_ref: sourceRef,
participants: [],
status: extracted.status === 'planned' ? 'planned' : 'mentioned',
metadata: { agent_session_id: row.agent_session_id },
};
if (!itemMatchesTimeWindow(item, ctx.time, ctx.temporalMode)) continue;
items.push(item);
}
return items;
}
@@ -0,0 +1,154 @@
import crypto from 'node:crypto';
import {
expandObservedFetchRange,
extractEventTime,
itemMatchesTimeWindow,
} from '../event-time-extract.mjs';
import mysql from 'mysql2/promise';
let meinputPool = null;
function getMeinputPool() {
const url = process.env.MEINPUT_DATABASE_URL ?? '';
if (!url) return null;
if (!meinputPool) {
meinputPool = mysql.createPool({ uri: url, waitForConnections: true, connectionLimit: 4 });
}
return meinputPool;
}
function newTimelineId(sourceRef) {
const hash = crypto.createHash('sha256').update(`timeline-v1|${sourceRef}`).digest('hex');
return [
hash.slice(0, 8),
hash.slice(8, 12),
`4${hash.slice(13, 16)}`,
hash.slice(16, 20),
hash.slice(20, 32),
].join('-');
}
function scoreTextImportance(text, expandedQueries = []) {
let score = 0.45;
const t = String(text ?? '');
if (t.length >= 20) score += 0.08;
if (/重要|紧急|必须|截止|会议|安排|明天|今天|项目/.test(t)) score += 0.15;
for (const q of expandedQueries) {
if (q && t.includes(q)) score += 0.05;
}
return Math.min(0.95, score);
}
function envelopeToTimelineItem(envelope, expandedQueries, temporalMode, time) {
const payload = envelope.payload ?? {};
const text = String(payload.text ?? '').trim();
if (!text) return null;
const observed = envelope.occurred_at;
const extracted = extractEventTime(text, observed);
const appName = payload.context?.app ?? null;
const appBundle = payload.context?.app_bundle_id ?? null;
const sourceRef = `meinput:segment:${envelope.evidence_id}`;
const item = {
timeline_item_id: newTimelineId(sourceRef),
user_id: envelope.user_id,
source: 'meinput',
type: /待办|记得|别忘了|跟进|截止/.test(text) ? 'todo' : 'mention',
event_time: extracted.event_time,
observed_time: observed,
title: text.slice(0, 80),
content: text.slice(0, 8192),
importance: scoreTextImportance(text, expandedQueries),
confidence: extracted.event_time ? extracted.confidence : 0.92,
source_ref: sourceRef,
participants: [],
status: extracted.status === 'planned' ? 'planned' : 'mentioned',
app_name: appName,
app_bundle_id: appBundle,
};
if (time && !itemMatchesTimeWindow(item, time, temporalMode)) return null;
return item;
}
async function fetchViaHttp(ctx) {
const base = process.env.MEINPUT_BASE_URL ?? 'https://input.tkmind.cn';
const username = process.env.MEINPUT_USERNAME ?? 'admin';
const password = process.env.MEINPUT_PASSWORD ?? '';
if (!password) return [];
const loginRes = await fetch(`${base}/v1/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const login = await loginRes.json();
if (!loginRes.ok || login.user_id !== ctx.userId) {
return [];
}
const url = new URL(`${base}/v1/evidence/export`);
url.searchParams.set('limit', '200');
url.searchParams.set('since', ctx.time.mention_range.start);
url.searchParams.set('until', ctx.time.mention_range.end);
const res = await fetch(url, { headers: { authorization: `Bearer ${login.access_token}` } });
const data = await res.json();
if (!res.ok) return [];
return (data.items ?? [])
.map((item) =>
envelopeToTimelineItem(
item,
ctx.retrieval.expanded_queries,
ctx.temporalMode,
ctx.time,
),
)
.filter(Boolean);
}
async function fetchViaDb(ctx) {
const pool = getMeinputPool();
if (!pool) return null;
const fetchRange = expandObservedFetchRange(ctx.time, ctx.temporalMode);
const [rows] = await pool.query(
`SELECT event_id, text, app_name, app_bundle_id, created_at
FROM mi_input_events
WHERE user_id = ? AND privacy_level = 'normal'
AND created_at >= ? AND created_at < ?
ORDER BY created_at ASC
LIMIT 800`,
[
ctx.userId,
fetchRange.start.slice(0, 23).replace('T', ' '),
fetchRange.end.slice(0, 23).replace('T', ' '),
],
);
return rows
.map((row) => {
const observed =
row.created_at instanceof Date ? row.created_at.toISOString() : new Date(row.created_at).toISOString();
const envelope = {
evidence_id: row.event_id,
user_id: ctx.userId,
occurred_at: observed,
payload: {
text: row.text,
context: { app: row.app_name, app_bundle_id: row.app_bundle_id },
},
};
return envelopeToTimelineItem(
envelope,
ctx.retrieval.expanded_queries,
ctx.temporalMode,
ctx.time,
);
})
.filter(Boolean);
}
/**
* @param {{ userId: string, retrieval: object, time: object, temporalMode: string }} ctx
*/
export async function searchMeinput(ctx) {
const dbItems = await fetchViaDb(ctx);
if (dbItems !== null) return dbItems;
return fetchViaHttp(ctx);
}
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { scheduleItemToTimelineItem } from './adapters/calendar.mjs';
test('scheduleItemToTimelineItem maps event with startAt', () => {
const ctx = {
temporalMode: 'PLANNED_IN',
time: {
mention_range: {
start: '2026-09-03T00:00:00+08:00',
end: '2026-09-04T00:00:00+08:00',
},
event_range: {
start: '2026-09-03T00:00:00+08:00',
end: '2026-09-04T00:00:00+08:00',
},
},
};
const item = scheduleItemToTimelineItem(
{
id: 'sched-1',
userId: 'user-1',
kind: 'event',
title: '与张总开会',
description: '项目方案确认',
startAt: new Date('2026-09-03T07:00:00.000Z').getTime(),
dueAt: null,
createdAt: new Date('2026-09-02T10:00:00.000Z').getTime(),
timezone: 'Asia/Shanghai',
location: null,
},
ctx,
);
assert.ok(item);
assert.equal(item.source, 'calendar');
assert.equal(item.type, 'event');
assert.equal(item.status, 'planned');
assert.equal(item.confidence, 0.98);
assert.match(item.source_ref, /^calendar:schedule:/);
});
+133
View File
@@ -0,0 +1,133 @@
import {
analyzeKeywords,
expandQueries,
inferTargets,
inferTemporalMode,
isTemporalRecallQuery,
} from './keyword-rules.mjs';
import { parseTimeScope } from './time-parser.mjs';
function envCalendarEnabled() {
const raw = String(process.env.MEMIND_TEMPORAL_RECALL_CALENDAR_ENABLED ?? '1').trim().toLowerCase();
return ['1', 'true', 'yes', 'on'].includes(raw);
}
/**
* @param {{ query: string, user_id: string, now?: Date, planner_level?: string }} input
*/
export function buildContextPlan(input) {
const query = String(input.query ?? '').trim();
const now = input.now instanceof Date ? input.now : new Date(input.now ?? Date.now());
const planner_level = input.planner_level ?? 'auto';
if (!query) {
throw new Error('invalid_query');
}
if (planner_level === 'semantic') {
const err = new Error('semantic planner not implemented in v0.1');
err.code = 'not_implemented';
throw err;
}
const temporal = isTemporalRecallQuery(query);
if (!temporal) {
return {
query_type: 'general_chat',
time: {
mention_range: parseTimeScope('这周', now).mention_range,
event_range: null,
relative_label: 'this_week',
},
temporal_mode: 'AMBIGUOUS',
targets: [{ type: 'mention', weight: 0.5 }],
sources: { calendar: 0.2, chat: 0.4, meinput: 0.4, memory_v2: 0.3 },
retrievals: [],
filters: { importance_min: 0.35, status: 'any' },
output: { group_by: 'importance', dedupe: true, timeline: true, wide_recall: true, max_items: 50 },
context_needs: {
user_snapshot: 'REQUIRED',
temporal_recall: 'SKIP',
memory_retrieval: 'SKIP',
},
planner_meta: { level: 'cheap_router', rule_hits: ['query:general_chat'], confidence: 0.9 },
};
}
const time = parseTimeScope(query, now);
const kw = analyzeKeywords(query);
const targets = inferTargets(query, kw);
const temporal_mode = inferTemporalMode(query);
const rule_hits = [...time.rule_hits, ...kw.rule_hits, `temporal_mode:${temporal_mode}`];
const sources = { ...kw.sources };
if (temporal_mode === 'PLANNED_IN' || temporal_mode === 'DUE_IN') {
sources.calendar = Math.min(1, sources.calendar + 0.2);
}
if (temporal_mode === 'MENTIONED_IN' || temporal_mode === 'CREATED_IN') {
sources.meinput = Math.min(1, sources.meinput + 0.15);
sources.chat = Math.min(1, sources.chat + 0.15);
}
const skip_below = 0.3;
const retrievals = [];
for (const [source, weight] of Object.entries(sources)) {
if (weight < skip_below) continue;
if (source === 'calendar' && !envCalendarEnabled()) continue;
retrievals.push({
source,
query_type:
source === 'meinput'
? 'important_mentions'
: source === 'chat'
? 'commitments_and_decisions'
: source === 'calendar'
? 'events'
: 'time_bounded',
expanded_queries: expandQueries(source, query, kw.commitmentKeywords),
weight: Number(weight.toFixed(2)),
skip_below,
});
}
retrievals.sort((a, b) => b.weight - a.weight);
const memoryHint = /决定|定了|上次|记得|记住/.test(query);
const context_needs = {
user_snapshot: 'OPTIONAL',
temporal_recall: 'REQUIRED',
memory_retrieval: memoryHint ? 'OPTIONAL' : 'SKIP',
};
return {
query_type: 'personal_temporal_recall',
time: {
mention_range: time.mention_range,
event_range: time.event_range,
relative_label: time.relative_label,
},
temporal_mode,
targets,
sources: Object.fromEntries(
Object.entries(sources).map(([k, v]) => [k, Number(v.toFixed(2))]),
),
retrievals,
filters: {
importance_min: kw.importance_min,
status: /还没|未完成|没做|待/.test(query) ? 'unresolved' : 'any',
},
output: {
group_by: temporal_mode === 'AMBIGUOUS' ? 'importance' : 'time',
dedupe: true,
timeline: true,
wide_recall: true,
max_items: 50,
},
context_needs,
planner_meta: {
level: 'cheap_router',
rule_hits,
confidence: Math.min(0.95, 0.65 + retrievals.length * 0.08),
},
};
}
+68
View File
@@ -0,0 +1,68 @@
function normalizeTitle(text) {
return String(text ?? '')
.toLowerCase()
.replace(/\s+/g, '')
.slice(0, 64);
}
function overlapScore(a, b) {
const ta = normalizeTitle(a);
const tb = normalizeTitle(b);
if (!ta || !tb) return 0;
if (ta === tb) return 1;
if (ta.includes(tb) || tb.includes(ta)) return 0.85;
const shorter = ta.length < tb.length ? ta : tb;
const longer = ta.length < tb.length ? tb : ta;
let common = 0;
for (let i = 0; i < shorter.length; i++) {
if (longer.includes(shorter[i])) common++;
}
return common / Math.max(longer.length, 1);
}
function timeClose(a, b, windowMs = 15 * 60_000) {
const ma = a ? new Date(a).getTime() : null;
const mb = b ? new Date(b).getTime() : null;
if (ma === null || mb === null) return false;
return Math.abs(ma - mb) <= windowMs;
}
/**
* @param {object[]} items
*/
export function dedupeTimelineItems(items) {
const kept = [];
for (const item of items) {
let merged = false;
for (let i = 0; i < kept.length; i++) {
const existing = kept[i];
const titleSim = overlapScore(existing.title, item.title);
const sameDayObserved =
existing.observed_time?.slice(0, 10) === item.observed_time?.slice(0, 10);
const close =
timeClose(existing.event_time, item.event_time) ||
(sameDayObserved && titleSim > 0.65);
if (titleSim >= 0.7 && close) {
const mergedFrom = [
...(existing.merged_from ?? [existing.source_ref]),
item.source_ref,
];
kept[i] = {
...existing,
recall_score: Math.max(existing.recall_score ?? 0, item.recall_score ?? 0),
importance: Math.max(existing.importance ?? 0, item.importance ?? 0),
confidence: Math.max(existing.confidence ?? 0, item.confidence ?? 0),
merged_from: mergedFrom,
content:
(existing.content?.length ?? 0) >= (item.content?.length ?? 0)
? existing.content
: item.content,
};
merged = true;
break;
}
}
if (!merged) kept.push({ ...item });
}
return kept;
}
@@ -0,0 +1,273 @@
const TZ_OFFSET_MIN = Number(process.env.TEMPORAL_RECALL_TZ_OFFSET_MIN ?? 480);
/** @param {Date} d */
function toLocalParts(d) {
const shifted = new Date(d.getTime() + TZ_OFFSET_MIN * 60_000);
return {
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth(),
day: shifted.getUTCDate(),
};
}
/** @param {{ year: number, month: number, day: number }} p @param {number} h @param {number} m */
function partsToDate(p, h = 0, m = 0) {
const utcMs =
Date.UTC(p.year, p.month, p.day, h, m, 0, 0) - TZ_OFFSET_MIN * 60_000;
return new Date(utcMs);
}
/** @param {Date} anchor @param {number} deltaDays */
function addDays(anchor, deltaDays) {
const p = toLocalParts(anchor);
return partsToDate({ year: p.year, month: p.month, day: p.day + deltaDays }, 0, 0);
}
const WEEKDAY_MAP = {
: 1,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 7,
};
function parseHour(text, hourRaw, minuteRaw) {
let hour = Number(hourRaw);
let minute = minuteRaw != null && minuteRaw !== '' ? Number(minuteRaw) : 0;
if (Number.isNaN(hour)) return null;
if (Number.isNaN(minute)) minute = 0;
if (/下午|晚上|傍晚/.test(text) && hour >= 1 && hour <= 11) hour += 12;
if (/中午/.test(text) && hour >= 1 && hour <= 10) hour += 12;
if (/凌晨/.test(text) && hour === 12) hour = 0;
if (hour === 24) hour = 0;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { hour, minute };
}
function resolveDayOffset(text) {
if (/大后天/.test(text)) return 3;
if (/后天/.test(text)) return 2;
if (/明天|明日/.test(text)) return 1;
if (/今天|今日/.test(text)) return 0;
if (/昨天|昨日/.test(text)) return -1;
if (/前天/.test(text)) return -2;
return null;
}
function resolveWeekday(text, anchor) {
const m = text.match(/(下?周|这个星期|这星期|本周)?([一二三四五六日天])/);
if (!m) return null;
const target = WEEKDAY_MAP[m[2]];
if (!target) return null;
const p = toLocalParts(anchor);
const anchorDate = partsToDate(p, 0, 0);
const anchorDow = new Date(anchorDate.getTime() + TZ_OFFSET_MIN * 60_000).getUTCDay();
const anchorMonBased = anchorDow === 0 ? 7 : anchorDow;
let delta = target - anchorMonBased;
if (delta <= 0) delta += 7;
if (/下周/.test(m[1] ?? '')) delta += 7;
return addDays(anchor, delta);
}
function resolveMonthDay(text, anchor) {
const m = text.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*日/);
if (!m) return null;
const month = Number(m[1]) - 1;
const day = Number(m[2]);
const p = toLocalParts(anchor);
let year = p.year;
if (month < p.month || (month === p.month && day < p.day)) year += 1;
return partsToDate({ year, month, day }, 0, 0);
}
const CN_DIGIT = {
: 0,
: 0,
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10,
};
function parseChineseNumber(raw) {
const s = String(raw ?? '').trim();
if (!s) return null;
if (/^\d+$/.test(s)) return Number(s);
if (s === '十') return 10;
if (s.startsWith('十')) {
const tail = CN_DIGIT[s[1]];
return tail != null ? 10 + tail : null;
}
if (s.endsWith('十')) {
const head = CN_DIGIT[s[0]];
return head != null ? head * 10 : null;
}
if (s.includes('十')) {
const [a, b] = s.split('十');
const head = a ? CN_DIGIT[a] ?? null : 1;
const tail = b ? CN_DIGIT[b] ?? null : 0;
if (head == null || tail == null) return null;
return head * 10 + tail;
}
return CN_DIGIT[s] ?? null;
}
function findTimeInText(text) {
const cnPattern =
/(上午|早上|中午|下午|晚上|傍晚|凌晨)?\s*([零〇一二两三四五六七八九十]{1,3})\s*(?:点|时)\s*(?:([零〇一二两三四五六七八九十]{1,3})\s*分?)?/;
const cnMatch = text.match(cnPattern);
if (cnMatch) {
const hour = parseChineseNumber(cnMatch[2]);
const minute = cnMatch[3] ? parseChineseNumber(cnMatch[3]) : 0;
if (hour != null) {
const parsed = parseHour(text, hour, minute ?? 0);
if (parsed) return parsed;
}
}
const patterns = [
/(上午|早上|中午|下午|晚上|傍晚|凌晨)?\s*(\d{1,2})\s*(?:[::点时])\s*(\d{1,2})?\s*(?:分)?/,
/(\d{1,2})\s*[:]\s*(\d{2})/,
];
for (const re of patterns) {
const m = text.match(re);
if (!m) continue;
const parsed = parseHour(text, m[2] ?? m[1], m[3] ?? m[2]);
if (parsed) return parsed;
}
return null;
}
function hasFutureIntent(text) {
return /明天|明日|后天|大后天|下周|周一|周二|周三|周四|周五|周六|周日|星期|安排|预约|会议|截止|之前|前要/.test(
text,
);
}
/**
* 从中文文本抽取计划/事件发生时间(相对 observed_time 锚点)。
*
* @param {string} text
* @param {string | Date} observedAt
* @returns {{ event_time: string | null, confidence: number, status: 'planned' | 'mentioned' | 'unknown' }}
*/
export function extractEventTime(text, observedAt) {
const raw = String(text ?? '').trim();
if (!raw) {
return { event_time: null, confidence: 0, status: 'unknown' };
}
const anchor = observedAt instanceof Date ? observedAt : new Date(observedAt);
if (Number.isNaN(anchor.getTime())) {
return { event_time: null, confidence: 0, status: 'unknown' };
}
let baseDay = null;
let dayConfidence = 0;
const dayOffset = resolveDayOffset(raw);
if (dayOffset != null) {
baseDay = addDays(anchor, dayOffset);
dayConfidence = 0.88;
}
if (!baseDay) {
baseDay = resolveWeekday(raw, anchor);
if (baseDay) dayConfidence = 0.75;
}
if (!baseDay) {
baseDay = resolveMonthDay(raw, anchor);
if (baseDay) dayConfidence = 0.8;
}
const timePart = findTimeInText(raw);
if (baseDay && timePart) {
const p = toLocalParts(baseDay);
const dt = partsToDate(p, timePart.hour, timePart.minute);
return {
event_time: dt.toISOString(),
confidence: Math.min(0.95, dayConfidence + 0.1),
status: 'planned',
};
}
if (baseDay) {
const p = toLocalParts(baseDay);
return {
event_time: partsToDate(p, 9, 0).toISOString(),
confidence: dayConfidence * 0.85,
status: 'planned',
};
}
if (timePart && /今天|今日/.test(raw)) {
const p = toLocalParts(anchor);
return {
event_time: partsToDate(p, timePart.hour, timePart.minute).toISOString(),
confidence: 0.82,
status: 'planned',
};
}
if (hasFutureIntent(raw)) {
return { event_time: null, confidence: 0.35, status: 'mentioned' };
}
return { event_time: null, confidence: 0, status: 'unknown' };
}
/**
* 判断 timeline item 是否与查询时间窗相关(observed 或 event 命中)。
*/
export function itemMatchesTimeWindow(item, time, temporalMode) {
const mentionStart = new Date(time?.mention_range?.start ?? 0).getTime();
const mentionEnd = new Date(time?.mention_range?.end ?? 0).getTime();
const eventStart = time?.event_range?.start
? new Date(time.event_range.start).getTime()
: mentionStart;
const eventEnd = time?.event_range?.end
? new Date(time.event_range.end).getTime()
: mentionEnd;
const observed = new Date(item.observed_time).getTime();
const event = item.event_time ? new Date(item.event_time).getTime() : null;
const inMention = observed >= mentionStart && observed < mentionEnd;
const inEvent = event != null && event >= eventStart && event < eventEnd;
switch (temporalMode) {
case 'OCCURRED_IN':
return inEvent || (!event && inMention);
case 'MENTIONED_IN':
case 'CREATED_IN':
return inMention;
case 'PLANNED_IN':
case 'DUE_IN':
return inEvent || inMention;
default:
return inMention || inEvent;
}
}
/**
* 为 DB 检索扩展 observed 窗口(捕获「昨天说明天」类输入)。
*/
export function expandObservedFetchRange(time, temporalMode) {
const start = new Date(time?.mention_range?.start ?? Date.now());
const end = new Date(time?.mention_range?.end ?? Date.now());
if (['PLANNED_IN', 'OCCURRED_IN', 'AMBIGUOUS', 'DUE_IN'].includes(temporalMode ?? '')) {
start.setDate(start.getDate() - 14);
}
return { start: start.toISOString(), end: end.toISOString() };
}
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
extractEventTime,
itemMatchesTimeWindow,
} from './event-time-extract.mjs';
test('extractEventTime parses 明天下午三点', () => {
const observed = new Date('2026-09-02T12:30:00+08:00');
const result = extractEventTime('明天下午三点去签合同', observed);
assert.equal(result.status, 'planned');
const hourLocal = new Date(result.event_time).getTime();
const expected = new Date('2026-09-03T07:00:00.000Z').getTime();
assert.equal(hourLocal, expected);
});
test('extractEventTime parses weekday', () => {
const observed = new Date('2026-09-03T10:00:00+08:00'); // Wed
const result = extractEventTime('下周五开会', observed);
assert.ok(result.event_time);
assert.equal(result.status, 'planned');
});
test('itemMatchesTimeWindow separates mention vs event', () => {
const time = {
mention_range: {
start: '2026-09-02T00:00:00+08:00',
end: '2026-09-03T00:00:00+08:00',
},
event_range: {
start: '2026-09-03T00:00:00+08:00',
end: '2026-09-04T00:00:00+08:00',
},
};
const item = {
observed_time: '2026-09-02T20:00:00+08:00',
event_time: '2026-09-03T15:00:00+08:00',
};
assert.equal(itemMatchesTimeWindow(item, time, 'MENTIONED_IN'), true);
assert.equal(itemMatchesTimeWindow(item, time, 'PLANNED_IN'), true);
});
+151
View File
@@ -0,0 +1,151 @@
const CALENDAR_KEYWORDS = [
'行程', '会议', '约会', '日历', '几点', '安排', '预约', '档期', '出发', '航班', '火车',
];
const MEINPUT_KEYWORDS = [
'输入', '打字', '说过', '原话', '上屏', '写过', '输入过', '发了', '微信', '消息',
];
const CHAT_KEYWORDS = [
'聊天', '对话', '讨论', '聊过', '问过', '你记得', '我们说过', '上次聊', 'agent', '助手',
];
const MEMORY_KEYWORDS = [
'决定', '定了', '之前定的', '记得', '记住', '上次', '当时', '承诺', '约定',
];
const COMMITMENT_KEYWORDS = [
'待办', '要做', '记得', '别忘了', '跟进', '确认', '截止', '交付', '完成', '处理',
];
const IMPORTANCE_KEYWORDS = ['重要', '关键', '紧急', '必须', '大事', '要紧'];
const SCHEDULE_QUESTION = /有什么|哪些|什么事|干嘛|做了什么|发生/;
const TEMPORAL_QUESTION = /什么时候|何时|几点|哪天/;
/**
* @param {string} query
*/
export function analyzeKeywords(query) {
const text = String(query ?? '');
const rule_hits = [];
const sources = {
calendar: 0.35,
chat: 0.55,
meinput: 0.55,
memory_v2: 0.35,
};
for (const kw of CALENDAR_KEYWORDS) {
if (text.includes(kw)) {
sources.calendar = Math.min(1, sources.calendar + 0.15);
rule_hits.push(`keyword:calendar:${kw}`);
}
}
for (const kw of MEINPUT_KEYWORDS) {
if (text.includes(kw)) {
sources.meinput = Math.min(1, sources.meinput + 0.12);
rule_hits.push(`keyword:meinput:${kw}`);
}
}
for (const kw of CHAT_KEYWORDS) {
if (text.includes(kw)) {
sources.chat = Math.min(1, sources.chat + 0.12);
rule_hits.push(`keyword:chat:${kw}`);
}
}
for (const kw of MEMORY_KEYWORDS) {
if (text.includes(kw)) {
sources.memory_v2 = Math.min(1, sources.memory_v2 + 0.12);
rule_hits.push(`keyword:memory:${kw}`);
}
}
if (SCHEDULE_QUESTION.test(text)) {
sources.calendar += 0.1;
sources.chat += 0.08;
sources.meinput += 0.08;
rule_hits.push('pattern:schedule_question');
}
const importance_min = IMPORTANCE_KEYWORDS.some((kw) => text.includes(kw)) ? 0.55 : 0.35;
if (importance_min > 0.35) rule_hits.push('filter:importance');
return { sources, rule_hits, importance_min, commitmentKeywords: COMMITMENT_KEYWORDS };
}
/**
* @param {string} query
* @param {{ sources: Record<string, number>, importance_min: number, commitmentKeywords: string[] }} kw
*/
export function inferTargets(query, kw) {
const text = String(query ?? '');
const targets = [];
if (/行程|会议|约会|日历|安排/.test(text)) {
targets.push({ type: 'calendar_event', weight: 0.9 }, { type: 'schedule', weight: 0.85 });
}
if (/待办|要做|完成|跟进|截止/.test(text)) {
targets.push({ type: 'todo', weight: 0.95 });
}
if (/决定|定了|承诺|约定/.test(text)) {
targets.push({ type: 'commitment', weight: 0.95 }, { type: 'decision', weight: 0.85 });
}
if (/重要|关键|紧急/.test(text)) {
targets.push({ type: 'important_event', weight: 0.9 });
}
if (!targets.length || /什么事|做了什么|发生|输入|说过/.test(text)) {
targets.push({ type: 'mention', weight: 0.75 }, { type: 'event', weight: 0.7 });
}
const seen = new Set();
return targets.filter((t) => {
if (seen.has(t.type)) return false;
seen.add(t.type);
return true;
});
}
/**
* @param {string} query
*/
export function inferTemporalMode(query) {
const text = String(query ?? '');
if (/说过|提到|安排|定了|计划|要做|记得|别忘了/.test(text) && /昨天|前天|上周|这周/.test(text)) {
if (/做了什么|发生了什么|干嘛了/.test(text)) return 'AMBIGUOUS';
if (/说过|提到|定了|安排/.test(text)) return 'MENTIONED_IN';
}
if (/有什么安排|行程|会议|几点|今天|明天/.test(text)) return 'PLANNED_IN';
if (/定了哪些|创建|记录/.test(text)) return 'CREATED_IN';
if (/截止|due|要交|到期/.test(text)) return 'DUE_IN';
if (/做了什么|发生了什么|干嘛|什么事/.test(text)) return 'OCCURRED_IN';
if (/重要|什么事/.test(text)) return 'AMBIGUOUS';
return 'AMBIGUOUS';
}
/**
* @param {string} query
*/
export function isTemporalRecallQuery(query) {
const text = String(query ?? '').trim();
if (!text) return false;
if (TEMPORAL_QUESTION.test(text)) return true;
if (/今天|昨天|前天|明天|这周|上周|本月|最近|近期|这几天/.test(text)) return true;
if (/做了什么|发生了什么|什么事|有什么|哪些|安排|行程|输入过|说过/.test(text)) return true;
return false;
}
export function expandQueries(source, query, commitmentKeywords) {
const base = [];
const text = String(query ?? '');
if (source === 'meinput') {
base.push('会议', '安排', '明天', '今天', '项目', '确认', '跟进', '截止', '记得');
} else if (source === 'chat') {
base.push('约', '安排', '明天', '会议', '确认', '跟进', '截止', '记得', '待办');
} else if (source === 'memory_v2') {
base.push('commitment', 'decision', 'task', 'todo');
} else if (source === 'calendar') {
base.push('会议', '预约', '行程');
}
for (const kw of commitmentKeywords) {
if (text.includes(kw) && !base.includes(kw)) base.push(kw);
}
for (const kw of IMPORTANCE_KEYWORDS) {
if (text.includes(kw) && !base.includes(kw)) base.push(kw);
}
return base.slice(0, 12);
}
+91
View File
@@ -0,0 +1,91 @@
const SOURCE_QUALITY = {
calendar: 0.95,
chat: 0.85,
meinput: 0.82,
memory_v2: 0.78,
};
function parseMs(value) {
if (!value) return null;
const ms = new Date(value).getTime();
return Number.isNaN(ms) ? null : ms;
}
function temporalMatch(item, plan) {
const mode = plan.temporal_mode ?? 'AMBIGUOUS';
const mentionStart = parseMs(plan.time?.mention_range?.start);
const mentionEnd = parseMs(plan.time?.mention_range?.end);
const eventStart = parseMs(plan.time?.event_range?.start);
const eventEnd = parseMs(plan.time?.event_range?.end);
const observed = parseMs(item.observed_time);
const event = parseMs(item.event_time);
const inMention =
observed !== null && mentionStart !== null && mentionEnd !== null
? observed >= mentionStart && observed < mentionEnd
: 0.5;
const inEvent =
event !== null && eventStart !== null && eventEnd !== null
? event >= eventStart && event < eventEnd
: inMention;
switch (mode) {
case 'OCCURRED_IN':
return event !== null ? (inEvent ? 1 : 0.2) : inMention * 0.85;
case 'MENTIONED_IN':
case 'CREATED_IN':
return inMention ? 1 : 0.25;
case 'PLANNED_IN':
case 'DUE_IN':
return event !== null ? (inEvent ? 1 : 0.3) : inMention * 0.7;
default:
return Math.max(inMention, inEvent * 0.9);
}
}
function semanticMatch(item, expandedQueries = []) {
if (!expandedQueries?.length) return 0.75;
const text = `${item.title ?? ''} ${item.content ?? ''}`;
let hits = 0;
for (const q of expandedQueries) {
if (q && text.includes(q)) hits += 1;
}
return Math.min(1, 0.45 + hits * 0.12);
}
/**
* @param {object} item
* @param {object} plan
* @param {string[]} expandedQueries
*/
export function computeRecallScore(item, plan, expandedQueries = []) {
const source_quality = SOURCE_QUALITY[item.source] ?? 0.7;
const temporal_match = temporalMatch(item, plan);
const semantic_match = semanticMatch(item, expandedQueries);
const importance = Number(item.importance ?? 0.5);
const confidence = Number(item.confidence ?? 0.8);
const recall_score =
source_quality * temporal_match * semantic_match * importance * confidence;
return Number(Math.min(1, recall_score).toFixed(4));
}
/**
* @param {object[]} items
* @param {object} plan
*/
export function rankTimelineItems(items, plan) {
const retrievalQueries = Object.fromEntries(
(plan.retrievals ?? []).map((r) => [r.source, r.expanded_queries ?? []]),
);
return items
.map((item) => ({
...item,
recall_score: computeRecallScore(
item,
plan,
retrievalQueries[item.source] ?? [],
),
}))
.filter((item) => item.recall_score >= (plan.filters?.importance_min ?? 0.35) * 0.55)
.sort((a, b) => b.recall_score - a.recall_score);
}
+125
View File
@@ -0,0 +1,125 @@
import { searchCalendar } from './adapters/calendar.mjs';
import { searchChat } from './adapters/chat.mjs';
import { searchMeinput } from './adapters/meinput.mjs';
import { buildContextPlan } from './context-planner.mjs';
import { dedupeTimelineItems } from './dedupe.mjs';
import { rankTimelineItems } from './rank.mjs';
const SOURCE_HANDLERS = {
calendar: searchCalendar,
meinput: searchMeinput,
chat: searchChat,
};
/**
* @param {import('mysql2/promise').Pool | null} pool
* @param {{ plan: object, userId: string, sessionId?: string, limit?: number }} opts
*/
export async function executeTemporalRecall(pool, opts) {
const started = Date.now();
const plan = opts.plan;
const userId = opts.userId;
const limit = Math.min(200, Math.max(1, Number(opts.limit ?? plan.output?.max_items ?? 50)));
const skipBelow = 0.3;
const sourcesQueried = [];
const tasks = (plan.retrievals ?? [])
.filter((r) => (r.weight ?? 0) >= (r.skip_below ?? skipBelow))
.map(async (retrieval) => {
const handler = SOURCE_HANDLERS[retrieval.source];
if (!handler) return [];
sourcesQueried.push(retrieval.source);
const ctx = {
userId,
pool,
retrieval,
time: plan.time,
temporalMode: plan.temporal_mode,
sessionId: opts.sessionId,
};
if (retrieval.source === 'chat') return searchChat(pool, ctx);
return handler(ctx);
});
const batches = await Promise.all(tasks);
const raw = batches.flat();
const ranked = rankTimelineItems(raw, plan);
const deduped = plan.output?.dedupe !== false ? dedupeTimelineItems(ranked) : ranked;
const returned = deduped.slice(0, limit);
let groups = null;
if (plan.temporal_mode === 'AMBIGUOUS' && plan.output?.timeline) {
const mentionStart = new Date(plan.time.mention_range.start).getTime();
const mentionEnd = new Date(plan.time.mention_range.end).getTime();
const occurred = [];
const mentioned = [];
for (const item of returned) {
const obs = new Date(item.observed_time).getTime();
if (item.event_time) {
const ev = new Date(item.event_time).getTime();
if (ev >= mentionStart && ev < mentionEnd) occurred.push(item);
else mentioned.push(item);
} else if (obs >= mentionStart && obs < mentionEnd) {
mentioned.push(item);
} else {
occurred.push(item);
}
}
groups = [
{ label: 'occurred_in_range', items: occurred },
{ label: 'mentioned_or_planned', items: mentioned },
];
}
return {
query_type: plan.query_type,
temporal_mode: plan.temporal_mode,
time_range: plan.time.mention_range,
groups,
items: groups ? [] : returned,
stats: {
sources_queried: sourcesQueried,
raw_count: raw.length,
deduped_count: deduped.length,
returned_count: returned.length,
elapsed_ms: Date.now() - started,
},
plan,
};
}
/**
* @param {import('mysql2/promise').Pool | null} pool
* @param {{ query?: string, plan?: object, user_id: string, now?: Date, session_id?: string, limit?: number }} input
*/
export async function queryTemporalRecall(pool, input) {
const userId = input.user_id;
const plan =
input.plan ??
buildContextPlan({
query: input.query ?? '',
user_id: userId,
now: input.now,
});
if (plan.context_needs?.temporal_recall === 'SKIP') {
return {
query_type: plan.query_type,
temporal_mode: plan.temporal_mode,
time_range: plan.time?.mention_range ?? null,
groups: null,
items: [],
stats: { sources_queried: [], raw_count: 0, deduped_count: 0, returned_count: 0, elapsed_ms: 0 },
plan,
};
}
return executeTemporalRecall(pool, {
plan,
userId,
sessionId: input.session_id,
limit: input.limit,
});
}
export { buildContextPlan };
+88
View File
@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildContextPlan } from '../temporal-recall-service/context-planner.mjs';
import { parseTimeScope } from '../temporal-recall-service/time-parser.mjs';
import { dedupeTimelineItems } from '../temporal-recall-service/dedupe.mjs';
import { formatTemporalRecallBlock } from '../temporal-recall-service/runtime-context.mjs';
test('parseTimeScope resolves yesterday', () => {
const now = new Date('2026-09-03T12:00:00+08:00');
const scope = parseTimeScope('我昨天有什么重要的事', now);
assert.equal(scope.relative_label, 'yesterday');
const startLocal = new Date(scope.mention_range.start);
assert.ok(startLocal.getTime() < now.getTime());
assert.ok(new Date(scope.mention_range.end).getTime() <= now.getTime());
});
test('buildContextPlan includes calendar for schedule questions', () => {
const plan = buildContextPlan({
query: '我今天有什么行程安排?',
user_id: 'user-1',
now: new Date('2026-09-03T12:00:00+08:00'),
});
assert.ok(plan.sources.calendar >= 0.5);
assert.ok(plan.retrievals.some((r) => r.source === 'calendar'));
});
test('buildContextPlan produces multi-source retrievals', () => {
const plan = buildContextPlan({
query: '我昨天有什么重要的事情安排吗?',
user_id: 'user-1',
now: new Date('2026-09-03T12:00:00+08:00'),
});
assert.equal(plan.query_type, 'personal_temporal_recall');
assert.equal(plan.context_needs.temporal_recall, 'REQUIRED');
assert.ok(plan.retrievals.length >= 2);
assert.ok(plan.sources.meinput > 0.5);
assert.ok(plan.sources.chat > 0.5);
});
test('dedupe merges similar items', () => {
const items = dedupeTimelineItems([
{
timeline_item_id: 'a',
title: '明天下午三点签合同',
content: '明天下午三点去签合同',
observed_time: '2026-09-02T10:00:00+08:00',
event_time: null,
source_ref: 'meinput:1',
recall_score: 0.8,
importance: 0.8,
confidence: 0.9,
},
{
timeline_item_id: 'b',
title: '明天下午三点去签合同',
content: '明天下午三点去跟张总签合同',
observed_time: '2026-09-02T10:05:00+08:00',
event_time: null,
source_ref: 'chat:1',
recall_score: 0.75,
importance: 0.75,
confidence: 0.85,
},
]);
assert.equal(items.length, 1);
assert.equal(items[0].merged_from?.length, 2);
});
test('formatTemporalRecallBlock renders grouped items', () => {
const block = formatTemporalRecallBlock({
groups: [
{
label: 'mentioned_or_planned',
items: [
{
source: 'meinput',
observed_time: '2026-09-02T10:00:00+08:00',
title: '明天下午三点签合同',
recall_score: 0.81,
},
],
},
],
});
assert.match(block, /时间范围回忆/);
assert.match(block, /meinput/);
assert.match(block, /签合同/);
});
+177
View File
@@ -0,0 +1,177 @@
import { getActiveSnapshot } from '../user-model-service/snapshot.mjs';
import { createUmsPool, isUmsDatabaseConfigured } from '../user-model-service/db.mjs';
import { resolveCanonicalUserId } from '../user-model-service/canonical-user.mjs';
import { buildContextPlan } from './context-planner.mjs';
import { queryTemporalRecall } from './recall.mjs';
function envEnabled(name, fallback = false) {
const raw = String(process.env[name] ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
let lazyUmsPool = null;
function resolveUmsPool(getUmsPool) {
if (typeof getUmsPool === 'function') {
const pool = getUmsPool();
if (pool) return pool;
}
if (!isUmsDatabaseConfigured()) return null;
if (!lazyUmsPool) lazyUmsPool = createUmsPool();
return lazyUmsPool;
}
function extractQueryText(query) {
return String(query ?? '').trim();
}
function formatTimelineItem(item) {
const observed = item.observed_time ?? '';
const event = item.event_time ?? '';
const showEvent = event && event !== observed;
const when = showEvent ? event : observed;
const day = when ? when.slice(0, 16).replace('T', ' ') : '';
const source = item.source ?? 'unknown';
const title = String(item.title ?? item.content ?? '').trim().slice(0, 120);
const score = item.recall_score != null ? ` (${item.recall_score})` : '';
const mentionNote =
showEvent && observed
? ` [提及于 ${observed.slice(0, 16).replace('T', ' ')}]`
: '';
return `- [${source}] ${day} ${title}${mentionNote}${score}`.trim();
}
/**
* @param {object} recallResult
*/
export function formatTemporalRecallBlock(recallResult) {
if (!recallResult) return '';
const lines = ['【时间范围回忆】', '以下内容来自用户在该时间范围内的输入与对话线索,仅作事实参考;不要主动暴露数据来源。'];
if (recallResult.groups?.length) {
for (const group of recallResult.groups) {
if (!group.items?.length) continue;
const label =
group.label === 'occurred_in_range'
? '实际发生:'
: group.label === 'mentioned_or_planned'
? '提到或安排:'
: `${group.label}`;
lines.push('', label);
for (const item of group.items.slice(0, 12)) {
lines.push(formatTimelineItem(item));
}
}
} else if (recallResult.items?.length) {
lines.push('');
for (const item of recallResult.items.slice(0, 15)) {
lines.push(formatTimelineItem(item));
}
} else {
return '';
}
return lines.join('\n').trim();
}
/**
* @param {object | null} snapshot
*/
export function formatUserSnapshotBlock(snapshot) {
if (!snapshot?.core) return '';
const lines = ['【用户快照】', '以下为慢变用户画像摘要,用于理解关注点,不是执行指令。'];
const projects = snapshot.core.active_projects ?? [];
const focus = snapshot.core.recent_focus ?? [];
const hints = snapshot.core.agent_hints ?? [];
if (projects.length) {
lines.push(`近期项目:${projects.map((p) => p.name).filter(Boolean).slice(0, 5).join('、')}`);
}
if (focus.length) {
lines.push(`关注话题:${focus.map((f) => f.topic).filter(Boolean).slice(0, 5).join('、')}`);
}
for (const hint of hints.slice(0, 3)) {
if (hint) lines.push(String(hint));
}
if (lines.length <= 2) return '';
return lines.join('\n');
}
/**
* @param {{
* pool?: import('mysql2/promise').Pool | null,
* getUmsPool?: () => import('mysql2/promise').Pool | null,
* userId: string,
* query: string,
* sessionId?: string | null,
* now?: Date,
* }} input
*/
export async function resolveRuntimeContext(input) {
if (!envEnabled('MEMIND_RUNTIME_CONTEXT_ENABLED', true)) {
return { enabled: false, plan: null, injectionEnabled: false, blocks: {} };
}
const query = extractQueryText(input.query);
if (!query || !input.userId) {
return { enabled: true, plan: null, injectionEnabled: false, blocks: {} };
}
const userId = resolveCanonicalUserId(input.userId);
const plan = buildContextPlan({
query,
user_id: userId,
now: input.now ?? new Date(),
});
const needs = plan.context_needs ?? {};
const blocks = {};
let temporalRecall = null;
let userSnapshot = null;
if (needs.temporal_recall === 'REQUIRED' || needs.temporal_recall === 'OPTIONAL') {
try {
temporalRecall = await queryTemporalRecall(input.pool ?? null, {
plan,
user_id: userId,
session_id: input.sessionId ?? undefined,
limit: plan.output?.max_items ?? 20,
});
const block = formatTemporalRecallBlock(temporalRecall);
if (block) blocks.temporal = block;
} catch (err) {
console.warn(
'[RuntimeContext] temporal recall skipped:',
err instanceof Error ? err.message : err,
);
}
}
if (needs.user_snapshot === 'REQUIRED' || needs.user_snapshot === 'OPTIONAL') {
const umsPool = resolveUmsPool(input.getUmsPool);
if (umsPool) {
try {
userSnapshot = await getActiveSnapshot(umsPool, userId, 'default');
const block = formatUserSnapshotBlock(userSnapshot);
if (block) blocks.snapshot = block;
} catch (err) {
console.warn(
'[RuntimeContext] user snapshot skipped:',
err instanceof Error ? err.message : err,
);
}
}
}
const injectionEnabled = Boolean(blocks.temporal || blocks.snapshot);
return {
enabled: true,
plan,
temporalRecall,
userSnapshot,
injectionEnabled,
blocks,
injectionText: [blocks.snapshot, blocks.temporal].filter(Boolean).join('\n\n'),
};
}
+158
View File
@@ -0,0 +1,158 @@
const TZ_OFFSET_MIN = Number(process.env.TEMPORAL_RECALL_TZ_OFFSET_MIN ?? 480);
function pad2(n) {
return String(n).padStart(2, '0');
}
/** @param {Date} d */
function toLocalParts(d) {
const shifted = new Date(d.getTime() + TZ_OFFSET_MIN * 60_000);
return {
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth(),
day: shifted.getUTCDate(),
dow: shifted.getUTCDay(),
hour: shifted.getUTCHours(),
};
}
/** @param {{ year: number, month: number, day: number }} p @param {number} h @param {number} m */
function partsToIso(p, h = 0, m = 0) {
const utcMs =
Date.UTC(p.year, p.month, p.day, h, m, 0, 0) - TZ_OFFSET_MIN * 60_000;
return new Date(utcMs).toISOString();
}
/** @param {Date} anchor */
function startOfDay(anchor) {
const p = toLocalParts(anchor);
return new Date(partsToIso(p, 0, 0));
}
/** @param {Date} anchor */
function endOfDay(anchor) {
const p = toLocalParts(anchor);
const start = Date.UTC(p.year, p.month, p.day + 1, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000;
return new Date(start);
}
/** @param {Date} anchor @param {number} deltaDays */
function addDays(anchor, deltaDays) {
const p = toLocalParts(anchor);
const ms = Date.UTC(p.year, p.month, p.day + deltaDays, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000;
return new Date(ms);
}
/** @param {Date} anchor */
function startOfWeek(anchor) {
const p = toLocalParts(anchor);
const mondayOffset = p.dow === 0 ? -6 : 1 - p.dow;
const ms =
Date.UTC(p.year, p.month, p.day + mondayOffset, 0, 0, 0, 0) -
TZ_OFFSET_MIN * 60_000;
return new Date(ms);
}
/** @param {Date} anchor */
function endOfWeek(anchor) {
const start = startOfWeek(anchor);
return addDays(start, 7);
}
/** @param {Date} anchor */
function startOfMonth(anchor) {
const p = toLocalParts(anchor);
return new Date(Date.UTC(p.year, p.month, 1, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000);
}
/** @param {Date} anchor */
function endOfMonth(anchor) {
const p = toLocalParts(anchor);
return new Date(Date.UTC(p.year, p.month + 1, 1, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000);
}
/**
* @param {string} query
* @param {Date} [now]
* @returns {{ mention_range: { start: string, end: string }, event_range: { start: string, end: string } | null, relative_label: string | null, rule_hits: string[] }}
*/
export function parseTimeScope(query, now = new Date()) {
const text = String(query ?? '');
const rule_hits = [];
let relative_label = null;
let rangeStart = null;
let rangeEnd = null;
const patterns = [
{ re: /前天/, label: 'day_before_yesterday', apply: () => {
const d = addDays(now, -2);
return [startOfDay(d), endOfDay(d)];
}},
{ re: /昨天|昨日/, label: 'yesterday', apply: () => {
const d = addDays(now, -1);
return [startOfDay(d), endOfDay(d)];
}},
{ re: /今天|今日/, label: 'today', apply: () => [startOfDay(now), endOfDay(now)] },
{ re: /明天|明日/, label: 'tomorrow', apply: () => {
const d = addDays(now, 1);
return [startOfDay(d), endOfDay(d)];
}},
{ re: /后天/, label: 'day_after_tomorrow', apply: () => {
const d = addDays(now, 2);
return [startOfDay(d), endOfDay(d)];
}},
{ re: /上周|上星期|上个星期/, label: 'last_week', apply: () => {
const thisWeek = startOfWeek(now);
const lastStart = addDays(thisWeek, -7);
return [lastStart, thisWeek];
}},
{ re: /这周|本周|这个星期|这星期/, label: 'this_week', apply: () => [startOfWeek(now), endOfWeek(now)] },
{ re: /上个月/, label: 'last_month', apply: () => {
const thisMonth = startOfMonth(now);
const p = toLocalParts(thisMonth);
const lastStart = new Date(Date.UTC(p.year, p.month - 1, 1, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000);
return [lastStart, thisMonth];
}},
{ re: /这个月|本月/, label: 'this_month', apply: () => [startOfMonth(now), endOfMonth(now)] },
{ re: /最近(\d+)天/, label: 'recent_days', apply: (m) => {
const days = Number(m[1]);
return [addDays(now, -days), endOfDay(now)];
}},
{ re: /最近一周|最近1周/, label: 'recent_week', apply: () => [addDays(now, -7), endOfDay(now)] },
];
for (const { re, label, apply } of patterns) {
const m = text.match(re);
if (m) {
[rangeStart, rangeEnd] = apply(m);
relative_label = label;
rule_hits.push(`time:${label}`);
break;
}
}
if (!rangeStart) {
if (/最近|近期|这几天/.test(text)) {
[rangeStart, rangeEnd] = [addDays(now, -7), endOfDay(now)];
relative_label = 'recent_week';
rule_hits.push('time:recent_fuzzy');
} else {
[rangeStart, rangeEnd] = [addDays(now, -7), endOfDay(now)];
relative_label = 'this_week';
rule_hits.push('time:default_week');
}
}
const mention_range = {
start: rangeStart.toISOString(),
end: rangeEnd.toISOString(),
};
let event_range = null;
if (/安排|行程|会议|约会|几点|日历/.test(text)) {
event_range = { ...mention_range };
rule_hits.push('time:event_range_linked');
}
return { mention_range, event_range, relative_label, rule_hits };
}