@corenel/term 0.1.0 → 0.2.1

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.
Files changed (56) hide show
  1. package/LICENSE +93 -0
  2. package/dist/chunk-2D7G4YW2.js +97 -0
  3. package/dist/chunk-CB5RFNB4.js +33 -0
  4. package/dist/chunk-F4ENQXQQ.js +909 -0
  5. package/dist/chunk-I44FC7PE.js +142 -0
  6. package/dist/chunk-IK4ZANNW.js +95 -0
  7. package/dist/chunk-M44CET5L.js +40 -0
  8. package/dist/chunk-OSJFMQCW.js +44 -0
  9. package/dist/{chunk-R6AXTD55.js → chunk-VTJEWHM3.js} +19 -1
  10. package/dist/commands.d.ts +14 -0
  11. package/dist/commands.d.ts.map +1 -1
  12. package/dist/commands.js +1 -1
  13. package/dist/describe.d.ts +23 -0
  14. package/dist/describe.d.ts.map +1 -1
  15. package/dist/describe.js +6 -1
  16. package/dist/diff.d.ts +100 -0
  17. package/dist/diff.d.ts.map +1 -0
  18. package/dist/diff.js +18 -0
  19. package/dist/ink/App.d.ts +12 -0
  20. package/dist/ink/App.d.ts.map +1 -1
  21. package/dist/ink/Modal.d.ts.map +1 -1
  22. package/dist/ink/TodoBlock.d.ts +10 -0
  23. package/dist/ink/TodoBlock.d.ts.map +1 -0
  24. package/dist/ink/banner.d.ts +36 -0
  25. package/dist/ink/banner.d.ts.map +1 -0
  26. package/dist/ink/index.d.ts.map +1 -1
  27. package/dist/ink/index.js +300 -140
  28. package/dist/ink/items.d.ts.map +1 -1
  29. package/dist/ink/scroll.d.ts +14 -2
  30. package/dist/ink/scroll.d.ts.map +1 -1
  31. package/dist/ink/status.d.ts.map +1 -1
  32. package/dist/ink/theme.d.ts +9 -0
  33. package/dist/ink/theme.d.ts.map +1 -1
  34. package/dist/ink/todos.d.ts +20 -0
  35. package/dist/ink/todos.d.ts.map +1 -0
  36. package/dist/markdown.d.ts.map +1 -1
  37. package/dist/persist.d.ts +27 -4
  38. package/dist/persist.d.ts.map +1 -1
  39. package/dist/persist.js +3 -1
  40. package/dist/policySource.d.ts +26 -0
  41. package/dist/policySource.d.ts.map +1 -0
  42. package/dist/policySource.js +8 -0
  43. package/dist/session.d.ts +199 -13
  44. package/dist/session.d.ts.map +1 -1
  45. package/dist/session.js +10 -5
  46. package/dist/state.d.ts +84 -0
  47. package/dist/state.d.ts.map +1 -1
  48. package/dist/state.js +5 -3
  49. package/dist/writePreview.d.ts +58 -0
  50. package/dist/writePreview.d.ts.map +1 -0
  51. package/dist/writePreview.js +9 -0
  52. package/package.json +19 -6
  53. package/dist/chunk-47IH6X5H.js +0 -20
  54. package/dist/chunk-HAQGHLW6.js +0 -235
  55. package/dist/chunk-HYWLODZT.js +0 -1230
  56. package/dist/chunk-TCVDVYMF.js +0 -40
