one-code-extension 0.5.0 → 0.6.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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -14,6 +14,7 @@ import { createHash } from "node:crypto";
14
14
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
15
15
  import { toolResultsOnBranch } from "./branch-restore.ts";
16
16
  import { parseClaudeId } from "./claude-id.ts";
17
+ import { leadingToolsItem, toolsItemFor } from "./ledger/wire.ts";
17
18
  import { RESPONSES_APIS } from "./system-role.ts";
18
19
  import { ADDENDUM_HEAD } from "./tool-additions.ts";
19
20
  import { normalizeToolName } from "../permissions/matcher.ts";
@@ -37,6 +38,47 @@ export interface DeferRequest {
37
38
  keywords?: string[];
38
39
  }
39
40
 
41
+ /**
42
+ * Ask the session's tool-search which tools it defers: emit a `DeferredQuery`
43
+ * and it adds one request per deferred tool. A subagent defers the same tools
44
+ * its parent injects into it (MCP, cron, SendMessage), whose owners run only
45
+ * in the parent.
46
+ */
47
+ export const DEFERRED_QUERY_CHANNEL = "one-code:deferred-query";
48
+
49
+ export interface DeferredQuery {
50
+ tools: DeferRequest[];
51
+ }
52
+
53
+ /** The tools this session defers, as its tool-search answers DEFERRED_QUERY_CHANNEL (none without one). */
54
+ export function queryDeferred(events: { emit(channel: string, data: unknown): void }): DeferRequest[] {
55
+ const query: DeferredQuery = { tools: [] };
56
+ events.emit(DEFERRED_QUERY_CHANNEL, query);
57
+ return query.tools;
58
+ }
59
+
60
+ /**
61
+ * The tools a child gets from its parent rather than its own extensions: the
62
+ * shared MCP tools (`mcp__*`, which no child extension owns) and `extra`, the
63
+ * ones the caller injects by name (the spawn and cron tools).
64
+ */
65
+ export function injectedByParent(extra: readonly { name: string }[]): (name: string) => boolean {
66
+ const names = new Set(extra.map((tool) => tool.name));
67
+ return (name) => name.startsWith("mcp__") || names.has(name);
68
+ }
69
+
70
+ /**
71
+ * An inline child extension that defers, at load, the parent's deferral of each
72
+ * tool `injected` names, whose owners run only in the parent. A tool the
73
+ * child's own extensions own stays theirs to defer or withhold per the child's
74
+ * model.
75
+ */
76
+ export function deferLikeParentExtension(parentDeferred: readonly DeferRequest[], injected: (name: string) => boolean) {
77
+ return (pi: { events: { emit(channel: string, data: unknown): void } }) => {
78
+ for (const request of parentDeferred) if (injected(request.name)) pi.events.emit(DEFER_CHANNEL, request);
79
+ };
80
+ }
81
+
40
82
  export interface SearchableTool {
41
83
  name: string;
42
84
  description: string;
@@ -101,6 +143,17 @@ export function deferredAddendumText(added: readonly string[]): string {
101
143
  return [ADDENDUM_HEAD, ...added].join("\n");
102
144
  }
103
145
 
146
+ /**
147
+ * The one-shot that says deferred tools the conversation was told about are
148
+ * gone (their MCP server disconnected or was removed, a resume without it).
149
+ * The earlier listing or addendum stays in the history as sent, and so may
150
+ * the tool's declaration, so without this the model calls it and gets "Tool
151
+ * not found".
152
+ */
153
+ export function deferredRemovedText(removed: readonly string[]): string {
154
+ return ["The following deferred tools are no longer available. tool_search cannot load them and calls to them fail; do not use them:", ...removed].join("\n");
155
+ }
156
+
104
157
  /** The loader every deferred tool sits behind. */
105
158
  export const TOOL_SEARCH = "tool_search";
106
159
 
@@ -161,6 +214,73 @@ export function toolSearchLoads(branch: readonly SessionEntry[]): Map<string, st
161
214
  }
162
215
  const TOOL_SEARCH_NAMES: ReadonlySet<string> = new Set([TOOL_SEARCH]);
163
216
 
