@cspeach/cli 1.1.49 → 1.1.50

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/cold-prune.js +1 -1
  2. package/dist/agent/conversation-prompt.js +52 -0
  3. package/dist/agent/loop.js +83 -2
  4. package/dist/agent/oversized-results.js +204 -0
  5. package/dist/agent/turn-error-ux.js +14 -0
  6. package/dist/commands/plan-continue-gate.js +11 -0
  7. package/dist/cost/cost-log.js +4 -0
  8. package/dist/cost/system-block.js +25 -0
  9. package/dist/models/server-config.js +13 -0
  10. package/dist/projects/workspace.js +6 -3
  11. package/dist/repl.js +16 -6
  12. package/dist/session/fresh-start.js +15 -0
  13. package/dist/tools/_filesystem-shared.js +7 -0
  14. package/dist/tools/cut-markers.js +47 -0
  15. package/dist/tools/filesystem/extract-document.js +50 -2
  16. package/dist/tools/filesystem/file-read.js +2 -2
  17. package/dist/tools/filesystem/read-document.js +20 -4
  18. package/dist/tools/tool-output-read.js +5 -1
  19. package/dist/tools/write-pipeline.js +5 -0
  20. package/package.json +121 -121
  21. package/dist/commands/project.js +0 -301
  22. package/dist/doctor/checks/zcspeach.js +0 -96
  23. package/dist/projects/email-template.js +0 -56
  24. package/dist/projects/merge-cca.js +0 -292
  25. package/dist/tools/local-files.js +0 -31
  26. package/dist/tools/project/cca_merge.js +0 -164
  27. package/dist/tools/subagent/schedule_create.js +0 -131
  28. package/dist/ui/error-emitter.js +0 -21
  29. package/dist/ui/header.js +0 -41
  30. package/dist/ui/ink-classifier-route.js +0 -19
  31. package/dist/ui/rich-error-box.js +0 -9
  32. package/dist/ui/sidebar.js +0 -12
  33. package/dist/ui/status-emitter.js +0 -19
  34. package/dist/ui/status-line.js +0 -62
  35. package/dist/ui/status-row.js +0 -38
  36. package/dist/ui/todo-panel.js +0 -85
  37. package/dist/ui/widgets/ask-form.js +0 -250
  38. package/dist/ui/widgets/ask-question-modal.js +0 -178
  39. package/fiori/zlas-inspection-lr/package.json +0 -26
  40. package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
  41. package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
  42. package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
  43. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
  44. package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
  45. package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
  46. package/live-check-2026-10-17-1343.txt +0 -41
@@ -35,7 +35,7 @@ function elidedMarker(chars) {
35
35
  return `[result elided at cold start — ${chars} chars]`;
36
36
  }
37
37
  /** Characters a tool_result's content carries (text, else its JSON). */
