@ccmsg/cli 0.13.0 → 0.14.1

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,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFile } from "node:fs/promises";
2
2
  import type {
3
3
  DumpPreset,
4
4
  TranscriptItemsReadArgs,
@@ -8,15 +8,14 @@ import { OpError } from "../dispatch/index.ts";
8
8
  import type { TranscriptFiles } from "../transcript/index.ts";
9
9
  import {
10
10
  bounded,
11
- classify,
12
11
  type Item,
13
12
  ledger,
14
- located,
15
13
  select,
16
14
  selection,
17
15
  within,
18
16
  } from "../transcript/items/index.ts";
19
17
  import { READ_LIMIT } from "../transcript/read.ts";
18
+ import { classified } from "../transcript/scan.ts";
20
19
 
21
20
  /** How many items one read may carry.
22
21
  *
@@ -52,23 +51,26 @@ export interface ItemsReadDeps {
52
51
  * is. That is what makes a link answerable: a result inside the range whose
53
52
  * call fell before it still names the call, and the caller can ask for the
54
53
  * call by the id it was given. */
55
- export function itemsRead(
54
+ export async function itemsRead(
56
55
  args: TranscriptItemsReadArgs,
57
56
  deps: ItemsReadDeps,
58
- ): TranscriptItemsReadResult {
57
+ ): Promise<TranscriptItemsReadResult> {
59
58
  bounded(args);
60
- const file = deps.files.locate(
59
+ const file = await deps.files.locate(
61
60
  args.sid,
62
61
  args.agent_id === undefined ? {} : { agent_id: args.agent_id },
63
62
  );
64
63
  let text: string;
65
64
  try {
66
- text = readFileSync(file, "utf8");
65
+ text = await readFile(file, "utf8");
67
66
  } catch {
68
67
  throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
69
68
  }
70
69
  const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
71
- const { items } = select(within(classify(located(text), deps.files.subjectOf(file)), args), keep);
70
+ const { items } = select(
71
+ within(await classified(text, await deps.files.subjectOf(file)), args),
72
+ keep,
73
+ );
72
74
  const page = paged(items, args.limit, backwards(args));
73
75
  return {
74
76
  items: page.items,
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, rename, writeFile } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
  import {
4
5
  LAST_LIVE_RETENTION_MS,
@@ -53,6 +54,9 @@ const VERSION = 1;
53
54
  export class LastLiveStore {
54
55
  #entries = new Map<Sid, StoredEntry>();
55
56
 
57
+ /** The writes already asked for, as one chain. */
58
+ #written: Promise<void> = Promise.resolve();
59
+
56
60
  /** `id` is this instance's own: every entry this store holds is by
57
61
  * definition an observation *this* instance made, so `instance` is forced
58
62
  * to it on both ends (load and record) rather than trusted from whatever
@@ -114,6 +118,12 @@ export class LastLiveStore {
114
118
  return true;
115
119
  }
116
120
 
121
+ /** Settle once every write asked for so far has landed. What a stop waits on,
122
+ * and what a reader of the file has to wait for to see the last change. */
123
+ async flush(): Promise<void> {
124
+ await this.#written;
125
+ }
126
+
117
127
  #prune(now: Timestamp): boolean {
118
128
  let dropped = false;
119
129
  for (const [sid, entry] of this.#entries) {
@@ -125,13 +135,26 @@ export class LastLiveStore {
125
135
  }
126
136
 
127
137
  /** Written whole through a temporary file, so a daemon killed mid-write
128
- * leaves the previous list rather than half of this one. */
138
+ * leaves the previous list rather than half of this one.
139
+ *
140
+ * A session appearing or going is an ordinary event of a running instance, so
141
+ * the write does not hold it still (DR-0015). The body is taken here, before
142
+ * anything is awaited, and each write is chained onto the one before it: what
143
+ * lands last is what the list said last, and two of them cannot be sharing
144
+ * one temporary file. */
129
145
  #save(): void {
130
146
  const document: Document = { version: VERSION, sessions: [...this.#entries.values()] };
131
147
  const temporary = `${this.file}.${process.pid}.tmp`;
132
- mkdirSync(dirname(this.file), { recursive: true });
133
- writeFileSync(temporary, `${JSON.stringify(document)}\n`);
134
- renameSync(temporary, this.file);
148
+ this.#written = this.#written.then(async () => {
149
+ try {
150
+ await mkdir(dirname(this.file), { recursive: true });
151
+ await writeFile(temporary, `${JSON.stringify(document)}\n`);
152
+ await rename(temporary, this.file);
153
+ } catch {
154
+ // A list that could not be written costs the Paused and Disappeared
155
+ // rows of the next run, and nothing of this one.
156
+ }
157
+ });
135
158
  }
136
159
  }
137
160
 
@@ -1,4 +1,5 @@
1
- import { realpathSync, statSync } from "node:fs";
1
+ import type { Stats } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
2
3
  import { basename, dirname, isAbsolute, join } from "node:path";
3
4
  import {
4
5
  type AgentInfo,
@@ -243,6 +244,13 @@ export class Sessions implements UpstreamResource {
243
244
  this.#reclaim(this.#live);
244
245
  }
245
246
 
247
+ /** Settle the list of lost sessions on disk. The writes happen as sessions
248
+ * come and go (DR-0015); this is for whoever has to see the file as it stands
249
+ * rather than as it was a moment ago — a stop, or a reader of the file. */
250
+ async flush(): Promise<void> {
251
+ await this.#lastLive.flush();
252
+ }
253
+
246
254
  /** Drop the `last_live` entry of every session that is live, which is what
247
255
  * keeps one session off both lists.
248
256
  *
@@ -258,10 +266,10 @@ export class Sessions implements UpstreamResource {
258
266
  * can speak about, and where everything this instance knows about where that
259
267
  * session lives comes from. The greeting names its sid because the op it
260
268
  * arrived under is the one whose schema asks for one. */
261
- helloSession = (input: HandlerInput): HelloResult => {
269
+ helloSession = async (input: HandlerInput): Promise<HelloResult> => {
262
270
  const args = input.args as unknown as HelloSessionArgs;
263
271
  this.#greetable(input, args.protocol_version);
264
- this.register(args.sid, args);
272
+ await this.register(args.sid, args);
265
273
  input.conn.onClose(() => this.release(args.sid));
266
274
  return this.#greeted(input);
267
275
  };
@@ -609,15 +617,15 @@ export class Sessions implements UpstreamResource {
609
617
  * silence for a retraction would let each of them erase what the last one
610
618
  * knew, and the session would be described by whichever process spoke most
611
619
  * recently rather than by everything it has said. */
612
- private register(sid: Sid, args: HelloSessionArgs): void {
613
- const now = Date.now();
620
+ private async register(sid: Sid, args: HelloSessionArgs): Promise<void> {
621
+ const stated = await metaOf(this.deps, args, (refused) => {
622
+ this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
623
+ });
624
+ // Read after the path has been settled, so that what this writes is built
625
+ // on the session as it stands now rather than as it stood before.
614
626
  const held = this.#connected.get(sid);
615
- const meta = {
616
- ...this.#stated.get(sid),
617
- ...metaOf(this.deps, args, (refused) => {
618
- this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
619
- }),
620
- };
627
+ const now = Date.now();
628
+ const meta = { ...this.#stated.get(sid), ...stated };
621
629
  this.#connected.set(sid, {
622
630
  sid,
623
631
  connected_at: held?.connected_at ?? now,
@@ -841,17 +849,17 @@ export class Sessions implements UpstreamResource {
841
849
  * simply does not act on a description it cannot stand behind. Why a path was
842
850
  * not taken is told to `refused`, which is the operator's answer to a field
843
851
  * that is simply absent from what `peers` says. */
844
- function metaOf(
852
+ async function metaOf(
845
853
  deps: Pick<SessionsDeps, "configHome" | "harness">,
846
854
  args: HelloSessionArgs,
847
855
  refused: (reason: string) => void,
848
- ): SessionMeta {
856
+ ): Promise<SessionMeta> {
849
857
  const meta: Record<string, string> = {};
850
858
  for (const field of META_FIELDS) {
851
859
  const value = args[field];
852
860
  if (value === undefined) continue;
853
861
  if (field === "transcript_path") {
854
- const taken = ownTranscript(value, deps);
862
+ const taken = await ownTranscript(value, deps);
855
863
  if (typeof taken === "string") meta[field] = taken;
856
864
  else refused(taken.refused);
857
865
  continue;
@@ -884,27 +892,36 @@ function metaOf(
884
892
  * comparison is between two paths resolved by one rule. The config home is not
885
893
  * treated that way — an instance answers for a home it is running out of, and
886
894
  * one that is not there names no tree to be inside of. */
887
- function ownTranscript(
895
+ async function ownTranscript(
888
896
  named: string,
889
897
  deps: Pick<SessionsDeps, "configHome" | "harness">,
890
- ): string | Refused {
898
+ ): Promise<string | Refused> {
891
899
  if (!isAbsolute(named)) return { refused: "not an absolute path" };
892
900
  let tree: string | undefined;
893
901
  try {
894
- const home = realpathSync(deps.configHome);
895
- tree = resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
902
+ const home = await realpath(deps.configHome);
903
+ tree = await resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
896
904
  } catch {
897
905
  return { refused: "the config home is not there" };
898
906
  }
899
- const settled = resolveAsFarAsItGoes(named);
907
+ const settled = await resolveAsFarAsItGoes(named);
900
908
  if (tree === undefined || settled === undefined || !within(settled, tree)) {
901
909
  return { refused: "outside this config home's transcript tree" };
902
910
  }
903
- const stat = statSync(settled, { throwIfNoEntry: false });
904
- if (stat !== undefined && !stat.isFile()) return { refused: "not a file" };
911
+ const known = await stated(settled);
912
+ if (known !== undefined && !known.isFile()) return { refused: "not a file" };
905
913
  return settled;
906
914
  }
907
915
 
916
+ /** What is at a path, or nothing where there is nothing at it. */
917
+ async function stated(path: string): Promise<Stats | undefined> {
918
+ try {
919
+ return await stat(path);
920
+ } catch {
921
+ return undefined;
922
+ }
923
+ }
924
+
908
925
  /** Why a stated path was not taken, in the words the log states it in. */
909
926
  interface Refused {
910
927
  readonly refused: string;
@@ -917,12 +934,12 @@ interface Refused {
917
934
  * kept as it was spelled. The result is compared against the tree as a whole,
918
935
  * which is what makes a `..` among the unwritten segments land wherever it
919
936
  * actually points rather than pass for being spelled inside. */
920
- function resolveAsFarAsItGoes(path: string): string | undefined {
937
+ async function resolveAsFarAsItGoes(path: string): Promise<string | undefined> {
921
938
  const unwritten: string[] = [];
922
939
  let at = path;
923
940
  for (;;) {
924
941
  try {
925
- return join(realpathSync(at), ...unwritten);
942
+ return join(await realpath(at), ...unwritten);
926
943
  } catch {
927
944
  const parent = dirname(at);
928
945
  // The root itself always resolves, so this is a path that named
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { parse, sep } from "node:path";
3
3
  import type {
4
4
  InstanceId,
@@ -9,6 +9,7 @@ import type {
9
9
  } from "@ccmsg/protocol";
10
10
  import { OpError } from "../dispatch/index.ts";
11
11
  import { readRecord, type TranscriptFile, type TranscriptFiles } from "../transcript/index.ts";
12
+ import { breathe, due } from "../transcript/scan.ts";
12
13
 
13
14
  /** What one search may read, and what it may answer with.
14
15
  *
@@ -57,7 +58,10 @@ export interface SearchDeps {
57
58
  * directory listing and a `stat` already say — the session id, the working
58
59
  * directory as the project directory spells it, when the file was last touched
59
60
  * — and only what survives that is read. */
60
- export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearchResult {
61
+ export async function search(
62
+ args: SessionSearchArgs,
63
+ deps: SearchDeps,
64
+ ): Promise<SessionSearchResult> {
61
65
  if ((args.config_dirs ?? [deps.configHome]).every((dir) => dir !== deps.configHome)) {
62
66
  // Every config home the caller named is one this instance does not know,
63
67
  // which the contract says to ignore — leaving nothing to search.
@@ -72,7 +76,7 @@ export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearch
72
76
  const hits: SessionSearchHit[] = [];
73
77
  let budget = SCAN_BUDGET_BYTES;
74
78
  let truncated = false;
75
- for (const candidate of deps.files.all()) {
79
+ for (const candidate of await deps.files.all()) {
76
80
  if (sid !== undefined && !candidate.sid.toLowerCase().includes(sid)) continue;
77
81
  if (candidate.updated_at < since) continue;
78
82
  if (!looksLike(candidate.project, cwdWords)) continue;
@@ -84,7 +88,7 @@ export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearch
84
88
  break;
85
89
  }
86
90
  budget -= candidate.size;
87
- const hit = read(candidate, clauses, wanted, deps);
91
+ const hit = await read(candidate, clauses, wanted, deps);
88
92
  // The working directory the project directory only approximates: a hit is
89
93
  // kept when the transcript's own `cwd` holds every word asked for.
90
94
  if (hit !== undefined && holds(hit.cwd, cwdWords)) hits.push(hit);
@@ -169,15 +173,15 @@ function compile(args: SessionSearchArgs): { clauses: Clause[]; budgets: Budget[
169
173
  * The pass is one: the records that carry the query also carry the working
170
174
  * directory, the title and what the session last ran as, so a hit is built
171
175
  * from the reading that decided it rather than from a second one. */
172
- function read(
176
+ async function read(
173
177
  candidate: TranscriptFile,
174
178
  clauses: readonly Clause[],
175
179
  wanted: { user: boolean; agent: boolean },
176
180
  deps: SearchDeps,
177
- ): SessionSearchHit | undefined {
181
+ ): Promise<SessionSearchHit | undefined> {
178
182
  let text: string;
179
183
  try {
180
- text = readFileSync(candidate.file, "utf8");
184
+ text = await readFile(candidate.file, "utf8");
181
185
  } catch {
182
186
  // Gone since it was listed, which is a session that ended mid-search.
183
187
  return undefined;
@@ -188,8 +192,13 @@ function read(
188
192
  let model: string | undefined;
189
193
  let effort: string | undefined;
190
194
  let createdAt: number | undefined;
195
+ let read = 0;
191
196
  for (const line of text.split("\n")) {
192
197
  if (line === "") continue;
198
+ read += 1;
199
+ // The file arrived in one `await` and reading it is CPU from here on, so
200
+ // the pass hands the loop back as it goes rather than at the file's end.
201
+ if (due(read)) await breathe();
193
202
  const record = readRecord(line);
194
203
  if (record === undefined) continue;
195
204
  cwd ??= record.cwd;
@@ -5,7 +5,7 @@ import type {
5
5
  SessionStatusSnapshot,
6
6
  Sid,
7
7
  } from "@ccmsg/protocol";
8
- import { canonical, within } from "../files/containment.ts";
8
+ import { canonicalSync, within } from "../files/containment.ts";
9
9
  import type { TopicValue, UpstreamResource } from "../topics/index.ts";
10
10
  import { topicParam } from "../topics/index.ts";
11
11
  import type { TranscriptFacts } from "../transcript/index.ts";
@@ -43,7 +43,7 @@ export function sessionStatusOf(
43
43
  sid: Sid;
44
44
  } {
45
45
  const stopped = stoppedOn(facts);
46
- const root = where.root === undefined ? undefined : canonical(where.root);
46
+ const root = where.root === undefined ? undefined : canonicalSync(where.root);
47
47
  return {
48
48
  sid,
49
49
  todos: [...facts.todos],
@@ -54,7 +54,7 @@ export function sessionStatusOf(
54
54
  external_files:
55
55
  root === undefined
56
56
  ? []
57
- : facts.named_files.filter((file) => !within(canonical(file.path), root)),
57
+ : facts.named_files.filter((file) => !within(canonicalSync(file.path), root)),
58
58
  workspace_folders: workspaceFolders(where.cwd),
59
59
  ...(stopped === undefined ? {} : { api_error: stopped }),
60
60
  };
@@ -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
+ }