pi-mega-compact 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/config/vector-cortex.js +19 -0
  2. package/dist/config.js +117 -0
  3. package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +4 -0
  4. package/dist/extensions/mega-events/context-handler/dbMirrorAppend.js +63 -0
  5. package/dist/extensions/mega-events/context-handler/gateCheck.js +59 -0
  6. package/dist/extensions/mega-events/context-handler/liveTrim.js +178 -0
  7. package/dist/extensions/mega-events/context-handler/pipelineRun.js +37 -0
  8. package/dist/extensions/mega-events/context-handler.js +39 -305
  9. package/dist/log.js +47 -0
  10. package/dist/src/config/vector-cortex.js +19 -0
  11. package/dist/src/config.js +1 -1
  12. package/dist/src/vector-cortex/encoder/asset.js +142 -0
  13. package/dist/src/vector-cortex/encoder/emit-vc2b.js +63 -0
  14. package/dist/src/vector-cortex/encoder/emit.js +42 -0
  15. package/dist/src/vector-cortex/encoder/heads.js +113 -0
  16. package/dist/src/vector-cortex/encoder/lexical.js +104 -0
  17. package/dist/src/vector-cortex/encoder/router.js +115 -0
  18. package/dist/src/vector-cortex/encoder/runtime.js +228 -0
  19. package/dist/src/vector-cortex/encoder/trigram.js +75 -0
  20. package/dist/src/vector-cortex/encoder/types.js +138 -0
  21. package/dist/vector-cortex/encoder/asset.js +142 -0
  22. package/dist/vector-cortex/encoder/emit-vc2b.js +63 -0
  23. package/dist/vector-cortex/encoder/emit.js +42 -0
  24. package/dist/vector-cortex/encoder/heads.js +113 -0
  25. package/dist/vector-cortex/encoder/lexical.js +104 -0
  26. package/dist/vector-cortex/encoder/router.js +115 -0
  27. package/dist/vector-cortex/encoder/runtime.js +228 -0
  28. package/dist/vector-cortex/encoder/trigram.js +75 -0
  29. package/dist/vector-cortex/encoder/types.js +138 -0
  30. package/extensions/dashboard-server/routes-rag-settings-helpers.ts +14 -0
  31. package/extensions/mega-events/context-handler/dbMirrorAppend.ts +93 -0
  32. package/extensions/mega-events/context-handler/gateCheck.ts +101 -0
  33. package/extensions/mega-events/context-handler/liveTrim.ts +241 -0
  34. package/extensions/mega-events/context-handler/pipelineRun.ts +79 -0
  35. package/extensions/mega-events/context-handler.ts +45 -347
  36. package/package.json +1 -1
  37. package/src/config/vector-cortex.ts +21 -0
  38. package/src/config.ts +2 -0
  39. package/src/vector-cortex/encoder/asset.ts +155 -0
  40. package/src/vector-cortex/encoder/emit-vc2b.ts +82 -0
  41. package/src/vector-cortex/encoder/emit.ts +51 -0
  42. package/src/vector-cortex/encoder/heads.ts +142 -0
  43. package/src/vector-cortex/encoder/lexical.ts +123 -0
  44. package/src/vector-cortex/encoder/router.ts +163 -0
  45. package/src/vector-cortex/encoder/runtime.ts +283 -0
  46. package/src/vector-cortex/encoder/trigram.ts +85 -0
  47. package/src/vector-cortex/encoder/types.ts +275 -0
@@ -2,45 +2,32 @@
2
2
  * mega-events/context-handler.ts — the context event handler (auto-trigger).
3
3
  *
4
4
  * Delegate-shell (extensions split): handles the live-trim compaction pipeline —
5
- * DB-mirror append, fast-gate threshold check, pipeline invocation, legacy
6
- * durable compact, and the live-trim message reconstruction that feeds pi's
7
- * transformContext. The extracted pieces live in ./context-handler/:
5
+ * DB-mirror append, fast-gate threshold check, replay/debounce, pipeline
6
+ * invocation, legacy durable compact, and the live-trim message reconstruction
7
+ * that feeds pi's transformContext. The extracted pieces live in ./context-handler/:
8
8
  * - messageText.ts (messageContentText — best-effort text extraction)
9
9
  * - tailResult.ts (buildTailResult — recall-tail injection factory)
10
10
  * - afterCompact.ts (persistEpochAndMaintain — epoch/wiki/seed/dedup writes)
