@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.
@@ -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
+ }
@@ -0,0 +1,144 @@
1
+ import type { DumpPreset } from "@ccmsg/protocol";
2
+ import type { Item } from "./item.ts";
3
+
4
+ /** Which of a transcript's items a dump keeps.
5
+ *
6
+ * A selection is a list read left to right, where each element is a type name,
7
+ * a prefix of one, either of those negated with `-`, or `@name` standing for a
8
+ * preset expanded in place. Order is what makes it usable: a prefix brings a
9
+ * family in and an exclusion after it takes one member back out, which is the
10
+ * shape a person actually reaches for — every tool but the reads, the whole
11
+ * conversation but not the thinking.
12
+ *
13
+ * A prefix matches at segment boundaries, so `tool` reaches `tool:Bash` and
14
+ * `message:user` reaches both directions, while `notice` never reaches a type
15
+ * that merely starts with those letters. */
16
+
17
+ /** What a dump keeps when nobody said: every family there is, less the
18
+ * attachments — the harness furnishing a turn rather than anything that
19
+ * happened in it, and more numerous than everything else together.
20
+ *
21
+ * Written as selectors a person could have typed, rather than as a wildcard
22
+ * this alone understands, because the file states the selection it was written
23
+ * under and a reader of that file has only the one vocabulary. */
24
+ const DEFAULT_TYPES = [
25
+ "message",
26
+ "thinking",
27
+ "tool",
28
+ "notice",
29
+ "system",
30
+ "hook",
31
+ "-system:attachment",
32
+ ];
33
+
34
+ export interface Selection {
35
+ /** Whether an item of this type is kept. */
36
+ readonly keeps: (type: string) => boolean;
37
+ /** The selection as applied: presets expanded and exclusions in place, which
38
+ * is what the dump file repeats so it says on its own what was left out. */
39
+ readonly elements: readonly string[];
40
+ }
41
+
42
+ /** What a dump was asked to keep.
43
+ *
44
+ * `preset` is the ground and `types` is applied over it, so naming both means
45
+ * "that one, with these changes" rather than one silently replacing the other.
46
+ * The two flags say in one word what the selection says in its own vocabulary,
47
+ * and are applied last for that reason: whatever brought thinking or an
48
+ * agent's machinery in, saying `no` takes it back out. */
49
+ export interface Ask {
50
+ readonly types?: readonly string[];
51
+ readonly preset?: DumpPreset;
52
+ readonly no_thinking?: boolean;
53
+ readonly no_agent?: boolean;
54
+ }
55
+
56
+ const NO_THINKING = ["-thinking"];
57
+ const NO_AGENT = ["-message:sub", "-tool:Agent"];
58
+
59
+ export function selection(ask: Ask, presets: readonly DumpPreset[]): Selection {
60
+ const asked = [...(ask.preset?.opts.types ?? []), ...(ask.types ?? [])];
61
+ const elements = expand(
62
+ [
63
+ ...(asked.length === 0 ? DEFAULT_TYPES : asked),
64
+ ...(ask.no_thinking === true ? NO_THINKING : []),
65
+ ...(ask.no_agent === true ? NO_AGENT : []),
66
+ ],
67
+ presets,
68
+ [],
69
+ );
70
+ const cache = new Map<string, boolean>();
71
+ return {
72
+ keeps: (type) => {
73
+ const known = cache.get(type);
74
+ if (known !== undefined) return known;
75
+ const kept = decide(type, elements);
76
+ cache.set(type, kept);
77
+ return kept;
78
+ },
79
+ elements,
80
+ };
81
+ }
82
+
83
+ /** Whether one type survives the list, reading it left to right: each element
84
+ * that reaches the type sets the answer, and the last one to reach it wins. */
85
+ function decide(type: string, elements: readonly string[]): boolean {
86
+ let kept = false;
87
+ for (const element of elements) {
88
+ const negated = element.startsWith("-");
89
+ const name = negated ? element.slice(1) : element;
90
+ if (reaches(name, type)) kept = !negated;
91
+ }
92
+ return kept;
93
+ }
94
+
95
+ function reaches(name: string, type: string): boolean {
96
+ return type === name || type.startsWith(`${name}:`);
97
+ }
98
+
99
+ /** A preset named in a selection, put where it was named.
100
+ *
101
+ * Expansion happens here rather than at the leaves so an exclusion written
102
+ * after a preset reaches what the preset brought in. Depth is bounded by the
103
+ * chain being walked: a cycle was refused when the config was read, so a name
104
+ * met twice on one path cannot happen and is a programming error rather than
105
+ * an operator's. */
106
+ function expand(
107
+ elements: readonly string[],
108
+ presets: readonly DumpPreset[],
109
+ path: readonly string[],
110
+ ): string[] {
111
+ return elements.flatMap((element) => {
112
+ const negated = element.startsWith("-");
113
+ const name = negated ? element.slice(1) : element;
114
+ if (!name.startsWith("@")) return [element];
115
+ const preset = presets.find((one) => one.name === name.slice(1));
116
+ if (preset === undefined || path.includes(name)) return [];
117
+ const inner = expand(preset.opts.types, presets, [...path, name]);
118
+ // A negated preset is every type it names, taken back out.
119
+ return negated ? inner.map(negate) : inner;
120
+ });
121
+ }
122
+
123
+ function negate(element: string): string {
124
+ return element.startsWith("-") ? element.slice(1) : `-${element}`;
125
+ }
126
+
127
+ /** The items a selection keeps, and how many of each type there were.
128
+ *
129
+ * The count is by type rather than a total because a total leaves the caller
130
+ * unable to tell a dump that kept what it asked for from one whose selection
131
+ * matched almost nothing. */
132
+ export function select(
133
+ items: readonly Item[],
134
+ keep: Selection,
135
+ ): { items: Item[]; entries: Record<string, number> } {
136
+ const kept: Item[] = [];
137
+ const entries: Record<string, number> = {};
138
+ for (const item of items) {
139
+ if (!keep.keeps(item.type)) continue;
140
+ kept.push(item);
141
+ entries[item.type] = (entries[item.type] ?? 0) + 1;
142
+ }
143
+ return { items: kept, entries };
144
+ }