38
- function contentChars(content) {
38
+ export function contentChars(content) {
39
39
  if (typeof content === 'string')
40
40
  return content.length;
41
41
  if (!Array.isArray(content))
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Conversation architecture (review 2026-10-21 §5 stage 2 item 1) — the one
3
+ * system prompt for a session in assistant.skills = inline mode, where
4
+ * playbooks load into the conversation as <skill> blocks instead of
5
+ * replacing the system prompt.
6
+ *
7
+ * DORMANT (stage 0): nothing imports this until stage 2 adds the inline
8
+ * setting. First draft — stage 2's eval set may change the words; move the
9
+ * pin with them.
10
+ *
11
+ * The proxy holds the same text as a literal
12
+ * (cspeach-proxy/src/skills/conversation-prompt.ts);
13
+ * src/agent/__tests__/conversation-prompt-pin.test.ts keeps them byte-equal
14
+ * and checks that no module imports either copy yet. Keep the literal free of
15
+ * backslashes, backticks and ${ so the raw literal is the runtime text.
16
+ */
17
+ export const CONVERSATION_SYSTEM_PROMPT = `You are CSPeach, an AI coding agent for SAP development. 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.
18
+
19
+ How you talk
20
+ - 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.
21
+ - Lead with the answer or what you found. Then why. Then what you'd do next, if anything.
22
+ - Plain words, short sentences. Use headings, tables or lists only when they make the answer easier to read.
23
+ - Have a view. If something is a bad idea, say so and say why. If you're not sure, say what you'd check.
24
+ - No flattery. Apologise only when you got something wrong.
25
+
26
+ How you work
27
+ - Look before you answer. When the question is about this developer's system or project, read the real thing with your tools instead of guessing. Say in a few words what you're checking.
28
+ - Ground what you say in what you read: object names, line numbers, field names, counts. 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.
29
+ - Batch independent reads in one response. When a result says it was saved to a file, read the slice you need with tool_output_read; do not re-run the command.
30
+ - Send more than two source reads, any where-used scan, ATC-finding triage or a package inventory to a reader: call read_agent with a precise brief and work from its report. Do not read those sources yourself when the turn will keep working afterwards. Send independent readers in one response so they run at the same time. Do not use a reader for one or two reads, a small edit, or to check your own work. Never more than three readers for one task unless the person asks.
31
+
32
+ Playbooks
33
+ - Without a loaded playbook you read and explain, and you never change SAP. When the conversation turns into a change, or the person asks for a deliverable a workflow produces, say briefly what you'd do and call hand_off with the right workflow and a clear request.
34
+ - When a playbook is loaded (a <skill> block below), it is in charge. Follow its steps, its plan-then-wait, and its one request_approval for all changes. The Safety and Ground rules below still hold when a playbook says otherwise.
35
+ - The most recently loaded playbook wins over any earlier one.
36
+ - Load another playbook when the person's request needs it: call hand_off, or dispatch_skill after the person has picked the command. Do not do another playbook's work yourself.
37
+
38
+ Safety
39
+ - Every change in SAP needs a valid approval_id from request_approval. You cannot bypass this. If you will change more than one object, put all the changes in one request_approval first.
40
+ - If a plan is rejected, ask what to change. Do not quietly revise and re-submit.
41
+ - Snapshots are taken before every write, and syntax and activation are checked after it. Do not ask the person to confirm snapshots. Do not activate when there are syntax errors.
42
+ - If ATC or syntax errors block activation, fix them or ask. Never suppress or hide them.
43
+ - To change logic inside existing methods without changing the class definition, use sap_update_method per method, not sap_set_source.
44
+ - All writes go into the active session transport. Do not ask for a transport number per change. Never use $TMP unless the person asks for local-only work.
45
+ - If a tool error blocks you and you don't know how to recover, say so clearly and ask how to proceed. Stop and ask before a fourth attempt at the same operation.
46
+ - For an unclear request, ask one question before acting.
47
+ - Before you delete or overwrite an object in a production namespace, confirm in text that the person means it, even when you have an approval_id.
48
+
49
+ Ground rules
50
+ - Follow the team's development standards when they are given in <development_standards>; they win over your own preferences.
51
+ - Use the SAP system and profile given in <session_context>; never assume a different system.
52
+ - Never ask for, print or store passwords or keys.`;
@@ -13,6 +13,7 @@ import { buildToolList, buildSkillToolAddition, placeSystemMessagesInPlace, reso
13
13
  import { settleSessionToolLoading } from './tool-loading-pin.js';
14
14
  import { resolveSkillAlias } from '../skill-catalog.js';
15
15
  import { retryWithBackoff } from './retry.js';
16
+ import { capToolResultContent, isPromptTooLongError, promptTooLongNotice, promptTooLongRetryLine, safeHistoryChars, trimOversizedToolResults } from './oversized-results.js';
16
17
  import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js';
17
18
  import { filterMachineBlocks, flushMachineBlocks, initialMachineBlockState } from '../renderer/machine-blocks.js';
18
19
  import { nextWritingActivity } from '../renderer/fence-state.js';
@@ -32,6 +33,7 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
32
33
  import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
33
34
  import { reportCallUsage } from '../usage/usage-reporter.js';
34
35
  import { appendCostLine, buildEntry as buildCostEntry, perCallTokens, summariseInputTransformations } from '../cost/cost-log.js';
36
+ import { noteSystemBlock, systemBlockKey } from '../cost/system-block.js';
35
37
  import { sessionMaxTokens, clampMaxTokens, sendsBlockBinding } from './provider-shape.js';
36
38
  import { profileOf, sendsEffortFor } from './connection-profile.js';
37
39
  import { computeSessionCost } from '../cost/session-cost.js';
@@ -654,6 +656,40 @@ export async function runTurn(params) {
654
656
  else
655
657
  console.log(line);
656
658
  }
659
+ // 2026-10-22 customer bug — an oversized tool result stayed in the history
660
+ // and every later message got "prompt is too long". Remove the largest tool
661
+ // results (user and assistant text stay) and save. Owner decision
662
+ // 2026-10-22: when something was removed, the SAME request goes out again
663
+ // once, in this turn (the user message is already in the history — never
664
+ // appended twice). No retry when the person stopped the turn, when nothing
665
+ // could be removed, or when this turn already retried once.
666
+ let promptTooLongRetried = false;
667
+ async function trimForPromptTooLong(err) {
668
+ const trimmed = trimOversizedToolResults(session.messages, safeHistoryChars(session.messages, err));
669
+ if (trimmed.removed > 0)
670
+ delete session.last_context_tokens;
671
+ session.turnInterrupted = false;
672
+ session.turnInterruptedReason = undefined;
673
+ await saveSession(session);
674
+ return trimmed;
675
+ }
676
+ /**
677
+ * Trim the history; when something was removed and a retry is allowed, show
678
+ * the info line and return true (the caller runs the same request again).
679
+ * Otherwise show the card (unless `card: false`) and return false.
680
+ */
681
+ async function recoverFromPromptTooLong(err, opts = {}) {
682
+ const mayRetry = !promptTooLongRetried && !params.signal?.aborted;
683
+ const trimmed = await trimForPromptTooLong(err);
684
+ if (mayRetry && trimmed.removed > 0) {
685
+ promptTooLongRetried = true;
686
+ emit('\n' + promptTooLongRetryLine(trimmed).trimEnd());
687
+ return true;
688
+ }
689
+ if (opts.card !== false)
690
+ emit('\n' + promptTooLongNotice(trimmed).trimEnd());
691
+ return false;
692
+ }
657
693
  // Guided clean-core journey (Task 3) — the journey survives only its own
658
694
  // queued step (or a plain reply inside it); any other turn ends the chain
659
695
  // with one line. No journey → nothing happens here.
@@ -1409,6 +1445,14 @@ ${imageExpansion.error}`));
1409
1445
  }).trimEnd());
1410
1446
  return;
1411
1447
  }
1448
+ // 2026-10-22 — the provider says the prompt is too long: remove the
1449
+ // largest tool results, save, and run the same request once more (or
1450
+ // show the card). The main turn only.
1451
+ if (!readerChild && !params.ctx.subagent && isPromptTooLongError(err)) {
1452
+ if (await recoverFromPromptTooLong(err))
1453
+ continue;
1454
+ return;
1455
+ }
1412
1456
  // Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
1413
1457
  const status = err?.status ?? err?.response?.status;
1414
1458
  const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
@@ -1659,6 +1703,15 @@ ${imageExpansion.error}`));
1659
1703
  if (callIndex === 0 && currentAssistantContent.length === 0 && isChatUnavailable(err)) {
1660
1704
  withdrawChatTurn((err?.status ?? err?.response?.status));
1661
1705
  }
1706
+ // 2026-10-22 — "prompt is too long" on the stream, before anything
1707
+ // streamed: the same recovery as at create time. A retry runs the same
1708
+ // request again right here (nothing of this round is persisted). No
1709
+ // retry: the catch only decides; the card and the final save (with
1710
+ // turnInterrupted cleared) come from the recovery after the persist block.
1711
+ if (!readerChild && !params.ctx.subagent && !params.signal?.aborted && currentAssistantContent.length === 0 && isPromptTooLongError(err)) {
1712
+ if (await recoverFromPromptTooLong(err, { card: false }))
1713
+ continue;
1714
+ }
1662
1715
  }
1663
1716
  // Final flush: emit any pending partial line after the stream ends.
1664
1717
  // Runs on both clean completion and mid-stream interruption.
@@ -1805,6 +1858,18 @@ ${imageExpansion.error}`));
1805
1858
  const callTokens = perCallTokens(nowTokens, lastLoggedTokens);
1806
1859
  lastLoggedTokens = nowTokens;
1807
1860
  const turnDurationMs = Date.now() - turnStartedAt;
1861
+ // Task 0.2 (conversation Stage 0) — main calls only: the skill header
1862
+ // this call sent and whether its system block matched the session's
1863
+ // previous main call (same four inputs as the headers above).
1864
+ const mainLine = !readerChild;
1865
+ const systemBlock = mainLine
1866
+ ? noteSystemBlock(session, systemBlockKey({
1867
+ skill: params.skill,
1868
+ delegateSkill: params.delegateSkill,
1869
+ style: getAssistantStyle(),
1870
+ level: getAssistantLevel(),
1871
+ }))
1872
+ : undefined;
1808
1873
  const entry = buildCostEntry({
1809
1874
  turn: turnNumber,
1810
1875
  // A4 — when a per-turn override ran the stream on a different model
@@ -1831,6 +1896,8 @@ ${imageExpansion.error}`));
1831
1896
  // Task 18 (F24) — running reader totals of this turn (absent until a
