@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.
- package/LICENSE +21 -21
- package/README.md +630 -589
- package/dist/agents/llm-runner.js +57 -57
- package/dist/cli/statusline.js +2 -2
- package/dist/cli.js +57 -26
- package/dist/cli.js.map +1 -1
- package/dist/memory/decisions.js +1 -1
- package/dist/memory/local.js +144 -144
- package/dist/memory/schema.js +228 -228
- package/dist/memory/sync.js +25 -25
- package/dist/router/implicit-outcomes.js +4 -4
- package/dist/router/learning-updater.js +45 -45
- package/dist/server.js +1 -1
- package/dist/tools/compact.js +3 -3
- package/dist/transcripts/archive.js +5 -5
- package/dist/transcripts/cache.d.ts +66 -0
- package/dist/transcripts/cache.d.ts.map +1 -0
- package/dist/transcripts/cache.js +0 -0
- package/dist/transcripts/cache.js.map +1 -0
- package/dist/transcripts/chunk.d.ts +26 -0
- package/dist/transcripts/chunk.d.ts.map +1 -0
- package/dist/transcripts/chunk.js +54 -0
- package/dist/transcripts/chunk.js.map +1 -0
- package/dist/transcripts/embed.d.ts +58 -0
- package/dist/transcripts/embed.d.ts.map +1 -0
- package/dist/transcripts/embed.js +303 -0
- package/dist/transcripts/embed.js.map +1 -0
- package/dist/transcripts/ingest.d.ts.map +1 -1
- package/dist/transcripts/ingest.js +14 -7
- package/dist/transcripts/ingest.js.map +1 -1
- package/dist/transcripts/manage.d.ts.map +1 -1
- package/dist/transcripts/manage.js +6 -0
- package/dist/transcripts/manage.js.map +1 -1
- package/dist/transcripts/mapping.js +5 -5
- package/dist/transcripts/pyramid.js +2 -2
- package/dist/transcripts/recall.d.ts +7 -0
- package/dist/transcripts/recall.d.ts.map +1 -1
- package/dist/transcripts/recall.js +25 -3
- package/dist/transcripts/recall.js.map +1 -1
- package/dist/transcripts/schema.d.ts +11 -1
- package/dist/transcripts/schema.d.ts.map +1 -1
- package/dist/transcripts/schema.js +126 -94
- package/dist/transcripts/schema.js.map +1 -1
- package/dist/transcripts/search.d.ts +35 -0
- package/dist/transcripts/search.d.ts.map +1 -1
- package/dist/transcripts/search.js +232 -34
- package/dist/transcripts/search.js.map +1 -1
- package/dist/transcripts/vectors.d.ts +40 -0
- package/dist/transcripts/vectors.d.ts.map +1 -0
- package/dist/transcripts/vectors.js +203 -0
- package/dist/transcripts/vectors.js.map +1 -0
- package/package.json +70 -61
- package/server.json +23 -23
- package/dist/skills/development/skill-api-design.d.ts +0 -15
- package/dist/skills/development/skill-api-design.d.ts.map +0 -1
- package/dist/skills/development/skill-api-design.js +0 -313
- package/dist/skills/development/skill-api-design.js.map +0 -1
- package/dist/skills/development/skill-auth.d.ts +0 -15
- package/dist/skills/development/skill-auth.d.ts.map +0 -1
- package/dist/skills/development/skill-auth.js +0 -255
- package/dist/skills/development/skill-auth.js.map +0 -1
- package/dist/skills/development/skill-ci-cd.d.ts +0 -2
- package/dist/skills/development/skill-ci-cd.d.ts.map +0 -1
- package/dist/skills/development/skill-ci-cd.js +0 -2
- package/dist/skills/development/skill-ci-cd.js.map +0 -1
- package/dist/skills/development/skill-crud.d.ts +0 -15
- package/dist/skills/development/skill-crud.d.ts.map +0 -1
- package/dist/skills/development/skill-crud.js +0 -193
- package/dist/skills/development/skill-crud.js.map +0 -1
- package/dist/skills/development/skill-db-schema.d.ts +0 -2
- package/dist/skills/development/skill-db-schema.d.ts.map +0 -1
- package/dist/skills/development/skill-db-schema.js +0 -2
- package/dist/skills/development/skill-db-schema.js.map +0 -1
- package/dist/skills/development/skill-docker.d.ts +0 -2
- package/dist/skills/development/skill-docker.d.ts.map +0 -1
- package/dist/skills/development/skill-docker.js +0 -2
- package/dist/skills/development/skill-docker.js.map +0 -1
- package/dist/skills/development/skill-env-setup.d.ts +0 -2
- package/dist/skills/development/skill-env-setup.d.ts.map +0 -1
- package/dist/skills/development/skill-env-setup.js +0 -2
- package/dist/skills/development/skill-env-setup.js.map +0 -1
- package/dist/skills/development/skill-scaffold.d.ts +0 -15
- package/dist/skills/development/skill-scaffold.d.ts.map +0 -1
- package/dist/skills/development/skill-scaffold.js +0 -299
- package/dist/skills/development/skill-scaffold.js.map +0 -1
- package/dist/skills/intelligence/skill-complexity-score.d.ts +0 -15
- package/dist/skills/intelligence/skill-complexity-score.d.ts.map +0 -1
- package/dist/skills/intelligence/skill-complexity-score.js +0 -66
- package/dist/skills/intelligence/skill-complexity-score.js.map +0 -1
- package/dist/skills/intelligence/skill-cost-track.d.ts +0 -14
- package/dist/skills/intelligence/skill-cost-track.d.ts.map +0 -1
- package/dist/skills/intelligence/skill-cost-track.js +0 -36
- package/dist/skills/intelligence/skill-cost-track.js.map +0 -1
- package/dist/skills/intelligence/skill-learning-loop.d.ts +0 -15
- package/dist/skills/intelligence/skill-learning-loop.d.ts.map +0 -1
- package/dist/skills/intelligence/skill-learning-loop.js +0 -66
- package/dist/skills/intelligence/skill-learning-loop.js.map +0 -1
- package/dist/skills/intelligence/skill-pattern-detect.d.ts +0 -14
- package/dist/skills/intelligence/skill-pattern-detect.d.ts.map +0 -1
- package/dist/skills/intelligence/skill-pattern-detect.js +0 -35
- package/dist/skills/intelligence/skill-pattern-detect.js.map +0 -1
- package/dist/skills/intelligence/skill-rate-watch.d.ts +0 -15
- package/dist/skills/intelligence/skill-rate-watch.d.ts.map +0 -1
- package/dist/skills/intelligence/skill-rate-watch.js +0 -58
- package/dist/skills/intelligence/skill-rate-watch.js.map +0 -1
- package/dist/skills/memory/skill-context-compress.d.ts +0 -15
- package/dist/skills/memory/skill-context-compress.d.ts.map +0 -1
- package/dist/skills/memory/skill-context-compress.js +0 -82
- package/dist/skills/memory/skill-context-compress.js.map +0 -1
- package/dist/skills/memory/skill-cross-sync.d.ts +0 -15
- package/dist/skills/memory/skill-cross-sync.d.ts.map +0 -1
- package/dist/skills/memory/skill-cross-sync.js +0 -88
- package/dist/skills/memory/skill-cross-sync.js.map +0 -1
- package/dist/skills/memory/skill-decision-log.d.ts +0 -15
- package/dist/skills/memory/skill-decision-log.d.ts.map +0 -1
- package/dist/skills/memory/skill-decision-log.js +0 -103
- package/dist/skills/memory/skill-decision-log.js.map +0 -1
- package/dist/skills/memory/skill-session-restore.d.ts +0 -15
- package/dist/skills/memory/skill-session-restore.d.ts.map +0 -1
- package/dist/skills/memory/skill-session-restore.js +0 -39
- package/dist/skills/memory/skill-session-restore.js.map +0 -1
- package/dist/skills/memory/skill-session-save.d.ts +0 -15
- package/dist/skills/memory/skill-session-save.d.ts.map +0 -1
- package/dist/skills/memory/skill-session-save.js +0 -74
- package/dist/skills/memory/skill-session-save.js.map +0 -1
- package/dist/skills/quality/skill-accessibility.d.ts +0 -2
- package/dist/skills/quality/skill-accessibility.d.ts.map +0 -1
- package/dist/skills/quality/skill-accessibility.js +0 -2
- package/dist/skills/quality/skill-accessibility.js.map +0 -1
- package/dist/skills/quality/skill-code-review.d.ts +0 -15
- package/dist/skills/quality/skill-code-review.d.ts.map +0 -1
- package/dist/skills/quality/skill-code-review.js +0 -60
- package/dist/skills/quality/skill-code-review.js.map +0 -1
- package/dist/skills/quality/skill-docs-gen.d.ts +0 -2
- package/dist/skills/quality/skill-docs-gen.d.ts.map +0 -1
- package/dist/skills/quality/skill-docs-gen.js +0 -2
- package/dist/skills/quality/skill-docs-gen.js.map +0 -1
- package/dist/skills/quality/skill-perf-audit.d.ts +0 -2
- package/dist/skills/quality/skill-perf-audit.d.ts.map +0 -1
- package/dist/skills/quality/skill-perf-audit.js +0 -2
- package/dist/skills/quality/skill-perf-audit.js.map +0 -1
- package/dist/skills/quality/skill-security-scan.d.ts +0 -15
- package/dist/skills/quality/skill-security-scan.d.ts.map +0 -1
- package/dist/skills/quality/skill-security-scan.js +0 -67
- package/dist/skills/quality/skill-security-scan.js.map +0 -1
- package/dist/skills/quality/skill-test-suite.d.ts +0 -15
- package/dist/skills/quality/skill-test-suite.d.ts.map +0 -1
- package/dist/skills/quality/skill-test-suite.js +0 -274
- package/dist/skills/quality/skill-test-suite.js.map +0 -1
- package/dist/skills/workflow/skill-deploy.d.ts +0 -2
- package/dist/skills/workflow/skill-deploy.d.ts.map +0 -1
- package/dist/skills/workflow/skill-deploy.js +0 -2
- package/dist/skills/workflow/skill-deploy.js.map +0 -1
- package/dist/skills/workflow/skill-git-workflow.d.ts +0 -2
- package/dist/skills/workflow/skill-git-workflow.d.ts.map +0 -1
- package/dist/skills/workflow/skill-git-workflow.js +0 -2
- package/dist/skills/workflow/skill-git-workflow.js.map +0 -1
- package/dist/skills/workflow/skill-rollback.d.ts +0 -2
- package/dist/skills/workflow/skill-rollback.d.ts.map +0 -1
- package/dist/skills/workflow/skill-rollback.js +0 -2
- package/dist/skills/workflow/skill-rollback.js.map +0 -1
- package/dist/skills/workflow/skill-task-breakdown.d.ts +0 -2
- package/dist/skills/workflow/skill-task-breakdown.d.ts.map +0 -1
- package/dist/skills/workflow/skill-task-breakdown.js +0 -2
- 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+
|
|
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 =
|
|
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":"
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
111
|
-
|
|
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.
|
|
119
|
-
//
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
const
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|