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);
}