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.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/commands/omp-vcc.md +19 -0
- package/commands/vcc-recall.md +21 -0
- package/extensions/main.ts +319 -0
- package/extensions/vcc-core/commands/vcc-recall.ts +2 -0
- package/extensions/vcc-core/core/brief.ts +404 -0
- package/extensions/vcc-core/core/build-sections.ts +77 -0
- package/extensions/vcc-core/core/compact-args.ts +46 -0
- package/extensions/vcc-core/core/content.ts +157 -0
- package/extensions/vcc-core/core/drill-down.ts +299 -0
- package/extensions/vcc-core/core/filter-noise.ts +42 -0
- package/extensions/vcc-core/core/format-recall.ts +101 -0
- package/extensions/vcc-core/core/format.ts +82 -0
- package/extensions/vcc-core/core/lineage.ts +27 -0
- package/extensions/vcc-core/core/load-messages.ts +44 -0
- package/extensions/vcc-core/core/normalize.ts +66 -0
- package/extensions/vcc-core/core/rank.ts +284 -0
- package/extensions/vcc-core/core/recall-scope.ts +31 -0
- package/extensions/vcc-core/core/render-entries.ts +55 -0
- package/extensions/vcc-core/core/report.ts +233 -0
- package/extensions/vcc-core/core/sanitize.ts +6 -0
- package/extensions/vcc-core/core/search-entries.ts +576 -0
- package/extensions/vcc-core/core/settings.ts +151 -0
- package/extensions/vcc-core/core/skill-collapse.ts +36 -0
- package/extensions/vcc-core/core/summarize.ts +208 -0
- package/extensions/vcc-core/core/token-estimate.ts +101 -0
- package/extensions/vcc-core/core/tool-args.ts +17 -0
- package/extensions/vcc-core/details.ts +12 -0
- package/extensions/vcc-core/extract/commits.ts +70 -0
- package/extensions/vcc-core/extract/files.ts +88 -0
- package/extensions/vcc-core/extract/goals.ts +80 -0
- package/extensions/vcc-core/extract/preferences.ts +56 -0
- package/extensions/vcc-core/hook.ts +1017 -0
- package/extensions/vcc-core/sections.ts +9 -0
- package/extensions/vcc-core/types.ts +17 -0
- package/package.json +104 -0
- package/scripts/smoke.ts +116 -0
- package/scripts/uninstall-reset.js +73 -0
- package/skills/omp-vcc/SKILL.md +35 -0
- 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
|
+
};
|