@tekmidian/pai 0.9.8 → 0.9.10

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 (43) hide show
  1. package/ARCHITECTURE.md +73 -1
  2. package/README.md +54 -2
  3. package/dist/{auto-route-CruBrTf-.mjs → auto-route-DL4ViDKC.mjs} +2 -2
  4. package/dist/{auto-route-CruBrTf-.mjs.map → auto-route-DL4ViDKC.mjs.map} +1 -1
  5. package/dist/cli/index.mjs +246 -16
  6. package/dist/cli/index.mjs.map +1 -1
  7. package/dist/daemon/index.mjs +7 -6
  8. package/dist/daemon/index.mjs.map +1 -1
  9. package/dist/{daemon-WQY1kwOF.mjs → daemon-dYymOnwd.mjs} +39 -19
  10. package/dist/daemon-dYymOnwd.mjs.map +1 -0
  11. package/dist/daemon-mcp/index.mjs +46 -0
  12. package/dist/daemon-mcp/index.mjs.map +1 -1
  13. package/dist/{db-DdUperSl.mjs → db-CYmBWcjh.mjs} +35 -13
  14. package/dist/db-CYmBWcjh.mjs.map +1 -0
  15. package/dist/{detector-CNU3zCwP.mjs → detector-AzVtGLtL.mjs} +2 -2
  16. package/dist/{detector-CNU3zCwP.mjs.map → detector-AzVtGLtL.mjs.map} +1 -1
  17. package/dist/{factory-DKDPRhAN.mjs → factory-BDBIfTDC.mjs} +3 -3
  18. package/dist/{factory-DKDPRhAN.mjs.map → factory-BDBIfTDC.mjs.map} +1 -1
  19. package/dist/hooks/whisper-rules.mjs +15 -12
  20. package/dist/hooks/whisper-rules.mjs.map +2 -2
  21. package/dist/index.d.mts +3 -1
  22. package/dist/index.d.mts.map +1 -1
  23. package/dist/index.mjs +2 -2
  24. package/dist/kg-entity-D5v7RCDi.mjs +176 -0
  25. package/dist/kg-entity-D5v7RCDi.mjs.map +1 -0
  26. package/dist/{kg-extraction-BlGM40q7.mjs → kg-extraction-Uvr9fTlz.mjs} +56 -21
  27. package/dist/kg-extraction-Uvr9fTlz.mjs.map +1 -0
  28. package/dist/{search-DC1qhkKn.mjs → search-i2nlQ-JM.mjs} +48 -19
  29. package/dist/search-i2nlQ-JM.mjs.map +1 -0
  30. package/dist/{sqlite-BJrME_vg.mjs → sqlite-xUe94oUZ.mjs} +9 -2
  31. package/dist/sqlite-xUe94oUZ.mjs.map +1 -0
  32. package/dist/{tools-gMHdjmHC.mjs → tools-BNBvJNph.mjs} +285 -7
  33. package/dist/tools-BNBvJNph.mjs.map +1 -0
  34. package/package.json +1 -1
  35. package/src/hooks/ts/user-prompt/whisper-rules.ts +15 -12
  36. package/dist/daemon-WQY1kwOF.mjs.map +0 -1
  37. package/dist/db-DdUperSl.mjs.map +0 -1
  38. package/dist/kg-B5ysyRLC.mjs +0 -94
  39. package/dist/kg-B5ysyRLC.mjs.map +0 -1
  40. package/dist/kg-extraction-BlGM40q7.mjs.map +0 -1
  41. package/dist/search-DC1qhkKn.mjs.map +0 -1
  42. package/dist/sqlite-BJrME_vg.mjs.map +0 -1
  43. package/dist/tools-gMHdjmHC.mjs.map +0 -1
package/ARCHITECTURE.md CHANGED
@@ -1,4 +1,4 @@
1
- # PAI Knowledge OS — Architecture (v0.8.0)
1
+ # PAI Knowledge OS — Architecture (v0.9.10)
2
2
 
3
3
  Technical reference for PAI's architecture, database schema, CLI commands, and development setup.
4
4
 
@@ -1047,6 +1047,78 @@ These tables are populated by the PostToolUse hook classifier and queried by the
1047
1047
 
1048
1048
  **Indexes:** B-tree on project_id, session_id, type, created_at DESC, content_hash.
1049
1049
 