@@ -1,1230 +0,0 @@
1
- import {
2
- emptyState
3
- } from "./chunk-47IH6X5H.js";
4
- import {
5
- parseCommand
6
- } from "./chunk-R6AXTD55.js";
7
- import {
8
- capPreview,
9
- describeToolCall,
10
- formatTools
11
- } from "./chunk-TCVDVYMF.js";
12
- import {
13
- committedFromMessages,
14
- titleFrom
15
- } from "./chunk-HAQGHLW6.js";
16
-
17
- // ../harness/providers/gateway.ts
18
- import OpenAI from "openai";
19
-
20
- // ../harness/log.ts
21
- var enabled = true;
22
- function setAgentLogging(on) {
23
- enabled = on;
24
- if (on) console.log("[agent] logging enabled");
25
- }
26
- function alog(...args) {
27
- if (enabled) console.debug("[agent]", ...args);
28
- }
29
- if (typeof window !== "undefined") {
30
- window.setAgentLogging = setAgentLogging;
31
- }
32
-
33
- // ../harness/providers/gateway.ts
34
- function usageFromChunk(u) {
35
- const cached = u.prompt_tokens_details?.cached_tokens;
36
- const out = { promptTokens: u.prompt_tokens || 0, completionTokens: u.completion_tokens || 0 };
37
- if (typeof cached === "number") out.cachedTokens = cached;
38
- return out;
39
- }
40
- async function streamCompletion(client, params, signal, onText) {
41
- alog("stream:create", { model: params.model, messages: params.messages.length, tools: params.tools?.length ?? 0 });
42
- let stream;
43
- try {
44
- stream = await client.chat.completions.create(
45
- {
46
- model: params.model,
47
- messages: params.messages,
48
- tools: params.tools,
49
- tool_choice: params.tool_choice,
50
- // Undefined is dropped by the SDK's JSON serialization, so an unset cap
51
- // leaves output unbounded (unchanged behavior).
52
- max_completion_tokens: params.max_completion_tokens,
53
- // Undefined is dropped by the SDK, so an unset temperature leaves the
54
- // provider default in place (unchanged behavior).
55
- temperature: params.temperature,
56
- stream: true,
57
- stream_options: { include_usage: true }
58
- },
59
- // The agent loop (loop.ts) is the SOLE retry authority — it owns the bounded
60
- // backoff retry for transient failures. Disable the OpenAI SDK's own retries
61
- // (default 2) so they don't MULTIPLY with the loop's: a gateway timeout was
62
- // being retried SDK(1+2) x loop(1+3) = ~12 times, turning one 300s timeout
63
- // into ~12 minutes of dead attempts.
64
- { signal, maxRetries: 0 }
65
- );
66
- } catch (e) {
67
- const err = e;
68
- const raw = err?.error;
69
- const detail = (typeof raw === "string" ? raw : raw?.error || raw?.message) || err?.message || String(e);
70
- const msg = typeof err?.status === "number" ? `Gateway ${err.status}: ${detail}` : detail;
71
- alog("stream:create-error", msg);
72
- throw new Error(msg);
73
- }
74
- alog("stream:open");
75
- let content = "";
76
- let finishReason = "stop";
77
- let chunkN = 0;
78
- const usage = { promptTokens: 0, completionTokens: 0 };
79
- const byIndex = /* @__PURE__ */ new Map();
80
- for await (const chunk of stream) {
81
- if (chunkN++ === 0) alog("stream:first-chunk");
82
- const choice = chunk.choices?.[0];
83
- if (choice?.delta?.content) {
84
- content += choice.delta.content;
85
- onText?.(choice.delta.content);
86
- }
87
- for (const tc of choice?.delta?.tool_calls ?? []) {
88
- const cur = byIndex.get(tc.index) ?? { id: "", name: "", arguments: "" };
89
- if (tc.id) cur.id = tc.id;
90
- if (tc.function?.name) cur.name = tc.function.name;
91
- if (tc.function?.arguments) cur.arguments += tc.function.arguments;
92
- byIndex.set(tc.index, cur);
93
- }
94
- if (choice?.finish_reason) finishReason = choice.finish_reason;
95
- if (chunk.usage) {
96
- const u = usageFromChunk(chunk.usage);
97
- usage.promptTokens = u.promptTokens;
98
- usage.completionTokens = u.completionTokens;
99
- if (u.cachedTokens != null) usage.cachedTokens = u.cachedTokens;
100
- }
101
- }
102
- const toolCalls = [...byIndex.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
103
- alog("stream:end", { chunks: chunkN, contentLen: content.length, toolCalls: toolCalls.length, finishReason, usage });
104
- return { content, toolCalls, usage, finishReason };
105
- }
106
-
107
- // ../harness/core/recovery.ts
108
- var DEFAULT_RECOVERY = {
109
- maxStreamRetries: 3,
110
- maxToolRetries: 2,
111
- baseDelayMs: 500,
112
- maxDelayMs: 8e3
113
- };
114
- function resolveRecovery(cfg) {
115
- return cfg ? { ...DEFAULT_RECOVERY, ...cfg } : DEFAULT_RECOVERY;
116
- }
117
- var TRANSIENT_STATUS = /* @__PURE__ */ new Set([408, 409, 425, 429, 500, 502, 503, 504]);
118
- var FATAL_STATUS = /* @__PURE__ */ new Set([400, 401, 403, 404, 405, 422]);
119
- function statusOf(message) {
120
- const m = message.match(/\b(?:gateway|http|status)\b[\s:]*?(\d{3})\b/i);
121
- return m ? Number(m[1]) : null;
122
- }
123
- function classifyStreamError(message, status) {
124
- if (/\baborted\b|AbortError/i.test(message)) return "fatal";
125
- if (/\b(timed out|request timeout|gateway timeout|deadline exceeded)\b/i.test(message)) return "fatal";
126
- const code = status ?? statusOf(message);
127
- if (code != null) {
128
- if (FATAL_STATUS.has(code)) return "fatal";
129
- if (TRANSIENT_STATUS.has(code)) return "transient";
130
- }
131
- if (/\b(invalid[\s_-]?api[\s_-]?key|incorrect api key|unauthorized|forbidden|quota exceeded|insufficient)\b/i.test(message)) return "fatal";
132
- return "transient";
133
- }
134
- function abortableSleep(ms, signal) {
135
- if (signal?.aborted) return Promise.resolve(false);
136
- return new Promise((resolve) => {
137
- const onAbort = () => {
138
- clearTimeout(t);
139
- resolve(false);
140
- };
141
- const t = setTimeout(() => {
142
- signal?.removeEventListener("abort", onAbort);
143
- resolve(true);
144
- }, ms);
145
- signal?.addEventListener("abort", onAbort, { once: true });
146
- });
147
- }
148
- function classifyToolError(message) {
149
- if (/\b(invalid|malformed|required|must be|expected|not valid json|missing|unrecognized|schema|parameter)\b/i.test(message)) return "bad-args";
150
- return "tool-failure";
151
- }
152
- function shouldRetryStream(kind, attempt, cfg) {
153
- return kind === "transient" && attempt <= cfg.maxStreamRetries;
154
- }
155
- function backoffDelay(cfg, attempt) {
156
- const exp = cfg.baseDelayMs * 2 ** Math.max(0, attempt - 1);
157
- return Math.min(cfg.maxDelayMs, exp);
158
- }
159
- function toolRecoveryHint(tool, kind, message, attempt, cfg) {
160
- const head = `Error from ${tool}: ${message}`;
161
- if (attempt > cfg.maxToolRetries) {
162
- return `${head}
163
- This tool has now failed ${attempt} times the same way. Do NOT call ${tool} again with these arguments \u2014 use a different tool, change your approach, or continue without it.`;
164
- }
165
- if (kind === "bad-args") {
166
- return `${head}
167
- The arguments appear invalid. Re-read ${tool}'s parameter schema and call it again with corrected arguments.`;
168
- }
169
- return `${head}
170
- This may be recoverable. Reconsider your inputs or try an alternative before calling ${tool} again.`;
171
- }
172
- var ToolFailureTracker = class {
173
- fails = /* @__PURE__ */ new Map();
174
- /** Record a failure; returns the running same-kind attempt count (1-based). A
175
- * different failure kind resets the counter for that tool. */
176
- fail(tool, kind) {
177
- const prev = this.fails.get(tool);
178
- const count = prev && prev.kind === kind ? prev.count + 1 : 1;
179
- this.fails.set(tool, { kind, count });
180
- return count;
181
- }
182
- /** Clear a tool's failure streak; returns the streak it cleared (kind + count)
183
- * so a recovery can be recorded with the RIGHT kind/attempt, or null if there
184
- * was none. */
185
- succeed(tool) {
186
- const prev = this.fails.get(tool);
187
- if (!prev) return null;
188
- this.fails.delete(tool);
189
- return prev;
190
- }
191
- };
192
-
193
- // ../harness/guardrail/glob.ts
194
- function globToRegex(glob) {
195
- let src = "";
196
- for (let i = 0; i < glob.length; i++) {
197
- const c = glob[i];
198
- if (c === "*") {
199
- if (glob[i + 1] === "*") {
200
- src += ".*";
201
- i++;
202
- } else {
203
- src += "[^.]*";
204
- }
205
- } else if (c === "?") {
206
- src += "[^.]";
207
- } else if (".+^$()[]{}|\\".includes(c)) {
208
- src += "\\" + c;
209
- } else {
210
- src += c;
211
- }
212
- }
213
- return new RegExp(`^${src}$`);
214
- }
215
- function matchesAnyGlob(name, globs) {
216
- return globs.some((g) => globToRegex(g).test(name));
217
- }
218
-
219
- // ../harness/guardrail/guardrail.ts
220
- function callMatches(call, globs) {
221
- if (!globs.length) return false;
222
- if (call.address && matchesAnyGlob(call.address, globs)) return true;
223
- return matchesAnyGlob(call.name, globs);
224
- }
225
- function initialState() {
226
- return { turn: 0, toolCalls: 0, calledTools: /* @__PURE__ */ new Set() };
227
- }
228
- function onViolation(policy) {
229
- return policy.onViolation ?? "stop";
230
- }
231
- function violate(kind, message, action, tool) {
232
- const violation = tool ? { kind, message, tool } : { kind, message };
233
- return { ok: false, violation, action };
234
- }
235
- function evaluateTurnStart(policy, state) {
236
- if (policy.maxTurns !== void 0 && state.turn >= policy.maxTurns) {
237
- return violate("MAX_TURNS", `Exceeded maxTurns=${policy.maxTurns}`, onViolation(policy));
238
- }
239
- return { ok: true };
240
- }
241
- function evaluateToolCall(policy, state, call) {
242
- if (!call.exists) {
243
- return violate("UNKNOWN_TOOL", `Model invoked unknown tool: ${call.name}`, "stop", call.name);
244
- }
245
- const allow = policy.tools?.allow ?? [];
246
- const deny = policy.tools?.deny ?? [];
247
- if (deny.length && callMatches(call, deny)) {
248
- return violate("TOOL_DENIED", `Tool denied by policy: ${call.name}`, onViolation(policy), call.name);
249
- }
250
- if (allow.length && !callMatches(call, allow)) {
251
- return violate("TOOL_NOT_ALLOWED", `Tool not in allow list: ${call.name}`, onViolation(policy), call.name);
252
- }
253
- if (policy.maxToolCalls !== void 0 && state.toolCalls + 1 > policy.maxToolCalls) {
254
- return violate("MAX_TOOL_CALLS", `Exceeded maxToolCalls=${policy.maxToolCalls}`, onViolation(policy), call.name);
255
- }
256
- return { ok: true };
257
- }
258
- function evaluateCompletion(policy, state) {
259
- const mustCall = policy.mustCall ?? [];
260
- const missing = mustCall.filter((name) => !matchesAnyGlob2(name, state.calledTools));
261
- if (!missing.length) return [];
262
- return [{ kind: "MUST_CALL_MISSING", message: `Required tools were not invoked: ${missing.join(", ")}` }];
263
- }
264
- function matchesAnyGlob2(globOrName, called) {
265
- for (const name of called) if (matchesAnyGlob(name, [globOrName])) return true;
266
- return false;
267
- }
268
-
269
- // ../harness/guards.ts
270
- var ALLOWED = { allowed: true };
271
- var guards = [];
272
- async function checkFeature(feature) {
273
- for (const g of [...guards]) {
274
- if (!g.appliesTo(feature)) continue;
275
- const v = await g.check(feature);
276
- if (!v.allowed) return v;
277
- }
278
- return ALLOWED;
279
- }
280
-
281
- // ../harness/guardrail/builtins.ts
282
- var STANDARD_POLICY = {
283
- version: 1,
284
- name: "standard",
285
- description: "Read-only tools run automatically; writes ask for approval.",
286
- onViolation: "stop",
287
- maxTurns: 8,
288
- permissions: {
289
- "prompd:propose_edit": "allow",
290
- // proposals are reviewed in the diff UI, not applied directly
291
- "system:*": "deny"
292
- // harness self-configuration is never automatic
293
- }
294
- // defaultPermission omitted -> the mutates heuristic (reads allow, writes ask)
295
- };
296
-
297
- // ../harness/tools/namespaces.ts
298
- var BUILTIN_NAMESPACES = {
299
- list_files: "fs",
300
- read_file: "fs",
301
- stat_file: "fs",
302
- search_files: "fs",
303
- write_file: "fs",
304
- create_file: "fs",
305
- delete_file: "fs",
306
- rename_file: "fs",
307
- compile_prompd: "prompd",
308
- propose_edit: "prompd",
309
- search_packages: "prompd",
310
- web_search: "web",
311
- save_memory: "memory",
312
- recall_memory: "memory",
313
- recall_result: "memory",
314
- kv_get: "memory",
315
- kv_set: "memory",
316
- search_sessions: "memory",
317
- ask_user: "agent",
318
- spawn_agent: "agent",
319
- spawn_agents: "agent",
320
- todo_write: "agent",
321
- // Named here as a backstop; the sidecar's own decls also carry `namespace`,
322
- // and an explicit field always wins (resolveNamespace). Both say the same
323
- // thing so a sidecar that predates this still lands in the right namespace.
324
- sidecar_fs_read_file: "sidecar",
325
- sidecar_fs_write_file: "sidecar",
326
- sidecar_fs_read_bytes: "sidecar",
327
- sidecar_fs_list_dir: "sidecar",
328
- sidecar_fs_delete: "sidecar",
329
- sidecar_shell_exec: "sidecar",
330
- // Host-side orchestration tools. They are not in allTools() - they reach the
331
- // agent as remote decls (workerFactory) - but they are still governed by the
332
- // policy, so they need a canonical address like everything else.
333
- create_strategy: "agent",
334
- edit_strategy: "agent",
335
- view_strategy: "agent",
336
- run_strategy: "agent",
337
- list_strategies: "agent",
338
- list_saved_strategies: "agent"
339
- };
340
- function resolveNamespace(tool) {
341
- if (tool.namespace) return tool.namespace;
342
- const known = BUILTIN_NAMESPACES[tool.name];
343
- if (known) return known;
344
- const i = tool.name.indexOf("__");
345
- if (i > 0) {
346
- const head = tool.name.slice(0, i);
347
- if (head === "mcp" || head === "skill") return head;
348
- }
349
- return "other";
350
- }
351
- function toolAddress(tool) {
352
- const ns = resolveNamespace(tool);
353
- let leaf = tool.name;
354
- if (ns === "mcp" || ns === "skill") {
355
- const m = new RegExp(`^${ns}__?`).exec(leaf);
356
- if (m) leaf = leaf.slice(m[0].length);
357
- }
358
- if (ns === "sidecar") {
359
- for (const p of ["sidecar_fs_", "sidecar_"]) {
360
- if (leaf.startsWith(p)) {
361
- leaf = leaf.slice(p.length);
362
- break;
363
- }
364
- }
365
- }
366
- return `${ns}:${leaf}`;
367
- }
368
-
369
- // ../harness/compression.ts
370
- var BASE = { off: Infinity, conservative: 24e3, balanced: 12e3, aggressive: 6e3 };
371
- var FLOOR = { off: Infinity, conservative: 8e3, balanced: 4e3, aggressive: 2e3 };
372
- function effectiveThreshold(mode, fill = 0) {
373
- if (mode === "off") return Infinity;
374
- const factor = fill < 0.5 ? 1 : fill < 0.7 ? 0.7 : fill < 0.85 ? 0.5 : 0.35;
375
- return Math.max(FLOOR[mode], Math.round(BASE[mode] * factor));
376
- }
377
- function formatOffloadRef(ref) {
378
- const head = `[offloaded tool result \xB7 ${ref.toolName} \xB7 ${ref.bytes.toLocaleString()} bytes \xB7 recall the full content with recall_result({ id: "${ref.id}" })]`;
379
- return ref.preview ? `${head}
380
- --- preview ---
381
- ${ref.preview}${ref.bytes > ref.preview.length ? "\n\u2026(truncated)" : ""}` : head;
382
- }
383
-
384
- // ../harness/prompts/toolsMarker.ts
385
- var TOOLS_MARKER = "\uE000PROMPD_TOOLS\uE000";
386
- function formatToolLines(tools) {
387
- return tools.map((t) => `- \`${t.name}\`: ${t.description}`).join("\n");
388
- }
389
- function fillToolsMarker(system, tools) {
390
- if (!system.includes(TOOLS_MARKER)) return system;
391
- return system.replaceAll(TOOLS_MARKER, tools.length ? formatToolLines(tools) : "(no tools available this run)");
392
- }
393
-
394
- // ../harness/core/loop.ts
395
- async function runAgent(args) {
396
- const { client, system, tools, toolCtx } = args;
397
- const policy = args.policy ?? STANDARD_POLICY;
398
- const permission = args.permission;
399
- const compression = args.compression;
400
- const ctxWindow = compression?.contextWindow;
401
- let fill = 0;
402
- const maxTurns = policy.maxTurns ?? args.maxTurns ?? 8;
403
- const signal = args.signal ?? new AbortController().signal;
404
- const emit = (e) => args.onEvent?.(e);
405
- const gstate = initialState();
406
- const recovery = resolveRecovery(args.recovery);
407
- const toolFails = new ToolFailureTracker();
408
- const messages = [...args.messages];
409
- const filledSystem = system ? fillToolsMarker(system, tools) : system;
410
- if (filledSystem && !messages.some((m) => m.role === "system")) {
411
- messages.unshift({ role: "system", content: filledSystem });
412
- }
413
- const toolDefs = tools.map((t) => ({
414
- type: "function",
415
- function: { name: t.name, description: t.description, parameters: t.parameters }
416
- }));
417
- const toolByName = new Map(tools.map((t) => [t.name, t]));
418
- const usage = { promptTokens: 0, completionTokens: 0 };
419
- let text = "";
420
- let stopReason = "stop";
421
- let lastToolSig = null;
422
- let lastStreamErr = "";
423
- for (let turn = 1; turn <= maxTurns; turn++) {
424
- if (signal.aborted) {
425
- stopReason = "aborted";
426
- break;
427
- }
428
- emit({ type: "turn", turn });
429
- gstate.turn = turn - 1;
430
- const turnGate = evaluateTurnStart(policy, gstate);
431
- if (!turnGate.ok) {
432
- emit({ type: "policy-violation", kind: turnGate.violation.kind, message: turnGate.violation.message, action: turnGate.action, tool: turnGate.violation.tool });
433
- if (turnGate.action === "stop") {
434
- stopReason = "policy";
435
- break;
436
- }
437
- }
438
- let streamed;
439
- let streamFail = null;
440
- const model = args.getModel?.() ?? args.model;
441
- for (let sAttempt = 0; ; ) {
442
- try {
443
- const result = await streamCompletion(
444
- client,
445
- {
446
- model,
447
- messages,
448
- tools: toolDefs.length ? toolDefs : void 0,
449
- tool_choice: toolDefs.length ? "auto" : void 0,
450
- max_completion_tokens: args.maxCompletionTokens,
451
- temperature: args.temperature
452
- },
453
- signal,
454
- (delta) => emit({ type: "assistant-delta", delta })
455
- );
456
- if (sAttempt > 0) emit({ type: "heal", record: { site: "stream", kind: "transient", strategy: "retry-backoff", attempt: sAttempt, outcome: "recovered", error: lastStreamErr } });
457
- streamed = result;
458
- break;
459
- } catch (e) {
460
- if (signal.aborted || e?.name === "AbortError") {
461
- streamFail = "aborted";
462
- break;
463
- }
464
- const msg = String(e?.message || e);
465
- lastStreamErr = msg;
466
- const kind = classifyStreamError(msg);
467
- sAttempt++;
468
- if (shouldRetryStream(kind, sAttempt, recovery)) {
469
- emit({ type: "heal", record: { site: "stream", kind, strategy: "retry-backoff", attempt: sAttempt, outcome: "retrying", error: msg } });
470
- if (!await abortableSleep(backoffDelay(recovery, sAttempt), signal)) {
471
- emit({ type: "heal", record: { site: "stream", kind, strategy: "give-up", attempt: sAttempt, outcome: "abandoned", error: msg } });
472
- streamFail = "aborted";
473
- break;
474
- }
475
- continue;
476
- }
477
- emit({ type: "heal", record: { site: "stream", kind, strategy: "give-up", attempt: sAttempt, outcome: kind === "fatal" ? "abandoned" : "exhausted", error: msg } });
478
- emit({ type: "error", message: msg });
479
- streamFail = "error";
480
- break;
481
- }
482
- }
483
- if (streamFail) {
484
- stopReason = streamFail;
485
- break;
486
- }
487
- if (!streamed) {
488
- stopReason = "error";
489
- break;
490
- }
491
- usage.promptTokens += streamed.usage.promptTokens;
492
- usage.completionTokens += streamed.usage.completionTokens;
493
- if (ctxWindow && ctxWindow > 0 && streamed.usage.promptTokens) {
494
- fill = Math.min(1, streamed.usage.promptTokens / ctxWindow);
495
- }
496
- emit({ type: "usage", usage: streamed.usage });
497
- const assistantMsg = streamed.toolCalls.length ? {
498
- role: "assistant",
499
- content: streamed.content || null,
500
- tool_calls: streamed.toolCalls.map((t) => ({
501
- id: t.id,
502
- type: "function",
503
- function: { name: t.name, arguments: t.arguments }
504
- }))
505
- } : { role: "assistant", content: streamed.content };
506
- messages.push(assistantMsg);
507
- if (streamed.content) {
508
- text = streamed.content;
509
- emit({ type: "assistant", content: streamed.content });
510
- }
511
- if (streamed.toolCalls.length === 0) {
512
- stopReason = streamed.finishReason || "stop";
513
- break;
514
- }
515
- const sig = JSON.stringify(streamed.toolCalls.map((t) => ({ n: t.name, a: t.arguments })));
516
- if (sig === lastToolSig) {
517
- emit({ type: "error", message: "Stopped: the model repeated the same tool call without making progress." });
518
- stopReason = "stuck";
519
- break;
520
- }
521
- lastToolSig = sig;
522
- let policyStop = false;
523
- for (const call of streamed.toolCalls) {
524
- let parsed = {};
525
- try {
526
- parsed = call.arguments ? JSON.parse(call.arguments) : {};
527
- } catch {
528
- messages.push({ role: "tool", tool_call_id: call.id, content: `Error: arguments were not valid JSON: ${call.arguments}` });
529
- emit({ type: "tool-result", id: call.id, name: call.name, result: "invalid JSON arguments", isError: true });
530
- continue;
531
- }
532
- alog("tool", call.name, parsed);
533
- emit({ type: "tool-call", id: call.id, name: call.name, args: parsed });
534
- const tool = toolByName.get(call.name);
535
- if (!tool) {
536
- messages.push({ role: "tool", tool_call_id: call.id, content: `Error: unknown tool "${call.name}"` });
537
- emit({ type: "tool-result", id: call.id, name: call.name, result: "unknown tool", isError: true });
538
- continue;
539
- }
540
- const address = toolAddress(tool);
541
- const gate = evaluateToolCall(policy, gstate, { name: call.name, exists: true, address });
542
- if (!gate.ok) {
543
- const msg = `Blocked by policy "${policy.name}": ${gate.violation.message}`;
544
- messages.push({ role: "tool", tool_call_id: call.id, content: `Error: ${msg}` });
545
- emit({ type: "policy-violation", kind: gate.violation.kind, message: gate.violation.message, action: gate.action, tool: call.name });
546
- emit({ type: "tool-result", id: call.id, name: call.name, result: msg, isError: true });
547
- if (gate.action === "stop") {
548
- policyStop = true;
549
- break;
550
- }
551
- continue;
552
- }
553
- if (tool.requiresFeature) {
554
- const verdict = await checkFeature(tool.requiresFeature);
555
- if (!verdict.allowed) {
556
- const msg = `Feature unavailable (${tool.requiresFeature}): ${verdict.reason}`;
557
- messages.push({ role: "tool", tool_call_id: call.id, content: `Error: ${msg}` });
558
- emit({ type: "tool-result", id: call.id, name: tool.name, result: msg, isError: true });
559
- continue;
560
- }
561
- }
562
- if (permission) {
563
- const decision = await permission.check({ name: tool.name, address, mutates: tool.mutates, permission: tool.permission }, parsed);
564
- emit({ type: "permission", tool: tool.name, decision });
565
- if (decision === "deny") {
566
- messages.push({ role: "tool", tool_call_id: call.id, content: "Permission denied by the user \u2014 the action was not performed." });
567
- emit({ type: "tool-result", id: call.id, name: tool.name, result: "permission denied", isError: true });
568
- continue;
569
- }
570
- }
571
- const startedAt = Date.now();
572
- try {
573
- const result = await tool.run(parsed, { ...toolCtx, callId: call.id });
574
- const durationMs = Date.now() - startedAt;
575
- gstate.toolCalls++;
576
- gstate.calledTools.add(tool.name);
577
- gstate.calledTools.add(address);
578
- const recovered = toolFails.succeed(tool.name);
579
- if (recovered) {
580
- emit({ type: "heal", record: { site: "tool", tool: tool.name, kind: recovered.kind, strategy: "steer", attempt: recovered.count, outcome: "recovered", error: "" } });
581
- }
582
- let out = result;
583
- if (compression && compression.mode !== "off" && !tool.noOffload && toolCtx.recall) {
584
- if (result.length > effectiveThreshold(compression.mode, fill)) {
585
- try {
586
- const ref = await toolCtx.recall.record(tool.name, result);
587
- out = formatOffloadRef(ref);
588
- emit({ type: "offload", id: ref.id, name: tool.name, bytes: ref.bytes });
589
- } catch {
590
- out = result;
591
- }
592
- }
593
- }
594
- messages.push({ role: "tool", tool_call_id: call.id, content: out });
595
- emit({ type: "tool-result", id: call.id, name: call.name, result: out, durationMs });
596
- } catch (e) {
597
- const errMsg = String(e?.message || e);
598
- const kind = classifyToolError(errMsg);
599
- const attempt = toolFails.fail(tool.name, kind);
600
- const exhausted = attempt > recovery.maxToolRetries;
601
- emit({ type: "heal", record: { site: "tool", tool: tool.name, kind, strategy: exhausted ? "give-up" : "steer", attempt, outcome: exhausted ? "exhausted" : "retrying", error: errMsg } });
602
- messages.push({ role: "tool", tool_call_id: call.id, content: toolRecoveryHint(tool.name, kind, errMsg, attempt, recovery) });
603
- emit({ type: "tool-result", id: call.id, name: call.name, result: errMsg, isError: true, durationMs: Date.now() - startedAt });
604
- }
605
- }
606
- if (policyStop) {
607
- stopReason = "policy";
608
- break;
609
- }
610
- if (turn === maxTurns) stopReason = "max_turns";
611
- }
612
- reconcileDanglingToolCalls(messages);
613
- for (const v of evaluateCompletion(policy, gstate)) {
614
- emit({ type: "policy-violation", kind: v.kind, message: v.message, action: "stop", tool: v.tool });
615
- }
616
- alog("done", stopReason, "tokens", usage);
617
- emit({ type: "done", reason: stopReason });
618
- return { messages, text, usage, stopReason };
619
- }
620
- function reconcileDanglingToolCalls(messages) {
621
- let idx = -1;
622
- for (let i = messages.length - 1; i >= 0; i--) {
623
- const m = messages[i];
624
- if (m.role === "assistant" && m.tool_calls && m.tool_calls.length) {
625
- idx = i;
626
- break;
627
- }
628
- }
629
- if (idx < 0) return;
630
- const asst = messages[idx];
631
- if (asst.role !== "assistant" || !asst.tool_calls) return;
632
- const answered = /* @__PURE__ */ new Set();
633
- for (let i = idx + 1; i < messages.length; i++) {
634
- const m = messages[i];
635
- if (m.role === "tool") answered.add(m.tool_call_id);
636
- }
637
- for (const c of asst.tool_calls) {
638
- if (!answered.has(c.id)) {
639
- messages.push({ role: "tool", tool_call_id: c.id, content: "Tool not run \u2014 the run stopped before this call." });
640
- }
641
- }
642
- }
643
-
644
- // src/markdown.ts
645
- var FENCE = /^```(\w*)\s*$/;
646
- var HEADING = /^(#{1,6})\s+(.*)$/;
647
- var BULLET = /^\s*[-*]\s+(.*)$/;
648
- var NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
649
- function parseMarkdown(text) {
650
- return parseMarkdownSpans(text).map((s) => s.block);
651
- }
652
- function parseMarkdownSpans(text) {
653
- const out = [];
654
- const lines = text.split(/\r?\n/);
655
- let fence = null;
656
- for (const line of lines) {
657
- const f = FENCE.exec(line);
658
- if (fence) {
659
- if (f) {
660
- fence.raw.push(line);
661
- out.push({ block: { kind: "code", lang: fence.lang, text: fence.body.join("\n") }, raw: fence.raw.join("\n") });
662
- fence = null;
663
- } else {
664
- fence.body.push(line);
665
- fence.raw.push(line);
666
- }
667
- continue;
668
- }
669
- if (f) {
670
- fence = { lang: f[1] ?? "", body: [], raw: [line] };
671
- continue;
672
- }
673
- if (!line.trim()) {
674
- out.push({ block: { kind: "blank", text: "" }, raw: line });
675
- continue;
676
- }
677
- const h = HEADING.exec(line);
678
- if (h) {
679
- out.push({ block: { kind: "h", level: h[1].length, text: stripInline(h[2]) }, raw: line });
680
- continue;
681
- }
682
- const li = BULLET.exec(line) ?? NUMBERED.exec(line);
683
- if (li) {
684
- out.push({ block: { kind: "li", text: stripInline(li[1]) }, raw: line });
685
- continue;
686
- }
687
- out.push({ block: { kind: "p", text: stripInline(line) }, raw: line });
688
- }
689
- if (fence) out.push({ block: { kind: "code", lang: fence.lang, text: fence.body.join("\n") }, raw: fence.raw.join("\n") });
690
- return out;
691
- }
692
- function stripInline(s) {
693
- return s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/\*\*(\S(?:[^*]*\S)?)\*\*/g, "$1").replace(/(?<!\*)\*(\S(?:[^*]*\S)?)\*(?!\*)/g, "$1").replace(/`([^`]+)`/g, "$1");
694
- }
695
-
696
- // src/session.ts
697
- var HELP_TEXT = [
698
- "Type a request and press enter.",
699
- "",
700
- " /tools what the agent can do, and what it can change",
701
- " /expand #3 re-print a tool result in full",
702
- " /resume [id] reopen an earlier conversation",
703
- " /clear forget the conversation",
704
- " /exit leave",
705
- "",
706
- /* Deliberately not 'esc stops a turn': this text is shared, and Escape is
707
- * an Ink-only key -- the readline fallback has no key handler at all, so
708
- * there Ctrl-C is the only way to stop a turn. Name the one that is true
709
- * on both faces, and mark the other as what it is. */
710
- "Ctrl-C stops a turn; twice leaves. Ctrl-D leaves. In the full terminal face, esc also stops a turn."
711
- ].join("\n");
712
- var DELTA_FLUSH_MS = 50;
713
- var TermSession = class {
714
- constructor(deps) {
715
- this.deps = deps;
716
- }
717
- state = emptyState();
718
- listeners = /* @__PURE__ */ new Set();
719
- nextOrd = 1;
720
- idSeq = 0;
721
- pendingDelta = "";
722
- flushTimer = null;
723
- /* The full text streamed so far THIS ROUND (reset at beginTurn and again at
724
- * every 'assistant' event, since a round of tool calls can be followed by
725
- * another round of assistant text). Re-parsed into markdown blocks on every
726
- * flush so all-but-the-last block can be committed as soon as it can no
727
- * longer change -- see the module comment and flushDeltas(). */
728
- streamFull = "";
729
- /** How many leading blocks of `streamFull` have already been committed.
730
- * Only the block at this index (the last one seen so far) is still live. */
731
- streamCommittedBlocks = 0;
732
- /* An offload is emitted just BEFORE the tool-result it belongs to (see
733
- * @corenel/harness core/loop.ts: recall.record() then emit('offload'), then
734
- * emit('tool-result')), and it carries the BLOB id, not the call id. So it is
735
- * held here and claimed by the next result rather than looked up by id. */
736
- pendingOffload = null;
737
- /** The model's truth, as opposed to `committed`, which is display. */
738
- history = [];
739
- turnAc = null;
740
- /** Monotonic turn id. Events stamped with an older one are dropped: a late
741
- * delta from an aborted turn must never paint into the next one. */
742
- turnId = 0;
743
- activeTurn = 0;
744
- pending = null;
745
- draining = false;
746
- /* Bumped whenever the conversation is REPLACED out from under a turn --
747
- * `/clear` and `resume()`. A turn captures it when it starts and only adopts
748
- * the messages it accumulated if the stamp still matches, because runAgent
749
- * copies `messages` at turn start: without this, a `/clear` typed mid-turn
750
- * was undone the moment the turn landed (and then written to disk), so the
751
- * screen said forgotten while the next request still carried everything. */
752
- historyGen = 0;
753
- /* True from the moment a turn starts until the queue behind it has been
754
- * drained. `status` flips to 'idle' inside runAgent's 'done' event, but
755
- * runTurn then awaits persist() -- a DISK WRITE -- before it reaches drain().
756
- * A line submitted in that window used to find an idle controller with a
757
- * non-empty queue and start a turn immediately: the queue lost its order,
758
- * and two turns could overlap, leaving a user message with no reply. */
759
- settling = false;
760
- /** The approval gate. Supplied by whichever face can show a prompt (readline's
761
- * `ask`, the Ink modal, ...) — the controller has no rendering of its own, so
762
- * it cannot build one itself. Unattached, `runTurn` simply omits `permission`
763
- * from the runner args, and the loop runs every tool ungated. */
764
- permission;
765
- wantsExit = false;
766
- attachPermission(p) {
767
- this.permission = p;
768
- }
769
- historyForTest() {
770
- return this.history;
771
- }
772
- getSnapshot() {
773
- return this.state;
774
- }
775
- subscribe(fn) {
776
- this.listeners.add(fn);
777
- return () => {
778
- this.listeners.delete(fn);
779
- };
780
- }
781
- /** Replace state and notify. Every mutation goes through here so `seq` and the
782
- * snapshot identity can never drift from the data. */
783
- set(patch) {
784
- this.state = { ...this.state, ...patch, seq: this.state.seq + 1 };
785
- for (const fn of this.listeners) fn();
786
- }
787
- id(prefix) {
788
- return `${prefix}${++this.idSeq}`;
789
- }
790
- commit(item) {
791
- this.set({ committed: [...this.state.committed, item] });
792
- }
793
- note(level, text, detail) {
794
- this.commit({ kind: "note", id: this.id("n"), level, text, ...detail ? { detail } : {} });
795
- }
796
- /** Open a live turn. Public-for-test until Task 4 gives it a real caller. */
797
- beginTurnForTest() {
798
- this.beginTurn();
799
- }
800
- flushDeltasForTest() {
801
- this.flushDeltas();
802
- }
803
- beginTurn() {
804
- this.streamFull = "";
805
- this.streamCommittedBlocks = 0;
806
- this.set({
807
- live: { streaming: "", tools: [], turn: (this.state.live?.turn ?? 0) + 1 },
808
- status: "running",
809
- error: null,
810
- turnUsage: { promptTokens: 0, completionTokens: 0 }
811
- });
812
- }
813
- /** Commit every markdown block in `streamFull` that can no longer change --
814
- * i.e. every block except the last, which more text can still extend --
815
- * and return the still-live trailing block's raw text. This is what keeps
816
- * the live region Ink redraws every frame bounded by one block instead of
817
- * the whole reply: see the module comment for why an unbounded live region
818
- * corrupts the frame once it scrolls out of the viewport.
819
- *
820
- * KNOWN LIMIT: any single block taller than the terminal -- not just a
821
- * fenced code block, but equally one long unbroken paragraph, or any other
822
- * span with no internal blank-line break -- stays "the trailing block", and
823
- * so stays live and unbounded, until it closes (or the blank line that ends
824
- * it arrives). That bounds the live region by one block rather than by the
825
- * viewport, which the headless controller has no way to know. It is still
826
- * a large improvement over the whole reply staying live, and a single block
827
- * taller than the terminal is rare. */
828
- commitCompletedBlocks() {
829
- const spans = parseMarkdownSpans(this.streamFull);
830
- if (spans.length === 0) return "";
831
- while (this.streamCommittedBlocks < spans.length - 1) {
832
- const span = spans[this.streamCommittedBlocks];
833
- this.commit({ kind: "assistant", id: this.id("a"), text: span.raw });
834
- this.streamCommittedBlocks++;
835
- }
836
- return spans[spans.length - 1].raw;
837
- }
838
- flushDeltas() {
839
- if (this.flushTimer) {
840
- clearTimeout(this.flushTimer);
841
- this.flushTimer = null;
842
- }
843
- if (!this.pendingDelta) return;
844
- const chunk = this.pendingDelta;
845
- this.pendingDelta = "";
846
- const live = this.state.live;
847
- if (!live) return;
848
- this.streamFull += chunk;
849
- const trailing = this.commitCompletedBlocks();
850
- this.set({ live: { ...this.state.live, streaming: trailing } });
851
- }
852
- scheduleFlush() {
853
- if (this.flushTimer) return;
854
- this.flushTimer = setTimeout(() => {
855
- this.flushTimer = null;
856
- this.flushDeltas();
857
- }, DELTA_FLUSH_MS);
858
- this.flushTimer.unref?.();
859
- }
860
- /** Commit whatever the live region still holds -- the trailing block that
861
- * flushDeltas kept live, since every block before it is already committed
862
- * -- and close the turn. */
863
- endTurn() {
864
- this.flushDeltas();
865
- const live = this.state.live;
866
- if (live?.streaming.trim()) {
867
- this.commit({ kind: "assistant", id: this.id("a"), text: live.streaming });
868
- }
869
- const stopped = this.state.status === "aborting";
870
- this.set({ live: null, status: "idle" });
871
- this.streamFull = "";
872
- this.streamCommittedBlocks = 0;
873
- if (stopped) this.note("info", "(stopped)");
874
- }
875
- handleEvent(e) {
876
- this.deps.onEvent?.(e);
877
- switch (e.type) {
878
- case "turn": {
879
- const live = this.state.live;
880
- if (live) this.set({ live: { ...live, turn: e.turn } });
881
- break;
882
- }
883
- case "assistant-delta":
884
- this.pendingDelta += e.delta;
885
- this.scheduleFlush();
886
- break;
887
- case "assistant": {
888
- this.flushDeltas();
889
- const live = this.state.live;
890
- const trailing = live?.streaming ?? "";
891
- if (trailing.trim()) {
892
- this.commit({ kind: "assistant", id: this.id("a"), text: trailing });
893
- } else if (this.streamCommittedBlocks === 0 && e.content.trim()) {
894
- this.commit({ kind: "assistant", id: this.id("a"), text: e.content });
895
- }
896
- if (live) this.set({ live: { ...this.state.live, streaming: "" } });
897
- this.streamFull = "";
898
- this.streamCommittedBlocks = 0;
899
- break;
900
- }
901
- case "tool-call": {
902
- this.flushDeltas();
903
- const live = this.state.live;
904
- if (!live) break;
905
- const tool = {
906
- callId: e.id,
907
- ord: this.nextOrd++,
908
- name: e.name,
909
- subject: describeToolCall(e.name, e.args)
910
- };
911
- this.set({ live: { ...live, tools: [...live.tools, tool] } });
912
- break;
913
- }
914
- case "tool-result": {
915
- this.flushDeltas();
916
- const live = this.state.live;
917
- const pending = live?.tools.find((t) => t.callId === e.id);
918
- const isError = e.isError === true;
919
- const offload = this.pendingOffload;
920
- this.pendingOffload = null;
921
- const item = {
922
- kind: "tool",
923
- id: this.id("t"),
924
- callId: e.id,
925
- ord: pending?.ord ?? this.nextOrd++,
926
- name: e.name,
927
- subject: pending?.subject ?? "",
928
- preview: capPreview(e.result, isError),
929
- isError,
930
- ...e.durationMs !== void 0 ? { durationMs: e.durationMs } : {},
931
- ...offload ? { offloadedBytes: offload.bytes, offloadRefId: offload.refId } : {}
932
- };
933
- if (live) {
934
- this.state = {
935
- ...this.state,
936
- live: { ...live, tools: live.tools.filter((t) => t.callId !== e.id) }
937
- };
938
- }
939
- this.commit(item);
940
- break;
941
- }
942
- case "offload":
943
- this.pendingOffload = { refId: e.id, bytes: e.bytes };
944
- break;
945
- case "permission":
946
- case "permission-audit":
947
- break;
948
- case "policy-violation":
949
- this.note(
950
- e.action === "stop" ? "error" : "warn",
951
- e.action === "stop" ? `blocked ${e.message}` : `warning ${e.message}`,
952
- e.tool
953
- );
954
- break;
955
- case "heal":
956
- if (e.record.outcome === "abandoned" || e.record.outcome === "exhausted") {
957
- this.note("warn", `recovery gave up ${e.record.error ?? e.record.strategy}`);
958
- }
959
- break;
960
- case "usage":
961
- this.set({
962
- usage: {
963
- promptTokens: this.state.usage.promptTokens + e.usage.promptTokens,
964
- completionTokens: this.state.usage.completionTokens + e.usage.completionTokens
965
- },
966
- turnUsage: {
967
- promptTokens: this.state.turnUsage.promptTokens + e.usage.promptTokens,
968
- completionTokens: this.state.turnUsage.completionTokens + e.usage.completionTokens
969
- }
970
- });
971
- break;
972
- case "artifact":
973
- this.note("info", `artifact ${e.kind}${e.title ? ` ${e.title}` : ""}`);
974
- break;
975
- case "budget-stop":
976
- case "budget-warn":
977
- this.note("warn", `budget ${e.type === "budget-stop" ? "stopped" : "over"}`);
978
- break;
979
- case "error":
980
- this.flushDeltas();
981
- this.note("error", e.message);
982
- this.set({ error: e.message });
983
- break;
984
- case "done":
985
- this.endTurn();
986
- break;
987
- default:
988
- break;
989
- }
990
- }
991
- /** Route one line: a command runs here; anything else is a turn. */
992
- async submit(text) {
993
- const line = text.trim();
994
- if (!line) return;
995
- const cmd = parseCommand(line);
996
- if (cmd) {
997
- await this.runCommand(cmd);
998
- return;
999
- }
1000
- if (this.state.status !== "idle" || this.settling) {
1001
- this.set({ queued: [...this.state.queued, line] });
1002
- return;
1003
- }
1004
- await this.runTurn(line);
1005
- }
1006
- async runCommand(cmd) {
1007
- switch (cmd.cmd) {
1008
- case "exit":
1009
- this.wantsExit = true;
1010
- return;
1011
- case "clear":
1012
- this.history = [];
1013
- this.historyGen++;
1014
- this.set({
1015
- committed: [],
1016
- usage: { promptTokens: 0, completionTokens: 0 },
1017
- turnUsage: { promptTokens: 0, completionTokens: 0 }
1018
- });
1019
- return;
1020
- case "tools":
1021
- this.note("info", formatTools(this.deps.tools));
1022
- return;
1023
- case "help":
1024
- this.note("info", HELP_TEXT);
1025
- return;
1026
- case "expand": {
1027
- if (cmd.ord === void 0) {
1028
- this.note("warn", "usage: /expand #3");
1029
- return;
1030
- }
1031
- await this.expand(cmd.ord);
1032
- return;
1033
- }
1034
- case "resume":
1035
- await this.resume(cmd.id);
1036
- return;
1037
- }
1038
- }
1039
- /** Save after the turn, never during: a half-written turn is a lie on disk. */
1040
- async persist() {
1041
- const p = this.deps.persistence;
1042
- if (!p) return;
1043
- try {
1044
- const title = this.state.session?.title ?? titleFrom(this.history);
1045
- const { id } = await p.save({ id: this.state.session?.id ?? null, title, messages: this.history });
1046
- this.set({ session: { id, title } });
1047
- } catch (e) {
1048
- this.note("warn", `could not save this conversation: ${e instanceof Error ? e.message : String(e)}`);
1049
- }
1050
- }
1051
- /** Reopen a conversation: `id` if given, otherwise the most recent. */
1052
- async resume(id) {
1053
- const p = this.deps.persistence;
1054
- if (!p) {
1055
- this.note("warn", "this session is not being saved, so there is nothing to resume");
1056
- return;
1057
- }
1058
- const loaded = await p.load(id);
1059
- if (!loaded) {
1060
- this.note("warn", id ? `no conversation ${id}` : "no earlier conversation to resume");
1061
- return;
1062
- }
1063
- this.history = loaded.messages;
1064
- this.historyGen++;
1065
- this.set({
1066
- committed: committedFromMessages(loaded.messages),
1067
- transcriptGen: this.state.transcriptGen + 1,
1068
- session: { id: loaded.id, title: loaded.title }
1069
- });
1070
- }
1071
- /** Re-print a committed tool row in full, as a NEW block at the bottom.
1072
- * Inline rendering owns this trade: the original row's bytes belong to the
1073
- * terminal's scrollback and cannot be rewritten in place. */
1074
- async expand(ord) {
1075
- const row = this.state.committed.find((i) => i.kind === "tool" && i.ord === ord);
1076
- if (!row || row.kind !== "tool") {
1077
- this.note("warn", `no tool result #${ord} in this conversation`);
1078
- return;
1079
- }
1080
- const full = await this.fullResult(row.id, row);
1081
- this.note("info", `#${row.ord} ${row.name} ${row.subject}`, full);
1082
- }
1083
- /** The full body of a committed row. `committed` only ever keeps the capped
1084
- * `preview`, and a second copy would be redundant AND wrong: `history`
1085
- * already carries the exact string the model saw for this call (the full
1086
- * body when it wasn't offloaded, the short offload ref when it was — see
1087
- * @corenel/harness core/loop.ts's `messages.push({ role: 'tool', ... })`),
1088
- * and `history` is cleared by `/clear`, so a stale row can't outlive the
1089
- * conversation it belongs to the way a side map would. Task 8 adds the (a)
1090
- * branch below: reading an offloaded body back out of the blob store by
1091
- * `row.offloadRefId` — the seam, not the wiring: the CLI does not pass
1092
- * `deps.recall` (context compression in node is a separate feature), so
1093
- * this branch is currently always skipped in practice. */
1094
- async fullResult(_id, row) {
1095
- if (row.offloadedBytes !== void 0 && this.deps.recall) {
1096
- const full = await this.deps.recall(row.offloadRefId ?? row.id);
1097
- if (full) return full;
1098
- }
1099
- const msg = this.history.find(
1100
- (m) => m.role === "tool" && m.tool_call_id === row.callId
1101
- );
1102
- if (msg && typeof msg.content === "string") return msg.content;
1103
- return row.preview.join("\n");
1104
- }
1105
- async runTurn(line) {
1106
- this.settling = true;
1107
- this.commit({ kind: "user", id: this.id("u"), text: line });
1108
- this.history.push({ role: "user", content: line });
1109
- const gen = this.historyGen;
1110
- const ac = new AbortController();
1111
- this.turnAc = ac;
1112
- const mine = ++this.turnId;
1113
- this.activeTurn = mine;
1114
- this.beginTurn();
1115
- const run = this.deps.runner ?? runAgent;
1116
- try {
1117
- const result = await run({
1118
- client: this.deps.client,
1119
- model: this.deps.model,
1120
- ...this.deps.system ? { system: this.deps.system } : {},
1121
- messages: this.history,
1122
- tools: this.deps.tools,
1123
- toolCtx: this.deps.toolCtx(ac.signal, (q) => this.askUser(q)),
1124
- signal: ac.signal,
1125
- ...this.permission ? { permission: this.permission } : {},
1126
- onEvent: (e) => {
1127
- if (this.activeTurn === mine) this.handleEvent(e);
1128
- }
1129
- });
1130
- if (this.activeTurn !== mine) return;
1131
- if (ac.signal.aborted || result.stopReason === "aborted") {
1132
- await this.finishAbandoned(gen);
1133
- return;
1134
- }
1135
- if (this.historyGen === gen) this.history = result.messages;
1136
- this.endTurn();
1137
- await this.persist();
1138
- if (this.activeTurn === mine) this.activeTurn = 0;
1139
- } catch (e) {
1140
- if (this.activeTurn === mine) {
1141
- if (!ac.signal.aborted) this.note("error", e instanceof Error ? e.message : String(e));
1142
- await this.finishAbandoned(gen);
1143
- }
1144
- } finally {
1145
- if (this.turnAc === ac) this.turnAc = null;
1146
- this.forceClosePending();
1147
- await this.drain();
1148
- if (!this.draining) this.settling = false;
1149
- }
1150
- }
1151
- /** Close out a turn that produced nothing usable -- aborted, or failed.
1152
- * Drops the abandoned user message: a user message with no reply, or a tool
1153
- * call with no result, poisons the next request. Exactly once, and never when
1154
- * the conversation was replaced under it (`/clear`, `/resume`), where the
1155
- * message it would drop belongs to someone else's history. */
1156
- async finishAbandoned(gen) {
1157
- if (this.historyGen === gen) this.history = this.history.slice(0, -1);
1158
- this.endTurn();
1159
- await this.persist();
1160
- this.activeTurn = 0;
1161
- }
1162
- async drain() {
1163
- if (this.draining) return;
1164
- this.draining = true;
1165
- try {
1166
- for (; ; ) {
1167
- const [next, ...rest] = this.state.queued;
1168
- if (next === void 0) break;
1169
- this.set({ queued: rest });
1170
- await this.runTurn(next);
1171
- }
1172
- } finally {
1173
- this.draining = false;
1174
- }
1175
- }
1176
- /** Stop the TURN, never the process. A long tool call you regret is the common
1177
- * case, and killing the shell to escape it loses the conversation. */
1178
- abort() {
1179
- if (this.state.status === "idle" && !this.pending) return;
1180
- this.set({ status: "aborting" });
1181
- this.forceClosePending();
1182
- this.turnAc?.abort();
1183
- }
1184
- askPermission(req) {
1185
- if (this.deps.approveAll) return Promise.resolve("allow");
1186
- if (this.pending) throw new Error("a question is already open");
1187
- const detail = req.input;
1188
- const shown = typeof detail?.command === "string" ? detail.command : typeof detail?.path === "string" ? detail.path : "";
1189
- this.set({ modal: { kind: "permission", tool: req.tool, detail: shown } });
1190
- return new Promise((resolve) => {
1191
- this.pending = { resolve: (v) => resolve(v ?? "deny") };
1192
- });
1193
- }
1194
- askUser(questions) {
1195
- if (this.pending) throw new Error("a question is already open");
1196
- this.set({ modal: { kind: "user", questions } });
1197
- return new Promise((resolve) => {
1198
- this.pending = { resolve: (v) => resolve(v ?? null) };
1199
- });
1200
- }
1201
- /** The view's answer to whichever modal is open. */
1202
- answer(value) {
1203
- this.resolvePending(value);
1204
- }
1205
- /** Force-close whatever modal is open, on turn teardown or `abort()`. The
1206
- * sentinel must match the modal's OWN contract, not a shared default:
1207
- * `askPermission` promises `PermissionDecision`, where `'deny'` is a real
1208
- * value; `askUser` promises `AskAnswers | null`, and `'deny'` there would be
1209
- * a bogus truthy string standing in for the documented "dismissed" (`null`)
1210
- * outcome that `askUserTool` (packages/harness/tools/builtins.ts) checks for. */
1211
- forceClosePending() {
1212
- if (!this.pending) return;
1213
- this.resolvePending(this.state.modal?.kind === "user" ? null : "deny");
1214
- }
1215
- resolvePending(value) {
1216
- const p = this.pending;
1217
- if (!p) return;
1218
- this.pending = null;
1219
- this.set({ modal: null });
1220
- p.resolve(value);
1221
- }
1222
- };
1223
-
1224
- export {
1225
- matchesAnyGlob,
1226
- STANDARD_POLICY,
1227
- parseMarkdown,
1228
- DELTA_FLUSH_MS,
1229
- TermSession
1230
- };