tonedial 0.1.2 → 0.1.4

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/dist/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // Argument parsing, dispatch, and human/JSON output.
2
2
  import { parseArgs } from "node:util";
3
3
  import { isAbsolute } from "node:path";
4
- import { ConfigError, isConfigV2, nearest } from "./core/config.js";
4
+ import { ConfigError, SETTING_KEYS, isConfigV2, nearest } from "./core/config.js";
5
+ import { AXIS_ORDER } from "./core/anchors.js";
5
6
  import { compile } from "./core/compile.js";
6
7
  import { toCompileInput } from "./core/resolve.js";
7
8
  import { inspectContextLayout } from "./core/seeds.js";
8
9
  import { OperationError } from "./core/operations.js";
9
10
  import { RegionError } from "./wiring/region.js";
10
11
  import { connectionScopeFlag, installShape, resolveAgentPaths, resolvePaths } from "./core/paths.js";
11
- import { messages } from "./core/i18n.js";
12
+ import { catalogue, configLanguage, detectLanguage, messages } from "./core/i18n.js";
12
13
  import { isColourMode, palette } from "./ui/style.js";
13
14
  import { cells, maxCells, resolveBudget, wrap } from "./ui/width.js";
14
15
  import { interactive } from "./ui/prompt.js";
@@ -22,6 +23,7 @@ import { runSave } from "./commands/save.js";
22
23
  import { runCreate } from "./commands/create.js";
23
24
  import { runAddress, runPersona, runSet, runReset } from "./commands/set.js";
24
25
  import { runShow } from "./commands/show.js";
26
+ import { runGet, settingHelp } from "./commands/get.js";
25
27
  import { runUse } from "./commands/use.js";
26
28
  import { runDoctor } from "./commands/doctor.js";
27
29
  import { runEject } from "./commands/eject.js";
@@ -34,7 +36,7 @@ export const JSON_SCHEMA = 3;
34
36
  * run time is the one thing that breaks all three binary packagers at once. A test asserts this
35
37
  * equals the manifest, which is the cheap half of the trade.
36
38
  */
37
- export const VERSION = "0.1.2";
39
+ export const VERSION = "0.1.4";
38
40
  const FLAG_NAMES = ["project", "check", "explain", "global", "here", "migrateLegacy", "auto", "dryRun", "fix", "rollback", "fromOperation", "fromPreset", "restorePreset", "preset", "slug", "acceptUsage", "lang", "commands", "tools"];
39
41
  /** Command-line spelling for each flag, where it differs from the field name. */
