@ccmsg/cli 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/cli.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env bun
2
- import { type MessageSendArgs, type NotifySendArgs, PROTOCOL_VERSION } from "@ccmsg/protocol";
2
+ import {
3
+ type MessageSendArgs,
4
+ type NotifySendArgs,
5
+ PROTOCOL_VERSION,
6
+ type SessionDumpFile,
7
+ type SessionDumpWriteArgs,
8
+ type SessionDumpWriteResult,
9
+ } from "@ccmsg/protocol";
3
10
  import {
4
11
  add as addToConfig,
5
12
  ask,
@@ -24,8 +31,10 @@ import {
24
31
  type Target,
25
32
  targetFor,
26
33
  } from "./daemon/index.ts";
34
+ import { readFileSync, writeFileSync } from "node:fs";
27
35
  import { homedir } from "node:os";
28
36
  import { isAbsolute, join } from "node:path";
37
+ import { document } from "./transcript/items/index.ts";
29
38
  import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "./harness/index.ts";
30
39
 
31
40
  /** The variables a session is named by, for the help and for the message a
@@ -357,6 +366,40 @@ const ROOT: Command = {
357
366
  ],
358
367
  run: (args) => agents(args),
359
368
  },
369
+ {
370
+ name: "dump",
371
+ summary: "セッション (か配下の worker 1 体) の transcript を型ごとの表示で書き出す",
372
+ usage: "ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>]",
373
+ options: [
374
+ ["--preset <名前>", "instance が持つ選択 (ccmsg dump presets で一覧)"],
375
+ ["--types <選択>", "型をカンマ区切りで。prefix 可、-で除外、@名前で preset 展開"],
376
+ ["--since <at|uuid>", "下限。時刻 (ISO か epoch ミリ秒) か record の uuid"],
377
+ ["--until <at|uuid>", "上限。同上"],
378
+ ["--max-chars <n>", "1 アイテムの本文をこの文字数で切る (既定は切らない)"],
379
+ ["--json", "markdown ではなく dump file の中身をそのまま出す"],
380
+ ["--out <path>", "標準出力ではなくこの path に書く"],
381
+ ],
382
+ notes: [
383
+ {
384
+ title: "別のセッションのやり方を読む:",
385
+ docs: [
386
+ ["1", "ccmsg dump <sid> --preset howto で親を読む"],
387
+ ["2", "末尾の ids 台帳から良さそうな worker の agent id を選ぶ"],
388
+ ["3", "ccmsg dump <sid>/agent-<id> --preset howto で主語を移して掘る"],
389
+ ],
390
+ },
391
+ ],
392
+ children: [
393
+ {
394
+ name: "presets",
395
+ summary: "この instance が持つ preset の名前と中身を並べる",
396
+ usage: "ccmsg dump presets",
397
+ bare: true,
398
+ run: () => instanceAsk({ op: "dump_presets_read" }),
399
+ },
400
+ ],
401
+ run: (args) => dump(args),
402
+ },
360
403
  {
361
404
  name: "post",
362
405
  summary: "別のセッションへメッセージを送る",
@@ -857,6 +900,115 @@ async function topic(
857
900
  }
858
901
  }
859
902
 
903
+ /** `ccmsg dump <sid>[/agent-<id>]`: read how a session worked.
904
+ *
905
+ * The instance writes the file — it is the one that can read a transcript, and
906
+ * a path that outlives the request is the point of the op — and this reads it
907
+ * back and draws it. Which means the two halves stay where they belong: what
908
+ * an item is settled by whoever read the file, and how an item reads is
909
+ * settled here, where somebody is looking at it.
910
+ *
911
+ * `--json` hands over the file as it stands, for a reader that is a program. */
912
+ async function dump(args: readonly string[]): Promise<unknown> {
913
+ const parsed = options(args, ["preset", "types", "since", "until", "out", "max-chars"], ["json"]);
914
+ const subject = parsed.rest[0];
915
+ if (subject === undefined) {
916
+ throw new CommandError(
917
+ "invalid_args",
918
+ "使い方: ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>]",
919
+ );
920
+ }
921
+ const written = (await instanceAsk({
922
+ op: "session_dump_write",
923
+ ...dumpArgs(subject, parsed.named),
924
+ })) as unknown as SessionDumpWriteResult;
925
+ const body = readFileSync(written.path, "utf8");
926
+ const since = parsed.named.get("since");
927
+ const until = parsed.named.get("until");
928
+ const limit = parsed.named.get("max-chars");
929
+ const text = parsed.flags.has("json")
930
+ ? body
931
+ : document(JSON.parse(body) as SessionDumpFile, {
932
+ instance: written.instance,
933
+ ...(since === undefined ? {} : { since }),
934
+ ...(until === undefined ? {} : { until }),
935
+ ...(limit === undefined ? {} : { max_chars: chars(limit) }),
936
+ });
937
+ const out = parsed.named.get("out");
938
+ if (out === undefined) {
939
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
940
+ return undefined;
941
+ }
942
+ writeFileSync(out, text);
943
+ return {
944
+ path: out,
945
+ bytes: Buffer.byteLength(text),
946
+ dump: written.path,
947
+ instance: written.instance,
948
+ entries: written.entries,
949
+ ids: written.ids,
950
+ };
951
+ }
952
+
953
+ /** What a person typed, as the op's arguments.
954
+ *
955
+ * `<sid>/agent-<id>` is split here and nowhere else: the contract keeps the
956
+ * two apart so that a sid stays a validated sid, and the joined spelling is
957
+ * the CLI's own convenience — it is how the file the agent's records live in
958
+ * is named, which is what makes the two halves tellable apart by eye. */
959
+ export function dumpArgs(
960
+ subject: string,
961
+ named: ReadonlyMap<string, string> = new Map(),
962
+ ): SessionDumpWriteArgs {
963
+ const at = subject.indexOf(AGENT_MARK);
964
+ const sid = at === -1 ? subject : subject.slice(0, at);
965
+ const agent = at === -1 ? undefined : subject.slice(at + AGENT_MARK.length);
966
+ const types = named.get("types");
967
+ const preset = named.get("preset");
968
+ return {
969
+ sid,
970
+ ...(agent === undefined || agent === "" ? {} : { agent_id: agent }),
971
+ ...(preset === undefined ? {} : { preset }),
972
+ ...(types === undefined
973
+ ? {}
974
+ : {
975
+ types: types
976
+ .split(",")
977
+ .map((one) => one.trim())
978
+ .filter((one) => one !== ""),
979
+ }),
980
+ ...bound("since", named.get("since")),
981
+ ...bound("until", named.get("until")),
982
+ };
983
+ }
984
+
985
+ const AGENT_MARK = "/agent-";
986
+
987
+ /** One bound, as whichever of the two kinds it was written in.
988
+ *
989
+ * A time and a record id cannot be confused for one another — one parses as a
990
+ * moment and the other does not — so the caller writes what they have rather
991
+ * than saying which it is. */
992
+ function bound(kind: "since" | "until", value: string | undefined): Record<string, unknown> {
993
+ if (value === undefined || value === "") return {};
994
+ const at = moment(value);
995
+ return at === undefined ? { [`${kind}_uuid`]: value } : { [`${kind}_at`]: at };
996
+ }
997
+
998
+ function moment(value: string): number | undefined {
999
+ if (/^\d+$/.test(value)) return Number(value);
1000
+ const parsed = Date.parse(value);
1001
+ return Number.isNaN(parsed) ? undefined : parsed;
1002
+ }
1003
+
1004
+ function chars(value: string): number {
1005
+ const limit = Number(value);
1006
+ if (!Number.isInteger(limit) || limit <= 0) {
1007
+ throw new CommandError("invalid_args", "--max-chars は 1 以上の整数です");
1008
+ }
1009
+ return limit;
1010
+ }
1011
+
860
1012
  /** `ccmsg post <sid> <text>`: start a conversation with another session. */
861
1013
  function post(args: readonly string[]): Promise<unknown> {
862
1014
  const parsed = options(args, ["sid"]);
@@ -1106,6 +1258,26 @@ async function call(
1106
1258
  `自分のセッション ID が分かりません (--sid か ${SESSION_ENV.join(" / ")})`,
1107
1259
  );
1108
1260
  }
1261
+ return await exchange(
1262
+ { op: "hello", role: "session", sid, protocol_version: PROTOCOL_VERSION, ...meta },
1263
+ request,
1264
+ );
1265
+ }
1266
+
1267
+ /** One op, spoken as the person at the keyboard.
1268
+ *
1269
+ * Which is who is asking: reading a transcript is not something a session is a
1270
+ * party to, and the ops that do it are open to a person and to nobody else. */
1271
+ function instanceAsk(request: Record<string, unknown>): Promise<unknown> {
1272
+ return exchange({ op: "hello", role: "user", protocol_version: PROTOCOL_VERSION }, request);
1273
+ }
1274
+
1275
+ /** Greet this config home's instance, ask it one thing, and answer with what
1276
+ * it said. */
1277
+ async function exchange(
1278
+ greeting: Record<string, unknown>,
1279
+ request: Record<string, unknown>,
1280
+ ): Promise<unknown> {
1109
1281
  const paths = resolvePaths();
1110
1282
  const conn = await connect(paths.socket);
1111
1283
  if (conn === undefined) {
@@ -1115,15 +1287,9 @@ async function call(
1115
1287
  );
1116
1288
  }
1117
1289
  try {
1118
- const greeting = await conn.ask({
1119
- op: "hello",
1120
- role: "session",
1121
- sid,
1122
- protocol_version: PROTOCOL_VERSION,
1123
- ...meta,
1124
- });
1125
- if (greeting["ok"] !== true) {
1126
- throw new CommandError("forbidden", `hello が拒否されました: ${JSON.stringify(greeting)}`);
1290
+ const greeted = await conn.ask(greeting);
1291
+ if (greeted["ok"] !== true) {
1292
+ throw new CommandError("forbidden", `hello が拒否されました: ${JSON.stringify(greeted)}`);
1127
1293
  }
1128
1294
  const answer = await conn.ask(request);
1129
1295
  if (answer["ok"] !== true) {
@@ -63,6 +63,16 @@ ccmsg peers --all 他ホストの instance が知っている分も含め
63
63
  根拠をこちらで要約し直しても情報は増えず、時間とコンテキストだけが減る。人に言うのは
64
64
  自セッション目線の事実 (何を頼んだ・何が返り・その結果こちらが何をしたか) だけ。
65
65
 
66
+ ## 別のセッションのやり方を読む
67
+
68
+ \`\`\`
69
+ ccmsg dump <sid> --preset howto 親セッションが何を考えて何を叩いたか
70
+ ccmsg dump <sid>/agent-<id> --preset howto その worker 自身のやり口
71
+ \`\`\`
72
+
73
+ 出力の末尾に ids 台帳があり、そこに出た \`agent\` の id が 2 行目の \`<id>\` になる。
74
+ preset の一覧は \`ccmsg dump presets\`。
75
+
66
76
  ## 見ている人へ知らせる
67
77
 
68
78
  \`\`\`
@@ -43,6 +43,11 @@ const NOT_ITEMS = new Set([
43
43
  "summary",
44
44
  ]);
45
45
 
46
+ /** The tools that start an agent, in the spellings the harness has used for
47
+ * the one thing. Both are read the same way: the call is also a brief, and
48
+ * what comes back is also an answer. */
49
+ const SPAWNS = new Set(["Agent", "Task"]);
50
+
46
51
  /** What an item is under construction: the contract's shape, before it is
47
52
  * settled. A call learns the id of what answered it only when the answer
48
53
  * arrives, which is why these are written to after they are made. */
@@ -184,7 +189,7 @@ class Classification {
184
189
  ...(fields ?? { input }),
185
190
  });
186
191
  let message: Draft | undefined;
187
- if (name === "Agent") {
192
+ if (SPAWNS.has(name)) {
188
193
  message = make("message:sub:out", {
189
194
  role: "use",
190
195
  prompt: str(input["prompt"]) ?? "",
@@ -247,10 +252,25 @@ class Classification {
247
252
  call.tool["result_item"] = item.uuid;
248
253
  // An agent's id is known only once it has started, so the message that
249
254
  // 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;
255
+ const result = row(answer) ?? {};
256
+ const agent = str(result["agentId"]) ?? str(result["agent_id"]);
257
+ if (call.message === undefined) return;
258
+ if (agent !== undefined) call.message["agent_id"] = agent;
259
+ // An agent that was waited on answers here, in the call's own result. One
260
+ // started in the background answers much later in a notification of its
261
+ // own, and this result then says only that it was launched — so what
262
+ // decides is whether an answer came back, not which tool was called.
263
+ const said = answered(result["content"]);
264
+ if (said === undefined) return;
265
+ const reply = make("message:sub:in", {
266
+ role: "result",
267
+ parent_item: call.message.uuid,
268
+ text: said,
269
+ ...optional("agent_id", agent),
270
+ ...optional("status", str(result["status"])),
271
+ ...optional("duration_ms", count(result["totalDurationMs"])),
272
+ });
273
+ call.message["result_item"] = reply.uuid;
254
274
  }
255
275
 
256
276
  /** A `type: "user"` line whose content is words rather than a tool's answer.
@@ -385,6 +405,15 @@ function attribute(said: string, name: string): string | undefined {
385
405
  return new RegExp(`${name}="([^"]*)"`).exec(said)?.[1] || undefined;
386
406
  }
387
407
 
408
+ /** What an agent handed back, which the harness writes as the blocks of a
409
+ * message. Nothing back is not an answer of no words: a launch says only that
410
+ * the agent started, and reading that as an empty answer would claim it had
411
+ * finished. */
412
+ function answered(raw: unknown): string | undefined {
413
+ const said = text(raw)?.trim();
414
+ return said === undefined || said === "" ? undefined : said;
415
+ }
416
+
388
417
  /** A content field that is sometimes a string and sometimes the blocks of
389
418
  * one. */
390
419
  function text(raw: unknown): string | undefined {
@@ -0,0 +1,214 @@
1
+ import type { DumpIdEntry, SessionDumpFile } from "@ccmsg/protocol";
2
+ import type { Item } from "./item.ts";
3
+ import { elapsed, fragment, words } from "./render.ts";
4
+
5
+ /** A whole dump as one document.
6
+ *
7
+ * What the file holds is items in the order the transcript had them, and what
8
+ * a person reads is that same order with the pairs put back together: a call
9
+ * and the answer that came straight back read as one thing, and an answer that
10
+ * arrived twenty turns later reads where it arrived, saying which call it
11
+ * belongs to. Folding is decided here rather than in the classification,
12
+ * because it is a fact about how far apart two items ended up in this
13
+ * particular selection and not about what either of them is.
14
+ *
15
+ * An agent is the exception: its answer is drawn under the brief that asked
16
+ * for it however many turns apart they are. The pair is a conversation with
17
+ * somebody else, and a conversation split across the page is one nobody can
18
+ * follow. */
19
+
20
+ /** What the file cannot say about itself: which instance wrote it, and the
21
+ * bounds the request was made with. The file states the selection because the
22
+ * selection decides what is inside it; a bound decides only where it stops,
23
+ * and a reader who wants it is told here. */
24
+ export interface DumpView {
25
+ readonly instance?: string;
26
+ readonly since?: string;
27
+ readonly until?: string;
28
+ /** How much of one item's body is drawn before the rest is reported by its
29
+ * length. Nothing is cut when nobody says: a dump is read to find out what
30
+ * was actually written, and the reader who wants less is the one who knows
31
+ * how much less. */
32
+ readonly max_chars?: number;
33
+ }
34
+
35
+ const INDENT = " ";
36
+
37
+ export function document(file: SessionDumpFile, view: DumpView = {}): string {
38
+ const items = file.items as unknown as Item[];
39
+ const paired = pair(items);
40
+ const lines: string[] = [...heading(file, view)];
41
+ lines.push("## items", "");
42
+ if (items.length === 0) lines.push("(なし)", "");
43
+ for (let at = 0; at < items.length; at += 1) {
44
+ if (paired.folded.has(at)) continue;
45
+ const item = items[at] as Item;
46
+ const child = paired.child.get(at);
47
+ lines.push(...draw(item, child === undefined ? undefined : (items[child] as Item), view), "");
48
+ }
49
+ lines.push(...ledger(file.ids));
50
+ return `${lines.join("\n").trimEnd()}\n`;
51
+ }
52
+
53
+ /** What this is a dump of, before anything that happened in it. */
54
+ function heading(file: SessionDumpFile, view: DumpView): string[] {
55
+ const subject = file.agent_id === undefined ? file.sid : `${file.sid}/agent-${file.agent_id}`;
56
+ const lines = [`# dump ${subject}`, ""];
57
+ lines.push(`- 対象: \`${subject}\``);
58
+ if (view.instance !== undefined) lines.push(`- instance: \`${view.instance}\``);
59
+ lines.push(`- 書き出し: ${new Date(file.written_at).toISOString()}`);
60
+ lines.push(`- types: ${file.types.map((one) => `\`${one}\``).join(" ") || "(既定)"}`);
61
+ const bounds = words(
62
+ view.since === undefined ? undefined : `since=${view.since}`,
63
+ view.until === undefined ? undefined : `until=${view.until}`,
64
+ );
65
+ if (bounds !== "") lines.push(`- 範囲: ${bounds}`);
66
+ lines.push(`- items: ${String(file.items.length)}`, "");
67
+ return lines;
68
+ }
69
+
70
+ /** One item, with whatever was folded into it.
71
+ *
72
+ * A call keeps its own heading and the answer's words are put at the end of
73
+ * it, so `→` reads as "and this came back". An answer drawn where it arrived
74
+ * points the other way, at a call the reader has already gone past. */
75
+ function draw(item: Item, child: Item | undefined, view: DumpView): string[] {
76
+ const own = fragment(item);
77
+ const answer = child === undefined ? undefined : fragment(child);
78
+ const nested = child !== undefined && child.type.startsWith("message:sub");
79
+ const link = isResult(item)
80
+ ? arrow("←", item["parent_item"])
81
+ : (arrow("→", item["result_item"]) ?? (item["role"] === "use" ? "(未着)" : undefined));
82
+ const head = isResult(item)
83
+ ? words(prefix(item), link, own.head, clock(item))
84
+ : words(
85
+ prefix(item),
86
+ own.head,
87
+ link,
88
+ nested || answer === undefined ? undefined : answer.head,
89
+ clock(item),
90
+ );
91
+ const under = [
92
+ ...body(own.body, view),
93
+ ...(answer === undefined || nested ? [] : body(answer.body, view)),
94
+ ];
95
+ const lines = [head, ...under.map((line) => `${INDENT}${line}`)];
96
+ if (!nested || child === undefined || answer === undefined) return lines;
97
+ // The agent's answer, under the brief that asked for it. It keeps a heading
98
+ // of its own — it has its own instant, and often a status the brief could
99
+ // not have known — and is indented to say whose answer it is.
100
+ lines.push(`${INDENT}${words(prefix(child), answer.head, clock(child))}`);
101
+ for (const line of body(answer.body, view)) lines.push(`${INDENT}${INDENT}${line}`);
102
+ return lines;
103
+ }
104
+
105
+ /** `[uuid8] type`, which is how an item is pointed at: the id is what a reader
106
+ * goes back to the transcript with, and the type is what it was read as. */
107
+ function prefix(item: Item): string {
108
+ return `[${item.uuid.slice(0, 8)}] ${item.type}`;
109
+ }
110
+
111
+ function clock(item: Item): string {
112
+ const at = new Date(item.at);
113
+ const time = `${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
114
+ return item.turn === undefined ? time : `${time} turn ${String(item.turn)}`;
115
+ }
116
+
117
+ function two(value: number): string {
118
+ return String(value).padStart(2, "0");
119
+ }
120
+
121
+ function arrow(mark: string, id: unknown): string | undefined {
122
+ return typeof id === "string" && id !== "" ? `${mark} ${id.slice(0, 8)}` : undefined;
123
+ }
124
+
125
+ function isResult(item: Item): boolean {
126
+ return item["role"] === "result";
127
+ }
128
+
129
+ /** The lines under a heading, cut only where a reader asked for a cut. */
130
+ function body(source: readonly string[], view: DumpView): string[] {
131
+ const limit = view.max_chars;
132
+ if (limit === undefined || limit <= 0) return [...source];
133
+ const kept: string[] = [];
134
+ let held = 0;
135
+ for (const line of source) {
136
+ if (held + line.length <= limit) {
137
+ kept.push(line);
138
+ held += line.length + 1;
139
+ continue;
140
+ }
141
+ const room = Math.max(0, limit - held);
142
+ const rest = source.join("\n").length - held - room;
143
+ if (room > 0) kept.push(line.slice(0, room));
144
+ kept.push(`… (残り ${String(Math.max(rest, 0))} 文字)`);
145
+ break;
146
+ }
147
+ return kept;
148
+ }
149
+
150
+ /** The ids the items carried, which is what a reader descends by: the agent
151
+ * that did the thing worth copying is named here, and dumping it is the same
152
+ * request with that id as its subject. */
153
+ function ledger(ids: readonly DumpIdEntry[]): string[] {
154
+ const lines = ["## ids", ""];
155
+ if (ids.length === 0) return [...lines, "(なし)"];
156
+ lines.push("| kind | id | label | status |", "|---|---|---|---|");
157
+ for (const entry of ids) {
158
+ const status = words(entry.status, elapsed(entry.duration_ms));
159
+ lines.push(
160
+ `| ${cell(entry.kind)} | \`${cell(entry.id)}\` | ${cell(entry.label ?? "")} | ${cell(status)} |`,
161
+ );
162
+ }
163
+ return lines;
164
+ }
165
+
166
+ function cell(text: string): string {
167
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
168
+ }
169
+
170
+ /** Which answer belongs to which call, and which of those are drawn together.
171
+ *
172
+ * A tool's two halves are matched on the id the harness pairs them with, so
173
+ * two calls in the same record are never confused for one another. An agent's
174
+ * are matched on the record the brief was written in and the agent that
175
+ * answered, which is what the classification filled in once the agent had
176
+ * started. */
177
+ function pair(items: readonly Item[]): {
178
+ child: Map<number, number>;
179
+ folded: Set<number>;
180
+ } {
181
+ const child = new Map<number, number>();
182
+ const folded = new Set<number>();
183
+ const waiting = new Map<string, number[]>();
184
+ for (let at = 0; at < items.length; at += 1) {
185
+ const item = items[at] as Item;
186
+ // Which half of an exchange this is, which the contract calls an item's
187
+ // role and nothing here confuses with who is allowed to ask for one.
188
+ const half = item["role"];
189
+ if (half === "use") {
190
+ const queue = waiting.get(key(item, false));
191
+ if (queue === undefined) waiting.set(key(item, false), [at]);
192
+ else queue.push(at);
193
+ continue;
194
+ }
195
+ if (half !== "result") continue;
196
+ const call = waiting.get(key(item, true))?.shift();
197
+ if (call === undefined) continue;
198
+ // A pair the reader would have to scroll between is left where each half
199
+ // happened, unless it is an agent's: what an agent was asked and what it
200
+ // answered are one exchange whatever fell between them.
201
+ if (!item.type.startsWith("message:sub") && call !== at - 1) continue;
202
+ child.set(call, at);
203
+ folded.add(at);
204
+ }
205
+ return { child, folded };
206
+ }
207
+
208
+ function key(item: Item, result: boolean): string {
209
+ const call = item["tool_use_id"];
210
+ if (typeof call === "string" && call !== "") return `${item.type}${call}`;
211
+ const record = result ? item["parent_item"] : item.uuid;
212
+ const agent = item["agent_id"];
213
+ return `sub${String(record)}${typeof agent === "string" ? agent : ""}`;
214
+ }
@@ -1,4 +1,6 @@
1
1
  export { classify } from "./classify.ts";
2
+ export { document, type DumpView } from "./document.ts";
2
3
  export type { Item } from "./item.ts";
3
4
  export { ledger } from "./ids.ts";
5
+ export { type Fragment, fragment } from "./render.ts";
4
6
  export { type Ask, type Selection, select, selection } from "./select.ts";
@@ -0,0 +1,421 @@
1
+ import type { Item } from "./item.ts";
2
+
3
+ /** One item as the words a person reads.
4
+ *
5
+ * A type is drawn by one function, the way the same type is drawn by one
6
+ * component where the destination is a screen instead of text. What the two
7
+ * share is the classification; how a `tool:Bash` reads is the drawing's own
8
+ * business, and neither side carries the other's.
9
+ *
10
+ * A type nobody wrote a drawing for is still drawn. The generic shape says the
11
+ * type name and lays out whatever fields the item carried, so a tool that
12
+ * arrived after this file was written reads worse than a known one and is
13
+ * never missing — a line that vanishes quietly is the failure a dump cannot be
14
+ * read around. */
15
+
16
+ /** What one item says about itself: the words that belong on its heading, and
17
+ * the lines that go under it.
18
+ *
19
+ * Split because a call and its answer are two items that often read as one:
20
+ * folding them puts the answer's heading words at the end of the call's
21
+ * heading and its lines under the call's, and neither piece has to know
22
+ * whether that happened. */
23
+ export interface Fragment {
24
+ readonly head: string;
25
+ readonly body: readonly string[];
26
+ }
27
+
28
+ /** How a type is drawn. */
29
+ type Draw = (item: Item) => Fragment;
30
+
31
+ const EMPTY: readonly string[] = [];
32
+
33
+ /** The drawing for one item, whatever its type. */
34
+ export function fragment(item: Item): Fragment {
35
+ const type = item.type;
36
+ if (type.startsWith("tool:")) {
37
+ const name = type.slice("tool:".length);
38
+ const result = isResult(item);
39
+ const draw = (result ? RESULTS : USES)[name];
40
+ if (draw !== undefined) return draw(item);
41
+ // A tool nothing knows the shape of arrives carrying what it was called
42
+ // with and what it answered, which is what the generic shape lays out.
43
+ return { head: "", body: summary(item[result ? "result" : "input"]) };
44
+ }
45
+ const draw = ITEMS[type];
46
+ if (draw !== undefined) return draw(item);
47
+ if (type.startsWith("hook:")) return hook(item);
48
+ if (type.startsWith("system:attachment:")) return { head: "", body: summary(item["attachment"]) };
49
+ return { head: "", body: summary(own(item)) };
50
+ }
51
+
52
+ function isResult(item: Item): boolean {
53
+ return item["role"] === "result";
54
+ }
55
+
56
+ // --- message, thinking and the harness's own voice ---
57
+
58
+ /** The types whose whole content is what was said. Their body is the words,
59
+ * kept as they were written: a dump is read to find out what somebody actually
60
+ * wrote, and a reader who wants less asks for less. */
61
+ const SAID: readonly string[] = [
62
+ "message:user:in",
63
+ "message:user:out",
64
+ "thinking",
65
+ "system:compact",
66
+ ];
67
+
68
+ const ITEMS: Record<string, Draw> = {
69
+ ...Object.fromEntries(SAID.map((type) => [type, said])),
70
+
71
+ "message:sub:out": (item) => ({
72
+ head: words(
73
+ field(item, "agent_id", "agent="),
74
+ field(item, "subagent_type", "type="),
75
+ field(item, "name", "name="),
76
+ str(item, "description"),
77
+ ),
78
+ body: lines(str(item, "prompt")),
79
+ }),
80
+
81
+ "message:sub:in": (item) => ({
82
+ head: words(
83
+ field(item, "agent_id", "agent="),
84
+ field(item, "status", "status="),
85
+ elapsed(num(item, "duration_ms")),
86
+ ),
87
+ body: lines(str(item, "text")),
88
+ }),
89
+
90
+ "message:session:out": (item) => ({
91
+ head: words(field(item, "to", "to="), field(item, "reply_to", "reply_to="), mid(item)),
92
+ body: lines(str(item, "text")),
93
+ }),
94
+
95
+ "message:session:in": (item) => ({
96
+ head: words(field(item, "from", "from="), mid(item)),
97
+ body: lines(str(item, "text")),
98
+ }),
99
+
100
+ // A person operated the harness, or the harness spoke in someone else's
101
+ // voice. Both are why a conversation jumps rather than part of it, so they
102
+ // are a line each and the line names what happened. Everything the record
103
+ // held is in the JSON dump beside this one, addressable by the id shown.
104
+ "notice:slash": (item) => ({
105
+ head: words(`/${str(item, "command") ?? ""}`, str(item, "args"), first(str(item, "stdout"))),
106
+ body: EMPTY,
107
+ }),
108
+ "notice:interrupt": (item) => ({ head: first(str(item, "text")) ?? "", body: EMPTY }),
109
+ "system:api-error": (item) => ({ head: first(str(item, "text")) ?? "", body: EMPTY }),
110
+ "system:caveat": (item) => ({ head: first(str(item, "text")) ?? "", body: EMPTY }),
111
+ "system:resume": (item) => ({ head: first(str(item, "text")) ?? "", body: EMPTY }),
112
+ "system:task": (item) => ({
113
+ head: words(
114
+ field(item, "task_id", "task="),
115
+ field(item, "event", "event="),
116
+ first(str(item, "text")),
117
+ ),
118
+ body: EMPTY,
119
+ }),
120
+ "system:unknown": (item) => ({ head: "", body: summary(item["record"]) }),
121
+ };
122
+
123
+ function said(item: Item): Fragment {
124
+ return { head: "", body: lines(str(item, "text")) };
125
+ }
126
+
127
+ function mid(item: Item): string | undefined {
128
+ return field(item, "msg_id", "mid=");
129
+ }
130
+
131
+ /** The operator's own code, and what it did with its turn. */
132
+ function hook(item: Item): Fragment {
133
+ return {
134
+ head: words(
135
+ str(item, "hook_name"),
136
+ str(item, "outcome"),
137
+ field(item, "tool_use_id", "tool="),
138
+ exit(num(item, "exit_code")),
139
+ elapsed(num(item, "duration_ms")),
140
+ field(item, "stderr", "stderr="),
141
+ ),
142
+ body: lines(str(item, "content")),
143
+ };
144
+ }
145
+
146
+ function exit(code: number | undefined): string | undefined {
147
+ return code === undefined ? undefined : `exit=${String(code)}`;
148
+ }
149
+
150
+ // --- tools ---
151
+
152
+ /** What each call says on its heading and under it.
153
+ *
154
+ * The command, the path, the pattern: the one thing that says which call this
155
+ * was goes on the heading, and a body is for what a reader has to look at line
156
+ * by line rather than recognise at a glance. */
157
+ const USES: Record<string, Draw> = {
158
+ Bash: (item) => ({
159
+ head: str(item, "description") ?? "",
160
+ body: lines(str(item, "command")).map((line) => `$ ${line}`),
161
+ }),
162
+ Read: (item) => ({
163
+ head: words(str(item, "file_path"), at(item)),
164
+ body: EMPTY,
165
+ }),
166
+ Write: (item) => ({
167
+ head: words(str(item, "file_path"), rows(num(item, "lines"))),
168
+ body: EMPTY,
169
+ }),
170
+ Edit: (item) => ({
171
+ head: words(str(item, "file_path"), edited(item)),
172
+ body: EMPTY,
173
+ }),
174
+ Grep: pattern,
175
+ Glob: pattern,
176
+ WebFetch: (item) => ({ head: words(str(item, "url"), str(item, "prompt")), body: EMPTY }),
177
+ WebSearch: (item) => ({ head: field(item, "query", "query=") ?? "", body: EMPTY }),
178
+ // The brief itself is the `message:sub:out` beside this call, so the call
179
+ // says which agent was started and leaves the words to the message.
180
+ Agent: (item) => ({
181
+ head: words(
182
+ field(item, "subagent_type", "type="),
183
+ field(item, "name", "name="),
184
+ str(item, "description"),
185
+ ),
186
+ body: EMPTY,
187
+ }),
188
+ SendMessage: (item) => ({
189
+ head: words(field(item, "to", "to="), str(item, "summary")),
190
+ body: EMPTY,
191
+ }),
192
+ Monitor: (item) => ({
193
+ head: words(
194
+ str(item, "description"),
195
+ item["persistent"] === true ? "persistent" : undefined,
196
+ until(num(item, "timeout_ms")),
197
+ ),
198
+ body: lines(str(item, "command")).map((line) => `$ ${line}`),
199
+ }),
200
+ Skill: (item) => ({ head: words(str(item, "skill"), str(item, "args")), body: EMPTY }),
201
+ TodoWrite: (item) => {
202
+ const todos = list(item["todos"]);
203
+ const width = Math.max(0, ...todos.map((todo) => todo.status.length));
204
+ return {
205
+ head: `${String(todos.length)} items`,
206
+ body: todos.map((todo) => `${todo.status.padEnd(width)} ${todo.content}`),
207
+ };
208
+ },
209
+ TaskStop: (item) => ({ head: field(item, "task_id", "task=") ?? "", body: EMPTY }),
210
+ CronCreate: (item) => ({
211
+ head: str(item, "cron") ?? "",
212
+ body: lines(str(item, "prompt")),
213
+ }),
214
+ };
215
+
216
+ /** What each answer says, on the heading it is folded into or on its own.
217
+ *
218
+ * An answer that is a fact about the call — how many lines, which id — is
219
+ * heading words, so a folded pair reads as one line. An answer somebody has to
220
+ * read is a body. */
221
+ const RESULTS: Record<string, Draw> = {
222
+ Bash: (item) => ({
223
+ head: item["interrupted"] === true ? "中断" : "",
224
+ body: [...stream("stdout", str(item, "stdout")), ...stream("stderr", str(item, "stderr"))],
225
+ }),
226
+ Read: (item) => ({
227
+ head: words(rows(num(item, "lines")), bytes(num(item, "bytes"))),
228
+ body: EMPTY,
229
+ }),
230
+ Write: ok,
231
+ Edit: ok,
232
+ Grep: hits,
233
+ Glob: hits,
234
+ WebFetch: (item) => ({ head: "", body: lines(str(item, "text")) }),
235
+ WebSearch: (item) => {
236
+ const found = num(item, "results");
237
+ return { head: found === undefined ? "" : `${String(found)} results`, body: EMPTY };
238
+ },
239
+ Agent: (item) => ({
240
+ head: words(field(item, "agent_id", "agent="), field(item, "status", "status=")),
241
+ body: EMPTY,
242
+ }),
243
+ SendMessage: (item) => ({
244
+ head: words(mid(item), field(item, "routing", "routing=")),
245
+ body: EMPTY,
246
+ }),
247
+ Monitor: (item) => ({ head: field(item, "task_id", "task=") ?? "", body: EMPTY }),
248
+ Skill: (item) => ({
249
+ head: words(
250
+ field(item, "agent_id", "agent="),
251
+ item["background"] === true ? "background" : undefined,
252
+ field(item, "status", "status="),
253
+ ),
254
+ body: EMPTY,
255
+ }),
256
+ TodoWrite: ok,
257
+ TaskStop: ok,
258
+ CronCreate: (item) => ({ head: field(item, "cron_id", "cron=") ?? "", body: EMPTY }),
259
+ };
260
+
261
+ function pattern(item: Item): Fragment {
262
+ return {
263
+ head: words(field(item, "pattern", "pattern="), field(item, "path", "path=")),
264
+ body: EMPTY,
265
+ };
266
+ }
267
+
268
+ /** A tool that says nothing but whether it worked. Success is the silent case:
269
+ * a heading crowded with `ok` is a heading nobody reads. */
270
+ function ok(item: Item): Fragment {
271
+ return { head: item["ok"] === false ? "失敗" : "", body: EMPTY };
272
+ }
273
+
274
+ function hits(item: Item): Fragment {
275
+ const found = num(item, "matches");
276
+ return { head: found === undefined ? "" : `${String(found)} hits`, body: EMPTY };
277
+ }
278
+
279
+ /** One of a shell call's two streams, kept whole. A single line sits beside
280
+ * its name; more than one goes under it, because a stream that needs reading
281
+ * needs its own left edge. */
282
+ function stream(name: string, text: string | undefined): string[] {
283
+ const rows = lines(text);
284
+ if (rows.length === 0) return [];
285
+ if (rows.length === 1) return [`${name} ${rows[0] as string}`];
286
+ return [name, ...rows.map((line) => ` ${line}`)];
287
+ }
288
+
289
+ function at(item: Item): string | undefined {
290
+ const offset = num(item, "offset");
291
+ const limit = num(item, "limit");
292
+ if (offset === undefined && limit === undefined) return undefined;
293
+ return `${String(offset ?? 0)}+${limit === undefined ? "" : String(limit)}`;
294
+ }
295
+
296
+ function edited(item: Item): string | undefined {
297
+ const old = num(item, "old_lines");
298
+ const fresh = num(item, "new_lines");
299
+ if (old === undefined && fresh === undefined) return undefined;
300
+ return `-${String(old ?? 0)} +${String(fresh ?? 0)}`;
301
+ }
302
+
303
+ function rows(count: number | undefined): string | undefined {
304
+ return count === undefined ? undefined : `${String(count)} 行`;
305
+ }
306
+
307
+ function bytes(count: number | undefined): string | undefined {
308
+ return count === undefined ? undefined : `${String(count)} B`;
309
+ }
310
+
311
+ function until(ms: number | undefined): string | undefined {
312
+ return ms === undefined ? undefined : `timeout=${elapsed(ms) ?? ""}`;
313
+ }
314
+
315
+ // --- the pieces every drawing is made of ---
316
+
317
+ function str(item: Item, name: string): string | undefined {
318
+ const value = item[name];
319
+ return typeof value === "string" && value !== "" ? value : undefined;
320
+ }
321
+
322
+ function num(item: Item, name: string): number | undefined {
323
+ const value = item[name];
324
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
325
+ }
326
+
327
+ function field(item: Item, name: string, label: string): string | undefined {
328
+ const value = str(item, name);
329
+ return value === undefined ? undefined : `${label}${value}`;
330
+ }
331
+
332
+ /** Heading words, separated by the two spaces that keep them apart without
333
+ * inventing a syntax to parse. */
334
+ export function words(...parts: (string | undefined)[]): string {
335
+ return parts.filter((part) => part !== undefined && part !== "").join(" ");
336
+ }
337
+
338
+ function lines(text: string | undefined): string[] {
339
+ if (text === undefined) return [];
340
+ const body = text.replace(/\s+$/, "");
341
+ return body === "" ? [] : body.split("\n");
342
+ }
343
+
344
+ /** The first line of something that is drawn as one line, saying that there is
345
+ * more where the rest was left in the file beside this one. */
346
+ function first(text: string | undefined): string | undefined {
347
+ const rows = lines(text);
348
+ const head = rows[0];
349
+ if (head === undefined) return undefined;
350
+ return rows.length === 1 ? head : `${head} …`;
351
+ }
352
+
353
+ /** A duration in the units a person compares them in. */
354
+ export function elapsed(ms: number | undefined): string | undefined {
355
+ if (ms === undefined) return undefined;
356
+ if (ms < 1_000) return `${String(Math.round(ms))}ms`;
357
+ const seconds = Math.round(ms / 1_000);
358
+ if (seconds < 60) return `${String(seconds)}s`;
359
+ return `${String(Math.floor(seconds / 60))}m${String(seconds % 60).padStart(2, "0")}s`;
360
+ }
361
+
362
+ interface Todo {
363
+ readonly content: string;
364
+ readonly status: string;
365
+ }
366
+
367
+ function list(value: unknown): Todo[] {
368
+ if (!Array.isArray(value)) return [];
369
+ return value.flatMap((entry) => {
370
+ if (typeof entry !== "object" || entry === null) return [];
371
+ const each = entry as Record<string, unknown>;
372
+ const content = each["content"];
373
+ const status = each["status"];
374
+ return typeof content === "string" && typeof status === "string" ? [{ content, status }] : [];
375
+ });
376
+ }
377
+
378
+ /** The base fields every item has, which the heading already said. What is
379
+ * left is the type's own, and that is what a generic drawing lays out. */
380
+ const BASE = new Set(["uuid", "type", "at", "turn", "role", "result_item", "parent_item"]);
381
+
382
+ function own(item: Item): Record<string, unknown> {
383
+ return Object.fromEntries(Object.entries(item).filter(([name]) => !BASE.has(name)));
384
+ }
385
+
386
+ /** How deep a value nobody wrote a drawing for is laid out. Two levels is what
387
+ * shows a record's shape — its fields, and what each of them is — without the
388
+ * drawing becoming the file. */
389
+ const DEPTH = 2;
390
+
391
+ /** How long a value is quoted before it is reported by its length instead. */
392
+ const VALUE = 200;
393
+
394
+ /** Whatever it was, as lines under a heading.
395
+ *
396
+ * Not JSON: the reader of a dump is looking for what happened, and a nested
397
+ * object's punctuation is in the way of that. One field per line, flattened by
398
+ * the path it sits at, with what is deeper than the layout goes said by its
399
+ * shape rather than shown. */
400
+ function summary(value: unknown, depth = DEPTH, path = ""): string[] {
401
+ if (value === undefined) return [];
402
+ if (typeof value !== "object" || value === null)
403
+ return [`${path}${path === "" ? "" : " "}${scalar(value)}`];
404
+ if (Array.isArray(value)) {
405
+ if (depth <= 0) return [`${path} [${String(value.length)} 件]`];
406
+ return value.flatMap((entry, index) => summary(entry, depth - 1, `${path}[${String(index)}]`));
407
+ }
408
+ const fields = Object.entries(value as Record<string, unknown>);
409
+ if (depth <= 0) return [`${path} {${fields.map(([name]) => name).join(", ")}}`];
410
+ return fields.flatMap(([name, each]) =>
411
+ summary(each, depth - 1, path === "" ? name : `${path}.${name}`),
412
+ );
413
+ }
414
+
415
+ function scalar(value: unknown): string {
416
+ if (typeof value !== "string") return String(value);
417
+ const single = value.replace(/\s+/g, " ").trim();
418
+ return single.length <= VALUE
419
+ ? single
420
+ : `${single.slice(0, VALUE)}… (${String(single.length)} 文字)`;
421
+ }
@@ -196,6 +196,12 @@ const RESULT: Record<string, (result: unknown, failed: boolean) => Record<string
196
196
  },
197
197
  };
198
198
 
199
+ /** `Task` is the harness's older name for the tool that starts an agent. The
200
+ * type keeps whichever name the record used — a reader matches what it sees
201
+ * against what it ran — and both are read the same way. */
202
+ USE["Task"] = USE["Agent"] as Reader;
203
+ RESULT["Task"] = RESULT["Agent"] as (result: unknown, failed: boolean) => Record<string, unknown>;
204
+
199
205
  function matches(result: unknown): Record<string, unknown> {
200
206
  const fields = row(result);
201
207
  if (fields === undefined) return {};