@ccmsg/cli 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.12.0"
23
+ "@ccmsg/protocol": "1.13.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
@@ -537,6 +537,9 @@ export class Instance {
537
537
  this.#topics.attach("inbox", this.#delivery);
538
538
  this.#topics.attach("notify", this.#notify);
539
539
  this.#topics.attach("transcript", this.#transcripts);
540
+ // One tail feeds both: the bytes as they are appended, and what those
541
+ // bytes were read as.
542
+ this.#topics.attach("transcript_items", this.#transcripts);
540
543
  this.#topics.attach("session_status", this.#status);
541
544
  this.#topics.attach("session_errors", this.#status);
542
545
  this.#topics.attach("llm_requests", this.#gateway.requests);
@@ -8,7 +8,15 @@ import type {
8
8
  SessionDumpWriteResult,
9
9
  } from "@ccmsg/protocol";
10
10
  import { OpError } from "../dispatch/index.ts";
11
- import { classify, type Item, ledger, select, selection } from "../transcript/items/index.ts";
11
+ import {
12
+ bounded,
13
+ classify,
14
+ ledger,
15
+ located,
16
+ select,
17
+ selection,
18
+ within,
19
+ } from "../transcript/items/index.ts";
12
20
  import type { TranscriptFiles } from "../transcript/index.ts";
13
21
 
14
22
  /** Where dumps land: one directory under this instance's own state, named
@@ -43,12 +51,7 @@ export interface DumpDeps {
43
51
  * unchanged down a chain of agents, which is what makes the ledger's agent ids
44
52
  * a way to descend rather than just a list. */
45
53
  export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDumpWriteResult {
46
- if (args.since_at !== undefined && args.since_uuid !== undefined) {
47
- throw new OpError("invalid_args", "a lower bound is a time or a record, not both");
48
- }
49
- if (args.until_at !== undefined && args.until_uuid !== undefined) {
50
- throw new OpError("invalid_args", "an upper bound is a time or a record, not both");
51
- }
54
+ bounded(args);
52
55
  const preset = presetFor(args.preset, deps.presets);
53
56
  const file = deps.files.locate(
54
57
  args.sid,
@@ -72,7 +75,7 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
72
75
  },
73
76
  deps.presets,
74
77
  );
75
- const { items, entries } = select(within(classify(text.split("\n")), args), keep);
78
+ const { items, entries } = select(within(classify(located(text)), args), keep);
76
79
  const ids = ledger(items);
77
80
  const written_at = Date.now();
78
81
  // The file repeats what it was asked for. A dump outlives the request that
@@ -110,34 +113,3 @@ function presetFor(
110
113
  if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
111
114
  return found;
112
115
  }
113
-
114
- /** The items within the bounds, in the order the transcript holds them.
115
- *
116
- * A record bound cuts at that record's position rather than at its clock, so
117
- * records sharing an instant stay on their own side of the cut — which is the
118
- * whole reason the contract offers both kinds of bound. Every item a record
119
- * became carries that record's id, so a bound by record keeps a turn's
120
- * thinking, words and calls together. */
121
- function within(items: readonly Item[], args: SessionDumpWriteArgs): Item[] {
122
- const kept: Item[] = [];
123
- // A lower bound by record starts closed: it opens at the record it names,
124
- // which is included.
125
- let open = args.since_uuid === undefined;
126
- for (let at = 0; at < items.length; at += 1) {
127
- const item = items[at];
128
- if (item === undefined) continue;
129
- if (!open) {
130
- if (item.uuid !== args.since_uuid) continue;
131
- open = true;
132
- }
133
- if (args.since_at !== undefined && item.at < args.since_at) continue;
134
- if (args.until_at !== undefined && item.at > args.until_at) break;
135
- kept.push(item);
136
- // An upper bound by record is inclusive and cuts after the last item that
137
- // record became, so the rest of the same record is still let through.
138
- if (args.until_uuid !== undefined && item.uuid === args.until_uuid) {
139
- if (items[at + 1]?.uuid !== item.uuid) break;
140
- }
141
- }
142
- return kept;
143
- }
@@ -17,6 +17,8 @@ import {
17
17
  type SessionSearchArgs,
18
18
  type Sid,
19
19
  TITLE_MAX_CHARS,
20
+ type TranscriptItemsReadArgs,
21
+ type TranscriptItemsReadResult,
20
22
  type TranscriptReadArgs,
21
23
  type TranscriptReadResult,
22
24
  } from "@ccmsg/protocol";
@@ -25,6 +27,7 @@ import { sees, type Viewer } from "../files/index.ts";
25
27
  import { readSlice, type TranscriptFiles } from "../transcript/index.ts";
26
28
  import { dumpWrite } from "./dump.ts";
27
29
  import { forkOrigin } from "./fork.ts";
30
+ import { itemsRead } from "./items.ts";
28
31
  import type { SessionProcesses } from "./processes.ts";
29
32
  import { search } from "./search.ts";
30
33
 
@@ -144,6 +147,19 @@ export function sessionHandlers(deps: SessionOpsDeps) {
144
147
  const file = deps.files.locate(args.sid, args);
145
148
  return readSlice(args.sid, file, args.before, args.max_bytes);
146
149
  },
150
+
151
+ /** The same transcript, as the items it was read into.
152
+ *
153
+ * The role narrows it the way it narrows the raw read: what a role may see
154
+ * is one rule whatever is being read, and a caller that cannot see a
155
+ * session cannot see it in either vocabulary. */
156
+ transcript_items_read: (input: HandlerInput): TranscriptItemsReadResult => {
157
+ const args = input.args as unknown as TranscriptItemsReadArgs;
158
+ if (!sees(args.sid, viewer(input))) {
159
+ throw new OpError("not_found", `no transcript is known for ${args.sid}`);
160
+ }
161
+ return itemsRead(args, { files: deps.files, presets: deps.presets });
162
+ },
147
163
  };
148
164
  }
149
165
 
