pi-mega-compact 0.21.1 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -116,8 +116,19 @@ export const NEW_UI = () => ragEnabled("MEGACOMPACT_NEW_UI");
116
116
  // default (0.12) keeps recall permissive within a repo while still rejecting
117
117
  // effectively-unrelated hits. Call-time read so tests can set the env per-test.
118
118
  // ---------------------------------------------------------------------------
119
- /** Same-repo recall cosine floor: top winner must be >= this to be injected. */
120
- export const RECALL_MIN_COSINE = () => Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
119
+ /**
120
+ * Same-repo recall cosine floor: top winner must be >= this to be injected.
121
+ *
122
+ * E1 follow-up (PR #18 review): NaN-safe + clamped to [0,1]. A typo'd env var
123
+ * yielded NaN before; `cosine < NaN` is false, which disabled gate 1 entirely
124
+ * (every candidate passed). Non-finite falls back to 0.12; out-of-range clamps.
125
+ */
126
+ export const RECALL_MIN_COSINE = () => {
127
+ const n = Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
128
+ if (!Number.isFinite(n))
129
+ return 0.12;
130
+ return Math.min(1, Math.max(0, n));
131
+ };
121
132
  // ---------------------------------------------------------------------------
122
133
  // Vector-cortex flags + breaker constants (VC0A+). Positive sprint flags,
123
134
  // default ON, `=0`/`_DISABLED` off. Re-exported from src/config/vector-cortex.ts
@@ -19,6 +19,7 @@ export const SHINGLE_SIZE = 5; // char 5-grams
19
19
  const MAX_SHINGLES = 50_000; // QA #7/#15 complexity cap
20
20
  const SEED = 0xdeadbeef;
21
21
  const P = 2147483647; // 2^31 - 1, Mersenne prime
22
+ const PBigInt = 2147483647n; // BigInt twin for overflow-safe modular reduction
22
23
  /** Per-index universal-hashing coefficients, derived deterministically from SEED. */
