@cspeach/cli 1.1.48 → 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 (93) hide show
  1. package/dist/agent/cold-prune.js +1 -1
  2. package/dist/agent/conversation-prompt.js +52 -0
  3. package/dist/agent/level.js +1 -1
  4. package/dist/agent/loop.js +90 -8
  5. package/dist/agent/oversized-results.js +204 -0
  6. package/dist/agent/sap-connection-adapter.js +2 -0
  7. package/dist/agent/system-prompt.js +2 -2
  8. package/dist/agent/tool-dispatch.js +13 -1
  9. package/dist/agent/turn-error-ux.js +14 -0
  10. package/dist/approvals/op-labels.js +49 -10
  11. package/dist/approvals/render.js +14 -23
  12. package/dist/approvals/risk-floor.js +196 -19
  13. package/dist/approvals/visible-changes.js +35 -0
  14. package/dist/commands/help.js +1 -1
  15. package/dist/commands/plan-continue-gate.js +11 -0
  16. package/dist/commands/team-plan.js +16 -6
  17. package/dist/commands/team.js +295 -68
  18. package/dist/cost/cost-log.js +4 -0
  19. package/dist/cost/system-block.js +25 -0
  20. package/dist/journey/chain.js +489 -61
  21. package/dist/journey/clean-core.js +61 -36
  22. package/dist/journey/flow.js +75 -2
  23. package/dist/journey/people.js +37 -0
  24. package/dist/journey/rebuild.js +7 -6
  25. package/dist/journey/run.js +129 -42
  26. package/dist/journey/trigger.js +16 -10
  27. package/dist/models/server-config.js +13 -0
  28. package/dist/one-shot.js +1 -1
  29. package/dist/project-context/render.js +7 -0
  30. package/dist/projects/clean-core-confirm.js +166 -44
  31. package/dist/projects/extract-cca.js +8 -7
  32. package/dist/projects/index.js +0 -1
  33. package/dist/projects/merge-upgrade.js +2 -2
  34. package/dist/projects/project-folder.js +2 -1
  35. package/dist/projects/save-command.js +51 -14
  36. package/dist/projects/workspace.js +6 -3
  37. package/dist/register/decision-rules.js +2 -1
  38. package/dist/register/export-files.js +18 -0
  39. package/dist/register/picture.js +3 -3
  40. package/dist/register/project-transport.js +125 -0
  41. package/dist/register/records.js +25 -13
  42. package/dist/register/rules-edit.js +5 -0
  43. package/dist/register/status-file/progress-chart.js +17 -6
  44. package/dist/register/status-file/register-map.js +8 -5
  45. package/dist/register/status-file/register-model.js +55 -36
  46. package/dist/register/status-file/register-parts.js +97 -82
  47. package/dist/register/status-file/register-status-file.js +16 -7
  48. package/dist/register/status-file/report-model.js +293 -205
  49. package/dist/register/status-file/team-css.js +1 -1
  50. package/dist/register/status-html.js +11 -4
  51. package/dist/register/store.js +2 -2
  52. package/dist/register/workbooks.js +24 -13
  53. package/dist/renderer/footer-line.js +10 -2
  54. package/dist/renderer/tool-labels.js +2 -2
  55. package/dist/repl/builtin-commands.js +1 -1
  56. package/dist/repl/continue-skill.js +5 -0
  57. package/dist/repl/level-command.js +6 -15
  58. package/dist/repl.js +37 -14
  59. package/dist/router/router-core.js +5 -3
  60. package/dist/session/fresh-start.js +15 -0
  61. package/dist/skill-catalog.js +4 -7
  62. package/dist/tools/_filesystem-shared.js +7 -0
  63. package/dist/tools/_team-work-path.js +53 -0
  64. package/dist/tools/approval.js +97 -19
  65. package/dist/tools/cut-markers.js +47 -0
  66. package/dist/tools/filesystem/extract-document.js +50 -2
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-read.js +2 -2
  69. package/dist/tools/filesystem/file-write.js +8 -1
  70. package/dist/tools/filesystem/read-document.js +20 -4
  71. package/dist/tools/project/_merge-shared.js +2 -2
  72. package/dist/tools/project/_team-tool-shared.js +5 -0
  73. package/dist/tools/project/rules_propose.js +2 -1
  74. package/dist/tools/project/team_findings.js +81 -28
  75. package/dist/tools/project/team_lane_move.js +127 -0
  76. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  77. package/dist/tools/text-elements/tools.js +7 -1
  78. package/dist/tools/tool-loading.js +1 -1
  79. package/dist/tools/tool-output-read.js +5 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +24 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/footer-line.js +19 -1
  84. package/package.json +3 -3
  85. package/dist/projects/merge-cca.js +0 -311
  86. package/dist/tools/project/cca_merge.js +0 -174
  87. package/test-harness/fe-draft/app.ts +0 -188
  88. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  89. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  90. package/test-harness/fe-draft/routes.ts +0 -224
  91. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  92. package/test-harness/fe-draft/server.ts +0 -225
  93. package/test-harness/fe-draft/variants.ts +0 -196