11
+ * - dbMirrorAppend.ts (appendMirrorAndLedger — mirror + VC1B ledger append)
12
+ * - gateCheck.ts (evaluateGate — S29 fast-gate threshold evaluation)
13
+ * - pipelineRun.ts (invokePipeline — adaptive-compression runCompact)
14
+ * - liveTrim.ts (buildLiveTrimView — S16 live-trim reconstruction + cap)
11
15
  */
12
16
  import type {
13
17
  ExtensionAPI,
14
18
  ExtensionContext,
15
19
  ContextEvent,
16
20
  } from "@earendil-works/pi-coding-agent";
17
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
18
- import {
19
- openStore,
20
- resolveModelThreshold,
21
- DEFAULT_SAFETY_MARGIN_PCT,
22
- DEFAULT_FIRE_POINT_PCT,
23
- } from "../../src/store/sqlite.js";
24
- import { autoCompactCheck } from "../../src/compact.js";
25
- import {
26
- estimateSessionTokens,
27
- estimateBlockTokens,
28
- estimateMessageTokens,
29
- } from "../../src/tokens.js";
21
+ import { estimateSessionTokens } from "../../src/tokens.js";
30
22
  import type { MegaRuntime } from "../mega-runtime.js";
31
- import { runCompact, piCompactWouldNoop } from "../mega-pipeline.js";
32
- import {
33
- pressureFromPct,
34
- pressureRatio,
35
- type MegaConfig,
36
- } from "../mega-config.js";
37
- import { appendMirrorMessages } from "./mirror-append.js";
38
- import { appendMessagesToLedger } from "../mega-runtime/vector-cortex-ledger.js";
39
- import { epochIdFor } from "../../src/mirror/epoch.js";
40
- import { computeLiveTrimCut, liveTrimSummaryMessage } from "../mega-trim.js";
41
- import { messageContentText } from "./context-handler/messageText.js";
23
+ import { piCompactWouldNoop } from "../mega-pipeline.js";
24
+ import type { MegaConfig } from "../mega-config.js";
42
25
  import { buildTailResult } from "./context-handler/tailResult.js";
43
26
  import { persistEpochAndMaintain } from "./context-handler/afterCompact.js";
27
+ import { appendMirrorAndLedger } from "./context-handler/dbMirrorAppend.js";
28
+ import { evaluateGate } from "./context-handler/gateCheck.js";
29
+ import { invokePipeline } from "./context-handler/pipelineRun.js";
30
+ import { buildLiveTrimView } from "./context-handler/liveTrim.js";
44
31
 
45
32
  /** Register the context event handler (live-trim auto-trigger). */
