xtctx 0.3.2 → 0.11.3

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 (308) hide show
  1. package/CHANGELOG.md +317 -122
  2. package/LICENSE +21 -21
  3. package/README.md +154 -121
  4. package/dist/src/cli/disconnect.d.ts +8 -0
  5. package/dist/src/cli/disconnect.d.ts.map +1 -0
  6. package/dist/src/cli/disconnect.js +42 -0
  7. package/dist/src/cli/disconnect.js.map +1 -0
  8. package/dist/src/cli/hook.d.ts +7 -0
  9. package/dist/src/cli/hook.d.ts.map +1 -0
  10. package/dist/src/cli/hook.js +32 -0
  11. package/dist/src/cli/hook.js.map +1 -0
  12. package/dist/src/cli/index.d.ts.map +1 -1
  13. package/dist/src/cli/index.js +86 -33
  14. package/dist/src/cli/index.js.map +1 -1
  15. package/dist/src/cli/setup.d.ts +8 -0
  16. package/dist/src/cli/setup.d.ts.map +1 -0
  17. package/dist/src/cli/setup.js +76 -0
  18. package/dist/src/cli/setup.js.map +1 -0
  19. package/dist/src/cli/status.d.ts +7 -0
  20. package/dist/src/cli/status.d.ts.map +1 -0
  21. package/dist/src/cli/status.js +133 -0
  22. package/dist/src/cli/status.js.map +1 -0
  23. package/dist/src/config/disconnect.d.ts +34 -0
  24. package/dist/src/config/disconnect.d.ts.map +1 -0
  25. package/dist/src/config/disconnect.js +386 -0
  26. package/dist/src/config/disconnect.js.map +1 -0
  27. package/dist/src/config/managed-block.d.ts +29 -0
  28. package/dist/src/config/managed-block.d.ts.map +1 -0
  29. package/dist/src/config/managed-block.js +67 -0
  30. package/dist/src/config/managed-block.js.map +1 -0
  31. package/dist/src/config/mcp-config.d.ts +93 -0
  32. package/dist/src/config/mcp-config.d.ts.map +1 -0
  33. package/dist/src/config/mcp-config.js +556 -0
  34. package/dist/src/config/mcp-config.js.map +1 -0
  35. package/dist/src/config/setup.d.ts +39 -0
  36. package/dist/src/config/setup.d.ts.map +1 -0
  37. package/dist/src/config/setup.js +379 -0
  38. package/dist/src/config/setup.js.map +1 -0
  39. package/dist/src/config/skills.d.ts +73 -0
  40. package/dist/src/config/skills.d.ts.map +1 -0
  41. package/dist/src/config/skills.js +427 -0
  42. package/dist/src/config/skills.js.map +1 -0
  43. package/dist/src/handoff/embeddings.d.ts +26 -0
  44. package/dist/src/handoff/embeddings.d.ts.map +1 -0
  45. package/dist/src/handoff/embeddings.js +132 -0
  46. package/dist/src/handoff/embeddings.js.map +1 -0
  47. package/dist/src/handoff/sqlite-index.d.ts +52 -0
  48. package/dist/src/handoff/sqlite-index.d.ts.map +1 -0
  49. package/dist/src/handoff/sqlite-index.js +905 -0
  50. package/dist/src/handoff/sqlite-index.js.map +1 -0
  51. package/dist/src/handoff/types.d.ts +63 -0
  52. package/dist/src/handoff/types.d.ts.map +1 -0
  53. package/dist/src/handoff/types.js +2 -0
  54. package/dist/src/handoff/types.js.map +1 -0
  55. package/dist/src/handoff/vector.d.ts +4 -0
  56. package/dist/src/handoff/vector.d.ts.map +1 -0
  57. package/dist/src/handoff/vector.js +31 -0
  58. package/dist/src/handoff/vector.js.map +1 -0
  59. package/dist/src/mcp/server.d.ts +3 -20
  60. package/dist/src/mcp/server.d.ts.map +1 -1
  61. package/dist/src/mcp/server.js +90 -205
  62. package/dist/src/mcp/server.js.map +1 -1
  63. package/dist/src/mcp/tools/continuity.d.ts +21 -0
  64. package/dist/src/mcp/tools/continuity.d.ts.map +1 -0
  65. package/dist/src/mcp/tools/continuity.js +55 -0
  66. package/dist/src/mcp/tools/continuity.js.map +1 -0
  67. package/dist/src/mcp/tools/manifest.d.ts +40 -0
  68. package/dist/src/mcp/tools/manifest.d.ts.map +1 -0
  69. package/dist/src/mcp/tools/manifest.js +105 -0
  70. package/dist/src/mcp/tools/manifest.js.map +1 -0
  71. package/dist/src/mcp/tools/sessions.d.ts +18 -31
  72. package/dist/src/mcp/tools/sessions.d.ts.map +1 -1
  73. package/dist/src/mcp/tools/sessions.js +109 -16
  74. package/dist/src/mcp/tools/sessions.js.map +1 -1
  75. package/dist/src/runtime/services.d.ts +11 -38
  76. package/dist/src/runtime/services.d.ts.map +1 -1
  77. package/dist/src/runtime/services.js +61 -200
  78. package/dist/src/runtime/services.js.map +1 -1
  79. package/dist/src/scrapers/antigravity.d.ts +52 -0
  80. package/dist/src/scrapers/antigravity.d.ts.map +1 -0
  81. package/dist/src/scrapers/antigravity.js +869 -0
  82. package/dist/src/scrapers/antigravity.js.map +1 -0
  83. package/dist/src/scrapers/base.d.ts +53 -1
  84. package/dist/src/scrapers/base.d.ts.map +1 -1
  85. package/dist/src/scrapers/base.js +93 -4
  86. package/dist/src/scrapers/base.js.map +1 -1
  87. package/dist/src/scrapers/claude-code.d.ts +37 -6
  88. package/dist/src/scrapers/claude-code.d.ts.map +1 -1
  89. package/dist/src/scrapers/claude-code.js +214 -40
  90. package/dist/src/scrapers/claude-code.js.map +1 -1
  91. package/dist/src/scrapers/codex.d.ts +36 -6
  92. package/dist/src/scrapers/codex.d.ts.map +1 -1
  93. package/dist/src/scrapers/codex.js +283 -60
  94. package/dist/src/scrapers/codex.js.map +1 -1
  95. package/dist/src/scrapers/copilot-cli.d.ts +34 -0
  96. package/dist/src/scrapers/copilot-cli.d.ts.map +1 -0
  97. package/dist/src/scrapers/copilot-cli.js +342 -0
  98. package/dist/src/scrapers/copilot-cli.js.map +1 -0
  99. package/dist/src/scrapers/copilot.d.ts +41 -8
  100. package/dist/src/scrapers/copilot.d.ts.map +1 -1
  101. package/dist/src/scrapers/copilot.js +256 -118
  102. package/dist/src/scrapers/copilot.js.map +1 -1
  103. package/dist/src/scrapers/cursor.d.ts +24 -9
  104. package/dist/src/scrapers/cursor.d.ts.map +1 -1
  105. package/dist/src/scrapers/cursor.js +259 -152
  106. package/dist/src/scrapers/cursor.js.map +1 -1
  107. package/dist/src/scrapers/index.d.ts +23 -0
  108. package/dist/src/scrapers/index.d.ts.map +1 -0
  109. package/dist/src/scrapers/index.js +22 -0
  110. package/dist/src/scrapers/index.js.map +1 -0
  111. package/dist/src/scrapers/opencode.d.ts +37 -0
  112. package/dist/src/scrapers/opencode.d.ts.map +1 -0
  113. package/dist/src/scrapers/opencode.js +277 -0
  114. package/dist/src/scrapers/opencode.js.map +1 -0
  115. package/dist/src/scrapers/registry.d.ts +1 -0
  116. package/dist/src/scrapers/registry.d.ts.map +1 -1
  117. package/dist/src/scrapers/registry.js +3 -0
  118. package/dist/src/scrapers/registry.js.map +1 -1
  119. package/dist/src/tools/sources.d.ts +28 -0
  120. package/dist/src/tools/sources.d.ts.map +1 -0
  121. package/dist/src/tools/sources.js +149 -0
  122. package/dist/src/tools/sources.js.map +1 -0
  123. package/dist/src/types/config.d.ts +24 -37
  124. package/dist/src/types/config.d.ts.map +1 -1
  125. package/dist/src/types/index.d.ts +0 -2
  126. package/dist/src/types/index.d.ts.map +1 -1
  127. package/dist/src/types/index.js +0 -2
  128. package/dist/src/types/index.js.map +1 -1
  129. package/dist/src/types/scraper.d.ts +34 -5
  130. package/dist/src/types/scraper.d.ts.map +1 -1
  131. package/dist/src/utils/atomic-file.d.ts +8 -0
  132. package/dist/src/utils/atomic-file.d.ts.map +1 -0
  133. package/dist/src/utils/atomic-file.js +15 -0
  134. package/dist/src/utils/atomic-file.js.map +1 -0
  135. package/dist/src/utils/errors.d.ts +7 -0
  136. package/dist/src/utils/errors.d.ts.map +1 -0
  137. package/dist/src/utils/errors.js +16 -0
  138. package/dist/src/utils/errors.js.map +1 -0
  139. package/dist/src/utils/package-info.d.ts +11 -0
  140. package/dist/src/utils/package-info.d.ts.map +1 -0
  141. package/dist/src/utils/package-info.js +30 -0
  142. package/dist/src/utils/package-info.js.map +1 -0
  143. package/dist/src/utils/project-scope.d.ts +19 -0
  144. package/dist/src/utils/project-scope.d.ts.map +1 -0
  145. package/dist/src/utils/project-scope.js +61 -0
  146. package/dist/src/utils/project-scope.js.map +1 -0
  147. package/package.json +70 -66
  148. package/dist/src/api/routes/config.d.ts +0 -4
  149. package/dist/src/api/routes/config.d.ts.map +0 -1
  150. package/dist/src/api/routes/config.js +0 -61
  151. package/dist/src/api/routes/config.js.map +0 -1
  152. package/dist/src/api/routes/knowledge.d.ts +0 -4
  153. package/dist/src/api/routes/knowledge.d.ts.map +0 -1
  154. package/dist/src/api/routes/knowledge.js +0 -40
  155. package/dist/src/api/routes/knowledge.js.map +0 -1
  156. package/dist/src/api/routes/search.d.ts +0 -7
  157. package/dist/src/api/routes/search.d.ts.map +0 -1
  158. package/dist/src/api/routes/search.js +0 -108
  159. package/dist/src/api/routes/search.js.map +0 -1
  160. package/dist/src/api/routes/sources.d.ts +0 -12
  161. package/dist/src/api/routes/sources.d.ts.map +0 -1
  162. package/dist/src/api/routes/sources.js +0 -70
  163. package/dist/src/api/routes/sources.js.map +0 -1
  164. package/dist/src/api/server.d.ts +0 -26
  165. package/dist/src/api/server.d.ts.map +0 -1
  166. package/dist/src/api/server.js +0 -312
  167. package/dist/src/api/server.js.map +0 -1
  168. package/dist/src/cli/ingest.d.ts +0 -6
  169. package/dist/src/cli/ingest.d.ts.map +0 -1
  170. package/dist/src/cli/ingest.js +0 -11
  171. package/dist/src/cli/ingest.js.map +0 -1
  172. package/dist/src/cli/init.d.ts +0 -6
  173. package/dist/src/cli/init.d.ts.map +0 -1
  174. package/dist/src/cli/init.js +0 -105
  175. package/dist/src/cli/init.js.map +0 -1
  176. package/dist/src/cli/serve.d.ts +0 -7
  177. package/dist/src/cli/serve.d.ts.map +0 -1
  178. package/dist/src/cli/serve.js +0 -100
  179. package/dist/src/cli/serve.js.map +0 -1
  180. package/dist/src/cli/sync.d.ts +0 -5
  181. package/dist/src/cli/sync.d.ts.map +0 -1
  182. package/dist/src/cli/sync.js +0 -13
  183. package/dist/src/cli/sync.js.map +0 -1
  184. package/dist/src/compaction/pipeline.d.ts +0 -19
  185. package/dist/src/compaction/pipeline.d.ts.map +0 -1
  186. package/dist/src/compaction/pipeline.js +0 -37
  187. package/dist/src/compaction/pipeline.js.map +0 -1
  188. package/dist/src/compaction/providers/cli.d.ts +0 -13
  189. package/dist/src/compaction/providers/cli.d.ts.map +0 -1
  190. package/dist/src/compaction/providers/cli.js +0 -60
  191. package/dist/src/compaction/providers/cli.js.map +0 -1
  192. package/dist/src/compaction/rule-based.d.ts +0 -8
  193. package/dist/src/compaction/rule-based.d.ts.map +0 -1
  194. package/dist/src/compaction/rule-based.js +0 -120
  195. package/dist/src/compaction/rule-based.js.map +0 -1
  196. package/dist/src/config/loader.d.ts +0 -12
  197. package/dist/src/config/loader.d.ts.map +0 -1
  198. package/dist/src/config/loader.js +0 -76
  199. package/dist/src/config/loader.js.map +0 -1
  200. package/dist/src/config/mappings/claude-code.d.ts +0 -3
  201. package/dist/src/config/mappings/claude-code.d.ts.map +0 -1
  202. package/dist/src/config/mappings/claude-code.js +0 -31
  203. package/dist/src/config/mappings/claude-code.js.map +0 -1
  204. package/dist/src/config/mappings/codex.d.ts +0 -3
  205. package/dist/src/config/mappings/codex.d.ts.map +0 -1
  206. package/dist/src/config/mappings/codex.js +0 -31
  207. package/dist/src/config/mappings/codex.js.map +0 -1
  208. package/dist/src/config/mappings/copilot.d.ts +0 -3
  209. package/dist/src/config/mappings/copilot.d.ts.map +0 -1
  210. package/dist/src/config/mappings/copilot.js +0 -33
  211. package/dist/src/config/mappings/copilot.js.map +0 -1
  212. package/dist/src/config/mappings/cursor.d.ts +0 -3
  213. package/dist/src/config/mappings/cursor.d.ts.map +0 -1
  214. package/dist/src/config/mappings/cursor.js +0 -29
  215. package/dist/src/config/mappings/cursor.js.map +0 -1
  216. package/dist/src/config/sync.d.ts +0 -13
  217. package/dist/src/config/sync.d.ts.map +0 -1
  218. package/dist/src/config/sync.js +0 -96
  219. package/dist/src/config/sync.js.map +0 -1
  220. package/dist/src/ingestion/coordinator.d.ts +0 -37
  221. package/dist/src/ingestion/coordinator.d.ts.map +0 -1
  222. package/dist/src/ingestion/coordinator.js +0 -127
  223. package/dist/src/ingestion/coordinator.js.map +0 -1
  224. package/dist/src/ingestion/daemon.d.ts +0 -17
  225. package/dist/src/ingestion/daemon.d.ts.map +0 -1
  226. package/dist/src/ingestion/daemon.js +0 -35
  227. package/dist/src/ingestion/daemon.js.map +0 -1
  228. package/dist/src/ingestion/watcher.d.ts +0 -19
  229. package/dist/src/ingestion/watcher.d.ts.map +0 -1
  230. package/dist/src/ingestion/watcher.js +0 -70
  231. package/dist/src/ingestion/watcher.js.map +0 -1
  232. package/dist/src/knowledge/autotag.d.ts +0 -3
  233. package/dist/src/knowledge/autotag.d.ts.map +0 -1
  234. package/dist/src/knowledge/autotag.js +0 -24
  235. package/dist/src/knowledge/autotag.js.map +0 -1
  236. package/dist/src/knowledge/dedup.d.ts +0 -7
  237. package/dist/src/knowledge/dedup.d.ts.map +0 -1
  238. package/dist/src/knowledge/dedup.js +0 -15
  239. package/dist/src/knowledge/dedup.js.map +0 -1
  240. package/dist/src/knowledge/repository.d.ts +0 -12
  241. package/dist/src/knowledge/repository.d.ts.map +0 -1
  242. package/dist/src/knowledge/repository.js +0 -74
  243. package/dist/src/knowledge/repository.js.map +0 -1
  244. package/dist/src/mcp/tools/config.d.ts +0 -36
  245. package/dist/src/mcp/tools/config.d.ts.map +0 -1
  246. package/dist/src/mcp/tools/config.js +0 -54
  247. package/dist/src/mcp/tools/config.js.map +0 -1
  248. package/dist/src/mcp/tools/knowledge.d.ts +0 -17
  249. package/dist/src/mcp/tools/knowledge.d.ts.map +0 -1
  250. package/dist/src/mcp/tools/knowledge.js +0 -46
  251. package/dist/src/mcp/tools/knowledge.js.map +0 -1
  252. package/dist/src/mcp/tools/search.d.ts +0 -22
  253. package/dist/src/mcp/tools/search.d.ts.map +0 -1
  254. package/dist/src/mcp/tools/search.js +0 -30
  255. package/dist/src/mcp/tools/search.js.map +0 -1
  256. package/dist/src/mcp/tools/write.d.ts +0 -38
  257. package/dist/src/mcp/tools/write.d.ts.map +0 -1
  258. package/dist/src/mcp/tools/write.js +0 -97
  259. package/dist/src/mcp/tools/write.js.map +0 -1
  260. package/dist/src/runtime/ingestion.d.ts +0 -17
  261. package/dist/src/runtime/ingestion.d.ts.map +0 -1
  262. package/dist/src/runtime/ingestion.js +0 -106
  263. package/dist/src/runtime/ingestion.js.map +0 -1
  264. package/dist/src/scrapers/gemini.d.ts +0 -17
  265. package/dist/src/scrapers/gemini.d.ts.map +0 -1
  266. package/dist/src/scrapers/gemini.js +0 -245
  267. package/dist/src/scrapers/gemini.js.map +0 -1
  268. package/dist/src/store/embeddings.d.ts +0 -9
  269. package/dist/src/store/embeddings.d.ts.map +0 -1
  270. package/dist/src/store/embeddings.js +0 -28
  271. package/dist/src/store/embeddings.js.map +0 -1
  272. package/dist/src/store/lance.d.ts +0 -25
  273. package/dist/src/store/lance.d.ts.map +0 -1
  274. package/dist/src/store/lance.js +0 -74
  275. package/dist/src/store/lance.js.map +0 -1
  276. package/dist/src/store/search.d.ts +0 -18
  277. package/dist/src/store/search.d.ts.map +0 -1
  278. package/dist/src/store/search.js +0 -55
  279. package/dist/src/store/search.js.map +0 -1
  280. package/dist/src/types/compaction.d.ts +0 -29
  281. package/dist/src/types/compaction.d.ts.map +0 -1
  282. package/dist/src/types/compaction.js +0 -2
  283. package/dist/src/types/compaction.js.map +0 -1
  284. package/dist/src/types/context.d.ts +0 -33
  285. package/dist/src/types/context.d.ts.map +0 -1
  286. package/dist/src/types/context.js +0 -18
  287. package/dist/src/types/context.js.map +0 -1
  288. package/dist/src/utils/retry.d.ts +0 -11
  289. package/dist/src/utils/retry.d.ts.map +0 -1
  290. package/dist/src/utils/retry.js +0 -46
  291. package/dist/src/utils/retry.js.map +0 -1
  292. package/dist/src/utils/shutdown.d.ts +0 -25
  293. package/dist/src/utils/shutdown.d.ts.map +0 -1
  294. package/dist/src/utils/shutdown.js +0 -56
  295. package/dist/src/utils/shutdown.js.map +0 -1
  296. package/dist/web/apple-touch-icon.png +0 -0
  297. package/dist/web/assets/index-Nuadr5ox.css +0 -1
  298. package/dist/web/assets/index-aA0u8eXA.js +0 -3504
  299. package/dist/web/assets/primeicons-C6QP2o4f.woff2 +0 -0
  300. package/dist/web/assets/primeicons-DMOk5skT.eot +0 -0
  301. package/dist/web/assets/primeicons-Dr5RGzOO.svg +0 -345
  302. package/dist/web/assets/primeicons-MpK4pl85.ttf +0 -0
  303. package/dist/web/assets/primeicons-WjwUDZjB.woff +0 -0
  304. package/dist/web/favicon.ico +0 -0
  305. package/dist/web/favicon.svg +0 -21
  306. package/dist/web/index.html +0 -21
  307. package/dist/web/site.webmanifest +0 -19
  308. package/dist/web/vite.svg +0 -1
