@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.3.4",
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.10.0"
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]);
@@ -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
- saveShared(file, { ...shared, instances: [...shared.instances, entry] });
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 {
@@ -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, key by key. A config home the file does not list still
235
- * resolves — `daemon run` on an unregistered directory is the defaults plus
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 { ...shared.defaults, ...entry?.settings };
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)) {
@@ -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
@@ -1,25 +1,47 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { InstanceId, SessionDumpWriteArgs, SessionDumpWriteResult } from "@ccmsg/protocol";
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 { readRecord, type TranscriptFiles, type TranscriptRecord } from "../transcript/index.ts";
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 file = deps.files.session(args.sid);
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
- const entries = collect(text, args);
38
- const document = {
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
- instance: deps.self,
41
- source: file,
42
- generated_at: Date.now(),
43
- entries,
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 path = join(dir, `${args.sid}-${document.generated_at}.json`);
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
- /** One record of a dump: what was said, by whom, when.
99
+ /** The named selection a request asked for.
59
100
  *
60
- * The fields are the record's own, taken from the type that reads a transcript
61
- * line rather than restated here a dump reports what was read, and a second
62
- * spelling of those fields would be a second interpretation of the file. */
63
- type DumpEntry = Pick<TranscriptRecord, "uuid" | "said_at" | "said_by" | "text" | "thinking">;
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 records within the bounds, in the order the transcript holds them.
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
- function collect(text: string, args: SessionDumpWriteArgs): DumpEntry[] {
71
- const entries: DumpEntry[] = [];
72
- // A lower bound by record starts the dump closed: it opens at the record it
73
- // names, which is included.
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 (const line of text.split("\n")) {
76
- if (line === "") continue;
77
- const record = readRecord(line);
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 (record.uuid !== args.since_uuid) continue;
130
+ if (item.uuid !== args.since_uuid) continue;
81
131
  open = true;
82
132
  }
83
- if (args.since_at !== undefined && (record.said_at ?? 0) < args.since_at) continue;
84
- if (args.until_at !== undefined && (record.said_at ?? 0) > args.until_at) break;
85
- // The machinery of in-process agents: their turns interleave into the
86
- // session's own file, and a successor resuming the session is resuming the
87
- // session rather than them.
88
- if (args.no_agent === true && record.sidechain) {
89
- if (record.uuid !== undefined && record.uuid === args.until_uuid) break;
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 entries;
142
+ return kept;
105
143
  }
@@ -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 eight ops that observe and operate on sessions.
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);
@@ -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";