@tekmidian/pai 0.9.13 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{auto-route-DL4ViDKC.mjs → auto-route-D54Kv7yI.mjs} +2 -2
- package/dist/{auto-route-DL4ViDKC.mjs.map → auto-route-D54Kv7yI.mjs.map} +1 -1
- package/dist/cli/index.mjs +808 -345
- package/dist/cli/index.mjs.map +1 -1
- package/dist/{clusters-CRlPBpq8.mjs → clusters-BwmdKB-V.mjs} +1 -1
- package/dist/{clusters-CRlPBpq8.mjs.map → clusters-BwmdKB-V.mjs.map} +1 -1
- package/dist/{config-BuhHWyOK.mjs → config-DqBY3aT0.mjs} +1 -1
- package/dist/{config-BuhHWyOK.mjs.map → config-DqBY3aT0.mjs.map} +1 -1
- package/dist/daemon/index.mjs +12 -8
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-BryEC3as.mjs +1426 -0
- package/dist/daemon-BryEC3as.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +2 -2
- package/dist/{detector-AzVtGLtL.mjs → detector-Q_lVXtj-.mjs} +1 -1
- package/dist/{detector-AzVtGLtL.mjs.map → detector-Q_lVXtj-.mjs.map} +1 -1
- package/dist/{factory-BDBIfTDC.mjs → factory-BufouUQ1.mjs} +3 -3
- package/dist/{factory-BDBIfTDC.mjs.map → factory-BufouUQ1.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +39 -0
- package/dist/hooks/context-compression-hook.mjs.map +2 -2
- package/dist/hooks/stop-hook.mjs +36 -0
- package/dist/hooks/stop-hook.mjs.map +2 -2
- package/dist/index.mjs +2 -2
- package/dist/indexer-D7MvSQPY.mjs +1 -0
- package/dist/{indexer-backend-DXkDh-kc.mjs → indexer-backend-BrrCnasS.mjs} +1 -1
- package/dist/{indexer-backend-DXkDh-kc.mjs.map → indexer-backend-BrrCnasS.mjs.map} +1 -1
- package/dist/{ipc-client-CoyUHPod.mjs → ipc-client-C3pjwy2m.mjs} +1 -1
- package/dist/{ipc-client-CoyUHPod.mjs.map → ipc-client-C3pjwy2m.mjs.map} +1 -1
- package/dist/{kg-entity-D5v7RCDi.mjs → kg-entity-DVzTsy6G.mjs} +1 -1
- package/dist/{kg-entity-D5v7RCDi.mjs.map → kg-entity-DVzTsy6G.mjs.map} +1 -1
- package/dist/kg-extraction-r5KkTmWY.mjs +524 -0
- package/dist/kg-extraction-r5KkTmWY.mjs.map +1 -0
- package/dist/{latent-ideas-DvWBRHsy.mjs → latent-ideas-dWVuI_dg.mjs} +2 -2
- package/dist/{latent-ideas-DvWBRHsy.mjs.map → latent-ideas-dWVuI_dg.mjs.map} +1 -1
- package/dist/{migrate-jokLenje.mjs → migrate-fLD6rAdO.mjs} +2 -2
- package/dist/{migrate-jokLenje.mjs.map → migrate-fLD6rAdO.mjs.map} +1 -1
- package/dist/{neighborhood-u8ytjmWq.mjs → neighborhood-DixmpfPT.mjs} +1 -1
- package/dist/{neighborhood-u8ytjmWq.mjs.map → neighborhood-DixmpfPT.mjs.map} +1 -1
- package/dist/{note-context-CG2_e-0W.mjs → note-context-BY7rfGdl.mjs} +1 -1
- package/dist/{note-context-CG2_e-0W.mjs.map → note-context-BY7rfGdl.mjs.map} +1 -1
- package/dist/{postgres-BGERehmX.mjs → postgres-Cp25xZes.mjs} +1 -1
- package/dist/{postgres-BGERehmX.mjs.map → postgres-Cp25xZes.mjs.map} +1 -1
- package/dist/{query-feedback-CQSumXDy.mjs → query-feedback-C0hKUHVz.mjs} +1 -1
- package/dist/{query-feedback-CQSumXDy.mjs.map → query-feedback-C0hKUHVz.mjs.map} +1 -1
- package/dist/{sqlite-xUe94oUZ.mjs → sqlite-B3JHfJc2.mjs} +1 -1
- package/dist/{sqlite-xUe94oUZ.mjs.map → sqlite-B3JHfJc2.mjs.map} +1 -1
- package/dist/{state-BIlxNRUn.mjs → state-BHZAhLou.mjs} +1 -1
- package/dist/{state-BIlxNRUn.mjs.map → state-BHZAhLou.mjs.map} +1 -1
- package/dist/{themes-9jxFn3Rf.mjs → themes-DO8mUVwD.mjs} +1 -1
- package/dist/{themes-9jxFn3Rf.mjs.map → themes-DO8mUVwD.mjs.map} +1 -1
- package/dist/{tools-CF0lB5lW.mjs → tools-C1JNgya7.mjs} +14 -14
- package/dist/{tools-CF0lB5lW.mjs.map → tools-C1JNgya7.mjs.map} +1 -1
- package/dist/{trace-C2XrzssW.mjs → trace-TYCLMCkY.mjs} +1 -1
- package/dist/{trace-C2XrzssW.mjs.map → trace-TYCLMCkY.mjs.map} +1 -1
- package/dist/{utils-QSfKagcj.mjs → utils-BAxjW3j8.mjs} +2 -2
- package/dist/{utils-QSfKagcj.mjs.map → utils-BAxjW3j8.mjs.map} +1 -1
- package/dist/{vault-indexer-iHRjcy6e.mjs → vault-indexer-DsPC6wQf.mjs} +1 -1
- package/dist/{vault-indexer-iHRjcy6e.mjs.map → vault-indexer-DsPC6wQf.mjs.map} +1 -1
- package/dist/{daemon-w2fE3UMz.mjs → work-queue-worker-94INtUiz.mjs} +54 -1398
- package/dist/work-queue-worker-94INtUiz.mjs.map +1 -0
- package/dist/{zettelkasten-BdaMzTGQ.mjs → zettelkasten-B-94vghH.mjs} +3 -3
- package/dist/{zettelkasten-BdaMzTGQ.mjs.map → zettelkasten-B-94vghH.mjs.map} +1 -1
- package/package.json +1 -1
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +48 -0
- package/src/hooks/ts/stop/stop-hook.ts +40 -0
- package/dist/daemon-w2fE3UMz.mjs.map +0 -1
- package/dist/kg-extraction-Uvr9fTlz.mjs +0 -246
- package/dist/kg-extraction-Uvr9fTlz.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ipc-client-CoyUHPod.mjs","names":[],"sources":["../src/daemon/ipc-client.ts"],"sourcesContent":["/**\n * ipc-client.ts — IPC client for the PAI Daemon MCP shim\n *\n * PaiClient connects to the Unix Domain Socket served by daemon.ts\n * and forwards tool calls to the daemon. Uses a fresh socket connection per\n * call (connect → write JSON + newline → read response line → parse → destroy).\n * This keeps the client stateless and avoids connection management complexity.\n *\n * Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).\n */\n\nimport { connect, Socket } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n NotificationConfig,\n NotificationMode,\n NotificationEvent,\n SendResult,\n} from \"../notifications/types.js\";\nimport type { TopicCheckParams, TopicCheckResult } from \"../topics/detector.js\";\nimport type { AutoRouteResult } from \"../session/auto-route.js\";\n\n// ---------------------------------------------------------------------------\n// Protocol types\n// ---------------------------------------------------------------------------\n\n/** Default socket path */\nexport const IPC_SOCKET_PATH = \"/tmp/pai.sock\";\n\n/** Timeout for IPC calls (60 seconds) */\nconst IPC_TIMEOUT_MS = 60_000;\n\ninterface IpcRequest {\n id: string;\n method: string;\n params: Record<string, unknown>;\n}\n\ninterface IpcResponse {\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/**\n * Thin IPC proxy that forwards tool calls to pai-daemon over a Unix\n * Domain Socket. Each call opens a fresh connection, sends one NDJSON request,\n * reads the response, and closes. Stateless and simple.\n */\nexport class PaiClient {\n private readonly socketPath: string;\n\n constructor(socketPath?: string) {\n this.socketPath = socketPath ?? IPC_SOCKET_PATH;\n }\n\n /**\n * Call a PAI tool by name with the given params.\n * Returns the tool result or throws on error.\n */\n async call(method: string, params: Record<string, unknown>): Promise<unknown> {\n return this.send(method, params);\n }\n\n /**\n * Check daemon status.\n */\n async status(): Promise<Record<string, unknown>> {\n const result = await this.send(\"status\", {});\n return result as Record<string, unknown>;\n }\n\n /**\n * Trigger an immediate index run.\n */\n async triggerIndex(): Promise<void> {\n await this.send(\"index_now\", {});\n }\n\n // -------------------------------------------------------------------------\n // Notification methods\n // -------------------------------------------------------------------------\n\n /**\n * Get the current notification config from the daemon.\n */\n async getNotificationConfig(): Promise<{\n config: NotificationConfig;\n activeChannels: string[];\n }> {\n const result = await this.send(\"notification_get_config\", {});\n return result as { config: NotificationConfig; activeChannels: string[] };\n }\n\n /**\n * Patch the notification config on the daemon (and persist to disk).\n */\n async setNotificationConfig(patch: {\n mode?: NotificationMode;\n channels?: Partial<NotificationConfig[\"channels\"]>;\n routing?: Partial<NotificationConfig[\"routing\"]>;\n }): Promise<{ config: NotificationConfig }> {\n const result = await this.send(\"notification_set_config\", patch as Record<string, unknown>);\n return result as { config: NotificationConfig };\n }\n\n /**\n * Send a notification via the daemon (routes to configured channels).\n */\n async sendNotification(payload: {\n event: NotificationEvent;\n message: string;\n title?: string;\n }): Promise<SendResult> {\n const result = await this.send(\"notification_send\", payload as Record<string, unknown>);\n return result as SendResult;\n }\n\n // -------------------------------------------------------------------------\n // Topic detection methods\n // -------------------------------------------------------------------------\n\n /**\n * Check whether the provided context text has drifted to a different project\n * than the session's current routing.\n */\n async topicCheck(params: TopicCheckParams): Promise<TopicCheckResult> {\n const result = await this.send(\"topic_check\", params as unknown as Record<string, unknown>);\n return result as TopicCheckResult;\n }\n\n // -------------------------------------------------------------------------\n // Session routing methods\n // -------------------------------------------------------------------------\n\n /**\n * Automatically detect which project a session belongs to.\n * Tries path match, PAI.md marker walk, then topic detection (if context given).\n */\n async sessionAutoRoute(params: {\n cwd?: string;\n context?: string;\n }): Promise<AutoRouteResult | null> {\n // session_auto_route returns a ToolResult (content array). Extract the text\n // and parse JSON from it.\n const result = await this.send(\"session_auto_route\", params as Record<string, unknown>);\n const toolResult = result as { content?: Array<{ text: string }>; isError?: boolean };\n if (toolResult.isError) return null;\n const text = toolResult.content?.[0]?.text ?? \"\";\n // Text is either JSON (on match) or a human-readable \"no match\" message\n try {\n return JSON.parse(text) as AutoRouteResult;\n } catch {\n return null;\n }\n }\n\n // -------------------------------------------------------------------------\n // Internal transport\n // -------------------------------------------------------------------------\n\n /**\n * Send a single IPC request and wait for the response.\n * Opens a new socket connection per call — simple and reliable.\n */\n private send(\n method: string,\n params: Record<string, unknown>\n ): Promise<unknown> {\n const socketPath = this.socketPath;\n\n return new Promise((resolve, reject) => {\n let socket: Socket | null = null;\n let done = false;\n let buffer = \"\";\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n function finish(error: Error | null, value?: unknown): void {\n if (done) return;\n done = true;\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n try {\n socket?.destroy();\n } catch {\n // ignore\n }\n if (error) {\n reject(error);\n } else {\n resolve(value);\n }\n }\n\n socket = connect(socketPath, () => {\n const request: IpcRequest = {\n id: randomUUID(),\n method,\n params,\n };\n socket!.write(JSON.stringify(request) + \"\\n\");\n });\n\n socket.on(\"data\", (chunk: Buffer) => {\n buffer += chunk.toString();\n const nl = buffer.indexOf(\"\\n\");\n if (nl === -1) return;\n\n const line = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 1);\n\n let response: IpcResponse;\n try {\n response = JSON.parse(line) as IpcResponse;\n } catch {\n finish(new Error(`IPC parse error: ${line}`));\n return;\n }\n\n if (!response.ok) {\n finish(new Error(response.error ?? \"IPC call failed\"));\n } else {\n finish(null, response.result);\n }\n });\n\n socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n finish(\n new Error(\n \"PAI daemon not running. Start it with: pai daemon serve\"\n )\n );\n } else {\n finish(e);\n }\n });\n\n socket.on(\"end\", () => {\n if (!done) {\n finish(new Error(\"IPC connection closed before response\"));\n }\n });\n\n timer = setTimeout(() => {\n finish(new Error(\"IPC call timed out after 60s\"));\n }, IPC_TIMEOUT_MS);\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AA2BA,MAAa,kBAAkB;;AAG/B,MAAM,iBAAiB;;;;;;AAwBvB,IAAa,YAAb,MAAuB;CACrB,AAAiB;CAEjB,YAAY,YAAqB;AAC/B,OAAK,aAAa,cAAc;;;;;;CAOlC,MAAM,KAAK,QAAgB,QAAmD;AAC5E,SAAO,KAAK,KAAK,QAAQ,OAAO;;;;;CAMlC,MAAM,SAA2C;AAE/C,SADe,MAAM,KAAK,KAAK,UAAU,EAAE,CAAC;;;;;CAO9C,MAAM,eAA8B;AAClC,QAAM,KAAK,KAAK,aAAa,EAAE,CAAC;;;;;CAUlC,MAAM,wBAGH;AAED,SADe,MAAM,KAAK,KAAK,2BAA2B,EAAE,CAAC;;;;;CAO/D,MAAM,sBAAsB,OAIgB;AAE1C,SADe,MAAM,KAAK,KAAK,2BAA2B,MAAiC;;;;;CAO7F,MAAM,iBAAiB,SAIC;AAEtB,SADe,MAAM,KAAK,KAAK,qBAAqB,QAAmC;;;;;;CAYzF,MAAM,WAAW,QAAqD;AAEpE,SADe,MAAM,KAAK,KAAK,eAAe,OAA6C;;;;;;CAY7F,MAAM,iBAAiB,QAGa;EAIlC,MAAM,aADS,MAAM,KAAK,KAAK,sBAAsB,OAAkC;AAEvF,MAAI,WAAW,QAAS,QAAO;EAC/B,MAAM,OAAO,WAAW,UAAU,IAAI,QAAQ;AAE9C,MAAI;AACF,UAAO,KAAK,MAAM,KAAK;UACjB;AACN,UAAO;;;;;;;CAYX,AAAQ,KACN,QACA,QACkB;EAClB,MAAM,aAAa,KAAK;AAExB,SAAO,IAAI,SAAS,SAAS,WAAW;GACtC,IAAI,SAAwB;GAC5B,IAAI,OAAO;GACX,IAAI,SAAS;GACb,IAAI,QAA8C;GAElD,SAAS,OAAO,OAAqB,OAAuB;AAC1D,QAAI,KAAM;AACV,WAAO;AACP,QAAI,UAAU,MAAM;AAClB,kBAAa,MAAM;AACnB,aAAQ;;AAEV,QAAI;AACF,aAAQ,SAAS;YACX;AAGR,QAAI,MACF,QAAO,MAAM;QAEb,SAAQ,MAAM;;AAIlB,YAAS,QAAQ,kBAAkB;IACjC,MAAM,UAAsB;KAC1B,IAAI,YAAY;KAChB;KACA;KACD;AACD,WAAQ,MAAM,KAAK,UAAU,QAAQ,GAAG,KAAK;KAC7C;AAEF,UAAO,GAAG,SAAS,UAAkB;AACnC,cAAU,MAAM,UAAU;IAC1B,MAAM,KAAK,OAAO,QAAQ,KAAK;AAC/B,QAAI,OAAO,GAAI;IAEf,MAAM,OAAO,OAAO,MAAM,GAAG,GAAG;AAChC,aAAS,OAAO,MAAM,KAAK,EAAE;IAE7B,IAAI;AACJ,QAAI;AACF,gBAAW,KAAK,MAAM,KAAK;YACrB;AACN,4BAAO,IAAI,MAAM,oBAAoB,OAAO,CAAC;AAC7C;;AAGF,QAAI,CAAC,SAAS,GACZ,QAAO,IAAI,MAAM,SAAS,SAAS,kBAAkB,CAAC;QAEtD,QAAO,MAAM,SAAS,OAAO;KAE/B;AAEF,UAAO,GAAG,UAAU,MAA6B;AAC/C,QAAI,EAAE,SAAS,YAAY,EAAE,SAAS,eACpC,wBACE,IAAI,MACF,0DACD,CACF;QAED,QAAO,EAAE;KAEX;AAEF,UAAO,GAAG,aAAa;AACrB,QAAI,CAAC,KACH,wBAAO,IAAI,MAAM,wCAAwC,CAAC;KAE5D;AAEF,WAAQ,iBAAiB;AACvB,2BAAO,IAAI,MAAM,+BAA+B,CAAC;MAChD,eAAe;IAClB"}
|
|
1
|
+
{"version":3,"file":"ipc-client-C3pjwy2m.mjs","names":[],"sources":["../src/daemon/ipc-client.ts"],"sourcesContent":["/**\n * ipc-client.ts — IPC client for the PAI Daemon MCP shim\n *\n * PaiClient connects to the Unix Domain Socket served by daemon.ts\n * and forwards tool calls to the daemon. Uses a fresh socket connection per\n * call (connect → write JSON + newline → read response line → parse → destroy).\n * This keeps the client stateless and avoids connection management complexity.\n *\n * Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).\n */\n\nimport { connect, Socket } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n NotificationConfig,\n NotificationMode,\n NotificationEvent,\n SendResult,\n} from \"../notifications/types.js\";\nimport type { TopicCheckParams, TopicCheckResult } from \"../topics/detector.js\";\nimport type { AutoRouteResult } from \"../session/auto-route.js\";\n\n// ---------------------------------------------------------------------------\n// Protocol types\n// ---------------------------------------------------------------------------\n\n/** Default socket path */\nexport const IPC_SOCKET_PATH = \"/tmp/pai.sock\";\n\n/** Timeout for IPC calls (60 seconds) */\nconst IPC_TIMEOUT_MS = 60_000;\n\ninterface IpcRequest {\n id: string;\n method: string;\n params: Record<string, unknown>;\n}\n\ninterface IpcResponse {\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/**\n * Thin IPC proxy that forwards tool calls to pai-daemon over a Unix\n * Domain Socket. Each call opens a fresh connection, sends one NDJSON request,\n * reads the response, and closes. Stateless and simple.\n */\nexport class PaiClient {\n private readonly socketPath: string;\n\n constructor(socketPath?: string) {\n this.socketPath = socketPath ?? IPC_SOCKET_PATH;\n }\n\n /**\n * Call a PAI tool by name with the given params.\n * Returns the tool result or throws on error.\n */\n async call(method: string, params: Record<string, unknown>): Promise<unknown> {\n return this.send(method, params);\n }\n\n /**\n * Check daemon status.\n */\n async status(): Promise<Record<string, unknown>> {\n const result = await this.send(\"status\", {});\n return result as Record<string, unknown>;\n }\n\n /**\n * Trigger an immediate index run.\n */\n async triggerIndex(): Promise<void> {\n await this.send(\"index_now\", {});\n }\n\n // -------------------------------------------------------------------------\n // Notification methods\n // -------------------------------------------------------------------------\n\n /**\n * Get the current notification config from the daemon.\n */\n async getNotificationConfig(): Promise<{\n config: NotificationConfig;\n activeChannels: string[];\n }> {\n const result = await this.send(\"notification_get_config\", {});\n return result as { config: NotificationConfig; activeChannels: string[] };\n }\n\n /**\n * Patch the notification config on the daemon (and persist to disk).\n */\n async setNotificationConfig(patch: {\n mode?: NotificationMode;\n channels?: Partial<NotificationConfig[\"channels\"]>;\n routing?: Partial<NotificationConfig[\"routing\"]>;\n }): Promise<{ config: NotificationConfig }> {\n const result = await this.send(\"notification_set_config\", patch as Record<string, unknown>);\n return result as { config: NotificationConfig };\n }\n\n /**\n * Send a notification via the daemon (routes to configured channels).\n */\n async sendNotification(payload: {\n event: NotificationEvent;\n message: string;\n title?: string;\n }): Promise<SendResult> {\n const result = await this.send(\"notification_send\", payload as Record<string, unknown>);\n return result as SendResult;\n }\n\n // -------------------------------------------------------------------------\n // Topic detection methods\n // -------------------------------------------------------------------------\n\n /**\n * Check whether the provided context text has drifted to a different project\n * than the session's current routing.\n */\n async topicCheck(params: TopicCheckParams): Promise<TopicCheckResult> {\n const result = await this.send(\"topic_check\", params as unknown as Record<string, unknown>);\n return result as TopicCheckResult;\n }\n\n // -------------------------------------------------------------------------\n // Session routing methods\n // -------------------------------------------------------------------------\n\n /**\n * Automatically detect which project a session belongs to.\n * Tries path match, PAI.md marker walk, then topic detection (if context given).\n */\n async sessionAutoRoute(params: {\n cwd?: string;\n context?: string;\n }): Promise<AutoRouteResult | null> {\n // session_auto_route returns a ToolResult (content array). Extract the text\n // and parse JSON from it.\n const result = await this.send(\"session_auto_route\", params as Record<string, unknown>);\n const toolResult = result as { content?: Array<{ text: string }>; isError?: boolean };\n if (toolResult.isError) return null;\n const text = toolResult.content?.[0]?.text ?? \"\";\n // Text is either JSON (on match) or a human-readable \"no match\" message\n try {\n return JSON.parse(text) as AutoRouteResult;\n } catch {\n return null;\n }\n }\n\n // -------------------------------------------------------------------------\n // Internal transport\n // -------------------------------------------------------------------------\n\n /**\n * Send a single IPC request and wait for the response.\n * Opens a new socket connection per call — simple and reliable.\n */\n private send(\n method: string,\n params: Record<string, unknown>\n ): Promise<unknown> {\n const socketPath = this.socketPath;\n\n return new Promise((resolve, reject) => {\n let socket: Socket | null = null;\n let done = false;\n let buffer = \"\";\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n function finish(error: Error | null, value?: unknown): void {\n if (done) return;\n done = true;\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n try {\n socket?.destroy();\n } catch {\n // ignore\n }\n if (error) {\n reject(error);\n } else {\n resolve(value);\n }\n }\n\n socket = connect(socketPath, () => {\n const request: IpcRequest = {\n id: randomUUID(),\n method,\n params,\n };\n socket!.write(JSON.stringify(request) + \"\\n\");\n });\n\n socket.on(\"data\", (chunk: Buffer) => {\n buffer += chunk.toString();\n const nl = buffer.indexOf(\"\\n\");\n if (nl === -1) return;\n\n const line = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 1);\n\n let response: IpcResponse;\n try {\n response = JSON.parse(line) as IpcResponse;\n } catch {\n finish(new Error(`IPC parse error: ${line}`));\n return;\n }\n\n if (!response.ok) {\n finish(new Error(response.error ?? \"IPC call failed\"));\n } else {\n finish(null, response.result);\n }\n });\n\n socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n finish(\n new Error(\n \"PAI daemon not running. Start it with: pai daemon serve\"\n )\n );\n } else {\n finish(e);\n }\n });\n\n socket.on(\"end\", () => {\n if (!done) {\n finish(new Error(\"IPC connection closed before response\"));\n }\n });\n\n timer = setTimeout(() => {\n finish(new Error(\"IPC call timed out after 60s\"));\n }, IPC_TIMEOUT_MS);\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AA2BA,MAAa,kBAAkB;;AAG/B,MAAM,iBAAiB;;;;;;AAwBvB,IAAa,YAAb,MAAuB;CACrB,AAAiB;CAEjB,YAAY,YAAqB;AAC/B,OAAK,aAAa,cAAc;;;;;;CAOlC,MAAM,KAAK,QAAgB,QAAmD;AAC5E,SAAO,KAAK,KAAK,QAAQ,OAAO;;;;;CAMlC,MAAM,SAA2C;AAE/C,SADe,MAAM,KAAK,KAAK,UAAU,EAAE,CAAC;;;;;CAO9C,MAAM,eAA8B;AAClC,QAAM,KAAK,KAAK,aAAa,EAAE,CAAC;;;;;CAUlC,MAAM,wBAGH;AAED,SADe,MAAM,KAAK,KAAK,2BAA2B,EAAE,CAAC;;;;;CAO/D,MAAM,sBAAsB,OAIgB;AAE1C,SADe,MAAM,KAAK,KAAK,2BAA2B,MAAiC;;;;;CAO7F,MAAM,iBAAiB,SAIC;AAEtB,SADe,MAAM,KAAK,KAAK,qBAAqB,QAAmC;;;;;;CAYzF,MAAM,WAAW,QAAqD;AAEpE,SADe,MAAM,KAAK,KAAK,eAAe,OAA6C;;;;;;CAY7F,MAAM,iBAAiB,QAGa;EAIlC,MAAM,aADS,MAAM,KAAK,KAAK,sBAAsB,OAAkC;AAEvF,MAAI,WAAW,QAAS,QAAO;EAC/B,MAAM,OAAO,WAAW,UAAU,IAAI,QAAQ;AAE9C,MAAI;AACF,UAAO,KAAK,MAAM,KAAK;UACjB;AACN,UAAO;;;;;;;CAYX,AAAQ,KACN,QACA,QACkB;EAClB,MAAM,aAAa,KAAK;AAExB,SAAO,IAAI,SAAS,SAAS,WAAW;GACtC,IAAI,SAAwB;GAC5B,IAAI,OAAO;GACX,IAAI,SAAS;GACb,IAAI,QAA8C;GAElD,SAAS,OAAO,OAAqB,OAAuB;AAC1D,QAAI,KAAM;AACV,WAAO;AACP,QAAI,UAAU,MAAM;AAClB,kBAAa,MAAM;AACnB,aAAQ;;AAEV,QAAI;AACF,aAAQ,SAAS;YACX;AAGR,QAAI,MACF,QAAO,MAAM;QAEb,SAAQ,MAAM;;AAIlB,YAAS,QAAQ,kBAAkB;IACjC,MAAM,UAAsB;KAC1B,IAAI,YAAY;KAChB;KACA;KACD;AACD,WAAQ,MAAM,KAAK,UAAU,QAAQ,GAAG,KAAK;KAC7C;AAEF,UAAO,GAAG,SAAS,UAAkB;AACnC,cAAU,MAAM,UAAU;IAC1B,MAAM,KAAK,OAAO,QAAQ,KAAK;AAC/B,QAAI,OAAO,GAAI;IAEf,MAAM,OAAO,OAAO,MAAM,GAAG,GAAG;AAChC,aAAS,OAAO,MAAM,KAAK,EAAE;IAE7B,IAAI;AACJ,QAAI;AACF,gBAAW,KAAK,MAAM,KAAK;YACrB;AACN,4BAAO,IAAI,MAAM,oBAAoB,OAAO,CAAC;AAC7C;;AAGF,QAAI,CAAC,SAAS,GACZ,QAAO,IAAI,MAAM,SAAS,SAAS,kBAAkB,CAAC;QAEtD,QAAO,MAAM,SAAS,OAAO;KAE/B;AAEF,UAAO,GAAG,UAAU,MAA6B;AAC/C,QAAI,EAAE,SAAS,YAAY,EAAE,SAAS,eACpC,wBACE,IAAI,MACF,0DACD,CACF;QAED,QAAO,EAAE;KAEX;AAEF,UAAO,GAAG,aAAa;AACrB,QAAI,CAAC,KACH,wBAAO,IAAI,MAAM,wCAAwC,CAAC;KAE5D;AAEF,WAAQ,iBAAiB;AACvB,2BAAO,IAAI,MAAM,+BAA+B,CAAC;MAChD,eAAe;IAClB"}
|
|
@@ -173,4 +173,4 @@ function updateEntityFeedbackWeight(db, entityId, normalizedRating, alpha = .1)
|
|
|
173
173
|
|
|
174
174
|
//#endregion
|
|
175
175
|
export { kgContradictions as a, kgAdd as i, updateEntityFeedbackWeight as n, kgInvalidate as o, upsertKgEntity as r, kgQuery as s, listKgEntities as t };
|
|
176
|
-
//# sourceMappingURL=kg-entity-
|
|
176
|
+
//# sourceMappingURL=kg-entity-DVzTsy6G.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"kg-entity-D5v7RCDi.mjs","names":[],"sources":["../src/memory/kg.ts","../src/memory/kg-entity.ts"],"sourcesContent":["/**\n * Temporal Knowledge Graph — kg_triples CRUD layer.\n *\n * Uses the Postgres connection pool from the storage backend.\n * Triples are time-scoped: valid_from/valid_to enable point-in-time queries.\n * Invalidation sets valid_to = NOW() instead of deleting rows.\n */\n\nimport type { Pool } from \"pg\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgTriple {\n id: number;\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n valid_from: Date;\n valid_to?: Date;\n confidence: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n created_at: Date;\n}\n\nexport interface KgAddParams {\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n confidence?: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n}\n\nexport interface KgQueryParams {\n subject?: string;\n predicate?: string;\n object?: string;\n project_id?: number;\n as_of?: Date;\n include_invalidated?: boolean;\n}\n\nexport interface KgContradiction {\n subject: string;\n predicate: string;\n objects: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction rowToTriple(row: Record<string, unknown>): KgTriple {\n return {\n id: row.id as number,\n subject: row.subject as string,\n predicate: row.predicate as string,\n object: row.object as string,\n project_id: row.project_id as number | undefined,\n source_session: row.source_session as string | undefined,\n valid_from: new Date(row.valid_from as string),\n valid_to: row.valid_to ? new Date(row.valid_to as string) : undefined,\n confidence: row.confidence as \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\",\n created_at: new Date(row.created_at as string),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Core operations\n// ---------------------------------------------------------------------------\n\n/**\n * Add a new triple to the knowledge graph.\n * Returns the inserted triple.\n */\nexport async function kgAdd(pool: Pool, params: KgAddParams): Promise<KgTriple> {\n const confidence = params.confidence ?? \"EXTRACTED\";\n const result = await pool.query<Record<string, unknown>>(\n `INSERT INTO kg_triples\n (subject, predicate, object, project_id, source_session, confidence)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING *`,\n [\n params.subject,\n params.predicate,\n params.object,\n params.project_id ?? null,\n params.source_session ?? null,\n confidence,\n ]\n );\n return rowToTriple(result.rows[0]);\n}\n\n/**\n * Query triples by subject, predicate, object, and/or project.\n * Supports point-in-time queries via as_of.\n * By default only returns currently-valid triples (valid_to IS NULL).\n */\nexport async function kgQuery(pool: Pool, params: KgQueryParams): Promise<KgTriple[]> {\n const conditions: string[] = [];\n const values: unknown[] = [];\n let idx = 1;\n\n if (params.subject !== undefined) {\n conditions.push(`subject = $${idx++}`);\n values.push(params.subject);\n }\n if (params.predicate !== undefined) {\n conditions.push(`predicate = $${idx++}`);\n values.push(params.predicate);\n }\n if (params.object !== undefined) {\n conditions.push(`object = $${idx++}`);\n values.push(params.object);\n }\n if (params.project_id !== undefined) {\n conditions.push(`project_id = $${idx++}`);\n values.push(params.project_id);\n }\n\n if (params.as_of !== undefined) {\n // Valid at the given timestamp: started before or at as_of, and not yet ended\n conditions.push(`valid_from <= $${idx++}`);\n values.push(params.as_of);\n conditions.push(`(valid_to IS NULL OR valid_to > $${idx++})`);\n values.push(params.as_of);\n } else if (!params.include_invalidated) {\n // Default: only currently-valid (no valid_to set)\n conditions.push(`valid_to IS NULL`);\n }\n\n const where = conditions.length > 0 ? `WHERE ${conditions.join(\" AND \")}` : \"\";\n const result = await pool.query<Record<string, unknown>>(\n `SELECT * FROM kg_triples ${where} ORDER BY valid_from DESC`,\n values\n );\n return result.rows.map(rowToTriple);\n}\n\n/**\n * Invalidate a triple by setting valid_to = NOW().\n * Does not delete the row — preserves history.\n */\nexport async function kgInvalidate(pool: Pool, tripleId: number): Promise<void> {\n await pool.query(\n `UPDATE kg_triples SET valid_to = NOW() WHERE id = $1 AND valid_to IS NULL`,\n [tripleId]\n );\n}\n\n/**\n * Find contradictions: cases where the same (subject, predicate) pair has\n * multiple currently-valid objects.\n */\nexport async function kgContradictions(\n pool: Pool,\n subject: string\n): Promise<KgContradiction[]> {\n const result = await pool.query<{ subject: string; predicate: string; objects: string[] }>(\n `SELECT subject, predicate, array_agg(object ORDER BY object) AS objects\n FROM kg_triples\n WHERE subject = $1\n AND valid_to IS NULL\n GROUP BY subject, predicate\n HAVING COUNT(*) > 1`,\n [subject]\n );\n return result.rows.map((row) => ({\n subject: row.subject,\n predicate: row.predicate,\n objects: row.objects,\n }));\n}\n","/**\n * kg-entity.ts — Entity content-addressing with multi-tenant support.\n *\n * Provides UUID5-style deterministic content hashes for KG entities and edges,\n * ensuring that the same entity name always maps to the same ID within a tenant.\n * This enables idempotent upserts and stable foreign keys for kg_triples.\n *\n * Multi-tenant support: each tenant namespace gets its own entity ID space.\n * The default tenant is \"default\" for single-user deployments.\n */\n\nimport { createHash } from \"node:crypto\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgEntity {\n entity_id: string;\n tenant_id: string;\n name: string;\n type: string;\n description?: string;\n first_seen?: number;\n last_seen?: number;\n mention_count: number;\n feedback_weight: number;\n}\n\nexport interface KgEntityUpsertParams {\n name: string;\n type?: string;\n description?: string;\n tenantId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Content addressing\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a deterministic entity ID (UUID5-style) for a given name and tenant.\n *\n * The ID is a hex digest derived from \"tenant_id:name\" so the same entity\n * always receives the same ID within a tenant namespace.\n *\n * @param name Entity name (case-preserved)\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function entityContentId(name: string, tenantId = \"default\"): string {\n return createHash(\"sha256\")\n .update(`entity:${tenantId}:${name}`)\n .digest(\"hex\")\n .slice(0, 32); // 128-bit hex string — UUID5-compatible length\n}\n\n/**\n * Generate a deterministic edge ID for a (source, relation, target) triple\n * within a tenant namespace.\n *\n * @param source Source entity name\n * @param relation Relation/predicate verb phrase\n * @param target Target entity name\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function edgeContentId(\n source: string,\n relation: string,\n target: string,\n tenantId = \"default\"\n): string {\n return createHash(\"sha256\")\n .update(`edge:${tenantId}:${source}:${relation}:${target}`)\n .digest(\"hex\")\n .slice(0, 32);\n}\n\n// ---------------------------------------------------------------------------\n// SQLite entity upsert (for federation.db)\n// ---------------------------------------------------------------------------\n\n/**\n * Upsert a KG entity in the federation SQLite database.\n *\n * If the entity already exists for this tenant:\n * - Updates last_seen to now\n * - Increments mention_count\n * - Updates description if provided (overwrites older description)\n *\n * Returns the entity_id for use as a foreign key in kg_triples.\n */\nexport function upsertKgEntity(\n db: Database,\n params: KgEntityUpsertParams\n): string {\n const tenantId = params.tenantId ?? \"default\";\n const entityId = entityContentId(params.name, tenantId);\n const now = Date.now();\n\n db.prepare(`\n INSERT INTO kg_entities\n (entity_id, tenant_id, name, type, description, first_seen, last_seen, mention_count, feedback_weight)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, 1, 0.5)\n ON CONFLICT(entity_id) DO UPDATE SET\n last_seen = excluded.last_seen,\n mention_count = mention_count + 1,\n description = COALESCE(excluded.description, description),\n type = CASE WHEN excluded.type != 'unknown' THEN excluded.type ELSE type END\n `).run(\n entityId,\n tenantId,\n params.name,\n params.type ?? \"unknown\",\n params.description ?? null,\n now,\n now\n );\n\n return entityId;\n}\n\n/**\n * Look up a KG entity by name within a tenant.\n * Returns null if the entity does not exist.\n */\nexport function findKgEntity(\n db: Database,\n name: string,\n tenantId = \"default\"\n): KgEntity | null {\n const entityId = entityContentId(name, tenantId);\n const row = db.prepare(\n \"SELECT * FROM kg_entities WHERE entity_id = ? AND tenant_id = ?\"\n ).get(entityId, tenantId) as KgEntity | undefined;\n return row ?? null;\n}\n\n/**\n * List KG entities for a tenant, optionally filtered by type.\n *\n * @param db Federation SQLite database\n * @param tenantId Tenant namespace (default: \"default\")\n * @param type Optional entity type filter\n * @param limit Maximum entities to return (default: 100)\n */\nexport function listKgEntities(\n db: Database,\n tenantId = \"default\",\n type?: string,\n limit = 100\n): KgEntity[] {\n if (type) {\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? AND type = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, type, limit) as KgEntity[];\n }\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, limit) as KgEntity[];\n}\n\n// ---------------------------------------------------------------------------\n// Feedback weight update (MR2 — EMA)\n// ---------------------------------------------------------------------------\n\n/**\n * Apply an EMA (Exponential Moving Average) feedback update to an entity's weight.\n *\n * EMA formula: new_weight = old_weight + alpha * (target - old_weight)\n *\n * @param db Federation SQLite database\n * @param entityId Entity ID to update\n * @param normalizedRating Rating normalized to [0, 1] (e.g., rating/5 for 1-5 scale)\n * @param alpha EMA learning rate (default: 0.1)\n */\nexport function updateEntityFeedbackWeight(\n db: Database,\n entityId: string,\n normalizedRating: number,\n alpha = 0.1\n): void {\n const row = db.prepare(\n \"SELECT feedback_weight FROM kg_entities WHERE entity_id = ?\"\n ).get(entityId) as { feedback_weight: number } | undefined;\n\n if (!row) return;\n\n const newWeight = row.feedback_weight + alpha * (normalizedRating - row.feedback_weight);\n db.prepare(\n \"UPDATE kg_entities SET feedback_weight = ? WHERE entity_id = ?\"\n ).run(newWeight, entityId);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"kg-entity-DVzTsy6G.mjs","names":[],"sources":["../src/memory/kg.ts","../src/memory/kg-entity.ts"],"sourcesContent":["/**\n * Temporal Knowledge Graph — kg_triples CRUD layer.\n *\n * Uses the Postgres connection pool from the storage backend.\n * Triples are time-scoped: valid_from/valid_to enable point-in-time queries.\n * Invalidation sets valid_to = NOW() instead of deleting rows.\n */\n\nimport type { Pool } from \"pg\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgTriple {\n id: number;\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n valid_from: Date;\n valid_to?: Date;\n confidence: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n created_at: Date;\n}\n\nexport interface KgAddParams {\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n confidence?: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n}\n\nexport interface KgQueryParams {\n subject?: string;\n predicate?: string;\n object?: string;\n project_id?: number;\n as_of?: Date;\n include_invalidated?: boolean;\n}\n\nexport interface KgContradiction {\n subject: string;\n predicate: string;\n objects: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction rowToTriple(row: Record<string, unknown>): KgTriple {\n return {\n id: row.id as number,\n subject: row.subject as string,\n predicate: row.predicate as string,\n object: row.object as string,\n project_id: row.project_id as number | undefined,\n source_session: row.source_session as string | undefined,\n valid_from: new Date(row.valid_from as string),\n valid_to: row.valid_to ? new Date(row.valid_to as string) : undefined,\n confidence: row.confidence as \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\",\n created_at: new Date(row.created_at as string),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Core operations\n// ---------------------------------------------------------------------------\n\n/**\n * Add a new triple to the knowledge graph.\n * Returns the inserted triple.\n */\nexport async function kgAdd(pool: Pool, params: KgAddParams): Promise<KgTriple> {\n const confidence = params.confidence ?? \"EXTRACTED\";\n const result = await pool.query<Record<string, unknown>>(\n `INSERT INTO kg_triples\n (subject, predicate, object, project_id, source_session, confidence)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING *`,\n [\n params.subject,\n params.predicate,\n params.object,\n params.project_id ?? null,\n params.source_session ?? null,\n confidence,\n ]\n );\n return rowToTriple(result.rows[0]);\n}\n\n/**\n * Query triples by subject, predicate, object, and/or project.\n * Supports point-in-time queries via as_of.\n * By default only returns currently-valid triples (valid_to IS NULL).\n */\nexport async function kgQuery(pool: Pool, params: KgQueryParams): Promise<KgTriple[]> {\n const conditions: string[] = [];\n const values: unknown[] = [];\n let idx = 1;\n\n if (params.subject !== undefined) {\n conditions.push(`subject = $${idx++}`);\n values.push(params.subject);\n }\n if (params.predicate !== undefined) {\n conditions.push(`predicate = $${idx++}`);\n values.push(params.predicate);\n }\n if (params.object !== undefined) {\n conditions.push(`object = $${idx++}`);\n values.push(params.object);\n }\n if (params.project_id !== undefined) {\n conditions.push(`project_id = $${idx++}`);\n values.push(params.project_id);\n }\n\n if (params.as_of !== undefined) {\n // Valid at the given timestamp: started before or at as_of, and not yet ended\n conditions.push(`valid_from <= $${idx++}`);\n values.push(params.as_of);\n conditions.push(`(valid_to IS NULL OR valid_to > $${idx++})`);\n values.push(params.as_of);\n } else if (!params.include_invalidated) {\n // Default: only currently-valid (no valid_to set)\n conditions.push(`valid_to IS NULL`);\n }\n\n const where = conditions.length > 0 ? `WHERE ${conditions.join(\" AND \")}` : \"\";\n const result = await pool.query<Record<string, unknown>>(\n `SELECT * FROM kg_triples ${where} ORDER BY valid_from DESC`,\n values\n );\n return result.rows.map(rowToTriple);\n}\n\n/**\n * Invalidate a triple by setting valid_to = NOW().\n * Does not delete the row — preserves history.\n */\nexport async function kgInvalidate(pool: Pool, tripleId: number): Promise<void> {\n await pool.query(\n `UPDATE kg_triples SET valid_to = NOW() WHERE id = $1 AND valid_to IS NULL`,\n [tripleId]\n );\n}\n\n/**\n * Find contradictions: cases where the same (subject, predicate) pair has\n * multiple currently-valid objects.\n */\nexport async function kgContradictions(\n pool: Pool,\n subject: string\n): Promise<KgContradiction[]> {\n const result = await pool.query<{ subject: string; predicate: string; objects: string[] }>(\n `SELECT subject, predicate, array_agg(object ORDER BY object) AS objects\n FROM kg_triples\n WHERE subject = $1\n AND valid_to IS NULL\n GROUP BY subject, predicate\n HAVING COUNT(*) > 1`,\n [subject]\n );\n return result.rows.map((row) => ({\n subject: row.subject,\n predicate: row.predicate,\n objects: row.objects,\n }));\n}\n","/**\n * kg-entity.ts — Entity content-addressing with multi-tenant support.\n *\n * Provides UUID5-style deterministic content hashes for KG entities and edges,\n * ensuring that the same entity name always maps to the same ID within a tenant.\n * This enables idempotent upserts and stable foreign keys for kg_triples.\n *\n * Multi-tenant support: each tenant namespace gets its own entity ID space.\n * The default tenant is \"default\" for single-user deployments.\n */\n\nimport { createHash } from \"node:crypto\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgEntity {\n entity_id: string;\n tenant_id: string;\n name: string;\n type: string;\n description?: string;\n first_seen?: number;\n last_seen?: number;\n mention_count: number;\n feedback_weight: number;\n}\n\nexport interface KgEntityUpsertParams {\n name: string;\n type?: string;\n description?: string;\n tenantId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Content addressing\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a deterministic entity ID (UUID5-style) for a given name and tenant.\n *\n * The ID is a hex digest derived from \"tenant_id:name\" so the same entity\n * always receives the same ID within a tenant namespace.\n *\n * @param name Entity name (case-preserved)\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function entityContentId(name: string, tenantId = \"default\"): string {\n return createHash(\"sha256\")\n .update(`entity:${tenantId}:${name}`)\n .digest(\"hex\")\n .slice(0, 32); // 128-bit hex string — UUID5-compatible length\n}\n\n/**\n * Generate a deterministic edge ID for a (source, relation, target) triple\n * within a tenant namespace.\n *\n * @param source Source entity name\n * @param relation Relation/predicate verb phrase\n * @param target Target entity name\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function edgeContentId(\n source: string,\n relation: string,\n target: string,\n tenantId = \"default\"\n): string {\n return createHash(\"sha256\")\n .update(`edge:${tenantId}:${source}:${relation}:${target}`)\n .digest(\"hex\")\n .slice(0, 32);\n}\n\n// ---------------------------------------------------------------------------\n// SQLite entity upsert (for federation.db)\n// ---------------------------------------------------------------------------\n\n/**\n * Upsert a KG entity in the federation SQLite database.\n *\n * If the entity already exists for this tenant:\n * - Updates last_seen to now\n * - Increments mention_count\n * - Updates description if provided (overwrites older description)\n *\n * Returns the entity_id for use as a foreign key in kg_triples.\n */\nexport function upsertKgEntity(\n db: Database,\n params: KgEntityUpsertParams\n): string {\n const tenantId = params.tenantId ?? \"default\";\n const entityId = entityContentId(params.name, tenantId);\n const now = Date.now();\n\n db.prepare(`\n INSERT INTO kg_entities\n (entity_id, tenant_id, name, type, description, first_seen, last_seen, mention_count, feedback_weight)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, 1, 0.5)\n ON CONFLICT(entity_id) DO UPDATE SET\n last_seen = excluded.last_seen,\n mention_count = mention_count + 1,\n description = COALESCE(excluded.description, description),\n type = CASE WHEN excluded.type != 'unknown' THEN excluded.type ELSE type END\n `).run(\n entityId,\n tenantId,\n params.name,\n params.type ?? \"unknown\",\n params.description ?? null,\n now,\n now\n );\n\n return entityId;\n}\n\n/**\n * Look up a KG entity by name within a tenant.\n * Returns null if the entity does not exist.\n */\nexport function findKgEntity(\n db: Database,\n name: string,\n tenantId = \"default\"\n): KgEntity | null {\n const entityId = entityContentId(name, tenantId);\n const row = db.prepare(\n \"SELECT * FROM kg_entities WHERE entity_id = ? AND tenant_id = ?\"\n ).get(entityId, tenantId) as KgEntity | undefined;\n return row ?? null;\n}\n\n/**\n * List KG entities for a tenant, optionally filtered by type.\n *\n * @param db Federation SQLite database\n * @param tenantId Tenant namespace (default: \"default\")\n * @param type Optional entity type filter\n * @param limit Maximum entities to return (default: 100)\n */\nexport function listKgEntities(\n db: Database,\n tenantId = \"default\",\n type?: string,\n limit = 100\n): KgEntity[] {\n if (type) {\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? AND type = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, type, limit) as KgEntity[];\n }\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, limit) as KgEntity[];\n}\n\n// ---------------------------------------------------------------------------\n// Feedback weight update (MR2 — EMA)\n// ---------------------------------------------------------------------------\n\n/**\n * Apply an EMA (Exponential Moving Average) feedback update to an entity's weight.\n *\n * EMA formula: new_weight = old_weight + alpha * (target - old_weight)\n *\n * @param db Federation SQLite database\n * @param entityId Entity ID to update\n * @param normalizedRating Rating normalized to [0, 1] (e.g., rating/5 for 1-5 scale)\n * @param alpha EMA learning rate (default: 0.1)\n */\nexport function updateEntityFeedbackWeight(\n db: Database,\n entityId: string,\n normalizedRating: number,\n alpha = 0.1\n): void {\n const row = db.prepare(\n \"SELECT feedback_weight FROM kg_entities WHERE entity_id = ?\"\n ).get(entityId) as { feedback_weight: number } | undefined;\n\n if (!row) return;\n\n const newWeight = row.feedback_weight + alpha * (normalizedRating - row.feedback_weight);\n db.prepare(\n \"UPDATE kg_entities SET feedback_weight = ? WHERE entity_id = ?\"\n ).run(newWeight, entityId);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { _ as warn, c as ok, i as err, n as dim, r as encodeDir, s as now, t as bold } from "./utils-BAxjW3j8.mjs";
|
|
2
|
+
import { a as slugify, i as parseSessionFilename, n as decodeEncodedDir, t as buildEncodedDirMap } from "./migrate-fLD6rAdO.mjs";
|
|
3
|
+
import { n as ensurePaiMarker, t as discoverPaiMarkers } from "./pai-marker-CXQPX2P6.mjs";
|
|
4
|
+
import { i as kgAdd, o as kgInvalidate, r as upsertKgEntity, s as kgQuery } from "./kg-entity-DVzTsy6G.mjs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
//#region src/cli/commands/registry/utils.ts
|
|
10
|
+
/**
|
|
11
|
+
* Upsert a project row. Returns { id, isNew }.
|
|
12
|
+
*
|
|
13
|
+
* Matching priority:
|
|
14
|
+
* 1. root_path — most reliable; handles slug collisions
|
|
15
|
+
* 2. encoded_dir — Claude project dirs are canonical
|
|
16
|
+
* 3. Insert with suffix-deduplication on slug collision
|
|
17
|
+
*/
|
|
18
|
+
function upsertProject(db, slug, rootPath, encodedDir) {
|
|
19
|
+
const ts = now();
|
|
20
|
+
const byPath = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
|
|
21
|
+
if (byPath) {
|
|
22
|
+
const encodedOwner = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir);
|
|
23
|
+
if (!encodedOwner || encodedOwner.id === byPath.id) db.prepare("UPDATE projects SET encoded_dir = ?, updated_at = ? WHERE id = ?").run(encodedDir, ts, byPath.id);
|
|
24
|
+
return {
|
|
25
|
+
id: byPath.id,
|
|
26
|
+
isNew: false
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const byEncoded = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir);
|
|
30
|
+
if (byEncoded) {
|
|
31
|
+
const pathOwner = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
|
|
32
|
+
if (!pathOwner || pathOwner.id === byEncoded.id) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(rootPath, ts, byEncoded.id);
|
|
33
|
+
return {
|
|
34
|
+
id: byEncoded.id,
|
|
35
|
+
isNew: false
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
let finalSlug = slug;
|
|
39
|
+
let attempt = 0;
|
|
40
|
+
while (true) {
|
|
41
|
+
if (!db.prepare("SELECT id FROM projects WHERE slug = ?").get(finalSlug)) break;
|
|
42
|
+
attempt++;
|
|
43
|
+
finalSlug = `${slug}-${attempt}`;
|
|
44
|
+
}
|
|
45
|
+
const result = db.prepare(`INSERT OR IGNORE INTO projects
|
|
46
|
+
(slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
|
|
47
|
+
VALUES (?, ?, ?, ?, 'local', 'active', ?, ?)`).run(finalSlug, finalSlug, rootPath, encodedDir, ts, ts);
|
|
48
|
+
if (result.changes === 0) {
|
|
49
|
+
const fallback = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir) ?? db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
|
|
50
|
+
if (fallback) return {
|
|
51
|
+
id: fallback.id,
|
|
52
|
+
isNew: false
|
|
53
|
+
};
|
|
54
|
+
throw new Error(`upsertProject: INSERT OR IGNORE was suppressed but no matching row found for root_path=${rootPath} encoded_dir=${encodedDir}`);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
id: result.lastInsertRowid,
|
|
58
|
+
isNew: true
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Upsert a session note. Returns true if newly inserted. */
|
|
62
|
+
function upsertSession(db, projectId, number, date, slug, title, filename) {
|
|
63
|
+
if (db.prepare("SELECT id FROM sessions WHERE project_id = ? AND number = ?").get(projectId, number)) return false;
|
|
64
|
+
const ts = now();
|
|
65
|
+
db.prepare(`INSERT INTO sessions
|
|
66
|
+
(project_id, number, date, slug, title, filename, status, created_at)
|
|
67
|
+
VALUES (?, ?, ?, ?, ?, ?, 'completed', ?)`).run(projectId, number, date, slug, title, filename, ts);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/cli/commands/registry/scan.ts
|
|
73
|
+
/** Registry scan command: walk ~/.claude/projects/ and populate the registry. */
|
|
74
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
75
|
+
const PAI_CONFIG_DIR = join(homedir(), ".pai");
|
|
76
|
+
const PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, "config.json");
|
|
77
|
+
function loadScanConfig() {
|
|
78
|
+
if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(PAI_CONFIG_FILE, "utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
return { scan_dirs: [] };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function saveScanConfig(config) {
|
|
86
|
+
mkdirSync(PAI_CONFIG_DIR, { recursive: true });
|
|
87
|
+
writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
88
|
+
}
|
|
89
|
+
function resolveHome(p) {
|
|
90
|
+
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
91
|
+
return resolve(p);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Recursively find all .md files in a directory, including YYYY/MM subdirectories.
|
|
95
|
+
* Returns filenames (basename only).
|
|
96
|
+
*/
|
|
97
|
+
function findNoteFiles(dir) {
|
|
98
|
+
const results = [];
|
|
99
|
+
if (!existsSync(dir)) return results;
|
|
100
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".md")) results.push(entry.name);
|
|
101
|
+
else if (entry.isDirectory() && /^\d{4}$/.test(entry.name)) {
|
|
102
|
+
const yearDir = join(dir, entry.name);
|
|
103
|
+
for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) if (monthEntry.isDirectory() && /^\d{2}$/.test(monthEntry.name)) {
|
|
104
|
+
const monthDir = join(yearDir, monthEntry.name);
|
|
105
|
+
for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) if (noteEntry.isFile() && noteEntry.name.endsWith(".md")) results.push(noteEntry.name);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
function performScan(db) {
|
|
111
|
+
const result = {
|
|
112
|
+
projectsScanned: 0,
|
|
113
|
+
projectsNew: 0,
|
|
114
|
+
projectsUpdated: 0,
|
|
115
|
+
sessionsScanned: 0,
|
|
116
|
+
sessionsNew: 0,
|
|
117
|
+
skipped: []
|
|
118
|
+
};
|
|
119
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR)) throw new Error(`Claude projects directory not found: ${CLAUDE_PROJECTS_DIR}`);
|
|
120
|
+
const entries = readdirSync(CLAUDE_PROJECTS_DIR).filter((name) => {
|
|
121
|
+
return statSync(join(CLAUDE_PROJECTS_DIR, name)).isDirectory();
|
|
122
|
+
});
|
|
123
|
+
const lookupMap = buildEncodedDirMap();
|
|
124
|
+
for (const encodedDir of entries) {
|
|
125
|
+
const rootPath = decodeEncodedDir(encodedDir, lookupMap);
|
|
126
|
+
if (!existsSync(rootPath)) {
|
|
127
|
+
result.skipped.push(`${encodedDir} (decoded: ${rootPath} — path not found on disk)`);
|
|
128
|
+
result.projectsScanned++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const slug = slugify(basename(rootPath) || encodedDir);
|
|
132
|
+
const { id, isNew } = upsertProject(db, slug, rootPath, encodedDir);
|
|
133
|
+
result.projectsScanned++;
|
|
134
|
+
if (isNew) result.projectsNew++;
|
|
135
|
+
else result.projectsUpdated++;
|
|
136
|
+
try {
|
|
137
|
+
ensurePaiMarker(rootPath, slug);
|
|
138
|
+
} catch {}
|
|
139
|
+
const claudeNotesDir = join(CLAUDE_PROJECTS_DIR, encodedDir, "Notes");
|
|
140
|
+
if (existsSync(claudeNotesDir)) {
|
|
141
|
+
if (claudeNotesDir !== join(rootPath, "Notes")) db.prepare("UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?").run(claudeNotesDir, Date.now(), id);
|
|
142
|
+
}
|
|
143
|
+
if (!existsSync(claudeNotesDir)) continue;
|
|
144
|
+
const noteFiles = findNoteFiles(claudeNotesDir);
|
|
145
|
+
for (const filename of noteFiles) {
|
|
146
|
+
const parsed = parseSessionFilename(filename);
|
|
147
|
+
if (!parsed) continue;
|
|
148
|
+
result.sessionsScanned++;
|
|
149
|
+
if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
{
|
|
153
|
+
const activeProjects = db.prepare("SELECT id, slug, root_path FROM projects WHERE status = 'active'").all();
|
|
154
|
+
for (const project of activeProjects) {
|
|
155
|
+
const notesDir = join(project.root_path, "Notes");
|
|
156
|
+
if (!existsSync(notesDir)) continue;
|
|
157
|
+
let files;
|
|
158
|
+
try {
|
|
159
|
+
files = findNoteFiles(notesDir);
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const filename of files) {
|
|
164
|
+
const parsed = parseSessionFilename(filename);
|
|
165
|
+
if (!parsed) continue;
|
|
166
|
+
result.sessionsScanned++;
|
|
167
|
+
if (upsertSession(db, project.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const config = loadScanConfig();
|
|
172
|
+
if (config.scan_dirs.length) for (const rawDir of config.scan_dirs) {
|
|
173
|
+
const scanDir = resolveHome(rawDir);
|
|
174
|
+
if (!existsSync(scanDir)) {
|
|
175
|
+
result.skipped.push(`${rawDir} (configured scan_dir not found)`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const children = readdirSync(scanDir).filter((name) => {
|
|
179
|
+
if (name.startsWith(".")) return false;
|
|
180
|
+
const full = join(scanDir, name);
|
|
181
|
+
try {
|
|
182
|
+
return statSync(full).isDirectory();
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
for (const child of children) {
|
|
188
|
+
const childPath = join(scanDir, child);
|
|
189
|
+
const childSlug = slugify(child);
|
|
190
|
+
const childEncoded = encodeDir(childPath);
|
|
191
|
+
const existing = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(childPath);
|
|
192
|
+
if (existing) {
|
|
193
|
+
result.projectsScanned++;
|
|
194
|
+
result.projectsUpdated++;
|
|
195
|
+
try {
|
|
196
|
+
ensurePaiMarker(childPath, childSlug);
|
|
197
|
+
} catch {}
|
|
198
|
+
const notesDir = join(childPath, "Notes");
|
|
199
|
+
if (existsSync(notesDir)) {
|
|
200
|
+
const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(".md"));
|
|
201
|
+
for (const filename of noteFiles) {
|
|
202
|
+
const parsed = parseSessionFilename(filename);
|
|
203
|
+
if (!parsed) continue;
|
|
204
|
+
result.sessionsScanned++;
|
|
205
|
+
if (upsertSession(db, existing.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const { id, isNew } = upsertProject(db, childSlug, childPath, childEncoded);
|
|
211
|
+
result.projectsScanned++;
|
|
212
|
+
if (isNew) result.projectsNew++;
|
|
213
|
+
else result.projectsUpdated++;
|
|
214
|
+
try {
|
|
215
|
+
ensurePaiMarker(childPath, childSlug);
|
|
216
|
+
} catch {}
|
|
217
|
+
const notesDir = join(childPath, "Notes");
|
|
218
|
+
if (existsSync(notesDir)) {
|
|
219
|
+
const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(".md"));
|
|
220
|
+
for (const filename of noteFiles) {
|
|
221
|
+
const parsed = parseSessionFilename(filename);
|
|
222
|
+
if (!parsed) continue;
|
|
223
|
+
result.sessionsScanned++;
|
|
224
|
+
if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (config.scan_dirs.length) {
|
|
230
|
+
const markers = discoverPaiMarkers(config.scan_dirs.map(resolveHome).filter(existsSync));
|
|
231
|
+
for (const marker of markers) {
|
|
232
|
+
const registeredRow = db.prepare("SELECT id, root_path, slug FROM projects WHERE slug = ?").get(marker.slug);
|
|
233
|
+
if (!registeredRow) continue;
|
|
234
|
+
if (registeredRow.root_path !== marker.projectRoot) {
|
|
235
|
+
const newEncoded = encodeDir(marker.projectRoot);
|
|
236
|
+
const now4 = Date.now();
|
|
237
|
+
const encodedOwner = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(newEncoded);
|
|
238
|
+
const pathOwner = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(marker.projectRoot);
|
|
239
|
+
const encodedSafe = !encodedOwner || encodedOwner.id === registeredRow.id;
|
|
240
|
+
const pathSafe = !pathOwner || pathOwner.id === registeredRow.id;
|
|
241
|
+
if (encodedSafe && pathSafe) db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(marker.projectRoot, newEncoded, now4, registeredRow.id);
|
|
242
|
+
else if (pathSafe) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(marker.projectRoot, now4, registeredRow.id);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Run the registry scan CLI command.
|
|
250
|
+
*
|
|
251
|
+
* @param opts.quick When true, skips verbose output (same scan, less noise).
|
|
252
|
+
* The underlying scan is always incremental via upsert —
|
|
253
|
+
* this flag exists for hook/daemon-triggered invocations that
|
|
254
|
+
* want minimal log output.
|
|
255
|
+
*/
|
|
256
|
+
function cmdScan(db, opts = {}) {
|
|
257
|
+
const config = loadScanConfig();
|
|
258
|
+
if (!opts.quick) {
|
|
259
|
+
console.log(dim("Scanning ~/.claude/projects/ ..."));
|
|
260
|
+
if (config.scan_dirs.length) console.log(dim(`Scanning ${config.scan_dirs.length} extra dir(s): ${config.scan_dirs.join(", ")}`));
|
|
261
|
+
console.log(dim("Scanning project-root Notes/ directories ..."));
|
|
262
|
+
}
|
|
263
|
+
let result;
|
|
264
|
+
try {
|
|
265
|
+
result = performScan(db);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error(err(String(e)));
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
if (!opts.quick) {
|
|
271
|
+
console.log(ok(`Scanned ${bold(String(result.projectsScanned))} projects, ${bold(String(result.sessionsScanned))} session notes.`));
|
|
272
|
+
console.log(dim(` Projects: ${result.projectsNew} new, ${result.projectsUpdated} updated`));
|
|
273
|
+
console.log(dim(` Sessions: ${result.sessionsNew} new`));
|
|
274
|
+
if (result.skipped.length) {
|
|
275
|
+
console.log();
|
|
276
|
+
console.log(warn(` ${result.skipped.length} project(s) skipped (path not found on disk):`));
|
|
277
|
+
for (const s of result.skipped.slice(0, 10)) console.log(dim(` ${s}`));
|
|
278
|
+
if (result.skipped.length > 10) console.log(dim(` ... and ${result.skipped.length - 10} more`));
|
|
279
|
+
}
|
|
280
|
+
} else process.stderr.write(`[registry-scan] ${result.projectsScanned} projects, ${result.sessionsScanned} sessions (${result.projectsNew}+${result.sessionsNew} new).\n`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/daemon/templates/triple-extraction-prompt.ts
|
|
285
|
+
/**
|
|
286
|
+
* triple-extraction-prompt.ts — Prompt template for KG triple extraction.
|
|
287
|
+
*
|
|
288
|
+
* Used by the session-summary-worker to extract structured facts from
|
|
289
|
+
* a completed session summary and store them in the temporal knowledge graph.
|
|
290
|
+
*/
|
|
291
|
+
function buildTripleExtractionPrompt(params) {
|
|
292
|
+
return `Extract structured entities and relations from this coding session.
|
|
293
|
+
|
|
294
|
+
Output a single JSON object with two arrays: "entities" and "relations".
|
|
295
|
+
|
|
296
|
+
Entity types: project | person | concept | tool | file | version | decision | technology | organization
|
|
297
|
+
|
|
298
|
+
Rules:
|
|
299
|
+
- Be SPECIFIC: entity names must be concrete (e.g., "FSRS", "Glidr", "Matthias")
|
|
300
|
+
- Use snake_case relation verb phrases (e.g., "uses_algorithm", "decided_to", "shipped_version")
|
|
301
|
+
- Skip opinions, speculation, and "we should" statements
|
|
302
|
+
- Skip entities obvious from project metadata unless they have a meaningful relation
|
|
303
|
+
- Maximum 15 relations per session — pick the most important
|
|
304
|
+
- Each entity should have a brief description (1 sentence, what it is in this context)
|
|
305
|
+
- Each relation must reference entity names that appear in the entities array
|
|
306
|
+
|
|
307
|
+
Example output:
|
|
308
|
+
{
|
|
309
|
+
"entities": [
|
|
310
|
+
{"name": "Glidr", "type": "project", "description": "Flashcard app using FSRS spaced repetition"},
|
|
311
|
+
{"name": "FSRS", "type": "concept", "description": "Free Spaced Repetition Scheduler algorithm"},
|
|
312
|
+
{"name": "Matthias", "type": "person", "description": "Developer of Glidr and Quassl"},
|
|
313
|
+
{"name": "Quassl", "type": "project", "description": "iOS app being rewritten in Flutter"},
|
|
314
|
+
{"name": "Flutter", "type": "technology", "description": "Cross-platform mobile framework"}
|
|
315
|
+
],
|
|
316
|
+
"relations": [
|
|
317
|
+
{"source": "Glidr", "relation": "uses_algorithm", "target": "FSRS"},
|
|
318
|
+
{"source": "Glidr", "relation": "shipped_version", "target": "1.0.5"},
|
|
319
|
+
{"source": "Matthias", "relation": "decided_to_rewrite", "target": "Quassl"},
|
|
320
|
+
{"source": "Quassl", "relation": "migrating_to", "target": "Flutter"}
|
|
321
|
+
]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
PROJECT: ${params.projectSlug}
|
|
325
|
+
|
|
326
|
+
SESSION CONTENT:
|
|
327
|
+
${params.sessionContent}
|
|
328
|
+
|
|
329
|
+
GIT COMMITS:
|
|
330
|
+
${params.gitLog}
|
|
331
|
+
|
|
332
|
+
JSON object (entities + relations):`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/memory/kg-extraction.ts
|
|
337
|
+
/**
|
|
338
|
+
* kg-extraction.ts — Shared KG triple extraction logic.
|
|
339
|
+
*
|
|
340
|
+
* Extracted from session-summary-worker.ts so both the worker and the
|
|
341
|
+
* CLI backfill (`pai kg backfill`) can use the same code path.
|
|
342
|
+
*
|
|
343
|
+
* Provides:
|
|
344
|
+
* - findClaudeBinary() — locate the claude CLI
|
|
345
|
+
* - spawnClaude() — generic prompt -> response runner (strips ANTHROPIC_API_KEY)
|
|
346
|
+
* - extractAndStoreTriples() — run the extractor prompt and persist triples to Postgres
|
|
347
|
+
*/
|
|
348
|
+
/**
|
|
349
|
+
* Find the `claude` CLI binary. Checks common installation locations first
|
|
350
|
+
* (launchd PATH is minimal so bare "claude" often won't resolve).
|
|
351
|
+
*/
|
|
352
|
+
function findClaudeBinary() {
|
|
353
|
+
const candidates = [
|
|
354
|
+
join(homedir(), ".local", "bin", "claude"),
|
|
355
|
+
join(homedir(), ".claude", "local", "claude"),
|
|
356
|
+
"/usr/local/bin/claude",
|
|
357
|
+
"/opt/homebrew/bin/claude"
|
|
358
|
+
];
|
|
359
|
+
for (const candidate of candidates) try {
|
|
360
|
+
if (existsSync(candidate)) return candidate;
|
|
361
|
+
} catch {}
|
|
362
|
+
return "claude";
|
|
363
|
+
}
|
|
364
|
+
const CLAUDE_TIMEOUT_MS = {
|
|
365
|
+
haiku: 6e4,
|
|
366
|
+
sonnet: 12e4,
|
|
367
|
+
opus: 3e5
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* Spawn the claude CLI with a prompt on stdin and return stdout.
|
|
371
|
+
*
|
|
372
|
+
* IMPORTANT: ANTHROPIC_API_KEY is stripped from the spawned environment so
|
|
373
|
+
* the CLI uses the user's Max plan (free) instead of billing the API key.
|
|
374
|
+
*/
|
|
375
|
+
async function spawnClaude(prompt, model = "sonnet") {
|
|
376
|
+
const claudeBin = findClaudeBinary();
|
|
377
|
+
if (!claudeBin) {
|
|
378
|
+
process.stderr.write("[kg-extraction] claude CLI not found.\n");
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
const { spawn } = await import("node:child_process");
|
|
382
|
+
return new Promise((resolve) => {
|
|
383
|
+
let timer = null;
|
|
384
|
+
const { ANTHROPIC_API_KEY: _drop, ...envWithoutApiKey } = process.env;
|
|
385
|
+
const child = spawn(claudeBin, [
|
|
386
|
+
"--model",
|
|
387
|
+
model,
|
|
388
|
+
"-p",
|
|
389
|
+
"--no-session-persistence"
|
|
390
|
+
], {
|
|
391
|
+
env: envWithoutApiKey,
|
|
392
|
+
stdio: [
|
|
393
|
+
"pipe",
|
|
394
|
+
"pipe",
|
|
395
|
+
"pipe"
|
|
396
|
+
]
|
|
397
|
+
});
|
|
398
|
+
let stdout = "";
|
|
399
|
+
let stderr = "";
|
|
400
|
+
child.stdout.on("data", (chunk) => {
|
|
401
|
+
stdout += chunk.toString();
|
|
402
|
+
});
|
|
403
|
+
child.stderr.on("data", (chunk) => {
|
|
404
|
+
stderr += chunk.toString();
|
|
405
|
+
});
|
|
406
|
+
child.on("error", (err) => {
|
|
407
|
+
if (timer) {
|
|
408
|
+
clearTimeout(timer);
|
|
409
|
+
timer = null;
|
|
410
|
+
}
|
|
411
|
+
process.stderr.write(`[kg-extraction] ${model} spawn error: ${err.message}\n`);
|
|
412
|
+
resolve(null);
|
|
413
|
+
});
|
|
414
|
+
child.on("close", (code) => {
|
|
415
|
+
if (timer) {
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
timer = null;
|
|
418
|
+
}
|
|
419
|
+
if (code !== 0) {
|
|
420
|
+
process.stderr.write(`[kg-extraction] ${model} exited ${code}: ${stderr.slice(0, 300)}\n`);
|
|
421
|
+
resolve(null);
|
|
422
|
+
} else resolve(stdout.trim() || null);
|
|
423
|
+
});
|
|
424
|
+
timer = setTimeout(() => {
|
|
425
|
+
process.stderr.write(`[kg-extraction] ${model} timed out — killing process.\n`);
|
|
426
|
+
child.kill("SIGTERM");
|
|
427
|
+
resolve(null);
|
|
428
|
+
}, CLAUDE_TIMEOUT_MS[model] ?? 12e4);
|
|
429
|
+
child.stdin.write(prompt);
|
|
430
|
+
child.stdin.end();
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Extract structured KG triples from a session summary and store them in
|
|
435
|
+
* Postgres. Idempotent: if a (subject, predicate) pair already has the same
|
|
436
|
+
* object, no new row is added; if the object differs, the old triple is
|
|
437
|
+
* invalidated (valid_to = NOW()) and a new one is inserted.
|
|
438
|
+
*
|
|
439
|
+
* Best-effort: per-triple errors are caught and logged but never thrown.
|
|
440
|
+
* Returns a small stats object so callers can report progress.
|
|
441
|
+
*/
|
|
442
|
+
async function extractAndStoreTriples(pool, params) {
|
|
443
|
+
const stats = {
|
|
444
|
+
extracted: 0,
|
|
445
|
+
added: 0,
|
|
446
|
+
superseded: 0
|
|
447
|
+
};
|
|
448
|
+
const jsonOutput = await spawnClaude(buildTripleExtractionPrompt({
|
|
449
|
+
sessionContent: params.summaryText,
|
|
450
|
+
projectSlug: params.projectSlug,
|
|
451
|
+
gitLog: params.gitLog ?? ""
|
|
452
|
+
}), params.model ?? "sonnet");
|
|
453
|
+
if (!jsonOutput) return stats;
|
|
454
|
+
const cleaned = jsonOutput.replace(/^```json\s*/m, "").replace(/^```\s*/m, "").replace(/\s*```$/m, "").trim();
|
|
455
|
+
let triples;
|
|
456
|
+
try {
|
|
457
|
+
const parsed = JSON.parse(cleaned);
|
|
458
|
+
if (Array.isArray(parsed)) triples = parsed;
|
|
459
|
+
else if (parsed && typeof parsed === "object" && Array.isArray(parsed.relations)) {
|
|
460
|
+
const newFmt = parsed;
|
|
461
|
+
if (params.federationDb && Array.isArray(newFmt.entities)) {
|
|
462
|
+
const tenantId = params.tenantId ?? "default";
|
|
463
|
+
for (const entity of newFmt.entities) {
|
|
464
|
+
if (!entity.name) continue;
|
|
465
|
+
try {
|
|
466
|
+
upsertKgEntity(params.federationDb, {
|
|
467
|
+
name: entity.name,
|
|
468
|
+
type: entity.type ?? "unknown",
|
|
469
|
+
description: entity.description,
|
|
470
|
+
tenantId
|
|
471
|
+
});
|
|
472
|
+
} catch (entityErr) {
|
|
473
|
+
process.stderr.write(`[kg-extraction] entity upsert error (${entity.name}): ${entityErr}\n`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
triples = newFmt.relations.map((r) => ({
|
|
478
|
+
subject: r.source,
|
|
479
|
+
predicate: r.relation,
|
|
480
|
+
object: r.target
|
|
481
|
+
}));
|
|
482
|
+
} else {
|
|
483
|
+
process.stderr.write(`[kg-extraction] Unexpected JSON shape — neither array nor {entities,relations}\n`);
|
|
484
|
+
return stats;
|
|
485
|
+
}
|
|
486
|
+
} catch (e) {
|
|
487
|
+
process.stderr.write(`[kg-extraction] JSON parse failed: ${e}\n`);
|
|
488
|
+
return stats;
|
|
489
|
+
}
|
|
490
|
+
if (!Array.isArray(triples)) return stats;
|
|
491
|
+
stats.extracted = triples.length;
|
|
492
|
+
for (const t of triples) {
|
|
493
|
+
if (!t.subject || !t.predicate || !t.object) continue;
|
|
494
|
+
try {
|
|
495
|
+
const existing = await kgQuery(pool, {
|
|
496
|
+
subject: t.subject,
|
|
497
|
+
predicate: t.predicate,
|
|
498
|
+
project_id: params.projectId ?? void 0
|
|
499
|
+
});
|
|
500
|
+
if (existing.find((e) => e.object === t.object && !e.valid_to)) continue;
|
|
501
|
+
const supersedes = existing.find((e) => e.object !== t.object && !e.valid_to);
|
|
502
|
+
if (supersedes) {
|
|
503
|
+
await kgInvalidate(pool, supersedes.id);
|
|
504
|
+
stats.superseded++;
|
|
505
|
+
}
|
|
506
|
+
await kgAdd(pool, {
|
|
507
|
+
subject: t.subject,
|
|
508
|
+
predicate: t.predicate,
|
|
509
|
+
object: t.object,
|
|
510
|
+
project_id: params.projectId ?? void 0,
|
|
511
|
+
source_session: params.sessionId,
|
|
512
|
+
confidence: "EXTRACTED"
|
|
513
|
+
});
|
|
514
|
+
stats.added++;
|
|
515
|
+
} catch (tripleErr) {
|
|
516
|
+
process.stderr.write(`[kg-extraction] store error (${t.subject}): ${tripleErr}\n`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return stats;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
//#endregion
|
|
523
|
+
export { resolveHome as a, upsertSession as c, performScan as i, cmdScan as n, saveScanConfig as o, loadScanConfig as r, upsertProject as s, extractAndStoreTriples as t };
|
|
524
|
+
//# sourceMappingURL=kg-extraction-r5KkTmWY.mjs.map
|