@ccmsg/cli 0.14.0 → 0.15.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.
@@ -0,0 +1,208 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, rename, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { ClassificationState, Item } from "./items/index.ts";
5
+ import type { FoldState } from "./fold.ts";
6
+
7
+ /** Which shape of folded state this build can read back.
8
+ *
9
+ * A cache entry is the answer a past run derived, taken on trust: nothing
10
+ * re-reads the bytes it was derived from. So it may only be read back by a
11
+ * build that would have derived the same answer from them, and the version is
12
+ * how that is asserted — raise it whenever what the fold keeps or how it reads
13
+ * a record changes, and every entry written before says nothing to this build.
14
+ *
15
+ * `test/transcript.test.ts` holds the digest of the sources this number stands
16
+ * for and fails when they move without it, so the assertion is checked rather
17
+ * than remembered. */
18
+ export const FOLD_CACHE_VERSION = 2;
19
+
20
+ /** What one session's fold had reached, as it is written down.
21
+ *
22
+ * The offset is what makes the rest of it usable: it says which bytes the
23
+ * state accounts for, so the next run reads from there instead of from the
24
+ * beginning. The file's identity is beside it because the offset counts bytes
25
+ * of one file — a transcript replaced by another of the same name has bytes
26
+ * this state describes none of. */
27
+ export interface FoldCacheEntry {
28
+ readonly version: number;
29
+ readonly path: string;
30
+ readonly dev: number;
31
+ readonly ino: number;
32
+ /** Just past the last record the state accounts for. */
33
+ readonly offset: number;
34
+ readonly fold: FoldState;
35
+ /** What the reading that produced the items would carry into the next record:
36
+ * the turn it had reached, whose file it decided this is, and the calls still
37
+ * waiting for an answer. Without it a resumed run would answer a record
38
+ * differently from the run that read everything before it — counting turns
39
+ * from zero again, and calling a result whose call is known here the reserved
40
+ * name for a call nobody saw. */
41
+ readonly reading: ClassificationState;
42
+ /** The end of the reading, which is what a subscription opens on. Kept with
43
+ * the fold because both are derived from the same pass, and a resumed run
44
+ * that held only the fold would open the items topic on an empty list while
45
+ * claiming to know the session. */
46
+ readonly items: readonly Item[];
47
+ }
48
+
49
+ /** Where folded transcripts are kept between runs.
50
+ *
51
+ * Everything here can be derived again from the transcript it came from, so a
52
+ * miss, a discarded entry and an emptied directory are all the same thing: the
53
+ * file is read from its beginning. Nothing asks whether a write succeeded for
54
+ * that reason — a cache that could not be written costs the next run one read.
55
+ *
56
+ * Writes are chained rather than overlapped, so two saves of the same session
57
+ * cannot race over the temporary file they rename from. */
58
+ export class FoldCache {
59
+ #writing: Promise<void> = Promise.resolve();
60
+
61
+ constructor(private readonly dir: string) {}
62
+
63
+ /** What was folded out of this file, or nothing when what is on disk does not
64
+ * describe the file as it stands. */
65
+ async read(path: string): Promise<FoldCacheEntry | undefined> {
66
+ let entry: FoldCacheEntry;
67
+ try {
68
+ entry = JSON.parse(await Bun.file(this.#fileFor(path)).text()) as FoldCacheEntry;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ // The shape is checked and not assumed. An entry whose version matches but
73
+ // whose fields are not what this build reads back would otherwise be taken
74
+ // apart by whoever reads it, and a reading that throws leaves the session
75
+ // unopenable until the instance restarts — where a file that says nothing
76
+ // this build can use costs one reading (DR-0015 §2.5: what an await brings
77
+ // back is an input, not a promise kept).
78
+ if (!describes(entry, path)) return undefined;
79
+ let known: Awaited<ReturnType<typeof stat>>;
80
+ try {
81
+ known = await stat(path);
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ // A different file under the same name, or the same file grown shorter than
86
+ // the bytes the state accounts for: either way the state describes bytes
87
+ // that are not there, and the file is read from its beginning.
88
+ if (known.dev !== entry.dev || known.ino !== entry.ino) return undefined;
89
+ if (known.size < entry.offset) return undefined;
90
+ return entry;
91
+ }
92
+
93
+ /** Keep what has been folded so far. The file's identity is read here rather
94
+ * than taken from the caller, so what is written describes the file the
95
+ * offset was actually counted in. */
96
+ save(
97
+ path: string,
98
+ offset: number,
99
+ fold: FoldState,
100
+ reading: ClassificationState,
101
+ items: readonly Item[],
102
+ ): Promise<void> {
103
+ // The items are taken now rather than when the write runs: the offset and
104
+ // the fold describe this moment, and a list still being appended to would
105
+ // put records past the offset into an entry that claims to end at it.
106
+ const held = [...items];
107
+ this.#writing = this.#writing.then(() => this.#write(path, offset, fold, reading, held));
108
+ return this.#writing;
109
+ }
110
+
111
+ /** Forget what was folded out of this file, for a transcript that turned out
112
+ * to be another one. */
113
+ drop(path: string): Promise<void> {
114
+ this.#writing = this.#writing.then(async () => {
115
+ await unlink(this.#fileFor(path)).catch(() => undefined);
116
+ });
117
+ return this.#writing;
118
+ }
119
+
120
+ async #write(
121
+ path: string,
122
+ offset: number,
123
+ fold: FoldState,
124
+ reading: ClassificationState,
125
+ items: readonly Item[],
126
+ ): Promise<void> {
127
+ let known: Awaited<ReturnType<typeof stat>>;
128
+ try {
129
+ known = await stat(path);
130
+ } catch {
131
+ return;
132
+ }
133
+ const entry: FoldCacheEntry = {
134
+ version: FOLD_CACHE_VERSION,
135
+ path,
136
+ dev: known.dev,
137
+ ino: known.ino,
138
+ offset,
139
+ fold,
140
+ reading,
141
+ items: [...items],
142
+ };
143
+ const file = this.#fileFor(path);
144
+ const temporary = `${file}.${process.pid}.tmp`;
145
+ try {
146
+ await mkdir(this.dir, { recursive: true });
147
+ await writeFile(temporary, JSON.stringify(entry));
148
+ await rename(temporary, file);
149
+ } catch {
150
+ await unlink(temporary).catch(() => undefined);
151
+ }
152
+ }
153
+
154
+ /** One file per transcript, named by a digest of its path: the path is what
155
+ * identifies the transcript, and a digest of it is a name every filesystem
156
+ * takes however the path was spelled. */
157
+ #fileFor(path: string): string {
158
+ return join(this.dir, `${createHash("sha256").update(path).digest("hex").slice(0, 32)}.json`);
159
+ }
160
+ }
161
+
162
+ /** Whether what was read back is an entry this build can take up.
163
+ *
164
+ * Everything the fold and the reading are restored from is checked, because
165
+ * restoring walks it: a field of the wrong shape is a file that says nothing
166
+ * this build can use, which is the same as no file at all. What is not checked
167
+ * is what nothing walks — the contents of an item, of a call's arguments, of a
168
+ * todo — since those are carried whole and stated as they were written. */
169
+ function describes(entry: unknown, path: string): entry is FoldCacheEntry {
170
+ if (!isObject(entry)) return false;
171
+ if (entry["version"] !== FOLD_CACHE_VERSION || entry["path"] !== path) return false;
172
+ if (!counted(entry["offset"]) || !counted(entry["dev"]) || !counted(entry["ino"])) return false;
173
+ return states(entry["fold"]) && reads(entry["reading"]) && Array.isArray(entry["items"]);
174
+ }
175
+
176
+ function states(fold: unknown): boolean {
177
+ if (!isObject(fold)) return false;
178
+ for (const key of ["files", "todos", "teammates", "background", "workflows", "agents", "calls"]) {
179
+ const held = fold[key];
180
+ if (!Array.isArray(held)) return false;
181
+ for (const pair of held) {
182
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") return false;
183
+ }
184
+ }
185
+ return true;
186
+ }
187
+
188
+ function reads(reading: unknown): boolean {
189
+ if (!isObject(reading)) return false;
190
+ if (!counted(reading["turn"]) || typeof reading["subject"] !== "string") return false;
191
+ if (!Array.isArray(reading["calls"])) return false;
192
+ for (const pair of reading["calls"]) {
193
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") return false;
194
+ const call: unknown = pair[1];
195
+ if (!isObject(call) || typeof call["tool"] !== "string" || typeof call["name"] !== "string") {
196
+ return false;
197
+ }
198
+ }
199
+ return true;
200
+ }
201
+
202
+ function isObject(value: unknown): value is Record<string, unknown> {
203
+ return typeof value === "object" && value !== null && !Array.isArray(value);
204
+ }
205
+
206
+ function counted(value: unknown): value is number {
207
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
208
+ }
@@ -1,4 +1,4 @@
1
- import { readdirSync, type Stats, statSync } from "node:fs";
1
+ import type { Stats } from "node:fs";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import type { Sid, TranscriptSubject } from "@ccmsg/protocol";
@@ -95,16 +95,16 @@ export class TranscriptFiles {
95
95
  * greeted or is no longer running. Both stay inside this harness's own
96
96
  * transcript tree — the announced path because it was taken only if it was
97
97
  * inside it, the walk because that tree is what it walks (M6). */
98
- path(sid: Sid): string | undefined {
98
+ async path(sid: Sid): Promise<string | undefined> {
99
99
  const announced = this.deps.announced(sid);
100
- if (announced !== undefined && isFile(announced)) return announced;
101
- return this.find(sid);
100
+ if (announced !== undefined && (await stated(announced)) !== undefined) return announced;
101
+ return await this.find(sid);
102
102
  }
103
103
 
104
104
  /** The session's own transcript, for an op that has nothing to answer
105
105
  * without one. */
106
- session(sid: Sid): string {
107
- const found = this.path(sid);
106
+ async session(sid: Sid): Promise<string> {
107
+ const found = await this.path(sid);
108
108
  if (found === undefined) throw new OpError("not_found", `no transcript is held for ${sid}`);
109
109
  return found;
110
110
  }
@@ -116,7 +116,7 @@ export class TranscriptFiles {
116
116
  * cannot be combined — a request carrying both names two files and is a
117
117
  * caller's mistake rather than a choice this makes for them. */
118
118
  async locate(sid: Sid, names: AgentNames = {}): Promise<string> {
119
- const file = this.session(sid);
119
+ const file = await this.session(sid);
120
120
  if (names.agent_id !== undefined && names.teammate !== undefined) {
121
121
  throw new OpError("invalid_args", "agent_id and teammate name two different transcripts");
122
122
  }
@@ -238,21 +238,23 @@ export class TranscriptFiles {
238
238
  * which is one `stat` per directory instead of a listing; one whose name
239
239
  * carries more than the sid is walked, because the rest of the name is
240
240
  * exactly what this does not know. */
241
- private find(sid: Sid): string | undefined {
241
+ private async find(sid: Sid): Promise<string | undefined> {
242
242
  const layout = LAYOUTS[this.deps.harness];
243
- const dirs = directories(this.#root(), layout.depth);
243
+ const dirs = await walked(this.#root(), layout.depth);
244
244
  const nameOf = layout.nameOf;
245
245
  if (nameOf !== undefined) {
246
246
  if (!SID.test(sid)) return undefined;
247
247
  for (const dir of dirs) {
248
248
  const file = join(dir, nameOf(sid));
249
- if (isFile(file)) return file;
249
+ if ((await stated(file)) !== undefined) return file;
250
250
  }
251
251
  return undefined;
252
252
  }
253
253
  for (const dir of dirs) {
254
- for (const entry of names(dir)) {
255
- if (layout.sidOf(entry) === sid && isFile(join(dir, entry))) return join(dir, entry);
254
+ for (const entry of await listed(dir)) {
255
+ if (layout.sidOf(entry) !== sid) continue;
256
+ const file = join(dir, entry);
257
+ if ((await stated(file)) !== undefined) return file;
256
258
  }
257
259
  }
258
260
  return undefined;
@@ -265,13 +267,9 @@ export class TranscriptFiles {
265
267
  * wrote, and a tree with a directory nobody expected is one whose files are
266
268
  * still found.
267
269
  *
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. */
270
+ * One walk for every caller: enumerating the tree and finding one session's
271
+ * file in it are the same reading, and a subscription that opens on it waits
272
+ * for it like any other caller (CT-Q8). */
275
273
  async function walked(root: string, depth: number): Promise<string[]> {
276
274
  let level = [root];
277
275
  for (let step = 0; step < depth; step += 1) {
@@ -284,14 +282,6 @@ async function walked(root: string, depth: number): Promise<string[]> {
284
282
  return level;
285
283
  }
286
284
 
287
- function directories(root: string, depth: number): string[] {
288
- let level = [root];
289
- for (let step = 0; step < depth; step += 1) {
290
- level = level.flatMap((dir) => names(dir).map((entry) => join(dir, entry)));
291
- }
292
- return level;
293
- }
294
-
295
285
  export interface AgentNames {
296
286
  readonly agent_id?: string;
297
287
  readonly run_id?: string;
@@ -343,14 +333,6 @@ async function listed(dir: string): Promise<string[]> {
343
333
  }
344
334
  }
345
335
 
346
- function names(dir: string): string[] {
347
- try {
348
- return readdirSync(dir);
349
- } catch {
350
- return [];
351
- }
352
- }
353
-
354
336
  async function stated(file: string): Promise<Stats | undefined> {
355
337
  try {
356
338
  const known = await stat(file);
@@ -359,16 +341,3 @@ async function stated(file: string): Promise<Stats | undefined> {
359
341
  return undefined;
360
342
  }
361
343
  }
362
-
363
- function statOf(file: string) {
364
- try {
365
- const known = statSync(file);
366
- return known.isFile() ? known : undefined;
367
- } catch {
368
- return undefined;
369
- }
370
- }
371
-
372
- function isFile(file: string): boolean {
373
- return statOf(file) !== undefined;
374
- }
@@ -61,6 +61,29 @@ export const NO_FACTS: TranscriptFacts = {
61
61
  agent_tree: { teammates: [], agents: [], workflows: [] },
62
62
  };
63
63
 
64
+ /** Everything the fold carries from one record to the next, as data.
65
+ *
66
+ * The facts are what a consumer reads; this is what reading further needs. The
67
+ * two are not the same — a todo is held under the key a later record updates it
68
+ * by, a call is held until its result arrives — so a fold resumed from its
69
+ * facts alone would answer the next record differently from one that had read
70
+ * every record before it. What is kept here is the whole of that difference,
71
+ * which is what makes resuming from it the same reading rather than a similar
72
+ * one. */
73
+ export interface FoldState {
74
+ readonly api_error?: SessionApiError;
75
+ readonly last_user_input_at?: Timestamp;
76
+ readonly model?: string;
77
+ readonly effort?: string;
78
+ readonly files: readonly (readonly [string, ExternalFile])[];
79
+ readonly todos: readonly (readonly [string, SessionTodo])[];
80
+ readonly teammates: readonly (readonly [string, Teammate])[];
81
+ readonly background: readonly (readonly [string, SessionBackgroundStatus])[];
82
+ readonly workflows: readonly (readonly [string, SessionWorkflowStatus])[];
83
+ readonly agents: readonly (readonly [string, AgentTreeNode])[];
84
+ readonly calls: readonly (readonly [string, PendingCall])[];
85
+ }
86
+
64
87
  /** The one place a transcript line is interpreted.
65
88
  *
66
89
  * Nothing outside this module parses a transcript record. A line arrives, the
@@ -104,6 +127,43 @@ export class TranscriptFold {
104
127
  };
105
128
  }
106
129
 
130
+ /** Everything the reading carries forward, so that it can be taken up again
131
+ * where it stopped. */
132
+ get held(): FoldState {
133
+ return {
134
+ ...(this.#apiError === undefined ? {} : { api_error: this.#apiError }),
135
+ ...(this.#lastUserInputAt === undefined ? {} : { last_user_input_at: this.#lastUserInputAt }),
136
+ ...(this.#model === undefined ? {} : { model: this.#model }),
137
+ ...(this.#effort === undefined ? {} : { effort: this.#effort }),
138
+ files: [...this.#files],
139
+ todos: [...this.#todos],
140
+ teammates: [...this.#teammates],
141
+ background: [...this.#background],
142
+ workflows: [...this.#workflows],
143
+ agents: [...this.#agents],
144
+ calls: [...this.#calls],
145
+ };
146
+ }
147
+
148
+ /** Take up a reading somebody else left off, replacing whatever this fold
149
+ * held. The state is trusted as the record of records already read — what
150
+ * makes it trustworthy is decided where it is kept (`FOLD_CACHE_VERSION`),
151
+ * not here. */
152
+ restore(state: FoldState): void {
153
+ this.reset();
154
+ this.#apiError = state.api_error;
155
+ this.#lastUserInputAt = state.last_user_input_at;
156
+ this.#model = state.model;
157
+ this.#effort = state.effort;
158
+ for (const [key, value] of state.files) this.#files.set(key, value);
159
+ for (const [key, value] of state.todos) this.#todos.set(key, value);
160
+ for (const [key, value] of state.teammates) this.#teammates.set(key, value);
161
+ for (const [key, value] of state.background) this.#background.set(key, { ...value });
162
+ for (const [key, value] of state.workflows) this.#workflows.set(key, { ...value });
163
+ for (const [key, value] of state.agents) this.#agents.set(key, { ...value });
164
+ for (const [key, value] of state.calls) this.#calls.set(key, value);
165
+ }
166
+
107
167
  reset(): void {
108
168
  this.#apiError = undefined;
109
169
  this.#lastUserInputAt = undefined;
@@ -648,13 +708,13 @@ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
648
708
 
649
709
  /** A teammate as the fold holds it: what the contract states about it, and the
650
710
  * two facts only the tree needs. */
651
- interface Teammate {
711
+ export interface Teammate {
652
712
  readonly status: Mutable<SessionTeammate>;
653
713
  agent_id?: string;
654
714
  team_name?: string;
655
715
  }
656
716
 
657
- interface PendingCall {
717
+ export interface PendingCall {
658
718
  readonly name: string;
659
719
  readonly input: Record<string, unknown>;
660
720
  readonly at?: Timestamp;
@@ -4,7 +4,9 @@ export {
4
4
  TranscriptFiles,
5
5
  type TranscriptFilesDeps,
6
6
  } from "./files.ts";
7
+ export { FOLD_CACHE_VERSION, FoldCache, type FoldCacheEntry } from "./cache.ts";
7
8
  export {
9
+ type FoldState,
8
10
  NO_FACTS,
9
11
  readRecord,
10
12
  type TranscriptFacts,
@@ -12,5 +14,5 @@ export {
12
14
  type TranscriptRecord,
13
15
  } from "./fold.ts";
14
16
  export { READ_LIMIT, readSlice } from "./read.ts";
15
- export { type Appended, FOLD_TAIL_BYTES, TranscriptTail } from "./tail.ts";
17
+ export { type Appended, READ_CHUNK_BYTES, TranscriptTail } from "./tail.ts";
16
18
  export { ITEMS_SNAPSHOT, Transcripts, type TranscriptsDeps } from "./transcripts.ts";
@@ -97,6 +97,27 @@ type Draft = Record<string, unknown> & {
97
97
  at: number;
98
98
  };
99
99
 
100
+ /** Everything a reading carries from one record to the next, as data.
101
+ *
102
+ * The items already handed over are not here — a reader holds those — but what
103
+ * decides how the next record reads is: which turn the file is in, which slash
104
+ * command output belongs to, whose file it is, and the calls still waiting for
105
+ * an answer. A reading taken up from this answers the next record exactly as
106
+ * the reading that produced it would have.
107
+ *
108
+ * The outstanding calls name their items by id rather than carrying them: the
109
+ * items are what a reader already holds, and naming them is what lets a result
110
+ * point back at the call in the very list the reader has. */
111
+ export interface ClassificationState {
112
+ readonly turn: number;
113
+ readonly slash?: string;
114
+ readonly subject: TranscriptSubject;
115
+ readonly calls: readonly (readonly [
116
+ string,
117
+ { readonly tool: string; readonly message?: string; readonly name: string },
118
+ ])[];
119
+ }
120
+
100
121
  /** A whole transcript read as items, in the order the file holds them.
101
122
  *
102
123
  * The file is read through once and the links are filled in as the answers
@@ -144,6 +165,62 @@ export class Classification {
144
165
  this.#subject = subject;
145
166
  }
146
167
 
168
+ /** What this reading would carry into the next record. */
169
+ get held(): ClassificationState {
170
+ const calls: ClassificationState["calls"] = [...this.#calls].map(([id, call]) => [
171
+ id,
172
+ {
173
+ tool: call.tool.id,
174
+ name: call.name,
175
+ ...optional("message", call.message?.id),
176
+ },
177
+ ]);
178
+ return {
179
+ turn: this.#turn,
180
+ subject: this.#subject,
181
+ calls,
182
+ ...optional("slash", this.#slash),
183
+ };
184
+ }
185
+
186
+ /** Take up a reading somebody else left off.
187
+ *
188
+ * `known` is the items that reading handed over and this one still holds, so
189
+ * an outstanding call is matched back to the item a reader has rather than to
190
+ * a copy of it: what the answer writes into the call then lands where the
191
+ * reader will look for it. A call whose item the reader no longer holds is
192
+ * kept by name and id alone — the result still says which tool answered and
193
+ * which item it belongs to, and there is no item left to write into. */
194
+ restore(state: ClassificationState, known: Iterable<Item>): void {
195
+ this.#items = [];
196
+ this.#calls.clear();
197
+ this.#turn = state.turn;
198
+ this.#subject = state.subject;
199
+ this.#slash = state.slash;
200
+ const held = new Map<string, Draft>();
201
+ for (const item of known) {
202
+ const draft = item as unknown as Draft;
203
+ if (typeof draft.id === "string") held.set(draft.id, draft);
204
+ }
205
+ const detached = (id: string): Draft =>
206
+ held.get(id) ??
207
+ ({
208
+ id,
209
+ uuid: "",
210
+ subject: this.#subject,
211
+ source: { offset: 0, bytes: 0 },
212
+ type: "",
213
+ at: 0,
214
+ } as Draft);
215
+ for (const [id, call] of state.calls) {
216
+ this.#calls.set(id, {
217
+ tool: detached(call.tool),
218
+ name: call.name,
219
+ ...optional("message", call.message === undefined ? undefined : detached(call.message)),
220
+ });
221
+ }
222
+ }
223
+
147
224
  /** The records of one chunk as the items they were read as, oldest first.
148
225
  *
149
226
  * What comes back is this chunk's items alone. A call the chunk before it
@@ -1,4 +1,4 @@
1
- export { classify, Classification } from "./classify.ts";
1
+ export { classify, Classification, type ClassificationState } from "./classify.ts";
2
2
  export { document, type DumpView } from "./document.ts";
3
3
  export { fields, type Item } from "./item.ts";
4
4
  export { ledger } from "./ids.ts";