pi-cliffcompaction 0.1.0 → 0.2.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/CHANGELOG.md +4 -0
- package/README.md +16 -15
- package/index.ts +36 -3
- package/lib/cliff.ts +32 -2
- package/lib/config.ts +15 -3
- package/lib/decode.ts +5 -5
- package/lib/dialects/anthropic.ts +50 -3
- package/lib/dialects/base.ts +28 -0
- package/lib/dialects/index.ts +8 -1
- package/lib/dialects/openai-chat.ts +39 -10
- package/lib/dialects/openai-responses.ts +99 -10
- package/lib/engine.ts +120 -108
- package/lib/images.ts +29 -10
- package/lib/index.ts +4 -1
- package/lib/json.ts +9 -14
- package/lib/pi-hook.ts +144 -73
- package/lib/store.ts +18 -4
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ After install, **fully restart** Pi/OMP. `/reload` can keep old JavaScript modul
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Compared to Pi compact
|
|
27
27
|
|
|
28
28
|
Default Pi compact asks a model to write a structured memo (`Goal`, `Progress`, `Key Decisions`, file lists). Tool dumps are truncated for that summarizer, then rewritten. The last ~20k tokens stay verbatim. The previous memo is fed into the next one.
|
|
29
29
|
|
|
@@ -49,11 +49,6 @@ This package keeps the same *when* (token window, overflow, `/compact`) and repl
|
|
|
49
49
|
|
|
50
50
|
## Install
|
|
51
51
|
|
|
52
|
-
```bash
|
|
53
|
-
pi install npm:pi-cliffcompaction
|
|
54
|
-
omp install npm:pi-cliffcompaction
|
|
55
|
-
```
|
|
56
|
-
|
|
57
52
|
From a clone of [AdityaVG13/pi-stack](https://github.com/AdityaVG13/pi-stack), inside the repo:
|
|
58
53
|
|
|
59
54
|
```bash
|
|
@@ -201,7 +196,7 @@ Override path: `PI_CLIFF_CONFIG` / `OMP_CLIFF_CONFIG`, or `PI_CONFIG_DIR` / `OMP
|
|
|
201
196
|
| `keepThinking` | `true` | Fold thinking as text; `false` drops it |
|
|
202
197
|
| `cmdMaxChars` | `150` | Tool-call signature budget |
|
|
203
198
|
| `resultMaxChars` | `500` | Longer tool results are dropped |
|
|
204
|
-
| `humanMaxChars` | `20000` |
|
|
199
|
+
| `humanMaxChars` | `20000` | Cap on summarized middle user text; not the protected live head |
|
|
205
200
|
| `shadow` | `false` | Cancel compaction; log what would have happened |
|
|
206
201
|
| `strict` | `false` | Walk summary truncation (rung 3) when still over the library threshold |
|
|
207
202
|
| `thresholdTokens` | `200000` | Engine/library trigger (chars/4). Pi's own trigger is separate |
|
|
@@ -235,7 +230,9 @@ The Pi extension is a thin adapter. The algorithm is importable with no Pi host:
|
|
|
235
230
|
| `Engine.reactive(ctx)` | Context-length error ladder. |
|
|
236
231
|
| Dialects | `anthropic`, `openai` (Chat Completions), `openai-responses`, `pi` |
|
|
237
232
|
|
|
238
|
-
Token
|
|
233
|
+
Token estimates use the floor of serialized UTF-16 code units / 4, with Python-style separators but JavaScript number/string semantics. This is not a tokenizer. Images use PNG/JPEG/GIF/WebP dimensions (28x28 patches, capped at 4784); raw base64, data URLs and Pi image blocks are recognized. Unknown dimensions conservatively cost 4784 without fetching remote URLs.
|
|
234
|
+
|
|
235
|
+
Digests are implementation-local: key ordering, floating-point formatting and Unicode counts can differ from CPython. The shared ASCII reference fixtures do not establish general cross-language hash compatibility. Library inputs must be JSON data.
|
|
239
236
|
|
|
240
237
|
---
|
|
241
238
|
|
|
@@ -244,15 +241,20 @@ Token estimate is chars/4 on `json.dumps`-style serialization, except images whi
|
|
|
244
241
|
- Summaries contain only excerpts of original text, never a paraphrase.
|
|
245
242
|
- Exactly one summary header after compaction; re-compaction does not nest.
|
|
246
243
|
- Head messages and kept tail messages are identity-equal to the input objects (library `compact` / `Engine`).
|
|
247
|
-
-
|
|
244
|
+
- `Engine.prepare` validates every message against its current serialized content, including nested in-place edits. Only identical content can reuse a digest/cost; hash-chain prefixes are reused only while canonical digests agree. The engine retains snapshots for its latest request, not every visited branch.
|
|
245
|
+
- Cached summaries are scoped to the dialect, configuration and fixed request budget. Emergency escalation/truncation stays request-local. Changed canonical content cannot reuse a summary covering the old content.
|
|
246
|
+
- Prefix-store entry and serialized UTF-8 byte caps are hard admission limits. Oversized entries are not cached and do not evict useful entries; zero capacity disables admission. These are not total heap limits.
|
|
247
|
+
- OpenAI Chat summary keys include the effective `reasoning_content` / `reasoning` text so reasoning redactions invalidate old summaries.
|
|
248
248
|
- Image token cost does not track base64 length.
|
|
249
249
|
|
|
250
250
|
## Error model
|
|
251
251
|
|
|
252
252
|
- `compact` returns `null` when there is no assistant turn, not enough turns to keep, or the rewrite would not shrink the list.
|
|
253
253
|
- `Engine.prepare` never throws on a well-formed body; store misses and inconsistent entries fail-open to passthrough.
|
|
254
|
-
- The Pi hook catches handler errors and returns undefined (Pi default compaction).
|
|
255
|
-
-
|
|
254
|
+
- The Pi hook catches handler errors and returns undefined (Pi default compaction). Protected heads containing images or other non-text content also use this fallback, rather than silently losing attachments. The fallback may use an LLM and does not preserve image bytes verbatim.
|
|
255
|
+
- Pi compaction keeps the original head across successive cliffs, without recycling an old summary or retaining the discarded middle. Protected head text is not trimmed, truncated or interpreted as a summary marker.
|
|
256
|
+
- Strict truncation packs against the actual local billable estimate, including escaping and the kept tail. Library head messages and the Pi hook's folded live head text are protected, even when that floor cannot fit.
|
|
257
|
+
- The engine exposes `overBudget`; the Pi hook persists `details.overBudget` with its estimate. Neither silently drops the protected task or tail to force a fit.
|
|
256
258
|
|
|
257
259
|
## Tests
|
|
258
260
|
|
|
@@ -260,16 +262,15 @@ Token estimate is chars/4 on `json.dumps`-style serialization, except images whi
|
|
|
260
262
|
npm test --prefix packages/pi-cliffcompaction
|
|
261
263
|
```
|
|
262
264
|
|
|
263
|
-
|
|
265
|
+
Tests cover content-validated caching/redaction, warm/cold equivalence, escaped budget boundaries, cache admission, image accounting and protected Pi task text. `tests/reference-gold.test.mjs` checks the shared reference fixtures; `tests/json.test.mjs` records the JavaScript-local compatibility boundaries.
|
|
264
266
|
|
|
265
267
|
## No-claim boundaries
|
|
266
268
|
|
|
267
269
|
- This package does **not** claim the paper's SWE-bench / Terminal-Bench / KernelBench scores. Those were measured on other scaffolds with this algorithm.
|
|
268
270
|
- Trigger timing is Pi's (`compaction.reserveTokens`). The GitHub proxy default of 200k tokens is a library default, not what Pi uses unless you set Pi's reserve so the remaining window matches.
|
|
269
271
|
- Selector / Soft Group Verification from the paper is out of scope.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
272
|
+
- Model-level hash identity is conditional on collision freedom for the compared inputs. No global fixed-length hash injectivity, SHA-256 collision proof, or Lean-to-TypeScript refinement proof is claimed.
|
|
273
|
+
- Redacting a request prevents stale-summary reuse for that history; it does not securely erase older prefix-store entries from memory before eviction.
|
|
273
274
|
- Other extensions that also handle `session_before_compact` will race. Load one.
|
|
274
275
|
- `/compact` with extra instructions is ignored.
|
|
275
276
|
- Shadow mode on overflow cancels recovery compaction; the overflowing request is left as-is (fail-open).
|
package/index.ts
CHANGED
|
@@ -15,7 +15,14 @@ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent
|
|
|
15
15
|
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { loadConfig, type Config } from "./lib/config.ts";
|
|
17
17
|
import { isRecord, type JsonObject, type JsonValue } from "./lib/decode.ts";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
compactSession,
|
|
20
|
+
liveFromEntries,
|
|
21
|
+
resolveCompactReason,
|
|
22
|
+
type CliffDetails,
|
|
23
|
+
type CompactReason,
|
|
24
|
+
type HookEntry,
|
|
25
|
+
} from "./lib/pi-hook.ts";
|
|
19
26
|
|
|
20
27
|
type LastCliff = {
|
|
21
28
|
reason: string;
|
|
@@ -23,6 +30,10 @@ type LastCliff = {
|
|
|
23
30
|
details: CliffDetails;
|
|
24
31
|
};
|
|
25
32
|
|
|
33
|
+
function eventReason(event: object): unknown {
|
|
34
|
+
return Object.prototype.hasOwnProperty.call(event, "reason") ? (event as { reason?: unknown }).reason : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
function jsonObjectFrom(value: JsonValue): JsonObject {
|
|
27
38
|
// SAFETY: LLM Message is JSON-serializable; reparse to JsonObject.
|
|
28
39
|
const parsed: JsonValue = JSON.parse(JSON.stringify(value));
|
|
@@ -61,6 +72,15 @@ function hookEntriesFromBranch(branchEntries: readonly SessionEntry[]): HookEntr
|
|
|
61
72
|
continue;
|
|
62
73
|
}
|
|
63
74
|
|
|
75
|
+
if (entry.type === "branch_summary" && entry.summary) {
|
|
76
|
+
out.push({
|
|
77
|
+
id: entry.id,
|
|
78
|
+
kind: "branch_summary",
|
|
79
|
+
summary: entry.summary,
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
64
84
|
if (entry.type === "compaction") {
|
|
65
85
|
out.push({
|
|
66
86
|
id: entry.id,
|
|
@@ -102,6 +122,7 @@ function formatStatus(cfg: Config, last: LastCliff | null): string {
|
|
|
102
122
|
export default function registerCliffCompaction(pi: ExtensionAPI) {
|
|
103
123
|
let cfg = loadConfig();
|
|
104
124
|
let last: LastCliff | null = null;
|
|
125
|
+
let pendingReason: CompactReason = "manual";
|
|
105
126
|
|
|
106
127
|
pi.registerCommand("cliff", {
|
|
107
128
|
description: "CliffCompaction status and config (mechanical autocompaction)",
|
|
@@ -125,12 +146,24 @@ export default function registerCliffCompaction(pi: ExtensionAPI) {
|
|
|
125
146
|
},
|
|
126
147
|
});
|
|
127
148
|
|
|
149
|
+
// session_before_compact has no reason. Auto-compaction emits this first.
|
|
150
|
+
pi.on("auto_compaction_start", (event) => {
|
|
151
|
+
pendingReason = resolveCompactReason(eventReason(event));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
pi.on("auto_compaction_end", () => {
|
|
155
|
+
pendingReason = "manual";
|
|
156
|
+
});
|
|
157
|
+
|
|
128
158
|
pi.on("session_before_compact", async (event, ctx) => {
|
|
129
159
|
if (!cfg.enabled) {
|
|
130
160
|
return;
|
|
131
161
|
}
|
|
132
162
|
|
|
133
|
-
const { preparation, branchEntries
|
|
163
|
+
const { preparation, branchEntries } = event;
|
|
164
|
+
const fromEvent = resolveCompactReason(eventReason(event));
|
|
165
|
+
const reason = fromEvent !== "manual" ? fromEvent : pendingReason;
|
|
166
|
+
pendingReason = "manual";
|
|
134
167
|
|
|
135
168
|
try {
|
|
136
169
|
const entries = hookEntriesFromBranch(branchEntries);
|
|
@@ -180,6 +213,6 @@ export default function registerCliffCompaction(pi: ExtensionAPI) {
|
|
|
180
213
|
return;
|
|
181
214
|
}
|
|
182
215
|
|
|
183
|
-
ctx.ui.setStatus("cliff", "cliff · compacted (" +
|
|
216
|
+
ctx.ui.setStatus("cliff", "cliff · compacted (" + (last?.reason ?? "done") + ")");
|
|
184
217
|
});
|
|
185
218
|
}
|
package/lib/cliff.ts
CHANGED
|
@@ -70,6 +70,15 @@ export function compact(
|
|
|
70
70
|
|
|
71
71
|
let headLen = firstAssistant;
|
|
72
72
|
|
|
73
|
+
// A cliff followed by another user prompt is no longer the final head item.
|
|
74
|
+
// Only the original prefix before that cliff remains protected on a recap.
|
|
75
|
+
for (let i = 0; i < firstAssistant; i++) {
|
|
76
|
+
if (dialect.isSummaryMessage(messages[i])) {
|
|
77
|
+
headLen = i;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
while (
|
|
74
83
|
headLen > 0 &&
|
|
75
84
|
(dialect.isSummaryMessage(messages[headLen - 1]) ||
|
|
@@ -124,11 +133,32 @@ export function compact(
|
|
|
124
133
|
newMessages.push(msg);
|
|
125
134
|
}
|
|
126
135
|
|
|
127
|
-
|
|
136
|
+
const cut = messages.length - kept.length;
|
|
137
|
+
|
|
138
|
+
if (newMessages.length > messages.length) {
|
|
128
139
|
return null;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
|
-
|
|
142
|
+
// keepRecent 0 replaces a one-message body with one summary. Message count
|
|
143
|
+
// stays the same; thought/result caps can still shrink the payload.
|
|
144
|
+
// Do not treat "drop the previous summary, keep the same tail" as gain:
|
|
145
|
+
// that is a same-length rewrite that throws away compacted history.
|
|
146
|
+
if (newMessages.length === messages.length) {
|
|
147
|
+
let onlySummaries = true;
|
|
148
|
+
let oldChars = 0;
|
|
149
|
+
|
|
150
|
+
for (let i = headLen; i < cut; i++) {
|
|
151
|
+
if (!dialect.isSummaryMessage(messages[i])) {
|
|
152
|
+
onlySummaries = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
oldChars += JSON.stringify(messages[i]).length;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (onlySummaries || JSON.stringify(summary).length >= oldChars) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
132
162
|
|
|
133
163
|
return { messages: newMessages, headLen, summary, cut };
|
|
134
164
|
}
|
package/lib/config.ts
CHANGED
|
@@ -70,9 +70,15 @@ function envInt(name: string, fallback: number): number {
|
|
|
70
70
|
return fallback;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const
|
|
73
|
+
const trimmed = raw.trim();
|
|
74
74
|
|
|
75
|
-
if (
|
|
75
|
+
if (!/^[+-]?\d+$/.test(trimmed)) {
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const n = Number.parseInt(trimmed, 10);
|
|
80
|
+
|
|
81
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
76
82
|
return fallback;
|
|
77
83
|
}
|
|
78
84
|
|
|
@@ -116,7 +122,7 @@ export function replaceConfig(cfg: Config, patch: ConfigPatch): Config {
|
|
|
116
122
|
function intField(obj: JsonObject, snake: string, camel: string, fallback: number): number {
|
|
117
123
|
const raw = obj[snake] ?? obj[camel];
|
|
118
124
|
|
|
119
|
-
if (isNumber(raw) && Number.isInteger(raw)) {
|
|
125
|
+
if (isNumber(raw) && Number.isInteger(raw) && raw >= 0) {
|
|
120
126
|
return raw;
|
|
121
127
|
}
|
|
122
128
|
|
|
@@ -183,6 +189,12 @@ export function defaultConfigPath(): string {
|
|
|
183
189
|
return explicit;
|
|
184
190
|
}
|
|
185
191
|
|
|
192
|
+
const configDir = envRaw("PI_CONFIG_DIR") || envRaw("OMP_CONFIG_DIR");
|
|
193
|
+
|
|
194
|
+
if (configDir) {
|
|
195
|
+
return join(configDir, "cliffcompaction.json");
|
|
196
|
+
}
|
|
197
|
+
|
|
186
198
|
const home = homedir();
|
|
187
199
|
const piDir = envRaw("PI_CONFIG_DIR") ?? join(home, ".pi", "agent");
|
|
188
200
|
const ompDir = envRaw("OMP_CONFIG_DIR") ?? join(home, ".omp", "agent");
|
package/lib/decode.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Boundary decoders (anti-slop). JSON request bodies are untyped at the
|
|
3
3
|
* I/O edge; everything inside the engine consumes these predicates.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Primitive checks use constructor identity: JSON data never boxes
|
|
6
|
+
* primitives. Records use prototype identity so an own constructor field
|
|
7
|
+
* remains data. Null-prototype objects also count as records.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export type JsonPrimitive = string | number | boolean | null;
|
|
@@ -45,9 +45,9 @@ export function isObject(value: JsonValue | undefined | null): value is JsonObje
|
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const
|
|
48
|
+
const proto = Object.getPrototypeOf(value);
|
|
49
49
|
|
|
50
|
-
return
|
|
50
|
+
return proto === Object.prototype || proto === null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export function isRecord(value: JsonValue | undefined | null): value is JsonObject {
|
|
@@ -41,6 +41,8 @@ function resultText(content: JsonValue | undefined): string {
|
|
|
41
41
|
for (const p of content) {
|
|
42
42
|
if (isRecord(p) && asString(p.type) === "text") {
|
|
43
43
|
parts.push(asString(p.text));
|
|
44
|
+
} else if (isRecord(p) && asString(p.type) === "search_result") {
|
|
45
|
+
parts.push(resultText(p.content));
|
|
44
46
|
} else if (isString(p)) {
|
|
45
47
|
parts.push(p);
|
|
46
48
|
}
|
|
@@ -68,12 +70,25 @@ function canonBlock(block: JsonObject): JsonArray {
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
if (t === "tool_result") {
|
|
71
|
-
|
|
73
|
+
const result: JsonArray = [
|
|
72
74
|
"tool_result",
|
|
73
75
|
asString(block.tool_use_id),
|
|
74
76
|
resultText(block.content),
|
|
75
77
|
asBoolean(block.is_error, false),
|
|
76
78
|
];
|
|
79
|
+
|
|
80
|
+
const attachments: JsonArray = [];
|
|
81
|
+
|
|
82
|
+
for (const part of asArray(block.content)) {
|
|
83
|
+
if (isRecord(part) && (asString(part.type) === "image" || asString(part.type) === "document")) {
|
|
84
|
+
attachments.push(canonBlock(part));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Dropped attachment bytes still determine whether the original turn triggers a cliff.
|
|
89
|
+
if (attachments.length > 0) result.push(attachments);
|
|
90
|
+
|
|
91
|
+
return result;
|
|
77
92
|
}
|
|
78
93
|
|
|
79
94
|
if (t === "thinking") {
|
|
@@ -86,6 +101,19 @@ function canonBlock(block: JsonObject): JsonArray {
|
|
|
86
101
|
|
|
87
102
|
if (t === "image" || t === "document") {
|
|
88
103
|
const src = asObject(block.source) ?? {};
|
|
104
|
+
|
|
105
|
+
if (t === "document" && asString(src.type) === "content") {
|
|
106
|
+
const blocks: JsonArray = [];
|
|
107
|
+
|
|
108
|
+
for (const part of asArray(src.content)) {
|
|
109
|
+
if (isRecord(part)) {
|
|
110
|
+
blocks.push(canonBlock(part));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [t, "content", digestObj(isString(src.content) ? src.content : blocks)];
|
|
115
|
+
}
|
|
116
|
+
|
|
89
117
|
const payload = asString(src.data) || asString(src.url);
|
|
90
118
|
const h = digestBytes(payload);
|
|
91
119
|
|
|
@@ -207,9 +235,28 @@ function summarizeAssistant(msg: JsonObject, cfg: Config): string[] {
|
|
|
207
235
|
if (cfg.keepThinking) {
|
|
208
236
|
thinkings.push(asString(b.thinking));
|
|
209
237
|
}
|
|
210
|
-
} else if (t === "tool_use") {
|
|
238
|
+
} else if (t === "tool_use" || t === "server_tool_use" || t === "mcp_tool_use") {
|
|
211
239
|
const args = canonicalJson(b.input ?? {});
|
|
212
240
|
sigs.push("[" + asString(b.name, "?") + "] " + truncate(args, cfg.cmdMaxChars));
|
|
241
|
+
} else if (t === "mcp_tool_result" || t === "bash_code_execution_tool_result" || t === "code_execution_tool_result"
|
|
242
|
+
|| t === "text_editor_code_execution_tool_result"
|
|
243
|
+
|| (t === "web_search_tool_result" && asString(asObject(b.content)?.type) === "web_search_tool_result_error")
|
|
244
|
+
|| (t === "web_fetch_tool_result" && asString(asObject(b.content)?.type) === "web_fetch_tool_result_error")
|
|
245
|
+
|| (t === "tool_search_tool_result" && asString(asObject(b.content)?.type) === "tool_search_tool_result_error")) {
|
|
246
|
+
const content = asObject(b.content);
|
|
247
|
+
|
|
248
|
+
if (t === "text_editor_code_execution_tool_result"
|
|
249
|
+
&& asString(content?.type) === "text_editor_code_execution_view_result"
|
|
250
|
+
&& asString(content?.file_type) !== "text") {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const text = t === "mcp_tool_result"
|
|
255
|
+
? resultText(b.content).trim() : canonicalJson(b.content ?? null);
|
|
256
|
+
|
|
257
|
+
if (text && text.length <= cfg.resultMaxChars) {
|
|
258
|
+
sigs.push("result: " + text);
|
|
259
|
+
}
|
|
213
260
|
}
|
|
214
261
|
}
|
|
215
262
|
}
|
|
@@ -293,7 +340,7 @@ function summarizeUser(msg: JsonObject, cfg: Config): string[] {
|
|
|
293
340
|
}
|
|
294
341
|
|
|
295
342
|
parts.push("user: " + truncate(text.trim(), cfg.humanMaxChars));
|
|
296
|
-
} else if (t === "tool_result") {
|
|
343
|
+
} else if (t === "tool_result" || t === "search_result") {
|
|
297
344
|
const text = resultText(b.content).trim();
|
|
298
345
|
|
|
299
346
|
if (text && text.length <= cfg.resultMaxChars) {
|
package/lib/dialects/base.ts
CHANGED
|
@@ -15,6 +15,12 @@ export const SUMMARY_HEADER =
|
|
|
15
15
|
export const PI_COMPACTION_PREFIX =
|
|
16
16
|
"The conversation history before this point was compacted into the following summary:";
|
|
17
17
|
|
|
18
|
+
/** Match Pi's convertToLlm wrapping of /tree branch_summary entries. */
|
|
19
|
+
export const BRANCH_SUMMARY_PREFIX =
|
|
20
|
+
"The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
|
|
21
|
+
|
|
22
|
+
export const BRANCH_SUMMARY_SUFFIX = "</summary>";
|
|
23
|
+
|
|
18
24
|
const TASK_NOTIFICATION_RE = /<task-notification>[\s\S]*?<\/task-notification>\s*/g;
|
|
19
25
|
|
|
20
26
|
export function stripTaskNotifications(text: string): string {
|
|
@@ -36,6 +42,28 @@ export function startsWithSummaryHeader(text: string): boolean {
|
|
|
36
42
|
return text.startsWith(SUMMARY_HEADER) || text.startsWith(PI_COMPACTION_PREFIX);
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/** Keep the newest contiguous summary parts that fit the caller's wire budget.
|
|
46
|
+
* The protected prefix survives even when it alone cannot fit.
|
|
47
|
+
*/
|
|
48
|
+
export function fitSummary(prefix: string, parts: string[], fits: (text: string) => boolean): string {
|
|
49
|
+
let text = prefix;
|
|
50
|
+
// Header-only prefixes need a blank line before the first part. A prefix that
|
|
51
|
+
// already holds protected parts (folded Pi head) must keep the --- delimiter.
|
|
52
|
+
const glue = prefix.includes("\n\n") ? "\n\n---\n\n" : "\n\n";
|
|
53
|
+
|
|
54
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
55
|
+
const candidate = prefix + glue + parts.slice(i).join("\n\n---\n\n");
|
|
56
|
+
|
|
57
|
+
if (!fits(candidate)) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
text = candidate;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
|
|
39
67
|
export type Dialect = {
|
|
40
68
|
name: string;
|
|
41
69
|
digestMessage: (msg: JsonObject) => string;
|
package/lib/dialects/index.ts
CHANGED
|
@@ -11,7 +11,14 @@ import type { Dialect } from "./base.ts";
|
|
|
11
11
|
|
|
12
12
|
export type { Dialect } from "./base.ts";
|
|
13
13
|
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
SUMMARY_HEADER,
|
|
16
|
+
PI_COMPACTION_PREFIX,
|
|
17
|
+
BRANCH_SUMMARY_PREFIX,
|
|
18
|
+
BRANCH_SUMMARY_SUFFIX,
|
|
19
|
+
truncate,
|
|
20
|
+
stripTaskNotifications,
|
|
21
|
+
} from "./base.ts";
|
|
15
22
|
|
|
16
23
|
export { DIALECT as anthropicDialect } from "./anthropic.ts";
|
|
17
24
|
|
|
@@ -53,8 +53,14 @@ export function contentText(content: JsonValue | undefined): string {
|
|
|
53
53
|
const parts: string[] = [];
|
|
54
54
|
|
|
55
55
|
for (const p of content) {
|
|
56
|
-
if (isRecord(p)
|
|
57
|
-
|
|
56
|
+
if (isRecord(p)) {
|
|
57
|
+
const type = asString(p.type);
|
|
58
|
+
|
|
59
|
+
if (type === "text") {
|
|
60
|
+
parts.push(asString(p.text));
|
|
61
|
+
} else if (type === "refusal") {
|
|
62
|
+
parts.push(asString(p.refusal));
|
|
63
|
+
}
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -68,6 +74,17 @@ export function contentText(content: JsonValue | undefined): string {
|
|
|
68
74
|
return String(content);
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
function toolCallsOf(msg: JsonObject): JsonArray {
|
|
78
|
+
const calls = asArray(msg.tool_calls).slice();
|
|
79
|
+
const legacy = asObject(msg.function_call);
|
|
80
|
+
|
|
81
|
+
if (legacy !== null) {
|
|
82
|
+
calls.push({ function: legacy });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return calls;
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
export function digestMessage(msg: JsonObject): string {
|
|
72
89
|
const content = msg.content;
|
|
73
90
|
let blocks: JsonArray = [];
|
|
@@ -86,15 +103,23 @@ export function digestMessage(msg: JsonObject): string {
|
|
|
86
103
|
blocks = out;
|
|
87
104
|
}
|
|
88
105
|
|
|
106
|
+
// Refusal-only completions have null content but still carry assistant text.
|
|
107
|
+
const refusal = asString(msg.refusal);
|
|
108
|
+
|
|
109
|
+
if (refusal) {
|
|
110
|
+
blocks.push(["refusal", refusal]);
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
const toolCalls: JsonArray = [];
|
|
90
114
|
|
|
91
|
-
for (const tc of
|
|
115
|
+
for (const tc of toolCallsOf(msg)) {
|
|
92
116
|
if (!isRecord(tc)) {
|
|
93
117
|
continue;
|
|
94
118
|
}
|
|
95
119
|
|
|
96
|
-
const
|
|
97
|
-
|
|
120
|
+
const custom = asString(tc.type) === "custom";
|
|
121
|
+
const fn = asObject(custom ? tc.custom : tc.function) ?? {};
|
|
122
|
+
toolCalls.push([asString(tc.id), asString(fn.name), asString(custom ? fn.input : fn.arguments)]);
|
|
98
123
|
}
|
|
99
124
|
|
|
100
125
|
return digestObj([
|
|
@@ -103,6 +128,8 @@ export function digestMessage(msg: JsonObject): string {
|
|
|
103
128
|
toolCalls,
|
|
104
129
|
asString(msg.tool_call_id),
|
|
105
130
|
asString(msg.name),
|
|
131
|
+
// Reasoning is summary content, not volatile transport metadata.
|
|
132
|
+
asString(msg.reasoning_content ?? msg.reasoning),
|
|
106
133
|
]);
|
|
107
134
|
}
|
|
108
135
|
|
|
@@ -129,16 +156,18 @@ function summarizeAssistant(msg: JsonObject, cfg: Config): string[] {
|
|
|
129
156
|
}
|
|
130
157
|
}
|
|
131
158
|
|
|
132
|
-
const
|
|
159
|
+
const text = [contentText(msg.content).trim(), asString(msg.refusal).trim()].filter(Boolean).join("\n");
|
|
160
|
+
const thought = truncate(text, cfg.thoughtMaxChars);
|
|
133
161
|
const sigs: string[] = [];
|
|
134
162
|
|
|
135
|
-
for (const tc of
|
|
163
|
+
for (const tc of toolCallsOf(msg)) {
|
|
136
164
|
if (!isRecord(tc)) {
|
|
137
165
|
continue;
|
|
138
166
|
}
|
|
139
167
|
|
|
140
|
-
const
|
|
141
|
-
|
|
168
|
+
const custom = asString(tc.type) === "custom";
|
|
169
|
+
const fn = asObject(custom ? tc.custom : tc.function) ?? {};
|
|
170
|
+
let args = custom ? fn.input : fn.arguments;
|
|
142
171
|
|
|
143
172
|
if (!isString(args)) {
|
|
144
173
|
args = canonicalJson(args ?? "");
|
|
@@ -171,7 +200,7 @@ export function summarizeMessage(msg: JsonObject, cfg: Config): string[] {
|
|
|
171
200
|
return summarizeAssistant(msg, cfg);
|
|
172
201
|
}
|
|
173
202
|
|
|
174
|
-
if (role === "tool") {
|
|
203
|
+
if (role === "tool" || role === "function") {
|
|
175
204
|
const text = contentText(msg.content).trim();
|
|
176
205
|
|
|
177
206
|
if (text && text.length <= cfg.resultMaxChars) {
|