@cspeach/cli 1.1.45 → 1.1.46
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/agent/chat-prompt.js +47 -0
- package/dist/agent/chat-unavailable.js +9 -0
- package/dist/agent/level-state.js +11 -0
- package/dist/agent/level.js +60 -0
- package/dist/agent/loop.js +110 -2
- package/dist/agent/provider-shape.js +6 -2
- package/dist/agent/providers/ai-hub-provider.js +3 -1
- package/dist/agent/providers/azure-provider.js +3 -1
- package/dist/agent/providers/byok-provider.js +3 -1
- package/dist/agent/providers/local-provider.js +3 -1
- package/dist/agent/style-state.js +11 -0
- package/dist/agent/style.js +59 -0
- package/dist/agent/system-prompt.js +24 -3
- package/dist/agent/tool-dispatch.js +55 -0
- package/dist/cli.js +8 -0
- package/dist/commands/config-set.js +59 -1
- package/dist/commands/config-show.js +1 -0
- package/dist/commands/export-docx.js +66 -0
- package/dist/commands/help.js +28 -2
- package/dist/config/loader.js +51 -1
- package/dist/docx/doc-meta.js +47 -0
- package/dist/docx/markdown-to-docx.js +250 -0
- package/dist/docx/write-docx.js +54 -0
- package/dist/one-shot.js +10 -0
- package/dist/projects/document-save.js +62 -0
- package/dist/projects/document-skills.js +9 -0
- package/dist/renderer/footer-line.js +6 -1
- package/dist/renderer/startup-lines.js +6 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/renderer/tool-widget.js +5 -1
- package/dist/repl/builtin-commands.js +4 -1
- package/dist/repl/chat-command.js +64 -0
- package/dist/repl/chat-lane.js +135 -0
- package/dist/repl/level-command.js +79 -0
- package/dist/repl/style-command.js +67 -0
- package/dist/repl.js +186 -105
- package/dist/session/queued-step.js +5 -1
- package/dist/session/schema.js +1 -0
- package/dist/session/store.js +4 -0
- package/dist/tools/hand-off.js +86 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/widgets/ask-card.js +2 -1
- package/package.json +2 -2
- package/.cspeach/project.json +0 -4
package/dist/cli.js
CHANGED
|
@@ -275,6 +275,13 @@ export async function main(argv) {
|
|
|
275
275
|
const { runOneShot } = await import('./one-shot.js');
|
|
276
276
|
return runOneShot(args.slice(1).join(' '), { sapAlias, noSap });
|
|
277
277
|
}
|
|
278
|
+
// Word export (2026-10-19): `cspeach export <file>.md --docx` writes a Word
|
|
279
|
+
// copy next to the file. Must sit above the one-shot fallback, or the words
|
|
280
|
+
// would be sent to the model as a paid prompt.
|
|
281
|
+
if (args[0] === 'export') {
|
|
282
|
+
const { parseExportDocxArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
|
|
283
|
+
return runExportDocxCommand({ ...parseExportDocxArgs(args.slice(1)), cwd: process.cwd(), system: noSap ? null : sapAlias ?? null, emit: (l) => console.log(l) });
|
|
284
|
+
}
|
|
278
285
|
// Default: if first arg exists and is not a flag, treat as one-shot prompt.
|
|
279
286
|
if (args.length > 0 && !args[0].startsWith('-')) {
|
|
280
287
|
const { runOneShot } = await import('./one-shot.js');
|
|
@@ -301,6 +308,7 @@ Usage:
|
|
|
301
308
|
cspeach Start interactive REPL
|
|
302
309
|
cspeach "<prompt>" One-shot — runs one turn and exits
|
|
303
310
|
cspeach --one-shot "<prompt>" Same as above, explicit flag
|
|
311
|
+
cspeach export <file>.md --docx Write a Word copy next to a saved review or handover
|
|
304
312
|
cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
|
|
305
313
|
cspeach --no-sap Start without a SAP system (paste-mode skills only)
|
|
306
314
|
cspeach --continue, -c Continue the latest session in this folder
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { formatAttention } from '../renderer/notice-shape.js';
|
|
3
3
|
import { loadConfig, saveConfig, isValidWriteMode } from '../config/loader.js';
|
|
4
|
+
import { ASSISTANT_STYLES, parseStyle } from '../agent/style.js';
|
|
5
|
+
import { ASSISTANT_LEVELS, parseLevel } from '../agent/level.js';
|
|
4
6
|
import { STANDALONE_PLATFORMS, STANDALONE_DATABASES, STANDALONE_DEPLOYMENTS, STANDALONE_IDES, isStandalonePlatform, deriveDatabase, deriveDeployment, defaultAbapCloudOnly, defaultRelease, } from '../sap/standalone-profile.js';
|
|
5
7
|
/**
|
|
6
8
|
* Mutable keys exposed via `cspeach config set`.
|
|
@@ -14,9 +16,13 @@ const MUTABLE_KEYS = new Set([
|
|
|
14
16
|
'effort',
|
|
15
17
|
'telemetry',
|
|
16
18
|
'classifier.safe_mode',
|
|
19
|
+
'assistant.lane',
|
|
20
|
+
'assistant.style',
|
|
21
|
+
'assistant.level',
|
|
17
22
|
'local_build',
|
|
18
23
|
'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
|
|
19
24
|
'show_cogs',
|
|
25
|
+
'word_copy',
|
|
20
26
|
'write_mode',
|
|
21
27
|
'plan_mode',
|
|
22
28
|
'plan_audit',
|
|
@@ -76,6 +82,7 @@ const LLM_MODE_VALUES = ['managed', 'byok', 'ai-hub', 'local', 'azure'];
|
|
|
76
82
|
const PLAN_MODE_VALUES = ['step', 'guarded'];
|
|
77
83
|
const PLAN_AUDIT_VALUES = ['on', 'off'];
|
|
78
84
|
const USAGE_REPORTING_VALUES = ['on', 'off'];
|
|
85
|
+
const ASSISTANT_LANE_VALUES = ['chat', 'skills'];
|
|
79
86
|
const APPROVALS_VALUES = ['plan', 'per-write'];
|
|
80
87
|
/**
|
|
81
88
|
* Thrown on validation failure. The outer CLI dispatcher converts to
|
|
@@ -102,7 +109,7 @@ function errUnset(msg) {
|
|
|
102
109
|
* Internal, loader-derived flags that are never serialised (stripped by
|
|
103
110
|
* saveConfig). They must never be targets of `config set`/`config unset`.
|
|
104
111
|
*/
|
|
105
|
-
const INTERNAL_KEYS = new Set(['default_model_set', 'effort_set', 'file_keys']);
|
|
112
|
+
const INTERNAL_KEYS = new Set(['default_model_set', 'effort_set', 'file_keys', 'assistant_file_keys']);
|
|
106
113
|
/**
|
|
107
114
|
* Virtual keys whose values live in the OS keychain, not config.toml. `config
|
|
108
115
|
* unset` cannot remove them — that is a keychain operation — so they are
|
|
@@ -135,6 +142,18 @@ export function markExplicit(cfg, key) {
|
|
|
135
142
|
cfg.file_keys = [];
|
|
136
143
|
if (!cfg.file_keys.includes(top))
|
|
137
144
|
cfg.file_keys.push(top);
|
|
145
|
+
// [assistant] is sparse per field too (staged rollout, 2026-10-17): an
|
|
146
|
+
// explicit `config set assistant.lane skills` must stick after a later
|
|
147
|
+
// release flips the lane default.
|
|
148
|
+
if (top === 'assistant') {
|
|
149
|
+
const leaf = key.split('.').slice(1).join('.');
|
|
150
|
+
if (leaf) {
|
|
151
|
+
if (!Array.isArray(cfg.assistant_file_keys))
|
|
152
|
+
cfg.assistant_file_keys = [];
|
|
153
|
+
if (!cfg.assistant_file_keys.includes(leaf))
|
|
154
|
+
cfg.assistant_file_keys.push(leaf);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
138
157
|
}
|
|
139
158
|
/** Human-readable "allowed values" error for a standalone.<field> set. */
|
|
140
159
|
function standaloneValueError(field, value) {
|
|
@@ -519,6 +538,30 @@ export async function runConfigSet(args) {
|
|
|
519
538
|
}
|
|
520
539
|
cfg.classifier.safe_mode = value === 'true';
|
|
521
540
|
break;
|
|
541
|
+
// Chat lane (spec 2026-10-17 D2/D9). markExplicit below keeps the table.
|
|
542
|
+
case 'assistant.lane': {
|
|
543
|
+
const lane = value.trim().toLowerCase();
|
|
544
|
+
if (lane !== 'chat' && lane !== 'skills') {
|
|
545
|
+
err(`assistant.lane must be one of: ${ASSISTANT_LANE_VALUES.join(', ')}`);
|
|
546
|
+
}
|
|
547
|
+
cfg.assistant.lane = lane;
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case 'assistant.style': {
|
|
551
|
+
const style = parseStyle(value);
|
|
552
|
+
if (!style)
|
|
553
|
+
err(`assistant.style must be one of: ${ASSISTANT_STYLES.join(', ')}`);
|
|
554
|
+
cfg.assistant.style = style;
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
// Spec §10 D13 — who CSPeach is talking to.
|
|
558
|
+
case 'assistant.level': {
|
|
559
|
+
const level = parseLevel(value);
|
|
560
|
+
if (!level)
|
|
561
|
+
err(`assistant.level must be one of: ${ASSISTANT_LEVELS.join(', ')}`);
|
|
562
|
+
cfg.assistant.level = level;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
522
565
|
case 'local_files':
|
|
523
566
|
// Deprecated alias (2026-06-13). Still applied — but it WRITES to the new
|
|
524
567
|
// `local_build` key so the user's config converges on the new name. The
|
|
@@ -568,6 +611,14 @@ export async function runConfigSet(args) {
|
|
|
568
611
|
: ' → cost figures will show credits on a managed, metered account.'));
|
|
569
612
|
break;
|
|
570
613
|
}
|
|
614
|
+
case 'word_copy': {
|
|
615
|
+
const b = coerceBool(value);
|
|
616
|
+
if (b === null)
|
|
617
|
+
err(`${key} must be on/off (also accepts true/false, 1/0).`);
|
|
618
|
+
cfg.word_copy = b;
|
|
619
|
+
console.log(chalk.dim(b ? ' → a Word copy is written with every saved review or handover.' : ' → CSPeach will not ask about Word copies.'));
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
571
622
|
case 'write_mode':
|
|
572
623
|
if (!isValidWriteMode(value)) {
|
|
573
624
|
err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
|
|
@@ -747,9 +798,16 @@ export function validateValue(key, value) {
|
|
|
747
798
|
return TELEMETRY_VALUES.includes(value);
|
|
748
799
|
case 'classifier.safe_mode':
|
|
749
800
|
return value === 'true' || value === 'false';
|
|
801
|
+
case 'assistant.lane':
|
|
802
|
+
return ASSISTANT_LANE_VALUES.includes(value.trim().toLowerCase());
|
|
803
|
+
case 'assistant.style':
|
|
804
|
+
return parseStyle(value) !== null;
|
|
805
|
+
case 'assistant.level':
|
|
806
|
+
return parseLevel(value) !== null;
|
|
750
807
|
case 'local_build':
|
|
751
808
|
case 'local_files': // deprecated alias — same on/off coercion
|
|
752
809
|
case 'show_cogs':
|
|
810
|
+
case 'word_copy':
|
|
753
811
|
return coerceBool(value) !== null;
|
|
754
812
|
case 'write_mode':
|
|
755
813
|
return isValidWriteMode(value);
|
|
@@ -26,6 +26,7 @@ const REDACTED_KEYS = new Set([
|
|
|
26
26
|
const INTERNAL_KEYS = new Set([
|
|
27
27
|
'default_model_set',
|
|
28
28
|
'file_keys',
|
|
29
|
+
'assistant_file_keys',
|
|
29
30
|
// Task 4 — effort_set is loader-derived; effort_explicit is the stored
|
|
30
31
|
// marker behind it (plumbing, not a user-facing setting).
|
|
31
32
|
'effort_set',
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// cspeach-cli/src/commands/export-docx.ts
|
|
2
|
+
//
|
|
3
|
+
// `cspeach export <file>.md --docx` and `/export docx <file>`: one body for both.
|
|
4
|
+
// Reads the Markdown, writes <base>.docx next to it (never overwriting), prints
|
|
5
|
+
// the path. Same emit seam as export-audit.ts (chunkEmitter in Ink, console.log
|
|
6
|
+
// in classic, console.log in cli.ts).
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { metaFromMarkdown } from '../docx/doc-meta.js';
|
|
10
|
+
import { writeDocxNextTo } from '../docx/write-docx.js';
|
|
11
|
+
export const EXPORT_DOCX_USAGE = 'Usage: cspeach export <file>.md --docx (in the REPL: /export docx <file>.md)';
|
|
12
|
+
export function parseExportDocxArgs(args) {
|
|
13
|
+
let docx = false;
|
|
14
|
+
let file;
|
|
15
|
+
for (const a of args) {
|
|
16
|
+
if (a === '--docx')
|
|
17
|
+
docx = true;
|
|
18
|
+
else if (!a.startsWith('-') && !file)
|
|
19
|
+
file = a;
|
|
20
|
+
}
|
|
21
|
+
return { ...(file ? { file } : {}), docx };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The file after `/export docx` in the REPL: the whole rest of the line, so a
|
|
25
|
+
* path with spaces survives (OneDrive folders), with one pair of quotes removed.
|
|
26
|
+
*/
|
|
27
|
+
export function docxFileFromReplArgs(rest) {
|
|
28
|
+
const m = /^\s*docx\b\s*(.*)$/is.exec(rest);
|
|
29
|
+
if (!m)
|
|
30
|
+
return undefined;
|
|
31
|
+
const f = m[1].trim().replace(/^(["'])(.*)\1$/s, '$2').trim();
|
|
32
|
+
return f.length > 0 ? f : undefined;
|
|
33
|
+
}
|
|
34
|
+
function localDate() {
|
|
35
|
+
const d = new Date();
|
|
36
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
37
|
+
}
|
|
38
|
+
export async function runExportDocxCommand(opts) {
|
|
39
|
+
const { emit } = opts;
|
|
40
|
+
if (!opts.docx || !opts.file) {
|
|
41
|
+
emit(EXPORT_DOCX_USAGE);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
const mdPath = path.resolve(opts.cwd ?? process.cwd(), opts.file);
|
|
45
|
+
if (path.extname(mdPath).toLowerCase() !== '.md') {
|
|
46
|
+
emit(`Only .md files can be exported to Word. ${EXPORT_DOCX_USAGE}`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
if (!fs.existsSync(mdPath)) {
|
|
50
|
+
emit(`File not found: ${mdPath}`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
// Inside the try: a directory named x.md (EISDIR) or an unreadable file
|
|
55
|
+
// (EACCES) gives the one failure line below, not a stack.
|
|
56
|
+
const markdown = await fs.promises.readFile(mdPath, 'utf8');
|
|
57
|
+
const meta = metaFromMarkdown(markdown, { date: opts.today ?? localDate(), ...(opts.system ? { system: opts.system } : {}) });
|
|
58
|
+
const out = await writeDocxNextTo(mdPath, markdown, meta);
|
|
59
|
+
emit(`Word copy: ${out}`);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
emit(`Word copy not written. ${e.message}`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/commands/help.js
CHANGED
|
@@ -50,6 +50,7 @@ export const HELP_GROUPS = [
|
|
|
50
50
|
] },
|
|
51
51
|
{ title: 'Session', rows: [
|
|
52
52
|
{ name: '/new', description: fixed('Next prompt picks a skill afresh (also /reset)') },
|
|
53
|
+
{ name: '/chat on', description: fixed('Switch to chat — just talk; /abap-… for a guided workflow') },
|
|
53
54
|
{ name: '/recap', description: fixed('Show the recap (last session, open tasks, plan)') },
|
|
54
55
|
{ name: '/cost', description: costCommandDescription },
|
|
55
56
|
{ name: '/usage', description: usageCommandDescription },
|
|
@@ -61,8 +62,30 @@ export const HELP_GROUPS = [
|
|
|
61
62
|
{ title: 'Look and settings', rows: [
|
|
62
63
|
{ name: '/ui [auto|ink]', description: fixed('Change the UI mode (applies after a relaunch)') },
|
|
63
64
|
{ name: '/ui look [panels|legacy]', description: fixed('Switch between the panels and legacy looks') },
|
|
65
|
+
{ name: '/style [professional|conversational|humor|blunt]', description: fixed('Change the talking style — words only; findings and warnings never change') },
|
|
66
|
+
{ name: '/level [developer|learning|functional]', description: fixed('Who CSPeach is talking to — how much it explains, never what it found; for a non-developer also run cspeach config set write_mode advisory-only') },
|
|
64
67
|
] },
|
|
65
68
|
];
|
|
69
|
+
/** Chat lane (spec 2026-10-17 D8): the Session group's /new reads "Fresh start"
|
|
70
|
+
* and /chat + /chat off replace the skills lane's /chat on row. */
|
|
71
|
+
const CHAT_HELP_GROUPS = HELP_GROUPS.map((g) => g.title !== 'Session' ? g : {
|
|
72
|
+
title: g.title,
|
|
73
|
+
rows: g.rows.flatMap((r) => {
|
|
74
|
+
if (r.name === '/new')
|
|
75
|
+
return [
|
|
76
|
+
{ name: '/new', description: fixed('Fresh start (also /reset)') },
|
|
77
|
+
{ name: '/chat', description: fixed('Back to chat — leave the current workflow') },
|
|
78
|
+
{ name: '/chat off', description: fixed('Skill routing — every message picks a skill again') },
|
|
79
|
+
];
|
|
80
|
+
if (r.name === '/chat on')
|
|
81
|
+
return [];
|
|
82
|
+
return [r];
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
/** The help groups for a lane; the skills lane gets HELP_GROUPS itself. */
|
|
86
|
+
export function helpGroupsFor(lane) {
|
|
87
|
+
return lane === 'chat' ? CHAT_HELP_GROUPS : HELP_GROUPS;
|
|
88
|
+
}
|
|
66
89
|
/** /help lists the `?` key itself first, then I2's sheet rows in their order. */
|
|
67
90
|
const SHORTCUTS = [['?', 'shortcuts'], ...SHORTCUT_ROWS];
|
|
68
91
|
/**
|
|
@@ -103,14 +126,17 @@ const groupTitle = (t) => paint('sap', chalk.bold(t));
|
|
|
103
126
|
* can route output through chunkEmitter instead of console.log (which
|
|
104
127
|
* corrupts the React frame). Default keeps the classic-mode behaviour.
|
|
105
128
|
*/
|
|
106
|
-
export function printHelp(emit = (s) => console.log(s)) {
|
|
129
|
+
export function printHelp(emit = (s) => console.log(s), opts = {}) {
|
|
130
|
+
const lane = opts.lane ?? 'skills';
|
|
107
131
|
const width = uiWidth();
|
|
108
132
|
const descWidth = Math.max(MIN_DESC_WIDTH, width - NAME_COL_WIDTH);
|
|
109
133
|
const continuationIndent = ' '.repeat(NAME_COL_WIDTH);
|
|
110
134
|
const row = (name, desc) => ` ${chalk.bold(truncateToWidth(name, NAME_COL_PAD).padEnd(NAME_COL_PAD))} ${paint('dim', truncateToWidth(desc, descWidth))}`;
|
|
111
135
|
emit('');
|
|
112
136
|
emit(panelHeading('Commands', 'type / to search'));
|
|
113
|
-
|
|
137
|
+
if (lane === 'chat')
|
|
138
|
+
emit(paint('dim', 'Just talk to me. Use /abap-… when you want a specific guided workflow.'));
|
|
139
|
+
for (const group of helpGroupsFor(lane)) {
|
|
114
140
|
emit('');
|
|
115
141
|
emit(groupTitle(group.title));
|
|
116
142
|
for (const r of group.rows) {
|
package/dist/config/loader.js
CHANGED
|
@@ -4,6 +4,8 @@ import toml from '@iarna/toml';
|
|
|
4
4
|
import { configFile, cspeachRoot } from './paths.js';
|
|
5
5
|
import { DEFAULT_SESSION_MODEL } from './model-defaults.js';
|
|
6
6
|
import { sanitiseStandaloneProfile } from '../sap/standalone-profile.js';
|
|
7
|
+
import { DEFAULT_STYLE, parseStyle } from '../agent/style.js';
|
|
8
|
+
import { DEFAULT_LEVEL, parseLevel } from '../agent/level.js';
|
|
7
9
|
/**
|
|
8
10
|
* Whitelist guard for WriteMode — rejects typos / stale values from disk so
|
|
9
11
|
* downstream code that trusts the WriteMode type doesn't see garbage.
|
|
@@ -36,6 +38,12 @@ export const DEFAULT_CONFIG = {
|
|
|
36
38
|
// the default; 'legacy' reproduces the pre-overhaul output exactly.
|
|
37
39
|
// `background` is left unset: getBackground() then reads COLORFGBG, else dark.
|
|
38
40
|
ui: { rendering: 'auto', look: 'panels' },
|
|
41
|
+
// 2026-10-17 — chat lane (spec docs/superpowers/specs/2026-10-17-chat-lane-and-style-design.md
|
|
42
|
+
// D2/D9). 'skills' is today's router — the default for the first release
|
|
43
|
+
// (staged rollout, owner 2026-10-17; users opt in with /chat on, a later
|
|
44
|
+
// release flips this); 'professional' sends no style; 'developer' sends no
|
|
45
|
+
// level (§10 D13).
|
|
46
|
+
assistant: { lane: 'skills', style: DEFAULT_STYLE, level: DEFAULT_LEVEL },
|
|
39
47
|
// Default `approval-gated` — preserves current UX (user approves each write).
|
|
40
48
|
write_mode: 'approval-gated',
|
|
41
49
|
// Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
|
|
@@ -186,6 +194,14 @@ export async function loadConfig() {
|
|
|
186
194
|
// when it's strictly true — a string/typo collapses to "absent" so it
|
|
187
195
|
// can't win the `local_build ?? local_files` fallback.
|
|
188
196
|
local_files: parsed.local_files === true ? true : undefined,
|
|
197
|
+
// word_copy: same absent-preserving coercion as local_build. Absent →
|
|
198
|
+
// undefined ("ask"); a typo collapses to false ("never ask"), so a typo
|
|
199
|
+
// can never write files nobody asked for.
|
|
200
|
+
word_copy: parsed.word_copy === true
|
|
201
|
+
? true
|
|
202
|
+
: parsed.word_copy === undefined
|
|
203
|
+
? undefined
|
|
204
|
+
: false,
|
|
189
205
|
// render_smoke: plain boolean, DEFAULT true (no legacy alias). Preserve
|
|
190
206
|
// only a genuine boolean; a typo/wrong-type collapses to undefined which
|
|
191
207
|
// resolveRenderSmoke reads as the default ON (fail-safe: a config typo can
|
|
@@ -268,6 +284,9 @@ export async function loadConfig() {
|
|
|
268
284
|
llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
|
|
269
285
|
classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
|
|
270
286
|
ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) },
|
|
287
|
+
assistant: { ...DEFAULT_CONFIG.assistant, ...(parsed.assistant ?? {}) },
|
|
288
|
+
// INTERNAL — the sub-keys the file's [assistant] table carried (see the type).
|
|
289
|
+
assistant_file_keys: parsed.assistant && typeof parsed.assistant === 'object' ? Object.keys(parsed.assistant) : [],
|
|
271
290
|
// Deep-merge `compact` so a user config that only overrides one
|
|
272
291
|
// field (e.g. just `keep_recent_turns = 10`) still gets the four
|
|
273
292
|
// unspecified defaults — and a typo'd field falls through harmlessly.
|
|
@@ -284,6 +303,11 @@ export async function loadConfig() {
|
|
|
284
303
|
// 2026-09-26 — unknown values fall back instead of reaching the renderer.
|
|
285
304
|
if (merged.ui.look !== 'panels' && merged.ui.look !== 'legacy')
|
|
286
305
|
merged.ui.look = DEFAULT_CONFIG.ui.look;
|
|
306
|
+
// Chat lane (2026-10-17) — unknown values fall back, like ui.look.
|
|
307
|
+
if (merged.assistant.lane !== 'chat' && merged.assistant.lane !== 'skills')
|
|
308
|
+
merged.assistant.lane = DEFAULT_CONFIG.assistant.lane;
|
|
309
|
+
merged.assistant.style = parseStyle(merged.assistant.style) ?? DEFAULT_CONFIG.assistant.style;
|
|
310
|
+
merged.assistant.level = parseLevel(merged.assistant.level) ?? DEFAULT_CONFIG.assistant.level;
|
|
287
311
|
if (merged.ui.background !== undefined && merged.ui.background !== 'dark' && merged.ui.background !== 'light') {
|
|
288
312
|
delete merged.ui.background;
|
|
289
313
|
}
|
|
@@ -311,6 +335,9 @@ export async function loadConfig() {
|
|
|
311
335
|
// callers see a genuine "absent" shape (treated as OFF → credits).
|
|
312
336
|
if (merged.show_cogs === undefined)
|
|
313
337
|
delete merged.show_cogs;
|
|
338
|
+
// Same for `word_copy` (absent = ask each time).
|
|
339
|
+
if (merged.word_copy === undefined)
|
|
340
|
+
delete merged.word_copy;
|
|
314
341
|
// Same for `plan_mode`: absent-or-invalid must not serialise as
|
|
315
342
|
// `plan_mode = undefined` — drop the key so `config show` stays clean and
|
|
316
343
|
// callers see a genuine "absent" shape (treated as 'step').
|
|
@@ -378,6 +405,14 @@ export async function loadConfig() {
|
|
|
378
405
|
throw err;
|
|
379
406
|
}
|
|
380
407
|
}
|
|
408
|
+
/** The effective Word-copy behaviour of the save hook (see `word_copy`). */
|
|
409
|
+
export function resolveWordCopy(cfg) {
|
|
410
|
+
if (cfg.word_copy === true)
|
|
411
|
+
return 'always';
|
|
412
|
+
if (cfg.word_copy === false)
|
|
413
|
+
return 'never';
|
|
414
|
+
return 'ask';
|
|
415
|
+
}
|
|
381
416
|
/**
|
|
382
417
|
* Resolve the effective "local build tools" toggle from a loaded config.
|
|
383
418
|
*
|
|
@@ -432,7 +467,7 @@ export async function saveConfig(config) {
|
|
|
432
467
|
// file-key snapshot) and must never be written back into config.toml.
|
|
433
468
|
// effort_set is internal too (Task 4); the stored marker effort_explicit is kept.
|
|
434
469
|
// compact_threshold_set is internal too (Task 19).
|
|
435
|
-
const { default_model_set: _dropSet, effort_set: _dropEffortSet, compact_threshold_set: _dropThresholdSet, file_keys: _dropKeys, ...toWrite } = config;
|
|
470
|
+
const { default_model_set: _dropSet, effort_set: _dropEffortSet, compact_threshold_set: _dropThresholdSet, file_keys: _dropKeys, assistant_file_keys: assistantFileKeys, ...toWrite } = config;
|
|
436
471
|
// Sparse serialisation: drop any key that the file did NOT carry AND whose
|
|
437
472
|
// value is deep-equal to the built-in default. Keys absent from DEFAULT_CONFIG
|
|
438
473
|
// (optional keys like audit_model) never deep-equal a defined value, so any
|
|
@@ -446,6 +481,21 @@ export async function saveConfig(config) {
|
|
|
446
481
|
delete writable[k];
|
|
447
482
|
}
|
|
448
483
|
}
|
|
484
|
+
// The same rule one level down for [assistant] (staged rollout, 2026-10-17):
|
|
485
|
+
// write a field only when the file carried it or it differs from the default.
|
|
486
|
+
if (writable.assistant && typeof writable.assistant === 'object') {
|
|
487
|
+
const keep = assistantFileKeys ?? [];
|
|
488
|
+
const defaults = DEFAULT_CONFIG.assistant;
|
|
489
|
+
const a = { ...writable.assistant };
|
|
490
|
+
for (const k of Object.keys(a)) {
|
|
491
|
+
if (!keep.includes(k) && a[k] === defaults[k])
|
|
492
|
+
delete a[k];
|
|
493
|
+
}
|
|
494
|
+
if (Object.keys(a).length === 0)
|
|
495
|
+
delete writable.assistant;
|
|
496
|
+
else
|
|
497
|
+
writable.assistant = a;
|
|
498
|
+
}
|
|
449
499
|
await fs.writeFile(configFile(), toml.stringify(writable), 'utf-8');
|
|
450
500
|
}
|
|
451
501
|
/** The effective plan-run approval mode — absent/invalid ⇒ 'plan'. */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// cspeach-cli/src/docx/doc-meta.ts
|
|
2
|
+
//
|
|
3
|
+
// The metadata a Word copy carries on its title page. Filled by the caller
|
|
4
|
+
// (the save hook knows the session's SAP alias; the export command knows the
|
|
5
|
+
// file) and, for what the text itself says, by metaFromMarkdown (Task 3).
|
|
6
|
+
const KIND_TITLE = { review: 'Code review', handover: 'Handover', document: 'Document' };
|
|
7
|
+
/** Z/Y objects, SAP-style names: ZCL_X, ZFI_DUP_INVOICE_CHECK, Z_REPORT, /NS/ZCL_X. */
|
|
8
|
+
const OBJECT_RE = /(?<![\w/])((?:\/[A-Z0-9]+\/)?[ZY][A-Z0-9_]{2,39})\b/;
|
|
9
|
+
/** Inline Markdown → plain words: `code` → code, [text](url) → text, **x** → x. */
|
|
10
|
+
function plain(s) {
|
|
11
|
+
return s
|
|
12
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
13
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
14
|
+
.replace(/\*\*([^*]*)\*\*/g, '$1')
|
|
15
|
+
// *x* / _x_ only at word edges, so ZFI_DUP_INVOICE_CHECK keeps its underscores.
|
|
16
|
+
.replace(/(^|[^\w])([*_])([^*_\n]+)\2(?!\w)/g, '$1$3')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.trim();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Title = the first ATX heading (any level), cleaned. Object = the Z/Y name after
|
|
22
|
+
* " — " / " - " in that heading, else the first Z/Y name anywhere in that
|
|
23
|
+
* heading, else the first `**Object:** NAME` line. Kind from
|
|
24
|
+
* the hints. Never throws; a text with no heading gets the kind's title.
|
|
25
|
+
*/
|
|
26
|
+
export function metaFromMarkdown(markdown, hints) {
|
|
27
|
+
const lines = (markdown ?? '').split(/\r?\n/);
|
|
28
|
+
const heading = lines.find((l) => /^#{1,6}\s+\S/.test(l));
|
|
29
|
+
const title = heading ? plain(heading.replace(/^#{1,6}\s+/, '')) : KIND_TITLE[hints.kind ?? 'document'];
|
|
30
|
+
let object;
|
|
31
|
+
const afterDash = heading ? plain(heading).split(/\s[—–-]\s/)[1] : undefined;
|
|
32
|
+
object = afterDash?.match(OBJECT_RE)?.[1];
|
|
33
|
+
// Headings without a dash: "Performance Analysis: ZCL_X", "ZCL_X (CLAS)".
|
|
34
|
+
if (!object && heading)
|
|
35
|
+
object = plain(heading).match(OBJECT_RE)?.[1];
|
|
36
|
+
if (!object) {
|
|
37
|
+
const objLine = lines.find((l) => /^\*\*Object:\*\*/i.test(l.trim()));
|
|
38
|
+
object = objLine ? plain(objLine.replace(/^\*\*Object:\*\*/i, '')).match(OBJECT_RE)?.[1] : undefined;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
title,
|
|
42
|
+
date: hints.date,
|
|
43
|
+
...(hints.system ? { system: hints.system } : {}),
|
|
44
|
+
...(object ? { object } : {}),
|
|
45
|
+
...(hints.kind ? { kind: hints.kind } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|