@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.
Files changed (45) 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/startup-lines.js +6 -2
  29. package/dist/renderer/tool-labels.js +2 -1
  30. package/dist/renderer/tool-widget.js +5 -1
  31. package/dist/repl/builtin-commands.js +4 -1
  32. package/dist/repl/chat-command.js +64 -0
  33. package/dist/repl/chat-lane.js +135 -0
  34. package/dist/repl/level-command.js +79 -0
  35. package/dist/repl/style-command.js +67 -0
  36. package/dist/repl.js +186 -105
  37. package/dist/session/queued-step.js +5 -1
  38. package/dist/session/schema.js +1 -0
  39. package/dist/session/store.js +4 -0
  40. package/dist/tools/hand-off.js +86 -0
  41. package/dist/tools/tool-loading.js +3 -0
  42. package/dist/ui/sap-state-store.js +1 -0
  43. package/dist/ui/widgets/ask-card.js +2 -1
  44. package/package.json +2 -2
  45. package/.cspeach/project.json +0 -4
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Chat lane (spec 2026-10-17 D3) — the `_chat` built-in prompt. OWNER COPY
3
+ * (spec §4.1): change it only through the owner. The PROXY holds the rendered
4
+ * text as a literal (cspeach-proxy/src/skills/chat-prompt.ts); this file
5
+ * renders the same text from SKILL_CATALOG for direct providers (BYOK, AI-hub,
6
+ * Azure, local). src/agent/__tests__/chat-prompt-pin.test.ts keeps them
7
+ * byte-equal — when a skill joins or leaves the catalog, paste the new
8
+ * CHAT_SYSTEM_PROMPT into the proxy literal.
9
+ *
10
+ * Imports only the pure catalog: tool-dispatch and the loop import this file.
11
+ */
12
+ import { SKILL_CATALOG } from '../skill-catalog.js';
13
+ /** The skill header a chat turn carries. Never session.skill, never a picker entry. */
14
+ export const CHAT_SKILL = '_chat';
15
+ export function isChatSkill(name) {
16
+ return name === CHAT_SKILL;
17
+ }
18
+ export const CHAT_PROMPT_TEMPLATE = `You are CSPeach, an AI coding agent for SAP development. In this conversation you are the developer's senior colleague: years of ABAP, S/4HANA, FI/CO, MM, SD, RAP, CDS and Fiori, and the person the team asks when something doesn't add up.
19
+
20
+ How you talk
21
+ - This is a chat, not a report. Answer what was asked, at the size it was asked: a quick question gets a short answer; "walk me through it" gets a walk-through.
22
+ - Lead with the answer or what you found. Then why. Then what you'd do next, if anything.
23
+ - Plain words, short sentences. Use headings, tables or lists only when they make this answer easier to read, never as a template.
24
+ - Have a view. If something is a bad idea, say so and say why. If you're not sure, say what you'd check.
25
+ - Don't restate the request, don't narrate at length what you're about to do, don't end with a menu of options unless there's a real choice to make. One clear recommendation beats five.
26
+ - No flattery. Apologise only when you got something wrong.
27
+
28
+ How you work
29
+ - Look before you answer. When the question is about this developer's system or project — a program, a class, a table, a dump, a transport, data, a file — read the real thing with your tools instead of guessing. Say in a few words what you're checking.
30
+ - Ground what you say in what you read: object names, line numbers, field names, counts, amounts. Never invent objects, fields, results or tool output. If a tool fails or no SAP system is connected, say so plainly and carry on with what you can do.
31
+ - If you have no tools in this session, answer from experience and say what you would check on the system.
32
+ - You can read freely, and you can write files in the developer's project folder when they ask (notes, a saved explanation, an export).
33
+ - You never change anything in SAP from this chat. Creating, changing, activating or deleting objects, and creating or releasing transports, run through CSPeach's guided workflows, which snapshot, plan, ask for approval, check syntax, activate and verify. When the conversation turns into a change, say briefly what you'd change and call hand_off with the right workflow and a clear request. Do the same when the developer asks for a complete deliverable a workflow produces (a full code review, a handover document, an upgrade scan). Don't ask "shall I hand off?" — the workflow shows its plan and asks before it writes.
34
+ - After a workflow finishes, you're back in this chat and can see what it did. Pick up from there.
35
+
36
+ Workflows you can hand off to
37
+ {{SKILL_CATALOG}}
38
+
39
+ Ground rules
40
+ - Follow the team's development standards when they are given in <development_standards>; they win over your own preferences.
41
+ - Use the SAP system and profile given in <session_context>; never assume a different system.
42
+ - Never ask for, print or store passwords or keys.`;
43
+ /** One line per skill: "/abap-x — purpose". Backticks are dropped (no meaning in a prompt; keeps the proxy literal escape-free). */
44
+ export function renderSkillCatalog(entries) {
45
+ return entries.map((e) => `/${e.name} — ${e.description.replace(/`/g, '').replace(/\s+/g, ' ').trim()}`).join('\n');
46
+ }
47
+ export const CHAT_SYSTEM_PROMPT = CHAT_PROMPT_TEMPLATE.replace('{{SKILL_CATALOG}}', renderSkillCatalog(SKILL_CATALOG));
@@ -0,0 +1,9 @@
1
+ /** D11 — the managed proxy answered 404 skill_not_found / 403 for `_chat` (an older server). The REPL re-runs the message through today's router. */
2
+ export class ChatUnavailableError extends Error {
3
+ status;
4
+ constructor(status) {
5
+ super(`chat lane unavailable on this server (HTTP ${status})`);
6
+ this.status = status;
7
+ this.name = 'ChatUnavailableError';
8
+ }
9
+ }
@@ -0,0 +1,11 @@
1
+ // Process-wide level (pattern: agent/style-state.ts). Set from
2
+ // cfg.assistant.level at startup (REPL + one-shot, wherever setAssistantStyle
3
+ // is called) and by /level; read by the loop when it builds the per-call headers.
4
+ import { DEFAULT_LEVEL } from './level.js';
5
+ let current = DEFAULT_LEVEL;
6
+ export function getAssistantLevel() {
7
+ return current;
8
+ }
9
+ export function setAssistantLevel(level) {
10
+ current = level;
11
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Level — who CSPeach is talking to (spec 2026-10-17 §10, D13–D16). Style is
3
+ * HOW it talks; level is WHO it talks to (how much it explains). OWNER COPY
4
+ * (spec §10.1): the two sentences and the D15 paragraph are product copy.
5
+ *
6
+ * Managed: the loop sends X-CSPeach-Level and the proxy appends the text
7
+ * after any style text. Direct providers append it locally. `developer` sends
8
+ * no header and no text, so every existing request stays byte-identical.
9
+ * The level never grants or removes a permission (D15): nothing here is read
10
+ * by dispatch or write_mode.
11
+ *
12
+ * The proxy holds the same constants (cspeach-proxy/src/skills/level-texts.ts);
13
+ * src/agent/__tests__/level-pin.test.ts keeps LEVEL_INVARIANT, LEVEL_SENTENCES
14
+ * and the marked composition byte-equal. Edit both files together.
15
+ */
16
+ export const ASSISTANT_LEVELS = ['developer', 'learning', 'functional'];
17
+ export const DEFAULT_LEVEL = 'developer';
18
+ /** Header name as the loop sends it (the proxy reads headers case-insensitively). */
19
+ export const LEVEL_HEADER = 'X-CSPeach-Level';
20
+ export function parseLevel(raw) {
21
+ if (typeof raw !== 'string')
22
+ return null;
23
+ const v = raw.trim().toLowerCase();
24
+ return ASSISTANT_LEVELS.includes(v) ? v : null;
25
+ }
26
+ export const LEVEL_INVARIANT = `The level changes how much you explain, never what you found: findings, numbers, object names, warnings, risks, approvals, plan and safety prompts stay exact. It does not change what this person is allowed to do in SAP.`;
27
+ export const LEVEL_SENTENCES = Object.freeze({
28
+ learning: 'You\'re talking to a developer who is still building experience. Explain the why, not just the what. The first time a technical term comes up, say in a few words what it means. When you show a fix, point out the pattern behind it so they can use it again. Stay encouraging and concrete; never talk down. Just before you ask them to approve anything or answer a yes/no question, say in one or two plain sentences what will happen, what could go wrong and how it can be undone, then ask. Keep it short; don\'t lecture.',
29
+ functional: 'You\'re talking to someone who knows SAP and the business process well but is not a developer. Start with what it means for the business: which documents, amounts, users or reports are affected. Avoid code and technical terms unless they are needed; when one is needed, explain it in plain words. Show code only when they ask for it. When a code change is needed, don\'t hand it to a workflow: write a developer request to the project folder as cspeach-out/developer-request-<object-or-topic>.md (if that name is taken, add -2, -3 and so on) with the sections Summary, What we found, Proposed change, How to test and Open questions, written so a developer can act on it without asking anything; then tell them where it is and that a developer can open it in CSPeach and continue from it. Hand off to a workflow only if they clearly ask to make the change themselves. Just before you ask them to approve anything or answer a yes/no question, say in one or two plain sentences what will happen, what could go wrong and how it can be undone, then ask. Keep it short; don\'t lecture.',
30
+ });
31
+ // ---- pinned: level text composition ----
32
+ /** The text appended under a "Who you're talking to" heading; null for developer (today's behaviour). */
33
+ export function levelText(level) {
34
+ if (level === 'developer')
35
+ return null;
36
+ return `Who you're talking to\n${LEVEL_SENTENCES[level]}\n\n${LEVEL_INVARIANT}`;
37
+ }
38
+ /** Appends the level text to the END of the last block, after any style text already there (no new block, so no extra cache breakpoint). Never mutates the input. */
39
+ export function appendLevelText(blocks, level) {
40
+ const text = levelText(level);
41
+ if (text === null || blocks.length === 0)
42
+ return [...blocks];
43
+ const out = [...blocks];
44
+ const last = out[out.length - 1];
45
+ out[out.length - 1] = { ...last, text: `${last.text}\n\n${text}` };
46
+ return out;
47
+ }
48
+ // ---- end pinned ----
49
+ /** The header to add to a model call: nothing for developer (§6.5 extended). */
50
+ export function levelHeader(level) {
51
+ return level === 'developer' ? {} : { [LEVEL_HEADER]: level };
52
+ }
53
+ /** A direct provider reads the level the loop put in the per-call headers. Absent or unknown ⇒ developer. */
54
+ export function levelFromHeaders(headers) {
55
+ for (const [k, v] of Object.entries(headers ?? {})) {
56
+ if (k.toLowerCase() === LEVEL_HEADER.toLowerCase())
57
+ return parseLevel(v) ?? DEFAULT_LEVEL;
58
+ }
59
+ return DEFAULT_LEVEL;
60
+ }
@@ -43,6 +43,7 @@ import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
43
43
  import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
