@coreplane/switchboard 1.214.0 → 1.215.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/dist/assets/config/config.example.yaml +2 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +568 -6
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +11 -2
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/runEvents.ts +12 -8
- package/dist/assets/src/core/runLedger/sessionLog.ts +207 -0
- package/dist/assets/src/core/runLedger/transcript.ts +187 -0
- package/dist/assets/src/core/runLedger/types.ts +22 -5
- package/dist/assets/src/core/runRecord.ts +50 -1
- package/dist/assets/src/core/ship/coordinator.ts +70 -33
- package/dist/assets/src/execution/residentInstanceId.ts +11 -3
- package/dist/cli.js +628 -279
- package/package.json +1 -1
|
@@ -35,7 +35,15 @@
|
|
|
35
35
|
// ONE DeliveryDO (named "delivery"): the delivery page's snapshot per
|
|
36
36
|
// repository — the merged pull requests' facts over the window, one row
|
|
37
37
|
// each, and when they were read (docs/reference/specs/delivery.md item 10).
|
|
38
|
-
{ "name": "DELIVERY", "class_name": "DeliveryDO" }
|
|
38
|
+
{ "name": "DELIVERY", "class_name": "DeliveryDO" },
|
|
39
|
+
// One SessionLogDO per session — a thread and an agent (the DO name is
|
|
40
|
+
// `<threadKey>:<agent>`): the transcript rows of every run of the
|
|
41
|
+
// session, a full-text index over them, and the notepad
|
|
42
|
+
// (docs/reference/specs/session-log.md). Kept after a run finishes; the
|
|
43
|
+
// retention sweep drops it once its last kept run is gone and no run is
|
|
44
|
+
// live on the thread. Runs claimed before it existed still finish on
|
|
45
|
+
// RunTranscriptDO, which stays bound until no such row is live.
|
|
46
|
+
{ "name": "SESSION_LOGS", "class_name": "SessionLogDO" }
|
|
39
47
|
]
|
|
40
48
|
},
|
|
41
49
|
// The bot shim's ship coordinator Workflow, bound across scripts by the bot's
|
|
@@ -65,6 +73,7 @@
|
|
|
65
73
|
// run's diagnosis); the FrictionDO that once mirrored those diagnoses is
|
|
66
74
|
// retired with its rows — docs/reference/specs/self-improvement.md item 1.
|
|
67
75
|
{ "tag": "v7", "deleted_classes": ["FrictionDO"] },
|
|
68
|
-
{ "tag": "v8", "new_sqlite_classes": ["DeliveryDO"] }
|
|
76
|
+
{ "tag": "v8", "new_sqlite_classes": ["DeliveryDO"] },
|
|
77
|
+
{ "tag": "v9", "new_sqlite_classes": ["SessionLogDO"] }
|
|
69
78
|
]
|
|
70
79
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.215.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.215.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -19000,7 +19000,7 @@
|
|
|
19000
19000
|
},
|
|
19001
19001
|
"packages/switchboard": {
|
|
19002
19002
|
"name": "@coreplane/switchboard",
|
|
19003
|
-
"version": "1.
|
|
19003
|
+
"version": "1.215.0",
|
|
19004
19004
|
"license": "Apache-2.0",
|
|
19005
19005
|
"dependencies": {
|
|
19006
19006
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.215.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -142,6 +142,10 @@ export interface CoordinatorUnit {
|
|
|
142
142
|
/** The unit's thread, once opened; a task's is the requesting thread from the start. */
|
|
143
143
|
threadKey?: string;
|
|
144
144
|
sourceUrl?: string;
|
|
145
|
+
/** The unit's review thread, opened once beside the unit's thread: every
|
|
146
|
+
* review round runs there (record 0034), so the review child's worktree is
|
|
147
|
+
* readonly and its own and no round wipes the coding thread's. */
|
|
148
|
+
reviewThread?: { threadKey: string; sourceUrl?: string };
|
|
145
149
|
/** The unit's board issue in the repository, when one titled by the unit id exists — the handoff's destination. */
|
|
146
150
|
issue?: number;
|
|
147
151
|
pr?: { number: number; url: string };
|
|
@@ -173,6 +177,7 @@ const isResume = (v: unknown): boolean =>
|
|
|
173
177
|
isFinite(v.pr) &&
|
|
174
178
|
(v.headSha === undefined || isText(v.headSha)) &&
|
|
175
179
|
(v.url === undefined || isText(v.url, 2048));
|
|
180
|
+
const isThread = (v: unknown): boolean => isObject(v) && isText(v.threadKey) && isOptionalText(v.sourceUrl);
|
|
176
181
|
|
|
177
182
|
/** Structural check on a record from outside the process (a Worker response, an HTTP body). */
|
|
178
183
|
export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
|
|
@@ -202,6 +207,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
|
|
|
202
207
|
if (!isText(r.unit, 32) || !isText(r.slug) || !isText(r.branch) || !isOptionalText(r.title)) return false;
|
|
203
208
|
if (!Array.isArray(r.dependsOn) || !r.dependsOn.every((d) => isText(d, 32))) return false;
|
|
204
209
|
if (!isOptionalText(r.threadKey) || !isOptionalText(r.sourceUrl)) return false;
|
|
210
|
+
if (r.reviewThread !== undefined && !isThread(r.reviewThread)) return false;
|
|
205
211
|
if (r.issue !== undefined && !isFinite(r.issue)) return false;
|
|
206
212
|
if (r.pr !== undefined && !isPr(r.pr)) return false;
|
|
207
213
|
if (r.resume !== undefined && !isResume(r.resume)) return false;
|
|
@@ -536,20 +536,24 @@ export type RunEvent =
|
|
|
536
536
|
* the router gave (redacted, capped — the same text the card's `routed:`
|
|
537
537
|
* line carries) and the model that decided. A compound route is `preset:
|
|
538
538
|
* "conductor"` with `parts` — one per child the conductor was told to
|
|
539
|
-
* spawn: its preset and its text, the child's whole prompt. A compound
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
539
|
+
* spawn: its preset and its text, the child's whole prompt. A compound
|
|
540
|
+
* answer that carried a write-identity part collapsed onto that preset
|
|
541
|
+
* (record 0034: a write ask is never a part): `preset` is the write preset
|
|
542
|
+
* the run is, `collapsed.presets` the preset each part named in answer
|
|
543
|
+
* order, and no `parts`. A compound the parse refused is recorded too, on
|
|
544
|
+
* the run that fell to the default: `preset` is `defaults.agent` and
|
|
545
|
+
* `reason` reads `compound_rejected: <why>` (the run's
|
|
546
|
+
* `run_meta.agentSource` stays `default`). Published by the dispatcher
|
|
547
|
+
* straight to the registry right after `run_meta`, once per run the router
|
|
548
|
+
* answered; absent on every run a directive, a sticky preset or a scope
|
|
549
|
+
* chose. Head material, like `run_meta`. Additive: unknown → ignored. */
|
|
547
550
|
| {
|
|
548
551
|
type: "route";
|
|
549
552
|
preset: string;
|
|
550
553
|
reason: string;
|
|
551
554
|
model: string;
|
|
552
555
|
parts?: ReadonlyArray<{ preset: string; text: string }>;
|
|
556
|
+
collapsed?: { presets: ReadonlyArray<string> };
|
|
553
557
|
seq?: number;
|
|
554
558
|
at?: number;
|
|
555
559
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// The session log's pure rules (docs/decisions/0035-a-session-log-outlives-its-runs-compaction-is-a-pointer.md;
|
|
2
|
+
// docs/reference/specs/session-log.md): one log per thread and agent holds the
|
|
3
|
+
// transcript rows of every run of the session, each run a range of it. This
|
|
4
|
+
// file is node-free — the bot and `deploy/cloudflare-memory/worker.ts` import
|
|
5
|
+
// it alike — and holds what both sides must agree on: the object's name, the
|
|
6
|
+
// text the full-text index sees for a row, the byte policy's choice of what to
|
|
7
|
+
// drop, the tail cut a follow-up seeds from, and the sweep's drop decision.
|
|
8
|
+
|
|
9
|
+
import type { ChatMessage, ContentPart } from "../../providers/types.js";
|
|
10
|
+
import { DEFAULT_RETENTION_POLICY, utf8ByteLength } from "../runRecord.js";
|
|
11
|
+
import type { StoredRow } from "./transcript.js";
|
|
12
|
+
|
|
13
|
+
export { isRunSession, SESSION_KEY_PATTERN, type RunSession } from "../runRecord.js";
|
|
14
|
+
|
|
15
|
+
/** The byte policy's default: `RetentionPolicy.sessionLogMaxBytes`. */
|
|
16
|
+
export const DEFAULT_SESSION_LOG_MAX_BYTES = DEFAULT_RETENTION_POLICY.sessionLogMaxBytes;
|
|
17
|
+
|
|
18
|
+
/** The object's name: the thread and the agent, the pair record 0034 calls a
|
|
19
|
+
* session. A run without a resolved agent keys on a dash so the name still
|
|
20
|
+
* has both halves. */
|
|
21
|
+
export function sessionKey(threadKey: string, agent: string | undefined): string {
|
|
22
|
+
return `${threadKey}:${agent ?? "-"}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The request is the seed's last user turn (`splitSeed` reads the seed the
|
|
26
|
+
* same way); a seed with no user turn — or no turns — puts its first row there. */
|
|
27
|
+
export function requestIndex(seed: readonly ChatMessage[]): number {
|
|
28
|
+
for (let i = seed.length - 1; i >= 0; i--) if (seed[i].role === "user") return i;
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RowKind = "text" | "tool_result" | "tool_use" | "attachment" | "compaction" | "other";
|
|
33
|
+
|
|
34
|
+
function parseStored(json: string): StoredRow | undefined {
|
|
35
|
+
try {
|
|
36
|
+
const v = JSON.parse(json) as unknown;
|
|
37
|
+
return typeof v === "object" && v !== null ? (v as StoredRow) : undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What a stored row holds, for the byte policy (only a `tool_result` is
|
|
44
|
+
* replaceable) and the index (only text kinds carry text). */
|
|
45
|
+
export function rowKind(json: string): RowKind {
|
|
46
|
+
const stored = parseStored(json);
|
|
47
|
+
if (!stored) return "other";
|
|
48
|
+
if ("compaction" in stored) return "compaction";
|
|
49
|
+
const type = (stored.part as { type?: unknown }).type;
|
|
50
|
+
switch (type) {
|
|
51
|
+
case "text":
|
|
52
|
+
return "text";
|
|
53
|
+
case "tool_result":
|
|
54
|
+
return "tool_result";
|
|
55
|
+
case "tool_use":
|
|
56
|
+
return "tool_use";
|
|
57
|
+
case "image":
|
|
58
|
+
case "document":
|
|
59
|
+
return "attachment";
|
|
60
|
+
default:
|
|
61
|
+
return "other";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const textOfResultContent = (content: unknown): string => {
|
|
66
|
+
if (typeof content === "string") return content;
|
|
67
|
+
if (!Array.isArray(content)) return "";
|
|
68
|
+
return content
|
|
69
|
+
.filter((c): c is { type: "text"; text: string } => typeof c === "object" && c !== null && c.type === "text")
|
|
70
|
+
.map((c) => c.text)
|
|
71
|
+
.join("\n");
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The text the full-text index holds for a row: a text part's text, a tool
|
|
75
|
+
* result's text (the failing test's name in a vitest report is findable), a
|
|
76
|
+
* tool call's name and arguments (a command is findable), a compaction row's
|
|
77
|
+
* summary; an attachment, a thinking block or an unreadable row index nothing. */
|
|
78
|
+
export function textOfStoredRow(json: string): string {
|
|
79
|
+
const stored = parseStored(json);
|
|
80
|
+
if (!stored) return "";
|
|
81
|
+
if ("compaction" in stored) return typeof stored.compaction?.summary === "string" ? stored.compaction.summary : "";
|
|
82
|
+
const part = stored.part as ContentPart | undefined;
|
|
83
|
+
if (!part) return "";
|
|
84
|
+
switch (part.type) {
|
|
85
|
+
case "text":
|
|
86
|
+
return part.text;
|
|
87
|
+
case "tool_result":
|
|
88
|
+
return textOfResultContent(part.content);
|
|
89
|
+
case "tool_use":
|
|
90
|
+
return `${part.name} ${JSON.stringify(part.input ?? {})}`;
|
|
91
|
+
default:
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The run record keeps this much of a tool's output; the marker says so. */
|
|
97
|
+
const RECORD_TOOL_OUTPUT_CHARS = "8,000 characters";
|
|
98
|
+
|
|
99
|
+
/** The row that replaces a tool result the byte policy drops: the same role
|
|
100
|
+
* and call id (the model's `tool_use` keeps its result, so the conversation
|
|
101
|
+
* stays valid), the error flag, and a text naming what went and where the
|
|
102
|
+
* rest still is. Undefined for any row that is not a tool result — user and
|
|
103
|
+
* assistant text are never dropped. */
|
|
104
|
+
export function droppedToolResultRow(json: string): string | undefined {
|
|
105
|
+
const stored = parseStored(json);
|
|
106
|
+
if (!stored || "compaction" in stored) return undefined;
|
|
107
|
+
const part = stored.part as ContentPart | undefined;
|
|
108
|
+
if (!part || part.type !== "tool_result") return undefined;
|
|
109
|
+
const marker: ContentPart = {
|
|
110
|
+
type: "tool_result",
|
|
111
|
+
toolUseId: part.toolUseId,
|
|
112
|
+
content: `[session log: this tool result (${utf8ByteLength(json)} bytes) was dropped to keep the session under its byte policy; the run record keeps its first ${RECORD_TOOL_OUTPUT_CHARS}]`,
|
|
113
|
+
...(part.isError === true ? { isError: true } : {}),
|
|
114
|
+
};
|
|
115
|
+
return JSON.stringify({ role: stored.role, part: marker });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface TrimCandidate {
|
|
119
|
+
id: number;
|
|
120
|
+
bytes: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Which tool-result rows the byte policy replaces once a log is `excess`
|
|
124
|
+
* bytes over its budget: the oldest first (the caller passes them oldest
|
|
125
|
+
* first), each freeing its bytes less the marker's, until the excess is
|
|
126
|
+
* covered or the candidates run out — never a user or assistant text row,
|
|
127
|
+
* which are not candidates. */
|
|
128
|
+
export function planSessionTrim(candidates: readonly TrimCandidate[], excess: number, markerBytes: number): number[] {
|
|
129
|
+
const ids: number[] = [];
|
|
130
|
+
let freed = 0;
|
|
131
|
+
for (const c of candidates) {
|
|
132
|
+
if (freed >= excess) break;
|
|
133
|
+
const gain = c.bytes - markerBytes;
|
|
134
|
+
if (gain <= 0) continue;
|
|
135
|
+
ids.push(c.id);
|
|
136
|
+
freed += gain;
|
|
137
|
+
}
|
|
138
|
+
return ids;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface TailRow {
|
|
142
|
+
idx: number;
|
|
143
|
+
bytes: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The tail a follow-up seeds from: walking the rows newest first, the first
|
|
147
|
+
* index of the oldest turn that still fits `maxBytes` with every newer turn
|
|
148
|
+
* — whole turns only, so no tool call is parted from its result. Undefined
|
|
149
|
+
* when even the newest turn is over the budget, or the log is empty. */
|
|
150
|
+
export function tailCut(rowsNewestFirst: readonly TailRow[], maxBytes: number): number | undefined {
|
|
151
|
+
let total = 0;
|
|
152
|
+
let from: number | undefined;
|
|
153
|
+
let i = 0;
|
|
154
|
+
while (i < rowsNewestFirst.length) {
|
|
155
|
+
const idx = rowsNewestFirst[i].idx;
|
|
156
|
+
let turnBytes = 0;
|
|
157
|
+
let j = i;
|
|
158
|
+
while (j < rowsNewestFirst.length && rowsNewestFirst[j].idx === idx) turnBytes += rowsNewestFirst[j++].bytes;
|
|
159
|
+
if (total + turnBytes > maxBytes) break;
|
|
160
|
+
total += turnBytes;
|
|
161
|
+
from = idx;
|
|
162
|
+
i = j;
|
|
163
|
+
}
|
|
164
|
+
return from;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What the sweep re-reads about one candidate right before dropping it
|
|
168
|
+
* (session-log item 7): whether a kept run record names the session, and
|
|
169
|
+
* whether any run is live on its thread. */
|
|
170
|
+
export interface DropCandidate {
|
|
171
|
+
key: string;
|
|
172
|
+
hasKeptRun: boolean;
|
|
173
|
+
threadLive: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type DropDecision = "drop" | "kept-run" | "thread-live";
|
|
177
|
+
|
|
178
|
+
/** The sweep's decision (session-log item 7), one per candidate on the facts
|
|
179
|
+
* read at that moment: a session object is dropped only when no kept run
|
|
180
|
+
* record names it and no live run holds its thread — any live run on the
|
|
181
|
+
* thread blocks every session of that thread, since the live row is the
|
|
182
|
+
* conservative signal the sweep has. A kept run outranks a live thread in
|
|
183
|
+
* the answer, since it is the longer-lived reason. */
|
|
184
|
+
export function sessionsToDrop(candidates: readonly DropCandidate[]): Array<{ key: string; decision: DropDecision }> {
|
|
185
|
+
return candidates.map((c) => ({
|
|
186
|
+
key: c.key,
|
|
187
|
+
decision: c.hasKeptRun ? "kept-run" : c.threadLive ? "thread-live" : "drop",
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The attachments a stored row references: a part's own `dataRef`, and the
|
|
192
|
+
* `dataRef` of each of a tool result's nested parts — so a trimmed result
|
|
193
|
+
* can take with it what nothing else references (session-log item 5). */
|
|
194
|
+
export function attachmentRefsOf(json: string): string[] {
|
|
195
|
+
const stored = parseStored(json);
|
|
196
|
+
if (!stored || "compaction" in stored) return [];
|
|
197
|
+
const part = stored.part as (ContentPart & { dataRef?: unknown }) | undefined;
|
|
198
|
+
if (!part) return [];
|
|
199
|
+
const refs: string[] = [];
|
|
200
|
+
if (typeof part.dataRef === "string") refs.push(part.dataRef);
|
|
201
|
+
if (part.type === "tool_result" && Array.isArray(part.content)) {
|
|
202
|
+
for (const c of part.content as Array<{ dataRef?: unknown }>) {
|
|
203
|
+
if (typeof c?.dataRef === "string") refs.push(c.dataRef);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return refs;
|
|
207
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The transcript on the wire (docs/reference/specs/run-history.md item 32). The runner's
|
|
2
|
+
// `messages` array is append-only, so the durable form is one row per content
|
|
3
|
+
// part: no row nears the Durable Object's 2 MB limit, a step writes only its
|
|
4
|
+
// new turns, and the read side assembles the exact array — thinking blocks and
|
|
5
|
+
// all — or names the gap. Base64 attachment data over a threshold is stored
|
|
6
|
+
// once and referenced, so a 20 MB PDF in the seed is one attachment row, not
|
|
7
|
+
// a 20 MB part row.
|
|
8
|
+
|
|
9
|
+
import type { ChatMessage, ContentPart } from "../../providers/types.js";
|
|
10
|
+
import { utf8ByteLength } from "../runRecord.js";
|
|
11
|
+
import {
|
|
12
|
+
ATTACHMENT_REF_BYTES,
|
|
13
|
+
TRANSCRIPT_PART_BYTES,
|
|
14
|
+
type CompactionEntry,
|
|
15
|
+
type TranscriptAttachment,
|
|
16
|
+
type TranscriptRow,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
// Every budget here is in UTF-8 BYTES, measured with `utf8ByteLength`, because
|
|
20
|
+
// the fences they must stay under are bytes: the Worker's Content-Length check
|
|
21
|
+
// and the Durable Object's row limit. `String.length` counts UTF-16 code units,
|
|
22
|
+
// and JSON.stringify leaves non-ASCII literal, so a 1.4M-character CJK part is
|
|
23
|
+
// ~4.2 MB on the wire.
|
|
24
|
+
|
|
25
|
+
/** The stored form of one part: the turn's role rides on every row so a turn
|
|
26
|
+
* is reconstructible from its rows alone. */
|
|
27
|
+
export interface StoredPart {
|
|
28
|
+
role: ChatMessage["role"];
|
|
29
|
+
part: ContentPart | (ContentPart & { dataRef: string });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The stored form of a compaction row: no role, no part — the entry alone. */
|
|
33
|
+
export interface StoredCompaction {
|
|
34
|
+
compaction: CompactionEntry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type StoredRow = StoredPart | StoredCompaction;
|
|
38
|
+
|
|
39
|
+
const attachmentRef = (idx: number, part: number) => `t${idx}p${part}`;
|
|
40
|
+
|
|
41
|
+
function hasData(part: ContentPart): part is ContentPart & { data: string; mediaType: string } {
|
|
42
|
+
return (part.type === "image" || part.type === "document") && typeof (part as { data?: unknown }).data === "string";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isCompaction = (v: ChatMessage | StoredCompaction): v is StoredCompaction => "compaction" in v;
|
|
46
|
+
|
|
47
|
+
/** One turn → its rows (and any externalized attachments). Refuses a part the
|
|
48
|
+
* row budget cannot hold: a transcript is never truncated. A compaction entry
|
|
49
|
+
* is one row at its index, part 0. */
|
|
50
|
+
export function turnRows(
|
|
51
|
+
idx: number,
|
|
52
|
+
message: ChatMessage | StoredCompaction,
|
|
53
|
+
opts: { partBytes?: number; attachmentRefBytes?: number } = {},
|
|
54
|
+
): { rows: TranscriptRow[]; attachments: TranscriptAttachment[] } {
|
|
55
|
+
const partBytes = opts.partBytes ?? TRANSCRIPT_PART_BYTES;
|
|
56
|
+
const refBytes = opts.attachmentRefBytes ?? ATTACHMENT_REF_BYTES;
|
|
57
|
+
const rows: TranscriptRow[] = [];
|
|
58
|
+
const attachments: TranscriptAttachment[] = [];
|
|
59
|
+
if (isCompaction(message)) {
|
|
60
|
+
const json = JSON.stringify({ compaction: message.compaction } satisfies StoredCompaction);
|
|
61
|
+
const bytes = utf8ByteLength(json);
|
|
62
|
+
if (bytes > partBytes) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`transcript: the compaction row at turn ${idx} is ${bytes} bytes, over the ${partBytes} row budget`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return { rows: [{ idx, part: 0, json }], attachments };
|
|
68
|
+
}
|
|
69
|
+
message.content.forEach((part, i) => {
|
|
70
|
+
let stored: StoredPart["part"] = part;
|
|
71
|
+
if (hasData(part) && part.data.length > refBytes) {
|
|
72
|
+
const ref = attachmentRef(idx, i);
|
|
73
|
+
attachments.push({ ref, mediaType: part.mediaType, data: part.data });
|
|
74
|
+
stored = { ...(part as ContentPart), data: "", dataRef: ref } as StoredPart["part"];
|
|
75
|
+
}
|
|
76
|
+
const json = JSON.stringify({ role: message.role, part: stored } satisfies StoredPart);
|
|
77
|
+
const bytes = utf8ByteLength(json);
|
|
78
|
+
if (bytes > partBytes) {
|
|
79
|
+
throw new Error(`transcript: part ${i} of turn ${idx} is ${bytes} bytes, over the ${partBytes} row budget`);
|
|
80
|
+
}
|
|
81
|
+
rows.push({ idx, part: i, json });
|
|
82
|
+
});
|
|
83
|
+
return { rows, attachments };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Pack rows into requests whose JSON stays under `maxBytes`, in order. A
|
|
87
|
+
* single row larger than the fence travels alone (the row budget already
|
|
88
|
+
* bounds it below the fence in production). */
|
|
89
|
+
export function chunkRows(rows: readonly TranscriptRow[], maxBytes: number): TranscriptRow[][] {
|
|
90
|
+
const chunks: TranscriptRow[][] = [];
|
|
91
|
+
let current: TranscriptRow[] = [];
|
|
92
|
+
let size = 2; // the array brackets
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
const rowSize = utf8ByteLength(JSON.stringify(row)) + 1;
|
|
95
|
+
if (current.length > 0 && size + rowSize > maxBytes) {
|
|
96
|
+
chunks.push(current);
|
|
97
|
+
current = [];
|
|
98
|
+
size = 2;
|
|
99
|
+
}
|
|
100
|
+
current.push(row);
|
|
101
|
+
size += rowSize;
|
|
102
|
+
}
|
|
103
|
+
if (current.length > 0) chunks.push(current);
|
|
104
|
+
return chunks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A compaction row as the assembled transcript reports it: the entry, the
|
|
108
|
+
* index in `messages` of the first message after it (`before`), and — when
|
|
109
|
+
* the entry names `keptFrom` and that row is in the assembled span — the
|
|
110
|
+
* index in `messages` of the message pi kept first. */
|
|
111
|
+
export interface AssembledCompaction {
|
|
112
|
+
before: number;
|
|
113
|
+
entry: CompactionEntry;
|
|
114
|
+
keptBefore?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type AssembledTranscript =
|
|
118
|
+
| { complete: true; turns: number; messages: ChatMessage[]; compactions: AssembledCompaction[] }
|
|
119
|
+
| { complete: false; turns: number; messages: ChatMessage[]; compactions: AssembledCompaction[]; gap: string };
|
|
120
|
+
|
|
121
|
+
/** Rows (any order) → the turns, contiguous from `base` (a log index; 0 for a
|
|
122
|
+
* run's own object), as a conversation counted from 0. `turns` counts every
|
|
123
|
+
* row index — a compaction row included, since the step records count it —
|
|
124
|
+
* while `messages` carries the conversation alone and `compactions` says
|
|
125
|
+
* where each entry sits in it. Stops at the first gap and says where it is;
|
|
126
|
+
* the turns before it are returned so an `interrupted` record can still carry
|
|
127
|
+
* what was stored. */
|
|
128
|
+
export function assembleTranscript(
|
|
129
|
+
rows: readonly TranscriptRow[],
|
|
130
|
+
attachments: readonly TranscriptAttachment[],
|
|
131
|
+
base = 0,
|
|
132
|
+
): AssembledTranscript {
|
|
133
|
+
const byRef = new Map(attachments.map((a) => [a.ref, a]));
|
|
134
|
+
const byTurn = new Map<number, Map<number, StoredRow>>();
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
const idx = row.idx - base;
|
|
137
|
+
let parts = byTurn.get(idx);
|
|
138
|
+
if (!parts) byTurn.set(idx, (parts = new Map()));
|
|
139
|
+
parts.set(row.part, JSON.parse(row.json) as StoredRow);
|
|
140
|
+
}
|
|
141
|
+
const messages: ChatMessage[] = [];
|
|
142
|
+
const compactions: AssembledCompaction[] = [];
|
|
143
|
+
/** The `messages` index each turn index landed at (a compaction row lands nowhere). */
|
|
144
|
+
const messageIndexOf = new Map<number, number>();
|
|
145
|
+
const gap = (why: string): AssembledTranscript => ({
|
|
146
|
+
complete: false,
|
|
147
|
+
turns: messages.length + compactions.length,
|
|
148
|
+
messages,
|
|
149
|
+
compactions,
|
|
150
|
+
gap: why,
|
|
151
|
+
});
|
|
152
|
+
const turnCount = byTurn.size === 0 ? 0 : Math.max(...byTurn.keys()) + 1;
|
|
153
|
+
for (let idx = 0; idx < turnCount; idx++) {
|
|
154
|
+
const parts = byTurn.get(idx);
|
|
155
|
+
if (!parts) return gap(`turn ${idx} is missing`);
|
|
156
|
+
const first = parts.get(0);
|
|
157
|
+
if (first && "compaction" in first) {
|
|
158
|
+
compactions.push({ before: messages.length, entry: first.compaction });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const content: ContentPart[] = [];
|
|
162
|
+
let role: ChatMessage["role"] | undefined;
|
|
163
|
+
const partCount = Math.max(...parts.keys()) + 1;
|
|
164
|
+
for (let p = 0; p < partCount; p++) {
|
|
165
|
+
const stored = parts.get(p);
|
|
166
|
+
if (!stored || "compaction" in stored) return gap(`turn ${idx} is missing part ${p}`);
|
|
167
|
+
role = stored.role;
|
|
168
|
+
const part = stored.part as ContentPart & { dataRef?: string };
|
|
169
|
+
if (part.dataRef !== undefined) {
|
|
170
|
+
const att = byRef.get(part.dataRef);
|
|
171
|
+
if (!att) return gap(`attachment ${part.dataRef} is missing`);
|
|
172
|
+
const { dataRef: _ref, ...rest } = part;
|
|
173
|
+
content.push({ ...rest, data: att.data } as ContentPart);
|
|
174
|
+
} else {
|
|
175
|
+
content.push(part);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
messageIndexOf.set(idx, messages.length);
|
|
179
|
+
messages.push({ role: role ?? "user", content });
|
|
180
|
+
}
|
|
181
|
+
for (const c of compactions) {
|
|
182
|
+
if (c.entry.keptFrom === undefined) continue;
|
|
183
|
+
const kept = messageIndexOf.get(c.entry.keptFrom - base);
|
|
184
|
+
if (kept !== undefined) c.keptBefore = kept;
|
|
185
|
+
}
|
|
186
|
+
return { complete: true, turns: messages.length + compactions.length, messages, compactions };
|
|
187
|
+
}
|
|
@@ -7,7 +7,7 @@ import type { ChatMessage, ToolDef } from "../../providers/types.js";
|
|
|
7
7
|
import type { ChannelVisibility } from "../authz/types.js";
|
|
8
8
|
import type { RunProfile } from "../../config/profile.js";
|
|
9
9
|
import type { RunEvent } from "../runEvents.js";
|
|
10
|
-
import type { RunSeed } from "../runRecord.js";
|
|
10
|
+
import type { RunSeed, RunSession } from "../runRecord.js";
|
|
11
11
|
|
|
12
12
|
/** How long a generation's claim on a run lasts without a heartbeat. */
|
|
13
13
|
export const LEASE_MS = 30_000;
|
|
@@ -73,6 +73,12 @@ export interface LiveRunMeta {
|
|
|
73
73
|
/** Where the run's conversation started (item 52), so a reclaimed run's
|
|
74
74
|
* record still says so: `parent` for a spawned child, `channel` otherwise. */
|
|
75
75
|
seed?: RunSeed;
|
|
76
|
+
/** The run's place in its session's log (docs/reference/specs/session-log.md item
|
|
77
|
+
* 2): set at the claim, so a resume reads the rows from `seedFrom` and
|
|
78
|
+
* continues appending at its indices. Absent on rows claimed before the
|
|
79
|
+
* session log existed — those resume from their own transcript object —
|
|
80
|
+
* and on runs without a conversation of their own. */
|
|
81
|
+
session?: RunSession;
|
|
76
82
|
/** Which executor the run attached: what `makeExecutor` chose. */
|
|
77
83
|
selection?: "resident" | "sandbox" | "local" | "none";
|
|
78
84
|
/** The worktree path the system prompt names. */
|
|
@@ -194,10 +200,21 @@ export interface TranscriptAttachment {
|
|
|
194
200
|
data: string;
|
|
195
201
|
}
|
|
196
202
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
/** pi's compaction entry as the log stores it (docs/reference/specs/session-log.md item
|
|
204
|
+
* 6): the summary pi wrote of everything before the row, the size it replaced,
|
|
205
|
+
* pi's own id for the first entry it kept (forensics: it names nothing in a
|
|
206
|
+
* rebuilt file) and, when the mirror can say, `keptFrom` — the log index of
|
|
207
|
+
* that entry — so a rebuilt session keeps what pi kept. */
|
|
208
|
+
export interface CompactionEntry {
|
|
209
|
+
summary: string;
|
|
210
|
+
tokensBefore?: number;
|
|
211
|
+
firstKeptEntryId?: string;
|
|
212
|
+
keptFrom?: number;
|
|
201
213
|
}
|
|
202
214
|
|
|
215
|
+
/** The rows a step write carries, each at its log index: the previous step's
|
|
216
|
+
* results and this step's assistant turn as messages, and pi's compaction
|
|
217
|
+
* entry as a row of its own between them. */
|
|
218
|
+
export type TranscriptTurn = { idx: number; message: ChatMessage } | { idx: number; compaction: CompactionEntry };
|
|
219
|
+
|
|
203
220
|
export type AppendableEvent = RunEvent & { seq: number };
|