@ccmsg/cli 0.3.5 → 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.5",
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",
@@ -95,10 +95,54 @@ export function registered(env: Env): Target[] {
95
95
  return loadShared(paths.configFile).instances.map((entry) => targetFor(env, entry.dir));
96
96
  }
97
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
+
98
137
  /** Add a config home to the shared file. The settings it will run with are the
99
138
  * defaults until somebody edits its entry, so the entry starts empty — save
100
139
  * for the harness, which is written down when it is not the default because it
101
- * 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. */
102
146
  export function add(env: Env, dir: string, harness: Harness = DEFAULT_HARNESS): InstanceRow {
103
147
  const home = configHome(dir, harness);
104
148
  const file = resolvePaths(env).configFile;
@@ -110,7 +154,11 @@ export function add(env: Env, dir: string, harness: Harness = DEFAULT_HARNESS):
110
154
  dir: home,
111
155
  settings: harness === DEFAULT_HARNESS ? {} : { harness },
112
156
  };
113
- 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] });
114
162
  const target = targetFor(env, home);
115
163
  // The id is made here rather than at the first start, so that what `add`
116
164
  // prints is what the instance will answer to and so that a person can write
@@ -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).
@@ -258,6 +271,11 @@ export const MERGE_RULES: Readonly<Record<string, MergeRule>> = {
258
271
  "upstream.launcher.templates": "replace",
259
272
  "upstream.launcher.clean_env": "replace",
260
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",
261
279
  };
262
280
 
263
281
  function ruleFor(path: string): MergeRule {
@@ -308,9 +326,92 @@ export function parseConfig(file: string, fields: Record<string, unknown>): Inst
308
326
  DEFAULT_CONFIG.direct_delivery,
309
327
  ),
310
328
  fork_origin: flagOf(file, "fork_origin", fields["fork_origin"], DEFAULT_CONFIG.fork_origin),
329
+ dump: dumpOf(file, fields["dump"]),
311
330
  };
312
331
  }
313
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
+
314
415
  function harnessOf(file: string, raw: unknown): Harness {
315
416
  if (raw === undefined) return DEFAULT_HARNESS;
316
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";