@@ -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.`;
@@ -26,7 +26,7 @@ export function parseLevel(raw) {
26
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
27
  export const LEVEL_SENTENCES = Object.freeze({
28
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.',
29
+ functional: 'You\'re talking to a developer who wants the business view first. Start with what it means for the business: which documents, amounts, users or reports are affected. Avoid technical terms unless they are needed; when one is needed, explain it in plain words. Show code only when they ask for it. If they ask you to write it up for a developer, write 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. 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
30
  });
31
31
  // ---- pinned: level text composition ----
32
32
  /** The text appended under a "Who you're talking to" heading; null for developer (today's behaviour). */
@@ -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';
@@ -72,6 +74,7 @@ import { loadWatchdogConfig, evaluateWatchdog } from './turn-watchdog.js';
72
74
  import { maybeBuildProjectContext } from './maybe-build-project-context.js';
73
75
  import { buildSapConnectionContext } from './sap-connection-adapter.js';
74
76
  import { getCurrentTransport } from '../repl/current-transport.js';
77
+ import { projectTransport } from '../register/project-transport.js';
75
78
  import { collectTurnAssistantText } from './turn-assistant-text.js';
76
79
  import { checkAndMark, newGuardState } from './parallel-write-guard.js';
77
80
  import { userPromptText } from '../session/user-prompt.js';
@@ -116,12 +119,10 @@ const SAVE_HOOK_SKILLS = new Set([
116
119
  'abap-upgrade-fix',
117
120
  'abap-upgrade-verify',
118
121
  'abap-upgrade-merge',
119
- // 2026-05-09: CCA pipeline manifest envelopes. /abap-cca-merge produces
120
- // a consolidated cca-assessment from N parallel consultant slices.
121
122
  // 2026-10-06: the thin /abap-cca (1.1.31) reads the estate inventory and
122
123
  // writes no envelope, so it is no longer a save-hook skill. An old
123
124
  // cca-manifest in a turn is still offered through detectArtifactSkill.
124
- 'abap-cca-merge',
125
+ // 2026-10-20: /abap-cca-merge cut (drift spec §3.6); its name aliases to abap-cca.
125
126
  // 2026-05-10: v0.7 trio chain. /abap-modernize emits modernize-result;
126
127
  // /abap-test emits test-coverage. Both accept cca-assessment via --from
127
128
  // and can fan out into further chains after their own envelope saves.
@@ -408,7 +409,7 @@ export async function maybeOfferSave(p) {
408
409
  // the output carried a complete csforge:plan-manifest block. The label
409
410
  // stays the primary trigger — it is authoritative for prose-extracted
410
411
  // artefacts with no inline marker (spec-gap / design / estimate) and keeps
411
- // merge-skill provenance exact (abap-cca-merge saves as abap-cca-merge).
412
+ // merge-skill provenance exact (abap-upgrade-merge saves as abap-upgrade-merge).
412
413
  // When the label would NOT fire, a recognizable manifest block in the
413
414
  // output resolves the registry skill instead (detectArtifactSkill), so a
414
415
  // misroute can no longer silently lose the artifact.
@@ -655,6 +656,40 @@ export async function runTurn(params) {
655
656
  else
656
657
  console.log(line);
657
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
+ }
658
693
  // Guided clean-core journey (Task 3) — the journey survives only its own
659
694
  // queued step (or a plain reply inside it); any other turn ends the chain
660
695
  // with one line. No journey → nothing happens here.
@@ -925,6 +960,8 @@ ${imageExpansion.error}`));
925
960
  const sapConnection = params.ctx.sapAlias
