pi-mega-compact 0.6.7 → 0.6.9

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.
@@ -83,7 +83,52 @@ function readIndex() {
83
83
  inputRate: r.input_rate ?? null,
84
84
  outputRate: r.output_rate ?? null,
85
85
  lastSeen: Number(r.last_seen ?? 0),
86
+ // Defaults — enriched below from each repo's own store.
87
+ tokensKept: 0,
88
+ tokensDropped: 0,
89
+ sessions: 0,
90
+ contextWindow: null,
91
+ maxTokens: null,
92
+ reasoning: null,
86
93
  }));
94
+ // Enrich each repo with per-store token + model detail read directly via
95
+ // node:sqlite (same zero-dependency invariant as readIndex; no store graph
96
+ // import). Best-effort: a missing/corrupt store degrades to the defaults
97
+ // above so the dashboard never fails to render.
98
+ for (const repo of mapped) {
99
+ try {
100
+ const storePath = join(repo.stateDir, "sqlite.db");
101
+ if (existsSync(storePath)) {
102
+ const sdb = new DatabaseSync(storePath, { readOnly: true });
103
+ try {
104
+ const tok = sdb
105
+ .prepare(`SELECT COALESCE(SUM(token_estimate),0) AS kept,
106
+ COALESCE(SUM(original_token_estimate),0) AS dropped,
107
+ COUNT(DISTINCT session_id) AS sess
108
+ FROM context_chunks WHERE dedup_status != 'removed'`)
109
+ .get();
110
+ repo.tokensKept = Number(tok.kept ?? 0);
111
+ repo.tokensDropped = Number(tok.dropped ?? 0);
112
+ repo.sessions = Number(tok.sess ?? 0);
113
+ const mrow = sdb
114
+ .prepare(`SELECT context_window, max_tokens, reasoning
115
+ FROM model_snapshots ORDER BY captured_at DESC LIMIT 1`)
116
+ .get();
117
+ if (mrow) {
118
+ repo.contextWindow = Number(mrow.context_window ?? 0) || null;
119
+ repo.maxTokens = Number(mrow.max_tokens ?? 0) || null;
120
+ repo.reasoning = Number(mrow.reasoning ?? 0) === 1;
121
+ }
122
+ }
123
+ finally {
124
+ sdb.close();
125
+ }
126
+ }
127
+ }
128
+ catch {
129
+ /* best-effort — keep the defaults */
130
+ }
131
+ }
87
132
  // Defensive display hygiene (belt-and-suspenders — the real fix is that
88
133
  // tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
89
134
  // paths that should never have been real repos, and collapse duplicate
