@ccmsg/cli 0.12.0 → 0.14.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.
@@ -1,4 +1,5 @@
1
- import { readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { readdirSync, type Stats, statSync } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
2
3
  import { basename, dirname, join } from "node:path";
3
4
  import type { Sid, TranscriptSubject } from "@ccmsg/protocol";
4
5
  import { type Harness, HARNESS } from "../harness/index.ts";
@@ -114,7 +115,7 @@ export class TranscriptFiles {
114
115
  * `agent_id` and `teammate` are two ways of naming the same kind of file and
115
116
  * cannot be combined — a request carrying both names two files and is a
116
117
  * caller's mistake rather than a choice this makes for them. */
117
- locate(sid: Sid, names: AgentNames = {}): string {
118
+ async locate(sid: Sid, names: AgentNames = {}): Promise<string> {
118
119
  const file = this.session(sid);
119
120
  if (names.agent_id !== undefined && names.teammate !== undefined) {
120
121
  throw new OpError("invalid_args", "agent_id and teammate name two different transcripts");
@@ -127,9 +128,11 @@ export class TranscriptFiles {
127
128
  }
128
129
  const under = agentsDir(file, names.run_id);
129
130
  if (names.agent_id !== undefined) {
130
- return existing(join(under, `agent-${name(names.agent_id, AGENT_ID, "agent_id")}${SUFFIX}`));
131
+ return await existing(
132
+ join(under, `agent-${name(names.agent_id, AGENT_ID, "agent_id")}${SUFFIX}`),
133
+ );
131
134
  }
132
- return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
135
+ return await this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
133
136
  }
134
137
 
135
138
  /** Which standing a transcript was written from, which every item read out
@@ -148,13 +151,13 @@ export class TranscriptFiles {
148
151
  * nothing goes on standing, nobody is addressed by name — so a teammate read
149
152
  * as one loses a name it might have been drawn under, where the reverse would
150
153
  * have a reader write back to something that is already gone. */
151
- subjectOf(file: string): TranscriptSubject {
154
+ async subjectOf(file: string): Promise<TranscriptSubject> {
152
155
  const name = basename(file);
153
156
  if (!name.startsWith(AGENT_PREFIX) || !name.endsWith(SUFFIX)) return "main";
154
157
  let note: unknown;
155
158
  try {
156
159
  note = JSON.parse(
157
- readFileSync(join(dirname(file), `${name.slice(0, -SUFFIX.length)}.meta.json`), "utf8"),
160
+ await readFile(join(dirname(file), `${name.slice(0, -SUFFIX.length)}.meta.json`), "utf8"),
158
161
  );
159
162
  } catch {
160
163
  return "sub";
@@ -168,10 +171,10 @@ export class TranscriptFiles {
168
171
  * The name a teammate carries in conversation is not its filename, so the
169
172
  * directory's own records are read for it rather than the name being
170
173
  * substituted into a path. */
171
- private teammate(under: string, wanted: string): string {
174
+ private async teammate(under: string, wanted: string): Promise<string> {
172
175
  let names: string[];
173
176
  try {
174
- names = readdirSync(under);
177
+ names = await readdir(under);
175
178
  } catch {
176
179
  throw new OpError("not_found", `no agent has run under this session`);
177
180
  }
@@ -179,13 +182,13 @@ export class TranscriptFiles {
179
182
  if (!each.endsWith(".meta.json")) continue;
180
183
  let document: unknown;
181
184
  try {
182
- document = JSON.parse(readFileSync(join(under, each), "utf8"));
185
+ document = JSON.parse(await readFile(join(under, each), "utf8"));
183
186
  } catch {
184
187
  continue;
185
188
  }
186
189
  const named = (document as { name?: unknown } | null)?.name;
187
190
  if (named !== wanted) continue;
188
- return existing(join(under, `${each.slice(0, -".meta.json".length)}${SUFFIX}`));
191
+ return await existing(join(under, `${each.slice(0, -".meta.json".length)}${SUFFIX}`));
189
192
  }
190
193
  throw new OpError("not_found", `no teammate of this session is addressed as ${wanted}`);
191
194
  }
@@ -195,16 +198,16 @@ export class TranscriptFiles {
195
198
  * The one enumeration a search and a fork sweep both start from. It states
196
199
  * the file and what a `stat` already said about it, so neither has to stat
197
200
  * again to decide whether to open it. */
198
- all(): TranscriptFile[] {
201
+ async all(): Promise<TranscriptFile[]> {
199
202
  const layout = LAYOUTS[this.deps.harness];
200
203
  const found: TranscriptFile[] = [];
201
- for (const dir of directories(this.#root(), layout.depth)) {
202
- for (const entry of names(dir)) {
204
+ for (const dir of await walked(this.#root(), layout.depth)) {
205
+ for (const entry of await listed(dir)) {
203
206
  const sid = layout.sidOf(entry);
204
207
  if (sid === undefined) continue;
205
208
  const file = join(dir, entry);
206
- const stat = statOf(file);
207
- if (stat === undefined) continue;
209
+ const known = await stated(file);
210
+ if (known === undefined) continue;
208
211
  found.push({
209
212
  sid,
210
213
  file,
@@ -213,9 +216,9 @@ export class TranscriptFiles {
213
216
  // a session ran narrows nothing, and the transcript's own `cwd`
214
217
  // decides as it already does.
215
218
  ...(this.deps.harness === "claude" ? { project: basename(dir) } : {}),
216
- size: stat.size,
217
- created_at: Math.round(stat.birthtimeMs || stat.ctimeMs),
218
- updated_at: Math.round(stat.mtimeMs),
219
+ size: known.size,
220
+ created_at: Math.round(known.birthtimeMs || known.ctimeMs),
221
+ updated_at: Math.round(known.mtimeMs),
219
222
  });
220
223
  }
221
224
  }
@@ -260,7 +263,27 @@ export class TranscriptFiles {
260
263
  *
261
264
  * Names are read rather than dates computed: what is there is what the harness
262
265
  * wrote, and a tree with a directory nobody expected is one whose files are
263
- * still found. */
266
+ * still found.
267
+ *
268
+ * There are two of this walk, and of the two readings below it, because the
269
+ * two callers are not alike: `all()` runs from an op and reads the tree
270
+ * without holding the instance, while `path()` also runs from a subscription
271
+ * being opened, which states its value in the turn it is opened in and so
272
+ * cannot wait (DESIGN §6.2). The one that cannot wait is the one CT-Q8 and the
273
+ * issue `fold-from-head-with-versioned-cache` settle, and the two become one
274
+ * reading again when they do. */
275
+ async function walked(root: string, depth: number): Promise<string[]> {
276
+ let level = [root];
277
+ for (let step = 0; step < depth; step += 1) {
278
+ const below: string[] = [];
279
+ for (const dir of level) {
280
+ for (const entry of await listed(dir)) below.push(join(dir, entry));
281
+ }
282
+ level = below;
283
+ }
284
+ return level;
285
+ }
286
+
264
287
  function directories(root: string, depth: number): string[] {
265
288
  let level = [root];
266
289
  for (let step = 0; step < depth; step += 1) {
@@ -305,11 +328,21 @@ function name(value: string, shape: RegExp, field: string): string {
305
328
  return value;
306
329
  }
307
330
 
308
- function existing(file: string): string {
309
- if (!isFile(file)) throw new OpError("not_found", "no transcript is held for that agent");
331
+ async function existing(file: string): Promise<string> {
332
+ if ((await stated(file)) === undefined) {
333
+ throw new OpError("not_found", "no transcript is held for that agent");
334
+ }
310
335
  return file;
311
336
  }
312
337
 
338
+ async function listed(dir: string): Promise<string[]> {
339
+ try {
340
+ return await readdir(dir);
341
+ } catch {
342
+ return [];
343
+ }
344
+ }
345
+
313
346
  function names(dir: string): string[] {
314
347
  try {
315
348
  return readdirSync(dir);
@@ -318,10 +351,19 @@ function names(dir: string): string[] {
318
351
  }
319
352
  }
320
353
 
354
+ async function stated(file: string): Promise<Stats | undefined> {
355
+ try {
356
+ const known = await stat(file);
357
+ return known.isFile() ? known : undefined;
358
+ } catch {
359
+ return undefined;
360
+ }
361
+ }
362
+
321
363
  function statOf(file: string) {
322
364
  try {
323
- const stat = statSync(file);
324
- return stat.isFile() ? stat : undefined;
365
+ const known = statSync(file);
366
+ return known.isFile() ? known : undefined;
325
367
  } catch {
326
368
  return undefined;
327
369
  }
@@ -1,4 +1,4 @@
1
- import { closeSync, openSync, readSync, statSync } from "node:fs";
1
+ import { open, stat } from "node:fs/promises";
2
2
  import type { TranscriptReadResult, Sid } from "@ccmsg/protocol";
3
3
  import { OpError } from "../dispatch/index.ts";
4
4
 
@@ -20,15 +20,15 @@ export const READ_LIMIT = 512 * 1024;
20
20
  *
21
21
  * The offsets are the ones the `transcript` topic's frames carry, so what a
22
22
  * client reads and what arrives live stitch together without overlap. */
23
- export function readSlice(
23
+ export async function readSlice(
24
24
  sid: Sid,
25
25
  file: string,
26
26
  before?: number,
27
27
  maxBytes?: number,
28
- ): TranscriptReadResult {
28
+ ): Promise<TranscriptReadResult> {
29
29
  let size: number;
30
30
  try {
31
- size = statSync(file).size;
31
+ size = (await stat(file)).size;
32
32
  } catch {
33
33
  throw new OpError("not_found", `the transcript of ${sid} could not be read`);
34
34
  }
@@ -45,7 +45,7 @@ export function readSlice(
45
45
  // arithmetic puts them — inside a character as readily as before one — and
46
46
  // decoding first would turn those bytes into replacement characters of a
47
47
  // different length, moving every offset derived from them.
48
- const bytes = slice(file, probe, until);
48
+ const bytes = await slice(file, probe, until);
49
49
  // What of the read is whole records: everything up to the last newline. A
50
50
  // record the writer has not finished ends the file without one.
51
51
  const lastNewline = bytes.lastIndexOf(NEWLINE);
@@ -69,14 +69,14 @@ const NEWLINE = 0x0a;
69
69
 
70
70
  /** The bytes in a range. A range that reads short — the file was truncated
71
71
  * between the stat and the read — yields what was actually there. */
72
- function slice(file: string, from: number, to: number): Buffer {
72
+ async function slice(file: string, from: number, to: number): Promise<Buffer> {
73
73
  if (to <= from) return Buffer.alloc(0);
74
- const handle = openSync(file, "r");
74
+ const handle = await open(file, "r");
75
75
  try {
76
76
  const buffer = Buffer.alloc(to - from);
77
- const read = readSync(handle, buffer, 0, buffer.length, from);
78
- return buffer.subarray(0, read);
77
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, from);
78
+ return buffer.subarray(0, bytesRead);
79
79
  } finally {
80
- closeSync(handle);
80
+ await handle.close();
81
81
  }
82
82
  }
@@ -0,0 +1,49 @@
1
+ import type { TranscriptSubject } from "@ccmsg/protocol";
2
+ import { Classification, type Item } from "./items/index.ts";
3
+ import { positioned, span } from "./items/record.ts";
4
+
5
+ /** How many records one pass reads before it hands the loop back.
6
+ *
7
+ * A transcript is read whole by the ops that classify one, and reading it is
8
+ * CPU rather than IO: the file arrives in one `await` and every record in it is
9
+ * then parsed. Nothing else on the instance runs while that happens, so the
10
+ * pass is cut into turns — small enough that the wait an op elsewhere sees is
11
+ * one turn of parsing, large enough that the turns themselves cost nothing
12
+ * measurable against the parsing they carry. */
13
+ const RECORDS_PER_TURN = 2000;
14
+
15
+ /** Hand the event loop back, so that whatever else is waiting on it runs.
16
+ *
17
+ * The pause of a macrotask rather than of a microtask: a promise resolved with
18
+ * nothing to wait for is drained before the loop is reached at all, which would
19
+ * make the yield a shape in the code and nothing in the behaviour. */
20
+ export function breathe(): Promise<void> {
21
+ return new Promise((resume) => {
22
+ setImmediate(resume);
23
+ });
24
+ }
25
+
26
+ /** Whether this many records have gone by since the last pause. */
27
+ export function due(read: number): boolean {
28
+ return read > 0 && read % RECORDS_PER_TURN === 0;
29
+ }
30
+
31
+ /** A whole transcript as the items it was read into, yielding as it goes.
32
+ *
33
+ * The same reading `classify` does over the whole text at once, cut into turns:
34
+ * one `Classification` is fed chunk after chunk, so a call in one chunk is
35
+ * still known when its result arrives in a later one, and the items come back
36
+ * in the order the records are in. */
37
+ export async function classified(text: string, subject: TranscriptSubject): Promise<Item[]> {
38
+ const reading = new Classification(subject);
39
+ const lines = text.split("\n");
40
+ const items: Item[] = [];
41
+ let offset = 0;
42
+ for (let from = 0; from < lines.length; from += RECORDS_PER_TURN) {
43
+ const chunk = lines.slice(from, from + RECORDS_PER_TURN);
44
+ for (const item of reading.readAll(positioned(chunk, offset))) items.push(item);
45
+ for (const line of chunk) offset += span(line);
46
+ if (from + RECORDS_PER_TURN < lines.length) await breathe();
47
+ }
48
+ return items;
49
+ }