44
44
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
45
45
  import { planLaneBlockForTurn } from '../projects/plan-lane.js';
46
+ import { DOCUMENT_SKILLS } from '../projects/document-skills.js';
46
47
  import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
47
48
  import { getLatestSkeletonDecision } from '../rap/skeleton-session.js';
48
49
  import { afterRebuildTurn } from '../journey/rebuild.js';
@@ -64,7 +65,7 @@ import { renderStandardsBlock } from '../standards/standards-file.js';
64
65
  import { composeSessionContextBlock } from './session-context.js';
65
66
  import { objectKeyFromInput } from './retry-key.js';
66
67
  import { repairPartialBlocks, stripPreFallbackBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError, stripUnsignedThinking, isThinkingOnly, dropTrailingThinkingOnly } from './repair-partial.js';
67
- import { drainSteering } from './steering-queue.js';
68
+ import { drainSteering, enqueueSteering } from './steering-queue.js';
68
69
  import { TurnStreamWriter } from './turn-stream.js';
69
70
  import { applyToolResultCheckpoint } from './skill-checkpoint.js';
70
71
  import { loadWatchdogConfig, evaluateWatchdog } from './turn-watchdog.js';
@@ -79,6 +80,12 @@ import { maybeColdPrune } from './cold-prune.js';
79
80
  import { createAdtLock, serializeAdt } from '../tools/subagent/adt-serial.js';
