@ccmsg/protocol 1.12.0 → 1.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/protocol",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
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 (9) ---
199
+ // --- control: session observation and operation (10) ---
200
200
  session_kill: {
201
201
  plane: "control",
202
202
  roles: USER_ONLY,
@@ -248,6 +248,14 @@ export const OP_ATTRIBUTES = {
248
248
  scope: "role",
249
249
  errors: ["not_found"],
250
250
  },
251
+ transcript_items_read: {
252
+ plane: "control",
253
+ roles: AGENT_AND_USER,
254
+ needs_hello: true,
255
+ locality: "instance-local",
256
+ scope: "role",
257
+ errors: ["not_found"],
258
+ },
251
259
  session_fork_origin: {
252
260
  plane: "control",
253
261
  roles: USER_ONLY,
@@ -15,7 +15,7 @@ export const PLAIN_TOPICS = [
15
15
  ] as const;
16
16
 
17
17
  /** Topics naming one session, written `<topic>:<sid>`. */
18
- export const SESSION_SCOPED_TOPICS = ["session_status", "transcript"] as const;
18
+ export const SESSION_SCOPED_TOPICS = ["session_status", "transcript", "transcript_items"] as const;
19
19
 
20
20
  /** Topics naming one namespace, written `<topic>:<ns>`. The parameter is a name
21
21
  * its users choose rather than an identifier this contract issues, so it is
@@ -102,6 +102,10 @@ export const TOPIC_ATTRIBUTES = {
102
102
  // half to leave alone: the frame is simply the whole of it.
103
103
  session_status: { roles: ["user"], granularity: "whole" },
104
104
  transcript: { roles: ["user"], granularity: "append" },
105
+ // The same appending, in items rather than in bytes. Both are offered because
106
+ // they answer different needs: one draws the conversation, the other shows a
107
+ // record as it was written.
108
+ transcript_items: { roles: ["user"], granularity: "append" },
105
109
  kv: { roles: ["user"], granularity: "element" },
106
110
  // The only topic no person may subscribe to: its elements are the secrets
107
111
  // that authenticate them. A relay carries it as the instance it is, not on a
@@ -65,14 +65,47 @@ export const TranscriptItemSelector = Type.String({
65
65
  });
66
66
  export type TranscriptItemSelector = Static<typeof TranscriptItemSelector>;
67
67
 
68
+ /** Where in the transcript the record an item was read from begins, and how far
69
+ * it runs.
70
+ *
71
+ * An item is what a reader made of a record, and a reader is fallible: the one
72
+ * question it cannot answer is what the record actually said. These two numbers
73
+ * are that answer's address — `transcript_read` bounded to end at `offset +
74
+ * bytes` and to carry `bytes` returns the record itself — which is what lets a
75
+ * client show items and still let a person open the line behind one. Several
76
+ * items read out of a single record share the address, so what comes back is
77
+ * the record and not a slice of it. */
78
+ export const TranscriptItemSource = Type.Object(
79
+ {
80
+ offset: Type.Integer({ minimum: 0 }),
81
+ bytes: Type.Integer({ minimum: 1 }),
82
+ },
83
+ { $id: "TranscriptItemSource" },
84
+ );
85
+ export type TranscriptItemSource = Static<typeof TranscriptItemSource>;
86
+
87
+ /** An item's own identity, `<uuid>:<index>` — the record it was read from and
88
+ * where in that record it stood.
89
+ *
90
+ * The record's id alone does not identify an item: one assistant record becomes
91
+ * the thinking, the text and each call it held, and a link that pointed by
92
+ * `uuid` would name all of them at once. */
93
+ export const TranscriptItemId = Type.String({
94
+ pattern: "^[^\\s:]+:(?:0|[1-9][0-9]*)$",
95
+ $id: "TranscriptItemId",
96
+ });
97
+ export type TranscriptItemId = Static<typeof TranscriptItemId>;
98
+
68
99
  /** What every item carries, whatever its type.
69
100
  *
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. */
101
+ * `id` is what the links below point with never a position in the array,
102
+ * since a selection or a range decides which items exist in a given read — and
103
+ * `uuid` stays beside it as the record the item came out of, which is what a
104
+ * reader groups by when it wants the whole of one line. */
74
105
  const BASE_FIELDS = {
106
+ id: TranscriptItemId,
75
107
  uuid: Type.String({ minLength: 1 }),
108
+ source: TranscriptItemSource,
76
109
  /** The item's own instant. A call and its result each keep their own. */
77
110
  at: Timestamp,
78
111
  /** Which turn of the session the item fell in, when the reader could place
@@ -86,22 +119,25 @@ const BASE_FIELDS = {
86
119
  * Some results arrive many turns later — an agent runs, a monitor waits — with
87
120
  * other items in between, so folding them into a single item would make the
88
121
  * 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. */
122
+ * keep their own, linked by id, and folding is left to whoever draws them.
123
+ *
124
+ * A link is written from the whole transcript and not from the slice it travels
125
+ * in, so a `result_item` naming an item outside the range asked for is the
126
+ * ordinary case rather than a broken pointer: the reader knows the id and can
127
+ * ask for it. A `use` without one is a call that has not come back. */
92
128
  const USE_FIELDS = {
93
129
  role: Type.Literal("use"),
94
- /** The result's uuid, once there is one. */
95
- result_item: Type.Optional(Type.String({ minLength: 1 })),
130
+ /** The result's id, once there is one. */
131
+ result_item: Type.Optional(TranscriptItemId),
96
132
  };
97
133
 
98
134
  const RESULT_FIELDS = {
99
135
  role: Type.Literal("result"),
100
136
  /** The call this answers. Always known: a result exists because a call did. */
101
- parent_item: Type.String({ minLength: 1 }),
137
+ parent_item: TranscriptItemId,
102
138
  };
103
139
 
104
- function item(type: TSchema, fields: Record<string, TSchema> = {}): TSchema {
140
+ function item<T extends TSchema, F extends Record<string, TSchema>>(type: T, fields: F) {
105
141
  return Type.Object({ ...BASE_FIELDS, type, ...fields });
106
142
  }
107
143
 
@@ -220,16 +256,16 @@ const Hook = item(Type.String({ pattern: "^hook:[A-Za-z0-9_.-]+$" }), {
220
256
 
221
257
  const ToolType = Type.String({ pattern: "^tool:[A-Za-z0-9_.-]+$" });
222
258
 
223
- function toolUse(name: string, fields: Record<string, TSchema>): TSchema {
224
- return item(Type.Literal(`tool:${name}`), {
259
+ function toolUse<N extends string, F extends Record<string, TSchema>>(name: N, fields: F) {
260
+ return item(Type.Literal(`tool:${name}` as const), {
225
261
  ...USE_FIELDS,
226
262
  tool_use_id: Type.String(),
227
263
  ...fields,
228
264
  });
229
265
  }
230
266
 
231
- function toolResult(name: string, fields: Record<string, TSchema>): TSchema {
232
- return item(Type.Literal(`tool:${name}`), {
267
+ function toolResult<N extends string, F extends Record<string, TSchema>>(name: N, fields: F) {
268
+ return item(Type.Literal(`tool:${name}` as const), {
233
269
  ...RESULT_FIELDS,
234
270
  tool_use_id: Type.String(),
235
271
  ...fields,
@@ -253,7 +289,7 @@ const ToolResultGeneric = item(ToolType, {
253
289
  result: Type.Record(Type.String(), Type.Unknown()),
254
290
  });
255
291
 
256
- const TOOL_ITEMS: TSchema[] = [
292
+ const TOOL_ITEMS = [
257
293
  /** No exit code: what the harness records of a shell call is its output and
258
294
  * whether it was interrupted, so that is what a reader has to judge by. */
259
295
  toolUse("Bash", { command: Type.String(), description: OptText }),
@@ -1,6 +1,7 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response, topicFrame } from "../envelope.ts";
3
- import { Sid } from "../identifiers.ts";
3
+ import { Sid, Timestamp } from "../identifiers.ts";
4
+ import { DumpIds, TranscriptItem, TranscriptItemId, TranscriptItemSelector } from "./dump.ts";
4
5
 
5
6
  /** Reads a slice of a session's transcript.
6
7
  *
@@ -50,6 +51,79 @@ export type TranscriptReadResult = Static<typeof TranscriptReadResult>;
50
51
  export const TranscriptReadRequest = request("transcript_read", TranscriptReadArgs);
51
52
  export const TranscriptReadResponse = response("transcript_read", TranscriptReadResult);
52
53
 
54
+ /** Reads a slice of a transcript as the items it was read into.
55
+ *
56
+ * The read above answers with the file's own lines, which leaves whoever asked
57
+ * holding a harness's private format; this answers with what those lines were
58
+ * classified as, so a client draws items and never learns how a record is
59
+ * shaped. Both stay: the typed read is what a client works in, and the raw one
60
+ * is how it fetches the record behind an item it wants to see verbatim, by the
61
+ * `source` that item carries.
62
+ *
63
+ * The range is cut the way a dump's is — an instant or a record on either side
64
+ * — and `since_id` resumes a read that stopped at a limit. The role decides how
65
+ * much is visible, as it does for the raw read. */
66
+ export const TranscriptItemsReadArgs = Type.Object({
67
+ sid: Sid,
68
+ /** Read one agent below the session instead of the session itself. */
69
+ agent_id: Type.Optional(Type.String()),
70
+ /** Inclusive lower bound in time. */
71
+ since_at: Type.Optional(Timestamp),
72
+ /** Inclusive lower bound as a transcript record id, which cuts at that
73
+ * record's position rather than at its clock. Give one lower bound only. */
74
+ since_uuid: Type.Optional(Type.String()),
75
+ /** Resume at this item, the one a previous reply named as `next`. Finer than
76
+ * `since_uuid`, which would start again at the first item of a record whose
77
+ * later items were already read. */
78
+ since_id: Type.Optional(TranscriptItemId),
79
+ until_at: Type.Optional(Timestamp),
80
+ until_uuid: Type.Optional(Type.String()),
81
+ /** Which item types to keep, applied left to right. Absent keeps everything
82
+ * but the attachments, as a dump's absent selection does. */
83
+ types: Type.Optional(Type.Array(TranscriptItemSelector)),
84
+ /** How many items to answer with; the instance narrows this to its own
85
+ * limit. */
86
+ limit: Type.Optional(Type.Integer({ minimum: 1 })),
87
+ });
88
+ export type TranscriptItemsReadArgs = Static<typeof TranscriptItemsReadArgs>;
89
+
90
+ export const TranscriptItemsReadResult = Type.Object({
91
+ /** Oldest first, as the transcript had them. */
92
+ items: Type.Array(TranscriptItem),
93
+ /** The first item left out, when a limit cut the answer short. Absent means
94
+ * the range was answered whole. */
95
+ next: Type.Optional(TranscriptItemId),
96
+ /** The ids the answered items carried, gathered as a dump gathers them.
97
+ * Absent when the caller did not ask the instance to collect them. */
98
+ ids: Type.Optional(DumpIds),
99
+ });
100
+ export type TranscriptItemsReadResult = Static<typeof TranscriptItemsReadResult>;
101
+
102
+ export const TranscriptItemsReadRequest = request("transcript_items_read", TranscriptItemsReadArgs);
103
+ export const TranscriptItemsReadResponse = response(
104
+ "transcript_items_read",
105
+ TranscriptItemsReadResult,
106
+ );
107
+
108
+ /** The `transcript_items:<sid>` topic.
109
+ *
110
+ * What `transcript:<sid>` carries as appended bytes, carried as the items those
111
+ * bytes were read as. A subscriber holds a list it only ever appends to, so the
112
+ * opening frame is the tail of it — the last items the instance kept, in a
113
+ * count it decides — and every frame after carries what has since been
114
+ * classified. A client that wants further back asks for it by range rather than
115
+ * waiting for a snapshot to grow.
116
+ *
117
+ * A record still being written is not classified until its line ends, which is
118
+ * the same rule the raw topic sends whole lines under. */
119
+ export const TranscriptItemsFrame = topicFrame(
120
+ "transcript_items",
121
+ Type.Object({
122
+ sid: Sid,
123
+ items: Type.Array(TranscriptItem),
124
+ }),
125
+ );
126
+
53
127
  /** The `transcript:<sid>` topic.
54
128
  *
55
129
  * The one topic whose frames are not a whole value: a transcript is appended
@@ -67,7 +67,12 @@ import type {
67
67
  SessionSearchRequest,
68
68
  SessionSearchResponse,
69
69
  } from "../control/session.ts";
70
- import type { TranscriptReadRequest, TranscriptReadResponse } from "../control/transcript.ts";
70
+ import type {
71
+ TranscriptItemsReadRequest,
72
+ TranscriptItemsReadResponse,
73
+ TranscriptReadRequest,
74
+ TranscriptReadResponse,
75
+ } from "../control/transcript.ts";
71
76
  import type { TranslateRunRequest, TranslateRunResponse } from "../control/translate.ts";
72
77
  import { FIXTURE_IDS, FIXTURE_NOW } from "./ids.ts";
73
78
 
@@ -217,48 +222,66 @@ export const DUMP_PRESETS_READ_RESPONSE: Static<typeof DumpPresetsReadResponse>
217
222
  *
218
223
  * A call and its result appear as the two items they are, linked both ways, so
219
224
  * an implementation can check that it draws the pair without assuming they are
220
- * adjacent. */
225
+ * adjacent. The thinking and the call after it were read out of one record and
226
+ * carry the one address it sits at, which is the case an id exists for. */
221
227
  export const TRANSCRIPT_ITEMS: Static<typeof TranscriptItem>[] = [
222
228
  {
229
+ id: "3f9a21c4:0",
223
230
  uuid: "3f9a21c4",
231
+ source: { offset: 180_000, bytes: 420 },
224
232
  type: "message:user:in",
225
233
  at: FIXTURE_NOW - 3_600_000,
226
234
  turn: 1,
227
235
  text: "dump のアイテム型を整理して",
228
236
  },
229
- { uuid: "f10b6d43", type: "thinking", at: FIXTURE_NOW - 3_500_000, text: "台帳として分ける" },
230
237
  {
231
- uuid: "07c5e1b8",
238
+ id: "f10b6d43:0",
239
+ uuid: "f10b6d43",
240
+ source: { offset: 180_420, bytes: 980 },
241
+ type: "thinking",
242
+ at: FIXTURE_NOW - 3_500_000,
243
+ text: "台帳として分ける",
244
+ },
245
+ {
246
+ id: "f10b6d43:1",
247
+ uuid: "f10b6d43",
248
+ source: { offset: 180_420, bytes: 980 },
232
249
  type: "tool:Bash",
233
250
  at: FIXTURE_NOW - 3_400_000,
234
251
  role: "use",
235
252
  tool_use_id: "toolu_01Ne9BDS",
236
- result_item: "18d6f2c9",
253
+ result_item: "18d6f2c9:0",
237
254
  command: "jq -r '.type' session.jsonl | sort | uniq -c",
238
255
  description: "count the record types",
239
256
  },
240
257
  {
258
+ id: "18d6f2c9:0",
241
259
  uuid: "18d6f2c9",
260
+ source: { offset: 181_400, bytes: 260 },
242
261
  type: "tool:Bash",
243
262
  at: FIXTURE_NOW - 3_399_000,
244
263
  role: "result",
245
264
  tool_use_id: "toolu_01Ne9BDS",
246
- parent_item: "07c5e1b8",
265
+ parent_item: "f10b6d43:1",
247
266
  stdout: "1174 assistant\n753 user\n",
248
267
  interrupted: false,
249
268
  },
250
269
  {
270
+ id: "b7e41d09:0",
251
271
  uuid: "b7e41d09",
272
+ source: { offset: 181_660, bytes: 640 },
252
273
  type: "message:sub:out",
253
274
  at: FIXTURE_NOW - 3_300_000,
254
275
  role: "use",
255
- result_item: "c2d80f16",
276
+ result_item: "c2d80f16:0",
256
277
  prompt: "docs/design/dump-kinds.md を書き直す",
257
278
  agent_id: "a471372f2",
258
279
  subagent_type: "opus5-worker-high",
259
280
  },
260
281
  {
282
+ id: "92e6d4f5:0",
261
283
  uuid: "92e6d4f5",
284
+ source: { offset: 182_300, bytes: 310 },
262
285
  type: "hook:PreToolUse",
263
286
  at: FIXTURE_NOW - 3_200_000,
264
287
  hook_name: "PreToolUse:Bash",
@@ -267,7 +290,9 @@ export const TRANSCRIPT_ITEMS: Static<typeof TranscriptItem>[] = [
267
290
  tool_use_id: "toolu_01Ne9BDS",
268
291
  },
269
292
  {
293
+ id: "81d5c3e4:0",
270
294
  uuid: "81d5c3e4",
295
+ source: { offset: 182_610, bytes: 190 },
271
296
  type: "system:attachment:queued_command",
272
297
  at: FIXTURE_NOW - 3_100_000,
273
298
  attachment: { type: "queued_command", command: "/pre-clear" },
@@ -292,6 +317,25 @@ export const TRANSCRIPT_READ_RESPONSE: Static<typeof TranscriptReadResponse> = {
292
317
  size: 182_400,
293
318
  };
294
319
 
320
+ export const TRANSCRIPT_ITEMS_READ_REQUEST: Static<typeof TranscriptItemsReadRequest> = {
321
+ request_id,
322
+ op: "transcript_items_read",
323
+ sid,
324
+ since_at: FIXTURE_NOW - 3_600_000,
325
+ types: ["message", "thinking", "tool:Bash"],
326
+ limit: 200,
327
+ };
328
+
329
+ /** The answer stops where the limit did and names what comes next, so the
330
+ * caller asks for the rest with `since_id` and reads nothing twice. */
331
+ export const TRANSCRIPT_ITEMS_READ_RESPONSE: Static<typeof TranscriptItemsReadResponse> = {
332
+ ok: true,
333
+ request_id,
334
+ items: TRANSCRIPT_ITEMS,
335
+ next: "c2d80f16:0",
336
+ ids: [{ kind: "agent", id: "a471372f2", label: "dump-kinds-design", status: "running" }],
337
+ };
338
+
295
339
  export const SESSION_FORK_ORIGIN_REQUEST: Static<typeof SessionForkOriginRequest> = {
296
340
  request_id,
297
341
  op: "session_fork_origin",
@@ -100,6 +100,10 @@ export const OP_FIXTURES = {
100
100
  request: control.TRANSCRIPT_READ_REQUEST,
101
101
  response: control.TRANSCRIPT_READ_RESPONSE,
102
102
  },
103
+ transcript_items_read: {
104
+ request: control.TRANSCRIPT_ITEMS_READ_REQUEST,
105
+ response: control.TRANSCRIPT_ITEMS_READ_RESPONSE,
106
+ },
103
107
  session_fork_origin: {
104
108
  request: control.SESSION_FORK_ORIGIN_REQUEST,
105
109
  response: control.SESSION_FORK_ORIGIN_RESPONSE,
@@ -162,6 +166,7 @@ export const TOPIC_FIXTURES = {
162
166
  agents: topics.AGENTS_FRAME,
163
167
  session_status: topics.SESSION_STATUS_FRAME,
164
168
  transcript: topics.TRANSCRIPT_FRAME,
169
+ transcript_items: topics.TRANSCRIPT_ITEMS_FRAME,
165
170
  session_errors: topics.SESSION_ERRORS_FRAME,
166
171
  llm_requests: topics.LLM_REQUESTS_FRAME,
167
172
  llm_status: topics.LLM_STATUS_FRAME,
@@ -6,7 +6,8 @@ import type { LlmRequestsFrame, LlmStatusFrame } from "../control/llm.ts";
6
6
  import type { PeersFrame } from "../control/peers.ts";
7
7
  import type { SessionErrorsFrame } from "../control/session-errors.ts";
8
8
  import type { SessionStatusFrame } from "../control/session-status.ts";
9
- import type { TranscriptFrame } from "../control/transcript.ts";
9
+ import type { TranscriptFrame, TranscriptItemsFrame } from "../control/transcript.ts";
10
+ import { TRANSCRIPT_ITEMS } from "./control.ts";
10
11
  import type { InboxFrame } from "../messaging/message.ts";
11
12
  import type { NotifyFrame } from "../messaging/notify.ts";
12
13
  import { FIXTURE_IDS, FIXTURE_NOW } from "./ids.ts";
@@ -264,6 +265,24 @@ export const TRANSCRIPT_SIZE_FRAME = {
264
265
  data: { sid, size: 182_400 },
265
266
  } satisfies Static<typeof TranscriptFrame>;
266
267
 
268
+ /** The typed topic, whose frames carry items rather than bytes. */
269
+ export const TRANSCRIPT_ITEMS_FRAME = {
270
+ ev: "topic",
271
+ topic: `transcript_items:${sid}`,
272
+ instance,
273
+ data: { sid, items: TRANSCRIPT_ITEMS.slice(-2) },
274
+ } satisfies Static<typeof TranscriptItemsFrame>;
275
+
276
+ /** Its opening frame, which is the tail the instance kept — the same shape as
277
+ * every frame after it, so a subscriber appends both the same way. */
278
+ export const TRANSCRIPT_ITEMS_SNAPSHOT_FRAME = {
279
+ ev: "topic",
280
+ topic: `transcript_items:${sid}`,
281
+ snapshot: true,
282
+ instance,
283
+ data: { sid, items: TRANSCRIPT_ITEMS },
284
+ } satisfies Static<typeof TranscriptItemsFrame>;
285
+
267
286
  export const SESSION_ERRORS_FRAME: Static<typeof SessionErrorsFrame> = {
268
287
  ev: "topic",
269
288
  topic: "session_errors",
package/src/schemas.ts CHANGED
@@ -104,6 +104,9 @@ import {
104
104
  } from "./control/session.ts";
105
105
  import {
106
106
  TranscriptFrame,
107
+ TranscriptItemsFrame,
108
+ TranscriptItemsReadRequest,
109
+ TranscriptItemsReadResponse,
107
110
  TranscriptReadRequest,
108
111
  TranscriptReadResponse,
109
112
  } from "./control/transcript.ts";
@@ -153,6 +156,10 @@ export const OP_SCHEMAS: Record<OpName, OpSchemas> = {
153
156
  session_dump_write: { request: SessionDumpWriteRequest, response: SessionDumpWriteResponse },
154
157
  dump_presets_read: { request: DumpPresetsReadRequest, response: DumpPresetsReadResponse },
155
158
  transcript_read: { request: TranscriptReadRequest, response: TranscriptReadResponse },
159
+ transcript_items_read: {
160
+ request: TranscriptItemsReadRequest,
161
+ response: TranscriptItemsReadResponse,
162
+ },
156
163
  session_fork_origin: { request: SessionForkOriginRequest, response: SessionForkOriginResponse },
157
164
  session_last_live_remove: {
158
165
  request: SessionLastLiveRemoveRequest,
@@ -197,6 +204,7 @@ export const TOPIC_SCHEMAS = {
197
204
  agents: AgentsFrame,
198
205
  session_status: SessionStatusFrame,
199
206
  transcript: TranscriptFrame,
207
+ transcript_items: TranscriptItemsFrame,
200
208
  session_errors: SessionErrorsFrame,
201
209
  llm_requests: LlmRequestsFrame,
202
210
  llm_status: LlmStatusFrame,