@tekmidian/pai 0.4.0 → 0.5.1

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.
Files changed (48) hide show
  1. package/ARCHITECTURE.md +16 -10
  2. package/README.md +74 -6
  3. package/dist/{auto-route-JjW3f7pV.mjs → auto-route-BG6I_4B1.mjs} +3 -3
  4. package/dist/{auto-route-JjW3f7pV.mjs.map → auto-route-BG6I_4B1.mjs.map} +1 -1
  5. package/dist/cli/index.mjs +121 -24
  6. package/dist/cli/index.mjs.map +1 -1
  7. package/dist/{config-DELNqq3Z.mjs → config-Cf92lGX_.mjs} +17 -3
  8. package/dist/config-Cf92lGX_.mjs.map +1 -0
  9. package/dist/daemon/index.mjs +7 -7
  10. package/dist/{daemon-CeTX4NpF.mjs → daemon-a1W4KgFq.mjs} +12 -12
  11. package/dist/{daemon-CeTX4NpF.mjs.map → daemon-a1W4KgFq.mjs.map} +1 -1
  12. package/dist/daemon-mcp/index.mjs +13 -4
  13. package/dist/daemon-mcp/index.mjs.map +1 -1
  14. package/dist/{detect-D7gPV3fQ.mjs → detect-BU3Nx_2L.mjs} +1 -1
  15. package/dist/{detect-D7gPV3fQ.mjs.map → detect-BU3Nx_2L.mjs.map} +1 -1
  16. package/dist/{detector-cYYhK2Mi.mjs → detector-Bp-2SM3x.mjs} +2 -2
  17. package/dist/{detector-cYYhK2Mi.mjs.map → detector-Bp-2SM3x.mjs.map} +1 -1
  18. package/dist/{factory-DZLvRf4m.mjs → factory-CeXQzlwn.mjs} +3 -3
  19. package/dist/{factory-DZLvRf4m.mjs.map → factory-CeXQzlwn.mjs.map} +1 -1
  20. package/dist/index.d.mts +29 -1
  21. package/dist/index.d.mts.map +1 -1
  22. package/dist/index.mjs +5 -3
  23. package/dist/{indexer-backend-BHztlJJg.mjs → indexer-backend-DQO-FqAI.mjs} +1 -1
  24. package/dist/{indexer-backend-BHztlJJg.mjs.map → indexer-backend-DQO-FqAI.mjs.map} +1 -1
  25. package/dist/{ipc-client-CLt2fNlC.mjs → ipc-client-Bjg_a1dc.mjs} +1 -1
  26. package/dist/{ipc-client-CLt2fNlC.mjs.map → ipc-client-Bjg_a1dc.mjs.map} +1 -1
  27. package/dist/mcp/index.mjs +19 -5
  28. package/dist/mcp/index.mjs.map +1 -1
  29. package/dist/{postgres-CRBe30Ag.mjs → postgres-CIxeqf_n.mjs} +1 -1
  30. package/dist/{postgres-CRBe30Ag.mjs.map → postgres-CIxeqf_n.mjs.map} +1 -1
  31. package/dist/reranker-D7bRAHi6.mjs +71 -0
  32. package/dist/reranker-D7bRAHi6.mjs.map +1 -0
  33. package/dist/{schemas-BY3Pjvje.mjs → schemas-BFIgGntb.mjs} +1 -1
  34. package/dist/{schemas-BY3Pjvje.mjs.map → schemas-BFIgGntb.mjs.map} +1 -1
  35. package/dist/{search-GK0ibTJy.mjs → search-_oHfguA5.mjs} +47 -4
  36. package/dist/search-_oHfguA5.mjs.map +1 -0
  37. package/dist/{sqlite-RyR8Up1v.mjs → sqlite-CymLKiDE.mjs} +2 -2
  38. package/dist/{sqlite-RyR8Up1v.mjs.map → sqlite-CymLKiDE.mjs.map} +1 -1
  39. package/dist/{tools-CUg0Lyg-.mjs → tools-DV_lsiCc.mjs} +29 -18
  40. package/dist/tools-DV_lsiCc.mjs.map +1 -0
  41. package/dist/{vault-indexer-Bo2aPSzP.mjs → vault-indexer-DXWs9pDn.mjs} +1 -1
  42. package/dist/{vault-indexer-Bo2aPSzP.mjs.map → vault-indexer-DXWs9pDn.mjs.map} +1 -1
  43. package/dist/{zettelkasten-Co-w0XSZ.mjs → zettelkasten-e-a4rW_6.mjs} +2 -2
  44. package/dist/{zettelkasten-Co-w0XSZ.mjs.map → zettelkasten-e-a4rW_6.mjs.map} +1 -1
  45. package/package.json +1 -1
  46. package/dist/config-DELNqq3Z.mjs.map +0 -1
  47. package/dist/search-GK0ibTJy.mjs.map +0 -1
  48. package/dist/tools-CUg0Lyg-.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["z\n .string","z\n .boolean","z\n .array","z.enum","z\n .number","z\n .enum","z.string","z\n .record","z.unknown"],"sources":["../../src/mcp/server.ts","../../src/mcp/index.ts"],"sourcesContent":["/**\n * PAI Knowledge OS — MCP Server (Phase 3)\n *\n * Exposes PAI registry and memory as MCP tools callable by Claude Code.\n *\n * Tools:\n * memory_search — BM25 search across indexed memory/notes\n * memory_get — Read a specific file or lines from a project\n * project_info — Get details for a project (by slug or current dir)\n * project_list — List projects with optional filters\n * session_list — List sessions for a project\n * registry_search — Full-text search over project slugs/names/paths\n * project_detect — Detect which project a path belongs to\n * project_health — Audit all projects for moved/deleted directories\n * session_route — Auto-route session to project (path/marker/topic)\n *\n * NOTE: All tool logic lives in tools.ts (shared with the daemon).\n * This file wires MCP schema definitions to those pure functions.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { z } from \"zod\";\nimport { openRegistry } from \"../registry/db.js\";\nimport { openFederation } from \"../memory/db.js\";\nimport {\n toolMemorySearch,\n toolMemoryGet,\n toolProjectInfo,\n toolProjectList,\n toolSessionList,\n toolRegistrySearch,\n toolProjectDetect,\n toolProjectHealth,\n toolNotificationConfig,\n toolTopicDetect,\n toolSessionRoute,\n toolZettelExplore,\n toolZettelHealth,\n toolZettelSurprise,\n toolZettelSuggest,\n toolZettelConverse,\n toolZettelThemes,\n} from \"./tools.js\";\n\n// ---------------------------------------------------------------------------\n// Database singletons (opened lazily, once per MCP server process)\n// ---------------------------------------------------------------------------\n\nlet _registryDb: ReturnType<typeof openRegistry> | null = null;\nlet _federationDb: ReturnType<typeof openFederation> | null = null;\n\nfunction getRegistryDb() {\n if (!_registryDb) _registryDb = openRegistry();\n return _registryDb;\n}\n\nfunction getFederationDb() {\n if (!_federationDb) _federationDb = openFederation();\n return _federationDb;\n}\n\n// ---------------------------------------------------------------------------\n// MCP server startup\n// ---------------------------------------------------------------------------\n\nexport async function startMcpServer(): Promise<void> {\n const server = new McpServer({\n name: \"pai\",\n version: \"0.1.0\",\n });\n\n // -------------------------------------------------------------------------\n // Tool: memory_search\n // -------------------------------------------------------------------------\n\n server.tool(\n \"memory_search\",\n [\n \"Search PAI federated memory using BM25 full-text ranking, semantic similarity, or a hybrid of both.\",\n \"\",\n \"Use this BEFORE answering questions about past work, decisions, dates, people,\",\n \"preferences, project status, todos, technical choices, or anything that might\",\n \"have been recorded in session notes or memory files.\",\n \"\",\n \"Modes:\",\n \" keyword — BM25 full-text search (default, fast, no embeddings required)\",\n \" semantic — Cosine similarity over vector embeddings (requires prior embed run)\",\n \" hybrid — Normalized combination of BM25 + cosine (best quality)\",\n \"\",\n \"Returns ranked snippets with project slug, file path, line range, and score.\",\n \"Higher score = more relevant.\",\n ].join(\"\\n\"),\n {\n query: z\n .string()\n .describe(\"Free-text search query. Multiple words are ORed together — any matching word returns a result, ranked by relevance.\"),\n project: z\n .string()\n .optional()\n .describe(\n \"Scope search to a single project by slug. Omit to search all projects.\"\n ),\n all_projects: z\n .boolean()\n .optional()\n .describe(\n \"Explicitly search all projects (default behaviour when project is omitted).\"\n ),\n sources: z\n .array(z.enum([\"memory\", \"notes\"]))\n .optional()\n .describe(\"Restrict to specific source types: 'memory' or 'notes'.\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .describe(\"Maximum results to return. Default: 10.\"),\n mode: z\n .enum([\"keyword\", \"semantic\", \"hybrid\"])\n .optional()\n .describe(\n \"Search mode: 'keyword' (BM25, default), 'semantic' (vector cosine), or 'hybrid' (both combined).\"\n ),\n },\n async (args) => {\n const result = await toolMemorySearch(\n getRegistryDb(),\n getFederationDb(),\n args\n );\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: memory_get\n // -------------------------------------------------------------------------\n\n server.tool(\n \"memory_get\",\n [\n \"Read the content of a specific file from a registered PAI project.\",\n \"\",\n \"Use this to read a full memory file, session note, or document after finding\",\n \"it via memory_search. Optionally restrict to a line range.\",\n \"\",\n \"The path must be a relative path within the project root (no ../ traversal).\",\n ].join(\"\\n\"),\n {\n project: z\n .string()\n .describe(\"Project slug identifying which project's files to read from.\"),\n path: z\n .string()\n .describe(\n \"Relative path within the project root (e.g. 'Notes/0001 - 2026-01-01 - Example.md').\"\n ),\n from: z\n .number()\n .int()\n .min(1)\n .optional()\n .describe(\"Starting line number (1-based, inclusive). Default: 1.\"),\n lines: z\n .number()\n .int()\n .min(1)\n .optional()\n .describe(\"Number of lines to return. Default: entire file.\"),\n },\n async (args) => {\n const result = toolMemoryGet(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: project_info\n // -------------------------------------------------------------------------\n\n server.tool(\n \"project_info\",\n [\n \"Get detailed information about a PAI registered project.\",\n \"\",\n \"Use this to look up a project's root path, type, status, tags, session count,\",\n \"and last active date. If no slug is provided, attempts to detect the current\",\n \"project from the caller's working directory.\",\n ].join(\"\\n\"),\n {\n slug: z\n .string()\n .optional()\n .describe(\n \"Project slug. Omit to auto-detect from the current working directory.\"\n ),\n },\n async (args) => {\n const result = toolProjectInfo(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: project_list\n // -------------------------------------------------------------------------\n\n server.tool(\n \"project_list\",\n [\n \"List registered PAI projects with optional filters.\",\n \"\",\n \"Use this to browse all known projects, find projects by status or tag,\",\n \"or get a quick overview of the PAI registry.\",\n ].join(\"\\n\"),\n {\n status: z\n .enum([\"active\", \"archived\", \"migrating\"])\n .optional()\n .describe(\"Filter by project status. Default: all statuses.\"),\n tag: z\n .string()\n .optional()\n .describe(\"Filter by tag name (exact match).\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(500)\n .optional()\n .describe(\"Maximum number of projects to return. Default: 50.\"),\n },\n async (args) => {\n const result = toolProjectList(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: session_list\n // -------------------------------------------------------------------------\n\n server.tool(\n \"session_list\",\n [\n \"List session notes for a PAI project.\",\n \"\",\n \"Use this to find what sessions exist for a project, see their dates and titles,\",\n \"and identify specific session notes to read via memory_get.\",\n ].join(\"\\n\"),\n {\n project: z.string().describe(\"Project slug to list sessions for.\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(500)\n .optional()\n .describe(\"Maximum sessions to return. Default: 10 (most recent first).\"),\n status: z\n .enum([\"open\", \"completed\", \"compacted\"])\n .optional()\n .describe(\"Filter by session status.\"),\n },\n async (args) => {\n const result = toolSessionList(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: registry_search\n // -------------------------------------------------------------------------\n\n server.tool(\n \"registry_search\",\n [\n \"Search PAI project registry by slug, display name, or path.\",\n \"\",\n \"Use this to find the slug for a project when you know its name or path,\",\n \"or to check if a project is registered. Returns matching project entries.\",\n ].join(\"\\n\"),\n {\n query: z\n .string()\n .describe(\n \"Search term matched against project slugs, display names, and root paths (case-insensitive substring match).\"\n ),\n },\n async (args) => {\n const result = toolRegistrySearch(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: project_detect\n // -------------------------------------------------------------------------\n\n server.tool(\n \"project_detect\",\n [\n \"Detect which registered PAI project a filesystem path belongs to.\",\n \"\",\n \"Use this at session start to auto-identify the current project from the\",\n \"working directory, or to map any path back to its registered project.\",\n \"\",\n \"Returns: slug, display_name, root_path, type, status, match_type (exact|parent),\",\n \"relative_path (if the given path is inside a project), and session stats.\",\n \"\",\n \"match_type 'exact' means the path IS the project root.\",\n \"match_type 'parent' means the path is a subdirectory of the project root.\",\n ].join(\"\\n\"),\n {\n cwd: z\n .string()\n .optional()\n .describe(\n \"Absolute path to detect project for. Defaults to the MCP server's process.cwd().\"\n ),\n },\n async (args) => {\n const result = toolProjectDetect(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: project_health\n // -------------------------------------------------------------------------\n\n server.tool(\n \"project_health\",\n [\n \"Audit all registered PAI projects to find moved or deleted directories.\",\n \"\",\n \"Returns a JSON report categorising every project as:\",\n \" active — root_path exists on disk\",\n \" stale — root_path missing, but a directory with the same name was found nearby\",\n \" dead — root_path missing, no candidate found\",\n \"\",\n \"Use this to diagnose orphaned sessions or missing project paths.\",\n ].join(\"\\n\"),\n {\n category: z\n .enum([\"active\", \"stale\", \"dead\", \"all\"])\n .optional()\n .describe(\"Filter results to a specific health category. Default: all.\"),\n },\n async (args) => {\n const result = await toolProjectHealth(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: notification_config\n // -------------------------------------------------------------------------\n\n server.tool(\n \"notification_config\",\n [\n \"Query or update the PAI unified notification configuration.\",\n \"\",\n \"Actions:\",\n \" get — Return the current notification mode, active channels, and routing table.\",\n \" set — Change the notification mode or update channel/routing config.\",\n \" send — Send a notification through the configured channels.\",\n \"\",\n \"Notification modes:\",\n \" auto — Use the per-event routing table (default)\",\n \" voice — All events sent as WhatsApp voice (TTS)\",\n \" whatsapp — All events sent as WhatsApp text\",\n \" ntfy — All events sent to ntfy.sh\",\n \" macos — All events sent as macOS notifications\",\n \" cli — All events written to CLI output only\",\n \" off — Suppress all notifications\",\n \"\",\n \"Event types for send: error | progress | completion | info | debug\",\n \"\",\n \"Examples:\",\n ' { \"action\": \"get\" }',\n ' { \"action\": \"set\", \"mode\": \"voice\" }',\n ' { \"action\": \"send\", \"event\": \"completion\", \"message\": \"Done!\" }',\n ].join(\"\\n\"),\n {\n action: z\n .enum([\"get\", \"set\", \"send\"])\n .describe(\"Action: 'get' (read config), 'set' (update config), 'send' (send notification).\"),\n mode: z\n .enum([\"auto\", \"voice\", \"whatsapp\", \"ntfy\", \"macos\", \"cli\", \"off\"])\n .optional()\n .describe(\"For action=set: new notification mode.\"),\n channels: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n \"For action=set: partial channel config overrides as a JSON object. \" +\n 'E.g. { \"whatsapp\": { \"enabled\": true }, \"macos\": { \"enabled\": false } }'\n ),\n routing: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n \"For action=set: partial routing overrides as a JSON object. \" +\n 'E.g. { \"error\": [\"whatsapp\", \"macos\"], \"progress\": [\"cli\"] }'\n ),\n event: z\n .enum([\"error\", \"progress\", \"completion\", \"info\", \"debug\"])\n .optional()\n .describe(\"For action=send: event type. Default: 'info'.\"),\n message: z\n .string()\n .optional()\n .describe(\"For action=send: the notification message body.\"),\n title: z\n .string()\n .optional()\n .describe(\"For action=send: optional notification title (used by macOS and ntfy).\"),\n },\n async (args) => {\n const result = await toolNotificationConfig(args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: topic_detect\n // -------------------------------------------------------------------------\n\n server.tool(\n \"topic_detect\",\n [\n \"Detect whether recent conversation context has shifted to a different project.\",\n \"\",\n \"Call this when the conversation may have drifted away from the initially-routed project.\",\n \"Provide a short summary of the recent context (last few messages or tool call results).\",\n \"\",\n \"Returns:\",\n \" shifted — true if a topic shift was detected\",\n \" current_project — the project the session is currently routed to\",\n \" suggested_project — the project that best matches the context\",\n \" confidence — [0,1] fraction of memory mass held by suggested_project\",\n \" chunks_scored — number of memory chunks that contributed to scoring\",\n \" top_matches — top-3 projects with their confidence percentages\",\n \"\",\n \"A shift is reported when confidence >= threshold (default 0.6) and the\",\n \"best-matching project differs from current_project.\",\n \"\",\n \"Use cases:\",\n \" - Call at session start to confirm routing is correct\",\n \" - Call periodically when working across multiple concerns\",\n \" - Integrate with pre-tool hooks for automatic drift detection\",\n ].join(\"\\n\"),\n {\n context: z\n .string()\n .describe(\n \"Recent conversation context: a few sentences summarising what the session has been discussing. \" +\n \"Can include file paths, feature names, commands run, or any relevant text.\"\n ),\n current_project: z\n .string()\n .optional()\n .describe(\n \"The project slug this session is currently routed to. \" +\n \"If omitted, the tool still returns the best-matching project but shifted will always be false.\"\n ),\n threshold: z\n .number()\n .min(0)\n .max(1)\n .optional()\n .describe(\n \"Minimum confidence [0,1] to declare a shift. Default: 0.6. \" +\n \"Increase to reduce false positives. Decrease to catch subtle drifts.\"\n ),\n },\n async (args) => {\n const result = await toolTopicDetect(args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: session_route\n // -------------------------------------------------------------------------\n\n server.tool(\n \"session_route\",\n [\n \"Automatically detect which project this session belongs to.\",\n \"\",\n \"Call this at session start (e.g., from CLAUDE.md or a session-start hook)\",\n \"to route the session to the correct project automatically.\",\n \"\",\n \"Detection strategy (in priority order):\",\n \" 1. path — exact or parent-directory match in the project registry\",\n \" 2. marker — walk up from cwd looking for Notes/PAI.md marker files\",\n \" 3. topic — BM25 keyword search against memory (only if context provided)\",\n \"\",\n \"Returns:\",\n \" slug — the matched project slug\",\n \" display_name — human-readable project name\",\n \" root_path — absolute path to the project root\",\n \" method — how it was detected: 'path', 'marker', or 'topic'\",\n \" confidence — 1.0 for path/marker matches, BM25 fraction for topic\",\n \"\",\n \"If no match is found, returns a message explaining what was tried.\",\n \"Run 'pai project add .' to register the current directory.\",\n ].join(\"\\n\"),\n {\n cwd: z\n .string()\n .optional()\n .describe(\n \"Working directory to detect from. Defaults to process.cwd(). \" +\n \"Pass the session's actual working directory for accurate detection.\"\n ),\n context: z\n .string()\n .optional()\n .describe(\n \"Optional conversation context for topic-based fallback routing. \" +\n \"A few sentences summarising what the session will work on. \" +\n \"Only used if path and marker detection both fail.\"\n ),\n },\n async (args) => {\n const result = await toolSessionRoute(getRegistryDb(), getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_explore\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_explore\",\n [\n \"Explore the vault's knowledge graph using Luhmann's Folgezettel traversal.\",\n \"Follow trains of thought forward, backward, or both from a starting note.\",\n \"Classifies links as sequential (same-folder) or associative (cross-folder).\",\n ].join(\"\\n\"),\n {\n start_note: z\n .string()\n .describe(\"Path or title of the note to start traversal from.\"),\n depth: z\n .number()\n .int()\n .min(1)\n .max(10)\n .optional()\n .describe(\"How many link hops to traverse. Default: 3.\"),\n direction: z\n .enum([\"forward\", \"backward\", \"both\"])\n .optional()\n .describe(\"Traversal direction: 'forward' (outlinks), 'backward' (backlinks), or 'both'. Default: both.\"),\n mode: z\n .enum([\"sequential\", \"associative\", \"all\"])\n .optional()\n .describe(\"Link type filter: 'sequential' (same-folder), 'associative' (cross-folder), or 'all'. Default: all.\"),\n },\n async (args) => {\n const result = await toolZettelExplore(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_health\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_health\",\n [\n \"Audit the structural health of the Obsidian vault.\",\n \"Reports dead links, orphan notes, disconnected clusters, low-connectivity files, and an overall health score.\",\n ].join(\"\\n\"),\n {\n scope: z\n .enum([\"full\", \"recent\", \"project\"])\n .optional()\n .describe(\"Audit scope: 'full' (entire vault), 'recent' (recently modified), or 'project' (specific path). Default: full.\"),\n project_path: z\n .string()\n .optional()\n .describe(\"Absolute path to the project/folder to audit when scope='project'.\"),\n recent_days: z\n .number()\n .int()\n .optional()\n .describe(\"Number of days to look back when scope='recent'. Default: 30.\"),\n include: z\n .array(z.enum([\"dead_links\", \"orphans\", \"disconnected\", \"low_connectivity\"]))\n .optional()\n .describe(\"Specific checks to include. Omit to run all checks.\"),\n },\n async (args) => {\n const result = await toolZettelHealth(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_surprise\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_surprise\",\n [\n \"Find surprising connections — notes that are semantically similar to a reference note but far away in the link graph.\",\n \"High surprise = unexpected relevance.\",\n ].join(\"\\n\"),\n {\n reference_path: z\n .string()\n .describe(\"Path to the reference note to find surprising connections for.\"),\n vault_project_id: z\n .number()\n .int()\n .describe(\"Project ID of the vault to search within.\"),\n limit: z\n .number()\n .int()\n .optional()\n .describe(\"Maximum number of surprising notes to return. Default: 10.\"),\n min_similarity: z\n .number()\n .optional()\n .describe(\"Minimum semantic similarity [0,1] for a note to be considered. Default: 0.5.\"),\n min_graph_distance: z\n .number()\n .int()\n .optional()\n .describe(\"Minimum link hops away from the reference note. Default: 3.\"),\n },\n async (args) => {\n const result = await toolZettelSurprise(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_suggest\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_suggest\",\n [\n \"Suggest new connections for a note using semantic similarity, shared tags, and graph neighborhood (friends-of-friends).\",\n ].join(\"\\n\"),\n {\n note_path: z\n .string()\n .describe(\"Path to the note to generate link suggestions for.\"),\n vault_project_id: z\n .number()\n .int()\n .describe(\"Project ID of the vault to search within.\"),\n limit: z\n .number()\n .int()\n .optional()\n .describe(\"Maximum number of suggestions to return. Default: 10.\"),\n exclude_linked: z\n .boolean()\n .optional()\n .describe(\"Exclude notes already linked from this note. Default: true.\"),\n },\n async (args) => {\n const result = await toolZettelSuggest(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_converse\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_converse\",\n [\n \"Use the vault as a Zettelkasten communication partner.\",\n \"Ask a question, get relevant notes with cross-domain connections and a synthesis prompt for generating new insights.\",\n ].join(\"\\n\"),\n {\n question: z\n .string()\n .describe(\"The question or topic to explore in the vault.\"),\n vault_project_id: z\n .number()\n .int()\n .describe(\"Project ID of the vault to query.\"),\n depth: z\n .number()\n .int()\n .optional()\n .describe(\"How many link hops to follow from seed notes. Default: 2.\"),\n limit: z\n .number()\n .int()\n .optional()\n .describe(\"Maximum number of relevant notes to retrieve. Default: 10.\"),\n },\n async (args) => {\n const result = await toolZettelConverse(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_themes\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_themes\",\n [\n \"Detect emerging themes by clustering recent notes with similar embeddings.\",\n \"Reveals forming idea clusters and suggests index notes for unlinked clusters.\",\n ].join(\"\\n\"),\n {\n vault_project_id: z\n .number()\n .int()\n .describe(\"Project ID of the vault to analyse.\"),\n lookback_days: z\n .number()\n .int()\n .optional()\n .describe(\"Number of days of recent notes to cluster. Default: 30.\"),\n min_cluster_size: z\n .number()\n .int()\n .optional()\n .describe(\"Minimum notes required to form a theme cluster. Default: 3.\"),\n max_themes: z\n .number()\n .int()\n .optional()\n .describe(\"Maximum number of theme clusters to return. Default: 10.\"),\n similarity_threshold: z\n .number()\n .optional()\n .describe(\"Minimum cosine similarity to group notes into a cluster [0,1]. Default: 0.7.\"),\n },\n async (args) => {\n const result = await toolZettelThemes(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Connect transport and start serving\n // -------------------------------------------------------------------------\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n // Keep the process alive — the server runs until stdin closes\n}\n","#!/usr/bin/env node\n/**\n * PAI Knowledge OS — MCP server entry point\n *\n * When invoked as `node dist/mcp/index.mjs` (or via the `pai-mcp` bin),\n * starts the PAI MCP server on stdio transport so Claude Code can call\n * memory_search, memory_get, project_info, project_list, session_list,\n * and registry_search tools directly during conversations.\n */\n\nimport { startMcpServer } from \"./server.js\";\n\nstartMcpServer().catch((err) => {\n // Write errors to stderr only — stdout is reserved for JSON-RPC messages\n process.stderr.write(`PAI MCP server fatal error: ${String(err)}\\n`);\n process.exit(1);\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["z\n .string","z\n .boolean","z\n .array","z.enum","z\n .number","z\n .enum","z.string","z\n .record","z.unknown"],"sources":["../../src/mcp/server.ts","../../src/mcp/index.ts"],"sourcesContent":["/**\n * PAI Knowledge OS — MCP Server (Phase 3)\n *\n * Exposes PAI registry and memory as MCP tools callable by Claude Code.\n *\n * Tools:\n * memory_search — BM25 search across indexed memory/notes\n * memory_get — Read a specific file or lines from a project\n * project_info — Get details for a project (by slug or current dir)\n * project_list — List projects with optional filters\n * session_list — List sessions for a project\n * registry_search — Full-text search over project slugs/names/paths\n * project_detect — Detect which project a path belongs to\n * project_health — Audit all projects for moved/deleted directories\n * session_route — Auto-route session to project (path/marker/topic)\n *\n * NOTE: All tool logic lives in tools.ts (shared with the daemon).\n * This file wires MCP schema definitions to those pure functions.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { z } from \"zod\";\nimport { openRegistry } from \"../registry/db.js\";\nimport { openFederation } from \"../memory/db.js\";\nimport { loadConfig } from \"../daemon/config.js\";\nimport {\n toolMemorySearch,\n toolMemoryGet,\n toolProjectInfo,\n toolProjectList,\n toolSessionList,\n toolRegistrySearch,\n toolProjectDetect,\n toolProjectHealth,\n toolNotificationConfig,\n toolTopicDetect,\n toolSessionRoute,\n toolZettelExplore,\n toolZettelHealth,\n toolZettelSurprise,\n toolZettelSuggest,\n toolZettelConverse,\n toolZettelThemes,\n} from \"./tools.js\";\n\n// ---------------------------------------------------------------------------\n// Database singletons (opened lazily, once per MCP server process)\n// ---------------------------------------------------------------------------\n\nlet _registryDb: ReturnType<typeof openRegistry> | null = null;\nlet _federationDb: ReturnType<typeof openFederation> | null = null;\n\nfunction getRegistryDb() {\n if (!_registryDb) _registryDb = openRegistry();\n return _registryDb;\n}\n\nfunction getFederationDb() {\n if (!_federationDb) _federationDb = openFederation();\n return _federationDb;\n}\n\n// ---------------------------------------------------------------------------\n// MCP server startup\n// ---------------------------------------------------------------------------\n\nexport async function startMcpServer(): Promise<void> {\n const server = new McpServer({\n name: \"pai\",\n version: \"0.1.0\",\n });\n\n // -------------------------------------------------------------------------\n // Tool: memory_search\n // -------------------------------------------------------------------------\n\n server.tool(\n \"memory_search\",\n [\n \"Search PAI federated memory using BM25 full-text ranking, semantic similarity, or a hybrid of both.\",\n \"\",\n \"Use this BEFORE answering questions about past work, decisions, dates, people,\",\n \"preferences, project status, todos, technical choices, or anything that might\",\n \"have been recorded in session notes or memory files.\",\n \"\",\n \"Modes:\",\n \" keyword — BM25 full-text search (default, fast, no embeddings required)\",\n \" semantic — Cosine similarity over vector embeddings (requires prior embed run)\",\n \" hybrid — Normalized combination of BM25 + cosine (best quality)\",\n \"\",\n \"Reranking is ON by default — results are re-scored with a cross-encoder model for better relevance.\",\n \"Set rerank=false to skip reranking (faster but less accurate ordering).\",\n \"\",\n \"Recency boost optionally down-weights older results (recency_boost=90 means scores halve every 90 days).\",\n \"\",\n \"Defaults come from ~/.config/pai/config.json (search section). Per-call parameters override config defaults.\",\n \"\",\n \"Returns ranked snippets with project slug, file path, line range, and score.\",\n \"Higher score = more relevant.\",\n ].join(\"\\n\"),\n {\n query: z\n .string()\n .describe(\"Free-text search query. Multiple words are ORed together — any matching word returns a result, ranked by relevance.\"),\n project: z\n .string()\n .optional()\n .describe(\n \"Scope search to a single project by slug. Omit to search all projects.\"\n ),\n all_projects: z\n .boolean()\n .optional()\n .describe(\n \"Explicitly search all projects (default behaviour when project is omitted).\"\n ),\n sources: z\n .array(z.enum([\"memory\", \"notes\"]))\n .optional()\n .describe(\"Restrict to specific source types: 'memory' or 'notes'.\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .describe(\"Maximum results to return. Default: 10.\"),\n mode: z\n .enum([\"keyword\", \"semantic\", \"hybrid\"])\n .optional()\n .describe(\n \"Search mode: 'keyword' (BM25, default), 'semantic' (vector cosine), or 'hybrid' (both combined).\"\n ),\n rerank: z\n .boolean()\n .optional()\n .describe(\n \"Rerank results using a cross-encoder model for better relevance. Default: true. Set to false to skip reranking for faster but less accurate results.\"\n ),\n recency_boost: z\n .number()\n .int()\n .min(0)\n .max(365)\n .optional()\n .describe(\n \"Apply recency boost: score halves every N days. 0 = off. Default from config (typically 90). Applied after reranking.\"\n ),\n },\n async (args) => {\n const config = loadConfig();\n const result = await toolMemorySearch(\n getRegistryDb(),\n getFederationDb(),\n { ...args, recencyBoost: args.recency_boost },\n config.search,\n );\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: memory_get\n // -------------------------------------------------------------------------\n\n server.tool(\n \"memory_get\",\n [\n \"Read the content of a specific file from a registered PAI project.\",\n \"\",\n \"Use this to read a full memory file, session note, or document after finding\",\n \"it via memory_search. Optionally restrict to a line range.\",\n \"\",\n \"The path must be a relative path within the project root (no ../ traversal).\",\n ].join(\"\\n\"),\n {\n project: z\n .string()\n .describe(\"Project slug identifying which project's files to read from.\"),\n path: z\n .string()\n .describe(\n \"Relative path within the project root (e.g. 'Notes/0001 - 2026-01-01 - Example.md').\"\n ),\n from: z\n .number()\n .int()\n .min(1)\n .optional()\n .describe(\"Starting line number (1-based, inclusive). Default: 1.\"),\n lines: z\n .number()\n .int()\n .min(1)\n .optional()\n .describe(\"Number of lines to return. Default: entire file.\"),\n },\n async (args) => {\n const result = toolMemoryGet(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: project_info\n // -------------------------------------------------------------------------\n\n server.tool(\n \"project_info\",\n [\n \"Get detailed information about a PAI registered project.\",\n \"\",\n \"Use this to look up a project's root path, type, status, tags, session count,\",\n \"and last active date. If no slug is provided, attempts to detect the current\",\n \"project from the caller's working directory.\",\n ].join(\"\\n\"),\n {\n slug: z\n .string()\n .optional()\n .describe(\n \"Project slug. Omit to auto-detect from the current working directory.\"\n ),\n },\n async (args) => {\n const result = toolProjectInfo(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: project_list\n // -------------------------------------------------------------------------\n\n server.tool(\n \"project_list\",\n [\n \"List registered PAI projects with optional filters.\",\n \"\",\n \"Use this to browse all known projects, find projects by status or tag,\",\n \"or get a quick overview of the PAI registry.\",\n ].join(\"\\n\"),\n {\n status: z\n .enum([\"active\", \"archived\", \"migrating\"])\n .optional()\n .describe(\"Filter by project status. Default: all statuses.\"),\n tag: z\n .string()\n .optional()\n .describe(\"Filter by tag name (exact match).\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(500)\n .optional()\n .describe(\"Maximum number of projects to return. Default: 50.\"),\n },\n async (args) => {\n const result = toolProjectList(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: session_list\n // -------------------------------------------------------------------------\n\n server.tool(\n \"session_list\",\n [\n \"List session notes for a PAI project.\",\n \"\",\n \"Use this to find what sessions exist for a project, see their dates and titles,\",\n \"and identify specific session notes to read via memory_get.\",\n ].join(\"\\n\"),\n {\n project: z.string().describe(\"Project slug to list sessions for.\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(500)\n .optional()\n .describe(\"Maximum sessions to return. Default: 10 (most recent first).\"),\n status: z\n .enum([\"open\", \"completed\", \"compacted\"])\n .optional()\n .describe(\"Filter by session status.\"),\n },\n async (args) => {\n const result = toolSessionList(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: registry_search\n // -------------------------------------------------------------------------\n\n server.tool(\n \"registry_search\",\n [\n \"Search PAI project registry by slug, display name, or path.\",\n \"\",\n \"Use this to find the slug for a project when you know its name or path,\",\n \"or to check if a project is registered. Returns matching project entries.\",\n ].join(\"\\n\"),\n {\n query: z\n .string()\n .describe(\n \"Search term matched against project slugs, display names, and root paths (case-insensitive substring match).\"\n ),\n },\n async (args) => {\n const result = toolRegistrySearch(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: project_detect\n // -------------------------------------------------------------------------\n\n server.tool(\n \"project_detect\",\n [\n \"Detect which registered PAI project a filesystem path belongs to.\",\n \"\",\n \"Use this at session start to auto-identify the current project from the\",\n \"working directory, or to map any path back to its registered project.\",\n \"\",\n \"Returns: slug, display_name, root_path, type, status, match_type (exact|parent),\",\n \"relative_path (if the given path is inside a project), and session stats.\",\n \"\",\n \"match_type 'exact' means the path IS the project root.\",\n \"match_type 'parent' means the path is a subdirectory of the project root.\",\n ].join(\"\\n\"),\n {\n cwd: z\n .string()\n .optional()\n .describe(\n \"Absolute path to detect project for. Defaults to the MCP server's process.cwd().\"\n ),\n },\n async (args) => {\n const result = toolProjectDetect(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: project_health\n // -------------------------------------------------------------------------\n\n server.tool(\n \"project_health\",\n [\n \"Audit all registered PAI projects to find moved or deleted directories.\",\n \"\",\n \"Returns a JSON report categorising every project as:\",\n \" active — root_path exists on disk\",\n \" stale — root_path missing, but a directory with the same name was found nearby\",\n \" dead — root_path missing, no candidate found\",\n \"\",\n \"Use this to diagnose orphaned sessions or missing project paths.\",\n ].join(\"\\n\"),\n {\n category: z\n .enum([\"active\", \"stale\", \"dead\", \"all\"])\n .optional()\n .describe(\"Filter results to a specific health category. Default: all.\"),\n },\n async (args) => {\n const result = await toolProjectHealth(getRegistryDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: notification_config\n // -------------------------------------------------------------------------\n\n server.tool(\n \"notification_config\",\n [\n \"Query or update the PAI unified notification configuration.\",\n \"\",\n \"Actions:\",\n \" get — Return the current notification mode, active channels, and routing table.\",\n \" set — Change the notification mode or update channel/routing config.\",\n \" send — Send a notification through the configured channels.\",\n \"\",\n \"Notification modes:\",\n \" auto — Use the per-event routing table (default)\",\n \" voice — All events sent as WhatsApp voice (TTS)\",\n \" whatsapp — All events sent as WhatsApp text\",\n \" ntfy — All events sent to ntfy.sh\",\n \" macos — All events sent as macOS notifications\",\n \" cli — All events written to CLI output only\",\n \" off — Suppress all notifications\",\n \"\",\n \"Event types for send: error | progress | completion | info | debug\",\n \"\",\n \"Examples:\",\n ' { \"action\": \"get\" }',\n ' { \"action\": \"set\", \"mode\": \"voice\" }',\n ' { \"action\": \"send\", \"event\": \"completion\", \"message\": \"Done!\" }',\n ].join(\"\\n\"),\n {\n action: z\n .enum([\"get\", \"set\", \"send\"])\n .describe(\"Action: 'get' (read config), 'set' (update config), 'send' (send notification).\"),\n mode: z\n .enum([\"auto\", \"voice\", \"whatsapp\", \"ntfy\", \"macos\", \"cli\", \"off\"])\n .optional()\n .describe(\"For action=set: new notification mode.\"),\n channels: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n \"For action=set: partial channel config overrides as a JSON object. \" +\n 'E.g. { \"whatsapp\": { \"enabled\": true }, \"macos\": { \"enabled\": false } }'\n ),\n routing: z\n .record(z.string(), z.unknown())\n .optional()\n .describe(\n \"For action=set: partial routing overrides as a JSON object. \" +\n 'E.g. { \"error\": [\"whatsapp\", \"macos\"], \"progress\": [\"cli\"] }'\n ),\n event: z\n .enum([\"error\", \"progress\", \"completion\", \"info\", \"debug\"])\n .optional()\n .describe(\"For action=send: event type. Default: 'info'.\"),\n message: z\n .string()\n .optional()\n .describe(\"For action=send: the notification message body.\"),\n title: z\n .string()\n .optional()\n .describe(\"For action=send: optional notification title (used by macOS and ntfy).\"),\n },\n async (args) => {\n const result = await toolNotificationConfig(args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: topic_detect\n // -------------------------------------------------------------------------\n\n server.tool(\n \"topic_detect\",\n [\n \"Detect whether recent conversation context has shifted to a different project.\",\n \"\",\n \"Call this when the conversation may have drifted away from the initially-routed project.\",\n \"Provide a short summary of the recent context (last few messages or tool call results).\",\n \"\",\n \"Returns:\",\n \" shifted — true if a topic shift was detected\",\n \" current_project — the project the session is currently routed to\",\n \" suggested_project — the project that best matches the context\",\n \" confidence — [0,1] fraction of memory mass held by suggested_project\",\n \" chunks_scored — number of memory chunks that contributed to scoring\",\n \" top_matches — top-3 projects with their confidence percentages\",\n \"\",\n \"A shift is reported when confidence >= threshold (default 0.6) and the\",\n \"best-matching project differs from current_project.\",\n \"\",\n \"Use cases:\",\n \" - Call at session start to confirm routing is correct\",\n \" - Call periodically when working across multiple concerns\",\n \" - Integrate with pre-tool hooks for automatic drift detection\",\n ].join(\"\\n\"),\n {\n context: z\n .string()\n .describe(\n \"Recent conversation context: a few sentences summarising what the session has been discussing. \" +\n \"Can include file paths, feature names, commands run, or any relevant text.\"\n ),\n current_project: z\n .string()\n .optional()\n .describe(\n \"The project slug this session is currently routed to. \" +\n \"If omitted, the tool still returns the best-matching project but shifted will always be false.\"\n ),\n threshold: z\n .number()\n .min(0)\n .max(1)\n .optional()\n .describe(\n \"Minimum confidence [0,1] to declare a shift. Default: 0.6. \" +\n \"Increase to reduce false positives. Decrease to catch subtle drifts.\"\n ),\n },\n async (args) => {\n const result = await toolTopicDetect(args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: session_route\n // -------------------------------------------------------------------------\n\n server.tool(\n \"session_route\",\n [\n \"Automatically detect which project this session belongs to.\",\n \"\",\n \"Call this at session start (e.g., from CLAUDE.md or a session-start hook)\",\n \"to route the session to the correct project automatically.\",\n \"\",\n \"Detection strategy (in priority order):\",\n \" 1. path — exact or parent-directory match in the project registry\",\n \" 2. marker — walk up from cwd looking for Notes/PAI.md marker files\",\n \" 3. topic — BM25 keyword search against memory (only if context provided)\",\n \"\",\n \"Returns:\",\n \" slug — the matched project slug\",\n \" display_name — human-readable project name\",\n \" root_path — absolute path to the project root\",\n \" method — how it was detected: 'path', 'marker', or 'topic'\",\n \" confidence — 1.0 for path/marker matches, BM25 fraction for topic\",\n \"\",\n \"If no match is found, returns a message explaining what was tried.\",\n \"Run 'pai project add .' to register the current directory.\",\n ].join(\"\\n\"),\n {\n cwd: z\n .string()\n .optional()\n .describe(\n \"Working directory to detect from. Defaults to process.cwd(). \" +\n \"Pass the session's actual working directory for accurate detection.\"\n ),\n context: z\n .string()\n .optional()\n .describe(\n \"Optional conversation context for topic-based fallback routing. \" +\n \"A few sentences summarising what the session will work on. \" +\n \"Only used if path and marker detection both fail.\"\n ),\n },\n async (args) => {\n const result = await toolSessionRoute(getRegistryDb(), getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_explore\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_explore\",\n [\n \"Explore the vault's knowledge graph using Luhmann's Folgezettel traversal.\",\n \"Follow trains of thought forward, backward, or both from a starting note.\",\n \"Classifies links as sequential (same-folder) or associative (cross-folder).\",\n ].join(\"\\n\"),\n {\n start_note: z\n .string()\n .describe(\"Path or title of the note to start traversal from.\"),\n depth: z\n .number()\n .int()\n .min(1)\n .max(10)\n .optional()\n .describe(\"How many link hops to traverse. Default: 3.\"),\n direction: z\n .enum([\"forward\", \"backward\", \"both\"])\n .optional()\n .describe(\"Traversal direction: 'forward' (outlinks), 'backward' (backlinks), or 'both'. Default: both.\"),\n mode: z\n .enum([\"sequential\", \"associative\", \"all\"])\n .optional()\n .describe(\"Link type filter: 'sequential' (same-folder), 'associative' (cross-folder), or 'all'. Default: all.\"),\n },\n async (args) => {\n const result = await toolZettelExplore(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_health\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_health\",\n [\n \"Audit the structural health of the Obsidian vault.\",\n \"Reports dead links, orphan notes, disconnected clusters, low-connectivity files, and an overall health score.\",\n ].join(\"\\n\"),\n {\n scope: z\n .enum([\"full\", \"recent\", \"project\"])\n .optional()\n .describe(\"Audit scope: 'full' (entire vault), 'recent' (recently modified), or 'project' (specific path). Default: full.\"),\n project_path: z\n .string()\n .optional()\n .describe(\"Absolute path to the project/folder to audit when scope='project'.\"),\n recent_days: z\n .number()\n .int()\n .optional()\n .describe(\"Number of days to look back when scope='recent'. Default: 30.\"),\n include: z\n .array(z.enum([\"dead_links\", \"orphans\", \"disconnected\", \"low_connectivity\"]))\n .optional()\n .describe(\"Specific checks to include. Omit to run all checks.\"),\n },\n async (args) => {\n const result = await toolZettelHealth(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_surprise\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_surprise\",\n [\n \"Find surprising connections — notes that are semantically similar to a reference note but far away in the link graph.\",\n \"High surprise = unexpected relevance.\",\n ].join(\"\\n\"),\n {\n reference_path: z\n .string()\n .describe(\"Path to the reference note to find surprising connections for.\"),\n vault_project_id: z\n .number()\n .int()\n .describe(\"Project ID of the vault to search within.\"),\n limit: z\n .number()\n .int()\n .optional()\n .describe(\"Maximum number of surprising notes to return. Default: 10.\"),\n min_similarity: z\n .number()\n .optional()\n .describe(\"Minimum semantic similarity [0,1] for a note to be considered. Default: 0.5.\"),\n min_graph_distance: z\n .number()\n .int()\n .optional()\n .describe(\"Minimum link hops away from the reference note. Default: 3.\"),\n },\n async (args) => {\n const result = await toolZettelSurprise(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_suggest\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_suggest\",\n [\n \"Suggest new connections for a note using semantic similarity, shared tags, and graph neighborhood (friends-of-friends).\",\n ].join(\"\\n\"),\n {\n note_path: z\n .string()\n .describe(\"Path to the note to generate link suggestions for.\"),\n vault_project_id: z\n .number()\n .int()\n .describe(\"Project ID of the vault to search within.\"),\n limit: z\n .number()\n .int()\n .optional()\n .describe(\"Maximum number of suggestions to return. Default: 10.\"),\n exclude_linked: z\n .boolean()\n .optional()\n .describe(\"Exclude notes already linked from this note. Default: true.\"),\n },\n async (args) => {\n const result = await toolZettelSuggest(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_converse\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_converse\",\n [\n \"Use the vault as a Zettelkasten communication partner.\",\n \"Ask a question, get relevant notes with cross-domain connections and a synthesis prompt for generating new insights.\",\n ].join(\"\\n\"),\n {\n question: z\n .string()\n .describe(\"The question or topic to explore in the vault.\"),\n vault_project_id: z\n .number()\n .int()\n .describe(\"Project ID of the vault to query.\"),\n depth: z\n .number()\n .int()\n .optional()\n .describe(\"How many link hops to follow from seed notes. Default: 2.\"),\n limit: z\n .number()\n .int()\n .optional()\n .describe(\"Maximum number of relevant notes to retrieve. Default: 10.\"),\n },\n async (args) => {\n const result = await toolZettelConverse(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Tool: zettel_themes\n // -------------------------------------------------------------------------\n\n server.tool(\n \"zettel_themes\",\n [\n \"Detect emerging themes by clustering recent notes with similar embeddings.\",\n \"Reveals forming idea clusters and suggests index notes for unlinked clusters.\",\n ].join(\"\\n\"),\n {\n vault_project_id: z\n .number()\n .int()\n .describe(\"Project ID of the vault to analyse.\"),\n lookback_days: z\n .number()\n .int()\n .optional()\n .describe(\"Number of days of recent notes to cluster. Default: 30.\"),\n min_cluster_size: z\n .number()\n .int()\n .optional()\n .describe(\"Minimum notes required to form a theme cluster. Default: 3.\"),\n max_themes: z\n .number()\n .int()\n .optional()\n .describe(\"Maximum number of theme clusters to return. Default: 10.\"),\n similarity_threshold: z\n .number()\n .optional()\n .describe(\"Minimum cosine similarity to group notes into a cluster [0,1]. Default: 0.7.\"),\n },\n async (args) => {\n const result = await toolZettelThemes(getFederationDb(), args);\n return {\n content: result.content.map((c) => ({ type: c.type as \"text\", text: c.text })),\n isError: result.isError,\n };\n }\n );\n\n // -------------------------------------------------------------------------\n // Connect transport and start serving\n // -------------------------------------------------------------------------\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n // Keep the process alive — the server runs until stdin closes\n}\n","#!/usr/bin/env node\n/**\n * PAI Knowledge OS — MCP server entry point\n *\n * When invoked as `node dist/mcp/index.mjs` (or via the `pai-mcp` bin),\n * starts the PAI MCP server on stdio transport so Claude Code can call\n * memory_search, memory_get, project_info, project_list, session_list,\n * and registry_search tools directly during conversations.\n */\n\nimport { startMcpServer } from \"./server.js\";\n\nstartMcpServer().catch((err) => {\n // Write errors to stderr only — stdout is reserved for JSON-RPC messages\n process.stderr.write(`PAI MCP server fatal error: ${String(err)}\\n`);\n process.exit(1);\n});\n"],"mappings":"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"}