1050
+ ### Knowledge Graph Entity Tables (PostgreSQL)
1051
+
1052
+ Introduced in v0.9.10 to support entity deduplication and graph-completion search.
1053
+
1054
+ **`kg_entities`** — Deduplicated named entities with content-address hashing:
1055
+
1056
+ | Column | Type | Description |
1057
+ |--------|------|-------------|
1058
+ | `id` | SERIAL | Primary key |
1059
+ | `project_id` | INTEGER | Owning project (FK → registry projects) |
1060
+ | `name` | TEXT | Canonical entity name (normalized) |
1061
+ | `type` | TEXT | Entity type: `person`, `project`, `library`, `concept`, `place`, `other` |
1062
+ | `content_hash` | TEXT | SHA-256 of `lower(trim(name))` — identity key for deduplication |
1063
+ | `last_accessed_at` | TIMESTAMPTZ | Timestamp of last `memory_get` that touched this entity |
1064
+ | `created_at` | TIMESTAMPTZ | First observation timestamp |
1065
+
1066
+ The `content_hash` column carries a unique constraint per `project_id`. When two indexing passes encounter the same entity name, they resolve to the same row — no duplicates accumulate.
1067
+
1068
+ **`kg_entity_chunks`** — Join table linking entities to the chunks they appear in:
1069
+
1070
+ | Column | Type | Description |
1071
+ |--------|------|-------------|
1072
+ | `entity_id` | INTEGER | FK → kg_entities.id |
1073
+ | `chunk_id` | TEXT | FK → pai_chunks.id (SHA-256) |
1074
+
1075
+ **Indexes:** Unique on `(project_id, content_hash)`, B-tree on entity_id, chunk_id.
1076
+
1077
+ ---
1078
+
1079
+ ### Graph-Completion Search Pipeline
1080
+
1081
+ `graphCompletionSearch` in `src/memory/graph-search.ts` implements the four-stage pipeline:
1082
+
1083
+ 1. **Vector seeds** — calls the standard `memory_search` with `mode: "semantic"` to retrieve top-K chunks by embedding similarity.
1084
+ 2. **Entity resolution** — for each seed chunk, looks up associated entities via `kg_entity_chunks`.
1085
+ 3. **Graph traversal** — fetches one-hop neighbors from `kg_triples` for each resolved entity; collects their associated chunk IDs via `kg_entity_chunks`.
1086
+ 4. **Re-rank** — merges seed chunks with neighbor-expanded candidates, deduplicates, and scores the full set with the cross-encoder. Returns results sorted by final cross-encoder score.
1087
+
1088
+ The pipeline is invoked when the `memory_search` MCP tool receives `mode: "graph"`. It falls back to hybrid search in SQLite mode (no graph available).
1089
+
1090
+ ---
1091
+
1092
+ ### Feedback Weight EMA System
1093
+
1094
+ `src/memory/feedback.ts` maintains a `search_feedback` table:
1095
+
1096
+ | Column | Type | Description |
1097
+ |--------|------|-------------|
1098
+ | `chunk_id` | TEXT | FK → pai_chunks.id |
1099
+ | `project_id` | INTEGER | Owning project |
1100
+ | `weight` | REAL | Current EMA weight (default 1.0) |
1101
+ | `signal_count` | INTEGER | Total positive signals received |
1102
+ | `updated_at` | TIMESTAMPTZ | Last signal timestamp |
1103
+
1104
+ When `memory_get` is called for a chunk, a positive signal is recorded and the weight updated:
1105
+
1106
+ ```
1107
+ new_weight = alpha * 1.0 + (1 - alpha) * old_weight (alpha = 0.1 by default)
1108
+ ```
1109
+
1110
+ The `memory_search` result scorer multiplies each chunk's base score by its feedback weight before final ranking. A chunk accessed 10 times reaches approximately 1.65x its baseline score; one accessed 50 times approaches 2.0x.
1111
+
1112
+ Access timestamps are written to both `kg_entities.last_accessed_at` (for entity rows) and `pai_chunks.last_accessed_at` (for chunk rows) on every `memory_get` call. This supports the recency boost calculation and enables future LRU eviction for very large knowledge bases.
1113
+
1114
+ ---
1115
+
1116
+ ### Multi-Tenant Isolation
1117
+
1118
+ Every memory table (`pai_chunks`, `pai_files`, `kg_entities`, `kg_entity_chunks`, `kg_triples`, `pai_observations`, `pai_session_summaries`, `search_feedback`) carries a `project_id` column. All queries in the storage backend append `WHERE project_id = $1` when `all_projects` is false (the default).
1119
+
1120
+ Cross-project visibility is opt-in: pass `all_projects: true` to `memory_search`, or use `--all` with the CLI. The `memory_tunnels` MCP tool explicitly queries across projects to surface cross-project concept bridges — this is its intended purpose, not a leak.
1121
+
1050
1122
  **Content Tiers:**
1051
1123
 
1052
1124
  | Tier | Description | Example |
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PAI Knowledge OS — v0.9.7
1
+ # PAI Knowledge OS — v0.9.10
2
2
 
3
3
  Claude Code has a memory problem. Every new session starts cold — no idea what you built yesterday, what decisions you made, or where you left off. PAI fixes this.
4
4
 
@@ -393,6 +393,53 @@ This reveals unexpected intellectual bridges: the same concurrency pattern used
393
393
 
394
394
  ---
395
395
 
396
+ ## Memory Architecture
397
+
398
+ PAI's memory system uses a three-tier hybrid store inspired by Cognee's approach to knowledge graphs and retrieval. Each tier has a distinct role, and they work together to answer queries that no single store could handle alone.
399
+
400
+ ### Three-Tier Hybrid Store
401
+
402
+ | Tier | Backend | What it stores |
403
+ |------|---------|----------------|
404
+ | **Chunks + entities** | SQLite (simple mode) or PostgreSQL (full mode) | Text chunks with embeddings; named entity records with content-address hashes |
405
+ | **Knowledge graph** | PostgreSQL (`kg_triples`) | Subject-predicate-object triples with `valid_from`/`valid_to` timestamps |
406
+ | **Vector embeddings** | pgvector (full mode) | 768-dimensional Snowflake Arctic embeddings on chunks and vault notes |
407
+
408
+ ### Entity Deduplication via Content-Address Hashing
409
+
410
+ Named entities (people, projects, libraries, concepts) extracted during indexing are stored in a `kg_entities` table and deduplicated using a content-address hash derived from the entity's canonical name. Two mentions of "PostgreSQL" in different session notes resolve to a single entity row — the hash acts as a stable identity, so the graph stays normalized even as new content is indexed.
411
+
412
+ ### Graph-Completion Search Pipeline
413
+
414
+ Standard vector search finds semantically similar chunks. Graph-completion search goes further:
415
+
416
+ 1. **Vector seeds** — a semantic search returns the top-K most relevant chunks.
417
+ 2. **Graph traversal** — the entities mentioned in those chunks are looked up in `kg_triples`; their immediate neighbors are fetched (one hop).
418
+ 3. **Candidate expansion** — the neighbor entities' associated chunks are added to the result set.
419
+ 4. **Re-rank** — the expanded candidate set is re-scored by the cross-encoder, which reads each (query, result) pair together. Results are sorted by this final relevance score.
420
+
421
+ This means a query about "the PAI daemon" can surface a session note that mentions the daemon only indirectly — because a connected entity (the Unix socket, the launchd service) appears in both the graph and the note.
422
+
423
+ ### Feedback Loop with Relevance Scoring
424
+
425
+ Every search result that is subsequently retrieved via `memory_get` (i.e., actually read by the model) generates a positive feedback signal. These signals are stored and used to adjust future search weights using an exponential moving average (EMA):
426
+
427
+ ```
428
+ new_weight = alpha * signal + (1 - alpha) * old_weight
429
+ ```
430
+
431
+ The default alpha is 0.1, so recent positive signals gradually raise a chunk's effective score without overriding the semantic baseline. This creates a personalization loop: content you actually use rises in future rankings; content you skip does not.
432
+
433
+ ### Access Timestamp Tracking
434
+
435
+ Every chunk row carries a `last_accessed_at` timestamp updated on each `memory_get` call. This supports recency boost (content accessed recently scores higher) and enables future eviction policies for very large knowledge bases.
436
+
437
+ ### Multi-Tenant Support
438
+
439
+ PAI isolates memory by project. Every chunk, entity, and observation row carries a `project_id` foreign key. Searches default to the current project; the `all_projects: true` flag (or `--all` CLI option) lifts the filter. Knowledge-graph triples carry a `project_id` as well, so cross-project tunnels (`memory_tunnels`) are detected explicitly rather than accidentally.
440
+
441
+ ---
442
+
396
443
  ## Automatic Observation Capture