80
81
  import { hasReaderReadTools, INTERRUPTED_LINE } from '../tools/subagent/reader-prompt.js';
81
82
  import { openStatusPage } from '../util/open-in-browser.js';
83
+ import { isChatSkill } from './chat-prompt.js';
84
+ import { getAssistantStyle } from './style-state.js';
85
+ import { styleHeader } from './style.js';
86
+ import { getAssistantLevel } from './level-state.js';
87
+ import { levelHeader } from './level.js';
88
+ import { ChatUnavailableError } from './chat-unavailable.js';
82
89
  /**
83
90
  * Author identity for project-file metadata. Reads CSPEACH_AUTHOR_NAME first,
84
91
  * then platform USER/USERNAME, then a generic fallback. Role is fixed to
@@ -368,6 +375,33 @@ export async function inkAwareChoose(a) {
368
375
  export async function maybeOfferSave(p) {
369
376
  if (p.assistantText.trim().length === 0)
370
377
  return;
378
+ // Word export (2026-10-19): reviews and handovers are prose documents, not
379
+ // envelopes. They get a Markdown save under cspeach-out and the Word
380
+ // question (projects/document-save.ts). Nothing below this branch changes
381
+ // for any other skill.
382
+ // The skill check reads a tiny constant module; document-save.ts (and the
383
+ // docx package) loads only for these two skills, inside the catch, so a
384
+ // broken docx install costs one line on a review save and nothing else.
385
+ if (DOCUMENT_SKILLS[p.skill]) {
386
+ if (p.subagent)
387
+ return;
388
+ const d = new Date();
389
+ const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
390
+ try {
391
+ const { offerDocumentSave } = await import('../projects/document-save.js');
392
+ const { isHeadless } = await import('../renderer/tty.js');
393
+ const { loadConfig, resolveWordCopy } = await import('../config/loader.js');
394
+ await offerDocumentSave({
395
+ skill: p.skill, markdown: p.assistantText, cwd: process.cwd(), sapSystem: p.sapSystem ?? null,
396
+ today, headless: isHeadless(), wordCopy: resolveWordCopy(await loadConfig()),
397
+ confirm: inkAwareConfirm, emit: p.emit,
398
+ });
399
+ }
400
+ catch (e) {
401
+ p.emit(formatInfo(`Not saved: ${e.message}`).trimEnd());
402
+ }
403
+ return;
404
+ }
371
405
  // C1 (2026-06-11, defect D22) — the hook fires on artifact EVIDENCE, not
372
406
  // only on the routed-skill label. The seed-session plan save was lost
373
407
  // because the turn was misrouted (label 'abap-rap', not in the set) while
@@ -901,6 +935,19 @@ ${imageExpansion.error}`));
901
935
  const finalUserContent = projectContextBlock
902
936
  ? `${projectContextBlock}\n\n${enriched}`
903
937
  : enriched;
938
+ // Chat lane (D11) — what a withdrawn `_chat` turn restores: the whole
939
+ // pre-turn message array (placeSystemMessagesInPlace may re-sort the tail
940
+ // after the last assistant message, so a length-based splice is not
941
+ // enough), the workflow state it would otherwise clear, the turn counter
942
+ // and any steering drained in round 0.
943
+ const preTurnMessages = session.messages.slice();
944
+ const preTurnState = {
945
+ skill: session.skill,
946
+ lane: session.lane,
947
+ tool_set_skill: session.tool_set_skill,
948
+ turn_seq: session.turn_seq,
949
+ };
950
+ let round0Steers = [];
904
951
  // Images (if any) ride as blocks ahead of the text; with none this is the
905
952
  // same plain string as before.
906
953
  session.messages.push({ role: 'user', content: buildUserContent(finalUserContent, imageExpansion.images) });
@@ -916,7 +963,13 @@ ${imageExpansion.error}`));
916
963
  if (droppedImages > 0) {
917
964
  emit(chalk.dim(`(${droppedImages} older image(s) removed from the conversation to keep it under the size limit)`));
918
965
  }
919
- session.skill = params.skill;
966
+ // Chat lane (D7, D-B) — `_chat` is the wire name of a chat turn, never the
967
+ // session's skill: a chat turn CLEARS the active workflow so a later bare
968
+ // "y" goes to chat (D8). `lane` is what dispatch gates on (D4); a subagent
969
+ // or reader child keeps the lane it was spawned with (D-C).
970
+ session.skill = isChatSkill(params.skill) ? null : params.skill;
971
+ if (!params.ctx.subagent && !params.ctx.readerMode)
972
+ session.lane = isChatSkill(params.skill) ? 'chat' : 'skill';
920
973
  // Task 16 (spec D11) — deferred tool loading. The tools array is built ONE
921
974
  // way for the whole session: the core set plus the starters of the skill
922
975
  // pinned on `session.tool_set_skill` are loaded, every other tool is
@@ -1106,12 +1159,51 @@ ${imageExpansion.error}`));
1106
1159
  if (steers.length > 0) {
1107
1160
  const text = steers.join('\n');
1108
1161
  session.messages.push({ role: 'user', content: text });
1162
+ if (roundIndex === 0)
1163
+ round0Steers = steers;
1109
1164
  emit(chalk.cyan(`↳ steering: ${steers.join(' / ')}`));
1110
1165
  }
1111
1166
  }
1112
1167
  let buffer = initialBufferState();
1113
1168
  let machineBlocks = initialMachineBlockState();
1114
1169
  let writingCode = false; // piece 2 §11.2 — "writing code…" while a fence is held back
1170
+ // D11 — a proxy without the chat lane answers 404 skill_not_found (or 403)
1171
+ // for `_chat`, either from createStream or (managed SDK) on the first
1172
+ // stream read. Only on the turn's first call, before anything streamed.
1173
+ const isChatUnavailable = (e) => {
1174
+ if (!isChatSkill(params.skill))
1175
+ return false;
1176
+ const st = e?.status ?? e?.response?.status;
1177
+ const raw = e?.error ?? e?.response?.data ?? e?.body;
1178
+ let b = raw;
1179
+ if (typeof raw === 'string') {
1180
+ try {
1181
+ b = JSON.parse(raw);
1182
+ }
1183
+ catch {
1184
+ b = undefined;
1185
+ }
1186
+ }
1187
+ return (st === 404 && b?.error === 'skill_not_found') || (st === 403 && b?.error === 'skill_not_in_entitlements');
1188
+ };
1189
+ // Withdraw this turn (its messages and the workflow state it cleared) so
1190
+ // the REPL re-runs the same text through today's router. Nothing of the
1191
+ // withdrawn turn is persisted or printed (no interrupted flag, no cost line).
1192
+ const withdrawChatTurn = (st) => {
1193
+ session.messages.splice(0, session.messages.length, ...preTurnMessages);
1194
+ session.skill = preTurnState.skill;
1195
+ session.lane = preTurnState.lane;
1196
+ session.tool_set_skill = preTurnState.tool_set_skill;
1197
+ session.turn_seq = preTurnState.turn_seq;
1198
+ // Steering drained in round 0 goes back to the queue, ahead of any
1199
+ // typed since, so the re-run turn still sees it.
1200
+ if (round0Steers.length > 0) {
1201
+ const later = drainSteering();
1202
+ for (const t of [...round0Steers, ...later])
1203
+ enqueueSteering(t);
1204
+ }
1205
+ throw new ChatUnavailableError(st);
1206
+ };
1115
1207
  let stream;
1116
1208
  // 2026-05-06: paint a "thinking" spinner during the LLM round-trip
1117
1209
  // wait so the user sees activity instead of staring at a silent
@@ -1263,6 +1355,10 @@ ${imageExpansion.error}`));
1263
1355
  'X-CSForge-Skill': params.skill,
1264
1356
  'X-CSPeach-Model-Role': params.modelRole ?? 'session_default',
1265
1357
  ...(params.delegateSkill ? { 'X-CSPeach-Delegate-Skill': params.delegateSkill } : {}),
1358
+ // D9 — professional sends nothing (byte-identical requests).
1359
+ ...styleHeader(getAssistantStyle()),
1360
+ // §10 D14 — developer sends nothing (byte-identical requests).
1361
+ ...levelHeader(getAssistantLevel()),
1266
1362
  };
1267
1363
  sentParams = streamParams;
1268
1364
  sentHeaders = headers;
@@ -1316,6 +1412,12 @@ ${imageExpansion.error}`));
1316
1412
  const status = err?.status ?? err?.response?.status;
1317
1413
  const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
1318
1414
  const body = typeof bodyRaw === 'string' ? JSON.parse(bodyRaw) : bodyRaw;
1415
+ // D11 — a proxy without the chat lane answers 404 skill_not_found (or
1416
+ // 403) for `_chat`. Withdraw this turn (its messages and the workflow
1417
+ // state it cleared) and let the REPL re-run the same text through
1418
+ // today's router. First call of the turn only.
1419
+ if (roundIndex === 0 && isChatUnavailable(err))
1420
+ withdrawChatTurn(status);
1319
1421
  if (status === 409 && body?.error === 'upgrade_required') {
1320
1422
  emit('\n' + formatFailure({
1321
1423
  what: 'This skill needs a newer CSPeach',
@@ -1552,6 +1654,10 @@ ${imageExpansion.error}`));
1552
1654
  interruptedError = err;
1553
1655
  thinkingSpinner.stop();
1554
1656
  thinkingHeartbeat.stop();
1657
+ // D11 on the first stream read: withdraw before the persist block runs.
1658
+ if (callIndex === 0 && currentAssistantContent.length === 0 && isChatUnavailable(err)) {
1659
+ withdrawChatTurn((err?.status ?? err?.response?.status));
1660
+ }
1555
1661
  }
1556
1662
  // Final flush: emit any pending partial line after the stream ends.
1557
1663
  // Runs on both clean completion and mid-stream interruption.
@@ -1854,6 +1960,8 @@ ${imageExpansion.error}`));
1854
1960
  // they are the only modelOverride caller — kept honest anyway).
1855
1961
  model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
1856
1962
  emit,
1963
+ sapSystem: session.sap_system,
1964
+ ...(params.ctx.subagent ? { subagent: true } : {}),
1857
1965
  promotedFrom: promotedFromForSave,
1858
1966
  ...(promotedFromPathForSave ? { promotedFromPath: promotedFromPathForSave } : {}),
1859
1967
  ...(() => {
@@ -1,5 +1,7 @@
1
1
  import { TOOL_CHANGES_BETA, isToolSearchEntry } from '../tools/tool-loading.js';
2
2
  import { DELEGATE_SKILL_HEADER } from './system-prompt.js';
3
+ import { STYLE_HEADER } from './style.js';
4
+ import { LEVEL_HEADER } from './level.js';
3
5
  /** Betas the proxy sends on every managed turn (messages.ts `betas: [...]`). */
