@afu_context/afu-context 0.0.0-stage → 0.1.7

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.
@@ -0,0 +1,272 @@
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";
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
+ const report = (ctx, error) => {
16
+ if (ctx.signal?.aborted)
17
+ return;
18
+ log("adapter_error", { session: sessionHash(ctx.sessionManager.getSessionId()), reason: "host_model_unavailable_or_changed" });
19
+ if (warned || options.notifyErrors === false)
20
+ return;
21
+ warned = true;
22
+ const message = "host model unavailable or changed";
23
+ ctx.ui.notify(`compact unavailable; Pi compaction remains active: ${message}`, "warning");
24
+ };
25
+ pi.on("agent_settled", async (_event, ctx) => {
26
+ log("request_observed", { session: sessionHash(ctx.sessionManager.getSessionId()), phase: "host_turn_settled" });
27
+ const entries = ctx.sessionManager.buildContextEntries();
28
+ const visible = modelVisibleEntries(entries);
29
+ const latest = visible.at(-1);
30
+ if (latest === undefined)
31
+ return;
32
+ const request = piEntriesToRequest(entries, ctx, new Set([latest.id]));
33
+ if (request === undefined)
34
+ return;
35
+ try {
36
+ await compact(request, ctx, ctx.signal);
37
+ warned = false;
38
+ }
39
+ catch (error) {
40
+ report(ctx, error);
41
+ }
42
+ });
43
+ pi.on("session_before_compact", async (event, ctx) => {
44
+ const started = performance.now();
45
+ const entries = modelVisibleEntries(ctx.sessionManager.buildContextEntries());
46
+ const latest = entries.at(-1);
47
+ if (latest === undefined)
48
+ return;
49
+ const requiredIds = new Set([latest.id]);
50
+ const request = piEntriesToRequest(entries, ctx, requiredIds);
51
+ if (request === undefined)
52
+ return;
53
+ try {
54
+ const result = await compact(request, ctx, event.signal);
55
+ warned = false;
56
+ const compaction = piCompactionFromResult(entries, result, event.preparation.tokensBefore);
57
+ if (compaction) {
58
+ const sid = sessionHash(ctx.sessionManager.getSessionId());
59
+ const artifactId = observationId(result);
60
+ const previous = pending.get(sid);
61
+ if (previous?.artifactId && previous.artifactId !== artifactId)
62
+ log("summary_discarded", { session: sid, artifact_id: previous.artifactId, reason: "superseded" });
63
+ pending.set(sid, { signature: bindingKey([compaction.summary, compaction.firstKeptEntryId]), artifactId });
64
+ log("summary_handoff", { session: sid, artifact_id: artifactId });
65
+ }
66
+ return compaction === undefined ? undefined : { compaction };
67
+ }
68
+ catch (error) {
69
+ if (!event.signal.aborted)
70
+ report(ctx, error);
71
+ return undefined;
72
+ }
73
+ finally {
74
+ log("host_compaction_receipt", { session: sessionHash(ctx.sessionManager.getSessionId()), host_compaction_ms: performance.now() - started });
75
+ }
76
+ });
77
+ pi.on("session_compact", async (event, ctx) => {
78
+ const sid = sessionHash(ctx.sessionManager.getSessionId());
79
+ const handoff = pending.get(sid);
80
+ if (event.fromExtension && handoff?.signature === bindingKey([event.compactionEntry.summary, event.compactionEntry.firstKeptEntryId]))
81
+ log("summary_applied", { session: sid, artifact_id: handoff.artifactId, phase: "host_commit" });
82
+ else if (handoff)
83
+ log("summary_discarded", { session: sid, artifact_id: handoff.artifactId, reason: "host_used_other_summary" });
84
+ pending.delete(sid);
85
+ });
86
+ pi.on("session_compact_failed", async (_event, ctx) => {
87
+ const sid = sessionHash(ctx.sessionManager.getSessionId());
88
+ const handoff = pending.get(sid);
89
+ if (pending.delete(sid)) {
90
+ log("summary_discarded", { session: sid, artifact_id: handoff?.artifactId, reason: "host_commit_failed" });
91
+ log("adapter_error", { session: sid, reason: "host_commit_failed" });
92
+ }
93
+ });
94
+ pi.on("session_shutdown", async () => {
95
+ log("runtime_stopped");
96
+ await host.close();
97
+ await injected?.close();
98
+ });
99
+ };
100
+ }
101
+ /** Use the same provider runtime (including OAuth/custom headers) as Pi chat. */
102
+ export async function piHostBinding(ctx, pi) {
103
+ const model = ctx.model;
104
+ if (!model)
105
+ throw new Error("No selected Pi model");
106
+ if (!Number.isSafeInteger(model.contextWindow) || model.contextWindow <= 0)
107
+ throw new Error("Pi model window unavailable");
108
+ const identity = async () => {
109
+ const selected = ctx.model;
110
+ if (!selected)
111
+ return "";
112
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(selected);
113
+ if (!auth.ok)
114
+ throw new Error("Pi authentication unavailable");
115
+ return bindingKey([ctx.sessionManager.getSessionId(), selected, auth]);
116
+ };
117
+ const key = await identity();
118
+ let usage = {};
119
+ return {
120
+ key,
121
+ diagnostics: { host: "pi", session: sessionHash(ctx.sessionManager.getSessionId()), usage: () => usage },
122
+ contextWindow: model.contextWindow,
123
+ current: async () => key === await identity(),
124
+ complete: async (prompt, signal, ids) => {
125
+ const entries = ids?.length ? modelVisibleEntries(ctx.sessionManager.buildContextEntries()) : [];
126
+ const selected = summaryPrefix(entries.map(entry => ({ id: entry.id, messages: convertToLlm(sessionEntryToContextMessages(entry)) })), ids, prompt);
127
+ const definitions = pi?.getAllTools();
128
+ const tools = pi?.getActiveTools().map(name => {
129
+ const tool = definitions?.find(t => t.name === name);
130
+ if (!tool)
131
+ throw new Error("Active Pi tool unavailable");
132
+ return { name: tool.name, description: tool.description, parameters: tool.parameters };
133
+ });
134
+ const response = await ctx.modelRegistry.complete(model, {
135
+ ...(ids?.length ? { systemPrompt: ctx.getSystemPrompt(), ...(tools ? { tools } : {}) } : {}),
136
+ messages: [...selected.messages, { role: "user", content: selected.prompt, timestamp: Date.now() }],
137
+ }, { signal, maxTokens: 4096, sessionId: ctx.sessionManager.getSessionId() });
138
+ usage = piSummaryUsage(response.usage);
139
+ if (response.stopReason !== "stop" || response.content.some(b => b.type === "toolCall"))
140
+ throw new Error("Pi summary incomplete");
141
+ return response.content.filter(b => b.type === "text").map(b => b.text).join("\n");
142
+ },
143
+ };
144
+ }
145
+ export function piEntriesToRequest(entries, ctx, requiredIds) {
146
+ const usage = ctx.getContextUsage();
147
+ if (usage === undefined || usage.contextWindow <= 0)
148
+ return undefined;
149
+ const visible = modelVisibleEntries(entries);
150
+ if (visible.length < 2)
151
+ return undefined;
152
+ const callGroups = toolCallGroups(visible);
153
+ const items = visible.map((entry) => {
154
+ const message = sessionEntryToContextMessages(entry)[0];
155
+ if (message === undefined)
156
+ throw new Error(`Pi entry ${entry.id} has no model-visible message`);
157
+ const record = message;
158
+ const role = typeof record.role === "string" ? record.role : "user";
159
+ const callId = typeof record.toolCallId === "string" ? record.toolCallId : undefined;
160
+ const atomicGroup = callId === undefined ? callGroups.entryGroups.get(entry.id) : callGroups.calls.get(callId);
161
+ return {
162
+ schema_version: "1",
163
+ id: entry.id,
164
+ kind: entry.type === "compaction" ? "artifact" : piKind(role),
165
+ content: normalizeContent(record.content ?? ("summary" in entry ? entry.summary : "")),
166
+ source: { schema_version: "1", provider: "pi.session", source_id: entry.id },
167
+ required: requiredIds.has(entry.id),
168
+ ...(atomicGroup === undefined ? {} : { atomic_group: atomicGroup }),
169
+ metadata: {
170
+ adapter: "pi",
171
+ native_role: role,
172
+ native_entry_type: entry.type,
173
+ },
174
+ };
175
+ });
176
+ return {
177
+ schema_version: "1",
178
+ session_id: ctx.sessionManager.getSessionId(),
179
+ token_budget: usage.contextWindow,
180
+ items,
181
+ query: "",
182
+ mode: "active",
183
+ metadata: {
184
+ adapter: "pi",
185
+ source_profile: {
186
+ representation: "text",
187
+ sequence_semantics: "append_only",
188
+ atomic_unit: "turn",
189
+ preserve_atomic_units: true,
190
+ loss_tolerance: "low",
191
+ },
192
+ runtime_version: "0.84.4",
193
+ query_aware: false,
194
+ },
195
+ };
196
+ }
197
+ export function piCompactionFromResult(entries, result, tokensBefore) {
198
+ const artifact = findAppliedArtifact(result);
199
+ if (artifact === undefined || typeof artifact.item.content !== "string")
200
+ return undefined;
201
+ const visibleIds = modelVisibleEntries(entries).map((entry) => entry.id);
202
+ if (artifact.replacedItemIds.length >= visibleIds.length)
203
+ return undefined;
204
+ for (const [index, id] of artifact.replacedItemIds.entries()) {
205
+ if (visibleIds[index] !== id)
206
+ return undefined;
207
+ }
208
+ const firstKeptEntryId = visibleIds[artifact.replacedItemIds.length];
209
+ if (firstKeptEntryId === undefined)
210
+ return undefined;
211
+ return {
212
+ summary: artifact.item.content,
213
+ firstKeptEntryId,
214
+ tokensBefore,
215
+ estimatedTokensAfter: result.usage.output_tokens,
216
+ details: {
217
+ ...(artifact.artifactId === undefined ? {} : { artifactId: artifact.artifactId }),
218
+ diagnostics: result.diagnostics,
219
+ },
220
+ };
221
+ }
222
+ function modelVisibleEntries(entries) {
223
+ return entries.filter((entry) => sessionEntryToContextMessages(entry).length > 0);
224
+ }
225
+ function piKind(role) {
226
+ if (role === "assistant")
227
+ return "assistant";
228
+ if (role === "toolResult" || role === "tool")
229
+ return "tool";
230
+ if (role === "system")
231
+ return "system";
232
+ return "user";
233
+ }
234
+ function toolCallGroups(entries) {
235
+ const calls = new Map();
236
+ const entryGroups = new Map();
237
+ for (const entry of entries) {
238
+ const message = sessionEntryToContextMessages(entry)[0];
239
+ if (!isRecord(message) || !Array.isArray(message.content))
240
+ continue;
241
+ const callIds = message.content.flatMap((block) => {
242
+ if (!isRecord(block) || block.type !== "toolCall" || typeof block.id !== "string")
243
+ return [];
244
+ return [block.id];
245
+ });
246
+ if (callIds.length === 0)
247
+ continue;
248
+ const group = `pi-tool-batch:${entry.id}`;
249
+ entryGroups.set(entry.id, group);
250
+ for (const callId of callIds)
251
+ calls.set(callId, group);
252
+ }
253
+ return { calls, entryGroups };
254
+ }
255
+ function isRecord(value) {
256
+ return typeof value === "object" && value !== null && !Array.isArray(value);
257
+ }
258
+ export default createCompactPiExtension();
259
+ /** SDKs normalize absent cache fields to zero. Only positive counters prove observation.
260
+ * Keep normalized zero unknown instead of inventing a measured cache miss.
261
+ */
262
+ export function piSummaryUsage(u) {
263
+ if (!u)
264
+ return { cache_usage_known: false };
265
+ return {
266
+ input_tokens: u.input + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0), output_tokens: u.output,
267
+ cache_usage_known: u.cacheRead > 0,
268
+ ...(u.cacheRead > 0 ? { cache_read_input_tokens: u.cacheRead } : {}),
269
+ ...(u.cacheWrite > 0 ? { cache_creation_input_tokens: u.cacheWrite } : {}),
270
+ };
271
+ }
272
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,168 @@
1
+ import { journal } from "./telemetry.js";
2
+ import { createServer } from "node:http";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { CompactWorkerClient, workerOptionsFromEnv } from "./index.js";
5
+ export function bindingKey(value) {
6
+ return createHash("sha256").update((JSON.stringify(value) ?? "null")).digest("hex");
7
+ }
8
+ /** Private in-process callback: provider credentials stay inside the host. */
9
+ export class HostCompactPool {
10
+ options;
11
+ server;
12
+ starting;
13
+ base = "";
14
+ capability = randomBytes(32).toString("hex");
15
+ entries = new Map();
16
+ controllers = new Set();
17
+ closed = false;
18
+ constructor(options = workerOptionsFromEnv()) {
19
+ this.options = options;
20
+ }
21
+ async compact(request, binding, signal) {
22
+ const override = this.options.env?.AFU_HOST_CONTEXT_WINDOW_OVERRIDE ?? process.env.AFU_HOST_CONTEXT_WINDOW_OVERRIDE;
23
+ if (override && override !== "auto") {
24
+ const window = Number(override);
25
+ if (!Number.isSafeInteger(window) || window < 1)
26
+ throw new Error("Invalid context window");
27
+ binding = { ...binding, contextWindow: window, key: bindingKey([binding.key, window]) };
28
+ }
29
+ if (this.closed || !await binding.current())
30
+ throw new Error("Host model changed");
31
+ this.starting ??= this.start();
32
+ await this.starting;
33
+ if (this.closed)
34
+ throw new Error("Host worker closed");
35
+ for (const [key, old] of this.entries) {
36
+ let active = true;
37
+ if (key !== binding.key) {
38
+ try {
39
+ active = await old.binding.current();
40
+ }
41
+ catch {
42
+ active = false;
43
+ }
44
+ }
45
+ if (!active) {
46
+ this.entries.delete(key);
47
+ const closing = old.client.close({ drain: false }).catch(() => undefined);
48
+ if (process.platform === "win32")
49
+ await closing;
50
+ }
51
+ }
52
+ if (this.closed)
53
+ throw new Error("Host worker closed");
54
+ let entry = this.entries.get(binding.key);
55
+ if (!entry) {
56
+ // Do not evict or interrupt another active session to admit a new one.
57
+ if (this.entries.size >= 16)
58
+ throw new Error("Host model binding limit reached");
59
+ const args = [...(this.options.args ?? ["compact", "worker"])];
60
+ const factory = args.indexOf("--factory");
61
+ if (factory >= 0 && args[factory + 1] !== "afu_context.local_worker:create_plugin")
62
+ throw new Error("Host binding requires the local worker factory");
63
+ if (factory < 0)
64
+ args.push("--factory", "afu_context.local_worker:create_plugin");
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: {
67
+ ...this.options.env,
68
+ ...(binding.contextWindow === undefined ? {} : { AFU_CONTEXT_WINDOW: String(binding.contextWindow) }),
69
+ AFU_HOST_SUMMARY_URL: `${this.base}/${this.capability}/${binding.key}`,
70
+ AFU_HOST_SUMMARY_TOKEN: this.capability,
71
+ NO_PROXY: [process.env.NO_PROXY, "127.0.0.1"].filter(Boolean).join(","),
72
+ no_proxy: [process.env.no_proxy, "127.0.0.1"].filter(Boolean).join(","),
73
+ }, onStderr: () => undefined });
74
+ entry = { binding, client };
75
+ this.entries.set(binding.key, entry);
76
+ }
77
+ entry.binding = binding;
78
+ if (this.closed)
79
+ throw new Error("Host worker closed");
80
+ const result = await entry.client.compact({ ...request, ...(binding.contextWindow === undefined ? {} : { token_budget: binding.contextWindow }), session_id: bindingKey([request.session_id, binding.key]) }, signal);
81
+ if (!await binding.current())
82
+ throw new Error("Host model changed");
83
+ return result;
84
+ }
85
+ async start() {
86
+ const server = createServer(async (req, res) => {
87
+ const key = req.url?.split("/")[2];
88
+ const entry = key ? this.entries.get(key) : undefined;
89
+ // COSEC: loopback alone is not authentication; check a random capability,
90
+ // exact route and method. Never log provider errors, headers or bodies.
91
+ if (req.method !== "POST" || !entry || req.url !== `/${this.capability}/${key}/chat/completions` || req.headers.authorization !== `Bearer ${this.capability}`) {
92
+ res.writeHead(403).end();
93
+ return;
94
+ }
95
+ let artifactId;
96
+ let started;
97
+ const controller = new AbortController();
98
+ this.controllers.add(controller);
99
+ const timer = setTimeout(() => controller.abort(), 120_000);
100
+ res.on("close", () => { if (!res.writableEnded)
101
+ controller.abort(); });
102
+ try {
103
+ const chunks = [];
104
+ let size = 0;
105
+ for await (const chunk of req) {
106
+ size += chunk.length;
107
+ if (size > 16 * 1024 * 1024)
108
+ throw new Error("Summary too large");
109
+ chunks.push(chunk);
110
+ }
111
+ const body = JSON.parse(Buffer.concat(chunks).toString());
112
+ const prompt = body?.messages?.[0]?.content;
113
+ if (typeof prompt !== "string" || !await entry.binding.current())
114
+ throw new Error("Invalid summary request");
115
+ const diag = entry.binding.diagnostics;
116
+ artifactId = typeof body.afu_summary_id === "string" && /^[a-f0-9]{24}$/.test(body.afu_summary_id) ? body.afu_summary_id : undefined;
117
+ const ids = body.afu_source_item_ids;
118
+ // COSEC: source coverage is validated before any host-provider call.
119
+ if (ids !== undefined && (!Array.isArray(ids) || ids.some((id) => typeof id !== "string") || new Set(ids).size !== ids.length))
120
+ throw new Error("Invalid source coverage");
121
+ const binding = entry.binding;
122
+ started = performance.now();
123
+ if (diag)
124
+ journal(diag.host)("summary_started", { session: diag.session, artifact_id: artifactId });
125
+ const text = await binding.complete(prompt, controller.signal, ids);
126
+ if (!text.trim() || !await binding.current())
127
+ throw new Error("Stale summary");
128
+ if (diag)
129
+ journal(diag.host)("summary_ready", { session: diag.session, artifact_id: artifactId, duration_ms: performance.now() - started, ...diag.usage?.() });
130
+ res.setHeader("Content-Type", "application/json");
131
+ res.end(JSON.stringify({ choices: [{ message: { content: text }, finish_reason: "stop" }] }));
132
+ }
133
+ catch {
134
+ const diag = entry.binding.diagnostics;
135
+ if (diag)
136
+ 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" });
137
+ if (!res.destroyed)
138
+ res.writeHead(502).end('{"error":"host_summary_unavailable"}');
139
+ }
140
+ finally {
141
+ clearTimeout(timer);
142
+ this.controllers.delete(controller);
143
+ }
144
+ });
145
+ this.server = server;
146
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
147
+ const address = server.address();
148
+ if (!address || typeof address === "string")
149
+ throw new Error("Host bridge unavailable");
150
+ this.base = `http://127.0.0.1:${address.port}`;
151
+ }
152
+ async close() {
153
+ this.closed = true;
154
+ await this.starting?.catch(() => undefined);
155
+ for (const controller of this.controllers)
156
+ controller.abort();
157
+ // Entries registered by a compact() racing with close are closed here too.
158
+ while (this.entries.size) {
159
+ const entries = [...this.entries.values()];
160
+ this.entries.clear();
161
+ await Promise.allSettled(entries.map(e => e.client.close({ drain: false })));
162
+ }
163
+ this.server?.closeAllConnections();
164
+ if (this.server)
165
+ await new Promise(resolve => this.server.close(() => resolve()));
166
+ }
167
+ }
168
+ //# sourceMappingURL=host-worker.js.map
@@ -0,0 +1,242 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ export * from "./protocol.js";
4
+ export class CompactWorkerError extends Error {
5
+ name = "CompactWorkerError";
6
+ }
7
+ export class CompactWorkerClient {
8
+ options;
9
+ child;
10
+ lines;
11
+ nextId = 1;
12
+ pending = new Map();
13
+ closing = false;
14
+ constructor(options = {}) {
15
+ this.options = {
16
+ ...options,
17
+ command: options.command ?? "afu-context",
18
+ args: options.args ?? ["compact", "worker"],
19
+ timeoutMs: options.timeoutMs ?? 30_000,
20
+ };
21
+ }
22
+ async ping(signal) {
23
+ await this.call("ping", {}, signal);
24
+ }
25
+ async compact(request, signal) {
26
+ const value = await this.call("compact", { request: request }, signal);
27
+ if (!isCompactResult(value)) {
28
+ throw new CompactWorkerError("worker returned an invalid CompactResult");
29
+ }
30
+ return value;
31
+ }
32
+ async drain(signal) {
33
+ await this.call("drain", {}, signal);
34
+ }
35
+ async close(options = {}) {
36
+ if (this.child === undefined)
37
+ return;
38
+ this.closing = true;
39
+ const child = this.child;
40
+ let stopped = false;
41
+ try {
42
+ await this.call("shutdown", { drain: options.drain ?? true }, undefined, true);
43
+ stopped = true;
44
+ }
45
+ finally {
46
+ this.lines?.close();
47
+ child.stdin.end();
48
+ if (!stopped && child.exitCode === null && !child.killed)
49
+ child.kill();
50
+ this.child = undefined;
51
+ this.lines = undefined;
52
+ this.rejectAll(new CompactWorkerError("worker closed"));
53
+ if (process.platform === "win32" && child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
54
+ // Windows keeps the worker's cwd/files locked until the process exits.
55
+ await new Promise((resolve, reject) => {
56
+ const finished = () => { clearTimeout(kill); clearTimeout(deadline); resolve(); };
57
+ const kill = setTimeout(() => child.kill(), 5000);
58
+ const deadline = setTimeout(() => {
59
+ child.off("exit", finished);
60
+ reject(new CompactWorkerError("worker did not exit after shutdown"));
61
+ }, 10000);
62
+ child.once("exit", finished);
63
+ });
64
+ }
65
+ }
66
+ }
67
+ call(method, params, signal, allowWhileClosing = false) {
68
+ if (this.closing && !allowWhileClosing) {
69
+ return Promise.reject(new CompactWorkerError("worker is closing"));
70
+ }
71
+ if (signal?.aborted) {
72
+ return Promise.reject(abortError(signal));
73
+ }
74
+ const child = this.ensureStarted();
75
+ const id = `${process.pid}-${this.nextId++}`;
76
+ return new Promise((resolve, reject) => {
77
+ const pending = { resolve, reject };
78
+ if (this.options.timeoutMs > 0) {
79
+ pending.timer = setTimeout(() => {
80
+ this.pending.delete(id);
81
+ pending.removeAbort?.();
82
+ reject(new CompactWorkerError(`worker request timed out after ${this.options.timeoutMs}ms`));
83
+ }, this.options.timeoutMs);
84
+ }
85
+ if (signal !== undefined) {
86
+ const onAbort = () => {
87
+ this.pending.delete(id);
88
+ if (pending.timer !== undefined)
89
+ clearTimeout(pending.timer);
90
+ reject(abortError(signal));
91
+ };
92
+ signal.addEventListener("abort", onAbort, { once: true });
93
+ pending.removeAbort = () => signal.removeEventListener("abort", onAbort);
94
+ }
95
+ this.pending.set(id, pending);
96
+ const payload = JSON.stringify({ id, method, params });
97
+ child.stdin.write(`${payload}\n`, (error) => {
98
+ if (error === null || error === undefined)
99
+ return;
100
+ this.finishPending(id, undefined, new CompactWorkerError(error.message));
101
+ });
102
+ });
103
+ }
104
+ ensureStarted() {
105
+ if (this.child !== undefined)
106
+ return this.child;
107
+ const child = spawn(this.options.command, this.options.args, {
108
+ cwd: this.options.cwd,
109
+ env: { ...process.env, ...this.options.env },
110
+ stdio: ["pipe", "pipe", "pipe"],
111
+ });
112
+ this.child = child;
113
+ // A worker that dies mid-write emits EPIPE on stdin; without a listener
114
+ // Node treats that as fatal and takes the host process down with it.
115
+ child.stdin.on("error", (error) => {
116
+ this.rejectAll(new CompactWorkerError(`worker input closed: ${error.message}`));
117
+ });
118
+ this.lines = createInterface({ input: child.stdout });
119
+ this.lines.on("line", (line) => this.handleLine(line));
120
+ child.stderr.on("data", (chunk) => {
121
+ const text = chunk.toString("utf8");
122
+ if (this.options.onStderr !== undefined)
123
+ this.options.onStderr(text);
124
+ else
125
+ process.stderr.write(`[afu_context compact worker] ${text}`);
126
+ });
127
+ child.on("error", (error) => {
128
+ // A spawn failure emits "error" without "exit": forget this child so
129
+ // the next call starts a fresh worker instead of writing to a dead pipe.
130
+ if (this.child === child) {
131
+ this.child = undefined;
132
+ this.lines?.close();
133
+ this.lines = undefined;
134
+ }
135
+ this.rejectAll(new CompactWorkerError(`worker failed to start: ${error.message}`));
136
+ });
137
+ child.on("exit", (code, signal) => {
138
+ const expected = this.closing && code === 0;
139
+ if (!expected || this.pending.size > 0) {
140
+ this.rejectAll(new CompactWorkerError(`worker exited (code=${String(code)}, signal=${String(signal)})`));
141
+ }
142
+ if (this.child === child) {
143
+ this.child = undefined;
144
+ this.lines = undefined;
145
+ }
146
+ });
147
+ return child;
148
+ }
149
+ handleLine(line) {
150
+ let value;
151
+ try {
152
+ value = JSON.parse(line);
153
+ }
154
+ catch {
155
+ this.rejectAll(new CompactWorkerError("worker wrote non-JSON data to stdout"));
156
+ return;
157
+ }
158
+ if (!isWorkerResponse(value)) {
159
+ this.rejectAll(new CompactWorkerError("worker wrote an invalid response envelope"));
160
+ return;
161
+ }
162
+ if (value.ok && value.result !== undefined) {
163
+ this.finishPending(value.id, value.result);
164
+ return;
165
+ }
166
+ const message = value.error?.message ?? "worker request failed";
167
+ const type = value.error?.type;
168
+ this.finishPending(value.id, undefined, new CompactWorkerError(type === undefined ? message : `${type}: ${message}`));
169
+ }
170
+ finishPending(id, value, error) {
171
+ const pending = this.pending.get(id);
172
+ if (pending === undefined)
173
+ return;
174
+ this.pending.delete(id);
175
+ if (pending.timer !== undefined)
176
+ clearTimeout(pending.timer);
177
+ pending.removeAbort?.();
178
+ if (error !== undefined)
179
+ pending.reject(error);
180
+ else
181
+ pending.resolve(value ?? null);
182
+ }
183
+ rejectAll(error) {
184
+ for (const id of this.pending.keys())
185
+ this.finishPending(id, undefined, error);
186
+ }
187
+ }
188
+ export function workerOptionsFromEnv(env = process.env) {
189
+ const encodedArgs = env.AFU_CONTEXT_COMPACT_WORKER_ARGS;
190
+ let args;
191
+ if (encodedArgs !== undefined) {
192
+ const decoded = JSON.parse(encodedArgs);
193
+ if (!Array.isArray(decoded) || !decoded.every((value) => typeof value === "string")) {
194
+ throw new CompactWorkerError("AFU_CONTEXT_COMPACT_WORKER_ARGS must be a JSON string array");
195
+ }
196
+ args = decoded;
197
+ }
198
+ const encodedTimeout = env.AFU_CONTEXT_COMPACT_WORKER_TIMEOUT_MS;
199
+ const timeoutMs = encodedTimeout === undefined ? undefined : Number(encodedTimeout);
200
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
201
+ throw new CompactWorkerError("AFU_CONTEXT_COMPACT_WORKER_TIMEOUT_MS must be a non-negative number");
202
+ }
203
+ return {
204
+ ...(env.AFU_CONTEXT_COMPACT_WORKER_COMMAND === undefined ? {} : { command: env.AFU_CONTEXT_COMPACT_WORKER_COMMAND }),
205
+ ...(args === undefined ? {} : { args }),
206
+ ...(env.AFU_CONTEXT_COMPACT_WORKER_CWD === undefined ? {} : { cwd: env.AFU_CONTEXT_COMPACT_WORKER_CWD }),
207
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
208
+ };
209
+ }
210
+ function abortError(signal) {
211
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
212
+ }
213
+ function isWorkerResponse(value) {
214
+ if (!isRecord(value) || typeof value.id !== "string" || typeof value.ok !== "boolean")
215
+ return false;
216
+ if (value.result !== undefined && !isJsonValue(value.result))
217
+ return false;
218
+ return value.error === undefined || isRecord(value.error);
219
+ }
220
+ function isCompactResult(value) {
221
+ return (isRecord(value) &&
222
+ value.schema_version === "1" &&
223
+ Array.isArray(value.items) &&
224
+ Array.isArray(value.logical_items) &&
225
+ isRecord(value.usage) &&
226
+ isRecord(value.async_job));
227
+ }
228
+ function isRecord(value) {
229
+ return typeof value === "object" && value !== null && !Array.isArray(value);
230
+ }
231
+ function isJsonValue(value) {
232
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
233
+ return true;
234
+ }
235
+ if (Array.isArray(value))
236
+ return value.every(isJsonValue);
237
+ return isRecord(value) && Object.values(value).every(isJsonValue);
238
+ }
239
+ export { HostCompactPool, bindingKey } from "./host-worker.js";
240
+ export { journal, sessionHash } from "./telemetry.js";
241
+ export { summaryPrefix } from "./prefix.js";
242
+ //# sourceMappingURL=index.js.map