@ccmsg/cli 0.3.5 → 0.4.2

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.
@@ -1,25 +1,47 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { InstanceId, SessionDumpWriteArgs, SessionDumpWriteResult } from "@ccmsg/protocol";
3
+ import type {
4
+ DumpPreset,
5
+ InstanceId,
6
+ SessionDumpFile,
7
+ SessionDumpWriteArgs,
8
+ SessionDumpWriteResult,
9
+ } from "@ccmsg/protocol";
4
10
  import { OpError } from "../dispatch/index.ts";
5
- import { readRecord, type TranscriptFiles, type TranscriptRecord } from "../transcript/index.ts";
11
+ import { classify, type Item, ledger, select, selection } from "../transcript/items/index.ts";
12
+ import type { TranscriptFiles } from "../transcript/index.ts";
6
13
 
7
14
  /** Where dumps land: one directory under this instance's own state, named
8
15
  * after the config home it answers for like every other per-instance path
9
16
  * (§8.1). The caller never supplies a path, so there is none to contain. */
10
17
  export const DUMPS = "dumps";
11
18
 
19
+ /** What a dump file is called. Two extensions rather than one so that a reader
20
+ * knows both that it is JSON and that it is JSON of a shape the contract
21
+ * states — the file travels by its path, outliving the request that made it,
22
+ * and is opened by whoever was handed that path. */
23
+ export const DUMP_SUFFIX = ".dump.json";
24
+
12
25
  export interface DumpDeps {
13
26
  readonly self: InstanceId;
14
27
  readonly stateDir: string;
15
28
  readonly files: TranscriptFiles;
29
+ /** The selections this instance is configured with, which is what a `preset`
30
+ * name and an `@name` inside a selection are resolved against. */
31
+ readonly presets: readonly DumpPreset[];
16
32
  }
17
33
 
