@cspeach/cli 1.1.49 → 1.1.51

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 (64) 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 +189 -5
  4. package/dist/agent/oversized-results.js +204 -0
  5. package/dist/agent/turn-error-ux.js +14 -0
  6. package/dist/cli.js +1 -1
  7. package/dist/commands/compact.js +6 -0
  8. package/dist/commands/config-set.js +1 -1
  9. package/dist/commands/export-audit.js +26 -3
  10. package/dist/commands/export-conversation.js +343 -0
  11. package/dist/commands/help.js +3 -0
  12. package/dist/commands/plan-boundary.js +5 -0
  13. package/dist/commands/plan-continue-gate.js +11 -0
  14. package/dist/commands/plan-continue.js +11 -0
  15. package/dist/commands/plan-startup.js +5 -1
  16. package/dist/cost/cost-log.js +4 -0
  17. package/dist/cost/system-block.js +25 -0
  18. package/dist/docx/doc-meta.js +9 -1
  19. package/dist/docx/markdown-to-docx.js +14 -1
  20. package/dist/lane/app-chain.js +24 -1
  21. package/dist/models/server-config.js +13 -0
  22. package/dist/projects/document-save.js +65 -8
  23. package/dist/projects/document-skills.js +32 -1
  24. package/dist/projects/workspace.js +6 -3
  25. package/dist/repl/builtin-commands.js +1 -1
  26. package/dist/repl/slash-completer.js +4 -0
  27. package/dist/repl.js +50 -16
  28. package/dist/session/cli-record.js +165 -0
  29. package/dist/session/fresh-start.js +19 -0
  30. package/dist/tools/_filesystem-shared.js +7 -0
  31. package/dist/tools/cut-markers.js +47 -0
  32. package/dist/tools/filesystem/extract-document.js +50 -2
  33. package/dist/tools/filesystem/file-read.js +2 -2
  34. package/dist/tools/filesystem/read-document.js +20 -4
  35. package/dist/tools/tool-output-read.js +5 -1
  36. package/dist/tools/write-pipeline.js +5 -0
  37. package/dist/ui/card-slot.js +18 -1
  38. package/package.json +121 -121
  39. package/dist/commands/project.js +0 -301
  40. package/dist/doctor/checks/zcspeach.js +0 -96
  41. package/dist/projects/email-template.js +0 -56
  42. package/dist/projects/merge-cca.js +0 -292
  43. package/dist/tools/local-files.js +0 -31
  44. package/dist/tools/project/cca_merge.js +0 -164
  45. package/dist/tools/subagent/schedule_create.js +0 -131
  46. package/dist/ui/error-emitter.js +0 -21
  47. package/dist/ui/header.js +0 -41
  48. package/dist/ui/ink-classifier-route.js +0 -19
  49. package/dist/ui/rich-error-box.js +0 -9
  50. package/dist/ui/sidebar.js +0 -12
  51. package/dist/ui/status-emitter.js +0 -19
  52. package/dist/ui/status-line.js +0 -62
  53. package/dist/ui/status-row.js +0 -38
  54. package/dist/ui/todo-panel.js +0 -85
  55. package/dist/ui/widgets/ask-form.js +0 -250
  56. package/dist/ui/widgets/ask-question-modal.js +0 -178
  57. package/fiori/zlas-inspection-lr/package.json +0 -26
  58. package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
  59. package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
  60. package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
  61. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
  62. package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
  63. package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
  64. 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';
@@ -43,7 +45,9 @@ import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
43
45
  import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
44
46
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
45
47
  import { planLaneBlockForTurn } from '../projects/plan-lane.js';
46
- import { DOCUMENT_SKILLS } from '../projects/document-skills.js';
48
+ import { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE } from '../projects/document-skills.js';
49
+ import { metaFromMarkdown } from '../docx/doc-meta.js';
50
+ import { bindCliCardSession, documentOffersFor, markCliMessage, CONTINUE_LINE, WATCHDOG_LINE } from '../session/cli-record.js';
47
51
  import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
48
52
  import { getLatestSkeletonDecision } from '../rap/skeleton-session.js';
