@ccmsg/cli 0.4.2 → 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 +2 -2
- package/src/instance/instance.ts +3 -0
- package/src/sessions/dump.ts +11 -39
- package/src/sessions/handlers.ts +16 -0
- package/src/sessions/items.ts +105 -0
- package/src/transcript/items/classify.ts +154 -34
- package/src/transcript/items/document.ts +35 -31
- package/src/transcript/items/ids.ts +0 -0
- package/src/transcript/items/index.ts +12 -3
- package/src/transcript/items/item.ts +18 -21
- package/src/transcript/items/record.ts +39 -0
- package/src/transcript/items/render.ts +25 -14
- package/src/transcript/items/select.ts +67 -0
- package/src/transcript/items/tools.ts +6 -0
- package/src/transcript/tail.ts +38 -27
- package/src/transcript/transcripts.ts +73 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
23
|
+
"@ccmsg/protocol": "1.13.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/instance/instance.ts
CHANGED
|
@@ -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);
|
package/src/sessions/dump.ts
CHANGED
|
@@ -8,7 +8,15 @@ import type {
|
|
|
8
8
|
SessionDumpWriteResult,
|
|
9
9
|
} from "@ccmsg/protocol";
|
|
10
10
|
import { OpError } from "../dispatch/index.ts";
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
package/src/sessions/handlers.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
@@ -43,10 +56,26 @@ const NOT_ITEMS = new Set([
|
|
|
43
56
|
"summary",
|
|
44
57
|
]);
|
|
45
58
|
|
|
59
|
+
/** The tools that start an agent, in the spellings the harness has used for
|
|
60
|
+
* the one thing. Both are read the same way: the call is also a brief, and
|
|
61
|
+
* what comes back is also an answer. */
|
|
62
|
+
const SPAWNS = new Set(["Agent", "Task"]);
|
|
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
|
+
|
|
46
69
|
/** What an item is under construction: the contract's shape, before it is
|
|
47
70
|
* settled. A call learns the id of what answered it only when the answer
|
|
48
71
|
* arrives, which is why these are written to after they are made. */
|
|
49
|
-
type Draft = Record<string, unknown> & {
|
|
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
|
+
};
|
|
50
79
|
|
|
51
80
|
/** A whole transcript read as items, in the order the file holds them.
|
|
52
81
|
*
|
|
@@ -55,23 +84,19 @@ type Draft = Record<string, unknown> & { uuid: string; type: string; at: number
|
|
|
55
84
|
* apart the harness wrote them. Reading the whole file before any range is
|
|
56
85
|
* applied is what makes `parent_item` answerable: a result inside the range
|
|
57
86
|
* whose call fell before it still names the call. */
|
|
58
|
-
export function classify(
|
|
87
|
+
export function classify(records: Iterable<Located>): Item[] {
|
|
59
88
|
const state = new Classification();
|
|
60
|
-
|
|
61
|
-
if (line === "") continue;
|
|
62
|
-
let parsed: unknown;
|
|
63
|
-
try {
|
|
64
|
-
parsed = JSON.parse(line);
|
|
65
|
-
} catch {
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (isRow(parsed)) state.read(parsed);
|
|
69
|
-
}
|
|
70
|
-
return state.items;
|
|
89
|
+
return state.readAll(records);
|
|
71
90
|
}
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
|
|
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[] = [];
|
|
75
100
|
/** The call each tool result belongs to, by the id the harness pairs them
|
|
76
101
|
* with. Holds the `tool:*` item and, for an `Agent` call, the
|
|
77
102
|
* `message:sub:out` beside it — the same exchange seen from the two sides
|
|
@@ -81,15 +106,54 @@ class Classification {
|
|
|
81
106
|
/** The last slash command invoked, which is what its output belongs to. */
|
|
82
107
|
#slash: string | undefined;
|
|
83
108
|
|
|
84
|
-
read
|
|
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 {
|
|
85
136
|
const type = str(record["type"]);
|
|
86
137
|
if (type === undefined || NOT_ITEMS.has(type)) return;
|
|
87
138
|
const uuid = str(record["uuid"]) ?? "";
|
|
88
139
|
if (uuid === "") return;
|
|
89
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;
|
|
90
145
|
const make = (kind: string, fields: Record<string, unknown> = {}): Draft => {
|
|
91
|
-
const draft: Draft = {
|
|
92
|
-
|
|
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);
|
|
93
157
|
return draft;
|
|
94
158
|
};
|
|
95
159
|
if (type === "attachment") return this.#attachment(record, make);
|
|
@@ -184,7 +248,7 @@ class Classification {
|
|
|
184
248
|
...(fields ?? { input }),
|
|
185
249
|
});
|
|
186
250
|
let message: Draft | undefined;
|
|
187
|
-
if (name
|
|
251
|
+
if (SPAWNS.has(name)) {
|
|
188
252
|
message = make("message:sub:out", {
|
|
189
253
|
role: "use",
|
|
190
254
|
prompt: str(input["prompt"]) ?? "",
|
|
@@ -200,12 +264,26 @@ class Classification {
|
|
|
200
264
|
...(addressed(to) ? {} : { role: "use" }),
|
|
201
265
|
...(addressed(to)
|
|
202
266
|
? { text: text(input["message"]) ?? "", to }
|
|
203
|
-
:
|
|
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 }),
|
|
204
273
|
});
|
|
205
274
|
} else if (name === "Bash" && isCcmsgSend(str(input["command"]))) {
|
|
206
275
|
make("message:session:out", { text: str(input["command"]) ?? "" });
|
|
207
276
|
}
|
|
208
|
-
if (id
|
|
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) });
|
|
209
287
|
}
|
|
210
288
|
|
|
211
289
|
#user(record: Row, make: Make): void {
|
|
@@ -234,23 +312,54 @@ class Classification {
|
|
|
234
312
|
#answer(record: Row, block: Row, make: Make): void {
|
|
235
313
|
const id = str(block["tool_use_id"]) ?? "";
|
|
236
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
|
+
}
|
|
237
324
|
const failed = block["is_error"] === true;
|
|
238
325
|
const answer = record["toolUseResult"];
|
|
239
|
-
const fields = resultFields(call
|
|
240
|
-
const item = make(`tool:${segment(call
|
|
326
|
+
const fields = resultFields(call.name, answer, failed);
|
|
327
|
+
const item = make(`tool:${segment(call.name)}`, {
|
|
241
328
|
role: "result",
|
|
242
|
-
parent_item: call
|
|
329
|
+
parent_item: call.tool.id,
|
|
243
330
|
tool_use_id: id,
|
|
244
331
|
...(fields ?? { result: genericResult(answer) }),
|
|
245
332
|
});
|
|
246
|
-
|
|
247
|
-
call.tool["result_item"] = item.uuid;
|
|
333
|
+
call.tool["result_item"] = item.id;
|
|
248
334
|
// An agent's id is known only once it has started, so the message that
|
|
249
335
|
// asked for it learns its own id from the answer.
|
|
250
|
-
const result = row(answer);
|
|
251
|
-
const agent =
|
|
252
|
-
|
|
253
|
-
|
|
336
|
+
const result = row(answer) ?? {};
|
|
337
|
+
const agent = str(result["agentId"]) ?? str(result["agent_id"]);
|
|
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
|
+
}
|
|
346
|
+
if (agent !== undefined) call.message["agent_id"] = agent;
|
|
347
|
+
// An agent that was waited on answers here, in the call's own result. One
|
|
348
|
+
// started in the background answers much later in a notification of its
|
|
349
|
+
// own, and this result then says only that it was launched — so what
|
|
350
|
+
// decides is whether an answer came back, not which tool was called.
|
|
351
|
+
const said = answered(result["content"]);
|
|
352
|
+
if (said === undefined) return;
|
|
353
|
+
const reply = make("message:sub:in", {
|
|
354
|
+
role: "result",
|
|
355
|
+
parent_item: call.message.id,
|
|
356
|
+
text: said,
|
|
357
|
+
...optional("agent_id", agent),
|
|
358
|
+
...optional("status", str(result["status"])),
|
|
359
|
+
...optional("duration_ms", count(result["totalDurationMs"])),
|
|
360
|
+
});
|
|
361
|
+
call.message["result_item"] = reply.id;
|
|
362
|
+
this.#calls.delete(id);
|
|
254
363
|
}
|
|
255
364
|
|
|
256
365
|
/** A `type: "user"` line whose content is words rather than a tool's answer.
|
|
@@ -332,18 +441,20 @@ class Classification {
|
|
|
332
441
|
* itself. Either way it is an agent answering and reads as one. */
|
|
333
442
|
#notification(said: string, make: Make): void {
|
|
334
443
|
const answer = tagged(said, "result");
|
|
335
|
-
const
|
|
444
|
+
const key = tagged(said, "tool-use-id") ?? "";
|
|
445
|
+
const call = this.#calls.get(key);
|
|
336
446
|
if (answer !== undefined && call !== undefined) {
|
|
337
447
|
const asked = call.message ?? call.tool;
|
|
338
448
|
const item = make("message:sub:in", {
|
|
339
449
|
role: "result",
|
|
340
|
-
parent_item: asked.
|
|
450
|
+
parent_item: asked.id,
|
|
341
451
|
text: answer,
|
|
342
452
|
...optional("agent_id", str(asked["agent_id"]) ?? tagged(said, "task-id")),
|
|
343
453
|
...optional("status", tagged(said, "status")),
|
|
344
454
|
...optional("duration_ms", count(Number(tagged(said, "duration_ms")))),
|
|
345
455
|
});
|
|
346
|
-
if (call.message !== undefined) call.message["result_item"] = item.
|
|
456
|
+
if (call.message !== undefined) call.message["result_item"] = item.id;
|
|
457
|
+
this.#calls.delete(key);
|
|
347
458
|
return;
|
|
348
459
|
}
|
|
349
460
|
make("system:task", {
|
|
@@ -385,6 +496,15 @@ function attribute(said: string, name: string): string | undefined {
|
|
|
385
496
|
return new RegExp(`${name}="([^"]*)"`).exec(said)?.[1] || undefined;
|
|
386
497
|
}
|
|
387
498
|
|
|
499
|
+
/** What an agent handed back, which the harness writes as the blocks of a
|
|
500
|
+
* message. Nothing back is not an answer of no words: a launch says only that
|
|
501
|
+
* the agent started, and reading that as an empty answer would claim it had
|
|
502
|
+
* finished. */
|
|
503
|
+
function answered(raw: unknown): string | undefined {
|
|
504
|
+
const said = text(raw)?.trim();
|
|
505
|
+
return said === undefined || said === "" ? undefined : said;
|
|
506
|
+
}
|
|
507
|
+
|
|
388
508
|
/** A content field that is sometimes a string and sometimes the blocks of
|
|
389
509
|
* one. */
|
|
390
510
|
function text(raw: unknown): string | undefined {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DumpIdEntry, SessionDumpFile } from "@ccmsg/protocol";
|
|
2
|
-
import type
|
|
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
|
|
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
|
-
/** `[
|
|
106
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* are
|
|
175
|
-
*
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
1
|
+
import type { TranscriptItem } from "@ccmsg/protocol";
|
|
2
|
+
|
|
3
|
+
/** One classified item, at the shape the contract states.
|
|
2
4
|
*
|
|
3
|
-
* The contract
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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:"))
|
|
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([
|
|
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
|
+
}
|
|
@@ -196,6 +196,12 @@ const RESULT: Record<string, (result: unknown, failed: boolean) => Record<string
|
|
|
196
196
|
},
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
/** `Task` is the harness's older name for the tool that starts an agent. The
|
|
200
|
+
* type keeps whichever name the record used — a reader matches what it sees
|
|
201
|
+
* against what it ran — and both are read the same way. */
|
|
202
|
+
USE["Task"] = USE["Agent"] as Reader;
|
|
203
|
+
RESULT["Task"] = RESULT["Agent"] as (result: unknown, failed: boolean) => Record<string, unknown>;
|
|
204
|
+
|
|
199
205
|
function matches(result: unknown): Record<string, unknown> {
|
|
200
206
|
const fields = row(result);
|
|
201
207
|
if (fields === undefined) return {};
|
package/src/transcript/tail.ts
CHANGED
|
@@ -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
|
|
36
|
-
* seed of
|
|
37
|
-
|
|
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
|
|
123
|
-
|
|
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
|
|
128
|
-
|
|
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.
|
|
143
|
+
if (complete.byteLength === 0) return;
|
|
145
144
|
const start = this.#offset;
|
|
146
|
-
const end = start + byteLength
|
|
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
|
|
161
|
-
*
|
|
162
|
-
|
|
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)
|
|
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(
|
|
179
|
-
const last =
|
|
180
|
-
return last < 0 ?
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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: (
|
|
145
|
+
onSeed: (seeded) => {
|
|
122
146
|
// The end of the file as it already stood: it settles what the fold
|
|
123
|
-
// says
|
|
124
|
-
|
|
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
|
|
138
|
-
*
|
|
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
|
|