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,284 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import type { NormalizedBlock, FileOps } from "../types";
|
|
3
|
+
import { extractPath } from "./tool-args";
|
|
4
|
+
import { compileBrief, heredocCloseIndex } from "./brief";
|
|
5
|
+
|
|
6
|
+
export interface BriefRankingOptions {
|
|
7
|
+
/** Maximum normalized blocks used to build the brief transcript. */
|
|
8
|
+
maxBlocks?: number;
|
|
9
|
+
/** Always keep this many latest blocks to preserve local continuity. */
|
|
10
|
+
preserveRecentBlocks?: number;
|
|
11
|
+
/** Hook-provided file activity, used as structural signal instead of prose guessing. */
|
|
12
|
+
fileOps?: FileOps;
|
|
13
|
+
/**
|
|
14
|
+
* Optional size budget (in characters of rendered brief) for the selected
|
|
15
|
+
* blocks. When set, this is the PRIMARY limit: blocks are added by score
|
|
16
|
+
* until the budget is reached, and lower-value blocks are skipped rather
|
|
17
|
+
* than truncating the tail. maxBlocks still applies as a safety upper bound.
|
|
18
|
+
* Callers derive this from a token budget via charsPerToken.
|
|
19
|
+
* When maxBriefCharsCeiling + briefCharsPerBlock are also set, this acts as
|
|
20
|
+
* the FLOOR of a size-relative budget (see below).
|
|
21
|
+
*/
|
|
22
|
+
maxBriefChars?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Optional upper bound for a size-relative char budget. When set together
|
|
25
|
+
* with maxBriefChars (floor) and briefCharsPerBlock (slope), the effective
|
|
26
|
+
* budget becomes clamp(briefCharsPerBlock * blockCount, maxBriefChars,
|
|
27
|
+
* maxBriefCharsCeiling): larger transcripts (which carry more high-value
|
|
28
|
+
* long-tail -- edits, commands, tests) get more brief budget, while small
|
|
29
|
+
* sessions stay at the floor and this hard ceiling prevents unbounded growth.
|
|
30
|
+
*/
|
|
31
|
+
maxBriefCharsCeiling?: number;
|
|
32
|
+
/** Per-block slope (chars) for the size-relative budget. Requires the ceiling. */
|
|
33
|
+
briefCharsPerBlock?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RankedBlock {
|
|
37
|
+
block: NormalizedBlock;
|
|
38
|
+
index: number;
|
|
39
|
+
score: number;
|
|
40
|
+
reasons: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_MAX_BLOCKS = 80;
|
|
44
|
+
const DEFAULT_RECENT_BLOCKS = 16;
|
|
45
|
+
|
|
46
|
+
const EDIT_TOOL_RE = /^(edit|write|multiedit|quick_edit|target_edit|apply_patch)$/i;
|
|
47
|
+
const READ_TOOL_RE = /^(read|glob|grep|ls|find|semantic_query|semantic_grep|semantic_show)$/i;
|
|
48
|
+
const TEST_COMMAND_RE = /\b(?:bun|npm|pnpm|yarn|node|pytest|cargo|go|mvn|gradle)\b[^\n]*(?:test|spec|check|lint|build|tsc)/i;
|
|
49
|
+
const GH_PR_POLL_RE = /(?:^|\s)gh\s+pr\s+(?:view|checks)\s+(\d+)\b/i;
|
|
50
|
+
// Durable workflow facts: which PR/issue was acted on, and git state changes.
|
|
51
|
+
// Structural (command shape), not prose — same spirit as TEST_COMMAND_RE.
|
|
52
|
+
const WORKFLOW_COMMAND_RE =
|
|
53
|
+
/(?:^|\s)(?:gh\s+(?:pr|issue)\s+[a-z-]+|git\s+(?:commit|push|merge|rebase|revert|cherry-pick|tag|reset|checkout|branch)\b)/i;
|
|
54
|
+
const MIN_SEGMENT_CLOSING_ASSISTANT_CHARS = 120;
|
|
55
|
+
|
|
56
|
+
// A bash block whose every meaningful line is pure scaffolding (set -e, cd,
|
|
57
|
+
// export, ls, echo, sleep, pwd, comments, heredoc bodies) carries no durable
|
|
58
|
+
// fact. Such blocks get a score penalty so the size-relative budget never
|
|
59
|
+
// pulls them in ahead of real edits/commands when spare chars appear.
|
|
60
|
+
const TRIVIAL_BASH_LINE_RE =
|
|
61
|
+
/^(?:set\s+[-+]|cd(?:\s+\S+)?$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#|ls(?:\s|$)|echo\b|clear$|sleep\b)/;
|
|
62
|
+
const TRIVIAL_BASH_PENALTY = 16;
|
|
63
|
+
|
|
64
|
+
const isTrivialOnlyBash = (raw: string): boolean => {
|
|
65
|
+
const lines = raw.split("\n");
|
|
66
|
+
const kept: string[] = [];
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
kept.push(lines[i]);
|
|
69
|
+
// Skip heredoc bodies (they are content, not scaffolding) using the same
|
|
70
|
+
// hardened detection as brief.ts: only skip when the terminator exists
|
|
71
|
+
// downstream, so a stray `<<` never swallows a later real command.
|
|
72
|
+
const close = heredocCloseIndex(lines, i);
|
|
73
|
+
if (close !== -1) i = close;
|
|
74
|
+
}
|
|
75
|
+
const meaningful = kept.map((l) => l.trim()).filter(Boolean).filter((l) => !TRIVIAL_BASH_LINE_RE.test(l));
|
|
76
|
+
return meaningful.length === 0;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const asPathSet = (paths?: string[]): Set<string> => new Set((paths ?? []).filter(Boolean));
|
|
80
|
+
|
|
81
|
+
const bashCommandFromBlock = (block: NormalizedBlock): string | undefined => {
|
|
82
|
+
if (block.kind === "bash") return block.command;
|
|
83
|
+
if (block.kind === "tool_call" && /^bash$/i.test(block.name) && typeof block.args.command === "string") {
|
|
84
|
+
return block.args.command;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const pathFromBlock = (block: NormalizedBlock): string | undefined => {
|
|
90
|
+
if (block.kind === "tool_call") return extractPath(block.args) ?? undefined;
|
|
91
|
+
if (block.kind === "bash") {
|
|
92
|
+
const match = block.command.match(/(?:^|\s)([\w./-]+\.[\w-]+)(?:\s|$)/);
|
|
93
|
+
return match?.[1];
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const add = (ranked: RankedBlock, points: number, reason: string) => {
|
|
99
|
+
ranked.score += points;
|
|
100
|
+
ranked.reasons.push(reason);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const scoreBlock = (
|
|
104
|
+
block: NormalizedBlock,
|
|
105
|
+
index: number,
|
|
106
|
+
total: number,
|
|
107
|
+
modifiedFiles: Set<string>,
|
|
108
|
+
readFiles: Set<string>,
|
|
109
|
+
): RankedBlock => {
|
|
110
|
+
const ranked: RankedBlock = { block, index, score: 0, reasons: [] };
|
|
111
|
+
const recency = total <= 1 ? 0 : Math.round((index / (total - 1)) * 12);
|
|
112
|
+
add(ranked, recency, "recency");
|
|
113
|
+
|
|
114
|
+
if (block.kind === "user") add(ranked, 18, "user-turn");
|
|
115
|
+
if (block.kind === "assistant") add(ranked, 10, "assistant-context");
|
|
116
|
+
if (block.kind === "tool_result") add(ranked, 1, "tool-result-low-value");
|
|
117
|
+
|
|
118
|
+
if (block.kind === "tool_call") {
|
|
119
|
+
const command = bashCommandFromBlock(block);
|
|
120
|
+
if (EDIT_TOOL_RE.test(block.name)) add(ranked, 34, "edit-tool");
|
|
121
|
+
else if (command && TEST_COMMAND_RE.test(command)) add(ranked, 26, "test-command");
|
|
122
|
+
else if (READ_TOOL_RE.test(block.name)) add(ranked, 6, "read-tool");
|
|
123
|
+
else add(ranked, 12, "tool-call");
|
|
124
|
+
if (command && WORKFLOW_COMMAND_RE.test(command)) add(ranked, 14, "workflow-command");
|
|
125
|
+
if (command && isTrivialOnlyBash(command)) add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (block.kind === "bash") {
|
|
129
|
+
add(ranked, 8, "bash");
|
|
130
|
+
if (block.exitCode != null && block.exitCode !== 0) add(ranked, 24, "nonzero-exit");
|
|
131
|
+
if (TEST_COMMAND_RE.test(block.command)) add(ranked, 22, "test-command");
|
|
132
|
+
if (WORKFLOW_COMMAND_RE.test(block.command)) add(ranked, 14, "workflow-command");
|
|
133
|
+
// Penalize scaffolding-only commands, but not ones that failed (a failed
|
|
134
|
+
// `ls`/`cd` still records a real state fact via nonzero-exit).
|
|
135
|
+
if (isTrivialOnlyBash(block.command) && !(block.exitCode != null && block.exitCode !== 0)) {
|
|
136
|
+
add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const path = pathFromBlock(block);
|
|
141
|
+
if (path) {
|
|
142
|
+
if (modifiedFiles.has(path)) add(ranked, 18, "hook-modified-file");
|
|
143
|
+
if (readFiles.has(path)) add(ranked, 6, "hook-read-file");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (block.kind === "tool_result" && block.text.length > 1000) add(ranked, -8, "long-tool-result");
|
|
147
|
+
return ranked;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const boostAdjacency = (ranked: RankedBlock[]) => {
|
|
151
|
+
const important = ranked
|
|
152
|
+
.filter((r) => r.score >= 34 || r.reasons.includes("edit-tool") || r.reasons.includes("test-command") || r.reasons.includes("nonzero-exit"))
|
|
153
|
+
.map((r) => r.index);
|
|
154
|
+
|
|
155
|
+
for (const idx of important) {
|
|
156
|
+
for (let i = idx - 1; i >= Math.max(0, idx - 8); i--) {
|
|
157
|
+
if (ranked[i].block.kind === "user") {
|
|
158
|
+
add(ranked[i], 10, "near-important-event");
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (let i = idx - 1; i >= Math.max(0, idx - 4); i--) {
|
|
163
|
+
if (ranked[i].block.kind === "assistant") {
|
|
164
|
+
add(ranked[i], 7, "near-important-event");
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (let i = idx + 1; i <= Math.min(ranked.length - 1, idx + 4); i++) {
|
|
169
|
+
if (ranked[i].block.kind === "assistant" || ranked[i].block.kind === "bash") {
|
|
170
|
+
add(ranked[i], 5, "after-important-event");
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const nextNonToolResult = (ranked: RankedBlock[], index: number): NormalizedBlock | undefined => {
|
|
178
|
+
for (let i = index + 1; i < ranked.length; i++) {
|
|
179
|
+
if (ranked[i].block.kind !== "tool_result") return ranked[i].block;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const boostSegmentClosingAssistants = (ranked: RankedBlock[]) => {
|
|
185
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
186
|
+
const current = ranked[i];
|
|
187
|
+
if (current.block.kind !== "assistant") continue;
|
|
188
|
+
if (current.block.text.trim().length < MIN_SEGMENT_CLOSING_ASSISTANT_CHARS) continue;
|
|
189
|
+
const next = nextNonToolResult(ranked, i);
|
|
190
|
+
if (!next || next.kind === "user") {
|
|
191
|
+
add(current, 14, "segment-closing-assistant");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const dedupKey = (block: NormalizedBlock): string | undefined => {
|
|
197
|
+
const command = bashCommandFromBlock(block);
|
|
198
|
+
const ghPrPoll = command?.match(GH_PR_POLL_RE);
|
|
199
|
+
if (ghPrPoll) return `gh-pr-poll:${ghPrPoll[1]}`;
|
|
200
|
+
if (command) {
|
|
201
|
+
const normalized = command.replace(/\s+/g, " ").trim();
|
|
202
|
+
return normalized ? `bash:${normalized}` : undefined;
|
|
203
|
+
}
|
|
204
|
+
if (block.kind === "tool_call") {
|
|
205
|
+
const path = pathFromBlock(block);
|
|
206
|
+
return path ? `tool:${block.name.toLowerCase()}:${path}` : undefined;
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const rankBriefBlocks = (blocks: NormalizedBlock[], options: BriefRankingOptions = {}): RankedBlock[] => {
|
|
212
|
+
const modifiedFiles = asPathSet(options.fileOps?.modifiedFiles);
|
|
213
|
+
const readFiles = asPathSet(options.fileOps?.readFiles);
|
|
214
|
+
const ranked = blocks.map((block, index) => scoreBlock(block, index, blocks.length, modifiedFiles, readFiles));
|
|
215
|
+
boostAdjacency(ranked);
|
|
216
|
+
boostSegmentClosingAssistants(ranked);
|
|
217
|
+
return ranked;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const selectRankedBriefBlocks = (
|
|
221
|
+
blocks: NormalizedBlock[],
|
|
222
|
+
options: BriefRankingOptions = {},
|
|
223
|
+
): NormalizedBlock[] => {
|
|
224
|
+
const maxBlocks = options.maxBlocks ?? DEFAULT_MAX_BLOCKS;
|
|
225
|
+
// Size-relative budget: when a ceiling + slope are provided, the effective
|
|
226
|
+
// char budget scales with transcript length (block count) between the floor
|
|
227
|
+
// (maxBriefChars) and the ceiling. Larger transcripts carry more high-value
|
|
228
|
+
// long-tail, so they earn more brief budget; small sessions stay at the floor.
|
|
229
|
+
const maxBriefChars =
|
|
230
|
+
options.maxBriefChars != null && options.maxBriefCharsCeiling != null && options.briefCharsPerBlock != null
|
|
231
|
+
? Math.round(
|
|
232
|
+
Math.min(
|
|
233
|
+
options.maxBriefCharsCeiling,
|
|
234
|
+
Math.max(options.maxBriefChars, options.briefCharsPerBlock * blocks.length),
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
: options.maxBriefChars;
|
|
238
|
+
// Fast path: nothing to trim by count and no char budget to enforce.
|
|
239
|
+
if (blocks.length <= maxBlocks && maxBriefChars == null) return blocks;
|
|
240
|
+
|
|
241
|
+
const preserveRecentBlocks = Math.min(options.preserveRecentBlocks ?? DEFAULT_RECENT_BLOCKS, maxBlocks);
|
|
242
|
+
const ranked = rankBriefBlocks(blocks, options);
|
|
243
|
+
const selected = new Set<number>();
|
|
244
|
+
const seenKeys = new Set<string>();
|
|
245
|
+
|
|
246
|
+
// Per-block rendered size, only computed when a char budget is active.
|
|
247
|
+
const costs = maxBriefChars == null
|
|
248
|
+
? null
|
|
249
|
+
: blocks.map((b) => (b.kind === "tool_result" ? 0 : compileBrief([b]).length + 1));
|
|
250
|
+
let usedChars = 0;
|
|
251
|
+
|
|
252
|
+
// Keep the latest blocks to preserve local continuity, iterating NEWEST first
|
|
253
|
+
// so the most recent context is guaranteed. When a char budget is active these
|
|
254
|
+
// are charged against it too and over-budget blocks are skipped -- otherwise a
|
|
255
|
+
// run of large recent blocks could blow past maxBriefChars (the old bug on very
|
|
256
|
+
// long transcripts, where preserve-recent alone exceeded the budget).
|
|
257
|
+
for (let i = blocks.length - 1; i >= Math.max(0, blocks.length - preserveRecentBlocks); i--) {
|
|
258
|
+
if (blocks[i].kind === "tool_result") continue;
|
|
259
|
+
if (selected.has(i)) continue;
|
|
260
|
+
if (costs && usedChars + costs[i] > maxBriefChars!) continue;
|
|
261
|
+
selected.add(i);
|
|
262
|
+
if (costs) usedChars += costs[i];
|
|
263
|
+
const key = dedupKey(blocks[i]);
|
|
264
|
+
if (key) seenKeys.add(key);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const ordered = [...ranked].sort((a, b) => b.score - a.score || b.index - a.index);
|
|
268
|
+
for (const item of ordered) {
|
|
269
|
+
if (selected.size >= maxBlocks) break;
|
|
270
|
+
if (selected.has(item.index)) continue;
|
|
271
|
+
if (item.block.kind === "tool_result") continue;
|
|
272
|
+
const key = dedupKey(item.block);
|
|
273
|
+
if (key && seenKeys.has(key)) continue;
|
|
274
|
+
if (costs) {
|
|
275
|
+
// Skip (not break) so smaller high-value blocks can still fit the budget.
|
|
276
|
+
if (usedChars + costs[item.index] > maxBriefChars!) continue;
|
|
277
|
+
usedChars += costs[item.index];
|
|
278
|
+
}
|
|
279
|
+
selected.add(item.index);
|
|
280
|
+
if (key) seenKeys.add(key);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return [...selected].sort((a, b) => a - b).map((i) => blocks[i]);
|
|
284
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
export type RecallScope = "lineage" | "all";
|
|
3
|
+
export type RecallMode = "hybrid" | "touched";
|
|
4
|
+
|
|
5
|
+
const SCOPE_RE = /\bscope:(lineage|all)\b/i;
|
|
6
|
+
|
|
7
|
+
const VALID_MODES = new Set(["hybrid", "touched"]);
|
|
8
|
+
|
|
9
|
+
export const normalizeRecallScope = (scope?: unknown): RecallScope =>
|
|
10
|
+
typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalize a mode param to a supported recall mode. Without OM integration,
|
|
14
|
+
* only "touched" adds behavior beyond the default hybrid search — "file"-only
|
|
15
|
+
* search is not implemented in pi-vcc, so it is not exposed.
|
|
16
|
+
*
|
|
17
|
+
* Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
|
|
18
|
+
* k0valik — a pi-vcc derivative.
|
|
19
|
+
*/
|
|
20
|
+
export const normalizeRecallMode = (mode?: unknown): RecallMode =>
|
|
21
|
+
typeof mode === "string" && VALID_MODES.has(mode.toLowerCase())
|
|
22
|
+
? (mode.toLowerCase() as RecallMode)
|
|
23
|
+
: "hybrid";
|
|
24
|
+
|
|
25
|
+
export const parseRecallScope = (text: string): { scope: RecallScope; text: string } => {
|
|
26
|
+
const match = text.match(SCOPE_RE);
|
|
27
|
+
return {
|
|
28
|
+
scope: normalizeRecallScope(match?.[1]),
|
|
29
|
+
text: text.replace(SCOPE_RE, "").replace(/\s+/g, " ").trim(),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import type { Message } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import { clip, textOf } from "./content";
|
|
4
|
+
import { summarizeToolArgs } from "./tool-args";
|
|
5
|
+
import { extractPath } from "./tool-args";
|
|
6
|
+
|
|
7
|
+
export interface RenderedEntry {
|
|
8
|
+
index: number;
|
|
9
|
+
role: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
files?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const toolCalls = (content: Message["content"]): string => {
|
|
15
|
+
if (!content || typeof content === "string") return "";
|
|
16
|
+
return content
|
|
17
|
+
.filter((c) => c.type === "toolCall")
|
|
18
|
+
.map((c) => `${c.name}(${summarizeToolArgs(c.arguments)})`)
|
|
19
|
+
.join(", ");
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const extractFilesFromContent = (content: Message["content"]): string[] => {
|
|
23
|
+
if (!content || typeof content === "string") return [];
|
|
24
|
+
return content
|
|
25
|
+
.filter((c) => c.type === "toolCall")
|
|
26
|
+
.map((c) => extractPath(c.arguments))
|
|
27
|
+
.filter((p): p is string => p !== null);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const renderMessage = (msg: Message, index: number, full = false): RenderedEntry => {
|
|
31
|
+
if (msg.role === "user") {
|
|
32
|
+
return { index, role: "user", summary: full ? textOf(msg.content) : clip(textOf(msg.content), 300) };
|
|
33
|
+
}
|
|
34
|
+
if (msg.role === "toolResult") {
|
|
35
|
+
const text = full ? textOf(msg.content) : clip(textOf(msg.content), 200);
|
|
36
|
+
return {
|
|
37
|
+
index, role: "tool_result",
|
|
38
|
+
summary: `[${msg.toolName}] ${text}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// bashExecution has command+output instead of content
|
|
42
|
+
if ((msg as any).role === "bashExecution") {
|
|
43
|
+
const cmd = (msg as any).command ?? "";
|
|
44
|
+
const out = (msg as any).output ?? "";
|
|
45
|
+
const text = full ? `$ ${cmd}\n${out}` : clip(`$ ${cmd}\n${out}`, 300);
|
|
46
|
+
return { index, role: "bash", summary: text };
|
|
47
|
+
}
|
|
48
|
+
const text = full ? textOf(msg.content) : clip(textOf(msg.content), 300);
|
|
49
|
+
const tools = toolCalls(msg.content);
|
|
50
|
+
const files = extractFilesFromContent(msg.content);
|
|
51
|
+
const summary = tools ? `${tools}\n${text}` : text;
|
|
52
|
+
return { index, role: "assistant", summary, ...(files.length > 0 && { files }) };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import type { Message } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import { buildSections } from "./build-sections";
|
|
4
|
+
import { clip } from "./content";
|
|
5
|
+
import { normalize } from "./normalize";
|
|
6
|
+
import { renderMessage } from "./render-entries";
|
|
7
|
+
import { searchEntries } from "./search-entries";
|
|
8
|
+
import { type CompileInput, compile } from "./summarize";
|
|
9
|
+
import { estimateTokensFromChars } from "./token-estimate";
|
|
10
|
+
|
|
11
|
+
const SECTION_HEADERS = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context"];
|
|
12
|
+
|
|
13
|
+
interface RoleCounts {
|
|
14
|
+
user: number;
|
|
15
|
+
assistant: number;
|
|
16
|
+
toolResult: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface BlockCounts {
|
|
20
|
+
user: number;
|
|
21
|
+
assistant: number;
|
|
22
|
+
toolCalls: number;
|
|
23
|
+
toolResults: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RecallProbe {
|
|
27
|
+
label: string;
|
|
28
|
+
sourceText: string;
|
|
29
|
+
query: string;
|
|
30
|
+
summaryMentioned: boolean;
|
|
31
|
+
recallHits: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CompactReport {
|
|
35
|
+
summary: string;
|
|
36
|
+
before: {
|
|
37
|
+
messageCount: number;
|
|
38
|
+
roleCounts: RoleCounts;
|
|
39
|
+
blockCounts: BlockCounts;
|
|
40
|
+
inputChars: number;
|
|
41
|
+
estimatedTokens: number;
|
|
42
|
+
topFiles: string[];
|
|
43
|
+
preview: string;
|
|
44
|
+
};
|
|
45
|
+
after: {
|
|
46
|
+
summaryLength: number;
|
|
47
|
+
estimatedTokens: number;
|
|
48
|
+
sectionCount: number;
|
|
49
|
+
summaryPreview: string;
|
|
50
|
+
goalsCount: number;
|
|
51
|
+
blockersCount: number;
|
|
52
|
+
briefTranscriptLines: number;
|
|
53
|
+
};
|
|
54
|
+
compression: {
|
|
55
|
+
charsBefore: number;
|
|
56
|
+
charsAfter: number;
|
|
57
|
+
ratio: number;
|
|
58
|
+
messagesBefore: number;
|
|
59
|
+
};
|
|
60
|
+
recall: {
|
|
61
|
+
probes: RecallProbe[];
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const countRoles = (messages: Message[]): RoleCounts => {
|
|
66
|
+
const counts: RoleCounts = { user: 0, assistant: 0, toolResult: 0 };
|
|
67
|
+
for (const msg of messages) {
|
|
68
|
+
if (msg.role === "user") counts.user += 1;
|
|
69
|
+
else if (msg.role === "assistant") counts.assistant += 1;
|
|
70
|
+
else if (msg.role === "toolResult") counts.toolResult += 1;
|
|
71
|
+
}
|
|
72
|
+
return counts;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const countBlocks = (messages: Message[]): BlockCounts => {
|
|
76
|
+
const counts: BlockCounts = {
|
|
77
|
+
user: 0,
|
|
78
|
+
assistant: 0,
|
|
79
|
+
toolCalls: 0,
|
|
80
|
+
toolResults: 0,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const block of normalize(messages)) {
|
|
84
|
+
if (block.kind === "user") counts.user += 1;
|
|
85
|
+
else if (block.kind === "assistant") counts.assistant += 1;
|
|
86
|
+
else if (block.kind === "tool_call") counts.toolCalls += 1;
|
|
87
|
+
else if (block.kind === "tool_result") counts.toolResults += 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return counts;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const inputCharsOf = (messages: Message[]): number =>
|
|
94
|
+
messages
|
|
95
|
+
.map((msg, index) => renderMessage(msg, index, true).summary.length)
|
|
96
|
+
.reduce((sum, len) => sum + len, 0);
|
|
97
|
+
|
|
98
|
+
const topFilesOf = (messages: Message[]): string[] => {
|
|
99
|
+
const files = new Set<string>();
|
|
100
|
+
for (const block of normalize(messages)) {
|
|
101
|
+
if (block.kind === "tool_call") {
|
|
102
|
+
for (const key of ["path", "file_path", "filePath", "file"]) {
|
|
103
|
+
const val = block.args[key];
|
|
104
|
+
if (typeof val === "string") { files.add(val); break; }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [...files].slice(0, 10);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const previewOf = (messages: Message[], edgeCount = 3): string => {
|
|
112
|
+
const rendered = messages.map((msg, index) => renderMessage(msg, index));
|
|
113
|
+
if (rendered.length === 0) return "(empty)";
|
|
114
|
+
if (rendered.length <= edgeCount * 2) {
|
|
115
|
+
return rendered
|
|
116
|
+
.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`)
|
|
117
|
+
.join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const first = rendered.slice(0, edgeCount);
|
|
121
|
+
const last = rendered.slice(-edgeCount);
|
|
122
|
+
return [
|
|
123
|
+
...first.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
|
|
124
|
+
"...",
|
|
125
|
+
...last.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
|
|
126
|
+
].join("\n");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const sectionCountOf = (summary: string): number =>
|
|
130
|
+
SECTION_HEADERS.filter((header) => summary.includes(`[${header}]`)).length;
|
|
131
|
+
|
|
132
|
+
const briefLineCountOf = (summary: string): number => {
|
|
133
|
+
const sep = "\n\n---\n\n";
|
|
134
|
+
const idx = summary.indexOf(sep);
|
|
135
|
+
if (idx < 0) return 0;
|
|
136
|
+
return summary.slice(idx + sep.length).split("\n").length;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const queryTermsOf = (text: string): string[] =>
|
|
140
|
+
(text.match(/[\p{L}\p{N}_./-]{3,}/gu) ?? [])
|
|
141
|
+
.map((part) => part.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
|
|
144
|
+
const queryOf = (text: string): string => {
|
|
145
|
+
const terms = queryTermsOf(text);
|
|
146
|
+
return terms.slice(0, 6).join(" ");
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const matchesQuery = (text: string, query: string): boolean => {
|
|
150
|
+
const hay = text.toLowerCase();
|
|
151
|
+
return query
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.split(/\s+/)
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.every((term) => hay.includes(term));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const probesOf = (messages: Message[], summary: string): RecallProbe[] => {
|
|
159
|
+
const blocks = normalize(messages);
|
|
160
|
+
const data = buildSections({ blocks });
|
|
161
|
+
|
|
162
|
+
// Find first file from tool calls
|
|
163
|
+
let firstFile = "";
|
|
164
|
+
for (const b of blocks) {
|
|
165
|
+
if (b.kind === "tool_call") {
|
|
166
|
+
for (const key of ["path", "file_path", "filePath", "file"]) {
|
|
167
|
+
if (typeof b.args[key] === "string") { firstFile = b.args[key] as string; break; }
|
|
168
|
+
}
|
|
169
|
+
if (firstFile) break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rawProbes = [
|
|
174
|
+
{ label: "goal", text: data.sessionGoal[0] ?? "" },
|
|
175
|
+
{ label: "file", text: firstFile },
|
|
176
|
+
{ label: "problem", text: data.outstandingContext[0] ?? "" },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const rendered = messages.map((msg, index) => renderMessage(msg, index));
|
|
180
|
+
|
|
181
|
+
return rawProbes
|
|
182
|
+
.map(({ label, text }) => {
|
|
183
|
+
const sourceText = text.trim();
|
|
184
|
+
const query = queryOf(sourceText);
|
|
185
|
+
if (!query) return null;
|
|
186
|
+
return {
|
|
187
|
+
label,
|
|
188
|
+
sourceText,
|
|
189
|
+
query,
|
|
190
|
+
summaryMentioned: matchesQuery(summary, query),
|
|
191
|
+
recallHits: searchEntries(rendered, messages, query).length,
|
|
192
|
+
};
|
|
193
|
+
})
|
|
194
|
+
.filter((probe): probe is RecallProbe => probe !== null);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const buildCompactReport = (input: CompileInput): CompactReport => {
|
|
198
|
+
const summary = compile(input);
|
|
199
|
+
const data = buildSections({ blocks: normalize(input.messages) });
|
|
200
|
+
const inputChars = inputCharsOf(input.messages);
|
|
201
|
+
const topFiles = topFilesOf(input.messages);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
summary,
|
|
205
|
+
before: {
|
|
206
|
+
messageCount: input.messages.length,
|
|
207
|
+
roleCounts: countRoles(input.messages),
|
|
208
|
+
blockCounts: countBlocks(input.messages),
|
|
209
|
+
inputChars,
|
|
210
|
+
estimatedTokens: estimateTokensFromChars(inputChars),
|
|
211
|
+
topFiles,
|
|
212
|
+
preview: previewOf(input.messages),
|
|
213
|
+
},
|
|
214
|
+
after: {
|
|
215
|
+
summaryLength: summary.length,
|
|
216
|
+
estimatedTokens: estimateTokensFromChars(summary.length),
|
|
217
|
+
sectionCount: sectionCountOf(summary),
|
|
218
|
+
summaryPreview: summary,
|
|
219
|
+
goalsCount: data.sessionGoal.length,
|
|
220
|
+
blockersCount: data.outstandingContext.length,
|
|
221
|
+
briefTranscriptLines: briefLineCountOf(summary),
|
|
222
|
+
},
|
|
223
|
+
compression: {
|
|
224
|
+
charsBefore: inputChars,
|
|
225
|
+
charsAfter: summary.length,
|
|
226
|
+
ratio: summary.length === 0 ? 0 : Number((inputChars / summary.length).toFixed(2)),
|
|
227
|
+
messagesBefore: input.messages.length,
|
|
228
|
+
},
|
|
229
|
+
recall: {
|
|
230
|
+
probes: probesOf(input.messages, summary),
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
};
|