@hncode/hncode 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,10 +13,53 @@ A cyan-blue terminal user interface (TUI) coding agent inspired by [Kimi Code](h
13
13
  - **Cyan-blue TUI**: Raw terminal renderer for perfect Windows Terminal compatibility
14
14
  - **Dual Protocol Support**: Works with both OpenAI-compatible APIs and Anthropic
15
15
  - **Smart Context Management**: Auto-compaction at 85% usage, AI-summarized history retention
16
+ - **Prompt Caching**: Explicit cache breakpoints for the stable prefix (`/cache`)
16
17
  - **MCP-style Toolbelt**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, etc.
18
+ - **MCP Client**: Connect real Model Context Protocol servers (stdio + HTTP) and use their tools
19
+ - **Skills**: Package reusable prompts as Markdown (`/import-skill`, `/skill:<name>` with tab-completion)
20
+ - **Shell Hooks**: Run your own commands at lifecycle events (`PreToolUse`, `PostToolUse`, …)
21
+ - **Git Workflow**: `/git`, `/commit`, `/branch`, `/worktree`, `/pr` plus a `Git` tool for the agent
22
+ - **Plan Review**: Review what a plan will touch, edit it, then approve before any file changes
17
23
  - **Plugin System**: Extend with custom tools, commands, and hooks
18
24
  - **Session Persistence**: Resume conversations across sessions
19
25
  - **Permission Modes**: Ask, YOLO, or Auto modes for different workflows
26
+ - **Shell Passthrough**: type `!` on an empty prompt to switch the composer into shell mode — no model round-trip
27
+ - **CI / Scripting**: Headless `-p` with `json` / `stream-json` output and outcome-based exit codes
28
+ - **Remote Control**: `--control` exposes a local socket to prompt/status/interrupt a running session
29
+
30
+ ### CI usage
31
+
32
+ ```bash
33
+ # One prompt, machine-readable result, exit code reflects the outcome.
34
+ hncode -p "fix the failing tests" --output-format json
35
+ # -> {"type":"result","ok":true,"reply":"…","toolCalls":["Bash"],"files":["src/a.js"],…}
36
+
37
+ # Exit codes: 0 ok · 1 failure · 2 config · 3 no answer · 4 interrupted
38
+ hncode -p "run the linter and fix everything" --output-format stream-json | jq -c 'select(.type=="result")'
39
+ ```
40
+
41
+ ### Shell passthrough
42
+
43
+ Type `!` on an **empty** prompt to switch the composer into shell mode — the `!`
44
+ becomes the prompt symbol and the line runs directly, with no model involved:
45
+
46
+ ```
47
+ ! git add -A && git push
48
+ ! npm test
49
+ ```
50
+
51
+ The `!` is a mode marker, not part of what you type, so it appears exactly once.
52
+ Press `Esc` or `Backspace` on an empty line to leave the mode. Output appears in
53
+ the transcript as a `↳` receipt.
54
+
55
+ ### Remote control
56
+
57
+ ```bash
58
+ hncode --control # in one terminal (TUI)
59
+ hncode control status # in another
60
+ hncode control prompt "run the tests" # queue a prompt on the running session
61
+ hncode control interrupt
62
+ ```
20
63
 
21
64
  ---
22
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/agent-task.js CHANGED
@@ -176,3 +176,81 @@ export function statusColor(status) {
176
176
  if (status === 'completed') return 'gray';
177
177
  return 'red';
178
178
  }
179
+
180
+ // ---- background-task transcript card (mirrors kimi-code) --------------------
181
+ // A background task reports its lifecycle as a one-line card in the transcript:
182
+ //
183
+ // ● agent task completed in background (review the diff)
184
+ // ✗ bash task failed in background (npm test · exit 1)
185
+ //
186
+ // `phase` drives the bullet colour (started / completed / failed); `headline`
187
+ // and the dim `detail` carry the rest. Ported from kimi-code's
188
+ // formatBackgroundTaskTranscript (apps/kimi-code/src/tui/utils/background-task-status.ts)
189
+ // so the two tools read the same way.
190
+
191
+ const BG_DETAIL_MAX = 240;
192
+
193
+ function bgTruncate(value) {
194
+ if (value === undefined || value === null) return undefined;
195
+ const collapsed = String(value).trim().replace(/\s+/g, ' ');
196
+ if (!collapsed) return undefined;
197
+ return collapsed.length <= BG_DETAIL_MAX ? collapsed : collapsed.slice(0, BG_DETAIL_MAX - 1) + '…';
198
+ }
199
+
200
+ export function backgroundTaskPhase(status) {
201
+ if (status === 'running') return 'started';
202
+ if (status === 'completed') return 'completed';
203
+ return 'failed'; // failed / timed_out / killed / lost
204
+ }
205
+
206
+ function bgSubject(kind) {
207
+ if (kind === 'agent') return 'agent task';
208
+ if (kind === 'question') return 'question task';
209
+ return 'bash task';
210
+ }
211
+
212
+ function bgHeadline(status, kind) {
213
+ const subject = bgSubject(kind);
214
+ switch (status) {
215
+ case 'running': return `${subject} started in background`;
216
+ case 'completed': return `${subject} completed in background`;
217
+ case 'failed': return `${subject} failed in background`;
218
+ case 'timed_out': return `${subject} timed out`;
219
+ case 'killed': return `${subject} stopped`;
220
+ case 'lost': return `${subject} lost`;
221
+ default: return `${subject} ${status}`;
222
+ }
223
+ }
224
+
225
+ function bgDetail(task) {
226
+ const parts = [];
227
+ const description = bgTruncate(task.description);
228
+ if (description !== undefined) parts.push(description);
229
+
230
+ if (task.status === 'completed' || task.status === 'failed') {
231
+ if (task.kind === 'process' && task.exitCode !== null && task.exitCode !== undefined) {
232
+ parts.push(`exit ${task.exitCode}`);
233
+ }
234
+ }
235
+ if (task.status === 'killed') {
236
+ const reason = bgTruncate(task.stopReason);
237
+ parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped');
238
+ }
239
+ if (task.status === 'failed') {
240
+ const reason = bgTruncate(task.stopReason);
241
+ if (reason !== undefined) parts.push(reason);
242
+ }
243
+ if (task.status === 'timed_out') parts.push('timed out');
244
+ if (task.status === 'lost') parts.push('session restarted before completion');
245
+
246
+ return parts.length ? parts.join(' · ') : undefined;
247
+ }
248
+
249
+ // Build the card payload for a task snapshot.
250
+ export function backgroundTaskCard(task) {
251
+ return {
252
+ phase: backgroundTaskPhase(task.status),
253
+ headline: bgHeadline(task.status, task.kind),
254
+ detail: bgDetail(task),
255
+ };
256
+ }
package/src/agent.js CHANGED
@@ -7,22 +7,28 @@ import { tools, getTool, llmTools } from './tools/index.js';
7
7
  import { DELEGATION_TOOLS } from './subagent-types.js';
8
8
  import { estimateMessagesTokens } from './term.js';
9
9
  import { runHooks } from './plugin.js';
10
+ import { loadHooks, runShellHooks } from './hooks.js';
10
11
 
11
- // ---- completion guard --------------------------------------------------------
12
- // A turn is only complete when the model ends it with an actual answer. Models
13
- // do stop mid-task: the stream ends right after a reasoning block, or with an
14
- // empty assistant message, and the caller would report "done" for unfinished
15
- // work. When that happens we tell the model what it did and give it more steps.
16
- // Bounded, so a model that insists on stopping cannot loop forever.
17
- export const MAX_NUDGES = 5;
18
-
19
- export function nudgeMessage(why) {
20
- return `[hncode] Your turn ended without a final answer: ${why}. `
21
- + 'This usually means the task is not finished. If the work IS complete, reply with a short '
22
- + 'summary of what changed and how you verified it. Otherwise keep going with the tools right '
23
- + 'now — do not stop silently.';
12
+ // A short, stable key for the prompt cache, derived from the conversation's
13
+ // first user message. Stability is the whole point: the same conversation must
14
+ // produce the same key on every turn, so a cheap deterministic hash beats a
15
+ // random id. (Different conversations may collide — that is harmless, it only
16
+ // means the provider's routing key is shared.)
17
+ function hashKey(s) {
18
+ let h = 0x811c9dc5;
19
+ const str = String(s || '');
20
+ for (let i = 0; i < str.length; i++) {
21
+ h ^= str.charCodeAt(i);
22
+ h = (h * 0x01000193) >>> 0;
23
+ }
24
+ return h.toString(36) + '-' + str.length.toString(36);
24
25
  }
25
26
 
27
+
28
+ // Final instruction appended to the system prompt (see SYSTEM_PROMPT).
29
+ // (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
30
+
31
+
26
32
  // Final instruction appended to the system prompt (see SYSTEM_PROMPT).
27
33
  // (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
28
34
 
@@ -118,9 +124,28 @@ export class Agent {
118
124
  // tool a live getter over this agent's own messages instead of a copy, so the
119
125
  // snapshot is taken at spawn time and reflects everything up to that point.
120
126
  this.ctx._parentMessages = this.messages;
127
+ // Prompt-cache key: identify the SESSION, not the request, so the cached
128
+ // prefix is reused across turns instead of thrashing. The caller may pass one
129
+ // (the TUI knows the session id); otherwise derive a stable value from the
130
+ // first user message so repeated runs of the same conversation still share a
131
+ // prefix. A subagent inherits the parent's key (same conversation family).
132
+ if (cfg.sessionId == null) {
133
+ const firstUser = (Array.isArray(messages) ? messages : []).find((m) => m.role === 'user' && typeof m.content === 'string');
134
+ cfg = { ...cfg, sessionId: firstUser ? hashKey(firstUser.content) : 'default' };
135
+ this.cfg = cfg;
136
+ }
121
137
  this.llm = new LLM(cfg);
122
138
  this.stopRequested = false;
123
139
  this.onApproval = onApproval; // async (toolName, args) => boolean
140
+ // Shell hooks (Claude Code-style): user-configured commands run at lifecycle
141
+ // events. ALWAYS stored as a `{ hooks: {Event: [...]} }` config, which is the
142
+ // shape runShellHooks() reads. A caller may inject one (tests / the TUI,
143
+ // which reloads per turn); otherwise it is read from disk for this workspace.
144
+ this.hookConfig = cfg.hookConfig
145
+ ? (cfg.hookConfig.hooks ? cfg.hookConfig : { hooks: cfg.hookConfig })
146
+ : { hooks: loadHooks(cfg.workspace).hooks };
147
+ // The turn's user prompt, for the UserPromptSubmit hook. Set by run().
148
+ this._turnPrompt = '';
124
149
  // Messages the user types while the agent is streaming. They are injected
125
150
  // as user messages right AFTER the current round of tool calls, so the
126
151
  // model sees "tool callback / user steer" together on the next request —
@@ -147,11 +172,23 @@ export class Agent {
147
172
  // block at the end of this method. Both are awaited and both swallow plugin
148
173
  // errors (see runHooks), so a broken plugin cannot abort a turn.
149
174
  await runHooks('onTurnStart', { messages: this.messages });
175
+ // SHELL HOOK: SessionStart fires once per agent run (the first turn of a
176
+ // session). A hook failure never blocks — it is a convenience signal.
177
+ await runShellHooks(this.hookConfig, 'SessionStart', { messages: this.messages }, this.cfg.workspace);
178
+ // SHELL HOOK: UserPromptSubmit fires with the latest user message. It cannot
179
+ // block (Claude Code's can add context; blocking the user's own prompt is a
180
+ // worse failure than a missing hook), so the result is only logged.
181
+ {
182
+ const lastUser = [...this.messages].reverse().find((m) => m.role === 'user' && typeof m.content === 'string');
183
+ if (lastUser) {
184
+ this._turnPrompt = lastUser.content;
185
+ await runShellHooks(this.hookConfig, 'UserPromptSubmit', { prompt: lastUser.content }, this.cfg.workspace);
186
+ }
187
+ }
150
188
  let text = '';
151
189
  let toolCalls = [];
152
190
  let emittedEnd = false;
153
191
  let thought = '';
154
- let nudges = 0;
155
192
  // decide whether running out of steps actually left the turn unfinished.
156
193
  let hadToolCallsOnFinalStep = false;
157
194
 
@@ -204,8 +241,6 @@ export class Agent {
204
241
  // No step cap: the loop runs until the model produces a final answer, the
205
242
  // user interrupts (Esc / Ctrl-C), or an error ends the turn. maxSteps is
206
243
  // kept only for callers that explicitly request a bound (0/undefined = none).
207
- // Runaway safety comes from the completion guard (MAX_NUDGES) and the user's
208
- // ability to interrupt, not from a fixed tool-round budget.
209
244
  const stepLimit = (this.maxSteps > 0) ? this.maxSteps : Infinity;
210
245
  for (let step = 0; step < stepLimit; step++) {
211
246
  // Process any steer messages collected during the previous step
@@ -230,32 +265,90 @@ export class Agent {
230
265
  // preserved rather than simply discarded.
231
266
  const maxCtx = this.cfg.maxContextTokens || 512000;
232
267
  let usedNow = estimateMessagesTokens(this.messages, this.cfg);
233
- if (maxCtx > 0 && usedNow >= maxCtx * 0.85) {
268
+ if (this.cfg.autoCompact !== false && maxCtx > 0 && usedNow >= maxCtx * 0.85) {
234
269
  const before = usedNow;
235
- const keepN = Math.max(1, Math.ceil(this.messages.length * 0.2));
236
- const keep = this.messages.slice(-keepN);
237
- const dropped = this.messages.slice(0, this.messages.length - keepN);
238
- this.messages = keep;
239
- // Ask the model to summarize what was trimmed so far, then prepend it
240
- // as a system message so the model retains the conversation arc.
270
+ // SHELL HOOK: PreCompact — a chance to back up the transcript before the
271
+ // older half is summarized away. Never blocks (the trim must go ahead).
272
+ await runShellHooks(this.hookConfig, 'PreCompact',
273
+ { tokens: usedNow, messages: this.messages }, this.cfg.workspace);
274
+ // Tell the TUI the trim is under way. Summarizing is a full model
275
+ // round-trip, so without this the UI sat silent for seconds mid-turn
276
+ // with no sign anything was happening.
277
+ this.onEvent({ type: 'compacting', before: usedNow, keep: 0, dropped: 0 });
278
+ //
279
+ // ORDER: SUMMARIZE FIRST, THEN TRIM. The summary is generated from the
280
+ // FULL history so the summarizing model can see what just happened — the
281
+ // most recent exchange is what tells it what the session is doing now.
282
+ // Trimming first and summarizing the leftovers summarized only the OLD
283
+ // part and lost the information the summary needed most.
284
+ //
285
+ // The transcript fed to the summarizer is BOUNDED: the full history can
286
+ // itself exceed the window, in which case the call fails and the trim
287
+ // would drop everything with nothing to show for it. Keep the MOST RECENT
288
+ // messages within the budget — they carry the live state — and clip any
289
+ // single oversized message.
290
+ const SUMMARY_INPUT_CHARS = 120_000;
291
+ const perMsgCap = 8_000;
292
+ const summaryParts = [];
293
+ let summaryChars = 0;
294
+ for (let i = this.messages.length - 1; i >= 0; i--) {
295
+ const m = this.messages[i];
296
+ const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
297
+ const clipped = raw.length > perMsgCap ? raw.slice(0, perMsgCap) + '\n…[clipped]' : raw;
298
+ const line = `${m.role}: ${clipped}`;
299
+ if (summaryChars + line.length > SUMMARY_INPUT_CHARS) break;
300
+ summaryChars += line.length;
301
+ summaryParts.unshift(line);
302
+ }
241
303
  let summary = '';
242
- if (dropped.length > 0) {
304
+ if (this.messages.length > 0) {
243
305
  const summaryPrompt = [
244
306
  { role: 'system', content: 'Summarize the conversation history that follows. Capture the user\'s goals, the key decisions made, files created/modified, and the current state of any ongoing work. Be concise but thorough.' },
245
- ...dropped,
307
+ { role: 'user', content: summaryParts.join('\n\n') },
246
308
  ];
247
309
  const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 2048) };
248
310
  const llm = new LLM(cfgCopy);
249
311
  try { summary = await llm.requestText(summaryPrompt); } catch { summary = ''; }
250
- if (summary) {
251
- this.messages = [{
252
- role: 'system',
253
- content: `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of what was trimmed:\n\n${summary}`,
254
- }, ...this.messages];
255
- }
256
312
  }
313
+ // NOW trim. Keep under a TOKEN budget, not a message count: sizes vary by
314
+ // orders of magnitude (one Read result can dwarf fifty short turns), so a
315
+ // fixed 20%-of-messages slice could still leave the request above the
316
+ // threshold or throw away far more than needed. Walk backwards until the
317
+ // retained tail reaches ~30% of the window; always keep at least the last
318
+ // two so the newest exchange survives.
319
+ const keepBudget = Math.max(1, Math.floor(maxCtx * 0.3));
320
+ let keepFrom = this.messages.length;
321
+ let keptTokens = 0;
322
+ while (keepFrom > 0) {
323
+ const n = this.messages.length - keepFrom; // already-kept count
324
+ if (n >= 2 && keptTokens >= keepBudget) break;
325
+ const cand = this.messages[keepFrom - 1];
326
+ const t = estimateMessagesTokens([cand], this.cfg) - estimateMessagesTokens([], this.cfg);
327
+ // Always take at least the last two messages, however big they are.
328
+ if (n >= 2 && keptTokens + t > keepBudget) break;
329
+ keptTokens += t;
330
+ keepFrom--;
331
+ }
332
+ // BOUNDARY FIX: never let the kept slice START on a `tool` message whose
333
+ // assistant(toolCalls) was dropped — most APIs reject a tool result with
334
+ // no preceding call. Pull the boundary back over any leading tool rows.
335
+ while (keepFrom > 0 && keepFrom < this.messages.length && this.messages[keepFrom].role === 'tool') {
336
+ keepFrom--;
337
+ }
338
+ const keep = this.messages.slice(keepFrom);
339
+ const droppedCount = keepFrom;
340
+ // Final shape: SUMMARY + the most recent messages.
341
+ // ALWAYS prepend a marker, even when summarization failed. Silently
342
+ // dropping history left the model (and the user) with no idea that context
343
+ // was lost; a one-line note makes the gap explicit.
344
+ this.messages = [{
345
+ role: 'system',
346
+ content: summary
347
+ ? `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of the conversation so far:\n\n${summary}`
348
+ : `[hncode] ${droppedCount} earlier message(s) were dropped to stay within the context window, and no summary could be generated. Their detail is no longer available.`,
349
+ }, ...keep];
257
350
  usedNow = estimateMessagesTokens(this.messages, this.cfg);
258
- this.onEvent({ type: 'compacted', before, after: usedNow, kept: keepN });
351
+ this.onEvent({ type: 'compacted', before, after: usedNow, kept: keep.length });
259
352
  }
260
353
  // Live context gauge: report the estimated size of the request about to be
261
354
  // sent, so the TUI's context readout is refreshed at every step rather than
@@ -311,18 +404,8 @@ export class Agent {
311
404
  for (const msg of steers) this.messages.push({ role: 'user', content: msg });
312
405
  continue;
313
406
  } else if (!hasText) {
314
- if (nudges < MAX_NUDGES) {
315
- nudges++;
316
- const why = thought.trim()
317
- ? 'your last output was reasoning/thinking only, with no answer text'
318
- : 'your final message was empty';
319
- this.onEvent({ type: 'nudge', reason: why, attempt: nudges, max: MAX_NUDGES });
320
- const last = this.messages[this.messages.length - 1];
321
- const body = nudgeMessage(why);
322
- if (last && last.role === 'user' && typeof last.content === 'string') last.content += '\n\n' + body;
323
- else this.messages.push({ role: 'user', content: body });
324
- continue;
325
- }
407
+ // No tool calls and no answer text: the model stopped without saying
408
+ // anything. End the turn.
326
409
  break;
327
410
  } else {
328
411
  // The model produced its answer. A steer typed WHILE that answer was
@@ -389,6 +472,21 @@ export class Agent {
389
472
  result = `Error: unknown tool "${tc.name}".`;
390
473
  allOk = false;
391
474
  } else {
475
+ // SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
476
+ // message goes back to the model in place of the tool result — that is
477
+ // the whole point of a pre-hook (e.g. refuse a write that fails a lint
478
+ // gate). Checked BEFORE approving/executing so a blocked call never
479
+ // touches the filesystem.
480
+ const pre = await runShellHooks(this.hookConfig, 'PreToolUse',
481
+ { toolName: tc.name, toolArgs: tc.args }, this.ctx.cwd || this.cfg.workspace);
482
+ if (pre.blocked) {
483
+ result = `Blocked by a PreToolUse hook: ${pre.reason}`;
484
+ this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
485
+ this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
486
+ this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
487
+ allOk = false;
488
+ continue;
489
+ }
392
490
  if (this.onToolStart) this.onToolStart(tc.name, tc.args);
393
491
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
394
492
  // Plugin hook: onToolExecute fires BEFORE the tool runs and receives the
@@ -404,6 +502,10 @@ export class Agent {
404
502
  this.ctx.lastResult = result;
405
503
  // Plugin hook: onToolResult fires with the tool's output.
406
504
  await runHooks('onToolResult', tc.name, result, this.ctx);
505
+ // SHELL HOOK: PostToolUse. Never blocks (the tool already ran); a
506
+ // failure is logged. This is where "format after every edit" lives.
507
+ await runShellHooks(this.hookConfig, 'PostToolUse',
508
+ { toolName: tc.name, toolArgs: tc.args, toolResult: result }, this.ctx.cwd || this.cfg.workspace);
407
509
  // Surface the live TODO list so the TUI can render its panel.
408
510
  if (tc.name === 'TodoList') {
409
511
  this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
@@ -441,7 +543,6 @@ export class Agent {
441
543
  this.onEvent({
442
544
  type: 'incomplete',
443
545
  reason: 'the step budget was exhausted while tools were still being called',
444
- usedNudges: nudges,
445
546
  });
446
547
  }
447
548
  if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
@@ -453,5 +554,9 @@ export class Agent {
453
554
  // Plugin lifecycle: the turn is over (normally, interrupted, or out of steps).
454
555
  // In a finally-equivalent position — every exit path above reaches here.
455
556
  await runHooks('onTurnEnd', { messages: this.messages, stopped: this.stopRequested });
557
+ // SHELL HOOK: Stop fires when the turn ends, whether it finished or was
558
+ // interrupted. Never blocks.
559
+ await runShellHooks(this.hookConfig, 'Stop',
560
+ { messages: this.messages, stopped: this.stopRequested, prompt: this._turnPrompt }, this.cfg.workspace);
456
561
  }
457
562
  }
package/src/cache.js ADDED
@@ -0,0 +1,130 @@
1
+ // Prompt caching — explicit cache breakpoints for long, stable prefixes.
2
+ //
3
+ // Why this is worth doing: an agent resends the whole conversation every turn,
4
+ // and the prefix (system prompt + tool definitions + early messages) is
5
+ // byte-identical across turns. Providers that support prompt caching bill those
6
+ // tokens at a fraction of the input price and skip the prefill compute. Without
7
+ // explicit breakpoints only the providers that cache automatically benefit.
8
+ //
9
+ // Protocol differences:
10
+ // * anthropic — explicit: mark up to N breakpoints with
11
+ // `cache_control: { type: "ephemeral" }`. Anthropic caches everything UP TO
12
+ // and INCLUDING a marked block, so the LAST breakpoint should sit at the end
13
+ // of the stable prefix. The minimum cacheable prefix is model-dependent
14
+ // (~1024 tokens); marking a shorter prefix is a no-op, not an error.
15
+ // * openai — automatic for prompts over ~1024 tokens on supporting models. A
16
+ // stable `prompt_cache_key` improves routing; keys must not vary per request.
17
+ // * everything else — no-op. Unknown providers are left untouched: sending an
18
+ // unsupported `cache_control` field can 400 some gateways.
19
+ //
20
+ // The cache is only useful when the prefix is STABLE, so the helpers here take
21
+ // care to mark the same positions every turn for the same conversation.
22
+
23
+ import { estimateTokens } from './term.js';
24
+
25
+ // Anthropic allows at most 4 cache breakpoints per request.
26
+ export const MAX_BREAKPOINTS = 4;
27
+ // Below this, a breakpoint is pointless: most providers will not cache a prefix
28
+ // this short, and the marker is wasted budget.
29
+ export const MIN_CACHEABLE_TOKENS = 1024;
30
+
31
+ export function supportsCaching(protocol) {
32
+ return protocol === 'anthropic' || protocol === 'openai';
33
+ }
34
+
35
+ // Index (into `messages`) of the last message that belongs to the STABLE prefix.
36
+ //
37
+ // The prefix is only cacheable while it cannot change between turns. The system
38
+ // prompt and tool list are fixed for a session; the conversation then grows by
39
+ // APPENDING. So the prefix is "everything except the recent tail", and the tail
40
+ // must be excluded because it is what changes next turn.
41
+ //
42
+ // We keep the last `keepTail` messages out of the cached region: a tool result
43
+ // appended this turn is not part of the stable prefix next turn until it is
44
+ // itself superseded. Returns -1 when there is nothing worth marking.
45
+ export function stablePrefixEnd(messages, keepTail = 2) {
46
+ const n = Array.isArray(messages) ? messages.length : 0;
47
+ if (n === 0) return -1;
48
+ // Never mark the very last message: it is the newest content and is the most
49
+ // likely to be replaced/regenerated.
50
+ const end = Math.min(n - 1, n - 1 - Math.max(1, keepTail));
51
+ return end >= 0 ? end : -1;
52
+ }
53
+
54
+ // Attach Anthropic `cache_control` breakpoints to a request body IN PLACE-safe
55
+ // fashion (returns a new body). Breakpoints are placed:
56
+ // 1. on the LAST tool definition (caches the whole tool block),
57
+ // 2. on the system prompt (caches tools + system together),
58
+ // 3. on the last message of the stable prefix (caches the conversation head).
59
+ // Marking the tail first and working backwards keeps the total <= MAX_BREAKPOINTS.
60
+ export function withAnthropicCache(body, opts = {}) {
61
+ if (!body || typeof body !== 'object') return body;
62
+ const minTokens = opts.minTokens || MIN_CACHEABLE_TOKENS;
63
+ const out = { ...body };
64
+ const marker = { type: 'ephemeral' };
65
+
66
+ // Tools: marking the last one caches the entire tools array.
67
+ if (Array.isArray(out.tools) && out.tools.length) {
68
+ const tools = out.tools.slice();
69
+ tools[tools.length - 1] = { ...tools[tools.length - 1], cache_control: marker };
70
+ out.tools = tools;
71
+ }
72
+
73
+ // System: a plain string must become a text block to carry cache_control.
74
+ if (typeof out.system === 'string' && out.system.trim()) {
75
+ out.system = [{ type: 'text', text: out.system, cache_control: marker }];
76
+ } else if (Array.isArray(out.system) && out.system.length) {
77
+ const sys = out.system.slice();
78
+ const last = sys.length - 1;
79
+ sys[last] = { ...(typeof sys[last] === 'string' ? { type: 'text', text: sys[last] } : sys[last]), cache_control: marker };
80
+ out.system = sys;
81
+ }
82
+
83
+ // Conversation head: mark the stable prefix's last message.
84
+ if (Array.isArray(out.messages) && out.messages.length) {
85
+ const end = stablePrefixEnd(out.messages, opts.keepTail);
86
+ if (end >= 0 && end < out.messages.length) {
87
+ // Only worth a breakpoint once the prefix is long enough to cache.
88
+ const prefixText = out.messages.slice(0, end + 1)
89
+ .map((m) => (typeof m.content === 'string' ? m.content
90
+ : Array.isArray(m.content) ? m.content.map((b) => b.text || '').join('') : ''))
91
+ .join('\n');
92
+ if (estimateTokens(prefixText) >= minTokens) {
93
+ const msgs = out.messages.slice();
94
+ const msg = { ...msgs[end] };
95
+ if (typeof msg.content === 'string') {
96
+ msg.content = [{ type: 'text', text: msg.content }];
97
+ }
98
+ if (Array.isArray(msg.content) && msg.content.length) {
99
+ const c = msg.content.slice();
100
+ const li = c.length - 1;
101
+ c[li] = { ...c[li], cache_control: marker };
102
+ msg.content = c;
103
+ }
104
+ msgs[end] = msg;
105
+ out.messages = msgs;
106
+ }
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // OpenAI-compatible: most providers cache automatically. A STABLE
113
+ // `prompt_cache_key` helps routing on the providers that accept it, and must not
114
+ // change between turns of the same conversation or the cache thrashes.
115
+ // `seed` should therefore identify the SESSION, not the request.
116
+ export function withOpenAiCache(body, opts = {}) {
117
+ if (!body || typeof body !== 'object') return body;
118
+ const key = opts.sessionKey;
119
+ if (!key) return body;
120
+ return { ...body, prompt_cache_key: String(key) };
121
+ }
122
+
123
+ // Apply the right caching strategy for `protocol`. Returns the body unchanged
124
+ // for protocols with no explicit support.
125
+ export function applyPromptCache(protocol, body, opts = {}) {
126
+ if (!opts.enabled) return body;
127
+ if (protocol === 'anthropic') return withAnthropicCache(body, opts);
128
+ if (protocol === 'openai') return withOpenAiCache(body, opts);
129
+ return body;
130
+ }