opencode-blackhole 0.0.0-stage → 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 +35 -0
- package/LICENSE.vendor +21 -0
- package/README.md +377 -2
- package/dist/server.js +5025 -0
- package/index.ts +1 -0
- package/package.json +57 -4
- package/src/adapter/opencode-messages.ts +270 -0
- package/src/compaction/build.ts +264 -0
- package/src/compaction/handoff.ts +421 -0
- package/src/config.ts +316 -0
- package/src/index.ts +714 -0
- package/src/memory/ledger.ts +254 -0
- package/src/memory/manager.ts +659 -0
- package/src/memory/models.ts +172 -0
- package/src/memory/prompts.ts +145 -0
- package/src/recall/recall.ts +327 -0
- package/src/state/paths.ts +42 -0
- package/src/store/capture.ts +84 -0
- package/src/store/transcript.ts +279 -0
- package/src/vcc/README.md +51 -0
- package/src/vcc/core/brief.ts +407 -0
- package/src/vcc/core/build-sections.ts +81 -0
- package/src/vcc/core/compact-args.ts +45 -0
- package/src/vcc/core/content.ts +156 -0
- package/src/vcc/core/drill-down.ts +302 -0
- package/src/vcc/core/filter-noise.ts +41 -0
- package/src/vcc/core/format-recall.ts +100 -0
- package/src/vcc/core/format.ts +104 -0
- package/src/vcc/core/lineage.ts +26 -0
- package/src/vcc/core/normalize.ts +76 -0
- package/src/vcc/core/rank.ts +283 -0
- package/src/vcc/core/recall-request.ts +160 -0
- package/src/vcc/core/recall-scope.ts +30 -0
- package/src/vcc/core/render-entries.ts +55 -0
- package/src/vcc/core/sanitize.ts +5 -0
- package/src/vcc/core/search-entries.ts +594 -0
- package/src/vcc/core/skill-collapse.ts +35 -0
- package/src/vcc/core/summarize.ts +289 -0
- package/src/vcc/core/token-estimate.ts +100 -0
- package/src/vcc/core/tool-args.ts +16 -0
- package/src/vcc/details.ts +11 -0
- package/src/vcc/extract/commits.ts +96 -0
- package/src/vcc/extract/files.ts +87 -0
- package/src/vcc/extract/goals.ts +79 -0
- package/src/vcc/extract/preferences.ts +55 -0
- package/src/vcc/extract/tracked-commands.ts +232 -0
- package/src/vcc/load-messages.ts +42 -0
- package/src/vcc/pi-types.ts +53 -0
- package/src/vcc/sections.ts +12 -0
- package/src/vcc/types.ts +16 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import type { Message } from "../pi-types";
|
|
2
|
+
import type { FileOps } from "../types";
|
|
3
|
+
import { normalize } from "./normalize";
|
|
4
|
+
import { filterNoise } from "./filter-noise";
|
|
5
|
+
import { buildSections } from "./build-sections";
|
|
6
|
+
import { formatSummary, capBrief, capItems, stripCapMarker, BRIEF_MAX_LINES, RECALL_NOTE, wrapLongLines } from "./format";
|
|
7
|
+
import { COMMAND_SEPARATOR, COMMANDS_PER_NAME } from "../extract/tracked-commands";
|
|
8
|
+
import { selectRankedBriefBlocks, type BriefRankingOptions } from "./rank";
|
|
9
|
+
|
|
10
|
+
export interface CompileInput {
|
|
11
|
+
messages: Message[];
|
|
12
|
+
previousSummary?: string;
|
|
13
|
+
fileOps?: FileOps;
|
|
14
|
+
/**
|
|
15
|
+
* Session-global `#N` index per message position (see
|
|
16
|
+
* src/core/global-indices.ts). Parallel to `messages`; a missing entry
|
|
17
|
+
* renders as no ref (fail-closed). Omitted entirely → legacy positional.
|
|
18
|
+
*/
|
|
19
|
+
sourceIndices?: Array<number | undefined>;
|
|
20
|
+
/** See BuildSectionsInput.trackCommands (core/build-sections.ts) --
|
|
21
|
+
* empty/omitted by default, threaded through from
|
|
22
|
+
* PiVccSettings.trackCommands by the caller (hooks/before-compact.ts). */
|
|
23
|
+
trackCommands?: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RankedCompileInput extends CompileInput {
|
|
27
|
+
ranking?: BriefRankingOptions;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HEADER_NAMES = ["Session Goal", "Files And Changes", "Commits", "Tracked Commands", "Outstanding Context", "User Preferences"];
|
|
31
|
+
|
|
32
|
+
const SEPARATOR = "\n\n---\n\n";
|
|
33
|
+
|
|
34
|
+
/** Extract a named section from summary text */
|
|
35
|
+
const sectionOf = (text: string, header: string): string => {
|
|
36
|
+
const tag = `[${header}]`;
|
|
37
|
+
const start = text.indexOf(tag);
|
|
38
|
+
if (start < 0) return "";
|
|
39
|
+
const after = text.slice(start);
|
|
40
|
+
// Find next section header or separator
|
|
41
|
+
const nextSection = HEADER_NAMES
|
|
42
|
+
.filter((h) => h !== header)
|
|
43
|
+
.map((h) => after.indexOf(`[${h}]`))
|
|
44
|
+
.filter((n) => n > 0);
|
|
45
|
+
const nextSep = after.indexOf("\n\n---\n\n");
|
|
46
|
+
const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
|
|
47
|
+
const end = candidates[0];
|
|
48
|
+
return (end ? after.slice(0, end) : after).trim();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Extract the brief transcript part (everything after ---) */
|
|
52
|
+
const briefOf = (text: string): string => {
|
|
53
|
+
const idx = text.indexOf(SEPARATOR);
|
|
54
|
+
if (idx < 0) return "";
|
|
55
|
+
return text.slice(idx + SEPARATOR.length).trim();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Merge a header section */
|
|
59
|
+
const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
|
|
60
|
+
// Outstanding Context is volatile -- always use fresh only
|
|
61
|
+
if (header === "Outstanding Context") return fresh;
|
|
62
|
+
if (!prev) return fresh;
|
|
63
|
+
if (!fresh) return prev;
|
|
64
|
+
|
|
65
|
+
// Files And Changes: merge by category (Modified/Created/Read), dedup paths
|
|
66
|
+
if (header === "Files And Changes") {
|
|
67
|
+
return mergeFileLines(prev, fresh);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Tracked Commands: same categorized-merge shape as Files And Changes, but
|
|
71
|
+
// categories are whatever command names the user configured in
|
|
72
|
+
// trackCommands -- discover them from the actual text rather than a
|
|
73
|
+
// fixed list, so a config change between compactions doesn't orphan a
|
|
74
|
+
// category that was already recorded.
|
|
75
|
+
if (header === "Tracked Commands") {
|
|
76
|
+
return mergeTrackedCommandLines(prev, fresh);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Session Goal, User Preferences: line-level dedup, cap
|
|
80
|
+
const isClean = (l: string) => l.startsWith("- ") && !l.includes("<skill") && !l.includes("</skill");
|
|
81
|
+
const prevLines = prev.split("\n").filter(isClean);
|
|
82
|
+
const freshLines = fresh.split("\n").filter(isClean);
|
|
83
|
+
const combined = [...new Set([...prevLines, ...freshLines])];
|
|
84
|
+
const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : 15;
|
|
85
|
+
const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
|
|
86
|
+
if (capped.length === 0) return "";
|
|
87
|
+
return `[${header}]\n${capped.join("\n")}`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generic categorized-section merge: parses "- Category: a, b, c (+N more)"
|
|
92
|
+
* lines from both prev and fresh text, unions each category's items across
|
|
93
|
+
* compactions (deduped via Set), and re-renders. Extracted from what used
|
|
94
|
+
* to be Files And Changes' only inline implementation, in preparation for
|
|
95
|
+
* a second categorized section reusing the identical shape (see the
|
|
96
|
+
* follow-up `feat/track-commands-section` branch/PR).
|
|
97
|
+
*/
|
|
98
|
+
const mergeCategorizedLines = (
|
|
99
|
+
categories: readonly string[],
|
|
100
|
+
prev: string,
|
|
101
|
+
fresh: string,
|
|
102
|
+
splitOn: string,
|
|
103
|
+
touchOnDup = false,
|
|
104
|
+
): Record<string, Set<string>> => {
|
|
105
|
+
const merged: Record<string, Set<string>> = {};
|
|
106
|
+
for (const cat of categories) merged[cat] = new Set();
|
|
107
|
+
|
|
108
|
+
for (const text of [prev, fresh]) {
|
|
109
|
+
for (const line of text.split("\n")) {
|
|
110
|
+
for (const cat of categories) {
|
|
111
|
+
const prefix = `- ${cat}: `;
|
|
112
|
+
if (!line.startsWith(prefix)) continue;
|
|
113
|
+
// Strip the overflow marker so it never merges back in as an entry.
|
|
114
|
+
const rest = stripCapMarker(line.slice(prefix.length));
|
|
115
|
+
for (const p of rest.split(splitOn)) {
|
|
116
|
+
const trimmed = p.trim();
|
|
117
|
+
if (!trimmed) continue;
|
|
118
|
+
// touchOnDup: re-inserting an existing entry moves it to the
|
|
119
|
+
// tail — with tail-capping this makes the section "N most-
|
|
120
|
+
// recently-USED" rather than "N most-recently-first-seen".
|
|
121
|
+
if (touchOnDup && merged[cat].has(trimmed)) merged[cat].delete(trimmed);
|
|
122
|
+
merged[cat].add(trimmed);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return merged;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const formatCategorizedLines = (
|
|
131
|
+
header: string,
|
|
132
|
+
merged: Record<string, Set<string>>,
|
|
133
|
+
categories: readonly string[],
|
|
134
|
+
joinWith: string,
|
|
135
|
+
itemLimit = 10,
|
|
136
|
+
keep: "head" | "tail" = "head",
|
|
137
|
+
): string => {
|
|
138
|
+
const lines: string[] = [];
|
|
139
|
+
for (const cat of categories) {
|
|
140
|
+
if (merged[cat].size > 0) lines.push(`- ${cat}: ${capItems([...merged[cat]], itemLimit, joinWith, keep)}`);
|
|
141
|
+
}
|
|
142
|
+
if (lines.length === 0) return "";
|
|
143
|
+
return `[${header}]\n${lines.join("\n")}`;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const FILE_CATEGORIES = ["Modified", "Created", "Read"] as const;
|
|
147
|
+
|
|
148
|
+
/** Merge Files And Changes by category, dedup paths across compactions */
|
|
149
|
+
const mergeFileLines = (prev: string, fresh: string): string => {
|
|
150
|
+
const merged = mergeCategorizedLines(FILE_CATEGORIES, prev, fresh, ",");
|
|
151
|
+
// Dedup: if already in Modified, drop from Created (file existed before)
|
|
152
|
+
for (const p of merged.Modified) merged.Created.delete(p);
|
|
153
|
+
return formatCategorizedLines("Files And Changes", merged, FILE_CATEGORIES, ", ");
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Category names actually present in "- <name>: ..." lines in `text`. */
|
|
157
|
+
const discoverCategoryNames = (text: string): string[] => {
|
|
158
|
+
const names = new Set<string>();
|
|
159
|
+
for (const line of text.split("\n")) {
|
|
160
|
+
const m = line.match(/^- ([^:]+): /);
|
|
161
|
+
if (m) names.add(m[1]);
|
|
162
|
+
}
|
|
163
|
+
return [...names];
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Merge Tracked Commands by whatever command names actually appear in prev/
|
|
168
|
+
* fresh (not a fixed category list, since trackCommands is user-config).
|
|
169
|
+
* Line format (separator, cap) is owned by extract/tracked-commands.ts.
|
|
170
|
+
*/
|
|
171
|
+
const mergeTrackedCommandLines = (prev: string, fresh: string): string => {
|
|
172
|
+
const categories = [...new Set([...discoverCategoryNames(prev), ...discoverCategoryNames(fresh)])];
|
|
173
|
+
const merged = mergeCategorizedLines(categories, prev, fresh, COMMAND_SEPARATOR, true);
|
|
174
|
+
return formatCategorizedLines("Tracked Commands", merged, categories, COMMAND_SEPARATOR, COMMANDS_PER_NAME, "tail");
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const mergeBriefTranscript = (prev: string, fresh: string): string => {
|
|
178
|
+
if (!prev) return fresh;
|
|
179
|
+
if (!fresh) return prev;
|
|
180
|
+
return prev + "\n\n" + fresh;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const briefLineCount = (text: string): number =>
|
|
184
|
+
text ? text.split("\n").length : 0;
|
|
185
|
+
|
|
186
|
+
const capBriefToLineBudget = (text: string, maxLines: number): string => {
|
|
187
|
+
if (!text || maxLines <= 0) return "";
|
|
188
|
+
const lines = text.split("\n");
|
|
189
|
+
if (lines.length <= maxLines) return text;
|
|
190
|
+
const kept = lines.slice(-maxLines);
|
|
191
|
+
const firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
|
|
192
|
+
const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
|
|
193
|
+
const omitted = lines.length - clean.length;
|
|
194
|
+
return `...(${omitted} earlier lines omitted)\n\n${clean.join("\n")}`;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const mergeBriefTranscriptWithFreshBudget = (prev: string, fresh: string): string => {
|
|
198
|
+
if (!prev) return fresh;
|
|
199
|
+
if (!fresh) return capBrief(prev);
|
|
200
|
+
const freshLines = briefLineCount(fresh);
|
|
201
|
+
const remainingPrevLines = Math.max(0, BRIEF_MAX_LINES - freshLines);
|
|
202
|
+
const prevTail = capBriefToLineBudget(prev, remainingPrevLines);
|
|
203
|
+
return prevTail ? `${prevTail}\n\n${fresh}` : fresh;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const mergePrevious = (prev: string, fresh: string, options: { preserveFreshBrief?: boolean } = {}): string => {
|
|
207
|
+
// Merge header sections
|
|
208
|
+
const headers = HEADER_NAMES
|
|
209
|
+
.map((header) => {
|
|
210
|
+
const freshSec = sectionOf(fresh, header);
|
|
211
|
+
// The stored summary went through wrapLongLines; rejoin each item's
|
|
212
|
+
// indented continuation lines so the per-section merges, which read
|
|
213
|
+
// only "- " lines, see whole items.
|
|
214
|
+
const prevSec = sectionOf(prev, header).replace(/\n[ \t]+(?=\S)/g, " ");
|
|
215
|
+
return mergeHeaderSection(header, prevSec, freshSec);
|
|
216
|
+
})
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
|
|
219
|
+
// Merge brief transcript
|
|
220
|
+
const prevBrief = briefOf(prev);
|
|
221
|
+
const freshBrief = briefOf(fresh);
|
|
222
|
+
const mergedBrief = options.preserveFreshBrief
|
|
223
|
+
? mergeBriefTranscriptWithFreshBudget(prevBrief, freshBrief)
|
|
224
|
+
: mergeBriefTranscript(prevBrief, freshBrief);
|
|
225
|
+
|
|
226
|
+
const parts: string[] = [];
|
|
227
|
+
if (headers.length > 0) {
|
|
228
|
+
parts.push(headers.join("\n\n"));
|
|
229
|
+
}
|
|
230
|
+
if (mergedBrief) {
|
|
231
|
+
parts.push(options.preserveFreshBrief ? mergedBrief : capBrief(mergedBrief));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return parts.join(SEPARATOR);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
interface CompileWithBriefBlocksOptions {
|
|
238
|
+
briefBlocksFor?: (blocks: ReturnType<typeof normalize>) => ReturnType<typeof normalize>;
|
|
239
|
+
capFreshBrief?: boolean;
|
|
240
|
+
preserveFreshBriefOnMerge?: boolean;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const compileWithBriefBlocks = (input: CompileInput, options: CompileWithBriefBlocksOptions = {}): string => {
|
|
244
|
+
const blocks = filterNoise(normalize(input.messages, input.sourceIndices));
|
|
245
|
+
const briefBlocks = options.briefBlocksFor?.(blocks);
|
|
246
|
+
const data = buildSections({ blocks, briefBlocks, fileOps: input.fileOps, trackCommands: input.trackCommands });
|
|
247
|
+
const fresh = formatSummary(data, { capBriefTranscript: options.capFreshBrief ?? true });
|
|
248
|
+
// Strip any legacy RECALL_NOTE baked into prev summary (pre-fix format)
|
|
249
|
+
// so merge doesn't re-stack it inside the brief.
|
|
250
|
+
const prev = input.previousSummary
|
|
251
|
+
? stripRecallNote(input.previousSummary)
|
|
252
|
+
: undefined;
|
|
253
|
+
const merged = prev ? mergePrevious(prev, fresh, { preserveFreshBrief: options.preserveFreshBriefOnMerge }) : fresh;
|
|
254
|
+
if (!merged) return "";
|
|
255
|
+
return wrapLongLines(merged + SEPARATOR + RECALL_NOTE);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export const compile = (input: CompileInput): string =>
|
|
259
|
+
compileWithBriefBlocks(input);
|
|
260
|
+
|
|
261
|
+
export const compileRanked = (input: RankedCompileInput): string =>
|
|
262
|
+
compileWithBriefBlocks(input, {
|
|
263
|
+
briefBlocksFor: (blocks) => selectRankedBriefBlocks(blocks, {
|
|
264
|
+
...input.ranking,
|
|
265
|
+
fileOps: input.ranking?.fileOps ?? input.fileOps,
|
|
266
|
+
}),
|
|
267
|
+
capFreshBrief: false,
|
|
268
|
+
preserveFreshBriefOnMerge: true,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* RECALL_NOTE as stored: wrapLongLines breaks it across lines (it is longer
|
|
273
|
+
* than the wrap width), so match its words separated by any whitespace,
|
|
274
|
+
* together with the separator in front of it.
|
|
275
|
+
*/
|
|
276
|
+
const RECALL_NOTE_RE = new RegExp(
|
|
277
|
+
"\\n*(?:---\\n+)?" +
|
|
278
|
+
RECALL_NOTE.split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") +
|
|
279
|
+
"\\n*",
|
|
280
|
+
"g",
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Remove every RECALL_NOTE from a previous summary before merging. The exact
|
|
285
|
+
* match this replaced never found the wrapped note, so each compaction kept
|
|
286
|
+
* the old note inside the brief and stacked one more; this also cleans those.
|
|
287
|
+
*/
|
|
288
|
+
const stripRecallNote = (text: string): string =>
|
|
289
|
+
text.replace(RECALL_NOTE_RE, "\n\n").trimEnd();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const DEFAULT_CHARS_PER_TOKEN = 4;
|
|
2
|
+
export const MIN_CHARS_PER_TOKEN = 2;
|
|
3
|
+
export const MAX_CHARS_PER_TOKEN = 6;
|
|
4
|
+
|
|
5
|
+
export type TokenEstimateMode = "heuristic" | "calibrated";
|
|
6
|
+
|
|
7
|
+
export interface TokenEstimateCalibration {
|
|
8
|
+
mode: TokenEstimateMode;
|
|
9
|
+
charsPerToken: number;
|
|
10
|
+
sourceChars?: number;
|
|
11
|
+
sourceTokens?: number;
|
|
12
|
+
rawCharsPerToken?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const clamp = (value: number, min: number, max: number): number =>
|
|
16
|
+
Math.min(max, Math.max(min, value));
|
|
17
|
+
|
|
18
|
+
export const calibrateCharsPerToken = (
|
|
19
|
+
sourceChars: number,
|
|
20
|
+
sourceTokens: number | undefined,
|
|
21
|
+
): TokenEstimateCalibration => {
|
|
22
|
+
if (!sourceTokens || sourceTokens <= 0 || sourceChars <= 0) {
|
|
23
|
+
return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rawCharsPerToken = sourceChars / sourceTokens;
|
|
27
|
+
if (!Number.isFinite(rawCharsPerToken) || rawCharsPerToken <= 0) {
|
|
28
|
+
return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
mode: "calibrated",
|
|
33
|
+
charsPerToken: clamp(rawCharsPerToken, MIN_CHARS_PER_TOKEN, MAX_CHARS_PER_TOKEN),
|
|
34
|
+
sourceChars,
|
|
35
|
+
sourceTokens,
|
|
36
|
+
rawCharsPerToken,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const estimateTokensFromChars = (
|
|
41
|
+
chars: number,
|
|
42
|
+
charsPerToken = DEFAULT_CHARS_PER_TOKEN,
|
|
43
|
+
): number => Math.ceil(chars / charsPerToken);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Chars attributed to one image part, mirroring pi-agent-core's own
|
|
47
|
+
* estimateTokens heuristic (4800 chars ≈ 1200 tokens at 4 chars/token).
|
|
48
|
+
*/
|
|
49
|
+
export const IMAGE_CONTENT_CHARS = 4800;
|
|
50
|
+
|
|
51
|
+
const safeJsonStringify = (value: unknown): string => {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.stringify(value ?? "") ?? "";
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Estimate the char length of a message's content (string or content-parts
|
|
61
|
+
* array). Counts every token-bearing part that pi-agent-core's harness
|
|
62
|
+
* estimateTokens counts, so the calibrated chars/token ratio is not deflated:
|
|
63
|
+
* - text → text.length
|
|
64
|
+
* - thinking → thinking.length (opus emits large reasoning blocks)
|
|
65
|
+
* - toolCall → name + arguments (Pi uses `arguments`; `input` kept for compat)
|
|
66
|
+
* - image → IMAGE_CONTENT_CHARS
|
|
67
|
+
* - toolResult → nested content (legacy part shape)
|
|
68
|
+
*/
|
|
69
|
+
export const estimateMessageContentChars = (content: unknown): number => {
|
|
70
|
+
if (typeof content === "string") return content.length;
|
|
71
|
+
if (!Array.isArray(content)) return 0;
|
|
72
|
+
return content.reduce((sum: number, part: any) => {
|
|
73
|
+
if (!part || typeof part !== "object") return sum;
|
|
74
|
+
switch (part.type) {
|
|
75
|
+
case "text":
|
|
76
|
+
return sum + (typeof part.text === "string" ? part.text.length : 0);
|
|
77
|
+
case "thinking":
|
|
78
|
+
return sum + (typeof part.thinking === "string" ? part.thinking.length : 0);
|
|
79
|
+
case "toolCall": {
|
|
80
|
+
const args = part.arguments ?? part.input;
|
|
81
|
+
const argLength = typeof args === "string" ? args.length : safeJsonStringify(args).length;
|
|
82
|
+
return sum + (part.name?.length ?? 0) + argLength;
|
|
83
|
+
}
|
|
84
|
+
case "toolResult": {
|
|
85
|
+
const c = part.content;
|
|
86
|
+
return sum + (typeof c === "string" ? c.length : safeJsonStringify(c).length);
|
|
87
|
+
}
|
|
88
|
+
case "image":
|
|
89
|
+
return sum + IMAGE_CONTENT_CHARS;
|
|
90
|
+
default:
|
|
91
|
+
// Unknown part: fall back to any text field so we never undercount.
|
|
92
|
+
return sum + (typeof part.text === "string" ? part.text.length : 0);
|
|
93
|
+
}
|
|
94
|
+
}, 0);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const estimateMessageContentTokens = (
|
|
98
|
+
content: unknown,
|
|
99
|
+
charsPerToken = DEFAULT_CHARS_PER_TOKEN,
|
|
100
|
+
): number => estimateTokensFromChars(estimateMessageContentChars(content), charsPerToken);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const PATH_KEYS = ["path", "file_path", "filePath", "file"] as const;
|
|
2
|
+
|
|
3
|
+
export const extractPath = (args: Record<string, unknown>): string | null => {
|
|
4
|
+
for (const key of ["path", "file_path", "filePath", "file"]) {
|
|
5
|
+
if (typeof args[key] === "string") return args[key] as string;
|
|
6
|
+
}
|
|
7
|
+
return null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const summarizeToolArgs = (args: Record<string, unknown>): string => {
|
|
11
|
+
const path = extractPath(args);
|
|
12
|
+
if (path) return `path=${path}`;
|
|
13
|
+
if (typeof args.command === "string") return `command=${args.command}`;
|
|
14
|
+
if (typeof args.query === "string") return `query=${args.query}`;
|
|
15
|
+
return Object.keys(args).join(", ");
|
|
16
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CompactionReason } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface PiVccCompactionDetails {
|
|
4
|
+
compactor: "pi-vcc";
|
|
5
|
+
version: number;
|
|
6
|
+
sections: string[];
|
|
7
|
+
sourceMessageCount: number;
|
|
8
|
+
previousSummaryUsed: boolean;
|
|
9
|
+
reason?: CompactionReason;
|
|
10
|
+
willRetry?: boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { NormalizedBlock } from "../types";
|
|
2
|
+
|
|
3
|
+
interface CommitInfo {
|
|
4
|
+
hash?: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const COMMIT_MSG_RE = /git\s+commit[^\n]*?-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\$?'((?:[^'\\]|\\.)*)')/;
|
|
9
|
+
// Match short hash from git output: "[branch hash]" or "main hash" or 7-12 hex
|
|
10
|
+
const HASH_RE = /\b([0-9a-f]{7,12})\b/;
|
|
11
|
+
|
|
12
|
+
const cleanMessage = (msg: string): string =>
|
|
13
|
+
msg.replace(/\\"/g, '"').replace(/\\'/g, "'").trim();
|
|
14
|
+
|
|
15
|
+
const linesOf = (text: string): string[] => text.split(/\\n|\n/);
|
|
16
|
+
|
|
17
|
+
const firstLineOf = (text: string): string => {
|
|
18
|
+
const line = linesOf(text)[0] ?? "";
|
|
19
|
+
return line.trim();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// COMMIT_MSG_RE only yields the `$(cat <<...` opener (truncated at the inner quote for <<"DELIM"), so heredoc -m is re-read from the raw command.
|
|
23
|
+
const HEREDOC_COMMIT_CMD_RE =
|
|
24
|
+
/^git\s+commit[^\n"'&;|]*?-m\s+"\$\(cat\s+<<-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_]\w*))(?:\r?\n|\\n)/;
|
|
25
|
+
|
|
26
|
+
const firstHeredocBodyLine = (rest: string, delim: string): string => {
|
|
27
|
+
for (const line of linesOf(rest)) {
|
|
28
|
+
const trimmed = line.replace(/^\t+/, "").trim();
|
|
29
|
+
if (trimmed === delim) break;
|
|
30
|
+
if (trimmed) return trimmed;
|
|
31
|
+
}
|
|
32
|
+
return "";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const subjectFromHeredocCmd = (cmd: string): string | null => {
|
|
36
|
+
const m = cmd.match(HEREDOC_COMMIT_CMD_RE);
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
const delim = m[1] ?? m[2] ?? m[3] ?? "";
|
|
39
|
+
return cleanMessage(firstHeredocBodyLine(cmd.slice(m.index! + m[0].length), delim));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract git commits from bash tool calls (`git commit -m "..."`) and pair
|
|
44
|
+
* with hash from the immediately following tool_result.
|
|
45
|
+
*/
|
|
46
|
+
export const extractCommits = (blocks: NormalizedBlock[]): CommitInfo[] => {
|
|
47
|
+
const commits: CommitInfo[] = [];
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
50
|
+
const b = blocks[i];
|
|
51
|
+
if (b.kind !== "tool_call" || b.name !== "bash") continue;
|
|
52
|
+
const cmd = typeof b.args.command === "string" ? b.args.command : "";
|
|
53
|
+
if (!/\bgit\s+commit\b/.test(cmd)) continue;
|
|
54
|
+
const m = cmd.match(COMMIT_MSG_RE);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
// First -m is `"$(cat <<DELIM ...`: COMMIT_MSG_RE only sees `$(cat <<...`, so read the heredoc body.
|
|
57
|
+
const heredoc = m[1] !== undefined && /^\$\(cat\s+<</.test(m[1])
|
|
58
|
+
? subjectFromHeredocCmd(cmd.slice(m.index!))
|
|
59
|
+
: null;
|
|
60
|
+
const message = heredoc ?? firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
|
|
61
|
+
if (heredoc === null && /^\$\(cat\s+<</.test(message)) continue;
|
|
62
|
+
if (!message) continue;
|
|
63
|
+
|
|
64
|
+
let hash: string | undefined;
|
|
65
|
+
// Look at next tool_result for hash
|
|
66
|
+
for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
|
|
67
|
+
const r = blocks[j];
|
|
68
|
+
if (r.kind !== "tool_result") continue;
|
|
69
|
+
// Common git commit output: `[branch <hash>] message` or `<branch> <hash>..<hash>`
|
|
70
|
+
const bracket = r.text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
|
|
71
|
+
if (bracket) { hash = bracket[1]; break; }
|
|
72
|
+
const range = r.text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
|
|
73
|
+
if (range) { hash = range[2]; break; }
|
|
74
|
+
const plain = r.text.match(HASH_RE);
|
|
75
|
+
if (plain) { hash = plain[1]; break; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Dedup by message+hash
|
|
79
|
+
const key = `${hash ?? ""}::${message}`;
|
|
80
|
+
if (!commits.some((c) => `${c.hash ?? ""}::${c.message}` === key)) {
|
|
81
|
+
commits.push({ hash, message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return commits;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const formatCommits = (commits: CommitInfo[], limit = 8): string[] => {
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
const items = commits.slice(-limit); // keep most recent
|
|
91
|
+
for (const c of items) {
|
|
92
|
+
const prefix = c.hash ? `${c.hash}: ` : "";
|
|
93
|
+
lines.push(`${prefix}${c.message}`);
|
|
94
|
+
}
|
|
95
|
+
return lines;
|
|
96
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { FileOps, NormalizedBlock } from "../types";
|
|
2
|
+
import { extractPath } from "../core/tool-args";
|
|
3
|
+
|
|
4
|
+
interface FileActivity {
|
|
5
|
+
read: Set<string>;
|
|
6
|
+
modified: Set<string>;
|
|
7
|
+
created: Set<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Tool names are matched case-insensitively (see `matches`), mirroring the /i
|
|
11
|
+
// regexes in core/rank.ts. Entries must be lowercase.
|
|
12
|
+
const FILE_READ_TOOLS = new Set([
|
|
13
|
+
"read", "read_file", "view",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// Multi-file patch tools (apply_patch) carry their paths inside the diff payload,
|
|
17
|
+
// not in a path arg, so extractPath yields nothing for them; those files are
|
|
18
|
+
// recovered from the hook-provided fileOps below.
|
|
19
|
+
const FILE_WRITE_TOOLS = new Set([
|
|
20
|
+
"edit", "write", "edit_file", "write_file",
|
|
21
|
+
"multiedit", "quick_edit", "target_edit", "apply_patch",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const FILE_CREATE_TOOLS = new Set([
|
|
25
|
+
"write", "write_file",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const matches = (tools: Set<string>, name: string): boolean => tools.has(name.toLowerCase());
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Find the longest common directory prefix among absolute paths.
|
|
32
|
+
* Returns "" if fewer than 2 absolute paths or no meaningful common prefix.
|
|
33
|
+
*/
|
|
34
|
+
const longestCommonDirPrefix = (paths: string[]): string => {
|
|
35
|
+
const abs = paths.filter((p) => p.startsWith("/"));
|
|
36
|
+
if (abs.length < 2) return "";
|
|
37
|
+
const split = abs.map((p) => p.split("/"));
|
|
38
|
+
const min = Math.min(...split.map((s) => s.length));
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < min - 1) {
|
|
41
|
+
const seg = split[0][i];
|
|
42
|
+
if (!split.every((s) => s[i] === seg)) break;
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
if (i < 2) return ""; // require at least /a/b common
|
|
46
|
+
return split[0].slice(0, i).join("/") + "/";
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const trimPaths = (set: Set<string>, prefix: string): Set<string> => {
|
|
50
|
+
if (!prefix) return set;
|
|
51
|
+
const out = new Set<string>();
|
|
52
|
+
for (const p of set) {
|
|
53
|
+
out.add(p.startsWith(prefix) ? p.slice(prefix.length) : p);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const extractFiles = (
|
|
59
|
+
blocks: NormalizedBlock[],
|
|
60
|
+
fileOps?: FileOps,
|
|
61
|
+
): FileActivity => {
|
|
62
|
+
const act: FileActivity = {
|
|
63
|
+
read: new Set(fileOps?.readFiles ?? []),
|
|
64
|
+
modified: new Set(fileOps?.modifiedFiles ?? []),
|
|
65
|
+
created: new Set(fileOps?.createdFiles ?? []),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (const b of blocks) {
|
|
69
|
+
if (b.kind !== "tool_call") continue;
|
|
70
|
+
const p = extractPath(b.args);
|
|
71
|
+
if (!p) continue;
|
|
72
|
+
|
|
73
|
+
if (matches(FILE_READ_TOOLS, b.name)) act.read.add(p);
|
|
74
|
+
if (matches(FILE_WRITE_TOOLS, b.name)) act.modified.add(p);
|
|
75
|
+
if (matches(FILE_CREATE_TOOLS, b.name)) act.created.add(p);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const all = [...act.read, ...act.modified, ...act.created];
|
|
79
|
+
const prefix = longestCommonDirPrefix(all);
|
|
80
|
+
if (prefix) {
|
|
81
|
+
act.read = trimPaths(act.read, prefix);
|
|
82
|
+
act.modified = trimPaths(act.modified, prefix);
|
|
83
|
+
act.created = trimPaths(act.created, prefix);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return act;
|
|
87
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { NormalizedBlock } from "../types";
|
|
2
|
+
import { nonEmptyLines, clip } from "../core/content";
|
|
3
|
+
import { collapseSkillLines } from "../core/skill-collapse";
|
|
4
|
+
|
|
5
|
+
const SCOPE_CHANGE_RE =
|
|
6
|
+
/\b(instead|actually|change of plan|forget that|new task|switch to|now I want|pivot|let'?s do|stop .* and)\b/i;
|
|
7
|
+
|
|
8
|
+
const TASK_RE =
|
|
9
|
+
/\b(fix|implement|add|create|build|refactor|debug|investigate|update|remove|delete|migrate|deploy|test|write|set up)\b/i;
|
|
10
|
+
|
|
11
|
+
const NOISE_SHORT_RE = /^(ok|yes|no|sure|yeah|yep|go|hi|hey|thx|thanks|ok\b.*|y|n|k)\s*[.!?]*$/i;
|
|
12
|
+
|
|
13
|
+
// Reject lines that are clearly not user goals (pasted output, code, paths, tool dumps)
|
|
14
|
+
// or meta-prompt boilerplate (command templates like `/issues` that start with "For each issue:"
|
|
15
|
+
// followed by numbered "Read the issue in full..." steps).
|
|
16
|
+
const NON_GOAL_RE =
|
|
17
|
+
/^\s*[\[│├└─╭╰]|```|^\s*(=[A-Z]+\(|function |const |let |var |import |export |class )|^(https?:|file:|\/[A-Za-z])|\\n|^\s*For each\b|\bin full\b[^\n]*\b(comments|issue|issues|PRs?|linked)\b/;
|
|
18
|
+
|
|
19
|
+
// Signals that the rest of the user message is a command template (e.g. /issues),
|
|
20
|
+
// in which case we should stop collecting goals at the signal line.
|
|
21
|
+
const TEMPLATE_SIGNAL_RE =
|
|
22
|
+
/^\s*(For each\b|Do NOT implement\b|Analyze and propose\b|If Task\/context\b|Output:\s*$)/i;
|
|
23
|
+
|
|
24
|
+
const truncateAtTemplate = (lines: string[]): string[] => {
|
|
25
|
+
const idx = lines.findIndex((l) => TEMPLATE_SIGNAL_RE.test(l));
|
|
26
|
+
return idx >= 0 ? lines.slice(0, idx) : lines;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const stripLeadingBullet = (line: string): string =>
|
|
30
|
+
line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").trim();
|
|
31
|
+
|
|
32
|
+
const MAX_GOAL_CHARS = 200;
|
|
33
|
+
|
|
34
|
+
const isSubstantiveGoal = (text: string): boolean => {
|
|
35
|
+
const t = text.trim();
|
|
36
|
+
if (t.length <= 5) return false;
|
|
37
|
+
if (t.length > MAX_GOAL_CHARS) return false;
|
|
38
|
+
if (NOISE_SHORT_RE.test(t)) return false;
|
|
39
|
+
if (NON_GOAL_RE.test(t)) return false;
|
|
40
|
+
return true;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Test scope-change / task intent only on the leading portion of a user block
|
|
44
|
+
// so that pasted outputs below the actual instruction do not trigger matches.
|
|
45
|
+
const LEADING_CHARS = 200;
|
|
46
|
+
|
|
47
|
+
export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
|
|
48
|
+
const goals: string[] = [];
|
|
49
|
+
let latestScopeChange: string[] | null = null;
|
|
50
|
+
|
|
51
|
+
for (const b of blocks) {
|
|
52
|
+
if (b.kind !== "user") continue;
|
|
53
|
+
const rawLines = nonEmptyLines(b.text);
|
|
54
|
+
const truncated = truncateAtTemplate(rawLines);
|
|
55
|
+
const lines = collapseSkillLines(truncated.filter(isSubstantiveGoal))
|
|
56
|
+
.map(stripLeadingBullet)
|
|
57
|
+
.filter((l) => l.length > 5);
|
|
58
|
+
if (lines.length === 0) continue;
|
|
59
|
+
|
|
60
|
+
if (goals.length === 0) {
|
|
61
|
+
goals.push(...lines.slice(0, 6));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const leading = b.text.slice(0, LEADING_CHARS);
|
|
66
|
+
if (SCOPE_CHANGE_RE.test(leading)) {
|
|
67
|
+
latestScopeChange = lines.slice(0, 3).map((l) => clip(l, MAX_GOAL_CHARS));
|
|
68
|
+
} else if (TASK_RE.test(leading) && lines[0].length > 15) {
|
|
69
|
+
latestScopeChange = lines.slice(0, 2).map((l) => clip(l, MAX_GOAL_CHARS));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only emit the [Scope change] marker when we actually captured bullets.
|
|
74
|
+
if (latestScopeChange && latestScopeChange.length > 0) {
|
|
75
|
+
goals.push("[Scope change]", ...latestScopeChange);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return goals.slice(0, 8);
|
|
79
|
+
};
|