4
6
  export const BASE_BETAS = ['compact-2026-01-12'];
5
7
  /** Task 6 — beta that enables `fallbacks: 'default'` (server-side fallback). */
@@ -267,13 +269,15 @@ export function withoutInternalHeaders(headers) {
267
269
  }
268
270
  /**
269
271
  * Task 9 — the role header is new proxy plumbing; AI-hub keeps today's header
270
- * set, so X-CSPeach-Model-Role (and, 2026-10-04, the delegate header) are removed.
272
+ * set, so X-CSPeach-Model-Role (and, 2026-10-04, the delegate header; and,
273
+ * 2026-10-17, the talking-style and level headers — applied locally) are
274
+ * removed.
271
275
  */
272
276
  export function withoutRoleHeader(headers) {
273
277
  const out = {};
274
278
  for (const [k, v] of Object.entries(headers ?? {})) {
275
279
  const lk = k.toLowerCase();
276
- if (lk === 'x-cspeach-model-role' || lk === DELEGATE_SKILL_HEADER.toLowerCase())
280
+ if (lk === 'x-cspeach-model-role' || lk === DELEGATE_SKILL_HEADER.toLowerCase() || lk === STYLE_HEADER.toLowerCase() || lk === LEVEL_HEADER.toLowerCase())
277
281
  continue;
278
282
  out[k] = v;
279
283
  }
@@ -3,6 +3,8 @@ import keychain from '../../auth/keychain.js';
3
3
  import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
4
4
  import { delegateSkillName, requireSkillName, toLegacyShape, withoutRoleHeader } from '../provider-shape.js';
5
5
  import { systemPromptForSkill } from '../system-prompt.js';
6
+ import { styleFromHeaders } from '../style.js';
7
+ import { levelFromHeaders } from '../level.js';
6
8
  import { AI_HUB_PROFILE } from '../connection-profile.js';
7
9
  const KEYCHAIN_SERVICE = 'cspeach.ai-hub';
8
10
  const KEYCHAIN_ACCOUNT = 'token';
@@ -26,7 +28,7 @@ export function createAiHubProvider(cfg, getBearer = defaultBearer) {
26
28
  const skillName = requireSkillName(options, 'AI Hub');
27
29
  const token = await getToken();
28
30
  // Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
29
- const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: AI_HUB_PROFILE.cacheMarkers }, delegateSkillName(options));
31
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: AI_HUB_PROFILE.cacheMarkers }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
30
32
  const request = {
31
33
  ...toLegacyShape(params),
32
34
  system,
@@ -17,6 +17,8 @@ import { AZURE_PROFILE } from '../azure-profile.js';
17
17
  import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
18
18
  import { delegateSkillName, headersForProfile, requireSkillName, withoutToolChanges, withSkillPrompt } from '../provider-shape.js';
19
19
  import { systemPromptForSkill } from '../system-prompt.js';
20
+ import { styleFromHeaders } from '../style.js';
21
+ import { levelFromHeaders } from '../level.js';
20
22
  import { familyAcceptsEffort, familyFromTable } from '../../models/model-identity.js';
21
23
  import { getActiveConnection } from '../../connection/cache.js';
22
24
  import { azureTurnVerdict, AzureConnectionRefusedError } from '../../connection/state.js';
@@ -64,7 +66,7 @@ export function createAzureProvider(cfg, getBearer = defaultBearer, deps = {}) {
64
66
  throw new AzureConnectionRefusedError(v.message);
65
67
  const conn = cached.connection;
66
68
  const skillName = requireSkillName(options, 'Azure');
67
- const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: profile.cacheMarkers }, delegateSkillName(options));
69
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: profile.cacheMarkers }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
68
70
  const base = profile.toolChanges ? params : withoutToolChanges(params);
