@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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat lane (spec 2026-10-17 D1/D6/D8) — the lane decision both REPL paths
|
|
3
|
+
* make BEFORE today's routing block, as a pure function:
|
|
4
|
+
* explicit — the person typed (or CSPeach queued) a /skill
|
|
5
|
+
* route — today's block, verbatim (skills lane, or a piped/non-TTY surface)
|
|
6
|
+
* continue — chat lane, but the previous workflow keeps the turn (D6)
|
|
7
|
+
* chat — everything else in the chat lane
|
|
8
|
+
* ACK_PHRASES / isAckContinuation / CONVERSATIONAL_ACKS / isConversationalAck
|
|
9
|
+
* moved here from repl.tsx unchanged so the decision and the routing block
|
|
10
|
+
* share one predicate.
|
|
11
|
+
*/
|
|
12
|
+
import { isShortFollowUp } from '../router/resume-routing.js';
|
|
13
|
+
import { isChatSkill } from '../agent/chat-prompt.js';
|
|
14
|
+
import { enableToolFlags } from '../tools/_flag.js';
|
|
15
|
+
/** Short user inputs that almost always mean "continue what we were doing"
|
|
16
|
+
* rather than "start a new task." When the previous turn left a skill
|
|
17
|
+
* in session.skill, an ack-only follow-up should route there instead of
|
|
18
|
+
* re-classifying as a fresh prompt (which produces nonsense like "yes"
|
|
19
|
+
* → /abap-explain at 35% confidence). */
|
|
20
|
+
export const ACK_PHRASES = new Set([
|
|
21
|
+
'yes', 'y', 'yeah', 'yep', 'yup', 'sure',
|
|
22
|
+
'ok', 'okay', 'k', 'kk',
|
|
23
|
+
'proceed', 'continue', 'go', 'go ahead', 'do it', 'do that', 'go on',
|
|
24
|
+
'no', 'n', 'nope', 'nah',
|
|
25
|
+
'stop', 'cancel', 'abort', 'never mind', 'nm',
|
|
26
|
+
'approve', 'approved', 'accept', 'accepted',
|
|
27
|
+
'decline', 'denied', 'reject', 'rejected',
|
|
28
|
+
'skip', 'next',
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Returns true when `trimmed` is a short acknowledgement-style follow-up
|
|
32
|
+
* AND there is a previous skill to continue. Treats both bare acks and
|
|
33
|
+
* short ack-prefixed phrases ("yes please", "no thanks") the same way.
|
|
34
|
+
* Strips trailing punctuation so "yes." and "yes!" still register.
|
|
35
|
+
*/
|
|
36
|
+
export function isAckContinuation(trimmed, sessionSkill) {
|
|
37
|
+
if (!sessionSkill)
|
|
38
|
+
return false;
|
|
39
|
+
const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
40
|
+
if (ACK_PHRASES.has(normalized))
|
|
41
|
+
return true;
|
|
42
|
+
// "yes please", "go ahead and do it", "no thanks" — first word is an ack
|
|
43
|
+
// and the whole phrase is short enough to be a follow-up not a new task.
|
|
44
|
+
if (normalized.length <= 30) {
|
|
45
|
+
const firstWord = normalized.split(/\s+/)[0] ?? '';
|
|
46
|
+
if (ACK_PHRASES.has(firstWord))
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/** Conversational acks — "user closes the previous thread, no further work"
|
|
52
|
+
* (moved from repl.tsx unchanged). Distinct from ACK_PHRASES (which
|
|
53
|
+
* largely mean "yes/no to a question"). In the skills lane the routing
|
|
54
|
+
* block prints a friendly ack for these and returns without classifying.
|
|
55
|
+
*
|
|
56
|
+
* 2026-05-17 — Laeeq hit "thank you" being classified and run through
|
|
57
|
+
* /abap-explain, producing another huge code explanation. THIS is the
|
|
58
|
+
* fix for that. */
|
|
59
|
+
export const CONVERSATIONAL_ACKS = new Set([
|
|
60
|
+
'thanks', 'thank you', 'thank u', 'thx', 'tx', 'ty', 'ta',
|
|
61
|
+
'cool', 'nice', 'great', 'awesome', 'perfect',
|
|
62
|
+
'got it', 'gotit', 'understood',
|
|
63
|
+
'cheers',
|
|
64
|
+
// Common ackowledgements that follow a long answer.
|
|
65
|
+
'makes sense',
|
|
66
|
+
]);
|
|
67
|
+
/** True when `trimmed` is one of CONVERSATIONAL_ACKS (today's skills-lane predicate, unchanged). */
|
|
68
|
+
export function isConversationalAck(trimmed) {
|
|
69
|
+
const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
70
|
+
return CONVERSATIONAL_ACKS.has(normalized);
|
|
71
|
+
}
|
|
72
|
+
/** Chat lane only: a conversational ack, also behind a leading "ok"/"okay"
|
|
73
|
+
* ("ok thanks", "okay, got it") — those start with an ACK_PHRASES word and
|
|
74
|
+
* would otherwise read as a "yes" to the workflow. */
|
|
75
|
+
function isChatClosingAck(trimmed) {
|
|
76
|
+
return isConversationalAck(stripOkPrefix(trimmed));
|
|
77
|
+
}
|
|
78
|
+
function stripOkPrefix(trimmed) {
|
|
79
|
+
return trimmed.trim().replace(/^(ok|okay)[\s,]+/i, '');
|
|
80
|
+
}
|
|
81
|
+
/** Pure thanks — the only closing acks that win over a workflow's open question (D6). */
|
|
82
|
+
const THANKS_PHRASES = new Set(['thanks', 'thank you', 'thank u', 'thx', 'tx', 'ty', 'ta', 'cheers']);
|
|
83
|
+
/** "thanks" / "ok, thanks!" — while a workflow is asking, "perfect" / "ok great"
|
|
84
|
+
* is an answer to it ("Shall I create these 5 objects?"), but thanks is not. */
|
|
85
|
+
function isThanksOnly(trimmed) {
|
|
86
|
+
return THANKS_PHRASES.has(stripOkPrefix(trimmed).toLowerCase().replace(/[.!?,]+$/, ''));
|
|
87
|
+
}
|
|
88
|
+
/** Today's continuation predicate (repl.tsx Ink + classic paths), as one
|
|
89
|
+
* function: the skill that keeps the turn, or null. */
|
|
90
|
+
export function wantsContinuation(i) {
|
|
91
|
+
if (!i.sessionSkill)
|
|
92
|
+
return null;
|
|
93
|
+
return (i.awaitingSkillAnswer || (i.resumedSkill !== null && isShortFollowUp(i.trimmed)) || isAckContinuation(i.trimmed, i.sessionSkill)) ? i.sessionSkill : null;
|
|
94
|
+
}
|
|
95
|
+
export function decideLane(i) {
|
|
96
|
+
if (i.explicitSkill)
|
|
97
|
+
return { kind: 'explicit', skill: i.explicitSkill };
|
|
98
|
+
if (i.laneSetting === 'skills' || !i.interactive)
|
|
99
|
+
return { kind: 'route' };
|
|
100
|
+
// "thanks" / "ok thanks" closes the thread — chat answers it; it is never
|
|
101
|
+
// a workflow turn or a "yes" to the workflow's question. While a workflow
|
|
102
|
+
// just asked, only pure thanks closes it: "perfect" / "ok great" answer it.
|
|
103
|
+
if (i.awaitingSkillAnswer ? isThanksOnly(i.trimmed) : isChatClosingAck(i.trimmed))
|
|
104
|
+
return { kind: 'chat' };
|
|
105
|
+
const continued = wantsContinuation(i);
|
|
106
|
+
if (continued)
|
|
107
|
+
return { kind: 'continue', skill: continued };
|
|
108
|
+
return { kind: 'chat' };
|
|
109
|
+
}
|
|
110
|
+
/** The status row label (D7): "chat", never "/_chat". */
|
|
111
|
+
export function turnStatusLabel(skill) {
|
|
112
|
+
return isChatSkill(skill) ? 'chat' : `/${skill}`;
|
|
113
|
+
}
|
|
114
|
+
/** D11 — the one dim line shown in place of the echo when the message re-runs through today's router. */
|
|
115
|
+
export const CHAT_UNAVAILABLE_LINE = "Chat isn't available on this server yet — using skill routing.";
|
|
116
|
+
export function chatFallbackOrigin() {
|
|
117
|
+
return { by: 'chat_fallback', line: CHAT_UNAVAILABLE_LINE };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The one place a process lane's tool side effect is applied (D5, §6.1).
|
|
121
|
+
* Only an interactive chat-lane process enables the `hand_off` flag; a
|
|
122
|
+
* skills-lane process, piped stdin and one-shot keep today's tools array
|
|
123
|
+
* byte-identical (guarded by chat-lane-hand-off-guard.test.ts).
|
|
124
|
+
*
|
|
125
|
+
* Seam for Task 14 (`/chat on`): after setting the REPL's `laneSetting`, call
|
|
126
|
+
* this again with the new lane — the next turn's tools array then lists
|
|
127
|
+
* hand_off (in static mode the array grows once; the user chose the switch).
|
|
128
|
+
* It never disables: `/chat off` and the D11 fallback leave the flag on, so
|
|
129
|
+
* the array stays stable for the rest of the session; hand_off is inert there
|
|
130
|
+
* because its handler and dispatch refuse it outside the chat lane.
|
|
131
|
+
*/
|
|
132
|
+
export function applyLaneTools(lane, interactive) {
|
|
133
|
+
if (lane === 'chat' && interactive)
|
|
134
|
+
enableToolFlags(['hand_off']);
|
|
135
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ASSISTANT_LEVELS, parseLevel } from '../agent/level.js';
|
|
2
|
+
import { setAssistantLevel } from '../agent/level-state.js';
|
|
3
|
+
import { formatFailure } from '../renderer/notice-shape.js';
|
|
4
|
+
import { paint } from '../renderer/theme.js';
|
|
5
|
+
import { getGlyphs } from '../renderer/glyphs.js';
|
|
6
|
+
/** Spec §10 D13 — who CSPeach is talking to. One line per level, shown in the picker. Product copy; plain words. */
|
|
7
|
+
export const LEVEL_DESCRIPTIONS = Object.freeze({
|
|
8
|
+
developer: "Talks to a developer — today's behaviour (default)",
|
|
9
|
+
learning: 'Explains the why and the terms — for a developer still building experience',
|
|
10
|
+
functional: 'Business first, code only when asked — hands code changes to a developer as a request file',
|
|
11
|
+
});
|
|
12
|
+
/** D15 — the recommended set-up for a non-developer, said once, never enforced.
|
|
13
|
+
* /level is saved but /mode is session-only, so the tip leads with the saved
|
|
14
|
+
* write_mode setting (the pairing would otherwise lapse next launch). */
|
|
15
|
+
export const FUNCTIONAL_WRITE_MODE_TIP = 'For someone who should not change SAP, also run: cspeach config set write_mode advisory-only (or /mode advisory for this session)';
|
|
16
|
+
/** D-L — one line: the confirmation, plus the D15 tip only when it applies. */
|
|
17
|
+
export function levelConfirmation(level, writeMode) {
|
|
18
|
+
const base = `Level set to ${level}`;
|
|
19
|
+
return level === 'functional' && writeMode !== 'advisory-only' ? `${base} · ${FUNCTIONAL_WRITE_MODE_TIP}` : `${base}.`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* `writeMode` is the mode in force now (the REPL passes the effective mode, so
|
|
23
|
+
* a `/mode advisory` earlier in the session counts); it defaults to the config.
|
|
24
|
+
*/
|
|
25
|
+
export async function handleLevelCommand(arg, cfg, save, emit, pick, writeMode = cfg.write_mode) {
|
|
26
|
+
const raw = arg.trim();
|
|
27
|
+
let next;
|
|
28
|
+
if (raw === '') {
|
|
29
|
+
next = await pick(cfg.assistant.level);
|
|
30
|
+
if (next === null)
|
|
31
|
+
return; // cancelled: nothing changes, nothing printed
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
next = parseLevel(raw);
|
|
35
|
+
if (!next) {
|
|
36
|
+
emit(formatFailure({ what: `Unknown level: ${raw}. Try ${ASSISTANT_LEVELS.join(', ')}.` }).trimEnd());
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
cfg.assistant.level = next;
|
|
41
|
+
setAssistantLevel(next);
|
|
42
|
+
try {
|
|
43
|
+
await save(cfg);
|
|
44
|
+
emit(`${paint('ok', getGlyphs().done)} ${levelConfirmation(next, writeMode)}`);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
emit(formatFailure({ what: `Failed to save config: ${err.message} (applied for this session only)` }).trimEnd());
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Ink: an AskCard choice under the card-slot focus claim; the cursor starts on
|
|
51
|
+
* the current level, tagged "(current)" — "(default)" is only developer's description. */
|
|
52
|
+
export async function pickLevelInk(current) {
|
|
53
|
+
const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
|
|
54
|
+
const { withFocusClaim } = await import('../ui/card-slot.js');
|
|
55
|
+
const res = await withFocusClaim(() => askQuestionEmitter.request({
|
|
56
|
+
id: 'level',
|
|
57
|
+
question: "Who you're talking to",
|
|
58
|
+
kind: 'choice',
|
|
59
|
+
cursorValue: current,
|
|
60
|
+
choices: ASSISTANT_LEVELS.map((l) => ({ value: l, label: l === current ? `${l} (current)` : l, description: LEVEL_DESCRIPTIONS[l] })),
|
|
61
|
+
}));
|
|
62
|
+
return parseLevel(res.answer);
|
|
63
|
+
}
|
|
64
|
+
/** Classic: a themed inquirer select; Ctrl+C there resolves null. */
|
|
65
|
+
export async function pickLevelClassic(current) {
|
|
66
|
+
const { withInquirer } = await import('./inquirer-guard.js');
|
|
67
|
+
const { select } = await import('./themed-prompts.js');
|
|
68
|
+
try {
|
|
69
|
+
const v = await withInquirer(() => select({
|
|
70
|
+
message: "Who you're talking to",
|
|
71
|
+
default: current,
|
|
72
|
+
choices: ASSISTANT_LEVELS.map((l) => ({ value: l, name: l, description: LEVEL_DESCRIPTIONS[l] })),
|
|
73
|
+
}));
|
|
74
|
+
return parseLevel(v);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null; // Ctrl+C in inquirer
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ASSISTANT_STYLES, parseStyle } from '../agent/style.js';
|
|
2
|
+
import { setAssistantStyle } from '../agent/style-state.js';
|
|
3
|
+
import { formatFailure } from '../renderer/notice-shape.js';
|
|
4
|
+
import { paint } from '../renderer/theme.js';
|
|
5
|
+
import { getGlyphs } from '../renderer/glyphs.js';
|
|
6
|
+
/** One line per style, shown in the picker. Product copy; plain words. */
|
|
7
|
+
export const STYLE_DESCRIPTIONS = Object.freeze({
|
|
8
|
+
professional: "Clear and neutral — today's voice (default)",
|
|
9
|
+
conversational: 'Like a teammate in a chat — relaxed, still brief',
|
|
10
|
+
humor: "A light, dry aside now and then — never at anyone's expense",
|
|
11
|
+
blunt: 'Direct, no padding — critiques the code, never the person',
|
|
12
|
+
});
|
|
13
|
+
export async function handleStyleCommand(arg, cfg, save, emit, pick) {
|
|
14
|
+
const raw = arg.trim();
|
|
15
|
+
let next;
|
|
16
|
+
if (raw === '') {
|
|
17
|
+
next = await pick(cfg.assistant.style);
|
|
18
|
+
if (next === null)
|
|
19
|
+
return; // cancelled: nothing changes, nothing printed
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
next = parseStyle(raw);
|
|
23
|
+
if (!next) {
|
|
24
|
+
emit(formatFailure({ what: `Unknown style: ${raw}. Try ${ASSISTANT_STYLES.join(', ')}.` }).trimEnd());
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
cfg.assistant.style = next;
|
|
29
|
+
setAssistantStyle(next);
|
|
30
|
+
try {
|
|
31
|
+
await save(cfg);
|
|
32
|
+
emit(`${paint('ok', getGlyphs().done)} Style set to ${next}.`);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
emit(formatFailure({ what: `Failed to save config: ${err.message} (applied for this session only)` }).trimEnd());
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Ink: an AskCard choice under the card-slot focus claim; the cursor starts on
|
|
39
|
+
* the current style, tagged "(current)" — "(default)" is only professional's description. */
|
|
40
|
+
export async function pickStyleInk(current) {
|
|
41
|
+
const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
|
|
42
|
+
const { withFocusClaim } = await import('../ui/card-slot.js');
|
|
43
|
+
const res = await withFocusClaim(() => askQuestionEmitter.request({
|
|
44
|
+
id: 'style',
|
|
45
|
+
question: 'Talking style',
|
|
46
|
+
kind: 'choice',
|
|
47
|
+
cursorValue: current,
|
|
48
|
+
choices: ASSISTANT_STYLES.map((s) => ({ value: s, label: s === current ? `${s} (current)` : s, description: STYLE_DESCRIPTIONS[s] })),
|
|
49
|
+
}));
|
|
50
|
+
return parseStyle(res.answer);
|
|
51
|
+
}
|
|
52
|
+
/** Classic: a themed inquirer select; Ctrl+C there resolves null. */
|
|
53
|
+
export async function pickStyleClassic(current) {
|
|
54
|
+
const { withInquirer } = await import('./inquirer-guard.js');
|
|
55
|
+
const { select } = await import('./themed-prompts.js');
|
|
56
|
+
try {
|
|
57
|
+
const v = await withInquirer(() => select({
|
|
58
|
+
message: 'Talking style',
|
|
59
|
+
default: current,
|
|
60
|
+
choices: ASSISTANT_STYLES.map((s) => ({ value: s, name: s, description: STYLE_DESCRIPTIONS[s] })),
|
|
61
|
+
}));
|
|
62
|
+
return parseStyle(v);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null; // Ctrl+C in inquirer
|
|
66
|
+
}
|
|
67
|
+
}
|