@@ -0,0 +1,905 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readdir, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import Database from "better-sqlite3";
5
+ import { DEFAULT_EMBEDDING_MODEL, TransformersEmbeddingProvider, poolVectors, splitTextForEmbedding, } from "./embeddings.js";
6
+ import { cosineSimilarity, deserializeVector, serializeVector } from "./vector.js";
7
+ /**
8
+ * Bumped whenever the schema shape changes. The index is derived data, so a
9
+ * version mismatch (older or newer) triggers a full rebuild rather than a
10
+ * migration — the transcript stores remain authoritative.
11
+ */
12
+ const SCHEMA_VERSION = 1;
13
+ const DEFAULT_LIMIT = 5;
14
+ const MAX_LIMIT = 100;
15
+ const DEFAULT_WINDOW_SIZE = 8;
16
+ const DEFAULT_WINDOW_STRIDE = 4;
17
+ const MAX_MATCHES_PER_SESSION = 3;
18
+ /**
19
+ * Minimum raw cosine similarity for a retrieval window to count as a semantic
20
+ * match. Unrelated sentence-transformer pairs sit near 0; related ones are
21
+ * comfortably above this.
22
+ */
23
+ const MIN_SEMANTIC_COSINE = 0.15;
24
+ const SOURCE_CURSOR_OVERLAP_MS = 1_000;
25
+ export class SqliteHandoffIndex {
26
+ dbPath;
27
+ projectRoot;
28
+ tools;
29
+ db = null;
30
+ stmts = null;
31
+ initialized;
32
+ refreshPromise = null;
33
+ lastRefreshMs = 0;
34
+ refreshTtlMs = 5_000;
35
+ embeddingProvider;
36
+ windowSize;
37
+ windowStride;
38
+ constructor(dbPath, projectRoot, tools, options = {}) {
39
+ this.dbPath = dbPath;
40
+ this.projectRoot = projectRoot;
41
+ this.tools = tools;
42
+ this.embeddingProvider =
43
+ options.embeddingProvider ?? new TransformersEmbeddingProvider(DEFAULT_EMBEDDING_MODEL);
44
+ this.windowSize = Math.max(2, Math.floor(options.windowSize ?? DEFAULT_WINDOW_SIZE));
45
+ this.windowStride = Math.max(1, Math.floor(options.windowStride ?? DEFAULT_WINDOW_STRIDE));
46
+ this.initialized = this.initialize();
47
+ // Attach a no-op handler so a failed open cannot become an unhandled
48
+ // rejection (which would kill the process) before the first caller
49
+ // awaits; each awaiter of `initialized` still observes the rejection.
50
+ this.initialized.catch(() => { });
51
+ }
52
+ async listRecentSessions(limit, toolFilter) {
53
+ await this.refresh({ toolFilter });
54
+ const db = this.getDb();
55
+ const normalizedLimit = normalizeLimit(limit, DEFAULT_LIMIT);
56
+ const filters = normalizeToolFilter(toolFilter);
57
+ const where = filters.length > 0 ? `WHERE tool IN (${placeholders(filters.length)})` : "";
58
+ const rows = db
59
+ .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path
60
+ FROM sessions
61
+ ${where}
62
+ ORDER BY last_activity_at DESC
63
+ LIMIT ?`)
64
+ .all(...filters, normalizedLimit);
65
+ return rows.map(formatSessionRow);
66
+ }
67
+ async getSessionByRef(sessionRef) {
68
+ await this.refresh({ sessionRef });
69
+ const db = this.getDb();
70
+ const row = db
71
+ .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path
72
+ FROM sessions
73
+ WHERE session_ref = ?`)
74
+ .get(sessionRef);
75
+ return row ? formatSessionRow(row) : null;
76
+ }
77
+ async getSessionDetail(sessionRef, offset, limit) {
78
+ await this.refresh({ sessionRef });
79
+ const db = this.getDb();
80
+ const normalizedOffset = Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0;
81
+ const normalizedLimit = normalizeLimit(limit, 50);
82
+ const rows = db
83
+ .prepare(`SELECT id, timestamp, role, content, message_index, source_pointer
84
+ FROM messages
85
+ WHERE session_ref = ?
86
+ ORDER BY timestamp ASC, message_index ASC, id ASC
87
+ LIMIT ? OFFSET ?`)
88
+ .all(sessionRef, normalizedLimit, normalizedOffset);
89
+ return rows.map((row) => ({
90
+ timestamp: row.timestamp,
91
+ role: row.role,
92
+ content: row.content,
93
+ source_pointer: row.source_pointer ?? undefined,
94
+ }));
95
+ }
96
+ async searchSessions(query, limit, toolFilter, mode = "hybrid") {
97
+ await this.refresh({ toolFilter });
98
+ const trimmed = query.trim();
99
+ if (!trimmed) {
100
+ return [];
101
+ }
102
+ const normalizedMode = normalizeSearchMode(mode);
103
+ if (normalizedMode === "keyword") {
104
+ return this.keywordSearch(trimmed, limit, toolFilter);
105
+ }
106
+ try {
107
+ const results = await this.semanticSearch(trimmed, limit, toolFilter, normalizedMode);
108
+ clearSetting(this.getDb(), "last_error:embeddings");
109
+ return results;
110
+ }
111
+ catch (error) {
112
+ if (normalizedMode === "hybrid") {
113
+ // Degrading to keyword keeps search useful, but a broken embedding
114
+ // path must not be invisible: it silently returned keyword-only
115
+ // results for every user while status still read healthy.
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ setSetting(this.getDb(), "last_error:embeddings", message);
118
+ process.stderr.write(`xtctx: semantic search unavailable, using keyword only (${message})\n`);
119
+ return this.keywordSearch(trimmed, limit, toolFilter);
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ async getStatus() {
125
+ await this.refresh({ statusOnly: true });
126
+ const db = this.getDb();
127
+ const sessionCount = count(db, "sessions");
128
+ const messageCount = count(db, "messages");
129
+ const retrievalUnitCount = count(db, "retrieval_units");
130
+ const vectorizedUnitCount = count(db, "retrieval_unit_vectors");
131
+ const lastScan = getSetting(db, "last_scan_at");
132
+ const indexedByTool = new Map(db
133
+ .prepare(`SELECT s.tool,
134
+ COUNT(DISTINCT s.session_ref) AS sessions,
135
+ COUNT(m.id) AS messages,
136
+ MAX(m.indexed_at) AS last_indexed_at
137
+ FROM sessions s
138
+ LEFT JOIN messages m ON m.session_ref = s.session_ref
139
+ GROUP BY s.tool`)
140
+ .all().map((row) => [row.tool, row]));
141
+ const tools = await Promise.all(this.tools.map(async ({ tool, scraper }) => {
142
+ const detected = await safeDetect(scraper);
143
+ const indexed = indexedByTool.get(tool);
144
+ return {
145
+ tool,
146
+ detected,
147
+ store_paths: scraper.getStorePaths(),
148
+ indexed_sessions: indexed?.sessions ?? 0,
149
+ indexed_messages: indexed?.messages ?? 0,
150
+ last_indexed_at: indexed?.last_indexed_at ?? null,
151
+ last_error: getSetting(db, `last_error:${tool}`),
152
+ };
153
+ }));
154
+ return {
155
+ project_root: this.projectRoot,
156
+ db_path: this.dbPath,
157
+ last_scan_at: lastScan,
158
+ sessions: sessionCount,
159
+ messages: messageCount,
160
+ retrieval_units: retrievalUnitCount,
161
+ vectorized_units: vectorizedUnitCount,
162
+ vector_model: this.embeddingProvider.model,
163
+ embedding_error: getSetting(db, "last_error:embeddings"),
164
+ tools,
165
+ };
166
+ }
167
+ async close() {
168
+ await this.initialized.catch(() => { });
169
+ this.db?.close();
170
+ this.db = null;
171
+ }
172
+ async refresh(reason) {
173
+ await this.initialized;
174
+ if (reason.statusOnly) {
175
+ return;
176
+ }
177
+ if (Date.now() - this.lastRefreshMs < this.refreshTtlMs) {
178
+ return;
179
+ }
180
+ if (!this.refreshPromise) {
181
+ this.refreshPromise = this.refreshNow().finally(() => {
182
+ // Stamp the TTL on failure as well as success so a persistently
183
+ // broken refresh backs off instead of re-running on every call.
184
+ this.lastRefreshMs = Date.now();
185
+ this.refreshPromise = null;
186
+ });
187
+ }
188
+ await this.refreshPromise;
189
+ }
190
+ async refreshNow() {
191
+ const db = this.getDb();
192
+ const startedAt = new Date().toISOString();
193
+ const touchedSessions = new Set();
194
+ for (const { scraper } of this.tools) {
195
+ if (!(await safeDetect(scraper))) {
196
+ continue;
197
+ }
198
+ let latestTimestamp = null;
199
+ try {
200
+ for await (const chunk of scraper.scrape()) {
201
+ const sessionRef = this.upsertChunk(chunk);
202
+ if (sessionRef) {
203
+ touchedSessions.add(sessionRef);
204
+ }
205
+ if (!latestTimestamp || chunk.timestamp > latestTimestamp) {
206
+ latestTimestamp = chunk.timestamp;
207
+ }
208
+ }
209
+ if (latestTimestamp) {
210
+ await scraper.saveScrapedPosition({
211
+ lastTimestamp: overlapTimestamp(latestTimestamp),
212
+ });
213
+ }
214
+ clearSetting(db, `last_error:${scraper.tool}`);
215
+ }
216
+ catch (error) {
217
+ setSetting(db, `last_error:${scraper.tool}`, error instanceof Error ? error.message : String(error));
218
+ // Deliberately do NOT advance the cursor here: chunks yielded before
219
+ // the failure may sort after content in files never reached, and
220
+ // advancing would skip that content permanently. Re-scraping the
221
+ // same window is safe (message ids are deterministic hashes).
222
+ }
223
+ }
224
+ for (const sessionRef of touchedSessions) {
225
+ // Roll up message_count/preview once per touched session rather than
226
+ // once per inserted message (which made indexing O(N²) per session).
227
+ this.prepared().sessionRollup.run(sessionRef);
228
+ this.rebuildRetrievalUnitsForSession(sessionRef);
229
+ }
230
+ setSetting(db, "last_scan_at", startedAt);
231
+ }
232
+ upsertChunk(chunk) {
233
+ if (!chunk.content.trim()) {
234
+ return null;
235
+ }
236
+ const stmts = this.prepared();
237
+ const timestamp = chunk.timestamp.toISOString();
238
+ const sessionRef = `${chunk.tool}:${chunk.sessionId}`;
239
+ const messageIndex = chunk.metadata.messageIndex ?? 0;
240
+ const id = hashParts([
241
+ chunk.tool,
242
+ chunk.sessionId,
243
+ timestamp,
244
+ chunk.role,
245
+ String(messageIndex),
246
+ chunk.content,
247
+ ]);
248
+ const contentHash = hashParts([chunk.content]);
249
+ const now = new Date().toISOString();
250
+ const metadataJson = JSON.stringify(chunk.metadata ?? {});
251
+ const sourcePointer = sourcePathFromMetadata(chunk.metadata);
252
+ stmts.upsertChunkTxn([
253
+ sessionRef,
254
+ chunk.tool,
255
+ chunk.sessionId,
256
+ this.projectRoot,
257
+ timestamp,
258
+ timestamp,
259
+ sourcePointer,
260
+ now,
261
+ ], [
262
+ id,
263
+ sessionRef,
264
+ chunk.tool,
265
+ chunk.sessionId,
266
+ timestamp,
267
+ chunk.role,
268
+ chunk.content,
269
+ messageIndex,
270
+ contentHash,
271
+ metadataJson,
272
+ sourcePointer,
273
+ now,
274
+ ]);
275
+ return sessionRef;
276
+ }
277
+ rebuildRetrievalUnitsForSession(sessionRef) {
278
+ const db = this.getDb();
279
+ const stmts = this.prepared();
280
+ const messages = stmts.selectSessionMessages.all(sessionRef);
281
+ if (messages.length === 0) {
282
+ db.prepare("DELETE FROM retrieval_units_fts WHERE session_ref = ?").run(sessionRef);
283
+ db.prepare("DELETE FROM retrieval_units WHERE session_ref = ?").run(sessionRef);
284
+ return;
285
+ }
286
+ const session = stmts.selectSessionTool.get(sessionRef);
287
+ if (!session) {
288
+ return;
289
+ }
290
+ // Diff the desired windows against what is stored instead of deleting
291
+ // everything: unit ids are deterministic content hashes, so unchanged
292
+ // windows (and, via the FK, their vectors) survive a re-index untouched.
293
+ const now = new Date().toISOString();
294
+ const desired = new Map();
295
+ for (const window of buildMessageWindows(messages, this.windowSize, this.windowStride)) {
296
+ const content = formatRetrievalUnitContent(sessionRef, window.messages);
297
+ const searchableText = window.messages.map((message) => message.content).join("\n");
298
+ const contentHash = hashParts([content]);
299
+ const unitId = hashParts([
300
+ "retrieval-unit",
301
+ sessionRef,
302
+ String(window.start.message_index),
303
+ String(window.end.message_index),
304
+ contentHash,
305
+ ]);
306
+ desired.set(unitId, {
307
+ start: window.start,
308
+ end: window.end,
309
+ content,
310
+ searchableText,
311
+ contentHash,
312
+ });
313
+ }
314
+ const existing = new Set(stmts.selectUnitIds.all(sessionRef).map((row) => row.id));
315
+ const applyDiff = db.transaction(() => {
316
+ for (const unitId of existing) {
317
+ if (!desired.has(unitId)) {
318
+ stmts.deleteUnitFts.run(unitId);
319
+ stmts.deleteUnit.run(unitId);
320
+ }
321
+ }
322
+ for (const [unitId, unit] of desired) {
323
+ if (existing.has(unitId)) {
324
+ continue;
325
+ }
326
+ stmts.insertUnit.run(unitId, sessionRef, session.tool, unit.start.message_index, unit.end.message_index, unit.start.timestamp, unit.end.timestamp, unit.content, unit.contentHash, now);
327
+ // Only the transcript text is keyword-indexed. `unit.content` also
328
+ // carries the window scaffolding ("Session: …", "Turn 1/8 |
329
+ // message_index=0 | user @ …") that gives the embedding model
330
+ // ordering context; indexing it made `message_index` or a tool name
331
+ // match every session.
332
+ stmts.insertUnitFts.run(unitId, sessionRef, session.tool, unit.searchableText);
333
+ }
334
+ });
335
+ applyDiff();
336
+ }
337
+ prepared() {
338
+ if (this.stmts) {
339
+ return this.stmts;
340
+ }
341
+ const db = this.getDb();
342
+ const upsertSession = db.prepare(`INSERT INTO sessions
343
+ (session_ref, tool, source_session_id, project_root, started_at, last_activity_at,
344
+ message_count, preview, source_path, updated_at)
345
+ VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)
346
+ ON CONFLICT(session_ref) DO UPDATE SET
347
+ started_at = CASE
348
+ WHEN excluded.started_at < started_at THEN excluded.started_at
349
+ ELSE started_at
350
+ END,
351
+ last_activity_at = CASE
352
+ WHEN excluded.last_activity_at > last_activity_at THEN excluded.last_activity_at
353
+ ELSE last_activity_at
354
+ END,
355
+ source_path = COALESCE(source_path, excluded.source_path),
356
+ updated_at = excluded.updated_at`);
357
+ const insertMessage = db.prepare(`INSERT OR IGNORE INTO messages
358
+ (id, session_ref, tool, source_session_id, timestamp, role, content,
359
+ message_index, content_hash, metadata_json, source_pointer, indexed_at)
360
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
361
+ this.stmts = {
362
+ upsertSession,
363
+ insertMessage,
364
+ upsertChunkTxn: db.transaction((sessionArgs, messageArgs) => {
365
+ upsertSession.run(...sessionArgs);
366
+ insertMessage.run(...messageArgs);
367
+ }),
368
+ sessionRollup: db.prepare(`UPDATE sessions
369
+ SET message_count = (
370
+ SELECT COUNT(*) FROM messages WHERE messages.session_ref = sessions.session_ref
371
+ ),
372
+ preview = COALESCE(
373
+ (
374
+ SELECT substr(content, 1, 240)
375
+ FROM messages
376
+ WHERE messages.session_ref = sessions.session_ref
377
+ ORDER BY timestamp ASC, message_index ASC, id ASC
378
+ LIMIT 1
379
+ ),
380
+ preview
381
+ )
382
+ WHERE session_ref = ?`),
383
+ selectSessionMessages: db.prepare(`SELECT id, timestamp, role, content, message_index, source_pointer
384
+ FROM messages
385
+ WHERE session_ref = ?
386
+ ORDER BY timestamp ASC, message_index ASC, id ASC`),
387
+ selectSessionTool: db.prepare("SELECT tool FROM sessions WHERE session_ref = ?"),
388
+ selectUnitIds: db.prepare("SELECT id FROM retrieval_units WHERE session_ref = ?"),
389
+ insertUnit: db.prepare(`INSERT INTO retrieval_units
390
+ (id, session_ref, tool, message_start_index, message_end_index,
391
+ started_at, ended_at, content, content_hash, updated_at)
392
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
393
+ insertUnitFts: db.prepare(`INSERT INTO retrieval_units_fts(unit_id, session_ref, tool, content)
394
+ VALUES (?, ?, ?, ?)`),
395
+ deleteUnit: db.prepare("DELETE FROM retrieval_units WHERE id = ?"),
396
+ deleteUnitFts: db.prepare("DELETE FROM retrieval_units_fts WHERE unit_id = ?"),
397
+ };
398
+ return this.stmts;
399
+ }
400
+ async keywordSearch(query, limit, toolFilter) {
401
+ const rows = this.queryKeywordUnits(query, limit, toolFilter);
402
+ // Rank by BM25 position so relevance, not recency, dominates ordering.
403
+ return groupUnits(rows, rankKeywordRows(rows), "keyword", normalizeLimit(limit, DEFAULT_LIMIT));
404
+ }
405
+ async semanticSearch(query, limit, toolFilter, mode) {
406
+ const normalizedLimit = normalizeLimit(limit, DEFAULT_LIMIT);
407
+ await this.ensureVectors(toolFilter);
408
+ const db = this.getDb();
409
+ const filters = normalizeToolFilter(toolFilter);
410
+ const toolWhere = filters.length > 0 ? `AND u.tool IN (${placeholders(filters.length)})` : "";
411
+ const rows = db
412
+ .prepare(`${retrievalUnitSelect()},
413
+ v.vector,
414
+ v.dimensions
415
+ FROM retrieval_units u
416
+ JOIN retrieval_unit_vectors v ON v.unit_id = u.id
417
+ JOIN sessions s ON s.session_ref = u.session_ref
418
+ WHERE v.model = ? ${toolWhere}`)
419
+ .all(this.embeddingProvider.model, ...filters);
420
+ if (rows.length === 0) {
421
+ return [];
422
+ }
423
+ const keywordRows = mode === "hybrid" ? this.queryKeywordUnits(query, limit, toolFilter) : [];
424
+ const keywordScores = rankKeywordRows(keywordRows);
425
+ const queryVector = await this.embeddingProvider.embed(query);
426
+ const timeRange = getTimeRange(rows.map((row) => row.ended_at));
427
+ const scored = rows
428
+ .map((row) => {
429
+ const rawCosine = cosineSimilarity(queryVector, deserializeVector(row.vector, row.dimensions));
430
+ const semanticScore = normalizeCosine(rawCosine);
431
+ const keywordScore = keywordScores.get(row.unit_id) ?? 0;
432
+ const recencyScore = scoreRecency(row.ended_at, timeRange);
433
+ const continuityScore = scoreContinuity(row.message_end_index, row.session_message_count);
434
+ return {
435
+ row,
436
+ rawCosine,
437
+ score: blendScores(mode, semanticScore, keywordScore, recencyScore, continuityScore),
438
+ semanticScore,
439
+ keywordScore,
440
+ recencyScore,
441
+ continuityScore,
442
+ };
443
+ })
444
+ // Require actual evidence. Cosine normalises to ~0.5 for unrelated
445
+ // content, so recency and continuity alone were enough to return the
446
+ // entire corpus for a query matching nothing — formatted exactly like a
447
+ // real hit. A unit qualifies on semantic similarity or a keyword match;
448
+ // "no matching sessions" is a more useful answer than a nearest vector.
449
+ .filter((item) => item.rawCosine >= MIN_SEMANTIC_COSINE || item.keywordScore > 0)
450
+ .sort((left, right) => right.score - left.score);
451
+ return groupScoredUnits(scored, mode, normalizedLimit);
452
+ }
453
+ queryKeywordUnits(query, limit, toolFilter) {
454
+ const ftsQuery = toFtsQuery(query);
455
+ if (!ftsQuery) {
456
+ return [];
457
+ }
458
+ const db = this.getDb();
459
+ const normalizedLimit = normalizeLimit(limit, DEFAULT_LIMIT);
460
+ const filters = normalizeToolFilter(toolFilter);
461
+ const toolWhere = filters.length > 0 ? `AND u.tool IN (${placeholders(filters.length)})` : "";
462
+ return db
463
+ .prepare(`${retrievalUnitSelect()}
464
+ FROM retrieval_units_fts f
465
+ JOIN retrieval_units u ON u.id = f.unit_id
466
+ JOIN sessions s ON s.session_ref = u.session_ref
467
+ WHERE retrieval_units_fts MATCH ? ${toolWhere}
468
+ ORDER BY bm25(retrieval_units_fts), u.ended_at DESC
469
+ LIMIT ?`)
470
+ .all(ftsQuery, ...filters, normalizedLimit * MAX_MATCHES_PER_SESSION);
471
+ }
472
+ async ensureVectors(toolFilter) {
473
+ const db = this.getDb();
474
+ const filters = normalizeToolFilter(toolFilter);
475
+ const toolWhere = filters.length > 0 ? `AND u.tool IN (${placeholders(filters.length)})` : "";
476
+ const rows = db
477
+ .prepare(`SELECT u.id AS unit_id, u.content, u.content_hash
478
+ FROM retrieval_units u
479
+ LEFT JOIN retrieval_unit_vectors v
480
+ ON v.unit_id = u.id
481
+ AND v.model = ?
482
+ AND v.content_hash = u.content_hash
483
+ WHERE v.unit_id IS NULL ${toolWhere}
484
+ ORDER BY u.ended_at DESC`)
485
+ .all(this.embeddingProvider.model, ...filters);
486
+ if (rows.length === 0) {
487
+ return;
488
+ }
489
+ const upsert = db.prepare(`INSERT INTO retrieval_unit_vectors
490
+ (unit_id, model, dimensions, content_hash, vector, created_at)
491
+ VALUES (?, ?, ?, ?, ?, ?)
492
+ ON CONFLICT(unit_id, model) DO UPDATE SET
493
+ dimensions = excluded.dimensions,
494
+ content_hash = excluded.content_hash,
495
+ vector = excluded.vector,
496
+ created_at = excluded.created_at`);
497
+ // Bounded batches keep memory flat on a first-time index of a large
498
+ // history, and each batch commits before the next one embeds.
499
+ const unitBatchSize = 64;
500
+ for (let start = 0; start < rows.length; start += unitBatchSize) {
501
+ const batch = rows.slice(start, start + unitBatchSize);
502
+ // Long windows are segmented to the model's sequence budget and
503
+ // mean-pooled, so content beyond the window's opening still shapes
504
+ // the unit's vector.
505
+ const segmented = batch.map((row) => splitTextForEmbedding(row.content));
506
+ const segmentVectors = await this.embeddingProvider.embedBatch(segmented.flat());
507
+ let cursor = 0;
508
+ const pooled = segmented.map((segments) => {
509
+ const slice = segmentVectors.slice(cursor, cursor + segments.length);
510
+ cursor += segments.length;
511
+ return poolVectors(slice);
512
+ });
513
+ const now = new Date().toISOString();
514
+ const transaction = db.transaction(() => {
515
+ batch.forEach((row, index) => {
516
+ const vector = pooled[index];
517
+ upsert.run(row.unit_id, this.embeddingProvider.model, vector.length, row.content_hash, serializeVector(vector), now);
518
+ });
519
+ });
520
+ transaction();
521
+ }
522
+ }
523
+ async initialize() {
524
+ await mkdir(dirname(this.dbPath), { recursive: true });
525
+ try {
526
+ this.db = openDatabase(this.dbPath);
527
+ }
528
+ catch {
529
+ // The index is derived data: a corrupt or schema-incompatible database
530
+ // is discarded and rebuilt from the transcript stores on next refresh.
531
+ await this.deleteDatabaseFiles();
532
+ this.db = openDatabase(this.dbPath);
533
+ }
534
+ // One rule covers every way the index can end up empty — deleted by a
535
+ // user (the recovery the docs invite), rebuilt after corruption, or
536
+ // wiped by hand: an empty index cannot honour a scraper cursor, because
537
+ // the cursor would skip straight past history still sitting on disk.
538
+ // On a genuine first run there are no cursor files, so this is a no-op.
539
+ const sessionCount = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get().count;
540
+ if (sessionCount === 0) {
541
+ await this.clearScraperCursors();
542
+ }
543
+ }
544
+ async deleteDatabaseFiles() {
545
+ for (const suffix of ["", "-wal", "-shm"]) {
546
+ await rm(`${this.dbPath}${suffix}`, { force: true });
547
+ }
548
+ }
549
+ /**
550
+ * Drop every scraper's saved position so the next refresh re-scans from the
551
+ * beginning. Called whenever the index is empty; see initialize().
552
+ */
553
+ async clearScraperCursors() {
554
+ const stateDir = dirname(this.dbPath);
555
+ try {
556
+ for (const entry of await readdir(stateDir)) {
557
+ if (entry.endsWith("-state.json")) {
558
+ await rm(join(stateDir, entry), { force: true });
559
+ }
560
+ }
561
+ }
562
+ catch {
563
+ // No state directory yet: nothing to reset.
564
+ }
565
+ }
566
+ getDb() {
567
+ if (!this.db) {
568
+ throw new Error("xtctx handoff index is closed");
569
+ }
570
+ return this.db;
571
+ }
572
+ }
573
+ function openDatabase(dbPath) {
574
+ const db = new Database(dbPath);
575
+ try {
576
+ db.pragma("journal_mode = WAL");
577
+ db.pragma("foreign_keys = ON");
578
+ const objectCount = db.prepare("SELECT COUNT(*) AS count FROM sqlite_master").get().count;
579
+ const version = db.pragma("user_version", { simple: true });
580
+ if (objectCount > 0 && version !== SCHEMA_VERSION) {
581
+ throw new Error(`xtctx index schema version ${version} does not match supported version ${SCHEMA_VERSION}`);
582
+ }
583
+ createSchema(db);
584
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
585
+ return db;
586
+ }
587
+ catch (error) {
588
+ db.close();
589
+ throw error;
590
+ }
591
+ }
592
+ function createSchema(db) {
593
+ db.exec(`
594
+ CREATE TABLE IF NOT EXISTS sessions (
595
+ session_ref TEXT PRIMARY KEY,
596
+ tool TEXT NOT NULL,
597
+ source_session_id TEXT NOT NULL,
598
+ project_root TEXT NOT NULL,
599
+ started_at TEXT NOT NULL,
600
+ last_activity_at TEXT NOT NULL,
601
+ message_count INTEGER NOT NULL DEFAULT 0,
602
+ preview TEXT,
603
+ source_path TEXT,
604
+ updated_at TEXT NOT NULL
605
+ );
606
+
607
+ CREATE TABLE IF NOT EXISTS messages (
608
+ id TEXT PRIMARY KEY,
609
+ session_ref TEXT NOT NULL REFERENCES sessions(session_ref) ON DELETE CASCADE,
610
+ tool TEXT NOT NULL,
611
+ source_session_id TEXT NOT NULL,
612
+ timestamp TEXT NOT NULL,
613
+ role TEXT NOT NULL,
614
+ content TEXT NOT NULL,
615
+ message_index INTEGER NOT NULL,
616
+ content_hash TEXT NOT NULL,
617
+ metadata_json TEXT NOT NULL,
618
+ source_pointer TEXT,
619
+ indexed_at TEXT NOT NULL
620
+ );
621
+
622
+ CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(last_activity_at DESC);
623
+ CREATE INDEX IF NOT EXISTS idx_messages_session_order
624
+ ON messages(session_ref, timestamp, message_index, id);
625
+
626
+ CREATE TABLE IF NOT EXISTS retrieval_units (
627
+ id TEXT PRIMARY KEY,
628
+ session_ref TEXT NOT NULL REFERENCES sessions(session_ref) ON DELETE CASCADE,
629
+ tool TEXT NOT NULL,
630
+ message_start_index INTEGER NOT NULL,
631
+ message_end_index INTEGER NOT NULL,
632
+ started_at TEXT NOT NULL,
633
+ ended_at TEXT NOT NULL,
634
+ content TEXT NOT NULL,
635
+ content_hash TEXT NOT NULL,
636
+ updated_at TEXT NOT NULL
637
+ );
638
+
639
+ CREATE INDEX IF NOT EXISTS idx_retrieval_units_session
640
+ ON retrieval_units(session_ref, message_start_index, message_end_index);
641
+ CREATE INDEX IF NOT EXISTS idx_retrieval_units_tool_time
642
+ ON retrieval_units(tool, ended_at DESC);
643
+
644
+ CREATE VIRTUAL TABLE IF NOT EXISTS retrieval_units_fts
645
+ USING fts5(unit_id UNINDEXED, session_ref UNINDEXED, tool UNINDEXED, content);
646
+
647
+ CREATE TABLE IF NOT EXISTS retrieval_unit_vectors (
648
+ unit_id TEXT NOT NULL REFERENCES retrieval_units(id) ON DELETE CASCADE,
649
+ model TEXT NOT NULL,
650
+ dimensions INTEGER NOT NULL,
651
+ content_hash TEXT NOT NULL,
652
+ vector BLOB NOT NULL,
653
+ created_at TEXT NOT NULL,
654
+ PRIMARY KEY (unit_id, model)
655
+ );
656
+
657
+ CREATE INDEX IF NOT EXISTS idx_retrieval_unit_vectors_model
658
+ ON retrieval_unit_vectors(model);
659
+
660
+ CREATE TABLE IF NOT EXISTS settings (
661
+ key TEXT PRIMARY KEY,
662
+ value TEXT NOT NULL
663
+ );
664
+ `);
665
+ }
666
+ function retrievalUnitSelect() {
667
+ return `SELECT u.id AS unit_id,
668
+ u.session_ref,
669
+ u.tool,
670
+ u.message_start_index,
671
+ u.message_end_index,
672
+ u.started_at,
673
+ u.ended_at,
674
+ u.content,
675
+ u.content_hash,
676
+ s.started_at AS session_started_at,
677
+ s.last_activity_at AS session_last_activity_at,
678
+ s.message_count AS session_message_count,
679
+ s.preview AS session_preview,
680
+ s.source_path`;
681
+ }
682
+ function formatSessionRow(row) {
683
+ return {
684
+ session_ref: row.session_ref,
685
+ tool: row.tool,
686
+ started_at: row.started_at,
687
+ last_activity_at: row.last_activity_at,
688
+ message_count: row.message_count,
689
+ preview: row.preview ?? undefined,
690
+ source_path: row.source_path ?? undefined,
691
+ };
692
+ }
693
+ function buildMessageWindows(messages, windowSize, windowStride) {
694
+ const windows = [];
695
+ for (let start = 0; start < messages.length; start += windowStride) {
696
+ const slice = messages.slice(start, start + windowSize);
697
+ if (slice.length === 0) {
698
+ continue;
699
+ }
700
+ windows.push({
701
+ start: slice[0],
702
+ end: slice[slice.length - 1],
703
+ messages: slice,
704
+ });
705
+ if (start + windowSize >= messages.length) {
706
+ break;
707
+ }
708
+ }
709
+ return windows;
710
+ }
711
+ function formatRetrievalUnitContent(sessionRef, messages) {
712
+ const lines = [
713
+ `Session: ${sessionRef}`,
714
+ `Chronological window: messages ${messages[0].message_index} through ${messages[messages.length - 1].message_index}`,
715
+ ];
716
+ for (const [index, message] of messages.entries()) {
717
+ lines.push([
718
+ `Turn ${index + 1}/${messages.length}`,
719
+ `message_index=${message.message_index}`,
720
+ `${message.role} @ ${message.timestamp}`,
721
+ ].join(" | "));
722
+ lines.push(message.content);
723
+ }
724
+ return lines.join("\n");
725
+ }
726
+ function groupUnits(rows, keywordScores, retrieval, limit) {
727
+ const timeRange = getTimeRange(rows.map((row) => row.ended_at));
728
+ const scored = rows.map((row) => {
729
+ const keywordScore = keywordScores.get(row.unit_id) ?? 0;
730
+ const recencyScore = scoreRecency(row.ended_at, timeRange);
731
+ const continuityScore = scoreContinuity(row.message_end_index, row.session_message_count);
732
+ return {
733
+ row,
734
+ score: blendScores("keyword", 0, keywordScore, recencyScore, continuityScore),
735
+ semanticScore: 0,
736
+ keywordScore,
737
+ recencyScore,
738
+ continuityScore,
739
+ };
740
+ });
741
+ return groupScoredUnits(scored, retrieval, limit);
742
+ }
743
+ function groupScoredUnits(scored, retrieval, limit) {
744
+ const sessions = new Map();
745
+ for (const item of scored) {
746
+ const existing = sessions.get(item.row.session_ref);
747
+ const match = formatMatch(item);
748
+ if (existing) {
749
+ if ((existing.matches?.length ?? 0) < MAX_MATCHES_PER_SESSION) {
750
+ existing.matches = [...(existing.matches ?? []), match];
751
+ }
752
+ existing.score = Math.max(existing.score ?? 0, item.score);
753
+ continue;
754
+ }
755
+ sessions.set(item.row.session_ref, {
756
+ session_ref: item.row.session_ref,
757
+ tool: item.row.tool,
758
+ started_at: item.row.session_started_at,
759
+ last_activity_at: item.row.session_last_activity_at,
760
+ message_count: item.row.session_message_count,
761
+ preview: item.row.session_preview ?? previewText(item.row.content),
762
+ source_path: item.row.source_path ?? undefined,
763
+ score: item.score,
764
+ retrieval,
765
+ matches: [match],
766
+ });
767
+ if (sessions.size >= limit) {
768
+ break;
769
+ }
770
+ }
771
+ return [...sessions.values()].sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
772
+ }
773
+ function formatMatch(item) {
774
+ return {
775
+ unit_id: item.row.unit_id,
776
+ message_start_index: item.row.message_start_index,
777
+ message_end_index: item.row.message_end_index,
778
+ started_at: item.row.started_at,
779
+ ended_at: item.row.ended_at,
780
+ preview: previewText(item.row.content),
781
+ score: roundScore(item.score),
782
+ semantic_score: roundScore(item.semanticScore),
783
+ keyword_score: roundScore(item.keywordScore),
784
+ recency_score: roundScore(item.recencyScore),
785
+ continuity_score: roundScore(item.continuityScore),
786
+ };
787
+ }
788
+ function rankKeywordRows(rows) {
789
+ const scores = new Map();
790
+ rows.forEach((row, index) => {
791
+ scores.set(row.unit_id, 1 / (index + 1));
792
+ });
793
+ return scores;
794
+ }
795
+ function blendScores(mode, semanticScore, keywordScore, recencyScore, continuityScore) {
796
+ if (mode === "vector") {
797
+ return 0.85 * semanticScore + 0.1 * recencyScore + 0.05 * continuityScore;
798
+ }
799
+ if (mode === "keyword") {
800
+ return 0.75 * keywordScore + 0.15 * recencyScore + 0.1 * continuityScore;
801
+ }
802
+ return (0.65 * semanticScore +
803
+ 0.2 * keywordScore +
804
+ 0.1 * recencyScore +
805
+ 0.05 * continuityScore);
806
+ }
807
+ function normalizeCosine(score) {
808
+ return Math.max(0, Math.min(1, (score + 1) / 2));
809
+ }
810
+ function scoreContinuity(messageEndIndex, messageCount) {
811
+ if (messageCount <= 1) {
812
+ return 1;
813
+ }
814
+ return Math.max(0, Math.min(1, messageEndIndex / (messageCount - 1)));
815
+ }
816
+ function scoreRecency(value, range) {
817
+ const timestamp = Date.parse(value);
818
+ if (!Number.isFinite(timestamp) || range.newest <= range.oldest) {
819
+ return 1;
820
+ }
821
+ return Math.max(0, Math.min(1, (timestamp - range.oldest) / (range.newest - range.oldest)));
822
+ }
823
+ function getTimeRange(values) {
824
+ const timestamps = values
825
+ .map((value) => Date.parse(value))
826
+ .filter((value) => Number.isFinite(value));
827
+ if (timestamps.length === 0) {
828
+ return { oldest: 0, newest: 0 };
829
+ }
830
+ return {
831
+ oldest: Math.min(...timestamps),
832
+ newest: Math.max(...timestamps),
833
+ };
834
+ }
835
+ function previewText(value) {
836
+ return value.replace(/\s+/g, " ").trim().slice(0, 240);
837
+ }
838
+ function roundScore(value) {
839
+ return Math.round(value * 1000) / 1000;
840
+ }
841
+ function normalizeLimit(value, fallback) {
842
+ if (!Number.isFinite(value) || value <= 0) {
843
+ return fallback;
844
+ }
845
+ return Math.min(Math.floor(value), MAX_LIMIT);
846
+ }
847
+ function normalizeSearchMode(value) {
848
+ return value === "keyword" || value === "vector" || value === "hybrid" ? value : "hybrid";
849
+ }
850
+ function normalizeToolFilter(value) {
851
+ if (!Array.isArray(value)) {
852
+ return [];
853
+ }
854
+ return [...new Set(value.filter((item) => typeof item === "string" && item.length > 0))];
855
+ }
856
+ function toFtsQuery(query) {
857
+ return (query
858
+ .toLowerCase()
859
+ .match(/[a-z0-9_./:-]{2,}/g)
860
+ ?.map((term) => `"${term.replace(/"/g, "\"\"")}"`)
861
+ .join(" OR ") ?? "");
862
+ }
863
+ function placeholders(countValue) {
864
+ return Array.from({ length: countValue }, () => "?").join(", ");
865
+ }
866
+ function count(db, table) {
867
+ const row = db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get();
868
+ return row.count;
869
+ }
870
+ function getSetting(db, key) {
871
+ const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
872
+ return row?.value ?? null;
873
+ }
874
+ function setSetting(db, key, value) {
875
+ db.prepare(`INSERT INTO settings(key, value)
876
+ VALUES (?, ?)
877
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
878
+ }
879
+ function clearSetting(db, key) {
880
+ db.prepare("DELETE FROM settings WHERE key = ?").run(key);
881
+ }
882
+ function sourcePathFromMetadata(metadata) {
883
+ const value = metadata.sourcePath;
884
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
885
+ }
886
+ function overlapTimestamp(value) {
887
+ return new Date(Math.max(0, value.getTime() - SOURCE_CURSOR_OVERLAP_MS));
888
+ }
889
+ function hashParts(parts) {
890
+ const hash = createHash("sha256");
891
+ for (const part of parts) {
892
+ hash.update(part);
893
+ hash.update("\0");
894
+ }
895
+ return hash.digest("hex");
896
+ }
897
+ async function safeDetect(scraper) {
898
+ try {
899
+ return await scraper.detect();
900
+ }
901
+ catch {
902
+ return false;
903
+ }
904
+ }
905
+ //# sourceMappingURL=sqlite-index.js.map