omp-vcc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/commands/omp-vcc.md +19 -0
  4. package/commands/vcc-recall.md +21 -0
  5. package/extensions/main.ts +319 -0
  6. package/extensions/vcc-core/commands/vcc-recall.ts +2 -0
  7. package/extensions/vcc-core/core/brief.ts +404 -0
  8. package/extensions/vcc-core/core/build-sections.ts +77 -0
  9. package/extensions/vcc-core/core/compact-args.ts +46 -0
  10. package/extensions/vcc-core/core/content.ts +157 -0
  11. package/extensions/vcc-core/core/drill-down.ts +299 -0
  12. package/extensions/vcc-core/core/filter-noise.ts +42 -0
  13. package/extensions/vcc-core/core/format-recall.ts +101 -0
  14. package/extensions/vcc-core/core/format.ts +82 -0
  15. package/extensions/vcc-core/core/lineage.ts +27 -0
  16. package/extensions/vcc-core/core/load-messages.ts +44 -0
  17. package/extensions/vcc-core/core/normalize.ts +66 -0
  18. package/extensions/vcc-core/core/rank.ts +284 -0
  19. package/extensions/vcc-core/core/recall-scope.ts +31 -0
  20. package/extensions/vcc-core/core/render-entries.ts +55 -0
  21. package/extensions/vcc-core/core/report.ts +233 -0
  22. package/extensions/vcc-core/core/sanitize.ts +6 -0
  23. package/extensions/vcc-core/core/search-entries.ts +576 -0
  24. package/extensions/vcc-core/core/settings.ts +151 -0
  25. package/extensions/vcc-core/core/skill-collapse.ts +36 -0
  26. package/extensions/vcc-core/core/summarize.ts +208 -0
  27. package/extensions/vcc-core/core/token-estimate.ts +101 -0
  28. package/extensions/vcc-core/core/tool-args.ts +17 -0
  29. package/extensions/vcc-core/details.ts +12 -0
  30. package/extensions/vcc-core/extract/commits.ts +70 -0
  31. package/extensions/vcc-core/extract/files.ts +88 -0
  32. package/extensions/vcc-core/extract/goals.ts +80 -0
  33. package/extensions/vcc-core/extract/preferences.ts +56 -0
  34. package/extensions/vcc-core/hook.ts +1017 -0
  35. package/extensions/vcc-core/sections.ts +9 -0
  36. package/extensions/vcc-core/types.ts +17 -0
  37. package/package.json +104 -0
  38. package/scripts/smoke.ts +116 -0
  39. package/scripts/uninstall-reset.js +73 -0
  40. package/skills/omp-vcc/SKILL.md +35 -0
  41. package/types.d.ts +114 -0
