@tekmidian/pai 0.66.0 → 0.66.2

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 (165) hide show
  1. package/dist/{aibroker-client-B8c42Lh8.mjs → aibroker-client-CHEEvJZW.mjs} +2 -2
  2. package/dist/{aibroker-client-C5Fw7DNz.mjs → aibroker-client-Dfv7j1Od.mjs} +3 -3
  3. package/dist/{aibroker-client-C5Fw7DNz.mjs.map → aibroker-client-Dfv7j1Od.mjs.map} +1 -1
  4. package/dist/{auto-route-o0BOsXn7.mjs → auto-route-BnizyALK.mjs} +65 -4
  5. package/dist/auto-route-BnizyALK.mjs.map +1 -0
  6. package/dist/auto-route-D6fW1Q8z.mjs +3 -0
  7. package/dist/{chain-D4PuQ2c-.mjs → chain-D5876UQX.mjs} +3 -4
  8. package/dist/{chain-D4PuQ2c-.mjs.map → chain-D5876UQX.mjs.map} +1 -1
  9. package/dist/cli/index.mjs +18 -23
  10. package/dist/cli/index.mjs.map +1 -1
  11. package/dist/cli/program.mjs +17 -22
  12. package/dist/{config-BbLFD7Uf.mjs → config-C-LiGlop.mjs} +2 -2
  13. package/dist/{config-BbLFD7Uf.mjs.map → config-C-LiGlop.mjs.map} +1 -1
  14. package/dist/{config-YinjgXEJ.mjs → config-DKIA7wgF.mjs} +1 -1
  15. package/dist/{context-handover-cache-pkHzmL3c.mjs → context-handover-cache-9PGvXRIw.mjs} +984 -10
  16. package/dist/context-handover-cache-9PGvXRIw.mjs.map +1 -0
  17. package/dist/daemon/index.mjs +17 -19
  18. package/dist/daemon/index.mjs.map +1 -1
  19. package/dist/daemon-BUgNvMUh.mjs +5029 -0
  20. package/dist/daemon-BUgNvMUh.mjs.map +1 -0
  21. package/dist/daemon-DL0eglMJ.mjs +18 -0
  22. package/dist/daemon-mcp/index.mjs +15 -10
  23. package/dist/daemon-mcp/index.mjs.map +1 -1
  24. package/dist/{embeddings-CcscYWwk.mjs → embeddings-CEBGrzwu.mjs} +1 -1
  25. package/dist/{embeddings-Bx3q0QOY.mjs → embeddings-DOLZnT1X.mjs} +1 -1
  26. package/dist/{embeddings-Bx3q0QOY.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
  27. package/dist/{env-JNEIrQWg.mjs → env-DiolswKQ.mjs} +1 -1
  28. package/dist/{env-JNEIrQWg.mjs.map → env-DiolswKQ.mjs.map} +1 -1
  29. package/dist/factory-DrBg24UT.mjs +8 -0
  30. package/dist/factory-Ypf8r0fK.mjs +2892 -0
  31. package/dist/factory-Ypf8r0fK.mjs.map +1 -0
  32. package/dist/{fallback-B5svk75f.mjs → fallback-D51R4shY.mjs} +27 -13
  33. package/dist/fallback-D51R4shY.mjs.map +1 -0
  34. package/dist/{chunker-BH4i-F2b.mjs → helpers-CZsi_49C.mjs} +221 -2
  35. package/dist/helpers-CZsi_49C.mjs.map +1 -0
  36. package/dist/hooks/block-sleep-poll.mjs.map +2 -2
  37. package/dist/hooks/load-project-context.mjs.map +2 -2
  38. package/dist/hooks/worker-guard.mjs +5 -0
  39. package/dist/hooks/worker-guard.mjs.map +2 -2
  40. package/dist/hooks/worker-status-line.mjs +1 -0
  41. package/dist/hooks/worker-status-line.mjs.map +2 -2
  42. package/dist/index.mjs +6 -7
  43. package/dist/{ipc-client-D16Xw6Uo.mjs → ipc-client-BLYX51nG.mjs} +2 -2
  44. package/dist/{ipc-client-D16Xw6Uo.mjs.map → ipc-client-BLYX51nG.mjs.map} +1 -1
  45. package/dist/main-resolver-BYgP86C1.mjs +7 -0
  46. package/dist/{main-resolver-DPPwHNtn.mjs → main-resolver-kVkMJZUD.mjs} +6 -6
  47. package/dist/{main-resolver-DPPwHNtn.mjs.map → main-resolver-kVkMJZUD.mjs.map} +1 -1
  48. package/dist/{migrate-BD7D8EEh.mjs → migrate-Bq0esMOa.mjs} +2 -2
  49. package/dist/{migrate-BD7D8EEh.mjs.map → migrate-Bq0esMOa.mjs.map} +1 -1
  50. package/dist/{pai-marker-D1MMswkz.mjs → pai-marker-DXVpFsYz.mjs} +1 -1
  51. package/dist/{pai-marker-D1MMswkz.mjs.map → pai-marker-DXVpFsYz.mjs.map} +1 -1
  52. package/dist/{planner-EJPZnnif.mjs → planner-D5yt0EfA.mjs} +10 -7
  53. package/dist/{planner-EJPZnnif.mjs.map → planner-D5yt0EfA.mjs.map} +1 -1
  54. package/dist/postgres-BmLr0MUm.mjs +5 -0
  55. package/dist/{postgres-Ceqsa64C.mjs → postgres-D3xc2RB4.mjs} +364 -9
  56. package/dist/postgres-D3xc2RB4.mjs.map +1 -0
  57. package/dist/{program-DSETzrWg.mjs → program-DIXRtAFy.mjs} +80 -70
  58. package/dist/program-DIXRtAFy.mjs.map +1 -0
  59. package/dist/{query-feedback-BIaZTTFO.mjs → query-feedback-B7FYE4JR.mjs} +1 -1
  60. package/dist/{query-feedback-BIaZTTFO.mjs.map → query-feedback-B7FYE4JR.mjs.map} +1 -1
  61. package/dist/{reranker-DKv80KO5.mjs → reranker-3lnggwgq.mjs} +1 -1
  62. package/dist/{reranker-DKv80KO5.mjs.map → reranker-3lnggwgq.mjs.map} +1 -1
  63. package/dist/{reranker-CFiEzHQu.mjs → reranker-CZ2mP4cf.mjs} +1 -1
  64. package/dist/router-BK-hFeQ6.mjs +3 -0
  65. package/dist/{router-a5G7q8_3.mjs → router-BaTbc9VX.mjs} +2 -2
  66. package/dist/{router-a5G7q8_3.mjs.map → router-BaTbc9VX.mjs.map} +1 -1
  67. package/dist/{run-Z4akoa8G.mjs → run-CLYTOm0x.mjs} +764 -25
  68. package/dist/run-CLYTOm0x.mjs.map +1 -0
  69. package/dist/{run-env-DRcp7A8K.mjs → run-env-Bnmf6bRI.mjs} +2 -2
  70. package/dist/{run-env-DRcp7A8K.mjs.map → run-env-Bnmf6bRI.mjs.map} +1 -1
  71. package/dist/{runtime-paths-CHTg3ywb.mjs → runtime-paths-D3FKAm69.mjs} +1 -1
  72. package/dist/{runtime-paths-CHTg3ywb.mjs.map → runtime-paths-D3FKAm69.mjs.map} +1 -1
  73. package/dist/{search-BOSphCJ1.mjs → search-CNAGTiJP.mjs} +2 -2
  74. package/dist/{search-BOSphCJ1.mjs.map → search-CNAGTiJP.mjs.map} +1 -1
  75. package/dist/{sources-CijVso3n.mjs → sources-CM2g-CLT.mjs} +2 -2
  76. package/dist/{sources-CijVso3n.mjs.map → sources-CM2g-CLT.mjs.map} +1 -1
  77. package/dist/{stop-words-BdQuaE9K.mjs → stop-words-DtxaTWU_.mjs} +1 -1
  78. package/dist/{stop-words-BdQuaE9K.mjs.map → stop-words-DtxaTWU_.mjs.map} +1 -1
  79. package/dist/{utils-C9HsYDpO.mjs → utils-DddRwMsG.mjs} +2 -2
  80. package/dist/{utils-C9HsYDpO.mjs.map → utils-DddRwMsG.mjs.map} +1 -1
  81. package/dist/{zettelkasten-CtGHQWPU.mjs → zettelkasten-BhZMvmoK.mjs} +149 -7
  82. package/dist/zettelkasten-BhZMvmoK.mjs.map +1 -0
  83. package/dist/zettelkasten-BwV1pluL.mjs +5 -0
  84. package/docs/commands/worker.md +2 -0
  85. package/package.json +1 -1
  86. package/src/hooks/ts/lib/worker-guard.test.ts +12 -0
  87. package/src/hooks/ts/lib/worker-guard.ts +9 -0
  88. package/dist/async-C2Bm_Lal.mjs +0 -300
  89. package/dist/async-C2Bm_Lal.mjs.map +0 -1
  90. package/dist/auto-route-o0BOsXn7.mjs.map +0 -1
  91. package/dist/chunker-BH4i-F2b.mjs.map +0 -1
  92. package/dist/clusters-BCtD3fbe.mjs +0 -169
  93. package/dist/clusters-BCtD3fbe.mjs.map +0 -1
  94. package/dist/context-handover-cache-pkHzmL3c.mjs.map +0 -1
  95. package/dist/daemon-BEoVgVfJ.mjs +0 -1741
  96. package/dist/daemon-BEoVgVfJ.mjs.map +0 -1
  97. package/dist/daemon-DPAYU9hp.mjs +0 -19
  98. package/dist/detector-BEPFsINR.mjs +0 -3
  99. package/dist/detector-BRTtAWrM.mjs +0 -65
  100. package/dist/detector-BRTtAWrM.mjs.map +0 -1
  101. package/dist/factory-CNpPQQ-5.mjs +0 -249
  102. package/dist/factory-CNpPQQ-5.mjs.map +0 -1
  103. package/dist/factory-DY7x73mE.mjs +0 -3
  104. package/dist/fallback-B5svk75f.mjs.map +0 -1
  105. package/dist/federation-db-BTyoufBh.mjs +0 -139
  106. package/dist/federation-db-BTyoufBh.mjs.map +0 -1
  107. package/dist/federation-db-HfIFc7FG.mjs +0 -3
  108. package/dist/helpers-BRCJg0G3.mjs +0 -223
  109. package/dist/helpers-BRCJg0G3.mjs.map +0 -1
  110. package/dist/indexer-backend-DFF2FrYx.mjs +0 -5
  111. package/dist/indexer-backend-isSLg6yE.mjs +0 -1
  112. package/dist/kg-entity-DCOcsVFD.mjs +0 -29
  113. package/dist/kg-entity-DCOcsVFD.mjs.map +0 -1
  114. package/dist/latent-ideas-wwAXquGe.mjs +0 -191
  115. package/dist/latent-ideas-wwAXquGe.mjs.map +0 -1
  116. package/dist/link-boost-CnI7UVMJ.mjs +0 -34
  117. package/dist/link-boost-CnI7UVMJ.mjs.map +0 -1
  118. package/dist/main-resolver-BKz_OuEh.mjs +0 -7
  119. package/dist/merge-2gqRPFu2.mjs +0 -3
  120. package/dist/merge-DgU9OgZy.mjs +0 -6
  121. package/dist/merge-DgU9OgZy.mjs.map +0 -1
  122. package/dist/module-paths-DdRzbkUI.mjs +0 -44
  123. package/dist/module-paths-DdRzbkUI.mjs.map +0 -1
  124. package/dist/neighborhood-2FsmzoxG.mjs +0 -114
  125. package/dist/neighborhood-2FsmzoxG.mjs.map +0 -1
  126. package/dist/note-context-De63k8na.mjs +0 -106
  127. package/dist/note-context-De63k8na.mjs.map +0 -1
  128. package/dist/postgres-Ceqsa64C.mjs.map +0 -1
  129. package/dist/program-DSETzrWg.mjs.map +0 -1
  130. package/dist/query-feedback-B_iigYj-.mjs +0 -3
  131. package/dist/registry-db-C7voqML9.mjs +0 -213
  132. package/dist/registry-db-C7voqML9.mjs.map +0 -1
  133. package/dist/registry-db-JHPhA8vF.mjs +0 -3
  134. package/dist/registry-postgres-nOfyBSIW.mjs +0 -795
  135. package/dist/registry-postgres-nOfyBSIW.mjs.map +0 -1
  136. package/dist/registry-sqlite-DrCW1aRK.mjs +0 -593
  137. package/dist/registry-sqlite-DrCW1aRK.mjs.map +0 -1
  138. package/dist/router-CXUGsv85.mjs +0 -3
  139. package/dist/run-Z4akoa8G.mjs.map +0 -1
  140. package/dist/search-Bf3Kub3F.mjs +0 -3
  141. package/dist/server-DgmAHyFK.mjs +0 -403
  142. package/dist/server-DgmAHyFK.mjs.map +0 -1
  143. package/dist/session-keepalive-BDhytXEo.mjs +0 -582
  144. package/dist/session-keepalive-BDhytXEo.mjs.map +0 -1
  145. package/dist/sqlite-CqsTy6xo.mjs +0 -936
  146. package/dist/sqlite-CqsTy6xo.mjs.map +0 -1
  147. package/dist/state-DW8zdweW.mjs +0 -3
  148. package/dist/state-HyjqTihC.mjs +0 -76
  149. package/dist/state-HyjqTihC.mjs.map +0 -1
  150. package/dist/themes-CTaOj3e1.mjs +0 -148
  151. package/dist/themes-CTaOj3e1.mjs.map +0 -1
  152. package/dist/tools-BcOKPBtg.mjs +0 -1489
  153. package/dist/tools-BcOKPBtg.mjs.map +0 -1
  154. package/dist/tools-C-_l0o8Z.mjs +0 -6
  155. package/dist/trace-CtV0RO6n.mjs +0 -137
  156. package/dist/trace-CtV0RO6n.mjs.map +0 -1
  157. package/dist/utils-BrX9FLeK.mjs +0 -3
  158. package/dist/vault-indexer-BG1sEVyM.mjs +0 -537
  159. package/dist/vault-indexer-BG1sEVyM.mjs.map +0 -1
  160. package/dist/wakeup-D8n33hYV.mjs +0 -335
  161. package/dist/wakeup-D8n33hYV.mjs.map +0 -1
  162. package/dist/work-queue-worker-4tLa5gpQ.mjs +0 -567
  163. package/dist/work-queue-worker-4tLa5gpQ.mjs.map +0 -1
  164. package/dist/work-queue-worker-rFZvTO3p.mjs +0 -11
  165. package/dist/zettelkasten-CtGHQWPU.mjs.map +0 -1
@@ -0,0 +1,2892 @@
1
+ import { a as parseSessionTitleChunk, c as yieldToEventLoop, f as fileContentHash, h as sha256File, i as isPathTooBroadForContentScan, n as chunkId, o as walkContentFiles, r as detectTier, s as walkMdFiles, t as INDEX_YIELD_EVERY, u as chunkMarkdown } from "./helpers-CZsi_49C.mjs";
2
+ import { t as STOP_WORDS } from "./stop-words-DtxaTWU_.mjs";
3
+ import { n as buildFtsQuery, r as isQuerySyntaxError } from "./search-CNAGTiJP.mjs";
4
+ import { i as entityContentId, t as PostgresBackend } from "./postgres-D3xc2RB4.mjs";
5
+ import { n as cosineSimilarity, r as deserializeEmbedding } from "./embeddings-DOLZnT1X.mjs";
6
+ import { t as routeNotification } from "./router-BaTbc9VX.mjs";
7
+ import { s as loadConfig } from "./config-C-LiGlop.mjs";
8
+ import { a as paiHomePath, o as resolvePaiFile } from "./pai-home-Cm9rcJgX.mjs";
9
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { basename, dirname, join, relative } from "node:path";
12
+ import pg from "pg";
13
+ import BetterSqlite3 from "better-sqlite3";
14
+
15
+ //#region src/storage/outage.ts
16
+ let current = null;
17
+ /** Record that the backend is currently unreachable. */
18
+ function setBackendOutage(outage) {
19
+ current = outage;
20
+ }
21
+ /**
22
+ * Record that the backend answered.
23
+ *
24
+ * Called on every successful connection, including the first — so a daemon that
25
+ * never had a problem reports none, and one that recovered stops reporting an
26
+ * outage that has ended.
27
+ */
28
+ function clearBackendOutage() {
29
+ current = null;
30
+ }
31
+ /** The current outage, or null when the backend is answering. */
32
+ function getBackendOutage() {
33
+ return current;
34
+ }
35
+ /**
36
+ * Human-readable elapsed time, for a status line rather than a log.
37
+ *
38
+ * Exported because the status command needs the duration on its own, to colour
39
+ * it separately from the rest of the sentence. Without this it kept a second
40
+ * copy of the same rounding, which is how two renderings of one outage drift.
41
+ */
42
+ function humanDuration(ms) {
43
+ const mins = Math.max(1, Math.round(ms / 6e4));
44
+ return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;
45
+ }
46
+
47
+ //#endregion
48
+ //#region src/storage/sqlite/federation-schema.ts
49
+ const FEDERATION_SCHEMA_SQL = `
50
+ PRAGMA journal_mode = WAL;
51
+ PRAGMA foreign_keys = ON;
52
+
53
+ CREATE TABLE IF NOT EXISTS memory_files (
54
+ project_id INTEGER NOT NULL,
55
+ path TEXT NOT NULL,
56
+ source TEXT NOT NULL DEFAULT 'memory',
57
+ tier TEXT NOT NULL DEFAULT 'topic',
58
+ hash TEXT NOT NULL,
59
+ mtime INTEGER NOT NULL,
60
+ size INTEGER NOT NULL,
61
+ PRIMARY KEY (project_id, path)
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS memory_chunks (
65
+ id TEXT PRIMARY KEY,
66
+ project_id INTEGER NOT NULL,
67
+ source TEXT NOT NULL DEFAULT 'memory',
68
+ tier TEXT NOT NULL DEFAULT 'topic',
69
+ path TEXT NOT NULL,
70
+ start_line INTEGER NOT NULL,
71
+ end_line INTEGER NOT NULL,
72
+ hash TEXT NOT NULL,
73
+ text TEXT NOT NULL,
74
+ updated_at INTEGER NOT NULL,
75
+ last_accessed_at INTEGER,
76
+ relevance_score REAL DEFAULT 0.5,
77
+ embedding BLOB
78
+ );
79
+
80
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
81
+ text,
82
+ id UNINDEXED,
83
+ project_id UNINDEXED,
84
+ path UNINDEXED,
85
+ source UNINDEXED,
86
+ tier UNINDEXED,
87
+ start_line UNINDEXED,
88
+ end_line UNINDEXED
89
+ );
90
+
91
+ CREATE INDEX IF NOT EXISTS idx_mc_project ON memory_chunks(project_id);
92
+ CREATE INDEX IF NOT EXISTS idx_mc_source ON memory_chunks(project_id, source);
93
+ CREATE INDEX IF NOT EXISTS idx_mc_tier ON memory_chunks(tier);
94
+ CREATE INDEX IF NOT EXISTS idx_mf_project ON memory_files(project_id);
95
+
96
+ CREATE TABLE IF NOT EXISTS kg_entities (
97
+ entity_id TEXT PRIMARY KEY,
98
+ tenant_id TEXT NOT NULL DEFAULT 'default',
99
+ name TEXT NOT NULL,
100
+ type TEXT NOT NULL DEFAULT 'unknown',
101
+ description TEXT,
102
+ first_seen INTEGER,
103
+ last_seen INTEGER,
104
+ mention_count INTEGER NOT NULL DEFAULT 1,
105
+ feedback_weight REAL NOT NULL DEFAULT 0.5,
106
+ UNIQUE(tenant_id, entity_id)
107
+ );
108
+
109
+ CREATE INDEX IF NOT EXISTS idx_kge_tenant ON kg_entities(tenant_id);
110
+ CREATE INDEX IF NOT EXISTS idx_kge_name ON kg_entities(tenant_id, name);
111
+ CREATE INDEX IF NOT EXISTS idx_kge_type ON kg_entities(tenant_id, type);
112
+ `;
113
+ /**
114
+ * Apply the full federation schema to an open database.
115
+ *
116
+ * Idempotent — all statements use IF NOT EXISTS so calling this on an
117
+ * already-initialised database is safe.
118
+ *
119
+ * Also runs any necessary migrations for existing databases (e.g. adding the
120
+ * embedding column to an older schema that was created without it).
121
+ */
122
+ function initializeFederationSchema(db) {
123
+ db.exec(FEDERATION_SCHEMA_SQL);
124
+ runMigrations$1(db);
125
+ }
126
+ /**
127
+ * Apply incremental migrations to an existing database.
128
+ *
129
+ * Each migration is idempotent — safe to call on a database that has already
130
+ * been migrated.
131
+ */
132
+ function runMigrations$1(db) {
133
+ const columns = db.prepare("PRAGMA table_info(memory_chunks)").all();
134
+ if (!columns.some((c) => c.name === "embedding")) db.exec("ALTER TABLE memory_chunks ADD COLUMN embedding BLOB");
135
+ db.exec("CREATE INDEX IF NOT EXISTS idx_mc_embedding ON memory_chunks(id) WHERE embedding IS NOT NULL");
136
+ if (!columns.some((c) => c.name === "last_accessed_at")) db.exec("ALTER TABLE memory_chunks ADD COLUMN last_accessed_at INTEGER");
137
+ if (!columns.some((c) => c.name === "relevance_score")) db.exec("ALTER TABLE memory_chunks ADD COLUMN relevance_score REAL DEFAULT 0.5");
138
+ }
139
+
140
+ //#endregion
141
+ //#region src/storage/sqlite/federation-db.ts
142
+ /**
143
+ * Database connection helper for the PAI federation DB.
144
+ *
145
+ * Uses better-sqlite3 (synchronous API) to open or create federation.db.
146
+ * On first open it runs the full DDL via initializeFederationSchema().
147
+ */
148
+ /** Old federation.db path, inside the ~/.pai/ directory (pre-2026-09-19). */
149
+ function oldFederationPath() {
150
+ return join(homedir(), ".pai", "federation.db");
151
+ }
152
+ /** Federation DB path: PAI_HOME/federation.db if present, else the old
153
+ * ~/.pai/federation.db (one-time stderr notice), else the new path. */
154
+ function federationDbPath() {
155
+ return resolvePaiFile(paiHomePath("federation.db"), [oldFederationPath()], "pai config migrate --federation");
156
+ }
157
+ /**
158
+ * Open (or create) the PAI federation database.
159
+ *
160
+ * @param path Absolute path to federation.db. Defaults to PAI_HOME/federation.db
161
+ * (falling back to the pre-2026-09-19 ~/.pai/federation.db).
162
+ * @returns An open better-sqlite3 Database instance.
163
+ *
164
+ * Side effects on first call:
165
+ * - Creates the parent directory if it does not exist.
166
+ * - Enables WAL journal mode.
167
+ * - Runs initializeFederationSchema() to ensure tables exist.
168
+ */
169
+ function openFederation(path = federationDbPath()) {
170
+ mkdirSync(dirname(path), { recursive: true });
171
+ const db = new BetterSqlite3(path);
172
+ db.pragma("journal_mode = WAL");
173
+ db.pragma("foreign_keys = ON");
174
+ initializeFederationSchema(db);
175
+ return db;
176
+ }
177
+
178
+ //#endregion
179
+ //#region src/storage/sqlite/indexer.ts
180
+ /**
181
+ * Synchronous (SQLite) indexer — moved from memory/indexer/sync.ts so the
182
+ * only file that opens prepared statements against the federation database
183
+ * lives under src/storage/ (design doc docs/design/postgres-only.md, unit 7).
184
+ *
185
+ * Scans project memory/ and Notes/ directories, chunks markdown files, and
186
+ * inserts the resulting chunks into the federation memory store for BM25
187
+ * search. Uses raw better-sqlite3 Database directly for maximum SQLite
188
+ * performance (synchronous transactions, no serialisation overhead) — the
189
+ * reason SQLiteBackend keeps this path instead of routing through the
190
+ * generic (StorageBackend) indexer that Postgres uses.
191
+ */
192
+ /**
193
+ * Index a single file into the federation database.
194
+ *
195
+ * @returns true if the file was re-indexed (changed or new), false if skipped.
196
+ */
197
+ function indexFile(db, projectId, rootPath, relativePath, source, tier) {
198
+ const absPath = join(rootPath, relativePath);
199
+ let content;
200
+ let stat;
201
+ try {
202
+ content = readFileSync(absPath, "utf8");
203
+ stat = statSync(absPath);
204
+ } catch {
205
+ return false;
206
+ }
207
+ const hash = fileContentHash(content);
208
+ const mtime = Math.floor(stat.mtimeMs);
209
+ const size = stat.size;
210
+ if (db.prepare("SELECT hash FROM memory_files WHERE project_id = ? AND path = ?").get(projectId, relativePath)?.hash === hash) return false;
211
+ const oldChunkIds = db.prepare("SELECT id FROM memory_chunks WHERE project_id = ? AND path = ?").all(projectId, relativePath);
212
+ const deleteFts = db.prepare("DELETE FROM memory_fts WHERE id = ?");
213
+ const deleteChunk = db.prepare("DELETE FROM memory_chunks WHERE project_id = ? AND path = ?");
214
+ db.transaction(() => {
215
+ for (const row of oldChunkIds) deleteFts.run(row.id);
216
+ deleteChunk.run(projectId, relativePath);
217
+ })();
218
+ const chunks = chunkMarkdown(content);
219
+ const insertChunk = db.prepare(`
220
+ INSERT INTO memory_chunks (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at)
221
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
222
+ `);
223
+ const insertFts = db.prepare(`
224
+ INSERT INTO memory_fts (text, id, project_id, path, source, tier, start_line, end_line)
225
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
226
+ `);
227
+ const upsertFile = db.prepare(`
228
+ INSERT INTO memory_files (project_id, path, source, tier, hash, mtime, size)
229
+ VALUES (?, ?, ?, ?, ?, ?, ?)
230
+ ON CONFLICT(project_id, path) DO UPDATE SET
231
+ source = excluded.source,
232
+ tier = excluded.tier,
233
+ hash = excluded.hash,
234
+ mtime = excluded.mtime,
235
+ size = excluded.size
236
+ `);
237
+ const updatedAt = Date.now();
238
+ db.transaction(() => {
239
+ for (let i = 0; i < chunks.length; i++) {
240
+ const chunk = chunks[i];
241
+ const id = chunkId(projectId, relativePath, i, chunk.startLine, chunk.endLine);
242
+ insertChunk.run(id, projectId, source, tier, relativePath, chunk.startLine, chunk.endLine, chunk.hash, chunk.text, updatedAt);
243
+ insertFts.run(chunk.text, id, projectId, relativePath, source, tier, chunk.startLine, chunk.endLine);
244
+ }
245
+ upsertFile.run(projectId, relativePath, source, tier, hash, mtime, size);
246
+ })();
247
+ return true;
248
+ }
249
+ /**
250
+ * Index all memory, Notes, and content files for a single registered project.
251
+ */
252
+ async function indexProject(db, projectId, rootPath, claudeNotesDir) {
253
+ const result = {
254
+ filesProcessed: 0,
255
+ chunksCreated: 0,
256
+ filesSkipped: 0
257
+ };
258
+ const filesToIndex = [];
259
+ const rootMemoryMd = join(rootPath, "MEMORY.md");
260
+ if (existsSync(rootMemoryMd)) filesToIndex.push({
261
+ absPath: rootMemoryMd,
262
+ rootBase: rootPath,
263
+ source: "memory",
264
+ tier: "evergreen"
265
+ });
266
+ const memoryDir = join(rootPath, "memory");
267
+ for (const absPath of walkMdFiles(memoryDir)) {
268
+ const tier = detectTier(relative(rootPath, absPath));
269
+ filesToIndex.push({
270
+ absPath,
271
+ rootBase: rootPath,
272
+ source: "memory",
273
+ tier
274
+ });
275
+ }
276
+ const notesDir = join(rootPath, "Notes");
277
+ for (const absPath of walkMdFiles(notesDir)) filesToIndex.push({
278
+ absPath,
279
+ rootBase: rootPath,
280
+ source: "notes",
281
+ tier: "session"
282
+ });
283
+ {
284
+ const titleInsertChunk = db.prepare(`
285
+ INSERT OR IGNORE INTO memory_chunks (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at)
286
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
287
+ `);
288
+ const titleInsertFts = db.prepare(`
289
+ INSERT OR IGNORE INTO memory_fts (text, id, project_id, path, source, tier, start_line, end_line)
290
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
291
+ `);
292
+ const updatedAt = Date.now();
293
+ for (const absPath of walkMdFiles(notesDir)) {
294
+ const text = parseSessionTitleChunk(basename(absPath));
295
+ if (!text) continue;
296
+ const syntheticPath = `${relative(rootPath, absPath)}::title`;
297
+ const id = chunkId(projectId, syntheticPath, 0, 0, 0);
298
+ const hash = sha256File(text);
299
+ db.transaction(() => {
300
+ titleInsertChunk.run(id, projectId, "notes", "session", syntheticPath, 0, 0, hash, text, updatedAt);
301
+ titleInsertFts.run(text, id, projectId, syntheticPath, "notes", "session", 0, 0);
302
+ })();
303
+ }
304
+ }
305
+ if (!isPathTooBroadForContentScan(rootPath)) for (const absPath of walkContentFiles(rootPath)) filesToIndex.push({
306
+ absPath,
307
+ rootBase: rootPath,
308
+ source: "content",
309
+ tier: "topic"
310
+ });
311
+ if (claudeNotesDir && claudeNotesDir !== notesDir) {
312
+ for (const absPath of walkMdFiles(claudeNotesDir)) filesToIndex.push({
313
+ absPath,
314
+ rootBase: claudeNotesDir,
315
+ source: "notes",
316
+ tier: "session"
317
+ });
318
+ {
319
+ const updatedAt = Date.now();
320
+ const titleInsertChunk2 = db.prepare(`
321
+ INSERT OR IGNORE INTO memory_chunks (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at)
322
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
323
+ `);
324
+ const titleInsertFts2 = db.prepare(`
325
+ INSERT OR IGNORE INTO memory_fts (text, id, project_id, path, source, tier, start_line, end_line)
326
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
327
+ `);
328
+ for (const absPath of walkMdFiles(claudeNotesDir)) {
329
+ const text = parseSessionTitleChunk(basename(absPath));
330
+ if (!text) continue;
331
+ const syntheticPath = `${relative(claudeNotesDir, absPath)}::title`;
332
+ const id = chunkId(projectId, syntheticPath, 0, 0, 0);
333
+ const hash = sha256File(text);
334
+ db.transaction(() => {
335
+ titleInsertChunk2.run(id, projectId, "notes", "session", syntheticPath, 0, 0, hash, text, updatedAt);
336
+ titleInsertFts2.run(text, id, projectId, syntheticPath, "notes", "session", 0, 0);
337
+ })();
338
+ }
339
+ }
340
+ if (claudeNotesDir.endsWith("/Notes")) {
341
+ const claudeProjectDir = claudeNotesDir.slice(0, -6);
342
+ const claudeMemoryDir = join(claudeProjectDir, "memory");
343
+ const claudeMemoryMd = join(claudeProjectDir, "MEMORY.md");
344
+ if (existsSync(claudeMemoryMd)) filesToIndex.push({
345
+ absPath: claudeMemoryMd,
346
+ rootBase: claudeProjectDir,
347
+ source: "memory",
348
+ tier: "evergreen"
349
+ });
350
+ for (const absPath of walkMdFiles(claudeMemoryDir)) {
351
+ const tier = detectTier(relative(claudeProjectDir, absPath));
352
+ filesToIndex.push({
353
+ absPath,
354
+ rootBase: claudeProjectDir,
355
+ source: "memory",
356
+ tier
357
+ });
358
+ }
359
+ }
360
+ }
361
+ await yieldToEventLoop();
362
+ let filesSinceYield = 0;
363
+ for (const { absPath, rootBase, source, tier } of filesToIndex) {
364
+ if (filesSinceYield >= INDEX_YIELD_EVERY) {
365
+ await yieldToEventLoop();
366
+ filesSinceYield = 0;
367
+ }
368
+ filesSinceYield++;
369
+ const relPath = relative(rootBase, absPath);
370
+ if (indexFile(db, projectId, rootBase, relPath, source, tier)) {
371
+ const count = db.prepare("SELECT COUNT(*) as n FROM memory_chunks WHERE project_id = ? AND path = ?").get(projectId, relPath);
372
+ result.filesProcessed++;
373
+ result.chunksCreated += count.n;
374
+ } else result.filesSkipped++;
375
+ }
376
+ const livePaths = /* @__PURE__ */ new Set();
377
+ for (const { absPath, rootBase } of filesToIndex) livePaths.add(relative(rootBase, absPath));
378
+ const dbChunkPaths = db.prepare("SELECT DISTINCT path FROM memory_chunks WHERE project_id = ?").all(projectId);
379
+ const stalePaths = [];
380
+ for (const row of dbChunkPaths) {
381
+ const basePath = row.path.endsWith("::title") ? row.path.slice(0, -7) : row.path;
382
+ if (!livePaths.has(basePath)) stalePaths.push(row.path);
383
+ }
384
+ if (stalePaths.length > 0) {
385
+ const deleteChunksFts = db.prepare("DELETE FROM memory_fts WHERE id = ?");
386
+ const deleteChunks = db.prepare("DELETE FROM memory_chunks WHERE project_id = ? AND path = ?");
387
+ const deleteFile = db.prepare("DELETE FROM memory_files WHERE project_id = ? AND path = ?");
388
+ db.transaction(() => {
389
+ for (const stalePath of stalePaths) {
390
+ const chunkIds = db.prepare("SELECT id FROM memory_chunks WHERE project_id = ? AND path = ?").all(projectId, stalePath);
391
+ for (const { id } of chunkIds) deleteChunksFts.run(id);
392
+ deleteChunks.run(projectId, stalePath);
393
+ deleteFile.run(projectId, stalePath);
394
+ }
395
+ })();
396
+ }
397
+ return result;
398
+ }
399
+ /**
400
+ * Index all active projects registered in the registry DB.
401
+ *
402
+ * Async: yields to the event loop between each project so that the daemon's
403
+ * Unix socket server can process IPC requests (e.g. status) while indexing.
404
+ */
405
+ async function indexAllSqlite(db, registry) {
406
+ const projects = await registry.listProjects({ status: "active" });
407
+ const totals = {
408
+ filesProcessed: 0,
409
+ chunksCreated: 0,
410
+ filesSkipped: 0
411
+ };
412
+ for (const project of projects) {
413
+ await yieldToEventLoop();
414
+ const r = await indexProject(db, project.id, project.root_path, project.claude_notes_dir);
415
+ totals.filesProcessed += r.filesProcessed;
416
+ totals.chunksCreated += r.chunksCreated;
417
+ totals.filesSkipped += r.filesSkipped;
418
+ }
419
+ return {
420
+ projects: projects.length,
421
+ result: totals
422
+ };
423
+ }
424
+
425
+ //#endregion
426
+ //#region src/storage/sqlite/tunnels.ts
427
+ async function findTunnelsForSlugMap(db, slugMap, opts) {
428
+ const projectIds = Object.keys(slugMap).map(Number);
429
+ if (projectIds.length < 2) return {
430
+ tunnels: [],
431
+ projects_analyzed: projectIds.length,
432
+ total_concepts_evaluated: 0
433
+ };
434
+ let candidateTerms = [];
435
+ try {
436
+ candidateTerms = db.prepare(`SELECT term, SUM(doc) AS doc_count, SUM(cnt) AS total_cnt
437
+ FROM memory_fts_v
438
+ GROUP BY term
439
+ HAVING SUM(cnt) >= ?
440
+ ORDER BY SUM(doc) DESC
441
+ LIMIT 500`).all(opts.min_occurrences).map((r) => r.term).filter((t) => t.length >= 3 && !STOP_WORDS.has(t));
442
+ } catch {
443
+ const sampleRows = db.prepare(`SELECT LOWER(text) AS text FROM memory_chunks
444
+ WHERE LENGTH(text) > 20
445
+ ORDER BY RANDOM()
446
+ LIMIT 2000`).all();
447
+ const freq = /* @__PURE__ */ new Map();
448
+ for (const { text } of sampleRows) {
449
+ const tokens = text.split(/[\s\p{P}]+/u).filter(Boolean).filter((t) => t.length >= 3 && !STOP_WORDS.has(t));
450
+ for (const t of tokens) freq.set(t, (freq.get(t) ?? 0) + 1);
451
+ }
452
+ candidateTerms = [...freq.entries()].filter(([, n]) => n >= opts.min_occurrences).sort((a, b) => b[1] - a[1]).slice(0, 200).map(([t]) => t);
453
+ }
454
+ if (candidateTerms.length === 0) return {
455
+ tunnels: [],
456
+ projects_analyzed: projectIds.length,
457
+ total_concepts_evaluated: 0
458
+ };
459
+ const tunnels = [];
460
+ for (const term of candidateTerms) try {
461
+ const rows = db.prepare(`SELECT c.project_id, COUNT(*) AS cnt,
462
+ MIN(c.updated_at) AS first_seen,
463
+ MAX(c.updated_at) AS last_seen
464
+ FROM memory_fts f
465
+ JOIN memory_chunks c ON c.id = f.id
466
+ WHERE memory_fts MATCH ?
467
+ AND c.project_id IN (${projectIds.map(() => "?").join(", ")})
468
+ GROUP BY c.project_id`).all(`"${term.replace(/"/g, "\"\"")}"`, ...projectIds);
469
+ if (rows.length < opts.min_projects) continue;
470
+ const totalOccurrences = rows.reduce((s, r) => s + Number(r.cnt), 0);
471
+ if (totalOccurrences < opts.min_occurrences) continue;
472
+ const projects = rows.map((r) => slugMap[r.project_id] ?? String(r.project_id)).filter(Boolean);
473
+ const firstSeen = Math.min(...rows.map((r) => r.first_seen));
474
+ const lastSeen = Math.max(...rows.map((r) => r.last_seen));
475
+ tunnels.push({
476
+ concept: term,
477
+ projects,
478
+ occurrences: totalOccurrences,
479
+ first_seen: firstSeen,
480
+ last_seen: lastSeen
481
+ });
482
+ } catch {
483
+ continue;
484
+ }
485
+ tunnels.sort((a, b) => {
486
+ const byProjects = b.projects.length - a.projects.length;
487
+ if (byProjects !== 0) return byProjects;
488
+ return b.occurrences - a.occurrences;
489
+ });
490
+ return {
491
+ tunnels: tunnels.slice(0, opts.limit),
492
+ projects_analyzed: projectIds.length,
493
+ total_concepts_evaluated: candidateTerms.length
494
+ };
495
+ }
496
+ /** Find cross-project concept tunnels against a SQLite federation database. */
497
+ async function findTunnelsSqlite(db, registry, options) {
498
+ const opts = {
499
+ min_projects: options?.min_projects ?? 2,
500
+ min_occurrences: options?.min_occurrences ?? 3,
501
+ limit: options?.limit ?? 20
502
+ };
503
+ const projectRows = await registry.listProjects({ excludeArchived: true });
504
+ const slugMap = {};
505
+ for (const { id, slug } of projectRows) slugMap[id] = slug;
506
+ return findTunnelsForSlugMap(db, slugMap, opts);
507
+ }
508
+
509
+ //#endregion
510
+ //#region src/storage/sqlite/sources.ts
511
+ function getMemorySourcesReportSqlite(db) {
512
+ return {
513
+ composition: db.prepare(`SELECT source, tier, COUNT(*) AS chunks,
514
+ SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END) AS embedded
515
+ FROM memory_chunks GROUP BY source, tier ORDER BY COUNT(*) DESC`).all(),
516
+ paths: db.prepare(`SELECT path, COUNT(*) AS chunks FROM memory_chunks GROUP BY path`).all(),
517
+ churn: db.prepare(`SELECT date(updated_at/1000,'unixepoch') AS day, COUNT(*) AS chunks,
518
+ SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END) AS embedded
519
+ FROM memory_chunks GROUP BY day ORDER BY day DESC LIMIT 10`).all()
520
+ };
521
+ }
522
+
523
+ //#endregion
524
+ //#region src/storage/sqlite/search.ts
525
+ /**
526
+ * Search across all indexed memory using FTS5 BM25 ranking.
527
+ *
528
+ * Results are ordered by BM25 score (most relevant first).
529
+ * FTS5 bm25() returns negative values; closer to 0 = more relevant.
530
+ * We negate the score so callers get positive values where higher = better.
531
+ */
532
+ function searchMemory(db, query, opts) {
533
+ const maxResults = opts?.maxResults ?? 10;
534
+ const ftsQuery = buildFtsQuery(query);
535
+ const conditions = [];
536
+ const params = [ftsQuery];
537
+ if (opts?.projectIds && opts.projectIds.length > 0) {
538
+ const placeholders = opts.projectIds.map(() => "?").join(", ");
539
+ conditions.push(`c.project_id IN (${placeholders})`);
540
+ params.push(...opts.projectIds);
541
+ }
542
+ if (opts?.sources && opts.sources.length > 0) {
543
+ const placeholders = opts.sources.map(() => "?").join(", ");
544
+ conditions.push(`c.source IN (${placeholders})`);
545
+ params.push(...opts.sources);
546
+ }
547
+ if (opts?.tiers && opts.tiers.length > 0) {
548
+ const placeholders = opts.tiers.map(() => "?").join(", ");
549
+ conditions.push(`c.tier IN (${placeholders})`);
550
+ params.push(...opts.tiers);
551
+ }
552
+ const whereClause = conditions.length > 0 ? "AND " + conditions.join(" AND ") : "";
553
+ params.push(maxResults);
554
+ const sql = `
555
+ SELECT
556
+ c.id,
557
+ c.project_id,
558
+ c.path,
559
+ c.start_line,
560
+ c.end_line,
561
+ c.text AS snippet,
562
+ c.tier,
563
+ c.source,
564
+ c.updated_at,
565
+ c.last_accessed_at,
566
+ c.relevance_score,
567
+ bm25(memory_fts) AS bm25_score
568
+ FROM memory_fts
569
+ JOIN memory_chunks c ON memory_fts.id = c.id
570
+ WHERE memory_fts MATCH ?
571
+ ${whereClause}
572
+ ORDER BY bm25_score
573
+ LIMIT ?
574
+ `;
575
+ let rows;
576
+ try {
577
+ rows = db.prepare(sql).all(...params);
578
+ } catch (e) {
579
+ if (!isQuerySyntaxError(e)) throw new Error(`Memory keyword search failed — the index is unusable, so this is NOT an empty result set. Cause: ${e instanceof Error ? e.message : String(e)}`);
580
+ return [];
581
+ }
582
+ const minScore = opts?.minScore ?? 0;
583
+ return rows.map((row) => {
584
+ const score = -row.bm25_score * (.5 + (row.relevance_score ?? .5));
585
+ return {
586
+ chunkId: row.id,
587
+ projectId: row.project_id,
588
+ path: row.path,
589
+ startLine: row.start_line,
590
+ endLine: row.end_line,
591
+ snippet: row.snippet,
592
+ score,
593
+ tier: row.tier,
594
+ source: row.source,
595
+ updatedAt: row.updated_at,
596
+ lastAccessedAt: row.last_accessed_at ?? void 0
597
+ };
598
+ }).filter((r) => r.score >= minScore);
599
+ }
600
+ /**
601
+ * Search chunks using brute-force cosine similarity over stored embeddings.
602
+ *
603
+ * Only chunks that have a non-null embedding BLOB are considered.
604
+ *
605
+ * @param queryEmbedding Pre-computed Float32Array for the search query.
606
+ */
607
+ function searchMemorySemantic(db, queryEmbedding, opts) {
608
+ const maxResults = opts?.maxResults ?? 10;
609
+ const conditions = ["embedding IS NOT NULL"];
610
+ const params = [];
611
+ if (opts?.projectIds && opts.projectIds.length > 0) {
612
+ const placeholders = opts.projectIds.map(() => "?").join(", ");
613
+ conditions.push(`project_id IN (${placeholders})`);
614
+ params.push(...opts.projectIds);
615
+ }
616
+ if (opts?.sources && opts.sources.length > 0) {
617
+ const placeholders = opts.sources.map(() => "?").join(", ");
618
+ conditions.push(`source IN (${placeholders})`);
619
+ params.push(...opts.sources);
620
+ }
621
+ if (opts?.tiers && opts.tiers.length > 0) {
622
+ const placeholders = opts.tiers.map(() => "?").join(", ");
623
+ conditions.push(`tier IN (${placeholders})`);
624
+ params.push(...opts.tiers);
625
+ }
626
+ const sql = `
627
+ SELECT id, project_id, path, start_line, end_line, text, tier, source, embedding, updated_at, last_accessed_at, relevance_score
628
+ FROM memory_chunks
629
+ ${"WHERE " + conditions.join(" AND ")}
630
+ LIMIT 5000
631
+ `;
632
+ const rows = db.prepare(sql).all(...params);
633
+ if (rows.length === 0) return [];
634
+ const scored = rows.map((row) => {
635
+ const score = cosineSimilarity(queryEmbedding, deserializeEmbedding(row.embedding)) * (.5 + (row.relevance_score ?? .5));
636
+ return {
637
+ chunkId: row.id,
638
+ projectId: row.project_id,
639
+ path: row.path,
640
+ startLine: row.start_line,
641
+ endLine: row.end_line,
642
+ snippet: row.text,
643
+ score,
644
+ tier: row.tier,
645
+ source: row.source,
646
+ updatedAt: row.updated_at,
647
+ lastAccessedAt: row.last_accessed_at ?? void 0
648
+ };
649
+ });
650
+ const minScore = opts?.minScore ?? -Infinity;
651
+ return scored.filter((r) => r.score >= minScore).sort((a, b) => b.score - a.score).slice(0, maxResults);
652
+ }
653
+ /**
654
+ * Update last_accessed_at for a set of chunk IDs to the current timestamp.
655
+ *
656
+ * Best-effort: errors are silently ignored so search is never blocked.
657
+ */
658
+ function touchChunksLastAccessed(db, chunkIds) {
659
+ if (chunkIds.length === 0) return;
660
+ try {
661
+ const now = Date.now();
662
+ const placeholders = chunkIds.map(() => "?").join(", ");
663
+ db.prepare(`UPDATE memory_chunks SET last_accessed_at = ? WHERE id IN (${placeholders})`).run(now, ...chunkIds);
664
+ } catch {}
665
+ }
666
+
667
+ //#endregion
668
+ //#region src/storage/sqlite/kg-entity.ts
669
+ /**
670
+ * Upsert a KG entity in the federation SQLite database.
671
+ *
672
+ * If the entity already exists for this tenant:
673
+ * - Updates last_seen to now
674
+ * - Increments mention_count
675
+ * - Updates description if provided (overwrites older description)
676
+ *
677
+ * Returns the entity_id for use as a foreign key in kg_triples.
678
+ */
679
+ function upsertKgEntitySqlite(db, params) {
680
+ const tenantId = params.tenantId ?? "default";
681
+ const entityId = entityContentId(params.name, tenantId);
682
+ const now = Date.now();
683
+ db.prepare(`
684
+ INSERT INTO kg_entities
685
+ (entity_id, tenant_id, name, type, description, first_seen, last_seen, mention_count, feedback_weight)
686
+ VALUES
687
+ (?, ?, ?, ?, ?, ?, ?, 1, 0.5)
688
+ ON CONFLICT(entity_id) DO UPDATE SET
689
+ last_seen = excluded.last_seen,
690
+ mention_count = mention_count + 1,
691
+ description = COALESCE(excluded.description, description),
692
+ type = CASE WHEN excluded.type != 'unknown' THEN excluded.type ELSE type END
693
+ `).run(entityId, tenantId, params.name, params.type ?? "unknown", params.description ?? null, now, now);
694
+ return entityId;
695
+ }
696
+ /**
697
+ * Look up a KG entity by name within a tenant.
698
+ * Returns null if the entity does not exist.
699
+ */
700
+ function findKgEntitySqlite(db, name, tenantId = "default") {
701
+ const entityId = entityContentId(name, tenantId);
702
+ return db.prepare("SELECT * FROM kg_entities WHERE entity_id = ? AND tenant_id = ?").get(entityId, tenantId) ?? null;
703
+ }
704
+ /**
705
+ * List KG entities for a tenant, optionally filtered by type.
706
+ */
707
+ function listKgEntitiesSqlite(db, tenantId = "default", type, limit = 100) {
708
+ if (type) return db.prepare("SELECT * FROM kg_entities WHERE tenant_id = ? AND type = ? ORDER BY mention_count DESC LIMIT ?").all(tenantId, type, limit);
709
+ return db.prepare("SELECT * FROM kg_entities WHERE tenant_id = ? ORDER BY mention_count DESC LIMIT ?").all(tenantId, limit);
710
+ }
711
+ /**
712
+ * Apply an EMA (Exponential Moving Average) feedback update to an entity's weight.
713
+ *
714
+ * EMA formula: new_weight = old_weight + alpha * (target - old_weight)
715
+ */
716
+ function updateEntityFeedbackWeightSqlite(db, entityId, normalizedRating, alpha = .1) {
717
+ const row = db.prepare("SELECT feedback_weight FROM kg_entities WHERE entity_id = ?").get(entityId);
718
+ if (!row) return;
719
+ const newWeight = row.feedback_weight + alpha * (normalizedRating - row.feedback_weight);
720
+ db.prepare("UPDATE kg_entities SET feedback_weight = ? WHERE entity_id = ?").run(newWeight, entityId);
721
+ }
722
+
723
+ //#endregion
724
+ //#region src/storage/sqlite.ts
725
+ var SQLiteBackend = class {
726
+ backendType = "sqlite";
727
+ supportsPostgresFeatures = false;
728
+ db;
729
+ constructor(db) {
730
+ this.db = db;
731
+ }
732
+ /**
733
+ * Expose the raw better-sqlite3 Database handle. Storage-internal only —
734
+ * used by db-admin.ts for `pai db query|tables|schema` (the one place
735
+ * outside this class allowed to reach for it). Every other operation must
736
+ * be a StorageBackend method; nothing outside src/storage/ may call this.
737
+ */
738
+ getRawDb() {
739
+ return this.db;
740
+ }
741
+ async close() {
742
+ try {
743
+ this.db.close();
744
+ } catch {}
745
+ }
746
+ async getStats() {
747
+ return {
748
+ files: this.db.prepare("SELECT COUNT(*) AS n FROM memory_files").get().n,
749
+ chunks: this.db.prepare("SELECT COUNT(*) AS n FROM memory_chunks").get().n
750
+ };
751
+ }
752
+ async getProjectStats(projectId) {
753
+ return {
754
+ files: this.db.prepare("SELECT COUNT(*) AS n FROM memory_files WHERE project_id = ?").get(projectId).n,
755
+ chunks: this.db.prepare("SELECT COUNT(*) AS n FROM memory_chunks WHERE project_id = ?").get(projectId).n
756
+ };
757
+ }
758
+ async getFileHash(projectId, path) {
759
+ return this.db.prepare("SELECT hash FROM memory_files WHERE project_id = ? AND path = ?").get(projectId, path)?.hash;
760
+ }
761
+ async upsertFile(file) {
762
+ this.db.prepare(`INSERT INTO memory_files (project_id, path, source, tier, hash, mtime, size)
763
+ VALUES (?, ?, ?, ?, ?, ?, ?)
764
+ ON CONFLICT(project_id, path) DO UPDATE SET
765
+ source = excluded.source,
766
+ tier = excluded.tier,
767
+ hash = excluded.hash,
768
+ mtime = excluded.mtime,
769
+ size = excluded.size`).run(file.projectId, file.path, file.source, file.tier, file.hash, file.mtime, file.size);
770
+ }
771
+ async getChunkIds(projectId, path) {
772
+ return this.db.prepare("SELECT id FROM memory_chunks WHERE project_id = ? AND path = ?").all(projectId, path).map((r) => r.id);
773
+ }
774
+ async deleteChunksForFile(projectId, path) {
775
+ const ids = await this.getChunkIds(projectId, path);
776
+ const deleteFts = this.db.prepare("DELETE FROM memory_fts WHERE id = ?");
777
+ const deleteChunks = this.db.prepare("DELETE FROM memory_chunks WHERE project_id = ? AND path = ?");
778
+ this.db.transaction(() => {
779
+ for (const id of ids) deleteFts.run(id);
780
+ deleteChunks.run(projectId, path);
781
+ })();
782
+ }
783
+ async insertChunks(chunks) {
784
+ if (chunks.length === 0) return;
785
+ const insertChunk = this.db.prepare(`INSERT INTO memory_chunks (id, project_id, source, tier, path, start_line, end_line, hash, text, updated_at)
786
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
787
+ const insertFts = this.db.prepare(`INSERT INTO memory_fts (text, id, project_id, path, source, tier, start_line, end_line)
788
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
789
+ this.db.transaction(() => {
790
+ for (const c of chunks) {
791
+ insertChunk.run(c.id, c.projectId, c.source, c.tier, c.path, c.startLine, c.endLine, c.hash, c.text, c.updatedAt);
792
+ insertFts.run(c.text, c.id, c.projectId, c.path, c.source, c.tier, c.startLine, c.endLine);
793
+ }
794
+ })();
795
+ }
796
+ async getDistinctChunkPaths(projectId) {
797
+ return this.db.prepare("SELECT DISTINCT path FROM memory_chunks WHERE project_id = ?").all(projectId).map((r) => r.path);
798
+ }
799
+ async deletePaths(projectId, paths) {
800
+ if (paths.length === 0) return;
801
+ const deleteFts = this.db.prepare("DELETE FROM memory_fts WHERE id = ?");
802
+ const deleteChunks = this.db.prepare("DELETE FROM memory_chunks WHERE project_id = ? AND path = ?");
803
+ const deleteFile = this.db.prepare("DELETE FROM memory_files WHERE project_id = ? AND path = ?");
804
+ this.db.transaction(() => {
805
+ for (const path of paths) {
806
+ const ids = this.db.prepare("SELECT id FROM memory_chunks WHERE project_id = ? AND path = ?").all(projectId, path);
807
+ for (const { id } of ids) deleteFts.run(id);
808
+ deleteChunks.run(projectId, path);
809
+ deleteFile.run(projectId, path);
810
+ }
811
+ })();
812
+ }
813
+ async getUnembeddedChunkIds(projectId, limit, after) {
814
+ const conditions = ["embedding IS NULL"];
815
+ const params = [];
816
+ if (projectId !== void 0) {
817
+ conditions.push("project_id = ?");
818
+ params.push(projectId);
819
+ }
820
+ const paginating = after !== void 0;
821
+ if (after) {
822
+ conditions.push("(project_id > ? OR (project_id = ? AND id > ?))");
823
+ params.push(after.projectId, after.projectId, after.id);
824
+ }
825
+ const where = "WHERE " + conditions.join(" AND ");
826
+ const orderBy = paginating ? "project_id, id" : `CASE
827
+ WHEN path LIKE '🧠 Ideaverse/%' THEN 0
828
+ WHEN path LIKE 'Z - Zettelkasten/%' THEN 0
829
+ WHEN path LIKE '💼 Business/%' THEN 0
830
+ WHEN path LIKE '📆 Meetings/%' THEN 1
831
+ WHEN path LIKE '💡 Insights/%' THEN 1
832
+ WHEN path LIKE '👨‍💻 People/%' THEN 1
833
+ WHEN path LIKE 'University/%' THEN 1
834
+ WHEN path LIKE 'Copilot/%' THEN 1
835
+ WHEN path LIKE '🗓️ Daily Notes/%' THEN 2
836
+ WHEN path LIKE 'PAI/%' THEN 3
837
+ WHEN path LIKE '09-job-search/%' THEN 4
838
+ WHEN path LIKE 'seriousletter/%' THEN 4
839
+ WHEN path LIKE 'Attachments/%' THEN 5
840
+ ELSE 2
841
+ END, id`;
842
+ return this.db.prepare(`SELECT id, text, project_id, path FROM memory_chunks ${where}
843
+ ORDER BY ${orderBy}${limit !== void 0 ? " LIMIT ?" : ""}`).all(...params, ...limit !== void 0 ? [limit] : []);
844
+ }
845
+ async updateEmbedding(chunkId, embedding) {
846
+ this.db.prepare("UPDATE memory_chunks SET embedding = ? WHERE id = ?").run(embedding, chunkId);
847
+ }
848
+ async searchKeyword(query, opts) {
849
+ return searchMemory(this.db, query, opts);
850
+ }
851
+ async searchSemantic(queryEmbedding, opts) {
852
+ return searchMemorySemantic(this.db, queryEmbedding, opts);
853
+ }
854
+ vaultNotSupported() {
855
+ throw new Error("Vault operations require the Postgres backend");
856
+ }
857
+ async upsertVaultFile() {
858
+ this.vaultNotSupported();
859
+ }
860
+ async deleteVaultFile() {
861
+ this.vaultNotSupported();
862
+ }
863
+ async getVaultFile() {
864
+ this.vaultNotSupported();
865
+ }
866
+ async getVaultFileByInode() {
867
+ this.vaultNotSupported();
868
+ }
869
+ async getAllVaultFiles() {
870
+ this.vaultNotSupported();
871
+ }
872
+ async getRecentVaultFiles() {
873
+ this.vaultNotSupported();
874
+ }
875
+ async countVaultFiles() {
876
+ this.vaultNotSupported();
877
+ }
878
+ async upsertVaultAliases() {
879
+ this.vaultNotSupported();
880
+ }
881
+ async deleteVaultAliases() {
882
+ this.vaultNotSupported();
883
+ }
884
+ async replaceLinksForSources() {
885
+ this.vaultNotSupported();
886
+ }
887
+ async getLinksFromSource() {
888
+ this.vaultNotSupported();
889
+ }
890
+ async getLinksToTarget() {
891
+ this.vaultNotSupported();
892
+ }
893
+ async getVaultLinkGraph() {
894
+ this.vaultNotSupported();
895
+ }
896
+ async upsertVaultHealth() {
897
+ this.vaultNotSupported();
898
+ }
899
+ async getVaultHealth() {
900
+ this.vaultNotSupported();
901
+ }
902
+ async getOrphans() {
903
+ this.vaultNotSupported();
904
+ }
905
+ async getDeadLinks() {
906
+ this.vaultNotSupported();
907
+ }
908
+ async upsertNameIndex() {
909
+ this.vaultNotSupported();
910
+ }
911
+ async replaceNameIndex() {
912
+ this.vaultNotSupported();
913
+ }
914
+ async resolveVaultName() {
915
+ this.vaultNotSupported();
916
+ }
917
+ async searchVaultNameIndex() {
918
+ this.vaultNotSupported();
919
+ }
920
+ async getVaultFilesByPaths() {
921
+ this.vaultNotSupported();
922
+ }
923
+ async getVaultFilesByPathsAfter() {
924
+ this.vaultNotSupported();
925
+ }
926
+ async getVaultLinksFromPaths() {
927
+ this.vaultNotSupported();
928
+ }
929
+ async getChunksWithEmbeddings() {
930
+ this.vaultNotSupported();
931
+ }
932
+ async getChunksForPath() {
933
+ this.vaultNotSupported();
934
+ }
935
+ async searchChunksByText() {
936
+ this.vaultNotSupported();
937
+ }
938
+ async countVaultFilesWithPrefix() {
939
+ this.vaultNotSupported();
940
+ }
941
+ async countVaultFilesAfter() {
942
+ this.vaultNotSupported();
943
+ }
944
+ async countVaultLinksWithPrefix() {
945
+ this.vaultNotSupported();
946
+ }
947
+ async countVaultLinksAfter() {
948
+ this.vaultNotSupported();
949
+ }
950
+ async getDeadLinksWithLineNumbers() {
951
+ this.vaultNotSupported();
952
+ }
953
+ async getDeadLinksWithPrefix() {
954
+ this.vaultNotSupported();
955
+ }
956
+ async getDeadLinksAfter() {
957
+ this.vaultNotSupported();
958
+ }
959
+ async getOrphansWithPrefix() {
960
+ this.vaultNotSupported();
961
+ }
962
+ async getOrphansAfter() {
963
+ this.vaultNotSupported();
964
+ }
965
+ async getLowConnectivity() {
966
+ this.vaultNotSupported();
967
+ }
968
+ async getLowConnectivityWithPrefix() {
969
+ this.vaultNotSupported();
970
+ }
971
+ async getLowConnectivityAfter() {
972
+ this.vaultNotSupported();
973
+ }
974
+ async getAllVaultFilePaths() {
975
+ this.vaultNotSupported();
976
+ }
977
+ async getVaultFilePathsWithPrefix() {
978
+ this.vaultNotSupported();
979
+ }
980
+ async getVaultFilePathsAfter() {
981
+ this.vaultNotSupported();
982
+ }
983
+ async getVaultLinkEdges() {
984
+ this.vaultNotSupported();
985
+ }
986
+ async getVaultLinkEdgesWithPrefix() {
987
+ this.vaultNotSupported();
988
+ }
989
+ async getVaultLinkEdgesAfter() {
990
+ this.vaultNotSupported();
991
+ }
992
+ async getVaultAlias() {
993
+ this.vaultNotSupported();
994
+ }
995
+ async upsertKgEntity(params) {
996
+ return upsertKgEntitySqlite(this.db, params);
997
+ }
998
+ async findKgEntity(name, tenantId = "default") {
999
+ return findKgEntitySqlite(this.db, name, tenantId);
1000
+ }
1001
+ async listKgEntities(tenantId = "default", type, limit = 100) {
1002
+ return listKgEntitiesSqlite(this.db, tenantId, type, limit);
1003
+ }
1004
+ async updateEntityFeedbackWeight(entityId, normalizedRating, alpha = .1) {
1005
+ return updateEntityFeedbackWeightSqlite(this.db, entityId, normalizedRating, alpha);
1006
+ }
1007
+ async getChunksForFeedback(chunkIds) {
1008
+ if (chunkIds.length === 0) return [];
1009
+ const placeholders = chunkIds.map(() => "?").join(", ");
1010
+ return this.db.prepare(`SELECT id, text, relevance_score FROM memory_chunks WHERE id IN (${placeholders})`).all(...chunkIds).map((r) => ({
1011
+ id: r.id,
1012
+ text: r.text,
1013
+ relevanceScore: r.relevance_score
1014
+ }));
1015
+ }
1016
+ async updateChunkRelevanceScore(chunkId, score) {
1017
+ this.db.prepare("UPDATE memory_chunks SET relevance_score = ? WHERE id = ?").run(score, chunkId);
1018
+ }
1019
+ async touchChunksLastAccessed(chunkIds) {
1020
+ touchChunksLastAccessed(this.db, chunkIds);
1021
+ }
1022
+ async indexAll(registry) {
1023
+ return indexAllSqlite(this.db, registry);
1024
+ }
1025
+ async findTunnels(registry, options) {
1026
+ return findTunnelsSqlite(this.db, registry, options);
1027
+ }
1028
+ async getMemorySourcesReport() {
1029
+ return getMemorySourcesReportSqlite(this.db);
1030
+ }
1031
+ postgresOnly(feature) {
1032
+ throw new Error(`${feature} is not supported on the sqlite backend — set "storageBackend": "postgres" in the daemon config`);
1033
+ }
1034
+ async addKgTriple() {
1035
+ this.postgresOnly("kg_triples");
1036
+ }
1037
+ async queryKgTriples() {
1038
+ this.postgresOnly("kg_triples");
1039
+ }
1040
+ async invalidateKgTriple() {
1041
+ this.postgresOnly("kg_triples");
1042
+ }
1043
+ async getKgContradictions() {
1044
+ this.postgresOnly("kg_triples");
1045
+ }
1046
+ async getKgStats() {
1047
+ this.postgresOnly("kg_triples");
1048
+ }
1049
+ async storeObservation() {
1050
+ this.postgresOnly("observations");
1051
+ }
1052
+ async storeObservationWithProject() {
1053
+ this.postgresOnly("observations");
1054
+ }
1055
+ async queryObservations() {
1056
+ this.postgresOnly("observations");
1057
+ }
1058
+ async queryRecentObservations() {
1059
+ this.postgresOnly("observations");
1060
+ }
1061
+ async querySessionObservations() {
1062
+ this.postgresOnly("observations");
1063
+ }
1064
+ async getObservationStats() {
1065
+ this.postgresOnly("observations");
1066
+ }
1067
+ async getObservationTypesForPaths() {
1068
+ return /* @__PURE__ */ new Map();
1069
+ }
1070
+ async storeSessionSummary() {
1071
+ this.postgresOnly("session summaries");
1072
+ }
1073
+ async queryRecentSummaries() {
1074
+ this.postgresOnly("session summaries");
1075
+ }
1076
+ async recordSkillInvocation() {
1077
+ this.postgresOnly("skill telemetry");
1078
+ }
1079
+ async querySkillTelemetry() {
1080
+ this.postgresOnly("skill telemetry");
1081
+ }
1082
+ async countFilesWithPaths(paths) {
1083
+ if (paths.length === 0) return 0;
1084
+ const placeholders = paths.map(() => "?").join(", ");
1085
+ return this.db.prepare(`SELECT COUNT(*) AS n FROM memory_files WHERE path IN (${placeholders})`).get(...paths).n;
1086
+ }
1087
+ async renameFilePaths(moves) {
1088
+ if (moves.length === 0) return 0;
1089
+ const updateFiles = this.db.prepare("UPDATE memory_files SET path = ? WHERE path = ?");
1090
+ const updateChunks = this.db.prepare("UPDATE memory_chunks SET path = ? WHERE path = ?");
1091
+ let filesUpdated = 0;
1092
+ this.db.transaction(() => {
1093
+ for (const { oldPath, newPath } of moves) {
1094
+ const r = updateFiles.run(newPath, oldPath);
1095
+ filesUpdated += r.changes;
1096
+ updateChunks.run(newPath, oldPath);
1097
+ }
1098
+ })();
1099
+ return filesUpdated;
1100
+ }
1101
+ };
1102
+
1103
+ //#endregion
1104
+ //#region src/registry/merge.ts
1105
+ var MergeError = class extends Error {};
1106
+
1107
+ //#endregion
1108
+ //#region src/storage/registry-postgres.ts
1109
+ /**
1110
+ * PostgresRegistryBackend — implements RegistryBackend over the pg pool
1111
+ * pattern already used by PostgresBackend (src/storage/postgres/backend.ts).
1112
+ *
1113
+ * Same SQL shape as SQLiteRegistryBackend, translated to $-placeholders.
1114
+ * Behavioural notes where Postgres diverges from SQLite's "OR IGNORE":
1115
+ * - `INSERT ... ON CONFLICT DO NOTHING` (no target) catches any unique
1116
+ * violation on the table, matching SQLite's INSERT OR IGNORE.
1117
+ * - SQLite's `UPDATE OR IGNORE` (used once, in reassignLinksTarget/
1118
+ * applyProjectMerge's link retarget) has no direct Postgres equivalent —
1119
+ * an UPDATE cannot specify ON CONFLICT. Replicated with a NOT EXISTS guard
1120
+ * that skips rows whose new (session_id, target_project_id) would collide,
1121
+ * leaving them for the caller's existing cleanup DELETE, exactly as the
1122
+ * SQLite version does.
1123
+ */
1124
+ const { Pool: PgPool } = pg;
1125
+ /**
1126
+ * node-postgres returns BIGINT columns (created_at/updated_at/archived_at/
1127
+ * closed_at, and MAX() over a BIGINT column) as strings — JS numbers can't
1128
+ * safely hold the full 64-bit range. Every value here is Date.now() ms and
1129
+ * well inside Number.isSafeInteger, so a plain Number() conversion keeps the
1130
+ * shape identical to better-sqlite3's row objects (same pattern as
1131
+ * rowToKgEntity in postgres/backend.ts).
1132
+ */
1133
+ function toNum(v) {
1134
+ return typeof v === "string" ? Number(v) : v;
1135
+ }
1136
+ function toNumOrNull(v) {
1137
+ return v === null || v === void 0 ? null : toNum(v);
1138
+ }
1139
+ function mapProject(row) {
1140
+ return {
1141
+ ...row,
1142
+ created_at: toNum(row.created_at),
1143
+ updated_at: toNum(row.updated_at),
1144
+ archived_at: toNumOrNull(row.archived_at)
1145
+ };
1146
+ }
1147
+ function mapProjectWithStats(row) {
1148
+ return {
1149
+ ...mapProject(row),
1150
+ session_count: toNum(row.session_count),
1151
+ last_active: toNumOrNull(row.last_active)
1152
+ };
1153
+ }
1154
+ function mapSession(row) {
1155
+ return {
1156
+ ...row,
1157
+ created_at: toNum(row.created_at),
1158
+ closed_at: toNumOrNull(row.closed_at)
1159
+ };
1160
+ }
1161
+ var PostgresRegistryBackend = class {
1162
+ backendType = "postgres";
1163
+ pool;
1164
+ /** False when constructed from a pool owned elsewhere (the shared storage
1165
+ * backend pool, wired by src/storage/factory.ts) — close() must not end
1166
+ * a pool this instance doesn't own. */
1167
+ ownsPool;
1168
+ constructor(configOrPool) {
1169
+ if (configOrPool instanceof PgPool) {
1170
+ this.pool = configOrPool;
1171
+ this.ownsPool = false;
1172
+ return;
1173
+ }
1174
+ const config = configOrPool;
1175
+ this.pool = new PgPool({
1176
+ connectionString: config.connectionString ?? `postgresql://${config.user ?? "pai"}:${config.password ?? "pai"}@${config.host ?? "localhost"}:${config.port ?? 5432}/${config.database ?? "pai"}`,
1177
+ max: config.maxConnections ?? 5,
1178
+ connectionTimeoutMillis: config.connectionTimeoutMs ?? 5e3,
1179
+ idleTimeoutMillis: 3e4,
1180
+ allowExitOnIdle: true
1181
+ });
1182
+ this.ownsPool = true;
1183
+ this.pool.on("error", (err) => {
1184
+ process.stderr.write(`[pai-postgres] Registry pool error: ${err.message}\n`);
1185
+ });
1186
+ }
1187
+ async close() {
1188
+ if (this.ownsPool) await this.pool.end();
1189
+ }
1190
+ async resetRegistry() {
1191
+ const client = await this.pool.connect();
1192
+ try {
1193
+ await client.query("BEGIN");
1194
+ await client.query("DELETE FROM links");
1195
+ await client.query("DELETE FROM compaction_log");
1196
+ await client.query("DELETE FROM session_tags");
1197
+ await client.query("DELETE FROM project_tags");
1198
+ await client.query("DELETE FROM aliases");
1199
+ await client.query("DELETE FROM sessions");
1200
+ await client.query("DELETE FROM tags");
1201
+ await client.query("DELETE FROM projects");
1202
+ await client.query("COMMIT");
1203
+ } catch (e) {
1204
+ await client.query("ROLLBACK");
1205
+ throw e;
1206
+ } finally {
1207
+ client.release();
1208
+ }
1209
+ }
1210
+ async getProjectById(id) {
1211
+ const r = await this.pool.query("SELECT * FROM projects WHERE id = $1", [id]);
1212
+ return r.rows[0] ? mapProject(r.rows[0]) : null;
1213
+ }
1214
+ async getProjectBySlug(slug, opts = {}) {
1215
+ let sql = opts.caseInsensitive ? "SELECT * FROM projects WHERE lower(slug) = lower($1)" : "SELECT * FROM projects WHERE slug = $1";
1216
+ const params = [slug];
1217
+ if (opts.excludeId !== void 0) {
1218
+ params.push(opts.excludeId);
1219
+ sql += ` AND id != $${params.length}`;
1220
+ }
1221
+ const r = await this.pool.query(sql, params);
1222
+ return r.rows[0] ? mapProject(r.rows[0]) : null;
1223
+ }
1224
+ async getProjectByAlias(alias, opts = {}) {
1225
+ const sql = opts.caseInsensitive ? "SELECT p.* FROM projects p JOIN aliases a ON a.project_id = p.id WHERE lower(a.alias) = lower($1)" : "SELECT p.* FROM projects p JOIN aliases a ON a.project_id = p.id WHERE a.alias = $1";
1226
+ const r = await this.pool.query(sql, [alias]);
1227
+ return r.rows[0] ? mapProject(r.rows[0]) : null;
1228
+ }
1229
+ async getProjectByRootPath(rootPath, opts = {}) {
1230
+ let sql = "SELECT * FROM projects WHERE root_path = $1";
1231
+ const params = [rootPath];
1232
+ if (opts.excludeId !== void 0) {
1233
+ params.push(opts.excludeId);
1234
+ sql += ` AND id != $${params.length}`;
1235
+ }
1236
+ const r = await this.pool.query(sql, params);
1237
+ return r.rows[0] ? mapProject(r.rows[0]) : null;
1238
+ }
1239
+ async getProjectByEncodedDir(encodedDir, opts = {}) {
1240
+ let sql = "SELECT * FROM projects WHERE encoded_dir = $1";
1241
+ const params = [encodedDir];
1242
+ if (opts.excludeId !== void 0) {
1243
+ params.push(opts.excludeId);
1244
+ sql += ` AND id != $${params.length}`;
1245
+ }
1246
+ const r = await this.pool.query(sql, params);
1247
+ return r.rows[0] ? mapProject(r.rows[0]) : null;
1248
+ }
1249
+ async findProjectByCwdPrefix(cwd) {
1250
+ const r = await this.pool.query(`SELECT * FROM projects WHERE status = 'active' AND $1 LIKE root_path || '%'
1251
+ ORDER BY length(root_path) DESC LIMIT 1`, [cwd]);
1252
+ return r.rows[0] ? mapProject(r.rows[0]) : null;
1253
+ }
1254
+ async listProjects(opts = {}) {
1255
+ const where = [];
1256
+ const params = [];
1257
+ if (opts.status) {
1258
+ params.push(opts.status);
1259
+ where.push(`p.status = $${params.length}`);
1260
+ }
1261
+ if (opts.excludeArchived) where.push("p.status != 'archived'");
1262
+ if (opts.tagName) {
1263
+ params.push(opts.tagName);
1264
+ where.push(`p.id IN (SELECT pt.project_id FROM project_tags pt JOIN tags t ON pt.tag_id = t.id WHERE t.name = $${params.length})`);
1265
+ }
1266
+ let sql = "SELECT p.* FROM projects p";
1267
+ if (where.length) sql += " WHERE " + where.join(" AND ");
1268
+ switch (opts.orderBy) {
1269
+ case "updated_desc":
1270
+ sql += " ORDER BY p.updated_at DESC";
1271
+ break;
1272
+ case "slug":
1273
+ sql += " ORDER BY p.slug ASC";
1274
+ break;
1275
+ case "id":
1276
+ sql += " ORDER BY p.id";
1277
+ break;
1278
+ default:
1279
+ sql += " ORDER BY p.status ASC, p.updated_at DESC";
1280
+ break;
1281
+ }
1282
+ if (opts.limit !== void 0) {
1283
+ params.push(opts.limit);
1284
+ sql += ` LIMIT $${params.length}`;
1285
+ }
1286
+ return (await this.pool.query(sql, params)).rows.map(mapProject);
1287
+ }
1288
+ async listProjectsByPathLengthDesc(opts = {}) {
1289
+ const where = opts.excludeArchived ? "WHERE status != 'archived'" : "";
1290
+ return (await this.pool.query(`SELECT * FROM projects ${where} ORDER BY LENGTH(root_path) DESC`)).rows.map(mapProject);
1291
+ }
1292
+ async listProjectsWithSessionStats(opts = {}) {
1293
+ const where = [];
1294
+ const params = [];
1295
+ if (opts.status) {
1296
+ params.push(opts.status);
1297
+ where.push(`p.status = $${params.length}`);
1298
+ }
1299
+ if (opts.tagId !== void 0) {
1300
+ params.push(opts.tagId);
1301
+ where.push(`p.id IN (SELECT project_id FROM project_tags WHERE tag_id = $${params.length})`);
1302
+ }
1303
+ let sql = `
1304
+ SELECT p.*,
1305
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id)::int AS session_count,
1306
+ (SELECT MAX(s.created_at) FROM sessions s WHERE s.project_id = p.id) AS last_active
1307
+ FROM projects p
1308
+ `;
1309
+ if (where.length) sql += " WHERE " + where.join(" AND ");
1310
+ sql += opts.orderBy === "updated_desc" ? " ORDER BY p.updated_at DESC" : " ORDER BY p.status ASC, p.updated_at DESC";
1311
+ return (await this.pool.query(sql, params)).rows.map(mapProjectWithStats);
1312
+ }
1313
+ async listNamedProjects(opts = {}) {
1314
+ const sql = `
1315
+ SELECT p.*, a.alias AS name,
1316
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id)::int AS session_count,
1317
+ (SELECT MAX(s.created_at) FROM sessions s WHERE s.project_id = p.id) AS last_active
1318
+ FROM projects p
1319
+ ${opts.includeUnnamed ? "LEFT JOIN" : "JOIN"} aliases a ON a.project_id = p.id
1320
+ WHERE p.status = 'active'
1321
+ ORDER BY p.updated_at DESC
1322
+ `;
1323
+ return (await this.pool.query(sql)).rows.map((row) => ({
1324
+ ...mapProjectWithStats(row),
1325
+ name: row.name
1326
+ }));
1327
+ }
1328
+ async searchProjects(query, limit = 20) {
1329
+ const q = `%${query}%`;
1330
+ return (await this.pool.query(`SELECT * FROM projects WHERE slug LIKE $1 OR display_name LIKE $1 OR root_path LIKE $1
1331
+ ORDER BY updated_at DESC LIMIT $2`, [q, limit])).rows.map(mapProject);
1332
+ }
1333
+ async countProjects(opts = {}) {
1334
+ const r = opts.status ? await this.pool.query("SELECT COUNT(*)::text AS n FROM projects WHERE status = $1", [opts.status]) : await this.pool.query("SELECT COUNT(*)::text AS n FROM projects");
1335
+ return parseInt(r.rows[0].n, 10);
1336
+ }
1337
+ async getMostRecentProjectUpdatedAt() {
1338
+ const r = await this.pool.query("SELECT updated_at FROM projects ORDER BY updated_at DESC LIMIT 1");
1339
+ return r.rows[0] ? Number(r.rows[0].updated_at) : null;
1340
+ }
1341
+ async countChildProjects(parentId) {
1342
+ const r = await this.pool.query("SELECT COUNT(*)::text AS n FROM projects WHERE parent_id = $1", [parentId]);
1343
+ return parseInt(r.rows[0].n, 10);
1344
+ }
1345
+ async findSiblingProjectsBySlugPattern(excludeProjectId, slug, limit = 3) {
1346
+ return (await this.pool.query(`SELECT p.slug, p.root_path, COUNT(s.id)::int AS session_count
1347
+ FROM projects p LEFT JOIN sessions s ON s.project_id = p.id
1348
+ WHERE p.id != $1 AND (p.slug = $2 OR p.slug LIKE $2 || '-%' OR $2 LIKE p.slug || '-%')
1349
+ GROUP BY p.id HAVING COUNT(s.id) > 0 ORDER BY session_count DESC LIMIT $3`, [
1350
+ excludeProjectId,
1351
+ slug,
1352
+ limit
1353
+ ])).rows;
1354
+ }
1355
+ async createProject(input) {
1356
+ const type = input.type ?? "local";
1357
+ const status = input.status ?? "active";
1358
+ return mapProject((await this.pool.query(`INSERT INTO projects (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
1359
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [
1360
+ input.slug,
1361
+ input.displayName,
1362
+ input.rootPath,
1363
+ input.encodedDir,
1364
+ type,
1365
+ status,
1366
+ input.createdAt,
1367
+ input.updatedAt
1368
+ ])).rows[0]);
1369
+ }
1370
+ async createProjectWithSlugRetry(input) {
1371
+ let slug = input.baseSlug;
1372
+ let attempt = 0;
1373
+ while (true) {
1374
+ const r = await this.pool.query(`INSERT INTO projects (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
1375
+ VALUES ($1, $2, $3, $4, 'local', 'active', $5, $6)
1376
+ ON CONFLICT DO NOTHING RETURNING id`, [
1377
+ slug,
1378
+ input.displayName,
1379
+ input.rootPath,
1380
+ input.encodedDir,
1381
+ input.createdAt,
1382
+ input.updatedAt
1383
+ ]);
1384
+ if (r.rows.length > 0) return {
1385
+ id: r.rows[0].id,
1386
+ slug,
1387
+ created: true
1388
+ };
1389
+ const existing = await this.pool.query("SELECT id FROM projects WHERE root_path = $1", [input.rootPath]);
1390
+ if (existing.rows[0]) return {
1391
+ id: existing.rows[0].id,
1392
+ slug,
1393
+ created: false
1394
+ };
1395
+ attempt++;
1396
+ slug = `${input.baseSlug}-${attempt}`;
1397
+ }
1398
+ }
1399
+ async updateProjectPath(id, patch, updatedAt) {
1400
+ const sets = [];
1401
+ const params = [];
1402
+ if (patch.rootPath !== void 0) {
1403
+ params.push(patch.rootPath);
1404
+ sets.push(`root_path = $${params.length}`);
1405
+ }
1406
+ if (patch.encodedDir !== void 0) {
1407
+ params.push(patch.encodedDir);
1408
+ sets.push(`encoded_dir = $${params.length}`);
1409
+ }
1410
+ if (updatedAt !== void 0) {
1411
+ params.push(updatedAt);
1412
+ sets.push(`updated_at = $${params.length}`);
1413
+ }
1414
+ if (!sets.length) return;
1415
+ params.push(id);
1416
+ await this.pool.query(`UPDATE projects SET ${sets.join(", ")} WHERE id = $${params.length}`, params);
1417
+ }
1418
+ async updateProjectStatus(id, status, opts = {}) {
1419
+ const sets = [];
1420
+ const params = [status];
1421
+ sets.push("status = $1");
1422
+ if (opts.archivedAt !== void 0) {
1423
+ params.push(opts.archivedAt);
1424
+ sets.push(`archived_at = $${params.length}`);
1425
+ }
1426
+ if (opts.updatedAt !== void 0) {
1427
+ params.push(opts.updatedAt);
1428
+ sets.push(`updated_at = $${params.length}`);
1429
+ }
1430
+ params.push(id);
1431
+ let sql = `UPDATE projects SET ${sets.join(", ")} WHERE id = $${params.length}`;
1432
+ if (opts.requireCurrentStatus) {
1433
+ params.push(opts.requireCurrentStatus);
1434
+ sql += ` AND status = $${params.length}`;
1435
+ }
1436
+ await this.pool.query(sql, params);
1437
+ }
1438
+ async updateProjectDisplayName(id, displayName, updatedAt) {
1439
+ if (updatedAt !== void 0) await this.pool.query("UPDATE projects SET display_name = $1, updated_at = $2 WHERE id = $3", [
1440
+ displayName,
1441
+ updatedAt,
1442
+ id
1443
+ ]);
1444
+ else await this.pool.query("UPDATE projects SET display_name = $1 WHERE id = $2", [displayName, id]);
1445
+ }
1446
+ async updateProjectType(id, type, updatedAt) {
1447
+ if (updatedAt !== void 0) await this.pool.query("UPDATE projects SET type = $1, updated_at = $2 WHERE id = $3", [
1448
+ type,
1449
+ updatedAt,
1450
+ id
1451
+ ]);
1452
+ else await this.pool.query("UPDATE projects SET type = $1 WHERE id = $2", [type, id]);
1453
+ }
1454
+ async updateProjectSessionConfig(id, config, updatedAt) {
1455
+ if (updatedAt !== void 0) await this.pool.query("UPDATE projects SET session_config = $1, updated_at = $2 WHERE id = $3", [
1456
+ config,
1457
+ updatedAt,
1458
+ id
1459
+ ]);
1460
+ else await this.pool.query("UPDATE projects SET session_config = $1 WHERE id = $2", [config, id]);
1461
+ }
1462
+ async updateProjectClaudeNotesDir(id, dir, updatedAt) {
1463
+ if (updatedAt !== void 0) await this.pool.query("UPDATE projects SET claude_notes_dir = $1, updated_at = $2 WHERE id = $3", [
1464
+ dir,
1465
+ updatedAt,
1466
+ id
1467
+ ]);
1468
+ else await this.pool.query("UPDATE projects SET claude_notes_dir = $1 WHERE id = $2", [dir, id]);
1469
+ }
1470
+ async updateProjectObsidianLink(id, link, updatedAt) {
1471
+ if (updatedAt !== void 0) await this.pool.query("UPDATE projects SET obsidian_link = $1, updated_at = $2 WHERE id = $3", [
1472
+ link,
1473
+ updatedAt,
1474
+ id
1475
+ ]);
1476
+ else await this.pool.query("UPDATE projects SET obsidian_link = $1 WHERE id = $2", [link, id]);
1477
+ }
1478
+ async updateProjectSlug(id, slug, updatedAt) {
1479
+ await this.pool.query("UPDATE projects SET slug = $1, updated_at = $2 WHERE id = $3", [
1480
+ slug,
1481
+ updatedAt,
1482
+ id
1483
+ ]);
1484
+ }
1485
+ async reassignProjectParent(fromParentId, toParentId) {
1486
+ return (await this.pool.query("UPDATE projects SET parent_id = $1 WHERE parent_id = $2", [toParentId, fromParentId])).rowCount ?? 0;
1487
+ }
1488
+ async deleteProject(id) {
1489
+ await this.pool.query("DELETE FROM projects WHERE id = $1", [id]);
1490
+ }
1491
+ async deleteProjectCascade(id) {
1492
+ const client = await this.pool.connect();
1493
+ try {
1494
+ await client.query("BEGIN");
1495
+ await client.query("DELETE FROM links WHERE target_project_id = $1", [id]);
1496
+ await client.query("DELETE FROM links WHERE session_id IN (SELECT id FROM sessions WHERE project_id = $1)", [id]);
1497
+ await client.query("DELETE FROM compaction_log WHERE project_id = $1", [id]);
1498
+ await client.query("DELETE FROM project_tags WHERE project_id = $1", [id]);
1499
+ await client.query("DELETE FROM aliases WHERE project_id = $1", [id]);
1500
+ await client.query("DELETE FROM sessions WHERE project_id = $1", [id]);
1501
+ await client.query("DELETE FROM projects WHERE id = $1", [id]);
1502
+ await client.query("COMMIT");
1503
+ } catch (e) {
1504
+ await client.query("ROLLBACK");
1505
+ throw e;
1506
+ } finally {
1507
+ client.release();
1508
+ }
1509
+ }
1510
+ async listTagsForProject(projectId) {
1511
+ return (await this.pool.query("SELECT t.name FROM tags t JOIN project_tags pt ON pt.tag_id = t.id WHERE pt.project_id = $1 ORDER BY t.name", [projectId])).rows.map((row) => row.name);
1512
+ }
1513
+ async listAllTags() {
1514
+ return (await this.pool.query("SELECT id, name FROM tags ORDER BY name")).rows;
1515
+ }
1516
+ async upsertTag(name) {
1517
+ await this.pool.query("INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", [name]);
1518
+ return (await this.pool.query("SELECT id FROM tags WHERE name = $1", [name])).rows[0].id;
1519
+ }
1520
+ async addProjectTag(projectId, tagId) {
1521
+ await this.pool.query("INSERT INTO project_tags (project_id, tag_id) VALUES ($1, $2)", [projectId, tagId]);
1522
+ }
1523
+ async projectHasTag(projectId, tagId) {
1524
+ return ((await this.pool.query("SELECT 1 FROM project_tags WHERE project_id = $1 AND tag_id = $2", [projectId, tagId])).rowCount ?? 0) > 0;
1525
+ }
1526
+ async deleteProjectTag(projectId, tagId) {
1527
+ await this.pool.query("DELETE FROM project_tags WHERE project_id = $1 AND tag_id = $2", [projectId, tagId]);
1528
+ }
1529
+ async reassignProjectTag(fromProjectId, toProjectId, tagId) {
1530
+ await this.pool.query("UPDATE project_tags SET project_id = $1 WHERE project_id = $2 AND tag_id = $3", [
1531
+ toProjectId,
1532
+ fromProjectId,
1533
+ tagId
1534
+ ]);
1535
+ }
1536
+ async listProjectTagIds(projectId) {
1537
+ return (await this.pool.query("SELECT tag_id FROM project_tags WHERE project_id = $1", [projectId])).rows.map((row) => row.tag_id);
1538
+ }
1539
+ async countProjectTagsForProject(projectId) {
1540
+ const r = await this.pool.query("SELECT COUNT(*)::text AS n FROM project_tags WHERE project_id = $1", [projectId]);
1541
+ return parseInt(r.rows[0].n, 10);
1542
+ }
1543
+ async copyProjectTags(fromProjectId, toProjectId) {
1544
+ await this.pool.query(`INSERT INTO project_tags (project_id, tag_id) SELECT $1, tag_id FROM project_tags WHERE project_id = $2
1545
+ ON CONFLICT (project_id, tag_id) DO NOTHING`, [toProjectId, fromProjectId]);
1546
+ }
1547
+ async deleteProjectTagsForProject(projectId) {
1548
+ await this.pool.query("DELETE FROM project_tags WHERE project_id = $1", [projectId]);
1549
+ }
1550
+ async resolveAlias(alias) {
1551
+ return (await this.pool.query("SELECT project_id FROM aliases WHERE alias = $1", [alias])).rows[0]?.project_id ?? null;
1552
+ }
1553
+ async listAliasesForProject(projectId) {
1554
+ return (await this.pool.query("SELECT alias FROM aliases WHERE project_id = $1 ORDER BY alias", [projectId])).rows.map((row) => row.alias);
1555
+ }
1556
+ async addAlias(alias, projectId) {
1557
+ await this.pool.query("INSERT INTO aliases (alias, project_id) VALUES ($1, $2)", [alias, projectId]);
1558
+ }
1559
+ async removeAlias(alias) {
1560
+ await this.pool.query("DELETE FROM aliases WHERE alias = $1", [alias]);
1561
+ }
1562
+ async countAliasesForProject(projectId) {
1563
+ const r = await this.pool.query("SELECT COUNT(*)::text AS n FROM aliases WHERE project_id = $1", [projectId]);
1564
+ return parseInt(r.rows[0].n, 10);
1565
+ }
1566
+ async moveProjectAliases(fromProjectId, toProjectId) {
1567
+ await this.pool.query("UPDATE aliases SET project_id = $1 WHERE project_id = $2", [toProjectId, fromProjectId]);
1568
+ }
1569
+ async reassignAlias(alias, toProjectId) {
1570
+ await this.pool.query("UPDATE aliases SET project_id = $1 WHERE alias = $2", [toProjectId, alias]);
1571
+ }
1572
+ async listAliasMap() {
1573
+ return (await this.pool.query(`SELECT a.alias AS alias, p.slug AS slug, p.root_path AS root_path
1574
+ FROM aliases a JOIN projects p ON p.id = a.project_id WHERE p.status != 'archived'`)).rows;
1575
+ }
1576
+ async getSessionById(id) {
1577
+ const r = await this.pool.query("SELECT * FROM sessions WHERE id = $1", [id]);
1578
+ return r.rows[0] ? mapSession(r.rows[0]) : null;
1579
+ }
1580
+ async getSessionByNumber(projectId, number) {
1581
+ const r = await this.pool.query("SELECT * FROM sessions WHERE project_id = $1 AND number = $2", [projectId, number]);
1582
+ return r.rows[0] ? mapSession(r.rows[0]) : null;
1583
+ }
1584
+ async getLatestSessionForProject(projectId) {
1585
+ const r = await this.pool.query("SELECT * FROM sessions WHERE project_id = $1 ORDER BY number DESC LIMIT 1", [projectId]);
1586
+ return r.rows[0] ? mapSession(r.rows[0]) : null;
1587
+ }
1588
+ async getMaxSessionNumber(projectId) {
1589
+ return (await this.pool.query("SELECT COALESCE(MAX(number), 0) AS n FROM sessions WHERE project_id = $1", [projectId])).rows[0].n;
1590
+ }
1591
+ async listSessionsForProject(projectId, opts = {}) {
1592
+ let sql = `SELECT * FROM sessions WHERE project_id = $1 ORDER BY ${opts.orderBy === "created_desc" ? "created_at DESC" : "number ASC"}`;
1593
+ const params = [projectId];
1594
+ if (opts.limit !== void 0) {
1595
+ params.push(opts.limit);
1596
+ sql += ` LIMIT $${params.length}`;
1597
+ }
1598
+ return (await this.pool.query(sql, params)).rows.map(mapSession);
1599
+ }
1600
+ async listSessions(opts = {}) {
1601
+ const where = [];
1602
+ const params = [];
1603
+ if (opts.projectId !== void 0) {
1604
+ params.push(opts.projectId);
1605
+ where.push(`s.project_id = $${params.length}`);
1606
+ }
1607
+ if (opts.status) {
1608
+ params.push(opts.status);
1609
+ where.push(`s.status = $${params.length}`);
1610
+ }
1611
+ let sql = `
1612
+ SELECT s.*, p.slug AS project_slug, p.display_name AS project_name
1613
+ FROM sessions s JOIN projects p ON p.id = s.project_id
1614
+ `;
1615
+ if (where.length) sql += " WHERE " + where.join(" AND ");
1616
+ sql += " ORDER BY s.date DESC, s.number DESC";
1617
+ params.push(opts.limit ?? 20);
1618
+ sql += ` LIMIT $${params.length}`;
1619
+ return (await this.pool.query(sql, params)).rows.map((row) => ({
1620
+ ...mapSession(row),
1621
+ project_slug: row.project_slug,
1622
+ project_name: row.project_name
1623
+ }));
1624
+ }
1625
+ async findSessionByFilename(projectId, filename) {
1626
+ return (await this.pool.query("SELECT id FROM sessions WHERE project_id = $1 AND filename = $2 LIMIT 1", [projectId, filename])).rows[0] ?? null;
1627
+ }
1628
+ async sessionNumberTaken(projectId, number) {
1629
+ return ((await this.pool.query("SELECT 1 FROM sessions WHERE project_id = $1 AND number = $2 LIMIT 1", [projectId, number])).rowCount ?? 0) > 0;
1630
+ }
1631
+ async createSession(input) {
1632
+ const status = input.status ?? "completed";
1633
+ return mapSession((await this.pool.query(`INSERT INTO sessions (project_id, number, date, slug, title, filename, status, created_at)
1634
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [
1635
+ input.projectId,
1636
+ input.number,
1637
+ input.date,
1638
+ input.slug,
1639
+ input.title,
1640
+ input.filename,
1641
+ status,
1642
+ input.createdAt
1643
+ ])).rows[0]);
1644
+ }
1645
+ async upsertSessionIfAbsent(input) {
1646
+ if (((await this.pool.query("SELECT id FROM sessions WHERE project_id = $1 AND number = $2", [input.projectId, input.number])).rowCount ?? 0) > 0) return false;
1647
+ const status = input.status ?? "completed";
1648
+ await this.pool.query(`INSERT INTO sessions (project_id, number, date, slug, title, filename, status, created_at)
1649
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
1650
+ input.projectId,
1651
+ input.number,
1652
+ input.date,
1653
+ input.slug,
1654
+ input.title,
1655
+ input.filename,
1656
+ status,
1657
+ input.createdAt
1658
+ ]);
1659
+ return true;
1660
+ }
1661
+ async updateSessionMeta(id, patch) {
1662
+ const sets = [];
1663
+ const params = [];
1664
+ if (patch.slug !== void 0) {
1665
+ params.push(patch.slug);
1666
+ sets.push(`slug = $${params.length}`);
1667
+ }
1668
+ if (patch.title !== void 0) {
1669
+ params.push(patch.title);
1670
+ sets.push(`title = $${params.length}`);
1671
+ }
1672
+ if (patch.filename !== void 0) {
1673
+ params.push(patch.filename);
1674
+ sets.push(`filename = $${params.length}`);
1675
+ }
1676
+ if (!sets.length) return;
1677
+ params.push(id);
1678
+ await this.pool.query(`UPDATE sessions SET ${sets.join(", ")} WHERE id = $${params.length}`, params);
1679
+ }
1680
+ async updateSessionNumber(id, number, opts = {}) {
1681
+ if (opts.filename !== void 0) await this.pool.query("UPDATE sessions SET number = $1, filename = $2 WHERE id = $3", [
1682
+ number,
1683
+ opts.filename,
1684
+ id
1685
+ ]);
1686
+ else await this.pool.query("UPDATE sessions SET number = $1 WHERE id = $2", [number, id]);
1687
+ }
1688
+ async updateSessionFilename(id, filename) {
1689
+ await this.pool.query("UPDATE sessions SET filename = $1 WHERE id = $2", [filename, id]);
1690
+ }
1691
+ async updateSessionStatus(id, status, opts = {}) {
1692
+ if (opts.closedAt !== void 0) await this.pool.query("UPDATE sessions SET status = $1, closed_at = $2 WHERE id = $3", [
1693
+ status,
1694
+ opts.closedAt,
1695
+ id
1696
+ ]);
1697
+ else await this.pool.query("UPDATE sessions SET status = $1 WHERE id = $2", [status, id]);
1698
+ }
1699
+ async moveSessionToProject(id, projectId, opts = {}) {
1700
+ if (opts.number !== void 0) await this.pool.query("UPDATE sessions SET project_id = $1, number = $2 WHERE id = $3", [
1701
+ projectId,
1702
+ opts.number,
1703
+ id
1704
+ ]);
1705
+ else await this.pool.query("UPDATE sessions SET project_id = $1 WHERE id = $2", [projectId, id]);
1706
+ }
1707
+ async deleteSession(id) {
1708
+ await this.pool.query("DELETE FROM sessions WHERE id = $1", [id]);
1709
+ }
1710
+ async backfillFoldedSession(keepId, dropId) {
1711
+ await this.pool.query(`UPDATE sessions SET
1712
+ claude_session_id = COALESCE(claude_session_id,
1713
+ (SELECT claude_session_id FROM sessions WHERE id = $1)),
1714
+ token_count = COALESCE(token_count,
1715
+ (SELECT token_count FROM sessions WHERE id = $1)),
1716
+ closed_at = COALESCE(closed_at,
1717
+ (SELECT closed_at FROM sessions WHERE id = $1)),
1718
+ status = CASE WHEN status = 'open'
1719
+ AND (SELECT status FROM sessions WHERE id = $1) != 'open'
1720
+ THEN (SELECT status FROM sessions WHERE id = $1)
1721
+ ELSE status END
1722
+ WHERE id = $2`, [dropId, keepId]);
1723
+ }
1724
+ async countSessionsForProject(projectId) {
1725
+ const r = await this.pool.query("SELECT COUNT(*)::text AS n FROM sessions WHERE project_id = $1", [projectId]);
1726
+ return parseInt(r.rows[0].n, 10);
1727
+ }
1728
+ async countSessions() {
1729
+ const r = await this.pool.query("SELECT COUNT(*)::text AS n FROM sessions");
1730
+ return parseInt(r.rows[0].n, 10);
1731
+ }
1732
+ async getMostRecentSessionDate(projectId) {
1733
+ return (await this.pool.query("SELECT date FROM sessions WHERE project_id = $1 ORDER BY date DESC LIMIT 1", [projectId])).rows[0]?.date ?? null;
1734
+ }
1735
+ async getMostRecentSessionCreatedAt() {
1736
+ const r = await this.pool.query("SELECT created_at FROM sessions ORDER BY created_at DESC LIMIT 1");
1737
+ return r.rows[0] ? Number(r.rows[0].created_at) : null;
1738
+ }
1739
+ async listSessionTagIds(sessionId) {
1740
+ return (await this.pool.query("SELECT tag_id FROM session_tags WHERE session_id = $1", [sessionId])).rows.map((row) => row.tag_id);
1741
+ }
1742
+ async sessionHasTag(sessionId, tagId) {
1743
+ return ((await this.pool.query("SELECT 1 FROM session_tags WHERE session_id = $1 AND tag_id = $2", [sessionId, tagId])).rowCount ?? 0) > 0;
1744
+ }
1745
+ async addSessionTag(sessionId, tagId) {
1746
+ await this.pool.query("INSERT INTO session_tags (session_id, tag_id) VALUES ($1, $2)", [sessionId, tagId]);
1747
+ }
1748
+ async deleteSessionTag(sessionId, tagId) {
1749
+ await this.pool.query("DELETE FROM session_tags WHERE session_id = $1 AND tag_id = $2", [sessionId, tagId]);
1750
+ }
1751
+ async reassignSessionTag(fromSessionId, toSessionId, tagId) {
1752
+ await this.pool.query("UPDATE session_tags SET session_id = $1 WHERE session_id = $2 AND tag_id = $3", [
1753
+ toSessionId,
1754
+ fromSessionId,
1755
+ tagId
1756
+ ]);
1757
+ }
1758
+ async listTagsForSession(sessionId) {
1759
+ return (await this.pool.query("SELECT t.name FROM tags t JOIN session_tags st ON st.tag_id = t.id WHERE st.session_id = $1 ORDER BY t.name", [sessionId])).rows.map((row) => row.name);
1760
+ }
1761
+ async addLink(input) {
1762
+ await this.pool.query("INSERT INTO links (session_id, target_project_id, link_type, created_at) VALUES ($1, $2, $3, $4)", [
1763
+ input.sessionId,
1764
+ input.targetProjectId,
1765
+ input.linkType,
1766
+ input.createdAt
1767
+ ]);
1768
+ }
1769
+ async listLinksForSession(sessionId) {
1770
+ return (await this.pool.query("SELECT id, target_project_id FROM links WHERE session_id = $1", [sessionId])).rows;
1771
+ }
1772
+ async listLinksForProject(projectId) {
1773
+ return (await this.pool.query("SELECT id, session_id FROM links WHERE target_project_id = $1", [projectId])).rows;
1774
+ }
1775
+ async linkExists(sessionId, targetProjectId) {
1776
+ return ((await this.pool.query("SELECT 1 FROM links WHERE session_id = $1 AND target_project_id = $2 LIMIT 1", [sessionId, targetProjectId])).rowCount ?? 0) > 0;
1777
+ }
1778
+ async deleteLink(id) {
1779
+ await this.pool.query("DELETE FROM links WHERE id = $1", [id]);
1780
+ }
1781
+ async deleteLinksTargetingProject(projectId) {
1782
+ await this.pool.query("DELETE FROM links WHERE target_project_id = $1", [projectId]);
1783
+ }
1784
+ async deleteLinksFromProjectSessions(projectId) {
1785
+ await this.pool.query("DELETE FROM links WHERE session_id IN (SELECT id FROM sessions WHERE project_id = $1)", [projectId]);
1786
+ }
1787
+ async retargetLink(id, targetProjectId) {
1788
+ await this.pool.query("UPDATE links SET target_project_id = $1 WHERE id = $2", [targetProjectId, id]);
1789
+ }
1790
+ async moveLinkToSession(id, sessionId) {
1791
+ await this.pool.query("UPDATE links SET session_id = $1 WHERE id = $2", [sessionId, id]);
1792
+ }
1793
+ async reassignLinksTarget(fromProjectId, toProjectId) {
1794
+ await this.pool.query(`UPDATE links SET target_project_id = $1 WHERE target_project_id = $2
1795
+ AND NOT EXISTS (
1796
+ SELECT 1 FROM links l2 WHERE l2.session_id = links.session_id AND l2.target_project_id = $1
1797
+ )`, [toProjectId, fromProjectId]);
1798
+ }
1799
+ async countLinksForProject(projectId) {
1800
+ const r = await this.pool.query("SELECT COUNT(*)::text AS n FROM links WHERE target_project_id = $1", [projectId]);
1801
+ return parseInt(r.rows[0].n, 10);
1802
+ }
1803
+ async deleteSelfLinksForProject(projectId) {
1804
+ await this.pool.query(`DELETE FROM links WHERE target_project_id = $1
1805
+ AND session_id IN (SELECT id FROM sessions WHERE project_id = $2)`, [projectId, projectId]);
1806
+ }
1807
+ async appendCompactionLog(entry) {
1808
+ await this.pool.query(`INSERT INTO compaction_log (project_id, session_id, trigger, files_written, token_count, created_at)
1809
+ VALUES ($1, $2, $3, $4, $5, $6)`, [
1810
+ entry.projectId,
1811
+ entry.sessionId,
1812
+ entry.trigger,
1813
+ entry.filesWritten,
1814
+ entry.tokenCount,
1815
+ entry.createdAt
1816
+ ]);
1817
+ }
1818
+ async countCompactionLogsForProject(projectId) {
1819
+ const r = await this.pool.query("SELECT COUNT(*)::text AS n FROM compaction_log WHERE project_id = $1", [projectId]);
1820
+ return parseInt(r.rows[0].n, 10);
1821
+ }
1822
+ async moveCompactionLogsByProject(fromProjectId, toProjectId) {
1823
+ return (await this.pool.query("UPDATE compaction_log SET project_id = $1 WHERE project_id = $2", [toProjectId, fromProjectId])).rowCount ?? 0;
1824
+ }
1825
+ async moveCompactionLogsBySession(fromSessionId, toSessionId) {
1826
+ await this.pool.query("UPDATE compaction_log SET session_id = $1 WHERE session_id = $2", [toSessionId, fromSessionId]);
1827
+ }
1828
+ async deleteCompactionLogsForProject(projectId) {
1829
+ await this.pool.query("DELETE FROM compaction_log WHERE project_id = $1", [projectId]);
1830
+ }
1831
+ async planProjectMerge(fromSlug, intoSlug) {
1832
+ if (fromSlug === intoSlug) throw new MergeError(`Cannot merge ${fromSlug} into itself.`);
1833
+ const fromR = await this.pool.query("SELECT id, slug FROM projects WHERE slug = $1", [fromSlug]);
1834
+ const intoR = await this.pool.query("SELECT id, slug FROM projects WHERE slug = $1", [intoSlug]);
1835
+ const from = fromR.rows[0];
1836
+ const into = intoR.rows[0];
1837
+ if (!from) throw new MergeError(`No project with slug "${fromSlug}".`);
1838
+ if (!into) throw new MergeError(`No project with slug "${intoSlug}".`);
1839
+ let next = (await this.pool.query("SELECT COALESCE(MAX(number), 0) AS n FROM sessions WHERE project_id = $1", [into.id])).rows[0].n;
1840
+ const sessions = (await this.pool.query("SELECT id, number FROM sessions WHERE project_id = $1 ORDER BY number ASC", [from.id])).rows.map((s) => ({
1841
+ id: s.id,
1842
+ from: s.number,
1843
+ to: ++next
1844
+ }));
1845
+ const count = async (sql, ...args) => {
1846
+ const r = await this.pool.query(sql, args);
1847
+ return parseInt(r.rows[0].n, 10);
1848
+ };
1849
+ const aliasTaken = await count("SELECT COUNT(*)::text AS n FROM aliases WHERE alias = $1", from.slug) > 0 || await count("SELECT COUNT(*)::text AS n FROM projects WHERE slug = $1", from.slug) > 1;
1850
+ return {
1851
+ fromId: from.id,
1852
+ fromSlug: from.slug,
1853
+ intoId: into.id,
1854
+ intoSlug: into.slug,
1855
+ sessions,
1856
+ tags: await count("SELECT COUNT(*)::text AS n FROM project_tags WHERE project_id = $1", from.id),
1857
+ aliases: await count("SELECT COUNT(*)::text AS n FROM aliases WHERE project_id = $1", from.id),
1858
+ compactions: await count("SELECT COUNT(*)::text AS n FROM compaction_log WHERE project_id = $1", from.id),
1859
+ links: await count("SELECT COUNT(*)::text AS n FROM links WHERE target_project_id = $1", from.id),
1860
+ aliasToAdd: aliasTaken ? void 0 : from.slug
1861
+ };
1862
+ }
1863
+ async applyProjectMerge(plan) {
1864
+ const client = await this.pool.connect();
1865
+ try {
1866
+ await client.query("BEGIN");
1867
+ for (const s of plan.sessions) await client.query("UPDATE sessions SET project_id = $1, number = $2 WHERE id = $3", [
1868
+ plan.intoId,
1869
+ s.to,
1870
+ s.id
1871
+ ]);
1872
+ await client.query(`INSERT INTO project_tags (project_id, tag_id)
1873
+ SELECT $1, tag_id FROM project_tags WHERE project_id = $2
1874
+ ON CONFLICT (project_id, tag_id) DO NOTHING`, [plan.intoId, plan.fromId]);
1875
+ await client.query("DELETE FROM project_tags WHERE project_id = $1", [plan.fromId]);
1876
+ await client.query("UPDATE aliases SET project_id = $1 WHERE project_id = $2", [plan.intoId, plan.fromId]);
1877
+ await client.query("UPDATE compaction_log SET project_id = $1 WHERE project_id = $2", [plan.intoId, plan.fromId]);
1878
+ await client.query(`UPDATE links SET target_project_id = $1 WHERE target_project_id = $2
1879
+ AND NOT EXISTS (
1880
+ SELECT 1 FROM links l2 WHERE l2.session_id = links.session_id AND l2.target_project_id = $1
1881
+ )`, [plan.intoId, plan.fromId]);
1882
+ await client.query("DELETE FROM links WHERE target_project_id = $1", [plan.fromId]);
1883
+ await client.query(`DELETE FROM links WHERE target_project_id = $1
1884
+ AND session_id IN (SELECT id FROM sessions WHERE project_id = $2)`, [plan.intoId, plan.intoId]);
1885
+ if (plan.aliasToAdd) await client.query("INSERT INTO aliases (alias, project_id) VALUES ($1, $2) ON CONFLICT (alias) DO NOTHING", [plan.aliasToAdd, plan.intoId]);
1886
+ await client.query("DELETE FROM projects WHERE id = $1", [plan.fromId]);
1887
+ await client.query("COMMIT");
1888
+ } catch (e) {
1889
+ await client.query("ROLLBACK");
1890
+ throw e;
1891
+ } finally {
1892
+ client.release();
1893
+ }
1894
+ }
1895
+ };
1896
+
1897
+ //#endregion
1898
+ //#region src/storage/sqlite/registry-schema.ts
1899
+ const SCHEMA_VERSION = 4;
1900
+ const CREATE_TABLES_SQL = `
1901
+ PRAGMA journal_mode = WAL;
1902
+ PRAGMA foreign_keys = ON;
1903
+
1904
+ CREATE TABLE IF NOT EXISTS projects (
1905
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1906
+ slug TEXT NOT NULL UNIQUE,
1907
+ display_name TEXT NOT NULL,
1908
+ root_path TEXT NOT NULL UNIQUE,
1909
+ encoded_dir TEXT NOT NULL UNIQUE,
1910
+ type TEXT NOT NULL DEFAULT 'local'
1911
+ CHECK(type IN ('local','central','obsidian-linked','external')),
1912
+ status TEXT NOT NULL DEFAULT 'active'
1913
+ CHECK(status IN ('active','archived','migrating')),
1914
+ parent_id INTEGER,
1915
+ obsidian_link TEXT,
1916
+ claude_notes_dir TEXT,
1917
+ session_config TEXT,
1918
+ created_at INTEGER NOT NULL,
1919
+ updated_at INTEGER NOT NULL,
1920
+ archived_at INTEGER,
1921
+ FOREIGN KEY (parent_id) REFERENCES projects(id)
1922
+ );
1923
+
1924
+ CREATE TABLE IF NOT EXISTS sessions (
1925
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1926
+ project_id INTEGER NOT NULL,
1927
+ number INTEGER NOT NULL,
1928
+ date TEXT NOT NULL,
1929
+ slug TEXT NOT NULL,
1930
+ title TEXT NOT NULL,
1931
+ filename TEXT NOT NULL,
1932
+ status TEXT NOT NULL DEFAULT 'open'
1933
+ CHECK(status IN ('open','completed','compacted')),
1934
+ claude_session_id TEXT,
1935
+ token_count INTEGER,
1936
+ created_at INTEGER NOT NULL,
1937
+ closed_at INTEGER,
1938
+ UNIQUE (project_id, number),
1939
+ FOREIGN KEY (project_id) REFERENCES projects(id)
1940
+ );
1941
+
1942
+ CREATE TABLE IF NOT EXISTS tags (
1943
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1944
+ name TEXT NOT NULL UNIQUE
1945
+ );
1946
+
1947
+ CREATE TABLE IF NOT EXISTS project_tags (
1948
+ project_id INTEGER NOT NULL,
1949
+ tag_id INTEGER NOT NULL,
1950
+ PRIMARY KEY (project_id, tag_id),
1951
+ FOREIGN KEY (project_id) REFERENCES projects(id),
1952
+ FOREIGN KEY (tag_id) REFERENCES tags(id)
1953
+ );
1954
+
1955
+ CREATE TABLE IF NOT EXISTS session_tags (
1956
+ session_id INTEGER NOT NULL,
1957
+ tag_id INTEGER NOT NULL,
1958
+ PRIMARY KEY (session_id, tag_id),
1959
+ FOREIGN KEY (session_id) REFERENCES sessions(id),
1960
+ FOREIGN KEY (tag_id) REFERENCES tags(id)
1961
+ );
1962
+
1963
+ CREATE TABLE IF NOT EXISTS aliases (
1964
+ alias TEXT PRIMARY KEY,
1965
+ project_id INTEGER NOT NULL,
1966
+ FOREIGN KEY (project_id) REFERENCES projects(id)
1967
+ );
1968
+
1969
+ CREATE TABLE IF NOT EXISTS compaction_log (
1970
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1971
+ project_id INTEGER NOT NULL,
1972
+ session_id INTEGER,
1973
+ trigger TEXT NOT NULL
1974
+ CHECK(trigger IN ('precompact','manual','end-session')),
1975
+ files_written TEXT NOT NULL,
1976
+ token_count INTEGER,
1977
+ created_at INTEGER NOT NULL,
1978
+ FOREIGN KEY (project_id) REFERENCES projects(id),
1979
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
1980
+ );
1981
+
1982
+ CREATE TABLE IF NOT EXISTS links (
1983
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1984
+ session_id INTEGER NOT NULL,
1985
+ target_project_id INTEGER NOT NULL,
1986
+ link_type TEXT NOT NULL DEFAULT 'related'
1987
+ CHECK(link_type IN ('related','follow-up','reference')),
1988
+ created_at INTEGER NOT NULL,
1989
+ UNIQUE (session_id, target_project_id),
1990
+ FOREIGN KEY (session_id) REFERENCES sessions(id),
1991
+ FOREIGN KEY (target_project_id) REFERENCES projects(id)
1992
+ );
1993
+
1994
+ CREATE TABLE IF NOT EXISTS schema_version (
1995
+ version INTEGER PRIMARY KEY,
1996
+ applied_at INTEGER NOT NULL
1997
+ );
1998
+
1999
+ -- Indexes
2000
+ CREATE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug);
2001
+ CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
2002
+ CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(type);
2003
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
2004
+ CREATE INDEX IF NOT EXISTS idx_sessions_date ON sessions(date);
2005
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
2006
+ CREATE INDEX IF NOT EXISTS idx_sessions_claude ON sessions(claude_session_id);
2007
+ CREATE INDEX IF NOT EXISTS idx_pc_project ON project_tags(project_id);
2008
+ `;
2009
+ /**
2010
+ * Run the full DDL against an open database connection.
2011
+ *
2012
+ * The function is idempotent — every statement uses IF NOT EXISTS so it is
2013
+ * safe to call on an already-initialised database. After creating the tables
2014
+ * it inserts the current SCHEMA_VERSION into schema_version if no row exists
2015
+ * yet.
2016
+ */
2017
+ function initializeSchema(db) {
2018
+ db.exec(CREATE_TABLES_SQL);
2019
+ if (!db.prepare("SELECT version FROM schema_version WHERE version = ?").get(SCHEMA_VERSION)) db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run(SCHEMA_VERSION, Date.now());
2020
+ }
2021
+ /**
2022
+ * Apply incremental schema migrations to an already-initialised database.
2023
+ *
2024
+ * Each migration is guarded by a version check so it is safe to call on
2025
+ * databases at any schema version — already-applied migrations are skipped.
2026
+ */
2027
+ function runMigrations(db) {
2028
+ const current = db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get()?.version ?? 0;
2029
+ if (current < 2) db.transaction(() => {
2030
+ try {
2031
+ db.exec("ALTER TABLE projects ADD COLUMN claude_notes_dir TEXT");
2032
+ } catch {}
2033
+ db.prepare("INSERT OR REPLACE INTO schema_version (version, applied_at) VALUES (?, ?)").run(2, Date.now());
2034
+ })();
2035
+ if (current < 3) db.transaction(() => {
2036
+ try {
2037
+ db.exec(`
2038
+ CREATE TABLE IF NOT EXISTS links (
2039
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2040
+ session_id INTEGER NOT NULL,
2041
+ target_project_id INTEGER NOT NULL,
2042
+ link_type TEXT NOT NULL DEFAULT 'related'
2043
+ CHECK(link_type IN ('related','follow-up','reference')),
2044
+ created_at INTEGER NOT NULL,
2045
+ UNIQUE (session_id, target_project_id),
2046
+ FOREIGN KEY (session_id) REFERENCES sessions(id),
2047
+ FOREIGN KEY (target_project_id) REFERENCES projects(id)
2048
+ )
2049
+ `);
2050
+ } catch {}
2051
+ db.prepare("INSERT OR REPLACE INTO schema_version (version, applied_at) VALUES (?, ?)").run(3, Date.now());
2052
+ })();
2053
+ if (current < 4) db.transaction(() => {
2054
+ try {
2055
+ db.exec("ALTER TABLE projects ADD COLUMN session_config TEXT");
2056
+ } catch {}
2057
+ db.prepare("INSERT OR REPLACE INTO schema_version (version, applied_at) VALUES (?, ?)").run(4, Date.now());
2058
+ })();
2059
+ }
2060
+
2061
+ //#endregion
2062
+ //#region src/storage/sqlite/registry-db.ts
2063
+ /**
2064
+ * Database connection helper for the PAI registry.
2065
+ *
2066
+ * Uses better-sqlite3 (synchronous API) to open or create registry.db.
2067
+ * On first open it runs the full DDL via initializeSchema().
2068
+ */
2069
+ /** Old registry path, inside the ~/.pai/ directory (pre-2026-09-19). */
2070
+ function oldRegistryPath() {
2071
+ return join(homedir(), ".pai", "registry.db");
2072
+ }
2073
+ /** Registry path: PAI_HOME/registry.db if present, else the old
2074
+ * ~/.pai/registry.db (one-time stderr notice), else the new path. */
2075
+ function registryDbPath() {
2076
+ return resolvePaiFile(paiHomePath("registry.db"), [oldRegistryPath()], "pai config migrate --registry");
2077
+ }
2078
+ /**
2079
+ * Open (or create) the PAI registry database.
2080
+ *
2081
+ * @param path Absolute path to registry.db. Defaults to PAI_HOME/registry.db
2082
+ * (falling back to the pre-2026-09-19 ~/.pai/registry.db).
2083
+ * @returns An open better-sqlite3 Database instance.
2084
+ *
2085
+ * Side effects on first call:
2086
+ * - Creates the parent directory if it does not exist.
2087
+ * - Enables WAL journal mode.
2088
+ * - Runs initializeSchema() if schema_version is empty.
2089
+ */
2090
+ function openRegistry(path = registryDbPath()) {
2091
+ mkdirSync(dirname(path), { recursive: true });
2092
+ const db = new BetterSqlite3(path);
2093
+ db.pragma("journal_mode = WAL");
2094
+ db.pragma("foreign_keys = ON");
2095
+ if (!db.prepare(`SELECT name FROM sqlite_master
2096
+ WHERE type = 'table' AND name = 'schema_version'`).get()) initializeSchema(db);
2097
+ else if (!db.prepare("SELECT version FROM schema_version LIMIT 1").get()) initializeSchema(db);
2098
+ runMigrations(db);
2099
+ return db;
2100
+ }
2101
+
2102
+ //#endregion
2103
+ //#region src/storage/sqlite/registry-merge.ts
2104
+ /**
2105
+ * What a merge would do, without doing it.
2106
+ *
2107
+ * Built as a plan first because the session renumbering is the part a reader will
2108
+ * not predict, and printing "0001 -> 0016" is the difference between a command
2109
+ * that can be trusted and one that has to be taken on faith.
2110
+ */
2111
+ function planMerge(db, fromSlug, intoSlug) {
2112
+ if (fromSlug === intoSlug) throw new MergeError(`Cannot merge ${fromSlug} into itself.`);
2113
+ const from = db.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(fromSlug);
2114
+ const into = db.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(intoSlug);
2115
+ if (!from) throw new MergeError(`No project with slug "${fromSlug}".`);
2116
+ if (!into) throw new MergeError(`No project with slug "${intoSlug}".`);
2117
+ let next = db.prepare("SELECT COALESCE(MAX(number), 0) AS n FROM sessions WHERE project_id = ?").get(into.id).n;
2118
+ const sessions = db.prepare("SELECT id, number FROM sessions WHERE project_id = ? ORDER BY number ASC").all(from.id).map((s) => ({
2119
+ id: s.id,
2120
+ from: s.number,
2121
+ to: ++next
2122
+ }));
2123
+ const count = (sql, ...args) => db.prepare(sql).get(...args).n;
2124
+ const aliasTaken = count("SELECT COUNT(*) AS n FROM aliases WHERE alias = ?", from.slug) > 0 || count("SELECT COUNT(*) AS n FROM projects WHERE slug = ?", from.slug) > 1;
2125
+ return {
2126
+ fromId: from.id,
2127
+ fromSlug: from.slug,
2128
+ intoId: into.id,
2129
+ intoSlug: into.slug,
2130
+ sessions,
2131
+ tags: count("SELECT COUNT(*) AS n FROM project_tags WHERE project_id = ?", from.id),
2132
+ aliases: count("SELECT COUNT(*) AS n FROM aliases WHERE project_id = ?", from.id),
2133
+ compactions: count("SELECT COUNT(*) AS n FROM compaction_log WHERE project_id = ?", from.id),
2134
+ links: count("SELECT COUNT(*) AS n FROM links WHERE target_project_id = ?", from.id),
2135
+ aliasToAdd: aliasTaken ? void 0 : from.slug
2136
+ };
2137
+ }
2138
+ /**
2139
+ * Apply a plan. One transaction: either the whole row is folded in or nothing is.
2140
+ *
2141
+ * A half-merged project is the worst outcome available here — sessions moved but
2142
+ * the row still present, or the row gone and its tags orphaned — and with foreign
2143
+ * keys off nothing would complain.
2144
+ */
2145
+ function applyMerge(db, plan) {
2146
+ db.transaction(() => {
2147
+ const move = db.prepare("UPDATE sessions SET project_id = ?, number = ? WHERE id = ?");
2148
+ for (const s of plan.sessions) move.run(plan.intoId, s.to, s.id);
2149
+ db.prepare(`INSERT OR IGNORE INTO project_tags (project_id, tag_id)
2150
+ SELECT ?, tag_id FROM project_tags WHERE project_id = ?`).run(plan.intoId, plan.fromId);
2151
+ db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(plan.fromId);
2152
+ db.prepare("UPDATE aliases SET project_id = ? WHERE project_id = ?").run(plan.intoId, plan.fromId);
2153
+ db.prepare("UPDATE compaction_log SET project_id = ? WHERE project_id = ?").run(plan.intoId, plan.fromId);
2154
+ db.prepare(`UPDATE OR IGNORE links SET target_project_id = ? WHERE target_project_id = ?`).run(plan.intoId, plan.fromId);
2155
+ db.prepare("DELETE FROM links WHERE target_project_id = ?").run(plan.fromId);
2156
+ db.prepare(`DELETE FROM links WHERE target_project_id = ?
2157
+ AND session_id IN (SELECT id FROM sessions WHERE project_id = ?)`).run(plan.intoId, plan.intoId);
2158
+ if (plan.aliasToAdd) db.prepare("INSERT OR IGNORE INTO aliases (alias, project_id) VALUES (?, ?)").run(plan.aliasToAdd, plan.intoId);
2159
+ db.prepare("DELETE FROM projects WHERE id = ?").run(plan.fromId);
2160
+ })();
2161
+ }
2162
+
2163
+ //#endregion
2164
+ //#region src/storage/registry-sqlite.ts
2165
+ var SQLiteRegistryBackend = class {
2166
+ backendType = "sqlite";
2167
+ db;
2168
+ constructor(db) {
2169
+ this.db = db;
2170
+ }
2171
+ /** Expose the raw better-sqlite3 Database handle for not-yet-converted callers. */
2172
+ getRawDb() {
2173
+ return this.db;
2174
+ }
2175
+ async close() {
2176
+ try {
2177
+ this.db.close();
2178
+ } catch {}
2179
+ }
2180
+ async resetRegistry() {
2181
+ this.db.transaction(() => {
2182
+ this.db.prepare("DELETE FROM links").run();
2183
+ this.db.prepare("DELETE FROM compaction_log").run();
2184
+ this.db.prepare("DELETE FROM session_tags").run();
2185
+ this.db.prepare("DELETE FROM project_tags").run();
2186
+ this.db.prepare("DELETE FROM aliases").run();
2187
+ this.db.prepare("DELETE FROM sessions").run();
2188
+ this.db.prepare("DELETE FROM tags").run();
2189
+ this.db.prepare("DELETE FROM projects").run();
2190
+ })();
2191
+ }
2192
+ async getProjectById(id) {
2193
+ return this.db.prepare("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
2194
+ }
2195
+ async getProjectBySlug(slug, opts = {}) {
2196
+ let sql = opts.caseInsensitive ? "SELECT * FROM projects WHERE lower(slug) = lower(?)" : "SELECT * FROM projects WHERE slug = ?";
2197
+ const params = [slug];
2198
+ if (opts.excludeId !== void 0) {
2199
+ sql += " AND id != ?";
2200
+ params.push(opts.excludeId);
2201
+ }
2202
+ return this.db.prepare(sql).get(...params) ?? null;
2203
+ }
2204
+ async getProjectByAlias(alias, opts = {}) {
2205
+ const sql = opts.caseInsensitive ? "SELECT p.* FROM projects p JOIN aliases a ON a.project_id = p.id WHERE lower(a.alias) = lower(?)" : "SELECT p.* FROM projects p JOIN aliases a ON a.project_id = p.id WHERE a.alias = ?";
2206
+ return this.db.prepare(sql).get(alias) ?? null;
2207
+ }
2208
+ async getProjectByRootPath(rootPath, opts = {}) {
2209
+ let sql = "SELECT * FROM projects WHERE root_path = ?";
2210
+ const params = [rootPath];
2211
+ if (opts.excludeId !== void 0) {
2212
+ sql += " AND id != ?";
2213
+ params.push(opts.excludeId);
2214
+ }
2215
+ return this.db.prepare(sql).get(...params) ?? null;
2216
+ }
2217
+ async getProjectByEncodedDir(encodedDir, opts = {}) {
2218
+ let sql = "SELECT * FROM projects WHERE encoded_dir = ?";
2219
+ const params = [encodedDir];
2220
+ if (opts.excludeId !== void 0) {
2221
+ sql += " AND id != ?";
2222
+ params.push(opts.excludeId);
2223
+ }
2224
+ return this.db.prepare(sql).get(...params) ?? null;
2225
+ }
2226
+ async findProjectByCwdPrefix(cwd) {
2227
+ return this.db.prepare(`SELECT * FROM projects WHERE status = 'active' AND ? LIKE root_path || '%'
2228
+ ORDER BY length(root_path) DESC LIMIT 1`).get(cwd) ?? null;
2229
+ }
2230
+ async listProjects(opts = {}) {
2231
+ const where = [];
2232
+ const params = [];
2233
+ if (opts.status) {
2234
+ where.push("p.status = ?");
2235
+ params.push(opts.status);
2236
+ }
2237
+ if (opts.excludeArchived) where.push("p.status != 'archived'");
2238
+ if (opts.tagName) {
2239
+ where.push("p.id IN (SELECT pt.project_id FROM project_tags pt JOIN tags t ON pt.tag_id = t.id WHERE t.name = ?)");
2240
+ params.push(opts.tagName);
2241
+ }
2242
+ let sql = "SELECT p.* FROM projects p";
2243
+ if (where.length) sql += " WHERE " + where.join(" AND ");
2244
+ switch (opts.orderBy) {
2245
+ case "updated_desc":
2246
+ sql += " ORDER BY p.updated_at DESC";
2247
+ break;
2248
+ case "slug":
2249
+ sql += " ORDER BY p.slug ASC";
2250
+ break;
2251
+ case "id":
2252
+ sql += " ORDER BY p.id";
2253
+ break;
2254
+ default:
2255
+ sql += " ORDER BY p.status ASC, p.updated_at DESC";
2256
+ break;
2257
+ }
2258
+ if (opts.limit !== void 0) {
2259
+ sql += " LIMIT ?";
2260
+ params.push(opts.limit);
2261
+ }
2262
+ return this.db.prepare(sql).all(...params);
2263
+ }
2264
+ async listProjectsByPathLengthDesc(opts = {}) {
2265
+ const where = opts.excludeArchived ? "WHERE status != 'archived'" : "";
2266
+ return this.db.prepare(`SELECT * FROM projects ${where} ORDER BY LENGTH(root_path) DESC`).all();
2267
+ }
2268
+ async listProjectsWithSessionStats(opts = {}) {
2269
+ const where = [];
2270
+ const params = [];
2271
+ if (opts.status) {
2272
+ where.push("p.status = ?");
2273
+ params.push(opts.status);
2274
+ }
2275
+ if (opts.tagId !== void 0) {
2276
+ where.push("p.id IN (SELECT project_id FROM project_tags WHERE tag_id = ?)");
2277
+ params.push(opts.tagId);
2278
+ }
2279
+ let sql = `
2280
+ SELECT p.*,
2281
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count,
2282
+ (SELECT MAX(s.created_at) FROM sessions s WHERE s.project_id = p.id) AS last_active
2283
+ FROM projects p
2284
+ `;
2285
+ if (where.length) sql += " WHERE " + where.join(" AND ");
2286
+ sql += opts.orderBy === "updated_desc" ? " ORDER BY p.updated_at DESC" : " ORDER BY p.status ASC, p.updated_at DESC";
2287
+ return this.db.prepare(sql).all(...params);
2288
+ }
2289
+ async listNamedProjects(opts = {}) {
2290
+ const sql = `
2291
+ SELECT p.*, a.alias AS name,
2292
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count,
2293
+ (SELECT MAX(s.created_at) FROM sessions s WHERE s.project_id = p.id) AS last_active
2294
+ FROM projects p
2295
+ ${opts.includeUnnamed ? "LEFT JOIN" : "JOIN"} aliases a ON a.project_id = p.id
2296
+ WHERE p.status = 'active'
2297
+ ORDER BY p.updated_at DESC
2298
+ `;
2299
+ return this.db.prepare(sql).all();
2300
+ }
2301
+ async searchProjects(query, limit = 20) {
2302
+ const q = `%${query}%`;
2303
+ return this.db.prepare(`SELECT * FROM projects WHERE slug LIKE ? OR display_name LIKE ? OR root_path LIKE ?
2304
+ ORDER BY updated_at DESC LIMIT ?`).all(q, q, q, limit);
2305
+ }
2306
+ async countProjects(opts = {}) {
2307
+ const sql = opts.status ? "SELECT COUNT(*) AS n FROM projects WHERE status = ?" : "SELECT COUNT(*) AS n FROM projects";
2308
+ return (opts.status ? this.db.prepare(sql).get(opts.status) : this.db.prepare(sql).get()).n;
2309
+ }
2310
+ async getMostRecentProjectUpdatedAt() {
2311
+ return this.db.prepare("SELECT updated_at FROM projects ORDER BY updated_at DESC LIMIT 1").get()?.updated_at ?? null;
2312
+ }
2313
+ async countChildProjects(parentId) {
2314
+ return this.db.prepare("SELECT COUNT(*) AS n FROM projects WHERE parent_id = ?").get(parentId).n;
2315
+ }
2316
+ async findSiblingProjectsBySlugPattern(excludeProjectId, slug, limit = 3) {
2317
+ return this.db.prepare(`SELECT p.slug, p.root_path, COUNT(s.id) AS session_count
2318
+ FROM projects p LEFT JOIN sessions s ON s.project_id = p.id
2319
+ WHERE p.id != ? AND (p.slug = ? OR p.slug LIKE ? || '-%' OR ? LIKE p.slug || '-%')
2320
+ GROUP BY p.id HAVING session_count > 0 ORDER BY session_count DESC LIMIT ?`).all(excludeProjectId, slug, slug, slug, limit);
2321
+ }
2322
+ async createProject(input) {
2323
+ const type = input.type ?? "local";
2324
+ const status = input.status ?? "active";
2325
+ const result = this.db.prepare(`INSERT INTO projects (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
2326
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(input.slug, input.displayName, input.rootPath, input.encodedDir, type, status, input.createdAt, input.updatedAt);
2327
+ return await this.getProjectById(result.lastInsertRowid);
2328
+ }
2329
+ async createProjectWithSlugRetry(input) {
2330
+ let slug = input.baseSlug;
2331
+ let attempt = 0;
2332
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO projects (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
2333
+ VALUES (?, ?, ?, ?, 'local', 'active', ?, ?)`);
2334
+ while (true) {
2335
+ const info = insert.run(slug, input.displayName, input.rootPath, input.encodedDir, input.createdAt, input.updatedAt);
2336
+ if (info.changes > 0) return {
2337
+ id: info.lastInsertRowid,
2338
+ slug,
2339
+ created: true
2340
+ };
2341
+ const existing = this.db.prepare("SELECT id FROM projects WHERE root_path = ?").get(input.rootPath);
2342
+ if (existing) return {
2343
+ id: existing.id,
2344
+ slug,
2345
+ created: false
2346
+ };
2347
+ attempt++;
2348
+ slug = `${input.baseSlug}-${attempt}`;
2349
+ }
2350
+ }
2351
+ async updateProjectPath(id, patch, updatedAt) {
2352
+ const sets = [];
2353
+ const params = [];
2354
+ if (patch.rootPath !== void 0) {
2355
+ sets.push("root_path = ?");
2356
+ params.push(patch.rootPath);
2357
+ }
2358
+ if (patch.encodedDir !== void 0) {
2359
+ sets.push("encoded_dir = ?");
2360
+ params.push(patch.encodedDir);
2361
+ }
2362
+ if (updatedAt !== void 0) {
2363
+ sets.push("updated_at = ?");
2364
+ params.push(updatedAt);
2365
+ }
2366
+ if (!sets.length) return;
2367
+ params.push(id);
2368
+ this.db.prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`).run(...params);
2369
+ }
2370
+ async updateProjectStatus(id, status, opts = {}) {
2371
+ const sets = ["status = ?"];
2372
+ const params = [status];
2373
+ if (opts.archivedAt !== void 0) {
2374
+ sets.push("archived_at = ?");
2375
+ params.push(opts.archivedAt);
2376
+ }
2377
+ if (opts.updatedAt !== void 0) {
2378
+ sets.push("updated_at = ?");
2379
+ params.push(opts.updatedAt);
2380
+ }
2381
+ let sql = `UPDATE projects SET ${sets.join(", ")} WHERE id = ?`;
2382
+ params.push(id);
2383
+ if (opts.requireCurrentStatus) {
2384
+ sql += " AND status = ?";
2385
+ params.push(opts.requireCurrentStatus);
2386
+ }
2387
+ this.db.prepare(sql).run(...params);
2388
+ }
2389
+ async updateProjectDisplayName(id, displayName, updatedAt) {
2390
+ if (updatedAt !== void 0) this.db.prepare("UPDATE projects SET display_name = ?, updated_at = ? WHERE id = ?").run(displayName, updatedAt, id);
2391
+ else this.db.prepare("UPDATE projects SET display_name = ? WHERE id = ?").run(displayName, id);
2392
+ }
2393
+ async updateProjectType(id, type, updatedAt) {
2394
+ if (updatedAt !== void 0) this.db.prepare("UPDATE projects SET type = ?, updated_at = ? WHERE id = ?").run(type, updatedAt, id);
2395
+ else this.db.prepare("UPDATE projects SET type = ? WHERE id = ?").run(type, id);
2396
+ }
2397
+ async updateProjectSessionConfig(id, config, updatedAt) {
2398
+ if (updatedAt !== void 0) this.db.prepare("UPDATE projects SET session_config = ?, updated_at = ? WHERE id = ?").run(config, updatedAt, id);
2399
+ else this.db.prepare("UPDATE projects SET session_config = ? WHERE id = ?").run(config, id);
2400
+ }
2401
+ async updateProjectClaudeNotesDir(id, dir, updatedAt) {
2402
+ if (updatedAt !== void 0) this.db.prepare("UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?").run(dir, updatedAt, id);
2403
+ else this.db.prepare("UPDATE projects SET claude_notes_dir = ? WHERE id = ?").run(dir, id);
2404
+ }
2405
+ async updateProjectObsidianLink(id, link, updatedAt) {
2406
+ if (updatedAt !== void 0) this.db.prepare("UPDATE projects SET obsidian_link = ?, updated_at = ? WHERE id = ?").run(link, updatedAt, id);
2407
+ else this.db.prepare("UPDATE projects SET obsidian_link = ? WHERE id = ?").run(link, id);
2408
+ }
2409
+ async updateProjectSlug(id, slug, updatedAt) {
2410
+ this.db.prepare("UPDATE projects SET slug = ?, updated_at = ? WHERE id = ?").run(slug, updatedAt, id);
2411
+ }
2412
+ async reassignProjectParent(fromParentId, toParentId) {
2413
+ return this.db.prepare("UPDATE projects SET parent_id = ? WHERE parent_id = ?").run(toParentId, fromParentId).changes;
2414
+ }
2415
+ async deleteProject(id) {
2416
+ this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
2417
+ }
2418
+ async deleteProjectCascade(id) {
2419
+ this.db.transaction(() => {
2420
+ this.db.prepare("DELETE FROM links WHERE target_project_id = ?").run(id);
2421
+ this.db.prepare("DELETE FROM links WHERE session_id IN (SELECT id FROM sessions WHERE project_id = ?)").run(id);
2422
+ this.db.prepare("DELETE FROM compaction_log WHERE project_id = ?").run(id);
2423
+ this.db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(id);
2424
+ this.db.prepare("DELETE FROM aliases WHERE project_id = ?").run(id);
2425
+ this.db.prepare("DELETE FROM sessions WHERE project_id = ?").run(id);
2426
+ this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
2427
+ })();
2428
+ }
2429
+ async listTagsForProject(projectId) {
2430
+ return this.db.prepare("SELECT t.name FROM tags t JOIN project_tags pt ON pt.tag_id = t.id WHERE pt.project_id = ? ORDER BY t.name").all(projectId).map((r) => r.name);
2431
+ }
2432
+ async listAllTags() {
2433
+ return this.db.prepare("SELECT id, name FROM tags ORDER BY name").all();
2434
+ }
2435
+ async upsertTag(name) {
2436
+ this.db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(name);
2437
+ return this.db.prepare("SELECT id FROM tags WHERE name = ?").get(name).id;
2438
+ }
2439
+ async addProjectTag(projectId, tagId) {
2440
+ this.db.prepare("INSERT INTO project_tags (project_id, tag_id) VALUES (?, ?)").run(projectId, tagId);
2441
+ }
2442
+ async projectHasTag(projectId, tagId) {
2443
+ return !!this.db.prepare("SELECT 1 FROM project_tags WHERE project_id = ? AND tag_id = ?").get(projectId, tagId);
2444
+ }
2445
+ async deleteProjectTag(projectId, tagId) {
2446
+ this.db.prepare("DELETE FROM project_tags WHERE project_id = ? AND tag_id = ?").run(projectId, tagId);
2447
+ }
2448
+ async reassignProjectTag(fromProjectId, toProjectId, tagId) {
2449
+ this.db.prepare("UPDATE project_tags SET project_id = ? WHERE project_id = ? AND tag_id = ?").run(toProjectId, fromProjectId, tagId);
2450
+ }
2451
+ async listProjectTagIds(projectId) {
2452
+ return this.db.prepare("SELECT tag_id FROM project_tags WHERE project_id = ?").all(projectId).map((r) => r.tag_id);
2453
+ }
2454
+ async countProjectTagsForProject(projectId) {
2455
+ return this.db.prepare("SELECT COUNT(*) AS n FROM project_tags WHERE project_id = ?").get(projectId).n;
2456
+ }
2457
+ async copyProjectTags(fromProjectId, toProjectId) {
2458
+ this.db.prepare("INSERT OR IGNORE INTO project_tags (project_id, tag_id) SELECT ?, tag_id FROM project_tags WHERE project_id = ?").run(toProjectId, fromProjectId);
2459
+ }
2460
+ async deleteProjectTagsForProject(projectId) {
2461
+ this.db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(projectId);
2462
+ }
2463
+ async resolveAlias(alias) {
2464
+ return this.db.prepare("SELECT project_id FROM aliases WHERE alias = ?").get(alias)?.project_id ?? null;
2465
+ }
2466
+ async listAliasesForProject(projectId) {
2467
+ return this.db.prepare("SELECT alias FROM aliases WHERE project_id = ? ORDER BY alias").all(projectId).map((r) => r.alias);
2468
+ }
2469
+ async addAlias(alias, projectId) {
2470
+ this.db.prepare("INSERT INTO aliases (alias, project_id) VALUES (?, ?)").run(alias, projectId);
2471
+ }
2472
+ async removeAlias(alias) {
2473
+ this.db.prepare("DELETE FROM aliases WHERE alias = ?").run(alias);
2474
+ }
2475
+ async countAliasesForProject(projectId) {
2476
+ return this.db.prepare("SELECT COUNT(*) AS n FROM aliases WHERE project_id = ?").get(projectId).n;
2477
+ }
2478
+ async moveProjectAliases(fromProjectId, toProjectId) {
2479
+ this.db.prepare("UPDATE aliases SET project_id = ? WHERE project_id = ?").run(toProjectId, fromProjectId);
2480
+ }
2481
+ async reassignAlias(alias, toProjectId) {
2482
+ this.db.prepare("UPDATE aliases SET project_id = ? WHERE alias = ?").run(toProjectId, alias);
2483
+ }
2484
+ async listAliasMap() {
2485
+ return this.db.prepare(`SELECT a.alias AS alias, p.slug AS slug, p.root_path AS root_path
2486
+ FROM aliases a JOIN projects p ON p.id = a.project_id WHERE p.status != 'archived'`).all();
2487
+ }
2488
+ async getSessionById(id) {
2489
+ return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id) ?? null;
2490
+ }
2491
+ async getSessionByNumber(projectId, number) {
2492
+ return this.db.prepare("SELECT * FROM sessions WHERE project_id = ? AND number = ?").get(projectId, number) ?? null;
2493
+ }
2494
+ async getLatestSessionForProject(projectId) {
2495
+ return this.db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(projectId) ?? null;
2496
+ }
2497
+ async getMaxSessionNumber(projectId) {
2498
+ return this.db.prepare("SELECT COALESCE(MAX(number), 0) AS n FROM sessions WHERE project_id = ?").get(projectId).n;
2499
+ }
2500
+ async listSessionsForProject(projectId, opts = {}) {
2501
+ let sql = `SELECT * FROM sessions WHERE project_id = ? ORDER BY ${opts.orderBy === "created_desc" ? "created_at DESC" : "number ASC"}`;
2502
+ const params = [projectId];
2503
+ if (opts.limit !== void 0) {
2504
+ sql += " LIMIT ?";
2505
+ params.push(opts.limit);
2506
+ }
2507
+ return this.db.prepare(sql).all(...params);
2508
+ }
2509
+ async listSessions(opts = {}) {
2510
+ const where = [];
2511
+ const params = [];
2512
+ if (opts.projectId !== void 0) {
2513
+ where.push("s.project_id = ?");
2514
+ params.push(opts.projectId);
2515
+ }
2516
+ if (opts.status) {
2517
+ where.push("s.status = ?");
2518
+ params.push(opts.status);
2519
+ }
2520
+ let sql = `
2521
+ SELECT s.*, p.slug AS project_slug, p.display_name AS project_name
2522
+ FROM sessions s JOIN projects p ON p.id = s.project_id
2523
+ `;
2524
+ if (where.length) sql += " WHERE " + where.join(" AND ");
2525
+ sql += " ORDER BY s.date DESC, s.number DESC";
2526
+ const limit = opts.limit ?? 20;
2527
+ sql += ` LIMIT ${limit}`;
2528
+ return this.db.prepare(sql).all(...params);
2529
+ }
2530
+ async findSessionByFilename(projectId, filename) {
2531
+ return this.db.prepare("SELECT id FROM sessions WHERE project_id = ? AND filename = ? LIMIT 1").get(projectId, filename) ?? null;
2532
+ }
2533
+ async sessionNumberTaken(projectId, number) {
2534
+ return !!this.db.prepare("SELECT 1 FROM sessions WHERE project_id = ? AND number = ? LIMIT 1").get(projectId, number);
2535
+ }
2536
+ async createSession(input) {
2537
+ const status = input.status ?? "completed";
2538
+ const result = this.db.prepare(`INSERT INTO sessions (project_id, number, date, slug, title, filename, status, created_at)
2539
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(input.projectId, input.number, input.date, input.slug, input.title, input.filename, status, input.createdAt);
2540
+ return await this.getSessionById(result.lastInsertRowid);
2541
+ }
2542
+ async upsertSessionIfAbsent(input) {
2543
+ if (this.db.prepare("SELECT id FROM sessions WHERE project_id = ? AND number = ?").get(input.projectId, input.number)) return false;
2544
+ const status = input.status ?? "completed";
2545
+ this.db.prepare(`INSERT INTO sessions (project_id, number, date, slug, title, filename, status, created_at)
2546
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(input.projectId, input.number, input.date, input.slug, input.title, input.filename, status, input.createdAt);
2547
+ return true;
2548
+ }
2549
+ async updateSessionMeta(id, patch) {
2550
+ const sets = [];
2551
+ const params = [];
2552
+ if (patch.slug !== void 0) {
2553
+ sets.push("slug = ?");
2554
+ params.push(patch.slug);
2555
+ }
2556
+ if (patch.title !== void 0) {
2557
+ sets.push("title = ?");
2558
+ params.push(patch.title);
2559
+ }
2560
+ if (patch.filename !== void 0) {
2561
+ sets.push("filename = ?");
2562
+ params.push(patch.filename);
2563
+ }
2564
+ if (!sets.length) return;
2565
+ params.push(id);
2566
+ this.db.prepare(`UPDATE sessions SET ${sets.join(", ")} WHERE id = ?`).run(...params);
2567
+ }
2568
+ async updateSessionNumber(id, number, opts = {}) {
2569
+ if (opts.filename !== void 0) this.db.prepare("UPDATE sessions SET number = ?, filename = ? WHERE id = ?").run(number, opts.filename, id);
2570
+ else this.db.prepare("UPDATE sessions SET number = ? WHERE id = ?").run(number, id);
2571
+ }
2572
+ async updateSessionFilename(id, filename) {
2573
+ this.db.prepare("UPDATE sessions SET filename = ? WHERE id = ?").run(filename, id);
2574
+ }
2575
+ async updateSessionStatus(id, status, opts = {}) {
2576
+ if (opts.closedAt !== void 0) this.db.prepare("UPDATE sessions SET status = ?, closed_at = ? WHERE id = ?").run(status, opts.closedAt, id);
2577
+ else this.db.prepare("UPDATE sessions SET status = ? WHERE id = ?").run(status, id);
2578
+ }
2579
+ async moveSessionToProject(id, projectId, opts = {}) {
2580
+ if (opts.number !== void 0) this.db.prepare("UPDATE sessions SET project_id = ?, number = ? WHERE id = ?").run(projectId, opts.number, id);
2581
+ else this.db.prepare("UPDATE sessions SET project_id = ? WHERE id = ?").run(projectId, id);
2582
+ }
2583
+ async deleteSession(id) {
2584
+ this.db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
2585
+ }
2586
+ async backfillFoldedSession(keepId, dropId) {
2587
+ this.db.prepare(`UPDATE sessions SET
2588
+ claude_session_id = COALESCE(claude_session_id,
2589
+ (SELECT claude_session_id FROM sessions WHERE id = ?)),
2590
+ token_count = COALESCE(token_count,
2591
+ (SELECT token_count FROM sessions WHERE id = ?)),
2592
+ closed_at = COALESCE(closed_at,
2593
+ (SELECT closed_at FROM sessions WHERE id = ?)),
2594
+ status = CASE WHEN status = 'open'
2595
+ AND (SELECT status FROM sessions WHERE id = ?) != 'open'
2596
+ THEN (SELECT status FROM sessions WHERE id = ?)
2597
+ ELSE status END
2598
+ WHERE id = ?`).run(dropId, dropId, dropId, dropId, dropId, keepId);
2599
+ }
2600
+ async countSessionsForProject(projectId) {
2601
+ return this.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE project_id = ?").get(projectId).n;
2602
+ }
2603
+ async countSessions() {
2604
+ return this.db.prepare("SELECT COUNT(*) AS n FROM sessions").get().n;
2605
+ }
2606
+ async getMostRecentSessionDate(projectId) {
2607
+ return this.db.prepare("SELECT date FROM sessions WHERE project_id = ? ORDER BY date DESC LIMIT 1").get(projectId)?.date ?? null;
2608
+ }
2609
+ async getMostRecentSessionCreatedAt() {
2610
+ return this.db.prepare("SELECT created_at FROM sessions ORDER BY created_at DESC LIMIT 1").get()?.created_at ?? null;
2611
+ }
2612
+ async listSessionTagIds(sessionId) {
2613
+ return this.db.prepare("SELECT tag_id FROM session_tags WHERE session_id = ?").all(sessionId).map((r) => r.tag_id);
2614
+ }
2615
+ async sessionHasTag(sessionId, tagId) {
2616
+ return !!this.db.prepare("SELECT 1 FROM session_tags WHERE session_id = ? AND tag_id = ?").get(sessionId, tagId);
2617
+ }
2618
+ async addSessionTag(sessionId, tagId) {
2619
+ this.db.prepare("INSERT INTO session_tags (session_id, tag_id) VALUES (?, ?)").run(sessionId, tagId);
2620
+ }
2621
+ async deleteSessionTag(sessionId, tagId) {
2622
+ this.db.prepare("DELETE FROM session_tags WHERE session_id = ? AND tag_id = ?").run(sessionId, tagId);
2623
+ }
2624
+ async reassignSessionTag(fromSessionId, toSessionId, tagId) {
2625
+ this.db.prepare("UPDATE session_tags SET session_id = ? WHERE session_id = ? AND tag_id = ?").run(toSessionId, fromSessionId, tagId);
2626
+ }
2627
+ async listTagsForSession(sessionId) {
2628
+ return this.db.prepare("SELECT t.name FROM tags t JOIN session_tags st ON st.tag_id = t.id WHERE st.session_id = ? ORDER BY t.name").all(sessionId).map((r) => r.name);
2629
+ }
2630
+ async addLink(input) {
2631
+ this.db.prepare("INSERT INTO links (session_id, target_project_id, link_type, created_at) VALUES (?, ?, ?, ?)").run(input.sessionId, input.targetProjectId, input.linkType, input.createdAt);
2632
+ }
2633
+ async listLinksForSession(sessionId) {
2634
+ return this.db.prepare("SELECT id, target_project_id FROM links WHERE session_id = ?").all(sessionId);
2635
+ }
2636
+ async listLinksForProject(projectId) {
2637
+ return this.db.prepare("SELECT id, session_id FROM links WHERE target_project_id = ?").all(projectId);
2638
+ }
2639
+ async linkExists(sessionId, targetProjectId) {
2640
+ return !!this.db.prepare("SELECT 1 FROM links WHERE session_id = ? AND target_project_id = ? LIMIT 1").get(sessionId, targetProjectId);
2641
+ }
2642
+ async deleteLink(id) {
2643
+ this.db.prepare("DELETE FROM links WHERE id = ?").run(id);
2644
+ }
2645
+ async deleteLinksTargetingProject(projectId) {
2646
+ this.db.prepare("DELETE FROM links WHERE target_project_id = ?").run(projectId);
2647
+ }
2648
+ async deleteLinksFromProjectSessions(projectId) {
2649
+ this.db.prepare("DELETE FROM links WHERE session_id IN (SELECT id FROM sessions WHERE project_id = ?)").run(projectId);
2650
+ }
2651
+ async retargetLink(id, targetProjectId) {
2652
+ this.db.prepare("UPDATE links SET target_project_id = ? WHERE id = ?").run(targetProjectId, id);
2653
+ }
2654
+ async moveLinkToSession(id, sessionId) {
2655
+ this.db.prepare("UPDATE links SET session_id = ? WHERE id = ?").run(sessionId, id);
2656
+ }
2657
+ async reassignLinksTarget(fromProjectId, toProjectId) {
2658
+ this.db.prepare("UPDATE OR IGNORE links SET target_project_id = ? WHERE target_project_id = ?").run(toProjectId, fromProjectId);
2659
+ }
2660
+ async countLinksForProject(projectId) {
2661
+ return this.db.prepare("SELECT COUNT(*) AS n FROM links WHERE target_project_id = ?").get(projectId).n;
2662
+ }
2663
+ async deleteSelfLinksForProject(projectId) {
2664
+ this.db.prepare(`DELETE FROM links WHERE target_project_id = ?
2665
+ AND session_id IN (SELECT id FROM sessions WHERE project_id = ?)`).run(projectId, projectId);
2666
+ }
2667
+ async appendCompactionLog(entry) {
2668
+ this.db.prepare(`INSERT INTO compaction_log (project_id, session_id, trigger, files_written, token_count, created_at)
2669
+ VALUES (?, ?, ?, ?, ?, ?)`).run(entry.projectId, entry.sessionId, entry.trigger, entry.filesWritten, entry.tokenCount, entry.createdAt);
2670
+ }
2671
+ async countCompactionLogsForProject(projectId) {
2672
+ return this.db.prepare("SELECT COUNT(*) AS n FROM compaction_log WHERE project_id = ?").get(projectId).n;
2673
+ }
2674
+ async moveCompactionLogsByProject(fromProjectId, toProjectId) {
2675
+ return this.db.prepare("UPDATE compaction_log SET project_id = ? WHERE project_id = ?").run(toProjectId, fromProjectId).changes;
2676
+ }
2677
+ async moveCompactionLogsBySession(fromSessionId, toSessionId) {
2678
+ this.db.prepare("UPDATE compaction_log SET session_id = ? WHERE session_id = ?").run(toSessionId, fromSessionId);
2679
+ }
2680
+ async deleteCompactionLogsForProject(projectId) {
2681
+ this.db.prepare("DELETE FROM compaction_log WHERE project_id = ?").run(projectId);
2682
+ }
2683
+ async planProjectMerge(fromSlug, intoSlug) {
2684
+ return planMerge(this.db, fromSlug, intoSlug);
2685
+ }
2686
+ async applyProjectMerge(plan) {
2687
+ applyMerge(this.db, plan);
2688
+ }
2689
+ };
2690
+
2691
+ //#endregion
2692
+ //#region src/storage/factory.ts
2693
+ /** Backoff schedule (ms) for the bounded CLI retry path. */
2694
+ const CLI_RETRY_DELAYS_MS = [
2695
+ 500,
2696
+ 1e3,
2697
+ 2e3
2698
+ ];
2699
+ /** Backoff cap (ms) for the daemon's infinite retry path. */
2700
+ const DAEMON_RETRY_CAP_MS = 15e3;
2701
+ /**
2702
+ * Create and return the configured StorageBackend.
2703
+ *
2704
+ * Auto-behaviour:
2705
+ * - storageBackend = "sqlite" → SQLiteBackend always
2706
+ * - storageBackend = "postgres" → PostgresBackend (retried; never falls back)
2707
+ */
2708
+ async function createStorageBackend(config, opts = {}) {
2709
+ if (config.storageBackend === "postgres") return await getSharedPostgresBackend(config, opts.waitForPostgres ?? false);
2710
+ return createSQLiteBackend();
2711
+ }
2712
+ /**
2713
+ * Attempt a single Postgres connection (ensure DB + test). Returns the live
2714
+ * backend on success, or an error string describing why it failed.
2715
+ */
2716
+ async function attemptPostgres(config) {
2717
+ const pgConfig = config.postgres ?? {};
2718
+ let backend = null;
2719
+ try {
2720
+ await PostgresBackend.ensureDatabase(pgConfig);
2721
+ backend = new PostgresBackend(pgConfig);
2722
+ const err = await backend.testConnection();
2723
+ if (err) {
2724
+ await backend.close().catch(() => {});
2725
+ return { error: err };
2726
+ }
2727
+ return { backend };
2728
+ } catch (e) {
2729
+ if (backend) await backend.close().catch(() => {});
2730
+ return { error: e instanceof Error ? e.message : String(e) };
2731
+ }
2732
+ }
2733
+ /**
2734
+ * Consecutive failures before the outage is escalated to the user.
2735
+ *
2736
+ * Not the first failure: a container restarting, or the daemon starting before
2737
+ * Docker is up, recovers within a few seconds and is not worth a notification.
2738
+ * By the fifth attempt the backoff has already spent tens of seconds, which is
2739
+ * long enough that something is actually wrong.
2740
+ */
2741
+ const ESCALATE_AFTER_ATTEMPTS = 5;
2742
+ /** Tell the user the backend is down, through whatever channels are configured. */
2743
+ async function notifyBackendDown(attempts, lastError) {
2744
+ try {
2745
+ await routeNotification({
2746
+ event: "error",
2747
+ title: "PAI: storage backend unreachable",
2748
+ message: `Postgres has not answered in ${attempts} attempts (${lastError}). Indexing, session notes and the work queue are stalled until it returns. Check the container, then \`pai daemon status\`.`
2749
+ }, loadConfig().notifications);
2750
+ } catch {}
2751
+ }
2752
+ /** And say when it comes back, so the alert is not left hanging. */
2753
+ async function notifyBackendRecovered(attempts, since) {
2754
+ try {
2755
+ const mins = since ? Math.max(1, Math.round((Date.now() - since) / 6e4)) : null;
2756
+ await routeNotification({
2757
+ event: "completion",
2758
+ title: "PAI: storage backend back",
2759
+ message: `Postgres answered after ${attempts} attempts` + (mins ? `, ${mins} min down` : "") + `. The queue will drain on its own.`
2760
+ }, loadConfig().notifications);
2761
+ } catch {}
2762
+ }
2763
+ /**
2764
+ * Shared retry loop: attempt a connection, back off, escalate, repeat — used
2765
+ * by both the federation and registry Postgres connectors so the retry/outage
2766
+ * behaviour (§6, "never falls back to SQLite silently") stays in one place.
2767
+ */
2768
+ async function retryConnect(attempt, waitForever, label) {
2769
+ let attemptN = 0;
2770
+ let outageSince = null;
2771
+ let escalated = false;
2772
+ while (true) {
2773
+ attemptN++;
2774
+ const result = await attempt();
2775
+ if ("backend" in result) {
2776
+ if (attemptN > 1) process.stderr.write(`[pai-daemon] Connected to ${label} (after ${attemptN} attempts).\n`);
2777
+ clearBackendOutage();
2778
+ if (escalated) notifyBackendRecovered(attemptN, outageSince);
2779
+ return result.backend;
2780
+ }
2781
+ const lastError = result.error;
2782
+ if (!waitForever && attemptN > CLI_RETRY_DELAYS_MS.length) throw new Error(`${label} unreachable after ${attemptN} attempts: ${lastError}. Is Docker Desktop / Postgres running? Refusing to fall back to SQLite (would split the corpus). Start Postgres and retry.`);
2783
+ const delayMs = waitForever ? Math.min(DAEMON_RETRY_CAP_MS, 1e3 * 2 ** Math.min(attemptN - 1, 4)) : CLI_RETRY_DELAYS_MS[attemptN - 1];
2784
+ process.stderr.write(`[pai-daemon] ${label} unavailable (${lastError}). Retry ${attemptN}${waitForever ? "" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} in ${delayMs}ms...\n`);
2785
+ setBackendOutage({
2786
+ backend: label,
2787
+ since: outageSince ?? (outageSince = Date.now()),
2788
+ attempts: attemptN,
2789
+ lastError: String(lastError)
2790
+ });
2791
+ if (waitForever && attemptN === ESCALATE_AFTER_ATTEMPTS && !escalated) {
2792
+ escalated = true;
2793
+ notifyBackendDown(attemptN, String(lastError));
2794
+ }
2795
+ await new Promise((r) => setTimeout(r, delayMs));
2796
+ }
2797
+ }
2798
+ async function connectPostgres(config, waitForever) {
2799
+ return retryConnect(() => attemptPostgres(config), waitForever, "PostgreSQL backend");
2800
+ }
2801
+ /**
2802
+ * The process's one Postgres connection: storage and registry share a
2803
+ * single pg Pool (one connection budget) instead of each opening their own.
2804
+ * Whichever of createStorageBackend()/createRegistryBackend() runs first
2805
+ * creates it via connectPostgres()'s retry/outage handling; the other
2806
+ * reuses its pool through PostgresBackend.getPool(). Reset by closeStorage().
2807
+ */
2808
+ let pgBackendPromise = null;
2809
+ function getSharedPostgresBackend(config, waitForever) {
2810
+ if (!pgBackendPromise) pgBackendPromise = connectPostgres(config, waitForever);
2811
+ return pgBackendPromise;
2812
+ }
2813
+ async function createSQLiteBackend() {
2814
+ return new SQLiteBackend(openFederation());
2815
+ }
2816
+ /**
2817
+ * Create and return the configured RegistryBackend (projects, sessions,
2818
+ * tags, aliases, links, compaction_log). Same auto-behaviour and same
2819
+ * never-fall-back-to-SQLite rule as createStorageBackend().
2820
+ */
2821
+ async function createRegistryBackend(config, opts = {}) {
2822
+ if (config.storageBackend === "postgres") return new PostgresRegistryBackend((await getSharedPostgresBackend(config, opts.waitForPostgres ?? false)).getPool());
2823
+ return createSQLiteRegistryBackend();
2824
+ }
2825
+ async function createSQLiteRegistryBackend() {
2826
+ return new SQLiteRegistryBackend(openRegistry());
2827
+ }
2828
+ /**
2829
+ * Process-wide backend accessors.
2830
+ *
2831
+ * Callers across the codebase must get their StorageBackend/RegistryBackend
2832
+ * from here rather than constructing SQLite or Postgres directly — this is
2833
+ * the one place that decides which backend a process uses, and the one place
2834
+ * that closes it on exit.
2835
+ */
2836
+ let storagePromise = null;
2837
+ let registryPromise = null;
2838
+ let beforeExitRegistered = false;
2839
+ function registerBeforeExit() {
2840
+ if (beforeExitRegistered) return;
2841
+ beforeExitRegistered = true;
2842
+ process.once("beforeExit", () => {
2843
+ closeStorage();
2844
+ });
2845
+ }
2846
+ /**
2847
+ * Returns the process-wide StorageBackend, creating it on first call.
2848
+ *
2849
+ * The whole body up to and including the `storagePromise = (async () =>
2850
+ * ...)()` assignment must run synchronously (no `await` before it) — two
2851
+ * calls issued in the same tick (e.g. `Promise.all([get(), get()])`) must
2852
+ * both observe the same cached promise rather than each racing to create
2853
+ * their own backend.
2854
+ */
2855
+ async function getStorageBackend() {
2856
+ if (!storagePromise) storagePromise = (async () => {
2857
+ registerBeforeExit();
2858
+ return await createStorageBackend(loadConfig());
2859
+ })();
2860
+ return storagePromise;
2861
+ }
2862
+ /** Returns the process-wide RegistryBackend, creating it on first call. Same synchronous-caching requirement as getStorageBackend(). */
2863
+ async function getRegistryBackend() {
2864
+ if (!registryPromise) registryPromise = (async () => {
2865
+ registerBeforeExit();
2866
+ return await createRegistryBackend(loadConfig());
2867
+ })();
2868
+ return registryPromise;
2869
+ }
2870
+ /** Closes any backends created via getStorageBackend()/getRegistryBackend(). Idempotent. */
2871
+ async function closeStorage() {
2872
+ const pending = [storagePromise, registryPromise];
2873
+ const pg = pgBackendPromise;
2874
+ storagePromise = null;
2875
+ registryPromise = null;
2876
+ pgBackendPromise = null;
2877
+ for (const p of pending) {
2878
+ if (!p) continue;
2879
+ try {
2880
+ const backend = await p;
2881
+ if (backend.backendType === "postgres") continue;
2882
+ await backend.close();
2883
+ } catch {}
2884
+ }
2885
+ if (pg) try {
2886
+ await (await pg).close();
2887
+ } catch {}
2888
+ }
2889
+
2890
+ //#endregion
2891
+ export { getStorageBackend as a, MergeError as c, getBackendOutage as d, humanDuration as f, getRegistryBackend as i, federationDbPath as l, createRegistryBackend as n, oldRegistryPath as o, createStorageBackend as r, registryDbPath as s, closeStorage as t, oldFederationPath as u };
2892
+ //# sourceMappingURL=factory-Ypf8r0fK.mjs.map