49
53
  import { afterRebuildTurn } from '../journey/rebuild.js';
@@ -371,6 +375,69 @@ export async function inkAwareChoose(a) {
371
375
  emitTrace(formatAnswerTrace(header, shown, uiWidth()));
372
376
  return { kind: 'picked', answer };
373
377
  }
378
+ /**
379
+ * Word copy everywhere (2026-10-23) — a newer document skill offers the save
380
+ * only when its answer is shaped like a document: at least one Markdown
381
+ * heading. A short answer to a quick question ("what is clean core?") is
382
+ * not offered. Review/handover do not use this check (unchanged).
383
+ */
384
+ function looksLikeDocument(text) {
385
+ return /^#{1,6}\s+\S/m.test(text);
386
+ }
387
+ const OBJECT_TOKEN_RE = /(?<![\w/])((?:\/[A-Za-z0-9]+\/)?[ZYzy][A-Za-z0-9_]{2,39})\b/g;
388
+ /**
389
+ * The first Z/Y object name in the prompt (final-review m-2): a name with an
390
+ * underscore (zcl_order, ZFI_DUP_INVOICE_CHECK), or one typed in capitals with
391
+ * at least 4 characters (ZTEST). Ordinary words never count: you, yes, YES, YOU.
392
+ */
393
+ export function promptObjectName(prompt) {
394
+ for (const m of prompt.matchAll(OBJECT_TOKEN_RE)) {
395
+ const t = m[1];
396
+ if (t.includes('_') || (t.length >= 4 && t === t.toUpperCase()))
397
+ return t.toUpperCase();
398
+ }
399
+ return undefined;
400
+ }
401
+ /**
402
+ * Note this turn's (skill, object). `fresh` is false only when the key was
403
+ * already offered in this session (final-review I-3: a clarifying question
404
+ * before the document no longer uses up the offer). Without a session: always
405
+ * fresh (no rule). The offers are cleared by /new (session/cli-record.ts).
406
+ */
407
+ function noteDocumentTurn(p) {
408
+ if (!p.session)
409
+ return { fresh: true, markOffered: () => { } };
410
+ const object = metaFromMarkdown(p.assistantText, { date: '' }).object ?? promptObjectName(p.userMessage) ?? '';
411
+ const key = `${p.skill}|${object}`;
412
+ const offered = documentOffersFor(p.session);
413
+ return { fresh: !offered.has(key), markOffered: () => { offered.add(key); } };
414
+ }
415
+ /** Today's local date, YYYY-MM-DD (the document file names use it). */
416
+ function localToday() {
417
+ const d = new Date();
418
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
419
+ }
420
+ /**
421
+ * The Word step after an artefact save. document-save.ts (and the docx
422
+ * package) loads only here, inside the catch: a broken docx install costs
423
+ * one line after the save and never the save itself.
424
+ */
425
+ async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
426
+ try {
427
+ const { offerWordCopyAfterArtifactSave } = await import('../projects/document-save.js');
428
+ const { isHeadless } = await import('../renderer/tty.js');
429
+ const { loadConfig, resolveWordCopy } = await import('../config/loader.js');
430
+ await offerWordCopyAfterArtifactSave({
431
+ saveSkill, savedPath, markdown: p.assistantText, cwd: process.cwd(), sapSystem: p.sapSystem ?? null,
432
+ // Inside a guided flow the Word step never asks: same rule as headless.
433
+ today: localToday(), headless: isHeadless() || p.guided === true, wordCopy: resolveWordCopy(await loadConfig()),
434
+ confirm, emit: p.emit,
435
+ });
436
+ }
437
+ catch (e) {
438
+ p.emit(formatInfo(`Word copy not written: ${e.message}`).trimEnd());
439
+ }
440
+ }
374
441
  export async function maybeOfferSave(p) {
375
442
  if (p.assistantText.trim().length === 0)
376
443
  return;
@@ -381,7 +448,26 @@ export async function maybeOfferSave(p) {
381
448
  // The skill check reads a tiny constant module; document-save.ts (and the
382
449
  // docx package) loads only for these two skills, inside the catch, so a
383
450
  // broken docx install costs one line on a review save and nothing else.
384
- if (DOCUMENT_SKILLS[p.skill]) {
451
+ // Word copy everywhere (2026-10-23): the newer document skills (explain,
452
+ // impact, preflight, …) yield to the artefact save when their answer
453
+ // carries an artefact block, and offer nothing when the turn ended on a
454
+ // question. Review and handover take this branch exactly as before.
455
+ const confirm = p.confirm ?? inkAwareConfirm;
456
+ const doc = DOCUMENT_SKILLS[p.skill];
457
+ // Fix round 1 (I-3): which skill and object this turn was about (every turn,
458
+ // so a follow-up of the same skill on the same object is known).
459
+ const docTurn = noteDocumentTurn(p);
460
+ let offerDoc = doc !== undefined;
461
+ if (doc?.yieldsToArtifact) {
462
+ // Headless runs of the newer document skills write no file (scripts and CI
463
+ // keep today's behaviour). Review and handover keep theirs.
464
+ // A sub-agent turn never saves, so it never uses up the offer (final-review m-1).
465
+ offerDoc = !p.subagent && docTurn.fresh && !(await import('../renderer/tty.js')).isHeadless() && !p.awaitingAnswer && !p.guided
466
+ && looksLikeDocument(p.assistantText) && !detectArtifactSkill(p.assistantText);
467
+ if (offerDoc)
468
+ docTurn.markOffered();
469
+ }
470
+ if (doc && offerDoc) {
385
471
  if (p.subagent)
386
472
  return;
387
473
  const d = new Date();
@@ -393,7 +479,7 @@ export async function maybeOfferSave(p) {
393
479
  await offerDocumentSave({
394
480
  skill: p.skill, markdown: p.assistantText, cwd: process.cwd(), sapSystem: p.sapSystem ?? null,
395
481
  today, headless: isHeadless(), wordCopy: resolveWordCopy(await loadConfig()),
396
- confirm: inkAwareConfirm, emit: p.emit,
482
+ confirm, emit: p.emit,
397
483
  });
398
484
  }
399
485
  catch (e) {
@@ -469,6 +555,13 @@ export async function maybeOfferSave(p) {
469
555
  // Never headless: a headless journey queues nothing.
470
556
  ...(p.journey && !isHeadless() && p.skill === saveSkill ? { journey: p.journey } : {}),
471
557
  });
558
+ // Word copy everywhere (2026-10-23) — a saved project file whose answer
559
+ // is a document (spec gaps, design, estimate, plan, upgrade scan/report)
560
+ // gets the same Word step as a review: `word_copy` decides, headless
561
+ // never asks. The project file itself is untouched. Sub-agent turns: no.
562
+ if (savedPath && !p.subagent && WORD_AFTER_ARTIFACT_SAVE[saveSkill]) {
563
+ await offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm);
564
+ }
472
565
  // After a spec-gap file is saved, let a developer answer the open BLOCKER
473
566
  // questions inline (recorded as `answered` items in the SAME file) — the
474
567
  // business questions stay open for a consultant to answer in the viewer.
@@ -654,6 +747,40 @@ export async function runTurn(params) {
654
747
  else
655
748
  console.log(line);
656
749
  }
750
+ // 2026-10-22 customer bug — an oversized tool result stayed in the history
751
+ // and every later message got "prompt is too long". Remove the largest tool
752
+ // results (user and assistant text stay) and save. Owner decision
753
+ // 2026-10-22: when something was removed, the SAME request goes out again
754
+ // once, in this turn (the user message is already in the history — never
755
+ // appended twice). No retry when the person stopped the turn, when nothing
756
+ // could be removed, or when this turn already retried once.
757
+ let promptTooLongRetried = false;
758
+ async function trimForPromptTooLong(err) {
759
+ const trimmed = trimOversizedToolResults(session.messages, safeHistoryChars(session.messages, err));
760
+ if (trimmed.removed > 0)
761
+ delete session.last_context_tokens;
762
+ session.turnInterrupted = false;
763
+ session.turnInterruptedReason = undefined;
764
+ await saveSession(session);
765
+ return trimmed;
766
+ }
767
+ /**
768
+ * Trim the history; when something was removed and a retry is allowed, show
769
+ * the info line and return true (the caller runs the same request again).
770
+ * Otherwise show the card (unless `card: false`) and return false.
771
+ */
772
+ async function recoverFromPromptTooLong(err, opts = {}) {
773
+ const mayRetry = !promptTooLongRetried && !params.signal?.aborted;
774
+ const trimmed = await trimForPromptTooLong(err);
775
+ if (mayRetry && trimmed.removed > 0) {
776
+ promptTooLongRetried = true;
777
+ emit('\n' + promptTooLongRetryLine(trimmed).trimEnd());
778
+ return true;
779
+ }
780
+ if (opts.card !== false)
781
+ emit('\n' + promptTooLongNotice(trimmed).trimEnd());
782
+ return false;
783
+ }
657
784
  // Guided clean-core journey (Task 3) — the journey survives only its own
658
785
  // queued step (or a plain reply inside it); any other turn ends the chain
659
786
  // with one line. No journey → nothing happens here.
@@ -952,6 +1079,12 @@ ${imageExpansion.error}`));
952
1079
  // Images (if any) ride as blocks ahead of the text; with none this is the
953
1080
  // same plain string as before.
954
1081
  session.messages.push({ role: 'user', content: buildUserContent(finalUserContent, imageExpansion.images) });
1082
+ if (params.userMessageOrigin) {
1083
+ markCliMessage(session, session.messages[session.messages.length - 1].content, params.userMessageOrigin.kind, params.userMessageOrigin.line);
1084
+ }
1085
+ // The CLI cards answered during this turn (plan gate, lane, journey) belong to this session.
1086
+ if (!params.ctx.subagent && !readerChild)
1087
+ bindCliCardSession(session);
955
1088
  // Round 6 review I4 / final batch 1 — the switch note is dropped only once
956
1089
  // it is in a pushed user message.
957
1090
  if (switchNoteUsed)
@@ -1160,6 +1293,7 @@ ${imageExpansion.error}`));
1160
1293
  if (steers.length > 0) {
1161
1294
  const text = steers.join('\n');
1162
1295
  session.messages.push({ role: 'user', content: text });
1296
+ markCliMessage(session, text, 'steer', '');
1163
1297
  if (roundIndex === 0)
1164
1298
  round0Steers = steers;
1165
1299
  emit(chalk.cyan(`↳ steering: ${steers.join(' / ')}`));
@@ -1409,6 +1543,14 @@ ${imageExpansion.error}`));
1409
1543
  }).trimEnd());
1410
1544
  return;
1411
1545
  }
1546
+ // 2026-10-22 — the provider says the prompt is too long: remove the
1547
+ // largest tool results, save, and run the same request once more (or
1548
+ // show the card). The main turn only.
1549
+ if (!readerChild && !params.ctx.subagent && isPromptTooLongError(err)) {
1550
+ if (await recoverFromPromptTooLong(err))
1551
+ continue;
1552
+ return;
1553
+ }
1412
1554
  // Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
1413
1555
  const status = err?.status ?? err?.response?.status;
1414
1556
  const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
@@ -1659,6 +1801,15 @@ ${imageExpansion.error}`));
1659
1801
  if (callIndex === 0 && currentAssistantContent.length === 0 && isChatUnavailable(err)) {
1660
1802
  withdrawChatTurn((err?.status ?? err?.response?.status));
1661
1803
  }