1832
1897
  // reader has run).
1833
1898
  reader: params.ctx.readerSpend,
1899
+ skill: mainLine ? params.skill : undefined,
1900
+ system_block: systemBlock,
1834
1901
  });
1835
1902
  readerCallsLogged = params.ctx.readerSpend?.calls ?? 0;
1836
1903
  void appendCostLine(session.id, entry);
@@ -1870,13 +1937,25 @@ ${imageExpansion.error}`));
1870
1937
  await turnStreamWriter.close(interruptedError !== null
1871
1938
  ? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
1872
1939
  : undefined);
1940
+ // 2026-10-22 — "prompt is too long" can also arrive on the stream: the
1941
+ // same recovery as at create time, instead of a "type continue" that
1942
+ // could only fail again.
1943
+ const recoverTooLong = interruptedError !== null && !readerChild && !params.ctx.subagent
1944
+ && !params.signal?.aborted && isPromptTooLongError(interruptedError);
1873
1945
  // Guided clean-core journey (Task 3, fix round 1) — the queued step was
1874
1946
  // interrupted: pause the journey so "carry on" resumes it on the first try.
1947
+ // On a recovery the card is the one next step: the journey is paused, but
1948
+ // its "Paused … carry on" line is not printed.
1875
1949
  if (interruptedError !== null && !readerChild && !params.ctx.subagent) {
1876
1950
  const journeyLine = pauseJourneyForInterruptedTurn(session);
1877
- if (journeyLine && !isHeadless())
1951
+ if (journeyLine && !isHeadless() && !recoverTooLong)
1878
1952
  emit(journeyLine);
1879
1953
  }
1954
+ if (recoverTooLong) {
1955
+ promptTooLongRetried = true; // after the persist block: never a retry from here
1956
+ await recoverFromPromptTooLong(interruptedError);
1957
+ return;
1958
+ }
1880
1959
  // Re-throw AFTER persistence so the outer REPL catch can render the
1881
1960
  // graceful error UX with full session-id + log-path context.
1882
1961
  if (interruptedError !== null)
@@ -2434,10 +2513,12 @@ ${imageExpansion.error}`));
2434
2513
  }
