@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/repl.js
CHANGED
|
@@ -16,6 +16,10 @@ import { formatPanel } from './renderer/panel-rows.js';
|
|
|
16
16
|
import { sanitizeControlChars } from './renderer/sanitize.js';
|
|
17
17
|
import { formatRoutingLine } from './renderer/routing-line.js';
|
|
18
18
|
import { handleUiLook, handleUiRendering } from './repl/ui-look-command.js';
|
|
19
|
+
import { handleStyleCommand, pickStyleClassic, pickStyleInk } from './repl/style-command.js';
|
|
20
|
+
import { handleLevelCommand, pickLevelClassic, pickLevelInk } from './repl/level-command.js';
|
|
21
|
+
import { handleChatCommand } from './repl/chat-command.js';
|
|
22
|
+
import { ChatUnavailableError } from './agent/chat-unavailable.js';
|
|
19
23
|
import { CLI_VERSION } from './lib/version.js';
|
|
20
24
|
import { createOfflineAdtClient } from './sap/offline-adt-client.js';
|
|
21
25
|
import { STANDALONE_ALIAS } from './tools/index.js';
|
|
@@ -23,7 +27,11 @@ import { resumeStandaloneDecision, applySystemSwitch } from './repl/resume-stand
|
|
|
23
27
|
import { clearAppFlow } from './lane/app-flow.js';
|
|
24
28
|
import { clearJourneyFlow } from './journey/flow.js';
|
|
25
29
|
import { createDispatchSlot, queuedStepView } from './session/queued-step.js';
|
|
26
|
-
import { applyResumedSkill
|
|
30
|
+
import { applyResumedSkill } from './router/resume-routing.js';
|
|
31
|
+
import { ACK_PHRASES, wantsContinuation, isConversationalAck, decideLane, turnStatusLabel, applyLaneTools, chatFallbackOrigin } from './repl/chat-lane.js';
|
|
32
|
+
import { CHAT_SKILL } from './agent/chat-prompt.js';
|
|
33
|
+
import { setAssistantStyle } from './agent/style-state.js';
|
|
34
|
+
import { setAssistantLevel } from './agent/level-state.js';
|
|
27
35
|
import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
|
|
28
36
|
import { getStore } from './auth/api-key.js';
|
|
29
37
|
import { readAuthFile } from './auth/auth-file.js';
|
|
@@ -157,6 +165,7 @@ import './tools/approval.js';
|
|
|
157
165
|
import './tools/snapshot.js';
|
|
158
166
|
import './tools/ask-question.js';
|
|
159
167
|
import './tools/dispatch-skill.js';
|
|
168
|
+
import './tools/hand-off.js';
|
|
160
169
|
import './tools/todo.js';
|
|
161
170
|
import './tools/filesystem/file-read.js';
|
|
162
171
|
import './tools/tool-output-read.js';
|
|
@@ -211,68 +220,8 @@ function loadHistory() {
|
|
|
211
220
|
// and were unreachable.
|
|
212
221
|
.reverse());
|
|
213
222
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
* in session.skill, an ack-only follow-up should route there instead of
|
|
217
|
-
* re-classifying as a fresh prompt (which produces nonsense like "yes"
|
|
218
|
-
* → /abap-explain at 35% confidence). */
|
|
219
|
-
const ACK_PHRASES = new Set([
|
|
220
|
-
'yes', 'y', 'yeah', 'yep', 'yup', 'sure',
|
|
221
|
-
'ok', 'okay', 'k', 'kk',
|
|
222
|
-
'proceed', 'continue', 'go', 'go ahead', 'do it', 'do that', 'go on',
|
|
223
|
-
'no', 'n', 'nope', 'nah',
|
|
224
|
-
'stop', 'cancel', 'abort', 'never mind', 'nm',
|
|
225
|
-
'approve', 'approved', 'accept', 'accepted',
|
|
226
|
-
'decline', 'denied', 'reject', 'rejected',
|
|
227
|
-
'skip', 'next',
|
|
228
|
-
]);
|
|
229
|
-
/** Conversational acknowledgements that mean "I'm done with the previous
|
|
230
|
-
* turn, no further work needed." Distinct from ACK_PHRASES (which
|
|
231
|
-
* largely mean "yes/no to a question"). For these we don't classify,
|
|
232
|
-
* don't call any model — just print a friendly ack and return. The user
|
|
233
|
-
* isn't asking us to do more work; they're closing the prior thread.
|
|
234
|
-
*
|
|
235
|
-
* 2026-05-17 — Laeeq hit "thank you" being classified and run through
|
|
236
|
-
* /abap-explain, producing another huge code explanation. THIS is the
|
|
237
|
-
* fix for that. */
|
|
238
|
-
const CONVERSATIONAL_ACKS = new Set([
|
|
239
|
-
'thanks', 'thank you', 'thank u', 'thx', 'tx', 'ty', 'ta',
|
|
240
|
-
'cool', 'nice', 'great', 'awesome', 'perfect',
|
|
241
|
-
'got it', 'gotit', 'understood',
|
|
242
|
-
'cheers',
|
|
243
|
-
// Common ackowledgements that follow a long answer.
|
|
244
|
-
'makes sense',
|
|
245
|
-
]);
|
|
246
|
-
/**
|
|
247
|
-
* True when `trimmed` is one of CONVERSATIONAL_ACKS. We treat these as
|
|
248
|
-
* "user closes the previous thread, no further work" — print a friendly
|
|
249
|
-
* acknowledgement and return without classifying or calling any model.
|
|
250
|
-
*/
|
|
251
|
-
function isConversationalAck(trimmed) {
|
|
252
|
-
const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
253
|
-
return CONVERSATIONAL_ACKS.has(normalized);
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Returns true when `trimmed` is a short acknowledgement-style follow-up
|
|
257
|
-
* AND there is a previous skill to continue. Treats both bare acks and
|
|
258
|
-
* short ack-prefixed phrases ("yes please", "no thanks") the same way.
|
|
259
|
-
* Strips trailing punctuation so "yes." and "yes!" still register.
|
|
260
|
-
*/
|
|
261
|
-
function isAckContinuation(trimmed, sessionSkill) {
|
|
262
|
-
if (!sessionSkill)
|
|
263
|
-
return false;
|
|
264
|
-
const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
265
|
-
if (ACK_PHRASES.has(normalized))
|
|
266
|
-
return true;
|
|
267
|
-
// "yes please", "go ahead and do it", "no thanks" — first word is an ack
|
|
268
|
-
// and the whole phrase is short enough to be a follow-up not a new task.
|
|
269
|
-
if (normalized.length <= 30) {
|
|
270
|
-
const firstWord = normalized.split(/\s+/)[0] ?? '';
|
|
271
|
-
if (ACK_PHRASES.has(firstWord))
|
|
272
|
-
return true;
|
|
273
|
-
}
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
223
|
+
// CONVERSATIONAL_ACKS / isConversationalAck live in repl/chat-lane.ts (moved
|
|
224
|
+
// unchanged — the chat-lane decision reads the same set).
|
|
276
225
|
/**
|
|
277
226
|
* Returns true when `trimmed` is a bare ack ("y", "n", "ok", "cancel", ...)
|
|
278
227
|
* with no surrounding context — regardless of whether a session.skill exists.
|
|
@@ -538,6 +487,15 @@ export async function runRepl(opts) {
|
|
|
538
487
|
}
|
|
539
488
|
let cfg = await loadConfig();
|
|
540
489
|
setLook(cfg.ui.look);
|
|
490
|
+
// Chat lane (spec 2026-10-17 D1/D2/D5/D9) — the process lane and talking
|
|
491
|
+
// style. `laneSetting` is a `let`: Task 14's `/chat on|off` and the D11
|
|
492
|
+
// fallback (Task 15) reassign it, and decideLane reads it on every message.
|
|
493
|
+
// applyLaneTools is the one place the hand_off flag is enabled (interactive
|
|
494
|
+
// chat lane only — piped stdin keeps today's tools array and router).
|
|
495
|
+
let laneSetting = cfg.assistant.lane;
|
|
496
|
+
applyLaneTools(laneSetting, !!process.stdin.isTTY);
|
|
497
|
+
setAssistantStyle(cfg.assistant.style);
|
|
498
|
+
setAssistantLevel(cfg.assistant.level);
|
|
541
499
|
// Apply the rendering mode BEFORE any shouldUseInk() call. This is the
|
|
542
500
|
// interactive entry point, so it is never one-shot whatever argv holds. A
|
|
543
501
|
// stored `classic` is retiring: read as auto, rewritten once, one dim notice
|
|
@@ -820,6 +778,8 @@ export async function runRepl(opts) {
|
|
|
820
778
|
user: standalone ? '' : cfg.sap[selectedAlias].username,
|
|
821
779
|
writeMode: startupWriteMode,
|
|
822
780
|
standalone,
|
|
781
|
+
// Chat lane is interactive-only (applyLaneTools); piped stdin never chats.
|
|
782
|
+
chatLane: laneSetting === 'chat' && !!process.stdin.isTTY,
|
|
823
783
|
});
|
|
824
784
|
// v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
|
|
825
785
|
// preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
|
|
@@ -931,6 +891,7 @@ export async function runRepl(opts) {
|
|
|
931
891
|
tools: localTools,
|
|
932
892
|
writeMode: startupWriteMode,
|
|
933
893
|
cwd: process.cwd(),
|
|
894
|
+
chat: laneSetting === 'chat' && !!process.stdin.isTTY,
|
|
934
895
|
};
|
|
935
896
|
if (opts?.resumedSession) {
|
|
936
897
|
const turnCount = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
|
|
@@ -979,7 +940,7 @@ export async function runRepl(opts) {
|
|
|
979
940
|
}
|
|
980
941
|
}
|
|
981
942
|
else if (getLook() === 'panels') {
|
|
982
|
-
process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools), notices: startupNotices }));
|
|
943
|
+
process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools, laneSetting), notices: startupNotices }));
|
|
983
944
|
}
|
|
984
945
|
else {
|
|
985
946
|
console.log(chalk.gray(`\nSession ${session.id} on ${selectedAlias}. Type /exit to quit.\n`));
|
|
@@ -1166,7 +1127,7 @@ export async function runRepl(opts) {
|
|
|
1166
1127
|
if (trimmed === '/help' || trimmed === '/skills') {
|
|
1167
1128
|
// Phase D5 — route through chunkEmitter so help lands in Body,
|
|
1168
1129
|
// not above the Ink frame (which would scroll off + corrupt).
|
|
1169
|
-
printHelp((line) => chunkEmitter.emit('chunk', line + '\n'));
|
|
1130
|
+
printHelp((line) => chunkEmitter.emit('chunk', line + '\n'), { lane: laneSetting });
|
|
1170
1131
|
return;
|
|
1171
1132
|
}
|
|
1172
1133
|
// /usage (2026-10-09) — this month in credits, from the proxy's ledger.
|
|
@@ -1190,8 +1151,14 @@ export async function runRepl(opts) {
|
|
|
1190
1151
|
if (trimmed === '/export' || trimmed.startsWith('/export ')) {
|
|
1191
1152
|
const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
|
|
1192
1153
|
const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
|
|
1154
|
+
// Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
|
|
1155
|
+
if (sub === 'docx') {
|
|
1156
|
+
const { docxFileFromReplArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
|
|
1157
|
+
await runExportDocxCommand({ file: docxFileFromReplArgs(trimmed.slice('/export'.length)), docx: true, system: session.sap_system, emit: (l) => chunkEmitter.emit('chunk', l + '\n') });
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1193
1160
|
if (sub !== 'audit') {
|
|
1194
|
-
chunkEmitter.emit('chunk', chalk.dim('\nUsage: /export audit [filename]\n'));
|
|
1161
|
+
chunkEmitter.emit('chunk', chalk.dim('\nUsage: /export audit [filename] · /export docx <file>.md\n'));
|
|
1195
1162
|
return;
|
|
1196
1163
|
}
|
|
1197
1164
|
await runExportAuditCommand({
|
|
@@ -1318,6 +1285,24 @@ export async function runRepl(opts) {
|
|
|
1318
1285
|
await handleUiRendering(arg, cfg, saveConfig, emit, shouldUseInk() ? 'ink' : 'classic');
|
|
1319
1286
|
return;
|
|
1320
1287
|
}
|
|
1288
|
+
// /style [value] — talking style (spec 2026-10-17 D9); saved like /ui look.
|
|
1289
|
+
if (trimmed === '/style' || trimmed.startsWith('/style ')) {
|
|
1290
|
+
if (!cfg.assistant)
|
|
1291
|
+
cfg.assistant = { ...DEFAULT_CONFIG.assistant };
|
|
1292
|
+
const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
|
|
1293
|
+
await handleStyleCommand(trimmed.slice('/style'.length), cfg, saveConfig, emit, pickStyleInk);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
// /level [value] — who CSPeach is talking to (spec 2026-10-17 §10 D13); saved like /style.
|
|
1297
|
+
// The D15 tip reads the write mode in force now (a /mode change this session counts).
|
|
1298
|
+
if (trimmed === '/level' || trimmed.startsWith('/level ')) {
|
|
1299
|
+
if (!cfg.assistant)
|
|
1300
|
+
cfg.assistant = { ...DEFAULT_CONFIG.assistant };
|
|
1301
|
+
const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
|
|
1302
|
+
const writeModeNow = getEffectiveWriteMode(cfg.write_mode, cfg.sap[selectedAlias]?.role);
|
|
1303
|
+
await handleLevelCommand(trimmed.slice('/level'.length), cfg, saveConfig, emit, pickLevelInk, writeModeNow);
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1321
1306
|
// /reroute — re-dispatch session.lastUserPrompt to a different skill
|
|
1322
1307
|
if (trimmed === '/reroute' || trimmed.startsWith('/reroute ')) {
|
|
1323
1308
|
session.awaitingSkillAnswer = false;
|
|
@@ -1385,7 +1370,19 @@ export async function runRepl(opts) {
|
|
|
1385
1370
|
todoEmitter.emit('update', []);
|
|
1386
1371
|
clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
|
|
1387
1372
|
clearJourneyFlow(session); // Task 3 — and a clean-core journey
|
|
1388
|
-
chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
|
|
1373
|
+
chunkEmitter.emit('chunk', '\n' + (laneSetting === 'chat' ? formatInfo('Fresh start.') : formatInfo('Fresh start · the next prompt picks a skill again')));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
// /chat [on|off] (spec 2026-10-17 D6, owner amendment) — leave a workflow,
|
|
1377
|
+
// or switch the lane now and save it; the next message reads the new lane.
|
|
1378
|
+
if (trimmed === '/chat' || trimmed.startsWith('/chat ')) {
|
|
1379
|
+
if (!cfg.assistant)
|
|
1380
|
+
cfg.assistant = { ...DEFAULT_CONFIG.assistant };
|
|
1381
|
+
laneSetting = await handleChatCommand(trimmed.slice('/chat'.length), {
|
|
1382
|
+
lane: laneSetting, cfg, save: saveConfig, session, interactive: !!process.stdin.isTTY,
|
|
1383
|
+
emit: (s) => chunkEmitter.emit('chunk', '\n' + s + '\n'),
|
|
1384
|
+
});
|
|
1385
|
+
sapStateStore.update({ chatLane: laneSetting === 'chat' });
|
|
1389
1386
|
return;
|
|
1390
1387
|
}
|
|
1391
1388
|
// /transport — session current-transport state for Rule 9 (transport isolation)
|
|
@@ -1520,7 +1517,22 @@ export async function runRepl(opts) {
|
|
|
1520
1517
|
const previousObject = session.last_object ?? null;
|
|
1521
1518
|
const shortcutSkill = parsed.skill;
|
|
1522
1519
|
let skill = shortcutSkill;
|
|
1523
|
-
|
|
1520
|
+
// Chat lane (D1/D6/D-G) — decided here, once, before today's routing
|
|
1521
|
+
// block. Skills lane ⇒ always `explicit` or `route` (block unchanged).
|
|
1522
|
+
const laneDecision = decideLane({
|
|
1523
|
+
laneSetting, interactive: !!process.stdin.isTTY, explicitSkill: shortcutSkill, trimmed,
|
|
1524
|
+
sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill,
|
|
1525
|
+
});
|
|
1526
|
+
if (laneDecision.kind === 'chat') {
|
|
1527
|
+
// M1 — just talk: no router, no routing line, no picker.
|
|
1528
|
+
skill = CHAT_SKILL;
|
|
1529
|
+
}
|
|
1530
|
+
else if (laneDecision.kind === 'continue') {
|
|
1531
|
+
// D6 — the workflow keeps the turn; the one existing line says so.
|
|
1532
|
+
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: laneDecision.skill }));
|
|
1533
|
+
skill = laneDecision.skill;
|
|
1534
|
+
}
|
|
1535
|
+
else if (!skill) {
|
|
1524
1536
|
// v0.3.1 — if the last skill turn ended with a question, route the
|
|
1525
1537
|
// user's reply back to the same skill instead of reclassifying. This
|
|
1526
1538
|
// stops short answers like "1" or "S/4 on-prem" from being treated
|
|
@@ -1541,9 +1553,10 @@ export async function runRepl(opts) {
|
|
|
1541
1553
|
// Round 6 item 4c / review I2 — after a resume, a short follow-up
|
|
1542
1554
|
// ("continue", a number) stays in the session's skill; anything else
|
|
1543
1555
|
// is classified (applyResumedSkill below).
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
skill
|
|
1556
|
+
const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
|
|
1557
|
+
if (continuedSkill) {
|
|
1558
|
+
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: continuedSkill }));
|
|
1559
|
+
skill = continuedSkill;
|
|
1547
1560
|
}
|
|
1548
1561
|
else if (isBareAck(trimmed)) {
|
|
1549
1562
|
// Bare "y" / "n" / "cancel" with no skill to continue — typing this
|
|
@@ -1969,7 +1982,7 @@ export async function runRepl(opts) {
|
|
|
1969
1982
|
// loop.ts) so the try/finally GUARANTEES the row clears even when the
|
|
1970
1983
|
// turn throws; loop.ts only narrates activity changes in between.
|
|
1971
1984
|
// (turnStatusEmitter is the module-level singleton import — BUG-2b.)
|
|
1972
|
-
turnStatusEmitter.start(
|
|
1985
|
+
turnStatusEmitter.start(turnStatusLabel(skill));
|
|
1973
1986
|
try {
|
|
1974
1987
|
// Task 6 (review M-2) — guarded chain start: announce and disable
|
|
1975
1988
|
// auto_approve for this phase's turn (request_approval consults the
|
|
@@ -2081,20 +2094,33 @@ export async function runRepl(opts) {
|
|
|
2081
2094
|
});
|
|
2082
2095
|
}
|
|
2083
2096
|
catch (err) {
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
print
|
|
2097
|
-
|
|
2097
|
+
if (err instanceof ChatUnavailableError) {
|
|
2098
|
+
// D11 — this server has no chat lane (404/403 for _chat; the loop
|
|
2099
|
+
// already withdrew the turn). Switch this PROCESS to skill routing
|
|
2100
|
+
// (config is not saved) and re-run the same message once through
|
|
2101
|
+
// today's router; the queued step shows the one notice line in
|
|
2102
|
+
// place of a second echo. The re-run decides `route`, so it can
|
|
2103
|
+
// never throw ChatUnavailableError again.
|
|
2104
|
+
laneSetting = 'skills';
|
|
2105
|
+
sapStateStore.update({ chatLane: false });
|
|
2106
|
+
pendingDispatchAccessor.set(trimmed, chatFallbackOrigin());
|
|
2107
|
+
}
|
|
2108
|
+
else {
|
|
2109
|
+
// 2026-09-24 — print the turn-error block INTO the conversation (it
|
|
2110
|
+
// stays on screen) instead of the overlay box that vanished after 5 s
|
|
2111
|
+
// — a customer's dropped turn left no trace of what happened or what
|
|
2112
|
+
// to do. Same block classic mode prints; in-session it leads with
|
|
2113
|
+
// "type continue" (the partial turn is already saved).
|
|
2114
|
+
const { renderTurnError } = await import('./agent/turn-error-ux.js');
|
|
2115
|
+
turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
|
|
2116
|
+
await renderTurnError({
|
|
2117
|
+
err,
|
|
2118
|
+
session,
|
|
2119
|
+
userInterrupted: turnCancelledByUser,
|
|
2120
|
+
inSession: true,
|
|
2121
|
+
print: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2098
2124
|
}
|
|
2099
2125
|
finally {
|
|
2100
2126
|
turnStatusEmitter.stop();
|
|
@@ -3134,7 +3160,7 @@ export async function runRepl(opts) {
|
|
|
3134
3160
|
}
|
|
3135
3161
|
// /help and /skills show the skill catalog flat-list.
|
|
3136
3162
|
if (trimmed === '/help' || trimmed === '/skills') {
|
|
3137
|
-
printHelp();
|
|
3163
|
+
printHelp(undefined, { lane: laneSetting });
|
|
3138
3164
|
continue;
|
|
3139
3165
|
}
|
|
3140
3166
|
// /usage (2026-10-09) — classic mirror of the Ink handler above.
|
|
@@ -3156,8 +3182,14 @@ export async function runRepl(opts) {
|
|
|
3156
3182
|
if (trimmed === '/export' || trimmed.startsWith('/export ')) {
|
|
3157
3183
|
const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
|
|
3158
3184
|
const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
|
|
3185
|
+
// Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
|
|
3186
|
+
if (sub === 'docx') {
|
|
3187
|
+
const { docxFileFromReplArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
|
|
3188
|
+
await runExportDocxCommand({ file: docxFileFromReplArgs(trimmed.slice('/export'.length)), docx: true, system: session.sap_system, emit: (l) => console.log(l) });
|
|
3189
|
+
continue;
|
|
3190
|
+
}
|
|
3159
3191
|
if (sub !== 'audit') {
|
|
3160
|
-
console.log(chalk.dim('Usage: /export audit [filename]'));
|
|
3192
|
+
console.log(chalk.dim('Usage: /export audit [filename] · /export docx <file>.md'));
|
|
3161
3193
|
continue;
|
|
3162
3194
|
}
|
|
3163
3195
|
await runExportAuditCommand({
|
|
@@ -3291,6 +3323,21 @@ export async function runRepl(opts) {
|
|
|
3291
3323
|
await handleUiRendering(arg, cfg, saveConfig, (s) => console.log(s), shouldUseInk() ? 'ink' : 'classic');
|
|
3292
3324
|
continue;
|
|
3293
3325
|
}
|
|
3326
|
+
// /style [value] — talking style (spec 2026-10-17 D9); saved like /ui look.
|
|
3327
|
+
if (trimmed === '/style' || trimmed.startsWith('/style ')) {
|
|
3328
|
+
if (!cfg.assistant)
|
|
3329
|
+
cfg.assistant = { ...DEFAULT_CONFIG.assistant };
|
|
3330
|
+
await handleStyleCommand(trimmed.slice('/style'.length), cfg, saveConfig, (s) => console.log(s), pickStyleClassic);
|
|
3331
|
+
continue;
|
|
3332
|
+
}
|
|
3333
|
+
// /level [value] — who CSPeach is talking to (spec 2026-10-17 §10 D13); saved like /style.
|
|
3334
|
+
if (trimmed === '/level' || trimmed.startsWith('/level ')) {
|
|
3335
|
+
if (!cfg.assistant)
|
|
3336
|
+
cfg.assistant = { ...DEFAULT_CONFIG.assistant };
|
|
3337
|
+
const writeModeNow = getEffectiveWriteMode(cfg.write_mode, cfg.sap[selectedAlias]?.role);
|
|
3338
|
+
await handleLevelCommand(trimmed.slice('/level'.length), cfg, saveConfig, (s) => console.log(s), pickLevelClassic, writeModeNow);
|
|
3339
|
+
continue;
|
|
3340
|
+
}
|
|
3294
3341
|
// /reroute — re-dispatch session.lastUserPrompt to a different skill
|
|
3295
3342
|
if (trimmed === '/reroute' || trimmed.startsWith('/reroute ')) {
|
|
3296
3343
|
session.awaitingSkillAnswer = false;
|
|
@@ -3339,7 +3386,18 @@ export async function runRepl(opts) {
|
|
|
3339
3386
|
session.todos = []; // P2 — /new clears the plan (spec §4)
|
|
3340
3387
|
clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
|
|
3341
3388
|
clearJourneyFlow(session); // Task 3 — and a clean-core journey
|
|
3342
|
-
console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
|
|
3389
|
+
console.log((laneSetting === 'chat' ? formatInfo('Fresh start.') : formatInfo('Fresh start · the next prompt picks a skill again')).trimEnd());
|
|
3390
|
+
continue;
|
|
3391
|
+
}
|
|
3392
|
+
// /chat [on|off] — classic mirror of the Ink handler above.
|
|
3393
|
+
if (trimmed === '/chat' || trimmed.startsWith('/chat ')) {
|
|
3394
|
+
if (!cfg.assistant)
|
|
3395
|
+
cfg.assistant = { ...DEFAULT_CONFIG.assistant };
|
|
3396
|
+
laneSetting = await handleChatCommand(trimmed.slice('/chat'.length), {
|
|
3397
|
+
lane: laneSetting, cfg, save: saveConfig, session, interactive: !!process.stdin.isTTY,
|
|
3398
|
+
emit: (s) => console.log(s),
|
|
3399
|
+
});
|
|
3400
|
+
sapStateStore.update({ chatLane: laneSetting === 'chat' });
|
|
3343
3401
|
continue;
|
|
3344
3402
|
}
|
|
3345
3403
|
// /transport — session current-transport state for Rule 9 (transport isolation)
|
|
@@ -3583,7 +3641,20 @@ export async function runRepl(opts) {
|
|
|
3583
3641
|
const previousSkill = session.skill; // snapshot before this turn dispatches
|
|
3584
3642
|
const previousObject = session.last_object ?? null; // carries from two turns ago
|
|
3585
3643
|
let skill = parsed.skill;
|
|
3586
|
-
|
|
3644
|
+
// Chat lane (D1/D6/D-G) — classic mirror of the Ink decision. Piped stdin
|
|
3645
|
+
// is a scripting surface and keeps today's router (interactive: false).
|
|
3646
|
+
const laneDecision = decideLane({
|
|
3647
|
+
laneSetting, interactive: !pipedStdin, explicitSkill: parsed.skill, trimmed,
|
|
3648
|
+
sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill,
|
|
3649
|
+
});
|
|
3650
|
+
if (laneDecision.kind === 'chat') {
|
|
3651
|
+
skill = CHAT_SKILL;
|
|
3652
|
+
}
|
|
3653
|
+
else if (laneDecision.kind === 'continue') {
|
|
3654
|
+
console.log(formatRoutingLine({ kind: 'continuing', skill: laneDecision.skill }).trimEnd());
|
|
3655
|
+
skill = laneDecision.skill;
|
|
3656
|
+
}
|
|
3657
|
+
else if (!skill) {
|
|
3587
3658
|
// v0.3.1 — if the last skill turn ended with a question, route the
|
|
3588
3659
|
// user's reply back to the same skill instead of reclassifying. This
|
|
3589
3660
|
// stops short answers like "1" or "S/4 on-prem" from being treated
|
|
@@ -3598,9 +3669,10 @@ export async function runRepl(opts) {
|
|
|
3598
3669
|
continue;
|
|
3599
3670
|
}
|
|
3600
3671
|
// Round 6 item 4c / review I2 — see the Ink path.
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
skill
|
|
3672
|
+
const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
|
|
3673
|
+
if (continuedSkill) {
|
|
3674
|
+
console.log(formatRoutingLine({ kind: 'continuing', skill: continuedSkill }).trimEnd());
|
|
3675
|
+
skill = continuedSkill;
|
|
3604
3676
|
}
|
|
3605
3677
|
else if (isBareAck(trimmed)) {
|
|
3606
3678
|
// Bare "y" / "n" / "cancel" with no skill to continue — almost always
|
|
@@ -4093,18 +4165,27 @@ export async function runRepl(opts) {
|
|
|
4093
4165
|
});
|
|
4094
4166
|
}
|
|
4095
4167
|
catch (err) {
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4168
|
+
if (err instanceof ChatUnavailableError) {
|
|
4169
|
+
// D11 — classic mirror of the Ink catch: process lane only, no save;
|
|
4170
|
+
// the loop-top slot consumer re-runs the message once.
|
|
4171
|
+
laneSetting = 'skills';
|
|
4172
|
+
sapStateStore.update({ chatLane: false });
|
|
4173
|
+
pendingDispatchAccessor.set(trimmed, chatFallbackOrigin());
|
|
4174
|
+
}
|
|
4175
|
+
else {
|
|
4176
|
+
// v0.6 — graceful turn-error UX. The loop's try/catch already
|
|
4177
|
+
// persisted any partial assistant content to session.messages and
|
|
4178
|
+
// wrote the streamed prose to ~/.cspeach/streams/. Here we only
|
|
4179
|
+
// surface a clean 4–6 line diagnostic + write a full log so the
|
|
4180
|
+
// user can resume cleanly. See agent/turn-error-ux.ts for details.
|
|
4181
|
+
await renderTurnError({
|
|
4182
|
+
err,
|
|
4183
|
+
session,
|
|
4184
|
+
userInterrupted: interruptedByUser,
|
|
4185
|
+
inSession: true,
|
|
4186
|
+
print: (line) => console.error(line),
|
|
4187
|
+
});
|
|
4188
|
+
}
|
|
4108
4189
|
}
|
|
4109
4190
|
finally {
|
|
4110
4191
|
rl.removeListener('SIGINT', onSigint);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// the exact command was written by the model, so it shows as "Auto-routed: …",
|
|
14
14
|
// never as typed. An untagged command keeps the old echo.
|
|
15
15
|
import { shownUpTo } from '../register/atc-file.js';
|
|
16
|
+
import { paint } from '../renderer/theme.js';
|
|
16
17
|
export function createDispatchSlot() {
|
|
17
18
|
let cmd = null;
|
|
18
19
|
let origin = null;
|
|
@@ -41,5 +42,8 @@ export function queuedStepView(origin) {
|
|
|
41
42
|
if (!origin)
|
|
42
43
|
return { echo: true };
|
|
43
44
|
const line = origin.line ? shownUpTo(origin.line, 160).trim() : '';
|
|
44
|
-
|
|
45
|
+
if (!line)
|
|
46
|
+
return { echo: false, line: null };
|
|
47
|
+
// D11 — the chat-fallback line is dim; painted after shownUpTo flattened it.
|
|
48
|
+
return { echo: false, line: origin.by === 'chat_fallback' ? paint('dim', line) : line };
|
|
45
49
|
}
|
package/dist/session/schema.js
CHANGED
package/dist/session/store.js
CHANGED
|
@@ -72,6 +72,10 @@ function validateSchema(parsed) {
|
|
|
72
72
|
// Phase 4 chunk 4A: agentMode defaults to 'skill' for sessions written
|
|
73
73
|
// before this chunk landed. Mirrors the lastUserPrompt back-compat above.
|
|
74
74
|
parsed.agentMode ??= 'skill';
|
|
75
|
+
// Chat lane (2026-10-17 D7): older files have no lane — they ran skill turns.
|
|
76
|
+
// Anything but 'chat' (missing or hand-edited) loads as 'skill', the safe default.
|
|
77
|
+
if (parsed.lane !== 'chat')
|
|
78
|
+
parsed.lane = 'skill';
|
|
75
79
|
// 2026-10-08: sessions are listed per folder. Older files have no folder.
|
|
76
80
|
parsed.project_dir ??= null;
|
|
77
81
|
parsed.project_id ??= null;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hand_off (chat lane, spec 2026-10-17 D5) — the chat's one way into a guided
|
|
3
|
+
* workflow. Queues `/<skill> <request>` through the existing pendingDispatch
|
|
4
|
+
* slot (shown as one line "→ /abap-x", never as the person's own line); the
|
|
5
|
+
* chat turn then ends and the workflow runs with its own plan + approvals.
|
|
6
|
+
* No extra consent card (D5): the workflow asks before any write.
|
|
7
|
+
* Refused outside the chat lane; dispatch_skill stays the skills' own switch.
|
|
8
|
+
*
|
|
9
|
+
* Flag-gated: only a chat-lane process enables it (Task 11), so a skills-lane
|
|
10
|
+
* session never lists it and its tools array stays byte-identical to today.
|
|
11
|
+
*/
|
|
12
|
+
import { registerTool } from './index.js';
|
|
13
|
+
import { isPlanResumeCommand } from '../commands/plan-resume.js';
|
|
14
|
+
import { SKILL_CATALOG, resolveSkillAlias } from '../skill-catalog.js';
|
|
15
|
+
/** Longest request hand_off queues (after whitespace is collapsed). */
|
|
16
|
+
export const HAND_OFF_REQUEST_MAX = 500;
|
|
17
|
+
export const HAND_OFF_LINE = (skill) => `→ /${skill}`;
|
|
18
|
+
export const buildHandOffCommand = (skill, request) => `/${skill} ${request}`;
|
|
19
|
+
const CATALOG_NAMES = new Set(SKILL_CATALOG.map((s) => s.name));
|
|
20
|
+
/**
|
|
21
|
+
* A workflow name as the model wrote it → its catalog name, or null.
|
|
22
|
+
* Strips one leading `/`, lowercases, resolves a retired alias, and accepts a
|
|
23
|
+
* bare name ("refactor" → "abap-refactor"). Never `_chat` (not in the catalog).
|
|
24
|
+
*/
|
|
25
|
+
export function normaliseSkill(raw) {
|
|
26
|
+
if (typeof raw !== 'string')
|
|
27
|
+
return null;
|
|
28
|
+
const bare = raw.trim().replace(/^\//, '').toLowerCase();
|
|
29
|
+
if (!bare)
|
|
30
|
+
return null;
|
|
31
|
+
for (const candidate of [bare, `abap-${bare}`]) {
|
|
32
|
+
const resolved = resolveSkillAlias(candidate);
|
|
33
|
+
if (CATALOG_NAMES.has(resolved))
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
registerTool({
|
|
39
|
+
name: 'hand_off',
|
|
40
|
+
description: 'Hand this conversation to a CSPeach guided workflow when the developer wants a change made in SAP or a complete deliverable (a full review, a handover document, an upgrade scan). The workflow shows its plan and asks before it writes, so do not ask "shall I hand off?" first — say in one line what you would change, then call this. skill: the workflow name (e.g. "abap-refactor", "abap-generate", "abap-transport", "abap-review"). request: what to do, one clear sentence with object names. After this returns, end the turn.',
|
|
41
|
+
isMutating: false,
|
|
42
|
+
flagGated: true,
|
|
43
|
+
category: 'session',
|
|
44
|
+
input_schema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
skill: { type: 'string', description: 'Workflow name without the slash, e.g. "abap-refactor".' },
|
|
48
|
+
request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags.' },
|
|
49
|
+
},
|
|
50
|
+
required: ['skill', 'request'],
|
|
51
|
+
},
|
|
52
|
+
handler: async (args, ctx) => {
|
|
53
|
+
const bad = (error, reason) => ({ is_error: true, content: JSON.stringify({ error, reason }) });
|
|
54
|
+
if (ctx.session?.lane !== 'chat') {
|
|
55
|
+
return bad('HAND_OFF_OUTSIDE_CHAT', 'hand_off is only for the chat; in a workflow, continue with the workflow (dispatch_skill switches workflows after the person agreed).');
|
|
56
|
+
}
|
|
57
|
+
const skill = normaliseSkill(args.skill);
|
|
58
|
+
if (!skill) {
|
|
59
|
+
return bad('HAND_OFF_UNKNOWN_SKILL', `unknown workflow "${String(args.skill ?? '')}". Use one of: ${SKILL_CATALOG.map((s) => s.name).join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
const request = typeof args.request === 'string' ? args.request.trim().replace(/\s+/g, ' ') : '';
|
|
62
|
+
if (!request)
|
|
63
|
+
return bad('HAND_OFF_INVALID', 'request must say what the workflow should do');
|
|
64
|
+
if (request.length > HAND_OFF_REQUEST_MAX) {
|
|
65
|
+
return bad('HAND_OFF_INVALID', `request is too long (${request.length} chars; at most ${HAND_OFF_REQUEST_MAX}). Say it in one short sentence with the object names.`);
|
|
66
|
+
}
|
|
67
|
+
const command = buildHandOffCommand(skill, request);
|
|
68
|
+
if (isPlanResumeCommand(command)) {
|
|
69
|
+
return bad('PLAN_RESUME_HARNESS_OWNED', 'Plan continuation is owned by CSPeach; never hand off "/abap-plan --resume".');
|
|
70
|
+
}
|
|
71
|
+
if (request.startsWith('--')) {
|
|
72
|
+
return bad('HAND_OFF_INVALID', 'request must start with what to do, not with flags (--…). Say it in one plain sentence with the object names.');
|
|
73
|
+
}
|
|
74
|
+
if (!ctx.pendingDispatch) {
|
|
75
|
+
return bad('DISPATCH_UNSUPPORTED', 'no dispatch slot in this context (one-shot or test); tell the developer which /abap-… command to run instead');
|
|
76
|
+
}
|
|
77
|
+
ctx.pendingDispatch.set(command, { by: 'hand_off', line: HAND_OFF_LINE(skill) });
|
|
78
|
+
return {
|
|
79
|
+
content: JSON.stringify({
|
|
80
|
+
ok: true,
|
|
81
|
+
queued: command,
|
|
82
|
+
note: 'The workflow runs next and asks before it writes. End the turn now with at most one short line; do not print the command.',
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
});
|
|
@@ -86,6 +86,9 @@ export const SKILL_STARTERS = Object.freeze({
|
|
|
86
86
|
// Selection texts (2026-10-04): a new PARAMETERS / SELECT-OPTIONS gets its text in the same change.
|
|
87
87
|
'abap-refactor': ['sap_get_text_elements', 'sap_set_text_elements'],
|
|
88
88
|
'abap-generate': ['sap_get_text_elements', 'sap_set_text_elements', 'sap_run'],
|
|
89
|
+
// Chat lane (D5): the hand-off tool is loaded on chat turns; flag-gated, so a skills-lane session never lists it.
|
|
90
|
+
// `_chat` is the one SKILL_STARTERS key that is not a catalog skill (pinned in tool-loading.test.ts).
|
|
91
|
+
'_chat': ['hand_off'],
|
|
89
92
|
});
|
|
90
93
|
/** The starter tools for a skill; unknown skill (or none) ⇒ []. */
|
|
91
94
|
export function startersForSkill(skill) {
|
|
@@ -20,6 +20,7 @@ const DEFAULT_STATE = {
|
|
|
20
20
|
creditsMode: false, creditsSessionTotal: 0, creditsBalance: null,
|
|
21
21
|
creditsUnitLabel: 'credits', creditsPending: false,
|
|
22
22
|
standalone: false,
|
|
23
|
+
chatLane: false,
|
|
23
24
|
};
|
|
24
25
|
/**
|
|
25
26
|
* Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
|
|
@@ -178,6 +178,7 @@ export function toCardQuestions(request) {
|
|
|
178
178
|
options: freeText ? [] : request.choices,
|
|
179
179
|
otherRow: false,
|
|
180
180
|
defaultValue: request.defaultValue,
|
|
181
|
+
cursorValue: request.cursorValue,
|
|
181
182
|
keys: request.keys,
|
|
182
183
|
}];
|
|
183
184
|
}
|
|
@@ -323,7 +324,7 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
|
|
|
323
324
|
// Focus slots: 0..n-1 = questions; n = the Review step (2+ questions,
|
|
324
325
|
// reachable only once every question has an answer).
|
|
325
326
|
const [focus, setFocus] = useState(0);
|
|
326
|
-
const [cursors, setCursors] = useState(() => questions.map((qq) => Math.max(0, qq.options.findIndex((o) => o.value === qq.defaultValue))));
|
|
327
|
+
const [cursors, setCursors] = useState(() => questions.map((qq) => Math.max(0, qq.options.findIndex((o) => o.value === (qq.defaultValue ?? qq.cursorValue)))));
|
|
327
328
|
const [ticks, setTicks] = useState(() => questions.map(() => new Set()));
|
|
328
329
|
const [drafts, setDrafts] = useState(() => questions.map(() => ''));
|
|
329
330
|
const [answers, setAnswers] = useState(() => questions.map(() => null));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.46",
|
|
4
4
|
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abap",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"clipboardy": "^5.3.1",
|
|
75
75
|
"cross-spawn": "^7.0.6",
|
|
76
76
|
"diff": "^9.0.0",
|
|
77
|
+
"docx": "^9.0.2",
|
|
77
78
|
"ejs": "3.1.10",
|
|
78
79
|
"fuse.js": "^7.0.0",
|
|
79
80
|
"ink": "^5.0.0",
|
|
@@ -104,7 +105,6 @@
|
|
|
104
105
|
"@types/turndown": "^5.0.5",
|
|
105
106
|
"@ui5/builder": "4.3.2",
|
|
106
107
|
"@ui5/fs": "4.0.6",
|
|
107
|
-
"docx": "^9.0.2",
|
|
108
108
|
"husky": "^9.1.7",
|
|
109
109
|
"ink-testing-library": "^4.0.0",
|
|
110
110
|
"pdf-lib": "^1.17.1",
|