46
33
  export function registerContextHandler(
@@ -101,122 +88,14 @@ export function registerContextHandler(
101
88
 
102
89
  const view = viewForFallback ?? runtime.engineView(messages);
103
90
 
104
- // S27 DB-mirror: append incoming messages to raw_transcript.
105
- // Runs BEFORE fast-gate so every message is captured, even if we
106
- // don't compact this turn. Append is idempotent (content_hash PK).
107
- // F3: high-water mark (mirror-append.ts) skips already-processed
108
- // messages on subsequent events. On fork/rewind (shorter list or
109
- // boundary hash mismatch) the mark is dropped, falling back to a
110
- // full reprocess.
111
- if (config.dbMirror) {
112
- try {
113
- const db = openStore(runtime.currentStateDir);
114
- appendMirrorMessages(
115
- db,
116
- messages,
117
- runtime.rt.sessionId,
118
- epochIdFor(runtime.rt.sessionId),
119
- runtime.currentTurn,
120
- );
121
- // P2.2: populate conversation_thread + tool_results tables for
122
- // prompt-cache analytics and durable separation. The live-array
123
- // separation (buildSeparatedPrompt / buildCacheOptimizedPrompt in
124
- // tailResult above) is sufficient for the prompt-construction path;
125
- // these DB writes persist the split for post-hoc analysis, dashboard
126
- // queries, and future readers. Non-fatal — failure here never breaks
127
- // the agent loop (PREVENT-PI-004: zero network, local SQLite only).
128
- {
129
- const sid = runtime.rt.sessionId;
130
- const turn = runtime.currentTurn;
131
- const now = Date.now();
132
- const threadStmt = db.prepare(
133
- "INSERT OR IGNORE INTO conversation_thread (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
134
- );
135
- const toolStmt = db.prepare(
136
- "INSERT OR IGNORE INTO tool_results (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
137
- );
138
- for (const m of messages) {
139
- const role = m.role;
140
- const content = messageContentText(m);
141
- if (role === "user" || role === "assistant") {
142
- threadStmt.run(sid, role, content, turn, now);
143
- } else if (role === "toolResult" || role === "bashExecution") {
144
- toolStmt.run(sid, role, content, turn, now);
145
- }
146
- }
147
- }
148
- } catch (e) {
149
- runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
150
- }
151
- }
91
+ // S27 DB-mirror + VC1B ledger append. Runs BEFORE the fast-gate so every
92
+ // message is captured, even if we don't compact this turn. Non-fatal.
93
+ appendMirrorAndLedger(runtime, config, messages);
152
94
 
153
- // VC1B (S1): canonical messages -> v2 ledger occurrences. Flag-OFF opens
154
- // no DB (byte-identical predecessor); non-fatal. onFailure surfaces
155
- // per-append rejections (e.g. EVT_SEQ_REGRESSION on rewind/fork) as
156
- // structured warnings rather than swallowing them silently.
157
- try {
158
- appendMessagesToLedger(
159
- runtime.currentStateDir,
160
- runtime.rt.sessionId,
161
- messages,
162
- runtime.logger,
163
- );
164
- } catch (e) {
165
- runtime.logger.warn("vc1b-ledger-append-fail", { error: String(e) });
166
- }
167
-
168
- // S52 / v0.16.1: per-model threshold override. The user can tune the
169
- // fire point + safety margin PER MODEL (different providers' models range
170
- // 8K-1M+ context, so one global tier % is wrong). Falls back to env/default
171
- // when no override row exists. Computed once here + reused in the tail cap
172
- // below; the lookup is a single SQLite PK hit (cheap; cached after the
173
- // first read in a session).
174
- const _modelIdForThreshold = runtime.currentModel?.modelId ?? null;
175
- const _perModelThreshold = resolveModelThreshold(_modelIdForThreshold, {
176
- safetyMarginFallback: DEFAULT_SAFETY_MARGIN_PCT,
177
- firePointFallback:
178
- config.tierPct != null
179
- ? Math.round(config.tierPct * 100)
180
- : DEFAULT_FIRE_POINT_PCT,
181
- stateDir: runtime.currentStateDir,
182
- });
183
- // S29 FAST GATE: drive the auto-trigger off the context % (the number the
184
- // menu bar shows), NOT the token count — the model under-reports tokens,
185
- // so a token-only gate misses the overshoot that causes max-output-tokens
186
- // truncation. The fire point is the per-model override when present,
187
- // otherwise the tier's percent threshold (tierPct) unless overridden by
188
- // MEGACOMPACT_AUTO_PCT_TRIGGER. `custom` (absolute
189
- // MEGACOMPACT_THRESHOLD_TOKENS, tierPct null) is an explicit opt-out of
190
- // percent scaling — it keeps the token gate. When pct is unavailable
191
- // (window unknown / a model that doesn't report percent) a tiered config
192
- // falls back to the token gate (S27 boot-fallback guarantee) instead of
193
- // skipping compaction — a percent-only gate would regress that.
194
- let gatePassed = false;
195
- if (config.tierPct != null && pct != null) {
196
- // Per-model override is a % (10-90); tierPct is a fraction (0.1-1.0).
197
- // Prefer the override; fall back to autoPctTrigger + tierPct.
198
- const tierPctFraction = config.autoPctTrigger ?? config.tierPct;
199
- const perModelFraction = _perModelThreshold.firePointPct / 100;
200
- const firePct =
201
- _modelIdForThreshold != null ? perModelFraction : tierPctFraction;
202
- gatePassed = pct / 100 >= firePct;
203
- } else {
204
- // custom tier OR tiered-but-pct-unavailable → token gate (S27 fallback).
205
- if (currentTokens < runtime.effectiveThreshold) {
206
- runtime.diagCtxFastGate++;
207
- return tailResult() ?? undefined;
208
- }
209
- const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
210
- if (!check.shouldCompact) {
211
- runtime.diagCtxNoCompact++;
212
- return tailResult() ?? undefined;
213
- }
214
- gatePassed = true;
215
- }
216
- if (!gatePassed) {
217
- runtime.diagCtxFastGate++;
218
- return tailResult() ?? undefined;
219
- }
95
+ // S29 FAST GATE: drive the auto-trigger off the context percent (see
96
+ // gateCheck.ts). Returns a tailed view when the gate does not pass.
97
+ const gate = evaluateGate(runtime, config, { pct, currentTokens, tailResult });
98
+ if (gate.kind === "return") return gate.view;
220
99
 
221
100
  // D.2: Replay MUST be exempt from debounce — replay is free (no compute,
222
101
  // no re-write) and prevents unnecessary KV-cache invalidation. Check
@@ -266,40 +145,20 @@ export function registerContextHandler(
266
145
  }
267
146
  runtime.debounceUntil = now + 2000;
268
147
 
269
- // Adaptive compression (Fix E): scale compression strength + keepFrom depth
270
- // with how close we are to the model context limit. Null-safe: when the
271
- // token-fallback path ran (pct unavailable) use the token-basis pressure
272
- // (the same basis the runtime `pressure` getter uses for custom/no-window).
273
- const pressure =
274
- pct != null
275
- ? pressureFromPct(pct)
276
- : pressureRatio(currentTokens, runtime.effectiveThreshold);
277
- const ran = runCompact(pi, runtime, config, ctx, messages, {
278
- compressionPressure: pressure,
148
+ // Adaptive-compression pipeline invocation (see pipelineRun.ts). Returns a
149
+ // tailed view ("return") when compaction skipped; otherwise "proceed" with
150
+ // the result + pressure consumed by the live-trim stage below.
151
+ const pipeline = invokePipeline(pi, runtime, config, ctx, {
152
+ messages,
153
+ pct,
154
+ currentTokens,
155
+ tailResult,
279
156
  });
280
- // D.3: skip paths fall back to replay instead of returning empty.
281
- // If runCompact skipped and we have a valid trimCache, replay it
282
- // (free stability win) — otherwise defer to the next event.
283
- if (ran.skipped) {
284
- runtime.diagCtxRunSkipped++;
285
- if (
286
- runtime.trimCache &&
287
- runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
288
- runtime.trimCache.cut <= messages.length
289
- ) {
290
- const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized by computeLiveTrimCut (src/boundary.ts); replayed verbatim, transcript only grows within an epoch.
291
- runtime.diagLiveTrimFires++;
292
- runtime.diagLiveTrimReplays++;
293
- runtime.snapshot(ctx);
294
- const skipView = [{ ...runtime.trimCache.summaryAgentMsg }, ...recent];
295
- return tailResult(skipView) ?? { messages: skipView };
296
- }
297
- return tailResult() ?? undefined;
298
- }
157
+ if (pipeline.kind === "return") return pipeline.view;
299
158
 
300
159
  // S27 DB-mirror: write checkpoint_epoch + stamp turn epochs + auto-wiki +
301
160
  // topic seed + fire-and-forget dedup. Best-effort + non-fatal.
302
- await persistEpochAndMaintain(runtime, config, ran);
161
+ await persistEpochAndMaintain(runtime, config, pipeline.ran);
303
162
 
304
163
  // LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
305
164
  // manual compact path aborts the in-flight turn — only used behind the flag.
@@ -351,180 +210,19 @@ export function registerContextHandler(
351
210
  }
352
211
 
353
212
  // S16 LIVE trim: collapse the compacted region to a summary + recent anchor.
354
- // Non-destructive: pi keeps the real transcript; only this LLM call sees the
355
- // trimmed window. We compute the cut on the engine view (pure, tested) then
356
- // slice the ORIGINAL pi AgentMessage[] from that index (lossless alignment,
357
- // mirroring dropCompactedRange) and prepend a user-role summary message.
358
- // A build failure or unsafe cut returns nothing (no trim this call — the
359
- // next context event retries). The anchor floor is read live from env (the
360
- // config value is the cached default) so it can be tuned per-test / per-run
361
- // without reloading the module.
362
- try {
363
- const anchorEnv = process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
364
- const anchorUserMessages =
365
- anchorEnv != null &&
366
- anchorEnv !== "" &&
367
- Number.isFinite(Number(anchorEnv))
368
- ? Number(anchorEnv)
369
- : config.anchorUserMessages;
370
- const cut = computeLiveTrimCut(view, {
371
- compactedFrom: ran.result.compactedFrom,
372
- summary: ran.result.summary,
373
- anchorUserMessages,
374
- // CRITICAL-OVER ESCAPE HATCH: when context is at/over ~90% of the
375
- // window, relief takes priority over the anchor floor. Without this,
376
- // computeLiveTrimCut bails to null (can't satisfy the floor) and the
377
- // model is fed a raw overflow that errors every turn — the
378
- // "Already compacted" + overflow death-spiral (2026-08-01 incident).
379
- // A thin anchor is recoverable; an overflowed session is not.
380
- //
381
- // CRITICAL: pct is null for OpenAI-compatible providers that don't
382
- // report usage.percent (e.g. neuralwatt). Without the token-pressure
383
- // fallback the hatch never armed → cut=null → raw overflow → 400
384
- // "conversation too long even after compaction" (2026-08-03 incident
385
- // on glm-5.2-short, 200K window). Now also fires on pressure >= 0.9
386
- // (token-basis) so the hatch arms regardless of whether the provider
387
- // reports pct.
388
- criticalOver: (pct ?? 0) >= 90 || pressure >= 0.9,
389
- });
390
- if (cut === null) {
391
- runtime.diagCtxCutNull++;
392
- runtime.logger.info("live-trim-skip", {
393
- sessionId: runtime.rt.sessionId,
394
- compactedFrom: ran.result.compactedFrom,
395
- viewLen: view.length,
396
- anchorUserMessages,
397
- criticalOver: (pct ?? 0) >= 90,
398
- });
399
- return tailResult() ?? undefined; // unsafe / below anchor floor — no trim this call
400
- }
401
- const summaryMsg = liveTrimSummaryMessage({
402
- compactedFrom: ran.result.compactedFrom,
403
- summary: ran.result.summary,
404
- anchorUserMessages: config.anchorUserMessages,
405
- });
406
- // Synthesize a user-role AgentMessage carrying the compacted summary.
407
- const summaryAgentMsg = {
408
- role: "user" as const,
409
- content: summaryMsg.text,
410
- // v0.8.6: stable timestamp across the epoch (NOT Date.now()) so the
411
- // summary message bytes — and thus the KV-cache prefix — don't drift
412
- // on every replay within the same compaction epoch.
413
- timestamp: runtime.rt.lastCompactAt ?? Date.now(),
414
- } as unknown as AgentMessage;
415
- const recentRaw = messages.slice(cut); // guardrails-allow PREVENT-PI-002: `cut` is the pre-sanitized `compactedFrom` produced by src/boundary.ts computeDropRange, so the preserved run begins on a toolPair-safe index.
416
-
417
- // FIX 2 (2026-08-03 incident): TOKEN-BUDGET CAP on the live-trim view.
418
- // Compaction fires at tier% of the window (140K for a 200K window),
419
- // but a SINGLE turn can inject a huge tool output (file read, bash) that
420
- // jumps context from 139K → 199K+ before the next gate fires. When that
421
- // happens [summary + preserved tail] can STILL exceed the model window,
422
- // and the provider rejects with 400 "conversation too long even after
423
- // compaction". The anchor floor (PREVENT-PI-001) keeps ≥N user messages
424
- // but has NO token cap, so a 2-message tail of two 80K bash outputs sails
425
- // right past the window.
426
- //
427
- // Cap: when the model context window is known, reserve room for the
428
- // summary + the model's max output tokens + a 10% safety margin, then
429
- // drop oldest preserved messages from the front of `recentRaw` until the
430
- // tail fits. Never drops below the FINAL message (always keep the latest
431
- // turn so the agent can respond). This is a last-resort HARD cap — it
432
- // only fires when the preserved tail alone is oversized, which is rare.
433
- const ctxWindow = runtime.lastCtxWindow;
434
- // Reuse the per-model threshold resolved at the gate (single lookup).
435
- const modelThreshold = _perModelThreshold;
436
- // Reserve room for output tokens. Use the model's reported max output
437
- // when known; fall back to 10% of the window (scales with any model —
438
- // 20K for a 200K window, 100K for a 1M window) so we never let the
439
- // preserved tail eat the model's output budget when maxTokens is unknown.
440
- const maxOutput =
441
- runtime.currentModel?.maxTokens && runtime.currentModel.maxTokens > 0
442
- ? runtime.currentModel.maxTokens
443
- : Math.ceil(ctxWindow * 0.1);
444
- let recent = recentRaw;
445
- if (ctxWindow > 0 && recentRaw.length > 1) {
446
- const summaryTokens = estimateBlockTokens(summaryMsg.text);
447
- // Reserve: summary + max output + per-model safety margin (0-20%).
448
- const safetyMargin = Math.ceil(
449
- ctxWindow * (modelThreshold.safetyMarginPct / 100),
450
- );
451
- const budget = ctxWindow - maxOutput - safetyMargin - summaryTokens;
452
- if (budget > 0) {
453
- // Walk recent from the front, dropping oldest first until the
454
- // remaining tail fits. Use the AgentMessage→engine-text estimate via
455
- // messageContentText (already imported) + estimateMessageTokens.
456
- let tailTokens = 0;
457
- for (let i = recentRaw.length - 1; i >= 0; i--) {
458
- const m = recentRaw[i];
459
- tailTokens += estimateMessageTokens({
460
- text: messageContentText(m),
461
- });
462
- if (tailTokens > budget) {
463
- // Keep from i+1 onward; but never fewer than the final message.
464
- const startIdx = Math.min(i + 1, recentRaw.length - 1);
465
- if (startIdx > 0) {
466
- recent = recentRaw.slice(startIdx);
467
- runtime.logger.warn("live-trim-tail-cap", {
468
- sessionId: runtime.rt.sessionId,
469
- dropped: startIdx,
470
- tailTokens,
471
- safetyMarginPct: modelThreshold.safetyMarginPct,
472
- budget,
473
- ctxWindow,
474
- });
475
- }
476
- break;
477
- }
478
- }
479
- }
480
- }
481
-
482
- // v0.8.6: cache the trim view so subsequent gated calls in this epoch
483
- // replay it verbatim (stabilizing the KV-cache prefix) instead of
484
- // regenerating a fresh summary + sentinel every fire.
485
- runtime.trimCache = {
486
- // v0.8.7: key the replay cache on the STABLE epoch signal
487
- // (rt.lastCheckpointId) instead of ran.result.checkpointId, which is
488
- // dedup-volatile: on a re-compact that dedups onto a DIFFERENT existing
489
- // checkpoint, result.checkpointId is the matched id (engine.ts:188) while
490
- // lastCheckpointId is only updated on a genuinely new checkpoint
491
- // (compact.ts:100-104). Keying on result.checkpointId would make
492
- // trimCache.checkpointId != rt.lastCheckpointId forever after that
493
- // dedup fire, disabling replay for the rest of the epoch (the
494
- // alternating cache-miss that 0.8.6 meant to fix). Prefer the stable
495
- // signal; fall back to result.checkpointId then the epoch timestamp
496
- // only for the no-checkpoint edge case.
497
- checkpointId:
498
- runtime.rt.lastCheckpointId ??
499
- ran.result.checkpointId ??
500
- `epoch-${runtime.rt.lastCompactAt ?? Date.now()}`,
501
- cut,
502
- summaryAgentMsg,
503
- ctxPct: pct ?? null,
504
- ctxTokens: currentTokens,
505
- };
506
- runtime.snapshot(ctx);
507
- // DIAG (team-run relief): confirm the live trim actually fires + how big
508
- // the window still is. The return is non-durable (per-LLM-call only), so
509
- // this is the signal that the model is being fed a compacted view while
510
- // the on-disk transcript + context meter keep growing.
511
- runtime.diagLiveTrimFires++;
512
- runtime.logger.info("live-trim", {
513
- sessionId: runtime.rt.sessionId,
514
- inputMsgs: messages.length,
515
- outputMsgs: recent.length + 1,
516
- compactedFrom: cut,
517
- ctxPct: pct,
518
- ctxTokens: usage?.tokens ?? null,
519
- });
520
- return (
521
- tailResult([summaryAgentMsg, ...recent]) ?? {
522
- messages: [summaryAgentMsg, ...recent],
523
- }
524
- );
525
- } catch {
526
- runtime.diagCtxThrown++;
527
- return tailResult() ?? undefined; // non-fatal: no trim this call; the next context event retries
528
- }
213
+ // See liveTrim.ts. Non-destructive: pi keeps the real transcript; only this
214
+ // LLM call sees the trimmed window. Returns undefined on unsafe cut/throw
215
+ // the next context event retries.
216
+ return buildLiveTrimView(runtime, config, ctx, {
217
+ messages,
218
+ view,
219
+ pct,
220
+ currentTokens,
221
+ usageTokens: usage?.tokens,
222
+ pressure: pipeline.pressure,
223
+ ran: pipeline.ran,
224
+ perModelThreshold: gate.perModelThreshold,
225
+ tailResult,
226
+ });
529
227
  });
530
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -69,6 +69,27 @@ export const VC0C_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC0C");
69
69
  */
70
70
  export const VC1C_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC1C");
71
71
 
72
+ /**
73
+ * VC2A — offline model runtime and asset decision (ModelManifestV1 /
74
+ * EncoderRuntime).
75
+ * Default ON. `MEGACOMPACT_VC2A=0` disables and is byte-identical to the
76
+ * predecessor (mode C: no asset manifest is read/verified, the encoder runtime
77
+ * idles in mode C, zero `vector_cortex_encoder_*` emissions; the trigram/lexical
78
+ * paths are unchanged). The real consumers are the encoder emit seam and the
79
+ * encoder runtime's A/B/C selection.
80
+ */
81
+ export const VC2A_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC2A");
82
+
83
+ /**
84
+ * VC2B — multi-head encoder (VectorSetV1 / HeadCalibrationDraft).
85
+ * Default ON. `MEGACOMPACT_VC2B=0` disables and is byte-identical to the
86
+ * predecessor (the encoder emits no per-head vectors and no fallback-selected
87
+ * event; the trigram/lexical paths themselves are unchanged and are the
88
+ * predecessor's mode-B/C producers). The real consumers are the encoder-heads
89
+ * emit seam and the multi-head encoder producers (heads/trigram/lexical).
90
+ */
91
+ export const VC2B_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC2B");
92
+
72
93
  // ---------------------------------------------------------------------------
73
94
  // Breaker state machine constants (TRIAD_RESILIENCE.md §breaker).
74
95
  // Rolled numbers for one 60s window; VC0C consumes these at its breaker seam.
package/src/config.ts CHANGED
@@ -157,6 +157,8 @@ export {
157
157
  VC0C_ENABLED,
158
158
  VC1B_ENABLED,
159
159
  VC1C_ENABLED,
160
+ VC2A_ENABLED,
161
+ VC2B_ENABLED,
160
162
  BREAKER_WINDOW_MS,
161
163
  BREAKER_MIN_ATTEMPTS,
162
164
  BREAKER_PERF_FAILURES,
@@ -0,0 +1,155 @@
1
+ /**
2
+ * vector-cortex/encoder/asset.ts — VC2A asset verification (task 2).
3
+ *
4
+ * Verifies a ModelManifestV1 before any allocation: SHA-256 the ONNX and
5
+ * tokenizer against the manifest digests, require opset 17, batch exactly 1 and
6
+ * maximum 512 tokens, and confirm the current platform is in the supported
7
+ * matrix. On ANY of these the caller demotes to mode B (asset-free trigram) —
8
+ * never a remote fetch (PREVENT-PI-004). A truncated/unreadable asset during
9
+ * the digest read demotes with ENC_ASSET_UNREADABLE.
10
+ *
11
+ * Pi-agnostic. Filesystem reads only, zero network (PREVENT-PI-004).
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { readFileSync, statSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import {
18
+ ENC_FAIL,
19
+ ENCODER_BATCH,
20
+ ENCODER_MAX_TOKENS,
21
+ ENCODER_OPSET,
22
+ ENCODER_SUPPORTED_PLATFORMS,
23
+ type EncoderPlatform,
24
+ type ModelManifestV1,
25
+ } from "./types.js";
26
+
27
+ export type AssetVerifyResult =
28
+ | { ok: true; embeddedBytes: number; maxTokens: number; onnxDigest: string; tokenizerDigest: string }
29
+ | { ok: false; code: string };
30
+
31
+ /** True when `p` is a single basename: non-empty, no path separators, no "..",
32
+ * no leading dot-segment traversal. Keeps manifest-controlled asset paths
33
+ * confined to the asset directory (no path-traversal via join()). */
34
+ function isBasename(p: string): boolean {
35
+ if (!p || p.length === 0 || p.includes("/") || p.includes("\\")) return false;
36
+ if (p === "." || p === "..") return false;
37
+ return true;
38
+ }
39
+
40
+ /** Digest the on-disk bytes of one asset file; "" on unreadable (truncated). */
41
+ function digestFile(path: string): string | null {
42
+ try {
43
+ const buf = readFileSync(path);
44
+ return createHash("sha256").update(buf).digest("hex");
45
+ } catch {
46
+ // Truncated / unreadable during the digest read -> ENC_ASSET_UNREADABLE.
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Detect the current platform (MODEL_ASSET supported matrix). Unrecognized
53
+ * hosts return null so verification demotes to mode B (unsupported platform).
54
+ */
55
+ export function detectPlatform(host = process.platform, arch = process.arch): EncoderPlatform | null {
56
+ if (!host || !arch) return null;
57
+ // Normalize "win32"/"win32"/"linux"/"darwin" + "x64"/"arm64".
58
+ const plat = host === "win32" ? "win32" : host === "darwin" ? "darwin" : host === "linux" ? "linux" : "";
59
+ const a = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : "";
60
+ if (!plat || !a) return null;
61
+ const candidate = `${plat}-${a}` as EncoderPlatform;
62
+ return (ENCODER_SUPPORTED_PLATFORMS as readonly string[]).includes(candidate)
63
+ ? candidate
64
+ : null;
65
+ }
66
+
67
+ function isManifest(m: unknown): m is ModelManifestV1 {
68
+ const o = m as ModelManifestV1;
69
+ return (
70
+ !!o &&
71
+ typeof o === "object" &&
72
+ o.schema === "model-manifest-v1" &&
73
+ typeof o.opset === "number" &&
74
+ typeof o.batch === "number" &&
75
+ typeof o.maxTokens === "number" &&
76
+ typeof o.platform === "string" &&
77
+ !!o.onnx &&
78
+ !!o.tokenizer &&
79
+ typeof o.onnx.path === "string" &&
80
+ typeof o.onnx.sha256 === "string" &&
81
+ typeof o.tokenizer.path === "string" &&
82
+ typeof o.tokenizer.sha256 === "string"
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Verify the asset manifest + digest + constraints BEFORE allocation.
88
+ *
89
+ * - manifest parse/shape failure -> ENC_MANIFEST_INVALID -> mode B
90
+ * - unsupported platform -> ENC_PLATFORM_UNSUPPORTED -> mode B
91
+ * - opset != 17 -> ENC_OPSET_INVALID -> mode B
92
+ * - batch != 1 -> ENC_BATCH_INVALID -> mode B
93
+ * - maxTokens > 512 -> ENC_TOKENS_EXCEEDED -> mode B
94
+ * - on-disk digest != manifest digest -> ENC_DIGEST_MISMATCH (one-byte mutation)
95
+ * - unreadable/truncated file -> ENC_ASSET_UNREADABLE
96
+ *
97
+ * Returns ok only when EVERY constraint passes and both files hash to the
98
+ * declared digests (the "only batch1/max512 verified assets reach inference"
99
+ * invariant). The ok result surfaces the verified manifest's `maxTokens`
100
+ * (<= 512) so the runtime can enforce the per-manifest token capacity at
101
+ * inference (Q03), not just the global 512 ceiling.
102
+ */
103
+ export function verifyEncoderAsset(
104
+ assetDir: string,
105
+ manifest: unknown,
106
+ platform: EncoderPlatform | null = detectPlatform(),
107
+ ): AssetVerifyResult {
108
+ if (typeof manifest !== "object" || manifest === null || !isManifest(manifest)) {
109
+ return { ok: false, code: ENC_FAIL.MANIFEST_INVALID };
110
+ }
111
+ if (!platform) return { ok: false, code: ENC_FAIL.PLATFORM_UNSUPPORTED };
112
+ // The manifest's declared platform must match the runtime host (per-platform
113
+ // asset pinning): a bundle cross-shipped to the wrong arch is not qualified.
114
+ if (manifest.platform !== platform) return { ok: false, code: ENC_FAIL.PLATFORM_UNSUPPORTED };
115
+ if (manifest.opset !== ENCODER_OPSET) return { ok: false, code: ENC_FAIL.OPSET_INVALID };
116
+ if (manifest.batch !== ENCODER_BATCH) return { ok: false, code: ENC_FAIL.BATCH_INVALID };
117
+ if (manifest.maxTokens > ENCODER_MAX_TOKENS) return { ok: false, code: ENC_FAIL.TOKENS_EXCEEDED };
118
+
119
+ // Constrain asset paths to basenames (no separators / no '..') so a forged
120
+ // manifest cannot read digests from arbitrary paths off the asset dir.
121
+ if (!isBasename(manifest.onnx.path) || !isBasename(manifest.tokenizer.path)) {
122
+ return { ok: false, code: ENC_FAIL.MANIFEST_INVALID };
123
+ }
124
+
125
+ const onnxPath = join(assetDir, manifest.onnx.path);
126
+ const onnxDigest = digestFile(onnxPath);
127
+ if (onnxDigest === null) return { ok: false, code: ENC_FAIL.ASSET_UNREADABLE };
128
+ if (onnxDigest !== manifest.onnx.sha256) return { ok: false, code: ENC_FAIL.DIGEST_MISMATCH };
129
+
130
+ const tokPath = join(assetDir, manifest.tokenizer.path);
131
+ const tokDigest = digestFile(tokPath);
132
+ if (tokDigest === null) return { ok: false, code: ENC_FAIL.ASSET_UNREADABLE };
133
+ if (tokDigest !== manifest.tokenizer.sha256) return { ok: false, code: ENC_FAIL.DIGEST_MISMATCH };
134
+
135
+ let embeddedBytes = 0;
136
+ try {
137
+ embeddedBytes = statSync(onnxPath).size + statSync(tokPath).size;
138
+ } catch {
139
+ return { ok: false, code: ENC_FAIL.ASSET_UNREADABLE };
140
+ }
141
+ return { ok: true, embeddedBytes, maxTokens: manifest.maxTokens, onnxDigest, tokenizerDigest: tokDigest };
142
+ }
143
+
144
+ /**
145
+ * Read + shape-check a committed ModelManifestV1 from an asset directory.
146
+ * Returns the parsed manifest or null when the file is absent/malformed.
147
+ */
148
+ export function readEncoderManifest(assetDir: string): ModelManifestV1 | null {
149
+ try {
150
+ const raw = JSON.parse(readFileSync(join(assetDir, "manifest.json"), "utf8")) as unknown;
151
+ return isManifest(raw) ? raw : null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }