From 9af20c2ecc22aef344529ff24ced4db7b95292e9 Mon Sep 17 00:00:00 2001 From: mrc Date: Tue, 17 Mar 2026 17:24:43 +0700 Subject: [PATCH 01/24] feat(asm): add coding_execution routing contract and coding packet foundation for opencode lane --- bin/opencode-mcp-server.mjs | 33 +++++ src/core/contracts/adapter-contracts.ts | 2 + src/core/contracts/project-query-contracts.ts | 78 +++++++++++ .../usecases/default-memory-usecase-port.ts | 123 ++++++++++++++++++ src/tools/project-tools.ts | 121 +++++++++++++++++ tests/test-opencode-mcp-stdio.ts | 5 +- tests/test-project-registry.ts | 39 ++++++ 7 files changed, 399 insertions(+), 2 deletions(-) diff --git a/bin/opencode-mcp-server.mjs b/bin/opencode-mcp-server.mjs index 704efc0..e8f089d 100644 --- a/bin/opencode-mcp-server.mjs +++ b/bin/opencode-mcp-server.mjs @@ -42,6 +42,30 @@ export function buildOpencodeMcpToolDescriptors() { required: ["query"], }, ), + makeTool( + "asm_project_coding_packet", + "Build coding packet (foundation lane) for OpenCode using ASM project-aware/code-aware retrieval context.", + { + type: "object", + properties: { + project_id: { type: "string" }, + project_alias: { type: "string" }, + query: { type: "string" }, + objective: { type: "string" }, + task_id: { type: "string" }, + tracker_issue_key: { type: "string" }, + task_title: { type: "string" }, + symbol_name: { type: "string" }, + relative_path: { type: "string" }, + route_path: { type: "string" }, + limit: { type: "number" }, + acceptance_criteria: { type: "array", items: { type: "string" } }, + constraints: { type: "array", items: { type: "string" } }, + out_of_scope: { type: "array", items: { type: "string" } }, + validation_commands: { type: "array", items: { type: "string" } }, + }, + }, + ), ]; } @@ -116,6 +140,15 @@ async function handleToolCall(name, args) { return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; } + if (name === "asm_project_coding_packet") { + const data = await usecase.run("project.coding_packet", { + context, + meta: { source: "cli", toolName: "asm.mcp.opencode.coding_packet" }, + payload: args || {}, + }); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + } + return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true }; } diff --git a/src/core/contracts/adapter-contracts.ts b/src/core/contracts/adapter-contracts.ts index b35a2db..72d0180 100644 --- a/src/core/contracts/adapter-contracts.ts +++ b/src/core/contracts/adapter-contracts.ts @@ -51,6 +51,8 @@ export type MemoryUseCaseName = | "project.feature_pack.generate" | "project.feature_pack.query" | "project.developer_query" + | "project.routing_contract" + | "project.coding_packet" | "project.binding_preview" | "project.opencode_search" | "memory.capture" diff --git a/src/core/contracts/project-query-contracts.ts b/src/core/contracts/project-query-contracts.ts index 4498843..07aabea 100644 --- a/src/core/contracts/project-query-contracts.ts +++ b/src/core/contracts/project-query-contracts.ts @@ -99,3 +99,81 @@ export interface ProjectDeveloperQueryResponseV1 { generated_at: string; generator_version: "asm-109-slice8"; } + +export type ProjectWorkstreamType = "research_execution" | "coding_execution" | "review_execution"; + +export interface ProjectRoutingContractPayload { + project_id?: string; + project_alias?: string; + query?: string; + objective?: string; + workstream_type?: ProjectWorkstreamType; +} + +export interface ProjectRoutingContractV1 { + routing_contract_version: "asm-routing-v1"; + workstream_type: ProjectWorkstreamType; + project_id: string; + project_alias: string | null; + route_target: { + lane: "opencode"; + mode: "foundation"; + reason: string; + }; + retrieval_profile: { + project_aware: true; + code_aware: true; + primary_usecase: "project.developer_query"; + }; + generated_at: string; +} + +export interface ProjectCodingPacketPayload { + project_id?: string; + project_alias?: string; + query?: string; + objective?: string; + task_id?: string; + tracker_issue_key?: string; + task_title?: string; + symbol_name?: string; + relative_path?: string; + route_path?: string; + limit?: number; + acceptance_criteria?: string[]; + constraints?: string[]; + out_of_scope?: string[]; + validation_commands?: string[]; +} + +export interface ProjectCodingPacketV1 { + packet_version: "asm-coding-packet-v1"; + routing: ProjectRoutingContractV1; + objective: string; + project: { + project_id: string; + project_alias: string | null; + }; + selectors: { + task_id?: string; + tracker_issue_key?: string; + task_title?: string; + symbol_name?: string; + relative_path?: string; + route_path?: string; + }; + context: { + developer_query: ProjectDeveloperQueryResponseV1; + primary_files: string[]; + primary_symbols: string[]; + change_context: string[]; + }; + execution_hints: { + acceptance_criteria: string[]; + constraints: string[]; + out_of_scope: string[]; + validation_commands: string[]; + handoff_language: "vi"; + }; + generated_at: string; +} diff --git a/src/core/usecases/default-memory-usecase-port.ts b/src/core/usecases/default-memory-usecase-port.ts index 9c77837..c2781d6 100644 --- a/src/core/usecases/default-memory-usecase-port.ts +++ b/src/core/usecases/default-memory-usecase-port.ts @@ -37,11 +37,16 @@ import type { ProjectChangeOverlayV1, } from "../contracts/change-overlay-contracts.js"; import type { + ProjectCodingPacketPayload, + ProjectCodingPacketV1, ProjectDeveloperQueryCanonicalIntent, ProjectDeveloperQueryIntent, ProjectDeveloperQueryPayload, ProjectDeveloperQueryPrimaryResult, ProjectDeveloperQueryResponseV1, + ProjectRoutingContractPayload, + ProjectRoutingContractV1, + ProjectWorkstreamType, } from "../contracts/project-query-contracts.js"; import { resolveAsmCoreProjectWorkspaceRoot } from "../../shared/asm-config.js"; @@ -565,6 +570,10 @@ export class DefaultMemoryUseCasePort implements MemoryUseCasePort { return this.handleProjectFeaturePackQuery(payload as unknown as ProjectFeaturePackQueryPayload, req) as TRes; case "project.developer_query": return this.handleProjectDeveloperQuery(payload as unknown as ProjectDeveloperQueryPayload, req) as TRes; + case "project.routing_contract": + return this.handleProjectRoutingContract(payload as unknown as ProjectRoutingContractPayload, req) as TRes; + case "project.coding_packet": + return this.handleProjectCodingPacket(payload as unknown as ProjectCodingPacketPayload, req) as TRes; case "graph.entity.get": return this.handleGraphEntityGet(payload as unknown as GraphEntityGetPayload, req) as TRes; case "graph.entity.set": @@ -918,6 +927,120 @@ export class DefaultMemoryUseCasePort implements MemoryUseCasePort { }; } + private normalizeWorkstreamType(raw?: string): ProjectWorkstreamType { + const normalized = String(raw || "").trim().toLowerCase(); + if (normalized === "research_execution" || normalized === "coding_execution" || normalized === "review_execution") { + return normalized; + } + return "coding_execution"; + } + + private handleProjectRoutingContract( + payload: ProjectRoutingContractPayload, + req: CoreRequestEnvelope, + ): ProjectRoutingContractV1 { + const identity = normalizePrivateIdentity(req.context); + const project = this.resolveProjectRef(identity.userId, identity.agentId, { + project_id: payload.project_id, + project_alias: payload.project_alias, + }); + + const workstreamType = this.normalizeWorkstreamType(payload.workstream_type); + const reasonByWorkstream: Record = { + coding_execution: "OpenClaw orchestrates and routes coding execution to OpenCode lane foundation.", + research_execution: "Research execution keeps OpenCode lane ready through project/code-aware retrieval foundation.", + review_execution: "Review execution can reuse OpenCode coding lane foundation with project-aware packet context.", + }; + + return { + routing_contract_version: "asm-routing-v1", + workstream_type: workstreamType, + project_id: project.project_id, + project_alias: payload.project_alias || null, + route_target: { + lane: "opencode", + mode: "foundation", + reason: reasonByWorkstream[workstreamType], + }, + retrieval_profile: { + project_aware: true, + code_aware: true, + primary_usecase: "project.developer_query", + }, + generated_at: new Date().toISOString(), + }; + } + + private handleProjectCodingPacket( + payload: ProjectCodingPacketPayload, + req: CoreRequestEnvelope, + ): ProjectCodingPacketV1 { + const routing = this.handleProjectRoutingContract( + { + project_id: payload.project_id, + project_alias: payload.project_alias, + query: payload.query, + objective: payload.objective, + workstream_type: "coding_execution", + }, + req, + ); + + const query = String(payload.query || payload.objective || payload.task_title || "").trim(); + const objective = String(payload.objective || payload.query || "").trim() || "Implement requested coding change with project-aware/code-aware context."; + + const developerQuery = this.handleProjectDeveloperQuery( + { + project_id: routing.project_id, + project_alias: routing.project_alias || undefined, + query, + task_id: payload.task_id, + tracker_issue_key: payload.tracker_issue_key, + task_title: payload.task_title, + symbol_name: payload.symbol_name, + relative_path: payload.relative_path, + route_path: payload.route_path, + limit: payload.limit, + }, + req, + ); + + const primaryFiles = developerQuery.files.slice(0, 12); + const primarySymbols = developerQuery.symbols.slice(0, 16); + + return { + packet_version: "asm-coding-packet-v1", + routing, + objective, + project: { + project_id: routing.project_id, + project_alias: routing.project_alias, + }, + selectors: { + ...(payload.task_id ? { task_id: payload.task_id } : {}), + ...(payload.tracker_issue_key ? { tracker_issue_key: payload.tracker_issue_key } : {}), + ...(payload.task_title ? { task_title: payload.task_title } : {}), + ...(payload.symbol_name ? { symbol_name: payload.symbol_name } : {}), + ...(payload.relative_path ? { relative_path: payload.relative_path } : {}), + ...(payload.route_path ? { route_path: payload.route_path } : {}), + }, + context: { + developer_query: developerQuery, + primary_files: primaryFiles, + primary_symbols: primarySymbols, + change_context: developerQuery.change_context, + }, + execution_hints: { + acceptance_criteria: payload.acceptance_criteria || [], + constraints: payload.constraints || [], + out_of_scope: payload.out_of_scope || [], + validation_commands: payload.validation_commands || [], + handoff_language: "vi", + }, + generated_at: new Date().toISOString(), + }; + } + private handleProjectSetRegistrationState(payload: ProjectSetRegistrationStatePayload, req: CoreRequestEnvelope) { const identity = normalizePrivateIdentity(req.context); diff --git a/src/tools/project-tools.ts b/src/tools/project-tools.ts index f01b27a..6f06ddc 100644 --- a/src/tools/project-tools.ts +++ b/src/tools/project-tools.ts @@ -1449,6 +1449,127 @@ export function registerProjectTools( }, }); + api.registerTool({ + name: "project_routing_contract", + label: "Project Routing Contract", + description: + "Routing contract/workstream foundation for OpenClaw orchestrates -> OpenCode coding lane. Returns project-aware + code-aware routing envelope.", + parameters: { + type: "object", + properties: { + project_id: { type: "string" }, + project_alias: { type: "string" }, + query: { type: "string" }, + objective: { type: "string" }, + workstream_type: { + type: "string", + enum: ["research_execution", "coding_execution", "review_execution"], + }, + }, + required: [], + }, + async execute( + _id: string, + params: { + project_id?: string; + project_alias?: string; + query?: string; + objective?: string; + workstream_type?: "research_execution" | "coding_execution" | "review_execution"; + }, + ctx: any, + ) { + try { + const sessionKey = getSessionKey(ctx); + const { userId, agentId } = parseOpenClawSessionIdentity(sessionKey); + const useCasePort = getMemoryUseCasePortForContext(ctx); + + const data = await useCasePort.run("project.routing_contract", { + context: { userId, agentId }, + payload: params, + meta: { + source: "openclaw", + toolName: "project_routing_contract", + requestId: _id, + }, + }); + + return createResult(JSON.stringify(data, null, 2)); + } catch (error) { + return createResult(`Error: ${error instanceof Error ? error.message : String(error)}`, true); + } + }, + }); + + api.registerTool({ + name: "project_coding_packet", + label: "Project Coding Packet", + description: + "Build coding packet schema for OpenCode lane using existing project-aware/code-aware retrieval (developer_query).", + parameters: { + type: "object", + properties: { + project_id: { type: "string" }, + project_alias: { type: "string" }, + query: { type: "string" }, + objective: { type: "string" }, + task_id: { type: "string" }, + tracker_issue_key: { type: "string" }, + task_title: { type: "string" }, + symbol_name: { type: "string" }, + relative_path: { type: "string" }, + route_path: { type: "string" }, + limit: { type: "number" }, + acceptance_criteria: { type: "array", items: { type: "string" } }, + constraints: { type: "array", items: { type: "string" } }, + out_of_scope: { type: "array", items: { type: "string" } }, + validation_commands: { type: "array", items: { type: "string" } }, + }, + required: [], + }, + async execute( + _id: string, + params: { + project_id?: string; + project_alias?: string; + query?: string; + objective?: string; + task_id?: string; + tracker_issue_key?: string; + task_title?: string; + symbol_name?: string; + relative_path?: string; + route_path?: string; + limit?: number; + acceptance_criteria?: string[]; + constraints?: string[]; + out_of_scope?: string[]; + validation_commands?: string[]; + }, + ctx: any, + ) { + try { + const sessionKey = getSessionKey(ctx); + const { userId, agentId } = parseOpenClawSessionIdentity(sessionKey); + const useCasePort = getMemoryUseCasePortForContext(ctx); + + const data = await useCasePort.run("project.coding_packet", { + context: { userId, agentId }, + payload: params, + meta: { + source: "openclaw", + toolName: "project_coding_packet", + requestId: _id, + }, + }); + + return createResult(JSON.stringify(data, null, 2)); + } catch (error) { + return createResult(`Error: ${error instanceof Error ? error.message : String(error)}`, true); + } + }, + }); + api.registerTool({ name: "project_developer_query", label: "Project Developer Query", diff --git a/tests/test-opencode-mcp-stdio.ts b/tests/test-opencode-mcp-stdio.ts index d288209..85f7ea1 100644 --- a/tests/test-opencode-mcp-stdio.ts +++ b/tests/test-opencode-mcp-stdio.ts @@ -124,9 +124,10 @@ async function main() { const tools = Array.isArray(toolsList.result?.tools) ? toolsList.result.tools : []; const toolNames = tools.map((tool: any) => String(tool?.name || "")).sort(); - assert(toolNames.length === 2, "tools/list must return exactly 2 read-only tools"); + assert(toolNames.length === 3, "tools/list must return exactly 3 read-only tools"); assert(toolNames[0] === "asm_project_binding_preview", "missing asm_project_binding_preview"); - assert(toolNames[1] === "asm_project_opencode_search", "missing asm_project_opencode_search"); + assert(toolNames[1] === "asm_project_coding_packet", "missing asm_project_coding_packet"); + assert(toolNames[2] === "asm_project_opencode_search", "missing asm_project_opencode_search"); console.log("✅ opencode MCP stdio smoke passed"); } finally { diff --git a/tests/test-project-registry.ts b/tests/test-project-registry.ts index 6dd0026..aa9f0f9 100644 --- a/tests/test-project-registry.ts +++ b/tests/test-project-registry.ts @@ -1381,6 +1381,45 @@ async function main() { assertEqual(result.binding.selected_project.project_alias, "agent-smart-memo", "explicit project alias must win over session alias"); }); + await test("project.routing_contract returns coding_execution route to opencode foundation lane", async () => { + const result = await usecase.run("project.routing_contract", { + ...ctx, + payload: { + project_alias: "agent-smart-memo", + workstream_type: "coding_execution", + objective: "Implement typed query parser", + }, + }); + + assertEqual(result.routing_contract_version, "asm-routing-v1", "routing contract version should be stable"); + assertEqual(result.workstream_type, "coding_execution", "workstream type should be preserved"); + assertEqual(result.route_target.lane, "opencode", "coding execution should route to opencode lane"); + assertEqual(result.route_target.mode, "foundation", "initial routing should stay in foundation mode"); + assertEqual(result.retrieval_profile.project_aware, true, "routing should declare project-aware retrieval"); + assertEqual(result.retrieval_profile.code_aware, true, "routing should declare code-aware retrieval"); + }); + + await test("project.coding_packet builds packet using project.developer_query context", async () => { + const result = await usecase.run("project.coding_packet", { + ...ctx, + payload: { + project_alias: "agent-smart-memo", + query: "code aware retrieval", + objective: "Wire packet to coding lane", + acceptance_criteria: ["packet has developer context"], + constraints: ["no deploy"], + }, + }); + + assertEqual(result.packet_version, "asm-coding-packet-v1", "coding packet version should be stable"); + assertEqual(result.routing.workstream_type, "coding_execution", "coding packet should force coding_execution workstream"); + assertEqual(result.routing.route_target.lane, "opencode", "coding packet routing should target opencode lane"); + assertEqual(typeof result.context.developer_query.query_id, "string", "packet should embed developer_query context"); + assert(Array.isArray(result.context.primary_files), "packet should expose primary_files list"); + assert(Array.isArray(result.execution_hints.acceptance_criteria), "packet should preserve execution hints"); + assertEqual(result.execution_hints.handoff_language, "vi", "A2A handoff language must stay Vietnamese"); + }); + await test("project.list returns registry entries", async () => { const rows = await usecase.run("project.list", { ...ctx, From 82bbfcdc89786bb8540da19d9bb93ad88bd96cbc Mon Sep 17 00:00:00 2001 From: mrc Date: Wed, 18 Mar 2026 19:51:44 +0700 Subject: [PATCH 02/24] fix: add package-name bin alias for npx UX --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 7354e78..ff73279 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.1", - "description": "Smart Memory Plugin for OpenClaw — structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", + "version": "5.1.2", + "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", "bin": { - "asm": "bin/asm.mjs" + "asm": "bin/asm.mjs", + "agent-smart-memo": "bin/asm.mjs" }, "openclaw": { "extensions": [ From babeda2c3a9d9f2f743b1fb2aaac3df51a85e422 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 11:33:02 +0700 Subject: [PATCH 03/24] fix: ship asm cli runtime dependencies in npm package --- bin/asm.mjs | 2 +- src/cli/platform-installers.ts | 2 +- src/types/mjs.d.ts | 5 +++++ tsconfig.openclaw.json | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 src/types/mjs.d.ts diff --git a/bin/asm.mjs b/bin/asm.mjs index 8cb43c3..3fd62b3 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { runInitOpenClaw } from "../scripts/init-openclaw.mjs"; -import { createShellRunner, runInitSetupFlow, runInstallPlatformFlow } from "../src/cli/platform-installers.ts"; +import { createShellRunner, runInitSetupFlow, runInstallPlatformFlow } from "../dist/cli/platform-installers.js"; import { runOpencodeMcpServer } from "./opencode-mcp-server.mjs"; const ASM_PLUGIN_PACKAGE = "@mrc2204/agent-smart-memo"; diff --git a/src/cli/platform-installers.ts b/src/cli/platform-installers.ts index 2b33704..67d28b3 100644 --- a/src/cli/platform-installers.ts +++ b/src/cli/platform-installers.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { doctorAsmSharedConfig, getAsmSharedConfig, resolveAsmConfigPath } from "../shared/asm-config.ts"; +import { doctorAsmSharedConfig, getAsmSharedConfig, resolveAsmConfigPath } from "../shared/asm-config.js"; import { runInitOpenClaw } from "../../scripts/init-openclaw.mjs"; export interface AsmShellResult { diff --git a/src/types/mjs.d.ts b/src/types/mjs.d.ts new file mode 100644 index 0000000..20b15ee --- /dev/null +++ b/src/types/mjs.d.ts @@ -0,0 +1,5 @@ +declare module "*.mjs" { + const value: any; + export default value; + export const runInitOpenClaw: any; +} diff --git a/tsconfig.openclaw.json b/tsconfig.openclaw.json index becdec5..bb50085 100644 --- a/tsconfig.openclaw.json +++ b/tsconfig.openclaw.json @@ -3,6 +3,6 @@ "compilerOptions": { "outDir": "./dist-openclaw" }, - "files": ["src/index.ts"], + "files": ["src/index.ts", "src/cli/platform-installers.ts", "src/shared/asm-config.ts", "src/types/mjs.d.ts"], "exclude": ["node_modules", "dist", "dist-openclaw", "dist-paperclip", "dist-core", "tests"] } From 330b0e075ad53b10b2aba085603e0cbb6a741caa Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 11:33:53 +0700 Subject: [PATCH 04/24] fix: bundle asm CLI runtime files for npx install flow --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ff73279..a53022d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.2", + "version": "5.1.3", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", From 60fa813cd79c0bbd9b17669d4a7f6d9af8e07712 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 11:42:13 +0700 Subject: [PATCH 05/24] fix: remove symlink-sensitive asm CLI entry guard --- bin/asm.mjs | 8 +++----- package.json | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/bin/asm.mjs b/bin/asm.mjs index 3fd62b3..29b0c8f 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -358,8 +358,6 @@ export async function main(argv = process.argv.slice(2)) { return 1; } -if (import.meta.url === `file://${process.argv[1]}`) { - main().then((code) => { - process.exitCode = code; - }); -} +main().then((code) => { + process.exitCode = code; +}); diff --git a/package.json b/package.json index a53022d..1a14206 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.3", + "version": "5.1.4", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", From 9f3ff6bedb28b2891e08bccd11003ad6f2ea9c87 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 11:47:51 +0700 Subject: [PATCH 06/24] feat: default bare install to openclaw --- bin/asm.mjs | 8 +++++--- package.json | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bin/asm.mjs b/bin/asm.mjs index 29b0c8f..a4d545f 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -36,11 +36,13 @@ export function parseAsmCliArgs(argv = []) { return { command: "setup-openclaw", argv: args.slice(2) }; } - if (first === "install" && (args[1] || "")) { + if (first === "install") { + const platform = String(args[1] || "openclaw").trim().toLowerCase(); + const hasExplicitPlatform = Boolean(args[1]); return { command: "install-platform", - platform: String(args[1] || "").trim().toLowerCase(), - argv: args.slice(2), + platform, + argv: hasExplicitPlatform ? args.slice(2) : args.slice(1), }; } diff --git a/package.json b/package.json index 1a14206..a528cc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.4", + "version": "5.1.5", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", From 0db1876317f2bdf0fd4bb4c3dd76603c90e584b2 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 11:51:04 +0700 Subject: [PATCH 07/24] fix: support npx package-name install UX --- bin/asm.mjs | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/asm.mjs b/bin/asm.mjs index a4d545f..ae26bb8 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -21,7 +21,10 @@ function includesAsmPlugin(output) { } export function parseAsmCliArgs(argv = []) { - const args = Array.isArray(argv) ? argv.map((x) => String(x)) : []; + let args = Array.isArray(argv) ? argv.map((x) => String(x)) : []; + if (args[0] === 'agent-smart-memo' || args[0] === '@mrc2204/agent-smart-memo') { + args = args.slice(1); + } const first = args[0] || ""; if (!first || first === "help" || first === "--help" || first === "-h") { diff --git a/package.json b/package.json index a528cc0..60525e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.5", + "version": "5.1.6", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", From 040460bdf843b2786ff265850996a24cdc97352e Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 12:46:13 +0700 Subject: [PATCH 08/24] fix: avoid slow plugin inventory during openclaw install --- package.json | 2 +- src/cli/platform-installers.ts | 37 +++++++++++----------------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 60525e7..24b2d45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.6", + "version": "5.1.7", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", diff --git a/src/cli/platform-installers.ts b/src/cli/platform-installers.ts index 67d28b3..7efd23d 100644 --- a/src/cli/platform-installers.ts +++ b/src/cli/platform-installers.ts @@ -164,33 +164,20 @@ async function runSetupOpenClawInstall(ctx: AsmInstallContext): Promise includesAsmPlugin(item?.name || item?.id || item?.package || item?.pluginId)); - } catch { - installed = includesAsmPlugin(tryJson.stdout); - } - } - if (!installed) { - const tryText = runner("openclaw", ["plugins", "list"]); - installed = tryText.ok && includesAsmPlugin(tryText.stdout); + log("[ASM-84] attempting direct plugin install/update: @mrc2204/agent-smart-memo"); + const install = runner("openclaw", ["plugins", "install", "@mrc2204/agent-smart-memo"]); + const installOutput = `${install.stdout || ""}\n${install.stderr || ""}`; + const installedLikeSuccess = install.ok || includesAsmPlugin(installOutput) || /already installed|already exists|linked plugin path|plugin install command completed|Config overwrite/i.test(installOutput); + + if (!installedLikeSuccess) { + log("[ASM-84] ❌ failed to install or verify plugin via OpenClaw CLI."); + if (install.stdout) log(install.stdout); + if (install.stderr) log(install.stderr); + return { ok: false, step: "install-plugin", platform: "openclaw" }; } - if (!installed) { - log("[ASM-84] plugin not detected. Installing: @mrc2204/agent-smart-memo"); - const install = runner("openclaw", ["plugins", "install", "@mrc2204/agent-smart-memo"]); - if (!install.ok) { - if (install.stderr) log(install.stderr); - return { ok: false, step: "install-plugin", platform: "openclaw" }; - } - } + if (install.stdout) log(install.stdout); + if (install.stderr) log(install.stderr); const mode = parseNonInteractive(argv); const initResult = await initOpenClaw({ interactive: !mode.nonInteractive, autoApply: mode.autoApply }); From c089509e7c5ad93b9a439a22424ee90626655cc0 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 13:00:51 +0700 Subject: [PATCH 09/24] fix: separate cli bootstrap from platform install semantics --- bin/asm.mjs | 24 ++++++++++++++++++++++-- package.json | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/bin/asm.mjs b/bin/asm.mjs index ae26bb8..fa4cb3e 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -40,12 +40,15 @@ export function parseAsmCliArgs(argv = []) { } if (first === "install") { - const platform = String(args[1] || "openclaw").trim().toLowerCase(); const hasExplicitPlatform = Boolean(args[1]); + if (!hasExplicitPlatform) { + return { command: "install-cli", argv: [] }; + } + const platform = String(args[1] || "").trim().toLowerCase(); return { command: "install-platform", platform, - argv: hasExplicitPlatform ? args.slice(2) : args.slice(1), + argv: args.slice(2), }; } @@ -80,6 +83,7 @@ export function printHelp(log = console.log) { log("asm - Agent Smart Memo CLI"); log(""); log("Usage:"); + log(" asm install # install / expose CLI only"); log(" asm setup-openclaw [--yes]"); log(" asm setup openclaw [--yes]"); log(" asm install openclaw [--yes]"); @@ -167,6 +171,17 @@ function parseProjectEventArgs(argv = []) { return out; } +export async function runCliBootstrapFlow({ log = console.log } = {}) { + log("[ASM-CLI] Installing / exposing ASM CLI only..."); + log(`[ASM-CLI] Package: ${ASM_PLUGIN_PACKAGE}`); + log("[ASM-CLI] The CLI entrypoint is now available as: asm"); + log("[ASM-CLI] Next steps:"); + log(" 1) asm install openclaw"); + log(" 2) asm install opencode"); + log(" 3) asm install paperclip"); + return { ok: true, step: "install-cli" }; +} + export async function runSetupOpenClawFlow({ runner = createShellRunner(), initOpenClaw = runInitOpenClaw, @@ -250,6 +265,11 @@ export async function main(argv = process.argv.slice(2)) { return 0; } + if (parsed.command === "install-cli") { + const result = await runCliBootstrapFlow({ log: console.log }); + return result.ok ? 0 : 1; + } + if (parsed.command === "setup-openclaw") { const result = await runSetupOpenClawFlow({ argv: parsed.argv }); return result.ok ? 0 : 1; diff --git a/package.json b/package.json index 24b2d45..6c3ad3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.7", + "version": "5.1.8", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", From 3883e5458b51e7ec8a2a2a55200618aba231db58 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 13:27:30 +0700 Subject: [PATCH 10/24] feat: offer shell profile PATH patch during CLI bootstrap --- bin/asm.mjs | 89 ++++++++++++++++++++++++++++++++++++++++++++++------ package.json | 2 +- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/bin/asm.mjs b/bin/asm.mjs index fa4cb3e..07419b9 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { runInitOpenClaw } from "../scripts/init-openclaw.mjs"; import { createShellRunner, runInitSetupFlow, runInstallPlatformFlow } from "../dist/cli/platform-installers.js"; import { runOpencodeMcpServer } from "./opencode-mcp-server.mjs"; @@ -8,6 +8,10 @@ import { runOpencodeMcpServer } from "./opencode-mcp-server.mjs"; const ASM_PLUGIN_PACKAGE = "@mrc2204/agent-smart-memo"; const ASM_PLUGIN_ID = "agent-smart-memo"; +console.error("[ASM-TRACE] import.meta.url=", import.meta.url); +console.error("[ASM-TRACE] argv=", JSON.stringify(process.argv)); +console.error("[ASM-TRACE] cwd=", process.cwd()); + function text(value) { return typeof value === "string" ? value.trim() : ""; } @@ -171,15 +175,82 @@ function parseProjectEventArgs(argv = []) { return out; } +function resolveUserBinDir() { + const home = process.env.HOME || process.cwd(); + return join(home, '.local', 'bin'); +} + +function pathContains(dir) { + return String(process.env.PATH || '').split(':').includes(dir); +} + +function detectShellProfile() { + const shell = String(process.env.SHELL || '').trim(); + const home = process.env.HOME || process.cwd(); + if (shell.endsWith('/zsh')) return { shell: 'zsh', profilePath: join(home, '.zshrc') }; + if (shell.endsWith('/bash')) return { shell: 'bash', profilePath: join(home, '.bashrc') }; + return { shell: shell || 'unknown', profilePath: join(home, '.profile') }; +} + +function profileHasPathLine(profilePath, userBin) { + try { + const content = readFileSync(profilePath, 'utf8'); + return content.includes(userBin) || content.includes('$HOME/.local/bin'); + } catch { + return false; + } +} + +function appendPathLine(profilePath, userBin) { + const exportLine = `\n# Added by ASM CLI installer\nexport PATH=\"${userBin}:$PATH\"\n`; + const existing = (() => { try { return readFileSync(profilePath, 'utf8'); } catch { return ''; } })(); + if (!existing.includes(userBin) && !existing.includes('$HOME/.local/bin')) { + writeFileSync(profilePath, `${existing}${exportLine}`, 'utf8'); + } +} + +function createAsmLauncher() { + const userBin = resolveUserBinDir(); + mkdirSync(userBin, { recursive: true }); + const launcherPath = join(userBin, 'asm'); + const packageRoot = resolve(dirname(new URL(import.meta.url).pathname), '..'); + const launcher = `#!/usr/bin/env bash\nnode \"${join(packageRoot, 'bin', 'asm.mjs')}\" \"$@\"\n`; + writeFileSync(launcherPath, launcher, 'utf8'); + chmodSync(launcherPath, 0o755); + return { launcherPath, userBin }; +} + export async function runCliBootstrapFlow({ log = console.log } = {}) { - log("[ASM-CLI] Installing / exposing ASM CLI only..."); + log('[ASM-CLI] Installing / exposing ASM CLI only...'); log(`[ASM-CLI] Package: ${ASM_PLUGIN_PACKAGE}`); - log("[ASM-CLI] The CLI entrypoint is now available as: asm"); - log("[ASM-CLI] Next steps:"); - log(" 1) asm install openclaw"); - log(" 2) asm install opencode"); - log(" 3) asm install paperclip"); - return { ok: true, step: "install-cli" }; + const installed = createAsmLauncher(); + log(`[ASM-CLI] Installed launcher: ${installed.launcherPath}`); + if (!pathContains(installed.userBin)) { + const detected = detectShellProfile(); + log(`[ASM-CLI] ${installed.userBin} is not currently on PATH.`); + const shouldPatch = process.stdin.isTTY + ? await askYesNo(`[ASM-CLI] Add ${installed.userBin} to ${detected.profilePath} now? [y/N] `) + : false; + if (shouldPatch) { + appendPathLine(detected.profilePath, installed.userBin); + log(`[ASM-CLI] Updated ${detected.profilePath}`); + log(`[ASM-CLI] Run: source ${detected.profilePath} (or open a new terminal)`); + process.env.PATH = `${installed.userBin}:${process.env.PATH || ''}`; + } else { + log(`[ASM-CLI] To enable 'asm' in future shells, add this line to ${detected.profilePath}:`); + log(` export PATH=\"${installed.userBin}:$PATH\"`); + } + } + const verify = createShellRunner()('bash', ['-lc', `"${installed.launcherPath}" --help`]); + if (!verify.ok) { + return { ok: false, step: 'verify-cli-launcher', details: { stdout: verify.stdout, stderr: verify.stderr, launcherPath: installed.launcherPath } }; + } + log('[ASM-CLI] asm launcher verified successfully.'); + log('[ASM-CLI] Next steps:'); + log(' 1) asm install openclaw'); + log(' 2) asm install opencode'); + log(' 3) asm install paperclip'); + return { ok: true, step: 'install-cli', details: installed }; } export async function runSetupOpenClawFlow({ diff --git a/package.json b/package.json index 6c3ad3b..b7f7375 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.8", + "version": "5.1.10", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", From dd8f7aa0cd2f05beb02d1bc9a6d73390ffc944a1 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 13:40:27 +0700 Subject: [PATCH 11/24] docs: clarify supported install flows --- README.md | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b242a21..77baf4d 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,17 @@ asm install opencode ## 3) Install ASM -### Global install +There are currently **two supported install flows**. + +### Flow A — CLI-first (recommended for ASM CLI usage) +Install the CLI globally first: + ```bash npm install -g @mrc2204/agent-smart-memo ``` -### Initialize shared ASM config +Then initialize shared config: + ```bash asm init-setup --yes ``` @@ -106,19 +111,32 @@ This creates or updates: ~/.config/asm/config.json ``` -### Install into a platform +Then install a runtime target: + ```bash asm install openclaw asm install paperclip asm install opencode ``` -Legacy compatibility still exists for older OpenClaw flows, but the preferred path is now: +### Flow B — Plugin-first (direct OpenClaw plugin install) +If you only want the OpenClaw plugin directly, install it through OpenClaw: -```text -asm init-setup -> asm install +```bash +openclaw plugins install @mrc2204/agent-smart-memo ``` +Then continue with OpenClaw-side config/bootstrap as needed. + +### Important note +The command below is **not the recommended primary flow right now**: + +```bash +npx @mrc2204/agent-smart-memo install +``` + +Use the two supported flows above until CLI bootstrap is fully separated/standardized. + --- ## 4) Shared config source-of-truth @@ -177,13 +195,18 @@ This keeps `openclaw.json` from becoming a second core source-of-truth. ## 5) OpenClaw quick start -### Install from npm +### Install from npm (CLI-first) ```bash npm install -g @mrc2204/agent-smart-memo asm init-setup --yes asm install openclaw --yes ``` +### Install plugin directly into OpenClaw (plugin-first) +```bash +openclaw plugins install @mrc2204/agent-smart-memo +``` + ### Install locally from source ```bash npm install From e1cb45813d7c45a82814e867ad320009ffb36e82 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 13:43:53 +0700 Subject: [PATCH 12/24] release: 5.1.11 clarify install flows and improve CLI bootstrap --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b7f7375..61f9400 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.10", + "version": "5.1.11", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", From e6debe8661d2f592cc66c173a8c7174d6a13ee0d Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 14:23:15 +0700 Subject: [PATCH 13/24] fix: prioritize asmConfigPath over hardcoded runtime defaults --- bin/asm.mjs | 49 +++++++++++---------------- package.json | 2 +- src/index.ts | 54 ++++++++++++------------------ src/shared/asm-config.ts | 71 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 63 deletions(-) diff --git a/bin/asm.mjs b/bin/asm.mjs index 07419b9..1cb59cc 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { runInitOpenClaw } from "../scripts/init-openclaw.mjs"; import { createShellRunner, runInitSetupFlow, runInstallPlatformFlow } from "../dist/cli/platform-installers.js"; import { runOpencodeMcpServer } from "./opencode-mcp-server.mjs"; +import { resolveAsmRuntimeConfig } from "../dist/shared/asm-config.js"; const ASM_PLUGIN_PACKAGE = "@mrc2204/agent-smart-memo"; const ASM_PLUGIN_ID = "agent-smart-memo"; @@ -386,39 +387,29 @@ export async function main(argv = process.argv.slice(2)) { console.error('[ASM-87] project-event requires --project-id and --repo-root'); return 1; } - const pluginId = 'agent-smart-memo'; - const cfgPath = resolve(process.env.HOME || '', '.openclaw', 'openclaw.json'); - let qdrantCollection = 'mrc_bot'; - let llmBaseUrl = 'http://localhost:8317/v1'; - let llmApiKey = 'proxypal-local'; - let llmModel = 'gpt-5.4'; - let embedModel = 'qwen3-embedding:0.6b'; - let embedDimensions = 1024; - let slotDbDir = resolve(process.env.HOME || '', '.openclaw', 'agent-memo'); - try { - const raw = JSON.parse(readFileSync(cfgPath, 'utf8')); - const cfg = raw?.plugins?.entries?.[pluginId]?.config || {}; - qdrantCollection = cfg.qdrantCollection || qdrantCollection; - llmBaseUrl = cfg.llmBaseUrl || llmBaseUrl; - llmApiKey = cfg.llmApiKey || llmApiKey; - llmModel = cfg.llmModel || llmModel; - embedModel = cfg.embedModel || embedModel; - embedDimensions = cfg.embedDimensions || embedDimensions; - slotDbDir = cfg.slotDbDir || slotDbDir; - } catch {} - - process.env.OPENCLAW_SLOTDB_DIR = slotDbDir; + const runtime = resolveAsmRuntimeConfig({ env: process.env, homeDir: process.env.HOME }); + + process.env.OPENCLAW_SLOTDB_DIR = runtime.slotDbDir; process.env.AGENT_MEMO_PROJECT_WORKSPACE_ROOT = event.repoRoot; process.env.AGENT_MEMO_REPO_CLONE_ROOT = event.repoRoot; process.env.PROJECT_WORKSPACE_ROOT = event.repoRoot; process.env.REPO_CLONE_ROOT = event.repoRoot; - process.env.QDRANT_COLLECTION = qdrantCollection; - process.env.LLM_BASE_URL = llmBaseUrl; - process.env.LLM_API_KEY = llmApiKey; - process.env.LLM_MODEL = llmModel; - process.env.EMBED_MODEL = embedModel; - process.env.EMBEDDING_MODEL = embedModel; - process.env.EMBEDDING_DIMENSIONS = String(embedDimensions); + process.env.QDRANT_COLLECTION = runtime.qdrantCollection; + process.env.LLM_BASE_URL = runtime.llmBaseUrl; + process.env.LLM_API_KEY = runtime.llmApiKey; + process.env.LLM_MODEL = runtime.llmModel; + process.env.EMBED_MODEL = runtime.embedModel; + process.env.EMBEDDING_MODEL = runtime.embedModel; + process.env.EMBEDDING_DIMENSIONS = String(runtime.embedDimensions); + process.env.AGENT_MEMO_QDRANT_HOST = runtime.qdrantHost; + process.env.AGENT_MEMO_QDRANT_PORT = String(runtime.qdrantPort); + process.env.AGENT_MEMO_QDRANT_COLLECTION = runtime.qdrantCollection; + process.env.AGENT_MEMO_QDRANT_VECTOR_SIZE = String(runtime.qdrantVectorSize); + process.env.AGENT_MEMO_EMBED_BASE_URL = runtime.embedBaseUrl; + process.env.AGENT_MEMO_EMBED_MODEL = runtime.embedModel; + process.env.AGENT_MEMO_EMBED_DIMENSIONS = String(runtime.embedDimensions); + + const slotDbDir = runtime.slotDbDir; const { SlotDB } = await import('../dist/db/slot-db.js'); const { DefaultMemoryUseCasePort } = await import('../dist/core/usecases/default-memory-usecase-port.js'); diff --git a/package.json b/package.json index 61f9400..e349007 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.11", + "version": "5.1.12", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index 42636a0..033fb9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveSlotDbDir } from "./shared/slotdb-path.js"; -import { getAsmSharedConfig, resolveAsmCoreProjectWorkspaceRoot, resolveAsmCoreSlotDbDir } from "./shared/asm-config.js"; +import { resolveAsmRuntimeConfig } from "./shared/asm-config.js"; // Tool modules import { registerSlotTools } from "./tools/slot-tools.js"; @@ -289,39 +289,27 @@ const agentMemoPlugin = { const slotCategories = DEFAULT_CATEGORIES; const asmConfigPath = firstNonEmptyString(config.asmConfigPath); - const shared = getAsmSharedConfig({ configPath: asmConfigPath, env: process.env, homeDir: process.env.HOME }).config || {}; - const sharedCore = (shared.core || {}) as Record; - const qdrantHost = firstNonEmptyString(sharedCore.qdrantHost) || "localhost"; - const qdrantPort = Number(sharedCore.qdrantPort) || 6333; - const qdrantCollection = firstNonEmptyString(sharedCore.qdrantCollection) || "mrc_bot"; - const qdrantVectorSize = Number(sharedCore.qdrantVectorSize) || 1024; - const llmBaseUrl = firstNonEmptyString(sharedCore.llmBaseUrl) || "http://localhost:8317/v1"; - const llmApiKey = firstNonEmptyString(sharedCore.llmApiKey) || "proxypal-local"; - const resolvedLlmModel = firstNonEmptyString(sharedCore.llmModel, findNestedStringKey(rawConfig, "llmModel")); - const llmModel = resolvedLlmModel || "gemini-2.5-flash"; - const llmModelFallbackUsed = !resolvedLlmModel; - const embedBaseUrl = firstNonEmptyString(sharedCore.embedBaseUrl) || "http://localhost:11434"; - const sharedEmbedBackend = firstNonEmptyString(sharedCore.embedBackend); - const embedBackend = - sharedEmbedBackend === "ollama" || sharedEmbedBackend === "openai" || sharedEmbedBackend === "docker" - ? sharedEmbedBackend as EmbedBackend - : undefined; - const embedModel = firstNonEmptyString(sharedCore.embedModel) || "qwen3-embedding:0.6b"; - const embedDimensions = Number(sharedCore.embedDimensions) || 1024; - const autoCaptureEnabled = sharedCore.autoCaptureEnabled ?? true; - const autoCaptureMinConfidence = Number(sharedCore.autoCaptureMinConfidence) || 0.7; - const contextWindowMaxTokens = Number(sharedCore.contextWindowMaxTokens) || 12000; - const summarizeEveryActions = Number(sharedCore.summarizeEveryActions) || 6; - const projectWorkspaceRoot = resolveAsmCoreProjectWorkspaceRoot({ configPath: asmConfigPath, env: process.env, homeDir: process.env.HOME }); - - // State directory from env or default + const runtime = resolveAsmRuntimeConfig({ configPath: asmConfigPath, env: process.env, homeDir: process.env.HOME }); + const qdrantHost = runtime.qdrantHost; + const qdrantPort = runtime.qdrantPort; + const qdrantCollection = runtime.qdrantCollection; + const qdrantVectorSize = runtime.qdrantVectorSize; + const llmBaseUrl = runtime.llmBaseUrl; + const llmApiKey = runtime.llmApiKey; + const llmModel = runtime.llmModel; + const llmModelFallbackUsed = false; + const embedBaseUrl = runtime.embedBaseUrl; + const embedBackend = runtime.embedBackend as EmbedBackend | undefined; + const embedModel = runtime.embedModel; + const embedDimensions = runtime.embedDimensions; + const autoCaptureEnabled = runtime.autoCaptureEnabled; + const autoCaptureMinConfidence = runtime.autoCaptureMinConfidence; + const contextWindowMaxTokens = runtime.contextWindowMaxTokens; + const summarizeEveryActions = runtime.summarizeEveryActions; + const projectWorkspaceRoot = runtime.projectWorkspaceRoot; + const stateDir = process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`; - const slotDbDir = resolveSlotDbDir({ - stateDir, - slotDbDir: resolveAsmCoreSlotDbDir({ configPath: asmConfigPath, env: process.env, homeDir: process.env.HOME }), - env: process.env, - homeDir: process.env.HOME, - }); + const slotDbDir = runtime.slotDbDir; if (projectWorkspaceRoot) { process.env.AGENT_MEMO_PROJECT_WORKSPACE_ROOT = projectWorkspaceRoot; diff --git a/src/shared/asm-config.ts b/src/shared/asm-config.ts index e13b79b..3a3da46 100644 --- a/src/shared/asm-config.ts +++ b/src/shared/asm-config.ts @@ -64,6 +64,27 @@ export interface LoadAsmSharedConfigResult { }; } +export interface AsmResolvedRuntimeConfig { + asmConfigPath: string; + projectWorkspaceRoot: string; + qdrantHost: string; + qdrantPort: number; + qdrantCollection: string; + qdrantVectorSize: number; + llmBaseUrl: string; + llmApiKey: string; + llmModel: string; + embedBaseUrl: string; + embedBackend?: "ollama" | "openai" | "docker"; + embedModel: string; + embedDimensions: number; + autoCaptureEnabled: boolean; + autoCaptureMinConfidence: number; + contextWindowMaxTokens: number; + summarizeEveryActions: number; + slotDbDir: string; +} + export interface AsmSharedConfigDoctorResult { path: string; source: "explicit" | "env" | "default"; @@ -360,6 +381,56 @@ export function getAsmSharedConfig(input: LoadAsmSharedConfigInput = {}): LoadAs return loadAsmSharedConfig(input); } +function requireString(value: unknown, field: string): string { + if (typeof value === "string" && value.trim()) return value.trim(); + throw new Error(`ASM shared config missing required field: core.${field}`); +} + +function requireNumber(value: unknown, field: string): number { + const n = Number(value); + if (Number.isFinite(n)) return n; + throw new Error(`ASM shared config missing/invalid required field: core.${field}`); +} + +function requireBoolean(value: unknown, field: string): boolean { + if (typeof value === "boolean") return value; + throw new Error(`ASM shared config missing/invalid required field: core.${field}`); +} + +export function resolveAsmRuntimeConfig(input: LoadAsmSharedConfigInput = {}): AsmResolvedRuntimeConfig { + const path = resolveAsmConfigPath(input); + const loaded = loadAsmSharedConfig(input); + if (!loaded.config || !loaded.config.core) { + throw new Error(`ASM shared config not loaded or missing core at: ${path}`); + } + const core = loaded.config.core as Record; + const embedBackend = firstNonEmptyString(core.embedBackend); + const slotDbDir = resolveAsmCoreSlotDbDir(input); + const projectWorkspaceRoot = resolveAsmCoreProjectWorkspaceRoot(input); + if (!slotDbDir) throw new Error(`ASM shared config missing required field: core.storage.slotDbDir at ${path}`); + if (!projectWorkspaceRoot) throw new Error(`ASM shared config missing required field: core.projectWorkspaceRoot at ${path}`); + return { + asmConfigPath: path, + projectWorkspaceRoot, + qdrantHost: requireString(core.qdrantHost, 'qdrantHost'), + qdrantPort: requireNumber(core.qdrantPort, 'qdrantPort'), + qdrantCollection: requireString(core.qdrantCollection, 'qdrantCollection'), + qdrantVectorSize: requireNumber(core.qdrantVectorSize, 'qdrantVectorSize'), + llmBaseUrl: requireString(core.llmBaseUrl, 'llmBaseUrl'), + llmApiKey: requireString(core.llmApiKey, 'llmApiKey'), + llmModel: requireString(core.llmModel, 'llmModel'), + embedBaseUrl: requireString(core.embedBaseUrl, 'embedBaseUrl'), + embedBackend: embedBackend === 'ollama' || embedBackend === 'openai' || embedBackend === 'docker' ? embedBackend : undefined, + embedModel: requireString(core.embedModel, 'embedModel'), + embedDimensions: requireNumber(core.embedDimensions, 'embedDimensions'), + autoCaptureEnabled: requireBoolean(core.autoCaptureEnabled, 'autoCaptureEnabled'), + autoCaptureMinConfidence: requireNumber(core.autoCaptureMinConfidence, 'autoCaptureMinConfidence'), + contextWindowMaxTokens: requireNumber(core.contextWindowMaxTokens, 'contextWindowMaxTokens'), + summarizeEveryActions: requireNumber(core.summarizeEveryActions, 'summarizeEveryActions'), + slotDbDir, + }; +} + export function doctorAsmSharedConfig(input: LoadAsmSharedConfigInput = {}): AsmSharedConfigDoctorResult { const pathInfo = resolveAsmConfigPathInfo(input); const loaded = loadAsmSharedConfig(input); From 2467273e5e63d6917586f7625a7873a85e8e7767 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 14:30:24 +0700 Subject: [PATCH 14/24] fix: stop runtime defaults from masking asm shared config --- package.json | 2 +- scripts/init-openclaw.mjs | 26 ++++++++++---------------- src/adapters/paperclip/runtime.ts | 20 +++++++++++--------- src/hooks/auto-capture.ts | 6 +++--- 4 files changed, 25 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index e349007..dbd2661 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.12", + "version": "5.1.13", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "type": "module", "main": "dist/index.js", diff --git a/scripts/init-openclaw.mjs b/scripts/init-openclaw.mjs index f88278c..8f763b5 100644 --- a/scripts/init-openclaw.mjs +++ b/scripts/init-openclaw.mjs @@ -633,23 +633,17 @@ export async function runInitOpenClaw({ env = process.env, interactive = true, a const sharedCore = asObj(shared.core); const defaults = { - qdrantHost: String(sharedCore.qdrantHost || "localhost"), - qdrantPort: toIntOrDefault(sharedCore.qdrantPort, 6333), - qdrantCollection: String(sharedCore.qdrantCollection || "mrc_bot"), - llmBaseUrl: String(sharedCore.llmBaseUrl || "http://localhost:8317/v1"), - llmModel: String(sharedCore.llmModel || "gemini-2.5-flash"), + qdrantHost: String(sharedCore.qdrantHost || ""), + qdrantPort: toIntOrDefault(sharedCore.qdrantPort, 0), + qdrantCollection: String(sharedCore.qdrantCollection || ""), + llmBaseUrl: String(sharedCore.llmBaseUrl || ""), + llmModel: String(sharedCore.llmModel || ""), llmApiKey: String(sharedCore.llmApiKey || ""), - embedBackend: String(sharedCore.embedBackend || "ollama"), - embedModel: String(sharedCore.embedModel || "qwen3-embedding:0.6b"), - embedDimensions: toIntOrDefault(sharedCore.embedDimensions, 1024), - slotDbDir: String(sharedCore.storage?.slotDbDir || env.OPENCLAW_SLOTDB_DIR || `${env.HOME}/.openclaw/agent-memo`), - projectWorkspaceRoot: String( - sharedCore.projectWorkspaceRoot || - env.AGENT_MEMO_PROJECT_WORKSPACE_ROOT || - env.AGENT_MEMO_REPO_CLONE_ROOT || - `${env.HOME}/Work/projects` || - `${env.HOME}/.openclaw/workspace/projects`, - ), + embedBackend: String(sharedCore.embedBackend || ""), + embedModel: String(sharedCore.embedModel || ""), + embedDimensions: toIntOrDefault(sharedCore.embedDimensions, 0), + slotDbDir: String(sharedCore.storage?.slotDbDir || ""), + projectWorkspaceRoot: String(sharedCore.projectWorkspaceRoot || ""), asmConfigPath, mapMemorySlot: asObj(asObj(current.plugins).slots).memory === PLUGIN_ID, telegramOnboardingCommands: dedupeStringArray([ diff --git a/src/adapters/paperclip/runtime.ts b/src/adapters/paperclip/runtime.ts index 898811f..d2925c9 100644 --- a/src/adapters/paperclip/runtime.ts +++ b/src/adapters/paperclip/runtime.ts @@ -6,6 +6,7 @@ import { QdrantClient } from "../../services/qdrant.js"; import { EmbeddingClient } from "../../services/embedding.js"; import { DeduplicationService } from "../../services/dedupe.js"; import { SemanticMemoryUseCase } from "../../core/usecases/semantic-memory-usecase.js"; +import { resolveAsmRuntimeConfig } from "../../shared/asm-config.js"; export interface PaperclipRuntimeOptions { stateDir?: string; @@ -29,10 +30,11 @@ export interface PaperclipRuntime { } export function createPaperclipRuntime(options?: PaperclipRuntimeOptions): PaperclipRuntime { + const runtime = resolveAsmRuntimeConfig({ env: process.env, homeDir: process.env.HOME }); const stateDir = options?.stateDir || process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`; const slotDbDir = resolveSlotDbDir({ stateDir, - slotDbDir: options?.slotDbDir, + slotDbDir: options?.slotDbDir || runtime.slotDbDir, env: process.env, homeDir: process.env.HOME, }); @@ -41,17 +43,17 @@ export function createPaperclipRuntime(options?: PaperclipRuntimeOptions): Paper const semanticUseCase = options?.semanticUseCase || (() => { const qdrant = new QdrantClient({ - host: options?.qdrantHost || process.env.AGENT_MEMO_QDRANT_HOST || "localhost", - port: Number(options?.qdrantPort || process.env.AGENT_MEMO_QDRANT_PORT || 6333), - collection: options?.qdrantCollection || process.env.AGENT_MEMO_QDRANT_COLLECTION || "mrc_bot", - vectorSize: Number(options?.qdrantVectorSize || process.env.AGENT_MEMO_QDRANT_VECTOR_SIZE || 1024), + host: options?.qdrantHost || process.env.AGENT_MEMO_QDRANT_HOST || runtime.qdrantHost, + port: Number(options?.qdrantPort || process.env.AGENT_MEMO_QDRANT_PORT || runtime.qdrantPort), + collection: options?.qdrantCollection || process.env.AGENT_MEMO_QDRANT_COLLECTION || runtime.qdrantCollection, + vectorSize: Number(options?.qdrantVectorSize || process.env.AGENT_MEMO_QDRANT_VECTOR_SIZE || runtime.qdrantVectorSize), }); const embedding = new EmbeddingClient({ - embeddingApiUrl: options?.embedBaseUrl || process.env.AGENT_MEMO_EMBED_BASE_URL || "http://localhost:11434", - backend: options?.embedBackend, - model: options?.embedModel || process.env.AGENT_MEMO_EMBED_MODEL || "qwen3-embedding:0.6b", - dimensions: Number(options?.embedDimensions || process.env.AGENT_MEMO_EMBED_DIMENSIONS || 1024), + embeddingApiUrl: options?.embedBaseUrl || process.env.AGENT_MEMO_EMBED_BASE_URL || runtime.embedBaseUrl, + backend: options?.embedBackend || runtime.embedBackend, + model: options?.embedModel || process.env.AGENT_MEMO_EMBED_MODEL || runtime.embedModel, + dimensions: Number(options?.embedDimensions || process.env.AGENT_MEMO_EMBED_DIMENSIONS || runtime.embedDimensions), stateDir, }); diff --git a/src/hooks/auto-capture.ts b/src/hooks/auto-capture.ts index 5b79962..53e8031 100644 --- a/src/hooks/auto-capture.ts +++ b/src/hooks/auto-capture.ts @@ -33,9 +33,9 @@ const DEFAULT_CONFIG: AutoCaptureConfig = { enabled: true, minConfidence: 0.7, useLLM: true, - llmBaseUrl: "http://localhost:8317/v1", - llmApiKey: "proxypal-local", - llmModel: "gemini-2.5-flash", + llmBaseUrl: "", + llmApiKey: "", + llmModel: "", summarizeEveryActions: 6, }; From 6241ca3dfca6b2ac12549243527c5f32ca50513e Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 15:15:24 +0700 Subject: [PATCH 15/24] fix: finalize ASM config-boundary and installer cleanup --- bin/opencode-mcp-server.mjs | 8 +- src/cli/platform-installers.ts | 895 ++++++++++++++++++------------ src/config.ts | 133 +---- src/index.ts | 914 ++++++++++++++++++------------- tests/test-opencode-mcp-stdio.ts | 319 ++++++----- 5 files changed, 1293 insertions(+), 976 deletions(-) diff --git a/bin/opencode-mcp-server.mjs b/bin/opencode-mcp-server.mjs index e8f089d..ff05b13 100644 --- a/bin/opencode-mcp-server.mjs +++ b/bin/opencode-mcp-server.mjs @@ -73,13 +73,11 @@ let usecasePortPromise = null; async function getUseCasePort() { if (!usecasePortPromise) { usecasePortPromise = (async () => { - const { resolveAsmCoreSlotDbDir } = await import(new URL("../dist/shared/asm-config.js", import.meta.url)); + const { resolveAsmRuntimeConfig } = await import(new URL("../dist/shared/asm-config.js", import.meta.url)); const { SlotDB } = await import(new URL("../dist/db/slot-db.js", import.meta.url)); const { DefaultMemoryUseCasePort } = await import(new URL("../dist/core/usecases/default-memory-usecase-port.js", import.meta.url)); - const slotDbDir = - resolveAsmCoreSlotDbDir({ env: process.env, homeDir: process.env.HOME }) || - process.env.OPENCLAW_SLOTDB_DIR || - `${process.env.HOME}/.openclaw/agent-memo`; + const runtimeConfig = resolveAsmRuntimeConfig({ env: process.env, homeDir: process.env.HOME }); + const slotDbDir = runtimeConfig.slotDbDir; const db = new SlotDB(slotDbDir); return { db, usecase: new DefaultMemoryUseCasePort(db) }; })(); diff --git a/src/cli/platform-installers.ts b/src/cli/platform-installers.ts index 7efd23d..1e59189 100644 --- a/src/cli/platform-installers.ts +++ b/src/cli/platform-installers.ts @@ -2,433 +2,618 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { doctorAsmSharedConfig, getAsmSharedConfig, resolveAsmConfigPath } from "../shared/asm-config.js"; -import { runInitOpenClaw } from "../../scripts/init-openclaw.mjs"; +import type { runInitOpenClaw } from "../../scripts/init-openclaw.mjs"; +import { + doctorAsmSharedConfig, + getAsmSharedConfig, + resolveAsmConfigPath, +} from "../shared/asm-config.js"; export interface AsmShellResult { - ok: boolean; - code: number; - stdout: string; - stderr: string; - error: string; + ok: boolean; + code: number; + stdout: string; + stderr: string; + error: string; } -export type AsmShellRunner = (command: string, args?: string[]) => AsmShellResult; +export type AsmShellRunner = ( + command: string, + args?: string[], +) => AsmShellResult; export interface AsmInstallContext { - runner: AsmShellRunner; - log: (line: string) => void; - argv: string[]; - env: NodeJS.ProcessEnv; - homeDir?: string; - initOpenClaw: typeof runInitOpenClaw; + runner: AsmShellRunner; + log: (line: string) => void; + argv: string[]; + env: NodeJS.ProcessEnv; + homeDir?: string; + initOpenClaw: typeof runInitOpenClaw; } export interface AsmInstallerDescriptor { - id: string; - displayName: string; - status: "implemented" | "planned"; - summary: string; - requiredSharedConfigKeys: string[]; - platformLocalConfigPaths: string[]; + id: string; + displayName: string; + status: "implemented" | "planned"; + summary: string; + requiredSharedConfigKeys: string[]; + platformLocalConfigPaths: string[]; } export interface AsmInstallerResult { - ok: boolean; - step: string; - platform: string; - details?: Record; + ok: boolean; + step: string; + platform: string; + details?: Record; } export interface AsmPlatformInstaller { - id: string; - describe(): AsmInstallerDescriptor; - install(ctx: AsmInstallContext): Promise; + id: string; + describe(): AsmInstallerDescriptor; + install(ctx: AsmInstallContext): Promise; } function text(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; + return typeof value === "string" ? value.trim() : ""; } function includesAsmPlugin(output: unknown): boolean { - const haystack = String(output || "").toLowerCase(); - return haystack.includes("agent-smart-memo") || haystack.includes("@mrc2204/agent-smart-memo"); + const haystack = String(output || "").toLowerCase(); + return ( + haystack.includes("agent-smart-memo") || + haystack.includes("@mrc2204/agent-smart-memo") + ); } export function createShellRunner(spawnImpl = spawnSync): AsmShellRunner { - return (command, args = []) => { - const result = spawnImpl(command, args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - env: process.env, - }); - - return { - ok: result.status === 0 && !result.error, - code: result.status ?? (result.error ? 1 : 0), - stdout: text(result.stdout), - stderr: text(result.stderr), - error: result.error ? String(result.error.message || result.error) : "", - }; - }; + return (command, args = []) => { + const result = spawnImpl(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + + return { + ok: result.status === 0 && !result.error, + code: result.status ?? (result.error ? 1 : 0), + stdout: text(result.stdout), + stderr: text(result.stderr), + error: result.error ? String(result.error.message || result.error) : "", + }; + }; } -function parseNonInteractive(argv: string[] = []): { nonInteractive: boolean; autoApply: boolean } { - const args = Array.isArray(argv) ? argv.map((item) => String(item).trim()).filter(Boolean) : []; - const enabled = args.includes("--yes") || args.includes("-y") || args.includes("--non-interactive"); - return { nonInteractive: enabled, autoApply: enabled }; +function parseNonInteractive(argv: string[] = []): { + nonInteractive: boolean; + autoApply: boolean; +} { + const args = Array.isArray(argv) + ? argv.map((item) => String(item).trim()).filter(Boolean) + : []; + const enabled = + args.includes("--yes") || + args.includes("-y") || + args.includes("--non-interactive"); + return { nonInteractive: enabled, autoApply: enabled }; } export async function runInitSetupFlow({ - log = console.log, - env = process.env, - homeDir = process.env.HOME, - argv = [], + log = console.log, + env = process.env, + homeDir = process.env.HOME, + argv = [], }: { - log?: (line: string) => void; - env?: NodeJS.ProcessEnv; - homeDir?: string; - argv?: string[]; + log?: (line: string) => void; + env?: NodeJS.ProcessEnv; + homeDir?: string; + argv?: string[]; } = {}) { - const mode = parseNonInteractive(argv); - const path = resolveAsmConfigPath({ env, homeDir }); - const doctor = doctorAsmSharedConfig({ env, homeDir }); - const loaded = getAsmSharedConfig({ env, homeDir }); - - const baseConfig = loaded.config || { schemaVersion: 1, core: {}, adapters: {} }; - const nextConfig = { - schemaVersion: typeof baseConfig.schemaVersion === "number" ? baseConfig.schemaVersion : 1, - ...baseConfig, - core: { - ...(baseConfig.core || {}), - projectWorkspaceRoot: baseConfig.core?.projectWorkspaceRoot || "~/Work/projects", - qdrantHost: baseConfig.core?.qdrantHost || "localhost", - qdrantPort: baseConfig.core?.qdrantPort || 6333, - qdrantCollection: baseConfig.core?.qdrantCollection || "mrc_bot", - qdrantVectorSize: baseConfig.core?.qdrantVectorSize || 1024, - llmBaseUrl: baseConfig.core?.llmBaseUrl || "http://localhost:8317/v1", - llmApiKey: baseConfig.core?.llmApiKey || "proxypal-local", - llmModel: baseConfig.core?.llmModel || "gpt-5.4", - embedBaseUrl: baseConfig.core?.embedBaseUrl || "http://localhost:11434", - embedBackend: baseConfig.core?.embedBackend || "ollama", - embedModel: baseConfig.core?.embedModel || "qwen3-embedding:0.6b", - embedDimensions: baseConfig.core?.embedDimensions || 1024, - autoCaptureEnabled: baseConfig.core?.autoCaptureEnabled ?? true, - autoCaptureMinConfidence: baseConfig.core?.autoCaptureMinConfidence || 0.7, - contextWindowMaxTokens: baseConfig.core?.contextWindowMaxTokens || 32000, - summarizeEveryActions: baseConfig.core?.summarizeEveryActions || 6, - storage: { - ...(baseConfig.core?.storage || {}), - slotDbDir: baseConfig.core?.storage?.slotDbDir || "~/.local/share/asm/slotdb", - }, - }, - adapters: { - ...(baseConfig.adapters || {}), - openclaw: { - enabled: true, - ...((baseConfig.adapters || {}).openclaw || {}), - }, - paperclip: { - enabled: true, - ...((baseConfig.adapters || {}).paperclip || {}), - }, - opencode: { - enabled: true, - mode: "read-only", - ...((baseConfig.adapters || {}).opencode || {}), - }, - }, - }; - - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8"); - log(`[ASM-104] init-setup ${doctor.exists ? "updated" : "created"} shared config at: ${path}`); - - return { - ok: true, - step: "init-setup", - path, - existed: doctor.exists, - nonInteractive: mode.nonInteractive, - }; + const mode = parseNonInteractive(argv); + const path = resolveAsmConfigPath({ env, homeDir }); + const doctor = doctorAsmSharedConfig({ env, homeDir }); + const loaded = getAsmSharedConfig({ env, homeDir }); + + // Bootstrap-only defaults for first-time setup. + // These values seed shared config and must not be treated as runtime fallback defaults. + const bootstrapDefaults = { + projectWorkspaceRoot: "~/Work/projects", + qdrantHost: "localhost", + qdrantPort: 6333, + qdrantCollection: "mrc_bot", + qdrantVectorSize: 1024, + llmBaseUrl: "http://localhost:8317/v1", + llmApiKey: "proxypal-local", + llmModel: "gpt-5.4", + embedBaseUrl: "http://localhost:11434", + embedBackend: "ollama", + embedModel: "qwen3-embedding:0.6b", + embedDimensions: 1024, + autoCaptureEnabled: true, + autoCaptureMinConfidence: 0.7, + contextWindowMaxTokens: 32000, + summarizeEveryActions: 6, + slotDbDir: "~/.local/share/asm/slotdb", + } as const; + + const baseConfig = loaded.config || { + schemaVersion: 1, + core: {}, + adapters: {}, + }; + const nextConfig = { + schemaVersion: + typeof baseConfig.schemaVersion === "number" + ? baseConfig.schemaVersion + : 1, + ...baseConfig, + core: { + ...(baseConfig.core || {}), + projectWorkspaceRoot: + baseConfig.core?.projectWorkspaceRoot || + bootstrapDefaults.projectWorkspaceRoot, + qdrantHost: baseConfig.core?.qdrantHost || bootstrapDefaults.qdrantHost, + qdrantPort: baseConfig.core?.qdrantPort || bootstrapDefaults.qdrantPort, + qdrantCollection: + baseConfig.core?.qdrantCollection || bootstrapDefaults.qdrantCollection, + qdrantVectorSize: + baseConfig.core?.qdrantVectorSize || bootstrapDefaults.qdrantVectorSize, + llmBaseUrl: baseConfig.core?.llmBaseUrl || bootstrapDefaults.llmBaseUrl, + llmApiKey: baseConfig.core?.llmApiKey || bootstrapDefaults.llmApiKey, + llmModel: baseConfig.core?.llmModel || bootstrapDefaults.llmModel, + embedBaseUrl: + baseConfig.core?.embedBaseUrl || bootstrapDefaults.embedBaseUrl, + embedBackend: + baseConfig.core?.embedBackend || bootstrapDefaults.embedBackend, + embedModel: baseConfig.core?.embedModel || bootstrapDefaults.embedModel, + embedDimensions: + baseConfig.core?.embedDimensions || bootstrapDefaults.embedDimensions, + autoCaptureEnabled: + baseConfig.core?.autoCaptureEnabled ?? + bootstrapDefaults.autoCaptureEnabled, + autoCaptureMinConfidence: + baseConfig.core?.autoCaptureMinConfidence || + bootstrapDefaults.autoCaptureMinConfidence, + contextWindowMaxTokens: + baseConfig.core?.contextWindowMaxTokens || + bootstrapDefaults.contextWindowMaxTokens, + summarizeEveryActions: + baseConfig.core?.summarizeEveryActions || + bootstrapDefaults.summarizeEveryActions, + storage: { + ...(baseConfig.core?.storage || {}), + slotDbDir: + baseConfig.core?.storage?.slotDbDir || bootstrapDefaults.slotDbDir, + }, + }, + adapters: { + ...(baseConfig.adapters || {}), + openclaw: { + enabled: true, + ...((baseConfig.adapters || {}).openclaw || {}), + }, + paperclip: { + enabled: true, + ...((baseConfig.adapters || {}).paperclip || {}), + }, + opencode: { + enabled: true, + mode: "read-only", + ...((baseConfig.adapters || {}).opencode || {}), + }, + }, + }; + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8"); + log( + `[ASM-104] init-setup ${doctor.exists ? "updated" : "created"} shared config at: ${path}`, + ); + log( + "[ASM-104] init-setup defaults are bootstrap-only; runtime must resolve from ASM shared config fields, not installer defaults.", + ); + + return { + ok: true, + step: "init-setup", + path, + existed: doctor.exists, + nonInteractive: mode.nonInteractive, + }; } -async function runSetupOpenClawInstall(ctx: AsmInstallContext): Promise { - const { runner, initOpenClaw, log, argv } = ctx; - log("[ASM-84] setup-openclaw: checking OpenClaw CLI ..."); - const openclawVersion = runner("openclaw", ["--version"]); - if (!openclawVersion.ok) { - log("[ASM-84] ❌ openclaw binary not found or not executable."); - if (openclawVersion.stderr) log(`[ASM-84] details: ${openclawVersion.stderr}`); - if (openclawVersion.error) log(`[ASM-84] error: ${openclawVersion.error}`); - return { ok: false, step: "check-openclaw", platform: "openclaw" }; - } - - log("[ASM-84] attempting direct plugin install/update: @mrc2204/agent-smart-memo"); - const install = runner("openclaw", ["plugins", "install", "@mrc2204/agent-smart-memo"]); - const installOutput = `${install.stdout || ""}\n${install.stderr || ""}`; - const installedLikeSuccess = install.ok || includesAsmPlugin(installOutput) || /already installed|already exists|linked plugin path|plugin install command completed|Config overwrite/i.test(installOutput); - - if (!installedLikeSuccess) { - log("[ASM-84] ❌ failed to install or verify plugin via OpenClaw CLI."); - if (install.stdout) log(install.stdout); - if (install.stderr) log(install.stderr); - return { ok: false, step: "install-plugin", platform: "openclaw" }; - } - - if (install.stdout) log(install.stdout); - if (install.stderr) log(install.stderr); - - const mode = parseNonInteractive(argv); - const initResult = await initOpenClaw({ interactive: !mode.nonInteractive, autoApply: mode.autoApply }); - return { - ok: true, - step: initResult?.applied ? "done" : "init-openclaw", - platform: "openclaw", - details: { applied: Boolean(initResult?.applied) }, - }; +async function runSetupOpenClawInstall( + ctx: AsmInstallContext, +): Promise { + const { runner, initOpenClaw, log, argv } = ctx; + log("[ASM-84] setup-openclaw: checking OpenClaw CLI ..."); + const openclawVersion = runner("openclaw", ["--version"]); + if (!openclawVersion.ok) { + log("[ASM-84] ❌ openclaw binary not found or not executable."); + if (openclawVersion.stderr) + log(`[ASM-84] details: ${openclawVersion.stderr}`); + if (openclawVersion.error) log(`[ASM-84] error: ${openclawVersion.error}`); + return { ok: false, step: "check-openclaw", platform: "openclaw" }; + } + + log( + "[ASM-84] attempting direct plugin install/update: @mrc2204/agent-smart-memo", + ); + const install = runner("openclaw", [ + "plugins", + "install", + "@mrc2204/agent-smart-memo", + ]); + const installOutput = `${install.stdout || ""}\n${install.stderr || ""}`; + const installedLikeSuccess = + install.ok || + includesAsmPlugin(installOutput) || + /already installed|already exists|linked plugin path|plugin install command completed|Config overwrite/i.test( + installOutput, + ); + + if (!installedLikeSuccess) { + log("[ASM-84] ❌ failed to install or verify plugin via OpenClaw CLI."); + if (install.stdout) log(install.stdout); + if (install.stderr) log(install.stderr); + return { ok: false, step: "install-plugin", platform: "openclaw" }; + } + + if (install.stdout) log(install.stdout); + if (install.stderr) log(install.stderr); + + const mode = parseNonInteractive(argv); + const initResult = await initOpenClaw({ + interactive: !mode.nonInteractive, + autoApply: mode.autoApply, + }); + return { + ok: true, + step: initResult?.applied ? "done" : "init-openclaw", + platform: "openclaw", + details: { applied: Boolean(initResult?.applied) }, + }; } function resolveOpencodeConfigPath(homeDir?: string): string { - const home = homeDir || process.env.HOME || process.cwd(); - return join(home, ".config", "opencode", "config.json"); + const home = homeDir || process.env.HOME || process.cwd(); + return join(home, ".config", "opencode", "config.json"); } function resolveAsmCliCommandForOpencode(): string[] { - const installerDir = dirname(fileURLToPath(import.meta.url)); - const asmCliPath = join(installerDir, "..", "..", "bin", "asm.mjs"); - return [process.execPath, asmCliPath, "mcp", "opencode"]; + const installerDir = dirname(fileURLToPath(import.meta.url)); + const asmCliPath = join(installerDir, "..", "..", "bin", "asm.mjs"); + return [process.execPath, asmCliPath, "mcp", "opencode"]; } function ensureOpencodeConfig( - opencodeConfigPath: string, - asmConfigPath: string, + opencodeConfigPath: string, + asmConfigPath: string, ): { existed: boolean; config: Record } { - const existed = existsSync(opencodeConfigPath); - let current: Record = {}; - if (existed) { - try { - const parsed = JSON.parse(readFileSync(opencodeConfigPath, "utf8")); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - current = parsed as Record; - } - } catch { - current = {}; - } - } - - const currentMcp = current.mcp && typeof current.mcp === "object" && !Array.isArray(current.mcp) - ? { ...(current.mcp as Record) } - : {}; - const legacyServers = currentMcp.servers && typeof currentMcp.servers === "object" && !Array.isArray(currentMcp.servers) - ? (currentMcp.servers as Record) - : {}; - delete currentMcp.servers; - - const next = { - ...current, - mcp: { - ...legacyServers, - ...currentMcp, - asm: { - type: "local", - command: resolveAsmCliCommandForOpencode(), - enabled: true, - environment: { - ASM_CONFIG: asmConfigPath, - ASM_MCP_AGENT_ID: "opencode", - }, - }, - }, - }; - - mkdirSync(dirname(opencodeConfigPath), { recursive: true }); - writeFileSync(opencodeConfigPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); - return { existed, config: next }; + const existed = existsSync(opencodeConfigPath); + let current: Record = {}; + if (existed) { + try { + const parsed = JSON.parse(readFileSync(opencodeConfigPath, "utf8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + current = parsed as Record; + } + } catch { + current = {}; + } + } + + const currentMcp = + current.mcp && + typeof current.mcp === "object" && + !Array.isArray(current.mcp) + ? { ...(current.mcp as Record) } + : {}; + const legacyServers = + currentMcp.servers && + typeof currentMcp.servers === "object" && + !Array.isArray(currentMcp.servers) + ? (currentMcp.servers as Record) + : {}; + delete currentMcp.servers; + + const next = { + ...current, + mcp: { + ...legacyServers, + ...currentMcp, + asm: { + type: "local", + command: resolveAsmCliCommandForOpencode(), + enabled: true, + environment: { + ASM_CONFIG: asmConfigPath, + ASM_MCP_AGENT_ID: "opencode", + }, + }, + }, + }; + + mkdirSync(dirname(opencodeConfigPath), { recursive: true }); + writeFileSync( + opencodeConfigPath, + `${JSON.stringify(next, null, 2)}\n`, + "utf8", + ); + return { existed, config: next }; } function createPlannedInstaller( - id: "paperclip" | "opencode", - summary: string, - requiredSharedConfigKeys: string[], - platformLocalConfigPaths: string[], - detailLines: string[], + id: "paperclip" | "opencode", + summary: string, + requiredSharedConfigKeys: string[], + platformLocalConfigPaths: string[], + detailLines: string[], ): AsmPlatformInstaller { - return { - id, - describe() { - return { id, displayName: id, status: "planned", summary, requiredSharedConfigKeys, platformLocalConfigPaths }; - }, - async install(ctx) { - ctx.log(`[ASM-104] install ${id} is not implemented yet.`); - for (const line of detailLines) ctx.log(line); - return { - ok: false, - step: `install-${id}-not-implemented`, - platform: id, - details: { requiredSharedConfigKeys, platformLocalConfigPaths }, - }; - }, - }; + return { + id, + describe() { + return { + id, + displayName: id, + status: "planned", + summary, + requiredSharedConfigKeys, + platformLocalConfigPaths, + }; + }, + async install(ctx) { + ctx.log(`[ASM-104] install ${id} is not implemented yet.`); + for (const line of detailLines) ctx.log(line); + return { + ok: false, + step: `install-${id}-not-implemented`, + platform: id, + details: { requiredSharedConfigKeys, platformLocalConfigPaths }, + }; + }, + }; } const openclawInstaller: AsmPlatformInstaller = { - id: "openclaw", - describe() { - return { - id: "openclaw", - displayName: "OpenClaw", - status: "implemented", - summary: "Installs ASM into OpenClaw and bootstraps a minimal openclaw.json entry that points back to ASM shared config.", - requiredSharedConfigKeys: ["core.projectWorkspaceRoot", "core.storage.slotDbDir", "core.qdrantHost", "core.llmBaseUrl", "core.embedModel", "adapters.openclaw.enabled"], - platformLocalConfigPaths: ["~/.openclaw/openclaw.json", "~/.config/asm/config.json"], - }; - }, - async install(ctx) { - return runSetupOpenClawInstall(ctx); - }, + id: "openclaw", + describe() { + return { + id: "openclaw", + displayName: "OpenClaw", + status: "implemented", + summary: + "Installs ASM into OpenClaw and bootstraps a minimal openclaw.json entry that points back to ASM shared config.", + requiredSharedConfigKeys: [ + "core.projectWorkspaceRoot", + "core.storage.slotDbDir", + "core.qdrantHost", + "core.llmBaseUrl", + "core.embedModel", + "adapters.openclaw.enabled", + ], + platformLocalConfigPaths: [ + "~/.openclaw/openclaw.json", + "~/.config/asm/config.json", + ], + }; + }, + async install(ctx) { + return runSetupOpenClawInstall(ctx); + }, }; function deriveRepoRoot(homeDir?: string): string { - return homeDir ? join(homeDir, "Work", "projects") : process.cwd(); + return homeDir ? join(homeDir, "Work", "projects") : process.cwd(); } -function packageArtifactExists(repoRoot: string, relativePath: string): string | null { - const full = join(repoRoot, relativePath); - return existsSync(full) ? full : null; +function packageArtifactExists( + repoRoot: string, + relativePath: string, +): string | null { + const full = join(repoRoot, relativePath); + return existsSync(full) ? full : null; } const paperclipInstaller: AsmPlatformInstaller = { - id: "paperclip", - describe() { - return { - id: "paperclip", - displayName: "Paperclip", - status: "implemented", - summary: "Prepare Paperclip runtime/plugin-local artifacts and print host install guidance using shared ASM config.", - requiredSharedConfigKeys: ["core.projectWorkspaceRoot", "core.storage.slotDbDir", "adapters.paperclip.enabled"], - platformLocalConfigPaths: ["artifacts/paperclip-plugin-local", "paperclip host/plugin config"], - }; - }, - async install(ctx) { - const initSetup = await runInitSetupFlow({ log: ctx.log, env: ctx.env, homeDir: ctx.homeDir, argv: ["--yes"] }); - const asmConfigPath = String(initSetup.path); - const repoRoot = deriveRepoRoot(ctx.homeDir ? undefined : undefined); - - const packageRuntime = ctx.runner("npm", ["run", "package:paperclip"]); - if (!packageRuntime.ok) { - return { ok: false, step: "package-paperclip-runtime-failed", platform: "paperclip", details: { stderr: packageRuntime.stderr, stdout: packageRuntime.stdout } }; - } - - const packageLocal = ctx.runner("npm", ["run", "package:paperclip:plugin-local"]); - if (!packageLocal.ok) { - return { ok: false, step: "package-paperclip-plugin-local-failed", platform: "paperclip", details: { stderr: packageLocal.stderr, stdout: packageLocal.stdout } }; - } - - const artifactDir = packageArtifactExists(process.cwd(), "artifacts/paperclip-plugin-local") || join(process.cwd(), "artifacts", "paperclip-plugin-local"); - const runtimeDir = packageArtifactExists(process.cwd(), "artifacts/npm/paperclip") || join(process.cwd(), "artifacts", "npm", "paperclip"); - - const installCommand = `paperclipai plugin install ${artifactDir}`; - ctx.log(`[ASM-104] install paperclip prepared local plugin artifact at: ${artifactDir}`); - ctx.log(`[ASM-104] install paperclip prepared runtime package at: ${runtimeDir}`); - ctx.log(`[ASM-104] Next step on Paperclip host: ${installCommand}`); - ctx.log(`[ASM-104] ASM shared config remains source-of-truth at: ${asmConfigPath}`); - - return { - ok: true, - step: "install-paperclip", - platform: "paperclip", - details: { - asmConfigPath, - artifactDir, - runtimeDir, - installCommand, - }, - }; - }, + id: "paperclip", + describe() { + return { + id: "paperclip", + displayName: "Paperclip", + status: "implemented", + summary: + "Prepare Paperclip runtime/plugin-local artifacts and print host install guidance using shared ASM config.", + requiredSharedConfigKeys: [ + "core.projectWorkspaceRoot", + "core.storage.slotDbDir", + "adapters.paperclip.enabled", + ], + platformLocalConfigPaths: [ + "artifacts/paperclip-plugin-local", + "paperclip host/plugin config", + ], + }; + }, + async install(ctx) { + const initSetup = await runInitSetupFlow({ + log: ctx.log, + env: ctx.env, + homeDir: ctx.homeDir, + argv: ["--yes"], + }); + const asmConfigPath = String(initSetup.path); + const repoRoot = deriveRepoRoot(ctx.homeDir ? undefined : undefined); + + const packageRuntime = ctx.runner("npm", ["run", "package:paperclip"]); + if (!packageRuntime.ok) { + return { + ok: false, + step: "package-paperclip-runtime-failed", + platform: "paperclip", + details: { + stderr: packageRuntime.stderr, + stdout: packageRuntime.stdout, + }, + }; + } + + const packageLocal = ctx.runner("npm", [ + "run", + "package:paperclip:plugin-local", + ]); + if (!packageLocal.ok) { + return { + ok: false, + step: "package-paperclip-plugin-local-failed", + platform: "paperclip", + details: { stderr: packageLocal.stderr, stdout: packageLocal.stdout }, + }; + } + + const artifactDir = + packageArtifactExists( + process.cwd(), + "artifacts/paperclip-plugin-local", + ) || join(process.cwd(), "artifacts", "paperclip-plugin-local"); + const runtimeDir = + packageArtifactExists(process.cwd(), "artifacts/npm/paperclip") || + join(process.cwd(), "artifacts", "npm", "paperclip"); + + const installCommand = `paperclipai plugin install ${artifactDir}`; + ctx.log( + `[ASM-104] install paperclip prepared local plugin artifact at: ${artifactDir}`, + ); + ctx.log( + `[ASM-104] install paperclip prepared runtime package at: ${runtimeDir}`, + ); + ctx.log(`[ASM-104] Next step on Paperclip host: ${installCommand}`); + ctx.log( + `[ASM-104] ASM shared config remains source-of-truth at: ${asmConfigPath}`, + ); + + return { + ok: true, + step: "install-paperclip", + platform: "paperclip", + details: { + asmConfigPath, + artifactDir, + runtimeDir, + installCommand, + }, + }; + }, }; const opencodeInstaller: AsmPlatformInstaller = { - id: "opencode", - describe() { - return { - id: "opencode", - displayName: "OpenCode", - status: "implemented", - summary: "Bootstrap OpenCode read-only/MCP integration using ASM shared config and ASM-106 retrieval contract.", - requiredSharedConfigKeys: ["core.projectWorkspaceRoot", "core.storage.slotDbDir", "adapters.opencode.enabled", "adapters.opencode.mode"], - platformLocalConfigPaths: ["~/.config/opencode/config.json"], - }; - }, - async install(ctx) { - const initSetup = await runInitSetupFlow({ log: ctx.log, env: ctx.env, homeDir: ctx.homeDir, argv: ["--yes"] }); - const asmConfigPath = String(initSetup.path); - const opencodeConfigPath = resolveOpencodeConfigPath(ctx.homeDir); - const ensured = ensureOpencodeConfig(opencodeConfigPath, asmConfigPath); - ctx.log(`[ASM-104] install opencode ${ensured.existed ? "updated" : "created"} config at: ${opencodeConfigPath}`); - ctx.log("[ASM-104] OpenCode MCP/read-only integration now points to ASM shared config and should use ASM-106 retrieval contract."); - return { - ok: true, - step: "install-opencode", - platform: "opencode", - details: { - asmConfigPath, - opencodeConfigPath, - existed: ensured.existed, - }, - }; - }, + id: "opencode", + describe() { + return { + id: "opencode", + displayName: "OpenCode", + status: "implemented", + summary: + "Bootstrap OpenCode read-only/MCP integration using ASM shared config and ASM-106 retrieval contract.", + requiredSharedConfigKeys: [ + "core.projectWorkspaceRoot", + "core.storage.slotDbDir", + "adapters.opencode.enabled", + "adapters.opencode.mode", + ], + platformLocalConfigPaths: ["~/.config/opencode/config.json"], + }; + }, + async install(ctx) { + const initSetup = await runInitSetupFlow({ + log: ctx.log, + env: ctx.env, + homeDir: ctx.homeDir, + argv: ["--yes"], + }); + const asmConfigPath = String(initSetup.path); + const opencodeConfigPath = resolveOpencodeConfigPath(ctx.homeDir); + const ensured = ensureOpencodeConfig(opencodeConfigPath, asmConfigPath); + ctx.log( + `[ASM-104] install opencode ${ensured.existed ? "updated" : "created"} config at: ${opencodeConfigPath}`, + ); + ctx.log( + "[ASM-104] OpenCode MCP/read-only integration now points to ASM shared config and should use ASM-106 retrieval contract.", + ); + return { + ok: true, + step: "install-opencode", + platform: "opencode", + details: { + asmConfigPath, + opencodeConfigPath, + existed: ensured.existed, + }, + }; + }, }; const REGISTRY = new Map([ - [openclawInstaller.id, openclawInstaller], - [paperclipInstaller.id, paperclipInstaller], - [opencodeInstaller.id, opencodeInstaller], + [openclawInstaller.id, openclawInstaller], + [paperclipInstaller.id, paperclipInstaller], + [opencodeInstaller.id, opencodeInstaller], ]); -export function getAsmPlatformInstaller(platform: string): AsmPlatformInstaller | null { - const normalized = String(platform || "").trim().toLowerCase(); - return REGISTRY.get(normalized) || null; +export function getAsmPlatformInstaller( + platform: string, +): AsmPlatformInstaller | null { + const normalized = String(platform || "") + .trim() + .toLowerCase(); + return REGISTRY.get(normalized) || null; } export function listAsmPlatformInstallers(): AsmInstallerDescriptor[] { - return Array.from(REGISTRY.values()).map((installer) => installer.describe()); + return Array.from(REGISTRY.values()).map((installer) => installer.describe()); } export async function runInstallPlatformFlow({ - platform, - runner, - initOpenClaw, - log, - argv, - env = process.env, - homeDir = process.env.HOME, + platform, + runner, + initOpenClaw, + log, + argv, + env = process.env, + homeDir = process.env.HOME, }: { - platform?: string; - runner: AsmShellRunner; - initOpenClaw: typeof runInitOpenClaw; - log: (line: string) => void; - argv: string[]; - env?: NodeJS.ProcessEnv; - homeDir?: string; + platform?: string; + runner: AsmShellRunner; + initOpenClaw: typeof runInitOpenClaw; + log: (line: string) => void; + argv: string[]; + env?: NodeJS.ProcessEnv; + homeDir?: string; }): Promise { - const installer = getAsmPlatformInstaller(String(platform || "")); - if (!installer) { - log(`[ASM-104] Unknown install target: ${String(platform || "(empty)").trim() || "(empty)"}`); - log(`[ASM-104] Supported install targets right now: ${listAsmPlatformInstallers().map((item) => item.id).join(" | ")}`); - return { - ok: false, - step: "unknown-install-target", - platform: String(platform || "").trim().toLowerCase() || "unknown", - }; - } - - return installer.install({ - runner, - initOpenClaw, - log, - argv, - env, - homeDir, - }); + const installer = getAsmPlatformInstaller(String(platform || "")); + if (!installer) { + log( + `[ASM-104] Unknown install target: ${String(platform || "(empty)").trim() || "(empty)"}`, + ); + log( + `[ASM-104] Supported install targets right now: ${listAsmPlatformInstallers() + .map((item) => item.id) + .join(" | ")}`, + ); + return { + ok: false, + step: "unknown-install-target", + platform: + String(platform || "") + .trim() + .toLowerCase() || "unknown", + }; + } + + return installer.install({ + runner, + initOpenClaw, + log, + argv, + env, + homeDir, + }); } diff --git a/src/config.ts b/src/config.ts index 5c5704d..d93ed24 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,122 +1,41 @@ /** - * Configuration loader for Agent-Memo Plugin - * Reads from environment variables or .env file + * @deprecated + * Legacy runtime config module is intentionally neutralized. + * Runtime configuration must come from ASM shared config via: + * - resolveAsmRuntimeConfig(...) in src/shared/asm-config.ts + * - resolveAsmCore* helpers for read-only access */ -import { config } from "dotenv"; -import { join } from "path"; - -// Load .env file from plugin directory -config({ path: join(import.meta.dirname || "", "../.env") }); +function throwDeprecatedConfigAccess(symbolName: string): never { + throw new Error( + `[ASM-104] ${symbolName} is deprecated in src/config.ts. ` + + "Use src/shared/asm-config.ts (ASM shared config) as the single runtime source-of-truth.", + ); +} /** - * Plugin configuration object - * All values read from environment variables with sensible defaults + * @deprecated Legacy symbol retained only for import compatibility. + * Any runtime access throws to prevent hidden env/default fallback behavior. */ -export const PluginConfig = { - // Qdrant settings - qdrant: { - host: process.env.QDRANT_HOST || "localhost", - port: parseInt(process.env.QDRANT_PORT || "6333"), - collection: process.env.QDRANT_COLLECTION || "mrc_bot", - timeout: parseInt(process.env.QDRANT_TIMEOUT || "30000"), - }, - - // Ollama LLM settings - ollama: { - host: process.env.OLLAMA_HOST || "http://localhost", - port: parseInt(process.env.OLLAMA_PORT || "11434"), - model: process.env.OLLAMA_MODEL || "deepseek-r1:8b", - timeout: parseInt(process.env.OLLAMA_TIMEOUT || "60000"), - get baseUrl() { - return `${this.host}:${this.port}`; - }, - }, - - // Embedding service settings - embedding: { - model: process.env.EMBEDDING_MODEL || "text-embedding-3-small", - dimensions: parseInt(process.env.EMBEDDING_DIMENSIONS || "1024"), - apiUrl: process.env.EMBEDDING_API_URL || "http://localhost:8000", - }, - - // Auto-capture settings - autoCapture: { - enabled: process.env.AUTO_CAPTURE_ENABLED !== "false", // default true - minConfidence: parseFloat(process.env.AUTO_CAPTURE_MIN_CONFIDENCE || "0.7"), - useLLM: process.env.AUTO_CAPTURE_USE_LLM !== "false", // default true - // RAG Memory Isolation - Noise Filter settings - agentBlocklist: (process.env.AUTO_CAPTURE_AGENT_BLOCKLIST || "").split(",").filter(Boolean), - noisePatterns: (process.env.AUTO_CAPTURE_NOISE_PATTERNS || "").split(",").filter(Boolean).map((p) => new RegExp(p, "i")), - maxMessagesPerCapture: parseInt(process.env.AUTO_CAPTURE_MAX_MESSAGES || "50"), - minContentLength: parseInt(process.env.AUTO_CAPTURE_MIN_CONTENT_LENGTH || "20"), - noiseThreshold: parseFloat(process.env.AUTO_CAPTURE_NOISE_THRESHOLD || "0.5"), - }, - - // Context window management settings - contextWindow: { - maxConversationTokens: parseInt( - process.env.CONTEXT_WINDOW_MAX_TOKENS || "12000" - ), - tokenEstimateDivisor: parseInt( - process.env.CONTEXT_WINDOW_TOKEN_DIVISOR || "4" - ), - absoluteMaxMessages: parseInt( - process.env.CONTEXT_WINDOW_MAX_MESSAGES || "200" - ), - }, - - // State storage - stateDir: process.env.STATE_DIR || process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`, - slotDbDir: process.env.OPENCLAW_SLOTDB_DIR || `${process.env.HOME}/.openclaw/agent-memo`, - - // Plugin defaults - defaults: { - namespace: process.env.DEFAULT_NAMESPACE || "default", - slotCategories: (process.env.DEFAULT_SLOT_CATEGORIES || "profile,preferences,project,environment,custom").split(","), - }, -}; +export const PluginConfig = new Proxy({} as Record, { + get(_target, prop) { + return throwDeprecatedConfigAccess(`PluginConfig.${String(prop)}`); + }, +}) as Record; /** - * Validate configuration - * Checks if required services are accessible + * @deprecated Legacy symbol retained only for import compatibility. */ -export async function validateConfig(): Promise<{ qdrant: boolean; ollama: boolean }> { - const results = { qdrant: false, ollama: false }; - - // Check Qdrant - try { - const response = await fetch( - `http://${PluginConfig.qdrant.host}:${PluginConfig.qdrant.port}/collections`, - { signal: AbortSignal.timeout(5000) } - ); - results.qdrant = response.ok; - } catch { - results.qdrant = false; - } - - // Check Ollama - try { - const response = await fetch(`${PluginConfig.ollama.baseUrl}/api/tags`, { - signal: AbortSignal.timeout(5000), - }); - results.ollama = response.ok; - } catch { - results.ollama = false; - } - - return results; +export async function validateConfig(): Promise<{ + qdrant: boolean; + ollama: boolean; +}> { + return throwDeprecatedConfigAccess("validateConfig()"); } /** - * Print configuration (for debugging) + * @deprecated Legacy symbol retained only for import compatibility. */ export function printConfig(): void { - console.log("[AgentMemo] Configuration:"); - console.log(` Qdrant: ${PluginConfig.qdrant.host}:${PluginConfig.qdrant.port}/${PluginConfig.qdrant.collection}`); - console.log(` Ollama: ${PluginConfig.ollama.baseUrl} (model: ${PluginConfig.ollama.model})`); - console.log(` Embedding: ${PluginConfig.embedding.model} (${PluginConfig.embedding.dimensions}d)`); - console.log(` AutoCapture: ${PluginConfig.autoCapture.enabled ? "enabled" : "disabled"}`); - console.log(` StateDir: ${PluginConfig.stateDir}`); - console.log(` SlotDbDir: ${PluginConfig.slotDbDir}`); + return throwDeprecatedConfigAccess("printConfig()"); } diff --git a/src/index.ts b/src/index.ts index 033fb9c..bfcad63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ /** * Agent-Memo: Slot Memory Plugin for OpenClaw v3.0 - * + * * Refactored to use modular tool structure with single Qdrant collection * - Slot tools: memory_slot_get/set/delete/list * - Graph tools: memory_graph_entity_get/set/rel_add/rel_remove/search @@ -8,450 +8,590 @@ * - Hooks: auto-recall, auto-capture */ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { SlotDB } from "./db/slot-db.js"; -import { QdrantClient } from "./services/qdrant.js"; -import { EmbeddingClient, type EmbedBackend } from "./services/embedding.js"; -import { DeduplicationService } from "./services/dedupe.js"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveSlotDbDir } from "./shared/slotdb-path.js"; -import { resolveAsmRuntimeConfig } from "./shared/asm-config.js"; - -// Tool modules -import { registerSlotTools } from "./tools/slot-tools.js"; -import { registerGraphTools } from "./tools/graph-tools.js"; -import { registerSemanticMemoryTools } from "./tools/semantic-memory-tools.js"; -import { registerProjectTools } from "./tools/project-tools.js"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { registerTelegramAddProjectCommand } from "./commands/telegram-addproject-command.js"; import { SemanticMemoryUseCase } from "./core/usecases/semantic-memory-usecase.js"; - +import { SlotDB } from "./db/slot-db.js"; +import { registerAutoCapture } from "./hooks/auto-capture.js"; // Hook modules import { registerAutoRecall } from "./hooks/auto-recall.js"; -import { registerAutoCapture } from "./hooks/auto-capture.js"; import { registerMemoryToolContextInjector } from "./hooks/tool-context-injector.js"; -import { registerTelegramAddProjectCommand } from "./commands/telegram-addproject-command.js"; +import { DeduplicationService } from "./services/dedupe.js"; +import { type EmbedBackend, EmbeddingClient } from "./services/embedding.js"; +import { QdrantClient } from "./services/qdrant.js"; +import { resolveAsmRuntimeConfig } from "./shared/asm-config.js"; +import { resolveSlotDbDir } from "./shared/slotdb-path.js"; +import { registerGraphTools } from "./tools/graph-tools.js"; +import { registerProjectTools } from "./tools/project-tools.js"; +import { registerSemanticMemoryTools } from "./tools/semantic-memory-tools.js"; +// Tool modules +import { registerSlotTools } from "./tools/slot-tools.js"; // ============================================================================ // Plugin Configuration Interface // ============================================================================ export interface AgentMemoConfig { - asmConfigPath?: string; + asmConfigPath?: string; } -const CONFIG_KEY_CANDIDATES: (keyof AgentMemoConfig)[] = [ - "asmConfigPath", -]; +const CONFIG_KEY_CANDIDATES: (keyof AgentMemoConfig)[] = ["asmConfigPath"]; function asObject(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; } function hasAnyConfigKey(obj: Record | null): boolean { - if (!obj) return false; - return CONFIG_KEY_CANDIDATES.some((key) => key in obj); + if (!obj) return false; + return CONFIG_KEY_CANDIDATES.some((key) => key in obj); } function firstNonEmptyString(...values: unknown[]): string | undefined { - for (const value of values) { - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); - } - } - return undefined; + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; } function findNestedStringKey( - input: unknown, - key: string, - maxDepth = 5 + input: unknown, + key: string, + maxDepth = 5, ): string | undefined { - const visited = new Set(); - - function walk(node: unknown, depth: number): string | undefined { - if (!node || typeof node !== "object" || depth > maxDepth || visited.has(node)) { - return undefined; - } - - visited.add(node); - const obj = node as Record; - - if (typeof obj[key] === "string" && (obj[key] as string).trim().length > 0) { - return (obj[key] as string).trim(); - } - - for (const value of Object.values(obj)) { - const found = walk(value, depth + 1); - if (found) return found; - } - - return undefined; - } - - return walk(input, 0); + const visited = new Set(); + + function walk(node: unknown, depth: number): string | undefined { + if ( + !node || + typeof node !== "object" || + depth > maxDepth || + visited.has(node) + ) { + return undefined; + } + + visited.add(node); + const obj = node as Record; + + if ( + typeof obj[key] === "string" && + (obj[key] as string).trim().length > 0 + ) { + return (obj[key] as string).trim(); + } + + for (const value of Object.values(obj)) { + const found = walk(value, depth + 1); + if (found) return found; + } + + return undefined; + } + + return walk(input, 0); } function resolveLegacyConfig(rawConfig: unknown): { - config: AgentMemoConfig; - source: string; + config: AgentMemoConfig; + source: string; } { - const root = asObject(rawConfig); - - const candidates: Array<{ source: string; value: Record | null }> = [ - { source: "api.config", value: root }, - { source: "api.config.config", value: asObject(root?.config) }, - { source: "api.config.entry.config", value: asObject(root?.entry?.config) }, - { source: "api.config.plugin.config", value: asObject(root?.plugin?.config) }, - { source: "api.config.value.config", value: asObject(root?.value?.config) }, - { source: "api.config.settings.config", value: asObject(root?.settings?.config) }, - ]; - - for (const candidate of candidates) { - if (hasAnyConfigKey(candidate.value)) { - return { config: candidate.value as AgentMemoConfig, source: candidate.source }; - } - } - - // Backward compatibility for wrapper style { enabled, config } - if (asObject(root?.config)) { - return { - config: asObject(root?.config) as AgentMemoConfig, - source: "api.config.config (wrapper-fallback)", - }; - } - - return { config: {}, source: "default" }; + const root = asObject(rawConfig); + + const candidates: Array<{ + source: string; + value: Record | null; + }> = [ + { source: "api.config", value: root }, + { source: "api.config.config", value: asObject(root?.config) }, + { source: "api.config.entry.config", value: asObject(root?.entry?.config) }, + { + source: "api.config.plugin.config", + value: asObject(root?.plugin?.config), + }, + { source: "api.config.value.config", value: asObject(root?.value?.config) }, + { + source: "api.config.settings.config", + value: asObject(root?.settings?.config), + }, + ]; + + for (const candidate of candidates) { + if (hasAnyConfigKey(candidate.value)) { + return { + config: candidate.value as AgentMemoConfig, + source: candidate.source, + }; + } + } + + // Backward compatibility for wrapper style { enabled, config } + if (asObject(root?.config)) { + return { + config: asObject(root?.config) as AgentMemoConfig, + source: "api.config.config (wrapper-fallback)", + }; + } + + return { config: {}, source: "default" }; } function resolvePluginConfig( - api: OpenClawPluginApi, - pluginId: string + api: OpenClawPluginApi, + pluginId: string, ): { config: AgentMemoConfig; source: string } { - const pluginConfig = asObject((api as any).pluginConfig); - if (typeof pluginConfig?.asmConfigPath === "string" && pluginConfig.asmConfigPath.trim()) { - return { config: { asmConfigPath: pluginConfig.asmConfigPath.trim() }, source: "pluginConfig" }; - } - - const legacyEntryConfig = asObject((api as any)?.config?.plugins?.entries?.[pluginId]?.config); - if (typeof legacyEntryConfig?.asmConfigPath === "string" && legacyEntryConfig.asmConfigPath.trim()) { - return { - config: { asmConfigPath: legacyEntryConfig.asmConfigPath.trim() }, - source: "api.config.plugins.entries[pluginId].config", - }; - } - - return { config: {}, source: "default" }; + const pluginConfig = asObject((api as any).pluginConfig); + if ( + typeof pluginConfig?.asmConfigPath === "string" && + pluginConfig.asmConfigPath.trim() + ) { + return { + config: { asmConfigPath: pluginConfig.asmConfigPath.trim() }, + source: "pluginConfig", + }; + } + + const legacyEntryConfig = asObject( + (api as any)?.config?.plugins?.entries?.[pluginId]?.config, + ); + if ( + typeof legacyEntryConfig?.asmConfigPath === "string" && + legacyEntryConfig.asmConfigPath.trim() + ) { + return { + config: { asmConfigPath: legacyEntryConfig.asmConfigPath.trim() }, + source: "api.config.plugins.entries[pluginId].config", + }; + } + + return { config: {}, source: "default" }; } // ============================================================================ // Plugin Definition // ============================================================================ -const DEFAULT_CATEGORIES = ["profile", "preferences", "project", "environment", "custom"]; +const DEFAULT_CATEGORIES = [ + "profile", + "preferences", + "project", + "environment", + "custom", +]; export const AGENT_MEMO_PLUGIN_ID = "agent-smart-memo"; export const AGENT_MEMO_PLUGIN_NAME = "Agent Memo (Slot Memory + Graph)"; -export const AGENT_MEMO_PLUGIN_DESCRIPTION = "Structured slot memory, graph relationships, and semantic search for OpenClaw"; +export const AGENT_MEMO_PLUGIN_DESCRIPTION = + "Structured slot memory, graph relationships, and semantic search for OpenClaw"; export const AGENT_MEMO_CONFIG_SCHEMA = { - type: "object", - additionalProperties: false, - properties: { - slotCategories: { - type: "array", - items: { type: "string" }, - description: "Allowed slot categories", - }, - maxSlots: { - type: "number", - description: "Maximum number of slots per scope", - }, - injectStateTokenBudget: { - type: "number", - description: "Max tokens for Current State injection", - }, - asmConfigPath: { - type: "string", - description: "Path to shared ASM config source-of-truth used by the OpenClaw plugin runtime.", - }, - }, + type: "object", + additionalProperties: false, + properties: { + slotCategories: { + type: "array", + items: { type: "string" }, + description: "Allowed slot categories", + }, + maxSlots: { + type: "number", + description: "Maximum number of slots per scope", + }, + injectStateTokenBudget: { + type: "number", + description: "Max tokens for Current State injection", + }, + asmConfigPath: { + type: "string", + description: + "Path to shared ASM config source-of-truth used by the OpenClaw plugin runtime.", + }, + qdrantHost: { + type: "string", + description: "Qdrant host", + }, + qdrantPort: { + type: "number", + description: "Qdrant port", + }, + qdrantCollection: { + type: "string", + description: "Qdrant collection name", + }, + qdrantVectorSize: { + type: "number", + description: "Qdrant vector size", + }, + llmBaseUrl: { + type: "string", + description: "Base URL for LLM API", + }, + llmApiKey: { + type: "string", + description: "API key for LLM provider", + }, + llmModel: { + type: "string", + description: "LLM model name", + }, + embedBaseUrl: { + type: "string", + description: "Base URL for embedding service", + }, + embedBackend: { + type: "string", + description: "Embedding backend", + }, + embedModel: { + type: "string", + description: "Embedding model", + }, + embedDimensions: { + type: "number", + description: "Embedding vector dimensions", + }, + autoCaptureEnabled: { + type: "boolean", + description: "Enable automatic capture", + }, + autoCaptureMinConfidence: { + type: "number", + description: "Minimum confidence threshold for auto-capture", + }, + contextWindowMaxTokens: { + type: "number", + description: "Context window maximum tokens", + }, + summarizeEveryActions: { + type: "number", + description: "Summarize every N actions", + }, + projectWorkspaceRoot: { + type: "string", + description: "Project workspace root path", + }, + slotDbDir: { + type: "string", + description: "SlotDB directory", + }, + }, } as const; export const AGENT_MEMO_UI_HINTS = { - slotCategories: { - label: "Slot Categories", - placeholder: "profile, preferences, project, environment", - }, - maxSlots: { - label: "Max Slots", - placeholder: "500", - }, - injectStateTokenBudget: { - label: "State Injection Token Budget", - placeholder: "500", - }, - qdrantHost: { - label: "Qdrant Host", - placeholder: "localhost", - }, - qdrantPort: { - label: "Qdrant Port", - placeholder: "6333", - }, - qdrantCollection: { - label: "Qdrant Collection", - placeholder: "mrc_bot_memory", - }, - llmBaseUrl: { - label: "LLM Base URL", - placeholder: "http://localhost:8317/v1", - }, - llmApiKey: { - label: "LLM API Key", - placeholder: "proxypal-local", - }, - llmModel: { - label: "LLM Model", - placeholder: "gemini-3.1-pro-low", - }, - embedBaseUrl: { - label: "Embedding Base URL", - placeholder: "http://localhost:11434", - }, - embedBackend: { - label: "Embedding Backend", - placeholder: "ollama", - }, - embedModel: { - label: "Embedding Model", - placeholder: "qwen3-embedding:0.6b", - }, - embedDimensions: { - label: "Embedding Dimensions", - placeholder: "1024", - }, - slotDbDir: { - label: "SlotDB Directory", - placeholder: "/Users/you/.openclaw/agent-memo", - }, - projectWorkspaceRoot: { - label: "Project Workspace Root", - placeholder: "/Users/you/Work/projects", - }, - autoCaptureEnabled: { - label: "Auto Capture Enabled", - }, - autoCaptureMinConfidence: { - label: "Min Confidence", - placeholder: "0.7", - }, - contextWindowMaxTokens: { - label: "Context Window Max Tokens", - placeholder: "12000", - }, - summarizeEveryActions: { - label: "Summarize Every N Actions", - placeholder: "6", - }, + slotCategories: { + label: "Slot Categories", + placeholder: "profile, preferences, project, environment", + }, + maxSlots: { + label: "Max Slots", + placeholder: "500", + }, + injectStateTokenBudget: { + label: "State Injection Token Budget", + placeholder: "500", + }, + qdrantHost: { + label: "Qdrant Host", + placeholder: "localhost", + }, + qdrantPort: { + label: "Qdrant Port", + placeholder: "6333", + }, + qdrantCollection: { + label: "Qdrant Collection", + placeholder: "mrc_bot_memory", + }, + llmBaseUrl: { + label: "LLM Base URL", + placeholder: "http://localhost:8317/v1", + }, + llmApiKey: { + label: "LLM API Key", + placeholder: "proxypal-local", + }, + llmModel: { + label: "LLM Model", + placeholder: "gemini-3.1-pro-low", + }, + embedBaseUrl: { + label: "Embedding Base URL", + placeholder: "http://localhost:11434", + }, + embedBackend: { + label: "Embedding Backend", + placeholder: "ollama", + }, + embedModel: { + label: "Embedding Model", + placeholder: "qwen3-embedding:0.6b", + }, + embedDimensions: { + label: "Embedding Dimensions", + placeholder: "1024", + }, + slotDbDir: { + label: "SlotDB Directory", + placeholder: "/Users/you/.openclaw/agent-memo", + }, + projectWorkspaceRoot: { + label: "Project Workspace Root", + placeholder: "/Users/you/Work/projects", + }, + autoCaptureEnabled: { + label: "Auto Capture Enabled", + }, + autoCaptureMinConfidence: { + label: "Min Confidence", + placeholder: "0.7", + }, + contextWindowMaxTokens: { + label: "Context Window Max Tokens", + placeholder: "12000", + }, + summarizeEveryActions: { + label: "Summarize Every N Actions", + placeholder: "6", + }, } as const; export function getAgentMemoPluginDefinition() { - return { - id: AGENT_MEMO_PLUGIN_ID, - name: AGENT_MEMO_PLUGIN_NAME, - description: AGENT_MEMO_PLUGIN_DESCRIPTION, - kind: "memory" as const, - configSchema: AGENT_MEMO_CONFIG_SCHEMA, - uiHints: AGENT_MEMO_UI_HINTS, - }; + return { + id: AGENT_MEMO_PLUGIN_ID, + name: AGENT_MEMO_PLUGIN_NAME, + description: AGENT_MEMO_PLUGIN_DESCRIPTION, + kind: "memory" as const, + configSchema: AGENT_MEMO_CONFIG_SCHEMA, + uiHints: AGENT_MEMO_UI_HINTS, + }; } export function loadAgentMemoPluginDefinitionFromSource() { - const pluginPath = join(dirname(fileURLToPath(import.meta.url)), "../openclaw.plugin.json"); - return JSON.parse(readFileSync(pluginPath, "utf8")); + const pluginPath = join( + dirname(fileURLToPath(import.meta.url)), + "../openclaw.plugin.json", + ); + return JSON.parse(readFileSync(pluginPath, "utf8")); } const agentMemoPlugin = { - ...getAgentMemoPluginDefinition(), - - register(api: OpenClawPluginApi) { - // ---------------------------------------------------------------- - // Resolve config with priority: - // 1) api.pluginConfig - // 2) api.config.plugins.entries[pluginId].config (compat) - // 3) legacy api.config shapes - // ---------------------------------------------------------------- - const rawConfig = (api as any).config; - const { config, source } = resolvePluginConfig(api, "agent-smart-memo"); - - const slotCategories = DEFAULT_CATEGORIES; - const asmConfigPath = firstNonEmptyString(config.asmConfigPath); - const runtime = resolveAsmRuntimeConfig({ configPath: asmConfigPath, env: process.env, homeDir: process.env.HOME }); - const qdrantHost = runtime.qdrantHost; - const qdrantPort = runtime.qdrantPort; - const qdrantCollection = runtime.qdrantCollection; - const qdrantVectorSize = runtime.qdrantVectorSize; - const llmBaseUrl = runtime.llmBaseUrl; - const llmApiKey = runtime.llmApiKey; - const llmModel = runtime.llmModel; - const llmModelFallbackUsed = false; - const embedBaseUrl = runtime.embedBaseUrl; - const embedBackend = runtime.embedBackend as EmbedBackend | undefined; - const embedModel = runtime.embedModel; - const embedDimensions = runtime.embedDimensions; - const autoCaptureEnabled = runtime.autoCaptureEnabled; - const autoCaptureMinConfidence = runtime.autoCaptureMinConfidence; - const contextWindowMaxTokens = runtime.contextWindowMaxTokens; - const summarizeEveryActions = runtime.summarizeEveryActions; - const projectWorkspaceRoot = runtime.projectWorkspaceRoot; - - const stateDir = process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`; - const slotDbDir = runtime.slotDbDir; - - if (projectWorkspaceRoot) { - process.env.AGENT_MEMO_PROJECT_WORKSPACE_ROOT = projectWorkspaceRoot; - process.env.AGENT_MEMO_REPO_CLONE_ROOT = projectWorkspaceRoot; - } - - console.log( - `[AgentMemo] Startup config: source=${source}, resolved llmModel: ${llmModel}, fallbackUsed=${llmModelFallbackUsed}` - ); - console.log("[AgentMemo] Configuration:"); - console.log(` Slot categories: ${slotCategories.join(", ")}`); - console.log(` Qdrant: ${qdrantHost}:${qdrantPort}/${qdrantCollection}`); - console.log(` LLM: ${llmBaseUrl} (model: ${llmModel})`); - console.log(` Embedding: ${embedBaseUrl} (backend: ${embedBackend || "auto"}, model: ${embedModel}, ${embedDimensions}d)`); - console.log(` AutoCapture: ${autoCaptureEnabled ? "enabled" : "disabled"}`); - console.log(` ContextWindow: ${contextWindowMaxTokens} tokens`); - console.log(` SummarizeEveryActions: ${summarizeEveryActions}`); - console.log(` SlotDB dir: ${slotDbDir}`); - if (projectWorkspaceRoot) { - console.log(` ProjectWorkspaceRoot: ${projectWorkspaceRoot}`); - } - - // ---------------------------------------------------------------- - // Initialize services - // ---------------------------------------------------------------- - const slotDB = new SlotDB(stateDir, { slotDbDir }); - - // Single Qdrant collection for all agents - namespace isolation via payload - const routeMapRaw = process.env.EMBEDDING_DIM_ROUTE_MAP || ""; - const routeMap = routeMapRaw - .split(",") - .map((pair) => pair.trim()) - .filter(Boolean) - .reduce>((acc, pair) => { - const [dimText, collection] = pair.split(":").map((x) => x?.trim()); - const dim = Number(dimText); - if (Number.isFinite(dim) && dim > 0 && collection) { - acc[dim] = collection; - } - return acc; - }, {}); - - const qdrant = new QdrantClient({ - host: qdrantHost, - port: qdrantPort, - collection: qdrantCollection, - vectorSize: qdrantVectorSize, - dimensionRouteMap: routeMap, - }); - - const embedding = new EmbeddingClient({ - embeddingApiUrl: embedBaseUrl, - backend: embedBackend, - model: embedModel, - dimensions: embedDimensions, - stateDir, - }); - - embedding.calibrateRuntimeCapability(true).catch((error: any) => { - console.warn(`[AgentMemo] Embedding calibration skipped: ${error.message}`); - }); - - try { - mkdirSync(join(stateDir, "plugin-data", "agent-smart-memo"), { recursive: true }); - writeFileSync( - join(stateDir, "plugin-data", "agent-smart-memo", "runtime-manifest.json"), - JSON.stringify({ - pluginId: "agent-smart-memo", - distEntry: import.meta.url, - generatedAt: new Date().toISOString(), - embedModel, - embedBaseUrl, - embedBackend: embedBackend || "auto", - embedDimensions, - qdrantCollection, - }, null, 2), - "utf8" - ); - } catch (error: any) { - console.warn(`[AgentMemo] Failed to write runtime manifest: ${error.message}`); - } - - const dedupe = new DeduplicationService(0.95, console); - const semanticUseCaseBySlotDbDir = new Map(); - const getSemanticUseCase = (resolvedSlotDbDir: string): SemanticMemoryUseCase => { - let uc = semanticUseCaseBySlotDbDir.get(resolvedSlotDbDir); - if (!uc) { - uc = new SemanticMemoryUseCase(qdrant, embedding, dedupe); - semanticUseCaseBySlotDbDir.set(resolvedSlotDbDir, uc); - } - return uc; - }; - - // ---------------------------------------------------------------- - // Register tools through shared use-case runtime boundary - // ---------------------------------------------------------------- - registerSemanticMemoryTools(api, { - stateDir, - slotDbDir, - semanticUseCaseFactory: (resolvedSlotDbDir) => getSemanticUseCase(resolvedSlotDbDir), - }); - - // ---------------------------------------------------------------- - // Register Slot & Graph tools - // ---------------------------------------------------------------- - registerSlotTools(api, slotCategories, { - stateDir, - slotDbDir, - semanticUseCaseFactory: (resolvedSlotDbDir) => getSemanticUseCase(resolvedSlotDbDir), - }); - registerGraphTools(api, { - stateDir, - slotDbDir, - semanticUseCaseFactory: (resolvedSlotDbDir) => getSemanticUseCase(resolvedSlotDbDir), - }); - registerProjectTools(api, { - stateDir, - slotDbDir, - semanticUseCaseFactory: (resolvedSlotDbDir) => getSemanticUseCase(resolvedSlotDbDir), - }); - registerTelegramAddProjectCommand(api); - - // ---------------------------------------------------------------- - // Register lifecycle hooks - // ---------------------------------------------------------------- - registerAutoRecall(api, slotDB, qdrant, embedding); - registerAutoCapture(api, slotDB, qdrant, embedding, dedupe, { - enabled: autoCaptureEnabled, - minConfidence: autoCaptureMinConfidence, - useLLM: true, - llmBaseUrl, - llmApiKey, - llmModel, - contextWindowMaxTokens, - summarizeEveryActions, - }); - registerMemoryToolContextInjector(api); - - console.log("[AgentMemo] Plugin registered successfully"); - console.log("[AgentMemo] Tools: memory_search, memory_store, memory_slot_*, memory_graph_*, project_registry_*, project_task_*, project_hybrid_search"); - console.log("[AgentMemo] Hooks: auto-recall, auto-capture, tool-context-injector"); - }, + ...getAgentMemoPluginDefinition(), + + register(api: OpenClawPluginApi) { + // ---------------------------------------------------------------- + // Resolve config with priority: + // 1) api.pluginConfig + // 2) api.config.plugins.entries[pluginId].config (compat) + // 3) legacy api.config shapes + // ---------------------------------------------------------------- + const rawConfig = (api as any).config; + const { config, source } = resolvePluginConfig(api, "agent-smart-memo"); + + const slotCategories = DEFAULT_CATEGORIES; + const asmConfigPath = firstNonEmptyString(config.asmConfigPath); + const runtime = resolveAsmRuntimeConfig({ + configPath: asmConfigPath, + env: process.env, + homeDir: process.env.HOME, + }); + const qdrantHost = runtime.qdrantHost; + const qdrantPort = runtime.qdrantPort; + const qdrantCollection = runtime.qdrantCollection; + const qdrantVectorSize = runtime.qdrantVectorSize; + const llmBaseUrl = runtime.llmBaseUrl; + const llmApiKey = runtime.llmApiKey; + const llmModel = runtime.llmModel; + const llmModelFallbackUsed = false; + const embedBaseUrl = runtime.embedBaseUrl; + const embedBackend = runtime.embedBackend as EmbedBackend | undefined; + const embedModel = runtime.embedModel; + const embedDimensions = runtime.embedDimensions; + const autoCaptureEnabled = runtime.autoCaptureEnabled; + const autoCaptureMinConfidence = runtime.autoCaptureMinConfidence; + const contextWindowMaxTokens = runtime.contextWindowMaxTokens; + const summarizeEveryActions = runtime.summarizeEveryActions; + const projectWorkspaceRoot = runtime.projectWorkspaceRoot; + + const stateDir = + process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`; + const slotDbDir = runtime.slotDbDir; + + if (projectWorkspaceRoot) { + process.env.AGENT_MEMO_PROJECT_WORKSPACE_ROOT = projectWorkspaceRoot; + process.env.AGENT_MEMO_REPO_CLONE_ROOT = projectWorkspaceRoot; + } + + console.log( + `[AgentMemo] Startup config: source=${source}, resolved llmModel: ${llmModel}, fallbackUsed=${llmModelFallbackUsed}`, + ); + console.log("[AgentMemo] Configuration:"); + console.log(` Slot categories: ${slotCategories.join(", ")}`); + console.log(` Qdrant: ${qdrantHost}:${qdrantPort}/${qdrantCollection}`); + console.log(` LLM: ${llmBaseUrl} (model: ${llmModel})`); + console.log( + ` Embedding: ${embedBaseUrl} (backend: ${embedBackend || "auto"}, model: ${embedModel}, ${embedDimensions}d)`, + ); + console.log( + ` AutoCapture: ${autoCaptureEnabled ? "enabled" : "disabled"}`, + ); + console.log(` ContextWindow: ${contextWindowMaxTokens} tokens`); + console.log(` SummarizeEveryActions: ${summarizeEveryActions}`); + console.log(` SlotDB dir: ${slotDbDir}`); + if (projectWorkspaceRoot) { + console.log(` ProjectWorkspaceRoot: ${projectWorkspaceRoot}`); + } + + // ---------------------------------------------------------------- + // Initialize services + // ---------------------------------------------------------------- + const slotDB = new SlotDB(stateDir, { slotDbDir }); + + // Single Qdrant collection for all agents - namespace isolation via payload + const routeMapRaw = process.env.EMBEDDING_DIM_ROUTE_MAP || ""; + const routeMap = routeMapRaw + .split(",") + .map((pair) => pair.trim()) + .filter(Boolean) + .reduce>((acc, pair) => { + const [dimText, collection] = pair.split(":").map((x) => x?.trim()); + const dim = Number(dimText); + if (Number.isFinite(dim) && dim > 0 && collection) { + acc[dim] = collection; + } + return acc; + }, {}); + + const qdrant = new QdrantClient({ + host: qdrantHost, + port: qdrantPort, + collection: qdrantCollection, + vectorSize: qdrantVectorSize, + dimensionRouteMap: routeMap, + }); + + const embedding = new EmbeddingClient({ + embeddingApiUrl: embedBaseUrl, + backend: embedBackend, + model: embedModel, + dimensions: embedDimensions, + stateDir, + }); + + embedding.calibrateRuntimeCapability(true).catch((error: any) => { + console.warn( + `[AgentMemo] Embedding calibration skipped: ${error.message}`, + ); + }); + + try { + mkdirSync(join(stateDir, "plugin-data", "agent-smart-memo"), { + recursive: true, + }); + writeFileSync( + join( + stateDir, + "plugin-data", + "agent-smart-memo", + "runtime-manifest.json", + ), + JSON.stringify( + { + pluginId: "agent-smart-memo", + distEntry: import.meta.url, + generatedAt: new Date().toISOString(), + embedModel, + embedBaseUrl, + embedBackend: embedBackend || "auto", + embedDimensions, + qdrantCollection, + }, + null, + 2, + ), + "utf8", + ); + } catch (error: any) { + console.warn( + `[AgentMemo] Failed to write runtime manifest: ${error.message}`, + ); + } + + const dedupe = new DeduplicationService(0.95, console); + const semanticUseCaseBySlotDbDir = new Map(); + const getSemanticUseCase = ( + resolvedSlotDbDir: string, + ): SemanticMemoryUseCase => { + let uc = semanticUseCaseBySlotDbDir.get(resolvedSlotDbDir); + if (!uc) { + uc = new SemanticMemoryUseCase(qdrant, embedding, dedupe); + semanticUseCaseBySlotDbDir.set(resolvedSlotDbDir, uc); + } + return uc; + }; + + // ---------------------------------------------------------------- + // Register tools through shared use-case runtime boundary + // ---------------------------------------------------------------- + registerSemanticMemoryTools(api, { + stateDir, + slotDbDir, + semanticUseCaseFactory: (resolvedSlotDbDir) => + getSemanticUseCase(resolvedSlotDbDir), + }); + + // ---------------------------------------------------------------- + // Register Slot & Graph tools + // ---------------------------------------------------------------- + registerSlotTools(api, slotCategories, { + stateDir, + slotDbDir, + semanticUseCaseFactory: (resolvedSlotDbDir) => + getSemanticUseCase(resolvedSlotDbDir), + }); + registerGraphTools(api, { + stateDir, + slotDbDir, + semanticUseCaseFactory: (resolvedSlotDbDir) => + getSemanticUseCase(resolvedSlotDbDir), + }); + registerProjectTools(api, { + stateDir, + slotDbDir, + semanticUseCaseFactory: (resolvedSlotDbDir) => + getSemanticUseCase(resolvedSlotDbDir), + }); + registerTelegramAddProjectCommand(api); + + // ---------------------------------------------------------------- + // Register lifecycle hooks + // ---------------------------------------------------------------- + registerAutoRecall(api, slotDB, qdrant, embedding); + registerAutoCapture(api, slotDB, qdrant, embedding, dedupe, { + enabled: autoCaptureEnabled, + minConfidence: autoCaptureMinConfidence, + useLLM: true, + llmBaseUrl, + llmApiKey, + llmModel, + contextWindowMaxTokens, + summarizeEveryActions, + }); + registerMemoryToolContextInjector(api); + + console.log("[AgentMemo] Plugin registered successfully"); + console.log( + "[AgentMemo] Tools: memory_search, memory_store, memory_slot_*, memory_graph_*, project_registry_*, project_task_*, project_hybrid_search", + ); + console.log( + "[AgentMemo] Hooks: auto-recall, auto-capture, tool-context-injector", + ); + }, }; export default agentMemoPlugin; diff --git a/tests/test-opencode-mcp-stdio.ts b/tests/test-opencode-mcp-stdio.ts index 85f7ea1..8712304 100644 --- a/tests/test-opencode-mcp-stdio.ts +++ b/tests/test-opencode-mcp-stdio.ts @@ -1,144 +1,219 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn } from "node:child_process"; +import type { EventEmitter } from "node:events"; import { once } from "node:events"; -import { fileURLToPath } from "node:url"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; function assert(condition: boolean, message: string): void { - if (!condition) throw new Error(message); + if (!condition) throw new Error(message); } interface JsonRpcMessage { - jsonrpc: string; - id?: number | string | null; - method?: string; - params?: any; - result?: any; - error?: any; + jsonrpc: string; + id?: number | string | null; + method?: string; + params?: any; + result?: any; + error?: any; } function encodeMessage(message: JsonRpcMessage): Buffer { - const body = Buffer.from(JSON.stringify(message), "utf8"); - const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "utf8"); - return Buffer.concat([header, body]); + const body = Buffer.from(JSON.stringify(message), "utf8"); + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "utf8"); + return Buffer.concat([header, body]); } async function waitForResponse( - proc: ReturnType, - predicate: (msg: JsonRpcMessage) => boolean, - timeoutMs = 5000, + proc: ChildProcessWithoutNullStreams, + predicate: (msg: JsonRpcMessage) => boolean, + timeoutMs = 5000, ): Promise { - return await new Promise((resolve, reject) => { - let buffer = Buffer.alloc(0); - const timer = setTimeout(() => { - cleanup(); - reject(new Error("Timeout waiting for MCP response")); - }, timeoutMs); - - const onData = (chunk: Buffer) => { - buffer = Buffer.concat([buffer, chunk]); - while (true) { - const headerEnd = buffer.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - - const headerText = buffer.slice(0, headerEnd).toString("utf8"); - const match = headerText.match(/Content-Length:\s*(\d+)/i); - if (!match) { - buffer = buffer.slice(headerEnd + 4); - continue; - } - - const contentLength = Number(match[1]); - const totalLength = headerEnd + 4 + contentLength; - if (buffer.length < totalLength) return; - - const body = buffer.slice(headerEnd + 4, totalLength).toString("utf8"); - buffer = buffer.slice(totalLength); - - try { - const parsed = JSON.parse(body) as JsonRpcMessage; - if (predicate(parsed)) { - cleanup(); - resolve(parsed); - return; - } - } catch { - // ignore unrelated malformed packet in test harness - } - } - }; - - const onExit = () => { - cleanup(); - reject(new Error("MCP server exited before expected response")); - }; - - const cleanup = () => { - clearTimeout(timer); - proc.stdout?.off("data", onData); - proc.off("exit", onExit); - }; - - proc.stdout?.on("data", onData); - proc.on("exit", onExit); - }); + return await new Promise((resolve, reject) => { + const procEvents = proc as unknown as EventEmitter; + let buffer = Buffer.alloc(0); + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Timeout waiting for MCP response")); + }, timeoutMs); + + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + + const headerText = buffer.slice(0, headerEnd).toString("utf8"); + const match = headerText.match(/Content-Length:\s*(\d+)/i); + if (!match) { + buffer = buffer.slice(headerEnd + 4); + continue; + } + + const contentLength = Number(match[1]); + const totalLength = headerEnd + 4 + contentLength; + if (buffer.length < totalLength) return; + + const body = buffer.slice(headerEnd + 4, totalLength).toString("utf8"); + buffer = buffer.slice(totalLength); + + try { + const parsed = JSON.parse(body) as JsonRpcMessage; + if (predicate(parsed)) { + cleanup(); + resolve(parsed); + return; + } + } catch { + // ignore unrelated malformed packet in test harness + } + } + }; + + const onExit = () => { + cleanup(); + reject(new Error("MCP server exited before expected response")); + }; + + const cleanup = () => { + clearTimeout(timer); + proc.stdout?.off("data", onData); + procEvents.off("exit", onExit); + }; + + proc.stdout?.on("data", onData); + procEvents.on("exit", onExit); + }); } async function main() { - const here = dirname(fileURLToPath(import.meta.url)); - const serverPath = join(here, "..", "bin", "opencode-mcp-server.mjs"); - - const proc = spawn(process.execPath, [serverPath], { - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ASM_MCP_AGENT_ID: "opencode" }, - }); - - try { - proc.stdin.write(encodeMessage({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "smoke", version: "1.0.0" }, - }, - })); - - const init = await waitForResponse(proc, (msg) => msg.id === 1); - assert(init.result?.protocolVersion === "2024-11-05", "initialize response must return protocol version"); - - proc.stdin.write(encodeMessage({ - jsonrpc: "2.0", - method: "notifications/initialized", - params: {}, - })); - - proc.stdin.write(encodeMessage({ - jsonrpc: "2.0", - id: 2, - method: "tools/list", - params: {}, - })); - - const toolsList = await waitForResponse(proc, (msg) => msg.id === 2); - const tools = Array.isArray(toolsList.result?.tools) ? toolsList.result.tools : []; - const toolNames = tools.map((tool: any) => String(tool?.name || "")).sort(); - - assert(toolNames.length === 3, "tools/list must return exactly 3 read-only tools"); - assert(toolNames[0] === "asm_project_binding_preview", "missing asm_project_binding_preview"); - assert(toolNames[1] === "asm_project_coding_packet", "missing asm_project_coding_packet"); - assert(toolNames[2] === "asm_project_opencode_search", "missing asm_project_opencode_search"); - - console.log("✅ opencode MCP stdio smoke passed"); - } finally { - proc.stdin.end(); - proc.kill("SIGTERM"); - await once(proc, "exit"); - } + const here = dirname(fileURLToPath(import.meta.url)); + const serverPath = join(here, "..", "bin", "opencode-mcp-server.mjs"); + const tempRoot = mkdtempSync(join(tmpdir(), "asm-opencode-mcp-")); + const asmConfigPath = join(tempRoot, "config.json"); + const slotDbDir = join(tempRoot, "slotdb"); + + writeFileSync( + asmConfigPath, + `${JSON.stringify( + { + schemaVersion: 1, + core: { + projectWorkspaceRoot: join(tempRoot, "workspace"), + qdrantHost: "localhost", + qdrantPort: 6333, + qdrantCollection: "mrc_bot", + qdrantVectorSize: 1024, + llmBaseUrl: "http://localhost:8317/v1", + llmApiKey: "test-key", + llmModel: "gpt-5.4", + embedBaseUrl: "http://localhost:11434", + embedBackend: "ollama", + embedModel: "qwen3-embedding:0.6b", + embedDimensions: 1024, + autoCaptureEnabled: true, + autoCaptureMinConfidence: 0.7, + contextWindowMaxTokens: 32000, + summarizeEveryActions: 6, + storage: { + slotDbDir, + }, + }, + adapters: { + opencode: { enabled: true, mode: "read-only" }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const proc: ChildProcessWithoutNullStreams = spawn( + process.execPath, + [serverPath], + { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + ASM_MCP_AGENT_ID: "opencode", + ASM_CONFIG: asmConfigPath, + }, + }, + ); + + try { + proc.stdin.write( + encodeMessage({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "smoke", version: "1.0.0" }, + }, + }), + ); + + const init = await waitForResponse(proc, (msg) => msg.id === 1); + assert( + init.result?.protocolVersion === "2024-11-05", + "initialize response must return protocol version", + ); + + proc.stdin.write( + encodeMessage({ + jsonrpc: "2.0", + method: "notifications/initialized", + params: {}, + }), + ); + + proc.stdin.write( + encodeMessage({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }), + ); + + const toolsList = await waitForResponse(proc, (msg) => msg.id === 2); + const tools = Array.isArray(toolsList.result?.tools) + ? toolsList.result.tools + : []; + const toolNames = tools.map((tool: any) => String(tool?.name || "")).sort(); + + assert( + toolNames.length === 3, + "tools/list must return exactly 3 read-only tools", + ); + assert( + toolNames[0] === "asm_project_binding_preview", + "missing asm_project_binding_preview", + ); + assert( + toolNames[1] === "asm_project_coding_packet", + "missing asm_project_coding_packet", + ); + assert( + toolNames[2] === "asm_project_opencode_search", + "missing asm_project_opencode_search", + ); + + console.log("✅ opencode MCP stdio smoke passed"); + } finally { + proc.stdin.end(); + proc.kill("SIGTERM"); + await once(proc as unknown as EventEmitter, "exit"); + } } main().catch((error) => { - console.error("❌ opencode MCP stdio smoke failed"); - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; + console.error("❌ opencode MCP stdio smoke failed"); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; }); From b15ad720321cb29d36637d464e6fb99e952d2295 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 15:16:25 +0700 Subject: [PATCH 16/24] chore(release): bump version to 5.1.14 --- package.json | 162 +++++++++++++++++++++++++-------------------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/package.json b/package.json index dbd2661..7e547c1 100644 --- a/package.json +++ b/package.json @@ -1,83 +1,83 @@ { - "name": "@mrc2204/agent-smart-memo", - "version": "5.1.13", - "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", - "type": "module", - "main": "dist/index.js", - "bin": { - "asm": "bin/asm.mjs", - "agent-smart-memo": "bin/asm.mjs" - }, - "openclaw": { - "extensions": [ - "./dist/index.js" - ] - }, - "scripts": { - "build": "npm run build:openclaw && npm run sync:openclaw:dist", - "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", - "build:openclaw": "tsc -p tsconfig.openclaw.json", - "build:paperclip": "tsc -p tsconfig.paperclip.json", - "build:core": "tsc -p tsconfig.core.json", - "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", - "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", - "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", - "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", - "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", - "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", - "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", - "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", - "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", - "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", - "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", - "publish:core": "npm run package:core && node scripts/publish-target.mjs core", - "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", - "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", - "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", - "test:asm-cli": "npx tsx tests/test-asm-cli.ts", - "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", - "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", - "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", - "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", - "validate:ab": "npx tsx scripts/validate-ab.ts", - "init-openclaw": "node scripts/init-openclaw.mjs", - "asm": "node bin/asm.mjs" - }, - "dependencies": { - "dotenv": "^17.3.1" - }, - "devDependencies": { - "@sinclair/typebox": "^0.34.0", - "@types/node": "^22.0.0", - "openclaw": "*", - "typescript": "^5.0.0" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/cong91/agent-smart-memo.git" - }, - "keywords": [ - "openclaw", - "plugin", - "memory", - "ai", - "agent", - "smart-memo", - "qdrant", - "auto-capture" - ], - "author": "mrc2204", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/", - "bin/", - "scripts/init-openclaw.mjs", - "openclaw.plugin.json", - "CONFIG.example.json", - "README.md", - "LICENSE" - ] + "name": "@mrc2204/agent-smart-memo", + "version": "5.1.14", + "description": "Smart Memory Plugin for OpenClaw — structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", + "keywords": [ + "agent", + "ai", + "auto-capture", + "memory", + "openclaw", + "plugin", + "qdrant", + "smart-memo" + ], + "license": "MIT", + "author": "mrc2204", + "repository": { + "type": "git", + "url": "git+https://github.com/cong91/agent-smart-memo.git" + }, + "bin": { + "agent-smart-memo": "bin/asm.mjs", + "asm": "bin/asm.mjs" + }, + "files": [ + "dist/", + "bin/", + "scripts/init-openclaw.mjs", + "openclaw.plugin.json", + "CONFIG.example.json", + "README.md", + "LICENSE" + ], + "type": "module", + "main": "dist/index.js", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "npm run build:openclaw && npm run sync:openclaw:dist", + "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", + "build:openclaw": "tsc -p tsconfig.openclaw.json", + "build:paperclip": "tsc -p tsconfig.paperclip.json", + "build:core": "tsc -p tsconfig.core.json", + "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", + "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", + "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", + "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", + "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", + "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", + "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", + "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", + "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", + "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", + "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", + "publish:core": "npm run package:core && node scripts/publish-target.mjs core", + "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", + "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", + "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", + "test:asm-cli": "npx tsx tests/test-asm-cli.ts", + "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", + "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", + "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", + "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", + "validate:ab": "npx tsx scripts/validate-ab.ts", + "init-openclaw": "node scripts/init-openclaw.mjs", + "asm": "node bin/asm.mjs" + }, + "dependencies": { + "dotenv": "^17.3.1" + }, + "devDependencies": { + "@sinclair/typebox": "^0.34.0", + "@types/node": "^22.0.0", + "openclaw": "*", + "typescript": "^5.0.0" + }, + "openclaw": { + "extensions": [ + "./dist/index.js" + ] + } } From 5b106901d4a0065cf5c2e7d690183e208deb555d Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 15:17:11 +0700 Subject: [PATCH 17/24] chore(release): narrow version bump to 5.1.14 --- package.json | 162 +++++++++++++++++++++++++-------------------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/package.json b/package.json index 7e547c1..dbd2661 100644 --- a/package.json +++ b/package.json @@ -1,83 +1,83 @@ { - "name": "@mrc2204/agent-smart-memo", - "version": "5.1.14", - "description": "Smart Memory Plugin for OpenClaw — structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", - "keywords": [ - "agent", - "ai", - "auto-capture", - "memory", - "openclaw", - "plugin", - "qdrant", - "smart-memo" - ], - "license": "MIT", - "author": "mrc2204", - "repository": { - "type": "git", - "url": "git+https://github.com/cong91/agent-smart-memo.git" - }, - "bin": { - "agent-smart-memo": "bin/asm.mjs", - "asm": "bin/asm.mjs" - }, - "files": [ - "dist/", - "bin/", - "scripts/init-openclaw.mjs", - "openclaw.plugin.json", - "CONFIG.example.json", - "README.md", - "LICENSE" - ], - "type": "module", - "main": "dist/index.js", - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "npm run build:openclaw && npm run sync:openclaw:dist", - "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", - "build:openclaw": "tsc -p tsconfig.openclaw.json", - "build:paperclip": "tsc -p tsconfig.paperclip.json", - "build:core": "tsc -p tsconfig.core.json", - "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", - "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", - "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", - "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", - "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", - "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", - "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", - "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", - "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", - "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", - "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", - "publish:core": "npm run package:core && node scripts/publish-target.mjs core", - "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", - "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", - "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", - "test:asm-cli": "npx tsx tests/test-asm-cli.ts", - "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", - "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", - "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", - "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", - "validate:ab": "npx tsx scripts/validate-ab.ts", - "init-openclaw": "node scripts/init-openclaw.mjs", - "asm": "node bin/asm.mjs" - }, - "dependencies": { - "dotenv": "^17.3.1" - }, - "devDependencies": { - "@sinclair/typebox": "^0.34.0", - "@types/node": "^22.0.0", - "openclaw": "*", - "typescript": "^5.0.0" - }, - "openclaw": { - "extensions": [ - "./dist/index.js" - ] - } + "name": "@mrc2204/agent-smart-memo", + "version": "5.1.13", + "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", + "type": "module", + "main": "dist/index.js", + "bin": { + "asm": "bin/asm.mjs", + "agent-smart-memo": "bin/asm.mjs" + }, + "openclaw": { + "extensions": [ + "./dist/index.js" + ] + }, + "scripts": { + "build": "npm run build:openclaw && npm run sync:openclaw:dist", + "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", + "build:openclaw": "tsc -p tsconfig.openclaw.json", + "build:paperclip": "tsc -p tsconfig.paperclip.json", + "build:core": "tsc -p tsconfig.core.json", + "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", + "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", + "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", + "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", + "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", + "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", + "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", + "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", + "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", + "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", + "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", + "publish:core": "npm run package:core && node scripts/publish-target.mjs core", + "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", + "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", + "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", + "test:asm-cli": "npx tsx tests/test-asm-cli.ts", + "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", + "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", + "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", + "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", + "validate:ab": "npx tsx scripts/validate-ab.ts", + "init-openclaw": "node scripts/init-openclaw.mjs", + "asm": "node bin/asm.mjs" + }, + "dependencies": { + "dotenv": "^17.3.1" + }, + "devDependencies": { + "@sinclair/typebox": "^0.34.0", + "@types/node": "^22.0.0", + "openclaw": "*", + "typescript": "^5.0.0" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cong91/agent-smart-memo.git" + }, + "keywords": [ + "openclaw", + "plugin", + "memory", + "ai", + "agent", + "smart-memo", + "qdrant", + "auto-capture" + ], + "author": "mrc2204", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/", + "bin/", + "scripts/init-openclaw.mjs", + "openclaw.plugin.json", + "CONFIG.example.json", + "README.md", + "LICENSE" + ] } From 722866be60a93571436983292a118a4f1093c683 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 15:20:04 +0700 Subject: [PATCH 18/24] chore(release): bump version to 5.1.14 --- package.json | 162 +++++++++++++++++++++++++-------------------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/package.json b/package.json index dbd2661..7e547c1 100644 --- a/package.json +++ b/package.json @@ -1,83 +1,83 @@ { - "name": "@mrc2204/agent-smart-memo", - "version": "5.1.13", - "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", - "type": "module", - "main": "dist/index.js", - "bin": { - "asm": "bin/asm.mjs", - "agent-smart-memo": "bin/asm.mjs" - }, - "openclaw": { - "extensions": [ - "./dist/index.js" - ] - }, - "scripts": { - "build": "npm run build:openclaw && npm run sync:openclaw:dist", - "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", - "build:openclaw": "tsc -p tsconfig.openclaw.json", - "build:paperclip": "tsc -p tsconfig.paperclip.json", - "build:core": "tsc -p tsconfig.core.json", - "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", - "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", - "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", - "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", - "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", - "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", - "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", - "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", - "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", - "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", - "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", - "publish:core": "npm run package:core && node scripts/publish-target.mjs core", - "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", - "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", - "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", - "test:asm-cli": "npx tsx tests/test-asm-cli.ts", - "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", - "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", - "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", - "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", - "validate:ab": "npx tsx scripts/validate-ab.ts", - "init-openclaw": "node scripts/init-openclaw.mjs", - "asm": "node bin/asm.mjs" - }, - "dependencies": { - "dotenv": "^17.3.1" - }, - "devDependencies": { - "@sinclair/typebox": "^0.34.0", - "@types/node": "^22.0.0", - "openclaw": "*", - "typescript": "^5.0.0" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/cong91/agent-smart-memo.git" - }, - "keywords": [ - "openclaw", - "plugin", - "memory", - "ai", - "agent", - "smart-memo", - "qdrant", - "auto-capture" - ], - "author": "mrc2204", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist/", - "bin/", - "scripts/init-openclaw.mjs", - "openclaw.plugin.json", - "CONFIG.example.json", - "README.md", - "LICENSE" - ] + "name": "@mrc2204/agent-smart-memo", + "version": "5.1.14", + "description": "Smart Memory Plugin for OpenClaw — structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", + "keywords": [ + "agent", + "ai", + "auto-capture", + "memory", + "openclaw", + "plugin", + "qdrant", + "smart-memo" + ], + "license": "MIT", + "author": "mrc2204", + "repository": { + "type": "git", + "url": "git+https://github.com/cong91/agent-smart-memo.git" + }, + "bin": { + "agent-smart-memo": "bin/asm.mjs", + "asm": "bin/asm.mjs" + }, + "files": [ + "dist/", + "bin/", + "scripts/init-openclaw.mjs", + "openclaw.plugin.json", + "CONFIG.example.json", + "README.md", + "LICENSE" + ], + "type": "module", + "main": "dist/index.js", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "npm run build:openclaw && npm run sync:openclaw:dist", + "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", + "build:openclaw": "tsc -p tsconfig.openclaw.json", + "build:paperclip": "tsc -p tsconfig.paperclip.json", + "build:core": "tsc -p tsconfig.core.json", + "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", + "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", + "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", + "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", + "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", + "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", + "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", + "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", + "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", + "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", + "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", + "publish:core": "npm run package:core && node scripts/publish-target.mjs core", + "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", + "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", + "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", + "test:asm-cli": "npx tsx tests/test-asm-cli.ts", + "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", + "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", + "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", + "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", + "validate:ab": "npx tsx scripts/validate-ab.ts", + "init-openclaw": "node scripts/init-openclaw.mjs", + "asm": "node bin/asm.mjs" + }, + "dependencies": { + "dotenv": "^17.3.1" + }, + "devDependencies": { + "@sinclair/typebox": "^0.34.0", + "@types/node": "^22.0.0", + "openclaw": "*", + "typescript": "^5.0.0" + }, + "openclaw": { + "extensions": [ + "./dist/index.js" + ] + } } From b3df52b49fe45b8f7cd9a98a815d0f808063ab6e Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 17:34:06 +0700 Subject: [PATCH 19/24] fix: split init-setup wizard from openclaw binding flow --- src/cli/platform-installers.ts | 490 +++++++++++++++--- tests/test-asm-cli.ts | 881 ++++++++++++++++++++++----------- 2 files changed, 1005 insertions(+), 366 deletions(-) diff --git a/src/cli/platform-installers.ts b/src/cli/platform-installers.ts index 1e59189..d198171 100644 --- a/src/cli/platform-installers.ts +++ b/src/cli/platform-installers.ts @@ -1,6 +1,8 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; import type { runInitOpenClaw } from "../../scripts/init-openclaw.mjs"; import { @@ -53,6 +55,8 @@ export interface AsmPlatformInstaller { install(ctx: AsmInstallContext): Promise; } +const ASM_PLUGIN_ID = "agent-smart-memo"; + function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } @@ -97,6 +101,250 @@ function parseNonInteractive(argv: string[] = []): { return { nonInteractive: enabled, autoApply: enabled }; } +function toInt(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; +} + +function toFloat(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function toBool(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "y"].includes(normalized)) return true; + if (["0", "false", "no", "n"].includes(normalized)) return false; + } + return fallback; +} + +async function promptYesNo( + question: string, + fallback = false, +): Promise { + if (!input.isTTY || !output.isTTY) return fallback; + const rl = createInterface({ input, output }); + try { + const answer = String(await rl.question(question)) + .trim() + .toLowerCase(); + if (!answer) return fallback; + if (["y", "yes", "1", "true"].includes(answer)) return true; + if (["n", "no", "0", "false"].includes(answer)) return false; + return fallback; + } finally { + rl.close(); + } +} + +async function promptText(question: string, fallback: string): Promise { + if (!input.isTTY || !output.isTTY) return fallback; + const rl = createInterface({ input, output }); + try { + const answer = String( + await rl.question(`${question} [${fallback}]: `), + ).trim(); + return answer || fallback; + } finally { + rl.close(); + } +} + +async function buildWizardCoreConfig( + existing: Record | undefined, + defaults: { + projectWorkspaceRoot: string; + qdrantHost: string; + qdrantPort: number; + qdrantCollection: string; + qdrantVectorSize: number; + llmBaseUrl: string; + llmApiKey: string; + llmModel: string; + embedBaseUrl: string; + embedBackend: string; + embedModel: string; + embedDimensions: number; + autoCaptureEnabled: boolean; + autoCaptureMinConfidence: number; + contextWindowMaxTokens: number; + summarizeEveryActions: number; + slotDbDir: string; + }, + mode: { nonInteractive: boolean }, +): Promise> { + const current = existing || {}; + + if (mode.nonInteractive) { + return { + projectWorkspaceRoot: + text(current.projectWorkspaceRoot) || defaults.projectWorkspaceRoot, + qdrantHost: text(current.qdrantHost) || defaults.qdrantHost, + qdrantPort: toInt(current.qdrantPort, defaults.qdrantPort), + qdrantCollection: + text(current.qdrantCollection) || defaults.qdrantCollection, + qdrantVectorSize: toInt( + current.qdrantVectorSize, + defaults.qdrantVectorSize, + ), + llmBaseUrl: text(current.llmBaseUrl) || defaults.llmBaseUrl, + llmApiKey: text(current.llmApiKey) || defaults.llmApiKey, + llmModel: text(current.llmModel) || defaults.llmModel, + embedBaseUrl: text(current.embedBaseUrl) || defaults.embedBaseUrl, + embedBackend: text(current.embedBackend) || defaults.embedBackend, + embedModel: text(current.embedModel) || defaults.embedModel, + embedDimensions: toInt(current.embedDimensions, defaults.embedDimensions), + autoCaptureEnabled: toBool( + current.autoCaptureEnabled, + defaults.autoCaptureEnabled, + ), + autoCaptureMinConfidence: toFloat( + current.autoCaptureMinConfidence, + defaults.autoCaptureMinConfidence, + ), + contextWindowMaxTokens: toInt( + current.contextWindowMaxTokens, + defaults.contextWindowMaxTokens, + ), + summarizeEveryActions: toInt( + current.summarizeEveryActions, + defaults.summarizeEveryActions, + ), + storage: { + slotDbDir: + text( + (current.storage as Record | undefined)?.slotDbDir, + ) || defaults.slotDbDir, + }, + }; + } + + const projectWorkspaceRoot = await promptText( + "projectWorkspaceRoot", + text(current.projectWorkspaceRoot) || defaults.projectWorkspaceRoot, + ); + const qdrantHost = await promptText( + "Qdrant host", + text(current.qdrantHost) || defaults.qdrantHost, + ); + const qdrantPort = toInt( + await promptText( + "Qdrant port", + String(toInt(current.qdrantPort, defaults.qdrantPort)), + ), + defaults.qdrantPort, + ); + const qdrantCollection = await promptText( + "Qdrant collection", + text(current.qdrantCollection) || defaults.qdrantCollection, + ); + const qdrantVectorSize = toInt( + await promptText( + "Qdrant vector size", + String(toInt(current.qdrantVectorSize, defaults.qdrantVectorSize)), + ), + defaults.qdrantVectorSize, + ); + const llmBaseUrl = await promptText( + "LLM base URL", + text(current.llmBaseUrl) || defaults.llmBaseUrl, + ); + const llmApiKey = await promptText( + "LLM API key", + text(current.llmApiKey) || defaults.llmApiKey, + ); + const llmModel = await promptText( + "LLM model", + text(current.llmModel) || defaults.llmModel, + ); + const embedBaseUrl = await promptText( + "Embedding base URL", + text(current.embedBaseUrl) || defaults.embedBaseUrl, + ); + const embedBackend = await promptText( + "Embedding backend", + text(current.embedBackend) || defaults.embedBackend, + ); + const embedModel = await promptText( + "Embedding model", + text(current.embedModel) || defaults.embedModel, + ); + const embedDimensions = toInt( + await promptText( + "Embedding dimensions", + String(toInt(current.embedDimensions, defaults.embedDimensions)), + ), + defaults.embedDimensions, + ); + const autoCaptureEnabled = toBool( + await promptText( + "autoCaptureEnabled (true/false)", + String(toBool(current.autoCaptureEnabled, defaults.autoCaptureEnabled)), + ), + defaults.autoCaptureEnabled, + ); + const autoCaptureMinConfidence = toFloat( + await promptText( + "autoCaptureMinConfidence", + String( + toFloat( + current.autoCaptureMinConfidence, + defaults.autoCaptureMinConfidence, + ), + ), + ), + defaults.autoCaptureMinConfidence, + ); + const contextWindowMaxTokens = toInt( + await promptText( + "contextWindowMaxTokens", + String( + toInt(current.contextWindowMaxTokens, defaults.contextWindowMaxTokens), + ), + ), + defaults.contextWindowMaxTokens, + ); + const summarizeEveryActions = toInt( + await promptText( + "summarizeEveryActions", + String( + toInt(current.summarizeEveryActions, defaults.summarizeEveryActions), + ), + ), + defaults.summarizeEveryActions, + ); + const slotDbDir = await promptText( + "slotDbDir", + text((current.storage as Record | undefined)?.slotDbDir) || + defaults.slotDbDir, + ); + + return { + projectWorkspaceRoot, + qdrantHost, + qdrantPort, + qdrantCollection, + qdrantVectorSize, + llmBaseUrl, + llmApiKey, + llmModel, + embedBaseUrl, + embedBackend, + embedModel, + embedDimensions, + autoCaptureEnabled, + autoCaptureMinConfidence, + contextWindowMaxTokens, + summarizeEveryActions, + storage: { + slotDbDir, + }, + }; +} + export async function runInitSetupFlow({ log = console.log, env = process.env, @@ -140,67 +388,80 @@ export async function runInitSetupFlow({ core: {}, adapters: {}, }; + + const adapters = { + ...(baseConfig.adapters || {}), + openclaw: { + enabled: true, + ...((baseConfig.adapters || {}).openclaw || {}), + }, + paperclip: { + enabled: true, + ...((baseConfig.adapters || {}).paperclip || {}), + }, + opencode: { + enabled: true, + mode: "read-only", + ...((baseConfig.adapters || {}).opencode || {}), + }, + }; + + const shouldRunWizard = mode.nonInteractive + ? true + : await promptYesNo( + "[ASM-104] Run shared ASM setup wizard now? [y/N] ", + false, + ); + + if (!shouldRunWizard) { + if (doctor.exists) { + log(`[ASM-104] init-setup kept existing shared config at: ${path}`); + log( + "[ASM-104] You can re-run `asm init-setup` anytime to open the full wizard.", + ); + return { + ok: true, + step: "init-setup", + path, + existed: true, + nonInteractive: mode.nonInteractive, + }; + } + + const minimalConfig = { + schemaVersion: 1, + core: {}, + adapters, + }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(minimalConfig, null, 2)}\n`, "utf8"); + log(`[ASM-104] init-setup created minimal shared config at: ${path}`); + log( + "[ASM-104] Shared config wizard was skipped. Edit this file later or rerun `asm init-setup` for full prompts.", + ); + return { + ok: true, + step: "init-setup", + path, + existed: false, + nonInteractive: mode.nonInteractive, + }; + } + + const wizardCore = await buildWizardCoreConfig( + baseConfig.core as Record | undefined, + bootstrapDefaults, + mode, + ); + const nextConfig = { schemaVersion: typeof baseConfig.schemaVersion === "number" ? baseConfig.schemaVersion : 1, ...baseConfig, - core: { - ...(baseConfig.core || {}), - projectWorkspaceRoot: - baseConfig.core?.projectWorkspaceRoot || - bootstrapDefaults.projectWorkspaceRoot, - qdrantHost: baseConfig.core?.qdrantHost || bootstrapDefaults.qdrantHost, - qdrantPort: baseConfig.core?.qdrantPort || bootstrapDefaults.qdrantPort, - qdrantCollection: - baseConfig.core?.qdrantCollection || bootstrapDefaults.qdrantCollection, - qdrantVectorSize: - baseConfig.core?.qdrantVectorSize || bootstrapDefaults.qdrantVectorSize, - llmBaseUrl: baseConfig.core?.llmBaseUrl || bootstrapDefaults.llmBaseUrl, - llmApiKey: baseConfig.core?.llmApiKey || bootstrapDefaults.llmApiKey, - llmModel: baseConfig.core?.llmModel || bootstrapDefaults.llmModel, - embedBaseUrl: - baseConfig.core?.embedBaseUrl || bootstrapDefaults.embedBaseUrl, - embedBackend: - baseConfig.core?.embedBackend || bootstrapDefaults.embedBackend, - embedModel: baseConfig.core?.embedModel || bootstrapDefaults.embedModel, - embedDimensions: - baseConfig.core?.embedDimensions || bootstrapDefaults.embedDimensions, - autoCaptureEnabled: - baseConfig.core?.autoCaptureEnabled ?? - bootstrapDefaults.autoCaptureEnabled, - autoCaptureMinConfidence: - baseConfig.core?.autoCaptureMinConfidence || - bootstrapDefaults.autoCaptureMinConfidence, - contextWindowMaxTokens: - baseConfig.core?.contextWindowMaxTokens || - bootstrapDefaults.contextWindowMaxTokens, - summarizeEveryActions: - baseConfig.core?.summarizeEveryActions || - bootstrapDefaults.summarizeEveryActions, - storage: { - ...(baseConfig.core?.storage || {}), - slotDbDir: - baseConfig.core?.storage?.slotDbDir || bootstrapDefaults.slotDbDir, - }, - }, - adapters: { - ...(baseConfig.adapters || {}), - openclaw: { - enabled: true, - ...((baseConfig.adapters || {}).openclaw || {}), - }, - paperclip: { - enabled: true, - ...((baseConfig.adapters || {}).paperclip || {}), - }, - opencode: { - enabled: true, - mode: "read-only", - ...((baseConfig.adapters || {}).opencode || {}), - }, - }, + core: wizardCore, + adapters, }; mkdirSync(dirname(path), { recursive: true }); @@ -224,7 +485,7 @@ export async function runInitSetupFlow({ async function runSetupOpenClawInstall( ctx: AsmInstallContext, ): Promise { - const { runner, initOpenClaw, log, argv } = ctx; + const { runner, log, env, homeDir } = ctx; log("[ASM-84] setup-openclaw: checking OpenClaw CLI ..."); const openclawVersion = runner("openclaw", ["--version"]); if (!openclawVersion.ok) { @@ -261,16 +522,114 @@ async function runSetupOpenClawInstall( if (install.stdout) log(install.stdout); if (install.stderr) log(install.stderr); - const mode = parseNonInteractive(argv); - const initResult = await initOpenClaw({ - interactive: !mode.nonInteractive, - autoApply: mode.autoApply, - }); + const asmConfigPath = resolveAsmConfigPath({ env, homeDir }); + if (!existsSync(asmConfigPath)) { + log(`[ASM-104] ❌ Shared ASM config not found at: ${asmConfigPath}`); + log( + "[ASM-104] Run `asm init-setup` first to create shared config, then rerun `asm install openclaw`.", + ); + return { + ok: false, + step: "missing-shared-config", + platform: "openclaw", + details: { asmConfigPath }, + }; + } + + const openclawConfigPath = + text(env.OPENCLAW_CONFIG_PATH) || + text(env.OPENCLAW_RUNTIME_CONFIG) || + join(homeDir || env.HOME || process.cwd(), ".openclaw", "openclaw.json"); + const openclawConfigExisted = existsSync(openclawConfigPath); + + let openclawConfig: Record = {}; + if (existsSync(openclawConfigPath)) { + try { + const parsed = JSON.parse(readFileSync(openclawConfigPath, "utf8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + openclawConfig = parsed as Record; + } + } catch (error) { + return { + ok: false, + step: "invalid-openclaw-config-json", + platform: "openclaw", + details: { + openclawConfigPath, + error: error instanceof Error ? error.message : String(error), + }, + }; + } + } + + const plugins = + openclawConfig.plugins && + typeof openclawConfig.plugins === "object" && + !Array.isArray(openclawConfig.plugins) + ? { ...(openclawConfig.plugins as Record) } + : {}; + const entries = + plugins.entries && + typeof plugins.entries === "object" && + !Array.isArray(plugins.entries) + ? { ...(plugins.entries as Record) } + : {}; + const prevEntry = + entries[ASM_PLUGIN_ID] && + typeof entries[ASM_PLUGIN_ID] === "object" && + !Array.isArray(entries[ASM_PLUGIN_ID]) + ? (entries[ASM_PLUGIN_ID] as Record) + : {}; + const prevEntryConfig = + prevEntry.config && + typeof prevEntry.config === "object" && + !Array.isArray(prevEntry.config) + ? (prevEntry.config as Record) + : {}; + const allow = Array.isArray(plugins.allow) + ? (plugins.allow as unknown[]).map((item) => text(item)).filter(Boolean) + : []; + if (!allow.includes(ASM_PLUGIN_ID)) allow.push(ASM_PLUGIN_ID); + + const nextOpenClawConfig = { + ...openclawConfig, + plugins: { + ...plugins, + allow, + entries: { + ...entries, + [ASM_PLUGIN_ID]: { + ...prevEntry, + enabled: true, + config: { + ...prevEntryConfig, + asmConfigPath, + }, + }, + }, + }, + }; + + mkdirSync(dirname(openclawConfigPath), { recursive: true }); + writeFileSync( + openclawConfigPath, + `${JSON.stringify(nextOpenClawConfig, null, 2)}\n`, + "utf8", + ); + log( + `[ASM-104] install openclaw bound plugins.entries.${ASM_PLUGIN_ID}.config.asmConfigPath -> ${asmConfigPath}`, + ); + log( + `[ASM-104] install openclaw ${openclawConfigExisted ? "updated" : "created"} config at: ${openclawConfigPath}`, + ); return { ok: true, - step: initResult?.applied ? "done" : "init-openclaw", + step: "bind-openclaw-config", platform: "openclaw", - details: { applied: Boolean(initResult?.applied) }, + details: { + asmConfigPath, + openclawConfigPath, + }, }; } @@ -402,10 +761,6 @@ const openclawInstaller: AsmPlatformInstaller = { }, }; -function deriveRepoRoot(homeDir?: string): string { - return homeDir ? join(homeDir, "Work", "projects") : process.cwd(); -} - function packageArtifactExists( repoRoot: string, relativePath: string, @@ -442,7 +797,6 @@ const paperclipInstaller: AsmPlatformInstaller = { argv: ["--yes"], }); const asmConfigPath = String(initSetup.path); - const repoRoot = deriveRepoRoot(ctx.homeDir ? undefined : undefined); const packageRuntime = ctx.runner("npm", ["run", "package:paperclip"]); if (!packageRuntime.ok) { diff --git a/tests/test-asm-cli.ts b/tests/test-asm-cli.ts index 4922f81..d8baaf6 100644 --- a/tests/test-asm-cli.ts +++ b/tests/test-asm-cli.ts @@ -1,343 +1,628 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { - detectPluginInstalled, - parseAsmCliArgs, - runSetupOpenClawFlow, + detectPluginInstalled, + parseAsmCliArgs, + runSetupOpenClawFlow, } from "../bin/asm.mjs"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { - createShellRunner, - getAsmPlatformInstaller, - listAsmPlatformInstallers, - runInitSetupFlow, - runInstallPlatformFlow, + createShellRunner, + getAsmPlatformInstaller, + listAsmPlatformInstallers, + runInitSetupFlow, + runInstallPlatformFlow, } from "../src/cli/platform-installers.ts"; function assert(condition: boolean, message: string): void { - if (!condition) throw new Error(message); + if (!condition) throw new Error(message); } -function assertEqual(actual: unknown, expected: unknown, message: string): void { - const a = JSON.stringify(actual); - const e = JSON.stringify(expected); - if (a !== e) { - throw new Error(`${message}\nactual=${a}\nexpected=${e}`); - } +function assertEqual( + actual: unknown, + expected: unknown, + message: string, +): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) { + throw new Error(`${message}\nactual=${a}\nexpected=${e}`); + } } function test(name: string, fn: () => void | Promise): void { - Promise.resolve() - .then(fn) - .then(() => { - console.log(`✅ ${name}`); - }) - .catch((error) => { - console.error(`❌ ${name}`); - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); + Promise.resolve() + .then(fn) + .then(() => { + console.log(`✅ ${name}`); + }) + .catch((error) => { + console.error(`❌ ${name}`); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); } test("parseAsmCliArgs supports help, setup-openclaw, install , and init-openclaw", () => { - assertEqual(parseAsmCliArgs([]), { command: "help", argv: [] }, "empty should map to help"); - assertEqual(parseAsmCliArgs(["--help"]), { command: "help", argv: [] }, "--help should map to help"); - assertEqual( - parseAsmCliArgs(["setup-openclaw"]), - { command: "setup-openclaw", argv: [] }, - "setup-openclaw should parse", - ); - assertEqual( - parseAsmCliArgs(["setup", "openclaw", "--x"]), - { command: "setup-openclaw", argv: ["--x"] }, - "setup openclaw alias should parse", - ); - assertEqual( - parseAsmCliArgs(["install", "openclaw", "--yes"]), - { command: "install-platform", platform: "openclaw", argv: ["--yes"] }, - "install openclaw should parse", - ); - assertEqual( - parseAsmCliArgs(["install", "paperclip"]), - { command: "install-platform", platform: "paperclip", argv: [] }, - "install paperclip should parse", - ); - assertEqual( - parseAsmCliArgs(["init-setup", "--yes"]), - { command: "init-setup", argv: ["--yes"] }, - "init-setup should parse", - ); - assertEqual( - parseAsmCliArgs(["mcp", "opencode"]), - { command: "mcp-opencode", argv: [] }, - "mcp opencode should parse", - ); - assertEqual( - parseAsmCliArgs(["init-openclaw", "--non-interactive"]), - { command: "init-openclaw", argv: ["--non-interactive"] }, - "init-openclaw should parse", - ); - assertEqual( - parseAsmCliArgs(["init", "openclaw", "--non-interactive"]), - { command: "init-openclaw", argv: ["--non-interactive"] }, - "init openclaw alias should parse", - ); - assertEqual( - parseAsmCliArgs(["project-event", "--project-id", "p1"]), - { command: "project-event", argv: ["--project-id", "p1"] }, - "project-event should parse", - ); + assertEqual( + parseAsmCliArgs([]), + { command: "help", argv: [] }, + "empty should map to help", + ); + assertEqual( + parseAsmCliArgs(["--help"]), + { command: "help", argv: [] }, + "--help should map to help", + ); + assertEqual( + parseAsmCliArgs(["setup-openclaw"]), + { command: "setup-openclaw", argv: [] }, + "setup-openclaw should parse", + ); + assertEqual( + parseAsmCliArgs(["setup", "openclaw", "--x"]), + { command: "setup-openclaw", argv: ["--x"] }, + "setup openclaw alias should parse", + ); + assertEqual( + parseAsmCliArgs(["install", "openclaw", "--yes"]), + { command: "install-platform", platform: "openclaw", argv: ["--yes"] }, + "install openclaw should parse", + ); + assertEqual( + parseAsmCliArgs(["install", "paperclip"]), + { command: "install-platform", platform: "paperclip", argv: [] }, + "install paperclip should parse", + ); + assertEqual( + parseAsmCliArgs(["init-setup", "--yes"]), + { command: "init-setup", argv: ["--yes"] }, + "init-setup should parse", + ); + assertEqual( + parseAsmCliArgs(["mcp", "opencode"]), + { command: "mcp-opencode", argv: [] }, + "mcp opencode should parse", + ); + assertEqual( + parseAsmCliArgs(["init-openclaw", "--non-interactive"]), + { command: "init-openclaw", argv: ["--non-interactive"] }, + "init-openclaw should parse", + ); + assertEqual( + parseAsmCliArgs(["init", "openclaw", "--non-interactive"]), + { command: "init-openclaw", argv: ["--non-interactive"] }, + "init openclaw alias should parse", + ); + assertEqual( + parseAsmCliArgs(["project-event", "--project-id", "p1"]), + { command: "project-event", argv: ["--project-id", "p1"] }, + "project-event should parse", + ); }); test("createShellRunner normalizes process output", () => { - const runner = createShellRunner((command: string, args: string[]) => { - assertEqual(command, "openclaw", "command should pass through"); - assertEqual(args, ["--version"], "args should pass through"); - return { status: 0, stdout: " 1.2.3 \n", stderr: "" } as any; - }); - - const result = runner("openclaw", ["--version"]); - assert(result.ok === true, "runner should mark ok when status=0"); - assertEqual(result.stdout, "1.2.3", "stdout should trim"); + const runner = createShellRunner(((command: string, args: string[]) => { + assertEqual(command, "openclaw", "command should pass through"); + assertEqual(args, ["--version"], "args should pass through"); + return { status: 0, stdout: " 1.2.3 \n", stderr: "" } as any; + }) as any); + + const result = runner("openclaw", ["--version"]); + assert(result.ok === true, "runner should mark ok when status=0"); + assertEqual(result.stdout, "1.2.3", "stdout should trim"); }); test("detectPluginInstalled supports list --json shape", () => { - const calls: Array = []; - const runner = (command: string, args: string[]) => { - calls.push(`${command} ${args.join(" ")}`); - if (args.includes("--json")) { - return { - ok: true, - code: 0, - stdout: JSON.stringify({ plugins: [{ id: "agent-smart-memo" }] }), - stderr: "", - error: "", - }; - } - - return { ok: false, code: 1, stdout: "", stderr: "", error: "" }; - }; - - const state = detectPluginInstalled(runner as any); - assertEqual(state.installed, true, "plugin should be detected from JSON"); - assert(calls[0]?.includes("plugins list --json"), "should call list --json first"); + const calls: Array = []; + const runner = (command: string, args: string[]) => { + calls.push(`${command} ${args.join(" ")}`); + if (args.includes("--json")) { + return { + ok: true, + code: 0, + stdout: JSON.stringify({ plugins: [{ id: "agent-smart-memo" }] }), + stderr: "", + error: "", + }; + } + + return { ok: false, code: 1, stdout: "", stderr: "", error: "" }; + }; + + const state = detectPluginInstalled(runner as any); + assertEqual(state.installed, true, "plugin should be detected from JSON"); + assert( + calls[0]?.includes("plugins list --json"), + "should call list --json first", + ); }); test("runSetupOpenClawFlow installs plugin when missing and runs init", async () => { - const logLines: string[] = []; - const calls: Array = []; - const runner = (command: string, args: string[]) => { - calls.push(`${command} ${args.join(" ")}`); - - if (args[0] === "--version") { - return { ok: true, code: 0, stdout: "1.0.0", stderr: "", error: "" }; - } - - if (args[0] === "plugins" && args[1] === "list" && args[2] === "--json") { - return { ok: true, code: 0, stdout: JSON.stringify({ plugins: [] }), stderr: "", error: "" }; - } - - if (args[0] === "plugins" && args[1] === "list") { - return { ok: true, code: 0, stdout: "", stderr: "", error: "" }; - } - - if (args[0] === "plugins" && args[1] === "install") { - return { ok: true, code: 0, stdout: "installed", stderr: "", error: "" }; - } - - return { ok: false, code: 1, stdout: "", stderr: "unknown", error: "" }; - }; - - let initCalled = 0; - let initParams: any = null; - const initOpenClaw = async (params?: any) => { - initCalled += 1; - initParams = params; - return { applied: true }; - }; - - const result = await runSetupOpenClawFlow({ - runner: runner as any, - initOpenClaw, - log: (line: string) => logLines.push(line), - }); - - assertEqual(result.ok, true, "flow should succeed"); - assertEqual(initCalled, 1, "init flow should be called once"); - assertEqual(initParams, { interactive: true, autoApply: false }, "default setup flow should remain interactive"); - assert( - calls.some((line) => line.includes("plugins install @mrc2204/agent-smart-memo")), - "should install plugin when missing", - ); - assert(logLines.some((line) => line.includes("Setup summary (before execution)")), "should print setup summary header"); - assert(logLines.some((line) => line.includes("already configured")), "should print already configured section"); - assert(logLines.some((line) => line.includes("will add")), "should print will add section"); - assert(logLines.some((line) => line.includes("will update")), "should print will update section"); - assert(logLines.some((line) => line.includes("setup-openclaw completed")), "should print completion line"); + const logLines: string[] = []; + const calls: Array = []; + const runner = (command: string, args: string[]) => { + calls.push(`${command} ${args.join(" ")}`); + + if (args[0] === "--version") { + return { ok: true, code: 0, stdout: "1.0.0", stderr: "", error: "" }; + } + + if (args[0] === "plugins" && args[1] === "list" && args[2] === "--json") { + return { + ok: true, + code: 0, + stdout: JSON.stringify({ plugins: [] }), + stderr: "", + error: "", + }; + } + + if (args[0] === "plugins" && args[1] === "list") { + return { ok: true, code: 0, stdout: "", stderr: "", error: "" }; + } + + if (args[0] === "plugins" && args[1] === "install") { + return { ok: true, code: 0, stdout: "installed", stderr: "", error: "" }; + } + + return { ok: false, code: 1, stdout: "", stderr: "unknown", error: "" }; + }; + + let initCalled = 0; + let initParams: any = null; + const initOpenClaw = async (params?: any) => { + initCalled += 1; + initParams = params; + return { applied: true, configPath: "~/.openclaw/openclaw.json" }; + }; + + const result = await runSetupOpenClawFlow({ + runner: runner as any, + initOpenClaw, + log: (line: string) => logLines.push(line), + }); + + assertEqual(result.ok, true, "flow should succeed"); + assertEqual(initCalled, 1, "init flow should be called once"); + assertEqual( + initParams, + { interactive: true, autoApply: false }, + "default setup flow should remain interactive", + ); + assert( + calls.some((line) => + line.includes("plugins install @mrc2204/agent-smart-memo"), + ), + "should install plugin when missing", + ); + assert( + logLines.some((line) => line.includes("Setup summary (before execution)")), + "should print setup summary header", + ); + assert( + logLines.some((line) => line.includes("already configured")), + "should print already configured section", + ); + assert( + logLines.some((line) => line.includes("will add")), + "should print will add section", + ); + assert( + logLines.some((line) => line.includes("will update")), + "should print will update section", + ); + assert( + logLines.some((line) => line.includes("setup-openclaw completed")), + "should print completion line", + ); }); test("runSetupOpenClawFlow supports non-interactive --yes mode", async () => { - const runner = (_command: string, args: string[]) => { - if (args[0] === "--version") { - return { ok: true, code: 0, stdout: "1.0.0", stderr: "", error: "" }; - } - - if (args[0] === "plugins" && args[1] === "list" && args[2] === "--json") { - return { - ok: true, - code: 0, - stdout: JSON.stringify({ plugins: [{ id: "agent-smart-memo" }] }), - stderr: "", - error: "", - }; - } - - if (args[0] === "plugins" && args[1] === "list") { - return { ok: true, code: 0, stdout: "", stderr: "", error: "" }; - } - - return { ok: false, code: 1, stdout: "", stderr: "unknown", error: "" }; - }; - - let initParams: any = null; - const result = await runSetupOpenClawFlow({ - runner: runner as any, - initOpenClaw: async (params?: any) => { - initParams = params; - return { applied: true }; - }, - argv: ["--yes"], - log: () => {}, - }); - - assertEqual(result.ok, true, "flow should succeed in --yes mode"); - assertEqual(initParams, { interactive: false, autoApply: true }, "--yes should force non-interactive apply"); + const runner = (_command: string, args: string[]) => { + if (args[0] === "--version") { + return { ok: true, code: 0, stdout: "1.0.0", stderr: "", error: "" }; + } + + if (args[0] === "plugins" && args[1] === "list" && args[2] === "--json") { + return { + ok: true, + code: 0, + stdout: JSON.stringify({ plugins: [{ id: "agent-smart-memo" }] }), + stderr: "", + error: "", + }; + } + + if (args[0] === "plugins" && args[1] === "list") { + return { ok: true, code: 0, stdout: "", stderr: "", error: "" }; + } + + return { ok: false, code: 1, stdout: "", stderr: "unknown", error: "" }; + }; + + let initParams: any = null; + const result = await runSetupOpenClawFlow({ + runner: runner as any, + initOpenClaw: async (params?: any) => { + initParams = params; + return { applied: true, configPath: "~/.openclaw/openclaw.json" }; + }, + argv: ["--yes"] as any, + log: () => {}, + }); + + assertEqual(result.ok, true, "flow should succeed in --yes mode"); + assertEqual( + initParams, + { interactive: false, autoApply: true }, + "--yes should force non-interactive apply", + ); }); test("installer registry exposes openclaw/paperclip/opencode descriptors", () => { - const installers = listAsmPlatformInstallers(); - assert(installers.some((item) => item.id === "openclaw" && item.status === "implemented"), "openclaw installer descriptor should exist"); - assert(installers.some((item) => item.id === "paperclip"), "paperclip installer descriptor should exist"); - assert(installers.some((item) => item.id === "opencode"), "opencode installer descriptor should exist"); - assertEqual(Boolean(getAsmPlatformInstaller("openclaw")), true, "installer lookup should resolve openclaw"); + const installers = listAsmPlatformInstallers(); + assert( + installers.some( + (item) => item.id === "openclaw" && item.status === "implemented", + ), + "openclaw installer descriptor should exist", + ); + assert( + installers.some((item) => item.id === "paperclip"), + "paperclip installer descriptor should exist", + ); + assert( + installers.some((item) => item.id === "opencode"), + "opencode installer descriptor should exist", + ); + assertEqual( + Boolean(getAsmPlatformInstaller("openclaw")), + true, + "installer lookup should resolve openclaw", + ); }); test("runInstallPlatformFlow routes openclaw to setup-openclaw flow", async () => { - let called = 0; - const result = await runInstallPlatformFlow({ - platform: "openclaw", - runner: (() => ({ ok: true, code: 0, stdout: "1.0.0", stderr: "", error: "" })) as any, - initOpenClaw: async (params?: any) => { - called += 1; - return { applied: Boolean(params) }; - }, - log: () => {}, - argv: ["--yes"], - }); - - assertEqual(result.ok, true, "install openclaw should route through existing setup flow"); - assertEqual(called, 1, "install openclaw should invoke bootstrap path once"); + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "asm-openclaw-install-")); + const asmConfigPath = path.join(home, ".config", "asm", "config.json"); + fs.mkdirSync(path.dirname(asmConfigPath), { recursive: true }); + fs.writeFileSync( + asmConfigPath, + JSON.stringify( + { + schemaVersion: 1, + core: { + projectWorkspaceRoot: "~/Work/projects", + qdrantHost: "localhost", + qdrantPort: 6333, + qdrantCollection: "mrc_bot", + qdrantVectorSize: 1024, + llmBaseUrl: "http://localhost:8317/v1", + llmApiKey: "proxypal-local", + llmModel: "gpt-5.4", + embedBaseUrl: "http://localhost:11434", + embedBackend: "ollama", + embedModel: "qwen3-embedding:0.6b", + embedDimensions: 1024, + autoCaptureEnabled: true, + autoCaptureMinConfidence: 0.7, + contextWindowMaxTokens: 32000, + summarizeEveryActions: 6, + storage: { slotDbDir: "~/.local/share/asm/slotdb" }, + }, + }, + null, + 2, + ) + "\n", + ); + + let called = 0; + const result = await runInstallPlatformFlow({ + platform: "openclaw", + runner: ((command: string, args: string[] = []) => { + if (command === "openclaw" && args[0] === "--version") { + return { ok: true, code: 0, stdout: "1.0.0", stderr: "", error: "" }; + } + if ( + command === "openclaw" && + args[0] === "plugins" && + args[1] === "install" + ) { + return { + ok: true, + code: 0, + stdout: "installed", + stderr: "", + error: "", + }; + } + return { ok: false, code: 1, stdout: "", stderr: "unknown", error: "" }; + }) as any, + initOpenClaw: async (params?: any) => { + called += 1; + return { + applied: Boolean(params), + configPath: "~/.openclaw/openclaw.json", + }; + }, + log: () => {}, + argv: ["--yes"], + env: { ...process.env, HOME: home }, + homeDir: home, + }); + + assertEqual( + result.ok, + true, + "install openclaw should bind openclaw config to shared asm config", + ); + assertEqual( + result.step, + "bind-openclaw-config", + "install openclaw should only bind asmConfigPath", + ); + assertEqual(called, 0, "install openclaw must not run init-openclaw wizard"); + + const openclawPath = path.join(home, ".openclaw", "openclaw.json"); + const written = JSON.parse(fs.readFileSync(openclawPath, "utf8")); + assertEqual( + written.plugins.entries["agent-smart-memo"].config.asmConfigPath, + asmConfigPath, + "openclaw config should bind asmConfigPath to shared config", + ); +}); + +test("runInstallPlatformFlow openclaw fails clearly when shared config missing", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "asm-openclaw-missing-shared-"), + ); + + const result = await runInstallPlatformFlow({ + platform: "openclaw", + runner: ((command: string, args: string[] = []) => { + if (command === "openclaw" && args[0] === "--version") { + return { ok: true, code: 0, stdout: "1.0.0", stderr: "", error: "" }; + } + if ( + command === "openclaw" && + args[0] === "plugins" && + args[1] === "install" + ) { + return { + ok: true, + code: 0, + stdout: "installed", + stderr: "", + error: "", + }; + } + return { ok: false, code: 1, stdout: "", stderr: "unknown", error: "" }; + }) as any, + initOpenClaw: async () => + ({ applied: true, configPath: "~/.openclaw/openclaw.json" }) as any, + log: () => {}, + argv: ["--yes"], + env: { ...process.env, HOME: home }, + homeDir: home, + }); + + assertEqual( + result.ok, + false, + "install openclaw should fail when shared config is missing", + ); + assertEqual( + result.step, + "missing-shared-config", + "missing shared config should return explicit failure step", + ); }); test("runInitSetupFlow creates shared ASM config with platform defaults", async () => { - const fs = await import("node:fs"); - const os = await import("node:os"); - const path = await import("node:path"); - - const home = fs.mkdtempSync(path.join(os.tmpdir(), "asm-init-setup-")); - const result = await runInitSetupFlow({ env: { ...process.env, HOME: home }, homeDir: home, argv: ["--yes"], log: () => {} }); - const written = JSON.parse(fs.readFileSync(result.path, "utf8")); - - assertEqual(result.ok, true, "init-setup should succeed"); - assertEqual(written.core.projectWorkspaceRoot, "~/Work/projects", "init-setup should ensure default shared workspace root"); - assertEqual(written.core.qdrantHost, "localhost", "init-setup should write qdrantHost into shared core config"); - assertEqual(written.core.llmBaseUrl, "http://localhost:8317/v1", "init-setup should write llmBaseUrl into shared core config"); - assertEqual(written.core.embedModel, "qwen3-embedding:0.6b", "init-setup should write embedModel into shared core config"); - assertEqual(written.adapters.opencode.mode, "read-only", "init-setup should enforce read-only default for opencode adapter"); + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + + const home = fs.mkdtempSync(path.join(os.tmpdir(), "asm-init-setup-")); + const result = await runInitSetupFlow({ + env: { ...process.env, HOME: home }, + homeDir: home, + argv: ["--yes"], + log: () => {}, + }); + const written = JSON.parse(fs.readFileSync(result.path, "utf8")); + + assertEqual(result.ok, true, "init-setup should succeed"); + assertEqual( + written.core.projectWorkspaceRoot, + "~/Work/projects", + "init-setup should ensure default shared workspace root", + ); + assertEqual( + written.core.qdrantHost, + "localhost", + "init-setup should write qdrantHost into shared core config", + ); + assertEqual( + written.core.llmBaseUrl, + "http://localhost:8317/v1", + "init-setup should write llmBaseUrl into shared core config", + ); + assertEqual( + written.core.embedModel, + "qwen3-embedding:0.6b", + "init-setup should write embedModel into shared core config", + ); + assertEqual( + written.adapters.opencode.mode, + "read-only", + "init-setup should enforce read-only default for opencode adapter", + ); }); test("runInstallPlatformFlow implements paperclip artifact preparation and opencode installer", async () => { - const fs = await import("node:fs"); - const os = await import("node:os"); - const path = await import("node:path"); - - const home = fs.mkdtempSync(path.join(os.tmpdir(), "asm-opencode-install-")); - const runner = ((command: string, args: string[] = []) => { - if (command === "npm" && args.join(" ") === "run package:paperclip") { - return { ok: true, code: 0, stdout: "packaged paperclip runtime", stderr: "", error: "" }; - } - if (command === "npm" && args.join(" ") === "run package:paperclip:plugin-local") { - const artifactDir = path.join(process.cwd(), "artifacts", "paperclip-plugin-local"); - const runtimeDir = path.join(process.cwd(), "artifacts", "npm", "paperclip"); - fs.mkdirSync(artifactDir, { recursive: true }); - fs.mkdirSync(runtimeDir, { recursive: true }); - fs.writeFileSync(path.join(artifactDir, "package.json"), "{}\n"); - fs.writeFileSync(path.join(runtimeDir, "package.json"), "{}\n"); - return { ok: true, code: 0, stdout: "packaged paperclip plugin local", stderr: "", error: "" }; - } - return createShellRunner() (command, args); - }) as any; - - const paperclip = await runInstallPlatformFlow({ - platform: "paperclip", - runner, - initOpenClaw: async () => ({ applied: true }) as any, - log: () => {}, - argv: [], - env: { ...process.env, HOME: home }, - homeDir: home, - }); - const opencode = await runInstallPlatformFlow({ - platform: "opencode", - runner: createShellRunner(), - initOpenClaw: async () => ({ applied: true }) as any, - log: () => {}, - argv: [], - env: { ...process.env, HOME: home }, - homeDir: home, - }); - - assertEqual(paperclip.ok, true, "paperclip install should now prepare artifacts successfully"); - assertEqual(paperclip.step, "install-paperclip", "paperclip should return implemented install step"); - assert(typeof paperclip.details.installCommand === "string" && paperclip.details.installCommand.includes("paperclipai plugin install"), "paperclip should return host install command"); - assertEqual(opencode.ok, true, "opencode install should now be implemented"); - assertEqual(opencode.step, "install-opencode", "opencode should return implemented install step"); - const written = JSON.parse(fs.readFileSync(path.join(home, ".config", "opencode", "config.json"), "utf8")); - assertEqual(written.mcp.asm.type, "local", "opencode config should register local MCP server at mcp."); - assert(Array.isArray(written.mcp.asm.command), "opencode config should store command array"); - assertEqual(written.mcp.asm.command[0], process.execPath, "opencode config should invoke node runtime explicitly"); - assert(typeof written.mcp.asm.command[1] === "string" && written.mcp.asm.command[1].endsWith("/bin/asm.mjs"), "opencode config should point to local asm CLI script"); - assertEqual(JSON.stringify(written.mcp.asm.command.slice(2)), JSON.stringify(["mcp", "opencode"]), "opencode config should forward mcp opencode args"); - assertEqual(written.mcp.asm.environment.ASM_MCP_AGENT_ID, "opencode", "opencode config should scope MCP agent id"); - assertEqual(written.mcp.asm.enabled, true, "opencode config should enable ASM MCP server"); + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + + const home = fs.mkdtempSync(path.join(os.tmpdir(), "asm-opencode-install-")); + const runner = ((command: string, args: string[] = []) => { + if (command === "npm" && args.join(" ") === "run package:paperclip") { + return { + ok: true, + code: 0, + stdout: "packaged paperclip runtime", + stderr: "", + error: "", + }; + } + if ( + command === "npm" && + args.join(" ") === "run package:paperclip:plugin-local" + ) { + const artifactDir = path.join( + process.cwd(), + "artifacts", + "paperclip-plugin-local", + ); + const runtimeDir = path.join( + process.cwd(), + "artifacts", + "npm", + "paperclip", + ); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(path.join(artifactDir, "package.json"), "{}\n"); + fs.writeFileSync(path.join(runtimeDir, "package.json"), "{}\n"); + return { + ok: true, + code: 0, + stdout: "packaged paperclip plugin local", + stderr: "", + error: "", + }; + } + return createShellRunner()(command, args); + }) as any; + + const paperclip = await runInstallPlatformFlow({ + platform: "paperclip", + runner, + initOpenClaw: async () => + ({ applied: true, configPath: "~/.openclaw/openclaw.json" }) as any, + log: () => {}, + argv: [], + env: { ...process.env, HOME: home }, + homeDir: home, + }); + const opencode = await runInstallPlatformFlow({ + platform: "opencode", + runner: createShellRunner(), + initOpenClaw: async () => + ({ applied: true, configPath: "~/.openclaw/openclaw.json" }) as any, + log: () => {}, + argv: [], + env: { ...process.env, HOME: home }, + homeDir: home, + }); + + assertEqual( + paperclip.ok, + true, + "paperclip install should now prepare artifacts successfully", + ); + assertEqual( + paperclip.step, + "install-paperclip", + "paperclip should return implemented install step", + ); + const paperclipDetails = (paperclip.details || {}) as Record; + assert( + typeof paperclipDetails.installCommand === "string" && + paperclipDetails.installCommand.includes("paperclipai plugin install"), + "paperclip should return host install command", + ); + assertEqual(opencode.ok, true, "opencode install should now be implemented"); + assertEqual( + opencode.step, + "install-opencode", + "opencode should return implemented install step", + ); + const written = JSON.parse( + fs.readFileSync( + path.join(home, ".config", "opencode", "config.json"), + "utf8", + ), + ); + assertEqual( + written.mcp.asm.type, + "local", + "opencode config should register local MCP server at mcp.", + ); + assert( + Array.isArray(written.mcp.asm.command), + "opencode config should store command array", + ); + assertEqual( + written.mcp.asm.command[0], + process.execPath, + "opencode config should invoke node runtime explicitly", + ); + assert( + typeof written.mcp.asm.command[1] === "string" && + written.mcp.asm.command[1].endsWith("/bin/asm.mjs"), + "opencode config should point to local asm CLI script", + ); + assertEqual( + JSON.stringify(written.mcp.asm.command.slice(2)), + JSON.stringify(["mcp", "opencode"]), + "opencode config should forward mcp opencode args", + ); + assertEqual( + written.mcp.asm.environment.ASM_MCP_AGENT_ID, + "opencode", + "opencode config should scope MCP agent id", + ); + assertEqual( + written.mcp.asm.enabled, + true, + "opencode config should enable ASM MCP server", + ); }); test("runSetupOpenClawFlow fails early when openclaw missing", async () => { - const runner = (_command: string, _args: string[]) => ({ - ok: false, - code: 127, - stdout: "", - stderr: "command not found", - error: "spawn ENOENT", - }); - - let initCalled = 0; - const result = await runSetupOpenClawFlow({ - runner: runner as any, - initOpenClaw: async () => { - initCalled += 1; - return { applied: true }; - }, - log: () => {}, - }); - - assertEqual(result.ok, false, "flow should fail if openclaw is missing"); - assertEqual(initCalled, 0, "init flow must not run when openclaw missing"); + const runner = (_command: string, _args: string[]) => ({ + ok: false, + code: 127, + stdout: "", + stderr: "command not found", + error: "spawn ENOENT", + }); + + let initCalled = 0; + const result = await runSetupOpenClawFlow({ + runner: runner as any, + initOpenClaw: async () => { + initCalled += 1; + return { applied: true, configPath: "~/.openclaw/openclaw.json" }; + }, + log: () => {}, + }); + + assertEqual(result.ok, false, "flow should fail if openclaw is missing"); + assertEqual(initCalled, 0, "init flow must not run when openclaw missing"); }); setTimeout(() => { - if (!process.exitCode) { - console.log("\n🎉 asm cli tests passed"); - } + if (!process.exitCode) { + console.log("\n🎉 asm cli tests passed"); + } }, 0); From b7221ce0e9b2569f3b203437a00d732b8ebe7ad4 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 17:34:06 +0700 Subject: [PATCH 20/24] chore(release): bump version to 5.1.15 --- package.json | 162 +++++++++++++++++++++++++-------------------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/package.json b/package.json index 7e547c1..2c0b861 100644 --- a/package.json +++ b/package.json @@ -1,83 +1,83 @@ { - "name": "@mrc2204/agent-smart-memo", - "version": "5.1.14", - "description": "Smart Memory Plugin for OpenClaw — structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", - "keywords": [ - "agent", - "ai", - "auto-capture", - "memory", - "openclaw", - "plugin", - "qdrant", - "smart-memo" - ], - "license": "MIT", - "author": "mrc2204", - "repository": { - "type": "git", - "url": "git+https://github.com/cong91/agent-smart-memo.git" - }, - "bin": { - "agent-smart-memo": "bin/asm.mjs", - "asm": "bin/asm.mjs" - }, - "files": [ - "dist/", - "bin/", - "scripts/init-openclaw.mjs", - "openclaw.plugin.json", - "CONFIG.example.json", - "README.md", - "LICENSE" - ], - "type": "module", - "main": "dist/index.js", - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "npm run build:openclaw && npm run sync:openclaw:dist", - "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", - "build:openclaw": "tsc -p tsconfig.openclaw.json", - "build:paperclip": "tsc -p tsconfig.paperclip.json", - "build:core": "tsc -p tsconfig.core.json", - "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", - "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", - "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", - "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", - "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", - "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", - "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", - "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", - "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", - "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", - "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", - "publish:core": "npm run package:core && node scripts/publish-target.mjs core", - "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", - "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", - "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", - "test:asm-cli": "npx tsx tests/test-asm-cli.ts", - "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", - "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", - "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", - "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", - "validate:ab": "npx tsx scripts/validate-ab.ts", - "init-openclaw": "node scripts/init-openclaw.mjs", - "asm": "node bin/asm.mjs" - }, - "dependencies": { - "dotenv": "^17.3.1" - }, - "devDependencies": { - "@sinclair/typebox": "^0.34.0", - "@types/node": "^22.0.0", - "openclaw": "*", - "typescript": "^5.0.0" - }, - "openclaw": { - "extensions": [ - "./dist/index.js" - ] - } + "name": "@mrc2204/agent-smart-memo", + "version": "5.1.15", + "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", + "keywords": [ + "agent", + "ai", + "auto-capture", + "memory", + "openclaw", + "plugin", + "qdrant", + "smart-memo" + ], + "license": "MIT", + "author": "mrc2204", + "repository": { + "type": "git", + "url": "git+https://github.com/cong91/agent-smart-memo.git" + }, + "bin": { + "agent-smart-memo": "bin/asm.mjs", + "asm": "bin/asm.mjs" + }, + "files": [ + "dist/", + "bin/", + "scripts/init-openclaw.mjs", + "openclaw.plugin.json", + "CONFIG.example.json", + "README.md", + "LICENSE" + ], + "type": "module", + "main": "dist/index.js", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "npm run build:openclaw && npm run sync:openclaw:dist", + "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", + "build:openclaw": "tsc -p tsconfig.openclaw.json", + "build:paperclip": "tsc -p tsconfig.paperclip.json", + "build:core": "tsc -p tsconfig.core.json", + "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", + "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", + "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", + "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", + "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", + "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", + "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", + "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", + "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", + "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", + "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", + "publish:core": "npm run package:core && node scripts/publish-target.mjs core", + "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", + "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", + "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", + "test:asm-cli": "npx tsx tests/test-asm-cli.ts", + "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", + "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", + "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", + "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", + "validate:ab": "npx tsx scripts/validate-ab.ts", + "init-openclaw": "node scripts/init-openclaw.mjs", + "asm": "node bin/asm.mjs" + }, + "dependencies": { + "dotenv": "^17.3.1" + }, + "devDependencies": { + "@sinclair/typebox": "^0.34.0", + "@types/node": "^22.0.0", + "openclaw": "*", + "typescript": "^5.0.0" + }, + "openclaw": { + "extensions": [ + "./dist/index.js" + ] + } } From a50ad1ad38749a1f36512f8142181e3519472ca6 Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 18:55:21 +0700 Subject: [PATCH 21/24] fix: auto-create qdrant collection from runtime config --- src/adapters/paperclip/runtime.ts | 7 +++++- src/index.ts | 4 +++ tests/test-qdrant-dimension.ts | 42 +++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/adapters/paperclip/runtime.ts b/src/adapters/paperclip/runtime.ts index d2925c9..55af0e8 100644 --- a/src/adapters/paperclip/runtime.ts +++ b/src/adapters/paperclip/runtime.ts @@ -42,13 +42,18 @@ export function createPaperclipRuntime(options?: PaperclipRuntimeOptions): Paper const slotDb = new SlotDB(stateDir, { slotDbDir }); const semanticUseCase = options?.semanticUseCase || (() => { + const qdrantCollection = options?.qdrantCollection || process.env.AGENT_MEMO_QDRANT_COLLECTION || runtime.qdrantCollection; const qdrant = new QdrantClient({ host: options?.qdrantHost || process.env.AGENT_MEMO_QDRANT_HOST || runtime.qdrantHost, port: Number(options?.qdrantPort || process.env.AGENT_MEMO_QDRANT_PORT || runtime.qdrantPort), - collection: options?.qdrantCollection || process.env.AGENT_MEMO_QDRANT_COLLECTION || runtime.qdrantCollection, + collection: qdrantCollection, vectorSize: Number(options?.qdrantVectorSize || process.env.AGENT_MEMO_QDRANT_VECTOR_SIZE || runtime.qdrantVectorSize), }); + void qdrant.createCollection().catch((error: any) => { + console.error(`[ASM-Paperclip] Failed to ensure Qdrant collection '${qdrantCollection}': ${error instanceof Error ? error.message : String(error)}`); + }); + const embedding = new EmbeddingClient({ embeddingApiUrl: options?.embedBaseUrl || process.env.AGENT_MEMO_EMBED_BASE_URL || runtime.embedBaseUrl, backend: options?.embedBackend || runtime.embedBackend, diff --git a/src/index.ts b/src/index.ts index bfcad63..d0b7bcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -475,6 +475,10 @@ const agentMemoPlugin = { dimensionRouteMap: routeMap, }); + void qdrant.createCollection().catch((error: any) => { + console.error(`[AgentMemo] Failed to ensure Qdrant collection '${qdrantCollection}': ${error instanceof Error ? error.message : String(error)}`); + }); + const embedding = new EmbeddingClient({ embeddingApiUrl: embedBaseUrl, backend: embedBackend, diff --git a/tests/test-qdrant-dimension.ts b/tests/test-qdrant-dimension.ts index 255bc0e..730ba18 100644 --- a/tests/test-qdrant-dimension.ts +++ b/tests/test-qdrant-dimension.ts @@ -54,8 +54,50 @@ async function testDimensionMismatchFailFast() { } } +async function testCreateCollectionSkipsWhenExists() { + const originalFetch = global.fetch as any; + const calls: string[] = []; + + (global as any).fetch = async (url: string, init?: any) => { + calls.push(`${init?.method || 'GET'} ${String(url)}`); + return { + ok: true, + status: 200, + headers: { get(name: string) { return name === 'content-type' ? 'application/json' : null; } }, + async json() { + return { + result: { + config: { + params: { + vectors: { size: 1024 }, + }, + }, + }, + }; + }, + async text() { return JSON.stringify({ ok: true }); }, + } as any; + }; + + try { + const qdrant = new QdrantClient({ + host: 'localhost', + port: 6333, + collection: 'existing_collection', + vectorSize: 1024, + }); + await qdrant.createCollection(); + assert(calls.length >= 1, 'should at least query collection existence/info'); + assert(calls.some((c) => c.includes('/collections/existing_collection')), 'should inspect target collection'); + assert(!calls.some((c) => c.startsWith('PUT ')), 'should not create collection when already exists'); + } finally { + (global as any).fetch = originalFetch; + } +} + async function main() { await testDimensionMismatchFailFast(); + await testCreateCollectionSkipsWhenExists(); console.log("✅ qdrant dimension tests passed"); } From 3e68b917c7ebb1c3ed278b069eaa8e3b110a69fa Mon Sep 17 00:00:00 2001 From: mrc Date: Thu, 19 Mar 2026 18:58:28 +0700 Subject: [PATCH 22/24] chore(release): bump version to 5.1.16 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c0b861..fda0e68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrc2204/agent-smart-memo", - "version": "5.1.15", + "version": "5.1.16", "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", "keywords": [ "agent", From ee3289031e34d4fbac1f97ecad34af9328bf171b Mon Sep 17 00:00:00 2001 From: mrc Date: Fri, 20 Mar 2026 09:45:18 +0700 Subject: [PATCH 23/24] feat(asm-122): implement memory foundation slices 115-121 --- bin/asm.mjs | 52 + package.json | 163 +- scripts/asm115-migrate.ts | 46 + src/core/migrations/asm115-migration-core.ts | 128 + src/core/precedence/recall-precedence.ts | 71 + src/core/promotion/promotion-lifecycle.ts | 80 + src/core/retrieval-policy.ts | 88 + src/core/usecases/semantic-memory-usecase.ts | 438 +- src/db/slot-db.ts | 7438 ++++++++++------- src/hooks/auto-capture.ts | 2344 +++--- src/hooks/auto-recall.ts | 1392 +-- src/scripts/asm115-migration-runner.ts | 317 + src/services/qdrant.ts | 732 +- src/shared/memory-config.ts | 598 +- src/tools/memory_search.ts | 492 +- src/tools/memory_store.ts | 498 +- src/types.ts | 139 +- tests/test-asm-cli.ts | 15 + tests/test-asm115-migration-core.ts | 116 + tests/test-asm121-parity-gate.ts | 335 + .../test-memory-tools-namespace-roundtrip.ts | 584 +- tests/test-promotion-lifecycle.ts | 84 + tests/test-recall-precedence.ts | 74 + tests/test-retrieval-policy.ts | 132 + tests/test-semantic-memory-usecase.ts | 295 +- 25 files changed, 10100 insertions(+), 6551 deletions(-) create mode 100644 scripts/asm115-migrate.ts create mode 100644 src/core/migrations/asm115-migration-core.ts create mode 100644 src/core/precedence/recall-precedence.ts create mode 100644 src/core/promotion/promotion-lifecycle.ts create mode 100644 src/core/retrieval-policy.ts create mode 100644 src/scripts/asm115-migration-runner.ts create mode 100644 tests/test-asm115-migration-core.ts create mode 100644 tests/test-asm121-parity-gate.ts create mode 100644 tests/test-promotion-lifecycle.ts create mode 100644 tests/test-recall-precedence.ts create mode 100644 tests/test-retrieval-policy.ts diff --git a/bin/asm.mjs b/bin/asm.mjs index 1cb59cc..5f751db 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { dirname, join, resolve } from "node:path"; import { runInitOpenClaw } from "../scripts/init-openclaw.mjs"; import { createShellRunner, runInitSetupFlow, runInstallPlatformFlow } from "../dist/cli/platform-installers.js"; @@ -77,6 +78,18 @@ export function parseAsmCliArgs(argv = []) { return { command: "project-event", argv: args.slice(1) }; } + if (first === "check-asm115") { + return { command: "check-asm115", argv: args.slice(1) }; + } + + if (first === "migrate-asm115") { + return { command: "migrate-asm115", argv: args.slice(1) }; + } + + if (first === "migrate" && (args[1] || "") === "asm115") { + return { command: "migrate-asm115", argv: args.slice(2) }; + } + if (first === "mcp" && (args[1] || "") === "opencode") { return { command: "mcp-opencode", argv: args.slice(2) }; } @@ -99,6 +112,9 @@ export function printHelp(log = console.log) { log(" asm init-openclaw [--non-interactive]"); log(" asm init openclaw [--non-interactive]"); log(" asm project-event --project-id --repo-root [--event-type post_commit|post_merge|post_rewrite|manual] [--source-rev ] [--changed-files a,b] [--deleted-files x,y] [--trusted-sync 0|1] [--full-snapshot 0|1]"); + log(" asm migrate-asm115 [--user-id ] [--agent-id ] [--snapshot-dir ] [--rollback-snapshot ] [--preflight-limit ]"); + log(" asm migrate asm115 [flags...]"); + log(" asm check-asm115 [--user-id ] [--agent-id ] [--preflight-limit ] # alias: verify status/version"); log(" asm help"); log(""); log("Roadmap commands (not implemented yet):"); @@ -440,6 +456,42 @@ export async function main(argv = process.argv.slice(2)) { } } + if (parsed.command === "migrate-asm115") { + try { + const proc = spawnSync( + "npx", + ["tsx", "scripts/asm115-migrate.ts", ...(parsed.argv || [])], + { + stdio: "inherit", + cwd: process.cwd(), + env: process.env, + }, + ); + return typeof proc.status === "number" ? proc.status : 1; + } catch (error) { + console.error(`[ASM-115] migrate failed: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + } + + if (parsed.command === "check-asm115") { + try { + const proc = spawnSync( + "npx", + ["tsx", "scripts/asm115-migrate.ts", "verify", ...(parsed.argv || [])], + { + stdio: "inherit", + cwd: process.cwd(), + env: process.env, + }, + ); + return typeof proc.status === "number" ? proc.status : 1; + } catch (error) { + console.error(`[ASM-115] check failed: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + } + console.error(`[ASM-84] Unknown command: ${argv.join(" ") || "(empty)"}`); printHelp(console.error); return 1; diff --git a/package.json b/package.json index fda0e68..06cce15 100644 --- a/package.json +++ b/package.json @@ -1,83 +1,84 @@ { - "name": "@mrc2204/agent-smart-memo", - "version": "5.1.16", - "description": "Smart Memory Plugin for OpenClaw \u2014 structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", - "keywords": [ - "agent", - "ai", - "auto-capture", - "memory", - "openclaw", - "plugin", - "qdrant", - "smart-memo" - ], - "license": "MIT", - "author": "mrc2204", - "repository": { - "type": "git", - "url": "git+https://github.com/cong91/agent-smart-memo.git" - }, - "bin": { - "agent-smart-memo": "bin/asm.mjs", - "asm": "bin/asm.mjs" - }, - "files": [ - "dist/", - "bin/", - "scripts/init-openclaw.mjs", - "openclaw.plugin.json", - "CONFIG.example.json", - "README.md", - "LICENSE" - ], - "type": "module", - "main": "dist/index.js", - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "npm run build:openclaw && npm run sync:openclaw:dist", - "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", - "build:openclaw": "tsc -p tsconfig.openclaw.json", - "build:paperclip": "tsc -p tsconfig.paperclip.json", - "build:core": "tsc -p tsconfig.core.json", - "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", - "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", - "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", - "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", - "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", - "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", - "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", - "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", - "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", - "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", - "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", - "publish:core": "npm run package:core && node scripts/publish-target.mjs core", - "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", - "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", - "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", - "test:asm-cli": "npx tsx tests/test-asm-cli.ts", - "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", - "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", - "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", - "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", - "validate:ab": "npx tsx scripts/validate-ab.ts", - "init-openclaw": "node scripts/init-openclaw.mjs", - "asm": "node bin/asm.mjs" - }, - "dependencies": { - "dotenv": "^17.3.1" - }, - "devDependencies": { - "@sinclair/typebox": "^0.34.0", - "@types/node": "^22.0.0", - "openclaw": "*", - "typescript": "^5.0.0" - }, - "openclaw": { - "extensions": [ - "./dist/index.js" - ] - } + "name": "@mrc2204/agent-smart-memo", + "version": "5.1.16", + "description": "Smart Memory Plugin for OpenClaw — structured slot memory with auto-capture, auto-recall, essence distillation, and Qdrant vector search", + "keywords": [ + "agent", + "ai", + "auto-capture", + "memory", + "openclaw", + "plugin", + "qdrant", + "smart-memo" + ], + "license": "MIT", + "author": "mrc2204", + "repository": { + "type": "git", + "url": "git+https://github.com/cong91/agent-smart-memo.git" + }, + "bin": { + "agent-smart-memo": "bin/asm.mjs", + "asm": "bin/asm.mjs" + }, + "files": [ + "dist/", + "bin/", + "scripts/init-openclaw.mjs", + "openclaw.plugin.json", + "CONFIG.example.json", + "README.md", + "LICENSE" + ], + "type": "module", + "main": "dist/index.js", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "npm run build:openclaw && npm run sync:openclaw:dist", + "build:all": "npm run build:openclaw && npm run build:paperclip && npm run build:core", + "build:openclaw": "tsc -p tsconfig.openclaw.json", + "build:paperclip": "tsc -p tsconfig.paperclip.json", + "build:core": "tsc -p tsconfig.core.json", + "sync:openclaw:dist": "node scripts/sync-openclaw-dist.mjs", + "package:openclaw": "npm run build:openclaw && node scripts/prepare-package-target.mjs openclaw", + "package:paperclip": "npm run build:paperclip && node scripts/prepare-package-target.mjs paperclip", + "package:core": "npm run build:core && node scripts/prepare-package-target.mjs core", + "pack:openclaw": "npm run package:openclaw && (cd artifacts/npm/openclaw && npm pack)", + "pack:paperclip": "npm run package:paperclip && (cd artifacts/npm/paperclip && npm pack)", + "package:paperclip:plugin-local": "npm run build:paperclip && node scripts/prepare-paperclip-plugin-local.mjs", + "pack:paperclip:plugin-local": "npm run package:paperclip:plugin-local && (cd artifacts/paperclip-plugin-local && npm pack)", + "pack:core": "npm run package:core && (cd artifacts/npm/core && npm pack)", + "publish:openclaw": "npm run package:openclaw && node scripts/publish-target.mjs openclaw", + "publish:paperclip": "npm run package:paperclip && node scripts/publish-target.mjs paperclip", + "publish:core": "npm run package:core && node scripts/publish-target.mjs core", + "test": "npx tsx tests/test.ts && npx tsx tests/test-auto-recall.ts && npx tsx tests/test-memory-config.ts", + "test:openclaw": "npx tsx tests/test-openclaw-adapter-contract.ts && npx tsx tests/test-openclaw-semantic-tools-integration.ts", + "test:paperclip": "npx tsx tests/test-paperclip-contracts.ts && npx tsx tests/test-paperclip-runtime-e2e.ts", + "test:asm-cli": "npx tsx tests/test-asm-cli.ts", + "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", + "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", + "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", + "migrate:asm115": "npx tsx scripts/asm115-migrate.ts", + "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", + "validate:ab": "npx tsx scripts/validate-ab.ts", + "init-openclaw": "node scripts/init-openclaw.mjs", + "asm": "node bin/asm.mjs" + }, + "dependencies": { + "dotenv": "^17.3.1" + }, + "devDependencies": { + "@sinclair/typebox": "^0.34.0", + "@types/node": "^22.0.0", + "openclaw": "*", + "typescript": "^5.0.0" + }, + "openclaw": { + "extensions": [ + "./dist/index.js" + ] + } } diff --git a/scripts/asm115-migrate.ts b/scripts/asm115-migrate.ts new file mode 100644 index 0000000..781818d --- /dev/null +++ b/scripts/asm115-migrate.ts @@ -0,0 +1,46 @@ +import { + type Asm115Mode, + runAsm115Migration, +} from "../src/scripts/asm115-migration-runner.js"; + +function parseArgs(argv: string[]) { + const args = Array.isArray(argv) ? argv.map((x) => String(x)) : []; + const mode = (args[0] || "preflight") as Asm115Mode; + const get = (flag: string): string | undefined => { + const idx = args.indexOf(flag); + if (idx < 0) return undefined; + return args[idx + 1]; + }; + return { + mode, + userId: get("--user-id"), + agentId: get("--agent-id"), + snapshotDir: get("--snapshot-dir"), + rollbackSnapshotPath: get("--rollback-snapshot"), + preflightLimit: Number(get("--preflight-limit") || 200), + }; +} + +async function main() { + const parsed = parseArgs(process.argv.slice(2)); + if ( + !["preflight", "plan", "apply", "verify", "rollback"].includes(parsed.mode) + ) { + console.error( + "[ASM-115] Invalid mode. Use: preflight|plan|apply|verify|rollback", + ); + process.exit(1); + } + + try { + const result = await runAsm115Migration(parsed); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error( + `[ASM-115] failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} + +main(); diff --git a/src/core/migrations/asm115-migration-core.ts b/src/core/migrations/asm115-migration-core.ts new file mode 100644 index 0000000..43d82cb --- /dev/null +++ b/src/core/migrations/asm115-migration-core.ts @@ -0,0 +1,128 @@ +import { + type MemoryNamespace, + type MemorySourceType, + resolveDefaultConfidence, + resolveMemoryScopeFromNamespace, + resolveMemoryTypeFromNamespace, +} from "../../shared/memory-config.js"; + +export const ASM115_SCHEMA_VERSION = "asm115.v1"; +export const ASM115_MIGRATION_ID = "asm115-memory-foundation"; + +export interface SemanticPointPayload { + namespace?: string; + source_type?: string; + schema_version?: string; + memory_scope?: string; + memory_type?: string; + promotion_state?: string; + confidence?: unknown; + [key: string]: unknown; +} + +export interface SemanticPointRecord { + id: string | number | Record; + payload: SemanticPointPayload; +} + +export interface SemanticPointPatch { + id: string | number | Record; + payload: SemanticPointPayload; + changedFields: string[]; +} + +function asMemoryNamespace(value: string): MemoryNamespace { + return value as MemoryNamespace; +} + +function asSourceType(value: unknown): MemorySourceType { + const normalized = String(value || "auto_capture") + .trim() + .toLowerCase(); + if ( + normalized === "auto_capture" || + normalized === "manual" || + normalized === "tool_call" || + normalized === "migration" || + normalized === "promotion" + ) { + return normalized; + } + return "auto_capture"; +} + +export function buildSemanticPayloadPatch( + input: SemanticPointRecord, +): SemanticPointPatch { + const payload = { ...(input.payload || {}) }; + const changedFields: string[] = []; + + const namespace = String( + payload.namespace || "agent.assistant.working_memory", + ).trim(); + const sourceType = asSourceType(payload.source_type); + + if (String(payload.schema_version || "") !== ASM115_SCHEMA_VERSION) { + payload.schema_version = ASM115_SCHEMA_VERSION; + changedFields.push("schema_version"); + } + + if (!payload.memory_scope) { + payload.memory_scope = resolveMemoryScopeFromNamespace( + asMemoryNamespace(namespace), + ); + changedFields.push("memory_scope"); + } + + if (!payload.memory_type) { + payload.memory_type = resolveMemoryTypeFromNamespace( + asMemoryNamespace(namespace), + ); + changedFields.push("memory_type"); + } + + if (!payload.promotion_state) { + payload.promotion_state = "raw"; + changedFields.push("promotion_state"); + } + + const confidenceNumber = Number(payload.confidence); + if (!Number.isFinite(confidenceNumber) || confidenceNumber <= 0) { + payload.confidence = resolveDefaultConfidence(sourceType); + changedFields.push("confidence"); + } + + return { + id: input.id, + payload, + changedFields, + }; +} + +export function planSemanticPayloadMigration(points: SemanticPointRecord[]): { + total: number; + changed: number; + patches: SemanticPointPatch[]; +} { + const patches = points + .map((point) => buildSemanticPayloadPatch(point)) + .filter((patch) => patch.changedFields.length > 0); + + return { + total: points.length, + changed: patches.length, + patches, + }; +} + +export function isAsm115Noop(input: { + pendingSemanticChanges: number; + migrationStatus?: string; + migrationSchemaTo?: string; +}): boolean { + return ( + input.pendingSemanticChanges === 0 && + input.migrationStatus === "migrated" && + input.migrationSchemaTo === ASM115_SCHEMA_VERSION + ); +} diff --git a/src/core/precedence/recall-precedence.ts b/src/core/precedence/recall-precedence.ts new file mode 100644 index 0000000..646305b --- /dev/null +++ b/src/core/precedence/recall-precedence.ts @@ -0,0 +1,71 @@ +export interface RecallInjectionContext { + currentState: string; + projectLivingState: string; + graphContext: string; + recentUpdates: string; + semanticMemories: string; + recallMeta?: { + recall_confidence: "high" | "medium" | "low"; + recall_suppressed: boolean; + suppression_reason?: string; + }; +} + +export interface RecallPrecedencePolicy { + slotdbTruth: "highest"; + semanticEvidence: "medium"; + graphRoutingSupport: "support"; +} + +export const DEFAULT_RECALL_PRECEDENCE_POLICY: RecallPrecedencePolicy = { + slotdbTruth: "highest", + semanticEvidence: "medium", + graphRoutingSupport: "support", +}; + +function formatRecallMeta( + recallMeta: RecallInjectionContext["recallMeta"], +): string { + if (!recallMeta) return ""; + return `\n ${recallMeta.recall_confidence}\n ${String(recallMeta.recall_suppressed)}${recallMeta.suppression_reason ? `\n ${recallMeta.suppression_reason}` : ""}\n`; +} + +export function buildRecallInjectionParts( + context: RecallInjectionContext, +): string[] { + const parts: string[] = []; + + // Precedence 1: SlotDB current truth. + const slotTruthBlocks = [ + context.currentState, + context.projectLivingState, + context.recentUpdates, + ].filter(Boolean); + + if (slotTruthBlocks.length > 0) { + parts.push( + `\n${slotTruthBlocks.join("\n\n")}\n`, + ); + } + + // Precedence 2: semantic memories are evidence/history/lessons. + if (context.semanticMemories) { + parts.push( + `\n${context.semanticMemories}\n`, + ); + } + + // Precedence 3: graph context is routing/ranking support only. + if (context.graphContext) { + parts.push( + `\n${context.graphContext}\n`, + ); + } + + const recallMetaBlock = formatRecallMeta(context.recallMeta); + if (recallMetaBlock) { + parts.push(recallMetaBlock); + } + + return parts; +} diff --git a/src/core/promotion/promotion-lifecycle.ts b/src/core/promotion/promotion-lifecycle.ts new file mode 100644 index 0000000..cfcaf1c --- /dev/null +++ b/src/core/promotion/promotion-lifecycle.ts @@ -0,0 +1,80 @@ +import type { + MemoryNamespace, + MemorySourceType, + MemoryType, + PromotionState, +} from "../../shared/memory-config.js"; +import { + resolveDefaultConfidence, + resolveMemoryTypeFromNamespace, +} from "../../shared/memory-config.js"; + +export type PromotionAction = "distill" | "promote" | "deprecate"; + +export interface PromotionMetadata { + memoryType: MemoryType; + promotionState: PromotionState; + confidence: number; +} + +export function transitionPromotionState( + current: PromotionState, + action: PromotionAction, +): PromotionState { + if (action === "deprecate") return "deprecated"; + if (current === "deprecated") return "deprecated"; + if (action === "distill") { + if (current === "raw") return "distilled"; + return current; + } + if (action === "promote") { + if (current === "raw" || current === "distilled") return "promoted"; + } + return current; +} + +export function resolveInitialPromotionState(input: { + namespace: MemoryNamespace; + sourceType: MemorySourceType; +}): PromotionState { + if (input.sourceType === "promotion") return "promoted"; + + // Avoid uncontrolled capture growth: auto-captured runbooks/lessons start at distilled. + if ( + input.sourceType === "auto_capture" && + (input.namespace === "shared.runbooks" || + input.namespace.endsWith(".lessons")) + ) { + return "distilled"; + } + + return "raw"; +} + +export function resolvePromotionMetadata(input: { + namespace: MemoryNamespace; + sourceType: MemorySourceType; + memoryType?: MemoryType; + promotionState?: PromotionState; + confidence?: number; +}): PromotionMetadata { + const memoryType = + input.memoryType || resolveMemoryTypeFromNamespace(input.namespace); + const promotionState = + input.promotionState || + resolveInitialPromotionState({ + namespace: input.namespace, + sourceType: input.sourceType, + }); + const baseline = resolveDefaultConfidence(input.sourceType); + const confidence = + typeof input.confidence === "number" && Number.isFinite(input.confidence) + ? input.confidence + : baseline; + + return { + memoryType, + promotionState, + confidence, + }; +} diff --git a/src/core/retrieval-policy.ts b/src/core/retrieval-policy.ts new file mode 100644 index 0000000..7bd1b62 --- /dev/null +++ b/src/core/retrieval-policy.ts @@ -0,0 +1,88 @@ +import { getNamespaceWeight } from "../shared/memory-config.js"; + +export type SessionMode = "strict" | "soft"; + +export interface ScoreSemanticCandidateInput { + rawScore: number; + agentId: string; + namespace: string; + sessionMode?: SessionMode; + preferredSessionId?: string; + payloadSessionId?: string; + sameSession?: boolean; + promotionState?: string; +} + +export interface ScoreSemanticCandidateOutput { + weightedBase: number; + sessionBoost: number; + promotionBoost: number; + finalScore: number; + sameSession: boolean; +} + +export const SOFT_SESSION_BOOST = 0.12; +export const PROMOTED_BOOST = 0.08; +export const DISTILLED_BOOST = 0.03; + +export function normalizeSessionToken(value: unknown): string { + return String(value || "") + .trim() + .toLowerCase(); +} + +export function resolveSessionMode(value: unknown): SessionMode { + return value === "strict" ? "strict" : "soft"; +} + +export function shouldApplyStrictSessionFilter( + sessionMode: unknown, + preferredSessionId: unknown, +): boolean { + return ( + resolveSessionMode(sessionMode) === "strict" && + normalizeSessionToken(preferredSessionId).length > 0 + ); +} + +export function scoreSemanticCandidate( + input: ScoreSemanticCandidateInput, +): ScoreSemanticCandidateOutput { + const weightedBase = Math.min( + 1, + Math.max(0, input.rawScore) * + getNamespaceWeight(input.agentId, input.namespace), + ); + + const preferredSessionId = normalizeSessionToken(input.preferredSessionId); + const payloadSessionId = normalizeSessionToken(input.payloadSessionId); + const sameSession = + typeof input.sameSession === "boolean" + ? input.sameSession + : Boolean( + preferredSessionId && + payloadSessionId && + preferredSessionId === payloadSessionId, + ); + + const sessionBoost = + resolveSessionMode(input.sessionMode) === "soft" && sameSession + ? SOFT_SESSION_BOOST + : 0; + + const promotionState = String(input.promotionState || "").toLowerCase(); + const promotionBoost = + promotionState === "promoted" + ? PROMOTED_BOOST + : promotionState === "distilled" + ? DISTILLED_BOOST + : 0; + + return { + weightedBase, + sessionBoost, + promotionBoost, + finalScore: Math.min(1, weightedBase + sessionBoost + promotionBoost), + sameSession, + }; +} diff --git a/src/core/usecases/semantic-memory-usecase.ts b/src/core/usecases/semantic-memory-usecase.ts index 3b3d788..48493fb 100644 --- a/src/core/usecases/semantic-memory-usecase.ts +++ b/src/core/usecases/semantic-memory-usecase.ts @@ -1,197 +1,275 @@ -import type { QdrantClient } from "../../services/qdrant.js"; -import type { EmbeddingClient } from "../../services/embedding.js"; import type { DeduplicationService } from "../../services/dedupe.js"; -import type { MemoryContext } from "../contracts/adapter-contracts.js"; -import { getAgentNamespaces, getNamespaceWeight, parseExplicitNamespace, toCoreAgent, type MemoryNamespace } from "../../shared/memory-config.js"; +import type { EmbeddingClient } from "../../services/embedding.js"; +import type { QdrantClient } from "../../services/qdrant.js"; +import { + getAgentNamespaces, + type MemoryNamespace, + parseExplicitNamespace, + resolveDefaultConfidence, + resolveMemoryScopeFromNamespace, + resolveMemoryTypeFromNamespace, + toCoreAgent, +} from "../../shared/memory-config.js"; import type { ScoredPoint } from "../../types.js"; +import type { MemoryContext } from "../contracts/adapter-contracts.js"; +import { + normalizeSessionToken, + resolveSessionMode, + scoreSemanticCandidate, + shouldApplyStrictSessionFilter, +} from "../retrieval-policy.js"; export interface MemoryCapturePayload { - text: string; - namespace?: string; - sessionId?: string; - userId?: string; - metadata?: Record; + text: string; + namespace?: string; + sessionId?: string; + userId?: string; + metadata?: Record; } export interface MemorySearchPayload { - query: string; - limit?: number; - minScore?: number; - namespace?: string; - sessionId?: string; - userId?: string; - sourceAgent?: string; + query: string; + limit?: number; + minScore?: number; + namespace?: string; + sessionId?: string; + sessionMode?: "strict" | "soft"; + userId?: string; + sourceAgent?: string; } export interface MemoryCaptureResult { - id: string; - created: boolean; - updated: boolean; - namespace: MemoryNamespace; - score?: number; + id: string; + created: boolean; + updated: boolean; + namespace: MemoryNamespace; + score?: number; } export interface MemorySearchResult { - query: string; - count: number; - results: Array<{ - id: string; - score: number; - rawScore: number; - text: string; - namespace: string; - timestamp?: number; - metadata?: Record; - }>; + query: string; + count: number; + results: Array<{ + id: string; + score: number; + rawScore: number; + text: string; + namespace: string; + timestamp?: number; + metadata?: Record; + }>; } export class SemanticMemoryUseCase { - constructor( - private readonly qdrant: QdrantClient, - private readonly embedding: EmbeddingClient, - private readonly dedupe: DeduplicationService, - ) {} - - async capture(payload: MemoryCapturePayload, context: MemoryContext): Promise { - if (!payload?.text || typeof payload.text !== "string" || payload.text.trim().length === 0) { - throw new Error("memory.capture requires payload.text"); - } - - const text = payload.text.trim(); - const sourceAgent = toCoreAgent(context.agentId || "assistant"); - const namespace = this.resolveNamespace(payload.namespace, sourceAgent); - - const embeddingResult = await this.embedDetailedCompat(text); - const vector = embeddingResult.vector; - - const existing = await this.qdrant.search(vector, 5, { - must: [{ key: "namespace", match: { value: namespace } }], - }); - - const duplicateId = this.dedupe.findDuplicate(text, existing); - const id = duplicateId || crypto.randomUUID(); - const now = Date.now(); - - await this.qdrant.upsert([ - { - id, - vector, - payload: { - text, - namespace, - agent: sourceAgent, - source_agent: sourceAgent, - source_type: "manual", - sessionId: payload.sessionId || context.sessionId || null, - userId: payload.userId || context.userId || null, - metadata: { - ...(payload.metadata || {}), - ...embeddingResult.metadata, - }, - ...embeddingResult.metadata, - timestamp: now, - ...(duplicateId ? { updatedAt: now } : {}), - }, - }, - ]); - - return { - id, - created: !duplicateId, - updated: Boolean(duplicateId), - namespace, - }; - } - - async search(payload: MemorySearchPayload, context: MemoryContext): Promise { - if (!payload?.query || typeof payload.query !== "string" || payload.query.trim().length === 0) { - throw new Error("memory.search requires payload.query"); - } - - const query = payload.query.trim(); - const sourceAgent = toCoreAgent(context.agentId || "assistant"); - const minScore = typeof payload.minScore === "number" ? payload.minScore : 0.7; - const limit = Math.min(Math.max(payload.limit || 5, 1), 20); - const namespaces = payload.namespace - ? [this.resolveNamespace(payload.namespace, sourceAgent)] - : getAgentNamespaces(sourceAgent); - - const namespaceFilter = namespaces.length === 1 - ? namespaces[0] - : null; - - const filterMust: any[] = []; - if (namespaceFilter) { - filterMust.push({ key: "namespace", match: { value: namespaceFilter } }); - } else { - filterMust.push({ should: namespaces.map((ns) => ({ key: "namespace", match: { value: ns } })) }); - } - - if (payload.sessionId || context.sessionId) { - filterMust.push({ key: "sessionId", match: { value: payload.sessionId || context.sessionId } }); - } - - if (payload.userId || context.userId) { - filterMust.push({ key: "userId", match: { value: payload.userId || context.userId } }); - } - - if (payload.sourceAgent) { - filterMust.push({ key: "source_agent", match: { value: payload.sourceAgent } }); - } - - const vector = await this.embedding.embed(query); - const points = await this.qdrant.search(vector, limit, { must: filterMust }); - - const weighted = points - .filter((r: ScoredPoint) => (r.payload?.namespace || "") !== "noise.filtered") - .map((r: ScoredPoint) => { - const ns = String(r.payload?.namespace || ""); - const weight = getNamespaceWeight(sourceAgent, ns); - const weightedScore = Math.min(1, r.score * weight); - return { - id: String(r.id), - rawScore: r.score, - score: weightedScore, - text: String(r.payload?.text || ""), - namespace: ns, - timestamp: typeof r.payload?.timestamp === "number" ? r.payload.timestamp : undefined, - metadata: (r.payload?.metadata || {}) as Record, - }; - }) - .filter((r) => r.score >= minScore) - .sort((a, b) => b.score - a.score); - - return { - query, - count: weighted.length, - results: weighted, - }; - } - - private resolveNamespace(namespace: string | undefined, sourceAgent: string): MemoryNamespace { - if (typeof namespace === "string" && namespace.trim().length > 0) { - return parseExplicitNamespace(namespace, sourceAgent); - } - return `agent.${sourceAgent}.working_memory` as MemoryNamespace; - } - - private async embedDetailedCompat(text: string): Promise<{ vector: number[]; metadata: Record }> { - const emb = this.embedding as any; - if (typeof emb.embedDetailed === "function") { - return emb.embedDetailed(text); - } - - const vector = await this.embedding.embed(text); - return { - vector, - metadata: { - embedding_chunked: false, - embedding_chunks_count: 1, - embedding_chunking_strategy: "array_batch_weighted_avg", - embedding_model: "unknown", - embedding_model_key: "unknown", - embedding_provider: "auto", - }, - }; - } + constructor( + private readonly qdrant: QdrantClient, + private readonly embedding: EmbeddingClient, + private readonly dedupe: DeduplicationService, + ) {} + + async capture( + payload: MemoryCapturePayload, + context: MemoryContext, + ): Promise { + if ( + !payload?.text || + typeof payload.text !== "string" || + payload.text.trim().length === 0 + ) { + throw new Error("memory.capture requires payload.text"); + } + + const text = payload.text.trim(); + const sourceAgent = toCoreAgent(context.agentId || "assistant"); + const namespace = this.resolveNamespace(payload.namespace, sourceAgent); + const memoryScope = resolveMemoryScopeFromNamespace(namespace); + const memoryType = resolveMemoryTypeFromNamespace(namespace); + const promotionState = "raw" as const; + const defaultConfidence = resolveDefaultConfidence("manual"); + + const embeddingResult = await this.embedDetailedCompat(text); + const vector = embeddingResult.vector; + + const existing = await this.qdrant.search(vector, 5, { + must: [{ key: "namespace", match: { value: namespace } }], + }); + + const duplicateId = this.dedupe.findDuplicate(text, existing); + const id = duplicateId || crypto.randomUUID(); + const now = Date.now(); + + await this.qdrant.upsert([ + { + id, + vector, + payload: { + text, + namespace, + agent: sourceAgent, + source_agent: sourceAgent, + source_type: "manual", + memory_scope: memoryScope, + memory_type: memoryType, + promotion_state: promotionState, + confidence: defaultConfidence, + sessionId: payload.sessionId || context.sessionId || null, + userId: payload.userId || context.userId || null, + metadata: { + ...(payload.metadata || {}), + ...embeddingResult.metadata, + }, + ...embeddingResult.metadata, + timestamp: now, + ...(duplicateId ? { updatedAt: now } : {}), + }, + }, + ]); + + return { + id, + created: !duplicateId, + updated: Boolean(duplicateId), + namespace, + }; + } + + async search( + payload: MemorySearchPayload, + context: MemoryContext, + ): Promise { + if ( + !payload?.query || + typeof payload.query !== "string" || + payload.query.trim().length === 0 + ) { + throw new Error("memory.search requires payload.query"); + } + + const query = payload.query.trim(); + const sourceAgent = toCoreAgent(context.agentId || "assistant"); + const minScore = + typeof payload.minScore === "number" ? payload.minScore : 0.7; + const sessionMode = resolveSessionMode(payload.sessionMode); + const preferredSessionId = normalizeSessionToken( + payload.sessionId || context.sessionId, + ); + const limit = Math.min(Math.max(payload.limit || 5, 1), 20); + const namespaces = payload.namespace + ? [this.resolveNamespace(payload.namespace, sourceAgent)] + : getAgentNamespaces(sourceAgent); + + const namespaceFilter = namespaces.length === 1 ? namespaces[0] : null; + + const filterMust: any[] = []; + if (namespaceFilter) { + filterMust.push({ key: "namespace", match: { value: namespaceFilter } }); + } else { + filterMust.push({ + should: namespaces.map((ns) => ({ + key: "namespace", + match: { value: ns }, + })), + }); + } + + if ( + shouldApplyStrictSessionFilter( + sessionMode, + payload.sessionId || context.sessionId, + ) + ) { + filterMust.push({ + key: "sessionId", + match: { value: payload.sessionId || context.sessionId }, + }); + } + + if (payload.userId || context.userId) { + filterMust.push({ + key: "userId", + match: { value: payload.userId || context.userId }, + }); + } + + if (payload.sourceAgent) { + filterMust.push({ + key: "source_agent", + match: { value: payload.sourceAgent }, + }); + } + + const vector = await this.embedding.embed(query); + const points = await this.qdrant.search(vector, limit, { + must: filterMust, + }); + + const weighted = points + .filter( + (r: ScoredPoint) => (r.payload?.namespace || "") !== "noise.filtered", + ) + .map((r: ScoredPoint) => { + const ns = String(r.payload?.namespace || ""); + const scored = scoreSemanticCandidate({ + rawScore: r.score, + agentId: sourceAgent, + namespace: ns, + sessionMode, + preferredSessionId, + payloadSessionId: r.payload?.sessionId, + promotionState: r.payload?.promotion_state, + }); + return { + id: String(r.id), + rawScore: r.score, + score: scored.finalScore, + text: String(r.payload?.text || ""), + namespace: ns, + timestamp: + typeof r.payload?.timestamp === "number" + ? r.payload.timestamp + : undefined, + metadata: (r.payload?.metadata || {}) as Record, + }; + }) + .filter((r) => r.score >= minScore) + .sort((a, b) => b.score - a.score); + + return { + query, + count: weighted.length, + results: weighted, + }; + } + + private resolveNamespace( + namespace: string | undefined, + sourceAgent: string, + ): MemoryNamespace { + if (typeof namespace === "string" && namespace.trim().length > 0) { + return parseExplicitNamespace(namespace, sourceAgent); + } + return `agent.${sourceAgent}.working_memory` as MemoryNamespace; + } + + private async embedDetailedCompat( + text: string, + ): Promise<{ vector: number[]; metadata: Record }> { + const emb = this.embedding as any; + if (typeof emb.embedDetailed === "function") { + return emb.embedDetailed(text); + } + + const vector = await this.embedding.embed(text); + return { + vector, + metadata: { + embedding_chunked: false, + embedding_chunks_count: 1, + embedding_chunking_strategy: "array_batch_weighted_avg", + embedding_model: "unknown", + embedding_model_key: "unknown", + embedding_provider: "auto", + }, + }; + } } diff --git a/src/db/slot-db.ts b/src/db/slot-db.ts index 7059612..156f17f 100644 --- a/src/db/slot-db.ts +++ b/src/db/slot-db.ts @@ -7,578 +7,596 @@ * Values are stored as JSON strings. */ -import { DatabaseSync } from "node:sqlite"; import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -import { mkdirSync, existsSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { populateUniversalCodeGraphForFile } from "../core/graph/code-graph-populator.js"; +import { buildSymbolId } from "../core/ingest/ids.js"; import { buildChunkArtifacts } from "../core/ingest/ingest-pipeline.js"; import { extractSemanticBlocks } from "../core/ingest/semantic-block-extractor.js"; -import { buildSymbolId } from "../core/ingest/ids.js"; -import { populateUniversalCodeGraphForFile } from "../core/graph/code-graph-populator.js"; -import { GraphDB } from "./graph-db.js"; import { getSlotTTL } from "../shared/memory-config.js"; -import { resolveLegacyStateDirInput, resolveSlotDbDir } from "../shared/slotdb-path.js"; +import { + resolveLegacyStateDirInput, + resolveSlotDbDir, +} from "../shared/slotdb-path.js"; +import { GraphDB } from "./graph-db.js"; -// Re-export GraphDB types -export { GraphDB }; export type { - Entity, - EntityRow, - Relationship, - RelationshipRow, - EntityCreateInput, - RelationshipCreateInput, - EntityFilter, - RelationDirection, + Entity, + EntityCreateInput, + EntityFilter, + EntityRow, + RelationDirection, + Relationship, + RelationshipCreateInput, + RelationshipRow, } from "./graph-db.js"; +// Re-export GraphDB types +export { GraphDB }; // ============================================================================ // Types // ============================================================================ export interface Slot { - id: string; - scope_user_id: string; - scope_agent_id: string; - category: string; - key: string; - value: unknown; - source: "auto_capture" | "manual" | "tool"; - confidence: number; - version: number; - created_at: string; - updated_at: string; - expires_at: string | null; + id: string; + scope_user_id: string; + scope_agent_id: string; + category: string; + key: string; + value: unknown; + source: "auto_capture" | "manual" | "tool"; + confidence: number; + version: number; + created_at: string; + updated_at: string; + expires_at: string | null; } export interface SlotRow { - id: string; - scope_user_id: string; - scope_agent_id: string; - category: string; - key: string; - value: string; // JSON-encoded - source: string; - confidence: number; - version: number; - created_at: string; - updated_at: string; - expires_at: string | null; + id: string; + scope_user_id: string; + scope_agent_id: string; + category: string; + key: string; + value: string; // JSON-encoded + source: string; + confidence: number; + version: number; + created_at: string; + updated_at: string; + expires_at: string | null; } export interface SlotSetInput { - key: string; - value: unknown; - category?: string; - source?: "auto_capture" | "manual" | "tool"; - confidence?: number; - expires_at?: string | null; + key: string; + value: unknown; + category?: string; + source?: "auto_capture" | "manual" | "tool"; + confidence?: number; + expires_at?: string | null; } export interface SlotGetInput { - key?: string; - category?: string; + key?: string; + category?: string; } export interface SlotListInput { - category?: string; - prefix?: string; + category?: string; + prefix?: string; } export interface ProjectRegisterInput { - project_id?: string; - project_name?: string; - project_alias: string; - repo_root?: string; - repo_remote?: string; - active_version?: string; - allow_alias_update?: boolean; - reuse_existing_repo_root?: boolean; + project_id?: string; + project_name?: string; + project_alias: string; + repo_root?: string; + repo_remote?: string; + active_version?: string; + allow_alias_update?: boolean; + reuse_existing_repo_root?: boolean; } export interface ProjectRecord { - project_id: string; - scope_user_id: string; - scope_agent_id: string; - project_name: string; - repo_root: string | null; - repo_remote_primary: string | null; - active_version: string | null; - lifecycle_status: "active" | "archived" | "disabled" | "detached" | "deindexed" | "purged"; - created_at: string; - updated_at: string; + project_id: string; + scope_user_id: string; + scope_agent_id: string; + project_name: string; + repo_root: string | null; + repo_remote_primary: string | null; + active_version: string | null; + lifecycle_status: + | "active" + | "archived" + | "disabled" + | "detached" + | "deindexed" + | "purged"; + created_at: string; + updated_at: string; } export interface ProjectDeindexResult { - project_id: string; - lifecycle_status: "deindexed"; - deindexed_at: string; - reason: string | null; - affected: { - files: number; - chunks: number; - symbols: number; - }; - searchable: false; + project_id: string; + lifecycle_status: "deindexed"; + deindexed_at: string; + reason: string | null; + affected: { + files: number; + chunks: number; + symbols: number; + }; + searchable: false; } export interface ProjectDetachResult { - project_id: string; - lifecycle_status: "detached"; - detached_at: string; - reason: string | null; - detached_fields: { - repo_root: boolean; - repo_remote_primary: boolean; - active_version: boolean; - aliases_removed: number; - tracker_mappings_removed: number; - }; - searchable: false; - next_actions: { - reattach_via_register_or_update: true; - reversible_by_re_register: true; - }; + project_id: string; + lifecycle_status: "detached"; + detached_at: string; + reason: string | null; + detached_fields: { + repo_root: boolean; + repo_remote_primary: boolean; + active_version: boolean; + aliases_removed: number; + tracker_mappings_removed: number; + }; + searchable: false; + next_actions: { + reattach_via_register_or_update: true; + reversible_by_re_register: true; + }; } export interface ProjectUnregisterResult { - project_id: string; - lifecycle_status: "disabled"; - unregistered_at: string; - mode: "safe"; - reason: string | null; - detached_fields: { - aliases_removed: number; - tracker_mappings_removed: number; - }; - registration_state: { - registration_status: "draft"; - validation_status: "warn"; - }; - searchable: false; - audit: { - deindexed_first: boolean; - confirm_required: true; - }; + project_id: string; + lifecycle_status: "disabled"; + unregistered_at: string; + mode: "safe"; + reason: string | null; + detached_fields: { + aliases_removed: number; + tracker_mappings_removed: number; + }; + registration_state: { + registration_status: "draft"; + validation_status: "warn"; + }; + searchable: false; + audit: { + deindexed_first: boolean; + confirm_required: true; + }; } export interface ProjectPurgePreviewResult { - project_id: string; - current_lifecycle_status: ProjectRecord["lifecycle_status"]; - purge_guard: { - destructive: true; - allowed: boolean; - reason: string; - requires_lifecycle_status: "disabled"; - requires_confirm: true; - }; - affected: { - project_row: 1; - aliases: number; - tracker_mappings: number; - registration_state: number; - index_runs: number; - watch_state: number; - file_index_state: number; - chunk_registry: number; - symbol_registry: number; - task_registry: number; - }; - previewed_at: string; + project_id: string; + current_lifecycle_status: ProjectRecord["lifecycle_status"]; + purge_guard: { + destructive: true; + allowed: boolean; + reason: string; + requires_lifecycle_status: "disabled"; + requires_confirm: true; + }; + affected: { + project_row: 1; + aliases: number; + tracker_mappings: number; + registration_state: number; + index_runs: number; + watch_state: number; + file_index_state: number; + chunk_registry: number; + symbol_registry: number; + task_registry: number; + }; + previewed_at: string; } export interface ProjectPurgeResult { - project_id: string; - lifecycle_status: "purged"; - purged_at: string; - reason: string | null; - deleted: { - project_row: 1; - aliases: number; - tracker_mappings: number; - registration_state: number; - index_runs: number; - watch_state: number; - file_index_state: number; - chunk_registry: number; - symbol_registry: number; - task_registry: number; - }; - searchable: false; - recoverable: false; - audit: { - confirm_required: true; - allowed_from_lifecycle_status: "disabled"; - }; + project_id: string; + lifecycle_status: "purged"; + purged_at: string; + reason: string | null; + deleted: { + project_row: 1; + aliases: number; + tracker_mappings: number; + registration_state: number; + index_runs: number; + watch_state: number; + file_index_state: number; + chunk_registry: number; + symbol_registry: number; + task_registry: number; + }; + searchable: false; + recoverable: false; + audit: { + confirm_required: true; + allowed_from_lifecycle_status: "disabled"; + }; } export interface ProjectAliasRecord { - id: string; - project_id: string; - scope_user_id: string; - scope_agent_id: string; - project_alias: string; - is_primary: number; - created_at: string; - updated_at: string; + id: string; + project_id: string; + scope_user_id: string; + scope_agent_id: string; + project_alias: string; + is_primary: number; + created_at: string; + updated_at: string; } export interface ProjectTrackerMappingInput { - project_id: string; - tracker_type: "jira" | "github" | "other"; - tracker_space_key?: string; - tracker_project_id?: string; - default_epic_key?: string; - board_key?: string; - active_version?: string; - external_project_url?: string; + project_id: string; + tracker_type: "jira" | "github" | "other"; + tracker_space_key?: string; + tracker_project_id?: string; + default_epic_key?: string; + board_key?: string; + active_version?: string; + external_project_url?: string; } export interface ProjectTrackerMappingRecord { - id: string; - project_id: string; - scope_user_id: string; - scope_agent_id: string; - tracker_type: "jira" | "github" | "other"; - tracker_space_key: string | null; - tracker_project_id: string | null; - default_epic_key: string | null; - board_key: string | null; - active_version: string | null; - external_project_url: string | null; - created_at: string; - updated_at: string; + id: string; + project_id: string; + scope_user_id: string; + scope_agent_id: string; + tracker_type: "jira" | "github" | "other"; + tracker_space_key: string | null; + tracker_project_id: string | null; + default_epic_key: string | null; + board_key: string | null; + active_version: string | null; + external_project_url: string | null; + created_at: string; + updated_at: string; } export interface ProjectRegistrationStateRecord { - project_id: string; - scope_user_id: string; - scope_agent_id: string; - registration_status: "draft" | "registered" | "validated" | "blocked"; - validation_status: "pending" | "ok" | "warn" | "error"; - validation_notes: string | null; - completeness_score: number; - missing_required_fields: string[]; - last_validated_at: string | null; - updated_at: string; + project_id: string; + scope_user_id: string; + scope_agent_id: string; + registration_status: "draft" | "registered" | "validated" | "blocked"; + validation_status: "pending" | "ok" | "warn" | "error"; + validation_notes: string | null; + completeness_score: number; + missing_required_fields: string[]; + last_validated_at: string | null; + updated_at: string; } export interface ProjectReindexDiffInput { - project_id: string; - source_rev?: string | null; - trigger_type?: "bootstrap" | "incremental" | "manual" | "repair"; - index_profile?: string; - full_snapshot?: boolean; - paths?: Array<{ - relative_path: string; - checksum?: string | null; - module?: string | null; - language?: string | null; - content?: string | null; - }>; + project_id: string; + source_rev?: string | null; + trigger_type?: "bootstrap" | "incremental" | "manual" | "repair"; + index_profile?: string; + full_snapshot?: boolean; + paths?: Array<{ + relative_path: string; + checksum?: string | null; + module?: string | null; + language?: string | null; + content?: string | null; + }>; } interface ProjectSymbolUpsertInput { - symbol_id: string; - project_id: string; - relative_path: string; - module: string | null; - language: string; - symbol_name: string; - symbol_fqn: string; - symbol_kind: string; - signature_hash?: string | null; - index_state: string; - active: number; - tombstone_at: string | null; - indexed_at: string | null; + symbol_id: string; + project_id: string; + relative_path: string; + module: string | null; + language: string; + symbol_name: string; + symbol_fqn: string; + symbol_kind: string; + signature_hash?: string | null; + index_state: string; + active: number; + tombstone_at: string | null; + indexed_at: string | null; } interface ProjectChunkUpsertInput { - chunk_id: string; - project_id: string; - file_id: string | null; - relative_path: string | null; - chunk_kind: string; - symbol_id: string | null; - task_id?: string | null; - checksum: string; - qdrant_point_id?: string | null; - index_state: string; - active: number; - tombstone_at: string | null; - indexed_at: string | null; + chunk_id: string; + project_id: string; + file_id: string | null; + relative_path: string | null; + chunk_kind: string; + symbol_id: string | null; + task_id?: string | null; + checksum: string; + qdrant_point_id?: string | null; + index_state: string; + active: number; + tombstone_at: string | null; + indexed_at: string | null; } export interface ProjectIndexWatchState { - project_id: string; - scope_user_id: string; - scope_agent_id: string; - last_source_rev: string | null; - last_checksum_snapshot: Record; - updated_at: string; + project_id: string; + scope_user_id: string; + scope_agent_id: string; + last_source_rev: string | null; + last_checksum_snapshot: Record; + updated_at: string; } export interface ProjectReindexDiffResult { - run_id: string; - project_id: string; - trigger_type: "bootstrap" | "incremental" | "manual" | "repair"; - index_profile: string; - source_rev: string | null; - changed: string[]; - unchanged: string[]; - deleted: string[]; - run_state: "indexed" | "error"; - watch_state: { - last_source_rev: string | null; - updated_at: string; - }; + run_id: string; + project_id: string; + trigger_type: "bootstrap" | "incremental" | "manual" | "repair"; + index_profile: string; + source_rev: string | null; + changed: string[]; + unchanged: string[]; + deleted: string[]; + run_state: "indexed" | "error"; + watch_state: { + last_source_rev: string | null; + updated_at: string; + }; } export interface ProjectTaskRegistryUpsertInput { - task_id: string; - project_id: string; - task_title: string; - task_type?: string | null; - task_status?: string | null; - parent_task_id?: string | null; - related_task_ids?: string[]; - files_touched?: string[]; - symbols_touched?: string[]; - commit_refs?: string[]; - diff_refs?: string[]; - decision_notes?: string | null; - tracker_issue_key?: string | null; + task_id: string; + project_id: string; + task_title: string; + task_type?: string | null; + task_status?: string | null; + parent_task_id?: string | null; + related_task_ids?: string[]; + files_touched?: string[]; + symbols_touched?: string[]; + commit_refs?: string[]; + diff_refs?: string[]; + decision_notes?: string | null; + tracker_issue_key?: string | null; } export interface TaskRegistryRecord { - task_id: string; - scope_user_id: string; - scope_agent_id: string; - project_id: string; - task_title: string; - task_type: string | null; - task_status: string | null; - parent_task_id: string | null; - related_task_ids: string[]; - files_touched: string[]; - symbols_touched: string[]; - commit_refs: string[]; - diff_refs: string[]; - decision_notes: string | null; - tracker_issue_key: string | null; - updated_at: string; + task_id: string; + scope_user_id: string; + scope_agent_id: string; + project_id: string; + task_title: string; + task_type: string | null; + task_status: string | null; + parent_task_id: string | null; + related_task_ids: string[]; + files_touched: string[]; + symbols_touched: string[]; + commit_refs: string[]; + diff_refs: string[]; + decision_notes: string | null; + tracker_issue_key: string | null; + updated_at: string; } export interface ProjectTaskLineageContextInput { - project_id: string; - task_id?: string; - tracker_issue_key?: string; - task_title?: string; - include_related?: boolean; - include_parent_chain?: boolean; + project_id: string; + task_id?: string; + tracker_issue_key?: string; + task_title?: string; + include_related?: boolean; + include_parent_chain?: boolean; } export interface ProjectTaskLineageContextResult { - focus: { - project_id: string; - task_id: string; - tracker_issue_key: string | null; - task_title: string; - }; - parent_chain: TaskRegistryRecord[]; - related_tasks: TaskRegistryRecord[]; - touched_files: string[]; - touched_symbols: string[]; - commit_refs: string[]; - decision_notes: string[]; + focus: { + project_id: string; + task_id: string; + tracker_issue_key: string | null; + task_title: string; + }; + parent_chain: TaskRegistryRecord[]; + related_tasks: TaskRegistryRecord[]; + touched_files: string[]; + touched_symbols: string[]; + commit_refs: string[]; + decision_notes: string[]; } export interface ProjectHybridSearchInput { - project_id: string; - query: string; - limit?: number; - debug?: boolean; - path_prefix?: string[]; - module?: string[]; - language?: string[]; - task_id?: string[]; - tracker_issue_key?: string[]; - task_context?: { - task_id?: string; - tracker_issue_key?: string; - task_title?: string; - include_related?: boolean; - include_parent_chain?: boolean; - }; + project_id: string; + query: string; + limit?: number; + debug?: boolean; + path_prefix?: string[]; + module?: string[]; + language?: string[]; + task_id?: string[]; + tracker_issue_key?: string[]; + task_context?: { + task_id?: string; + tracker_issue_key?: string; + task_title?: string; + include_related?: boolean; + include_parent_chain?: boolean; + }; } export interface ProjectHybridSearchResultItem { - source: "file_index_state" | "symbol_registry" | "chunk_registry" | "task_registry"; - id: string; - score: number; - project_id: string; - relative_path?: string; - module?: string | null; - language?: string | null; - symbol_name?: string; - symbol_kind?: string; - task_id?: string; - task_title?: string; - tracker_issue_key?: string | null; - snippet: string; + source: + | "file_index_state" + | "symbol_registry" + | "chunk_registry" + | "task_registry"; + id: string; + score: number; + project_id: string; + relative_path?: string; + module?: string | null; + language?: string | null; + symbol_name?: string; + symbol_kind?: string; + task_id?: string; + task_title?: string; + tracker_issue_key?: string | null; + snippet: string; } export interface ProjectHybridSearchTaskContextResolution { - status: "not_requested" | "resolved" | "selector_not_resolved"; - reason?: string; - selector: { - task_id?: string; - tracker_issue_key?: string; - task_title?: string; - }; - recoverable: boolean; + status: "not_requested" | "resolved" | "selector_not_resolved"; + reason?: string; + selector: { + task_id?: string; + tracker_issue_key?: string; + task_title?: string; + }; + recoverable: boolean; } export interface ProjectHybridSearchResult { - query: string; - project_id: string; - project_lifecycle_status?: ProjectRecord["lifecycle_status"]; - searchable?: boolean; - tombstone_summary?: { - files: number; - chunks: number; - symbols: number; - }; - count: number; - task_lineage_context: ProjectTaskLineageContextResult | null; - task_context_resolution: ProjectHybridSearchTaskContextResolution; - results: ProjectHybridSearchResultItem[]; - debug?: { - query_intent: { - looks_code_intent: boolean; - looks_identifier_query: boolean; - query_tokens: string[]; - }; - candidate_counts: { - file_index_state: number; - symbol_registry: number; - chunk_registry: number; - task_registry: number; - }; - top_candidates: { - file_index_state: Array>; - symbol_registry: Array>; - chunk_registry: Array>; - task_registry: Array>; - }; - }; + query: string; + project_id: string; + project_lifecycle_status?: ProjectRecord["lifecycle_status"]; + searchable?: boolean; + tombstone_summary?: { + files: number; + chunks: number; + symbols: number; + }; + count: number; + task_lineage_context: ProjectTaskLineageContextResult | null; + task_context_resolution: ProjectHybridSearchTaskContextResolution; + results: ProjectHybridSearchResultItem[]; + debug?: { + query_intent: { + looks_code_intent: boolean; + looks_identifier_query: boolean; + query_tokens: string[]; + }; + candidate_counts: { + file_index_state: number; + symbol_registry: number; + chunk_registry: number; + task_registry: number; + }; + top_candidates: { + file_index_state: Array>; + symbol_registry: Array>; + chunk_registry: Array>; + task_registry: Array>; + }; + }; } export interface ProjectLegacyBackfillInput { - mode?: "dry_run" | "apply"; - only_project_ids?: string[]; - only_aliases?: string[]; - force_registration_state?: boolean; - source?: "repo_root" | "repo_remote" | "task_registry" | "mixed"; + mode?: "dry_run" | "apply"; + only_project_ids?: string[]; + only_aliases?: string[]; + force_registration_state?: boolean; + source?: "repo_root" | "repo_remote" | "task_registry" | "mixed"; } export interface ProjectLegacyBackfillItem { - project_id: string; - project_name: string; - inferred_aliases: string[]; - inferred_tracker_mappings: Array<{ - tracker_type: "jira" | "github" | "other"; - tracker_space_key: string | null; - tracker_project_id: string | null; - default_epic_key: string | null; - confidence: number; - source: "repo_remote" | "task_registry"; - }>; - actions: string[]; - warnings: string[]; + project_id: string; + project_name: string; + inferred_aliases: string[]; + inferred_tracker_mappings: Array<{ + tracker_type: "jira" | "github" | "other"; + tracker_space_key: string | null; + tracker_project_id: string | null; + default_epic_key: string | null; + confidence: number; + source: "repo_remote" | "task_registry"; + }>; + actions: string[]; + warnings: string[]; } export interface ProjectLegacyBackfillResult { - mode: "dry_run" | "apply"; - source: "repo_root" | "repo_remote" | "task_registry" | "mixed"; - scanned_projects: number; - candidates: number; - updated_aliases: number; - updated_tracker_mappings: number; - updated_registration_states: number; - migration_state_upserts: number; - items: ProjectLegacyBackfillItem[]; + mode: "dry_run" | "apply"; + source: "repo_root" | "repo_remote" | "task_registry" | "mixed"; + scanned_projects: number; + candidates: number; + updated_aliases: number; + updated_tracker_mappings: number; + updated_registration_states: number; + migration_state_upserts: number; + items: ProjectLegacyBackfillItem[]; } export interface ProjectChangeOverlayQueryInput { - project_id: string; - task_id?: string; - tracker_issue_key?: string; - task_title?: string; - feature_key?: "project_onboarding_registration_indexing" | "code_aware_retrieval" | "heartbeat_health_runtime_integrity" | "change_aware_impact" | "post_entry_review_decision_support"; - feature_name?: string; - include_related?: boolean; - include_parent_chain?: boolean; + project_id: string; + task_id?: string; + tracker_issue_key?: string; + task_title?: string; + feature_key?: + | "project_onboarding_registration_indexing" + | "code_aware_retrieval" + | "heartbeat_health_runtime_integrity" + | "change_aware_impact" + | "post_entry_review_decision_support"; + feature_name?: string; + include_related?: boolean; + include_parent_chain?: boolean; } export interface ProjectChangeOverlaySymbol { - symbol_name: string; - symbol_kind?: string; - symbol_fqn?: string; - relative_path?: string; - source: "task_registry" | "symbol_registry"; + symbol_name: string; + symbol_kind?: string; + symbol_fqn?: string; + relative_path?: string; + source: "task_registry" | "symbol_registry"; } export interface ProjectChangeOverlayResult { - status: "ok" | "selector_not_resolved"; - reason?: string; - selector: { - task_id?: string; - tracker_issue_key?: string; - task_title?: string; - }; - recoverable: boolean; - project_id: string; - focus: { - task_id: string; - task_title: string; - tracker_issue_key: string | null; - }; - changed_files: string[]; - related_symbols: ProjectChangeOverlaySymbol[]; - commit_refs: string[]; + status: "ok" | "selector_not_resolved"; + reason?: string; + selector: { + task_id?: string; + tracker_issue_key?: string; + task_title?: string; + }; + recoverable: boolean; + project_id: string; + focus: { + task_id: string; + task_title: string; + tracker_issue_key: string | null; + }; + changed_files: string[]; + related_symbols: ProjectChangeOverlaySymbol[]; + commit_refs: string[]; } export interface ProjectFeaturePackProjectOnboardingIndexingSnapshot { - project: ProjectRecord; - aliases: ProjectAliasRecord[]; - registration: ProjectRegistrationStateRecord | null; - tracker_mappings: ProjectTrackerMappingRecord[]; - recent_files: Array<{ - relative_path: string; - module: string | null; - language: string | null; - }>; - recent_symbols: Array<{ - symbol_name: string; - symbol_kind: string; - symbol_fqn: string; - relative_path: string; - }>; - recent_tasks: Array<{ - task_id: string; - task_title: string; - tracker_issue_key: string | null; - task_status: string | null; - }>; - recent_index_runs: Array<{ - run_id: string; - trigger_type: string; - state: string; - started_at: string; - finished_at: string | null; - }>; + project: ProjectRecord; + aliases: ProjectAliasRecord[]; + registration: ProjectRegistrationStateRecord | null; + tracker_mappings: ProjectTrackerMappingRecord[]; + recent_files: Array<{ + relative_path: string; + module: string | null; + language: string | null; + }>; + recent_symbols: Array<{ + symbol_name: string; + symbol_kind: string; + symbol_fqn: string; + relative_path: string; + }>; + recent_tasks: Array<{ + task_id: string; + task_title: string; + tracker_issue_key: string | null; + task_status: string | null; + }>; + recent_index_runs: Array<{ + run_id: string; + trigger_type: string; + state: string; + started_at: string; + finished_at: string | null; + }>; } // ============================================================================ @@ -586,37 +604,40 @@ export interface ProjectFeaturePackProjectOnboardingIndexingSnapshot { // ============================================================================ export class SlotDB { - private db: DatabaseSync; - private stateDir: string; - private slotDbDir: string; - public graph: GraphDB; - - constructor(stateDirOrSlotDbDir: string, options?: { slotDbDir?: string }) { - this.stateDir = stateDirOrSlotDbDir; - - // Priority resolver for new config/env flow. - // If explicit slotDbDir option is provided, it is treated as already-resolved target dir. - if (options?.slotDbDir) { - this.slotDbDir = resolveSlotDbDir({ slotDbDir: options.slotDbDir, stateDir: stateDirOrSlotDbDir }); - } else { - // Backward compatibility for legacy constructor callsites that pass OPENCLAW_STATE_DIR. - this.slotDbDir = resolveLegacyStateDirInput(stateDirOrSlotDbDir); - } - - if (!existsSync(this.slotDbDir)) { - mkdirSync(this.slotDbDir, { recursive: true }); - } - - const dbPath = join(this.slotDbDir, "slots.db"); - this.db = new DatabaseSync(dbPath); - this.db.exec("PRAGMA journal_mode = WAL"); - this.db.exec("PRAGMA foreign_keys = ON"); - this.migrate(); - this.graph = new GraphDB(this.db); - } - - private migrate(): void { - this.db.exec(` + private db: DatabaseSync; + private stateDir: string; + private slotDbDir: string; + public graph: GraphDB; + + constructor(stateDirOrSlotDbDir: string, options?: { slotDbDir?: string }) { + this.stateDir = stateDirOrSlotDbDir; + + // Priority resolver for new config/env flow. + // If explicit slotDbDir option is provided, it is treated as already-resolved target dir. + if (options?.slotDbDir) { + this.slotDbDir = resolveSlotDbDir({ + slotDbDir: options.slotDbDir, + stateDir: stateDirOrSlotDbDir, + }); + } else { + // Backward compatibility for legacy constructor callsites that pass OPENCLAW_STATE_DIR. + this.slotDbDir = resolveLegacyStateDirInput(stateDirOrSlotDbDir); + } + + if (!existsSync(this.slotDbDir)) { + mkdirSync(this.slotDbDir, { recursive: true }); + } + + const dbPath = join(this.slotDbDir, "slots.db"); + this.db = new DatabaseSync(dbPath); + this.db.exec("PRAGMA journal_mode = WAL"); + this.db.exec("PRAGMA foreign_keys = ON"); + this.migrate(); + this.graph = new GraphDB(this.db); + } + + private migrate(): void { + this.db.exec(` CREATE TABLE IF NOT EXISTS slots ( id TEXT PRIMARY KEY, scope_user_id TEXT NOT NULL DEFAULT '', @@ -634,27 +655,27 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_slots_scope ON slots(scope_user_id, scope_agent_id) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_slots_category ON slots(category) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_slots_key ON slots(key) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_slots_updated ON slots(updated_at DESC) `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS projects ( project_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -670,13 +691,13 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_scope_repo_root ON projects(scope_user_id, scope_agent_id, repo_root) WHERE repo_root IS NOT NULL AND repo_root != '' `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS project_aliases ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, @@ -690,12 +711,12 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_project_aliases_project ON project_aliases(scope_user_id, scope_agent_id, project_id) `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS project_tracker_mappings ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, @@ -714,7 +735,7 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS project_registration_state ( project_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -730,8 +751,8 @@ export class SlotDB { ) `); - // ASM-76 (v5.1) bootstrap: metadata/control plane schema for ingest & reindex lifecycle. - this.db.exec(` + // ASM-76 (v5.1) bootstrap: metadata/control plane schema for ingest & reindex lifecycle. + this.db.exec(` CREATE TABLE IF NOT EXISTS index_runs ( run_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -747,12 +768,12 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_index_runs_project_state ON index_runs(scope_user_id, scope_agent_id, project_id, state, started_at) `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS file_index_state ( file_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -772,12 +793,12 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_file_state_project_path ON file_index_state(scope_user_id, scope_agent_id, project_id, relative_path) `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS chunk_registry ( chunk_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -798,12 +819,12 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_chunk_project_state ON chunk_registry(scope_user_id, scope_agent_id, project_id, index_state, active) `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS symbol_registry ( symbol_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -824,12 +845,12 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_symbol_project_module_name ON symbol_registry(scope_user_id, scope_agent_id, project_id, module, symbol_name) `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS task_registry ( task_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -851,12 +872,12 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_task_project_parent ON task_registry(scope_user_id, scope_agent_id, project_id, parent_task_id) `); - this.db.exec(` + this.db.exec(` CREATE TABLE IF NOT EXISTS migration_state ( migration_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -870,8 +891,8 @@ export class SlotDB { ) `); - // ASM-78 (v5.1) incremental reindex watch-state + diff/checksum control plane. - this.db.exec(` + // ASM-78 (v5.1) incremental reindex watch-state + diff/checksum control plane. + this.db.exec(` CREATE TABLE IF NOT EXISTS project_index_watch_state ( project_id TEXT NOT NULL, scope_user_id TEXT NOT NULL DEFAULT '', @@ -883,359 +904,429 @@ export class SlotDB { ) `); - this.db.exec(` + this.db.exec(` CREATE INDEX IF NOT EXISTS idx_project_watch_updated ON project_index_watch_state(scope_user_id, scope_agent_id, updated_at) `); - } - - // -------------------------------------------------------------------------- - // CRUD - // -------------------------------------------------------------------------- - - /** - * Set (upsert) a slot. Creates or updates, incrementing version. - */ - set( - scopeUserId: string, - scopeAgentId: string, - input: SlotSetInput, - ): Slot { - const now = new Date().toISOString(); - const category = input.category || this.inferCategory(input.key); - const valueJson = JSON.stringify(input.value); - const source = input.source || "tool"; - const confidence = input.confidence ?? 1.0; - - // Try to get existing - const selectStmt = this.db.prepare( - `SELECT * FROM slots + } + + // -------------------------------------------------------------------------- + // CRUD + // -------------------------------------------------------------------------- + + /** + * Set (upsert) a slot. Creates or updates, incrementing version. + */ + set(scopeUserId: string, scopeAgentId: string, input: SlotSetInput): Slot { + const now = new Date().toISOString(); + const category = input.category || this.inferCategory(input.key); + const valueJson = JSON.stringify(input.value); + const source = input.source || "tool"; + const confidence = input.confidence ?? 1.0; + + // Try to get existing + const selectStmt = this.db.prepare( + `SELECT * FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? AND key = ?`, - ); - const existing = selectStmt.get(scopeUserId, scopeAgentId, input.key) as SlotRow | undefined; - - if (existing) { - // Update existing slot - const newVersion = existing.version + 1; - const updateStmt = this.db.prepare( - `UPDATE slots + ); + const existing = selectStmt.get(scopeUserId, scopeAgentId, input.key) as + | SlotRow + | undefined; + + if (existing) { + // Update existing slot + const newVersion = existing.version + 1; + const updateStmt = this.db.prepare( + `UPDATE slots SET value = ?, category = ?, source = ?, confidence = ?, version = ?, updated_at = ?, expires_at = ? WHERE id = ?`, - ); - updateStmt.run( - valueJson, - category, - source, - confidence, - newVersion, - now, - input.expires_at !== undefined ? input.expires_at : existing.expires_at, - existing.id, - ); - - return { - ...existing, - category, - value: input.value, - source: source as Slot["source"], - confidence, - version: newVersion, - updated_at: now, - expires_at: input.expires_at !== undefined ? input.expires_at : existing.expires_at, - }; - } - - // Insert new slot - const id = randomUUID(); - const insertStmt = this.db.prepare( - `INSERT INTO slots (id, scope_user_id, scope_agent_id, category, key, value, source, confidence, version, created_at, updated_at, expires_at) + ); + updateStmt.run( + valueJson, + category, + source, + confidence, + newVersion, + now, + input.expires_at !== undefined ? input.expires_at : existing.expires_at, + existing.id, + ); + + return { + ...existing, + category, + value: input.value, + source: source as Slot["source"], + confidence, + version: newVersion, + updated_at: now, + expires_at: + input.expires_at !== undefined + ? input.expires_at + : existing.expires_at, + }; + } + + // Insert new slot + const id = randomUUID(); + const insertStmt = this.db.prepare( + `INSERT INTO slots (id, scope_user_id, scope_agent_id, category, key, value, source, confidence, version, created_at, updated_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, - ); - insertStmt.run( - id, - scopeUserId, - scopeAgentId, - category, - input.key, - valueJson, - source, - confidence, - now, - now, - input.expires_at || null, - ); - - return { - id, - scope_user_id: scopeUserId, - scope_agent_id: scopeAgentId, - category, - key: input.key, - value: input.value, - source: source as Slot["source"], - confidence, - version: 1, - created_at: now, - updated_at: now, - expires_at: input.expires_at || null, - }; - } - - /** - * Get a single slot by key, or all slots in a category. - */ - get( - scopeUserId: string, - scopeAgentId: string, - input: SlotGetInput, - ): Slot | Slot[] | null { - this.cleanExpired(scopeUserId, scopeAgentId); - - if (input.key) { - const stmt = this.db.prepare( - `SELECT * FROM slots + ); + insertStmt.run( + id, + scopeUserId, + scopeAgentId, + category, + input.key, + valueJson, + source, + confidence, + now, + now, + input.expires_at || null, + ); + + return { + id, + scope_user_id: scopeUserId, + scope_agent_id: scopeAgentId, + category, + key: input.key, + value: input.value, + source: source as Slot["source"], + confidence, + version: 1, + created_at: now, + updated_at: now, + expires_at: input.expires_at || null, + }; + } + + /** + * Get a single slot by key, or all slots in a category. + */ + get( + scopeUserId: string, + scopeAgentId: string, + input: SlotGetInput, + ): Slot | Slot[] | null { + this.cleanExpired(scopeUserId, scopeAgentId); + + if (input.key) { + const stmt = this.db.prepare( + `SELECT * FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? AND key = ?`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, input.key) as SlotRow | undefined; - - if (!row) return null; - return this.rowToSlot(row); - } - - if (input.category) { - const stmt = this.db.prepare( - `SELECT * FROM slots + ); + const row = stmt.get(scopeUserId, scopeAgentId, input.key) as + | SlotRow + | undefined; + + if (!row) return null; + return this.rowToSlot(row); + } + + if (input.category) { + const stmt = this.db.prepare( + `SELECT * FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? AND category = ? ORDER BY key ASC`, - ); - const rows = stmt.all(scopeUserId, scopeAgentId, input.category) as unknown as SlotRow[]; - - return rows.map((r) => this.rowToSlot(r)); - } - - // Return all slots - const stmt = this.db.prepare( - `SELECT * FROM slots + ); + const rows = stmt.all( + scopeUserId, + scopeAgentId, + input.category, + ) as unknown as SlotRow[]; + + return rows.map((r) => this.rowToSlot(r)); + } + + // Return all slots + const stmt = this.db.prepare( + `SELECT * FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? ORDER BY category ASC, key ASC`, - ); - const rows = stmt.all(scopeUserId, scopeAgentId) as unknown as SlotRow[]; - - return rows.map((r) => this.rowToSlot(r)); - } - - /** - * List slots with optional filtering. - */ - list( - scopeUserId: string, - scopeAgentId: string, - input?: SlotListInput, - ): Slot[] { - this.cleanExpired(scopeUserId, scopeAgentId); - - let query = `SELECT * FROM slots WHERE scope_user_id = ? AND scope_agent_id = ?`; - const params: (string | number | null)[] = [scopeUserId, scopeAgentId]; - - if (input?.category) { - query += ` AND category = ?`; - params.push(input.category); - } - - if (input?.prefix) { - query += ` AND key LIKE ?`; - params.push(`${input.prefix}%`); - } - - query += ` ORDER BY category ASC, key ASC`; - - const stmt = this.db.prepare(query); - const rows = stmt.all(...params) as unknown as SlotRow[]; - return rows.map((r) => this.rowToSlot(r)); - } - - /** - * Delete a slot by key. - */ - delete( - scopeUserId: string, - scopeAgentId: string, - key: string, - ): boolean { - const stmt = this.db.prepare( - `DELETE FROM slots + ); + const rows = stmt.all(scopeUserId, scopeAgentId) as unknown as SlotRow[]; + + return rows.map((r) => this.rowToSlot(r)); + } + + /** + * List slots with optional filtering. + */ + list( + scopeUserId: string, + scopeAgentId: string, + input?: SlotListInput, + ): Slot[] { + this.cleanExpired(scopeUserId, scopeAgentId); + + let query = `SELECT * FROM slots WHERE scope_user_id = ? AND scope_agent_id = ?`; + const params: (string | number | null)[] = [scopeUserId, scopeAgentId]; + + if (input?.category) { + query += ` AND category = ?`; + params.push(input.category); + } + + if (input?.prefix) { + query += ` AND key LIKE ?`; + params.push(`${input.prefix}%`); + } + + query += ` ORDER BY category ASC, key ASC`; + + const stmt = this.db.prepare(query); + const rows = stmt.all(...params) as unknown as SlotRow[]; + return rows.map((r) => this.rowToSlot(r)); + } + + /** + * Delete a slot by key. + */ + delete(scopeUserId: string, scopeAgentId: string, key: string): boolean { + const stmt = this.db.prepare( + `DELETE FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? AND key = ?`, - ); - const result = stmt.run(scopeUserId, scopeAgentId, key); - return result.changes > 0; - } - - /** - * Get the current state as a structured object for injection. - */ - getCurrentState( - scopeUserId: string, - scopeAgentId: string, - ): Record> { - this.cleanExpired(scopeUserId, scopeAgentId); - - const stmt = this.db.prepare( - `SELECT * FROM slots + ); + const result = stmt.run(scopeUserId, scopeAgentId, key); + return result.changes > 0; + } + + /** + * Get the current state as a structured object for injection. + */ + getCurrentState( + scopeUserId: string, + scopeAgentId: string, + ): Record> { + this.cleanExpired(scopeUserId, scopeAgentId); + + const stmt = this.db.prepare( + `SELECT * FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? ORDER BY category ASC, key ASC`, - ); - const rows = stmt.all(scopeUserId, scopeAgentId) as unknown as SlotRow[]; - - const state: Record> = {}; - - for (const row of rows) { - if (!state[row.category]) { - state[row.category] = {}; - } - try { - state[row.category][row.key] = JSON.parse(row.value); - } catch { - state[row.category][row.key] = row.value; - } - } - - return state; - } - - /** - * Get count of slots for a scope. - */ - count(scopeUserId: string, scopeAgentId: string): number { - const stmt = this.db.prepare( - `SELECT COUNT(*) as cnt FROM slots + ); + const rows = stmt.all(scopeUserId, scopeAgentId) as unknown as SlotRow[]; + + const state: Record> = {}; + + for (const row of rows) { + if (!state[row.category]) { + state[row.category] = {}; + } + try { + state[row.category][row.key] = JSON.parse(row.value); + } catch { + state[row.category][row.key] = row.value; + } + } + + return state; + } + + /** + * Get count of slots for a scope. + */ + count(scopeUserId: string, scopeAgentId: string): number { + const stmt = this.db.prepare( + `SELECT COUNT(*) as cnt FROM slots WHERE scope_user_id = ? AND scope_agent_id = ?`, - ); - const result = stmt.get(scopeUserId, scopeAgentId) as { cnt: number }; - return result.cnt; - } - - registerProject(scopeUserId: string, scopeAgentId: string, input: ProjectRegisterInput): { - project: ProjectRecord; - alias: ProjectAliasRecord; - registration: ProjectRegistrationStateRecord; - } { - const projectAlias = this.normalizeProjectAlias(input.project_alias); - if (!projectAlias) { - throw new Error("project_alias is required"); - } - - const now = new Date().toISOString(); - const projectId = this.normalizeProjectId(input.project_id) || randomUUID(); - const projectName = this.normalizeProjectName(input.project_name) || projectAlias; - const normalizedRepoRoot = this.normalizeRepoRoot(input.repo_root); - const normalizedRepoRemote = this.normalizeRepoRemote(input.repo_remote); - - const existingAlias = this.getProjectByAlias(scopeUserId, scopeAgentId, projectAlias); - if (existingAlias && existingAlias.project.project_id !== projectId && !input.allow_alias_update) { - throw new Error(`project_alias \"${projectAlias}\" is already mapped to another project_id`); - } - - let targetProjectId = projectId; - const existingByRepoRoot = - input.reuse_existing_repo_root && normalizedRepoRoot - ? this.findProjectByRepoRoot(scopeUserId, scopeAgentId, normalizedRepoRoot) - : null; - if (existingByRepoRoot) { - targetProjectId = existingByRepoRoot.project_id; - } - - const existing = this.getProjectById(scopeUserId, scopeAgentId, targetProjectId); - if (existing) { - const updateProject = this.db.prepare( - `UPDATE projects + ); + const result = stmt.get(scopeUserId, scopeAgentId) as { cnt: number }; + return result.cnt; + } + + registerProject( + scopeUserId: string, + scopeAgentId: string, + input: ProjectRegisterInput, + ): { + project: ProjectRecord; + alias: ProjectAliasRecord; + registration: ProjectRegistrationStateRecord; + } { + const projectAlias = this.normalizeProjectAlias(input.project_alias); + if (!projectAlias) { + throw new Error("project_alias is required"); + } + + const now = new Date().toISOString(); + const projectId = this.normalizeProjectId(input.project_id) || randomUUID(); + const projectName = + this.normalizeProjectName(input.project_name) || projectAlias; + const normalizedRepoRoot = this.normalizeRepoRoot(input.repo_root); + const normalizedRepoRemote = this.normalizeRepoRemote(input.repo_remote); + + const existingAlias = this.getProjectByAlias( + scopeUserId, + scopeAgentId, + projectAlias, + ); + if ( + existingAlias && + existingAlias.project.project_id !== projectId && + !input.allow_alias_update + ) { + throw new Error( + `project_alias "${projectAlias}" is already mapped to another project_id`, + ); + } + + let targetProjectId = projectId; + const existingByRepoRoot = + input.reuse_existing_repo_root && normalizedRepoRoot + ? this.findProjectByRepoRoot( + scopeUserId, + scopeAgentId, + normalizedRepoRoot, + ) + : null; + if (existingByRepoRoot) { + targetProjectId = existingByRepoRoot.project_id; + } + + const existing = this.getProjectById( + scopeUserId, + scopeAgentId, + targetProjectId, + ); + if (existing) { + const updateProject = this.db.prepare( + `UPDATE projects SET project_name = ?, repo_root = ?, repo_remote_primary = ?, active_version = ?, updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ); - updateProject.run( - projectName || existing.project_name, - normalizedRepoRoot ?? existing.repo_root, - normalizedRepoRemote ?? existing.repo_remote_primary, - input.active_version ?? existing.active_version, - now, - scopeUserId, - scopeAgentId, - targetProjectId, - ); - } else { - const insertProject = this.db.prepare( - `INSERT INTO projects ( + ); + updateProject.run( + projectName || existing.project_name, + normalizedRepoRoot ?? existing.repo_root, + normalizedRepoRemote ?? existing.repo_remote_primary, + input.active_version ?? existing.active_version, + now, + scopeUserId, + scopeAgentId, + targetProjectId, + ); + } else { + const insertProject = this.db.prepare( + `INSERT INTO projects ( project_id, scope_user_id, scope_agent_id, project_name, repo_root, repo_remote_primary, active_version, lifecycle_status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`, - ); - insertProject.run( - targetProjectId, - scopeUserId, - scopeAgentId, - projectName, - normalizedRepoRoot, - normalizedRepoRemote, - input.active_version || null, - now, - now, - ); - } - - this.upsertProjectAlias(scopeUserId, scopeAgentId, targetProjectId, projectAlias, true, now, input.allow_alias_update === true); - - const project = this.getProjectById(scopeUserId, scopeAgentId, targetProjectId); - if (!project) throw new Error("failed to persist project registry record"); - - const registration = this.upsertProjectRegistrationState(scopeUserId, scopeAgentId, { - project_id: targetProjectId, - registration_status: "registered", - validation_status: "ok", - validation_notes: null, - completeness_score: this.computeRegistrationCompleteness(project, projectAlias), - missing_required_fields: this.computeMissingRegistrationFields(project, projectAlias), - last_validated_at: now, - }); - - const alias = this.getProjectAlias(scopeUserId, scopeAgentId, projectAlias); - if (!alias) throw new Error("failed to persist project alias"); - - return { project, alias, registration }; - } - - private findProjectByRepoRoot(scopeUserId: string, scopeAgentId: string, repoRoot: string): ProjectRecord | null { - const normalizedRepoRoot = this.normalizeRepoRoot(repoRoot); - if (!normalizedRepoRoot) return null; - const stmt = this.db.prepare( - `SELECT * FROM projects WHERE scope_user_id = ? AND scope_agent_id = ? AND repo_root = ? LIMIT 1`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, normalizedRepoRoot) as ProjectRecord | undefined; - return row || null; - } - - getProjectById(scopeUserId: string, scopeAgentId: string, projectId: string): ProjectRecord | null { - const stmt = this.db.prepare( - `SELECT * FROM projects WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, projectId) as ProjectRecord | undefined; - return row || null; - } - - getProjectAlias(scopeUserId: string, scopeAgentId: string, projectAlias: string): ProjectAliasRecord | null { - const normalizedAlias = this.normalizeProjectAlias(projectAlias); - const stmt = this.db.prepare( - `SELECT * FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? AND project_alias = ?`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, normalizedAlias) as ProjectAliasRecord | undefined; - return row || null; - } - - getProjectByAlias(scopeUserId: string, scopeAgentId: string, projectAlias: string): { - project: ProjectRecord; - alias: ProjectAliasRecord; - } | null { - const normalizedAlias = this.normalizeProjectAlias(projectAlias); - const stmt = this.db.prepare( - `SELECT p.project_id, p.scope_user_id, p.scope_agent_id, p.project_name, p.repo_root, p.repo_remote_primary, + ); + insertProject.run( + targetProjectId, + scopeUserId, + scopeAgentId, + projectName, + normalizedRepoRoot, + normalizedRepoRemote, + input.active_version || null, + now, + now, + ); + } + + this.upsertProjectAlias( + scopeUserId, + scopeAgentId, + targetProjectId, + projectAlias, + true, + now, + input.allow_alias_update === true, + ); + + const project = this.getProjectById( + scopeUserId, + scopeAgentId, + targetProjectId, + ); + if (!project) throw new Error("failed to persist project registry record"); + + const registration = this.upsertProjectRegistrationState( + scopeUserId, + scopeAgentId, + { + project_id: targetProjectId, + registration_status: "registered", + validation_status: "ok", + validation_notes: null, + completeness_score: this.computeRegistrationCompleteness( + project, + projectAlias, + ), + missing_required_fields: this.computeMissingRegistrationFields( + project, + projectAlias, + ), + last_validated_at: now, + }, + ); + + const alias = this.getProjectAlias(scopeUserId, scopeAgentId, projectAlias); + if (!alias) throw new Error("failed to persist project alias"); + + return { project, alias, registration }; + } + + private findProjectByRepoRoot( + scopeUserId: string, + scopeAgentId: string, + repoRoot: string, + ): ProjectRecord | null { + const normalizedRepoRoot = this.normalizeRepoRoot(repoRoot); + if (!normalizedRepoRoot) return null; + const stmt = this.db.prepare( + `SELECT * FROM projects WHERE scope_user_id = ? AND scope_agent_id = ? AND repo_root = ? LIMIT 1`, + ); + const row = stmt.get(scopeUserId, scopeAgentId, normalizedRepoRoot) as + | ProjectRecord + | undefined; + return row || null; + } + + getProjectById( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + ): ProjectRecord | null { + const stmt = this.db.prepare( + `SELECT * FROM projects WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, + ); + const row = stmt.get(scopeUserId, scopeAgentId, projectId) as + | ProjectRecord + | undefined; + return row || null; + } + + getProjectAlias( + scopeUserId: string, + scopeAgentId: string, + projectAlias: string, + ): ProjectAliasRecord | null { + const normalizedAlias = this.normalizeProjectAlias(projectAlias); + const stmt = this.db.prepare( + `SELECT * FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? AND project_alias = ?`, + ); + const row = stmt.get(scopeUserId, scopeAgentId, normalizedAlias) as + | ProjectAliasRecord + | undefined; + return row || null; + } + + getProjectByAlias( + scopeUserId: string, + scopeAgentId: string, + projectAlias: string, + ): { + project: ProjectRecord; + alias: ProjectAliasRecord; + } | null { + const normalizedAlias = this.normalizeProjectAlias(projectAlias); + const stmt = this.db.prepare( + `SELECT p.project_id, p.scope_user_id, p.scope_agent_id, p.project_name, p.repo_root, p.repo_remote_primary, p.active_version, p.lifecycle_status, p.created_at, p.updated_at, a.id as alias_id, a.project_alias, a.is_primary, a.created_at as alias_created_at, a.updated_at as alias_updated_at FROM project_aliases a @@ -1243,2548 +1334,3411 @@ export class SlotDB { AND p.scope_user_id = a.scope_user_id AND p.scope_agent_id = a.scope_agent_id WHERE a.scope_user_id = ? AND a.scope_agent_id = ? AND a.project_alias = ?`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, normalizedAlias) as Record | undefined; - if (!row) return null; - - return { - project: { - project_id: String(row.project_id), - scope_user_id: String(row.scope_user_id), - scope_agent_id: String(row.scope_agent_id), - project_name: String(row.project_name), - repo_root: row.repo_root ? String(row.repo_root) : null, - repo_remote_primary: row.repo_remote_primary ? String(row.repo_remote_primary) : null, - active_version: row.active_version ? String(row.active_version) : null, - lifecycle_status: (String(row.lifecycle_status) as "active" | "archived" | "disabled" | "detached" | "deindexed" | "purged"), - created_at: String(row.created_at), - updated_at: String(row.updated_at), - }, - alias: { - id: String(row.alias_id), - project_id: String(row.project_id), - scope_user_id: String(row.scope_user_id), - scope_agent_id: String(row.scope_agent_id), - project_alias: String(row.project_alias), - is_primary: Number(row.is_primary), - created_at: String(row.alias_created_at), - updated_at: String(row.alias_updated_at), - }, - }; - } - - listProjects(scopeUserId: string, scopeAgentId: string): Array<{ - project: ProjectRecord; - aliases: ProjectAliasRecord[]; - registration: ProjectRegistrationStateRecord | null; - }> { - const projectsStmt = this.db.prepare( - `SELECT * FROM projects WHERE scope_user_id = ? AND scope_agent_id = ? ORDER BY updated_at DESC`, - ); - const projects = projectsStmt.all(scopeUserId, scopeAgentId) as unknown as ProjectRecord[]; - - const aliasesStmt = this.db.prepare( - `SELECT * FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? ORDER BY is_primary DESC, project_alias ASC`, - ); - const aliases = aliasesStmt.all(scopeUserId, scopeAgentId) as unknown as ProjectAliasRecord[]; - - const aliasesByProject = new Map(); - for (const alias of aliases) { - const list = aliasesByProject.get(alias.project_id) || []; - list.push(alias); - aliasesByProject.set(alias.project_id, list); - } - - return projects.map((project) => ({ - project, - aliases: aliasesByProject.get(project.project_id) || [], - registration: this.getProjectRegistrationState(scopeUserId, scopeAgentId, project.project_id), - })); - } - - setProjectTrackerMapping(scopeUserId: string, scopeAgentId: string, input: ProjectTrackerMappingInput): ProjectTrackerMappingRecord { - const now = new Date().toISOString(); - const existing = this.getProjectTrackerMapping(scopeUserId, scopeAgentId, input.project_id, input.tracker_type); - - if (existing) { - const stmt = this.db.prepare( - `UPDATE project_tracker_mappings + ); + const row = stmt.get(scopeUserId, scopeAgentId, normalizedAlias) as + | Record + | undefined; + if (!row) return null; + + return { + project: { + project_id: String(row.project_id), + scope_user_id: String(row.scope_user_id), + scope_agent_id: String(row.scope_agent_id), + project_name: String(row.project_name), + repo_root: row.repo_root ? String(row.repo_root) : null, + repo_remote_primary: row.repo_remote_primary + ? String(row.repo_remote_primary) + : null, + active_version: row.active_version ? String(row.active_version) : null, + lifecycle_status: String(row.lifecycle_status) as + | "active" + | "archived" + | "disabled" + | "detached" + | "deindexed" + | "purged", + created_at: String(row.created_at), + updated_at: String(row.updated_at), + }, + alias: { + id: String(row.alias_id), + project_id: String(row.project_id), + scope_user_id: String(row.scope_user_id), + scope_agent_id: String(row.scope_agent_id), + project_alias: String(row.project_alias), + is_primary: Number(row.is_primary), + created_at: String(row.alias_created_at), + updated_at: String(row.alias_updated_at), + }, + }; + } + + listProjects( + scopeUserId: string, + scopeAgentId: string, + ): Array<{ + project: ProjectRecord; + aliases: ProjectAliasRecord[]; + registration: ProjectRegistrationStateRecord | null; + }> { + const projectsStmt = this.db.prepare( + `SELECT * FROM projects WHERE scope_user_id = ? AND scope_agent_id = ? ORDER BY updated_at DESC`, + ); + const projects = projectsStmt.all( + scopeUserId, + scopeAgentId, + ) as unknown as ProjectRecord[]; + + const aliasesStmt = this.db.prepare( + `SELECT * FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? ORDER BY is_primary DESC, project_alias ASC`, + ); + const aliases = aliasesStmt.all( + scopeUserId, + scopeAgentId, + ) as unknown as ProjectAliasRecord[]; + + const aliasesByProject = new Map(); + for (const alias of aliases) { + const list = aliasesByProject.get(alias.project_id) || []; + list.push(alias); + aliasesByProject.set(alias.project_id, list); + } + + return projects.map((project) => ({ + project, + aliases: aliasesByProject.get(project.project_id) || [], + registration: this.getProjectRegistrationState( + scopeUserId, + scopeAgentId, + project.project_id, + ), + })); + } + + setProjectTrackerMapping( + scopeUserId: string, + scopeAgentId: string, + input: ProjectTrackerMappingInput, + ): ProjectTrackerMappingRecord { + const now = new Date().toISOString(); + const existing = this.getProjectTrackerMapping( + scopeUserId, + scopeAgentId, + input.project_id, + input.tracker_type, + ); + + if (existing) { + const stmt = this.db.prepare( + `UPDATE project_tracker_mappings SET tracker_space_key = ?, tracker_project_id = ?, default_epic_key = ?, board_key = ?, active_version = ?, external_project_url = ?, updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND tracker_type = ?`, - ); - stmt.run( - input.tracker_space_key || null, - input.tracker_project_id || null, - input.default_epic_key || null, - input.board_key || null, - input.active_version || null, - input.external_project_url || null, - now, - scopeUserId, - scopeAgentId, - input.project_id, - input.tracker_type, - ); - } else { - const stmt = this.db.prepare( - `INSERT INTO project_tracker_mappings ( + ); + stmt.run( + input.tracker_space_key || null, + input.tracker_project_id || null, + input.default_epic_key || null, + input.board_key || null, + input.active_version || null, + input.external_project_url || null, + now, + scopeUserId, + scopeAgentId, + input.project_id, + input.tracker_type, + ); + } else { + const stmt = this.db.prepare( + `INSERT INTO project_tracker_mappings ( id, project_id, scope_user_id, scope_agent_id, tracker_type, tracker_space_key, tracker_project_id, default_epic_key, board_key, active_version, external_project_url, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - stmt.run( - randomUUID(), - input.project_id, - scopeUserId, - scopeAgentId, - input.tracker_type, - input.tracker_space_key || null, - input.tracker_project_id || null, - input.default_epic_key || null, - input.board_key || null, - input.active_version || null, - input.external_project_url || null, - now, - now, - ); - } - - const mapping = this.getProjectTrackerMapping(scopeUserId, scopeAgentId, input.project_id, input.tracker_type); - if (!mapping) throw new Error("failed to persist project tracker mapping"); - return mapping; - } - - getProjectTrackerMapping( - scopeUserId: string, - scopeAgentId: string, - projectId: string, - trackerType: "jira" | "github" | "other", - ): ProjectTrackerMappingRecord | null { - const stmt = this.db.prepare( - `SELECT * FROM project_tracker_mappings WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND tracker_type = ?`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, projectId, trackerType) as ProjectTrackerMappingRecord | undefined; - return row || null; - } - - getProjectRegistrationState(scopeUserId: string, scopeAgentId: string, projectId: string): ProjectRegistrationStateRecord | null { - const stmt = this.db.prepare( - `SELECT * FROM project_registration_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, projectId) as { - project_id: string; - scope_user_id: string; - scope_agent_id: string; - registration_status: "draft" | "registered" | "validated" | "blocked"; - validation_status: "pending" | "ok" | "warn" | "error"; - validation_notes: string | null; - completeness_score: number; - missing_required_fields: string | null; - last_validated_at: string | null; - updated_at: string; - } | undefined; - if (!row) return null; - return { - project_id: row.project_id, - scope_user_id: row.scope_user_id, - scope_agent_id: row.scope_agent_id, - registration_status: row.registration_status, - validation_status: row.validation_status, - validation_notes: row.validation_notes, - completeness_score: row.completeness_score, - missing_required_fields: this.parseJsonArrayField(row.missing_required_fields), - last_validated_at: row.last_validated_at, - updated_at: row.updated_at, - }; - } - - updateProjectRegistrationState( - scopeUserId: string, - scopeAgentId: string, - input: { - project_id: string; - registration_status: "draft" | "registered" | "validated" | "blocked"; - validation_status: "pending" | "ok" | "warn" | "error"; - validation_notes?: string | null; - completeness_score: number; - missing_required_fields: string[]; - last_validated_at?: string | null; - }, - ): ProjectRegistrationStateRecord { - return this.upsertProjectRegistrationState(scopeUserId, scopeAgentId, { - ...input, - validation_notes: input.validation_notes ?? null, - last_validated_at: input.last_validated_at ?? new Date().toISOString(), - }); - } - - getProjectIndexWatchState( - scopeUserId: string, - scopeAgentId: string, - projectId: string, - ): ProjectIndexWatchState | null { - const stmt = this.db.prepare( - `SELECT * FROM project_index_watch_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, projectId) as { - project_id: string; - scope_user_id: string; - scope_agent_id: string; - last_source_rev: string | null; - last_checksum_snapshot: string | null; - updated_at: string; - } | undefined; - - if (!row) return null; - return { - project_id: row.project_id, - scope_user_id: row.scope_user_id, - scope_agent_id: row.scope_agent_id, - last_source_rev: row.last_source_rev, - last_checksum_snapshot: this.parseChecksumMap(row.last_checksum_snapshot), - updated_at: row.updated_at, - }; - } - - deindexProject( - scopeUserId: string, - scopeAgentId: string, - input: { project_id: string; reason?: string | null }, - ): ProjectDeindexResult { - const projectId = String(input.project_id || "").trim(); - if (!projectId) throw new Error("project_id is required"); - - const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); - if (!project) { - throw new Error(`project_id '${projectId}' is not registered`); - } - - const now = new Date().toISOString(); - const reason = input.reason == null ? null : String(input.reason).trim() || null; - - const fileCountStmt = this.db.prepare( - `SELECT COUNT(*) as cnt FROM file_index_state + ); + stmt.run( + randomUUID(), + input.project_id, + scopeUserId, + scopeAgentId, + input.tracker_type, + input.tracker_space_key || null, + input.tracker_project_id || null, + input.default_epic_key || null, + input.board_key || null, + input.active_version || null, + input.external_project_url || null, + now, + now, + ); + } + + const mapping = this.getProjectTrackerMapping( + scopeUserId, + scopeAgentId, + input.project_id, + input.tracker_type, + ); + if (!mapping) throw new Error("failed to persist project tracker mapping"); + return mapping; + } + + getProjectTrackerMapping( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + trackerType: "jira" | "github" | "other", + ): ProjectTrackerMappingRecord | null { + const stmt = this.db.prepare( + `SELECT * FROM project_tracker_mappings WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND tracker_type = ?`, + ); + const row = stmt.get(scopeUserId, scopeAgentId, projectId, trackerType) as + | ProjectTrackerMappingRecord + | undefined; + return row || null; + } + + getProjectRegistrationState( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + ): ProjectRegistrationStateRecord | null { + const stmt = this.db.prepare( + `SELECT * FROM project_registration_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, + ); + const row = stmt.get(scopeUserId, scopeAgentId, projectId) as + | { + project_id: string; + scope_user_id: string; + scope_agent_id: string; + registration_status: "draft" | "registered" | "validated" | "blocked"; + validation_status: "pending" | "ok" | "warn" | "error"; + validation_notes: string | null; + completeness_score: number; + missing_required_fields: string | null; + last_validated_at: string | null; + updated_at: string; + } + | undefined; + if (!row) return null; + return { + project_id: row.project_id, + scope_user_id: row.scope_user_id, + scope_agent_id: row.scope_agent_id, + registration_status: row.registration_status, + validation_status: row.validation_status, + validation_notes: row.validation_notes, + completeness_score: row.completeness_score, + missing_required_fields: this.parseJsonArrayField( + row.missing_required_fields, + ), + last_validated_at: row.last_validated_at, + updated_at: row.updated_at, + }; + } + + updateProjectRegistrationState( + scopeUserId: string, + scopeAgentId: string, + input: { + project_id: string; + registration_status: "draft" | "registered" | "validated" | "blocked"; + validation_status: "pending" | "ok" | "warn" | "error"; + validation_notes?: string | null; + completeness_score: number; + missing_required_fields: string[]; + last_validated_at?: string | null; + }, + ): ProjectRegistrationStateRecord { + return this.upsertProjectRegistrationState(scopeUserId, scopeAgentId, { + ...input, + validation_notes: input.validation_notes ?? null, + last_validated_at: input.last_validated_at ?? new Date().toISOString(), + }); + } + + getProjectIndexWatchState( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + ): ProjectIndexWatchState | null { + const stmt = this.db.prepare( + `SELECT * FROM project_index_watch_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, + ); + const row = stmt.get(scopeUserId, scopeAgentId, projectId) as + | { + project_id: string; + scope_user_id: string; + scope_agent_id: string; + last_source_rev: string | null; + last_checksum_snapshot: string | null; + updated_at: string; + } + | undefined; + + if (!row) return null; + return { + project_id: row.project_id, + scope_user_id: row.scope_user_id, + scope_agent_id: row.scope_agent_id, + last_source_rev: row.last_source_rev, + last_checksum_snapshot: this.parseChecksumMap(row.last_checksum_snapshot), + updated_at: row.updated_at, + }; + } + + deindexProject( + scopeUserId: string, + scopeAgentId: string, + input: { project_id: string; reason?: string | null }, + ): ProjectDeindexResult { + const projectId = String(input.project_id || "").trim(); + if (!projectId) throw new Error("project_id is required"); + + const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); + if (!project) { + throw new Error(`project_id '${projectId}' is not registered`); + } + + const now = new Date().toISOString(); + const reason = + input.reason == null ? null : String(input.reason).trim() || null; + + const fileCountStmt = this.db.prepare( + `SELECT COUNT(*) as cnt FROM file_index_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ); - const chunkCountStmt = this.db.prepare( - `SELECT COUNT(*) as cnt FROM chunk_registry + ); + const chunkCountStmt = this.db.prepare( + `SELECT COUNT(*) as cnt FROM chunk_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ); - const symbolCountStmt = this.db.prepare( - `SELECT COUNT(*) as cnt FROM symbol_registry + ); + const symbolCountStmt = this.db.prepare( + `SELECT COUNT(*) as cnt FROM symbol_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ); - - const files = Number((fileCountStmt.get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - const chunks = Number((chunkCountStmt.get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - const symbols = Number((symbolCountStmt.get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - - this.db.prepare( - `UPDATE file_index_state + ); + + const files = Number( + ( + fileCountStmt.get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + const chunks = Number( + ( + chunkCountStmt.get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + const symbols = Number( + ( + symbolCountStmt.get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + + this.db + .prepare( + `UPDATE file_index_state SET index_state = 'stale', active = 0, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ).run(now, now, scopeUserId, scopeAgentId, projectId); + ) + .run(now, now, scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `UPDATE chunk_registry + this.db + .prepare( + `UPDATE chunk_registry SET index_state = 'stale', active = 0, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ).run(now, now, scopeUserId, scopeAgentId, projectId); + ) + .run(now, now, scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `UPDATE symbol_registry + this.db + .prepare( + `UPDATE symbol_registry SET index_state = 'stale', active = 0, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ).run(now, now, scopeUserId, scopeAgentId, projectId); + ) + .run(now, now, scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `UPDATE projects + this.db + .prepare( + `UPDATE projects SET lifecycle_status = 'deindexed', updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(now, scopeUserId, scopeAgentId, projectId); - - this.insertIndexRun(scopeUserId, scopeAgentId, { - run_id: randomUUID(), - project_id: projectId, - index_profile: "default", - trigger_type: "manual", - state: "indexed", - started_at: now, - finished_at: now, - error_message: reason ? `deindex:${reason}` : "deindex", - }); - - return { - project_id: projectId, - lifecycle_status: "deindexed", - deindexed_at: now, - reason, - affected: { - files, - chunks, - symbols, - }, - searchable: false, - }; - } - - detachProject( - scopeUserId: string, - scopeAgentId: string, - input: { project_id: string; reason?: string | null }, - ): ProjectDetachResult { - const projectId = String(input.project_id || "").trim(); - if (!projectId) throw new Error("project_id is required"); - - const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); - if (!project) { - throw new Error(`project_id '${projectId}' is not registered`); - } - - const reason = input.reason == null ? null : String(input.reason).trim() || null; - if (project.lifecycle_status !== "deindexed") { - this.deindexProject(scopeUserId, scopeAgentId, { - project_id: projectId, - reason: reason || "detach_precondition_deindex", - }); - } - - const now = new Date().toISOString(); - const aliasesRemoved = Number((this.db.prepare( - `SELECT COUNT(*) as cnt FROM project_aliases + ) + .run(now, scopeUserId, scopeAgentId, projectId); + + this.insertIndexRun(scopeUserId, scopeAgentId, { + run_id: randomUUID(), + project_id: projectId, + index_profile: "default", + trigger_type: "manual", + state: "indexed", + started_at: now, + finished_at: now, + error_message: reason ? `deindex:${reason}` : "deindex", + }); + + return { + project_id: projectId, + lifecycle_status: "deindexed", + deindexed_at: now, + reason, + affected: { + files, + chunks, + symbols, + }, + searchable: false, + }; + } + + detachProject( + scopeUserId: string, + scopeAgentId: string, + input: { project_id: string; reason?: string | null }, + ): ProjectDetachResult { + const projectId = String(input.project_id || "").trim(); + if (!projectId) throw new Error("project_id is required"); + + const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); + if (!project) { + throw new Error(`project_id '${projectId}' is not registered`); + } + + const reason = + input.reason == null ? null : String(input.reason).trim() || null; + if (project.lifecycle_status !== "deindexed") { + this.deindexProject(scopeUserId, scopeAgentId, { + project_id: projectId, + reason: reason || "detach_precondition_deindex", + }); + } + + const now = new Date().toISOString(); + const aliasesRemoved = Number( + ( + this.db + .prepare( + `SELECT COUNT(*) as cnt FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - const trackerMappingsRemoved = Number((this.db.prepare( - `SELECT COUNT(*) as cnt FROM project_tracker_mappings + ) + .get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + const trackerMappingsRemoved = Number( + ( + this.db + .prepare( + `SELECT COUNT(*) as cnt FROM project_tracker_mappings WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - - this.db.prepare( - `DELETE FROM project_aliases + ) + .get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + + this.db + .prepare( + `DELETE FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM project_tracker_mappings + this.db + .prepare( + `DELETE FROM project_tracker_mappings WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `UPDATE projects + this.db + .prepare( + `UPDATE projects SET lifecycle_status = 'detached', repo_root = NULL, repo_remote_primary = NULL, active_version = NULL, updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(now, scopeUserId, scopeAgentId, projectId); - - return { - project_id: projectId, - lifecycle_status: "detached", - detached_at: now, - reason, - detached_fields: { - repo_root: project.repo_root != null, - repo_remote_primary: project.repo_remote_primary != null, - active_version: project.active_version != null, - aliases_removed: aliasesRemoved, - tracker_mappings_removed: trackerMappingsRemoved, - }, - searchable: false, - next_actions: { - reattach_via_register_or_update: true, - reversible_by_re_register: true, - }, - }; - } - - unregisterProject( - scopeUserId: string, - scopeAgentId: string, - input: { project_id: string; confirm?: boolean; mode?: "safe"; reason?: string | null }, - ): ProjectUnregisterResult { - const projectId = String(input.project_id || "").trim(); - if (!projectId) throw new Error("project_id is required"); - - const mode = input.mode || "safe"; - if (mode !== "safe") { - throw new Error("project.unregister currently supports mode='safe' only"); - } - if (input.confirm !== true) { - throw new Error("project.unregister requires explicit confirm=true"); - } - - const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); - if (!project) { - throw new Error(`project_id '${projectId}' is not registered`); - } - - const reason = input.reason == null ? null : String(input.reason).trim() || null; - let deindexedFirst = false; - if (project.lifecycle_status !== "deindexed") { - this.deindexProject(scopeUserId, scopeAgentId, { - project_id: projectId, - reason: reason || "unregister_precondition_deindex", - }); - deindexedFirst = true; - } - - const now = new Date().toISOString(); - const aliasesRemoved = Number((this.db.prepare( - `SELECT COUNT(*) as cnt FROM project_aliases + ) + .run(now, scopeUserId, scopeAgentId, projectId); + + return { + project_id: projectId, + lifecycle_status: "detached", + detached_at: now, + reason, + detached_fields: { + repo_root: project.repo_root != null, + repo_remote_primary: project.repo_remote_primary != null, + active_version: project.active_version != null, + aliases_removed: aliasesRemoved, + tracker_mappings_removed: trackerMappingsRemoved, + }, + searchable: false, + next_actions: { + reattach_via_register_or_update: true, + reversible_by_re_register: true, + }, + }; + } + + unregisterProject( + scopeUserId: string, + scopeAgentId: string, + input: { + project_id: string; + confirm?: boolean; + mode?: "safe"; + reason?: string | null; + }, + ): ProjectUnregisterResult { + const projectId = String(input.project_id || "").trim(); + if (!projectId) throw new Error("project_id is required"); + + const mode = input.mode || "safe"; + if (mode !== "safe") { + throw new Error("project.unregister currently supports mode='safe' only"); + } + if (input.confirm !== true) { + throw new Error("project.unregister requires explicit confirm=true"); + } + + const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); + if (!project) { + throw new Error(`project_id '${projectId}' is not registered`); + } + + const reason = + input.reason == null ? null : String(input.reason).trim() || null; + let deindexedFirst = false; + if (project.lifecycle_status !== "deindexed") { + this.deindexProject(scopeUserId, scopeAgentId, { + project_id: projectId, + reason: reason || "unregister_precondition_deindex", + }); + deindexedFirst = true; + } + + const now = new Date().toISOString(); + const aliasesRemoved = Number( + ( + this.db + .prepare( + `SELECT COUNT(*) as cnt FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - const trackerMappingsRemoved = Number((this.db.prepare( - `SELECT COUNT(*) as cnt FROM project_tracker_mappings + ) + .get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + const trackerMappingsRemoved = Number( + ( + this.db + .prepare( + `SELECT COUNT(*) as cnt FROM project_tracker_mappings WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - - this.db.prepare( - `DELETE FROM project_aliases + ) + .get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + + this.db + .prepare( + `DELETE FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM project_tracker_mappings + this.db + .prepare( + `DELETE FROM project_tracker_mappings WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `UPDATE projects + this.db + .prepare( + `UPDATE projects SET lifecycle_status = 'disabled', repo_root = NULL, repo_remote_primary = NULL, active_version = NULL, updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(now, scopeUserId, scopeAgentId, projectId); - - this.upsertProjectRegistrationState(scopeUserId, scopeAgentId, { - project_id: projectId, - registration_status: "draft", - validation_status: "warn", - validation_notes: reason ? `unregistered:${reason}` : "unregistered", - completeness_score: 0, - missing_required_fields: ["project_alias", "repo_root"], - last_validated_at: now, - }); - - return { - project_id: projectId, - lifecycle_status: "disabled", - unregistered_at: now, - mode, - reason, - detached_fields: { - aliases_removed: aliasesRemoved, - tracker_mappings_removed: trackerMappingsRemoved, - }, - registration_state: { - registration_status: "draft", - validation_status: "warn", - }, - searchable: false, - audit: { - deindexed_first: deindexedFirst, - confirm_required: true, - }, - }; - } - - purgePreviewProject( - scopeUserId: string, - scopeAgentId: string, - input: { project_id: string }, - ): ProjectPurgePreviewResult { - const projectId = String(input.project_id || "").trim(); - if (!projectId) throw new Error("project_id is required"); - - const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); - if (!project) { - throw new Error(`project_id '${projectId}' is not registered`); - } - - const countBy = (table: string) => - Number((this.db.prepare( - `SELECT COUNT(*) as cnt FROM ${table} + ) + .run(now, scopeUserId, scopeAgentId, projectId); + + this.upsertProjectRegistrationState(scopeUserId, scopeAgentId, { + project_id: projectId, + registration_status: "draft", + validation_status: "warn", + validation_notes: reason ? `unregistered:${reason}` : "unregistered", + completeness_score: 0, + missing_required_fields: ["project_alias", "repo_root"], + last_validated_at: now, + }); + + return { + project_id: projectId, + lifecycle_status: "disabled", + unregistered_at: now, + mode, + reason, + detached_fields: { + aliases_removed: aliasesRemoved, + tracker_mappings_removed: trackerMappingsRemoved, + }, + registration_state: { + registration_status: "draft", + validation_status: "warn", + }, + searchable: false, + audit: { + deindexed_first: deindexedFirst, + confirm_required: true, + }, + }; + } + + purgePreviewProject( + scopeUserId: string, + scopeAgentId: string, + input: { project_id: string }, + ): ProjectPurgePreviewResult { + const projectId = String(input.project_id || "").trim(); + if (!projectId) throw new Error("project_id is required"); + + const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); + if (!project) { + throw new Error(`project_id '${projectId}' is not registered`); + } + + const countBy = (table: string) => + Number( + ( + this.db + .prepare( + `SELECT COUNT(*) as cnt FROM ${table} WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - - const canPurge = project.lifecycle_status === "disabled"; - const reason = canPurge - ? "safe to purge: lifecycle_status is disabled and explicit confirm is still required" - : `purge blocked: lifecycle_status must be disabled (current=${project.lifecycle_status})`; - - return { - project_id: projectId, - current_lifecycle_status: project.lifecycle_status, - purge_guard: { - destructive: true, - allowed: canPurge, - reason, - requires_lifecycle_status: "disabled", - requires_confirm: true, - }, - affected: { - project_row: 1, - aliases: countBy("project_aliases"), - tracker_mappings: countBy("project_tracker_mappings"), - registration_state: countBy("project_registration_state"), - index_runs: countBy("index_runs"), - watch_state: countBy("project_index_watch_state"), - file_index_state: countBy("file_index_state"), - chunk_registry: countBy("chunk_registry"), - symbol_registry: countBy("symbol_registry"), - task_registry: countBy("task_registry"), - }, - previewed_at: new Date().toISOString(), - }; - } - - purgeProject( - scopeUserId: string, - scopeAgentId: string, - input: { project_id: string; confirm?: boolean; reason?: string | null }, - ): ProjectPurgeResult { - const projectId = String(input.project_id || "").trim(); - if (!projectId) throw new Error("project_id is required"); - if (input.confirm !== true) { - throw new Error("project.purge requires explicit confirm=true"); - } - - const preview = this.purgePreviewProject(scopeUserId, scopeAgentId, { project_id: projectId }); - if (!preview.purge_guard.allowed) { - throw new Error(preview.purge_guard.reason); - } - - const now = new Date().toISOString(); - const reason = input.reason == null ? null : String(input.reason).trim() || null; - - const deleted = { - project_row: 1 as const, - aliases: preview.affected.aliases, - tracker_mappings: preview.affected.tracker_mappings, - registration_state: preview.affected.registration_state, - index_runs: preview.affected.index_runs, - watch_state: preview.affected.watch_state, - file_index_state: preview.affected.file_index_state, - chunk_registry: preview.affected.chunk_registry, - symbol_registry: preview.affected.symbol_registry, - task_registry: preview.affected.task_registry, - }; - - this.db.exec("BEGIN"); - try { - this.db.prepare( - `DELETE FROM project_aliases + ) + .get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + + const canPurge = project.lifecycle_status === "disabled"; + const reason = canPurge + ? "safe to purge: lifecycle_status is disabled and explicit confirm is still required" + : `purge blocked: lifecycle_status must be disabled (current=${project.lifecycle_status})`; + + return { + project_id: projectId, + current_lifecycle_status: project.lifecycle_status, + purge_guard: { + destructive: true, + allowed: canPurge, + reason, + requires_lifecycle_status: "disabled", + requires_confirm: true, + }, + affected: { + project_row: 1, + aliases: countBy("project_aliases"), + tracker_mappings: countBy("project_tracker_mappings"), + registration_state: countBy("project_registration_state"), + index_runs: countBy("index_runs"), + watch_state: countBy("project_index_watch_state"), + file_index_state: countBy("file_index_state"), + chunk_registry: countBy("chunk_registry"), + symbol_registry: countBy("symbol_registry"), + task_registry: countBy("task_registry"), + }, + previewed_at: new Date().toISOString(), + }; + } + + purgeProject( + scopeUserId: string, + scopeAgentId: string, + input: { project_id: string; confirm?: boolean; reason?: string | null }, + ): ProjectPurgeResult { + const projectId = String(input.project_id || "").trim(); + if (!projectId) throw new Error("project_id is required"); + if (input.confirm !== true) { + throw new Error("project.purge requires explicit confirm=true"); + } + + const preview = this.purgePreviewProject(scopeUserId, scopeAgentId, { + project_id: projectId, + }); + if (!preview.purge_guard.allowed) { + throw new Error(preview.purge_guard.reason); + } + + const now = new Date().toISOString(); + const reason = + input.reason == null ? null : String(input.reason).trim() || null; + + const deleted = { + project_row: 1 as const, + aliases: preview.affected.aliases, + tracker_mappings: preview.affected.tracker_mappings, + registration_state: preview.affected.registration_state, + index_runs: preview.affected.index_runs, + watch_state: preview.affected.watch_state, + file_index_state: preview.affected.file_index_state, + chunk_registry: preview.affected.chunk_registry, + symbol_registry: preview.affected.symbol_registry, + task_registry: preview.affected.task_registry, + }; + + this.db.exec("BEGIN"); + try { + this.db + .prepare( + `DELETE FROM project_aliases WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM project_tracker_mappings + this.db + .prepare( + `DELETE FROM project_tracker_mappings WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM project_registration_state + this.db + .prepare( + `DELETE FROM project_registration_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM index_runs + this.db + .prepare( + `DELETE FROM index_runs WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM project_index_watch_state + this.db + .prepare( + `DELETE FROM project_index_watch_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM file_index_state + this.db + .prepare( + `DELETE FROM file_index_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM chunk_registry + this.db + .prepare( + `DELETE FROM chunk_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM symbol_registry + this.db + .prepare( + `DELETE FROM symbol_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM task_registry + this.db + .prepare( + `DELETE FROM task_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); + ) + .run(scopeUserId, scopeAgentId, projectId); - this.db.prepare( - `DELETE FROM projects + this.db + .prepare( + `DELETE FROM projects WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ).run(scopeUserId, scopeAgentId, projectId); - - this.db.exec("COMMIT"); - } catch (error) { - this.db.exec("ROLLBACK"); - throw error; - } - - return { - project_id: projectId, - lifecycle_status: "purged", - purged_at: now, - reason, - deleted, - searchable: false, - recoverable: false, - audit: { - confirm_required: true, - allowed_from_lifecycle_status: "disabled", - }, - }; - } - - reindexProjectByDiff( - scopeUserId: string, - scopeAgentId: string, - input: ProjectReindexDiffInput, - ): ProjectReindexDiffResult { - const now = new Date().toISOString(); - const runId = randomUUID(); - const triggerType = input.trigger_type || "incremental"; - const indexProfile = (input.index_profile || "default").trim() || "default"; - const sourceRev = input.source_rev?.trim() || null; - - if (!input.project_id || !String(input.project_id).trim()) { - throw new Error("project_id is required"); - } - - const project = this.getProjectById(scopeUserId, scopeAgentId, input.project_id); - if (!project) { - throw new Error(`project_id '${input.project_id}' is not registered`); - } - - const watch = this.getProjectIndexWatchState(scopeUserId, scopeAgentId, input.project_id); - const previousSnapshot = watch?.last_checksum_snapshot || {}; - - const currentSnapshot = new Map(); - for (const item of input.paths || []) { - const relativePath = this.normalizeRelativePath(item.relative_path); - if (!relativePath) continue; - const checksum = (item.checksum || "").trim() || "__missing__"; - currentSnapshot.set(relativePath, checksum); - } - - const changed: string[] = []; - const unchanged: string[] = []; - - for (const [relativePath, checksum] of currentSnapshot.entries()) { - const prev = previousSnapshot[relativePath]; - if (!prev || prev !== checksum) changed.push(relativePath); - else unchanged.push(relativePath); - } - - const deleted: string[] = []; - const treatAsFullSnapshot = input.full_snapshot === true || triggerType === "bootstrap"; - if (treatAsFullSnapshot) { - for (const prevPath of Object.keys(previousSnapshot)) { - if (!currentSnapshot.has(prevPath)) deleted.push(prevPath); - } - } - - this.insertIndexRun(scopeUserId, scopeAgentId, { - run_id: runId, - project_id: input.project_id, - index_profile: indexProfile, - trigger_type: triggerType, - state: "indexing", - started_at: now, - finished_at: null, - error_message: null, - }); - - try { - const nowIso = new Date().toISOString(); - for (const relativePath of changed) { - const item = (input.paths || []).find((p) => this.normalizeRelativePath(p.relative_path) === relativePath); - const fileId = this.makeScopedId(input.project_id, relativePath); - const language = item?.language || null; - this.upsertFileIndexState(scopeUserId, scopeAgentId, { - file_id: fileId, - project_id: input.project_id, - relative_path: relativePath, - module: item?.module || null, - language, - checksum: currentSnapshot.get(relativePath) || "__missing__", - last_commit_sha: sourceRev, - index_state: "indexed", - active: 1, - tombstone_at: null, - indexed_at: nowIso, - }); - - this.markProjectChunksByFileDeleted(scopeUserId, scopeAgentId, input.project_id, relativePath, nowIso); - this.markProjectSymbolsByFileDeleted(scopeUserId, scopeAgentId, input.project_id, relativePath, nowIso); - - const content = String(item?.content || ""); - if (content.trim()) { - const blocks = extractSemanticBlocks({ relativePath, content }); - const chunks = buildChunkArtifacts(input.project_id, fileId, relativePath, blocks); - for (const chunk of chunks) { - this.upsertChunkRegistry(scopeUserId, scopeAgentId, { - chunk_id: chunk.chunk_id, - project_id: input.project_id, - file_id: chunk.file_id, - relative_path: chunk.relative_path, - chunk_kind: chunk.chunk_kind, - symbol_id: chunk.symbol_id, - task_id: null, - checksum: chunk.checksum, - qdrant_point_id: null, - index_state: "indexed", - active: 1, - tombstone_at: null, - indexed_at: nowIso, - }); - } - - for (const block of blocks) { - if (!block.symbol_name || !["function", "class", "method", "tool"].includes(block.kind)) continue; - const symbolFqn = block.semantic_path || `${block.kind}:${block.symbol_name}`; - this.upsertSymbolRegistry(scopeUserId, scopeAgentId, { - symbol_id: buildSymbolId(input.project_id, relativePath, symbolFqn), - project_id: input.project_id, - relative_path: relativePath, - module: item?.module || null, - language: language || "text", - symbol_name: block.symbol_name, - symbol_fqn: symbolFqn, - symbol_kind: block.kind, - signature_hash: null, - index_state: "indexed", - active: 1, - tombstone_at: null, - indexed_at: nowIso, - }); - } - - populateUniversalCodeGraphForFile(this.graph, scopeUserId, scopeAgentId, { - projectId: input.project_id, - relativePath, - module: item?.module || null, - language: language || "text", - content, - blocks, - }); - } - } - - for (const relativePath of deleted) { - this.markFileIndexStateDeleted(scopeUserId, scopeAgentId, input.project_id, relativePath, nowIso); - this.markProjectChunksByFileDeleted(scopeUserId, scopeAgentId, input.project_id, relativePath, nowIso); - this.markProjectSymbolsByFileDeleted(scopeUserId, scopeAgentId, input.project_id, relativePath, nowIso); - } - - const checksumSnapshotRecord = Object.fromEntries(currentSnapshot.entries()); - this.upsertProjectIndexWatchState(scopeUserId, scopeAgentId, { - project_id: input.project_id, - last_source_rev: sourceRev, - last_checksum_snapshot: checksumSnapshotRecord, - updated_at: nowIso, - }); - - this.db.prepare( - `UPDATE projects + ) + .run(scopeUserId, scopeAgentId, projectId); + + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + + return { + project_id: projectId, + lifecycle_status: "purged", + purged_at: now, + reason, + deleted, + searchable: false, + recoverable: false, + audit: { + confirm_required: true, + allowed_from_lifecycle_status: "disabled", + }, + }; + } + + reindexProjectByDiff( + scopeUserId: string, + scopeAgentId: string, + input: ProjectReindexDiffInput, + ): ProjectReindexDiffResult { + const now = new Date().toISOString(); + const runId = randomUUID(); + const triggerType = input.trigger_type || "incremental"; + const indexProfile = (input.index_profile || "default").trim() || "default"; + const sourceRev = input.source_rev?.trim() || null; + + if (!input.project_id || !String(input.project_id).trim()) { + throw new Error("project_id is required"); + } + + const project = this.getProjectById( + scopeUserId, + scopeAgentId, + input.project_id, + ); + if (!project) { + throw new Error(`project_id '${input.project_id}' is not registered`); + } + + const watch = this.getProjectIndexWatchState( + scopeUserId, + scopeAgentId, + input.project_id, + ); + const previousSnapshot = watch?.last_checksum_snapshot || {}; + + const currentSnapshot = new Map(); + for (const item of input.paths || []) { + const relativePath = this.normalizeRelativePath(item.relative_path); + if (!relativePath) continue; + const checksum = (item.checksum || "").trim() || "__missing__"; + currentSnapshot.set(relativePath, checksum); + } + + const changed: string[] = []; + const unchanged: string[] = []; + + for (const [relativePath, checksum] of currentSnapshot.entries()) { + const prev = previousSnapshot[relativePath]; + if (!prev || prev !== checksum) changed.push(relativePath); + else unchanged.push(relativePath); + } + + const deleted: string[] = []; + const treatAsFullSnapshot = + input.full_snapshot === true || triggerType === "bootstrap"; + if (treatAsFullSnapshot) { + for (const prevPath of Object.keys(previousSnapshot)) { + if (!currentSnapshot.has(prevPath)) deleted.push(prevPath); + } + } + + this.insertIndexRun(scopeUserId, scopeAgentId, { + run_id: runId, + project_id: input.project_id, + index_profile: indexProfile, + trigger_type: triggerType, + state: "indexing", + started_at: now, + finished_at: null, + error_message: null, + }); + + try { + const nowIso = new Date().toISOString(); + for (const relativePath of changed) { + const item = (input.paths || []).find( + (p) => this.normalizeRelativePath(p.relative_path) === relativePath, + ); + const fileId = this.makeScopedId(input.project_id, relativePath); + const language = item?.language || null; + this.upsertFileIndexState(scopeUserId, scopeAgentId, { + file_id: fileId, + project_id: input.project_id, + relative_path: relativePath, + module: item?.module || null, + language, + checksum: currentSnapshot.get(relativePath) || "__missing__", + last_commit_sha: sourceRev, + index_state: "indexed", + active: 1, + tombstone_at: null, + indexed_at: nowIso, + }); + + this.markProjectChunksByFileDeleted( + scopeUserId, + scopeAgentId, + input.project_id, + relativePath, + nowIso, + ); + this.markProjectSymbolsByFileDeleted( + scopeUserId, + scopeAgentId, + input.project_id, + relativePath, + nowIso, + ); + + const content = String(item?.content || ""); + if (content.trim()) { + const blocks = extractSemanticBlocks({ relativePath, content }); + const chunks = buildChunkArtifacts( + input.project_id, + fileId, + relativePath, + blocks, + ); + for (const chunk of chunks) { + this.upsertChunkRegistry(scopeUserId, scopeAgentId, { + chunk_id: chunk.chunk_id, + project_id: input.project_id, + file_id: chunk.file_id, + relative_path: chunk.relative_path, + chunk_kind: chunk.chunk_kind, + symbol_id: chunk.symbol_id, + task_id: null, + checksum: chunk.checksum, + qdrant_point_id: null, + index_state: "indexed", + active: 1, + tombstone_at: null, + indexed_at: nowIso, + }); + } + + for (const block of blocks) { + if ( + !block.symbol_name || + !["function", "class", "method", "tool"].includes(block.kind) + ) + continue; + const symbolFqn = + block.semantic_path || `${block.kind}:${block.symbol_name}`; + this.upsertSymbolRegistry(scopeUserId, scopeAgentId, { + symbol_id: buildSymbolId( + input.project_id, + relativePath, + symbolFqn, + ), + project_id: input.project_id, + relative_path: relativePath, + module: item?.module || null, + language: language || "text", + symbol_name: block.symbol_name, + symbol_fqn: symbolFqn, + symbol_kind: block.kind, + signature_hash: null, + index_state: "indexed", + active: 1, + tombstone_at: null, + indexed_at: nowIso, + }); + } + + populateUniversalCodeGraphForFile( + this.graph, + scopeUserId, + scopeAgentId, + { + projectId: input.project_id, + relativePath, + module: item?.module || null, + language: language || "text", + content, + blocks, + }, + ); + } + } + + for (const relativePath of deleted) { + this.markFileIndexStateDeleted( + scopeUserId, + scopeAgentId, + input.project_id, + relativePath, + nowIso, + ); + this.markProjectChunksByFileDeleted( + scopeUserId, + scopeAgentId, + input.project_id, + relativePath, + nowIso, + ); + this.markProjectSymbolsByFileDeleted( + scopeUserId, + scopeAgentId, + input.project_id, + relativePath, + nowIso, + ); + } + + const checksumSnapshotRecord = Object.fromEntries( + currentSnapshot.entries(), + ); + this.upsertProjectIndexWatchState(scopeUserId, scopeAgentId, { + project_id: input.project_id, + last_source_rev: sourceRev, + last_checksum_snapshot: checksumSnapshotRecord, + updated_at: nowIso, + }); + + this.db + .prepare( + `UPDATE projects SET lifecycle_status = 'active', updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND lifecycle_status = 'deindexed'`, - ).run(nowIso, scopeUserId, scopeAgentId, input.project_id); - - this.finishIndexRun(scopeUserId, scopeAgentId, runId, "indexed", null, nowIso); - - this.db.prepare( - `UPDATE projects + ) + .run(nowIso, scopeUserId, scopeAgentId, input.project_id); + + this.finishIndexRun( + scopeUserId, + scopeAgentId, + runId, + "indexed", + null, + nowIso, + ); + + this.db + .prepare( + `UPDATE projects SET lifecycle_status = 'active', updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND lifecycle_status = 'deindexed'`, - ).run(nowIso, scopeUserId, scopeAgentId, input.project_id); - - return { - run_id: runId, - project_id: input.project_id, - trigger_type: triggerType, - index_profile: indexProfile, - source_rev: sourceRev, - changed, - unchanged, - deleted, - run_state: "indexed", - watch_state: { - last_source_rev: sourceRev, - updated_at: nowIso, - }, - }; - } catch (error) { - const err = error instanceof Error ? error.message : String(error); - this.finishIndexRun(scopeUserId, scopeAgentId, runId, "error", err, new Date().toISOString()); - throw error; - } - } - - upsertTaskRegistryRecord( - scopeUserId: string, - scopeAgentId: string, - input: ProjectTaskRegistryUpsertInput, - ): TaskRegistryRecord { - const now = new Date().toISOString(); - const taskId = String(input.task_id || "").trim(); - const projectId = String(input.project_id || "").trim(); - const taskTitle = String(input.task_title || "").trim(); - - if (!taskId) throw new Error("task_id is required"); - if (!projectId) throw new Error("project_id is required"); - if (!taskTitle) throw new Error("task_title is required"); - - const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); - if (!project) { - throw new Error(`project_id '${projectId}' is not registered`); - } - - const existing = this.getTaskRegistryRecordById(scopeUserId, scopeAgentId, taskId); - - const relatedTaskIds = this.normalizeStringArray(input.related_task_ids); - const filesTouched = this.normalizeStringArray(input.files_touched).map((p) => this.normalizeRelativePath(p)).filter(Boolean); - const symbolsTouched = this.normalizeStringArray(input.symbols_touched); - const commitRefs = this.normalizeStringArray(input.commit_refs); - const diffRefs = this.normalizeStringArray(input.diff_refs); - - if (existing) { - const stmt = this.db.prepare( - `UPDATE task_registry + ) + .run(nowIso, scopeUserId, scopeAgentId, input.project_id); + + return { + run_id: runId, + project_id: input.project_id, + trigger_type: triggerType, + index_profile: indexProfile, + source_rev: sourceRev, + changed, + unchanged, + deleted, + run_state: "indexed", + watch_state: { + last_source_rev: sourceRev, + updated_at: nowIso, + }, + }; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + this.finishIndexRun( + scopeUserId, + scopeAgentId, + runId, + "error", + err, + new Date().toISOString(), + ); + throw error; + } + } + + upsertTaskRegistryRecord( + scopeUserId: string, + scopeAgentId: string, + input: ProjectTaskRegistryUpsertInput, + ): TaskRegistryRecord { + const now = new Date().toISOString(); + const taskId = String(input.task_id || "").trim(); + const projectId = String(input.project_id || "").trim(); + const taskTitle = String(input.task_title || "").trim(); + + if (!taskId) throw new Error("task_id is required"); + if (!projectId) throw new Error("project_id is required"); + if (!taskTitle) throw new Error("task_title is required"); + + const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); + if (!project) { + throw new Error(`project_id '${projectId}' is not registered`); + } + + const existing = this.getTaskRegistryRecordById( + scopeUserId, + scopeAgentId, + taskId, + ); + + const relatedTaskIds = this.normalizeStringArray(input.related_task_ids); + const filesTouched = this.normalizeStringArray(input.files_touched) + .map((p) => this.normalizeRelativePath(p)) + .filter(Boolean); + const symbolsTouched = this.normalizeStringArray(input.symbols_touched); + const commitRefs = this.normalizeStringArray(input.commit_refs); + const diffRefs = this.normalizeStringArray(input.diff_refs); + + if (existing) { + const stmt = this.db.prepare( + `UPDATE task_registry SET project_id = ?, task_title = ?, task_type = ?, task_status = ?, parent_task_id = ?, related_task_ids = ?, files_touched = ?, symbols_touched = ?, commit_refs = ?, diff_refs = ?, decision_notes = ?, tracker_issue_key = ?, updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND task_id = ?`, - ); - stmt.run( - projectId, - taskTitle, - input.task_type ?? null, - input.task_status ?? null, - input.parent_task_id ?? null, - JSON.stringify(relatedTaskIds), - JSON.stringify(filesTouched), - JSON.stringify(symbolsTouched), - JSON.stringify(commitRefs), - JSON.stringify(diffRefs), - input.decision_notes ?? null, - input.tracker_issue_key ?? null, - now, - scopeUserId, - scopeAgentId, - taskId, - ); - } else { - const stmt = this.db.prepare( - `INSERT INTO task_registry ( + ); + stmt.run( + projectId, + taskTitle, + input.task_type ?? null, + input.task_status ?? null, + input.parent_task_id ?? null, + JSON.stringify(relatedTaskIds), + JSON.stringify(filesTouched), + JSON.stringify(symbolsTouched), + JSON.stringify(commitRefs), + JSON.stringify(diffRefs), + input.decision_notes ?? null, + input.tracker_issue_key ?? null, + now, + scopeUserId, + scopeAgentId, + taskId, + ); + } else { + const stmt = this.db.prepare( + `INSERT INTO task_registry ( task_id, scope_user_id, scope_agent_id, project_id, task_title, task_type, task_status, parent_task_id, related_task_ids, files_touched, symbols_touched, commit_refs, diff_refs, decision_notes, tracker_issue_key, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - stmt.run( - taskId, - scopeUserId, - scopeAgentId, - projectId, - taskTitle, - input.task_type ?? null, - input.task_status ?? null, - input.parent_task_id ?? null, - JSON.stringify(relatedTaskIds), - JSON.stringify(filesTouched), - JSON.stringify(symbolsTouched), - JSON.stringify(commitRefs), - JSON.stringify(diffRefs), - input.decision_notes ?? null, - input.tracker_issue_key ?? null, - now, - ); - } - - const row = this.getTaskRegistryRecordById(scopeUserId, scopeAgentId, taskId); - if (!row) throw new Error("failed to persist task registry record"); - return row; - } - - getTaskRegistryRecordById( - scopeUserId: string, - scopeAgentId: string, - taskId: string, - ): TaskRegistryRecord | null { - const normalizedTaskId = String(taskId || "").trim(); - if (!normalizedTaskId) return null; - - const stmt = this.db.prepare( - `SELECT * FROM task_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND task_id = ?`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, normalizedTaskId) as { - task_id: string; - scope_user_id: string; - scope_agent_id: string; - project_id: string; - task_title: string; - task_type: string | null; - task_status: string | null; - parent_task_id: string | null; - related_task_ids: string | null; - files_touched: string | null; - symbols_touched: string | null; - commit_refs: string | null; - diff_refs: string | null; - decision_notes: string | null; - tracker_issue_key: string | null; - updated_at: string; - } | undefined; - - if (!row) return null; - return this.rowToTaskRecord(row); - } - - getTaskRegistryRecordByTrackerIssueKey( - scopeUserId: string, - scopeAgentId: string, - projectId: string, - trackerIssueKey: string, - ): TaskRegistryRecord | null { - const tracker = String(trackerIssueKey || "").trim(); - if (!tracker) return null; - - const stmt = this.db.prepare( - `SELECT * FROM task_registry + ); + stmt.run( + taskId, + scopeUserId, + scopeAgentId, + projectId, + taskTitle, + input.task_type ?? null, + input.task_status ?? null, + input.parent_task_id ?? null, + JSON.stringify(relatedTaskIds), + JSON.stringify(filesTouched), + JSON.stringify(symbolsTouched), + JSON.stringify(commitRefs), + JSON.stringify(diffRefs), + input.decision_notes ?? null, + input.tracker_issue_key ?? null, + now, + ); + } + + const row = this.getTaskRegistryRecordById( + scopeUserId, + scopeAgentId, + taskId, + ); + if (!row) throw new Error("failed to persist task registry record"); + return row; + } + + getTaskRegistryRecordById( + scopeUserId: string, + scopeAgentId: string, + taskId: string, + ): TaskRegistryRecord | null { + const normalizedTaskId = String(taskId || "").trim(); + if (!normalizedTaskId) return null; + + const stmt = this.db.prepare( + `SELECT * FROM task_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND task_id = ?`, + ); + const row = stmt.get(scopeUserId, scopeAgentId, normalizedTaskId) as + | { + task_id: string; + scope_user_id: string; + scope_agent_id: string; + project_id: string; + task_title: string; + task_type: string | null; + task_status: string | null; + parent_task_id: string | null; + related_task_ids: string | null; + files_touched: string | null; + symbols_touched: string | null; + commit_refs: string | null; + diff_refs: string | null; + decision_notes: string | null; + tracker_issue_key: string | null; + updated_at: string; + } + | undefined; + + if (!row) return null; + return this.rowToTaskRecord(row); + } + + getTaskRegistryRecordByTrackerIssueKey( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + trackerIssueKey: string, + ): TaskRegistryRecord | null { + const tracker = String(trackerIssueKey || "").trim(); + if (!tracker) return null; + + const stmt = this.db.prepare( + `SELECT * FROM task_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND tracker_issue_key = ? ORDER BY updated_at DESC LIMIT 1`, - ); - const row = stmt.get(scopeUserId, scopeAgentId, projectId, tracker) as any; - if (!row) return null; - return this.rowToTaskRecord(row); - } - - getTaskLineageContext( - scopeUserId: string, - scopeAgentId: string, - input: ProjectTaskLineageContextInput, - ): ProjectTaskLineageContextResult { - const projectId = String(input.project_id || "").trim(); - if (!projectId) throw new Error("project_id is required"); - - const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); - if (!project) { - throw new Error(`project_id '${projectId}' is not registered`); - } - - let focus: TaskRegistryRecord | null = null; - - if (input.task_id) { - const byId = this.getTaskRegistryRecordById(scopeUserId, scopeAgentId, input.task_id); - if (byId && byId.project_id === projectId) focus = byId; - } - - if (!focus && input.tracker_issue_key) { - focus = this.getTaskRegistryRecordByTrackerIssueKey(scopeUserId, scopeAgentId, projectId, input.tracker_issue_key); - } - - if (!focus && input.task_title) { - const stmt = this.db.prepare( - `SELECT * FROM task_registry + ); + const row = stmt.get(scopeUserId, scopeAgentId, projectId, tracker) as any; + if (!row) return null; + return this.rowToTaskRecord(row); + } + + getTaskLineageContext( + scopeUserId: string, + scopeAgentId: string, + input: ProjectTaskLineageContextInput, + ): ProjectTaskLineageContextResult { + const projectId = String(input.project_id || "").trim(); + if (!projectId) throw new Error("project_id is required"); + + const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); + if (!project) { + throw new Error(`project_id '${projectId}' is not registered`); + } + + let focus: TaskRegistryRecord | null = null; + + if (input.task_id) { + const byId = this.getTaskRegistryRecordById( + scopeUserId, + scopeAgentId, + input.task_id, + ); + if (byId && byId.project_id === projectId) focus = byId; + } + + if (!focus && input.tracker_issue_key) { + focus = this.getTaskRegistryRecordByTrackerIssueKey( + scopeUserId, + scopeAgentId, + projectId, + input.tracker_issue_key, + ); + } + + if (!focus && input.task_title) { + const stmt = this.db.prepare( + `SELECT * FROM task_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND lower(task_title) LIKE ? ORDER BY updated_at DESC LIMIT 1`, - ); - const row = stmt.get( - scopeUserId, - scopeAgentId, - projectId, - `%${String(input.task_title).trim().toLowerCase()}%`, - ) as any; - if (row) focus = this.rowToTaskRecord(row); - } - - if (!focus) { - throw new Error("task lineage focus not found for provided selector"); - } - - const includeParentChain = input.include_parent_chain !== false; - const includeRelated = input.include_related !== false; - - const parentChain: TaskRegistryRecord[] = []; - if (includeParentChain) { - let cursor = focus.parent_task_id; - const guard = new Set(); - while (cursor && !guard.has(cursor)) { - guard.add(cursor); - const parent = this.getTaskRegistryRecordById(scopeUserId, scopeAgentId, cursor); - if (!parent) break; - parentChain.push(parent); - cursor = parent.parent_task_id; - } - } - - const relatedTasks: TaskRegistryRecord[] = []; - if (includeRelated) { - const seen = new Set(); - for (const relatedId of focus.related_task_ids || []) { - if (!relatedId || seen.has(relatedId)) continue; - seen.add(relatedId); - const related = this.getTaskRegistryRecordById(scopeUserId, scopeAgentId, relatedId); - if (related) relatedTasks.push(related); - } - } - - const aggregate = [focus, ...parentChain, ...relatedTasks]; - const touchedFiles = this.uniqueSorted(aggregate.flatMap((t) => t.files_touched || [])); - const touchedSymbols = this.uniqueSorted(aggregate.flatMap((t) => t.symbols_touched || [])); - const commitRefs = this.uniqueSorted(aggregate.flatMap((t) => t.commit_refs || [])); - const decisionNotes = this.uniqueSorted( - aggregate - .map((t) => String(t.decision_notes || "").trim()) - .filter(Boolean), - ); - - return { - focus: { - project_id: projectId, - task_id: focus.task_id, - tracker_issue_key: focus.tracker_issue_key, - task_title: focus.task_title, - }, - parent_chain: parentChain, - related_tasks: relatedTasks, - touched_files: touchedFiles, - touched_symbols: touchedSymbols, - commit_refs: commitRefs, - decision_notes: decisionNotes, - }; - } - - hybridSearchProjectContext( - scopeUserId: string, - scopeAgentId: string, - input: ProjectHybridSearchInput, - ): ProjectHybridSearchResult { - const projectId = String(input.project_id || "").trim(); - const query = String(input.query || "").trim(); - if (!projectId) throw new Error("project_id is required"); - if (!query) throw new Error("query is required"); - - const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); - if (!project) { - throw new Error(`project_id '${projectId}' is not registered`); - } - - const isSearchDisabled = ["deindexed", "detached", "disabled", "purged"].includes(project.lifecycle_status); - if (isSearchDisabled) { - const files = Number((this.db.prepare( - `SELECT COUNT(*) as cnt FROM file_index_state + ); + const row = stmt.get( + scopeUserId, + scopeAgentId, + projectId, + `%${String(input.task_title).trim().toLowerCase()}%`, + ) as any; + if (row) focus = this.rowToTaskRecord(row); + } + + if (!focus) { + throw new Error("task lineage focus not found for provided selector"); + } + + const includeParentChain = input.include_parent_chain !== false; + const includeRelated = input.include_related !== false; + + const parentChain: TaskRegistryRecord[] = []; + if (includeParentChain) { + let cursor = focus.parent_task_id; + const guard = new Set(); + while (cursor && !guard.has(cursor)) { + guard.add(cursor); + const parent = this.getTaskRegistryRecordById( + scopeUserId, + scopeAgentId, + cursor, + ); + if (!parent) break; + parentChain.push(parent); + cursor = parent.parent_task_id; + } + } + + const relatedTasks: TaskRegistryRecord[] = []; + if (includeRelated) { + const seen = new Set(); + for (const relatedId of focus.related_task_ids || []) { + if (!relatedId || seen.has(relatedId)) continue; + seen.add(relatedId); + const related = this.getTaskRegistryRecordById( + scopeUserId, + scopeAgentId, + relatedId, + ); + if (related) relatedTasks.push(related); + } + } + + const aggregate = [focus, ...parentChain, ...relatedTasks]; + const touchedFiles = this.uniqueSorted( + aggregate.flatMap((t) => t.files_touched || []), + ); + const touchedSymbols = this.uniqueSorted( + aggregate.flatMap((t) => t.symbols_touched || []), + ); + const commitRefs = this.uniqueSorted( + aggregate.flatMap((t) => t.commit_refs || []), + ); + const decisionNotes = this.uniqueSorted( + aggregate + .map((t) => String(t.decision_notes || "").trim()) + .filter(Boolean), + ); + + return { + focus: { + project_id: projectId, + task_id: focus.task_id, + tracker_issue_key: focus.tracker_issue_key, + task_title: focus.task_title, + }, + parent_chain: parentChain, + related_tasks: relatedTasks, + touched_files: touchedFiles, + touched_symbols: touchedSymbols, + commit_refs: commitRefs, + decision_notes: decisionNotes, + }; + } + + hybridSearchProjectContext( + scopeUserId: string, + scopeAgentId: string, + input: ProjectHybridSearchInput, + ): ProjectHybridSearchResult { + const projectId = String(input.project_id || "").trim(); + const query = String(input.query || "").trim(); + if (!projectId) throw new Error("project_id is required"); + if (!query) throw new Error("query is required"); + + const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); + if (!project) { + throw new Error(`project_id '${projectId}' is not registered`); + } + + const isSearchDisabled = [ + "deindexed", + "detached", + "disabled", + "purged", + ].includes(project.lifecycle_status); + if (isSearchDisabled) { + const files = Number( + ( + this.db + .prepare( + `SELECT COUNT(*) as cnt FROM file_index_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND tombstone_at IS NOT NULL`, - ).get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - const chunks = Number((this.db.prepare( - `SELECT COUNT(*) as cnt FROM chunk_registry + ) + .get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + const chunks = Number( + ( + this.db + .prepare( + `SELECT COUNT(*) as cnt FROM chunk_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND tombstone_at IS NOT NULL`, - ).get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - const symbols = Number((this.db.prepare( - `SELECT COUNT(*) as cnt FROM symbol_registry + ) + .get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + const symbols = Number( + ( + this.db + .prepare( + `SELECT COUNT(*) as cnt FROM symbol_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND tombstone_at IS NOT NULL`, - ).get(scopeUserId, scopeAgentId, projectId) as { cnt: number } | undefined)?.cnt || 0); - - const taskContextSelector = { - ...(input.task_context?.task_id ? { task_id: String(input.task_context.task_id).trim() } : {}), - ...(input.task_context?.tracker_issue_key ? { tracker_issue_key: String(input.task_context.tracker_issue_key).trim() } : {}), - ...(input.task_context?.task_title ? { task_title: String(input.task_context.task_title).trim() } : {}), - }; - const reasonByLifecycle: Record = { - deindexed: "project is deindexed; retrieval is disabled until reindex", - detached: "project is detached; retrieval is disabled until project is re-attached and reindexed", - disabled: "project is unregistered/disabled; retrieval is disabled until re-registration", - purged: "project is purged; retrieval is disabled", - }; - - return { - query, - project_id: projectId, - project_lifecycle_status: project.lifecycle_status, - searchable: false, - tombstone_summary: { files, chunks, symbols }, - count: 0, - task_lineage_context: null, - task_context_resolution: { - status: Object.keys(taskContextSelector).length > 0 ? "selector_not_resolved" : "not_requested", - ...(Object.keys(taskContextSelector).length > 0 - ? { reason: reasonByLifecycle[project.lifecycle_status] || "project lifecycle disables retrieval" } - : {}), - selector: taskContextSelector, - recoverable: project.lifecycle_status !== "purged", - }, - results: [], - debug: input.debug - ? { - query_intent: { - looks_code_intent: false, - looks_identifier_query: false, - query_tokens: [], - }, - candidate_counts: { - file_index_state: 0, - symbol_registry: 0, - chunk_registry: 0, - task_registry: 0, - }, - top_candidates: { - file_index_state: [], - symbol_registry: [], - chunk_registry: [], - task_registry: [], - }, - } - : undefined, - }; - } - - const limit = Math.min(Math.max(Number(input.limit || 10), 1), 50); - const queryLc = query.toLowerCase(); - const queryTokens = Array.from(new Set(queryLc.split(/[^a-z0-9._/-]+/i).map((t) => t.trim()).filter(Boolean))); - const tokenScore = (text: string): number => { - if (!queryTokens.length) return 0; - const hay = text.toLowerCase(); - let matched = 0; - for (const token of queryTokens) { - if (hay.includes(token)) matched += 1; - } - return matched / queryTokens.length; - }; - const exactMatchScore = (candidate: string): number => { - const value = String(candidate || '').trim().toLowerCase(); - if (!value) return 0; - if (value === queryLc) return 1; - if (value.endsWith(`.${queryLc}`)) return 0.92; - if (value.includes(`/${queryLc}`) || value.includes(`:${queryLc}`) || value.includes(`#${queryLc}`)) return 0.78; - return 0; - }; - const codeIntentHints = ['function', 'class', 'method', 'symbol', 'route', 'endpoint', 'extractor', 'registry', 'chunk', 'snippet', 'code']; - const looksCodeIntent = codeIntentHints.some((hint) => queryLc.includes(hint)) || query.includes('/') || query.includes('_'); - const looksIdentifierQuery = - /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(query) || - /^[a-zA-Z_][a-zA-Z0-9_.#:-]*$/.test(query) || - query.includes('::') || - query.includes('.') || - query.includes('_'); - const taskContextInput = input.task_context; - const taskContextSelector = { - ...(taskContextInput?.task_id ? { task_id: String(taskContextInput.task_id).trim() } : {}), - ...(taskContextInput?.tracker_issue_key ? { tracker_issue_key: String(taskContextInput.tracker_issue_key).trim() } : {}), - ...(taskContextInput?.task_title ? { task_title: String(taskContextInput.task_title).trim() } : {}), - }; - - let lineageContext: ProjectTaskLineageContextResult | null = null; - let taskContextResolution: ProjectHybridSearchTaskContextResolution = { - status: "not_requested", - selector: taskContextSelector, - recoverable: false, - }; - - if (Object.keys(taskContextSelector).length > 0) { - try { - lineageContext = this.getTaskLineageContext(scopeUserId, scopeAgentId, { - project_id: projectId, - task_id: taskContextInput?.task_id, - tracker_issue_key: taskContextInput?.tracker_issue_key, - task_title: taskContextInput?.task_title, - include_parent_chain: taskContextInput?.include_parent_chain, - include_related: taskContextInput?.include_related, - }); - taskContextResolution = { - status: "resolved", - selector: taskContextSelector, - recoverable: false, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("task lineage focus not found for provided selector")) { - throw error; - } - taskContextResolution = { - status: "selector_not_resolved", - reason: "task lineage focus not found for provided selector", - selector: taskContextSelector, - recoverable: true, - }; - } - } - - const lexicalPathPrefix = this.normalizeStringArray(input.path_prefix).map((p) => this.normalizeRelativePath(p)).filter(Boolean); - const lexicalModules = new Set(this.normalizeStringArray(input.module).map((s) => s.toLowerCase())); - const lexicalLanguages = new Set(this.normalizeStringArray(input.language).map((s) => s.toLowerCase())); - const lexicalTaskIds = new Set(this.normalizeStringArray(input.task_id)); - const lexicalIssueKeys = new Set(this.normalizeStringArray(input.tracker_issue_key).map((s) => s.toUpperCase())); - - if (lineageContext) { - lexicalTaskIds.add(lineageContext.focus.task_id); - if (lineageContext.focus.tracker_issue_key) lexicalIssueKeys.add(lineageContext.focus.tracker_issue_key.toUpperCase()); - for (const t of [...lineageContext.parent_chain, ...lineageContext.related_tasks]) { - lexicalTaskIds.add(t.task_id); - if (t.tracker_issue_key) lexicalIssueKeys.add(t.tracker_issue_key.toUpperCase()); - } - } - - const results: ProjectHybridSearchResultItem[] = []; - - const debugEnabled = input.debug === true; - const debugBuckets = { - file_index_state: [] as Array>, - symbol_registry: [] as Array>, - chunk_registry: [] as Array>, - task_registry: [] as Array>, - }; - const pushDebug = (bucket: keyof typeof debugBuckets, entry: Record) => { - if (!debugEnabled) return; - debugBuckets[bucket].push(entry); - }; - - const symbolRowsById = new Map(); - - const fileStmt = this.db.prepare( - `SELECT * FROM file_index_state + ) + .get(scopeUserId, scopeAgentId, projectId) as + | { cnt: number } + | undefined + )?.cnt || 0, + ); + + const taskContextSelector = { + ...(input.task_context?.task_id + ? { task_id: String(input.task_context.task_id).trim() } + : {}), + ...(input.task_context?.tracker_issue_key + ? { + tracker_issue_key: String( + input.task_context.tracker_issue_key, + ).trim(), + } + : {}), + ...(input.task_context?.task_title + ? { task_title: String(input.task_context.task_title).trim() } + : {}), + }; + const reasonByLifecycle: Record = { + deindexed: "project is deindexed; retrieval is disabled until reindex", + detached: + "project is detached; retrieval is disabled until project is re-attached and reindexed", + disabled: + "project is unregistered/disabled; retrieval is disabled until re-registration", + purged: "project is purged; retrieval is disabled", + }; + + return { + query, + project_id: projectId, + project_lifecycle_status: project.lifecycle_status, + searchable: false, + tombstone_summary: { files, chunks, symbols }, + count: 0, + task_lineage_context: null, + task_context_resolution: { + status: + Object.keys(taskContextSelector).length > 0 + ? "selector_not_resolved" + : "not_requested", + ...(Object.keys(taskContextSelector).length > 0 + ? { + reason: + reasonByLifecycle[project.lifecycle_status] || + "project lifecycle disables retrieval", + } + : {}), + selector: taskContextSelector, + recoverable: project.lifecycle_status !== "purged", + }, + results: [], + debug: input.debug + ? { + query_intent: { + looks_code_intent: false, + looks_identifier_query: false, + query_tokens: [], + }, + candidate_counts: { + file_index_state: 0, + symbol_registry: 0, + chunk_registry: 0, + task_registry: 0, + }, + top_candidates: { + file_index_state: [], + symbol_registry: [], + chunk_registry: [], + task_registry: [], + }, + } + : undefined, + }; + } + + const limit = Math.min(Math.max(Number(input.limit || 10), 1), 50); + const queryLc = query.toLowerCase(); + const queryTokens = Array.from( + new Set( + queryLc + .split(/[^a-z0-9._/-]+/i) + .map((t) => t.trim()) + .filter(Boolean), + ), + ); + const tokenScore = (text: string): number => { + if (!queryTokens.length) return 0; + const hay = text.toLowerCase(); + let matched = 0; + for (const token of queryTokens) { + if (hay.includes(token)) matched += 1; + } + return matched / queryTokens.length; + }; + const exactMatchScore = (candidate: string): number => { + const value = String(candidate || "") + .trim() + .toLowerCase(); + if (!value) return 0; + if (value === queryLc) return 1; + if (value.endsWith(`.${queryLc}`)) return 0.92; + if ( + value.includes(`/${queryLc}`) || + value.includes(`:${queryLc}`) || + value.includes(`#${queryLc}`) + ) + return 0.78; + return 0; + }; + const codeIntentHints = [ + "function", + "class", + "method", + "symbol", + "route", + "endpoint", + "extractor", + "registry", + "chunk", + "snippet", + "code", + ]; + const looksCodeIntent = + codeIntentHints.some((hint) => queryLc.includes(hint)) || + query.includes("/") || + query.includes("_"); + const looksIdentifierQuery = + /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(query) || + /^[a-zA-Z_][a-zA-Z0-9_.#:-]*$/.test(query) || + query.includes("::") || + query.includes(".") || + query.includes("_"); + const taskContextInput = input.task_context; + const taskContextSelector = { + ...(taskContextInput?.task_id + ? { task_id: String(taskContextInput.task_id).trim() } + : {}), + ...(taskContextInput?.tracker_issue_key + ? { + tracker_issue_key: String( + taskContextInput.tracker_issue_key, + ).trim(), + } + : {}), + ...(taskContextInput?.task_title + ? { task_title: String(taskContextInput.task_title).trim() } + : {}), + }; + + let lineageContext: ProjectTaskLineageContextResult | null = null; + let taskContextResolution: ProjectHybridSearchTaskContextResolution = { + status: "not_requested", + selector: taskContextSelector, + recoverable: false, + }; + + if (Object.keys(taskContextSelector).length > 0) { + try { + lineageContext = this.getTaskLineageContext(scopeUserId, scopeAgentId, { + project_id: projectId, + task_id: taskContextInput?.task_id, + tracker_issue_key: taskContextInput?.tracker_issue_key, + task_title: taskContextInput?.task_title, + include_parent_chain: taskContextInput?.include_parent_chain, + include_related: taskContextInput?.include_related, + }); + taskContextResolution = { + status: "resolved", + selector: taskContextSelector, + recoverable: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + !message.includes( + "task lineage focus not found for provided selector", + ) + ) { + throw error; + } + taskContextResolution = { + status: "selector_not_resolved", + reason: "task lineage focus not found for provided selector", + selector: taskContextSelector, + recoverable: true, + }; + } + } + + const lexicalPathPrefix = this.normalizeStringArray(input.path_prefix) + .map((p) => this.normalizeRelativePath(p)) + .filter(Boolean); + const lexicalModules = new Set( + this.normalizeStringArray(input.module).map((s) => s.toLowerCase()), + ); + const lexicalLanguages = new Set( + this.normalizeStringArray(input.language).map((s) => s.toLowerCase()), + ); + const lexicalTaskIds = new Set(this.normalizeStringArray(input.task_id)); + const lexicalIssueKeys = new Set( + this.normalizeStringArray(input.tracker_issue_key).map((s) => + s.toUpperCase(), + ), + ); + + if (lineageContext) { + lexicalTaskIds.add(lineageContext.focus.task_id); + if (lineageContext.focus.tracker_issue_key) + lexicalIssueKeys.add( + lineageContext.focus.tracker_issue_key.toUpperCase(), + ); + for (const t of [ + ...lineageContext.parent_chain, + ...lineageContext.related_tasks, + ]) { + lexicalTaskIds.add(t.task_id); + if (t.tracker_issue_key) + lexicalIssueKeys.add(t.tracker_issue_key.toUpperCase()); + } + } + + const results: ProjectHybridSearchResultItem[] = []; + + const debugEnabled = input.debug === true; + const debugBuckets = { + file_index_state: [] as Array>, + symbol_registry: [] as Array>, + chunk_registry: [] as Array>, + task_registry: [] as Array>, + }; + const pushDebug = ( + bucket: keyof typeof debugBuckets, + entry: Record, + ) => { + if (!debugEnabled) return; + debugBuckets[bucket].push(entry); + }; + + const symbolRowsById = new Map(); + + const fileStmt = this.db.prepare( + `SELECT * FROM file_index_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ); - const fileRows = fileStmt.all(scopeUserId, scopeAgentId, projectId) as any[]; - for (const row of fileRows) { - const relativePath = String(row.relative_path || ""); - const moduleName = row.module ? String(row.module) : null; - const language = row.language ? String(row.language) : null; - - if (lexicalPathPrefix.length > 0 && !lexicalPathPrefix.some((prefix) => relativePath.startsWith(prefix))) continue; - if (lexicalModules.size > 0 && !moduleName) continue; - if (lexicalModules.size > 0 && moduleName && !lexicalModules.has(moduleName.toLowerCase())) continue; - if (lexicalLanguages.size > 0 && !language) continue; - if (lexicalLanguages.size > 0 && language && !lexicalLanguages.has(language.toLowerCase())) continue; - - const text = `${relativePath} ${moduleName || ""} ${language || ""}`.toLowerCase(); - let score = 0; - if (text.includes(queryLc)) score += 0.55; - score += tokenScore(text) * 0.25; - if (lineageContext && lineageContext.touched_files.includes(relativePath)) score += 0.35; - if (looksCodeIntent) { - if (relativePath.includes('/docs/') || relativePath.startsWith('docs/') || relativePath.includes('README')) score -= 0.18; - if (relativePath.startsWith('src/') || relativePath.startsWith('tests/')) score += 0.14; - } else if (relativePath.includes("README") || relativePath.includes("docs/")) { - score += 0.05; - } - if (looksIdentifierQuery) { - score -= 0.12; - } - if (score <= 0.08) continue; - - pushDebug('file_index_state', { - relative_path: relativePath, - score: Number(score.toFixed(4)), - module: moduleName, - language, - text_exact: text.includes(queryLc), - token_score: Number(tokenScore(text).toFixed(4)), - }); - results.push({ - source: "file_index_state", - id: String(row.file_id), - score, - project_id: projectId, - relative_path: relativePath, - module: moduleName, - language, - snippet: `file ${relativePath}${moduleName ? ` (module ${moduleName})` : ""}`, - }); - } - - const symbolStmt = this.db.prepare( - `SELECT * FROM symbol_registry + ); + const fileRows = fileStmt.all( + scopeUserId, + scopeAgentId, + projectId, + ) as any[]; + for (const row of fileRows) { + const relativePath = String(row.relative_path || ""); + const moduleName = row.module ? String(row.module) : null; + const language = row.language ? String(row.language) : null; + + if ( + lexicalPathPrefix.length > 0 && + !lexicalPathPrefix.some((prefix) => relativePath.startsWith(prefix)) + ) + continue; + if (lexicalModules.size > 0 && !moduleName) continue; + if ( + lexicalModules.size > 0 && + moduleName && + !lexicalModules.has(moduleName.toLowerCase()) + ) + continue; + if (lexicalLanguages.size > 0 && !language) continue; + if ( + lexicalLanguages.size > 0 && + language && + !lexicalLanguages.has(language.toLowerCase()) + ) + continue; + + const text = + `${relativePath} ${moduleName || ""} ${language || ""}`.toLowerCase(); + let score = 0; + if (text.includes(queryLc)) score += 0.55; + score += tokenScore(text) * 0.25; + if (lineageContext && lineageContext.touched_files.includes(relativePath)) + score += 0.35; + if (looksCodeIntent) { + if ( + relativePath.includes("/docs/") || + relativePath.startsWith("docs/") || + relativePath.includes("README") + ) + score -= 0.18; + if ( + relativePath.startsWith("src/") || + relativePath.startsWith("tests/") + ) + score += 0.14; + } else if ( + relativePath.includes("README") || + relativePath.includes("docs/") + ) { + score += 0.05; + } + if (looksIdentifierQuery) { + score -= 0.12; + } + if (score <= 0.08) continue; + + pushDebug("file_index_state", { + relative_path: relativePath, + score: Number(score.toFixed(4)), + module: moduleName, + language, + text_exact: text.includes(queryLc), + token_score: Number(tokenScore(text).toFixed(4)), + }); + results.push({ + source: "file_index_state", + id: String(row.file_id), + score, + project_id: projectId, + relative_path: relativePath, + module: moduleName, + language, + snippet: `file ${relativePath}${moduleName ? ` (module ${moduleName})` : ""}`, + }); + } + + const symbolStmt = this.db.prepare( + `SELECT * FROM symbol_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ); - const symbolRows = symbolStmt.all(scopeUserId, scopeAgentId, projectId) as any[]; - for (const row of symbolRows) { - const relativePath = String(row.relative_path || ""); - const moduleName = row.module ? String(row.module) : null; - const language = row.language ? String(row.language) : null; - const symbolName = String(row.symbol_name || ""); - const symbolKind = String(row.symbol_kind || ""); - const symbolFqn = String(row.symbol_fqn || ""); - symbolRowsById.set(String(row.symbol_id), row); - - if (lexicalPathPrefix.length > 0 && !lexicalPathPrefix.some((prefix) => relativePath.startsWith(prefix))) continue; - if (lexicalModules.size > 0 && !moduleName) continue; - if (lexicalModules.size > 0 && moduleName && !lexicalModules.has(moduleName.toLowerCase())) continue; - if (lexicalLanguages.size > 0 && !language) continue; - if (lexicalLanguages.size > 0 && language && !lexicalLanguages.has(language.toLowerCase())) continue; - - const text = `${symbolName} ${symbolFqn} ${relativePath} ${moduleName || ""} ${symbolKind}`.toLowerCase(); - let score = 0; - if (text.includes(queryLc)) score += 0.62; - score += tokenScore(text) * 0.35; - score += exactMatchScore(symbolName) * 1.35; - score += exactMatchScore(symbolFqn) * 1.1; - if (looksIdentifierQuery) { - if (symbolName.toLowerCase() === queryLc) score += 1.8; - else if (symbolFqn.toLowerCase() === queryLc) score += 1.5; - else if (symbolFqn.toLowerCase().endsWith(`.${queryLc}`)) score += 1.1; - } - if (lineageContext && lineageContext.touched_symbols.includes(symbolName)) score += 0.3; - if (lineageContext && lineageContext.touched_files.includes(relativePath)) score += 0.12; - if (looksCodeIntent && (relativePath.startsWith('src/') || relativePath.startsWith('tests/'))) score += 0.08; - if (looksIdentifierQuery) score += 0.18; - if (score <= 0.08) continue; - - pushDebug('symbol_registry', { - relative_path: relativePath, - symbol_name: symbolName, - symbol_fqn: symbolFqn, - symbol_kind: symbolKind, - score: Number(score.toFixed(4)), - exact_symbol: symbolName.toLowerCase() === queryLc, - exact_fqn: symbolFqn.toLowerCase() === queryLc, - suffix_fqn: symbolFqn.toLowerCase().endsWith(`.${queryLc}`), - token_score: Number(tokenScore(text).toFixed(4)), - }); - results.push({ - source: "symbol_registry", - id: String(row.symbol_id), - score, - project_id: projectId, - relative_path: relativePath, - module: moduleName, - language, - symbol_name: symbolName, - symbol_kind: symbolKind, - snippet: `symbol ${symbolName} (${symbolKind}) in ${relativePath}`, - }); - } - - const chunkStmt = this.db.prepare( - `SELECT * FROM chunk_registry + ); + const symbolRows = symbolStmt.all( + scopeUserId, + scopeAgentId, + projectId, + ) as any[]; + for (const row of symbolRows) { + const relativePath = String(row.relative_path || ""); + const moduleName = row.module ? String(row.module) : null; + const language = row.language ? String(row.language) : null; + const symbolName = String(row.symbol_name || ""); + const symbolKind = String(row.symbol_kind || ""); + const symbolFqn = String(row.symbol_fqn || ""); + symbolRowsById.set(String(row.symbol_id), row); + + if ( + lexicalPathPrefix.length > 0 && + !lexicalPathPrefix.some((prefix) => relativePath.startsWith(prefix)) + ) + continue; + if (lexicalModules.size > 0 && !moduleName) continue; + if ( + lexicalModules.size > 0 && + moduleName && + !lexicalModules.has(moduleName.toLowerCase()) + ) + continue; + if (lexicalLanguages.size > 0 && !language) continue; + if ( + lexicalLanguages.size > 0 && + language && + !lexicalLanguages.has(language.toLowerCase()) + ) + continue; + + const text = + `${symbolName} ${symbolFqn} ${relativePath} ${moduleName || ""} ${symbolKind}`.toLowerCase(); + let score = 0; + if (text.includes(queryLc)) score += 0.62; + score += tokenScore(text) * 0.35; + score += exactMatchScore(symbolName) * 1.35; + score += exactMatchScore(symbolFqn) * 1.1; + if (looksIdentifierQuery) { + if (symbolName.toLowerCase() === queryLc) score += 1.8; + else if (symbolFqn.toLowerCase() === queryLc) score += 1.5; + else if (symbolFqn.toLowerCase().endsWith(`.${queryLc}`)) score += 1.1; + } + if (lineageContext && lineageContext.touched_symbols.includes(symbolName)) + score += 0.3; + if (lineageContext && lineageContext.touched_files.includes(relativePath)) + score += 0.12; + if ( + looksCodeIntent && + (relativePath.startsWith("src/") || relativePath.startsWith("tests/")) + ) + score += 0.08; + if (looksIdentifierQuery) score += 0.18; + if (score <= 0.08) continue; + + pushDebug("symbol_registry", { + relative_path: relativePath, + symbol_name: symbolName, + symbol_fqn: symbolFqn, + symbol_kind: symbolKind, + score: Number(score.toFixed(4)), + exact_symbol: symbolName.toLowerCase() === queryLc, + exact_fqn: symbolFqn.toLowerCase() === queryLc, + suffix_fqn: symbolFqn.toLowerCase().endsWith(`.${queryLc}`), + token_score: Number(tokenScore(text).toFixed(4)), + }); + results.push({ + source: "symbol_registry", + id: String(row.symbol_id), + score, + project_id: projectId, + relative_path: relativePath, + module: moduleName, + language, + symbol_name: symbolName, + symbol_kind: symbolKind, + snippet: `symbol ${symbolName} (${symbolKind}) in ${relativePath}`, + }); + } + + const chunkStmt = this.db.prepare( + `SELECT * FROM chunk_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1`, - ); - const chunkRows = chunkStmt.all(scopeUserId, scopeAgentId, projectId) as any[]; - for (const row of chunkRows) { - const relativePath = String(row.relative_path || ""); - const chunkKind = String(row.chunk_kind || ""); - const symbolId = row.symbol_id ? String(row.symbol_id) : null; - const symbolRow = symbolId ? symbolRowsById.get(symbolId) : null; - const symbolName = symbolRow ? String(symbolRow.symbol_name || '') : ''; - const symbolFqn = symbolRow ? String(symbolRow.symbol_fqn || '') : ''; - if (lexicalPathPrefix.length > 0 && !lexicalPathPrefix.some((prefix) => relativePath.startsWith(prefix))) continue; - const text = `${relativePath} ${chunkKind} ${symbolId || ""} ${symbolName} ${symbolFqn}`.toLowerCase(); - let score = 0; - if (text.includes(queryLc)) score += 0.6; - score += tokenScore(text) * 0.4; - score += exactMatchScore(symbolName) * 0.9; - score += exactMatchScore(symbolFqn) * 0.7; - if (looksIdentifierQuery) { - if (symbolName.toLowerCase() === queryLc) score += 1.0; - else if (symbolFqn.toLowerCase() === queryLc) score += 0.85; - } - if (lineageContext && lineageContext.touched_files.includes(relativePath)) score += 0.15; - if (looksCodeIntent) { - if (relativePath.includes('/docs/') || relativePath.startsWith('docs/') || relativePath.includes('README')) score -= 0.14; - if (relativePath.startsWith('src/') || relativePath.startsWith('tests/')) score += 0.1; - } - if (looksIdentifierQuery) score += 0.12; - if (score <= 0.08) continue; - pushDebug('chunk_registry', { - relative_path: relativePath, - chunk_kind: chunkKind, - symbol_name: symbolName || null, - symbol_fqn: symbolFqn || null, - score: Number(score.toFixed(4)), - exact_symbol: symbolName ? symbolName.toLowerCase() === queryLc : false, - token_score: Number(tokenScore(text).toFixed(4)), - }); - results.push({ - source: "chunk_registry", - id: String(row.chunk_id), - score, - project_id: projectId, - relative_path: relativePath, - symbol_name: symbolName || undefined, - snippet: symbolName ? `chunk ${chunkKind} for symbol ${symbolName} in ${relativePath}` : `chunk ${chunkKind} in ${relativePath}`, - }); - } - - const taskStmt = this.db.prepare( - `SELECT * FROM task_registry + ); + const chunkRows = chunkStmt.all( + scopeUserId, + scopeAgentId, + projectId, + ) as any[]; + for (const row of chunkRows) { + const relativePath = String(row.relative_path || ""); + const chunkKind = String(row.chunk_kind || ""); + const symbolId = row.symbol_id ? String(row.symbol_id) : null; + const symbolRow = symbolId ? symbolRowsById.get(symbolId) : null; + const symbolName = symbolRow ? String(symbolRow.symbol_name || "") : ""; + const symbolFqn = symbolRow ? String(symbolRow.symbol_fqn || "") : ""; + if ( + lexicalPathPrefix.length > 0 && + !lexicalPathPrefix.some((prefix) => relativePath.startsWith(prefix)) + ) + continue; + const text = + `${relativePath} ${chunkKind} ${symbolId || ""} ${symbolName} ${symbolFqn}`.toLowerCase(); + let score = 0; + if (text.includes(queryLc)) score += 0.6; + score += tokenScore(text) * 0.4; + score += exactMatchScore(symbolName) * 0.9; + score += exactMatchScore(symbolFqn) * 0.7; + if (looksIdentifierQuery) { + if (symbolName.toLowerCase() === queryLc) score += 1.0; + else if (symbolFqn.toLowerCase() === queryLc) score += 0.85; + } + if (lineageContext && lineageContext.touched_files.includes(relativePath)) + score += 0.15; + if (looksCodeIntent) { + if ( + relativePath.includes("/docs/") || + relativePath.startsWith("docs/") || + relativePath.includes("README") + ) + score -= 0.14; + if ( + relativePath.startsWith("src/") || + relativePath.startsWith("tests/") + ) + score += 0.1; + } + if (looksIdentifierQuery) score += 0.12; + if (score <= 0.08) continue; + pushDebug("chunk_registry", { + relative_path: relativePath, + chunk_kind: chunkKind, + symbol_name: symbolName || null, + symbol_fqn: symbolFqn || null, + score: Number(score.toFixed(4)), + exact_symbol: symbolName ? symbolName.toLowerCase() === queryLc : false, + token_score: Number(tokenScore(text).toFixed(4)), + }); + results.push({ + source: "chunk_registry", + id: String(row.chunk_id), + score, + project_id: projectId, + relative_path: relativePath, + symbol_name: symbolName || undefined, + snippet: symbolName + ? `chunk ${chunkKind} for symbol ${symbolName} in ${relativePath}` + : `chunk ${chunkKind} in ${relativePath}`, + }); + } + + const taskStmt = this.db.prepare( + `SELECT * FROM task_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ); - const taskRows = taskStmt.all(scopeUserId, scopeAgentId, projectId) as any[]; - for (const row of taskRows) { - const task = this.rowToTaskRecord(row); - const taskIssueKey = task.tracker_issue_key ? task.tracker_issue_key.toUpperCase() : null; - - if (lexicalTaskIds.size > 0 && !lexicalTaskIds.has(task.task_id)) { - if (!taskIssueKey || !lexicalIssueKeys.has(taskIssueKey)) { - // keep if user query still lexically matches strongly - } - } - - const text = [ - task.task_id, - task.task_title, - task.task_status || "", - task.tracker_issue_key || "", - ...(task.files_touched || []), - ...(task.symbols_touched || []), - ...(task.commit_refs || []), - task.decision_notes || "", - ].join(" ").toLowerCase(); - - let score = 0; - if (text.includes(queryLc)) score += 0.58; - score += tokenScore(text) * 0.25; - if (lexicalTaskIds.has(task.task_id)) score += 0.28; - if (taskIssueKey && lexicalIssueKeys.has(taskIssueKey)) score += 0.28; - if (lineageContext) { - if (task.task_id === lineageContext.focus.task_id) score += 0.35; - if (lineageContext.parent_chain.some((t) => t.task_id === task.task_id)) score += 0.2; - if (lineageContext.related_tasks.some((t) => t.task_id === task.task_id)) score += 0.2; - } - if (score <= 0.08) continue; - - pushDebug('task_registry', { - task_id: task.task_id, - tracker_issue_key: task.tracker_issue_key, - task_title: task.task_title, - score: Number(score.toFixed(4)), - token_score: Number(tokenScore(text).toFixed(4)), - }); - results.push({ - source: "task_registry", - id: task.task_id, - score, - project_id: projectId, - task_id: task.task_id, - task_title: task.task_title, - tracker_issue_key: task.tracker_issue_key, - snippet: `task ${task.task_id}: ${task.task_title}`, - }); - } - - const ranked = results - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map((item) => ({ ...item, score: Number(item.score.toFixed(4)) })); - - return { - query, - project_id: projectId, - project_lifecycle_status: project.lifecycle_status, - searchable: true, - count: ranked.length, - task_lineage_context: lineageContext, - task_context_resolution: taskContextResolution, - results: ranked, - debug: debugEnabled - ? { - query_intent: { - looks_code_intent: looksCodeIntent, - looks_identifier_query: looksIdentifierQuery, - query_tokens: queryTokens, - }, - candidate_counts: { - file_index_state: debugBuckets.file_index_state.length, - symbol_registry: debugBuckets.symbol_registry.length, - chunk_registry: debugBuckets.chunk_registry.length, - task_registry: debugBuckets.task_registry.length, - }, - top_candidates: { - file_index_state: debugBuckets.file_index_state.sort((a, b) => Number(b.score || 0) - Number(a.score || 0)).slice(0, 8), - symbol_registry: debugBuckets.symbol_registry.sort((a, b) => Number(b.score || 0) - Number(a.score || 0)).slice(0, 8), - chunk_registry: debugBuckets.chunk_registry.sort((a, b) => Number(b.score || 0) - Number(a.score || 0)).slice(0, 8), - task_registry: debugBuckets.task_registry.sort((a, b) => Number(b.score || 0) - Number(a.score || 0)).slice(0, 8), - }, - } - : undefined, - }; - } - - queryProjectChangeOverlay( - scopeUserId: string, - scopeAgentId: string, - input: ProjectChangeOverlayQueryInput, - ): ProjectChangeOverlayResult { - let lineage: ProjectTaskLineageContextResult | null = null; - - const taskIdSelector = String(input.task_id || "").trim(); - const trackerSelector = String(input.tracker_issue_key || "").trim(); - const taskTitleSelector = String(input.task_title || "").trim(); - const selector = { - ...(taskIdSelector ? { task_id: taskIdSelector } : {}), - ...(trackerSelector ? { tracker_issue_key: trackerSelector } : {}), - ...(taskTitleSelector ? { task_title: taskTitleSelector } : {}), - }; - - try { - lineage = this.getTaskLineageContext(scopeUserId, scopeAgentId, { - project_id: input.project_id, - task_id: input.task_id, - tracker_issue_key: input.tracker_issue_key, - task_title: input.task_title, - include_related: input.include_related, - include_parent_chain: input.include_parent_chain, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("task lineage focus not found for provided selector")) { - throw error; - } - - const unresolvedTaskId = taskIdSelector || `unresolved:${trackerSelector || taskTitleSelector || "selector"}`; - const unresolvedTitle = taskTitleSelector || "Unresolved task lineage selector"; - - return { - status: "selector_not_resolved", - reason: "task lineage focus not found for provided selector", - selector, - recoverable: true, - project_id: input.project_id, - focus: { - task_id: unresolvedTaskId, - task_title: unresolvedTitle, - tracker_issue_key: trackerSelector || null, - }, - changed_files: [], - related_symbols: [], - commit_refs: [], - }; - } - - const changedFiles = this.uniqueSorted( - (lineage.touched_files || []).map((p) => this.normalizeRelativePath(p)).filter(Boolean), - ); - - const relatedSymbolsMap = new Map(); - - for (const symbolName of lineage.touched_symbols || []) { - const normalized = String(symbolName || "").trim(); - if (!normalized) continue; - const key = `task_registry:${normalized}`; - if (!relatedSymbolsMap.has(key)) { - relatedSymbolsMap.set(key, { - symbol_name: normalized, - source: "task_registry", - }); - } - } - - if (changedFiles.length > 0) { - const placeholders = changedFiles.map(() => "?").join(","); - const stmt = this.db.prepare( - `SELECT symbol_name, symbol_kind, symbol_fqn, relative_path + ); + const taskRows = taskStmt.all( + scopeUserId, + scopeAgentId, + projectId, + ) as any[]; + for (const row of taskRows) { + const task = this.rowToTaskRecord(row); + const taskIssueKey = task.tracker_issue_key + ? task.tracker_issue_key.toUpperCase() + : null; + + if (lexicalTaskIds.size > 0 && !lexicalTaskIds.has(task.task_id)) { + if (!taskIssueKey || !lexicalIssueKeys.has(taskIssueKey)) { + // keep if user query still lexically matches strongly + } + } + + const text = [ + task.task_id, + task.task_title, + task.task_status || "", + task.tracker_issue_key || "", + ...(task.files_touched || []), + ...(task.symbols_touched || []), + ...(task.commit_refs || []), + task.decision_notes || "", + ] + .join(" ") + .toLowerCase(); + + let score = 0; + if (text.includes(queryLc)) score += 0.58; + score += tokenScore(text) * 0.25; + if (lexicalTaskIds.has(task.task_id)) score += 0.28; + if (taskIssueKey && lexicalIssueKeys.has(taskIssueKey)) score += 0.28; + if (lineageContext) { + if (task.task_id === lineageContext.focus.task_id) score += 0.35; + if (lineageContext.parent_chain.some((t) => t.task_id === task.task_id)) + score += 0.2; + if ( + lineageContext.related_tasks.some((t) => t.task_id === task.task_id) + ) + score += 0.2; + } + if (score <= 0.08) continue; + + pushDebug("task_registry", { + task_id: task.task_id, + tracker_issue_key: task.tracker_issue_key, + task_title: task.task_title, + score: Number(score.toFixed(4)), + token_score: Number(tokenScore(text).toFixed(4)), + }); + results.push({ + source: "task_registry", + id: task.task_id, + score, + project_id: projectId, + task_id: task.task_id, + task_title: task.task_title, + tracker_issue_key: task.tracker_issue_key, + snippet: `task ${task.task_id}: ${task.task_title}`, + }); + } + + const ranked = results + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((item) => ({ ...item, score: Number(item.score.toFixed(4)) })); + + return { + query, + project_id: projectId, + project_lifecycle_status: project.lifecycle_status, + searchable: true, + count: ranked.length, + task_lineage_context: lineageContext, + task_context_resolution: taskContextResolution, + results: ranked, + debug: debugEnabled + ? { + query_intent: { + looks_code_intent: looksCodeIntent, + looks_identifier_query: looksIdentifierQuery, + query_tokens: queryTokens, + }, + candidate_counts: { + file_index_state: debugBuckets.file_index_state.length, + symbol_registry: debugBuckets.symbol_registry.length, + chunk_registry: debugBuckets.chunk_registry.length, + task_registry: debugBuckets.task_registry.length, + }, + top_candidates: { + file_index_state: debugBuckets.file_index_state + .sort((a, b) => Number(b.score || 0) - Number(a.score || 0)) + .slice(0, 8), + symbol_registry: debugBuckets.symbol_registry + .sort((a, b) => Number(b.score || 0) - Number(a.score || 0)) + .slice(0, 8), + chunk_registry: debugBuckets.chunk_registry + .sort((a, b) => Number(b.score || 0) - Number(a.score || 0)) + .slice(0, 8), + task_registry: debugBuckets.task_registry + .sort((a, b) => Number(b.score || 0) - Number(a.score || 0)) + .slice(0, 8), + }, + } + : undefined, + }; + } + + queryProjectChangeOverlay( + scopeUserId: string, + scopeAgentId: string, + input: ProjectChangeOverlayQueryInput, + ): ProjectChangeOverlayResult { + let lineage: ProjectTaskLineageContextResult | null = null; + + const taskIdSelector = String(input.task_id || "").trim(); + const trackerSelector = String(input.tracker_issue_key || "").trim(); + const taskTitleSelector = String(input.task_title || "").trim(); + const selector = { + ...(taskIdSelector ? { task_id: taskIdSelector } : {}), + ...(trackerSelector ? { tracker_issue_key: trackerSelector } : {}), + ...(taskTitleSelector ? { task_title: taskTitleSelector } : {}), + }; + + try { + lineage = this.getTaskLineageContext(scopeUserId, scopeAgentId, { + project_id: input.project_id, + task_id: input.task_id, + tracker_issue_key: input.tracker_issue_key, + task_title: input.task_title, + include_related: input.include_related, + include_parent_chain: input.include_parent_chain, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + !message.includes("task lineage focus not found for provided selector") + ) { + throw error; + } + + const unresolvedTaskId = + taskIdSelector || + `unresolved:${trackerSelector || taskTitleSelector || "selector"}`; + const unresolvedTitle = + taskTitleSelector || "Unresolved task lineage selector"; + + return { + status: "selector_not_resolved", + reason: "task lineage focus not found for provided selector", + selector, + recoverable: true, + project_id: input.project_id, + focus: { + task_id: unresolvedTaskId, + task_title: unresolvedTitle, + tracker_issue_key: trackerSelector || null, + }, + changed_files: [], + related_symbols: [], + commit_refs: [], + }; + } + + const changedFiles = this.uniqueSorted( + (lineage.touched_files || []) + .map((p) => this.normalizeRelativePath(p)) + .filter(Boolean), + ); + + const relatedSymbolsMap = new Map(); + + for (const symbolName of lineage.touched_symbols || []) { + const normalized = String(symbolName || "").trim(); + if (!normalized) continue; + const key = `task_registry:${normalized}`; + if (!relatedSymbolsMap.has(key)) { + relatedSymbolsMap.set(key, { + symbol_name: normalized, + source: "task_registry", + }); + } + } + + if (changedFiles.length > 0) { + const placeholders = changedFiles.map(() => "?").join(","); + const stmt = this.db.prepare( + `SELECT symbol_name, symbol_kind, symbol_fqn, relative_path FROM symbol_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1 AND relative_path IN (${placeholders}) ORDER BY indexed_at DESC, symbol_name ASC`, - ); - const rows = stmt.all(scopeUserId, scopeAgentId, input.project_id, ...changedFiles) as Array<{ - symbol_name: string; - symbol_kind: string | null; - symbol_fqn: string | null; - relative_path: string | null; - }>; - - for (const row of rows) { - const symbolName = String(row.symbol_name || "").trim(); - if (!symbolName) continue; - const relPath = row.relative_path ? String(row.relative_path) : undefined; - const symbolFqn = row.symbol_fqn ? String(row.symbol_fqn) : undefined; - const key = `symbol_registry:${symbolName}:${symbolFqn || ""}:${relPath || ""}`; - if (!relatedSymbolsMap.has(key)) { - relatedSymbolsMap.set(key, { - symbol_name: symbolName, - symbol_kind: row.symbol_kind ? String(row.symbol_kind) : undefined, - symbol_fqn: symbolFqn, - relative_path: relPath, - source: "symbol_registry", - }); - } - } - } - - return { - status: "ok", - selector, - recoverable: false, - project_id: input.project_id, - focus: lineage.focus, - changed_files: changedFiles, - related_symbols: Array.from(relatedSymbolsMap.values()), - commit_refs: this.uniqueSorted(lineage.commit_refs || []), - }; - } - - getProjectFeaturePackProjectOnboardingIndexingSnapshot( - scopeUserId: string, - scopeAgentId: string, - projectId: string, - ): ProjectFeaturePackProjectOnboardingIndexingSnapshot { - const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); - if (!project) { - throw new Error(`project_id '${projectId}' is not registered`); - } - - const aliases = this.listProjects(scopeUserId, scopeAgentId) - .find((row) => row.project.project_id === projectId)?.aliases || []; - - const registration = this.getProjectRegistrationState(scopeUserId, scopeAgentId, projectId); - - const trackerStmt = this.db.prepare( - `SELECT * FROM project_tracker_mappings + ); + const rows = stmt.all( + scopeUserId, + scopeAgentId, + input.project_id, + ...changedFiles, + ) as Array<{ + symbol_name: string; + symbol_kind: string | null; + symbol_fqn: string | null; + relative_path: string | null; + }>; + + for (const row of rows) { + const symbolName = String(row.symbol_name || "").trim(); + if (!symbolName) continue; + const relPath = row.relative_path + ? String(row.relative_path) + : undefined; + const symbolFqn = row.symbol_fqn ? String(row.symbol_fqn) : undefined; + const key = `symbol_registry:${symbolName}:${symbolFqn || ""}:${relPath || ""}`; + if (!relatedSymbolsMap.has(key)) { + relatedSymbolsMap.set(key, { + symbol_name: symbolName, + symbol_kind: row.symbol_kind ? String(row.symbol_kind) : undefined, + symbol_fqn: symbolFqn, + relative_path: relPath, + source: "symbol_registry", + }); + } + } + } + + return { + status: "ok", + selector, + recoverable: false, + project_id: input.project_id, + focus: lineage.focus, + changed_files: changedFiles, + related_symbols: Array.from(relatedSymbolsMap.values()), + commit_refs: this.uniqueSorted(lineage.commit_refs || []), + }; + } + + getProjectFeaturePackProjectOnboardingIndexingSnapshot( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + ): ProjectFeaturePackProjectOnboardingIndexingSnapshot { + const project = this.getProjectById(scopeUserId, scopeAgentId, projectId); + if (!project) { + throw new Error(`project_id '${projectId}' is not registered`); + } + + const aliases = + this.listProjects(scopeUserId, scopeAgentId).find( + (row) => row.project.project_id === projectId, + )?.aliases || []; + + const registration = this.getProjectRegistrationState( + scopeUserId, + scopeAgentId, + projectId, + ); + + const trackerStmt = this.db.prepare( + `SELECT * FROM project_tracker_mappings WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? ORDER BY updated_at DESC`, - ); - const trackerMappings = (trackerStmt.all(scopeUserId, scopeAgentId, projectId) as Array).map((row) => ({ - id: String(row.id), - project_id: String(row.project_id), - scope_user_id: String(row.scope_user_id), - scope_agent_id: String(row.scope_agent_id), - tracker_type: String(row.tracker_type) as "jira" | "github" | "other", - tracker_space_key: row.tracker_space_key ? String(row.tracker_space_key) : null, - tracker_project_id: row.tracker_project_id ? String(row.tracker_project_id) : null, - default_epic_key: row.default_epic_key ? String(row.default_epic_key) : null, - board_key: row.board_key ? String(row.board_key) : null, - active_version: row.active_version ? String(row.active_version) : null, - external_project_url: row.external_project_url ? String(row.external_project_url) : null, - created_at: String(row.created_at), - updated_at: String(row.updated_at), - })); - - const fileStmt = this.db.prepare( - `SELECT relative_path, module, language + ); + const trackerMappings = ( + trackerStmt.all(scopeUserId, scopeAgentId, projectId) as Array + ).map((row) => ({ + id: String(row.id), + project_id: String(row.project_id), + scope_user_id: String(row.scope_user_id), + scope_agent_id: String(row.scope_agent_id), + tracker_type: String(row.tracker_type) as "jira" | "github" | "other", + tracker_space_key: row.tracker_space_key + ? String(row.tracker_space_key) + : null, + tracker_project_id: row.tracker_project_id + ? String(row.tracker_project_id) + : null, + default_epic_key: row.default_epic_key + ? String(row.default_epic_key) + : null, + board_key: row.board_key ? String(row.board_key) : null, + active_version: row.active_version ? String(row.active_version) : null, + external_project_url: row.external_project_url + ? String(row.external_project_url) + : null, + created_at: String(row.created_at), + updated_at: String(row.updated_at), + })); + + const fileStmt = this.db.prepare( + `SELECT relative_path, module, language FROM file_index_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1 ORDER BY indexed_at DESC, relative_path ASC LIMIT 12`, - ); - const recentFiles = (fileStmt.all(scopeUserId, scopeAgentId, projectId) as Array).map((row) => ({ - relative_path: String(row.relative_path), - module: row.module ? String(row.module) : null, - language: row.language ? String(row.language) : null, - })); - - const symbolStmt = this.db.prepare( - `SELECT symbol_name, symbol_kind, symbol_fqn, relative_path + ); + const recentFiles = ( + fileStmt.all(scopeUserId, scopeAgentId, projectId) as Array + ).map((row) => ({ + relative_path: String(row.relative_path), + module: row.module ? String(row.module) : null, + language: row.language ? String(row.language) : null, + })); + + const symbolStmt = this.db.prepare( + `SELECT symbol_name, symbol_kind, symbol_fqn, relative_path FROM symbol_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND active = 1 ORDER BY indexed_at DESC, symbol_name ASC LIMIT 16`, - ); - const recentSymbols = (symbolStmt.all(scopeUserId, scopeAgentId, projectId) as Array).map((row) => ({ - symbol_name: String(row.symbol_name), - symbol_kind: String(row.symbol_kind), - symbol_fqn: String(row.symbol_fqn), - relative_path: String(row.relative_path), - })); - - const taskStmt = this.db.prepare( - `SELECT task_id, task_title, tracker_issue_key, task_status + ); + const recentSymbols = ( + symbolStmt.all(scopeUserId, scopeAgentId, projectId) as Array + ).map((row) => ({ + symbol_name: String(row.symbol_name), + symbol_kind: String(row.symbol_kind), + symbol_fqn: String(row.symbol_fqn), + relative_path: String(row.relative_path), + })); + + const taskStmt = this.db.prepare( + `SELECT task_id, task_title, tracker_issue_key, task_status FROM task_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? ORDER BY updated_at DESC LIMIT 12`, - ); - const recentTasks = (taskStmt.all(scopeUserId, scopeAgentId, projectId) as Array).map((row) => ({ - task_id: String(row.task_id), - task_title: String(row.task_title), - tracker_issue_key: row.tracker_issue_key ? String(row.tracker_issue_key) : null, - task_status: row.task_status ? String(row.task_status) : null, - })); - - const runStmt = this.db.prepare( - `SELECT run_id, trigger_type, state, started_at, finished_at + ); + const recentTasks = ( + taskStmt.all(scopeUserId, scopeAgentId, projectId) as Array + ).map((row) => ({ + task_id: String(row.task_id), + task_title: String(row.task_title), + tracker_issue_key: row.tracker_issue_key + ? String(row.tracker_issue_key) + : null, + task_status: row.task_status ? String(row.task_status) : null, + })); + + const runStmt = this.db.prepare( + `SELECT run_id, trigger_type, state, started_at, finished_at FROM index_runs WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? ORDER BY started_at DESC LIMIT 8`, - ); - const recentIndexRuns = (runStmt.all(scopeUserId, scopeAgentId, projectId) as Array).map((row) => ({ - run_id: String(row.run_id), - trigger_type: String(row.trigger_type), - state: String(row.state), - started_at: String(row.started_at), - finished_at: row.finished_at ? String(row.finished_at) : null, - })); - - return { - project, - aliases, - registration, - tracker_mappings: trackerMappings, - recent_files: recentFiles, - recent_symbols: recentSymbols, - recent_tasks: recentTasks, - recent_index_runs: recentIndexRuns, - }; - } - - runLegacyCompatibilityBackfill( - scopeUserId: string, - scopeAgentId: string, - input: ProjectLegacyBackfillInput = {}, - ): ProjectLegacyBackfillResult { - const mode = input.mode || "dry_run"; - const source = input.source || "mixed"; - const now = new Date().toISOString(); - - const onlyProjectIds = new Set(this.normalizeStringArray(input.only_project_ids)); - const onlyAliases = new Set(this.normalizeStringArray(input.only_aliases).map((a) => this.normalizeProjectAlias(a))); - - const projects = this.listProjects(scopeUserId, scopeAgentId); - const selected = projects.filter((row) => { - if (onlyProjectIds.size > 0 && !onlyProjectIds.has(row.project.project_id)) return false; - if (onlyAliases.size > 0) { - const aliases = row.aliases.map((a) => this.normalizeProjectAlias(a.project_alias)); - if (!aliases.some((a) => onlyAliases.has(a))) return false; - } - return true; - }); - - let updatedAliases = 0; - let updatedMappings = 0; - let updatedRegistrations = 0; - let migrationStateUpserts = 0; - - const items: ProjectLegacyBackfillItem[] = []; - - for (const row of selected) { - const project = row.project; - const warnings: string[] = []; - const actions: string[] = []; - - const existingAliases = new Set( - row.aliases.map((a) => this.normalizeProjectAlias(a.project_alias)).filter(Boolean), - ); - - const inferredAliases = this.inferBackfillAliases(project, existingAliases, source); - const inferredMappings = this.inferBackfillTrackerMappings(scopeUserId, scopeAgentId, project.project_id, project, source); - - if (mode === "apply") { - for (const alias of inferredAliases) { - if (existingAliases.has(alias)) continue; - this.upsertProjectAlias(scopeUserId, scopeAgentId, project.project_id, alias, false, now, false); - existingAliases.add(alias); - updatedAliases += 1; - actions.push(`alias.backfilled:${alias}`); - } - - for (const mapping of inferredMappings) { - const existing = this.getProjectTrackerMapping(scopeUserId, scopeAgentId, project.project_id, mapping.tracker_type); - if ( - existing - && existing.tracker_space_key === mapping.tracker_space_key - && existing.default_epic_key === mapping.default_epic_key - && existing.tracker_project_id === mapping.tracker_project_id - ) { - continue; - } - - this.setProjectTrackerMapping(scopeUserId, scopeAgentId, { - project_id: project.project_id, - tracker_type: mapping.tracker_type, - tracker_space_key: mapping.tracker_space_key || undefined, - tracker_project_id: mapping.tracker_project_id || undefined, - default_epic_key: mapping.default_epic_key || undefined, - }); - updatedMappings += 1; - actions.push(`tracker.backfilled:${mapping.tracker_type}`); - } - } - - const primaryAlias = this.pickPrimaryAlias(existingAliases, project); - const completeness = this.computeRegistrationCompleteness(project, primaryAlias); - const missingFields = this.computeMissingRegistrationFields(project, primaryAlias); - const hasTracker = inferredMappings.length > 0 || Boolean(this.getProjectTrackerMapping(scopeUserId, scopeAgentId, project.project_id, "jira")); - const status: "registered" | "validated" = hasTracker && completeness >= 90 ? "validated" : "registered"; - const validationStatus: "ok" | "warn" = missingFields.length === 0 ? "ok" : "warn"; - - const existingRegistration = this.getProjectRegistrationState(scopeUserId, scopeAgentId, project.project_id); - const shouldUpdateRegistration = - !existingRegistration - || input.force_registration_state === true - || existingRegistration.registration_status === "draft" - || existingRegistration.validation_status !== validationStatus; - - if (shouldUpdateRegistration) { - if (mode === "apply") { - this.upsertProjectRegistrationState(scopeUserId, scopeAgentId, { - project_id: project.project_id, - registration_status: status, - validation_status: validationStatus, - validation_notes: `legacy_backfill:${source}`, - completeness_score: completeness, - missing_required_fields: missingFields, - last_validated_at: now, - }); - updatedRegistrations += 1; - actions.push("registration.backfilled"); - } - } - - if (mode === "apply") { - this.upsertMigrationState(scopeUserId, scopeAgentId, { - migration_id: `legacy-backfill:${project.project_id}`, - schema_from: "legacy", - schema_to: "5.1", - applied_at: now, - status: "migrated", - notes: JSON.stringify({ - source, - alias_count: inferredAliases.length, - tracker_count: inferredMappings.length, - }), - }); - migrationStateUpserts += 1; - } - - if (inferredAliases.length === 0) { - warnings.push("no additional alias inferred"); - } - if (inferredMappings.length === 0) { - warnings.push("no tracker mapping inferred"); - } - - items.push({ - project_id: project.project_id, - project_name: project.project_name, - inferred_aliases: inferredAliases, - inferred_tracker_mappings: inferredMappings, - actions, - warnings, - }); - } - - return { - mode, - source, - scanned_projects: projects.length, - candidates: selected.length, - updated_aliases: updatedAliases, - updated_tracker_mappings: updatedMappings, - updated_registration_states: updatedRegistrations, - migration_state_upserts: migrationStateUpserts, - items, - }; - } - - // -------------------------------------------------------------------------- - // Helpers - // -------------------------------------------------------------------------- - - private normalizeProjectAlias(alias: string | undefined): string { - return String(alias || "") - .trim() - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); - } - - private normalizeRelativePath(pathInput: string | undefined): string { - const normalized = String(pathInput || "") - .trim() - .replace(/\\/g, "/") - .replace(/^\.\//, "") - .replace(/\/+/g, "/"); - return normalized; - } - - private makeScopedId(projectId: string, relativePath: string): string { - return `${projectId}::${relativePath}`; - } - - private parseChecksumMap(raw: string | null | undefined): Record { - if (!raw) return {}; - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; - const out: Record = {}; - for (const [key, value] of Object.entries(parsed as Record)) { - if (typeof key === "string" && typeof value === "string") { - out[key] = value; - } - } - return out; - } catch { - return {}; - } - } - - private insertIndexRun( - scopeUserId: string, - scopeAgentId: string, - input: { - run_id: string; - project_id: string; - index_profile: string; - trigger_type: string; - state: string; - started_at: string; - finished_at: string | null; - error_message: string | null; - }, - ): void { - const stmt = this.db.prepare( - `INSERT INTO index_runs ( + ); + const recentIndexRuns = ( + runStmt.all(scopeUserId, scopeAgentId, projectId) as Array + ).map((row) => ({ + run_id: String(row.run_id), + trigger_type: String(row.trigger_type), + state: String(row.state), + started_at: String(row.started_at), + finished_at: row.finished_at ? String(row.finished_at) : null, + })); + + return { + project, + aliases, + registration, + tracker_mappings: trackerMappings, + recent_files: recentFiles, + recent_symbols: recentSymbols, + recent_tasks: recentTasks, + recent_index_runs: recentIndexRuns, + }; + } + + runLegacyCompatibilityBackfill( + scopeUserId: string, + scopeAgentId: string, + input: ProjectLegacyBackfillInput = {}, + ): ProjectLegacyBackfillResult { + const mode = input.mode || "dry_run"; + const source = input.source || "mixed"; + const now = new Date().toISOString(); + + const onlyProjectIds = new Set( + this.normalizeStringArray(input.only_project_ids), + ); + const onlyAliases = new Set( + this.normalizeStringArray(input.only_aliases).map((a) => + this.normalizeProjectAlias(a), + ), + ); + + const projects = this.listProjects(scopeUserId, scopeAgentId); + const selected = projects.filter((row) => { + if ( + onlyProjectIds.size > 0 && + !onlyProjectIds.has(row.project.project_id) + ) + return false; + if (onlyAliases.size > 0) { + const aliases = row.aliases.map((a) => + this.normalizeProjectAlias(a.project_alias), + ); + if (!aliases.some((a) => onlyAliases.has(a))) return false; + } + return true; + }); + + let updatedAliases = 0; + let updatedMappings = 0; + let updatedRegistrations = 0; + let migrationStateUpserts = 0; + + const items: ProjectLegacyBackfillItem[] = []; + + for (const row of selected) { + const project = row.project; + const warnings: string[] = []; + const actions: string[] = []; + + const existingAliases = new Set( + row.aliases + .map((a) => this.normalizeProjectAlias(a.project_alias)) + .filter(Boolean), + ); + + const inferredAliases = this.inferBackfillAliases( + project, + existingAliases, + source, + ); + const inferredMappings = this.inferBackfillTrackerMappings( + scopeUserId, + scopeAgentId, + project.project_id, + project, + source, + ); + + if (mode === "apply") { + for (const alias of inferredAliases) { + if (existingAliases.has(alias)) continue; + this.upsertProjectAlias( + scopeUserId, + scopeAgentId, + project.project_id, + alias, + false, + now, + false, + ); + existingAliases.add(alias); + updatedAliases += 1; + actions.push(`alias.backfilled:${alias}`); + } + + for (const mapping of inferredMappings) { + const existing = this.getProjectTrackerMapping( + scopeUserId, + scopeAgentId, + project.project_id, + mapping.tracker_type, + ); + if ( + existing && + existing.tracker_space_key === mapping.tracker_space_key && + existing.default_epic_key === mapping.default_epic_key && + existing.tracker_project_id === mapping.tracker_project_id + ) { + continue; + } + + this.setProjectTrackerMapping(scopeUserId, scopeAgentId, { + project_id: project.project_id, + tracker_type: mapping.tracker_type, + tracker_space_key: mapping.tracker_space_key || undefined, + tracker_project_id: mapping.tracker_project_id || undefined, + default_epic_key: mapping.default_epic_key || undefined, + }); + updatedMappings += 1; + actions.push(`tracker.backfilled:${mapping.tracker_type}`); + } + } + + const primaryAlias = this.pickPrimaryAlias(existingAliases, project); + const completeness = this.computeRegistrationCompleteness( + project, + primaryAlias, + ); + const missingFields = this.computeMissingRegistrationFields( + project, + primaryAlias, + ); + const hasTracker = + inferredMappings.length > 0 || + Boolean( + this.getProjectTrackerMapping( + scopeUserId, + scopeAgentId, + project.project_id, + "jira", + ), + ); + const status: "registered" | "validated" = + hasTracker && completeness >= 90 ? "validated" : "registered"; + const validationStatus: "ok" | "warn" = + missingFields.length === 0 ? "ok" : "warn"; + + const existingRegistration = this.getProjectRegistrationState( + scopeUserId, + scopeAgentId, + project.project_id, + ); + const shouldUpdateRegistration = + !existingRegistration || + input.force_registration_state === true || + existingRegistration.registration_status === "draft" || + existingRegistration.validation_status !== validationStatus; + + if (shouldUpdateRegistration) { + if (mode === "apply") { + this.upsertProjectRegistrationState(scopeUserId, scopeAgentId, { + project_id: project.project_id, + registration_status: status, + validation_status: validationStatus, + validation_notes: `legacy_backfill:${source}`, + completeness_score: completeness, + missing_required_fields: missingFields, + last_validated_at: now, + }); + updatedRegistrations += 1; + actions.push("registration.backfilled"); + } + } + + if (mode === "apply") { + this.upsertMigrationState(scopeUserId, scopeAgentId, { + migration_id: `legacy-backfill:${project.project_id}`, + schema_from: "legacy", + schema_to: "5.1", + applied_at: now, + status: "migrated", + notes: JSON.stringify({ + source, + alias_count: inferredAliases.length, + tracker_count: inferredMappings.length, + }), + }); + migrationStateUpserts += 1; + } + + if (inferredAliases.length === 0) { + warnings.push("no additional alias inferred"); + } + if (inferredMappings.length === 0) { + warnings.push("no tracker mapping inferred"); + } + + items.push({ + project_id: project.project_id, + project_name: project.project_name, + inferred_aliases: inferredAliases, + inferred_tracker_mappings: inferredMappings, + actions, + warnings, + }); + } + + return { + mode, + source, + scanned_projects: projects.length, + candidates: selected.length, + updated_aliases: updatedAliases, + updated_tracker_mappings: updatedMappings, + updated_registration_states: updatedRegistrations, + migration_state_upserts: migrationStateUpserts, + items, + }; + } + + // -------------------------------------------------------------------------- + // Helpers + // -------------------------------------------------------------------------- + + private normalizeProjectAlias(alias: string | undefined): string { + return String(alias || "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + } + + private normalizeRelativePath(pathInput: string | undefined): string { + const normalized = String(pathInput || "") + .trim() + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/\/+/g, "/"); + return normalized; + } + + private makeScopedId(projectId: string, relativePath: string): string { + return `${projectId}::${relativePath}`; + } + + private parseChecksumMap( + raw: string | null | undefined, + ): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return {}; + const out: Record = {}; + for (const [key, value] of Object.entries( + parsed as Record, + )) { + if (typeof key === "string" && typeof value === "string") { + out[key] = value; + } + } + return out; + } catch { + return {}; + } + } + + private insertIndexRun( + scopeUserId: string, + scopeAgentId: string, + input: { + run_id: string; + project_id: string; + index_profile: string; + trigger_type: string; + state: string; + started_at: string; + finished_at: string | null; + error_message: string | null; + }, + ): void { + const stmt = this.db.prepare( + `INSERT INTO index_runs ( run_id, scope_user_id, scope_agent_id, project_id, index_profile, trigger_type, state, started_at, finished_at, error_message ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - - stmt.run( - input.run_id, - scopeUserId, - scopeAgentId, - input.project_id, - input.index_profile, - input.trigger_type, - input.state, - input.started_at, - input.finished_at, - input.error_message, - ); - } - - private finishIndexRun( - scopeUserId: string, - scopeAgentId: string, - runId: string, - state: "indexed" | "error", - errorMessage: string | null, - finishedAt: string, - ): void { - const stmt = this.db.prepare( - `UPDATE index_runs + ); + + stmt.run( + input.run_id, + scopeUserId, + scopeAgentId, + input.project_id, + input.index_profile, + input.trigger_type, + input.state, + input.started_at, + input.finished_at, + input.error_message, + ); + } + + private finishIndexRun( + scopeUserId: string, + scopeAgentId: string, + runId: string, + state: "indexed" | "error", + errorMessage: string | null, + finishedAt: string, + ): void { + const stmt = this.db.prepare( + `UPDATE index_runs SET state = ?, finished_at = ?, error_message = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND run_id = ?`, - ); - stmt.run(state, finishedAt, errorMessage, scopeUserId, scopeAgentId, runId); - } - - private upsertFileIndexState( - scopeUserId: string, - scopeAgentId: string, - input: { - file_id: string; - project_id: string; - relative_path: string; - module: string | null; - language: string | null; - checksum: string; - last_commit_sha: string | null; - index_state: string; - active: number; - tombstone_at: string | null; - indexed_at: string | null; - }, - ): void { - const existingStmt = this.db.prepare( - `SELECT file_id FROM file_index_state + ); + stmt.run(state, finishedAt, errorMessage, scopeUserId, scopeAgentId, runId); + } + + private upsertFileIndexState( + scopeUserId: string, + scopeAgentId: string, + input: { + file_id: string; + project_id: string; + relative_path: string; + module: string | null; + language: string | null; + checksum: string; + last_commit_sha: string | null; + index_state: string; + active: number; + tombstone_at: string | null; + indexed_at: string | null; + }, + ): void { + const existingStmt = this.db.prepare( + `SELECT file_id FROM file_index_state WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND relative_path = ?`, - ); - const existing = existingStmt.get( - scopeUserId, - scopeAgentId, - input.project_id, - input.relative_path, - ) as { file_id: string } | undefined; - - if (existing) { - const updateStmt = this.db.prepare( - `UPDATE file_index_state + ); + const existing = existingStmt.get( + scopeUserId, + scopeAgentId, + input.project_id, + input.relative_path, + ) as { file_id: string } | undefined; + + if (existing) { + const updateStmt = this.db.prepare( + `UPDATE file_index_state SET module = ?, language = ?, checksum = ?, last_commit_sha = ?, index_state = ?, active = ?, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND file_id = ?`, - ); - updateStmt.run( - input.module, - input.language, - input.checksum, - input.last_commit_sha, - input.index_state, - input.active, - input.tombstone_at, - input.indexed_at, - scopeUserId, - scopeAgentId, - existing.file_id, - ); - return; - } - - const insertStmt = this.db.prepare( - `INSERT INTO file_index_state ( + ); + updateStmt.run( + input.module, + input.language, + input.checksum, + input.last_commit_sha, + input.index_state, + input.active, + input.tombstone_at, + input.indexed_at, + scopeUserId, + scopeAgentId, + existing.file_id, + ); + return; + } + + const insertStmt = this.db.prepare( + `INSERT INTO file_index_state ( file_id, scope_user_id, scope_agent_id, project_id, relative_path, module, language, checksum, last_commit_sha, index_state, active, tombstone_at, indexed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - - insertStmt.run( - input.file_id, - scopeUserId, - scopeAgentId, - input.project_id, - input.relative_path, - input.module, - input.language, - input.checksum, - input.last_commit_sha, - input.index_state, - input.active, - input.tombstone_at, - input.indexed_at, - ); - } - - private markFileIndexStateDeleted( - scopeUserId: string, - scopeAgentId: string, - projectId: string, - relativePath: string, - tombstoneAt: string, - ): void { - const stmt = this.db.prepare( - `UPDATE file_index_state + ); + + insertStmt.run( + input.file_id, + scopeUserId, + scopeAgentId, + input.project_id, + input.relative_path, + input.module, + input.language, + input.checksum, + input.last_commit_sha, + input.index_state, + input.active, + input.tombstone_at, + input.indexed_at, + ); + } + + private markFileIndexStateDeleted( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + relativePath: string, + tombstoneAt: string, + ): void { + const stmt = this.db.prepare( + `UPDATE file_index_state SET index_state = 'stale', active = 0, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND relative_path = ?`, - ); - stmt.run(tombstoneAt, tombstoneAt, scopeUserId, scopeAgentId, projectId, relativePath); - } - - private upsertChunkRegistry(scopeUserId: string, scopeAgentId: string, input: ProjectChunkUpsertInput): void { - const existing = this.db.prepare( - `SELECT chunk_id FROM chunk_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND chunk_id = ?`, - ).get(scopeUserId, scopeAgentId, input.chunk_id) as { chunk_id: string } | undefined; - - if (existing) { - this.db.prepare( - `UPDATE chunk_registry + ); + stmt.run( + tombstoneAt, + tombstoneAt, + scopeUserId, + scopeAgentId, + projectId, + relativePath, + ); + } + + private upsertChunkRegistry( + scopeUserId: string, + scopeAgentId: string, + input: ProjectChunkUpsertInput, + ): void { + const existing = this.db + .prepare( + `SELECT chunk_id FROM chunk_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND chunk_id = ?`, + ) + .get(scopeUserId, scopeAgentId, input.chunk_id) as + | { chunk_id: string } + | undefined; + + if (existing) { + this.db + .prepare( + `UPDATE chunk_registry SET project_id = ?, file_id = ?, relative_path = ?, chunk_kind = ?, symbol_id = ?, task_id = ?, checksum = ?, qdrant_point_id = ?, index_state = ?, active = ?, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND chunk_id = ?`, - ).run( - input.project_id, input.file_id, input.relative_path, input.chunk_kind, input.symbol_id, input.task_id || null, - input.checksum, input.qdrant_point_id || null, input.index_state, input.active, input.tombstone_at, input.indexed_at, - scopeUserId, scopeAgentId, input.chunk_id, - ); - return; - } - - this.db.prepare( - `INSERT INTO chunk_registry ( + ) + .run( + input.project_id, + input.file_id, + input.relative_path, + input.chunk_kind, + input.symbol_id, + input.task_id || null, + input.checksum, + input.qdrant_point_id || null, + input.index_state, + input.active, + input.tombstone_at, + input.indexed_at, + scopeUserId, + scopeAgentId, + input.chunk_id, + ); + return; + } + + this.db + .prepare( + `INSERT INTO chunk_registry ( chunk_id, scope_user_id, scope_agent_id, project_id, file_id, relative_path, chunk_kind, symbol_id, task_id, checksum, qdrant_point_id, index_state, active, tombstone_at, indexed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - input.chunk_id, scopeUserId, scopeAgentId, input.project_id, input.file_id, input.relative_path, input.chunk_kind, input.symbol_id, - input.task_id || null, input.checksum, input.qdrant_point_id || null, input.index_state, input.active, input.tombstone_at, input.indexed_at, - ); - } - - private upsertSymbolRegistry(scopeUserId: string, scopeAgentId: string, input: ProjectSymbolUpsertInput): void { - const existing = this.db.prepare( - `SELECT symbol_id FROM symbol_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND symbol_id = ?`, - ).get(scopeUserId, scopeAgentId, input.symbol_id) as { symbol_id: string } | undefined; - - if (existing) { - this.db.prepare( - `UPDATE symbol_registry + ) + .run( + input.chunk_id, + scopeUserId, + scopeAgentId, + input.project_id, + input.file_id, + input.relative_path, + input.chunk_kind, + input.symbol_id, + input.task_id || null, + input.checksum, + input.qdrant_point_id || null, + input.index_state, + input.active, + input.tombstone_at, + input.indexed_at, + ); + } + + private upsertSymbolRegistry( + scopeUserId: string, + scopeAgentId: string, + input: ProjectSymbolUpsertInput, + ): void { + const existing = this.db + .prepare( + `SELECT symbol_id FROM symbol_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND symbol_id = ?`, + ) + .get(scopeUserId, scopeAgentId, input.symbol_id) as + | { symbol_id: string } + | undefined; + + if (existing) { + this.db + .prepare( + `UPDATE symbol_registry SET project_id = ?, relative_path = ?, module = ?, language = ?, symbol_name = ?, symbol_fqn = ?, symbol_kind = ?, signature_hash = ?, index_state = ?, active = ?, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND symbol_id = ?`, - ).run( - input.project_id, input.relative_path, input.module, input.language, input.symbol_name, input.symbol_fqn, input.symbol_kind, - input.signature_hash || null, input.index_state, input.active, input.tombstone_at, input.indexed_at, - scopeUserId, scopeAgentId, input.symbol_id, - ); - return; - } - - this.db.prepare( - `INSERT INTO symbol_registry ( + ) + .run( + input.project_id, + input.relative_path, + input.module, + input.language, + input.symbol_name, + input.symbol_fqn, + input.symbol_kind, + input.signature_hash || null, + input.index_state, + input.active, + input.tombstone_at, + input.indexed_at, + scopeUserId, + scopeAgentId, + input.symbol_id, + ); + return; + } + + this.db + .prepare( + `INSERT INTO symbol_registry ( symbol_id, scope_user_id, scope_agent_id, project_id, relative_path, module, language, symbol_name, symbol_fqn, symbol_kind, signature_hash, index_state, active, tombstone_at, indexed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - input.symbol_id, scopeUserId, scopeAgentId, input.project_id, input.relative_path, input.module, input.language, - input.symbol_name, input.symbol_fqn, input.symbol_kind, input.signature_hash || null, input.index_state, input.active, input.tombstone_at, input.indexed_at, - ); - } - - private markProjectChunksByFileDeleted(scopeUserId: string, scopeAgentId: string, projectId: string, relativePath: string, tombstoneAt: string): void { - this.db.prepare( - `UPDATE chunk_registry + ) + .run( + input.symbol_id, + scopeUserId, + scopeAgentId, + input.project_id, + input.relative_path, + input.module, + input.language, + input.symbol_name, + input.symbol_fqn, + input.symbol_kind, + input.signature_hash || null, + input.index_state, + input.active, + input.tombstone_at, + input.indexed_at, + ); + } + + private markProjectChunksByFileDeleted( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + relativePath: string, + tombstoneAt: string, + ): void { + this.db + .prepare( + `UPDATE chunk_registry SET index_state = 'stale', active = 0, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND relative_path = ?`, - ).run(tombstoneAt, tombstoneAt, scopeUserId, scopeAgentId, projectId, relativePath); - } - - private markProjectSymbolsByFileDeleted(scopeUserId: string, scopeAgentId: string, projectId: string, relativePath: string, tombstoneAt: string): void { - this.db.prepare( - `UPDATE symbol_registry + ) + .run( + tombstoneAt, + tombstoneAt, + scopeUserId, + scopeAgentId, + projectId, + relativePath, + ); + } + + private markProjectSymbolsByFileDeleted( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + relativePath: string, + tombstoneAt: string, + ): void { + this.db + .prepare( + `UPDATE symbol_registry SET index_state = 'stale', active = 0, tombstone_at = ?, indexed_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND relative_path = ?`, - ).run(tombstoneAt, tombstoneAt, scopeUserId, scopeAgentId, projectId, relativePath); - } - - markProjectFileDeletedForEvent(scopeUserId: string, scopeAgentId: string, projectId: string, relativePath: string, tombstoneAt: string): void { - this.markFileIndexStateDeleted(scopeUserId, scopeAgentId, projectId, relativePath, tombstoneAt); - this.markProjectChunksByFileDeleted(scopeUserId, scopeAgentId, projectId, relativePath, tombstoneAt); - this.markProjectSymbolsByFileDeleted(scopeUserId, scopeAgentId, projectId, relativePath, tombstoneAt); - } - - private upsertProjectIndexWatchState( - scopeUserId: string, - scopeAgentId: string, - input: { - project_id: string; - last_source_rev: string | null; - last_checksum_snapshot: Record; - updated_at: string; - }, - ): void { - const existing = this.getProjectIndexWatchState(scopeUserId, scopeAgentId, input.project_id); - const checksumJson = JSON.stringify(input.last_checksum_snapshot || {}); - - if (existing) { - const stmt = this.db.prepare( - `UPDATE project_index_watch_state + ) + .run( + tombstoneAt, + tombstoneAt, + scopeUserId, + scopeAgentId, + projectId, + relativePath, + ); + } + + markProjectFileDeletedForEvent( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + relativePath: string, + tombstoneAt: string, + ): void { + this.markFileIndexStateDeleted( + scopeUserId, + scopeAgentId, + projectId, + relativePath, + tombstoneAt, + ); + this.markProjectChunksByFileDeleted( + scopeUserId, + scopeAgentId, + projectId, + relativePath, + tombstoneAt, + ); + this.markProjectSymbolsByFileDeleted( + scopeUserId, + scopeAgentId, + projectId, + relativePath, + tombstoneAt, + ); + } + + private upsertProjectIndexWatchState( + scopeUserId: string, + scopeAgentId: string, + input: { + project_id: string; + last_source_rev: string | null; + last_checksum_snapshot: Record; + updated_at: string; + }, + ): void { + const existing = this.getProjectIndexWatchState( + scopeUserId, + scopeAgentId, + input.project_id, + ); + const checksumJson = JSON.stringify(input.last_checksum_snapshot || {}); + + if (existing) { + const stmt = this.db.prepare( + `UPDATE project_index_watch_state SET last_source_rev = ?, last_checksum_snapshot = ?, updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ); - stmt.run( - input.last_source_rev, - checksumJson, - input.updated_at, - scopeUserId, - scopeAgentId, - input.project_id, - ); - return; - } - - const stmt = this.db.prepare( - `INSERT INTO project_index_watch_state ( + ); + stmt.run( + input.last_source_rev, + checksumJson, + input.updated_at, + scopeUserId, + scopeAgentId, + input.project_id, + ); + return; + } + + const stmt = this.db.prepare( + `INSERT INTO project_index_watch_state ( project_id, scope_user_id, scope_agent_id, last_source_rev, last_checksum_snapshot, updated_at ) VALUES (?, ?, ?, ?, ?, ?)`, - ); - stmt.run( - input.project_id, - scopeUserId, - scopeAgentId, - input.last_source_rev, - checksumJson, - input.updated_at, - ); - } - - private normalizeProjectId(projectId?: string): string { - return String(projectId || "").trim(); - } - - private normalizeProjectName(projectName?: string): string { - return String(projectName || "").trim(); - } - - private normalizeRepoRoot(repoRoot?: string): string | null { - const normalized = String(repoRoot || "").trim(); - return normalized || null; - } - - private normalizeRepoRemote(repoRemote?: string): string | null { - const normalized = String(repoRemote || "").trim(); - return normalized || null; - } - - private parseJsonArrayField(raw: string | null | undefined): string[] { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.map((item) => String(item)) : []; - } catch { - return []; - } - } - - private normalizeStringArray(input?: string[] | null): string[] { - if (!Array.isArray(input)) return []; - return this.uniqueSorted( - input - .map((item) => String(item || "").trim()) - .filter(Boolean), - ); - } - - private uniqueSorted(values: string[]): string[] { - return Array.from(new Set(values.map((v) => String(v || "").trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b)); - } - - private inferBackfillAliases( - project: ProjectRecord, - existingAliases: Set, - source: "repo_root" | "repo_remote" | "task_registry" | "mixed", - ): string[] { - const candidates = new Set(); - - if (source === "repo_root" || source === "mixed") { - if (project.repo_root) { - const parts = project.repo_root.replace(/\\/g, "/").split("/").filter(Boolean); - const leaf = parts[parts.length - 1] || ""; - const normalized = this.normalizeProjectAlias(leaf); - if (normalized) candidates.add(normalized); - } - } - - if (source === "repo_remote" || source === "mixed") { - if (project.repo_remote_primary) { - const remote = String(project.repo_remote_primary); - const m = remote.match(/[:\/]([^/]+?)\.git$/i) || remote.match(/[:\/]([^/]+?)$/i); - const repoName = m?.[1] || ""; - const normalized = this.normalizeProjectAlias(repoName); - if (normalized) candidates.add(normalized); - } - } - - const filtered = Array.from(candidates).filter((a) => !existingAliases.has(a)); - return filtered.sort((a, b) => a.localeCompare(b)); - } - - private inferBackfillTrackerMappings( - scopeUserId: string, - scopeAgentId: string, - projectId: string, - project: ProjectRecord, - source: "repo_root" | "repo_remote" | "task_registry" | "mixed", - ): Array<{ - tracker_type: "jira" | "github" | "other"; - tracker_space_key: string | null; - tracker_project_id: string | null; - default_epic_key: string | null; - confidence: number; - source: "repo_remote" | "task_registry"; - }> { - const result: Array<{ - tracker_type: "jira" | "github" | "other"; - tracker_space_key: string | null; - tracker_project_id: string | null; - default_epic_key: string | null; - confidence: number; - source: "repo_remote" | "task_registry"; - }> = []; - - if (source === "repo_remote" || source === "mixed") { - const remote = String(project.repo_remote_primary || "").trim(); - if (remote.includes("github.com")) { - result.push({ - tracker_type: "github", - tracker_space_key: null, - tracker_project_id: null, - default_epic_key: null, - confidence: 0.7, - source: "repo_remote", - }); - } - } - - if (source === "task_registry" || source === "mixed") { - const stmt = this.db.prepare( - `SELECT tracker_issue_key FROM task_registry + ); + stmt.run( + input.project_id, + scopeUserId, + scopeAgentId, + input.last_source_rev, + checksumJson, + input.updated_at, + ); + } + + private normalizeProjectId(projectId?: string): string { + return String(projectId || "").trim(); + } + + private normalizeProjectName(projectName?: string): string { + return String(projectName || "").trim(); + } + + private normalizeRepoRoot(repoRoot?: string): string | null { + const normalized = String(repoRoot || "").trim(); + return normalized || null; + } + + private normalizeRepoRemote(repoRemote?: string): string | null { + const normalized = String(repoRemote || "").trim(); + return normalized || null; + } + + private parseJsonArrayField(raw: string | null | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map((item) => String(item)) : []; + } catch { + return []; + } + } + + private normalizeStringArray(input?: string[] | null): string[] { + if (!Array.isArray(input)) return []; + return this.uniqueSorted( + input.map((item) => String(item || "").trim()).filter(Boolean), + ); + } + + private uniqueSorted(values: string[]): string[] { + return Array.from( + new Set(values.map((v) => String(v || "").trim()).filter(Boolean)), + ).sort((a, b) => a.localeCompare(b)); + } + + private inferBackfillAliases( + project: ProjectRecord, + existingAliases: Set, + source: "repo_root" | "repo_remote" | "task_registry" | "mixed", + ): string[] { + const candidates = new Set(); + + if (source === "repo_root" || source === "mixed") { + if (project.repo_root) { + const parts = project.repo_root + .replace(/\\/g, "/") + .split("/") + .filter(Boolean); + const leaf = parts[parts.length - 1] || ""; + const normalized = this.normalizeProjectAlias(leaf); + if (normalized) candidates.add(normalized); + } + } + + if (source === "repo_remote" || source === "mixed") { + if (project.repo_remote_primary) { + const remote = String(project.repo_remote_primary); + const m = + remote.match(/[:/]([^/]+?)\.git$/i) || remote.match(/[:/]([^/]+?)$/i); + const repoName = m?.[1] || ""; + const normalized = this.normalizeProjectAlias(repoName); + if (normalized) candidates.add(normalized); + } + } + + const filtered = Array.from(candidates).filter( + (a) => !existingAliases.has(a), + ); + return filtered.sort((a, b) => a.localeCompare(b)); + } + + private inferBackfillTrackerMappings( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + project: ProjectRecord, + source: "repo_root" | "repo_remote" | "task_registry" | "mixed", + ): Array<{ + tracker_type: "jira" | "github" | "other"; + tracker_space_key: string | null; + tracker_project_id: string | null; + default_epic_key: string | null; + confidence: number; + source: "repo_remote" | "task_registry"; + }> { + const result: Array<{ + tracker_type: "jira" | "github" | "other"; + tracker_space_key: string | null; + tracker_project_id: string | null; + default_epic_key: string | null; + confidence: number; + source: "repo_remote" | "task_registry"; + }> = []; + + if (source === "repo_remote" || source === "mixed") { + const remote = String(project.repo_remote_primary || "").trim(); + if (remote.includes("github.com")) { + result.push({ + tracker_type: "github", + tracker_space_key: null, + tracker_project_id: null, + default_epic_key: null, + confidence: 0.7, + source: "repo_remote", + }); + } + } + + if (source === "task_registry" || source === "mixed") { + const stmt = this.db.prepare( + `SELECT tracker_issue_key FROM task_registry WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ? AND tracker_issue_key IS NOT NULL AND tracker_issue_key != '' ORDER BY updated_at DESC LIMIT 200`, - ); - const rows = stmt.all(scopeUserId, scopeAgentId, projectId) as Array<{ tracker_issue_key: string | null }>; - const keys = rows.map((r) => String(r.tracker_issue_key || "").trim()).filter(Boolean); - const jiraLike = keys - .map((key) => key.match(/^([A-Z][A-Z0-9_]+)-\d+$/)?.[1] || null) - .filter((x): x is string => Boolean(x)); - if (jiraLike.length > 0) { - const counts = new Map(); - for (const p of jiraLike) counts.set(p, (counts.get(p) || 0) + 1); - const top = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]; - if (top) { - result.push({ - tracker_type: "jira", - tracker_space_key: top[0], - tracker_project_id: null, - default_epic_key: `${top[0]}-1`, - confidence: Math.min(0.95, 0.55 + top[1] * 0.03), - source: "task_registry", - }); - } - } - } - - const dedup = new Map(); - for (const item of result) { - const key = `${item.tracker_type}:${item.tracker_space_key || ""}:${item.default_epic_key || ""}`; - const prev = dedup.get(key); - if (!prev || item.confidence > prev.confidence) dedup.set(key, item); - } - - return Array.from(dedup.values()).sort((a, b) => b.confidence - a.confidence); - } - - private pickPrimaryAlias(existingAliases: Set, project: ProjectRecord): string { - const aliases = Array.from(existingAliases).filter(Boolean).sort((a, b) => a.localeCompare(b)); - if (aliases.length > 0) return aliases[0]; - - const fromName = this.normalizeProjectAlias(project.project_name); - if (fromName) return fromName; - - return this.normalizeProjectAlias(project.project_id) || "project"; - } - - private upsertMigrationState( - scopeUserId: string, - scopeAgentId: string, - input: { - migration_id: string; - schema_from: string; - schema_to: string; - applied_at: string; - status: string; - notes?: string | null; - }, - ): void { - const existingStmt = this.db.prepare( - `SELECT migration_id FROM migration_state + ); + const rows = stmt.all(scopeUserId, scopeAgentId, projectId) as Array<{ + tracker_issue_key: string | null; + }>; + const keys = rows + .map((r) => String(r.tracker_issue_key || "").trim()) + .filter(Boolean); + const jiraLike = keys + .map((key) => key.match(/^([A-Z][A-Z0-9_]+)-\d+$/)?.[1] || null) + .filter((x): x is string => Boolean(x)); + if (jiraLike.length > 0) { + const counts = new Map(); + for (const p of jiraLike) counts.set(p, (counts.get(p) || 0) + 1); + const top = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]; + if (top) { + result.push({ + tracker_type: "jira", + tracker_space_key: top[0], + tracker_project_id: null, + default_epic_key: `${top[0]}-1`, + confidence: Math.min(0.95, 0.55 + top[1] * 0.03), + source: "task_registry", + }); + } + } + } + + const dedup = new Map(); + for (const item of result) { + const key = `${item.tracker_type}:${item.tracker_space_key || ""}:${item.default_epic_key || ""}`; + const prev = dedup.get(key); + if (!prev || item.confidence > prev.confidence) dedup.set(key, item); + } + + return Array.from(dedup.values()).sort( + (a, b) => b.confidence - a.confidence, + ); + } + + private pickPrimaryAlias( + existingAliases: Set, + project: ProjectRecord, + ): string { + const aliases = Array.from(existingAliases) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + if (aliases.length > 0) return aliases[0]; + + const fromName = this.normalizeProjectAlias(project.project_name); + if (fromName) return fromName; + + return this.normalizeProjectAlias(project.project_id) || "project"; + } + + private upsertMigrationState( + scopeUserId: string, + scopeAgentId: string, + input: { + migration_id: string; + schema_from: string; + schema_to: string; + applied_at: string; + status: string; + notes?: string | null; + }, + ): void { + const existingStmt = this.db.prepare( + `SELECT migration_id FROM migration_state WHERE scope_user_id = ? AND scope_agent_id = ? AND migration_id = ?`, - ); - const existing = existingStmt.get(scopeUserId, scopeAgentId, input.migration_id) as { migration_id: string } | undefined; - - if (existing) { - const updateStmt = this.db.prepare( - `UPDATE migration_state + ); + const existing = existingStmt.get( + scopeUserId, + scopeAgentId, + input.migration_id, + ) as { migration_id: string } | undefined; + + if (existing) { + const updateStmt = this.db.prepare( + `UPDATE migration_state SET schema_from = ?, schema_to = ?, applied_at = ?, status = ?, notes = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND migration_id = ?`, - ); - updateStmt.run( - input.schema_from, - input.schema_to, - input.applied_at, - input.status, - input.notes || null, - scopeUserId, - scopeAgentId, - input.migration_id, - ); - return; - } - - const insertStmt = this.db.prepare( - `INSERT INTO migration_state ( + ); + updateStmt.run( + input.schema_from, + input.schema_to, + input.applied_at, + input.status, + input.notes || null, + scopeUserId, + scopeAgentId, + input.migration_id, + ); + return; + } + + const insertStmt = this.db.prepare( + `INSERT INTO migration_state ( migration_id, scope_user_id, scope_agent_id, schema_from, schema_to, applied_at, status, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ); - insertStmt.run( - input.migration_id, - scopeUserId, - scopeAgentId, - input.schema_from, - input.schema_to, - input.applied_at, - input.status, - input.notes || null, - ); - } - - private rowToTaskRecord(row: { - task_id: string; - scope_user_id: string; - scope_agent_id: string; - project_id: string; - task_title: string; - task_type: string | null; - task_status: string | null; - parent_task_id: string | null; - related_task_ids: string | null; - files_touched: string | null; - symbols_touched: string | null; - commit_refs: string | null; - diff_refs: string | null; - decision_notes: string | null; - tracker_issue_key: string | null; - updated_at: string; - }): TaskRegistryRecord { - return { - task_id: row.task_id, - scope_user_id: row.scope_user_id, - scope_agent_id: row.scope_agent_id, - project_id: row.project_id, - task_title: row.task_title, - task_type: row.task_type, - task_status: row.task_status, - parent_task_id: row.parent_task_id, - related_task_ids: this.parseJsonArrayField(row.related_task_ids), - files_touched: this.parseJsonArrayField(row.files_touched), - symbols_touched: this.parseJsonArrayField(row.symbols_touched), - commit_refs: this.parseJsonArrayField(row.commit_refs), - diff_refs: this.parseJsonArrayField(row.diff_refs), - decision_notes: row.decision_notes, - tracker_issue_key: row.tracker_issue_key, - updated_at: row.updated_at, - }; - } - - private computeMissingRegistrationFields(project: ProjectRecord, alias: string): string[] { - const missing: string[] = []; - if (!project.project_id) missing.push("project_id"); - if (!alias) missing.push("project_alias"); - if (!project.project_name) missing.push("project_name"); - return missing; - } - - private computeRegistrationCompleteness(project: ProjectRecord, alias: string): number { - const requiredTotal = 3; - const requiredPresent = [project.project_id, alias, project.project_name].filter(Boolean).length; - const optionalTotal = 3; - const optionalPresent = [project.repo_root, project.repo_remote_primary, project.active_version].filter(Boolean).length; - return Math.round((requiredPresent / requiredTotal) * 80 + (optionalPresent / optionalTotal) * 20); - } - - private upsertProjectAlias( - scopeUserId: string, - scopeAgentId: string, - projectId: string, - projectAlias: string, - isPrimary: boolean, - now: string, - allowAliasUpdate: boolean, - ): void { - const existingAlias = this.getProjectAlias(scopeUserId, scopeAgentId, projectAlias); - if (existingAlias) { - if (existingAlias.project_id !== projectId && !allowAliasUpdate) { - throw new Error(`project_alias \"${projectAlias}\" is already mapped to another project_id`); - } - const stmt = this.db.prepare(`UPDATE project_aliases SET project_id = ?, is_primary = ?, updated_at = ? WHERE id = ?`); - stmt.run(projectId, isPrimary ? 1 : 0, now, existingAlias.id); - return; - } - - const insertStmt = this.db.prepare( - `INSERT INTO project_aliases (id, project_id, scope_user_id, scope_agent_id, project_alias, is_primary, created_at, updated_at) + ); + insertStmt.run( + input.migration_id, + scopeUserId, + scopeAgentId, + input.schema_from, + input.schema_to, + input.applied_at, + input.status, + input.notes || null, + ); + } + + public recordMigrationState( + scopeUserId: string, + scopeAgentId: string, + input: { + migration_id: string; + schema_from: string; + schema_to: string; + applied_at: string; + status: string; + notes?: string | null; + }, + ): void { + this.upsertMigrationState(scopeUserId, scopeAgentId, input); + } + + public getMigrationState( + scopeUserId: string, + scopeAgentId: string, + migrationId: string, + ): { + migration_id: string; + schema_from: string; + schema_to: string; + applied_at: string; + status: string; + notes: string | null; + } | null { + const stmt = this.db.prepare( + `SELECT migration_id, schema_from, schema_to, applied_at, status, notes + FROM migration_state + WHERE scope_user_id = ? AND scope_agent_id = ? AND migration_id = ?`, + ); + const row = stmt.get(scopeUserId, scopeAgentId, migrationId) as + | { + migration_id: string; + schema_from: string; + schema_to: string; + applied_at: string; + status: string; + notes: string | null; + } + | undefined; + return row || null; + } + + public getMigrationStates( + scopeUserId: string, + scopeAgentId: string, + ): Array<{ + migration_id: string; + schema_from: string; + schema_to: string; + applied_at: string; + status: string; + notes: string | null; + }> { + const stmt = this.db.prepare( + `SELECT migration_id, schema_from, schema_to, applied_at, status, notes + FROM migration_state + WHERE scope_user_id = ? AND scope_agent_id = ? + ORDER BY applied_at DESC`, + ); + return stmt.all(scopeUserId, scopeAgentId) as Array<{ + migration_id: string; + schema_from: string; + schema_to: string; + applied_at: string; + status: string; + notes: string | null; + }>; + } + + private rowToTaskRecord(row: { + task_id: string; + scope_user_id: string; + scope_agent_id: string; + project_id: string; + task_title: string; + task_type: string | null; + task_status: string | null; + parent_task_id: string | null; + related_task_ids: string | null; + files_touched: string | null; + symbols_touched: string | null; + commit_refs: string | null; + diff_refs: string | null; + decision_notes: string | null; + tracker_issue_key: string | null; + updated_at: string; + }): TaskRegistryRecord { + return { + task_id: row.task_id, + scope_user_id: row.scope_user_id, + scope_agent_id: row.scope_agent_id, + project_id: row.project_id, + task_title: row.task_title, + task_type: row.task_type, + task_status: row.task_status, + parent_task_id: row.parent_task_id, + related_task_ids: this.parseJsonArrayField(row.related_task_ids), + files_touched: this.parseJsonArrayField(row.files_touched), + symbols_touched: this.parseJsonArrayField(row.symbols_touched), + commit_refs: this.parseJsonArrayField(row.commit_refs), + diff_refs: this.parseJsonArrayField(row.diff_refs), + decision_notes: row.decision_notes, + tracker_issue_key: row.tracker_issue_key, + updated_at: row.updated_at, + }; + } + + private computeMissingRegistrationFields( + project: ProjectRecord, + alias: string, + ): string[] { + const missing: string[] = []; + if (!project.project_id) missing.push("project_id"); + if (!alias) missing.push("project_alias"); + if (!project.project_name) missing.push("project_name"); + return missing; + } + + private computeRegistrationCompleteness( + project: ProjectRecord, + alias: string, + ): number { + const requiredTotal = 3; + const requiredPresent = [ + project.project_id, + alias, + project.project_name, + ].filter(Boolean).length; + const optionalTotal = 3; + const optionalPresent = [ + project.repo_root, + project.repo_remote_primary, + project.active_version, + ].filter(Boolean).length; + return Math.round( + (requiredPresent / requiredTotal) * 80 + + (optionalPresent / optionalTotal) * 20, + ); + } + + private upsertProjectAlias( + scopeUserId: string, + scopeAgentId: string, + projectId: string, + projectAlias: string, + isPrimary: boolean, + now: string, + allowAliasUpdate: boolean, + ): void { + const existingAlias = this.getProjectAlias( + scopeUserId, + scopeAgentId, + projectAlias, + ); + if (existingAlias) { + if (existingAlias.project_id !== projectId && !allowAliasUpdate) { + throw new Error( + `project_alias "${projectAlias}" is already mapped to another project_id`, + ); + } + const stmt = this.db.prepare( + `UPDATE project_aliases SET project_id = ?, is_primary = ?, updated_at = ? WHERE id = ?`, + ); + stmt.run(projectId, isPrimary ? 1 : 0, now, existingAlias.id); + return; + } + + const insertStmt = this.db.prepare( + `INSERT INTO project_aliases (id, project_id, scope_user_id, scope_agent_id, project_alias, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ); - insertStmt.run(randomUUID(), projectId, scopeUserId, scopeAgentId, projectAlias, isPrimary ? 1 : 0, now, now); - } - - private upsertProjectRegistrationState( - scopeUserId: string, - scopeAgentId: string, - input: { - project_id: string; - registration_status: "draft" | "registered" | "validated" | "blocked"; - validation_status: "pending" | "ok" | "warn" | "error"; - validation_notes: string | null; - completeness_score: number; - missing_required_fields: string[]; - last_validated_at: string | null; - }, - ): ProjectRegistrationStateRecord { - const now = new Date().toISOString(); - const existing = this.getProjectRegistrationState(scopeUserId, scopeAgentId, input.project_id); - const missingJson = JSON.stringify(input.missing_required_fields || []); - - if (existing) { - const stmt = this.db.prepare( - `UPDATE project_registration_state + ); + insertStmt.run( + randomUUID(), + projectId, + scopeUserId, + scopeAgentId, + projectAlias, + isPrimary ? 1 : 0, + now, + now, + ); + } + + private upsertProjectRegistrationState( + scopeUserId: string, + scopeAgentId: string, + input: { + project_id: string; + registration_status: "draft" | "registered" | "validated" | "blocked"; + validation_status: "pending" | "ok" | "warn" | "error"; + validation_notes: string | null; + completeness_score: number; + missing_required_fields: string[]; + last_validated_at: string | null; + }, + ): ProjectRegistrationStateRecord { + const now = new Date().toISOString(); + const existing = this.getProjectRegistrationState( + scopeUserId, + scopeAgentId, + input.project_id, + ); + const missingJson = JSON.stringify(input.missing_required_fields || []); + + if (existing) { + const stmt = this.db.prepare( + `UPDATE project_registration_state SET registration_status = ?, validation_status = ?, validation_notes = ?, completeness_score = ?, missing_required_fields = ?, last_validated_at = ?, updated_at = ? WHERE scope_user_id = ? AND scope_agent_id = ? AND project_id = ?`, - ); - stmt.run( - input.registration_status, - input.validation_status, - input.validation_notes, - input.completeness_score, - missingJson, - input.last_validated_at, - now, - scopeUserId, - scopeAgentId, - input.project_id, - ); - } else { - const stmt = this.db.prepare( - `INSERT INTO project_registration_state (project_id, scope_user_id, scope_agent_id, registration_status, validation_status, validation_notes, completeness_score, missing_required_fields, last_validated_at, updated_at) + ); + stmt.run( + input.registration_status, + input.validation_status, + input.validation_notes, + input.completeness_score, + missingJson, + input.last_validated_at, + now, + scopeUserId, + scopeAgentId, + input.project_id, + ); + } else { + const stmt = this.db.prepare( + `INSERT INTO project_registration_state (project_id, scope_user_id, scope_agent_id, registration_status, validation_status, validation_notes, completeness_score, missing_required_fields, last_validated_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - stmt.run( - input.project_id, - scopeUserId, - scopeAgentId, - input.registration_status, - input.validation_status, - input.validation_notes, - input.completeness_score, - missingJson, - input.last_validated_at, - now, - ); - } - - const state = this.getProjectRegistrationState(scopeUserId, scopeAgentId, input.project_id); - if (!state) throw new Error("failed to persist project_registration_state"); - return state; - } - - private rowToSlot(row: SlotRow): Slot { - let parsedValue: unknown; - try { - parsedValue = JSON.parse(row.value); - } catch { - parsedValue = row.value; - } - return { - ...row, - value: parsedValue, - source: row.source as Slot["source"], - }; - } - - private inferCategory(key: string): string { - const prefix = key.split(".")[0]; - const knownCategories = [ - "profile", - "preferences", - "project", - "environment", - ]; - if (knownCategories.includes(prefix)) { - return prefix; - } - return "custom"; - } - - private cleanExpired(scopeUserId: string, scopeAgentId: string): void { - const now = new Date().toISOString(); - // Remove explicitly expired slots - const stmt = this.db.prepare( - `DELETE FROM slots + ); + stmt.run( + input.project_id, + scopeUserId, + scopeAgentId, + input.registration_status, + input.validation_status, + input.validation_notes, + input.completeness_score, + missingJson, + input.last_validated_at, + now, + ); + } + + const state = this.getProjectRegistrationState( + scopeUserId, + scopeAgentId, + input.project_id, + ); + if (!state) throw new Error("failed to persist project_registration_state"); + return state; + } + + private rowToSlot(row: SlotRow): Slot { + let parsedValue: unknown; + try { + parsedValue = JSON.parse(row.value); + } catch { + parsedValue = row.value; + } + return { + ...row, + value: parsedValue, + source: row.source as Slot["source"], + }; + } + + private inferCategory(key: string): string { + const prefix = key.split(".")[0]; + const knownCategories = [ + "profile", + "preferences", + "project", + "environment", + ]; + if (knownCategories.includes(prefix)) { + return prefix; + } + return "custom"; + } + + private cleanExpired(scopeUserId: string, scopeAgentId: string): void { + const now = new Date().toISOString(); + // Remove explicitly expired slots + const stmt = this.db.prepare( + `DELETE FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? AND expires_at IS NOT NULL AND expires_at < ?`, - ); - stmt.run(scopeUserId, scopeAgentId, now); - - // Auto-expire slots based on category TTL (auto_capture source) - const categories = ['project', 'environment', 'custom']; - for (const cat of categories) { - const ttlDays = getSlotTTL(cat); - const cutoff = new Date(Date.now() - ttlDays * 24 * 60 * 60 * 1000).toISOString(); - const ttlStmt = this.db.prepare( - `DELETE FROM slots + ); + stmt.run(scopeUserId, scopeAgentId, now); + + // Auto-expire slots based on category TTL (auto_capture source) + const categories = ["project", "environment", "custom"]; + for (const cat of categories) { + const ttlDays = getSlotTTL(cat); + const cutoff = new Date( + Date.now() - ttlDays * 24 * 60 * 60 * 1000, + ).toISOString(); + const ttlStmt = this.db.prepare( + `DELETE FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? AND category = ? AND updated_at < ? AND key NOT LIKE '_autocapture%' AND source = 'auto_capture'`, - ); - ttlStmt.run(scopeUserId, scopeAgentId, cat, cutoff); - } - - // Safety cleanup: volatile project status slots should expire even if source was manual/tool. - // This prevents stale "current phase/task" slots from persisting forever. - const projectCutoff = new Date(Date.now() - getSlotTTL('project') * 24 * 60 * 60 * 1000).toISOString(); - const volatileProjectStmt = this.db.prepare( - `DELETE FROM slots + ); + ttlStmt.run(scopeUserId, scopeAgentId, cat, cutoff); + } + + // Safety cleanup: volatile project status slots should expire even if source was manual/tool. + // This prevents stale "current phase/task" slots from persisting forever. + const projectCutoff = new Date( + Date.now() - getSlotTTL("project") * 24 * 60 * 60 * 1000, + ).toISOString(); + const volatileProjectStmt = this.db.prepare( + `DELETE FROM slots WHERE scope_user_id = ? AND scope_agent_id = ? AND category = 'project' AND updated_at < ? @@ -3795,11 +4749,11 @@ export class SlotDB { 'project.phase', 'project.status' )`, - ); - volatileProjectStmt.run(scopeUserId, scopeAgentId, projectCutoff); - } + ); + volatileProjectStmt.run(scopeUserId, scopeAgentId, projectCutoff); + } - close(): void { - this.db.close(); - } + close(): void { + this.db.close(); + } } diff --git a/src/hooks/auto-capture.ts b/src/hooks/auto-capture.ts index 53e8031..9603c16 100644 --- a/src/hooks/auto-capture.ts +++ b/src/hooks/auto-capture.ts @@ -8,124 +8,160 @@ import crypto from "crypto"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { SlotDB } from "../db/slot-db.js"; -import { QdrantClient } from "../services/qdrant.js"; -import { EmbeddingClient } from "../services/embedding.js"; -import { DeduplicationService } from "../services/dedupe.js"; -import { extractWithLLM, checkLLMHealth, DistillMode } from "../services/llm-extractor.js"; -import { getAutoCaptureNamespace, MemoryNamespace, normalizeUserId, isLearningContent, toCoreAgent, evaluateNoiseV2, normalizeNamespace } from "../shared/memory-config.js"; +import { resolvePromotionMetadata } from "../core/promotion/promotion-lifecycle.js"; +import type { SlotDB } from "../db/slot-db.js"; +import type { DeduplicationService } from "../services/dedupe.js"; +import type { EmbeddingClient } from "../services/embedding.js"; +import { + checkLLMHealth, + type DistillMode, + extractWithLLM, +} from "../services/llm-extractor.js"; +import type { QdrantClient } from "../services/qdrant.js"; +import { + evaluateNoiseV2, + getAutoCaptureNamespace, + isLearningContent, + type MemoryNamespace, + normalizeNamespace, + normalizeUserId, + toCoreAgent, +} from "../shared/memory-config.js"; // Event type constant for type-safe event handling const AGENT_END_EVENT = "agent_end" as const; interface AutoCaptureConfig { - enabled: boolean; - minConfidence: number; - useLLM: boolean; - llmBaseUrl: string; - llmApiKey: string; - llmModel: string; - contextWindowMaxTokens?: number; - summarizeEveryActions?: number; + enabled: boolean; + minConfidence: number; + useLLM: boolean; + llmBaseUrl: string; + llmApiKey: string; + llmModel: string; + contextWindowMaxTokens?: number; + summarizeEveryActions?: number; } const DEFAULT_CONFIG: AutoCaptureConfig = { - enabled: true, - minConfidence: 0.7, - useLLM: true, - llmBaseUrl: "", - llmApiKey: "", - llmModel: "", - summarizeEveryActions: 6, + enabled: true, + minConfidence: 0.7, + useLLM: true, + llmBaseUrl: "", + llmApiKey: "", + llmModel: "", + summarizeEveryActions: 6, }; interface ConversationMessage { - role: "user" | "assistant" | "system"; - content: string; + role: "user" | "assistant" | "system"; + content: string; } - interface LivingStateSummary { - last_actions: string[]; - current_focus: string; - next_steps: string[]; - active_context?: string; - timestamp?: number; - ttl?: number; + last_actions: string[]; + current_focus: string; + next_steps: string[]; + active_context?: string; + timestamp?: number; + ttl?: number; } interface SessionSummaryValue { - summary: string; - key_decisions: string[]; - outcomes: string[]; - ttl: number; - timestamp: number; + summary: string; + key_decisions: string[]; + outcomes: string[]; + ttl: number; + timestamp: number; } function trimLine(s: string, max = 180): string { - const t = s.replace(/\s+/g, " ").trim(); - return t.length > max ? `${t.slice(0, max)}...` : t; + const t = s.replace(/\s+/g, " ").trim(); + return t.length > max ? `${t.slice(0, max)}...` : t; } function inferCurrentFocus(lines: string[]): string { - const joined = lines.join(" ").toLowerCase(); - - const focusPatterns: Array<{ re: RegExp; label: string }> = [ - { re: /\b(test|fix|bug|error|debug)\b/, label: "Fixing/testing implementation details" }, - { re: /\b(refactor|cleanup|optimi[sz]e)\b/, label: "Refactoring and improving code quality" }, - { re: /\b(implement|th[êe]m|t[ií]ch h[ợo]p|x[aâ]y d[ựu]ng)\b/, label: "Implementing requested feature changes" }, - { re: /\b(prompt|inject|system prompt|before_agent_start)\b/, label: "Updating prompt injection and session context" }, - { re: /\b(slot|memory|project_living_state)\b/, label: "Maintaining SlotDB project living state" }, - ]; - - for (const p of focusPatterns) { - if (p.re.test(joined)) return p.label; - } - - return "Working on current user-requested task"; + const joined = lines.join(" ").toLowerCase(); + + const focusPatterns: Array<{ re: RegExp; label: string }> = [ + { + re: /\b(test|fix|bug|error|debug)\b/, + label: "Fixing/testing implementation details", + }, + { + re: /\b(refactor|cleanup|optimi[sz]e)\b/, + label: "Refactoring and improving code quality", + }, + { + re: /\b(implement|th[êe]m|t[ií]ch h[ợo]p|x[aâ]y d[ựu]ng)\b/, + label: "Implementing requested feature changes", + }, + { + re: /\b(prompt|inject|system prompt|before_agent_start)\b/, + label: "Updating prompt injection and session context", + }, + { + re: /\b(slot|memory|project_living_state)\b/, + label: "Maintaining SlotDB project living state", + }, + ]; + + for (const p of focusPatterns) { + if (p.re.test(joined)) return p.label; + } + + return "Working on current user-requested task"; } function inferNextSteps(lines: string[], currentFocus: string): string[] { - const lower = lines.map((l) => l.toLowerCase()); - const next: string[] = []; - - const hasTest = lower.some((l) => /\btest|spec|verify|assert\b/.test(l)); - const hasBuild = lower.some((l) => /\bbuild|compile|tsc\b/.test(l)); - const hasHook = lower.some((l) => /\bhook|auto-capture|auto-recall|before_agent_start\b/.test(l)); - - if (hasHook) next.push("Validate hook flow end-to-end with realistic session events"); - if (hasBuild) next.push("Re-run build to ensure no TypeScript/runtime regressions"); - if (hasTest) next.push("Run and review targeted tests for auto-capture and recall behavior"); - - if (next.length === 0) { - next.push(`Continue: ${currentFocus}`); - next.push("Verify outputs in SlotDB and injected system prompt context"); - } - - return next.slice(0, 3); + const lower = lines.map((l) => l.toLowerCase()); + const next: string[] = []; + + const hasTest = lower.some((l) => /\btest|spec|verify|assert\b/.test(l)); + const hasBuild = lower.some((l) => /\bbuild|compile|tsc\b/.test(l)); + const hasHook = lower.some((l) => + /\bhook|auto-capture|auto-recall|before_agent_start\b/.test(l), + ); + + if (hasHook) + next.push("Validate hook flow end-to-end with realistic session events"); + if (hasBuild) + next.push("Re-run build to ensure no TypeScript/runtime regressions"); + if (hasTest) + next.push( + "Run and review targeted tests for auto-capture and recall behavior", + ); + + if (next.length === 0) { + next.push(`Continue: ${currentFocus}`); + next.push("Verify outputs in SlotDB and injected system prompt context"); + } + + return next.slice(0, 3); } -function summarizeProjectLivingState(messages: ConversationMessage[]): LivingStateSummary { - const actionable = messages - .filter((m) => m.role === "assistant" || m.role === "user") - .map((m) => extractMessageText(m.content)) - .flatMap((txt) => txt.split("\n")) - .map((l) => trimLine(l)) - .filter((l) => l.length > 0) - .filter((l) => !/^NO_REPLY$/i.test(l)) - .filter((l) => !/^HEARTBEAT_OK$/i.test(l)) - .filter((l) => !/^\[Tool/i.test(l)) - .slice(-16); - - const lastActions = actionable.slice(-5); - const currentFocus = inferCurrentFocus(actionable); - const nextSteps = inferNextSteps(actionable, currentFocus); - - return { - last_actions: lastActions, - current_focus: currentFocus, - next_steps: nextSteps, - }; +function summarizeProjectLivingState( + messages: ConversationMessage[], +): LivingStateSummary { + const actionable = messages + .filter((m) => m.role === "assistant" || m.role === "user") + .map((m) => extractMessageText(m.content)) + .flatMap((txt) => txt.split("\n")) + .map((l) => trimLine(l)) + .filter((l) => l.length > 0) + .filter((l) => !/^NO_REPLY$/i.test(l)) + .filter((l) => !/^HEARTBEAT_OK$/i.test(l)) + .filter((l) => !/^\[Tool/i.test(l)) + .slice(-16); + + const lastActions = actionable.slice(-5); + const currentFocus = inferCurrentFocus(actionable); + const nextSteps = inferNextSteps(actionable, currentFocus); + + return { + last_actions: lastActions, + current_focus: currentFocus, + next_steps: nextSteps, + }; } const SHORT_TERM_TTL_MS = 48 * 3600 * 1000; @@ -133,175 +169,194 @@ const MID_TERM_TTL_MS = 30 * 24 * 3600 * 1000; const ONE_DAY_MS = 24 * 3600 * 1000; function toExpiryIso(msFromNow: number): string { - return new Date(Date.now() + msFromNow).toISOString(); + return new Date(Date.now() + msFromNow).toISOString(); } function getDateKey(date: Date = new Date()): string { - return date.toISOString().split("T")[0]; + return date.toISOString().split("T")[0]; } function getYesterdayDateKey(): string { - return getDateKey(new Date(Date.now() - ONE_DAY_MS)); + return getDateKey(new Date(Date.now() - ONE_DAY_MS)); } function isExpired(value: any): boolean { - if (!value || typeof value !== "object") return true; - const ts = typeof value.timestamp === "number" ? value.timestamp : 0; - const ttl = typeof value.ttl === "number" ? value.ttl : 0; - if (!ts || !ttl) return true; - return Date.now() > ts + ttl; + if (!value || typeof value !== "object") return true; + const ts = typeof value.timestamp === "number" ? value.timestamp : 0; + const ttl = typeof value.ttl === "number" ? value.ttl : 0; + if (!ts || !ttl) return true; + return Date.now() > ts + ttl; } function detectImportantPattern(text: string): boolean { - const normalized = String(text || "").toLowerCase(); - const keywords = [ - "hack", - "exploit", - "drawdown", - "root cause", - "regulation", - "sec", - "etf", - "delist", - "breakout", - "black swan", - "critical", - "incident", - ]; - return keywords.some((k) => normalized.includes(k)); + const normalized = String(text || "").toLowerCase(); + const keywords = [ + "hack", + "exploit", + "drawdown", + "root cause", + "regulation", + "sec", + "etf", + "delist", + "breakout", + "black swan", + "critical", + "incident", + ]; + return keywords.some((k) => normalized.includes(k)); } function extractDecisions(messages: ConversationMessage[]): string[] { - return messages - .map((m) => extractMessageText(m.content)) - .flatMap((txt) => txt.split("\n")) - .map((line) => trimLine(line)) - .filter((line) => /(quyết định|chốt|decide|approved|approve|selected)/i.test(line)) - .slice(-5); + return messages + .map((m) => extractMessageText(m.content)) + .flatMap((txt) => txt.split("\n")) + .map((line) => trimLine(line)) + .filter((line) => + /(quyết định|chốt|decide|approved|approve|selected)/i.test(line), + ) + .slice(-5); } function extractOutcomes(messages: ConversationMessage[]): string[] { - return messages - .map((m) => extractMessageText(m.content)) - .flatMap((txt) => txt.split("\n")) - .map((line) => trimLine(line)) - .filter((line) => /(done|xong|completed|passed|failed|deployed|delivered)/i.test(line)) - .slice(-5); + return messages + .map((m) => extractMessageText(m.content)) + .flatMap((txt) => txt.split("\n")) + .map((line) => trimLine(line)) + .filter((line) => + /(done|xong|completed|passed|failed|deployed|delivered)/i.test(line), + ) + .slice(-5); } function buildDaySummary(messages: ConversationMessage[]): string { - const lines = messages - .map((m) => extractMessageText(m.content)) - .flatMap((txt) => txt.split("\n")) - .map((line) => trimLine(line, 220)) - .filter((line) => line.length > 0) - .slice(-12); - - return lines.join("\n"); + const lines = messages + .map((m) => extractMessageText(m.content)) + .flatMap((txt) => txt.split("\n")) + .map((line) => trimLine(line, 220)) + .filter((line) => line.length > 0) + .slice(-12); + + return lines.join("\n"); } -function formatMemoryContext(livingState: any, recentSummary: any, vectorResults: Array<{ text: string }> = []): string { - const blocks: string[] = []; +function formatMemoryContext( + livingState: any, + recentSummary: any, + vectorResults: Array<{ text: string }> = [], +): string { + const blocks: string[] = []; - if (livingState) { - blocks.push(`SHORT_TERM: ${JSON.stringify(livingState)}`); - } + if (livingState) { + blocks.push(`SHORT_TERM: ${JSON.stringify(livingState)}`); + } - if (recentSummary) { - blocks.push(`MID_TERM: ${JSON.stringify(recentSummary)}`); - } + if (recentSummary) { + blocks.push(`MID_TERM: ${JSON.stringify(recentSummary)}`); + } - if (vectorResults.length > 0) { - blocks.push(`LONG_TERM: ${vectorResults.map((v) => v.text).join(" | ")}`); - } + if (vectorResults.length > 0) { + blocks.push(`LONG_TERM: ${vectorResults.map((v) => v.text).join(" | ")}`); + } - return blocks.join("\n"); + return blocks.join("\n"); } /** * Auto-recall helper: short-term -> mid-term -> long-term fallback */ export async function injectMemoryContext( - agentId: string, - deps: { db: SlotDB; qdrant: QdrantClient; embedding: EmbeddingClient; userId: string; query?: string } + agentId: string, + deps: { + db: SlotDB; + qdrant: QdrantClient; + embedding: EmbeddingClient; + userId: string; + query?: string; + }, ): Promise { - const { db, qdrant, embedding, userId, query } = deps; - - const living = db.get(userId, agentId, { key: "project_living_state" }); - const livingState = living && !Array.isArray(living) ? living.value : null; - - let recentSummary: any = null; - const vectorResults: Array<{ text: string }> = []; - - if (!livingState || isExpired(livingState)) { - const yesterday = getYesterdayDateKey(); - const mid = db.get(userId, agentId, { key: `session.${yesterday}.summary` }); - recentSummary = mid && !Array.isArray(mid) ? mid.value : null; - - if (!recentSummary && query && query.trim().length > 0) { - try { - const vector = await embedding.embed(query); - const results = await qdrant.search(vector, 5, { - must: [{ key: "namespace", match: { value: "session_summaries" } }], - }); - for (const r of results) { - if (r.payload?.text) { - vectorResults.push({ text: String(r.payload.text) }); - } - } - } catch (error: any) { - console.error("[AutoCapture] injectMemoryContext semantic fallback error:", error.message); - } - } - } - - return formatMemoryContext(livingState, recentSummary, vectorResults); + const { db, qdrant, embedding, userId, query } = deps; + + const living = db.get(userId, agentId, { key: "project_living_state" }); + const livingState = living && !Array.isArray(living) ? living.value : null; + + let recentSummary: any = null; + const vectorResults: Array<{ text: string }> = []; + + if (!livingState || isExpired(livingState)) { + const yesterday = getYesterdayDateKey(); + const mid = db.get(userId, agentId, { + key: `session.${yesterday}.summary`, + }); + recentSummary = mid && !Array.isArray(mid) ? mid.value : null; + + if (!recentSummary && query && query.trim().length > 0) { + try { + const vector = await embedding.embed(query); + const results = await qdrant.search(vector, 5, { + must: [{ key: "namespace", match: { value: "session_summaries" } }], + }); + for (const r of results) { + if (r.payload?.text) { + vectorResults.push({ text: String(r.payload.text) }); + } + } + } catch (error: any) { + console.error( + "[AutoCapture] injectMemoryContext semantic fallback error:", + error.message, + ); + } + } + } + + return formatMemoryContext(livingState, recentSummary, vectorResults); } /** * Infer distill mode based on agent type and content */ function inferDistillMode(agentId: string, text: string): DistillMode { - // Trader agent → market signals - if (toCoreAgent(agentId) === "trader") { - return "market_signal"; - } - - // Learning content → principles - if (isLearningContent(text)) { - return "principles"; - } - - // Scrum/Fullstack/Creator → requirements (technical constraints) - if (["scrum", "fullstack", "creator"].includes(agentId)) { - return "requirements"; - } - - // Default - return "general"; + // Trader agent → market signals + if (toCoreAgent(agentId) === "trader") { + return "market_signal"; + } + + // Learning content → principles + if (isLearningContent(text)) { + return "principles"; + } + + // Scrum/Fullstack/Creator → requirements (technical constraints) + if (["scrum", "fullstack", "creator"].includes(agentId)) { + return "requirements"; + } + + // Default + return "general"; } /** * Context Window Management Configuration */ interface ContextWindowConfig { - maxConversationTokens: number; // default: 12_000 - tokenEstimateDivisor: number; // default: 4 - absoluteMaxMessages: number; // default: 200 + maxConversationTokens: number; // default: 12_000 + tokenEstimateDivisor: number; // default: 4 + absoluteMaxMessages: number; // default: 200 } interface SelectionStats { - totalMessages: number; - filteredMessages: number; - selectedMessages: number; - estimatedTokens: number; - budgetUsedPercent: number; + totalMessages: number; + filteredMessages: number; + selectedMessages: number; + estimatedTokens: number; + budgetUsedPercent: number; } const DEFAULT_CONTEXT_WINDOW: ContextWindowConfig = { - maxConversationTokens: 12_000, - tokenEstimateDivisor: 4, - absoluteMaxMessages: 200, + maxConversationTokens: 12_000, + tokenEstimateDivisor: 4, + absoluteMaxMessages: 200, }; /** @@ -309,10 +364,10 @@ const DEFAULT_CONTEXT_WINDOW: ContextWindowConfig = { * Detects configuration, deployment, and environment-related content */ const PROJECT_CONTEXT_PATTERNS: RegExp[] = [ - /\b(đã config|đã chốt|rule mới|quy định|cấu hình)\b/i, - /\b(deploy|release|migration|rollback)\b/i, - /\b(production|staging|environment)\b/i, - /\b(API key|endpoint|port|host|database)\b/i, + /\b(đã config|đã chốt|rule mới|quy định|cấu hình)\b/i, + /\b(deploy|release|migration|rollback)\b/i, + /\b(production|staging|environment)\b/i, + /\b(API key|endpoint|port|host|database)\b/i, ]; /** @@ -320,7 +375,7 @@ const PROJECT_CONTEXT_PATTERNS: RegExp[] = [ * Used to route configuration/deployment content to project_context namespace */ function isProjectContextContent(text: string): boolean { - return PROJECT_CONTEXT_PATTERNS.some(p => p.test(text)); + return PROJECT_CONTEXT_PATTERNS.some((p) => p.test(text)); } /** @@ -329,95 +384,95 @@ function isProjectContextContent(text: string): boolean { * CRITICAL: Must NEVER return [object Object] - uses JSON.stringify as ultimate fallback */ function extractMessageText(content: unknown): string { - // Simple string case - if (typeof content === "string") { - return content; - } - - // Array of content blocks (OpenAI/Anthropic format) - if (Array.isArray(content)) { - return content - .map((block: any) => { - // Text block - if (block?.type === "text" && typeof block.text === "string") { - return block.text; - } - // Tool use block - if (block?.type === "tool_use") { - return `[Tool: ${block.name || "unknown"}]`; - } - // Tool result block - if (block?.type === "tool_result") { - return `[Tool Result]`; - } - // Image block - if (block?.type === "image" || block?.type === "image_url") { - return "[Image]"; - } - // Fallback for any object with text property - if (typeof block?.text === "string") { - return block.text; - } - // String content property - if (typeof block?.content === "string") { - return block.content; - } - // Last resort: stringify if it's an object - if (typeof block === "object" && block !== null) { - try { - return JSON.stringify(block); - } catch { - return "[Content]"; - } - } - return String(block); - }) - .join(" "); - } - - // Object with text property - if (typeof content === "object" && content !== null && "text" in content) { - const textValue = (content as any).text; - if (typeof textValue === "string") { - return textValue; - } - // If text is not a string, try to stringify it - try { - return JSON.stringify(textValue); - } catch { - return "[Complex Content]"; - } - } - - // Object with content property (common in some message formats) - if (typeof content === "object" && content !== null && "content" in content) { - const contentValue = (content as any).content; - if (typeof contentValue === "string") { - return contentValue; - } - if (Array.isArray(contentValue)) { - return extractMessageText(contentValue); - } - try { - return JSON.stringify(contentValue); - } catch { - return "[Complex Content]"; - } - } - - // Handle nested objects - stringify instead of toString() - if (typeof content === "object" && content !== null) { - try { - return JSON.stringify(content); - } catch { - return "[Complex Content]"; - } - } - - // Fallback for primitives (number, boolean, null, undefined) - if (content === null) return ""; - if (content === undefined) return ""; - return String(content); + // Simple string case + if (typeof content === "string") { + return content; + } + + // Array of content blocks (OpenAI/Anthropic format) + if (Array.isArray(content)) { + return content + .map((block: any) => { + // Text block + if (block?.type === "text" && typeof block.text === "string") { + return block.text; + } + // Tool use block + if (block?.type === "tool_use") { + return `[Tool: ${block.name || "unknown"}]`; + } + // Tool result block + if (block?.type === "tool_result") { + return `[Tool Result]`; + } + // Image block + if (block?.type === "image" || block?.type === "image_url") { + return "[Image]"; + } + // Fallback for any object with text property + if (typeof block?.text === "string") { + return block.text; + } + // String content property + if (typeof block?.content === "string") { + return block.content; + } + // Last resort: stringify if it's an object + if (typeof block === "object" && block !== null) { + try { + return JSON.stringify(block); + } catch { + return "[Content]"; + } + } + return String(block); + }) + .join(" "); + } + + // Object with text property + if (typeof content === "object" && content !== null && "text" in content) { + const textValue = (content as any).text; + if (typeof textValue === "string") { + return textValue; + } + // If text is not a string, try to stringify it + try { + return JSON.stringify(textValue); + } catch { + return "[Complex Content]"; + } + } + + // Object with content property (common in some message formats) + if (typeof content === "object" && content !== null && "content" in content) { + const contentValue = (content as any).content; + if (typeof contentValue === "string") { + return contentValue; + } + if (Array.isArray(contentValue)) { + return extractMessageText(contentValue); + } + try { + return JSON.stringify(contentValue); + } catch { + return "[Complex Content]"; + } + } + + // Handle nested objects - stringify instead of toString() + if (typeof content === "object" && content !== null) { + try { + return JSON.stringify(content); + } catch { + return "[Complex Content]"; + } + } + + // Fallback for primitives (number, boolean, null, undefined) + if (content === null) return ""; + if (content === undefined) return ""; + return String(content); } /** @@ -425,7 +480,7 @@ function extractMessageText(content: unknown): string { * Uses chars / divisor approximation (default: /4 for English/Vietnamese mix) */ function estimateTokens(text: string, divisor: number = 4): number { - return Math.ceil(text.length / divisor); + return Math.ceil(text.length / divisor); } /** @@ -433,784 +488,987 @@ function estimateTokens(text: string, divisor: number = 4): number { * Iterates from newest to oldest, accumulating messages until budget is reached */ function selectMessagesWithinBudget( - messages: ConversationMessage[], - config: ContextWindowConfig = DEFAULT_CONTEXT_WINDOW + messages: ConversationMessage[], + config: ContextWindowConfig = DEFAULT_CONTEXT_WINDOW, ): { selected: ConversationMessage[]; stats: SelectionStats } { - // 1. Filter out system messages - only keep user and assistant - const filtered = messages.filter( - m => m.role === "user" || m.role === "assistant" - ); - - // 2. Safety cap: if more than absoluteMaxMessages, keep only the most recent ones - const capped = filtered.length > config.absoluteMaxMessages - ? filtered.slice(-config.absoluteMaxMessages) - : filtered; - - // 3. Reverse accumulation: start from newest message - const selected: ConversationMessage[] = []; - let tokenCount = 0; - - for (let i = capped.length - 1; i >= 0; i--) { - const msg = capped[i]; - const msgTokens = estimateTokens( - `${msg.role}: ${extractMessageText(msg.content)}`, - config.tokenEstimateDivisor - ); - - if (tokenCount + msgTokens > config.maxConversationTokens) { - break; // Budget exhausted - } - - selected.unshift(msg); // Prepend to maintain chronological order - tokenCount += msgTokens; - } - - // 4. Stats for logging - const stats: SelectionStats = { - totalMessages: messages.length, - filteredMessages: filtered.length, - selectedMessages: selected.length, - estimatedTokens: tokenCount, - budgetUsedPercent: Math.round( - (tokenCount / config.maxConversationTokens) * 100 - ), - }; - - return { selected, stats }; + // 1. Filter out system messages - only keep user and assistant + const filtered = messages.filter( + (m) => m.role === "user" || m.role === "assistant", + ); + + // 2. Safety cap: if more than absoluteMaxMessages, keep only the most recent ones + const capped = + filtered.length > config.absoluteMaxMessages + ? filtered.slice(-config.absoluteMaxMessages) + : filtered; + + // 3. Reverse accumulation: start from newest message + const selected: ConversationMessage[] = []; + let tokenCount = 0; + + for (let i = capped.length - 1; i >= 0; i--) { + const msg = capped[i]; + const msgTokens = estimateTokens( + `${msg.role}: ${extractMessageText(msg.content)}`, + config.tokenEstimateDivisor, + ); + + if (tokenCount + msgTokens > config.maxConversationTokens) { + break; // Budget exhausted + } + + selected.unshift(msg); // Prepend to maintain chronological order + tokenCount += msgTokens; + } + + // 4. Stats for logging + const stats: SelectionStats = { + totalMessages: messages.length, + filteredMessages: filtered.length, + selectedMessages: selected.length, + estimatedTokens: tokenCount, + budgetUsedPercent: Math.round( + (tokenCount / config.maxConversationTokens) * 100, + ), + }; + + return { selected, stats }; } /** * Extract facts using LLM or fallback to patterns */ async function extractFacts( - messages: ConversationMessage[], - currentSlots: Record>, - cfg: AutoCaptureConfig, - forceUseLLM?: boolean, - distillMode: DistillMode = "general", + messages: ConversationMessage[], + currentSlots: Record>, + cfg: AutoCaptureConfig, + forceUseLLM?: boolean, + distillMode: DistillMode = "general", ): Promise<{ slot_updates: any[]; slot_removals: any[]; memories: any[] }> { - // Build context window config from optional cfg setting - const contextWindowConfig: ContextWindowConfig = { - maxConversationTokens: cfg.contextWindowMaxTokens ?? DEFAULT_CONTEXT_WINDOW.maxConversationTokens, - tokenEstimateDivisor: DEFAULT_CONTEXT_WINDOW.tokenEstimateDivisor, - absoluteMaxMessages: DEFAULT_CONTEXT_WINDOW.absoluteMaxMessages, - }; - - // Use token-aware context window selection instead of fixed message count - const { selected: recentMessages, stats } = selectMessagesWithinBudget(messages, contextWindowConfig); - - const text = recentMessages - .map((m) => `${m.role}: ${extractMessageText(m.content)}`) - .join("\n"); - - console.log( - `[AutoCapture] Context window: ${stats.selectedMessages}/${stats.totalMessages} msgs, ` + - `~${stats.estimatedTokens} tokens (${stats.budgetUsedPercent}% budget)` - ); - - // Determine if we should use LLM (allow override from params) - const shouldUseLLM = forceUseLLM !== undefined ? forceUseLLM : cfg.useLLM; - - // Try LLM first - if (shouldUseLLM) { - const isHealthy = await checkLLMHealth(cfg.llmBaseUrl, cfg.llmApiKey); - if (isHealthy) { - console.log("[AutoCapture] Using LLM for extraction, model:", cfg.llmModel); - // Pass LLM config fields to extractWithLLM - const llmConfig = { - baseUrl: cfg.llmBaseUrl, - apiKey: cfg.llmApiKey, - model: cfg.llmModel, - }; - return extractWithLLM(text, currentSlots, llmConfig, distillMode); - } - console.log("[AutoCapture] LLM unavailable, using pattern fallback"); - } - - // Fallback to pattern matching - return extractWithPatterns(text); + // Build context window config from optional cfg setting + const contextWindowConfig: ContextWindowConfig = { + maxConversationTokens: + cfg.contextWindowMaxTokens ?? + DEFAULT_CONTEXT_WINDOW.maxConversationTokens, + tokenEstimateDivisor: DEFAULT_CONTEXT_WINDOW.tokenEstimateDivisor, + absoluteMaxMessages: DEFAULT_CONTEXT_WINDOW.absoluteMaxMessages, + }; + + // Use token-aware context window selection instead of fixed message count + const { selected: recentMessages, stats } = selectMessagesWithinBudget( + messages, + contextWindowConfig, + ); + + const text = recentMessages + .map((m) => `${m.role}: ${extractMessageText(m.content)}`) + .join("\n"); + + console.log( + `[AutoCapture] Context window: ${stats.selectedMessages}/${stats.totalMessages} msgs, ` + + `~${stats.estimatedTokens} tokens (${stats.budgetUsedPercent}% budget)`, + ); + + // Determine if we should use LLM (allow override from params) + const shouldUseLLM = forceUseLLM !== undefined ? forceUseLLM : cfg.useLLM; + + // Try LLM first + if (shouldUseLLM) { + const isHealthy = await checkLLMHealth(cfg.llmBaseUrl, cfg.llmApiKey); + if (isHealthy) { + console.log( + "[AutoCapture] Using LLM for extraction, model:", + cfg.llmModel, + ); + // Pass LLM config fields to extractWithLLM + const llmConfig = { + baseUrl: cfg.llmBaseUrl, + apiKey: cfg.llmApiKey, + model: cfg.llmModel, + }; + return extractWithLLM(text, currentSlots, llmConfig, distillMode); + } + console.log("[AutoCapture] LLM unavailable, using pattern fallback"); + } + + // Fallback to pattern matching + return extractWithPatterns(text); } /** * Pattern-based extraction (fallback) */ -function extractWithPatterns(text: string): { slot_updates: any[]; slot_removals: any[]; memories: any[] } { - const result: { slot_updates: any[]; slot_removals: any[]; memories: any[] } = { - slot_updates: [], - slot_removals: [], - memories: [], - }; - - // Name extraction - const nameMatch = text.match(/tên tôi là\s+([^.,;!?\n]+)/i); - if ((nameMatch?.[1]?.trim().length ?? 0) >= 2) { - result.slot_updates.push({ - key: "profile.name", - value: nameMatch![1].trim(), - confidence: 0.85, - category: "profile", - }); - } - - // Location - const locMatch = text.match(/(?:tôi ở|tôi sống ở|mình ở|I live in)\s+([^.,;!?\n]+)/i); - if ((locMatch?.[1]?.trim().length ?? 0) >= 2) { - result.slot_updates.push({ - key: "profile.location", - value: locMatch![1].trim(), - confidence: 0.8, - category: "profile", - }); - } - - // Theme - const themeMatch = text.match(/(dark|light)\s+theme/i); - if (themeMatch) { - result.slot_updates.push({ - key: "preferences.theme", - value: themeMatch[1].toLowerCase(), - confidence: 0.9, - category: "preferences", - }); - } - - // Project - const projMatch = text.match(/(?:đang làm|working on|project)\s+([^.,;!?\n]+)/i); - if ((projMatch?.[1]?.trim().length ?? 0) >= 2) { - result.slot_updates.push({ - key: "project.current", - value: projMatch![1].trim(), - confidence: 0.75, - category: "project", - }); - } - - return result; +function extractWithPatterns(text: string): { + slot_updates: any[]; + slot_removals: any[]; + memories: any[]; +} { + const result: { slot_updates: any[]; slot_removals: any[]; memories: any[] } = + { + slot_updates: [], + slot_removals: [], + memories: [], + }; + + // Name extraction + const nameMatch = text.match(/tên tôi là\s+([^.,;!?\n]+)/i); + if ((nameMatch?.[1]?.trim().length ?? 0) >= 2) { + result.slot_updates.push({ + key: "profile.name", + value: nameMatch![1].trim(), + confidence: 0.85, + category: "profile", + }); + } + + // Location + const locMatch = text.match( + /(?:tôi ở|tôi sống ở|mình ở|I live in)\s+([^.,;!?\n]+)/i, + ); + if ((locMatch?.[1]?.trim().length ?? 0) >= 2) { + result.slot_updates.push({ + key: "profile.location", + value: locMatch![1].trim(), + confidence: 0.8, + category: "profile", + }); + } + + // Theme + const themeMatch = text.match(/(dark|light)\s+theme/i); + if (themeMatch) { + result.slot_updates.push({ + key: "preferences.theme", + value: themeMatch[1].toLowerCase(), + confidence: 0.9, + category: "preferences", + }); + } + + // Project + const projMatch = text.match( + /(?:đang làm|working on|project)\s+([^.,;!?\n]+)/i, + ); + if ((projMatch?.[1]?.trim().length ?? 0) >= 2) { + result.slot_updates.push({ + key: "project.current", + value: projMatch![1].trim(), + confidence: 0.75, + category: "project", + }); + } + + return result; } - - async function embedWithMetadataCompat( - embedding: any, - text: string, + embedding: any, + text: string, ): Promise<{ vector: number[]; metadata: Record }> { - if (embedding && typeof embedding.embedDetailed === "function") { - return embedding.embedDetailed(text); - } - - const vector = await embedding.embed(text); - return { - vector, - metadata: { - embedding_chunked: false, - embedding_chunks_count: 1, - embedding_chunking_strategy: "array_batch_weighted_avg", - embedding_model: "unknown", - embedding_max_tokens: 0, - embedding_safe_chunk_tokens: 0, - }, - }; + if (embedding && typeof embedding.embedDetailed === "function") { + return embedding.embedDetailed(text); + } + + const vector = await embedding.embed(text); + return { + vector, + metadata: { + embedding_chunked: false, + embedding_chunks_count: 1, + embedding_chunking_strategy: "array_batch_weighted_avg", + embedding_model: "unknown", + embedding_max_tokens: 0, + embedding_safe_chunk_tokens: 0, + }, + }; } async function storeSemanticMemory( - qdrant: QdrantClient, - embedding: EmbeddingClient, - text: string, - namespace: string, - payloadExtras: Record = {}, + qdrant: QdrantClient, + embedding: EmbeddingClient, + text: string, + namespace: MemoryNamespace, + payloadExtras: Record = {}, ): Promise { - const normalizedText = typeof text === "string" ? text.trim() : ""; - if (!normalizedText) { - console.warn(`[AutoCapture] Skip semantic memory upsert: empty text (namespace=${namespace})`); - return; - } - - const embeddingResult = await embedWithMetadataCompat(embedding as any, normalizedText); - const vector = embeddingResult.vector; - const sourceAgent = String((payloadExtras as any)?.source_agent || "assistant"); - await qdrant.upsert([ - { - id: crypto.randomUUID(), - vector, - payload: { - text: normalizedText, - agent: toCoreAgent(sourceAgent), - namespace, - ...embeddingResult.metadata, - metadata: { - ...((payloadExtras as any)?.metadata || {}), - ...embeddingResult.metadata, - }, - timestamp: Date.now(), - ...payloadExtras, - }, - }, - ]); + const normalizedText = typeof text === "string" ? text.trim() : ""; + if (!normalizedText) { + console.warn( + `[AutoCapture] Skip semantic memory upsert: empty text (namespace=${namespace})`, + ); + return; + } + + const embeddingResult = await embedWithMetadataCompat( + embedding as any, + normalizedText, + ); + const vector = embeddingResult.vector; + const sourceAgent = String( + (payloadExtras as any)?.source_agent || "assistant", + ); + const sourceType = String( + (payloadExtras as any)?.source_type || "auto_capture", + ) as any; + const lifecycle = resolvePromotionMetadata({ + namespace, + sourceType, + memoryType: (payloadExtras as any)?.memory_type, + promotionState: (payloadExtras as any)?.promotion_state, + confidence: + typeof (payloadExtras as any)?.confidence === "number" + ? (payloadExtras as any).confidence + : undefined, + }); + await qdrant.upsert([ + { + id: crypto.randomUUID(), + vector, + payload: { + text: normalizedText, + agent: toCoreAgent(sourceAgent), + namespace, + ...embeddingResult.metadata, + metadata: { + ...((payloadExtras as any)?.metadata || {}), + ...embeddingResult.metadata, + }, + memory_type: lifecycle.memoryType, + promotion_state: lifecycle.promotionState, + confidence: lifecycle.confidence, + timestamp: Date.now(), + ...payloadExtras, + }, + }, + ]); } export function captureShortTermState( - db: SlotDB, - userId: string, - agentId: string, - messages: ConversationMessage[], - activeContext: string, - actionsSinceLastCapture: number, + db: SlotDB, + userId: string, + agentId: string, + messages: ConversationMessage[], + activeContext: string, + actionsSinceLastCapture: number, ): boolean { - if (actionsSinceLastCapture < 3) return false; - - const summary = summarizeProjectLivingState(messages); - const shortTermValue: LivingStateSummary = { - last_actions: summary.last_actions.slice(-5), - current_focus: summary.current_focus, - next_steps: summary.next_steps.slice(0, 3), - active_context: activeContext, - timestamp: Date.now(), - ttl: SHORT_TERM_TTL_MS, - }; - - db.set(userId, agentId, { - key: "project_living_state", - value: shortTermValue, - category: "project", - source: "auto_capture", - confidence: 0.85, - expires_at: toExpiryIso(SHORT_TERM_TTL_MS), - }); - - return true; + if (actionsSinceLastCapture < 3) return false; + + const summary = summarizeProjectLivingState(messages); + const shortTermValue: LivingStateSummary = { + last_actions: summary.last_actions.slice(-5), + current_focus: summary.current_focus, + next_steps: summary.next_steps.slice(0, 3), + active_context: activeContext, + timestamp: Date.now(), + ttl: SHORT_TERM_TTL_MS, + }; + + db.set(userId, agentId, { + key: "project_living_state", + value: shortTermValue, + category: "project", + source: "auto_capture", + confidence: 0.85, + expires_at: toExpiryIso(SHORT_TERM_TTL_MS), + }); + + return true; } export async function captureMidTermSummary( - db: SlotDB, - qdrant: QdrantClient, - embedding: EmbeddingClient, - input: { - userId: string; - agentId: string; - sessionKey: string; - messages: ConversationMessage[]; - sessionEnding?: boolean; - lastMidTermCaptureAt?: number; - now?: number; - } + db: SlotDB, + qdrant: QdrantClient, + embedding: EmbeddingClient, + input: { + userId: string; + agentId: string; + sessionKey: string; + messages: ConversationMessage[]; + sessionEnding?: boolean; + lastMidTermCaptureAt?: number; + now?: number; + }, ): Promise<{ stored: boolean; capturedAt: number }> { - const now = input.now ?? Date.now(); - const lastCaptured = input.lastMidTermCaptureAt ?? 0; - const shouldCreateMidTermSummary = Boolean(input.sessionEnding) || now - lastCaptured >= ONE_DAY_MS; - - if (!shouldCreateMidTermSummary) { - return { stored: false, capturedAt: lastCaptured }; - } - - const dateKey = getDateKey(new Date(now)); - const daySummary = buildDaySummary(input.messages); - const extractedDecisions = extractDecisions(input.messages); - const trackedOutcomes = extractOutcomes(input.messages); - const sessionSummary: SessionSummaryValue = { - summary: daySummary, - key_decisions: extractedDecisions, - outcomes: trackedOutcomes, - ttl: MID_TERM_TTL_MS, - timestamp: now, - }; - - db.set(input.userId, input.agentId, { - key: `session.${dateKey}.summary`, - value: sessionSummary, - category: "custom", - source: "auto_capture", - confidence: 0.9, - expires_at: new Date(now + MID_TERM_TTL_MS).toISOString(), - }); - - await storeSemanticMemory(qdrant, embedding, daySummary, normalizeNamespace("shared.runbooks", input.agentId), { - date: dateKey, - session_id: input.sessionKey, - source_agent: input.agentId, - source_type: "auto_capture", - userId: input.userId, - metadata: { - date: dateKey, - session_id: input.sessionKey, - }, - }); - - return { stored: true, capturedAt: now }; + const now = input.now ?? Date.now(); + const lastCaptured = input.lastMidTermCaptureAt ?? 0; + const shouldCreateMidTermSummary = + Boolean(input.sessionEnding) || now - lastCaptured >= ONE_DAY_MS; + + if (!shouldCreateMidTermSummary) { + return { stored: false, capturedAt: lastCaptured }; + } + + const dateKey = getDateKey(new Date(now)); + const daySummary = buildDaySummary(input.messages); + const extractedDecisions = extractDecisions(input.messages); + const trackedOutcomes = extractOutcomes(input.messages); + const sessionSummary: SessionSummaryValue = { + summary: daySummary, + key_decisions: extractedDecisions, + outcomes: trackedOutcomes, + ttl: MID_TERM_TTL_MS, + timestamp: now, + }; + + db.set(input.userId, input.agentId, { + key: `session.${dateKey}.summary`, + value: sessionSummary, + category: "custom", + source: "auto_capture", + confidence: 0.9, + expires_at: new Date(now + MID_TERM_TTL_MS).toISOString(), + }); + + await storeSemanticMemory( + qdrant, + embedding, + daySummary, + normalizeNamespace("shared.runbooks", input.agentId), + { + date: dateKey, + session_id: input.sessionKey, + source_agent: input.agentId, + source_type: "auto_capture", + userId: input.userId, + metadata: { + date: dateKey, + session_id: input.sessionKey, + }, + }, + ); + + return { stored: true, capturedAt: now }; } export async function captureLongTermPattern( - qdrant: QdrantClient, - embedding: EmbeddingClient, - input: { text: string; agentId: string; userId: string } + qdrant: QdrantClient, + embedding: EmbeddingClient, + input: { text: string; agentId: string; userId: string }, ): Promise { - if (!detectImportantPattern(input.text)) { - return false; - } - - await storeSemanticMemory(qdrant, embedding, input.text, normalizeNamespace(`agent.${toCoreAgent(input.agentId)}.lessons`, input.agentId), { - entity_type: "principle", - source_agent: input.agentId, - source_type: "auto_capture", - userId: input.userId, - }); - - return true; + if (!detectImportantPattern(input.text)) { + return false; + } + + await storeSemanticMemory( + qdrant, + embedding, + input.text, + normalizeNamespace( + `agent.${toCoreAgent(input.agentId)}.lessons`, + input.agentId, + ), + { + entity_type: "principle", + source_agent: input.agentId, + source_type: "auto_capture", + userId: input.userId, + }, + ); + + return true; } /** * Register auto-capture */ export function registerAutoCapture( - api: OpenClawPluginApi, - db: SlotDB, - qdrant: QdrantClient, - embedding: EmbeddingClient, - dedupe: DeduplicationService, - config?: Partial, + api: OpenClawPluginApi, + db: SlotDB, + qdrant: QdrantClient, + embedding: EmbeddingClient, + dedupe: DeduplicationService, + config?: Partial, ): void { - const cfg: AutoCaptureConfig = { ...DEFAULT_CONFIG, ...config }; - - if (!cfg.enabled) { - console.log("[AutoCapture] Disabled"); - return; - } - - console.log(`[AutoCapture] Enabled (LLM: ${cfg.useLLM})`); - - // Lock to prevent re-entrant/infinite loops - let isCapturing = false; - - // Auto-summarize counters (per process lifecycle) - let actionCounter = 0; - let actionsSinceLastCapture = 0; - let lastMidTermCaptureAt = Date.now(); - const summarizeEvery = Math.max(1, cfg.summarizeEveryActions ?? 6); - - - // Manual capture tool - api.registerTool({ - name: "memory_auto_capture", - label: "Memory Auto Capture", - description: "Analyze text and extract facts using LLM or pattern matching", - parameters: { - type: "object", - properties: { - text: { type: "string", description: "Text to analyze" }, - use_llm: { type: "boolean", description: "Use LLM for extraction (default: true)" }, - }, - required: ["text"], - }, - async execute(_id: string, params: { text: string; use_llm?: boolean }, ctx: any) { - try { - const sessionKey = ctx?.sessionKey || "agent:main:default"; - const agentId = sessionKey.split(":")[1] || "main"; - const userId = normalizeUserId(sessionKey.split(":").slice(2).join(":") || "default"); - - const messages = [{ role: "user" as const, content: params.text }]; - const currentState = db.getCurrentState(userId, agentId); - - // Pass use_llm param to override config - const distillMode = inferDistillMode(agentId, params.text); - const extracted = await extractFacts(messages, currentState, cfg, params.use_llm, distillMode); - - // Process slot removals first (manual tool parity with hook behavior) - let slotsRemoved = 0; - if (extracted.slot_removals && extracted.slot_removals.length > 0) { - for (const removal of extracted.slot_removals) { - try { - const deleted = db.delete(userId, agentId, removal.key); - if (deleted) slotsRemoved++; - } catch (e) { - console.error("[AutoCapture] Failed to remove slot:", e); - } - } - } - - // Store slots - let slotsStored = 0; - - for (const fact of extracted.slot_updates) { - if (fact.confidence < cfg.minConfidence!) continue; - - try { - db.set(userId, agentId, { - key: fact.key, - value: fact.value, - category: fact.category, - source: "auto_capture", - confidence: fact.confidence, - }); - slotsStored++; - } catch (e) { - console.error("[AutoCapture] Failed to store:", e); - } - } - - return { - content: [{ - type: "text", - text: `✅ Extraction complete!\nMethod: ${params.use_llm !== false ? "LLM" : "Pattern"}\nSlots stored: ${slotsStored}\nSlots removed: ${slotsRemoved}\n\nExtracted:\n${JSON.stringify(extracted, null, 2)}`, - }], - details: { extracted, slotsStored, slotsRemoved }, - }; - } catch (error: any) { - return { - content: [{ type: "text", text: `❌ Error: ${error.message}` }], - details: { error: error.message }, - }; - } - }, - }); - - console.log("[AutoCapture] Registered memory_auto_capture tool"); - - // Auto-capture hook after each conversation turn using type-safe event name - api.on(AGENT_END_EVENT, async (event: unknown, ctx: unknown) => { - // Prevent re-entrant/infinite loops - if (isCapturing) { - console.log("[AutoCapture] Skipping: capture already in progress"); - return; - } - - try { - isCapturing = true; - - // Type-safe casting for runtime values - const typedEvent = event as { messages?: unknown[]; response?: string; metadata?: Record }; - const typedCtx = ctx as { sessionKey?: string; channel?: string; messageChannel?: string }; - - const sessionKey = typedCtx?.sessionKey ?? "agent:main:default"; - const agentId = sessionKey.split(":")[1] || "main"; - const userId = normalizeUserId(sessionKey.split(":").slice(2).join(":") || "default"); - - // HEARTBEAT SKIP: heartbeat triggers agent_end but re-scans same old messages → wastes LLM tokens - // agent_end ctx passes messageProvider (not messageChannel) — for heartbeat runs it equals "heartbeat" - const messageProvider = (typedCtx as any)?.messageProvider || ""; - if (messageProvider === "heartbeat") { - console.log(`[AutoCapture] Skipping: heartbeat channel (no new user content to capture)`); - return; - } - - // 5-agent capture eligibility: no coarse blocklist applied by default - - // Get conversation messages from event with type-safe access - const messages = (typedEvent?.messages ?? []) as ConversationMessage[]; - if (messages.length === 0) return; - - // Skip if only system messages - const hasUserOrAssistant = messages.some((m: any) => - m.role === "user" || m.role === "assistant" - ); - if (!hasUserOrAssistant) return; - - // Skip messages that look like internal AutoCapture messages (prevent self-triggering) - const hasAutoCaptureSource = messages.some((m: any) => { - const text = extractMessageText(m.content); - return text.includes("[AutoCapture]") || text.includes("Memory stored") || text.includes("Memory updated"); - }); - if (hasAutoCaptureSource) { - console.log("[AutoCapture] Skipping: conversation contains AutoCapture internal messages"); - return; - } - - // Use a wider window than just last 4 messages so model can see - // transition language like "đã xong", "move to phase X", etc. - // extractFacts() will still enforce token budget. - const captureWindowMessages = messages.slice(-12); - - // Hash content để detect duplicate - const turnText = captureWindowMessages - .map((m: any) => `${m.role}: ${extractMessageText(m.content)}`) - .join("\n"); - - // Skip empty/noise turns: NO_REPLY, HEARTBEAT_OK, tool-only responses - const trimmedText = turnText.replace(/^(user|assistant|system):\s*/gm, '').trim(); - const noisePatterns = [ - /^NO_REPLY$/i, - /^HEARTBEAT_OK$/i, - /^\[Tool:/, - /^\{"type":"toolCall"/, - /^$/, - ]; - const meaningfulLines = trimmedText.split('\n').filter(line => { - const l = line.trim(); - return l.length > 0 && !noisePatterns.some(p => p.test(l)); - }); - if (meaningfulLines.length === 0) { - console.log(`[AutoCapture] Skipping: no meaningful content (NO_REPLY/HEARTBEAT_OK/tool-only)`); - return; - } - - const contentHash = crypto.createHash('sha256').update(turnText).digest('hex').slice(0, 16); - - // Check hash từ SlotDB (persist qua restart) - const hashKey = '_autocapture_hash'; - const existingSlot = db.get(userId, agentId, { key: hashKey }); - const existingHash = Array.isArray(existingSlot) ? undefined : existingSlot?.value; - - if (existingHash === contentHash) { - console.log(`[AutoCapture] Skipping: content hash unchanged (${contentHash})`); - return; - } - - console.log(`[AutoCapture] New content detected (hash: ${String(existingHash)?.slice(0,8)}→${contentHash.slice(0,8)})`); - - // Combine all message text for noise detection and namespace routing - const fullText = captureWindowMessages - .map((m: any) => extractMessageText(m.content)) - .join(" "); - - // Namespace router v2 (ASM-5) - const coreAgent = toCoreAgent(agentId); - let targetNamespace: MemoryNamespace = getAutoCaptureNamespace(coreAgent, fullText); - - // Noise policy v2: quarantine into noise.filtered instead of skipping - const noiseEval = evaluateNoiseV2(fullText, "auto_capture"); - if (!isLearningContent(fullText) && noiseEval.isNoise) { - targetNamespace = "noise.filtered" as MemoryNamespace; - console.log(`[AutoCapture] Noise detected (score=${noiseEval.score}) → quarantine namespace=noise.filtered`); - } - - console.log(`[AutoCapture] Processing ${captureWindowMessages.length} recent messages for ${agentId} (namespace: ${targetNamespace})`); - - const currentState = db.getCurrentState(userId, agentId); - const distillMode = inferDistillMode(agentId, fullText); - console.log(`[AutoCapture] Distill mode: ${distillMode} (agent: ${agentId})`); - const extracted = await extractFacts(captureWindowMessages, currentState, cfg, undefined, distillMode); - - // Process slot REMOVALS first (invalidation) - let slotsRemoved = 0; - if (extracted.slot_removals && extracted.slot_removals.length > 0) { - for (const removal of extracted.slot_removals) { - try { - const deleted = db.delete(userId, agentId, removal.key); - if (deleted) { - slotsRemoved++; - console.log(`[AutoCapture] Removed stale slot: ${removal.key} (reason: ${removal.reason})`); - } - } catch (e) { - console.error("[AutoCapture] Failed to remove slot:", e); - } - } - } - - // Store slots - let slotsStored = 0; - - // Save hash to SlotDB for next comparison - db.set(userId, agentId, { - key: hashKey, - value: contentHash, - category: "custom", - source: "auto_capture", - confidence: 1.0, - }); - for (const fact of extracted.slot_updates) { - if (fact.confidence < cfg.minConfidence!) continue; - try { - db.set(userId, agentId, { - key: fact.key, - value: fact.value, - category: fact.category, - source: "auto_capture", - confidence: fact.confidence, - }); - slotsStored++; - console.log(`[AutoCapture] Stored: ${fact.key} = ${JSON.stringify(fact.value)}`); - } catch (e) { - console.error("[AutoCapture] Failed to store slot:", e); - } - } - - // Store memories to Qdrant - let memoriesStored = 0; - console.log(`[AutoCapture] Extracted ${extracted.memories.length} memories, ${extracted.slot_updates.length} slot updates`); - - if (extracted.memories.length > 0) { - console.log(`[AutoCapture] Starting Qdrant storage for ${extracted.memories.length} memories...`); - - for (let i = 0; i < extracted.memories.length; i++) { - const memory = extracted.memories[i]; - console.log(`[AutoCapture] Processing memory ${i + 1}/${extracted.memories.length}...`); - - try { - const text = typeof memory === "string" ? memory : memory.text || JSON.stringify(memory); - if (!text || text.trim().length === 0) { - console.warn(`[AutoCapture] Memory ${i + 1} has empty text, skipping`); - continue; - } - - console.log(`[AutoCapture] Generating embedding for: "${text.substring(0, 60)}..."`); - let vector: number[]; - let embeddingMeta: Record = {}; - try { - const embeddingResult = await embedWithMetadataCompat(embedding as any, text); - vector = embeddingResult.vector; - embeddingMeta = embeddingResult.metadata; - console.log(`[AutoCapture] Embedding generated, vector length: ${vector.length}, chunks=${embeddingResult.metadata.embedding_chunks_count}`); - } catch (embedError: any) { - console.error(`[AutoCapture] Embedding failed for memory ${i + 1}:`, embedError.message); - continue; - } - - // Check for duplicates (scoped to target namespace) - console.log(`[AutoCapture] Searching for duplicates in namespace: ${targetNamespace}...`); - let candidates: any[] = []; - try { - candidates = await qdrant.search(vector, 5, { - must: [{ key: "namespace", match: { value: targetNamespace } }], - }); - console.log(`[AutoCapture] Found ${candidates.length} candidate matches`); - } catch (searchError: any) { - console.error(`[AutoCapture] Duplicate search failed:`, searchError.message); - candidates = []; - } - - const duplicateId = dedupe.findDuplicate(text, candidates); - console.log(`[AutoCapture] Duplicate check result: ${duplicateId ? `found duplicate ${duplicateId}` : "no duplicate"}`); - - if (duplicateId) { - // Update existing memory - console.log(`[AutoCapture] Updating existing memory ${duplicateId}...`); - try { - await qdrant.upsert([{ - id: duplicateId, - vector, - payload: { - text, - agent: coreAgent, - namespace: targetNamespace, - source_agent: coreAgent, - source_type: "auto_capture", - userId: userId, - ...embeddingMeta, - metadata: { - ...embeddingMeta, - }, - timestamp: Date.now(), - updatedAt: Date.now(), - }, - }]); - console.log(`[AutoCapture] ✓ Memory updated (duplicate): ${text.substring(0, 50)}...`); - memoriesStored++; - } catch (upsertError: any) { - console.error(`[AutoCapture] Failed to update duplicate memory:`, upsertError.message); - } - } else { - // Create new memory - const id = crypto.randomUUID(); - console.log(`[AutoCapture] Creating new memory with ID: ${id}...`); - try { - await qdrant.upsert([{ - id, - vector, - payload: { - text, - agent: coreAgent, - namespace: targetNamespace, - source_agent: coreAgent, - source_type: "auto_capture", - userId: userId, - ...embeddingMeta, - metadata: { - ...embeddingMeta, - }, - timestamp: Date.now(), - }, - }]); - console.log(`[AutoCapture] ✓ Memory stored: ${text.substring(0, 50)}...`); - memoriesStored++; - } catch (upsertError: any) { - console.error(`[AutoCapture] Failed to store new memory:`, upsertError.message); - } - } - } catch (e: any) { - console.error(`[AutoCapture] Unexpected error processing memory ${i + 1}:`, e.message); - console.error(`[AutoCapture] Stack:`, e.stack); - } - } - console.log(`[AutoCapture] Memory storage complete: ${memoriesStored}/${extracted.memories.length} stored`); - } else { - console.log(`[AutoCapture] No memories to store (empty extraction result)`); - } - - // Auto-summarize project living state after every N actions OR task transition - actionCounter += 1; - actionsSinceLastCapture += 1; - const transitionKeys = new Set([ - "project.current", - "project.current_task", - "project.current_epic", - "project.phase", - "project.status", - ]); - const hasTaskTransition = - extracted.slot_updates.some((s: any) => transitionKeys.has(s.key)) || - extracted.slot_removals.some((s: any) => transitionKeys.has(s.key)); - const shouldSummarize = actionCounter % summarizeEvery === 0 || hasTaskTransition; - - if (shouldSummarize || actionsSinceLastCapture >= 3) { - try { - const stored = captureShortTermState( - db, - userId, - agentId, - captureWindowMessages, - fullText, - actionsSinceLastCapture, - ); - if (stored) { - actionsSinceLastCapture = 0; - console.log( - `[AutoCapture] Updated slot: project_living_state (${hasTaskTransition ? "task transition" : `every ${summarizeEvery} actions`})` - ); - } - } catch (summaryError) { - console.error("[AutoCapture] Failed to update project_living_state:", summaryError); - } - } - - const now = Date.now(); - const sessionEnding = Boolean((typedEvent?.metadata as any)?.sessionEnding); - - try { - const midTerm = await captureMidTermSummary(db, qdrant, embedding, { - userId, - agentId, - sessionKey, - messages, - sessionEnding, - lastMidTermCaptureAt, - now, - }); - if (midTerm.stored) { - lastMidTermCaptureAt = midTerm.capturedAt; - console.log(`[AutoCapture] Stored mid-term session summary for ${getDateKey(new Date(midTerm.capturedAt))}`); - } - } catch (midTermError) { - console.error("[AutoCapture] Failed to create mid-term session summary:", midTermError); - } - - try { - const storedPattern = await captureLongTermPattern(qdrant, embedding, { - text: fullText, - agentId, - userId, - }); - if (storedPattern) { - console.log("[AutoCapture] Stored long-term market pattern"); - } - } catch (patternError) { - console.error("[AutoCapture] Failed to store long-term pattern:", patternError); - } - - if (slotsStored > 0 || memoriesStored > 0 || slotsRemoved > 0) { - console.log(`[AutoCapture] Complete: ${slotsStored} stored, ${slotsRemoved} removed, ${memoriesStored} memories`); - } - } catch (error) { - console.error("[AutoCapture] Hook error:", error); - } finally { - // Always release the lock to prevent deadlocks - isCapturing = false; - } - }); + const cfg: AutoCaptureConfig = { ...DEFAULT_CONFIG, ...config }; + + if (!cfg.enabled) { + console.log("[AutoCapture] Disabled"); + return; + } + + console.log(`[AutoCapture] Enabled (LLM: ${cfg.useLLM})`); + + // Lock to prevent re-entrant/infinite loops + let isCapturing = false; + + // Auto-summarize counters (per process lifecycle) + let actionCounter = 0; + let actionsSinceLastCapture = 0; + let lastMidTermCaptureAt = Date.now(); + const summarizeEvery = Math.max(1, cfg.summarizeEveryActions ?? 6); + + // Manual capture tool + api.registerTool({ + name: "memory_auto_capture", + label: "Memory Auto Capture", + description: "Analyze text and extract facts using LLM or pattern matching", + parameters: { + type: "object", + properties: { + text: { type: "string", description: "Text to analyze" }, + use_llm: { + type: "boolean", + description: "Use LLM for extraction (default: true)", + }, + }, + required: ["text"], + }, + async execute( + _id: string, + params: { text: string; use_llm?: boolean }, + ctx: any, + ) { + try { + const sessionKey = ctx?.sessionKey || "agent:main:default"; + const agentId = sessionKey.split(":")[1] || "main"; + const userId = normalizeUserId( + sessionKey.split(":").slice(2).join(":") || "default", + ); + + const messages = [{ role: "user" as const, content: params.text }]; + const currentState = db.getCurrentState(userId, agentId); + + // Pass use_llm param to override config + const distillMode = inferDistillMode(agentId, params.text); + const extracted = await extractFacts( + messages, + currentState, + cfg, + params.use_llm, + distillMode, + ); + + // Process slot removals first (manual tool parity with hook behavior) + let slotsRemoved = 0; + if (extracted.slot_removals && extracted.slot_removals.length > 0) { + for (const removal of extracted.slot_removals) { + try { + const deleted = db.delete(userId, agentId, removal.key); + if (deleted) slotsRemoved++; + } catch (e) { + console.error("[AutoCapture] Failed to remove slot:", e); + } + } + } + + // Store slots + let slotsStored = 0; + + for (const fact of extracted.slot_updates) { + if (fact.confidence < cfg.minConfidence!) continue; + + try { + db.set(userId, agentId, { + key: fact.key, + value: fact.value, + category: fact.category, + source: "auto_capture", + confidence: fact.confidence, + }); + slotsStored++; + } catch (e) { + console.error("[AutoCapture] Failed to store:", e); + } + } + + return { + content: [ + { + type: "text", + text: `✅ Extraction complete!\nMethod: ${params.use_llm !== false ? "LLM" : "Pattern"}\nSlots stored: ${slotsStored}\nSlots removed: ${slotsRemoved}\n\nExtracted:\n${JSON.stringify(extracted, null, 2)}`, + }, + ], + details: { extracted, slotsStored, slotsRemoved }, + }; + } catch (error: any) { + return { + content: [{ type: "text", text: `❌ Error: ${error.message}` }], + details: { error: error.message }, + }; + } + }, + }); + + console.log("[AutoCapture] Registered memory_auto_capture tool"); + + // Auto-capture hook after each conversation turn using type-safe event name + api.on(AGENT_END_EVENT, async (event: unknown, ctx: unknown) => { + // Prevent re-entrant/infinite loops + if (isCapturing) { + console.log("[AutoCapture] Skipping: capture already in progress"); + return; + } + + try { + isCapturing = true; + + // Type-safe casting for runtime values + const typedEvent = event as { + messages?: unknown[]; + response?: string; + metadata?: Record; + }; + const typedCtx = ctx as { + sessionKey?: string; + channel?: string; + messageChannel?: string; + }; + + const sessionKey = typedCtx?.sessionKey ?? "agent:main:default"; + const agentId = sessionKey.split(":")[1] || "main"; + const userId = normalizeUserId( + sessionKey.split(":").slice(2).join(":") || "default", + ); + + // HEARTBEAT SKIP: heartbeat triggers agent_end but re-scans same old messages → wastes LLM tokens + // agent_end ctx passes messageProvider (not messageChannel) — for heartbeat runs it equals "heartbeat" + const messageProvider = (typedCtx as any)?.messageProvider || ""; + if (messageProvider === "heartbeat") { + console.log( + `[AutoCapture] Skipping: heartbeat channel (no new user content to capture)`, + ); + return; + } + + // 5-agent capture eligibility: no coarse blocklist applied by default + + // Get conversation messages from event with type-safe access + const messages = (typedEvent?.messages ?? []) as ConversationMessage[]; + if (messages.length === 0) return; + + // Skip if only system messages + const hasUserOrAssistant = messages.some( + (m: any) => m.role === "user" || m.role === "assistant", + ); + if (!hasUserOrAssistant) return; + + // Skip messages that look like internal AutoCapture messages (prevent self-triggering) + const hasAutoCaptureSource = messages.some((m: any) => { + const text = extractMessageText(m.content); + return ( + text.includes("[AutoCapture]") || + text.includes("Memory stored") || + text.includes("Memory updated") + ); + }); + if (hasAutoCaptureSource) { + console.log( + "[AutoCapture] Skipping: conversation contains AutoCapture internal messages", + ); + return; + } + + // Use a wider window than just last 4 messages so model can see + // transition language like "đã xong", "move to phase X", etc. + // extractFacts() will still enforce token budget. + const captureWindowMessages = messages.slice(-12); + + // Hash content để detect duplicate + const turnText = captureWindowMessages + .map((m: any) => `${m.role}: ${extractMessageText(m.content)}`) + .join("\n"); + + // Skip empty/noise turns: NO_REPLY, HEARTBEAT_OK, tool-only responses + const trimmedText = turnText + .replace(/^(user|assistant|system):\s*/gm, "") + .trim(); + const noisePatterns = [ + /^NO_REPLY$/i, + /^HEARTBEAT_OK$/i, + /^\[Tool:/, + /^\{"type":"toolCall"/, + /^$/, + ]; + const meaningfulLines = trimmedText.split("\n").filter((line) => { + const l = line.trim(); + return l.length > 0 && !noisePatterns.some((p) => p.test(l)); + }); + if (meaningfulLines.length === 0) { + console.log( + `[AutoCapture] Skipping: no meaningful content (NO_REPLY/HEARTBEAT_OK/tool-only)`, + ); + return; + } + + const contentHash = crypto + .createHash("sha256") + .update(turnText) + .digest("hex") + .slice(0, 16); + + // Check hash từ SlotDB (persist qua restart) + const hashKey = "_autocapture_hash"; + const existingSlot = db.get(userId, agentId, { key: hashKey }); + const existingHash = Array.isArray(existingSlot) + ? undefined + : existingSlot?.value; + + if (existingHash === contentHash) { + console.log( + `[AutoCapture] Skipping: content hash unchanged (${contentHash})`, + ); + return; + } + + console.log( + `[AutoCapture] New content detected (hash: ${String(existingHash)?.slice(0, 8)}→${contentHash.slice(0, 8)})`, + ); + + // Combine all message text for noise detection and namespace routing + const fullText = captureWindowMessages + .map((m: any) => extractMessageText(m.content)) + .join(" "); + + // Namespace router v2 (ASM-5) + const coreAgent = toCoreAgent(agentId); + let targetNamespace: MemoryNamespace = getAutoCaptureNamespace( + coreAgent, + fullText, + ); + + // Noise policy v2: quarantine into noise.filtered instead of skipping + const noiseEval = evaluateNoiseV2(fullText, "auto_capture"); + if (!isLearningContent(fullText) && noiseEval.isNoise) { + targetNamespace = "noise.filtered" as MemoryNamespace; + console.log( + `[AutoCapture] Noise detected (score=${noiseEval.score}) → quarantine namespace=noise.filtered`, + ); + } + + console.log( + `[AutoCapture] Processing ${captureWindowMessages.length} recent messages for ${agentId} (namespace: ${targetNamespace})`, + ); + + const currentState = db.getCurrentState(userId, agentId); + const distillMode = inferDistillMode(agentId, fullText); + console.log( + `[AutoCapture] Distill mode: ${distillMode} (agent: ${agentId})`, + ); + const extracted = await extractFacts( + captureWindowMessages, + currentState, + cfg, + undefined, + distillMode, + ); + + // Process slot REMOVALS first (invalidation) + let slotsRemoved = 0; + if (extracted.slot_removals && extracted.slot_removals.length > 0) { + for (const removal of extracted.slot_removals) { + try { + const deleted = db.delete(userId, agentId, removal.key); + if (deleted) { + slotsRemoved++; + console.log( + `[AutoCapture] Removed stale slot: ${removal.key} (reason: ${removal.reason})`, + ); + } + } catch (e) { + console.error("[AutoCapture] Failed to remove slot:", e); + } + } + } + + // Store slots + let slotsStored = 0; + + // Save hash to SlotDB for next comparison + db.set(userId, agentId, { + key: hashKey, + value: contentHash, + category: "custom", + source: "auto_capture", + confidence: 1.0, + }); + for (const fact of extracted.slot_updates) { + if (fact.confidence < cfg.minConfidence!) continue; + try { + db.set(userId, agentId, { + key: fact.key, + value: fact.value, + category: fact.category, + source: "auto_capture", + confidence: fact.confidence, + }); + slotsStored++; + console.log( + `[AutoCapture] Stored: ${fact.key} = ${JSON.stringify(fact.value)}`, + ); + } catch (e) { + console.error("[AutoCapture] Failed to store slot:", e); + } + } + + // Store memories to Qdrant + let memoriesStored = 0; + console.log( + `[AutoCapture] Extracted ${extracted.memories.length} memories, ${extracted.slot_updates.length} slot updates`, + ); + + if (extracted.memories.length > 0) { + console.log( + `[AutoCapture] Starting Qdrant storage for ${extracted.memories.length} memories...`, + ); + + for (let i = 0; i < extracted.memories.length; i++) { + const memory = extracted.memories[i]; + console.log( + `[AutoCapture] Processing memory ${i + 1}/${extracted.memories.length}...`, + ); + + try { + const text = + typeof memory === "string" + ? memory + : memory.text || JSON.stringify(memory); + if (!text || text.trim().length === 0) { + console.warn( + `[AutoCapture] Memory ${i + 1} has empty text, skipping`, + ); + continue; + } + + console.log( + `[AutoCapture] Generating embedding for: "${text.substring(0, 60)}..."`, + ); + let vector: number[]; + let embeddingMeta: Record = {}; + try { + const embeddingResult = await embedWithMetadataCompat( + embedding as any, + text, + ); + vector = embeddingResult.vector; + embeddingMeta = embeddingResult.metadata; + console.log( + `[AutoCapture] Embedding generated, vector length: ${vector.length}, chunks=${embeddingResult.metadata.embedding_chunks_count}`, + ); + } catch (embedError: any) { + console.error( + `[AutoCapture] Embedding failed for memory ${i + 1}:`, + embedError.message, + ); + continue; + } + + // Check for duplicates (scoped to target namespace) + console.log( + `[AutoCapture] Searching for duplicates in namespace: ${targetNamespace}...`, + ); + let candidates: any[] = []; + try { + candidates = await qdrant.search(vector, 5, { + must: [{ key: "namespace", match: { value: targetNamespace } }], + }); + console.log( + `[AutoCapture] Found ${candidates.length} candidate matches`, + ); + } catch (searchError: any) { + console.error( + `[AutoCapture] Duplicate search failed:`, + searchError.message, + ); + candidates = []; + } + + const duplicateId = dedupe.findDuplicate(text, candidates); + const lifecycle = resolvePromotionMetadata({ + namespace: targetNamespace, + sourceType: "auto_capture", + }); + console.log( + `[AutoCapture] Duplicate check result: ${duplicateId ? `found duplicate ${duplicateId}` : "no duplicate"}`, + ); + + if (duplicateId) { + // Update existing memory + console.log( + `[AutoCapture] Updating existing memory ${duplicateId}...`, + ); + try { + await qdrant.upsert([ + { + id: duplicateId, + vector, + payload: { + text, + agent: coreAgent, + namespace: targetNamespace, + source_agent: coreAgent, + source_type: "auto_capture", + memory_type: lifecycle.memoryType, + promotion_state: lifecycle.promotionState, + confidence: lifecycle.confidence, + userId: userId, + ...embeddingMeta, + metadata: { + ...embeddingMeta, + }, + timestamp: Date.now(), + updatedAt: Date.now(), + }, + }, + ]); + console.log( + `[AutoCapture] ✓ Memory updated (duplicate): ${text.substring(0, 50)}...`, + ); + memoriesStored++; + } catch (upsertError: any) { + console.error( + `[AutoCapture] Failed to update duplicate memory:`, + upsertError.message, + ); + } + } else { + // Create new memory + const id = crypto.randomUUID(); + console.log( + `[AutoCapture] Creating new memory with ID: ${id}...`, + ); + try { + await qdrant.upsert([ + { + id, + vector, + payload: { + text, + agent: coreAgent, + namespace: targetNamespace, + source_agent: coreAgent, + source_type: "auto_capture", + memory_type: lifecycle.memoryType, + promotion_state: lifecycle.promotionState, + confidence: lifecycle.confidence, + userId: userId, + ...embeddingMeta, + metadata: { + ...embeddingMeta, + }, + timestamp: Date.now(), + }, + }, + ]); + console.log( + `[AutoCapture] ✓ Memory stored: ${text.substring(0, 50)}...`, + ); + memoriesStored++; + } catch (upsertError: any) { + console.error( + `[AutoCapture] Failed to store new memory:`, + upsertError.message, + ); + } + } + } catch (e: any) { + console.error( + `[AutoCapture] Unexpected error processing memory ${i + 1}:`, + e.message, + ); + console.error(`[AutoCapture] Stack:`, e.stack); + } + } + console.log( + `[AutoCapture] Memory storage complete: ${memoriesStored}/${extracted.memories.length} stored`, + ); + } else { + console.log( + `[AutoCapture] No memories to store (empty extraction result)`, + ); + } + + // Auto-summarize project living state after every N actions OR task transition + actionCounter += 1; + actionsSinceLastCapture += 1; + const transitionKeys = new Set([ + "project.current", + "project.current_task", + "project.current_epic", + "project.phase", + "project.status", + ]); + const hasTaskTransition = + extracted.slot_updates.some((s: any) => transitionKeys.has(s.key)) || + extracted.slot_removals.some((s: any) => transitionKeys.has(s.key)); + const shouldSummarize = + actionCounter % summarizeEvery === 0 || hasTaskTransition; + + if (shouldSummarize || actionsSinceLastCapture >= 3) { + try { + const stored = captureShortTermState( + db, + userId, + agentId, + captureWindowMessages, + fullText, + actionsSinceLastCapture, + ); + if (stored) { + actionsSinceLastCapture = 0; + console.log( + `[AutoCapture] Updated slot: project_living_state (${hasTaskTransition ? "task transition" : `every ${summarizeEvery} actions`})`, + ); + } + } catch (summaryError) { + console.error( + "[AutoCapture] Failed to update project_living_state:", + summaryError, + ); + } + } + + const now = Date.now(); + const sessionEnding = Boolean( + (typedEvent?.metadata as any)?.sessionEnding, + ); + + try { + const midTerm = await captureMidTermSummary(db, qdrant, embedding, { + userId, + agentId, + sessionKey, + messages, + sessionEnding, + lastMidTermCaptureAt, + now, + }); + if (midTerm.stored) { + lastMidTermCaptureAt = midTerm.capturedAt; + console.log( + `[AutoCapture] Stored mid-term session summary for ${getDateKey(new Date(midTerm.capturedAt))}`, + ); + } + } catch (midTermError) { + console.error( + "[AutoCapture] Failed to create mid-term session summary:", + midTermError, + ); + } + + try { + const storedPattern = await captureLongTermPattern(qdrant, embedding, { + text: fullText, + agentId, + userId, + }); + if (storedPattern) { + console.log("[AutoCapture] Stored long-term market pattern"); + } + } catch (patternError) { + console.error( + "[AutoCapture] Failed to store long-term pattern:", + patternError, + ); + } + + if (slotsStored > 0 || memoriesStored > 0 || slotsRemoved > 0) { + console.log( + `[AutoCapture] Complete: ${slotsStored} stored, ${slotsRemoved} removed, ${memoriesStored} memories`, + ); + } + } catch (error) { + console.error("[AutoCapture] Hook error:", error); + } finally { + // Always release the lock to prevent deadlocks + isCapturing = false; + } + }); } diff --git a/src/hooks/auto-recall.ts b/src/hooks/auto-recall.ts index f84d6ab..9205269 100644 --- a/src/hooks/auto-recall.ts +++ b/src/hooks/auto-recall.ts @@ -6,735 +6,851 @@ */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { SlotDB } from "../db/slot-db.js"; -import { QdrantClient } from "../services/qdrant.js"; -import { EmbeddingClient } from "../services/embedding.js"; -import { getAgentNamespaces, MemoryNamespace, normalizeUserId, getNamespaceWeight } from "../shared/memory-config.js"; +import { buildRecallInjectionParts } from "../core/precedence/recall-precedence.js"; +import { + normalizeSessionToken, + scoreSemanticCandidate, +} from "../core/retrieval-policy.js"; +import type { SlotDB } from "../db/slot-db.js"; +import type { EmbeddingClient } from "../services/embedding.js"; +import type { QdrantClient } from "../services/qdrant.js"; +import { + getAgentNamespaces, + type MemoryNamespace, + normalizeUserId, +} from "../shared/memory-config.js"; // Token budget for different context types const TOKEN_BUDGETS = { - currentState: 500, - recentSlots: 300, - graphContext: 400, - semanticMemories: 600, + currentState: 500, + recentSlots: 300, + graphContext: 400, + semanticMemories: 600, }; interface RecallContext { - sessionKey: string; - stateDir: string; - userId: string; - agentId: string; + sessionKey: string; + stateDir: string; + userId: string; + agentId: string; } interface RecallHintSet { - sessionKeys: Set; - topicTags: Set; + sessionKeys: Set; + topicTags: Set; } interface SemanticMemoryCandidate { - text: string; - score: number; - namespace?: string; - payload?: Record; - adjustedScore?: number; - sameSession?: boolean; - sameProject?: boolean; - crossProject?: boolean; + text: string; + score: number; + namespace?: string; + payload?: Record; + adjustedScore?: number; + sameSession?: boolean; + sameProject?: boolean; + crossProject?: boolean; } interface SemanticSelectionResult { - memories: Array<{ text: string; score: number; namespace?: string }>; - recallConfidence: "high" | "medium" | "low"; - suppressed: boolean; - suppressionReason?: string; + memories: Array<{ text: string; score: number; namespace?: string }>; + recallConfidence: "high" | "medium" | "low"; + suppressed: boolean; + suppressionReason?: string; } /** * Format current state as XML for system prompt injection */ -function formatCurrentState(state: Record>): string { - if (Object.keys(state).length === 0) return ""; - - let xml = "\n"; - for (const [category, slots] of Object.entries(state)) { - xml += ` <${category}>\n`; - for (const [key, value] of Object.entries(slots)) { - // Skip internal keys (e.g. _autocapture_hash) - if (key.startsWith('_')) continue; - const displayKey = key.includes(".") ? key.split(".").slice(1).join(".") : key; - const displayValue = typeof value === "object" ? JSON.stringify(value) : String(value); - // Truncate long values - const truncated = displayValue.length > 100 ? displayValue.substring(0, 100) + "..." : displayValue; - xml += ` <${displayKey}>${truncated}\n`; - } - xml += ` \n`; - } - xml += ""; - return xml; +function formatCurrentState( + state: Record>, +): string { + if (Object.keys(state).length === 0) return ""; + + let xml = "\n"; + for (const [category, slots] of Object.entries(state)) { + xml += ` <${category}>\n`; + for (const [key, value] of Object.entries(slots)) { + // Skip internal keys (e.g. _autocapture_hash) + if (key.startsWith("_")) continue; + const displayKey = key.includes(".") + ? key.split(".").slice(1).join(".") + : key; + const displayValue = + typeof value === "object" ? JSON.stringify(value) : String(value); + // Truncate long values + const truncated = + displayValue.length > 100 + ? displayValue.substring(0, 100) + "..." + : displayValue; + xml += ` <${displayKey}>${truncated}\n`; + } + xml += ` \n`; + } + xml += ""; + return xml; } - function formatProjectLivingState(value: unknown): string { - if (!value || typeof value !== "object") return ""; - - const v = value as { - last_actions?: unknown; - current_focus?: unknown; - next_steps?: unknown; - }; - - const lastActions = Array.isArray(v.last_actions) - ? v.last_actions.map((x) => String(x)).slice(-5) - : []; - const currentFocus = typeof v.current_focus === "string" ? v.current_focus : ""; - const nextSteps = Array.isArray(v.next_steps) - ? v.next_steps.map((x) => String(x)).slice(0, 5) - : []; - - if (lastActions.length === 0 && !currentFocus && nextSteps.length === 0) { - return ""; - } - - const xmlEscape = (s: string) => - s.replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - - let xml = "\n"; - - if (lastActions.length > 0) { - xml += " \n"; - lastActions.forEach((a, i) => { - xml += ` ${xmlEscape(a)}\n`; - }); - xml += " \n"; - } - - if (currentFocus) { - xml += ` ${xmlEscape(currentFocus)}\n`; - } - - if (nextSteps.length > 0) { - xml += " \n"; - nextSteps.forEach((s, i) => { - xml += ` ${xmlEscape(s)}\n`; - }); - xml += " \n"; - } - - xml += ""; - return xml; + if (!value || typeof value !== "object") return ""; + + const v = value as { + last_actions?: unknown; + current_focus?: unknown; + next_steps?: unknown; + }; + + const lastActions = Array.isArray(v.last_actions) + ? v.last_actions.map((x) => String(x)).slice(-5) + : []; + const currentFocus = + typeof v.current_focus === "string" ? v.current_focus : ""; + const nextSteps = Array.isArray(v.next_steps) + ? v.next_steps.map((x) => String(x)).slice(0, 5) + : []; + + if (lastActions.length === 0 && !currentFocus && nextSteps.length === 0) { + return ""; + } + + const xmlEscape = (s: string) => + s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + let xml = "\n"; + + if (lastActions.length > 0) { + xml += " \n"; + lastActions.forEach((a, i) => { + xml += ` ${xmlEscape(a)}\n`; + }); + xml += " \n"; + } + + if (currentFocus) { + xml += ` ${xmlEscape(currentFocus)}\n`; + } + + if (nextSteps.length > 0) { + xml += " \n"; + nextSteps.forEach((s, i) => { + xml += ` ${xmlEscape(s)}\n`; + }); + xml += " \n"; + } + + xml += ""; + return xml; } /** * Format graph context showing related entities */ function formatGraphContext( - entities: Array<{ name: string; type: string }>, - relationships: Array<{ source: string; target: string; type: string }>, + entities: Array<{ name: string; type: string }>, + relationships: Array<{ source: string; target: string; type: string }>, ): string { - if (entities.length === 0) return ""; - - let xml = "\n"; - - // List entities - xml += " \n"; - entities.slice(0, 10).forEach((e) => { // Limit to 10 entities - xml += ` \n`; - }); - xml += " \n"; - - // List key relationships - if (relationships.length > 0) { - xml += " \n"; - relationships.slice(0, 8).forEach((r) => { // Limit to 8 relationships - xml += ` ${r.source} --[${r.type}]--> ${r.target}\n`; - }); - xml += " \n"; - } - - xml += ""; - return xml; + if (entities.length === 0) return ""; + + let xml = "\n"; + + // List entities + xml += " \n"; + entities.slice(0, 10).forEach((e) => { + // Limit to 10 entities + xml += ` \n`; + }); + xml += " \n"; + + // List key relationships + if (relationships.length > 0) { + xml += " \n"; + relationships.slice(0, 8).forEach((r) => { + // Limit to 8 relationships + xml += ` ${r.source} --[${r.type}]--> ${r.target}\n`; + }); + xml += " \n"; + } + + xml += ""; + return xml; } /** * Format semantic memories as XML for system prompt injection */ -function formatSemanticMemories(memories: Array<{ text: string; score: number; namespace?: string }>): string { - if (memories.length === 0) return ""; - - let xml = "\n"; - memories.forEach((m, i) => { - const nsAttr = m.namespace ? ` ns="${m.namespace}"` : ""; - xml += ` ${m.text}\n`; - }); - xml += ""; - return xml; +function formatSemanticMemories( + memories: Array<{ text: string; score: number; namespace?: string }>, +): string { + if (memories.length === 0) return ""; + + let xml = "\n"; + memories.forEach((m, i) => { + const nsAttr = m.namespace ? ` ns="${m.namespace}"` : ""; + xml += ` ${m.text}\n`; + }); + xml += ""; + return xml; } function normalizeToken(value: unknown): string { - if (value === null || value === undefined) return ""; - const s = String(value).trim().toLowerCase(); - return s; + if (value === null || value === undefined) return ""; + return normalizeSessionToken(value); } function splitToTags(input: string): string[] { - return input - .split(/[\s,;|:/\\]+/g) - .map((x) => normalizeToken(x)) - .filter((x) => x.length >= 3) - .slice(0, 12); + return input + .split(/[\s,;|:/\\]+/g) + .map((x) => normalizeToken(x)) + .filter((x) => x.length >= 3) + .slice(0, 12); } function collectRecallHints( - sessionKey: string, - projectLivingStateValue: unknown, - currentState: Record>, + sessionKey: string, + projectLivingStateValue: unknown, + currentState: Record>, ): RecallHintSet { - const hints: RecallHintSet = { - sessionKeys: new Set(), - topicTags: new Set(), - }; - - const normalizedSession = normalizeToken(sessionKey); - if (normalizedSession) hints.sessionKeys.add(normalizedSession); - - const sessionTail = normalizeToken(sessionKey.split(":").slice(2).join(":")); - if (sessionTail) hints.sessionKeys.add(sessionTail); - - const living = (projectLivingStateValue && typeof projectLivingStateValue === "object") - ? (projectLivingStateValue as Record) - : null; - - if (living) { - const activeContext = normalizeToken(living.active_context); - if (activeContext) { - hints.topicTags.add(activeContext); - splitToTags(activeContext).forEach((t) => hints.topicTags.add(t)); - } - - const currentFocus = normalizeToken(living.current_focus); - if (currentFocus) { - splitToTags(currentFocus).forEach((t) => hints.topicTags.add(t)); - } - } - - const projectState = currentState.project || {}; - for (const key of ["project.current", "project.current_epic", "project.current_task", "project.phase", "project.status"]) { - const raw = projectState[key]; - const normalized = normalizeToken(raw); - if (normalized) { - hints.topicTags.add(normalized); - splitToTags(normalized).forEach((t) => hints.topicTags.add(t)); - } - } - - return hints; + const hints: RecallHintSet = { + sessionKeys: new Set(), + topicTags: new Set(), + }; + + const normalizedSession = normalizeToken(sessionKey); + if (normalizedSession) hints.sessionKeys.add(normalizedSession); + + const sessionTail = normalizeToken(sessionKey.split(":").slice(2).join(":")); + if (sessionTail) hints.sessionKeys.add(sessionTail); + + const living = + projectLivingStateValue && typeof projectLivingStateValue === "object" + ? (projectLivingStateValue as Record) + : null; + + if (living) { + const activeContext = normalizeToken(living.active_context); + if (activeContext) { + hints.topicTags.add(activeContext); + splitToTags(activeContext).forEach((t) => hints.topicTags.add(t)); + } + + const currentFocus = normalizeToken(living.current_focus); + if (currentFocus) { + splitToTags(currentFocus).forEach((t) => hints.topicTags.add(t)); + } + } + + const projectState = currentState.project || {}; + for (const key of [ + "project.current", + "project.current_epic", + "project.current_task", + "project.phase", + "project.status", + ]) { + const raw = projectState[key]; + const normalized = normalizeToken(raw); + if (normalized) { + hints.topicTags.add(normalized); + splitToTags(normalized).forEach((t) => hints.topicTags.add(t)); + } + } + + return hints; } function getSessionTokenFromPayload(payload: Record): string { - const direct = normalizeToken(payload.sessionId || payload.session_id || payload.thread_id || payload.threadId || payload.conversationId || payload.conversation_id); - if (direct) return direct; - - const meta = payload.metadata && typeof payload.metadata === "object" ? payload.metadata as Record : {}; - return normalizeToken(meta.sessionId || meta.session_id || meta.thread_id || meta.threadId || meta.conversationId || meta.conversation_id); + const direct = normalizeToken( + payload.sessionId || + payload.session_id || + payload.thread_id || + payload.threadId || + payload.conversationId || + payload.conversation_id, + ); + if (direct) return direct; + + const meta = + payload.metadata && typeof payload.metadata === "object" + ? (payload.metadata as Record) + : {}; + return normalizeToken( + meta.sessionId || + meta.session_id || + meta.thread_id || + meta.threadId || + meta.conversationId || + meta.conversation_id, + ); } function collectPayloadTopicTags(payload: Record): Set { - const tags = new Set(); - const meta = payload.metadata && typeof payload.metadata === "object" ? payload.metadata as Record : {}; - - const rawCandidates: unknown[] = [ - payload.project, - payload.projectTag, - payload.project_tag, - payload.topic, - payload.topicTag, - payload.topic_tag, - meta.project, - meta.projectTag, - meta.project_tag, - meta.topic, - meta.topicTag, - meta.topic_tag, - payload.namespace, - ]; - - for (const raw of rawCandidates) { - const v = normalizeToken(raw); - if (!v) continue; - tags.add(v); - splitToTags(v).forEach((x) => tags.add(x)); - } - - const listCandidates: unknown[] = [payload.tags, payload.topics, meta.tags, meta.topics]; - for (const lc of listCandidates) { - if (Array.isArray(lc)) { - lc.forEach((item) => { - const v = normalizeToken(item); - if (v) { - tags.add(v); - splitToTags(v).forEach((x) => tags.add(x)); - } - }); - } - } - - return tags; + const tags = new Set(); + const meta = + payload.metadata && typeof payload.metadata === "object" + ? (payload.metadata as Record) + : {}; + + const rawCandidates: unknown[] = [ + payload.project, + payload.projectTag, + payload.project_tag, + payload.topic, + payload.topicTag, + payload.topic_tag, + meta.project, + meta.projectTag, + meta.project_tag, + meta.topic, + meta.topicTag, + meta.topic_tag, + payload.namespace, + ]; + + for (const raw of rawCandidates) { + const v = normalizeToken(raw); + if (!v) continue; + tags.add(v); + splitToTags(v).forEach((x) => tags.add(x)); + } + + const listCandidates: unknown[] = [ + payload.tags, + payload.topics, + meta.tags, + meta.topics, + ]; + for (const lc of listCandidates) { + if (Array.isArray(lc)) { + lc.forEach((item) => { + const v = normalizeToken(item); + if (v) { + tags.add(v); + splitToTags(v).forEach((x) => tags.add(x)); + } + }); + } + } + + return tags; } function intersects(a: Set, b: Set): boolean { - if (a.size === 0 || b.size === 0) return false; - for (const x of a) { - if (b.has(x)) return true; - } - return false; + if (a.size === 0 || b.size === 0) return false; + for (const x of a) { + if (b.has(x)) return true; + } + return false; } -function applyRecencyBoost(baseScore: number, payload: Record, sameSession: boolean): number { - const tsRaw = payload.updatedAt || payload.timestamp || payload.ts; - const ts = Number(tsRaw); - if (!Number.isFinite(ts) || ts <= 0) return baseScore; - - const ageMs = Math.max(0, Date.now() - ts); - if (sameSession) { - if (ageMs <= 60 * 60 * 1000) return baseScore + 0.12; - if (ageMs <= 24 * 60 * 60 * 1000) return baseScore + 0.07; - if (ageMs <= 3 * 24 * 60 * 60 * 1000) return baseScore + 0.03; - } - if (ageMs <= 60 * 60 * 1000) return baseScore + 0.02; - return baseScore; +function applyRecencyBoost( + baseScore: number, + payload: Record, + sameSession: boolean, +): number { + const tsRaw = payload.updatedAt || payload.timestamp || payload.ts; + const ts = Number(tsRaw); + if (!Number.isFinite(ts) || ts <= 0) return baseScore; + + const ageMs = Math.max(0, Date.now() - ts); + if (sameSession) { + if (ageMs <= 60 * 60 * 1000) return baseScore + 0.12; + if (ageMs <= 24 * 60 * 60 * 1000) return baseScore + 0.07; + if (ageMs <= 3 * 24 * 60 * 60 * 1000) return baseScore + 0.03; + } + if (ageMs <= 60 * 60 * 1000) return baseScore + 0.02; + return baseScore; } export function selectSemanticMemories( - results: Array<{ score: number; payload?: Record }>, - ctx: RecallContext, - hints: RecallHintSet, + results: Array<{ score: number; payload?: Record }>, + ctx: RecallContext, + hints: RecallHintSet, ): SemanticSelectionResult { - const weighted: SemanticMemoryCandidate[] = results - .filter((r: any) => (r.payload?.namespace || "") !== "noise.filtered") - .map((r: any) => { - const payload = (r.payload || {}) as Record; - const ns = String(payload.namespace || ""); - const baseWeighted = Math.min(1, r.score * getNamespaceWeight(ctx.agentId, ns)); - - const sessionToken = getSessionTokenFromPayload(payload); - const sameSession = sessionToken ? hints.sessionKeys.has(sessionToken) : false; - - const memoryTags = collectPayloadTopicTags(payload); - const sameProject = intersects(hints.topicTags, memoryTags); - const crossProject = hints.topicTags.size > 0 && memoryTags.size > 0 && !sameProject; - - let adjusted = baseWeighted; - if (sameSession) adjusted += 0.2; - if (sameProject) adjusted += 0.1; - if (crossProject) adjusted -= 0.18; - adjusted = applyRecencyBoost(adjusted, payload, sameSession); - - return { - text: payload.text || "", - score: baseWeighted, - namespace: ns, - payload, - adjustedScore: Math.max(0, Math.min(1, adjusted)), - sameSession, - sameProject, - crossProject, - }; - }) - .filter((m) => m.text.length > 0) - .sort((a, b) => (b.adjustedScore || 0) - (a.adjustedScore || 0)); - - const kept = weighted.filter((m) => (m.adjustedScore || 0) >= 0.7).slice(0, 5); - - if (kept.length === 0) { - return { - memories: [], - recallConfidence: "low", - suppressed: true, - suppressionReason: "no_high_relevance", - }; - } - - const top3 = kept.slice(0, 3); - const crossCount = top3.filter((m) => m.crossProject).length; - const sessionCount = top3.filter((m) => m.sameSession).length; - const projectCount = top3.filter((m) => m.sameProject).length; - - // ASM-42 hardening: - // Require at least one same-session or same-project anchor in top hits. - // This avoids injecting seemingly relevant but cross-scope memories. - if (sessionCount === 0 && projectCount === 0) { - return { - memories: [], - recallConfidence: "low", - suppressed: true, - suppressionReason: "missing_scope_anchor", - }; - } - - if (crossCount >= 2 && sessionCount === 0 && projectCount === 0) { - return { - memories: [], - recallConfidence: "low", - suppressed: true, - suppressionReason: "mixed_or_cross_topic_top_hits", - }; - } - - const recallConfidence: "high" | "medium" | "low" = - sessionCount >= 1 || projectCount >= 2 - ? "high" - : crossCount >= 1 - ? "medium" - : "high"; - - const cap = recallConfidence === "medium" ? 2 : 5; - return { - memories: kept.slice(0, cap).map((m) => ({ - text: m.text, - score: m.adjustedScore || m.score, - namespace: m.namespace, - })), - recallConfidence, - suppressed: false, - }; + const weighted: SemanticMemoryCandidate[] = results + .filter((r: any) => (r.payload?.namespace || "") !== "noise.filtered") + .map((r: any) => { + const payload = (r.payload || {}) as Record; + const ns = String(payload.namespace || ""); + + const sessionToken = getSessionTokenFromPayload(payload); + const sameSession = sessionToken + ? hints.sessionKeys.has(sessionToken) + : false; + const preferredSession = + hints.sessionKeys.size > 0 + ? [...hints.sessionKeys][0] + : normalizeSessionToken(ctx.sessionKey); + const scored = scoreSemanticCandidate({ + rawScore: r.score, + agentId: ctx.agentId, + namespace: ns, + sessionMode: "soft", + preferredSessionId: preferredSession, + payloadSessionId: sessionToken, + sameSession, + promotionState: payload.promotion_state, + }); + + const memoryTags = collectPayloadTopicTags(payload); + const sameProject = intersects(hints.topicTags, memoryTags); + const crossProject = + hints.topicTags.size > 0 && memoryTags.size > 0 && !sameProject; + + let adjusted = scored.finalScore; + if (sameSession) adjusted += 0.08; + if (sameProject) adjusted += 0.1; + if (crossProject) adjusted -= 0.18; + adjusted = applyRecencyBoost(adjusted, payload, sameSession); + + return { + text: payload.text || "", + score: scored.weightedBase, + namespace: ns, + payload, + adjustedScore: Math.max(0, Math.min(1, adjusted)), + sameSession, + sameProject, + crossProject, + }; + }) + .filter((m) => m.text.length > 0) + .sort((a, b) => (b.adjustedScore || 0) - (a.adjustedScore || 0)); + + const kept = weighted + .filter((m) => (m.adjustedScore || 0) >= 0.7) + .slice(0, 5); + + if (kept.length === 0) { + return { + memories: [], + recallConfidence: "low", + suppressed: true, + suppressionReason: "no_high_relevance", + }; + } + + const top3 = kept.slice(0, 3); + const crossCount = top3.filter((m) => m.crossProject).length; + const sessionCount = top3.filter((m) => m.sameSession).length; + const projectCount = top3.filter((m) => m.sameProject).length; + + // ASM-42 hardening: + // Require at least one same-session or same-project anchor in top hits. + // This avoids injecting seemingly relevant but cross-scope memories. + if (sessionCount === 0 && projectCount === 0) { + return { + memories: [], + recallConfidence: "low", + suppressed: true, + suppressionReason: "missing_scope_anchor", + }; + } + + if (crossCount >= 2 && sessionCount === 0 && projectCount === 0) { + return { + memories: [], + recallConfidence: "low", + suppressed: true, + suppressionReason: "mixed_or_cross_topic_top_hits", + }; + } + + const recallConfidence: "high" | "medium" | "low" = + sessionCount >= 1 || projectCount >= 2 + ? "high" + : crossCount >= 1 + ? "medium" + : "high"; + + const cap = recallConfidence === "medium" ? 2 : 5; + return { + memories: kept.slice(0, cap).map((m) => ({ + text: m.text, + score: m.adjustedScore || m.score, + namespace: m.namespace, + })), + recallConfidence, + suppressed: false, + }; } /** * Build multi-namespace filter for Qdrant search */ function buildNamespaceFilter(namespaces: MemoryNamespace[]): any { - if (namespaces.length === 0) { - return { must: [{ key: "namespace", match: { value: "shared.project_context" } }] }; - } - - if (namespaces.length === 1) { - return { must: [{ key: "namespace", match: { value: namespaces[0] } }] }; - } - - // Multiple namespaces - use OR (should) - return { - must: [{ - should: namespaces.map(ns => ({ - key: "namespace", - match: { value: ns }, - })), - }], - }; + if (namespaces.length === 0) { + return { + must: [{ key: "namespace", match: { value: "shared.project_context" } }], + }; + } + + if (namespaces.length === 1) { + return { must: [{ key: "namespace", match: { value: namespaces[0] } }] }; + } + + // Multiple namespaces - use OR (should) + return { + must: [ + { + should: namespaces.map((ns) => ({ + key: "namespace", + match: { value: ns }, + })), + }, + ], + }; } /** * Gather auto-recall context from all memory sources */ export async function gatherRecallContext( - db: SlotDB, - qdrant: QdrantClient, - embedding: EmbeddingClient, - ctx: RecallContext, - userQuery?: string, + db: SlotDB, + qdrant: QdrantClient, + embedding: EmbeddingClient, + ctx: RecallContext, + userQuery?: string, ): Promise<{ - currentState: string; - projectLivingState: string; - graphContext: string; - recentUpdates: string; - semanticMemories: string; - recallMeta: { - recall_confidence: "high" | "medium" | "low"; - recall_suppressed: boolean; - suppression_reason?: string; - }; + currentState: string; + projectLivingState: string; + graphContext: string; + recentUpdates: string; + semanticMemories: string; + recallMeta: { + recall_confidence: "high" | "medium" | "low"; + recall_suppressed: boolean; + suppression_reason?: string; + }; }> { - // 1. Get Current State from slots (all scopes) - const scopes = [ - { userId: ctx.userId, agentId: ctx.agentId, label: "private" }, - { userId: ctx.userId, agentId: "__team__", label: "team" }, - { userId: "__public__", agentId: "__public__", label: "public" }, - ]; - - const mergedState: Record> = {}; - const mergedTimestamps: Record> = {}; - - for (const scope of scopes) { - const state = db.getCurrentState(scope.userId, scope.agentId); - const slots = db.list(scope.userId, scope.agentId); - // Build timestamp map - const tsMap: Record = {}; - for (const s of slots) { - tsMap[s.key] = s.updated_at; - } - - for (const [category, catSlots] of Object.entries(state)) { - if (!mergedState[category]) { - mergedState[category] = {}; - mergedTimestamps[category] = {}; - } - for (const [key, value] of Object.entries(catSlots)) { - // Skip internal keys (e.g. _autocapture_hash) - if (key.startsWith('_')) continue; - const existingTs = mergedTimestamps[category]?.[key]; - const newTs = tsMap[key] || ""; - // Keep the NEWEST version (freshness wins) - if (!existingTs || newTs > existingTs) { - mergedState[category][key] = value; - mergedTimestamps[category][key] = newTs; - } - } - } - } - - const currentStateXml = formatCurrentState(mergedState); - - // 1.5 Get project_living_state slot (private > team > public) - const projectLivingCandidates = [ - db.get(ctx.userId, ctx.agentId, { key: "project_living_state" }), - db.get(ctx.userId, "__team__", { key: "project_living_state" }), - db.get("__public__", "__public__", { key: "project_living_state" }), - ]; - let projectLivingStateXml = ""; - let projectLivingStateValue: unknown = null; - for (const c of projectLivingCandidates) { - if (c && !Array.isArray(c)) { - projectLivingStateValue = c.value; - projectLivingStateXml = formatProjectLivingState(c.value); - if (projectLivingStateXml) break; - } - } - - const recallHints = collectRecallHints(ctx.sessionKey, projectLivingStateValue, mergedState); - - // 2. Get Graph Context (from private scope only for privacy) - const allEntities = db.graph.listEntities(ctx.userId, ctx.agentId); - const entityList = allEntities.slice(0, 10).map((e) => ({ name: e.name, type: e.type })); - - const relationships: Array<{ source: string; target: string; type: string }> = []; - for (const entity of allEntities.slice(0, 5)) { - const rels = db.graph.getRelationships(ctx.userId, ctx.agentId, entity.id, "outgoing"); - for (const rel of rels.slice(0, 2)) { - const target = db.graph.getEntity(ctx.userId, ctx.agentId, rel.target_entity_id); - if (target) { - relationships.push({ source: entity.name, target: target.name, type: rel.relation_type }); - } - } - } - - const graphContextXml = formatGraphContext(entityList, relationships); - - // 3. Recent Updates (last 5 modified slots) - const allSlots: Array<{ key: string; updated_at: string }> = []; - for (const scope of scopes) { - const slots = db.list(scope.userId, scope.agentId); - slots.forEach((s) => allSlots.push({ key: s.key, updated_at: s.updated_at })); - } - - const recentSlots = allSlots - .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) - .slice(0, 5); - - const recentUpdates = recentSlots.length > 0 - ? `\n${recentSlots.map((s) => ` `).join("\n")}\n` - : ""; - - // 4. Semantic Memories from Qdrant (NEW) - let semanticMemoriesXml = ""; - let recallMeta: { recall_confidence: "high" | "medium" | "low"; recall_suppressed: boolean; suppression_reason?: string } = { - recall_confidence: "medium", - recall_suppressed: false, - }; - - if (userQuery && userQuery.trim().length > 0) { - try { - // Get agent's namespaces - const namespaces = getAgentNamespaces(ctx.agentId); - - // Generate embedding for the query - const vector = await embedding.embed(userQuery); - - // Build multi-namespace filter - const namespaceFilter = buildNamespaceFilter(namespaces); - - // Search for relevant memories - const results = await qdrant.search(vector, 8, namespaceFilter); - - const selection = selectSemanticMemories(results, ctx, recallHints); - recallMeta = { - recall_confidence: selection.recallConfidence, - recall_suppressed: selection.suppressed, - suppression_reason: selection.suppressionReason, - }; - semanticMemoriesXml = formatSemanticMemories(selection.memories); - - if (selection.memories.length > 0) { - console.log(`[AutoRecall] Found ${selection.memories.length} relevant semantic memories for query (confidence=${selection.recallConfidence}, namespaces: ${namespaces.join(", ")})`); - } else if (selection.suppressed) { - console.warn(`[AutoRecall] Semantic recall suppressed due to low confidence: ${selection.suppressionReason || "unknown"}`); - } - } catch (error: any) { - console.error("[AutoRecall] Error querying semantic memories:", error.message); - semanticMemoriesXml = ""; - recallMeta = { - recall_confidence: "low", - recall_suppressed: true, - suppression_reason: "semantic_search_error", - }; - } - } - - return { - currentState: currentStateXml, - projectLivingState: projectLivingStateXml, - graphContext: graphContextXml, - recentUpdates, - semanticMemories: semanticMemoriesXml, - recallMeta, - }; + // 1. Get Current State from slots (all scopes) + const scopes = [ + { userId: ctx.userId, agentId: ctx.agentId, label: "private" }, + { userId: ctx.userId, agentId: "__team__", label: "team" }, + { userId: "__public__", agentId: "__public__", label: "public" }, + ]; + + const mergedState: Record> = {}; + const mergedTimestamps: Record> = {}; + + for (const scope of scopes) { + const state = db.getCurrentState(scope.userId, scope.agentId); + const slots = db.list(scope.userId, scope.agentId); + // Build timestamp map + const tsMap: Record = {}; + for (const s of slots) { + tsMap[s.key] = s.updated_at; + } + + for (const [category, catSlots] of Object.entries(state)) { + if (!mergedState[category]) { + mergedState[category] = {}; + mergedTimestamps[category] = {}; + } + for (const [key, value] of Object.entries(catSlots)) { + // Skip internal keys (e.g. _autocapture_hash) + if (key.startsWith("_")) continue; + const existingTs = mergedTimestamps[category]?.[key]; + const newTs = tsMap[key] || ""; + // Keep the NEWEST version (freshness wins) + if (!existingTs || newTs > existingTs) { + mergedState[category][key] = value; + mergedTimestamps[category][key] = newTs; + } + } + } + } + + const currentStateXml = formatCurrentState(mergedState); + + // 1.5 Get project_living_state slot (private > team > public) + const projectLivingCandidates = [ + db.get(ctx.userId, ctx.agentId, { key: "project_living_state" }), + db.get(ctx.userId, "__team__", { key: "project_living_state" }), + db.get("__public__", "__public__", { key: "project_living_state" }), + ]; + let projectLivingStateXml = ""; + let projectLivingStateValue: unknown = null; + for (const c of projectLivingCandidates) { + if (c && !Array.isArray(c)) { + projectLivingStateValue = c.value; + projectLivingStateXml = formatProjectLivingState(c.value); + if (projectLivingStateXml) break; + } + } + + const recallHints = collectRecallHints( + ctx.sessionKey, + projectLivingStateValue, + mergedState, + ); + + // 2. Get Graph Context (from private scope only for privacy) + const allEntities = db.graph.listEntities(ctx.userId, ctx.agentId); + const entityList = allEntities + .slice(0, 10) + .map((e) => ({ name: e.name, type: e.type })); + + const relationships: Array<{ source: string; target: string; type: string }> = + []; + for (const entity of allEntities.slice(0, 5)) { + const rels = db.graph.getRelationships( + ctx.userId, + ctx.agentId, + entity.id, + "outgoing", + ); + for (const rel of rels.slice(0, 2)) { + const target = db.graph.getEntity( + ctx.userId, + ctx.agentId, + rel.target_entity_id, + ); + if (target) { + relationships.push({ + source: entity.name, + target: target.name, + type: rel.relation_type, + }); + } + } + } + + const graphContextXml = formatGraphContext(entityList, relationships); + + // 3. Recent Updates (last 5 modified slots) + const allSlots: Array<{ key: string; updated_at: string }> = []; + for (const scope of scopes) { + const slots = db.list(scope.userId, scope.agentId); + slots.forEach((s) => + allSlots.push({ key: s.key, updated_at: s.updated_at }), + ); + } + + const recentSlots = allSlots + .sort( + (a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ) + .slice(0, 5); + + const recentUpdates = + recentSlots.length > 0 + ? `\n${recentSlots.map((s) => ` `).join("\n")}\n` + : ""; + + // 4. Semantic Memories from Qdrant (NEW) + let semanticMemoriesXml = ""; + let recallMeta: { + recall_confidence: "high" | "medium" | "low"; + recall_suppressed: boolean; + suppression_reason?: string; + } = { + recall_confidence: "medium", + recall_suppressed: false, + }; + + if (userQuery && userQuery.trim().length > 0) { + try { + // Get agent's namespaces + const namespaces = getAgentNamespaces(ctx.agentId); + + // Generate embedding for the query + const vector = await embedding.embed(userQuery); + + // Build multi-namespace filter + const namespaceFilter = buildNamespaceFilter(namespaces); + + // Search for relevant memories + const results = await qdrant.search(vector, 8, namespaceFilter); + + const selection = selectSemanticMemories(results, ctx, recallHints); + recallMeta = { + recall_confidence: selection.recallConfidence, + recall_suppressed: selection.suppressed, + suppression_reason: selection.suppressionReason, + }; + semanticMemoriesXml = formatSemanticMemories(selection.memories); + + if (selection.memories.length > 0) { + console.log( + `[AutoRecall] Found ${selection.memories.length} relevant semantic memories for query (confidence=${selection.recallConfidence}, namespaces: ${namespaces.join(", ")})`, + ); + } else if (selection.suppressed) { + console.warn( + `[AutoRecall] Semantic recall suppressed due to low confidence: ${selection.suppressionReason || "unknown"}`, + ); + } + } catch (error: any) { + console.error( + "[AutoRecall] Error querying semantic memories:", + error.message, + ); + semanticMemoriesXml = ""; + recallMeta = { + recall_confidence: "low", + recall_suppressed: true, + suppression_reason: "semantic_search_error", + }; + } + } + + return { + currentState: currentStateXml, + projectLivingState: projectLivingStateXml, + graphContext: graphContextXml, + recentUpdates, + semanticMemories: semanticMemoriesXml, + recallMeta, + }; } /** * Inject recall context into system prompt */ -export function injectRecallContext(systemPrompt: string, context: { - currentState: string; - projectLivingState: string; - graphContext: string; - recentUpdates: string; - semanticMemories: string; - recallMeta?: { - recall_confidence: "high" | "medium" | "low"; - recall_suppressed: boolean; - suppression_reason?: string; - }; -}): string { - // Build injection block - const injectionParts: string[] = []; - - if (context.currentState) { - injectionParts.push(context.currentState); - } - - if (context.projectLivingState) { - injectionParts.push(context.projectLivingState); - } - - if (context.graphContext) { - injectionParts.push(context.graphContext); - } - - if (context.recentUpdates) { - injectionParts.push(context.recentUpdates); - } - - if (context.semanticMemories) { - injectionParts.push(context.semanticMemories); - } - - if (context.recallMeta) { - const confidenceBlock = `\n ${context.recallMeta.recall_confidence}\n ${String(context.recallMeta.recall_suppressed)}${context.recallMeta.suppression_reason ? `\n ${context.recallMeta.suppression_reason}` : ""}\n`; - injectionParts.push(confidenceBlock); - } - - if (injectionParts.length === 0) { - return systemPrompt; - } - - const injection = `\n${injectionParts.join("\n\n")}\n\n\n`; - - // Insert after any existing system tags or at the beginning - if (systemPrompt.includes("")) { - // Insert after tag - return systemPrompt.replace("", `\n\n${injection}`); - } - - // Prepend to the prompt - return injection + systemPrompt; +export function injectRecallContext( + systemPrompt: string, + context: { + currentState: string; + projectLivingState: string; + graphContext: string; + recentUpdates: string; + semanticMemories: string; + recallMeta?: { + recall_confidence: "high" | "medium" | "low"; + recall_suppressed: boolean; + suppression_reason?: string; + }; + }, +): string { + const injectionParts = buildRecallInjectionParts(context); + + if (injectionParts.length === 0) { + return systemPrompt; + } + + const injection = `\n${injectionParts.join("\n\n")}\n\n\n`; + + // Insert after any existing system tags or at the beginning + if (systemPrompt.includes("")) { + // Insert after tag + return systemPrompt.replace("", `\n\n${injection}`); + } + + // Prepend to the prompt + return injection + systemPrompt; } /** * Register auto-recall hook */ export function registerAutoRecall( - api: OpenClawPluginApi, - db: SlotDB, - qdrant: QdrantClient, - embedding: EmbeddingClient + api: OpenClawPluginApi, + db: SlotDB, + qdrant: QdrantClient, + embedding: EmbeddingClient, ): void { - // Hook into agent lifecycle using the on() method - api.on("before_agent_start", async (event: unknown, ctx: unknown) => { - const typedEvent = event as { messages?: Array<{ role: string; content: string }>; systemPrompt?: string }; - const typedCtx = ctx as { sessionKey?: string }; - - const sessionKey = typedCtx?.sessionKey || "agent:main:default"; - const parts = sessionKey.split(":"); - const agentId = parts.length >= 2 ? parts[1] : "main"; - const userId = normalizeUserId(parts.length >= 3 ? parts.slice(2).join(":") : "default"); - - // Extract user query from last user message for semantic search - let userQuery = ""; - if (typedEvent?.messages && typedEvent.messages.length > 0) { - // Find the last user message - for (let i = typedEvent.messages.length - 1; i >= 0; i--) { - const msg = typedEvent.messages[i]; - if (msg.role === "user" && msg.content) { - userQuery = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content); - break; - } - } - } - - const recallCtx: RecallContext = { - sessionKey, - stateDir: process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`, - userId, - agentId, - }; - - try { - const context = await gatherRecallContext(db, qdrant, embedding, recallCtx, userQuery); - - // Get original system prompt from event if available - const originalPrompt = typedEvent?.systemPrompt || ""; - - // Return system prompt override via the hook result - return { - systemPrompt: injectRecallContext(originalPrompt, context), - }; - } catch (error) { - console.error("Auto-recall error:", error); - } - }); + // Hook into agent lifecycle using the on() method + api.on("before_agent_start", async (event: unknown, ctx: unknown) => { + const typedEvent = event as { + messages?: Array<{ role: string; content: string }>; + systemPrompt?: string; + }; + const typedCtx = ctx as { sessionKey?: string }; + + const sessionKey = typedCtx?.sessionKey || "agent:main:default"; + const parts = sessionKey.split(":"); + const agentId = parts.length >= 2 ? parts[1] : "main"; + const userId = normalizeUserId( + parts.length >= 3 ? parts.slice(2).join(":") : "default", + ); + + // Extract user query from last user message for semantic search + let userQuery = ""; + if (typedEvent?.messages && typedEvent.messages.length > 0) { + // Find the last user message + for (let i = typedEvent.messages.length - 1; i >= 0; i--) { + const msg = typedEvent.messages[i]; + if (msg.role === "user" && msg.content) { + userQuery = + typeof msg.content === "string" + ? msg.content + : JSON.stringify(msg.content); + break; + } + } + } + + const recallCtx: RecallContext = { + sessionKey, + stateDir: + process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`, + userId, + agentId, + }; + + try { + const context = await gatherRecallContext( + db, + qdrant, + embedding, + recallCtx, + userQuery, + ); + + // Get original system prompt from event if available + const originalPrompt = typedEvent?.systemPrompt || ""; + + // Return system prompt override via the hook result + return { + systemPrompt: injectRecallContext(originalPrompt, context), + }; + } catch (error) { + console.error("Auto-recall error:", error); + } + }); } /** * Get formatted recall context for manual injection */ export async function getRecallContextText( - db: SlotDB, - qdrant: QdrantClient, - embedding: EmbeddingClient, - sessionKey: string, - userQuery?: string, + db: SlotDB, + qdrant: QdrantClient, + embedding: EmbeddingClient, + sessionKey: string, + userQuery?: string, ): Promise { - const parts = sessionKey.split(":"); - const agentId = parts.length >= 2 ? parts[1] : "main"; - const userId = normalizeUserId(parts.length >= 3 ? parts.slice(2).join(":") : "default"); - - const ctx: RecallContext = { - sessionKey, - stateDir: process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`, - userId, - agentId, - }; - - const context = await gatherRecallContext(db, qdrant, embedding, ctx, userQuery); - - const parts2: string[] = []; - if (context.currentState) parts2.push(context.currentState); - if (context.projectLivingState) parts2.push(context.projectLivingState); - if (context.graphContext) parts2.push(context.graphContext); - if (context.recentUpdates) parts2.push(context.recentUpdates); - if (context.semanticMemories) parts2.push(context.semanticMemories); - - return parts2.join("\n\n"); + const parts = sessionKey.split(":"); + const agentId = parts.length >= 2 ? parts[1] : "main"; + const userId = normalizeUserId( + parts.length >= 3 ? parts.slice(2).join(":") : "default", + ); + + const ctx: RecallContext = { + sessionKey, + stateDir: process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`, + userId, + agentId, + }; + + const context = await gatherRecallContext( + db, + qdrant, + embedding, + ctx, + userQuery, + ); + + const parts2: string[] = []; + if (context.currentState) parts2.push(context.currentState); + if (context.projectLivingState) parts2.push(context.projectLivingState); + if (context.graphContext) parts2.push(context.graphContext); + if (context.recentUpdates) parts2.push(context.recentUpdates); + if (context.semanticMemories) parts2.push(context.semanticMemories); + + return parts2.join("\n\n"); } diff --git a/src/scripts/asm115-migration-runner.ts b/src/scripts/asm115-migration-runner.ts new file mode 100644 index 0000000..1943cf2 --- /dev/null +++ b/src/scripts/asm115-migration-runner.ts @@ -0,0 +1,317 @@ +import { cpSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + ASM115_MIGRATION_ID, + ASM115_SCHEMA_VERSION, + isAsm115Noop, + planSemanticPayloadMigration, + type SemanticPointRecord, +} from "../core/migrations/asm115-migration-core.js"; +import { GraphDB } from "../db/graph-db.js"; +import { SlotDB } from "../db/slot-db.js"; +import { QdrantClient } from "../services/qdrant.js"; +import { resolveAsmRuntimeConfig } from "../shared/asm-config.js"; +import { resolveSlotDbDir } from "../shared/slotdb-path.js"; + +export type Asm115Mode = "preflight" | "plan" | "apply" | "verify" | "rollback"; + +export interface RunAsm115MigrationInput { + mode: Asm115Mode; + env?: NodeJS.ProcessEnv; + homeDir?: string; + userId?: string; + agentId?: string; + snapshotDir?: string; + rollbackSnapshotPath?: string; + preflightLimit?: number; +} + +interface PlaneStatus { + version: string; + needsMigration: boolean; + details: Record; +} + +interface Asm115Plan { + slotdb: PlaneStatus; + graph: PlaneStatus; + semantic: PlaneStatus; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function detectSlotDbVersion(slotDbDir: string): string { + const dbPath = join(slotDbDir, "slots.db"); + if (!existsSync(dbPath)) return "missing"; + const db = new DatabaseSync(dbPath); + try { + const table = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='migration_state'`, + ) + .get() as { name: string } | undefined; + if (!table) return "legacy"; + return "has_migration_state"; + } finally { + db.close(); + } +} + +function detectGraphVersion(slotDbDir: string): { + version: string; + entities: number; + relationships: number; +} { + const dbPath = join(slotDbDir, "slots.db"); + if (!existsSync(dbPath)) { + return { version: "missing", entities: 0, relationships: 0 }; + } + const db = new DatabaseSync(dbPath); + try { + const entitiesTable = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='entities'`, + ) + .get() as { name: string } | undefined; + const relTable = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='relationships'`, + ) + .get() as { name: string } | undefined; + if (!entitiesTable || !relTable) { + return { version: "legacy", entities: 0, relationships: 0 }; + } + const e = + ( + db.prepare(`SELECT COUNT(*) as c FROM entities`).get() as + | { c: number } + | undefined + )?.c || 0; + const r = + ( + db.prepare(`SELECT COUNT(*) as c FROM relationships`).get() as + | { c: number } + | undefined + )?.c || 0; + return { version: "graph_v1", entities: e, relationships: r }; + } finally { + db.close(); + } +} + +async function collectSemanticPoints( + qdrant: QdrantClient, + limit: number, +): Promise { + const points: SemanticPointRecord[] = []; + let offset: any; + do { + const page = await qdrant.scroll(limit, offset, false); + for (const point of page.points) { + points.push({ + id: point.id, + payload: point.payload || {}, + }); + } + offset = page.nextOffset; + } while (offset !== undefined && offset !== null); + return points; +} + +function ensureSnapshotDir(dir: string): void { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); +} + +function createSlotDbSnapshot(slotDbDir: string, snapshotDir: string): string { + ensureSnapshotDir(snapshotDir); + const source = join(slotDbDir, "slots.db"); + const target = join(snapshotDir, `slots.db.${Date.now()}.bak`); + if (existsSync(source)) { + cpSync(source, target, { force: true }); + } + return target; +} + +function restoreSlotDbSnapshot(snapshotPath: string, slotDbDir: string): void { + if (!existsSync(snapshotPath)) { + throw new Error(`rollback snapshot not found: ${snapshotPath}`); + } + const target = join(slotDbDir, "slots.db"); + cpSync(snapshotPath, target, { force: true }); +} + +export async function runAsm115Migration( + input: RunAsm115MigrationInput, +): Promise> { + const env = input.env || process.env; + const runtime = resolveAsmRuntimeConfig({ + env, + homeDir: input.homeDir || env.HOME, + }); + const slotDbDir = resolveSlotDbDir({ + env, + homeDir: input.homeDir || env.HOME, + slotDbDir: runtime.slotDbDir, + }); + + const userId = input.userId || "telegram:dm:5165741309"; + const agentId = input.agentId || "assistant"; + const slotDb = new SlotDB(slotDbDir, { slotDbDir }); + const qdrant = new QdrantClient({ + host: runtime.qdrantHost, + port: runtime.qdrantPort, + collection: runtime.qdrantCollection, + vectorSize: runtime.qdrantVectorSize, + }); + + const slotVersion = detectSlotDbVersion(slotDbDir); + const graph = detectGraphVersion(slotDbDir); + const semanticCount = await qdrant.countPoints(true); + const points = await collectSemanticPoints( + qdrant, + Math.max(10, input.preflightLimit || 200), + ); + const semanticPlan = planSemanticPayloadMigration(points); + const existingMigration = slotDb.getMigrationState( + userId, + agentId, + ASM115_MIGRATION_ID, + ); + + const plan: Asm115Plan = { + slotdb: { + version: slotVersion, + needsMigration: slotVersion !== "missing", + details: { + slot_db_dir: slotDbDir, + }, + }, + graph: { + version: graph.version, + needsMigration: graph.version !== "missing", + details: { + entities: graph.entities, + relationships: graph.relationships, + }, + }, + semantic: { + version: semanticPlan.changed === 0 ? ASM115_SCHEMA_VERSION : "mixed", + needsMigration: semanticPlan.changed > 0, + details: { + collection: runtime.qdrantCollection, + total_points: semanticCount, + pending_points: semanticPlan.changed, + }, + }, + }; + + if (input.mode === "preflight" || input.mode === "plan") { + const noop = isAsm115Noop({ + pendingSemanticChanges: semanticPlan.changed, + migrationStatus: existingMigration?.status, + migrationSchemaTo: existingMigration?.schema_to, + }); + slotDb.close(); + return { + mode: input.mode, + migration_id: ASM115_MIGRATION_ID, + schema_target: ASM115_SCHEMA_VERSION, + no_op: noop, + plan, + existing_migration_state: existingMigration, + semantic_patch_preview: semanticPlan.patches.slice(0, 20).map((p) => ({ + id: p.id, + changed_fields: p.changedFields, + })), + }; + } + + if (input.mode === "verify") { + const noop = isAsm115Noop({ + pendingSemanticChanges: semanticPlan.changed, + migrationStatus: existingMigration?.status, + migrationSchemaTo: existingMigration?.schema_to, + }); + slotDb.close(); + return { + mode: "verify", + migration_id: ASM115_MIGRATION_ID, + schema_target: ASM115_SCHEMA_VERSION, + verified: noop, + remaining_semantic_points: semanticPlan.changed, + migration_state: existingMigration, + plan, + }; + } + + if (input.mode === "rollback") { + if (!input.rollbackSnapshotPath) { + slotDb.close(); + throw new Error("rollback requires --rollback-snapshot "); + } + restoreSlotDbSnapshot(input.rollbackSnapshotPath, slotDbDir); + slotDb.recordMigrationState(userId, agentId, { + migration_id: ASM115_MIGRATION_ID, + schema_from: ASM115_SCHEMA_VERSION, + schema_to: "rollback", + applied_at: nowIso(), + status: "rolled_back", + notes: JSON.stringify({ rollback_snapshot: input.rollbackSnapshotPath }), + }); + const state = slotDb.getMigrationState( + userId, + agentId, + ASM115_MIGRATION_ID, + ); + slotDb.close(); + return { + mode: "rollback", + migration_id: ASM115_MIGRATION_ID, + rolled_back: true, + migration_state: state, + }; + } + + const snapshotDir = + input.snapshotDir || join(slotDbDir, "migration-snapshots"); + const snapshotPath = createSlotDbSnapshot(slotDbDir, snapshotDir); + + if (semanticPlan.patches.length > 0) { + await qdrant.setPayload( + semanticPlan.patches.map((patch) => ({ + id: patch.id, + payload: patch.payload, + })), + ); + } + + slotDb.recordMigrationState(userId, agentId, { + migration_id: ASM115_MIGRATION_ID, + schema_from: existingMigration?.schema_to || "legacy", + schema_to: ASM115_SCHEMA_VERSION, + applied_at: nowIso(), + status: "migrated", + notes: JSON.stringify({ + snapshot_path: snapshotPath, + semantic_updates: semanticPlan.changed, + total_semantic_points: semanticPlan.total, + }), + }); + const state = slotDb.getMigrationState(userId, agentId, ASM115_MIGRATION_ID); + slotDb.close(); + + return { + mode: "apply", + migration_id: ASM115_MIGRATION_ID, + schema_target: ASM115_SCHEMA_VERSION, + applied: true, + snapshot_path: snapshotPath, + semantic_updates: semanticPlan.changed, + total_semantic_points: semanticPlan.total, + migration_state: state, + plan, + }; +} diff --git a/src/services/qdrant.ts b/src/services/qdrant.ts index 3667b08..c892f87 100644 --- a/src/services/qdrant.ts +++ b/src/services/qdrant.ts @@ -1,346 +1,414 @@ -import { Point, SearchResponse, ScoredPoint } from "../types.js"; +import type { Point, ScoredPoint, SearchResponse } from "../types.js"; interface QdrantConfig { - host: string; - port: number; - collection: string; - vectorSize: number; - timeout?: number; - maxRetries?: number; - retryDelay?: number; - dimensionRouteMap?: Record; + host: string; + port: number; + collection: string; + vectorSize: number; + timeout?: number; + maxRetries?: number; + retryDelay?: number; + dimensionRouteMap?: Record; } type QdrantPointId = string | number | Record; interface ScrollResponse { - result?: { - points?: Array<{ id: QdrantPointId; payload?: Record; vector?: number[] | Record }>; - next_page_offset?: any; - }; + result?: { + points?: Array<{ + id: QdrantPointId; + payload?: Record; + vector?: number[] | Record; + }>; + next_page_offset?: any; + }; } /** * Qdrant client with retry + dimension fail-fast */ export class QdrantClient { - private config: Required> & { dimensionRouteMap: Record }; - private logger: any; - private cachedCollectionVectorSize: number | null = null; - - constructor(config: QdrantConfig, logger?: any) { - this.config = { - host: config.host, - port: config.port, - collection: config.collection, - vectorSize: config.vectorSize, - timeout: config.timeout || 30000, - maxRetries: config.maxRetries || 3, - retryDelay: config.retryDelay || 1000, - dimensionRouteMap: config.dimensionRouteMap || {}, - }; - this.logger = logger || console; - } - - getCollectionName(): string { - return this.config.collection; - } - - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - private async request(path: string, options: RequestInit, attempt: number = 1): Promise { - const url = `http://${this.config.host}:${this.config.port}${path}`; - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); - - const response = await fetch(url, { - ...options, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const text = await response.text().catch(() => "Unknown error"); - throw new Error(`HTTP ${response.status}: ${text}`); - } - - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return await response.json(); - } - return null; - - } catch (error: any) { - if (attempt < this.config.maxRetries && this.isRetryableError(error)) { - const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); - this.logger.warn(`[Qdrant] Retry ${attempt} after ${delay}ms: ${error.message}`); - await this.sleep(delay); - return this.request(path, options, attempt + 1); - } - throw error; - } - } - - private isRetryableError(error: any): boolean { - return error.message.includes("timeout") || - error.message.includes("network") || - error.message.includes("ECONNREFUSED") || - error.name === "AbortError"; - } - - async collectionExists(): Promise { - try { - await this.request(`/collections/${this.config.collection}`, { method: "GET" }); - return true; - } catch (error: any) { - if (error.message.includes("404")) { - return false; - } - throw error; - } - } - - async getCollectionInfo(): Promise { - return this.request(`/collections/${this.config.collection}`, { method: "GET" }); - } - - private extractCollectionVectorSize(info: any): number { - const vectors = info?.result?.config?.params?.vectors; - if (typeof vectors?.size === "number") return vectors.size; - - if (vectors && typeof vectors === "object") { - const first = Object.values(vectors)[0] as any; - if (typeof first?.size === "number") return first.size; - } - - return this.config.vectorSize; - } - - async getCollectionVectorSize(forceRefresh = false): Promise { - if (!forceRefresh && this.cachedCollectionVectorSize) return this.cachedCollectionVectorSize; - - const info = await this.getCollectionInfo(); - const size = this.extractCollectionVectorSize(info); - this.cachedCollectionVectorSize = size; - return size; - } - - private async failFastOnDimensionMismatch(actualDim: number, operation: string): Promise { - const expected = await this.getCollectionVectorSize(); - if (actualDim === expected) return; - - const routeCollection = this.config.dimensionRouteMap[actualDim]; - const routeHint = routeCollection - ? `Route hint: use collection '${routeCollection}' for dim=${actualDim}.` - : "No safe route configured. Update collection/vector size or embedding model."; - - throw new Error( - `[Qdrant][DIMENSION_MISMATCH] op=${operation} collection=${this.config.collection} expected=${expected} got=${actualDim}. ${routeHint}` - ); - } - - async countPoints(exact = true): Promise { - const res = await this.request(`/collections/${this.config.collection}/points/count`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ exact }), - }); - return Number(res?.result?.count || 0); - } - - async createCollection(): Promise { - const exists = await this.collectionExists(); - if (exists) { - this.logger.info(`[Qdrant] Collection ${this.config.collection} already exists`); - this.cachedCollectionVectorSize = await this.getCollectionVectorSize(true); - return; - } - - this.logger.info(`[Qdrant] Creating collection ${this.config.collection}`); - - await this.request(`/collections/${this.config.collection}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - vectors: { - size: this.config.vectorSize, - distance: "Cosine", - }, - optimizers_config: { - default_segment_number: 2, - }, - }), - }); - - this.cachedCollectionVectorSize = this.config.vectorSize; - this.logger.info(`[Qdrant] Collection created successfully`); - - await this.createPayloadIndex("namespace", "keyword"); - await this.createPayloadIndex("agent", "keyword"); - await this.createPayloadIndex("source_agent", "keyword"); - await this.createPayloadIndex("source_type", "keyword"); - await this.createPayloadIndex("timestamp", "integer"); - await this.createPayloadIndex("userId", "keyword"); - - // ASM-76 (v5.1) bootstrap indexes for project-aware retrieval schema - await this.createPayloadIndex("schema_version", "keyword"); - await this.createPayloadIndex("project_id", "keyword"); - await this.createPayloadIndex("chunk_id", "keyword"); - await this.createPayloadIndex("doc_kind", "keyword"); - await this.createPayloadIndex("relative_path", "keyword"); - await this.createPayloadIndex("language", "keyword"); - await this.createPayloadIndex("module", "keyword"); - await this.createPayloadIndex("symbol_name", "keyword"); - await this.createPayloadIndex("symbol_id", "keyword"); - await this.createPayloadIndex("task_id", "keyword"); - await this.createPayloadIndex("commit_sha", "keyword"); - await this.createPayloadIndex("checksum", "keyword"); - await this.createPayloadIndex("indexed_at", "integer"); - await this.createPayloadIndex("tombstone_at", "keyword"); - await this.createPayloadIndex("active", "bool"); - await this.createPayloadIndex("index_state", "keyword"); - } - - async createPayloadIndex(fieldName: string, fieldType: "keyword" | "integer" | "float" | "bool"): Promise { - try { - await this.request(`/collections/${this.config.collection}/index`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - field_name: fieldName, - field_schema: fieldType, - }), - }); - this.logger.info(`[Qdrant] Created payload index: ${fieldName}`); - } catch (error: any) { - this.logger.warn(`[Qdrant] Failed to create index ${fieldName}: ${error.message}`); - } - } - - async upsert(points: Point[]): Promise { - for (const point of points) { - await this.failFastOnDimensionMismatch(point.vector.length, "upsert"); - } - - await this.request(`/collections/${this.config.collection}/points`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ points }), - }); - } - - async updateVectors(points: Array<{ id: any; vector: number[] }>): Promise { - if (points.length === 0) return; - - for (const point of points) { - await this.failFastOnDimensionMismatch(point.vector.length, "updateVectors"); - } - - try { - await this.request(`/collections/${this.config.collection}/points/vectors`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ points }), - }); - return; - } catch (error: any) { - this.logger.warn(`[Qdrant] updateVectors endpoint failed, fallback to upsert /points: ${error.message}`); - } - - throw new Error("updateVectors endpoint unsupported"); - } - - async setPayload(payloadById: Array<{ id: string; payload: Record }>): Promise { - if (payloadById.length === 0) return; - - for (const row of payloadById) { - await this.request(`/collections/${this.config.collection}/points/payload`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - payload: row.payload, - points: [row.id], - }), - }); - } - } - - async scroll(limit: number, offset?: any, withVector = false): Promise<{ points: Array<{ id: string; payload: Record; vector?: number[] }>; nextOffset?: any }> { - const body: any = { - limit, - with_payload: true, - with_vector: withVector, - }; - - if (offset !== undefined && offset !== null) { - body.offset = offset; - } - - const response: ScrollResponse = await this.request( - `/collections/${this.config.collection}/points/scroll`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - } - ); - - const points = (response?.result?.points || []).map((p) => { - let vector: number[] | undefined; - if (Array.isArray(p.vector)) vector = p.vector; - else if (p.vector && typeof p.vector === "object") { - const values = Object.values(p.vector); - if (Array.isArray(values[0])) vector = values[0] as number[]; - } - return { - id: p.id as any, - payload: p.payload || {}, - vector, - }; - }); - - return { - points, - nextOffset: response?.result?.next_page_offset, - }; - } - - async search(vector: number[], limit: number = 5, filter?: Record): Promise { - await this.failFastOnDimensionMismatch(vector.length, "search"); - - const body: any = { - vector, - limit, - with_payload: true, - with_vector: false, - }; - - if (filter) { - body.filter = filter; - } - - const response: SearchResponse = await this.request( - `/collections/${this.config.collection}/points/search`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - } - ); - - return response.result || []; - } - - async deleteByFilter(filter: Record): Promise { - await this.request(`/collections/${this.config.collection}/points/delete`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ filter }), - }); - } + private config: Required> & { + dimensionRouteMap: Record; + }; + private logger: any; + private cachedCollectionVectorSize: number | null = null; + + constructor(config: QdrantConfig, logger?: any) { + this.config = { + host: config.host, + port: config.port, + collection: config.collection, + vectorSize: config.vectorSize, + timeout: config.timeout || 30000, + maxRetries: config.maxRetries || 3, + retryDelay: config.retryDelay || 1000, + dimensionRouteMap: config.dimensionRouteMap || {}, + }; + this.logger = logger || console; + } + + getCollectionName(): string { + return this.config.collection; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async request( + path: string, + options: RequestInit, + attempt: number = 1, + ): Promise { + const url = `http://${this.config.host}:${this.config.port}${path}`; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + this.config.timeout, + ); + + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const text = await response.text().catch(() => "Unknown error"); + throw new Error(`HTTP ${response.status}: ${text}`); + } + + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return await response.json(); + } + return null; + } catch (error: any) { + if (attempt < this.config.maxRetries && this.isRetryableError(error)) { + const delay = Math.min(1000 * 2 ** (attempt - 1), 10000); + this.logger.warn( + `[Qdrant] Retry ${attempt} after ${delay}ms: ${error.message}`, + ); + await this.sleep(delay); + return this.request(path, options, attempt + 1); + } + throw error; + } + } + + private isRetryableError(error: any): boolean { + return ( + error.message.includes("timeout") || + error.message.includes("network") || + error.message.includes("ECONNREFUSED") || + error.name === "AbortError" + ); + } + + async collectionExists(): Promise { + try { + await this.request(`/collections/${this.config.collection}`, { + method: "GET", + }); + return true; + } catch (error: any) { + if (error.message.includes("404")) { + return false; + } + throw error; + } + } + + async getCollectionInfo(): Promise { + return this.request(`/collections/${this.config.collection}`, { + method: "GET", + }); + } + + private extractCollectionVectorSize(info: any): number { + const vectors = info?.result?.config?.params?.vectors; + if (typeof vectors?.size === "number") return vectors.size; + + if (vectors && typeof vectors === "object") { + const first = Object.values(vectors)[0] as any; + if (typeof first?.size === "number") return first.size; + } + + return this.config.vectorSize; + } + + async getCollectionVectorSize(forceRefresh = false): Promise { + if (!forceRefresh && this.cachedCollectionVectorSize) + return this.cachedCollectionVectorSize; + + const info = await this.getCollectionInfo(); + const size = this.extractCollectionVectorSize(info); + this.cachedCollectionVectorSize = size; + return size; + } + + private async failFastOnDimensionMismatch( + actualDim: number, + operation: string, + ): Promise { + const expected = await this.getCollectionVectorSize(); + if (actualDim === expected) return; + + const routeCollection = this.config.dimensionRouteMap[actualDim]; + const routeHint = routeCollection + ? `Route hint: use collection '${routeCollection}' for dim=${actualDim}.` + : "No safe route configured. Update collection/vector size or embedding model."; + + throw new Error( + `[Qdrant][DIMENSION_MISMATCH] op=${operation} collection=${this.config.collection} expected=${expected} got=${actualDim}. ${routeHint}`, + ); + } + + async countPoints(exact = true): Promise { + const res = await this.request( + `/collections/${this.config.collection}/points/count`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exact }), + }, + ); + return Number(res?.result?.count || 0); + } + + async createCollection(): Promise { + const exists = await this.collectionExists(); + if (exists) { + this.logger.info( + `[Qdrant] Collection ${this.config.collection} already exists`, + ); + this.cachedCollectionVectorSize = + await this.getCollectionVectorSize(true); + return; + } + + this.logger.info(`[Qdrant] Creating collection ${this.config.collection}`); + + await this.request(`/collections/${this.config.collection}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + vectors: { + size: this.config.vectorSize, + distance: "Cosine", + }, + optimizers_config: { + default_segment_number: 2, + }, + }), + }); + + this.cachedCollectionVectorSize = this.config.vectorSize; + this.logger.info(`[Qdrant] Collection created successfully`); + + await this.createPayloadIndex("namespace", "keyword"); + await this.createPayloadIndex("agent", "keyword"); + await this.createPayloadIndex("source_agent", "keyword"); + await this.createPayloadIndex("source_type", "keyword"); + await this.createPayloadIndex("timestamp", "integer"); + await this.createPayloadIndex("userId", "keyword"); + + // ASM-76 (v5.1) bootstrap indexes for project-aware retrieval schema + await this.createPayloadIndex("schema_version", "keyword"); + await this.createPayloadIndex("project_id", "keyword"); + await this.createPayloadIndex("chunk_id", "keyword"); + await this.createPayloadIndex("doc_kind", "keyword"); + await this.createPayloadIndex("relative_path", "keyword"); + await this.createPayloadIndex("language", "keyword"); + await this.createPayloadIndex("module", "keyword"); + await this.createPayloadIndex("symbol_name", "keyword"); + await this.createPayloadIndex("symbol_id", "keyword"); + await this.createPayloadIndex("task_id", "keyword"); + await this.createPayloadIndex("commit_sha", "keyword"); + await this.createPayloadIndex("checksum", "keyword"); + await this.createPayloadIndex("indexed_at", "integer"); + await this.createPayloadIndex("tombstone_at", "keyword"); + await this.createPayloadIndex("active", "bool"); + await this.createPayloadIndex("index_state", "keyword"); + } + + async createPayloadIndex( + fieldName: string, + fieldType: "keyword" | "integer" | "float" | "bool", + ): Promise { + try { + await this.request(`/collections/${this.config.collection}/index`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + field_name: fieldName, + field_schema: fieldType, + }), + }); + this.logger.info(`[Qdrant] Created payload index: ${fieldName}`); + } catch (error: any) { + this.logger.warn( + `[Qdrant] Failed to create index ${fieldName}: ${error.message}`, + ); + } + } + + async upsert(points: Point[]): Promise { + for (const point of points) { + await this.failFastOnDimensionMismatch(point.vector.length, "upsert"); + } + + await this.request(`/collections/${this.config.collection}/points`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ points }), + }); + } + + async updateVectors( + points: Array<{ id: any; vector: number[] }>, + ): Promise { + if (points.length === 0) return; + + for (const point of points) { + await this.failFastOnDimensionMismatch( + point.vector.length, + "updateVectors", + ); + } + + try { + await this.request( + `/collections/${this.config.collection}/points/vectors`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ points }), + }, + ); + return; + } catch (error: any) { + this.logger.warn( + `[Qdrant] updateVectors endpoint failed, fallback to upsert /points: ${error.message}`, + ); + } + + throw new Error("updateVectors endpoint unsupported"); + } + + async setPayload( + payloadById: Array<{ + id: string | number | Record; + payload: Record; + }>, + ): Promise { + if (payloadById.length === 0) return; + + for (const row of payloadById) { + await this.request( + `/collections/${this.config.collection}/points/payload`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + payload: row.payload, + points: [row.id], + }), + }, + ); + } + } + + async scroll( + limit: number, + offset?: any, + withVector = false, + ): Promise<{ + points: Array<{ + id: string; + payload: Record; + vector?: number[]; + }>; + nextOffset?: any; + }> { + const body: any = { + limit, + with_payload: true, + with_vector: withVector, + }; + + if (offset !== undefined && offset !== null) { + body.offset = offset; + } + + const response: ScrollResponse = await this.request( + `/collections/${this.config.collection}/points/scroll`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + const points = (response?.result?.points || []).map((p) => { + let vector: number[] | undefined; + if (Array.isArray(p.vector)) vector = p.vector; + else if (p.vector && typeof p.vector === "object") { + const values = Object.values(p.vector); + if (Array.isArray(values[0])) vector = values[0] as number[]; + } + return { + id: p.id as any, + payload: p.payload || {}, + vector, + }; + }); + + return { + points, + nextOffset: response?.result?.next_page_offset, + }; + } + + async search( + vector: number[], + limit: number = 5, + filter?: Record, + ): Promise { + await this.failFastOnDimensionMismatch(vector.length, "search"); + + const body: any = { + vector, + limit, + with_payload: true, + with_vector: false, + }; + + if (filter) { + body.filter = filter; + } + + const response: SearchResponse = await this.request( + `/collections/${this.config.collection}/points/search`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + return response.result || []; + } + + async deleteByFilter(filter: Record): Promise { + await this.request(`/collections/${this.config.collection}/points/delete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filter }), + }); + } } diff --git a/src/shared/memory-config.ts b/src/shared/memory-config.ts index f680c41..77a3b96 100644 --- a/src/shared/memory-config.ts +++ b/src/shared/memory-config.ts @@ -6,194 +6,244 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -export const DEFAULT_CORE_AGENTS = ["assistant", "scrum", "fullstack", "trader", "creator"] as const; +export const DEFAULT_CORE_AGENTS = [ + "assistant", + "scrum", + "fullstack", + "trader", + "creator", +] as const; export type DefaultCoreAgent = (typeof DEFAULT_CORE_AGENTS)[number]; export type AgentNamespace = - | `agent.${string}.working_memory` - | `agent.${string}.lessons` - | `agent.${string}.decisions`; + | `agent.${string}.working_memory` + | `agent.${string}.lessons` + | `agent.${string}.decisions`; /** New normalized namespace model (ASM-5, dynamic agent registry aware) */ export type MemoryNamespace = - | AgentNamespace - | "shared.project_context" - | "shared.rules_slotdb" - | "shared.runbooks" - | "noise.filtered"; + | AgentNamespace + | "shared.project_context" + | "shared.rules_slotdb" + | "shared.runbooks" + | "noise.filtered"; + +export type MemoryScope = "session" | "agent" | "project" | "shared"; + +export type MemoryType = + | "fact" + | "lesson" + | "decision" + | "runbook" + | "episodic_trace" + | "task_context" + | "rule" + | "noise"; + +export type PromotionState = "raw" | "distilled" | "promoted" | "deprecated"; + +export type MemorySourceType = + | "auto_capture" + | "manual" + | "tool_call" + | "migration" + | "promotion"; /** Legacy namespaces kept for migration compatibility */ export type LegacyNamespace = - | "agent_decisions" - | "user_profile" - | "project_context" - | "trading_signals" - | "agent_learnings" - | "system_rules" - | "session_summaries" - | "market_patterns" - | "default"; + | "agent_decisions" + | "user_profile" + | "project_context" + | "trading_signals" + | "agent_learnings" + | "system_rules" + | "session_summaries" + | "market_patterns" + | "default"; interface OpenClawAgentListEntry { - id?: unknown; + id?: unknown; } interface OpenClawRuntimeConfig { - agents?: { - list?: OpenClawAgentListEntry[]; - }; + agents?: { + list?: OpenClawAgentListEntry[]; + }; } const STATIC_FALLBACK_AGENT_SET = new Set(DEFAULT_CORE_AGENTS); -const AGENT_NAMESPACE_RE = /^agent\.([a-z0-9][a-z0-9_-]*)\.(working_memory|lessons|decisions)$/i; +const AGENT_NAMESPACE_RE = + /^agent\.([a-z0-9][a-z0-9_-]*)\.(working_memory|lessons|decisions)$/i; let cachedRegistry: { - configPath: string; - mtimeMs: number; - agentIds: string[]; + configPath: string; + mtimeMs: number; + agentIds: string[]; } | null = null; function normalizeAgentId(agentId: string | null | undefined): string { - return String(agentId || "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, ""); + return String(agentId || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); } function getStateDir(): string { - return process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`; + return process.env.OPENCLAW_STATE_DIR || `${process.env.HOME}/.openclaw`; } export function resolveOpenClawConfigPath(): string { - const explicit = process.env.OPENCLAW_CONFIG_PATH || process.env.OPENCLAW_RUNTIME_CONFIG; - if (explicit && explicit.trim()) { - return explicit.trim(); - } - return join(getStateDir(), "openclaw.json"); + const explicit = + process.env.OPENCLAW_CONFIG_PATH || process.env.OPENCLAW_RUNTIME_CONFIG; + if (explicit && explicit.trim()) { + return explicit.trim(); + } + return join(getStateDir(), "openclaw.json"); } function readRuntimeAgentIdsFromConfig(configPath: string): string[] { - if (!existsSync(configPath)) { - return [...DEFAULT_CORE_AGENTS]; - } - - try { - const parsed = JSON.parse(readFileSync(configPath, "utf8")) as OpenClawRuntimeConfig; - const listed = Array.isArray(parsed?.agents?.list) ? parsed.agents.list : []; - const dynamicIds = listed - .map((entry) => normalizeAgentId(String(entry?.id || ""))) - .filter(Boolean); - - const merged = new Set([...DEFAULT_CORE_AGENTS, ...dynamicIds]); - return [...merged]; - } catch { - return [...DEFAULT_CORE_AGENTS]; - } + if (!existsSync(configPath)) { + return [...DEFAULT_CORE_AGENTS]; + } + + try { + const parsed = JSON.parse( + readFileSync(configPath, "utf8"), + ) as OpenClawRuntimeConfig; + const listed = Array.isArray(parsed?.agents?.list) + ? parsed.agents.list + : []; + const dynamicIds = listed + .map((entry) => normalizeAgentId(String(entry?.id || ""))) + .filter(Boolean); + + const merged = new Set([...DEFAULT_CORE_AGENTS, ...dynamicIds]); + return [...merged]; + } catch { + return [...DEFAULT_CORE_AGENTS]; + } } export function getRegisteredAgentIds(): string[] { - const configPath = resolveOpenClawConfigPath(); - - try { - const mtimeMs = existsSync(configPath) ? statSync(configPath).mtimeMs : -1; - if (cachedRegistry && cachedRegistry.configPath === configPath && cachedRegistry.mtimeMs === mtimeMs) { - return [...cachedRegistry.agentIds]; - } - - const agentIds = readRuntimeAgentIdsFromConfig(configPath); - cachedRegistry = { configPath, mtimeMs, agentIds }; - return [...agentIds]; - } catch { - return [...DEFAULT_CORE_AGENTS]; - } + const configPath = resolveOpenClawConfigPath(); + + try { + const mtimeMs = existsSync(configPath) ? statSync(configPath).mtimeMs : -1; + if ( + cachedRegistry && + cachedRegistry.configPath === configPath && + cachedRegistry.mtimeMs === mtimeMs + ) { + return [...cachedRegistry.agentIds]; + } + + const agentIds = readRuntimeAgentIdsFromConfig(configPath); + cachedRegistry = { configPath, mtimeMs, agentIds }; + return [...agentIds]; + } catch { + return [...DEFAULT_CORE_AGENTS]; + } } export function isRegisteredAgent(agentId: string): boolean { - const normalized = normalizeAgentId(agentId); - if (!normalized) return false; - return getRegisteredAgentIds().includes(normalized); + const normalized = normalizeAgentId(agentId); + if (!normalized) return false; + return getRegisteredAgentIds().includes(normalized); } -export function resolveAgentId(agentId: string | null | undefined, fallbackAgent: string = "assistant"): string { - const normalized = normalizeAgentId(agentId); - if (normalized) { - return normalized; - } - - const fallback = normalizeAgentId(fallbackAgent); - return fallback || "assistant"; +export function resolveAgentId( + agentId: string | null | undefined, + fallbackAgent: string = "assistant", +): string { + const normalized = normalizeAgentId(agentId); + if (normalized) { + return normalized; + } + + const fallback = normalizeAgentId(fallbackAgent); + return fallback || "assistant"; } -const LEGACY_TO_NEW_NAMESPACE: Partial> = { - agent_decisions: "agent.assistant.decisions", - user_profile: "shared.project_context", - project_context: "shared.project_context", - trading_signals: "agent.trader.decisions", - agent_learnings: "agent.assistant.lessons", - system_rules: "shared.rules_slotdb", - default: "agent.assistant.working_memory", +const LEGACY_TO_NEW_NAMESPACE: Partial< + Record +> = { + agent_decisions: "agent.assistant.decisions", + user_profile: "shared.project_context", + project_context: "shared.project_context", + trading_signals: "agent.trader.decisions", + agent_learnings: "agent.assistant.lessons", + system_rules: "shared.rules_slotdb", + default: "agent.assistant.working_memory", }; -export function isAgentNamespace(value: string | null | undefined): value is AgentNamespace { - return typeof value === "string" && AGENT_NAMESPACE_RE.test(value.trim()); +export function isAgentNamespace( + value: string | null | undefined, +): value is AgentNamespace { + return typeof value === "string" && AGENT_NAMESPACE_RE.test(value.trim()); } -export function normalizeNamespace(value: string | null | undefined, fallbackAgent: string = "assistant"): MemoryNamespace { - const agent = resolveAgentId(fallbackAgent); - if (!value) return `agent.${agent}.working_memory`; - - const trimmed = value.trim(); - if ( - trimmed === "shared.project_context" - || trimmed === "shared.rules_slotdb" - || trimmed === "shared.runbooks" - || trimmed === "noise.filtered" - || isAgentNamespace(trimmed) - ) { - return trimmed as MemoryNamespace; - } - - const directAgentAlias = resolveAgentId(trimmed); - if (directAgentAlias && isRegisteredAgent(directAgentAlias)) { - return `agent.${directAgentAlias}.working_memory`; - } - - const mapped = LEGACY_TO_NEW_NAMESPACE[trimmed as LegacyNamespace]; - if (mapped) return mapped; - - return `agent.${agent}.working_memory`; +export function normalizeNamespace( + value: string | null | undefined, + fallbackAgent: string = "assistant", +): MemoryNamespace { + const agent = resolveAgentId(fallbackAgent); + if (!value) return `agent.${agent}.working_memory`; + + const trimmed = value.trim(); + if ( + trimmed === "shared.project_context" || + trimmed === "shared.rules_slotdb" || + trimmed === "shared.runbooks" || + trimmed === "noise.filtered" || + isAgentNamespace(trimmed) + ) { + return trimmed as MemoryNamespace; + } + + const directAgentAlias = resolveAgentId(trimmed); + if (directAgentAlias && isRegisteredAgent(directAgentAlias)) { + return `agent.${directAgentAlias}.working_memory`; + } + + const mapped = LEGACY_TO_NEW_NAMESPACE[trimmed as LegacyNamespace]; + if (mapped) return mapped; + + return `agent.${agent}.working_memory`; } -export function parseExplicitNamespace(value: string | null | undefined, fallbackAgent: string = "assistant"): MemoryNamespace { - if (!value || !value.trim()) { - throw new Error("Namespace cannot be empty when provided explicitly"); - } - - const trimmed = value.trim(); - const agent = resolveAgentId(fallbackAgent); - - if ( - trimmed === "shared.project_context" - || trimmed === "shared.rules_slotdb" - || trimmed === "shared.runbooks" - || trimmed === "noise.filtered" - || isAgentNamespace(trimmed) - ) { - return trimmed as MemoryNamespace; - } - - const directAgentAlias = resolveAgentId(trimmed); - if (directAgentAlias && isRegisteredAgent(directAgentAlias)) { - return `agent.${directAgentAlias}.working_memory`; - } - - const mapped = LEGACY_TO_NEW_NAMESPACE[trimmed as LegacyNamespace]; - if (mapped) return mapped; - - throw new Error( - `Unknown namespace: ${trimmed}. Use a registered agent alias, canonical agent..(working_memory|lessons|decisions), or shared namespace.` - ); +export function parseExplicitNamespace( + value: string | null | undefined, + fallbackAgent: string = "assistant", +): MemoryNamespace { + if (!value || !value.trim()) { + throw new Error("Namespace cannot be empty when provided explicitly"); + } + + const trimmed = value.trim(); + const agent = resolveAgentId(fallbackAgent); + + if ( + trimmed === "shared.project_context" || + trimmed === "shared.rules_slotdb" || + trimmed === "shared.runbooks" || + trimmed === "noise.filtered" || + isAgentNamespace(trimmed) + ) { + return trimmed as MemoryNamespace; + } + + const directAgentAlias = resolveAgentId(trimmed); + if (directAgentAlias && isRegisteredAgent(directAgentAlias)) { + return `agent.${directAgentAlias}.working_memory`; + } + + const mapped = LEGACY_TO_NEW_NAMESPACE[trimmed as LegacyNamespace]; + if (mapped) return mapped; + + throw new Error( + `Unknown namespace: ${trimmed}. Use a registered agent alias, canonical agent..(working_memory|lessons|decisions), or shared namespace.`, + ); } /** @@ -202,7 +252,7 @@ export function parseExplicitNamespace(value: string | null | undefined, fallbac * New behavior keeps unknown/extra registry agents as themselves. */ export function toCoreAgent(agentId: string): string { - return resolveAgentId(agentId); + return resolveAgentId(agentId); } /** @@ -215,151 +265,221 @@ export const DEFAULT_AGENT_BLOCKLIST = new Set([]); * Per-agent recall namespaces (noise.filtered is intentionally excluded) */ export function getAgentNamespaces(agentId: string): MemoryNamespace[] { - const agent = resolveAgentId(agentId); - return [ - `agent.${agent}.working_memory`, - `agent.${agent}.lessons`, - `agent.${agent}.decisions`, - "shared.project_context", - "shared.rules_slotdb", - "shared.runbooks", - ]; + const agent = resolveAgentId(agentId); + return [ + `agent.${agent}.working_memory`, + `agent.${agent}.lessons`, + `agent.${agent}.decisions`, + "shared.project_context", + "shared.rules_slotdb", + "shared.runbooks", + ]; } -export function getAutoCaptureNamespace(agentId: string, text?: string): MemoryNamespace { - const agent = resolveAgentId(agentId); - const content = String(text || ""); - - if (isLearningContent(content)) return `agent.${agent}.lessons`; - if (isDecisionContent(content)) return `agent.${agent}.decisions`; - if (isRunbookContent(content)) return "shared.runbooks"; - if (isRuleContent(content)) return "shared.rules_slotdb"; - if (isProjectContextContent(content)) return "shared.project_context"; - return `agent.${agent}.working_memory`; +export function getAutoCaptureNamespace( + agentId: string, + text?: string, +): MemoryNamespace { + const agent = resolveAgentId(agentId); + const content = String(text || ""); + + if (isLearningContent(content)) return `agent.${agent}.lessons`; + if (isDecisionContent(content)) return `agent.${agent}.decisions`; + if (isRunbookContent(content)) return "shared.runbooks"; + if (isRuleContent(content)) return "shared.rules_slotdb"; + if (isProjectContextContent(content)) return "shared.project_context"; + return `agent.${agent}.working_memory`; } /** Recall priority weighting policy */ -const SHARED_NAMESPACE_WEIGHT: Record<"shared.project_context" | "shared.rules_slotdb" | "shared.runbooks", number> = { - "shared.project_context": 1.08, - "shared.rules_slotdb": 1.18, - "shared.runbooks": 1.12, +const SHARED_NAMESPACE_WEIGHT: Record< + "shared.project_context" | "shared.rules_slotdb" | "shared.runbooks", + number +> = { + "shared.project_context": 1.08, + "shared.rules_slotdb": 1.18, + "shared.runbooks": 1.12, }; export function getNamespaceWeight(agentId: string, namespace: string): number { - const agent = resolveAgentId(agentId); - if (namespace === `agent.${agent}.decisions`) return 1.25; - if (namespace === `agent.${agent}.lessons`) return 1.2; - if (namespace === `agent.${agent}.working_memory`) return 1.1; - - if (namespace in SHARED_NAMESPACE_WEIGHT) { - return SHARED_NAMESPACE_WEIGHT[namespace as keyof typeof SHARED_NAMESPACE_WEIGHT]; - } - - if (namespace === "noise.filtered") return 0.01; - return 1.0; + const agent = resolveAgentId(agentId); + if (namespace === `agent.${agent}.decisions`) return 1.25; + if (namespace === `agent.${agent}.lessons`) return 1.2; + if (namespace === `agent.${agent}.working_memory`) return 1.1; + + if (namespace in SHARED_NAMESPACE_WEIGHT) { + return SHARED_NAMESPACE_WEIGHT[ + namespace as keyof typeof SHARED_NAMESPACE_WEIGHT + ]; + } + + if (namespace === "noise.filtered") return 0.01; + return 1.0; } /** Noise policy v2 */ export const NOISE_PATTERNS_V2: RegExp[] = [ - /^\s*(ok|k|kk|yes|no|thanks?|tks|thx)\s*$/i, - /^\s*(no_reply|heartbeat_ok)\s*$/i, - /^\s*[.?]+\s*$/, - /^\s*\/\w+/, - /^\s*\[tool[:\]]/i, - /^\s*\{\s*"type"\s*:\s*"toolCall"/i, - /^\s*(ping|pong)\s*$/i, + /^\s*(ok|k|kk|yes|no|thanks?|tks|thx)\s*$/i, + /^\s*(no_reply|heartbeat_ok)\s*$/i, + /^\s*[.?]+\s*$/, + /^\s*\/\w+/, + /^\s*\[tool[:\]]/i, + /^\s*\{\s*"type"\s*:\s*"toolCall"/i, + /^\s*(ping|pong)\s*$/i, ]; const SOURCE_TYPE_NOISE_WEIGHT: Record = { - auto_capture: 0.15, - tool_call: 0.2, - manual: 0.02, + auto_capture: 0.15, + tool_call: 0.2, + manual: 0.02, }; -export function evaluateNoiseV2(text: string, sourceType: "auto_capture" | "manual" | "tool_call" = "auto_capture"): { - score: number; - isNoise: boolean; - matchedPatterns: string[]; +export function evaluateNoiseV2( + text: string, + sourceType: "auto_capture" | "manual" | "tool_call" = "auto_capture", +): { + score: number; + isNoise: boolean; + matchedPatterns: string[]; } { - const content = String(text || "").trim(); - const matchedPatterns = NOISE_PATTERNS_V2.filter((p) => p.test(content)).map((p) => p.toString()); - - const lengthPenalty = content.length < 8 ? 0.45 : content.length < 24 ? 0.15 : 0; - const patternScore = matchedPatterns.length > 0 ? Math.min(0.8, matchedPatterns.length * 0.4) : 0; - const sourceScore = SOURCE_TYPE_NOISE_WEIGHT[sourceType] ?? 0.1; - - const score = Math.min(1, Number((patternScore + sourceScore + lengthPenalty).toFixed(3))); - return { - score, - isNoise: score >= 0.62, - matchedPatterns, - }; + const content = String(text || "").trim(); + const matchedPatterns = NOISE_PATTERNS_V2.filter((p) => p.test(content)).map( + (p) => p.toString(), + ); + + const lengthPenalty = + content.length < 8 ? 0.45 : content.length < 24 ? 0.15 : 0; + const patternScore = + matchedPatterns.length > 0 + ? Math.min(0.8, matchedPatterns.length * 0.4) + : 0; + const sourceScore = SOURCE_TYPE_NOISE_WEIGHT[sourceType] ?? 0.1; + + const score = Math.min( + 1, + Number((patternScore + sourceScore + lengthPenalty).toFixed(3)), + ); + return { + score, + isNoise: score >= 0.62, + matchedPatterns, + }; } export function isLearningContent(text: string): boolean { - return /\b(learned|lesson|takeaway|kinh nghiệm|bài học|rút ra|postmortem|root cause)\b/i.test(text); + return /\b(learned|lesson|takeaway|kinh nghiệm|bài học|rút ra|postmortem|root cause)\b/i.test( + text, + ); } export function isDecisionContent(text: string): boolean { - return /\b(decision|approved|chốt|quyết định|ship|go with|reject|accept)\b/i.test(text); + return /\b(decision|approved|chốt|quyết định|ship|go with|reject|accept)\b/i.test( + text, + ); } export function isProjectContextContent(text: string): boolean { - return /\b(deploy|release|migration|rollback|staging|production|port|endpoint|schema|db|api key|config)\b/i.test(text); + return /\b(deploy|release|migration|rollback|staging|production|port|endpoint|schema|db|api key|config)\b/i.test( + text, + ); } export function isRuleContent(text: string): boolean { - return /\b(rule|policy|guardrail|must|never|always|slotdb|quy tắc|bắt buộc|không được)\b/i.test(text); + return /\b(rule|policy|guardrail|must|never|always|slotdb|quy tắc|bắt buộc|không được)\b/i.test( + text, + ); } export function isRunbookContent(text: string): boolean { - return /\b(runbook|sop|playbook|incident response|checklist|triage|khắc phục|vận hành)\b/i.test(text); + return /\b(runbook|sop|playbook|incident response|checklist|triage|khắc phục|vận hành)\b/i.test( + text, + ); } export function isBlockedAgent(agentId: string): boolean { - return DEFAULT_AGENT_BLOCKLIST.has(agentId); + return DEFAULT_AGENT_BLOCKLIST.has(agentId); } export function normalizeUserId(rawUserId: string): string { - if (rawUserId === '__team__' || rawUserId === '__public__') { - return rawUserId; - } - return 'default'; + if (rawUserId === "__team__" || rawUserId === "__public__") { + return rawUserId; + } + return "default"; +} + +export function resolveMemoryScopeFromNamespace( + namespace: MemoryNamespace, +): MemoryScope { + if (namespace.startsWith("agent.")) return "agent"; + if (namespace === "shared.project_context") return "project"; + if (namespace === "noise.filtered") return "session"; + return "shared"; +} + +export function resolveMemoryTypeFromNamespace( + namespace: MemoryNamespace, +): MemoryType { + if (namespace.endsWith(".lessons")) return "lesson"; + if (namespace.endsWith(".decisions")) return "decision"; + if (namespace === "shared.rules_slotdb") return "rule"; + if (namespace === "shared.runbooks") return "runbook"; + if (namespace === "shared.project_context") return "task_context"; + if (namespace === "noise.filtered") return "noise"; + return "episodic_trace"; +} + +export function resolveDefaultConfidence(sourceType: MemorySourceType): number { + switch (sourceType) { + case "manual": + return 0.9; + case "tool_call": + return 0.85; + case "migration": + return 0.75; + case "promotion": + return 0.95; + case "auto_capture": + default: + return 0.7; + } } export const SLOT_TTL_DAYS: Record = { - project: 7, - environment: 3, - custom: 14, - profile: 90, - preferences: 90, + project: 7, + environment: 3, + custom: 14, + profile: 90, + preferences: 90, }; export function getSlotTTL(category: string): number { - return SLOT_TTL_DAYS[category] ?? 30; + return SLOT_TTL_DAYS[category] ?? 30; } export class NoiseFilter { - private agentId: string; - constructor(agentId: string) { - this.agentId = agentId; - } - - isBlocked(): boolean { - return isBlockedAgent(this.agentId); - } - - shouldSkip(text: string): boolean { - return evaluateNoiseV2(text, "auto_capture").isNoise; - } - - classify(text: string, sourceType: "auto_capture" | "manual" | "tool_call" = "auto_capture") { - return evaluateNoiseV2(text, sourceType); - } - - getTargetNamespace(text?: string): MemoryNamespace { - return getAutoCaptureNamespace(this.agentId, text); - } + private agentId: string; + constructor(agentId: string) { + this.agentId = agentId; + } + + isBlocked(): boolean { + return isBlockedAgent(this.agentId); + } + + shouldSkip(text: string): boolean { + return evaluateNoiseV2(text, "auto_capture").isNoise; + } + + classify( + text: string, + sourceType: "auto_capture" | "manual" | "tool_call" = "auto_capture", + ) { + return evaluateNoiseV2(text, sourceType); + } + + getTargetNamespace(text?: string): MemoryNamespace { + return getAutoCaptureNamespace(this.agentId, text); + } } export const CORE_AGENTS = DEFAULT_CORE_AGENTS; diff --git a/src/tools/memory_search.ts b/src/tools/memory_search.ts index 6ae5915..3449571 100644 --- a/src/tools/memory_search.ts +++ b/src/tools/memory_search.ts @@ -1,222 +1,286 @@ -import { QdrantClient } from "../services/qdrant.js"; -import { EmbeddingClient } from "../services/embedding.js"; -import { SearchParams, ToolResult, ScoredPoint, MemoryNamespace } from "../types.js"; -import { getAgentNamespaces, getNamespaceWeight, parseExplicitNamespace, toCoreAgent } from "../shared/memory-config.js"; - -function resolveAgentFromRuntimeParams(params: { agentId?: string; sessionId?: string; namespace?: unknown }): string { - const directAgentId = typeof params.agentId === "string" ? params.agentId.trim() : ""; - if (directAgentId) return toCoreAgent(directAgentId); - - const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : ""; - if (sessionId) { - const parts = sessionId.split(":"); - if (parts.length >= 2 && parts[0] === "agent") { - const fromSession = parts[1]?.trim(); - if (fromSession) return toCoreAgent(fromSession); - } - } - - const namespace = typeof params.namespace === "string" ? params.namespace.trim() : ""; - const nsMatch = /^agent\.([a-z0-9][a-z0-9_-]*)\.(working_memory|lessons|decisions)$/i.exec(namespace); - if (nsMatch?.[1]) { - return toCoreAgent(nsMatch[1]); - } - - return "assistant"; +import { + normalizeSessionToken, + resolveSessionMode, + scoreSemanticCandidate, + shouldApplyStrictSessionFilter, +} from "../core/retrieval-policy.js"; +import type { EmbeddingClient } from "../services/embedding.js"; +import type { QdrantClient } from "../services/qdrant.js"; +import { + getAgentNamespaces, + parseExplicitNamespace, + toCoreAgent, +} from "../shared/memory-config.js"; +import type { + MemoryNamespace, + ScoredPoint, + SearchParams, + ToolResult, +} from "../types.js"; + +function resolveAgentFromRuntimeParams(params: { + agentId?: string; + sessionId?: string; + namespace?: unknown; +}): string { + const directAgentId = + typeof params.agentId === "string" ? params.agentId.trim() : ""; + if (directAgentId) return toCoreAgent(directAgentId); + + const sessionId = + typeof params.sessionId === "string" ? params.sessionId.trim() : ""; + if (sessionId) { + const parts = sessionId.split(":"); + if (parts.length >= 2 && parts[0] === "agent") { + const fromSession = parts[1]?.trim(); + if (fromSession) return toCoreAgent(fromSession); + } + } + + const namespace = + typeof params.namespace === "string" ? params.namespace.trim() : ""; + const nsMatch = + /^agent\.([a-z0-9][a-z0-9_-]*)\.(working_memory|lessons|decisions)$/i.exec( + namespace, + ); + if (nsMatch?.[1]) { + return toCoreAgent(nsMatch[1]); + } + + return "assistant"; } export const memorySearchSchema = { - type: "object", - properties: { - query: { - type: "string", - description: "Search query for relevant memories", - }, - limit: { - type: "number", - description: "Max results (default: 5)", - minimum: 1, - maximum: 20, - }, - namespace: { - type: "string", - description: "Filter by namespace (default: auto-detected from agent)", - }, - sessionId: { - type: "string", - description: "Filter by session ID", - }, - userId: { - type: "string", - description: "Filter by user ID", - }, - minScore: { - type: "number", - description: "Minimum similarity score (0-1, default: 0.7)", - minimum: 0, - maximum: 1, - }, - sourceAgent: { - type: "string", - description: "Filter by source agent ID", - }, - }, - required: ["query"], + type: "object", + properties: { + query: { + type: "string", + description: "Search query for relevant memories", + }, + limit: { + type: "number", + description: "Max results (default: 5)", + minimum: 1, + maximum: 20, + }, + namespace: { + type: "string", + description: "Filter by namespace (default: auto-detected from agent)", + }, + sessionId: { + type: "string", + description: "Filter by session ID", + }, + sessionMode: { + type: "string", + enum: ["strict", "soft"], + description: + "Session matching mode (default: soft). strict=hard filter, soft=score boost only", + }, + userId: { + type: "string", + description: "Filter by user ID", + }, + minScore: { + type: "number", + description: "Minimum similarity score (0-1, default: 0.7)", + minimum: 0, + maximum: 1, + }, + sourceAgent: { + type: "string", + description: "Filter by source agent ID", + }, + }, + required: ["query"], }; export function createMemorySearchTool( - qdrant: QdrantClient, - embedding: EmbeddingClient, - defaultNamespace: MemoryNamespace + qdrant: QdrantClient, + embedding: EmbeddingClient, + defaultNamespace: MemoryNamespace, ) { - const createDetails = (text: string, extra: Record = {}) => ({ - ...extra, - toolResult: { text }, - }); - - return { - name: "memory_search", - label: "Memory Search", - description: "Search stored memories by semantic similarity. Returns most relevant past information.", - parameters: memorySearchSchema, - - async execute( - _id: string, - params: SearchParams & { sourceAgent?: string; agentId?: string }, - _signal?: AbortSignal - ): Promise { - try { - // Validate - if (!params.query || typeof params.query !== "string") { - return { - content: [{ type: "text", text: "Error: query is required" }], - isError: true, - details: createDetails("Error: query is required", { error: "Missing query parameter" }), - }; - } - - const query = params.query.trim(); - if (query.length === 0) { - return { - content: [{ type: "text", text: "Error: query cannot be empty" }], - isError: true, - details: createDetails("Error: query cannot be empty", { error: "Empty query" }), - }; - } - - const limit = Math.min(Math.max(params.limit || 5, 1), 20); - const minScore = params.minScore ?? 0.7; - - // Determine namespaces to search (normalize user-facing aliases to canonical namespaces) - const sourceAgent = resolveAgentFromRuntimeParams(params); - const namespaces: MemoryNamespace[] = params.namespace - ? [parseExplicitNamespace(params.namespace as string, sourceAgent)] - : getAgentNamespaces(sourceAgent); - - // Build namespace filter (OR if multiple namespaces) - const namespaceConditions = namespaces.map(ns => ({ - key: "namespace", - match: { value: ns }, - })); - - const filterConditions: any[] = []; - - if (namespaceConditions.length === 1) { - filterConditions.push(namespaceConditions[0]); - } else if (namespaceConditions.length > 1) { - filterConditions.push({ should: namespaceConditions }); - } - - if (params.sessionId) { - filterConditions.push({ - key: "sessionId", - match: { value: params.sessionId }, - }); - } - - if (params.userId) { - filterConditions.push({ - key: "userId", - match: { value: params.userId }, - }); - } - - if (params.sourceAgent) { - filterConditions.push({ - key: "source_agent", - match: { value: params.sourceAgent }, - }); - } - - const filter = filterConditions.length > 0 ? { must: filterConditions } : undefined; - - // Generate embedding and search - const vector = await embedding.embed(query); - const results = await qdrant.search(vector, limit, filter); - - // Exclude quarantined noise and apply namespace-priority weighting - const weighted = results - .filter((r: ScoredPoint) => (r.payload?.namespace || "") !== "noise.filtered") - .map((r: ScoredPoint) => { - const ns = String(r.payload?.namespace || ""); - const weight = getNamespaceWeight(sourceAgent, ns); - return { - ...r, - _rawScore: r.score, - score: Math.min(1, r.score * weight), - } as ScoredPoint & { _rawScore: number }; - }) - .sort((a: any, b: any) => b.score - a.score); - - // Filter by minScore on weighted score - const filtered = weighted.filter((r: ScoredPoint) => r.score >= minScore); - - if (filtered.length === 0) { - const textOut = "No relevant memories found."; - return { - content: [{ type: "text", text: textOut }], - details: createDetails(textOut, { count: 0, query }), - }; - } - - // Format results - const formatted = filtered.map((r: ScoredPoint, i: number) => { - const payload = r.payload; - const date = payload.timestamp - ? new Date(payload.timestamp).toLocaleDateString() - : "Unknown"; - - const lines = [ - `[${i + 1}] Score: ${(r.score * 100).toFixed(1)}%`, - `Namespace: ${payload.namespace || "unknown"}`, - `Text: ${payload.text}`, - `Date: ${date}`, - ]; - - if (payload.metadata && Object.keys(payload.metadata).length > 0) { - lines.push(`Metadata: ${JSON.stringify(payload.metadata)}`); - } - - return lines.join("\n"); - }).join("\n\n---\n\n"); - - const textOut = `Found ${filtered.length} relevant memories for "${query}":\n\n${formatted}`; - return { - content: [{ - type: "text", - text: textOut, - }], - details: createDetails(textOut, { count: filtered.length, query, results: filtered }), - }; - - } catch (error: any) { - const textOut = `Error searching memories: ${error.message}`; - return { - content: [{ type: "text", text: textOut }], - isError: true, - details: createDetails(textOut, { error: error.message }), - }; - } - }, - }; + const createDetails = ( + text: string, + extra: Record = {}, + ) => ({ + ...extra, + toolResult: { text }, + }); + + return { + name: "memory_search", + label: "Memory Search", + description: + "Search stored memories by semantic similarity. Returns most relevant past information.", + parameters: memorySearchSchema, + + async execute( + _id: string, + params: SearchParams & { sourceAgent?: string; agentId?: string }, + _signal?: AbortSignal, + ): Promise { + try { + // Validate + if (!params.query || typeof params.query !== "string") { + return { + content: [{ type: "text", text: "Error: query is required" }], + isError: true, + details: createDetails("Error: query is required", { + error: "Missing query parameter", + }), + }; + } + + const query = params.query.trim(); + if (query.length === 0) { + return { + content: [{ type: "text", text: "Error: query cannot be empty" }], + isError: true, + details: createDetails("Error: query cannot be empty", { + error: "Empty query", + }), + }; + } + + const limit = Math.min(Math.max(params.limit || 5, 1), 20); + const minScore = params.minScore ?? 0.7; + const sessionMode = resolveSessionMode((params as any).sessionMode); + const preferredSessionId = normalizeSessionToken(params.sessionId); + + // Determine namespaces to search (normalize user-facing aliases to canonical namespaces) + const sourceAgent = resolveAgentFromRuntimeParams(params); + const namespaces: MemoryNamespace[] = params.namespace + ? [parseExplicitNamespace(params.namespace as string, sourceAgent)] + : getAgentNamespaces(sourceAgent); + + // Build namespace filter (OR if multiple namespaces) + const namespaceConditions = namespaces.map((ns) => ({ + key: "namespace", + match: { value: ns }, + })); + + const filterConditions: any[] = []; + + if (namespaceConditions.length === 1) { + filterConditions.push(namespaceConditions[0]); + } else if (namespaceConditions.length > 1) { + filterConditions.push({ should: namespaceConditions }); + } + + if (shouldApplyStrictSessionFilter(sessionMode, preferredSessionId)) { + filterConditions.push({ + key: "sessionId", + match: { value: params.sessionId }, + }); + } + + if (params.userId) { + filterConditions.push({ + key: "userId", + match: { value: params.userId }, + }); + } + + if (params.sourceAgent) { + filterConditions.push({ + key: "source_agent", + match: { value: params.sourceAgent }, + }); + } + + const filter = + filterConditions.length > 0 ? { must: filterConditions } : undefined; + + // Generate embedding and search + const vector = await embedding.embed(query); + const results = await qdrant.search(vector, limit, filter); + + // Exclude quarantined noise and apply namespace-priority weighting + const weighted = results + .filter( + (r: ScoredPoint) => + (r.payload?.namespace || "") !== "noise.filtered", + ) + .map((r: ScoredPoint) => { + const ns = String(r.payload?.namespace || ""); + const payloadSession = normalizeSessionToken(r.payload?.sessionId); + const scored = scoreSemanticCandidate({ + rawScore: r.score, + agentId: sourceAgent, + namespace: ns, + sessionMode, + preferredSessionId, + payloadSessionId: payloadSession, + promotionState: r.payload?.promotion_state, + }); + return { + ...r, + _rawScore: r.score, + _sessionBoost: scored.sessionBoost, + score: scored.finalScore, + } as ScoredPoint & { _rawScore: number }; + }) + .sort((a: any, b: any) => b.score - a.score); + + // Filter by minScore on weighted score + const filtered = weighted.filter( + (r: ScoredPoint) => r.score >= minScore, + ); + + if (filtered.length === 0) { + const textOut = "No relevant memories found."; + return { + content: [{ type: "text", text: textOut }], + details: createDetails(textOut, { count: 0, query }), + }; + } + + // Format results + const formatted = filtered + .map((r: ScoredPoint, i: number) => { + const payload = r.payload; + const date = payload.timestamp + ? new Date(payload.timestamp).toLocaleDateString() + : "Unknown"; + + const lines = [ + `[${i + 1}] Score: ${(r.score * 100).toFixed(1)}%`, + `Namespace: ${payload.namespace || "unknown"}`, + `Text: ${payload.text}`, + `Date: ${date}`, + ]; + + if (payload.metadata && Object.keys(payload.metadata).length > 0) { + lines.push(`Metadata: ${JSON.stringify(payload.metadata)}`); + } + + return lines.join("\n"); + }) + .join("\n\n---\n\n"); + + const textOut = `Found ${filtered.length} relevant memories for "${query}":\n\n${formatted}`; + return { + content: [ + { + type: "text", + text: textOut, + }, + ], + details: createDetails(textOut, { + count: filtered.length, + query, + results: filtered, + }), + }; + } catch (error: any) { + const textOut = `Error searching memories: ${error.message}`; + return { + content: [{ type: "text", text: textOut }], + isError: true, + details: createDetails(textOut, { error: error.message }), + }; + } + }, + }; } diff --git a/src/tools/memory_store.ts b/src/tools/memory_store.ts index 6841e2e..53b95e3 100644 --- a/src/tools/memory_store.ts +++ b/src/tools/memory_store.ts @@ -1,229 +1,285 @@ -import { QdrantClient } from "../services/qdrant.js"; -import { EmbeddingClient } from "../services/embedding.js"; -import { DeduplicationService } from "../services/dedupe.js"; -import { StoreParams, ToolResult, Point, MemoryNamespace } from "../types.js"; -import { normalizeNamespace, parseExplicitNamespace, toCoreAgent, evaluateNoiseV2 } from "../shared/memory-config.js"; - -function resolveAgentFromRuntimeParams(params: { agentId?: string; sessionId?: string; namespace?: unknown }): string { - const directAgentId = typeof params.agentId === "string" ? params.agentId.trim() : ""; - if (directAgentId) return toCoreAgent(directAgentId); - - const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : ""; - if (sessionId) { - const parts = sessionId.split(":"); - if (parts.length >= 2 && parts[0] === "agent") { - const fromSession = parts[1]?.trim(); - if (fromSession) return toCoreAgent(fromSession); - } - } - - const namespace = typeof params.namespace === "string" ? params.namespace.trim() : ""; - const nsMatch = /^agent\.([a-z0-9][a-z0-9_-]*)\.(working_memory|lessons|decisions)$/i.exec(namespace); - if (nsMatch?.[1]) { - return toCoreAgent(nsMatch[1]); - } - - return "assistant"; +import type { DeduplicationService } from "../services/dedupe.js"; +import type { EmbeddingClient } from "../services/embedding.js"; +import type { QdrantClient } from "../services/qdrant.js"; +import { + evaluateNoiseV2, + normalizeNamespace, + parseExplicitNamespace, + resolveDefaultConfidence, + resolveMemoryScopeFromNamespace, + resolveMemoryTypeFromNamespace, + toCoreAgent, +} from "../shared/memory-config.js"; +import type { + MemoryNamespace, + Point, + StoreParams, + ToolResult, +} from "../types.js"; + +function resolveAgentFromRuntimeParams(params: { + agentId?: string; + sessionId?: string; + namespace?: unknown; +}): string { + const directAgentId = + typeof params.agentId === "string" ? params.agentId.trim() : ""; + if (directAgentId) return toCoreAgent(directAgentId); + + const sessionId = + typeof params.sessionId === "string" ? params.sessionId.trim() : ""; + if (sessionId) { + const parts = sessionId.split(":"); + if (parts.length >= 2 && parts[0] === "agent") { + const fromSession = parts[1]?.trim(); + if (fromSession) return toCoreAgent(fromSession); + } + } + + const namespace = + typeof params.namespace === "string" ? params.namespace.trim() : ""; + const nsMatch = + /^agent\.([a-z0-9][a-z0-9_-]*)\.(working_memory|lessons|decisions)$/i.exec( + namespace, + ); + if (nsMatch?.[1]) { + return toCoreAgent(nsMatch[1]); + } + + return "assistant"; } export const memoryStoreSchema = { - type: "object", - properties: { - text: { - type: "string", - description: "The content to remember", - }, - namespace: { - type: "string", - description: "Namespace for organization (default: 'shared.project_context')", - }, - sessionId: { - type: "string", - description: "Optional session ID for context isolation", - }, - userId: { - type: "string", - description: "Optional user ID for multi-user systems", - }, - metadata: { - type: "object", - description: "Additional metadata to store", - }, - }, - required: ["text"], + type: "object", + properties: { + text: { + type: "string", + description: "The content to remember", + }, + namespace: { + type: "string", + description: + "Namespace for organization (default: 'shared.project_context')", + }, + sessionId: { + type: "string", + description: "Optional session ID for context isolation", + }, + userId: { + type: "string", + description: "Optional user ID for multi-user systems", + }, + metadata: { + type: "object", + description: "Additional metadata to store", + }, + }, + required: ["text"], }; export function createMemoryStoreTool( - qdrant: QdrantClient, - embedding: EmbeddingClient, - dedupe: DeduplicationService, - defaultNamespace: MemoryNamespace + qdrant: QdrantClient, + embedding: EmbeddingClient, + dedupe: DeduplicationService, + defaultNamespace: MemoryNamespace, ) { - const createDetails = (text: string, extra: Record = {}) => ({ - ...extra, - toolResult: { text }, - }); - - return { - name: "memory_store", - label: "Memory Store", - description: "Store a memory in the vector database. Automatically deduplicates similar content.", - parameters: memoryStoreSchema, - - async execute( - _id: string, - params: StoreParams & { agentId?: string }, - _signal?: AbortSignal - ): Promise { - try { - // Validate - if (!params.text || typeof params.text !== "string") { - return { - content: [{ type: "text", text: "Error: text is required" }], - isError: true, - details: createDetails("Error: text is required", { error: "Missing text parameter" }), - }; - } - - const text = params.text.trim(); - if (text.length === 0) { - return { - content: [{ type: "text", text: "Error: text cannot be empty" }], - isError: true, - details: createDetails("Error: text cannot be empty", { error: "Empty text" }), - }; - } - - if (text.length > 10000) { - return { - content: [{ type: "text", text: "Error: text exceeds 10000 character limit" }], - isError: true, - details: createDetails("Error: text exceeds 10000 character limit", { error: "Text too long", length: text.length }), - }; - } - - // Namespace router + normalization policy (ASM-5) - const sourceAgent = resolveAgentFromRuntimeParams(params); - const requestedNamespace = - typeof params.namespace === "string" && params.namespace.trim().length > 0 - ? params.namespace - : `agent.${sourceAgent}.working_memory`; - let namespace = typeof params.namespace === "string" && params.namespace.trim().length > 0 - ? parseExplicitNamespace(requestedNamespace, sourceAgent) - : normalizeNamespace(requestedNamespace, sourceAgent); - - // Noise policy v2: quarantine noisy content into noise.filtered - const noise = evaluateNoiseV2(text, "tool_call"); - if (noise.isNoise) { - namespace = "noise.filtered" as MemoryNamespace; - } - - // Generate embedding (chunking + weighted average) - const embeddingResult = typeof (embedding as any).embedDetailed === "function" - ? await (embedding as any).embedDetailed(text) - : { - vector: await embedding.embed(text), - metadata: { - embedding_chunked: false, - embedding_chunks_count: 1, - embedding_chunking_strategy: "array_batch_weighted_avg", - embedding_model: "unknown", - embedding_model_key: "unknown", - embedding_provider: "auto", - embedding_max_tokens: 0, - embedding_safe_chunk_tokens: 0, - embedding_source: "docs", - embedding_fallback_hash: false, - }, - }; - const vector = embeddingResult.vector; - - // Check for duplicates - const candidates = await qdrant.search(vector, 5, { - must: [ - { key: "namespace", match: { value: namespace } }, - ], - }); - - const duplicateId = dedupe.findDuplicate(text, candidates); - - if (duplicateId) { - // Update existing memory - const point: Point = { - id: duplicateId, - vector, - payload: { - text, - namespace, - agent: sourceAgent, - source_agent: sourceAgent, - source_type: "tool_call" as const, - sessionId: params.sessionId || null, - userId: params.userId || null, - metadata: { - ...(params.metadata || {}), - ...embeddingResult.metadata, - noise_score: noise.score, - noise_matched_patterns: noise.matchedPatterns, - }, - ...embeddingResult.metadata, - timestamp: Date.now(), - noise_score: noise.score, - updatedAt: Date.now(), - }, - }; - - await qdrant.upsert([point]); - - const textOut = `Memory updated (duplicate detected, ID: ${duplicateId})`; - return { - content: [{ type: "text", text: textOut }], - details: createDetails(textOut, { id: duplicateId, updated: true }), - }; - } - - // Create new memory with UUID v4 - const id = crypto.randomUUID(); - - const point: Point = { - id, - vector, - payload: { - text, - namespace, - agent: sourceAgent, - source_agent: sourceAgent, - source_type: "tool_call" as const, - sessionId: params.sessionId || null, - userId: params.userId || null, - metadata: { - ...(params.metadata || {}), - ...embeddingResult.metadata, - noise_score: noise.score, - noise_matched_patterns: noise.matchedPatterns, - }, - ...embeddingResult.metadata, - timestamp: Date.now(), - noise_score: noise.score, - }, - }; - - await qdrant.upsert([point]); - - const textOut = `Memory stored successfully (ID: ${id})`; - return { - content: [{ type: "text", text: textOut }], - details: createDetails(textOut, { id, created: true }), - }; - - } catch (error: any) { - const textOut = `Error storing memory: ${error.message}`; - return { - content: [{ type: "text", text: textOut }], - isError: true, - details: createDetails(textOut, { error: error.message }), - }; - } - }, - }; + const createDetails = ( + text: string, + extra: Record = {}, + ) => ({ + ...extra, + toolResult: { text }, + }); + + return { + name: "memory_store", + label: "Memory Store", + description: + "Store a memory in the vector database. Automatically deduplicates similar content.", + parameters: memoryStoreSchema, + + async execute( + _id: string, + params: StoreParams & { agentId?: string }, + _signal?: AbortSignal, + ): Promise { + try { + // Validate + if (!params.text || typeof params.text !== "string") { + return { + content: [{ type: "text", text: "Error: text is required" }], + isError: true, + details: createDetails("Error: text is required", { + error: "Missing text parameter", + }), + }; + } + + const text = params.text.trim(); + if (text.length === 0) { + return { + content: [{ type: "text", text: "Error: text cannot be empty" }], + isError: true, + details: createDetails("Error: text cannot be empty", { + error: "Empty text", + }), + }; + } + + if (text.length > 10000) { + return { + content: [ + { + type: "text", + text: "Error: text exceeds 10000 character limit", + }, + ], + isError: true, + details: createDetails( + "Error: text exceeds 10000 character limit", + { + error: "Text too long", + length: text.length, + }, + ), + }; + } + + // Namespace router + normalization policy (ASM-5) + const sourceAgent = resolveAgentFromRuntimeParams(params); + const requestedNamespace = + typeof params.namespace === "string" && + params.namespace.trim().length > 0 + ? params.namespace + : `agent.${sourceAgent}.working_memory`; + let namespace = + typeof params.namespace === "string" && + params.namespace.trim().length > 0 + ? parseExplicitNamespace(requestedNamespace, sourceAgent) + : normalizeNamespace(requestedNamespace, sourceAgent); + + // Noise policy v2: quarantine noisy content into noise.filtered + const noise = evaluateNoiseV2(text, "tool_call"); + if (noise.isNoise) { + namespace = "noise.filtered" as MemoryNamespace; + } + const memoryScope = resolveMemoryScopeFromNamespace(namespace); + const memoryType = resolveMemoryTypeFromNamespace(namespace); + const promotionState = "raw" as const; + const defaultConfidence = resolveDefaultConfidence("tool_call"); + + // Generate embedding (chunking + weighted average) + const embeddingResult = + typeof (embedding as any).embedDetailed === "function" + ? await (embedding as any).embedDetailed(text) + : { + vector: await embedding.embed(text), + metadata: { + embedding_chunked: false, + embedding_chunks_count: 1, + embedding_chunking_strategy: "array_batch_weighted_avg", + embedding_model: "unknown", + embedding_model_key: "unknown", + embedding_provider: "auto", + embedding_max_tokens: 0, + embedding_safe_chunk_tokens: 0, + embedding_source: "docs", + embedding_fallback_hash: false, + }, + }; + const vector = embeddingResult.vector; + + // Check for duplicates + const candidates = await qdrant.search(vector, 5, { + must: [{ key: "namespace", match: { value: namespace } }], + }); + + const duplicateId = dedupe.findDuplicate(text, candidates); + + if (duplicateId) { + // Update existing memory + const point: Point = { + id: duplicateId, + vector, + payload: { + text, + namespace, + agent: sourceAgent, + source_agent: sourceAgent, + source_type: "tool_call" as const, + memory_scope: memoryScope, + memory_type: memoryType, + promotion_state: promotionState, + confidence: defaultConfidence, + sessionId: params.sessionId || null, + userId: params.userId || null, + metadata: { + ...(params.metadata || {}), + ...embeddingResult.metadata, + noise_score: noise.score, + noise_matched_patterns: noise.matchedPatterns, + }, + ...embeddingResult.metadata, + timestamp: Date.now(), + noise_score: noise.score, + updatedAt: Date.now(), + }, + }; + + await qdrant.upsert([point]); + + const textOut = `Memory updated (duplicate detected, ID: ${duplicateId})`; + return { + content: [{ type: "text", text: textOut }], + details: createDetails(textOut, { id: duplicateId, updated: true }), + }; + } + + // Create new memory with UUID v4 + const id = crypto.randomUUID(); + + const point: Point = { + id, + vector, + payload: { + text, + namespace, + agent: sourceAgent, + source_agent: sourceAgent, + source_type: "tool_call" as const, + memory_scope: memoryScope, + memory_type: memoryType, + promotion_state: promotionState, + confidence: defaultConfidence, + sessionId: params.sessionId || null, + userId: params.userId || null, + metadata: { + ...(params.metadata || {}), + ...embeddingResult.metadata, + noise_score: noise.score, + noise_matched_patterns: noise.matchedPatterns, + }, + ...embeddingResult.metadata, + timestamp: Date.now(), + noise_score: noise.score, + }, + }; + + await qdrant.upsert([point]); + + const textOut = `Memory stored successfully (ID: ${id})`; + return { + content: [{ type: "text", text: textOut }], + details: createDetails(textOut, { id, created: true }), + }; + } catch (error: any) { + const textOut = `Error storing memory: ${error.message}`; + return { + content: [{ type: "text", text: textOut }], + isError: true, + details: createDetails(textOut, { error: error.message }), + }; + } + }, + }; } diff --git a/src/types.ts b/src/types.ts index 2e8eae9..9e835e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,101 +1,120 @@ // Import MemoryNamespace from shared config -import type { MemoryNamespace as SharedMemoryNamespace } from "./shared/memory-config.js"; +import type { + MemoryScope, + MemorySourceType, + MemoryType, + PromotionState, + MemoryNamespace as SharedMemoryNamespace, +} from "./shared/memory-config.js"; // Re-export for external use -export type { MemoryNamespace as MemoryNamespace } from "./shared/memory-config.js"; +export type { MemoryNamespace } from "./shared/memory-config.js"; // Qdrant Types export interface Point { - id: string; - vector: number[]; - payload: Record; + id: string; + vector: number[]; + payload: Record; } export interface ScoredPoint { - id: string; - version?: number; - score: number; - payload: Record; - vector?: number[]; + id: string; + version?: number; + score: number; + payload: Record; + vector?: number[]; } export interface SearchResponse { - result: ScoredPoint[]; - status?: string; - time?: number; + result: ScoredPoint[]; + status?: string; + time?: number; } // Memory Types export interface MemoryEntry { - id: string; - text: string; - namespace: SharedMemoryNamespace; - sessionId?: string; - userId?: string; - source_agent?: string; - source_type?: "auto_capture" | "manual" | "tool_call"; - metadata: Record; - timestamp: number; - updatedAt?: number; + id: string; + text: string; + namespace: SharedMemoryNamespace; + sessionId?: string; + userId?: string; + source_agent?: string; + source_type?: MemorySourceType; + memory_scope?: MemoryScope; + memory_type?: MemoryType; + promotion_state?: PromotionState; + confidence?: number; + metadata: Record; + timestamp: number; + updatedAt?: number; } /** Memory payload structure for Qdrant */ export interface MemoryPayload { - text: string; - namespace: SharedMemoryNamespace; - source_agent: string; - source_type: "auto_capture" | "manual" | "tool_call"; - userId: string; - sessionId?: string; - timestamp: number; - updatedAt?: number; - confidence?: number; - tags?: string[]; - metadata?: Record; + text: string; + namespace: SharedMemoryNamespace; + source_agent: string; + source_type: MemorySourceType; + userId: string; + sessionId?: string; + memory_scope?: MemoryScope; + memory_type?: MemoryType; + promotion_state?: PromotionState; + timestamp: number; + updatedAt?: number; + confidence?: number; + tags?: string[]; + metadata?: Record; } export interface MemorySearchResult { - entry: MemoryEntry; - score: number; + entry: MemoryEntry; + score: number; } // Plugin Config export interface MemoryConfig { - qdrantUrl: string; - collectionName: string; - vectorSize: number; - embeddingApiUrl: string; - timeout: number; - maxRetries: number; - retryDelay: number; - defaultNamespace: SharedMemoryNamespace; - similarityThreshold: number; + qdrantUrl: string; + collectionName: string; + vectorSize: number; + embeddingApiUrl: string; + timeout: number; + maxRetries: number; + retryDelay: number; + defaultNamespace: SharedMemoryNamespace; + similarityThreshold: number; } // Tool Parameters export interface StoreParams { - text: string; - namespace?: SharedMemoryNamespace; - sessionId?: string; - userId?: string; - metadata?: Record; + text: string; + namespace?: SharedMemoryNamespace; + sessionId?: string; + userId?: string; + metadata?: Record; } export interface SearchParams { - query: string; - limit?: number; - namespace?: SharedMemoryNamespace; - sessionId?: string; - userId?: string; - minScore?: number; + query: string; + limit?: number; + namespace?: SharedMemoryNamespace; + sessionId?: string; + sessionMode?: "strict" | "soft"; + userId?: string; + minScore?: number; } // Tool Output - Match AgentToolResult structure export interface ToolResult { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; - details: unknown; + content: Array<{ type: "text"; text: string }>; + isError?: boolean; + details: unknown; } // Essence-Distiller Types (V2) -export type { EssenceDocument, ExtractionResult, DistillationConfig, DistillationPipeline } from "./types/essence-distiller.js"; +export type { + DistillationConfig, + DistillationPipeline, + EssenceDocument, + ExtractionResult, +} from "./types/essence-distiller.js"; diff --git a/tests/test-asm-cli.ts b/tests/test-asm-cli.ts index d8baaf6..11f5be2 100644 --- a/tests/test-asm-cli.ts +++ b/tests/test-asm-cli.ts @@ -99,6 +99,21 @@ test("parseAsmCliArgs supports help, setup-openclaw, install , and ini { command: "project-event", argv: ["--project-id", "p1"] }, "project-event should parse", ); + assertEqual( + parseAsmCliArgs(["migrate-asm115", "plan", "--preflight-limit", "10"]), + { command: "migrate-asm115", argv: ["plan", "--preflight-limit", "10"] }, + "migrate-asm115 command should parse", + ); + assertEqual( + parseAsmCliArgs(["migrate", "asm115", "verify"]), + { command: "migrate-asm115", argv: ["verify"] }, + "migrate asm115 alias should parse", + ); + assertEqual( + parseAsmCliArgs(["check-asm115", "--preflight-limit", "5"]), + { command: "check-asm115", argv: ["--preflight-limit", "5"] }, + "check-asm115 should parse", + ); }); test("createShellRunner normalizes process output", () => { diff --git a/tests/test-asm115-migration-core.ts b/tests/test-asm115-migration-core.ts new file mode 100644 index 0000000..5ed2470 --- /dev/null +++ b/tests/test-asm115-migration-core.ts @@ -0,0 +1,116 @@ +import { + ASM115_SCHEMA_VERSION, + buildSemanticPayloadPatch, + isAsm115Noop, + planSemanticPayloadMigration, +} from "../src/core/migrations/asm115-migration-core.js"; + +function assert(condition: unknown, message: string): void { + if (!condition) throw new Error(message); +} + +function assertEqual( + actual: unknown, + expected: unknown, + message: string, +): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) { + throw new Error(`${message}\nactual=${a}\nexpected=${e}`); + } +} + +function test(name: string, fn: () => void): void { + try { + fn(); + console.log(`✅ ${name}`); + } catch (error) { + console.error(`❌ ${name}`); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +test("buildSemanticPayloadPatch adds missing ASM-115 fields", () => { + const patch = buildSemanticPayloadPatch({ + id: "p1", + payload: { + namespace: "agent.assistant.working_memory", + source_type: "manual", + }, + }); + + assertEqual( + patch.payload.schema_version, + ASM115_SCHEMA_VERSION, + "must set schema version", + ); + assertEqual(patch.payload.memory_scope, "agent", "must infer memory_scope"); + assertEqual( + patch.payload.memory_type, + "episodic_trace", + "must infer memory_type", + ); + assertEqual( + patch.payload.promotion_state, + "raw", + "must default promotion_state", + ); + assert( + typeof patch.payload.confidence === "number", + "must default confidence", + ); + assert(patch.changedFields.length >= 5, "must report changed fields"); +}); + +test("planSemanticPayloadMigration reports changed patches only", () => { + const result = planSemanticPayloadMigration([ + { + id: "legacy", + payload: { + namespace: "agent.assistant.working_memory", + }, + }, + { + id: "already", + payload: { + namespace: "agent.assistant.working_memory", + schema_version: ASM115_SCHEMA_VERSION, + memory_scope: "agent", + memory_type: "episodic_trace", + promotion_state: "raw", + confidence: 0.7, + }, + }, + ]); + + assertEqual(result.total, 2, "total mismatch"); + assertEqual(result.changed, 1, "changed mismatch"); + assertEqual(result.patches.length, 1, "patch list mismatch"); + assertEqual(result.patches[0]?.id, "legacy", "should only patch legacy row"); +}); + +test("isAsm115Noop only true when migration already applied and no pending semantic updates", () => { + assert( + isAsm115Noop({ + pendingSemanticChanges: 0, + migrationStatus: "migrated", + migrationSchemaTo: ASM115_SCHEMA_VERSION, + }), + "should be noop when already migrated and no pending changes", + ); + + assert( + !isAsm115Noop({ + pendingSemanticChanges: 1, + migrationStatus: "migrated", + migrationSchemaTo: ASM115_SCHEMA_VERSION, + }), + "pending changes must disable noop", + ); +}); + +if (!process.exitCode) { + console.log("\n🎉 ASM-115 migration core tests passed"); +} diff --git a/tests/test-asm121-parity-gate.ts b/tests/test-asm121-parity-gate.ts new file mode 100644 index 0000000..e570077 --- /dev/null +++ b/tests/test-asm121-parity-gate.ts @@ -0,0 +1,335 @@ +import { SemanticMemoryUseCase } from "../src/core/usecases/semantic-memory-usecase.js"; +import { + injectRecallContext, + selectSemanticMemories, +} from "../src/hooks/auto-recall.js"; +import { DeduplicationService } from "../src/services/dedupe.js"; +import { createMemorySearchTool } from "../src/tools/memory_search.js"; + +function assert(condition: unknown, message: string): void { + if (!condition) throw new Error(message); +} + +function assertEqual( + actual: unknown, + expected: unknown, + message: string, +): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) throw new Error(`${message}\nactual=${a}\nexpected=${e}`); +} + +function test(name: string, fn: () => Promise | void): Promise { + return Promise.resolve() + .then(fn) + .then(() => console.log(`✅ ${name}`)) + .catch((err) => { + console.error(`❌ ${name}`); + console.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + }); +} + +class MockEmbedding { + async embed(text: string): Promise { + const seed = Array.from(text).reduce((acc, c) => acc + c.charCodeAt(0), 0); + return [seed % 101, seed % 97, seed % 89, seed % 83].map((n) => + Number((n / 100).toFixed(3)), + ); + } + + async embedDetailed( + text: string, + ): Promise<{ vector: number[]; metadata: Record }> { + return { + vector: await this.embed(text), + metadata: { + embedding_chunked: false, + embedding_chunks_count: 1, + embedding_chunking_strategy: "array_batch_weighted_avg", + embedding_model: "mock", + embedding_provider: "mock", + }, + }; + } +} + +class MockQdrant { + public points: any[] = []; + + async upsert(points: any[]): Promise { + for (const p of points) { + const idx = this.points.findIndex((x) => x.id === p.id); + if (idx >= 0) this.points[idx] = p; + else this.points.push(p); + } + } + + async search(_vector: number[], limit = 5, filter?: any): Promise { + const filtered = this.points.filter((p) => { + const must = filter?.must || []; + return must.every((m: any) => { + if (Array.isArray(m.should)) { + return m.should.some((s: any) => this.matchLeaf(p.payload, s)); + } + return this.matchLeaf(p.payload, m); + }); + }); + + return filtered.slice(0, limit).map((p) => ({ + id: p.id, + score: + typeof p.payload?.mockScore === "number" ? p.payload.mockScore : 0.9, + payload: p.payload, + })); + } + + private matchLeaf(payload: any, cond: any): boolean { + const key = cond?.key; + const val = cond?.match?.value; + if (!key) return true; + return payload?.[key] === val; + } +} + +async function main() { + const qdrant = new MockQdrant(); + const embedding = new MockEmbedding(); + const dedupe = new DeduplicationService(0.95, console); + const memorySearch = createMemorySearchTool( + qdrant as any, + embedding as any, + "shared.project_context", + ); + const usecase = new SemanticMemoryUseCase( + qdrant as any, + embedding as any, + dedupe, + ); + + await qdrant.upsert([ + { + id: "p-same-session", + vector: [0.1, 0.2, 0.3, 0.4], + payload: { + text: "Parity same session memory", + namespace: "agent.assistant.working_memory", + sessionId: "strict-session", + source_agent: "assistant", + userId: "u1", + mockScore: 0.84, + timestamp: Date.now(), + }, + }, + { + id: "p-cross-session", + vector: [0.2, 0.2, 0.3, 0.5], + payload: { + text: "Parity cross session memory", + namespace: "agent.assistant.working_memory", + sessionId: "other-session", + source_agent: "assistant", + userId: "u1", + mockScore: 0.86, + timestamp: Date.now(), + }, + }, + ]); + + await test("strict mode parity: tool + usecase both hard-filter session", async () => { + const toolRes = await memorySearch.execute("p1", { + query: "parity strict", + namespace: "assistant" as any, + agentId: "assistant", + userId: "u1", + sessionMode: "strict", + sessionId: "strict-session", + minScore: 0.1, + }); + assert(toolRes.isError !== true, "tool strict search must succeed"); + const toolIds = ((toolRes as any).details?.results || []).map((r: any) => + String(r.id), + ); + + const usecaseRes = await usecase.search( + { + query: "parity strict", + namespace: "assistant", + userId: "u1", + sessionMode: "strict", + sessionId: "strict-session", + minScore: 0.1, + }, + { userId: "u1", agentId: "assistant", sessionId: "strict-session" }, + ); + const usecaseIds = usecaseRes.results.map((r) => String(r.id)); + + assertEqual( + toolIds, + ["p-same-session"], + "tool strict must return only same session", + ); + assertEqual( + usecaseIds, + ["p-same-session"], + "usecase strict must return only same session", + ); + }); + + await test("soft mode parity: tool + usecase return identical ordering/signature", async () => { + const toolRes = await memorySearch.execute("p2", { + query: "parity soft", + namespace: "assistant" as any, + agentId: "assistant", + userId: "u1", + sessionMode: "soft", + sessionId: "strict-session", + minScore: 0.1, + }); + assert(toolRes.isError !== true, "tool soft search must succeed"); + const toolResults = (toolRes as any).details?.results || []; + + const usecaseRes = await usecase.search( + { + query: "parity soft", + namespace: "assistant", + userId: "u1", + sessionMode: "soft", + sessionId: "strict-session", + minScore: 0.1, + }, + { userId: "u1", agentId: "assistant", sessionId: "strict-session" }, + ); + + const toolIds = toolResults.map((r: any) => String(r.id)); + const usecaseIds = usecaseRes.results.map((r) => String(r.id)); + assertEqual( + toolIds, + usecaseIds, + "tool/usecase ordering must stay parity in soft mode", + ); + + const toolScores = toolResults.map((r: any) => Number(r.score.toFixed(6))); + const usecaseScores = usecaseRes.results.map((r) => + Number(r.score.toFixed(6)), + ); + assertEqual( + toolScores, + usecaseScores, + "tool/usecase scoring must stay parity in soft mode", + ); + }); + + await test("drift detection: auto-recall keeps same-session anchor preference", () => { + const selection = selectSemanticMemories( + [ + { + score: 0.84, + payload: qdrant.points.find((p) => p.id === "p-same-session") + ?.payload, + }, + { + score: 0.86, + payload: qdrant.points.find((p) => p.id === "p-cross-session") + ?.payload, + }, + ], + { + sessionKey: "agent:assistant:strict-session", + stateDir: "/tmp", + userId: "u1", + agentId: "assistant", + }, + { + sessionKeys: new Set([ + "agent:assistant:strict-session", + "strict-session", + ]), + topicTags: new Set(["parity", "assistant"]), + }, + ); + + assert( + selection.memories.length > 0, + "auto-recall should keep at least one memory", + ); + assert( + String(selection.memories[0]?.text || "").includes("same session"), + "auto-recall top memory should preserve same-session anchor preference", + ); + }); + + await test("precedence check (foundation): injected current-state appears before semantic memories", () => { + const injected = injectRecallContext("base", { + currentState: + "slot-truth", + projectLivingState: "", + graphContext: "", + recentUpdates: "", + semanticMemories: + 'supporting-evidence', + recallMeta: { + recall_confidence: "high", + recall_suppressed: false, + }, + }); + + const slotPos = injected.indexOf(""); + const semanticPos = injected.indexOf(""); + assert( + slotPos >= 0 && semanticPos >= 0, + "injected prompt must include both slot and semantic blocks", + ); + assert( + slotPos < semanticPos, + "slot/current-state block should appear before semantic memories block", + ); + assert( + injected.includes('') && + injected.includes(''), + "injected prompt should expose explicit precedence wrappers for slot truth and semantic evidence", + ); + }); + + await test("precedence check (asm-117): semantic evidence appears before graph routing support", () => { + const injected = injectRecallContext("base", { + currentState: + "slot-truth", + projectLivingState: "", + graphContext: + '', + recentUpdates: "", + semanticMemories: + 'history-evidence', + recallMeta: { + recall_confidence: "high", + recall_suppressed: false, + }, + }); + + const semanticPos = injected.indexOf( + '', + ); + const graphPos = injected.indexOf( + '', + ); + assert( + semanticPos >= 0 && graphPos >= 0, + "injected prompt must include semantic and graph precedence wrappers", + ); + assert( + semanticPos < graphPos, + "semantic evidence must be injected before graph routing support", + ); + }); + + if (!process.exitCode) { + console.log("\n🎉 ASM-121 parity gate tests passed"); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/tests/test-memory-tools-namespace-roundtrip.ts b/tests/test-memory-tools-namespace-roundtrip.ts index 418edda..b3c96c7 100644 --- a/tests/test-memory-tools-namespace-roundtrip.ts +++ b/tests/test-memory-tools-namespace-roundtrip.ts @@ -1,229 +1,399 @@ -import { createMemoryStoreTool } from "../src/tools/memory_store.js"; -import { createMemorySearchTool } from "../src/tools/memory_search.js"; import { DeduplicationService } from "../src/services/dedupe.js"; +import { createMemorySearchTool } from "../src/tools/memory_search.js"; +import { createMemoryStoreTool } from "../src/tools/memory_store.js"; import type { Point, ScoredPoint } from "../src/types.js"; function assert(condition: unknown, message: string): void { - if (!condition) throw new Error(message); + if (!condition) throw new Error(message); } -function assertEqual(actual: unknown, expected: unknown, message: string): void { - const a = JSON.stringify(actual); - const e = JSON.stringify(expected); - if (a !== e) throw new Error(`${message}\nactual=${a}\nexpected=${e}`); +function assertEqual( + actual: unknown, + expected: unknown, + message: string, +): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) throw new Error(`${message}\nactual=${a}\nexpected=${e}`); } function test(name: string, fn: () => Promise | void): Promise { - return Promise.resolve() - .then(fn) - .then(() => console.log(`✅ ${name}`)) - .catch((err) => { - console.error(`❌ ${name}`); - console.error(err instanceof Error ? err.message : String(err)); - process.exitCode = 1; - }); + return Promise.resolve() + .then(fn) + .then(() => console.log(`✅ ${name}`)) + .catch((err) => { + console.error(`❌ ${name}`); + console.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + }); } class MockEmbedding { - async embed(text: string): Promise { - const seed = Array.from(text).reduce((acc, c) => acc + c.charCodeAt(0), 0); - return [seed % 101, seed % 97, seed % 89, seed % 83].map((n) => Number((n / 100).toFixed(3))); - } - - async embedDetailed(text: string): Promise<{ vector: number[]; metadata: Record }> { - return { - vector: await this.embed(text), - metadata: { - embedding_chunked: false, - embedding_chunks_count: 1, - embedding_chunking_strategy: "array_batch_weighted_avg", - embedding_model: "mock", - embedding_model_key: "mock::v1", - embedding_provider: "mock", - embedding_max_tokens: 0, - embedding_safe_chunk_tokens: 0, - embedding_source: "tests", - embedding_fallback_hash: false, - }, - }; - } + async embed(text: string): Promise { + const seed = Array.from(text).reduce((acc, c) => acc + c.charCodeAt(0), 0); + return [seed % 101, seed % 97, seed % 89, seed % 83].map((n) => + Number((n / 100).toFixed(3)), + ); + } + + async embedDetailed( + text: string, + ): Promise<{ vector: number[]; metadata: Record }> { + return { + vector: await this.embed(text), + metadata: { + embedding_chunked: false, + embedding_chunks_count: 1, + embedding_chunking_strategy: "array_batch_weighted_avg", + embedding_model: "mock", + embedding_model_key: "mock::v1", + embedding_provider: "mock", + embedding_max_tokens: 0, + embedding_safe_chunk_tokens: 0, + embedding_source: "tests", + embedding_fallback_hash: false, + }, + }; + } } class MockQdrant { - public points: Point[] = []; - public lastSearchFilter: Record | undefined; - - async upsert(points: Point[]): Promise { - for (const point of points) { - const idx = this.points.findIndex((p) => p.id === point.id); - if (idx >= 0) this.points[idx] = point; - else this.points.push(point); - } - } - - async search(_vector: number[], limit = 5, filter?: Record): Promise { - this.lastSearchFilter = filter; - const matched = this.points.filter((p) => this.matchesFilter(p.payload, filter)); - return matched.slice(0, limit).map((p) => ({ - id: p.id, - score: 0.95, - payload: p.payload, - })); - } - - private matchesFilter(payload: Record, filter?: Record): boolean { - if (!filter?.must || !Array.isArray(filter.must)) return true; - - return filter.must.every((condition: any) => { - if (condition.should && Array.isArray(condition.should)) { - return condition.should.some((c: any) => this.matchLeaf(payload, c)); - } - return this.matchLeaf(payload, condition); - }); - } - - private matchLeaf(payload: Record, condition: any): boolean { - const key = condition?.key; - const value = condition?.match?.value; - if (!key) return true; - return payload?.[key] === value; - } + public points: Point[] = []; + public lastSearchFilter: Record | undefined; + + async upsert(points: Point[]): Promise { + for (const point of points) { + const idx = this.points.findIndex((p) => p.id === point.id); + if (idx >= 0) this.points[idx] = point; + else this.points.push(point); + } + } + + async search( + _vector: number[], + limit = 5, + filter?: Record, + ): Promise { + this.lastSearchFilter = filter; + const matched = this.points.filter((p) => + this.matchesFilter(p.payload, filter), + ); + return matched.slice(0, limit).map((p) => ({ + id: p.id, + score: 0.95, + payload: p.payload, + })); + } + + private matchesFilter( + payload: Record, + filter?: Record, + ): boolean { + if (!filter?.must || !Array.isArray(filter.must)) return true; + + return filter.must.every((condition: any) => { + if (condition.should && Array.isArray(condition.should)) { + return condition.should.some((c: any) => this.matchLeaf(payload, c)); + } + return this.matchLeaf(payload, condition); + }); + } + + private matchLeaf(payload: Record, condition: any): boolean { + const key = condition?.key; + const value = condition?.match?.value; + if (!key) return true; + return payload?.[key] === value; + } } async function main() { - const qdrant = new MockQdrant(); - const embedding = new MockEmbedding(); - const dedupe = new DeduplicationService(0.95, console); - - const memoryStore = createMemoryStoreTool(qdrant as any, embedding as any, dedupe, "shared.project_context"); - const memorySearch = createMemorySearchTool(qdrant as any, embedding as any, "shared.project_context"); - - await test("store normalizes alias namespace 'assistant' -> canonical", async () => { - const res = await memoryStore.execute("t1", { - text: "ASM namespace alias assistant roundtrip", - namespace: "assistant" as any, - agentId: "assistant", - }); - - assert(res.isError !== true, "memory_store should succeed"); - assert(qdrant.points.length >= 1, "point must be stored"); - const saved = qdrant.points[qdrant.points.length - 1]; - assertEqual(saved.payload.namespace, "agent.assistant.working_memory", "alias assistant must map to canonical namespace"); - - const details = (res as any).details; - assert(details?.toolResult?.text, "memory_store details.toolResult.text must exist"); - }); - - await test("search from scrum session honors explicit assistant alias instead of fallback agent", async () => { - const res = await memorySearch.execute("t1b", { - query: "namespace alias assistant from scrum", - namespace: "assistant" as any, - agentId: "scrum", - minScore: 0.1, - }); - - assert(res.isError !== true, "memory_search should succeed from scrum context"); - - const must = (qdrant.lastSearchFilter as any)?.must || []; - const nsCondition = must.find((m: any) => m?.key === "namespace"); - assertEqual( - nsCondition?.match?.value, - "agent.assistant.working_memory", - "explicit assistant alias must stay assistant even when fallback agent is scrum" - ); - }); - - await test("search normalizes alias namespace 'assistant' -> canonical filter", async () => { - const res = await memorySearch.execute("t2", { - query: "namespace alias assistant", - namespace: "assistant" as any, - agentId: "assistant", - minScore: 0.1, - }); - - assert(res.isError !== true, "memory_search should succeed"); - - const must = (qdrant.lastSearchFilter as any)?.must || []; - const nsCondition = must.find((m: any) => m?.key === "namespace"); - assertEqual(nsCondition?.match?.value, "agent.assistant.working_memory", "search namespace filter must be canonical"); - - const text = res.content?.[0]?.text || ""; - assert(String(text).includes("Found"), "search should return found message"); - - const details = (res as any).details; - assert(details?.toolResult?.text, "memory_search details.toolResult.text must exist"); - }); - - await test("canonical namespace query still works", async () => { - const res = await memorySearch.execute("t3", { - query: "assistant roundtrip", - namespace: "agent.assistant.working_memory", - agentId: "assistant", - minScore: 0.1, - }); - - assert(res.isError !== true, "canonical namespace search should succeed"); - assert(String(res.content?.[0]?.text || "").includes("Found"), "canonical search should find memory"); - }); - - await test("legacy/shared namespace project_context maps to shared.project_context (store->search roundtrip)", async () => { - const text = "ASM legacy namespace project context roundtrip"; - - const storeRes = await memoryStore.execute("t4", { - text, - namespace: "project_context" as any, - agentId: "assistant", - }); - assert(storeRes.isError !== true, "legacy namespace store should succeed"); - - const saved = qdrant.points[qdrant.points.length - 1]; - assertEqual(saved.payload.namespace, "shared.project_context", "project_context must normalize to shared.project_context"); - - const searchRes = await memorySearch.execute("t5", { - query: "legacy namespace project context", - namespace: "project_context" as any, - agentId: "assistant", - minScore: 0.1, - }); - - assert(searchRes.isError !== true, "legacy namespace search should succeed"); - const must = (qdrant.lastSearchFilter as any)?.must || []; - const nsCondition = must.find((m: any) => m?.key === "namespace"); - assertEqual(nsCondition?.match?.value, "shared.project_context", "legacy namespace search filter must map to shared.project_context"); - assert(String(searchRes.content?.[0]?.text || "").includes("Found"), "legacy namespace roundtrip should find memory"); - }); - - await test("unknown explicit namespace returns clear validation error instead of silent fallback", async () => { - const searchRes = await memorySearch.execute("t6", { - query: "unknown namespace", - namespace: "totally_unknown_namespace" as any, - agentId: "assistant", - minScore: 0.1, - }); - assert(searchRes.isError === true, "unknown explicit namespace search must fail clearly"); - assert( - String(searchRes.content?.[0]?.text || "").includes("Unknown namespace"), - "search error should mention unknown namespace" - ); - - const storeRes = await memoryStore.execute("t7", { - text: "should not store", - namespace: "totally_unknown_namespace" as any, - agentId: "assistant", - }); - assert(storeRes.isError === true, "unknown explicit namespace store must fail clearly"); - assert( - String(storeRes.content?.[0]?.text || "").includes("Unknown namespace"), - "store error should mention unknown namespace" - ); - }); - - if (!process.exitCode) { - console.log("\n🎉 memory tools namespace roundtrip tests passed"); - } + const qdrant = new MockQdrant(); + const embedding = new MockEmbedding(); + const dedupe = new DeduplicationService(0.95, console); + + const memoryStore = createMemoryStoreTool( + qdrant as any, + embedding as any, + dedupe, + "shared.project_context", + ); + const memorySearch = createMemorySearchTool( + qdrant as any, + embedding as any, + "shared.project_context", + ); + + await test("store normalizes alias namespace 'assistant' -> canonical", async () => { + const res = await memoryStore.execute("t1", { + text: "ASM namespace alias assistant roundtrip", + namespace: "assistant" as any, + agentId: "assistant", + }); + + assert(res.isError !== true, "memory_store should succeed"); + assert(qdrant.points.length >= 1, "point must be stored"); + const saved = qdrant.points[qdrant.points.length - 1]; + assertEqual( + saved.payload.namespace, + "agent.assistant.working_memory", + "alias assistant must map to canonical namespace", + ); + assertEqual( + saved.payload.memory_scope, + "agent", + "working_memory should map to memory_scope=agent", + ); + assertEqual( + saved.payload.memory_type, + "episodic_trace", + "working_memory should map to memory_type=episodic_trace", + ); + assertEqual( + saved.payload.promotion_state, + "raw", + "new captures should default promotion_state=raw", + ); + assert( + typeof saved.payload.confidence === "number", + "confidence should be present in payload", + ); + + const details = (res as any).details; + assert( + details?.toolResult?.text, + "memory_store details.toolResult.text must exist", + ); + }); + + await test("search from scrum session honors explicit assistant alias instead of fallback agent", async () => { + const res = await memorySearch.execute("t1b", { + query: "namespace alias assistant from scrum", + namespace: "assistant" as any, + agentId: "scrum", + minScore: 0.1, + }); + + assert( + res.isError !== true, + "memory_search should succeed from scrum context", + ); + + const must = (qdrant.lastSearchFilter as any)?.must || []; + const nsCondition = must.find((m: any) => m?.key === "namespace"); + assertEqual( + nsCondition?.match?.value, + "agent.assistant.working_memory", + "explicit assistant alias must stay assistant even when fallback agent is scrum", + ); + }); + + await test("search normalizes alias namespace 'assistant' -> canonical filter", async () => { + const res = await memorySearch.execute("t2", { + query: "namespace alias assistant", + namespace: "assistant" as any, + agentId: "assistant", + minScore: 0.1, + }); + + assert(res.isError !== true, "memory_search should succeed"); + + const must = (qdrant.lastSearchFilter as any)?.must || []; + const nsCondition = must.find((m: any) => m?.key === "namespace"); + assertEqual( + nsCondition?.match?.value, + "agent.assistant.working_memory", + "search namespace filter must be canonical", + ); + + const text = res.content?.[0]?.text || ""; + assert( + String(text).includes("Found"), + "search should return found message", + ); + + const details = (res as any).details; + assert( + details?.toolResult?.text, + "memory_search details.toolResult.text must exist", + ); + }); + + await test("canonical namespace query still works", async () => { + const res = await memorySearch.execute("t3", { + query: "assistant roundtrip", + namespace: "agent.assistant.working_memory", + agentId: "assistant", + minScore: 0.1, + }); + + assert(res.isError !== true, "canonical namespace search should succeed"); + assert( + String(res.content?.[0]?.text || "").includes("Found"), + "canonical search should find memory", + ); + }); + + await test("legacy/shared namespace project_context maps to shared.project_context (store->search roundtrip)", async () => { + const text = "ASM legacy namespace project context roundtrip"; + + const storeRes = await memoryStore.execute("t4", { + text, + namespace: "project_context" as any, + agentId: "assistant", + }); + assert(storeRes.isError !== true, "legacy namespace store should succeed"); + + const saved = qdrant.points[qdrant.points.length - 1]; + assertEqual( + saved.payload.namespace, + "shared.project_context", + "project_context must normalize to shared.project_context", + ); + assertEqual( + saved.payload.memory_scope, + "project", + "shared.project_context should map memory_scope=project", + ); + assertEqual( + saved.payload.memory_type, + "task_context", + "shared.project_context should map memory_type=task_context", + ); + + const searchRes = await memorySearch.execute("t5", { + query: "legacy namespace project context", + namespace: "project_context" as any, + agentId: "assistant", + minScore: 0.1, + }); + + assert( + searchRes.isError !== true, + "legacy namespace search should succeed", + ); + const must = (qdrant.lastSearchFilter as any)?.must || []; + const nsCondition = must.find((m: any) => m?.key === "namespace"); + assertEqual( + nsCondition?.match?.value, + "shared.project_context", + "legacy namespace search filter must map to shared.project_context", + ); + assert( + String(searchRes.content?.[0]?.text || "").includes("Found"), + "legacy namespace roundtrip should find memory", + ); + }); + + await test("unknown explicit namespace returns clear validation error instead of silent fallback", async () => { + const searchRes = await memorySearch.execute("t6", { + query: "unknown namespace", + namespace: "totally_unknown_namespace" as any, + agentId: "assistant", + minScore: 0.1, + }); + assert( + searchRes.isError === true, + "unknown explicit namespace search must fail clearly", + ); + assert( + String(searchRes.content?.[0]?.text || "").includes("Unknown namespace"), + "search error should mention unknown namespace", + ); + + const storeRes = await memoryStore.execute("t7", { + text: "should not store", + namespace: "totally_unknown_namespace" as any, + agentId: "assistant", + }); + assert( + storeRes.isError === true, + "unknown explicit namespace store must fail clearly", + ); + assert( + String(storeRes.content?.[0]?.text || "").includes("Unknown namespace"), + "store error should mention unknown namespace", + ); + }); + + await test("backward compatibility: search still returns legacy payload without v2 metadata fields", async () => { + qdrant.points.push({ + id: "legacy-no-v2-fields", + vector: [0.1, 0.2, 0.3, 0.4], + payload: { + text: "legacy payload record", + namespace: "agent.assistant.working_memory", + source_agent: "assistant", + source_type: "manual", + timestamp: Date.now(), + }, + }); + + const res = await memorySearch.execute("t8", { + query: "legacy payload", + namespace: "assistant" as any, + agentId: "assistant", + minScore: 0.1, + }); + + assert( + res.isError !== true, + "legacy payload without v2 metadata should still be searchable", + ); + assert( + String(res.content?.[0]?.text || "").includes("Found"), + "search should still return found message for legacy payload", + ); + }); + + await test("sessionMode=soft does not inject hard session filter (default soft semantics)", async () => { + const res = await memorySearch.execute("t9", { + query: "soft session mode", + namespace: "assistant" as any, + agentId: "assistant", + sessionId: "session-soft-1", + sessionMode: "soft" as any, + minScore: 0.1, + }); + + assert( + res.isError !== true, + "memory_search soft session mode should succeed", + ); + const must = (qdrant.lastSearchFilter as any)?.must || []; + const sessionCondition = must.find((m: any) => m?.key === "sessionId"); + assert(!sessionCondition, "soft mode must not add sessionId hard filter"); + }); + + await test("sessionMode=strict injects hard session filter", async () => { + const res = await memorySearch.execute("t10", { + query: "strict session mode", + namespace: "assistant" as any, + agentId: "assistant", + sessionId: "session-strict-1", + sessionMode: "strict" as any, + minScore: 0.1, + }); + + assert( + res.isError !== true, + "memory_search strict session mode should succeed", + ); + const must = (qdrant.lastSearchFilter as any)?.must || []; + const sessionCondition = must.find((m: any) => m?.key === "sessionId"); + assertEqual( + sessionCondition?.match?.value, + "session-strict-1", + "strict mode must add sessionId hard filter", + ); + }); + + if (!process.exitCode) { + console.log("\n🎉 memory tools namespace roundtrip tests passed"); + } } main().catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); }); diff --git a/tests/test-promotion-lifecycle.ts b/tests/test-promotion-lifecycle.ts new file mode 100644 index 0000000..1bf500c --- /dev/null +++ b/tests/test-promotion-lifecycle.ts @@ -0,0 +1,84 @@ +import { + resolveInitialPromotionState, + resolvePromotionMetadata, + transitionPromotionState, +} from "../src/core/promotion/promotion-lifecycle.js"; + +function assert(condition: unknown, message: string): void { + if (!condition) throw new Error(message); +} + +function assertEqual( + actual: unknown, + expected: unknown, + message: string, +): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) throw new Error(`${message}\nactual=${a}\nexpected=${e}`); +} + +function test(name: string, fn: () => void): void { + try { + fn(); + console.log(`✅ ${name}`); + } catch (error) { + console.error(`❌ ${name}`); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +test("raw -> distilled -> promoted transition", () => { + const distilled = transitionPromotionState("raw", "distill"); + const promoted = transitionPromotionState(distilled, "promote"); + assertEqual(distilled, "distilled", "raw should transition to distilled"); + assertEqual(promoted, "promoted", "distilled should transition to promoted"); +}); + +test("deprecated state is terminal", () => { + const next = transitionPromotionState("deprecated", "promote"); + assertEqual(next, "deprecated", "deprecated must remain deprecated"); +}); + +test("auto-capture lessons/runbooks start at distilled to avoid raw spam", () => { + assertEqual( + resolveInitialPromotionState({ + namespace: "agent.assistant.lessons", + sourceType: "auto_capture", + }), + "distilled", + "lessons from auto-capture should default distilled", + ); + + assertEqual( + resolveInitialPromotionState({ + namespace: "shared.runbooks", + sourceType: "auto_capture", + }), + "distilled", + "runbooks from auto-capture should default distilled", + ); +}); + +test("promotion metadata derives memory_type and defaults", () => { + const meta = resolvePromotionMetadata({ + namespace: "agent.assistant.lessons", + sourceType: "auto_capture", + }); + assertEqual( + meta.memoryType, + "lesson", + "lessons namespace should infer memory_type=lesson", + ); + assertEqual( + meta.promotionState, + "distilled", + "auto-capture lessons should be distilled", + ); + assert(typeof meta.confidence === "number", "confidence must be computed"); +}); + +if (!process.exitCode) { + console.log("\n🎉 promotion lifecycle tests passed"); +} diff --git a/tests/test-recall-precedence.ts b/tests/test-recall-precedence.ts new file mode 100644 index 0000000..bfa4eb2 --- /dev/null +++ b/tests/test-recall-precedence.ts @@ -0,0 +1,74 @@ +import { buildRecallInjectionParts } from "../src/core/precedence/recall-precedence.js"; + +function assert(condition: unknown, message: string): void { + if (!condition) throw new Error(message); +} + +function test(name: string, fn: () => void): void { + try { + fn(); + console.log(`✅ ${name}`); + } catch (error) { + console.error(`❌ ${name}`); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +test("precedence order: slotdb truth > semantic evidence > graph routing support", () => { + const parts = buildRecallInjectionParts({ + currentState: "1", + projectLivingState: "2", + recentUpdates: "3", + semanticMemories: + "evidence", + graphContext: "route", + recallMeta: { + recall_confidence: "high", + recall_suppressed: false, + }, + }); + + assert(parts.length >= 4, "expected all precedence parts to be present"); + assert( + parts[0].startsWith(''), + "slotdb truth should be injected first", + ); + assert( + parts[1].startsWith(''), + "semantic evidence should be injected second", + ); + assert( + parts[2].startsWith(''), + "graph routing support should be injected third", + ); +}); + +test("slotdb wrapper contains current + living + recent blocks", () => { + const parts = buildRecallInjectionParts({ + currentState: "truth", + projectLivingState: + "f", + recentUpdates: '', + semanticMemories: "", + graphContext: "", + }); + + const slotPart = parts[0] || ""; + assert( + slotPart.includes(""), + "slot wrapper must include current-state", + ); + assert( + slotPart.includes(""), + "slot wrapper must include project-living-state", + ); + assert( + slotPart.includes(""), + "slot wrapper must include recent-updates", + ); +}); + +if (!process.exitCode) { + console.log("\n🎉 recall precedence tests passed"); +} diff --git a/tests/test-retrieval-policy.ts b/tests/test-retrieval-policy.ts new file mode 100644 index 0000000..316e956 --- /dev/null +++ b/tests/test-retrieval-policy.ts @@ -0,0 +1,132 @@ +import { + normalizeSessionToken, + resolveSessionMode, + scoreSemanticCandidate, + shouldApplyStrictSessionFilter, +} from "../src/core/retrieval-policy.js"; + +function assert(condition: unknown, message: string): void { + if (!condition) throw new Error(message); +} + +function assertEqual( + actual: unknown, + expected: unknown, + message: string, +): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) throw new Error(`${message}\nactual=${a}\nexpected=${e}`); +} + +function test(name: string, fn: () => void): void { + try { + fn(); + console.log(`✅ ${name}`); + } catch (error) { + console.error(`❌ ${name}`); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +test("normalizeSessionToken lowercases and trims", () => { + assertEqual( + normalizeSessionToken(" AbC-123 "), + "abc-123", + "session token normalization mismatch", + ); +}); + +test("resolveSessionMode defaults to soft", () => { + assertEqual( + resolveSessionMode(undefined), + "soft", + "default session mode should be soft", + ); + assertEqual( + resolveSessionMode("strict"), + "strict", + "strict mode should be preserved", + ); +}); + +test("strict filter gate follows strict mode with non-empty session", () => { + assert( + shouldApplyStrictSessionFilter("strict", "s1"), + "strict mode with session must apply filter", + ); + assert( + !shouldApplyStrictSessionFilter("soft", "s1"), + "soft mode must not apply strict filter", + ); + assert( + !shouldApplyStrictSessionFilter("strict", ""), + "empty session should not apply strict filter", + ); +}); + +test("soft mode same-session gets boost", () => { + const scored = scoreSemanticCandidate({ + rawScore: 0.8, + agentId: "assistant", + namespace: "agent.assistant.working_memory", + sessionMode: "soft", + preferredSessionId: "session-1", + payloadSessionId: "session-1", + }); + assert( + scored.sessionBoost > 0, + "same-session in soft mode must receive boost", + ); + assert( + scored.finalScore > scored.weightedBase, + "final score should include session boost", + ); +}); + +test("strict mode does not add session boost", () => { + const scored = scoreSemanticCandidate({ + rawScore: 0.8, + agentId: "assistant", + namespace: "agent.assistant.working_memory", + sessionMode: "strict", + preferredSessionId: "session-1", + payloadSessionId: "session-1", + }); + assertEqual(scored.sessionBoost, 0, "strict mode should not use soft boost"); +}); + +test("promoted memories should rank above raw when base score is equal", () => { + const baseInput = { + rawScore: 0.8, + agentId: "assistant", + namespace: "agent.assistant.working_memory", + sessionMode: "soft" as const, + preferredSessionId: "session-1", + payloadSessionId: "other-session", + }; + + const raw = scoreSemanticCandidate({ + ...baseInput, + promotionState: "raw", + }); + const distilled = scoreSemanticCandidate({ + ...baseInput, + promotionState: "distilled", + }); + const promoted = scoreSemanticCandidate({ + ...baseInput, + promotionState: "promoted", + }); + + assert(distilled.finalScore > raw.finalScore, "distilled should outrank raw"); + assert( + promoted.finalScore > distilled.finalScore, + "promoted should outrank distilled", + ); +}); + +if (!process.exitCode) { + console.log("\n🎉 retrieval policy tests passed"); +} diff --git a/tests/test-semantic-memory-usecase.ts b/tests/test-semantic-memory-usecase.ts index 6a73e2f..3f62f04 100644 --- a/tests/test-semantic-memory-usecase.ts +++ b/tests/test-semantic-memory-usecase.ts @@ -2,110 +2,217 @@ import { SemanticMemoryUseCase } from "../src/core/usecases/semantic-memory-usec import { DeduplicationService } from "../src/services/dedupe.js"; function assert(condition: unknown, message: string): void { - if (!condition) throw new Error(message); + if (!condition) throw new Error(message); } class MockEmbedding { - async embed(text: string): Promise { - const seed = Array.from(text).reduce((a, c) => a + c.charCodeAt(0), 0); - return [seed % 97, seed % 89, seed % 83].map((n) => Number((n / 100).toFixed(3))); - } - - async embedDetailed(text: string): Promise<{ vector: number[]; metadata: Record }> { - return { - vector: await this.embed(text), - metadata: { - embedding_model: "mock", - embedding_provider: "mock", - }, - }; - } + async embed(text: string): Promise { + const seed = Array.from(text).reduce((a, c) => a + c.charCodeAt(0), 0); + return [seed % 97, seed % 89, seed % 83].map((n) => + Number((n / 100).toFixed(3)), + ); + } + + async embedDetailed( + text: string, + ): Promise<{ vector: number[]; metadata: Record }> { + return { + vector: await this.embed(text), + metadata: { + embedding_model: "mock", + embedding_provider: "mock", + }, + }; + } } class MockQdrant { - public points: any[] = []; - - async upsert(points: any[]): Promise { - for (const p of points) { - const idx = this.points.findIndex((x) => x.id === p.id); - if (idx >= 0) this.points[idx] = p; - else this.points.push(p); - } - } - - async search(_vector: number[], limit = 5, filter?: any): Promise { - const filtered = this.points.filter((p) => { - const must = filter?.must || []; - return must.every((m: any) => { - if (Array.isArray(m.should)) { - return m.should.some((s: any) => this.matchLeaf(p.payload, s)); - } - return this.matchLeaf(p.payload, m); - }); - }); - - return filtered.slice(0, limit).map((p) => ({ - id: p.id, - score: 0.95, - payload: p.payload, - })); - } - - private matchLeaf(payload: any, cond: any): boolean { - const key = cond?.key; - const val = cond?.match?.value; - if (!key) return true; - return payload?.[key] === val; - } + public points: any[] = []; + + async upsert(points: any[]): Promise { + for (const p of points) { + const idx = this.points.findIndex((x) => x.id === p.id); + if (idx >= 0) this.points[idx] = p; + else this.points.push(p); + } + } + + async search(_vector: number[], limit = 5, filter?: any): Promise { + const filtered = this.points.filter((p) => { + const must = filter?.must || []; + return must.every((m: any) => { + if (Array.isArray(m.should)) { + return m.should.some((s: any) => this.matchLeaf(p.payload, s)); + } + return this.matchLeaf(p.payload, m); + }); + }); + + return filtered.slice(0, limit).map((p) => ({ + id: p.id, + score: 0.95, + payload: p.payload, + })); + } + + private matchLeaf(payload: any, cond: any): boolean { + const key = cond?.key; + const val = cond?.match?.value; + if (!key) return true; + return payload?.[key] === val; + } } async function run() { - console.log("\n🧪 Semantic Memory UseCase Tests\n"); - - const qdrant = new MockQdrant(); - const embedding = new MockEmbedding(); - const dedupe = new DeduplicationService(0.95, console); - const usecase = new SemanticMemoryUseCase(qdrant as any, embedding as any, dedupe); - - const context = { userId: "u1", agentId: "assistant", sessionId: "s1" }; - - const captureRes = await usecase.capture( - { - text: "ASM-43 semantic usecase path is wired", - namespace: "assistant", - metadata: { source: "test" }, - }, - context, - ); - - assert(captureRes.created === true, "capture should create point"); - assert(captureRes.namespace === "agent.assistant.working_memory", "alias namespace must be canonicalized"); - - const searchRes = await usecase.search( - { - query: "semantic usecase path", - namespace: "assistant", - minScore: 0.1, - }, - context, - ); - - assert(searchRes.count >= 1, "search should return result"); - assert(searchRes.results[0].namespace === "agent.assistant.working_memory", "search namespace should match canonical"); - - const duplicate = await usecase.capture( - { - text: "ASM-43 semantic usecase path is wired", - namespace: "assistant", - }, - context, - ); - assert(duplicate.updated === true, "duplicate capture should update existing point"); - - console.log("✅ semantic memory usecase tests passed\n"); + console.log("\n🧪 Semantic Memory UseCase Tests\n"); + + const qdrant = new MockQdrant(); + const embedding = new MockEmbedding(); + const dedupe = new DeduplicationService(0.95, console); + const usecase = new SemanticMemoryUseCase( + qdrant as any, + embedding as any, + dedupe, + ); + + const context = { userId: "u1", agentId: "assistant", sessionId: "s1" }; + + const captureRes = await usecase.capture( + { + text: "ASM-43 semantic usecase path is wired", + namespace: "assistant", + metadata: { source: "test" }, + }, + context, + ); + + assert(captureRes.created === true, "capture should create point"); + assert( + captureRes.namespace === "agent.assistant.working_memory", + "alias namespace must be canonicalized", + ); + + const firstPayload = qdrant.points.find( + (p) => p.id === captureRes.id, + )?.payload; + assert( + firstPayload?.memory_scope === "agent", + "capture payload should include memory_scope=agent", + ); + assert( + firstPayload?.memory_type === "episodic_trace", + "capture payload should include memory_type=episodic_trace", + ); + assert( + firstPayload?.promotion_state === "raw", + "capture payload should include promotion_state=raw", + ); + assert( + typeof firstPayload?.confidence === "number", + "capture payload should include confidence", + ); + + const searchRes = await usecase.search( + { + query: "semantic usecase path", + namespace: "assistant", + minScore: 0.1, + }, + context, + ); + + assert(searchRes.count >= 1, "search should return result"); + assert( + searchRes.results[0].namespace === "agent.assistant.working_memory", + "search namespace should match canonical", + ); + + const duplicate = await usecase.capture( + { + text: "ASM-43 semantic usecase path is wired", + namespace: "assistant", + }, + context, + ); + assert( + duplicate.updated === true, + "duplicate capture should update existing point", + ); + + await usecase.capture( + { + text: "ASM-43 strict session only", + namespace: "assistant", + sessionId: "strict-session", + }, + { ...context, sessionId: "strict-session" }, + ); + await usecase.capture( + { + text: "ASM-43 cross session note", + namespace: "assistant", + sessionId: "other-session", + }, + { ...context, sessionId: "other-session" }, + ); + + const strictSearch = await usecase.search( + { + query: "session", + namespace: "assistant", + minScore: 0.1, + sessionMode: "strict", + sessionId: "strict-session", + }, + context, + ); + assert( + strictSearch.results.every( + (r) => + String(r.text).includes("strict session") || + String(r.text).includes("semantic usecase"), + ), + "strict mode should constrain to the requested session results", + ); + + const softSearch = await usecase.search( + { + query: "session", + namespace: "assistant", + minScore: 0.1, + sessionMode: "soft", + sessionId: "strict-session", + }, + context, + ); + assert( + softSearch.results.some((r) => String(r.text).includes("cross session")), + "soft mode should still allow cross-session results", + ); + + await usecase.capture( + { + text: "ASM-43 project scoped capture", + namespace: "project_context", + }, + context, + ); + const projectPayload = qdrant.points.find( + (p) => p.payload?.text === "ASM-43 project scoped capture", + )?.payload; + assert( + projectPayload?.memory_scope === "project", + "project_context should map to memory_scope=project", + ); + assert( + projectPayload?.memory_type === "task_context", + "project_context should map to memory_type=task_context", + ); + + console.log("✅ semantic memory usecase tests passed\n"); } run().catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); }); From d62507ee0e7631cad393a82043dc0edf298b4109 Mon Sep 17 00:00:00 2001 From: mrc Date: Fri, 20 Mar 2026 12:09:55 +0700 Subject: [PATCH 24/24] refactor(memory-foundation): rename ASM-115 migration surfaces to domain-based names --- bin/asm.mjs | 31 +-- package.json | 2 +- ...igrate.ts => migrate-memory-foundation.ts} | 10 +- ...core.ts => memory-foundation-migration.ts} | 12 +- ... => memory-foundation-migration-runner.ts} | 187 ++++++++++++++---- src/services/qdrant.ts | 19 ++ tests/test-asm-cli.ts | 23 ++- ... test-memory-foundation-migration-core.ts} | 20 +- 8 files changed, 224 insertions(+), 80 deletions(-) rename scripts/{asm115-migrate.ts => migrate-memory-foundation.ts} (78%) rename src/core/migrations/{asm115-migration-core.ts => memory-foundation-migration.ts} (87%) rename src/scripts/{asm115-migration-runner.ts => memory-foundation-migration-runner.ts} (55%) rename tests/{test-asm115-migration-core.ts => test-memory-foundation-migration-core.ts} (82%) diff --git a/bin/asm.mjs b/bin/asm.mjs index 5f751db..9e4ade7 100755 --- a/bin/asm.mjs +++ b/bin/asm.mjs @@ -78,16 +78,20 @@ export function parseAsmCliArgs(argv = []) { return { command: "project-event", argv: args.slice(1) }; } - if (first === "check-asm115") { - return { command: "check-asm115", argv: args.slice(1) }; + if (first === "check-memory-foundation") { + return { command: "check-memory-foundation", argv: args.slice(1) }; } - if (first === "migrate-asm115") { - return { command: "migrate-asm115", argv: args.slice(1) }; + if (first === "migrate-memory-foundation") { + return { command: "migrate-memory-foundation", argv: args.slice(1) }; } - if (first === "migrate" && (args[1] || "") === "asm115") { - return { command: "migrate-asm115", argv: args.slice(2) }; + if (first === "memory" && (args[1] || "") === "migrate") { + return { command: "migrate-memory-foundation", argv: args.slice(2) }; + } + + if (first === "memory" && (args[1] || "") === "check") { + return { command: "check-memory-foundation", argv: args.slice(2) }; } if (first === "mcp" && (args[1] || "") === "opencode") { @@ -112,9 +116,10 @@ export function printHelp(log = console.log) { log(" asm init-openclaw [--non-interactive]"); log(" asm init openclaw [--non-interactive]"); log(" asm project-event --project-id --repo-root [--event-type post_commit|post_merge|post_rewrite|manual] [--source-rev ] [--changed-files a,b] [--deleted-files x,y] [--trusted-sync 0|1] [--full-snapshot 0|1]"); - log(" asm migrate-asm115 [--user-id ] [--agent-id ] [--snapshot-dir ] [--rollback-snapshot ] [--preflight-limit ]"); - log(" asm migrate asm115 [flags...]"); - log(" asm check-asm115 [--user-id ] [--agent-id ] [--preflight-limit ] # alias: verify status/version"); + log(" asm migrate-memory-foundation [--user-id ] [--agent-id ] [--snapshot-dir ] [--rollback-snapshot ] [--preflight-limit ]"); + log(" asm memory migrate [flags...]"); + log(" asm check-memory-foundation [--user-id ] [--agent-id ] [--preflight-limit ] # alias: verify status/version"); + log(" asm memory check [--user-id ] [--agent-id ] [--preflight-limit ]"); log(" asm help"); log(""); log("Roadmap commands (not implemented yet):"); @@ -456,11 +461,11 @@ export async function main(argv = process.argv.slice(2)) { } } - if (parsed.command === "migrate-asm115") { + if (parsed.command === "migrate-memory-foundation") { try { const proc = spawnSync( "npx", - ["tsx", "scripts/asm115-migrate.ts", ...(parsed.argv || [])], + ["tsx", "scripts/migrate-memory-foundation.ts", ...(parsed.argv || [])], { stdio: "inherit", cwd: process.cwd(), @@ -474,11 +479,11 @@ export async function main(argv = process.argv.slice(2)) { } } - if (parsed.command === "check-asm115") { + if (parsed.command === "check-memory-foundation") { try { const proc = spawnSync( "npx", - ["tsx", "scripts/asm115-migrate.ts", "verify", ...(parsed.argv || [])], + ["tsx", "scripts/migrate-memory-foundation.ts", "verify", ...(parsed.argv || [])], { stdio: "inherit", cwd: process.cwd(), diff --git a/package.json b/package.json index 06cce15..bc2b207 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "smoke:paperclip:local": "npm run build:paperclip && node scripts/paperclip-local-smoke-debug.mjs", "clean": "rm -rf dist dist-openclaw dist-paperclip dist-core artifacts/npm", "migrate:namespaces": "npx tsx scripts/migrate-namespaces.ts", - "migrate:asm115": "npx tsx scripts/asm115-migrate.ts", + "migrate:memory-foundation": "npx tsx scripts/migrate-memory-foundation.ts", "distill:namespaces": "npx tsx scripts/distill-by-namespace.ts", "validate:ab": "npx tsx scripts/validate-ab.ts", "init-openclaw": "node scripts/init-openclaw.mjs", diff --git a/scripts/asm115-migrate.ts b/scripts/migrate-memory-foundation.ts similarity index 78% rename from scripts/asm115-migrate.ts rename to scripts/migrate-memory-foundation.ts index 781818d..1634791 100644 --- a/scripts/asm115-migrate.ts +++ b/scripts/migrate-memory-foundation.ts @@ -1,11 +1,11 @@ import { - type Asm115Mode, - runAsm115Migration, -} from "../src/scripts/asm115-migration-runner.js"; + type MemoryFoundationMigrationMode, + runMemoryFoundationMigration, +} from "../src/scripts/memory-foundation-migration-runner.js"; function parseArgs(argv: string[]) { const args = Array.isArray(argv) ? argv.map((x) => String(x)) : []; - const mode = (args[0] || "preflight") as Asm115Mode; + const mode = (args[0] || "preflight") as MemoryFoundationMigrationMode; const get = (flag: string): string | undefined => { const idx = args.indexOf(flag); if (idx < 0) return undefined; @@ -33,7 +33,7 @@ async function main() { } try { - const result = await runAsm115Migration(parsed); + const result = await runMemoryFoundationMigration(parsed); console.log(JSON.stringify(result, null, 2)); } catch (error) { console.error( diff --git a/src/core/migrations/asm115-migration-core.ts b/src/core/migrations/memory-foundation-migration.ts similarity index 87% rename from src/core/migrations/asm115-migration-core.ts rename to src/core/migrations/memory-foundation-migration.ts index 43d82cb..0143ac0 100644 --- a/src/core/migrations/asm115-migration-core.ts +++ b/src/core/migrations/memory-foundation-migration.ts @@ -6,8 +6,8 @@ import { resolveMemoryTypeFromNamespace, } from "../../shared/memory-config.js"; -export const ASM115_SCHEMA_VERSION = "asm115.v1"; -export const ASM115_MIGRATION_ID = "asm115-memory-foundation"; +export const MEMORY_FOUNDATION_SCHEMA_VERSION = "memory-foundation.v1"; +export const MEMORY_FOUNDATION_MIGRATION_ID = "memory-foundation-v1"; export interface SemanticPointPayload { namespace?: string; @@ -62,8 +62,8 @@ export function buildSemanticPayloadPatch( ).trim(); const sourceType = asSourceType(payload.source_type); - if (String(payload.schema_version || "") !== ASM115_SCHEMA_VERSION) { - payload.schema_version = ASM115_SCHEMA_VERSION; + if (String(payload.schema_version || "") !== MEMORY_FOUNDATION_SCHEMA_VERSION) { + payload.schema_version = MEMORY_FOUNDATION_SCHEMA_VERSION; changedFields.push("schema_version"); } @@ -115,7 +115,7 @@ export function planSemanticPayloadMigration(points: SemanticPointRecord[]): { }; } -export function isAsm115Noop(input: { +export function isMemoryFoundationMigrationNoop(input: { pendingSemanticChanges: number; migrationStatus?: string; migrationSchemaTo?: string; @@ -123,6 +123,6 @@ export function isAsm115Noop(input: { return ( input.pendingSemanticChanges === 0 && input.migrationStatus === "migrated" && - input.migrationSchemaTo === ASM115_SCHEMA_VERSION + input.migrationSchemaTo === MEMORY_FOUNDATION_SCHEMA_VERSION ); } diff --git a/src/scripts/asm115-migration-runner.ts b/src/scripts/memory-foundation-migration-runner.ts similarity index 55% rename from src/scripts/asm115-migration-runner.ts rename to src/scripts/memory-foundation-migration-runner.ts index 1943cf2..bc17b1c 100644 --- a/src/scripts/asm115-migration-runner.ts +++ b/src/scripts/memory-foundation-migration-runner.ts @@ -1,23 +1,23 @@ -import { cpSync, existsSync, mkdirSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { - ASM115_MIGRATION_ID, - ASM115_SCHEMA_VERSION, - isAsm115Noop, + MEMORY_FOUNDATION_MIGRATION_ID, + MEMORY_FOUNDATION_SCHEMA_VERSION, + isMemoryFoundationMigrationNoop, planSemanticPayloadMigration, type SemanticPointRecord, -} from "../core/migrations/asm115-migration-core.js"; +} from "../core/migrations/memory-foundation-migration.js"; import { GraphDB } from "../db/graph-db.js"; import { SlotDB } from "../db/slot-db.js"; import { QdrantClient } from "../services/qdrant.js"; import { resolveAsmRuntimeConfig } from "../shared/asm-config.js"; import { resolveSlotDbDir } from "../shared/slotdb-path.js"; -export type Asm115Mode = "preflight" | "plan" | "apply" | "verify" | "rollback"; +export type MemoryFoundationMigrationMode = "preflight" | "plan" | "apply" | "verify" | "rollback"; -export interface RunAsm115MigrationInput { - mode: Asm115Mode; +export interface RunMemoryFoundationMigrationInput { + mode: MemoryFoundationMigrationMode; env?: NodeJS.ProcessEnv; homeDir?: string; userId?: string; @@ -33,12 +33,26 @@ interface PlaneStatus { details: Record; } -interface Asm115Plan { +interface MemoryFoundationMigrationPlan { slotdb: PlaneStatus; graph: PlaneStatus; semantic: PlaneStatus; } +interface SemanticSnapshotEntry { + id: string | number | Record; + payload: Record; + keys: string[]; +} + +interface SemanticSnapshotFile { + migration_id: string; + collection: string; + schema_target: string; + created_at: string; + entries: SemanticSnapshotEntry[]; +} + function nowIso(): string { return new Date().toISOString(); } @@ -125,6 +139,73 @@ function ensureSnapshotDir(dir: string): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } +function createSemanticSnapshot( + entries: SemanticPointRecord[], + collection: string, + snapshotDir: string, +): string { + ensureSnapshotDir(snapshotDir); + const target = join(snapshotDir, `semantic-payload.${Date.now()}.json`); + const snapshot: SemanticSnapshotFile = { + migration_id: MEMORY_FOUNDATION_MIGRATION_ID, + collection, + schema_target: MEMORY_FOUNDATION_SCHEMA_VERSION, + created_at: nowIso(), + entries: entries.map((entry) => ({ + id: entry.id, + payload: JSON.parse(JSON.stringify(entry.payload || {})), + keys: Object.keys(entry.payload || {}), + })), + }; + writeFileSync(target, JSON.stringify(snapshot, null, 2)); + return target; +} + +async function restoreSemanticSnapshot( + qdrant: QdrantClient, + snapshotPath: string, +): Promise { + if (!existsSync(snapshotPath)) { + throw new Error(`semantic rollback snapshot not found: ${snapshotPath}`); + } + const snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")) as SemanticSnapshotFile; + if (!Array.isArray(snapshot.entries)) { + throw new Error("semantic rollback snapshot invalid: entries missing"); + } + + const managedKeys = Array.from( + new Set( + snapshot.entries.flatMap((entry) => + Array.isArray(entry.keys) ? entry.keys : Object.keys(entry.payload || {}), + ), + ), + ); + + if (managedKeys.length > 0) { + await qdrant.deletePayloadKeys( + snapshot.entries.map((entry) => entry.id), + managedKeys, + ); + } + + const entriesWithCurrentPayload = await collectSemanticPoints(qdrant, 500); + const currentById = new Map( + entriesWithCurrentPayload.map((entry) => [JSON.stringify(entry.id), entry]), + ); + + const restoreEntries = snapshot.entries.map((entry) => { + const key = JSON.stringify(entry.id); + const current = currentById.get(key); + const restoredPayload = { ...(current?.payload || {}), ...(entry.payload || {}) }; + return { + id: entry.id, + payload: restoredPayload, + }; + }); + + await qdrant.setPayload(restoreEntries); +} + function createSlotDbSnapshot(slotDbDir: string, snapshotDir: string): string { ensureSnapshotDir(snapshotDir); const source = join(slotDbDir, "slots.db"); @@ -143,8 +224,8 @@ function restoreSlotDbSnapshot(snapshotPath: string, slotDbDir: string): void { cpSync(snapshotPath, target, { force: true }); } -export async function runAsm115Migration( - input: RunAsm115MigrationInput, +export async function runMemoryFoundationMigration( + input: RunMemoryFoundationMigrationInput, ): Promise> { const env = input.env || process.env; const runtime = resolveAsmRuntimeConfig({ @@ -178,10 +259,26 @@ export async function runAsm115Migration( const existingMigration = slotDb.getMigrationState( userId, agentId, - ASM115_MIGRATION_ID, + MEMORY_FOUNDATION_MIGRATION_ID, ); + let effectiveMigrationStatus = existingMigration?.status; + let effectiveMigrationSchemaTo = existingMigration?.schema_to; + if (existingMigration?.status === "rolled_back" && existingMigration?.notes) { + try { + const rollbackNotes = JSON.parse(existingMigration.notes) as Record; + if (typeof rollbackNotes.previous_status === "string") { + effectiveMigrationStatus = rollbackNotes.previous_status; + } + if (typeof rollbackNotes.previous_schema_to === "string") { + effectiveMigrationSchemaTo = rollbackNotes.previous_schema_to; + } + } catch { + effectiveMigrationStatus = undefined; + effectiveMigrationSchemaTo = undefined; + } + } - const plan: Asm115Plan = { + const plan: MemoryFoundationMigrationPlan = { slotdb: { version: slotVersion, needsMigration: slotVersion !== "missing", @@ -198,7 +295,7 @@ export async function runAsm115Migration( }, }, semantic: { - version: semanticPlan.changed === 0 ? ASM115_SCHEMA_VERSION : "mixed", + version: semanticPlan.changed === 0 ? MEMORY_FOUNDATION_SCHEMA_VERSION : "mixed", needsMigration: semanticPlan.changed > 0, details: { collection: runtime.qdrantCollection, @@ -209,20 +306,20 @@ export async function runAsm115Migration( }; if (input.mode === "preflight" || input.mode === "plan") { - const noop = isAsm115Noop({ + const noop = isMemoryFoundationMigrationNoop({ pendingSemanticChanges: semanticPlan.changed, - migrationStatus: existingMigration?.status, - migrationSchemaTo: existingMigration?.schema_to, + migrationStatus: effectiveMigrationStatus, + migrationSchemaTo: effectiveMigrationSchemaTo, }); slotDb.close(); return { mode: input.mode, - migration_id: ASM115_MIGRATION_ID, - schema_target: ASM115_SCHEMA_VERSION, + migration_id: MEMORY_FOUNDATION_MIGRATION_ID, + schema_target: MEMORY_FOUNDATION_SCHEMA_VERSION, no_op: noop, plan, existing_migration_state: existingMigration, - semantic_patch_preview: semanticPlan.patches.slice(0, 20).map((p) => ({ + semantic_patch_preview: semanticPlan.patches.slice(0, 20).map((p: { id: string | number | Record; changedFields: string[] }) => ({ id: p.id, changed_fields: p.changedFields, })), @@ -230,16 +327,16 @@ export async function runAsm115Migration( } if (input.mode === "verify") { - const noop = isAsm115Noop({ + const noop = isMemoryFoundationMigrationNoop({ pendingSemanticChanges: semanticPlan.changed, - migrationStatus: existingMigration?.status, - migrationSchemaTo: existingMigration?.schema_to, + migrationStatus: effectiveMigrationStatus, + migrationSchemaTo: effectiveMigrationSchemaTo, }); slotDb.close(); return { mode: "verify", - migration_id: ASM115_MIGRATION_ID, - schema_target: ASM115_SCHEMA_VERSION, + migration_id: MEMORY_FOUNDATION_MIGRATION_ID, + schema_target: MEMORY_FOUNDATION_SCHEMA_VERSION, verified: noop, remaining_semantic_points: semanticPlan.changed, migration_state: existingMigration, @@ -253,23 +350,34 @@ export async function runAsm115Migration( throw new Error("rollback requires --rollback-snapshot "); } restoreSlotDbSnapshot(input.rollbackSnapshotPath, slotDbDir); + let semanticRollbackSnapshot: string | null = null; + const existingNotes = existingMigration?.notes ? JSON.parse(existingMigration.notes) : {}; + semanticRollbackSnapshot = typeof existingNotes.semantic_snapshot_path === "string" ? existingNotes.semantic_snapshot_path : null; + if (semanticRollbackSnapshot) { + await restoreSemanticSnapshot(qdrant, semanticRollbackSnapshot); + } slotDb.recordMigrationState(userId, agentId, { - migration_id: ASM115_MIGRATION_ID, - schema_from: ASM115_SCHEMA_VERSION, + migration_id: MEMORY_FOUNDATION_MIGRATION_ID, + schema_from: MEMORY_FOUNDATION_SCHEMA_VERSION, schema_to: "rollback", applied_at: nowIso(), status: "rolled_back", - notes: JSON.stringify({ rollback_snapshot: input.rollbackSnapshotPath }), + notes: JSON.stringify({ + rollback_snapshot: input.rollbackSnapshotPath, + semantic_snapshot_path: semanticRollbackSnapshot, + previous_status: existingMigration?.status || null, + previous_schema_to: existingMigration?.schema_to || null, + }), }); const state = slotDb.getMigrationState( userId, agentId, - ASM115_MIGRATION_ID, + MEMORY_FOUNDATION_MIGRATION_ID, ); slotDb.close(); return { mode: "rollback", - migration_id: ASM115_MIGRATION_ID, + migration_id: MEMORY_FOUNDATION_MIGRATION_ID, rolled_back: true, migration_state: state, }; @@ -278,10 +386,15 @@ export async function runAsm115Migration( const snapshotDir = input.snapshotDir || join(slotDbDir, "migration-snapshots"); const snapshotPath = createSlotDbSnapshot(slotDbDir, snapshotDir); + const semanticSnapshotPath = createSemanticSnapshot( + points, + runtime.qdrantCollection, + snapshotDir, + ); if (semanticPlan.patches.length > 0) { await qdrant.setPayload( - semanticPlan.patches.map((patch) => ({ + semanticPlan.patches.map((patch: { id: string | number | Record; payload: Record }) => ({ id: patch.id, payload: patch.payload, })), @@ -289,26 +402,28 @@ export async function runAsm115Migration( } slotDb.recordMigrationState(userId, agentId, { - migration_id: ASM115_MIGRATION_ID, + migration_id: MEMORY_FOUNDATION_MIGRATION_ID, schema_from: existingMigration?.schema_to || "legacy", - schema_to: ASM115_SCHEMA_VERSION, + schema_to: MEMORY_FOUNDATION_SCHEMA_VERSION, applied_at: nowIso(), status: "migrated", notes: JSON.stringify({ snapshot_path: snapshotPath, + semantic_snapshot_path: semanticSnapshotPath, semantic_updates: semanticPlan.changed, total_semantic_points: semanticPlan.total, }), }); - const state = slotDb.getMigrationState(userId, agentId, ASM115_MIGRATION_ID); + const state = slotDb.getMigrationState(userId, agentId, MEMORY_FOUNDATION_MIGRATION_ID); slotDb.close(); return { mode: "apply", - migration_id: ASM115_MIGRATION_ID, - schema_target: ASM115_SCHEMA_VERSION, + migration_id: MEMORY_FOUNDATION_MIGRATION_ID, + schema_target: MEMORY_FOUNDATION_SCHEMA_VERSION, applied: true, snapshot_path: snapshotPath, + semantic_snapshot_path: semanticSnapshotPath, semantic_updates: semanticPlan.changed, total_semantic_points: semanticPlan.total, migration_state: state, diff --git a/src/services/qdrant.ts b/src/services/qdrant.ts index c892f87..0ac676d 100644 --- a/src/services/qdrant.ts +++ b/src/services/qdrant.ts @@ -323,6 +323,25 @@ export class QdrantClient { } } + async deletePayloadKeys( + ids: Array>, + keys: string[], + ): Promise { + if (ids.length === 0 || keys.length === 0) return; + + await this.request( + `/collections/${this.config.collection}/points/payload/delete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + keys, + points: ids, + }), + }, + ); + } + async scroll( limit: number, offset?: any, diff --git a/tests/test-asm-cli.ts b/tests/test-asm-cli.ts index 11f5be2..a244539 100644 --- a/tests/test-asm-cli.ts +++ b/tests/test-asm-cli.ts @@ -100,19 +100,24 @@ test("parseAsmCliArgs supports help, setup-openclaw, install , and ini "project-event should parse", ); assertEqual( - parseAsmCliArgs(["migrate-asm115", "plan", "--preflight-limit", "10"]), - { command: "migrate-asm115", argv: ["plan", "--preflight-limit", "10"] }, - "migrate-asm115 command should parse", + parseAsmCliArgs(["migrate-memory-foundation", "plan", "--preflight-limit", "10"]), + { command: "migrate-memory-foundation", argv: ["plan", "--preflight-limit", "10"] }, + "migrate-memory-foundation command should parse", ); assertEqual( - parseAsmCliArgs(["migrate", "asm115", "verify"]), - { command: "migrate-asm115", argv: ["verify"] }, - "migrate asm115 alias should parse", + parseAsmCliArgs(["memory", "migrate", "verify"]), + { command: "migrate-memory-foundation", argv: ["verify"] }, + "memory migrate alias should parse", ); assertEqual( - parseAsmCliArgs(["check-asm115", "--preflight-limit", "5"]), - { command: "check-asm115", argv: ["--preflight-limit", "5"] }, - "check-asm115 should parse", + parseAsmCliArgs(["check-memory-foundation", "--preflight-limit", "5"]), + { command: "check-memory-foundation", argv: ["--preflight-limit", "5"] }, + "check-memory-foundation should parse", + ); + assertEqual( + parseAsmCliArgs(["memory", "check", "--preflight-limit", "5"]), + { command: "check-memory-foundation", argv: ["--preflight-limit", "5"] }, + "memory check alias should parse", ); }); diff --git a/tests/test-asm115-migration-core.ts b/tests/test-memory-foundation-migration-core.ts similarity index 82% rename from tests/test-asm115-migration-core.ts rename to tests/test-memory-foundation-migration-core.ts index 5ed2470..8157a12 100644 --- a/tests/test-asm115-migration-core.ts +++ b/tests/test-memory-foundation-migration-core.ts @@ -1,9 +1,9 @@ import { - ASM115_SCHEMA_VERSION, + MEMORY_FOUNDATION_SCHEMA_VERSION, buildSemanticPayloadPatch, - isAsm115Noop, + isMemoryFoundationMigrationNoop, planSemanticPayloadMigration, -} from "../src/core/migrations/asm115-migration-core.js"; +} from "../src/core/migrations/memory-foundation-migration.js"; function assert(condition: unknown, message: string): void { if (!condition) throw new Error(message); @@ -43,7 +43,7 @@ test("buildSemanticPayloadPatch adds missing ASM-115 fields", () => { assertEqual( patch.payload.schema_version, - ASM115_SCHEMA_VERSION, + MEMORY_FOUNDATION_SCHEMA_VERSION, "must set schema version", ); assertEqual(patch.payload.memory_scope, "agent", "must infer memory_scope"); @@ -76,7 +76,7 @@ test("planSemanticPayloadMigration reports changed patches only", () => { id: "already", payload: { namespace: "agent.assistant.working_memory", - schema_version: ASM115_SCHEMA_VERSION, + schema_version: MEMORY_FOUNDATION_SCHEMA_VERSION, memory_scope: "agent", memory_type: "episodic_trace", promotion_state: "raw", @@ -91,21 +91,21 @@ test("planSemanticPayloadMigration reports changed patches only", () => { assertEqual(result.patches[0]?.id, "legacy", "should only patch legacy row"); }); -test("isAsm115Noop only true when migration already applied and no pending semantic updates", () => { +test("isMemoryFoundationMigrationNoop only true when migration already applied and no pending semantic updates", () => { assert( - isAsm115Noop({ + isMemoryFoundationMigrationNoop({ pendingSemanticChanges: 0, migrationStatus: "migrated", - migrationSchemaTo: ASM115_SCHEMA_VERSION, + migrationSchemaTo: MEMORY_FOUNDATION_SCHEMA_VERSION, }), "should be noop when already migrated and no pending changes", ); assert( - !isAsm115Noop({ + !isMemoryFoundationMigrationNoop({ pendingSemanticChanges: 1, migrationStatus: "migrated", - migrationSchemaTo: ASM115_SCHEMA_VERSION, + migrationSchemaTo: MEMORY_FOUNDATION_SCHEMA_VERSION, }), "pending changes must disable noop", );