2435
2514
  // Normal path falls through: the existing `toolResults.push(...)` block
2436
2515
  // immediately below handles both success and the not-yet-capped failure case.
2516
+ // 2026-10-22 — the general guard: a result over TOOL_RESULT_MAX_CHARS
2517
+ // is cut with a note; a result under it is the same value (byte-identical).
2437
2518
  toolResults.push({
2438
2519
  type: 'tool_result',
2439
2520
  tool_use_id: block.id,
2440
- content: result.content,
2521
+ content: capToolResultContent(result.content),
2441
2522
  is_error: result.is_error,
2442
2523
  });
2443
2524
  }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Oversized tool results (2026-10-22 customer bug).
3
+ *
4
+ * A Word spec whose pictures came back as base64 gave one tool result of
5
+ * ~2.3M tokens. The provider answered "prompt is too long", and because the
6
+ * result stayed in session.messages every later message failed the same way.
7
+ *
8
+ * - capToolResultContent: the loop's general guard. Any tool result over
9
+ * TOOL_RESULT_MAX_CHARS is cut with a note; anything under it is returned
10
+ * as the same value, so those requests stay byte-identical.
11
+ * - isPromptTooLongError + trimOversizedToolResults + promptTooLongNotice:
12
+ * the recovery when the provider still says the prompt is too long. Only
13
+ * tool_result content changes (largest first); the block, its tool_use_id
14
+ * pairing and every user and assistant text stay. Thinking blocks after
15
+ * the first changed result are dropped, as the cold-start prune does: they
16
+ * are bound to the old prefix.
17
+ */
18
+ import { contentChars } from './cold-prune.js';
19
+ import { stripThinking } from '../commands/compact.js';
20
+ import { formatAttention, formatFailure, formatInfo } from '../renderer/notice-shape.js';
21
+ /** Largest tool result text the loop sends (≈100–130k tokens). */
22
+ export const TOOL_RESULT_MAX_CHARS = 400_000;
23
+ /** A tool result smaller than this is never removed by the recovery. */
24
+ export const TRIM_MIN_CHARS = 10_000;
25
+ /** History budget when the provider's error names no token counts. */
26
+ export const DEFAULT_SAFE_HISTORY_CHARS = 600_000;
27
+ /** Head-room under the provider's named limit (char→token ratios vary). */
28
+ const BUDGET_MARGIN = 0.7;
29
+ function cutNote(more) {
30
+ return `\n… [result cut: ${more} more chars. It was too large to send in one piece. Read it in smaller parts, for example with offset and limit.]`;
31
+ }
32
+ /**
33
+ * Cap a tool result's text at `max` chars. A string or text blocks under the
34
+ * cap come back as the same value (no copy); image and other blocks are left
35
+ * alone. Never mutates its input.
36
+ */
37
+ export function capToolResultContent(content, max = TOOL_RESULT_MAX_CHARS) {
38
+ if (typeof content === 'string') {
39
+ if (content.length <= max)
40
+ return content;
41
+ return (content.slice(0, max) + cutNote(content.length - max));
42
+ }
43
+ if (!Array.isArray(content))
44
+ return content;
45
+ let left = max;
46
+ let changed = false;
47
+ const out = content.map((b) => {
48
+ const block = b;
49
+ if (block?.type !== 'text' || typeof block.text !== 'string')
50
+ return b;
51
+ if (block.text.length <= left) {
52
+ left -= block.text.length;
53
+ return b;
54
+ }
55
+ changed = true;
56
+ const keep = left;
57
+ left = 0;
58
+ return { ...block, text: block.text.slice(0, keep) + cutNote(block.text.length - keep) };
59
+ });
60
+ return (changed ? out : content);
61
+ }
62
+ // The API's "prompt is too long" 400 and its 413 request_too_large; the
63
+ // local / OpenAI-shaped context-length error.
64
+ const PROMPT_TOO_LONG_RE = /prompt is too long|request_too_large|context_length_exceeded|maximum context length/i;
65
+ function errorTexts(err) {
66
+ if (err == null)
67
+ return [];
68
+ const e = err;
69
+ const out = [];
70
+ out.push(err instanceof Error ? err.message : typeof e.message === 'string' ? e.message : String(err));
71
+ if (e.error != null) {
72
+ try {
73
+ out.push(typeof e.error === 'string' ? e.error : JSON.stringify(e.error));
74
+ }
75
+ catch { /* unserialisable body */ }
76
+ }
77
+ return out;
78
+ }
79
+ /**
80
+ * True for the provider's "prompt is too long" refusal — the same words come
81
+ * back from a direct provider and through our proxy (message text or the
82
+ * SDK's parsed `.error` body) — and for the same condition in its other
83
+ * shapes: the API's 413 request_too_large and the local / OpenAI-shaped
84
+ * context-length error.
85
+ */
86
+ export function isPromptTooLongError(err) {
87
+ return errorTexts(err).some((t) => PROMPT_TOO_LONG_RE.test(t));
88
+ }
89
+ /**
90
+ * The history size (JSON chars) to trim down to. When the error names the
91
+ * counts ("2357619 tokens > 1000000 maximum") the history is scaled to that
92
+ * limit with head-room; otherwise DEFAULT_SAFE_HISTORY_CHARS.
93
+ */
94
+ export function safeHistoryChars(messages, err) {
95
+ for (const t of errorTexts(err)) {
96
+ const m = /(\d+)\s*tokens?\s*>\s*(\d+)/i.exec(t);
97
+ if (!m)
98
+ continue;
99
+ const sent = Number(m[1]);
100
+ const limit = Number(m[2]);
101
+ if (sent > 0 && limit > 0 && limit < sent) {
102
+ return Math.floor(JSON.stringify(messages).length * (limit / sent) * BUDGET_MARGIN);
103
+ }
104
+ }
105
+ return DEFAULT_SAFE_HISTORY_CHARS;
106
+ }
107
+ function removedNote(chars) {
108
+ return `[removed: this result was too large to send — ${chars} chars. Read it in smaller parts.]`;
109
+ }
110
+ /** Recognises a result this recovery already removed. */
111
+ export const REMOVED_RESULT_RE = /^\[removed: this result was too large to send — \d+ chars\. Read it in smaller parts\.\]$/;
112
+ /**
113
+ * Replace the content of the largest tool results (each over TRIM_MIN_CHARS)
114
+ * with a short note until the history's JSON is under `budgetChars`. At least
115
+ * the largest one goes even when the count already looks under budget (the
116
+ * provider said it is too long). Mutates `messages` in place.
117
+ */
118
+ export function trimOversizedToolResults(messages, budgetChars) {
119
+ const candidates = [];
120
+ messages.forEach((m, i) => {
121
+ if (m.role !== 'user' || !Array.isArray(m.content))
122
+ return;
123
+ for (const block of m.content) {
124
+ if (block?.type !== 'tool_result')
125
+ continue;
126
+ if (typeof block.content === 'string' && REMOVED_RESULT_RE.test(block.content))
127
+ continue;
128
+ const chars = contentChars(block.content);
129
+ if (chars >= TRIM_MIN_CHARS)
130
+ candidates.push({ msg: i, block, chars });
131
+ }
132
+ });
133
+ if (candidates.length === 0)
134
+ return { removed: 0, removedChars: 0 };
135
+ candidates.sort((a, b) => b.chars - a.chars);
136
+ let total = JSON.stringify(messages).length;
137
+ let removed = 0;
138
+ let removedChars = 0;
139
+ let firstChanged = Infinity;
140
+ for (const c of candidates) {
141
+ if (removed > 0 && total <= budgetChars)
142
+ break;
143
+ const before = JSON.stringify(c.block.content ?? null).length;
144
+ const note = removedNote(c.chars);
145
+ c.block.content = note;
146
+ total -= before - JSON.stringify(note).length;
147
+ removed++;
148
+ removedChars += c.chars;
149
+ firstChanged = Math.min(firstChanged, c.msg);
150
+ }
151
+ // Round 2+ of a tool loop: the history ends in tool_results answering the
152
+ // last assistant message. That message keeps its thinking (the API wants
153
+ // the open tool loop's assistant turn to start with it); the cold-prune
154
+ // rule applies to the messages before it.
155
+ const openLoopAssistant = openToolLoopAssistant(messages);
156
+ for (let i = firstChanged + 1; i < messages.length; i++) {
157
+ if (i === openLoopAssistant)
158
+ continue;
159
+ messages[i] = stripThinking(messages[i]);
160
+ }
161
+ return { removed, removedChars };
162
+ }
163
+ /**
164
+ * Index of the assistant message whose tool_use the history's final
165
+ * tool_results answer, or -1 when the history does not end that way.
166
+ */
167
+ function openToolLoopAssistant(messages) {
168
+ const last = messages[messages.length - 1];
169
+ const prev = messages[messages.length - 2];
170
+ if (!last || !prev || last.role !== 'user' || prev.role !== 'assistant')
171
+ return -1;
172
+ if (!Array.isArray(last.content) || !Array.isArray(prev.content))
173
+ return -1;
174
+ const answered = new Set(last.content
175
+ .filter((b) => b?.type === 'tool_result')
176
+ .map((b) => b.tool_use_id));
177
+ const asks = prev.content.some((b) => b?.type === 'tool_use' && answered.has(b.id));
178
+ return asks ? messages.length - 2 : -1;
179
+ }
180
+ /** The card shown after the recovery ran (never "type continue"). */
181
+ export function promptTooLongNotice(r) {
182
+ if (r.removed === 0) {
183
+ return formatFailure({
184
+ what: 'This conversation is too large to send',
185
+ detail: ['There is nothing large left that we can remove from it.'],
186
+ next: 'type /new to start a fresh conversation',
187
+ });
188
+ }
189
+ const what = r.removed === 1 ? 'one large tool result' : `${r.removed} large tool results`;
190
+ return formatAttention({
191
+ text: 'This conversation got too large to send',
192
+ fact: `we removed ${what} (${r.removedChars.toLocaleString('en-US')} characters) and saved the session · your messages and our replies are kept`,
193
+ next: 'send your message again',
194
+ });
195
+ }
196
+ /**
197
+ * The one info line before the automatic retry (owner decision 2026-10-22):
198
+ * the history was trimmed and the same request goes out again, so there is
199
+ * no "Next:" step for the person.
200
+ */
201
+ export function promptTooLongRetryLine(r) {
202
+ const what = r.removed === 1 ? 'one large tool result' : `${r.removed} large tool results`;
203
+ return formatInfo(`We removed ${what} (${r.removedChars.toLocaleString('en-US')} characters) to keep this conversation within its limit. Your messages and our replies are kept.`);
204
+ }
@@ -23,6 +23,7 @@ import * as path from 'node:path';
23
23
  import { TurnStreamWriter } from './turn-stream.js';
