@jigyasudham/veto 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +630 -589
  3. package/dist/agents/llm-runner.js +57 -57
  4. package/dist/cli/statusline.js +2 -2
  5. package/dist/cli.js +57 -26
  6. package/dist/cli.js.map +1 -1
  7. package/dist/memory/decisions.js +1 -1
  8. package/dist/memory/local.js +144 -144
  9. package/dist/memory/schema.js +228 -228
  10. package/dist/memory/sync.js +25 -25
  11. package/dist/router/implicit-outcomes.js +4 -4
  12. package/dist/router/learning-updater.js +45 -45
  13. package/dist/server.js +1 -1
  14. package/dist/tools/compact.js +3 -3
  15. package/dist/transcripts/archive.js +5 -5
  16. package/dist/transcripts/cache.d.ts +66 -0
  17. package/dist/transcripts/cache.d.ts.map +1 -0
  18. package/dist/transcripts/cache.js +0 -0
  19. package/dist/transcripts/cache.js.map +1 -0
  20. package/dist/transcripts/chunk.d.ts +26 -0
  21. package/dist/transcripts/chunk.d.ts.map +1 -0
  22. package/dist/transcripts/chunk.js +54 -0
  23. package/dist/transcripts/chunk.js.map +1 -0
  24. package/dist/transcripts/embed.d.ts +58 -0
  25. package/dist/transcripts/embed.d.ts.map +1 -0
  26. package/dist/transcripts/embed.js +303 -0
  27. package/dist/transcripts/embed.js.map +1 -0
  28. package/dist/transcripts/ingest.d.ts.map +1 -1
  29. package/dist/transcripts/ingest.js +14 -7
  30. package/dist/transcripts/ingest.js.map +1 -1
  31. package/dist/transcripts/manage.d.ts.map +1 -1
  32. package/dist/transcripts/manage.js +6 -0
  33. package/dist/transcripts/manage.js.map +1 -1
  34. package/dist/transcripts/mapping.js +5 -5
  35. package/dist/transcripts/pyramid.js +2 -2
  36. package/dist/transcripts/recall.d.ts +7 -0
  37. package/dist/transcripts/recall.d.ts.map +1 -1
  38. package/dist/transcripts/recall.js +25 -3
  39. package/dist/transcripts/recall.js.map +1 -1
  40. package/dist/transcripts/schema.d.ts +11 -1
  41. package/dist/transcripts/schema.d.ts.map +1 -1
  42. package/dist/transcripts/schema.js +126 -94
  43. package/dist/transcripts/schema.js.map +1 -1
  44. package/dist/transcripts/search.d.ts +35 -0
  45. package/dist/transcripts/search.d.ts.map +1 -1
  46. package/dist/transcripts/search.js +232 -34
  47. package/dist/transcripts/search.js.map +1 -1
  48. package/dist/transcripts/vectors.d.ts +40 -0
  49. package/dist/transcripts/vectors.d.ts.map +1 -0
  50. package/dist/transcripts/vectors.js +203 -0
  51. package/dist/transcripts/vectors.js.map +1 -0
  52. package/package.json +70 -61
  53. package/server.json +23 -23
  54. package/dist/skills/development/skill-api-design.d.ts +0 -15
  55. package/dist/skills/development/skill-api-design.d.ts.map +0 -1
  56. package/dist/skills/development/skill-api-design.js +0 -313
  57. package/dist/skills/development/skill-api-design.js.map +0 -1
  58. package/dist/skills/development/skill-auth.d.ts +0 -15
  59. package/dist/skills/development/skill-auth.d.ts.map +0 -1
  60. package/dist/skills/development/skill-auth.js +0 -255
  61. package/dist/skills/development/skill-auth.js.map +0 -1
  62. package/dist/skills/development/skill-ci-cd.d.ts +0 -2
  63. package/dist/skills/development/skill-ci-cd.d.ts.map +0 -1
  64. package/dist/skills/development/skill-ci-cd.js +0 -2
  65. package/dist/skills/development/skill-ci-cd.js.map +0 -1
  66. package/dist/skills/development/skill-crud.d.ts +0 -15
  67. package/dist/skills/development/skill-crud.d.ts.map +0 -1
  68. package/dist/skills/development/skill-crud.js +0 -193
  69. package/dist/skills/development/skill-crud.js.map +0 -1
  70. package/dist/skills/development/skill-db-schema.d.ts +0 -2
  71. package/dist/skills/development/skill-db-schema.d.ts.map +0 -1
  72. package/dist/skills/development/skill-db-schema.js +0 -2
  73. package/dist/skills/development/skill-db-schema.js.map +0 -1
  74. package/dist/skills/development/skill-docker.d.ts +0 -2
  75. package/dist/skills/development/skill-docker.d.ts.map +0 -1
  76. package/dist/skills/development/skill-docker.js +0 -2
  77. package/dist/skills/development/skill-docker.js.map +0 -1
  78. package/dist/skills/development/skill-env-setup.d.ts +0 -2
  79. package/dist/skills/development/skill-env-setup.d.ts.map +0 -1
  80. package/dist/skills/development/skill-env-setup.js +0 -2
  81. package/dist/skills/development/skill-env-setup.js.map +0 -1
  82. package/dist/skills/development/skill-scaffold.d.ts +0 -15
  83. package/dist/skills/development/skill-scaffold.d.ts.map +0 -1
  84. package/dist/skills/development/skill-scaffold.js +0 -299
  85. package/dist/skills/development/skill-scaffold.js.map +0 -1
  86. package/dist/skills/intelligence/skill-complexity-score.d.ts +0 -15
  87. package/dist/skills/intelligence/skill-complexity-score.d.ts.map +0 -1
  88. package/dist/skills/intelligence/skill-complexity-score.js +0 -66
  89. package/dist/skills/intelligence/skill-complexity-score.js.map +0 -1
  90. package/dist/skills/intelligence/skill-cost-track.d.ts +0 -14
  91. package/dist/skills/intelligence/skill-cost-track.d.ts.map +0 -1
  92. package/dist/skills/intelligence/skill-cost-track.js +0 -36
  93. package/dist/skills/intelligence/skill-cost-track.js.map +0 -1
  94. package/dist/skills/intelligence/skill-learning-loop.d.ts +0 -15
  95. package/dist/skills/intelligence/skill-learning-loop.d.ts.map +0 -1
  96. package/dist/skills/intelligence/skill-learning-loop.js +0 -66
  97. package/dist/skills/intelligence/skill-learning-loop.js.map +0 -1
  98. package/dist/skills/intelligence/skill-pattern-detect.d.ts +0 -14
  99. package/dist/skills/intelligence/skill-pattern-detect.d.ts.map +0 -1
  100. package/dist/skills/intelligence/skill-pattern-detect.js +0 -35
  101. package/dist/skills/intelligence/skill-pattern-detect.js.map +0 -1
  102. package/dist/skills/intelligence/skill-rate-watch.d.ts +0 -15
  103. package/dist/skills/intelligence/skill-rate-watch.d.ts.map +0 -1
  104. package/dist/skills/intelligence/skill-rate-watch.js +0 -58
  105. package/dist/skills/intelligence/skill-rate-watch.js.map +0 -1
  106. package/dist/skills/memory/skill-context-compress.d.ts +0 -15
  107. package/dist/skills/memory/skill-context-compress.d.ts.map +0 -1
  108. package/dist/skills/memory/skill-context-compress.js +0 -82
  109. package/dist/skills/memory/skill-context-compress.js.map +0 -1
  110. package/dist/skills/memory/skill-cross-sync.d.ts +0 -15
  111. package/dist/skills/memory/skill-cross-sync.d.ts.map +0 -1
  112. package/dist/skills/memory/skill-cross-sync.js +0 -88
  113. package/dist/skills/memory/skill-cross-sync.js.map +0 -1
  114. package/dist/skills/memory/skill-decision-log.d.ts +0 -15
  115. package/dist/skills/memory/skill-decision-log.d.ts.map +0 -1
  116. package/dist/skills/memory/skill-decision-log.js +0 -103
  117. package/dist/skills/memory/skill-decision-log.js.map +0 -1
  118. package/dist/skills/memory/skill-session-restore.d.ts +0 -15
  119. package/dist/skills/memory/skill-session-restore.d.ts.map +0 -1
  120. package/dist/skills/memory/skill-session-restore.js +0 -39
  121. package/dist/skills/memory/skill-session-restore.js.map +0 -1
  122. package/dist/skills/memory/skill-session-save.d.ts +0 -15
  123. package/dist/skills/memory/skill-session-save.d.ts.map +0 -1
  124. package/dist/skills/memory/skill-session-save.js +0 -74
  125. package/dist/skills/memory/skill-session-save.js.map +0 -1
  126. package/dist/skills/quality/skill-accessibility.d.ts +0 -2
  127. package/dist/skills/quality/skill-accessibility.d.ts.map +0 -1
  128. package/dist/skills/quality/skill-accessibility.js +0 -2
  129. package/dist/skills/quality/skill-accessibility.js.map +0 -1
  130. package/dist/skills/quality/skill-code-review.d.ts +0 -15
  131. package/dist/skills/quality/skill-code-review.d.ts.map +0 -1
  132. package/dist/skills/quality/skill-code-review.js +0 -60
  133. package/dist/skills/quality/skill-code-review.js.map +0 -1
  134. package/dist/skills/quality/skill-docs-gen.d.ts +0 -2
  135. package/dist/skills/quality/skill-docs-gen.d.ts.map +0 -1
  136. package/dist/skills/quality/skill-docs-gen.js +0 -2
  137. package/dist/skills/quality/skill-docs-gen.js.map +0 -1
  138. package/dist/skills/quality/skill-perf-audit.d.ts +0 -2
  139. package/dist/skills/quality/skill-perf-audit.d.ts.map +0 -1
  140. package/dist/skills/quality/skill-perf-audit.js +0 -2
  141. package/dist/skills/quality/skill-perf-audit.js.map +0 -1
  142. package/dist/skills/quality/skill-security-scan.d.ts +0 -15
  143. package/dist/skills/quality/skill-security-scan.d.ts.map +0 -1
  144. package/dist/skills/quality/skill-security-scan.js +0 -67
  145. package/dist/skills/quality/skill-security-scan.js.map +0 -1
  146. package/dist/skills/quality/skill-test-suite.d.ts +0 -15
  147. package/dist/skills/quality/skill-test-suite.d.ts.map +0 -1
  148. package/dist/skills/quality/skill-test-suite.js +0 -274
  149. package/dist/skills/quality/skill-test-suite.js.map +0 -1
  150. package/dist/skills/workflow/skill-deploy.d.ts +0 -2
  151. package/dist/skills/workflow/skill-deploy.d.ts.map +0 -1
  152. package/dist/skills/workflow/skill-deploy.js +0 -2
  153. package/dist/skills/workflow/skill-deploy.js.map +0 -1
  154. package/dist/skills/workflow/skill-git-workflow.d.ts +0 -2
  155. package/dist/skills/workflow/skill-git-workflow.d.ts.map +0 -1
  156. package/dist/skills/workflow/skill-git-workflow.js +0 -2
  157. package/dist/skills/workflow/skill-git-workflow.js.map +0 -1
  158. package/dist/skills/workflow/skill-rollback.d.ts +0 -2
  159. package/dist/skills/workflow/skill-rollback.d.ts.map +0 -1
  160. package/dist/skills/workflow/skill-rollback.js +0 -2
  161. package/dist/skills/workflow/skill-rollback.js.map +0 -1
  162. package/dist/skills/workflow/skill-task-breakdown.d.ts +0 -2
  163. package/dist/skills/workflow/skill-task-breakdown.d.ts.map +0 -1
  164. package/dist/skills/workflow/skill-task-breakdown.js +0 -2
  165. package/dist/skills/workflow/skill-task-breakdown.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/transcripts/schema.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,0BAA0B,IAAI,CAAC;AAG5C,eAAO,MAAM,WAAW,4GAEd,CAAC;AACX,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAErD,MAAM,MAAM,SAAS,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAExD,eAAO,MAAM,UAAU,EAAE,SAAS,EA+HjC,CAAC;AAIF,eAAO,MAAM,gBAAgB,EAAE,SAAS,EAAsE,CAAC;AAE/G,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,cAAc,EAAE,MAAM,CAAC;IACvB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,SAAS,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/transcripts/schema.ts"],"names":[],"mappings":"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"}