40
42
  const FLAG_SPELLING = {
@@ -47,6 +49,7 @@ const FLAG_SPELLING = {
47
49
  /** Each command declares its accepted flags and positional limit; null means unlimited. */
48
50
  const COMMANDS = {
49
51
  set: { run: runSet, flags: [], max: null, context: "state" },
52
+ get: { run: runGet, flags: [], max: 1 },
50
53
  test: { run: runTest, flags: ["project", "dryRun", "preset", "acceptUsage"], max: 1 },
51
54
  address: { run: runAddress, flags: [], max: 2, context: "state" },
52
55
  persona: { run: runPersona, flags: [], max: 2, context: "state" },
@@ -77,7 +80,8 @@ export const COMMAND_NAMES = Object.keys(COMMANDS);
77
80
  /** Intent groups share one command inventory between compact and full help. */
78
81
  const COMMAND_GROUPS = [
79
82
  ["tone", [
80
- ["set", "set <axis>=<level> …"],
83
+ ["set", "set <key>=<value> …"],
84
+ ["get", "get [key]"],
81
85
  ["address", "address [self user]"],
82
86
  ["persona", "persona [agent user]"],
83
87
  ["reset", "reset [axis]"],
@@ -108,6 +112,7 @@ const COMMAND_GROUPS = [
108
112
  const COMMAND_SYNTAX = COMMAND_GROUPS.flatMap(([, rows]) => rows);
109
113
  const COMMAND_EXAMPLES = {
110
114
  set: ["tonedial set", "tonedial set warmth=5 critique=4", "tonedial set persona.agent=reviewer"],
115
+ get: ["tonedial get", "tonedial get language", "tonedial get speech_level --json"],
111
116
  test: ["tonedial test codex --dry-run", "tonedial test claude --project .", "tonedial test codex --preset my-tone --accept-usage"],
112
117
  address: ['tonedial address "assistant" "friend"'],
113
118
  persona: ["tonedial persona", 'tonedial persona "Fictional reviewer" "Developer"'],
@@ -216,7 +221,8 @@ function compactUsage(t, ui) {
216
221
  ].join("\n");
217
222
  }
218
223
  /** One command's syntax and only the flags that can affect that command. */
219
- function commandUsage(name, t, ui) {
224
+ function commandUsage(name, copy, ui) {
225
+ const t = copy.cli;
220
226
  const command = COMMANDS[name];
221
227
  const syntax = COMMAND_SYNTAX.find(([key]) => key === name)?.[1] ?? name;
222
228
  const flagKeys = [
@@ -231,6 +237,7 @@ function commandUsage(name, t, ui) {
231
237
  return [syntax, t.help.flags[key]];
232
238
  });
233
239
  const width = Math.max(...flags.map(([flag]) => cells(flag)));
240
+ const keyWidth = Math.max(...[...AXIS_ORDER, ...SETTING_KEYS].map(key => maxCells(key)));
234
241
  return [
235
242
  ui.bold(`tonedial ${syntax}`),
236
243
  "",
@@ -241,6 +248,12 @@ function commandUsage(name, t, ui) {
241
248
  "",
242
249
  ui.bold(t.help.examples),
243
250
  ...COMMAND_EXAMPLES[name].map(example => ` ${ui.accent(example)}`),
251
+ ...(["get", "set"].includes(name) ? [
252
+ "", ui.bold(t.artifact.whatEachAxisObserves),
253
+ ...helpRows(AXIS_ORDER.map(key => [key, settingHelp(key, copy).join(" · ")]), keyWidth, ui),
254
+ "", ui.bold(t.artifact.whatElseIsSettable),
255
+ ...helpRows(SETTING_KEYS.map(key => [key, settingHelp(key, copy).join(" · ")]), keyWidth, ui),
256
+ ] : []),
244
257
  ].join("\n");
245
258
  }
246
259
  /**
@@ -319,7 +332,8 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
319
332
  // Resolved before the mode is validated, so the message about a bad `--color` is itself
320
333
  // painted the way the rest of this invocation would have been.
321
334
  const configPath = resolvePaths(env).config;
322
- const say = messages(configPath, env);
335
+ const copy = catalogue(configLanguage(configPath) ?? detectLanguage(env));
336
+ const say = copy.cli;
323
337
  const colour = String(parsed.values.color);
324
338
  const mode = isColourMode(colour) ? colour : "auto";
325
339
  // The line budget is settled here beside the colour, because it is the same kind of decision:
@@ -353,7 +367,7 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
353
367
  if (!Object.hasOwn(COMMANDS, name)) {
354
368
  return fail(asJson, say.errors.notACommand(asJson ? name : humanText(name), COMMAND_NAMES, nearest(name, COMMAND_NAMES)), 2, uiErr);
355
369
  }
356
- process.stdout.write(`${commandUsage(name, say, ui)}\n`);
370
+ process.stdout.write(`${commandUsage(name, copy, ui)}\n`);
357
371
  return 0;
358
372
  }
359
373
  // npx supplies no durable bare command for generated controls. Help and version stay available.
@@ -503,7 +517,7 @@ function fail(asJson, message, code, ui) {
503
517
  }
504
518
  else {
505
519
  // A standalone path is a copy target, not prose to split into unusable fragments.
506
- const lines = message.split("\n").flatMap((line) => isAbsolute(line) || /^(?:tonedial|npm|curl) /.test(line) ? [humanText(line)] : wrap(humanText(line), ui.width));
520
+ const lines = message.split("\n").flatMap((line) => isAbsolute(line) || /^(?:tonedial|curl) |^npm (?:install|uninstall|update) -|^https?:\/\//.test(line) ? [humanText(line)] : wrap(humanText(line), ui.width));
507
521
  process.stderr.write(lines.map((line) => ui.error(line)).join("\n") + "\n");
508
522
  }
509
523
  return code;
@@ -515,11 +515,34 @@ async function doctorReport(ctx, showRollbackCommands = true, showCodexAction =
515
515
  operations: inspected.journals.map(({ operationId, state }) => ({ operationId, state })),
516
516
  };
517
517
  if (inspected.lock.state !== "none" || inspected.journals.some(({ state }) => state === "pending" || state === "invalid")) {
518
+ const owner = inspected.lock.owner;
519
+ const blocking = inspected.journals.filter(({ state }) => state === "pending" || state === "invalid");
520
+ const shown = ctx.flags.verbose ? inspected.journals : inspected.journals.filter(({ operationId, state }) => state === "pending" || state === "invalid" || operationId === owner?.operationId);
521
+ const lines = [
522
+ ...wrap(ctx.t.doctor.recoveryPending, ctx.ui.width - 2).map(line => ` ${ctx.ui.warn(line)}`),
523
+ ...shown.map(({ operationId, state }) => ` ${operationId} ${ctx.t.doctor.operationState[state]}`),
524
+ ];
525
+ if (owner !== null && !shown.some(({ operationId }) => operationId === owner.operationId))
526
+ lines.push(` ${owner.operationId}`);
527
+ if (inspected.lock.state === "live" || inspected.lock.state === "unknown" || inspected.lock.state === "invalid") {
528
+ lines.push(...wrap(ctx.t.doctor.rollbackStatus[inspected.lock.state === "live" ? "busy" : "unresolved"], ctx.ui.width - 2)
529
+ .map(line => ` ${line}`));
530
+ }
531
+ else if (inspected.lock.state === "dead" && owner?.kind === "mutation") {
532
+ const entry = inspected.journals.find(({ operationId }) => operationId === owner.operationId);
533
+ if (entry?.journal && entry.journal.owner.nonce === owner.nonce) {
534
+ const selection = inspectRollback(ctx.paths, owner.operationId);
535
+ if (entry.state === "pending" && !selection.synthetic && blocking.every(({ operationId, journal }) => operationId === owner.operationId && journal !== null)) {
536
+ lines.push("", ` ${ctx.t.doctor.resumeOperation}`, ...nextStep(repairCommand(ctx, `doctor --fix --from-operation ${owner.operationId}`), ctx.ui).slice(1));
537
+ }
538
+ if (showRollbackCommands && previewRollback(ctx.paths, owner.operationId, ctx.t).status === "ready") {
539
+ lines.push("", ...receiptLines(ctx.t.recovery.rollback(owner.operationId), ctx.ui.width - 2).map(line => ` ${line}`));
540
+ }
541
+ }
542
+ }
543
+ lines.push(...nextStep(`${repairCommand(ctx, "doctor")} --json`, ctx.ui));
518
544
  return {
519
- lines: [
520
- ...wrap(ctx.t.doctor.recoveryPending, ctx.ui.width - 2).map((line) => ` ${ctx.ui.warn(line)}`),
521
- ...recovery.operations.map(({ operationId, state }) => ` ${operationId} ${ctx.t.doctor.operationState[state]}`),
522
- ],
545
+ lines,
523
546
  // The damaged config is deliberately not parsed here. `operation` is the only safe context
524
547
  // fact to expose until the owner-selected rollback or recovery has completed.
525
548
  json: {
@@ -0,0 +1,41 @@
1
+ // Read active settings without compiling, repairing, or interpreting stored text.
2
+ import { isAxis, AGE_BANDS, GENDERS, ConfigError, nearest, settingValues } from "../core/config.js";
3
+ import { LANGUAGES, catalogue, detectLanguage, isLanguage } from "../core/i18n.js";
4
+ import { humanText } from "../ui/format.js";
5
+ import { maxCells, padEnd, wrap } from "../ui/width.js";
6
+ import { planConfig } from "./compile.js";
7
+ /** The same descriptions and allowed tokens serve key reads and command help. */
8
+ export function settingHelp(key, copy) {
9
+ if (isAxis(key)) {
10
+ const hint = copy.axisHints[key];
11
+ return [hint.owns, `1–5 · 1 ${hint.low} · 5 ${hint.high}`];
12
+ }
13
+ const choices = key === "language" ? LANGUAGES : key === "persona.age" ? AGE_BANDS : key === "persona.gender" ? GENDERS : null;
14
+ return [copy.settingHints[key], ...(choices ? [choices.join(" | ")] : [])];
15
+ }
16
+ export function runGet(ctx, args) {
17
+ const { change, config } = planConfig(ctx);
18
+ if (change.before === null)
19
+ throw new ConfigError(ctx.t.test.missingConfig);
20
+ const all = settingValues(config);
21
+ const [requested] = args;
22
+ if (requested !== undefined && !Object.hasOwn(all, requested)) {
23
+ throw new ConfigError(ctx.t.axis.notSettable(ctx.asJson ? requested : humanText(requested), nearest(requested, Object.keys(all))));
24
+ }
25
+ const values = requested === undefined ? all : { [requested]: all[requested] };
26
+ const column = Math.max(...Object.keys(values).map(key => maxCells(key)));
27
+ const budget = ctx.ui.width - column - 4;
28
+ const lines = Object.entries(values).flatMap(([key, value]) => {
29
+ const text = value === null ? "—" : humanText(String(value));
30
+ if (budget < 12)
31
+ return [` ${ctx.ui.accent(key)}`, ...wrap(text, ctx.ui.width - 4).map(line => ` ${line}`)];
32
+ const [first, ...rest] = wrap(text, budget);
33
+ return [` ${ctx.ui.accent(padEnd(key, column))} ${first ?? ""}`,
34
+ ...rest.map(line => `${" ".repeat(column + 4)}${line}`)];
35
+ });
36
+ if (requested !== undefined) {
37
+ const copy = catalogue(isLanguage(config.language) ? config.language : detectLanguage(ctx.env));
38
+ lines.push("", ...settingHelp(requested, copy).flatMap(hint => wrap(hint, ctx.ui.width - 2).map(line => ` ${ctx.ui.dim(line)}`)));
39
+ }
40
+ return { lines, json: { values } };
41
+ }
@@ -8,7 +8,7 @@ import { compile } from "../core/compile.js";
8
8
  import { toCompileInput } from "../core/resolve.js";
9
9
  import { isLanguage, LANGUAGES } from "../core/i18n.js";
10
10
  import { recoverLegacyTone } from "../core/legacy-tone.js";
11
- import { exportAliasesState, stateOverlapsInstructions } from "../core/paths.js";
11
+ import { connectionScopeFlag, exportAliasesState, stateOverlapsInstructions } from "../core/paths.js";
12
12
  import { applyPreset, matchesPreset, assertPresetSelection, BUNDLED, customPresetDir, inspectPresetLibrary, parsePreset, PRESET_MAX_BYTES, resolvePreset, serializePreset, SLUG_PATTERN, } from "../core/presets.js";
13
13
  import { contextArtifactTargets, generatedSeedSelection, planGeneratedSeedRebuild, seedFiles, SEED_VERSION, } from "../core/seeds.js";
14
14
  import { inspectOperations, readJournal, retireOperationForAppRebuild, } from "../core/operations.js";
@@ -254,9 +254,14 @@ export async function runAppRebuild(ctx) {
254
254
  else if (!ctx.flags.auto && ctx.canPrompt) {
255
255
  const input = createInterface({ input: process.stdin, output: process.stdout });
256
256
  try {
257
- process.stdout.write(` ${t.doctor.recoveryPending}\n ${owner.operationId}\n`);
258
- if ((await input.question(" > ")).trim() !== owner.operationId)
259
- throw new ConfigError(t.doctor.recoveryPending);
257
+ const operation = t.recovery.resumeOperation(owner.operationId).split("\n").flatMap(line => line === owner.operationId ? [line] : wrap(line, ctx.ui.width - 2));
258
+ process.stdout.write(`${operation.map(line => ` ${line}`).join("\n")}\n`);
259
+ const question = wrap(t.recovery.resumeQuestion, ctx.ui.width - 3).map(line => ` ${line}`).join("\n");
260
+ const answer = (await input.question(`${question} `)).trim();
261
+ if (answer === "")
262
+ throw new ConfigError(t.recovery.cancelled);
263
+ if (answer !== owner.operationId)
264
+ throw new ConfigError(t.recovery.operationMismatch);
260
265
  retirementId = owner.operationId;
261
266
  }
262
267
  finally {
@@ -394,6 +399,8 @@ export async function runAppRebuild(ctx) {
394
399
  }
395
400
  const seedsSelection = generatedSeedSelection(ctx.paths, config, ctx.flags.tools !== null && ctx.flags.tools !== "none", t);
396
401
  const seeds = planGeneratedSeedRebuild(ctx.paths, config.managedSeeds, seedsSelection, t);
402
+ const generatedSeeds = seedFiles(ctx.paths, seedsSelection);
403
+ const rewrittenGenerated = new Set(generatedSeeds.filter(seed => seeds.operationActions.some(({ action }) => action.path === seed.path || (object(action.after)?.type === "directory" && seed.path.startsWith(`${action.path}${sep}`)))).map(seed => seed.path));
397
404
  checks.push(...seeds.checks);
398
405
  config.managedSeeds = seeds.records;
399
406
  config.seedVersion = SEED_VERSION;
@@ -457,40 +464,54 @@ export async function runAppRebuild(ctx) {
457
464
  auto: ctx.flags.auto, dryRun: ctx.flags.dryRun, verbose: ctx.flags.verbose, ui: ctx.ui, t,
458
465
  heading: t.recovery.heading,
459
466
  interactive: () => ctx.canPrompt, ...(ctx.canPrompt ? {} : { out: () => { } }),
460
- recoveryPresentation: { lines: presentation, failureMessage: t.recovery.failure },
467
+ recoveryPresentation: { lines: presentation,
468
+ failureMessage: (id, phase, path, code) => `${t.recovery.failure(id, phase, path, code)}\ntonedial doctor ${connectionScopeFlag(ctx.agent)} --json` },
461
469
  verify: () => {
470
+ let verificationPath = ctx.paths.config;
471
+ const mismatch = () => new ConfigError(t.recovery.sourceInvalid, { cause: { code: "VERIFY_MISMATCH", path: verificationPath } });
462
472
  try {
463
473
  const repaired = parseConfig(capturedText(captureRawEntry(ctx.paths.config, t, { within: ctx.paths.root })) ?? "", t);
464
474
  if (!isConfigV2(repaired) || serializeConfig(repaired, t) !== serializeConfig(config, t))
465
- throw new ConfigError(t.recovery.sourceInvalid);
466
- for (const seed of seedFiles(ctx.paths, seedsSelection)) {
475
+ throw mismatch();
476
+ for (const seed of generatedSeeds) {
477
+ verificationPath = seed.path;
467
478
  const now = captureRawEntry(seed.path, t, { within: ctx.paths.root });
468
479
  if (capturedText(now) !== seed.contents)
469
- throw new ConfigError(t.recovery.sourceInvalid);
480
+ throw mismatch();
470
481
  }
471
- for (const target of artifacts)
482
+ for (const target of artifacts) {
483
+ verificationPath = target.path;
472
484
  if (capturedText(captureRawEntry(target.path, t, { within: ctx.paths.root })) !== artifact)
473
- throw new ConfigError(t.recovery.sourceInvalid);
485
+ throw mismatch();
486
+ }
487
+ verificationPath = ctx.paths.tone;
474
488
  if (capturedText(captureRawEntry(ctx.paths.tone, t, { within: ctx.paths.root })) !== artifact)
475
- throw new ConfigError(t.recovery.sourceInvalid);
489
+ throw mismatch();
476
490
  for (const entry of library.entries) {
477
491
  for (const before of entry.selectionReads) {
478
- if (before.path === restoredPreset?.capture.path)
492
+ // Rewritten generated entries were checked against their new bytes above.
493
+ if (before.path === restoredPreset?.capture.path || rewrittenGenerated.has(before.path))
479
494
  continue;
495
+ verificationPath = before.path;
480
496
  const now = captureRawEntry(before.path, t, { within: ctx.paths.root, maxBytes: PRESET_MAX_BYTES });
481
497
  if (now.type !== before.type || now.identity !== before.identity || now.mode !== before.mode ||
482
498
  (now.bytes === null ? before.bytes !== null : before.bytes === null || !now.bytes.equals(before.bytes))) {
483
- throw new ConfigError(t.recovery.sourceInvalid);
499
+ throw mismatch();
484
500
  }
485
501
  }
486
502
  }
487
503
  for (const slug of [...copies, ...(restoredPreset ? [restoredPreset.slug] : [])]) {
504
+ verificationPath = slug === restoredPreset?.slug ? restoredPreset.capture.path
505
+ : library.recoveryCopies.find(copy => copy.preset.slug === slug).path;
488
506
  if (resolvePreset(ctx.paths.presets, slug, t) === null)
489
- throw new ConfigError(t.recovery.sourceInvalid);
507
+ throw mismatch();
490
508
  }
491
509
  }
492
- catch {
493
- throw new ConfigError(t.recovery.sourceInvalid);
510
+ catch (cause) {
511
+ if (cause instanceof ConfigError && cause.cause?.code === "VERIFY_MISMATCH")
512
+ throw cause;
513
+ const code = cause !== null && typeof cause === "object" && "code" in cause ? cause.code : undefined;
514
+ throw new ConfigError(t.recovery.sourceInvalid, { cause: { code, path: verificationPath, cause } });
494
515
  }
495
516
  },
496
517
  };
@@ -60,7 +60,7 @@ export async function savePreset(ctx, displayName, state, options = {}) {
60
60
  [address?.self_ref ? ctx.t.persona.callsItself(humanText(address.self_ref)) : "",
61
61
  address?.user_ref ? ctx.t.persona.callsYou(humanText(address.user_ref)) : ""].filter(Boolean).join(" · "),
62
62
  ...["user", "agent"].map((field) => `${ctx.t.persona.descriptionFull(field, humanText(config.persona[field] ?? ""))} (${ctx.t.create.descriptionCount([...(config.persona[field] ?? "")].length, PERSONA_DESCRIPTION_MAX)})`),
63
- `${(config.safeword ? ctx.t.settings.safewordSet(humanText(config.safeword)) : ctx.t.settings.noSafeword).split(" — ")[0]} · ${ctx.t.settings.register(config.speech_level)}`,
63
+ `${config.safeword ? ctx.t.settings.safewordSet(humanText(config.safeword)) : ctx.t.settings.noSafeword} · ${ctx.t.settings.register(config.speech_level)}`,
64
64
  ];
65
65
  for (const detail of details.filter(Boolean))
66
66
  review.push(...wrap(detail, ctx.ui.width - 2).map((line) => ` ${line}`));
@@ -5,7 +5,7 @@
5
5
  // job is to say so. Every selected level remains unchanged, including contradictions.
6
6
  import { AXIS_DEFAULT, AXIS_ORDER, riskyAxes } from "../core/anchors.js";
7
7
  import { check as checkCombos, PERSONA_DESCRIPTION_MAX } from "../core/combos.js";
8
- import { AGE_BANDS, GENDERS, SPEECH_LEVELS, ConfigError, SETTING_KEYS, isAxis, nearest, nearestAxis, serializeConfig, } from "../core/config.js";
8
+ import { AGE_BANDS, GENDERS, SPEECH_LEVELS, ConfigError, SETTING_KEYS, isAxis, nearest, nearestAxis, serializeConfig, settingValues, } from "../core/config.js";
9
9
  import { toCompileInput } from "../core/resolve.js";
10
10
  import { LANGUAGES, catalogue, isLanguage } from "../core/i18n.js";
11
11
  import { presetSettings, resolvePreset } from "../core/presets.js";
@@ -157,33 +157,19 @@ function movementLines(moves, ctx, axes) {
157
157
  * Returns `undefined` for a key this tool does not have, which `assign` then rejects by name.
158
158
  */
159
159
  function readKey(config, key, t) {
160
- if (key === "safeword")
161
- return config.safeword ?? UNSET;
162
- if (key === "speech_level")
163
- return config.speech_level;
164
- if (key === "language")
165
- return config.language;
166
- if (key.startsWith("persona.")) {
167
- const field = key.slice("persona.".length);
168
- if (field === "age")
169
- return config.persona.age ?? UNSET;
170
- if (field === "gender")
171
- return config.persona.gender ?? UNSET;
172
- if (field === "self_ref")
173
- return config.persona.address?.self_ref ?? UNSET;
174
- if (field === "user_ref")
175
- return config.persona.address?.user_ref ?? UNSET;
176
- if (field === "note") {
177
- const note = config.persona.note;
178
- return note ? t.persona.noteChars([...note].length) : UNSET;
179
- }
180
- if (field === "user" || field === "agent") {
181
- const description = config.persona[field];
182
- return description ? t.persona.descriptionChars(field, [...description].length) : UNSET;
183
- }
160
+ const values = settingValues(config);
161
+ if (!Object.hasOwn(values, key))
184
162
  return undefined;
163
+ const value = values[key];
164
+ if (value === null)
165
+ return UNSET;
166
+ if (typeof value === "string") {
167
+ if (key === "persona.note")
168
+ return t.persona.noteChars([...value].length);
169
+ if (key === "persona.user" || key === "persona.agent")
170
+ return t.persona.descriptionChars(key.slice("persona.".length), [...value].length);
185
171
  }
186
- return isAxis(key) ? String(config.axes[key]) : undefined;
172
+ return String(value);
187
173
  }
188
174
  /**
189
175
  * Assign each pair, and record the movement each one actually made.
@@ -5,7 +5,7 @@ import { isAbsolute } from "node:path";
5
5
  import { stripVTControlCharacters } from "node:util";
6
6
  import { AXIS_DEFAULT, AXIS_GROUP, AXIS_KIND, AXIS_RISK_FROM, riskyAxes } from "../core/anchors.js";
7
7
  import { check as checkCombos, PERSONA_DESCRIPTION_MAX } from "../core/combos.js";
8
- import { ConfigError, isConfigV2 } from "../core/config.js";
8
+ import { ConfigError, isConfigV2, settingValues } from "../core/config.js";
9
9
  import { compile, measure } from "../core/compile.js";
10
10
  import { withPreset, matchesPreset, inspectPresetLibrary, resolvePreset } from "../core/presets.js";
11
11
  import { toCompileInput } from "../core/resolve.js";
@@ -31,6 +31,7 @@ export function runShow(ctx, args) {
31
31
  }
32
32
  const shown = preview ? withPreset(config, preview) : config;
33
33
  const axes = shown.axes;
34
+ const values = settingValues(shown);
34
35
  const input = toCompileInput(shown);
35
36
  const artifact = compile(input);
36
37
  const size = measure(artifact);
@@ -63,33 +64,37 @@ export function runShow(ctx, args) {
63
64
  for (const folded of wrap(sizeLabel, ui.width - 2))
64
65
  lines.push(` ${ui.dim(folded)}`);
65
66
  }
66
- const p = shown.persona;
67
- const personaSet = Boolean(p.age || p.gender || p.note || p.address || p.user || p.agent);
67
+ const { "persona.age": age, "persona.gender": gender, "persona.note": note, "persona.self_ref": selfRef, "persona.user_ref": userRef, "persona.user": user, "persona.agent": agent } = values;
68
+ const personaSet = Boolean(age || gender || note || selfRef || userRef || user || agent);
68
69
  const gauges = [gaugeHeader(ui, ctx.t), ...gaugeBlock(axes, ui)];
69
70
  const gaugeWidth = Math.max(...gauges.map((line) => maxCells(stripVTControlCharacters(line))));
70
71
  const detailWidth = ui.terminal && ui.width >= 119 ? ui.width - gaugeWidth - 4 : ui.width - 2;
71
72
  const details = [ui.bold(ctx.t.artifact.settingsHeading)];
72
73
  if (personaSet) {
73
74
  const bits = [
74
- p.age ? humanText(p.age) : null,
75
- p.gender ? humanText(p.gender) : null,
76
- p.address?.self_ref ? ctx.t.persona.callsItself(humanText(p.address.self_ref)) : null,
77
- p.address?.user_ref ? ctx.t.persona.callsYou(humanText(p.address.user_ref)) : null,
78
- p.note ? ctx.t.persona.noteChars([...p.note].length) : null,
79
- p.user && p.agent ? ctx.t.persona.descriptionCounts([...p.user].length, [...p.agent].length, PERSONA_DESCRIPTION_MAX) : null,
80
- p.user && !p.agent ? ctx.t.persona.descriptionChars("user", [...p.user].length) : null,
81
- p.agent && !p.user ? ctx.t.persona.descriptionChars("agent", [...p.agent].length) : null,
75
+ age ? humanText(age) : null,
76
+ gender ? humanText(gender) : null,
77
+ selfRef ? ctx.t.persona.callsItself(humanText(selfRef)) : null,
78
+ userRef ? ctx.t.persona.callsYou(humanText(userRef)) : null,
79
+ note ? ctx.t.persona.noteChars([...note].length) : null,
80
+ user && agent ? ctx.t.persona.descriptionCounts([...user].length, [...agent].length, PERSONA_DESCRIPTION_MAX) : null,
81
+ user && !agent ? ctx.t.persona.descriptionChars("user", [...user].length) : null,
82
+ agent && !user ? ctx.t.persona.descriptionChars("agent", [...agent].length) : null,
82
83
  ].filter(Boolean);
83
84
  if (bits.length)
84
85
  for (const folded of wrap(bits.join(" · "), detailWidth))
85
86
  details.push(ui.dim(folded));
86
- for (const [field, value] of [["agent", p.agent], ["user", p.user]]) {
87
+ for (const [field, value] of [["agent", agent], ["user", user]]) {
87
88
  if (value === null)
88
89
  continue;
89
90
  for (const folded of wrap(ctx.t.persona.descriptionFull(field, humanText(value)), detailWidth)) {
90
91
  details.push(ui.dim(folded));
91
92
  }
92
93
  }
94
+ if (note !== null) {
95
+ for (const folded of wrap(ctx.t.persona.descriptionFull("note", humanText(note)), detailWidth))
96
+ details.push(ui.dim(folded));
97
+ }
93
98
  }
94
99
  // Folded rather than laid out: the safeword is user text of unbounded length, so this row
95
100
  // can always exceed the budget and no amount of shortening the fixed words prevents it.
@@ -97,7 +102,7 @@ export function runShow(ctx, args) {
97
102
  ? [...new Set(Object.values(config.wiring.registrations).map((record) => `${record.target} (${record.scope})`))]
98
103
  : Object.keys(config.wiring);
99
104
  for (const text of [
100
- stateRow(shown, ctx.t), ctx.t.artifact.displaySettings(config.language),
105
+ stateRow(values, ctx.t), ctx.t.artifact.displaySettings(values.language),
101
106
  ctx.t.artifact.connectionRecords(records.map(humanText)),
102
107
  ])
103
108
  for (const folded of wrap(text, detailWidth))
@@ -170,10 +175,10 @@ export function runShow(ctx, args) {
170
175
  riskAxes: riskyAxes(axes),
171
176
  riskThresholds: AXIS_RISK_FROM,
172
177
  persona: shown.persona,
173
- safeword: shown.safeword,
174
- speech_level: shown.speech_level,
178
+ safeword: values.safeword,
179
+ speech_level: values.speech_level,
175
180
  // Interface language never changes the compiled tone.
176
- language: config.language,
181
+ language: values.language,
177
182
  chars: size.chars,
178
183
  artifact: { exists: artifactOnDisk },
179
184
  },
@@ -197,9 +202,9 @@ function titleRow(name, sizeLabel, ctx) {
197
202
  * Safeword and register on one row. Plain text, because it is folded before it is
198
203
  * painted and an escape sequence measures as characters while occupying no cell.
199
204
  */
200
- function stateRow(config, t) {
201
- const safeword = config.safeword ? t.settings.safewordSet(humanText(config.safeword)).split(" — ")[0] : t.settings.noSafeword.split(" — ")[0];
202
- return `${safeword} · ${t.settings.register(config.speech_level)}`;
205
+ function stateRow(values, t) {
206
+ const safeword = values.safeword ? t.settings.safewordSet(humanText(values.safeword)) : t.settings.noSafeword;
207
+ return `${safeword} · ${t.settings.register(values.speech_level)}`;
203
208
  }
204
209
  // Presence alone establishes neither current contents nor native loading.
205
210
  function artifactExists(ctx) {
@@ -164,6 +164,7 @@ export const SPEECH_LEVEL = {
164
164
  auto: "In languages with honorifics, choose address forms for the current relationship and scene; keep them consistent within each reply.",
165
165
  polite: "In languages with honorifics, use polite address forms even with familiar wording.",
166
166
  plain: "In languages with honorifics, use plain address forms even with formal wording.",
167
+ rough: "In languages with honorifics, use rough, cheeky non-honorific sentence endings; keep the configured address terms and the selected formality, warmth, profanity and playfulness levels.",
167
168
  };
168
169
  export const CONTROLS = {
169
170
  /**
@@ -35,8 +35,8 @@ function validate(config) {
35
35
  throw new RangeError(`axis ${axis} is ${level}, not a level 1 to 5`);
36
36
  }
37
37
  }
38
- if (!(config.speech_level in SPEECH_LEVEL)) {
39
- throw new RangeError(`speech_level is ${config.speech_level}, not auto, polite, or plain`);
38
+ if (!Object.hasOwn(SPEECH_LEVEL, config.speech_level)) {
39
+ throw new RangeError(`speech_level is ${config.speech_level}, not auto, polite, plain, or rough`);
40
40
  }
41
41
  if (config.persona !== null) {
42
42
  for (const field of ["user", "agent"]) {
@@ -10,7 +10,7 @@ export const CONFIG_SCHEMA = 2;
10
10
  export const LEGACY_CONFIG_SCHEMA = 1;
11
11
  export const AGE_BANDS = ["unspecified", "teens", "20s", "30s", "40s", "50s+"];
12
12
  export const GENDERS = ["unspecified", "masculine", "feminine", "neutral"];
13
- export const SPEECH_LEVELS = ["auto", "polite", "plain"];
13
+ export const SPEECH_LEVELS = ["auto", "polite", "plain", "rough"];
14
14
  export const WIRING_SCOPES = ["global", "project"];
15
15
  export const RESOURCE_KINDS = ["hook", "skill", "shell", "reader", "command"];
16
16
  export const RESOURCE_TYPES = ["file", "directory", "symlink"];
@@ -853,20 +853,32 @@ export const isAxis = (name) => AXIS_ORDER.includes(name);
853
853
  /**
854
854
  * Everything `set <key>=<value>` takes that is not an axis.
855
855
  *
856
- * Two consumers: the did-you-mean match in `set`, and the order `show --explain` renders the
857
- * per-language descriptions in. It does not drive `assign` or `readKey` — each of those branches
858
- * carries its own parsing and its own refusal.
856
+ * Shared by key discovery, setting reads, and the did-you-mean match in `set`.
857
+ * Assignment retains its own validation.
859
858
  *
860
- * Only one drift direction is caught. A key listed here with no branch in `assign` fails the
861
- * test that drives every entry through `set`. A branch added to `assign` and never listed here
862
- * is invisible: it would not appear in `--explain` and `nearest` would not suggest it, and
863
- * nothing fails. Closing that needs `assign` to be a table rather than a chain of `if`s.
859
+ * Command tests exercise this inventory through both setting and reading values.
864
860
  */
865
861
  export const SETTING_KEYS = [
866
862
  "safeword", "speech_level", "language",
867
863
  "persona.age", "persona.gender", "persona.self_ref", "persona.user_ref", "persona.note",
868
864
  "persona.user", "persona.agent",
869
865
  ];
866
+ /** Current editable values, without presentation labels or internal ownership metadata. */
867
+ export function settingValues(config) {
868
+ return {
869
+ ...Object.fromEntries(AXIS_ORDER.map(axis => [axis, config.axes[axis]])),
870
+ safeword: config.safeword,
871
+ speech_level: config.speech_level,
872
+ language: config.language,
873
+ "persona.age": config.persona.age,
874
+ "persona.gender": config.persona.gender,
875
+ "persona.self_ref": config.persona.address?.self_ref ?? null,
876
+ "persona.user_ref": config.persona.address?.user_ref ?? null,
877
+ "persona.note": config.persona.note,
878
+ "persona.user": config.persona.user,
879
+ "persona.agent": config.persona.agent,
880
+ };
881
+ }
870
882
  /** Levenshtein-lite: enough to say "did you mean critique?" and no more. */
871
883
  export function nearest(name, candidates) {
872
884
  const distance = (a, b) => {