@pi-unipi/compactor 2.6.1 → 2.6.2
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/README.md +5 -4
- package/package.json +1 -1
- package/skills/compactor/SKILL.md +1 -1
- package/skills/compactor-detail/SKILL.md +3 -5
- package/skills/compactor-doctor/SKILL.md +1 -1
- package/skills/compactor-stats/SKILL.md +1 -1
- package/src/commands/index.ts +47 -78
- package/src/compaction/brief.ts +161 -90
- package/src/compaction/build-sections.ts +3 -4
- package/src/compaction/compact-args.ts +86 -0
- package/src/compaction/cut.ts +270 -28
- package/src/compaction/drill-down.ts +261 -0
- package/src/compaction/format-recall.ts +96 -0
- package/src/compaction/format.ts +8 -3
- package/src/compaction/hooks.ts +248 -72
- package/src/compaction/merge.ts +34 -4
- package/src/compaction/rank.ts +270 -0
- package/src/compaction/recall-scope.ts +28 -0
- package/src/compaction/search-entries.ts +333 -96
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/summarize.ts +37 -6
- package/src/compaction/token-estimate.ts +104 -0
- package/src/compaction/touched-files.ts +35 -0
- package/src/config/manager.ts +2 -27
- package/src/config/presets.ts +0 -2
- package/src/config/schema.ts +2 -16
- package/src/executor/executor.ts +6 -15
- package/src/executor/runtime.ts +2 -12
- package/src/index.ts +12 -122
- package/src/info-screen.ts +3 -10
- package/src/security/evaluator.ts +0 -53
- package/src/security/policy.ts +7 -8
- package/src/session/db.ts +0 -6
- package/src/tools/ctx-execute-file.ts +0 -5
- package/src/tools/register.ts +27 -50
- package/src/tools/vcc-recall.ts +86 -48
- package/src/tui/settings-overlay.ts +20 -40
- package/src/types.ts +43 -100
- package/src/display/diff-renderer.ts +0 -281
- package/src/display/line-width-safety.ts +0 -28
- package/src/display/render-utils.ts +0 -52
- package/src/display/thinking-label.ts +0 -18
- package/src/display/tool-overrides.ts +0 -136
- package/src/tools/compact.ts +0 -20
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Per-project config is merged over global config when the current project directo
|
|
|
51
51
|
| `/unipi:compact-preset <name>` | Applies a preset globally. | Names: `precise`, `balanced`, `thorough`, `lean`. Old names map to new ones: `opencode→precise`, `verbose→thorough`, `minimal→lean`. |
|
|
52
52
|
| `/unipi:compact-help` | Shows compact help inside Pi. | Quick command reference. |
|
|
53
53
|
|
|
54
|
-
Content/project indexing is
|
|
54
|
+
Content/project indexing is not part of this package. Use `read`/`bash` (rg) for project search.
|
|
55
55
|
|
|
56
56
|
---
|
|
57
57
|
|
|
@@ -102,7 +102,7 @@ These settings control what is extracted into compaction summaries or how relate
|
|
|
102
102
|
| `Session Continuity` | `full`, `off` | Controls XML-style resume snapshots. Off prevents snapshot creation and injection; Pi’s normal compaction summary still remains. |
|
|
103
103
|
| `Sandbox Execution` | `all`, `off` | Controls sandbox tool registration. Changes require a session reload. Allowed languages and output limit are enforced from config. |
|
|
104
104
|
|
|
105
|
-
Config-only legacy field: `fts5Index` remains in the schema for compatibility
|
|
105
|
+
Config-only legacy field: `fts5Index` remains in the schema for compatibility. It is hidden from Compactor settings and presets. Legacy `sessionContinuity.mode: "essential-only"`, `eventCategories`, and `sandboxExecution.mode: "safe-only"` values remain loadable; essential-only behaves as full and safe-only behaves as enabled/all until explicit filtering/security policies are designed. Legacy `toolDisplay` and `showTruncationHints` fields also remain loadable but are deprecated and ignored; their profile names were never mapped to runtime renderer modes. Narrow-terminal diff clamping remains active as independent safety behavior.
|
|
106
106
|
|
|
107
107
|
### Auto tab
|
|
108
108
|
|
|
@@ -112,7 +112,7 @@ These settings control UniPi-managed percentage auto-compaction. They are separa
|
|
|
112
112
|
|
|
113
113
|
| Setting | Default | Meaning |
|
|
114
114
|
|---|---:|---|
|
|
115
|
-
| `Percentage Trigger` | `off` | When on, UniPi checks context usage at `
|
|
115
|
+
| `Percentage Trigger` | `off` | When on, UniPi checks context usage at `agent_end` (after the agent run settles) and can call compaction when the threshold is reached. Disabled by default for backward compatibility. |
|
|
116
116
|
| `Threshold` | `80%` | Trigger point using Pi's live `ctx.getContextUsage().percent`. |
|
|
117
117
|
| `Cooldown` | `60s` | Minimum time between UniPi-triggered compaction attempts. Prevents loops. |
|
|
118
118
|
| `Repeat Growth` | `4k` | If usage is still above threshold after compaction, require this many new tokens before triggering again. |
|
|
@@ -217,10 +217,11 @@ Typical session order:
|
|
|
217
217
|
- Updates runtime byte/tool counters.
|
|
218
218
|
- Applies display overrides and width-safe diff clamping before output reaches the TUI.
|
|
219
219
|
|
|
220
|
-
6. **`
|
|
220
|
+
6. **`agent_end`**
|
|
221
221
|
- If percentage auto-compaction is enabled, reads `ctx.getContextUsage()`.
|
|
222
222
|
- Runs cooldown/repeat-growth safeguards.
|
|
223
223
|
- Calls `ctx.compact({ customInstructions: COMPACTOR_INSTRUCTION })` when eligible.
|
|
224
|
+
- Fires only after the agent run settles: `ctx.compact()` aborts any active operation first, so triggering mid-run (e.g. at `turn_end`) would kill the next provider request.
|
|
224
225
|
|
|
225
226
|
7. **Pi decides to compact**
|
|
226
227
|
- This can be from `/unipi:lossless-compact`, UniPi percentage auto-compaction, or Pi core's reserve-token fallback.
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ description: Context management — compact session, recall history, run code, s
|
|
|
12
12
|
|
|
13
13
|
## Finding Past Work
|
|
14
14
|
- `session_recall(query)` → search this session (BM25 or regex), including raw messages that may no longer be in live context after compaction.
|
|
15
|
-
- For
|
|
15
|
+
- For project/file search, use `read`/`bash` (rg) directly; content indexing is not part of compactor.
|
|
16
16
|
|
|
17
17
|
## Running Code
|
|
18
18
|
- `sandbox(lang, code)` → single script. `sandbox_batch(items)` → atomic.
|
|
@@ -88,7 +88,7 @@ Estimate remaining context tokens and percent full. Uses Pi's live context usage
|
|
|
88
88
|
## Anti-Patterns
|
|
89
89
|
|
|
90
90
|
1. **Don't compact in a tight loop.** Use `context_budget` first and compact at natural break points.
|
|
91
|
-
2. **Don't use `session_recall` for project-wide code search.** It searches the conversation/session
|
|
91
|
+
2. **Don't use `session_recall` for project-wide code search.** It searches the conversation/session only — use `read`/`bash`(rg) for files.
|
|
92
92
|
3. **Don't use `sandbox` for file operations.** Use normal file tools for reads/writes; sandbox is for computation or quick scripts.
|
|
93
93
|
4. **Don't use empty or vague recall queries.** Search for specific filenames, issue numbers, commands, or decisions.
|
|
94
94
|
5. **Don't compact mid-thought if avoidable.** Finish the current step, then compact.
|
|
@@ -112,7 +112,5 @@ Estimate remaining context tokens and percent full. Uses Pi's live context usage
|
|
|
112
112
|
3. `compactor_stats` — confirm compactions/savings are recorded.
|
|
113
113
|
|
|
114
114
|
### Project Search
|
|
115
|
-
Compactor
|
|
116
|
-
1.
|
|
117
|
-
2. `/unipi:cocoindex-update`
|
|
118
|
-
3. `cocoindex_search(query)`
|
|
115
|
+
Compactor does not own project content indexing. For code search use `bash` with rg.
|
|
116
|
+
1. `bash` with `rg` for code search
|
|
@@ -48,4 +48,4 @@ This means:
|
|
|
48
48
|
- 3 compactions saved roughly 15K tokens.
|
|
49
49
|
- 7 sandbox executions and 15 recall/search queries were recorded.
|
|
50
50
|
|
|
51
|
-
Project content indexing stats are not owned by compactor
|
|
51
|
+
Project content indexing stats are not owned by compactor; use `read`/`bash` (rg) for file search.
|
package/src/commands/index.ts
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { loadConfig, saveConfig } from "../config/manager.js";
|
|
10
10
|
import { applyPreset, parsePreset } from "../config/presets.js";
|
|
11
|
-
import { COMPACTOR_INSTRUCTION } from "@pi-unipi/core";
|
|
12
|
-
import { getLastCompactionStats } from "../compaction/hooks.js";
|
|
11
|
+
import { COMPACTOR_INSTRUCTION, formatTokens } from "@pi-unipi/core";
|
|
12
|
+
import { getLastCompactionStats, formatCompactionStats } from "../compaction/hooks.js";
|
|
13
13
|
import { vccRecall } from "../tools/vcc-recall.js";
|
|
14
14
|
import { ctxStats } from "../tools/ctx-stats.js";
|
|
15
15
|
import { ctxDoctor } from "../tools/ctx-doctor.js";
|
|
16
16
|
import { recallBlocksFromContext } from "../session/recall-blocks.js";
|
|
17
17
|
import { filterNoise } from "../compaction/filter-noise.js";
|
|
18
|
+
import { parseRecallScope } from "../compaction/recall-scope.js";
|
|
18
19
|
import type { SessionDB } from "../session/db.js";
|
|
19
20
|
import type { NormalizedBlock, RuntimeCounters } from "../types.js";
|
|
20
21
|
|
|
@@ -25,77 +26,55 @@ export interface CommandDeps {
|
|
|
25
26
|
getCounters?: () => RuntimeCounters;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function deprecationLog(_oldName: string, _newName: string): void {
|
|
29
|
-
// Deprecation logging disabled — was writing to stdout causing TUI rendering issues.
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const formatTokens = (n: number): string => {
|
|
33
|
-
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
34
|
-
return String(n);
|
|
35
|
-
};
|
|
36
|
-
|
|
37
29
|
export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
|
|
38
30
|
// ── /unipi:lossless-compact ──────────────────────────
|
|
31
|
+
const losslessCompactHandler = async (_args: string, ctx: ExtensionCommandContext) => {
|
|
32
|
+
ctx.compact({
|
|
33
|
+
customInstructions: COMPACTOR_INSTRUCTION,
|
|
34
|
+
onComplete: () => {
|
|
35
|
+
const stats = getLastCompactionStats();
|
|
36
|
+
if (stats) {
|
|
37
|
+
ctx.ui.notify(formatCompactionStats(stats), "info");
|
|
38
|
+
} else {
|
|
39
|
+
ctx.ui.notify("Compaction completed.", "info");
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
onError: (err: Error) => {
|
|
43
|
+
if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
|
|
44
|
+
ctx.ui.notify("Nothing to compact.", "info");
|
|
45
|
+
} else {
|
|
46
|
+
ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
};
|
|
39
51
|
pi.registerCommand("unipi:lossless-compact", {
|
|
40
52
|
description: "Immediate zero-LLM compaction — structured summary with full recall",
|
|
41
|
-
handler:
|
|
42
|
-
ctx.compact({
|
|
43
|
-
customInstructions: COMPACTOR_INSTRUCTION,
|
|
44
|
-
onComplete: () => {
|
|
45
|
-
const stats = getLastCompactionStats();
|
|
46
|
-
if (stats) {
|
|
47
|
-
ctx.ui.notify(
|
|
48
|
-
`Compacted ${stats.totalMessages} messages (~${formatTokens(stats.tokensBefore)} tokens) → ${stats.kept} messages (~${formatTokens(stats.tokensAfterEst)} tokens)`,
|
|
49
|
-
"info",
|
|
50
|
-
);
|
|
51
|
-
} else {
|
|
52
|
-
ctx.ui.notify("Compaction completed.", "info");
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
onError: (err: Error) => {
|
|
56
|
-
if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
|
|
57
|
-
ctx.ui.notify("Nothing to compact.", "info");
|
|
58
|
-
} else {
|
|
59
|
-
ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
},
|
|
53
|
+
handler: losslessCompactHandler,
|
|
64
54
|
});
|
|
65
|
-
// Deprecated alias — old name
|
|
66
55
|
pi.registerCommand("unipi:compact", {
|
|
67
|
-
description: "
|
|
68
|
-
handler:
|
|
69
|
-
deprecationLog("/unipi:compact", "/unipi:lossless-compact");
|
|
70
|
-
ctx.compact({
|
|
71
|
-
customInstructions: COMPACTOR_INSTRUCTION,
|
|
72
|
-
onComplete: () => {
|
|
73
|
-
const stats = getLastCompactionStats();
|
|
74
|
-
if (stats) {
|
|
75
|
-
ctx.ui.notify(
|
|
76
|
-
`Compacted ${stats.totalMessages} messages (~${formatTokens(stats.tokensBefore)} tokens) → ${stats.kept} messages (~${formatTokens(stats.tokensAfterEst)} tokens)`,
|
|
77
|
-
"info",
|
|
78
|
-
);
|
|
79
|
-
} else {
|
|
80
|
-
ctx.ui.notify("Compaction completed.", "info");
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
onError: (err: Error) => {
|
|
84
|
-
if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
|
|
85
|
-
ctx.ui.notify("Nothing to compact.", "info");
|
|
86
|
-
} else {
|
|
87
|
-
ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
},
|
|
56
|
+
description: "Alias for /unipi:lossless-compact",
|
|
57
|
+
handler: losslessCompactHandler,
|
|
92
58
|
});
|
|
93
59
|
|
|
94
60
|
// ── /unipi:session-recall (new) ─────────────────────
|
|
61
|
+
// pi-vcc parity: results are sent as a visible custom message AND fed to the
|
|
62
|
+
// agent as context (triggerTurn), so recall results drive the next turn.
|
|
95
63
|
const sessionRecallHandler = async (args: string, ctx: ExtensionCommandContext, commandName = "/unipi:session-recall") => {
|
|
96
|
-
const
|
|
64
|
+
const raw = args.trim();
|
|
65
|
+
const suffix = commandName === "/unipi:compact-recall" ? " (deprecated; use /unipi:session-recall <query>)" : "";
|
|
66
|
+
if (!raw) {
|
|
67
|
+
ctx.ui.notify(`Usage: ${commandName} <query>${suffix}`, "warning");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Parse scope:all and page:N inline (pi-vcc parity)
|
|
72
|
+
const parsed = parseRecallScope(raw);
|
|
73
|
+
const pageMatch = parsed.text.match(/\bpage:(\d+)\b/i);
|
|
74
|
+
const page = pageMatch ? Math.max(1, parseInt(pageMatch[1], 10)) : 1;
|
|
75
|
+
const query = parsed.text.replace(/\bpage:\d+\b/i, "").trim();
|
|
76
|
+
|
|
97
77
|
if (!query) {
|
|
98
|
-
const suffix = commandName === "/unipi:compact-recall" ? " (deprecated; use /unipi:session-recall <query>)" : "";
|
|
99
78
|
ctx.ui.notify(`Usage: ${commandName} <query>${suffix}`, "warning");
|
|
100
79
|
return;
|
|
101
80
|
}
|
|
@@ -109,25 +88,20 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
|
|
|
109
88
|
ctx.ui.notify("No session history available for search.", "warning");
|
|
110
89
|
return;
|
|
111
90
|
}
|
|
112
|
-
const result = vccRecall(blocks, { query,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
const lines = result.hits.map(
|
|
118
|
-
(h, i) => `[${i + 1}] score=${h.score.toFixed(2)} kind=${h.kind}\n${h.text.slice(0, 200)}`,
|
|
91
|
+
const result = vccRecall(blocks, { query, scope: parsed.scope, page });
|
|
92
|
+
pi.sendMessage(
|
|
93
|
+
{ customType: "compactor-recall", content: result.text, display: true },
|
|
94
|
+
{ triggerTurn: true },
|
|
119
95
|
);
|
|
120
|
-
ctx.ui.notify(`Found ${result.total} results:\n${lines.join("\n\n")}`, "info");
|
|
121
96
|
};
|
|
122
97
|
pi.registerCommand("unipi:session-recall", {
|
|
123
|
-
description: "
|
|
98
|
+
description: "Recall earlier parts of this session. Plain keywords work best; add scope:all to reach edited or retried turns.",
|
|
124
99
|
handler: sessionRecallHandler,
|
|
125
100
|
});
|
|
126
101
|
// Deprecated alias
|
|
127
102
|
pi.registerCommand("unipi:compact-recall", {
|
|
128
103
|
description: "(DEPRECATED) Search session history — use /unipi:session-recall instead",
|
|
129
104
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
130
|
-
deprecationLog("/unipi:compact-recall", "/unipi:session-recall");
|
|
131
105
|
return sessionRecallHandler(args, ctx, "/unipi:compact-recall");
|
|
132
106
|
},
|
|
133
107
|
});
|
|
@@ -233,12 +207,7 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
|
|
|
233
207
|
" /unipi:compact-preset <name> — apply preset\n" +
|
|
234
208
|
"\n" +
|
|
235
209
|
"Percentage trigger:\n" +
|
|
236
|
-
" Disabled by default. Enable in /unipi:compact-settings to compact at a context % before Pi's reserve-token limit
|
|
237
|
-
"\n" +
|
|
238
|
-
"Content indexing has moved to @pi-unipi/cocoindex:\n" +
|
|
239
|
-
" /unipi:cocoindex-init — initialize pipeline\n" +
|
|
240
|
-
" /unipi:cocoindex-update — index project files\n" +
|
|
241
|
-
" cocoindex_search — search indexed content",
|
|
210
|
+
" Disabled by default. Enable in /unipi:compact-settings to compact at a context % before Pi's reserve-token limit.",
|
|
242
211
|
"info",
|
|
243
212
|
);
|
|
244
213
|
},
|
package/src/compaction/brief.ts
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { NormalizedBlock } from "../types.js";
|
|
6
|
-
import { clip
|
|
6
|
+
import { clip } from "./content.js";
|
|
7
7
|
import { extractPath } from "./extract/files.js";
|
|
8
|
+
import { collapseSkillText } from "./skill-collapse.js";
|
|
8
9
|
|
|
9
10
|
const TRUNCATE_USER = 256;
|
|
10
|
-
const
|
|
11
|
+
const SEGMENT_CLOSING_ASSISTANT_HEAD_WORDS = 120;
|
|
12
|
+
const SEGMENT_CLOSING_ASSISTANT_TAIL_WORDS = 120;
|
|
13
|
+
const ASSISTANT_HEAD_WORDS = 80;
|
|
14
|
+
const ASSISTANT_TAIL_WORDS = 120;
|
|
15
|
+
const MIN_SEGMENT_CLOSING_ASSISTANT_CHARS = 120;
|
|
11
16
|
|
|
12
17
|
const SELF_TALK_PREFIX_RE =
|
|
13
18
|
/^\s*(?:hmm|wait|actually|oh|okay|ok|well|so)[,.!\s-]+/i;
|
|
@@ -33,8 +38,15 @@ const STOP_WORDS = new Set([
|
|
|
33
38
|
"if", "then", "than", "when", "where", "how", "just", "also",
|
|
34
39
|
]);
|
|
35
40
|
|
|
41
|
+
const normalizeForTokenBudget = (text: string): string =>
|
|
42
|
+
text
|
|
43
|
+
.replace(/\r\n?/g, "\n")
|
|
44
|
+
.replace(/[^\S\n]+/g, " ")
|
|
45
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
46
|
+
.trim();
|
|
47
|
+
|
|
36
48
|
const truncateTokens = (text: string, limit: number): string => {
|
|
37
|
-
const flat = text
|
|
49
|
+
const flat = normalizeForTokenBudget(text);
|
|
38
50
|
let count = 0;
|
|
39
51
|
let lastEnd = 0;
|
|
40
52
|
for (const seg of segmenter.segment(flat)) {
|
|
@@ -51,17 +63,138 @@ const truncateTokens = (text: string, limit: number): string => {
|
|
|
51
63
|
return flat;
|
|
52
64
|
};
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
/** Word spans excluding stop words — positions into the normalized flat text */
|
|
67
|
+
const significantWordSpans = (flat: string): { start: number; end: number }[] => {
|
|
68
|
+
const words: { start: number; end: number }[] = [];
|
|
69
|
+
for (const seg of segmenter.segment(flat)) {
|
|
70
|
+
if (!isWord(seg)) continue;
|
|
71
|
+
if (STOP_WORDS.has(seg.segment.toLowerCase())) continue;
|
|
72
|
+
words.push({ start: seg.index, end: seg.index + seg.segment.length });
|
|
73
|
+
}
|
|
74
|
+
return words;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Keep the first headLimit and last tailLimit significant words; elide the middle */
|
|
78
|
+
const truncateTokensHeadTail = (text: string, headLimit: number, tailLimit: number): string => {
|
|
79
|
+
const flat = normalizeForTokenBudget(text);
|
|
80
|
+
if (headLimit <= 0 || tailLimit <= 0) return flat;
|
|
81
|
+
const words = significantWordSpans(flat);
|
|
82
|
+
if (words.length <= headLimit + tailLimit) return flat;
|
|
83
|
+
const head = flat.slice(0, words[headLimit - 1].end).trimEnd();
|
|
84
|
+
const tail = flat.slice(words[words.length - tailLimit].start).trimStart();
|
|
85
|
+
return `${head}\n...(middle truncated)...\n${tail}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const nextRenderableBlock = (blocks: NormalizedBlock[], index: number): NormalizedBlock | undefined => {
|
|
89
|
+
for (let i = index + 1; i < blocks.length; i++) {
|
|
90
|
+
if (blocks[i].kind !== "tool_result") return blocks[i];
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isSegmentClosingAssistant = (blocks: NormalizedBlock[], index: number): boolean => {
|
|
96
|
+
if (blocks[index]?.kind !== "assistant") return false;
|
|
97
|
+
const next = nextRenderableBlock(blocks, index);
|
|
98
|
+
return !next || next.kind === "user";
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ── bash command compression ──
|
|
102
|
+
|
|
103
|
+
const BASH_CAP = 240;
|
|
55
104
|
const PIPE_TAIL_RE = /\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|awk|uniq|python3|node|bun)(?:\s[^|]*)?$/;
|
|
105
|
+
// Preamble/boilerplate lines that carry no durable fact on their own. Dropped
|
|
106
|
+
// when a script has more informative lines, so `set -euo pipefail\ngit commit`
|
|
107
|
+
// renders the commit rather than the shell option.
|
|
108
|
+
const TRIVIAL_LINE_RE = /^(?:set\s+[-+]|cd\s+\S+$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#)/;
|
|
109
|
+
// A real heredoc opener: `<<` at a command boundary — not preceded by a word
|
|
110
|
+
// char or `)`, so shift ops (`8 << 20`, `Rd<<8`) are not misread — with an
|
|
111
|
+
// identifier terminator starting [A-Za-z_], so numeric `<< 10` is rejected too.
|
|
112
|
+
export const HEREDOC_OPEN_RE = /(?<![\w)])<<-?\s*["']?([A-Za-z_]\w*)["']?/;
|
|
113
|
+
// File-writer heredocs (`cat > f <<EOF`, tee, dd) already name their target, so
|
|
114
|
+
// the opener alone is informative → body-only. EVERY other heredoc has a
|
|
115
|
+
// content-free opener (interpreters python3/node, remote shells `ssh host <<CMD`,
|
|
116
|
+
// sqlite3, ...) → we surface a one-line body preview.
|
|
117
|
+
const FILEWRITER_HEREDOC_RE = /(?:^|[|&;]\s*)(?:cat|tee|dd)\b/;
|
|
118
|
+
const HEREDOC_BODY_CAP = 80;
|
|
119
|
+
// Body lines that are pure boilerplate and make a poor preview.
|
|
120
|
+
const BODY_NOISE_RE = /^(?:import\s|from\s+\S+\s+import|require\(|const\s+\w+\s*=\s*require|#|\/\/|"""|'''|"use strict"|use\s+strict|<\?php)/;
|
|
56
121
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
122
|
+
/**
|
|
123
|
+
* If `lines[i]` opens a heredoc whose terminator actually appears on a later
|
|
124
|
+
* line, return that terminator's line index; otherwise -1. Callers use -1 to
|
|
125
|
+
* leave following lines intact instead of treating a stray `<<` (a shift op, a
|
|
126
|
+
* quoted string, or a truncated body) as a heredoc and skipping real commands.
|
|
127
|
+
*/
|
|
128
|
+
export const heredocCloseIndex = (lines: string[], i: number): number => {
|
|
129
|
+
const hd = lines[i].match(HEREDOC_OPEN_RE);
|
|
130
|
+
if (!hd) return -1;
|
|
131
|
+
const term = hd[1];
|
|
132
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
133
|
+
if (lines[j].trim() === term) return j;
|
|
134
|
+
}
|
|
135
|
+
return -1;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const stripCdPrefix = (line: string): string => line.replace(/^cd\s+\S+\s*&&\s*/, "").trim();
|
|
139
|
+
const stripPipeTail = (line: string): string => {
|
|
140
|
+
let c = line;
|
|
60
141
|
for (let i = 0; i < 3; i++) {
|
|
61
|
-
const stripped =
|
|
62
|
-
if (stripped ===
|
|
63
|
-
|
|
142
|
+
const stripped = c.replace(PIPE_TAIL_RE, "");
|
|
143
|
+
if (stripped === c) break;
|
|
144
|
+
c = stripped;
|
|
64
145
|
}
|
|
146
|
+
return c.trim();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Semantic compression of a (possibly multi-line) bash command.
|
|
151
|
+
* 1. Drop heredoc BODIES (keep the opener line, e.g. `cat > f <<EOF` or
|
|
152
|
+
* `python3 - <<PY`): the body is prose/script content that bloats the brief
|
|
153
|
+
* without adding countable facts, while the opener still records what ran.
|
|
154
|
+
* 2. Drop trivial preamble lines (set -euo pipefail, cd-only, export, source,
|
|
155
|
+
* comments) unless they are the ONLY line, so real work below them surfaces.
|
|
156
|
+
* 3. Strip `cd <path> &&` prefixes and pipe-tail formatting per line.
|
|
157
|
+
* 4. Join the remaining meaningful lines with `; ` and cap length.
|
|
158
|
+
*/
|
|
159
|
+
const compressBash = (raw: string): string => {
|
|
160
|
+
// Pass 1: keep heredoc opener lines, skip their bodies + terminators.
|
|
161
|
+
const rawLines = raw.split("\n");
|
|
162
|
+
const withoutHeredocBodies: string[] = [];
|
|
163
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
164
|
+
const line = rawLines[i];
|
|
165
|
+
withoutHeredocBodies.push(line);
|
|
166
|
+
// Only treat this as a heredoc when its terminator appears downstream; a
|
|
167
|
+
// stray `<<` (string/expression or truncated body) leaves later lines intact.
|
|
168
|
+
const close = heredocCloseIndex(rawLines, i);
|
|
169
|
+
if (close === -1) continue;
|
|
170
|
+
// File-writer heredocs (`cat > f <<EOF`) name their target → keep opener only.
|
|
171
|
+
// Every other heredoc has a content-free opener → grab the first meaningful
|
|
172
|
+
// body line as a preview (`python3 - <<PY`, `ssh host <<CMD`, `sqlite3 <<SQL`).
|
|
173
|
+
const wantPreview = !FILEWRITER_HEREDOC_RE.test(line);
|
|
174
|
+
let preview = "";
|
|
175
|
+
for (let j = i + 1; wantPreview && !preview && j < close; j++) {
|
|
176
|
+
const t = rawLines[j].trim();
|
|
177
|
+
if (t && !BODY_NOISE_RE.test(t)) preview = t;
|
|
178
|
+
}
|
|
179
|
+
if (preview) {
|
|
180
|
+
const clipped = preview.length > HEREDOC_BODY_CAP ? preview.slice(0, HEREDOC_BODY_CAP - 1) + "\u2026" : preview;
|
|
181
|
+
withoutHeredocBodies[withoutHeredocBodies.length - 1] = `${line.trim()} ${clipped}`;
|
|
182
|
+
}
|
|
183
|
+
i = close; // for-loop's i++ then skips past the terminator line
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const lines = withoutHeredocBodies.map((l) => l.trim()).filter(Boolean);
|
|
187
|
+
if (lines.length === 0) return raw.trim();
|
|
188
|
+
|
|
189
|
+
const meaningful = lines
|
|
190
|
+
.filter((l) => !TRIVIAL_LINE_RE.test(l))
|
|
191
|
+
.map((l) => stripPipeTail(stripCdPrefix(l)))
|
|
192
|
+
.filter(Boolean);
|
|
193
|
+
// If everything was trivial (e.g. a bare `set -e` or `ls`), fall back to the
|
|
194
|
+
// first line so we never emit an empty marker.
|
|
195
|
+
const chosen = meaningful.length ? meaningful : [stripPipeTail(stripCdPrefix(lines[0]))].filter(Boolean);
|
|
196
|
+
|
|
197
|
+
const cmd = chosen.join("; ");
|
|
65
198
|
if (cmd.length > BASH_CAP) {
|
|
66
199
|
return cmd.slice(0, BASH_CAP - 3) + "...";
|
|
67
200
|
}
|
|
@@ -97,15 +230,6 @@ export interface BriefLine {
|
|
|
97
230
|
lines: string[];
|
|
98
231
|
}
|
|
99
232
|
|
|
100
|
-
export interface TranscriptEntry {
|
|
101
|
-
role: "user" | "assistant" | "tool_error";
|
|
102
|
-
text?: string;
|
|
103
|
-
tool?: string;
|
|
104
|
-
cmd?: string;
|
|
105
|
-
ref?: string;
|
|
106
|
-
count?: number;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
233
|
export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
110
234
|
const sections: BriefLine[] = [];
|
|
111
235
|
let lastHeader = "";
|
|
@@ -119,14 +243,23 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
|
119
243
|
lastHeader = header;
|
|
120
244
|
};
|
|
121
245
|
|
|
122
|
-
|
|
246
|
+
const pushText = (header: string, text: string, ref = "") => {
|
|
247
|
+
const lines = text.split("\n");
|
|
248
|
+
if (ref && lines.length > 0) {
|
|
249
|
+
lines[lines.length - 1] = `${lines[lines.length - 1]}${ref}`;
|
|
250
|
+
}
|
|
251
|
+
for (const line of lines) push(header, line);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
|
255
|
+
const b = blocks[blockIndex];
|
|
123
256
|
switch (b.kind) {
|
|
124
257
|
case "user": {
|
|
125
258
|
if (!b.text.trim()) break;
|
|
126
|
-
const text = truncateTokens(b.text, TRUNCATE_USER);
|
|
259
|
+
const text = truncateTokens(collapseSkillText(b.text), TRUNCATE_USER);
|
|
127
260
|
if (text) {
|
|
128
261
|
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
129
|
-
|
|
262
|
+
pushText("[user]", text, ref);
|
|
130
263
|
}
|
|
131
264
|
lastHeader = "[user]";
|
|
132
265
|
break;
|
|
@@ -138,10 +271,12 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
|
138
271
|
if (stripped === raw) break;
|
|
139
272
|
raw = stripped;
|
|
140
273
|
}
|
|
141
|
-
const text =
|
|
274
|
+
const text = isSegmentClosingAssistant(blocks, blockIndex)
|
|
275
|
+
? truncateTokensHeadTail(raw, SEGMENT_CLOSING_ASSISTANT_HEAD_WORDS, SEGMENT_CLOSING_ASSISTANT_TAIL_WORDS)
|
|
276
|
+
: truncateTokensHeadTail(raw, ASSISTANT_HEAD_WORDS, ASSISTANT_TAIL_WORDS);
|
|
142
277
|
if (text) {
|
|
143
278
|
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
144
|
-
|
|
279
|
+
pushText("[assistant]", text, ref);
|
|
145
280
|
}
|
|
146
281
|
break;
|
|
147
282
|
}
|
|
@@ -154,7 +289,7 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
|
154
289
|
}
|
|
155
290
|
case "tool_result": {
|
|
156
291
|
if (b.isError) {
|
|
157
|
-
const body =
|
|
292
|
+
const body = b.text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
|
158
293
|
if (!body || body === "(no output)") break;
|
|
159
294
|
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
160
295
|
const header = `[tool_error] ${b.name}${ref}`;
|
|
@@ -265,70 +400,6 @@ export const stringifyBrief = (sections: BriefLine[]): string => {
|
|
|
265
400
|
return out.join("\n");
|
|
266
401
|
};
|
|
267
402
|
|
|
268
|
-
|
|
269
|
-
const m = line.match(/^\* (\S+)\s*(?:"([^"]*)")?\s*(?:\((#[\d, #]+)\))?\s*(?:x(\d+))?$/);
|
|
270
|
-
if (!m) return null;
|
|
271
|
-
return {
|
|
272
|
-
tool: m[1],
|
|
273
|
-
cmd: m[2] || undefined,
|
|
274
|
-
ref: m[3] || undefined,
|
|
275
|
-
count: m[4] ? parseInt(m[4]) : undefined,
|
|
276
|
-
};
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
const extractRef = (text: string): { clean: string; ref?: string } => {
|
|
280
|
-
const m = text.match(/\s*\(#(\d+)\)$/);
|
|
281
|
-
if (!m) return { clean: text };
|
|
282
|
-
return { clean: text.slice(0, m.index).trimEnd(), ref: `#${m[1]}` };
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
export const sectionsToTranscript = (sections: BriefLine[]): TranscriptEntry[] => {
|
|
286
|
-
const entries: TranscriptEntry[] = [];
|
|
287
|
-
|
|
288
|
-
for (const sec of sections) {
|
|
289
|
-
if (sec.header === "[user]") {
|
|
290
|
-
for (const line of sec.lines) {
|
|
291
|
-
const { clean, ref } = extractRef(line);
|
|
292
|
-
entries.push({ role: "user", text: clean, ...(ref && { ref }) });
|
|
293
|
-
}
|
|
294
|
-
} else if (sec.header === "[assistant]") {
|
|
295
|
-
for (const line of sec.lines) {
|
|
296
|
-
if (line.startsWith("* ")) {
|
|
297
|
-
const parsed = parseToolLine(line);
|
|
298
|
-
if (parsed) {
|
|
299
|
-
entries.push({
|
|
300
|
-
role: "assistant",
|
|
301
|
-
tool: parsed.tool,
|
|
302
|
-
...(parsed.cmd && { cmd: parsed.cmd }),
|
|
303
|
-
...(parsed.ref && { ref: parsed.ref }),
|
|
304
|
-
...(parsed.count && { count: parsed.count }),
|
|
305
|
-
});
|
|
306
|
-
} else {
|
|
307
|
-
const { clean, ref } = extractRef(line.slice(2));
|
|
308
|
-
entries.push({ role: "assistant", text: clean, ...(ref && { ref }) });
|
|
309
|
-
}
|
|
310
|
-
} else {
|
|
311
|
-
const { clean, ref } = extractRef(line);
|
|
312
|
-
entries.push({ role: "assistant", text: clean, ...(ref && { ref }) });
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
} else if (sec.header.startsWith("[tool_error]")) {
|
|
316
|
-
const headerMatch = sec.header.match(/^\[tool_error\]\s+(\S+)\s*(?:\(#(\d+)\))?/);
|
|
317
|
-
const tool = headerMatch?.[1] ?? "unknown";
|
|
318
|
-
const ref = headerMatch?.[2] ? `#${headerMatch[2]}` : undefined;
|
|
319
|
-
for (const line of sec.lines) {
|
|
320
|
-
entries.push({
|
|
321
|
-
role: "tool_error",
|
|
322
|
-
tool,
|
|
323
|
-
text: line,
|
|
324
|
-
...(ref && { ref }),
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return entries;
|
|
331
|
-
};
|
|
332
|
-
|
|
403
|
+
/** Render a set of blocks to a brief transcript string (used by ranked selection cost calc) */
|
|
333
404
|
export const compileBrief = (blocks: NormalizedBlock[]): string =>
|
|
334
405
|
stringifyBrief(buildBriefSections(blocks));
|
|
@@ -8,7 +8,7 @@ import { extractGoals } from "./extract/goals.js";
|
|
|
8
8
|
import { extractFiles } from "./extract/files.js";
|
|
9
9
|
import { extractPreferences, dedupPreferencesAgainstGoals } from "./extract/preferences.js";
|
|
10
10
|
import { extractCommits, formatCommits } from "./extract/commits.js";
|
|
11
|
-
import { buildBriefSections,
|
|
11
|
+
import { buildBriefSections, stringifyBrief } from "./brief.js";
|
|
12
12
|
import type { SectionData } from "../types.js";
|
|
13
13
|
export type { SectionData } from "../types.js";
|
|
14
14
|
|
|
@@ -57,9 +57,9 @@ const formatFileActivity = (blocks: NormalizedBlock[]): string[] => {
|
|
|
57
57
|
return lines;
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
-
export const buildSections = (input: { blocks: NormalizedBlock[] }): SectionData => {
|
|
60
|
+
export const buildSections = (input: { blocks: NormalizedBlock[]; briefBlocks?: NormalizedBlock[] }): SectionData => {
|
|
61
61
|
const { blocks } = input;
|
|
62
|
-
const briefSections = buildBriefSections(blocks);
|
|
62
|
+
const briefSections = buildBriefSections(input.briefBlocks ?? blocks);
|
|
63
63
|
const sessionGoal = extractGoals(blocks);
|
|
64
64
|
const userPreferences = dedupPreferencesAgainstGoals(
|
|
65
65
|
extractPreferences(blocks),
|
|
@@ -72,6 +72,5 @@ export const buildSections = (input: { blocks: NormalizedBlock[] }): SectionData
|
|
|
72
72
|
commits: formatCommits(extractCommits(blocks)),
|
|
73
73
|
userPreferences,
|
|
74
74
|
briefTranscript: stringifyBrief(briefSections),
|
|
75
|
-
transcriptEntries: sectionsToTranscript(briefSections),
|
|
76
75
|
};
|
|
77
76
|
};
|