@@ -0,0 +1,105 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type {
3
+ DumpPreset,
4
+ TranscriptItemsReadArgs,
5
+ TranscriptItemsReadResult,
6
+ } from "@ccmsg/protocol";
7
+ import { OpError } from "../dispatch/index.ts";
8
+ import type { TranscriptFiles } from "../transcript/index.ts";
9
+ import {
10
+ bounded,
11
+ classify,
12
+ type Item,
13
+ ledger,
14
+ located,
15
+ select,
16
+ selection,
17
+ within,
18
+ } from "../transcript/items/index.ts";
19
+ import { READ_LIMIT } from "../transcript/read.ts";
20
+
21
+ /** How many items one read may carry.
22
+ *
23
+ * The count a client draws in one go rather than a bound on the payload: an
24
+ * item is a sentence or a whole brief, so the size of a page is not something
25
+ * a count can state. The bytes below are what actually bounds it, and this is
26
+ * what keeps a page a page. */
27
+ export const ITEMS_LIMIT = 500;
28
+
29
+ /** The selector that asks for the ledger.
30
+ *
31
+ * The ledger is not an item type — an id says how to point at something, not
32
+ * what a line is — so asking for it is asking for a section rather than
33
+ * selecting a family. It is written in the same list because that is where a
34
+ * caller says what it wants out of a transcript. */
35
+ const IDS = "ids";
36
+
37
+ export interface ItemsReadDeps {
38
+ readonly files: TranscriptFiles;
39
+ /** The named selections this instance is configured with, which is what an
40
+ * `@name` inside a selection is resolved against. */
41
+ readonly presets: readonly DumpPreset[];
42
+ }
43
+
44
+ /** A slice of a transcript as the items it was read into.
45
+ *
46
+ * The raw read answers with the harness's own lines, which leaves the caller
47
+ * holding a private format; this answers with what those lines were classified
48
+ * as. Both stay: a client works in items and fetches the record behind one by
49
+ * the `source` that item carries.
50
+ *
51
+ * The whole file is classified before the range is applied, the way a dump's
52
+ * is. That is what makes a link answerable: a result inside the range whose
53
+ * call fell before it still names the call, and the caller can ask for the
54
+ * call by the id it was given. */
55
+ export function itemsRead(
56
+ args: TranscriptItemsReadArgs,
57
+ deps: ItemsReadDeps,
58
+ ): TranscriptItemsReadResult {
59
+ bounded(args);
60
+ const file = deps.files.locate(
61
+ args.sid,
62
+ args.agent_id === undefined ? {} : { agent_id: args.agent_id },
63
+ );
64
+ let text: string;
65
+ try {
66
+ text = readFileSync(file, "utf8");
67
+ } catch {
68
+ throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
69
+ }
70
+ const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
71
+ const { items } = select(within(classify(located(text)), args), keep);
72
+ const page = paged(items, args.limit);
73
+ return {
74
+ items: page.items,
75
+ ...(page.next === undefined ? {} : { next: page.next }),
76
+ ...(keep.keeps(IDS) ? { ids: ledger(page.items) } : {}),
77
+ };
78
+ }
79
+
80
+ /** As much of the range as one answer carries, and where the next one starts.
81
+ *
82
+ * Two bounds, because either alone leaves a case unanswered: a count cannot
83
+ * keep a page of long briefs inside what a connection should carry, and bytes
84
+ * alone would answer with a number of items that varied with what was said in
85
+ * them. Whichever is reached first ends the page, and the first item left out
86
+ * is named so the caller resumes exactly where this stopped. */
87
+ function paged(
88
+ items: readonly Item[],
89
+ limit: number | undefined,
90
+ ): { items: Item[]; next?: string } {
91
+ const most = Math.min(limit ?? ITEMS_LIMIT, ITEMS_LIMIT);
92
+ const kept: Item[] = [];
93
+ let held = 0;
94
+ for (const item of items) {
95
+ // A first item larger than the whole budget is still answered: a page of
96
+ // nothing would leave the caller resuming at the item it just failed to
97
+ // get, forever.
98
+ if (kept.length > 0 && (kept.length >= most || held >= READ_LIMIT)) {
99
+ return { items: kept, next: item.id };
100
+ }
101
+ kept.push(item);
102
+ held += JSON.stringify(item).length;
103
+ }
104
+ return { items: kept };
105
+ }
@@ -1,5 +1,18 @@
1
1
  import type { Item } from "./item.ts";
2
- import { count, instant, isRow, list, optional, type Row, row, str, tagged } from "./record.ts";
2
+ import {
3
+ count,
4
+ instant,
5
+ isRow,
6
+ list,
7
+ type Located,
8
+ located,
9
+ optional,
10
+ type Row,
11
+ row,
12
+ span,
13
+ str,
14
+ tagged,
15
+ } from "./record.ts";
3
16
  import { genericResult, resultFields, useFields } from "./tools.ts";
4
17
 
5
18
  /** Turning a harness's transcript into the items the contract names.
@@ -48,10 +61,21 @@ const NOT_ITEMS = new Set([
48
61
  * what comes back is also an answer. */
49
62
  const SPAWNS = new Set(["Agent", "Task"]);
50
63
 
64
+ /** How many calls awaiting an answer one reading holds. Reached only by calls
65
+ * that are never answered, since an answered one is let go where it is
66
+ * answered. */
67
+ const OUTSTANDING_CALLS = 4096;
68
+
51
69
  /** What an item is under construction: the contract's shape, before it is
52
70
  * settled. A call learns the id of what answered it only when the answer
53
71
  * arrives, which is why these are written to after they are made. */
54
- type Draft = Record<string, unknown> & { uuid: string; type: string; at: number };
72
+ type Draft = Record<string, unknown> & {
73
+ id: string;
74
+ uuid: string;
75
+ source: { offset: number; bytes: number };
76
+ type: string;
77
+ at: number;
78
+ };
55
79
 
56
80
  /** A whole transcript read as items, in the order the file holds them.
57
81
  *
@@ -60,23 +84,19 @@ type Draft = Record<string, unknown> & { uuid: string; type: string; at: number
60
84
  * apart the harness wrote them. Reading the whole file before any range is
61
85
  * applied is what makes `parent_item` answerable: a result inside the range
62
86
  * whose call fell before it still names the call. */
