@ccmsg/cli 0.3.4 → 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.
- package/package.json +2 -2
- package/src/cli.ts +23 -0
- package/src/daemon/registry.ts +61 -3
- package/src/instance/config.ts +161 -5
- package/src/instance/instance.ts +1 -0
- package/src/sessions/dump.ts +91 -53
- package/src/sessions/handlers.ts +14 -1
- package/src/sessions/index.ts +1 -1
- package/src/transcript/items/classify.ts +400 -0
- package/src/transcript/items/ids.ts +0 -0
- package/src/transcript/items/index.ts +4 -0
- package/src/transcript/items/item.ts +23 -0
- package/src/transcript/items/record.ts +71 -0
- package/src/transcript/items/select.ts +144 -0
- package/src/transcript/items/tools.ts +231 -0
|
@@ -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,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
|
+
}
|