@ccmsg/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import type {
|
|
3
|
+
AgentTreeGroups,
|
|
4
|
+
AgentTreeNode,
|
|
5
|
+
ExternalFile,
|
|
6
|
+
SessionApiError,
|
|
7
|
+
SessionBackgroundStatus,
|
|
8
|
+
SessionTeammate,
|
|
9
|
+
SessionTodo,
|
|
10
|
+
SessionWorkflowStatus,
|
|
11
|
+
Timestamp,
|
|
12
|
+
} from "@ccmsg/protocol";
|
|
13
|
+
|
|
14
|
+
/** Everything one session's transcript is folded into (§3.3).
|
|
15
|
+
*
|
|
16
|
+
* One fold, not one per consumer: the same line settles whether the session is
|
|
17
|
+
* stopped, when a person last spoke to it, which files it named and what is
|
|
18
|
+
* running below it, so it is read once and every value it can settle is
|
|
19
|
+
* settled from that read (M5). The two optional fields are absent until a line
|
|
20
|
+
* says otherwise, and the lists are empty — which is what a transcript that has
|
|
21
|
+
* not been read yet looks like, and is also the contract's "nothing was
|
|
22
|
+
* declared". */
|
|
23
|
+
export interface TranscriptFacts {
|
|
24
|
+
/** The error the latest turn ended on. Present only while it stands: a real
|
|
25
|
+
* turn after it clears it, so this is the session's current state and not
|
|
26
|
+
* every error it ever hit. One of the two things §5.2 calls Waiting. */
|
|
27
|
+
readonly api_error?: SessionApiError;
|
|
28
|
+
/** When a person last put something into the session (§5.3). */
|
|
29
|
+
readonly last_user_input_at?: Timestamp;
|
|
30
|
+
/** What answered on the latest turn, and how hard it was asked to think.
|
|
31
|
+
*
|
|
32
|
+
* Both are the transcript's answer rather than the greeting's: a session
|
|
33
|
+
* names its model once, when it greets, and `/model` and `/effort` move it
|
|
34
|
+
* afterwards without saying so again. The two are read off the same row, so
|
|
35
|
+
* they can never describe different turns. */
|
|
36
|
+
readonly model?: string;
|
|
37
|
+
readonly effort?: string;
|
|
38
|
+
/** Every absolute path the transcript named, whichever way it named it.
|
|
39
|
+
*
|
|
40
|
+
* Not yet the contract's `external_files`, which is the paths outside the
|
|
41
|
+
* session's root: the root is a greeting's fact and the fold holds no
|
|
42
|
+
* greeting, so the fold states what was named and the filter runs where the
|
|
43
|
+
* root is known. */
|
|
44
|
+
readonly named_files: readonly ExternalFile[];
|
|
45
|
+
readonly todos: readonly SessionTodo[];
|
|
46
|
+
readonly teammates: readonly SessionTeammate[];
|
|
47
|
+
readonly background: readonly SessionBackgroundStatus[];
|
|
48
|
+
readonly workflows: readonly SessionWorkflowStatus[];
|
|
49
|
+
readonly agent_tree: AgentTreeGroups;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a transcript nobody has read yet says. Every list empty rather than
|
|
53
|
+
* absent, so a consumer never has to tell "not read" from "nothing declared" —
|
|
54
|
+
* the contract spells both the same way. */
|
|
55
|
+
export const NO_FACTS: TranscriptFacts = {
|
|
56
|
+
named_files: [],
|
|
57
|
+
todos: [],
|
|
58
|
+
teammates: [],
|
|
59
|
+
background: [],
|
|
60
|
+
workflows: [],
|
|
61
|
+
agent_tree: { teammates: [], agents: [], workflows: [] },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** The one place a transcript line is interpreted.
|
|
65
|
+
*
|
|
66
|
+
* Nothing outside this module parses a transcript record. A line arrives, the
|
|
67
|
+
* fold updates what it can from it, and the values the domain states are read
|
|
68
|
+
* off the result — so a value can never be derived by two different readings
|
|
69
|
+
* of the same file (§3.3, M5).
|
|
70
|
+
*
|
|
71
|
+
* Feeding lines is order-dependent by design: the api error is the state of
|
|
72
|
+
* the latest turn, so a later line undoing an earlier one is the point.
|
|
73
|
+
* `reset` starts over, which is what a rewritten transcript needs. */
|
|
74
|
+
export class TranscriptFold {
|
|
75
|
+
#apiError: SessionApiError | undefined;
|
|
76
|
+
#lastUserInputAt: Timestamp | undefined;
|
|
77
|
+
#model: string | undefined;
|
|
78
|
+
#effort: string | undefined;
|
|
79
|
+
/** Paths in the order they were first named, so the value is stable across
|
|
80
|
+
* reads of the same file. */
|
|
81
|
+
readonly #files = new Map<string, ExternalFile>();
|
|
82
|
+
readonly #todos = new Map<string, SessionTodo>();
|
|
83
|
+
readonly #teammates = new Map<string, Teammate>();
|
|
84
|
+
readonly #background = new Map<string, Mutable<SessionBackgroundStatus>>();
|
|
85
|
+
readonly #workflows = new Map<string, Mutable<SessionWorkflowStatus>>();
|
|
86
|
+
readonly #agents = new Map<string, Mutable<AgentTreeNode>>();
|
|
87
|
+
/** A tool call waiting for its result. The call carries the arguments and the
|
|
88
|
+
* result carries the identifiers the arguments never mention, so neither half
|
|
89
|
+
* describes what started on its own. */
|
|
90
|
+
readonly #calls = new Map<string, PendingCall>();
|
|
91
|
+
|
|
92
|
+
get facts(): TranscriptFacts {
|
|
93
|
+
return {
|
|
94
|
+
...(this.#apiError === undefined ? {} : { api_error: this.#apiError }),
|
|
95
|
+
...(this.#lastUserInputAt === undefined ? {} : { last_user_input_at: this.#lastUserInputAt }),
|
|
96
|
+
...(this.#model === undefined ? {} : { model: this.#model }),
|
|
97
|
+
...(this.#effort === undefined ? {} : { effort: this.#effort }),
|
|
98
|
+
named_files: [...this.#files.values()],
|
|
99
|
+
todos: [...this.#todos.values()],
|
|
100
|
+
teammates: [...this.#teammates.values()].map((each) => each.status),
|
|
101
|
+
background: [...this.#background.values()],
|
|
102
|
+
workflows: [...this.#workflows.values()],
|
|
103
|
+
agent_tree: this.#agentTree(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
reset(): void {
|
|
108
|
+
this.#apiError = undefined;
|
|
109
|
+
this.#lastUserInputAt = undefined;
|
|
110
|
+
this.#model = undefined;
|
|
111
|
+
this.#effort = undefined;
|
|
112
|
+
this.#files.clear();
|
|
113
|
+
this.#todos.clear();
|
|
114
|
+
this.#teammates.clear();
|
|
115
|
+
this.#background.clear();
|
|
116
|
+
this.#workflows.clear();
|
|
117
|
+
this.#agents.clear();
|
|
118
|
+
this.#calls.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fold one whole record. Answers whether anything a consumer reads changed,
|
|
122
|
+
* so a file that grew without saying anything new publishes nothing.
|
|
123
|
+
*
|
|
124
|
+
* A line that is not JSON is skipped rather than treated as an error: the
|
|
125
|
+
* transcript is written by another process, and a record still being written
|
|
126
|
+
* is only ever half a line. */
|
|
127
|
+
line(text: string): boolean {
|
|
128
|
+
if (text.length === 0) return false;
|
|
129
|
+
let row: unknown;
|
|
130
|
+
try {
|
|
131
|
+
row = JSON.parse(text);
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (!isRecord(row)) return false;
|
|
136
|
+
// Every value this fold derives, derived from the one parse (M5).
|
|
137
|
+
let changed = this.#foldApiError(row);
|
|
138
|
+
if (this.#foldAnswered(row)) changed = true;
|
|
139
|
+
if (this.#foldUserInput(row)) changed = true;
|
|
140
|
+
if (this.#foldCalls(row)) changed = true;
|
|
141
|
+
if (this.#foldResult(row)) changed = true;
|
|
142
|
+
if (this.#foldRelay(row)) changed = true;
|
|
143
|
+
if (this.#foldAttachment(row)) changed = true;
|
|
144
|
+
if (this.#foldNotification(row)) changed = true;
|
|
145
|
+
return changed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The api-error state, from an assistant row.
|
|
149
|
+
*
|
|
150
|
+
* The harness writes its own failures as assistant messages carrying
|
|
151
|
+
* `isApiErrorMessage: true` ("Prompt is too long", "API Error: 500 …",
|
|
152
|
+
* "Please run /login"): the turn stopped and the session sits idle until a
|
|
153
|
+
* person intervenes, which is why it counts as Waiting (§5.2). A row the
|
|
154
|
+
* model actually produced clears it — a row the harness wrote itself carries
|
|
155
|
+
* `model: "<synthetic>"` and does not, so the harness's own "No response
|
|
156
|
+
* requested." cannot pass for the agent answering again. A user row is not a
|
|
157
|
+
* clear either: a person typing does not resolve the error, and the
|
|
158
|
+
* assistant row that follows settles it either way.
|
|
159
|
+
*
|
|
160
|
+
* Sidechain rows never signal. A subagent's transcript interleaves into the
|
|
161
|
+
* same file, and neither its failure nor its recovery describes what the
|
|
162
|
+
* session's main context is doing.
|
|
163
|
+
*
|
|
164
|
+
* Each condition above is the old daemon's observation of real transcripts,
|
|
165
|
+
* carried over as an observed fact about the harness rather than as a rule
|
|
166
|
+
* this daemon chose. */
|
|
167
|
+
#foldApiError(row: Record<string, unknown>): boolean {
|
|
168
|
+
if (row["type"] !== "assistant" || row["isSidechain"] === true) return false;
|
|
169
|
+
const message = row["message"];
|
|
170
|
+
if (!isRecord(message)) return false;
|
|
171
|
+
if (row["isApiErrorMessage"] !== true) {
|
|
172
|
+
const model = str(message["model"]);
|
|
173
|
+
if (model === undefined || model === "<synthetic>") return false;
|
|
174
|
+
if (this.#apiError === undefined) return false;
|
|
175
|
+
this.#apiError = undefined;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
const text = blockText(message["content"]);
|
|
179
|
+
if (text === undefined) return false;
|
|
180
|
+
const occurredAt = instant(row["timestamp"]);
|
|
181
|
+
if (occurredAt === undefined) return false;
|
|
182
|
+
// A stall writes several error rows as it is retried; the newest is the
|
|
183
|
+
// one the person is stuck on.
|
|
184
|
+
if (this.#apiError?.text === text && this.#apiError.occurred_at === occurredAt) return false;
|
|
185
|
+
this.#apiError = { text, occurred_at: occurredAt };
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** What answered the latest turn, from an assistant row.
|
|
190
|
+
*
|
|
191
|
+
* The model sits on `message.model` and the effort beside it on the row, and
|
|
192
|
+
* both are taken from the same row so that they describe one turn. The
|
|
193
|
+
* newest row wins outright: a session moved to another model mid-transcript
|
|
194
|
+
* is on that model now, which is the whole reason this is not the greeting's
|
|
195
|
+
* value.
|
|
196
|
+
*
|
|
197
|
+
* A row the harness wrote itself carries `model: "<synthetic>"` and says
|
|
198
|
+
* nothing about what is answering, and a sidechain row is a subagent, which
|
|
199
|
+
* runs on a model of its own. Effort absent from an otherwise real row
|
|
200
|
+
* clears what an earlier row said rather than keeping it: the row states the
|
|
201
|
+
* turn, and a turn that names no effort has none to report. */
|
|
202
|
+
#foldAnswered(row: Record<string, unknown>): boolean {
|
|
203
|
+
if (row["type"] !== "assistant" || row["isSidechain"] === true) return false;
|
|
204
|
+
const message = row["message"];
|
|
205
|
+
if (!isRecord(message)) return false;
|
|
206
|
+
const model = str(message["model"]);
|
|
207
|
+
if (model === undefined || model === "<synthetic>") return false;
|
|
208
|
+
const effort = str(row["effort"]);
|
|
209
|
+
if (this.#model === model && this.#effort === effort) return false;
|
|
210
|
+
this.#model = model;
|
|
211
|
+
this.#effort = effort;
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** When a person last spoke, from a user row.
|
|
216
|
+
*
|
|
217
|
+
* A user row is only sometimes a person: the harness injects skill bodies,
|
|
218
|
+
* command caveats and notifications as user rows too. `isMeta: true` marks
|
|
219
|
+
* an injection and `promptSource: "system"` marks a row the harness raised
|
|
220
|
+
* on its own — neither is someone typing. The remaining exclusions are by
|
|
221
|
+
* the text's opening, which is how the injections that carry neither marker
|
|
222
|
+
* were observed to be recognisable.
|
|
223
|
+
*
|
|
224
|
+
* A sidechain user row is a subagent being prompted by its parent, which is
|
|
225
|
+
* a session speaking to itself rather than a person speaking to it. */
|
|
226
|
+
#foldUserInput(row: Record<string, unknown>): boolean {
|
|
227
|
+
if (row["type"] !== "user" || row["isSidechain"] === true) return false;
|
|
228
|
+
if (row["isMeta"] === true || row["promptSource"] === "system") return false;
|
|
229
|
+
const message = row["message"];
|
|
230
|
+
if (!isRecord(message)) return false;
|
|
231
|
+
const text = blockText(message["content"]);
|
|
232
|
+
if (text === undefined || !isHuman(text)) return false;
|
|
233
|
+
const at = instant(row["timestamp"]);
|
|
234
|
+
// Only forwards: a transcript is appended in order, and a row without a
|
|
235
|
+
// readable instant says nothing about when anyone spoke.
|
|
236
|
+
if (at === undefined || (this.#lastUserInputAt ?? 0) >= at) return false;
|
|
237
|
+
this.#lastUserInputAt = at;
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The tool calls an assistant row makes.
|
|
242
|
+
*
|
|
243
|
+
* A file tool settles here rather than on its result: the call already
|
|
244
|
+
* carries the path, and the old daemon observed a read whose result never
|
|
245
|
+
* arrived as still having named the file. Everything else waits, because the
|
|
246
|
+
* identifiers a started thing is known by — a background task's id, a
|
|
247
|
+
* teammate's, a workflow's run — appear only in the result.
|
|
248
|
+
*
|
|
249
|
+
* A sidechain row names files the same way any other does: the contract asks
|
|
250
|
+
* which paths the transcript names, and a subagent's read is one of them. It
|
|
251
|
+
* starts nothing, though — a spawn made inside a subagent is that subagent's
|
|
252
|
+
* child, not the session's, and this fold can only see depth from the file
|
|
253
|
+
* the spawn was written in. */
|
|
254
|
+
#foldCalls(row: Record<string, unknown>): boolean {
|
|
255
|
+
if (row["type"] !== "assistant") return false;
|
|
256
|
+
const message = row["message"];
|
|
257
|
+
if (!isRecord(message)) return false;
|
|
258
|
+
const content = message["content"];
|
|
259
|
+
if (!Array.isArray(content)) return false;
|
|
260
|
+
const sidechain = row["isSidechain"] === true;
|
|
261
|
+
const at = instant(row["timestamp"]);
|
|
262
|
+
let changed = false;
|
|
263
|
+
for (const block of content) {
|
|
264
|
+
if (!isRecord(block) || block["type"] !== "tool_use") continue;
|
|
265
|
+
const name = str(block["name"]);
|
|
266
|
+
if (name === undefined) continue;
|
|
267
|
+
const input = isRecord(block["input"]) ? block["input"] : {};
|
|
268
|
+
const field = FILE_INPUT[name];
|
|
269
|
+
if (field !== undefined && this.#named(str(input[field]), "tool")) changed = true;
|
|
270
|
+
const id = str(block["id"]);
|
|
271
|
+
if (sidechain || id === undefined) continue;
|
|
272
|
+
// Oldest out first: a call whose result never came is the one least
|
|
273
|
+
// likely to still be answered.
|
|
274
|
+
if (this.#calls.size >= MAX_PENDING_CALLS) {
|
|
275
|
+
const oldest = this.#calls.keys().next();
|
|
276
|
+
if (oldest.done !== true) this.#calls.delete(oldest.value);
|
|
277
|
+
}
|
|
278
|
+
this.#calls.set(id, { name, input, ...optional("at", at) });
|
|
279
|
+
}
|
|
280
|
+
return changed;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** What a tool call turned out to have started, from the user row carrying
|
|
284
|
+
* its result. The arguments and the result are read together because neither
|
|
285
|
+
* half names the thing on its own. */
|
|
286
|
+
#foldResult(row: Record<string, unknown>): boolean {
|
|
287
|
+
if (row["type"] !== "user") return false;
|
|
288
|
+
const message = row["message"];
|
|
289
|
+
if (!isRecord(message)) return false;
|
|
290
|
+
const content = message["content"];
|
|
291
|
+
if (!Array.isArray(content)) return false;
|
|
292
|
+
// One result per row, as the harness writes them, so the row's own
|
|
293
|
+
// `toolUseResult` belongs to the block that names the call.
|
|
294
|
+
const result = isRecord(row["toolUseResult"]) ? row["toolUseResult"] : undefined;
|
|
295
|
+
let changed = false;
|
|
296
|
+
for (const block of content) {
|
|
297
|
+
if (!isRecord(block) || block["type"] !== "tool_result") continue;
|
|
298
|
+
const id = str(block["tool_use_id"]);
|
|
299
|
+
if (id === undefined) continue;
|
|
300
|
+
const call = this.#calls.get(id);
|
|
301
|
+
this.#calls.delete(id);
|
|
302
|
+
// A call that failed started nothing, and a result the harness wrote in
|
|
303
|
+
// some other shape says nothing this fold can read.
|
|
304
|
+
if (call === undefined || result === undefined || block["is_error"] === true) continue;
|
|
305
|
+
if (this.#started(call, result)) changed = true;
|
|
306
|
+
}
|
|
307
|
+
return changed;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** One completed call, in the kinds the old daemon observed results for. */
|
|
311
|
+
#started(call: PendingCall, result: Record<string, unknown>): boolean {
|
|
312
|
+
switch (call.name) {
|
|
313
|
+
case "Agent":
|
|
314
|
+
// The one call with two outcomes: a long-lived member of the team, or
|
|
315
|
+
// a one-off spawn. The result says which.
|
|
316
|
+
return str(result["status"]) === "teammate_spawned"
|
|
317
|
+
? this.#teammateSpawned(call, result)
|
|
318
|
+
: this.#agentSpawned(call, result);
|
|
319
|
+
case "Monitor":
|
|
320
|
+
return this.#running(str(result["taskId"]), "monitor", call, {});
|
|
321
|
+
case "Bash":
|
|
322
|
+
// Only a background one is a task; a foreground command is the turn.
|
|
323
|
+
return call.input["run_in_background"] === true
|
|
324
|
+
? this.#running(str(result["backgroundTaskId"]), "bash", call, {})
|
|
325
|
+
: false;
|
|
326
|
+
case "Workflow":
|
|
327
|
+
return this.#workflowStarted(call, result);
|
|
328
|
+
case "SendMessage":
|
|
329
|
+
return result["success"] === true
|
|
330
|
+
? this.#teammate(str(call.input["to"]), (each) => {
|
|
331
|
+
each.last_sent_at = call.at;
|
|
332
|
+
})
|
|
333
|
+
: false;
|
|
334
|
+
case "TaskStop":
|
|
335
|
+
return this.#stopped(str(call.input["task_id"]), result);
|
|
336
|
+
case "TaskCreate":
|
|
337
|
+
case "TaskUpdate":
|
|
338
|
+
return this.#todo(call, result);
|
|
339
|
+
default:
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#teammateSpawned(call: PendingCall, result: Record<string, unknown>): boolean {
|
|
345
|
+
const name = str(result["name"]) ?? str(call.input["name"]);
|
|
346
|
+
return this.#teammate(name, (each, side) => {
|
|
347
|
+
each.spawned = true;
|
|
348
|
+
each.state = "active";
|
|
349
|
+
each.spawned_at = call.at;
|
|
350
|
+
each.agent_type = str(result["agent_type"]);
|
|
351
|
+
each.color = str(result["color"]);
|
|
352
|
+
each.model = str(result["model"]);
|
|
353
|
+
side.agent_id = str(result["agent_id"]);
|
|
354
|
+
side.team_name = str(result["team_name"]);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A one-off spawn: a background task to whoever is watching the session, and
|
|
359
|
+
* a node of the tree below it. Both come from this one result, so the two
|
|
360
|
+
* cannot disagree about what was started. */
|
|
361
|
+
#agentSpawned(call: PendingCall, result: Record<string, unknown>): boolean {
|
|
362
|
+
const id = str(result["agentId"]);
|
|
363
|
+
if (id === undefined) return false;
|
|
364
|
+
// A spawn the caller waits for has already finished by the time its result
|
|
365
|
+
// is written; one launched asynchronously is still going.
|
|
366
|
+
const async = result["isAsync"] === true || launched(str(result["status"]));
|
|
367
|
+
const state = async ? "running" : (str(result["status"]) ?? "completed");
|
|
368
|
+
const agentType = str(call.input["subagent_type"]) ?? str(result["agentType"]);
|
|
369
|
+
this.#running(id, "agent", call, {
|
|
370
|
+
status: state,
|
|
371
|
+
...optional("agent_type", agentType),
|
|
372
|
+
...(async ? {} : optional("ended_at", call.at)),
|
|
373
|
+
});
|
|
374
|
+
this.#agents.set(id, {
|
|
375
|
+
agent_id: id,
|
|
376
|
+
spawn_depth: 0,
|
|
377
|
+
kind: "subagent",
|
|
378
|
+
state,
|
|
379
|
+
children: [],
|
|
380
|
+
...optional("agent_type", agentType),
|
|
381
|
+
...optional("description", str(call.input["description"])),
|
|
382
|
+
...optional("model", str(result["resolvedModel"]) ?? str(call.input["model"])),
|
|
383
|
+
...optional("last_activity_at", call.at),
|
|
384
|
+
});
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#workflowStarted(call: PendingCall, result: Record<string, unknown>): boolean {
|
|
389
|
+
const taskId = str(result["taskId"]);
|
|
390
|
+
const name = str(result["workflowName"]);
|
|
391
|
+
if (taskId === undefined || name === undefined || call.at === undefined) return false;
|
|
392
|
+
this.#workflows.set(taskId, {
|
|
393
|
+
task_id: taskId,
|
|
394
|
+
name,
|
|
395
|
+
// A run that has only just been launched is running; the record it
|
|
396
|
+
// writes at the end is what says how it went.
|
|
397
|
+
status: launched(str(result["status"])) ? "running" : (str(result["status"]) ?? "running"),
|
|
398
|
+
started_at: call.at,
|
|
399
|
+
// Declared in the record the run writes when it ends, which is a file
|
|
400
|
+
// beside the transcript rather than a line in it.
|
|
401
|
+
phases: [],
|
|
402
|
+
agents: [],
|
|
403
|
+
...optional("summary", str(result["summary"])),
|
|
404
|
+
...optional("run_id", str(result["runId"])),
|
|
405
|
+
});
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A background task, in the three kinds the contract distinguishes. */
|
|
410
|
+
#running(
|
|
411
|
+
taskId: string | undefined,
|
|
412
|
+
kind: SessionBackgroundStatus["kind"],
|
|
413
|
+
call: PendingCall,
|
|
414
|
+
over: Partial<SessionBackgroundStatus>,
|
|
415
|
+
): boolean {
|
|
416
|
+
if (taskId === undefined || call.at === undefined) return false;
|
|
417
|
+
this.#background.set(taskId, {
|
|
418
|
+
task_id: taskId,
|
|
419
|
+
kind,
|
|
420
|
+
description: str(call.input["description"]) ?? "",
|
|
421
|
+
status: "running",
|
|
422
|
+
started_at: call.at,
|
|
423
|
+
...over,
|
|
424
|
+
});
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** `TaskStop` names one thing by id without saying which kind it was, so each
|
|
429
|
+
* place a task can be is tried in turn. */
|
|
430
|
+
#stopped(taskId: string | undefined, result: Record<string, unknown>): boolean {
|
|
431
|
+
if (taskId === undefined) return false;
|
|
432
|
+
if (str(result["task_type"]) === "in_process_teammate") {
|
|
433
|
+
return this.#teammate(taskId, (each) => {
|
|
434
|
+
each.state = "stopped";
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return this.#ended(taskId, "stopped", undefined);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** One started thing reaching its end, wherever it is held.
|
|
441
|
+
*
|
|
442
|
+
* A spawned agent is in two places — the background list and the tree — and
|
|
443
|
+
* both are settled here, so what a reader is told about the same agent
|
|
444
|
+
* cannot differ between the two. */
|
|
445
|
+
#ended(taskId: string, status: string, at: Timestamp | undefined): boolean {
|
|
446
|
+
let changed = false;
|
|
447
|
+
for (const where of [this.#workflows, this.#background]) {
|
|
448
|
+
const entry = where.get(taskId);
|
|
449
|
+
if (entry === undefined) continue;
|
|
450
|
+
entry.status = status;
|
|
451
|
+
if (at !== undefined) entry.ended_at = at;
|
|
452
|
+
changed = true;
|
|
453
|
+
}
|
|
454
|
+
const agent = this.#agents.get(taskId);
|
|
455
|
+
if (agent !== undefined) {
|
|
456
|
+
agent.state = status;
|
|
457
|
+
if (at !== undefined) agent.last_activity_at = at;
|
|
458
|
+
changed = true;
|
|
459
|
+
}
|
|
460
|
+
return changed;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The session's task list, as the two calls that write it leave it.
|
|
464
|
+
*
|
|
465
|
+
* `TaskCreate` states a task and `TaskUpdate` changes one, and an update adds
|
|
466
|
+
* to the dependency lists rather than replacing them — which is the shape of
|
|
467
|
+
* its arguments, since it names what to add. */
|
|
468
|
+
#todo(call: PendingCall, result: Record<string, unknown>): boolean {
|
|
469
|
+
const task = isRecord(result["task"]) ? result["task"] : undefined;
|
|
470
|
+
const id = str(call.input["taskId"]) ?? str(result["taskId"]) ?? str(task?.["id"]);
|
|
471
|
+
if (id === undefined) return false;
|
|
472
|
+
if (str(call.input["status"]) === "deleted") return this.#todos.delete(id);
|
|
473
|
+
const before = this.#todos.get(id);
|
|
474
|
+
const subject =
|
|
475
|
+
str(call.input["subject"]) ?? str(task?.["subject"]) ?? before?.subject ?? "(unknown)";
|
|
476
|
+
const status = str(call.input["status"]) ?? before?.status ?? "pending";
|
|
477
|
+
this.#todos.set(id, {
|
|
478
|
+
id,
|
|
479
|
+
subject,
|
|
480
|
+
status,
|
|
481
|
+
blocked_by: merge(before?.blocked_by, call.input["addBlockedBy"]),
|
|
482
|
+
blocks: merge(before?.blocks, call.input["addBlocks"]),
|
|
483
|
+
...optional("owner", str(call.input["owner"]) ?? before?.owner),
|
|
484
|
+
});
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A message from a teammate, which the harness relays into the session as a
|
|
489
|
+
* user row wrapping it in a tag. It is the only sign a teammate gives of
|
|
490
|
+
* still being there, so it is also where its liveness comes from. */
|
|
491
|
+
#foldRelay(row: Record<string, unknown>): boolean {
|
|
492
|
+
if (row["type"] !== "user") return false;
|
|
493
|
+
const message = row["message"];
|
|
494
|
+
if (!isRecord(message)) return false;
|
|
495
|
+
const text = blockText(message["content"]);
|
|
496
|
+
if (text === undefined) return false;
|
|
497
|
+
const relay = RELAY.exec(text);
|
|
498
|
+
if (relay === null) return false;
|
|
499
|
+
const name = relay[1];
|
|
500
|
+
// The harness speaks under this name itself; it is not a member of the team.
|
|
501
|
+
if (name === undefined || name === "system") return false;
|
|
502
|
+
const at = instant(row["timestamp"]);
|
|
503
|
+
return this.#teammate(name, (each) => {
|
|
504
|
+
each.last_received_at = at;
|
|
505
|
+
each.state = (relay[2] ?? "").trimStart().startsWith(IDLE) ? "idle" : "active";
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** The records the harness writes for something attached to a turn rather
|
|
510
|
+
* than said in it: a file the person put in front of the session, and the
|
|
511
|
+
* task list a subagent's work left changed. */
|
|
512
|
+
#foldAttachment(row: Record<string, unknown>): boolean {
|
|
513
|
+
if (row["type"] !== "attachment") return false;
|
|
514
|
+
const attachment = row["attachment"];
|
|
515
|
+
if (!isRecord(attachment)) return false;
|
|
516
|
+
const kind = str(attachment["type"]);
|
|
517
|
+
if (kind === undefined) return false;
|
|
518
|
+
if (kind === "task_reminder") return this.#reminded(attachment);
|
|
519
|
+
const field = ATTACHMENT_FILE[kind];
|
|
520
|
+
return field === undefined ? false : this.#named(str(attachment[field]), "attachment");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The whole task list, restated. This is the only sight of a task a subagent
|
|
524
|
+
* created, so the list is upserted rather than replaced: a reminder that
|
|
525
|
+
* omits a task is not the harness saying the task is gone, and deletion has
|
|
526
|
+
* its own call. */
|
|
527
|
+
#reminded(attachment: Record<string, unknown>): boolean {
|
|
528
|
+
const content = attachment["content"];
|
|
529
|
+
if (!Array.isArray(content)) return false;
|
|
530
|
+
let changed = false;
|
|
531
|
+
for (const item of content) {
|
|
532
|
+
if (!isRecord(item)) continue;
|
|
533
|
+
const id = str(item["id"]);
|
|
534
|
+
const subject = str(item["subject"]);
|
|
535
|
+
const status = str(item["status"]);
|
|
536
|
+
if (id === undefined || subject === undefined || status === undefined) continue;
|
|
537
|
+
this.#todos.set(id, {
|
|
538
|
+
id,
|
|
539
|
+
subject,
|
|
540
|
+
status,
|
|
541
|
+
blocked_by: merge(undefined, item["blockedBy"]),
|
|
542
|
+
blocks: merge(undefined, item["blocks"]),
|
|
543
|
+
...optional("owner", str(item["owner"])),
|
|
544
|
+
});
|
|
545
|
+
changed = true;
|
|
546
|
+
}
|
|
547
|
+
return changed;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** How a started thing is seen to end. The harness queues the notification
|
|
551
|
+
* into the session, and the queue row is the only place the outcome appears —
|
|
552
|
+
* the tool result was written when the thing started. */
|
|
553
|
+
#foldNotification(row: Record<string, unknown>): boolean {
|
|
554
|
+
if (row["type"] !== "queue-operation" || row["operation"] !== "enqueue") return false;
|
|
555
|
+
const content = str(row["content"]);
|
|
556
|
+
if (content === undefined || !content.includes("<task-notification>")) return false;
|
|
557
|
+
// Only as far as the notification's own summary: everything after it is
|
|
558
|
+
// text somebody else wrote, and could spell these tags itself.
|
|
559
|
+
const head = content.slice(0, cut(content));
|
|
560
|
+
const taskId = tagged(head, "task-id");
|
|
561
|
+
const status = tagged(head, "status");
|
|
562
|
+
if (taskId === undefined || status === undefined || status === "running") return false;
|
|
563
|
+
return this.#ended(taskId, status, instant(row["timestamp"]));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** One path the transcript named. A path named both ways keeps the origin it
|
|
567
|
+
* was first seen with, which is what the contract states; a tool naming a
|
|
568
|
+
* path an attachment already carried is still the same file. */
|
|
569
|
+
#named(path: string | undefined, origin: ExternalFile["origin"]): boolean {
|
|
570
|
+
if (path === undefined || !isAbsolute(path) || this.#files.has(path)) return false;
|
|
571
|
+
this.#files.set(path, { path, origin });
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** A teammate by name, created on first sight. Every route to one goes
|
|
576
|
+
* through here: a spawn, a message out, a message back and a stop are four
|
|
577
|
+
* sightings of one member, and four spellings of "the same name" could come
|
|
578
|
+
* apart. */
|
|
579
|
+
#teammate(
|
|
580
|
+
name: string | undefined,
|
|
581
|
+
change: (status: Mutable<SessionTeammate>, side: Teammate) => void,
|
|
582
|
+
): boolean {
|
|
583
|
+
if (name === undefined) return false;
|
|
584
|
+
const held = this.#teammates.get(name) ?? {
|
|
585
|
+
// Seen only by having spoken: it exists, but this transcript never
|
|
586
|
+
// watched it start.
|
|
587
|
+
status: { name, spawned: false, state: "active" },
|
|
588
|
+
};
|
|
589
|
+
change(held.status, held);
|
|
590
|
+
this.#teammates.set(name, held);
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** What the transcript itself says is running below the session.
|
|
595
|
+
*
|
|
596
|
+
* Direct children only, all at depth zero: a spawn is written in the file of
|
|
597
|
+
* whoever made it, so the session's own transcript names the agents it
|
|
598
|
+
* started and says nothing about what those in turn started. Depth, workflow
|
|
599
|
+
* membership and an agent's own last activity are read from the files the
|
|
600
|
+
* harness writes beside the transcript — a source this fold does not have —
|
|
601
|
+
* so the workflow group stays empty rather than being guessed at. */
|
|
602
|
+
#agentTree(): AgentTreeGroups {
|
|
603
|
+
const teammates: AgentTreeNode[] = [];
|
|
604
|
+
for (const held of this.#teammates.values()) {
|
|
605
|
+
const { status } = held;
|
|
606
|
+
if (held.agent_id === undefined) continue;
|
|
607
|
+
teammates.push({
|
|
608
|
+
agent_id: held.agent_id,
|
|
609
|
+
teammate_name: status.name,
|
|
610
|
+
spawn_depth: 0,
|
|
611
|
+
kind: "teammate",
|
|
612
|
+
state: status.state,
|
|
613
|
+
children: [],
|
|
614
|
+
...optional("agent_type", status.agent_type),
|
|
615
|
+
...optional("color", status.color),
|
|
616
|
+
...optional("model", status.model),
|
|
617
|
+
...optional("team_name", held.team_name),
|
|
618
|
+
...optional(
|
|
619
|
+
"last_activity_at",
|
|
620
|
+
status.last_received_at ?? status.last_sent_at ?? status.spawned_at,
|
|
621
|
+
),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
return { teammates, agents: [...this.#agents.values()], workflows: [] };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
|
629
|
+
|
|
630
|
+
/** A teammate as the fold holds it: what the contract states about it, and the
|
|
631
|
+
* two facts only the tree needs. */
|
|
632
|
+
interface Teammate {
|
|
633
|
+
readonly status: Mutable<SessionTeammate>;
|
|
634
|
+
agent_id?: string;
|
|
635
|
+
team_name?: string;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
interface PendingCall {
|
|
639
|
+
readonly name: string;
|
|
640
|
+
readonly input: Record<string, unknown>;
|
|
641
|
+
readonly at?: Timestamp;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** How many unanswered calls are worth holding. A turn makes a handful; a
|
|
645
|
+
* transcript read from its end can begin part-way through one, leaving calls
|
|
646
|
+
* whose results were never in the window. */
|
|
647
|
+
const MAX_PENDING_CALLS = 256;
|
|
648
|
+
|
|
649
|
+
/** The file tools, and the argument each names its file with. Observed on the
|
|
650
|
+
* harness's own calls rather than derived from a naming rule: a tool whose
|
|
651
|
+
* argument happens to be a path is not thereby a file tool. */
|
|
652
|
+
const FILE_INPUT: Record<string, string> = {
|
|
653
|
+
Read: "file_path",
|
|
654
|
+
Write: "file_path",
|
|
655
|
+
Edit: "file_path",
|
|
656
|
+
MultiEdit: "file_path",
|
|
657
|
+
NotebookEdit: "notebook_path",
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
/** The attachment kinds that carry a file, and the field the path is in. The
|
|
661
|
+
* other kinds attach text the harness composed, which names no file. */
|
|
662
|
+
const ATTACHMENT_FILE: Record<string, string> = {
|
|
663
|
+
edited_text_file: "filename",
|
|
664
|
+
file: "filename",
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/** How the harness wraps a teammate's message on its way into the session. */
|
|
668
|
+
const RELAY = /<teammate-message[^>]*\steammate_id="([^"]*)"[^>]*>([\s\S]*)/;
|
|
669
|
+
|
|
670
|
+
/** The opening of the message a teammate sends when it has nothing to do. */
|
|
671
|
+
const IDLE = '{"type":"idle_notification"';
|
|
672
|
+
|
|
673
|
+
/** How a result says the thing it started is still going rather than already
|
|
674
|
+
* over: the call returned as soon as it was launched. A result that says
|
|
675
|
+
* nothing at all means the same. */
|
|
676
|
+
function launched(status: string | undefined): boolean {
|
|
677
|
+
return status === undefined || status === "async_launched";
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Where a notification stops describing itself and starts quoting. */
|
|
681
|
+
function cut(content: string): number {
|
|
682
|
+
let end = content.length;
|
|
683
|
+
for (const opening of ["<summary>", "<event>", "<result>"]) {
|
|
684
|
+
const at = content.indexOf(opening);
|
|
685
|
+
if (at >= 0 && at < end) end = at;
|
|
686
|
+
}
|
|
687
|
+
return end;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function tagged(text: string, tag: string): string | undefined {
|
|
691
|
+
return str(new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(text)?.[1]);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** A dependency list with what an update adds, in one order however either
|
|
695
|
+
* side spelled it. */
|
|
696
|
+
function merge(before: readonly string[] | undefined, added: unknown): string[] {
|
|
697
|
+
const all = new Set(before ?? []);
|
|
698
|
+
if (Array.isArray(added)) {
|
|
699
|
+
for (const each of added) {
|
|
700
|
+
const id = str(each);
|
|
701
|
+
if (id !== undefined) all.add(id);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return [...all].sort();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** One transcript record, as the ops that read a whole transcript need it.
|
|
708
|
+
*
|
|
709
|
+
* The fold above follows a file as it grows; a search, a dump and a fork sweep
|
|
710
|
+
* read one that has stopped growing. Both are the same act of interpretation,
|
|
711
|
+
* so both live here: nothing outside this module turns a transcript line into
|
|
712
|
+
* meaning, and the harness's own spellings — its record types, its block
|
|
713
|
+
* kinds, its ISO instants — stop at this boundary (§3.5). */
|
|
714
|
+
export interface TranscriptRecord {
|
|
715
|
+
/** The record id a dump's bounds cut at. */
|
|
716
|
+
readonly uuid?: string;
|
|
717
|
+
readonly said_at?: Timestamp;
|
|
718
|
+
/** Who said it, in the contract's two words. Absent for a record that is
|
|
719
|
+
* neither side speaking — a title change, a summary, a harness note. */
|
|
720
|
+
readonly said_by?: "user" | "agent";
|
|
721
|
+
/** The words, with the thinking blocks kept apart so a dump can leave them
|
|
722
|
+
* out without a second reading of the row. */
|
|
723
|
+
readonly text?: string;
|
|
724
|
+
readonly thinking?: string;
|
|
725
|
+
/** A subagent's turn, interleaved into the session's own file. */
|
|
726
|
+
readonly sidechain: boolean;
|
|
727
|
+
/** Where the session ran, which the first records carry. */
|
|
728
|
+
readonly cwd?: string;
|
|
729
|
+
/** What `/rename` wrote, which is the only writer of a session's title. */
|
|
730
|
+
readonly title?: string;
|
|
731
|
+
/** What the turn ran as, in the transcript's own spelling. */
|
|
732
|
+
readonly model?: string;
|
|
733
|
+
readonly effort?: string;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Read one line. A line that is not a record — half-written, or not JSON at
|
|
737
|
+
* all — yields nothing, the same way the fold skips it. */
|
|
738
|
+
export function readRecord(line: string): TranscriptRecord | undefined {
|
|
739
|
+
let row: unknown;
|
|
740
|
+
try {
|
|
741
|
+
row = JSON.parse(line);
|
|
742
|
+
} catch {
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
if (!isRecord(row)) return undefined;
|
|
746
|
+
const type = str(row["type"]);
|
|
747
|
+
const message = isRecord(row["message"]) ? row["message"] : undefined;
|
|
748
|
+
const model = message === undefined ? undefined : str(message["model"]);
|
|
749
|
+
return {
|
|
750
|
+
sidechain: row["isSidechain"] === true,
|
|
751
|
+
...optional("uuid", str(row["uuid"])),
|
|
752
|
+
...optional("said_at", instant(row["timestamp"])),
|
|
753
|
+
...optional("said_by", saidBy(type)),
|
|
754
|
+
...optional("text", message === undefined ? undefined : blockText(message["content"])),
|
|
755
|
+
...optional("thinking", message === undefined ? undefined : thinkingText(message["content"])),
|
|
756
|
+
...optional("cwd", str(row["cwd"])),
|
|
757
|
+
...optional("title", type === "custom-title" ? str(row["customTitle"]) : undefined),
|
|
758
|
+
...optional("model", model === "<synthetic>" ? undefined : model),
|
|
759
|
+
...optional("effort", str(row["effort"])),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** The harness's record types, in the contract's two words. Its `assistant` is
|
|
764
|
+
* the contract's `agent`; every other type is a record neither side spoke. */
|
|
765
|
+
function saidBy(type: string | undefined): "user" | "agent" | undefined {
|
|
766
|
+
if (type === "user") return "user";
|
|
767
|
+
return type === "assistant" ? "agent" : undefined;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function optional<K extends string, V>(key: K, value: V | undefined): Record<K, V> | object {
|
|
771
|
+
return value === undefined ? {} : { [key]: value };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** The text a message states, whoever wrote it. A plain prompt is a string; a
|
|
775
|
+
* prompt with an attachment, and every row the harness writes, is a block
|
|
776
|
+
* array whose text blocks carry the words. An array holding only tool results
|
|
777
|
+
* yields nothing, which is what a tool answering looks like, and an error row
|
|
778
|
+
* with several blocks reads as all of them rather than as its first line. */
|
|
779
|
+
function blockText(content: unknown): string | undefined {
|
|
780
|
+
if (typeof content === "string") return content.trim() || undefined;
|
|
781
|
+
if (!Array.isArray(content)) return undefined;
|
|
782
|
+
const parts: string[] = [];
|
|
783
|
+
for (const block of content) {
|
|
784
|
+
if (!isRecord(block) || block["type"] !== "text") continue;
|
|
785
|
+
const text = str(block["text"]);
|
|
786
|
+
if (text !== undefined) parts.push(text);
|
|
787
|
+
}
|
|
788
|
+
return parts.join("\n").trim() || undefined;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** The model's own reasoning, which a dump may be asked to leave out. Kept
|
|
792
|
+
* apart from `text` rather than filtered out of it, so leaving it out is a
|
|
793
|
+
* field the dump does not write rather than a second pass over the blocks. */
|
|
794
|
+
function thinkingText(content: unknown): string | undefined {
|
|
795
|
+
if (!Array.isArray(content)) return undefined;
|
|
796
|
+
const parts: string[] = [];
|
|
797
|
+
for (const block of content) {
|
|
798
|
+
if (!isRecord(block) || block["type"] !== "thinking") continue;
|
|
799
|
+
const text = str(block["thinking"]);
|
|
800
|
+
if (text !== undefined) parts.push(text);
|
|
801
|
+
}
|
|
802
|
+
return parts.join("\n").trim() || undefined;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Openings observed on harness-written user rows that carry no marker of
|
|
806
|
+
* their own. A person's prompt can begin with anything, so these are matched
|
|
807
|
+
* against exactly rather than treated as a shape. */
|
|
808
|
+
const INJECTED_OPENINGS = [
|
|
809
|
+
"<",
|
|
810
|
+
"[SYSTEM NOTIFICATION - NOT USER INPUT]",
|
|
811
|
+
"Another Claude session sent a message:",
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
function isHuman(text: string): boolean {
|
|
815
|
+
return !INJECTED_OPENINGS.some((opening) => text.startsWith(opening));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** A transcript instant, in the contract's spelling. The harness writes ISO
|
|
819
|
+
* strings; the contract's `Timestamp` is Unix ms (§3.5). */
|
|
820
|
+
function instant(value: unknown): Timestamp | undefined {
|
|
821
|
+
const text = str(value);
|
|
822
|
+
if (text === undefined) return undefined;
|
|
823
|
+
const at = Date.parse(text);
|
|
824
|
+
return Number.isNaN(at) ? undefined : at;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function str(value: unknown): string | undefined {
|
|
828
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
832
|
+
return typeof value === "object" && value !== null;
|
|
833
|
+
}
|