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.
- package/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- 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
|
|
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
|
-
*
|
|
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
|
|
397
|
-
*
|
|
398
|
-
* `defer_loading: true`. A tool
|
|
399
|
-
* loading output is not on the
|
|
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(
|
|
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 [
|
|
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
|
+
}
|