1804
+ // 2026-10-22 — "prompt is too long" on the stream, before anything
1805
+ // streamed: the same recovery as at create time. A retry runs the same
1806
+ // request again right here (nothing of this round is persisted). No
1807
+ // retry: the catch only decides; the card and the final save (with
1808
+ // turnInterrupted cleared) come from the recovery after the persist block.
1809
+ if (!readerChild && !params.ctx.subagent && !params.signal?.aborted && currentAssistantContent.length === 0 && isPromptTooLongError(err)) {
1810
+ if (await recoverFromPromptTooLong(err, { card: false }))
1811
+ continue;
1812
+ }
1662
1813
  }
1663
1814
  // Final flush: emit any pending partial line after the stream ends.
1664
1815
  // Runs on both clean completion and mid-stream interruption.
@@ -1805,6 +1956,18 @@ ${imageExpansion.error}`));
1805
1956
  const callTokens = perCallTokens(nowTokens, lastLoggedTokens);
1806
1957
  lastLoggedTokens = nowTokens;
1807
1958
  const turnDurationMs = Date.now() - turnStartedAt;
1959
+ // Task 0.2 (conversation Stage 0) — main calls only: the skill header
1960
+ // this call sent and whether its system block matched the session's
1961
+ // previous main call (same four inputs as the headers above).
1962
+ const mainLine = !readerChild;
1963
+ const systemBlock = mainLine
1964
+ ? noteSystemBlock(session, systemBlockKey({
1965
+ skill: params.skill,
1966
+ delegateSkill: params.delegateSkill,
1967
+ style: getAssistantStyle(),
1968
+ level: getAssistantLevel(),
1969
+ }))
1970
+ : undefined;
1808
1971
  const entry = buildCostEntry({
1809
1972
  turn: turnNumber,
1810
1973
  // A4 — when a per-turn override ran the stream on a different model
@@ -1831,6 +1994,8 @@ ${imageExpansion.error}`));
1831
1994
  // Task 18 (F24) — running reader totals of this turn (absent until a
