@liwu1995/afu-context 0.0.0-stage → 0.1.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.
@@ -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,157 @@
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
+ void old.client.close({ drain: false }).catch(() => undefined);
48
+ }
49
+ }
50
+ let entry = this.entries.get(binding.key);
51
+ if (!entry) {
52
+ // Do not evict or interrupt another active session to admit a new one.
53
+ if (this.entries.size >= 16)
54
+ throw new Error("Host model binding limit reached");
55
+ const args = [...(this.options.args ?? ["compact", "worker"])];
56
+ const factory = args.indexOf("--factory");
57
+ if (factory >= 0 && args[factory + 1] !== "afu_context.local_worker:create_plugin")
58
+ throw new Error("Host binding requires the local worker factory");
59
+ if (factory < 0)
60
+ args.push("--factory", "afu_context.local_worker:create_plugin");
61
+ const client = new CompactWorkerClient({ ...this.options, timeoutMs: this.options.timeoutMs ?? 150_000, args, env: {
62
+ ...this.options.env,
63
+ ...(binding.contextWindow === undefined ? {} : { AFU_CONTEXT_WINDOW: String(binding.contextWindow) }),
64
+ AFU_HOST_SUMMARY_URL: `${this.base}/${this.capability}/${binding.key}`,
65
+ AFU_HOST_SUMMARY_TOKEN: this.capability,
66
+ NO_PROXY: [process.env.NO_PROXY, "127.0.0.1"].filter(Boolean).join(","),
67
+ no_proxy: [process.env.no_proxy, "127.0.0.1"].filter(Boolean).join(","),
68
+ }, onStderr: () => undefined });
69
+ entry = { binding, client };
70
+ this.entries.set(binding.key, entry);
71
+ }
72
+ entry.binding = binding;
73
+ const result = await entry.client.compact({ ...request, ...(binding.contextWindow === undefined ? {} : { token_budget: binding.contextWindow }), session_id: bindingKey([request.session_id, binding.key]) }, signal);
74
+ if (!await binding.current())
75
+ throw new Error("Host model changed");
76
+ return result;
77
+ }
78
+ async start() {
79
+ const server = createServer(async (req, res) => {
80
+ const key = req.url?.split("/")[2];
81
+ const entry = key ? this.entries.get(key) : undefined;
82
+ // COSEC: loopback alone is not authentication; check a random capability,
83
+ // exact route and method. Never log provider errors, headers or bodies.
84
+ if (req.method !== "POST" || !entry || req.url !== `/${this.capability}/${key}/chat/completions` || req.headers.authorization !== `Bearer ${this.capability}`) {
85
+ res.writeHead(403).end();
86
+ return;
87
+ }
88
+ let artifactId;
89
+ let started;
90
+ const controller = new AbortController();
91
+ this.controllers.add(controller);
92
+ const timer = setTimeout(() => controller.abort(), 120_000);
93
+ res.on("close", () => { if (!res.writableEnded)
94
+ controller.abort(); });
95
+ try {
96
+ const chunks = [];
97
+ let size = 0;
98
+ for await (const chunk of req) {
99
+ size += chunk.length;
100
+ if (size > 16 * 1024 * 1024)
101
+ throw new Error("Summary too large");
102
+ chunks.push(chunk);
103
+ }
104
+ const body = JSON.parse(Buffer.concat(chunks).toString());
105
+ const prompt = body?.messages?.[0]?.content;
106
+ if (typeof prompt !== "string" || !await entry.binding.current())
107
+ throw new Error("Invalid summary request");
108
+ const diag = entry.binding.diagnostics;
109
+ artifactId = typeof body.afu_summary_id === "string" && /^[a-f0-9]{24}$/.test(body.afu_summary_id) ? body.afu_summary_id : undefined;
110
+ const ids = body.afu_source_item_ids;
111
+ // COSEC: source coverage is validated before any host-provider call.
112
+ if (ids !== undefined && (!Array.isArray(ids) || ids.some((id) => typeof id !== "string") || new Set(ids).size !== ids.length))
113
+ throw new Error("Invalid source coverage");
114
+ const binding = entry.binding;
115
+ started = performance.now();
116
+ if (diag)
117
+ journal(diag.host)("summary_started", { session: diag.session, artifact_id: artifactId });
118
+ const text = await binding.complete(prompt, controller.signal, ids);
119
+ if (!text.trim() || !await binding.current())
120
+ throw new Error("Stale summary");
121
+ if (diag)
122
+ journal(diag.host)("summary_ready", { session: diag.session, artifact_id: artifactId, duration_ms: performance.now() - started, ...diag.usage?.() });
123
+ res.setHeader("Content-Type", "application/json");
124
+ res.end(JSON.stringify({ choices: [{ message: { content: text }, finish_reason: "stop" }] }));
125
+ }
126
+ catch {
127
+ const diag = entry.binding.diagnostics;
128
+ if (diag)
129
+ 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" });
130
+ if (!res.destroyed)
131
+ res.writeHead(502).end('{"error":"host_summary_unavailable"}');
132
+ }
133
+ finally {
134
+ clearTimeout(timer);
135
+ this.controllers.delete(controller);
136
+ }
137
+ });
138
+ this.server = server;
139
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
140
+ const address = server.address();
141
+ if (!address || typeof address === "string")
142
+ throw new Error("Host bridge unavailable");
143
+ this.base = `http://127.0.0.1:${address.port}`;
144
+ }
145
+ async close() {
146
+ this.closed = true;
147
+ await this.starting;
148
+ for (const controller of this.controllers)
149
+ controller.abort();
150
+ await Promise.allSettled([...this.entries.values()].map(e => e.client.close({ drain: false })));
151
+ this.entries.clear();
152
+ this.server?.closeAllConnections();
153
+ if (this.server)
154
+ await new Promise(resolve => this.server.close(() => resolve()));
155
+ }
156
+ }
157
+ //# sourceMappingURL=host-worker.js.map
@@ -0,0 +1,218 @@
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
+ }
54
+ }
55
+ call(method, params, signal, allowWhileClosing = false) {
56
+ if (this.closing && !allowWhileClosing) {
57
+ return Promise.reject(new CompactWorkerError("worker is closing"));
58
+ }
59
+ if (signal?.aborted) {
60
+ return Promise.reject(abortError(signal));
61
+ }
62
+ const child = this.ensureStarted();
63
+ const id = `${process.pid}-${this.nextId++}`;
64
+ return new Promise((resolve, reject) => {
65
+ const pending = { resolve, reject };
66
+ if (this.options.timeoutMs > 0) {
67
+ pending.timer = setTimeout(() => {
68
+ this.pending.delete(id);
69
+ pending.removeAbort?.();
70
+ reject(new CompactWorkerError(`worker request timed out after ${this.options.timeoutMs}ms`));
71
+ }, this.options.timeoutMs);
72
+ }
73
+ if (signal !== undefined) {
74
+ const onAbort = () => {
75
+ this.pending.delete(id);
76
+ if (pending.timer !== undefined)
77
+ clearTimeout(pending.timer);
78
+ reject(abortError(signal));
79
+ };
80
+ signal.addEventListener("abort", onAbort, { once: true });
81
+ pending.removeAbort = () => signal.removeEventListener("abort", onAbort);
82
+ }
83
+ this.pending.set(id, pending);
84
+ const payload = JSON.stringify({ id, method, params });
85
+ child.stdin.write(`${payload}\n`, (error) => {
86
+ if (error === null || error === undefined)
87
+ return;
88
+ this.finishPending(id, undefined, new CompactWorkerError(error.message));
89
+ });
90
+ });
91
+ }
92
+ ensureStarted() {
93
+ if (this.child !== undefined)
94
+ return this.child;
95
+ const child = spawn(this.options.command, this.options.args, {
96
+ cwd: this.options.cwd,
97
+ env: { ...process.env, ...this.options.env },
98
+ stdio: ["pipe", "pipe", "pipe"],
99
+ });
100
+ this.child = child;
101
+ this.lines = createInterface({ input: child.stdout });
102
+ this.lines.on("line", (line) => this.handleLine(line));
103
+ child.stderr.on("data", (chunk) => {
104
+ const text = chunk.toString("utf8");
105
+ if (this.options.onStderr !== undefined)
106
+ this.options.onStderr(text);
107
+ else
108
+ process.stderr.write(`[afu_context compact worker] ${text}`);
109
+ });
110
+ child.on("error", (error) => {
111
+ this.rejectAll(new CompactWorkerError(`worker failed to start: ${error.message}`));
112
+ });
113
+ child.on("exit", (code, signal) => {
114
+ const expected = this.closing && code === 0;
115
+ if (!expected || this.pending.size > 0) {
116
+ this.rejectAll(new CompactWorkerError(`worker exited (code=${String(code)}, signal=${String(signal)})`));
117
+ }
118
+ if (this.child === child) {
119
+ this.child = undefined;
120
+ this.lines = undefined;
121
+ }
122
+ });
123
+ return child;
124
+ }
125
+ handleLine(line) {
126
+ let value;
127
+ try {
128
+ value = JSON.parse(line);
129
+ }
130
+ catch {
131
+ this.rejectAll(new CompactWorkerError("worker wrote non-JSON data to stdout"));
132
+ return;
133
+ }
134
+ if (!isWorkerResponse(value)) {
135
+ this.rejectAll(new CompactWorkerError("worker wrote an invalid response envelope"));
136
+ return;
137
+ }
138
+ if (value.ok && value.result !== undefined) {
139
+ this.finishPending(value.id, value.result);
140
+ return;
141
+ }
142
+ const message = value.error?.message ?? "worker request failed";
143
+ const type = value.error?.type;
144
+ this.finishPending(value.id, undefined, new CompactWorkerError(type === undefined ? message : `${type}: ${message}`));
145
+ }
146
+ finishPending(id, value, error) {
147
+ const pending = this.pending.get(id);
148
+ if (pending === undefined)
149
+ return;
150
+ this.pending.delete(id);
151
+ if (pending.timer !== undefined)
152
+ clearTimeout(pending.timer);
153
+ pending.removeAbort?.();
154
+ if (error !== undefined)
155
+ pending.reject(error);
156
+ else
157
+ pending.resolve(value ?? null);
158
+ }
159
+ rejectAll(error) {
160
+ for (const id of this.pending.keys())
161
+ this.finishPending(id, undefined, error);
162
+ }
163
+ }
164
+ export function workerOptionsFromEnv(env = process.env) {
165
+ const encodedArgs = env.AFU_CONTEXT_COMPACT_WORKER_ARGS;
166
+ let args;
167
+ if (encodedArgs !== undefined) {
168
+ const decoded = JSON.parse(encodedArgs);
169
+ if (!Array.isArray(decoded) || !decoded.every((value) => typeof value === "string")) {
170
+ throw new CompactWorkerError("AFU_CONTEXT_COMPACT_WORKER_ARGS must be a JSON string array");
171
+ }
172
+ args = decoded;
173
+ }
174
+ const encodedTimeout = env.AFU_CONTEXT_COMPACT_WORKER_TIMEOUT_MS;
175
+ const timeoutMs = encodedTimeout === undefined ? undefined : Number(encodedTimeout);
176
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
177
+ throw new CompactWorkerError("AFU_CONTEXT_COMPACT_WORKER_TIMEOUT_MS must be a non-negative number");
178
+ }
179
+ return {
180
+ ...(env.AFU_CONTEXT_COMPACT_WORKER_COMMAND === undefined ? {} : { command: env.AFU_CONTEXT_COMPACT_WORKER_COMMAND }),
181
+ ...(args === undefined ? {} : { args }),
182
+ ...(env.AFU_CONTEXT_COMPACT_WORKER_CWD === undefined ? {} : { cwd: env.AFU_CONTEXT_COMPACT_WORKER_CWD }),
183
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
184
+ };
185
+ }
186
+ function abortError(signal) {
187
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
188
+ }
189
+ function isWorkerResponse(value) {
190
+ if (!isRecord(value) || typeof value.id !== "string" || typeof value.ok !== "boolean")
191
+ return false;
192
+ if (value.result !== undefined && !isJsonValue(value.result))
193
+ return false;
194
+ return value.error === undefined || isRecord(value.error);
195
+ }
196
+ function isCompactResult(value) {
197
+ return (isRecord(value) &&
198
+ value.schema_version === "1" &&
199
+ Array.isArray(value.items) &&
200
+ Array.isArray(value.logical_items) &&
201
+ isRecord(value.usage) &&
202
+ isRecord(value.async_job));
203
+ }
204
+ function isRecord(value) {
205
+ return typeof value === "object" && value !== null && !Array.isArray(value);
206
+ }
207
+ function isJsonValue(value) {
208
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
209
+ return true;
210
+ }
211
+ if (Array.isArray(value))
212
+ return value.every(isJsonValue);
213
+ return isRecord(value) && Object.values(value).every(isJsonValue);
214
+ }
215
+ export { HostCompactPool, bindingKey } from "./host-worker.js";
216
+ export { journal, sessionHash } from "./telemetry.js";
217
+ export { summaryPrefix } from "./prefix.js";
218
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ /** Keep the host prefix unchanged; coverage instructions are appended at its end. */
2
+ export function summaryPrefix(entries, ids, prompt) {
3
+ if (!ids?.length)
4
+ return { messages: [], prompt }; // artifact-only consolidation
5
+ const start = entries.findIndex(entry => entry.id === ids[0]);
6
+ if (start < 0 || ids.some((id, i) => entries[start + i]?.id !== id))
7
+ throw new Error("Summary source no longer matches host history");
8
+ const before = entries.slice(0, start).flatMap(e => [...e.messages]).length;
9
+ const messages = entries.slice(0, start + ids.length).flatMap(e => [...e.messages]);
10
+ return { messages, prompt: before === 0 ? prompt : `${prompt}\nSource scope: use only messages ${before + 1} through ${messages.length} (one-based) as new source, together with any previous summary supplied in this request. Earlier messages are context only; do not import additional facts from them. Return one replacement summary.` };
11
+ }
12
+ //# sourceMappingURL=prefix.js.map