@cspeach/cli 1.1.45 → 1.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/agent/chat-prompt.js +47 -0
  2. package/dist/agent/chat-unavailable.js +9 -0
  3. package/dist/agent/level-state.js +11 -0
  4. package/dist/agent/level.js +60 -0
  5. package/dist/agent/loop.js +110 -2
  6. package/dist/agent/provider-shape.js +6 -2
  7. package/dist/agent/providers/ai-hub-provider.js +3 -1
  8. package/dist/agent/providers/azure-provider.js +3 -1
  9. package/dist/agent/providers/byok-provider.js +3 -1
  10. package/dist/agent/providers/local-provider.js +3 -1
  11. package/dist/agent/style-state.js +11 -0
  12. package/dist/agent/style.js +59 -0
  13. package/dist/agent/system-prompt.js +24 -3
  14. package/dist/agent/tool-dispatch.js +55 -0
  15. package/dist/cli.js +8 -0
  16. package/dist/commands/config-set.js +59 -1
  17. package/dist/commands/config-show.js +1 -0
  18. package/dist/commands/export-docx.js +66 -0
  19. package/dist/commands/help.js +28 -2
  20. package/dist/config/loader.js +51 -1
  21. package/dist/docx/doc-meta.js +47 -0
  22. package/dist/docx/markdown-to-docx.js +250 -0
  23. package/dist/docx/write-docx.js +54 -0
  24. package/dist/one-shot.js +10 -0
  25. package/dist/projects/document-save.js +62 -0
  26. package/dist/projects/document-skills.js +9 -0
  27. package/dist/renderer/footer-line.js +6 -1
  28. package/dist/renderer/goodbye.js +4 -1
  29. package/dist/renderer/startup-lines.js +6 -2
  30. package/dist/renderer/tool-labels.js +2 -1
  31. package/dist/renderer/tool-widget.js +5 -1
  32. package/dist/repl/builtin-commands.js +4 -1
  33. package/dist/repl/chat-command.js +64 -0
  34. package/dist/repl/chat-lane.js +135 -0
  35. package/dist/repl/level-command.js +79 -0
  36. package/dist/repl/style-command.js +67 -0
  37. package/dist/repl.js +186 -105
  38. package/dist/session/queued-step.js +5 -1
  39. package/dist/session/schema.js +1 -0
  40. package/dist/session/store.js +4 -0
  41. package/dist/tools/hand-off.js +86 -0
  42. package/dist/tools/tool-loading.js +3 -0
  43. package/dist/ui/sap-state-store.js +1 -0
  44. package/dist/ui/widgets/ask-card.js +2 -1
  45. package/package.json +2 -2
  46. 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
+ }