@coreplane/switchboard 1.217.0 → 1.219.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.
Files changed (25) hide show
  1. package/dist/assets/config/config.example.yaml +6 -0
  2. package/dist/assets/deploy/cloudflare/worker.ts +4 -0
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +96 -6
  4. package/dist/assets/deploy/secrets.manifest.json +12 -0
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +20 -0
  9. package/dist/assets/src/core/runEvents.ts +6 -0
  10. package/dist/assets/src/core/runFriction.ts +40 -3
  11. package/dist/assets/src/core/runLedger/sessionLog.ts +22 -0
  12. package/dist/assets/src/core/runLedger/types.ts +17 -0
  13. package/dist/assets/src/mcp/registry.ts +33 -5
  14. package/dist/assets/web/dist/.vite/manifest.json +18 -18
  15. package/dist/assets/web/dist/assets/{ResidentDetailPage-CWyEd5fq.js → ResidentDetailPage-DvCt96Dk.js} +1 -1
  16. package/dist/assets/web/dist/assets/{ResidentsIndexPage-DqFqRTWb.js → ResidentsIndexPage-BZaYja1-.js} +1 -1
  17. package/dist/assets/web/dist/assets/{RunRoutePage-CghwVr_X.js → RunRoutePage-DI4vzCUu.js} +4 -4
  18. package/dist/assets/web/dist/assets/{RunsIndexPage-BcYehvW5.js → RunsIndexPage-Ch9uP4p4.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ScheduledPage-mKa20Qdd.js → ScheduledPage-DAkAHMCH.js} +1 -1
  20. package/dist/assets/web/dist/assets/{StatusDot-QMofJG-E.js → StatusDot-CfBonGcV.js} +1 -1
  21. package/dist/assets/web/dist/assets/{Tooltip-D-zVPexc.js → Tooltip-CBD36E3W.js} +1 -1
  22. package/dist/assets/web/dist/assets/{dist-D43zKxNV.js → dist-DQ1U73LF.js} +1 -1
  23. package/dist/assets/web/dist/assets/{main-ClTMtz8n.js → main-CdLnG-yl.js} +2 -2
  24. package/dist/cli.js +522 -120
  25. package/package.json +1 -1
@@ -473,6 +473,12 @@ workspaceDir: ./workspaces
473
473
  # auth: bearer
474
474
  # tokenEnv: MCP_GITHUB_TOKEN # static bearer from the bot env (startup fails if unset)
475
475
  # agents: [general, research, coding] # default [general, research]
476
+ # lake: # a server behind Cloudflare Access: the gate wants a service token,
477
+ # url: https://vega.example.com/mcp # not an Authorization header — name the two headers and the bot
478
+ # auth: none # env vars holding them (manifest secrets on Cloudflare); an unset
479
+ # headersEnv: # one makes the server `unavailable` for that run, naming it
480
+ # CF-Access-Client-Id: MCP_ACCESS_CLIENT_ID
481
+ # CF-Access-Client-Secret: MCP_ACCESS_CLIENT_SECRET
476
482
  # channels:
477
483
  # "slack:C0123":
478
484
  # mcpServers:
@@ -90,6 +90,8 @@ export interface Env {
90
90
  ANTHROPIC_ADMIN_KEY?: string; // costs dash (optional): Anthropic Admin API key for the LLM cost report
91
91
  MEMORY_TOKEN?: string; // durable memory + friction ledger + schedule firings + MCP registry: bearer for the state Worker
92
92
  MCP_CREDENTIAL_KEY?: string; // MCP registry: the bot-only key that seals server credentials before they reach the McpDO
93
+ MCP_ACCESS_CLIENT_ID?: string; // MCP servers behind Cloudflare Access: the service token's client id, named by a server's `headersEnv`
94
+ MCP_ACCESS_CLIENT_SECRET?: string; // MCP servers behind Cloudflare Access: the service token's client secret, named by a server's `headersEnv`
93
95
  STATE_WORKER_URL?: string; // var: the state Worker's base URL — where this shim records each scheduled firing
94
96
  ARTIFACTS_R2_ACCESS_KEY_ID?: string; // artifact store: the bucket-scoped S3 token the bot signs presigned URLs with
95
97
  ARTIFACTS_R2_SECRET_ACCESS_KEY?: string; // artifact store: the secret half of that token
@@ -124,6 +126,8 @@ const FORWARDED_OPTIONAL = [
124
126
  "BRAVE_SEARCH_API_KEY",
125
127
  "MEMORY_TOKEN",
126
128
  "MCP_CREDENTIAL_KEY",
129
+ "MCP_ACCESS_CLIENT_ID",
130
+ "MCP_ACCESS_CLIENT_SECRET",
127
131
  "STATE_WORKER_URL",
128
132
  "ARTIFACTS_R2_ACCESS_KEY_ID",
129
133
  "ARTIFACTS_R2_SECRET_ACCESS_KEY",
@@ -46,7 +46,10 @@ import {
46
46
  attachmentRefsOf,
47
47
  DEFAULT_SESSION_LOG_MAX_BYTES,
48
48
  droppedToolResultRow,
49
+ GAP_MARKER,
50
+ NOTEPAD_MAX_BYTES,
49
51
  planSessionTrim,
52
+ roleOfStoredRow,
50
53
  rowKind,
51
54
  sessionsToDrop,
52
55
  tailCut,
@@ -80,6 +83,7 @@ import {
80
83
  type LiveRunRow,
81
84
  type ReclaimedRun,
82
85
  type RunState,
86
+ type SessionHit,
83
87
  type StepRecord,
84
88
  type StopMode,
85
89
  type TranscriptAttachment,
@@ -210,6 +214,10 @@ const FTS_CANDIDATES_FLOOR = 50;
210
214
  * tokens and are untouched; the engine still ranks with the FULL query, so the
211
215
  * cap only shapes which rows can become candidates. */
212
216
  const MAX_MATCH_TOKENS = 24;
217
+ /** A `recall` query's size and the most hits one answers (session-log item 10):
218
+ * a query is a few words, and the tool's default is five. */
219
+ const MAX_SEARCH_QUERY_BYTES = 1_024;
220
+ const MAX_SEARCH_HITS = 50;
213
221
  /** Request body ceiling, checked against Content-Length before parsing. A full
214
222
  * batch (50 × 4000-char texts + keywords + envelope) fits comfortably. */
215
223
  const MAX_BODY_BYTES = 512 * 1024;
@@ -2892,18 +2900,65 @@ export class SessionLogDO extends DurableObject<Env> {
2892
2900
  return { ...(await this.read(from)), from };
2893
2901
  }
2894
2902
 
2895
- /** The rows whose text matches `query`, newest first. */
2896
- async search(query: string, limit: number): Promise<Array<{ idx: number; part: number; text: string }>> {
2903
+ /** The rows whose text matches `query`, in relevance order (FTS5's bm25 rank,
2904
+ * the order the memory store's search uses; the newest first among equals),
2905
+ * each with its turn, part, role, kind and indexed text — what `recall`
2906
+ * answers (session-log item 10). */
2907
+ async search(query: string, limit: number): Promise<SessionHit[]> {
2897
2908
  const match = ftsMatchExpr(query);
2898
2909
  if (match === null) return [];
2899
2910
  return this.sql
2900
- .exec<{ idx: number; part: number; text: string }>(
2901
- `SELECT t.idx, t.part, t.text FROM turns_fts f JOIN turns t ON t.id = f.rowid
2902
- WHERE turns_fts MATCH ? ORDER BY t.idx DESC, t.part DESC LIMIT ?`,
2911
+ .exec<{ idx: number; part: number; kind: string; json: string; text: string }>(
2912
+ `SELECT t.idx, t.part, t.kind, t.json, t.text FROM turns_fts f JOIN turns t ON t.id = f.rowid
2913
+ WHERE turns_fts MATCH ? ORDER BY f.rank, t.idx DESC, t.part DESC LIMIT ?`,
2903
2914
  match,
2904
2915
  limit,
2905
2916
  )
2906
- .toArray();
2917
+ .toArray()
2918
+ .map(({ idx, part, kind, json, text }) => {
2919
+ const role = roleOfStoredRow(json);
2920
+ return { idx, part, ...(role !== undefined ? { role } : {}), kind, text };
2921
+ });
2922
+ }
2923
+
2924
+ /** The gap markers (session-log item 9) whose turn lies in `[from, to]`: the
2925
+ * rows a follow-up appended because the run before it detached, so a search
2926
+ * whose hits straddle one can say the log ends short between them. */
2927
+ async gapsBetween(from: number, to: number): Promise<number[]> {
2928
+ return this.sql
2929
+ .exec<{ idx: number }>(
2930
+ `SELECT DISTINCT idx FROM turns WHERE idx >= ? AND idx <= ? AND kind = 'text' AND text = ? ORDER BY idx`,
2931
+ from,
2932
+ to,
2933
+ GAP_MARKER,
2934
+ )
2935
+ .toArray()
2936
+ .map((r) => r.idx);
2937
+ }
2938
+
2939
+ /** The notepad, replaced whole by the live run (session-log item 10): the
2940
+ * same fence as a row write — unknown-run before an owner, fenced for
2941
+ * another generation — and the write's time kept beside the text. */
2942
+ async writeNotepad(gen: string, text: string, now: number): Promise<FenceResult> {
2943
+ let out: FenceResult = { ok: true };
2944
+ this.ctx.storage.transactionSync(() => {
2945
+ const owner = this.owner();
2946
+ if (owner === undefined) {
2947
+ out = { ok: false, reason: "unknown-run" };
2948
+ return;
2949
+ }
2950
+ if (owner.gen !== gen) {
2951
+ out = { ok: false, reason: "fenced" };
2952
+ return;
2953
+ }
2954
+ this.sql.exec(
2955
+ `INSERT INTO notepad (k, text, updated_at) VALUES (1, ?, ?)
2956
+ ON CONFLICT(k) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at`,
2957
+ text,
2958
+ now,
2959
+ );
2960
+ });
2961
+ return out;
2907
2962
  }
2908
2963
 
2909
2964
  async bytes(): Promise<number> {
@@ -2968,6 +3023,9 @@ const LEDGER_ROUTES = new Set([
2968
3023
  "/runs/session/read",
2969
3024
  "/runs/session/read-tail",
2970
3025
  "/runs/session/clear-owner",
3026
+ "/runs/session/search",
3027
+ "/runs/session/notepad",
3028
+ "/runs/session/notepad/write",
2971
3029
  ]);
2972
3030
 
2973
3031
  /** Routes whose bodies may carry a record, a transcript chunk, or an event batch. */
@@ -3187,6 +3245,38 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
3187
3245
  if (!g.ok) return json({ error: g.error }, 400);
3188
3246
  return fenced(await stub.clearOwner(runId.value, g.value));
3189
3247
  }
3248
+ // `recall` (session-log item 10): the hits in relevance order, and the gap
3249
+ // markers that lie between the oldest and the newest of them.
3250
+ if (pathname === "/runs/session/search") {
3251
+ if (
3252
+ typeof b.query !== "string" ||
3253
+ b.query.trim().length === 0 ||
3254
+ utf8ByteLength(b.query) > MAX_SEARCH_QUERY_BYTES
3255
+ )
3256
+ return json({ error: `query must be a non-empty string of at most ${MAX_SEARCH_QUERY_BYTES} bytes` }, 400);
3257
+ const limit = b.limit;
3258
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_HITS)
3259
+ return json({ error: `limit must be an integer in 1..${MAX_SEARCH_HITS}` }, 400);
3260
+ const hits = await stub.search(b.query, limit);
3261
+ const gaps =
3262
+ hits.length > 1
3263
+ ? await stub.gapsBetween(Math.min(...hits.map((h) => h.idx)), Math.max(...hits.map((h) => h.idx)))
3264
+ : [];
3265
+ console.log(`[runs/session/search] ${key.value} -> ${hits.length} hit(s), ${gaps.length} gap(s)`);
3266
+ return json({ hits, gaps });
3267
+ }
3268
+ if (pathname === "/runs/session/notepad") return json({ notepad: await stub.notepad() });
3269
+ if (pathname === "/runs/session/notepad/write") {
3270
+ const g = gen(b.gen);
3271
+ if (!g.ok) return json({ error: g.error }, 400);
3272
+ if (typeof b.text !== "string") return json({ error: "text must be a string" }, 400);
3273
+ const bytes = utf8ByteLength(b.text);
3274
+ if (bytes > NOTEPAD_MAX_BYTES)
3275
+ return json({ error: `text is ${bytes} bytes; the notepad holds at most ${NOTEPAD_MAX_BYTES}` }, 400);
3276
+ const r = await stub.writeNotepad(g.value, b.text, systemClock());
3277
+ console.log(`[runs/session/notepad/write] ${key.value} <- ${bytes} byte(s), ok=${r.ok}`);
3278
+ return fenced(r);
3279
+ }
3190
3280
  return json({ error: "not found" }, 404);
3191
3281
  }
3192
3282
 
@@ -99,6 +99,18 @@
99
99
  "optional": true,
100
100
  "note": "AES-256-GCM key (32 bytes, base64: `openssl rand -base64 32`) the bot seals MCP server credentials with before they go to the state Worker's McpDO (docs/reference/specs/mcp-tools.md). Bot-only by design: the Worker stores ciphertext. Rotation re-seals every credential — not built yet; rotate = users re-run `mcp connect`."
101
101
  },
102
+ {
103
+ "name": "MCP_ACCESS_CLIENT_ID",
104
+ "workers": ["bot"],
105
+ "optional": true,
106
+ "note": "Client id of the Cloudflare Access service token the bot presents to MCP servers behind Access — a pinned server's `headersEnv` names it under `CF-Access-Client-Id` (docs/reference/specs/mcp-tools.md item 11). Minted in Zero Trust → Access → Service Auth of the account that guards the server, and enrolled on that Access application's policy. Optional: without it such a server is `unavailable` naming the variable."
107
+ },
108
+ {
109
+ "name": "MCP_ACCESS_CLIENT_SECRET",
110
+ "workers": ["bot"],
111
+ "optional": true,
112
+ "note": "Client secret of that service token — `headersEnv` names it under `CF-Access-Client-Secret`. Shown once at creation; rotate by minting a new token, `deploy secrets bot --only` both names, `deploy restart`, then revoke the old one."
113
+ },
102
114
  {
103
115
  "name": "MEMORY_TOKEN",
104
116
  "workers": ["bot", "resident", "memory"],
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.217.0",
3
+ "version": "1.219.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.217.0",
9
+ "version": "1.219.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -16710,7 +16710,7 @@
16710
16710
  },
16711
16711
  "packages/switchboard": {
16712
16712
  "name": "@coreplane/switchboard",
16713
- "version": "1.217.0",
16713
+ "version": "1.219.0",
16714
16714
  "license": "Apache-2.0",
16715
16715
  "dependencies": {
16716
16716
  "@anthropic-ai/sdk": "^0.124.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.217.0",
3
+ "version": "1.219.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.217.0",
3
- "commit": "9007d5664a8f07d092a33dbf373f5377577adc36",
4
- "builtAt": "2026-09-15T00:05:36.819Z"
2
+ "version": "1.219.0",
3
+ "commit": "584d81012dc5119229ec5e4ea04a304e3eceb450",
4
+ "builtAt": "2026-09-15T01:38:10.165Z"
5
5
  }
@@ -202,6 +202,14 @@ Whole files: up to 1 GiB where the artifact store is configured (a recording, a
202
202
  Files the person dropped on the thread that were too large to show you inline are already in ./attachments/ in your workspace when the turn's text names them (a video for ffmpeg, a large PDF, a zip); read them from there — never ask for a re-upload.
203
203
  Text stays in your message; do not attach what you can say.`;
204
204
 
205
+ // Every prompt that can run on the pi harness carries this verbatim
206
+ // (docs/reference/specs/session-log.md item 10; record 0035, "The notepad"): what
207
+ // belongs in the agent's notes for the thread, and why — the one thing sure to
208
+ // survive a compaction and reach the next run there — beside the reach `recall`
209
+ // gives into every earlier turn. Said once so the prompts cannot drift on it.
210
+ const NOTEPAD = `YOUR NOTES AND YOUR REACH BACK. This thread's conversation outlives your context window and this run: every turn — yours, the person's, every tool call and its output, from this run and the runs before it in this thread — is kept in a log you can search with the \`recall\` tool (words → the matching turns with their numbers; a turn number → that turn whole). When something you need is no longer in front of you, recall it instead of redoing the work or guessing.
211
+ Keep notes with the \`notes\` tool: one short document, replaced whole each time, at most 8 KiB — decisions and their reasons, the names of things you found (files, tests, commits, the head your tests were green at), what is not yet proven. They are the one thing sure to survive a compaction and to reach the next run in this thread: they ride your system prompt at its start and come back to you right after a compaction. Write them when you decide something worth keeping, not only at the end.`;
212
+
205
213
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
206
214
 
207
215
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -231,6 +239,8 @@ ${PR_DESCRIPTION_TEMPLATE}
231
239
 
232
240
  ${SHOW_FILES}
233
241
 
242
+ ${NOTEPAD}
243
+
234
244
  Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
235
245
 
236
246
  If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
@@ -271,6 +281,8 @@ ${PR_DESCRIPTION_TEMPLATE}
271
281
 
272
282
  ${SHOW_FILES}
273
283
 
284
+ ${NOTEPAD}
285
+
274
286
  Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
275
287
 
276
288
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
@@ -333,6 +345,8 @@ REVIEW THE PR'S OWN HEAD, NOTHING ELSE: the commit you read must be the PR's hea
333
345
 
334
346
  ${REVIEW_VERDICT_INSTRUCTION}
335
347
 
348
+ ${NOTEPAD}
349
+
336
350
  Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
337
351
 
338
352
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -363,6 +377,8 @@ REVIEW THE PR'S OWN HEAD, NOTHING ELSE: the commit you read must be the PR's hea
363
377
 
364
378
  ${REVIEW_VERDICT_INSTRUCTION}
365
379
 
380
+ ${NOTEPAD}
381
+
366
382
  Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
367
383
 
368
384
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -419,8 +435,12 @@ TIME. Your budget is up to two hours — less when a boundary or the request's \
419
435
 
420
436
  READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no \`gh pr create\`, no PR or issue write of any kind. You hold a read credential and your job is to find out, not to change. If the investigation shows a change is needed, say exactly what and where in your write-up and point the user at \`agent:coding\`.
421
437
 
438
+ You cannot attach or post files: your whole answer is text. Never say a file is attached or below — name its path in the workspace and describe it (what it shows, its size) instead; a person who needs the file itself asks \`agent:coding\`, which can attach.
439
+
422
440
  Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending) once you have it, and update items as they start (✱) and finish (✓ — only after they actually happened). Items are short outcomes ("Clone and install", "Time the full suite"), never commands.
423
441
 
442
+ ${NOTEPAD}
443
+
424
444
  Report outcomes faithfully: a check you could not run is "could not check", never a guess. Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks — render the claim table as aligned rows inside a code block). Your final message is posted to Slack: lead with the overall verdict in one line, then the claim table, then what a follow-up should do.`;
425
445
 
426
446
  // The conductor (docs/reference/specs/agent-conductor.md): a run that starts
@@ -387,6 +387,12 @@ export type RunEvent =
387
387
  * `input`, bounded (newest 20 turns / 256 KiB) and gated by
388
388
  * `runHistory.includeContext`. */
389
389
  | { type: "context"; text: string; seq?: number; at?: number }
390
+ /** The agent's notes for this thread as the `notes` tool last wrote them
391
+ * (docs/reference/specs/session-log.md item 10; record 0035, "The notepad"):
392
+ * the whole notepad, at most `NOTEPAD_MAX_BYTES`, so the record shows what
393
+ * the next run of the session starts from. Published by the tool on every
394
+ * write; the record's last one is the final text. */
395
+ | { type: "notes"; text: string; spanId?: string; seq?: number; at?: number }
390
396
  /** The model's prose BETWEEN tool calls — text content that rode alongside
391
397
  * tool_use in one completion. Emitted by the runner, redacted, uncapped. The
392
398
  * final text-only completion is NOT one of these (that is the `answer`). */
@@ -38,7 +38,8 @@ export type FrictionCategory =
38
38
  | "setup_install"
39
39
  | "wrap_up"
40
40
  | "budget_hit"
41
- | "infra_failure";
41
+ | "infra_failure"
42
+ | "unkept_promise";
42
43
 
43
44
  export const FRICTION_CATEGORIES: readonly FrictionCategory[] = [
44
45
  "slow_tool",
@@ -49,6 +50,7 @@ export const FRICTION_CATEGORIES: readonly FrictionCategory[] = [
49
50
  "wrap_up",
50
51
  "budget_hit",
51
52
  "infra_failure",
53
+ "unkept_promise",
52
54
  ];
53
55
 
54
56
  /** Human labels — the verdict line, the report's table and its finding lines. */
@@ -61,6 +63,7 @@ export const CATEGORY_LABEL: Record<FrictionCategory, string> = {
61
63
  wrap_up: "agent wind-down",
62
64
  budget_hit: "budget hits",
63
65
  infra_failure: "infra failures",
66
+ unkept_promise: "unkept promises",
64
67
  };
65
68
 
66
69
  /** What a category's time is a share OF: tool time for the categories whose
@@ -75,8 +78,17 @@ export const DENOMINATOR_OF: Record<FrictionCategory, "tool" | "run"> = {
75
78
  wrap_up: "run",
76
79
  budget_hit: "run",
77
80
  infra_failure: "run",
81
+ unkept_promise: "run",
78
82
  };
79
83
 
84
+ /** The ways a reply tells the person a file is attached — the promise `unkept_promise` holds a run to.
85
+ * One deliberate expression, case-insensitive: "attached below/here/above", "see (the) attached",
86
+ * "I've/I have attached", "is/are attached", "attachment(s) (is/are) below/here". A path such as
87
+ * `./attachments/1-clip.mp4` or the bare word "attachments/" names a place, not a promise, and
88
+ * does not match; nor does prose about attaching in an `assistant` turn — only the `answer` counts. */
89
+ export const ATTACHMENT_PROMISE =
90
+ /\b(?:attached (?:below|here|above)|see (?:the )?attached|i(?:'ve| have) attached|(?:is|are) attached\b|attachments? (?:(?:is|are) )?(?:below|here))/i;
91
+
80
92
  export type FrictionSeverity = "low" | "medium" | "high";
81
93
 
82
94
  export interface FrictionFinding {
@@ -193,14 +205,16 @@ interface PendingCall {
193
205
  }
194
206
 
195
207
  /** The event types that tell the run's story rather than its steps — the
196
- * narrative (`input`/`context`/`assistant`/`answer`) and what the run is about. */
197
- type NarrativeEvent = Extract<RunEvent, { type: "input" | "context" | "assistant" | "answer" | "run_meta" }>;
208
+ * narrative (`input`/`context`/`assistant`/`answer`, the `notes` the agent
209
+ * kept) and what the run is about. */
210
+ type NarrativeEvent = Extract<RunEvent, { type: "input" | "context" | "assistant" | "answer" | "notes" | "run_meta" }>;
198
211
  function isNarrative(ev: RunEvent): ev is NarrativeEvent {
199
212
  return (
200
213
  ev.type === "input" ||
201
214
  ev.type === "context" ||
202
215
  ev.type === "assistant" ||
203
216
  ev.type === "answer" ||
217
+ ev.type === "notes" ||
204
218
  ev.type === "run_meta"
205
219
  );
206
220
  }
@@ -380,6 +394,9 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
380
394
  let sideFactEvents = 0; // skill_use / review_artifact / pr_description / pr_opened / review_posted / ship_round / route: facts about the run, not steps
381
395
  let spanEvents = 0; // span_start / span_end (docs/reference/specs/tracing.md): timing records, not steps
382
396
  let wrapUp: { index: number; at?: number } | undefined;
397
+ // The reply and the files the run sent out: what `unkept_promise` compares after the loop.
398
+ let answer: { index: number; text: string } | undefined;
399
+ let outboundArtifacts = 0;
383
400
  events.forEach((ev, index) => {
384
401
  flushTurns(index);
385
402
  if (isSpanRecord(ev)) {
@@ -388,8 +405,10 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
388
405
  }
389
406
  if (isNarrative(ev)) {
390
407
  narrativeEvents++; // the narrative and `run_meta` are neither steps nor findings
408
+ if (ev.type === "answer") answer = { index, text: ev.text };
391
409
  return;
392
410
  }
411
+ if (ev.type === "artifact" && ev.direction === "out") outboundArtifacts++;
393
412
  // Side facts about the run, not steps: skill_use rides beside a use_skill
394
413
  // call that already produced its own tool pair, and artifact beside the
395
414
  // attach_file call (or the dispatcher's staging) that moved the file;
@@ -580,6 +599,24 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
580
599
  f.interval = { start: at, end: Math.max(at, windowEnd) };
581
600
  }
582
601
  }
602
+ // The reply promised a file the run never produced: no tool failed (a preset without
603
+ // attach_file makes no call), so the answer's own words are the only witness. Once per
604
+ // run, anchored to the answer, no extent; the line that made the promise is quoted.
605
+ if (answer !== undefined && outboundArtifacts === 0) {
606
+ const promised = ATTACHMENT_PROMISE.exec(answer.text);
607
+ if (promised) {
608
+ const line =
609
+ answer.text.slice(0, promised.index).split("\n").pop()! + answer.text.slice(promised.index).split("\n")[0]!;
610
+ const trimmed = line.trim();
611
+ const quoted = trimmed.length > 120 ? `…${trimmed.slice(trimmed.length - 119)}` : trimmed;
612
+ findings.push({
613
+ category: "unkept_promise",
614
+ severity: "low",
615
+ summary: `unkept promise: the reply says a file is attached but the run produced none — "${quoted}"`,
616
+ eventIndex: answer.index,
617
+ });
618
+ }
619
+ }
583
620
 
584
621
  // ---- totals ----------------------------------------------------------------
585
622
  let toolTimeMs: number | undefined;
@@ -62,6 +62,28 @@ export function rowKind(json: string): RowKind {
62
62
  }
63
63
  }
64
64
 
65
+ /** Whose turn a stored row is: the role the row's JSON carries; a compaction
66
+ * row or an unreadable one has none. What `recall` reports beside a hit. */
67
+ export function roleOfStoredRow(json: string): "user" | "assistant" | undefined {
68
+ const stored = parseStored(json);
69
+ if (!stored || "compaction" in stored) return undefined;
70
+ const role = (stored as { role?: unknown }).role;
71
+ return role === "user" || role === "assistant" ? role : undefined;
72
+ }
73
+
74
+ /** The notepad's size (record 0035, "The notepad"): one document per session,
75
+ * written whole, at most this many UTF-8 bytes; `notes` refuses over it naming
76
+ * the size, and the object's write route does too. */
77
+ export const NOTEPAD_MAX_BYTES = 8_192;
78
+
79
+ /** The row a follow-up appends when the previous run's record says `broken`
80
+ * (session-log item 9): a user text turn saying the log ends short of what
81
+ * that run saw. Named here so the object can tell a gap row apart for
82
+ * `recall`, which says when a search straddles one. */
83
+ export const GAP_MARKER =
84
+ "[The log of this conversation ends short of what the previous run saw: its connection to the ledger broke, " +
85
+ "so its later turns and its final reply are not here.]";
86
+
65
87
  const textOfResultContent = (content: unknown): string => {
66
88
  if (typeof content === "string") return content;
67
89
  if (!Array.isArray(content)) return "";
@@ -206,6 +206,23 @@ export interface TranscriptRow {
206
206
  json: string;
207
207
  }
208
208
 
209
+ /** One hit of a session log's full-text search (session-log item 10): the
210
+ * row's turn and part, whose turn it is, what kind of row (`rowKind`), and
211
+ * the text the index held for it. */
212
+ export interface SessionHit {
213
+ idx: number;
214
+ part: number;
215
+ role?: "user" | "assistant";
216
+ kind: string;
217
+ text: string;
218
+ }
219
+
220
+ /** A session's notepad (record 0035, "The notepad"): the text and when it was last written. */
221
+ export interface Notepad {
222
+ text: string;
223
+ updatedAt: number;
224
+ }
225
+
209
226
  /** An externalized attachment: base64 data stored once, referenced from a row. */
210
227
  export interface TranscriptAttachment {
211
228
  ref: string;
@@ -15,13 +15,19 @@ export type McpAuthKind = "none" | "bearer" | "oauth";
15
15
 
16
16
  /** One server as a scope carries it. `tokenEnv` is the static-config way to
17
17
  * supply a bearer (an env var on the bot); without it a bearer server's token
18
- * is the sealed credential the connect page stored. */
18
+ * is the sealed credential the connect page stored. `headersEnv` is the
19
+ * static-config way to supply any other request header — header name → the
20
+ * bot env var holding its value — for a server behind a gate that is not the
21
+ * server's own auth (a Cloudflare Access service token: `CF-Access-Client-Id`
22
+ * + `CF-Access-Client-Secret`). It composes with every `auth` kind; the
23
+ * Authorization header itself is `auth`'s and is refused here. */
19
24
  export interface McpServerEntry {
20
25
  url: string;
21
26
  /** Agents whose runs may see it (default general + research). */
22
27
  agents?: string[];
23
28
  auth: McpAuthKind;
24
29
  tokenEnv?: string;
30
+ headersEnv?: Record<string, string>;
25
31
  /** Who added it at run time (`slack:U…`, `cli:local`); absent for static config. */
26
32
  addedBy?: string;
27
33
  addedAt?: number;
@@ -126,6 +132,24 @@ export const MCP_SERVERS_PER_SCOPE_MAX = 32;
126
132
  const isStr = (v: unknown, max = 4_096): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
127
133
  const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
128
134
 
135
+ /** At most this many `headersEnv` entries on one server. */
136
+ export const MCP_HEADERS_MAX = 8;
137
+ /** An HTTP header field name (RFC 9110 token), ≤ 64 chars. */
138
+ export const MCP_HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,64}$/;
139
+
140
+ /** Shape only: a non-empty mapping of header name → env var name, each a
141
+ * string (the config layer holds names to `MCP_HEADER_NAME_RE` and refuses
142
+ * Authorization). */
143
+ function isHeadersEnv(v: unknown): v is Record<string, string> {
144
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
145
+ const entries = Object.entries(v as Record<string, unknown>);
146
+ return (
147
+ entries.length > 0 &&
148
+ entries.length <= MCP_HEADERS_MAX &&
149
+ entries.every(([name, envVar]) => isStr(name, 64) && isStr(envVar, 128))
150
+ );
151
+ }
152
+
129
153
  function isOutcome(v: unknown): v is NonNullable<McpTicket["outcome"]> {
130
154
  if (!v || typeof v !== "object" || Array.isArray(v)) return false;
131
155
  const o = v as Record<string, unknown>;
@@ -148,6 +172,7 @@ export function isMcpServerEntry(v: unknown): v is McpServerEntry {
148
172
  e.agents.every((a) => isStr(a, 32)))) &&
149
173
  (e.auth === "none" || e.auth === "bearer" || e.auth === "oauth") &&
150
174
  (e.tokenEnv === undefined || isStr(e.tokenEnv, 128)) &&
175
+ (e.headersEnv === undefined || isHeadersEnv(e.headersEnv)) &&
151
176
  (e.addedBy === undefined || isStr(e.addedBy, 260)) &&
152
177
  (e.addedAt === undefined || isNum(e.addedAt))
153
178
  );
@@ -194,9 +219,10 @@ export interface McpServerView {
194
219
  url: string;
195
220
  agents: string[];
196
221
  auth: McpAuthKind;
197
- /** `static` = bearer from `tokenEnv` on the bot; `connected` = a sealed
198
- * credential is stored (or no credential is needed); `awaiting_credential`
199
- * = bearer, nothing stored yet. */
222
+ /** `static` = the credential is the bot's environment (`tokenEnv`, or
223
+ * `headersEnv` on an `auth: none` server); `connected` = a sealed credential
224
+ * is stored (or no credential is needed); `awaiting_credential` = bearer,
225
+ * nothing stored yet. */
200
226
  state: "connected" | "awaiting_credential" | "static";
201
227
  source: "config" | "runtime";
202
228
  addedBy?: string;
@@ -212,7 +238,9 @@ export function serverView(
212
238
  const parsed = parseMcpScopeKey(scopeKey);
213
239
  const state: McpServerView["state"] =
214
240
  entry.auth === "none"
215
- ? "connected"
241
+ ? entry.headersEnv
242
+ ? "static"
243
+ : "connected"
216
244
  : entry.tokenEnv
217
245
  ? "static"
218
246
  : opts.hasCredential