@@ -443,15 +488,23 @@ function dashboardHtml(tierName) {
443
488
  <thead>
444
489
  <tr>
445
490
  <th>Model</th><th>Provider</th>
446
- <th style="text-align:right">Repos</th>
491
+ <th style="text-align:right" title="Tokens dropped from context by compaction (the input reclaimed)">Tokens In</th>
492
+ <th style="text-align:right" title="Tokens kept as compacted summaries still in context (the output retained)">Tokens Out</th>
493
+ <th style="text-align:right">Freed</th>
494
+ <th style="text-align:right" title="Model context window (max input tokens the model accepts)">Ctx Window</th>
495
+ <th style="text-align:right" title="Model max output tokens per turn">Max Out</th>
496
+ <th style="text-align:right" title="Reasoning-capable model">Reas.</th>
497
+ <th style="text-align:right" title="Distinct sessions with at least one checkpoint">Sessions</th>
447
498
  <th style="text-align:right">Checkpoints</th>
448
- <th style="text-align:right">Tokens Saved</th>
499
+ <th style="text-align:right" title="USD per input token">In $/tok</th>
500
+ <th style="text-align:right" title="USD per output token">Out $/tok</th>
449
501
  <th style="text-align:right">$ Saved</th>
450
502
  <th style="text-align:right">Last Used</th>
451
503
  </tr>
452
504
  </thead>
453
- <tbody id="bm-rows"><tr><td colspan="7" class="repo-none">loading…</td></tr></tbody>
505
+ <tbody id="bm-rows"><tr><td colspan="14" class="repo-none">loading…</td></tr></tbody>
454
506
  </table>
507
+ <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>
455
508
 
456
509
  <div class="updated" id="sm-updated"></div>
457
510
  </div>
@@ -673,33 +726,67 @@ function dashboardHtml(tierName) {
673
726
  var rows = document.getElementById('bm-rows');
674
727
  if (!rows) return;
675
728
  if (!repos || !repos.length) {
676
- rows.innerHTML = '<tr><td colspan="7" class="repo-none">No repositories registered yet.</td></tr>';
729
+ rows.innerHTML = '<tr><td colspan="14" class="repo-none">No repositories registered yet.</td></tr>';
677
730
  return;
678
731
  }
679
732
  var groups = {};
680
733
  for (var i = 0; i < repos.length; i++) {
681
734
  var r = repos[i];
682
735
  var key = (r.modelName && String(r.modelName).trim()) || '(unknown)';
683
- if (!groups[key]) groups[key] = { model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0, tokensSaved: 0, usd: 0, lastAt: 0, rates: [] };
736
+ if (!groups[key]) groups[key] = {
737
+ model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0,
738
+ tokensSaved: 0, tokensIn: 0, tokensOut: 0, sessions: 0, usd: 0, lastAt: 0,
739
+ inRates: [], outRates: [], ctxWindows: [], maxTokens: [], reasoning: null,
740
+ };
684
741
  var g = groups[key];
685
742
  g.repos++;
686
743
  g.checkpoints += (r.checkpointCount || 0);
687
744
  g.tokensSaved += (r.tokensSaved || 0);
688
- if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.rates.push(r.inputRate); }
745
+ g.tokensIn += (r.tokensDropped || 0);
746
+ g.tokensOut += (r.tokensKept || 0);
747
+ g.sessions += (r.sessions || 0);
748
+ if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.inRates.push(r.inputRate); }
749
+ if (r.outputRate) g.outRates.push(r.outputRate);
750
+ if (r.contextWindow) g.ctxWindows.push(r.contextWindow);
751
+ if (r.maxTokens) g.maxTokens.push(r.maxTokens);
752
+ if (r.reasoning != null) g.reasoning = r.reasoning;
689
753
  if (r.lastCompactedAt && r.lastCompactedAt > g.lastAt) g.lastAt = r.lastCompactedAt;
690
754
  }
691
755
  var arr = [];
692
756
  for (var k in groups) { if (Object.prototype.hasOwnProperty.call(groups, k)) arr.push(groups[k]); }
693
757
  arr.sort(function(a, b) { return b.tokensSaved - a.tokensSaved; });