1832
1995
  // reader has run).
1833
1996
  reader: params.ctx.readerSpend,
1997
+ skill: mainLine ? params.skill : undefined,
1998
+ system_block: systemBlock,
1834
1999
  });
1835
2000
  readerCallsLogged = params.ctx.readerSpend?.calls ?? 0;
1836
2001
  void appendCostLine(session.id, entry);
@@ -1870,13 +2035,25 @@ ${imageExpansion.error}`));
1870
2035
  await turnStreamWriter.close(interruptedError !== null
1871
2036
  ? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
1872
2037
  : undefined);
2038
+ // 2026-10-22 — "prompt is too long" can also arrive on the stream: the
2039
+ // same recovery as at create time, instead of a "type continue" that
2040
+ // could only fail again.
2041
+ const recoverTooLong = interruptedError !== null && !readerChild && !params.ctx.subagent
2042
+ && !params.signal?.aborted && isPromptTooLongError(interruptedError);
1873
2043
  // Guided clean-core journey (Task 3, fix round 1) — the queued step was
1874
2044
  // interrupted: pause the journey so "carry on" resumes it on the first try.
2045
+ // On a recovery the card is the one next step: the journey is paused, but
2046
+ // its "Paused … carry on" line is not printed.
1875
2047
  if (interruptedError !== null && !readerChild && !params.ctx.subagent) {
1876
2048
  const journeyLine = pauseJourneyForInterruptedTurn(session);
1877
- if (journeyLine && !isHeadless())
2049
+ if (journeyLine && !isHeadless() && !recoverTooLong)
1878
2050
  emit(journeyLine);
1879
2051
  }
2052
+ if (recoverTooLong) {
2053
+ promptTooLongRetried = true; // after the persist block: never a retry from here
2054
+ await recoverFromPromptTooLong(interruptedError);
2055
+ return;
2056
+ }
1880
2057
  // Re-throw AFTER persistence so the outer REPL catch can render the
1881
2058
  // graceful error UX with full session-id + log-path context.
1882
2059
  if (interruptedError !== null)
@@ -1963,6 +2140,9 @@ ${imageExpansion.error}`));
1963
2140
  emit,