69
71
  const family = familyFromTable(params.model, conn.deployments);
70
72
  const body = withAzureEffort(shapeForAzure(withSkillPrompt(base, system, { cacheMarkers: profile.cacheMarkers }), profile), profile, roleOf(options.headers), family);
@@ -3,6 +3,8 @@ import keychain from '../../auth/keychain.js';
3
3
  import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
4
4
  import { delegateSkillName, modelAcceptsFallbacks, requiredBetas, requireSkillName, withoutInternalHeaders, withSkillPrompt } from '../provider-shape.js';
5
5
  import { systemPromptForSkill } from '../system-prompt.js';
6
+ import { styleFromHeaders } from '../style.js';
7
+ import { levelFromHeaders } from '../level.js';
6
8
  import { BYOK_PROFILE } from '../connection-profile.js';
7
9
  const KEYCHAIN_SERVICE = 'cspeach.anthropic';
8
10
  const KEYCHAIN_ACCOUNT = 'api-key';
@@ -19,7 +21,7 @@ export function createByokProvider(cfg, getBearer = defaultBearer) {
19
21
  const skillName = requireSkillName(options, 'BYOK');
20
22
  const key = await getKey();
21
23
  // Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
22
- const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: BYOK_PROFILE.cacheMarkers }, delegateSkillName(options));
24
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: BYOK_PROFILE.cacheMarkers }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
23
25
  // Task 6 — opt into server-side fallback, only for the families probe