@@ -332,4 +332,4 @@ function buildPgTsQuery(query) {
332
332
 
333
333
  //#endregion
334
334
  export { PostgresBackend };
335
- //# sourceMappingURL=postgres-CRBe30Ag.mjs.map
335
+ //# sourceMappingURL=postgres-CIxeqf_n.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"postgres-CRBe30Ag.mjs","names":[],"sources":["../src/storage/postgres.ts"],"sourcesContent":["/**\n * PostgresBackend — implements StorageBackend using PostgreSQL + pgvector.\n *\n * Vector similarity: pgvector's <=> cosine distance operator\n * Full-text search: PostgreSQL tsvector/tsquery (replaces SQLite FTS5)\n * Connection pooling: node-postgres Pool\n *\n * Schema is initialized via docker/init.sql.\n * This module only handles runtime queries — schema creation is external.\n */\n\nimport pg from \"pg\";\nimport type { Pool, PoolClient } from \"pg\";\nimport type { StorageBackend, ChunkRow, FileRow, FederationStats } from \"./interface.js\";\nimport type { SearchResult, SearchOptions } from \"../memory/search.js\";\nimport { buildFtsQuery } from \"../memory/search.js\";\n\nconst { Pool: PgPool } = pg;\n\n// ---------------------------------------------------------------------------\n// Postgres config\n// ---------------------------------------------------------------------------\n\nexport interface PostgresConfig {\n connectionString?: string;\n host?: string;\n port?: number;\n database?: string;\n user?: string;\n password?: string;\n /** Maximum pool connections. Default 5 */\n maxConnections?: number;\n /** Connection timeout in ms. Default 5000 */\n connectionTimeoutMs?: number;\n}\n\n// ---------------------------------------------------------------------------\n// Implementation\n// ---------------------------------------------------------------------------\n\nexport class PostgresBackend implements StorageBackend {\n readonly backendType = \"postgres\" as const;\n\n private pool: Pool;\n\n constructor(config: PostgresConfig) {\n const connStr =\n config.connectionString ??\n `postgresql://${config.user ?? \"pai\"}:${config.password ?? \"pai\"}@${config.host ?? \"localhost\"}:${config.port ?? 5432}/${config.database ?? \"pai\"}`;\n\n this.pool = new PgPool({\n connectionString: connStr,\n max: config.maxConnections ?? 5,\n connectionTimeoutMillis: config.connectionTimeoutMs ?? 5000,\n idleTimeoutMillis: 30_000,\n });\n\n // Log pool errors so they don't crash the process silently\n this.pool.on(\"error\", (err) => {\n process.stderr.write(`[pai-postgres] Pool error: ${err.message}\\n`);\n });\n }\n\n // -------------------------------------------------------------------------\n // Lifecycle\n // -------------------------------------------------------------------------\n\n async close(): Promise<void> {\n await this.pool.end();\n }\n\n async getStats(): Promise<FederationStats> {\n const client = await this.pool.connect();\n try {\n const filesResult = await client.query<{ n: string }>(\n \"SELECT COUNT(*)::text AS n FROM pai_files\"\n );\n const chunksResult = await client.query<{ n: string }>(\n \"SELECT COUNT(*)::text AS n FROM pai_chunks\"\n );\n return {\n files: parseInt(filesResult.rows[0]?.n ?? \"0\", 10),\n chunks: parseInt(chunksResult.rows[0]?.n ?? \"0\", 10),\n };\n } finally {\n client.release();\n }\n }\n\n /**\n * Test the connection by running a trivial query.\n * Returns null on success, error message on failure.\n */\n async testConnection(): Promise<string | null> {\n let client: PoolClient | null = null;\n try {\n client = await this.pool.connect();\n await client.query(\"SELECT 1\");\n return null;\n } catch (e) {\n return e instanceof Error ? e.message : String(e);\n } finally {\n client?.release();\n }\n }\n\n // -------------------------------------------------------------------------\n // File tracking\n // -------------------------------------------------------------------------\n\n async getFileHash(projectId: number, path: string): Promise<string | undefined> {\n const result = await this.pool.query<{ hash: string }>(\n \"SELECT hash FROM pai_files WHERE project_id = $1 AND path = $2\",\n [projectId, path]\n );\n return result.rows[0]?.hash;\n }\n\n async upsertFile(file: FileRow): Promise<void> {\n await this.pool.query(\n `INSERT INTO pai_files (project_id, path, source, tier, hash, mtime, size)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (project_id, path) DO UPDATE SET\n source = EXCLUDED.source,\n tier = EXCLUDED.tier,\n hash = EXCLUDED.hash,\n mtime = EXCLUDED.mtime,\n size = EXCLUDED.size`,\n [file.projectId, file.path, file.source, file.tier, file.hash, file.mtime, file.size]\n );\n }\n\n // -------------------------------------------------------------------------\n // Chunk management\n // -------------------------------------------------------------------------\n\n async getChunkIds(projectId: number, path: string): Promise<string[]> {\n const result = await this.pool.query<{ id: string }>(\n \"SELECT id FROM pai_chunks WHERE project_id = $1 AND path = $2\",\n [projectId, path]\n );\n return result.rows.map((r) => r.id);\n }\n\n async deleteChunksForFile(projectId: number, path: string): Promise<void> {\n // Foreign key CASCADE handles pai_chunks deletion automatically\n // but we don't have FK to pai_chunks from pai_files, so delete explicitly\n await this.pool.query(\n \"DELETE FROM pai_chunks WHERE project_id = $1 AND path = $2\",\n [projectId, path]\n );\n }\n\n async insertChunks(chunks: ChunkRow[]): Promise<void> {\n if (chunks.length === 0) return;\n\n const client = await this.pool.connect();\n try {\n await client.query(\"BEGIN\");\n\n for (const c of chunks) {\n // embedding is null at insert time; updated separately via updateEmbedding()\n await client.query(\n `INSERT INTO pai_chunks\n (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at, fts_vector)\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n to_tsvector('simple', $9))\n ON CONFLICT (id) DO UPDATE SET\n project_id = EXCLUDED.project_id,\n source = EXCLUDED.source,\n tier = EXCLUDED.tier,\n path = EXCLUDED.path,\n start_line = EXCLUDED.start_line,\n end_line = EXCLUDED.end_line,\n hash = EXCLUDED.hash,\n text = EXCLUDED.text,\n updated_at = EXCLUDED.updated_at,\n fts_vector = EXCLUDED.fts_vector`,\n [\n c.id,\n c.projectId,\n c.source,\n c.tier,\n c.path,\n c.startLine,\n c.endLine,\n c.hash,\n c.text,\n c.updatedAt,\n ]\n );\n }\n\n await client.query(\"COMMIT\");\n } catch (e) {\n await client.query(\"ROLLBACK\");\n throw e;\n } finally {\n client.release();\n }\n }\n\n async getUnembeddedChunkIds(projectId?: number): Promise<Array<{ id: string; text: string }>> {\n if (projectId !== undefined) {\n const result = await this.pool.query<{ id: string; text: string }>(\n \"SELECT id, text FROM pai_chunks WHERE embedding IS NULL AND project_id = $1 ORDER BY id\",\n [projectId]\n );\n return result.rows;\n }\n const result = await this.pool.query<{ id: string; text: string }>(\n \"SELECT id, text FROM pai_chunks WHERE embedding IS NULL ORDER BY id\"\n );\n return result.rows;\n }\n\n async updateEmbedding(chunkId: string, embedding: Buffer): Promise<void> {\n // Deserialize the Buffer (Float32Array LE bytes) to a number[] for pgvector\n const vec = bufferToVector(embedding);\n const vecStr = \"[\" + vec.join(\",\") + \"]\";\n await this.pool.query(\n \"UPDATE pai_chunks SET embedding = $1::vector WHERE id = $2\",\n [vecStr, chunkId]\n );\n }\n\n // -------------------------------------------------------------------------\n // Search — keyword (tsvector/tsquery)\n // -------------------------------------------------------------------------\n\n async searchKeyword(query: string, opts?: SearchOptions): Promise<SearchResult[]> {\n const maxResults = opts?.maxResults ?? 10;\n\n // Build tsquery from the same token logic as buildFtsQuery, but for Postgres\n const tsQuery = buildPgTsQuery(query);\n if (!tsQuery) return [];\n\n // Use 'simple' dictionary: preserves tokens as-is, no language-specific\n // stemming. Works reliably with any language (German, French, etc.).\n const conditions: string[] = [\"fts_vector @@ to_tsquery('simple', $1)\"];\n const params: (string | number)[] = [tsQuery];\n let paramIdx = 2;\n\n if (opts?.projectIds && opts.projectIds.length > 0) {\n const placeholders = opts.projectIds.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`project_id IN (${placeholders})`);\n params.push(...opts.projectIds);\n }\n\n if (opts?.sources && opts.sources.length > 0) {\n const placeholders = opts.sources.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`source IN (${placeholders})`);\n params.push(...opts.sources);\n }\n\n if (opts?.tiers && opts.tiers.length > 0) {\n const placeholders = opts.tiers.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`tier IN (${placeholders})`);\n params.push(...opts.tiers);\n }\n\n params.push(maxResults);\n const limitParam = `$${paramIdx}`;\n\n const sql = `\n SELECT\n project_id,\n path,\n start_line,\n end_line,\n text AS snippet,\n tier,\n source,\n ts_rank(fts_vector, to_tsquery('simple', $1)) AS rank_score\n FROM pai_chunks\n WHERE ${conditions.join(\" AND \")}\n ORDER BY rank_score DESC\n LIMIT ${limitParam}\n `;\n\n try {\n const result = await this.pool.query<{\n project_id: number;\n path: string;\n start_line: number;\n end_line: number;\n snippet: string;\n tier: string;\n source: string;\n rank_score: number;\n }>(sql, params);\n\n return result.rows.map((row) => ({\n projectId: row.project_id,\n path: row.path,\n startLine: row.start_line,\n endLine: row.end_line,\n snippet: row.snippet,\n score: row.rank_score,\n tier: row.tier,\n source: row.source,\n }));\n } catch (e) {\n process.stderr.write(`[pai-postgres] searchKeyword error: ${e}\\n`);\n return [];\n }\n }\n\n // -------------------------------------------------------------------------\n // Search — semantic (pgvector cosine distance)\n // -------------------------------------------------------------------------\n\n async searchSemantic(queryEmbedding: Float32Array, opts?: SearchOptions): Promise<SearchResult[]> {\n const maxResults = opts?.maxResults ?? 10;\n\n const conditions: string[] = [\"embedding IS NOT NULL\"];\n const params: (string | number | string)[] = [];\n let paramIdx = 1;\n\n // pgvector vector literal\n const vecStr = \"[\" + Array.from(queryEmbedding).join(\",\") + \"]\";\n params.push(vecStr);\n const vecParam = `$${paramIdx++}`;\n\n if (opts?.projectIds && opts.projectIds.length > 0) {\n const placeholders = opts.projectIds.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`project_id IN (${placeholders})`);\n params.push(...opts.projectIds);\n }\n\n if (opts?.sources && opts.sources.length > 0) {\n const placeholders = opts.sources.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`source IN (${placeholders})`);\n params.push(...opts.sources);\n }\n\n if (opts?.tiers && opts.tiers.length > 0) {\n const placeholders = opts.tiers.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`tier IN (${placeholders})`);\n params.push(...opts.tiers);\n }\n\n params.push(maxResults);\n const limitParam = `$${paramIdx}`;\n\n // <=> is cosine distance; 1 - distance = cosine similarity\n const sql = `\n SELECT\n project_id,\n path,\n start_line,\n end_line,\n text AS snippet,\n tier,\n source,\n 1 - (embedding <=> ${vecParam}::vector) AS cosine_similarity\n FROM pai_chunks\n WHERE ${conditions.join(\" AND \")}\n ORDER BY embedding <=> ${vecParam}::vector\n LIMIT ${limitParam}\n `;\n\n try {\n const result = await this.pool.query<{\n project_id: number;\n path: string;\n start_line: number;\n end_line: number;\n snippet: string;\n tier: string;\n source: string;\n cosine_similarity: number;\n }>(sql, params);\n\n const minScore = opts?.minScore ?? -Infinity;\n\n return result.rows\n .map((row) => ({\n projectId: row.project_id,\n path: row.path,\n startLine: row.start_line,\n endLine: row.end_line,\n snippet: row.snippet,\n score: row.cosine_similarity,\n tier: row.tier,\n source: row.source,\n }))\n .filter((r) => r.score >= minScore);\n } catch (e) {\n process.stderr.write(`[pai-postgres] searchSemantic error: ${e}\\n`);\n return [];\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a Buffer of Float32 LE bytes (as stored in SQLite) to number[].\n */\nfunction bufferToVector(buf: Buffer): number[] {\n const floats: number[] = [];\n for (let i = 0; i < buf.length; i += 4) {\n floats.push(buf.readFloatLE(i));\n }\n return floats;\n}\n\n/**\n * Convert a free-text query to a Postgres tsquery string.\n *\n * Uses OR (|) semantics so that a chunk matching ANY query term is returned,\n * ranked by ts_rank (which scores higher when more terms match). AND (&)\n * semantics are too strict for multi-word queries because all terms rarely\n * co-occur in a single chunk.\n *\n * Example: \"Synchrotech interview follow-up Gilles\"\n * → \"synchrotech | interview | follow | gilles\"\n * → returns chunks containing any of these words, highest-matching first\n */\nfunction buildPgTsQuery(query: string): string {\n const STOP_WORDS = new Set([\n \"a\", \"an\", \"and\", \"are\", \"as\", \"at\", \"be\", \"been\", \"but\", \"by\",\n \"do\", \"for\", \"from\", \"has\", \"have\", \"he\", \"her\", \"him\", \"his\",\n \"how\", \"i\", \"if\", \"in\", \"is\", \"it\", \"its\", \"me\", \"my\", \"not\",\n \"of\", \"on\", \"or\", \"our\", \"out\", \"she\", \"so\", \"that\", \"the\",\n \"their\", \"them\", \"they\", \"this\", \"to\", \"up\", \"us\", \"was\", \"we\",\n \"were\", \"what\", \"when\", \"who\", \"will\", \"with\", \"you\", \"your\",\n ]);\n\n const tokens = query\n .toLowerCase()\n .split(/[\\s\\p{P}]+/u)\n .filter(Boolean)\n .filter((t) => t.length >= 2)\n .filter((t) => !STOP_WORDS.has(t))\n // Sanitize: strip tsquery special characters to prevent syntax errors\n .map((t) => t.replace(/'/g, \"''\").replace(/[&|!():]/g, \"\"))\n .filter(Boolean);\n\n if (tokens.length === 0) {\n // Fallback: sanitize the raw query and use it as a single term\n const raw = query.replace(/[^a-z0-9]/gi, \" \").trim().split(/\\s+/).filter(Boolean).join(\" | \");\n return raw || \"\";\n }\n\n // Use OR (|) so that chunks matching ANY term are returned.\n // ts_rank naturally scores chunks higher when more terms match, so the\n // most relevant results still bubble to the top.\n return tokens.join(\" | \");\n}\n\n// Re-export buildFtsQuery so it is accessible without importing search.ts\nexport { buildPgTsQuery };\n"],"mappings":"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"}
1
+ {"version":3,"file":"postgres-CIxeqf_n.mjs","names":[],"sources":["../src/storage/postgres.ts"],"sourcesContent":["/**\n * PostgresBackend — implements StorageBackend using PostgreSQL + pgvector.\n *\n * Vector similarity: pgvector's <=> cosine distance operator\n * Full-text search: PostgreSQL tsvector/tsquery (replaces SQLite FTS5)\n * Connection pooling: node-postgres Pool\n *\n * Schema is initialized via docker/init.sql.\n * This module only handles runtime queries — schema creation is external.\n */\n\nimport pg from \"pg\";\nimport type { Pool, PoolClient } from \"pg\";\nimport type { StorageBackend, ChunkRow, FileRow, FederationStats } from \"./interface.js\";\nimport type { SearchResult, SearchOptions } from \"../memory/search.js\";\nimport { buildFtsQuery } from \"../memory/search.js\";\n\nconst { Pool: PgPool } = pg;\n\n// ---------------------------------------------------------------------------\n// Postgres config\n// ---------------------------------------------------------------------------\n\nexport interface PostgresConfig {\n connectionString?: string;\n host?: string;\n port?: number;\n database?: string;\n user?: string;\n password?: string;\n /** Maximum pool connections. Default 5 */\n maxConnections?: number;\n /** Connection timeout in ms. Default 5000 */\n connectionTimeoutMs?: number;\n}\n\n// ---------------------------------------------------------------------------\n// Implementation\n// ---------------------------------------------------------------------------\n\nexport class PostgresBackend implements StorageBackend {\n readonly backendType = \"postgres\" as const;\n\n private pool: Pool;\n\n constructor(config: PostgresConfig) {\n const connStr =\n config.connectionString ??\n `postgresql://${config.user ?? \"pai\"}:${config.password ?? \"pai\"}@${config.host ?? \"localhost\"}:${config.port ?? 5432}/${config.database ?? \"pai\"}`;\n\n this.pool = new PgPool({\n connectionString: connStr,\n max: config.maxConnections ?? 5,\n connectionTimeoutMillis: config.connectionTimeoutMs ?? 5000,\n idleTimeoutMillis: 30_000,\n });\n\n // Log pool errors so they don't crash the process silently\n this.pool.on(\"error\", (err) => {\n process.stderr.write(`[pai-postgres] Pool error: ${err.message}\\n`);\n });\n }\n\n // -------------------------------------------------------------------------\n // Lifecycle\n // -------------------------------------------------------------------------\n\n async close(): Promise<void> {\n await this.pool.end();\n }\n\n async getStats(): Promise<FederationStats> {\n const client = await this.pool.connect();\n try {\n const filesResult = await client.query<{ n: string }>(\n \"SELECT COUNT(*)::text AS n FROM pai_files\"\n );\n const chunksResult = await client.query<{ n: string }>(\n \"SELECT COUNT(*)::text AS n FROM pai_chunks\"\n );\n return {\n files: parseInt(filesResult.rows[0]?.n ?? \"0\", 10),\n chunks: parseInt(chunksResult.rows[0]?.n ?? \"0\", 10),\n };\n } finally {\n client.release();\n }\n }\n\n /**\n * Test the connection by running a trivial query.\n * Returns null on success, error message on failure.\n */\n async testConnection(): Promise<string | null> {\n let client: PoolClient | null = null;\n try {\n client = await this.pool.connect();\n await client.query(\"SELECT 1\");\n return null;\n } catch (e) {\n return e instanceof Error ? e.message : String(e);\n } finally {\n client?.release();\n }\n }\n\n // -------------------------------------------------------------------------\n // File tracking\n // -------------------------------------------------------------------------\n\n async getFileHash(projectId: number, path: string): Promise<string | undefined> {\n const result = await this.pool.query<{ hash: string }>(\n \"SELECT hash FROM pai_files WHERE project_id = $1 AND path = $2\",\n [projectId, path]\n );\n return result.rows[0]?.hash;\n }\n\n async upsertFile(file: FileRow): Promise<void> {\n await this.pool.query(\n `INSERT INTO pai_files (project_id, path, source, tier, hash, mtime, size)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (project_id, path) DO UPDATE SET\n source = EXCLUDED.source,\n tier = EXCLUDED.tier,\n hash = EXCLUDED.hash,\n mtime = EXCLUDED.mtime,\n size = EXCLUDED.size`,\n [file.projectId, file.path, file.source, file.tier, file.hash, file.mtime, file.size]\n );\n }\n\n // -------------------------------------------------------------------------\n // Chunk management\n // -------------------------------------------------------------------------\n\n async getChunkIds(projectId: number, path: string): Promise<string[]> {\n const result = await this.pool.query<{ id: string }>(\n \"SELECT id FROM pai_chunks WHERE project_id = $1 AND path = $2\",\n [projectId, path]\n );\n return result.rows.map((r) => r.id);\n }\n\n async deleteChunksForFile(projectId: number, path: string): Promise<void> {\n // Foreign key CASCADE handles pai_chunks deletion automatically\n // but we don't have FK to pai_chunks from pai_files, so delete explicitly\n await this.pool.query(\n \"DELETE FROM pai_chunks WHERE project_id = $1 AND path = $2\",\n [projectId, path]\n );\n }\n\n async insertChunks(chunks: ChunkRow[]): Promise<void> {\n if (chunks.length === 0) return;\n\n const client = await this.pool.connect();\n try {\n await client.query(\"BEGIN\");\n\n for (const c of chunks) {\n // embedding is null at insert time; updated separately via updateEmbedding()\n await client.query(\n `INSERT INTO pai_chunks\n (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at, fts_vector)\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n to_tsvector('simple', $9))\n ON CONFLICT (id) DO UPDATE SET\n project_id = EXCLUDED.project_id,\n source = EXCLUDED.source,\n tier = EXCLUDED.tier,\n path = EXCLUDED.path,\n start_line = EXCLUDED.start_line,\n end_line = EXCLUDED.end_line,\n hash = EXCLUDED.hash,\n text = EXCLUDED.text,\n updated_at = EXCLUDED.updated_at,\n fts_vector = EXCLUDED.fts_vector`,\n [\n c.id,\n c.projectId,\n c.source,\n c.tier,\n c.path,\n c.startLine,\n c.endLine,\n c.hash,\n c.text,\n c.updatedAt,\n ]\n );\n }\n\n await client.query(\"COMMIT\");\n } catch (e) {\n await client.query(\"ROLLBACK\");\n throw e;\n } finally {\n client.release();\n }\n }\n\n async getUnembeddedChunkIds(projectId?: number): Promise<Array<{ id: string; text: string }>> {\n if (projectId !== undefined) {\n const result = await this.pool.query<{ id: string; text: string }>(\n \"SELECT id, text FROM pai_chunks WHERE embedding IS NULL AND project_id = $1 ORDER BY id\",\n [projectId]\n );\n return result.rows;\n }\n const result = await this.pool.query<{ id: string; text: string }>(\n \"SELECT id, text FROM pai_chunks WHERE embedding IS NULL ORDER BY id\"\n );\n return result.rows;\n }\n\n async updateEmbedding(chunkId: string, embedding: Buffer): Promise<void> {\n // Deserialize the Buffer (Float32Array LE bytes) to a number[] for pgvector\n const vec = bufferToVector(embedding);\n const vecStr = \"[\" + vec.join(\",\") + \"]\";\n await this.pool.query(\n \"UPDATE pai_chunks SET embedding = $1::vector WHERE id = $2\",\n [vecStr, chunkId]\n );\n }\n\n // -------------------------------------------------------------------------\n // Search — keyword (tsvector/tsquery)\n // -------------------------------------------------------------------------\n\n async searchKeyword(query: string, opts?: SearchOptions): Promise<SearchResult[]> {\n const maxResults = opts?.maxResults ?? 10;\n\n // Build tsquery from the same token logic as buildFtsQuery, but for Postgres\n const tsQuery = buildPgTsQuery(query);\n if (!tsQuery) return [];\n\n // Use 'simple' dictionary: preserves tokens as-is, no language-specific\n // stemming. Works reliably with any language (German, French, etc.).\n const conditions: string[] = [\"fts_vector @@ to_tsquery('simple', $1)\"];\n const params: (string | number)[] = [tsQuery];\n let paramIdx = 2;\n\n if (opts?.projectIds && opts.projectIds.length > 0) {\n const placeholders = opts.projectIds.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`project_id IN (${placeholders})`);\n params.push(...opts.projectIds);\n }\n\n if (opts?.sources && opts.sources.length > 0) {\n const placeholders = opts.sources.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`source IN (${placeholders})`);\n params.push(...opts.sources);\n }\n\n if (opts?.tiers && opts.tiers.length > 0) {\n const placeholders = opts.tiers.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`tier IN (${placeholders})`);\n params.push(...opts.tiers);\n }\n\n params.push(maxResults);\n const limitParam = `$${paramIdx}`;\n\n const sql = `\n SELECT\n project_id,\n path,\n start_line,\n end_line,\n text AS snippet,\n tier,\n source,\n ts_rank(fts_vector, to_tsquery('simple', $1)) AS rank_score\n FROM pai_chunks\n WHERE ${conditions.join(\" AND \")}\n ORDER BY rank_score DESC\n LIMIT ${limitParam}\n `;\n\n try {\n const result = await this.pool.query<{\n project_id: number;\n path: string;\n start_line: number;\n end_line: number;\n snippet: string;\n tier: string;\n source: string;\n rank_score: number;\n }>(sql, params);\n\n return result.rows.map((row) => ({\n projectId: row.project_id,\n path: row.path,\n startLine: row.start_line,\n endLine: row.end_line,\n snippet: row.snippet,\n score: row.rank_score,\n tier: row.tier,\n source: row.source,\n }));\n } catch (e) {\n process.stderr.write(`[pai-postgres] searchKeyword error: ${e}\\n`);\n return [];\n }\n }\n\n // -------------------------------------------------------------------------\n // Search — semantic (pgvector cosine distance)\n // -------------------------------------------------------------------------\n\n async searchSemantic(queryEmbedding: Float32Array, opts?: SearchOptions): Promise<SearchResult[]> {\n const maxResults = opts?.maxResults ?? 10;\n\n const conditions: string[] = [\"embedding IS NOT NULL\"];\n const params: (string | number | string)[] = [];\n let paramIdx = 1;\n\n // pgvector vector literal\n const vecStr = \"[\" + Array.from(queryEmbedding).join(\",\") + \"]\";\n params.push(vecStr);\n const vecParam = `$${paramIdx++}`;\n\n if (opts?.projectIds && opts.projectIds.length > 0) {\n const placeholders = opts.projectIds.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`project_id IN (${placeholders})`);\n params.push(...opts.projectIds);\n }\n\n if (opts?.sources && opts.sources.length > 0) {\n const placeholders = opts.sources.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`source IN (${placeholders})`);\n params.push(...opts.sources);\n }\n\n if (opts?.tiers && opts.tiers.length > 0) {\n const placeholders = opts.tiers.map(() => `$${paramIdx++}`).join(\", \");\n conditions.push(`tier IN (${placeholders})`);\n params.push(...opts.tiers);\n }\n\n params.push(maxResults);\n const limitParam = `$${paramIdx}`;\n\n // <=> is cosine distance; 1 - distance = cosine similarity\n const sql = `\n SELECT\n project_id,\n path,\n start_line,\n end_line,\n text AS snippet,\n tier,\n source,\n 1 - (embedding <=> ${vecParam}::vector) AS cosine_similarity\n FROM pai_chunks\n WHERE ${conditions.join(\" AND \")}\n ORDER BY embedding <=> ${vecParam}::vector\n LIMIT ${limitParam}\n `;\n\n try {\n const result = await this.pool.query<{\n project_id: number;\n path: string;\n start_line: number;\n end_line: number;\n snippet: string;\n tier: string;\n source: string;\n cosine_similarity: number;\n }>(sql, params);\n\n const minScore = opts?.minScore ?? -Infinity;\n\n return result.rows\n .map((row) => ({\n projectId: row.project_id,\n path: row.path,\n startLine: row.start_line,\n endLine: row.end_line,\n snippet: row.snippet,\n score: row.cosine_similarity,\n tier: row.tier,\n source: row.source,\n }))\n .filter((r) => r.score >= minScore);\n } catch (e) {\n process.stderr.write(`[pai-postgres] searchSemantic error: ${e}\\n`);\n return [];\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a Buffer of Float32 LE bytes (as stored in SQLite) to number[].\n */\nfunction bufferToVector(buf: Buffer): number[] {\n const floats: number[] = [];\n for (let i = 0; i < buf.length; i += 4) {\n floats.push(buf.readFloatLE(i));\n }\n return floats;\n}\n\n/**\n * Convert a free-text query to a Postgres tsquery string.\n *\n * Uses OR (|) semantics so that a chunk matching ANY query term is returned,\n * ranked by ts_rank (which scores higher when more terms match). AND (&)\n * semantics are too strict for multi-word queries because all terms rarely\n * co-occur in a single chunk.\n *\n * Example: \"Synchrotech interview follow-up Gilles\"\n * → \"synchrotech | interview | follow | gilles\"\n * → returns chunks containing any of these words, highest-matching first\n */\nfunction buildPgTsQuery(query: string): string {\n const STOP_WORDS = new Set([\n \"a\", \"an\", \"and\", \"are\", \"as\", \"at\", \"be\", \"been\", \"but\", \"by\",\n \"do\", \"for\", \"from\", \"has\", \"have\", \"he\", \"her\", \"him\", \"his\",\n \"how\", \"i\", \"if\", \"in\", \"is\", \"it\", \"its\", \"me\", \"my\", \"not\",\n \"of\", \"on\", \"or\", \"our\", \"out\", \"she\", \"so\", \"that\", \"the\",\n \"their\", \"them\", \"they\", \"this\", \"to\", \"up\", \"us\", \"was\", \"we\",\n \"were\", \"what\", \"when\", \"who\", \"will\", \"with\", \"you\", \"your\",\n ]);\n\n const tokens = query\n .toLowerCase()\n .split(/[\\s\\p{P}]+/u)\n .filter(Boolean)\n .filter((t) => t.length >= 2)\n .filter((t) => !STOP_WORDS.has(t))\n // Sanitize: strip tsquery special characters to prevent syntax errors\n .map((t) => t.replace(/'/g, \"''\").replace(/[&|!():]/g, \"\"))\n .filter(Boolean);\n\n if (tokens.length === 0) {\n // Fallback: sanitize the raw query and use it as a single term\n const raw = query.replace(/[^a-z0-9]/gi, \" \").trim().split(/\\s+/).filter(Boolean).join(\" | \");\n return raw || \"\";\n }\n\n // Use OR (|) so that chunks matching ANY term are returned.\n // ts_rank naturally scores chunks higher when more terms match, so the\n // most relevant results still bubble to the top.\n return tokens.join(\" | \");\n}\n\n// Re-export buildFtsQuery so it is accessible without importing search.ts\nexport { buildPgTsQuery };\n"],"mappings":"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"}
@@ -0,0 +1,71 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-95iHPtFO.mjs";
2
+
3
+ //#region src/memory/reranker.ts
4
+ var reranker_exports = /* @__PURE__ */ __exportAll({
5
+ configureRerankerModel: () => configureRerankerModel,
6
+ rerankResults: () => rerankResults
7
+ });
8
+ const DEFAULT_RERANKER_MODEL = "Xenova/ms-marco-MiniLM-L-6-v2";
9
+ let _tokenizer = null;
10
+ let _model = null;
11
+ let _currentModel = null;
12
+ let _loading = null;
13
+ /**
14
+ * Configure the reranker model.
15
+ * Must be called before the first rerank() call if you want a non-default model.
16
+ */
17
+ function configureRerankerModel(model) {
18
+ const resolved = model?.trim() || DEFAULT_RERANKER_MODEL;
19
+ if (_currentModel !== null && _currentModel !== resolved) {
20
+ _tokenizer = null;
21
+ _model = null;
22
+ _loading = null;
23
+ }
24
+ _currentModel = resolved;
25
+ }
26
+ async function ensureLoaded() {
27
+ if (_tokenizer && _model) return;
28
+ if (_loading) return _loading;
29
+ _loading = (async () => {
30
+ const model = _currentModel ?? DEFAULT_RERANKER_MODEL;
31
+ const { AutoTokenizer, AutoModelForSequenceClassification } = await import("@huggingface/transformers");
32
+ _tokenizer = await AutoTokenizer.from_pretrained(model);
33
+ _model = await AutoModelForSequenceClassification.from_pretrained(model, { dtype: "q8" });
34
+ _currentModel = model;
35
+ })();
36
+ return _loading;
37
+ }
38
+ /**
39
+ * Rerank search results using a cross-encoder model.
40
+ *
41
+ * Takes the top `maxCandidates` results from a first-stage retriever,
42
+ * scores each (query, snippet) pair through the cross-encoder, and
43
+ * returns them sorted by cross-encoder relevance score.
44
+ *
45
+ * The original retrieval score is replaced with the cross-encoder score.
46
+ */
47
+ async function rerankResults(query, results, opts) {
48
+ if (results.length === 0) return [];
49
+ const maxCandidates = opts?.maxCandidates ?? 50;
50
+ const topK = opts?.topK ?? results.length;
51
+ const candidates = results.slice(0, maxCandidates);
52
+ await ensureLoaded();
53
+ const queries = new Array(candidates.length).fill(query);
54
+ const documents = candidates.map((r) => r.snippet);
55
+ const inputs = _tokenizer(queries, {
56
+ text_pair: documents,
57
+ padding: true,
58
+ truncation: true
59
+ });
60
+ const scores = (await _model(inputs)).logits.tolist();
61
+ const scored = candidates.map((result, i) => ({
62
+ ...result,
63
+ score: scores[i][0]
64
+ }));
65
+ scored.sort((a, b) => b.score - a.score);
66
+ return scored.slice(0, topK);
67
+ }
68
+
69
+ //#endregion
70
+ export { rerankResults as n, reranker_exports as r, configureRerankerModel as t };
71
+ //# sourceMappingURL=reranker-D7bRAHi6.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reranker-D7bRAHi6.mjs","names":[],"sources":["../src/memory/reranker.ts"],"sourcesContent":["/**\n * Cross-encoder reranker for PAI memory search results.\n *\n * Uses Xenova/ms-marco-MiniLM-L-6-v2 — a 22.7M param cross-encoder trained on\n * MS MARCO passage ranking. The q8 quantized ONNX model is ~23 MB.\n *\n * Cross-encoders score (query, document) pairs jointly, producing more accurate\n * relevance scores than bi-encoder cosine similarity alone. The trade-off is\n * latency: cross-encoders must score each pair independently, so they are used\n * as a reranking step on top of a fast first-stage retriever (BM25 / cosine).\n *\n * The model is loaded as a lazy singleton — no startup cost until the first\n * rerank call. Subsequent calls reuse the loaded model.\n *\n * Inspired by QMD's Qwen3-reranker step (tobi/qmd).\n */\n\nimport type { SearchResult } from \"./search.js\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_RERANKER_MODEL = \"Xenova/ms-marco-MiniLM-L-6-v2\";\n\n// ---------------------------------------------------------------------------\n// Lazy singleton\n// ---------------------------------------------------------------------------\n\nlet _tokenizer: any = null;\nlet _model: any = null;\nlet _currentModel: string | null = null;\nlet _loading: Promise<void> | null = null;\n\n/**\n * Configure the reranker model.\n * Must be called before the first rerank() call if you want a non-default model.\n */\nexport function configureRerankerModel(model?: string): void {\n const resolved = model?.trim() || DEFAULT_RERANKER_MODEL;\n if (_currentModel !== null && _currentModel !== resolved) {\n _tokenizer = null;\n _model = null;\n _loading = null;\n }\n _currentModel = resolved;\n}\n\nasync function ensureLoaded(): Promise<void> {\n if (_tokenizer && _model) return;\n if (_loading) return _loading;\n\n _loading = (async () => {\n const model = _currentModel ?? DEFAULT_RERANKER_MODEL;\n const {\n AutoTokenizer,\n AutoModelForSequenceClassification,\n } = await import(\"@huggingface/transformers\");\n\n _tokenizer = await AutoTokenizer.from_pretrained(model);\n _model = await AutoModelForSequenceClassification.from_pretrained(\n model,\n { dtype: \"q8\" },\n );\n _currentModel = model;\n })();\n\n return _loading;\n}\n\n// ---------------------------------------------------------------------------\n// Reranking\n// ---------------------------------------------------------------------------\n\nexport interface RerankOptions {\n /** Maximum number of results to return after reranking. */\n topK?: number;\n /**\n * Maximum number of candidates to rerank.\n * Cross-encoders are O(n) per candidate, so we cap to keep latency\n * reasonable. Default: 50.\n */\n maxCandidates?: number;\n}\n\n/**\n * Rerank search results using a cross-encoder model.\n *\n * Takes the top `maxCandidates` results from a first-stage retriever,\n * scores each (query, snippet) pair through the cross-encoder, and\n * returns them sorted by cross-encoder relevance score.\n *\n * The original retrieval score is replaced with the cross-encoder score.\n */\nexport async function rerankResults(\n query: string,\n results: SearchResult[],\n opts?: RerankOptions,\n): Promise<SearchResult[]> {\n if (results.length === 0) return [];\n\n const maxCandidates = opts?.maxCandidates ?? 50;\n const topK = opts?.topK ?? results.length;\n\n // Cap candidates to rerank\n const candidates = results.slice(0, maxCandidates);\n\n await ensureLoaded();\n\n // Tokenize all (query, document) pairs in a single batch\n const queries = new Array(candidates.length).fill(query);\n const documents = candidates.map((r) => r.snippet);\n\n const inputs = _tokenizer!(queries, {\n text_pair: documents,\n padding: true,\n truncation: true,\n });\n\n // Run the cross-encoder\n const output = await _model!(inputs);\n const logits = output.logits;\n\n // ms-marco-MiniLM returns raw logits (not sigmoid-normalized).\n // Higher = more relevant.\n const scores: number[][] = logits.tolist();\n\n // Build reranked results\n const scored = candidates.map((result, i) => ({\n ...result,\n score: scores[i][0],\n }));\n\n // Sort by cross-encoder score descending\n scored.sort((a, b) => b.score - a.score);\n\n return scored.slice(0, topK);\n}\n"],"mappings":";;;;;;;AAuBA,MAAM,yBAAyB;AAM/B,IAAI,aAAkB;AACtB,IAAI,SAAc;AAClB,IAAI,gBAA+B;AACnC,IAAI,WAAiC;;;;;AAMrC,SAAgB,uBAAuB,OAAsB;CAC3D,MAAM,WAAW,OAAO,MAAM,IAAI;AAClC,KAAI,kBAAkB,QAAQ,kBAAkB,UAAU;AACxD,eAAa;AACb,WAAS;AACT,aAAW;;AAEb,iBAAgB;;AAGlB,eAAe,eAA8B;AAC3C,KAAI,cAAc,OAAQ;AAC1B,KAAI,SAAU,QAAO;AAErB,aAAY,YAAY;EACtB,MAAM,QAAQ,iBAAiB;EAC/B,MAAM,EACJ,eACA,uCACE,MAAM,OAAO;AAEjB,eAAa,MAAM,cAAc,gBAAgB,MAAM;AACvD,WAAS,MAAM,mCAAmC,gBAChD,OACA,EAAE,OAAO,MAAM,CAChB;AACD,kBAAgB;KACd;AAEJ,QAAO;;;;;;;;;;;AA2BT,eAAsB,cACpB,OACA,SACA,MACyB;AACzB,KAAI,QAAQ,WAAW,EAAG,QAAO,EAAE;CAEnC,MAAM,gBAAgB,MAAM,iBAAiB;CAC7C,MAAM,OAAO,MAAM,QAAQ,QAAQ;CAGnC,MAAM,aAAa,QAAQ,MAAM,GAAG,cAAc;AAElD,OAAM,cAAc;CAGpB,MAAM,UAAU,IAAI,MAAM,WAAW,OAAO,CAAC,KAAK,MAAM;CACxD,MAAM,YAAY,WAAW,KAAK,MAAM,EAAE,QAAQ;CAElD,MAAM,SAAS,WAAY,SAAS;EAClC,WAAW;EACX,SAAS;EACT,YAAY;EACb,CAAC;CAQF,MAAM,UALS,MAAM,OAAQ,OAAO,EACd,OAIY,QAAQ;CAG1C,MAAM,SAAS,WAAW,KAAK,QAAQ,OAAO;EAC5C,GAAG;EACH,OAAO,OAAO,GAAG;EAClB,EAAE;AAGH,QAAO,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM;AAExC,QAAO,OAAO,MAAM,GAAG,KAAK"}
@@ -3402,4 +3402,4 @@ const meta = meta$1;
3402
3402
 
3403
3403
  //#endregion
3404
3404
  export { record as a, number as i, array as n, string as o, boolean as r, unknown as s, _enum as t };
3405
- //# sourceMappingURL=schemas-BY3Pjvje.mjs.map
3405
+ //# sourceMappingURL=schemas-BFIgGntb.mjs.map