@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.
- package/package.json +2 -2
- package/src/cli.ts +176 -10
- package/src/daemon/registry.ts +50 -2
- package/src/instance/config.ts +102 -1
- package/src/instance/instance.ts +1 -0
- package/src/plugin/skill.ts +10 -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/document.ts +214 -0
- package/src/transcript/items/ids.ts +0 -0
- package/src/transcript/items/index.ts +6 -0
- package/src/transcript/items/item.ts +23 -0
- package/src/transcript/items/record.ts +71 -0
- package/src/transcript/items/render.ts +421 -0
- package/src/transcript/items/select.ts +144 -0
- package/src/transcript/items/tools.ts +231 -0
package/src/sessions/dump.ts
CHANGED
|
@@ -1,25 +1,47 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
DumpPreset,
|
|
5
|
+
InstanceId,
|
|
6
|
+
SessionDumpFile,
|
|
7
|
+
SessionDumpWriteArgs,
|
|
8
|
+
SessionDumpWriteResult,
|
|
9
|
+
} from "@ccmsg/protocol";
|
|
4
10
|
import { OpError } from "../dispatch/index.ts";
|
|
5
|
-
import {
|
|
11
|
+
import { classify, type Item, ledger, select, selection } from "../transcript/items/index.ts";
|
|
12
|
+
import type { TranscriptFiles } from "../transcript/index.ts";
|
|
6
13
|
|
|
7
14
|
/** Where dumps land: one directory under this instance's own state, named
|
|
8
15
|
* after the config home it answers for like every other per-instance path
|
|
9
16
|
* (§8.1). The caller never supplies a path, so there is none to contain. */
|
|
10
17
|
export const DUMPS = "dumps";
|
|
11
18
|
|
|
19
|
+
/** What a dump file is called. Two extensions rather than one so that a reader
|
|
20
|
+
* knows both that it is JSON and that it is JSON of a shape the contract
|
|
21
|
+
* states — the file travels by its path, outliving the request that made it,
|
|
22
|
+
* and is opened by whoever was handed that path. */
|
|
23
|
+
export const DUMP_SUFFIX = ".dump.json";
|
|
24
|
+
|
|
12
25
|
export interface DumpDeps {
|
|
13
26
|
readonly self: InstanceId;
|
|
14
27
|
readonly stateDir: string;
|
|
15
28
|
readonly files: TranscriptFiles;
|
|
29
|
+
/** The selections this instance is configured with, which is what a `preset`
|
|
30
|
+
* name and an `@name` inside a selection are resolved against. */
|
|
31
|
+
readonly presets: readonly DumpPreset[];
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
/** Write a session's dump and answer with where it went.
|
|
19
35
|
*
|
|
20
36
|
* What this adds over reading the transcript is a durable artifact whose path
|
|
21
37
|
* can be handed to a successor session, rather than a payload that would
|
|
22
|
-
* travel out through a client and back in again.
|
|
38
|
+
* travel out through a client and back in again.
|
|
39
|
+
*
|
|
40
|
+
* The subject is the session, or one agent below it when the request names
|
|
41
|
+
* one. Every item type is read from wherever the subject stands — an agent's
|
|
42
|
+
* `message:user:in` is the brief its parent gave it — so one selection carries
|
|
43
|
+
* unchanged down a chain of agents, which is what makes the ledger's agent ids
|
|
44
|
+
* a way to descend rather than just a list. */
|
|
23
45
|
export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDumpWriteResult {
|
|
24
46
|
if (args.since_at !== undefined && args.since_uuid !== undefined) {
|
|
25
47
|
throw new OpError("invalid_args", "a lower bound is a time or a record, not both");
|
|
@@ -27,79 +49,95 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
27
49
|
if (args.until_at !== undefined && args.until_uuid !== undefined) {
|
|
28
50
|
throw new OpError("invalid_args", "an upper bound is a time or a record, not both");
|
|
29
51
|
}
|
|
30
|
-
const
|
|
52
|
+
const preset = presetFor(args.preset, deps.presets);
|
|
53
|
+
const file = deps.files.locate(
|
|
54
|
+
args.sid,
|
|
55
|
+
args.agent_id === undefined ? {} : { agent_id: args.agent_id },
|
|
56
|
+
);
|
|
31
57
|
let text: string;
|
|
32
58
|
try {
|
|
33
59
|
text = readFileSync(file, "utf8");
|
|
34
60
|
} catch {
|
|
35
61
|
throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
|
|
36
62
|
}
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
// The whole file is classified before the range is applied, so a result
|
|
64
|
+
// inside the range still names the call that fell before it. Cutting first
|
|
65
|
+
// would leave `parent_item` pointing at something the reader never saw.
|
|
66
|
+
const keep = selection(
|
|
67
|
+
{
|
|
68
|
+
...(args.types === undefined ? {} : { types: args.types }),
|
|
69
|
+
...(preset === undefined ? {} : { preset }),
|
|
70
|
+
...(args.no_thinking === undefined ? {} : { no_thinking: args.no_thinking }),
|
|
71
|
+
...(args.no_agent === undefined ? {} : { no_agent: args.no_agent }),
|
|
72
|
+
},
|
|
73
|
+
deps.presets,
|
|
74
|
+
);
|
|
75
|
+
const { items, entries } = select(within(classify(text.split("\n")), args), keep);
|
|
76
|
+
const ids = ledger(items);
|
|
77
|
+
const written_at = Date.now();
|
|
78
|
+
// The file repeats what it was asked for. A dump outlives the request that
|
|
79
|
+
// made it and is read by whoever was handed the path, so it has to say on
|
|
80
|
+
// its own what it is a dump of and what was left out — which is why the
|
|
81
|
+
// selection is written as applied, with the presets already expanded.
|
|
82
|
+
const document: SessionDumpFile = {
|
|
39
83
|
sid: args.sid,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
84
|
+
...(args.agent_id === undefined ? {} : { agent_id: args.agent_id }),
|
|
85
|
+
written_at,
|
|
86
|
+
types: [...keep.elements],
|
|
87
|
+
items,
|
|
88
|
+
ids,
|
|
44
89
|
};
|
|
45
90
|
const dir = join(deps.stateDir, DUMPS);
|
|
46
91
|
mkdirSync(dir, { recursive: true });
|
|
47
|
-
const
|
|
92
|
+
const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
|
|
93
|
+
const path = join(dir, `${named}-${written_at}${DUMP_SUFFIX}`);
|
|
48
94
|
const body = `${JSON.stringify(document, undefined, 2)}\n`;
|
|
49
95
|
writeFileSync(path, body);
|
|
50
|
-
return {
|
|
51
|
-
path,
|
|
52
|
-
instance: deps.self,
|
|
53
|
-
entries: entries.length,
|
|
54
|
-
bytes: Buffer.byteLength(body),
|
|
55
|
-
};
|
|
96
|
+
return { path, instance: deps.self, entries, ids, bytes: Buffer.byteLength(body) };
|
|
56
97
|
}
|
|
57
98
|
|
|
58
|
-
/**
|
|
99
|
+
/** The named selection a request asked for.
|
|
59
100
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
101
|
+
* A name this instance does not have is refused rather than ignored: a dump
|
|
102
|
+
* silently wider than what was asked for is the failure a selection exists to
|
|
103
|
+
* prevent. */
|
|
104
|
+
function presetFor(
|
|
105
|
+
name: string | undefined,
|
|
106
|
+
presets: readonly DumpPreset[],
|
|
107
|
+
): DumpPreset | undefined {
|
|
108
|
+
if (name === undefined) return undefined;
|
|
109
|
+
const found = presets.find((one) => one.name === name);
|
|
110
|
+
if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
64
113
|
|
|
65
|
-
/** The
|
|
114
|
+
/** The items within the bounds, in the order the transcript holds them.
|
|
66
115
|
*
|
|
67
116
|
* A record bound cuts at that record's position rather than at its clock, so
|
|
68
117
|
* records sharing an instant stay on their own side of the cut — which is the
|
|
69
|
-
* whole reason the contract offers both kinds of bound.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
118
|
+
* whole reason the contract offers both kinds of bound. Every item a record
|
|
119
|
+
* became carries that record's id, so a bound by record keeps a turn's
|
|
120
|
+
* thinking, words and calls together. */
|
|
121
|
+
function within(items: readonly Item[], args: SessionDumpWriteArgs): Item[] {
|
|
122
|
+
const kept: Item[] = [];
|
|
123
|
+
// A lower bound by record starts closed: it opens at the record it names,
|
|
124
|
+
// which is included.
|
|
74
125
|
let open = args.since_uuid === undefined;
|
|
75
|
-
for (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (record === undefined) continue;
|
|
126
|
+
for (let at = 0; at < items.length; at += 1) {
|
|
127
|
+
const item = items[at];
|
|
128
|
+
if (item === undefined) continue;
|
|
79
129
|
if (!open) {
|
|
80
|
-
if (
|
|
130
|
+
if (item.uuid !== args.since_uuid) continue;
|
|
81
131
|
open = true;
|
|
82
132
|
}
|
|
83
|
-
if (args.since_at !== undefined &&
|
|
84
|
-
if (args.until_at !== undefined &&
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
if (args.
|
|
89
|
-
if (
|
|
90
|
-
continue;
|
|
133
|
+
if (args.since_at !== undefined && item.at < args.since_at) continue;
|
|
134
|
+
if (args.until_at !== undefined && item.at > args.until_at) break;
|
|
135
|
+
kept.push(item);
|
|
136
|
+
// An upper bound by record is inclusive and cuts after the last item that
|
|
137
|
+
// record became, so the rest of the same record is still let through.
|
|
138
|
+
if (args.until_uuid !== undefined && item.uuid === args.until_uuid) {
|
|
139
|
+
if (items[at + 1]?.uuid !== item.uuid) break;
|
|
91
140
|
}
|
|
92
|
-
entries.push({
|
|
93
|
-
...(record.uuid === undefined ? {} : { uuid: record.uuid }),
|
|
94
|
-
...(record.said_at === undefined ? {} : { said_at: record.said_at }),
|
|
95
|
-
...(record.said_by === undefined ? {} : { said_by: record.said_by }),
|
|
96
|
-
...(record.text === undefined ? {} : { text: record.text }),
|
|
97
|
-
...(args.no_thinking === true || record.thinking === undefined
|
|
98
|
-
? {}
|
|
99
|
-
: { thinking: record.thinking }),
|
|
100
|
-
});
|
|
101
|
-
// An upper bound by record is inclusive, so the cut is after it.
|
|
102
|
-
if (record.uuid !== undefined && record.uuid === args.until_uuid) break;
|
|
103
141
|
}
|
|
104
|
-
return
|
|
142
|
+
return kept;
|
|
105
143
|
}
|
package/src/sessions/handlers.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Capability,
|
|
3
|
+
type DumpPreset,
|
|
4
|
+
type DumpPresetsReadResult,
|
|
3
5
|
type InstanceId,
|
|
4
6
|
type SessionDumpWriteArgs,
|
|
5
7
|
type SessionEnvReadArgs,
|
|
@@ -57,9 +59,11 @@ export interface SessionOpsDeps {
|
|
|
57
59
|
* instance last saw them. The sessions domain owns the list; this op only
|
|
58
60
|
* asks it to forget a row. */
|
|
59
61
|
readonly forget: (sid: Sid) => boolean;
|
|
62
|
+
/** The named selections this instance is configured with (§3.6). */
|
|
63
|
+
readonly presets: readonly DumpPreset[];
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/** The
|
|
66
|
+
/** The ops that observe and operate on sessions.
|
|
63
67
|
*
|
|
64
68
|
* None of them decides who may call it: dispatch has settled that from the
|
|
65
69
|
* attribute table. The one that narrows by role is `transcript_read`, and it
|
|
@@ -104,8 +108,17 @@ export function sessionHandlers(deps: SessionOpsDeps) {
|
|
|
104
108
|
self: deps.self,
|
|
105
109
|
stateDir: deps.stateDir,
|
|
106
110
|
files: deps.files,
|
|
111
|
+
presets: deps.presets,
|
|
107
112
|
}),
|
|
108
113
|
|
|
114
|
+
/** Which selections a dump may be asked for by name.
|
|
115
|
+
*
|
|
116
|
+
* Nothing else states them, so a client without this could only offer a
|
|
117
|
+
* free-text field and let the instance refuse. A preset that references
|
|
118
|
+
* another is answered as written: the expansion, and the refusal of a
|
|
119
|
+
* cycle, happen where the config is read. */
|
|
120
|
+
dump_presets_read: (): DumpPresetsReadResult => ({ presets: [...deps.presets] }),
|
|
121
|
+
|
|
109
122
|
session_fork_origin: (input: HandlerInput): SessionForkOriginResult => {
|
|
110
123
|
const args = input.args as unknown as SessionForkOriginArgs;
|
|
111
124
|
const origin = forkOrigin(args.sid, deps.files);
|
package/src/sessions/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from "./classify.ts";
|
|
2
|
-
export { DUMPS, dumpWrite } from "./dump.ts";
|
|
2
|
+
export { DUMP_SUFFIX, DUMPS, dumpWrite } from "./dump.ts";
|
|
3
3
|
export { forkOrigin } from "./fork.ts";
|
|
4
4
|
export * from "./harness.ts";
|
|
5
5
|
export { sessionCapabilities, sessionHandlers, type SessionOpsDeps } from "./handlers.ts";
|
|
@@ -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
|
+
}
|