217
+ /**
218
+ * The tools `entries` use: loaded by a `tool_search` result in them (`loads`,
219
+ * their `toolSearchLoads`), or called by an assistant message in them. A call
220
+ * the dispatcher failed as "Tool <name> not found" does not count: the tool
221
+ * was not loaded, and the miss steers the model to `tool_search` to load it.
222
+ */
223
+ export function toolsInUse(entries: readonly SessionEntry[], loads: ToolSearchLoads = toolSearchLoads(entries)): Set<string> {
224
+ const inUse = new Set([...loads.values()].flat());
225
+ const missed = new Set<string>();
226
+ for (const entry of entries) {
227
+ const message = entry.type === "message" ? (entry.message as { role?: string; toolCallId?: unknown; isError?: unknown }) : undefined;
228
+ if (message?.role === "toolResult" && message.isError === true && typeof message.toolCallId === "string" && toolNotFoundName(resultText(message))) missed.add(message.toolCallId);
229
+ }
230
+ for (const entry of entries) {
231
+ const message = entry.type === "message" ? (entry.message as { role?: string; content?: unknown }) : undefined;
232
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
233
+ for (const block of message.content as Array<{ type?: string; id?: unknown; name?: unknown }>) {
234
+ if (block?.type === "toolCall" && typeof block.name === "string" && !(typeof block.id === "string" && missed.has(block.id))) inUse.add(block.name);
235
+ }
236
+ }
237
+ return inUse;
238
+ }
239
+
240
+ /** Why a deferred tool is callable without a fresh `tool_search` load. */
241
+ export interface CallableReasons {
242
+ /** Loaded by a `tool_search` result in context, or called in context: its history names it. */
243
+ inUse: ReadonlySet<string>;
244
+ /** Announced callable by a `tool_addition` in the current epoch (the ledger's told facts). */
245
+ toldCallable: ReadonlySet<string>;
246
+ /** Declared eagerly by the recorded head the next request starts from: it stays declared, so it stays callable. */
247
+ declaredEager: ReadonlySet<string>;
248
+ /** Announced callable by the next request itself: loadable and untold inside an epoch, on a model that takes `tool_addition`. */
249
+ announcing: ReadonlySet<string>;
250
+ }
251
+
252
+ /**
253
+ * The deferred tools that are callable now, derived from the branch, the model
254
+ * and the registry (lifecycle-grid-hunt design.md section 6): every other
255
+ * deferred tool is inactive, whoever activated it. One function decides, so a
256
+ * resume, `/reload`, `/tree`, a compaction, a model switch and a late defer
257
+ * cannot disagree with what the wire declares and what the model was told.
258
+ */
259
+ export function callableDeferredTools(deferred: Iterable<string>, reasons: CallableReasons): Set<string> {
260
+ const callable = new Set<string>();
261
+ for (const name of deferred) {
262
+ if (reasons.inUse.has(name) || reasons.toldCallable.has(name) || reasons.declaredEager.has(name) || reasons.announcing.has(name)) callable.add(name);
263
+ }
264
+ return callable;
265
+ }
266
+
267
+ /**
268
+ * pi's active set with exactly the `callable` deferred tools active (those
269
+ * pi has registered) and no `withheld` tool; every other tool as it was. The
270
+ * input itself when nothing changes.
271
+ */
272
+ export function reconcileActive(
273
+ active: readonly string[],
274
+ deferred: ReadonlySet<string>,
275
+ callable: ReadonlySet<string>,
276
+ withheld: ReadonlySet<string>,
277
+ registered: ReadonlySet<string>,
278
+ ): readonly string[] {
279
+ const kept = active.filter((name) => !withheld.has(name) && (!deferred.has(name) || callable.has(name)));
280
+ const added = [...callable].filter((name) => registered.has(name) && !withheld.has(name) && !kept.includes(name));
281
+ return kept.length === active.length && added.length === 0 ? active : [...kept, ...added];
282
+ }
283
+
164
284
  type WireTool = Record<string, unknown> & { name: string };
165
285
  type WireBlock = Record<string, unknown> & { type?: unknown };
166
286
  type WireMessage = { role?: unknown; content?: unknown };