926
961
  ? buildSapConnectionContext(params.ctx.sapAlias, sapSystemInfo, {
927
962
  currentTransport: getCurrentTransport() ?? null,
963
+ // Task 6 (project view): the project request of this folder; null outside a project.
964
+ projectTransport: projectTransport(params.ctx.cwd),
928
965
  })
929
966
  : null;
930
967
  projectContextBlock = await maybeBuildProjectContext({
@@ -1106,7 +1143,7 @@ ${imageExpansion.error}`));
1106
1143
  session.turn_seq = (session.turn_seq ?? Math.max(turnNumber - 1, session.lastAutoCompactTurn ?? 0)) + 1;
1107
1144
  // Snapshot session.messages.length at turn start so the save hook can
1108
1145
  // walk every assistant message added during this turn — not just the
1109
- // final one. Skills like /abap-cca-merge emit their manifest in an early
1146
+ // final one. Skills like /abap-upgrade-merge emit their manifest in an early
1110
1147
  // message before a closing ask_question widget; the final wrap-up
1111
1148
  // message ends the turn but doesn't carry the manifest. See
1112
1149
  // ./turn-assistant-text.ts for the full rationale.
@@ -1408,6 +1445,14 @@ ${imageExpansion.error}`));
1408
1445
  }).trimEnd());
1409
1446
  return;
1410
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
+ }
1411
1456
  // Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
1412
1457
  const status = err?.status ?? err?.response?.status;
1413
1458
  const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
@@ -1658,6 +1703,15 @@ ${imageExpansion.error}`));
1658
1703
  if (callIndex === 0 && currentAssistantContent.length === 0 && isChatUnavailable(err)) {
1659
1704
  withdrawChatTurn((err?.status ?? err?.response?.status));
1660
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
+ }
1661
1715
  }
1662
1716
  // Final flush: emit any pending partial line after the stream ends.
1663
1717
  // Runs on both clean completion and mid-stream interruption.
@@ -1804,6 +1858,18 @@ ${imageExpansion.error}`));
1804
1858
  const callTokens = perCallTokens(nowTokens, lastLoggedTokens);
1805
1859
  lastLoggedTokens = nowTokens;
1806
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;
1807
1873
  const entry = buildCostEntry({
1808
1874
  turn: turnNumber,
1809
1875
  // A4 — when a per-turn override ran the stream on a different model
@@ -1830,6 +1896,8 @@ ${imageExpansion.error}`));
1830
1896
  // Task 18 (F24) — running reader totals of this turn (absent until a
1831
1897
  // reader has run).
1832
1898
  reader: params.ctx.readerSpend,
1899
+ skill: mainLine ? params.skill : undefined,
1900
+ system_block: systemBlock,
1833
1901
  });
1834
1902
  readerCallsLogged = params.ctx.readerSpend?.calls ?? 0;
1835
1903
  void appendCostLine(session.id, entry);
@@ -1869,13 +1937,25 @@ ${imageExpansion.error}`));
1869
1937
  await turnStreamWriter.close(interruptedError !== null
1870
1938
  ? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
1871
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);
1872
1945
  // Guided clean-core journey (Task 3, fix round 1) — the queued step was
1873
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.
1874
1949
  if (interruptedError !== null && !readerChild && !params.ctx.subagent) {
1875
1950
  const journeyLine = pauseJourneyForInterruptedTurn(session);
1876
- if (journeyLine && !isHeadless())
1951
+ if (journeyLine && !isHeadless() && !recoverTooLong)
1877
1952
  emit(journeyLine);
1878
1953
  }
1954
+ if (recoverTooLong) {
1955
+ promptTooLongRetried = true; // after the persist block: never a retry from here
1956
+ await recoverFromPromptTooLong(interruptedError);
1957
+ return;
1958
+ }
1879
1959
  // Re-throw AFTER persistence so the outer REPL catch can render the
1880
1960
  // graceful error UX with full session-id + log-path context.
1881
1961
  if (interruptedError !== null)
@@ -1932,7 +2012,7 @@ ${imageExpansion.error}`));
1932
2012
  // a background block on the next REPL prompt.
1933
2013
  //
1934
2014
  // 2026-05-12: collect text from ALL assistant messages added during
1935
- // this turn, not just the final one. Skills like /abap-cca-merge emit
2015
+ // this turn, not just the final one. Skills like /abap-upgrade-merge emit
1936
2016
  // their manifest in an early message before a closing ask_question
1937
2017
  // widget; the final wrap-up message ends the turn but doesn't
1938
2018
  // carry the manifest. See ./turn-assistant-text.ts for details.
@@ -2433,10 +2513,12 @@ ${imageExpansion.error}`));
2433
2513
  }