1964
2141
  sapSystem: session.sap_system,
1965
2142
  ...(params.ctx.subagent ? { subagent: true } : {}),
2143
+ ...(session.awaitingSkillAnswer === true ? { awaitingAnswer: true } : {}),
2144
+ ...(getJourneyFlow(session) || getAppFlow(session) ? { guided: true } : {}),
2145
+ session,
1966
2146
  promotedFrom: promotedFromForSave,
1967
2147
  ...(promotedFromPathForSave ? { promotedFromPath: promotedFromPathForSave } : {}),
1968
2148
  ...(() => {
@@ -2116,6 +2296,7 @@ ${imageExpansion.error}`));
2116
2296
  + 'If you were mid-way through a tool call, or (for /abap-plan) had not yet emitted the closing cspeach:plan-manifest block, complete it now. '
2117
2297
  + 'Keep narration brief to stay within the limit.',
2118
2298
  });
2299
+ markCliMessage(session, session.messages[session.messages.length - 1].content, 'continue', CONTINUE_LINE);
2119
2300
  continue;
2120
2301
  }
2121
2302
  if (sawMaxTokens) {
@@ -2434,10 +2615,12 @@ ${imageExpansion.error}`));
2434
2615
  }
2435
2616
  // Normal path falls through: the existing `toolResults.push(...)` block