24
26
  // P3 proved (allow-list); requiredBetas adds its beta.
25
27
  const prompted = withSkillPrompt(params, system, { cacheMarkers: BYOK_PROFILE.cacheMarkers });
@@ -4,6 +4,8 @@ import crypto from 'node:crypto';
4
4
  import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
5
5
  import { delegateSkillName, requireSkillName } from '../provider-shape.js';
6
6
  import { systemPromptForSkill } from '../system-prompt.js';
7
+ import { styleFromHeaders } from '../style.js';
8
+ import { levelFromHeaders } from '../level.js';
7
9
  import { LOCAL_PROFILE } from '../connection-profile.js';
8
10
  export function createLocalProvider(cfg, getBearer = defaultBearer) {
9
11
  if (!cfg.llm.local_base_url) {
@@ -27,7 +29,7 @@ export function createLocalProvider(cfg, getBearer = defaultBearer) {
27
29
  // message (no cache markers). anthropicParamsToOpenAI copies only
28
30
  // max_tokens / messages / system, so newer fields never reach the endpoint.
29
31
  const skillName = requireSkillName(options, 'Local');
30
- const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false }, delegateSkillName(options));
32
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false }, delegateSkillName(options), styleFromHeaders(options.headers), levelFromHeaders(options.headers));
31
33
  const OpenAI = await loadOpenAI();
32
34
  const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
33
35
  const openAiParams = anthropicParamsToOpenAI({ ...params, system }, model);