@@ -7,7 +7,7 @@
7
7
  // rather than editing an existing one, so the schema grows without churn and
8
8
  // re-open is idempotent.
9
9
  // Highest migration version defined below.
10
- export const TRANSCRIPTS_SCHEMA_VERSION = 3;
10
+ export const TRANSCRIPTS_SCHEMA_VERSION = 4;
11
11
  // The common vocabulary every source normalizes into.
12
12
  export const EVENT_KINDS = [
13
13
  'user_message', 'assistant_message', 'tool_call', 'tool_result', 'reasoning', 'meta', 'unknown',
@@ -17,39 +17,39 @@ export const MIGRATIONS = [
17
17
  // v1 — the two foundational anchor tables. Events / segments / FTS get their
18
18
  // own later migrations (Steps 6/9/10) so we don't big-bang the schema.
19
19
  version: 1,
20
- up: `
21
- -- L0 archive index: one row per captured source-CLI session.
22
- CREATE TABLE IF NOT EXISTS archives (
23
- id TEXT PRIMARY KEY,
24
- source TEXT NOT NULL DEFAULT 'claude', -- claude|codex|gemini
25
- source_session_id TEXT NOT NULL,
26
- project_dir TEXT, -- normalized (lowercase drive)
27
- veto_session_id TEXT, -- link to veto.db sessions.id when known
28
- archive_path TEXT NOT NULL, -- absolute path to the .gz on disk
29
- content_sha256 TEXT NOT NULL, -- of raw source bytes (dedup key)
30
- source_bytes INTEGER NOT NULL DEFAULT 0, -- uncompressed size
31
- archive_bytes INTEGER NOT NULL DEFAULT 0, -- gz size on disk
32
- source_format_hint TEXT, -- e.g. 'claude-jsonl'
33
- parser_version INTEGER NOT NULL DEFAULT 0,
34
- indexed_through_seq INTEGER NOT NULL DEFAULT 0, -- lazy-index watermark
35
- captured_at TEXT NOT NULL,
36
- updated_at TEXT NOT NULL,
37
- UNIQUE(source, source_session_id)
38
- );
39
- CREATE INDEX IF NOT EXISTS idx_archives_project ON archives(project_dir);
40
- CREATE INDEX IF NOT EXISTS idx_archives_sha ON archives(content_sha256);
41
-
42
- -- Live host-session -> transcript-file mapping, UPSERTed fire-and-forget by
43
- -- the statusline (Step 4) so save-time capture knows which file to read.
44
- CREATE TABLE IF NOT EXISTS session_map (
45
- source TEXT NOT NULL DEFAULT 'claude',
46
- source_session_id TEXT NOT NULL,
47
- transcript_path TEXT NOT NULL,
48
- project_dir TEXT,
49
- last_seen_at TEXT NOT NULL,
50
- PRIMARY KEY (source, source_session_id)
51
- );
52
- CREATE INDEX IF NOT EXISTS idx_session_map_project ON session_map(project_dir);
20
+ up: `
21
+ -- L0 archive index: one row per captured source-CLI session.
22
+ CREATE TABLE IF NOT EXISTS archives (
23
+ id TEXT PRIMARY KEY,
24
+ source TEXT NOT NULL DEFAULT 'claude', -- claude|codex|gemini
25
+ source_session_id TEXT NOT NULL,
26
+ project_dir TEXT, -- normalized (lowercase drive)
27
+ veto_session_id TEXT, -- link to veto.db sessions.id when known
28
+ archive_path TEXT NOT NULL, -- absolute path to the .gz on disk
29
+ content_sha256 TEXT NOT NULL, -- of raw source bytes (dedup key)
30
+ source_bytes INTEGER NOT NULL DEFAULT 0, -- uncompressed size
31
+ archive_bytes INTEGER NOT NULL DEFAULT 0, -- gz size on disk
32
+ source_format_hint TEXT, -- e.g. 'claude-jsonl'
33
+ parser_version INTEGER NOT NULL DEFAULT 0,
34
+ indexed_through_seq INTEGER NOT NULL DEFAULT 0, -- lazy-index watermark
35
+ captured_at TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL,
37
+ UNIQUE(source, source_session_id)
38
+ );
39
+ CREATE INDEX IF NOT EXISTS idx_archives_project ON archives(project_dir);
40
+ CREATE INDEX IF NOT EXISTS idx_archives_sha ON archives(content_sha256);
41
+
42
+ -- Live host-session -> transcript-file mapping, UPSERTed fire-and-forget by
43
+ -- the statusline (Step 4) so save-time capture knows which file to read.
44
+ CREATE TABLE IF NOT EXISTS session_map (
45
+ source TEXT NOT NULL DEFAULT 'claude',
46
+ source_session_id TEXT NOT NULL,
47
+ transcript_path TEXT NOT NULL,
48
+ project_dir TEXT,
49
+ last_seen_at TEXT NOT NULL,
50
+ PRIMARY KEY (source, source_session_id)
51
+ );
52
+ CREATE INDEX IF NOT EXISTS idx_session_map_project ON session_map(project_dir);
53
53
  `,
54
54
  },
55
55
  {
@@ -58,33 +58,33 @@ export const MIGRATIONS = [
58
58
  // UNCOMPRESSED L0 bytes for exact expansion (Step 11). Unknown/unparsed lines
59
59
  // are stored+ordered (kind='unknown') so format drift never loses data.
60
60
  version: 2,
61
- up: `
62
- CREATE TABLE IF NOT EXISTS events (
63
- id TEXT PRIMARY KEY,
64
- archive_id TEXT NOT NULL,
65
- source_session_id TEXT NOT NULL,
66
- seq INTEGER NOT NULL, -- monotonic per archive, per event
67
- line_index INTEGER NOT NULL, -- source JSONL line (0-based)
68
- block_index INTEGER NOT NULL DEFAULT 0,
69
- kind TEXT NOT NULL, -- EVENT_KINDS
70
- source_type TEXT, -- verbatim source 'type' (user/assistant/system/…)
71
- role TEXT,
72
- tool_name TEXT, -- for tool_call/tool_result
73
- text TEXT, -- normalized + MASKED content for FTS
74
- secret_count INTEGER NOT NULL DEFAULT 0,
75
- event_uuid TEXT, -- native uuid (Claude)
76
- parent_uuid TEXT, -- native parentUuid (tree)
77
- is_sidechain INTEGER NOT NULL DEFAULT 0,
78
- ts_source TEXT, -- verbatim timestamp string
79
- ts_utc TEXT, -- normalized ISO
80
- raw_offset INTEGER NOT NULL DEFAULT 0,
81
- raw_length INTEGER NOT NULL DEFAULT 0,
82
- UNIQUE(archive_id, seq),
83
- FOREIGN KEY (archive_id) REFERENCES archives(id)
84
- );
85
- CREATE INDEX IF NOT EXISTS idx_events_archive ON events(archive_id, seq);
86
- CREATE INDEX IF NOT EXISTS idx_events_session ON events(source_session_id);
87
- CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
61
+ up: `
62
+ CREATE TABLE IF NOT EXISTS events (
63
+ id TEXT PRIMARY KEY,
64
+ archive_id TEXT NOT NULL,
65
+ source_session_id TEXT NOT NULL,
66
+ seq INTEGER NOT NULL, -- monotonic per archive, per event
67
+ line_index INTEGER NOT NULL, -- source JSONL line (0-based)
68
+ block_index INTEGER NOT NULL DEFAULT 0,
69
+ kind TEXT NOT NULL, -- EVENT_KINDS
70
+ source_type TEXT, -- verbatim source 'type' (user/assistant/system/…)
71
+ role TEXT,
72
+ tool_name TEXT, -- for tool_call/tool_result
73
+ text TEXT, -- normalized + MASKED content for FTS
74
+ secret_count INTEGER NOT NULL DEFAULT 0,
75
+ event_uuid TEXT, -- native uuid (Claude)
76
+ parent_uuid TEXT, -- native parentUuid (tree)
77
+ is_sidechain INTEGER NOT NULL DEFAULT 0,
78
+ ts_source TEXT, -- verbatim timestamp string
79
+ ts_utc TEXT, -- normalized ISO
80
+ raw_offset INTEGER NOT NULL DEFAULT 0,
81
+ raw_length INTEGER NOT NULL DEFAULT 0,
82
+ UNIQUE(archive_id, seq),
83
+ FOREIGN KEY (archive_id) REFERENCES archives(id)
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_events_archive ON events(archive_id, seq);
86
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(source_session_id);
87
+ CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
88
88
  `,
89
89
  },
90
90
  {
@@ -104,39 +104,71 @@ export const MIGRATIONS = [
104
104
  // loads its module, which throws on exactly the Nodes this redo supports.
105
105
  // Databases that ran the old dev-only v3 keep an inert orphan table.
106
106
  version: 3,
107
- up: `
108
- -- One row per indexed event. project_dir/session denormalized so the
109
- -- hot query is a single join (same shape events_fts had). The INTEGER id
110
- -- exists so postings never carry a 36-char uuid — measured, that uuid
111
- -- (stored twice per posting: PK + purge index) was ~3x the entire
112
- -- index's justified size.
113
- CREATE TABLE IF NOT EXISTS search_docs (
114
- id INTEGER PRIMARY KEY,
115
- event_id TEXT NOT NULL UNIQUE,
116
- archive_id TEXT NOT NULL,
117
- source_session_id TEXT NOT NULL,
118
- project_dir TEXT,
119
- seq INTEGER NOT NULL,
120
- kind TEXT NOT NULL,
121
- len INTEGER NOT NULL -- token count, for BM25 length norm
122
- );
123
- CREATE INDEX IF NOT EXISTS idx_search_docs_archive ON search_docs(archive_id);
124
- CREATE INDEX IF NOT EXISTS idx_search_docs_scope
125
- ON search_docs(project_dir, source_session_id);
126
-
127
- -- Term dictionary: term text stored once, compact integer join key.
128
- CREATE TABLE IF NOT EXISTS search_terms (
129
- id INTEGER PRIMARY KEY,
130
- term TEXT NOT NULL UNIQUE
131
- );
132
-
133
- CREATE TABLE IF NOT EXISTS search_postings (
134
- term_id INTEGER NOT NULL,
135
- doc_id INTEGER NOT NULL,
136
- tf INTEGER NOT NULL,
137
- PRIMARY KEY (term_id, doc_id)
138
- ) WITHOUT ROWID;
139
- CREATE INDEX IF NOT EXISTS idx_postings_doc ON search_postings(doc_id);
107
+ up: `
108
+ -- One row per indexed event. project_dir/session denormalized so the
109
+ -- hot query is a single join (same shape events_fts had). The INTEGER id
110
+ -- exists so postings never carry a 36-char uuid — measured, that uuid
111
+ -- (stored twice per posting: PK + purge index) was ~3x the entire
112
+ -- index's justified size.
113
+ CREATE TABLE IF NOT EXISTS search_docs (
114
+ id INTEGER PRIMARY KEY,
115
+ event_id TEXT NOT NULL UNIQUE,
116
+ archive_id TEXT NOT NULL,
117
+ source_session_id TEXT NOT NULL,
118
+ project_dir TEXT,
119
+ seq INTEGER NOT NULL,
120
+ kind TEXT NOT NULL,
121
+ len INTEGER NOT NULL -- token count, for BM25 length norm
122
+ );
123
+ CREATE INDEX IF NOT EXISTS idx_search_docs_archive ON search_docs(archive_id);
124
+ CREATE INDEX IF NOT EXISTS idx_search_docs_scope
125
+ ON search_docs(project_dir, source_session_id);
126
+
127
+ -- Term dictionary: term text stored once, compact integer join key.
128
+ CREATE TABLE IF NOT EXISTS search_terms (
129
+ id INTEGER PRIMARY KEY,
130
+ term TEXT NOT NULL UNIQUE
131
+ );
132
+
133
+ CREATE TABLE IF NOT EXISTS search_postings (
134
+ term_id INTEGER NOT NULL,
135
+ doc_id INTEGER NOT NULL,
136
+ tf INTEGER NOT NULL,
137
+ PRIMARY KEY (term_id, doc_id)
138
+ ) WITHOUT ROWID;
139
+ CREATE INDEX IF NOT EXISTS idx_postings_doc ON search_postings(doc_id);
140
+ `,
141
+ },
142
+ {
143
+ // v4 — chunk vectors for the semantic layer (Phase B).
144
+ //
145
+ // One row per WINDOW, not per event: whole-event vectors fail the
146
+ // paraphrase gate to mean-pooling dilution (spec §11 amendment). Keyed by
147
+ // the same INTEGER doc_id the postings use — storing the 36-char event
148
+ // uuid here would repeat the mistake that made the lexical index 3x too
149
+ // big. Chunks are internal (C6): nothing outside search.ts sees them.
150
+ //
151
+ // No `scale` column: cosine similarity is invariant to a positive
152
+ // per-vector scalar, so the dequantization scale cancels entirely. The
153
+ // stored `norm` is the L2 norm of the int8 vector itself, precomputed so a
154
+ // query is one dot product and one divide.
155
+ //
156
+ // Vectors live in their own lifecycle: `archives.chunker_version` and
157
+ // `archives.embed_model` gate re-derivation independently of
158
+ // parser_version, so ingest never depends on the model being installed and
159
+ // installing it later backfills without re-parsing anything.
160
+ version: 4,
161
+ up: `
162
+ CREATE TABLE IF NOT EXISTS search_vectors (
163
+ doc_id INTEGER NOT NULL,
164
+ chunk_index INTEGER NOT NULL,
165
+ vec BLOB NOT NULL, -- int8, one byte per dimension
166
+ norm REAL NOT NULL, -- L2 norm of the int8 vector
167
+ PRIMARY KEY (doc_id, chunk_index)
168
+ );
169
+
170
+ ALTER TABLE archives ADD COLUMN chunker_version INTEGER NOT NULL DEFAULT 0;
171
+ ALTER TABLE archives ADD COLUMN embed_model TEXT;
140
172
  `,
141
173
  },
142
174
  ];
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/transcripts/schema.ts"],"names":[],"mappings":"AAAA,oFAAoF;AACpF,EAAE;AACF,6EAA6E;AAC7E,8EAA8E;AAC9E,2EAA2E;AAC3E,2EAA2E;AAC3E,6EAA6E;AAC7E,yBAAyB;AAEzB,2CAA2C;AAC3C,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC;AAE5C,sDAAsD;AACtD,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,cAAc,EAAE,mBAAmB,EAAE,WAAW,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS;CACvF,CAAC;AAKX,MAAM,CAAC,MAAM,UAAU,GAAgB;IACrC;QACE,6EAA6E;QAC7E,uEAAuE;QACvE,OAAO,EAAE,CAAC;QACV,EAAE,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAiCH;KACF;IACD;QACE,2EAA2E;QAC3E,uEAAuE;QACvE,8EAA8E;QAC9E,wEAAwE;QACxE,OAAO,EAAE,CAAC;QACV,EAAE,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;KA2BH;KACF;IACD;QACE,uEAAuE;QACvE,mEAAmE;QACnE,wEAAwE;QACxE,uEAAuE;QACvE,mEAAmE;QACnE,EAAE;QACF,0EAA0E;QAC1E,yEAAyE;QACzE,0EAA0E;QAC1E,sEAAsE;QACtE,iEAAiE;QACjE,EAAE;QACF,wEAAwE;QACxE,0EAA0E;QAC1E,qEAAqE;QACrE,OAAO,EAAE,CAAC;QACV,EAAE,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAiCH;KACF;CACF,CAAC;AAEF,0EAA0E;AAC1E,oDAAoD;AACpD,MAAM,CAAC,MAAM,gBAAgB,GAAgB,CAAC,cAAc,EAAE,mBAAmB,EAAE,WAAW,EAAE,aAAa,CAAC,CAAC"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/transcripts/schema.ts"],"names":[],"mappings":"AAAA,oFAAoF;AACpF,EAAE;AACF,6EAA6E;AAC7E,8EAA8E;AAC9E,2EAA2E;AAC3E,2EAA2E;AAC3E,6EAA6E;AAC7E,yBAAyB;AAEzB,2CAA2C;AAC3C,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC;AAE5C,sDAAsD;AACtD,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,cAAc,EAAE,mBAAmB,EAAE,WAAW,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS;CACvF,CAAC;AAKX,MAAM,CAAC,MAAM,UAAU,GAAgB;IACrC;QACE,6EAA6E;QAC7E,uEAAuE;QACvE,OAAO,EAAE,CAAC;QACV,EAAE,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAiCH;KACF;IACD;QACE,2EAA2E;QAC3E,uEAAuE;QACvE,8EAA8E;QAC9E,wEAAwE;QACxE,OAAO,EAAE,CAAC;QACV,EAAE,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;KA2BH;KACF;IACD;QACE,uEAAuE;QACvE,mEAAmE;QACnE,wEAAwE;QACxE,uEAAuE;QACvE,mEAAmE;QACnE,EAAE;QACF,0EAA0E;QAC1E,yEAAyE;QACzE,0EAA0E;QAC1E,sEAAsE;QACtE,iEAAiE;QACjE,EAAE;QACF,wEAAwE;QACxE,0EAA0E;QAC1E,qEAAqE;QACrE,OAAO,EAAE,CAAC;QACV,EAAE,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAiCH;KACF;IACD;QACE,uDAAuD;QACvD,EAAE;QACF,kEAAkE;QAClE,0EAA0E;QAC1E,uEAAuE;QACvE,wEAAwE;QACxE,sEAAsE;QACtE,EAAE;QACF,kEAAkE;QAClE,uEAAuE;QACvE,2EAA2E;QAC3E,2CAA2C;QAC3C,EAAE;QACF,sEAAsE;QACtE,6DAA6D;QAC7D,2EAA2E;QAC3E,6DAA6D;QAC7D,OAAO,EAAE,CAAC;QACV,EAAE,EAAE;;;;;;;;;;;KAWH;KACF;CACF,CAAC;AAEF,0EAA0E;AAC1E,oDAAoD;AACpD,MAAM,CAAC,MAAM,gBAAgB,GAAgB,CAAC,cAAc,EAAE,mBAAmB,EAAE,WAAW,EAAE,aAAa,CAAC,CAAC"}
@@ -19,4 +19,39 @@ export type SearchOptions = {
19
19
  */
20
20
  export declare function buildSnippet(text: string, terms: Set<string>): string;
21
21
  export declare function searchEvents(query: string, opts?: SearchOptions): SearchHit[];
22
+ /**
23
+ * One ranker's contribution for a 0-based rank.
24
+ *
25
+ * Exported so the property the fix turns on can be asserted directly rather
26
+ * than through retrieval, where it is not reliably constructible: with a
27
+ * bag-of-words embedding model, lexical overlap implies semantic similarity,
28
+ * so a document that matches a query's words but not its meaning barely
29
+ * exists. The arithmetic, however, is exact.
30
+ */
31
+ export declare function rrfWeight(rank0: number): number;
32
+ /**
33
+ * Fuse ranked id lists by reciprocal rank. Pure, for testing and reuse.
34
+ *
35
+ * The property that matters: a top-ranked find from ONE ranker must be able
36
+ * to outrank a document both rankers place mid-list. That is the case the
37
+ * semantic layer exists to serve — a strict paraphrase is found by the
38
+ * semantic ranker alone — and it is exactly what k=60 at depth 50 broke.
39
+ */
40
+ export declare function fuseByRank(lists: string[][]): {
41
+ id: string;
42
+ score: number;
43
+ }[];
44
+ /**
45
+ * BM25 and semantic recall, fused by reciprocal rank.
46
+ *
47
+ * RRF over raw score blending is deliberate: BM25 scores are unbounded and
48
+ * corpus-dependent while cosines sit in [-1, 1], so any weighted sum would be
49
+ * a hidden tuning knob that drifts with corpus size. Ranks have neither
50
+ * problem, and lexical survival is measurable (a keyword hit must not be
51
+ * pushed out of the fused list by semantic noise).
52
+ *
53
+ * Degrades silently to BM25 alone when no vectors are stored or the model is
54
+ * not installed (A2) — the caller cannot tell the difference except by score.
55
+ */
56
+ export declare function searchEventsHybrid(query: string, opts?: SearchOptions): SearchHit[];
22
57
  //# sourceMappingURL=search.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/transcripts/search.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAiBF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CAiCrE;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,SAAS,EAAE,CAiEjF"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/transcripts/search.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAiBF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CAiCrE;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,SAAS,EAAE,CA2EjF;AAgDD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAAE,CAQ7E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,SAAS,EAAE,CA2FvF"}
@@ -12,6 +12,9 @@
12
12
  import { getTranscriptsDb } from './store.js';
13
13
  import { normalizeProjectDir } from '../memory/local.js';
14
14
  import { tokenize } from './tokenize.js';
15
+ import { STRIDE_CHARS, WINDOW_CHARS } from './chunk.js';
16
+ import { searchVectors } from './vectors.js';
17
+ import { getDocCache, scopeMask } from './cache.js';
15
18
  const K1 = 1.2;
16
19
  const B = 0.75;
17
20
  // Terms present in more than half the corpus carry ~no signal but force
@@ -74,7 +77,8 @@ export function searchEvents(query, opts = {}) {
74
77
  const terms = [...new Set(tokenize(query))];
75
78
  if (terms.length === 0)
76
79
  return [];
77
- const stats = db.prepare(`SELECT COUNT(*) AS n, COALESCE(AVG(len), 0) AS avgdl FROM search_docs`).get();
80
+ const docs = getDocCache();
81
+ const stats = { n: docs.count, avgdl: docs.avgdl };
78
82
  if (stats.n === 0)
79
83
  return [];
80
84
  // Resolve terms against the dictionary in one round trip.
@@ -88,47 +92,241 @@ export function searchEvents(query, opts = {}) {
88
92
  let kept = withDf.filter(t => t.df / stats.n <= DF_CAP_RATIO);
89
93
  if (kept.length === 0)
90
94
  kept = withDf; // every term is that common — keep them all
91
- const where = [`p.term_id IN (${kept.map(() => '?').join(', ')})`];
92
- const params = kept.map(t => t.id);
93
- if (opts.projectDir) {
94
- where.push('d.project_dir = ?');
95
- params.push(normalizeProjectDir(opts.projectDir));
96
- }
97
- if (opts.sourceSessionId) {
98
- where.push('d.source_session_id = ?');
99
- params.push(opts.sourceSessionId);
100
- }
101
- const rows = db.prepare(`SELECT p.term_id, p.tf, d.event_id, d.archive_id, d.source_session_id, d.project_dir, d.seq, d.kind, d.len
102
- FROM search_postings p JOIN search_docs d ON d.id = p.doc_id
103
- WHERE ${where.join(' AND ')}`).all(...params);
95
+ // Postings only three narrow columns, no join. Joining search_docs per
96
+ // POSTING to fetch columns needed for the final handful of rows cost 7.1 ms
97
+ // per common term at 50 sessions; this costs 2.6 ms. Scope and length come
98
+ // from the cached metadata instead, and full rows are read for the top-K.
99
+ const rows = db.prepare(`SELECT term_id, doc_id, tf FROM search_postings WHERE term_id IN (${kept.map(() => '?').join(', ')})`).all(...kept.map(t => t.id));
104
100
  if (rows.length === 0)
105
101
  return [];
102
+ const mask = scopeMask(docs, {
103
+ projectDir: opts.projectDir ? normalizeProjectDir(opts.projectDir) : undefined,
104
+ sourceSessionId: opts.sourceSessionId,
105
+ });
106
106
  const dfById = new Map(kept.map(t => [t.id, t.df]));
107
107
  const acc = new Map();
108
108
  for (const r of rows) {
109
+ if (mask && mask[r.doc_id] !== 1)
110
+ continue;
109
111
  const df = dfById.get(r.term_id) ?? 1;
110
- const prev = acc.get(r.event_id);
111
- const inc = bm25(r.tf, df, stats.n, r.len, stats.avgdl);
112
- if (prev)
113
- prev.score += inc;
114
- else
115
- acc.set(r.event_id, { doc: r, score: inc });
112
+ const inc = bm25(r.tf, df, stats.n, docs.len[r.doc_id] ?? 0, stats.avgdl);
113
+ acc.set(r.doc_id, (acc.get(r.doc_id) ?? 0) + inc);
116
114
  }
115
+ if (acc.size === 0)
116
+ return [];
117
117
  const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
118
- const top = [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit);
119
- // Snippets: fetch text for the returned rows only.
120
- const ids = top.map(t => t.doc.event_id);
121
- const textRows = db.prepare(`SELECT id, text FROM events WHERE id IN (${ids.map(() => '?').join(', ')})`).all(...ids);
122
- const textById = new Map(textRows.map(r => [r.id, r.text ?? '']));
118
+ const top = [...acc.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
119
+ // Metadata and text for the returned rows only.
120
+ const docIds = top.map(([id]) => id);
121
+ const marks2 = docIds.map(() => '?').join(', ');
122
+ const metaRows = db.prepare(`SELECT d.id, d.event_id, d.archive_id, d.source_session_id, d.seq, d.kind, e.text
123
+ FROM search_docs d JOIN events e ON e.id = d.event_id
124
+ WHERE d.id IN (${marks2})`).all(...docIds);
125
+ const metaById = new Map(metaRows.map(r => [r.id, r]));
123
126
  const termSet = new Set(kept.map(t => t.term));
124
- return top.map(({ doc, score }) => ({
125
- eventId: doc.event_id,
126
- archiveId: doc.archive_id,
127
- sourceSessionId: doc.source_session_id,
128
- seq: doc.seq,
129
- kind: doc.kind,
130
- snippet: buildSnippet(textById.get(doc.event_id) ?? '', termSet),
131
- score,
132
- }));
127
+ const out = [];
128
+ for (const [docId, score] of top) {
129
+ const m = metaById.get(docId);
130
+ if (!m)
131
+ continue;
132
+ out.push({
133
+ eventId: m.event_id,
134
+ archiveId: m.archive_id,
135
+ sourceSessionId: m.source_session_id,
136
+ seq: m.seq,
137
+ kind: m.kind,
138
+ snippet: buildSnippet(m.text ?? '', termSet),
139
+ score,
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+ // --- Hybrid retrieval -------------------------------------------------------
145
+ // How deep each ranker is consulted before fusing. Deeper than the returned
146
+ // limit so a result ranked mid-list by one ranker can still be lifted by the
147
+ // other — the entire mechanism by which a paraphrase beats keyword overlap.
148
+ const CANDIDATE_DEPTH = 50;
149
+ /**
150
+ * Reciprocal-rank fusion constant.
151
+ *
152
+ * NOT 60. That value comes from the RRF paper's TREC runs over ~1000-document
153
+ * lists; at our candidate depth it is actively harmful. With k=60 and d=50 the
154
+ * whole ranking compresses into a 1.8x spread (rank 1 = 0.0164, rank 50 =
155
+ * 0.0091), which destroys rank information and lets agreement dominate
156
+ * quality: two mid-list agreements (0.011 + 0.011) outscore one top-ranked
157
+ * single-ranker find (0.0164).
158
+ *
159
+ * That is fatal for the case this layer exists to serve. A strict paraphrase
160
+ * shares no vocabulary with its target, so the target is found by the semantic
161
+ * ranker ALONE and earns exactly one contribution — while any keyword-adjacent
162
+ * event earns a comparable one for free. The semantic discovery loses to noise.
163
+ *
164
+ * Requirement: a rank-1 hit from a single ranker must be able to outrank a pair
165
+ * of mid-list agreements. 1/(k+1) > 2/(k+d/2) solves to k < d/2 - 2, i.e.
166
+ * k < 23 at d=50. 10 sits comfortably inside that bound and keeps the top of
167
+ * the list sharply separated (rank 1 = 0.091, rank 10 = 0.050, rank 50 = 0.017).
168
+ */
169
+ const RRF_K = 10;
170
+ /**
171
+ * BM25 contributes only candidates it is actually confident about: anything
172
+ * below this fraction of its own top score is tail noise that would otherwise
173
+ * occupy fusion slots and crowd out semantic finds.
174
+ *
175
+ * Applied to BM25 only, deliberately. The two rankers have different score
176
+ * geometries: BM25 decays fast and spans orders of magnitude, so relative
177
+ * score is meaningful. Cosines sit in a narrow band — measured on the real
178
+ * corpus, off-topic queries reach 0.40 while on-topic start at 0.45 — so a
179
+ * relative floor there would be noise itself. Depth is the semantic side's
180
+ * only honest control.
181
+ *
182
+ * Only the tail is affected, so a strong keyword hit can never be dropped and
183
+ * lexical survival is preserved by construction.
184
+ */
185
+ const BM25_RELATIVE_FLOOR = 0.25;
186
+ /**
187
+ * One ranker's contribution for a 0-based rank.
188
+ *
189
+ * Exported so the property the fix turns on can be asserted directly rather
190
+ * than through retrieval, where it is not reliably constructible: with a
191
+ * bag-of-words embedding model, lexical overlap implies semantic similarity,
192
+ * so a document that matches a query's words but not its meaning barely
193
+ * exists. The arithmetic, however, is exact.
194
+ */
195
+ export function rrfWeight(rank0) {
196
+ return 1 / (RRF_K + rank0 + 1);
197
+ }
198
+ /**
199
+ * Fuse ranked id lists by reciprocal rank. Pure, for testing and reuse.
200
+ *
201
+ * The property that matters: a top-ranked find from ONE ranker must be able
202
+ * to outrank a document both rankers place mid-list. That is the case the
203
+ * semantic layer exists to serve — a strict paraphrase is found by the
204
+ * semantic ranker alone — and it is exactly what k=60 at depth 50 broke.
205
+ */
206
+ export function fuseByRank(lists) {
207
+ const acc = new Map();
208
+ for (const list of lists) {
209
+ list.forEach((id, i) => acc.set(id, (acc.get(id) ?? 0) + rrfWeight(i)));
210
+ }
211
+ return [...acc.entries()]
212
+ .map(([id, score]) => ({ id, score }))
213
+ .sort((a, b) => b.score - a.score);
214
+ }
215
+ /**
216
+ * BM25 and semantic recall, fused by reciprocal rank.
217
+ *
218
+ * RRF over raw score blending is deliberate: BM25 scores are unbounded and
219
+ * corpus-dependent while cosines sit in [-1, 1], so any weighted sum would be
220
+ * a hidden tuning knob that drifts with corpus size. Ranks have neither
221
+ * problem, and lexical survival is measurable (a keyword hit must not be
222
+ * pushed out of the fused list by semantic noise).
223
+ *
224
+ * Degrades silently to BM25 alone when no vectors are stored or the model is
225
+ * not installed (A2) — the caller cannot tell the difference except by score.
226
+ */
227
+ export function searchEventsHybrid(query, opts = {}) {
228
+ const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
229
+ const lexicalAll = searchEvents(query, { ...opts, limit: CANDIDATE_DEPTH });
230
+ const lexFloor = (lexicalAll[0]?.score ?? 0) * BM25_RELATIVE_FLOOR;
231
+ const lexical = lexicalAll.filter(h => h.score >= lexFloor);
232
+ // Cosine ranking has no notion of "no match" — it always returns its nearest
233
+ // neighbours, however far away they are. A similarity floor cannot fix that
234
+ // here: measured on the real corpus, off-topic queries reach 0.40 while true
235
+ // strict paraphrases go as low as 0.21, so the distributions overlap and any
236
+ // threshold that suppresses noise also cuts the recall this layer exists for.
237
+ //
238
+ // What DOES separate them is whether the query has any footing in the corpus
239
+ // at all. A paraphrase shares no words with its target but still speaks the
240
+ // corpus's language; `zzz-nonexistent-term` shares nothing with anything. So
241
+ // the semantic layer is allowed to generalize, never to extrapolate from a
242
+ // query the corpus has no word of — and "nothing matched" stays expressible.
243
+ // Unfiltered on the fallback paths: the relative floor exists to stop tail
244
+ // noise occupying FUSION slots. With no fusion happening it would just be
245
+ // hiding results the lexical ranker legitimately found.
246
+ if (!queryHasCorpusTerms(query))
247
+ return lexicalAll.slice(0, limit);
248
+ let semantic = [];
249
+ try {
250
+ semantic = searchVectors(query, {
251
+ projectDir: opts.projectDir ? normalizeProjectDir(opts.projectDir) : undefined,
252
+ sourceSessionId: opts.sourceSessionId,
253
+ limit: CANDIDATE_DEPTH,
254
+ });
255
+ }
256
+ catch {
257
+ semantic = []; // a broken model must not take the lexical path down with it
258
+ }
259
+ if (semantic.length === 0)
260
+ return lexicalAll.slice(0, limit);
261
+ const db = getTranscriptsDb();
262
+ // Resolve the semantic hits' doc ids to event metadata in one round trip.
263
+ const docIds = semantic.map(s => s.docId);
264
+ const docRows = db.prepare(`SELECT id, event_id, archive_id, source_session_id, seq, kind
265
+ FROM search_docs WHERE id IN (${docIds.map(() => '?').join(', ')})`).all(...docIds);
266
+ const docById = new Map(docRows.map(r => [r.id, r]));
267
+ const fused = new Map();
268
+ lexical.forEach((hit, i) => {
269
+ fused.set(hit.eventId, { hit, rrf: rrfWeight(i) });
270
+ });
271
+ semantic.forEach((s, i) => {
272
+ const doc = docById.get(s.docId);
273
+ if (!doc)
274
+ return;
275
+ const prev = fused.get(doc.event_id);
276
+ const inc = rrfWeight(i);
277
+ if (prev) {
278
+ prev.rrf += inc;
279
+ prev.chunkIndex = s.chunkIndex;
280
+ }
281
+ else
282
+ fused.set(doc.event_id, { doc, chunkIndex: s.chunkIndex, rrf: inc });
283
+ });
284
+ const ranked = [...fused.entries()]
285
+ .sort((a, b) => b[1].rrf - a[1].rrf)
286
+ .slice(0, limit);
287
+ // Text is needed only for entries that arrived semantically — lexical hits
288
+ // already carry a snippet built from their matched terms.
289
+ const needText = ranked.filter(([, e]) => !e.hit).map(([eventId]) => eventId);
290
+ const textById = new Map();
291
+ if (needText.length > 0) {
292
+ const rows = db.prepare(`SELECT id, text FROM events WHERE id IN (${needText.map(() => '?').join(', ')})`).all(...needText);
293
+ for (const r of rows)
294
+ textById.set(r.id, r.text ?? '');
295
+ }
296
+ const terms = new Set(tokenize(query));
297
+ return ranked.map(([eventId, e]) => {
298
+ if (e.hit)
299
+ return { ...e.hit, score: e.rrf };
300
+ const doc = e.doc;
301
+ const text = textById.get(eventId) ?? '';
302
+ return {
303
+ eventId,
304
+ archiveId: doc.archive_id,
305
+ sourceSessionId: doc.source_session_id,
306
+ seq: doc.seq,
307
+ kind: doc.kind,
308
+ // Aim the excerpt at the window that actually matched, so a long event
309
+ // does not open on an unrelated first paragraph (C6's opportunity).
310
+ snippet: buildSnippet(windowAround(text, e.chunkIndex ?? 0), terms),
311
+ score: e.rrf,
312
+ };
313
+ });
314
+ }
315
+ /** True when at least one query token appears in the corpus dictionary. */
316
+ function queryHasCorpusTerms(query) {
317
+ const terms = [...new Set(tokenize(query))];
318
+ if (terms.length === 0)
319
+ return false;
320
+ const db = getTranscriptsDb();
321
+ const hit = db.prepare(`SELECT 1 AS present FROM search_terms WHERE term IN (${terms.map(() => '?').join(', ')}) LIMIT 1`).get(...terms);
322
+ return hit !== undefined;
323
+ }
324
+ /** The slice of `text` the winning chunk covered, widened by one stride each
325
+ * way so the snippet has context to work with. */
326
+ function windowAround(text, chunkIndex) {
327
+ if (text.length <= WINDOW_CHARS)
328
+ return text;
329
+ const start = Math.max(0, chunkIndex * STRIDE_CHARS - STRIDE_CHARS);
330
+ return text.slice(start, start + WINDOW_CHARS + STRIDE_CHARS * 2);
133
331
  }
134
332
  //# sourceMappingURL=search.js.map