@ccmsg/cli 0.5.1 → 0.6.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/cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.14.0"
23
+ "@ccmsg/protocol": "1.15.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
@@ -105,7 +105,7 @@ export class Notify implements UpstreamResource {
105
105
  // raise another (§6.4).
106
106
  if (this.deps.publish(NOTIFY, notification, this.deps.self) === "rate_limited") {
107
107
  throw new OpError(
108
- "internal_error",
108
+ "rate_limited",
109
109
  "a watcher is behind on this topic; the notification was not taken",
110
110
  );
111
111
  }
@@ -80,21 +80,18 @@ export function itemsRead(
80
80
 
81
81
  /** Whether the range's end is the part to answer with.
82
82
  *
83
- * A caller that named where to start is reading forward from there; one that
84
- * named only where to stop is looking at the newest of what it asked for, and
85
- * answering with the oldest of that range would hand it the far side of a
86
- * transcript it is walking back through. With neither bound the range is the
87
- * whole transcript, which is read from its beginning. */
83
+ * A caller that named where to start is reading forward from there; anyone
84
+ * else is looking at the newest of what it asked for, and answering with the
85
+ * oldest of that range would hand it the far side of a transcript it is
86
+ * walking back through. Naming no bound at all is the ordinary first read and
87
+ * answers the tail the same way; a caller that wants the transcript from its
88
+ * beginning says so with `since_at: 0`. */
88
89
  function backwards(bounds: TranscriptItemsReadArgs): boolean {
89
90
  const lower =
90
91
  bounds.since_at !== undefined ||
91
92
  bounds.since_uuid !== undefined ||
92
93
  bounds.since_id !== undefined;
93
- const upper =
94
- bounds.until_at !== undefined ||
95
- bounds.until_uuid !== undefined ||
96
- bounds.until_id !== undefined;
97
- return upper && !lower;
94
+ return !lower;
98
95
  }
99
96
 
100
97
  /** As much of the range as one answer carries, and where the next one starts.
@@ -13,4 +13,4 @@ export {
13
13
  } from "./fold.ts";
14
14
  export { READ_LIMIT, readSlice } from "./read.ts";
15
15
  export { type Appended, FOLD_TAIL_BYTES, TranscriptTail } from "./tail.ts";
16
- export { Transcripts, type TranscriptsDeps } from "./transcripts.ts";
16
+ export { ITEMS_SNAPSHOT, Transcripts, type TranscriptsDeps } from "./transcripts.ts";
@@ -58,9 +58,21 @@ const NOT_ITEMS = new Set([
58
58
 
59
59
  /** The tools that start an agent, in the spellings the harness has used for
60
60
  * the one thing. Both are read the same way: the call is also a brief, and
61
- * what comes back is also an answer. */
61
+ * what comes back is also an answer.
62
+ *
63
+ * They arrive under one type. Two names for one thing would put the same item
64
+ * in the vocabulary twice, and a selection asking for the tool that starts an
65
+ * agent would have to know which spelling this transcript happened to use. The
66
+ * spelling the harness wrote stays on the item as `harness_name`, for a reader
67
+ * matching what it sees against what it ran. */
62
68
  const SPAWNS = new Set(["Agent", "Task"]);
63
69
 
70
+ /** The name an item is typed under, which is the harness's own except where
71
+ * two of its names are one thing. */
72
+ function typedAs(name: string): string {
73
+ return SPAWNS.has(name) ? "Agent" : name;
74
+ }
75
+
64
76
  /** How many calls awaiting an answer one reading holds. Reached only by calls
65
77
  * that are never answered, since an answered one is let go where it is
66
78
  * answered. */
@@ -135,8 +147,12 @@ export class Classification {
135
147
  read(record: Row, source: { offset: number; bytes: number }): void {
136
148
  const type = str(record["type"]);
137
149
  if (type === undefined || NOT_ITEMS.has(type)) return;
138
- const uuid = str(record["uuid"]) ?? "";
139
- if (uuid === "") return;
150
+ // A record the harness wrote without an id of its own still happened, and
151
+ // an item is pointed at by the record it came from — so where the record
152
+ // stands in the file stands in for the id it lacks. The `@` says which of
153
+ // the two it is, since a reader that groups by record must not take an
154
+ // address for something the harness will write again.
155
+ const uuid = str(record["uuid"]) ?? `@${String(source.offset)}`;
140
156
  const at = instant(record["timestamp"]);
141
157
  // Where in its record an item stood. One record becomes the thinking, the
142
158
  // words and each call of a turn, and a link that named only the record
@@ -242,15 +258,18 @@ export class Classification {
242
258
  const id = str(block["id"]) ?? "";
243
259
  const input = row(block["input"]) ?? {};
244
260
  const fields = useFields(name, input);
245
- const tool = make(`tool:${segment(name)}`, {
261
+ const called = typedAs(name);
262
+ const item = make(`tool:${segment(called)}`, {
246
263
  role: "use",
247
264
  tool_use_id: id,
265
+ ...(called === name ? {} : { harness_name: name }),
248
266
  ...(fields ?? { input }),
249
267
  });
250
268
  let message: Draft | undefined;
251
269
  if (SPAWNS.has(name)) {
252
270
  message = make("message:sub:out", {
253
271
  role: "use",
272
+ tool_use_id: id,
254
273
  prompt: str(input["prompt"]) ?? "",
255
274
  ...optional("subagent_type", str(input["subagent_type"])),
256
275
  ...optional("name", str(input["name"])),
@@ -261,7 +280,7 @@ export class Classification {
261
280
  // A sid is the harness's own uuid; anything else is a name, and a name
262
281
  // is how an agent below this session is addressed.
263
282
  make(addressed(to) ? "message:session:out" : "message:sub:out", {
264
- ...(addressed(to) ? {} : { role: "use" }),
283
+ ...(addressed(to) ? {} : { role: "use", tool_use_id: id }),
265
284
  ...(addressed(to)
266
285
  ? { text: text(input["message"]) ?? "", to }
267
286
  : // Writing to an agent is one direction of a correspondence, not a
@@ -283,7 +302,7 @@ export class Classification {
283
302
  const oldest = this.#calls.keys().next();
284
303
  if (oldest.done !== true) this.#calls.delete(oldest.value);
285
304
  }
286
- this.#calls.set(id, { tool, name, ...optional("message", message) });
305
+ this.#calls.set(id, { tool: item, name: called, ...optional("message", message) });
287
306
  }
288
307
 
289
308
  #user(record: Row, make: Make): void {
@@ -313,12 +332,18 @@ export class Classification {
313
332
  const id = str(block["tool_use_id"]) ?? "";
314
333
  const call = this.#calls.get(id);
315
334
  if (call === undefined) {
316
- // An answer to a call this reading never saw. A result item names the
317
- // call it answers and there is no id to name, so what is stated is the
318
- // record itself rather than a pointer to something that does not exist.
319
- // It happens where a reading starts part-way down a file: the whole file
320
- // is read before a dump's range is applied, so the call is there.
321
- make("system:unknown", { record });
335
+ // An answer to a call this reading never saw, which is what a reading
336
+ // that starts part-way down a file meets. The record says which call it
337
+ // answers and never which tool was called, so the type is the reserved
338
+ // name for a result whose tool this instance does not know rather than a
339
+ // name guessed from what came back. What ties it to the call is the key
340
+ // the harness paired them by, which a reader joins against the calls it
341
+ // holds.
342
+ make("tool:unknown", {
343
+ role: "result",
344
+ parent_tool_use_id: id,
345
+ result: genericResult(record["toolUseResult"]),
346
+ });
322
347
  return;
323
348
  }
324
349
  const failed = block["is_error"] === true;
@@ -327,7 +352,7 @@ export class Classification {
327
352
  const item = make(`tool:${segment(call.name)}`, {
328
353
  role: "result",
329
354
  parent_item: call.tool.id,
330
- tool_use_id: id,
355
+ parent_tool_use_id: id,
331
356
  ...(fields ?? { result: genericResult(answer) }),
332
357
  });
333
358
  call.tool["result_item"] = item.id;
@@ -353,6 +378,7 @@ export class Classification {
353
378
  const reply = make("message:sub:in", {
354
379
  role: "result",
355
380
  parent_item: call.message.id,
381
+ parent_tool_use_id: id,
356
382
  text: said,
357
383
  ...optional("agent_id", agent),
358
384
  ...optional("status", str(result["status"])),
@@ -448,6 +474,7 @@ export class Classification {
448
474
  const item = make("message:sub:in", {
449
475
  role: "result",
450
476
  parent_item: asked.id,
477
+ parent_tool_use_id: key,
451
478
  text: answer,
452
479
  ...optional("agent_id", str(asked["agent_id"]) ?? tagged(said, "task-id")),
453
480
  ...optional("status", tagged(said, "status")),
@@ -44,7 +44,15 @@ export function document(file: SessionDumpFile, view: DumpView = {}): string {
44
44
  if (paired.folded.has(at)) continue;
45
45
  const item = items[at] as Item;
46
46
  const child = paired.child.get(at);
47
- lines.push(...draw(item, child === undefined ? undefined : (items[child] as Item), view), "");
47
+ lines.push(
48
+ ...draw(
49
+ item,
50
+ child === undefined ? undefined : (items[child] as Item),
51
+ view,
52
+ paired.parent.get(at),
53
+ ),
54
+ "",
55
+ );
48
56
  }
49
57
  lines.push(...ledger(file.ids));
50
58
  return `${lines.join("\n").trimEnd()}\n`;
@@ -72,12 +80,12 @@ function heading(file: SessionDumpFile, view: DumpView): string[] {
72
80
  * A call keeps its own heading and the answer's words are put at the end of
73
81
  * it, so `→` reads as "and this came back". An answer drawn where it arrived
74
82
  * points the other way, at a call the reader has already gone past. */
75
- function draw(item: Item, child: Item | undefined, view: DumpView): string[] {
83
+ function draw(item: Item, child: Item | undefined, view: DumpView, parent?: string): string[] {
76
84
  const own = fragment(item);
77
85
  const answer = child === undefined ? undefined : fragment(child);
78
86
  const nested = child !== undefined && child.type.startsWith("message:sub");
79
87
  const link = isResult(item)
80
- ? arrow("←", fields(item)["parent_item"])
88
+ ? arrow("←", parent)
81
89
  : (arrow("→", fields(item)["result_item"]) ?? waiting(item));
82
90
  const head = isResult(item)
83
91
  ? words(prefix(item), link, own.head, clock(item))
@@ -185,27 +193,49 @@ function cell(text: string): string {
185
193
  return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
186
194
  }
187
195
 
188
- /** Which answer belongs to which call, and which of those are drawn together.
196
+ /** Which answer belongs to which call, which call each answer points back at,
197
+ * and which of those pairs are drawn together.
189
198
  *
190
199
  * An answer names the call it answers, so the matching is a lookup: no two
191
200
  * calls in one record are confused for one another, and a tool and an agent
192
201
  * are paired by the same rule rather than by the ids each of them happens to
193
- * carry. */
202
+ * carry. An answer read where its call was not says instead which key the
203
+ * harness paired them by, and the call that names that key is the one it
204
+ * belongs to. */
194
205
  function pair(items: readonly Item[]): {
195
206
  child: Map<number, number>;
196
207
  folded: Set<number>;
208
+ parent: Map<number, string>;
197
209
  } {
198
210
  const child = new Map<number, number>();
199
211
  const folded = new Set<number>();
212
+ const parent = new Map<number, string>();
200
213
  const where = new Map<string, number>();
201
- for (let at = 0; at < items.length; at += 1) where.set((items[at] as Item).id, at);
214
+ const called = new Map<string, string>();
215
+ for (let at = 0; at < items.length; at += 1) {
216
+ const item = items[at] as Item;
217
+ where.set(item.id, at);
218
+ const key = fields(item)["tool_use_id"];
219
+ if (fields(item)["role"] === "use" && typeof key === "string" && key !== "") {
220
+ called.set(joined(item, key), item.id);
221
+ }
222
+ }
202
223
  for (let at = 0; at < items.length; at += 1) {
203
224
  const item = items[at] as Item;
204
225
  // Which half of an exchange this is, which the contract calls an item's
205
226
  // role and nothing here confuses with who is allowed to ask for one.
206
227
  if (fields(item)["role"] !== "result") continue;
207
- const parent = fields(item)["parent_item"];
208
- const call = typeof parent === "string" ? where.get(parent) : undefined;
228
+ const named = fields(item)["parent_item"];
229
+ const key = fields(item)["parent_tool_use_id"];
230
+ const to =
231
+ typeof named === "string"
232
+ ? named
233
+ : typeof key === "string"
234
+ ? called.get(joined(item, key))
235
+ : undefined;
236
+ if (to === undefined) continue;
237
+ parent.set(at, to);
238
+ const call = where.get(to);
209
239
  if (call === undefined) continue;
210
240
  // A pair the reader would have to scroll between is left where each half
211
241
  // happened, unless it is an agent's: what an agent was asked and what it
@@ -214,5 +244,14 @@ function pair(items: readonly Item[]): {
214
244
  child.set(call, at);
215
245
  folded.add(at);
216
246
  }
217
- return { child, folded };
247
+ return { child, folded, parent };
248
+ }
249
+
250
+ /** The key an exchange is joined on. One call the harness gave a key to is two
251
+ * items where it started an agent — the call and the brief beside it — so the
252
+ * side of the exchange goes into the key: a tool's answer belongs to the call
253
+ * and an agent's to the brief, and the harness's key alone would not say
254
+ * which. */
255
+ function joined(item: Item, key: string): string {
256
+ return `${item.type.startsWith("message:sub") ? "sub" : "tool"}\n${key}`;
218
257
  }
@@ -1,4 +1,4 @@
1
- import { type FSWatcher, statSync, watch } from "node:fs";
1
+ import { closeSync, type FSWatcher, openSync, readSync, statSync, watch } from "node:fs";
2
2
  import { open, stat } from "node:fs/promises";
3
3
  import { CONFIRM_POLL_MS } from "../sessions/harness.ts";
4
4
 
@@ -86,11 +86,16 @@ export class TranscriptTail {
86
86
  }
87
87
 
88
88
  /** Begin following, seeding the fold from the end of what is already there.
89
- * Resolves once the seed has been read, so a snapshot taken after it states
90
- * a size the fold has caught up with. */
89
+ *
90
+ * The seed is read before this returns rather than awaited, for the reason
91
+ * the size is read in the constructor: a subscription's snapshot is answered
92
+ * in the same turn the tail is started, and what the seed settles — the
93
+ * fold's values, and the items a subscriber opens on — would otherwise be
94
+ * stated as empty and the whole existing end of the file would arrive later
95
+ * as though it had just been appended. */
91
96
  async start(): Promise<void> {
92
97
  if (this.running) return;
93
- await this.#seed();
98
+ this.#seed();
94
99
  try {
95
100
  this.#watcher = watch(this.path, () => void this.refresh());
96
101
  } catch {
@@ -116,11 +121,11 @@ export class TranscriptTail {
116
121
  return this.#reading;
117
122
  }
118
123
 
119
- async #seed(): Promise<void> {
124
+ #seed(): void {
120
125
  const size = this.#size;
121
126
  if (size === 0) return;
122
127
  const from = Math.max(0, size - FOLD_TAIL_BYTES);
123
- const complete = whole(await this.#slice(from, size));
128
+ const complete = whole(this.#sliceSync(from, size));
124
129
  this.#offset = from + complete.byteLength;
125
130
  // The first line is half a record whenever the read began mid-file, so it
126
131
  // is dropped: what is read are whole records or nothing.
@@ -164,6 +169,26 @@ export class TranscriptTail {
164
169
  * arithmetic puts it, inside a character as readily as before one, and
165
170
  * decoding first would turn those bytes into a replacement character of a
166
171
  * different length and move every offset derived from it. */
172
+ /** The same range, read without yielding, which is what the seed is read
173
+ * through: the turn that starts a tail is the turn that answers a
174
+ * subscription, and it has to hold the end of the file by then. Bounded by
175
+ * `FOLD_TAIL_BYTES` however large the transcript is. */
176
+ #sliceSync(from: number, to: number): Buffer {
177
+ if (to <= from) return Buffer.alloc(0);
178
+ let handle: number;
179
+ try {
180
+ handle = openSync(this.path, "r");
181
+ } catch {
182
+ return Buffer.alloc(0);
183
+ }
184
+ try {
185
+ const buffer = Buffer.alloc(to - from);
186
+ return buffer.subarray(0, readSync(handle, buffer, 0, buffer.length, from));
187
+ } finally {
188
+ closeSync(handle);
189
+ }
190
+ }
191
+
167
192
  async #slice(from: number, to: number): Promise<Buffer> {
168
193
  if (to <= from) return Buffer.alloc(0);
169
194
  const handle = await open(this.path, "r").catch(() => undefined);