@@ -0,0 +1,11 @@
1
+ // Process-wide talking style (pattern: renderer/look.ts). Set from
2
+ // cfg.assistant.style at startup (REPL + one-shot) and by /style; read by the
3
+ // loop when it builds the per-call headers.
4
+ import { DEFAULT_STYLE } from './style.js';
5
+ let current = DEFAULT_STYLE;
6
+ export function getAssistantStyle() {
7
+ return current;
8
+ }
9
+ export function setAssistantStyle(style) {
10
+ current = style;
11
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Talking style (spec 2026-10-17 D9/D10). Words change; findings, numbers,
3
+ * warnings and safety prompts never do. OWNER COPY (spec §4.2): the sentences
4
+ * and the D10 paragraph are product copy.
5
+ *
6
+ * Managed: the loop sends X-CSPeach-Style and the proxy appends the text.
7
+ * Direct providers append it locally. `professional` sends no header and no
8
+ * text, so every existing request stays byte-identical.
9
+ *
10
+ * The proxy holds the same constants (cspeach-proxy/src/skills/style-texts.ts);
11
+ * src/agent/__tests__/style-pin.test.ts keeps STYLE_INVARIANT, STYLE_SENTENCES
12
+ * and the marked composition byte-equal. Edit both files together.
13
+ */
14
+ export const ASSISTANT_STYLES = ['professional', 'conversational', 'humor', 'blunt'];
15
+ export const DEFAULT_STYLE = 'professional';
16
+ /** Header name as the loop sends it (the proxy reads headers case-insensitively). */
17
+ export const STYLE_HEADER = 'X-CSPeach-Style';
18
+ export function parseStyle(raw) {
19
+ if (typeof raw !== 'string')
20
+ return null;
21
+ const v = raw.trim().toLowerCase();
22
+ return ASSISTANT_STYLES.includes(v) ? v : null;
23
+ }
24
+ export const STYLE_INVARIANT = `Style changes your wording only. It never changes findings, numbers, object names, warnings, risks, approvals, plan or safety prompts, or error messages — keep those exact and plain. No jokes or attitude around errors, approvals, writes or data loss. Anything saved to a file for other people is written professionally. No profanity, no remarks about people.`;
25
+ export const STYLE_SENTENCES = Object.freeze({
26
+ conversational: 'Talk like a teammate in a chat: relaxed, warm, first names fine, contractions fine. Still brief and specific.',
27
+ humor: 'A light, dry sense of humour is welcome — at most one small aside when it fits naturally. Never forced, never at anyone\'s expense.',
28
+ blunt: 'Be blunt. No softening, no padding: say what\'s wrong and what to do, in as few words as it takes. Critique the code, never the person.',
29
+ });
30
+ // ---- pinned: style text composition ----
31
+ /** The text appended under a "Talking style" heading; null for professional (today's voice). */
32
+ export function styleText(style) {
33
+ if (style === 'professional')
34
+ return null;
35
+ return `Talking style\n${STYLE_SENTENCES[style]}\n\n${STYLE_INVARIANT}`;
36
+ }
37
+ /** Appends the style text to the END of the last block (no new block, so no extra cache breakpoint). Never mutates the input. */
38
+ export function appendStyleText(blocks, style) {
39
+ const text = styleText(style);
40
+ if (text === null || blocks.length === 0)
41
+ return [...blocks];
42
+ const out = [...blocks];
43
+ const last = out[out.length - 1];
44
+ out[out.length - 1] = { ...last, text: `${last.text}\n\n${text}` };
45
+ return out;
46
+ }
47
+ // ---- end pinned ----
48
+ /** The header to add to a model call: nothing for professional (§6.5). */
49
+ export function styleHeader(style) {
50
+ return style === 'professional' ? {} : { [STYLE_HEADER]: style };
51
+ }
52
+ /** A direct provider reads the style the loop put in the per-call headers. Absent or unknown ⇒ professional. */
53
+ export function styleFromHeaders(headers) {
54
+ for (const [k, v] of Object.entries(headers ?? {})) {
55
+ if (k.toLowerCase() === STYLE_HEADER.toLowerCase())
56
+ return parseStyle(v) ?? DEFAULT_STYLE;
57
+ }
58
+ return DEFAULT_STYLE;
59
+ }
@@ -9,6 +9,9 @@
9
9
  */
10
10
  import { FORGE_PRINCIPLES_PREAMBLE } from '../skills/preamble.js';
11
11
  import { READER_SKILL, READER_SYSTEM_PROMPT } from '../tools/subagent/reader-prompt.js';
12
+ import { CHAT_SKILL, CHAT_SYSTEM_PROMPT } from './chat-prompt.js';
13
+ import { appendStyleText, DEFAULT_STYLE } from './style.js';
14
+ import { appendLevelText, DEFAULT_LEVEL } from './level.js';
12
15
  /** 2026-10-04 (continuous run) — the phase's delegate skill travels as this header. */
13
16
  export const DELEGATE_SKILL_HEADER = 'X-CSPeach-Delegate-Skill';
14
17
  /** The line that tells the model how the two bodies relate. Same text in cspeach-proxy. */
@@ -40,16 +43,34 @@ export function buildReaderSystemPrompt(opts) {
40
43
  const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
41
44
  return [{ type: 'text', text: READER_SYSTEM_PROMPT, ...marker }];
42
45
  }
46
+ /**
47
+ * Chat lane (spec 2026-10-17 D3) — one block, the pinned chat prompt, as the
48
+ * proxy sends it for the `_chat` skill header. The coding-workflow preamble is
49
+ * NOT sent on chat turns. A non-professional style is appended to that block;
50
+ * a non-developer level (§10 D14) follows the style text.
51
+ */
52
+ export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
53
+ const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
54
+ // D-K order: style, then level, one block.
55
+ return appendLevelText(appendStyleText([{ type: 'text', text: CHAT_SYSTEM_PROMPT, ...marker }], style), level);
56
+ }
43
57
  /**
44
58
  * The system prompt for the skill named in the X-CSForge-Skill header, plus —
45
59
  * on a plan phase turn — the delegate playbook named in X-CSPeach-Delegate-Skill.
46
60
  * A delegate that cannot be fetched degrades to the plain prompt (never throws);
47
61
  * a delegate equal to the skill itself is ignored. The internal `_reader` skill
48
- * has no SKILL.md, so it never reaches the skill source (a lookup would fail).
62
+ * has no SKILL.md, so it never reaches the skill source (a lookup would fail);
63
+ * nor does the built-in `_chat` (D3). A non-professional `style` (D9) is
64
+ * appended to the END of the last block — professional changes nothing, and a
65
+ * reader (which reports facts) never gets style. A non-developer `level`
66
+ * (§10 D14) follows the style text; developer changes nothing; a reader never
67
+ * gets it.
49
68
  */
