@cspeach/cli 1.1.49 → 1.1.51
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/cold-prune.js +1 -1
- package/dist/agent/conversation-prompt.js +52 -0
- package/dist/agent/loop.js +189 -5
- package/dist/agent/oversized-results.js +204 -0
- package/dist/agent/turn-error-ux.js +14 -0
- package/dist/cli.js +1 -1
- package/dist/commands/compact.js +6 -0
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/export-audit.js +26 -3
- package/dist/commands/export-conversation.js +343 -0
- package/dist/commands/help.js +3 -0
- package/dist/commands/plan-boundary.js +5 -0
- package/dist/commands/plan-continue-gate.js +11 -0
- package/dist/commands/plan-continue.js +11 -0
- package/dist/commands/plan-startup.js +5 -1
- package/dist/cost/cost-log.js +4 -0
- package/dist/cost/system-block.js +25 -0
- package/dist/docx/doc-meta.js +9 -1
- package/dist/docx/markdown-to-docx.js +14 -1
- package/dist/lane/app-chain.js +24 -1
- package/dist/models/server-config.js +13 -0
- package/dist/projects/document-save.js +65 -8
- package/dist/projects/document-skills.js +32 -1
- package/dist/projects/workspace.js +6 -3
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/slash-completer.js +4 -0
- package/dist/repl.js +50 -16
- package/dist/session/cli-record.js +165 -0
- package/dist/session/fresh-start.js +19 -0
- package/dist/tools/_filesystem-shared.js +7 -0
- package/dist/tools/cut-markers.js +47 -0
- package/dist/tools/filesystem/extract-document.js +50 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/read-document.js +20 -4
- package/dist/tools/tool-output-read.js +5 -1
- package/dist/tools/write-pipeline.js +5 -0
- package/dist/ui/card-slot.js +18 -1
- package/package.json +121 -121
- package/dist/commands/project.js +0 -301
- package/dist/doctor/checks/zcspeach.js +0 -96
- package/dist/projects/email-template.js +0 -56
- package/dist/projects/merge-cca.js +0 -292
- package/dist/tools/local-files.js +0 -31
- package/dist/tools/project/cca_merge.js +0 -164
- package/dist/tools/subagent/schedule_create.js +0 -131
- package/dist/ui/error-emitter.js +0 -21
- package/dist/ui/header.js +0 -41
- package/dist/ui/ink-classifier-route.js +0 -19
- package/dist/ui/rich-error-box.js +0 -9
- package/dist/ui/sidebar.js +0 -12
- package/dist/ui/status-emitter.js +0 -19
- package/dist/ui/status-line.js +0 -62
- package/dist/ui/status-row.js +0 -38
- package/dist/ui/todo-panel.js +0 -85
- package/dist/ui/widgets/ask-form.js +0 -250
- package/dist/ui/widgets/ask-question-modal.js +0 -178
- package/fiori/zlas-inspection-lr/package.json +0 -26
- package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
- package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
- package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
- package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
- package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
- package/live-check-2026-10-17-1343.txt +0 -41
|
@@ -616,7 +616,7 @@ export async function runConfigSet(args) {
|
|
|
616
616
|
if (b === null)
|
|
617
617
|
err(`${key} must be on/off (also accepts true/false, 1/0).`);
|
|
618
618
|
cfg.word_copy = b;
|
|
619
|
-
console.log(chalk.dim(b ? ' → a Word copy is written with every
|
|
619
|
+
console.log(chalk.dim(b ? ' → a Word copy is written with every document CSPeach saves.' : ' → CSPeach will not ask about Word copies.'));
|
|
620
620
|
break;
|
|
621
621
|
}
|
|
622
622
|
case 'write_mode':
|
|
@@ -17,10 +17,33 @@ import path from 'node:path';
|
|
|
17
17
|
import { buildAuditReport } from '../session/audit-export.js';
|
|
18
18
|
import { globalCredits } from '../cost/credits.js';
|
|
19
19
|
import { readCostLog } from '../cost/cost-log.js';
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* The /export usage line (2026-10-23). Here, in the light audit module both
|
|
22
|
+
* REPL handlers already load, so `/export audit` and a mistyped `/export`
|
|
23
|
+
* never load the Word package (final-review I-2).
|
|
24
|
+
*/
|
|
25
|
+
export const EXPORT_REPL_USAGE = 'Usage: /export docx (the last answer to Word) · /export docx <name> · /export docx conversation · /export audit [file]';
|
|
26
|
+
/**
|
|
27
|
+
* Parse the text after `/export` into a subcommand + optional filename.
|
|
28
|
+
*
|
|
29
|
+
* 2026-10-23 (a customer's screenshot): the Word forms people actually type
|
|
30
|
+
* all mean "Word": `/export docx x.docx`, `/export x.md --docx`,
|
|
31
|
+
* `/export x.docx`, `/export conversation`, `/export word`. A `--docx` or
|
|
32
|
+
* `--word` flag anywhere is dropped. For sub 'docx', `docxArgs` is the line
|
|
33
|
+
* to hand to runExportDocxRepl (" docx <rest>").
|
|
34
|
+
*/
|
|
21
35
|
export function parseExportArgs(rest) {
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
let flag = false;
|
|
37
|
+
const cleaned = rest.replace(/(^|\s)--(?:docx|word)(?=\s|$)/gi, () => { flag = true; return ' '; }).trim();
|
|
38
|
+
const parts = cleaned.split(/\s+/).filter(Boolean);
|
|
39
|
+
const first = (parts[0] ?? '').toLowerCase();
|
|
40
|
+
if (first === 'docx' || first === 'word') {
|
|
41
|
+
return { sub: 'docx', docxArgs: ` docx ${cleaned.slice(parts[0].length).trim()}`.trimEnd() };
|
|
42
|
+
}
|
|
43
|
+
if (first !== 'audit' && (flag || first === 'conversation' || /\.(?:md|docx)["']?$/i.test(cleaned))) {
|
|
44
|
+
return { sub: 'docx', docxArgs: ` docx ${cleaned}`.trimEnd() };
|
|
45
|
+
}
|
|
46
|
+
return { sub: first, filename: parts[1] };
|
|
24
47
|
}
|
|
25
48
|
/** Deterministic default filename: date from session data, not now(). */
|
|
26
49
|
export function defaultAuditFilename(session) {
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// cspeach-cli/src/commands/export-conversation.ts
|
|
2
|
+
//
|
|
3
|
+
// /export docx (2026-10-23, from a customer's own words): turn what he sees in
|
|
4
|
+
// the terminal into a Word document at any point, to attach to the change
|
|
5
|
+
// ticket.
|
|
6
|
+
//
|
|
7
|
+
// /export docx the last answer CSPeach gave
|
|
8
|
+
// /export docx conversation the whole conversation so far: the developer's
|
|
9
|
+
// messages, CSPeach's answers, and every question
|
|
10
|
+
// CSPeach asked (ask_question, approvals) with the
|
|
11
|
+
// options shown and the answer picked
|
|
12
|
+
// /export docx <file>.md unchanged (export-docx.ts)
|
|
13
|
+
//
|
|
14
|
+
// Reads only what is already in the session: no model call, no SAP call.
|
|
15
|
+
// Raw tool results are never written (a source body read from SAP stays out);
|
|
16
|
+
// only the answers to questions are taken from them. Files go to
|
|
17
|
+
// cspeach-out/ (never overwritten), the .docx next to the .md.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { metaFromMarkdown } from '../docx/doc-meta.js';
|
|
21
|
+
import { writeDocxNextTo, writeNoClobber } from '../docx/write-docx.js';
|
|
22
|
+
import { normalizeAskInput } from '../tools/ask-question.js';
|
|
23
|
+
import { isUserPrompt, userPromptText } from '../session/user-prompt.js';
|
|
24
|
+
import { resolveCliMarks, startsTurn } from '../session/cli-record.js';
|
|
25
|
+
import { docxFileFromReplArgs, runExportDocxCommand } from './export-docx.js';
|
|
26
|
+
// The usage line lives in the light export-audit module (final-review I-2); re-exported here.
|
|
27
|
+
export { EXPORT_REPL_USAGE } from './export-audit.js';
|
|
28
|
+
const OUT_DIR = 'cspeach-out';
|
|
29
|
+
/** HTML comments are machine blocks (artefact manifests, widget markers): the terminal never shows them. */
|
|
30
|
+
function readerText(text) {
|
|
31
|
+
return text.replace(/<!--[\s\S]*?-->/g, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
32
|
+
}
|
|
33
|
+
/** Context blocks the CLI adds to a prompt; the developer never typed them. */
|
|
34
|
+
const INJECTED_TAGS = ['session_context', 'project_context', 'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection'];
|
|
35
|
+
function typedText(text) {
|
|
36
|
+
let out = text;
|
|
37
|
+
for (const tag of INJECTED_TAGS) {
|
|
38
|
+
out = out.replace(new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, 'g'), '');
|
|
39
|
+
}
|
|
40
|
+
out = out.replace(/<attached file="([^"]*)">[\s\S]*?<\/attached>/g, (_m, f) => `@${f}`);
|
|
41
|
+
return out.trim();
|
|
42
|
+
}
|
|
43
|
+
function blocks(m) {
|
|
44
|
+
return Array.isArray(m.content) ? m.content : [];
|
|
45
|
+
}
|
|
46
|
+
function assistantTexts(m) {
|
|
47
|
+
if (m.role !== 'assistant')
|
|
48
|
+
return [];
|
|
49
|
+
if (typeof m.content === 'string')
|
|
50
|
+
return [m.content];
|
|
51
|
+
return blocks(m).filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text);
|
|
52
|
+
}
|
|
53
|
+
/** Indexes of the messages that start a turn: a typed prompt, a plan step, a queued step (fix round 1, I-1/I-2). */
|
|
54
|
+
function turnStarts(messages, session) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const marks = resolveCliMarks(session, messages);
|
|
57
|
+
messages.forEach((m, i) => { if (isUserPrompt(m) && startsTurn(marks[i]))
|
|
58
|
+
out.push(i); });
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The last answer: everything CSPeach said after the last turn's prompt. A
|
|
63
|
+
* continuation after the output limit, a watchdog note or a mid-turn steer is
|
|
64
|
+
* not a new turn, so a long answer comes out whole. `turn` = session.turn_seq
|
|
65
|
+
* when the session has it (right after a compaction too), else the count.
|
|
66
|
+
*/
|
|
67
|
+
export function lastAnswer(messages, session) {
|
|
68
|
+
const starts = turnStarts(messages, session);
|
|
69
|
+
if (starts.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
const last = starts[starts.length - 1];
|
|
72
|
+
const turn = session?.turn_seq ?? starts.length;
|
|
73
|
+
const text = readerText(messages.slice(last + 1).flatMap(assistantTexts).join('\n\n'));
|
|
74
|
+
return text.length > 0 ? { text: text + '\n', turn } : null;
|
|
75
|
+
}
|
|
76
|
+
/** tool_use_id → the result's text (only read for questions and approvals). */
|
|
77
|
+
function resultsById(messages) {
|
|
78
|
+
const out = new Map();
|
|
79
|
+
for (const m of messages) {
|
|
80
|
+
if (m.role !== 'user')
|
|
81
|
+
continue;
|
|
82
|
+
for (const b of blocks(m)) {
|
|
83
|
+
if (b?.type !== 'tool_result' || typeof b.tool_use_id !== 'string')
|
|
84
|
+
continue;
|
|
85
|
+
const c = b.content;
|
|
86
|
+
const text = typeof c === 'string' ? c
|
|
87
|
+
: Array.isArray(c) ? c.filter((x) => x?.type === 'text').map((x) => String(x.text ?? '')).join('') : '';
|
|
88
|
+
out.set(b.tool_use_id, text);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function parse(json) {
|
|
94
|
+
if (json === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(json);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const quote = (lines) => lines.map((l) => (l.length ? `> ${l}` : '>')).join('\n');
|
|
104
|
+
/** One question (or a form of up to four) with its options and the answer picked. */
|
|
105
|
+
function questionBlock(input, result) {
|
|
106
|
+
let form;
|
|
107
|
+
try {
|
|
108
|
+
form = normalizeAskInput(input);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const r = parse(result);
|
|
114
|
+
const answers = [];
|
|
115
|
+
if (r && Array.isArray(r.answers)) {
|
|
116
|
+
for (const a of r.answers)
|
|
117
|
+
answers.push({ id: String(a?.id), answer: a?.answer == null ? null : String(a.answer), auto: a?.auto_answered === true });
|
|
118
|
+
}
|
|
119
|
+
else if (r && 'answer' in r) {
|
|
120
|
+
answers.push({ id: String(r.id ?? form.questions[0]?.id), answer: r.answer == null ? null : String(r.answer), auto: r.auto_answered === true });
|
|
121
|
+
}
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const q of form.questions) {
|
|
124
|
+
out.push(`**CSPeach asked:** ${q.question}`);
|
|
125
|
+
if (q.options.length)
|
|
126
|
+
out.push(`Options: ${q.options.map((o) => o.label).join(' · ')}`);
|
|
127
|
+
const a = answers.find((x) => x.id === q.id) ?? (form.questions.length === 1 ? answers[0] : undefined);
|
|
128
|
+
if (a && a.answer !== null && a.answer.length > 0) {
|
|
129
|
+
const shown = q.options.length && !q.freeText
|
|
130
|
+
? a.answer.split(',').map((v) => q.options.find((o) => o.value === v)?.label ?? v).join(', ')
|
|
131
|
+
: a.answer;
|
|
132
|
+
out.push(a.auto ? `**Answer (picked automatically, no one answered):** ${shown}` : `**Answer:** ${shown}`);
|
|
133
|
+
}
|
|
134
|
+
else if (r?.error === 'headless_unanswerable' || r?.error === undefined && r?.headless) {
|
|
135
|
+
out.push('**Answer:** none (no one was there to answer)');
|
|
136
|
+
}
|
|
137
|
+
else if (r?.cancelled) {
|
|
138
|
+
out.push('**Answer:** none (the question was closed)');
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
out.push('**Answer:** not recorded');
|
|
142
|
+
}
|
|
143
|
+
out.push('');
|
|
144
|
+
}
|
|
145
|
+
return quote(out.slice(0, -1));
|
|
146
|
+
}
|
|
147
|
+
/** An approval CSPeach asked for: the summary, the changes (never the diff), the answer. */
|
|
148
|
+
function approvalBlock(input, result) {
|
|
149
|
+
const r = parse(result);
|
|
150
|
+
if (!r || r.error)
|
|
151
|
+
return null; // refused before anything was shown: no question was asked
|
|
152
|
+
const out = [`**CSPeach asked for approval:** ${String(input?.summary ?? 'a change')}`];
|
|
153
|
+
for (const c of Array.isArray(input?.changes) ? input.changes : []) {
|
|
154
|
+
out.push(`- ${String(c?.op ?? '')} ${String(c?.object ?? '')}${c?.type ? ` (${String(c.type)})` : ''}`.replace(/\s+/g, ' ').trimEnd());
|
|
155
|
+
}
|
|
156
|
+
let answer;
|
|
157
|
+
if (typeof r.advisory_outcome === 'string') {
|
|
158
|
+
answer = r.headless ? '**Answer (no one answered):** not applied'
|
|
159
|
+
: `**Answer:** ${r.advisory_outcome === 'applied' ? 'applied by hand' : r.advisory_outcome === 'aborted' ? 'stopped' : 'skipped'}`;
|
|
160
|
+
}
|
|
161
|
+
else if (r.auto_approved) {
|
|
162
|
+
answer = '**Answer (approved automatically, auto-approve is on):** Approved';
|
|
163
|
+
}
|
|
164
|
+
else if (typeof r.approved_by === 'string') {
|
|
165
|
+
answer = `**Answer (approved earlier, by ${r.approved_by}):** Approved`;
|
|
166
|
+
}
|
|
167
|
+
else if (r.reason === 'headless_no_approval') {
|
|
168
|
+
answer = '**Answer (no one answered):** Not approved';
|
|
169
|
+
}
|
|
170
|
+
else if (r.reason === 'plan_rejected') {
|
|
171
|
+
answer = '**Answer:** Cancelled at the plan gate';
|
|
172
|
+
}
|
|
173
|
+
else if (r.reason === 'user_cancelled_turn') {
|
|
174
|
+
answer = '**Answer:** Stopped';
|
|
175
|
+
}
|
|
176
|
+
else if (r.approved === true) {
|
|
177
|
+
const skipped = Array.isArray(r.skipped) ? r.skipped.map((s) => String(s?.object ?? '')).filter(Boolean) : [];
|
|
178
|
+
answer = skipped.length ? `**Answer:** Approved, except ${skipped.join(', ')} (skipped)` : '**Answer:** Approved';
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
answer = '**Answer:** Not approved';
|
|
182
|
+
}
|
|
183
|
+
out.push(answer);
|
|
184
|
+
return quote(out);
|
|
185
|
+
}
|
|
186
|
+
/** One CLI card: the question, what it showed, the options and the answer. */
|
|
187
|
+
function cardBlock(c) {
|
|
188
|
+
const out = [`**CSPeach asked:** ${c.question}`];
|
|
189
|
+
// Fix round 2 (N-1): when it was answered (UTC, to the minute) and which plan it was about.
|
|
190
|
+
const when = c.at && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(c.at) ? `At: ${c.at.slice(0, 10)} ${c.at.slice(11, 16)} UTC` : '';
|
|
191
|
+
const about = c.ref ? `Plan: ${c.ref}` : '';
|
|
192
|
+
if (when || about)
|
|
193
|
+
out.push([when, about].filter(Boolean).join(' · '));
|
|
194
|
+
for (const f of c.facts ?? [])
|
|
195
|
+
out.push(`- ${f}`);
|
|
196
|
+
if (c.options?.length)
|
|
197
|
+
out.push(`Options: ${c.options.join(' · ')}`);
|
|
198
|
+
out.push(c.by === 'user' ? `**Answer:** ${c.answer}`
|
|
199
|
+
: c.by === 'auto' ? `**Answer (picked automatically):** ${c.answer}`
|
|
200
|
+
: `**Answer (no one at the terminal):** ${c.answer}`);
|
|
201
|
+
return quote(out);
|
|
202
|
+
}
|
|
203
|
+
/** The whole conversation, in order. Pure: the same session always gives the same text. */
|
|
204
|
+
export function conversationMarkdown(o) {
|
|
205
|
+
const results = resultsById(o.messages);
|
|
206
|
+
const parts = [
|
|
207
|
+
'# CSPeach conversation', '',
|
|
208
|
+
`- Date: ${o.date}`,
|
|
209
|
+
`- SAP system: ${o.system ? o.system : 'none'}`,
|
|
210
|
+
`- Session: ${o.sessionId}`,
|
|
211
|
+
...(o.person ? [`- Signed in as: ${o.person}`] : []),
|
|
212
|
+
];
|
|
213
|
+
let speaker = null;
|
|
214
|
+
const say = (who, text) => {
|
|
215
|
+
if (speaker !== who) {
|
|
216
|
+
parts.push('', who === 'you' ? '## You' : '## CSPeach');
|
|
217
|
+
speaker = who;
|
|
218
|
+
}
|
|
219
|
+
parts.push('', text);
|
|
220
|
+
};
|
|
221
|
+
// CLI cards, anchored by turn_seq. The turns kept in `messages` are the last
|
|
222
|
+
// ones, so the i-th turn start here is turn (turn_seq - count + 1 + i). A
|
|
223
|
+
// card answered in turn t is placed before the prompt of turn t + 1.
|
|
224
|
+
const cards = [...(o.session?.cli_cards ?? [])].sort((a, b) => a.turn_seq - b.turn_seq);
|
|
225
|
+
const starts = turnStarts(o.messages, o.session);
|
|
226
|
+
const marks = resolveCliMarks(o.session, o.messages);
|
|
227
|
+
const lastTurn = o.session?.turn_seq ?? starts.length;
|
|
228
|
+
const turnOf = new Map(starts.map((idx, i) => [idx, lastTurn - starts.length + 1 + i]));
|
|
229
|
+
let nextCard = 0;
|
|
230
|
+
const flushCards = (beforeTurn) => {
|
|
231
|
+
while (nextCard < cards.length && cards[nextCard].turn_seq < beforeTurn)
|
|
232
|
+
say('cspeach', cardBlock(cards[nextCard++]));
|
|
233
|
+
};
|
|
234
|
+
for (const [idx, m] of o.messages.entries()) {
|
|
235
|
+
if (m.role === 'user') {
|
|
236
|
+
const n = turnOf.get(idx);
|
|
237
|
+
if (n !== undefined)
|
|
238
|
+
flushCards(n);
|
|
239
|
+
const typed = userPromptText(m);
|
|
240
|
+
if (typed === null)
|
|
241
|
+
continue;
|
|
242
|
+
// A message CSPeach wrote itself: one plain line, never its body.
|
|
243
|
+
const mark = marks[idx];
|
|
244
|
+
if (mark && mark.kind !== 'steer') {
|
|
245
|
+
say('cspeach', `*${mark.line}*`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const t = readerText(typedText(typed));
|
|
249
|
+
if (t.length)
|
|
250
|
+
say('you', t);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (m.role !== 'assistant')
|
|
254
|
+
continue;
|
|
255
|
+
const list = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : blocks(m);
|
|
256
|
+
for (const b of list) {
|
|
257
|
+
if (b?.type === 'text' && typeof b.text === 'string') {
|
|
258
|
+
const t = readerText(b.text);
|
|
259
|
+
if (t.length)
|
|
260
|
+
say('cspeach', t);
|
|
261
|
+
}
|
|
262
|
+
else if (b?.type === 'tool_use' && typeof b.id === 'string') {
|
|
263
|
+
const block = b.name === 'ask_question' ? questionBlock(b.input, results.get(b.id))
|
|
264
|
+
: b.name === 'request_approval' ? approvalBlock(b.input, results.get(b.id))
|
|
265
|
+
: null;
|
|
266
|
+
if (block)
|
|
267
|
+
say('cspeach', block);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
flushCards(Number.POSITIVE_INFINITY);
|
|
272
|
+
return parts.join('\n') + '\n';
|
|
273
|
+
}
|
|
274
|
+
function localDate() {
|
|
275
|
+
const d = new Date();
|
|
276
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
277
|
+
}
|
|
278
|
+
async function saveWithCopy(target, markdown, meta, emit) {
|
|
279
|
+
let mdPath;
|
|
280
|
+
try {
|
|
281
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
282
|
+
mdPath = await writeNoClobber(target, markdown);
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
emit(`Not saved. ${e.message}`);
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
emit(`Saved: ${path.resolve(mdPath)}`);
|
|
289
|
+
try {
|
|
290
|
+
const docx = await writeDocxNextTo(mdPath, markdown, meta);
|
|
291
|
+
emit(`Word copy: ${path.resolve(docx)}`);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
emit(`Word copy not written. ${e.message}`);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** `/export docx …` in the REPL (Ink and classic call this one body). */
|
|
300
|
+
export async function runExportDocxRepl(o) {
|
|
301
|
+
const file = docxFileFromReplArgs(o.args);
|
|
302
|
+
const system = o.session.sap_system ?? null;
|
|
303
|
+
const cwd = o.cwd ?? process.cwd();
|
|
304
|
+
if (file !== undefined && /\.md$/i.test(file) && fs.existsSync(path.resolve(cwd, file))) {
|
|
305
|
+
// Unchanged: a Word copy next to an existing Markdown file.
|
|
306
|
+
return runExportDocxCommand({ file, docx: true, system, ...(o.cwd ? { cwd: o.cwd } : {}), ...(o.today ? { today: o.today } : {}), emit: o.emit });
|
|
307
|
+
}
|
|
308
|
+
const today = o.today ?? localDate();
|
|
309
|
+
const out = path.join(cwd, OUT_DIR);
|
|
310
|
+
if (file === undefined || !/^conversation$/i.test(file)) {
|
|
311
|
+
// The last answer. Any other name ("explain1", "explain1.docx", a .md that
|
|
312
|
+
// does not exist yet) is the name to save it under: bare names go to
|
|
313
|
+
// cspeach-out/, a name with a folder goes where it says.
|
|
314
|
+
const a = lastAnswer(o.session.messages, o.session);
|
|
315
|
+
if (!a) {
|
|
316
|
+
o.emit('There is no answer to export yet.');
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
const meta = metaFromMarkdown(a.text, { date: today, kind: 'answer', ...(system ? { system } : {}) });
|
|
320
|
+
const raw = (file ?? '').replace(/\.(?:md|docx)$/i, '').trim().replace(/\\/g, '/');
|
|
321
|
+
const folder = raw.slice(0, raw.lastIndexOf('/') + 1);
|
|
322
|
+
const name = raw.slice(folder.length).trim() || `${today}-answer-${a.turn}`;
|
|
323
|
+
// A colon after the drive letter would write into a hidden Windows file
|
|
324
|
+
// stream (the user sees an empty file); the others are never valid.
|
|
325
|
+
if (/[<>:"|?*]/.test(folder.replace(/^[A-Za-z]:\//, '') + name)) {
|
|
326
|
+
o.emit(`"${file}" cannot be a file name. Use letters, numbers, spaces, - or _, for example: /export docx explain1`);
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
const target = folder ? path.resolve(cwd, folder, `${name}.md`) : path.join(out, `${name}.md`);
|
|
330
|
+
return saveWithCopy(target, a.text, meta, o.emit);
|
|
331
|
+
}
|
|
332
|
+
if (turnStarts(o.session.messages, o.session).length === 0) {
|
|
333
|
+
o.emit('There is no conversation to export yet.');
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
const md = conversationMarkdown({ messages: o.session.messages, session: o.session, date: today, system, sessionId: o.session.id, ...(o.person ? { person: o.person } : {}) });
|
|
337
|
+
const meta = {
|
|
338
|
+
title: 'CSPeach conversation', date: today, kind: 'conversation', session: o.session.id,
|
|
339
|
+
...(system ? { system } : {}), ...(o.person ? { person: o.person } : {}),
|
|
340
|
+
};
|
|
341
|
+
const short = o.session.id.replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || 'session';
|
|
342
|
+
return saveWithCopy(path.join(out, `${today}-conversation-${short}.md`), md, meta, o.emit);
|
|
343
|
+
}
|
package/dist/commands/help.js
CHANGED
|
@@ -47,6 +47,9 @@ export const HELP_GROUPS = [
|
|
|
47
47
|
{ name: '/files', description: fixed('Pick a saved project file and what to do with it') },
|
|
48
48
|
{ name: '@<file>', description: fixed('Attach a file from this folder to your prompt') },
|
|
49
49
|
{ name: '/export audit [file]', description: fixed('Write the signable session audit record to a file') },
|
|
50
|
+
{ name: '/export docx', description: fixed('Word copy of the last answer, saved in cspeach-out. Add a name to choose the file name: /export docx explain1') },
|
|
51
|
+
{ name: '/export docx conversation', description: fixed('Word copy of the whole conversation: your messages, the answers, the questions and what you picked') },
|
|
52
|
+
{ name: '/export docx <file>.md', description: fixed('Word copy of a Markdown file, next to it') },
|
|
50
53
|
] },
|
|
51
54
|
{ title: 'Session', rows: [
|
|
52
55
|
{ name: '/new', description: fixed('Next prompt picks a skill afresh (also /reset)') },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { saveSession } from '../session/store.js';
|
|
2
|
+
import { rebuildCliMarksAfterCompact, PLAN_BOUNDARY_LINE } from '../session/cli-record.js';
|
|
2
3
|
import { formatInfo } from '../renderer/notice-shape.js';
|
|
3
4
|
import { recordFullNotice } from '../renderer/notice-log.js';
|
|
4
5
|
import { paint } from '../renderer/theme.js';
|
|
@@ -35,7 +36,11 @@ export async function compactAtPhaseBoundary(session, summarise) {
|
|
|
35
36
|
if (!summary || summary.trim().length === 0)
|
|
36
37
|
throw new Error('the summary came back empty');
|
|
37
38
|
const backupPath = await backupBeforeCompact(session);
|
|
39
|
+
const oldMessages = session.messages;
|
|
38
40
|
session.messages = [buildPhaseBoundaryMessage(summary)];
|
|
41
|
+
// /export docx conversation: the summary is CSPeach's, not the developer's (one plain line there).
|
|
42
|
+
// Every earlier message is dropped: the record holds only the summary's mark.
|
|
43
|
+
rebuildCliMarksAfterCompact(session, oldMessages, oldMessages.length, session.messages[0].content, { kind: 'plan-boundary', line: PLAN_BOUNDARY_LINE });
|
|
39
44
|
delete session.last_context_tokens;
|
|
40
45
|
await saveSession(session);
|
|
41
46
|
return { performed: true, backupPath };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marker a turn continues from, or null: only an /abap-plan turn that is
|
|
3
|
+
* not already a plan resume, with a plan step in flight. /new clears the
|
|
4
|
+
* marker (session/fresh-start.ts), so "continue" after /new never takes this
|
|
5
|
+
* path.
|
|
6
|
+
*/
|
|
7
|
+
export function planContinuationMarker(session, skill, planResume) {
|
|
8
|
+
if (skill !== 'abap-plan' || planResume !== null)
|
|
9
|
+
return null;
|
|
10
|
+
return session.plan_in_flight ?? null;
|
|
11
|
+
}
|
|
@@ -29,6 +29,17 @@ function stepTitle(envelope, phaseId) {
|
|
|
29
29
|
const p = pc.ok ? pc.content.phases.find((q) => q.id === phaseId) : undefined;
|
|
30
30
|
return p && pc.ok ? phaseDisplayTitle(p, pc.content.phases) : 'the interrupted step';
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* /export docx conversation (2026-10-23): the one line that stands for a plan
|
|
34
|
+
* step prompt CSPeach wrote, e.g. "CSPeach ran plan step 2 (Skeleton)."
|
|
35
|
+
*/
|
|
36
|
+
export function planStepOriginLine(prepared) {
|
|
37
|
+
const pc = parsePlanContent(prepared.envelope.content);
|
|
38
|
+
const i = pc.ok ? pc.content.phases.findIndex((q) => q.id === prepared.nextPhaseId) : -1;
|
|
39
|
+
if (!pc.ok || i < 0)
|
|
40
|
+
return 'CSPeach ran the next plan step.';
|
|
41
|
+
return `CSPeach ran plan step ${i + 1} (${phaseDisplayTitle(pc.content.phases[i], pc.content.phases)}).`;
|
|
42
|
+
}
|
|
32
43
|
/** The one line shown when a bare "continue" re-attaches to an interrupted step. */
|
|
33
44
|
export function continuationNotice(prepared) {
|
|
34
45
|
return `Continuing ${stepTitle(prepared.envelope, prepared.nextPhaseId)} where it stopped`;
|
|
@@ -20,6 +20,7 @@ import { computeNextPhaseForRun, isPhaseSatisfiedForRun, phaseDisplayTitle, stat
|
|
|
20
20
|
import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
|
|
21
21
|
import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from './plan-resume.js';
|
|
22
22
|
import { newestPlanFamilyFiles } from '../session/recap.js';
|
|
23
|
+
import { recordCliCard } from '../session/cli-record.js';
|
|
23
24
|
import { legacyDirsFor } from '../projects/project-folder.js';
|
|
24
25
|
import { getPlanGrant, installPlanGrant, planGrantAllowed } from '../approvals/plan-grant.js';
|
|
25
26
|
import { DEFAULT_PLAN_MODE } from './plan-gate.js';
|
|
@@ -256,6 +257,9 @@ export function classicStartupChooser(prompt, log) {
|
|
|
256
257
|
log('', q.title, ...q.facts.map((f) => ` ${f}`), ...q.choices.map((c, i) => ` ${i + 1}. ${c.label}`));
|
|
257
258
|
const ans = (await prompt(`Choose 1-${q.choices.length} (Enter = ${q.choices[0].label}): `)).trim();
|
|
258
259
|
const n = ans === '' ? 1 : Number(ans);
|
|
259
|
-
|
|
260
|
+
const picked = Number.isInteger(n) && n >= 1 && n <= q.choices.length ? q.choices[n - 1] : null;
|
|
261
|
+
// /export docx conversation: the classic startup choice is recorded like the Ink card.
|
|
262
|
+
recordCliCard({ question: q.title, options: q.choices.map((c) => c.label), ...(q.facts.length ? { facts: [...q.facts] } : {}), answer: picked?.label ?? 'Cancelled', by: 'user' });
|
|
263
|
+
return picked ? picked.value : null;
|
|
260
264
|
};
|
|
261
265
|
}
|
package/dist/cost/cost-log.js
CHANGED
|
@@ -83,6 +83,10 @@ export function buildEntry(opts) {
|
|
|
83
83
|
entry.reader_cost_usd = Math.round(opts.reader.cost * 10000) / 10000;
|
|
84
84
|
entry.reader_calls = opts.reader.calls;
|
|
85
85
|
}
|
|
86
|
+
if (opts.skill !== undefined)
|
|
87
|
+
entry.skill = opts.skill;
|
|
88
|
+
if (opts.system_block !== undefined)
|
|
89
|
+
entry.system_block = opts.system_block;
|
|
86
90
|
return entry;
|
|
87
91
|
}
|
|
88
92
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation Stage 0, Task 0.2 — did this main call send the same system
|
|
3
|
+
* block as the session's previous one?
|
|
4
|
+
*
|
|
5
|
+
* The system block is decided by four inputs, on the proxy (skill body,
|
|
6
|
+
* delegate skill, style text, level text; the preamble is constant per CLI
|
|
7
|
+
* version) and on direct providers alike. The key joins them; the note
|
|
8
|
+
* compares it with the session's last key. Observability only: the result is
|
|
9
|
+
* written to the cost log and never changes a request.
|
|
10
|
+
*
|
|
11
|
+
* Kept in a module WeakMap, never in the session file (no schema change). A
|
|
12
|
+
* resumed session therefore starts at 'first' again.
|
|
13
|
+
*/
|
|
14
|
+
export function systemBlockKey(p) {
|
|
15
|
+
return `${p.skill}|${p.delegateSkill ?? ''}|${p.style}|${p.level}`;
|
|
16
|
+
}
|
|
17
|
+
const lastKeyBySession = new WeakMap();
|
|
18
|
+
/** Records `key` as the session's latest and says how it compares with the previous one. */
|
|
19
|
+
export function noteSystemBlock(session, key) {
|
|
20
|
+
const prev = lastKeyBySession.get(session);
|
|
21
|
+
lastKeyBySession.set(session, key);
|
|
22
|
+
if (prev === undefined)
|
|
23
|
+
return 'first';
|
|
24
|
+
return prev === key ? 'same' : 'changed';
|
|
25
|
+
}
|
package/dist/docx/doc-meta.js
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
// The metadata a Word copy carries on its title page. Filled by the caller
|
|
4
4
|
// (the save hook knows the session's SAP alias; the export command knows the
|
|
5
5
|
// file) and, for what the text itself says, by metaFromMarkdown (Task 3).
|
|
6
|
-
const KIND_TITLE = {
|
|
6
|
+
const KIND_TITLE = {
|
|
7
|
+
review: 'Code review', handover: 'Handover', document: 'Document',
|
|
8
|
+
explanation: 'Explanation', impact: 'Impact analysis', preflight: 'Preflight check',
|
|
9
|
+
'transport-analysis': 'Transport analysis', migration: 'Migration assessment',
|
|
10
|
+
'clean-core': 'Clean-core assessment', 'custom-code-analysis': 'Custom code analysis',
|
|
11
|
+
'spec-gap': 'Spec gaps', design: 'Design', estimate: 'Estimate', plan: 'Plan',
|
|
12
|
+
'upgrade-scan': 'Upgrade scan', 'upgrade-report': 'Upgrade report',
|
|
13
|
+
answer: 'CSPeach answer', conversation: 'CSPeach conversation',
|
|
14
|
+
};
|
|
7
15
|
/** Z/Y objects, SAP-style names: ZCL_X, ZFI_DUP_INVOICE_CHECK, Z_REPORT, /NS/ZCL_X. */
|
|
8
16
|
const OBJECT_RE = /(?<![\w/])((?:\/[A-Z0-9]+\/)?[ZY][A-Z0-9_]{2,39})\b/;
|
|
9
17
|
/** Inline Markdown → plain words: `code` → code, [text](url) → text, **x** → x. */
|
|
@@ -63,6 +63,10 @@ function titlePage(meta) {
|
|
|
63
63
|
facts.push(`System: ${meta.system}`);
|
|
64
64
|
if (meta.object)
|
|
65
65
|
facts.push(`Object: ${meta.object}`);
|
|
66
|
+
if (meta.session)
|
|
67
|
+
facts.push(`Session: ${meta.session}`);
|
|
68
|
+
if (meta.person)
|
|
69
|
+
facts.push(`Signed in as: ${meta.person}`);
|
|
66
70
|
return [
|
|
67
71
|
new Paragraph({ text: meta.title, heading: HeadingLevel.TITLE, spacing: { before: 2400 } }),
|
|
68
72
|
...facts.map((f) => new Paragraph({ text: f })),
|
|
@@ -120,18 +124,27 @@ function list(l, ctx, depth) {
|
|
|
120
124
|
const mark = l.ordered
|
|
121
125
|
? { numbering: { reference: NUMBERED, level, instance } }
|
|
122
126
|
: { bullet: { level } };
|
|
127
|
+
// Task-list items (`- [ ]` / `- [x]`) keep their box at the start of the item (2026-10-23).
|
|
128
|
+
const box = item.task ? (item.checked ? '☒ ' : '☐ ') : '';
|
|
123
129
|
let first = true;
|
|
124
130
|
for (const child of item.tokens) {
|
|
125
131
|
if (child.type === 'list') {
|
|
126
132
|
out.push(...list(child, ctx, depth + 1));
|
|
127
133
|
continue;
|
|
128
134
|
}
|
|
135
|
+
if (first && box && (child.type === 'text' || child.type === 'paragraph')) {
|
|
136
|
+
const p = child;
|
|
137
|
+
const kids = 'tokens' in p && p.tokens ? inline(p.tokens) : [new TextRun(p.text ?? p.raw ?? '')];
|
|
138
|
+
out.push(new Paragraph({ ...mark, children: [new TextRun(box), ...kids] }));
|
|
139
|
+
first = false;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
129
142
|
// The first text block of an item carries the marker; later paragraphs are indented plain text.
|
|
130
143
|
out.push(...blockToDocx(child, ctx, depth, first ? mark : { indent: { left: 720 * (level + 1) } }));
|
|
131
144
|
first = false;
|
|
132
145
|
}
|
|
133
146
|
if (first)
|
|
134
|
-
out.push(new Paragraph({ ...mark, text: item.text ?? '' }));
|
|
147
|
+
out.push(new Paragraph({ ...mark, text: box + (item.text ?? '') }));
|
|
135
148
|
}
|
|
136
149
|
return out;
|
|
137
150
|
}
|
package/dist/lane/app-chain.js
CHANGED
|
@@ -30,6 +30,7 @@ import { containSpecPath, specDisplayPath, SpecProtectedError } from './roots.js
|
|
|
30
30
|
import { resolveAtTokenAsync } from '../projects/workspace.js';
|
|
31
31
|
import { parseFromFlag } from '../skills/promotion-dispatch.js';
|
|
32
32
|
import { rebuildStartedAfterPlanSave } from '../journey/rebuild.js';
|
|
33
|
+
import { recordCliCard } from '../session/cli-record.js';
|
|
33
34
|
export const STEP_1 = 'Step 1 of 3: finding the gaps in the spec.';
|
|
34
35
|
export const STEP_2 = 'Step 2 of 3: choosing the build lane.';
|
|
35
36
|
export const STEP_3 = 'Step 3 of 3: planning the build.';
|
|
@@ -239,6 +240,10 @@ function readSavedPlan(savedPath) {
|
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
const OBJECT_LIST_FAILED = 'The object list could not be approved. Each SAP write asks first.';
|
|
243
|
+
/** The plan a "Build this now?" record is about: the plan file name and its version. */
|
|
244
|
+
function planRef(savedPath, version) {
|
|
245
|
+
return `${savedPath.split(/[\\/]/).pop() ?? savedPath} v${version}`;
|
|
246
|
+
}
|
|
242
247
|
/**
|
|
243
248
|
* The facts under "Build this now?": steps, the exact object list, transport,
|
|
244
249
|
* deploy, what stops. `grant: false` (the object-list approval cannot be
|
|
@@ -367,6 +372,9 @@ export async function afterPlanSave(h, savedPath) {
|
|
|
367
372
|
return;
|
|
368
373
|
}
|
|
369
374
|
if (h.headless || !h.queueNext) {
|
|
375
|
+
// /export docx conversation: the plan gate was not shown; say so in the record.
|
|
376
|
+
if (h.headless)
|
|
377
|
+
recordCliCard({ question: START_QUESTION, ref: planRef(savedPath, saved.envelope.version), answer: 'Not asked (no one at the terminal)', by: 'headless' }, h.session);
|
|
370
378
|
h.emit(`Start building with: ${cmd}`);
|
|
371
379
|
end();
|
|
372
380
|
return;
|
|
@@ -389,27 +397,42 @@ export async function afterPlanSave(h, savedPath) {
|
|
|
389
397
|
initial: 0,
|
|
390
398
|
aliases: { y: 'yes', n: 'no' },
|
|
391
399
|
});
|
|
400
|
+
// /export docx conversation (fix round 2, N-1): the record a change ticket
|
|
401
|
+
// needs — when, which plan file and version, the objects and transport shown,
|
|
402
|
+
// the answer, and whether the object-list approval was installed. Only when
|
|
403
|
+
// a card was really shown (an injected confirm, or a card listener).
|
|
404
|
+
const shown = h.confirm !== undefined || (await import('../ui/card-slot.js')).cardSlotEmitter.listenerCount('confirm') > 0;
|
|
405
|
+
const record = (answer) => {
|
|
406
|
+
if (shown)
|
|
407
|
+
recordCliCard({ question: START_QUESTION, ref: planRef(savedPath, saved.envelope.version), options: ['Yes', 'No'], facts: [...facts], answer, by: 'user' }, h.session);
|
|
408
|
+
};
|
|
392
409
|
if (res.value === 'yes' && !res.cancelled) {
|
|
393
410
|
// The plan's recorded transport becomes the session transport when the
|
|
394
411
|
// session has none, so the first step's writes land on (and are covered
|
|
395
412
|
// by) it without the model naming it — the same rule a resume uses.
|
|
396
413
|
adoptPlanTransportOnResume(saved.content.phases);
|
|
397
414
|
const sessionId = h.session.id;
|
|
415
|
+
let installed = true;
|
|
398
416
|
if (grantable && objects.length > 0 && typeof sessionId === 'string' && sessionId) {
|
|
399
417
|
const a = { planId: saved.envelope.id, sessionId, objects, ...(transport ? { transport } : {}) };
|
|
400
418
|
try {
|
|
401
419
|
if (h.grant)
|
|
402
420
|
await h.grant(a);
|
|
403
|
-
else if (!(await installPlanGrant({ ...a, ...(sapAlias ? { sapAlias } : {}) })))
|
|
421
|
+
else if (!(await installPlanGrant({ ...a, ...(sapAlias ? { sapAlias } : {}) }))) {
|
|
422
|
+
installed = false;
|
|
404
423
|
h.emit(OBJECT_LIST_FAILED);
|
|
424
|
+
}
|
|
405
425
|
}
|
|
406
426
|
catch {
|
|
427
|
+
installed = false;
|
|
407
428
|
h.emit(OBJECT_LIST_FAILED);
|
|
408
429
|
}
|
|
409
430
|
}
|
|
431
|
+
record(installed ? 'Yes' : 'Yes (object list not installed; each SAP write asks first)');
|
|
410
432
|
h.queueNext(cmd, APP_ORIGIN);
|
|
411
433
|
}
|
|
412
434
|
else {
|
|
435
|
+
record(res.value === 'no' ? 'No' : 'Cancelled');
|
|
413
436
|
h.emit(`Start it later with: ${cmd}`);
|
|
414
437
|
}
|
|
415
438
|
end();
|