pi-mega-compact 0.20.85 → 0.20.87

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 (42) hide show
  1. package/dist/config.js +9 -0
  2. package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +2 -0
  3. package/dist/extensions/mega-config.js +12 -0
  4. package/dist/extensions/mega-events/context-handler/gateCheck.js +27 -0
  5. package/dist/extensions/mega-events/context-handler/thrashGuard.js +186 -0
  6. package/dist/extensions/mega-events/context-handler.js +33 -1
  7. package/dist/extensions/mega-pipeline/compact/noop.js +104 -0
  8. package/dist/extensions/mega-pipeline/compact/run.js +268 -0
  9. package/dist/extensions/mega-pipeline/compact/vote.js +72 -0
  10. package/dist/extensions/mega-pipeline/compact.js +12 -343
  11. package/dist/extensions/mega-pipeline/recall/impl.js +258 -0
  12. package/dist/extensions/mega-pipeline/recall.js +6 -253
  13. package/dist/src/config.js +9 -0
  14. package/dist/src/failback/compact.js +109 -0
  15. package/dist/src/recall/readonly.js +39 -0
  16. package/dist/src/recall/recall3wf.fixture.js +67 -0
  17. package/dist/src/recall/validator.js +113 -0
  18. package/dist/src/recall/vote.js +217 -0
  19. package/dist/src/store/sqlite/fts5-search.js +26 -0
  20. package/dist/src/store/sqlite/meta.js +32 -0
  21. package/extensions/dashboard-server/routes-rag-settings-helpers.ts +9 -0
  22. package/extensions/mega-config-types.ts +13 -0
  23. package/extensions/mega-config.ts +12 -0
  24. package/extensions/mega-events/context-handler/gateCheck.ts +30 -0
  25. package/extensions/mega-events/context-handler/thrashGuard.ts +228 -0
  26. package/extensions/mega-events/context-handler.ts +36 -1
  27. package/extensions/mega-pipeline/compact/noop.ts +96 -0
  28. package/extensions/mega-pipeline/compact/run.ts +322 -0
  29. package/extensions/mega-pipeline/compact/vote.ts +85 -0
  30. package/extensions/mega-pipeline/compact.ts +12 -385
  31. package/extensions/mega-pipeline/recall/impl.ts +312 -0
  32. package/extensions/mega-pipeline/recall.ts +10 -306
  33. package/package.json +1 -1
  34. package/src/config.ts +12 -0
  35. package/src/failback/compact.ts +122 -0
  36. package/src/failback/types.ts +72 -0
  37. package/src/recall/readonly.ts +57 -0
  38. package/src/recall/recall3wf.fixture.ts +87 -0
  39. package/src/recall/validator.ts +150 -0
  40. package/src/recall/vote.ts +240 -0
  41. package/src/store/sqlite/fts5-search.ts +40 -0
  42. package/src/store/sqlite/meta.ts +36 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * recall/vote.ts — the 3-independent-source recall vote (3WF-3).
