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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user