diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index f0b28ca..fc629d9 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -19,6 +19,7 @@ import { createLlmProviderService } from './llm-providers.mjs'; import { createAssetGatewayConfigService } from './asset-gateway.mjs'; import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; +import { createMemoryV2AdminOpsService } from './memory-v2-admin-ops.mjs'; import { createGoalRunAdminOpsService } from './goal-run-admin-ops.mjs'; import { createOrchestratorAdminConfigService } from './services/orchestrator/admin-config.mjs'; import { createOrchestratorObservabilityService } from './services/orchestrator/observability.mjs'; @@ -113,6 +114,7 @@ export async function createAdminServices(env = {}) { }); await imageMakeAdminConfigService.ensureSchema(); const memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + const memoryV2AdminOpsService = createMemoryV2AdminOpsService(pool); const goalRunAdminOpsService = createGoalRunAdminOpsService(pool, { env: process.env }); const orchestratorConfigService = createOrchestratorAdminConfigService(pool); await orchestratorConfigService.ensureSchema(); @@ -167,6 +169,7 @@ export async function createAdminServices(env = {}) { assetGatewayConfigService, imageMakeAdminConfigService, memoryV2ConfigService, + memoryV2AdminOpsService, goalRunAdminOpsService, orchestratorConfigService, orchestratorObservabilityService, diff --git a/admin-routes.mjs b/admin-routes.mjs index b3d2529..9c57dda 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -31,6 +31,7 @@ function plazaRouteError(res, req, error) { * @param {object} deps.userAuth * @param {object|null} deps.llmProviderService * @param {object|null} deps.memoryV2ConfigService + * @param {object|null} deps.memoryV2AdminOpsService * @param {object|null} deps.goalRunAdminOpsService * @param {object|null} deps.orchestratorConfigService * @param {object|null} deps.orchestratorObservabilityService @@ -51,6 +52,7 @@ export function createAdminApi({ assetGatewayConfigService, imageMakeAdminConfigService, memoryV2ConfigService, + memoryV2AdminOpsService, goalRunAdminOpsService, orchestratorConfigService, orchestratorObservabilityService, @@ -233,6 +235,81 @@ export function createAdminApi({ return res.json(result); }); + adminApi.get('/memory-v2/metrics', requireAdmin, async (req, res) => { + if (!memoryV2AdminOpsService?.getProductMetrics) { + return res.status(503).json({ message: 'Memory V2 指标服务未启用' }); + } + try { + const since = String(req.query?.since ?? '7d').trim() || '7d'; + const userId = String(req.query?.userId ?? '').trim() || null; + const metrics = await memoryV2AdminOpsService.getProductMetrics({ since, userId }); + const audit = await memoryV2AdminOpsService.getShadowAudit({ since, userId }); + const counts = await memoryV2AdminOpsService.countCandidatesByStatus(); + return res.json({ metrics, audit, candidateCounts: counts }); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : '读取 Memory V2 指标失败', + }); + } + }); + + adminApi.get('/memory-v2/candidates', requireAdmin, async (req, res) => { + if (!memoryV2AdminOpsService?.listCandidates) { + return res.status(503).json({ message: 'Memory V2 候选服务未启用' }); + } + try { + const status = String(req.query?.status ?? 'candidate').trim() || 'candidate'; + const userId = String(req.query?.userId ?? '').trim() || null; + const limit = Number(req.query?.limit ?? 50); + const offset = Number(req.query?.offset ?? 0); + const items = await memoryV2AdminOpsService.listCandidates({ status, userId, limit, offset }); + return res.json({ items, status, limit, offset }); + } catch (err) { + return res.status(400).json({ + message: err instanceof Error ? err.message : '读取候选记忆失败', + }); + } + }); + + adminApi.post('/memory-v2/candidates/auto-review', requireAdmin, async (req, res) => { + if (!memoryV2AdminOpsService?.runAutoReview) { + return res.status(503).json({ message: 'Memory V2 候选服务未启用' }); + } + try { + const userId = String(req.body?.userId ?? '').trim() || null; + const limit = Number(req.body?.limit ?? 200); + const result = await memoryV2AdminOpsService.runAutoReview({ userId, limit }); + return res.json({ ok: true, ...result }); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : '自动审核候选记忆失败', + }); + } + }); + + adminApi.post('/memory-v2/candidates/:id/review', requireAdmin, async (req, res) => { + if (!memoryV2AdminOpsService?.reviewCandidate) { + return res.status(503).json({ message: 'Memory V2 候选服务未启用' }); + } + const status = String(req.body?.status ?? '').trim(); + if (!['accepted', 'rejected'].includes(status)) { + return res.status(400).json({ message: 'status 必须是 accepted 或 rejected' }); + } + try { + const result = await memoryV2AdminOpsService.reviewCandidate(req.params.id, status, { + reviewedBy: req.currentUser.id, + }); + if (!result.updated) { + return res.status(409).json({ message: '候选不存在或已审核' }); + } + return res.json({ ok: true, ...result }); + } catch (err) { + return res.status(400).json({ + message: err instanceof Error ? err.message : '审核候选记忆失败', + }); + } + }); + adminApi.get('/goal-runs/runtime', requireAdmin, async (_req, res) => { if (!goalRunAdminOpsService?.getRuntime) { return res.status(503).json({ message: 'Goal Run 管理服务未启用' }); diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs index 7a47fd8..6bbe061 100644 --- a/admin-routes.test.mjs +++ b/admin-routes.test.mjs @@ -90,6 +90,111 @@ test('admin memory-v2 config routes expose config and runtime state', async () = } }); +test('admin memory-v2 metrics and candidate review routes', async () => { + const router = createAdminApi({ + jsonBody: express.json(), + getToken() { + return 'token-admin'; + }, + userAuth: { + async getMe(token) { + if (token !== 'token-admin') return null; + return { id: 'admin-1', role: 'admin' }; + }, + }, + memoryV2AdminOpsService: { + async getProductMetrics() { + return { + window: { since: '7d', sinceMs: 1, untilMs: 2 }, + userId: null, + events: { + memory_candidate_saved: 3, + memory_promoted: 2, + memory_resolved_injected: 1, + memory_recall_hit: 1, + }, + sources: {}, + }; + }, + async getShadowAudit() { + return { + falseStoreRate: 0, + autoAcceptRate: 0.1, + resolveHitRate: 0.5, + suspiciousCandidateCount: 0, + pgvectorLagUserCount: 0, + }; + }, + async countCandidatesByStatus() { + return { candidate: 4, accepted: 2 }; + }, + async listCandidates() { + return [{ + id: 'cand-1', + userId: 'user-1', + sessionId: 'session-1', + memoryType: 'episodic', + content: '请记住我喜欢美式咖啡', + importance: 0.9, + confidence: 0.9, + status: 'candidate', + policyReason: 'explicit_memory_request', + createdAt: 1000, + updatedAt: 1000, + }]; + }, + async reviewCandidate(id, status, { reviewedBy }) { + assert.equal(id, 'cand-1'); + assert.equal(status, 'accepted'); + assert.equal(reviewedBy, 'admin-1'); + return { updated: true, status, reviewedAt: 2000 }; + }, + }, + llmProviderService: null, + memoryV2ConfigService: null, + plazaPosts: null, + plazaOps: null, + wechatAdmin: null, + subscriptionService: null, + }); + + const server = await startTestServer(router); + try { + const metricsRes = await fetch(`${server.baseUrl}/admin-api/memory-v2/metrics`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(metricsRes.status, 200); + const metricsBody = await metricsRes.json(); + assert.equal(metricsBody.metrics.events.memory_candidate_saved, 3); + assert.equal(metricsBody.candidateCounts.candidate, 4); + + const listRes = await fetch(`${server.baseUrl}/admin-api/memory-v2/candidates`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(listRes.status, 200); + const listBody = await listRes.json(); + assert.equal(listBody.items.length, 1); + + const reviewRes = await fetch(`${server.baseUrl}/admin-api/memory-v2/candidates/cand-1/review`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: 'h5_user_session=token-admin', + }, + body: JSON.stringify({ status: 'accepted' }), + }); + assert.equal(reviewRes.status, 200); + assert.deepEqual(await reviewRes.json(), { + ok: true, + updated: true, + status: 'accepted', + reviewedAt: 2000, + }); + } finally { + await server.close(); + } +}); + test('admin goal-runs routes expose summary, list, and detail', async () => { const router = createAdminApi({ jsonBody: express.json(), diff --git a/admin-server.mjs b/admin-server.mjs index 158cf96..1c552b9 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -79,6 +79,7 @@ const CONSOLES = { assetGatewayConfigService: services.assetGatewayConfigService, imageMakeAdminConfigService: services.imageMakeAdminConfigService, memoryV2ConfigService: services.memoryV2ConfigService, + memoryV2AdminOpsService: services.memoryV2AdminOpsService, goalRunAdminOpsService: services.goalRunAdminOpsService, orchestratorConfigService: services.orchestratorConfigService, orchestratorObservabilityService: services.orchestratorObservabilityService, diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index da9ce4b..d21ea3d 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -1461,11 +1461,37 @@ export function createAgentRunGateway({ memoryCount: Array.isArray(agentMemoryContext.memories) ? agentMemoryContext.memories.length : 0, + memoryPreviews: buildMemoryRecallPreviews(agentMemoryContext.memories), skipped: Boolean(agentMemoryContext.skipped), degraded: Boolean(agentMemoryContext.degraded), reason: agentMemoryContext.reason ?? null, latencyMs: Number(agentMemoryContext.latencyMs ?? 0), }); + if (agentMemoryContext.injectionEnabled) { + const memoryCount = Array.isArray(agentMemoryContext.memories) + ? agentMemoryContext.memories.length + : 0; + void recordMemoryV2ProductEvent(pool, { + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.RESOLVED_INJECTED, + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + runId, + data: { + mode: agentMemoryContext.mode, + memoryCount, + source: agentMemoryContext.source ?? null, + }, + }).catch(() => {}); + if (memoryCount > 0) { + void recordMemoryV2ProductEvent(pool, { + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.RECALL_HIT, + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + runId, + data: { memoryCount, mode: agentMemoryContext.mode }, + }).catch(() => {}); + } + } } } catch (err) { console.warn( diff --git a/docs/memory-v2/phase-a-canary.env.example b/docs/memory-v2/phase-a-canary.env.example new file mode 100644 index 0000000..a10372c --- /dev/null +++ b/docs/memory-v2/phase-a-canary.env.example @@ -0,0 +1,37 @@ +# Memory V2 Phase A canary example +# Copy selected variables into memind_adm runtime overrides or local .env. +# See docs/architecture/memory-v2-phase-a-closure-plan.md + +MEMORY_ENABLED=1 +MEMORY_FAIL_OPEN=1 + +# Candidate pipeline: canary only auto-accepts explicit "请记住" +MEMORY_CANDIDATE_ENABLED=1 +MEMORY_CANDIDATE_MODE=canary +MEMORY_CANDIDATE_PERSISTENCE_ENABLED=1 +MEMORY_CANDIDATE_AUTO_ACCEPT_ALL=0 +MEMORY_CANDIDATE_MIN_IMPORTANCE=0.7 +MEMORY_CANDIDATE_MIN_CONFIDENCE=0.8 + +# Agent injection aligned with the same canary users +MEMORY_AGENT_RESOLVE_ENABLED=1 +MEMORY_AGENT_INJECTION_MODE=canary +MEMORY_AGENT_CANARY_USER_IDS=32035858-9a20-425b-89da-c118ef0779aa,1c99b83b-0454-474f-a5d2-129d34506a32 + +# Lifecycle: promote only; forgetting/decay stay off in Phase A +MEMORY_LIFECYCLE_ENABLED=1 +MEMORY_LIFECYCLE_ROLLOUT_MODE=canary +MEMORY_LIFECYCLE_ROLLOUT_USER_IDS=32035858-9a20-425b-89da-c118ef0779aa,1c99b83b-0454-474f-a5d2-129d34506a32 +MEMORY_PROMOTION_ENABLED=1 +MEMORY_LIFECYCLE_WORKER_ENABLED=1 +MEMORY_LIFECYCLE_FORGETTING_ENABLED=0 +MEMORY_LIFECYCLE_DECAY_ENABLED=0 + +# pgvector (set URL + embedding module before enabling retrieval canary) +# MEMORY_BACKEND=pgvector +# MEMORY_VECTOR_ENABLED=1 +# MEMORY_PGVECTOR_ENABLED=1 +# MEMORY_PGVECTOR_DATABASE_URL=postgresql://john@127.0.0.1:5432/memind_memory +# MEMORY_PGVECTOR_TABLE=memory_embeddings +# MEMORY_PGVECTOR_EMBEDDING_MODULE=./scripts/embed-memory-v2-local-hash.mjs +# MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS=3 diff --git a/memory-v2-admin-ops.mjs b/memory-v2-admin-ops.mjs new file mode 100644 index 0000000..e7930ba --- /dev/null +++ b/memory-v2-admin-ops.mjs @@ -0,0 +1,119 @@ +import { + aggregateMemoryV2ProductMetrics, + ensureMemoryV2ProductEventsSchema, +} from './memory-v2-product-events.mjs'; +import { runCandidateAutoReviewBatch } from './memory-v2-candidate-auto-review.mjs'; +import { + createPersonalMemoryCandidateStore, + ensurePersonalMemoryCandidateSchema, +} from './memory-v2-personal-store.mjs'; +import { auditMemoryV2Shadow, parseSinceArg } from './memory-v2-shadow-audit.mjs'; + +async function tableExists(pool, tableName) { + const [rows] = await pool.query( + `SELECT 1 AS ok FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ? + LIMIT 1`, + [String(tableName)], + ); + return rows.length > 0; +} + +export function createMemoryV2AdminOpsService(pool, { now = () => Date.now() } = {}) { + if (!pool?.query) return null; + let schemaReady = false; + + async function ensureSchema() { + if (schemaReady) return; + await ensureMemoryV2ProductEventsSchema(pool); + await ensurePersonalMemoryCandidateSchema(pool); + schemaReady = true; + } + + return { + async getProductMetrics({ since = '7d', userId = null } = {}) { + await ensureSchema(); + return aggregateMemoryV2ProductMetrics(pool, { since, userId, now: now() }); + }, + + async getShadowAudit({ since = '7d', userId = null } = {}) { + await ensureSchema(); + const window = parseSinceArg(since, now()); + const params = [window.sinceMs]; + let candidateSql = `SELECT * FROM h5_memory_v2_candidates WHERE created_at >= ?`; + if (userId) { + candidateSql += ' AND user_id = ?'; + params.push(String(userId)); + } + candidateSql += ' ORDER BY created_at DESC LIMIT 5000'; + const [candidates] = await pool.query(candidateSql, params); + + const itemParams = [window.sinceMs]; + let itemSql = `SELECT * FROM h5_user_memory_items WHERE updated_at >= ?`; + if (userId) { + itemSql += ' AND user_id = ?'; + itemParams.push(String(userId)); + } + itemSql += ' ORDER BY updated_at DESC LIMIT 5000'; + const [memoryItems] = (await tableExists(pool, 'h5_user_memory_items')) + ? await pool.query(itemSql, itemParams) + : [[]]; + + const eventParams = [window.sinceMs]; + let eventSql = `SELECT e.event_type, e.data_json, e.created_at + FROM h5_agent_run_events e + WHERE e.event_type = 'agent_memory_resolved' AND e.created_at >= ?`; + if (userId) { + eventSql = `SELECT e.event_type, e.data_json, e.created_at + FROM h5_agent_run_events e + INNER JOIN h5_agent_runs r ON r.id = e.run_id + WHERE e.event_type = 'agent_memory_resolved' + AND e.created_at >= ? + AND r.user_id = ?`; + eventParams.push(String(userId)); + } + eventSql += ' ORDER BY e.created_at DESC LIMIT 5000'; + const [agentMemoryEvents] = (await tableExists(pool, 'h5_agent_run_events')) + ? await pool.query(eventSql, eventParams) + : [[]]; + + return auditMemoryV2Shadow({ + candidates, + memoryItems, + pgvectorMemoryIds: new Set(memoryItems.map((row) => String(row.id))), + pgvectorConfigured: false, + agentMemoryEvents, + sinceMs: window.sinceMs, + nowMs: now(), + }); + }, + + async listCandidates({ status = 'candidate', userId = null, limit = 50, offset = 0 } = {}) { + await ensureSchema(); + const store = createPersonalMemoryCandidateStore(pool, { now }); + return store.listCandidates({ status, userId, limit, offset }); + }, + + async countCandidatesByStatus() { + await ensureSchema(); + const store = createPersonalMemoryCandidateStore(pool, { now }); + return store.countByStatus(); + }, + + async reviewCandidate(id, status, { reviewedBy = null } = {}) { + await ensureSchema(); + const store = createPersonalMemoryCandidateStore(pool, { now }); + return store.reviewCandidate(id, status, { reviewedBy }); + }, + + async runAutoReview({ userId = null, limit = 200 } = {}) { + await ensureSchema(); + return runCandidateAutoReviewBatch(pool, { + userId, + limit, + reviewedBy: 'system:admin-auto-review', + now: now(), + }); + }, + }; +} diff --git a/memory-v2-candidate-auto-review.mjs b/memory-v2-candidate-auto-review.mjs new file mode 100644 index 0000000..d2cb033 --- /dev/null +++ b/memory-v2-candidate-auto-review.mjs @@ -0,0 +1,186 @@ +import { detectFalseStoreCandidate } from './memory-v2-shadow-audit.mjs'; +import { + inspectMemoryContentDurability, + resolvePersonalShadowConfig, +} from './memory-v2-personal-shadow.mjs'; +import { createPersonalMemoryCandidateStore } from './memory-v2-personal-store.mjs'; + +const AUTO_REVIEW_ACCEPT_REASONS = new Set([ + 'explicit_memory_request', + 'preference_signal', + 'goal_signal', + 'stable_fact_signal', +]); + +const AUTO_REVIEW_REJECT_REASONS = new Set([ + 'decision_signal', +]); + +export function resolveCandidateAutoReviewAction(candidate, { mode = 'canary' } = {}) { + const normalizedMode = String(mode ?? 'canary').trim().toLowerCase(); + if (normalizedMode === 'off' || normalizedMode === 'shadow') { + return { action: 'pending', reason: 'shadow_mode' }; + } + if (!candidate || String(candidate.status ?? 'candidate') !== 'candidate') { + return { action: 'pending', reason: 'not_pending' }; + } + + const content = String(candidate.content ?? ''); + const durabilityIssue = inspectMemoryContentDurability(content); + if (durabilityIssue) { + return { action: 'reject', reason: durabilityIssue }; + } + + const falseStore = detectFalseStoreCandidate(content); + if (falseStore.suspicious) { + return { action: 'reject', reason: falseStore.code ?? 'false_store' }; + } + + const policyReason = String(candidate.policyReason ?? ''); + if (AUTO_REVIEW_REJECT_REASONS.has(policyReason)) { + return { action: 'reject', reason: 'decision_signal_not_durable' }; + } + if (AUTO_REVIEW_ACCEPT_REASONS.has(policyReason)) { + return { action: 'accept', reason: 'auto_review_policy' }; + } + + return { action: 'pending', reason: 'no_auto_review_rule' }; +} + +export function resolveCandidateAutoReviewScopes(env = process.env) { + const config = resolvePersonalShadowConfig(env); + if (!config.enabled || config.effectiveMode === 'shadow') return []; + if (config.effectiveMode === 'active') return [null]; + + const rolloutMode = String(env.MEMORY_LIFECYCLE_ROLLOUT_MODE ?? 'off').trim().toLowerCase(); + const rolloutUserIds = String(env.MEMORY_LIFECYCLE_ROLLOUT_USER_IDS ?? '') + .split(/[\s,]+/u) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 1000); + + if (rolloutMode === 'canary' && rolloutUserIds.length > 0) { + return [...new Set(rolloutUserIds)]; + } + if (config.effectiveMode === 'canary') { + const agentCanaryUserIds = String(env.MEMORY_AGENT_CANARY_USER_IDS ?? '') + .split(/[\s,]+/u) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 1000); + if (agentCanaryUserIds.length > 0) return [...new Set(agentCanaryUserIds)]; + } + return []; +} + +export async function runCandidateAutoReviewBatch( + pool, + { + env = process.env, + userId = null, + limit = 100, + reviewedBy = 'system:auto-review', + dryRun = false, + now = () => Date.now(), + } = {}, +) { + if (!pool?.query) { + return { ok: false, skipped: true, reason: 'no_pool', accepted: 0, rejected: 0, pending: 0 }; + } + + const config = resolvePersonalShadowConfig(env); + if (!config.autoReviewEnabled) { + return { ok: true, skipped: true, reason: 'auto_review_disabled', accepted: 0, rejected: 0, pending: 0 }; + } + + const store = createPersonalMemoryCandidateStore(pool, { now }); + if (!store?.listCandidates || !store?.reviewCandidate) { + return { ok: false, skipped: true, reason: 'no_store', accepted: 0, rejected: 0, pending: 0 }; + } + + const items = await store.listCandidates({ + status: 'candidate', + userId, + limit: Math.max(1, Math.min(500, Number(limit) || 100)), + offset: 0, + }); + + let accepted = 0; + let rejected = 0; + let pending = 0; + const samples = []; + + for (const item of items) { + const decision = resolveCandidateAutoReviewAction(item, { mode: config.effectiveMode }); + if (decision.action === 'accept') { + if (dryRun) { + accepted += 1; + if (samples.length < 5) samples.push({ id: item.id, action: 'accepted', reason: decision.reason }); + continue; + } + const result = await store.reviewCandidate(item.id, 'accepted', { reviewedBy }); + if (result.updated) { + accepted += 1; + if (samples.length < 5) samples.push({ id: item.id, action: 'accepted', reason: decision.reason }); + } else { + pending += 1; + } + continue; + } + if (decision.action === 'reject') { + if (dryRun) { + rejected += 1; + if (samples.length < 5) samples.push({ id: item.id, action: 'rejected', reason: decision.reason }); + continue; + } + const result = await store.reviewCandidate(item.id, 'rejected', { reviewedBy }); + if (result.updated) { + rejected += 1; + if (samples.length < 5) samples.push({ id: item.id, action: 'rejected', reason: decision.reason }); + } else { + pending += 1; + } + continue; + } + pending += 1; + } + + return { + ok: true, + skipped: false, + dryRun, + mode: config.effectiveMode, + scanned: items.length, + accepted, + rejected, + pending, + samples, + }; +} + +export async function autoReviewCandidateIfPending( + pool, + candidate, + { + env = process.env, + reviewedBy = 'system:auto-review', + now = () => Date.now(), + } = {}, +) { + if (!pool?.query || !candidate?.id || String(candidate.status ?? '') !== 'candidate') { + return { reviewed: false, reason: 'not_pending' }; + } + const config = resolvePersonalShadowConfig(env); + const decision = resolveCandidateAutoReviewAction(candidate, { mode: config.effectiveMode }); + if (decision.action !== 'accept' && decision.action !== 'reject') { + return { reviewed: false, reason: decision.reason }; + } + const store = createPersonalMemoryCandidateStore(pool, { now }); + const status = decision.action === 'accept' ? 'accepted' : 'rejected'; + const result = await store.reviewCandidate(candidate.id, status, { reviewedBy }); + return { + reviewed: Boolean(result.updated), + status, + reason: decision.reason, + }; +} diff --git a/memory-v2-candidate-auto-review.test.mjs b/memory-v2-candidate-auto-review.test.mjs new file mode 100644 index 0000000..7c5132c --- /dev/null +++ b/memory-v2-candidate-auto-review.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + resolveCandidateAutoReviewAction, + resolveCandidateAutoReviewScopes, +} from './memory-v2-candidate-auto-review.mjs'; + +test('resolveCandidateAutoReviewAction accepts durable explicit and preference signals', () => { + assert.deepEqual( + resolveCandidateAutoReviewAction({ + status: 'candidate', + content: '请记住我喜欢喝美式咖啡', + policyReason: 'explicit_memory_request', + }), + { action: 'accept', reason: 'auto_review_policy' }, + ); + assert.deepEqual( + resolveCandidateAutoReviewAction({ + status: 'candidate', + content: '我偏好用 TypeScript 写前端代码', + policyReason: 'preference_signal', + }), + { action: 'accept', reason: 'auto_review_policy' }, + ); +}); + +test('resolveCandidateAutoReviewAction rejects decision signals and questions', () => { + assert.deepEqual( + resolveCandidateAutoReviewAction({ + status: 'candidate', + content: '我们已经决定采用 React 作为前端主框架', + policyReason: 'decision_signal', + }), + { action: 'reject', reason: 'decision_signal_not_durable' }, + ); + const question = resolveCandidateAutoReviewAction({ + status: 'candidate', + content: '什么是 Memory V2?', + policyReason: 'stable_fact_signal', + }); + assert.equal(question.action, 'reject'); + assert.ok(['what_is_question', 'non_durable_content'].includes(question.reason)); +}); + +test('resolveCandidateAutoReviewScopes prefers lifecycle canary users', () => { + const scopes = resolveCandidateAutoReviewScopes({ + MEMORY_CANDIDATE_ENABLED: '1', + MEMORY_CANDIDATE_MODE: 'canary', + MEMORY_LIFECYCLE_ROLLOUT_MODE: 'canary', + MEMORY_LIFECYCLE_ROLLOUT_USER_IDS: 'user-a,user-b', + }); + assert.deepEqual(scopes, ['user-a', 'user-b']); +}); diff --git a/memory-v2-candidate-repair.test.mjs b/memory-v2-candidate-repair.test.mjs new file mode 100644 index 0000000..dc51df2 --- /dev/null +++ b/memory-v2-candidate-repair.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { evaluateRepairCandidate } from './scripts/repair-memory-v2-candidates.mjs'; + +test('evaluateRepairCandidate flags agent continuation rows', () => { + const verdict = evaluateRepairCandidate({ + content: '继续修改同一个 public/durability-survey-20260727.html,保留全部现有 Page Data 绑定。', + policy_reason: 'decision_signal', + }); + assert.equal(verdict.repair, true); + assert.equal(verdict.code, 'non_durable_content'); +}); + +test('evaluateRepairCandidate keeps explicit remember rows', () => { + const verdict = evaluateRepairCandidate({ + content: '请记住我每周三下午做代码评审。', + policy_reason: 'explicit_memory_request', + }); + assert.equal(verdict.repair, false); +}); diff --git a/memory-v2-lifecycle.mjs b/memory-v2-lifecycle.mjs index 1cc7af0..42e01a5 100644 --- a/memory-v2-lifecycle.mjs +++ b/memory-v2-lifecycle.mjs @@ -57,6 +57,19 @@ function normalizeItem(row) { }; } +function normalizePromoteRawJson(value) { + if (value == null) return null; + if (typeof value === 'object') return JSON.stringify(value); + const text = String(value).trim(); + if (!text) return null; + try { + JSON.parse(text); + return text; + } catch { + return JSON.stringify({ legacyEvidence: text }); + } +} + export function createMemoryV2LifecycleService({ pool = null, env = process.env, now = () => Date.now(), logger = console } = {}) { const policy = resolveMemoryV2LifecyclePolicy(env); const metrics = { list: 0, forget: 0, expire: 0, compact: 0, promote: 0, reflect: 0, errors: 0, lastRunAt: null, lastError: null }; @@ -134,7 +147,7 @@ export function createMemoryV2LifecycleService({ pool = null, env = process.env, `INSERT IGNORE INTO ${MEMORY_TABLE} (id,user_id,label,memory_hash,memory_text,evidence_message_id,source_session_id,confidence,status,raw_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - [id, row.user_id, row.memory_type === 'semantic' ? 'knowledge' : row.memory_type, hash, row.content, null, row.session_id, row.confidence, 'active', row.evidence_json, row.created_at, now()], + [id, row.user_id, row.memory_type === 'semantic' ? 'knowledge' : row.memory_type, hash, row.content, null, row.session_id, row.confidence, 'active', normalizePromoteRawJson(row.evidence_json), row.created_at, now()], ); if (Number(result?.affectedRows ?? 0) > 0) { promoted += 1; diff --git a/memory-v2-personal-shadow.mjs b/memory-v2-personal-shadow.mjs index a215a79..31961bc 100644 --- a/memory-v2-personal-shadow.mjs +++ b/memory-v2-personal-shadow.mjs @@ -13,6 +13,13 @@ const TRIVIAL_PATTERNS = [ /^(?:你好|您好|谢谢|好的|可以|收到|再见|hi|hello|thanks)[!!。.\s]*$/i, /^(?:查一下|搜一下|看看|继续|下一步)[。.!!??\s]*$/i, ]; +const NON_DURABLE_PATTERNS = [ + /^(?:继续|接着|然后|现在请|请继续|帮我继续|重试上一轮)/u, + /(?:public\/[\w./-]+\.html|read_image|edit_file|page-data|Page Data|DURABILITY-|【Memind 任务编排】|\[Memory Context\])/iu, + /[??]\s*$/, + /^(?:什么是|解释一下|介绍一下|请问|能不能|是否可以|怎么|如何|为什么|啥是)/u, +]; +const AUTO_ACCEPT_POLICY_REASONS = new Set(['explicit_memory_request']); function readFlag(env, key, fallback = false) { const raw = env?.[key]; @@ -62,7 +69,13 @@ function stableHash(value) { function classify(text) { const rules = [ { type: 'episodic', importance: 0.95, confidence: 0.98, pattern: /(?:请记住|记住|以后要|从现在起)/i, reason: 'explicit_memory_request' }, - { type: 'episodic', importance: 0.9, confidence: 0.9, pattern: /(?:决定|确定|统一采用|必须|不允许|禁止|最终选择)/i, reason: 'decision_signal' }, + { + type: 'episodic', + importance: 0.9, + confidence: 0.9, + pattern: /(?:我(?:们)?(?:已经)?决定|最终决定|统一(?:采用|使用|改为)|从现在起必须|不再允许|永久禁止)/iu, + reason: 'decision_signal', + }, { type: 'preference', importance: 0.8, confidence: 0.88, pattern: /(?:我喜欢|我偏好|我不喜欢|我的习惯|倾向于|更希望)/i, reason: 'preference_signal' }, { type: 'goal', importance: 0.85, confidence: 0.86, pattern: /(?:长期目标|当前目标|目标是|计划要|正在建设|准备实现|希望长期)/i, reason: 'goal_signal' }, { type: 'semantic', importance: 0.75, confidence: 0.82, pattern: /(?:技术栈|架构原则|项目使用|系统采用|仓库位于|运行在)/i, reason: 'stable_fact_signal' }, @@ -77,13 +90,22 @@ function normalizeCandidateMode(value, fallback = 'shadow') { return CANDIDATE_MODES.has(normalized) ? normalized : fallback; } +export function inspectMemoryContentDurability(text) { + const normalized = normalizeText(text); + if (!normalized || normalized.length < 8) return 'too_short'; + if (TRIVIAL_PATTERNS.some((pattern) => pattern.test(normalized))) return 'trivial'; + if (NON_DURABLE_PATTERNS.some((pattern) => pattern.test(normalized))) { + return 'non_durable_content'; + } + return null; +} + export function shouldAutoAcceptCandidate(candidate, config) { if (!candidate || !config?.enabled) return false; const mode = normalizeCandidateMode(config.requestedMode, 'shadow'); if (mode === 'shadow' || mode === 'off') return false; - if (mode === 'active') return true; - if (candidate.policyReason === 'explicit_memory_request') return true; - return Number(candidate.confidence) >= Math.max(Number(config.minConfidence) || 0, 0.9); + if (config.autoAcceptAll) return true; + return AUTO_ACCEPT_POLICY_REASONS.has(String(candidate.policyReason ?? '')); } export function resolvePersonalShadowConfig(env = process.env) { @@ -98,6 +120,7 @@ export function resolvePersonalShadowConfig(env = process.env) { requestedMode, effectiveMode: enabled ? requestedMode : 'off', autoReviewEnabled: enabled && requestedMode !== 'shadow', + autoAcceptAll: readFlag(env, 'MEMORY_CANDIDATE_AUTO_ACCEPT_ALL', false), minImportance: readNumber(env, 'MEMORY_CANDIDATE_MIN_IMPORTANCE', 0.7, { min: 0, max: 1 }), minConfidence: readNumber(env, 'MEMORY_CANDIDATE_MIN_CONFIDENCE', 0.8, { min: 0, max: 1 }), maxPending: Math.round(readNumber(env, 'MEMORY_CANDIDATE_MAX_PENDING', 500, { min: 1, max: 5000 })), @@ -135,6 +158,9 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = () const text = messageText(message); if (!text || text.length < 8) return reject('too_short'); if (TRIVIAL_PATTERNS.some((pattern) => pattern.test(text))) return reject('trivial'); + if (NON_DURABLE_PATTERNS.some((pattern) => pattern.test(text))) { + return reject('non_durable_content'); + } if (config.rejectSensitive && SECRET_PATTERNS.some((pattern) => pattern.test(text))) { return reject('sensitive_content'); } @@ -205,7 +231,7 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = () const persisted = await store.saveCandidate(result.candidate, { autoAccept }); if (persisted?.inserted === false) { metrics.deduped += 1; - } else if (autoAccept) { + } else if (persisted?.status === 'accepted' || autoAccept) { result.candidate.status = 'accepted'; metrics.autoReviewed += 1; } else { @@ -229,6 +255,18 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = () autoReviewed: results.filter((result) => result.accepted && result.candidate?.status === 'accepted').length, pendingReview: results.filter((result) => result.accepted && result.candidate?.status === 'candidate').length, rejected: results.filter((result) => !result.accepted).length, + results: results.map((result) => ({ + accepted: Boolean(result.accepted), + reason: result.reason ?? null, + candidate: result.candidate + ? { + status: result.candidate.status, + content: result.candidate.content, + policyReason: result.candidate.policyReason, + memoryType: result.candidate.memoryType, + } + : null, + })), }; } catch (err) { metrics.errors += 1; diff --git a/memory-v2-personal-shadow.test.mjs b/memory-v2-personal-shadow.test.mjs index bcbe5ac..2ba3ca5 100644 --- a/memory-v2-personal-shadow.test.mjs +++ b/memory-v2-personal-shadow.test.mjs @@ -27,7 +27,7 @@ test('personal memory shadow config keeps shadow mode for manual review', () => assert.equal(config.autoReviewEnabled, false); }); -test('shouldAutoAcceptCandidate auto accepts explicit requests in canary mode', () => { +test('shouldAutoAcceptCandidate only auto accepts explicit requests unless autoAcceptAll is enabled', () => { const config = resolvePersonalShadowConfig({ MEMORY_CANDIDATE_ENABLED: '1', MEMORY_CANDIDATE_MODE: 'canary', @@ -44,15 +44,26 @@ test('shouldAutoAcceptCandidate auto accepts explicit requests in canary mode', assert.equal(shouldAutoAcceptCandidate({ policyReason: 'decision_signal', confidence: 0.9, - }, config), true); + }, config), false); + + const activeConfig = resolvePersonalShadowConfig({ + MEMORY_CANDIDATE_ENABLED: '1', + MEMORY_CANDIDATE_MODE: 'active', + MEMORY_CANDIDATE_AUTO_ACCEPT_ALL: '1', + }); + assert.equal(shouldAutoAcceptCandidate({ + policyReason: 'decision_signal', + confidence: 0.9, + }, activeConfig), true); }); -test('shadow pipeline auto accepts durable candidates in active mode', async () => { +test('shadow pipeline auto accepts durable candidates in active mode when autoAcceptAll is enabled', async () => { const saved = []; const pipeline = createPersonalMemoryShadowPipeline({ env: { MEMORY_CANDIDATE_ENABLED: '1', MEMORY_CANDIDATE_MODE: 'active', + MEMORY_CANDIDATE_AUTO_ACCEPT_ALL: '1', MEMORY_POLICY_ENABLED: '1', }, store: { @@ -75,6 +86,75 @@ test('shadow pipeline auto accepts durable candidates in active mode', async () assert.equal(pipeline.getStatus().phase, 'auto-review-v1'); }); +test('shadow pipeline keeps decision candidates pending in active mode by default', async () => { + const saved = []; + const pipeline = createPersonalMemoryShadowPipeline({ + env: { + MEMORY_CANDIDATE_ENABLED: '1', + MEMORY_CANDIDATE_MODE: 'active', + MEMORY_POLICY_ENABLED: '1', + }, + store: { + async saveCandidate(candidate, options = {}) { + saved.push({ candidate, options }); + return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' }; + }, + }, + }); + const result = await pipeline.observeWrite({ + userId: 'u1', + sessionId: 's1', + messages: [{ role: 'user', text: '我决定所有生产发布必须从完整 main 分支打包。' }], + }); + assert.equal(result.autoReviewed, 0); + assert.equal(result.pendingReview, 1); + assert.equal(saved[0].options.autoAccept, false); +}); + +test('shadow pipeline rejects agent continuation task envelopes', async () => { + const pipeline = createPersonalMemoryShadowPipeline({ + env: { + MEMORY_CANDIDATE_ENABLED: '1', + MEMORY_CANDIDATE_MODE: 'shadow', + }, + }); + const result = await pipeline.observeWrite({ + userId: 'u1', + sessionId: 's1', + messages: [{ + role: 'user', + text: '继续修改同一个 public/durability-survey-20260727.html,保留全部现有 Page Data 绑定。', + }], + }); + assert.equal(result.accepted, 0); + assert.equal(result.rejected, 1); + assert.equal(pipeline.listCandidates().length, 0); +}); + +test('shadow pipeline auto accepts explicit remember requests in canary mode', async () => { + const saved = []; + const pipeline = createPersonalMemoryShadowPipeline({ + env: { + MEMORY_CANDIDATE_ENABLED: '1', + MEMORY_CANDIDATE_MODE: 'canary', + }, + store: { + async saveCandidate(candidate, options = {}) { + saved.push({ candidate, options }); + return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' }; + }, + }, + }); + await pipeline.observeWrite({ + userId: 'u1', + sessionId: 's1', + messages: [{ role: 'user', text: '请记住我每周三下午做代码评审。' }], + }); + assert.equal(saved.length, 1); + assert.equal(saved[0].options.autoAccept, true); + assert.equal(saved[0].candidate.policyReason, 'explicit_memory_request'); +}); + test('shadow pipeline keeps low-confidence canary candidates pending review', async () => { const saved = []; const pipeline = createPersonalMemoryShadowPipeline({ @@ -232,7 +312,7 @@ test('shadow pipeline persists accepted candidates when a store is configured', assert.equal(pipeline.getStatus().persistence, 'mysql'); }); -test('Memory V2 does not wait for the shadow pipeline before returning legacy write result', async () => { +test('Memory V2 awaits shadow pipeline so personalMemory can surface in API responses', async () => { let releaseShadow; const shadowBlocked = new Promise((resolve) => { releaseShadow = resolve; }); const memory = createMemoryV2({ @@ -244,16 +324,21 @@ test('Memory V2 does not wait for the shadow pipeline before returning legacy wr }], personalShadowPipeline: { config: { enabled: true }, - observeWrite: () => shadowBlocked, + observeWrite: () => shadowBlocked.then(() => ({ results: [] })), getStatus: () => ({ enabled: true }), }, }); - const result = await Promise.race([ - memory.write({ userId: 'u1', sessionId: 's1', messages: [] }), - new Promise((_, reject) => setTimeout(() => reject(new Error('write waited for shadow')), 50)), - ]); - assert.equal(result.saved, 1); + let settled = false; + const writePromise = memory.write({ userId: 'u1', sessionId: 's1', messages: [] }).then((result) => { + settled = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(settled, false, 'write should wait for shadow pipeline'); releaseShadow(); + const result = await writePromise; + assert.equal(result.saved, 1); + assert.deepEqual(result.personalMemory, { results: [] }); }); test('Memory V2 exposes a shadow-only observation entry without calling legacy write', async () => { diff --git a/memory-v2-phase-a-ready.mjs b/memory-v2-phase-a-ready.mjs new file mode 100644 index 0000000..2b34330 --- /dev/null +++ b/memory-v2-phase-a-ready.mjs @@ -0,0 +1,96 @@ +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + +function readFlag(env, key, fallback = false) { + const raw = env?.[key]; + if (raw == null || raw === '') return fallback; + return TRUE_VALUES.has(String(raw).trim().toLowerCase()); +} + +function normalizeUserIds(value) { + return String(value ?? '') + .split(/[\s,]+/u) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function evaluateMemoryV2PhaseAReadiness(env = process.env) { + const issues = []; + const warnings = []; + const agentCanaryUserIds = normalizeUserIds(env.MEMORY_AGENT_CANARY_USER_IDS); + const lifecycleUserIds = normalizeUserIds(env.MEMORY_LIFECYCLE_ROLLOUT_USER_IDS); + const candidateMode = String(env.MEMORY_CANDIDATE_MODE ?? 'off').trim().toLowerCase(); + const injectionMode = String(env.MEMORY_AGENT_INJECTION_MODE ?? 'off').trim().toLowerCase(); + const lifecycleMode = String(env.MEMORY_LIFECYCLE_ROLLOUT_MODE ?? 'off').trim().toLowerCase(); + + if (!readFlag(env, 'MEMORY_ENABLED')) { + issues.push({ code: 'memory_disabled', message: 'MEMORY_ENABLED must be 1' }); + } + if (!readFlag(env, 'MEMORY_CANDIDATE_ENABLED')) { + issues.push({ code: 'candidate_disabled', message: 'MEMORY_CANDIDATE_ENABLED must be 1' }); + } + if (candidateMode !== 'canary') { + issues.push({ code: 'candidate_mode', message: `MEMORY_CANDIDATE_MODE must be canary (current: ${candidateMode || 'off'})` }); + } + if (readFlag(env, 'MEMORY_CANDIDATE_AUTO_ACCEPT_ALL')) { + issues.push({ code: 'auto_accept_all', message: 'MEMORY_CANDIDATE_AUTO_ACCEPT_ALL must stay 0 in Phase A' }); + } + if (!readFlag(env, 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED')) { + issues.push({ code: 'candidate_persistence', message: 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED must be 1' }); + } + if (!readFlag(env, 'MEMORY_AGENT_RESOLVE_ENABLED')) { + issues.push({ code: 'agent_resolve_disabled', message: 'MEMORY_AGENT_RESOLVE_ENABLED must be 1' }); + } + if (injectionMode !== 'canary') { + issues.push({ code: 'injection_mode', message: `MEMORY_AGENT_INJECTION_MODE must be canary (current: ${injectionMode || 'off'})` }); + } + if (agentCanaryUserIds.length === 0) { + issues.push({ code: 'agent_canary_users_missing', message: 'MEMORY_AGENT_CANARY_USER_IDS must list at least one user id' }); + } + if (!readFlag(env, 'MEMORY_LIFECYCLE_ENABLED')) { + issues.push({ code: 'lifecycle_disabled', message: 'MEMORY_LIFECYCLE_ENABLED must be 1' }); + } + if (lifecycleMode !== 'canary') { + issues.push({ code: 'lifecycle_mode', message: `MEMORY_LIFECYCLE_ROLLOUT_MODE must be canary (current: ${lifecycleMode || 'off'})` }); + } + if (lifecycleUserIds.length === 0) { + issues.push({ code: 'lifecycle_users_missing', message: 'MEMORY_LIFECYCLE_ROLLOUT_USER_IDS must list at least one user id' }); + } + if (agentCanaryUserIds.join(',') !== lifecycleUserIds.join(',')) { + issues.push({ + code: 'canary_user_mismatch', + message: 'MEMORY_AGENT_CANARY_USER_IDS and MEMORY_LIFECYCLE_ROLLOUT_USER_IDS must match exactly', + }); + } + if (!readFlag(env, 'MEMORY_PROMOTION_ENABLED')) { + issues.push({ code: 'promotion_disabled', message: 'MEMORY_PROMOTION_ENABLED must be 1' }); + } + if (readFlag(env, 'MEMORY_LIFECYCLE_FORGETTING_ENABLED')) { + issues.push({ code: 'forgetting_enabled', message: 'MEMORY_LIFECYCLE_FORGETTING_ENABLED must stay 0 in Phase A' }); + } + if (readFlag(env, 'MEMORY_LIFECYCLE_DECAY_ENABLED')) { + issues.push({ code: 'decay_enabled', message: 'MEMORY_LIFECYCLE_DECAY_ENABLED must stay 0 in Phase A' }); + } + + const pgConfigured = Boolean(String(env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim()) + && Boolean(String(env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? '').trim()); + if (!pgConfigured) { + warnings.push({ + code: 'pgvector_not_configured', + message: 'pgvector URL/embedding module not configured; recall will stay legacy-only until configured', + }); + } + + return { + ok: issues.length === 0, + issues, + warnings, + summary: { + candidateMode, + injectionMode, + lifecycleMode, + agentCanaryUserIds, + lifecycleUserIds, + pgConfigured, + }, + }; +} diff --git a/memory-v2-phase-a-ready.test.mjs b/memory-v2-phase-a-ready.test.mjs new file mode 100644 index 0000000..def8042 --- /dev/null +++ b/memory-v2-phase-a-ready.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { evaluateMemoryV2PhaseAReadiness } from './memory-v2-phase-a-ready.mjs'; + +test('evaluateMemoryV2PhaseAReadiness passes aligned canary config', () => { + const report = evaluateMemoryV2PhaseAReadiness({ + MEMORY_ENABLED: '1', + MEMORY_CANDIDATE_ENABLED: '1', + MEMORY_CANDIDATE_MODE: 'canary', + MEMORY_CANDIDATE_PERSISTENCE_ENABLED: '1', + MEMORY_CANDIDATE_AUTO_ACCEPT_ALL: '0', + MEMORY_AGENT_RESOLVE_ENABLED: '1', + MEMORY_AGENT_INJECTION_MODE: 'canary', + MEMORY_AGENT_CANARY_USER_IDS: 'user-a,user-b', + MEMORY_LIFECYCLE_ENABLED: '1', + MEMORY_LIFECYCLE_ROLLOUT_MODE: 'canary', + MEMORY_LIFECYCLE_ROLLOUT_USER_IDS: 'user-a,user-b', + MEMORY_PROMOTION_ENABLED: '1', + MEMORY_LIFECYCLE_FORGETTING_ENABLED: '0', + MEMORY_LIFECYCLE_DECAY_ENABLED: '0', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory', + MEMORY_PGVECTOR_EMBEDDING_MODULE: './scripts/embed-memory-v2-local-hash.mjs', + }); + assert.equal(report.ok, true); + assert.equal(report.warnings.length, 0); +}); + +test('evaluateMemoryV2PhaseAReadiness blocks auto accept all and user mismatch', () => { + const report = evaluateMemoryV2PhaseAReadiness({ + MEMORY_ENABLED: '1', + MEMORY_CANDIDATE_ENABLED: '1', + MEMORY_CANDIDATE_MODE: 'active', + MEMORY_CANDIDATE_AUTO_ACCEPT_ALL: '1', + MEMORY_AGENT_CANARY_USER_IDS: 'user-a', + MEMORY_LIFECYCLE_ROLLOUT_USER_IDS: 'user-b', + }); + assert.equal(report.ok, false); + assert.ok(report.issues.some((item) => item.code === 'auto_accept_all')); + assert.ok(report.issues.some((item) => item.code === 'canary_user_mismatch')); +}); diff --git a/memory-v2-product-events.mjs b/memory-v2-product-events.mjs new file mode 100644 index 0000000..9506e20 --- /dev/null +++ b/memory-v2-product-events.mjs @@ -0,0 +1,239 @@ +import { parseSinceArg } from './memory-v2-shadow-audit.mjs'; + +export const MEMORY_V2_PRODUCT_EVENT_TYPES = Object.freeze({ + CANDIDATE_SAVED: 'memory_candidate_saved', + PROMOTED: 'memory_promoted', + RESOLVED_INJECTED: 'memory_resolved_injected', + RECALL_HIT: 'memory_recall_hit', +}); + +const TABLE = 'h5_memory_v2_product_events'; + +export function buildMemoryV2ProductEventsSchemaSql({ table = TABLE } = {}) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error('Invalid product events table name'); + return `CREATE TABLE IF NOT EXISTS \`${table}\` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + user_id CHAR(36) NULL, + session_id VARCHAR(191) NULL, + run_id CHAR(36) NULL, + candidate_id VARCHAR(64) NULL, + data_json JSON NULL, + created_at BIGINT NOT NULL, + KEY idx_mv2_product_event_type_time (event_type, created_at), + KEY idx_mv2_product_event_user_time (user_id, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`; +} + +export async function ensureMemoryV2ProductEventsSchema(pool, options = {}) { + if (!pool?.query) throw new Error('Product events schema requires a MySQL pool'); + await pool.query(buildMemoryV2ProductEventsSchemaSql(options)); +} + +export async function recordMemoryV2ProductEvent( + pool, + { + eventType, + userId = null, + sessionId = null, + runId = null, + candidateId = null, + data = null, + createdAt = Date.now(), + } = {}, + { table = TABLE, now = () => Date.now() } = {}, +) { + if (!pool?.query || !eventType) return { recorded: false, reason: 'invalid_input' }; + const timestamp = Number(createdAt ?? now()) || now(); + await pool.query( + `INSERT INTO \`${table}\` (event_type, user_id, session_id, run_id, candidate_id, data_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + String(eventType), + userId == null ? null : String(userId), + sessionId == null ? null : String(sessionId), + runId == null ? null : String(runId), + candidateId == null ? null : String(candidateId), + data == null ? null : JSON.stringify(data), + timestamp, + ], + ); + return { recorded: true, eventType: String(eventType), createdAt: timestamp }; +} + +function parseEventData(raw) { + if (raw == null) return null; + if (typeof raw === 'object') return raw; + try { + return JSON.parse(String(raw)); + } catch { + return null; + } +} + +export async function tableExists(pool, tableName) { + const [rows] = await pool.query( + `SELECT 1 AS ok FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ? + LIMIT 1`, + [String(tableName)], + ); + return rows.length > 0; +} + +async function countProductEvents(pool, { sinceMs, userId, eventType, table = TABLE }) { + if (!(await tableExists(pool, table))) return 0; + const clauses = ['event_type = ?', 'created_at >= ?']; + const params = [eventType, sinceMs]; + if (userId) { + clauses.push('user_id = ?'); + params.push(String(userId)); + } + const [rows] = await pool.query( + `SELECT COUNT(*) AS count FROM \`${table}\` WHERE ${clauses.join(' AND ')}`, + params, + ); + return Number(rows[0]?.count ?? 0); +} + +async function countAgentMemoryEvents(pool, { sinceMs, userId, injectionOnly = false }) { + if (!(await tableExists(pool, 'h5_agent_run_events'))) return 0; + const clauses = ["e.event_type = 'agent_memory_resolved'", 'e.created_at >= ?']; + const params = [sinceMs]; + if (userId) { + clauses.push('r.user_id = ?'); + params.push(String(userId)); + } + if (injectionOnly) { + clauses.push(`JSON_EXTRACT(e.data_json, '$.injectionEnabled') = true`); + } + const [rows] = await pool.query( + `SELECT COUNT(*) AS count + FROM h5_agent_run_events e + INNER JOIN h5_agent_runs r ON r.id = e.run_id + WHERE ${clauses.join(' AND ')}`, + params, + ); + return Number(rows[0]?.count ?? 0); +} + +async function countAgentRecallHits(pool, { sinceMs, userId }) { + if (!(await tableExists(pool, 'h5_agent_run_events'))) return 0; + const clauses = [ + "e.event_type = 'agent_memory_resolved'", + 'e.created_at >= ?', + `JSON_EXTRACT(e.data_json, '$.injectionEnabled') = true`, + `CAST(JSON_UNQUOTE(JSON_EXTRACT(e.data_json, '$.memoryCount')) AS UNSIGNED) > 0`, + ]; + const params = [sinceMs]; + if (userId) { + clauses.push('r.user_id = ?'); + params.push(String(userId)); + } + const [rows] = await pool.query( + `SELECT COUNT(*) AS count + FROM h5_agent_run_events e + INNER JOIN h5_agent_runs r ON r.id = e.run_id + WHERE ${clauses.join(' AND ')}`, + params, + ); + return Number(rows[0]?.count ?? 0); +} + +async function countCandidatesCreated(pool, { sinceMs, userId }) { + if (!(await tableExists(pool, 'h5_memory_v2_candidates'))) return 0; + const clauses = ['created_at >= ?']; + const params = [sinceMs]; + if (userId) { + clauses.push('user_id = ?'); + params.push(String(userId)); + } + const [rows] = await pool.query( + `SELECT COUNT(*) AS count FROM h5_memory_v2_candidates WHERE ${clauses.join(' AND ')}`, + params, + ); + return Number(rows[0]?.count ?? 0); +} + +async function countMemoryItemsCreated(pool, { sinceMs, userId }) { + if (!(await tableExists(pool, 'h5_user_memory_items'))) return 0; + const clauses = ['created_at >= ?', "status = 'active'"]; + const params = [sinceMs]; + if (userId) { + clauses.push('user_id = ?'); + params.push(String(userId)); + } + const [rows] = await pool.query( + `SELECT COUNT(*) AS count FROM h5_user_memory_items WHERE ${clauses.join(' AND ')}`, + params, + ); + return Number(rows[0]?.count ?? 0); +} + +export async function aggregateMemoryV2ProductMetrics( + pool, + { since = '7d', userId = null, now = Date.now() } = {}, +) { + if (!pool?.query) throw new Error('Product metrics require a MySQL pool'); + const window = parseSinceArg(since, now); + const sinceMs = window.sinceMs; + + const [ + candidateSavedEvents, + promotedEvents, + injectedEvents, + recallHitEvents, + candidateSavedFallback, + promotedFallback, + injectedFallback, + recallHitFallback, + ] = await Promise.all([ + countProductEvents(pool, { + sinceMs, + userId, + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.CANDIDATE_SAVED, + }), + countProductEvents(pool, { + sinceMs, + userId, + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.PROMOTED, + }), + countProductEvents(pool, { + sinceMs, + userId, + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.RESOLVED_INJECTED, + }), + countProductEvents(pool, { + sinceMs, + userId, + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.RECALL_HIT, + }), + countCandidatesCreated(pool, { sinceMs, userId }), + countMemoryItemsCreated(pool, { sinceMs, userId }), + countAgentMemoryEvents(pool, { sinceMs, userId, injectionOnly: true }), + countAgentRecallHits(pool, { sinceMs, userId }), + ]); + + const events = { + memory_candidate_saved: candidateSavedEvents || candidateSavedFallback, + memory_promoted: promotedEvents || promotedFallback, + memory_resolved_injected: injectedEvents || injectedFallback, + memory_recall_hit: recallHitEvents || recallHitFallback, + }; + + return { + window: { + since: window.label, + sinceMs, + untilMs: now, + }, + userId: userId ? String(userId) : null, + events, + sources: { + memory_candidate_saved: candidateSavedEvents > 0 ? 'product_events' : 'candidates_table', + memory_promoted: promotedEvents > 0 ? 'product_events' : 'memory_items_table', + memory_resolved_injected: injectedEvents > 0 ? 'product_events' : 'agent_run_events', + memory_recall_hit: recallHitEvents > 0 ? 'product_events' : 'agent_run_events', + }, + }; +} diff --git a/memory-v2-product-events.test.mjs b/memory-v2-product-events.test.mjs new file mode 100644 index 0000000..e8abca4 --- /dev/null +++ b/memory-v2-product-events.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MEMORY_V2_PRODUCT_EVENT_TYPES, + aggregateMemoryV2ProductMetrics, + recordMemoryV2ProductEvent, +} from './memory-v2-product-events.mjs'; + +test('recordMemoryV2ProductEvent inserts typed rows', async () => { + const inserts = []; + const pool = { + async query(sql, params) { + inserts.push({ sql, params }); + return [{ insertId: 1 }]; + }, + }; + const result = await recordMemoryV2ProductEvent(pool, { + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.CANDIDATE_SAVED, + userId: 'user-1', + sessionId: 'session-1', + candidateId: 'cand-1', + data: { policyReason: 'explicit_memory_request' }, + createdAt: 1000, + }); + assert.equal(result.recorded, true); + assert.match(inserts[0].sql, /h5_memory_v2_product_events/); + assert.equal(inserts[0].params[0], 'memory_candidate_saved'); +}); + +test('aggregateMemoryV2ProductMetrics falls back to legacy tables', async () => { + const pool = { + async query(sql, params) { + if (String(sql).includes('information_schema.tables')) { + if (String(params[0]).includes('product_events')) return [[]]; + return [[{ ok: 1 }]]; + } + if (String(sql).includes('h5_memory_v2_candidates')) return [[{ count: 12 }]]; + if (String(sql).includes('h5_user_memory_items')) return [[{ count: 8 }]]; + if (String(sql).includes('agent_memory_resolved') && String(sql).includes('memoryCount')) { + return [[{ count: 5 }]]; + } + if (String(sql).includes('agent_memory_resolved')) return [[{ count: 7 }]]; + return [[]]; + }, + }; + const metrics = await aggregateMemoryV2ProductMetrics(pool, { + since: '7d', + now: 1_700_000_000_000, + }); + assert.equal(metrics.events.memory_candidate_saved, 12); + assert.equal(metrics.events.memory_promoted, 8); + assert.equal(metrics.events.memory_resolved_injected, 7); + assert.equal(metrics.events.memory_recall_hit, 5); + assert.equal(metrics.sources.memory_candidate_saved, 'candidates_table'); +}); diff --git a/memory-v2-recall-benchmark.mjs b/memory-v2-recall-benchmark.mjs new file mode 100644 index 0000000..76e3ee0 --- /dev/null +++ b/memory-v2-recall-benchmark.mjs @@ -0,0 +1,171 @@ +import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export function resolveEmbeddingModuleSpecifier(specifier, baseDir = process.cwd()) { + const value = String(specifier ?? '').trim(); + if (!value) return null; + if (value.startsWith('.') || value.startsWith('/')) { + return pathToFileURL(path.resolve(baseDir, value)).href; + } + return value; +} + +export const RECALL_BENCHMARK_CASES = [ + { + id: 'chinese-paraphrase-code', + memory: '用户的记忆召回灰度测试代号是 MEM-RECALL-NEW', + query: '我之前让你记住的记忆召回灰度测试代号是什么?请只回答完整代号。', + expectMatch: /MEM-RECALL-NEW/, + }, + { + id: 'explicit-weekly-review', + memory: '用户每周三下午做代码评审', + query: '我什么时候做代码评审?', + expectMatch: /周三/, + }, + { + id: 'preference-pgvector', + memory: '用户偏好使用 pgvector 做语义检索', + query: '我之前说过向量检索偏好用什么?', + expectMatch: /pgvector/, + }, + { + id: 'goal-long-term', + memory: '用户的长期目标是建设一个持续成长的 Personal Agent', + query: '我的长期目标是什么?', + expectMatch: /Personal Agent/, + }, +]; + +function buildDistractorRows(targetMemory, marker) { + return [ + { + id: 901, + content: '用户以前关注贵州旅游攻略', + type: 'interest', + score: 0.91, + created_at: '2026-06-01T00:00:00.000Z', + updated_at: '2026-06-01T00:00:00.000Z', + }, + { + id: 902, + content: targetMemory, + type: 'fact', + score: -0.49, + created_at: '2026-07-22T00:00:00.000Z', + updated_at: '2026-07-22T00:00:00.000Z', + }, + { + id: 903, + content: `无关占位记忆 ${marker}`, + type: 'fact', + score: 0.3, + created_at: '2026-07-21T00:00:00.000Z', + updated_at: '2026-07-21T00:00:00.000Z', + }, + ]; +} + +export async function probeEmbeddingModule({ + moduleSpecifier, + importModule = (specifier) => import(specifier), + sampleText = 'memory recall benchmark probe', +} = {}) { + const modulePath = String(moduleSpecifier ?? '').trim(); + if (!modulePath) { + return { configured: false, reason: 'embedding_module_not_configured' }; + } + try { + const resolved = resolveEmbeddingModuleSpecifier(modulePath); + if (!resolved) { + return { configured: false, reason: 'embedding_module_not_configured' }; + } + const imported = await importModule(resolved); + const embedQuery = imported?.embedQuery ?? imported?.default; + if (typeof embedQuery !== 'function') { + return { configured: false, modulePath, reason: 'embed_query_export_missing' }; + } + const vector = await embedQuery(sampleText); + if (!Array.isArray(vector) || vector.length === 0) { + return { configured: false, modulePath, reason: 'empty_embedding_vector' }; + } + return { + configured: true, + modulePath, + dimensions: vector.length, + samplePreview: vector.slice(0, 5), + }; + } catch (err) { + return { + configured: false, + modulePath, + reason: 'embedding_module_load_failed', + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function runRecallBenchmarkCase({ + testCase, + embedQuery, + limit = 5, +}) { + const marker = `CASE-${testCase.id}`; + const backend = createPgvectorMemoryBackend({ + enabled: true, + embedQuery, + pool: { + async query() { + return { rows: buildDistractorRows(testCase.memory, marker) }; + }, + }, + }); + const result = await backend.resolve({ + userId: 'benchmark-user', + query: testCase.query, + limit, + }); + const texts = result.memories.map((item) => item.text); + const hitIndex = texts.findIndex((text) => testCase.expectMatch.test(text)); + return { + id: testCase.id, + hit: hitIndex >= 0, + hitRank: hitIndex >= 0 ? hitIndex + 1 : null, + topText: texts[0] ?? null, + returned: texts.length, + }; +} + +export async function runRecallBenchmark({ + cases = RECALL_BENCHMARK_CASES, + embedQuery, + limit = 5, +} = {}) { + if (typeof embedQuery !== 'function') { + throw new Error('runRecallBenchmark requires embedQuery'); + } + const results = []; + for (const testCase of cases) { + results.push(await runRecallBenchmarkCase({ testCase, embedQuery, limit })); + } + const hits = results.filter((item) => item.hit).length; + return { + limit, + caseCount: cases.length, + recallAtK: cases.length > 0 ? hits / cases.length : 0, + hits, + misses: cases.length - hits, + results, + }; +} + +export function summarizeRecallBenchmark(report) { + return { + recallAt5: report.recallAtK, + hits: report.hits, + misses: report.misses, + caseCount: report.caseCount, + failedCases: report.results.filter((item) => !item.hit).map((item) => item.id), + }; +} diff --git a/memory-v2-recall-benchmark.test.mjs b/memory-v2-recall-benchmark.test.mjs new file mode 100644 index 0000000..e6a7395 --- /dev/null +++ b/memory-v2-recall-benchmark.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + RECALL_BENCHMARK_CASES, + probeEmbeddingModule, + runRecallBenchmark, + summarizeRecallBenchmark, +} from './memory-v2-recall-benchmark.mjs'; + +test('probeEmbeddingModule reports local hash embedding dimensions', async () => { + const probe = await probeEmbeddingModule({ + moduleSpecifier: './scripts/embed-memory-v2-local-hash.mjs', + importModule: (specifier) => import(specifier), + }); + assert.equal(probe.configured, true); + assert.equal(probe.dimensions, 3); +}); + +test('runRecallBenchmark hits hybrid recall cases with local hash embedding', async () => { + const { embedQuery } = await import('./scripts/embed-memory-v2-local-hash.mjs'); + const report = await runRecallBenchmark({ + cases: RECALL_BENCHMARK_CASES, + embedQuery, + limit: 5, + }); + const summary = summarizeRecallBenchmark(report); + assert.equal(report.caseCount, RECALL_BENCHMARK_CASES.length); + assert.ok(summary.recallAt5 >= 0.75, `expected recall@5 >= 0.75, got ${summary.recallAt5}`); +}); diff --git a/memory-v2-runtime.mjs b/memory-v2-runtime.mjs index 617a8a8..4d240c9 100644 --- a/memory-v2-runtime.mjs +++ b/memory-v2-runtime.mjs @@ -9,6 +9,15 @@ import { createPersonalMemoryCandidateStore, ensurePersonalMemoryCandidateSchema, } from './memory-v2-personal-store.mjs'; +import { + autoReviewCandidateIfPending, + runCandidateAutoReviewBatch, +} from './memory-v2-candidate-auto-review.mjs'; +import { + MEMORY_V2_PRODUCT_EVENT_TYPES, + ensureMemoryV2ProductEventsSchema, + recordMemoryV2ProductEvent, +} from './memory-v2-product-events.mjs'; import { createMem0HttpClient, createMem0MemoryBackend } from './memory-v2-mem0.mjs'; import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs'; import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs'; @@ -416,7 +425,42 @@ export async function createMemoryV2Runtime({ if (readFlag(env, 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', false) && mysqlPool?.query) { try { await ensurePersonalMemoryCandidateSchema(mysqlPool); + await ensureMemoryV2ProductEventsSchema(mysqlPool); personalCandidateStore = createPersonalMemoryCandidateStore(mysqlPool); + const originalSaveCandidate = personalCandidateStore.saveCandidate.bind(personalCandidateStore); + personalCandidateStore.saveCandidate = async (candidate, options = {}) => { + const persisted = await originalSaveCandidate(candidate, options); + if (persisted?.inserted) { + void recordMemoryV2ProductEvent(mysqlPool, { + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.CANDIDATE_SAVED, + userId: candidate.userId, + sessionId: candidate.sessionId, + candidateId: candidate.id, + data: { + policyReason: candidate.policyReason, + memoryType: candidate.memoryType, + status: persisted.status, + autoAccept: Boolean(options.autoAccept), + }, + }).catch(() => {}); + if (persisted.status === 'candidate') { + try { + const reviewed = await autoReviewCandidateIfPending(mysqlPool, { + ...candidate, + status: 'candidate', + }, { env }); + if (reviewed.reviewed && reviewed.status) { + persisted.status = reviewed.status; + } + } catch (err) { + logger?.warn?.( + `[memory-v2] candidate auto-review skipped: ${err instanceof Error ? err.message : err}`, + ); + } + } + } + return persisted; + }; } catch (err) { logger?.warn?.( `[memory-v2] candidate persistence unavailable: ${err instanceof Error ? err.message : err}`, @@ -444,6 +488,11 @@ export async function createMemoryV2Runtime({ try { for (const userId of lifecycleWorkerScopes) { const input = userId == null ? {} : { userId }; + await runCandidateAutoReviewBatch(mysqlPool, { + env, + userId, + limit: 200, + }); await lifecycle.expire(input); await lifecycle.compact(input); await lifecycle.promote(input); @@ -518,6 +567,14 @@ export async function createMemoryV2Runtime({ lifecycle.promote = async (input = {}) => { const result = await originalLifecyclePromote(input); if (Number(result?.promoted ?? 0) > 0) { + void recordMemoryV2ProductEvent(mysqlPool, { + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.PROMOTED, + userId: input?.userId ?? null, + data: { + promoted: result.promoted, + promotedUserIds: result.promotedUserIds ?? [], + }, + }).catch(() => {}); const promotedUserIds = Array.isArray(result?.promotedUserIds) ? result.promotedUserIds : input?.userId ? [input.userId] : []; diff --git a/memory-v2-runtime.test.mjs b/memory-v2-runtime.test.mjs index a9bafac..6acc342 100644 --- a/memory-v2-runtime.test.mjs +++ b/memory-v2-runtime.test.mjs @@ -105,7 +105,10 @@ test('createMemoryV2Runtime ensures candidate schema before enabling MySQL persi messages: [{ role: 'user', content: '请记住我偏好使用简洁的中文回答' }], }); assert.equal(observed.accepted, 1); - assert.equal(calls[1].sql.startsWith('INSERT'), true); + assert.ok( + calls.some((call) => call.sql.startsWith('INSERT')), + `expected candidate INSERT, got: ${calls.map((call) => call.sql.slice(0, 40)).join(' | ')}`, + ); await memory.close(); }); diff --git a/memory-v2-shadow-audit.mjs b/memory-v2-shadow-audit.mjs new file mode 100644 index 0000000..91b7cf0 --- /dev/null +++ b/memory-v2-shadow-audit.mjs @@ -0,0 +1,278 @@ +const MS_PER_HOUR = 3600000; +const MS_PER_DAY = 86400000; + +const FALSE_STORE_PATTERNS = [ + { + code: 'question_mark', + pattern: /[??]\s*$/, + message: 'Content ends with a question mark', + }, + { + code: 'what_is_question', + pattern: /^(?:什么是|解释一下|介绍一下|请问|能不能|是否可以|怎么|如何|为什么|啥是)/u, + message: 'Content looks like a question rather than a fact', + }, + { + code: 'imperative_only', + pattern: /^(?:查一下|搜一下|看看|继续|下一步|帮我|请帮我)/u, + message: 'Content looks like an instruction without durable facts', + }, + { + code: 'trivial_greeting', + pattern: /^(?:你好|您好|谢谢|好的|可以|收到|再见|hi|hello|thanks)[!!。.\s]*$/iu, + message: 'Content is a trivial greeting or acknowledgement', + }, + { + code: 'routing_leak', + pattern: /(?:\[Memory Context\]|【Memind 任务编排】|agent_orchestration)/u, + message: 'Content contains internal routing or memory envelope text', + }, +]; + +export function parseSinceArg(value, now = Date.now()) { + const raw = String(value ?? '7d').trim().toLowerCase(); + const match = raw.match(/^(\d+)(h|d|w)$/); + if (!match) { + throw new Error(`Invalid --since value "${value}". Expected formats like 24h, 7d, 2w.`); + } + const amount = Number(match[1]); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error(`Invalid --since value "${value}". Amount must be positive.`); + } + const unit = match[2]; + const multiplier = unit === 'h' ? MS_PER_HOUR : unit === 'w' ? MS_PER_DAY * 7 : MS_PER_DAY; + return { + sinceMs: now - amount * multiplier, + label: raw, + }; +} + +export function detectFalseStoreCandidate(content) { + const text = String(content ?? '').replace(/\s+/g, ' ').trim(); + if (!text) { + return { suspicious: true, code: 'empty_content', message: 'Content is empty' }; + } + for (const rule of FALSE_STORE_PATTERNS) { + if (rule.pattern.test(text)) { + return { suspicious: true, code: rule.code, message: rule.message }; + } + } + return { suspicious: false }; +} + +function normalizeCandidate(row) { + return { + id: String(row.id), + userId: String(row.user_id ?? row.userId), + sessionId: row.session_id == null ? null : String(row.session_id ?? row.sessionId), + memoryType: String(row.memory_type ?? row.memoryType ?? ''), + content: String(row.content ?? ''), + status: String(row.status ?? ''), + policyReason: String(row.policy_reason ?? row.policyReason ?? ''), + confidence: Number(row.confidence ?? 0), + importance: Number(row.importance ?? 0), + createdAt: Number(row.created_at ?? row.createdAt ?? 0), + updatedAt: Number(row.updated_at ?? row.updatedAt ?? 0), + }; +} + +function normalizeMemoryItem(row) { + return { + id: String(row.id), + userId: String(row.user_id ?? row.userId), + label: String(row.label ?? ''), + content: String(row.memory_text ?? row.content ?? ''), + status: String(row.status ?? ''), + createdAt: Number(row.created_at ?? row.createdAt ?? 0), + updatedAt: Number(row.updated_at ?? row.updatedAt ?? 0), + }; +} + +function countByField(items, field) { + const counts = new Map(); + for (const item of items) { + const key = String(item[field] ?? 'unknown'); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return Object.fromEntries([...counts.entries()].sort((a, b) => b[1] - a[1])); +} + +function topEntries(counts, limit = 10) { + return Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([key, count]) => ({ key, count })); +} + +export function auditMemoryV2Shadow({ + candidates = [], + memoryItems = [], + pgvectorMemoryIds = new Set(), + pgvectorConfigured = false, + agentMemoryEvents = [], + sinceMs = 0, + nowMs = Date.now(), + falseStoreSampleLimit = 20, + pgvectorLagSampleLimit = 20, +} = {}) { + const normalizedCandidates = candidates.map(normalizeCandidate); + const normalizedItems = memoryItems.map(normalizeMemoryItem); + const inWindowCandidates = normalizedCandidates.filter((item) => item.createdAt >= sinceMs); + const inWindowItems = normalizedItems.filter((item) => item.updatedAt >= sinceMs); + + const candidateStatusCounts = countByField(inWindowCandidates, 'status'); + const policyReasonCounts = countByField(inWindowCandidates, 'policyReason'); + const memoryTypeCounts = countByField(inWindowCandidates, 'memoryType'); + + const suspiciousCandidates = inWindowCandidates + .filter((item) => ['candidate', 'accepted'].includes(item.status)) + .map((item) => { + const verdict = detectFalseStoreCandidate(item.content); + if (!verdict.suspicious) return null; + return { + id: item.id, + userId: item.userId, + status: item.status, + policyReason: item.policyReason, + code: verdict.code, + message: verdict.message, + contentPreview: item.content.slice(0, 120), + createdAt: item.createdAt, + }; + }) + .filter(Boolean); + + const activeItems = normalizedItems.filter((item) => item.status === 'active'); + const pgvectorLagUsers = new Map(); + const pgvectorMissingSamples = []; + if (pgvectorConfigured) { + for (const item of activeItems) { + if (pgvectorMemoryIds.has(item.id)) continue; + pgvectorLagUsers.set(item.userId, (pgvectorLagUsers.get(item.userId) ?? 0) + 1); + if (pgvectorMissingSamples.length < pgvectorLagSampleLimit) { + pgvectorMissingSamples.push({ + memoryId: item.id, + userId: item.userId, + label: item.label, + updatedAt: item.updatedAt, + contentPreview: item.content.slice(0, 120), + }); + } + } + } + + const resolvedEvents = agentMemoryEvents.filter((event) => { + const createdAt = Number(event.created_at ?? event.createdAt ?? 0); + return createdAt >= sinceMs; + }); + const resolvedWithHits = resolvedEvents.filter((event) => { + const data = event.data_json ?? event.data ?? {}; + const count = Number(data.count ?? data.memoryCount ?? data.memories?.length ?? 0); + return count > 0; + }); + + const acceptedCount = candidateStatusCounts.accepted ?? 0; + const candidateCount = candidateStatusCounts.candidate ?? 0; + const reviewedCount = acceptedCount + (candidateStatusCounts.rejected ?? 0); + const autoAcceptRate = reviewedCount > 0 ? acceptedCount / reviewedCount : null; + const falseStoreRate = inWindowCandidates.length > 0 + ? suspiciousCandidates.length / inWindowCandidates.length + : null; + const resolveHitRate = resolvedEvents.length > 0 + ? resolvedWithHits.length / resolvedEvents.length + : null; + + const thresholds = { + falseStoreRateMax: 0.05, + pgvectorLagUsersMax: 0, + }; + + const issues = []; + if (falseStoreRate != null && falseStoreRate > thresholds.falseStoreRateMax) { + issues.push({ + code: 'false_store_rate_high', + message: `False-store sample rate ${(falseStoreRate * 100).toFixed(1)}% exceeds ${thresholds.falseStoreRateMax * 100}%`, + }); + } + if (pgvectorConfigured && pgvectorLagUsers.size > thresholds.pgvectorLagUsersMax) { + issues.push({ + code: 'pgvector_sync_lag', + message: `${pgvectorLagUsers.size} user(s) have active memories missing from pgvector`, + }); + } + + return { + ok: issues.length === 0, + generatedAt: nowMs, + window: { + sinceMs, + untilMs: nowMs, + candidateCount: inWindowCandidates.length, + memoryItemCount: inWindowItems.length, + agentMemoryResolvedEvents: resolvedEvents.length, + }, + summary: { + candidateStatusCounts, + policyReasonCounts, + memoryTypeCounts, + autoAcceptRate, + falseStoreRate, + resolveHitRate, + suspiciousCandidateCount: suspiciousCandidates.length, + pgvectorLagUserCount: pgvectorConfigured ? pgvectorLagUsers.size : null, + activeMemoryCount: activeItems.length, + pgvectorMemoryCount: pgvectorConfigured ? pgvectorMemoryIds.size : null, + }, + topPolicyReasons: topEntries(policyReasonCounts), + pgvectorLagUsers: [...pgvectorLagUsers.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, pgvectorLagSampleLimit) + .map(([userId, missingCount]) => ({ userId, missingCount })), + sampleFalseStores: suspiciousCandidates.slice(0, falseStoreSampleLimit), + pgvectorMissingSamples, + issues, + }; +} + +export function formatMemoryV2ShadowAuditReport(report) { + const lines = [ + `memory v2 shadow audit: ${report.ok ? 'ok' : 'issues found'}`, + `window: ${new Date(report.window.sinceMs).toISOString()} -> ${new Date(report.window.untilMs).toISOString()}`, + `candidates: ${report.window.candidateCount}`, + `memory items updated: ${report.window.memoryItemCount}`, + `agent_memory_resolved events: ${report.window.agentMemoryResolvedEvents}`, + '', + 'summary:', + JSON.stringify(report.summary, null, 2), + ]; + + if (report.topPolicyReasons.length > 0) { + lines.push('', 'top policy reasons:'); + for (const item of report.topPolicyReasons) { + lines.push(`- ${item.key}: ${item.count}`); + } + } + + if (report.pgvectorLagUsers.length > 0) { + lines.push('', 'pgvector lag users:'); + for (const item of report.pgvectorLagUsers) { + lines.push(`- ${item.userId}: ${item.missingCount} missing`); + } + } + + if (report.sampleFalseStores.length > 0) { + lines.push('', 'sample false stores:'); + for (const item of report.sampleFalseStores) { + lines.push(`- [${item.code}] ${item.id} (${item.policyReason}): ${item.contentPreview}`); + } + } + + if (report.issues.length > 0) { + lines.push('', 'issues:'); + for (const item of report.issues) { + lines.push(`- ${item.code}: ${item.message}`); + } + } + + return lines.join('\n'); +} diff --git a/memory-v2-shadow-audit.test.mjs b/memory-v2-shadow-audit.test.mjs new file mode 100644 index 0000000..4e18fb2 --- /dev/null +++ b/memory-v2-shadow-audit.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + auditMemoryV2Shadow, + detectFalseStoreCandidate, + formatMemoryV2ShadowAuditReport, + parseSinceArg, +} from './memory-v2-shadow-audit.mjs'; + +test('parseSinceArg supports hour/day/week windows', () => { + const now = Date.parse('2026-07-31T12:00:00.000Z'); + assert.equal(parseSinceArg('24h', now).sinceMs, now - 86400000); + assert.equal(parseSinceArg('7d', now).sinceMs, now - 7 * 86400000); + assert.equal(parseSinceArg('2w', now).sinceMs, now - 14 * 86400000); +}); + +test('detectFalseStoreCandidate flags questions and routing leaks', () => { + assert.equal(detectFalseStoreCandidate('SSE 是什么?').suspicious, true); + assert.equal(detectFalseStoreCandidate('请记住我每周三做代码评审').suspicious, false); + assert.equal( + detectFalseStoreCandidate('[Memory Context] 旧记忆').code, + 'routing_leak', + ); +}); + +test('auditMemoryV2Shadow summarizes candidates and pgvector lag', () => { + const nowMs = Date.parse('2026-07-31T12:00:00.000Z'); + const sinceMs = nowMs - 7 * 86400000; + const report = auditMemoryV2Shadow({ + sinceMs, + nowMs, + candidates: [ + { + id: 'c1', + user_id: 'u1', + memory_type: 'episodic', + content: '请记住我每周三下午做代码评审', + status: 'accepted', + policy_reason: 'explicit_memory_request', + confidence: 0.98, + importance: 0.95, + created_at: sinceMs + 1000, + updated_at: sinceMs + 1000, + }, + { + id: 'c2', + user_id: 'u1', + memory_type: 'semantic', + content: 'SSE 是什么?', + status: 'candidate', + policy_reason: 'stable_fact_signal', + confidence: 0.82, + importance: 0.75, + created_at: sinceMs + 2000, + updated_at: sinceMs + 2000, + }, + ], + memoryItems: [ + { + id: 'm1', + user_id: 'u1', + label: 'fact', + memory_text: '每周三下午做代码评审', + status: 'active', + created_at: sinceMs + 3000, + updated_at: sinceMs + 3000, + }, + { + id: 'm2', + user_id: 'u2', + label: 'fact', + memory_text: '使用 pgvector 做语义检索', + status: 'active', + created_at: sinceMs + 4000, + updated_at: sinceMs + 4000, + }, + ], + pgvectorMemoryIds: new Set(['m1']), + pgvectorConfigured: true, + agentMemoryEvents: [ + { + event_type: 'agent_memory_resolved', + created_at: sinceMs + 5000, + data_json: { count: 2 }, + }, + { + event_type: 'agent_memory_resolved', + created_at: sinceMs + 6000, + data_json: { count: 0 }, + }, + ], + }); + + assert.equal(report.window.candidateCount, 2); + assert.equal(report.summary.suspiciousCandidateCount, 1); + assert.equal(report.pgvectorLagUsers.length, 1); + assert.equal(report.pgvectorLagUsers[0].userId, 'u2'); + assert.equal(report.summary.resolveHitRate, 0.5); + assert.equal(report.ok, false); + assert.match(formatMemoryV2ShadowAuditReport(report), /pgvector lag users/); +}); diff --git a/memory-v2-user-feedback.mjs b/memory-v2-user-feedback.mjs new file mode 100644 index 0000000..ed55f2e --- /dev/null +++ b/memory-v2-user-feedback.mjs @@ -0,0 +1,54 @@ +export function summarizePersonalMemoryObservation(observation) { + if (!observation || observation.skipped || !observation.enabled) { + return { savedPreviews: [], autoReviewed: 0, pendingReview: 0 }; + } + const savedPreviews = (observation.results ?? []) + .filter((item) => item?.accepted && item?.candidate?.status === 'accepted') + .map((item) => ({ + preview: String(item.candidate.content ?? '').slice(0, 60), + policyReason: String(item.candidate.policyReason ?? ''), + memoryType: String(item.candidate.memoryType ?? ''), + })) + .slice(0, 3); + return { + savedPreviews, + autoReviewed: Number(observation.autoReviewed ?? 0), + pendingReview: Number(observation.pendingReview ?? 0), + }; +} + +export function formatPersonalMemorySavedNotice(summary) { + if (!summary?.savedPreviews?.length) return null; + const preview = summary.savedPreviews[0]?.preview; + if (!preview) return null; + return `已记住:${preview}${preview.length >= 60 ? '…' : ''}`; +} + +export function formatMemoryRecallNotice(count) { + const safeCount = Number(count ?? 0); + if (!Number.isFinite(safeCount) || safeCount <= 0) return null; + return `参考了你的 ${safeCount} 条记忆`; +} + +const INTERNAL_MEMORY_PREVIEW_RES = [ + /^\[Memory Context\]/i, + /^\[用户身份\]/, + /^【TKMind 路由提示】/, + /^【Memind 任务编排】/, + /^\[MindSpace 上下文\]/, +]; + +export function buildMemoryRecallPreviews(memories, { limit = 5, maxLength = 80 } = {}) { + if (!Array.isArray(memories)) return []; + const previews = []; + for (const item of memories) { + const raw = String(item?.text ?? item?.content ?? '').trim(); + if (!raw) continue; + if (INTERNAL_MEMORY_PREVIEW_RES.some((pattern) => pattern.test(raw))) continue; + if (/\[Memory Context\]/i.test(raw)) continue; + const clipped = raw.slice(0, maxLength); + previews.push(clipped.length < raw.length ? `${clipped}…` : clipped); + if (previews.length >= limit) break; + } + return previews; +} diff --git a/memory-v2-user-feedback.test.mjs b/memory-v2-user-feedback.test.mjs new file mode 100644 index 0000000..8392f82 --- /dev/null +++ b/memory-v2-user-feedback.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + formatMemoryRecallNotice, + formatPersonalMemorySavedNotice, + summarizePersonalMemoryObservation, + buildMemoryRecallPreviews, +} from './memory-v2-user-feedback.mjs'; + +test('summarizePersonalMemoryObservation extracts accepted previews', () => { + const summary = summarizePersonalMemoryObservation({ + enabled: true, + autoReviewed: 1, + results: [{ + accepted: true, + candidate: { + status: 'accepted', + content: '请记住我每周三下午做代码评审', + policyReason: 'explicit_memory_request', + memoryType: 'episodic', + }, + }], + }); + assert.equal(summary.savedPreviews.length, 1); + assert.match(formatPersonalMemorySavedNotice(summary) ?? '', /已记住:请记住我每周三下午做代码评审/); +}); + +test('formatMemoryRecallNotice formats recall count', () => { + assert.equal(formatMemoryRecallNotice(2), '参考了你的 2 条记忆'); + assert.equal(formatMemoryRecallNotice(0), null); +}); + +test('buildMemoryRecallPreviews strips internal memory markers', () => { + const previews = buildMemoryRecallPreviews([ + { text: '[Memory Context] secret' }, + { text: '我喜欢喝美式咖啡' }, + { text: '每周三下午做代码评审,这是一个比较长的偏好描述用来测试截断逻辑是否正常工作' }, + ], { limit: 3, maxLength: 12 }); + assert.deepEqual(previews, [ + '我喜欢喝美式咖啡', + '每周三下午做代码评审,这…', + ]); +}); diff --git a/memory-v2.mjs b/memory-v2.mjs index 0ebacbb..4cc3f0a 100644 --- a/memory-v2.mjs +++ b/memory-v2.mjs @@ -310,12 +310,15 @@ export function createMemoryV2({ if (!backend?.write) return skippedWriteResult('no_backend'); try { const result = await backend.write(input); + let personalMemory = null; if (shadowPipeline?.config?.enabled) { - void Promise.resolve(shadowPipeline.observeWrite(input)).catch((err) => { + try { + personalMemory = await shadowPipeline.observeWrite(input); + } catch (err) { logger?.warn?.( `[memory-v2] personal shadow pipeline skipped: ${err instanceof Error ? err.message : err}`, ); - }); + } } return { ok: true, @@ -326,6 +329,7 @@ export function createMemoryV2({ saved: Number(result?.saved ?? 0), analyzed: Number(result?.analyzed ?? 0), memories: Number(result?.memories ?? 0), + personalMemory, }; } catch (err) { return failOpen('write', err, { diff --git a/ops/src/App.tsx b/ops/src/App.tsx index abaca30..0640408 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -17,6 +17,7 @@ import { SystemPolicyPage } from './pages/admin/SystemPolicyPage'; import { OrchestratorPage } from './pages/admin/OrchestratorPage'; import { LlmProvidersPage } from './pages/admin/LlmProvidersPage'; import { GoalRunPage } from './pages/admin/GoalRunPage'; +import { MemoryV2Page } from './pages/admin/MemoryV2Page'; export function App() { return ( @@ -54,6 +55,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index fef23d4..e50964c 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -893,6 +893,96 @@ export async function patchMemoryV2Config(patch: Record) { export async function fetchMemoryV2Runtime() { return adminFetch('/admin-api/memory-v2/runtime'); } + +export type MemoryV2ProductMetrics = { + window: { since: string; sinceMs: number; untilMs: number }; + userId: string | null; + events: { + memory_candidate_saved: number; + memory_promoted: number; + memory_resolved_injected: number; + memory_recall_hit: number; + }; + sources: Record; +}; + +export type MemoryV2ShadowAuditSummary = { + falseStoreRate: number | null; + autoAcceptRate: number | null; + resolveHitRate: number | null; + suspiciousCandidateCount: number; + pgvectorLagUserCount: number; +}; + +export type MemoryV2MetricsResponse = { + metrics: MemoryV2ProductMetrics; + audit: MemoryV2ShadowAuditSummary; + candidateCounts: Record; +}; + +export type MemoryV2CandidateRow = { + id: string; + userId: string; + sessionId: string | null; + memoryType: string; + content: string; + importance: number; + confidence: number; + status: string; + policyReason: string; + createdAt: number; + updatedAt: number; +}; + +export async function fetchMemoryV2Metrics(params?: { since?: string; userId?: string }) { + const query = new URLSearchParams(); + if (params?.since) query.set('since', params.since); + if (params?.userId) query.set('userId', params.userId); + const suffix = query.toString() ? `?${query.toString()}` : ''; + return adminFetch(`/admin-api/memory-v2/metrics${suffix}`); +} + +export async function fetchMemoryV2Candidates(params?: { + status?: string; + userId?: string; + limit?: number; + offset?: number; +}) { + const query = new URLSearchParams(); + if (params?.status) query.set('status', params.status); + if (params?.userId) query.set('userId', params.userId); + if (params?.limit != null) query.set('limit', String(params.limit)); + if (params?.offset != null) query.set('offset', String(params.offset)); + const suffix = query.toString() ? `?${query.toString()}` : ''; + return adminFetch<{ items: MemoryV2CandidateRow[]; status: string; limit: number; offset: number }>( + `/admin-api/memory-v2/candidates${suffix}`, + ); +} + +export async function runMemoryV2CandidateAutoReview(params?: { userId?: string; limit?: number }) { + return adminFetch<{ + ok: true; + scanned: number; + accepted: number; + rejected: number; + pending: number; + samples?: Array<{ id: string; action: string; reason: string }>; + }>('/admin-api/memory-v2/candidates/auto-review', { + method: 'POST', + body: JSON.stringify({ + userId: params?.userId, + limit: params?.limit ?? 200, + }), + }); +} + +export async function reviewMemoryV2Candidate(id: string, status: 'accepted' | 'rejected') { + return adminFetch<{ ok: true; updated: boolean }>(`/admin-api/memory-v2/candidates/${encodeURIComponent(id)}/review`, { + method: 'POST', + body: JSON.stringify({ status }), + }); +} + export type GoalRunRuntimeState = { enabled: boolean; canaryUserIds: string[]; diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 7a773fa..7648b77 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -9,6 +9,7 @@ const links = [ { to: '/admin/policy', label: '策略中心' }, { to: '/admin/orchestrator', label: '任务编排' }, { to: '/admin/llm', label: '统一大模型' }, + { to: '/admin/memory-v2', label: 'Memory V2' }, { to: '/admin/goal-runs', label: 'Goal Run' }, ]; diff --git a/ops/src/pages/admin/MemoryV2Page.tsx b/ops/src/pages/admin/MemoryV2Page.tsx new file mode 100644 index 0000000..daefb97 --- /dev/null +++ b/ops/src/pages/admin/MemoryV2Page.tsx @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + fetchMemoryV2Candidates, + fetchMemoryV2Metrics, + reviewMemoryV2Candidate, + runMemoryV2CandidateAutoReview, + type MemoryV2CandidateRow, + type MemoryV2MetricsResponse, +} from '../../api/admin'; + +function formatTime(value: number | null | undefined) { + if (!value) return '—'; + return new Date(value).toLocaleString('zh-CN', { hour12: false }); +} + +function formatPercent(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '—'; + return `${(value * 100).toFixed(1)}%`; +} + +export function MemoryV2Page() { + const [metrics, setMetrics] = useState(null); + const [candidates, setCandidates] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [reviewingId, setReviewingId] = useState(null); + const [autoReviewBusy, setAutoReviewBusy] = useState(false); + const [autoReviewNote, setAutoReviewNote] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [metricsRes, candidatesRes] = await Promise.all([ + fetchMemoryV2Metrics({ since: '7d' }), + fetchMemoryV2Candidates({ status: 'candidate', limit: 50 }), + ]); + setMetrics(metricsRes); + setCandidates(candidatesRes.items); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + const runAutoReview = async () => { + setAutoReviewBusy(true); + setAutoReviewNote(null); + try { + const result = await runMemoryV2CandidateAutoReview({ limit: 200 }); + setAutoReviewNote( + `自动审核完成:接受 ${result.accepted},拒绝 ${result.rejected},仍待人工 ${result.pending}`, + ); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : '自动审核失败'); + } finally { + setAutoReviewBusy(false); + } + }; + + const review = async (id: string, status: 'accepted' | 'rejected') => { + setReviewingId(id); + try { + await reviewMemoryV2Candidate(id, status); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : '审核失败'); + } finally { + setReviewingId(null); + } + }; + + if (loading && !metrics) return

加载中…

; + + return ( +
+ {error &&

{error}

} + {autoReviewNote &&

{autoReviewNote}

} + +
+
+
+

Memory V2 指标

+

近 {metrics?.metrics.window.since ?? '7d'} 产品事件与 shadow 审计

+
+
+ + +
+
+ {metrics && ( + <> +
+ {Object.entries(metrics.metrics.events).map(([key, count]) => ( +
+

{key}

+ {count} +
+ ))} +
+
+

误存率:{formatPercent(metrics.audit.falseStoreRate)}

+

自动接受率:{formatPercent(metrics.audit.autoAcceptRate)}

+

resolve 命中率:{formatPercent(metrics.audit.resolveHitRate)}

+

+ 候选队列: + {Object.entries(metrics.candidateCounts) + .map(([status, count]) => `${status} × ${count}`) + .join(',') || '—'} +

+
+ + )} +
+ +
+

需人工复核({candidates.length})

+

+ canary 模式下写入时会自动审核:显式/偏好/目标类接受,decision_signal 与问句类拒绝。 + 此处仅展示自动审核未覆盖的例外项。 +

+ {candidates.length === 0 ? ( +

没有待人工复核的候选。

+ ) : ( +
+ {candidates.map((item) => ( +
+
+ {item.policyReason} + {formatTime(item.createdAt)} +
+

{item.content}

+

+ user={item.userId} · type={item.memoryType} · session={item.sessionId ?? '—'} +

+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/ops/src/pages/admin/SummaryPage.tsx b/ops/src/pages/admin/SummaryPage.tsx index 3aad765..13f1337 100644 --- a/ops/src/pages/admin/SummaryPage.tsx +++ b/ops/src/pages/admin/SummaryPage.tsx @@ -71,6 +71,9 @@ export function SummaryPage() { 统一大模型 / H5 路由 + + Memory V2 指标 + Goal Run 观测 diff --git a/package.json b/package.json index 73b97f1..506611a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "node scripts/dev-core.mjs", "setup:dev-launchagent": "bash scripts/install-memind-dev-launchagent.sh", "setup:dev-launchagent:uninstall": "bash scripts/install-memind-dev-launchagent.sh --uninstall", + "setup:deep-search-launchagent": "bash scripts/install-deep-search-launchagent.sh", + "setup:deep-search-launchagent:uninstall": "bash scripts/install-deep-search-launchagent.sh --uninstall", "dev:all": "node scripts/dev.mjs", "dev:plaza": "node scripts/dev-plaza.mjs", "start:plaza": "node scripts/start-plaza-prod.mjs", @@ -55,12 +57,20 @@ "check:mindspace-cover": "node scripts/check-mindspace-cover.mjs", "demo:thumbnails": "node scripts/thumbnail-preview-demo.mjs", "audit:conversation-packages": "node scripts/audit-conversation-packages.mjs", + "audit:memory-v2-shadow": "node scripts/audit-memory-v2-shadow.mjs", + "verify:memory-v2-product-events": "node --test memory-v2-product-events.test.mjs", + "auto-review:memory-v2-candidates": "node scripts/auto-review-memory-v2-candidates.mjs --apply", + "run:memory-v2-phase-a-closure": "node scripts/run-memory-v2-phase-a-closure.mjs", "verify:goal-run-service": "node --test goal-run-service.test.mjs goal-run-intent.test.mjs goal-run-resolve.test.mjs goal-run-gateway.test.mjs goal-run-policy.test.mjs goal-run-admin-ops.test.mjs goal-run-awaiting.test.mjs server/portal-goal-run-routes.test.mjs", "verify:goal-run-local": "node scripts/verify-goal-run-local.mjs", "verify:goal-run-http-local": "node scripts/verify-goal-run-http-local.mjs", + "setup:memory-v2-pgvector": "node scripts/setup-memory-v2-pgvector-local.mjs", + "repair:memory-v2-candidates": "node scripts/repair-memory-v2-candidates.mjs", + "verify:memory-v2-shadow-audit": "node --test memory-v2-shadow-audit.test.mjs", + "verify:memory-v2-recall-benchmark": "node --test memory-v2-recall-benchmark.test.mjs", "trace:mindspace-artifact": "node scripts/trace-mindspace-artifact.mjs", "check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs", - "check:memory-v2": "node scripts/check-memory-v2-health.mjs", + "check:memory-v2-phase-a": "node scripts/check-memory-v2-phase-a-ready.mjs", "canary:memory-v2-app": "node scripts/check-memory-v2-app-canary.mjs", "check:memory-v2-contracts": "node scripts/check-memory-v2-contracts.mjs", "check:memory-v2-config": "node scripts/check-memory-v2-config-gaps.mjs", diff --git a/scripts/audit-memory-v2-shadow.mjs b/scripts/audit-memory-v2-shadow.mjs new file mode 100644 index 0000000..a82858f --- /dev/null +++ b/scripts/audit-memory-v2-shadow.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import mysql from 'mysql2/promise'; +import { + auditMemoryV2Shadow, + formatMemoryV2ShadowAuditReport, + parseSinceArg, +} from '../memory-v2-shadow-audit.mjs'; +import { loadMemindEnvFiles } from './memind-runtime-profile.mjs'; + +function usage() { + return [ + 'Usage: node scripts/audit-memory-v2-shadow.mjs [--since 7d] [--user-id ] [--output ] [--json]', + '', + 'Audits Memory V2 shadow/canary data quality:', + '- candidate status and policy reason distribution', + '- suspicious false-store samples', + '- active memories missing from pgvector', + '- agent_memory_resolved hit rate', + ].join('\n'); +} + +function parseArgs(argv) { + const args = { + since: '7d', + userId: '', + output: '', + json: false, + }; + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--since' && argv[i + 1]) { + args.since = argv[++i]; + } else if (arg === '--user-id' && argv[i + 1]) { + args.userId = argv[++i]; + } else if (arg === '--output' && argv[i + 1]) { + args.output = argv[++i]; + } else if (arg === '--json') { + args.json = true; + } else if (arg === '--help' || arg === '-h') { + console.log(usage()); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + console.error(usage()); + process.exit(2); + } + } + return args; +} + +function createMysqlPoolFromEnv() { + loadMemindEnvFiles(process.cwd()); + const poolOptions = { connectionLimit: 3 }; + if (process.env.DATABASE_URL) { + return mysql.createPool({ + uri: process.env.DATABASE_URL, + ...poolOptions, + }); + } + if (!process.env.MYSQL_HOST && !process.env.MYSQL_DATABASE) { + throw new Error('Memory V2 shadow audit requires DATABASE_URL or MYSQL_* configuration'); + } + return mysql.createPool({ + host: process.env.MYSQL_HOST ?? 'localhost', + port: Number(process.env.MYSQL_PORT ?? 3306), + user: process.env.MYSQL_USER ?? 'boot', + password: process.env.MYSQL_PASSWORD ?? '', + database: process.env.MYSQL_DATABASE ?? 'tkmind', + ...poolOptions, + }); +} + +async function createPgPoolFromEnv() { + const connectionString = String(process.env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim(); + if (!connectionString) return null; + const imported = await import('pg'); + const PgPool = imported?.Pool ?? imported?.default?.Pool; + if (typeof PgPool !== 'function') { + throw new Error('pg module does not export Pool'); + } + return new PgPool({ + connectionString, + max: Math.max(1, Number(process.env.MEMORY_PGVECTOR_POOL_MAX ?? 3) || 3), + }); +} + +function isSafeIdentifier(value) { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(String(value ?? '')); +} + +async function tableExists(pool, tableName) { + const [rows] = await pool.query( + `SELECT 1 + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + LIMIT 1`, + [tableName], + ); + return rows.length > 0; +} + +async function loadCandidates(pool, { sinceMs, userId }) { + if (!(await tableExists(pool, 'h5_memory_v2_candidates'))) { + return { rows: [], tableMissing: true }; + } + const clauses = ['created_at >= ?']; + const params = [sinceMs]; + if (userId) { + clauses.push('user_id = ?'); + params.push(userId); + } + const [rows] = await pool.query( + `SELECT id, user_id, session_id, memory_type, content, status, + policy_reason, confidence, importance, created_at, updated_at + FROM h5_memory_v2_candidates + WHERE ${clauses.join(' AND ')} + ORDER BY created_at DESC + LIMIT 5000`, + params, + ); + return { rows, tableMissing: false }; +} + +async function loadMemoryItems(pool, { sinceMs, userId }) { + const clauses = ['updated_at >= ?']; + const params = [sinceMs]; + if (userId) { + clauses.push('user_id = ?'); + params.push(userId); + } + const [rows] = await pool.query( + `SELECT id, user_id, label, memory_text, status, created_at, updated_at + FROM h5_user_memory_items + WHERE ${clauses.join(' AND ')} + ORDER BY updated_at DESC + LIMIT 5000`, + params, + ); + + const activeClauses = ["status = 'active'"]; + const activeParams = []; + if (userId) { + activeClauses.push('user_id = ?'); + activeParams.push(userId); + } + const [activeRows] = await pool.query( + `SELECT id, user_id, label, memory_text, status, created_at, updated_at + FROM h5_user_memory_items + WHERE ${activeClauses.join(' AND ')} + ORDER BY updated_at DESC + LIMIT 10000`, + activeParams, + ); + + return { + updatedRows: rows, + activeRows, + }; +} + +async function loadAgentMemoryEvents(pool, { sinceMs, userId }) { + const clauses = ["e.event_type = 'agent_memory_resolved'", 'e.created_at >= ?']; + const params = [sinceMs]; + if (userId) { + clauses.push('r.user_id = ?'); + params.push(userId); + } + const [rows] = await pool.query( + `SELECT e.event_type, e.data_json, e.created_at, r.user_id + FROM h5_agent_run_events e + INNER JOIN h5_agent_runs r ON r.id = e.run_id + WHERE ${clauses.join(' AND ')} + ORDER BY e.created_at DESC + LIMIT 5000`, + params, + ); + return rows; +} + +async function loadPgvectorMemoryIds(pgPool, { userId, tableName }) { + if (!pgPool) return new Set(); + if (!isSafeIdentifier(tableName)) { + throw new Error(`Invalid pgvector table name: ${tableName}`); + } + const clauses = ['source_memory_id IS NOT NULL']; + const params = []; + if (userId) { + clauses.push('user_id = $1'); + params.push(userId); + } + try { + const result = await pgPool.query( + `SELECT source_memory_id + FROM "${tableName}" + WHERE ${clauses.join(' AND ')}`, + params, + ); + return new Set( + result.rows + .map((row) => String(row.source_memory_id ?? '').trim()) + .filter(Boolean), + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to query pgvector table "${tableName}": ${message}`); + } +} + +async function writeOutput(outputPath, payload) { + if (!outputPath) return; + const resolved = path.resolve(outputPath); + await fs.mkdir(path.dirname(resolved), { recursive: true }); + await fs.writeFile(resolved, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +const args = parseArgs(process.argv); +const nowMs = Date.now(); +const { sinceMs, label: sinceLabel } = parseSinceArg(args.since, nowMs); +const mysqlPool = createMysqlPoolFromEnv(); +let pgPool = null; + +try { + pgPool = await createPgPoolFromEnv(); + const [candidateResult, memoryResult, agentMemoryEvents] = await Promise.all([ + loadCandidates(mysqlPool, { sinceMs, userId: args.userId }), + loadMemoryItems(mysqlPool, { sinceMs, userId: args.userId }), + loadAgentMemoryEvents(mysqlPool, { sinceMs, userId: args.userId }), + ]); + + const pgvectorTable = String(process.env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings').trim() + || 'memory_embeddings'; + const pgvectorMemoryIds = await loadPgvectorMemoryIds(pgPool, { + userId: args.userId, + tableName: pgvectorTable, + }); + + const report = auditMemoryV2Shadow({ + candidates: candidateResult.rows, + memoryItems: memoryResult.activeRows, + pgvectorMemoryIds, + pgvectorConfigured: Boolean(pgPool), + agentMemoryEvents, + sinceMs, + nowMs, + }); + + const payload = { + ...report, + meta: { + since: sinceLabel, + userId: args.userId || null, + candidateTableMissing: candidateResult.tableMissing, + pgvectorConfigured: Boolean(pgPool), + pgvectorTable, + }, + }; + + await writeOutput(args.output, payload); + + if (args.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + console.log(formatMemoryV2ShadowAuditReport(payload)); + if (candidateResult.tableMissing) { + console.log('\nwarning: h5_memory_v2_candidates table is missing'); + } + if (!pgPool) { + console.log('\nwarning: MEMORY_PGVECTOR_DATABASE_URL not configured; pgvector lag check skipped'); + } + if (args.output) { + console.log(`\nreport written: ${path.resolve(args.output)}`); + } + } + + process.exit(report.ok || !pgPool || candidateResult.tableMissing ? 0 : 1); +} finally { + await mysqlPool.end(); + await pgPool?.end?.(); +} diff --git a/scripts/auto-review-memory-v2-candidates.mjs b/scripts/auto-review-memory-v2-candidates.mjs new file mode 100644 index 0000000..9ebb1c0 --- /dev/null +++ b/scripts/auto-review-memory-v2-candidates.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { pathToFileURL } from 'node:url'; +import { createDbPool, isDatabaseConfigured } from '../db.mjs'; +import { runCandidateAutoReviewBatch } from '../memory-v2-candidate-auto-review.mjs'; + +function usage() { + return [ + 'Usage: node scripts/auto-review-memory-v2-candidates.mjs [--apply] [--user-id ] [--limit ] [--json]', + '', + 'Default is dry-run summary against pending candidates.', + ].join('\n'); +} + +function parseArgs(argv) { + const options = { apply: false, userId: null, limit: 200, json: false, help: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--help' || arg === '-h') options.help = true; + else if (arg === '--apply') options.apply = true; + else if (arg === '--json') options.json = true; + else if (arg === '--user-id') options.userId = String(argv[++index] ?? '').trim() || null; + else if (arg === '--limit') options.limit = Number(argv[++index]); + else throw new Error(`Unknown argument: ${arg}`); + } + return options; +} + +export async function runAutoReviewMemoryV2CandidatesCli( + argv = process.argv.slice(2), + env = process.env, +) { + const options = parseArgs(argv); + if (options.help) { + console.log(usage()); + return { ok: true, mode: 'help' }; + } + if (!isDatabaseConfigured()) { + throw new Error('DATABASE_URL or MYSQL_* must be configured'); + } + const pool = createDbPool(); + try { + const result = await runCandidateAutoReviewBatch(pool, { + env, + userId: options.userId, + limit: options.limit, + reviewedBy: options.apply ? 'system:auto-review-cli' : 'system:auto-review-dry-run', + dryRun: !options.apply, + }); + const payload = { + ...result, + mode: options.apply ? 'apply' : 'dry-run', + note: options.apply + ? 'Auto-review applied to pending candidates' + : 'Dry-run only; pass --apply to mutate candidate statuses', + }; + if (options.json) console.log(JSON.stringify(payload, null, 2)); + else { + console.log(`${payload.mode}: scanned=${payload.scanned ?? 0} accept=${payload.accepted ?? 0} reject=${payload.rejected ?? 0} pending=${payload.pending ?? 0}`); + if (payload.samples?.length) console.log(JSON.stringify(payload.samples, null, 2)); + } + return payload; + } finally { + await pool.end?.(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runAutoReviewMemoryV2CandidatesCli().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/scripts/benchmark-memory-v2-recall.mjs b/scripts/benchmark-memory-v2-recall.mjs new file mode 100644 index 0000000..cccdcf0 --- /dev/null +++ b/scripts/benchmark-memory-v2-recall.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + probeEmbeddingModule, + resolveEmbeddingModuleSpecifier, + runRecallBenchmark, + summarizeRecallBenchmark, +} from '../memory-v2-recall-benchmark.mjs'; +import { loadMemindEnvFiles } from './memind-runtime-profile.mjs'; + +function usage() { + return [ + 'Usage: node scripts/benchmark-memory-v2-recall.mjs [--json] [--output ]', + '', + 'Reports MEMORY_PGVECTOR_EMBEDDING_MODULE dimensions and offline recall@5 baseline', + 'against the fixed Memory V2 hybrid-ranking benchmark suite.', + ].join('\n'); +} + +function parseArgs(argv) { + const args = { json: false, output: '' }; + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--output' && argv[i + 1]) args.output = argv[++i]; + else if (arg === '--help' || arg === '-h') { + console.log(usage()); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + console.error(usage()); + process.exit(2); + } + } + return args; +} + +async function loadEmbedQuery(moduleSpecifier) { + const resolved = resolveEmbeddingModuleSpecifier(moduleSpecifier); + if (!resolved) return null; + const imported = await import(resolved); + const fn = imported?.embedQuery ?? imported?.default; + return typeof fn === 'function' ? fn : null; +} + +async function writeOutput(outputPath, payload) { + if (!outputPath) return; + const resolved = path.resolve(outputPath); + await fs.mkdir(path.dirname(resolved), { recursive: true }); + await fs.writeFile(resolved, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +const args = parseArgs(process.argv); +loadMemindEnvFiles(process.cwd()); + +const embeddingModule = String(process.env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? '').trim() + || './scripts/embed-memory-v2-local-hash.mjs'; +const probe = await probeEmbeddingModule({ + moduleSpecifier: embeddingModule, + importModule: (specifier) => import(specifier), +}); +const embedQuery = await loadEmbedQuery(probe.configured ? embeddingModule : null); +const benchmark = embedQuery + ? await runRecallBenchmark({ embedQuery, limit: 5 }) + : null; + +const payload = { + generatedAt: new Date().toISOString(), + embedding: probe, + benchmark: benchmark ? { + ...summarizeRecallBenchmark(benchmark), + results: benchmark.results, + } : null, + env: { + MEMORY_PGVECTOR_EMBEDDING_MODULE: embeddingModule, + MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS: process.env.MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS ?? null, + MEMORY_PGVECTOR_ENABLED: process.env.MEMORY_PGVECTOR_ENABLED ?? null, + MEMORY_PGVECTOR_DATABASE_URL: process.env.MEMORY_PGVECTOR_DATABASE_URL ? '[configured]' : null, + }, +}; + +await writeOutput(args.output, payload); + +if (args.json) { + console.log(JSON.stringify(payload, null, 2)); +} else { + console.log('memory v2 recall benchmark'); + console.log(`embedding module: ${probe.modulePath ?? embeddingModule}`); + console.log(`configured: ${probe.configured}`); + if (probe.configured) { + console.log(`dimensions: ${probe.dimensions}`); + } else { + console.log(`reason: ${probe.reason}`); + } + if (benchmark) { + const summary = summarizeRecallBenchmark(benchmark); + console.log(`recall@5: ${(summary.recallAt5 * 100).toFixed(1)}% (${summary.hits}/${summary.caseCount})`); + if (summary.failedCases.length > 0) { + console.log(`failed cases: ${summary.failedCases.join(', ')}`); + } + for (const item of benchmark.results) { + console.log(`- ${item.id}: ${item.hit ? `hit@${item.hitRank}` : 'miss'} top="${item.topText ?? ''}"`); + } + } + if (args.output) { + console.log(`\nreport written: ${path.resolve(args.output)}`); + } +} + +process.exit(benchmark && summarizeRecallBenchmark(benchmark).recallAt5 >= 0.75 ? 0 : 1); diff --git a/scripts/check-memory-v2-phase-a-ready.mjs b/scripts/check-memory-v2-phase-a-ready.mjs new file mode 100644 index 0000000..06a780d --- /dev/null +++ b/scripts/check-memory-v2-phase-a-ready.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import { pathToFileURL } from 'node:url'; +import { evaluateMemoryV2PhaseAReadiness } from '../memory-v2-phase-a-ready.mjs'; +import { loadMemindEnvFiles } from './memind-runtime-profile.mjs'; + +function usage() { + return [ + 'Usage: node scripts/check-memory-v2-phase-a-ready.mjs [--json]', + '', + 'Validates local/admin env for Memory V2 Phase A canary rollout.', + ].join('\n'); +} + +function parseArgs(argv) { + const args = { json: false }; + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') args.json = true; + else if (arg === '--help' || arg === '-h') { + console.log(usage()); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + console.error(usage()); + process.exit(2); + } + } + return args; +} + +function printReport(report, { json = false } = {}) { + if (json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + console.log(`memory v2 phase-a readiness: ${report.ok ? 'ready' : 'blocked'}`); + console.log(JSON.stringify(report.summary, null, 2)); + for (const item of report.issues) { + console.log(`- issue ${item.code}: ${item.message}`); + } + for (const item of report.warnings) { + console.log(`- warning ${item.code}: ${item.message}`); + } +} + +async function main() { + const args = parseArgs(process.argv); + loadMemindEnvFiles(process.cwd()); + const report = evaluateMemoryV2PhaseAReadiness(process.env); + printReport(report, { json: args.json }); + process.exit(report.ok ? 0 : 1); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main(); +} diff --git a/scripts/repair-memory-v2-candidates.mjs b/scripts/repair-memory-v2-candidates.mjs new file mode 100644 index 0000000..a5d8804 --- /dev/null +++ b/scripts/repair-memory-v2-candidates.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +import mysql from 'mysql2/promise'; +import { pathToFileURL } from 'node:url'; +import { detectFalseStoreCandidate } from '../memory-v2-shadow-audit.mjs'; +import { inspectMemoryContentDurability } from '../memory-v2-personal-shadow.mjs'; +import { loadMemindEnvFiles } from './memind-runtime-profile.mjs'; + +function usage() { + return [ + 'Usage: node scripts/repair-memory-v2-candidates.mjs [--apply] [--user-id ] [--limit ] [--json]', + '', + 'Finds low-quality accepted/candidate rows and rejects them.', + 'Default mode is dry-run. --apply performs UPDATE status=rejected.', + ].join('\n'); +} + +function parseArgs(argv) { + const args = { + apply: false, + userId: '', + limit: 500, + json: false, + }; + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--apply') args.apply = true; + else if (arg === '--user-id' && argv[i + 1]) args.userId = argv[++i]; + else if (arg === '--limit' && argv[i + 1]) { + args.limit = Math.max(1, Math.min(5000, Number(argv[++i]) || 500)); + } else if (arg === '--json') args.json = true; + else if (arg === '--help' || arg === '-h') { + console.log(usage()); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + console.error(usage()); + process.exit(2); + } + } + return args; +} + +function createPoolFromEnv() { + loadMemindEnvFiles(process.cwd()); + const poolOptions = { connectionLimit: 3 }; + if (process.env.DATABASE_URL) { + return mysql.createPool({ uri: process.env.DATABASE_URL, ...poolOptions }); + } + return mysql.createPool({ + host: process.env.MYSQL_HOST ?? 'localhost', + port: Number(process.env.MYSQL_PORT ?? 3306), + user: process.env.MYSQL_USER ?? 'boot', + password: process.env.MYSQL_PASSWORD ?? '', + database: process.env.MYSQL_DATABASE ?? 'tkmind', + ...poolOptions, + }); +} + +export function evaluateRepairCandidate(row) { + const content = String(row.content ?? ''); + const durabilityReason = inspectMemoryContentDurability(content); + if (durabilityReason) { + return { repair: true, code: durabilityReason, message: `Non-durable content (${durabilityReason})` }; + } + const falseStore = detectFalseStoreCandidate(content); + if (falseStore.suspicious) { + return { repair: true, code: falseStore.code, message: falseStore.message }; + } + if (String(row.policy_reason ?? '') === 'decision_signal' && /(?:public\/|page data|durability-)/iu.test(content)) { + return { repair: true, code: 'decision_signal_task_leak', message: 'Decision signal on agent task content' }; + } + return { repair: false }; +} + +async function loadCandidates(pool, { userId, limit }) { + const clauses = ["status IN ('candidate', 'accepted')"]; + const params = []; + if (userId) { + clauses.push('user_id = ?'); + params.push(userId); + } + params.push(limit); + const [rows] = await pool.query( + `SELECT id, user_id, session_id, memory_type, content, status, policy_reason, + confidence, importance, created_at, updated_at + FROM h5_memory_v2_candidates + WHERE ${clauses.join(' AND ')} + ORDER BY updated_at DESC + LIMIT ?`, + params, + ); + return rows; +} + +async function applyRepairs(pool, repairs, nowMs) { + let updated = 0; + for (const item of repairs) { + const [result] = await pool.query( + `UPDATE h5_memory_v2_candidates + SET status = 'rejected', + reviewed_by = 'system:repair', + reviewed_at = ?, + updated_at = ? + WHERE id = ? + AND status IN ('candidate', 'accepted')`, + [nowMs, nowMs, item.id], + ); + updated += Number(result?.affectedRows ?? 0); + } + return updated; +} + +function printTextReport(report) { + console.log(`memory v2 candidate repair: ${report.apply ? 'apply' : 'dry-run'}`); + console.log(`scanned: ${report.scanned}`); + console.log(`repairable: ${report.repairable}`); + if (report.repairs.length > 0) { + for (const item of report.repairs.slice(0, 20)) { + console.log(`- [${item.code}] ${item.id} (${item.status}/${item.policyReason}): ${item.contentPreview}`); + } + if (report.repairs.length > 20) { + console.log(`... ${report.repairs.length - 20} more`); + } + } + if (report.apply) { + console.log(`updated: ${report.updated}`); + } +} + +export async function runMemoryV2CandidateRepair(args, { pool: injectedPool, nowMs = Date.now() } = {}) { + const ownsPool = !injectedPool; + const activePool = injectedPool ?? createPoolFromEnv(); + try { + const rows = await loadCandidates(activePool, args); + const repairs = rows + .map((row) => { + const verdict = evaluateRepairCandidate(row); + if (!verdict.repair) return null; + return { + id: String(row.id), + userId: String(row.user_id), + status: String(row.status), + policyReason: String(row.policy_reason ?? ''), + code: verdict.code, + message: verdict.message, + contentPreview: String(row.content ?? '').slice(0, 120), + }; + }) + .filter(Boolean); + + const report = { + ok: true, + apply: args.apply, + scanned: rows.length, + repairable: repairs.length, + repairs, + updated: 0, + }; + + if (args.apply && repairs.length > 0) { + report.updated = await applyRepairs(activePool, repairs, nowMs); + } + return report; + } finally { + if (ownsPool) { + await activePool.end(); + } + } +} + +async function main() { + const args = parseArgs(process.argv); + try { + const report = await runMemoryV2CandidateRepair(args); + if (args.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + printTextReport(report); + } + process.exit(0); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`repair failed: ${message}`); + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main(); +} diff --git a/scripts/run-memory-v2-phase-a-closure.mjs b/scripts/run-memory-v2-phase-a-closure.mjs new file mode 100644 index 0000000..b0ec380 --- /dev/null +++ b/scripts/run-memory-v2-phase-a-closure.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createDbPool, isDatabaseConfigured } from '../db.mjs'; +import { createMemoryV2LifecycleService } from '../memory-v2-lifecycle.mjs'; +import { resolveCandidateAutoReviewScopes, runCandidateAutoReviewBatch } from '../memory-v2-candidate-auto-review.mjs'; +import { syncLegacyUserMemoriesToPgvector } from '../memory-v2-pgvector-backfill.mjs'; +import { evaluateMemoryV2PhaseAReadiness } from '../memory-v2-phase-a-ready.mjs'; +import { loadMemindEnvFiles } from './memind-runtime-profile.mjs'; + +function usage() { + return [ + 'Usage: node scripts/run-memory-v2-phase-a-closure.mjs [--json] [--skip-auto-review] [--skip-promote] [--skip-pgvector]', + '', + 'Runs canary closure: auto-review pending candidates → promote accepted → pgvector user sync.', + ].join('\n'); +} + +function parseArgs(argv) { + const options = { + json: false, + skipAutoReview: false, + skipPromote: false, + skipPgvector: false, + help: false, + }; + for (const arg of argv) { + if (arg === '--json') options.json = true; + else if (arg === '--skip-auto-review') options.skipAutoReview = true; + else if (arg === '--skip-promote') options.skipPromote = true; + else if (arg === '--skip-pgvector') options.skipPgvector = true; + else if (arg === '--help' || arg === '-h') options.help = true; + else throw new Error(`Unknown argument: ${arg}`); + } + return options; +} + +async function loadEmbedMemory(env) { + const modulePath = env.MEMORY_PGVECTOR_EMBEDDING_MODULE; + if (!modulePath) throw new Error('MEMORY_PGVECTOR_EMBEDDING_MODULE is required for pgvector sync'); + const resolved = path.isAbsolute(modulePath) + ? modulePath + : path.resolve(process.cwd(), modulePath); + const mod = await import(pathToFileURL(resolved).href); + const embedMemory = mod.embedMemory ?? mod.default; + if (typeof embedMemory !== 'function') { + throw new Error('embedding module must export embedMemory(memory) or default'); + } + return embedMemory; +} + +async function countCandidatesByStatus(pool) { + const [rows] = await pool.query( + `SELECT status, COUNT(*) AS count FROM h5_memory_v2_candidates GROUP BY status`, + ); + return Object.fromEntries(rows.map((row) => [String(row.status), Number(row.count)])); +} + +async function countActiveMemoriesForUsers(pool, userIds) { + if (!userIds.length) return 0; + const placeholders = userIds.map(() => '?').join(', '); + const [rows] = await pool.query( + `SELECT COUNT(*) AS count FROM h5_user_memory_items + WHERE status = 'active' AND user_id IN (${placeholders})`, + userIds, + ); + return Number(rows[0]?.count ?? 0); +} + +async function countPgvectorForUsers(pgPool, userIds) { + if (!pgPool?.query || !userIds.length) return null; + const { rows } = await pgPool.query( + `SELECT COUNT(*)::int AS count FROM memory_embeddings WHERE user_id = ANY($1::text[])`, + [userIds], + ); + return Number(rows[0]?.count ?? 0); +} + +export async function runMemoryV2PhaseAClosure(env = process.env, options = {}) { + if (!isDatabaseConfigured()) { + throw new Error('DATABASE_URL or MYSQL_* must be configured'); + } + + const readiness = evaluateMemoryV2PhaseAReadiness(env); + const canaryUserIds = resolveCandidateAutoReviewScopes(env); + const mysqlPool = createDbPool(); + let pgPool = null; + + const report = { + ok: false, + readiness: readiness.summary, + canaryUserIds, + before: {}, + after: {}, + steps: {}, + }; + + try { + report.before.candidateCounts = await countCandidatesByStatus(mysqlPool); + report.before.activeMemories = await countActiveMemoriesForUsers(mysqlPool, canaryUserIds); + if (env.MEMORY_PGVECTOR_DATABASE_URL) { + const { default: pg } = await import('pg'); + pgPool = new pg.Pool({ connectionString: env.MEMORY_PGVECTOR_DATABASE_URL, max: 1 }); + report.before.pgvectorMemories = await countPgvectorForUsers(pgPool, canaryUserIds); + } + + if (!options.skipAutoReview) { + report.steps.autoReview = await runCandidateAutoReviewBatch(mysqlPool, { + env, + limit: 500, + reviewedBy: 'system:phase-a-closure', + }); + } else { + report.steps.autoReview = { skipped: true }; + } + + const lifecycle = createMemoryV2LifecycleService({ pool: mysqlPool, env }); + const promoteResults = []; + if (!options.skipPromote) { + for (const userId of canaryUserIds.length ? canaryUserIds : [null]) { + const input = userId ? { userId, limit: 200 } : { limit: 200 }; + promoteResults.push({ + userId, + result: await lifecycle.promote(input), + }); + } + report.steps.promote = promoteResults; + } else { + report.steps.promote = { skipped: true }; + } + + if (!options.skipPgvector && env.MEMORY_PGVECTOR_DATABASE_URL) { + if (!pgPool) { + const { default: pg } = await import('pg'); + pgPool = new pg.Pool({ connectionString: env.MEMORY_PGVECTOR_DATABASE_URL, max: 1 }); + } + const embedMemory = await loadEmbedMemory(env); + const syncResults = []; + for (const userId of canaryUserIds) { + syncResults.push(await syncLegacyUserMemoriesToPgvector({ + mysqlPool, + pgPool, + embedMemory, + tableName: env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings', + userId, + limit: Number(env.MEMORY_PGVECTOR_SYNC_USER_LIMIT ?? 50) || 50, + })); + } + report.steps.pgvectorSync = syncResults; + report.after.pgvectorMemories = await countPgvectorForUsers(pgPool, canaryUserIds); + } else { + report.steps.pgvectorSync = { skipped: true, reason: 'pgvector not configured' }; + } + + report.after.candidateCounts = await countCandidatesByStatus(mysqlPool); + report.after.activeMemories = await countActiveMemoriesForUsers(mysqlPool, canaryUserIds); + + const promotedTotal = Array.isArray(report.steps.promote) + ? report.steps.promote.reduce((sum, item) => sum + Number(item.result?.promoted ?? 0), 0) + : 0; + + const pendingAfter = Number(report.after.candidateCounts?.candidate ?? 0); + const acceptedAfter = Number(report.after.candidateCounts?.accepted ?? 0); + + report.ok = readiness.ok + && pendingAfter === 0 + && report.after.activeMemories > 0 + && (acceptedAfter === 0 || promotedTotal > 0 || report.after.activeMemories >= report.before.activeMemories) + && (options.skipPgvector || !env.MEMORY_PGVECTOR_DATABASE_URL + || Number(report.after.pgvectorMemories ?? 0) >= Math.max(1, report.after.activeMemories - 5)); + + report.summary = { + pendingCandidates: Number(report.after.candidateCounts?.candidate ?? 0), + acceptedCandidates: Number(report.after.candidateCounts?.accepted ?? 0), + promotedThisRun: promotedTotal, + activeMemoriesCanary: report.after.activeMemories, + pgvectorMemoriesCanary: report.after.pgvectorMemories ?? null, + }; + + return report; + } finally { + await pgPool?.end?.(); + await mysqlPool.end?.(); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + loadMemindEnvFiles(process.cwd()); + const report = await runMemoryV2PhaseAClosure(process.env, options); + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(`memory v2 phase-a closure: ${report.ok ? 'ok' : 'check required'}`); + console.log(JSON.stringify(report.summary, null, 2)); + if (report.steps.autoReview && !report.steps.autoReview.skipped) { + console.log(`auto-review: scanned=${report.steps.autoReview.scanned} accept=${report.steps.autoReview.accepted} reject=${report.steps.autoReview.rejected}`); + } + if (Array.isArray(report.steps.promote)) { + for (const item of report.steps.promote) { + console.log(`promote user=${item.userId ?? 'all'} promoted=${item.result?.promoted ?? 0}`); + } + } + } + process.exit(report.ok ? 0 : 1); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/scripts/setup-memory-v2-pgvector-local.mjs b/scripts/setup-memory-v2-pgvector-local.mjs new file mode 100644 index 0000000..9db8a2b --- /dev/null +++ b/scripts/setup-memory-v2-pgvector-local.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import { pathToFileURL } from 'node:url'; +import { ensurePgvectorMemorySchema } from '../memory-v2-pgvector-schema.mjs'; +import { runPgvectorMemorySmoke } from '../memory-v2-pgvector-smoke.mjs'; +import { loadMemindEnvFiles } from './memind-runtime-profile.mjs'; + +function usage() { + return [ + 'Usage: node scripts/setup-memory-v2-pgvector-local.mjs [--dimensions 3] [--skip-smoke]', + '', + 'Ensures local pgvector schema exists and runs a read/write smoke test.', + ].join('\n'); +} + +function parseArgs(argv) { + const args = { dimensions: 3, skipSmoke: false }; + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--dimensions' && argv[i + 1]) args.dimensions = Number(argv[++i]); + else if (arg === '--skip-smoke') args.skipSmoke = true; + else if (arg === '--help' || arg === '-h') { + console.log(usage()); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + console.error(usage()); + process.exit(2); + } + } + return args; +} + +async function createPgPool(connectionString) { + const imported = await import('pg'); + const PgPool = imported?.Pool ?? imported?.default?.Pool; + if (typeof PgPool !== 'function') throw new Error('pg module does not export Pool'); + return new PgPool({ connectionString, max: 3 }); +} + +async function main() { + const args = parseArgs(process.argv); + loadMemindEnvFiles(process.cwd()); + const connectionString = String(process.env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim() + || 'postgresql://john@127.0.0.1:5432/memind_memory'; + const tableName = String(process.env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings').trim() + || 'memory_embeddings'; + const pool = await createPgPool(connectionString); + try { + const schema = await ensurePgvectorMemorySchema(pool, { + tableName, + dimensions: args.dimensions, + createExtension: true, + }); + console.log(`pgvector schema ready: table=${schema.tableName} dimensions=${schema.dimensions}`); + if (!args.skipSmoke) { + const smoke = await runPgvectorMemorySmoke({ + pool, + tableName, + dimensions: args.dimensions, + cleanup: true, + embedQuery: async () => Array.from({ length: args.dimensions }, (_, index) => (index + 1) * 0.1), + }); + console.log(`pgvector smoke: ${smoke.ok ? 'ok' : 'failed'} inserted=${smoke.inserted} queried=${smoke.queried}`); + if (!smoke.ok) process.exit(1); + } + } finally { + await pool.end(); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/server/portal-agent-runtime-routes.mjs b/server/portal-agent-runtime-routes.mjs index aa61ed8..f3a75af 100644 --- a/server/portal-agent-runtime-routes.mjs +++ b/server/portal-agent-runtime-routes.mjs @@ -36,6 +36,8 @@ export function attachPortalAgentRuntimeRoutes( getTkmindProxy = () => null, getLlmProviderService = () => null, getAgentRunGateway = () => null, + getGoalRunService = () => null, + getChatIntentRouter = () => null, getSessionAccess = () => null, getMindSpaceAssetAgent = () => null, getCodeRunPolicyService = () => null, @@ -130,6 +132,8 @@ export function attachPortalAgentRuntimeRoutes( agentRunGateway, mindSpaceAssetAgent: getMindSpaceAssetAgent(), codeRunPolicyService: getCodeRunPolicyService(), + goalRunService: getGoalRunService(), + chatIntentRouter: getChatIntentRouter(), }), ], req, diff --git a/server/portal-user-memory-routes.mjs b/server/portal-user-memory-routes.mjs index 102aded..969b500 100644 --- a/server/portal-user-memory-routes.mjs +++ b/server/portal-user-memory-routes.mjs @@ -1,3 +1,5 @@ +import { summarizePersonalMemoryObservation } from '../memory-v2-user-feedback.mjs'; + function assertRouter(api) { if ( !api || @@ -11,11 +13,84 @@ function assertRouter(api) { } } +function parseRecallEventData(raw) { + if (!raw) return null; + if (typeof raw === 'object') return raw; + try { + return JSON.parse(String(raw)); + } catch { + return null; + } +} + +export async function resolveLatestMemoryRecallHint(pool, { userId, sessionId }) { + if (!pool?.query || !userId || !sessionId) { + return { + memoryCount: 0, + injectionEnabled: false, + mode: null, + createdAt: null, + savedPreview: null, + memoryPreviews: [], + }; + } + const [rows] = await pool.query( + `SELECT e.data_json, e.created_at, r.id AS run_id + FROM h5_agent_run_events e + INNER JOIN h5_agent_runs r ON r.id = e.run_id + WHERE r.user_id = ? + AND r.agent_session_id = ? + AND e.event_type = 'agent_memory_resolved' + ORDER BY e.created_at DESC + LIMIT 1`, + [String(userId), String(sessionId)], + ); + const [candidateRows] = await pool.query( + `SELECT content, updated_at + FROM h5_memory_v2_candidates + WHERE user_id = ? + AND session_id = ? + AND status = 'accepted' + ORDER BY updated_at DESC + LIMIT 1`, + [String(userId), String(sessionId)], + ); + const row = rows[0]; + const candidate = candidateRows[0]; + if (!row) { + return { + memoryCount: 0, + injectionEnabled: false, + mode: null, + createdAt: null, + runId: null, + savedPreview: candidate?.content ? String(candidate.content).slice(0, 60) : null, + savedAt: candidate?.updated_at == null ? null : Number(candidate.updated_at), + memoryPreviews: [], + }; + } + const data = parseRecallEventData(row.data_json); + const memoryPreviews = Array.isArray(data?.memoryPreviews) + ? data.memoryPreviews.map((item) => String(item).slice(0, 120)).filter(Boolean).slice(0, 5) + : []; + return { + memoryCount: Number(data?.memoryCount ?? data?.count ?? 0) || 0, + injectionEnabled: Boolean(data?.injectionEnabled), + mode: data?.mode == null ? null : String(data.mode), + createdAt: Number(row.created_at ?? 0) || null, + runId: row.run_id == null ? null : String(row.run_id), + savedPreview: candidate?.content ? String(candidate.content).slice(0, 60) : null, + savedAt: candidate?.updated_at == null ? null : Number(candidate.updated_at), + memoryPreviews, + }; +} + export function attachPortalUserMemoryRoutes( api, { getMemoryV2 = () => null, getTkmindProxy = () => null, + getPool = () => null, ensureUserMemoryCapability = async () => null, ownsAgentSession = async () => false, loadUserVisibleConversation = async () => [], @@ -64,12 +139,14 @@ export function attachPortalUserMemoryRoutes( sessionId, limit: 200, }); + const personalMemory = summarizePersonalMemoryObservation(result.personalMemory); return res.json({ ok: true, analyzed: result.analyzed ?? 0, memories: result.memories ?? 0, totalMemories: memories.length, syncedToSession, + personalMemory, }); } catch (err) { return res.status(500).json({ @@ -78,6 +155,37 @@ export function attachPortalUserMemoryRoutes( } }); + api.get('/user-memory/v1/recall-hint', async (req, res) => { + const memoryV2 = getMemoryV2(); + const memoryStatus = await memoryV2?.getStatus?.().catch(() => null); + if (!memoryStatus?.enabled) { + return res.status(503).json({ message: '长期记忆功能未启用' }); + } + const capabilityState = await ensureUserMemoryCapability(req, res); + if (!capabilityState) return; + + const sessionId = String(req.query?.sessionId ?? '').trim(); + if (!sessionId) { + return res.status(400).json({ message: '缺少 sessionId' }); + } + const owns = await ownsAgentSession(req.currentUser.id, sessionId); + if (!owns) { + return res.status(403).json({ message: '无权访问该会话' }); + } + + try { + const hint = await resolveLatestMemoryRecallHint(getPool(), { + userId: req.currentUser.id, + sessionId, + }); + return res.json({ ok: true, ...hint }); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : '读取记忆召回提示失败', + }); + } + }); + api.post('/user-memory/v1/sync', async (req, res) => { const memoryV2 = getMemoryV2(); const memoryStatus = await memoryV2?.getStatus?.().catch(() => null); diff --git a/server/portal-user-memory-routes.test.mjs b/server/portal-user-memory-routes.test.mjs index b5f8243..4855a0c 100644 --- a/server/portal-user-memory-routes.test.mjs +++ b/server/portal-user-memory-routes.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { attachPortalUserMemoryRoutes } from './portal-user-memory-routes.mjs'; +import { attachPortalUserMemoryRoutes, resolveLatestMemoryRecallHint } from './portal-user-memory-routes.mjs'; function createRouterRecorder() { const routes = new Map(); @@ -69,6 +69,7 @@ test('User Memory module preserves route inventory and order', () => { assert.deepEqual([...api.routes.keys()], [ 'POST /user-memory/v1/remember-recent', + 'GET /user-memory/v1/recall-hint', 'POST /user-memory/v1/sync', 'GET /user-memory/v1/items', 'DELETE /user-memory/v1/items/:memoryId', @@ -212,6 +213,11 @@ test('remember-recent preserves write, sync, resolve order and response projecti memories: 2, totalMemories: 3, syncedToSession: true, + personalMemory: { + savedPreviews: [], + autoReviewed: 0, + pendingReview: 0, + }, }); }); @@ -419,3 +425,79 @@ test('delete preserves forwarding, unavailable fallback, success, and errors', a assert.equal(failureRes.statusCode, 500); assert.deepEqual(failureRes.body, { message: 'forget failed' }); }); + +test('resolveLatestMemoryRecallHint merges recall event and saved candidate preview', async () => { + const pool = { + async query(sql, params) { + if (String(sql).includes('agent_memory_resolved')) { + return [[{ + data_json: JSON.stringify({ + memoryCount: 3, + injectionEnabled: true, + mode: 'canary', + memoryPreviews: ['我喜欢喝美式咖啡', '每周三做代码评审'], + }), + created_at: 1000, + run_id: 'run-1', + }]]; + } + if (String(sql).includes('h5_memory_v2_candidates')) { + assert.deepEqual(params, ['user-1', 'session-1']); + return [[{ content: '我喜欢喝美式咖啡', updated_at: 900 }]]; + } + return [[]]; + }, + }; + const hint = await resolveLatestMemoryRecallHint(pool, { + userId: 'user-1', + sessionId: 'session-1', + }); + assert.deepEqual(hint, { + memoryCount: 3, + injectionEnabled: true, + mode: 'canary', + createdAt: 1000, + runId: 'run-1', + savedPreview: '我喜欢喝美式咖啡', + savedAt: 900, + memoryPreviews: ['我喜欢喝美式咖啡', '每周三做代码评审'], + }); +}); + +test('recall-hint preserves capability gate and returns merged hint payload', async () => { + const api = createRouterRecorder(); + attachPortalUserMemoryRoutes( + api, + createDependencies({ + getPool: () => ({ + async query(sql) { + if (String(sql).includes('agent_memory_resolved')) { + return [[{ + data_json: JSON.stringify({ memoryCount: 2, injectionEnabled: true, mode: 'canary' }), + created_at: 2000, + run_id: 'run-9', + }]]; + } + return [[]]; + }, + }), + }), + ); + const res = createResponseRecorder(); + await api.routes.get('GET /user-memory/v1/recall-hint')( + createRequest({ query: { sessionId: 'session-9' } }), + res, + ); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { + ok: true, + memoryCount: 2, + injectionEnabled: true, + mode: 'canary', + createdAt: 2000, + runId: 'run-9', + savedPreview: null, + savedAt: null, + memoryPreviews: [], + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 109683d..1253ff2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -39,6 +39,7 @@ import type { PlanDefinition, ActiveSubscription, UserMemorySyncResponse, + UserMemoryRecallHintResponse, } from '../types'; import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message'; import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode'; @@ -238,6 +239,12 @@ export async function rememberUserMemory(sessionId: string): Promise { + return apiFetch( + `/user-memory/v1/recall-hint?sessionId=${encodeURIComponent(sessionId)}`, + ); +} + export async function syncUserMemory(sessionId: string): Promise { return apiFetch('/user-memory/v1/sync', { method: 'POST', diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 8e0c29a..47550d5 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -187,6 +187,7 @@ export function ChatView({ sessionsHasMore, sessionSearchQuery, messages, + memoryRecallByMessageId, messageHistoryLoadingMore, messageHistoryHasMore, messageHistoryTotal, @@ -559,6 +560,7 @@ export function ChatView({ variant="full" user={user} messages={messages} + memoryRecallByMessageId={memoryRecallByMessageId} historyLoadingMore={messageHistoryLoadingMore} historyHasMore={messageHistoryHasMore} historyTotal={messageHistoryTotal} diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index 0f3e23c..b238419 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'; import { useUserAvatar } from '../hooks/useUserAvatar'; import type { Message } from '../types'; import { getDisplayText, getFileAttachments, getImageUrls, getRenderableImageUrls, getThinking, shouldShowChatMessage } from '../utils/message'; +import { resolveMessageRecallKey } from '../utils/memoryFeedback'; import { getMessageSaveActions } from '../utils/messageSave'; import { renderMarkdown } from '../utils/markdown'; import { filterText } from '../utils/wordFilter'; @@ -169,6 +170,38 @@ function shouldShowEndTypingIndicator(messages: Message[], streaming: boolean) { return true; } +function MemoryRecallBadgeItem({ + recall, +}: { + recall: { count: number; previews: string[] }; +}) { + const [open, setOpen] = useState(false); + const hasPreviews = recall.previews.length > 0; + + return ( +
+ + {open && hasPreviews && ( +
    + {recall.previews.map((preview, index) => ( +
  • {preview}
  • + ))} +
+ )} +
+ ); +} + function ToolBadge({ message, active }: { message: Message; active: boolean }) { if (message.role !== 'assistant') return null; const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse'); @@ -291,6 +324,8 @@ function MessageRow({ compact = false, activeToolMessage = false, showInlineTyping = false, + memoryRecallByMessageId = {}, + messageIndex = 0, }: { message: Message; avatarUrl: string | null; @@ -306,10 +341,13 @@ function MessageRow({ compact?: boolean; activeToolMessage?: boolean; showInlineTyping?: boolean; + memoryRecallByMessageId?: Record; + messageIndex?: number; }) { const [actionsOpen, setActionsOpen] = useState(false); const rawText = getDisplayText(message); const text = filterText(rawText); + const memoryRecall = memoryRecallByMessageId[resolveMessageRecallKey(message, messageIndex)]; const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername }); const thinking = getThinking(message); const isUser = message.role === 'user'; @@ -326,7 +364,10 @@ function MessageRow({ {showInlineTyping ? ( ) : ( - + <> + + {memoryRecall && } + )} @@ -444,6 +485,7 @@ function MessageRow({ /> )} + {memoryRecall && } {showActionsToggle && (