@@ -383,20 +503,24 @@ export function responsesToolLoadCompat(model: ResponsesToolLoadModel | undefine
383
503
  type WireItem = Record<string, unknown>;
384
504
 
385
505
  /**
386
- * A Responses request whose `tools` array stays request 1's whatever
506
+ * A Responses request whose starting tools stay request 1's whatever
387
507
  * `tool_search` has loaded since: the OpenAI side of
388
508
  * {@link stabilizeDeferredTools}. pi 0.86 and later put a loaded tool into
389
509
  * `tools` for One Code (its forced system prompt collapses tool changes into
390
510
  * the head, findings §7), so every load re-cached the whole conversation (0
391
511
  * cached tokens on the request after a load on `openai-codex`, findings §45).
392
512
  *
393
- * A loaded registry tool leaves `tools` and enters the input right before the
513
+ * The starting tools are a top-level `tools` array (pi 1.0 to 1.3, and
514
+ * models pi does not flag) or, from pi 1.4.0, an `additional_tools` item at
515
+ * the head of the input (findings §78); this reads which from the payload.
516
+ * A loaded registry tool leaves that list and enters the input right before the
394
517
  * `function_call_output` of the `tool_search` call that loaded it, in pi-ai's
395
518
  * own native shape (`convertResponsesMessages` `appendSystemToolAdditions`):
396
- * an `additional_tools` developer item when the model supports one, else a
397
- * `tool_search_call` + `tool_search_output` pair whose tools carry
398
- * `defer_loading: true`. A tool the model called before that output, or whose
399
- * loading output is not on the wire, stays in `tools`.
519
+ * an `additional_tools` developer item when the request already declares in
520
+ * items or the model supports one, else a `tool_search_call` +
521
+ * `tool_search_output` pair whose tools carry `defer_loading: true`. A tool
522
+ * the model called before that output, or whose loading output is not on the
523
+ * wire, stays where pi declared it.
400
524
  *
401
525
  * Returns undefined (leave the payload alone) when nothing moves. Never
402
526
  * mutates its input.
@@ -407,9 +531,14 @@ export function stabilizeResponsesToolLoads(
407
531
  loads: ToolSearchLoads,
408
532
  compat: ToolLoadCompat,
409
533
  ): Record<string, unknown> | undefined {
410
- const tools = payload.tools;
411
534
  const input = payload.input;
412
- if (!Array.isArray(tools) || !Array.isArray(input) || loads.size === 0) return undefined;
535
+ if (!Array.isArray(input) || loads.size === 0) return undefined;
536
+ // Where the request declares its starting tools: a top-level array (pi 1.0
537
+ // to 1.3, and models pi does not flag), or an `additional_tools` item at the
538
+ // head of the input (pi 1.4.0 and later, findings §78). Read from the bytes.
539
+ const headItem = leadingToolsItem(payload.tools, input);
540
+ const tools = headItem ? headItem.tools : payload.tools;
541
+ if (!Array.isArray(tools)) return undefined;
413
542
 
414
543
  // Input index of each tool output and of each tool's first call. pi sends a
415
544
  // call id as `<call_id>|<item_id>`; the wire's `call_id` is the first part.
@@ -438,18 +567,20 @@ export function stabilizeResponsesToolLoads(
438
567
  }
439
568
  if (movedAt.size === 0) return undefined;
440
569
 
570
+ // A request that declares in items takes another item, shaped like its
571
+ // head; the top-level shape takes what the model's compat supports.
441
572
  const nextInput: unknown[] = [];
442
573
  (input as WireItem[]).forEach((item, index) => {
443
574
  const moved = movedAt.get(index);
444
- if (moved) nextInput.push(...toolLoadItems(moved, String(item.call_id), compat));
445
- nextInput.push(item);
575
+ if (moved) nextInput.push(...(headItem ? [toolsItemFor(moved, headItem)] : toolLoadItems(moved, String(item.call_id), compat)));
576
+ nextInput.push(headItem && index === 0 ? { ...headItem, tools: kept } : item);
446
577
  });
447
- return { ...payload, tools: kept, input: nextInput };
578
+ return headItem ? { ...payload, input: nextInput } : { ...payload, tools: kept, input: nextInput };
448
579
  }
449
580
 
450
581
  /** pi-ai's native items for tools added at one point of a Responses conversation. */
451
582
  function toolLoadItems(tools: WireItem[], loaderCallId: string, compat: ToolLoadCompat): WireItem[] {
452
- if (compat.supportsAdditionalTools) return [{ type: "additional_tools", role: "developer", tools }];
583
+ if (compat.supportsAdditionalTools) return [toolsItemFor(tools)];
453
584
  const names = tools.map((tool) => String(tool.name));
454
585
  const callId = `pi_tool_load_${createHash("sha256").update(`${loaderCallId}:${names.join(",")}`).digest("hex").slice(0, 16)}`;
455
586
  return [
@@ -511,6 +642,15 @@ export function withheldMissReminderText(name: string): string {
511
642
  return `\`${name}\` failed with "Tool ${name} not found" because it was withdrawn for the current model — it is not a deferred tool you can load with tool_search. Do not retry it; continue without it.`;
512
643
  }
513
644
 
645
+ /**
646
+ * A call to a tool the conversation still declares (the epoch's recorded
647
+ * tools stay declared, decisions/caching.md "record once") that this session
648
+ * no longer runs: a setting turned it off, or an extension stopped offering it.
649
+ */
650
+ export function unavailableToolReminderText(name: string): string {
651
+ return `\`${name}\` failed with "Tool ${name} not found" because it is no longer available in this session, although it is still declared. Do not retry it; continue without it.`;
652
+ }
653
+
514
654
  export interface SearchMatch {
515
655
  name: string;
516
656
  score: number;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * One fold per branch state: the extensions that read the ledger's fold
3
+ * (the ledger's `context` hook, its payload hook, tool-search, claude-context)
4
+ * share the last fold of each session instead of folding the same branch
5
+ * again. `foldBranch` is O(requests × list length), and tool-search folds at
6
+ * every `turn_start`, moments before the ledger's `context` hook folds the
7
+ * same branch for the request (in every child session too).
8
+ *
9
+ * The memo lives in process state (lib/process-state.ts), since each
10
+ * extension file evaluates its own copy of this module (findings §3), keyed
11
+ * by the session manager. A hit needs the same identity and the same entries
12
+ * (every entry the same object, in the same order): any appended entry, a
13
+ * different leaf or a rebuilt branch misses, so it never returns another
14
+ * branch's fold. The fold is a pure function of the two, and its readers
15
+ * never mutate it.
16
+ *
17
+ * Pure: no pi imports.
18
+ */
19
+ import { processState } from "../process-state.ts";
20
+ import { foldBranch, type LedgerFold } from "./fold.ts";
21
+ import type { EntryLike, RequestIdentity } from "./records.ts";
22
+
23
+ interface Memo {
24
+ /** A copy of the branch it folded: `getBranch()` may hand back a list that grows in place. */
25
+ entries: readonly EntryLike[];
26
+ identity: string;
27
+ fold: LedgerFold;
28
+ }
29
+
30
+ const memos = () => processState("ledger:fold-memo", () => new WeakMap<object, Memo>());
31
+
32
+ /** Whether two branches hold the same entries, each the same object, in the same order: the key a memo of anything derived from a branch takes. */
33
+ export const sameEntries = (a: readonly unknown[], b: readonly unknown[]) => a.length === b.length && a.every((entry, i) => entry === b[i]);
34
+
35
+ /** `foldBranch(branch, identity)`, reused while `owner`'s branch and the identity stay the same. Without an owner it folds. */
36
+ export function foldOnce(owner: object | undefined, branch: readonly EntryLike[], identity: RequestIdentity | undefined): LedgerFold {
37
+ if (!owner) return foldBranch(branch, identity);
38
+ const key = JSON.stringify(identity ?? null);
39
+ const memo = memos().get(owner);
40
+ if (memo && memo.identity === key && sameEntries(memo.entries, branch)) return memo.fold;
41
+ const fold = foldBranch(branch, identity);
42
+ memos().set(owner, { entries: [...branch], identity: key, fold });
43
+ return fold;
44
+ }
45
+
46
+ /** The fold of a session's current branch under `identity` (`sessionIdentity`, lib/anthropic-auth.ts). */
47
+ export function foldSession(sessionManager: { getBranch(): unknown[] } | undefined, identity: RequestIdentity | undefined): LedgerFold {
48
+ return foldOnce(sessionManager, (sessionManager?.getBranch() ?? []) as EntryLike[], identity);
49
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The fold: everything the ledger knows, derived from the current branch's
3
+ * entries alone (design.md sections 3 to 5). Resume, `/reload`, `/fork` and
4
+ * `/tree` need no restore code because each folds a different branch.
5
+ *
6
+ * Rules: told facts are a union within the
7
+ * epoch and start empty at its start; pending items are those no request
8
+ * delivered; pi's `usage` entries (a cache warm) are skipped. An epoch starts
9
+ * at the first request, after a compaction entry or an `epoch` record, and at
10
+ * a request whose identity (api, provider, model, auth) differs.
11
+ *
12
+ * Delivered means the model saw it and the branch kept it. A request counts
13
+ * (its consumed pending ids and the told records written with it, and it
14
+ * becomes the base the next request extends) only when its own reply follows
15
+ * it: the first message after the request record, before any other request,
16
+ * epoch or compaction, is an assistant message that did not end in an error
17
+ * or an abort and that pi did not omit later (a `context_edit` with no
18
+ * replacement: a recoverable length stop, a failed attempt before a retry).
19
+ * A request with no reply (the turn in flight, a process that died
20
+ * mid-stream, a re-send that replaced it, a btw fork copied mid-stream)
21
+ * counts nothing, so its pending items are delivered again.
22
+ * A request that does not count takes back what it changed: the `epoch`
23
+ * record written right before it and an identity change it made, so a failed
24
+ * model hop leaves the epoch it left.
25
+ *
26
+ * The rebuilt request shares its elements with the branch's entries: clone
27
+ * before mutating (`spliceRequest` does).
28
+ *
29
+ * Pure: no pi imports.
30
+ */
31
+ import { type Coverage, type EntryLike, type EpochCause, type PendingRecordData, readRecord, type RequestIdentity, type RequestRecordData, sameIdentity, type ToldFact } from "./records.ts";
32
+ import { declaredToolNames, leadingToolsItem, loadedInList, type RecordedRequest } from "./wire.ts";
33
+
34
+ export type { EpochCause };
35
+
36
+ /** A recorded request as the fold rebuilds it: the wire list and head, the context messages it covered, and where it was routed. */
37
+ export interface FoldedRequest extends RecordedRequest {
38
+ covers?: Coverage;
39
+ routing?: string;
40
+ }
41
+
42
+ export interface LedgerFold {
43
+ /** True when the next request opens an epoch: no request yet, a compaction or `epoch` record since, or a changed identity. */
44
+ opensEpoch: boolean;
45
+ /** Why, when `opensEpoch`. */
46
+ cause?: EpochCause;
47
+ /** The last request on the branch that counts (its reply followed it and stayed), rebuilt from its records. */
48
+ lastRequest: FoldedRequest | undefined;
49
+ /** Every element hash already stored on the branch. */
50
+ knownHashes: Set<string>;
51
+ /** Every tool the branch's counted requests declared, in the order of its first declaration: the order an epoch renders (ledger/wire.ts). */
52
+ toolOrder: string[];
53
+ /** Told facts in the current epoch: kind ("tools", "skills", `CALLABLE_TOOLS`) to names. */
54
+ told: Map<string, Set<string>>;
55
+ /** Modes told in the current epoch, by key: on or off as last told. */
56
+ modes: Map<string, boolean>;
57
+ /** Whole-text blocks told in the current epoch, by key: the content hash of the text last told. */
58
+ blocks: Map<string, string>;
59
+ /** Produced and not yet sent, latest per key, in branch order. */
60
+ pending: PendingRecordData[];
61
+ }
62
+
63
+ /** The told kind of the tools announced by a callable `tool_addition` (also folded under "tools"). */
64
+ export const CALLABLE_TOOLS = "tools:callable";
65
+
66
+ /** What an epoch has told the model, as the fold keeps it and a render reads it. */
67
+ export interface ToldState {
68
+ told: Map<string, Set<string>>;
69
+ modes: Map<string, boolean>;
70
+ blocks: Map<string, string>;
71
+ }
72
+
73
+ /** Add `facts` to what the epoch told: a mode or block as last told, names as a union (callable tools also under `CALLABLE_TOOLS`), tools told gone taken out. */
74
+ export function tellFacts(into: ToldState, facts: readonly ToldFact[]): void {
75
+ for (const fact of facts) {
76
+ if (fact.kind === "mode") into.modes.set(fact.key, fact.on);
77
+ else if (fact.kind === "block") into.blocks.set(fact.key, fact.hash);
78
+ else if (fact.kind === "tools" && fact.removed) {
79
+ for (const kind of ["tools", CALLABLE_TOOLS]) for (const name of fact.names) into.told.get(kind)?.delete(name);
80
+ } else {
81
+ const kinds = fact.kind === "tools" && fact.callable ? ["tools", CALLABLE_TOOLS] : [fact.kind];
82
+ for (const kind of kinds) {
83
+ const set = into.told.get(kind) ?? new Set<string>();
84
+ for (const name of fact.names) set.add(name);
85
+ into.told.set(kind, set);
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ /** What `facts` tell an epoch that starts empty. */
92
+ export function toldOf(facts: readonly ToldFact[]): ToldState {
93
+ const state: ToldState = { told: new Map(), modes: new Map(), blocks: new Map() };
94
+ tellFacts(state, facts);
95
+ return state;
96
+ }
97
+
98
+ /** The request a record describes, rebuilt on its base; undefined when the branch lacks a body it needs. */
99
+ function rebuild(data: RequestRecordData, base: RecordedRequest | undefined, blobs: ReadonlyMap<string, unknown>): FoldedRequest | undefined {
100
+ const kept = base?.list.length ?? 0;
101
+ if (data.keep > kept || !blobs.has(data.head) || !data.append.every((hash) => blobs.has(hash))) return undefined;
102
+ const head = blobs.get(data.head);
103
+ if (typeof head !== "object" || head === null || Array.isArray(head)) return undefined;
104
+ return {
105
+ identity: data.identity,
106
+ list: [...(base?.list.slice(0, data.keep) ?? []), ...data.append.map((hash) => blobs.get(hash))],
107
+ head: head as Record<string, unknown>,
108
+ hashes: [...(base?.hashes.slice(0, data.keep) ?? []), ...data.append],
109
+ headHash: data.head,
110
+ ...(data.covers ? { covers: data.covers } : {}),
111
+ ...(data.routing ? { routing: data.routing } : {}),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * What a request that does not count takes back: the told state, the epoch
117
+ * cause and the identity from before it. The told state is held by reference,
118
+ * not copied: nothing tells into it while a checkpoint is live (an epoch or a
119
+ * changed identity replaces it, and told records riding a request wait in its
120
+ * `facts` until it counts, when its checkpoint is dropped).
121
+ */
122
+ interface FoldCheckpoint {
123
+ told: ToldState;
124
+ resetSinceBase: EpochCause | undefined;
125
+ lastIdentity: RequestIdentity | undefined;
126
+ }
127
+
128
+ /** The ids of the entries pi omitted from the model's view: a `context_edit` with no replacement. */
129
+ function omittedIds(branch: readonly EntryLike[]): Set<string> {
130
+ const ids = new Set<string>();
131
+ for (const entry of branch) if (entry.type === "context_edit" && entry.replacement === null && typeof entry.targetId === "string") ids.add(entry.targetId);
132
+ return ids;
133
+ }
134
+
135
+ /** The tools a request declares at its head: its head's declarations, then a leading Responses `additional_tools` item's (pi 1.4.0 and later). */
136
+ function headToolNames(request: Pick<RecordedRequest, "head" | "list">): string[] {
137
+ const item = leadingToolsItem(request.head.tools, request.list);
138
+ return [...declaredToolNames(request.head), ...(item ? loadedInList([item]) : [])];
139
+ }
140
+
141
+ /** Fold the branch (root first, as `getBranch()` returns it). `identity` is the live request identity, for epoch detection. */
142
+ export function foldBranch(branch: readonly EntryLike[], identity?: RequestIdentity): LedgerFold {
143
+ const blobs = new Map<string, unknown>();
144
+ let told = toldOf([]);
145
+ const produced: { item: PendingRecordData; index: number }[] = [];
146
+ const consumed = new Set<string>();
147
+ const omitted = omittedIds(branch);
148
+ /** The index of the last compaction on the branch: an older `untilCompaction` pending item is stale. */
149
+ let lastCompaction = -1;
150
+ let base: FoldedRequest | undefined;
151
+ const toolOrder = new Set<string>();
152
+ /** The head hash of the last counted request, and its first list element's (a leading tools item's), whose declarations `toolOrder` holds. */
153
+ let orderedHead: string | undefined;
154
+ /** A compaction or `epoch` record after the base: the next request renders everything again. */
155
+ let resetSinceBase: EpochCause | undefined;
156
+ let lastIdentity: RequestIdentity | undefined;
157
+ /** The state before the last `epoch` record, while nothing but its own request may follow it. */
158
+ let beforeEpoch: { index: number; checkpoint: FoldCheckpoint } | undefined;
159
+ /** The latest request and what rode it, until its reply settles it. */
160
+ let open: { request: FoldedRequest | undefined; consumed: string[]; facts: ToldFact[]; checkpoint: FoldCheckpoint } | undefined;
161
+
162
+ const tell = (facts: readonly ToldFact[]) => tellFacts(told, facts);
163
+ const checkpoint = (): FoldCheckpoint => ({ told, resetSinceBase, lastIdentity });
164
+ /** A new epoch starts with nothing told: a new state, so a checkpoint keeps the one it holds. */
165
+ const resetTold = () => {
166
+ told = toldOf([]);
167
+ };
168
+ /** Count what the open request carried and make it the base; a request without its reply takes back what it changed. */
169
+ const settle = (counts: boolean) => {
170
+ if (!open) return;
171
+ if (counts) {
172
+ for (const id of open.consumed) consumed.add(id);
173
+ tell(open.facts);
174
+ if (open.request) {
175
+ base = open.request;
176
+ resetSinceBase = undefined;
177
+ // Requests mostly share their head: the last counted one's adds no name.
178
+ const heading = `${open.request.headHash}:${open.request.hashes[0] ?? ""}`;
179
+ if (heading !== orderedHead) for (const name of headToolNames(open.request)) toolOrder.add(name);
180
+ orderedHead = heading;
181
+ }
182
+ } else {
183
+ ({ told, resetSinceBase, lastIdentity } = open.checkpoint);
184
+ }
185
+ open = undefined;
186
+ };
187
+
188
+ branch.forEach((entry, index) => {
189
+ const attached = beforeEpoch?.index === index - 1 ? beforeEpoch : undefined;
190
+ beforeEpoch = undefined;
191
+ if (entry.type === "message") {
192
+ // A request's reply is the first message after its record: pi stores the
193
+ // prompt before the request and the tool results after the reply, so a
194
+ // user message first (a btw fork's side question, copied mid-stream) means
195
+ // no reply. pi drops an errored or aborted reply from the next request
196
+ // (transform-messages.js) and omits a recovered one.
197
+ const message = entry.message;
198
+ settle(message?.role === "assistant" && message.stopReason !== "error" && message.stopReason !== "aborted" && !omitted.has(entry.id));
199
+ return;
200
+ }
201
+ if (entry.type === "compaction") {
202
+ lastCompaction = index;
203
+ settle(false);
204
+ resetTold();
205
+ resetSinceBase = "compaction";
206
+ return;
207
+ }
208
+ const record = readRecord(entry);
209
+ if (!record) return;
210
+ switch (record.kind) {
211
+ case "epoch":
212
+ settle(false);
213
+ beforeEpoch = { index, checkpoint: checkpoint() };
214
+ resetTold();
215
+ resetSinceBase = record.data.cause;
216
+ return;
217
+ case "request": {
218
+ settle(false);
219
+ const data = record.data;
220
+ for (const [hash, body] of Object.entries(data.blobs)) if (!blobs.has(hash)) blobs.set(hash, body);
221
+ const before = attached?.checkpoint ?? checkpoint();
222
+ if (lastIdentity && !sameIdentity(lastIdentity, data.identity)) resetTold();
223
+ lastIdentity = data.identity;
224
+ open = { request: rebuild(data, base, blobs), consumed: data.consumed, facts: [], checkpoint: before };
225
+ return;
226
+ }
227
+ case "told":
228
+ if (open) open.facts.push(...record.data.facts);
229
+ else tell(record.data.facts);
230
+ return;
231
+ case "pending":
232
+ produced.push({ item: record.data, index });
233
+ return;
234
+ }
235
+ });
236
+ settle(false);
237
+
238
+ const latestByKey = new Map<string, number>();
239
+ for (const { item, index } of produced) if (item.key !== undefined) latestByKey.set(item.key, index);
240
+ const pending = produced
241
+ .filter(({ item, index }) => item.key === undefined || latestByKey.get(item.key) === index)
242
+ .filter(({ item, index }) => !consumed.has(item.id) && !(item.untilCompaction && index < lastCompaction))
243
+ .map(({ item }) => item);
244
+
245
+ const changed = (b: RequestIdentity, live: RequestIdentity): EpochCause | undefined =>
246
+ b.model !== live.model || b.api !== live.api || b.provider !== live.provider
247
+ ? "model"
248
+ : b.auth !== live.auth
249
+ ? "auth"
250
+ : b.effortMarkers !== live.effortMarkers
251
+ ? "effort"
252
+ : undefined;
253
+ // A compaction with no settled request before it (the only request failed, a
254
+ // session recorded before the ledger) still opens the compaction's epoch.
255
+ const cause: EpochCause | undefined = resetSinceBase ?? (!base ? "first" : identity ? changed(base.identity, identity) : undefined);
256
+ return {
257
+ opensEpoch: cause !== undefined,
258
+ ...(cause ? { cause } : {}),
259
+ lastRequest: base,
260
+ knownHashes: new Set(blobs.keys()),
261
+ toolOrder: [...toolOrder],
262
+ ...told,
263
+ pending,
264
+ };
265
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The final layout pass over a request's list (findings §52). On Anthropic a
3
+ * system message with content must come after a user message and reach an
4
+ * assistant message, or the end, through system messages only; a request
5
+ * that breaks the rule is a 400, every time, so a session that sends one
6
+ * cannot continue. Several builders put system messages in a list (the
7
+ * stack's system message, `<total_tokens>` countdowns, `tool_addition`
8
+ * notices) and several append to a list built earlier (a side call's prompt
9
+ * after a captured request, a fork's task after its parent's, hook context
10
+ * after a prompt). Instead of each checking what comes after, this pass runs
11
+ * last and makes the layout valid by construction: a run of system messages
12
+ * that a user message follows moves down to just before the next assistant
13
+ * message, or to the end, ahead of the trailing directives pi-ai appends.
14
+ *
15
+ * Only elements from `from` on move (a request's recorded prefix must keep its
16
+ * bytes); the system run that ends the prefix counts as new, since what comes
17
+ * after it is.
18
+ *
19
+ * Pure: no pi imports.
20
+ */
21
+ import { isObject } from "./records.ts";
22
+ import { appendPoint } from "./wire.ts";
23
+
24
+ const roleOf = (element: unknown) => (isObject(element) ? element.role : undefined);
25
+
26
+ /** A system message that is not the directive-only form, which may sit anywhere. */
27
+ function isContentSystem(element: unknown): boolean {
28
+ if (roleOf(element) !== "system" || !isObject(element)) return false;
29
+ const content = element.content;
30
+ return Array.isArray(content) || typeof content === "string" ? content.length > 0 : content !== undefined;
31
+ }
32
+
33
+ /** Whether the system run starting at `index` reaches a non-system element that is not an assistant message. */
34
+ function strandedAt(list: readonly unknown[], index: number): boolean {
35
+ let next = index + 1;
36
+ while (next < list.length && roleOf(list[next]) === "system") next++;
37
+ return next < list.length && roleOf(list[next]) !== "assistant";
38
+ }
39
+
40
+ /** `list` with no stranded system message at or after `from`: the input itself when nothing moves. */
41
+ export function settleSystemMessages<T>(list: readonly T[], api: string, from = 0): readonly T[] {
42
+ if (api !== "anthropic-messages") return list;
43
+ let start = Math.max(0, Math.min(from, list.length));
44
+ while (start > 0 && roleOf(list[start - 1]) === "system") start--;
45
+ if (!list.some((element, index) => index >= start && isContentSystem(element) && strandedAt(list, index))) return list;
46
+ const out = list.slice(0, start);
47
+ let held: T[] = [];
48
+ for (let i = start; i < list.length; i++) {
49
+ const element = list[i];
50
+ if (isContentSystem(element) && strandedAt(list, i)) {
51
+ held.push(element);
52
+ continue;
53
+ }
54
+ if (roleOf(element) === "assistant" && held.length > 0) {
55
+ out.push(...held);
56
+ held = [];
57
+ }
58
+ out.push(element);
59
+ }
60
+ if (held.length > 0) {
61
+ out.splice(Math.max(start, appendPoint(out, api)), 0, ...held);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** `payload` with its list settled from `from` on; the payload itself when nothing moves. */
67
+ export function settlePayload(payload: Record<string, unknown>, api: string, key: string, from = 0): Record<string, unknown> {
68
+ const list = payload[key];
69
+ if (!Array.isArray(list)) return payload;
70
+ const settled = settleSystemMessages(list, api, from);
71
+ return settled === list ? payload : { ...payload, [key]: settled };
72
+ }