pi-mega-compact 0.6.7 → 0.7.0

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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * wordpiece.ts — a self-contained WordPiece tokenizer for BERT/MiniLM.
3
+ *
4
+ * Loads the canonical `vocab.txt` (bert-base-uncased, ~30K tokens) from disk and
5
+ * implements the standard uncased BERT preprocessing + greedy longest-match
6
+ * WordPiece segmentation. No native dependency, no network — the vocab file is a
7
+ * local artifact fetched once by scripts/setup-minilm.mjs (PREVENT-PI-004).
8
+ *
9
+ * This mirrors HuggingFace `BertTokenizer` closely enough for sentence-embedding
10
+ * use: lowercase, strip accents, split on whitespace + punctuation, then
11
+ * WordPiece each token with the `##` continuation convention. Special tokens
12
+ * [CLS]/[SEP] are added by the caller's encode().
13
+ */
14
+ import { readFileSync, existsSync } from "node:fs";
15
+ const UNK = "[UNK]";
16
+ const CLS = "[CLS]";
17
+ const SEP = "[SEP]";
18
+ const PAD = "[PAD]";
19
+ const MAX_INPUT_CHARS_PER_WORD = 200;
20
+ export class WordPieceTokenizer {
21
+ vocab;
22
+ clsId;
23
+ sepId;
24
+ padId;
25
+ unkId;
26
+ constructor(vocab) {
27
+ this.vocab = vocab;
28
+ this.clsId = vocab.get(CLS) ?? 101;
29
+ this.sepId = vocab.get(SEP) ?? 102;
30
+ this.padId = vocab.get(PAD) ?? 0;
31
+ this.unkId = vocab.get(UNK) ?? 100;
32
+ }
33
+ /** Build a tokenizer from a vocab.txt file (one token per line, index = line). */
34
+ static fromVocabFile(path) {
35
+ if (!existsSync(path)) {
36
+ throw new Error(`WordPiece vocab not found at ${path}. Run: node scripts/setup-minilm.mjs`);
37
+ }
38
+ const lines = readFileSync(path, "utf-8").split("\n");
39
+ const vocab = new Map();
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const tok = lines[i].replace(/\r$/, "");
42
+ if (tok.length > 0 || i < lines.length - 1)
43
+ vocab.set(tok, i);
44
+ }
45
+ return new WordPieceTokenizer(vocab);
46
+ }
47
+ /** Uncased BERT basic tokenization: lowercase, strip accents, split on ws+punct. */
48
+ basicTokenize(text) {
49
+ // NFD + strip combining marks (accent removal), then lowercase.
50
+ const cleaned = text
51
+ .normalize("NFD")
52
+ .replace(/[̀-ͯ]/g, "")
53
+ .toLowerCase();
54
+ const tokens = [];
55
+ let buf = "";
56
+ const flush = () => {
57
+ if (buf.length > 0) {
58
+ tokens.push(buf);
59
+ buf = "";
60
+ }
61
+ };
62
+ for (const ch of cleaned) {
63
+ if (/\s/.test(ch)) {
64
+ flush();
65
+ }
66
+ else if (/[!-/:-@[-`{-~¡-¿]/.test(ch)) {
67
+ // Punctuation becomes its own token.
68
+ flush();
69
+ tokens.push(ch);
70
+ }
71
+ else {
72
+ buf += ch;
73
+ }
74
+ }
75
+ flush();
76
+ return tokens;
77
+ }
78
+ /** Greedy longest-match WordPiece for a single word. */
79
+ wordpiece(word) {
80
+ if (word.length > MAX_INPUT_CHARS_PER_WORD)
81
+ return [UNK];
82
+ const pieces = [];
83
+ let start = 0;
84
+ while (start < word.length) {
85
+ let end = word.length;
86
+ let cur = null;
87
+ while (start < end) {
88
+ let sub = word.slice(start, end);
89
+ if (start > 0)
90
+ sub = "##" + sub;
91
+ if (this.vocab.has(sub)) {
92
+ cur = sub;
93
+ break;
94
+ }
95
+ end--;
96
+ }
97
+ if (cur === null)
98
+ return [UNK]; // any unmatchable piece → whole word is UNK
99
+ pieces.push(cur);
100
+ start = end;
101
+ }
102
+ return pieces;
103
+ }
104
+ /** Tokenize text into WordPiece token strings (no special tokens). */
105
+ tokenize(text) {
106
+ const out = [];
107
+ for (const word of this.basicTokenize(text)) {
108
+ for (const piece of this.wordpiece(word))
109
+ out.push(piece);
110
+ }
111
+ return out;
112
+ }
113
+ /**
114
+ * Encode text into model inputs with [CLS]…[SEP], truncated to `maxLen`.
115
+ * attention_mask is all 1s (no padding for single-sequence inference).
116
+ */
117
+ encode(text, maxLen = 256) {
118
+ const pieces = this.tokenize(text).slice(0, Math.max(0, maxLen - 2));
119
+ const inputIds = [this.clsId];
120
+ for (const p of pieces)
121
+ inputIds.push(this.vocab.get(p) ?? this.unkId);
122
+ inputIds.push(this.sepId);
123
+ return {
124
+ inputIds,
125
+ attentionMask: inputIds.map(() => 1),
126
+ tokenTypeIds: inputIds.map(() => 0),
127
+ };
128
+ }
129
+ }
@@ -70,6 +70,17 @@ interface IndexRepo {
70
70
  inputRate: number | null;
71
71
  outputRate: number | null;
72
72
  lastSeen: number;
73
+ // Per-repo token + model detail (S25 dashboard enrichment), read directly
74
+ // from each repo's node:sqlite store at stateDir. tokensKept = Σ stored
75
+ // summary tokens ("out"); tokensDropped = Σ original region tokens ("in");
76
+ // sessions = distinct sessions with a checkpoint; contextWindow/maxTokens/
77
+ // reasoning come from the latest model_snapshots row for that repo.
78
+ tokensKept: number;
79
+ tokensDropped: number;
80
+ sessions: number;
81
+ contextWindow: number | null;
82
+ maxTokens: number | null;
83
+ reasoning: boolean | null;
73
84
  }
74
85
 
75
86
  interface IndexSummary {
@@ -107,7 +118,54 @@ function readIndex(): IndexIndex | null {
107
118
  inputRate: (r.input_rate as number | null) ?? null,
108
119
  outputRate: (r.output_rate as number | null) ?? null,
109
120
  lastSeen: Number(r.last_seen ?? 0),
121
+ // Defaults — enriched below from each repo's own store.
122
+ tokensKept: 0,
123
+ tokensDropped: 0,
124
+ sessions: 0,
125
+ contextWindow: null,
126
+ maxTokens: null,
127
+ reasoning: null,
110
128
  }));
129
+ // Enrich each repo with per-store token + model detail read directly via
130
+ // node:sqlite (same zero-dependency invariant as readIndex; no store graph
131
+ // import). Best-effort: a missing/corrupt store degrades to the defaults
132
+ // above so the dashboard never fails to render.
133
+ for (const repo of mapped) {
134
+ try {
135
+ const storePath = join(repo.stateDir, "sqlite.db");
136
+ if (existsSync(storePath)) {
137
+ const sdb = new DatabaseSync(storePath, { readOnly: true });
138
+ try {
139
+ const tok = sdb
140
+ .prepare(
141
+ `SELECT COALESCE(SUM(token_estimate),0) AS kept,
142
+ COALESCE(SUM(original_token_estimate),0) AS dropped,
143
+ COUNT(DISTINCT session_id) AS sess
144
+ FROM context_chunks WHERE dedup_status != 'removed'`,
145
+ )
146
+ .get() as { kept: number; dropped: number; sess: number };
147
+ repo.tokensKept = Number(tok.kept ?? 0);
148
+ repo.tokensDropped = Number(tok.dropped ?? 0);
149
+ repo.sessions = Number(tok.sess ?? 0);
150
+ const mrow = sdb
151
+ .prepare(
152
+ `SELECT context_window, max_tokens, reasoning
153
+ FROM model_snapshots ORDER BY captured_at DESC LIMIT 1`,
154
+ )
155
+ .get() as { context_window: number; max_tokens: number; reasoning: number } | undefined;
156
+ if (mrow) {
157
+ repo.contextWindow = Number(mrow.context_window ?? 0) || null;
158
+ repo.maxTokens = Number(mrow.max_tokens ?? 0) || null;
159
+ repo.reasoning = Number(mrow.reasoning ?? 0) === 1;
160
+ }
161
+ } finally {
162
+ sdb.close();
163
+ }
164
+ }
165
+ } catch {
166
+ /* best-effort — keep the defaults */
167
+ }
168
+ }
111
169
  // Defensive display hygiene (belt-and-suspenders — the real fix is that
112
170
  // tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
113
171
  // paths that should never have been real repos, and collapse duplicate
@@ -236,14 +294,15 @@ function readSnapshot(snapshotPath: string) {
236
294
  tier: "unknown",
237
295
  presetTier: "unknown",
238
296
  pressure: 0,
239
- config: { fastGatePct: 80, thresholdTokens: 100_000, anchorUserMessages: 1, preserveRecent: 2, auto: true, autoInlineK: 3 },
297
+ config: { fastGatePct: 80, thresholdTokens: 100_000, tierPct: null, effectiveThresholdPct: null, anchorUserMessages: 1, preserveRecent: 2, auto: true, autoInlineK: 3 },
240
298
  session: { id: null, state: null, persistedThisSession: false, lastCheckpointId: null, lastCompactedFrom: 0 },
241
299
  context: { tokens: null, percent: null, contextWindow: 0 },
242
- trigger: { armed: false, ready: false, currentTokens: null, thresholdTokens: 100_000, fastGatePct: 80 },
300
+ trigger: { armed: false, ready: false, currentTokens: null, thresholdTokens: 100_000, fastGatePct: 80, tierPct: null, effectiveThresholdPct: null },
243
301
  store: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, injectedCount: 0, dedupHitRate: 0, storageDedupRate: 0, dedupCollapsed: 0 },
244
302
  crew: { activeAgents: 0, currentTurn: 0 },
245
303
  repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
246
304
  integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
305
+ compression: { session: { tokensIn: 0, tokensOut: 0, tokensFreed: 0, compressionPct: 0, dedupPct: 0 }, repo: { tokensIn: 0, tokensOut: 0, tokensFreed: 0, compressionPct: 0, dedupPct: 0 } },
247
306
  model: undefined,
248
307
  } as Snapshot;
249
308
  }
@@ -430,9 +489,9 @@ function dashboardHtml(tierName: string): string {
430
489
  <div class="conf-grid">
431
490
  <span class="label" title="Live pressure band — climbs low→mega as context fills the window.">Tier (live)</span><span class="value" id="cf-tier">${tierName}</span>
432
491
  <span class="label" title="The env-resolved base compaction preset (low/medium/high/ultra/mega) that set the token threshold.">Preset</span><span class="value" id="cf-preset">—</span>
433
- <span class="label" title="Live pressure = currentTokens / thresholdTokens (0–100%).">Pressure</span><span class="value" id="cf-pressure">—</span>
434
- <span class="label">Threshold</span><span class="value" id="cf-threshold">—</span>
435
- <span class="label">Fast Gate</span><span class="value" id="cf-gate">—</span>
492
+ <span class="label" title="Live pressure = currentTokens / threshold — % of the model context window (threshold fires at the tier's % of window).">Pressure</span><span class="value" id="cf-pressure">—</span>
493
+ <span class="label" title="Compaction threshold = tierPct × model context window — mega-compact trims BELOW pi's native ~80% auto-compact for any model size.">Threshold</span><span class="value" id="cf-threshold">—</span>
494
+ <span class="label" title="Fast-gate arming floor — the live trim arms once context passes this % of the window.">Fast Gate</span><span class="value" id="cf-gate">—</span>
436
495
  <span class="label">Auto</span><span class="value" id="cf-auto">—</span>
437
496
  <span class="label">Anchor</span><span class="value" id="cf-anchor">—</span>
438
497
  </div>
@@ -541,15 +600,23 @@ function dashboardHtml(tierName: string): string {
541
600
  <thead>
542
601
  <tr>
543
602
  <th>Model</th><th>Provider</th>
544
- <th style="text-align:right">Repos</th>
603
+ <th style="text-align:right" title="Tokens dropped from context by compaction (the input reclaimed)">Tokens In</th>
604
+ <th style="text-align:right" title="Tokens kept as compacted summaries still in context (the output retained)">Tokens Out</th>
605
+ <th style="text-align:right">Freed</th>
606
+ <th style="text-align:right" title="Model context window (max input tokens the model accepts)">Ctx Window</th>
607
+ <th style="text-align:right" title="Model max output tokens per turn">Max Out</th>
608
+ <th style="text-align:right" title="Reasoning-capable model">Reas.</th>
609
+ <th style="text-align:right" title="Distinct sessions with at least one checkpoint">Sessions</th>
545
610
  <th style="text-align:right">Checkpoints</th>
546
- <th style="text-align:right">Tokens Saved</th>
611
+ <th style="text-align:right" title="USD per input token">In $/tok</th>
612
+ <th style="text-align:right" title="USD per output token">Out $/tok</th>
547
613
  <th style="text-align:right">$ Saved</th>
548
614
  <th style="text-align:right">Last Used</th>
549
615
  </tr>
550
616
  </thead>
551
- <tbody id="bm-rows"><tr><td colspan="7" class="repo-none">loading…</td></tr></tbody>
617
+ <tbody id="bm-rows"><tr><td colspan="14" class="repo-none">loading…</td></tr></tbody>
552
618
  </table>
619
+ <p class="legend-note" style="margin-top:8px">Tokens In = Σ original region tokens dropped by compaction. Tokens Out = Σ compacted summary tokens still retained in context. Freed = Tokens In − Tokens Out (net context reclaimed). Ctx Window / Max Out / Reas. come from the latest captured model snapshot for each repo.</p>
553
620
 
554
621
  <div class="updated" id="sm-updated"></div>
555
622
  </div>
@@ -645,7 +712,17 @@ function dashboardHtml(tierName: string): string {
645
712
  document.getElementById('cf-tier').textContent = d.tier + ' (live)';
646
713
  document.getElementById('cf-preset').textContent = d.presetTier;
647
714
  document.getElementById('cf-pressure').textContent = Math.round((d.pressure || 0) * 100) + '%';
648
- document.getElementById('cf-threshold').textContent = d.config.thresholdTokens.toLocaleString();
715
+ // (b) Threshold: show the effective token threshold AND the % of the model
716
+ // context window it represents (percentage-based tiers). d.config.tierPct
717
+ // is present on the live snapshot written by the runtime (Phase-1/2a).
718
+ var cfgPct = d.config.tierPct;
719
+ var cw = d.context.contextWindow || 0;
720
+ var thresholdTxt = d.config.thresholdTokens.toLocaleString();
721
+ if (cfgPct != null && cw > 0) {
722
+ thresholdTxt += ' (' + Math.round(cfgPct * 100) + '% of ' + cw.toLocaleString() + ')';
723
+ }
724
+ document.getElementById('cf-threshold').textContent = thresholdTxt;
725
+ // (c) Fast Gate: arming floor — live trim arms once context passes this %.
649
726
  document.getElementById('cf-gate').textContent = d.config.fastGatePct + '%';
650
727
  document.getElementById('cf-auto').textContent = d.config.auto ? 'enabled' : 'disabled';
651
728
  document.getElementById('cf-anchor').textContent = d.config.anchorUserMessages;
@@ -771,33 +848,67 @@ function dashboardHtml(tierName: string): string {
771
848
  var rows = document.getElementById('bm-rows');
772
849
  if (!rows) return;
773
850
  if (!repos || !repos.length) {
774
- rows.innerHTML = '<tr><td colspan="7" class="repo-none">No repositories registered yet.</td></tr>';
851
+ rows.innerHTML = '<tr><td colspan="14" class="repo-none">No repositories registered yet.</td></tr>';
775
852
  return;
776
853
  }
777
854
  var groups = {};
778
855
  for (var i = 0; i < repos.length; i++) {
779
856
  var r = repos[i];
780
857
  var key = (r.modelName && String(r.modelName).trim()) || '(unknown)';
781
- if (!groups[key]) groups[key] = { model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0, tokensSaved: 0, usd: 0, lastAt: 0, rates: [] };
858
+ if (!groups[key]) groups[key] = {
859
+ model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0,
860
+ tokensSaved: 0, tokensIn: 0, tokensOut: 0, sessions: 0, usd: 0, lastAt: 0,
861
+ inRates: [], outRates: [], ctxWindows: [], maxTokens: [], reasoning: null,
862
+ };
782
863
  var g = groups[key];
783
864
  g.repos++;
784
865
  g.checkpoints += (r.checkpointCount || 0);
785
866
  g.tokensSaved += (r.tokensSaved || 0);
786
- if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.rates.push(r.inputRate); }
867
+ g.tokensIn += (r.tokensDropped || 0);
868
+ g.tokensOut += (r.tokensKept || 0);
869
+ g.sessions += (r.sessions || 0);
870
+ if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.inRates.push(r.inputRate); }
871
+ if (r.outputRate) g.outRates.push(r.outputRate);
872
+ if (r.contextWindow) g.ctxWindows.push(r.contextWindow);
873
+ if (r.maxTokens) g.maxTokens.push(r.maxTokens);
874
+ if (r.reasoning != null) g.reasoning = r.reasoning;
787
875
  if (r.lastCompactedAt && r.lastCompactedAt > g.lastAt) g.lastAt = r.lastCompactedAt;
788
876
  }
789
877
  var arr = [];
790
878
  for (var k in groups) { if (Object.prototype.hasOwnProperty.call(groups, k)) arr.push(groups[k]); }
791
879
  arr.sort(function(a, b) { return b.tokensSaved - a.tokensSaved; });
880
+ // Helper: a set of numeric samples collapses to a single value when all
881
+ // repos in the group agree, otherwise shows the range (min–max) so the
882
+ // user can see mixed-config model groups at a glance.
883
+ function collapseNum(samples) {
884
+ if (!samples || !samples.length) return '—';
885
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
886
+ return lo === hi ? lo.toLocaleString() : lo.toLocaleString() + '–' + hi.toLocaleString();
887
+ }
888
+ function collapseRate(samples) {
889
+ if (!samples || !samples.length) return '—';
890
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
891
+ var fmt = function(v) { return '$' + v.toFixed(6); };
892
+ return lo === hi ? fmt(lo) : fmt(lo) + '–' + fmt(hi);
893
+ }
792
894
  rows.innerHTML = arr.map(function(g) {
895
+ var freed = (g.tokensIn || 0) - (g.tokensOut || 0);
793
896
  var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
794
897
  var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
898
+ var reas = g.reasoning == null ? '—' : (g.reasoning ? 'yes' : 'no');
795
899
  return '<tr>' +
796
900
  '<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
797
901
  '<td>' + sanitize(g.provider) + '</td>' +
798
- '<td class="num">' + g.repos.toLocaleString() + '</td>' +
902
+ '<td class="num">' + (g.tokensIn || 0).toLocaleString() + '</td>' +
903
+ '<td class="num">' + (g.tokensOut || 0).toLocaleString() + '</td>' +
904
+ '<td class="num">' + freed.toLocaleString() + '</td>' +
905
+ '<td class="num">' + collapseNum(g.ctxWindows) + '</td>' +
906
+ '<td class="num">' + collapseNum(g.maxTokens) + '</td>' +
907
+ '<td class="num">' + reas + '</td>' +
908
+ '<td class="num">' + g.sessions.toLocaleString() + '</td>' +
799
909
  '<td class="num">' + g.checkpoints.toLocaleString() + '</td>' +
800
- '<td class="num">' + g.tokensSaved.toLocaleString() + '</td>' +
910
+ '<td class="num">' + collapseRate(g.inRates) + '</td>' +
911
+ '<td class="num">' + collapseRate(g.outRates) + '</td>' +
801
912
  '<td class="num">' + sanitize(usd) + '</td>' +
802
913
  '<td class="num">' + sanitize(when) + '</td>' +
803
914
  '</tr>';
@@ -125,10 +125,21 @@ export function registerCommands(pi: ExtensionAPI, runtime: MegaRuntime, config:
125
125
  repoCount = listRepoRegistry(process.env.MEGACOMPACT_INDEX_DIR).length;
126
126
  } catch { /* non-fatal */ }
127
127
  const crossRepoStr = `${crossRepoInjections} cross-repo injections recorded · ${repoCount} repos indexed`;
128
+ // Effective compaction threshold = tierPct × model context window (kept
129
+ // BELOW pi's native ~80% auto-compact for any model size). Falls back to
130
+ // the boot token value when the window is unknown (custom tier / pre-
131
+ // model-select). Display matches the dashboard's percentage-based view.
132
+ const effThreshold = config.tierPct != null && ctxWindow > 0
133
+ ? Math.round(config.tierPct * ctxWindow)
134
+ : config.thresholdTokens;
135
+ const winStr = ctxWindow > 0
136
+ ? (ctxWindow >= 1_000_000 ? `${Math.round(ctxWindow / 1_000_000)}M` : `${Math.round(ctxWindow / 1_000)}k`)
137
+ : "?";
138
+ const tierPctStr = config.tierPct != null ? `${Math.round(config.tierPct * 100)}%` : "n/a";
128
139
  ctx.ui.notify(
129
140
  `[mega-compact] pct=${pct} tokens=${tokens} tier=${runtime.pressureBand} (live) preset=${config.tier} ` +
130
141
  `pressure=${Math.round(runtime.pressure * 100)}% fastGate=${config.fastGatePct}% ` +
131
- `threshold=${config.thresholdTokens} auto=${config.auto} autoInline=${config.autoInline}\n` +
142
+ `threshold=${effThreshold.toLocaleString()} (${tierPctStr} of ${winStr} window) tierPct=${config.tierPct != null ? config.tierPct.toFixed(2) : "n/a"} auto=${config.auto} autoInline=${config.autoInline}\n` +
132
143
  `[mega-compact] store: ${st.checkpointCount} chkpt · ` +
133
144
  `${st.totalTokenEstimate} tok · last=${st.lastCheckpointId ?? "—"} · ` +
134
145
  `injected=${st.injectedCount} · dedup=${(st.dedupHitRate * 100).toFixed(0)}%\n` +