@ccmsg/cli 0.3.5 → 0.4.1

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,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
+ }
Binary file
@@ -0,0 +1,4 @@
1
+ export { classify } from "./classify.ts";
2
+ export type { Item } from "./item.ts";
3
+ export { ledger } from "./ids.ts";
4
+ 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
+ }
@@ -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
+ }