@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.
@@ -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.214.0",
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.214.0",
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.214.0",
19003
+ "version": "1.215.0",
19004
19004
  "license": "Apache-2.0",
19005
19005
  "dependencies": {
19006
19006
  "@anthropic-ai/sdk": "^0.124.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.214.0",
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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.214.0",
3
- "commit": "5f594682c5b0a24cb11929a33cd6ef28a085634c",
4
- "builtAt": "2026-09-14T19:50:00.483Z"
2
+ "version": "1.215.0",
3
+ "commit": "801fa224f1f6af4bd152b8555337aa4c8c0b9ea2",
4
+ "builtAt": "2026-09-14T21:24:17.702Z"
5
5
  }
@@ -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 the
540
- * parse refused is recorded too, on the run that fell to the default:
541
- * `preset` is `defaults.agent` and `reason` reads `compound_rejected:
542
- * <why>` (the run's `run_meta.agentSource` stays `default`). Published by
543
- * the dispatcher straight to the registry right after `run_meta`, once per
544
- * run the router answered; absent on every run a directive, a sticky
545
- * preset or a scope chose. Head material, like `run_meta`. Additive:
546
- * unknown → ignored. */
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
- /** The turns a step write carries: the previous step's results and this step's assistant turn. */
198
- export interface TranscriptTurn {
199
- idx: number;
200
- message: ChatMessage;
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 };