be464a5b8d
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>
214 lines
7.1 KiB
JavaScript
214 lines
7.1 KiB
JavaScript
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);
|
|
}
|
|
|
|
/**
|
|
* Rain 模式:按精确时间区间全量拉取 MeInput 原始记录(不过滤、不排序)。
|
|
* @param {{ userId: string, range: { start: string, end: string } }} input
|
|
*/
|
|
export async function fetchMeinputRangeFull(input) {
|
|
const userId = String(input.userId ?? '').trim();
|
|
const start = String(input.range?.start ?? '').trim();
|
|
const end = String(input.range?.end ?? '').trim();
|
|
if (!userId || !start || !end) return [];
|
|
|
|
const pool = getMeinputPool();
|
|
if (pool) {
|
|
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`,
|
|
[userId, start.slice(0, 23).replace('T', ' '), end.slice(0, 23).replace('T', ' ')],
|
|
);
|
|
return rows.map((row) => ({
|
|
event_id: row.event_id,
|
|
text: row.text,
|
|
app_name: row.app_name,
|
|
app_bundle_id: row.app_bundle_id,
|
|
created_at:
|
|
row.created_at instanceof Date ? row.created_at.toISOString() : new Date(row.created_at).toISOString(),
|
|
}));
|
|
}
|
|
|
|
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 !== userId) return [];
|
|
|
|
const url = new URL(`${base}/v1/evidence/export`);
|
|
url.searchParams.set('limit', '10000');
|
|
url.searchParams.set('since', start);
|
|
url.searchParams.set('until', 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) => ({
|
|
event_id: item.evidence_id ?? item.event_id,
|
|
text: item.payload?.text ?? item.text ?? '',
|
|
app_name: item.payload?.context?.app ?? item.app_name ?? null,
|
|
app_bundle_id: item.payload?.context?.app_bundle_id ?? item.app_bundle_id ?? null,
|
|
created_at: item.occurred_at ?? item.created_at,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|