@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.3.5",
3
+ "version": "0.4.2",
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
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env bun
2
- import { type MessageSendArgs, type NotifySendArgs, PROTOCOL_VERSION } from "@ccmsg/protocol";
2
+ import {
3
+ type MessageSendArgs,
4
+ type NotifySendArgs,
5
+ PROTOCOL_VERSION,
6
+ type SessionDumpFile,
7
+ type SessionDumpWriteArgs,
8
+ type SessionDumpWriteResult,
9
+ } from "@ccmsg/protocol";
3
10
  import {
4
11
  add as addToConfig,
5
12
  ask,
@@ -24,8 +31,10 @@ import {
24
31
  type Target,
25
32
  targetFor,
26
33
  } from "./daemon/index.ts";
34
+ import { readFileSync, writeFileSync } from "node:fs";
27
35
  import { homedir } from "node:os";
28
36
  import { isAbsolute, join } from "node:path";
37
+ import { document } from "./transcript/items/index.ts";
29
38
  import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "./harness/index.ts";
30
39
 
31
40
  /** The variables a session is named by, for the help and for the message a
@@ -357,6 +366,40 @@ const ROOT: Command = {
357
366
  ],
358
367
  run: (args) => agents(args),
359
368
  },
369
+ {
370
+ name: "dump",
371
+ summary: "セッション (か配下の worker 1 体) の transcript を型ごとの表示で書き出す",
372
+ usage: "ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>]",
373
+ options: [
374
+ ["--preset <名前>", "instance が持つ選択 (ccmsg dump presets で一覧)"],
375
+ ["--types <選択>", "型をカンマ区切りで。prefix 可、-で除外、@名前で preset 展開"],
376
+ ["--since <at|uuid>", "下限。時刻 (ISO か epoch ミリ秒) か record の uuid"],
377
+ ["--until <at|uuid>", "上限。同上"],
378
+ ["--max-chars <n>", "1 アイテムの本文をこの文字数で切る (既定は切らない)"],
379
+ ["--json", "markdown ではなく dump file の中身をそのまま出す"],
380
+ ["--out <path>", "標準出力ではなくこの path に書く"],
381
+ ],
382
+ notes: [
383
+ {
384
+ title: "別のセッションのやり方を読む:",
385
+ docs: [
386
+ ["1", "ccmsg dump <sid> --preset howto で親を読む"],
387
+ ["2", "末尾の ids 台帳から良さそうな worker の agent id を選ぶ"],
388
+ ["3", "ccmsg dump <sid>/agent-<id> --preset howto で主語を移して掘る"],
389
+ ],
390
+ },
391
+ ],
392
+ children: [
393
+ {
394
+ name: "presets",
395
+ summary: "この instance が持つ preset の名前と中身を並べる",
396
+ usage: "ccmsg dump presets",
397
+ bare: true,
398
+ run: () => instanceAsk({ op: "dump_presets_read" }),
399
+ },
400
+ ],
401
+ run: (args) => dump(args),
402
+ },
360
403
  {
361
404
  name: "post",
362
405
  summary: "別のセッションへメッセージを送る",
@@ -857,6 +900,115 @@ async function topic(
857
900
  }
858
901
  }
859
902
 
903
+ /** `ccmsg dump <sid>[/agent-<id>]`: read how a session worked.
904
+ *
905
+ * The instance writes the file — it is the one that can read a transcript, and
906
+ * a path that outlives the request is the point of the op — and this reads it
907
+ * back and draws it. Which means the two halves stay where they belong: what
908
+ * an item is settled by whoever read the file, and how an item reads is
909
+ * settled here, where somebody is looking at it.
910
+ *
911
+ * `--json` hands over the file as it stands, for a reader that is a program. */
912
+ async function dump(args: readonly string[]): Promise<unknown> {
913
+ const parsed = options(args, ["preset", "types", "since", "until", "out", "max-chars"], ["json"]);
914
+ const subject = parsed.rest[0];
915
+ if (subject === undefined) {
916
+ throw new CommandError(
917
+ "invalid_args",
918
+ "使い方: ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>]",
919
+ );
920
+ }
921
+ const written = (await instanceAsk({
922
+ op: "session_dump_write",
923
+ ...dumpArgs(subject, parsed.named),
924
+ })) as unknown as SessionDumpWriteResult;
925
+ const body = readFileSync(written.path, "utf8");
926
+ const since = parsed.named.get("since");
927
+ const until = parsed.named.get("until");
928
+ const limit = parsed.named.get("max-chars");
929
+ const text = parsed.flags.has("json")
930
+ ? body
931
+ : document(JSON.parse(body) as SessionDumpFile, {
932
+ instance: written.instance,
933
+ ...(since === undefined ? {} : { since }),
934
+ ...(until === undefined ? {} : { until }),
935
+ ...(limit === undefined ? {} : { max_chars: chars(limit) }),
936
+ });
937
+ const out = parsed.named.get("out");
938
+ if (out === undefined) {
939
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
940
+ return undefined;
941
+ }
942
+ writeFileSync(out, text);
943
+ return {
944
+ path: out,
945
+ bytes: Buffer.byteLength(text),
946
+ dump: written.path,
947
+ instance: written.instance,
948
+ entries: written.entries,
949
+ ids: written.ids,
950
+ };
951
+ }
952
+
953
+ /** What a person typed, as the op's arguments.
954
+ *
955
+ * `<sid>/agent-<id>` is split here and nowhere else: the contract keeps the
956
+ * two apart so that a sid stays a validated sid, and the joined spelling is
957
+ * the CLI's own convenience — it is how the file the agent's records live in
958
+ * is named, which is what makes the two halves tellable apart by eye. */
959
+ export function dumpArgs(
960
+ subject: string,
961
+ named: ReadonlyMap<string, string> = new Map(),
962
+ ): SessionDumpWriteArgs {
963
+ const at = subject.indexOf(AGENT_MARK);
964
+ const sid = at === -1 ? subject : subject.slice(0, at);
965
+ const agent = at === -1 ? undefined : subject.slice(at + AGENT_MARK.length);
966
+ const types = named.get("types");
967
+ const preset = named.get("preset");
968
+ return {
969
+ sid,
970
+ ...(agent === undefined || agent === "" ? {} : { agent_id: agent }),
971
+ ...(preset === undefined ? {} : { preset }),
972
+ ...(types === undefined
973
+ ? {}
974
+ : {
975
+ types: types
976
+ .split(",")
977
+ .map((one) => one.trim())
978
+ .filter((one) => one !== ""),
979
+ }),
980
+ ...bound("since", named.get("since")),
981
+ ...bound("until", named.get("until")),
982
+ };
983
+ }
984
+
985
+ const AGENT_MARK = "/agent-";
986
+
987
+ /** One bound, as whichever of the two kinds it was written in.
988
+ *
989
+ * A time and a record id cannot be confused for one another — one parses as a
990
+ * moment and the other does not — so the caller writes what they have rather
991
+ * than saying which it is. */
992
+ function bound(kind: "since" | "until", value: string | undefined): Record<string, unknown> {
993
+ if (value === undefined || value === "") return {};
994
+ const at = moment(value);
995
+ return at === undefined ? { [`${kind}_uuid`]: value } : { [`${kind}_at`]: at };
996
+ }
997
+
998
+ function moment(value: string): number | undefined {
999
+ if (/^\d+$/.test(value)) return Number(value);
1000
+ const parsed = Date.parse(value);
1001
+ return Number.isNaN(parsed) ? undefined : parsed;
1002
+ }
1003
+
1004
+ function chars(value: string): number {
1005
+ const limit = Number(value);
1006
+ if (!Number.isInteger(limit) || limit <= 0) {
1007
+ throw new CommandError("invalid_args", "--max-chars は 1 以上の整数です");
1008
+ }
1009
+ return limit;
1010
+ }
1011
+
860
1012
  /** `ccmsg post <sid> <text>`: start a conversation with another session. */
861
1013
  function post(args: readonly string[]): Promise<unknown> {
862
1014
  const parsed = options(args, ["sid"]);
@@ -1106,6 +1258,26 @@ async function call(
1106
1258
  `自分のセッション ID が分かりません (--sid か ${SESSION_ENV.join(" / ")})`,
1107
1259
  );
1108
1260
  }
1261
+ return await exchange(
1262
+ { op: "hello", role: "session", sid, protocol_version: PROTOCOL_VERSION, ...meta },
1263
+ request,
1264
+ );
1265
+ }
1266
+
1267
+ /** One op, spoken as the person at the keyboard.
1268
+ *
1269
+ * Which is who is asking: reading a transcript is not something a session is a
1270
+ * party to, and the ops that do it are open to a person and to nobody else. */
1271
+ function instanceAsk(request: Record<string, unknown>): Promise<unknown> {
1272
+ return exchange({ op: "hello", role: "user", protocol_version: PROTOCOL_VERSION }, request);
1273
+ }
1274
+
1275
+ /** Greet this config home's instance, ask it one thing, and answer with what
1276
+ * it said. */
1277
+ async function exchange(
1278
+ greeting: Record<string, unknown>,
1279
+ request: Record<string, unknown>,
1280
+ ): Promise<unknown> {
1109
1281
  const paths = resolvePaths();
1110
1282
  const conn = await connect(paths.socket);
1111
1283
  if (conn === undefined) {
@@ -1115,15 +1287,9 @@ async function call(
1115
1287
  );
1116
1288
  }
1117
1289
  try {
1118
- const greeting = await conn.ask({
1119
- op: "hello",
1120
- role: "session",
1121
- sid,
1122
- protocol_version: PROTOCOL_VERSION,
1123
- ...meta,
1124
- });
1125
- if (greeting["ok"] !== true) {
1126
- throw new CommandError("forbidden", `hello が拒否されました: ${JSON.stringify(greeting)}`);
1290
+ const greeted = await conn.ask(greeting);
1291
+ if (greeted["ok"] !== true) {
1292
+ throw new CommandError("forbidden", `hello が拒否されました: ${JSON.stringify(greeted)}`);
1127
1293
  }
1128
1294
  const answer = await conn.ask(request);
1129
1295
  if (answer["ok"] !== true) {
@@ -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
@@ -63,6 +63,16 @@ ccmsg peers --all 他ホストの instance が知っている分も含め
63
63
  根拠をこちらで要約し直しても情報は増えず、時間とコンテキストだけが減る。人に言うのは
64
64
  自セッション目線の事実 (何を頼んだ・何が返り・その結果こちらが何をしたか) だけ。
65
65
 
66
+ ## 別のセッションのやり方を読む
67
+
68
+ \`\`\`
69
+ ccmsg dump <sid> --preset howto 親セッションが何を考えて何を叩いたか
70
+ ccmsg dump <sid>/agent-<id> --preset howto その worker 自身のやり口
71
+ \`\`\`
72
+
73
+ 出力の末尾に ids 台帳があり、そこに出た \`agent\` の id が 2 行目の \`<id>\` になる。
74
+ preset の一覧は \`ccmsg dump presets\`。
75
+
66
76
  ## 見ている人へ知らせる
67
77
 
68
78
  \`\`\`