3
+ *
4
+ * Three INDEPENDENT, read-only sources name candidate checkpoints:
5
+ * A vector — raw semantic hits (recall/readonly.ts), cosine 0..1 scale.
6
+ * B fts5 — BM25 trigram hits (hydrated to checkpointIds), FTS5 BM25 scale
7
+ * (negative score = better match; ranking order is what matters).
8
+ * C recency — the N freshest checkpoints by timestamp, query-INDEPENDENT.
9
+ * (NOT turn_recall / TurnReader — that would just echo already-
10
+ * injected content, not an independent signal.)
11
+ *
12
+ * Each source is a different score scale, so they are NOT directly comparable.
13
+ * We normalize each source's scores to a 0..1 scale (per-source min-max) BEFORE
14
+ * combining. Averaging raw cosine (0..1) with raw BM25 (arbitrary negative
15
+ * magnitude) or a recency rank would be meaningless — the largest-magnitude
16
+ * scale would always dominate. Normalization makes each source a peer voter.
17
+ *
18
+ * Overlap rule: a checkpoint named by >=2 of 3 distinct sources short-circuits
19
+ * as a winner. Fallback (no 2/3 majority): rank all candidates by the
20
+ * cross-source MEAN of their normalized scores.
21
+ *
22
+ * Non-fatal throughout. Pi-agnostic: no pi runtime imports.
23
+ */
24
+ import { openStore } from "../store/sqlite/utils.js";
25
+ import { fts5SearchScoped, hydrateFts5Hits } from "../store/sqlite/fts5-search.js";
26
+ // The SQLite store is the source of truth (src/store.ts's same-named helper
27
+ // reads the LEGACY gzipped-JSON DR snapshot, which is empty for live sessions —
28
+ // importing it here would silently starve sources B and C). Mirrors the import
29
+ // in vector-search.ts + tieredRouter.ts.
30
+ import { listCheckpoints } from "../store/sqlite.js";
31
+ import { computeContentDigest } from "../dedup/digest.js";
32
+ import { recallRawHits } from "./readonly.js";
33
+ import { Logger } from "../log.js";
34
+ import type { VectorStore, SearchHit } from "../vectorStore.js";
35
+ import type { RecallCandidate, VoteResult } from "../failback/types.js";
36
+
37
+ /** Options for the three-source recall vote. */
38
+ export interface VoteOptions {
39
+ /** Normalized session id. */
40
+ sessionId: string;
41
+ /** Recall query text. */
42
+ query: string;
43
+ /** Max vector/fts5 hits to consider (default 3). */
44
+ limit?: number;
45
+ /** How many freshest checkpoints source C contributes (default = limit). */
46
+ recencyCount?: number;
47
+ }
48
+
49
+ /** Per-source normalization: map raw scores to 0..1 via min-max within source. */
50
+ function normalizeScores(scores: number[]): Map<number, number> {
51
+ const map = new Map<number, number>();
52
+ if (scores.length === 0) return map;
53
+ const min = Math.min(...scores);
54
+ const max = Math.max(...scores);
55
+ const span = max - min;
56
+ scores.forEach((s, i) => {
57
+ map.set(i, span === 0 ? 1 : (s - min) / span);
58
+ });
59
+ return map;
60
+ }
61
+
62
+ /**
63
+ * Run the three-source recall vote. Returns agreement winners + a per-id vote
64
+ * count + the names of sources that produced no winning candidate. Non-fatal:
65
+ * any search failure degrades to the remaining sources (empty winners allowed).
66
+ */
67
+ export function voteRecall(opts: VoteOptions, store: VectorStore): VoteResult {
68
+ const logger = new Logger();
69
+ const limit = opts.limit ?? 3;
70
+ const recencyCount = opts.recencyCount ?? limit;
71
+
72
+ // ── Source A: vector (raw hits, cosine 0..1). ────────────────────────────
73
+ const vectorCands: RecallCandidate[] = recallRawHits(
74
+ { sessionId: opts.sessionId, query: opts.query, limit },
75
+ store,
76
+ ).map((h: SearchHit) => ({
77
+ checkpointId: h.checkpoint.checkpointId,
78
+ score: h.score,
79
+ source: "vector" as const,
80
+ }));
81
+
82
+ // ── Source B: fts5 (BM25, hydrated to checkpointIds). ─────────────────────
83
+ // Dedup on L0 content digest so the SAME normalized text under two ids does
84
+ // not double-vote — collapse to one candidate per digest.
85
+ const fts5Cands: RecallCandidate[] = (() => {
86
+ try {
87
+ const reader = openStore(store.stateDir);
88
+ const hits = fts5SearchScoped(opts.query, reader, opts.sessionId, limit);
89
+ // FTS5 returns scores ordered best-first (bm25 asc); lower = better.
90
+ // We keep the raw score (negative-is-better) and flip in normalization.
91
+ const hydrated = hydrateFts5Hits(hits, opts.sessionId, store.stateDir);
92
+ // Dedup on checkpointId so the same checkpoint cannot double-count, AND
93
+ // on the L0 CONTENT digest so identical normalized text stored under two
94
+ // different ids collapses to one vote. The digest is taken over the
95
+ // joined `summary` (real content): hashing the id string would be a
96
+ // no-op tier, since ids are unique by definition.
97
+ const seenDigest = new Set<string>();
98
+ const seenId = new Set<string>();
99
+ const out: RecallCandidate[] = [];
100
+ for (const h of hydrated) {
101
+ if (seenId.has(h.checkpointId)) continue;
102
+ seenId.add(h.checkpointId);
103
+ const digest = computeContentDigest(h.summary).contentHash;
104
+ if (seenDigest.has(digest)) continue;
105
+ seenDigest.add(digest);
106
+ out.push({ checkpointId: h.checkpointId, score: h.score, source: "fts5" });
107
+ }
108
+ return out;
109
+ } catch {
110
+ return [];
111
+ }
112
+ })();
113
+
114
+ // ── Source C: recency (N freshest checkpoints, query-independent). ────────
115
+ // Timestamp-ordered; the lower the index the fresher. Score = recency rank
116
+ // (fresh = high) so normalization treats newest as best.
117
+ const recencyCands: RecallCandidate[] = (() => {
118
+ try {
119
+ const cps = listCheckpoints(opts.sessionId, store.stateDir)
120
+ .filter((c) => c.dedupStatus !== "removed")
121
+ .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
122
+ .slice(0, recencyCount);
123
+ return cps.map((cp, i) => ({
124
+ checkpointId: cp.checkpointId,
125
+ // Fresher => higher raw score (recency rank). Normalized below.
126
+ score: cps.length - i,
127
+ source: "recency" as const,
128
+ }));
129
+ } catch {
130
+ return [];
131
+ }
132
+ })();
133
+
134
+ const sources: { name: string; cands: RecallCandidate[] }[] = [
135
+ { name: "vector", cands: vectorCands },
136
+ { name: "fts5", cands: fts5Cands },
137
+ { name: "recency", cands: recencyCands },
138
+ ];
139
+
140
+ // Per-source normalization to 0..1 so the three scales are comparable.
141
+ const perSource = sources.map((s) => ({
142
+ name: s.name,
143
+ norm: normalizeScores(s.cands.map((c) => c.score)),
144
+ }));
145
+
146
+ // Aggregate: best normalized score per source per checkpointId + vote count.
147
+ const bestScoreBySource = new Map<string, Map<string, number>>();
148
+ const seenIds = new Set<string>();
149
+ for (const src of sources) {
150
+ const norm = perSource.find((p) => p.name === src.name)!.norm;
151
+ const bestByCp = new Map<string, number>();
152
+ src.cands.forEach((c, i) => {
153
+ const n = norm.get(i) ?? 0;
154
+ const prev = bestByCp.get(c.checkpointId);
155
+ if (prev === undefined || n > prev) bestByCp.set(c.checkpointId, n);
156
+ seenIds.add(c.checkpointId);
157
+ });
158
+ bestScoreBySource.set(src.name, bestByCp);
159
+ }
160
+
161
+ // Vote count = number of DISTINCT sources naming each checkpointId.
162
+ const votes: Record<string, number> = {};
163
+ const sumByCp = new Map<string, number>();
164
+ for (const id of seenIds) {
165
+ let count = 0;
166
+ let sum = 0;
167
+ for (const src of sources) {
168
+ const m = bestScoreBySource.get(src.name)!;
169
+ if (m.has(id)) {
170
+ count++;
171
+ sum += m.get(id)!;
172
+ }
173
+ }
174
+ votes[id] = count;
175
+ sumByCp.set(id, sum);
176
+ }
177
+
178
+ /** Mean normalized score across the sources that named `id`. */
179
+ const meanScore = (id: string): number =>
180
+ (sumByCp.get(id) ?? 0) / (votes[id] ?? 1);
181
+
182
+ // Short-circuit: >=2 of 3 distinct sources => winner (agreement). Ranked by
183
+ // vote count first (stronger agreement wins), then by mean normalized score —
184
+ // the validator consumes this list in order and takes the first that passes,
185
+ // so the ordering IS the ranking and must not be Set-insertion order.
186
+ const winners: RecallCandidate[] = [];
187
+ const divergent = new Set<string>(sources.map((s) => s.name));
188
+ const agreed = [...seenIds]
189
+ .filter((id) => (votes[id] ?? 0) >= 2)
190
+ .sort((a, b) => (votes[b] ?? 0) - (votes[a] ?? 0) || meanScore(b) - meanScore(a));
191
+ for (const id of agreed) {
192
+ const cand = (() => {
193
+ for (const src of sources) {
194
+ const c = src.cands.find((x) => x.checkpointId === id);
195
+ if (c) return c;
196
+ }
197
+ return null;
198
+ })();
199
+ if (cand) winners.push(cand);
200
+ }
201
+
202
+ if (winners.length === 0) {
203
+ // Fallback (no 2-of-3 agreement): rank by cross-source MEAN normalized score.
204
+ const ranked = [...seenIds].sort((a, b) => meanScore(b) - meanScore(a));
205
+ for (const id of ranked) {
206
+ const cand = (() => {
207
+ for (const src of sources) {
208
+ const c = src.cands.find((x) => x.checkpointId === id);
209
+ if (c) return c;
210
+ }
211
+ return null;
212
+ })();
213
+ if (cand) winners.push(cand);
214
+ }
215
+ }
216
+
217
+ // Divergence = a source that named NONE of the winning checkpoints. This must
218
+ // be computed per SOURCE against the winning ID SET, not from `winner.source`
219
+ // (a winner is one candidate object carrying a single source label, so an id
220
+ // agreed on by all three sources would still credit only one of them).
221
+ const winningIds = new Set(winners.map((w) => w.checkpointId));
222
+ for (const src of sources) {
223
+ if (src.cands.some((c) => winningIds.has(c.checkpointId))) {
224
+ divergent.delete(src.name);
225
+ }
226
+ }
227
+
228
+ if (divergent.size > 0) {
229
+ logger.info("recall_vote_divergence", {
230
+ divergentSources: [...divergent],
231
+ winnerCount: winners.length,
232
+ });
233
+ }
234
+
235
+ return {
236
+ winners,
237
+ votes,
238
+ divergentSources: [...divergent],
239
+ };
240
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { DatabaseSync } from "node:sqlite";
16
+ import { listCheckpoints } from "./checkpoints.js";
16
17
 
17
18
  export interface Fts5Hit {
18
19
  /** checkpoint id (chkpt_001 etc.) from the context_chunks_trgm row. */
@@ -88,3 +89,42 @@ export function fts5SearchScoped(
88
89
  // No session filter — plain FTS5 search across all sessions.
89
90
  return fts5Search(query, reader, limit);
90
91
  }
92
+
93
+ /** A hydrated FTS5 hit: the id + BM25 score joined with its checkpoint summary. */
94
+ export interface HydratedFts5Hit {
95
+ /** checkpoint id the FTS5 row referred to. */
96
+ checkpointId: string;
97
+ /** BM25 relevance score carried over from the FTS5 row. */
98
+ score: number;
99
+ /** The joined checkpoint's summary text (content basis for L0 digest dedup). */
100
+ summary: string;
101
+ }
102
+
103
+ /**
104
+ * Hydrate FTS5 hits (which only carry checkpoint `id` + BM25 `score`, with NO
105
+ * checkpoint object) by joining against the real stored checkpoints. Mirrors the
106
+ * private `hydrateHits` in tieredRouter.ts (listCheckpoints → Map on
107
+ * checkpointId → join, dropping ids with no checkpoint).
108
+ *
109
+ * The joined `summary` is returned alongside the id because the 3WF-3 voter
110
+ * dedups candidates on the L0 CONTENT digest — hashing the id string instead
111
+ * would be a no-op tier (ids are already unique), silently disabling the check.
112
+ *
113
+ * @param hits Raw Fts5Hit rows (id + BM25 score).
114
+ * @param sessionId Session scope used to fetch the checkpoint set.
115
+ * @param stateDir On-disk state dir for the sync store.
116
+ */
117
+ export function hydrateFts5Hits(
118
+ hits: Fts5Hit[],
119
+ sessionId: string,
120
+ stateDir: string,
121
+ ): HydratedFts5Hit[] {
122
+ const cps = listCheckpoints(sessionId, stateDir);
123
+ const cpMap = new Map(cps.map((cp) => [cp.checkpointId, cp]));
124
+ const out: HydratedFts5Hit[] = [];
125
+ for (const h of hits) {
126
+ const cp = cpMap.get(h.id);
127
+ if (cp) out.push({ checkpointId: h.id, score: h.score, summary: cp.summary });
128
+ }
129
+ return out;
130
+ }
@@ -55,6 +55,42 @@ export function getMetaNumber(key: string, stateDir: string = getStateDir()): nu
55
55
  return Number.isFinite(n) ? n : 0;
56
56
  }
57
57
 
58
+ /**
59
+ * Upsert a single numeric meta key to an absolute value (NOT a cumulative
60
+ * counter). Follows the `addTokensSaved` INSERT-ON-CONFLICT pattern with a
61
+ * fully parameterized query (PREVENT-002: no SQL string concat — `key` and
62
+ * `value` are both bound, never interpolated). The only write is the standard
63
+ * ON CONFLICT upsert of THIS key's own value; no other key is touched, no
64
+ * DELETE is issued.
65
+ *
66
+ * Used by the 3WF-2 ThrashGuard to persist exactly two keys:
67
+ * - `thrasguard.baseline_tokens` — the live-window token count at the moment
68
+ * an ineffective compaction was observed (the baseline the guard re-arms from).
69
+ * - `thrasguard.blocked_until` — the live-window token count below which
70
+ * re-firing is refused (guard active).
71
+ *
72
+ * Non-finite input (NaN / ±Infinity) is rejected: the extension must never
73
+ * persist a non-number into the meta table (getMetaNumber would read it back as
74
+ * 0), so we return early, non-fatal. Best-effort: any store failure is swallowed.
75
+ */
76
+ export function setMetaNumber(
77
+ key: string,
78
+ value: number,
79
+ stateDir: string = getStateDir(),
80
+ ): void {
81
+ if (key.length === 0) return;
82
+ if (!Number.isFinite(value)) return;
83
+ try {
84
+ const db = openStore(stateDir);
85
+ db.prepare(
86
+ `INSERT INTO meta(key, value) VALUES(?, ?)
87
+ ON CONFLICT(key) DO UPDATE SET value = ?`,
88
+ ).run(key, String(value), String(value));
89
+ } catch {
90
+ /* non-fatal: meta writes never break the agent loop */
91
+ }
92
+ }
93
+
58
94
  /** Atomically add `delta` to an integer meta counter. */
59
95
  function incMeta(key: string, delta: number, stateDir: string = getStateDir()): void {
60
96
  if (!(delta > 0)) return;