@cspeach/cli 1.1.45 → 1.1.47
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/goodbye.js +4 -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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// cspeach-cli/src/docx/markdown-to-docx.ts
|
|
2
|
+
//
|
|
3
|
+
// Markdown string + metadata → .docx Buffer. One pure function, no UI, no
|
|
4
|
+
// filesystem: the save hook, the export command and (later) the transport
|
|
5
|
+
// dossier all call it.
|
|
6
|
+
//
|
|
7
|
+
// Parser: the `marked` lexer the CLI already ships (renderer/markdown.ts uses
|
|
8
|
+
// it for terminal output). A fresh `Marked` instance here so the terminal
|
|
9
|
+
// extensions never leak in. Writer: the `docx` package.
|
|
10
|
+
//
|
|
11
|
+
// Rule: unknown Markdown degrades to plain text; this function never throws
|
|
12
|
+
// for any input string. Every block is converted inside a try/catch that falls
|
|
13
|
+
// back to the token's raw text.
|
|
14
|
+
import { Marked } from 'marked';
|
|
15
|
+
import { AlignmentType, BorderStyle, Document, ExternalHyperlink, HeadingLevel, LevelFormat, Packer, PageBreak, Paragraph, ShadingType, Table, TableCell, TableRow, TextRun, WidthType, } from 'docx';
|
|
16
|
+
const CODE_FONT = 'Consolas';
|
|
17
|
+
const CODE_SIZE = 18; // half-points: 9 pt
|
|
18
|
+
const CODE_FILL = 'F2F2F2';
|
|
19
|
+
const NUMBERED = 'cspeach-numbered';
|
|
20
|
+
// Text width of the docx default page (A4, 1-inch margins) in twips. Tables get
|
|
21
|
+
// explicit column widths that add up to it: with percentage widths only, docx
|
|
22
|
+
// writes 100-twip grid columns and Google Docs shows one letter per line.
|
|
23
|
+
const TEXT_WIDTH = 11906 - 2 * 1440;
|
|
24
|
+
const MIN_COL = 720; // half an inch
|
|
25
|
+
const HEADINGS = [
|
|
26
|
+
HeadingLevel.HEADING_1, HeadingLevel.HEADING_2, HeadingLevel.HEADING_3,
|
|
27
|
+
HeadingLevel.HEADING_4, HeadingLevel.HEADING_5, HeadingLevel.HEADING_6,
|
|
28
|
+
];
|
|
29
|
+
/** One numbered-list instance per list so each restarts at 1. */
|
|
30
|
+
class Ctx {
|
|
31
|
+
numberedInstances = 0;
|
|
32
|
+
}
|
|
33
|
+
export async function markdownToDocx(markdown, meta) {
|
|
34
|
+
const ctx = new Ctx();
|
|
35
|
+
const body = blocksFrom(markdown, ctx);
|
|
36
|
+
const doc = new Document({
|
|
37
|
+
creator: 'CSPeach',
|
|
38
|
+
lastModifiedBy: 'CSPeach',
|
|
39
|
+
title: meta.title,
|
|
40
|
+
numbering: {
|
|
41
|
+
config: [{
|
|
42
|
+
reference: NUMBERED,
|
|
43
|
+
levels: [0, 1, 2].map((level) => ({
|
|
44
|
+
level,
|
|
45
|
+
format: LevelFormat.DECIMAL,
|
|
46
|
+
text: `%${level + 1}.`,
|
|
47
|
+
alignment: AlignmentType.START,
|
|
48
|
+
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
|
|
49
|
+
})),
|
|
50
|
+
}],
|
|
51
|
+
},
|
|
52
|
+
styles: {
|
|
53
|
+
default: { document: { run: { font: 'Calibri', size: 22 } } },
|
|
54
|
+
},
|
|
55
|
+
sections: [{ children: [...titlePage(meta), ...body] }],
|
|
56
|
+
});
|
|
57
|
+
return Packer.toBuffer(doc);
|
|
58
|
+
}
|
|
59
|
+
/** Title, date, system, object, "Prepared with CSPeach", then a page break. (Task 3 pins it.) */
|
|
60
|
+
function titlePage(meta) {
|
|
61
|
+
const facts = [`Date: ${meta.date}`];
|
|
62
|
+
if (meta.system)
|
|
63
|
+
facts.push(`System: ${meta.system}`);
|
|
64
|
+
if (meta.object)
|
|
65
|
+
facts.push(`Object: ${meta.object}`);
|
|
66
|
+
return [
|
|
67
|
+
new Paragraph({ text: meta.title, heading: HeadingLevel.TITLE, spacing: { before: 2400 } }),
|
|
68
|
+
...facts.map((f) => new Paragraph({ text: f })),
|
|
69
|
+
new Paragraph({ text: 'Prepared with CSPeach', spacing: { before: 480 } }),
|
|
70
|
+
new Paragraph({ children: [new PageBreak()] }),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
/** Lex, then convert every top-level block. Never throws. */
|
|
74
|
+
export function blocksFrom(markdown, ctx = new Ctx()) {
|
|
75
|
+
let tokens;
|
|
76
|
+
try {
|
|
77
|
+
tokens = new Marked({ gfm: true }).lexer(markdown ?? '');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [new Paragraph({ text: markdown ?? '' })];
|
|
81
|
+
}
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const t of tokens)
|
|
84
|
+
out.push(...blockToDocx(t, ctx, 0));
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function blockToDocx(t, ctx, depth, extra = {}) {
|
|
88
|
+
try {
|
|
89
|
+
switch (t.type) {
|
|
90
|
+
case 'space': return [];
|
|
91
|
+
case 'heading': {
|
|
92
|
+
const h = t;
|
|
93
|
+
return [new Paragraph({ ...extra, heading: HEADINGS[Math.min(h.depth, 6) - 1], children: inline(h.tokens) })];
|
|
94
|
+
}
|
|
95
|
+
case 'paragraph':
|
|
96
|
+
case 'text': {
|
|
97
|
+
const p = t;
|
|
98
|
+
const kids = 'tokens' in p && p.tokens ? inline(p.tokens) : [new TextRun(p.text ?? p.raw ?? '')];
|
|
99
|
+
return [new Paragraph({ ...extra, children: kids })];
|
|
100
|
+
}
|
|
101
|
+
case 'list': return list(t, ctx, depth);
|
|
102
|
+
case 'code': return code(t);
|
|
103
|
+
case 'table': return [table(t)];
|
|
104
|
+
case 'blockquote': return quote(t, ctx, depth);
|
|
105
|
+
case 'hr': return [new Paragraph({ thematicBreak: true })];
|
|
106
|
+
case 'html': return [new Paragraph({ ...extra, text: t.raw.trim() })];
|
|
107
|
+
default: return [new Paragraph({ ...extra, text: ('raw' in t ? String(t.raw) : '').trim() })];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [new Paragraph({ text: ('raw' in t ? String(t.raw) : '').trim() })];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Nested lists: a bullet or numbered paragraph per item; child lists recurse one level deeper. */
|
|
115
|
+
function list(l, ctx, depth) {
|
|
116
|
+
const level = Math.min(depth, 2);
|
|
117
|
+
const instance = l.ordered ? ctx.numberedInstances++ : -1;
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const item of l.items) {
|
|
120
|
+
const mark = l.ordered
|
|
121
|
+
? { numbering: { reference: NUMBERED, level, instance } }
|
|
122
|
+
: { bullet: { level } };
|
|
123
|
+
let first = true;
|
|
124
|
+
for (const child of item.tokens) {
|
|
125
|
+
if (child.type === 'list') {
|
|
126
|
+
out.push(...list(child, ctx, depth + 1));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// The first text block of an item carries the marker; later paragraphs are indented plain text.
|
|
130
|
+
out.push(...blockToDocx(child, ctx, depth, first ? mark : { indent: { left: 720 * (level + 1) } }));
|
|
131
|
+
first = false;
|
|
132
|
+
}
|
|
133
|
+
if (first)
|
|
134
|
+
out.push(new Paragraph({ ...mark, text: item.text ?? '' }));
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function code(c) {
|
|
139
|
+
const lines = (c.text ?? '').split('\n');
|
|
140
|
+
return lines.map((line) => new Paragraph({
|
|
141
|
+
shading: { type: ShadingType.CLEAR, fill: CODE_FILL },
|
|
142
|
+
spacing: { before: 0, after: 0 },
|
|
143
|
+
children: [new TextRun({ text: line.length ? line : ' ', font: CODE_FONT, size: CODE_SIZE })],
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
function table(t) {
|
|
147
|
+
const width = Math.max(1, t.header.length);
|
|
148
|
+
const pad = (cells) => {
|
|
149
|
+
const row = cells.slice(0, width);
|
|
150
|
+
while (row.length < width)
|
|
151
|
+
row.push({ text: '', tokens: [] });
|
|
152
|
+
return row;
|
|
153
|
+
};
|
|
154
|
+
const header = pad(t.header);
|
|
155
|
+
const rows = t.rows.map(pad);
|
|
156
|
+
const cols = columnWidths([header, ...rows], width);
|
|
157
|
+
const cell = (c, i, bold) => new TableCell({
|
|
158
|
+
width: { size: cols[i], type: WidthType.DXA },
|
|
159
|
+
children: [new Paragraph({ children: inline(c.tokens ?? [], bold ? { bold: true } : {}) })],
|
|
160
|
+
shading: bold ? { type: ShadingType.CLEAR, fill: 'E7E6E6' } : undefined,
|
|
161
|
+
});
|
|
162
|
+
return new Table({
|
|
163
|
+
width: { size: TEXT_WIDTH, type: WidthType.DXA },
|
|
164
|
+
columnWidths: cols,
|
|
165
|
+
rows: [
|
|
166
|
+
new TableRow({ tableHeader: true, children: header.map((c, i) => cell(c, i, true)) }),
|
|
167
|
+
...rows.map((r) => new TableRow({ children: r.map((c, i) => cell(c, i, false)) })),
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Split the page width by each column's longest text (capped), never below MIN_COL. */
|
|
172
|
+
function columnWidths(rows, n) {
|
|
173
|
+
const weight = Array.from({ length: n }, (_, i) => Math.min(60, Math.max(3, ...rows.map((r) => (r[i]?.text ?? '').length))));
|
|
174
|
+
const total = weight.reduce((a, b) => a + b, 0);
|
|
175
|
+
const spare = Math.max(0, TEXT_WIDTH - MIN_COL * n);
|
|
176
|
+
const cols = weight.map((w) => MIN_COL + Math.floor((spare * w) / total));
|
|
177
|
+
cols[n - 1] += TEXT_WIDTH - cols.reduce((a, b) => a + b, 0);
|
|
178
|
+
return cols;
|
|
179
|
+
}
|
|
180
|
+
function quote(q, ctx, depth) {
|
|
181
|
+
const extra = {
|
|
182
|
+
indent: { left: 720 },
|
|
183
|
+
border: { left: { style: BorderStyle.SINGLE, size: 12, color: 'BFBFBF', space: 8 } },
|
|
184
|
+
};
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const child of q.tokens)
|
|
187
|
+
out.push(...blockToDocx(child, ctx, depth, extra));
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/** Inline tokens → runs. Unknown inline → its raw text. */
|
|
191
|
+
function inline(tokens, style = {}) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const t of tokens ?? []) {
|
|
194
|
+
try {
|
|
195
|
+
switch (t.type) {
|
|
196
|
+
case 'text':
|
|
197
|
+
case 'escape':
|
|
198
|
+
out.push(run(t.text, style));
|
|
199
|
+
break;
|
|
200
|
+
case 'strong':
|
|
201
|
+
out.push(...inline(t.tokens, { ...style, bold: true }));
|
|
202
|
+
break;
|
|
203
|
+
case 'em':
|
|
204
|
+
out.push(...inline(t.tokens, { ...style, italics: true }));
|
|
205
|
+
break;
|
|
206
|
+
case 'del':
|
|
207
|
+
out.push(...inline(t.tokens, { ...style, strike: true }));
|
|
208
|
+
break;
|
|
209
|
+
case 'codespan':
|
|
210
|
+
out.push(run(t.text, { ...style, code: true }));
|
|
211
|
+
break;
|
|
212
|
+
case 'br':
|
|
213
|
+
out.push(new TextRun({ break: 1 }));
|
|
214
|
+
break;
|
|
215
|
+
case 'link': {
|
|
216
|
+
const l = t;
|
|
217
|
+
out.push(new ExternalHyperlink({
|
|
218
|
+
link: l.href,
|
|
219
|
+
children: [new TextRun({ text: unescapeHtml(textOf(l.tokens) || l.href), style: 'Hyperlink' })],
|
|
220
|
+
}));
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'image':
|
|
224
|
+
out.push(run(t.text || t.href, style));
|
|
225
|
+
break;
|
|
226
|
+
default: out.push(run('raw' in t ? String(t.raw) : '', style));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
out.push(run('raw' in t ? String(t.raw) : '', style));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
// marked's lexer HTML-escapes text and inline code; docx escapes again on write,
|
|
236
|
+
// so without this the reader sees ">" instead of ">".
|
|
237
|
+
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
238
|
+
function unescapeHtml(text) {
|
|
239
|
+
return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m]);
|
|
240
|
+
}
|
|
241
|
+
function run(text, s) {
|
|
242
|
+
return new TextRun({
|
|
243
|
+
text: unescapeHtml(text ?? ''),
|
|
244
|
+
bold: s.bold, italics: s.italics, strike: s.strike,
|
|
245
|
+
...(s.code ? { font: CODE_FONT, size: CODE_SIZE } : {}),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function textOf(tokens) {
|
|
249
|
+
return (tokens ?? []).map((t) => ('text' in t ? String(t.text) : ('raw' in t ? String(t.raw) : ''))).join('');
|
|
250
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// cspeach-cli/src/docx/write-docx.ts
|
|
2
|
+
//
|
|
3
|
+
// The Word copy goes next to its Markdown source with the same base name.
|
|
4
|
+
// Never overwrites (repo convention: projects/save.ts, projects/sort-command.ts):
|
|
5
|
+
// `wx` flag, then -2, -3 … when the name is taken. A file held open by Word
|
|
6
|
+
// or a synced folder (EPERM / EBUSY) is reported once in plain words, never
|
|
7
|
+
// retried in a loop.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { markdownToDocx } from './markdown-to-docx.js';
|
|
11
|
+
const MAX_SUFFIX = 999;
|
|
12
|
+
export function docxPathFor(mdPath) {
|
|
13
|
+
const parsed = path.parse(mdPath);
|
|
14
|
+
const base = parsed.ext.toLowerCase() === '.md' ? parsed.name : parsed.base;
|
|
15
|
+
return path.join(parsed.dir, `${base}.docx`);
|
|
16
|
+
}
|
|
17
|
+
/** The first of `p`, `<stem>-2<ext>`, `-3` … that does not exist. */
|
|
18
|
+
export async function uniquePath(p) {
|
|
19
|
+
const { dir, name, ext } = path.parse(p);
|
|
20
|
+
let candidate = p;
|
|
21
|
+
for (let n = 2; n <= MAX_SUFFIX; n++) {
|
|
22
|
+
if (!fs.existsSync(candidate))
|
|
23
|
+
return candidate;
|
|
24
|
+
candidate = path.join(dir, `${name}-${n}${ext}`);
|
|
25
|
+
}
|
|
26
|
+
if (!fs.existsSync(candidate))
|
|
27
|
+
return candidate;
|
|
28
|
+
throw new Error(`Too many copies of ${path.basename(p)} in ${dir} (${MAX_SUFFIX}). Delete some and try again.`);
|
|
29
|
+
}
|
|
30
|
+
/** Write `data` to `target`, or to the next free -N name if a race took it. */
|
|
31
|
+
export async function writeNoClobber(target, data) {
|
|
32
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
33
|
+
const p = await uniquePath(target);
|
|
34
|
+
try {
|
|
35
|
+
await fs.promises.writeFile(p, data, { flag: 'wx' });
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
const code = e.code;
|
|
40
|
+
if (code === 'EEXIST')
|
|
41
|
+
continue;
|
|
42
|
+
if (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES') {
|
|
43
|
+
throw new Error(`Could not write ${p}: it is open in another program or the folder is read-only. Close it and try again.`);
|
|
44
|
+
}
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Could not find a free name for ${path.basename(target)}.`);
|
|
49
|
+
}
|
|
50
|
+
export async function writeDocxNextTo(mdPath, markdown, meta) {
|
|
51
|
+
const buf = await markdownToDocx(markdown, meta);
|
|
52
|
+
await fs.promises.mkdir(path.dirname(mdPath), { recursive: true });
|
|
53
|
+
return writeNoClobber(docxPathFor(mdPath), buf);
|
|
54
|
+
}
|
package/dist/one-shot.js
CHANGED
|
@@ -26,6 +26,8 @@ import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer
|
|
|
26
26
|
import { setRenderingOverride, setHeadlessOverride, setOneShotOverride } from './renderer/tty.js';
|
|
27
27
|
import { resolveRenderingMode } from './renderer/rendering-mode.js';
|
|
28
28
|
import { setLook, setBackground } from './renderer/look.js';
|
|
29
|
+
import { setAssistantStyle } from './agent/style-state.js';
|
|
30
|
+
import { setAssistantLevel } from './agent/level-state.js';
|
|
29
31
|
import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
|
|
30
32
|
import { createClassicOutputEmitter } from './renderer/notices.js';
|
|
31
33
|
import { AnswerTrim } from './renderer/answer-trim.js';
|
|
@@ -49,6 +51,7 @@ import './tools/approval.js';
|
|
|
49
51
|
import './tools/snapshot.js';
|
|
50
52
|
import './tools/ask-question.js';
|
|
51
53
|
import './tools/dispatch-skill.js';
|
|
54
|
+
import './tools/hand-off.js';
|
|
52
55
|
import './tools/todo.js';
|
|
53
56
|
import './tools/filesystem/file-read.js';
|
|
54
57
|
import './tools/tool-output-read.js';
|
|
@@ -100,6 +103,13 @@ export async function runOneShot(prompt, opts) {
|
|
|
100
103
|
setOneShotOverride(true);
|
|
101
104
|
setRenderingOverride(resolveRenderingMode(cfg.ui.rendering).mode);
|
|
102
105
|
setLook(cfg.ui.look);
|
|
106
|
+
// Chat lane D-D/D9 — the talking style rides on one-shot workflow turns too
|
|
107
|
+
// (professional = no header, so the default request is unchanged). The
|
|
108
|
+
// chat lane itself never runs here: one-shot keeps today's routing.
|
|
109
|
+
// Read defensively: a config without an `assistant` table (older test
|
|
110
|
+
// doubles, a hand-built cfg) means the defaults.
|
|
111
|
+
setAssistantStyle(cfg.assistant?.style ?? 'professional');
|
|
112
|
+
setAssistantLevel(cfg.assistant?.level ?? 'developer'); // §10 D14 — same rule as the style
|
|
103
113
|
setBackground(cfg.ui.background);
|
|
104
114
|
// model-governance step 2d — fetch the served model-config (managed mode
|
|
105
115
|
// only). Task 3: awaited, but startup waits at most 1.5 s (then a fresh disk
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/document-save.ts
|
|
2
|
+
//
|
|
3
|
+
// The save for the two document skills (2026-10-19, owner decision 2026-10-17).
|
|
4
|
+
// A review or a handover is prose, not an envelope: it goes to
|
|
5
|
+
// <cwd>/cspeach-out/<date>-<kind>-<OBJECT>.md (the folder the model already
|
|
6
|
+
// writes extra copies to; skipped by the @-picker; exempt from the Rule 8
|
|
7
|
+
// card). Then one question, "Word copy too?", unless `word_copy` decides.
|
|
8
|
+
// Never overwrites. Never throws: a failure is one line through `emit`.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import { askYesNo, isYes } from './yes-no.js';
|
|
13
|
+
import { metaFromMarkdown } from '../docx/doc-meta.js';
|
|
14
|
+
import { writeDocxNextTo, writeNoClobber } from '../docx/write-docx.js';
|
|
15
|
+
import { safeFileStem } from '../register/xlsx.js';
|
|
16
|
+
import { DOCUMENT_SKILLS } from './document-skills.js';
|
|
17
|
+
export { DOCUMENT_SKILLS };
|
|
18
|
+
export const OUT_DIR = 'cspeach-out';
|
|
19
|
+
export function documentFilename(kind, object, date) {
|
|
20
|
+
const stem = safeFileStem((object ?? 'document').replace(/\//g, ''));
|
|
21
|
+
return `${date}-${kind}-${stem}.md`;
|
|
22
|
+
}
|
|
23
|
+
export async function offerDocumentSave(p) {
|
|
24
|
+
const skill = DOCUMENT_SKILLS[p.skill];
|
|
25
|
+
if (!skill || p.markdown.trim().length === 0)
|
|
26
|
+
return {};
|
|
27
|
+
const meta = metaFromMarkdown(p.markdown, { date: p.today, kind: skill.kind, ...(p.sapSystem ? { system: p.sapSystem } : {}) });
|
|
28
|
+
if (!p.headless) {
|
|
29
|
+
const a = await askYesNo({ question: `Save this ${skill.noun} to a file?`, confirm: p.confirm });
|
|
30
|
+
if (!isYes(a))
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
p.emit(chalk.dim(`headless: saving the ${skill.noun} without asking`));
|
|
35
|
+
}
|
|
36
|
+
let mdPath;
|
|
37
|
+
try {
|
|
38
|
+
const target = path.join(p.cwd, OUT_DIR, documentFilename(skill.kind, meta.object, p.today));
|
|
39
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
40
|
+
mdPath = await writeNoClobber(target, p.markdown);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
p.emit(`Not saved. ${e.message}`);
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
p.emit(`Saved: ${path.resolve(mdPath)}`);
|
|
47
|
+
let wantWord = p.wordCopy === 'always';
|
|
48
|
+
if (p.wordCopy === 'ask' && !p.headless) {
|
|
49
|
+
wantWord = isYes(await askYesNo({ question: 'Word copy too?', confirm: p.confirm }));
|
|
50
|
+
}
|
|
51
|
+
if (!wantWord)
|
|
52
|
+
return { mdPath };
|
|
53
|
+
try {
|
|
54
|
+
const docxPath = await writeDocxNextTo(mdPath, p.markdown, meta);
|
|
55
|
+
p.emit(`Word copy: ${path.resolve(docxPath)}`);
|
|
56
|
+
return { mdPath, docxPath };
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
p.emit(`Word copy not written. ${e.message}`);
|
|
60
|
+
return { mdPath };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/document-skills.ts
|
|
2
|
+
//
|
|
3
|
+
// The two document skills, on their own so the agent loop can check a skill
|
|
4
|
+
// name without loading document-save.ts (and the docx package behind it) on
|
|
5
|
+
// every turn. Type-only import: nothing heavy loads from here.
|
|
6
|
+
export const DOCUMENT_SKILLS = {
|
|
7
|
+
'abap-review': { kind: 'review', noun: 'review' },
|
|
8
|
+
'abap-handover': { kind: 'handover', noun: 'handover' },
|
|
9
|
+
};
|
|
@@ -38,13 +38,14 @@ export function footerStateFrom(s) {
|
|
|
38
38
|
user: s.user || undefined,
|
|
39
39
|
transport: (s.transport ?? s.lastWriteTransport ?? s.confirmedTransport) || undefined,
|
|
40
40
|
standalone: s.standalone === true,
|
|
41
|
+
chat: s.chatLane === true,
|
|
41
42
|
mode: s.writeMode ?? '',
|
|
42
43
|
spend: formatSpendSegment(s),
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
const MODE_ROLE = { 'advisory-only': 'warn', 'approval-gated': 'ok', auto: 'sap' };
|
|
46
47
|
/**
|
|
47
|
-
* `<SYS> · <USER> · transport <TR> · <mode> · <spend>` on the left and a dim
|
|
48
|
+
* `<SYS> · <USER> · [chat ·] transport <TR> · <mode> · <spend>` on the left and a dim
|
|
48
49
|
* `? shortcuts` on the right, padded to exactly `width`. When it does not fit,
|
|
49
50
|
* the hint drops first, then the left side truncates.
|
|
50
51
|
*/
|
|
@@ -52,6 +53,8 @@ export function formatFooterLine(s, width) {
|
|
|
52
53
|
const parts = [];
|
|
53
54
|
if (s.standalone) {
|
|
54
55
|
parts.push({ text: 'standalone', role: 'sap' }, { text: 'local files', role: 'dim' });
|
|
56
|
+
if (s.chat)
|
|
57
|
+
parts.push({ text: 'chat', role: 'sap' });
|
|
55
58
|
}
|
|
56
59
|
else {
|
|
57
60
|
// System, user and transport come from config / SAP responses (the
|
|
@@ -60,6 +63,8 @@ export function formatFooterLine(s, width) {
|
|
|
60
63
|
parts.push(s.system ? { text: sanitizeControlChars(s.system), role: 'sap' } : { text: 'no system', role: 'dim' });
|
|
61
64
|
if (s.user)
|
|
62
65
|
parts.push({ text: sanitizeControlChars(s.user), role: 'dim' });
|
|
66
|
+
if (s.chat)
|
|
67
|
+
parts.push({ text: 'chat', role: 'sap' });
|
|
63
68
|
parts.push({ text: s.transport ? `transport ${sanitizeControlChars(s.transport)}` : 'no transport', role: 'dim' });
|
|
64
69
|
}
|
|
65
70
|
if (s.mode)
|
package/dist/renderer/goodbye.js
CHANGED
|
@@ -10,6 +10,7 @@ import { panelRow } from './panel-rows.js';
|
|
|
10
10
|
import { paint } from './theme.js';
|
|
11
11
|
import { formatInfo } from './notice-shape.js';
|
|
12
12
|
import { sanitizeControlChars } from './sanitize.js';
|
|
13
|
+
import { creditsActive } from '../cost/credits.js';
|
|
13
14
|
const LABEL = 10; // 'Last skill'
|
|
14
15
|
/**
|
|
15
16
|
* `logDir` — the display path of this session's turn-stream folder, passed
|
|
@@ -25,7 +26,9 @@ export function formatGoodbye(p) {
|
|
|
25
26
|
chalk.bold(`Session ${id}`),
|
|
26
27
|
panelRow('Turns', String(p.turns), LABEL),
|
|
27
28
|
panelRow('Last skill', skill, LABEL),
|
|
28
|
-
|
|
29
|
+
// Credits mode: no token count beside credits (owner 2026-10-02 for /cost
|
|
30
|
+
// and the footer; 2026-10-19 here) — it invites "credits per token" maths.
|
|
31
|
+
...(creditsActive() ? [] : [panelRow('Tokens', `${p.tokens.toLocaleString('en-US')} (input + output + cache)`, LABEL)]),
|
|
29
32
|
panelRow('Spend', p.spend, LABEL),
|
|
30
33
|
'',
|
|
31
34
|
// Round 6 review I1 — a session never written (no turns) has nothing to resume.
|
|
@@ -109,6 +109,8 @@ export function aliasSourceNote(source, alias) {
|
|
|
109
109
|
}
|
|
110
110
|
export function panelsStatusLine(p) {
|
|
111
111
|
const segs = [p.standalone ? paint('dim', 'standalone (no SAP system)') : paint('sap', p.system)];
|
|
112
|
+
if (p.chat)
|
|
113
|
+
segs.push(paint('sap', 'chat'));
|
|
112
114
|
if (p.tools === 'files+commands')
|
|
113
115
|
segs.push(paint('warn', 'local files + commands on'));
|
|
114
116
|
else if (p.tools === 'files')
|
|
@@ -120,10 +122,12 @@ export function panelsStatusLine(p) {
|
|
|
120
122
|
return ' ' + segs.join(paint('dim', ' · '));
|
|
121
123
|
}
|
|
122
124
|
/** Line 3 of the panels start screen (non-resume). */
|
|
123
|
-
export function panelsHintLine(tools) {
|
|
125
|
+
export function panelsHintLine(tools, lane = 'skills') {
|
|
124
126
|
const text = tools === 'files+commands'
|
|
125
127
|
? 'turn off commands: cspeach config set local_build off · ? for shortcuts'
|
|
126
|
-
:
|
|
128
|
+
: lane === 'chat'
|
|
129
|
+
? 'Just talk to me · use /abap-… when you want a specific guided workflow · /help for commands'
|
|
130
|
+
: '? for shortcuts · /help for commands';
|
|
127
131
|
return ' ' + paint('dim', text);
|
|
128
132
|
}
|
|
129
133
|
/** Line 3 of the panels start screen on --resume: the recap's "last session" fact.
|
|
@@ -199,6 +199,7 @@ const RULES = {
|
|
|
199
199
|
ask_question: (a) => ({ verb: 'Question', object: shortRight(str(a['question']) ?? '') }),
|
|
200
200
|
todo_set: () => ({ verb: 'Updated todos', object: '' }),
|
|
201
201
|
dispatch_skill: (a) => ({ verb: 'Ran skill', object: shortRight(str(a['command']) ?? '') }),
|
|
202
|
+
hand_off: (a) => ({ verb: 'Handed off', object: shortRight(`/${(str(a['skill']) ?? '').replace(/^\//, '')}`) }),
|
|
202
203
|
read_document: (a, r) => ({ verb: 'Read document', object: shortLeft(str(a['path']) ?? ''), ...(r !== undefined ? { detail: `${lineCount(r)} lines` } : {}) }),
|
|
203
204
|
web_fetch: (a) => ({ verb: 'Fetched', object: shortRight(str(a['url']) ?? '') }),
|
|
204
205
|
web_search: (a) => ({ verb: 'Searched web', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
|
|
@@ -276,7 +277,7 @@ const PRESENT = {
|
|
|
276
277
|
'Created data element': 'Creating data element', 'Read BAdI': 'Reading BAdI', 'Read enhancement options': 'Reading enhancement options of', 'Created BAdI implementation': 'Creating BAdI implementation', 'Created table type': 'Creating table type', 'Read table type': 'Reading table type', 'Read transport request': 'Reading transport request', 'Listed function group': 'Listing function group', Locked: 'Locking', Unlocked: 'Unlocking',
|
|
277
278
|
Published: 'Publishing', Unpublished: 'Unpublishing', Messages: 'Maintaining messages in',
|
|
278
279
|
'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
|
|
279
|
-
'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
|
|
280
|
+
'Ran skill': 'Running skill', 'Handed off': 'Handing off', 'Read document': 'Reading document', Fetched: 'Fetching',
|
|
280
281
|
'Searched web': 'Searching web', 'Read project context': 'Reading project context',
|
|
281
282
|
'Read playbook': 'Reading playbook', 'Read convention': 'Reading convention', 'Merged CCA': 'Merging CCA',
|
|
282
283
|
'Merged progress': 'Merging progress', 'Checked background': 'Checking background',
|
|
@@ -48,8 +48,12 @@ const peach = (s) => chalk.hex(PEACH)(s);
|
|
|
48
48
|
* block is the representation, so the raw `⏺ todo_set({"todos":[…])`
|
|
49
49
|
* line + ⎿ JSON result are suppressed. Owner feedback drove this: the
|
|
50
50
|
* transcript should show the full checklist, not a JSON tool line.
|
|
51
|
+
* - hand_off: the chat lane queues the workflow with one dim `→ /abap-x`
|
|
52
|
+
* line (queued-step origin, both REPLs) — that line is the
|
|
53
|
+
* representation, so a "Handed off /abap-x" row would only double it.
|
|
54
|
+
* A refused hand_off still shows its ✗ row.
|
|
51
55
|
*/
|
|
52
|
-
const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set']);
|
|
56
|
+
const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set', 'hand_off']);
|
|
53
57
|
export function isWidgetSuppressedTool(name) {
|
|
54
58
|
return WIDGET_SUPPRESSED_TOOLS.has(name);
|
|
55
59
|
}
|
|
@@ -33,7 +33,7 @@ export const BUILTIN_ENTRIES = [
|
|
|
33
33
|
{ name: '/cost', get description() { return costCommandDescription(); } },
|
|
34
34
|
{ name: '/usage', get description() { return usageCommandDescription(); } },
|
|
35
35
|
{ name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
|
|
36
|
-
{ name: '/export', description: 'Export the
|
|
36
|
+
{ name: '/export', description: 'Export the session audit record (/export audit [filename]) or a Word copy of a saved file (/export docx <file>.md)' },
|
|
37
37
|
{ name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
|
|
38
38
|
{ name: '/exit', description: 'Exit CSPeach' },
|
|
39
39
|
{ name: '/quit', description: 'Exit CSPeach (alias)' },
|
|
@@ -41,10 +41,13 @@ export const BUILTIN_ENTRIES = [
|
|
|
41
41
|
{ name: '/skills', description: 'Show all skills (alias)' },
|
|
42
42
|
{ name: '/mode', description: 'Cycle or set the session write mode (advisory / gated / auto) — config unchanged' },
|
|
43
43
|
{ name: '/new', description: 'Reset routing — next prompt is classified afresh' },
|
|
44
|
+
{ name: '/chat', description: 'Chat mode — /chat on · /chat off · /chat to leave a workflow' },
|
|
44
45
|
{ name: '/recap', description: 'Show the session recap (last session · open tasks · plan progress)' },
|
|
45
46
|
{ name: '/reset', description: 'Reset routing (alias)' },
|
|
46
47
|
{ name: '/rewind', description: 'Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)' },
|
|
47
48
|
{ name: '/ui', description: 'View or change rendering mode (auto / ink), or the look: /ui look panels|legacy' },
|
|
49
|
+
{ name: '/style', description: 'View or change the talking style: professional (default), conversational, humor or blunt' },
|
|
50
|
+
{ name: '/level', description: 'Who CSPeach is talking to: developer (default), learning or functional' },
|
|
48
51
|
{ name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
|
|
49
52
|
{ name: '/transport', description: 'View or set the active transport for this session' },
|
|
50
53
|
{ name: '/rules', description: 'View or change the decision rules of the joined team project (the lead changes them)' },
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { applyLaneTools } from './chat-lane.js';
|
|
2
|
+
import { clearAppFlow } from '../lane/app-flow.js';
|
|
3
|
+
import { clearJourneyFlow } from '../journey/flow.js';
|
|
4
|
+
import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
|
|
5
|
+
import { markExplicit } from '../commands/config-set.js';
|
|
6
|
+
export const CHAT_BACK_LINE = 'Back in chat · just talk, or /abap-… for a guided workflow · /chat off for skill routing';
|
|
7
|
+
export const CHAT_ON_LINE = 'Chat is on · just talk, or /abap-… for a guided workflow';
|
|
8
|
+
export const CHAT_ON_PIPED_LINE = 'Chat is on · it applies in an interactive terminal';
|
|
9
|
+
export const CHAT_OFF_LINE = 'Skill routing is on · every message picks a skill again · /chat on switches back';
|
|
10
|
+
export const CHAT_SKILLS_LANE_LINE = 'Skill routing is on · /chat on switches to chat';
|
|
11
|
+
export const CHAT_USAGE_LINE = 'Use /chat, /chat on or /chat off';
|
|
12
|
+
/** Leave the active workflow: the next message goes to chat (D6). */
|
|
13
|
+
function leaveWorkflow(session) {
|
|
14
|
+
session.skill = null;
|
|
15
|
+
session.awaitingSkillAnswer = false;
|
|
16
|
+
clearAppFlow(session);
|
|
17
|
+
clearJourneyFlow(session);
|
|
18
|
+
}
|
|
19
|
+
/** Returns the lane in force after the command. */
|
|
20
|
+
export async function handleChatCommand(arg, d) {
|
|
21
|
+
const a = arg.trim().toLowerCase();
|
|
22
|
+
const say = (s) => d.emit(formatInfo(s).trimEnd());
|
|
23
|
+
if (a === '') {
|
|
24
|
+
if (d.lane === 'chat') {
|
|
25
|
+
leaveWorkflow(d.session);
|
|
26
|
+
say(CHAT_BACK_LINE);
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
say(CHAT_SKILLS_LANE_LINE);
|
|
30
|
+
return d.lane;
|
|
31
|
+
}
|
|
32
|
+
if (a !== 'on' && a !== 'off') {
|
|
33
|
+
say(CHAT_USAGE_LINE);
|
|
34
|
+
return d.lane;
|
|
35
|
+
}
|
|
36
|
+
const next = a === 'on' ? 'chat' : 'skills';
|
|
37
|
+
// Already on with a workflow running: same as bare /chat — back in chat.
|
|
38
|
+
const hadWorkflow = !!d.session.skill;
|
|
39
|
+
if (next === 'chat')
|
|
40
|
+
leaveWorkflow(d.session);
|
|
41
|
+
else if (next !== d.lane)
|
|
42
|
+
d.session.awaitingSkillAnswer = false; // review E — a chat turn's question never carries into routing
|
|
43
|
+
if (next === 'chat' && d.lane === 'chat' && hadWorkflow) {
|
|
44
|
+
say(CHAT_BACK_LINE);
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
if (next !== d.lane) {
|
|
48
|
+
d.cfg.assistant.lane = next;
|
|
49
|
+
// Spec D2: an explicit switch is saved explicitly, even when it equals
|
|
50
|
+
// today's default, so a later release's default flip never overrides it.
|
|
51
|
+
markExplicit(d.cfg, 'assistant.lane');
|
|
52
|
+
// In static tool mode the session's tools array grows once here (hand_off);
|
|
53
|
+
// /chat off never removes it — hand_off is refused outside the chat lane.
|
|
54
|
+
applyLaneTools(next, d.interactive);
|
|
55
|
+
try {
|
|
56
|
+
await d.save(d.cfg);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
d.emit(formatFailure({ what: `Failed to save config: ${err.message} (applied for this session only)` }).trimEnd());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
say(next === 'chat' ? (d.interactive ? CHAT_ON_LINE : CHAT_ON_PIPED_LINE) : CHAT_OFF_LINE);
|
|
63
|
+
return next;
|
|
64
|
+
}
|