@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.
- package/package.json +1 -1
- package/src/files/containment.ts +6 -23
- package/src/instance/instance.ts +12 -5
- package/src/instance/paths.ts +6 -0
- package/src/messaging/inbox.ts +35 -3
- package/src/sessions/fork.ts +1 -1
- package/src/sessions/harness.ts +5 -5
- package/src/sessions/status.ts +56 -20
- package/src/sessions/workspace.ts +15 -15
- package/src/topics/handlers.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/cache.ts +208 -0
- package/src/transcript/files.ts +17 -48
- package/src/transcript/fold.ts +62 -2
- package/src/transcript/index.ts +3 -1
- package/src/transcript/items/classify.ts +77 -0
- package/src/transcript/items/index.ts +1 -1
- package/src/transcript/tail.ts +68 -87
- package/src/transcript/transcripts.ts +134 -51
|
@@ -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
|
+
}
|
package/src/transcript/files.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 &&
|
|
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 =
|
|
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 (
|
|
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
|
|
255
|
-
if (layout.sidOf(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
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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
|
-
}
|
package/src/transcript/fold.ts
CHANGED
|
@@ -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;
|
package/src/transcript/index.ts
CHANGED
|
@@ -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,
|
|
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";
|