758
+ // Helper: a set of numeric samples collapses to a single value when all
759
+ // repos in the group agree, otherwise shows the range (min–max) so the
760
+ // user can see mixed-config model groups at a glance.
761
+ function collapseNum(samples) {
762
+ if (!samples || !samples.length) return '—';
763
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
764
+ return lo === hi ? lo.toLocaleString() : lo.toLocaleString() + '–' + hi.toLocaleString();
765
+ }
766
+ function collapseRate(samples) {
767
+ if (!samples || !samples.length) return '—';
768
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
769
+ var fmt = function(v) { return '$' + v.toFixed(6); };
770
+ return lo === hi ? fmt(lo) : fmt(lo) + '–' + fmt(hi);
771
+ }
694
772
  rows.innerHTML = arr.map(function(g) {
773
+ var freed = (g.tokensIn || 0) - (g.tokensOut || 0);
695
774
  var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
696
775
  var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
776
+ var reas = g.reasoning == null ? '—' : (g.reasoning ? 'yes' : 'no');
697
777
  return '<tr>' +
698
778
  '<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
699
779
  '<td>' + sanitize(g.provider) + '</td>' +
700
- '<td class="num">' + g.repos.toLocaleString() + '</td>' +
780
+ '<td class="num">' + (g.tokensIn || 0).toLocaleString() + '</td>' +
781
+ '<td class="num">' + (g.tokensOut || 0).toLocaleString() + '</td>' +
782
+ '<td class="num">' + freed.toLocaleString() + '</td>' +
783
+ '<td class="num">' + collapseNum(g.ctxWindows) + '</td>' +
784
+ '<td class="num">' + collapseNum(g.maxTokens) + '</td>' +
785
+ '<td class="num">' + reas + '</td>' +
786
+ '<td class="num">' + g.sessions.toLocaleString() + '</td>' +
701
787
  '<td class="num">' + g.checkpoints.toLocaleString() + '</td>' +
702
- '<td class="num">' + g.tokensSaved.toLocaleString() + '</td>' +
788
+ '<td class="num">' + collapseRate(g.inRates) + '</td>' +
789
+ '<td class="num">' + collapseRate(g.outRates) + '</td>' +
703
790
  '<td class="num">' + sanitize(usd) + '</td>' +
704
791
  '<td class="num">' + sanitize(when) + '</td>' +
705
792
  '</tr>';
@@ -16,7 +16,11 @@ function truncate(s, max) {
16
16
  return s.length <= max ? s : `${s.slice(0, max)}…`;
17
17
  }
18
18
  function firstText(m) {
19
- const t = m.text.trim();
19
+ // PREVENT crash: pi can hand us a message with text: undefined (pure
20
+ // tool-call/tool-result). Guard the trim so the legacy summarizeMessages
21
+ // path can't throw the same undefined-text crash the extractive path did.
22
+ const raw = m.text ?? "";
23
+ const t = raw.trim();
20
24
  return t.length > 0 ? t : undefined;
21
25
  }
22
26
  /** Heuristic: does this text look like chatty filler we can collapse? */
@@ -9,13 +9,16 @@
9
9
  import { extractFileCandidates } from "./compact.js";
10
10
  /** Classify a message's relationship to a file path. */
11
11
  function fileOps(msg) {
12
- // msg.text may be undefined for pure tool-call/result messages; the guard
13
- // lives in extractFileCandidates, but the early return short-circuits the
14
- // write-detection regex too so we never classify an empty message.
15
- const paths = extractFileCandidates(msg.text);
12
+ // PREVENT crash: msg.text may be undefined for pure tool-call/result
13
+ // messages. extractFileCandidates guards the split, but if it returned a
14
+ // hit we'd still call .toLowerCase() on the raw (possibly-undefined) text.
15
+ // Coerce once so both extractFileCandidates and the write-detection regex
16
+ // are safe, and the early return still skips empty messages.
17
+ const text = msg.text ?? "";
18
+ const paths = extractFileCandidates(text);
16
19
  if (paths.length === 0)
17
20
  return [];
18
- const low = msg.text.toLowerCase();
21
+ const low = text.toLowerCase();
19
22
  const isWrite = /\b(write|edit|create|save|append|overwrite|update|patch|modify)\b/.test(low);
20
23
  return paths.map((p) => ({ path: p, op: isWrite ? "write" : "read" }));
21
24
  }
@@ -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
@@ -541,15 +599,23 @@ function dashboardHtml(tierName: string): string {
541
599
  <thead>
542
600
  <tr>
543
601
  <th>Model</th><th>Provider</th>
544
- <th style="text-align:right">Repos</th>
602
+ <th style="text-align:right" title="Tokens dropped from context by compaction (the input reclaimed)">Tokens In</th>
603
+ <th style="text-align:right" title="Tokens kept as compacted summaries still in context (the output retained)">Tokens Out</th>
604
+ <th style="text-align:right">Freed</th>
605
+ <th style="text-align:right" title="Model context window (max input tokens the model accepts)">Ctx Window</th>
606
+ <th style="text-align:right" title="Model max output tokens per turn">Max Out</th>
607
+ <th style="text-align:right" title="Reasoning-capable model">Reas.</th>
608
+ <th style="text-align:right" title="Distinct sessions with at least one checkpoint">Sessions</th>
545
609
  <th style="text-align:right">Checkpoints</th>
546
- <th style="text-align:right">Tokens Saved</th>
610
+ <th style="text-align:right" title="USD per input token">In $/tok</th>
611
+ <th style="text-align:right" title="USD per output token">Out $/tok</th>
547
612
  <th style="text-align:right">$ Saved</th>
548
613
  <th style="text-align:right">Last Used</th>
549
614
  </tr>
550
615
  </thead>
551
- <tbody id="bm-rows"><tr><td colspan="7" class="repo-none">loading…</td></tr></tbody>
616
+ <tbody id="bm-rows"><tr><td colspan="14" class="repo-none">loading…</td></tr></tbody>
552
617
  </table>
618
+ <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
619
 
554
620
  <div class="updated" id="sm-updated"></div>
555
621
  </div>
@@ -771,33 +837,67 @@ function dashboardHtml(tierName: string): string {
771
837
  var rows = document.getElementById('bm-rows');
772
838
  if (!rows) return;
773
839
  if (!repos || !repos.length) {
774
- rows.innerHTML = '<tr><td colspan="7" class="repo-none">No repositories registered yet.</td></tr>';
840
+ rows.innerHTML = '<tr><td colspan="14" class="repo-none">No repositories registered yet.</td></tr>';
775
841
  return;
776
842
  }
777
843
  var groups = {};
778
844
  for (var i = 0; i < repos.length; i++) {
779
845
  var r = repos[i];
780
846
  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: [] };
847
+ if (!groups[key]) groups[key] = {
848
+ model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0,
849
+ tokensSaved: 0, tokensIn: 0, tokensOut: 0, sessions: 0, usd: 0, lastAt: 0,
850
+ inRates: [], outRates: [], ctxWindows: [], maxTokens: [], reasoning: null,
851
+ };
782
852
  var g = groups[key];
783
853
  g.repos++;
784
854
  g.checkpoints += (r.checkpointCount || 0);
785
855
  g.tokensSaved += (r.tokensSaved || 0);
786
- if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.rates.push(r.inputRate); }
856
+ g.tokensIn += (r.tokensDropped || 0);
857
+ g.tokensOut += (r.tokensKept || 0);
858
+ g.sessions += (r.sessions || 0);
859
+ if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.inRates.push(r.inputRate); }
860
+ if (r.outputRate) g.outRates.push(r.outputRate);
861
+ if (r.contextWindow) g.ctxWindows.push(r.contextWindow);
862
+ if (r.maxTokens) g.maxTokens.push(r.maxTokens);
863
+ if (r.reasoning != null) g.reasoning = r.reasoning;
787
864
  if (r.lastCompactedAt && r.lastCompactedAt > g.lastAt) g.lastAt = r.lastCompactedAt;
788
865
  }
789
866
  var arr = [];
790
867
  for (var k in groups) { if (Object.prototype.hasOwnProperty.call(groups, k)) arr.push(groups[k]); }
791
868
  arr.sort(function(a, b) { return b.tokensSaved - a.tokensSaved; });
869
+ // Helper: a set of numeric samples collapses to a single value when all
870
+ // repos in the group agree, otherwise shows the range (min–max) so the
871
+ // user can see mixed-config model groups at a glance.
872
+ function collapseNum(samples) {
873
+ if (!samples || !samples.length) return '—';
874
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
875
+ return lo === hi ? lo.toLocaleString() : lo.toLocaleString() + '–' + hi.toLocaleString();
876
+ }
877
+ function collapseRate(samples) {
878
+ if (!samples || !samples.length) return '—';
879
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
880
+ var fmt = function(v) { return '$' + v.toFixed(6); };
881
+ return lo === hi ? fmt(lo) : fmt(lo) + '–' + fmt(hi);
882
+ }
792
883
  rows.innerHTML = arr.map(function(g) {
884
+ var freed = (g.tokensIn || 0) - (g.tokensOut || 0);
793
885
  var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
794
886
  var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
887
+ var reas = g.reasoning == null ? '—' : (g.reasoning ? 'yes' : 'no');
795
888
  return '<tr>' +
796
889
  '<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
797
890
  '<td>' + sanitize(g.provider) + '</td>' +
798
- '<td class="num">' + g.repos.toLocaleString() + '</td>' +
891
+ '<td class="num">' + (g.tokensIn || 0).toLocaleString() + '</td>' +
892
+ '<td class="num">' + (g.tokensOut || 0).toLocaleString() + '</td>' +
893
+ '<td class="num">' + freed.toLocaleString() + '</td>' +
894
+ '<td class="num">' + collapseNum(g.ctxWindows) + '</td>' +
895
+ '<td class="num">' + collapseNum(g.maxTokens) + '</td>' +
896
+ '<td class="num">' + reas + '</td>' +
897
+ '<td class="num">' + g.sessions.toLocaleString() + '</td>' +
799
898
  '<td class="num">' + g.checkpoints.toLocaleString() + '</td>' +
800
- '<td class="num">' + g.tokensSaved.toLocaleString() + '</td>' +
899
+ '<td class="num">' + collapseRate(g.inRates) + '</td>' +
900
+ '<td class="num">' + collapseRate(g.outRates) + '</td>' +
801
901
  '<td class="num">' + sanitize(usd) + '</td>' +
802
902
  '<td class="num">' + sanitize(when) + '</td>' +
803
903
  '</tr>';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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-2-Clause",
package/src/compact.ts CHANGED
@@ -23,7 +23,11 @@ function truncate(s: string, max: number): string {
23
23
  }
24
24
 
25
25
  function firstText(m: EngineMessage): string | undefined {
26
- const t = m.text.trim();
26
+ // PREVENT crash: pi can hand us a message with text: undefined (pure
27
+ // tool-call/tool-result). Guard the trim so the legacy summarizeMessages
28
+ // path can't throw the same undefined-text crash the extractive path did.
29
+ const raw = m.text ?? "";
30
+ const t = raw.trim();
27
31
  return t.length > 0 ? t : undefined;
28
32
  }
29
33
 
package/src/supersede.ts CHANGED
@@ -12,12 +12,15 @@ import { extractFileCandidates } from "./compact.js";
12
12
 
13
13
  /** Classify a message's relationship to a file path. */
14
14
  function fileOps(msg: EngineMessage): { path: string; op: "read" | "write" }[] {
15
- // msg.text may be undefined for pure tool-call/result messages; the guard
16
- // lives in extractFileCandidates, but the early return short-circuits the
17
- // write-detection regex too so we never classify an empty message.
18
- const paths = extractFileCandidates(msg.text);
15
+ // PREVENT crash: msg.text may be undefined for pure tool-call/result
16
+ // messages. extractFileCandidates guards the split, but if it returned a
17
+ // hit we'd still call .toLowerCase() on the raw (possibly-undefined) text.
18
+ // Coerce once so both extractFileCandidates and the write-detection regex
19
+ // are safe, and the early return still skips empty messages.
20
+ const text = msg.text ?? "";
21
+ const paths = extractFileCandidates(text);
19
22
  if (paths.length === 0) return [];
20
- const low = msg.text.toLowerCase();
23
+ const low = text.toLowerCase();
21
24
  const isWrite = /\b(write|edit|create|save|append|overwrite|update|patch|modify)\b/.test(low);
22
25
  return paths.map((p) => ({ path: p, op: isWrite ? "write" : "read" }));
23
26
  }