diff --git a/App/backend/local-api-contracts/src/memory-runtime.ts b/App/backend/local-api-contracts/src/memory-runtime.ts index 9be0d84fa..2cc81ba62 100644 --- a/App/backend/local-api-contracts/src/memory-runtime.ts +++ b/App/backend/local-api-contracts/src/memory-runtime.ts @@ -300,7 +300,6 @@ export const StartTurnOutputSchema = z.object({ turnId: NonEmptyStringSchema, contextPacketId: NonEmptyStringSchema, sessionId: NonEmptyStringSchema, - episodeId: NonEmptyStringSchema, injectedContext: InjectedContextSchema, searchEventId: NonEmptyStringSchema, sourceMemoryIds: z.array(NonEmptyStringSchema), @@ -333,11 +332,14 @@ export const CompleteTurnOutputSchema = z.object({ sessionId: NonEmptyStringSchema, episodeId: NonEmptyStringSchema, rawTurnId: NonEmptyStringSchema, - l1MemoryId: NonEmptyStringSchema, + l1MemoryId: z.string(), + l1MemoryIds: z.array(NonEmptyStringSchema), + closedEpisodeIds: z.array(NonEmptyStringSchema), scheduledEvolution: z.boolean(), jobs: z.array(JobRefSchema), changeSeq: z.number().int().nonnegative(), - serverTime: IsoTimeSchema + serverTime: IsoTimeSchema, + duplicate: z.boolean().optional() }); export type CompleteTurnOutput = z.infer; @@ -423,12 +425,15 @@ export const GetMemoryOutputSchema = z.object({ worldModel: z .object({ sourceMemoryIds: z.array(NonEmptyStringSchema), - confidence: z.number().optional() + confidence: z.number().optional(), + summary: z.string().optional() }) .optional(), skill: z .object({ invocationGuide: z.string(), + retrievalBlurb: z.string().optional(), + triggerContext: z.string().optional(), procedure: z.array(z.string()).optional(), sourcePolicyIds: z.array(NonEmptyStringSchema), sourceWorldModelIds: z.array(NonEmptyStringSchema), diff --git a/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts b/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts index 90cf8a93f..f1f051fe0 100644 --- a/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts +++ b/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts @@ -439,7 +439,6 @@ function startTurnOutput() { turnId: "turn-1", contextPacketId: "context-1", sessionId: "session-1", - episodeId: "episode-1", injectedContext: { markdown: "", sections: [] }, searchEventId: "search-1", sourceMemoryIds: [], @@ -454,6 +453,8 @@ function completeTurnOutput() { turnId: "turn-1", sessionId: "session-1", l1MemoryId: "memory-1", + l1MemoryIds: ["memory-1"], + closedEpisodeIds: [], rawTurnId: "raw-1", episodeId: "episode-1", scheduledEvolution: false, diff --git a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts index 0ede0880e..ae07eebbd 100644 --- a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts +++ b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts @@ -191,7 +191,8 @@ export function createHttpMemoryClient( return request("POST", "runWorker", WorkerRunOutputSchema, { body: { limit: input.limit, - targetMemoryIds: input.targetMemoryIds + targetMemoryIds: input.targetMemoryIds, + priorityCohortOnly: input.priorityCohortOnly }, signal: input.signal, timeoutMs: input.timeoutMs diff --git a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts index b5239c504..aba0cf7f8 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts @@ -440,7 +440,6 @@ function startTurnOutput(body: unknown) { turnId: input.turnId ?? "turn-1", contextPacketId: "context-1", sessionId: input.sessionId, - episodeId: "episode-1", injectedContext: { markdown: "", sections: [] }, searchEventId: "search-1", sourceMemoryIds: [], @@ -455,6 +454,8 @@ function completeTurnOutput() { turnId: "turn-1", sessionId: "session-1", l1MemoryId: "memory-1", + l1MemoryIds: ["memory-1"], + closedEpisodeIds: [], rawTurnId: "raw-1", episodeId: "episode-1", scheduledEvolution: false, diff --git a/App/backend/src/adapters/outbound/memory-client/types.ts b/App/backend/src/adapters/outbound/memory-client/types.ts index b3de43710..ba728dd91 100644 --- a/App/backend/src/adapters/outbound/memory-client/types.ts +++ b/App/backend/src/adapters/outbound/memory-client/types.ts @@ -55,6 +55,7 @@ export interface MemoryClient { runWorker(input: { limit: number; targetMemoryIds?: string[]; + priorityCohortOnly?: boolean; signal?: AbortSignal; timeoutMs?: number; }): Promise; diff --git a/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts index 7dc463ab7..067caaeae 100644 --- a/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts @@ -259,7 +259,6 @@ describe("claude code skill target", () => { if (url.pathname === "/api/v1/turns/start") { writeJsonResponse(response, 200, { turnId: "claude-turn-1", - episodeId: "claude-episode-1", sourceMemoryIds: ["claude-memory-1"], injectedContext: { markdown: "Claude historical context" } }); @@ -333,7 +332,7 @@ describe("claude code skill target", () => { answer: "修复已经完成", sourceMemoryIds: ["claude-memory-1"] }); - expect(requests[3]?.body.episodeId).toBe("claude-episode-1"); + expect(requests[3]?.body).not.toHaveProperty("episodeId"); } finally { await close(server); } diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts index 9d7fb12de..7aa38b09e 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts @@ -248,7 +248,6 @@ describe("codex skill target", () => { if (request.method === "POST" && url.pathname === "/api/v1/turns/start") { writeJsonResponse(response, 200, { turnId: "turn-stop-1", - episodeId: "episode-1", sourceMemoryIds: ["memory-1"], injectedContext: { markdown: "Relevant prior context" } }); @@ -331,7 +330,7 @@ describe("codex skill target", () => { source: "codex", sourceMemoryIds: ["memory-1"] }); - expect(requests[3]?.body.episodeId).toBe("episode-1"); + expect(requests[3]?.body).not.toHaveProperty("episodeId"); } finally { await close(server); } diff --git a/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts index d2d169392..1020028ea 100644 --- a/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts @@ -209,7 +209,6 @@ describe("cursor skill target", () => { if (url.pathname === "/api/v1/turns/start") { writeJsonResponse(response, 200, { turnId: "cursor-turn-1", - episodeId: "cursor-episode-1", sourceMemoryIds: ["cursor-memory-1"], injectedContext: { markdown: "Cursor historical context" } }); @@ -296,7 +295,7 @@ describe("cursor skill target", () => { sourceMemoryIds: ["cursor-memory-1"], status: "succeeded" }); - expect(requests[3]?.body.episodeId).toBe("cursor-episode-1"); + expect(requests[3]?.body).not.toHaveProperty("episodeId"); const cancelledEvent = { ...eventBase, diff --git a/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts index f77f05722..e1b0d2186 100644 --- a/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts @@ -138,7 +138,6 @@ describe("opencode skill target", () => { if (targetUrl.pathname === "/api/v1/turns/start") { return jsonResponse({ turnId: "memmy-turn-1", - episodeId: "episode-1", sourceMemoryIds: ["trace-1"], injectedContext: { markdown: "User prefers concise answers." } }); @@ -188,7 +187,6 @@ describe("opencode skill target", () => { expect(requests.find((request) => request.path.endsWith("/complete"))?.body).toMatchObject({ adapterId: "memmy-opencode-plugin", sessionId: "memmy-session-1", - episodeId: "episode-1", query: "请检查 README", answer: "检查完成", status: "succeeded", @@ -196,6 +194,7 @@ describe("opencode skill target", () => { toolResults: [{ tool_call_id: "call-1", content: "README contents", output: "README contents" }], sourceMemoryIds: ["trace-1"] }); + expect(requests.find((request) => request.path.endsWith("/complete"))?.body).not.toHaveProperty("episodeId"); } finally { globalThis.fetch = originalFetch; } diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-default.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-default.ts index faba239d9..591c85eed 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-default.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-default.ts @@ -50,7 +50,7 @@ export function renderMemmyDefaultContent(source: string): string { `memmy-memory turn start --source ${source} --session-id "$SESSION_ID" --query "$USER_QUERY"`, "```", "", - "Use returned `injectedContext` as historical memory context only. Keep the returned `turnId` for completion; `episodeId` identifies the episode selected at turn start. Keep the current user query separate from recalled memory.", + "Use returned `injectedContext` as historical memory context only. Keep the returned `turnId` for completion; the final `episodeId` is returned by `turn complete`. Keep the current user query separate from recalled memory.", "", "At the end of the turn, write the final interaction:", "", diff --git a/App/backend/src/services/agent-source-scan-runner.ts b/App/backend/src/services/agent-source-scan-runner.ts index 05c10bc61..94556a209 100644 --- a/App/backend/src/services/agent-source-scan-runner.ts +++ b/App/backend/src/services/agent-source-scan-runner.ts @@ -134,15 +134,20 @@ export async function runAgentSourceScanJob( } callbacks.onResumeChanged({ phase: "summarize", results }); + const failures = await agentSources.processImportSummaries( + results.flatMap((result) => result.memoryIds ?? []), + { ...scanOptions, progressSourceId: job.sourceId } + ); + const resultByMemoryId = new Map(); for (const result of results) { - const failures = await agentSources.processImportSummaries(result.memoryIds ?? [], { - ...scanOptions, - progressSourceId: result.sourceId - }); - result.errors.push(...failures.map((failure) => ({ + for (const memoryId of result.memoryIds ?? []) resultByMemoryId.set(memoryId, result); + } + for (const failure of failures) { + const result = resultByMemoryId.get(failure.memoryId); + result?.errors.push({ conversationId: failure.memoryId, reason: failure.reason - }))); + }); } if (job.controller.signal.aborted) { return; diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts index 5766fe5d3..bbdf29273 100644 --- a/App/backend/src/services/agent-source-service.ts +++ b/App/backend/src/services/agent-source-service.ts @@ -47,9 +47,7 @@ import { export type { ScanProgress } from "../adapters/outbound/agent-source/types.js"; const SCAN_MESSAGE_YIELD_INTERVAL = 100; -const IMPORT_SUMMARY_PRIORITY_LIMIT = 100; -const IMPORT_SUMMARY_PRIORITY_BATCH_SIZE = 20; -const IMPORT_SUMMARY_STANDARD_BATCH_SIZE = 100; +const IMPORT_WORKER_BATCH_SIZE = 4; const IMPORT_WORKER_TIMEOUT_MS = 600_000; const IMPORT_PROGRESS_POLL_INTERVAL_MS = 250; const INITIAL_GLOBAL_MEMORY_LIMIT = 1_000; @@ -122,13 +120,11 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption async scanAll(scanOptions = {}) { const collected = await this.collectAll(scanOptions); const results = await this.ingestCollected(collected, scanOptions); - for (const result of results) { - const failures = await this.processImportSummaries(result.memoryIds ?? [], { - ...scanOptions, - progressSourceId: result.sourceId - }); - appendProcessingFailures(result, failures); - } + const failures = await this.processImportSummaries( + results.flatMap((result) => result.memoryIds ?? []), + { ...scanOptions, progressSourceId: "all" } + ); + appendProcessingFailuresToResults(results, failures); return results; }, @@ -986,7 +982,6 @@ async function processPendingImportSummaries( const failures: ProcessingFailure[] = []; const progressSourceId = scanOptions.progressSourceId ?? "all"; let indexed = 0; - let prioritySummaries = 0; let lastProgressAt = Date.now(); emitProgress(scanOptions, { sourceId: progressSourceId, @@ -998,20 +993,13 @@ async function processPendingImportSummaries( while (pendingMemoryIds.size > 0) { scanOptions.signal?.throwIfAborted(); - const limit = prioritySummaries < IMPORT_SUMMARY_PRIORITY_LIMIT - ? IMPORT_SUMMARY_PRIORITY_BATCH_SIZE - : IMPORT_SUMMARY_STANDARD_BATCH_SIZE; const result = await options.memoryClient.runWorker({ - limit, - targetMemoryIds: [...pendingMemoryIds], + limit: IMPORT_WORKER_BATCH_SIZE, + priorityCohortOnly: true, signal: scanOptions.signal, timeoutMs: IMPORT_WORKER_TIMEOUT_MS }); - prioritySummaries += result.jobs.filter((job) => - job.jobType === "import_summary" && - Boolean(job.targetMemoryId && pendingMemoryIds.has(job.targetMemoryId)) - ).length; const refreshed = await options.memoryClient.getMemoryProcessingStatus([...pendingMemoryIds]); const processingByMemoryId = new Map(refreshed.items.map((item) => [item.memoryId, item])); const activeMemoryIds = new Set(refreshed.items @@ -1067,6 +1055,20 @@ function appendProcessingFailures(result: ScanResult, failures: readonly Process }))); } +function appendProcessingFailuresToResults( + results: readonly ScanResult[], + failures: readonly ProcessingFailure[] +): void { + const resultByMemoryId = new Map(); + for (const result of results) { + for (const memoryId of result.memoryIds ?? []) resultByMemoryId.set(memoryId, result); + } + for (const failure of failures) { + const result = resultByMemoryId.get(failure.memoryId); + if (result) appendProcessingFailures(result, [failure]); + } +} + async function* toAsyncIterable(messages: readonly ConversationMessage[]): AsyncIterable { for (const message of messages) { diff --git a/App/backend/src/services/tests/agent-source-service.test.ts b/App/backend/src/services/tests/agent-source-service.test.ts index db6a59e27..0f22629b2 100644 --- a/App/backend/src/services/tests/agent-source-service.test.ts +++ b/App/backend/src/services/tests/agent-source-service.test.ts @@ -466,9 +466,92 @@ describe("agent source service", () => { expect(events).toEqual(["scan:cursor", "scan:custom", "ingest:cursor", "ingest:custom"]); }); + it("enqueues every scanned source into one global priority drain", async () => { + const baseMemoryClient = createMockMemoryClient(); + const enqueueCalls: string[][] = []; + const workerCalls: Array<{ + targetMemoryIds?: string[]; + priorityCohortOnly?: boolean; + }> = []; + const service = createService({ + adapters: [ + createFakeAdapter("cursor", [createMessage("cursor", 1)]), + createFakeAdapter("custom", [createMessage("custom", 1)]) + ], + ingestionService: { + async ingest(messages, ctx) { + for await (const _message of messages) { + // Consume the source stream before returning its durable memory id. + } + return { + attempted: 1, + written: 1, + deduped: 0, + failed: 0, + writtenMemories: 1, + dedupedMemories: 0, + failedMemories: 0, + memoryIds: [`memory-${ctx.sourceId}`], + conversations: 1, + completedConversationIds: [], + incompleteConversationIds: [], + failedConversationIds: [], + errors: [] + }; + } + }, + memoryClient: { + ...baseMemoryClient, + async enqueueImportSummaries(memoryIds) { + enqueueCalls.push([...(memoryIds ?? [])]); + return { + enqueued: memoryIds?.length ?? 0, + memoryIds: memoryIds ?? [], + serverTime: "2026-05-28T10:00:00.000Z" + }; + }, + async runWorker(input) { + workerCalls.push(input); + return baseMemoryClient.runWorker(input); + }, + async getMemoryProcessingStatus(memoryIds) { + return { + items: memoryIds.map((memoryId) => ({ + memoryId, + state: "ready" as const, + stage: null, + activeJobId: null, + attemptCount: 1, + manualRetryCount: 0, + retryAction: "retry" as const, + errorCode: null, + errorMessage: null, + failedAt: null, + updatedAt: "2026-05-28T10:00:00.000Z" + })), + serverTime: "2026-05-28T10:00:00.000Z" + }; + } + } + }); + + await service.scanAll(); + + expect(enqueueCalls).toEqual([["memory-cursor", "memory-custom"]]); + expect(workerCalls).toEqual([ + expect.objectContaining({ + limit: 4, + priorityCohortOnly: true + }) + ]); + expect(workerCalls[0]?.targetMemoryIds).toBeUndefined(); + }); + it("reconciles summary progress when another worker finishes the scan memories", async () => { const baseMemoryClient = createMockMemoryClient(); const workerTargets: string[][] = []; + const workerLimits: number[] = []; + const workerPriorityCohorts: Array = []; let enqueueCalls = 0; const memoryClient: MemoryClient = { ...baseMemoryClient, @@ -500,6 +583,8 @@ describe("agent source service", () => { }, async runWorker(input) { workerTargets.push(input.targetMemoryIds ?? []); + workerLimits.push(input.limit); + workerPriorityCohorts.push(input.priorityCohortOnly); return baseMemoryClient.runWorker(input); } }; @@ -515,7 +600,9 @@ describe("agent source service", () => { } })).resolves.toEqual([]); - expect(workerTargets).toEqual([["memory-a", "memory-b"]]); + expect(workerTargets).toEqual([[]]); + expect(workerLimits).toEqual([4]); + expect(workerPriorityCohorts).toEqual([true]); expect(progress).toEqual([ { current: 0, total: 2 }, { current: 2, total: 2 } diff --git a/App/backend/src/tests/memory-runtime-contracts.test.ts b/App/backend/src/tests/memory-runtime-contracts.test.ts index 82b12c88f..4f8bff9ec 100644 --- a/App/backend/src/tests/memory-runtime-contracts.test.ts +++ b/App/backend/src/tests/memory-runtime-contracts.test.ts @@ -225,11 +225,11 @@ function closeSessionOutput() { } function startTurnOutput() { - return { turnId: "turn-1", contextPacketId: "context-1", sessionId: "session-1", episodeId: "episode-1", injectedContext: injectedContext(), searchEventId: "search-1", sourceMemoryIds: ["memory-1"], hits: [recallHit()], status: [], serverTime: ISO }; + return { turnId: "turn-1", contextPacketId: "context-1", sessionId: "session-1", injectedContext: injectedContext(), searchEventId: "search-1", sourceMemoryIds: ["memory-1"], hits: [recallHit()], status: [], serverTime: ISO }; } function completeTurnOutput() { - return { turnId: "turn-1", sessionId: "session-1", l1MemoryId: "memory-1", rawTurnId: "raw-1", episodeId: "episode-1", scheduledEvolution: true, jobs: [jobRef()], changeSeq: 3, serverTime: ISO }; + return { turnId: "turn-1", sessionId: "session-1", l1MemoryId: "memory-1", l1MemoryIds: ["memory-1"], closedEpisodeIds: [], rawTurnId: "raw-1", episodeId: "episode-1", scheduledEvolution: true, jobs: [jobRef()], changeSeq: 3, serverTime: ISO }; } function searchOutput() { diff --git a/App/backend/src/tests/support/mock-memory-client.ts b/App/backend/src/tests/support/mock-memory-client.ts index 4dcd8f0cf..acaa73e7b 100644 --- a/App/backend/src/tests/support/mock-memory-client.ts +++ b/App/backend/src/tests/support/mock-memory-client.ts @@ -101,7 +101,6 @@ export function createMockMemoryClient(options: CreateMockMemoryClientOptions = turnId: input.turnId ?? randomUUID(), contextPacketId: randomUUID(), sessionId: input.sessionId, - episodeId: randomUUID(), injectedContext: { markdown: "", sections: [] @@ -122,6 +121,8 @@ export function createMockMemoryClient(options: CreateMockMemoryClientOptions = episodeId: randomUUID(), rawTurnId: randomUUID(), l1MemoryId: randomUUID(), + l1MemoryIds: [], + closedEpisodeIds: [], scheduledEvolution: false, jobs: [], ...nextChange(), diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index d3c6284cf..297440f0c 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -1001,7 +1001,7 @@ export const zhCNMessages = { "memory.policies.sourceMemories": "来源记忆", "memory.policies.noSourceTasks": "暂无来源任务", "memory.policies.noSourceMemories": "暂无来源记忆", - "memory.skills.invocationGuide": "调用指南", + "memory.skills.invocationGuide": "适用场景", "memory.skills.body": "SKILL.md 内容", "memory.skills.decisionGuidance": "决策指引", "memory.skills.prefer": "推荐做法", @@ -2360,7 +2360,7 @@ export const enUSMessages: Record = { "memory.policies.sourceMemories": "Source memories", "memory.policies.noSourceTasks": "No source tasks", "memory.policies.noSourceMemories": "No source memories", - "memory.skills.invocationGuide": "Invocation guide", + "memory.skills.invocationGuide": "When to use", "memory.skills.body": "SKILL.md content", "memory.skills.decisionGuidance": "Decision guidance", "memory.skills.prefer": "Preferred actions", diff --git a/App/frontend/desktop/src/pages/memory-sources-page.tsx b/App/frontend/desktop/src/pages/memory-sources-page.tsx index 1c2a24f93..8eff685f8 100644 --- a/App/frontend/desktop/src/pages/memory-sources-page.tsx +++ b/App/frontend/desktop/src/pages/memory-sources-page.tsx @@ -858,7 +858,7 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { type="button" onClick={openFullScanConfirm} disabled={isScanning} - className="flex items-start gap-3 rounded-card border-content-panel bg-status-error-soft/50 p-3 text-left transition-all hover:bg-status-error-soft/60 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-status-error/20" + className="flex items-start gap-3 rounded-card border-content-panel bg-background-paper/70 p-3 text-left transition-all hover:bg-background-paper disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-status-error/20" > diff --git a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx index 76ee1244e..93c3e8e36 100644 --- a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx @@ -924,7 +924,7 @@ function TraceTurnEventBlock(props: { event: TraceTurnEvent }) { if (event.kind === "tool") { return ( - +
diff --git a/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx b/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx index d524f957e..02d764cb1 100644 --- a/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx @@ -60,8 +60,7 @@ interface SkillView { createdAt: string; updatedAt: string; body: string; - summary: string; - invocationGuide: string; + usageGuide: string; decisionGuidance: SkillDecisionGuidance; evidenceAnchors: string[]; sourcePolicyIds: string[]; @@ -443,7 +442,7 @@ function SkillDetail(props: { detail: GetMemoryOutput; timeline: SkillTimelineEn - + {skill.usageGuide && } {hasDecisionGuidance && ( @@ -676,10 +675,28 @@ function skillFromDetail(detail: GetMemoryOutput): SkillView { const properties = recordValue(metadata.properties); const info = recordValue(metadata.info); const internalInfo = recordValue(properties.internal_info); + const layerSkill = recordValue(detail.item.skill); const skill = recordValue(firstDefined(internalInfo.skill, metadata.skill, properties.skill)); + const procedure = recordValue(firstDefined(skill.procedureJson, skill.procedure_json, internalInfo.procedureJson, internalInfo.procedure_json)); const decisionGuidance = readDecisionGuidance( firstDefined(skill.decisionGuidance, skill.decision_guidance, internalInfo.decisionGuidance, internalInfo.decision_guidance) ); + const body = cleanMemoryBody(detail.item.body); + const shortUsageGuide = uniqueStrings([ + firstString(layerSkill.retrievalBlurb, layerSkill.retrieval_blurb, procedure.retrievalBlurb, procedure.retrieval_blurb) ?? "", + firstString(layerSkill.triggerContext, layerSkill.trigger_context, procedure.triggerContext, procedure.trigger_context) ?? "" + ]).join("\n\n"); + const parsedUsageGuide = parseMarkdownSection(detail.item.body, ["When to use", "\u9002\u7528\u573a\u666f", "\u8c03\u7528\u65f6\u673a"]); + const legacyInvocationGuide = firstString( + layerSkill.invocationGuide, + skill.invocationGuide, + skill.invocation_guide, + internalInfo.invocationGuide, + internalInfo.invocation_guide + ); + const distinctLegacyGuide = legacyInvocationGuide && cleanMemoryBody(legacyInvocationGuide) !== body + ? legacyInvocationGuide + : ""; return { title: displaySkillTitle(detail.item, firstString(skill.title, internalInfo.title)), @@ -687,15 +704,8 @@ function skillFromDetail(detail: GetMemoryOutput): SkillView { source: firstString(metadata.source, internalInfo.source), createdAt: detail.item.createdAt, updatedAt: detail.item.updatedAt, - body: cleanMemoryBody(detail.item.body), - summary: cleanMemoryText(detail.item.summary), - invocationGuide: firstString( - skill.invocationGuide, - skill.invocation_guide, - internalInfo.invocationGuide, - internalInfo.invocation_guide, - parseMarkdownSection(detail.item.body, ["Invocation", "\u8c03\u7528\u6307\u5357", "\u8c03\u7528"]) - ) ?? "", + body, + usageGuide: shortUsageGuide || parsedUsageGuide || distinctLegacyGuide, decisionGuidance, evidenceAnchors: readEvidenceAnchors(firstDefined(skill.evidenceAnchors, skill.evidence_anchors, internalInfo.evidenceAnchors, internalInfo.evidence_anchors)), sourcePolicyIds: stringArray(firstDefined(skill.sourcePolicyIds, skill.source_policy_ids, internalInfo.sourcePolicyIds, internalInfo.source_policy_ids)), diff --git a/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx index 6c3422d99..425b03126 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx @@ -199,8 +199,10 @@ describe("MemoriesSubPage", () => { expect(html).toContain("摘要"); expect(html).toContain("阅读策略、指标和组合模型相关文件。"); expect(html).toContain("相关步骤"); - expect(html).toContain("工具调用 · rg"); - expect(html).toContain("工具调用 · npm_test"); + expect(html).not.toContain("工具调用 · rg"); + expect(html).not.toContain("工具调用 · npm_test"); + expect(html).toContain('memory-tool-card__name">rg'); + expect(html).toContain('memory-tool-card__name">npm_test'); expect(html).not.toContain("正文"); expect(html).not.toContain("Goal:"); expect(html).not.toContain("Summary:"); @@ -521,7 +523,7 @@ describe("MemoriesSubPage", () => { }); const firstThinkingIndex = html.indexOf("先调用系统命令检查内存。"); - const toolIndex = html.indexOf("工具调用 · exec"); + const toolIndex = html.indexOf('memory-tool-card__name">exec'); const secondThinkingIndex = html.indexOf("工具返回 16 GB 后确认答案。"); const assistantIndex = html.indexOf("这台电脑的内存是 16 GB。"); expect(firstThinkingIndex).toBeGreaterThan(-1); @@ -629,8 +631,9 @@ describe("MemoriesSubPage", () => { expect(html).toContain("用户"); expect(html).not.toContain("用户 Query"); expect(html).toContain("记忆管理"); - expect(html).toContain("工具调用 · read_file"); - expect(html).toContain("read_file"); + expect(html).not.toContain("工具调用 · read_file"); + expect(html.match(/read_file/g)?.length).toBe(1); + expect(html).toContain('memory-tool-card__name">read_file'); expect(html).toContain("输入"); expect(html).toContain("输出"); expect(html).toContain("读取 MemoryPage 页面结构。"); diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts index a3f19e090..2442b256b 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts +++ b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts @@ -329,7 +329,6 @@ export function createMockMemoryRuntimeClient(): MemoryRuntimeClient { turnId: input.turnId ?? "mock-turn", contextPacketId: "context-1", sessionId: input.sessionId, - episodeId: "mock-episode", injectedContext: { markdown: "- 用户偏好中文注释", sections: [] }, searchEventId: "search-1", sourceMemoryIds: hits.map((hit) => hit.id), @@ -343,6 +342,8 @@ export function createMockMemoryRuntimeClient(): MemoryRuntimeClient { turnId: "mock-turn", sessionId: "mock-session", l1MemoryId: "memory-trace-1", + l1MemoryIds: ["memory-trace-1"], + closedEpisodeIds: [], rawTurnId: "raw-turn-1", episodeId: "mock-episode", scheduledEvolution: true, diff --git a/App/frontend/desktop/src/pages/memory/tests/skills-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/skills-sub-page.test.tsx index 14287522d..c9636705b 100644 --- a/App/frontend/desktop/src/pages/memory/tests/skills-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/skills-sub-page.test.tsx @@ -36,7 +36,25 @@ describe("SkillsSubPage", () => { const html = renderSkills({ status: "ready", data: skillPanelItemsFixture, - detail: { status: "ready", data: { detail: skillPanelDetailFixture, timeline: skillTimelineEntries() } } + detail: { + status: "ready", + data: { + detail: { + ...skillPanelDetailFixture, + item: { + ...skillPanelDetailFixture.item, + skill: { + invocationGuide: skillPanelDetailFixture.item.body, + retrievalBlurb: "根据仓库真实代码补齐中文文件级、函数级和字段含义注释。", + triggerContext: "当用户要求补充或修正中文代码注释时使用。", + sourcePolicyIds: ["memory-policy-1"], + sourceWorldModelIds: [] + } + } + }, + timeline: skillTimelineEntries() + } + } }); expect(html).toContain("根据仓库真实代码补齐中文文件级、函数级和字段含义注释。"); expect(html).toContain("先读文件"); @@ -48,7 +66,7 @@ describe("SkillsSubPage", () => { expect(html).toContain("memory-delete-button"); expect(html).toContain('data-icon="trash-2"'); expect(html).not.toContain(">v4<"); - expect(html).toContain("调用指南"); + expect(html).toContain("适用场景"); expect(html).toContain("来源经验"); expect(html).toContain("memory-policy-1"); expect(html).toContain("进化时间线"); @@ -91,6 +109,18 @@ describe("SkillsSubPage", () => { expect(html).not.toContain(">resolving<"); }); + it("旧技能缺少短召回字段时不把完整正文重复显示为适用场景", () => { + const html = renderSkills({ + status: "ready", + data: skillPanelItemsFixture, + detail: { status: "ready", data: { detail: skillPanelDetailFixture, timeline: [] } } + }); + + expect(html).toContain("SKILL.md 内容"); + expect(html).not.toContain("适用场景"); + expect(html).not.toContain("根据仓库真实代码补齐中文文件级、函数级和字段含义注释。"); + }); + it("技能列表生命周期和详情技能状态使用同一套展示状态", () => { expect(skillStatusTone("resolving")).toBe("candidate"); expect(skillStatusTone("candidate")).toBe("candidate"); diff --git a/App/frontend/desktop/src/pages/memory/tests/world-model-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/world-model-sub-page.test.tsx index 8e5820f1a..22595cab9 100644 --- a/App/frontend/desktop/src/pages/memory/tests/world-model-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/world-model-sub-page.test.tsx @@ -32,13 +32,14 @@ const worldDetail: GetMemoryOutput = { properties: { internal_info: { world_model: { + summary: "Memmy 的本地记忆服务按层暴露记忆,并由管理页直接读取。", policyIds: ["memory-policy-1"], structure: { environment: [ { label: "本地记忆底座", description: "记忆服务通过 panel items 暴露 L1/L2/L3/Skill 数据。", - evidenceIds: ["memory-trace-1"] + evidenceIds: ["memory-trace-1", "po_1", "tr_fake"] } ], inference: [ @@ -147,8 +148,13 @@ describe("WorldModelSubPage", () => { expect(html).toContain('data-icon="trash-2"'); expect(html).toContain("候选"); expect(html).toContain("结构化认知"); + expect(html).toContain("Memmy 的本地记忆服务按层暴露记忆,并由管理页直接读取。"); + expect(html).not.toContain("Memmy 是本地记忆 sidecar,不负责调度外部 Agent 任务队列。"); expect(html).toContain("环境拓扑"); expect(html).toContain("本地记忆底座"); + expect(html).toContain("memory-trace-1"); + expect(html).not.toContain("po_1"); + expect(html).not.toContain("tr_fake"); expect(html).toContain("memory-policy-1"); }); }); diff --git a/App/frontend/desktop/src/pages/memory/world-model-sub-page.tsx b/App/frontend/desktop/src/pages/memory/world-model-sub-page.tsx index 9d9c93bf9..7cd5bdc83 100644 --- a/App/frontend/desktop/src/pages/memory/world-model-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/world-model-sub-page.tsx @@ -350,6 +350,9 @@ function WorldModelDrawer(props: { detail: WorldModelDetailState; onClose: () => function WorldModelDetail(props: { detail: GetMemoryOutput }) { const { t } = useTranslation(); const worldModel = worldModelFromDetail(props.detail); + const hasStructuredCognition = worldModel.structure.environment.length > 0 || + worldModel.structure.inference.length > 0 || + worldModel.structure.constraints.length > 0; return ( <> @@ -370,9 +373,10 @@ function WorldModelDetail(props: { detail: GetMemoryOutput }) { )} - - - + {worldModel.summary && } + {hasStructuredCognition + ? + : } @@ -491,6 +495,7 @@ function worldModelFromDetail(detail: GetMemoryOutput): WorldModelView { const metadata = detail.item.metadata; const properties = recordValue(metadata.properties); const internalInfo = recordValue(properties.internal_info); + const layerWorldModel = recordValue(detail.item.worldModel); const worldModel = recordValue(firstDefined(internalInfo.world_model, internalInfo.worldModel, metadata.world_model, metadata.worldModel)); const structure = readWorldModelStructure( firstDefined(worldModel.structure, internalInfo.structure, properties.structure, metadata.structure) @@ -503,7 +508,7 @@ function worldModelFromDetail(detail: GetMemoryOutput): WorldModelView { createdAt: detail.item.createdAt, updatedAt: detail.item.updatedAt, body: cleanMemoryBody(detail.item.body), - summary: displayWorldModelSummary(detail.item), + summary: cleanWorldModelText(firstString(layerWorldModel.summary, worldModel.summary, internalInfo.summary)), policyIds: stringArray(firstDefined(worldModel.policyIds, worldModel.policy_ids, internalInfo.policyIds, internalInfo.policy_ids)), sourceMemoryIds: detail.item.sourceMemoryIds, structure @@ -550,10 +555,16 @@ function structureEntry(value: unknown, key?: string): WorldModelStructureEntry return { label: label ?? description, description, - evidenceIds: stringArray(firstDefined(record.evidenceIds, record.evidence_ids, record.sourceMemoryIds, record.source_memory_ids)) + evidenceIds: stringArray( + firstDefined(record.evidenceIds, record.evidence_ids, record.sourceMemoryIds, record.source_memory_ids) + ).filter(isDisplayableWorldModelEvidenceId) }; } +function isDisplayableWorldModelEvidenceId(value: string): boolean { + return /^(?:policy_|trace_|memory-(?:policy|trace)-)[a-z0-9_-]+$/i.test(value); +} + function displayWorldModelTitle( item: Pick & { body?: string }, ...candidates: Array @@ -566,15 +577,6 @@ function displayWorldModelTitle( return displayMemoryId(item.id); } -function displayWorldModelSummary(item: Pick & { body?: string }): string { - for (const value of [item.summary, firstReadableWorldBodyLine(item.body), item.title]) { - const text = cleanWorldModelText(value); - if (isDisplayableWorldModelText(text)) return text; - } - - return ""; -} - function firstReadableWorldBodyLine(body?: string): string | undefined { return cleanMemoryBody(body) .split(/\r?\n/) diff --git a/App/frontend/desktop/src/pages/tests/app-frame.test.tsx b/App/frontend/desktop/src/pages/tests/app-frame.test.tsx index 3b15c7c39..a3a487855 100644 --- a/App/frontend/desktop/src/pages/tests/app-frame.test.tsx +++ b/App/frontend/desktop/src/pages/tests/app-frame.test.tsx @@ -237,7 +237,7 @@ describe("AppFrame", () => { it("positions the task action menu as a top-level viewport overlay", () => { const overlayStyle = resolveSidebarMenuOverlayStyle( - { right: 188, bottom: 424 }, + { left: 196, right: 188, bottom: 424 }, { width: 512, height: 768 }, { width: 128, height: 128, margin: 8, gap: 4 } ); @@ -720,6 +720,7 @@ describe("AppFrame", () => { onPin={() => undefined} onRequestArchive={() => undefined} onConfirmArchive={() => undefined} + onCancelArchive={() => undefined} /> ); @@ -731,6 +732,7 @@ describe("AppFrame", () => { onPin={() => undefined} onRequestArchive={() => undefined} onConfirmArchive={() => undefined} + onCancelArchive={() => undefined} /> ); @@ -742,6 +744,7 @@ describe("AppFrame", () => { onPin={() => undefined} onRequestArchive={() => undefined} onConfirmArchive={() => undefined} + onCancelArchive={() => undefined} /> ); diff --git a/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx index 5e19f91b3..0ed115695 100644 --- a/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx @@ -242,7 +242,6 @@ function PickerHarness(props: { projects={projects} registryState={props.registryState ?? "ready"} disabled={false} - canChooseOtherFolder onToggle={() => setOpen((current) => !current)} onClose={() => setOpen(false)} onSelect={(nextTarget) => { diff --git a/App/memmy-agent/README.md b/App/memmy-agent/README.md index e61787923..50e7fbf73 100644 --- a/App/memmy-agent/README.md +++ b/App/memmy-agent/README.md @@ -158,6 +158,7 @@ agents: providers: openai: apiKey: ${OPENAI_API_KEY} + apiBase: https://example.com/v1 # Optional: override the provider's default endpoint ``` Set the referenced environment variable in the current shell: @@ -169,6 +170,10 @@ export OPENAI_API_KEY="your-api-key" `memmy-agent` resolves `${ENV_NAME}` and `${ENV_NAME:fallback}` placeholders when it loads the configuration. +`providers.*.apiBase` works with OpenAI-compatible and Anthropic-compatible +gateways. For an unregistered OpenAI-compatible gateway, use `provider: custom` +and configure `providers.custom.apiKey` plus `providers.custom.apiBase`. + OAuth providers support explicit login and logout: ```bash diff --git a/App/memmy-agent/src/providers/factory.ts b/App/memmy-agent/src/providers/factory.ts index 19c5ef5df..05acab721 100644 --- a/App/memmy-agent/src/providers/factory.ts +++ b/App/memmy-agent/src/providers/factory.ts @@ -82,7 +82,13 @@ function makeProviderCore( } else if (backend === "openai_compat" && !model.startsWith("bedrock/")) { const exempt = Boolean(spec?.isOauth || spec?.isLocal || spec?.isDirect); if (!providerConfig?.apiKey && !exempt) { - throw new ValueError(`No API key configured for provider '${providerName}'.`); + if (!providerName && resolved.provider !== "auto") { + throw new ValueError( + `Provider '${resolved.provider}' is not a known provider. Use a provider name from ` + + `the registry, or 'custom' with providers.custom.apiBase set.`, + ); + } + throw new ValueError(`No API key configured for provider '${providerName ?? resolved.provider}'.`); } } diff --git a/App/memmy-agent/tests/memmy-memory/hook.test.ts b/App/memmy-agent/tests/memmy-memory/hook.test.ts index b95261f5c..18af9d1a3 100644 --- a/App/memmy-agent/tests/memmy-memory/hook.test.ts +++ b/App/memmy-agent/tests/memmy-memory/hook.test.ts @@ -13,7 +13,6 @@ function fakeClient() { startTurn: vi.fn(async (turnId: string, body: any) => ({ turnId, sessionId: body.sessionId, - episodeId: "ep-1", sourceMemoryIds: ["trace-source"], injectedContext: { markdown: "Relevant prior memory." }, })), @@ -115,12 +114,12 @@ describe("MemmyMemoryHook", () => { const completeBody = (client.completeTurn as any).mock.calls[0][1]; expect(completeBody).toMatchObject({ sessionId: "session-generated-1", - episodeId: "ep-1", query: "Please continue", answer: "Done", sourceMemoryIds: ["trace-source"], status: "succeeded" }); + expect(completeBody).not.toHaveProperty("episodeId"); expect(completeBody.requestId).toMatch(/^memmy-agent-complete:/u); expect(hook.currentTurnId("cli:direct")).toBeNull(); }); diff --git a/App/memmy-agent/tests/providers/providers-init.test.ts b/App/memmy-agent/tests/providers/providers-init.test.ts index fcd2a9a9b..fe6877d4b 100644 --- a/App/memmy-agent/tests/providers/providers-init.test.ts +++ b/App/memmy-agent/tests/providers/providers-init.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Config, ValueError } from "../../src/config/schema.js"; +import { Config } from "../../src/config/schema.js"; import { AnthropicProvider } from "../../src/providers/anthropic-provider.js"; import { AzureOpenAIProvider } from "../../src/providers/azure-openai-provider.js"; import { GitHubCopilotProvider } from "../../src/providers/github-copilot-provider.js"; @@ -32,9 +32,12 @@ describe("provider initialization", () => { }); it("validates required provider credentials", () => { - expect(() => makeProvider(new Config({ agents: { defaults: { provider: "openai", model: "gpt-4.1" } } }))).toThrow( - ValueError, - ); + expect(() => + makeProvider(new Config({ agents: { defaults: { provider: "not_registered", model: "gpt-4.1" } } })), + ).toThrow("Provider 'not_registered' is not a known provider"); + expect(() => + makeProvider(new Config({ agents: { defaults: { provider: "openai", model: "gpt-4.1" } } })), + ).toThrow("No API key configured for provider 'openai'"); expect(() => makeProvider(new Config({ agents: { defaults: { provider: "azure", model: "deployment" } } })), ).toThrow("Azure OpenAI requires apiKey and apiBase"); diff --git a/App/shell/desktop/electron-builder.unsigned.yml b/App/shell/desktop/electron-builder.unsigned.yml index b35483193..5c26ab431 100644 --- a/App/shell/desktop/electron-builder.unsigned.yml +++ b/App/shell/desktop/electron-builder.unsigned.yml @@ -9,6 +9,10 @@ directories: files: - dist/**/* - package.json + - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}" + - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*" + - "!**/node_modules/**/*.{test,spec}.*" + - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}" asar: true asarUnpack: diff --git a/App/shell/desktop/electron-builder.win.unsigned.yml b/App/shell/desktop/electron-builder.win.unsigned.yml index 7068fa33c..3b57c9297 100644 --- a/App/shell/desktop/electron-builder.win.unsigned.yml +++ b/App/shell/desktop/electron-builder.win.unsigned.yml @@ -9,6 +9,10 @@ directories: files: - dist/**/* - package.json + - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}" + - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*" + - "!**/node_modules/**/*.{test,spec}.*" + - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}" asar: true asarUnpack: diff --git a/App/shell/desktop/electron-builder.win.yml b/App/shell/desktop/electron-builder.win.yml index 751a617c2..453e1d5ec 100644 --- a/App/shell/desktop/electron-builder.win.yml +++ b/App/shell/desktop/electron-builder.win.yml @@ -9,6 +9,10 @@ directories: files: - dist/**/* - package.json + - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}" + - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*" + - "!**/node_modules/**/*.{test,spec}.*" + - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}" asar: true asarUnpack: diff --git a/App/shell/desktop/electron-builder.yml b/App/shell/desktop/electron-builder.yml index 983d4eedf..ca761e1fb 100644 --- a/App/shell/desktop/electron-builder.yml +++ b/App/shell/desktop/electron-builder.yml @@ -9,6 +9,10 @@ directories: files: - dist/**/* - package.json + - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}" + - "!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*" + - "!**/node_modules/**/*.{test,spec}.*" + - "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}" asar: true asarUnpack: diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index 255d54b58..d5bc8e9bc 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -234,6 +234,29 @@ describe("desktop packaged runtime boundaries", () => { } }); + it("excludes dependency tests and docs from every desktop app archive", () => { + for (const configPath of [ + electronBuilderPath, + unsignedElectronBuilderPath, + winElectronBuilderPath, + winUnsignedBuilderPath + ]) { + const config = parseYaml(readFileSync(configPath, "utf8")) as { + files?: string[]; + }; + const files = config.files ?? []; + + expect(files).toContain("dist/**/*"); + expect(files).toContain("!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}"); + expect(files).toContain("!**/node_modules/**/{test,tests,__tests__,doc,docs,example,examples,coverage,.github}/**/*"); + expect(files).toContain("!**/node_modules/**/*.{test,spec}.*"); + expect(files).toContain( + "!**/node_modules/**/{README,README*.md,README*.mdown,README*.markdown,README*.rst,README*.txt,CHANGELOG,CHANGELOG*.md,CHANGELOG*.mdown,CHANGELOG*.markdown,CHANGELOG*.rst,CHANGELOG*.txt,CONTRIBUTING,CONTRIBUTING*.md,CONTRIBUTING*.mdown,CONTRIBUTING*.markdown,CONTRIBUTING*.rst,CONTRIBUTING*.txt,CODE_OF_CONDUCT,CODE_OF_CONDUCT*.md,CODE_OF_CONDUCT*.mdown,CODE_OF_CONDUCT*.markdown,CODE_OF_CONDUCT*.rst,CODE_OF_CONDUCT*.txt,SECURITY,SECURITY*.md,SECURITY*.mdown,SECURITY*.markdown,SECURITY*.rst,SECURITY*.txt}" + ); + expect(files).not.toContain("!**/node_modules/**/*.md"); + } + }); + it("unpacks the sqlite-vec native extension in every desktop package variant", () => { for (const configPath of [ electronBuilderPath, @@ -1019,6 +1042,29 @@ describe("desktop packaged runtime boundaries", () => { expect(winSource).toContain("sqlite-vec-windows-x64/vec0.*"); }); + it("prunes third-party package docs and tests from macOS runtime before packaging", () => { + const source = readFileSync(packageMacDmgPath, "utf8"); + + expect(source).toContain("prune_node_modules_non_runtime_files"); + expect(source).toContain('prune_node_modules_non_runtime_files "$RUNTIME_DIR"'); + expect(source).toContain("-name tests"); + expect(source).toContain("-name docs"); + expect(source).toContain('-iname "README*.md"'); + expect(source).toContain('-iname "README*.mdown"'); + expect(source).toContain('-iname "CHANGELOG*.md"'); + expect(source).toContain('-iname "SECURITY*.md"'); + expect(source).toContain('-iname "*.test.js"'); + expect(source).toContain('-iname "*.test.ts"'); + expect(source).toContain('! \\( \\'); + expect(source).toContain('-iname "LICENSE*"'); + expect(source).toContain('-iname "NOTICE*"'); + expect(source).toContain('rm -f "$RUNTIME_DIR/memmy-agent/dist/skills/README.md"'); + + expect(source.indexOf('prune_node_modules_non_runtime_files "$RUNTIME_DIR"')).toBeLessThan( + source.indexOf("npx electron-builder"), + ); + }); + it("sets an explicit edition in macOS package wrappers", () => { for (const [name, accountChannel, edition] of [ ["cn-unsigned", "phone", "cn"], diff --git a/Memory/src/algorithm/plugin-algorithms.ts b/Memory/src/algorithm/plugin-algorithms.ts index 1942a21da..5a6be7105 100644 --- a/Memory/src/algorithm/plugin-algorithms.ts +++ b/Memory/src/algorithm/plugin-algorithms.ts @@ -102,6 +102,8 @@ export interface SkillMemoryMeta { sourceWorldModelIds: string[]; evidenceAnchorIds: string[]; invocationGuide: string; + retrievalBlurb?: string; + triggerContext?: string; trialsAttempted: number; trialsPassed: number; repairOrigin: boolean; @@ -425,7 +427,10 @@ function detectFeedbackPreference( }; } if (/(prefer|instead|should use|下次用|改用|而不是)/.test(normalized)) { - return { shape: "preference", confidence: 0.55 }; + return { + shape: "preference", + confidence: feedbackMatchesAny(normalized, FEEDBACK_NEGATIVE_PATTERNS) ? 0.75 : 0.55 + }; } return null; } @@ -1047,7 +1052,8 @@ Fields: - turnSummaries: chronological L1 summaries of the episode. - finalExchange: exact trailing user and assistant text. - execution: authoritative aggregate tool outcome. -- feedback: explicit or implicit user signal; implicit feedback is weaker. +- feedback: the latest explicit or implicit user signal; implicit feedback is weaker. +- feedbackHistory: all captured user signals in chronological order. - host: authoritative host-agent identity/model context. Do not project your own identity, provider, policies, or capabilities onto the host agent. @@ -1057,7 +1063,8 @@ Score three independent axes in [-1, 1]: - user_satisfaction: -1 correction/frustration, 0 no signal, +1 acceptance. Rules: -- Judge goal achievement against mission, using turnSummaries in order. +- Judge goal achievement against the active goal, using turnSummaries in order. If later user turns revise or replace the initial mission within the same episode, grade the latest active goal. +- Treat feedback chronologically. A negative correction followed by demonstrated recovery or explicit acceptance is not a permanent failure. - If execution.completedByTool is "no", goal_achievement must not exceed 0 unless a later summary shows a successful recovery. - Explicit negative feedback without later recovery means goal_achievement <= 0. @@ -1278,7 +1285,7 @@ Rules: export const L3_ABSTRACTION_PROMPT = { id: "l3.abstraction", - version: 2, + version: 3, description: "Distill an L3 world model (declarative environment knowledge) from a cluster of L2 policies, with explicit boundaries against L2 procedural drift.", system: `You abstract environment world models from cross-task policy evidence. @@ -1357,14 +1364,19 @@ Return JSON: "title": "short noun phrase, e.g. 'Alpine python dependency model'", "domain_tags": ["tag1", "tag2"], // 1-4 short, lowercase, no spaces "environment": [ - { "label": "...", "description": "...", "evidenceIds": ["po_...", "tr_..."] } + { "label": "...", "description": "...", "evidenceIds": ["policy_", "trace_"] } ], "inference": [ { "label": "...", "description": "...", "evidenceIds": [] } ], "constraints": [ { "label": "...", "description": "...", "evidenceIds": [] } ], - "body": "rendered markdown summary of the three sections", + "summary": "1-3 sentences describing the environment and its most important invariants", "confidence": number in [0, 1], "supersedes_world_ids": [] -}` +} + +Evidence ID rules: +- Copy evidence IDs exactly from the input lines prefixed with "policy" or "trace". +- Never abbreviate, rewrite, or invent an evidence ID. +- Use [] when no supplied ID directly supports an entry.` } as const; export const SKILL_CRYSTALLIZE_PROMPT = { @@ -1484,6 +1496,7 @@ export interface WorldModelMemoryMeta { cohesion: number; admission: "strict" | "loose"; structure: WorldModelStructure; + summary?: string; body: string; vec: number[] | null; } @@ -1510,6 +1523,7 @@ export interface WorldModelDraft { cohesion: number; admission: "strict" | "loose"; structure: WorldModelStructure; + summary: string; body: string; vec: number[] | null; tags: string[]; @@ -3250,10 +3264,32 @@ export function policyMetaFromMemory(memory: MemoryRow): PolicyMemoryMeta | null }; } +export function failureAvoidancePolicyIsRetrievalEligible(policy: PolicyMemoryMeta): boolean { + if (policy.experienceType !== "failure_avoidance" && policy.evidencePolarity !== "negative") { + return true; + } + if (policy.confidence < 0.6 || !policy.trigger.trim()) return false; + const preferences = new Set(policy.decisionGuidance.preference.map(normalizeGuidanceForComparison).filter(Boolean)); + const antiPatterns = new Set(policy.decisionGuidance.antiPattern.map(normalizeGuidanceForComparison).filter(Boolean)); + if (preferences.size === 0 || antiPatterns.size === 0) return false; + return [...preferences].some((item) => !antiPatterns.has(item)); +} + +function normalizeGuidanceForComparison(value: string): string { + return value + .toLowerCase() + .replace(/^(?:avoid|prefer|safer behavior)\s*:\s*/i, "") + .replace(/[\s.。!!??,,;;::]+$/g, "") + .replace(/\s+/g, " ") + .trim(); +} + export function skillMetaFromMemory(memory: MemoryRow): SkillMemoryMeta | null { if (memory.memoryLayer !== "Skill") return null; const skill = getInternal>(memory, "skill"); if (!skill) return null; + const procedure = recordField(skill, "procedure_json") ?? + recordField(memory.properties.internal_info as Record, "procedure_json"); return { id: memory.id, memory, @@ -3266,6 +3302,12 @@ export function skillMetaFromMemory(memory: MemoryRow): SkillMemoryMeta | null { evidenceAnchorIds: stringArrayField(skill, "evidence_anchor_ids") .concat(stringArrayField(skill, "evidence_anchors")), invocationGuide: stringField(skill, "invocation_guide") ?? memory.memoryValue, + retrievalBlurb: procedure + ? stringField(procedure, "retrievalBlurb") ?? stringField(procedure, "retrieval_blurb") + : undefined, + triggerContext: procedure + ? stringField(procedure, "triggerContext") ?? stringField(procedure, "trigger_context") + : undefined, trialsAttempted: numberField(skill, "trials_attempted") ?? 0, trialsPassed: numberField(skill, "trials_passed") ?? 0, repairOrigin: booleanishField(skill, "repairOrigin") ?? booleanishField(skill, "repair_origin") ?? false, @@ -3297,11 +3339,51 @@ export function worldModelMetaFromMemory(memory: MemoryRow): WorldModelMemoryMet cohesion: numberField(wm, "cohesion") ?? 1, admission: statusField(wm, "admission", ["strict", "loose"]) ?? "strict", structure: worldModelStructureField(wm, "structure"), + summary: stringField(wm, "summary") ?? stringField(memory.properties.internal_info as Record, "summary"), body: stringField(wm, "body") ?? memory.memoryValue, vec: memoryVector(memory, "vec") }; } +export const RETRIEVAL_DOCUMENT_VERSION = 2; + +/** Builds the canonical text shared by vector, FTS, and in-memory retrieval for Skill and L3. */ +export function retrievalDocumentForMemory(memory: MemoryRow): string { + const skill = skillMetaFromMemory(memory); + if (skill) { + const shortGuide = [skill.retrievalBlurb, skill.triggerContext].filter(Boolean); + return [skill.name, ...(shortGuide.length > 0 ? shortGuide : [skill.invocationGuide]), memory.tags.join(" ")] + .filter(Boolean) + .join("\n"); + } + + const world = worldModelMetaFromMemory(memory); + if (world) { + const structuredFacts = world.summary + ? [ + ...world.structure.environment, + ...world.structure.inference, + ...world.structure.constraints + ].map((entry) => [entry.label, entry.description].filter(Boolean).join(": ")) + : [world.body]; + return [world.title, world.summary, world.domainTags.join(" "), ...structuredFacts] + .filter(Boolean) + .join("\n"); + } + + return memory.memoryValue; +} + +export function retrievalDocumentSourceHash(memory: MemoryRow): string { + return stableHash(retrievalDocumentForMemory(memory)); +} + +export function retrievalDocumentIsCurrent(memory: MemoryRow): boolean { + const index = recordField(memory.properties.internal_info as Record, "retrieval_index"); + return numberField(index ?? {}, "version") === RETRIEVAL_DOCUMENT_VERSION && + stringField(index ?? {}, "source_hash") === retrievalDocumentSourceHash(memory); +} + function worldModelTitleFromMemory(memory: MemoryRow, wm: Record): string { return firstWorldModelDisplayString( stringField(wm, "title"), @@ -3611,6 +3693,7 @@ export function buildWorldModelDraft(args: { admission, cohesion }); + const summary = fallbackWorldModelSummary(title, structure); const body = [ title, `Admission: ${admission} (cohesion=${round(cohesion, 4)})`, @@ -3635,6 +3718,7 @@ export function buildWorldModelDraft(args: { cohesion, admission, structure, + summary, body, vec: center, tags: distinct(["world_model", ...tags]) @@ -4621,6 +4705,15 @@ function fallbackWorldModelStructure(input: { }; } +function fallbackWorldModelSummary(title: string, structure: WorldModelStructure): string { + const facts = [ + structure.environment[0]?.description, + structure.inference[0]?.description, + structure.constraints[0]?.description + ].filter((value): value is string => Boolean(value?.trim())); + return [title, ...facts].join(" — "); +} + function skillNameFromPolicy(policy: PolicyMemoryMeta): string { const raw = policy.title .replace(/^Policy:\s*/i, "") @@ -4917,6 +5010,9 @@ function candidateFromMemory( if (memory.memoryLayer === "L2" && policy?.status === "archived") { return null; } + if (memory.memoryLayer === "L2" && policy && !failureAvoidancePolicyIsRetrievalEligible(policy)) { + return null; + } if (memory.memoryLayer === "L3" && (world?.confidence ?? 0) < options.config.minWorldModelConfidence) { return null; } @@ -5461,11 +5557,11 @@ function memoryTextForRetrieval(memory: MemoryRow): string { } const skill = skillMetaFromMemory(memory); if (skill) { - return [skill.name, skill.invocationGuide].join("\n"); + return retrievalDocumentForMemory(memory); } const world = worldModelMetaFromMemory(memory); if (world) { - return [world.title, world.body, world.domainTags.join(" ")].join("\n"); + return retrievalDocumentForMemory(memory); } return memory.memoryValue; } diff --git a/Memory/src/cli/skills/memmy-memory/references/turn-complete.md b/Memory/src/cli/skills/memmy-memory/references/turn-complete.md index bee90556c..75be561b9 100644 --- a/Memory/src/cli/skills/memmy-memory/references/turn-complete.md +++ b/Memory/src/cli/skills/memmy-memory/references/turn-complete.md @@ -17,6 +17,7 @@ API shape: - `sessionId`, `query`, and `answer` are required; - `status` is optional and normalized to `succeeded` or `failed`. - `source` should be passed as `--source ` by installed agent skills. +- the response returns the final `episodeId` after episode routing and turn persistence commit together. Never store: - secrets, credentials, access tokens, private keys, or passwords; @@ -51,4 +52,5 @@ Working rules: - keep `answer` accurate to the actual result; - use `--status failed` when the task failed but the result is still useful to remember; - do not call this command for a user-cancelled turn; +- use the returned `episodeId` as the turn's final episode assignment; - save returned memory ids when later inspection or deletion may be needed. diff --git a/Memory/src/cli/skills/memmy-memory/references/turn-start.md b/Memory/src/cli/skills/memmy-memory/references/turn-start.md index f75a461a9..e33915af2 100644 --- a/Memory/src/cli/skills/memmy-memory/references/turn-start.md +++ b/Memory/src/cli/skills/memmy-memory/references/turn-start.md @@ -17,10 +17,10 @@ API shape: - `query` is required; - `turnId` is optional; - `source` should be passed as `--source ` by installed agent skills; -- the response includes the selected `episodeId` and may include injected context, hits, status, and source memory ids; -- the operation selects, opens, closes, or reopens an episode as needed and records the recall; -- it creates a `started` RawTurn, attaches it to the selected episode, and records the recall; -- it does not create an L1 memory before the turn is completed. +- the response includes `turnId` and may include injected context, hits, status, and source memory ids; +- the operation records the recall and an internal episode-routing proposal without changing episode state; +- it does not create a RawTurn, episode, L1 memory, or evolution job before the turn is completed; +- the final `episodeId` is selected and returned by `turn complete`. Do not use this command to: - create a session; @@ -48,7 +48,6 @@ memmy-memory turn start --source codex --session-id se_123 --query "fix failing Working rules: - use the returned `turnId` in `turn complete`; -- retain the returned `episodeId`; the same `turnId` is also used server-side to bind `turn complete` to that episode; - read `injectedContext`, `hits`, and `status` before relying on the context; - treat returned `injectedContext` as historical memory only, not as the current user request; - keep the current user request separate and authoritative when using recalled memory; diff --git a/Memory/src/server/http.ts b/Memory/src/server/http.ts index c6405d1c2..146263129 100644 --- a/Memory/src/server/http.ts +++ b/Memory/src/server/http.ts @@ -210,9 +210,7 @@ function createAutoWorkerDrain( let startupTimer: ReturnType | undefined; let delayedTimer: ReturnType | undefined; const maxCycles = 40; - const priorityJobLimit = 100; - const priorityBatchSize = 20; - const standardBatchSize = 100; + const workerBatchSize = 4; async function drain(): Promise { if (disposed) { @@ -235,16 +233,13 @@ function createAutoWorkerDrain( } do { requested = false; - let prioritySummariesDuringDrain = 0; for (let cycle = 0; cycle < maxCycles; cycle += 1) { - const limit = prioritySummariesDuringDrain < priorityJobLimit ? priorityBatchSize : standardBatchSize; - const result = await service.runWorkerOnce(limit, {}); + const result = await service.runWorkerOnce(workerBatchSize, { + priorityCohortOnly: true + }); if (result.leased === 0 && result.embeddingRetries.leased === 0) { break; } - prioritySummariesDuringDrain += result.jobs.filter((job) => - job.jobType === "trace_summary" || job.jobType === "import_summary" - ).length; if (cycle === maxCycles - 1) { continueSoon = true; } @@ -578,12 +573,14 @@ async function routeRequest( const request = envelopeWithPrincipal(asObject(body, "worker.run"), principal) as RequestEnvelope & { limit?: unknown; targetMemoryIds?: unknown; + priorityCohortOnly?: unknown; }; return service.runWorkerOnce( parseNumberValue(request.limit) ?? parseNumber(url.searchParams.get("limit")) ?? 20, { ...request, - targetMemoryIds: parseOptionalStringArray(request.targetMemoryIds, "worker.run.targetMemoryIds") + targetMemoryIds: parseOptionalStringArray(request.targetMemoryIds, "worker.run.targetMemoryIds"), + priorityCohortOnly: request.priorityCohortOnly === true } ); } @@ -744,10 +741,13 @@ function publicCompleteTurnResponse(result: unknown): Record { episodeId: record.episodeId, rawTurnId: record.rawTurnId, l1MemoryId: record.l1MemoryId, + l1MemoryIds: record.l1MemoryIds, + closedEpisodeIds: record.closedEpisodeIds, scheduledEvolution: record.scheduledEvolution, jobs: record.jobs, changeSeq: record.changeSeq, - serverTime: record.serverTime + serverTime: record.serverTime, + ...(record.duplicate === true ? { duplicate: true } : {}) }; } @@ -757,7 +757,6 @@ function publicStartTurnResponse(result: unknown): Record { turnId: record.turnId, contextPacketId: record.contextPacketId, sessionId: record.sessionId, - episodeId: record.episodeId, searchEventId: record.searchEventId, injectedContext: record.injectedContext, sourceMemoryIds: record.sourceMemoryIds, diff --git a/Memory/src/service/embedding/embedding-job-processor.ts b/Memory/src/service/embedding/embedding-job-processor.ts index 7a304c1a1..eaa85e819 100644 --- a/Memory/src/service/embedding/embedding-job-processor.ts +++ b/Memory/src/service/embedding/embedding-job-processor.ts @@ -7,7 +7,7 @@ import { clip,firstLine } from "../../utils/text.js"; * generic job-enqueue policy; this processor owns the job-specific state * transitions, model calls, and change records. */ -import { traceMetaFromMemory } from "../../algorithm/plugin-algorithms.js"; +import { retrievalDocumentSourceHash,traceMetaFromMemory } from "../../algorithm/plugin-algorithms.js"; import type { Embedder,LlmClient } from "../../model/types.js"; import type { EmbeddingRetryRecord,EmbeddingRetryVectorField,EvolutionJobRecord,Repositories } from "../../storage/repositories.js"; import { kindFromMemory } from "../../storage/repositories.js"; @@ -42,6 +42,7 @@ export interface PreparedEmbeddingJob { text: string; role: "document" | "query"; vectorField: EmbeddingRetryVectorField; + sourceHash?: string; } export interface EnqueueWorkerJobInput { @@ -62,6 +63,7 @@ export interface PersistEmbeddingVectorInput { vector: number[]; attemptCount: number; source: string; + sourceHash?: string; allowedProcessingStates?: MemoryProcessingState[]; finalize?: (saved: MemoryRow, hadProcessing: boolean, at: string) => void; } @@ -142,12 +144,16 @@ export class EmbeddingJobProcessor { return { job, memory, text, role: "document", vectorField: "vec_summary" }; } + const text = embeddingTextForMemory(memory); return { job, memory, - text: embeddingTextForMemory(memory), + text, role: "query", - vectorField: "vec" + vectorField: "vec", + sourceHash: memory.memoryLayer === "Skill" || memory.memoryLayer === "L3" + ? retrievalDocumentSourceHash(memory) + : undefined }; } @@ -155,12 +161,14 @@ export class EmbeddingJobProcessor { const current = this.deps.repos.memories.get(item.memory.id); if (!current) throw new Error(`embedding target not found: ${item.memory.id}`); if (!processingJobMatchesMemory(item.job, current)) return; + if (item.sourceHash && retrievalDocumentSourceHash(current) !== item.sourceHash) return; this.persistEmbeddingVector({ memoryId: current.id, vectorField: item.vectorField, vector, attemptCount: item.job.attempts, source: "worker.embedding", + sourceHash: item.sourceHash, allowedProcessingStates: ["embedding_pending", "embedding"], finalize: (_saved, hadProcessing, at) => { if (hadProcessing) this.deps.repos.runtime.completeJob(item.job.id, at); @@ -177,7 +185,8 @@ export class EmbeddingJobProcessor { const vectorized = updateMemoryVectorField(current, input.vectorField, input.vector, { model: this.deps.embedder.config.model ?? this.deps.embedder.config.provider, provider: this.deps.embedder.config.provider, - updatedAt: at + updatedAt: at, + sourceHash: input.sourceHash }); saved = this.deps.repos.memories.updateMaintenance( current.memoryLayer === "L1" ? updateImportPipelineStatus(vectorized, "indexed", at) : vectorized diff --git a/Memory/src/service/embedding/embedding-pipeline.ts b/Memory/src/service/embedding/embedding-pipeline.ts index 8716bc750..10a2a1cef 100644 --- a/Memory/src/service/embedding/embedding-pipeline.ts +++ b/Memory/src/service/embedding/embedding-pipeline.ts @@ -7,6 +7,8 @@ import type { } from "../../storage/repositories.js"; import { policyMetaFromMemory, + RETRIEVAL_DOCUMENT_VERSION, + retrievalDocumentForMemory, skillMetaFromMemory, traceMetaFromMemory, worldModelMetaFromMemory @@ -49,11 +51,11 @@ export function embeddingTextForMemory(memory: MemoryRow): string { } const skill = skillMetaFromMemory(memory); if (skill) { - return [skill.name, skill.invocationGuide].filter(Boolean).join("\n"); + return retrievalDocumentForMemory(memory); } const world = worldModelMetaFromMemory(memory); if (world) { - return [world.title, world.body, world.domainTags.join(" ")].filter(Boolean).join("\n"); + return retrievalDocumentForMemory(memory); } return memory.memoryValue; } @@ -129,7 +131,7 @@ export function updateMemoryVectorField( memory: MemoryRow, vectorField: EmbeddingRetryVectorField, vector: number[], - input: { provider: string; model: string; updatedAt: string } + input: { provider: string; model: string; updatedAt: string; sourceHash?: string } ): MemoryRow { const internal = memory.properties.internal_info; const nextInternal: Record = { ...internal }; @@ -142,6 +144,13 @@ export function updateMemoryVectorField( } else if (memory.memoryLayer === "Skill" && isRecord(internal.skill)) { nextInternal.skill = { ...internal.skill }; } + if ((memory.memoryLayer === "L3" || memory.memoryLayer === "Skill") && input.sourceHash) { + nextInternal.retrieval_index = { + version: RETRIEVAL_DOCUMENT_VERSION, + source_hash: input.sourceHash, + indexed_at: input.updatedAt + }; + } const updated = { ...memory, diff --git a/Memory/src/service/evolution/negative-experience-pipeline.ts b/Memory/src/service/evolution/negative-experience-pipeline.ts index 03f444f1b..983de91a6 100644 --- a/Memory/src/service/evolution/negative-experience-pipeline.ts +++ b/Memory/src/service/evolution/negative-experience-pipeline.ts @@ -1,4 +1,8 @@ -import { policyMetaFromMemory } from "../../algorithm/plugin-algorithms.js"; +import { + classifyFeedbackText, + traceMetaFromMemory, + type FeedbackTextShape +} from "../../algorithm/plugin-algorithms.js"; import type { MemmyConfig } from "../../config/index.js"; import type { DecisionRepairRecord, @@ -10,6 +14,7 @@ import type { import type { MemoryRow } from "../../types.js"; import { stableHash } from "../../utils/id.js"; import { isRecord } from "../../utils/json.js"; +import { clip } from "../../utils/text.js"; import { profileIdFromMemory, projectIdFromMemory @@ -42,6 +47,8 @@ interface NegativeExperienceDraft { verification: string; confidence: number; salience: number; + evidenceStrength: number; + feedbackShape?: FeedbackTextShape; } export interface NegativeExperiencePipelineDeps { @@ -66,7 +73,10 @@ export class NegativeExperiencePipeline { if (!draft || !isActionableNegativeExperience(draft)) return; const config = this.deps.config.algorithm.negativeExperience; - const sourceTraceIds = draft.episode.l1MemoryIds.slice(0, config.maxSourceIds); + const sourceTraceIds = (draft.sourceMemory + ? [draft.sourceMemory.id] + : draft.episode.l1MemoryIds.slice(0, 1)) + .slice(0, config.maxSourceIds); const signature = negativeExperienceSignature(draft); const scopeIdentity = [ (draft.sourceMemory ? projectIdFromMemory(draft.sourceMemory) : undefined) ?? draft.episode.projectId ?? "", @@ -139,6 +149,7 @@ export class NegativeExperiencePipeline { gain: 0, raw_gain: 0, policy_confidence: draft.confidence, + evidence_strength: draft.evidenceStrength, salience: draft.salience, status: "candidate", experience_type: "failure_avoidance", @@ -180,6 +191,7 @@ export class NegativeExperiencePipeline { gain: 0, raw_gain: 0, policy_confidence: draft.confidence, + evidence_strength: draft.evidenceStrength, status: "candidate", source_episode_ids: mergedEpisodeIds, source_trace_ids: mergedTraceIds, @@ -253,11 +265,15 @@ export class NegativeExperiencePipeline { const repairId = text(job.payload.repairId); const feedback = feedbackId ? this.deps.repos.runtime.getFeedback(feedbackId) : undefined; const repair = repairId ? this.deps.repos.runtime.getDecisionRepair(repairId) : undefined; - const sourceMemory = episode.l1MemoryIds - .map((id) => this.deps.repos.memories.get(id)) - .find((memory): memory is MemoryRow => Boolean(memory)); + const sourceMemory = feedback?.l1MemoryId + ? this.deps.repos.memories.get(feedback.l1MemoryId) + : [...episode.l1MemoryIds].reverse() + .map((id) => this.deps.repos.memories.get(id)) + .find((memory): memory is MemoryRow => Boolean(memory)); + const sourceTrace = sourceMemory ? traceMetaFromMemory(sourceMemory) : null; const rawTurns = this.deps.repos.runtime.listRawTurnsByEpisode(episode.id); const trigger = text(job.payload.triggerCondition) + ?? text(sourceTrace?.userText) ?? rawTurns.find((turn) => text(turn.userText))?.userText?.trim() ?? text(episode.title) ?? text(episode.summary) @@ -266,8 +282,10 @@ export class NegativeExperiencePipeline { ?? text(episode.rewardDetail.reason) ?? text(isRecord(episode.meta.reward) ? episode.meta.reward.reason : undefined); const issue = text(job.payload.issue) ?? repair?.issue; + const feedbackText = feedback?.rationale ?? issue ?? ""; + const feedbackClassification = classifyFeedbackText(feedbackText); const antiPattern = stripGuidanceLabel(text(job.payload.antiPattern) - ?? feedback?.rationale + ?? text(sourceTrace?.agentText) ?? repair?.antiPattern ?? issue ?? rewardReason @@ -278,8 +296,14 @@ export class NegativeExperiencePipeline { ?? feedback?.rationale ?? (rewardReason ? `Address and verify this failure before continuing: ${rewardReason}` : "")); const sourceBasis = sourceBasisFor(source, feedback); + const feedbackConfidence = feedback?.polarity === "negative" && isOperationalSaferBehavior(feedbackText) + ? Math.max(0.65, feedbackClassification.confidence) + : feedbackClassification.confidence; + const repairConfidence = number(repair?.meta.confidence); const rawConfidence = number(job.payload.confidence) - ?? feedback?.magnitude + ?? (source === "negative_feedback" && feedback + ? Math.max(repairConfidence ?? 0, feedbackConfidence) + : repairConfidence) ?? (typeof episode.rTask === "number" ? Math.abs(episode.rTask) : 0); const confidenceCap = sourceBasis === "implicit_failure_analysis" ? this.deps.config.algorithm.negativeExperience.implicitConfidenceCap @@ -292,9 +316,9 @@ export class NegativeExperiencePipeline { sourceMemory, feedback, repair, - trigger, - antiPattern, - preference, + trigger: clip(trigger, 240), + antiPattern: clip(antiPattern, 360), + preference: clip(preference, 360), verification: text(job.payload.verification) ?? "Check that the plan avoids the historical failure mode before acting.", confidence: clamp(rawConfidence, 0, confidenceCap), @@ -302,21 +326,15 @@ export class NegativeExperiencePipeline { typeof episode.rTask === "number" ? Math.abs(episode.rTask) : 0, feedback?.magnitude ?? 0, number(repair?.meta.confidence) ?? 0 - ), 0, 1) + ), 0, 1), + evidenceStrength: clamp(feedback?.magnitude ?? Math.abs(episode.rTask ?? 0), 0, 1), + ...(feedback ? { feedbackShape: feedbackClassification.shape } : {}) }; } private findExisting(draft: NegativeExperienceDraft, key: string): MemoryRow | undefined { - const sameEpisode = this.deps.repos.memories - .list({ memoryLayer: "L2" }, 1000) - .find((memory) => { - const policy = policyMetaFromMemory(memory); - return policy?.experienceType === "failure_avoidance" - && policy.evidencePolarity === "negative" - && policy.sourceEpisodeIds.includes(draft.episode.id); - }); - return sameEpisode - ?? this.deps.repos.memories.getByKey("L2", key); + void draft; + return this.deps.repos.memories.getByKey("L2", key); } } @@ -343,12 +361,26 @@ function sourceBasisFor( function isActionableNegativeExperience(draft: NegativeExperienceDraft): boolean { if (!draft.trigger.trim() || !draft.antiPattern.trim() || !draft.preference.trim()) return false; + const minConfidence = draft.sourceBasis === "tool_failure_burst" ? 0.4 : 0.6; + if (draft.confidence < minConfidence) return false; + if (normalizeSignatureText(draft.antiPattern) === normalizeSignatureText(draft.preference)) return false; + if (draft.sourceBasis === "user_corrective_feedback") { + if (!draft.feedbackShape || draft.feedbackShape === "confusion") { + return false; + } + if (!isOperationalSaferBehavior(draft.preference)) return false; + } return !( isGenericNegativeGuidance(draft.antiPattern) && isGenericNegativeGuidance(draft.preference) ); } +function isOperationalSaferBehavior(value: string): boolean { + return /\b(use|avoid|verify|check|confirm|must|should|instead|report|explain|cite|link)\b/i.test(value) || + /(使用|改用|避免|不要|验证|检查|确认|必须|应该|说明|注明|引用|链接|先)/.test(value); +} + function isGenericNegativeGuidance(value: string): boolean { const normalized = value .toLowerCase() diff --git a/Memory/src/service/evolution/reward-pipeline.ts b/Memory/src/service/evolution/reward-pipeline.ts index c64f2ad18..9c4b0f511 100644 --- a/Memory/src/service/evolution/reward-pipeline.ts +++ b/Memory/src/service/evolution/reward-pipeline.ts @@ -74,11 +74,21 @@ export class RewardPipeline { const rewardSource = this.rewardSourceForJob(job); if (!rewardSource) return; const { source, trace } = rewardSource; - const hasFeedbackSignal = + const episode = trace.episodeId ? this.deps.repos.runtime.getEpisode(trace.episodeId) : undefined; + if (episode && episode.status !== "closed") return; + const phase = episode ? "final" : "feedback"; + const payloadHasFeedback = typeof job.payload.polarity === "string" || typeof job.payload.magnitude === "number" || typeof job.payload.rationale === "string"; - const fallbackFeedback = heuristicHumanScore(hasFeedbackSignal + const episodeFeedback = episode?.feedbackIds + .map((id) => this.deps.repos.runtime.getFeedback(id)) + .filter((item): item is NonNullable => Boolean(item)) ?? []; + const latestEpisodeFeedback = [...episodeFeedback].reverse().find((item) => item.channel === "explicit") + ?? episodeFeedback[episodeFeedback.length - 1]; + const fallbackFeedback = heuristicHumanScore(latestEpisodeFeedback + ? [latestEpisodeFeedback] + : payloadHasFeedback ? [{ channel: job.payload.channel === "implicit" ? "implicit" : "explicit", polarity: job.payload.polarity === "negative" @@ -95,7 +105,7 @@ export class RewardPipeline { .map((memory) => this.deps.traceMeta(memory)) .filter((item): item is TraceMeta => Boolean(item && item.episodeId === trace.episodeId)) .sort((a, b) => a.ts - b.ts); - const skipReason = hasFeedbackSignal + const skipReason = episodeFeedback.length > 0 || payloadHasFeedback ? null : rewardSkipReason(episodeTraces, this.deps.config.algorithm.reward); if (skipReason && trace.episodeId) { @@ -103,6 +113,7 @@ export class RewardPipeline { const scoredAt = this.deps.nowIso(); const rewardDetail = { rHuman: 0, + phase, source: "heuristic", axes: { goalAchievement: 0, processQuality: 0, userSatisfaction: 0 }, reason: skipReason, @@ -119,7 +130,8 @@ export class RewardPipeline { ...(previousEpisode?.meta.closeReason === "finalized" ? {} : { closeReason: "abandoned", abandonReason: skipReason }), - reward: rewardDetail + reward: rewardDetail, + rewardDirty: null } }); if (savedEpisode) { @@ -152,6 +164,7 @@ export class RewardPipeline { const previousEpisode = this.deps.repos.runtime.getEpisode(trace.episodeId); const rewardDetail = { rHuman: feedback.rHuman, + phase, source: feedback.source, axes: feedback.axes, reason: feedback.reason, @@ -167,7 +180,7 @@ export class RewardPipeline { const savedEpisode = this.deps.repos.runtime.updateEpisodeReward(trace.episodeId, { rTask: feedback.rHuman, rewardDetail, - metaPatch: { reward: rewardDetail } + metaPatch: { reward: rewardDetail, rewardDirty: null } }); rewardedEpisode = savedEpisode; if (savedEpisode) { @@ -188,10 +201,10 @@ export class RewardPipeline { this.deps.config.algorithm.negativeExperience.enabled && feedback.rHuman <= this.deps.config.algorithm.negativeExperience.failureRTaskThreshold ) { - const feedbackId = typeof job.payload.feedbackId === "string" + const feedbackId = job.payload.polarity === "negative" && typeof job.payload.feedbackId === "string" ? job.payload.feedbackId : undefined; - const repairId = typeof job.payload.repairId === "string" + const repairId = feedbackId && typeof job.payload.repairId === "string" ? job.payload.repairId : undefined; this.deps.enqueueJob({ @@ -346,6 +359,9 @@ export class RewardPipeline { episode, episodeTraces: input.episodeTraces, feedbackPayload: input.payload, + feedbackHistory: episode?.feedbackIds + .map((id) => this.deps.repos.runtime.getFeedback(id)) + .filter((item): item is NonNullable => Boolean(item)), summaryMaxChars: this.deps.config.algorithm.reward.summaryMaxChars })) } @@ -552,6 +568,12 @@ export interface RewardEpisodeInput { magnitude: number; rationale?: string; }; + feedbackHistory?: Array<{ + channel: "explicit" | "implicit"; + polarity: "positive" | "neutral" | "negative"; + magnitude: number; + rationale?: string; + }>; host?: { agent?: string; agentIdentity?: string; @@ -567,6 +589,12 @@ export function buildRewardEpisodeInput(input: { episode?: EpisodeRecord; episodeTraces: readonly TraceMeta[]; feedbackPayload: Record; + feedbackHistory?: Array<{ + channel: "explicit" | "implicit"; + polarity: "positive" | "neutral" | "negative"; + magnitude: number; + rationale?: string; + }>; summaryMaxChars: number; }): RewardEpisodeInput { const traces = input.episodeTraces.length @@ -579,6 +607,12 @@ export function buildRewardEpisodeInput(input: { const first = traces[0] ?? input.trace; const last = traces[traces.length - 1] ?? input.trace; const feedback = rewardFeedbackInput(input.feedbackPayload); + const feedbackHistory = input.feedbackHistory?.map((item) => ({ + channel: item.channel, + polarity: item.polarity, + magnitude: item.magnitude, + ...(item.rationale ? { rationale: rewardOneLine(item.rationale, 240) } : {}) + })); const host = rewardHostInput(input.source, input.episode); return { mission: rewardOneLine(rewardEpisodeMission(input.episode, first.userText), 400), @@ -589,6 +623,7 @@ export function buildRewardEpisodeInput(input: { }, execution: rewardExecutionOutcome(traces), ...(feedback ? { feedback } : {}), + ...(feedbackHistory?.length ? { feedbackHistory } : {}), ...(host ? { host } : {}) }; } diff --git a/Memory/src/service/evolution/world-model-pipeline.ts b/Memory/src/service/evolution/world-model-pipeline.ts index ad94bdcf4..226ed923c 100644 --- a/Memory/src/service/evolution/world-model-pipeline.ts +++ b/Memory/src/service/evolution/world-model-pipeline.ts @@ -140,9 +140,13 @@ export class WorldModelPipeline { } const rawDraft = enhancement.draft; const existing = this.findWorldModelMergeTarget(rawDraft); - const draft = existing + const mergedDraft = existing ? mergeWorldModelDraftForUpdate(rawDraft, existing, this.deps.config.algorithm.l3Abstraction.confidenceDelta) : rawDraft; + const draft = { + ...mergedDraft, + body: renderWorldModelBody(mergedDraft.title, mergedDraft.structure) + }; const l3 = this.deps.buildMemory({ userId, conversationId: source?.conversationId, @@ -169,6 +173,7 @@ export class WorldModelPipeline { plugin_algorithm: "l3.abstraction.v7", source_memory_ids: draft.policyIds, title: draft.title, + summary: draft.summary, body: draft.body, structure: draft.structure, domain_tags: draft.domainTags, @@ -183,6 +188,7 @@ export class WorldModelPipeline { cohesion: draft.cohesion, admission: draft.admission, structure: draft.structure, + summary: draft.summary, body: draft.body, vec: draft.vec } @@ -362,9 +368,11 @@ private async enhanceWorldModelDrafts( const selectedPolicies = policies .filter((policy) => fallback.policyIds.includes(policy.id)) .slice(0, 8); + const allowedEvidenceIds = new Set(); const languageSamples: Array = []; const policySummaries = selectedPolicies .map((policy) => { + allowedEvidenceIds.add(policy.id); const traces = this.gatherWorldModelEvidence(policy); languageSamples.push( policy.title, @@ -374,6 +382,7 @@ private async enhanceWorldModelDrafts( policy.boundary ); for (const trace of traces) { + allowedEvidenceIds.add(trace.id); languageSamples.push(trace.userText, trace.agentText, trace.reflection); } const traceBlocks = traces @@ -386,7 +395,7 @@ private async enhanceWorldModelDrafts( ].join("\n")) .join("\n"); return capText([ - `- ${policy.title}`, + `- policy ${policy.id}: ${policy.title}`, ` trigger=${policy.trigger}`, ` procedure=${policy.procedure}`, ` verification=${policy.verification}`, @@ -398,7 +407,7 @@ private async enhanceWorldModelDrafts( .join("\n"); const result = await this.deps.skillLlm.completeJson<{ title?: unknown; - body?: unknown; + summary?: unknown; structure?: unknown; environment?: unknown; inference?: unknown; @@ -436,10 +445,12 @@ private async enhanceWorldModelDrafts( continue; } const title = skillText(result.title); - const structure = coerceWorldModelStructure(result, fallback.structure); - const body = typeof result.body === "string" && skillMarkdown(result.body) - ? skillMarkdown(result.body) - : renderWorldModelBody(title, structure); + const structure = coerceWorldModelStructure(result, fallback.structure, allowedEvidenceIds); + const body = renderWorldModelBody(title, structure); + const generatedSummary = skillText(result.summary); + const summary = generatedSummary && generatedSummary !== body + ? generatedSummary + : renderWorldModelSummary(title, structure); const domainTags = normaliseWorldModelTags(result.domain_tags); const effectiveDomainTags = domainTags.length > 0 ? domainTags : fallback.domainTags; out.push({ @@ -447,6 +458,7 @@ private async enhanceWorldModelDrafts( draft: { ...fallback, title, + summary, body, structure, confidence: shapeWorldModelConfidence( @@ -569,19 +581,21 @@ function l3AbstractionInvalidReason(result: unknown): string | null { function coerceWorldModelStructure( result: Record, - fallback: WorldModelDraft["structure"] + fallback: WorldModelDraft["structure"], + allowedEvidenceIds: ReadonlySet ): WorldModelDraft["structure"] { const rawStructure = isRecord(result.structure) ? result.structure : {}; return { - environment: coerceWorldModelEntries(rawStructure.environment ?? result.environment, fallback.environment), - inference: coerceWorldModelEntries(rawStructure.inference ?? result.inference, fallback.inference), - constraints: coerceWorldModelEntries(rawStructure.constraints ?? result.constraints, fallback.constraints) + environment: coerceWorldModelEntries(rawStructure.environment ?? result.environment, fallback.environment, allowedEvidenceIds), + inference: coerceWorldModelEntries(rawStructure.inference ?? result.inference, fallback.inference, allowedEvidenceIds), + constraints: coerceWorldModelEntries(rawStructure.constraints ?? result.constraints, fallback.constraints, allowedEvidenceIds) }; } function coerceWorldModelEntries( value: unknown, - fallback: WorldModelDraft["structure"]["environment"] + fallback: WorldModelDraft["structure"]["environment"], + allowedEvidenceIds: ReadonlySet ): WorldModelDraft["structure"]["environment"] { if (!Array.isArray(value)) return fallback; const entries = value @@ -590,7 +604,10 @@ function coerceWorldModelEntries( const label = skillText(item.label); const description = skillMarkdown(firstString(item.description, item.body, item.text)); if (!label && !description) return null; - const evidenceIds = stringArray(item.evidenceIds ?? item.evidence_ids); + const evidenceIds = uniq( + stringArray(item.evidenceIds ?? item.evidence_ids) + .filter((id) => allowedEvidenceIds.has(id)) + ); return { label: label || description.slice(0, 32), description, @@ -635,6 +652,18 @@ function renderWorldModelBody( return lines.join("\n").trim(); } +function renderWorldModelSummary( + title: string, + structure: WorldModelDraft["structure"] +): string { + const facts = [ + structure.environment[0]?.description, + structure.inference[0]?.description, + structure.constraints[0]?.description + ].filter((value): value is string => Boolean(value?.trim())); + return capText([title, ...facts].join(" — "), 500); +} + function skillText(value: unknown): string { return stripDangerousMarkdownLinks(stripUnsafeHtml(skillRawString(value))) .replace(SKILL_CONTROL_RE, "") diff --git a/Memory/src/service/feedback/feedback-experience.ts b/Memory/src/service/feedback/feedback-experience.ts index db5f50c0f..14a0d8b8c 100644 --- a/Memory/src/service/feedback/feedback-experience.ts +++ b/Memory/src/service/feedback/feedback-experience.ts @@ -271,7 +271,10 @@ async feedback(request: FeedbackRequest): Promise { if (feedback.polarity !== "negative") { jobs.push(...await this.maybeCreateFeedbackExperience(attributedRequest, feedback, context)); } - if (attributedRequest.l1MemoryId || attributedRequest.episodeId) { + const rewardEpisode = attributedRequest.episodeId + ? this.deps.repos.runtime.getEpisode(attributedRequest.episodeId) + : undefined; + if ((attributedRequest.l1MemoryId || attributedRequest.episodeId) && rewardEpisode?.status !== "open") { jobs.push( this.deps.enqueueJob({ jobType: "reward", @@ -286,6 +289,7 @@ async feedback(request: FeedbackRequest): Promise { magnitude: feedback.magnitude, rationale: feedback.rationale, ...(repair?.repairId ? { repairId: repair.repairId } : {}), + ...(rewardEpisode?.status === "closed" ? { phase: "final" } : {}), trigger: feedback.channel === "implicit" ? "implicit_feedback" : "explicit_feedback" } }) diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts index 0d0bc450f..3725b57ef 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -214,6 +214,8 @@ type InternalMemorySearchRequest = MemorySearchRequest & { targetSkillId?: string; contextHints?: Record; injectedContextQuery?: string; + turnIntentDecision?: unknown; + routeProposal?: unknown; recordEvent?: boolean; }; @@ -783,8 +785,6 @@ export class MemoryService { contextPacketId: string; turnId: string; sessionId: string; - episodeId: string; - closedEpisodeIds: string[]; searchEventId: string; hits: RecallHit[]; injectedContext: InjectedContext; @@ -1646,7 +1646,10 @@ export class MemoryService { runWorkerOnce( limit = 100, - request: RequestEnvelope & { targetMemoryIds?: string[] } = {} + request: RequestEnvelope & { + targetMemoryIds?: string[]; + priorityCohortOnly?: boolean; + } = {} ): ReturnType { return this.workerRunner.runWorkerOnce(limit, request); } @@ -1840,7 +1843,6 @@ export class MemoryService { request: TurnStartRequest & Record ): ReturnType { const turnId = request.turnId ?? newId("turn"); - const episodeId = `episode_${stableHash(`readonly:${request.sessionId}:${turnId}`).slice(0, 20)}`; const contextHints = turnStartContextHints(request); const search = await this.search({ requestId: request.requestId, @@ -1858,11 +1860,9 @@ export class MemoryService { injectedContextQuery: request.query }); return { - contextPacketId: `ctx_${stableHash(`${request.sessionId}:${episodeId}:${turnId}:${search.searchEventId}`).slice(0, 20)}`, + contextPacketId: `ctx_${stableHash(`${request.sessionId}:unbound:${turnId}:${search.searchEventId}`).slice(0, 20)}`, turnId, sessionId: request.sessionId, - episodeId, - closedEpisodeIds: [], searchEventId: search.searchEventId, hits: search.hits, injectedContext: search.injectedContext, diff --git a/Memory/src/service/read-model/memory.ts b/Memory/src/service/read-model/memory.ts index 2335485b2..b77a84db4 100644 --- a/Memory/src/service/read-model/memory.ts +++ b/Memory/src/service/read-model/memory.ts @@ -37,9 +37,9 @@ export function memoryDetailWithLayerPayload(detail: MemoryDetailItem, memory: M } else if (memory.memoryLayer === "L2") { const policy = policyMetaFromMemory(memory); item.policy = { utilityScore: policy?.gain, confidence: policy?.confidence, evidenceMemoryIds: policy?.sourceTraceIds ?? sourceMemoryIdsFromMemory(memory), repairHints: policy?.verification ? [policy.verification] : [] }; } else if (memory.memoryLayer === "L3") { - const worldModel = worldModelMetaFromMemory(memory); item.worldModel = { sourceMemoryIds: worldModel?.policyIds ?? sourceMemoryIdsFromMemory(memory), confidence: worldModel?.confidence }; + const worldModel = worldModelMetaFromMemory(memory); item.worldModel = { sourceMemoryIds: worldModel?.policyIds ?? sourceMemoryIdsFromMemory(memory), confidence: worldModel?.confidence, summary: worldModel?.summary }; } else if (memory.memoryLayer === "Skill") { - const skill = skillMetaFromMemory(memory); item.skill = { invocationGuide: skill?.invocationGuide ?? detail.body, procedure: procedureFromSkillMemory(memory), sourcePolicyIds: skill?.sourcePolicyIds ?? [], sourceWorldModelIds: skill?.sourceWorldModelIds ?? [], reliabilityScore: skill?.eta, utilityScore: skill?.eta, evidenceCount: skill?.evidenceAnchorIds.length }; + const skill = skillMetaFromMemory(memory); item.skill = { invocationGuide: skill?.invocationGuide ?? detail.body, retrievalBlurb: skill?.retrievalBlurb, triggerContext: skill?.triggerContext, procedure: procedureFromSkillMemory(memory), sourcePolicyIds: skill?.sourcePolicyIds ?? [], sourceWorldModelIds: skill?.sourceWorldModelIds ?? [], reliabilityScore: skill?.eta, utilityScore: skill?.eta, evidenceCount: skill?.evidenceAnchorIds.length }; } return item; } diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index dbd083363..3dc61015a 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -7,6 +7,7 @@ import { clip } from "../../utils/text.js"; import { compileRetrievalQuery, displayReflectionText, + failureAvoidancePolicyIsRetrievalEligible, focusResearchRetrievalQuery, isRepositoryRepairPrompt, isResearchDomain, @@ -71,6 +72,8 @@ type InternalMemorySearchRequest = MemorySearchRequest & { targetSkillId?: string; contextHints?: Record; injectedContextQuery?: string; + turnIntentDecision?: unknown; + routeProposal?: unknown; recordEvent?: boolean; }; @@ -913,7 +916,11 @@ function contextMemoriesForInjectedSources(memories: MemoryRow[], sourceMemoryId if (visibleIds.has(memory.id)) return true; if (memory.memoryLayer !== "L2") return false; const policy = policyMetaFromMemory(memory); - if (!policy || !policyHasDecisionGuidance(policy)) return false; + if ( + !policy || + !policyHasDecisionGuidance(policy) || + !failureAvoidancePolicyIsRetrievalEligible(policy) + ) return false; if (legacySkillSourcePolicyIds.has(memory.id)) return true; return policy.sourceTraceIds.some((id) => visibleIds.has(id)) || policy.sourceEpisodeIds.some((id) => visibleEpisodeIds.has(id)); @@ -943,7 +950,11 @@ function contextMemoriesForRecallHits(hits: RecallHit[], memories: MemoryRow[]): for (const memory of memories) { if (memory.memoryLayer !== "L2") continue; const policy = policyMetaFromMemory(memory); - if (!policy || !policyHasDecisionGuidance(policy)) continue; + if ( + !policy || + !policyHasDecisionGuidance(policy) || + !failureAvoidancePolicyIsRetrievalEligible(policy) + ) continue; const traceOverlap = policy.sourceTraceIds.some((id) => hitTraceIds.has(id)); const episodeOverlap = policy.sourceEpisodeIds.some((id) => hitEpisodeIds.has(id)); const legacySkillFallback = legacySkillSourcePolicyIds.has(memory.id); @@ -1022,6 +1033,7 @@ function failureAvoidanceSection(memories: MemoryRow[]): InjectedContext["sectio const policy = policyMetaFromMemory(memory); if ( !policy + || !failureAvoidancePolicyIsRetrievalEligible(policy) || ( policy.experienceType !== "failure_avoidance" && policy.evidencePolarity !== "negative" diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index 951d39b68..9ac6076d8 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -9,6 +9,7 @@ import { retrievePluginMemories, signatureFromTraceParts, traceMetaFromMemory, + type IntentDecision, type TurnRelationDecision } from "../../algorithm/plugin-algorithms.js"; import { @@ -92,6 +93,22 @@ type SessionTurnDependencies = { interface CompleteTurnResponse { turnId: string; sessionId: string; episodeId: string; rawTurnId: string; l1MemoryId: string; l1MemoryIds: string[]; closedEpisodeIds: string[]; scheduledEvolution: boolean; jobs: JobRef[]; changeSeq: number; syncCursor: string; etag: string; serverTime: string; duplicate?: boolean; } type EndTopicDecision = TurnRelationDecision & { relation: "end_topic" }; interface EpisodeTurnRoute { episode: EpisodeRecord; endTopicDecision?: EndTopicDecision; } +type TurnRouteAction = "create_first" | "append" | "split" | "end_topic"; +interface TurnRouteProposal { + action: TurnRouteAction; + baseEpisodeId?: string; + relationDecision: TurnRelationDecision; + proposedAt: string; + mergeMode: boolean; + withinMergeWindow: boolean; + gapMs: number; +} +interface CommittedTurnRoute extends EpisodeTurnRoute { + closedEpisodeIds: string[]; + jobs: EvolutionJobRecord[]; + proposal: TurnRouteProposal; + proposalStale: boolean; +} export interface ToolOutcomeObservation { toolId: string; success?: boolean; reason?: string; } @@ -133,8 +150,78 @@ const EXPLICIT_END_TOPIC_COMMANDS = new Set([ "不聊了" ]); -function episodeTurnRoute(episode: EpisodeRecord, endTopicDecision?: EndTopicDecision): EpisodeTurnRoute { - return { episode, endTopicDecision }; +function turnRouteProposalFromRecallRequest(request: unknown): TurnRouteProposal | undefined { + if (!isRecord(request) || !isRecord(request.routeProposal)) return undefined; + const proposal = request.routeProposal; + const decision = isRecord(proposal.relationDecision) ? proposal.relationDecision : undefined; + const action = proposal.action; + if ( + (action !== "create_first" && action !== "append" && action !== "split" && action !== "end_topic") || + !decision || + (decision.relation !== "revision" && + decision.relation !== "follow_up" && + decision.relation !== "new_task" && + decision.relation !== "end_topic" && + decision.relation !== "unknown") || + typeof decision.confidence !== "number" || + typeof decision.reason !== "string" || + !Array.isArray(decision.signals) || + !decision.signals.every((signal) => typeof signal === "string") || + typeof proposal.proposedAt !== "string" || + typeof proposal.mergeMode !== "boolean" || + typeof proposal.withinMergeWindow !== "boolean" || + typeof proposal.gapMs !== "number" + ) { + return undefined; + } + return { + action, + ...(typeof proposal.baseEpisodeId === "string" ? { baseEpisodeId: proposal.baseEpisodeId } : {}), + relationDecision: { + relation: decision.relation, + confidence: decision.confidence, + reason: decision.reason, + signals: decision.signals as string[], + ...(typeof decision.llmModel === "string" ? { llmModel: decision.llmModel } : {}) + }, + proposedAt: proposal.proposedAt, + mergeMode: proposal.mergeMode, + withinMergeWindow: proposal.withinMergeWindow, + gapMs: proposal.gapMs + }; +} + +function turnIntentDecisionFromRecallRequest(request: unknown): IntentDecision | undefined { + if (!isRecord(request) || !isRecord(request.turnIntentDecision)) return undefined; + const decision = request.turnIntentDecision; + const retrieval = isRecord(decision.retrieval) ? decision.retrieval : undefined; + if ( + (decision.kind !== "task" && + decision.kind !== "memory_probe" && + decision.kind !== "chitchat" && + decision.kind !== "meta" && + decision.kind !== "unknown") || + typeof decision.confidence !== "number" || + typeof decision.reason !== "string" || + !Array.isArray(decision.signals) || + !decision.signals.every((signal) => typeof signal === "string") || + !retrieval || + typeof retrieval.tier1 !== "boolean" || + typeof retrieval.tier2 !== "boolean" || + typeof retrieval.tier3 !== "boolean" + ) { + return undefined; + } + return decision as unknown as IntentDecision; +} + +function turnContextPacketId( + sessionId: string, + baseEpisodeId: string | undefined, + turnId: string, + searchEventId: string +): string { + return `ctx_${stableHash(`${sessionId}:${baseEpisodeId ?? "unbound"}:${turnId}:${searchEventId}`).slice(0, 20)}`; } function explicitEndTopicDecision(text: string): EndTopicDecision | undefined { @@ -200,17 +287,6 @@ function episodeClosedByEndTopicTurn(episode: EpisodeRecord, turnId: string): bo episode.meta.endTopicTurnId === turnId; } -export function closedEpisodeIdsFromBoundary( - before: EpisodeRecord | undefined, - selected: EpisodeRecord, - after: EpisodeRecord | undefined -): string[] { - if (!before || before.id === selected.id || before.status !== "open" || after?.status !== "closed") { - return []; - } - return [before.id]; -} - export function summarizeTurn(rawTurn: RawTurnRecord): string { const parts = [ `Turn: ${rawTurn.turnId}`, @@ -700,8 +776,6 @@ export class SessionTurnService { contextPacketId: string; turnId: string; sessionId: string; - episodeId: string; - closedEpisodeIds: string[]; searchEventId: string; hits: RecallHit[]; injectedContext: InjectedContext; @@ -725,35 +799,18 @@ export class SessionTurnService { const turnId = request.turnId ?? newId("turn"); const intentDecision = classifyIntent(request.query); const endTopicDecision = explicitEndTopicDecision(request.query); - const existingRawTurn = this.deps.repos.runtime.getRawTurnBySessionTurn(session.id, turnId); - if (existingRawTurn) { - this.deps.assertRawTurnInScope(existingRawTurn, request.namespace); - } - const latestEpisodeBefore = existingRawTurn - ? undefined - : this.deps.repos.runtime.latestEpisodeForSession(session.id); - const episode = existingRawTurn - ? this.deps.requireEpisode(existingRawTurn.episodeId) - : endTopicDecision - ? this.ensureEpisode(session) - : await this.ensureEpisodeForTurnWithLlm(session, undefined, request.query, "turn.start"); - const closedEpisodeIds = closedEpisodeIdsFromBoundary( - latestEpisodeBefore, - episode, - latestEpisodeBefore ? this.deps.repos.runtime.getEpisode(latestEpisodeBefore.id) : undefined + const routeProposal = await this.proposeEpisodeRouteWithLlm( + session, + request.query, + endTopicDecision ); - if (episode.rawTurnIds.length === 0) { - this.deps.repos.runtime.updateEpisodeMeta(episode.id, { - intentDecision - }); - } const contextHints = turnStartContextHints(request); const search = await this.deps.search({ requestId: request.requestId, adapterId: request.adapterId, namespace: namespaceForSession(session), sessionId: session.id, - episodeId: episode.id, + episodeId: routeProposal.baseEpisodeId, turnId, query: buildSearchQuery({ ...request, contextHints }, this.deps.config.domain), layers: endTopicDecision @@ -764,64 +821,22 @@ export class SessionTurnService { includeInjectedContext: true, retrievalMode: "turn_start", contextHints, - injectedContextQuery: request.query + injectedContextQuery: request.query, + turnIntentDecision: intentDecision, + routeProposal }); - const contextPacketId = `ctx_${stableHash(`${session.id}:${episode.id}:${turnId}:${search.searchEventId}`).slice(0, 20)}`; - if (!existingRawTurn) { - const at = nowIso(); - this.deps.repos.runtime.touchSession(session.id, at); - const rawTurn = this.deps.repos.runtime.insertRawTurn({ - id: rawTurnIdForSessionTurn(session.id, turnId), - sessionId: session.id, - episodeId: episode.id, - turnId, - userId: session.userId, - conversationId: session.conversationId, - userText: request.query, - toolCalls: [], - toolResults: [], - sourceMemoryIds: search.sourceMemoryIds, - usage: {}, - messagePayload: { - turn_start: { - contextPacketId, - searchEventId: search.searchEventId, - sourceMemoryIds: search.sourceMemoryIds, - intent_decision: intentDecision, - ...(endTopicDecision - ? { - episode_close: { - closeAfterComplete: true, - decision: endTopicDecision - } - } - : {}) - } - }, - status: "started", - createdAt: at - }); - this.deps.repos.runtime.appendEpisodeRawTurn(episode.id, rawTurn.id, at); - this.deps.repos.runtime.appendChange({ - memoryId: rawTurn.id, - namespaceId: this.deps.namespaceIdFromSession(session), - kind: "raw_turn", - op: "created", - entityId: rawTurn.id, - userId: session.userId, - changeType: "raw_turn_created", - after: rawTurn, - source: "turn.start", - createdAt: at - }); - } + const contextPacketId = turnContextPacketId( + session.id, + routeProposal.baseEpisodeId, + turnId, + search.searchEventId + ); + this.deps.repos.runtime.touchSession(session.id, nowIso()); return { contextPacketId, turnId, sessionId: session.id, - episodeId: episode.id, - closedEpisodeIds, searchEventId: search.searchEventId, hits: search.hits, injectedContext: search.injectedContext, @@ -832,7 +847,7 @@ export class SessionTurnService { ...(intentDecision.kind === "chitchat" || intentDecision.kind === "meta" ? [`intent:${intentDecision.kind}:retrieval_skipped`] : []), - ...(endTopicDecision ? ["relation:end_topic"] : []) + `relation:${routeProposal.relationDecision.relation}:proposed` ], serverTime: nowIso() }; @@ -869,6 +884,8 @@ export class SessionTurnService { } return { ...(existing.response as CompleteTurnResponse), + scheduledEvolution: false, + jobs: [], duplicate: true }; } @@ -881,6 +898,42 @@ export class SessionTurnService { if (existingRawTurn) { this.deps.assertRawTurnInScope(existingRawTurn, request.namespace); } + if (existingRawTurn && isRecord(existingRawTurn.messagePayload?.turn_complete)) { + const at = nowIso(); + const episode = this.deps.requireEpisode(existingRawTurn.episodeId); + const l1MemoryIds = episode.l1MemoryIds.filter((memoryId: string) => { + const memory = this.deps.repos.memories.get(memoryId); + return memory && this.deps.rawTurnIdFromMemory(memory) === existingRawTurn.id; + }); + const responseChangeSeq = this.deps.repos.runtime.latestChangeSeq( + session.userId, + this.deps.namespaceIdFromSession(session) + ); + const body: CompleteTurnResponse = { + turnId, + sessionId: session.id, + episodeId: episode.id, + rawTurnId: existingRawTurn.id, + l1MemoryId: l1MemoryIds[0] ?? "", + l1MemoryIds, + closedEpisodeIds: episodeClosedByEndTopicTurn(episode, turnId) ? [episode.id] : [], + scheduledEvolution: false, + jobs: [], + changeSeq: responseChangeSeq, + syncCursor: this.deps.encodeChangeCursor(responseChangeSeq, namespaceForSession(session)), + etag: stableHash({ + changeSeq: responseChangeSeq, + l1MemoryIds, + rawTurnId: existingRawTurn.id + }), + serverTime: at, + duplicate: true + }; + if (idempotencyKey) { + this.deps.repos.runtime.saveIdempotency(idempotencyKey, requestHash, body, at); + } + return body; + } const turnStartRecall = this.deps.repos.runtime.getTurnStartRecallEvent(session.id, turnId); const requestSourceMemoryIds = normalizeCompleteTurnSourceMemoryIds(request); const sourceMemoryIds = requestSourceMemoryIds.length > 0 @@ -889,35 +942,101 @@ export class SessionTurnService { const completionRequest = sourceMemoryIds === requestSourceMemoryIds ? request : { ...request, sourceMemoryIds }; - const intentDecision = classifyIntent(request.query); + const intentDecision = turnIntentDecisionFromRecallRequest(turnStartRecall?.request) ?? + classifyIntent(request.query); const endTopicDecision = explicitEndTopicDecision(request.query) ?? (existingRawTurn ? endTopicDecisionFromRawTurn(existingRawTurn) : undefined); - const latestEpisodeBefore = existingRawTurn - ? undefined - : this.deps.repos.runtime.latestEpisodeForSession(session.id); - const route = existingRawTurn - ? episodeTurnRoute( - this.deps.requireEpisode(existingRawTurn.episodeId), - endTopicDecision - ) - : episodeTurnRoute( - this.ensureEpisodeForTurn( - session, - request.episodeId ?? turnStartRecall?.episodeId, - request.query, - "turn.complete" - ), - endTopicDecision + const at = nowIso(); + const recalledProposal = turnRouteProposalFromRecallRequest(turnStartRecall?.request); + let route: CommittedTurnRoute; + if (request.episodeId) { + const episode = this.ensureEpisode(session, request.episodeId); + const decision = endTopicDecision ?? recalledProposal?.relationDecision ?? classifyTurnRelation({ + prevUserText: "", + prevAssistantText: "", + newUserText: request.query, + prevTags: [] + }); + const routedEndTopicDecision = endTopicDecision ?? ( + decision.relation === "end_topic" ? decision as EndTopicDecision : undefined + ); + route = { + episode, + ...(routedEndTopicDecision ? { endTopicDecision: routedEndTopicDecision } : {}), + closedEpisodeIds: [], + jobs: [], + proposal: { + ...(recalledProposal ?? this.buildTurnRouteProposal(episode, decision, undefined, at)), + action: routedEndTopicDecision ? "end_topic" : "append", + baseEpisodeId: episode.id, + relationDecision: decision + }, + proposalStale: false + }; + } else { + const latest = this.deps.repos.runtime.latestEpisodeForSession(session.id); + const proposalUsesObservedUnboundEpisode = Boolean( + (recalledProposal?.action === "create_first" || recalledProposal?.action === "end_topic") && + recalledProposal.baseEpisodeId === undefined && + existingRawTurn && + latest?.id === existingRawTurn.episodeId && + !this.episodeRelationContext(latest).prevUserText + ); + const proposalIsCurrent = Boolean(recalledProposal) && + (recalledProposal?.baseEpisodeId === latest?.id || proposalUsesObservedUnboundEpisode) && + !(recalledProposal?.action === "append" && + latest?.status === "closed" && + latest.meta.closeReason === "end_topic"); + if (!recalledProposal && existingRawTurn) { + const episode = this.deps.requireEpisode(existingRawTurn.episodeId); + const decision = endTopicDecision ?? classifyTurnRelation({ + prevUserText: "", + prevAssistantText: "", + newUserText: request.query, + prevTags: [] + }); + const routedEndTopicDecision = decision.relation === "end_topic" + ? decision as EndTopicDecision + : undefined; + route = { + episode, + ...(routedEndTopicDecision ? { endTopicDecision: routedEndTopicDecision } : {}), + closedEpisodeIds: [], + jobs: [], + proposal: { + ...this.buildTurnRouteProposal(episode, decision, undefined, at), + action: routedEndTopicDecision ? "end_topic" : "append", + baseEpisodeId: episode.id + }, + proposalStale: true + }; + } else { + const proposal = proposalIsCurrent + ? recalledProposal! + : this.proposeEpisodeRoute(session, request.query, endTopicDecision); + route = this.commitTurnRouteProposal( + session, + proposal, + request.query, + "turn.complete", + at, + !proposalIsCurrent ); + } + } const episode = route.episode; - const closedEpisodeIds = closedEpisodeIdsFromBoundary( - latestEpisodeBefore, - episode, - latestEpisodeBefore ? this.deps.repos.runtime.getEpisode(latestEpisodeBefore.id) : undefined - ); + const committedEndTopicDecision = route.endTopicDecision ?? endTopicDecision; + const closedEpisodeIds = [...route.closedEpisodeIds]; this.deps.assertEpisodeInScope(episode, request.namespace); - const at = nowIso(); + if (existingRawTurn && existingRawTurn.episodeId !== episode.id) { + this.deps.repos.runtime.rebindRawTurnEpisode( + existingRawTurn.id, + existingRawTurn.episodeId, + episode.id, + at + ); + } this.deps.repos.runtime.touchSession(session.id, at); const rawTurnId = rawTurnIdForSessionTurn(session.id, turnId); const requestToolCalls = normalizeCompleteTurnToolCalls(completionRequest); @@ -925,20 +1044,25 @@ export class SessionTurnService { const requestArtifacts = normalizeCompleteTurnArtifacts(completionRequest); const turnStartPayload = { intent_decision: intentDecision, + routeProposal: recalledProposal ?? route.proposal, + ...(route.proposalStale ? { routeProposalStale: true } : {}), ...(turnStartRecall ? { - contextPacketId: `ctx_${stableHash( - `${session.id}:${turnStartRecall.episodeId ?? episode.id}:${turnId}:${turnStartRecall.id}` - ).slice(0, 20)}`, + contextPacketId: turnContextPacketId( + session.id, + turnStartRecall.episodeId, + turnId, + turnStartRecall.id + ), searchEventId: turnStartRecall.id, sourceMemoryIds } : {}), - ...(endTopicDecision + ...(committedEndTopicDecision ? { episode_close: { closeAfterComplete: true, - decision: endTopicDecision + decision: committedEndTopicDecision } } : {}) @@ -974,7 +1098,10 @@ export class SessionTurnService { const rawTurnFirstCompleted = rawTurnCreated || !isRecord(existingRawTurn.messagePayload?.turn_complete); const completedObservedRawTurn = existingRawTurn - ? completeObservedRawTurn(existingRawTurn, completionRequest, at) + ? { + ...completeObservedRawTurn(existingRawTurn, completionRequest, at), + episodeId: episode.id + } : undefined; const rawTurn = completedObservedRawTurn ? this.deps.repos.runtime.updateRawTurn({ @@ -1031,7 +1158,7 @@ export class SessionTurnService { const l1MemoryIds: string[] = []; let changeSeq = 0; - const jobs: EvolutionJobRecord[] = []; + const jobs: EvolutionJobRecord[] = [...route.jobs]; for (const step of capturedSteps) { const stepRawTurnId = step.rawTurnId ?? rawTurn.id; @@ -1192,7 +1319,7 @@ export class SessionTurnService { createdAt: at }); } - const completedEndTopicDecision = route.endTopicDecision ?? endTopicDecisionFromRawTurn(rawTurn); + const completedEndTopicDecision = committedEndTopicDecision ?? endTopicDecisionFromRawTurn(rawTurn); if (rawTurnFirstCompleted && completedEndTopicDecision) { const beforeClose = this.deps.repos.runtime.getEpisode(episode.id) ?? episode; const closed = this.deps.repos.runtime.closeEpisode(episode.id, { @@ -1265,7 +1392,7 @@ export class SessionTurnService { return body; }); - for (const memoryId of response.l1MemoryIds) { + for (const memoryId of response.duplicate ? [] : response.l1MemoryIds) { const memory = this.deps.repos.memories.get(memoryId); recordApiLog(this.deps.repos.runtime, "memory_add", { sessionId: response.sessionId, @@ -2004,51 +2131,87 @@ export class SessionTurnService { ); } - private ensureEpisodeForTurn( + private buildTurnRouteProposal( + latest: EpisodeRecord | undefined, + decision: TurnRelationDecision, + lastTurnAtMs?: number, + proposedAt = nowIso() + ): TurnRouteProposal { + const mergeMode = this.deps.config.algorithm.session.followUpMode === "merge_follow_ups"; + const proposedAtMs = Date.parse(proposedAt); + const gapMs = lastTurnAtMs + ? Math.max(0, (Number.isFinite(proposedAtMs) ? proposedAtMs : Date.now()) - lastTurnAtMs) + : 0; + const withinMergeWindow = + this.deps.config.algorithm.session.mergeMaxGapMs === 0 || + gapMs <= this.deps.config.algorithm.session.mergeMaxGapMs; + const shouldAppendOpen = + mergeMode && + withinMergeWindow && + (decision.relation === "revision" || + decision.relation === "follow_up" || + decision.relation === "unknown"); + const shouldReopenClosed = latest !== undefined && latest.meta.closeReason !== "end_topic" && ( + decision.relation === "revision" || + (mergeMode && + withinMergeWindow && + (decision.relation === "follow_up" || decision.relation === "unknown")) + ); + const action: TurnRouteAction = decision.relation === "end_topic" + ? "end_topic" + : !latest + ? "create_first" + : latest.status === "open" + ? (shouldAppendOpen ? "append" : "split") + : (shouldReopenClosed ? "append" : "split"); + return { + action, + ...(latest ? { baseEpisodeId: latest.id } : {}), + relationDecision: decision, + proposedAt, + mergeMode, + withinMergeWindow, + gapMs + }; + } + + private proposeEpisodeRoute( session: SessionRecord, - episodeId: string | undefined, - userText: string | undefined, - source: string - ): EpisodeRecord { - if (episodeId || !userText?.trim()) { - return this.ensureEpisode(session, episodeId); - } + userText: string, + forcedDecision?: TurnRelationDecision + ): TurnRouteProposal { const latest = this.deps.repos.runtime.latestEpisodeForSession(session.id); - if (!latest) { - return this.ensureEpisode(session); - } - const relationContext = this.episodeRelationContext(latest); - if (!relationContext.prevUserText) { - return this.ensureEpisode(session); - } - const decision = classifyTurnRelation({ - prevUserText: relationContext.prevUserText, - prevAssistantText: relationContext.prevAssistantText, + const relationContext = latest ? this.episodeRelationContext(latest) : undefined; + const decision = forcedDecision ?? classifyTurnRelation({ + prevUserText: relationContext?.prevUserText ?? "", + prevAssistantText: relationContext?.prevAssistantText ?? "", newUserText: userText, - gapMs: relationContext.lastTurnAtMs + gapMs: relationContext?.lastTurnAtMs ? Math.max(0, Date.now() - relationContext.lastTurnAtMs) : undefined, - prevTags: relationContext.tags + prevTags: relationContext?.tags ?? [] }); - return this.applyEpisodeRelationDecision(session, latest, decision, userText, source, relationContext.lastTurnAtMs); + return this.buildTurnRouteProposal(latest, decision, relationContext?.lastTurnAtMs); } - private async ensureEpisodeForTurnWithLlm( + private async proposeEpisodeRouteWithLlm( session: SessionRecord, - episodeId: string | undefined, - userText: string | undefined, - source: string - ): Promise { - if (episodeId || !userText?.trim()) { - return this.ensureEpisode(session, episodeId); - } + userText: string, + forcedDecision?: TurnRelationDecision + ): Promise { const latest = this.deps.repos.runtime.latestEpisodeForSession(session.id); - if (!latest) { - return this.ensureEpisode(session); - } - const relationContext = this.episodeRelationContext(latest); - if (!relationContext.prevUserText) { - return this.ensureEpisode(session); + const relationContext = latest ? this.episodeRelationContext(latest) : undefined; + if (forcedDecision || !latest || !relationContext?.prevUserText) { + const decision = forcedDecision ?? classifyTurnRelation({ + prevUserText: relationContext?.prevUserText ?? "", + prevAssistantText: relationContext?.prevAssistantText ?? "", + newUserText: userText, + gapMs: relationContext?.lastTurnAtMs + ? Math.max(0, Date.now() - relationContext.lastTurnAtMs) + : undefined, + prevTags: relationContext?.tags ?? [] + }); + return this.buildTurnRouteProposal(latest, decision, relationContext?.lastTurnAtMs); } const decision = await classifyTurnRelationWithLlm({ prevUserText: relationContext.prevUserText, @@ -2061,114 +2224,66 @@ export class SessionTurnService { }, { llm: this.deps.llm }); - return this.applyEpisodeRelationDecision(session, latest, decision, userText, source, relationContext.lastTurnAtMs); + return this.buildTurnRouteProposal(latest, decision, relationContext.lastTurnAtMs); } - private applyEpisodeRelationDecision( + private commitTurnRouteProposal( session: SessionRecord, - latest: EpisodeRecord, - decision: ReturnType, + proposal: TurnRouteProposal, userText: string, source: string, - lastTurnAtMs?: number - ): EpisodeRecord { - const mergeMode = this.deps.config.algorithm.session.followUpMode === "merge_follow_ups"; - const gapMs = lastTurnAtMs ? Math.max(0, Date.now() - lastTurnAtMs) : 0; - const withinMergeWindow = - this.deps.config.algorithm.session.mergeMaxGapMs === 0 || - gapMs <= this.deps.config.algorithm.session.mergeMaxGapMs; - const shouldAppendOpen = - mergeMode && - withinMergeWindow && - (decision.relation === "revision" || - decision.relation === "follow_up" || - decision.relation === "unknown"); - if (latest.status === "open") { - if (shouldAppendOpen) { + at: string, + proposalStale: boolean + ): CommittedTurnRoute { + const decision = proposal.relationDecision; + const closedEpisodeIds: string[] = []; + const jobs: EvolutionJobRecord[] = []; + if (proposal.action === "create_first") { + return { + episode: this.ensureEpisode(session), + closedEpisodeIds, + jobs, + proposal, + proposalStale + }; + } + if (proposal.action === "end_topic") { + const base = proposal.baseEpisodeId + ? this.deps.repos.runtime.getEpisode(proposal.baseEpisodeId) + : undefined; + const episode = base?.status === "open" ? base : this.ensureEpisode(session); + return { + episode, + endTopicDecision: decision as EndTopicDecision, + closedEpisodeIds, + jobs, + proposal, + proposalStale + }; + } + + const baseEpisodeId = proposal.baseEpisodeId; + if (!baseEpisodeId) { + throw new MemoryServiceError("conflict", "episode route proposal is missing its base episode"); + } + const latest = this.deps.requireEpisode(baseEpisodeId); + if (proposal.action === "append") { + if (latest.status === "open") { if (decision.relation === "revision") { this.recordRevisionFeedback(session, latest, userText, source); } - return this.deps.repos.runtime.updateEpisodeMeta(latest.id, { + const episode = this.deps.repos.runtime.updateEpisodeMeta(latest.id, { relation: decision.relation, relationDecision: decision, relationRouting: { action: "append_to_open_episode", - mergeMode, - withinMergeWindow, - gapMs + mergeMode: proposal.mergeMode, + withinMergeWindow: proposal.withinMergeWindow, + gapMs: proposal.gapMs } - }) ?? latest; + }, at) ?? latest; + return { episode, closedEpisodeIds, jobs, proposal, proposalStale }; } - if (decision.relation === "new_task" || !shouldAppendOpen) { - this.recordImplicitTurnFeedback(session, latest, userText); - const at = nowIso(); - const closed = this.deps.repos.runtime.closeEpisode(latest.id, { - closeReason: "topic_boundary", - relation: decision.relation, - relationDecision: decision, - relationRouting: { - action: decision.relation === "new_task" - ? "close_open_and_start_new_task" - : "close_open_and_start_new_episode", - mergeMode, - withinMergeWindow, - gapMs - }, - closedBy: source - }, at); - if (closed) { - this.deps.repos.runtime.appendChange({ - memoryId: closed.id, - namespaceId: this.deps.namespaceIdFromSession(session), - kind: "episode", - op: "updated", - entityId: closed.id, - userId: closed.userId, - changeType: "episode_closed", - before: latest, - after: closed, - source, - createdAt: at - }); - this.deps.finalizeClosedEpisode(closed, at, "topic_boundary"); - } - const next = this.ensureEpisode(session); - return this.deps.repos.runtime.updateEpisodeMeta(next.id, { - relation: decision.relation, - relationDecision: decision, - previousEpisodeId: latest.id, - relationRouting: { - action: decision.relation === "new_task" - ? "start_new_task_episode" - : "start_new_episode", - mergeMode, - withinMergeWindow, - gapMs - } - }, at) ?? next; - } - return this.deps.repos.runtime.updateEpisodeMeta(latest.id, { - relation: decision.relation, - relationDecision: decision - }) ?? latest; - } - - if (latest.meta.closeReason === "end_topic") { - const next = this.ensureEpisode(session); - return this.deps.repos.runtime.updateEpisodeMeta(next.id, { - relation: decision.relation, - relationDecision: decision, - previousEpisodeId: latest.id - }) ?? next; - } - - const shouldReopenClosed = - decision.relation === "revision" || - (mergeMode && - withinMergeWindow && - (decision.relation === "follow_up" || decision.relation === "unknown")); - if (shouldReopenClosed) { - const at = nowIso(); const reopened = this.deps.repos.runtime.reopenEpisode(latest.id, { relation: decision.relation, relationDecision: decision, @@ -2176,9 +2291,9 @@ export class SessionTurnService { reopenReason: decision.relation === "revision" ? "revision" : "follow_up", relationRouting: { action: "reopen_previous_episode", - mergeMode, - withinMergeWindow, - gapMs + mergeMode: proposal.mergeMode, + withinMergeWindow: proposal.withinMergeWindow, + gapMs: proposal.gapMs }, rewardDirty: { reason: "episode_reopened", @@ -2186,41 +2301,77 @@ export class SessionTurnService { at } }, at); - if (reopened) { + if (!reopened) { + throw new MemoryServiceError("conflict", "failed to reopen the proposed episode"); + } + this.deps.repos.runtime.appendChange({ + memoryId: reopened.id, + namespaceId: this.deps.namespaceIdFromSession(session), + kind: "episode", + op: "updated", + entityId: reopened.id, + userId: reopened.userId, + changeType: "episode_reopened", + before: latest, + after: reopened, + source, + createdAt: at + }); + if (decision.relation === "revision") { + this.recordRevisionFeedback(session, reopened, userText, source); + } + return { episode: reopened, closedEpisodeIds, jobs, proposal, proposalStale }; + } + + this.recordImplicitTurnFeedback(session, latest, userText); + if (latest.status === "open") { + const closed = this.deps.repos.runtime.closeEpisode(latest.id, { + closeReason: "topic_boundary", + relation: decision.relation, + relationDecision: decision, + relationRouting: { + action: decision.relation === "new_task" + ? "close_open_and_start_new_task" + : "close_open_and_start_new_episode", + mergeMode: proposal.mergeMode, + withinMergeWindow: proposal.withinMergeWindow, + gapMs: proposal.gapMs + }, + closedBy: source + }, at); + if (closed) { this.deps.repos.runtime.appendChange({ - memoryId: reopened.id, + memoryId: closed.id, namespaceId: this.deps.namespaceIdFromSession(session), kind: "episode", op: "updated", - entityId: reopened.id, - userId: reopened.userId, - changeType: "episode_reopened", + entityId: closed.id, + userId: closed.userId, + changeType: "episode_closed", before: latest, - after: reopened, + after: closed, source, createdAt: at }); - if (decision.relation === "revision") { - this.recordRevisionFeedback(session, reopened, userText, source); - } - return reopened; + jobs.push(...this.deps.finalizeClosedEpisode(closed, at, "topic_boundary")); + closedEpisodeIds.push(closed.id); } + } else { + jobs.push(...this.deps.finalizeClosedEpisode(latest, at, "topic_boundary")); } - - this.recordImplicitTurnFeedback(session, latest, userText); - this.deps.finalizeClosedEpisode(latest, nowIso(), "topic_boundary"); const next = this.ensureEpisode(session); - return this.deps.repos.runtime.updateEpisodeMeta(next.id, { + const episode = this.deps.repos.runtime.updateEpisodeMeta(next.id, { relation: decision.relation, relationDecision: decision, previousEpisodeId: latest.id, relationRouting: { action: decision.relation === "new_task" ? "start_new_task_episode" : "start_new_episode", - mergeMode, - withinMergeWindow, - gapMs + mergeMode: proposal.mergeMode, + withinMergeWindow: proposal.withinMergeWindow, + gapMs: proposal.gapMs } - }) ?? next; + }, at) ?? next; + return { episode, closedEpisodeIds, jobs, proposal, proposalStale }; } private episodeRelationContext(episode: EpisodeRecord): { @@ -2232,6 +2383,7 @@ export class SessionTurnService { const rawTurns = episode.rawTurnIds .map((id) => this.deps.repos.runtime.getRawTurn(id)) .filter((rawTurn): rawTurn is RawTurnRecord => Boolean(rawTurn)) + .filter((rawTurn) => isRecord(rawTurn.messagePayload?.turn_complete)) .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)); const userTurns = rawTurns .map((rawTurn) => rawTurn.userText?.trim()) @@ -2336,22 +2488,6 @@ export class SessionTurnService { }); this.deps.repos.runtime.appendEpisodeFeedback(episode.id, feedback.id, at); this.deps.maybeCreateDecisionRepair(feedbackRequest, feedback, contextHash, this.deps.namespaceIdFromSession(session)); - this.deps.enqueueJob({ - jobType: "reward", - userId: session.userId, - sessionId: session.id, - episodeId: episode.id, - payload: { - feedbackId: feedback.id, - l1MemoryId: target.id, - channel: feedback.channel, - polarity: feedback.polarity, - magnitude: feedback.magnitude, - rationale: feedback.rationale, - trigger: "implicit_turn_feedback" - }, - createdAt: at - }); for (const trial of this.deps.pendingTrialsForFeedback(feedback)) { this.deps.enqueueJob({ jobType: "skill_trial_resolve", @@ -2442,22 +2578,6 @@ export class SessionTurnService { }); this.deps.repos.runtime.appendEpisodeFeedback(episode.id, feedback.id, at); this.deps.maybeCreateDecisionRepair(feedbackRequest, feedback, contextHash, this.deps.namespaceIdFromSession(session)); - this.deps.enqueueJob({ - jobType: "reward", - userId: session.userId, - sessionId: session.id, - episodeId: episode.id, - payload: { - feedbackId: feedback.id, - l1MemoryId: target.id, - channel: feedback.channel, - polarity: feedback.polarity, - magnitude: feedback.magnitude, - rationale: feedback.rationale, - trigger: "revision_feedback" - }, - createdAt: at - }); for (const trial of this.deps.pendingTrialsForFeedback(feedback)) { this.deps.enqueueJob({ jobType: "skill_trial_resolve", diff --git a/Memory/src/service/worker/job-handlers.ts b/Memory/src/service/worker/job-handlers.ts index c144cc439..a472e8398 100644 --- a/Memory/src/service/worker/job-handlers.ts +++ b/Memory/src/service/worker/job-handlers.ts @@ -330,12 +330,27 @@ export function enqueueEpisodeRewardAfterReflection( episode.status !== "closed" || episodeHasRewardForReflection(episode) || episodeRewardWasSkipped(episode) || - deps.repos.runtime.hasEpisodeJob(episode.id, "reward", ["queued", "leased", "failed"]) + ( + deps.repos.runtime.hasEpisodeJob(episode.id, "reward", ["queued", "leased", "failed"]) + && !episode.meta.rewardDirty + ) ) return []; const target = deps.feedbackTargetFromEpisode(episode); if (!target) return []; + const feedback = [...episode.feedbackIds] + .reverse() + .map((id) => deps.repos.runtime.getFeedback(id)) + .find((item) => Boolean(item)); const feedbackWindowSec = Math.max(1, deps.reward.feedbackWindowSec); - const runAfter = new Date(Date.parse(at) + feedbackWindowSec * 1000).toISOString(); + const runAfter = feedback + ? at + : new Date(Date.parse(at) + feedbackWindowSec * 1000).toISOString(); + const repair = feedback + ? [...episode.decisionRepairIds] + .reverse() + .map((id) => deps.repos.runtime.getDecisionRepair(id)) + .find((item) => item?.feedbackId === feedback.id) + : undefined; return [enqueueJob(deps, { jobType: "reward", userId: episode.userId, @@ -345,6 +360,15 @@ export function enqueueEpisodeRewardAfterReflection( l1MemoryId: target.id, trigger, targetKind: "episode", + phase: "final", + ...(feedback ? { + feedbackId: feedback.id, + channel: feedback.channel, + polarity: feedback.polarity, + magnitude: feedback.magnitude, + rationale: feedback.rationale + } : {}), + ...(repair ? { repairId: repair.id } : {}), runAfter }, createdAt: at @@ -405,7 +429,17 @@ export function enqueueImportSummaryIfMissing( } export function episodeHasRewardForReflection(episode: EpisodeRecord): boolean { - return typeof episode.rTask === "number" && !episodeRewardWasSkipped(episode); + if ( + episode.status !== "closed" || + typeof episode.rTask !== "number" || + episode.rewardDetail.phase !== "final" || + episodeRewardWasSkipped(episode) + ) return false; + const traceIds = Array.isArray(episode.rewardDetail.traceIds) + ? episode.rewardDetail.traceIds.filter((id): id is string => typeof id === "string") + : []; + return traceIds.length === episode.l1MemoryIds.length && + traceIds.every((id, index) => id === episode.l1MemoryIds[index]); } export function episodeRewardWasSkipped(episode: EpisodeRecord): boolean { diff --git a/Memory/src/service/worker/worker-runner.ts b/Memory/src/service/worker/worker-runner.ts index 38ddd10c9..479c69571 100644 --- a/Memory/src/service/worker/worker-runner.ts +++ b/Memory/src/service/worker/worker-runner.ts @@ -5,6 +5,10 @@ * injected explicitly so this module has no service-class dependency. */ import type { Embedder } from "../../model/types.js"; +import { + retrievalDocumentIsCurrent, + retrievalDocumentSourceHash +} from "../../algorithm/plugin-algorithms.js"; import { createMemoryLogger, memoryErrorFields } from "../../logging/logger.js"; import { jobToRef, @@ -20,6 +24,7 @@ import type { PreparedEmbeddingJob } from "../embedding/embedding-job-processor.js"; import { + embeddingTextForMemory, embeddingRetryBackoffMs, embeddingRetryToRunItem } from "../embedding/embedding-pipeline.js"; @@ -76,6 +81,7 @@ export interface WorkerStartupReconciliation { restartedFailedProcessing: number; enqueuedImportSummaries: number; enqueuedEmbeddingRepairs: number; + enqueuedRetrievalReindexes: number; } export interface EmbeddingRetryClaim { @@ -151,7 +157,8 @@ export class WorkerRunner { requeuedEmbeddingRetries: 0, restartedFailedProcessing: 0, enqueuedImportSummaries: 0, - enqueuedEmbeddingRepairs: 0 + enqueuedEmbeddingRepairs: 0, + enqueuedRetrievalReindexes: 0 }; } @@ -170,6 +177,7 @@ export class WorkerRunner { let enqueuedImportSummaries = 0; let enqueuedEmbeddingRepairs = 0; + let enqueuedRetrievalReindexes = 0; const activeProcessing = this.deps.repos.processing.listByStates([ "summary_pending", "summarizing", @@ -257,18 +265,47 @@ export class WorkerRunner { }, ["embedding_pending", "embedding"]); } + const retrievalMemories = this.deps.repos.memories.list({ + memoryLayer: ["Skill", "L3"], + status: ["activated", "resolving"] + }, limit); + for (const memory of retrievalMemories) { + this.deps.repos.memories.reindexFts(memory); + if (!this.deps.capture.embedAfterCapture || retrievalDocumentIsCurrent(memory)) continue; + if (this.deps.repos.runtime.hasPendingJob(memory.id, "embedding")) continue; + const sourceHash = retrievalDocumentSourceHash(memory); + this.deps.enqueueJob({ + jobType: "embedding", + userId: memory.userId, + sessionId: memory.sessionId, + targetMemoryId: memory.id, + dedupeKey: `embedding:retrieval-v2:${memory.id}:${sourceHash}`, + payload: { + reason: "startup.retrieval_document_v2", + retrievalSourceHash: sourceHash + }, + maxAttempts: 6, + createdAt: at + }); + enqueuedRetrievalReindexes += 1; + } + return { requeuedJobs: interruptedJobs.length + failedJobs.length, requeuedEmbeddingRetries: embeddingRetries.length, restartedFailedProcessing, enqueuedImportSummaries, - enqueuedEmbeddingRepairs + enqueuedEmbeddingRepairs, + enqueuedRetrievalReindexes }; } async runWorkerOnce( limit = 100, - request: RequestEnvelope & { targetMemoryIds?: string[] } = {} + request: RequestEnvelope & { + targetMemoryIds?: string[]; + priorityCohortOnly?: boolean; + } = {} ): Promise { if (!this.deps.memoryAddEnabled()) { return this.deps.runWorkerNoWrite(request); @@ -283,11 +320,13 @@ export class WorkerRunner { for (const { before, after } of requeuedJobs) { this.deps.appendJobChange(after, "queued", before); } - const jobs = this.deps.repos.runtime.leaseQueuedJobs(normalizedLimit, 60, targetMemoryIds); + const jobs = this.deps.repos.runtime.leaseQueuedJobs( + normalizedLimit, + 60, + targetMemoryIds, + request.priorityCohortOnly + ); const retryCapacity = Math.max(0, normalizedLimit - jobs.length); - const embeddingRetries = retryCapacity > 0 - ? await this.runEmbeddingRetryOnce(retryCapacity, targetMemoryIds) - : { leased: 0, succeeded: 0, failed: 0, items: [] }; const results: WorkerJobRunResult[] = []; for (let index = 0; index < jobs.length;) { const job = jobs[index]!; @@ -313,6 +352,9 @@ export class WorkerRunner { results.push(await this.runLeasedWorkerJob(job)); index += 1; } + const embeddingRetries = retryCapacity > 0 + ? await this.runEmbeddingRetryOnce(retryCapacity, targetMemoryIds) + : { leased: 0, succeeded: 0, failed: 0, items: [] }; const succeeded = results.reduce((sum, result) => sum + result.succeeded, 0); const failed = results.reduce((sum, result) => sum + result.failed, 0); @@ -555,6 +597,21 @@ export class WorkerRunner { if (!memory) { throw new Error(`embedding retry target not found: ${retry.targetKind}:${retry.targetId}`); } + if ((memory.memoryLayer === "Skill" || memory.memoryLayer === "L3") && embeddingTextForMemory(memory) !== retry.sourceText) { + const completed = this.deps.repos.runtime.markEmbeddingRetrySucceededClaimed(retry.id, { + ...claim, + now: this.nowMs() + }); + if (completed) this.deps.appendEmbeddingRetryChange(completed, "succeeded", retry); + const replacement = this.deps.enqueueEmbeddingRetry( + memory, + embeddingTextForMemory(memory), + this.deps.nowIso(), + retry.vectorField + ); + this.deps.appendEmbeddingRetryChange(replacement, "queued"); + return { succeeded: 0, failed: 0, item: completed ? embeddingRetryToRunItem(completed) : null }; + } let completed: EmbeddingRetryRecord | undefined; this.deps.embeddingJobs.persistEmbeddingVector({ memoryId: memory.id, @@ -562,6 +619,9 @@ export class WorkerRunner { vector, attemptCount: retry.attempts + 1, source: "worker.embedding_retry", + sourceHash: memory.memoryLayer === "Skill" || memory.memoryLayer === "L3" + ? retrievalDocumentSourceHash(memory) + : undefined, allowedProcessingStates: ["embedding_pending", "embedding"], finalize: () => { completed = this.deps.repos.runtime.markEmbeddingRetrySucceededClaimed(retry.id, { diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index f4abd3266..f5ee88958 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -1,4 +1,5 @@ import type Database from "better-sqlite3"; +import { retrievalDocumentForMemory } from "../algorithm/plugin-algorithms.js"; import type { FeedbackRequest, JobRef, @@ -384,7 +385,7 @@ export class MemoryRepository { ) .run(memoryToSql(prepared.memory)); this.vectors.replace(prepared.memory.id, prepared.vectors, prepared.memory.updatedAt); - this.indexFts(prepared.memory); + this.reindexFts(prepared.memory); return attachMemoryVectors(prepared.memory, prepared.vectors); } @@ -442,7 +443,7 @@ export class MemoryRepository { this.vectors.upsert(updated.id, vector, updated.updatedAt); } } - this.indexFts(updated); + this.reindexFts(updated); return attachMemoryVectors(updated, updated.deletedAt || updated.status === "deleted" ? [] : mergedVectors); } @@ -940,13 +941,13 @@ export class MemoryRepository { }; } - private indexFts(memory: MemoryRow): void { + reindexFts(memory: MemoryRow): void { try { this.db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(memory.id); if (!memory.deletedAt && memory.status !== "deleted") { this.db .prepare(`INSERT INTO memories_fts (id, identifier, memory_value, tags) VALUES (?, ?, ?, ?)`) - .run(memory.id, memory.id, memory.memoryValue, memory.tags.join(" ")); + .run(memory.id, memory.id, retrievalDocumentForMemory(memory), memory.tags.join(" ")); } } catch { // The service search path is deterministic JS scoring; FTS is maintained @@ -1341,8 +1342,9 @@ export class RuntimeRepository { reopenEpisode(episodeId: string, metaPatch: Record = {}, at = nowIso()): EpisodeRecord | undefined { const episode = this.getEpisode(episodeId); if (!episode) return undefined; + const { reward: _staleReward, ...baseMeta } = episode.meta; const meta = { - ...episode.meta, + ...baseMeta, ...metaPatch }; this.db @@ -1350,6 +1352,8 @@ export class RuntimeRepository { `UPDATE episodes SET status = 'open', closed_at = NULL, + r_task = NULL, + reward_detail_json = '{}', meta_json = ?, updated_at = ? WHERE id = ?` @@ -1359,6 +1363,8 @@ export class RuntimeRepository { ...episode, status: "open", closedAt: null, + rTask: undefined, + rewardDetail: {}, meta, updatedAt: at }; @@ -1559,6 +1565,32 @@ export class RuntimeRepository { }; } + rebindRawTurnEpisode( + rawTurnId: string, + fromEpisodeId: string, + toEpisodeId: string, + at = nowIso() + ): void { + if (fromEpisodeId === toEpisodeId) return; + const fromEpisode = this.getEpisode(fromEpisodeId); + if (!fromEpisode || !this.getEpisode(toEpisodeId)) { + throw new Error("cannot rebind a raw turn to a missing episode"); + } + const remainingRawTurnIds = fromEpisode.rawTurnIds.filter((id) => id !== rawTurnId); + this.db + .prepare( + `UPDATE episodes + SET raw_turn_ids_json = ?, + turn_count = ?, + updated_at = ? + WHERE id = ?` + ) + .run(toJson(remainingRawTurnIds), remainingRawTurnIds.length, at, fromEpisodeId); + this.db.prepare("UPDATE raw_turns SET episode_id = ? WHERE id = ?").run(toEpisodeId, rawTurnId); + this.db.prepare("UPDATE artifacts SET episode_id = ? WHERE raw_turn_id = ?").run(toEpisodeId, rawTurnId); + this.appendEpisodeRawTurn(toEpisodeId, rawTurnId, at); + } + appendEpisodeFeedback(episodeId: string, feedbackId: string, at = nowIso()): EpisodeRecord | undefined { return this.appendEpisodeArrayValue(episodeId, "feedbackIds", "feedback_ids_json", feedbackId, at); } @@ -1680,7 +1712,8 @@ export class RuntimeRepository { this.db .prepare( `UPDATE raw_turns - SET user_text = @userText, + SET episode_id = @episodeId, + user_text = @userText, assistant_text = @assistantText, reasoning_summary = @reasoningSummary, tool_calls_json = @toolCallsJson, @@ -1695,6 +1728,7 @@ export class RuntimeRepository { ) .run({ id: rawTurn.id, + episodeId: rawTurn.episodeId, userText: rawTurn.userText ?? null, assistantText: rawTurn.assistantText ?? null, reasoningSummary: rawTurn.reasoningSummary ?? null, @@ -2186,7 +2220,8 @@ export class RuntimeRepository { leaseQueuedJobs( limit = 10, leaseSeconds = 60, - targetMemoryIds?: readonly string[] + targetMemoryIds?: readonly string[], + priorityCohortOnly = false ): EvolutionJobRecord[] { if (targetMemoryIds?.length === 0) { return []; @@ -2197,9 +2232,9 @@ export class RuntimeRepository { ? `AND target_memory_id IN (${targetMemoryIds.map(() => "?").join(", ")})` : ""; const transaction = this.db.transaction(() => { - const rows = this.db + const candidates = this.db .prepare( - `SELECT * + `SELECT *, ${evolutionJobPrioritySql()} AS queue_priority FROM evolution_jobs WHERE (status = 'queued' OR (status = 'leased' AND leased_until IS NOT NULL AND leased_until <= ?)) @@ -2237,7 +2272,13 @@ export class RuntimeRepository { ORDER BY ${evolutionJobOrderSql()} LIMIT ?` ) - .all(at, at, ...(targetMemoryIds ?? []), limit) as SqlJobRow[]; + .all(at, at, ...(targetMemoryIds ?? []), limit) as Array; + const queuePriority = candidates[0]?.queue_priority; + const rows = priorityCohortOnly && queuePriority !== undefined + ? candidates.filter((row) => row.queue_priority === queuePriority) + : candidates; for (const row of rows) { this.db @@ -2488,7 +2529,7 @@ export class RuntimeRepository { FROM embedding_retry_queue q LEFT JOIN memories m ON m.id = q.target_id ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} - ORDER BY q.next_attempt_at ASC, q.created_at ASC + ORDER BY ${embeddingRetryOrderSql()} LIMIT ? OFFSET ?` ) .all(...params, limit, offset) as SqlEmbeddingRetryRow[]; @@ -2562,20 +2603,21 @@ export class RuntimeRepository { } const limit = Math.max(1, Math.min(200, Math.floor(input.limit ?? 25))); const targetFilter = input.targetMemoryIds - ? `AND target_id IN (${input.targetMemoryIds.map(() => "?").join(", ")})` + ? `AND q.target_id IN (${input.targetMemoryIds.map(() => "?").join(", ")})` : ""; const transaction = this.db.transaction(() => { const rows = this.db .prepare( - `SELECT * - FROM embedding_retry_queue + `SELECT q.* + FROM embedding_retry_queue q + LEFT JOIN memories m ON m.id = q.target_id WHERE ( - status = 'pending' - OR (status = 'in_progress' AND lease_until IS NOT NULL AND lease_until <= ?) + q.status = 'pending' + OR (q.status = 'in_progress' AND q.lease_until IS NOT NULL AND q.lease_until <= ?) ) - AND next_attempt_at <= ? + AND q.next_attempt_at <= ? ${targetFilter} - ORDER BY next_attempt_at ASC, created_at ASC + ORDER BY ${embeddingRetryOrderSql()} LIMIT ?` ) .all(input.now, input.now, ...(input.targetMemoryIds ?? []), limit) as SqlEmbeddingRetryRow[]; @@ -4798,27 +4840,39 @@ function isSerializedBuffer(value: unknown): value is { __memmy_type: "buffer"; } function evolutionJobOrderSql(): string { - const summaryPlaceholderSql = importSummaryPlaceholderSql(); - const importIndexingSql = importIndexingSqlPredicate(); - return `CASE WHEN status = 'leased' THEN 0 ELSE 1 END ASC, + const memoryProcessingJob = `job_type IN ('trace_summary', 'import_summary', 'embedding') + AND target_memory_id IS NOT NULL`; + return `${evolutionJobPrioritySql()} ASC, + CASE WHEN ${memoryProcessingJob} + THEN COALESCE( + (SELECT created_at FROM memories WHERE memories.id = evolution_jobs.target_memory_id), + created_at + ) + ELSE '' + END DESC, CASE + WHEN job_type IN ('trace_summary', 'import_summary') THEN 0 + WHEN job_type = 'embedding' THEN 1 + ELSE 2 + END ASC, + CASE WHEN status = 'leased' THEN 0 ELSE 1 END ASC, + created_at ASC, + rowid ASC`; +} + +function evolutionJobPrioritySql(): string { + const importedTarget = targetMemoryMatchesSql(agentSourceMemorySql("memories")); + const interactiveL1Target = targetMemoryMatchesSql( + `memories.memory_layer = 'L1' AND NOT (${agentSourceMemorySql("memories")})` + ); + return `CASE WHEN json_extract(payload_json, '$.source') = 'memory.processing.manual_retry' THEN 0 - WHEN job_type = 'episode_idle_close' THEN 1 - WHEN job_type = 'embedding' AND EXISTS ( - SELECT 1 - FROM memories - WHERE memories.id = evolution_jobs.target_memory_id - AND ${importIndexingSql} - ) THEN 4 - WHEN job_type = 'trace_summary' THEN 5 - WHEN job_type = 'import_summary' THEN 6 - WHEN job_type = 'embedding' AND EXISTS ( - SELECT 1 - FROM memories - WHERE memories.id = evolution_jobs.target_memory_id - AND ${summaryPlaceholderSql} - ) THEN 7 - WHEN job_type = 'embedding' THEN 10 + WHEN job_type = 'trace_summary' + OR (job_type = 'embedding' AND ${interactiveL1Target}) THEN 1 + WHEN job_type = 'import_summary' + OR (job_type = 'embedding' AND ${importedTarget}) THEN 2 + WHEN job_type = 'embedding' THEN 3 + WHEN job_type = 'episode_idle_close' THEN 10 WHEN job_type = 'reflection' THEN 20 WHEN job_type = 'reward' THEN 30 WHEN job_type = 'span_big_turn' THEN 35 @@ -4828,38 +4882,41 @@ function evolutionJobOrderSql(): string { WHEN job_type = 'skill_crystallization' THEN 70 WHEN job_type = 'skill_trial_resolve' THEN 80 ELSE 100 - END ASC, - CASE - WHEN job_type IN ('trace_summary', 'import_summary') OR ( - job_type = 'embedding' AND EXISTS ( - SELECT 1 - FROM memories - WHERE memories.id = evolution_jobs.target_memory_id - AND ${summaryPlaceholderSql} - ) - ) - THEN COALESCE((SELECT updated_at FROM memories WHERE memories.id = evolution_jobs.target_memory_id), updated_at) - ELSE '' - END DESC, - created_at ASC, - rowid ASC`; -} - -function importSummaryPlaceholderSql(): string { - const summary = "COALESCE(json_extract(memories.info_json, '$.summary'), '')"; - const firstLine = `TRIM(REPLACE(REPLACE(CASE WHEN instr(${summary}, char(10)) > 0 THEN substr(${summary}, 1, instr(${summary}, char(10)) - 1) ELSE ${summary} END, '#', ''), char(13), ''))`; - return `${firstLine} IN ('user', 'assistant', 'system', 'tool', 'developer', '摘要排队中', '摘要整理中')`; + END`; } -function importIndexingSqlPredicate(): string { +function targetMemoryMatchesSql(predicate: string): string { return `EXISTS ( SELECT 1 - FROM memory_processing_state - WHERE memory_processing_state.memory_id = memories.id - AND memory_processing_state.state IN ('embedding_pending', 'embedding') + FROM memories + WHERE memories.id = evolution_jobs.target_memory_id + AND ${predicate} + )`; +} + +function agentSourceMemorySql(alias: string): string { + return `( + json_extract(${alias}.properties_json, '$.internal_info.plugin_algorithm') LIKE 'memory.add.import_async.%' + OR EXISTS ( + SELECT 1 + FROM json_each(${alias}.tags_json) + WHERE lower(json_each.value) = 'agent-source' + ) )`; } +function embeddingRetryOrderSql(): string { + const importedMemory = agentSourceMemorySql("m"); + return `CASE + WHEN q.target_kind = 'trace' AND m.memory_layer = 'L1' AND NOT (${importedMemory}) THEN 0 + WHEN q.target_kind = 'trace' AND m.memory_layer = 'L1' AND ${importedMemory} THEN 1 + ELSE 2 + END ASC, + m.created_at DESC, + q.next_attempt_at ASC, + q.created_at ASC`; +} + export function jobToRef(job: EvolutionJobRecord): JobRef { return { jobId: job.id, diff --git a/Memory/tests/algorithm/plugin-algorithms.test.ts b/Memory/tests/algorithm/plugin-algorithms.test.ts index ff8447625..f06a47ff8 100644 --- a/Memory/tests/algorithm/plugin-algorithms.test.ts +++ b/Memory/tests/algorithm/plugin-algorithms.test.ts @@ -1644,6 +1644,55 @@ describe("plugin algorithm parity helpers", () => { expect(result.hits.map((hit) => hit.id)).toEqual(["policy-active"]); }); + it("filters malformed failure-avoidance policies whose preference repeats the anti-pattern", () => { + const malformed = policyMemory( + "policy_5608950f4a75b91d2db4", + "黄金与比特币分析纠错", + "active", + [1, 0] + ); + const malformedPolicy = malformed.properties.internal_info.policy as Record; + Object.assign(malformedPolicy, { + experience_type: "failure_avoidance", + evidence_polarity: "negative", + skill_eligible: false, + policy_confidence: 1, + decision_guidance: { + preference: ["我说的是黄金,不是比特币"], + anti_pattern: ["我说的是黄金,不是比特币"] + } + }); + const actionable = policyMemory( + "policy-actionable-correction", + "TLS port correction", + "active", + [1, 0] + ); + const actionablePolicy = actionable.properties.internal_info.policy as Record; + Object.assign(actionablePolicy, { + experience_type: "failure_avoidance", + evidence_polarity: "negative", + skill_eligible: false, + policy_confidence: 0.75, + decision_guidance: { + preference: ["Use port 443 and verify TLS before reporting completion"], + anti_pattern: ["Configure port 80 and skip TLS verification"] + } + }); + + const result = retrievePluginMemories({ + query: "TLS port correction 黄金 比特币", + queryVector: [1, 0], + memories: [malformed, actionable], + layers: ["L2"], + limit: 5, + mode: "search", + now: Date.parse("2026-05-29T00:00:00.000Z") + }); + + expect(result.hits.map((hit) => hit.id)).toEqual(["policy-actionable-correction"]); + }); + it("uses plugin Tier-2 experience salience for feedback-derived L2 policies", () => { const plainPolicy = policyMemory("policy-plain", "python pytest policy", "active", [1, 0]); const feedbackPolicy = policyMemory("policy-feedback", "python pytest policy", "active", [1, 0]); diff --git a/Memory/tests/contract/memory-rest-service.test.ts b/Memory/tests/contract/memory-rest-service.test.ts index 04ba953c6..844aea8de 100644 --- a/Memory/tests/contract/memory-rest-service.test.ts +++ b/Memory/tests/contract/memory-rest-service.test.ts @@ -212,13 +212,12 @@ describe("MemoryService / REST contract", () => { body: JSON.stringify(startRequestBody) }); const started = await startResponse.json() as { - episodeId: string; searchEventId: string; turnId: string; }; expect(startResponse.status).toBe(200); expect(started.turnId).toBe("cursor-http-turn"); - expect(started.episodeId).toMatch(/^episode_/u); + expect(started).not.toHaveProperty("episodeId"); const afterFirstStart = { episodes: (db.db.prepare("SELECT COUNT(*) AS count FROM episodes").get() as { count: number }).count, rawTurns: (db.db.prepare("SELECT COUNT(*) AS count FROM raw_turns").get() as { count: number }).count, @@ -228,8 +227,6 @@ describe("MemoryService / REST contract", () => { }; expect(afterFirstStart).toEqual({ ...beforeStart, - episodes: beforeStart.episodes + 1, - rawTurns: beforeStart.rawTurns + 1, recalls: beforeStart.recalls + 1, apiLogs: beforeStart.apiLogs + 1, idempotency: beforeStart.idempotency + 1 @@ -238,11 +235,7 @@ describe("MemoryService / REST contract", () => { `SELECT episode_id, assistant_text, status FROM raw_turns WHERE session_id = ? AND turn_id = ?` - ).get(opened.sessionId, started.turnId)).toEqual({ - episode_id: started.episodeId, - assistant_text: null, - status: "started" - }); + ).get(opened.sessionId, started.turnId)).toBeUndefined(); expect(db.db.prepare( `SELECT tool_name, json_extract(input_json, '$.retrievalMode') AS retrieval_mode FROM api_logs @@ -258,7 +251,6 @@ describe("MemoryService / REST contract", () => { body: JSON.stringify(startRequestBody) }); const duplicateStarted = await duplicateStartResponse.json() as { - episodeId: string; searchEventId: string; turnId: string; }; @@ -300,7 +292,7 @@ describe("MemoryService / REST contract", () => { }); const completed = await completeResponse.json() as { episodeId: string; rawTurnId: string }; expect(completeResponse.status).toBe(200); - expect(completed.episodeId).toBe(started.episodeId); + expect(completed.episodeId).toMatch(/^episode_/u); const sessionRow = db.db.prepare( "SELECT source, profile_id, workspace_path FROM sessions WHERE id = ?" diff --git a/Memory/tests/http-startup.test.ts b/Memory/tests/http-startup.test.ts index 4c07fd070..9f0e4f242 100644 --- a/Memory/tests/http-startup.test.ts +++ b/Memory/tests/http-startup.test.ts @@ -51,8 +51,10 @@ describe("Memory HTTP startup", () => { let runs = 0; let timerFired = false; let timerObservedBeforeSecondRun = false; + const limits: number[] = []; const service = stubService(() => undefined); - service.runWorkerOnce = async () => { + service.runWorkerOnce = async (limit) => { + limits.push(limit ?? 100); runs += 1; if (runs === 1) { setTimeout(() => { @@ -73,6 +75,7 @@ describe("Memory HTTP startup", () => { await waitFor(() => runs >= 2); expect(timerObservedBeforeSecondRun).toBe(true); + expect(limits).toEqual([4, 4]); }); }); diff --git a/Memory/tests/repository/memory-retrieval-index.test.ts b/Memory/tests/repository/memory-retrieval-index.test.ts index 8ca1a95b2..c6ee4aaeb 100644 --- a/Memory/tests/repository/memory-retrieval-index.test.ts +++ b/Memory/tests/repository/memory-retrieval-index.test.ts @@ -102,6 +102,37 @@ describe("memory retrieval indexes", () => { } }); + it("indexes Skill retrieval metadata and can refresh a legacy FTS row in place", () => { + const root = mkdtempSync(join(tmpdir(), "mindock-skill-retrieval-index-")); + try { + const db = new MemoryDb({ path: join(root, "memory.sqlite") }); + const repos = new Repositories(db.db); + const memory = retrievalSkillMemory(); + repos.memories.insert(memory); + + expect(repos.memories.searchFtsIds("\"retrievalneedle\"", { memoryLayer: "Skill" }, 5) + .map((hit) => hit.id)).toContain(memory.id); + expect(repos.memories.searchFtsIds("\"procedureonlyneedle\"", { memoryLayer: "Skill" }, 5) + .map((hit) => hit.id)).not.toContain(memory.id); + + db.db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(memory.id); + db.db.prepare( + `INSERT INTO memories_fts (id, identifier, memory_value, tags) VALUES (?, ?, ?, ?)` + ).run(memory.id, memory.id, memory.memoryValue, memory.tags.join(" ")); + expect(repos.memories.searchFtsIds("\"procedureonlyneedle\"", { memoryLayer: "Skill" }, 5) + .map((hit) => hit.id)).toContain(memory.id); + + repos.memories.reindexFts(repos.memories.get(memory.id)!); + expect(repos.memories.searchFtsIds("\"retrievalneedle\"", { memoryLayer: "Skill" }, 5) + .map((hit) => hit.id)).toContain(memory.id); + expect(repos.memories.searchFtsIds("\"procedureonlyneedle\"", { memoryLayer: "Skill" }, 5) + .map((hit) => hit.id)).not.toContain(memory.id); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it.each([ { layer: "L1", owner: "trace", fields: ["vec_summary", "vec_action"] }, { layer: "L2", owner: "policy", fields: ["vec"] }, @@ -384,6 +415,43 @@ function authorityMemory(id: string, layer: MemoryLayer): MemoryRow { }; } +function retrievalSkillMemory(): MemoryRow { + const at = "2026-06-18T00:00:00.000Z"; + return { + id: "skill_retrieval_indexed", + timeline: at, + userId: "skill-index-user", + memoryType: "SkillMemory", + status: "activated", + visibility: "private", + memoryKey: "skill:retrieval-indexed", + memoryValue: "# Skill retrieval\n\nprocedureonlyneedle", + tags: ["skill", "retrieval"], + info: {}, + properties: { + internal_info: { + memory_layer: "Skill", + memory_kind: "skill", + skill: { + name: "Skill retrieval", + status: "active", + invocation_guide: "# Skill retrieval\n\nprocedureonlyneedle", + procedure_json: { + retrievalBlurb: "retrievalneedle", + triggerContext: "Use when retrieval metadata matches." + } + } + } + }, + memoryLayer: "Skill", + contentHash: "skill-retrieval-indexed-hash", + version: 1, + createdAt: at, + updatedAt: at, + deletedAt: null + }; +} + function vectorTimestamps(db: MemoryDb, memoryId: string): Record { const rows = db.db.prepare( `SELECT vector_field, updated_at diff --git a/Memory/tests/service/embedding/embedding-processing.test.ts b/Memory/tests/service/embedding/embedding-processing.test.ts index f8c175748..1a91fab63 100644 --- a/Memory/tests/service/embedding/embedding-processing.test.ts +++ b/Memory/tests/service/embedding/embedding-processing.test.ts @@ -6,7 +6,14 @@ import { type Embedder, type MemoryRow } from "../../../src/index.js"; -import { embeddingTextForMemory } from "../../../src/service/embedding/embedding-pipeline.js"; +import { + retrievalDocumentIsCurrent, + retrievalDocumentSourceHash +} from "../../../src/algorithm/plugin-algorithms.js"; +import { + embeddingTextForMemory, + updateMemoryVectorField +} from "../../../src/service/embedding/embedding-pipeline.js"; import { Repositories } from "../../../src/storage/repositories.js"; import { createBatchReflectionLlm, @@ -25,6 +32,50 @@ const { afterEach(cleanup); describe("MemoryService / embedding / processing", () => { + it("embeds Skill retrieval metadata instead of the full SKILL.md when short metadata exists", () => { + const text = embeddingTextForMemory(skillMemory({ + retrievalBlurb: "Use for safe SQLite schema migrations.", + triggerContext: "Trigger when a task changes tables or indexes." + })); + + expect(text).toContain("Use for safe SQLite schema migrations."); + expect(text).toContain("Trigger when a task changes tables or indexes."); + expect(text).not.toContain("PROCEDURE_ONLY_SENTINEL"); + }); + + it("keeps legacy Skill memories searchable through their invocation guide", () => { + expect(embeddingTextForMemory(skillMemory())).toContain("PROCEDURE_ONLY_SENTINEL"); + }); + + it("marks a replacement Skill vector with its retrieval document version and source hash", () => { + const memory = skillMemory({ + retrievalBlurb: "Use for safe SQLite schema migrations.", + triggerContext: "Trigger when a task changes tables or indexes." + }); + const sourceHash = retrievalDocumentSourceHash(memory); + const updated = updateMemoryVectorField(memory, "vec", [1, 0], { + provider: "test", + model: "test", + updatedAt: "2026-07-24T01:00:00.000Z", + sourceHash + }); + + expect(updated.properties.internal_info.retrieval_index).toEqual({ + version: 2, + source_hash: sourceHash, + indexed_at: "2026-07-24T01:00:00.000Z" + }); + expect(retrievalDocumentIsCurrent(updated)).toBe(true); + }); + + it("embeds L3 summary and structure without duplicating the rendered body", () => { + const text = embeddingTextForMemory(worldModelMemory()); + + expect(text).toContain("Schema migrations require staged verification."); + expect(text).toContain("Environment: SQLite database"); + expect(text).not.toContain("BODY_ONLY_SENTINEL"); + }); + it("falls back to title when negative L2 title and trigger exceed 2048 mixed-language tokens", () => { const title = "Avoid"; const triggerAtLimit = [ @@ -215,11 +266,13 @@ describe("MemoryService / embedding / processing", () => { layers: ["L1"] }); expect(recall.hits.some((hit) => hit.id === complete.l1MemoryId)).toBe(true); - const openEpisodeRun = await service.runWorkerOnce(10); - expect(openEpisodeRun.jobs.map((job) => job.jobType)).toEqual(["episode_idle_close", "trace_summary"]); + const openEpisodeRun = await service.runWorkerOnce(10, { priorityCohortOnly: true }); + expect(openEpisodeRun.jobs.map((job) => job.jobType)).toEqual(["trace_summary"]); expect(llmCalls.filter((call) => call.options.operation === "capture.summarize")).toHaveLength(1); - const embeddingRun = await service.runWorkerOnce(10); + const embeddingRun = await service.runWorkerOnce(10, { priorityCohortOnly: true }); expect(embeddingRun.jobs.map((job) => job.jobType)).toEqual(["embedding"]); + const episodeRun = await service.runWorkerOnce(10, { priorityCohortOnly: true }); + expect(episodeRun.jobs.map((job) => job.jobType)).toEqual(["episode_idle_close"]); expect(embeddingTexts).toHaveLength(1); expect(db.db.prepare( `SELECT COUNT(*) AS count FROM evolution_jobs @@ -287,6 +340,79 @@ function negativePolicyMemory(title: string, trigger: string): MemoryRow { }; } +function skillMemory(short?: { + retrievalBlurb: string; + triggerContext: string; +}): MemoryRow { + const now = "2026-07-24T00:00:00.000Z"; + return { + id: "skill_retrieval_document", + timeline: now, + userId: "skill-retrieval-user", + memoryType: "SkillMemory", + status: "activated", + visibility: "private", + memoryKey: "skill:sqlite-migration", + memoryValue: "# SQLite migration\n\nPROCEDURE_ONLY_SENTINEL", + tags: ["sqlite", "migration"], + info: {}, + properties: { + internal_info: { + memory_layer: "Skill", + memory_kind: "skill", + skill: { + name: "SQLite migration", + status: "active", + invocation_guide: "# SQLite migration\n\nPROCEDURE_ONLY_SENTINEL", + ...(short ? { procedure_json: short } : {}) + } + } + }, + memoryLayer: "Skill", + version: 1, + createdAt: now, + updatedAt: now + }; +} + +function worldModelMemory(): MemoryRow { + const now = "2026-07-24T00:00:00.000Z"; + return { + id: "world_model_retrieval_document", + timeline: now, + userId: "world-retrieval-user", + memoryType: "LongTermMemory", + status: "activated", + visibility: "private", + memoryKey: "world-model:sqlite-migrations", + memoryValue: "# SQLite migrations\n\nBODY_ONLY_SENTINEL", + tags: ["sqlite", "migration"], + info: {}, + properties: { + internal_info: { + memory_layer: "L3", + memory_kind: "world_model", + world_model: { + title: "SQLite migrations", + domain_key: "engineering|database", + domain_tags: ["sqlite", "migration"], + summary: "Schema migrations require staged verification.", + body: "# SQLite migrations\n\nBODY_ONLY_SENTINEL", + structure: { + environment: [{ label: "Environment", description: "SQLite database" }], + inference: [{ label: "Inference", description: "Verify focused paths first" }], + constraints: [{ label: "Constraint", description: "Preserve old readers" }] + } + } + } + }, + memoryLayer: "L3", + version: 1, + createdAt: now, + updatedAt: now + }; +} + function createFlakyEmbedder(): Embedder { let batchCalls = 0; return { diff --git a/Memory/tests/service/evolution/evolution-llm-stubs.ts b/Memory/tests/service/evolution/evolution-llm-stubs.ts index 36d358b81..fa89d6fa3 100644 --- a/Memory/tests/service/evolution/evolution-llm-stubs.ts +++ b/Memory/tests/service/evolution/evolution-llm-stubs.ts @@ -63,7 +63,7 @@ export function createCapturingL2Llm(calls: Array<{ support_trace_ids: [] }) as unknown as T; } - if (options.operation === "l3.abstraction.v2") { + if (options.operation === "l3.abstraction.v3") { return (l3AbstractionResponse ?? { title: "Pytest sqlite migration environment", domain_tags: ["pytest", "sqlite"], @@ -163,7 +163,7 @@ export function createCapturingL2Llm(calls: Array<{ export function createNoToolSkillLlm(calls: Array<{ messages: Array<{ role: string; content: string }>; options: { operation: string }; -}> = []): LlmClient { +}> = [], l3AbstractionResponse?: Record): LlmClient { const base = createCapturingL2Llm(calls, { name: "memory_workflow_pytest_retry", retrieval_blurb: "Use for python REST memory workflows and pytest retry workflows that require focused verification.", @@ -178,7 +178,7 @@ export function createNoToolSkillLlm(calls: Array<{ }], tools: [], tags: ["pytest", "retry"] - }); + }, undefined, l3AbstractionResponse); return { ...base, async completeJson>( diff --git a/Memory/tests/service/evolution/negative-experience.test.ts b/Memory/tests/service/evolution/negative-experience.test.ts index 86e84a358..9dc3a1c3f 100644 --- a/Memory/tests/service/evolution/negative-experience.test.ts +++ b/Memory/tests/service/evolution/negative-experience.test.ts @@ -123,10 +123,18 @@ describe("MemoryService / evolution / negative experience", () => { expect(service.panelItems({ namespace, layer: "L2" }).items).toEqual([]); expect(feedback.jobs.map((job) => job.jobType)).not.toContain("negative_experience"); - expect(feedback.jobs.map((job) => job.jobType)).toContain("reward"); + expect(feedback.jobs.map((job) => job.jobType)).not.toContain("reward"); + service.closeSession(session.sessionId); await service.runWorkerOnce(50); expect(service.panelItems({ namespace, layer: "L2" }).items).toEqual([]); + expect(service.panelJobs({ namespace, status: "queued" }).items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ jobType: "reward" }) + ]) + ); + + await service.runWorkerOnce(50); expect(service.panelJobs({ namespace, status: "queued" }).items).toEqual( expect.arrayContaining([ expect.objectContaining({ jobType: "negative_experience" }) @@ -161,10 +169,8 @@ describe("MemoryService / evolution / negative experience", () => { }); expect(detail.body).toContain("Wrong port"); expect(detail.body).toContain("443"); - expect(operations).toEqual([ - "capture.summarize", - "reward.reward.r_human.v7" - ]); + expect(operations[0]).toBe("capture.summarize"); + expect(operations.filter((operation) => operation === "reward.reward.r_human.v7")).toHaveLength(1); const negativePolicy = (detail.metadata.properties as { internal_info: { policy: { @@ -176,10 +182,9 @@ describe("MemoryService / evolution / negative experience", () => { const initialVersion = policies[0]!.version; await service.runWorkerOnce(50); - expect(embeddedTexts).toEqual([ - [negativePolicy.title, negativePolicy.trigger].join("\n") - ]); - expect(embeddingRoles).toEqual(["query"]); + const policyEmbeddingText = [negativePolicy.title, negativePolicy.trigger].join("\n"); + expect(embeddedTexts).toContain(policyEmbeddingText); + expect(embeddingRoles[embeddedTexts.indexOf(policyEmbeddingText)]).toBe("query"); expect(service.panelItems({ namespace, layer: "L2" }).items).toEqual([ expect.objectContaining({ id: policies[0]!.id, version: initialVersion }) ]); @@ -200,7 +205,7 @@ describe("MemoryService / evolution / negative experience", () => { db.close(); }); - it("admits an episode exactly at the configured negative rTask boundary", async () => { + it("does not turn a weak negative score at the boundary into a policy", async () => { const operations: string[] = []; const llm = createCountingLlm(operations, { goal_achievement: -0.15, @@ -261,6 +266,13 @@ describe("MemoryService / evolution / negative experience", () => { magnitude: 1 }); + service.closeSession(session.sessionId); + await service.runWorkerOnce(50); + expect(service.panelJobs({ namespace, status: "queued" }).items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ jobType: "reward" }) + ]) + ); await service.runWorkerOnce(50); expect(service.panelJobs({ namespace, status: "queued" }).items).toEqual( expect.arrayContaining([ @@ -270,14 +282,9 @@ describe("MemoryService / evolution / negative experience", () => { await service.runWorkerOnce(50); const policies = service.panelItems({ namespace, layer: "L2" }).items; - expect(policies).toHaveLength(1); - expect(service.getMemory(policies[0]!.id, { namespace }).body).toContain( - "TLS verification was skipped" - ); - expect(operations).toEqual([ - "capture.summarize", - "reward.reward.r_human.v7" - ]); + expect(policies).toEqual([]); + expect(operations[0]).toBe("capture.summarize"); + expect(operations.filter((operation) => operation === "reward.reward.r_human.v7")).toHaveLength(1); db.close(); }); @@ -335,6 +342,8 @@ describe("MemoryService / evolution / negative experience", () => { magnitude: 1, rationale: "Wrong port: use 443 and verify TLS before reporting completion." }); + service.closeSession(session.sessionId); + await service.runWorkerOnce(50); await service.runWorkerOnce(50); await service.runWorkerOnce(50); const recall = await service.search({ @@ -383,6 +392,8 @@ describe("MemoryService / evolution / negative experience", () => { magnitude: 1, rationale: "Wrong port: use 443 and verify TLS before reporting completion." }); + service.closeSession(otherSession.sessionId); + await service.runWorkerOnce(50); await service.runWorkerOnce(50); await service.runWorkerOnce(50); const otherRecall = await service.search({ @@ -450,6 +461,9 @@ describe("MemoryService / evolution / negative experience", () => { rationale: "Be careful." }); + service.closeSession(session.sessionId); + await service.runWorkerOnce(50); + await service.runWorkerOnce(50); await service.runWorkerOnce(50); expect(service.panelItems({ namespace, layer: "L2" }).items).toEqual([]); @@ -503,10 +517,12 @@ describe("MemoryService / evolution / negative experience", () => { magnitude: 1, rationale: `TLS_ROTATION_GUARD_${index} verify certificate rotation before completion.` }); + service.closeSession(session.sessionId); if (index === 24) targetSessionId = session.sessionId; } await service.runWorkerOnce(1000); await service.runWorkerOnce(1000); + await service.runWorkerOnce(1000); const crossUserPolicy = db.db.prepare( `SELECT id FROM memories diff --git a/Memory/tests/service/evolution/orchestration.test.ts b/Memory/tests/service/evolution/orchestration.test.ts index 751b0aa5f..e4136f4b7 100644 --- a/Memory/tests/service/evolution/orchestration.test.ts +++ b/Memory/tests/service/evolution/orchestration.test.ts @@ -81,6 +81,7 @@ describe("MemoryService / evolution / orchestration", () => { makeTraceEligibleForL2(db, complete.l1MemoryId); } + service.closeSession(session.sessionId); let succeeded = 0; for (let i = 0; i < 20; i += 1) { succeeded += (await service.runWorkerOnce(100)).succeeded; @@ -207,31 +208,45 @@ describe("MemoryService / evolution / orchestration", () => { }); expect(searchedSkills.items.length).toBeGreaterThanOrEqual(1); const skillId = skills.items[0]!.id; + const trialSession = service.openSession({ + namespace: { + source: "codex", + profileId: "jiang", + userId: "user-2", + sessionKey: "skill-trial" + } + }); + const trialTurn = service.completeTurn("turn-skill-trial", { + sessionId: trialSession.sessionId, + episodeId: "episode-skill-trial", + query: "apply the recalled python REST memory workflow skill", + answer: "applied the recalled workflow" + }); const trial = service.useSkill(skillId, { adapterId: "test-adapter", requestId: "skill-use-1", - sessionId: session.sessionId, - episodeId: completes[0]!.episodeId, - rawTurnId: completes[0]!.rawTurnId, - turnId: completes[0]!.turnId + sessionId: trialSession.sessionId, + episodeId: trialTurn.episodeId, + rawTurnId: trialTurn.rawTurnId, + turnId: trialTurn.turnId }); const duplicateTrial = service.useSkill(skillId, { adapterId: "test-adapter", requestId: "skill-use-1", - sessionId: session.sessionId, - episodeId: completes[0]!.episodeId, - rawTurnId: completes[0]!.rawTurnId, - turnId: completes[0]!.turnId + sessionId: trialSession.sessionId, + episodeId: trialTurn.episodeId, + rawTurnId: trialTurn.rawTurnId, + turnId: trialTurn.turnId }); expect(duplicateTrial.trialId).toBe(trial.trialId); expect(duplicateTrial.duplicate).toBe(true); const duplicateEpisodeTrial = service.useSkill(skillId, { adapterId: "test-adapter", requestId: "skill-use-2", - sessionId: session.sessionId, - episodeId: completes[0]!.episodeId, - rawTurnId: completes[0]!.rawTurnId, - turnId: completes[0]!.turnId + sessionId: trialSession.sessionId, + episodeId: trialTurn.episodeId, + rawTurnId: trialTurn.rawTurnId, + turnId: trialTurn.turnId }); expect(duplicateEpisodeTrial.trialId).toBe(trial.trialId); expect(duplicateEpisodeTrial.duplicate).toBe(true); @@ -241,7 +256,7 @@ describe("MemoryService / evolution / orchestration", () => { WHERE skill_memory_id = ? AND episode_id = ? AND outcome = 'unknown'` - ).get(skillId, completes[0]!.episodeId) as { count: number }; + ).get(skillId, trialTurn.episodeId) as { count: number }; expect(pendingTrialCount.count).toBe(1); const pendingTrial = db.db.prepare( `SELECT status, outcome, l1_memory_id @@ -250,7 +265,7 @@ describe("MemoryService / evolution / orchestration", () => { ).get(trial.trialId) as { status: string; outcome: string; l1_memory_id: string | null }; expect(pendingTrial.status).toBe("pending"); expect(pendingTrial.outcome).toBe("unknown"); - expect(pendingTrial.l1_memory_id).toBe(completes[0]!.l1MemoryId); + expect(pendingTrial.l1_memory_id).toBe(trialTurn.l1MemoryId); const prematureResolveJobs = db.db.prepare( `SELECT COUNT(*) AS count FROM evolution_jobs @@ -295,9 +310,9 @@ describe("MemoryService / evolution / orchestration", () => { entity_id: trial.trialId }); const skillFeedback = await service.feedback({ - sessionId: session.sessionId, - episodeId: completes[0]!.episodeId, - rawTurnId: completes[0]!.rawTurnId, + sessionId: trialSession.sessionId, + episodeId: trialTurn.episodeId, + rawTurnId: trialTurn.rawTurnId, channel: "explicit", polarity: "positive", magnitude: 1, @@ -314,7 +329,7 @@ describe("MemoryService / evolution / orchestration", () => { target_memory_id: string | null; payload_json: string; }; - expect(trialResolveJobRow.episode_id).toBe(completes[0]!.episodeId); + expect(trialResolveJobRow.episode_id).toBe(trialTurn.episodeId); expect(trialResolveJobRow.target_memory_id).toBeNull(); expect(JSON.parse(trialResolveJobRow.payload_json)).toMatchObject({ trialId: trial.trialId, @@ -410,8 +425,8 @@ describe("MemoryService / evolution / orchestration", () => { { trialId: trial.trialId, status: "pass", - episodeId: completes[0]!.episodeId, - reward: expect.any(Number) + episodeId: trialTurn.episodeId, + reward: undefined } ])); const episodeIndexes = db.db.prepare( @@ -449,7 +464,7 @@ describe("MemoryService / evolution / orchestration", () => { kind: "skill_trial", op: "updated", entity_id: trial.trialId, - source: "worker.reward.updated" + source: "worker.skill_trial_resolve" }); const recall = await service.search({ @@ -556,6 +571,9 @@ describe("MemoryService / evolution / orchestration", () => { service.closeSession(session.sessionId); await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); makeTraceEligibleForL2(db, second.l1MemoryId); db.db.prepare( `UPDATE evolution_jobs @@ -595,7 +613,7 @@ describe("MemoryService / evolution / orchestration", () => { payload_json: string; }>; expect(downstreamJobs.map((job) => job.job_type)).toEqual(["l3_abstraction", "skill_crystallization"]); - expect(downstreamJobs.map((job) => job.status)).toEqual(["queued", "queued"]); + expect(downstreamJobs.map((job) => job.status)).toEqual(["succeeded", "succeeded"]); expect(downstreamJobs.map((job) => job.episode_id)).toEqual([ "episode-l2-activation-2", "episode-l2-activation-2" @@ -608,13 +626,13 @@ describe("MemoryService / evolution / orchestration", () => { targetKind: "policy_cluster", seedPolicyId: "policy_l2_activation_downstream", policyIds: ["policy_l2_activation_downstream"], - previousStatus: "candidate", + previousStatus: "active", status: "active" }); expect(skillJob?.target_memory_id).toBe("policy_l2_activation_downstream"); expect(JSON.parse(skillJob!.payload_json)).toMatchObject({ reason: "l2.policy.updated", - previousStatus: "candidate", + previousStatus: "active", status: "active" }); @@ -850,8 +868,9 @@ describe("MemoryService / evolution / orchestration", () => { }); makeTraceEligibleForL2(db, complete.l1MemoryId); } + service.closeSession(session.sessionId); let policyCreated = false; - for (let i = 0; i < 20; i += 1) { + for (let i = 0; i < 40; i += 1) { await service.runWorkerOnce(1); const l2Count = db.db.prepare( `SELECT COUNT(*) AS count @@ -984,14 +1003,14 @@ describe("MemoryService / evolution / orchestration", () => { for (let i = 0; i < 16; i += 1) { await service.runWorkerOnce(100); if ( - calls.some((call) => call.options.operation === "l3.abstraction.v2") && + calls.some((call) => call.options.operation === "l3.abstraction.v3") && calls.some((call) => call.options.operation === "skill.crystallize") ) { break; } } - const l3Call = calls.find((call) => call.options.operation === "l3.abstraction.v2"); + const l3Call = calls.find((call) => call.options.operation === "l3.abstraction.v3"); if (l3Call) { expect(l3Call.options.thinkingMode).toBe("enabled"); expect(l3Call.messages[0]!.content).toContain("declarative"); diff --git a/Memory/tests/service/evolution/policy-induction.test.ts b/Memory/tests/service/evolution/policy-induction.test.ts index dd8816ee0..a9cae88d3 100644 --- a/Memory/tests/service/evolution/policy-induction.test.ts +++ b/Memory/tests/service/evolution/policy-induction.test.ts @@ -148,8 +148,27 @@ describe("MemoryService / evolution / policy induction", () => { service.closeSession(session.sessionId); await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); makeTraceEligibleForL2(db, complete.l1MemoryId); - db.db.prepare(`UPDATE evolution_jobs SET status = 'succeeded' WHERE job_type <> 'l2_association'`).run(); + db.db.prepare(`DELETE FROM trace_policy_links WHERE l1_memory_id = ?`).run(complete.l1MemoryId); + db.db.prepare(`UPDATE evolution_jobs SET status = 'succeeded'`).run(); + const associationAt = new Date().toISOString(); + db.db.prepare( + `INSERT INTO evolution_jobs ( + id, job_type, status, user_id, session_id, episode_id, target_memory_id, + payload_json, attempts, max_attempts, created_at, updated_at + ) VALUES (?, 'l2_association', 'queued', ?, ?, ?, ?, '{}', 0, 3, ?, ?)` + ).run( + "job_best_l2_association", + "user-best-l2-association", + session.sessionId, + complete.episodeId, + complete.l1MemoryId, + associationAt, + associationAt + ); await service.runWorkerOnce(20); const links = db.db.prepare( @@ -349,6 +368,9 @@ describe("MemoryService / evolution / policy induction", () => { service.closeSession(profileA.sessionId); service.closeSession(profileB.sessionId); await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); makeTraceEligibleForL2(db, firstA.l1MemoryId); makeTraceEligibleForL2(db, firstB.l1MemoryId); db.db.prepare(`UPDATE evolution_jobs SET status = 'succeeded' WHERE job_type <> 'l2_induction'`).run(); @@ -379,6 +401,9 @@ describe("MemoryService / evolution / policy induction", () => { makeTraceEligibleForL2(db, secondA.l1MemoryId); service.closeSession(profileANext.sessionId); await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); makeTraceEligibleForL2(db, secondA.l1MemoryId); db.db.prepare(`UPDATE evolution_jobs SET status = 'succeeded' WHERE job_type <> 'l2_induction'`).run(); @@ -470,6 +495,9 @@ describe("MemoryService / evolution / policy induction", () => { } service.closeSession(profileA.sessionId); await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); for (const turn of turnsA) { setTraceSignatureAndVectorForTest(db, turn.l1MemoryId, signature, [1, 0, 0]); } @@ -490,6 +518,9 @@ describe("MemoryService / evolution / policy induction", () => { } service.closeSession(profileB.sessionId); await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); + await service.runWorkerOnce(20); for (const turn of turnsB) { setTraceSignatureAndVectorForTest(db, turn.l1MemoryId, signature, [1, 0, 0]); } @@ -907,6 +938,7 @@ describe("MemoryService / evolution / policy induction", () => { magnitude: 1, rationale: "the focused pytest migration workflow worked" }); + service.closeSession(session.sessionId); makeTraceEligibleForL2(db, complete.l1MemoryId); for (let i = 0; i < 8; i += 1) { await service.runWorkerOnce(50); @@ -1036,6 +1068,7 @@ describe("MemoryService / evolution / policy induction", () => { magnitude: 1, rationale: "the focused migration diagnosis worked" }); + service.closeSession(session.sessionId); makeTraceEligibleForL2(db, complete.l1MemoryId); for (let i = 0; i < 8; i += 1) { await service.runWorkerOnce(50); diff --git a/Memory/tests/service/evolution/reward.test.ts b/Memory/tests/service/evolution/reward.test.ts index f77fc542f..6d87fa670 100644 --- a/Memory/tests/service/evolution/reward.test.ts +++ b/Memory/tests/service/evolution/reward.test.ts @@ -171,7 +171,7 @@ describe("MemoryService / evolution / reward", () => { userId: "user-implicit-reward", status: "queued" }).items.map((job) => job.jobType); - expect(queuedOrder.slice(0, 2)).toEqual(["episode_idle_close", "trace_summary"]); + expect(queuedOrder.slice(0, 2)).toEqual(["trace_summary", "episode_idle_close"]); const run = await service.runWorkerOnce(20); expect(run.changeSeq).toBeGreaterThan(0); @@ -203,7 +203,7 @@ describe("MemoryService / evolution / reward", () => { db.close(); }); - it("still reflects unscored L1 memories when an episode already has reward", async () => { + it("waits until episode close and scores every trace exactly once", async () => { const calls: Array<{ messages: Array<{ role: string; content: string }>; options: { operation: string }; @@ -264,32 +264,13 @@ describe("MemoryService / evolution / reward", () => { rationale: "我不是只让你推荐一个吗" }); await service.runWorkerOnce(20); - const rewarded = db.db.prepare( + const openEpisode = db.db.prepare( `SELECT r_task FROM episodes WHERE id = ?` ).get(first.episodeId) as { r_task: number | null }; - expect(typeof rewarded.r_task).toBe("number"); - const immediateRewardCall = calls.find((call) => - call.options.operation === "reward.reward.r_human.v7" - ); - expect(immediateRewardCall).toBeTruthy(); - const immediateRewardInput = JSON.parse( - immediateRewardCall!.messages.find((message) => message.role === "user")!.content - ) as { - turnSummaries: string[]; - finalExchange: { user: string; assistant: string }; - }; - expect(immediateRewardInput.turnSummaries[0]).toBe("LLM batch summary"); - expect(immediateRewardInput.turnSummaries[0]!.length).toBeLessThanOrEqual(200); - expect(immediateRewardInput.turnSummaries[1]).toBe("LLM batch summary"); - expect(immediateRewardInput.finalExchange).toEqual({ - user: "水果中和西瓜比较相似有哪些,推荐一个", - assistant: "我推荐哈密瓜。" - }); - expect(calls.filter((call) => - call.options.operation === "reward.reward.r_human.v7" - )).toHaveLength(1); + expect(openEpisode.r_task).toBeNull(); + expect(calls.filter((call) => call.options.operation === "reward.reward.r_human.v7")).toEqual([]); expect(calls.filter((call) => call.options.operation === "capture.summarize")).toHaveLength(2); const third = service.completeTurn("turn-reward-before-reflection-3", { @@ -308,6 +289,7 @@ describe("MemoryService / evolution / reward", () => { ).get(first.episodeId) as { count: number }; expect(queuedReflection.count).toBe(1); + await service.runWorkerOnce(20); await service.runWorkerOnce(20); const reflectedItems = service.panelItems({ userId: "user-reward-before-reflection", @@ -316,6 +298,34 @@ describe("MemoryService / evolution / reward", () => { expect(reflectedItems).toHaveLength(3); expect(reflectedItems.every((item) => item.metrics?.reflectionDone)).toBe(true); expect(calls.some((call) => call.options.operation === "capture.reflection.batch.v13")).toBe(true); + const rewardCalls = calls.filter((call) => call.options.operation === "reward.reward.r_human.v7"); + expect(rewardCalls).toHaveLength(1); + const rewardInput = JSON.parse( + rewardCalls[0]!.messages.find((message) => message.role === "user")!.content + ) as { + turnSummaries: string[]; + finalExchange: { user: string; assistant: string }; + feedbackHistory: Array<{ polarity: string }>; + }; + expect(rewardInput.turnSummaries).toHaveLength(3); + expect(rewardInput.finalExchange).toEqual({ + user: "哈密瓜和西瓜谁的营养价值更高", + assistant: "综合营养密度上哈密瓜通常更高一点。" + }); + expect(rewardInput.feedbackHistory).toEqual([ + expect.objectContaining({ polarity: "negative" }) + ]); + const rewarded = db.db.prepare( + `SELECT r_task, reward_detail_json + FROM episodes + WHERE id = ?` + ).get(first.episodeId) as { r_task: number | null; reward_detail_json: string }; + expect(typeof rewarded.r_task).toBe("number"); + expect(JSON.parse(rewarded.reward_detail_json)).toMatchObject({ + phase: "final", + traceCount: 3, + traceIds: [first.l1MemoryId, second.l1MemoryId, third.l1MemoryId] + }); db.close(); }); @@ -344,6 +354,7 @@ describe("MemoryService / evolution / reward", () => { }); service.closeSession(session.sessionId); + await service.runWorkerOnce(20); await service.runWorkerOnce(20); const memory = db.db.prepare( @@ -638,6 +649,12 @@ describe("MemoryService / evolution / reward", () => { magnitude: 1, rationale: "accepted, but process was only partial" }, + feedbackHistory: [{ + channel: "explicit", + polarity: "positive", + magnitude: 1, + rationale: "accepted, but process was only partial" + }], host: { agent: "codex" } @@ -732,23 +749,7 @@ describe("MemoryService / evolution / reward", () => { rationale: "wrong, use port 443 instead and verify TLS" }); - const rewardJob = feedback.jobs.find((job) => job.jobType === "reward"); - expect(rewardJob?.targetMemoryId).toBeUndefined(); - const rewardJobRow = db.db.prepare( - `SELECT episode_id, target_memory_id, payload_json - FROM evolution_jobs - WHERE id = ?` - ).get(rewardJob!.jobId) as { - episode_id: string | null; - target_memory_id: string | null; - payload_json: string; - }; - expect(rewardJobRow.episode_id).toBe(complete.episodeId); - expect(rewardJobRow.target_memory_id).toBeNull(); - expect(JSON.parse(rewardJobRow.payload_json)).toMatchObject({ - l1MemoryId: complete.l1MemoryId, - feedbackId: feedback.feedbackId - }); + expect(feedback.jobs.map((job) => job.jobType)).not.toContain("reward"); const feedbackRow = db.db.prepare( `SELECT l1_memory_id, raw_turn_id, episode_id, session_id FROM feedback @@ -774,6 +775,32 @@ describe("MemoryService / evolution / reward", () => { expect(JSON.parse(episodeIndexes.feedback_ids_json)).toContain(feedback.feedbackId); expect(JSON.parse(episodeIndexes.decision_repair_ids_json)).toContain(feedback.repair?.repairId); + const beforeClose = JSON.parse((db.db.prepare( + `SELECT properties_json FROM memories WHERE id = ?` + ).get(complete.l1MemoryId) as { properties_json: string }).properties_json) as { + internal_info: { trace: { r_human?: number } }; + }; + expect(beforeClose.internal_info.trace.r_human).toBeUndefined(); + + service.closeSession(session.sessionId); + await service.runWorkerOnce(50); + const rewardJobRow = db.db.prepare( + `SELECT episode_id, target_memory_id, payload_json + FROM evolution_jobs + WHERE job_type = 'reward' + AND episode_id = ?` + ).get(complete.episodeId) as { + episode_id: string | null; + target_memory_id: string | null; + payload_json: string; + }; + expect(rewardJobRow.episode_id).toBe(complete.episodeId); + expect(rewardJobRow.target_memory_id).toBeNull(); + expect(JSON.parse(rewardJobRow.payload_json)).toMatchObject({ + phase: "final", + l1MemoryId: complete.l1MemoryId, + feedbackId: feedback.feedbackId + }); await service.runWorkerOnce(50); const memory = db.db.prepare( diff --git a/Memory/tests/service/evolution/world-model.test.ts b/Memory/tests/service/evolution/world-model.test.ts index 6669b4168..15c3226d1 100644 --- a/Memory/tests/service/evolution/world-model.test.ts +++ b/Memory/tests/service/evolution/world-model.test.ts @@ -24,7 +24,14 @@ afterEach(cleanup); describe("MemoryService / evolution / world model", () => { it("merges L3 world models by policy overlap even when the domain key changes", async () => { - const { db, service } = createTestService({ skillLlm: createNoToolSkillLlm() }); + const calls: Array<{ + messages: Array<{ role: string; content: string }>; + options: { operation: string }; + }> = []; + const l3Response: Record = {}; + const { db, service } = createTestService({ + skillLlm: createNoToolSkillLlm(calls, l3Response) + }); const session = service.openSession({ namespace: { source: "codex", @@ -39,6 +46,24 @@ describe("MemoryService / evolution / world model", () => { query: "python pytest l3 overlap merge", answer: "Run pytest, inspect the failure, retry after fixing issue, then verify the result." }); + Object.assign(l3Response, { + title: "Pytest sqlite migration environment", + domain_tags: ["pytest", "sqlite"], + environment: [{ + label: "verified evidence", + description: "The environment is supported by a policy and its source trace.", + evidenceIds: [ + "policy_l3_policy_overlap", + complete.l1MemoryId, + "po_1", + "trace_missing" + ] + }], + inference: [], + constraints: [], + summary: "Pytest migration behavior is supported by verified evidence.", + confidence: 0.82 + }); insertActivePolicyMemory(db, { id: "policy_l3_policy_overlap", userId: "user-l3-policy-overlap", @@ -100,6 +125,12 @@ describe("MemoryService / evolution / world model", () => { domain_tags?: string[]; confidence?: number; body?: string; + structure?: { + environment?: Array<{ + label?: string; + evidenceIds?: string[]; + }>; + }; }; }; }; @@ -107,6 +138,15 @@ describe("MemoryService / evolution / world model", () => { expect(world.internal_info?.world_model?.domain_tags).toEqual(expect.arrayContaining(["legacy", "pytest", "sqlite"])); expect(world.internal_info?.world_model_confidence).toBeCloseTo(0.65); expect(world.internal_info?.world_model?.confidence).toBeCloseTo(0.65); + expect(world.internal_info?.world_model?.structure?.environment + ?.find((entry) => entry.label === "verified evidence")?.evidenceIds).toEqual([ + "policy_l3_policy_overlap", + complete.l1MemoryId + ]); + const l3Call = calls.find((call) => call.options.operation === "l3.abstraction.v3"); + expect(l3Call?.messages[0]?.content).toContain("Never abbreviate, rewrite, or invent an evidence ID"); + expect(l3Call?.messages[2]?.content).toContain("policy policy_l3_policy_overlap:"); + expect(l3Call?.messages[2]?.content).toContain(`trace ${complete.l1MemoryId}`); expect(worlds[0]!.memory_value).not.toContain("Merged policies:"); expect(world.internal_info?.body).not.toContain("Merged policies:"); expect(world.internal_info?.world_model?.body).not.toContain("Merged policies:"); @@ -507,12 +547,12 @@ describe("MemoryService / evolution / world model", () => { ); for (let i = 0; i < 20; i += 1) { await service.runWorkerOnce(100); - if (calls.some((call) => call.options.operation === "l3.abstraction.v2")) { + if (calls.some((call) => call.options.operation === "l3.abstraction.v3")) { break; } } - expect(calls.some((call) => call.options.operation === "l3.abstraction.v2")).toBe(true); + expect(calls.some((call) => call.options.operation === "l3.abstraction.v3")).toBe(true); const l3Count = db.db.prepare( `SELECT COUNT(*) AS count FROM memories diff --git a/Memory/tests/service/feedback/decision-repair-llm-stub.ts b/Memory/tests/service/feedback/decision-repair-llm-stub.ts index 0e5ddf87f..a413505fb 100644 --- a/Memory/tests/service/feedback/decision-repair-llm-stub.ts +++ b/Memory/tests/service/feedback/decision-repair-llm-stub.ts @@ -40,7 +40,7 @@ export function createDecisionRepairEvolutionLlm(): LlmClient { support_trace_ids: [] } as unknown as T; } - if (options.operation === "l3.abstraction.v2") { + if (options.operation === "l3.abstraction.v3") { return { title: "SQLite migration repair environment", domain_tags: ["sqlite", "migration"], diff --git a/Memory/tests/service/feedback/decision-repair.test.ts b/Memory/tests/service/feedback/decision-repair.test.ts index 5018a99de..9284241e9 100644 --- a/Memory/tests/service/feedback/decision-repair.test.ts +++ b/Memory/tests/service/feedback/decision-repair.test.ts @@ -118,6 +118,7 @@ describe("MemoryService / feedback / decision repair", () => { }); makeTraceEligibleForL2(db, complete.l1MemoryId); } + service.closeSession(session.sessionId); for (let i = 0; i < 20; i += 1) { await service.runWorkerOnce(100); } @@ -726,6 +727,9 @@ describe("MemoryService / feedback / decision repair", () => { magnitude: 1, rationale: "wrong, do not repeat the SQL query before inspecting the migration output" }); + service.closeSession(negativeSession.sessionId); + await service.runWorkerOnce(100); + await service.runWorkerOnce(100); await service.runWorkerOnce(100); const repair = db.db.prepare( @@ -778,12 +782,6 @@ describe("MemoryService / feedback / decision repair", () => { kind: "repair", op: "created" }); - expect(service.panelJobs({ - userId: negativeUserId, - status: "queued" - }).items).toEqual(expect.arrayContaining([ - expect.objectContaining({ jobType: "negative_experience" }) - ])); await service.runWorkerOnce(100); const policies = service.panelItems({ userId: negativeUserId, diff --git a/Memory/tests/service/feedback/experience.test.ts b/Memory/tests/service/feedback/experience.test.ts index 4f94895da..07a53324a 100644 --- a/Memory/tests/service/feedback/experience.test.ts +++ b/Memory/tests/service/feedback/experience.test.ts @@ -152,10 +152,12 @@ describe("MemoryService / feedback / experience", () => { expect(beforeWorker).toHaveLength(1); expect(beforeWorker[0]!.id).toBe(created[0]!.id); expect(avoid.jobs.map((job) => job.jobType)).not.toContain("negative_experience"); - expect(avoid.jobs.map((job) => job.jobType)).toContain("reward"); + expect(avoid.jobs.map((job) => job.jobType)).not.toContain("reward"); expect(avoid.jobs.map((job) => job.jobType)).not.toContain("l3_abstraction"); expect(avoid.jobs.map((job) => job.jobType)).not.toContain("skill_crystallization"); + service.closeSession(session.sessionId); + await service.runWorkerOnce(100); await service.runWorkerOnce(100); await service.runWorkerOnce(100); @@ -194,7 +196,7 @@ describe("MemoryService / feedback / experience", () => { evidence_polarity?: string; skill_eligible?: boolean; source_feedback_ids?: string[]; - decision_guidance?: { anti_pattern?: string[] }; + decision_guidance?: { preference?: string[]; anti_pattern?: string[] }; }; }; }).internal_info.policy; @@ -203,7 +205,8 @@ describe("MemoryService / feedback / experience", () => { expect(negativePolicy.evidence_polarity).toBe("negative"); expect(negativePolicy.skill_eligible).toBe(false); expect(negativePolicy.source_feedback_ids).toEqual([avoid.feedbackId]); - expect(negativePolicy.decision_guidance?.anti_pattern?.join("\n")).toContain("filename"); + expect(negativePolicy.decision_guidance?.anti_pattern?.join("\n")).toContain("validated the issuer field"); + expect(negativePolicy.decision_guidance?.preference?.join("\n")).toContain("filename"); db.close(); }); @@ -267,7 +270,9 @@ describe("MemoryService / feedback / experience", () => { expect(calls.find((call) => call.options.operation === "failure.experience.sink.v5")).toBeUndefined(); expect(feedbackResponse.jobs.map((job) => job.jobType)).not.toContain("negative_experience"); - expect(feedbackResponse.jobs.map((job) => job.jobType)).toContain("reward"); + expect(feedbackResponse.jobs.map((job) => job.jobType)).not.toContain("reward"); + service.closeSession(session.sessionId); + await service.runWorkerOnce(100); await service.runWorkerOnce(100); await service.runWorkerOnce(100); @@ -287,6 +292,7 @@ describe("MemoryService / feedback / experience", () => { verification?: string; decision_guidance?: { anti_pattern?: string[] }; policy_confidence?: number; + evidence_strength?: number; }; }; }).internal_info.policy; @@ -294,7 +300,8 @@ describe("MemoryService / feedback / experience", () => { expect(policy.procedure).toContain("filename"); expect(policy.verification).toContain("historical failure mode"); expect(policy.decision_guidance?.anti_pattern?.join("\n")).toContain("filename"); - expect(policy.policy_confidence).toBeGreaterThanOrEqual(0.91); + expect(policy.policy_confidence).toBeGreaterThanOrEqual(0.6); + expect(policy.evidence_strength).toBe(1); const skillRow = db.db.prepare( `SELECT id, properties_json diff --git a/Memory/tests/service/import/import-processing.test.ts b/Memory/tests/service/import/import-processing.test.ts index 621d0a783..245e08752 100644 --- a/Memory/tests/service/import/import-processing.test.ts +++ b/Memory/tests/service/import/import-processing.test.ts @@ -1033,14 +1033,28 @@ describe("MemoryService / import / processing", () => { userId: "user-import-order" }; - const older = addAgentSourceImport(service, namespace, "older memory query", "order-old"); - const newer = addAgentSourceImport(service, namespace, "newer memory query", "order-new"); - db.db.prepare(`UPDATE memories SET updated_at = ? WHERE id = ?`).run("2026-06-10T10:00:00.000Z", older.id); + const older = addAgentSourceImport( + service, + namespace, + "older memory query", + "order-old", + "2026-06-10T10:00:00.000Z" + ); + const newer = addAgentSourceImport( + service, + namespace, + "newer memory query", + "order-new", + "2026-06-10T12:00:00.000Z" + ); + db.db.prepare(`UPDATE memories SET updated_at = ? WHERE id = ?`).run("2026-06-10T13:00:00.000Z", older.id); db.db.prepare(`UPDATE memories SET updated_at = ? WHERE id = ?`).run("2026-06-10T12:00:00.000Z", newer.id); - const run = await service.runWorkerOnce(10); + const summaryRun = await service.runWorkerOnce(10); + const embeddingRun = await service.runWorkerOnce(10); - expect(run.jobs.map((job) => job.targetMemoryId)).toEqual([newer.id, older.id]); + expect(summaryRun.jobs.map((job) => job.targetMemoryId)).toEqual([newer.id, older.id]); + expect(embeddingRun.jobs.map((job) => job.targetMemoryId)).toEqual([newer.id, older.id]); expect(llmCalls[0]?.messages.find((message) => message.role === "user")?.content).toContain("newer memory query"); db.close(); @@ -1069,7 +1083,13 @@ describe("MemoryService / import / processing", () => { }; for (let index = 0; index < 25; index += 1) { - addAgentSourceImport(service, namespace, `imported query ${index}`, `interleave-${index}`); + addAgentSourceImport( + service, + namespace, + `imported query ${index}`, + `interleave-${index}`, + new Date(Date.UTC(2026, 5, 10, 10, index)).toISOString() + ); } const summaryRun = await service.runWorkerOnce(20); @@ -1165,8 +1185,20 @@ describe("MemoryService / import / processing", () => { userId: "user-import-placeholder-order" }; - const older = addAgentSourceImport(service, namespace, "older user query", "placeholder-old"); - const newer = addAgentSourceImport(service, namespace, "newer assistant placeholder query", "placeholder-new"); + const older = addAgentSourceImport( + service, + namespace, + "older user query", + "placeholder-old", + "2026-06-10T10:00:00.000Z" + ); + const newer = addAgentSourceImport( + service, + namespace, + "newer assistant placeholder query", + "placeholder-new", + "2026-06-10T12:00:00.000Z" + ); db.db.prepare(`DELETE FROM evolution_jobs WHERE target_memory_id IN (?, ?)`).run(older.id, newer.id); db.db.prepare(`UPDATE memories SET updated_at = ?, info_json = json_set(info_json, '$.summary', ?) WHERE id = ?`) .run("2026-06-10T10:00:00.000Z", "## user", older.id); @@ -1204,6 +1236,68 @@ describe("MemoryService / import / processing", () => { db.close(); }); + it("finishes a new Memmy chat memory before draining scanned-memory backlog", async () => { + const root = createTestRoot("mindock-memory-live-priority-"); + const db = new MemoryDb({ + path: join(root, "memory.sqlite") + }); + const llmCalls: Array<{ + messages: Array<{ role: string; content: string }>; + options: { operation: string }; + }> = []; + const embeddingTexts: string[] = []; + const service = createTestMemoryService({ + db, + mode: "dev", + llm: createBatchReflectionLlm(llmCalls), + embedder: createCapturingEmbedder(embeddingTexts) + }); + const namespace = { + source: "memmy", + profileId: "jiang", + userId: "user-live-priority" + }; + + const oldImport = addAgentSourceImport( + service, + namespace, + "old scanned memory", + "live-priority-old", + "2026-06-10T10:00:00.000Z" + ); + const recentImport = addAgentSourceImport( + service, + namespace, + "recent scanned memory", + "live-priority-recent", + "2026-06-10T12:00:00.000Z" + ); + const session = service.openSession({ namespace }); + const live = service.completeTurn("turn-live-priority", { + sessionId: session.sessionId, + query: "Remember the new interactive preference.", + answer: "The new interactive preference is dark mode." + }); + + const summaryRun = await service.runWorkerOnce(4, { priorityCohortOnly: true }); + const embeddingRun = await service.runWorkerOnce(4, { priorityCohortOnly: true }); + const scanRun = await service.runWorkerOnce(4, { priorityCohortOnly: true }); + + expect(summaryRun.jobs).toEqual([ + expect.objectContaining({ jobType: "trace_summary", targetMemoryId: live.l1MemoryId }) + ]); + expect(embeddingRun.jobs).toEqual([ + expect.objectContaining({ jobType: "embedding", targetMemoryId: live.l1MemoryId }) + ]); + expect(scanRun.jobs.map((job) => job.targetMemoryId)).toEqual([recentImport.id, oldImport.id]); + expect(scanRun.jobs.every((job) => job.jobType === "import_summary")).toBe(true); + expect(llmCalls[0]?.messages.find((message) => message.role === "user")?.content) + .toContain("new interactive preference"); + expect(embeddingTexts).toHaveLength(1); + + db.close(); + }); + it("guards imported trace embedding until a real summary job has run", async () => { const root = createTestRoot("mindock-memory-import-embedding-guard-"); const db = new MemoryDb({ diff --git a/Memory/tests/service/retrieval/injected-context.test.ts b/Memory/tests/service/retrieval/injected-context.test.ts index 219ac90b1..dc6948d18 100644 --- a/Memory/tests/service/retrieval/injected-context.test.ts +++ b/Memory/tests/service/retrieval/injected-context.test.ts @@ -375,7 +375,8 @@ describe("MemoryService / retrieval / injected context", () => { query: "fix sqlite budget migration", answer: "The sqlite budget migration is fixed." }); - expect(completed.episodeId).toBe(prepared.episodeId); + expect(prepared).not.toHaveProperty("episodeId"); + expect(completed.episodeId).toMatch(/^episode_/u); const rawTurn = db.db.prepare( "SELECT source_memory_ids_json, message_payload_json FROM raw_turns WHERE id = ?" ).get(completed.rawTurnId) as { @@ -787,7 +788,7 @@ describe("MemoryService / retrieval / injected context", () => { }); expect(unknown.status).not.toContain("intent:chitchat:retrieval_skipped"); - expect(db.db.prepare("SELECT COUNT(*) AS count FROM episodes").get()).toEqual({ count: 3 }); + expect(db.db.prepare("SELECT COUNT(*) AS count FROM episodes").get()).toEqual({ count: 0 }); expect(db.db.prepare("SELECT COUNT(*) AS count FROM recall_events").get()).toEqual({ count: 3 }); expect(db.db.prepare( "SELECT tool_name, COUNT(*) AS count FROM api_logs GROUP BY tool_name" diff --git a/Memory/tests/service/session/episode-relation.test.ts b/Memory/tests/service/session/episode-relation.test.ts index dc00cbc4e..4f00864ff 100644 --- a/Memory/tests/service/session/episode-relation.test.ts +++ b/Memory/tests/service/session/episode-relation.test.ts @@ -5,6 +5,7 @@ import { MemoryDb, type LlmClient } from "../../../src/index.js"; +import { Repositories } from "../../../src/storage/repositories.js"; import { accountRuntimeConfig, createCapturingEmbedder, @@ -147,13 +148,10 @@ describe("MemoryService / session / episode relation", () => { query: "结束会话" }); - expect(prepared).toMatchObject({ - episodeId: first.episodeId, - closedEpisodeIds: [], - hits: [], - sourceMemoryIds: [] - }); - expect(prepared.status).toContain("relation:end_topic"); + expect(prepared).toMatchObject({ hits: [], sourceMemoryIds: [] }); + expect(prepared).not.toHaveProperty("episodeId"); + expect(prepared).not.toHaveProperty("closedEpisodeIds"); + expect(prepared.status).toContain("relation:end_topic:proposed"); expect(relationCalls).toEqual([]); expect(service.getMemory(first.episodeId)).toMatchObject({ kind: "episode", @@ -220,7 +218,7 @@ describe("MemoryService / session / episode relation", () => { sessionId: session.sessionId, query }); - expect(prepared.status).not.toContain("relation:end_topic"); + expect(prepared.status).not.toContain("relation:end_topic:proposed"); } const prepared = await service.startTurn({ @@ -228,7 +226,7 @@ describe("MemoryService / session / episode relation", () => { sessionId: session.sessionId, query: "不聊了!" }); - expect(prepared.status).toContain("relation:end_topic"); + expect(prepared.status).toContain("relation:end_topic:proposed"); const completed = service.completeTurn("turn-explicit-end-topic-close", { sessionId: session.sessionId, @@ -239,6 +237,48 @@ describe("MemoryService / session / episode relation", () => { expect(completed.l1MemoryIds).toEqual([]); }); + it("commits an LLM end-topic proposal without capturing the control turn as L1", async () => { + const relationCalls: string[] = []; + const { service } = createTestService({ + llm: createRelationClassifierLlm(relationCalls, undefined, "end_topic") + }); + const session = service.openSession({ + namespace: { + source: "codex", + profileId: "jiang", + userId: "user-llm-end-topic" + } + }); + const first = service.completeTurn("turn-llm-end-topic-first", { + sessionId: session.sessionId, + query: "Configure nginx TLS", + answer: "Use port 443." + }); + const started = await service.startTurn({ + turnId: "turn-llm-end-topic-close", + sessionId: session.sessionId, + query: "That covers everything for this topic" + }); + expect(started.status).toContain("relation:end_topic:proposed"); + expect(relationCalls).toContain("relation.classify.v1"); + expect(service.getMemory(first.episodeId)).toMatchObject({ + kind: "episode", + status: "open" + }); + + const completed = service.completeTurn("turn-llm-end-topic-close", { + sessionId: session.sessionId, + query: "That covers everything for this topic", + answer: "Understood." + }); + expect(completed.closedEpisodeIds).toEqual([first.episodeId]); + expect(completed.l1MemoryIds).toEqual([]); + expect(service.getMemory(first.episodeId)).toMatchObject({ + kind: "episode", + status: "closed" + }); + }); + it("keeps end-topic start and complete retries idempotent", async () => { const relationCalls: string[] = []; const { service } = createTestService({ @@ -265,11 +305,12 @@ describe("MemoryService / session / episode relation", () => { const firstStart = await service.startTurn(request); const secondStart = await service.startTurn(request); - expect(firstStart.episodeId).toBe(secondStart.episodeId); - expect(firstStart.closedEpisodeIds).toEqual([]); - expect(secondStart.closedEpisodeIds).toEqual([]); - expect(firstStart.status).toContain("relation:end_topic"); - expect(secondStart.status).toContain("relation:end_topic"); + expect(firstStart).not.toHaveProperty("episodeId"); + expect(secondStart).not.toHaveProperty("episodeId"); + expect(firstStart).not.toHaveProperty("closedEpisodeIds"); + expect(secondStart).not.toHaveProperty("closedEpisodeIds"); + expect(firstStart.status).toContain("relation:end_topic:proposed"); + expect(secondStart.status).toContain("relation:end_topic:proposed"); expect(relationCalls).toEqual([]); const completeRequest = { @@ -282,11 +323,13 @@ describe("MemoryService / session / episode relation", () => { expect(secondComplete.closedEpisodeIds).toEqual(firstComplete.closedEpisodeIds); expect(secondComplete.jobs).toEqual([]); + expect(secondComplete.scheduledEvolution).toBe(false); + expect(secondComplete.duplicate).toBe(true); }); it("does not reopen an episode after an explicit end-topic boundary", async () => { const { service } = createTestService({ - llm: createRelationClassifierLlm([], undefined, ["end_topic", "follow_up"]) + llm: createRelationClassifierLlm([], undefined, "follow_up") }); const session = service.openSession({ namespace: { @@ -322,7 +365,7 @@ describe("MemoryService / session / episode relation", () => { answer: "可以使用 certbot 自动续期。" }); - expect(nextStart.episodeId).toBe(next.episodeId); + expect(nextStart).not.toHaveProperty("episodeId"); expect(next.episodeId).not.toBe(first.episodeId); expect(service.getMemory(first.episodeId)).toMatchObject({ kind: "episode", @@ -337,7 +380,7 @@ describe("MemoryService / session / episode relation", () => { it("binds a following turn after the explicit end-topic completion", async () => { const relationCalls: string[] = []; const { service } = createTestService({ - llm: createRelationClassifierLlm(relationCalls, undefined, ["end_topic", "follow_up"]) + llm: createRelationClassifierLlm(relationCalls, undefined, "follow_up") }); const session = service.openSession({ namespace: { @@ -363,7 +406,11 @@ describe("MemoryService / session / episode relation", () => { query: "继续说明证书续期" }); - expect(nextStart.episodeId).not.toBe(first.episodeId); + expect(nextStart).not.toHaveProperty("episodeId"); + expect(service.getMemory(first.episodeId)).toMatchObject({ + kind: "episode", + status: "open" + }); expect(relationCalls).toEqual(["relation.classify.v1"]); service.completeTurn("turn-pending-end-topic-close", { @@ -401,22 +448,34 @@ describe("MemoryService / session / episode relation", () => { query: "Configure nginx TLS for the service", answer: "Use port 443, install the certificate, and verify with curl." }); + const jobsBeforeStart = (db.db.prepare( + "SELECT COUNT(*) AS count FROM evolution_jobs" + ).get() as { count: number }).count; const prepared = await service.startTurn({ turnId: "turn-relation-new-task", sessionId: session.sessionId, query: "new task: summarize the Q4 hiring plan" }); - expect(prepared.episodeId).not.toBe(first.episodeId); - expect(prepared.closedEpisodeIds).toEqual([first.episodeId]); + expect(prepared).not.toHaveProperty("episodeId"); + expect(prepared).not.toHaveProperty("closedEpisodeIds"); expect(db.db.prepare( "SELECT COUNT(*) AS count FROM episodes WHERE session_id = ?" - ).get(session.sessionId)).toEqual({ count: 2 }); + ).get(session.sessionId)).toEqual({ count: 1 }); + expect(service.getMemory(first.episodeId)).toMatchObject({ + kind: "episode", + status: "open" + }); + expect(db.db.prepare( + "SELECT COUNT(*) AS count FROM evolution_jobs" + ).get()).toEqual({ count: jobsBeforeStart }); const completed = service.completeTurn("turn-relation-new-task", { sessionId: session.sessionId, query: "new task: summarize the Q4 hiring plan", answer: "The Q4 hiring plan has been summarized." }); - expect(completed.episodeId).toBe(prepared.episodeId); + expect(completed.episodeId).not.toBe(first.episodeId); + expect(completed.closedEpisodeIds).toEqual([first.episodeId]); + expect(completed.jobs.map((job) => job.jobType)).toContain("reflection"); const rows = db.db.prepare( `SELECT id, status, meta_json @@ -441,6 +500,134 @@ describe("MemoryService / session / episode relation", () => { db.close(); }); + it("ignores an uncompleted new-task proposal when routing the next completed turn", async () => { + const { db, service } = createTestService({ + llm: createRelationClassifierLlm([], undefined, "follow_up") + }); + const session = service.openSession({ + namespace: { + source: "codex", + profileId: "jiang", + userId: "user-cancelled-route-proposal" + } + }); + const first = service.completeTurn("turn-cancelled-proposal-first", { + sessionId: session.sessionId, + query: "Configure nginx TLS", + answer: "Use port 443." + }); + + await service.startTurn({ + turnId: "turn-cancelled-proposal", + sessionId: session.sessionId, + query: "换个任务:总结招聘计划" + }); + const nextStart = await service.startTurn({ + turnId: "turn-after-cancelled-proposal", + sessionId: session.sessionId, + query: "那证书自动续期呢" + }); + + expect(nextStart).not.toHaveProperty("episodeId"); + expect(db.db.prepare( + "SELECT COUNT(*) AS count FROM episodes WHERE session_id = ?" + ).get(session.sessionId)).toEqual({ count: 1 }); + expect(db.db.prepare( + "SELECT COUNT(*) AS count FROM raw_turns WHERE session_id = ? AND turn_id = ?" + ).get(session.sessionId, "turn-cancelled-proposal")).toEqual({ count: 0 }); + + const completed = service.completeTurn("turn-after-cancelled-proposal", { + sessionId: session.sessionId, + query: "那证书自动续期呢", + answer: "Use certbot renewal hooks." + }); + expect(completed.episodeId).toBe(first.episodeId); + expect(completed.closedEpisodeIds).toEqual([]); + db.close(); + }); + + it("reclassifies a stale route proposal and records the stale marker", async () => { + const { db, service } = createTestService(); + const session = service.openSession({ + namespace: { + source: "codex", + profileId: "jiang", + userId: "user-stale-route-proposal" + } + }); + const first = service.completeTurn("turn-stale-first", { + sessionId: session.sessionId, + query: "Configure nginx TLS", + answer: "Use port 443." + }); + await service.startTurn({ + turnId: "turn-stale-proposed", + sessionId: session.sessionId, + query: "new task: summarize the hiring plan" + }); + const intervening = service.completeTurn("turn-stale-intervening", { + sessionId: session.sessionId, + query: "new task: audit database backups", + answer: "The database backup audit is complete." + }); + expect(intervening.episodeId).not.toBe(first.episodeId); + + const completed = service.completeTurn("turn-stale-proposed", { + sessionId: session.sessionId, + query: "new task: summarize the hiring plan", + answer: "The hiring plan is summarized." + }); + expect(completed.episodeId).not.toBe(intervening.episodeId); + expect(completed.closedEpisodeIds).toEqual([intervening.episodeId]); + const raw = db.db.prepare( + "SELECT message_payload_json FROM raw_turns WHERE id = ?" + ).get(completed.rawTurnId) as { message_payload_json: string }; + expect(JSON.parse(raw.message_payload_json)).toMatchObject({ + turn_start: { + routeProposalStale: true, + routeProposal: { + baseEpisodeId: first.episodeId, + action: "split" + } + } + }); + db.close(); + }); + + it("honors an explicit episode id over a conflicting start proposal", async () => { + const { db, service } = createTestService(); + const session = service.openSession({ + namespace: { + source: "codex", + profileId: "jiang", + userId: "user-explicit-complete-episode" + } + }); + const first = service.completeTurn("turn-explicit-episode-first", { + sessionId: session.sessionId, + query: "Configure nginx TLS", + answer: "Use port 443." + }); + await service.startTurn({ + turnId: "turn-explicit-episode", + sessionId: session.sessionId, + query: "new task: summarize the hiring plan" + }); + + const completed = service.completeTurn("turn-explicit-episode", { + sessionId: session.sessionId, + episodeId: first.episodeId, + query: "new task: summarize the hiring plan", + answer: "The hiring plan is summarized." + }); + expect(completed.episodeId).toBe(first.episodeId); + expect(completed.closedEpisodeIds).toEqual([]); + expect(db.db.prepare( + "SELECT COUNT(*) AS count FROM episodes WHERE session_id = ?" + ).get(session.sessionId)).toEqual({ count: 1 }); + db.close(); + }); + it("keeps follow-up turns in the same episode", async () => { const { db, service } = createTestService(); const session = service.openSession({ @@ -460,8 +647,8 @@ describe("MemoryService / session / episode relation", () => { sessionId: session.sessionId, query: "那证书自动续期呢" }); - expect(prepared.episodeId).toBe(first.episodeId); - expect(prepared.closedEpisodeIds).toEqual([]); + expect(prepared).not.toHaveProperty("episodeId"); + expect(prepared).not.toHaveProperty("closedEpisodeIds"); const rows = db.db.prepare( `SELECT id, status, meta_json @@ -471,9 +658,7 @@ describe("MemoryService / session / episode relation", () => { ).all(session.sessionId) as Array<{ id: string; status: string; meta_json: string }>; expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ id: first.episodeId, status: "open" }); - expect(JSON.parse(rows[0]!.meta_json)).toMatchObject({ - relation: "follow_up" - }); + expect(JSON.parse(rows[0]!.meta_json)).not.toHaveProperty("relation"); const completed = service.completeTurn("turn-relation-follow-up-next", { sessionId: session.sessionId, @@ -543,7 +728,7 @@ describe("MemoryService / session / episode relation", () => { db.close(); }); - it("reserves a started raw turn in the selected episode and completes that same turn", async () => { + it("does not reserve a raw turn until completion commits the proposed episode", async () => { const root = createTestRoot("mindock-memory-turn-bind-"); const db = new MemoryDb({ path: join(root, "memory.sqlite") @@ -572,18 +757,14 @@ describe("MemoryService / session / episode relation", () => { sessionId: session.sessionId, query: "青竹项目的部署端口是多少?林浩偏好什么回答风格?" }); - expect(prepared.episodeId).toBe(first.episodeId); + expect(prepared).not.toHaveProperty("episodeId"); expect(relationCalls).toEqual(["relation.classify.v1"]); const reserved = db.db.prepare( `SELECT id, episode_id, status FROM raw_turns WHERE session_id = ? AND turn_id = ?` ).get(session.sessionId, "turn-bind-second") as { id: string; episode_id: string; status: string } | undefined; - expect(reserved).toEqual({ - id: expect.stringMatching(/^raw_/u), - episode_id: prepared.episodeId, - status: "started" - }); + expect(reserved).toBeUndefined(); const completed = service.completeTurn("turn-bind-second", { sessionId: session.sessionId, @@ -592,7 +773,7 @@ describe("MemoryService / session / episode relation", () => { }); expect(completed.episodeId).toBe(first.episodeId); - expect(completed.rawTurnId).toBe(reserved?.id); + expect(completed.rawTurnId).toMatch(/^raw_/u); const episodes = db.db.prepare( `SELECT id, turn_count, raw_turn_ids_json FROM episodes @@ -700,8 +881,21 @@ describe("MemoryService / session / episode relation", () => { query: "Database certificate rotation details please" }); - expect(prepared.episodeId).toBe(first.episodeId); + expect(prepared).not.toHaveProperty("episodeId"); expect(calls).toEqual(["relation.classify.v1", "relation.arbitration.v1"]); + const beforeComplete = db.db.prepare( + `SELECT meta_json + FROM episodes + WHERE session_id = ?` + ).get(session.sessionId) as { meta_json: string }; + expect(JSON.parse(beforeComplete.meta_json)).not.toHaveProperty("relationDecision"); + + const completed = service.completeTurn("turn-relation-llm-next", { + sessionId: session.sessionId, + query: "Database certificate rotation details please", + answer: "Rotate the certificate and reload the database client." + }); + expect(completed.episodeId).toBe(first.episodeId); const rows = db.db.prepare( `SELECT meta_json FROM episodes @@ -762,7 +956,7 @@ describe("MemoryService / session / episode relation", () => { db.close(); }); - it("turns revision relation messages into structured feedback and reward backprop", async () => { + it("records revision feedback immediately but defers reward backprop until episode close", async () => { const { db, service } = createTestService(); const session = service.openSession({ namespace: { @@ -781,16 +975,19 @@ describe("MemoryService / session / episode relation", () => { sessionId: session.sessionId, query: "wrong, use port 443 instead and verify TLS" }); - expect(prepared.episodeId).toBe(first.episodeId); + expect(prepared).not.toHaveProperty("episodeId"); expect(db.db.prepare( "SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-relation-revision'" - ).get()).toEqual({ count: 1 }); + ).get()).toEqual({ count: 0 }); const correction = service.completeTurn("turn-relation-revision-fix", { sessionId: session.sessionId, query: "wrong, use port 443 instead and verify TLS", answer: "Corrected: use port 443 and verify TLS." }); expect(correction.episodeId).toBe(first.episodeId); + expect(db.db.prepare( + "SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-relation-revision'" + ).get()).toEqual({ count: 1 }); const feedback = db.db.prepare( `SELECT id, l1_memory_id, raw_turn_id, polarity, raw_payload_json @@ -843,8 +1040,28 @@ describe("MemoryService / session / episode relation", () => { change_type: "decision_repair_created" }); - await service.runWorkerOnce(50); + const openMemory = db.db.prepare( + `SELECT properties_json + FROM memories + WHERE id = ?` + ).get(first.l1MemoryId) as { properties_json: string }; + const openTrace = (JSON.parse(openMemory.properties_json) as { + internal_info: { + trace: { + r_human?: number; + source_feedback_ids?: string[]; + }; + }; + }).internal_info.trace; + expect(openTrace.r_human).toBeUndefined(); + expect(db.db.prepare( + `SELECT COUNT(*) AS count + FROM evolution_jobs + WHERE episode_id = ? AND job_type = 'reward'` + ).get(first.episodeId)).toEqual({ count: 0 }); + service.closeSession(session.sessionId); + await runWorkerRounds(service, 2, 50); const memory = db.db.prepare( `SELECT properties_json FROM memories @@ -864,6 +1081,59 @@ describe("MemoryService / session / episode relation", () => { db.close(); }); + it("clears a stale final reward when a closed episode is reopened", async () => { + const { db, service } = createTestService(); + const session = service.openSession({ + namespace: { + source: "codex", + profileId: "jiang", + userId: "user-reopen-stale-reward" + } + }); + const first = service.completeTurn("turn-reopen-stale-reward-first", { + sessionId: session.sessionId, + query: "Configure nginx TLS for the service", + answer: "Use port 80 and skip certificate verification." + }); + const repos = new Repositories(db.db); + const rewardDetail = { + phase: "final", + rHuman: -0.25, + traceIds: [first.l1MemoryId] + }; + repos.runtime.updateEpisodeReward(first.episodeId, { + rTask: -0.25, + rewardDetail, + metaPatch: { reward: rewardDetail } + }); + repos.runtime.closeEpisode(first.episodeId, { closeReason: "idle_timeout" }); + + await service.startTurn({ + turnId: "turn-reopen-stale-reward-fix", + sessionId: session.sessionId, + query: "wrong, use port 443 instead and verify TLS" + }); + const correction = service.completeTurn("turn-reopen-stale-reward-fix", { + sessionId: session.sessionId, + query: "wrong, use port 443 instead and verify TLS", + answer: "Corrected: use port 443 and verify TLS." + }); + + expect(correction.episodeId).toBe(first.episodeId); + expect(repos.runtime.getEpisode(first.episodeId)).toMatchObject({ + status: "open", + rTask: undefined, + rewardDetail: {}, + meta: { + rewardDirty: { + reason: "episode_reopened" + } + } + }); + expect(repos.runtime.getEpisode(first.episodeId)?.meta).not.toHaveProperty("reward"); + db.close(); + }); + it("records plugin-style implicit turn feedback before opening the next episode", async () => { const { db, service } = createTestService(); const session = service.openSession({ @@ -883,8 +1153,15 @@ describe("MemoryService / session / episode relation", () => { sessionId: session.sessionId, query: "不对,应该用递归实现,这样性能不好。换个任务:实现二叉树层序遍历" }); - expect(prepared.episodeId).not.toBe(first.episodeId); - expect(prepared.closedEpisodeIds).toEqual([first.episodeId]); + expect(prepared).not.toHaveProperty("episodeId"); + expect(prepared).not.toHaveProperty("closedEpisodeIds"); + expect(db.db.prepare( + "SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-implicit-turn-feedback'" + ).get()).toEqual({ count: 0 }); + expect(service.getMemory(first.episodeId)).toMatchObject({ + kind: "episode", + status: "open" + }); const correction = service.completeTurn("turn-implicit-feedback-correction", { sessionId: session.sessionId, query: "不对,应该用递归实现,这样性能不好。换个任务:实现二叉树层序遍历", @@ -918,6 +1195,15 @@ describe("MemoryService / session / episode relation", () => { classifierPolarity: "negative" }); + const rewardBeforeReflection = db.db.prepare( + `SELECT COUNT(*) AS count + FROM evolution_jobs + WHERE job_type = 'reward' + AND json_extract(payload_json, '$.feedbackId') = ?` + ).get(feedback.id) as { count: number }; + expect(rewardBeforeReflection.count).toBe(0); + + await service.runWorkerOnce(20); const queuedReward = db.db.prepare( `SELECT payload_json FROM evolution_jobs @@ -927,7 +1213,8 @@ describe("MemoryService / session / episode relation", () => { expect(JSON.parse(queuedReward!.payload_json)).toMatchObject({ feedbackId: feedback.id, l1MemoryId: first.l1MemoryId, - trigger: "implicit_turn_feedback" + phase: "final", + trigger: "implicit_fallback" }); await runWorkerRounds(service, 2, 20); diff --git a/Memory/tests/service/session/idle-sweep.test.ts b/Memory/tests/service/session/idle-sweep.test.ts index bbb7eced5..58450705a 100644 --- a/Memory/tests/service/session/idle-sweep.test.ts +++ b/Memory/tests/service/session/idle-sweep.test.ts @@ -206,19 +206,16 @@ describe("MemoryService / session / idle sweep", () => { sessionId: longSession.sessionId, query: "Run a long deployment verification" }); - expect(started.episodeId).toMatch(/^episode_/u); + expect(started).not.toHaveProperty("episodeId"); expect(db.db.prepare( "SELECT episode_id, status FROM raw_turns WHERE session_id = ? AND turn_id = ?" - ).get(longSession.sessionId, "turn-long-running")).toEqual({ - episode_id: started.episodeId, - status: "started" - }); + ).get(longSession.sessionId, "turn-long-running")).toBeUndefined(); const completed = service.completeTurn("turn-long-running", { sessionId: longSession.sessionId, query: "Run a long deployment verification", answer: "The long deployment verification completed." }); - expect(completed.episodeId).toBe(started.episodeId); + expect(completed.episodeId).toMatch(/^episode_/u); db.db.prepare( `UPDATE episodes SET updated_at = ? @@ -264,7 +261,7 @@ describe("MemoryService / session / idle sweep", () => { sessionId: activeSession.sessionId, query: "Run a long tool-driven deployment" }); - expect(started.episodeId).toMatch(/^episode_/u); + expect(started).not.toHaveProperty("episodeId"); await service.observeTool({ sessionId: activeSession.sessionId, turnId: "turn-active-tool", @@ -278,7 +275,7 @@ describe("MemoryService / session / idle sweep", () => { WHERE session_id = ? AND turn_id = ?` ).get(activeSession.sessionId, "turn-active-tool") as { id: string; episode_id: string }; - expect(rawTurn.episode_id).toBe(started.episodeId); + expect(rawTurn.episode_id).toMatch(/^episode_/u); const oldAt = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); setRawTurnActivityAt(db, rawTurn.id, oldAt); await service.observeTool({ diff --git a/Memory/tests/service/session/turn-capture.test.ts b/Memory/tests/service/session/turn-capture.test.ts index 832505212..dc621de28 100644 --- a/Memory/tests/service/session/turn-capture.test.ts +++ b/Memory/tests/service/session/turn-capture.test.ts @@ -17,7 +17,7 @@ const { afterEach(cleanup); describe("MemoryService / session / turn capture", () => { - it("records a started RawTurn at turn.start and creates L1 only after turn.complete", async () => { + it("records only recall audit at turn.start and commits episode, RawTurn, and L1 at turn.complete", async () => { const { db, service } = createTestService(); const session = service.openSession({ namespace: { @@ -45,40 +45,26 @@ describe("MemoryService / session / turn capture", () => { }); expect(started.turnId).toBe("turn-start-readonly"); - expect(started.episodeId).toMatch(/^episode_/u); - expect(started.closedEpisodeIds).toEqual([]); + expect(started).not.toHaveProperty("episodeId"); + expect(started).not.toHaveProperty("closedEpisodeIds"); expect(counts()).toEqual({ ...before, - episodes: before.episodes + 1, - rawTurns: before.rawTurns + 1, recalls: before.recalls + 1, apiLogs: before.apiLogs + 1 }); - const startedRawTurn = db.db.prepare( - `SELECT episode_id, user_text, assistant_text, source_memory_ids_json, - message_payload_json, status - FROM raw_turns - WHERE session_id = ? AND turn_id = ?` - ).get(session.sessionId, started.turnId) as { - episode_id: string; - user_text: string; - assistant_text: string | null; - source_memory_ids_json: string; - message_payload_json: string; - status: string; - }; - expect(startedRawTurn).toMatchObject({ - episode_id: started.episodeId, - user_text: "Do not create L1 until the assistant finishes.", - assistant_text: null, - status: "started" - }); - expect(JSON.parse(startedRawTurn.source_memory_ids_json)).toEqual(started.sourceMemoryIds); - expect(JSON.parse(startedRawTurn.message_payload_json)).toMatchObject({ - turn_start: { - contextPacketId: started.contextPacketId, - searchEventId: started.searchEventId, - sourceMemoryIds: started.sourceMemoryIds + expect(db.db.prepare( + "SELECT COUNT(*) AS count FROM raw_turns WHERE session_id = ? AND turn_id = ?" + ).get(session.sessionId, started.turnId)).toEqual({ count: 0 }); + const recall = db.db.prepare( + `SELECT episode_id, request_json + FROM recall_events + WHERE id = ?` + ).get(started.searchEventId) as { episode_id: string | null; request_json: string }; + expect(recall.episode_id).toBeNull(); + expect(JSON.parse(recall.request_json)).toMatchObject({ + routeProposal: { + action: "create_first", + relationDecision: { relation: "new_task" } } }); expect(db.db.prepare( @@ -110,6 +96,34 @@ describe("MemoryService / session / turn capture", () => { apiLogs: before.apiLogs + 2, idempotency: before.idempotency + 1 }); + const completedRawTurn = db.db.prepare( + `SELECT episode_id, user_text, assistant_text, source_memory_ids_json, + message_payload_json, status + FROM raw_turns + WHERE id = ?` + ).get(completed.rawTurnId) as { + episode_id: string; + user_text: string; + assistant_text: string; + source_memory_ids_json: string; + message_payload_json: string; + status: string; + }; + expect(completedRawTurn).toMatchObject({ + episode_id: completed.episodeId, + user_text: "Do not create L1 until the assistant finishes.", + assistant_text: "The complete user and assistant turn is now safe to persist.", + status: "succeeded" + }); + expect(JSON.parse(completedRawTurn.source_memory_ids_json)).toEqual(started.sourceMemoryIds); + expect(JSON.parse(completedRawTurn.message_payload_json)).toMatchObject({ + turn_start: { + contextPacketId: started.contextPacketId, + searchEventId: started.searchEventId, + sourceMemoryIds: started.sourceMemoryIds, + routeProposal: { action: "create_first" } + } + }); expect(completed.jobs.map((job) => job.jobType)).toContain("episode_idle_close"); db.close(); }); @@ -138,25 +152,15 @@ describe("MemoryService / session / turn capture", () => { query: "For that sqlite migration, inspect the schema first." }); - expect(replacement.episodeId).toBe(interrupted.episodeId); + expect(interrupted).not.toHaveProperty("episodeId"); + expect(replacement).not.toHaveProperty("episodeId"); expect(memoryCount()).toBe(beforeMemories); expect(db.db.prepare( `SELECT turn_id, status, assistant_text FROM raw_turns WHERE session_id = ? ORDER BY created_at ASC, turn_id ASC` - ).all(session.sessionId)).toEqual([ - { - turn_id: "turn-interrupted", - status: "started", - assistant_text: null - }, - { - turn_id: "turn-replacement", - status: "started", - assistant_text: null - } - ]); + ).all(session.sessionId)).toEqual([]); const completed = service.completeTurn("turn-replacement", { sessionId: session.sessionId, @@ -171,16 +175,10 @@ describe("MemoryService / session / turn capture", () => { FROM raw_turns WHERE session_id = ? ORDER BY created_at ASC, turn_id ASC` - ).all(session.sessionId)).toEqual([ - { - turn_id: "turn-interrupted", - status: "started" - }, - { - turn_id: "turn-replacement", - status: "succeeded" - } - ]); + ).all(session.sessionId)).toEqual([{ + turn_id: "turn-replacement", + status: "succeeded" + }]); expect(db.db.prepare( `SELECT json_extract(properties_json, '$.internal_info.raw_turn_id') AS raw_turn_id FROM memories @@ -253,13 +251,83 @@ describe("MemoryService / session / turn capture", () => { }); expect(completed.l1MemoryIds).toHaveLength(1); - expect(db.db.prepare( - "SELECT status, user_text, assistant_text FROM raw_turns WHERE id = ?" - ).get(completed.rawTurnId)).toEqual({ + const raw = db.db.prepare( + "SELECT status, user_text, assistant_text, message_payload_json FROM raw_turns WHERE id = ?" + ).get(completed.rawTurnId) as { + status: string; + user_text: string; + assistant_text: string; + message_payload_json: string; + }; + expect(raw).toMatchObject({ status: "failed", user_text: "Run the deployment.", assistant_text: "Deployment failed: connection timed out." }); + expect(JSON.parse(raw.message_payload_json)).toMatchObject({ + turn_start: { + routeProposalStale: true, + routeProposal: { action: "create_first" } + } + }); + db.close(); + }); + + it("rebinds observed tool data when turn.complete commits a split proposal", async () => { + const { db, service } = createTestService(); + const session = service.openSession({ + namespace: { + source: "codex", + profileId: "default", + userId: "turn-observed-route-user" + } + }); + const first = service.completeTurn("turn-observed-route-first", { + sessionId: session.sessionId, + query: "Configure nginx TLS", + answer: "Use port 443." + }); + await service.startTurn({ + turnId: "turn-observed-route-split", + sessionId: session.sessionId, + query: "new task: summarize the hiring plan" + }); + const observed = await service.observeTool({ + sessionId: session.sessionId, + turnId: "turn-observed-route-split", + toolCallId: "call-hiring-plan", + toolName: "read_file", + args: { path: "hiring-plan.md" } + }); + expect(observed.rawTurnId).toMatch(/^raw_/u); + expect(db.db.prepare( + "SELECT episode_id FROM raw_turns WHERE id = ?" + ).get(observed.rawTurnId)).toEqual({ episode_id: first.episodeId }); + + const completed = service.completeTurn("turn-observed-route-split", { + sessionId: session.sessionId, + query: "new task: summarize the hiring plan", + answer: "The hiring plan is summarized." + }); + expect(completed.rawTurnId).toBe(observed.rawTurnId); + expect(completed.episodeId).not.toBe(first.episodeId); + expect(completed.closedEpisodeIds).toEqual([first.episodeId]); + expect(db.db.prepare( + "SELECT episode_id FROM raw_turns WHERE id = ?" + ).get(observed.rawTurnId)).toEqual({ episode_id: completed.episodeId }); + expect(db.db.prepare( + "SELECT DISTINCT episode_id FROM artifacts WHERE raw_turn_id = ?" + ).all(observed.rawTurnId)).toEqual([{ episode_id: completed.episodeId }]); + const episodeRows = db.db.prepare( + "SELECT id, raw_turn_ids_json FROM episodes WHERE id IN (?, ?) ORDER BY id" + ).all(first.episodeId, completed.episodeId) as Array<{ + id: string; + raw_turn_ids_json: string; + }>; + const firstRow = episodeRows.find((row) => row.id === first.episodeId); + const completedRow = episodeRows.find((row) => row.id === completed.episodeId); + expect(JSON.parse(firstRow!.raw_turn_ids_json)).not.toContain(observed.rawTurnId); + expect(JSON.parse(completedRow!.raw_turn_ids_json)).toContain(observed.rawTurnId); db.close(); }); diff --git a/Memory/tests/service/trials/skill-trial.test.ts b/Memory/tests/service/trials/skill-trial.test.ts index 62351c374..9965d44d2 100644 --- a/Memory/tests/service/trials/skill-trial.test.ts +++ b/Memory/tests/service/trials/skill-trial.test.ts @@ -25,6 +25,7 @@ describe("MemoryService / trials / skill trial", () => { answer: "applied the sqlite migration checklist and reported the neutral result" }); await service.runWorkerOnce(100); + await service.runWorkerOnce(100); const skillId = "skill_neutral_reward"; insertActiveSkillMemoryForTest(db, { @@ -53,11 +54,10 @@ describe("MemoryService / trials / skill trial", () => { magnitude: 1, rationale: "skill result was inconclusive" }); - expect(feedback.jobs.map((job) => job.jobType)).toEqual(expect.arrayContaining([ - "reward", - "skill_trial_resolve" - ])); + expect(feedback.jobs.map((job) => job.jobType)).toEqual(["skill_trial_resolve"]); + service.closeSession(session.sessionId); + await service.runWorkerOnce(100); await service.runWorkerOnce(100); const resolvedTrial = db.db.prepare( @@ -90,13 +90,25 @@ describe("MemoryService / trials / skill trial", () => { ).get(trial.trialId) as { source: string }; expect(trialResolvedChange.source).toBe("worker.reward.updated"); + const retrySession = service.openSession({ + namespace: { + ...namespace, + sessionKey: "retry" + } + }); + const retryTurn = service.completeTurn("turn-skill-neutral-reward-retry", { + sessionId: retrySession.sessionId, + episodeId: "episode-skill-neutral-reward-retry", + query: "retry the reusable sqlite migration checklist", + answer: "applied the checklist again" + }); const retryTrial = service.useSkill(skillId, { adapterId: "test-adapter", requestId: "skill-neutral-reward-2", - sessionId: session.sessionId, - episodeId: complete.episodeId, - rawTurnId: complete.rawTurnId, - turnId: complete.turnId + sessionId: retrySession.sessionId, + episodeId: retryTurn.episodeId, + rawTurnId: retryTurn.rawTurnId, + turnId: retryTurn.turnId }); expect(retryTrial.trialId).not.toBe(trial.trialId); expect(retryTrial.duplicate).toBeUndefined(); @@ -105,9 +117,8 @@ describe("MemoryService / trials / skill trial", () => { SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending, COUNT(*) AS total FROM skill_trials - WHERE skill_memory_id = ? - AND episode_id = ?` - ).get(skillId, complete.episodeId) as { pending: number; total: number }; + WHERE skill_memory_id = ?` + ).get(skillId) as { pending: number; total: number }; expect(trialCounts).toMatchObject({ pending: 1, total: 2 diff --git a/README.md b/README.md index 2f1535d58..3fac9ce95 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Memmy is not just a chat interface — it is an AI Agent Runtime t | 🔌 Integration Layer | Connect external ecosystems | Messaging channels, third-party tools, OpenAI-compatible API | | 🖥️ User Interface | Provide entry points | Desktop App, CLI/TUI, Web API | -### Repository Architecture +### System Architecture ![Memmy System Architecture](docs/assets/memmy-architecture-en.png) @@ -165,6 +165,7 @@ agents: providers: openai: apiKey: ${OPENAI_API_KEY} # Supports ${ENV_NAME}-style environment variable references + apiBase: https://example.com/v1 # Optional: override the provider's default endpoint tools: browser: enabled: true @@ -172,6 +173,8 @@ tools: idleTimeoutS: 900 ``` +`providers.*.apiBase` works with OpenAI-compatible and Anthropic-compatible gateways. For an unregistered OpenAI-compatible gateway, use `provider: custom` and configure `providers.custom.apiKey` plus `providers.custom.apiBase`. + The desktop app and `scripts/dev-start.sh` prepare the matching managed Chromium build before the Agent Gateway starts. Agent requests never download a browser; when the managed executable is unavailable, browser tools are omitted while other Agent features continue to work. ### Option 3: `memmy-memory` CLI (memory access for external Agents / scripts) diff --git a/README.zh-CN.md b/README.zh-CN.md index c2afcde93..97ad7c7a4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,7 @@ ## 🚀 开始体验 Memmy -点击进入[官网下载](https://memmy.cn/)或者 [GitHub Release](https://github.com/MemTensor/memmy-agent/releases)下载。 +点击进入[官网下载](https://memmy.cn/)或者 [GitHub Release](https://github.com/MemTensor/memmy-agent/releases) 下载。 > [!TIP] > 注册 Memmy 后,即可获得免费 AI 使用额度,系统会自动进行模型调度,帮助你体验完整的 Memory + Agent Runtime。 @@ -105,7 +105,7 @@ Memmy 不只是一个聊天界面,而是一套运行在本地的 AI Agent  | 🔌 Integration Layer | 连接外部生态 | 消息渠道、第三方工具、OpenAI 兼容 API | | 🖥️ User Interface | 提供使用入口 | Desktop App、CLI/TUI、Web 接口 | -### 仓库架构 +### 系统架构 ![Memmy 系统架构](docs/assets/memmy-architecture-zh.png) diff --git a/docs/cn/models/byok.mdx b/docs/cn/models/byok.mdx index 672e07689..1c80b3443 100644 --- a/docs/cn/models/byok.mdx +++ b/docs/cn/models/byok.mdx @@ -11,6 +11,23 @@ icon: Key 3. ASR(语音输入,当前固定 Qwen ASR)与图像生成模型,可跳过,不阻塞普通对话与记忆功能。 +## 自定义接口地址 + +通过 `providers.*.apiBase` 可以覆盖 Provider 的默认接口地址。对于未注册的 OpenAI 兼容网关,可以使用内置的 `custom` Provider: + +```yaml +agents: + defaults: + model: gateway-model + provider: custom +providers: + custom: + apiKey: ${GATEWAY_API_KEY} + apiBase: https://example.com/v1 +``` + +Anthropic 兼容网关可以使用 `provider: anthropic`,并配置 `providers.anthropic.apiBase`。 + ## Provider 默认值 | Provider | 默认 Endpoint | 默认模型占位 | diff --git a/docs/en/models/byok.mdx b/docs/en/models/byok.mdx index e352cb652..d1e20366e 100644 --- a/docs/en/models/byok.mdx +++ b/docs/en/models/byok.mdx @@ -11,6 +11,23 @@ Use your own model quota. Setup takes three steps, each with a real connection t 3. ASR (voice input, currently fixed to Qwen ASR) and the image generation model are skippable — they never block normal chat or memory features. +## Custom Endpoints + +Set `providers.*.apiBase` to override a provider's default endpoint. For an unregistered OpenAI-compatible gateway, use the built-in `custom` provider: + +```yaml +agents: + defaults: + model: gateway-model + provider: custom +providers: + custom: + apiKey: ${GATEWAY_API_KEY} + apiBase: https://example.com/v1 +``` + +Anthropic-compatible gateways can use `provider: anthropic` with `providers.anthropic.apiBase`. + ## Provider Defaults | Provider | Default Endpoint | Default Model Placeholder | diff --git a/scripts/internal/package-mac-dmg.sh b/scripts/internal/package-mac-dmg.sh index d595e49b0..82dbc5462 100755 --- a/scripts/internal/package-mac-dmg.sh +++ b/scripts/internal/package-mac-dmg.sh @@ -431,6 +431,97 @@ prune_onnxruntime_native_artifacts() { esac } +prune_node_modules_non_runtime_files() { + local runtime_root="$1" + + if [ ! -d "$runtime_root" ]; then + return + fi + + local modules_dir + while IFS= read -r modules_dir; do + if [ ! -d "$modules_dir" ]; then + continue + fi + + local disposable_list + disposable_list="$(mktemp)" + find "$modules_dir" -depth -type d \( \ + -name test -o \ + -name tests -o \ + -name __tests__ -o \ + -name doc -o \ + -name docs -o \ + -name example -o \ + -name examples -o \ + -name coverage -o \ + -name .github \ + \) > "$disposable_list" + + local disposable_dir + while IFS= read -r disposable_dir; do + rm -rf "$disposable_dir" + done < "$disposable_list" + rm -f "$disposable_list" + + if [ ! -d "$modules_dir" ]; then + continue + fi + + find "$modules_dir" -type f \( \ + -iname "README" -o \ + -iname "README*.md" -o \ + -iname "README*.mdown" -o \ + -iname "README*.markdown" -o \ + -iname "README*.rst" -o \ + -iname "README*.txt" -o \ + -iname "CHANGELOG" -o \ + -iname "CHANGELOG*.md" -o \ + -iname "CHANGELOG*.mdown" -o \ + -iname "CHANGELOG*.markdown" -o \ + -iname "CHANGELOG*.rst" -o \ + -iname "CHANGELOG*.txt" -o \ + -iname "CONTRIBUTING" -o \ + -iname "CONTRIBUTING*.md" -o \ + -iname "CONTRIBUTING*.mdown" -o \ + -iname "CONTRIBUTING*.markdown" -o \ + -iname "CONTRIBUTING*.rst" -o \ + -iname "CONTRIBUTING*.txt" -o \ + -iname "CODE_OF_CONDUCT" -o \ + -iname "CODE_OF_CONDUCT*.md" -o \ + -iname "CODE_OF_CONDUCT*.mdown" -o \ + -iname "CODE_OF_CONDUCT*.markdown" -o \ + -iname "CODE_OF_CONDUCT*.rst" -o \ + -iname "CODE_OF_CONDUCT*.txt" -o \ + -iname "SECURITY" -o \ + -iname "SECURITY*.md" -o \ + -iname "SECURITY*.mdown" -o \ + -iname "SECURITY*.markdown" -o \ + -iname "SECURITY*.rst" -o \ + -iname "SECURITY*.txt" \ + \) ! \( \ + -iname "LICENSE*" -o \ + -iname "NOTICE*" -o \ + -iname "COPYING*" \ + \) -delete + + find "$modules_dir" -type f \( \ + -iname "*.test.js" -o \ + -iname "*.test.cjs" -o \ + -iname "*.test.mjs" -o \ + -iname "*.test.ts" -o \ + -iname "*.test.tsx" -o \ + -iname "*.spec.js" -o \ + -iname "*.spec.cjs" -o \ + -iname "*.spec.mjs" -o \ + -iname "*.spec.ts" -o \ + -iname "*.spec.tsx" -o \ + -iname "test.js" -o \ + -iname "tests.json" \ + \) -delete + done < <(find "$runtime_root" -type d -name node_modules) +} + require_packaged_runtime_file() { local required_file="$1" @@ -502,6 +593,8 @@ prune_mac_runtime_artifacts() { echo "Pruning macOS runtime artifacts for darwin-$target_cpu." find "$RUNTIME_DIR" -type f -name "*.map" -delete + prune_node_modules_non_runtime_files "$RUNTIME_DIR" + rm -f "$RUNTIME_DIR/memmy-agent/dist/skills/README.md" while IFS= read -r module_dir; do prune_better_sqlite3_build_artifacts "$module_dir"