2436
2617
  // immediately below handles both success and the not-yet-capped failure case.
2618
+ // 2026-10-22 — the general guard: a result over TOOL_RESULT_MAX_CHARS
2619
+ // is cut with a note; a result under it is the same value (byte-identical).
2437
2620
  toolResults.push({
2438
2621
  type: 'tool_result',
2439
2622
  tool_use_id: block.id,
2440
- content: result.content,
2623
+ content: capToolResultContent(result.content),
2441
2624
  is_error: result.is_error,
2442
2625
  });
2443
2626
  }
@@ -2465,6 +2648,7 @@ ${imageExpansion.error}`));
2465
2648
  watchdogWarned = true;
2466
2649
  emit(chalk.yellow(`\n[watchdog] ${decision.reason}. Asking the model to wrap up.`));
2467
2650
  session.messages.push({ role: 'user', content: decision.message });
2651
+ markCliMessage(session, decision.message, 'watchdog', WATCHDOG_LINE);
2468
2652
  await saveSession(session);
2469
2653
  }
2470
2654
  }
@@ -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}`];
package/dist/cli.js CHANGED
@@ -308,7 +308,7 @@ Usage:
308
308
  cspeach Start interactive REPL
309
309
  cspeach "<prompt>" One-shot — runs one turn and exits
310
310
  cspeach --one-shot "<prompt>" Same as above, explicit flag
311
- cspeach export <file>.md --docx Write a Word copy next to a saved review or handover
311
+ cspeach export <file>.md --docx Write a Word copy next to a Markdown file
312
312
  cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
313
313
  cspeach --no-sap Start without a SAP system (paste-mode skills only)
314
314
  cspeach --continue, -c Continue the latest session in this folder
@@ -33,6 +33,7 @@ import { paint } from '../renderer/theme.js';
33
33
  import { getGlyphs } from '../renderer/glyphs.js';
34
34
  import { saveSession } from '../session/store.js';
35
35
  import { isUserPrompt } from '../session/user-prompt.js';
36
+ import { rebuildCliMarksAfterCompact, COMPACT_LINE } from '../session/cli-record.js';
36
37
  import { getActiveDeployments } from '../models/model-identity.js';
37
38
  /** Keep this many recent user-prompt anchored turns verbatim. */
38
39
  export const KEEP_RECENT_TURNS = 5;
@@ -305,7 +306,12 @@ export async function runCompact(opts) {
305
306
  // that produced it). The summary changed them, so the kept tail goes out
306
307
  // without thinking; text and tool calls stay. The backup above keeps all.
307
308
  const kept = plan.toKeep.map(stripThinking);
309
+ const oldMessages = session.messages;
308
310
  session.messages = [summaryMsg, ...kept];
311
+ // /export docx conversation: the summary is CSPeach's, not the developer's
312
+ // (one plain line there). The record is rebuilt in message order so the kept
313
+ // plan steps, continuations and steers keep their marks (final-review I-1).
314
+ rebuildCliMarksAfterCompact(session, oldMessages, plan.cutAt, summaryMsg.content, { kind: 'compact', line: COMPACT_LINE });
309
315
  // Task 19 — the recorded context size described the old history; the next
310
316
  // call records the new one. Clearing it keeps the auto-compact gate honest.
311
317
  delete session.last_context_tokens;