@coreplane/switchboard 1.249.1 → 1.251.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 +46 -4
- package/dist/assets/deploy/cloudflare-memory/worker.ts +260 -1
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +12 -3
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +7 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +3 -2
- package/dist/assets/project.json +4 -0
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +1 -1
- package/dist/assets/src/core/authz/policy.ts +10 -0
- package/dist/assets/src/core/authz/resource.ts +6 -2
- package/dist/assets/src/core/authz/types.ts +2 -0
- package/dist/assets/src/core/budgets.ts +13 -0
- package/dist/assets/src/core/coordinator/contract.ts +120 -0
- package/dist/assets/src/core/coordinator/driver.ts +17 -3
- package/dist/assets/src/core/modelCard.ts +19 -3
- package/dist/assets/src/core/prDescriptionTypes.ts +35 -0
- package/dist/assets/src/core/provider.ts +57 -3
- package/dist/assets/src/core/refusal.ts +14 -0
- package/dist/assets/src/core/runEvents.ts +49 -2
- package/dist/assets/src/core/runFriction.ts +2 -0
- package/dist/assets/src/core/runLedger/decisions.ts +9 -1
- package/dist/assets/src/core/runLedger/sessionLog.ts +10 -0
- package/dist/assets/src/core/runLedger/transcript.ts +13 -3
- package/dist/assets/src/core/runLedger/types.ts +72 -6
- package/dist/assets/src/core/runRecord.ts +34 -0
- package/dist/assets/src/core/ship/contract.ts +17 -4
- package/dist/assets/src/core/ship/coordinator.ts +70 -4
- package/dist/assets/src/core/trace/attrs.ts +13 -2
- package/dist/assets/src/core/trace/workerTrace.ts +3 -2
- package/dist/assets/src/core/types.ts +3 -0
- package/dist/assets/web/dist/.vite/manifest.json +58 -52
- package/dist/assets/web/dist/assets/DeliveryPage-DUXd-Sl-.js +1 -0
- package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +2 -0
- package/dist/assets/web/dist/assets/{PendingTurnRow-BuRre8it.js → PendingTurnRow-DDhMhrI7.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-BIUXyz6K.js → ResidentDetailPage-BnEoOnGQ.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BZymgSAb.js → ResidentsIndexPage-Dxpgf-l-.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-bgkjkA0p.js → RunRoutePage-9klVWhSF.js} +6 -6
- package/dist/assets/web/dist/assets/{RunsIndexPage-8S944AzB.js → RunsIndexPage-BplMIgaw.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-8bBtG9y3.js → ScheduledPage-B_GgeJrb.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-DQeNvfaV.js → SettingsPage-BXX4R113.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BPE5syBa.js → StatusDot-BOaw8le9.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DkoeZfTs.js → Tooltip-DYZZ4l4V.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-BUzw--Ii.js → UnitRoutePage-BaSW5Odq.js} +1 -1
- package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +1 -0
- package/dist/assets/web/dist/assets/{dist-D11y9ZJ4.js → dist-BCVXeBJ9.js} +1 -1
- package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +1 -0
- package/dist/assets/web/dist/assets/{main-B6LcgNM6.js → main-Dkcbtu3u.js} +2 -2
- package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +9 -0
- package/dist/cli.js +4594 -2743
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +0 -1
- package/dist/assets/web/dist/assets/HomePage-BpQRky8B.js +0 -2
- package/dist/assets/web/dist/assets/RunFoldRow-3m4CPRI4.js +0 -1
- package/dist/assets/web/dist/assets/indexRow-BmK74Vp1.js +0 -1
- package/dist/assets/web/dist/assets/sseReplay-DXC7kGbN.js +0 -9
|
@@ -114,6 +114,8 @@ export type Resource =
|
|
|
114
114
|
readonly visibility?: ChannelVisibility;
|
|
115
115
|
}
|
|
116
116
|
| { readonly type: "config-scope"; readonly kind: "user"; readonly id: string }
|
|
117
|
+
/** A thread's runtime scope (`config set thread`, routing-and-config item 27); `id` is the thread key. */
|
|
118
|
+
| { readonly type: "config-scope"; readonly kind: "thread"; readonly id: string }
|
|
117
119
|
| { readonly type: "config-scope"; readonly kind: "org" }
|
|
118
120
|
| { readonly type: "agent"; readonly name: string }
|
|
119
121
|
/** List-shaped actions with no single resource (`runs.list`, `friction.report`). */
|
|
@@ -85,6 +85,19 @@ export const DRAIN = {
|
|
|
85
85
|
defaultMinutes: 60,
|
|
86
86
|
} as const;
|
|
87
87
|
|
|
88
|
+
/** How long an intake receipt row is kept on the run history object
|
|
89
|
+
* (docs/reference/specs/run-history.md item 59; docs/decisions/0058): the larger of one day
|
|
90
|
+
* and the reconnect catch-up window the write named plus the drain deadline
|
|
91
|
+
* (`DRAIN.maxMinutes`, the longest a fleet drain may last), so a catch-up
|
|
92
|
+
* that runs after the longest allowed drain still reads the verdict instead
|
|
93
|
+
* of deciding the reply again. The window is clamped to a month so a
|
|
94
|
+
* misconfigured writer cannot make retention unbounded. */
|
|
95
|
+
export const INTAKE_WINDOW_MAX_MS = 30 * DAY_MS;
|
|
96
|
+
export function intakeReceiptRetentionMs(catchUpWindowMs: number): number {
|
|
97
|
+
const window = Math.min(Math.max(0, catchUpWindowMs), INTAKE_WINDOW_MAX_MS);
|
|
98
|
+
return Math.max(DAY_MS, window + minutesToMs(DRAIN.maxMinutes));
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
/** The presets that run the tool loop, and the one pipeline preset. */
|
|
89
102
|
export const LOOP_PRESETS = ["general", "coding", "review", "research", "explore", "conductor"] as const;
|
|
90
103
|
export type LoopPreset = (typeof LOOP_PRESETS)[number];
|
|
@@ -81,6 +81,124 @@ export function runFinishedEventType(runId: string): string {
|
|
|
81
81
|
return `${RUN_FINISHED_EVENT_PREFIX}${runId}`;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** A message into a thread an unfinished unit owns (record 0051's reply-as-event rule): one row
|
|
85
|
+
* of the unit's event list, appended by the dispatcher, folded into the
|
|
86
|
+
* unit's next coding spawn — or run as one fresh turn at the unit's end.
|
|
87
|
+
* `mode` is a receipt of the owner's state at append (record 0051): `steer` into a
|
|
88
|
+
* live run or between rounds, `wake` into an idle owner, `interrupt` into an
|
|
89
|
+
* owner idle after a stop — never a switch the sender fills. */
|
|
90
|
+
export type ThreadEventMode = "steer" | "wake" | "interrupt";
|
|
91
|
+
|
|
92
|
+
export interface ThreadEventAttachment {
|
|
93
|
+
mediaType: string;
|
|
94
|
+
data: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ThreadEvent {
|
|
99
|
+
/** Assigned by the store's append, in arrival order, per unit. */
|
|
100
|
+
seq: number;
|
|
101
|
+
/** The channel's message id, when the platform gave one. */
|
|
102
|
+
id?: string;
|
|
103
|
+
/** The sender (platform-namespaced) and the display name the channel knew. */
|
|
104
|
+
sender: string;
|
|
105
|
+
senderName?: string;
|
|
106
|
+
text: string;
|
|
107
|
+
attachments?: ThreadEventAttachment[];
|
|
108
|
+
/** How many attachments were dropped because the event was over the cap. */
|
|
109
|
+
attachmentsDropped?: number;
|
|
110
|
+
/** How many characters were cut from the end of `text` because the row was
|
|
111
|
+
* still over the cap without any attachment. */
|
|
112
|
+
textDropped?: number;
|
|
113
|
+
mode: ThreadEventMode;
|
|
114
|
+
at: number;
|
|
115
|
+
/** The spawn step or run that consumed the event; absent while unconsumed. */
|
|
116
|
+
consumedBy?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The most one durable event row may weigh, serialized — the durable inbox's
|
|
120
|
+
* own cap (`DURABLE_INBOX_MAX_BYTES`), restated here because this contract is
|
|
121
|
+
* node-free and the state Worker enforces it too. */
|
|
122
|
+
export const THREAD_EVENT_MAX_BYTES = 400 * 1024;
|
|
123
|
+
|
|
124
|
+
const serializedBytes = (v: unknown): number => new TextEncoder().encode(JSON.stringify(v)).length;
|
|
125
|
+
|
|
126
|
+
/** The event under the cap: attachments ride when the serialized row fits,
|
|
127
|
+
* else all of them are dropped and the row says how many — all or nothing,
|
|
128
|
+
* like the durable inbox (a partial carry would hand the model some of the
|
|
129
|
+
* sender's attachments as if they were all of them). A row still over the cap
|
|
130
|
+
* with no attachment left — a text alone past 400 KiB, possible through the
|
|
131
|
+
* ingress body — has its text cut from the end until the row fits, and the
|
|
132
|
+
* row says how many characters went: no event escapes the constant's promise.
|
|
133
|
+
* `TextEncoder`, not `Buffer`: both stores — the bot's and the state Worker's —
|
|
134
|
+
* apply it. */
|
|
135
|
+
export function capThreadEvent<T extends Omit<ThreadEvent, "seq"> & { seq?: number }>(
|
|
136
|
+
event: T,
|
|
137
|
+
): T & Pick<ThreadEvent, "attachmentsDropped" | "textDropped"> {
|
|
138
|
+
if (serializedBytes(event) <= THREAD_EVENT_MAX_BYTES) return event;
|
|
139
|
+
const attachments = event.attachments;
|
|
140
|
+
let capped: T & Pick<ThreadEvent, "attachmentsDropped" | "textDropped"> = event;
|
|
141
|
+
if (attachments && attachments.length > 0) {
|
|
142
|
+
const { attachments: _dropped, ...rest } = event;
|
|
143
|
+
capped = { ...rest, attachmentsDropped: attachments.length } as typeof capped;
|
|
144
|
+
if (serializedBytes(capped) <= THREAD_EVENT_MAX_BYTES) return capped;
|
|
145
|
+
}
|
|
146
|
+
// Text-only overflow: the row's shape is fixed except for the text, so the
|
|
147
|
+
// text is cut — by characters, so a multibyte cut never splits a code point
|
|
148
|
+
// pair the decoder would read as garbage — and shrunk until the bytes fit.
|
|
149
|
+
const text = capped.text;
|
|
150
|
+
const overhead = serializedBytes({ ...capped, text: "", textDropped: text.length });
|
|
151
|
+
let keep = Math.max(0, Math.min(text.length, THREAD_EVENT_MAX_BYTES - overhead));
|
|
152
|
+
for (;;) {
|
|
153
|
+
const cut = { ...capped, text: text.slice(0, keep), textDropped: text.length - keep };
|
|
154
|
+
if (keep === 0 || serializedBytes(cut) <= THREAD_EVENT_MAX_BYTES) return cut;
|
|
155
|
+
keep = Math.floor(keep * 0.9);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const isThreadEventAttachment = (v: unknown): v is ThreadEventAttachment =>
|
|
160
|
+
isObject(v) &&
|
|
161
|
+
typeof v.mediaType === "string" &&
|
|
162
|
+
typeof v.data === "string" &&
|
|
163
|
+
(v.name === undefined || typeof v.name === "string");
|
|
164
|
+
|
|
165
|
+
const isThreadEventMode = (v: unknown): v is ThreadEventMode => v === "steer" || v === "wake" || v === "interrupt";
|
|
166
|
+
|
|
167
|
+
/** Structural check on an event row from outside the process. */
|
|
168
|
+
export function isThreadEvent(v: unknown): v is ThreadEvent {
|
|
169
|
+
if (!isObject(v)) return false;
|
|
170
|
+
const r = v;
|
|
171
|
+
if (typeof r.seq !== "number" || !Number.isInteger(r.seq) || r.seq < 1) return false;
|
|
172
|
+
// The fixed fields are bounded too, so the cap's text cut has a floor to
|
|
173
|
+
// land on: a row whose id or names alone weighed the cap could never fit.
|
|
174
|
+
if (!isOptionalText(r.id)) return false;
|
|
175
|
+
if (!isText(r.sender)) return false;
|
|
176
|
+
if (!isOptionalText(r.senderName)) return false;
|
|
177
|
+
if (typeof r.text !== "string") return false;
|
|
178
|
+
if (r.attachments !== undefined && (!Array.isArray(r.attachments) || !r.attachments.every(isThreadEventAttachment)))
|
|
179
|
+
return false;
|
|
180
|
+
if (r.attachmentsDropped !== undefined && !isCount(r.attachmentsDropped)) return false;
|
|
181
|
+
if (r.textDropped !== undefined && !isCount(r.textDropped)) return false;
|
|
182
|
+
if (!isThreadEventMode(r.mode)) return false;
|
|
183
|
+
if (!isFinite(r.at)) return false;
|
|
184
|
+
if (!isOptionalText(r.consumedBy)) return false;
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The payload-free nudge the dispatcher sends an instance when a thread
|
|
189
|
+
* event lands on one of its units (record 0051's reply-as-event rule): the relay's
|
|
190
|
+
* alphabet — letters, digits, `_` and `-`, at most 100 characters, a colon
|
|
191
|
+
* refused — so the type is the two ids joined by `-`. The send addresses the
|
|
192
|
+
* instance by id (`workflow.get(id)`), so the type only has to name the unit
|
|
193
|
+
* within it: when the two ids together overflow the cap, the INSTANCE id is
|
|
194
|
+
* clipped and the unit is kept whole, and both ends compute the same string. */
|
|
195
|
+
export const UNIT_NUDGE_EVENT_PREFIX = "unit-nudge-";
|
|
196
|
+
export function unitNudgeEventType(key: { instanceId: string; unit: string }): string {
|
|
197
|
+
const suffix = `-${key.unit}`;
|
|
198
|
+
const room = 100 - UNIT_NUDGE_EVENT_PREFIX.length - suffix.length;
|
|
199
|
+
return `${UNIT_NUDGE_EVENT_PREFIX}${key.instanceId.slice(0, room)}${suffix}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
84
202
|
/** The event the bot's GitHub check-run intake sends a merge-waiting parent:
|
|
85
203
|
* the type carries the head sha (hex — inside the platform's alphabet), so a
|
|
86
204
|
* driver waiting at that head matches its own event and any other head's is
|
|
@@ -278,6 +396,8 @@ const MAX_ROUNDS = 200;
|
|
|
278
396
|
const isText = (v: unknown, max = MAX_TEXT): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
279
397
|
const isOptionalText = (v: unknown): boolean => v === undefined || isText(v);
|
|
280
398
|
const isFinite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
399
|
+
/** A count the writers produce: a non-negative integer, never a fraction, a negative or NaN. */
|
|
400
|
+
const isCount = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
281
401
|
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
|
|
282
402
|
const isPr = (v: unknown): boolean => isObject(v) && isFinite(v.number) && isText(v.url, 2048);
|
|
283
403
|
const isResume = (v: unknown): boolean =>
|
|
@@ -145,7 +145,7 @@ export function readBotAnswer(
|
|
|
145
145
|
|
|
146
146
|
/** The answers the bot itself calls a passing condition — the step is asked
|
|
147
147
|
* again, under the policy. Everything else, refusals included, is the machine's. */
|
|
148
|
-
const TRANSIENT = new Set(["github_unavailable", "no_channel", "thread_failed", "unit_not_started"]);
|
|
148
|
+
const TRANSIENT = new Set(["github_unavailable", "no_channel", "thread_failed", "unit_not_started", "not_host"]);
|
|
149
149
|
export function transientRefusal(answer: BotAnswer): string | undefined {
|
|
150
150
|
const { ok, error, message } = answer.body;
|
|
151
151
|
if (ok !== false || typeof error !== "string" || !TRANSIENT.has(error)) return undefined;
|
|
@@ -172,6 +172,8 @@ interface PlanFacts {
|
|
|
172
172
|
grantSource: GrantSource;
|
|
173
173
|
/** The instance's mark as the plan route answers it: a generated one-unit plan (a `plan` with no `path`). */
|
|
174
174
|
generated: boolean;
|
|
175
|
+
/** The runs page base the bot answered: the report links a child's write-up to its run page with it. */
|
|
176
|
+
runPageBase?: string;
|
|
175
177
|
repo: string;
|
|
176
178
|
base: string;
|
|
177
179
|
caps: ShipCaps;
|
|
@@ -217,6 +219,7 @@ function readPlan(a: BotAnswer): PlanFacts {
|
|
|
217
219
|
grantSource:
|
|
218
220
|
b.grantSource === "run" || b.grantSource === "user" || b.grantSource === "channel" ? b.grantSource : "org",
|
|
219
221
|
generated: b.generated === true,
|
|
222
|
+
...(typeof b.runPageBase === "string" && b.runPageBase.length > 0 ? { runPageBase: b.runPageBase } : {}),
|
|
220
223
|
repo: b.repo,
|
|
221
224
|
base: b.base,
|
|
222
225
|
caps: { maxRounds: b.caps.maxRounds, maxMinutes: b.caps.maxMinutes },
|
|
@@ -318,7 +321,7 @@ function isCommitChecks(v: unknown): v is { total: number; pending: string[]; fa
|
|
|
318
321
|
function prCheckReturn(step: string, a: BotAnswer): StepReturn {
|
|
319
322
|
const { ok, state, prNumber, url, headSha, sha, mergedAt, at } = a.body;
|
|
320
323
|
if (ok === true && state === "none") {
|
|
321
|
-
const { unrecovered, aheadOfBase } = a.body;
|
|
324
|
+
const { unrecovered, aheadOfBase, prClosed } = a.body;
|
|
322
325
|
return {
|
|
323
326
|
type: "pr-check",
|
|
324
327
|
step,
|
|
@@ -328,6 +331,9 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
328
331
|
// The branch's commits over the base, when the bot could read them
|
|
329
332
|
// (agent-ship item 12): zero is the `already_landed` ending's fact.
|
|
330
333
|
...(typeof aheadOfBase === "number" ? { aheadOfBase } : {}),
|
|
334
|
+
// The followed pull request verified closed unmerged (issue 1799):
|
|
335
|
+
// the machine must not brief a review round on it.
|
|
336
|
+
...(prClosed === true ? { prClosed: true } : {}),
|
|
331
337
|
},
|
|
332
338
|
at,
|
|
333
339
|
};
|
|
@@ -453,12 +459,19 @@ async function perform(
|
|
|
453
459
|
case "pr-check":
|
|
454
460
|
// `recover` rides only after a dead coding child: the bot opens the pull
|
|
455
461
|
// request from the pushed branch itself instead of answering `none`.
|
|
462
|
+
// `pr` is the machine's adopted pull request (issue 1799): the bot
|
|
463
|
+
// follows it when nothing heads the unit's branch and answers its live
|
|
464
|
+
// state instead of `none` over a minutes-old record fact.
|
|
456
465
|
return prCheckReturn(
|
|
457
466
|
action.step,
|
|
458
467
|
answerOf(
|
|
459
468
|
"pr-check",
|
|
460
469
|
await step.do(action.step, STEP_CONFIG, () =>
|
|
461
|
-
call(bot, "pr-check", {
|
|
470
|
+
call(bot, "pr-check", {
|
|
471
|
+
...tag,
|
|
472
|
+
...(action.recover !== undefined ? { recover: action.recover } : {}),
|
|
473
|
+
...(action.pr !== undefined ? { pr: action.pr } : {}),
|
|
474
|
+
}),
|
|
462
475
|
),
|
|
463
476
|
),
|
|
464
477
|
);
|
|
@@ -537,6 +550,7 @@ async function runUnit(
|
|
|
537
550
|
grant: plan.grant,
|
|
538
551
|
grantSource: plan.grantSource,
|
|
539
552
|
generated: plan.generated,
|
|
553
|
+
...(plan.runPageBase !== undefined ? { runPageBase: plan.runPageBase } : {}),
|
|
540
554
|
...(resume !== undefined ? { resume } : {}),
|
|
541
555
|
...(lastPush !== undefined ? { lastPush } : {}),
|
|
542
556
|
...(session !== undefined ? { session } : {}),
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// catalog lives in ./modelRegistry.ts.
|
|
11
11
|
|
|
12
12
|
import { EFFORT_LEVELS, type Effort } from "../effort.js";
|
|
13
|
-
import { vendorOf, wireOf, type ProviderConfig, type Wire } from "./provider.js";
|
|
13
|
+
import { billerHarnessProvider, vendorOf, wireOf, type ProviderConfig, type Wire } from "./provider.js";
|
|
14
14
|
import type { RegistryCard } from "./modelRegistry.js";
|
|
15
15
|
|
|
16
16
|
/** Which layer named a field: the operator's block, the registry card, or the
|
|
@@ -335,13 +335,29 @@ export function decideControls(card: ModelCard, asked: AskedControls): ControlDe
|
|
|
335
335
|
why: windowVouched ? "" : `no layer names the window; compacting at ${card.window}`,
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
-
|
|
338
|
+
// A `markers` rule needs a harness-side write that places the markers
|
|
339
|
+
// (record 0052's amendment: the harness write names the biller's own
|
|
340
|
+
// provider). The Anthropic wire's own packages place per-block breakpoints;
|
|
341
|
+
// on the chat wire a biller the table names caches the aggregator's own way
|
|
342
|
+
// — the pinned OpenCode binary exempts the openrouter route from per-block
|
|
343
|
+
// placement, so the write carries OpenRouter's top-level
|
|
344
|
+
// `cache_control: { type: "ephemeral" }` via `settings.extraBody` (measured
|
|
345
|
+
// in `opencode/testing/realDriver.test.ts`), and pi's compat sends the
|
|
346
|
+
// per-block markers. A biller served generically degrades, never a silent
|
|
347
|
+
// `native`.
|
|
348
|
+
const markersUnplaced =
|
|
349
|
+
card.cache === "markers" && card.wire === "openai-chat" && billerHarnessProvider(card.block) === undefined;
|
|
350
|
+
const cacheNative = card.cache !== "unknown" && !markersUnplaced;
|
|
339
351
|
decisions.push({
|
|
340
352
|
control: "cache",
|
|
341
353
|
outcome: cacheNative ? "native" : "degraded",
|
|
342
354
|
applied: card.cache,
|
|
343
355
|
vouched: cacheNative,
|
|
344
|
-
why: cacheNative
|
|
356
|
+
why: cacheNative
|
|
357
|
+
? ""
|
|
358
|
+
: markersUnplaced
|
|
359
|
+
? `no harness-side provider vouches for the "${card.block}" biller's cache markers; the rule goes out unvouched`
|
|
360
|
+
: `no layer names ${card.model}'s cache rule`,
|
|
345
361
|
});
|
|
346
362
|
|
|
347
363
|
return decisions;
|
|
@@ -37,6 +37,41 @@ export interface RenderedPointer extends Pointer {
|
|
|
37
37
|
anchor: RenderedPrAnchor;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** One reason a submitted description was refused, as the refusal and the
|
|
41
|
+
* `description_refused` run note carry it: the zod path (`risk`,
|
|
42
|
+
* `pointers.0.text`) and the schema's message; on a cap issue also the
|
|
43
|
+
* characters to remove (visible ones — raw for the title) and the longest
|
|
44
|
+
* prefix of the field that fits, cut at a word boundary (`fitToCap`), absent
|
|
45
|
+
* when no word of it fits. */
|
|
46
|
+
export interface DescriptionIssue {
|
|
47
|
+
path: string;
|
|
48
|
+
message: string;
|
|
49
|
+
remove?: number;
|
|
50
|
+
prefix?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The refused object as the `description_refused` note records it: JSON,
|
|
54
|
+
* nested at most four objects deep, and nothing else. The record crosses the
|
|
55
|
+
* run store's RPC boundary, whose typing walks every field: a field of
|
|
56
|
+
* `unknown` types the whole record `never`, and a recursive alias is
|
|
57
|
+
* "excessively deep" to it, so the nesting is spelled out level by level
|
|
58
|
+
* (as `RouteInputValue` is). A description is three deep at most —
|
|
59
|
+
* `pointers[i].anchor` — so one level is to spare; `recordedJson` stores a
|
|
60
|
+
* deeper value as its JSON text. */
|
|
61
|
+
export type RecordedJsonLeaf = string | number | boolean | null;
|
|
62
|
+
export type RecordedJsonObject1 = { readonly [key: string]: RecordedJsonLeaf | ReadonlyArray<RecordedJsonLeaf> };
|
|
63
|
+
export type RecordedJson1 =
|
|
64
|
+
RecordedJsonLeaf | RecordedJsonObject1 | ReadonlyArray<RecordedJsonLeaf | RecordedJsonObject1>;
|
|
65
|
+
export type RecordedJsonObject2 = { readonly [key: string]: RecordedJson1 };
|
|
66
|
+
export type RecordedJson2 =
|
|
67
|
+
RecordedJsonLeaf | RecordedJsonObject2 | ReadonlyArray<RecordedJsonLeaf | RecordedJsonObject2>;
|
|
68
|
+
export type RecordedJsonObject3 = { readonly [key: string]: RecordedJson2 };
|
|
69
|
+
export type RecordedJson3 =
|
|
70
|
+
RecordedJsonLeaf | RecordedJsonObject3 | ReadonlyArray<RecordedJsonLeaf | RecordedJsonObject3>;
|
|
71
|
+
export type RecordedJsonObject4 = { readonly [key: string]: RecordedJson3 };
|
|
72
|
+
export type RecordedJson =
|
|
73
|
+
RecordedJsonLeaf | RecordedJsonObject4 | ReadonlyArray<RecordedJsonLeaf | RecordedJsonObject4>;
|
|
74
|
+
|
|
40
75
|
/** The PR description as data (docs/decisions/0050): the map above the fold
|
|
41
76
|
* (tldr, why, pointers, feedbackWanted, risk, verified), every field capped
|
|
42
77
|
* so the map's size does not grow with the diff, and the collapsed half
|
|
@@ -95,9 +95,8 @@ export interface Provider {
|
|
|
95
95
|
|
|
96
96
|
/** The three wire shapes a provider block may declare (record 0052):
|
|
97
97
|
* Anthropic's Messages API, OpenAI's Chat Completions and OpenAI's Responses
|
|
98
|
-
* API.
|
|
99
|
-
*
|
|
100
|
-
* keeps routing through the compatible shape until then. */
|
|
98
|
+
* API. Each is a proxy route of its own (`PROXY_PATHS`): an `openai-responses`
|
|
99
|
+
* block runs on `/v1/responses`, pinned and metered like the other two. */
|
|
101
100
|
export const WIRES = ["anthropic-messages", "openai-chat", "openai-responses"] as const;
|
|
102
101
|
export type Wire = (typeof WIRES)[number];
|
|
103
102
|
|
|
@@ -123,6 +122,12 @@ export interface ProviderModelOverride {
|
|
|
123
122
|
cache?: "automatic" | "markers" | "none" | "unknown";
|
|
124
123
|
/** USD per million tokens, by kind. */
|
|
125
124
|
price?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
|
|
125
|
+
/** The answer shapes the model can produce for a forced one-call turn (the
|
|
126
|
+
* router's, intake's): `tool` — a forced tool call — and `text` — the
|
|
127
|
+
* one-JSON-object text contract. Absent, both are assumed; an empty list
|
|
128
|
+
* declares neither, and a load whose intake would classify on such a card
|
|
129
|
+
* is refused by name (routing-and-config item 27). */
|
|
130
|
+
answers?: ("tool" | "text")[];
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
export interface ProviderConfig {
|
|
@@ -204,6 +209,55 @@ export function vendorOf(
|
|
|
204
209
|
return { block, model, vendor: block, vendorId: model, vendorSource: "block" };
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
/** The harness-side provider a block's biller implies (record 0052's
|
|
213
|
+
* amendment: the harness write names the biller's own provider, never a
|
|
214
|
+
* generic alias). Keyed by the biller — the block's name — for the billers
|
|
215
|
+
* whose protocol a harness bundles a provider for. The wires with a package
|
|
216
|
+
* of their own (`anthropic-messages` → `@ai-sdk/anthropic`,
|
|
217
|
+
* `openai-responses` → `@ai-sdk/openai`) need no entry: the wire names the
|
|
218
|
+
* package. A chat-wire biller not here is served generically
|
|
219
|
+
* (`@ai-sdk/openai-compatible`), under which a `markers` cache rule cannot be
|
|
220
|
+
* vouched for (`decideControls`): the generic provider places no cache
|
|
221
|
+
* breakpoints. */
|
|
222
|
+
export interface BillerHarnessProvider {
|
|
223
|
+
/** The AI SDK package OpenCode's configuration names for the biller
|
|
224
|
+
* (`openCodeProviderPackage` adds the `aisdk:` prefix). */
|
|
225
|
+
openCodePackage: string;
|
|
226
|
+
/** The compat words pi keys on the biller's identity, copied once from pi's
|
|
227
|
+
* own completions detection of that biller and never inferred from a URL at
|
|
228
|
+
* run time: through the proxy pi sees the bot's URL, so `piModelsJson` must
|
|
229
|
+
* say the words the biller's own base URL would have made pi detect. */
|
|
230
|
+
piCompat: {
|
|
231
|
+
/** How the wire spells reasoning: `reasoning: { effort }` under
|
|
232
|
+
* `"openrouter"`, never the completions shape's flat `reasoning_effort`. */
|
|
233
|
+
thinkingFormat: string;
|
|
234
|
+
/** How a session id would ride the headers, were affinity ever turned on. */
|
|
235
|
+
sessionAffinityFormat: string;
|
|
236
|
+
/** The vendor-qualified id prefixes the biller grants the developer role:
|
|
237
|
+
* any other id is told `supportsDeveloperRole: false`, as pi's own
|
|
238
|
+
* detection would say against the biller directly. */
|
|
239
|
+
developerRoleIdPrefixes: readonly string[];
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The biller-to-provider table: one row per biller a harness speaks natively
|
|
244
|
+
* — OpenCode's package (U44) and pi's compat words (U45) side by side. */
|
|
245
|
+
export const BILLER_HARNESS_PROVIDERS: Readonly<Record<string, BillerHarnessProvider>> = {
|
|
246
|
+
openrouter: {
|
|
247
|
+
openCodePackage: "@openrouter/ai-sdk-provider",
|
|
248
|
+
piCompat: {
|
|
249
|
+
thinkingFormat: "openrouter",
|
|
250
|
+
sessionAffinityFormat: "openrouter",
|
|
251
|
+
developerRoleIdPrefixes: ["anthropic/", "openai/"],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** The biller's harness-side provider, or undefined when it is served generically. */
|
|
257
|
+
export function billerHarnessProvider(biller: string | undefined): BillerHarnessProvider | undefined {
|
|
258
|
+
return biller === undefined ? undefined : BILLER_HARNESS_PROVIDERS[biller];
|
|
259
|
+
}
|
|
260
|
+
|
|
207
261
|
/** The env var Anthropic's own SDK reads when an `anthropic` provider block names none. */
|
|
208
262
|
export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
|
|
209
263
|
|
|
@@ -22,6 +22,17 @@ export interface Guess {
|
|
|
22
22
|
evidence: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** A command handler's guess hint (record 0054): the corrected chat form and
|
|
26
|
+
* the evidence, without the full proposal — the proposal is synthesised at
|
|
27
|
+
* the invocation point from the original message and this hint's `line`. The
|
|
28
|
+
* handler carries this lighter type on `CommandError.guess`; the adapter
|
|
29
|
+
* (`invokeChatCommand`, `answerCommand`) builds the `Guess` that
|
|
30
|
+
* `renderRefusal` expects. */
|
|
31
|
+
export interface CommandGuessHint {
|
|
32
|
+
line: string;
|
|
33
|
+
evidence: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
/** One cause per code — the closed table `causeOf` reads. The codes are the
|
|
26
37
|
* inventory's: the dispatch gates' `refuse(code)` outcomes, the click's four
|
|
27
38
|
* `confirmation_*` codes, the two `elsewhere_*` reasons that never reached a
|
|
@@ -45,6 +56,8 @@ const CAUSE_OF = {
|
|
|
45
56
|
which_branch: "request",
|
|
46
57
|
workspace_lost: "system",
|
|
47
58
|
ship_budget: "request",
|
|
59
|
+
// one pipeline per thread (record 0060): the host key's claim answered thread-live
|
|
60
|
+
ship_thread_live: "request",
|
|
48
61
|
setup_failed: "system",
|
|
49
62
|
// the click on a confirmation (confirm.ts)
|
|
50
63
|
confirmation_used: "request",
|
|
@@ -95,6 +108,7 @@ const CAUSE_OF = {
|
|
|
95
108
|
directive_budget: "request",
|
|
96
109
|
directive_severity: "request",
|
|
97
110
|
directive_renewals: "request",
|
|
111
|
+
directive_verbosity: "request",
|
|
98
112
|
provider_unknown: "request",
|
|
99
113
|
// the model card's refusal (record 0052, model-proxy item 11): a control the
|
|
100
114
|
// resolved card does not take, named before any card or model call
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Types only, and from the zod-free module deliberately: this file is part of
|
|
2
2
|
// the node-free contract the memory Worker and web app compile with their own
|
|
3
3
|
// tsconfigs — importing prDescription.ts would drag zod into those graphs.
|
|
4
|
-
import type { PrDescription, RenderedPointer } from "./prDescriptionTypes.js";
|
|
4
|
+
import type { DescriptionIssue, PrDescription, RecordedJson, RenderedPointer } from "./prDescriptionTypes.js";
|
|
5
5
|
import type { HarnessScope } from "./harness/scope.js";
|
|
6
6
|
import type { ModelCard } from "./modelCard.js";
|
|
7
7
|
|
|
@@ -146,6 +146,15 @@ export type RunNoteKind =
|
|
|
146
146
|
* bounded extra model turn to submit it (docs/reference/specs/pr-description.md
|
|
147
147
|
* item 5). Published by the dispatcher before that turn. */
|
|
148
148
|
| "description_turn"
|
|
149
|
+
/** `submit_pr_description` refused the object (docs/reference/specs/pr-description.md
|
|
150
|
+
* item 5): the summary counts the fields over their cap and names them
|
|
151
|
+
* (or the issues, when none is a cap), `description` is the object as
|
|
152
|
+
* submitted — redacted like the accepted `pr_description` event's, and not
|
|
153
|
+
* necessarily a valid `PrDescription` — and `issues` the refusal's list
|
|
154
|
+
* with each cap's count to remove and the prefix that fits, so the record
|
|
155
|
+
* says what the model changed between one submit and the next. Published
|
|
156
|
+
* by the tool, before it answers. */
|
|
157
|
+
| "description_refused"
|
|
149
158
|
/** A review run's loop ended on a pull request without `submit_verdict`, and
|
|
150
159
|
* the same run is being given one bounded extra model turn to call it
|
|
151
160
|
* (docs/reference/specs/agent-review.md item 5; verdictTurn.ts). Published by
|
|
@@ -273,7 +282,15 @@ export type RunNoteKind =
|
|
|
273
282
|
/** The native loop's stuck-loop guard: the same tool call failed identically
|
|
274
283
|
* six times in a row and the run was forced into its write-up. Written by
|
|
275
284
|
* no loop since that loop's deletion; a record from before it may carry it. */
|
|
276
|
-
| "stuck_loop"
|
|
285
|
+
| "stuck_loop"
|
|
286
|
+
/** OpenCode's reject cascade ended the execution `interrupted` after the bot
|
|
287
|
+
* refused one of a step's two (or more) calls — the binary declines every
|
|
288
|
+
* other pending ask at a reject and ends the step `session.step.failed
|
|
289
|
+
* {aborted}`, the execution ending `session.execution.interrupted` — and the
|
|
290
|
+
* model never read the refusal. The loop re-prompts with the refusal so the
|
|
291
|
+
* model can continue, exactly as it does after a single refusal. Published
|
|
292
|
+
* by the OpenCode bridge. */
|
|
293
|
+
| "decline_cascade";
|
|
277
294
|
|
|
278
295
|
/** Every `RunNoteKind`, as a value (a reader that filters notes by kind uses
|
|
279
296
|
* this; adding a kind to the union without adding it here is a type error). */
|
|
@@ -298,6 +315,7 @@ export const RUN_NOTE_KINDS = [
|
|
|
298
315
|
"seed",
|
|
299
316
|
"redispatch",
|
|
300
317
|
"description_turn",
|
|
318
|
+
"description_refused",
|
|
301
319
|
"verdict_turn",
|
|
302
320
|
"cold_sandbox",
|
|
303
321
|
"ledger_untracked",
|
|
@@ -316,6 +334,7 @@ export const RUN_NOTE_KINDS = [
|
|
|
316
334
|
"directory_reached",
|
|
317
335
|
"budget_salvage",
|
|
318
336
|
"stuck_loop",
|
|
337
|
+
"decline_cascade",
|
|
319
338
|
] as const satisfies readonly RunNoteKind[];
|
|
320
339
|
type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]] ? true : never;
|
|
321
340
|
const _everyKindListed: _EveryKindListed = true;
|
|
@@ -566,6 +585,12 @@ export type RunEvent =
|
|
|
566
585
|
* interval the dropped setup records covered — a `not recorded` loss. */
|
|
567
586
|
from?: number;
|
|
568
587
|
to?: number;
|
|
588
|
+
/** On a `description_refused` note only: the refused object as submitted
|
|
589
|
+
* (JSON, not necessarily a valid `PrDescription`; `RecordedJson` says
|
|
590
|
+
* why it is typed level by level), every string leaf redacted, and the
|
|
591
|
+
* issues the refusal named. */
|
|
592
|
+
description?: RecordedJson;
|
|
593
|
+
issues?: DescriptionIssue[];
|
|
569
594
|
spanId?: string;
|
|
570
595
|
seq?: number;
|
|
571
596
|
at?: number;
|
|
@@ -600,6 +625,13 @@ export type RunEvent =
|
|
|
600
625
|
* run sent rather than a person (a parent's `send_to_run`), that run's
|
|
601
626
|
* id (docs/reference/specs/agent-conductor.md item 8). */
|
|
602
627
|
source?: { url?: string; channel?: string; user?: string; run?: string };
|
|
628
|
+
/** How the turn was delivered when it consumed a unit's thread events
|
|
629
|
+
* (record 0051's mode-as-receipt rule): the mode read off the owner's state when
|
|
630
|
+
* each event arrived — a receipt, never a switch — and the sequence
|
|
631
|
+
* numbers consumed, so the record names the events it folded. Absent on
|
|
632
|
+
* every run that consumed none. */
|
|
633
|
+
mode?: "steer" | "wake" | "interrupt";
|
|
634
|
+
consumed?: number[];
|
|
603
635
|
seq?: number;
|
|
604
636
|
at?: number;
|
|
605
637
|
}
|
|
@@ -826,6 +858,13 @@ export type RunEvent =
|
|
|
826
858
|
* `base` is absent when the spawn knew none; the post-step then falls to
|
|
827
859
|
* the coordinator store's `instance.base`. Additive: unknown → ignored. */
|
|
828
860
|
| { type: "coordinator_tag"; parentInstanceId: string; unit?: string; base?: string; seq?: number; at?: number }
|
|
861
|
+
/** The plan runner instance a ship run's hand-off created (record 0051 R2;
|
|
862
|
+
* docs/reference/specs/run-history.md item 2): published by the ship branch
|
|
863
|
+
* after `handOffToCoordinator` succeeds, straight to the registry like
|
|
864
|
+
* `pr_opened`, and projected onto `RunRecord.instanceId` the way
|
|
865
|
+
* `coordinator_tag` is — so the thread's owner rule can find the instance
|
|
866
|
+
* from the page's ship run. Additive: unknown → ignored. */
|
|
867
|
+
| { type: "ship_handoff"; instanceId: string; seq?: number; at?: number }
|
|
829
868
|
/** The review post-step's outcome when the verdict landed
|
|
830
869
|
* (docs/reference/specs/agent-review.md item 18): the pull request it was
|
|
831
870
|
* posted to, the head it was pinned to (the carried head after a rebase,
|
|
@@ -915,6 +954,14 @@ export type RunEvent =
|
|
|
915
954
|
seq?: number;
|
|
916
955
|
at?: number;
|
|
917
956
|
}
|
|
957
|
+
/** A refusal the door made ([record 0054](../../docs/decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md),
|
|
958
|
+
* as amended: every refusal is a run record; run-history.md item 2): the
|
|
959
|
+
* code, its one cause, and the sentence the person read — redacted and
|
|
960
|
+
* capped like a route receipt (`ROUTE_RECEIPT_CAP`). Exactly one per `door`
|
|
961
|
+
* record, published by `recordRefusal` beside the redacted request, so the
|
|
962
|
+
* door report counts every refusal — a gate refusal before any command is
|
|
963
|
+
* bound included — from the run store alone. Additive: unknown → ignored. */
|
|
964
|
+
| { type: "refusal"; code: string; cause: string; text: string; seq?: number; at?: number }
|
|
918
965
|
/** The span records (docs/reference/specs/tracing.md): published, counted and stored like
|
|
919
966
|
* every other event, read as timing and never as content. */
|
|
920
967
|
| SpanStartEvent
|
|
@@ -422,9 +422,11 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
|
|
|
422
422
|
ev.type === "pr_description" ||
|
|
423
423
|
ev.type === "pr_opened" ||
|
|
424
424
|
ev.type === "coordinator_tag" ||
|
|
425
|
+
ev.type === "ship_handoff" ||
|
|
425
426
|
ev.type === "review_posted" ||
|
|
426
427
|
ev.type === "ship_round" ||
|
|
427
428
|
ev.type === "route" ||
|
|
429
|
+
ev.type === "refusal" ||
|
|
428
430
|
ev.type === "reference" ||
|
|
429
431
|
ev.type === "lease" ||
|
|
430
432
|
ev.type === "pushed_head"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Durable Object applies them inside one transaction; the in-memory ledger
|
|
3
3
|
// applies them in tests; both agree because this is the only copy.
|
|
4
4
|
|
|
5
|
-
import type { ClaimResult, FenceResult, LivePhase } from "./types.js";
|
|
5
|
+
import type { ClaimResult, FenceResult, IntakeReceipt, IntakeWriteResult, LivePhase } from "./types.js";
|
|
6
6
|
|
|
7
7
|
/** One live run per thread. The existing row, if any, is what `live_runs` holds
|
|
8
8
|
* for the thread; the same run re-claimed by its owner is idempotent (a retry
|
|
@@ -26,6 +26,14 @@ export function decideClaim(
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** First writer wins on an intake receipt (item 59): the existing row, if
|
|
30
|
+
* any, is what `intake_receipts` holds for the key; only when none stands
|
|
31
|
+
* does this write land, and every caller acts on the STORED row. */
|
|
32
|
+
export function decideIntakeInsert(existing: IntakeReceipt | undefined, receipt: IntakeReceipt): IntakeWriteResult {
|
|
33
|
+
if (existing) return { inserted: false, stored: existing };
|
|
34
|
+
return { inserted: true, stored: receipt };
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
/** What an accepted claim does to the thread's row (item 42). `insert`: no row.
|
|
30
38
|
* `promote`: the owner's claim WITH a prompt on its own `attaching` row — the
|
|
31
39
|
* prompt, tools, card and state land and the phase goes `live`, identity and
|
|
@@ -84,6 +84,16 @@ export function roleOfStoredRow(json: string): "user" | "assistant" | undefined
|
|
|
84
84
|
return role === "user" || role === "assistant" ? role : undefined;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** The platform-namespaced id of the person who authored the turn a stored
|
|
88
|
+
* row belongs to (e.g. `slack:U…`); absent for machine turns, compaction
|
|
89
|
+
* rows, unreadable rows, and rows written before record 0057. */
|
|
90
|
+
export function actorOfStoredRow(json: string): string | undefined {
|
|
91
|
+
const stored = parseStored(json);
|
|
92
|
+
if (!stored || "compaction" in stored) return undefined;
|
|
93
|
+
const actor = (stored as { actor?: unknown }).actor;
|
|
94
|
+
return typeof actor === "string" ? actor : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
/** The notepad's size (record 0035, "The notepad"): one document per session,
|
|
88
98
|
* written whole, at most this many UTF-8 bytes; `notes` refuses over it naming
|
|
89
99
|
* the size, and the object's write route does too. */
|
|
@@ -23,10 +23,13 @@ import {
|
|
|
23
23
|
// ~4.2 MB on the wire.
|
|
24
24
|
|
|
25
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.
|
|
26
|
+
* is reconstructible from its rows alone. The actor is the platform-namespaced
|
|
27
|
+
* id of the person who authored the turn (e.g. `slack:U…`); absent for
|
|
28
|
+
* machine turns and rows written before record 0057. */
|
|
27
29
|
export interface StoredPart {
|
|
28
30
|
role: ChatMessage["role"];
|
|
29
31
|
part: ContentPart | (ContentPart & { dataRef: string });
|
|
32
|
+
actor?: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/** The stored form of a compaction row: no role, no part — the entry alone. */
|
|
@@ -46,11 +49,14 @@ const isCompaction = (v: ChatMessage | StoredCompaction): v is StoredCompaction
|
|
|
46
49
|
|
|
47
50
|
/** One turn → its rows (and any externalized attachments). Refuses a part the
|
|
48
51
|
* row budget cannot hold: a transcript is never truncated. A compaction entry
|
|
49
|
-
* is one row at its index, part 0.
|
|
52
|
+
* is one row at its index, part 0. The optional `actor` is the
|
|
53
|
+
* platform-namespaced id of the person who authored the turn (absent for
|
|
54
|
+
* machine turns, compaction rows and rows written before record 0057). */
|
|
50
55
|
export function turnRows(
|
|
51
56
|
idx: number,
|
|
52
57
|
message: ChatMessage | StoredCompaction,
|
|
53
58
|
opts: { partBytes?: number; attachmentRefBytes?: number } = {},
|
|
59
|
+
actor?: string,
|
|
54
60
|
): { rows: TranscriptRow[]; attachments: TranscriptAttachment[] } {
|
|
55
61
|
const partBytes = opts.partBytes ?? TRANSCRIPT_PART_BYTES;
|
|
56
62
|
const refBytes = opts.attachmentRefBytes ?? ATTACHMENT_REF_BYTES;
|
|
@@ -73,7 +79,11 @@ export function turnRows(
|
|
|
73
79
|
attachments.push({ ref, mediaType: part.mediaType, data: part.data });
|
|
74
80
|
stored = { ...(part as ContentPart), data: "", dataRef: ref } as StoredPart["part"];
|
|
75
81
|
}
|
|
76
|
-
const json = JSON.stringify({
|
|
82
|
+
const json = JSON.stringify({
|
|
83
|
+
role: message.role,
|
|
84
|
+
part: stored,
|
|
85
|
+
...(actor !== undefined ? { actor } : {}),
|
|
86
|
+
} satisfies StoredPart);
|
|
77
87
|
const bytes = utf8ByteLength(json);
|
|
78
88
|
if (bytes > partBytes) {
|
|
79
89
|
throw new Error(`transcript: part ${i} of turn ${idx} is ${bytes} bytes, over the ${partBytes} row budget`);
|