397
444
 
398
445
  PAI automatically classifies and stores every significant tool call during your sessions. When you edit a file, run a command, or make a decision, PAI captures it as a structured observation — building a searchable timeline of everything you've done across all projects.
@@ -754,7 +801,7 @@ External URLs (`https://`, `mailto:`, etc.) are excluded — only relative paths
754
801
 
755
802
  ## Release History
756
803
 
757
- 18 releases shipped from v0.7.2 to v0.9.7 (March 19 – April 10, 2026):
804
+ 21 releases shipped from v0.7.2 to v0.9.10 (March 19 – April 13, 2026):
758
805
 
759
806
  | Version | Feature |
760
807
  |---------|---------|
@@ -779,6 +826,9 @@ External URLs (`https://`, `mailto:`, etc.) are excluded — only relative paths
779
826
  | v0.9.5 | Budget-aware advisor mode |
780
827
  | v0.9.6 | Statusline auto-writes budget to advisor |
781
828
  | v0.9.7 | Advisor mode label in statusline, natural language mode switching |
829
+ | v0.9.8 | Privacy tags, compact search format, npx install |
830
+ | v0.9.9 | Fix advisor mode to delegate to haiku instead of hoarding in opus |
831
+ | v0.9.10 | Cognee-inspired three-tier memory: entity deduplication, graph-completion search, feedback EMA |
782
832
 
783
833
  ---
784
834
 