50
- export async function systemPromptForSkill(skillName, skillSource, opts, delegateName) {
69
+ export async function systemPromptForSkill(skillName, skillSource, opts, delegateName, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
51
70
  if (skillName === READER_SKILL)
52
71
  return buildReaderSystemPrompt(opts);
72
+ if (skillName === CHAT_SKILL)
73
+ return buildChatSystemPrompt(opts, style, level);
53
74
  const skill = await skillSource.getSkillBody(skillName);
54
75
  let delegate;
55
76
  if (delegateName && delegateName !== skillName) {
@@ -61,5 +82,5 @@ export async function systemPromptForSkill(skillName, skillSource, opts, delegat
61
82
  delegate = undefined;
62
83
  }
63
84
  }
64
- return buildSystemPrompt(skill.body, opts, delegate);
85
+ return appendLevelText(appendStyleText(buildSystemPrompt(skill.body, opts, delegate), style), level);
65
86
  }
@@ -11,7 +11,47 @@ import { READER_TOOLS } from '../tools/subagent/reader-prompt.js';
11
11
  import { riskOf } from './risk-gate.js';
12
12
  import { NOT_ON_THIS_RELEASE, gateWithVerify } from '../sap/profile-gate.js';
13
13
  import { INTERRUPTED_TOOL_RESULT } from './repair-partial.js';
14
+ import { WRITE_TOOLS } from '../tools/write-mode.js';
14
15
  const READER_TOOL_SET = new Set(READER_TOOLS);
16
+ /** Chat lane (spec 2026-10-17 D4) — the model-facing reason; names hand_off. */
17
+ export const CHAT_READ_ONLY_REASON = 'This chat reads but never changes SAP. To make this change, call hand_off with the right workflow (for example abap-refactor, abap-generate, abap-transport) and a clear request — the workflow shows its plan and asks before it writes.';
18
+ /** sap_run_unit_test in chat — unit test code can COMMIT; same footing as sap_run. */
19
+ export const CHAT_UNIT_TEST_REASON = 'This chat reads but never runs code on SAP — unit tests can change data. To run them, call hand_off with skill abap-test and a clear request.';
20
+ /** dispatch_skill in chat — hand_off is the chat's one switch (spec D5). */
21
+ export const CHAT_DISPATCH_SKILL_REASON = 'In this chat, dispatch_skill is not available. To start a guided workflow, call hand_off with the workflow name and a clear request.';
22
+ /** A call carrying an approval_id in chat — approvals belong to workflows, never to chat. */
23
+ export const CHAT_APPROVAL_ID_REASON = 'This chat never uses an approval. To make this change, call hand_off with the right workflow and a clear request — the workflow asks before it writes.';
24
+ /**
25
+ * Tools refused in chat beyond the generic rule: the two smokes write SAP
26
+ * data over OData although requiresSap is false; sap_run_unit_test runs code
27
+ * that can COMMIT; dispatch_skill could queue any slash command (hand_off is
28
+ * chat's switch, D5).
29
+ */
30
+ const CHAT_EXTRA_REFUSED = new Map([
31
+ ['fiori_draft_smoke', CHAT_READ_ONLY_REASON],
32
+ ['cap_link_smoke', CHAT_READ_ONLY_REASON],
33
+ ['sap_run_unit_test', CHAT_UNIT_TEST_REASON],
34
+ ['dispatch_skill', CHAT_DISPATCH_SKILL_REASON],
35
+ ]);
36
+ /**
37
+ * Chat lane refusal set (D-F): WRITE_TOOLS ∪ {request_approval} ∪ every tool
38
+ * that is both mutating and SAP-bound (sap_transport_create, sap_lock, … that
39
+ * WRITE_TOOLS leaves out; this also catches fiori_preview, sap_link,
40
+ * cap_metadata_import — safe side) ∪ CHAT_EXTRA_REFUSED. hand_off / reads /
41
+ * local files: no.
42
+ */
43
+ export function isChatRefusedTool(tool) {
44
+ return WRITE_TOOLS.has(tool.name) || tool.name === 'request_approval' || CHAT_EXTRA_REFUSED.has(tool.name)
45
+ || (tool.isMutating === true && tool.requiresSap === true);
46
+ }
47
+ /** The model-facing chat refusal for this call, or null when chat allows it. */
48
+ export function chatRefusal(tool, args) {
49
+ if (isChatRefusedTool(tool))
50
+ return CHAT_EXTRA_REFUSED.get(tool.name) ?? CHAT_READ_ONLY_REASON;
51
+ if (args && typeof args === 'object' && Object.prototype.hasOwnProperty.call(args, 'approval_id'))
52
+ return CHAT_APPROVAL_ID_REASON;
53
+ return null;
54
+ }
15
55
  export async function dispatchTool(name, args, ctx) {
16
56
  const tool = getTool(name);
17
57
  if (!tool) {
@@ -23,6 +63,21 @@ export async function dispatchTool(name, args, ctx) {
23
63
  if (ctx.readerMode && !READER_TOOL_SET.has(name)) {
24
64
  return { content: `error: read-only reader — ${name} is not allowed here`, is_error: true };
25
65
  }
66
+ // Chat lane (spec 2026-10-17 D4, §6.3) — chat reads freely and never changes
67
+ // SAP. The prompt says so; THIS is the guarantee: every SAP-mutating tool and
68
+ // request_approval are refused while the session's last turn ran in chat,
69
+ // with a reason that points the model at hand_off. It sits before the
70
+ // standalone, profile, credential, risk and Rule 8 gates, so no card is ever
71
+ // shown for a call chat cannot make. An agent_run child spreads the parent
72
+ // session (lane included) and is refused the same way. Reads, project files
73
+ // and the shell keep their existing gates below. Belt and braces: any call
74
+ // carrying an approval_id is refused too, so an approval minted in an
75
+ // earlier skill turn (reusable 30 min) can never be replayed from chat.
76
+ if (ctx.session?.lane === 'chat') {
77
+ const refusal = chatRefusal(tool, args);
78
+ if (refusal)
79
+ return { content: JSON.stringify({ error: 'chat_read_only', tool: name, message: refusal }), is_error: true };
80
+ }
26
81
  // Standalone safety net (2026-09-16). SAP-dependent tools are already
27
82
  // filtered out of the list the model sees (agent/loop.ts), but a model can
28
83
  // still call a tool it remembers from an earlier turn or from training. Fail