@ccmsg/protocol 1.10.0 → 1.12.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/package.json +1 -1
- package/src/attributes.ts +8 -1
- package/src/control/dump.ts +459 -0
- package/src/control/session.ts +27 -3
- package/src/fixtures/control.ts +115 -3
- package/src/fixtures/index.ts +4 -0
- package/src/index.ts +1 -0
- package/src/schemas.ts +2 -0
package/package.json
CHANGED
package/src/attributes.ts
CHANGED
|
@@ -196,7 +196,7 @@ export const OP_ATTRIBUTES = {
|
|
|
196
196
|
errors: [],
|
|
197
197
|
},
|
|
198
198
|
|
|
199
|
-
// --- control: session observation and operation (
|
|
199
|
+
// --- control: session observation and operation (9) ---
|
|
200
200
|
session_kill: {
|
|
201
201
|
plane: "control",
|
|
202
202
|
roles: USER_ONLY,
|
|
@@ -233,6 +233,13 @@ export const OP_ATTRIBUTES = {
|
|
|
233
233
|
locality: "instance-local",
|
|
234
234
|
errors: ["not_found"],
|
|
235
235
|
},
|
|
236
|
+
dump_presets_read: {
|
|
237
|
+
plane: "control",
|
|
238
|
+
roles: AGENT_AND_USER,
|
|
239
|
+
needs_hello: true,
|
|
240
|
+
locality: "instance-local",
|
|
241
|
+
errors: [],
|
|
242
|
+
},
|
|
236
243
|
transcript_read: {
|
|
237
244
|
plane: "control",
|
|
238
245
|
roles: AGENT_AND_USER,
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
|
2
|
+
import { request, response } from "../envelope.ts";
|
|
3
|
+
import { Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** The vocabulary a transcript is read in: item types, the shape one item
|
|
6
|
+
* takes, the ledger of ids they carry, and the named selections a person dumps
|
|
7
|
+
* by.
|
|
8
|
+
*
|
|
9
|
+
* A transcript is a harness's own file format, appended to in whatever shape
|
|
10
|
+
* that harness settles on. What travels between us is not that file but the
|
|
11
|
+
* items it was read as, so the classifying — which line is which item — belongs
|
|
12
|
+
* to whoever reads the file, and only the names it classifies into are written
|
|
13
|
+
* down here. That split is what lets a harness change its file, or a second
|
|
14
|
+
* harness be read at all, without the contract moving.
|
|
15
|
+
*
|
|
16
|
+
* A type name is `:`-separated and read left to right, so a prefix names
|
|
17
|
+
* everything below it: `tool` is every tool, `message:user` is both directions
|
|
18
|
+
* of what a person and a session said. Three families stay open, because their
|
|
19
|
+
* last segment is a name someone else coins — a tool, an attachment kind, a
|
|
20
|
+
* hook event — and closing them would turn every newcomer into `unknown`. */
|
|
21
|
+
|
|
22
|
+
/** A type name as written on the wire and in a selection.
|
|
23
|
+
*
|
|
24
|
+
* Segments after the first carry the spelling of whatever named them, which is
|
|
25
|
+
* why they are not held to snake_case: `tool:Bash` and `hook:PreToolUse` are
|
|
26
|
+
* the harness's words, and rewriting them would leave the reader unable to
|
|
27
|
+
* match what it sees against what it ran. */
|
|
28
|
+
export const TranscriptItemType = Type.String({
|
|
29
|
+
pattern: "^[a-z]+(?::[A-Za-z0-9_.-]+)*$",
|
|
30
|
+
$id: "TranscriptItemType",
|
|
31
|
+
});
|
|
32
|
+
export type TranscriptItemType = Static<typeof TranscriptItemType>;
|
|
33
|
+
|
|
34
|
+
/** The type names that are fully spelled out here. The three open families
|
|
35
|
+
* (`tool:<Name>`, `system:attachment:<kind>`, `hook:<Event>`) are not in the
|
|
36
|
+
* list: their last segment is coined elsewhere, and a name absent from this
|
|
37
|
+
* list is a newcomer rather than an error. */
|
|
38
|
+
export const TRANSCRIPT_ITEM_TYPES = [
|
|
39
|
+
"message:user:in",
|
|
40
|
+
"message:user:out",
|
|
41
|
+
"message:sub:in",
|
|
42
|
+
"message:sub:out",
|
|
43
|
+
"message:session:in",
|
|
44
|
+
"message:session:out",
|
|
45
|
+
"thinking",
|
|
46
|
+
"notice:slash",
|
|
47
|
+
"notice:interrupt",
|
|
48
|
+
"system:compact",
|
|
49
|
+
"system:api-error",
|
|
50
|
+
"system:task",
|
|
51
|
+
"system:caveat",
|
|
52
|
+
"system:resume",
|
|
53
|
+
"system:unknown",
|
|
54
|
+
] as const;
|
|
55
|
+
export type KnownTranscriptItemType = (typeof TRANSCRIPT_ITEM_TYPES)[number];
|
|
56
|
+
|
|
57
|
+
/** One element of a selection: a type name, a prefix of one, either of those
|
|
58
|
+
* negated with `-`, or `@name` standing for a preset expanded in place.
|
|
59
|
+
*
|
|
60
|
+
* Elements apply left to right, so an exclusion reaches whatever a prefix or an
|
|
61
|
+
* expansion before it brought in. */
|
|
62
|
+
export const TranscriptItemSelector = Type.String({
|
|
63
|
+
pattern: "^-?(?:@[A-Za-z0-9][A-Za-z0-9_-]*|[a-z]+(?::[A-Za-z0-9_.-]+)*)$",
|
|
64
|
+
$id: "TranscriptItemSelector",
|
|
65
|
+
});
|
|
66
|
+
export type TranscriptItemSelector = Static<typeof TranscriptItemSelector>;
|
|
67
|
+
|
|
68
|
+
/** What every item carries, whatever its type.
|
|
69
|
+
*
|
|
70
|
+
* `uuid` is the record's own id in the transcript, which is what makes an item
|
|
71
|
+
* addressable: a reader that wants more than the dump kept can go back to the
|
|
72
|
+
* file with it, and the links below point with it rather than with a position,
|
|
73
|
+
* since a selection or a range decides which items exist in a given dump. */
|
|
74
|
+
const BASE_FIELDS = {
|
|
75
|
+
uuid: Type.String({ minLength: 1 }),
|
|
76
|
+
/** The item's own instant. A call and its result each keep their own. */
|
|
77
|
+
at: Timestamp,
|
|
78
|
+
/** Which turn of the session the item fell in, when the reader could place
|
|
79
|
+
* it. Derived from the file and renumbered whenever it is read again, so it
|
|
80
|
+
* is an attribute to show and never a way to cut a range. */
|
|
81
|
+
turn: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** A call and what came back are two items, not one.
|
|
85
|
+
*
|
|
86
|
+
* Some results arrive many turns later — an agent runs, a monitor waits — with
|
|
87
|
+
* other items in between, so folding them into a single item would make the
|
|
88
|
+
* reader decide which of the two instants the fold happens at. Two items each
|
|
89
|
+
* keep their own, linked by uuid, and folding is left to whoever draws them. A
|
|
90
|
+
* `use` without a `result_item` is a call that has not come back, including one
|
|
91
|
+
* whose result falls outside the range asked for. */
|
|
92
|
+
const USE_FIELDS = {
|
|
93
|
+
role: Type.Literal("use"),
|
|
94
|
+
/** The result's uuid, once there is one. */
|
|
95
|
+
result_item: Type.Optional(Type.String({ minLength: 1 })),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const RESULT_FIELDS = {
|
|
99
|
+
role: Type.Literal("result"),
|
|
100
|
+
/** The call this answers. Always known: a result exists because a call did. */
|
|
101
|
+
parent_item: Type.String({ minLength: 1 }),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function item(type: TSchema, fields: Record<string, TSchema> = {}): TSchema {
|
|
105
|
+
return Type.Object({ ...BASE_FIELDS, type, ...fields });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const Text = Type.String();
|
|
109
|
+
|
|
110
|
+
// --- message: who said what to whom, from the subject's side ---
|
|
111
|
+
|
|
112
|
+
/** The subject is a session by default, or one agent below it when the dump
|
|
113
|
+
* names an agent, and `in` / `out` are read from wherever the subject stands.
|
|
114
|
+
* A dump of an agent therefore reads `message:user:in` as the brief its parent
|
|
115
|
+
* gave it, on the same type names a dump of the session uses — which is what
|
|
116
|
+
* lets one preset be carried down a chain of agents. */
|
|
117
|
+
const MessageUserIn = item(Type.Literal("message:user:in"), { text: Text });
|
|
118
|
+
const MessageUserOut = item(Type.Literal("message:user:out"), { text: Text });
|
|
119
|
+
|
|
120
|
+
const MessageSubOut = item(Type.Literal("message:sub:out"), {
|
|
121
|
+
...USE_FIELDS,
|
|
122
|
+
prompt: Text,
|
|
123
|
+
agent_id: Type.Optional(Type.String()),
|
|
124
|
+
/** The agent definition asked for, in the harness's spelling. */
|
|
125
|
+
subagent_type: Type.Optional(Type.String()),
|
|
126
|
+
name: Type.Optional(Type.String()),
|
|
127
|
+
description: Type.Optional(Type.String()),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const MessageSubIn = item(Type.Literal("message:sub:in"), {
|
|
131
|
+
...RESULT_FIELDS,
|
|
132
|
+
text: Text,
|
|
133
|
+
agent_id: Type.Optional(Type.String()),
|
|
134
|
+
status: Type.Optional(Type.String()),
|
|
135
|
+
duration_ms: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const MessageSessionOut = item(Type.Literal("message:session:out"), {
|
|
139
|
+
text: Text,
|
|
140
|
+
/** The addressee as the subject wrote it: a sid, or a name that was resolved
|
|
141
|
+
* to one. Kept unresolved when that is all the transcript says. */
|
|
142
|
+
to: Type.Optional(Type.String()),
|
|
143
|
+
msg_id: Type.Optional(Type.String()),
|
|
144
|
+
reply_to: Type.Optional(Type.String()),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const MessageSessionIn = item(Type.Literal("message:session:in"), {
|
|
148
|
+
text: Text,
|
|
149
|
+
from: Type.Optional(Type.String()),
|
|
150
|
+
msg_id: Type.Optional(Type.String()),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const Thinking = item(Type.Literal("thinking"), { text: Text });
|
|
154
|
+
|
|
155
|
+
// --- notice: a person operated the harness ---
|
|
156
|
+
|
|
157
|
+
/** Kept apart from `system:*` because these explain a break in the
|
|
158
|
+
* conversation: someone typed a command or stopped a turn. A reader skimming
|
|
159
|
+
* for why the thread jumps needs them, and can skip what the harness injected
|
|
160
|
+
* for its own reasons. */
|
|
161
|
+
const NoticeSlash = item(Type.Literal("notice:slash"), {
|
|
162
|
+
command: Type.String(),
|
|
163
|
+
args: Type.Optional(Type.String()),
|
|
164
|
+
stdout: Type.Optional(Type.String()),
|
|
165
|
+
});
|
|
166
|
+
const NoticeInterrupt = item(Type.Literal("notice:interrupt"), {
|
|
167
|
+
text: Type.Optional(Text),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// --- system: the harness talking in someone else's voice ---
|
|
171
|
+
|
|
172
|
+
const SystemCompact = item(Type.Literal("system:compact"), { text: Text });
|
|
173
|
+
const SystemApiError = item(Type.Literal("system:api-error"), { text: Text });
|
|
174
|
+
const SystemTask = item(Type.Literal("system:task"), {
|
|
175
|
+
text: Text,
|
|
176
|
+
/** The background task or monitor the event came from. */
|
|
177
|
+
task_id: Type.Optional(Type.String()),
|
|
178
|
+
event: Type.Optional(Type.String()),
|
|
179
|
+
});
|
|
180
|
+
const SystemCaveat = item(Type.Literal("system:caveat"), { text: Text });
|
|
181
|
+
const SystemResume = item(Type.Literal("system:resume"), { text: Text });
|
|
182
|
+
|
|
183
|
+
/** `system:attachment:<kind>` — the kind is the harness's own word for what it
|
|
184
|
+
* attached, taken through unchanged so an attachment nobody has seen before
|
|
185
|
+
* still arrives under its own name instead of collapsing into `unknown`. */
|
|
186
|
+
const SystemAttachment = item(Type.String({ pattern: "^system:attachment:[A-Za-z0-9_.-]+$" }), {
|
|
187
|
+
attachment: Type.Record(Type.String(), Type.Unknown()),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** What the reader could not place. It is still an item: a line that vanishes
|
|
191
|
+
* silently is the one failure a dump cannot be read around. */
|
|
192
|
+
const SystemUnknown = item(Type.Literal("system:unknown"), {
|
|
193
|
+
record: Type.Record(Type.String(), Type.Unknown()),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// --- hook: code the operator installed ---
|
|
197
|
+
|
|
198
|
+
/** `hook:<Event>` — the event alone, never the matcher. The name a hook runs
|
|
199
|
+
* under is `PreToolUse:Bash`, whose `:` would read as a level of the hierarchy
|
|
200
|
+
* and make `hook:PreToolUse` select nothing; the full name is a field instead,
|
|
201
|
+
* and prefix selection keeps meaning what it says. */
|
|
202
|
+
const Hook = item(Type.String({ pattern: "^hook:[A-Za-z0-9_.-]+$" }), {
|
|
203
|
+
/** The hook's full name, matcher included. */
|
|
204
|
+
hook_name: Type.String(),
|
|
205
|
+
outcome: Type.Union([
|
|
206
|
+
Type.Literal("additionalContext"),
|
|
207
|
+
Type.Literal("output"),
|
|
208
|
+
Type.Literal("block"),
|
|
209
|
+
]),
|
|
210
|
+
content: Type.Optional(Text),
|
|
211
|
+
command: Type.Optional(Type.String()),
|
|
212
|
+
exit_code: Type.Optional(Type.Integer()),
|
|
213
|
+
stderr: Type.Optional(Type.String()),
|
|
214
|
+
duration_ms: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
215
|
+
/** The call the hook fired around, for the events that have one. */
|
|
216
|
+
tool_use_id: Type.Optional(Type.String()),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// --- tool: `tool:<Name>`, one item for the call and one for the result ---
|
|
220
|
+
|
|
221
|
+
const ToolType = Type.String({ pattern: "^tool:[A-Za-z0-9_.-]+$" });
|
|
222
|
+
|
|
223
|
+
function toolUse(name: string, fields: Record<string, TSchema>): TSchema {
|
|
224
|
+
return item(Type.Literal(`tool:${name}`), {
|
|
225
|
+
...USE_FIELDS,
|
|
226
|
+
tool_use_id: Type.String(),
|
|
227
|
+
...fields,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function toolResult(name: string, fields: Record<string, TSchema>): TSchema {
|
|
232
|
+
return item(Type.Literal(`tool:${name}`), {
|
|
233
|
+
...RESULT_FIELDS,
|
|
234
|
+
tool_use_id: Type.String(),
|
|
235
|
+
...fields,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const OptText = Type.Optional(Text);
|
|
240
|
+
const OptCount = Type.Optional(Type.Integer({ minimum: 0 }));
|
|
241
|
+
|
|
242
|
+
/** A tool nobody wrote fields for still arrives, carrying what it was called
|
|
243
|
+
* with and what it answered. Fields are added to sharpen how a tool reads, not
|
|
244
|
+
* to decide whether it is kept. */
|
|
245
|
+
const ToolUseGeneric = item(ToolType, {
|
|
246
|
+
...USE_FIELDS,
|
|
247
|
+
tool_use_id: Type.String(),
|
|
248
|
+
input: Type.Record(Type.String(), Type.Unknown()),
|
|
249
|
+
});
|
|
250
|
+
const ToolResultGeneric = item(ToolType, {
|
|
251
|
+
...RESULT_FIELDS,
|
|
252
|
+
tool_use_id: Type.String(),
|
|
253
|
+
result: Type.Record(Type.String(), Type.Unknown()),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const TOOL_ITEMS: TSchema[] = [
|
|
257
|
+
/** No exit code: what the harness records of a shell call is its output and
|
|
258
|
+
* whether it was interrupted, so that is what a reader has to judge by. */
|
|
259
|
+
toolUse("Bash", { command: Type.String(), description: OptText }),
|
|
260
|
+
toolResult("Bash", {
|
|
261
|
+
stdout: OptText,
|
|
262
|
+
stderr: OptText,
|
|
263
|
+
interrupted: Type.Optional(Type.Boolean()),
|
|
264
|
+
}),
|
|
265
|
+
toolUse("Read", {
|
|
266
|
+
file_path: Type.String(),
|
|
267
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
268
|
+
limit: OptCount,
|
|
269
|
+
}),
|
|
270
|
+
toolResult("Read", { lines: OptCount, bytes: OptCount }),
|
|
271
|
+
toolUse("Write", { file_path: Type.String(), lines: OptCount }),
|
|
272
|
+
toolResult("Write", { ok: Type.Boolean() }),
|
|
273
|
+
/** The bodies are counted rather than carried: an edit's two sides are the
|
|
274
|
+
* file's content, and a dump that inlined them would be the file. */
|
|
275
|
+
toolUse("Edit", {
|
|
276
|
+
file_path: Type.String(),
|
|
277
|
+
old_lines: OptCount,
|
|
278
|
+
new_lines: OptCount,
|
|
279
|
+
}),
|
|
280
|
+
toolResult("Edit", { ok: Type.Boolean() }),
|
|
281
|
+
toolUse("Grep", { pattern: Type.String(), path: OptText }),
|
|
282
|
+
toolResult("Grep", { matches: OptCount }),
|
|
283
|
+
toolUse("Glob", { pattern: Type.String(), path: OptText }),
|
|
284
|
+
toolResult("Glob", { matches: OptCount }),
|
|
285
|
+
toolUse("WebFetch", { url: Type.String(), prompt: OptText }),
|
|
286
|
+
toolResult("WebFetch", { text: OptText }),
|
|
287
|
+
toolUse("WebSearch", { query: Type.String() }),
|
|
288
|
+
toolResult("WebSearch", { results: OptCount }),
|
|
289
|
+
/** The same exchange `message:sub:*` carries, seen from the calling side:
|
|
290
|
+
* this pair states that an agent was started and how it ended, and what it
|
|
291
|
+
* answered stays with the message. */
|
|
292
|
+
toolUse("Agent", {
|
|
293
|
+
prompt: Type.String(),
|
|
294
|
+
name: OptText,
|
|
295
|
+
subagent_type: OptText,
|
|
296
|
+
description: OptText,
|
|
297
|
+
}),
|
|
298
|
+
toolResult("Agent", { agent_id: OptText, status: OptText }),
|
|
299
|
+
toolUse("SendMessage", { to: Type.String(), summary: OptText }),
|
|
300
|
+
toolResult("SendMessage", { msg_id: OptText, routing: OptText }),
|
|
301
|
+
toolUse("Monitor", {
|
|
302
|
+
description: Type.String(),
|
|
303
|
+
command: OptText,
|
|
304
|
+
persistent: Type.Optional(Type.Boolean()),
|
|
305
|
+
timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
306
|
+
}),
|
|
307
|
+
toolResult("Monitor", { task_id: OptText }),
|
|
308
|
+
toolUse("Skill", { skill: Type.String(), args: OptText }),
|
|
309
|
+
toolResult("Skill", {
|
|
310
|
+
agent_id: OptText,
|
|
311
|
+
background: Type.Optional(Type.Boolean()),
|
|
312
|
+
status: OptText,
|
|
313
|
+
}),
|
|
314
|
+
toolUse("TodoWrite", {
|
|
315
|
+
todos: Type.Array(Type.Object({ content: Type.String(), status: Type.String() })),
|
|
316
|
+
}),
|
|
317
|
+
toolResult("TodoWrite", { ok: Type.Boolean() }),
|
|
318
|
+
toolUse("TaskStop", { task_id: Type.String() }),
|
|
319
|
+
toolResult("TaskStop", { ok: Type.Boolean() }),
|
|
320
|
+
toolUse("CronCreate", { cron: Type.String(), prompt: OptText }),
|
|
321
|
+
toolResult("CronCreate", { cron_id: OptText }),
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
/** One classified item.
|
|
325
|
+
*
|
|
326
|
+
* Items are finer than lines: one assistant record becomes the thinking, the
|
|
327
|
+
* text and each tool call it held. A reader that does not recognise a record
|
|
328
|
+
* still emits one — as a tool it has no fields for, an attachment under its own
|
|
329
|
+
* kind, or `system:unknown` — so nothing in the file goes missing without
|
|
330
|
+
* saying so. */
|
|
331
|
+
export const TranscriptItem = Type.Union(
|
|
332
|
+
[
|
|
333
|
+
MessageUserIn,
|
|
334
|
+
MessageUserOut,
|
|
335
|
+
MessageSubOut,
|
|
336
|
+
MessageSubIn,
|
|
337
|
+
MessageSessionOut,
|
|
338
|
+
MessageSessionIn,
|
|
339
|
+
Thinking,
|
|
340
|
+
NoticeSlash,
|
|
341
|
+
NoticeInterrupt,
|
|
342
|
+
SystemCompact,
|
|
343
|
+
SystemApiError,
|
|
344
|
+
SystemTask,
|
|
345
|
+
SystemCaveat,
|
|
346
|
+
SystemResume,
|
|
347
|
+
SystemAttachment,
|
|
348
|
+
SystemUnknown,
|
|
349
|
+
Hook,
|
|
350
|
+
...TOOL_ITEMS,
|
|
351
|
+
ToolUseGeneric,
|
|
352
|
+
ToolResultGeneric,
|
|
353
|
+
],
|
|
354
|
+
{ $id: "TranscriptItem" },
|
|
355
|
+
);
|
|
356
|
+
export type TranscriptItem = Static<typeof TranscriptItem>;
|
|
357
|
+
|
|
358
|
+
/** What kind of thing an id names. An id says how to point at something, not
|
|
359
|
+
* what a line is, which is why these are gathered rather than made types of
|
|
360
|
+
* their own — the same agent would otherwise appear once as an item and again
|
|
361
|
+
* as an id. */
|
|
362
|
+
export const DumpIdKind = Type.Union(
|
|
363
|
+
[
|
|
364
|
+
Type.Literal("agent"),
|
|
365
|
+
Type.Literal("task"),
|
|
366
|
+
Type.Literal("tool_use"),
|
|
367
|
+
Type.Literal("msg"),
|
|
368
|
+
Type.Literal("sid"),
|
|
369
|
+
Type.Literal("cron"),
|
|
370
|
+
],
|
|
371
|
+
{ $id: "DumpIdKind" },
|
|
372
|
+
);
|
|
373
|
+
export type DumpIdKind = Static<typeof DumpIdKind>;
|
|
374
|
+
|
|
375
|
+
export const DumpIdEntry = Type.Object(
|
|
376
|
+
{
|
|
377
|
+
kind: DumpIdKind,
|
|
378
|
+
id: Type.String({ minLength: 1 }),
|
|
379
|
+
/** What the id was called where it appeared — an agent's name, a monitor's
|
|
380
|
+
* description, a peer's working directory. */
|
|
381
|
+
label: Type.Optional(Type.String()),
|
|
382
|
+
/** Where it stood when the dump was written, for the ids that end. */
|
|
383
|
+
status: Type.Optional(Type.String()),
|
|
384
|
+
duration_ms: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
385
|
+
},
|
|
386
|
+
{ $id: "DumpIdEntry" },
|
|
387
|
+
);
|
|
388
|
+
export type DumpIdEntry = Static<typeof DumpIdEntry>;
|
|
389
|
+
|
|
390
|
+
/** The ids the dumped items carried, gathered once.
|
|
391
|
+
*
|
|
392
|
+
* The ledger is what a reader descends by: an agent that did the thing worth
|
|
393
|
+
* copying is named here, and dumping it is the same request with that id as its
|
|
394
|
+
* subject. It is relative to the subject, so an agent's ledger lists the agents
|
|
395
|
+
* it started and not itself. */
|
|
396
|
+
export const DumpIds = Type.Array(DumpIdEntry, { $id: "DumpIds" });
|
|
397
|
+
export type DumpIds = Static<typeof DumpIds>;
|
|
398
|
+
|
|
399
|
+
/** The file a dump is written to.
|
|
400
|
+
*
|
|
401
|
+
* The reply to a dump names a path rather than carrying the items, so the file
|
|
402
|
+
* is where they actually travel — which makes its shape as much a part of the
|
|
403
|
+
* contract as the reply is: a successor session handed the path, or a client
|
|
404
|
+
* that fetches it, would otherwise be reading a format nothing states. It
|
|
405
|
+
* repeats what it was asked for, because a file outlives the request that made
|
|
406
|
+
* it and has to say on its own what it is a dump of and what was left out. */
|
|
407
|
+
export const SessionDumpFile = Type.Object(
|
|
408
|
+
{
|
|
409
|
+
sid: Sid,
|
|
410
|
+
/** The agent the dump is of, absent when it is of the session itself. */
|
|
411
|
+
agent_id: Type.Optional(Type.String()),
|
|
412
|
+
written_at: Timestamp,
|
|
413
|
+
/** The selection as applied: presets expanded and exclusions kept in
|
|
414
|
+
* place, so the file states what it holds without the instance's config
|
|
415
|
+
* having to be read beside it. */
|
|
416
|
+
types: Type.Array(TranscriptItemSelector),
|
|
417
|
+
/** Oldest first, as the transcript had them. */
|
|
418
|
+
items: Type.Array(TranscriptItem),
|
|
419
|
+
ids: DumpIds,
|
|
420
|
+
},
|
|
421
|
+
{ $id: "SessionDumpFile" },
|
|
422
|
+
);
|
|
423
|
+
export type SessionDumpFile = Static<typeof SessionDumpFile>;
|
|
424
|
+
|
|
425
|
+
/** A selection an operator named and can ask for by name.
|
|
426
|
+
*
|
|
427
|
+
* Presets are configured on the instance rather than fixed here, because what
|
|
428
|
+
* they name is an interest — "how the work was done", "what to hand over" —
|
|
429
|
+
* and an interest is not a property of the wire. A type name stays one to one
|
|
430
|
+
* with what a record is, and the groupings people actually reach for are made
|
|
431
|
+
* by naming a set of them. */
|
|
432
|
+
export const DumpPreset = Type.Object(
|
|
433
|
+
{
|
|
434
|
+
name: Type.String({ minLength: 1 }),
|
|
435
|
+
description: Type.Optional(Type.String()),
|
|
436
|
+
opts: Type.Object({ types: Type.Array(TranscriptItemSelector) }),
|
|
437
|
+
},
|
|
438
|
+
{ $id: "DumpPreset" },
|
|
439
|
+
);
|
|
440
|
+
export type DumpPreset = Static<typeof DumpPreset>;
|
|
441
|
+
|
|
442
|
+
/** Reads the presets an instance is configured with.
|
|
443
|
+
*
|
|
444
|
+
* Nothing else states which names a dump may be asked for, so a client with no
|
|
445
|
+
* way to list them could only offer a free-text field and let the instance
|
|
446
|
+
* refuse. A preset that references another is answered as written; the
|
|
447
|
+
* expansion, and the refusal of a cycle or of a name that is not configured,
|
|
448
|
+
* happen where the config is validated. */
|
|
449
|
+
export const DumpPresetsReadArgs = Type.Object({});
|
|
450
|
+
export type DumpPresetsReadArgs = Static<typeof DumpPresetsReadArgs>;
|
|
451
|
+
|
|
452
|
+
export const DumpPresetsReadResult = Type.Object({
|
|
453
|
+
/** In configured order. */
|
|
454
|
+
presets: Type.Array(DumpPreset),
|
|
455
|
+
});
|
|
456
|
+
export type DumpPresetsReadResult = Static<typeof DumpPresetsReadResult>;
|
|
457
|
+
|
|
458
|
+
export const DumpPresetsReadRequest = request("dump_presets_read", DumpPresetsReadArgs);
|
|
459
|
+
export const DumpPresetsReadResponse = response("dump_presets_read", DumpPresetsReadResult);
|
package/src/control/session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { request, response } from "../envelope.ts";
|
|
3
3
|
import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
|
|
4
|
+
import { DumpIds, TranscriptItemSelector, TranscriptItemType } from "./dump.ts";
|
|
4
5
|
|
|
5
6
|
/** Ends the OS process behind a session.
|
|
6
7
|
*
|
|
@@ -173,6 +174,13 @@ export const SessionSearchResponse = response("session_search", SessionSearchRes
|
|
|
173
174
|
* destination is the instance's own data directory. */
|
|
174
175
|
export const SessionDumpWriteArgs = Type.Object({
|
|
175
176
|
sid: Sid,
|
|
177
|
+
/** Dump one agent below the session instead of the session itself, naming it
|
|
178
|
+
* as the transcript's own file does. The subject decides what every item
|
|
179
|
+
* type points at, so the same selection reads an agent's work the way it
|
|
180
|
+
* reads the session's. Kept beside `sid` rather than spelled into it: `sid`
|
|
181
|
+
* is validated as a sid, and a single joined string would be neither
|
|
182
|
+
* validated nor parsed anywhere but in the instance. */
|
|
183
|
+
agent_id: Type.Optional(Type.String()),
|
|
176
184
|
/** Inclusive lower bound in time. */
|
|
177
185
|
since_at: Type.Optional(Timestamp),
|
|
178
186
|
/** Inclusive lower bound as a transcript record id, which cuts at that
|
|
@@ -181,9 +189,19 @@ export const SessionDumpWriteArgs = Type.Object({
|
|
|
181
189
|
since_uuid: Type.Optional(Type.String()),
|
|
182
190
|
until_at: Type.Optional(Timestamp),
|
|
183
191
|
until_uuid: Type.Optional(Type.String()),
|
|
184
|
-
/**
|
|
192
|
+
/** Which item types to keep, applied left to right over the whole range.
|
|
193
|
+
* Absent keeps everything but the attachments. */
|
|
194
|
+
types: Type.Optional(Type.Array(TranscriptItemSelector)),
|
|
195
|
+
/** A preset configured on the instance, used as the ground `types` is then
|
|
196
|
+
* applied over. A name the instance does not have is refused rather than
|
|
197
|
+
* ignored, since a dump silently wider than asked for is the failure a
|
|
198
|
+
* selection exists to prevent. */
|
|
199
|
+
preset: Type.Optional(Type.String({ minLength: 1 })),
|
|
200
|
+
/** Leave out the assistant's thinking blocks. `["-thinking"]` says the same
|
|
201
|
+
* thing in the vocabulary the rest of the selection is written in. */
|
|
185
202
|
no_thinking: Type.Optional(Type.Boolean()),
|
|
186
|
-
/** Leave out the machinery of in-process agents
|
|
203
|
+
/** Leave out the machinery of in-process agents, which
|
|
204
|
+
* `["-message:sub", "-tool:Agent"]` also says. */
|
|
187
205
|
no_agent: Type.Optional(Type.Boolean()),
|
|
188
206
|
});
|
|
189
207
|
export type SessionDumpWriteArgs = Static<typeof SessionDumpWriteArgs>;
|
|
@@ -192,7 +210,13 @@ export const SessionDumpWriteResult = Type.Object({
|
|
|
192
210
|
/** Absolute path on the writing instance's host. */
|
|
193
211
|
path: Type.String(),
|
|
194
212
|
instance: InstanceId,
|
|
195
|
-
|
|
213
|
+
/** How many items of each type were written, keyed by type name. A single
|
|
214
|
+
* total leaves the caller unable to tell a dump that kept what it asked for
|
|
215
|
+
* from one whose selection matched almost nothing. */
|
|
216
|
+
entries: Type.Record(TranscriptItemType, Type.Integer({ minimum: 0 })),
|
|
217
|
+
/** The ids those items carried, so the next dump — of an agent named here —
|
|
218
|
+
* can be asked for without opening the file. */
|
|
219
|
+
ids: DumpIds,
|
|
196
220
|
bytes: Type.Integer({ minimum: 0 }),
|
|
197
221
|
});
|
|
198
222
|
export type SessionDumpWriteResult = Static<typeof SessionDumpWriteResult>;
|
package/src/fixtures/control.ts
CHANGED
|
@@ -45,6 +45,12 @@ import type {
|
|
|
45
45
|
SandboxRevokeRequest,
|
|
46
46
|
SandboxRevokeResponse,
|
|
47
47
|
} from "../control/sandbox.ts";
|
|
48
|
+
import type {
|
|
49
|
+
DumpPresetsReadRequest,
|
|
50
|
+
DumpPresetsReadResponse,
|
|
51
|
+
SessionDumpFile,
|
|
52
|
+
TranscriptItem,
|
|
53
|
+
} from "../control/dump.ts";
|
|
48
54
|
import type {
|
|
49
55
|
SessionDumpWriteRequest,
|
|
50
56
|
SessionDumpWriteResponse,
|
|
@@ -149,19 +155,125 @@ export const SESSION_DUMP_WRITE_REQUEST: Static<typeof SessionDumpWriteRequest>
|
|
|
149
155
|
request_id,
|
|
150
156
|
op: "session_dump_write",
|
|
151
157
|
sid,
|
|
158
|
+
agent_id: "a471372f2",
|
|
152
159
|
since_at: FIXTURE_NOW - 3_600_000,
|
|
153
|
-
|
|
160
|
+
preset: "howto",
|
|
161
|
+
types: ["@file", "-tool:Grep", "thinking"],
|
|
154
162
|
};
|
|
155
163
|
|
|
156
164
|
export const SESSION_DUMP_WRITE_RESPONSE: Static<typeof SessionDumpWriteResponse> = {
|
|
157
165
|
ok: true,
|
|
158
166
|
request_id,
|
|
159
|
-
path: "/transcripts/6f1a2b3c.dump.
|
|
167
|
+
path: "/transcripts/6f1a2b3c.dump.json",
|
|
160
168
|
instance,
|
|
161
|
-
entries:
|
|
169
|
+
entries: { thinking: 41, "tool:Bash": 62, "tool:Read": 25 },
|
|
170
|
+
ids: [
|
|
171
|
+
{
|
|
172
|
+
kind: "agent",
|
|
173
|
+
id: "a471372f2",
|
|
174
|
+
label: "dump-kinds-design",
|
|
175
|
+
status: "ok",
|
|
176
|
+
duration_ms: 252_000,
|
|
177
|
+
},
|
|
178
|
+
{ kind: "task", id: "b6mmcr0ax", label: "just watch", status: "running" },
|
|
179
|
+
{ kind: "sid", id: other_sid, label: "ccmsg-webui/main" },
|
|
180
|
+
],
|
|
162
181
|
bytes: 65_536,
|
|
163
182
|
};
|
|
164
183
|
|
|
184
|
+
/** The file the reply above names, holding the items themselves. */
|
|
185
|
+
export const SESSION_DUMP_FILE: Static<typeof SessionDumpFile> = {
|
|
186
|
+
sid,
|
|
187
|
+
agent_id: "a471372f2",
|
|
188
|
+
written_at: FIXTURE_NOW,
|
|
189
|
+
types: ["tool:Read", "tool:Write", "tool:Edit", "tool:Glob", "thinking"],
|
|
190
|
+
items: [],
|
|
191
|
+
ids: [{ kind: "agent", id: "a471372f2", label: "dump-kinds-design", status: "ok" }],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const DUMP_PRESETS_READ_REQUEST: Static<typeof DumpPresetsReadRequest> = {
|
|
195
|
+
request_id,
|
|
196
|
+
op: "dump_presets_read",
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const DUMP_PRESETS_READ_RESPONSE: Static<typeof DumpPresetsReadResponse> = {
|
|
200
|
+
ok: true,
|
|
201
|
+
request_id,
|
|
202
|
+
presets: [
|
|
203
|
+
{
|
|
204
|
+
name: "file",
|
|
205
|
+
description: "reading, writing and searching, as one interest",
|
|
206
|
+
opts: { types: ["tool:Read", "tool:Write", "tool:Edit", "tool:Glob", "tool:Grep"] },
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: "howto",
|
|
210
|
+
description: "how the work was done: what was thought, run, read and written",
|
|
211
|
+
opts: { types: ["thinking", "message:user", "message:sub", "tool:Bash", "@file"] },
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** One item of several types, as a reader emits them.
|
|
217
|
+
*
|
|
218
|
+
* A call and its result appear as the two items they are, linked both ways, so
|
|
219
|
+
* an implementation can check that it draws the pair without assuming they are
|
|
220
|
+
* adjacent. */
|
|
221
|
+
export const TRANSCRIPT_ITEMS: Static<typeof TranscriptItem>[] = [
|
|
222
|
+
{
|
|
223
|
+
uuid: "3f9a21c4",
|
|
224
|
+
type: "message:user:in",
|
|
225
|
+
at: FIXTURE_NOW - 3_600_000,
|
|
226
|
+
turn: 1,
|
|
227
|
+
text: "dump のアイテム型を整理して",
|
|
228
|
+
},
|
|
229
|
+
{ uuid: "f10b6d43", type: "thinking", at: FIXTURE_NOW - 3_500_000, text: "台帳として分ける" },
|
|
230
|
+
{
|
|
231
|
+
uuid: "07c5e1b8",
|
|
232
|
+
type: "tool:Bash",
|
|
233
|
+
at: FIXTURE_NOW - 3_400_000,
|
|
234
|
+
role: "use",
|
|
235
|
+
tool_use_id: "toolu_01Ne9BDS",
|
|
236
|
+
result_item: "18d6f2c9",
|
|
237
|
+
command: "jq -r '.type' session.jsonl | sort | uniq -c",
|
|
238
|
+
description: "count the record types",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
uuid: "18d6f2c9",
|
|
242
|
+
type: "tool:Bash",
|
|
243
|
+
at: FIXTURE_NOW - 3_399_000,
|
|
244
|
+
role: "result",
|
|
245
|
+
tool_use_id: "toolu_01Ne9BDS",
|
|
246
|
+
parent_item: "07c5e1b8",
|
|
247
|
+
stdout: "1174 assistant\n753 user\n",
|
|
248
|
+
interrupted: false,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
uuid: "b7e41d09",
|
|
252
|
+
type: "message:sub:out",
|
|
253
|
+
at: FIXTURE_NOW - 3_300_000,
|
|
254
|
+
role: "use",
|
|
255
|
+
result_item: "c2d80f16",
|
|
256
|
+
prompt: "docs/design/dump-kinds.md を書き直す",
|
|
257
|
+
agent_id: "a471372f2",
|
|
258
|
+
subagent_type: "opus5-worker-high",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
uuid: "92e6d4f5",
|
|
262
|
+
type: "hook:PreToolUse",
|
|
263
|
+
at: FIXTURE_NOW - 3_200_000,
|
|
264
|
+
hook_name: "PreToolUse:Bash",
|
|
265
|
+
outcome: "additionalContext",
|
|
266
|
+
content: "read コマンドを使うこと",
|
|
267
|
+
tool_use_id: "toolu_01Ne9BDS",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
uuid: "81d5c3e4",
|
|
271
|
+
type: "system:attachment:queued_command",
|
|
272
|
+
at: FIXTURE_NOW - 3_100_000,
|
|
273
|
+
attachment: { type: "queued_command", command: "/pre-clear" },
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
|
|
165
277
|
export const TRANSCRIPT_READ_REQUEST: Static<typeof TranscriptReadRequest> = {
|
|
166
278
|
request_id,
|
|
167
279
|
op: "transcript_read",
|
package/src/fixtures/index.ts
CHANGED
|
@@ -92,6 +92,10 @@ export const OP_FIXTURES = {
|
|
|
92
92
|
request: control.SESSION_DUMP_WRITE_REQUEST,
|
|
93
93
|
response: control.SESSION_DUMP_WRITE_RESPONSE,
|
|
94
94
|
},
|
|
95
|
+
dump_presets_read: {
|
|
96
|
+
request: control.DUMP_PRESETS_READ_REQUEST,
|
|
97
|
+
response: control.DUMP_PRESETS_READ_RESPONSE,
|
|
98
|
+
},
|
|
95
99
|
transcript_read: {
|
|
96
100
|
request: control.TRANSCRIPT_READ_REQUEST,
|
|
97
101
|
response: control.TRANSCRIPT_READ_RESPONSE,
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./common/ping.ts";
|
|
|
5
5
|
export * from "./common/shutdown.ts";
|
|
6
6
|
export * from "./common/topics.ts";
|
|
7
7
|
export * from "./control/agents.ts";
|
|
8
|
+
export * from "./control/dump.ts";
|
|
8
9
|
export * from "./control/files.ts";
|
|
9
10
|
export * from "./control/kv.ts";
|
|
10
11
|
export * from "./control/launcher.ts";
|
package/src/schemas.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
TopicUnsubscribeResponse,
|
|
34
34
|
} from "./common/topics.ts";
|
|
35
35
|
import { AgentsFrame } from "./control/agents.ts";
|
|
36
|
+
import { DumpPresetsReadRequest, DumpPresetsReadResponse } from "./control/dump.ts";
|
|
36
37
|
import {
|
|
37
38
|
DirListRequest,
|
|
38
39
|
DirListResponse,
|
|
@@ -150,6 +151,7 @@ export const OP_SCHEMAS: Record<OpName, OpSchemas> = {
|
|
|
150
151
|
session_env_read: { request: SessionEnvReadRequest, response: SessionEnvReadResponse },
|
|
151
152
|
session_search: { request: SessionSearchRequest, response: SessionSearchResponse },
|
|
152
153
|
session_dump_write: { request: SessionDumpWriteRequest, response: SessionDumpWriteResponse },
|
|
154
|
+
dump_presets_read: { request: DumpPresetsReadRequest, response: DumpPresetsReadResponse },
|
|
153
155
|
transcript_read: { request: TranscriptReadRequest, response: TranscriptReadResponse },
|
|
154
156
|
session_fork_origin: { request: SessionForkOriginRequest, response: SessionForkOriginResponse },
|
|
155
157
|
session_last_live_remove: {
|