24
24
  import { azureSignInTurnLine } from '../connection/azure-turn-error.js';
25
25
  import { azureErrorNotice, azureErrorContext } from '../connection/azure-errors.js';
26
+ import { isPromptTooLongError } from './oversized-results.js';
26
27
  export function logsRoot() {
27
28
  return path.join(os.homedir(), '.cspeach', 'logs');
28
29
  }
@@ -226,6 +227,19 @@ export async function renderTurnError(input) {
226
227
  print('');
227
228
  return;
228
229
  }
230
+ // 2026-10-22 — "prompt is too long": the loop recovers the main turn
231
+ // (agent/oversized-results.ts). Anything that still reaches here must not
232
+ // say "type continue" — that re-sends the same history and fails again.
233
+ if (isPromptTooLongError(input.err)) {
234
+ await writeErrorLog(id, input.err);
235
+ printBlock(print, formatFailure({
236
+ what: 'This conversation is too large to send',
237
+ detail: [describeError(input.err), `Session: ${id}`],
238
+ next: 'type /new to start a fresh conversation',
239
+ }));
240
+ print('');
241
+ return;
242
+ }
229
243
  const reason = describeError(input.err);
230
244
  const logPath = await writeErrorLog(id, input.err);
231
245
  const detail = [reason, `Session: ${id}`];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The marker a turn continues from, or null: only an /abap-plan turn that is