2434
2514
  // Normal path falls through: the existing `toolResults.push(...)` block
2435
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).
2436
2518
  toolResults.push({
2437
2519
  type: 'tool_result',
2438
2520
  tool_use_id: block.id,
2439
- content: result.content,
2521
+ content: capToolResultContent(result.content),
2440
2522
  is_error: result.is_error,
2441
2523
  });
2442
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
+ }
@@ -34,6 +34,8 @@ export function buildSapConnectionContext(alias, systemInfo, state = {}) {
34
34
  alias,
35
35
  system: toSummary(systemInfo),
36
36
  currentTransport: state.currentTransport ?? null,
37
+ // Only in a project folder: a context without one stays as it was, key for key.
38
+ ...(state.projectTransport ? { projectTransport: state.projectTransport } : {}),
37
39
  activeScope: toActiveScope(state),
38
40
  recentObjects: state.recentObjects ?? [],
39
41
  };
@@ -47,7 +47,7 @@ export function buildReaderSystemPrompt(opts) {
47
47
  * Chat lane (spec 2026-10-17 D3) — one block, the pinned chat prompt, as the
48
48
  * proxy sends it for the `_chat` skill header. The coding-workflow preamble is
49
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.
50
+ * a non-default level (§10 D14) follows the style text.
51
51
  */
52
52
  export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
53
53
  const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
@@ -62,7 +62,7 @@ export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAU
62
62
  * has no SKILL.md, so it never reaches the skill source (a lookup would fail);
63
63
  * nor does the built-in `_chat` (D3). A non-professional `style` (D9) is
64
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`
65
+ * reader (which reports facts) never gets style. A non-default `level`
66
66
  * (§10 D14) follows the style text; developer changes nothing; a reader never
67
67
  * gets it.
68
68
  */
@@ -21,17 +21,24 @@ export const CHAT_UNIT_TEST_REASON = 'This chat reads but never runs code on SAP
21
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
22
  /** A call carrying an approval_id in chat — approvals belong to workflows, never to chat. */
23
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
+ * team_lane_move in chat (Task 4, 2026-10-20) — listed like every tool, refused
26
+ * here. Moving packages between people is a lead's decision with its own card;
27
+ * in chat the lead is given the command instead.
28
+ */
29
+ export const CHAT_LANE_MOVE_REASON = 'In this chat, packages are not moved between people. The lead runs: cspeach team lane move --packages <names> --to <name> --reason "<why>"';
24
30
  /**
25
31
  * Tools refused in chat beyond the generic rule: the two smokes write SAP
26
32
  * data over OData although requiresSap is false; sap_run_unit_test runs code
27
33
  * that can COMMIT; dispatch_skill could queue any slash command (hand_off is
28
- * chat's switch, D5).
34
+ * chat's switch, D5); team_lane_move writes the team project's lanes (Task 4).
29
35
  */
30
36
  const CHAT_EXTRA_REFUSED = new Map([
31
37
  ['fiori_draft_smoke', CHAT_READ_ONLY_REASON],
32
38
  ['cap_link_smoke', CHAT_READ_ONLY_REASON],
33
39
  ['sap_run_unit_test', CHAT_UNIT_TEST_REASON],
34
40
  ['dispatch_skill', CHAT_DISPATCH_SKILL_REASON],
41
+ ['team_lane_move', CHAT_LANE_MOVE_REASON],
35
42
  ]);
36
43
  /**
37
44
  * Chat lane refusal set (D-F): WRITE_TOOLS ∪ {request_approval} ∪ every tool
@@ -256,6 +263,11 @@ function declined(r) {
256
263
  export function isLocalProjectWork(tool, args) {
257
264
  if (tool.category === 'filesystem')
258
265
  return true;
266
+ // Task 4 (2026-10-20) — every project tool writes project files behind its
267
+ // own card (team_lane_move) or reads only: a second lane move in one turn
268
+ // never gets a batch card on top of its own card. No project tool reaches SAP.
269
+ if (tool.category === 'project')
270
+ return true;
259
271
  // 2026-10-02 — fiori_deploy mode "test" (the default) writes nothing to SAP:
260
272
  // the backend validates the upload and keeps nothing. Not a batch write, so
261
273
  // a deploy phase shows ONE approval (the real deploy's request_approval),
@@ -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}`];