18
34
  /** Write a session's dump and answer with where it went.
19
35
  *
20
36
  * What this adds over reading the transcript is a durable artifact whose path
21
37
  * can be handed to a successor session, rather than a payload that would
22
- * travel out through a client and back in again. */
38
+ * travel out through a client and back in again.
39
+ *
40
+ * The subject is the session, or one agent below it when the request names
41
+ * one. Every item type is read from wherever the subject stands — an agent's
42
+ * `message:user:in` is the brief its parent gave it — so one selection carries
43
+ * unchanged down a chain of agents, which is what makes the ledger's agent ids
44
+ * a way to descend rather than just a list. */
23
45
  export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDumpWriteResult {
24
46
  if (args.since_at !== undefined && args.since_uuid !== undefined) {
25
47
  throw new OpError("invalid_args", "a lower bound is a time or a record, not both");
@@ -27,79 +49,95 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
27
49
  if (args.until_at !== undefined && args.until_uuid !== undefined) {
28
50
  throw new OpError("invalid_args", "an upper bound is a time or a record, not both");
29
51
  }
30
- const file = deps.files.session(args.sid);
52
+ const preset = presetFor(args.preset, deps.presets);
53
+ const file = deps.files.locate(
54
+ args.sid,
55
+ args.agent_id === undefined ? {} : { agent_id: args.agent_id },
56
+ );
31
57
  let text: string;
32
58
  try {
33
59
  text = readFileSync(file, "utf8");
34
60
  } catch {
35
61
  throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
36
62
  }
37
- const entries = collect(text, args);
38
- const document = {
63
+ // The whole file is classified before the range is applied, so a result
64
+ // inside the range still names the call that fell before it. Cutting first
65
+ // would leave `parent_item` pointing at something the reader never saw.
66
+ const keep = selection(
67
+ {
68
+ ...(args.types === undefined ? {} : { types: args.types }),
69
+ ...(preset === undefined ? {} : { preset }),
70
+ ...(args.no_thinking === undefined ? {} : { no_thinking: args.no_thinking }),
71
+ ...(args.no_agent === undefined ? {} : { no_agent: args.no_agent }),
72
+ },
73
+ deps.presets,
74
+ );
75
+ const { items, entries } = select(within(classify(text.split("\n")), args), keep);
76
+ const ids = ledger(items);
77
+ const written_at = Date.now();
78
+ // The file repeats what it was asked for. A dump outlives the request that
79
+ // made it and is read by whoever was handed the path, so it has to say on
80
+ // its own what it is a dump of and what was left out — which is why the
81
+ // selection is written as applied, with the presets already expanded.
82
+ const document: SessionDumpFile = {
39
83
  sid: args.sid,
40
- instance: deps.self,
41
- source: file,
42
- generated_at: Date.now(),
43
- entries,
84
+ ...(args.agent_id === undefined ? {} : { agent_id: args.agent_id }),
85
+ written_at,
86
+ types: [...keep.elements],
87
+ items,
88
+ ids,
44
89
  };
45
90
  const dir = join(deps.stateDir, DUMPS);
46
91
  mkdirSync(dir, { recursive: true });
47
- const path = join(dir, `${args.sid}-${document.generated_at}.json`);
92
+ const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
93
+ const path = join(dir, `${named}-${written_at}${DUMP_SUFFIX}`);
48
94
  const body = `${JSON.stringify(document, undefined, 2)}\n`;
49
95
  writeFileSync(path, body);
50
- return {
51
- path,
52
- instance: deps.self,
53
- entries: entries.length,
54
- bytes: Buffer.byteLength(body),
55
- };
96
+ return { path, instance: deps.self, entries, ids, bytes: Buffer.byteLength(body) };
56
97
  }
57
98
 
58
- /** One record of a dump: what was said, by whom, when.
99
+ /** The named selection a request asked for.
59
100
  *
60
- * The fields are the record's own, taken from the type that reads a transcript
61
- * line rather than restated here a dump reports what was read, and a second
62
- * spelling of those fields would be a second interpretation of the file. */
63
- type DumpEntry = Pick<TranscriptRecord, "uuid" | "said_at" | "said_by" | "text" | "thinking">;
101
+ * A name this instance does not have is refused rather than ignored: a dump
102
+ * silently wider than what was asked for is the failure a selection exists to
103
+ * prevent. */
104
+ function presetFor(
105
+ name: string | undefined,
106
+ presets: readonly DumpPreset[],
107
+ ): DumpPreset | undefined {
108
+ if (name === undefined) return undefined;
109
+ const found = presets.find((one) => one.name === name);
110
+ if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
111
+ return found;
112
+ }
64
113
 
65
- /** The records within the bounds, in the order the transcript holds them.
114
+ /** The items within the bounds, in the order the transcript holds them.
66
115
  *
67
116
  * A record bound cuts at that record's position rather than at its clock, so
68
117
  * records sharing an instant stay on their own side of the cut — which is the
69
- * whole reason the contract offers both kinds of bound. */
70
- function collect(text: string, args: SessionDumpWriteArgs): DumpEntry[] {
71
- const entries: DumpEntry[] = [];
72
- // A lower bound by record starts the dump closed: it opens at the record it
73
- // names, which is included.
118
+ * whole reason the contract offers both kinds of bound. Every item a record
119
+ * became carries that record's id, so a bound by record keeps a turn's
120
+ * thinking, words and calls together. */
121
+ function within(items: readonly Item[], args: SessionDumpWriteArgs): Item[] {
122
+ const kept: Item[] = [];
123
+ // A lower bound by record starts closed: it opens at the record it names,
124
+ // which is included.
74
125
  let open = args.since_uuid === undefined;
75
- for (const line of text.split("\n")) {
76
- if (line === "") continue;
77
- const record = readRecord(line);
78
- if (record === undefined) continue;
126
+ for (let at = 0; at < items.length; at += 1) {
127
+ const item = items[at];
128
+ if (item === undefined) continue;
79
129
  if (!open) {
80
- if (record.uuid !== args.since_uuid) continue;
130
+ if (item.uuid !== args.since_uuid) continue;
81
131
  open = true;
82
132
  }
83
- if (args.since_at !== undefined && (record.said_at ?? 0) < args.since_at) continue;
84
- if (args.until_at !== undefined && (record.said_at ?? 0) > args.until_at) break;
85
- // The machinery of in-process agents: their turns interleave into the
86
- // session's own file, and a successor resuming the session is resuming the
87
- // session rather than them.
88
- if (args.no_agent === true && record.sidechain) {
89
- if (record.uuid !== undefined && record.uuid === args.until_uuid) break;
90
- continue;
133
+ if (args.since_at !== undefined && item.at < args.since_at) continue;
134
+ if (args.until_at !== undefined && item.at > args.until_at) break;
135
+ kept.push(item);
136
+ // An upper bound by record is inclusive and cuts after the last item that
137
+ // record became, so the rest of the same record is still let through.
138
+ if (args.until_uuid !== undefined && item.uuid === args.until_uuid) {
139
+ if (items[at + 1]?.uuid !== item.uuid) break;
91
140
  }
92
- entries.push({
93
- ...(record.uuid === undefined ? {} : { uuid: record.uuid }),
94
- ...(record.said_at === undefined ? {} : { said_at: record.said_at }),
95
- ...(record.said_by === undefined ? {} : { said_by: record.said_by }),
96
- ...(record.text === undefined ? {} : { text: record.text }),
97
- ...(args.no_thinking === true || record.thinking === undefined
98
- ? {}
99
- : { thinking: record.thinking }),
100
- });
101
- // An upper bound by record is inclusive, so the cut is after it.
102
- if (record.uuid !== undefined && record.uuid === args.until_uuid) break;
103
141
  }
104
- return entries;
142
+ return kept;
105
143
  }
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  type Capability,
3
+ type DumpPreset,
4
+ type DumpPresetsReadResult,
3
5
  type InstanceId,
4
6
  type SessionDumpWriteArgs,
5
7
  type SessionEnvReadArgs,
@@ -57,9 +59,11 @@ export interface SessionOpsDeps {
57
59
  * instance last saw them. The sessions domain owns the list; this op only
58
60
  * asks it to forget a row. */
59
61
  readonly forget: (sid: Sid) => boolean;
62
+ /** The named selections this instance is configured with (§3.6). */
63
+ readonly presets: readonly DumpPreset[];
60
64
  }
61
65
 
62
- /** The eight ops that observe and operate on sessions.
66
+ /** The ops that observe and operate on sessions.
63
67
  *
64
68
  * None of them decides who may call it: dispatch has settled that from the
65
69
  * attribute table. The one that narrows by role is `transcript_read`, and it
@@ -104,8 +108,17 @@ export function sessionHandlers(deps: SessionOpsDeps) {
104
108
  self: deps.self,
105
109
  stateDir: deps.stateDir,
106
110
  files: deps.files,
111
+ presets: deps.presets,
107
112
  }),
108
113
 
114
+ /** Which selections a dump may be asked for by name.
115
+ *
116
+ * Nothing else states them, so a client without this could only offer a
117
+ * free-text field and let the instance refuse. A preset that references
118
+ * another is answered as written: the expansion, and the refusal of a
119
+ * cycle, happen where the config is read. */
120
+ dump_presets_read: (): DumpPresetsReadResult => ({ presets: [...deps.presets] }),
121
+
109
122
  session_fork_origin: (input: HandlerInput): SessionForkOriginResult => {
110
123
  const args = input.args as unknown as SessionForkOriginArgs;
111
124
  const origin = forkOrigin(args.sid, deps.files);
@@ -1,5 +1,5 @@
1
1
  export * from "./classify.ts";
2
- export { DUMPS, dumpWrite } from "./dump.ts";
2
+ export { DUMP_SUFFIX, DUMPS, dumpWrite } from "./dump.ts";
3
3
  export { forkOrigin } from "./fork.ts";
4
4
  export * from "./harness.ts";
5
5
  export { sessionCapabilities, sessionHandlers, type SessionOpsDeps } from "./handlers.ts";
@@ -0,0 +1,400 @@
1
+ import type { Item } from "./item.ts";
2
+ import { count, instant, isRow, list, optional, type Row, row, str, tagged } from "./record.ts";
3
+ import { genericResult, resultFields, useFields } from "./tools.ts";
4
+
5
+ /** Turning a harness's transcript into the items the contract names.
6
+ *
7
+ * The contract writes down the type names and what an item of each type
8
+ * carries, and says nothing about the file: the file is the harness's own, it
9
+ * changes without asking, and reading it is this instance's work (§3.8). So
10
+ * everything that knows what a line looks like is here, and what leaves is
11
+ * only ever an item.
12
+ *
13
+ * Items are finer than lines. One assistant record holds the thinking, the
14
+ * words and each tool call of a turn, and each of those is its own item — the
15
+ * unit a reader selects and draws by is the thing that happened, not the line
16
+ * the harness happened to write it on.
17
+ *
18
+ * Nothing is dropped for being unrecognised. A tool nobody wrote fields for
19
+ * arrives with what it was called with, an attachment arrives under its own
20
+ * kind, and a record that fits nothing arrives as `system:unknown`. The one
21
+ * failure a dump cannot be read around is a line that vanished quietly. */
22
+
23
+ /** Record types that are the interface and the session's own bookkeeping
24
+ * rather than anything that was said or done: the current mode, the title as
25
+ * it was retitled, the queue, the file-history snapshots the editor keeps.
26
+ *
27
+ * They are the bulk of a transcript — more than a third of the lines in the
28
+ * sessions this was measured against — and none of them is an event a reader
29
+ * of a dump is looking for. */
30
+ const NOT_ITEMS = new Set([
31
+ "mode",
32
+ "permission-mode",
33
+ "atis-latch",
34
+ "ai-title",
35
+ "custom-title",
36
+ "last-prompt",
37
+ "queue-operation",
38
+ "cost-state",
39
+ "file-history-snapshot",
40
+ "file-history-delta",
41
+ "bridge-session",
42
+ "progress",
43
+ "summary",
44
+ ]);
45
+
46
+ /** What an item is under construction: the contract's shape, before it is
47
+ * settled. A call learns the id of what answered it only when the answer
48
+ * arrives, which is why these are written to after they are made. */
49
+ type Draft = Record<string, unknown> & { uuid: string; type: string; at: number };
50
+
51
+ /** A whole transcript read as items, in the order the file holds them.
52
+ *
53
+ * The file is read through once and the links are filled in as the answers
54
+ * arrive, so a call and its result point at each other however many turns
55
+ * apart the harness wrote them. Reading the whole file before any range is
56
+ * applied is what makes `parent_item` answerable: a result inside the range
57
+ * whose call fell before it still names the call. */
58
+ export function classify(lines: Iterable<string>): Item[] {
59
+ const state = new Classification();
60
+ for (const line of lines) {
61
+ if (line === "") continue;
62
+ let parsed: unknown;
63
+ try {
64
+ parsed = JSON.parse(line);
65
+ } catch {
66
+ continue;
67
+ }
68
+ if (isRow(parsed)) state.read(parsed);
69
+ }
70
+ return state.items;
71
+ }
72
+
73
+ class Classification {
74
+ readonly items: Draft[] = [];
75
+ /** The call each tool result belongs to, by the id the harness pairs them
76
+ * with. Holds the `tool:*` item and, for an `Agent` call, the
77
+ * `message:sub:out` beside it — the same exchange seen from the two sides
78
+ * the contract names it from. */
79
+ readonly #calls = new Map<string, { tool: Draft; message?: Draft; name: string }>();
80
+ #turn = 0;
81
+ /** The last slash command invoked, which is what its output belongs to. */
82
+ #slash: string | undefined;
83
+
84
+ read(record: Row): void {
85
+ const type = str(record["type"]);
86
+ if (type === undefined || NOT_ITEMS.has(type)) return;
87
+ const uuid = str(record["uuid"]) ?? "";
88
+ if (uuid === "") return;
89
+ const at = instant(record["timestamp"]);
90
+ const make = (kind: string, fields: Record<string, unknown> = {}): Draft => {
91
+ const draft: Draft = { uuid, type: kind, at, turn: this.#turn, ...fields };
92
+ this.items.push(draft);
93
+ return draft;
94
+ };
95
+ if (type === "attachment") return this.#attachment(record, make);
96
+ if (type === "system") return this.#system(record, make);
97
+ if (type === "assistant") return this.#assistant(record, make);
98
+ if (type === "user") return this.#user(record, make);
99
+ make("system:unknown", { record });
100
+ }
101
+
102
+ /** An attachment is either the operator's own code speaking or the harness
103
+ * attaching something to a turn, and the two are kept apart because a reader
104
+ * cares about them for opposite reasons. */
105
+ #attachment(record: Row, make: Make): void {
106
+ const attachment = row(record["attachment"]) ?? {};
107
+ const kind = str(attachment["type"]);
108
+ if (kind === "hook_additional_context" || kind === "hook_success") {
109
+ const name = str(attachment["hookName"]) ?? "";
110
+ // The event alone is the type. A hook runs under `PreToolUse:Bash`,
111
+ // whose `:` would read as another level of the hierarchy and leave
112
+ // `hook:PreToolUse` selecting nothing.
113
+ const event = str(attachment["hookEvent"]) ?? name.split(":")[0] ?? "";
114
+ make(`hook:${segment(event)}`, {
115
+ hook_name: name,
116
+ outcome: kind === "hook_success" ? "output" : "additionalContext",
117
+ ...optional("content", text(attachment["content"])),
118
+ ...optional("command", str(attachment["command"])),
119
+ ...optional("exit_code", count(attachment["exitCode"])),
120
+ ...optional("stderr", str(attachment["stderr"])),
121
+ ...optional("duration_ms", count(attachment["durationMs"])),
122
+ ...optional("tool_use_id", str(attachment["toolUseID"])),
123
+ });
124
+ return;
125
+ }
126
+ make(`system:attachment:${segment(kind ?? "unknown")}`, { attachment });
127
+ }
128
+
129
+ /** The harness files a slash command's output as a line of its own, which
130
+ * says what came out and not what was run. The command it belongs to is the
131
+ * one that was just invoked — the harness writes the two together — so the
132
+ * output is reported under that name rather than under none. */
133
+ #system(record: Row, make: Make): void {
134
+ if (str(record["subtype"]) === "local_command") {
135
+ const content = str(record["content"]) ?? "";
136
+ make("notice:slash", {
137
+ command: this.#slash ?? "",
138
+ ...optional("stdout", tagged(content, "local-command-stdout") ?? content),
139
+ });
140
+ return;
141
+ }
142
+ make("system:unknown", { record });
143
+ }
144
+
145
+ #assistant(record: Row, make: Make): void {
146
+ const message = row(record["message"]) ?? {};
147
+ if (record["isApiErrorMessage"] === true) {
148
+ make("system:api-error", { text: text(message["content"]) ?? "" });
149
+ return;
150
+ }
151
+ for (const block of list(message["content"])) {
152
+ const fields = row(block);
153
+ if (fields === undefined) continue;
154
+ const kind = str(fields["type"]);
155
+ if (kind === "thinking") {
156
+ const thought = str(fields["thinking"])?.trim();
157
+ if (thought !== undefined && thought !== "") make("thinking", { text: thought });
158
+ continue;
159
+ }
160
+ if (kind === "text") {
161
+ const said = str(fields["text"])?.trim();
162
+ if (said !== undefined && said !== "") make("message:user:out", { text: said });
163
+ continue;
164
+ }
165
+ if (kind === "tool_use") this.#call(fields, make);
166
+ }
167
+ }
168
+
169
+ /** One tool call, and — where the call is one session addressing another
170
+ * mind — the message it also is.
171
+ *
172
+ * `Agent` is always both: the pair states that an agent was started and how
173
+ * it ended, and the brief it was given and what it answered are the message
174
+ * beside it. `SendMessage` and a `ccmsg` command are one or the other by
175
+ * whom they are addressed to. */
176
+ #call(block: Row, make: Make): void {
177
+ const name = str(block["name"]) ?? "";
178
+ const id = str(block["id"]) ?? "";
179
+ const input = row(block["input"]) ?? {};
180
+ const fields = useFields(name, input);
181
+ const tool = make(`tool:${segment(name)}`, {
182
+ role: "use",
183
+ tool_use_id: id,
184
+ ...(fields ?? { input }),
185
+ });
186
+ let message: Draft | undefined;
187
+ if (name === "Agent") {
188
+ message = make("message:sub:out", {
189
+ role: "use",
190
+ prompt: str(input["prompt"]) ?? "",
191
+ ...optional("subagent_type", str(input["subagent_type"])),
192
+ ...optional("name", str(input["name"])),
193
+ ...optional("description", str(input["description"])),
194
+ });
195
+ } else if (name === "SendMessage") {
196
+ const to = str(input["to"]) ?? "";
197
+ // A sid is the harness's own uuid; anything else is a name, and a name
198
+ // is how an agent below this session is addressed.
199
+ make(addressed(to) ? "message:session:out" : "message:sub:out", {
200
+ ...(addressed(to) ? {} : { role: "use" }),
201
+ ...(addressed(to)
202
+ ? { text: text(input["message"]) ?? "", to }
203
+ : { prompt: text(input["message"]) ?? "", name: to }),
204
+ });
205
+ } else if (name === "Bash" && isCcmsgSend(str(input["command"]))) {
206
+ make("message:session:out", { text: str(input["command"]) ?? "" });
207
+ }
208
+ if (id !== "") this.#calls.set(id, { tool, name, ...optional("message", message) });
209
+ }
210
+
211
+ #user(record: Row, make: Make): void {
212
+ const message = row(record["message"]) ?? {};
213
+ const content = message["content"];
214
+ if (Array.isArray(content)) {
215
+ let said = "";
216
+ for (const block of list(content)) {
217
+ const fields = row(block);
218
+ if (fields === undefined) continue;
219
+ if (str(fields["type"]) === "tool_result") {
220
+ this.#answer(record, fields, make);
221
+ continue;
222
+ }
223
+ const part = str(fields["text"]);
224
+ if (part !== undefined) said += said === "" ? part : `\n${part}`;
225
+ }
226
+ if (said !== "") this.#said(record, said, make);
227
+ return;
228
+ }
229
+ const said = str(content);
230
+ if (said !== undefined) this.#said(record, said, make);
231
+ }
232
+
233
+ /** What came back from a tool call, linked to the call in both directions. */
234
+ #answer(record: Row, block: Row, make: Make): void {
235
+ const id = str(block["tool_use_id"]) ?? "";
236
+ const call = this.#calls.get(id);
237
+ const failed = block["is_error"] === true;
238
+ const answer = record["toolUseResult"];
239
+ const fields = resultFields(call?.name, answer, failed);
240
+ const item = make(`tool:${segment(call?.name ?? "unknown")}`, {
241
+ role: "result",
242
+ parent_item: call?.tool.uuid ?? id,
243
+ tool_use_id: id,
244
+ ...(fields ?? { result: genericResult(answer) }),
245
+ });
246
+ if (call === undefined) return;
247
+ call.tool["result_item"] = item.uuid;
248
+ // An agent's id is known only once it has started, so the message that
249
+ // asked for it learns its own id from the answer.
250
+ const result = row(answer);
251
+ const agent =
252
+ result === undefined ? undefined : (str(result["agentId"]) ?? str(result["agent_id"]));
253
+ if (call.message !== undefined && agent !== undefined) call.message["agent_id"] = agent;
254
+ }
255
+
256
+ /** A `type: "user"` line whose content is words rather than a tool's answer.
257
+ *
258
+ * Most of what wears this shape was not said by a person: the harness
259
+ * reports background tasks, compaction and its own caveats in the same
260
+ * place, and another session's message arrives inside an envelope. The
261
+ * person's own turn is what is left when none of those match — read last,
262
+ * so nothing the harness injected is mistaken for someone speaking. */
263
+ #said(record: Row, said: string, make: Make): void {
264
+ if (record["isCompactSummary"] === true) {
265
+ make("system:compact", { text: said });
266
+ return;
267
+ }
268
+ if (said.startsWith("<local-command-caveat>")) {
269
+ make("system:caveat", { text: said });
270
+ return;
271
+ }
272
+ const command = tagged(said, "command-name");
273
+ if (command !== undefined) {
274
+ this.#slash = command;
275
+ make("notice:slash", {
276
+ command,
277
+ ...optional("args", tagged(said, "command-args") ?? tagged(said, "command-message")),
278
+ ...optional("stdout", tagged(said, "local-command-stdout")),
279
+ });
280
+ return;
281
+ }
282
+ if (said.startsWith("[Request interrupted")) {
283
+ make("notice:interrupt", { text: said });
284
+ return;
285
+ }
286
+ if (said.startsWith("Resume the paused workflow by calling: Workflow({")) {
287
+ make("system:resume", { text: said });
288
+ return;
289
+ }
290
+ if (said.startsWith("<task-notification>")) {
291
+ this.#notification(said, make);
292
+ return;
293
+ }
294
+ // The record nothing else in the file is a reply to is the brief this
295
+ // transcript was opened with, whoever the subject is: a person's first
296
+ // words to a session, or the parent's instructions to an agent. An agent
297
+ // is briefed inside the same envelope another session's message arrives
298
+ // in, so this is read before that envelope is — from where the subject
299
+ // stands, being told what to do is not the same as being written to.
300
+ if (record["parentUuid"] === null) {
301
+ this.#turn += 1;
302
+ make("message:user:in", { text: said });
303
+ return;
304
+ }
305
+ if (said.includes("<cross-session-message") || said.includes("<teammate-message")) {
306
+ make("message:session:in", {
307
+ text: said,
308
+ ...optional("from", attribute(said, "from") ?? attribute(said, "teammate_id")),
309
+ ...optional("msg_id", attribute(said, "mid")),
310
+ });
311
+ return;
312
+ }
313
+ if (record["isMeta"] === true) {
314
+ make("system:unknown", { record });
315
+ return;
316
+ }
317
+ // A turn begins where a person speaks, which is the only place a dump can
318
+ // count turns from — the harness numbers nothing.
319
+ this.#turn += 1;
320
+ make("message:user:in", { text: said });
321
+ }
322
+
323
+ /** A background task reporting, or an agent handing back its answer.
324
+ *
325
+ * The two arrive in the same envelope and are told apart by what it holds: a
326
+ * `<result>` is an agent that finished, and everything else is an event from
327
+ * a monitor or a background command.
328
+ *
329
+ * What the answer belongs to is whichever call started the agent. An `Agent`
330
+ * call has a brief, so the answer is the other half of that message; a
331
+ * `Skill` run in the background has none, and the answer hangs off the call
332
+ * itself. Either way it is an agent answering and reads as one. */
333
+ #notification(said: string, make: Make): void {
334
+ const answer = tagged(said, "result");
335
+ const call = this.#calls.get(tagged(said, "tool-use-id") ?? "");
336
+ if (answer !== undefined && call !== undefined) {
337
+ const asked = call.message ?? call.tool;
338
+ const item = make("message:sub:in", {
339
+ role: "result",
340
+ parent_item: asked.uuid,
341
+ text: answer,
342
+ ...optional("agent_id", str(asked["agent_id"]) ?? tagged(said, "task-id")),
343
+ ...optional("status", tagged(said, "status")),
344
+ ...optional("duration_ms", count(Number(tagged(said, "duration_ms")))),
345
+ });
346
+ if (call.message !== undefined) call.message["result_item"] = item.uuid;
347
+ return;
348
+ }
349
+ make("system:task", {
350
+ text: said,
351
+ ...optional("task_id", tagged(said, "task-id")),
352
+ ...optional("event", tagged(said, "event") ?? tagged(said, "summary")),
353
+ });
354
+ }
355
+ }
356
+
357
+ type Make = (kind: string, fields?: Record<string, unknown>) => Draft;
358
+
359
+ /** A session id as the harness writes one. What `SendMessage` addresses is
360
+ * either this — another session — or a name, which is an agent below this
361
+ * one. */
362
+ const SID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
363
+
364
+ function addressed(to: string): boolean {
365
+ return SID.test(to);
366
+ }
367
+
368
+ /** Whether a shell command is this session speaking to another one. */
369
+ function isCcmsgSend(command: string | undefined): boolean {
370
+ if (command === undefined) return false;
371
+ return /\bccmsg\s+(post|reply)\b/.test(command);
372
+ }
373
+
374
+ /** One segment of a type name. The harness's own spellings pass through — they
375
+ * are what a reader matches against what it ran — and a character the name
376
+ * could not carry is replaced rather than the segment being refused, so a
377
+ * newcomer still arrives under something close to its own name. */
378
+ function segment(name: string): string {
379
+ const cleaned = name.replace(/[^A-Za-z0-9_.-]/g, "-");
380
+ return cleaned === "" ? "unknown" : cleaned;
381
+ }
382
+
383
+ /** An attribute of one of the harness's envelope tags. */
384
+ function attribute(said: string, name: string): string | undefined {
385
+ return new RegExp(`${name}="([^"]*)"`).exec(said)?.[1] || undefined;
386
+ }
387
+
388
+ /** A content field that is sometimes a string and sometimes the blocks of
389
+ * one. */
390
+ function text(raw: unknown): string | undefined {
391
+ const found = str(raw);
392
+ if (found !== undefined) return found;
393
+ if (!Array.isArray(raw)) return undefined;
394
+ const parts = raw.flatMap((block) => {
395
+ const fields = row(block);
396
+ const part = fields === undefined ? str(block) : str(fields["text"]);
397
+ return part === undefined ? [] : [part];
398
+ });
399
+ return parts.length === 0 ? undefined : parts.join("\n");
400
+ }