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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One request's plan, shared over `pi.events` by the three parts of the
|
|
3
|
+
* request path (design.md section 5): the ledger's `context` hook decides it,
|
|
4
|
+
* the renderer (system-reminder) fills in what the request carries, and the
|
|
5
|
+
* ledger's payload hook splices, validates and records by it.
|
|
6
|
+
*
|
|
7
|
+
* The plan object is created once per `context` pass and passed by reference,
|
|
8
|
+
* so the renderer's additions reach the payload hook. A pi cache warm or a
|
|
9
|
+
* provider retry re-runs only the payload hooks; they reuse the last plan.
|
|
10
|
+
*
|
|
11
|
+
* Pure: no pi imports.
|
|
12
|
+
*/
|
|
13
|
+
import type { FoldedRequest, LedgerFold } from "./fold.ts";
|
|
14
|
+
import type { Coverage, EpochCause, ToldFact } from "./records.ts";
|
|
15
|
+
|
|
16
|
+
/** Published by the ledger's `context` hook, once per request. */
|
|
17
|
+
export const LEDGER_PLAN_CHANNEL = "one-code:ledger-plan";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Published by the ledger's payload hook with the request as it goes out
|
|
21
|
+
* (`LedgerSent`): the spliced, validated body, before cache-marks adds its
|
|
22
|
+
* look-back mark. A pi cache warm publishes it again.
|
|
23
|
+
*/
|
|
24
|
+
export const LEDGER_SENT_CHANNEL = "one-code:ledger-sent";
|
|
25
|
+
|
|
26
|
+
export interface LedgerSent {
|
|
27
|
+
payload: Record<string, unknown>;
|
|
28
|
+
model: { api?: string; provider?: string; id?: string } | undefined;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
plan: RequestPlan;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RequestPlan {
|
|
34
|
+
/** True when the request opens an epoch: the whole conversation goes through pi and the message-1 stack is rendered. */
|
|
35
|
+
epoch: boolean;
|
|
36
|
+
/** Why it opens one; the plan adds "coverage" (the base cannot be extended) and "empty" (nothing new since it) to the fold's causes. */
|
|
37
|
+
cause?: EpochCause;
|
|
38
|
+
/** The request this one extends inside its epoch; undefined on an epoch. */
|
|
39
|
+
base?: FoldedRequest;
|
|
40
|
+
/** The context messages this request covers, for its record. */
|
|
41
|
+
covers: Coverage;
|
|
42
|
+
/** The branch's fold at this request. */
|
|
43
|
+
fold: LedgerFold;
|
|
44
|
+
/** Pending ids this request carries (the renderer fills it). */
|
|
45
|
+
consumed: string[];
|
|
46
|
+
/**
|
|
47
|
+
* Each new block's text with the facts it tells the model, so a repair that
|
|
48
|
+
* drops a block drops its facts (the renderer fills it). The request tells
|
|
49
|
+
* exactly these blocks' facts, in this order.
|
|
50
|
+
*/
|
|
51
|
+
carried: CarriedFacts[];
|
|
52
|
+
/** Every context message pi built for the request, before the cut: what the whole request covers. */
|
|
53
|
+
messages: readonly CoveredMessage[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A block's text and the facts it tells: they count only while the text is on the wire. */
|
|
57
|
+
export interface CarriedFacts {
|
|
58
|
+
text: string;
|
|
59
|
+
facts: readonly ToldFact[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A context message as far as coverage reads it. */
|
|
63
|
+
export interface CoveredMessage {
|
|
64
|
+
role: string;
|
|
65
|
+
timestamp?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The coverage of a request that sends `messages`. */
|
|
69
|
+
export function coverageOf(messages: readonly CoveredMessage[]): Coverage {
|
|
70
|
+
const last = messages.at(-1);
|
|
71
|
+
return { count: messages.length, role: last?.role ?? "", timestamp: last?.timestamp ?? 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The plan for a request over `messages` (pi's context messages, before any
|
|
76
|
+
* extension injects into them), and the index where the tail this request
|
|
77
|
+
* sends through pi starts: 0 when it opens an epoch. The base is extended only
|
|
78
|
+
* when it covered a prefix of `messages` that is still there, ending in the
|
|
79
|
+
* same message, and something came after it.
|
|
80
|
+
*/
|
|
81
|
+
export function planRequest(fold: LedgerFold, messages: readonly CoveredMessage[]): { plan: RequestPlan; tailStart: number } {
|
|
82
|
+
const covers = coverageOf(messages);
|
|
83
|
+
const plan = (epoch: boolean, cause: EpochCause | undefined, base?: FoldedRequest): RequestPlan => ({
|
|
84
|
+
epoch,
|
|
85
|
+
...(cause ? { cause } : {}),
|
|
86
|
+
...(base ? { base } : {}),
|
|
87
|
+
covers,
|
|
88
|
+
fold,
|
|
89
|
+
consumed: [],
|
|
90
|
+
carried: [],
|
|
91
|
+
messages,
|
|
92
|
+
});
|
|
93
|
+
const base = fold.lastRequest;
|
|
94
|
+
if (fold.opensEpoch || !base) return { plan: plan(true, fold.cause ?? "first"), tailStart: 0 };
|
|
95
|
+
const at = base.covers;
|
|
96
|
+
const last = at && at.count > 0 ? messages[at.count - 1] : undefined;
|
|
97
|
+
if (!at || at.count > messages.length || (at.count > 0 && (last?.role !== at.role || (last?.timestamp ?? 0) !== at.timestamp))) {
|
|
98
|
+
return { plan: plan(true, "coverage"), tailStart: 0 };
|
|
99
|
+
}
|
|
100
|
+
if (at.count === messages.length) return { plan: plan(true, "empty"), tailStart: 0 };
|
|
101
|
+
return { plan: plan(false, undefined, base), tailStart: at.count };
|
|
102
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ledger's session entries: what One Code records on the branch so every
|
|
3
|
+
* request is a pure function of it (working-docs/features/lifecycle-grid-hunt/design.md
|
|
4
|
+
* section 4). Each is a pi custom entry (`pi.appendEntry`), invisible to the
|
|
5
|
+
* model and to plain pi, carrying `v` (the record format) and `by` (the One
|
|
6
|
+
* Code version that wrote it). A record of any other `v` is ignored, never
|
|
7
|
+
* partly read: that part of the branch is rendered again once and recorded
|
|
8
|
+
* anew (design.md section 13 and the plan's record versioning).
|
|
9
|
+
*
|
|
10
|
+
* Pure: no pi imports.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const LEDGER_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
export const RECORD = {
|
|
16
|
+
/** One request as sent, as a delta against the previous one on the branch. */
|
|
17
|
+
request: "one-code:request",
|
|
18
|
+
/** What each injected block announced (tool names, skills, a mode switch, a delivered pending item). */
|
|
19
|
+
told: "one-code:told",
|
|
20
|
+
/** Durable session state, latest entry per key wins. */
|
|
21
|
+
state: "one-code:state",
|
|
22
|
+
/** Produced for the model, not yet sent. */
|
|
23
|
+
pending: "one-code:pending",
|
|
24
|
+
/**
|
|
25
|
+
* An epoch opens at the next request: written right before the record of a
|
|
26
|
+
* request that opened one, and by an owner that must change the history
|
|
27
|
+
* above the append point (the no-op loop fold) to make the next one open.
|
|
28
|
+
*/
|
|
29
|
+
epoch: "one-code:epoch",
|
|
30
|
+
/** Written at every `/tree`: pi keeps the leaf move only in memory. */
|
|
31
|
+
branch: "one-code:branch",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export type RecordKind = keyof typeof RECORD;
|
|
35
|
+
|
|
36
|
+
/** The fields every record carries. */
|
|
37
|
+
export interface RecordBase {
|
|
38
|
+
v: typeof LEDGER_VERSION;
|
|
39
|
+
by: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Who a request went to; a change opens an epoch. */
|
|
43
|
+
export interface RequestIdentity {
|
|
44
|
+
api: string;
|
|
45
|
+
provider: string;
|
|
46
|
+
model: string;
|
|
47
|
+
/** "key" | "oauth" | "chatgpt": OAuth renames tools, ChatGPT sign-in drops fields, so a change opens an epoch. */
|
|
48
|
+
auth: string;
|
|
49
|
+
/** Whether pi-ai puts its per-message effort markers in the list; absent on a model without them. */
|
|
50
|
+
effortMarkers?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const sameIdentity = (a: RequestIdentity, b: RequestIdentity): boolean =>
|
|
54
|
+
a.api === b.api && a.provider === b.provider && a.model === b.model && a.auth === b.auth && a.effortMarkers === b.effortMarkers;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Which of the session's context messages a request covers: the first `count`
|
|
58
|
+
* of pi's `context` messages, the last of them identified by role and
|
|
59
|
+
* timestamp. The next request in the epoch sends only the messages after them
|
|
60
|
+
* (the tail) through pi, and takes the rest from the record.
|
|
61
|
+
*/
|
|
62
|
+
export interface Coverage {
|
|
63
|
+
count: number;
|
|
64
|
+
role: string;
|
|
65
|
+
timestamp: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RequestRecordData extends RecordBase {
|
|
69
|
+
identity: RequestIdentity;
|
|
70
|
+
/** Absent on a record that cannot be extended (written without a context pass): the next request opens an epoch. */
|
|
71
|
+
covers?: Coverage;
|
|
72
|
+
/** Elements of the previous request's list reused unchanged. */
|
|
73
|
+
keep: number;
|
|
74
|
+
/** Content hashes of the elements appended after them. */
|
|
75
|
+
append: string[];
|
|
76
|
+
/** Content hash of the non-list body (system, tools, thinking, …). */
|
|
77
|
+
head: string;
|
|
78
|
+
/** Bodies for hashes not yet on the branch, by hash. */
|
|
79
|
+
blobs: Record<string, unknown>;
|
|
80
|
+
/** Pending ids this request carried, so the outbox drops them. */
|
|
81
|
+
consumed: string[];
|
|
82
|
+
/**
|
|
83
|
+
* The session whose cache host the request was routed to (pi-ai's
|
|
84
|
+
* session-affinity headers): the session that opened the epoch, so a /fork
|
|
85
|
+
* that extends its parent's request goes where the parent's cache lives.
|
|
86
|
+
*/
|
|
87
|
+
routing?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ToldFact =
|
|
91
|
+
/**
|
|
92
|
+
* `callable`: announced by a `tool_addition` the model may call at once, not
|
|
93
|
+
* only listed for tool_search. `removed`: announced gone, so the epoch no
|
|
94
|
+
* longer holds them told.
|
|
95
|
+
*/
|
|
96
|
+
| { kind: "tools"; names: string[]; callable?: boolean; removed?: boolean }
|
|
97
|
+
| { kind: "skills"; names: string[] }
|
|
98
|
+
| { kind: "mode"; key: string; on: boolean }
|
|
99
|
+
/** A whole-text block (a menu, a catalog): the content hash of the text last told under `key`. */
|
|
100
|
+
| { kind: "block"; key: string; hash: string };
|
|
101
|
+
|
|
102
|
+
/** A whole-text block fact. */
|
|
103
|
+
export type BlockFact = Extract<ToldFact, { kind: "block" }>;
|
|
104
|
+
|
|
105
|
+
export interface ToldRecordData extends RecordBase {
|
|
106
|
+
facts: ToldFact[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface StateRecordData extends RecordBase {
|
|
110
|
+
key: string;
|
|
111
|
+
value: unknown;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface PendingRecordData extends RecordBase {
|
|
115
|
+
id: string;
|
|
116
|
+
/** A later pending item with the same key replaces this one while both are undelivered. */
|
|
117
|
+
key?: string;
|
|
118
|
+
text: string;
|
|
119
|
+
/** Where it lands: the request's tail (default), or before the text of the prompt that opens a request. */
|
|
120
|
+
placement?: "last-append" | "user-prepend";
|
|
121
|
+
/** Sent bare instead of inside a `<system-reminder>` frame. */
|
|
122
|
+
raw?: boolean;
|
|
123
|
+
/** Rides this call's result when the request carries it. */
|
|
124
|
+
toolCallId?: string;
|
|
125
|
+
/** Rides the user message stamped at this time when the request carries it. */
|
|
126
|
+
promptTimestamp?: number;
|
|
127
|
+
/** `user-prepend` only: placed once per request, however many are pending (the shared caveat). */
|
|
128
|
+
once?: boolean;
|
|
129
|
+
/** `user-prepend` only: on a request that does not open with a prompt, it rides the tail instead of waiting for one. */
|
|
130
|
+
orTail?: boolean;
|
|
131
|
+
/** Dropped, undelivered, at a later compaction: it belongs to the epoch the compaction before it opened (a post-compaction restore). */
|
|
132
|
+
untilCompaction?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Files whose whole content the item shows the model (a post-compaction
|
|
135
|
+
* restore), as they were when it was produced. The item and this record
|
|
136
|
+
* are one entry, so file-tracker's replay counts the files read exactly
|
|
137
|
+
* where the branch holds the item.
|
|
138
|
+
*/
|
|
139
|
+
observations?: FileVersion[];
|
|
140
|
+
/** The changed versions of files the item names without showing them (a changed-on-disk notice): never a read. */
|
|
141
|
+
notified?: FileVersion[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A file's version as a pending item or a tool result records it: the absolute path and content hash, with the size and modification time when known. */
|
|
145
|
+
export interface FileVersion {
|
|
146
|
+
/** Absolute, as the tool resolved it (worktree included). */
|
|
147
|
+
path: string;
|
|
148
|
+
sha256: string;
|
|
149
|
+
size?: number;
|
|
150
|
+
mtimeMs?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const EPOCH_CAUSES = ["first", "compaction", "model", "auth", "effort", "fold", "coverage", "empty"] as const;
|
|
154
|
+
|
|
155
|
+
/** Why an epoch opened: no request yet, a compaction, a model or auth switch, thinking turned off or on where pi marks effort per message, a history fold, a base that cannot be extended, or nothing new since it. */
|
|
156
|
+
export type EpochCause = (typeof EPOCH_CAUSES)[number];
|
|
157
|
+
|
|
158
|
+
export interface EpochRecordData extends RecordBase {
|
|
159
|
+
cause: EpochCause;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface BranchRecordData extends RecordBase {
|
|
163
|
+
fromLeafId: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface RecordData {
|
|
167
|
+
request: RequestRecordData;
|
|
168
|
+
told: ToldRecordData;
|
|
169
|
+
state: StateRecordData;
|
|
170
|
+
pending: PendingRecordData;
|
|
171
|
+
epoch: EpochRecordData;
|
|
172
|
+
branch: BranchRecordData;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The slice of a pi session entry the ledger reads. */
|
|
176
|
+
export interface EntryLike {
|
|
177
|
+
type: string;
|
|
178
|
+
id: string;
|
|
179
|
+
parentId?: string | null;
|
|
180
|
+
customType?: string;
|
|
181
|
+
data?: unknown;
|
|
182
|
+
message?: { role?: string; stopReason?: string };
|
|
183
|
+
/** A `context_edit`: the entry it edits, and its replacement (null omits the entry from the model's view). */
|
|
184
|
+
targetId?: string;
|
|
185
|
+
replacement?: unknown;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type ParsedRecord = { [K in RecordKind]: { kind: K; entryId: string; data: RecordData[K] } }[RecordKind];
|
|
189
|
+
|
|
190
|
+
const KIND_BY_TYPE = new Map<string, RecordKind>(Object.entries(RECORD).map(([kind, type]) => [type, kind as RecordKind]));
|
|
191
|
+
|
|
192
|
+
export const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
193
|
+
const isString = (value: unknown): value is string => typeof value === "string";
|
|
194
|
+
const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString);
|
|
195
|
+
|
|
196
|
+
export const isFileVersion = (file: unknown): file is FileVersion =>
|
|
197
|
+
isObject(file) && isString(file.path) && isString(file.sha256) && (file.size === undefined || typeof file.size === "number") && (file.mtimeMs === undefined || typeof file.mtimeMs === "number");
|
|
198
|
+
const isFileVersions = (value: unknown): boolean => Array.isArray(value) && value.every(isFileVersion);
|
|
199
|
+
|
|
200
|
+
const isCoverage = (value: unknown): boolean =>
|
|
201
|
+
isObject(value) && Number.isInteger(value.count) && (value.count as number) >= 0 && isString(value.role) && typeof value.timestamp === "number";
|
|
202
|
+
|
|
203
|
+
function isToldFact(fact: unknown): boolean {
|
|
204
|
+
if (!isObject(fact)) return false;
|
|
205
|
+
switch (fact.kind) {
|
|
206
|
+
case "tools":
|
|
207
|
+
return isStrings(fact.names) && (fact.callable === undefined || typeof fact.callable === "boolean") && (fact.removed === undefined || typeof fact.removed === "boolean");
|
|
208
|
+
case "skills":
|
|
209
|
+
return isStrings(fact.names);
|
|
210
|
+
case "mode":
|
|
211
|
+
return isString(fact.key) && typeof fact.on === "boolean";
|
|
212
|
+
case "block":
|
|
213
|
+
return isString(fact.key) && isString(fact.hash);
|
|
214
|
+
default:
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Each kind's own fields, checked whole: a record that fails is ignored, never partly read. */
|
|
220
|
+
const SHAPE: { [K in RecordKind]: (data: Record<string, unknown>) => boolean } = {
|
|
221
|
+
request: (d) =>
|
|
222
|
+
(d.covers === undefined || isCoverage(d.covers)) &&
|
|
223
|
+
isObject(d.identity) &&
|
|
224
|
+
isString(d.identity.api) &&
|
|
225
|
+
isString(d.identity.provider) &&
|
|
226
|
+
isString(d.identity.model) &&
|
|
227
|
+
isString(d.identity.auth) &&
|
|
228
|
+
(d.identity.effortMarkers === undefined || typeof d.identity.effortMarkers === "boolean") &&
|
|
229
|
+
Number.isInteger(d.keep) &&
|
|
230
|
+
(d.keep as number) >= 0 &&
|
|
231
|
+
isStrings(d.append) &&
|
|
232
|
+
isString(d.head) &&
|
|
233
|
+
isObject(d.blobs) &&
|
|
234
|
+
isStrings(d.consumed) &&
|
|
235
|
+
(d.routing === undefined || isString(d.routing)),
|
|
236
|
+
told: (d) => Array.isArray(d.facts) && d.facts.every(isToldFact),
|
|
237
|
+
state: (d) => isString(d.key) && "value" in d,
|
|
238
|
+
pending: (d) =>
|
|
239
|
+
isString(d.id) &&
|
|
240
|
+
isString(d.text) &&
|
|
241
|
+
(d.key === undefined || isString(d.key)) &&
|
|
242
|
+
(d.placement === undefined || d.placement === "last-append" || d.placement === "user-prepend") &&
|
|
243
|
+
(d.raw === undefined || typeof d.raw === "boolean") &&
|
|
244
|
+
(d.toolCallId === undefined || isString(d.toolCallId)) &&
|
|
245
|
+
(d.promptTimestamp === undefined || typeof d.promptTimestamp === "number") &&
|
|
246
|
+
(d.once === undefined || typeof d.once === "boolean") &&
|
|
247
|
+
(d.orTail === undefined || typeof d.orTail === "boolean") &&
|
|
248
|
+
(d.untilCompaction === undefined || typeof d.untilCompaction === "boolean") &&
|
|
249
|
+
(d.observations === undefined || isFileVersions(d.observations)) &&
|
|
250
|
+
(d.notified === undefined || isFileVersions(d.notified)),
|
|
251
|
+
epoch: (d) => (EPOCH_CAUSES as readonly unknown[]).includes(d.cause),
|
|
252
|
+
branch: (d) => isString(d.fromLeafId),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** The entry as a ledger record, or undefined when it is not one, has another `v`, or is malformed. */
|
|
256
|
+
export function readRecord(entry: EntryLike): ParsedRecord | undefined {
|
|
257
|
+
if (entry.type !== "custom" || entry.customType === undefined) return undefined;
|
|
258
|
+
const kind = KIND_BY_TYPE.get(entry.customType);
|
|
259
|
+
const data = entry.data;
|
|
260
|
+
if (!kind || !isObject(data) || data.v !== LEDGER_VERSION || !isString(data.by) || !SHAPE[kind](data)) return undefined;
|
|
261
|
+
return { kind, entryId: entry.id, data } as unknown as ParsedRecord;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A record's data with `v` and `by` filled in, ready for `pi.appendEntry(RECORD[kind], …)`. */
|
|
265
|
+
export function makeRecord<K extends RecordKind>(_kind: K, by: string, fields: Omit<RecordData[K], keyof RecordBase>): RecordData[K] {
|
|
266
|
+
return { v: LEDGER_VERSION, by, ...fields } as RecordData[K];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The latest value the branch's `one-code:state` entries hold for `key`, or undefined when none does (latest wins). */
|
|
270
|
+
export function stateOnBranch(branch: readonly EntryLike[], key: string): unknown {
|
|
271
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
272
|
+
const record = readRecord(branch[i]);
|
|
273
|
+
if (record?.kind === "state" && record.data.key === key) return record.data.value;
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable session state that follows the branch (design.md section 4.3): an
|
|
3
|
+
* owner records each change as a `one-code:state` entry and reads the
|
|
4
|
+
* branch's latest value back at a resume, `/reload`, a fork and a `/tree`
|
|
5
|
+
* switch.
|
|
6
|
+
*
|
|
7
|
+
* Pure: no pi imports (the session API is taken structurally).
|
|
8
|
+
*/
|
|
9
|
+
import { latestCompaction } from "../compaction-boundary.ts";
|
|
10
|
+
import { extensionVersion } from "../package-version.ts";
|
|
11
|
+
import { type EntryLike, makeRecord, RECORD, stateOnBranch } from "./records.ts";
|
|
12
|
+
|
|
13
|
+
/** The slice of the extension API that appends an entry. */
|
|
14
|
+
export interface EntryAppender {
|
|
15
|
+
appendEntry(customType: string, data?: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The slice of an extension context that reads the branch. */
|
|
19
|
+
export interface BranchReader {
|
|
20
|
+
sessionManager: { getBranch(): readonly unknown[] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Record `value` as the branch's state for `key`. */
|
|
24
|
+
export function recordState(pi: EntryAppender, key: string, value: unknown): void {
|
|
25
|
+
pi.appendEntry(RECORD.state, makeRecord("state", extensionVersion(), { key, value }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The branch's latest state for `key`, or undefined when none is recorded. */
|
|
29
|
+
export function readState(ctx: BranchReader, key: string): unknown {
|
|
30
|
+
return stateOnBranch(ctx.sessionManager.getBranch() as EntryLike[], key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The latest state for `key` on `branch` since its last compaction, or
|
|
35
|
+
* undefined when none is recorded after it. For state a compaction resets
|
|
36
|
+
* (design.md section 4.3 fold rules): what the summary replaced is out of the
|
|
37
|
+
* model's view, so what was told before it counts as untold.
|
|
38
|
+
*/
|
|
39
|
+
export function stateSinceCompaction(branch: readonly unknown[], key: string): unknown {
|
|
40
|
+
return stateOnBranch(branch.slice((latestCompaction(branch)?.index ?? -1) + 1) as EntryLike[], key);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The branch's latest state for `key` as a list of strings (other items dropped), or an empty list. */
|
|
44
|
+
export function readStringsState(ctx: BranchReader, key: string): string[] {
|
|
45
|
+
const stored = readState(ctx, key);
|
|
46
|
+
return Array.isArray(stored) ? stored.filter((item): item is string => typeof item === "string") : [];
|
|
47
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last step before a request goes out (design.md section 5, step 6). Each
|
|
3
|
+
* returned problem is a bug: tests treat any as fatal; at runtime the caller
|
|
4
|
+
* drops the offending block (`repairRequest`) and logs it, so a bookkeeping
|
|
5
|
+
* bug costs one stale announcement, never a 400.
|
|
6
|
+
*
|
|
7
|
+
* Pure: no pi imports.
|
|
8
|
+
*/
|
|
9
|
+
import { isObject, type ToldFact } from "./records.ts";
|
|
10
|
+
import { contentHash, declaredToolNames, listKey, type WirePayload } from "./wire.ts";
|
|
11
|
+
|
|
12
|
+
export type Problem =
|
|
13
|
+
/** A tool_reference, tool_addition or tool_removal names a tool `tools` does not declare. */
|
|
14
|
+
| { kind: "undeclared"; name: string; index: number }
|
|
15
|
+
/** Anthropic: a system message with content followed by something other than an assistant message (findings §52). */
|
|
16
|
+
| { kind: "stranded-system"; index: number };
|
|
17
|
+
|
|
18
|
+
/** The tool a `tool_reference` block names: `name` in a `tool_addition`, `tool_name` in a tool result. */
|
|
19
|
+
function referenceName(block: Record<string, unknown>): string | undefined {
|
|
20
|
+
if (block.type !== "tool_reference") return undefined;
|
|
21
|
+
const name = block.name ?? block.tool_name;
|
|
22
|
+
return typeof name === "string" ? name : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Every tool name a block references, at any depth: `tool_addition` and `tool_removal` wrap a `tool_reference`. */
|
|
26
|
+
function referencedNames(value: unknown, into: Set<string>): Set<string> {
|
|
27
|
+
if (Array.isArray(value)) for (const item of value) referencedNames(item, into);
|
|
28
|
+
else if (isObject(value)) {
|
|
29
|
+
const name = referenceName(value);
|
|
30
|
+
if (name !== undefined) into.add(name);
|
|
31
|
+
for (const item of Object.values(value)) referencedNames(item, into);
|
|
32
|
+
}
|
|
33
|
+
return into;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const roleOf = (element: unknown) => (isObject(element) ? element.role : undefined);
|
|
37
|
+
|
|
38
|
+
const hasContent = (element: unknown) => {
|
|
39
|
+
const content = isObject(element) ? element.content : undefined;
|
|
40
|
+
return Array.isArray(content) || typeof content === "string" ? content.length > 0 : content !== undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Anthropic: each system message with content must reach an assistant message, or the end, through system messages only. */
|
|
44
|
+
function strandedSystems(list: readonly unknown[]): number[] {
|
|
45
|
+
const stranded: number[] = [];
|
|
46
|
+
list.forEach((element, index) => {
|
|
47
|
+
if (roleOf(element) !== "system" || !hasContent(element)) return;
|
|
48
|
+
let next = index + 1;
|
|
49
|
+
while (next < list.length && roleOf(list[next]) === "system") next++;
|
|
50
|
+
if (next < list.length && roleOf(list[next]) !== "assistant") stranded.push(index);
|
|
51
|
+
});
|
|
52
|
+
return stranded;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateRequest(payload: WirePayload, api: string): Problem[] {
|
|
56
|
+
const problems: Problem[] = [];
|
|
57
|
+
const value = payload[listKey(api)];
|
|
58
|
+
const list: unknown[] = Array.isArray(value) ? value : [];
|
|
59
|
+
|
|
60
|
+
const declared = declaredToolNames(payload);
|
|
61
|
+
list.forEach((element, index) => {
|
|
62
|
+
for (const name of referencedNames(element, new Set())) if (!declared.has(name)) problems.push({ kind: "undeclared", name, index });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (api === "anthropic-messages") for (const index of strandedSystems(list)) problems.push({ kind: "stranded-system", index });
|
|
66
|
+
return problems;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `value` without the `tool_reference` blocks naming `names` (and the `tool_addition` wrapping one); undefined when a container would empty. */
|
|
70
|
+
function withoutReferences(value: unknown, names: ReadonlySet<string>): unknown {
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
return value.flatMap((item) => {
|
|
73
|
+
if (isObject(item) && (item.type === "tool_addition" || item.type === "tool_removal") && referencedNames(item, new Set()).size > 0 && [...referencedNames(item, new Set())].every((name) => names.has(name))) return [];
|
|
74
|
+
if (isObject(item) && names.has(referenceName(item) ?? "")) return [];
|
|
75
|
+
return [withoutReferences(item, names)];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (!isObject(value)) return value;
|
|
79
|
+
const out: Record<string, unknown> = {};
|
|
80
|
+
for (const [key, item] of Object.entries(value)) out[key] = withoutReferences(item, names);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The payload with every flagged problem removed, for a production request
|
|
86
|
+
* (a test treats any problem as fatal instead): references to undeclared
|
|
87
|
+
* tools are dropped, and so is a stranded system message.
|
|
88
|
+
*/
|
|
89
|
+
export function repairRequest(payload: WirePayload, api: string, problems: readonly Problem[]): WirePayload {
|
|
90
|
+
const key = listKey(api);
|
|
91
|
+
const value = payload[key];
|
|
92
|
+
if (!Array.isArray(value) || problems.length === 0) return payload;
|
|
93
|
+
const undeclared = new Set(problems.flatMap((problem) => (problem.kind === "undeclared" ? [problem.name] : [])));
|
|
94
|
+
const stranded = new Set(problems.flatMap((problem) => (problem.kind === "stranded-system" ? [problem.index] : [])));
|
|
95
|
+
let list = value.filter((_, index) => !stranded.has(index));
|
|
96
|
+
if (undeclared.size > 0) {
|
|
97
|
+
list = (withoutReferences(list, undeclared) as unknown[]).filter(
|
|
98
|
+
(element) => !(isObject(element) && element.role === "system" && Array.isArray(element.content) && element.content.length === 0 && element.output_config === undefined),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return { ...payload, [key]: list };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What a repaired request still delivers, so its record does not count a
|
|
106
|
+
* dropped block as sent. Facts ride the text that carries them: the consumed
|
|
107
|
+
* pending items, and the facts of each `carried` block, count only while
|
|
108
|
+
* their text is still in the payload's new elements (not `prefix`, the
|
|
109
|
+
* recorded ones by hash, where an earlier copy of the same text may sit).
|
|
110
|
+
* The callable-tool facts also lose a tool whose `tool_addition` was dropped
|
|
111
|
+
* (a listing for tool_search names tools the request need not declare).
|
|
112
|
+
*/
|
|
113
|
+
export function deliveredAfterRepair(
|
|
114
|
+
payload: WirePayload,
|
|
115
|
+
api: string,
|
|
116
|
+
delivered: { consumed: readonly string[]; facts: readonly ToldFact[] },
|
|
117
|
+
pending: readonly { id: string; text: string }[],
|
|
118
|
+
prefix: ReadonlySet<string> = new Set(),
|
|
119
|
+
carried: readonly { text: string; facts: readonly ToldFact[] }[] = [],
|
|
120
|
+
): { consumed: string[]; facts: ToldFact[] } {
|
|
121
|
+
const list = payload[listKey(api)];
|
|
122
|
+
const sent = JSON.stringify(Array.isArray(list) ? list.filter((element) => !prefix.has(contentHash(element))) : []);
|
|
123
|
+
const onWire = (text: string) => sent.includes(JSON.stringify(text).slice(1, -1));
|
|
124
|
+
const texts = new Map(pending.map((item) => [item.id, item.text]));
|
|
125
|
+
const dropped = new Set(carried.filter((block) => !onWire(block.text)).flatMap((block) => block.facts));
|
|
126
|
+
// The repair left only references to declared tools.
|
|
127
|
+
const referenced = referencedNames(payload[listKey(api)], new Set());
|
|
128
|
+
const facts = delivered.facts.flatMap((fact): ToldFact[] => {
|
|
129
|
+
if (dropped.has(fact)) return [];
|
|
130
|
+
if (fact.kind !== "tools" || !fact.callable) return [fact];
|
|
131
|
+
const names = fact.names.filter((name) => referenced.has(name));
|
|
132
|
+
return names.length > 0 ? [{ ...fact, names }] : [];
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
consumed: delivered.consumed.filter((id) => {
|
|
136
|
+
const text = texts.get(id);
|
|
137
|
+
return text === undefined || onWire(text);
|
|
138
|
+
}),
|
|
139
|
+
facts,
|
|
140
|
+
};
|
|
141
|
+
}
|