@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.
- package/dist/assets/config/config.example.yaml +6 -0
- package/dist/assets/deploy/cloudflare/worker.ts +4 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +96 -6
- package/dist/assets/deploy/secrets.manifest.json +12 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +20 -0
- package/dist/assets/src/core/runEvents.ts +6 -0
- package/dist/assets/src/core/runFriction.ts +40 -3
- package/dist/assets/src/core/runLedger/sessionLog.ts +22 -0
- package/dist/assets/src/core/runLedger/types.ts +17 -0
- package/dist/assets/src/mcp/registry.ts +33 -5
- package/dist/assets/web/dist/.vite/manifest.json +18 -18
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CWyEd5fq.js → ResidentDetailPage-DvCt96Dk.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-DqFqRTWb.js → ResidentsIndexPage-BZaYja1-.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-CghwVr_X.js → RunRoutePage-DI4vzCUu.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-BcYehvW5.js → RunsIndexPage-Ch9uP4p4.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-mKa20Qdd.js → ScheduledPage-DAkAHMCH.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-QMofJG-E.js → StatusDot-CfBonGcV.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-D-zVPexc.js → Tooltip-CBD36E3W.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-D43zKxNV.js → dist-DQ1U73LF.js} +1 -1
- package/dist/assets/web/dist/assets/{main-ClTMtz8n.js → main-CdLnG-yl.js} +2 -2
- package/dist/cli.js +522 -120
- 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`,
|
|
2896
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
16713
|
+
"version": "1.219.0",
|
|
16714
16714
|
"license": "Apache-2.0",
|
|
16715
16715
|
"dependencies": {
|
|
16716
16716
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.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",
|
package/dist/assets/source.json
CHANGED
|
@@ -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
|
|
197
|
-
|
|
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` =
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
-
?
|
|
241
|
+
? entry.headersEnv
|
|
242
|
+
? "static"
|
|
243
|
+
: "connected"
|
|
216
244
|
: entry.tokenEnv
|
|
217
245
|
? "static"
|
|
218
246
|
: opts.hasCredential
|