@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.
|
|
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.
|
|
23
|
+
"@ccmsg/protocol": "1.15.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/messaging/notify.ts
CHANGED
|
@@ -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
|
-
"
|
|
108
|
+
"rate_limited",
|
|
109
109
|
"a watcher is behind on this topic; the notification was not taken",
|
|
110
110
|
);
|
|
111
111
|
}
|
package/src/sessions/items.ts
CHANGED
|
@@ -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;
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
|
|
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.
|
package/src/transcript/index.ts
CHANGED
|
@@ -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
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
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
|
-
|
|
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(
|
|
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("←",
|
|
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,
|
|
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
|
-
|
|
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
|
|
208
|
-
const
|
|
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
|
}
|
package/src/transcript/tail.ts
CHANGED
|
@@ -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
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|