omp-vcc 0.1.7 → 0.1.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.
package/README.md CHANGED
@@ -19,15 +19,23 @@ New here → [`docs/setup.md`](docs/setup.md).
19
19
  - **Manual** `/omp-vcc [keep:N] [focus]` (`/pi-vcc` alias) — compact only, single-line toast `90k→22k` — e.g. `/omp-vcc keep:2 fix auth` (detailed savings via `/vcc-stats`).
20
20
  - **Recall** `vcc_recall({query, scope, page, mode, expand})` + `/vcc-recall` — regex → TF-IDF OR, 5/page, `mode:'touched'`, `#N:path` drill-down.
21
21
  - **Savings** toast `90k→22k (76% saved)` single line (inline `Last compaction` detail removed; use `/vcc-stats`), divider, `/vcc-stats` + `vcc_stats` tool (50-capped history, per-pi), `details.savings` persisted, `/tmp/omp-vcc-debug.json`.
22
+
22
23
  ## Commands
23
24
 
24
- | `/omp-vcc [keep:N] [focus]` | Compact only. `keep:0` = all. Smart-keep boosts `keep:1` (5k→25k) unless explicit. Toast single line; detail via `/vcc-stats`. |
25
- | `/pi-vcc` | Legacy alias for `/omp-vcc` |
26
- | `/vcc-recall [query] [scope:all] [page:N] [mode:touched]` | Search compacted history |
27
- | `/pi-vcc-recall` | Legacy alias for `/vcc-recall` |
28
- | `/vcc-stats [history\|all]` | Last savings + history table (single, no alias) |
25
+ | Command | What it does | Defaults (bare invocation) |
26
+ | --- | --- | --- |
27
+ | `/omp-vcc [keep:N] [focus]` | Compact only. Toast single line; detail via `/vcc-stats`. | `keep:1` — smart-keep grows it while the tail stays ≤5k (cap 25k). `keep:0` summarizes everything. No focus = no follow-up prompt. Explicit `keep:N` is never boosted. |
28
+ | `/pi-vcc` | Legacy alias for `/omp-vcc` | Same as above. |
29
+ | `/vcc-recall [query] [scope:all] [page:N]` | Search compacted history, 5 results per page. | No query = last 25 entries of the active branch. Scope defaults to the active lineage; `scope:all` searches the whole session. `page:1`. |
30
+ | `/pi-vcc-recall` | Legacy alias for `/vcc-recall` | Same as above. |
31
+ | `/vcc-stats [history\|all]` | Last-compaction savings + history table (single, no alias). | Bare = last-compaction detail (history table appended once >1 compaction exists). `history`/`all` = full table first, then the last detail. |
32
+ | `/vcc-config` | Effective config with per-key source (`file` / `host overlay` / `default`) plus resolved file path. | Args ignored — always the merged result. |
33
+
34
+ +
29
35
 
30
- Tools: `vcc_recall`, `vcc_stats` (approval `read`). Extension-only no `commands/*.md` file slash commands (avoids duplicate `/omp-vcc`).
36
+ +Search mode (`hybrid` regex→TF-IDF search default, `touched` = touched-files list) is a `vcc_recall` tool parameter only — the slash command always searches hybrid.
37
+ +
38
+ +Tools: `vcc_recall`, `vcc_stats` (approval `read`). Extension-only — no `commands/*.md` file slash commands (avoids duplicate `/omp-vcc`).
31
39
 
32
40
  ## Configuration
33
41
 
@@ -45,7 +53,7 @@ File `~/.omp/omp-vcc/config.json` — XDG: `$OMP_VCC_CONFIG_PATH` > `$PI_VCC_CON
45
53
  ```
46
54
 
47
55
  | Flag | Default | Effect |
48
- |---|---|---|
56
+ | --- | --- | --- |
49
57
  | `vccEnabled` | `true` | Master switch. `false` = no interception. |
50
58
  | `overrideDefaultCompaction` | `true` | `true` = VCC handles threshold/overflow. `false` = host `methodOrder` handles them; `/omp-vcc` still works. |
51
59
  | `smartKeepTail` | `true` | Grow `keep:1` when tail <5k (cap 25k). Explicit `keep:N` never boosted. |
@@ -53,7 +61,7 @@ File `~/.omp/omp-vcc/config.json` — XDG: `$OMP_VCC_CONFIG_PATH` > `$PI_VCC_CON
53
61
  | `debug` | `false` | Write `/tmp/omp-vcc-debug.json` per compaction. |
54
62
  | `chainShakeHint` | `false` | Eager post-VCC `shake` chain. Host rescue already auto-shakes on dead-end; this forces it. |
55
63
 
56
- Toggle live: `omp config set plugins."@zhulinchng/omp-vcc".debug true` or `/settings` → `@zhulinchng/omp-vcc`. File is source of truth; `ctx.settings` overlays at runtime.
64
+ Toggle live: `omp config set plugins."@zhulinchng/omp-vcc".debug true` or `/settings` → `@zhulinchng/omp-vcc`. File is source of truth; `ctx.settings` overlays at runtime. `/vcc-config` shows the merged result — use it to confirm a toggle took effect.
57
65
 
58
66
  ## How it works
59
67
 
@@ -70,7 +78,7 @@ See [`docs/harness.md`](docs/harness.md) for host impact and [`docs/architecture
70
78
  VCC summarizes **history**; `shake` elides `artifact://` blocks in **kept tail** (disjoint); `snapcompact` is an alternative history archiver (vision bitmaps, needs `model.input includes "image"`). One `CompactionEntry` per trigger — combos are additive or sequential, never double-summarization.
71
79
 
72
80
  | Trigger | `override` | Result |
73
- |---|---|---|
81
+ | --- | --- | --- |
74
82
  | threshold, `override:true` (default) | VCC handles; host auto-shakes if still over band (`#rescueCompactionDeadEnd`) | VCC + shake if dead-end |
75
83
  | threshold, `override:false` | Host walks `methodOrder` (`remote→snapcompact→handoff→shake→soft`) | VCC → snapcompact/shake fallback |
76
84
  | `/omp-vcc keep:2` | Always VCC (sentinel `__omp_vcc__`) | VCC |
@@ -82,9 +90,9 @@ Additive VCC+shake is automatic. Eager chain: `chainShakeHint:true`. Explicit mo
82
90
 