3
+ * not already a plan resume, with a plan step in flight. /new clears the
4
+ * marker (session/fresh-start.ts), so "continue" after /new never takes this
5
+ * path.
6
+ */
7
+ export function planContinuationMarker(session, skill, planResume) {
8
+ if (skill !== 'abap-plan' || planResume !== null)
9
+ return null;
10
+ return session.plan_in_flight ?? null;
11
+ }
@@ -83,6 +83,10 @@ export function buildEntry(opts) {
83
83
  entry.reader_cost_usd = Math.round(opts.reader.cost * 10000) / 10000;
84
84
  entry.reader_calls = opts.reader.calls;
85
85
  }
86
+ if (opts.skill !== undefined)
87
+ entry.skill = opts.skill;
88
+ if (opts.system_block !== undefined)
89
+ entry.system_block = opts.system_block;
86
90
  return entry;
87
91
  }
88
92
  /**
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Conversation Stage 0, Task 0.2 — did this main call send the same system
3
+ * block as the session's previous one?
4
+ *
5
+ * The system block is decided by four inputs, on the proxy (skill body,
6
+ * delegate skill, style text, level text; the preamble is constant per CLI
7
+ * version) and on direct providers alike. The key joins them; the note
8
+ * compares it with the session's last key. Observability only: the result is
9
+ * written to the cost log and never changes a request.
10
+ *
11
+ * Kept in a module WeakMap, never in the session file (no schema change). A
12
+ * resumed session therefore starts at 'first' again.
13
+ */
14
+ export function systemBlockKey(p) {
15
+ return `${p.skill}|${p.delegateSkill ?? ''}|${p.style}|${p.level}`;
16
+ }
17
+ const lastKeyBySession = new WeakMap();
18
+ /** Records `key` as the session's latest and says how it compares with the previous one. */
19
+ export function noteSystemBlock(session, key) {
20
+ const prev = lastKeyBySession.get(session);
21
+ lastKeyBySession.set(session, key);
22
+ if (prev === undefined)
23
+ return 'first';
24
+ return prev === key ? 'same' : 'changed';
25
+ }
@@ -105,12 +105,14 @@ function coerceDocument(body, fetchedAt) {
105
105
  return null;
106
106
  const toolLoading = coerceToolLoading(b.tool_loading);
107
107
  const autoCompactTokens = coerceAutoCompactTokens(b.auto_compact_tokens);
108
+ const inlineSkills = typeof b.inline_skills === 'boolean' ? b.inline_skills : undefined;
108
109
  return {
109
110
  roles,
110
111
  pricing: coercePricing(b.pricing),
111
112
  effort: coerceEffort(b.effort),
112
113
  ...(toolLoading ? { tool_loading: toolLoading } : {}),
113
114
  ...(autoCompactTokens !== undefined ? { auto_compact_tokens: autoCompactTokens } : {}),
115
+ ...(inlineSkills !== undefined ? { inline_skills: inlineSkills } : {}),
114
116
  fetchedAt: typeof fetchedAt === 'string' && fetchedAt.length > 0 ? fetchedAt : new Date().toISOString(),
115
117
  };
116
118
  }
