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,122 @@
1
+ /**
2
+ * src/failback/compact.ts — 3WF-2 candidate-veto + vote module (pure, advisory).
3
+ *
4
+ * The production bug this fixes: compaction "succeeded" (a checkpoint was
5
+ * persisted, `saved` grew) while the LIVE WINDOW (`currentTokens`) never
6
+ * shrank — because `saved` is a cumulative SQLite total, not the working-set
7
+ * delta. This module builds competing summary candidates (extractive vs
8
+ * cluster/raptor) and VOTES which one, if any, is worth replacing the
9
+ * supersede-only result. It is purely advisory/observational: it never mutates
10
+ * a checkpoint, never overwrites `result.summary`, and returning `null` means
11
+ * "keep the supersede-only result" — the caller must NOT substitute a summary.
12
+ *
13
+ * Pure: no store mutation, no I/O, no network, no console.*. pi-agnostic
14
+ * (imports only from src/). Designed for the 3WF umbrella flag gate at the
15
+ * extension layer (see extensions/mega-pipeline/compact/vote.ts).
16
+ */
17
+
18
+ import type { EngineMessage } from "../types.js";
19
+ import { collectRecentUserRequests, summarizeMessages } from "../compact.js";
20
+ import { summarizeCluster } from "../dedup/raptor/summarizer.js";
21
+ import { estimateBlockTokens } from "../tokens.js";
22
+ import type { CompactCandidate } from "./types.js";
23
+
24
+ /**
25
+ * Default floor (tokens of net reduction) below which a candidate vote is
26
+ * REJECTED, returning `null` (keep the supersede-only result).
27
+ *
28
+ * Rationale: a candidate that reduces the region by fewer than 1 token is not
29
+ * meaningfully smaller than the compacted region it would replace — swapping
30
+ * the supersede-only result for it buys nothing and only adds a (possibly
31
+ * less faithful) summary. The floor therefore requires the voted summary to be
32
+ * STRICTLY smaller than the compacted region. Set to 1 (minimally defensible:
33
+ * the summary must actually be smaller). Overridable via `opts.floor`.
34
+ */
35
+ export const DEFAULT_VOTE_FLOOR_TOKENS = 1;
36
+
37
+ /** Strip a trailing ellipsis/truncation marker from a needle before containment. */
38
+ function stripEllipsis(s: string): string {
39
+ // collectRecentUserRequests truncates to 160 chars via compact.ts's truncate,
40
+ // which appends the U+2026 ellipsis when it cuts. Drop it for a fair test.
41
+ return s.replace(/…\s*$/u, "").trim();
42
+ }
43
+
44
+ /** Normalize for containment: collapse whitespace, lowercase. */
45
+ function normalize(s: string): string {
46
+ return s.replace(/\s+/g, " ").trim().toLowerCase();
47
+ }
48
+
49
+ /** True when `summary` contains the content of EVERY recent user request. */
50
+ export function signalPreserved(summary: string, messages: EngineMessage[]): boolean {
51
+ const requests = collectRecentUserRequests(messages, 3);
52
+ if (requests.length === 0) return true; // nothing to preserve
53
+ const haystack = normalize(summary);
54
+ return requests.every((r) => haystack.includes(normalize(stripEllipsis(r))));
55
+ }
56
+
57
+ /**
58
+ * Build the two competing candidates (extractive + cluster/raptor) for a
59
+ * compacted message region. Degenerate (empty/whitespace-only) summaries are
60
+ * VETOED — never returned. Both candidates use estimateBlockTokens(summary) for
61
+ * a single consistent token basis so the vote compares like with like (the
62
+ * cluster path's own tokenEstimate is intentionally ignored for fairness).
63
+ */
64
+ export function buildCandidates(messages: EngineMessage[]): CompactCandidate[] {
65
+ const out: CompactCandidate[] = [];
66
+
67
+ const extractive = summarizeMessages(messages);
68
+ if (extractive.trim().length > 0) {
69
+ out.push({
70
+ source: "extractive",
71
+ summary: extractive,
72
+ tokenEstimate: estimateBlockTokens(extractive),
73
+ signalPreserved: signalPreserved(extractive, messages),
74
+ });
75
+ }
76
+
77
+ // summarizeCluster returns deterministic extractive when MEGACOMPACT_RAPTOR_MODEL
78
+ // is unset and the local-only Ollama variant when set — so using it makes the
79
+ // Ollama path an insertion that adds NO new LLM call site for the on-by-default
80
+ // extraction. No behavior change for the default config.
81
+ const cluster = summarizeCluster(messages).summary;
82
+ if (cluster.trim().length > 0) {
83
+ out.push({
84
+ source: "cluster",
85
+ summary: cluster,
86
+ tokenEstimate: estimateBlockTokens(cluster),
87
+ signalPreserved: signalPreserved(cluster, messages),
88
+ });
89
+ }
90
+
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Vote the best candidate. `score = reduction * (signalPreserved ? 1 : 0.5)`
96
+ * where `reduction = tokensBefore - candidate.tokenEstimate`. Ties resolve to
97
+ * the EARLIER (extractive) candidate for determinism. Returns `null` when the
98
+ * winner's score is below `opts.floor` (default DEFAULT_VOTE_FLOOR_TOKENS) —
99
+ * caller MUST keep the supersede-only result and must NOT substitute a summary.
100
+ */
101
+ export function voteCandidate(
102
+ messages: EngineMessage[],
103
+ tokensBefore: number,
104
+ opts: { floor?: number } = {},
105
+ ): CompactCandidate | null {
106
+ const floor = opts.floor ?? DEFAULT_VOTE_FLOOR_TOKENS;
107
+ const candidates = buildCandidates(messages);
108
+ let best: CompactCandidate | null = null;
109
+ let bestScore = -Infinity;
110
+ for (const c of candidates) {
111
+ const reduction = tokensBefore - c.tokenEstimate;
112
+ const score = reduction * (c.signalPreserved ? 1 : 0.5);
113
+ // Earlier candidate wins ties (strict > keeps insertion order = extractive first).
114
+ if (score > bestScore) {
115
+ bestScore = score;
116
+ best = c;
117
+ }
118
+ }
119
+ if (best === null) return null;
120
+ if (bestScore < floor) return null;
121
+ return best;
122
+ }
@@ -44,3 +44,75 @@ export interface GuardOpts {
44
44
  /** Max hits to recall (mirrors autoInlineK). */
45
45
  limit: number;
46
46
  }
47
+
48
+ /** A competing compaction summary candidate produced by the 3-source vote. */
49
+ export interface CompactCandidate {
50
+ /** Which generator produced this candidate (structural telemetry label —
51
+ * never infer the source by sniffing the summary text). */
52
+ source: "extractive" | "cluster";
53
+ /** The candidate summary text (extractive or cluster/raptor variant). */
54
+ summary: string;
55
+ /** Estimated token cost of the candidate summary (estimateBlockTokens basis). */
56
+ tokenEstimate: number;
57
+ /** True when the summary preserves every recent user request signal. */
58
+ signalPreserved: boolean;
59
+ }
60
+
61
+ /**
62
+ * The measured reduction verdict across consecutive `context` events in the
63
+ * LIVE WINDOW (the model's current working tokens), NOT the stored-checkpoint
64
+ * `saved` metric. The live-window `currentTokens` delta is the real signal of
65
+ * whether compaction actually freed working context; the stored `saved` field
66
+ * is a cumulative SQLite total that can look healthy while the live window is
67
+ * unchanged — the false metric behind the production thrash bug this sprint
68
+ * fixes. `liveBefore`/`liveAfter` are the live-window token counts bracketing
69
+ * the compaction.
70
+ */
71
+ export interface ReductionVerdict {
72
+ /** True when the live window measurably shrank after compaction. */
73
+ effective: boolean;
74
+ /** Live-window token count before the compaction event. */
75
+ liveBefore: number;
76
+ /** Live-window token count after the compaction event. */
77
+ liveAfter: number;
78
+ }
79
+
80
+ /**
81
+ * State of the compaction thrash guard. Arms after a compaction that produced
82
+ * no live-window reduction, so we do not re-fire into a window that cannot
83
+ * shrink. Re-arms or clears as the live window grows again.
84
+ */
85
+ export interface ThrashGuardState {
86
+ /** Live-window token count below which re-firing is refused (guard active). */
87
+ blockedUntilTokens: number;
88
+ /** ms epoch at which the guard was armed. */
89
+ armedAt: number;
90
+ }
91
+
92
+ /**
93
+ * A single recall candidate surfaced by one of the three independent, read-only
94
+ * recall sources in the 3WF-3 vote. `score` is the raw per-source score; the
95
+ * voter normalizes each source to a comparable 0..1 before combining.
96
+ */
97
+ export interface RecallCandidate {
98
+ /** Checkpoint id named by the source. */
99
+ checkpointId: string;
100
+ /** Raw per-source relevance score (scale depends on `source`). */
101
+ score: number;
102
+ /** Which independent source named this candidate. */
103
+ source: "vector" | "fts5" | "recency";
104
+ }
105
+
106
+ /**
107
+ * Outcome of the three-source recall vote. `winners` are the agreed candidates
108
+ * (ranked), `votes` counts how many distinct sources named each checkpointId,
109
+ * and `divergentSources` lists the sources that contributed no winner.
110
+ */
111
+ export interface VoteResult {
112
+ /** Agreed candidates, ranked best-first. */
113
+ winners: RecallCandidate[];
114
+ /** Per-checkpointId vote count (1..3 distinct sources). */
115
+ votes: Record<string, number>;
116
+ /** Source names that produced no winning candidate. */
117
+ divergentSources: string[];
118
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * recall/readonly.ts — read-only recall variant (3WF-3 Source A).
3
+ *
4
+ * A pure search+rank seam wrapping `engine.recall`'s RAW `hits` path. It is the
5
+ * canonical read-only entry point going forward (triggerGuard.ts still inlines
6
+ * `recall(...).hits` for its own need; this module is additive and does NOT
7
+ * refactor it).
8
+ *
9
+ * HARD contract (QA): this module MUST NOT call `vectorMarkInjected`, must NOT
10
+ * write any turn/recall rows, and must NOT emit S43 telemetry. It only searches
11
+ * and returns hits for the vote. RecallAndInline's inject loop is the ONLY place
12
+ * the injected-set is mutated; keying the vote on raw `hits` (skipInjected:false
13
+ * => hits === newHits) is deliberate — `newHits` is post-`skipInjected` filter,
14
+ * which would distort overlap appearance.
15
+ *
16
+ * Non-fatal: any failure returns [] so the caller degrades to other sources.
17
+ * Pi-agnostic: no pi runtime imports.
18
+ */
19
+ import { recall } from "../engine.js";
20
+ import type { VectorStore } from "../vectorStore.js";
21
+ import type { SearchHit } from "../vectorStore.js";
22
+
23
+ /** Options for the read-only recall seam. */
24
+ export interface ReadonlyRecallOptions {
25
+ /** Normalized session id. */
26
+ sessionId: string;
27
+ /** Recall query text. */
28
+ query: string;
29
+ /** Max hits to return (default 3). */
30
+ limit?: number;
31
+ }
32
+
33
+ /**
34
+ * Raw, read-only recall hits for the 3-source vote. Returns `engine.recall`'s
35
+ * RAW `.hits` (skipInjected:false => equals the unfiltered vector result). No
36
+ * injected-set mutation, no turn writes, no telemetry. Returns [] on failure.
37
+ */
38
+ export function recallRawHits(
39
+ opts: ReadonlyRecallOptions,
40
+ store: VectorStore,
41
+ ): SearchHit[] {
42
+ try {
43
+ const result = recall(
44
+ {
45
+ sessionId: opts.sessionId,
46
+ query: opts.query,
47
+ limit: opts.limit ?? 3,
48
+ skipInjected: false,
49
+ },
50
+ store,
51
+ );
52
+ return result.hits;
53
+ } catch {
54
+ // Non-fatal: never break the agent loop. Degrade to other sources.
55
+ return [];
56
+ }
57
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * src/recall/recall3wf.fixture.ts — shared fixtures for the 3WF-3 recall tests.
3
+ *
4
+ * Split out of recall3wf.test.ts (which crossed the src 300 soft cap) so each
5
+ * test file stays under the limit. These are REAL fixtures, not mocks/stubs:
6
+ * a REAL VectorStore over a temp stateDir, REAL checkpoints persisted via
7
+ * compactSession, and readers that go through the SAME working path the
8
+ * extension uses (recallRawHits -> vectorSearch -> listCheckpoints, and
9
+ * vectorWasInjected), mirroring the proven triggerGuard test pattern.
10
+ */
11
+ import { mkdtempSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import { VectorStore } from "../vectorStore.js";
16
+ import { compactSession } from "../engine.js";
17
+ import { recallAndInline } from "../recall.js";
18
+ import { recallRawHits } from "./readonly.js";
19
+ import { openStore } from "../store/sqlite/utils.js";
20
+ import { initSchema } from "../store/sqlite/schema.js";
21
+
22
+ /** Real EngineMessage fixture. */
23
+ export function msg(role: "user" | "assistant", text: string): any {
24
+ return { role, text };
25
+ }
26
+
27
+ /** Fresh isolated state dir per VectorStore. */
28
+ export function freshStore(): { store: VectorStore; dir: string } {
29
+ const dir = mkdtempSync(join(tmpdir(), "mc-3wf-"));
30
+ return { store: new VectorStore({ dedupSim: 0.9, stateDir: dir }), dir };
31
+ }
32
+
33
+ /** Persist N distinct checkpoints with distinct content + ascending timestamps. */
34
+ export function seed(store: VectorStore, topics: string[], sid = "sess_3wf"): void {
35
+ topics.forEach((t, i) => {
36
+ compactSession(
37
+ {
38
+ sessionId: sid,
39
+ messages: [msg("user", t), msg("assistant", "ok")],
40
+ keepFrom: 2,
41
+ timestamp: i + 1,
42
+ },
43
+ store,
44
+ );
45
+ });
46
+ }
47
+
48
+ /** Checkpoint ids via the real search path (vectorSearch -> listCheckpoints). */
49
+ export function checkpointIds(store: VectorStore, sid: string, query: string): string[] {
50
+ return recallRawHits({ sessionId: sid, query, limit: 10 }, store).map(
51
+ (h) => h.checkpoint.checkpointId,
52
+ );
53
+ }
54
+
55
+ /** Run the real recallAndInline path with skipInjected:false so nothing is
56
+ * marked and the block reflects the search result exactly (deterministic). */
57
+ export function recallAndInlineCapture(
58
+ sid: string,
59
+ query: string,
60
+ store: VectorStore,
61
+ ): { block: string; empty: boolean; toInject: unknown[] } {
62
+ const r = recallAndInline(
63
+ { sessionId: sid, query, limit: 3, source: "command", skipInjected: false, windowDedupe: false },
64
+ store,
65
+ );
66
+ return { block: r.block, empty: r.empty, toInject: r.toInject };
67
+ }
68
+
69
+ /** Count recall-provenance rows (turn_recall) for a session via raw SQL reader. */
70
+ export function countTurnRecallRows(store: VectorStore, sid: string): number {
71
+ try {
72
+ const reader = openStore(store.stateDir);
73
+ // Ensure the turns/turn_recall tables exist so a 0-count is meaningful
74
+ // (a write on the new path would be visible, not masked by a missing table).
75
+ initSchema(reader);
76
+ const row = reader
77
+ .prepare(
78
+ `SELECT COUNT(*) AS n FROM turn_recall tr
79
+ JOIN turns t ON t.id = tr.turn_id
80
+ WHERE t.session_id = ?`,
81
+ )
82
+ .get(sid) as { n: number };
83
+ return row?.n ?? 0;
84
+ } catch {
85
+ return 0;
86
+ }
87
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * recall/validator.ts — independent candidate validator (3WF-3).
3
+ *
4
+ * Judges candidates handed to it; it MUST NOT call any search itself. Given the
5
+ * ranked vote winners + the live-window text (already extracted by the caller,
6
+ * since src/ cannot import pi types), it walks the winners in order and returns
7
+ * the first that passes BOTH gates:
8
+ *
9
+ * 1. Cosine floor: the winner's score >= the same-repo floor (default 0.12,
10
+ * env MEGACOMPACT_RECALL_MIN_COSINE). The cross-repo 0.90 floor
11
+ * (config.crossRepoCosine) is SEPARATE and intentionally untouched.
12
+ * 2. Not already resident in the live window: reuse recall/sync.ts's exact
13
+ * comparison — embed each live message, embed the checkpoint summary, and
14
+ * treat the checkpoint as resident when cosineSimilarity >= dedupSim. We
15
+ * reuse that metric rather than inventing a new one.
16
+ *
17
+ * On a failing candidate it advances to the next-ranked winner. If ALL fail it
18
+ * returns the provenance floor (FloorBlock built from the newest checkpoint —
19
+ * pure over checkpoints, same semantics as triggerGuard's buildFloorBlock).
20
+ *
21
+ * Non-fatal throughout: any error degrades to the next candidate / the floor.
22
+ * Pi-agnostic: no pi runtime imports.
23
+ */
24
+ import { defaultEmbedder, cosineSimilarity } from "../embedder.js";
25
+ // SQLite store, NOT src/store.ts's legacy gzipped-JSON DR reader (that returns
26
+ // [] for live sessions). Mirrors vector-search.ts / tieredRouter.ts.
27
+ import { listCheckpoints } from "../store/sqlite.js";
28
+ import { RECALL_MIN_COSINE } from "../config.js";
29
+ import type { VectorStore } from "../vectorStore.js";
30
+ import type { RecallCandidate, FloorBlock } from "../failback/types.js";
31
+
32
+ /** Options for the recall validator. */
33
+ export interface ValidateOptions {
34
+ /** Normalized session id (for floor-block construction). */
35
+ sessionId: string;
36
+ /**
37
+ * The recall query. Required for a TRUE cosine gate: only `source:"vector"`
38
+ * candidates carry a cosine in `score` (fts5 carries BM25, recency carries a
39
+ * freshness rank), so comparing a raw mixed-scale score against a cosine
40
+ * floor would be meaningless. When supplied, the validator re-derives each
41
+ * candidate's cosine against the query locally (embedder only — never a
42
+ * search call, so the "independent of all three search calls" contract
43
+ * holds). When omitted, only `vector` candidates can clear the gate.
44
+ */
45
+ query?: string;
46
+ /** Live-window message texts already extracted by the caller. */
47
+ liveWindow?: string[];
48
+ /** Dedup similarity threshold for the live-window resident check. */
49
+ dedupSim?: number;
50
+ }
51
+
52
+ /** A validated winner, or the provenance floor when all candidates fail. */
53
+ export type ValidationOutcome =
54
+ | { kind: "candidate"; candidate: RecallCandidate }
55
+ | { kind: "floor"; floor: FloorBlock };
56
+
57
+ /** Build the provenance floor block from the session's newest checkpoint. */
58
+ function buildFloorBlock(sessionId: string, store: VectorStore): FloorBlock {
59
+ try {
60
+ const cps = listCheckpoints(sessionId, store.stateDir).filter(
61
+ (c) => c.dedupStatus !== "removed",
62
+ );
63
+ let newest = cps[0];
64
+ for (const cp of cps) {
65
+ if (!newest || (cp.timestamp ?? 0) > (newest.timestamp ?? 0)) newest = cp;
66
+ }
67
+ const summary = newest?.summary?.trim();
68
+ if (summary) {
69
+ return {
70
+ text:
71
+ "The following compacted context is the most recent checkpoint from " +
72
+ "this session (recall found no query-relevant match):\n\n" + summary,
73
+ basis: "lastCheckpoint",
74
+ };
75
+ }
76
+ return {
77
+ text:
78
+ "This session has compacted context but recall could not surface a " +
79
+ "checkpoint relevant to the current request; the most recent checkpoint " +
80
+ "summary is unavailable.",
81
+ basis: "lastCheckpoint",
82
+ };
83
+ } catch {
84
+ return {
85
+ text:
86
+ "This session has compacted context but recall could not surface a " +
87
+ "checkpoint relevant to the current request.",
88
+ basis: "none",
89
+ };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Validate the ranked vote winners, returning the first that passes both gates,
95
+ * or the provenance floor if none do. Does NOT mutate the injected set, does NOT
96
+ * write turns, does NOT emit telemetry. Non-fatal.
97
+ */
98
+ export function validateRecall(
99
+ winners: RecallCandidate[],
100
+ opts: ValidateOptions,
101
+ store: VectorStore,
102
+ ): ValidationOutcome {
103
+ const floor = RECALL_MIN_COSINE();
104
+ const dedupSim = opts.dedupSim ?? 0.9;
105
+ const embedder = defaultEmbedder();
106
+ const liveVecs = (opts.liveWindow ?? []).map((m) => embedder.embed(m));
107
+ // One checkpoint read for the whole pass (both gates share it).
108
+ const cps = listCheckpoints(opts.sessionId, store.stateDir);
109
+ const cpById = new Map(cps.map((c) => [c.checkpointId, c]));
110
+ const queryVec = opts.query ? embedder.embed(opts.query) : null;
111
+
112
+ for (const cand of winners) {
113
+ try {
114
+ const cp = cpById.get(cand.checkpointId);
115
+
116
+ // Gate 1: same-repo COSINE floor. `cand.score` is only a cosine for
117
+ // source "vector"; fts5 (BM25) and recency (freshness rank) live on
118
+ // other scales, so for those we re-derive the true cosine locally from
119
+ // the query + checkpoint embedding. No search call is made.
120
+ let cosine: number;
121
+ if (cand.source === "vector") {
122
+ cosine = cand.score;
123
+ } else if (queryVec && cp) {
124
+ cosine = cosineSimilarity(queryVec, embedder.embed(cp.summary));
125
+ } else {
126
+ // No comparable cosine available => cannot clear a cosine gate.
127
+ continue;
128
+ }
129
+ if (cosine < floor) continue;
130
+
131
+ // Gate 2: not already resident in the live window.
132
+ if (liveVecs.length > 0) {
133
+ if (!cp) continue; // cannot verify => skip rather than risk re-inject
134
+ const hitVec = embedder.embed(cp.summary);
135
+ const resident = liveVecs.some(
136
+ (v) => cosineSimilarity(v, hitVec) >= dedupSim,
137
+ );
138
+ if (resident) continue;
139
+ }
140
+
141
+ return { kind: "candidate", candidate: cand };
142
+ } catch {
143
+ // Non-fatal: skip this candidate, try the next.
144
+ continue;
145
+ }
146
+ }
147
+
148
+ // All candidates rejected -> provenance floor.
149
+ return { kind: "floor", floor: buildFloorBlock(opts.sessionId, store) };
150
+ }