63
- export function classify(lines: Iterable<string>): Item[] {
87
+ export function classify(records: Iterable<Located>): Item[] {
64
88
  const state = new Classification();
65
- for (const line of lines) {
66
- if (line === "") continue;
67
- let parsed: unknown;
68
- try {
69
- parsed = JSON.parse(line);
70
- } catch {
71
- continue;
72
- }
73
- if (isRow(parsed)) state.read(parsed);
74
- }
75
- return state.items;
89
+ return state.readAll(records);
76
90
  }
77
91
 
78
- class Classification {
79
- readonly items: Draft[] = [];
92
+ /** A transcript read as items while it is still being written.
93
+ *
94
+ * The same reading as `classify`, kept open: a tail hands it what has just
95
+ * been appended and takes back the items those bytes were, with the calls made
96
+ * before them still known — which is what lets a result arriving now name the
97
+ * call it answers. */
98
+ export class Classification {
99
+ #items: Draft[] = [];
80
100
  /** The call each tool result belongs to, by the id the harness pairs them
81
101
  * with. Holds the `tool:*` item and, for an `Agent` call, the
82
102
  * `message:sub:out` beside it — the same exchange seen from the two sides
@@ -86,15 +106,54 @@ class Classification {
86
106
  /** The last slash command invoked, which is what its output belongs to. */
87
107
  #slash: string | undefined;
88
108
 
89
- read(record: Row): void {
109
+ /** The records of one chunk as the items they were read as, oldest first.
110
+ *
111
+ * What comes back is this chunk's items alone. A call the chunk before it
112
+ * held is still known and can still be pointed at, but it has already been
113
+ * handed over and is not handed over twice: whoever is reading a transcript
114
+ * as it grows holds a list it only appends to. */
115
+ readAll(records: Iterable<Located>): Item[] {
116
+ for (const { line, offset } of records) {
117
+ let parsed: unknown;
118
+ try {
119
+ parsed = JSON.parse(line);
120
+ } catch {
121
+ continue;
122
+ }
123
+ if (isRow(parsed)) this.read(parsed, { offset, bytes: span(line) });
124
+ }
125
+ const made = this.#items;
126
+ this.#items = [];
127
+ return made as unknown as Item[];
128
+ }
129
+
130
+ /** A whole chunk of text as items, where the chunk begins at `start`. */
131
+ readChunk(chunk: string, start = 0): Item[] {
132
+ return this.readAll(located(chunk, start));
133
+ }
134
+
135
+ read(record: Row, source: { offset: number; bytes: number }): void {
90
136
  const type = str(record["type"]);
91
137
  if (type === undefined || NOT_ITEMS.has(type)) return;
92
138
  const uuid = str(record["uuid"]) ?? "";
93
139
  if (uuid === "") return;
94
140
  const at = instant(record["timestamp"]);
141
+ // Where in its record an item stood. One record becomes the thinking, the
142
+ // words and each call of a turn, and a link that named only the record
143
+ // would name all of them at once.
144
+ let index = 0;
95
145
  const make = (kind: string, fields: Record<string, unknown> = {}): Draft => {
96
- const draft: Draft = { uuid, type: kind, at, turn: this.#turn, ...fields };
97
- this.items.push(draft);
146
+ const draft: Draft = {
147
+ id: `${uuid}:${String(index)}`,
148
+ uuid,
149
+ source,
150
+ type: kind,
151
+ at,
152
+ turn: this.#turn,
153
+ ...fields,
154
+ };
155
+ index += 1;
156
+ this.#items.push(draft);
98
157
  return draft;
99
158
  };
100
159
  if (type === "attachment") return this.#attachment(record, make);
@@ -205,12 +264,26 @@ class Classification {
205
264
  ...(addressed(to) ? {} : { role: "use" }),
206
265
  ...(addressed(to)
207
266
  ? { text: text(input["message"]) ?? "", to }
208
- : { prompt: text(input["message"]) ?? "", name: to }),
267
+ : // Writing to an agent is one direction of a correspondence, not a
268
+ // call that returns: what the agent says back arrives as its own
269
+ // message whenever it chooses to send one, under nothing that
270
+ // names this. So the brief says it is waiting for nothing, and a
271
+ // reader is not left watching for an answer that has no way in.
272
+ { prompt: text(input["message"]) ?? "", name: to, one_way: true }),
209
273
  });
210
274
  } else if (name === "Bash" && isCcmsgSend(str(input["command"]))) {
211
275
  make("message:session:out", { text: str(input["command"]) ?? "" });
212
276
  }
213
- if (id !== "") this.#calls.set(id, { tool, name, ...optional("message", message) });
277
+ if (id === "") return;
278
+ // A call is dropped from the pairing once it has been answered, so what is
279
+ // held here is the calls still outstanding. The bound is for the one that
280
+ // never will be — a transcript ends mid-call, an agent is killed — which
281
+ // otherwise accumulates for as long as the file is followed.
282
+ if (this.#calls.size >= OUTSTANDING_CALLS) {
283
+ const oldest = this.#calls.keys().next();
284
+ if (oldest.done !== true) this.#calls.delete(oldest.value);
285
+ }
286
+ this.#calls.set(id, { tool, name, ...optional("message", message) });
214
287
  }
215
288
 
216
289
  #user(record: Row, make: Make): void {
@@ -239,22 +312,37 @@ class Classification {
239
312
  #answer(record: Row, block: Row, make: Make): void {
240
313
  const id = str(block["tool_use_id"]) ?? "";
241
314
  const call = this.#calls.get(id);
315
+ if (call === undefined) {
316
+ // An answer to a call this reading never saw. A result item names the
317
+ // call it answers and there is no id to name, so what is stated is the
318
+ // record itself rather than a pointer to something that does not exist.
319
+ // It happens where a reading starts part-way down a file: the whole file
320
+ // is read before a dump's range is applied, so the call is there.
321
+ make("system:unknown", { record });
322
+ return;
323
+ }
242
324
  const failed = block["is_error"] === true;
243
325
  const answer = record["toolUseResult"];
244
- const fields = resultFields(call?.name, answer, failed);
245
- const item = make(`tool:${segment(call?.name ?? "unknown")}`, {
326
+ const fields = resultFields(call.name, answer, failed);
327
+ const item = make(`tool:${segment(call.name)}`, {
246
328
  role: "result",
247
- parent_item: call?.tool.uuid ?? id,
329
+ parent_item: call.tool.id,
248
330
  tool_use_id: id,
249
331
  ...(fields ?? { result: genericResult(answer) }),
250
332
  });
251
- if (call === undefined) return;
252
- call.tool["result_item"] = item.uuid;
333
+ call.tool["result_item"] = item.id;
253
334
  // An agent's id is known only once it has started, so the message that
254
335
  // asked for it learns its own id from the answer.
255
336
  const result = row(answer) ?? {};
256
337
  const agent = str(result["agentId"]) ?? str(result["agent_id"]);
257
- if (call.message === undefined) return;
338
+ if (call.message === undefined) {
339
+ // The exchange is closed: nothing else in the file points back at this
340
+ // call, so what was held for the pairing is let go. A transcript
341
+ // followed while it grows is read by one long-lived reading, and a call
342
+ // kept after it was answered would be kept for the session's life.
343
+ this.#calls.delete(id);
344
+ return;
345
+ }
258
346
  if (agent !== undefined) call.message["agent_id"] = agent;
259
347
  // An agent that was waited on answers here, in the call's own result. One
260
348
  // started in the background answers much later in a notification of its
@@ -264,13 +352,14 @@ class Classification {
264
352
  if (said === undefined) return;
265
353
  const reply = make("message:sub:in", {
266
354
  role: "result",
267
- parent_item: call.message.uuid,
355
+ parent_item: call.message.id,
268
356
  text: said,
269
357
  ...optional("agent_id", agent),
270
358
  ...optional("status", str(result["status"])),
271
359
  ...optional("duration_ms", count(result["totalDurationMs"])),
272
360
  });
273
- call.message["result_item"] = reply.uuid;
361
+ call.message["result_item"] = reply.id;
362
+ this.#calls.delete(id);
274
363
  }
275
364
 
276
365
  /** A `type: "user"` line whose content is words rather than a tool's answer.
@@ -352,18 +441,20 @@ class Classification {
352
441
  * itself. Either way it is an agent answering and reads as one. */
353
442
  #notification(said: string, make: Make): void {
354
443
  const answer = tagged(said, "result");
355
- const call = this.#calls.get(tagged(said, "tool-use-id") ?? "");
444
+ const key = tagged(said, "tool-use-id") ?? "";
445
+ const call = this.#calls.get(key);
356
446
  if (answer !== undefined && call !== undefined) {
357
447
  const asked = call.message ?? call.tool;
358
448
  const item = make("message:sub:in", {
359
449
  role: "result",
360
- parent_item: asked.uuid,
450
+ parent_item: asked.id,
361
451
  text: answer,
362
452
  ...optional("agent_id", str(asked["agent_id"]) ?? tagged(said, "task-id")),
363
453
  ...optional("status", tagged(said, "status")),
364
454
  ...optional("duration_ms", count(Number(tagged(said, "duration_ms")))),
365
455
  });
366
- if (call.message !== undefined) call.message["result_item"] = item.uuid;
456
+ if (call.message !== undefined) call.message["result_item"] = item.id;
457
+ this.#calls.delete(key);
367
458
  return;
368
459
  }
369
460
  make("system:task", {
@@ -1,5 +1,5 @@
1
1
  import type { DumpIdEntry, SessionDumpFile } from "@ccmsg/protocol";
2
- import type { Item } from "./item.ts";
2
+ import { fields, type Item } from "./item.ts";
3
3
  import { elapsed, fragment, words } from "./render.ts";
4
4
 
5
5
  /** A whole dump as one document.
@@ -77,8 +77,8 @@ function draw(item: Item, child: Item | undefined, view: DumpView): string[] {
77
77
  const answer = child === undefined ? undefined : fragment(child);
78
78
  const nested = child !== undefined && child.type.startsWith("message:sub");
79
79
  const link = isResult(item)
80
- ? arrow("←", item["parent_item"])
81
- : (arrow("→", item["result_item"]) ?? (item["role"] === "use" ? "(未着)" : undefined));
80
+ ? arrow("←", fields(item)["parent_item"])
81
+ : (arrow("→", fields(item)["result_item"]) ?? waiting(item));
82
82
  const head = isResult(item)
83
83
  ? words(prefix(item), link, own.head, clock(item))
84
84
  : words(
@@ -102,10 +102,28 @@ function draw(item: Item, child: Item | undefined, view: DumpView): string[] {
102
102
  return lines;
103
103
  }
104
104
 
105
- /** `[uuid8] type`, which is how an item is pointed at: the id is what a reader
106
- * goes back to the transcript with, and the type is what it was read as. */
105
+ /** `[id] type`, which is how an item is pointed at: the id is what the links
106
+ * name, and the type is what the item was read as. The record's id is shown at
107
+ * the length a person compares by eye, with the place in the record kept whole
108
+ * — an item is one of several a record became, and a heading that dropped
109
+ * which one would not answer the arrow pointing at it. */
107
110
  function prefix(item: Item): string {
108
- return `[${item.uuid.slice(0, 8)}] ${item.type}`;
111
+ return `[${short(item.id)}] ${item.type}`;
112
+ }
113
+
114
+ function short(id: string): string {
115
+ const cut = id.lastIndexOf(":");
116
+ return cut < 0 ? id.slice(0, 8) : `${id.slice(0, Math.min(8, cut))}${id.slice(cut)}`;
117
+ }
118
+
119
+ /** A call with nothing pointing back at it. Waiting and having nothing to wait
120
+ * for read differently: an agent answers the brief that started it, and a
121
+ * message written to one is answered wherever that agent chooses, under
122
+ * nothing that names this. */
123
+ function waiting(item: Item): string | undefined {
124
+ const own = fields(item);
125
+ if (own["role"] !== "use") return undefined;
126
+ return own["one_way"] === true ? "(片道)" : "(未着)";
109
127
  }
110
128
 
111
129
  function clock(item: Item): string {
@@ -119,11 +137,11 @@ function two(value: number): string {
119
137
  }
120
138
 
121
139
  function arrow(mark: string, id: unknown): string | undefined {
122
- return typeof id === "string" && id !== "" ? `${mark} ${id.slice(0, 8)}` : undefined;
140
+ return typeof id === "string" && id !== "" ? `${mark} ${short(id)}` : undefined;
123
141
  }
124
142
 
125
143
  function isResult(item: Item): boolean {
126
- return item["role"] === "result";
144
+ return fields(item)["role"] === "result";
127
145
  }
128
146
 
129
147
  /** The lines under a heading, cut only where a reader asked for a cut. */
@@ -169,31 +187,25 @@ function cell(text: string): string {
169
187
 
170
188
  /** Which answer belongs to which call, and which of those are drawn together.
171
189
  *
172
- * A tool's two halves are matched on the id the harness pairs them with, so
173
- * two calls in the same record are never confused for one another. An agent's
174
- * are matched on the record the brief was written in and the agent that
175
- * answered, which is what the classification filled in once the agent had
176
- * started. */
190
+ * An answer names the call it answers, so the matching is a lookup: no two
191
+ * calls in one record are confused for one another, and a tool and an agent
192
+ * are paired by the same rule rather than by the ids each of them happens to
193
+ * carry. */
177
194
  function pair(items: readonly Item[]): {
178
195
  child: Map<number, number>;
179
196
  folded: Set<number>;
180
197
  } {
181
198
  const child = new Map<number, number>();
182
199
  const folded = new Set<number>();
183
- const waiting = new Map<string, number[]>();
200
+ const where = new Map<string, number>();
201
+ for (let at = 0; at < items.length; at += 1) where.set((items[at] as Item).id, at);
184
202
  for (let at = 0; at < items.length; at += 1) {
185
203
  const item = items[at] as Item;
186
204
  // Which half of an exchange this is, which the contract calls an item's
187
205
  // role and nothing here confuses with who is allowed to ask for one.
188
- const half = item["role"];
189
- if (half === "use") {
190
- const queue = waiting.get(key(item, false));
191
- if (queue === undefined) waiting.set(key(item, false), [at]);
192
- else queue.push(at);
193
- continue;
194
- }
195
- if (half !== "result") continue;
196
- const call = waiting.get(key(item, true))?.shift();
206
+ if (fields(item)["role"] !== "result") continue;
207
+ const parent = fields(item)["parent_item"];
208
+ const call = typeof parent === "string" ? where.get(parent) : undefined;
197
209
  if (call === undefined) continue;
198
210
  // A pair the reader would have to scroll between is left where each half
199
211
  // happened, unless it is an agent's: what an agent was asked and what it
@@ -204,11 +216,3 @@ function pair(items: readonly Item[]): {
204
216
  }
205
217
  return { child, folded };
206
218
  }
207
-
208
- function key(item: Item, result: boolean): string {
209
- const call = item["tool_use_id"];
210
- if (typeof call === "string" && call !== "") return `${item.type}${call}`;
211
- const record = result ? item["parent_item"] : item.uuid;
212
- const agent = item["agent_id"];
213
- return `sub${String(record)}${typeof agent === "string" ? agent : ""}`;
214
- }
Binary file
@@ -1,6 +1,15 @@
1
- export { classify } from "./classify.ts";
1
+ export { classify, Classification } from "./classify.ts";
2
2
  export { document, type DumpView } from "./document.ts";
3
- export type { Item } from "./item.ts";
3
+ export { fields, type Item } from "./item.ts";
4
4
  export { ledger } from "./ids.ts";
5
+ export { type Located, located, positioned } from "./record.ts";
5
6
  export { type Fragment, fragment } from "./render.ts";
6
- export { type Ask, type Selection, select, selection } from "./select.ts";
7
+ export {
8
+ type Ask,
9
+ bounded,
10
+ type Bounds,
11
+ type Selection,
12
+ select,
13
+ selection,
14
+ within,
15
+ } from "./select.ts";
@@ -1,23 +1,20 @@
1
- /** One classified item, at the shape the daemon holds it in.
1
+ import type { TranscriptItem } from "@ccmsg/protocol";
2
+
3
+ /** One classified item, at the shape the contract states.
2
4
  *
3
- * The contract states the same shape as a schema, which is what a dump is
4
- * checked against; what it does not give is a type to write code with — a
5
- * union of that many object schemas erases to nothing usable so the three
6
- * fields every item has are named here and the rest are the type's own. The
7
- * schema stays the authority: the tests validate what this produces against
8
- * it, so a field that drifts from the contract fails there rather than
9
- * travelling. */
10
- export interface Item {
11
- /** The record's id in the transcript, which is what makes an item
12
- * addressable and what the links between items point with. Every item one
13
- * record became carries it, so a bound by record keeps a turn whole. */
14
- readonly uuid: string;
15
- readonly type: string;
16
- /** The item's own instant. A call and its result each keep their own. */
17
- readonly at: number;
18
- /** Which turn of the session it fell in, counted from where a person spoke.
19
- * Renumbered whenever the file is read again, so it is an attribute to show
20
- * and never a way to cut a range. */
21
- readonly turn?: number;
22
- readonly [field: string]: unknown;
5
+ * The contract is the authority on what an item is, and its schema carries a
6
+ * type, so there is nothing to restate here: what the daemon holds and what
7
+ * travels are the same value, and a field that drifted would fail to compile
8
+ * rather than to validate. */
9
+ export type Item = TranscriptItem;
10
+
11
+ /** An item's fields read by name.
12
+ *
13
+ * The type is a union of one object per item type, which is what makes it
14
+ * precise when an item is built and useless when one is read: a drawing asks
15
+ * every item for `role` or `agent_id` and only some types have either. This is
16
+ * the one place that gap is crossed, so a reader names a field once instead of
17
+ * narrowing a union of fifty members it has no discriminant for. */
18
+ export function fields(item: Item): Record<string, unknown> {
19
+ return item as unknown as Record<string, unknown>;
23
20
  }
@@ -8,6 +8,45 @@
8
8
 
9
9
  export type Row = Record<string, unknown>;
10
10
 
11
+ /** One record of a transcript and where it begins in the file.
12
+ *
13
+ * Classification carries the position through to the items, because an item is
14
+ * what a reader made of a record and a reader is fallible: the position is how
15
+ * whoever holds the item asks the transcript what the record actually said. */
16
+ export interface Located {
17
+ readonly line: string;
18
+ readonly offset: number;
19
+ }
20
+
21
+ /** A chunk of a transcript as its records, each with its position, where
22
+ * `start` is the offset the chunk itself begins at.
23
+ *
24
+ * A record's span runs to the start of the next one — the newline that ends it
25
+ * included — so a read bounded to `offset + bytes` carrying `bytes` returns the
26
+ * record whole rather than everything but its terminator. Every record a
27
+ * transcript hands out is one the writer finished and terminated; a trailing
28
+ * fragment is not a record and is not offered here. */
29
+ export function located(chunk: string, start = 0): Located[] {
30
+ return positioned(chunk.split("\n"), start);
31
+ }
32
+
33
+ /** Records already split out of a chunk, placed from where the chunk begins.
34
+ * The one a tail hands over, which has done the splitting on the bytes. */
35
+ export function positioned(lines: readonly string[], start: number): Located[] {
36
+ const found: Located[] = [];
37
+ let offset = start;
38
+ for (const line of lines) {
39
+ if (line !== "") found.push({ line, offset });
40
+ offset += span(line);
41
+ }
42
+ return found;
43
+ }
44
+
45
+ /** How far a record runs from where it begins, its terminator included. */
46
+ export function span(line: string): number {
47
+ return Buffer.byteLength(line, "utf8") + 1;
48
+ }
49
+
11
50
  export function isRow(raw: unknown): raw is Row {
12
51
  return typeof raw === "object" && raw !== null && !Array.isArray(raw);
13
52
  }
@@ -1,4 +1,4 @@
1
- import type { Item } from "./item.ts";
1
+ import { fields, type Item } from "./item.ts";
2
2
 
3
3
  /** One item as the words a person reads.
4
4
  *
@@ -40,17 +40,18 @@ export function fragment(item: Item): Fragment {
40
40
  if (draw !== undefined) return draw(item);
41
41
  // A tool nothing knows the shape of arrives carrying what it was called
42
42
  // with and what it answered, which is what the generic shape lays out.
43
- return { head: "", body: summary(item[result ? "result" : "input"]) };
43
+ return { head: "", body: summary(fields(item)[result ? "result" : "input"]) };
44
44
  }
45
45
  const draw = ITEMS[type];
46
46
  if (draw !== undefined) return draw(item);
47
47
  if (type.startsWith("hook:")) return hook(item);
48
- if (type.startsWith("system:attachment:")) return { head: "", body: summary(item["attachment"]) };
48
+ if (type.startsWith("system:attachment:"))
49
+ return { head: "", body: summary(fields(item)["attachment"]) };
49
50
  return { head: "", body: summary(own(item)) };
50
51
  }
51
52
 
52
53
  function isResult(item: Item): boolean {
53
- return item["role"] === "result";
54
+ return fields(item)["role"] === "result";
54
55
  }
55
56
 
56
57
  // --- message, thinking and the harness's own voice ---
@@ -117,7 +118,7 @@ const ITEMS: Record<string, Draw> = {
117
118
  ),
118
119
  body: EMPTY,
119
120
  }),
120
- "system:unknown": (item) => ({ head: "", body: summary(item["record"]) }),
121
+ "system:unknown": (item) => ({ head: "", body: summary(fields(item)["record"]) }),
121
122
  };
122
123
 
123
124
  function said(item: Item): Fragment {
@@ -192,14 +193,14 @@ const USES: Record<string, Draw> = {
192
193
  Monitor: (item) => ({
193
194
  head: words(
194
195
  str(item, "description"),
195
- item["persistent"] === true ? "persistent" : undefined,
196
+ fields(item)["persistent"] === true ? "persistent" : undefined,
196
197
  until(num(item, "timeout_ms")),
197
198
  ),
198
199
  body: lines(str(item, "command")).map((line) => `$ ${line}`),
199
200
  }),
200
201
  Skill: (item) => ({ head: words(str(item, "skill"), str(item, "args")), body: EMPTY }),
201
202
  TodoWrite: (item) => {
202
- const todos = list(item["todos"]);
203
+ const todos = list(fields(item)["todos"]);
203
204
  const width = Math.max(0, ...todos.map((todo) => todo.status.length));
204
205
  return {
205
206
  head: `${String(todos.length)} items`,
@@ -220,7 +221,7 @@ const USES: Record<string, Draw> = {
220
221
  * read is a body. */
221
222
  const RESULTS: Record<string, Draw> = {
222
223
  Bash: (item) => ({
223
- head: item["interrupted"] === true ? "中断" : "",
224
+ head: fields(item)["interrupted"] === true ? "中断" : "",
224
225
  body: [...stream("stdout", str(item, "stdout")), ...stream("stderr", str(item, "stderr"))],
225
226
  }),
226
227
  Read: (item) => ({
@@ -248,7 +249,7 @@ const RESULTS: Record<string, Draw> = {
248
249
  Skill: (item) => ({
249
250
  head: words(
250
251
  field(item, "agent_id", "agent="),
251
- item["background"] === true ? "background" : undefined,
252
+ fields(item)["background"] === true ? "background" : undefined,
252
253
  field(item, "status", "status="),
253
254
  ),
254
255
  body: EMPTY,
@@ -268,7 +269,7 @@ function pattern(item: Item): Fragment {
268
269
  /** A tool that says nothing but whether it worked. Success is the silent case:
269
270
  * a heading crowded with `ok` is a heading nobody reads. */
270
271
  function ok(item: Item): Fragment {
271
- return { head: item["ok"] === false ? "失敗" : "", body: EMPTY };
272
+ return { head: fields(item)["ok"] === false ? "失敗" : "", body: EMPTY };
272
273
  }
273
274
 
274
275
  function hits(item: Item): Fragment {
@@ -315,12 +316,12 @@ function until(ms: number | undefined): string | undefined {
315
316
  // --- the pieces every drawing is made of ---
316
317
 
317
318
  function str(item: Item, name: string): string | undefined {
318
- const value = item[name];
319
+ const value = fields(item)[name];
319
320
  return typeof value === "string" && value !== "" ? value : undefined;
320
321
  }
321
322
 
322
323
  function num(item: Item, name: string): number | undefined {
323
- const value = item[name];
324
+ const value = fields(item)[name];
324
325
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
325
326
  }
326
327
 
@@ -377,10 +378,20 @@ function list(value: unknown): Todo[] {
377
378
 
378
379
  /** The base fields every item has, which the heading already said. What is
379
380
  * left is the type's own, and that is what a generic drawing lays out. */
380
- const BASE = new Set(["uuid", "type", "at", "turn", "role", "result_item", "parent_item"]);
381
+ const BASE = new Set([
382
+ "id",
383
+ "uuid",
384
+ "source",
385
+ "type",
386
+ "at",
387
+ "turn",
388
+ "role",
389
+ "result_item",
390
+ "parent_item",
391
+ ]);
381
392
 
382
393
  function own(item: Item): Record<string, unknown> {
383
- return Object.fromEntries(Object.entries(item).filter(([name]) => !BASE.has(name)));
394
+ return Object.fromEntries(Object.entries(fields(item)).filter(([name]) => !BASE.has(name)));
384
395
  }
385
396
 
386
397
  /** How deep a value nobody wrote a drawing for is laid out. Two levels is what
@@ -1,4 +1,5 @@
1
1
  import type { DumpPreset } from "@ccmsg/protocol";
2
+ import { OpError } from "../../dispatch/index.ts";
2
3
  import type { Item } from "./item.ts";
3
4
 
4
5
  /** Which of a transcript's items a dump keeps.
@@ -142,3 +143,69 @@ export function select(
142
143
  }
143
144
  return { items: kept, entries };
144
145
  }
146
+
147
+ /** Where a range of a transcript begins and ends.
148
+ *
149
+ * A record bound cuts at that record's position rather than at its clock, so
150
+ * records sharing an instant stay on their own side of the cut — which is the
151
+ * whole reason there are two kinds. An item bound is finer than either: it
152
+ * resumes inside a record whose earlier items were already answered for. */
153
+ export interface Bounds {
154
+ readonly since_at?: number;
155
+ readonly since_uuid?: string;
156
+ readonly since_id?: string;
157
+ readonly until_at?: number;
158
+ readonly until_uuid?: string;
159
+ }
160
+
161
+ /** The bounds as stated, refused where they say two things at once.
162
+ *
163
+ * A range with two lower bounds has no reading that is not a guess at which
164
+ * one was meant, and a guess that answers the wrong slice is worse than a
165
+ * refusal the caller can act on. */
166
+ export function bounded(bounds: Bounds): void {
167
+ const lower = [bounds.since_at, bounds.since_uuid, bounds.since_id].filter(
168
+ (one) => one !== undefined,
169
+ ).length;
170
+ if (lower > 1) {
171
+ throw new OpError("invalid_args", "a lower bound is a time, a record or an item, not several");
172
+ }
173
+ if (bounds.until_at !== undefined && bounds.until_uuid !== undefined) {
174
+ throw new OpError("invalid_args", "an upper bound is a time or a record, not both");
175
+ }
176
+ }
177
+
178
+ /** The items within the bounds, in the order the transcript holds them.
179
+ *
180
+ * Every item a record became carries that record's id, so a bound by record
181
+ * keeps a turn's thinking, words and calls together, while a bound by item
182
+ * cuts inside one. */
183
+ export function within(items: readonly Item[], bounds: Bounds): Item[] {
184
+ const kept: Item[] = [];
185
+ // A lower bound by record or by item starts closed: it opens at what it
186
+ // names, which is included.
187
+ let open = bounds.since_uuid === undefined && bounds.since_id === undefined;
188
+ for (let at = 0; at < items.length; at += 1) {
189
+ const item = items[at];
190
+ if (item === undefined) continue;
191
+ if (!open) {
192
+ if (
193
+ bounds.since_id !== undefined
194
+ ? item.id !== bounds.since_id
195
+ : item.uuid !== bounds.since_uuid
196
+ ) {
197
+ continue;
198
+ }
199
+ open = true;
200
+ }
201
+ if (bounds.since_at !== undefined && item.at < bounds.since_at) continue;
202
+ if (bounds.until_at !== undefined && item.at > bounds.until_at) break;
203
+ kept.push(item);
204
+ // An upper bound by record is inclusive and cuts after the last item that
205
+ // record became, so the rest of the same record is still let through.
206
+ if (bounds.until_uuid !== undefined && item.uuid === bounds.until_uuid) {
207
+ if (items[at + 1]?.uuid !== item.uuid) break;
208
+ }
209
+ }
210
+ return kept;
211
+ }
@@ -32,9 +32,10 @@ export interface Appended {
32
32
  export interface TailDeps {
33
33
  /** Complete lines only; a record still being written waits for its end. */
34
34
  readonly onAppended: (appended: Appended) => void;
35
- /** The end of the file as it stood when the tail opened, oldest first. The
36
- * seed of the fold, not something a subscriber is sent. */
37
- readonly onSeed: (lines: readonly string[]) => void;
35
+ /** The end of the file as it stood when the tail opened, oldest first, with
36
+ * the offsets that place it. The seed of what is folded and of what is
37
+ * classified, not something a subscriber is sent. */
38
+ readonly onSeed: (seeded: Appended) => void;
38
39
  /** The file is not the one the tail was reading: it shrank, so what was
39
40
  * folded out of the old contents no longer describes it. */
40
41
  readonly onTruncated: () => void;
@@ -119,14 +120,12 @@ export class TranscriptTail {
119
120
  const size = this.#size;
120
121
  if (size === 0) return;
121
122
  const from = Math.max(0, size - FOLD_TAIL_BYTES);
122
- const text = await this.#slice(from, size);
123
- const complete = whole(text);
124
- this.#offset = from + byteLength(complete);
125
- const lines = split(complete);
123
+ const complete = whole(await this.#slice(from, size));
124
+ this.#offset = from + complete.byteLength;
126
125
  // The first line is half a record whenever the read began mid-file, so it
127
- // is dropped: what the fold reads are whole records or nothing.
128
- if (from > 0) lines.shift();
129
- this.deps.onSeed(lines);
126
+ // is dropped: what is read are whole records or nothing.
127
+ const at = from === 0 ? 0 : complete.indexOf(NEWLINE) + 1;
128
+ this.deps.onSeed({ lines: split(complete, at), start: from + at, end: this.#offset, size });
130
129
  }
131
130
 
132
131
  async #read(): Promise<void> {
@@ -141,11 +140,11 @@ export class TranscriptTail {
141
140
  this.#size = size;
142
141
  if (size === this.#offset) return;
143
142
  const complete = whole(await this.#slice(this.#offset, size));
144
- if (complete.length === 0) return;
143
+ if (complete.byteLength === 0) return;
145
144
  const start = this.#offset;
146
- const end = start + byteLength(complete);
145
+ const end = start + complete.byteLength;
147
146
  this.#offset = end;
148
- this.deps.onAppended({ lines: split(complete), start, end, size });
147
+ this.deps.onAppended({ lines: split(complete, 0), start, end, size });
149
148
  }
150
149
 
151
150
  async #stat(): Promise<number> {
@@ -157,35 +156,47 @@ export class TranscriptTail {
157
156
  }
158
157
  }
159
158
 
160
- /** The bytes in a range, as text. A range that reads short — the file was
161
- * truncated between the stat and the read — yields what was actually there. */
162
- async #slice(from: number, to: number): Promise<string> {
159
+ /** The bytes in a range. A range that reads short — the file was truncated
160
+ * between the stat and the read — yields what was actually there.
161
+ *
162
+ * Bytes rather than text, because the offsets that place what is read are
163
+ * found in them: a slice that begins part-way into a file lands wherever the
164
+ * arithmetic puts it, inside a character as readily as before one, and
165
+ * decoding first would turn those bytes into a replacement character of a
166
+ * different length and move every offset derived from it. */
167
+ async #slice(from: number, to: number): Promise<Buffer> {
168
+ if (to <= from) return Buffer.alloc(0);
163
169
  const handle = await open(this.path, "r").catch(() => undefined);
164
- if (handle === undefined) return "";
170
+ if (handle === undefined) return Buffer.alloc(0);
165
171
  try {
166
172
  const buffer = Buffer.alloc(to - from);
167
173
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, from);
168
- return buffer.subarray(0, bytesRead).toString("utf8");
174
+ return buffer.subarray(0, bytesRead);
169
175
  } finally {
170
176
  await handle.close();
171
177
  }
172
178
  }
173
179
  }
174
180
 
181
+ const NEWLINE = 0x0a;
182
+
175
183
  /** What of a read is whole records: everything up to and including the last
176
184
  * newline. A transcript ends every record with one, so what follows the last
177
185
  * is a record the writer has not finished. */
178
- function whole(text: string): string {
179
- const last = text.lastIndexOf("\n");
180
- return last < 0 ? "" : text.slice(0, last + 1);
181
- }
182
-
183
- function split(complete: string): string[] {
184
- return complete.split("\n").slice(0, -1);
186
+ function whole(bytes: Buffer): Buffer {
187
+ const last = bytes.lastIndexOf(NEWLINE);
188
+ return last < 0 ? Buffer.alloc(0) : bytes.subarray(0, last + 1);
185
189
  }
186
190
 
187
- function byteLength(text: string): number {
188
- return Buffer.byteLength(text, "utf8");
191
+ /** The records in what was read, from a byte that begins one. */
192
+ function split(complete: Buffer, at: number): string[] {
193
+ const lines: string[] = [];
194
+ for (let from = at; from < complete.byteLength;) {
195
+ const newline = complete.indexOf(NEWLINE, from);
196
+ lines.push(complete.toString("utf8", from, newline));
197
+ from = newline + 1;
198
+ }
199
+ return lines;
189
200
  }
190
201
 
191
202
  /** How large the file is right now, or zero for one that is not there yet.
@@ -1,8 +1,25 @@
1
1
  import type { InstanceId, Sid } from "@ccmsg/protocol";
2
2
  import { topicParam, type TopicValue, type UpstreamResource } from "../topics/index.ts";
3
3
  import { NO_FACTS, type TranscriptFacts, TranscriptFold } from "./fold.ts";
4
+ import { Classification, type Item, positioned } from "./items/index.ts";
4
5
  import { type Appended, TranscriptTail } from "./tail.ts";
5
6
 
7
+ /** How many items a subscription to `transcript_items:<sid>` opens with.
8
+ *
9
+ * The tail of the same megabyte the fold is seeded from, bounded by a count
10
+ * because that read is bounded by bytes: a file of many small records would
11
+ * otherwise make the opening frame as large as the read that produced it. Two
12
+ * hundred items is several turns at the sizes the harness writes, which is
13
+ * more than a live view shows at once — and a client that wants further back
14
+ * asks for it by range rather than waiting for a snapshot to grow. */
15
+ export const ITEMS_SNAPSHOT = 200;
16
+
17
+ /** Which of the two topics a name is. Both are fed by one tail, so the
18
+ * resource is entered by either name and answers each in its own vocabulary. */
19
+ function isItems(topic: string): boolean {
20
+ return topic.startsWith("transcript_items:");
21
+ }
22
+
6
23
  export interface TranscriptsDeps {
7
24
  readonly self: InstanceId;
8
25
  /** Where a session's transcript is: what it announced when it greeted
@@ -58,7 +75,12 @@ export class Transcripts implements UpstreamResource {
58
75
  const sid = topicParam(topic);
59
76
  const followed = sid === undefined ? undefined : this.#followed.get(sid);
60
77
  if (sid === undefined || followed === undefined) return [];
61
- return [{ instance: this.deps.self, data: { sid, size: followed.tail.size } }];
78
+ // The items topic holds a list that is only appended to, so its snapshot
79
+ // is the end of that list rather than a place to start from.
80
+ const data = isItems(topic)
81
+ ? { sid, items: [...followed.recent] }
82
+ : { sid, size: followed.tail.size };
83
+ return [{ instance: this.deps.self, data }];
62
84
  }
63
85
 
64
86
  /** What the fold currently says about a session. Empty for one not being
@@ -117,15 +139,20 @@ export class Transcripts implements UpstreamResource {
117
139
  const followed: Followed = {
118
140
  holds: 1,
119
141
  fold,
142
+ reading: new Classification(),
143
+ recent: [],
120
144
  tail: new TranscriptTail(path, {
121
- onSeed: (lines) => {
145
+ onSeed: (seeded) => {
122
146
  // The end of the file as it already stood: it settles what the fold
123
- // says, and it is not an append, so nothing is published for it.
124
- if (foldAll(fold, lines)) this.deps.onFacts(sid);
147
+ // says and opens the reading that classifies what comes next, and it
148
+ // is not an append, so nothing is published for it.
149
+ this.#keep(sid, seeded);
150
+ if (foldAll(fold, seeded.lines)) this.deps.onFacts(sid);
125
151
  },
126
152
  onAppended: (appended) => this.#appended(sid, fold, appended),
127
153
  onTruncated: () => {
128
154
  fold.reset();
155
+ this.#reset(sid);
129
156
  this.deps.onFacts(sid);
130
157
  },
131
158
  ...(this.deps.pollMs === undefined ? {} : { pollMs: this.deps.pollMs }),
@@ -134,8 +161,15 @@ export class Transcripts implements UpstreamResource {
134
161
  return followed;
135
162
  }
136
163
 
137
- /** The one pass over what was appended: the lines go to the fold and to the
138
- * topic, in that order, and are not read a second time for either (M5). */
164
+ /** The one pass over what was appended: the lines go to the fold, to the
165
+ * classification and to the two topics, and are not read again for any of
166
+ * them (M5).
167
+ *
168
+ * Both topics are fed whether or not either is subscribed to, because the
169
+ * classification is a reading of the whole file kept open: a call answered
170
+ * now was made in bytes that went past long ago, and a reading started when
171
+ * somebody subscribed would not know it. What the memory holds is bounded —
172
+ * the calls still outstanding, and the items of the opening frame. */
139
173
  #appended(sid: Sid, fold: TranscriptFold, appended: Appended): void {
140
174
  const changed = foldAll(fold, appended.lines);
141
175
  this.deps.publish(`transcript:${sid}`, {
@@ -145,13 +179,46 @@ export class Transcripts implements UpstreamResource {
145
179
  end: appended.end,
146
180
  size: appended.size,
147
181
  });
182
+ const items = this.#keep(sid, appended);
183
+ // A record still being written was not read, so there is nothing to say
184
+ // about it yet; a chunk whose records were all the interface's own
185
+ // bookkeeping says nothing either.
186
+ if (items.length > 0) this.deps.publish(`transcript_items:${sid}`, { sid, items });
148
187
  if (changed) this.deps.onFacts(sid);
149
188
  }
189
+
190
+ /** What a chunk was read as, with the end of it held for the next
191
+ * subscription to open on. A result that fills in a call already handed over
192
+ * is not sent again: the call named nothing to wait for and the result names
193
+ * the call, so a reader ties the two together from what it already has. */
194
+ #keep(sid: Sid, chunk: Appended): readonly Item[] {
195
+ const followed = this.#followed.get(sid);
196
+ if (followed === undefined) return [];
197
+ const items = followed.reading.readAll(positioned(chunk.lines, chunk.start));
198
+ followed.recent.push(...items);
199
+ if (followed.recent.length > ITEMS_SNAPSHOT) {
200
+ followed.recent.splice(0, followed.recent.length - ITEMS_SNAPSHOT);
201
+ }
202
+ return items;
203
+ }
204
+
205
+ /** The file is not the one that was being read, so neither the reading nor
206
+ * what it produced describes it. */
207
+ #reset(sid: Sid): void {
208
+ const followed = this.#followed.get(sid);
209
+ if (followed === undefined) return;
210
+ followed.reading = new Classification();
211
+ followed.recent.length = 0;
212
+ }
150
213
  }
151
214
 
152
215
  interface Followed {
153
216
  holds: number;
154
217
  readonly fold: TranscriptFold;
218
+ /** The transcript read as items, kept open while the tail runs. */
219
+ reading: Classification;
220
+ /** The end of what has been read, which is what a subscription opens on. */
221
+ readonly recent: Item[];
155
222
  readonly tail: TranscriptTail;
156
223
  }
157
224