@@ -0,0 +1,151 @@
1
+ // @ts-nocheck
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, join } from "path";
5
+
6
+ // omp-vcc: XDG-aware config path, mirrored from pi-vcc but under ~/.omp
7
+ // Priorities: $OMP_VCC_CONFIG_PATH > $PI_VCC_CONFIG_PATH (legacy) > ~/.omp/omp-vcc/config.json
8
+ // Also respects $PI_CODING_AGENT_DIR / $OMP_DIR if set (oh-my-pi base dir)
9
+ const defaultBase = process.env.OMP_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".omp");
10
+ export const SETTINGS_PATH_DEFAULT = join(defaultBase, "omp-vcc", "config.json");
11
+ const legacyPiPath = join(homedir(), ".pi", "agent", "pi-vcc-config.json");
12
+ const settingsPath = (): string =>
13
+ process.env.OMP_VCC_CONFIG_PATH ??
14
+ process.env.PI_VCC_CONFIG_PATH ??
15
+ SETTINGS_PATH_DEFAULT;
16
+ /** Backwards-compat export. Resolves at access time, not import time. */
17
+ export const SETTINGS_PATH = settingsPath();
18
+ // For migration: if omp config missing but legacy pi config exists, we read legacy but write to new
19
+ const fallbackReadPath = (): string | null => {
20
+ const primary = settingsPath();
21
+ if (existsSync(primary)) return primary;
22
+ if (existsSync(legacyPiPath)) return legacyPiPath;
23
+ return null;
24
+ };
25
+
26
+ export interface PiVccSettings {
27
+ /** Master switch for omp-vcc — when false, no compaction interception occurs */
28
+ vccEnabled: boolean;
29
+ /**
30
+ * When true (default), pi-vcc handles ALL compactions:
31
+ * - /compact (no args)
32
+ * - /compact <text>
33
+ * - auto threshold / overflow
34
+ * - /pi-vcc (always handled regardless)
35
+ *
36
+ * When false, pi-vcc only handles /pi-vcc; everything else falls back to
37
+ * pi core's default LLM-based compaction. Existing config files keep their
38
+ * stored value; the new default applies to fresh installs only.
39
+ */
40
+ overrideDefaultCompaction: boolean;
41
+ /**
42
+ * When true (default), pi-vcc boosts the default keep-tail when the current
43
+ * keep:1 tail is small enough. Specifically: if the estimated tail for keep:1
44
+ * is <= MIN_SMART_TAIL_TOKENS (5k), increase keep up to the largest N whose
45
+ * tail stays <= MAX_SMART_TAIL_TOKENS (25k). Explicit `keep:N` from the user
46
+ * is always respected and never adjusted.
47
+ */
48
+ smartKeepTail: boolean;
49
+ /**
50
+ * When true (default), pi-vcc asks the agent to continue after a successful
51
+ * automatic compaction (threshold, or overflow after the assistant already
52
+ * finished with stop). This avoids a UX cliff where the agent finishes a response,
53
+ * immediately compacts, and then stops instead of continuing the task.
54
+ * Overflow retry is still owned by pi-core via willRetry.
55
+ */
56
+ continueAfterThresholdCompact: boolean;
57
+ /** Write debug snapshot to /tmp/omp-vcc-debug.json on each compaction. */
58
+ debug: boolean;
59
+ }
60
+
61
+ export const DEFAULT_SETTINGS: PiVccSettings = {
62
+ vccEnabled: true,
63
+ overrideDefaultCompaction: true,
64
+ smartKeepTail: true,
65
+ continueAfterThresholdCompact: true,
66
+ debug: false,
67
+ };
68
+
69
+ const readJson = (path: string): Record<string, unknown> | null => {
70
+ try {
71
+ return JSON.parse(readFileSync(path, "utf-8"));
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
76
+
77
+ export function loadSettings(ctx?: unknown): PiVccSettings {
78
+ // File is source of truth, but if the host provides plugin-scoped settings
79
+ // via ctx.settings (omp manifest `omp.settings` / `pi.settings` UI surface),
80
+ // merge them on top of file so /settings toggles take effect without restart.
81
+ // Host shapes vary: ctx.settings.get(key), ctx.config.get(key), or plain map.
82
+ const tryGet = (key: string): unknown => {
83
+ try {
84
+ const c = ctx as any;
85
+ if (!c) return undefined;
86
+ if (c.settings?.get) return c.settings.get(key);
87
+ if (c.config?.get) return c.config.get(key);
88
+ if (c.settings && typeof c.settings === "object" && key in c.settings) return c.settings[key];
89
+ if (c.config && typeof c.config === "object" && key in c.config) return c.config[key];
90
+ } catch {}
91
+ return undefined;
92
+ };
93
+ const file = (() => {
94
+ const primary = settingsPath();
95
+ const parsed = readJson(primary) ?? (() => {
96
+ const fb = fallbackReadPath();
97
+ return fb && fb !== primary ? readJson(fb) : null;
98
+ })();
99
+ if (!parsed || typeof parsed !== "object") return { ...DEFAULT_SETTINGS };
100
+ return { ...DEFAULT_SETTINGS, ...(parsed as Partial<PiVccSettings>) };
101
+ })();
102
+ if (!ctx) return file;
103
+ // Overlay plugin-scoped keys if host exposes them (e.g. plugins["@zhulinchng/omp-vcc"].vccEnabled)
104
+ const overlay: Partial<PiVccSettings> = {};
105
+ for (const k of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
106
+ const v = tryGet(`plugins.@zhulinchng/omp-vcc.${k}`) ?? tryGet(`plugins.omp-vcc.${k}`) ?? tryGet(`omp-vcc.${k}`) ?? tryGet(k);
107
+ if (v !== undefined) (overlay as any)[k] = v;
108
+ }
109
+ return Object.keys(overlay).length ? { ...file, ...overlay } : file;
110
+ }
111
+
112
+ /**
113
+ * Ensure ~/.omp/omp-vcc/config.json exists with default keys (migrates legacy pi path read).
114
+ * - File missing → create with full default block.
115
+ * - File exists but invalid JSON → no-op (don't clobber user file).
116
+ * - File exists and valid → fill in missing default keys, preserve existing values.
117
+ */
118
+ export function scaffoldSettings(): void {
119
+ try {
120
+ const path = settingsPath();
121
+ const dir = dirname(path);
122
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
123
+
124
+ if (!existsSync(path)) {
125
+ // migrate legacy pi-vcc config if present before creating fresh
126
+ const legacy = existsSync(legacyPiPath) ? readJson(legacyPiPath) : null;
127
+ if (legacy && typeof legacy === "object") {
128
+ const migrated = { ...DEFAULT_SETTINGS, ...(legacy as Partial<PiVccSettings>) };
129
+ writeFileSync(path, `${JSON.stringify(migrated, null, 2)}\n`);
130
+ return;
131
+ }
132
+ writeFileSync(path, `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`);
133
+ return;
134
+ }
135
+
136
+ const parsed = readJson(path);
137
+ if (!parsed || typeof parsed !== "object") return; // don't clobber
138
+
139
+ let changed = false;
140
+ const next: Record<string, unknown> = { ...parsed };
141
+ for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
142
+ if (!(key in next)) {
143
+ next[key] = value;
144
+ changed = true;
145
+ }
146
+ }
147
+ if (changed) writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
148
+ } catch {
149
+ // best-effort; never crash extension load
150
+ }
151
+ }
@@ -0,0 +1,36 @@
1
+ // @ts-nocheck
2
+ /** Shared skill-tag collapse utilities */
3
+
4
+ const SKILL_TAG_RE = /^-?\s*<skill\s+name="([^"]+)"/;
5
+ const SKILL_CLOSE_RE = /^-?\s*<\/skill>/;
6
+
7
+ /** Collapse skill tags in an array of lines — dedup by name, drop all content inside block */
8
+ export const collapseSkillLines = (lines: string[]): string[] => {
9
+ const result: string[] = [];
10
+ const seenSkills = new Set<string>();
11
+ let insideSkill = false;
12
+
13
+ for (const line of lines) {
14
+ const skillMatch = line.match(SKILL_TAG_RE);
15
+ if (skillMatch) {
16
+ insideSkill = true;
17
+ const name = skillMatch[1];
18
+ if (!seenSkills.has(name)) {
19
+ seenSkills.add(name);
20
+ result.push(`[skill: ${name}]`);
21
+ }
22
+ continue;
23
+ }
24
+ if (insideSkill) {
25
+ if (SKILL_CLOSE_RE.test(line)) insideSkill = false;
26
+ continue;
27
+ }
28
+ result.push(line);
29
+ }
30
+ return result;
31
+ };
32
+
33
+ /** Collapse <skill name="X" ...>...</skill> blocks in raw text */
34
+ const SKILL_BLOCK_RE = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?(?:<\/skill>|$)/g;
35
+ export const collapseSkillText = (text: string): string =>
36
+ text.replace(SKILL_BLOCK_RE, (_, name) => `[skill: ${name}]`);
@@ -0,0 +1,208 @@
1
+ // @ts-nocheck
2
+ import type { Message } from "@oh-my-pi/pi-ai";
3
+ import type { FileOps } from "../types";
4
+ import { normalize } from "./normalize";
5
+ import { filterNoise } from "./filter-noise";
6
+ import { buildSections } from "./build-sections";
7
+ import { formatSummary, capBrief, BRIEF_MAX_LINES, RECALL_NOTE, wrapLongLines } from "./format";
8
+ import { selectRankedBriefBlocks, type BriefRankingOptions } from "./rank";
9
+
10
+ export interface CompileInput {
11
+ messages: Message[];
12
+ previousSummary?: string;
13
+ fileOps?: FileOps;
14
+ }
15
+
16
+ export interface RankedCompileInput extends CompileInput {
17
+ ranking?: BriefRankingOptions;
18
+ }
19
+
20
+ const HEADER_NAMES = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"];
21
+
22
+ const SEPARATOR = "\n\n---\n\n";
23
+
24
+ /** Extract a named section from summary text */
25
+ const sectionOf = (text: string, header: string): string => {
26
+ 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
31
+ const nextSection = HEADER_NAMES
32
+ .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];
38
+ return (end ? after.slice(0, end) : after).trim();
39
+ };
40
+
41
+ /** Extract the brief transcript part (everything after ---) */
42
+ const briefOf = (text: string): string => {
43
+ const idx = text.indexOf(SEPARATOR);
44
+ if (idx < 0) return "";
45
+ return text.slice(idx + SEPARATOR.length).trim();
46
+ };
47
+
48
+ /** Merge a header section */
49
+ const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
50
+ // Outstanding Context is volatile -- always use fresh only
51
+ if (header === "Outstanding Context") return fresh;
52
+ if (!prev) return fresh;
53
+ if (!fresh) return prev;
54
+
55
+ // Files And Changes: merge by category (Modified/Created/Read), dedup paths
56
+ if (header === "Files And Changes") {
57
+ return mergeFileLines(prev, fresh);
58
+ }
59
+
60
+ // Session Goal, User Preferences: line-level dedup, cap
61
+ 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);
64
+ const combined = [...new Set([...prevLines, ...freshLines])];
65
+ const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : 15;
66
+ const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
67
+ if (capped.length === 0) return "";
68
+ return `[${header}]\n${capped.join("\n")}`;
69
+ };
70
+
71
+ /** Merge Files And Changes by category, dedup paths across compactions */
72
+ const mergeFileLines = (prev: string, fresh: string): string => {
73
+ const categories = ["Modified", "Created", "Read"] as const;
74
+ const merged: Record<string, Set<string>> = {};
75
+ for (const cat of categories) merged[cat] = new Set();
76
+
77
+ // Parse "- Modified: a, b, c (+N more)" lines from both prev and fresh
78
+ for (const text of [prev, fresh]) {
79
+ 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
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ // Dedup: if already in Modified, drop from Created (file existed before)
95
+ for (const p of merged.Modified) merged.Created.delete(p);
96
+
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
+
103
+ 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)}`);
107
+ if (lines.length === 0) return "";
108
+ return `[Files And Changes]\n${lines.join("\n")}`;
109
+ };
110
+
111
+ const mergeBriefTranscript = (prev: string, fresh: string): string => {
112
+ if (!prev) return fresh;
113
+ if (!fresh) return prev;
114
+ return prev + "\n\n" + fresh;
115
+ };
116
+
117
+ const briefLineCount = (text: string): number =>
118
+ text ? text.split("\n").length : 0;
119
+
120
+ const capBriefToLineBudget = (text: string, maxLines: number): string => {
121
+ if (!text || maxLines <= 0) return "";
122
+ const lines = text.split("\n");
123
+ if (lines.length <= maxLines) return text;
124
+ const kept = lines.slice(-maxLines);
125
+ const firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
126
+ const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
127
+ const omitted = lines.length - clean.length;
128
+ return `...(${omitted} earlier lines omitted)\n\n${clean.join("\n")}`;
129
+ };
130
+
131
+ const mergeBriefTranscriptWithFreshBudget = (prev: string, fresh: string): string => {
132
+ if (!prev) return fresh;
133
+ if (!fresh) return capBrief(prev);
134
+ const freshLines = briefLineCount(fresh);
135
+ const remainingPrevLines = Math.max(0, BRIEF_MAX_LINES - freshLines);
136
+ const prevTail = capBriefToLineBudget(prev, remainingPrevLines);
137
+ return prevTail ? `${prevTail}\n\n${fresh}` : fresh;
138
+ };
139
+
140
+ const mergePrevious = (prev: string, fresh: string, options: { preserveFreshBrief?: boolean } = {}): string => {
141
+ // Merge header sections
142
+ const headers = HEADER_NAMES
143
+ .map((header) => {
144
+ const freshSec = sectionOf(fresh, header);
145
+ const prevSec = sectionOf(prev, header);
146
+ return mergeHeaderSection(header, prevSec, freshSec);
147
+ })
148
+ .filter(Boolean);
149
+
150
+ // Merge brief transcript
151
+ const prevBrief = briefOf(prev);
152
+ const freshBrief = briefOf(fresh);
153
+ const mergedBrief = options.preserveFreshBrief
154
+ ? mergeBriefTranscriptWithFreshBudget(prevBrief, freshBrief)
155
+ : mergeBriefTranscript(prevBrief, freshBrief);
156
+
157
+ const parts: string[] = [];
158
+ if (headers.length > 0) {
159
+ parts.push(headers.join("\n\n"));
160
+ }
161
+ if (mergedBrief) {
162
+ parts.push(options.preserveFreshBrief ? mergedBrief : capBrief(mergedBrief));
163
+ }
164
+
165
+ return parts.join(SEPARATOR);
166
+ };
167
+
168
+ interface CompileWithBriefBlocksOptions {
169
+ briefBlocksFor?: (blocks: ReturnType<typeof normalize>) => ReturnType<typeof normalize>;
170
+ capFreshBrief?: boolean;
171
+ preserveFreshBriefOnMerge?: boolean;
172
+ }
173
+
174
+ const compileWithBriefBlocks = (input: CompileInput, options: CompileWithBriefBlocksOptions = {}): string => {
175
+ const blocks = filterNoise(normalize(input.messages));
176
+ const briefBlocks = options.briefBlocksFor?.(blocks);
177
+ const data = buildSections({ blocks, briefBlocks, fileOps: input.fileOps });
178
+ const fresh = formatSummary(data, { capBriefTranscript: options.capFreshBrief ?? true });
179
+ // Strip any legacy RECALL_NOTE baked into prev summary (pre-fix format)
180
+ // so merge doesn't re-stack it inside the brief.
181
+ const prev = input.previousSummary
182
+ ? stripRecallNote(input.previousSummary)
183
+ : undefined;
184
+ const merged = prev ? mergePrevious(prev, fresh, { preserveFreshBrief: options.preserveFreshBriefOnMerge }) : fresh;
185
+ if (!merged) return "";
186
+ return wrapLongLines(merged + SEPARATOR + RECALL_NOTE);
187
+ };
188
+
189
+ export const compile = (input: CompileInput): string =>
190
+ compileWithBriefBlocks(input);
191
+
192
+ export const compileRanked = (input: RankedCompileInput): string =>
193
+ compileWithBriefBlocks(input, {
194
+ briefBlocksFor: (blocks) => selectRankedBriefBlocks(blocks, {
195
+ ...input.ranking,
196
+ fileOps: input.ranking?.fileOps ?? input.fileOps,
197
+ }),
198
+ capFreshBrief: false,
199
+ preserveFreshBriefOnMerge: true,
200
+ });
201
+
202
+ const stripRecallNote = (text: string): string => {
203
+ // 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();
208
+ };
@@ -0,0 +1,101 @@
1
+ // @ts-nocheck
2
+ export const DEFAULT_CHARS_PER_TOKEN = 4;
3
+ export const MIN_CHARS_PER_TOKEN = 2;
4
+ export const MAX_CHARS_PER_TOKEN = 6;
5
+
6
+ export type TokenEstimateMode = "heuristic" | "calibrated";
7
+
8
+ export interface TokenEstimateCalibration {
9
+ mode: TokenEstimateMode;
10
+ charsPerToken: number;
11
+ sourceChars?: number;
12
+ sourceTokens?: number;
13
+ rawCharsPerToken?: number;
14
+ }
15
+
16
+ const clamp = (value: number, min: number, max: number): number =>
17
+ Math.min(max, Math.max(min, value));
18
+
19
+ export const calibrateCharsPerToken = (
20
+ sourceChars: number,
21
+ sourceTokens: number | undefined,
22
+ ): TokenEstimateCalibration => {
23
+ if (!sourceTokens || sourceTokens <= 0 || sourceChars <= 0) {
24
+ return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
25
+ }
26
+
27
+ const rawCharsPerToken = sourceChars / sourceTokens;
28
+ if (!Number.isFinite(rawCharsPerToken) || rawCharsPerToken <= 0) {
29
+ return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
30
+ }
31
+
32
+ return {
33
+ mode: "calibrated",
34
+ charsPerToken: clamp(rawCharsPerToken, MIN_CHARS_PER_TOKEN, MAX_CHARS_PER_TOKEN),
35
+ sourceChars,
36
+ sourceTokens,
37
+ rawCharsPerToken,
38
+ };
39
+ };
40
+
41
+ export const estimateTokensFromChars = (
42
+ chars: number,
43
+ charsPerToken = DEFAULT_CHARS_PER_TOKEN,
44
+ ): number => Math.ceil(chars / charsPerToken);
45
+
46
+ /**
47
+ * Chars attributed to one image part, mirroring pi-agent-core's own
48
+ * estimateTokens heuristic (4800 chars ≈ 1200 tokens at 4 chars/token).
49
+ */
50
+ export const IMAGE_CONTENT_CHARS = 4800;
51
+
52
+ const safeJsonStringify = (value: unknown): string => {
53
+ try {
54
+ return JSON.stringify(value ?? "") ?? "";
55
+ } catch {
56
+ return "";
57
+ }
58
+ };
59
+
60
+ /**
61
+ * Estimate the char length of a message's content (string or content-parts
62
+ * array). Counts every token-bearing part that pi-agent-core's harness
63
+ * estimateTokens counts, so the calibrated chars/token ratio is not deflated:
64
+ * - text → text.length
65
+ * - thinking → thinking.length (opus emits large reasoning blocks)
66
+ * - toolCall → name + arguments (Pi uses `arguments`; `input` kept for compat)
67
+ * - image → IMAGE_CONTENT_CHARS
68
+ * - toolResult → nested content (legacy part shape)
69
+ */
70
+ export const estimateMessageContentChars = (content: unknown): number => {
71
+ if (typeof content === "string") return content.length;
72
+ if (!Array.isArray(content)) return 0;
73
+ return content.reduce((sum: number, part: any) => {
74
+ if (!part || typeof part !== "object") return sum;
75
+ switch (part.type) {
76
+ case "text":
77
+ return sum + (typeof part.text === "string" ? part.text.length : 0);
78
+ case "thinking":
79
+ return sum + (typeof part.thinking === "string" ? part.thinking.length : 0);
80
+ case "toolCall": {
81
+ const args = part.arguments ?? part.input;
82
+ const argLength = typeof args === "string" ? args.length : safeJsonStringify(args).length;
83
+ return sum + (part.name?.length ?? 0) + argLength;
84
+ }
85
+ case "toolResult": {
86
+ const c = part.content;
87
+ return sum + (typeof c === "string" ? c.length : safeJsonStringify(c).length);
88
+ }
89
+ case "image":
90
+ return sum + IMAGE_CONTENT_CHARS;
91
+ default:
92
+ // Unknown part: fall back to any text field so we never undercount.
93
+ return sum + (typeof part.text === "string" ? part.text.length : 0);
94
+ }
95
+ }, 0);
96
+ };
97
+
98
+ export const estimateMessageContentTokens = (
99
+ content: unknown,
100
+ charsPerToken = DEFAULT_CHARS_PER_TOKEN,
101
+ ): number => estimateTokensFromChars(estimateMessageContentChars(content), charsPerToken);
@@ -0,0 +1,17 @@
1
+ // @ts-nocheck
2
+ export const PATH_KEYS = ["path", "file_path", "filePath", "file"] as const;
3
+
4
+ export const extractPath = (args: Record<string, unknown>): string | null => {
5
+ for (const key of ["path", "file_path", "filePath", "file"]) {
6
+ if (typeof args[key] === "string") return args[key] as string;
7
+ }
8
+ return null;
9
+ };
10
+
11
+ export const summarizeToolArgs = (args: Record<string, unknown>): string => {
12
+ const path = extractPath(args);
13
+ if (path) return `path=${path}`;
14
+ if (typeof args.command === "string") return `command=${args.command}`;
15
+ if (typeof args.query === "string") return `query=${args.query}`;
16
+ return Object.keys(args).join(", ");
17
+ };
@@ -0,0 +1,12 @@
1
+ // @ts-nocheck
2
+ import type { CompactionReason } from "./types";
3
+
4
+ export interface PiVccCompactionDetails {
5
+ compactor: "pi-vcc";
6
+ version: number;
7
+ sections: string[];
8
+ sourceMessageCount: number;
9
+ previousSummaryUsed: boolean;
10
+ reason?: CompactionReason;
11
+ willRetry?: boolean;
12
+ }
@@ -0,0 +1,70 @@
1
+ // @ts-nocheck
2
+ import type { NormalizedBlock } from "../types";
3
+
4
+ interface CommitInfo {
5
+ hash?: string;
6
+ message: string;
7
+ }
8
+
9
+ const COMMIT_MSG_RE = /git\s+commit[^\n]*?-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\$?'((?:[^'\\]|\\.)*)')/;
10
+ // Match short hash from git output: "[branch hash]" or "main hash" or 7-12 hex
11
+ const HASH_RE = /\b([0-9a-f]{7,12})\b/;
12
+
13
+ const firstLineOf = (text: string): string => {
14
+ const line = text.split(/\\n|\n/)[0] ?? "";
15
+ return line.trim();
16
+ };
17
+
18
+ const cleanMessage = (msg: string): string =>
19
+ msg.replace(/\\"/g, '"').replace(/\\'/g, "'").trim();
20
+
21
+ /**
22
+ * Extract git commits from bash tool calls (`git commit -m "..."`) and pair
23
+ * with hash from the immediately following tool_result.
24
+ */
25
+ export const extractCommits = (blocks: NormalizedBlock[]): CommitInfo[] => {
26
+ const commits: CommitInfo[] = [];
27
+
28
+ for (let i = 0; i < blocks.length; i++) {
29
+ const b = blocks[i];
30
+ if (b.kind !== "tool_call" || b.name !== "bash") continue;
31
+ const cmd = typeof b.args.command === "string" ? b.args.command : "";
32
+ if (!/\bgit\s+commit\b/.test(cmd)) continue;
33
+ const m = cmd.match(COMMIT_MSG_RE);
34
+ if (!m) continue;
35
+ const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
36
+ if (!message) continue;
37
+
38
+ let hash: string | undefined;
39
+ // Look at next tool_result for hash
40
+ for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
41
+ const r = blocks[j];
42
+ if (r.kind !== "tool_result") continue;
43
+ // Common git commit output: `[branch <hash>] message` or `<branch> <hash>..<hash>`
44
+ const bracket = r.text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
45
+ if (bracket) { hash = bracket[1]; break; }
46
+ const range = r.text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
47
+ if (range) { hash = range[2]; break; }
48
+ const plain = r.text.match(HASH_RE);
49
+ if (plain) { hash = plain[1]; break; }
50
+ }
51
+
52
+ // Dedup by message+hash
53
+ const key = `${hash ?? ""}::${message}`;
54
+ if (!commits.some((c) => `${c.hash ?? ""}::${c.message}` === key)) {
55
+ commits.push({ hash, message });
56
+ }
57
+ }
58
+
59
+ return commits;
60
+ };
61
+
62
+ export const formatCommits = (commits: CommitInfo[], limit = 8): string[] => {
63
+ const lines: string[] = [];
64
+ const items = commits.slice(-limit); // keep most recent
65
+ for (const c of items) {
66
+ const prefix = c.hash ? `${c.hash}: ` : "";
67
+ lines.push(`${prefix}${c.message}`);
68
+ }
69
+ return lines;
70
+ };
@@ -0,0 +1,88 @@
1
+ // @ts-nocheck
2
+ import type { FileOps, NormalizedBlock } from "../types";
3
+ import { extractPath } from "../core/tool-args";
4
+
5
+ interface FileActivity {
6
+ read: Set<string>;
7
+ modified: Set<string>;
8
+ created: Set<string>;
9
+ }
10
+
11
+ // Tool names are matched case-insensitively (see `matches`), mirroring the /i
12
+ // regexes in core/rank.ts. Entries must be lowercase.
13
+ const FILE_READ_TOOLS = new Set([
14
+ "read", "read_file", "view",
15
+ ]);
16
+
17
+ // Multi-file patch tools (apply_patch) carry their paths inside the diff payload,
18
+ // not in a path arg, so extractPath yields nothing for them; those files are
19
+ // recovered from the hook-provided fileOps below.
20
+ const FILE_WRITE_TOOLS = new Set([
21
+ "edit", "write", "edit_file", "write_file",
22
+ "multiedit", "quick_edit", "target_edit", "apply_patch",
23
+ ]);
24
+
25
+ const FILE_CREATE_TOOLS = new Set([
26
+ "write", "write_file",
27
+ ]);
28
+
29
+ const matches = (tools: Set<string>, name: string): boolean => tools.has(name.toLowerCase());
30
+
31
+ /**
32
+ * Find the longest common directory prefix among absolute paths.
33
+ * Returns "" if fewer than 2 absolute paths or no meaningful common prefix.
34
+ */
35
+ const longestCommonDirPrefix = (paths: string[]): string => {
36
+ const abs = paths.filter((p) => p.startsWith("/"));
37
+ if (abs.length < 2) return "";
38
+ const split = abs.map((p) => p.split("/"));
39
+ const min = Math.min(...split.map((s) => s.length));
40
+ let i = 0;
41
+ while (i < min - 1) {
42
+ const seg = split[0][i];
43
+ if (!split.every((s) => s[i] === seg)) break;
44
+ i++;
45
+ }
46
+ if (i < 2) return ""; // require at least /a/b common
47
+ return split[0].slice(0, i).join("/") + "/";
48
+ };
49
+
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
+ export const extractFiles = (
60
+ blocks: NormalizedBlock[],
61
+ fileOps?: FileOps,
62
+ ): FileActivity => {
63
+ const act: FileActivity = {
64
+ read: new Set(fileOps?.readFiles ?? []),
65
+ modified: new Set(fileOps?.modifiedFiles ?? []),
66
+ created: new Set(fileOps?.createdFiles ?? []),
67
+ };
68
+
69
+ for (const b of blocks) {
70
+ if (b.kind !== "tool_call") continue;
71
+ const p = extractPath(b.args);
72
+ if (!p) continue;
73
+
74
+ if (matches(FILE_READ_TOOLS, b.name)) act.read.add(p);
75
+ if (matches(FILE_WRITE_TOOLS, b.name)) act.modified.add(p);
76
+ if (matches(FILE_CREATE_TOOLS, b.name)) act.created.add(p);
77
+ }
78
+
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
+
87
+ return act;
88
+ };