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 +21 -12
- package/extensions/main.ts +3 -1
- package/extensions/vcc-core/core/brief.ts +11 -0
- package/extensions/vcc-core/core/build-sections.ts +6 -12
- package/extensions/vcc-core/core/format.ts +12 -5
- package/extensions/vcc-core/core/normalize.ts +9 -0
- package/extensions/vcc-core/core/rank.ts +1 -0
- package/extensions/vcc-core/core/settings.ts +77 -0
- package/extensions/vcc-core/core/summarize.ts +118 -43
- package/extensions/vcc-core/core/token-estimate.ts +39 -1
- package/extensions/vcc-core/extract/files.ts +45 -18
- package/extensions/vcc-core/hook.ts +84 -13
- package/extensions/vcc-core/types.ts +1 -0
- package/package.json +1 -1
- package/scripts/smoke.ts +4 -0
- package/skills/omp-vcc/SKILL.md +1 -0
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
|
-
|
|
|
25
|
-
|
|
|
26
|
-
| `/vcc
|
|
27
|
-
| `/pi-vcc
|
|
28
|
-
| `/vcc-
|
|
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
|
-
|
|
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 #
|
|
86
|
-
bun test tests/e2e --timeout 120000 #
|
|
87
|
-
bun run smoke #
|
|
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
|
|
package/extensions/main.ts
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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) =>
|
|
34
|
-
.filter((
|
|
35
|
-
|
|
36
|
-
const
|
|
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
|
|
45
|
-
|
|
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
|
|
63
|
-
const freshLines = fresh
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
532
|
-
//
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
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"),
|
package/skills/omp-vcc/SKILL.md
CHANGED
|
@@ -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
|
|