@liwu1995/afu-context 0.1.5 → 0.1.11

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.
@@ -1,5 +1,5 @@
1
- import { sessionEntryToContextMessages, convertToLlm } from "@earendil-works/pi-coding-agent";
2
- import { journal, sessionHash, summaryPrefix, observationId, CompactWorkerClient, HostCompactPool, bindingKey, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
1
+ import { sessionEntryToContextMessages, convertToLlm, estimateTokens, getAgentDir, SettingsManager, DEFAULT_COMPACTION_SETTINGS } from "@earendil-works/pi-coding-agent";
2
+ import { journal, sessionHash, afuTrigger, summaryPrefix, observationId, CompactWorkerClient, HostCompactPool, bindingKey, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
3
3
  export function createCompactPiExtension(options = {}) {
4
4
  return function compactPi(pi) {
5
5
  const injected = options.worker instanceof CompactWorkerClient ? options.worker : undefined;
@@ -12,9 +12,46 @@ export function createCompactPiExtension(options = {}) {
12
12
  let warned = false;
13
13
  const log = journal("pi");
14
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
+ pi.on("agent_start", invalidate);
29
+ pi.on("session_before_switch", invalidate);
30
+ pi.on("session_before_fork", invalidate);
31
+ pi.on("session_before_tree", invalidate);
32
+ pi.on("model_select", invalidate);
33
+ // Ready summaries per Pi session. They replace the summarized prefix of the
34
+ // very next model request (as the Codex/Claude Code gateway and DSH do),
35
+ // whether or not Pi's own compaction later accepts them.
36
+ const ready = new Map();
37
+ pi.on("context", async (event, ctx) => {
38
+ const sid = ctx.sessionManager.getSessionId();
39
+ const summary = ready.get(sid);
40
+ if (!summary)
41
+ return undefined;
42
+ const messages = applyPreparedSummary(event.messages, summary);
43
+ if (!messages)
44
+ return undefined;
45
+ log("summary_applied", { session: sessionHash(sid), artifact_id: summary.artifactId, phase: "host_request", removed_tokens_estimate: summary.removedTokens });
46
+ return { messages: messages };
47
+ });
15
48
  const report = (ctx, error) => {
16
49
  if (ctx.signal?.aborted)
17
50
  return;
51
+ // Pi's own compaction declining (its keep-recent window covers the whole
52
+ // session) is not a model problem; the summary is still used per request.
53
+ if (error instanceof Error && /Nothing to compact|Already compacted/.test(error.message))
54
+ return;
18
55
  log("adapter_error", { session: sessionHash(ctx.sessionManager.getSessionId()), reason: "host_model_unavailable_or_changed" });
19
56
  if (warned || options.notifyErrors === false)
20
57
  return;
@@ -23,7 +60,21 @@ export function createCompactPiExtension(options = {}) {
23
60
  ctx.ui.notify(`compact unavailable; Pi compaction remains active: ${message}`, "warning");
24
61
  };
25
62
  pi.on("agent_settled", async (_event, ctx) => {
26
- log("request_observed", { session: sessionHash(ctx.sessionManager.getSessionId()), phase: "host_turn_settled" });
63
+ invalidate();
64
+ const generation = epoch;
65
+ // Context size as Pi itself measures it, for the dashboard's progress to the AFU trigger.
66
+ let usage;
67
+ try {
68
+ usage = ctx.getContextUsage();
69
+ }
70
+ catch {
71
+ usage = undefined;
72
+ }
73
+ log("request_observed", {
74
+ session: sessionHash(ctx.sessionManager.getSessionId()), phase: "host_turn_settled",
75
+ ...(usage?.tokens == null ? {} : { estimated_input_tokens: usage.tokens }),
76
+ ...afuTrigger(usage?.contextWindow),
77
+ });
27
78
  const entries = ctx.sessionManager.buildContextEntries();
28
79
  const visible = modelVisibleEntries(entries);
29
80
  const latest = visible.at(-1);
@@ -33,7 +84,54 @@ export function createCompactPiExtension(options = {}) {
33
84
  if (request === undefined)
34
85
  return;
35
86
  try {
36
- await compact(request, ctx, ctx.signal);
87
+ const identity = fingerprint(ctx);
88
+ const binding = injected ? undefined : await piHostBinding(ctx, pi);
89
+ const prepare = () => injected ? injected.compact(request, ctx.signal) : host.compact(request, binding, ctx.signal);
90
+ const deadline = Date.now() + 630_000;
91
+ const valid = () => generation === epoch && identity === fingerprint(ctx) && !ctx.signal?.aborted;
92
+ const inspect = async (result) => {
93
+ if (!valid())
94
+ return;
95
+ if (piCompactionFromResult(visible, result, usage?.tokens ?? 0)) {
96
+ const prepared = preparedSummary(visible, result);
97
+ if (prepared)
98
+ ready.set(ctx.sessionManager.getSessionId(), prepared);
99
+ // Pi refuses to compact ("Nothing to compact") while its keep-recent
100
+ // window covers the whole branch, and shows that as an error. The
101
+ // summary is already used per request, so only commit when Pi can.
102
+ if (!piCanCompact(ctx, visible, options.keepRecentTokens))
103
+ return;
104
+ // The native compact API aborts an active turn. Invoke only while idle,
105
+ // with no queued prompt, then revalidate again in the native hook.
106
+ if (!ctx.isIdle?.() || ctx.hasPendingMessages?.() || automatic)
107
+ return;
108
+ if (binding && !await binding.current())
109
+ return;
110
+ if (!valid() || !ctx.isIdle() || ctx.hasPendingMessages())
111
+ return;
112
+ automatic = { result, fingerprint: identity, ...(binding ? { binding } : {}) };
113
+ const clear = () => { automatic = undefined; };
114
+ try {
115
+ ctx.compact({ onComplete: clear, onError: error => { clear(); report(ctx, error); } });
116
+ }
117
+ catch (error) {
118
+ clear();
119
+ throw error;
120
+ }
121
+ return;
122
+ }
123
+ if (!["scheduled", "running"].includes(result.async_job.status) || Date.now() >= deadline)
124
+ return;
125
+ timer = setTimeout(() => {
126
+ timer = undefined;
127
+ if (!valid())
128
+ return;
129
+ void prepare().then(inspect).catch(error => { if (valid())
130
+ report(ctx, error); });
131
+ }, 500);
132
+ timer.unref?.();
133
+ };
134
+ await inspect(await prepare());
37
135
  warned = false;
38
136
  }
39
137
  catch (error) {
@@ -41,6 +139,7 @@ export function createCompactPiExtension(options = {}) {
41
139
  }
42
140
  });
43
141
  pi.on("session_before_compact", async (event, ctx) => {
142
+ invalidate();
44
143
  const started = performance.now();
45
144
  const entries = modelVisibleEntries(ctx.sessionManager.buildContextEntries());
46
145
  const latest = entries.at(-1);
@@ -51,7 +150,13 @@ export function createCompactPiExtension(options = {}) {
51
150
  if (request === undefined)
52
151
  return;
53
152
  try {
54
- const result = await compact(request, ctx, event.signal);
153
+ const prepared = automatic;
154
+ // COSEC: an automatically requested commit must never use a stale model,
155
+ // session or history, nor fall back to an unrelated native summary.
156
+ if (prepared && ((prepared.binding && !await prepared.binding.current()) ||
157
+ prepared.fingerprint !== fingerprint(ctx) || event.signal.aborted))
158
+ return { cancel: true };
159
+ const result = prepared?.result ?? await compact(request, ctx, event.signal);
55
160
  warned = false;
56
161
  const compaction = piCompactionFromResult(entries, result, event.preparation.tokensBefore);
57
162
  if (compaction) {
@@ -63,18 +168,21 @@ export function createCompactPiExtension(options = {}) {
63
168
  pending.set(sid, { signature: bindingKey([compaction.summary, compaction.firstKeptEntryId]), artifactId });
64
169
  log("summary_handoff", { session: sid, artifact_id: artifactId });
65
170
  }
66
- return compaction === undefined ? undefined : { compaction };
171
+ return compaction === undefined ? (prepared ? { cancel: true } : undefined) : { compaction };
67
172
  }
68
173
  catch (error) {
69
174
  if (!event.signal.aborted)
70
175
  report(ctx, error);
71
- return undefined;
176
+ return automatic ? { cancel: true } : undefined;
72
177
  }
73
178
  finally {
74
179
  log("host_compaction_receipt", { session: sessionHash(ctx.sessionManager.getSessionId()), host_compaction_ms: performance.now() - started });
75
180
  }
76
181
  });
77
182
  pi.on("session_compact", async (event, ctx) => {
183
+ // Pi's history now starts from its own compaction entry; the prepared
184
+ // prefix can no longer match, so stop offering it.
185
+ ready.delete(ctx.sessionManager.getSessionId());
78
186
  const sid = sessionHash(ctx.sessionManager.getSessionId());
79
187
  const handoff = pending.get(sid);
80
188
  if (event.fromExtension && handoff?.signature === bindingKey([event.compactionEntry.summary, event.compactionEntry.firstKeptEntryId]))
@@ -92,6 +200,9 @@ export function createCompactPiExtension(options = {}) {
92
200
  }
93
201
  });
94
202
  pi.on("session_shutdown", async () => {
203
+ invalidate();
204
+ automatic = undefined;
205
+ ready.clear();
95
206
  log("runtime_stopped");
96
207
  await host.close();
97
208
  await injected?.close();
@@ -134,10 +245,17 @@ export async function piHostBinding(ctx, pi) {
134
245
  const response = await ctx.modelRegistry.complete(model, {
135
246
  ...(ids?.length ? { systemPrompt: ctx.getSystemPrompt(), ...(tools ? { tools } : {}) } : {}),
136
247
  messages: [...selected.messages, { role: "user", content: selected.prompt, timestamp: Date.now() }],
137
- }, { signal, maxTokens: 4096, sessionId: ctx.sessionManager.getSessionId() });
248
+ }, { signal, maxTokens: piSummaryMaxTokens(ctx, model), sessionId: ctx.sessionManager.getSessionId() });
138
249
  usage = piSummaryUsage(response.usage);
139
- if (response.stopReason !== "stop" || response.content.some(b => b.type === "toolCall"))
140
- throw new Error("Pi summary incomplete");
250
+ const failure = (reason) => Object.assign(new Error("Pi summary incomplete"), { afuReason: reason });
251
+ if (response.stopReason === "length")
252
+ throw failure("summary_output_limit");
253
+ if (response.stopReason === "error")
254
+ throw failure("summary_provider_error");
255
+ if (response.stopReason !== "stop")
256
+ throw failure("summary_incomplete");
257
+ if (response.content.some(b => b.type === "toolCall"))
258
+ throw failure("summary_called_tool");
141
259
  return response.content.filter(b => b.type === "text").map(b => b.text).join("\n");
142
260
  },
143
261
  };
@@ -182,6 +300,8 @@ export function piEntriesToRequest(entries, ctx, requiredIds) {
182
300
  mode: "active",
183
301
  metadata: {
184
302
  adapter: "pi",
303
+ // Pi's own measurement of the whole request; the worker follows it for the trigger.
304
+ ...(Number.isSafeInteger(usage.tokens) && usage.tokens > 0 ? { host_context_tokens: usage.tokens } : {}),
185
305
  source_profile: {
186
306
  representation: "text",
187
307
  sequence_semantics: "append_only",
@@ -219,6 +339,90 @@ export function piCompactionFromResult(entries, result, tokensBefore) {
219
339
  },
220
340
  };
221
341
  }
342
+ /**
343
+ * Output budget for one summary, the way Pi sizes its own: 80% of
344
+ * compaction.reserveTokens (16,384 by default, so 13,107), capped by the
345
+ * model's maxTokens. Reasoning models spend part of it thinking; a fixed
346
+ * 4,096 made long summaries stop at the limit and every retry fail.
347
+ */
348
+ export function piSummaryMaxTokens(ctx, model) {
349
+ let reserve = DEFAULT_COMPACTION_SETTINGS.reserveTokens;
350
+ try {
351
+ const value = SettingsManager.create(ctx.cwd, getAgentDir()).getCompactionReserveTokens();
352
+ if (Number.isFinite(value) && value > 0)
353
+ reserve = value;
354
+ }
355
+ catch { /* Pi's default */ }
356
+ const budget = Math.max(4096, Math.floor(0.8 * reserve));
357
+ return typeof model.maxTokens === "number" && model.maxTokens > 0 ? Math.min(budget, model.maxTokens) : budget;
358
+ }
359
+ /** Pi's compaction.keepRecentTokens for this project (Pi's own default when unset). */
360
+ function piKeepRecentTokens(ctx) {
361
+ try {
362
+ const value = SettingsManager.create(ctx.cwd, getAgentDir()).getCompactionKeepRecentTokens();
363
+ if (Number.isFinite(value) && value >= 0)
364
+ return value;
365
+ }
366
+ catch { /* fall back to Pi's default */ }
367
+ return DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
368
+ }
369
+ /**
370
+ * Whether Pi's own compaction would accept a commit now: the messages after
371
+ * the latest compaction must exceed keepRecentTokens (Pi's estimate), or Pi
372
+ * reports "Nothing to compact (session too small)".
373
+ */
374
+ export function piCanCompact(ctx, visible, keepRecent = piKeepRecentTokens(ctx)) {
375
+ let total = 0;
376
+ for (const entry of visible) {
377
+ if (entry.type === "compaction") {
378
+ total = 0;
379
+ continue;
380
+ }
381
+ for (const message of sessionEntryToContextMessages(entry))
382
+ total += estimateTokens(message);
383
+ }
384
+ return total > keepRecent;
385
+ }
386
+ /** A ready result that replaces a leading run of the given entries. */
387
+ export function preparedSummary(visible, result) {
388
+ const artifact = findAppliedArtifact(result);
389
+ if (artifact === undefined || typeof artifact.item.content !== "string")
390
+ return undefined;
391
+ const ids = visible.map((entry) => entry.id);
392
+ if (artifact.replacedItemIds.length === 0 || artifact.replacedItemIds.length >= ids.length)
393
+ return undefined;
394
+ if (artifact.replacedItemIds.some((id, index) => ids[index] !== id))
395
+ return undefined;
396
+ const replaced = visible.slice(0, artifact.replacedItemIds.length).flatMap((entry) => sessionEntryToContextMessages(entry));
397
+ const replacedChars = JSON.stringify(replaced).length;
398
+ // Same id the summary_started/summary_ready events carry, so the dashboard links them.
399
+ const artifactId = observationId(result) ?? artifact.artifactId;
400
+ return {
401
+ replaced,
402
+ summary: artifact.item.content,
403
+ ...(artifactId === undefined ? {} : { artifactId }),
404
+ removedTokens: Math.max(0, Math.round((replacedChars - artifact.item.content.length) / 4)),
405
+ };
406
+ }
407
+ /**
408
+ * Replace the summarized prefix of a model request with Pi's own compaction
409
+ * summary message. Only an exact prefix match is rewritten; anything else
410
+ * (a later Pi compaction, a branch switch, edited history) is sent unchanged.
411
+ */
412
+ export function applyPreparedSummary(messages, ready) {
413
+ const count = ready.replaced.length;
414
+ if (messages.length <= count)
415
+ return undefined;
416
+ for (let index = 0; index < count; index++) {
417
+ if (JSON.stringify(messages[index]) !== JSON.stringify(ready.replaced[index]))
418
+ return undefined;
419
+ }
420
+ const first = messages[0];
421
+ return [
422
+ { role: "compactionSummary", summary: ready.summary, tokensBefore: ready.removedTokens, timestamp: first?.timestamp ?? Date.now() },
423
+ ...messages.slice(count),
424
+ ];
425
+ }
222
426
  function modelVisibleEntries(entries) {
223
427
  return entries.filter((entry) => sessionEntryToContextMessages(entry).length > 0);
224
428
  }
@@ -62,7 +62,8 @@ export class HostCompactPool {
62
62
  throw new Error("Host binding requires the local worker factory");
63
63
  if (factory < 0)
64
64
  args.push("--factory", "afu_context.local_worker:create_plugin");
65
- const client = new CompactWorkerClient({ ...this.options, timeoutMs: this.options.timeoutMs ?? 150_000, args, env: {
65
+ // Above the 600 s background summary limit, so the worker reports its own timeout.
66
+ const client = new CompactWorkerClient({ ...this.options, timeoutMs: this.options.timeoutMs ?? 630_000, args, env: {
66
67
  ...this.options.env,
67
68
  ...(binding.contextWindow === undefined ? {} : { AFU_CONTEXT_WINDOW: String(binding.contextWindow) }),
68
69
  AFU_HOST_SUMMARY_URL: `${this.base}/${this.capability}/${binding.key}`,
@@ -129,10 +130,12 @@ export class HostCompactPool {
129
130
  res.setHeader("Content-Type", "application/json");
130
131
  res.end(JSON.stringify({ choices: [{ message: { content: text }, finish_reason: "stop" }] }));
131
132
  }
132
- catch {
133
+ catch (error) {
133
134
  const diag = entry.binding.diagnostics;
135
+ // A short, fixed reason code from the binding (never provider text).
136
+ const code = typeof error?.afuReason === "string" ? error.afuReason : "host_summary_unavailable";
134
137
  if (diag)
135
- journal(diag.host)("summary_failed", { session: diag.session, artifact_id: artifactId, duration_ms: started === undefined ? undefined : performance.now() - started, reason: controller.signal.aborted ? "summary_cancelled" : "host_summary_unavailable" });
138
+ journal(diag.host)("summary_failed", { session: diag.session, artifact_id: artifactId, duration_ms: started === undefined ? undefined : performance.now() - started, reason: controller.signal.aborted ? "summary_cancelled" : code });
136
139
  if (!res.destroyed)
137
140
  res.writeHead(502).end('{"error":"host_summary_unavailable"}');
138
141
  }
@@ -237,6 +237,6 @@ function isJsonValue(value) {
237
237
  return isRecord(value) && Object.values(value).every(isJsonValue);
238
238
  }
239
239
  export { HostCompactPool, bindingKey } from "./host-worker.js";
240
- export { journal, sessionHash } from "./telemetry.js";
240
+ export { journal, sessionHash, afuTrigger } from "./telemetry.js";
241
241
  export { summaryPrefix } from "./prefix.js";
242
242
  //# sourceMappingURL=index.js.map
@@ -3,15 +3,35 @@ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { randomUUID, createHash } from 'node:crypto';
5
5
  export const sessionHash = (value) => createHash('sha256').update(value).digest('hex').slice(0, 16);
6
+ /**
7
+ * Context window and the token count at which AFU starts a background summary,
8
+ * matching the worker (afu_context.local_worker): an explicit window override,
9
+ * then AFU_TRIGGER_RATIO, else 0.5 for windows of 1M tokens or more and 0.6 below.
10
+ */
11
+ export function afuTrigger(hostWindow, env = process.env, _platform = process.platform) {
12
+ const override = env.AFU_HOST_CONTEXT_WINDOW_OVERRIDE;
13
+ const window = override && override !== 'auto' ? Number(override) : hostWindow;
14
+ if (!Number.isSafeInteger(window) || !window || window < 1)
15
+ return {};
16
+ const configured = env.AFU_TRIGGER_RATIO && env.AFU_TRIGGER_RATIO !== 'auto' ? Number(env.AFU_TRIGGER_RATIO) : NaN;
17
+ const fallback = window >= 1_000_000 ? 0.5 : 0.6;
18
+ const ratio = configured > 0 && configured < 1 ? configured : fallback;
19
+ return { context_window: window, threshold: Math.floor(window * ratio) };
20
+ }
6
21
  const journals = new Map();
7
22
  export function journal(host) {
8
23
  let log = journals.get(host);
9
24
  if (!log) {
10
- log = createJournal(host);
11
- journals.set(host, log);
12
- log('runtime_started');
25
+ const created = createJournal(host);
26
+ journals.set(host, created);
27
+ created('runtime_started');
28
+ // One runtime_stopped per process, however many times the host recycles
29
+ // the adapter (DSH disposes an engine per agent scope).
30
+ process.once('exit', () => created('runtime_stopped'));
31
+ log = created;
13
32
  }
14
- return log;
33
+ return (event, fields) => { if (event !== 'runtime_stopped')
34
+ log(event, fields); };
15
35
  }
16
36
  function createJournal(host) {
17
37
  const root = join(process.env.AFU_STATE_DIR ?? join(homedir(), '.local/share/afu-context'), 'logs');
@@ -33,7 +53,7 @@ function createJournal(host) {
33
53
  for (const [k, v] of Object.entries(fields)) {
34
54
  if (['session', 'artifact_id', 'phase', 'reason'].includes(k) && typeof v === 'string' && /^[A-Za-z0-9_.:+-]{1,100}$/.test(v))
35
55
  row[k] = v;
36
- if (['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'context_window', 'threshold', 'duration_ms', 'host_compaction_ms'].includes(k) && typeof v === 'number' && Number.isFinite(v) && v >= 0)
56
+ if (['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'context_window', 'threshold', 'estimated_input_tokens', 'removed_tokens_estimate', 'duration_ms', 'host_compaction_ms'].includes(k) && typeof v === 'number' && Number.isFinite(v) && v >= 0)
37
57
  row[k] = v;
38
58
  if (['cache_usage_known', 'applied', 'upstream_success'].includes(k) && typeof v === 'boolean')
39
59
  row[k] = v;