@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,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
+ }
Binary file
@@ -0,0 +1,6 @@
1
+ export { classify } from "./classify.ts";
2
+ export { document, type DumpView } from "./document.ts";
3
+ export type { Item } from "./item.ts";
4
+ export { ledger } from "./ids.ts";
5
+ export { type Fragment, fragment } from "./render.ts";
6
+ export { type Ask, type Selection, select, selection } from "./select.ts";
@@ -0,0 +1,23 @@
1
+ /** One classified item, at the shape the daemon holds it in.
2
+ *
3
+ * The contract states the same shape as a schema, which is what a dump is
4
+ * checked against; what it does not give is a type to write code with — a
5
+ * union of that many object schemas erases to nothing usable — so the three
6
+ * fields every item has are named here and the rest are the type's own. The
7
+ * schema stays the authority: the tests validate what this produces against
8
+ * it, so a field that drifts from the contract fails there rather than
9
+ * travelling. */
10
+ export interface Item {
11
+ /** The record's id in the transcript, which is what makes an item
12
+ * addressable and what the links between items point with. Every item one
13
+ * record became carries it, so a bound by record keeps a turn whole. */
14
+ readonly uuid: string;
15
+ readonly type: string;
16
+ /** The item's own instant. A call and its result each keep their own. */
17
+ readonly at: number;
18
+ /** Which turn of the session it fell in, counted from where a person spoke.
19
+ * Renumbered whenever the file is read again, so it is an attribute to show
20
+ * and never a way to cut a range. */
21
+ readonly turn?: number;
22
+ readonly [field: string]: unknown;
23
+ }
@@ -0,0 +1,71 @@
1
+ /** Reading values out of a transcript line.
2
+ *
3
+ * A transcript is another program's file: every field is optional until it has
4
+ * been looked at, and a line that says something unexpected is a line to read
5
+ * around rather than to fail on. These are the only place that assumption is
6
+ * spelled out, so the classifier below can read a field and get either the
7
+ * value or nothing. */
8
+
9
+ export type Row = Record<string, unknown>;
10
+
11
+ export function isRow(raw: unknown): raw is Row {
12
+ return typeof raw === "object" && raw !== null && !Array.isArray(raw);
13
+ }
14
+
15
+ export function row(raw: unknown): Row | undefined {
16
+ return isRow(raw) ? raw : undefined;
17
+ }
18
+
19
+ export function str(raw: unknown): string | undefined {
20
+ return typeof raw === "string" && raw !== "" ? raw : undefined;
21
+ }
22
+
23
+ export function bool(raw: unknown): boolean | undefined {
24
+ return typeof raw === "boolean" ? raw : undefined;
25
+ }
26
+
27
+ export function count(raw: unknown): number | undefined {
28
+ return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? Math.round(raw) : undefined;
29
+ }
30
+
31
+ export function list(raw: unknown): unknown[] {
32
+ return Array.isArray(raw) ? raw : [];
33
+ }
34
+
35
+ /** An ISO instant as the milliseconds the contract counts in. A line whose
36
+ * clock is missing or unreadable is placed at zero rather than dropped: when
37
+ * it happened is one fact about the item, and the item is the rest. */
38
+ export function instant(raw: unknown): number {
39
+ if (typeof raw !== "string") return 0;
40
+ const at = Date.parse(raw);
41
+ return Number.isFinite(at) ? Math.max(0, Math.round(at)) : 0;
42
+ }
43
+
44
+ /** The text of a `<tag>` in one of the harness's angle-bracket envelopes.
45
+ *
46
+ * The envelopes are written by the harness for a person to read, not parsed
47
+ * back by anything that wrote them, so this reads them the way a person does:
48
+ * the first opening tag to its matching close, with no nesting assumed. */
49
+ export function tagged(text: string, tag: string): string | undefined {
50
+ const open = `<${tag}>`;
51
+ const from = text.indexOf(open);
52
+ if (from < 0) return undefined;
53
+ const to = text.indexOf(`</${tag}>`, from + open.length);
54
+ if (to < 0) return undefined;
55
+ const found = text.slice(from + open.length, to).trim();
56
+ return found === "" ? undefined : found;
57
+ }
58
+
59
+ /** Fields whose value is `undefined` are left out rather than written as null:
60
+ * the contract's optionals mean absent, and a present null is neither. */
61
+ export function optional<K extends string, V>(
62
+ name: K,
63
+ value: V | undefined,
64
+ ): Record<K, V> | Record<string, never> {
65
+ return value === undefined ? {} : ({ [name]: value } as Record<K, V>);
66
+ }
67
+
68
+ export function lines(text: string | undefined): number | undefined {
69
+ if (text === undefined) return undefined;
70
+ return text.split("\n").length;
71
+ }