23
24
  function coeffA(i) {
24
25
  return (SEED + i * 2 + 1) % P;
@@ -67,10 +68,10 @@ export function minhashSignature(text) {
67
68
  const b = coeffB(i);
68
69
  let min = P;
69
70
  for (const x of grams) {
70
- // (a*x + b) mod p — use Number math; a,x < 2^31 so a*x < 2^62, within
71
- // double-precision integer range (2^53) only if reduced; reduce a*x first.
72
- const ax = (a * (x % P)) % P;
73
- const h = (ax + b) % P;
71
+ // (a*x + b) mod p. a, x < 2^31 so a*x < 2^62 — EXCEEDS 2^53. The naive
72
+ // (a*(x%P))%P loses precision (verified: a=x=p-1 lossy 2147483644 vs
73
+ // exact 1). BigInt is correct + cheap (~5ms per signature).
74
+ const h = Number((BigInt(a) * BigInt(x % P) + BigInt(b)) % PBigInt);
74
75
  if (h < min)
75
76
  min = h;
76
77
  }
@@ -138,6 +138,7 @@ export const SETTINGS = [
138
138
  name: "Three-Way Failback",
139
139
  settings: [
140
140
  boolDirect("MEGACOMPACT_THREE_WAY_FAILBACK", "Three-Way Failback", "Umbrella for the 3-way failback safety system: TriggerGuard (stages a recall block even when session_start never fires), the live-window ReductionValidator + persisted ThrashGuard (stops ineffective compaction re-fire loops), the 3-source read-only recall vote + same-repo relevance floor, and InjectionConfirm (asserts the staged block reached the message list pi sends). OFF = byte-identical pre-3WF behavior (v0.20.83). Runtime reads envBool(plain key), so this uses the plain-write convention (not _DISABLED).", true),
141
+ boolDirect("MEGACOMPACT_ITHACUS_BRIDGE", "ithacus Bridge", "Mega↔ithacus bridge: gate the child extension + bridge usage that tie this extension to ithacus's durable compaction. Default ON; OFF (=0/`=false`) is byte-identical to pre-bridge behavior — the bridge is only consulted when this is ON. Positive sprint flag. Runtime reads envBool(plain key), so this uses the plain-write convention (not _DISABLED).", true),
141
142
  boolDirect("MEGACOMPACT_RECALL_TAIL_INJECT", "Recall Tail Inject", "Compose the staged recall block as a trailing user message on the context event (tail inject) instead of the legacy system-prompt prepend. Tail mode keeps the cache prefix stable and is the mode InjectionConfirm verifies against ContextEvent.messages; OFF falls back to the legacy prepend path (verified by string-contains). Runtime reads envBool(plain key), so this uses the plain-write convention (not _DISABLED).", true),
142
143
  ],
143
144
  },
@@ -0,0 +1,126 @@
1
+ /**
2
+ * mega-compact-child.ts — minimal extension loaded ONLY into dispatched child
3
+ * pi subprocesses (spawned by ithacus with a second `-e` flag).
4
+ *
5
+ * Design: a child is a FRESH pi process started with `--no-extensions -e <this
6
+ * file>` (see ithacus-spawn.ts). It does NOT receive the parent's MegaConfig and
7
+ * is a separate process, so it reads its two control env vars directly and owns
8
+ * its own bridge. It gives children recall-at-start + compaction-on-shutdown via
9
+ * the mega-compact bridge, with NO tools and NO console output, so it never
10
+ * pollutes the child's `--mode json` JSONL stdout that ithacus-spawn parses.
11
+ *
12
+ * Per the teammate brief this mirrors ithacus-child-mailbox.ts (default export,
13
+ * no console, dispose on session_shutdown) but registers ZERO tools — registering
14
+ * any tool risks a pi duplicate-tool-name hard-fail and children need none.
15
+ *
16
+ * PREVENT-PI-004: no network. The bridge is a same-repo relative import over a
17
+ * local sqlite store; the only I/O is the read-only `git rev-parse` inside
18
+ * repoStateDir. Nothing to flag.
19
+ */
20
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
21
+ import { createMegaBridge } from "../src/bridge.js";
22
+ import { repoStateDir } from "./mega-config.js";
23
+ import { STATE_DIR_DEFAULT } from "../src/config.js";
24
+ /** Default-ON env bool: only `=false`/`=0` disables (matches mega-config envBool). */
25
+ function envBool(name, fallback) {
26
+ const v = process.env[name];
27
+ if (v == null || v === "")
28
+ return fallback;
29
+ return v === "true" || v === "1";
30
+ }
31
+ /** Extract a recall query from a single AgentMessage (string or content blocks). */
32
+ function messageToText(m) {
33
+ const c = m.content;
34
+ if (typeof c === "string")
35
+ return c;
36
+ if (Array.isArray(c))
37
+ return c.map((b) => b.text ?? "").join(" ");
38
+ return "";
39
+ }
40
+ /** Convert a session's AgentMessages into the bridge's lightweight shape. */
41
+ function toBridgeMessages(ctx) {
42
+ const out = [];
43
+ try {
44
+ for (const entry of ctx.sessionManager.getEntries()) {
45
+ for (const m of sessionEntryToContextMessages(entry)) {
46
+ if (m.role === "user" || m.role === "assistant") {
47
+ out.push({ role: m.role, text: messageToText(m) });
48
+ }
49
+ }
50
+ }
51
+ }
52
+ catch {
53
+ /* non-fatal: a child without a session manager yields no messages */
54
+ }
55
+ return out;
56
+ }
57
+ export default function (pi) {
58
+ // Flag read at LOAD time: a flag-OFF child registers nothing and a flag-ON
59
+ // child that never fires a hook pays zero cost (bridge is built lazily).
60
+ if (!envBool("MEGACOMPACT_ITHACUS_BRIDGE", true))
61
+ return;
62
+ let bridge;
63
+ // Build the bridge lazily on first hook fire so cost is opt-in by usage.
64
+ const getBridge = () => {
65
+ if (!bridge) {
66
+ bridge = createMegaBridge({
67
+ stateDir: repoStateDir(process.cwd(), STATE_DIR_DEFAULT),
68
+ });
69
+ }
70
+ return bridge;
71
+ };
72
+ // S52-style recall injection: prepend staged checkpoints + durable memories
73
+ // to the system prompt, mirroring the main entry's before_agent_start path.
74
+ // 4th-layer stability guard: an unset/empty sessionId makes recall silently
75
+ // useless (the openclaw Date.now() gotcha), so skip outright.
76
+ pi.on("before_agent_start", async (event) => {
77
+ try {
78
+ const sessionId = process.env.ITHACUS_MEGA_SESSION_ID;
79
+ if (!sessionId || sessionId === "")
80
+ return undefined;
81
+ // Prefer the event's raw prompt; fall back to a generic query.
82
+ const query = event.prompt && event.prompt.trim() ? event.prompt.trim() : "";
83
+ if (query === "")
84
+ return undefined;
85
+ const b = getBridge();
86
+ const cp = b.recallCheckpoints({ sessionId, query, limit: 3 });
87
+ const mem = await b.recallMemories({ query, limit: 5 });
88
+ const blocks = [];
89
+ if (!cp.empty && cp.block)
90
+ blocks.push(cp.block);
91
+ if (!mem.empty && mem.block)
92
+ blocks.push(mem.block);
93
+ if (blocks.length === 0)
94
+ return undefined;
95
+ return { systemPrompt: `${event.systemPrompt ?? ""}\n\n${blocks.join("\n\n")}` };
96
+ }
97
+ catch {
98
+ // layer b: non-fatal — never break the agent loop. No injection.
99
+ return undefined;
100
+ }
101
+ });
102
+ // Compaction on shutdown: persist the session's messages as a checkpoint.
103
+ // Best-effort; non-fatal. Releases the sqlite handle via close().
104
+ // The bridge is constructed lazily here too: a child that only compacts (no
105
+ // recall fired) still persists its session. Best-effort; non-fatal.
106
+ pi.on("session_shutdown", async (_event, ctx) => {
107
+ try {
108
+ const sessionId = process.env.ITHACUS_MEGA_SESSION_ID;
109
+ if (!sessionId || sessionId === "")
110
+ return;
111
+ const messages = toBridgeMessages(ctx);
112
+ if (messages.length === 0)
113
+ return;
114
+ await getBridge().compact({ sessionId, messages });
115
+ }
116
+ catch {
117
+ /* non-fatal */
118
+ }
119
+ finally {
120
+ if (bridge) {
121
+ bridge.close();
122
+ bridge = undefined;
123
+ }
124
+ }
125
+ });
126
+ }
@@ -83,6 +83,16 @@ export default function (pi) {
83
83
  console.warn("[mega-compact] MEGACOMPACT_POISONED_REPEAT_THRESHOLD must be >= 1; using default 3");
84
84
  config.poisonedContextRepeatThreshold = 3;
85
85
  }
86
+ // E1: validate similarity thresholds — NaN or out of range silently disables
87
+ // recall dedup (anything >= NaN is false). envFlag guards NaN; clamp the rest.
88
+ if (!(config.dedupSim > 0 && config.dedupSim <= 1)) {
89
+ console.warn("[mega-compact] MEGACOMPACT_DEDUP_SIM must be in (0,1]; using default 0.9");
90
+ config.dedupSim = 0.9;
91
+ }
92
+ if (!(config.crossRepoCosine >= 0 && config.crossRepoCosine <= 1)) {
93
+ console.warn("[mega-compact] MEGACOMPACT_CROSSREPO_COSINE must be in [0,1]; using default 0.9");
94
+ config.crossRepoCosine = 0.9;
95
+ }
86
96
  const runtime = new MegaRuntime(config);
87
97
  registerEventHandlers(pi, runtime, config);
88
98
  registerCommands(pi, runtime, config);
@@ -171,7 +171,7 @@ export function loadConfig() {
171
171
  advisoryChannel: envBool("MEGACOMPACT_ADVISORY_CHANNEL", true),
172
172
  autoPctTrigger,
173
173
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
174
- dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
174
+ dedupSim: envFlag("MEGACOMPACT_DEDUP_SIM", 0.9),
175
175
  raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
176
176
  legacyDurableTrim: envBool("MEGACOMPACT_LEGACY_DURABLE_TRIM", false),
177
177
  dbMirror: envBool("MEGACOMPACT_DB_MIRROR", false),
@@ -180,13 +180,16 @@ export function loadConfig() {
180
180
  turnsDbEnabled: envBool("MEGACOMPACT_TURNS_DB", true),
181
181
  autoWikiEnabled: envBool("MEGACOMPACT_AUTO_WIKI", true),
182
182
  crossRepoEnabled: envBool("MEGACOMPACT_CROSSREPO_ENABLED", true),
183
- crossRepoCosine: Number(process.env.MEGACOMPACT_CROSSREPO_COSINE ?? "0.90"),
183
+ crossRepoCosine: envFlag("MEGACOMPACT_CROSSREPO_COSINE", 0.9),
184
184
  // 3WF-3: SAME-repo recall cosine floor applied by the 3-source validator to
185
185
  // the top winner. SEPARATE from crossRepoCosine (S17, default 0.90, stricter
186
186
  // and cross-repo only). This same-repo floor is permissive by default (0.12)
187
187
  // so recall still surfaces loosely-relevant within-repo context while
188
188
  // rejecting effectively-unrelated hits. Mirrors src/config.ts RECALL_MIN_COSINE.
189
- recallMinCosine: Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12"),
189
+ // E1 follow-up (PR #18 review): envFlag (Number.isFinite-guarded) like the
190
+ // dedupSim/crossRepoCosine fix in PR #18 — a typo'd env var must fall back
191
+ // to 0.12, not yield NaN.
192
+ recallMinCosine: envFlag("MEGACOMPACT_RECALL_MIN_COSINE", 0.12),
190
193
  memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
191
194
  memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
192
195
  recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
@@ -195,6 +198,11 @@ export function loadConfig() {
195
198
  // 3WF-1: TriggerGuard — guarantee a staged recall block on every context
196
199
  // event even when session_start never fires. Default ON; OFF = byte-identical.
197
200
  threeWayFailback: envBool("MEGACOMPACT_THREE_WAY_FAILBACK", true),
201
+ // Sprint A: Mega↔ithacus bridge — gate the child extension + bridge usage.
202
+ // Default ON; OFF (=0/`=false`) = byte-identical pre-bridge behavior.
203
+ // Runtime reads envBool(plain key), mirroring threeWayFailback (plain-write
204
+ // convention, not _DISABLED).
205
+ ithacusBridge: envBool("MEGACOMPACT_ITHACUS_BRIDGE", true),
198
206
  // 3WF-2: ThrashGuard re-arm budget as a fraction of effectiveThreshold.
199
207
  // 0.10 default (10% of the effective threshold) — see mega-config-types.
200
208
  // Clamped to [0.01, 0.5]: below 1% the guard is almost never armed (any
@@ -0,0 +1,181 @@
1
+ /**
2
+ * bridge/factory.ts — `createMegaBridge(opts)` implementation.
3
+ *
4
+ * A thin, pi-agnostic wrapper over the engine's compaction / recall / memory /
5
+ * fork / vector APIs. Stores are constructed lazily on first use (a consumer
6
+ * that only calls recallMemories pays no VectorStore cost). Exceptions
7
+ * propagate from every method except `fork` (catches ForkError by design) and
8
+ * `close` (swallows best-effort cleanup), so failures surface in tests.
9
+ */
10
+ import { compactSession } from "../engine.js";
11
+ import { recallAndInline, recallAndInlineAsync, recallMemoriesAndInline, } from "../recall.js";
12
+ import { forkFromConversation, ForkError } from "../fork.js";
13
+ import { createTurnStore } from "../store/turns/index.js";
14
+ import { addMemory } from "../store/sqlite/memories.js";
15
+ import { VectorStore, vectorSearch } from "../vectorStore.js";
16
+ import { repoKey } from "../store/repoKey.js";
17
+ /** Map a RecallInjectResult to the bridge's slimmer result contract. */
18
+ function mapRecallResult(r) {
19
+ return {
20
+ block: r.block,
21
+ report: r.report,
22
+ hitCount: r.toInject.length,
23
+ empty: r.empty,
24
+ };
25
+ }
26
+ /** Map the memoryRecallAndInline tuple result to the bridge contract. */
27
+ function mapMemoryResult(r) {
28
+ return {
29
+ block: r.block,
30
+ report: r.report,
31
+ hitCount: r.report.length,
32
+ empty: r.empty,
33
+ };
34
+ }
35
+ /** Map vectorSearch hits to the cortex result contract. */
36
+ function mapCortexHits(hits, limit) {
37
+ const top = hits.slice(0, limit);
38
+ return {
39
+ results: top.map((h) => ({
40
+ checkpointId: h.checkpoint.checkpointId,
41
+ score: h.score,
42
+ summary: h.checkpoint.summary,
43
+ })),
44
+ hitCount: top.length,
45
+ };
46
+ }
47
+ /**
48
+ * Create a MegaBridge over a single stateDir.
49
+ *
50
+ * The VectorStore and TurnStore are lazy: constructed on first use and cached
51
+ * in closures. The stateDir is retained for memory recall, which needs it
52
+ * directly.
53
+ */
54
+ export function createMegaBridge(opts) {
55
+ const stateDir = opts.stateDir;
56
+ let vectorStore;
57
+ let turnStore;
58
+ const getVectorStore = () => {
59
+ if (!vectorStore)
60
+ vectorStore = new VectorStore({ stateDir });
61
+ return vectorStore;
62
+ };
63
+ const getTurnStore = () => {
64
+ if (!turnStore)
65
+ turnStore = createTurnStore({ stateDir });
66
+ return turnStore;
67
+ };
68
+ return {
69
+ compact(input) {
70
+ const result = compactSession({
71
+ sessionId: input.sessionId,
72
+ messages: input.messages,
73
+ keepFrom: input.keepFrom,
74
+ summary: input.summary,
75
+ keyDecisions: input.keyDecisions,
76
+ nextSteps: input.nextSteps,
77
+ filesModified: input.filesModified,
78
+ compressionPressure: input.compressionPressure,
79
+ }, getVectorStore());
80
+ return {
81
+ skipped: result.skipped,
82
+ deduped: result.deduped,
83
+ summary: result.summary,
84
+ checkpointId: result.checkpointId,
85
+ tokenEstimate: result.tokenEstimate,
86
+ originalTokenEstimate: result.originalTokenEstimate,
87
+ compactedFrom: result.compactedFrom,
88
+ };
89
+ },
90
+ recallCheckpoints(opts) {
91
+ const recallOpts = {
92
+ sessionId: opts.sessionId,
93
+ query: opts.query,
94
+ limit: opts.limit ?? 3,
95
+ source: "command",
96
+ skipInjected: opts.skipInjected,
97
+ recallMaxTokens: opts.recallMaxTokens,
98
+ };
99
+ return mapRecallResult(recallAndInline(recallOpts, getVectorStore()));
100
+ },
101
+ async recallMemories(opts) {
102
+ const memOpts = {
103
+ query: opts.query,
104
+ stateDir,
105
+ limit: opts.limit,
106
+ minSimilarity: opts.minSimilarity,
107
+ crossRepo: opts.crossRepo,
108
+ crossRepoCosine: opts.crossRepoCosine,
109
+ recallMaxTokens: opts.recallMaxTokens,
110
+ };
111
+ const r = await recallMemoriesAndInline(memOpts);
112
+ return mapMemoryResult(r);
113
+ },
114
+ async recallAndInlineAsync(opts) {
115
+ const recallOpts = {
116
+ sessionId: opts.sessionId,
117
+ query: opts.query,
118
+ limit: opts.limit ?? 3,
119
+ source: "command",
120
+ skipInjected: opts.skipInjected,
121
+ recallMaxTokens: opts.recallMaxTokens,
122
+ };
123
+ const r = await recallAndInlineAsync(recallOpts, getVectorStore());
124
+ return mapRecallResult(r);
125
+ },
126
+ fork(opts) {
127
+ try {
128
+ const outcome = forkFromConversation(getTurnStore(), opts.parentConversationId, opts.turnIndex);
129
+ return {
130
+ childConversationId: outcome.childConversationId,
131
+ checkpointIds: outcome.checkpointIds,
132
+ forkTurnIndex: opts.turnIndex,
133
+ };
134
+ }
135
+ catch (e) {
136
+ if (e instanceof ForkError) {
137
+ return { error: e.code };
138
+ }
139
+ throw e;
140
+ }
141
+ },
142
+ cortexQuery(opts) {
143
+ const limit = opts.limit ?? 3;
144
+ const scope = opts.repo ?? repoKey(stateDir);
145
+ const hits = vectorSearch(getVectorStore(), scope, opts.query, limit);
146
+ return mapCortexHits(hits, limit);
147
+ },
148
+ addMemory(input) {
149
+ // repo === null ⇒ stateDir-scoped durable memory (matches recallMemories).
150
+ return addMemory({
151
+ kind: input.kind,
152
+ content: input.content,
153
+ tags: input.tags,
154
+ category: input.category,
155
+ }, null, stateDir);
156
+ },
157
+ recordTurn(input) {
158
+ const turn = {
159
+ conversationId: input.conversationId,
160
+ sessionId: input.sessionId,
161
+ turnIndex: input.turnIndex,
162
+ role: input.role ?? "assistant",
163
+ endedAt: input.endedAt ?? Date.now(),
164
+ ctxTokens: input.ctxTokens,
165
+ ctxPercent: input.ctxPercent,
166
+ model: input.model,
167
+ };
168
+ getTurnStore().asWriter().appendTurn(turn);
169
+ },
170
+ close() {
171
+ if (turnStore) {
172
+ try {
173
+ turnStore.close();
174
+ }
175
+ catch {
176
+ /* best-effort */
177
+ }
178
+ }
179
+ },
180
+ };
181
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * bridge/types.ts — contract types for the bidirectional mega-compact bridge.
3
+ *
4
+ * A pi-agnostic, unit-testable adapter surface that wraps the engine's
5
+ * compaction / recall / memory / fork / vector APIs behind one factory so an
6
+ * external host (ithacus) can drive them without importing pi-runtime types.
7
+ * Every type here mirrors a real engine signature (see factory.ts for the
8
+ * wiring).
9
+ */
10
+ export {};
@@ -0,0 +1 @@
1
+ export { createMegaBridge } from "./bridge/factory.js";
@@ -116,8 +116,19 @@ export const NEW_UI = () => ragEnabled("MEGACOMPACT_NEW_UI");
116
116
  // default (0.12) keeps recall permissive within a repo while still rejecting
117
117
  // effectively-unrelated hits. Call-time read so tests can set the env per-test.
118
118
  // ---------------------------------------------------------------------------
119
- /** Same-repo recall cosine floor: top winner must be >= this to be injected. */
120
- export const RECALL_MIN_COSINE = () => Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
119
+ /**
120
+ * Same-repo recall cosine floor: top winner must be >= this to be injected.
121
+ *
122
+ * E1 follow-up (PR #18 review): NaN-safe + clamped to [0,1]. A typo'd env var
123
+ * yielded NaN before; `cosine < NaN` is false, which disabled gate 1 entirely
124
+ * (every candidate passed). Non-finite falls back to 0.12; out-of-range clamps.
125
+ */
126
+ export const RECALL_MIN_COSINE = () => {
127
+ const n = Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
128
+ if (!Number.isFinite(n))
129
+ return 0.12;
130
+ return Math.min(1, Math.max(0, n));
131
+ };
121
132
  // ---------------------------------------------------------------------------
122
133
  // Vector-cortex flags + breaker constants (VC0A+). Positive sprint flags,
123
134
  // default ON, `=0`/`_DISABLED` off. Re-exported from src/config/vector-cortex.ts
@@ -19,6 +19,7 @@ export const SHINGLE_SIZE = 5; // char 5-grams
19
19
  const MAX_SHINGLES = 50_000; // QA #7/#15 complexity cap
20
20
  const SEED = 0xdeadbeef;
21
21
  const P = 2147483647; // 2^31 - 1, Mersenne prime
22
+ const PBigInt = 2147483647n; // BigInt twin for overflow-safe modular reduction
22
23
  /** Per-index universal-hashing coefficients, derived deterministically from SEED. */
23
24
  function coeffA(i) {
24
25
  return (SEED + i * 2 + 1) % P;
@@ -67,10 +68,10 @@ export function minhashSignature(text) {
67
68
  const b = coeffB(i);
68
69
  let min = P;
69
70
  for (const x of grams) {
70
- // (a*x + b) mod p — use Number math; a,x < 2^31 so a*x < 2^62, within
71
- // double-precision integer range (2^53) only if reduced; reduce a*x first.
72
- const ax = (a * (x % P)) % P;
73
- const h = (ax + b) % P;
71
+ // (a*x + b) mod p. a, x < 2^31 so a*x < 2^62 — EXCEEDS 2^53. The naive
72
+ // (a*(x%P))%P loses precision (verified: a=x=p-1 lossy 2147483644 vs
73
+ // exact 1). BigInt is correct + cheap (~5ms per signature).
74
+ const h = Number((BigInt(a) * BigInt(x % P) + BigInt(b)) % PBigInt);
74
75
  if (h < min)
75
76
  min = h;
76
77
  }
@@ -76,7 +76,14 @@ export function validateRecall(winners, opts, store) {
76
76
  // No comparable cosine available => cannot clear a cosine gate.
77
77
  continue;
78
78
  }
79
- if (cosine < floor)
79
+ // E1 follow-up (PR #18 review): NaN/Infinity must NEVER clear the floor.
80
+ // `NaN < floor` is false, so an unguarded comparison lets a NaN cosine
81
+ // PASS gate 1 and inject — one NaN source poisons the whole 3WF-3
82
+ // quorum. Reject non-finite scores explicitly; the candidate is skipped
83
+ // and, if all fail, the provenance floor ("no recall") is returned —
84
+ // never a zero-score injection. The default TrigramEmbedder cannot
85
+ // produce NaN (zero-norm guard), but a BYO localhost embedder can.
86
+ if (!Number.isFinite(cosine) || cosine < floor)
80
87
  continue;
81
88
  // Gate 2: not already resident in the live window.
82
89
  if (liveVecs.length > 0) {
@@ -31,17 +31,36 @@ import { listCheckpoints } from "../store/sqlite.js";
31
31
  import { computeContentDigest } from "../dedup/digest.js";
32
32
  import { recallRawHits } from "./readonly.js";
33
33
  import { Logger } from "../log.js";
34
- /** Per-source normalization: map raw scores to 0..1 via min-max within source. */
35
- function normalizeScores(scores) {
34
+ /**
35
+ * Per-source normalization: map raw scores to 0..1 via min-max within source.
36
+ *
37
+ * E1 follow-up (PR #18 review): non-finite scores (NaN/±Infinity) are DROPPED
38
+ * before the min/max fold — a single NaN silently propagates through Math.min/
39
+ * max and turns EVERY normalized score of that source into NaN (verified),
40
+ * poisoning the whole 3-source quorum. Dropping the bad entry degrades that
41
+ * source gracefully instead. Exported so the guard is unit-testable directly.
42
+ */
43
+ export function normalizeScores(scores) {
36
44
  const map = new Map();
37
- if (scores.length === 0)
38
- return map;
39
- const min = Math.min(...scores);
40
- const max = Math.max(...scores);
41
- const span = max - min;
45
+ const finite = [];
42
46
  scores.forEach((s, i) => {
43
- map.set(i, span === 0 ? 1 : (s - min) / span);
47
+ if (Number.isFinite(s))
48
+ finite.push({ idx: i, s });
44
49
  });
50
+ if (finite.length === 0)
51
+ return map;
52
+ let min = Infinity;
53
+ let max = -Infinity;
54
+ for (const { s } of finite) {
55
+ if (s < min)
56
+ min = s;
57
+ if (s > max)
58
+ max = s;
59
+ }
60
+ const span = max - min;
61
+ for (const { idx, s } of finite) {
62
+ map.set(idx, span === 0 ? 1 : (s - min) / span);
63
+ }
45
64
  return map;
46
65
  }
47
66
  /**
@@ -130,7 +149,13 @@ export function voteRecall(opts, store) {
130
149
  const norm = perSource.find((p) => p.name === src.name).norm;
131
150
  const bestByCp = new Map();
132
151
  src.cands.forEach((c, i) => {
133
- const n = norm.get(i) ?? 0;
152
+ // E1 follow-up: normalizeScores dropped non-finite scores; such a hit is
153
+ // NOT a valid nomination — skip it entirely instead of defaulting it to
154
+ // 0 (which would still name the checkpoint and let it rank last into the
155
+ // fallback ranking).
156
+ const n = norm.get(i);
157
+ if (n === undefined)
158
+ return;
134
159
  const prev = bestByCp.get(c.checkpointId);
135
160
  if (prev === undefined || n > prev)
136
161
  bestByCp.set(c.checkpointId, n);
@@ -87,7 +87,12 @@ export function hydrateFts5Hits(hits, sessionId, stateDir) {
87
87
  const out = [];
88
88
  for (const h of hits) {
89
89
  const cp = cpMap.get(h.id);
90
- if (cp)
90
+ // H1 follow-up (PR #18 review): exclude SemDeDup-'removed' rows. The FTS5
91
+ // index is NOT pruned by vectorSemDedup, so a removed row can still MATCH;
92
+ // hydrating it would let the fts5 voter NAME a dead checkpoint (a removed
93
+ // row plus a recency vote is a 2/3 agreement that passes the validator).
94
+ // Mirrors vectorSearch's read-time filter.
95
+ if (cp && cp.dedupStatus !== "removed")
91
96
  out.push({ checkpointId: h.id, score: h.score, summary: cp.summary });
92
97
  }
93
98
  return out;
@@ -72,7 +72,10 @@ export function vectorDedupe(store, sessionId, regionHashOrText, isText = false)
72
72
  const state = loadSessionState(sid, stateDir);
73
73
  if (state.storedRegionHashes.includes(hash))
74
74
  return true;
75
- return listCheckpoints(sid, stateDir).some((c) => c.regionHash === hash);
75
+ // H1 follow-up (PR #18 review): a SemDeDup-'removed' row's regionHash must not
76
+ // report "already represented" — its content is excluded from recall, so the
77
+ // incoming region is NOT deduplicated in any retrievable sense.
78
+ return listCheckpoints(sid, stateDir).some((c) => c.dedupStatus !== "removed" && c.regionHash === hash);
76
79
  }
77
80
  // ---------------------------------------------------------------------------
78
81
  // Injection tracking
@@ -115,7 +118,8 @@ export function vectorTopSimilar(store, sessionId, n) {
115
118
  const ordered = [...checkpoints].sort((a, b) => a.checkpointId.localeCompare(b.checkpointId));
116
119
  const current = ordered[ordered.length - 1];
117
120
  const scored = ordered
118
- .filter((cp) => cp.checkpointId !== current.checkpointId)
121
+ .filter((cp) => cp.checkpointId !== current.checkpointId &&
122
+ cp.dedupStatus !== "removed")
119
123
  .map((cp) => ({
120
124
  checkpoint: cp,
121
125
  score: cosineSimilarity(current.embedding, cp.embedding),
@@ -40,7 +40,11 @@ export function addCheckpoint(store, input) {
40
40
  const t0 = Date.now();
41
41
  const sessionId = normalizeSessionId(input.sessionId);
42
42
  const regionHash = computeRegionHash(input.regionText);
43
- const all = listCheckpoints(sessionId, store.stateDir);
43
+ // H1: exclude SemDeDup-'removed' rows from dedup matching. search() already
44
+ // filters these, but add() did NOT — so an L0/L1/L2 match against a previously
45
+ // removed duplicate would upsertCheckpoint it back to active, resurrecting it
46
+ // into recall and defeating SemDeDup.
47
+ const all = listCheckpoints(sessionId, store.stateDir).filter((cp) => cp.dedupStatus !== "removed");
44
48
  // Honest "tokens saved" base for this region. For a deduped add the whole
45
49
  // original region is discarded (nothing new stored); for a new checkpoint
46
50
  // we persist (orig − stored). Falls back to stored when orig is unknown.
@@ -305,6 +305,12 @@ export const SETTINGS: ReadonlyArray<SettingGroup> = [
305
305
  "Umbrella for the 3-way failback safety system: TriggerGuard (stages a recall block even when session_start never fires), the live-window ReductionValidator + persisted ThrashGuard (stops ineffective compaction re-fire loops), the 3-source read-only recall vote + same-repo relevance floor, and InjectionConfirm (asserts the staged block reached the message list pi sends). OFF = byte-identical pre-3WF behavior (v0.20.83). Runtime reads envBool(plain key), so this uses the plain-write convention (not _DISABLED).",
306
306
  true,
307
307
  ),
308
+ boolDirect(
309
+ "MEGACOMPACT_ITHACUS_BRIDGE",
310
+ "ithacus Bridge",
311
+ "Mega↔ithacus bridge: gate the child extension + bridge usage that tie this extension to ithacus's durable compaction. Default ON; OFF (=0/`=false`) is byte-identical to pre-bridge behavior — the bridge is only consulted when this is ON. Positive sprint flag. Runtime reads envBool(plain key), so this uses the plain-write convention (not _DISABLED).",
312
+ true,
313
+ ),
308
314
  boolDirect(
309
315
  "MEGACOMPACT_RECALL_TAIL_INJECT",
310
316
  "Recall Tail Inject",