pi-vcc 0.4.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 +120 -0
- package/demo.gif +0 -0
- package/flow/plans/20260515-1300/plan.md +206 -0
- package/index.ts +14 -0
- package/package.json +36 -0
- package/pi-vcc-config.schema.json +131 -0
- package/scripts/audit-sessions.ts +88 -0
- package/scripts/benchmark-real-sessions.ts +25 -0
- package/scripts/compare-before-after.ts +36 -0
- package/scripts/dump-branch-output.ts +20 -0
- package/src/commands/pi-vcc.ts +33 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +381 -0
- package/src/core/build-sections.ts +87 -0
- package/src/core/content.ts +60 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +27 -0
- package/src/core/format.ts +56 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +63 -0
- package/src/core/normalize.ts +66 -0
- package/src/core/recall-scope.ts +14 -0
- package/src/core/render-entries.ts +68 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +230 -0
- package/src/core/settings.ts +215 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +159 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +7 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/files.ts +80 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/references.ts +214 -0
- package/src/extract/signals.ts +145 -0
- package/src/hooks/before-compact.ts +405 -0
- package/src/sections.ts +14 -0
- package/src/tools/recall.ts +109 -0
- package/src/types.ts +14 -0
- package/tests/before-compact-hook.test.ts +181 -0
- package/tests/before-compact.test.ts +140 -0
- package/tests/brief.test.ts +206 -0
- package/tests/build-sections.test.ts +90 -0
- package/tests/compile.test.ts +110 -0
- package/tests/config-integration.test.ts +107 -0
- package/tests/content.test.ts +31 -0
- package/tests/edge-cases.test.ts +368 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/extract-references.test.ts +475 -0
- package/tests/extract-signals.test.ts +561 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +30 -0
- package/tests/format.test.ts +91 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +51 -0
- package/tests/normalize.test.ts +97 -0
- package/tests/real-sessions.test.ts +38 -0
- package/tests/recall-expand.test.ts +15 -0
- package/tests/recall-scope.test.ts +32 -0
- package/tests/recall-tool-scope.test.ts +67 -0
- package/tests/render-entries.test.ts +62 -0
- package/tests/report.test.ts +44 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +144 -0
- package/tests/settings-scaffold.test.ts +120 -0
- package/tests/settings.test.ts +32 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { compile } from "../src/core/summarize";
|
|
3
|
+
import { renderMessage } from "../src/core/render-entries";
|
|
4
|
+
import { clip } from "../src/core/content";
|
|
5
|
+
import { prepareSessionSamples } from "../tests/support/real-sessions";
|
|
6
|
+
import { loadSessionMessages } from "../tests/support/load-session";
|
|
7
|
+
|
|
8
|
+
const SEP = "=".repeat(80);
|
|
9
|
+
const samples = await prepareSessionSamples(2);
|
|
10
|
+
|
|
11
|
+
for (const sample of samples) {
|
|
12
|
+
const loaded = loadSessionMessages(sample.copy);
|
|
13
|
+
const { messages } = loaded;
|
|
14
|
+
|
|
15
|
+
const rendered = messages.map((m, i) => renderMessage(m, i));
|
|
16
|
+
const beforeLines = rendered.map(
|
|
17
|
+
(e) => `#${e.index} [${e.role}] ${clip(e.summary, 300)}`,
|
|
18
|
+
);
|
|
19
|
+
const beforeText = beforeLines.join("\n");
|
|
20
|
+
const afterText = compile({ messages });
|
|
21
|
+
|
|
22
|
+
console.log(SEP);
|
|
23
|
+
console.log(`FILE: ${basename(sample.source)}`);
|
|
24
|
+
console.log(`Messages: ${messages.length} | Before chars: ${beforeText.length} | After chars: ${afterText.length}`);
|
|
25
|
+
console.log(`Compression: ${(beforeText.length / afterText.length).toFixed(1)}x`);
|
|
26
|
+
console.log(SEP);
|
|
27
|
+
|
|
28
|
+
console.log("\n--- BEFORE (raw context, first 40 + last 20 entries) ---\n");
|
|
29
|
+
for (const line of beforeLines.slice(0, 40)) console.log(line);
|
|
30
|
+
if (beforeLines.length > 60) console.log(`\n... (${beforeLines.length - 60} entries omitted) ...\n`);
|
|
31
|
+
for (const line of beforeLines.slice(-20)) console.log(line);
|
|
32
|
+
|
|
33
|
+
console.log("\n--- AFTER (pi-vcc compiled summary) ---\n");
|
|
34
|
+
console.log(afterText);
|
|
35
|
+
console.log("\n");
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { compile } from "../src/core/summarize";
|
|
3
|
+
import { prepareSessionSamples } from "../tests/support/real-sessions";
|
|
4
|
+
import { loadSessionMessages } from "../tests/support/load-session";
|
|
5
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
const outDir = "/tmp/pi-vcc-compare";
|
|
8
|
+
const branch = process.argv[2] || "unknown";
|
|
9
|
+
mkdirSync(`${outDir}/${branch}`, { recursive: true });
|
|
10
|
+
|
|
11
|
+
const samples = await prepareSessionSamples(5);
|
|
12
|
+
for (const sample of samples) {
|
|
13
|
+
const name = basename(sample.source).slice(0, 30);
|
|
14
|
+
const loaded = loadSessionMessages(sample.copy);
|
|
15
|
+
const summary = compile({ messages: loaded.messages });
|
|
16
|
+
const outFile = `${outDir}/${branch}/${name}.txt`;
|
|
17
|
+
writeFileSync(outFile, summary);
|
|
18
|
+
console.log(`${name} (${loaded.messageCount} msgs) => ${summary.length} chars`);
|
|
19
|
+
}
|
|
20
|
+
console.log(`\nSaved to ${outDir}/${branch}/`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { getLastCompactionStats, PI_VCC_COMPACT_INSTRUCTION } from "../hooks/before-compact";
|
|
3
|
+
|
|
4
|
+
import { formatTokens } from "../core/format";
|
|
5
|
+
|
|
6
|
+
export const registerPiVccCommand = (pi: ExtensionAPI) => {
|
|
7
|
+
pi.registerCommand("pi-vcc", {
|
|
8
|
+
description: "Compact conversation with pi-vcc structured summary",
|
|
9
|
+
handler: async (_args, ctx) => {
|
|
10
|
+
ctx.compact({
|
|
11
|
+
customInstructions: PI_VCC_COMPACT_INSTRUCTION,
|
|
12
|
+
onComplete: () => {
|
|
13
|
+
const stats = getLastCompactionStats();
|
|
14
|
+
if (stats) {
|
|
15
|
+
ctx.ui.notify(
|
|
16
|
+
`pi-vcc: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
|
|
17
|
+
"info",
|
|
18
|
+
);
|
|
19
|
+
} else {
|
|
20
|
+
ctx.ui.notify("Compacted with pi-vcc", "info");
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
onError: (err) => {
|
|
24
|
+
if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
|
|
25
|
+
ctx.ui.notify("Nothing to compact", "warning");
|
|
26
|
+
} else {
|
|
27
|
+
ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { loadAllMessages } from "../core/load-messages";
|
|
3
|
+
import { searchEntries } from "../core/search-entries";
|
|
4
|
+
import { formatRecallOutput } from "../core/format-recall";
|
|
5
|
+
import { getActiveLineageEntryIds } from "../core/lineage";
|
|
6
|
+
import { parseRecallScope } from "../core/recall-scope";
|
|
7
|
+
|
|
8
|
+
const PAGE_SIZE = 5;
|
|
9
|
+
const DEFAULT_RECENT = 25;
|
|
10
|
+
|
|
11
|
+
export const registerVccRecallCommand = (pi: ExtensionAPI) => {
|
|
12
|
+
pi.registerCommand("pi-vcc-recall", {
|
|
13
|
+
description: "Search session history. Defaults to active lineage; add scope:all for off-lineage branches.",
|
|
14
|
+
handler: async (args: string, ctx) => {
|
|
15
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
16
|
+
if (!sessionFile) {
|
|
17
|
+
ctx.ui.notify("No session file available.", "error");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const raw = args.trim();
|
|
22
|
+
const parsed = parseRecallScope(raw);
|
|
23
|
+
const lineageEntryIds = parsed.scope === "lineage"
|
|
24
|
+
? getActiveLineageEntryIds(ctx.sessionManager)
|
|
25
|
+
: undefined;
|
|
26
|
+
if (!parsed.text) {
|
|
27
|
+
// No query: show recent
|
|
28
|
+
const { rendered } = await loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
29
|
+
const recent = rendered.slice(-DEFAULT_RECENT);
|
|
30
|
+
const output = (parsed.scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(recent);
|
|
31
|
+
pi.sendMessage({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: true });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Parse page:N from args
|
|
36
|
+
const pageMatch = parsed.text.match(/\bpage:(\d+)\b/i);
|
|
37
|
+
const page = pageMatch ? Math.max(1, parseInt(pageMatch[1], 10)) : 1;
|
|
38
|
+
const query = parsed.text.replace(/\bpage:\d+\b/i, "").trim();
|
|
39
|
+
|
|
40
|
+
if (!query) {
|
|
41
|
+
const { rendered } = await loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
42
|
+
const recent = rendered.slice(-DEFAULT_RECENT);
|
|
43
|
+
const output = (parsed.scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(recent);
|
|
44
|
+
pi.sendMessage({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: true });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { rendered, rawMessages } = await loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
49
|
+
const allResults = searchEntries(rendered, rawMessages, query);
|
|
50
|
+
|
|
51
|
+
const start = (page - 1) * PAGE_SIZE;
|
|
52
|
+
const pageResults = allResults.slice(start, start + PAGE_SIZE);
|
|
53
|
+
const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
|
|
54
|
+
const scopeSuffix = parsed.scope === "all" ? " (scope: all)" : "";
|
|
55
|
+
const header = totalPages > 1
|
|
56
|
+
? `Page ${page}/${totalPages} (${allResults.length} total matches${scopeSuffix})`
|
|
57
|
+
: `${allResults.length} matches${scopeSuffix}`;
|
|
58
|
+
const footer = page < totalPages
|
|
59
|
+
? `\n--- /pi-vcc-recall ${query}${parsed.scope === "all" ? " scope:all" : ""} page:${page + 1} ---`
|
|
60
|
+
: "";
|
|
61
|
+
const output = formatRecallOutput(pageResults, query, header) + footer;
|
|
62
|
+
pi.sendMessage({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: true });
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
};
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import type { NormalizedBlock } from "../types";
|
|
2
|
+
import { clip, firstLine } from "./content";
|
|
3
|
+
import { extractPath } from "./tool-args";
|
|
4
|
+
import { collapseSkillText } from "./skill-collapse";
|
|
5
|
+
|
|
6
|
+
const TRUNCATE_USER = 256;
|
|
7
|
+
const TRUNCATE_ASSISTANT = 200;
|
|
8
|
+
|
|
9
|
+
// Strip common self-reflective assistant prefixes that carry no semantic info.
|
|
10
|
+
// Conservative list: only removes the leading filler, preserves the actual content.
|
|
11
|
+
const SELF_TALK_PREFIX_RE =
|
|
12
|
+
/^\s*(?:hmm|wait|actually|oh|okay|ok|well|so)[,.!\s-]+/i;
|
|
13
|
+
|
|
14
|
+
// ── noise filtering ──
|
|
15
|
+
|
|
16
|
+
const isNoiseUser = (text: string): boolean => {
|
|
17
|
+
return !text.trim();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ── truncation ──
|
|
21
|
+
|
|
22
|
+
// Unicode-aware word segmentation via Intl.Segmenter (built-in, zero dependency)
|
|
23
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
24
|
+
|
|
25
|
+
/** Check if segment is a word (Bun's isWordLike is unreliable for alphanumeric tokens) */
|
|
26
|
+
const isWord = (seg: { segment: string; isWordLike?: boolean }): boolean =>
|
|
27
|
+
seg.isWordLike === true || /[\p{L}\p{N}]/u.test(seg.segment);
|
|
28
|
+
|
|
29
|
+
// Common stop words — don't count toward budget
|
|
30
|
+
const STOP_WORDS = new Set([
|
|
31
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
32
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
33
|
+
"should", "may", "might", "shall", "can", "need", "must",
|
|
34
|
+
"to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
|
|
35
|
+
"into", "through", "during", "before", "after", "above", "below",
|
|
36
|
+
"between", "under", "over",
|
|
37
|
+
"and", "but", "or", "nor", "not", "so", "yet", "both", "either",
|
|
38
|
+
"neither", "each", "every", "all", "any", "few", "more", "most",
|
|
39
|
+
"other", "some", "such", "no",
|
|
40
|
+
"that", "this", "these", "those", "it", "its",
|
|
41
|
+
"i", "me", "my", "we", "our", "you", "your", "he", "him", "his",
|
|
42
|
+
"she", "her", "they", "them", "their", "who", "which", "what",
|
|
43
|
+
"if", "then", "than", "when", "where", "how", "just", "also",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const truncateTokens = (text: string, limit: number): string => {
|
|
47
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
48
|
+
let count = 0;
|
|
49
|
+
let lastEnd = 0;
|
|
50
|
+
for (const seg of segmenter.segment(flat)) {
|
|
51
|
+
if (isWord(seg)) {
|
|
52
|
+
if (!STOP_WORDS.has(seg.segment.toLowerCase())) {
|
|
53
|
+
count++;
|
|
54
|
+
if (count > limit) {
|
|
55
|
+
return flat.slice(0, lastEnd).trimEnd() + "...(truncated)";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
lastEnd = seg.index + seg.segment.length;
|
|
60
|
+
}
|
|
61
|
+
return flat;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── bash command compression ──
|
|
65
|
+
|
|
66
|
+
const BASH_CAP = 120;
|
|
67
|
+
const PIPE_TAIL_RE = /\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|awk|uniq|python3|node|bun)(?:\s[^|]*)?$/;
|
|
68
|
+
|
|
69
|
+
/** Semantic compression: strip cd prefix, pipe tail formatting, cap length */
|
|
70
|
+
const compressBash = (raw: string): string => {
|
|
71
|
+
// Flatten multi-line: take first meaningful line
|
|
72
|
+
let cmd = raw.split("\n").map(l => l.trim()).filter(Boolean)[0] ?? raw;
|
|
73
|
+
// Strip cd <path> && prefix
|
|
74
|
+
cmd = cmd.replace(/^cd\s+\S+\s*&&\s*/, "");
|
|
75
|
+
// Strip pipe tail formatting commands (up to 3 times)
|
|
76
|
+
for (let i = 0; i < 3; i++) {
|
|
77
|
+
const stripped = cmd.replace(PIPE_TAIL_RE, "");
|
|
78
|
+
if (stripped === cmd) break;
|
|
79
|
+
cmd = stripped;
|
|
80
|
+
}
|
|
81
|
+
if (cmd.length > BASH_CAP) {
|
|
82
|
+
return cmd.slice(0, BASH_CAP - 3) + "...";
|
|
83
|
+
}
|
|
84
|
+
return cmd;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ── tool summary ──
|
|
88
|
+
|
|
89
|
+
const TOOL_SUMMARY_FIELDS: Record<string, string> = {
|
|
90
|
+
Read: "file_path", Edit: "file_path", Write: "file_path",
|
|
91
|
+
read: "file_path", edit: "file_path", write: "file_path",
|
|
92
|
+
Glob: "pattern", Grep: "pattern",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const toolOneLiner = (name: string, args: Record<string, unknown>): string => {
|
|
96
|
+
const field = TOOL_SUMMARY_FIELDS[name];
|
|
97
|
+
if (field && typeof args[field] === "string") {
|
|
98
|
+
return `* ${name} "${args[field] as string}"`;
|
|
99
|
+
}
|
|
100
|
+
const path = extractPath(args);
|
|
101
|
+
if (path) return `* ${name} "${path}"`;
|
|
102
|
+
if (name === "bash" || name === "Bash") {
|
|
103
|
+
const raw = (args.command ?? args.description ?? "") as string;
|
|
104
|
+
const cmd = compressBash(raw);
|
|
105
|
+
return `* ${name} "${cmd}"`;
|
|
106
|
+
}
|
|
107
|
+
if (typeof args.query === "string") {
|
|
108
|
+
return `* ${name} "${clip(args.query as string, 60)}"`;
|
|
109
|
+
}
|
|
110
|
+
return `* ${name}`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export interface BriefLine {
|
|
114
|
+
/** Section header like "[user]", "[assistant]", "[tool_error] bash" */
|
|
115
|
+
header: string;
|
|
116
|
+
/** Content lines for this section */
|
|
117
|
+
lines: string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Structured transcript entry for JSON output */
|
|
121
|
+
export interface TranscriptEntry {
|
|
122
|
+
role: "user" | "assistant" | "tool_error";
|
|
123
|
+
text?: string;
|
|
124
|
+
tool?: string;
|
|
125
|
+
cmd?: string;
|
|
126
|
+
ref?: string;
|
|
127
|
+
/** Collapse count when identical tool calls are grouped */
|
|
128
|
+
count?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build BriefLine sections from NormalizedBlocks.
|
|
133
|
+
*/
|
|
134
|
+
export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
135
|
+
const sections: BriefLine[] = [];
|
|
136
|
+
let lastHeader = "";
|
|
137
|
+
|
|
138
|
+
const push = (header: string, line: string) => {
|
|
139
|
+
if (header === lastHeader && sections.length > 0) {
|
|
140
|
+
sections[sections.length - 1].lines.push(line);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
sections.push({ header, lines: [line] });
|
|
144
|
+
lastHeader = header;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
for (const b of blocks) {
|
|
148
|
+
switch (b.kind) {
|
|
149
|
+
case "user": {
|
|
150
|
+
if (isNoiseUser(b.text)) break;
|
|
151
|
+
const text = truncateTokens(collapseSkillText(b.text), TRUNCATE_USER);
|
|
152
|
+
if (text) {
|
|
153
|
+
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
154
|
+
push("[user]", text + ref);
|
|
155
|
+
}
|
|
156
|
+
lastHeader = "[user]";
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "assistant": {
|
|
160
|
+
let raw = b.text;
|
|
161
|
+
// Strip leading self-talk prefix (up to 2x; assistants sometimes chain "Hmm, actually, ...")
|
|
162
|
+
for (let i = 0; i < 2; i++) {
|
|
163
|
+
const stripped = raw.replace(SELF_TALK_PREFIX_RE, "");
|
|
164
|
+
if (stripped === raw) break;
|
|
165
|
+
raw = stripped;
|
|
166
|
+
}
|
|
167
|
+
const text = truncateTokens(raw, TRUNCATE_ASSISTANT);
|
|
168
|
+
if (text) {
|
|
169
|
+
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
170
|
+
push("[assistant]", text + ref);
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "tool_call": {
|
|
175
|
+
// Skip malformed tool calls from streaming providers (empty name / fragmented args).
|
|
176
|
+
if (!b.name || b.name.trim() === "") break;
|
|
177
|
+
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
178
|
+
const summary = toolOneLiner(b.name, b.args) + ref;
|
|
179
|
+
push("[assistant]", summary);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "tool_result": {
|
|
183
|
+
if (b.isError) {
|
|
184
|
+
const body = firstLine(b.text, 150);
|
|
185
|
+
// Drop empty/placeholder error bodies — keep the line only if it carries info.
|
|
186
|
+
if (!body || body === "(no output)") break;
|
|
187
|
+
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
188
|
+
const header = `[tool_error] ${b.name}${ref}`;
|
|
189
|
+
push(header, body);
|
|
190
|
+
lastHeader = header;
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "thinking":
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Collapse consecutive identical tool lines (same text, different #ref)
|
|
200
|
+
for (const sec of sections) {
|
|
201
|
+
if (sec.header !== "[assistant]") continue;
|
|
202
|
+
const out: string[] = [];
|
|
203
|
+
for (const line of sec.lines) {
|
|
204
|
+
if (!line.startsWith("* ")) { out.push(line); continue; }
|
|
205
|
+
const ref = line.match(/\(#(\d+)\)$/)?.[1] ?? "";
|
|
206
|
+
const base = ref ? line.slice(0, -(ref.length + 3)).trimEnd() : line;
|
|
207
|
+
const last = out.length > 0 ? out[out.length - 1] : "";
|
|
208
|
+
const m = last.match(/^(.*) \((#[\d, #]+)\) x(\d+)$/);
|
|
209
|
+
if (m && m[1] === base) {
|
|
210
|
+
out[out.length - 1] = `${base} (${m[2]}, #${ref}) x${parseInt(m[3]) + 1}`;
|
|
211
|
+
} else if (last.match(/\(#\d+\)$/) && last.replace(/\s*\(#\d+\)$/, "") === base) {
|
|
212
|
+
const prevRef = last.match(/\(#(\d+)\)$/)?.[1];
|
|
213
|
+
out[out.length - 1] = `${base} (#${prevRef}, #${ref}) x2`;
|
|
214
|
+
} else {
|
|
215
|
+
out.push(line);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
sec.lines = out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Cap tool calls per [assistant] turn — keep tail (latest actions tend to
|
|
222
|
+
// be the deciding edits/writes; head is usually exploration noise).
|
|
223
|
+
const TOOL_CALLS_PER_TURN = 8;
|
|
224
|
+
for (const sec of sections) {
|
|
225
|
+
if (sec.header !== "[assistant]") continue;
|
|
226
|
+
const toolIdxs = sec.lines
|
|
227
|
+
.map((l, i) => (l.startsWith("* ") ? i : -1))
|
|
228
|
+
.filter((i) => i >= 0);
|
|
229
|
+
if (toolIdxs.length <= TOOL_CALLS_PER_TURN) continue;
|
|
230
|
+
const dropCount = toolIdxs.length - TOOL_CALLS_PER_TURN;
|
|
231
|
+
const dropSet = new Set(toolIdxs.slice(0, dropCount));
|
|
232
|
+
const firstKeptToolIdx = toolIdxs[dropCount];
|
|
233
|
+
const next: string[] = [];
|
|
234
|
+
let inserted = false;
|
|
235
|
+
for (let i = 0; i < sec.lines.length; i++) {
|
|
236
|
+
if (dropSet.has(i)) continue;
|
|
237
|
+
if (!inserted && i === firstKeptToolIdx) {
|
|
238
|
+
next.push(`* (${dropCount} earlier tool-call entries omitted)`);
|
|
239
|
+
inserted = true;
|
|
240
|
+
}
|
|
241
|
+
next.push(sec.lines[i]);
|
|
242
|
+
}
|
|
243
|
+
sec.lines = next;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Collapse consecutive identical [tool_error] sections (same tool, same body).
|
|
247
|
+
// E.g. 20 back-to-back `[tool_error] bash (#N) ... Command aborted` become one
|
|
248
|
+
// `[tool_error] bash (#refs...) x20` entry.
|
|
249
|
+
const collapsedErrors: BriefLine[] = [];
|
|
250
|
+
for (const sec of sections) {
|
|
251
|
+
const m = sec.header.match(/^\[tool_error\]\s+(\S+?)(?:\s*\(#(\d+)\))?$/);
|
|
252
|
+
if (!m || sec.lines.length !== 1) {
|
|
253
|
+
collapsedErrors.push(sec);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const tool = m[1];
|
|
257
|
+
const ref = m[2];
|
|
258
|
+
const body = sec.lines[0];
|
|
259
|
+
const prev = collapsedErrors[collapsedErrors.length - 1];
|
|
260
|
+
const prevMatch = prev?.header.match(
|
|
261
|
+
/^\[tool_error\]\s+(\S+?)\s*\(((?:#\d+(?:,\s*)?)+)\)(?:\s*x(\d+))?$/,
|
|
262
|
+
);
|
|
263
|
+
if (prev && prevMatch && prevMatch[1] === tool && prev.lines.length === 1 && prev.lines[0] === body) {
|
|
264
|
+
const refs = prevMatch[2] + (ref ? `, #${ref}` : "");
|
|
265
|
+
const count = prevMatch[3] ? parseInt(prevMatch[3]) + 1 : 2;
|
|
266
|
+
prev.header = `[tool_error] ${tool} (${refs}) x${count}`;
|
|
267
|
+
} else {
|
|
268
|
+
collapsedErrors.push(sec);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
sections.length = 0;
|
|
272
|
+
sections.push(...collapsedErrors);
|
|
273
|
+
|
|
274
|
+
return sections;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Stringify BriefLine sections into text format.
|
|
279
|
+
*/
|
|
280
|
+
export const stringifyBrief = (sections: BriefLine[]): string => {
|
|
281
|
+
|
|
282
|
+
// Emit sections -- suppress blank lines between consecutive tool summaries
|
|
283
|
+
const out: string[] = [];
|
|
284
|
+
for (let i = 0; i < sections.length; i++) {
|
|
285
|
+
const sec = sections[i];
|
|
286
|
+
if (i > 0) {
|
|
287
|
+
const prev = sections[i - 1];
|
|
288
|
+
const prevIsTools = prev.header === "[assistant]" &&
|
|
289
|
+
prev.lines.every((l) => l.startsWith("* "));
|
|
290
|
+
const curIsTools = sec.header === "[assistant]" &&
|
|
291
|
+
sec.lines.every((l) => l.startsWith("* "));
|
|
292
|
+
if (!(prevIsTools && curIsTools)) {
|
|
293
|
+
out.push("");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
out.push(sec.header);
|
|
297
|
+
for (const line of sec.lines) {
|
|
298
|
+
out.push(line);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return out.join("\n");
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** Parse a text line into a structured TranscriptEntry */
|
|
306
|
+
const parseToolLine = (line: string): { tool: string; cmd?: string; ref?: string; count?: number } | null => {
|
|
307
|
+
// * bash "cmd" (#5)
|
|
308
|
+
// * bash "cmd" (#1, #3) x2
|
|
309
|
+
// * tilth "query" (#7)
|
|
310
|
+
const m = line.match(/^\* (\S+)\s*(?:"([^"]*)")?\s*(?:\((#[\d, #]+)\))?\s*(?:x(\d+))?$/);
|
|
311
|
+
if (!m) return null;
|
|
312
|
+
return {
|
|
313
|
+
tool: m[1],
|
|
314
|
+
cmd: m[2] || undefined,
|
|
315
|
+
ref: m[3] || undefined,
|
|
316
|
+
count: m[4] ? parseInt(m[4]) : undefined,
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const extractRef = (text: string): { clean: string; ref?: string } => {
|
|
321
|
+
const m = text.match(/\s*\(#(\d+)\)$/);
|
|
322
|
+
if (!m) return { clean: text };
|
|
323
|
+
return { clean: text.slice(0, m.index).trimEnd(), ref: `#${m[1]}` };
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Convert BriefLine sections to structured TranscriptEntry array for JSON output.
|
|
328
|
+
*/
|
|
329
|
+
export const sectionsToTranscript = (sections: BriefLine[]): TranscriptEntry[] => {
|
|
330
|
+
const entries: TranscriptEntry[] = [];
|
|
331
|
+
|
|
332
|
+
for (const sec of sections) {
|
|
333
|
+
if (sec.header === "[user]") {
|
|
334
|
+
for (const line of sec.lines) {
|
|
335
|
+
const { clean, ref } = extractRef(line);
|
|
336
|
+
entries.push({ role: "user", text: clean, ...(ref && { ref }) });
|
|
337
|
+
}
|
|
338
|
+
} else if (sec.header === "[assistant]") {
|
|
339
|
+
for (const line of sec.lines) {
|
|
340
|
+
if (line.startsWith("* ")) {
|
|
341
|
+
const parsed = parseToolLine(line);
|
|
342
|
+
if (parsed) {
|
|
343
|
+
entries.push({
|
|
344
|
+
role: "assistant",
|
|
345
|
+
tool: parsed.tool,
|
|
346
|
+
...(parsed.cmd && { cmd: parsed.cmd }),
|
|
347
|
+
...(parsed.ref && { ref: parsed.ref }),
|
|
348
|
+
...(parsed.count && { count: parsed.count }),
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
// Fallback: unparseable tool line
|
|
352
|
+
const { clean, ref } = extractRef(line.slice(2));
|
|
353
|
+
entries.push({ role: "assistant", text: clean, ...(ref && { ref }) });
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
const { clean, ref } = extractRef(line);
|
|
357
|
+
entries.push({ role: "assistant", text: clean, ...(ref && { ref }) });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} else if (sec.header.startsWith("[tool_error]")) {
|
|
361
|
+
// [tool_error] bash (#5)
|
|
362
|
+
const headerMatch = sec.header.match(/^\[tool_error\]\s+(\S+)\s*(?:\(#(\d+)\))?/);
|
|
363
|
+
const tool = headerMatch?.[1] ?? "unknown";
|
|
364
|
+
const ref = headerMatch?.[2] ? `#${headerMatch[2]}` : undefined;
|
|
365
|
+
for (const line of sec.lines) {
|
|
366
|
+
entries.push({
|
|
367
|
+
role: "tool_error",
|
|
368
|
+
tool,
|
|
369
|
+
text: line,
|
|
370
|
+
...(ref && { ref }),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return entries;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** Convenience: build sections from blocks and stringify to text */
|
|
380
|
+
export const compileBrief = (blocks: NormalizedBlock[]): string =>
|
|
381
|
+
stringifyBrief(buildBriefSections(blocks));
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { NormalizedBlock } from "../types";
|
|
2
|
+
import { clip, clipSentence, firstLine, nonEmptyLines } from "./content";
|
|
3
|
+
import type { SectionData } from "../sections";
|
|
4
|
+
import { extractGoals } from "../extract/goals";
|
|
5
|
+
import { extractFiles } from "../extract/files";
|
|
6
|
+
import { extractPreferences, dedupPreferencesAgainstGoals } from "../extract/preferences";
|
|
7
|
+
import { extractCommits, formatCommits } from "../extract/commits";
|
|
8
|
+
import { extractReferences, formatReferences } from "../extract/references";
|
|
9
|
+
import { extractSignals, formatSignals } from "../extract/signals";
|
|
10
|
+
import type { ExtractionConfig } from "./settings";
|
|
11
|
+
import { buildBriefSections, sectionsToTranscript, stringifyBrief } from "./brief";
|
|
12
|
+
|
|
13
|
+
export interface BuildSectionsInput {
|
|
14
|
+
blocks: NormalizedBlock[];
|
|
15
|
+
extraction?: ExtractionConfig;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BLOCKER_RE =
|
|
19
|
+
/\b(fail(ed|s|ure|ing)?|broken|cannot|can't|won't work|does not work|doesn't work|still (broken|failing|wrong)|blocked|blocker|not (fixed|resolved|working)|crash(es|ed|ing)?)\b/i;
|
|
20
|
+
|
|
21
|
+
const extractOutstandingContext = (blocks: NormalizedBlock[]): string[] => {
|
|
22
|
+
const items: string[] = [];
|
|
23
|
+
const tail = blocks.slice(-20);
|
|
24
|
+
|
|
25
|
+
for (const b of tail) {
|
|
26
|
+
if (b.kind === "tool_result" && b.isError) {
|
|
27
|
+
items.push(`[${b.name}] ${firstLine(b.text, 150)}`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (b.kind === "assistant" || b.kind === "user") {
|
|
32
|
+
for (const line of nonEmptyLines(b.text)) {
|
|
33
|
+
if (!BLOCKER_RE.test(line)) continue;
|
|
34
|
+
if (line.length < 15) continue;
|
|
35
|
+
// Skip continuation fragments (sub-bullets, parentheticals, dangling clauses)
|
|
36
|
+
if (/^\s*[-*+>]\s/.test(line)) continue;
|
|
37
|
+
if (/^\s*\(/.test(line)) continue;
|
|
38
|
+
// Require sentence-like start: capital letter, code identifier, or quote
|
|
39
|
+
if (!/^\s*["'`*_]?[A-Z`]/.test(line)) continue;
|
|
40
|
+
const clipped = b.kind === "user" ? `[user] ${clipSentence(line, 150)}` : clipSentence(line, 150);
|
|
41
|
+
if (!items.includes(clipped)) items.push(clipped);
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return items.slice(0, 5);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const formatFileActivity = (blocks: NormalizedBlock[]): string[] => {
|
|
51
|
+
const act = extractFiles(blocks);
|
|
52
|
+
// Dedup: if already Modified, drop from Created (file existed before)
|
|
53
|
+
for (const p of act.modified) act.created.delete(p);
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
const cap = (set: Set<string>, limit: number) => {
|
|
56
|
+
const arr = [...set];
|
|
57
|
+
if (arr.length <= limit) return arr.join(", ");
|
|
58
|
+
return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
|
|
59
|
+
};
|
|
60
|
+
if (act.modified.size > 0) lines.push(`Modified: ${cap(act.modified, 10)}`);
|
|
61
|
+
if (act.created.size > 0) lines.push(`Created: ${cap(act.created, 10)}`);
|
|
62
|
+
if (act.read.size > 0) lines.push(`Read: ${cap(act.read, 10)}`);
|
|
63
|
+
return lines;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const buildSections = (input: BuildSectionsInput): SectionData => {
|
|
67
|
+
const { blocks, extraction } = input;
|
|
68
|
+
const refOpts = extraction?.references;
|
|
69
|
+
const sigOpts = extraction?.keySignals;
|
|
70
|
+
const briefSections = buildBriefSections(blocks);
|
|
71
|
+
const sessionGoal = extractGoals(blocks);
|
|
72
|
+
const userPreferences = dedupPreferencesAgainstGoals(
|
|
73
|
+
extractPreferences(blocks),
|
|
74
|
+
sessionGoal,
|
|
75
|
+
);
|
|
76
|
+
return {
|
|
77
|
+
sessionGoal,
|
|
78
|
+
outstandingContext: extractOutstandingContext(blocks),
|
|
79
|
+
filesAndChanges: formatFileActivity(blocks),
|
|
80
|
+
commits: formatCommits(extractCommits(blocks)),
|
|
81
|
+
references: formatReferences(extractReferences(blocks, refOpts)),
|
|
82
|
+
keySignals: formatSignals(extractSignals(blocks, sigOpts)),
|
|
83
|
+
userPreferences,
|
|
84
|
+
briefTranscript: stringifyBrief(briefSections),
|
|
85
|
+
transcriptEntries: sectionsToTranscript(briefSections),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
2
|
+
|
|
3
|
+
export const clip = (text: string, max = 200): string => {
|
|
4
|
+
if (text.length <= max) return text;
|
|
5
|
+
// Try to cut at a word boundary
|
|
6
|
+
const cut = text.lastIndexOf(" ", max);
|
|
7
|
+
let end = cut > max * 0.6 ? cut : max;
|
|
8
|
+
// Avoid splitting a surrogate pair
|
|
9
|
+
if (end > 0 && end < text.length) {
|
|
10
|
+
const code = text.charCodeAt(end - 1);
|
|
11
|
+
if (code >= 0xd800 && code <= 0xdbff) end--;
|
|
12
|
+
}
|
|
13
|
+
return text.slice(0, end);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Clip text to last sentence boundary at or before `max` chars.
|
|
18
|
+
* Falls back to word boundary (clip()) if no sentence end is found in the
|
|
19
|
+
* acceptable range. Trailing whitespace stripped.
|
|
20
|
+
*/
|
|
21
|
+
export const clipSentence = (text: string, max = 200): string => {
|
|
22
|
+
if (text.length <= max) return text;
|
|
23
|
+
// Look for sentence terminators followed by space/newline within [max*0.5, max]
|
|
24
|
+
const window = text.slice(0, max);
|
|
25
|
+
const matches = [...window.matchAll(/[.!?](?:\s|$)/g)];
|
|
26
|
+
if (matches.length > 0) {
|
|
27
|
+
const last = matches[matches.length - 1];
|
|
28
|
+
const end = (last.index ?? 0) + 1; // include the punctuation
|
|
29
|
+
if (end >= max * 0.5) return text.slice(0, end);
|
|
30
|
+
}
|
|
31
|
+
return clip(text, max);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const nonEmptyLines = (text: string): string[] =>
|
|
35
|
+
text.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
36
|
+
|
|
37
|
+
export const firstLine = (text: string, max = 200): string =>
|
|
38
|
+
clip(text.split("\n")[0] ?? "", max);
|
|
39
|
+
|
|
40
|
+
export const textParts = (content: Message["content"]): string[] => {
|
|
41
|
+
if (!content) return [];
|
|
42
|
+
if (typeof content === "string") return [content];
|
|
43
|
+
return content
|
|
44
|
+
.filter((part) => part.type === "text")
|
|
45
|
+
.map((part) => part.text);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const textOf = (content: Message["content"]): string =>
|
|
49
|
+
textParts(content).join("\n");
|
|
50
|
+
|
|
51
|
+
/** Extract a snippet of ~`radius` chars around the first match of `term` in `text`. */
|
|
52
|
+
export const snippet = (text: string, term: string, radius = 60): string | null => {
|
|
53
|
+
const idx = text.toLowerCase().indexOf(term.toLowerCase());
|
|
54
|
+
if (idx === -1) return null;
|
|
55
|
+
const start = Math.max(0, idx - radius);
|
|
56
|
+
const end = Math.min(text.length, idx + term.length + radius);
|
|
57
|
+
const prefix = start > 0 ? "..." : "";
|
|
58
|
+
const suffix = end < text.length ? "..." : "";
|
|
59
|
+
return `${prefix}${text.slice(start, end)}${suffix}`;
|
|
60
|
+
};
|