@ccmsg/cli 0.3.5 → 0.4.2
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/cli.ts +176 -10
- package/src/daemon/registry.ts +50 -2
- package/src/instance/config.ts +102 -1
- package/src/instance/instance.ts +1 -0
- package/src/plugin/skill.ts +10 -0
- package/src/sessions/dump.ts +91 -53
- package/src/sessions/handlers.ts +14 -1
- package/src/sessions/index.ts +1 -1
- package/src/transcript/items/classify.ts +400 -0
- package/src/transcript/items/document.ts +214 -0
- package/src/transcript/items/ids.ts +0 -0
- package/src/transcript/items/index.ts +6 -0
- package/src/transcript/items/item.ts +23 -0
- package/src/transcript/items/record.ts +71 -0
- package/src/transcript/items/render.ts +421 -0
- package/src/transcript/items/select.ts +144 -0
- package/src/transcript/items/tools.ts +231 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { DumpIdEntry, SessionDumpFile } from "@ccmsg/protocol";
|
|
2
|
+
import type { Item } from "./item.ts";
|
|
3
|
+
import { elapsed, fragment, words } from "./render.ts";
|
|
4
|
+
|
|
5
|
+
/** A whole dump as one document.
|
|
6
|
+
*
|
|
7
|
+
* What the file holds is items in the order the transcript had them, and what
|
|
8
|
+
* a person reads is that same order with the pairs put back together: a call
|
|
9
|
+
* and the answer that came straight back read as one thing, and an answer that
|
|
10
|
+
* arrived twenty turns later reads where it arrived, saying which call it
|
|
11
|
+
* belongs to. Folding is decided here rather than in the classification,
|
|
12
|
+
* because it is a fact about how far apart two items ended up in this
|
|
13
|
+
* particular selection and not about what either of them is.
|
|
14
|
+
*
|
|
15
|
+
* An agent is the exception: its answer is drawn under the brief that asked
|
|
16
|
+
* for it however many turns apart they are. The pair is a conversation with
|
|
17
|
+
* somebody else, and a conversation split across the page is one nobody can
|
|
18
|
+
* follow. */
|
|
19
|
+
|
|
20
|
+
/** What the file cannot say about itself: which instance wrote it, and the
|
|
21
|
+
* bounds the request was made with. The file states the selection because the
|
|
22
|
+
* selection decides what is inside it; a bound decides only where it stops,
|
|
23
|
+
* and a reader who wants it is told here. */
|
|
24
|
+
export interface DumpView {
|
|
25
|
+
readonly instance?: string;
|
|
26
|
+
readonly since?: string;
|
|
27
|
+
readonly until?: string;
|
|
28
|
+
/** How much of one item's body is drawn before the rest is reported by its
|
|
29
|
+
* length. Nothing is cut when nobody says: a dump is read to find out what
|
|
30
|
+
* was actually written, and the reader who wants less is the one who knows
|
|
31
|
+
* how much less. */
|
|
32
|
+
readonly max_chars?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const INDENT = " ";
|
|
36
|
+
|
|
37
|
+
export function document(file: SessionDumpFile, view: DumpView = {}): string {
|
|
38
|
+
const items = file.items as unknown as Item[];
|
|
39
|
+
const paired = pair(items);
|
|
40
|
+
const lines: string[] = [...heading(file, view)];
|
|
41
|
+
lines.push("## items", "");
|
|
42
|
+
if (items.length === 0) lines.push("(なし)", "");
|
|
43
|
+
for (let at = 0; at < items.length; at += 1) {
|
|
44
|
+
if (paired.folded.has(at)) continue;
|
|
45
|
+
const item = items[at] as Item;
|
|
46
|
+
const child = paired.child.get(at);
|
|
47
|
+
lines.push(...draw(item, child === undefined ? undefined : (items[child] as Item), view), "");
|
|
48
|
+
}
|
|
49
|
+
lines.push(...ledger(file.ids));
|
|
50
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What this is a dump of, before anything that happened in it. */
|
|
54
|
+
function heading(file: SessionDumpFile, view: DumpView): string[] {
|
|
55
|
+
const subject = file.agent_id === undefined ? file.sid : `${file.sid}/agent-${file.agent_id}`;
|
|
56
|
+
const lines = [`# dump ${subject}`, ""];
|
|
57
|
+
lines.push(`- 対象: \`${subject}\``);
|
|
58
|
+
if (view.instance !== undefined) lines.push(`- instance: \`${view.instance}\``);
|
|
59
|
+
lines.push(`- 書き出し: ${new Date(file.written_at).toISOString()}`);
|
|
60
|
+
lines.push(`- types: ${file.types.map((one) => `\`${one}\``).join(" ") || "(既定)"}`);
|
|
61
|
+
const bounds = words(
|
|
62
|
+
view.since === undefined ? undefined : `since=${view.since}`,
|
|
63
|
+
view.until === undefined ? undefined : `until=${view.until}`,
|
|
64
|
+
);
|
|
65
|
+
if (bounds !== "") lines.push(`- 範囲: ${bounds}`);
|
|
66
|
+
lines.push(`- items: ${String(file.items.length)}`, "");
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One item, with whatever was folded into it.
|
|
71
|
+
*
|
|
72
|
+
* A call keeps its own heading and the answer's words are put at the end of
|
|
73
|
+
* it, so `→` reads as "and this came back". An answer drawn where it arrived
|
|
74
|
+
* points the other way, at a call the reader has already gone past. */
|
|
75
|
+
function draw(item: Item, child: Item | undefined, view: DumpView): string[] {
|
|
76
|
+
const own = fragment(item);
|
|
77
|
+
const answer = child === undefined ? undefined : fragment(child);
|
|
78
|
+
const nested = child !== undefined && child.type.startsWith("message:sub");
|
|
79
|
+
const link = isResult(item)
|
|
80
|
+
? arrow("←", item["parent_item"])
|
|
81
|
+
: (arrow("→", item["result_item"]) ?? (item["role"] === "use" ? "(未着)" : undefined));
|
|
82
|
+
const head = isResult(item)
|
|
83
|
+
? words(prefix(item), link, own.head, clock(item))
|
|
84
|
+
: words(
|
|
85
|
+
prefix(item),
|
|
86
|
+
own.head,
|
|
87
|
+
link,
|
|
88
|
+
nested || answer === undefined ? undefined : answer.head,
|
|
89
|
+
clock(item),
|
|
90
|
+
);
|
|
91
|
+
const under = [
|
|
92
|
+
...body(own.body, view),
|
|
93
|
+
...(answer === undefined || nested ? [] : body(answer.body, view)),
|
|
94
|
+
];
|
|
95
|
+
const lines = [head, ...under.map((line) => `${INDENT}${line}`)];
|
|
96
|
+
if (!nested || child === undefined || answer === undefined) return lines;
|
|
97
|
+
// The agent's answer, under the brief that asked for it. It keeps a heading
|
|
98
|
+
// of its own — it has its own instant, and often a status the brief could
|
|
99
|
+
// not have known — and is indented to say whose answer it is.
|
|
100
|
+
lines.push(`${INDENT}${words(prefix(child), answer.head, clock(child))}`);
|
|
101
|
+
for (const line of body(answer.body, view)) lines.push(`${INDENT}${INDENT}${line}`);
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
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. */
|
|
107
|
+
function prefix(item: Item): string {
|
|
108
|
+
return `[${item.uuid.slice(0, 8)}] ${item.type}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clock(item: Item): string {
|
|
112
|
+
const at = new Date(item.at);
|
|
113
|
+
const time = `${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
|
|
114
|
+
return item.turn === undefined ? time : `${time} turn ${String(item.turn)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function two(value: number): string {
|
|
118
|
+
return String(value).padStart(2, "0");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function arrow(mark: string, id: unknown): string | undefined {
|
|
122
|
+
return typeof id === "string" && id !== "" ? `${mark} ${id.slice(0, 8)}` : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isResult(item: Item): boolean {
|
|
126
|
+
return item["role"] === "result";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The lines under a heading, cut only where a reader asked for a cut. */
|
|
130
|
+
function body(source: readonly string[], view: DumpView): string[] {
|
|
131
|
+
const limit = view.max_chars;
|
|
132
|
+
if (limit === undefined || limit <= 0) return [...source];
|
|
133
|
+
const kept: string[] = [];
|
|
134
|
+
let held = 0;
|
|
135
|
+
for (const line of source) {
|
|
136
|
+
if (held + line.length <= limit) {
|
|
137
|
+
kept.push(line);
|
|
138
|
+
held += line.length + 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const room = Math.max(0, limit - held);
|
|
142
|
+
const rest = source.join("\n").length - held - room;
|
|
143
|
+
if (room > 0) kept.push(line.slice(0, room));
|
|
144
|
+
kept.push(`… (残り ${String(Math.max(rest, 0))} 文字)`);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
return kept;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The ids the items carried, which is what a reader descends by: the agent
|
|
151
|
+
* that did the thing worth copying is named here, and dumping it is the same
|
|
152
|
+
* request with that id as its subject. */
|
|
153
|
+
function ledger(ids: readonly DumpIdEntry[]): string[] {
|
|
154
|
+
const lines = ["## ids", ""];
|
|
155
|
+
if (ids.length === 0) return [...lines, "(なし)"];
|
|
156
|
+
lines.push("| kind | id | label | status |", "|---|---|---|---|");
|
|
157
|
+
for (const entry of ids) {
|
|
158
|
+
const status = words(entry.status, elapsed(entry.duration_ms));
|
|
159
|
+
lines.push(
|
|
160
|
+
`| ${cell(entry.kind)} | \`${cell(entry.id)}\` | ${cell(entry.label ?? "")} | ${cell(status)} |`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function cell(text: string): string {
|
|
167
|
+
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Which answer belongs to which call, and which of those are drawn together.
|
|
171
|
+
*
|
|
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. */
|
|
177
|
+
function pair(items: readonly Item[]): {
|
|
178
|
+
child: Map<number, number>;
|
|
179
|
+
folded: Set<number>;
|
|
180
|
+
} {
|
|
181
|
+
const child = new Map<number, number>();
|
|
182
|
+
const folded = new Set<number>();
|
|
183
|
+
const waiting = new Map<string, number[]>();
|
|
184
|
+
for (let at = 0; at < items.length; at += 1) {
|
|
185
|
+
const item = items[at] as Item;
|
|
186
|
+
// Which half of an exchange this is, which the contract calls an item's
|
|
187
|
+
// 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();
|
|
197
|
+
if (call === undefined) continue;
|
|
198
|
+
// A pair the reader would have to scroll between is left where each half
|
|
199
|
+
// happened, unless it is an agent's: what an agent was asked and what it
|
|
200
|
+
// answered are one exchange whatever fell between them.
|
|
201
|
+
if (!item.type.startsWith("message:sub") && call !== at - 1) continue;
|
|
202
|
+
child.set(call, at);
|
|
203
|
+
folded.add(at);
|
|
204
|
+
}
|
|
205
|
+
return { child, folded };
|
|
206
|
+
}
|
|
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
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { classify } from "./classify.ts";
|
|
2
|
+
export { document, type DumpView } from "./document.ts";
|
|
3
|
+
export type { Item } from "./item.ts";
|
|
4
|
+
export { ledger } from "./ids.ts";
|
|
5
|
+
export { type Fragment, fragment } from "./render.ts";
|
|
6
|
+
export { type Ask, type Selection, select, selection } from "./select.ts";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** One classified item, at the shape the daemon holds it in.
|
|
2
|
+
*
|
|
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;
|
|
23
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Reading values out of a transcript line.
|
|
2
|
+
*
|
|
3
|
+
* A transcript is another program's file: every field is optional until it has
|
|
4
|
+
* been looked at, and a line that says something unexpected is a line to read
|
|
5
|
+
* around rather than to fail on. These are the only place that assumption is
|
|
6
|
+
* spelled out, so the classifier below can read a field and get either the
|
|
7
|
+
* value or nothing. */
|
|
8
|
+
|
|
9
|
+
export type Row = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export function isRow(raw: unknown): raw is Row {
|
|
12
|
+
return typeof raw === "object" && raw !== null && !Array.isArray(raw);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function row(raw: unknown): Row | undefined {
|
|
16
|
+
return isRow(raw) ? raw : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function str(raw: unknown): string | undefined {
|
|
20
|
+
return typeof raw === "string" && raw !== "" ? raw : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function bool(raw: unknown): boolean | undefined {
|
|
24
|
+
return typeof raw === "boolean" ? raw : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function count(raw: unknown): number | undefined {
|
|
28
|
+
return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? Math.round(raw) : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function list(raw: unknown): unknown[] {
|
|
32
|
+
return Array.isArray(raw) ? raw : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** An ISO instant as the milliseconds the contract counts in. A line whose
|
|
36
|
+
* clock is missing or unreadable is placed at zero rather than dropped: when
|
|
37
|
+
* it happened is one fact about the item, and the item is the rest. */
|
|
38
|
+
export function instant(raw: unknown): number {
|
|
39
|
+
if (typeof raw !== "string") return 0;
|
|
40
|
+
const at = Date.parse(raw);
|
|
41
|
+
return Number.isFinite(at) ? Math.max(0, Math.round(at)) : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The text of a `<tag>` in one of the harness's angle-bracket envelopes.
|
|
45
|
+
*
|
|
46
|
+
* The envelopes are written by the harness for a person to read, not parsed
|
|
47
|
+
* back by anything that wrote them, so this reads them the way a person does:
|
|
48
|
+
* the first opening tag to its matching close, with no nesting assumed. */
|
|
49
|
+
export function tagged(text: string, tag: string): string | undefined {
|
|
50
|
+
const open = `<${tag}>`;
|
|
51
|
+
const from = text.indexOf(open);
|
|
52
|
+
if (from < 0) return undefined;
|
|
53
|
+
const to = text.indexOf(`</${tag}>`, from + open.length);
|
|
54
|
+
if (to < 0) return undefined;
|
|
55
|
+
const found = text.slice(from + open.length, to).trim();
|
|
56
|
+
return found === "" ? undefined : found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Fields whose value is `undefined` are left out rather than written as null:
|
|
60
|
+
* the contract's optionals mean absent, and a present null is neither. */
|
|
61
|
+
export function optional<K extends string, V>(
|
|
62
|
+
name: K,
|
|
63
|
+
value: V | undefined,
|
|
64
|
+
): Record<K, V> | Record<string, never> {
|
|
65
|
+
return value === undefined ? {} : ({ [name]: value } as Record<K, V>);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function lines(text: string | undefined): number | undefined {
|
|
69
|
+
if (text === undefined) return undefined;
|
|
70
|
+
return text.split("\n").length;
|
|
71
|
+
}
|