afucontext 0.0.0-stage → 0.1.17
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 +201 -0
- package/README.md +158 -2
- package/README.zh-CN.md +159 -0
- package/bin/afu.mjs +127 -0
- package/bin/cache-lease.mjs +16 -0
- package/bin/host-autoload.py +546 -0
- package/bin/install-guide.mjs +56 -0
- package/bin/install-process.mjs +19 -0
- package/bin/platform.mjs +74 -0
- package/bin/prebuilt.mjs +64 -0
- package/bin/runtime.mjs +115 -0
- package/package.json +29 -4
- package/platform-dependencies.json +15 -0
- package/platform-locks/dsh/package-lock.json +425 -0
- package/platform-locks/dsh/package.json +16 -0
- package/platform-locks/pi/package-lock.json +1789 -0
- package/platform-locks/pi/package.json +9 -0
- package/python/afu_context_local-0.1.17-py3-none-any.whl +0 -0
- package/python/constraints.txt +25 -0
- package/release.json +7 -0
- package/scripts/dsh-overlay.mjs +105 -0
- package/scripts/dsh-presets.mjs +49 -0
- package/scripts/dsh-web-probe.mjs +14 -0
- package/src/afu_host_integrations/dsh/dist/commands.js +28 -0
- package/src/afu_host_integrations/dsh/dist/index.js +548 -0
- package/src/afu_host_integrations/pi/dist/index.js +565 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/commands.js +40 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/host-worker.js +170 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/index.js +243 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/prefix.js +12 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/protocol.js +79 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/telemetry.js +78 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { sessionEntryToContextMessages, convertToLlm, estimateTokens, getAgentDir, SettingsManager, DEFAULT_COMPACTION_SETTINGS } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { journal, sessionHash, afuTrigger, summaryPrefix, observationId, afuStatus, afuDashboard, STATUS_COMMAND, DASHBOARD_COMMAND, CompactWorkerClient, HostCompactPool, bindingKey, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
|
|
3
|
+
export function createCompactPiExtension(options = {}) {
|
|
4
|
+
return function compactPi(pi) {
|
|
5
|
+
const injected = options.worker instanceof CompactWorkerClient ? options.worker : undefined;
|
|
6
|
+
const host = new HostCompactPool(options.worker instanceof CompactWorkerClient ? undefined : options.worker);
|
|
7
|
+
const compact = async (request, ctx, signal) => {
|
|
8
|
+
if (injected)
|
|
9
|
+
return injected.compact(request, signal);
|
|
10
|
+
return host.compact(request, await piHostBinding(ctx, pi), signal);
|
|
11
|
+
};
|
|
12
|
+
let warned = false;
|
|
13
|
+
const log = journal("pi");
|
|
14
|
+
const pending = new Map();
|
|
15
|
+
let epoch = 0;
|
|
16
|
+
let timer;
|
|
17
|
+
let automatic;
|
|
18
|
+
const fingerprint = (ctx) => bindingKey([
|
|
19
|
+
ctx.sessionManager.getSessionId(), ctx.model,
|
|
20
|
+
ctx.sessionManager.buildContextEntries(),
|
|
21
|
+
]);
|
|
22
|
+
const invalidate = () => {
|
|
23
|
+
epoch++;
|
|
24
|
+
if (timer)
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
timer = undefined;
|
|
27
|
+
};
|
|
28
|
+
// /afuctx-status and /afuctx-dashboard, like the Claude Code and Codex commands.
|
|
29
|
+
pi.registerCommand?.(STATUS_COMMAND, {
|
|
30
|
+
description: "Show AFU context-compaction status for this session",
|
|
31
|
+
handler: async (_args, ctx) => {
|
|
32
|
+
ctx.ui.notify(await afuStatus("pi", ctx.sessionManager.getSessionId()), "info");
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
pi.registerCommand?.(DASHBOARD_COMMAND, {
|
|
36
|
+
description: "Open the AFU dashboard in the browser",
|
|
37
|
+
handler: async (_args, ctx) => {
|
|
38
|
+
ctx.ui.notify(await afuDashboard(), "info");
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
pi.on("agent_start", invalidate);
|
|
42
|
+
pi.on("session_before_switch", invalidate);
|
|
43
|
+
pi.on("session_before_fork", invalidate);
|
|
44
|
+
pi.on("session_before_tree", invalidate);
|
|
45
|
+
pi.on("model_select", invalidate);
|
|
46
|
+
// Ready summaries per Pi session. They replace the summarized prefix of the
|
|
47
|
+
// very next model request (as the Codex/Claude Code gateway and DSH do),
|
|
48
|
+
// whether or not Pi's own compaction later accepts them.
|
|
49
|
+
const ready = new Map();
|
|
50
|
+
// What the next model request carries, recorded when Pi builds it and
|
|
51
|
+
// settled by the assistant message that answers it (request_receipt).
|
|
52
|
+
const outgoing = new Map();
|
|
53
|
+
const cacheSeen = new Set();
|
|
54
|
+
// A summary Pi committed from AFU stays in every later request until the
|
|
55
|
+
// next compaction; those requests are the ones it made smaller.
|
|
56
|
+
const committed = new Map();
|
|
57
|
+
pi.on("context", async (event, ctx) => {
|
|
58
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
59
|
+
const summary = ready.get(sid);
|
|
60
|
+
const messages = summary ? applyPreparedSummary(event.messages, summary) : undefined;
|
|
61
|
+
if (!summary || !messages) {
|
|
62
|
+
const carried = committed.get(sid);
|
|
63
|
+
const first = event.messages[0];
|
|
64
|
+
outgoing.set(sid, carried && first?.role === "compactionSummary"
|
|
65
|
+
? { applied: true, ...(carried.artifactId ? { artifactId: carried.artifactId } : {}), removed: carried.removed }
|
|
66
|
+
: { applied: false });
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
outgoing.set(sid, { applied: true, ...(summary.artifactId ? { artifactId: summary.artifactId } : {}), removed: summary.removedTokens });
|
|
70
|
+
log("summary_applied", { session: sessionHash(sid), artifact_id: summary.artifactId, phase: "host_request", removed_tokens_estimate: summary.removedTokens });
|
|
71
|
+
return { messages: messages };
|
|
72
|
+
});
|
|
73
|
+
pi.on("message_end", async (event, ctx) => {
|
|
74
|
+
const message = event.message;
|
|
75
|
+
if (message.role !== "assistant")
|
|
76
|
+
return;
|
|
77
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
78
|
+
const sent = outgoing.get(sid);
|
|
79
|
+
outgoing.delete(sid);
|
|
80
|
+
if (!sent)
|
|
81
|
+
return;
|
|
82
|
+
const usage = message.usage ? piSummaryUsage(message.usage) : {};
|
|
83
|
+
// Pi reports absent cache counters as zero. Once this session's provider
|
|
84
|
+
// has reported a cache read, a zero is a real miss.
|
|
85
|
+
if (usage.cache_usage_known)
|
|
86
|
+
cacheSeen.add(sid);
|
|
87
|
+
else if (cacheSeen.has(sid) && typeof usage.input_tokens === "number")
|
|
88
|
+
Object.assign(usage, { cache_usage_known: true, cache_read_input_tokens: 0 });
|
|
89
|
+
const ok = message.stopReason !== "error" && message.stopReason !== "aborted";
|
|
90
|
+
log("request_receipt", {
|
|
91
|
+
session: sessionHash(sid), applied: sent.applied, upstream_success: ok,
|
|
92
|
+
...(sent.artifactId ? { artifact_id: sent.artifactId } : {}),
|
|
93
|
+
...(sent.applied && sent.removed !== undefined ? { removed_tokens_estimate: sent.removed } : {}),
|
|
94
|
+
...(ok ? usage : {}),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
const report = (ctx, error) => {
|
|
98
|
+
if (ctx.signal?.aborted)
|
|
99
|
+
return;
|
|
100
|
+
// Pi's own compaction declining (its keep-recent window covers the whole
|
|
101
|
+
// session) is not a model problem; the summary is still used per request.
|
|
102
|
+
if (error instanceof Error && /Nothing to compact|Already compacted/.test(error.message))
|
|
103
|
+
return;
|
|
104
|
+
log("adapter_error", { session: sessionHash(ctx.sessionManager.getSessionId()), reason: "host_model_unavailable_or_changed" });
|
|
105
|
+
if (warned || options.notifyErrors === false)
|
|
106
|
+
return;
|
|
107
|
+
warned = true;
|
|
108
|
+
const message = "host model unavailable or changed";
|
|
109
|
+
ctx.ui.notify(`compact unavailable; Pi compaction remains active: ${message}`, "warning");
|
|
110
|
+
};
|
|
111
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
112
|
+
invalidate();
|
|
113
|
+
const generation = epoch;
|
|
114
|
+
// Context size as Pi itself measures it, for the dashboard's progress to the AFU trigger.
|
|
115
|
+
let usage;
|
|
116
|
+
try {
|
|
117
|
+
usage = ctx.getContextUsage();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
usage = undefined;
|
|
121
|
+
}
|
|
122
|
+
log("request_observed", {
|
|
123
|
+
session: sessionHash(ctx.sessionManager.getSessionId()), phase: "host_turn_settled",
|
|
124
|
+
...(usage?.tokens == null ? {} : { estimated_input_tokens: usage.tokens }),
|
|
125
|
+
...afuTrigger(usage?.contextWindow),
|
|
126
|
+
});
|
|
127
|
+
const entries = ctx.sessionManager.buildContextEntries();
|
|
128
|
+
const visible = modelVisibleEntries(entries);
|
|
129
|
+
const latest = visible.at(-1);
|
|
130
|
+
if (latest === undefined)
|
|
131
|
+
return;
|
|
132
|
+
const request = piEntriesToRequest(entries, ctx, new Set([latest.id]));
|
|
133
|
+
if (request === undefined)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
const identity = fingerprint(ctx);
|
|
137
|
+
const binding = injected ? undefined : await piHostBinding(ctx, pi);
|
|
138
|
+
const prepare = () => injected ? injected.compact(request, ctx.signal) : host.compact(request, binding, ctx.signal);
|
|
139
|
+
const deadline = Date.now() + 630_000;
|
|
140
|
+
const valid = () => generation === epoch && identity === fingerprint(ctx) && !ctx.signal?.aborted;
|
|
141
|
+
const inspect = async (result) => {
|
|
142
|
+
if (!valid())
|
|
143
|
+
return;
|
|
144
|
+
if (piCompactionFromResult(visible, result, usage?.tokens ?? 0)) {
|
|
145
|
+
const prepared = preparedSummary(visible, result);
|
|
146
|
+
if (prepared)
|
|
147
|
+
ready.set(ctx.sessionManager.getSessionId(), prepared);
|
|
148
|
+
// Pi refuses to compact ("Nothing to compact") while its keep-recent
|
|
149
|
+
// window covers the whole branch, and shows that as an error. The
|
|
150
|
+
// summary is already used per request, so only commit when Pi can.
|
|
151
|
+
if (!piCanCompact(ctx, visible, options.keepRecentTokens))
|
|
152
|
+
return;
|
|
153
|
+
// The native compact API aborts an active turn. Invoke only while idle,
|
|
154
|
+
// with no queued prompt, then revalidate again in the native hook.
|
|
155
|
+
if (!ctx.isIdle?.() || ctx.hasPendingMessages?.() || automatic)
|
|
156
|
+
return;
|
|
157
|
+
if (binding && !await binding.current())
|
|
158
|
+
return;
|
|
159
|
+
if (!valid() || !ctx.isIdle() || ctx.hasPendingMessages())
|
|
160
|
+
return;
|
|
161
|
+
automatic = { result, fingerprint: identity, ...(binding ? { binding } : {}) };
|
|
162
|
+
const clear = () => { automatic = undefined; };
|
|
163
|
+
try {
|
|
164
|
+
ctx.compact({ onComplete: clear, onError: error => { clear(); report(ctx, error); } });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
clear();
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!["scheduled", "running"].includes(result.async_job.status) || Date.now() >= deadline)
|
|
173
|
+
return;
|
|
174
|
+
timer = setTimeout(() => {
|
|
175
|
+
timer = undefined;
|
|
176
|
+
if (!valid())
|
|
177
|
+
return;
|
|
178
|
+
void prepare().then(inspect).catch(error => { if (valid())
|
|
179
|
+
report(ctx, error); });
|
|
180
|
+
}, 500);
|
|
181
|
+
timer.unref?.();
|
|
182
|
+
};
|
|
183
|
+
await inspect(await prepare());
|
|
184
|
+
warned = false;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
report(ctx, error);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
191
|
+
invalidate();
|
|
192
|
+
const started = performance.now();
|
|
193
|
+
const entries = modelVisibleEntries(ctx.sessionManager.buildContextEntries());
|
|
194
|
+
const latest = entries.at(-1);
|
|
195
|
+
if (latest === undefined)
|
|
196
|
+
return;
|
|
197
|
+
const requiredIds = new Set([latest.id]);
|
|
198
|
+
const request = piEntriesToRequest(entries, ctx, requiredIds);
|
|
199
|
+
if (request === undefined)
|
|
200
|
+
return;
|
|
201
|
+
try {
|
|
202
|
+
const prepared = automatic;
|
|
203
|
+
// COSEC: an automatically requested commit must never use a stale model,
|
|
204
|
+
// session or history, nor fall back to an unrelated native summary.
|
|
205
|
+
if (prepared && ((prepared.binding && !await prepared.binding.current()) ||
|
|
206
|
+
prepared.fingerprint !== fingerprint(ctx) || event.signal.aborted))
|
|
207
|
+
return { cancel: true };
|
|
208
|
+
const result = prepared?.result ?? await compact(request, ctx, event.signal);
|
|
209
|
+
warned = false;
|
|
210
|
+
const compaction = piCompactionFromResult(entries, result, event.preparation.tokensBefore);
|
|
211
|
+
if (compaction) {
|
|
212
|
+
const sid = sessionHash(ctx.sessionManager.getSessionId());
|
|
213
|
+
const artifactId = observationId(result);
|
|
214
|
+
const previous = pending.get(sid);
|
|
215
|
+
if (previous?.artifactId && previous.artifactId !== artifactId)
|
|
216
|
+
log("summary_discarded", { session: sid, artifact_id: previous.artifactId, reason: "superseded" });
|
|
217
|
+
pending.set(sid, { signature: bindingKey([compaction.summary, compaction.firstKeptEntryId]), artifactId, removed: removedBy(entries, compaction.firstKeptEntryId, compaction.summary) });
|
|
218
|
+
log("summary_handoff", { session: sid, artifact_id: artifactId });
|
|
219
|
+
}
|
|
220
|
+
return compaction === undefined ? (prepared ? { cancel: true } : undefined) : { compaction };
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (!event.signal.aborted)
|
|
224
|
+
report(ctx, error);
|
|
225
|
+
return automatic ? { cancel: true } : undefined;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
log("host_compaction_receipt", { session: sessionHash(ctx.sessionManager.getSessionId()), host_compaction_ms: performance.now() - started });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
232
|
+
// Pi's history now starts from its own compaction entry; the prepared
|
|
233
|
+
// prefix can no longer match, so stop offering it.
|
|
234
|
+
ready.delete(ctx.sessionManager.getSessionId());
|
|
235
|
+
committed.delete(ctx.sessionManager.getSessionId());
|
|
236
|
+
const sid = sessionHash(ctx.sessionManager.getSessionId());
|
|
237
|
+
const handoff = pending.get(sid);
|
|
238
|
+
if (event.fromExtension && handoff?.signature === bindingKey([event.compactionEntry.summary, event.compactionEntry.firstKeptEntryId])) {
|
|
239
|
+
committed.set(ctx.sessionManager.getSessionId(), { ...(handoff.artifactId ? { artifactId: handoff.artifactId } : {}), removed: handoff.removed });
|
|
240
|
+
log("summary_applied", { session: sid, artifact_id: handoff.artifactId, phase: "host_commit", removed_tokens_estimate: handoff.removed });
|
|
241
|
+
}
|
|
242
|
+
else if (handoff)
|
|
243
|
+
log("summary_discarded", { session: sid, artifact_id: handoff.artifactId, reason: "host_used_other_summary" });
|
|
244
|
+
pending.delete(sid);
|
|
245
|
+
});
|
|
246
|
+
pi.on("session_compact_failed", async (_event, ctx) => {
|
|
247
|
+
const sid = sessionHash(ctx.sessionManager.getSessionId());
|
|
248
|
+
const handoff = pending.get(sid);
|
|
249
|
+
if (pending.delete(sid)) {
|
|
250
|
+
log("summary_discarded", { session: sid, artifact_id: handoff?.artifactId, reason: "host_commit_failed" });
|
|
251
|
+
log("adapter_error", { session: sid, reason: "host_commit_failed" });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
pi.on("session_shutdown", async () => {
|
|
255
|
+
invalidate();
|
|
256
|
+
automatic = undefined;
|
|
257
|
+
ready.clear();
|
|
258
|
+
committed.clear();
|
|
259
|
+
outgoing.clear();
|
|
260
|
+
log("runtime_stopped");
|
|
261
|
+
await host.close();
|
|
262
|
+
await injected?.close();
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/** Use the same provider runtime (including OAuth/custom headers) as Pi chat. */
|
|
267
|
+
export async function piHostBinding(ctx, pi) {
|
|
268
|
+
const model = ctx.model;
|
|
269
|
+
if (!model)
|
|
270
|
+
throw new Error("No selected Pi model");
|
|
271
|
+
if (!Number.isSafeInteger(model.contextWindow) || model.contextWindow <= 0)
|
|
272
|
+
throw new Error("Pi model window unavailable");
|
|
273
|
+
const identity = async () => {
|
|
274
|
+
const selected = ctx.model;
|
|
275
|
+
if (!selected)
|
|
276
|
+
return "";
|
|
277
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(selected);
|
|
278
|
+
if (!auth.ok)
|
|
279
|
+
throw new Error("Pi authentication unavailable");
|
|
280
|
+
return bindingKey([ctx.sessionManager.getSessionId(), selected, auth]);
|
|
281
|
+
};
|
|
282
|
+
const key = await identity();
|
|
283
|
+
let usage = {};
|
|
284
|
+
return {
|
|
285
|
+
key,
|
|
286
|
+
diagnostics: { host: "pi", session: sessionHash(ctx.sessionManager.getSessionId()), usage: () => usage },
|
|
287
|
+
contextWindow: model.contextWindow,
|
|
288
|
+
current: async () => key === await identity(),
|
|
289
|
+
complete: async (prompt, signal, ids) => {
|
|
290
|
+
const entries = ids?.length ? modelVisibleEntries(ctx.sessionManager.buildContextEntries()) : [];
|
|
291
|
+
const selected = summaryPrefix(entries.map(entry => ({ id: entry.id, messages: convertToLlm(sessionEntryToContextMessages(entry)) })), ids, prompt);
|
|
292
|
+
const definitions = pi?.getAllTools();
|
|
293
|
+
const tools = pi?.getActiveTools().map(name => {
|
|
294
|
+
const tool = definitions?.find(t => t.name === name);
|
|
295
|
+
if (!tool)
|
|
296
|
+
throw new Error("Active Pi tool unavailable");
|
|
297
|
+
return { name: tool.name, description: tool.description, parameters: tool.parameters };
|
|
298
|
+
});
|
|
299
|
+
const response = await ctx.modelRegistry.complete(model, {
|
|
300
|
+
...(ids?.length ? { systemPrompt: ctx.getSystemPrompt(), ...(tools ? { tools } : {}) } : {}),
|
|
301
|
+
messages: [...selected.messages, { role: "user", content: selected.prompt, timestamp: Date.now() }],
|
|
302
|
+
}, { signal, maxTokens: piSummaryMaxTokens(ctx, model), sessionId: ctx.sessionManager.getSessionId() });
|
|
303
|
+
usage = piSummaryUsage(response.usage);
|
|
304
|
+
const failure = (reason) => Object.assign(new Error("Pi summary incomplete"), { afuReason: reason });
|
|
305
|
+
if (response.stopReason === "length")
|
|
306
|
+
throw failure("summary_output_limit");
|
|
307
|
+
if (response.stopReason === "error")
|
|
308
|
+
throw failure("summary_provider_error");
|
|
309
|
+
if (response.stopReason !== "stop")
|
|
310
|
+
throw failure("summary_incomplete");
|
|
311
|
+
if (response.content.some(b => b.type === "toolCall"))
|
|
312
|
+
throw failure("summary_called_tool");
|
|
313
|
+
return response.content.filter(b => b.type === "text").map(b => b.text).join("\n");
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
export function piEntriesToRequest(entries, ctx, requiredIds) {
|
|
318
|
+
const usage = ctx.getContextUsage();
|
|
319
|
+
if (usage === undefined || usage.contextWindow <= 0)
|
|
320
|
+
return undefined;
|
|
321
|
+
const visible = modelVisibleEntries(entries);
|
|
322
|
+
if (visible.length < 2)
|
|
323
|
+
return undefined;
|
|
324
|
+
const callGroups = toolCallGroups(visible);
|
|
325
|
+
const items = visible.map((entry) => {
|
|
326
|
+
const message = sessionEntryToContextMessages(entry)[0];
|
|
327
|
+
if (message === undefined)
|
|
328
|
+
throw new Error(`Pi entry ${entry.id} has no model-visible message`);
|
|
329
|
+
const record = message;
|
|
330
|
+
const role = typeof record.role === "string" ? record.role : "user";
|
|
331
|
+
const callId = typeof record.toolCallId === "string" ? record.toolCallId : undefined;
|
|
332
|
+
const atomicGroup = callId === undefined ? callGroups.entryGroups.get(entry.id) : callGroups.calls.get(callId);
|
|
333
|
+
return {
|
|
334
|
+
schema_version: "1",
|
|
335
|
+
id: entry.id,
|
|
336
|
+
kind: entry.type === "compaction" ? "artifact" : piKind(role),
|
|
337
|
+
content: normalizeContent(record.content ?? ("summary" in entry ? entry.summary : "")),
|
|
338
|
+
source: { schema_version: "1", provider: "pi.session", source_id: entry.id },
|
|
339
|
+
required: requiredIds.has(entry.id),
|
|
340
|
+
...(atomicGroup === undefined ? {} : { atomic_group: atomicGroup }),
|
|
341
|
+
metadata: {
|
|
342
|
+
adapter: "pi",
|
|
343
|
+
native_role: role,
|
|
344
|
+
native_entry_type: entry.type,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
return {
|
|
349
|
+
schema_version: "1",
|
|
350
|
+
session_id: ctx.sessionManager.getSessionId(),
|
|
351
|
+
token_budget: usage.contextWindow,
|
|
352
|
+
items,
|
|
353
|
+
query: "",
|
|
354
|
+
mode: "active",
|
|
355
|
+
metadata: {
|
|
356
|
+
adapter: "pi",
|
|
357
|
+
// Pi's own measurement of the whole request; the worker follows it for the trigger.
|
|
358
|
+
...(Number.isSafeInteger(usage.tokens) && usage.tokens > 0 ? { host_context_tokens: usage.tokens } : {}),
|
|
359
|
+
source_profile: {
|
|
360
|
+
representation: "text",
|
|
361
|
+
sequence_semantics: "append_only",
|
|
362
|
+
atomic_unit: "turn",
|
|
363
|
+
preserve_atomic_units: true,
|
|
364
|
+
loss_tolerance: "low",
|
|
365
|
+
},
|
|
366
|
+
runtime_version: "0.84.4",
|
|
367
|
+
query_aware: false,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
export function piCompactionFromResult(entries, result, tokensBefore) {
|
|
372
|
+
const artifact = findAppliedArtifact(result);
|
|
373
|
+
if (artifact === undefined || typeof artifact.item.content !== "string")
|
|
374
|
+
return undefined;
|
|
375
|
+
const visible = modelVisibleEntries(entries);
|
|
376
|
+
const visibleIds = visible.map((entry) => entry.id);
|
|
377
|
+
const start = coveredStart(visibleIds, artifact.replacedItemIds, visible);
|
|
378
|
+
if (start < 0 || start + artifact.replacedItemIds.length >= visibleIds.length)
|
|
379
|
+
return undefined;
|
|
380
|
+
const firstKeptEntryId = visibleIds[start + artifact.replacedItemIds.length];
|
|
381
|
+
if (firstKeptEntryId === undefined)
|
|
382
|
+
return undefined;
|
|
383
|
+
return {
|
|
384
|
+
summary: artifact.item.content,
|
|
385
|
+
firstKeptEntryId,
|
|
386
|
+
tokensBefore,
|
|
387
|
+
estimatedTokensAfter: result.usage.output_tokens,
|
|
388
|
+
details: {
|
|
389
|
+
...(artifact.artifactId === undefined ? {} : { artifactId: artifact.artifactId }),
|
|
390
|
+
diagnostics: result.diagnostics,
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Output budget for one summary, the way Pi sizes its own: 80% of
|
|
396
|
+
* compaction.reserveTokens (16,384 by default, so 13,107), capped by the
|
|
397
|
+
* model's maxTokens. Reasoning models spend part of it thinking; a fixed
|
|
398
|
+
* 4,096 made long summaries stop at the limit and every retry fail.
|
|
399
|
+
*/
|
|
400
|
+
export function piSummaryMaxTokens(ctx, model) {
|
|
401
|
+
let reserve = DEFAULT_COMPACTION_SETTINGS.reserveTokens;
|
|
402
|
+
try {
|
|
403
|
+
const value = SettingsManager.create(ctx.cwd, getAgentDir()).getCompactionReserveTokens();
|
|
404
|
+
if (Number.isFinite(value) && value > 0)
|
|
405
|
+
reserve = value;
|
|
406
|
+
}
|
|
407
|
+
catch { /* Pi's default */ }
|
|
408
|
+
const budget = Math.max(4096, Math.floor(0.8 * reserve));
|
|
409
|
+
return typeof model.maxTokens === "number" && model.maxTokens > 0 ? Math.min(budget, model.maxTokens) : budget;
|
|
410
|
+
}
|
|
411
|
+
/** Pi's compaction.keepRecentTokens for this project (Pi's own default when unset). */
|
|
412
|
+
function piKeepRecentTokens(ctx) {
|
|
413
|
+
try {
|
|
414
|
+
const value = SettingsManager.create(ctx.cwd, getAgentDir()).getCompactionKeepRecentTokens();
|
|
415
|
+
if (Number.isFinite(value) && value >= 0)
|
|
416
|
+
return value;
|
|
417
|
+
}
|
|
418
|
+
catch { /* fall back to Pi's default */ }
|
|
419
|
+
return DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Whether Pi's own compaction would accept a commit now: the messages after
|
|
423
|
+
* the latest compaction must exceed keepRecentTokens (Pi's estimate), or Pi
|
|
424
|
+
* reports "Nothing to compact (session too small)".
|
|
425
|
+
*/
|
|
426
|
+
export function piCanCompact(ctx, visible, keepRecent = piKeepRecentTokens(ctx)) {
|
|
427
|
+
let total = 0;
|
|
428
|
+
for (const entry of visible) {
|
|
429
|
+
if (entry.type === "compaction") {
|
|
430
|
+
total = 0;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
for (const message of sessionEntryToContextMessages(entry))
|
|
434
|
+
total += estimateTokens(message);
|
|
435
|
+
}
|
|
436
|
+
return total > keepRecent;
|
|
437
|
+
}
|
|
438
|
+
/** A ready result that replaces a leading run of the given entries. */
|
|
439
|
+
export function preparedSummary(visible, result) {
|
|
440
|
+
const artifact = findAppliedArtifact(result);
|
|
441
|
+
if (artifact === undefined || typeof artifact.item.content !== "string")
|
|
442
|
+
return undefined;
|
|
443
|
+
const ids = visible.map((entry) => entry.id);
|
|
444
|
+
const start = coveredStart(ids, artifact.replacedItemIds, visible);
|
|
445
|
+
if (start < 0 || start + artifact.replacedItemIds.length >= ids.length)
|
|
446
|
+
return undefined;
|
|
447
|
+
const head = visible.slice(0, start).flatMap((entry) => sessionEntryToContextMessages(entry));
|
|
448
|
+
const replaced = visible.slice(start, start + artifact.replacedItemIds.length).flatMap((entry) => sessionEntryToContextMessages(entry));
|
|
449
|
+
const replacedChars = JSON.stringify(replaced).length;
|
|
450
|
+
// Same id the summary_started/summary_ready events carry, so the dashboard links them.
|
|
451
|
+
const artifactId = observationId(result) ?? artifact.artifactId;
|
|
452
|
+
return {
|
|
453
|
+
head,
|
|
454
|
+
replaced,
|
|
455
|
+
summary: artifact.item.content,
|
|
456
|
+
...(artifactId === undefined ? {} : { artifactId }),
|
|
457
|
+
removedTokens: Math.max(0, Math.round((replacedChars - artifact.item.content.length) / 4)),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Replace the summarized prefix of a model request with Pi's own compaction
|
|
462
|
+
* summary message. Only an exact prefix match is rewritten; anything else
|
|
463
|
+
* (a later Pi compaction, a branch switch, edited history) is sent unchanged.
|
|
464
|
+
*/
|
|
465
|
+
export function applyPreparedSummary(messages, ready) {
|
|
466
|
+
const head = ready.head ?? [];
|
|
467
|
+
const count = ready.replaced.length;
|
|
468
|
+
if (messages.length <= head.length + count)
|
|
469
|
+
return undefined;
|
|
470
|
+
for (let index = 0; index < head.length; index++) {
|
|
471
|
+
if (JSON.stringify(messages[index]) !== JSON.stringify(head[index]))
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
for (let index = 0; index < count; index++) {
|
|
475
|
+
if (JSON.stringify(messages[head.length + index]) !== JSON.stringify(ready.replaced[index]))
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
const first = messages[head.length];
|
|
479
|
+
return [
|
|
480
|
+
...messages.slice(0, head.length),
|
|
481
|
+
{ role: "compactionSummary", summary: ready.summary, tokensBefore: ready.removedTokens, timestamp: first?.timestamp ?? Date.now() },
|
|
482
|
+
...messages.slice(head.length + count),
|
|
483
|
+
];
|
|
484
|
+
}
|
|
485
|
+
/** Tokens (Pi's estimate) the entries before firstKeptEntryId cost, minus the summary replacing them. */
|
|
486
|
+
function removedBy(entries, firstKeptEntryId, summary) {
|
|
487
|
+
let total = 0;
|
|
488
|
+
for (const entry of entries) {
|
|
489
|
+
if (entry.id === firstKeptEntryId)
|
|
490
|
+
break;
|
|
491
|
+
for (const message of sessionEntryToContextMessages(entry))
|
|
492
|
+
total += estimateTokens(message);
|
|
493
|
+
}
|
|
494
|
+
return Math.max(0, total - Math.ceil(summary.length / 4));
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Where the summarized range starts among the visible entries. Pi 1.x keeps
|
|
498
|
+
* the system prompt as session entries (role "system") ahead of the
|
|
499
|
+
* conversation; AFU never summarizes those, so a summary may start after them.
|
|
500
|
+
* Returns -1 when the covered ids are not a contiguous run after that head.
|
|
501
|
+
*/
|
|
502
|
+
export function coveredStart(visibleIds, replaced, visible) {
|
|
503
|
+
if (replaced.length === 0)
|
|
504
|
+
return -1;
|
|
505
|
+
let start = 0;
|
|
506
|
+
while (start < visible.length && visibleIds[start] !== replaced[0] && isSystemEntry(visible[start]))
|
|
507
|
+
start++;
|
|
508
|
+
if (visibleIds[start] !== replaced[0])
|
|
509
|
+
return -1;
|
|
510
|
+
for (let index = 0; index < replaced.length; index++)
|
|
511
|
+
if (visibleIds[start + index] !== replaced[index])
|
|
512
|
+
return -1;
|
|
513
|
+
return start;
|
|
514
|
+
}
|
|
515
|
+
function isSystemEntry(entry) {
|
|
516
|
+
return sessionEntryToContextMessages(entry).every((message) => message.role === "system");
|
|
517
|
+
}
|
|
518
|
+
function modelVisibleEntries(entries) {
|
|
519
|
+
return entries.filter((entry) => sessionEntryToContextMessages(entry).length > 0);
|
|
520
|
+
}
|
|
521
|
+
function piKind(role) {
|
|
522
|
+
if (role === "assistant")
|
|
523
|
+
return "assistant";
|
|
524
|
+
if (role === "toolResult" || role === "tool")
|
|
525
|
+
return "tool";
|
|
526
|
+
if (role === "system")
|
|
527
|
+
return "system";
|
|
528
|
+
return "user";
|
|
529
|
+
}
|
|
530
|
+
function toolCallGroups(entries) {
|
|
531
|
+
const calls = new Map();
|
|
532
|
+
const entryGroups = new Map();
|
|
533
|
+
for (const entry of entries) {
|
|
534
|
+
const message = sessionEntryToContextMessages(entry)[0];
|
|
535
|
+
if (!isRecord(message) || !Array.isArray(message.content))
|
|
536
|
+
continue;
|
|
537
|
+
const callIds = message.content.flatMap((block) => {
|
|
538
|
+
if (!isRecord(block) || block.type !== "toolCall" || typeof block.id !== "string")
|
|
539
|
+
return [];
|
|
540
|
+
return [block.id];
|
|
541
|
+
});
|
|
542
|
+
if (callIds.length === 0)
|
|
543
|
+
continue;
|
|
544
|
+
const group = `pi-tool-batch:${entry.id}`;
|
|
545
|
+
entryGroups.set(entry.id, group);
|
|
546
|
+
for (const callId of callIds)
|
|
547
|
+
calls.set(callId, group);
|
|
548
|
+
}
|
|
549
|
+
return { calls, entryGroups };
|
|
550
|
+
}
|
|
551
|
+
function isRecord(value) {
|
|
552
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
553
|
+
}
|
|
554
|
+
export default createCompactPiExtension();
|
|
555
|
+
export function piSummaryUsage(u) {
|
|
556
|
+
if (!u)
|
|
557
|
+
return { cache_usage_known: false };
|
|
558
|
+
return {
|
|
559
|
+
input_tokens: u.input + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0), output_tokens: u.output,
|
|
560
|
+
cache_usage_known: u.cacheRead > 0,
|
|
561
|
+
...(u.cacheRead > 0 ? { cache_read_input_tokens: u.cacheRead } : {}),
|
|
562
|
+
...(u.cacheWrite > 0 ? { cache_creation_input_tokens: u.cacheWrite } : {}),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* In-host commands (/afuctx-status, /afuctx-dashboard) for hosts without a
|
|
4
|
+
* gateway: they run AFU's own CLI from the installed runtime. The command
|
|
5
|
+
* line is recorded at install time (AFU_STATE_CLI); nothing is taken from
|
|
6
|
+
* user input except the host's own session id, passed as one argument.
|
|
7
|
+
*/
|
|
8
|
+
export const STATUS_COMMAND = "afuctx-status";
|
|
9
|
+
export const DASHBOARD_COMMAND = "afuctx-dashboard";
|
|
10
|
+
function cli(env) {
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(env.AFU_STATE_CLI ?? "");
|
|
13
|
+
return Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string") ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function run(args, env, timeoutMs) {
|
|
20
|
+
const base = cli(env);
|
|
21
|
+
if (!base)
|
|
22
|
+
return Promise.resolve("AFU runtime command is unavailable; reinstall AFU for this host.");
|
|
23
|
+
const state = env.AFU_STATE_DIR ? ["--state", env.AFU_STATE_DIR] : [];
|
|
24
|
+
return new Promise(resolve => {
|
|
25
|
+
// COSEC: fixed executable and argv array; no shell.
|
|
26
|
+
execFile(base[0], [...base.slice(1), ...args, ...state], { timeout: timeoutMs, env: { ...process.env, ...env }, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
|
|
27
|
+
const text = (stdout || "").trim() || (stderr || "").trim();
|
|
28
|
+
resolve(text || (error ? "AFU command failed: " + error.code : ""));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** Short status of this host and of the given host session. */
|
|
33
|
+
export function afuStatus(host, sessionId, env = process.env) {
|
|
34
|
+
return run(["status", host, "--brief", "--session", sessionId], env, 15_000);
|
|
35
|
+
}
|
|
36
|
+
/** Start (or reuse) the dashboard and return its link line. */
|
|
37
|
+
export function afuDashboard(env = process.env) {
|
|
38
|
+
return run(["ui"], env, 30_000);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=commands.js.map
|