83
91
  ```sh
84
92
  bunx tsc --noEmit
85
- bun test # 515 tests, 48 files, 1443 expects, 0 fail
86
- bun test tests/e2e --timeout 120000 # 111 E2E
87
- bun run smoke # 9 checks: 3 hooks + 4 cmds + 2 tools + pipeline
93
+ bun test # 661 tests, 56 files, 2095 expects, 0 fail
94
+ bun test tests/e2e --timeout 120000 # 124 E2E
95
+ bun run smoke # 13 checks: 3 hooks + 6 cmds + 2 tools + dedup (+ pipeline)
88
96
  omp plugin link . && omp plugin doctor
89
97
  ```
90
98
 
@@ -97,6 +105,7 @@ bunx tsc --noEmit && bun test && bun run smoke
97
105
  omp -e @zhulinchng/omp-vcc
98
106
  /omp-vcc keep:1 # expect [Session Goal] + toast omp-vcc: kept 1/2 turns
99
107
  /vcc-stats # table + history
108
+ /vcc-config # effective config card with per-key source
100
109
  cat /tmp/omp-vcc-debug.json # when debug:true
101
110
  ```
102
111
 
@@ -19,6 +19,7 @@ import {
19
19
  scheduleCompactionStatsNotify,
20
20
  registerVccStatsTool as registerVccStatsToolHook,
21
21
  registerVccStatsCommand as registerVccStatsCommandHook,
22
+ registerVccConfigCommand as registerVccConfigCommandHook,
22
23
  } from "./vcc-core/hook";
23
24
  import { searchEntriesDetailed, getTouchedFiles } from "./vcc-core/core/search-entries";
24
25
  import { formatRecallOutput, formatTouchedOutput } from "./vcc-core/core/format-recall";
@@ -342,9 +343,10 @@ export default function (pi: ExtensionAPI): void {
342
343
  });
343
344
  // ── /vcc-stats commands — show savings table (PR3) ──
344
345
  registerVccStatsCommandHook(pi);
346
+ registerVccConfigCommandHook(pi);
345
347
 
346
348
  }
347
349
  // ── Re-exports for pi-vcc test compatibility (not dead: tests import via hook directly,
348
350
  // but external consumers and the `vcc-recall` shim may import via main) ──
349
- export { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION, getLastCompactionStats, getCompactionHistory, formatCompactionStats, formatStatsTable, formatLastStatsDetail, scheduleCompactionStatsNotify, AUTO_CONTINUE_CUSTOM_TYPE, LEGACY_AUTO_CONTINUE_CUSTOM_TYPE, invalidExpandIndices, registerRecallTool, registerVccRecallCommand, registerPiVccCommand, registerVccStatsTool, registerVccStatsCommand, clearCompactionHistoryForTests } from "./vcc-core/hook";
351
+ export { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION, getLastCompactionStats, getCompactionHistory, formatCompactionStats, formatStatsTable, formatLastStatsDetail, scheduleCompactionStatsNotify, AUTO_CONTINUE_CUSTOM_TYPE, LEGACY_AUTO_CONTINUE_CUSTOM_TYPE, invalidExpandIndices, registerRecallTool, registerVccRecallCommand, registerPiVccCommand, registerVccStatsTool, registerVccStatsCommand, registerVccConfigCommand, clearCompactionHistoryForTests } from "./vcc-core/hook";
350
352
  export { buildPiVccCustomInstructions, parseKeepAndPrompt } from "./vcc-core/core/compact-args";
@@ -321,6 +321,17 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
321
321
  }
322
322
  break;
323
323
  }
324
+ case "custom": {
325
+ // Injected context (memory, skills): kept verbatim under its own tag
326
+ // so the next session can attribute it correctly.
327
+ const text = truncateTokens(collapseSkillText(b.text), TRUNCATE_USER);
328
+ if (text) {
329
+ const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
330
+ pushText("[custom]", text, ref);
331
+ }
332
+ lastHeader = "[custom]";
333
+ break;
334
+ }
324
335
  case "tool_call": {
325
336
  // Skip malformed tool calls from streaming providers (empty name / fragmented args).
326
337
  if (!b.name || b.name.trim() === "") break;
@@ -3,7 +3,7 @@ import type { FileOps, NormalizedBlock } from "../types";
3
3
  import { clip, clipSentence, nonEmptyLines } from "./content";
4
4
  import type { SectionData } from "../sections";
5
5
  import { extractGoals } from "../extract/goals";
6
- import { extractFiles } from "../extract/files";
6
+ import { extractFiles, renderFileCategoryLines } from "../extract/files";
7
7
  import { extractPreferences, dedupPreferencesAgainstGoals } from "../extract/preferences";
8
8
  import { extractCommits, formatCommits } from "../extract/commits";
9
9
  import { buildBriefSections, stringifyBrief } from "./brief";
@@ -41,21 +41,15 @@ const extractOutstandingContext = (blocks: NormalizedBlock[]): string[] => {
41
41
 
42
42
  return items.slice(0, 5);
43
43
  };
44
-
45
44
  const formatFileActivity = (blocks: NormalizedBlock[], fileOps?: FileOps): string[] => {
46
45
  const act = extractFiles(blocks, fileOps);
47
46
  // Dedup: if already Modified, drop from Created (file existed before)
48
47
  for (const p of act.modified) act.created.delete(p);
49
- const lines: string[] = [];
50
- const cap = (set: Set<string>, limit: number) => {
51
- const arr = [...set];
52
- if (arr.length <= limit) return arr.join(", ");
53
- return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
54
- };
55
- if (act.modified.size > 0) lines.push(`Modified: ${cap(act.modified, 10)}`);
56
- if (act.created.size > 0) lines.push(`Created: ${cap(act.created, 10)}`);
57
- if (act.read.size > 0) lines.push(`Read: ${cap(act.read, 10)}`);
58
- return lines;
48
+ return [
49
+ ...renderFileCategoryLines("Modified", [...act.modified]),
50
+ ...renderFileCategoryLines("Created", [...act.created]),
51
+ ...renderFileCategoryLines("Read", [...act.read]),
52
+ ];
59
53
  };
60
54
 
61
55
  export const buildSections = (input: BuildSectionsInput): SectionData => {
@@ -18,14 +18,21 @@ const wrapLine = (line: string, maxChars: number): string[] => {
18
18
  const wrapped: string[] = [];
19
19
  let remaining = line;
20
20
  let prefix = "";
21
-
22
21
  while (prefix.length + remaining.length > maxChars) {
23
22
  const available = Math.max(20, maxChars - prefix.length);
24
23
  let splitAt = remaining.lastIndexOf(" ", available);
25
- if (splitAt < Math.floor(available * 0.5)) splitAt = available;
26
-
27
- wrapped.push(prefix + remaining.slice(0, splitAt).trimEnd());
28
- remaining = remaining.slice(splitAt).trimStart();
24
+ const hard = splitAt < Math.floor(available * 0.5);
25
+ if (hard) {
26
+ // No usable space: mid-token break. One char shorter plus a trailing
27
+ // backslash marks it so parsers rejoin WITHOUT a space (see
28
+ // mergeFileLines); the line stays within maxChars.
29
+ const cut = Math.max(1, available - 1);
30
+ wrapped.push(prefix + remaining.slice(0, cut) + "\\");
31
+ remaining = remaining.slice(cut);
32
+ } else {
33
+ wrapped.push(prefix + remaining.slice(0, splitAt).trimEnd());
34
+ remaining = remaining.slice(splitAt).trimStart();
35
+ }
29
36
  prefix = continuationIndent;
30
37
  }
31
38
 
@@ -60,6 +60,15 @@ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
60
60
  return blocks;
61
61
  }
62
62
 
63
+ // Injected context (custom_message entries: memory, skill context). Kept as
64
+ // its own kind so the brief can attribute it without mislabeling it as a
65
+ // user or assistant turn. branchSummary entries carry no content here.
66
+ if (msg.role === "custom") {
67
+ const text = sanitize(textOf(msg.content));
68
+ if (text) return [{ kind: "custom", text, sourceIndex: msgIndex }];
69
+ return [];
70
+ }
71
+
63
72
  return [];
64
73
  };
65
74
 
@@ -113,6 +113,7 @@ const scoreBlock = (
113
113
 
114
114
  if (block.kind === "user") add(ranked, 18, "user-turn");
115
115
  if (block.kind === "assistant") add(ranked, 10, "assistant-context");
116
+ if (block.kind === "custom") add(ranked, 12, "custom-context");
116
117
  if (block.kind === "tool_result") add(ranked, 1, "tool-result-low-value");
117
118
 
118
119
  if (block.kind === "tool_call") {
@@ -166,3 +166,80 @@ export function scaffoldSettings(): void {
166
166
  // best-effort; never crash extension load
167
167
  }
168
168
  }
169
+ /** Live-resolved config path. Unlike the `SETTINGS_PATH` const (frozen at import
170
+ * time), this reflects the current `OMP_VCC_CONFIG_PATH` / `PI_VCC_CONFIG_PATH` env. */
171
+ export function getSettingsPath(): string {
172
+ return settingsPath();
173
+ }
174
+
175
+ export type VccSettingSource = "file" | "overlay" | "default";
176
+
177
+ export interface VccConfigView {
178
+ /** Live primary path — where a config file WOULD live. */
179
+ path: string;
180
+ /** Actual file parsed (primary, XDG/legacy fallback, or null when none). */
181
+ readPath: string | null;
182
+ /** True when a candidate file exists (even if unparseable). */
183
+ filePresent: boolean;
184
+ /** True when a candidate file parsed as a JSON object. */
185
+ fileValid: boolean;
186
+ /** Effective values — same merge as `loadSettings` (defaults ← file ← ctx overlay). */
187
+ values: PiVccSettings;
188
+ /** Per-key provenance. Presence check is `key in parsed`, so a file key that
189
+ * happens to equal the default still counts as `file`. */
190
+ sources: Record<keyof PiVccSettings, VccSettingSource>;
191
+ }
192
+
193
+ /**
194
+ * `loadSettings` plus provenance for `/vcc-config`. Read-only: never creates or
195
+ * repairs files (`scaffoldSettings` owns that). Merge order mirrors `loadSettings`
196
+ * exactly — defaults, then the first readable candidate (primary, else the same
197
+ * `fallbackReadPath()` order), then the identical ctx overlay chain.
198
+ */
199
+ export function loadSettingsWithSources(ctx?: unknown): VccConfigView {
200
+ const path = settingsPath();
201
+ const primaryParsed = readJson(path);
202
+ let parsed: Record<string, unknown> | null = primaryParsed;
203
+ let readPath: string | null = primaryParsed ? path : null;
204
+ if (!parsed) {
205
+ const fb = fallbackReadPath();
206
+ if (fb && fb !== path) {
207
+ const fbParsed = readJson(fb);
208
+ if (fbParsed) {
209
+ parsed = fbParsed;
210
+ readPath = fb;
211
+ }
212
+ }
213
+ }
214
+ const candidateExists = fallbackReadPath() !== null;
215
+ const valid = !!parsed && typeof parsed === "object";
216
+ const values: PiVccSettings =
217
+ valid && parsed
218
+ ? { ...DEFAULT_SETTINGS, ...(parsed as Partial<PiVccSettings>) }
219
+ : { ...DEFAULT_SETTINGS };
220
+ const sources = {} as Record<keyof PiVccSettings, VccSettingSource>;
221
+ for (const k of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
222
+ sources[k] = valid && parsed && k in parsed ? "file" : "default";
223
+ }
224
+ if (ctx) {
225
+ const tryGet = (key: string): unknown => {
226
+ try {
227
+ const c = ctx as any;
228
+ if (!c) return undefined;
229
+ if (c.settings?.get) return c.settings.get(key);
230
+ if (c.config?.get) return c.config.get(key);
231
+ if (c.settings && typeof c.settings === "object" && key in c.settings) return c.settings[key];
232
+ if (c.config && typeof c.config === "object" && key in c.config) return c.config[key];
233
+ } catch {}
234
+ return undefined;
235
+ };
236
+ for (const k of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
237
+ const v = tryGet(`plugins.@zhulinchng/omp-vcc.${k}`) ?? tryGet(`plugins.omp-vcc.${k}`) ?? tryGet(`omp-vcc.${k}`) ?? tryGet(k);
238
+ if (v !== undefined) {
239
+ (values as any)[k] = v;
240
+ sources[k] = "overlay";
241
+ }
242
+ }
243
+ }
244
+ return { path, readPath, filePresent: candidateExists, fileValid: valid, values, sources };
245
+ }
@@ -6,6 +6,7 @@ import { filterNoise } from "./filter-noise";
6
6
  import { buildSections } from "./build-sections";
7
7
  import { formatSummary, capBrief, BRIEF_MAX_LINES, RECALL_NOTE, wrapLongLines } from "./format";
8
8
  import { selectRankedBriefBlocks, type BriefRankingOptions } from "./rank";
9
+ import { renderFileCategoryLines } from "../extract/files";
9
10
 
10
11
  export interface CompileInput {
11
12
  messages: Message[];
@@ -24,86 +25,155 @@ const SEPARATOR = "\n\n---\n\n";
24
25
  /** Extract a named section from summary text */
25
26
  const sectionOf = (text: string, header: string): string => {
26
27
  const tag = `[${header}]`;
27
- const start = text.indexOf(tag);
28
- if (start < 0) return "";
29
- const after = text.slice(start);
30
- // Find next section header or separator
28
+ // Scope to the headers region (before the first separator) and match the
29
+ // tag only at a line start: value lines may contain literal "[Tags]"
30
+ // mid-line and the brief transcript may contain ghost tags at line starts.
31
+ const head = text.split(SEPARATOR)[0];
32
+ const startMatch = new RegExp(`(^|\\n)${escapeRegExp(tag)}`).exec(head);
33
+ if (!startMatch) return "";
34
+ const start = startMatch.index + startMatch[1].length;
35
+ const after = head.slice(start);
36
+ // Find next section header (line-anchored) as the end boundary.
31
37
  const nextSection = HEADER_NAMES
32
38
  .filter((h) => h !== header)
33
- .map((h) => after.indexOf(`[${h}]`))
34
- .filter((n) => n > 0);
35
- const nextSep = after.indexOf("\n\n---\n\n");
36
- const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
37
- const end = candidates[0];
39
+ .map((h) => new RegExp(`\\n${escapeRegExp(`[${h}]`)}`).exec(after))
40
+ .filter((m) => m && m.index > 0)
41
+ .map((m) => m.index);
42
+ const end = nextSection.sort((a, b) => a - b)[0];
38
43
  return (end ? after.slice(0, end) : after).trim();
39
44
  };
40
45
 
41
46
  /** Extract the brief transcript part (everything after ---) */
42
47
  const briefOf = (text: string): string => {
43
48
  const idx = text.indexOf(SEPARATOR);
44
- if (idx < 0) return "";
45
- return text.slice(idx + SEPARATOR.length).trim();
49
+ if (idx >= 0) return text.slice(idx + SEPARATOR.length).trim();
50
+ // No separator: a stripped headerless summary is all brief, while a lone
51
+ // headers block (starts with a known tag) has no brief.
52
+ const tagPattern = new RegExp(`^\\[(${HEADER_NAMES.map(escapeRegExp).join("|")})\\]`);
53
+ if (tagPattern.test(text.trimStart())) return "";
54
+ return text.trim();
55
+ };
56
+
57
+ /** Rejoin wrapLongLines continuations (indented followers) before line-level
58
+ * merging, so wrapped value lines survive whole. Backslash-marked hard breaks
59
+ * rejoin without a space, normal breaks with one. */
60
+ const joinContinuations = (text: string): string[] => {
61
+ const out: string[] = [];
62
+ for (const line of text.split("\n")) {
63
+ if (out.length > 0 && /^\s+\S/.test(line)) {
64
+ const cont = line.trim();
65
+ const prev = out[out.length - 1];
66
+ out[out.length - 1] = prev.endsWith("\\") ? prev.slice(0, -1) + cont : `${prev} ${cont}`;
67
+ } else {
68
+ out.push(line);
69
+ }
70
+ }
71
+ return out;
46
72
  };
47
73
 
48
74
  /** Merge a header section */
49
75
  const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
50
76
  // Outstanding Context is volatile -- always use fresh only
51
77
  if (header === "Outstanding Context") return fresh;
52
- if (!prev) return fresh;
53
- if (!fresh) return prev;
54
78
 
55
- // Files And Changes: merge by category (Modified/Created/Read), dedup paths
79
+ // Files And Changes: merge by category (Modified/Created/Read), dedup paths.
80
+ // Always rendered through mergeFileLines (even one-sided) so stale bare
81
+ // "(+N more)" counts from legacy summaries are dropped and the display
82
+ // form stays canonical across cycles.
56
83
  if (header === "Files And Changes") {
84
+ if (!prev) return fresh;
57
85
  return mergeFileLines(prev, fresh);
58
86
  }
59
87
 
88
+ if (!prev) return fresh;
89
+ if (!fresh) return prev;
90
+
60
91
  // Session Goal, User Preferences: line-level dedup, cap
61
92
  const isClean = (l: string) => l.startsWith("- ") && !l.includes("<skill") && !l.includes("</skill");
62
- const prevLines = prev.split("\n").filter(isClean);
63
- const freshLines = fresh.split("\n").filter(isClean);
93
+ const prevLines = joinContinuations(prev).filter(isClean);
94
+ const freshLines = joinContinuations(fresh).filter(isClean);
64
95
  const combined = [...new Set([...prevLines, ...freshLines])];
65
96
  const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : 15;
66
97
  const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
67
98
  if (capped.length === 0) return "";
68
99
  return `[${header}]\n${capped.join("\n")}`;
69
100
  };
70
-
71
- /** Merge Files And Changes by category, dedup paths across compactions */
72
101
  const mergeFileLines = (prev: string, fresh: string): string => {
73
102
  const categories = ["Modified", "Created", "Read"] as const;
74
103
  const merged: Record<string, Set<string>> = {};
75
104
  for (const cat of categories) merged[cat] = new Set();
76
105
 
77
- // Parse "- Modified: a, b, c (+N more)" lines from both prev and fresh
106
+ // Parse three line forms from both prev and fresh:
107
+ // "- Modified: a, b" (bare; legacy relative kept verbatim)
108
+ // "- Modified (in /prefix/): a, b" (prefix-collapsed display)
109
+ // "- Modified (+2 more under /d/): b1, b2" (grouped overflow; dir may be "")
110
+ // formatSummary wraps long lines at 120 cols with an indented continuation:
111
+ // normal breaks rejoin with a space, backslash-marked mid-token breaks
112
+ // rejoin without one. A bare "(+N more)" line/suffix carries no names and
113
+ // is ignored (legacy counts were lossy by design).
114
+ const addPaths = (cat: string, prefix: string, rest: string) => {
115
+ const clean = rest.replace(/\s*\(\+\d+ more\)\s*$/, "");
116
+ for (const p of clean.split(",")) {
117
+ const trimmed = p.trim();
118
+ if (trimmed) merged[cat].add(prefix + trimmed);
119
+ }
120
+ };
121
+ const parseHead = (cat: string, tail: string): { prefix: string; rest: string } | null => {
122
+ if (tail.startsWith(": ")) return { prefix: "", rest: tail.slice(2) };
123
+ if (tail.startsWith(" (in ")) {
124
+ const end = tail.indexOf("): ");
125
+ if (end > 0) return { prefix: tail.slice(5, end), rest: tail.slice(end + 3) };
126
+ return null;
127
+ }
128
+ const grouped = /^ \(\+(\d+) more( under (.+?))?\): (.*)$/.exec(tail);
129
+ if (grouped) {
130
+ const dir = grouped[3] ?? "";
131
+ return { prefix: dir && !dir.endsWith("/") ? `${dir}/` : dir, rest: grouped[4] };
132
+ }
133
+ return null;
134
+ };
78
135
  for (const text of [prev, fresh]) {
136
+ let current: { cat: string; prefix: string; rest: string } | null = null;
137
+ const flush = () => {
138
+ if (current) {
139
+ addPaths(current.cat, current.prefix, current.rest);
140
+ current = null;
141
+ }
142
+ };
79
143
  for (const line of text.split("\n")) {
80
- for (const cat of categories) {
81
- const prefix = `- ${cat}: `;
82
- if (!line.startsWith(prefix)) continue;
83
- let rest = line.slice(prefix.length);
84
- // Strip "(+N more)" suffix
85
- rest = rest.replace(/\s*\(\+\d+ more\)\s*$/, "");
86
- for (const p of rest.split(",")) {
87
- const trimmed = p.trim();
88
- if (trimmed) merged[cat].add(trimmed);
89
- }
144
+ const head = /^- (Modified|Created|Read)(.*)$/.exec(line);
145
+ // "- Modified" without one of the three tails is a bare count or
146
+ // malformed: flush any pending accumulation and ignore.
147
+ if (head && (categories as readonly string[]).includes(head[1])) {
148
+ flush();
149
+ const parsed = parseHead(head[1], head[2]);
150
+ if (parsed) current = { cat: head[1], prefix: parsed.prefix, rest: parsed.rest };
151
+ continue;
152
+ }
153
+ if (current && /^\s+\S/.test(line)) {
154
+ const cont = line.trim();
155
+ if (current.rest.endsWith("\\")) current.rest = current.rest.slice(0, -1) + cont;
156
+ else current.rest += " " + cont;
157
+ } else {
158
+ flush();
90
159
  }
91
160
  }
161
+ flush();
92
162
  }
93
163
 
94
164
  // Dedup: if already in Modified, drop from Created (file existed before)
95
165
  for (const p of merged.Modified) merged.Created.delete(p);
96
166
 
97
- const cap = (set: Set<string>, limit: number) => {
98
- const arr = [...set];
99
- if (arr.length <= limit) return arr.join(", ");
100
- return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
101
- };
102
-
167
+ // Display shares the fresh path's renderer (flat + grouped overflow +
168
+ // honest bare count). Prefix derivation is display-only: the merged set
169
+ // always holds full paths, so form flips never corrupt.
103
170
  const lines: string[] = [];
104
- if (merged.Modified.size > 0) lines.push(`- Modified: ${cap(merged.Modified, 10)}`);
105
- if (merged.Created.size > 0) lines.push(`- Created: ${cap(merged.Created, 10)}`);
106
- if (merged.Read.size > 0) lines.push(`- Read: ${cap(merged.Read, 10)}`);
171
+ for (const cat of categories) {
172
+ if (merged[cat].size === 0) continue;
173
+ for (const line of renderFileCategoryLines(cat, [...merged[cat]])) {
174
+ lines.push(`- ${line}`);
175
+ }
176
+ }
107
177
  if (lines.length === 0) return "";
108
178
  return `[Files And Changes]\n${lines.join("\n")}`;
109
179
  };
@@ -183,7 +253,7 @@ const compileWithBriefBlocks = (input: CompileInput, options: CompileWithBriefBl
183
253
  : undefined;
184
254
  const merged = prev ? mergePrevious(prev, fresh, { preserveFreshBrief: options.preserveFreshBriefOnMerge }) : fresh;
185
255
  if (!merged) return "";
186
- return wrapLongLines(merged + SEPARATOR + RECALL_NOTE);
256
+ return wrapLongLines(merged) + SEPARATOR + RECALL_NOTE;
187
257
  };
188
258
 
189
259
  export const compile = (input: CompileInput): string =>
@@ -199,10 +269,15 @@ export const compileRanked = (input: RankedCompileInput): string =>
199
269
  preserveFreshBriefOnMerge: true,
200
270
  });
201
271
 
272
+ const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
273
+
274
+ const RECALL_NOTE_PATTERN = new RegExp(RECALL_NOTE.split(" ").map(escapeRegExp).join("\\s+"));
275
+
202
276
  const stripRecallNote = (text: string): string => {
203
277
  // Remove trailing RECALL_NOTE (and any separators surrounding it) if present.
204
- // Handles both current format (---\n\nNOTE) and bare trailing NOTE.
205
- const idx = text.lastIndexOf(RECALL_NOTE);
206
- if (idx < 0) return text;
207
- return text.slice(0, idx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
278
+ // Whitespace-insensitive: matches the current single-line format, a bare
279
+ // trailing note, and legacy copies wrapped mid-sentence by wrapLongLines.
280
+ const match = RECALL_NOTE_PATTERN.exec(text);
281
+ if (!match || match.index < 0) return text;
282
+ return text.slice(0, match.index).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
208
283
  };
@@ -19,6 +19,7 @@ const clamp = (value: number, min: number, max: number): number =>
19
19
  export const calibrateCharsPerToken = (
20
20
  sourceChars: number,
21
21
  sourceTokens: number | undefined,
22
+ sampleText?: string,
22
23
  ): TokenEstimateCalibration => {
23
24
  if (!sourceTokens || sourceTokens <= 0 || sourceChars <= 0) {
24
25
  return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
@@ -29,6 +30,24 @@ export const calibrateCharsPerToken = (
29
30
  return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
30
31
  }
31
32
 
33
+ // Slice/tokens mismatch guards (see hook calibration site): sourceChars
34
+ // covers the summarized slice while sourceTokens often counts the full
35
+ // context (plus per-message overhead), so raw is systematically <= truth
36
+ // for Latin text — trusting it inflates every token estimate and the
37
+ // pipeline over-trims. Conversely a high raw on CJK text means the token
38
+ // count under-describes the slice. When the ratio contradicts the
39
+ // content-class prior, fall back to that prior (reported as heuristic).
40
+ if (sampleText) {
41
+ const cjkChars = (sampleText.match(/[\u2E80-\u9FFF\uAC00-\uD7FF\u3000-\u303F]/g) ?? []).length;
42
+ const cjk = sampleText.length > 0 && cjkChars / sampleText.length >= 0.2;
43
+ if (!cjk && rawCharsPerToken < 2.5) {
44
+ return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN, sourceChars, sourceTokens, rawCharsPerToken };
45
+ }
46
+ if (cjk && rawCharsPerToken > 3) {
47
+ return { mode: "heuristic", charsPerToken: MIN_CHARS_PER_TOKEN, sourceChars, sourceTokens, rawCharsPerToken };
48
+ }
49
+ }
50
+
32
51
  return {
33
52
  mode: "calibrated",
34
53
  charsPerToken: clamp(rawCharsPerToken, MIN_CHARS_PER_TOKEN, MAX_CHARS_PER_TOKEN),
@@ -130,6 +149,21 @@ export const collectUsageStats = (messages: any[]): UsageStats => {
130
149
  let outputChars = 0;
131
150
  let minTs = Infinity;
132
151
  let maxTs = -Infinity;
152
+ // Bounded content sample for the calibration slice/tokens guards
153
+ // (classification only needs a fraction of the text).
154
+ let sample = "";
155
+ const takeSample = (text: unknown) => {
156
+ if (sample.length >= 8000 || typeof text !== "string" || !text) return;
157
+ sample += (sample ? "\n" : "") + text.slice(0, 8000 - sample.length);
158
+ };
159
+ const sampleParts = (content: unknown) => {
160
+ if (typeof content === "string") takeSample(content);
161
+ else if (Array.isArray(content)) {
162
+ for (const part of content) {
163
+ if (part?.type === "text" && typeof part.text === "string") takeSample(part.text);
164
+ }
165
+ }
166
+ };
133
167
  const usageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
134
168
  let sawUsage = false;
135
169
  for (const m of messages ?? []) {
@@ -149,19 +183,23 @@ export const collectUsageStats = (messages: any[]): UsageStats => {
149
183
  }
150
184
  if (role === "assistant") {
151
185
  outputChars += estimateMessageContentChars(m?.content);
186
+ sampleParts(m?.content);
152
187
  if (Array.isArray(m?.content)) {
153
188
  for (const part of m.content) if (part?.type === "toolCall") toolCallCount++;
154
189
  }
155
190
  } else if (role === "bashExecution") {
156
191
  inputChars += (typeof m?.command === "string" ? m.command.length : 0) + 1
157
192
  + (typeof m?.output === "string" ? m.output.length : 0);
193
+ takeSample(m?.command);
194
+ takeSample(m?.output);
158
195
  } else {
159
196
  inputChars += estimateMessageContentChars(m?.content);
197
+ sampleParts(m?.content);
160
198
  }
161
199
  }
162
200
  const totalChars = inputChars + outputChars;
163
201
  const sourceTokens = sawUsage ? usageTotals.input + usageTotals.output : undefined;
164
- const calibration = calibrateCharsPerToken(totalChars, sourceTokens);
202
+ const calibration = calibrateCharsPerToken(totalChars, sourceTokens, sample || undefined);
165
203
  return {
166
204
  messageCount: messages?.length ?? 0,
167
205
  byRole,
@@ -31,8 +31,10 @@ const matches = (tools: Set<string>, name: string): boolean => tools.has(name.to
31
31
  /**
32
32
  * Find the longest common directory prefix among absolute paths.
33
33
  * Returns "" if fewer than 2 absolute paths or no meaningful common prefix.
34
+ * Shared with core/summarize.ts, which re-derives one prefix per merge so
35
+ * prev-cycle and fresh paths keep a consistent display form.
34
36
  */
35
- const longestCommonDirPrefix = (paths: string[]): string => {
37
+ export const longestCommonDirPrefix = (paths: string[]): string => {
36
38
  const abs = paths.filter((p) => p.startsWith("/"));
37
39
  if (abs.length < 2) return "";
38
40
  const split = abs.map((p) => p.split("/"));
@@ -47,15 +49,6 @@ const longestCommonDirPrefix = (paths: string[]): string => {
47
49
  return split[0].slice(0, i).join("/") + "/";
48
50
  };
49
51
 
50
- const trimPaths = (set: Set<string>, prefix: string): Set<string> => {
51
- if (!prefix) return set;
52
- const out = new Set<string>();
53
- for (const p of set) {
54
- out.add(p.startsWith(prefix) ? p.slice(prefix.length) : p);
55
- }
56
- return out;
57
- };
58
-
59
52
  export const extractFiles = (
60
53
  blocks: NormalizedBlock[],
61
54
  fileOps?: FileOps,
@@ -76,13 +69,47 @@ export const extractFiles = (
76
69
  if (matches(FILE_CREATE_TOOLS, b.name)) act.created.add(p);
77
70
  }
78
71
 
79
- const all = [...act.read, ...act.modified, ...act.created];
80
- const prefix = longestCommonDirPrefix(all);
81
- if (prefix) {
82
- act.read = trimPaths(act.read, prefix);
83
- act.modified = trimPaths(act.modified, prefix);
84
- act.created = trimPaths(act.created, prefix);
85
- }
86
-
72
+ // Full paths are carried verbatim: per-cycle prefix trimming made prev-cycle
73
+ // and fresh paths unresolvable when mixed. Display collapse happens in
74
+ // renderFileCategoryLines below, re-derived every render for consistency.
87
75
  return act;
88
76
  };
77
+
78
+ /** Exact file paths shown flat per category; further named paths spill into
79
+ * per-directory overflow groups up to FILE_CATEGORY_TOTAL_CAP. */
80
+ export const FILE_CATEGORY_DISPLAY_CAP = 20;
81
+ /** Max NAMED file paths per category (flat + grouped overflow). Past this,
82
+ * excess degrades to a bare honest (+N more) count. */
83
+ export const FILE_CATEGORY_TOTAL_CAP = 100;
84
+
85
+ /**
86
+ * Render one Files-And-Changes category (lines WITHOUT the "- " bullet;
87
+ * callers add it or route through section()). Shared by the fresh path
88
+ * (build-sections) and the merge path (mergeFileLines) so both carry the
89
+ * same lossless overflow forms:
90
+ * `Modified: a, b` / `Modified (in /prefix/): a, b` (flat, prefix-collapsed)
91
+ * `Modified (+2 more under /d/): b1, b2` (grouped overflow)
92
+ * `Modified (+5 more)` (honest bare count)
93
+ */
94
+ export const renderFileCategoryLines = (cat: string, paths: string[]): string[] => {
95
+ if (paths.length === 0) return [];
96
+ const prefix = longestCommonDirPrefix(paths);
97
+ const strip = (p: string) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p);
98
+ const flat = paths.slice(0, FILE_CATEGORY_DISPLAY_CAP).map(strip);
99
+ const lines = [prefix ? `${cat} (in ${prefix}): ${flat.join(", ")}` : `${cat}: ${flat.join(", ")}`];
100
+ const overflow = paths.slice(FILE_CATEGORY_DISPLAY_CAP, FILE_CATEGORY_TOTAL_CAP);
101
+ const groups = new Map<string, string[]>();
102
+ for (const p of overflow) {
103
+ const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/") + 1) : "";
104
+ const base = dir ? p.slice(dir.length) : p;
105
+ const list = groups.get(dir) ?? [];
106
+ list.push(base);
107
+ groups.set(dir, list);
108
+ }
109
+ for (const [dir, bases] of groups) {
110
+ lines.push(`${cat} (+${bases.length} more${dir ? ` under ${dir}` : ""}): ${bases.join(", ")}`);
111
+ }
112
+ const dropped = paths.length - FILE_CATEGORY_DISPLAY_CAP - overflow.length;
113
+ if (dropped > 0) lines.push(`${cat} (+${dropped} more)`);
114
+ return lines;
115
+ };
@@ -4,7 +4,7 @@ import { createRequire } from "node:module";
4
4
  import { writeFileSync } from "fs";
5
5
  import { compileRanked } from "./core/summarize";
6
6
  import { buildPiVccCustomInstructions, parseKeepAndPrompt, PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
7
- import { loadSettings, type PiVccSettings } from "./core/settings";
7
+ import { loadSettings, loadSettingsWithSources, DEFAULT_SETTINGS, type PiVccSettings, type VccConfigView } from "./core/settings";
8
8
  import { calibrateCharsPerToken, estimateMessageContentChars, estimateMessageContentTokens, estimateTokensFromChars, collectUsageStats } from "./core/token-estimate";
9
9
  import type { PiVccCompactionDetails } from "./details";
10
10
  import type { CompactionReason } from "./types";
@@ -432,7 +432,7 @@ export type OwnCutResult =
432
432
  }
433
433
  | { ok: false; reason: OwnCutCancelReason };
434
434
 
435
- const collectLiveMessages = (branchEntries: any[]): EntryWithMessage[] => {
435
+ export const collectLiveMessages = (branchEntries: any[]): EntryWithMessage[] => {
436
436
  // Find the last compaction entry and its firstKeptEntryId
437
437
  let lastCompactionIdx = -1;
438
438
  let lastKeptId: string | undefined;
@@ -499,7 +499,7 @@ const collectLiveMessages = (branchEntries: any[]): EntryWithMessage[] => {
499
499
  return liveMessages;
500
500
  };
501
501
 
502
- export function buildOwnCut(branchEntries: any[], keepUserTurns = 1): OwnCutResult {
502
+ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1, explicitKeep = false): OwnCutResult {
503
503
  const normalizedKeepUserTurns = normalizeKeepUserTurns(keepUserTurns);
504
504
  const liveMessages = collectLiveMessages(branchEntries);
505
505
 
@@ -528,13 +528,29 @@ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1): OwnCutResu
528
528
  const cutIdx = targetUserIdx >= 0 ? userIndices[targetUserIdx] : -1;
529
529
 
530
530
  if (cutIdx <= 0) {
531
- // Keep request cannot form a safe boundary (single user prompt, no user prompt,
532
- // or keep larger than available user turns), so compact EVERYTHING and keep no tail.
531
+ // Explicit keep covering every user turn: keep the whole tail instead of
532
+ // compacting it away. Only the prefix before the first user turn (when
533
+ // any) is summarized. Default-path cuts and no-user-message sessions keep
534
+ // the old compact-all fallback so auto-compaction still makes progress
535
+ // (single-prompt + autonomous tail) and explicit compacts in autonomous
536
+ // sessions still do something.
537
+ if (explicitKeep && userIndices.length > 0) {
538
+ const firstUserIdx = userIndices[0];
539
+ return {
540
+ ok: true,
541
+ messages: liveMessages.slice(0, firstUserIdx).map((e) => e.message),
542
+ firstKeptEntryId: liveMessages[firstUserIdx].entry.id,
543
+ compactAll: false,
544
+ keptUserTurns: userIndices.length,
545
+ totalUserTurns: userIndices.length,
546
+ requestedKeepUserTurns: normalizedKeepUserTurns,
547
+ keepFallbackToCompactAll: false,
548
+ };
549
+ }
533
550
  // firstKeptEntryId="" is a sentinel: pi-core's buildSessionContext won't match it
534
551
  // (so 0 kept from pre-compaction), and next buildOwnCut triggers orphan recovery.
535
552
  return compactAll(true);
536
553
  }
537
-
538
554
  return {
539
555
  ok: true,
540
556
  messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
@@ -652,12 +668,18 @@ export interface ResolveSmartKeepResult {
652
668
  */
653
669
  const tailTokensForKeep = (branchEntries: any[], keepUserTurns: number, charsPerToken?: number): number | null => {
654
670
  const cut = buildOwnCut(branchEntries, keepUserTurns);
655
- if (!cut.ok || cut.compactAll) return null;
656
- const idx = branchEntries.findIndex((e: any) => e.id === cut.firstKeptEntryId);
657
- if (idx < 0) return null;
658
- const kept = branchEntries.slice(idx).filter((e: any) => e.type === "message");
659
- const chars = kept.reduce(
660
- (sum: number, e: any) => sum + estimateMessageContentChars(e.message?.content),
671
+ // Null when keep would trigger compact-all, cancel, or summarize nothing
672
+ // (keep-all cut with an empty prefix): the resolver stops growing instead
673
+ // of selecting a value that discards the tail or compacts nothing new.
674
+ if (!cut.ok || cut.compactAll || cut.messages.length === 0) return null;
675
+ // Measure over the live window (message + custom_message/branch_summary via
676
+ // toLiveMessage), not branchEntries filtered to type === "message": custom
677
+ // tails otherwise undercount and smart-keep over-grows (under-compaction).
678
+ const live = collectLiveMessages(branchEntries);
679
+ const keptIdx = live.findIndex((e) => e.entry.id === cut.firstKeptEntryId);
680
+ if (keptIdx < 0) return null;
681
+ const chars = live.slice(keptIdx).reduce(
682
+ (sum: number, e) => sum + estimateMessageContentChars(e.message?.content),
661
683
  0,
662
684
  );
663
685
  return estimateTokensFromChars(chars, charsPerToken);
@@ -755,9 +777,20 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
755
777
  const calibrationSummaryChars = typeof preparation.previousSummary === "string"
756
778
  ? preparation.previousSummary.length
757
779
  : 0;
780
+ // Content sample for the slice/tokens mismatch guards: slice text when the
781
+ // calibration cut has any, else the previous summary. Bounded (first 50
782
+ // string contents, 8k chars) — classification only needs a fraction.
783
+ const calibrationSample = calibrationCut.ok
784
+ ? calibrationCut.messages
785
+ .slice(0, 50)
786
+ .map((message: any) => (typeof message.content === "string" ? message.content : ""))
787
+ .join("\n")
788
+ .slice(0, 8000)
789
+ : "";
758
790
  const tokenEstimate = calibrateCharsPerToken(
759
791
  calibrationMessageChars + calibrationSummaryChars,
760
792
  preparation.tokensBefore,
793
+ calibrationSample || (typeof preparation.previousSummary === "string" ? preparation.previousSummary.slice(0, 8000) : undefined),
761
794
  );
762
795
 
763
796
  // Smart keep-tail: boost default keep when the tail is small.
@@ -769,7 +802,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
769
802
  smartKeepTail: settings.smartKeepTail,
770
803
  charsPerToken: tokenEstimate.charsPerToken,
771
804
  });
772
- let ownCut = buildOwnCut(branchEntries as any[], smartKeep.keepUserTurns);
805
+ let ownCut = buildOwnCut(branchEntries as any[], smartKeep.keepUserTurns, keepUserTurnsExplicit);
773
806
  // Default path only: rescue autonomous / oversized-tail sessions with a
774
807
  // token-budget cut. Explicit keep:N is respected absolutely (no-op here).
775
808
  if (ownCut.ok && !keepUserTurnsExplicit) {
@@ -921,6 +954,17 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
921
954
  },
922
955
  });
923
956
 
957
+ // Keep-all cut with an empty prefix and no previous summary yields nothing
958
+ // new to summarize. Never hand the host an empty summary — cancel and keep
959
+ // the session intact. (A non-empty prefix that compiles to "" falls through
960
+ // with the pre-existing behavior; custom-only prefixes carry no sections.)
961
+ if (!summary && agentMessages.length === 0) {
962
+ try {
963
+ ctx?.ui?.notify?.("omp-vcc: Nothing new to compact (keep covers all turns)", "info");
964
+ } catch {}
965
+ return { cancel: true };
966
+ }
967
+
924
968
  const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
925
969
  const summaryChars = summary.length;
926
970
  const summaryTokensEst = estimateTokensFromChars(summaryChars, tokenEstimate.charsPerToken);
@@ -1332,4 +1376,31 @@ export const registerVccStatsCommand = (pi: any) => {
1332
1376
  try { ctx?.ui?.notify?.(`vcc_stats: ${history.length} compaction(s)`, "info"); } catch {}
1333
1377
  };
1334
1378
  pi.registerCommand("vcc-stats", { description: "Show omp-vcc compaction savings (last + history)", handler });
1379
+ };
1380
+ // ── /vcc-config command — show effective configuration with per-key source ──
1381
+ export const formatVccConfigCard = (view: VccConfigView): string => {
1382
+ const header = `**omp-vcc config** (\`${view.path}\`)`;
1383
+ const status = !view.filePresent
1384
+ ? "No config file found — showing defaults."
1385
+ : !view.fileValid
1386
+ ? "Config file unparseable — showing defaults."
1387
+ : view.readPath === view.path
1388
+ ? `Source: file ${view.readPath}`
1389
+ : `Source: fallback file ${view.readPath}`;
1390
+ const lines = (Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]).map(
1391
+ (k) => `- ${k}: ${view.values[k] ? "on" : "off"} (${view.sources[k] === "overlay" ? "host overlay" : view.sources[k]})`,
1392
+ );
1393
+ return [header, status, ...lines].join("\n");
1394
+ };
1395
+
1396
+ export const registerVccConfigCommand = (pi: any) => {
1397
+ const handler = async (_args: string, ctx: any) => {
1398
+ // args deliberately ignored — always show the effective config
1399
+ const view = loadSettingsWithSources(ctx);
1400
+ const output = formatVccConfigCard(view);
1401
+ const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
1402
+ try { piAny.sendMessage?.({ customType: "vcc-config", content: output, display: true }, { triggerTurn: false }); } catch {}
1403
+ try { ctx?.ui?.notify?.(`vcc_config: ${Object.keys(view.values).length} keys from ${view.readPath ?? "defaults"}`, "info"); } catch {}
1404
+ };
1405
+ pi.registerCommand("vcc-config", { description: "Show omp-vcc effective configuration with per-key source", handler });
1335
1406
  };