@@ -277,6 +279,17 @@ export function servedToolLoading() {
277
279
  export function managedProxyServesNewShapes() {
278
280
  return snapshot?.tool_loading !== undefined;
279
281
  }
282
+ /**
283
+ * Conversation stage 0 (Task 0.5) — does the managed proxy serve the
284
+ * conversation prompt? True only when its served config says
285
+ * `inline_skills: true`; false with no snapshot or an older proxy. Managed
286
+ * snapshot only: BYOK builds its own system prompt and never needs it.
287
+ * Dormant — nothing calls it until stage 2 (pinned by
288
+ * __tests__/served-inline-skills-pin.test.ts). Pure sync read.
289
+ */
290
+ export function servedInlineSkills() {
291
+ return snapshot?.inline_skills === true;
292
+ }
280
293
  // 2026-10-02 (BYOK local routing) — BYOK now classifies prompts on the laptop
281
294
  // (direct to the model, the customer's key) instead of POSTing them to our
282
295
  // /v1/classify. It uses the router model CSPeach serves, `roles.router`, kept
@@ -606,6 +606,8 @@ function shortHint(filename) {
606
606
  * char boundary because we slice the decoded string, not the bytes.
607
607
  */
608
608
  const MAX_TEXT_FILE_CHARS = 64 * 1024;
609
+ /** The note after a cut attachment (also refused in SAP writes: tools/cut-markers.ts). */
610
+ export const ATTACHMENT_TRUNCATED_NOTE = '... [truncated, file exceeds 64 KB]';
609
611
  /**
610
612
  * Scan `userMessage` for `@<token>` references that resolve to .txt /
611
613
  * .md files in the workspace, read their content, and substitute the
@@ -690,9 +692,10 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
690
692
  try {
691
693
  const buf = readFileSync(path);
692
694
  const ext = path.toLowerCase().endsWith('.pdf') ? '.pdf' : '.docx';
693
- const { extractDocument } = await import('../tools/filesystem/extract-document.js');
695
+ const { extractDocument, splitLongLines, DOC_MAX_LINE_CHARS } = await import('../tools/filesystem/extract-document.js');
694
696
  const res = await extractDocument(buf, ext, { maxPages: 150 });
695
- content = res.text;
697
+ // 2026-10-22 — long lines split as in read_document; the 64 KB cap below is the total.
698
+ content = splitLongLines(res.text, DOC_MAX_LINE_CHARS);
696
699
  if (log)
697
700
  for (const w of res.warnings)
698
701
  log(`(${filename}: ${w})`);
@@ -714,7 +717,7 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
714
717
  }
715
718
  }
716
719
  if (content.length > MAX_TEXT_FILE_CHARS) {
717
- content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n... [truncated, file exceeds 64 KB]';
720
+ content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n' + ATTACHMENT_TRUNCATED_NOTE;
718
721
  }
719
722
  const replacement = `<attached file="${filename}">\n${content}\n</attached>`;
720
723
  substitutions.push({ index, matchLen: match.length, replacement });
package/dist/repl.js CHANGED
@@ -51,6 +51,8 @@ import { getConnection } from './sap/connection-manager.js';
51
51
  import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
52
52
  import { AdtClient, snapshots } from '@cspeach/sap-client';
53
53
  import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
54
+ import { startFreshConversation, freshStartText } from './session/fresh-start.js';
55
+ import { planContinuationMarker } from './commands/plan-continue-gate.js';
54
56
  import { newSession } from './session/schema.js';
55
57
  import { resolveModelRole } from './models/resolve.js';
56
58
  import { familyOf, planTierModelFor, getActiveDeployments } from './models/model-identity.js';
@@ -1376,7 +1378,11 @@ export async function runRepl(opts) {
1376
1378
  todoEmitter.emit('update', []);
1377
1379
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1378
1380
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
1379
- chunkEmitter.emit('chunk', '\n' + (laneSetting === 'chat' ? formatInfo('Fresh start.') : formatInfo('Fresh start · the next prompt picks a skill again')));
1381
+ // 2026-10-22 — and the conversation history: the next prompt sends none of it.
1382
+ startFreshConversation(session);
1383
+ idleKeepalive.disarm(); // no idle cache ping for the old history
1384
+ await saveSession(session);
1385
+ chunkEmitter.emit('chunk', '\n' + formatInfo(freshStartText(laneSetting)));
1380
1386
  return;
1381
1387
  }
1382
1388
  // /chat [on|off] (spec 2026-10-17 D6, owner amendment) — leave a workflow,
@@ -1925,8 +1931,8 @@ export async function runRepl(opts) {
1925
1931
  // prompt is NOT replaced and the context is NOT reset — "continue"
1926
1932
  // needs the accumulated transcript.
1927
1933
  let planContinue = null;
1928
- if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
1929
- const marker = session.plan_in_flight;
1934
+ const marker = planContinuationMarker(session, skill, planResume);
1935
+ if (marker) {
1930
1936
  const { preparePlanContinuation } = await import('./commands/plan-continue.js');
1931
1937
  const r = await preparePlanContinuation({
1932
1938
  marker,
@@ -3397,7 +3403,11 @@ export async function runRepl(opts) {
3397
3403
  session.todos = []; // P2 — /new clears the plan (spec §4)
3398
3404
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
3399
3405
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
3400
- console.log((laneSetting === 'chat' ? formatInfo('Fresh start.') : formatInfo('Fresh start · the next prompt picks a skill again')).trimEnd());
3406
+ // 2026-10-22 — and the conversation history: the next prompt sends none of it.
3407
+ startFreshConversation(session);
3408
+ idleKeepalive.disarm(); // no idle cache ping for the old history
3409
+ await saveSession(session);
3410
+ console.log(formatInfo(freshStartText(laneSetting)).trimEnd());
3401
3411
  continue;
3402
3412
  }
3403
3413
  // /chat [on|off] — classic mirror of the Ink handler above.
@@ -3990,8 +4000,8 @@ export async function runRepl(opts) {
3990
4000
  // of the Ink branch; see there for the full design note: mutually
3991
4001
  // exclusive with --resume, prompt NOT replaced, context NOT reset).
3992
4002
  let planContinue = null;
3993
- if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
3994
- const marker = session.plan_in_flight;
4003
+ const marker = planContinuationMarker(session, skill, planResume);
4004
+ if (marker) {
3995
4005
  const { preparePlanContinuation } = await import('./commands/plan-continue.js');
3996
4006
  const r = await preparePlanContinuation({
3997
4007
  marker,
@@ -0,0 +1,15 @@
1
+ export function startFreshConversation(session) {
2
+ session.messages = [];
3
+ session.turnInterrupted = false;
4
+ session.turnInterruptedReason = undefined;
5
+ delete session.last_context_tokens;
6
+ delete session.lastAutoCompactTurn;
7
+ // An open plan step does not survive: "continue" must not resume it.
8
+ session.plan_in_flight = null;
9
+ }
10
+ /** The one line /new prints. */
11
+ export function freshStartText(lane) {
12
+ return lane === 'chat'
13
+ ? 'Fresh start · the conversation is cleared.'
14
+ : 'Fresh start · the conversation is cleared · the next prompt picks a skill again';
15
+ }