@ccmsg/cli 0.3.4 → 0.4.1
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 +23 -0
- package/src/daemon/registry.ts +61 -3
- package/src/instance/config.ts +161 -5
- package/src/instance/instance.ts +1 -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/ids.ts +0 -0
- package/src/transcript/items/index.ts +4 -0
- package/src/transcript/items/item.ts +23 -0
- package/src/transcript/items/record.ts +71 -0
- package/src/transcript/items/select.ts +144 -0
- package/src/transcript/items/tools.ts +231 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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.12.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/cli.ts
CHANGED
|
@@ -34,11 +34,22 @@ const SESSION_ENV = HARNESSES.flatMap((harness) => [...HARNESS[harness].sessionE
|
|
|
34
34
|
import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
|
|
35
35
|
import {
|
|
36
36
|
isRunning,
|
|
37
|
+
MERGE_RULES,
|
|
37
38
|
resolveConfigHome,
|
|
38
39
|
resolvePaths,
|
|
39
40
|
resolvePathsFor,
|
|
40
41
|
start,
|
|
41
42
|
} from "./instance/index.ts";
|
|
43
|
+
|
|
44
|
+
/** The merge rules as the help prints them: one line per field path, in the
|
|
45
|
+
* order the schema declares them, so the table a person reads is the table the
|
|
46
|
+
* merge runs on. */
|
|
47
|
+
const MERGE_DOCS: readonly Doc[] = Object.entries(MERGE_RULES).map(([path, rule]) => [
|
|
48
|
+
path,
|
|
49
|
+
rule === "merge"
|
|
50
|
+
? "instances[] 側にある field だけを defaults に重ねる"
|
|
51
|
+
: "instances[] 側にあれば丸ごと置換する (追加・和にはならない)",
|
|
52
|
+
]);
|
|
42
53
|
import {
|
|
43
54
|
type Agent,
|
|
44
55
|
AGENTS,
|
|
@@ -98,6 +109,10 @@ interface Command {
|
|
|
98
109
|
readonly usage?: string;
|
|
99
110
|
readonly options?: readonly Doc[];
|
|
100
111
|
readonly env?: readonly Doc[];
|
|
112
|
+
/** Anything else this level has to state as a list of names, under a title of
|
|
113
|
+
* its own. Options and environment are the two every level shares; this is
|
|
114
|
+
* for what only one of them has. */
|
|
115
|
+
readonly notes?: readonly { readonly title: string; readonly docs: readonly Doc[] }[];
|
|
101
116
|
readonly children?: readonly Command[];
|
|
102
117
|
readonly run?: (args: readonly string[]) => Promise<unknown>;
|
|
103
118
|
/** Whether running it with nothing after it is a command rather than a
|
|
@@ -148,6 +163,13 @@ const ROOT: Command = {
|
|
|
148
163
|
`config home が動かすもの: ${HARNESSES.join(" | ")} (既定 ${DEFAULT_HARNESS})`,
|
|
149
164
|
],
|
|
150
165
|
],
|
|
166
|
+
notes: [
|
|
167
|
+
{
|
|
168
|
+
title:
|
|
169
|
+
"共通 config で instances[] の値が defaults に重なる規則 (掲載の無いパスは丸ごと置換):",
|
|
170
|
+
docs: MERGE_DOCS,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
151
173
|
run: (args) => added(args),
|
|
152
174
|
},
|
|
153
175
|
{
|
|
@@ -488,6 +510,7 @@ function help(path: readonly Command[]): string {
|
|
|
488
510
|
}
|
|
489
511
|
lines.push("");
|
|
490
512
|
}
|
|
513
|
+
for (const note of at.notes ?? []) section(lines, note.title, note.docs);
|
|
491
514
|
section(lines, "このレベルのオプション:", at.options);
|
|
492
515
|
section(lines, "グローバルオプション:", GLOBAL_OPTIONS);
|
|
493
516
|
section(lines, "環境変数:", [...(at.env ?? []), ...GLOBAL_ENV]);
|
package/src/daemon/registry.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { basename, isAbsolute, join, resolve } from "node:path";
|
|
|
3
3
|
import type { Endpoint, InstanceId, InstancePingResult } from "@ccmsg/protocol";
|
|
4
4
|
import { DEFAULT_HARNESS, type Harness, HARNESS, isHarness } from "../harness/index.ts";
|
|
5
5
|
import {
|
|
6
|
+
type InstanceConfig,
|
|
6
7
|
type InstanceEntry,
|
|
8
|
+
loadConfig,
|
|
7
9
|
loadShared,
|
|
8
10
|
saveShared,
|
|
9
11
|
settingsFor,
|
|
@@ -61,6 +63,14 @@ export interface InstanceRow {
|
|
|
61
63
|
/** One row of `daemon status`: the list's row, plus what the instance itself
|
|
62
64
|
* says when there is one to ask. */
|
|
63
65
|
export interface StatusRow extends InstanceRow {
|
|
66
|
+
/** What this config home's instance is configured with, after the shared
|
|
67
|
+
* file's defaults and its own entry are merged (§8.2).
|
|
68
|
+
*
|
|
69
|
+
* Answered whether or not anything is running, and read from the file rather
|
|
70
|
+
* than asked of the instance: this is what a restart would apply, which is
|
|
71
|
+
* the question an operator who just edited the file has. It carries no
|
|
72
|
+
* secret — the gateway's token is named by the path it is kept at. */
|
|
73
|
+
readonly config: InstanceConfig;
|
|
64
74
|
readonly version?: string;
|
|
65
75
|
readonly network?: InstancePingResult["network"];
|
|
66
76
|
/** The other instances this one names, each with where it is dialled: the id
|
|
@@ -85,10 +95,54 @@ export function registered(env: Env): Target[] {
|
|
|
85
95
|
return loadShared(paths.configFile).instances.map((entry) => targetFor(env, entry.dir));
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
/** The selections the shared file starts with.
|
|
99
|
+
*
|
|
100
|
+
* Presets are the operator's to name — what one names is an interest, and this
|
|
101
|
+
* instance has no opinion on which interests a person has — so these are
|
|
102
|
+
* written into the file as examples to edit rather than built in. A default
|
|
103
|
+
* that lived in the code would be invisible in the file and would come back
|
|
104
|
+
* after being deleted.
|
|
105
|
+
*
|
|
106
|
+
* They also show the two things a person would otherwise have to be told: that
|
|
107
|
+
* a prefix takes a family, and that `@name` puts one selection inside
|
|
108
|
+
* another. */
|
|
109
|
+
const STARTING_PRESETS = [
|
|
110
|
+
{
|
|
111
|
+
name: "file",
|
|
112
|
+
description: "ファイル操作。読み書きと探索をひとまとめに",
|
|
113
|
+
opts: { types: ["tool:Read", "tool:Write", "tool:Edit", "tool:Glob", "tool:Grep"] },
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "howto",
|
|
117
|
+
description: "調査のノウハウだけ。何を考えて何を叩いて何を読み書きしたか",
|
|
118
|
+
opts: { types: ["thinking", "message:user", "message:sub", "tool:Bash", "@file"] },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "journal",
|
|
122
|
+
description: "日記用。人との往復と worker の答え、思考は要点だけ",
|
|
123
|
+
opts: { types: ["message:user", "message:sub:in", "thinking"] },
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "handoff",
|
|
127
|
+
description: "後継セッションへの引き継ぎ。直近の会話と、走っているものの台帳",
|
|
128
|
+
opts: { types: ["message", "system:task", "ids"] },
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "audit",
|
|
132
|
+
description: "何をしたかの追跡。会話は落として操作と通知だけ",
|
|
133
|
+
opts: { types: ["@file", "tool:Bash", "notice", "ids"] },
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
88
137
|
/** Add a config home to the shared file. The settings it will run with are the
|
|
89
138
|
* defaults until somebody edits its entry, so the entry starts empty — save
|
|
90
139
|
* for the harness, which is written down when it is not the default because it
|
|
91
|
-
* is the one setting the directory itself cannot be asked for (§3.8).
|
|
140
|
+
* is the one setting the directory itself cannot be asked for (§3.8).
|
|
141
|
+
*
|
|
142
|
+
* The dump presets above go to `defaults`, and only where the file names none:
|
|
143
|
+
* they are the same for every instance and are examples to edit, so writing
|
|
144
|
+
* them per entry would repeat them and re-adding a config home would bring
|
|
145
|
+
* back what somebody deleted. */
|
|
92
146
|
export function add(env: Env, dir: string, harness: Harness = DEFAULT_HARNESS): InstanceRow {
|
|
93
147
|
const home = configHome(dir, harness);
|
|
94
148
|
const file = resolvePaths(env).configFile;
|
|
@@ -100,7 +154,11 @@ export function add(env: Env, dir: string, harness: Harness = DEFAULT_HARNESS):
|
|
|
100
154
|
dir: home,
|
|
101
155
|
settings: harness === DEFAULT_HARNESS ? {} : { harness },
|
|
102
156
|
};
|
|
103
|
-
|
|
157
|
+
const defaults =
|
|
158
|
+
shared.defaults["dump"] === undefined
|
|
159
|
+
? { ...shared.defaults, dump: { presets: STARTING_PRESETS } }
|
|
160
|
+
: shared.defaults;
|
|
161
|
+
saveShared(file, { defaults, instances: [...shared.instances, entry] });
|
|
104
162
|
const target = targetFor(env, home);
|
|
105
163
|
// The id is made here rather than at the first start, so that what `add`
|
|
106
164
|
// prints is what the instance will answer to and so that a person can write
|
|
@@ -155,7 +213,7 @@ export function list(env: Env): InstanceRow[] {
|
|
|
155
213
|
/** Ask one instance how it is. A config home with nothing behind it answers the
|
|
156
214
|
* list's row and nothing more: not running is a state, not a failure. */
|
|
157
215
|
export async function status(target: Target): Promise<StatusRow> {
|
|
158
|
-
const row = rowFor(target);
|
|
216
|
+
const row = { ...rowFor(target), config: loadConfig(target.paths.configFile, target.dir) };
|
|
159
217
|
const conn = await connect(target.paths.socket);
|
|
160
218
|
if (conn === undefined) return row;
|
|
161
219
|
try {
|
package/src/instance/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute } from "node:path";
|
|
3
|
-
import type { Endpoint } from "@ccmsg/protocol";
|
|
3
|
+
import type { DumpPreset, Endpoint } from "@ccmsg/protocol";
|
|
4
4
|
import { DEFAULT_HARNESS, type Harness, HARNESSES, isHarness } from "../harness/index.ts";
|
|
5
5
|
import { parseCidr } from "./client.ts";
|
|
6
6
|
|
|
@@ -91,6 +91,17 @@ export interface UpstreamConfig {
|
|
|
91
91
|
readonly sandbox_origin?: string;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** The named selections a person dumps by.
|
|
95
|
+
*
|
|
96
|
+
* Configured rather than fixed in the contract because what a preset names is
|
|
97
|
+
* an interest — how the work was done, what to hand over — and an interest is
|
|
98
|
+
* not a property of the wire. A type name stays one to one with what a record
|
|
99
|
+
* is, and the groupings people reach for are made by naming a set of them. */
|
|
100
|
+
export interface DumpConfig {
|
|
101
|
+
/** In configured order, which is the order `dump_presets_read` answers in. */
|
|
102
|
+
readonly presets: readonly DumpPreset[];
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
export interface InstanceConfig {
|
|
95
106
|
/** Which harness this config home runs (§3.8).
|
|
96
107
|
*
|
|
@@ -118,6 +129,7 @@ export interface InstanceConfig {
|
|
|
118
129
|
* one that does not never pays for it. The `fork` capability follows this,
|
|
119
130
|
* so a client learns which it is from `hello`. */
|
|
120
131
|
readonly fork_origin: boolean;
|
|
132
|
+
readonly dump: DumpConfig;
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
/** A config file that could not be understood.
|
|
@@ -146,6 +158,7 @@ export const DEFAULT_CONFIG: InstanceConfig = {
|
|
|
146
158
|
upstream: {},
|
|
147
159
|
direct_delivery: true,
|
|
148
160
|
fork_origin: false,
|
|
161
|
+
dump: { presets: [] },
|
|
149
162
|
};
|
|
150
163
|
|
|
151
164
|
/** Read the config, once, at startup (DV-Q8).
|
|
@@ -230,13 +243,73 @@ export function saveShared(file: string, shared: SharedConfig): void {
|
|
|
230
243
|
writeFileSync(file, `${JSON.stringify({ defaults: shared.defaults, instances }, null, 2)}\n`);
|
|
231
244
|
}
|
|
232
245
|
|
|
246
|
+
/** How one field of the shared file combines an instance's entry with the
|
|
247
|
+
* defaults.
|
|
248
|
+
*
|
|
249
|
+
* `merge` takes the two field by field, so an instance states only what it
|
|
250
|
+
* differs in; `replace` takes the instance's value whole. */
|
|
251
|
+
export type MergeRule = "merge" | "replace";
|
|
252
|
+
|
|
253
|
+
/** The rule for every field path that holds an object or an array, which are
|
|
254
|
+
* the only ones where "combine" could mean more than one thing.
|
|
255
|
+
*
|
|
256
|
+
* Declared beside the parsers rather than derived from the values, because
|
|
257
|
+
* whether a list is a sequence or a set is a fact about what the field means
|
|
258
|
+
* and every list looks the same without it. A path not named here replaces:
|
|
259
|
+
* that is what a scalar can do, and it is what an array does until some field
|
|
260
|
+
* is a set and says so. */
|
|
261
|
+
export const MERGE_RULES: Readonly<Record<string, MergeRule>> = {
|
|
262
|
+
// The same finished list goes to every instance (§7.1), so an instance that
|
|
263
|
+
// writes its own means to run with that one and no other.
|
|
264
|
+
peers: "replace",
|
|
265
|
+
entry: "merge",
|
|
266
|
+
"entry.source_ips": "replace",
|
|
267
|
+
"entry.trusted_proxies": "replace",
|
|
268
|
+
upstream: "merge",
|
|
269
|
+
"upstream.launcher": "merge",
|
|
270
|
+
"upstream.launcher.root_dirs": "replace",
|
|
271
|
+
"upstream.launcher.templates": "replace",
|
|
272
|
+
"upstream.launcher.clean_env": "replace",
|
|
273
|
+
"upstream.launcher.keep_env": "replace",
|
|
274
|
+
dump: "merge",
|
|
275
|
+
// A preset list is a whole vocabulary: an instance that names its own means
|
|
276
|
+
// to dump by those and not by the defaults' as well, since a name it did not
|
|
277
|
+
// write could shadow or be referenced by one it did.
|
|
278
|
+
"dump.presets": "replace",
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
function ruleFor(path: string): MergeRule {
|
|
282
|
+
return MERGE_RULES[path] ?? "replace";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function plainObject(raw: unknown): raw is Record<string, unknown> {
|
|
286
|
+
return typeof raw === "object" && raw !== null && !Array.isArray(raw);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function merged(
|
|
290
|
+
base: Record<string, unknown>,
|
|
291
|
+
over: Record<string, unknown>,
|
|
292
|
+
at: string,
|
|
293
|
+
): Record<string, unknown> {
|
|
294
|
+
const out: Record<string, unknown> = { ...base };
|
|
295
|
+
for (const [name, value] of Object.entries(over)) {
|
|
296
|
+
const path = at === "" ? name : `${at}.${name}`;
|
|
297
|
+
const under = out[name];
|
|
298
|
+
out[name] =
|
|
299
|
+
ruleFor(path) === "merge" && plainObject(under) && plainObject(value)
|
|
300
|
+
? merged(under, value, path)
|
|
301
|
+
: value;
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
233
306
|
/** What one config home's instance is configured with: its own entry over the
|
|
234
|
-
* shared defaults,
|
|
235
|
-
* resolves — `daemon run` on an unregistered directory is
|
|
236
|
-
* the built-ins. */
|
|
307
|
+
* shared defaults, by the rule each field path declares. A config home the file
|
|
308
|
+
* does not list still resolves — `daemon run` on an unregistered directory is
|
|
309
|
+
* the defaults plus the built-ins. */
|
|
237
310
|
export function settingsFor(shared: SharedConfig, dir: string): Record<string, unknown> {
|
|
238
311
|
const entry = shared.instances.find((one) => one.dir === dir);
|
|
239
|
-
return
|
|
312
|
+
return merged(shared.defaults, entry?.settings ?? {}, "");
|
|
240
313
|
}
|
|
241
314
|
|
|
242
315
|
/** One instance's settings, read at the shape the instance uses them. */
|
|
@@ -253,9 +326,92 @@ export function parseConfig(file: string, fields: Record<string, unknown>): Inst
|
|
|
253
326
|
DEFAULT_CONFIG.direct_delivery,
|
|
254
327
|
),
|
|
255
328
|
fork_origin: flagOf(file, "fork_origin", fields["fork_origin"], DEFAULT_CONFIG.fork_origin),
|
|
329
|
+
dump: dumpOf(file, fields["dump"]),
|
|
256
330
|
};
|
|
257
331
|
}
|
|
258
332
|
|
|
333
|
+
/** One element of a selection, as the contract spells it: a type name, a
|
|
334
|
+
* prefix of one, either negated with `-`, or `@name` for a preset. */
|
|
335
|
+
const SELECTOR = /^-?(?:@[A-Za-z0-9][A-Za-z0-9_-]*|[a-z]+(?::[A-Za-z0-9_.-]+)*)$/;
|
|
336
|
+
|
|
337
|
+
function dumpOf(file: string, raw: unknown): DumpConfig {
|
|
338
|
+
if (raw === undefined) return { presets: [] };
|
|
339
|
+
const fields = objectOf(file, "dump", raw);
|
|
340
|
+
const presets = presetsOf(file, fields["presets"]);
|
|
341
|
+
// A reference is resolved here rather than at each dump: a cycle or a name
|
|
342
|
+
// nobody configured would otherwise be found once per request, long after
|
|
343
|
+
// the file that holds the mistake was last looked at.
|
|
344
|
+
for (const preset of presets) resolvable(file, preset, presets, []);
|
|
345
|
+
return { presets };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function presetsOf(file: string, raw: unknown): DumpPreset[] {
|
|
349
|
+
if (raw === undefined) return [];
|
|
350
|
+
if (!Array.isArray(raw)) {
|
|
351
|
+
throw new ConfigError(file, "dump.presets must be an array of named selections");
|
|
352
|
+
}
|
|
353
|
+
const names = new Set<string>();
|
|
354
|
+
return raw.map((entry, index) => {
|
|
355
|
+
const at = `dump.presets[${index}]`;
|
|
356
|
+
const fields = objectOf(file, at, entry);
|
|
357
|
+
const name = fields["name"];
|
|
358
|
+
if (typeof name !== "string" || name === "") {
|
|
359
|
+
throw new ConfigError(file, `${at}.name must be a name for the selection`);
|
|
360
|
+
}
|
|
361
|
+
if (names.has(name)) throw new ConfigError(file, `${at}.name repeats ${name}`);
|
|
362
|
+
names.add(name);
|
|
363
|
+
const description = fields["description"];
|
|
364
|
+
if (description !== undefined && typeof description !== "string") {
|
|
365
|
+
throw new ConfigError(file, `${at}.description must be a string`);
|
|
366
|
+
}
|
|
367
|
+
const opts = objectOf(file, `${at}.opts`, fields["opts"]);
|
|
368
|
+
const types = stringsOf(file, `${at}.opts.types`, opts["types"]);
|
|
369
|
+
const wrong = types.filter((element) => !SELECTOR.test(element));
|
|
370
|
+
if (wrong.length > 0) {
|
|
371
|
+
throw new ConfigError(
|
|
372
|
+
file,
|
|
373
|
+
`${at}.opts.types must be item types, prefixes, exclusions or @presets, got ${wrong.join(", ")}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
name,
|
|
378
|
+
...(description === undefined ? {} : { description }),
|
|
379
|
+
opts: { types: [...types] },
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Every `@name` a preset reaches, down through the presets it names.
|
|
385
|
+
*
|
|
386
|
+
* The path is carried so a cycle is named where it closes rather than as a
|
|
387
|
+
* stack that ran out — an operator reading the refusal has to be able to find
|
|
388
|
+
* which two presets point at each other. */
|
|
389
|
+
function resolvable(
|
|
390
|
+
file: string,
|
|
391
|
+
preset: DumpPreset,
|
|
392
|
+
presets: readonly DumpPreset[],
|
|
393
|
+
path: readonly string[],
|
|
394
|
+
): void {
|
|
395
|
+
if (path.includes(preset.name)) {
|
|
396
|
+
throw new ConfigError(
|
|
397
|
+
file,
|
|
398
|
+
`dump.presets reference each other in a cycle: ${[...path, preset.name].join(" -> ")}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
for (const element of preset.opts.types) {
|
|
402
|
+
const name = element.startsWith("-") ? element.slice(1) : element;
|
|
403
|
+
if (!name.startsWith("@")) continue;
|
|
404
|
+
const referenced = presets.find((one) => one.name === name.slice(1));
|
|
405
|
+
if (referenced === undefined) {
|
|
406
|
+
throw new ConfigError(
|
|
407
|
+
file,
|
|
408
|
+
`dump.presets[${preset.name}] names ${name}, which is not configured`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
resolvable(file, referenced, presets, [...path, preset.name]);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
259
415
|
function harnessOf(file: string, raw: unknown): Harness {
|
|
260
416
|
if (raw === undefined) return DEFAULT_HARNESS;
|
|
261
417
|
if (!isHarness(raw)) {
|
package/src/instance/instance.ts
CHANGED
|
@@ -616,6 +616,7 @@ export class Instance {
|
|
|
616
616
|
hostProcessDeps(() => this.#sessions.rowsNow(), config.upstream.terminal_gateway),
|
|
617
617
|
),
|
|
618
618
|
forget: (sid) => this.#sessions.forget(sid),
|
|
619
|
+
presets: config.dump.presets,
|
|
619
620
|
}),
|
|
620
621
|
// The sandbox ops answer only where an origin is configured. Without one
|
|
621
622
|
// there is nothing to serve a minted URL, and dispatch already refuses
|
package/src/sessions/dump.ts
CHANGED
|
@@ -1,25 +1,47 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
DumpPreset,
|
|
5
|
+
InstanceId,
|
|
6
|
+
SessionDumpFile,
|
|
7
|
+
SessionDumpWriteArgs,
|
|
8
|
+
SessionDumpWriteResult,
|
|
9
|
+
} from "@ccmsg/protocol";
|
|
4
10
|
import { OpError } from "../dispatch/index.ts";
|
|
5
|
-
import {
|
|
11
|
+
import { classify, type Item, ledger, select, selection } from "../transcript/items/index.ts";
|
|
12
|
+
import type { TranscriptFiles } from "../transcript/index.ts";
|
|
6
13
|
|
|
7
14
|
/** Where dumps land: one directory under this instance's own state, named
|
|
8
15
|
* after the config home it answers for like every other per-instance path
|
|
9
16
|
* (§8.1). The caller never supplies a path, so there is none to contain. */
|
|
10
17
|
export const DUMPS = "dumps";
|
|
11
18
|
|
|
19
|
+
/** What a dump file is called. Two extensions rather than one so that a reader
|
|
20
|
+
* knows both that it is JSON and that it is JSON of a shape the contract
|
|
21
|
+
* states — the file travels by its path, outliving the request that made it,
|
|
22
|
+
* and is opened by whoever was handed that path. */
|
|
23
|
+
export const DUMP_SUFFIX = ".dump.json";
|
|
24
|
+
|
|
12
25
|
export interface DumpDeps {
|
|
13
26
|
readonly self: InstanceId;
|
|
14
27
|
readonly stateDir: string;
|
|
15
28
|
readonly files: TranscriptFiles;
|
|
29
|
+
/** The selections this instance is configured with, which is what a `preset`
|
|
30
|
+
* name and an `@name` inside a selection are resolved against. */
|
|
31
|
+
readonly presets: readonly DumpPreset[];
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
/** Write a session's dump and answer with where it went.
|
|
19
35
|
*
|
|
20
36
|
* What this adds over reading the transcript is a durable artifact whose path
|
|
21
37
|
* can be handed to a successor session, rather than a payload that would
|
|
22
|
-
* travel out through a client and back in again.
|
|
38
|
+
* travel out through a client and back in again.
|
|
39
|
+
*
|
|
40
|
+
* The subject is the session, or one agent below it when the request names
|
|
41
|
+
* one. Every item type is read from wherever the subject stands — an agent's
|
|
42
|
+
* `message:user:in` is the brief its parent gave it — so one selection carries
|
|
43
|
+
* unchanged down a chain of agents, which is what makes the ledger's agent ids
|
|
44
|
+
* a way to descend rather than just a list. */
|
|
23
45
|
export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDumpWriteResult {
|
|
24
46
|
if (args.since_at !== undefined && args.since_uuid !== undefined) {
|
|
25
47
|
throw new OpError("invalid_args", "a lower bound is a time or a record, not both");
|
|
@@ -27,79 +49,95 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
27
49
|
if (args.until_at !== undefined && args.until_uuid !== undefined) {
|
|
28
50
|
throw new OpError("invalid_args", "an upper bound is a time or a record, not both");
|
|
29
51
|
}
|
|
30
|
-
const
|
|
52
|
+
const preset = presetFor(args.preset, deps.presets);
|
|
53
|
+
const file = deps.files.locate(
|
|
54
|
+
args.sid,
|
|
55
|
+
args.agent_id === undefined ? {} : { agent_id: args.agent_id },
|
|
56
|
+
);
|
|
31
57
|
let text: string;
|
|
32
58
|
try {
|
|
33
59
|
text = readFileSync(file, "utf8");
|
|
34
60
|
} catch {
|
|
35
61
|
throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
|
|
36
62
|
}
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
// The whole file is classified before the range is applied, so a result
|
|
64
|
+
// inside the range still names the call that fell before it. Cutting first
|
|
65
|
+
// would leave `parent_item` pointing at something the reader never saw.
|
|
66
|
+
const keep = selection(
|
|
67
|
+
{
|
|
68
|
+
...(args.types === undefined ? {} : { types: args.types }),
|
|
69
|
+
...(preset === undefined ? {} : { preset }),
|
|
70
|
+
...(args.no_thinking === undefined ? {} : { no_thinking: args.no_thinking }),
|
|
71
|
+
...(args.no_agent === undefined ? {} : { no_agent: args.no_agent }),
|
|
72
|
+
},
|
|
73
|
+
deps.presets,
|
|
74
|
+
);
|
|
75
|
+
const { items, entries } = select(within(classify(text.split("\n")), args), keep);
|
|
76
|
+
const ids = ledger(items);
|
|
77
|
+
const written_at = Date.now();
|
|
78
|
+
// The file repeats what it was asked for. A dump outlives the request that
|
|
79
|
+
// made it and is read by whoever was handed the path, so it has to say on
|
|
80
|
+
// its own what it is a dump of and what was left out — which is why the
|
|
81
|
+
// selection is written as applied, with the presets already expanded.
|
|
82
|
+
const document: SessionDumpFile = {
|
|
39
83
|
sid: args.sid,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
84
|
+
...(args.agent_id === undefined ? {} : { agent_id: args.agent_id }),
|
|
85
|
+
written_at,
|
|
86
|
+
types: [...keep.elements],
|
|
87
|
+
items,
|
|
88
|
+
ids,
|
|
44
89
|
};
|
|
45
90
|
const dir = join(deps.stateDir, DUMPS);
|
|
46
91
|
mkdirSync(dir, { recursive: true });
|
|
47
|
-
const
|
|
92
|
+
const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
|
|
93
|
+
const path = join(dir, `${named}-${written_at}${DUMP_SUFFIX}`);
|
|
48
94
|
const body = `${JSON.stringify(document, undefined, 2)}\n`;
|
|
49
95
|
writeFileSync(path, body);
|
|
50
|
-
return {
|
|
51
|
-
path,
|
|
52
|
-
instance: deps.self,
|
|
53
|
-
entries: entries.length,
|
|
54
|
-
bytes: Buffer.byteLength(body),
|
|
55
|
-
};
|
|
96
|
+
return { path, instance: deps.self, entries, ids, bytes: Buffer.byteLength(body) };
|
|
56
97
|
}
|
|
57
98
|
|
|
58
|
-
/**
|
|
99
|
+
/** The named selection a request asked for.
|
|
59
100
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
101
|
+
* A name this instance does not have is refused rather than ignored: a dump
|
|
102
|
+
* silently wider than what was asked for is the failure a selection exists to
|
|
103
|
+
* prevent. */
|
|
104
|
+
function presetFor(
|
|
105
|
+
name: string | undefined,
|
|
106
|
+
presets: readonly DumpPreset[],
|
|
107
|
+
): DumpPreset | undefined {
|
|
108
|
+
if (name === undefined) return undefined;
|
|
109
|
+
const found = presets.find((one) => one.name === name);
|
|
110
|
+
if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
64
113
|
|
|
65
|
-
/** The
|
|
114
|
+
/** The items within the bounds, in the order the transcript holds them.
|
|
66
115
|
*
|
|
67
116
|
* A record bound cuts at that record's position rather than at its clock, so
|
|
68
117
|
* records sharing an instant stay on their own side of the cut — which is the
|
|
69
|
-
* whole reason the contract offers both kinds of bound.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
74
125
|
let open = args.since_uuid === undefined;
|
|
75
|
-
for (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (record === undefined) continue;
|
|
126
|
+
for (let at = 0; at < items.length; at += 1) {
|
|
127
|
+
const item = items[at];
|
|
128
|
+
if (item === undefined) continue;
|
|
79
129
|
if (!open) {
|
|
80
|
-
if (
|
|
130
|
+
if (item.uuid !== args.since_uuid) continue;
|
|
81
131
|
open = true;
|
|
82
132
|
}
|
|
83
|
-
if (args.since_at !== undefined &&
|
|
84
|
-
if (args.until_at !== undefined &&
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
if (args.
|
|
89
|
-
if (
|
|
90
|
-
continue;
|
|
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;
|
|
91
140
|
}
|
|
92
|
-
entries.push({
|
|
93
|
-
...(record.uuid === undefined ? {} : { uuid: record.uuid }),
|
|
94
|
-
...(record.said_at === undefined ? {} : { said_at: record.said_at }),
|
|
95
|
-
...(record.said_by === undefined ? {} : { said_by: record.said_by }),
|
|
96
|
-
...(record.text === undefined ? {} : { text: record.text }),
|
|
97
|
-
...(args.no_thinking === true || record.thinking === undefined
|
|
98
|
-
? {}
|
|
99
|
-
: { thinking: record.thinking }),
|
|
100
|
-
});
|
|
101
|
-
// An upper bound by record is inclusive, so the cut is after it.
|
|
102
|
-
if (record.uuid !== undefined && record.uuid === args.until_uuid) break;
|
|
103
141
|
}
|
|
104
|
-
return
|
|
142
|
+
return kept;
|
|
105
143
|
}
|
package/src/sessions/handlers.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Capability,
|
|
3
|
+
type DumpPreset,
|
|
4
|
+
type DumpPresetsReadResult,
|
|
3
5
|
type InstanceId,
|
|
4
6
|
type SessionDumpWriteArgs,
|
|
5
7
|
type SessionEnvReadArgs,
|
|
@@ -57,9 +59,11 @@ export interface SessionOpsDeps {
|
|
|
57
59
|
* instance last saw them. The sessions domain owns the list; this op only
|
|
58
60
|
* asks it to forget a row. */
|
|
59
61
|
readonly forget: (sid: Sid) => boolean;
|
|
62
|
+
/** The named selections this instance is configured with (§3.6). */
|
|
63
|
+
readonly presets: readonly DumpPreset[];
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/** The
|
|
66
|
+
/** The ops that observe and operate on sessions.
|
|
63
67
|
*
|
|
64
68
|
* None of them decides who may call it: dispatch has settled that from the
|
|
65
69
|
* attribute table. The one that narrows by role is `transcript_read`, and it
|
|
@@ -104,8 +108,17 @@ export function sessionHandlers(deps: SessionOpsDeps) {
|
|
|
104
108
|
self: deps.self,
|
|
105
109
|
stateDir: deps.stateDir,
|
|
106
110
|
files: deps.files,
|
|
111
|
+
presets: deps.presets,
|
|
107
112
|
}),
|
|
108
113
|
|
|
114
|
+
/** Which selections a dump may be asked for by name.
|
|
115
|
+
*
|
|
116
|
+
* Nothing else states them, so a client without this could only offer a
|
|
117
|
+
* free-text field and let the instance refuse. A preset that references
|
|
118
|
+
* another is answered as written: the expansion, and the refusal of a
|
|
119
|
+
* cycle, happen where the config is read. */
|
|
120
|
+
dump_presets_read: (): DumpPresetsReadResult => ({ presets: [...deps.presets] }),
|
|
121
|
+
|
|
109
122
|
session_fork_origin: (input: HandlerInput): SessionForkOriginResult => {
|
|
110
123
|
const args = input.args as unknown as SessionForkOriginArgs;
|
|
111
124
|
const origin = forkOrigin(args.sid, deps.files);
|
package/src/sessions/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from "./classify.ts";
|
|
2
|
-
export { DUMPS, dumpWrite } from "./dump.ts";
|
|
2
|
+
export { DUMP_SUFFIX, DUMPS, dumpWrite } from "./dump.ts";
|
|
3
3
|
export { forkOrigin } from "./fork.ts";
|
|
4
4
|
export * from "./harness.ts";
|
|
5
5
|
export { sessionCapabilities, sessionHandlers, type SessionOpsDeps } from "./handlers.ts";
|