@@ -800,6 +850,8 @@ PAI Knowledge OS is inspired by [Daniel Miessler](https://github.com/danielmiess
800
850
 
801
851
  The automatic observation capture system — classifying tool calls into structured observations with progressive context injection — is inspired by [claude-mem](https://github.com/thedotmack/claude-mem) by [thedotmack](https://github.com/thedotmack). claude-mem demonstrated that automatic memory capture during Claude Code sessions dramatically improves continuity. PAI adapts this concept with a rule-based classifier, PostgreSQL storage, and three-layer progressive disclosure.
802
852
 
853
+ The three-store hybrid memory architecture — combining SQLite/PostgreSQL chunks with a knowledge graph and vector embeddings, graph-completion search (vector seeds → graph traversal → re-rank), and the feedback EMA relevance loop — is inspired by [Cognee](https://github.com/topoteretes/cognee) by [topoteretes](https://github.com/topoteretes). Cognee showed that unifying structured knowledge graphs with unstructured vector retrieval produces dramatically better recall. PAI adapts this pattern to the personal knowledge OS context with project-scoped multi-tenancy and content-address entity deduplication.
854
+
803
855
  ---
804
856
 
805
857
  ## License
@@ -26,7 +26,7 @@ async function autoRoute(registryDb, federation, cwd, context) {
26
26
  const markerResult = findMarkerUpward(registryDb, target);
27
27
  if (markerResult) return markerResult;
28
28
  if (context && context.trim().length > 0) {
29
- const { detectTopicShift } = await import("./detector-CNU3zCwP.mjs").then((n) => n.n);
29
+ const { detectTopicShift } = await import("./detector-AzVtGLtL.mjs").then((n) => n.n);
30
30
  const topicResult = await detectTopicShift(registryDb, federation, {
31
31
  context,
32
32
  threshold: .5
@@ -83,4 +83,4 @@ function formatAutoRouteJson(result) {
83
83
 
84
84
  //#endregion
85
85
  export { autoRoute, formatAutoRouteJson };
86
- //# sourceMappingURL=auto-route-CruBrTf-.mjs.map
86
+ //# sourceMappingURL=auto-route-DL4ViDKC.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"auto-route-CruBrTf-.mjs","names":[],"sources":["../src/session/auto-route.ts"],"sourcesContent":["/**\n * Auto-route: automatic project routing suggestion on session start.\n *\n * Given a working directory (and optional conversation context), determine\n * which registered project the session belongs to.\n *\n * Strategy (in priority order):\n * 1. Path match — exact or parent-directory match in the project registry\n * 2. Marker walk — walk up from cwd looking for Notes/PAI.md, resolve slug\n * 3. Topic match — BM25 keyword search against memory (requires context text)\n *\n * The function is stateless and works with direct DB access (no daemon\n * required), making it fast and safe to call during session startup.\n */\n\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { resolve, dirname } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { readPaiMarker } from \"../registry/pai-marker.js\";\nimport { detectProject } from \"../cli/commands/detect.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type AutoRouteMethod = \"path\" | \"marker\" | \"topic\";\n\nexport interface AutoRouteResult {\n /** Project slug */\n slug: string;\n /** Human-readable project name */\n display_name: string;\n /** Absolute path to the project root */\n root_path: string;\n /** How the project was detected */\n method: AutoRouteMethod;\n /** Confidence [0,1]: 1.0 for path/marker matches, BM25 fraction for topic */\n confidence: number;\n}\n\n// ---------------------------------------------------------------------------\n// Core function\n// ---------------------------------------------------------------------------\n\n/**\n * Determine which project a session should be routed to.\n *\n * @param registryDb Open PAI registry database\n * @param federation Memory storage backend (needed only for topic fallback)\n * @param cwd Working directory to detect from (defaults to process.cwd())\n * @param context Optional conversation text for topic-based fallback\n * @returns Best project match, or null if nothing matched\n */\nexport async function autoRoute(\n registryDb: Database,\n federation: Database | StorageBackend,\n cwd?: string,\n context?: string\n): Promise<AutoRouteResult | null> {\n const target = resolve(cwd ?? process.cwd());\n\n // -------------------------------------------------------------------------\n // Strategy 1: Path match via registry\n // -------------------------------------------------------------------------\n\n const pathMatch = detectProject(registryDb, target);\n\n if (pathMatch) {\n return {\n slug: pathMatch.slug,\n display_name: pathMatch.display_name,\n root_path: pathMatch.root_path,\n method: \"path\",\n confidence: 1.0,\n };\n }\n\n // -------------------------------------------------------------------------\n // Strategy 2: PAI.md marker file walk\n //\n // Walk up from cwd, checking <dir>/Notes/PAI.md at each level.\n // Once found, resolve the slug against the registry to get full project info.\n // -------------------------------------------------------------------------\n\n const markerResult = findMarkerUpward(registryDb, target);\n if (markerResult) {\n return markerResult;\n }\n\n // -------------------------------------------------------------------------\n // Strategy 3: Topic detection (requires context text)\n // -------------------------------------------------------------------------\n\n if (context && context.trim().length > 0) {\n // Lazy import to avoid bundler pulling in daemon/index.mjs at module load time\n const { detectTopicShift } = await import(\"../topics/detector.js\");\n const topicResult = await detectTopicShift(registryDb, federation, {\n context,\n threshold: 0.5, // Lower threshold for initial routing (vs shift detection)\n });\n\n if (topicResult.suggestedProject && topicResult.confidence > 0) {\n // Look up the full project info from the registry\n const projectRow = registryDb\n .prepare(\n \"SELECT slug, display_name, root_path FROM projects WHERE slug = ? AND status != 'archived'\"\n )\n .get(topicResult.suggestedProject) as\n | { slug: string; display_name: string; root_path: string }\n | undefined;\n\n if (projectRow) {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"topic\",\n confidence: topicResult.confidence,\n };\n }\n }\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Marker walk helper\n// ---------------------------------------------------------------------------\n\n/**\n * Walk up the directory tree from `startDir`, checking each level for a\n * `Notes/PAI.md` file. If found, read the slug and look up the project.\n *\n * Stops at the filesystem root or after 20 levels (safety guard).\n */\nfunction findMarkerUpward(\n registryDb: Database,\n startDir: string\n): AutoRouteResult | null {\n let current = startDir;\n let depth = 0;\n\n while (depth < 20) {\n const markerPath = `${current}/Notes/PAI.md`;\n\n if (existsSync(markerPath)) {\n const marker = readPaiMarker(current);\n\n if (marker && marker.status !== \"archived\") {\n // Resolve slug to full project info in the registry\n const projectRow = registryDb\n .prepare(\n \"SELECT slug, display_name, root_path FROM projects WHERE slug = ? AND status != 'archived'\"\n )\n .get(marker.slug) as\n | { slug: string; display_name: string; root_path: string }\n | undefined;\n\n if (projectRow) {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"marker\",\n confidence: 1.0,\n };\n }\n }\n }\n\n const parent = dirname(current);\n if (parent === current) break; // Reached filesystem root\n current = parent;\n depth++;\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Format helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Format an AutoRouteResult as a human-readable string for CLI output.\n */\nexport function formatAutoRoute(result: AutoRouteResult): string {\n const lines: string[] = [\n `slug: ${result.slug}`,\n `display_name: ${result.display_name}`,\n `root_path: ${result.root_path}`,\n `method: ${result.method}`,\n `confidence: ${(result.confidence * 100).toFixed(0)}%`,\n ];\n return lines.join(\"\\n\");\n}\n\n/**\n * Format an AutoRouteResult as JSON for machine consumption.\n */\nexport function formatAutoRouteJson(result: AutoRouteResult): string {\n return JSON.stringify(result, null, 2);\n}\n"],"mappings":";;;;;;;;;;;;;;;AAsDA,eAAsB,UACpB,YACA,YACA,KACA,SACiC;CACjC,MAAM,SAAS,QAAQ,OAAO,QAAQ,KAAK,CAAC;CAM5C,MAAM,YAAY,cAAc,YAAY,OAAO;AAEnD,KAAI,UACF,QAAO;EACL,MAAM,UAAU;EAChB,cAAc,UAAU;EACxB,WAAW,UAAU;EACrB,QAAQ;EACR,YAAY;EACb;CAUH,MAAM,eAAe,iBAAiB,YAAY,OAAO;AACzD,KAAI,aACF,QAAO;AAOT,KAAI,WAAW,QAAQ,MAAM,CAAC,SAAS,GAAG;EAExC,MAAM,EAAE,qBAAqB,MAAM,OAAO;EAC1C,MAAM,cAAc,MAAM,iBAAiB,YAAY,YAAY;GACjE;GACA,WAAW;GACZ,CAAC;AAEF,MAAI,YAAY,oBAAoB,YAAY,aAAa,GAAG;GAE9D,MAAM,aAAa,WAChB,QACC,6FACD,CACA,IAAI,YAAY,iBAAiB;AAIpC,OAAI,WACF,QAAO;IACL,MAAM,WAAW;IACjB,cAAc,WAAW;IACzB,WAAW,WAAW;IACtB,QAAQ;IACR,YAAY,YAAY;IACzB;;;AAKP,QAAO;;;;;;;;AAaT,SAAS,iBACP,YACA,UACwB;CACxB,IAAI,UAAU;CACd,IAAI,QAAQ;AAEZ,QAAO,QAAQ,IAAI;AAGjB,MAAI,WAFe,GAAG,QAAQ,eAEJ,EAAE;GAC1B,MAAM,SAAS,cAAc,QAAQ;AAErC,OAAI,UAAU,OAAO,WAAW,YAAY;IAE1C,MAAM,aAAa,WAChB,QACC,6FACD,CACA,IAAI,OAAO,KAAK;AAInB,QAAI,WACF,QAAO;KACL,MAAM,WAAW;KACjB,cAAc,WAAW;KACzB,WAAW,WAAW;KACtB,QAAQ;KACR,YAAY;KACb;;;EAKP,MAAM,SAAS,QAAQ,QAAQ;AAC/B,MAAI,WAAW,QAAS;AACxB,YAAU;AACV;;AAGF,QAAO;;;;;AAwBT,SAAgB,oBAAoB,QAAiC;AACnE,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE"}
1
+ {"version":3,"file":"auto-route-DL4ViDKC.mjs","names":[],"sources":["../src/session/auto-route.ts"],"sourcesContent":["/**\n * Auto-route: automatic project routing suggestion on session start.\n *\n * Given a working directory (and optional conversation context), determine\n * which registered project the session belongs to.\n *\n * Strategy (in priority order):\n * 1. Path match — exact or parent-directory match in the project registry\n * 2. Marker walk — walk up from cwd looking for Notes/PAI.md, resolve slug\n * 3. Topic match — BM25 keyword search against memory (requires context text)\n *\n * The function is stateless and works with direct DB access (no daemon\n * required), making it fast and safe to call during session startup.\n */\n\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { resolve, dirname } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { readPaiMarker } from \"../registry/pai-marker.js\";\nimport { detectProject } from \"../cli/commands/detect.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type AutoRouteMethod = \"path\" | \"marker\" | \"topic\";\n\nexport interface AutoRouteResult {\n /** Project slug */\n slug: string;\n /** Human-readable project name */\n display_name: string;\n /** Absolute path to the project root */\n root_path: string;\n /** How the project was detected */\n method: AutoRouteMethod;\n /** Confidence [0,1]: 1.0 for path/marker matches, BM25 fraction for topic */\n confidence: number;\n}\n\n// ---------------------------------------------------------------------------\n// Core function\n// ---------------------------------------------------------------------------\n\n/**\n * Determine which project a session should be routed to.\n *\n * @param registryDb Open PAI registry database\n * @param federation Memory storage backend (needed only for topic fallback)\n * @param cwd Working directory to detect from (defaults to process.cwd())\n * @param context Optional conversation text for topic-based fallback\n * @returns Best project match, or null if nothing matched\n */\nexport async function autoRoute(\n registryDb: Database,\n federation: Database | StorageBackend,\n cwd?: string,\n context?: string\n): Promise<AutoRouteResult | null> {\n const target = resolve(cwd ?? process.cwd());\n\n // -------------------------------------------------------------------------\n // Strategy 1: Path match via registry\n // -------------------------------------------------------------------------\n\n const pathMatch = detectProject(registryDb, target);\n\n if (pathMatch) {\n return {\n slug: pathMatch.slug,\n display_name: pathMatch.display_name,\n root_path: pathMatch.root_path,\n method: \"path\",\n confidence: 1.0,\n };\n }\n\n // -------------------------------------------------------------------------\n // Strategy 2: PAI.md marker file walk\n //\n // Walk up from cwd, checking <dir>/Notes/PAI.md at each level.\n // Once found, resolve the slug against the registry to get full project info.\n // -------------------------------------------------------------------------\n\n const markerResult = findMarkerUpward(registryDb, target);\n if (markerResult) {\n return markerResult;\n }\n\n // -------------------------------------------------------------------------\n // Strategy 3: Topic detection (requires context text)\n // -------------------------------------------------------------------------\n\n if (context && context.trim().length > 0) {\n // Lazy import to avoid bundler pulling in daemon/index.mjs at module load time\n const { detectTopicShift } = await import(\"../topics/detector.js\");\n const topicResult = await detectTopicShift(registryDb, federation, {\n context,\n threshold: 0.5, // Lower threshold for initial routing (vs shift detection)\n });\n\n if (topicResult.suggestedProject && topicResult.confidence > 0) {\n // Look up the full project info from the registry\n const projectRow = registryDb\n .prepare(\n \"SELECT slug, display_name, root_path FROM projects WHERE slug = ? AND status != 'archived'\"\n )\n .get(topicResult.suggestedProject) as\n | { slug: string; display_name: string; root_path: string }\n | undefined;\n\n if (projectRow) {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"topic\",\n confidence: topicResult.confidence,\n };\n }\n }\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Marker walk helper\n// ---------------------------------------------------------------------------\n\n/**\n * Walk up the directory tree from `startDir`, checking each level for a\n * `Notes/PAI.md` file. If found, read the slug and look up the project.\n *\n * Stops at the filesystem root or after 20 levels (safety guard).\n */\nfunction findMarkerUpward(\n registryDb: Database,\n startDir: string\n): AutoRouteResult | null {\n let current = startDir;\n let depth = 0;\n\n while (depth < 20) {\n const markerPath = `${current}/Notes/PAI.md`;\n\n if (existsSync(markerPath)) {\n const marker = readPaiMarker(current);\n\n if (marker && marker.status !== \"archived\") {\n // Resolve slug to full project info in the registry\n const projectRow = registryDb\n .prepare(\n \"SELECT slug, display_name, root_path FROM projects WHERE slug = ? AND status != 'archived'\"\n )\n .get(marker.slug) as\n | { slug: string; display_name: string; root_path: string }\n | undefined;\n\n if (projectRow) {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"marker\",\n confidence: 1.0,\n };\n }\n }\n }\n\n const parent = dirname(current);\n if (parent === current) break; // Reached filesystem root\n current = parent;\n depth++;\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Format helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Format an AutoRouteResult as a human-readable string for CLI output.\n */\nexport function formatAutoRoute(result: AutoRouteResult): string {\n const lines: string[] = [\n `slug: ${result.slug}`,\n `display_name: ${result.display_name}`,\n `root_path: ${result.root_path}`,\n `method: ${result.method}`,\n `confidence: ${(result.confidence * 100).toFixed(0)}%`,\n ];\n return lines.join(\"\\n\");\n}\n\n/**\n * Format an AutoRouteResult as JSON for machine consumption.\n */\nexport function formatAutoRouteJson(result: AutoRouteResult): string {\n return JSON.stringify(result, null, 2);\n}\n"],"mappings":";;;;;;;;;;;;;;;AAsDA,eAAsB,UACpB,YACA,YACA,KACA,SACiC;CACjC,MAAM,SAAS,QAAQ,OAAO,QAAQ,KAAK,CAAC;CAM5C,MAAM,YAAY,cAAc,YAAY,OAAO;AAEnD,KAAI,UACF,QAAO;EACL,MAAM,UAAU;EAChB,cAAc,UAAU;EACxB,WAAW,UAAU;EACrB,QAAQ;EACR,YAAY;EACb;CAUH,MAAM,eAAe,iBAAiB,YAAY,OAAO;AACzD,KAAI,aACF,QAAO;AAOT,KAAI,WAAW,QAAQ,MAAM,CAAC,SAAS,GAAG;EAExC,MAAM,EAAE,qBAAqB,MAAM,OAAO;EAC1C,MAAM,cAAc,MAAM,iBAAiB,YAAY,YAAY;GACjE;GACA,WAAW;GACZ,CAAC;AAEF,MAAI,YAAY,oBAAoB,YAAY,aAAa,GAAG;GAE9D,MAAM,aAAa,WAChB,QACC,6FACD,CACA,IAAI,YAAY,iBAAiB;AAIpC,OAAI,WACF,QAAO;IACL,MAAM,WAAW;IACjB,cAAc,WAAW;IACzB,WAAW,WAAW;IACtB,QAAQ;IACR,YAAY,YAAY;IACzB;;;AAKP,QAAO;;;;;;;;AAaT,SAAS,iBACP,YACA,UACwB;CACxB,IAAI,UAAU;CACd,IAAI,QAAQ;AAEZ,QAAO,QAAQ,IAAI;AAGjB,MAAI,WAFe,GAAG,QAAQ,eAEJ,EAAE;GAC1B,MAAM,SAAS,cAAc,QAAQ;AAErC,OAAI,UAAU,OAAO,WAAW,YAAY;IAE1C,MAAM,aAAa,WAChB,QACC,6FACD,CACA,IAAI,OAAO,KAAK;AAInB,QAAI,WACF,QAAO;KACL,MAAM,WAAW;KACjB,cAAc,WAAW;KACzB,WAAW,WAAW;KACtB,QAAQ;KACR,YAAY;KACb;;;EAKP,MAAM,SAAS,QAAQ,QAAQ;AAC/B,MAAI,WAAW,QAAS;AACxB,YAAU;AACV;;AAGF,QAAO;;;;;AAwBT,SAAgB,oBAAoB,QAAiC;AACnE,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE"}
@@ -3,18 +3,18 @@ import { n as openRegistry } from "../db-BtuN768f.mjs";
3
3
  import { _ as warn, a as fmtDate, c as ok, d as scaffoldProjectDirs, f as shortenPath, i as err, l as renderTable, m as slugify, n as dim, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold, u as resolvePath } from "../utils-QSfKagcj.mjs";
4
4
  import { a as slugify$1, i as parseSessionFilename, n as decodeEncodedDir, t as buildEncodedDirMap } from "../migrate-jokLenje.mjs";
5
5
  import { n as ensurePaiMarker, t as discoverPaiMarkers } from "../pai-marker-CXQPX2P6.mjs";
6
- import { n as openFederation } from "../db-DdUperSl.mjs";
6
+ import { n as openFederation } from "../db-CYmBWcjh.mjs";
7
7
  import "../helpers-OCVFgprQ.mjs";
8
8
  import { i as indexProject, n as indexAll, t as embedChunks } from "../sync-CdHSL9Kc.mjs";
9
9
  import "../embeddings-DGRAPAYb.mjs";
10
10
  import { t as STOP_WORDS } from "../stop-words-BaMEGVeY.mjs";
11
- import { n as populateSlugs, r as searchMemory } from "../search-DC1qhkKn.mjs";
11
+ import { n as populateSlugs, r as searchMemory } from "../search-i2nlQ-JM.mjs";
12
12
  import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "../detect-CdaA48EI.mjs";
13
- import { t as extractAndStoreTriples } from "../kg-extraction-BlGM40q7.mjs";
13
+ import { t as extractAndStoreTriples } from "../kg-extraction-Uvr9fTlz.mjs";
14
14
  import { t as PaiClient } from "../ipc-client-CoyUHPod.mjs";
15
15
  import { a as expandHome, i as ensureConfigDir, n as CONFIG_FILE$2, o as loadConfig, t as CONFIG_DIR } from "../config-BuhHWyOK.mjs";
16
- import { t as createStorageBackend } from "../factory-DKDPRhAN.mjs";
17
- import { i as kgQuery } from "../kg-B5ysyRLC.mjs";
16
+ import { t as createStorageBackend } from "../factory-BDBIfTDC.mjs";
17
+ import { s as kgQuery } from "../kg-entity-D5v7RCDi.mjs";
18
18
  import { appendFileSync, chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
19
19
  import { homedir, platform, tmpdir } from "node:os";
20
20
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -1682,9 +1682,9 @@ function cmdActive(db, opts) {
1682
1682
  ], rows));
1683
1683
  }
1684
1684
  async function cmdAutoRoute(opts) {
1685
- const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("../auto-route-CruBrTf-.mjs");
1685
+ const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("../auto-route-DL4ViDKC.mjs");
1686
1686
  const { openRegistry } = await import("../db-BtuN768f.mjs").then((n) => n.t);
1687
- const { createStorageBackend } = await import("../factory-DKDPRhAN.mjs").then((n) => n.n);
1687
+ const { createStorageBackend } = await import("../factory-BDBIfTDC.mjs").then((n) => n.n);
1688
1688
  const { loadConfig } = await import("../config-BuhHWyOK.mjs").then((n) => n.r);
1689
1689
  const config = loadConfig();
1690
1690
  const registryDb = openRegistry();
@@ -3245,7 +3245,7 @@ function registerSearchCommand(memoryCmd, getDb) {
3245
3245
  }
3246
3246
  const recencyDays = parseInt(opts.recency ?? String(searchConfig.recencyBoostDays), 10);
3247
3247
  if (recencyDays > 0) {
3248
- const { applyRecencyBoost } = await import("../search-DC1qhkKn.mjs").then((n) => n.o);
3248
+ const { applyRecencyBoost } = await import("../search-i2nlQ-JM.mjs").then((n) => n.o);
3249
3249
  console.log(dim(`Applying recency boost (half-life: ${recencyDays} days)...`));
3250
3250
  results = applyRecencyBoost(results, recencyDays);
3251
3251
  }
@@ -3794,7 +3794,7 @@ function cmdLogs(opts) {
3794
3794
  }
3795
3795
  function registerDaemonCommands(daemonCmd) {
3796
3796
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
3797
- const { serve } = await import("../daemon-WQY1kwOF.mjs").then((n) => n.t);
3797
+ const { serve } = await import("../daemon-dYymOnwd.mjs").then((n) => n.t);
3798
3798
  const { loadConfig: lc, ensureConfigDir } = await import("../config-BuhHWyOK.mjs").then((n) => n.r);
3799
3799
  ensureConfigDir();
3800
3800
  await serve(lc());
@@ -7659,6 +7659,7 @@ async function backfillKgFromNotes(options = {}) {
7659
7659
  const backend = await createStorageBackend(config);
7660
7660
  if (backend.backendType !== "postgres") throw new Error("Postgres backend unavailable — fell back to SQLite. Cannot backfill KG.");
7661
7661
  const pool = backend.getPool();
7662
+ const federationDb = openFederation();
7662
7663
  const registry = openRegistry();
7663
7664
  let projects;
7664
7665
  try {
@@ -7705,7 +7706,8 @@ async function backfillKgFromNotes(options = {}) {
7705
7706
  projectId: project.id,
7706
7707
  sessionId: `backfill:${notePath}`,
7707
7708
  gitLog: "",
7708
- model: "sonnet"
7709
+ model: "sonnet",
7710
+ federationDb
7709
7711
  });
7710
7712
  result.notes_processed++;
7711
7713
  result.triples_extracted += stats.extracted;
@@ -7719,13 +7721,14 @@ async function backfillKgFromNotes(options = {}) {
7719
7721
  }
7720
7722
  }
7721
7723
  if (!options.dryRun) saveState(state);
7724
+ federationDb.close();
7722
7725
  await backend.close();
7723
7726
  return result;
7724
7727
  }
7725
7728
 
7726
7729
  //#endregion
7727
7730
  //#region src/cli/commands/kg.ts
7728
- async function getPool() {
7731
+ async function getPool$1() {
7729
7732
  const config = loadConfig();
7730
7733
  if (config.storageBackend !== "postgres") {
7731
7734
  console.error(err(" KG commands require Postgres backend."));
@@ -7782,8 +7785,8 @@ async function cmdBackfill(opts) {
7782
7785
  process.exit(1);
7783
7786
  }
7784
7787
  }
7785
- async function cmdQuery(opts) {
7786
- const { pool, close } = await getPool();
7788
+ async function cmdQuery$1(opts) {
7789
+ const { pool, close } = await getPool$1();
7787
7790
  try {
7788
7791
  let projectId;
7789
7792
  if (opts.project) {
@@ -7821,7 +7824,7 @@ async function cmdQuery(opts) {
7821
7824
  }
7822
7825
  async function cmdList(opts) {
7823
7826
  const limit = opts.limit ? parseInt(opts.limit, 10) : 50;
7824
- const { pool, close } = await getPool();
7827
+ const { pool, close } = await getPool$1();
7825
7828
  try {
7826
7829
  let projectId;
7827
7830
  if (opts.project) {
@@ -7844,7 +7847,7 @@ async function cmdList(opts) {
7844
7847
  }
7845
7848
  }
7846
7849
  async function cmdStats() {
7847
- const { pool, close } = await getPool();
7850
+ const { pool, close } = await getPool$1();
7848
7851
  try {
7849
7852
  const totals = await pool.query(`SELECT
7850
7853
  COUNT(*)::text AS total,
@@ -7880,7 +7883,7 @@ function registerKgCommands(kgCmd) {
7880
7883
  await cmdBackfill(opts);
7881
7884
  });
7882
7885
  kgCmd.command("query").description("Query KG triples by subject, predicate, object, time, or project").option("--subject <s>", "Filter by subject").option("--predicate <p>", "Filter by predicate").option("--object <o>", "Filter by object").option("--as-of <date>", "Point-in-time query (YYYY-MM-DD or ISO 8601)").option("--project <slug>", "Restrict to a project slug").option("--json", "Output raw JSON").action(async (opts) => {
7883
- await cmdQuery(opts);
7886
+ await cmdQuery$1(opts);
7884
7887
  });
7885
7888
  kgCmd.command("list").description("List currently-valid triples").option("--project <slug>", "Restrict to a project slug").option("--limit <n>", "Maximum triples to print", "50").action(async (opts) => {
7886
7889
  await cmdList(opts);
@@ -7890,6 +7893,232 @@ function registerKgCommands(kgCmd) {
7890
7893
  });
7891
7894
  }
7892
7895
 
7896
+ //#endregion
7897
+ //#region src/cli/commands/db.ts
7898
+ function getSqliteDb() {
7899
+ return openFederation();
7900
+ }
7901
+ function sqliteQuery(sql) {
7902
+ const db = getSqliteDb();
7903
+ try {
7904
+ const raw = db.prepare(sql).all();
7905
+ if (raw.length === 0) return {
7906
+ columns: [],
7907
+ rows: []
7908
+ };
7909
+ const columns = Object.keys(raw[0]);
7910
+ return {
7911
+ columns,
7912
+ rows: raw.map((r) => columns.map((c) => r[c]))
7913
+ };
7914
+ } finally {
7915
+ db.close();
7916
+ }
7917
+ }
7918
+ function sqliteTables() {
7919
+ const db = getSqliteDb();
7920
+ try {
7921
+ return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map((r) => r.name);
7922
+ } finally {
7923
+ db.close();
7924
+ }
7925
+ }
7926
+ function sqliteSchema(table) {
7927
+ const db = getSqliteDb();
7928
+ try {
7929
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) throw new Error(`Invalid table name: ${table}`);
7930
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
7931
+ if (rows.length === 0) throw new Error(`Table not found: ${table}`);
7932
+ return {
7933
+ columns: [
7934
+ "cid",
7935
+ "name",
7936
+ "type",
7937
+ "notnull",
7938
+ "default",
7939
+ "pk"
7940
+ ],
7941
+ data: rows.map((r) => [
7942
+ r.cid,
7943
+ r.name,
7944
+ r.type,
7945
+ r.notnull ? "NOT NULL" : "",
7946
+ r.dflt_value ?? "",
7947
+ r.pk ? "PK" : ""
7948
+ ])
7949
+ };
7950
+ } finally {
7951
+ db.close();
7952
+ }
7953
+ }
7954
+ async function getPool() {
7955
+ const pgConfig = loadConfig().postgres ?? {};
7956
+ const { Pool } = await import("pg");
7957
+ const pool = new Pool(pgConfig.connectionString ? { connectionString: pgConfig.connectionString } : {
7958
+ host: pgConfig.host ?? "localhost",
7959
+ port: pgConfig.port ?? 5432,
7960
+ database: pgConfig.database ?? "pai",
7961
+ user: pgConfig.user ?? "pai",
7962
+ password: pgConfig.password ?? "pai",
7963
+ connectionTimeoutMillis: pgConfig.connectionTimeoutMs ?? 5e3
7964
+ });
7965
+ try {
7966
+ (await pool.connect()).release();
7967
+ } catch (e) {
7968
+ await pool.end();
7969
+ const msg = e instanceof Error ? e.message : String(e);
7970
+ throw new Error(`Cannot connect to Postgres: ${msg}`);
7971
+ }
7972
+ return pool;
7973
+ }
7974
+ async function postgresQuery(sql) {
7975
+ const pool = await getPool();
7976
+ try {
7977
+ const result = await pool.query(sql);
7978
+ const columns = result.fields.map((f) => f.name);
7979
+ return {
7980
+ columns,
7981
+ rows: result.rows.map((r) => columns.map((c) => r[c]))
7982
+ };
7983
+ } finally {
7984
+ await pool.end();
7985
+ }
7986
+ }
7987
+ async function postgresTables() {
7988
+ const pool = await getPool();
7989
+ try {
7990
+ return (await pool.query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")).rows.map((r) => r.tablename);
7991
+ } finally {
7992
+ await pool.end();
7993
+ }
7994
+ }
7995
+ async function postgresSchema(table) {
7996
+ const pool = await getPool();
7997
+ try {
7998
+ const result = await pool.query(`SELECT column_name, data_type, is_nullable, column_default
7999
+ FROM information_schema.columns
8000
+ WHERE table_schema = 'public' AND table_name = $1
8001
+ ORDER BY ordinal_position`, [table]);
8002
+ if (result.rows.length === 0) throw new Error(`Table not found in public schema: ${table}`);
8003
+ return {
8004
+ columns: [
8005
+ "column",
8006
+ "type",
8007
+ "nullable",
8008
+ "default"
8009
+ ],
8010
+ rows: result.rows.map((r) => [
8011
+ r.column_name,
8012
+ r.data_type,
8013
+ r.is_nullable === "YES" ? "YES" : "NO",
8014
+ r.column_default ?? ""
8015
+ ])
8016
+ };
8017
+ } finally {
8018
+ await pool.end();
8019
+ }
8020
+ }
8021
+ function printResult(result, json) {
8022
+ if (json) {
8023
+ const objects = result.rows.map((row) => {
8024
+ const obj = {};
8025
+ result.columns.forEach((col, i) => {
8026
+ obj[col] = row[i];
8027
+ });
8028
+ return obj;
8029
+ });
8030
+ console.log(JSON.stringify(objects, null, 2));
8031
+ return;
8032
+ }
8033
+ if (result.rows.length === 0) {
8034
+ console.log();
8035
+ console.log(dim(" (no rows)"));
8036
+ console.log();
8037
+ return;
8038
+ }
8039
+ const stringRows = result.rows.map((row) => row.map((cell) => {
8040
+ if (cell === null || cell === void 0) return dim("NULL");
8041
+ if (typeof cell === "object") return JSON.stringify(cell);
8042
+ return String(cell);
8043
+ }));
8044
+ console.log();
8045
+ console.log(renderTable(result.columns, stringRows));
8046
+ console.log();
8047
+ console.log(dim(` ${result.rows.length} row(s)`));
8048
+ console.log();
8049
+ }
8050
+ async function cmdQuery(sql, opts) {
8051
+ const target = opts.db ?? "sqlite";
8052
+ console.log();
8053
+ console.log(header(` Query [${target}]`) + dim(` ${sql.slice(0, 80)}${sql.length > 80 ? "…" : ""}`));
8054
+ try {
8055
+ let result;
8056
+ if (target === "postgres") result = await postgresQuery(sql);
8057
+ else result = sqliteQuery(sql);
8058
+ printResult(result, opts.json ?? false);
8059
+ } catch (e) {
8060
+ const msg = e instanceof Error ? e.message : String(e);
8061
+ console.error(err(` ${msg}`));
8062
+ process.exit(1);
8063
+ }
8064
+ }
8065
+ async function cmdTables(opts) {
8066
+ const target = opts.db ?? "sqlite";
8067
+ console.log();
8068
+ console.log(header(` Tables [${target}]`));
8069
+ console.log();
8070
+ try {
8071
+ let tables;
8072
+ if (target === "postgres") tables = await postgresTables();
8073
+ else tables = sqliteTables();
8074
+ if (opts.json) {
8075
+ console.log(JSON.stringify(tables, null, 2));
8076
+ return;
8077
+ }
8078
+ if (tables.length === 0) console.log(dim(" (no tables found)"));
8079
+ else for (const t of tables) console.log(` ${bold(t)}`);
8080
+ console.log();
8081
+ console.log(dim(` ${tables.length} table(s)`));
8082
+ console.log();
8083
+ } catch (e) {
8084
+ const msg = e instanceof Error ? e.message : String(e);
8085
+ console.error(err(` ${msg}`));
8086
+ process.exit(1);
8087
+ }
8088
+ }
8089
+ async function cmdSchema(table, opts) {
8090
+ const target = opts.db ?? "sqlite";
8091
+ console.log();
8092
+ console.log(header(` Schema: ${table} [${target}]`));
8093
+ try {
8094
+ let result;
8095
+ if (target === "postgres") result = await postgresSchema(table);
8096
+ else {
8097
+ const raw = sqliteSchema(table);
8098
+ result = {
8099
+ columns: raw.columns,
8100
+ rows: raw.data
8101
+ };
8102
+ }
8103
+ printResult(result, opts.json ?? false);
8104
+ } catch (e) {
8105
+ const msg = e instanceof Error ? e.message : String(e);
8106
+ console.error(err(` ${msg}`));
8107
+ process.exit(1);
8108
+ }
8109
+ }
8110
+ function registerDbCommands(dbCmd) {
8111
+ dbCmd.command("query <sql>").description("Run a SQL query against the selected database").option("--db <target>", "Database target: sqlite (default) or postgres", "sqlite").option("--json", "Output results as JSON array").action(async (sql, opts) => {
8112
+ await cmdQuery(sql, opts);
8113
+ });
8114
+ dbCmd.command("tables").description("List all tables in the selected database").option("--db <target>", "Database target: sqlite (default) or postgres", "sqlite").option("--json", "Output as JSON array").action(async (opts) => {
8115
+ await cmdTables(opts);
8116
+ });
8117
+ dbCmd.command("schema <table>").description("Show column schema for a table").option("--db <target>", "Database target: sqlite (default) or postgres", "sqlite").option("--json", "Output as JSON array").action(async (table, opts) => {
8118
+ await cmdSchema(table, opts);
8119
+ });
8120
+ }
8121
+
7893
8122
  //#endregion
7894
8123
  //#region src/cli/index.ts
7895
8124
  /**
@@ -7939,6 +8168,7 @@ registerUpdateCommand(program);
7939
8168
  registerNotifyCommands(program.command("notify").description("Notification config: status, get, set, test, send"));
7940
8169
  registerTopicCommands(program.command("topic").description("Topic shift detection: check whether context has drifted to a different project"));
7941
8170
  registerKgCommands(program.command("kg").description("Temporal knowledge graph: backfill, query, list, stats"));
8171
+ registerDbCommands(program.command("db").description("Database inspection: query, tables, schema (sqlite or postgres)"));
7942
8172
  registerObsidianCommands(program.command("obsidian").description("Obsidian vault: sync project notes, view status, open in Obsidian"), getDb);
7943
8173
  registerZettelCommands(program.command("zettel").description("Zettelkasten intelligence: explore, surprise, converse, themes, health, suggest"), getDb);
7944
8174
  registerObservationCommands(program.command("observation").description("Observation capture: list, search, and stats"));