@@ -12,6 +12,7 @@ export interface FileOps {
12
12
  export type NormalizedBlock =
13
13
  | { kind: "user"; text: string; sourceIndex?: number }
14
14
  | { kind: "assistant"; text: string; sourceIndex?: number }
15
+ | { kind: "custom"; text: string; sourceIndex?: number }
15
16
  | { kind: "thinking"; text: string; sourceIndex?: number }
16
17
  | { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
17
18
  | { kind: "tool_result"; name: string; text: string; sourceIndex?: number }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-vcc",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "Algorithmic VCC compaction for omp - fast lossless no-LLM",
6
6
  "author": "Zhu Lin <zhulin@czl.my>",
package/scripts/smoke.ts CHANGED
@@ -75,6 +75,10 @@ try {
75
75
  "vcc-stats command registered",
76
76
  commands.some((c) => c.name === "vcc-stats"),
77
77
  );
78
+ check(
79
+ "vcc-config command registered",
80
+ commands.some((c) => c.name === "vcc-config"),
81
+ );
78
82
  check(
79
83
  "no omp-vcc-stats duplicate",
80
84
  !commands.some((c) => c.name === "omp-vcc-stats"),
@@ -49,6 +49,7 @@ Use `vcc_recall` to search for prior work … Do not redo work already completed
49
49
  | Task | How | Notes |
50
50
  |---|---|---|
51
51
  | **Check savings** | `/vcc-stats` · `vcc_stats({history:true})` | Last + history table (50-capped, no `omp-vcc-stats` alias). `/omp-vcc` toast is single line only; detailed savings via `/vcc-stats`. Use to confirm headroom before long edits. |
52
+ | **View config** | `/vcc-config` | Effective config with per-key source (`file` / `host overlay` / `default`) plus resolved file path. Args ignored. Use to confirm a `/settings` toggle took effect. |
52
53
  | **Recall search** | `/vcc-recall <query> [scope:all] [page:2]` · alias `/pi-vcc-recall` · tool `vcc_recall({query, scope, mode, page, expand})` | 5 hits/page, up to 50 total. See cookbook below. |
53
54
  | **Stats tool** | `vcc_stats({history?: boolean})` | Same as `/vcc-stats`. `history:true` = full 50-row table. |
54
55