@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
@@ -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
@@ -1,7 +1,8 @@
1
1
  // cspeach-cli/src/projects/document-save.ts
2
2
  //
3
- // The save for the two document skills (2026-10-19, owner decision 2026-10-17).
4
- // A review or a handover is prose, not an envelope: it goes to
3
+ // The save for the document skills (2026-10-19, owner decision 2026-10-17;
4
+ // widened 2026-10-23, Word copy everywhere — see document-skills.ts).
5
+ // A review, a handover, an explanation, … is prose, not an envelope: it goes to
5
6
  // <cwd>/cspeach-out/<date>-<kind>-<OBJECT>.md (the folder the model already
6
7
  // writes extra copies to; skipped by the @-picker; exempt from the Rule 8
7
8
  // card). Then one question, "Word copy too?", unless `word_copy` decides.
@@ -13,8 +14,9 @@ import { askYesNo, isYes } from './yes-no.js';
13
14
  import { metaFromMarkdown } from '../docx/doc-meta.js';
14
15
  import { writeDocxNextTo, writeNoClobber } from '../docx/write-docx.js';
15
16
  import { safeFileStem } from '../register/xlsx.js';
16
- import { DOCUMENT_SKILLS } from './document-skills.js';
17
- export { DOCUMENT_SKILLS };
17
+ import { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE } from './document-skills.js';
18
+ import { handoverPathFor } from './handover-md.js';
19
+ export { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE };
18
20
  export const OUT_DIR = 'cspeach-out';
19
21
  export function documentFilename(kind, object, date) {
20
22
  const stem = safeFileStem((object ?? 'document').replace(/\//g, ''));
@@ -44,19 +46,74 @@ export async function offerDocumentSave(p) {
44
46
  return {};
45
47
  }
46
48
  p.emit(`Saved: ${path.resolve(mdPath)}`);
49
+ const docxPath = await offerWordCopy({
50
+ // Fix round 1 (m-1): the newer document skills' Word copy leaves out HTML comments
51
+ // (widget markers); review and handover pass their text exactly as before.
52
+ nextTo: mdPath, markdown: skill.yieldsToArtifact ? p.markdown.replace(/<!--[\s\S]*?-->\s*/g, '') : p.markdown, meta, headless: p.headless, wordCopy: p.wordCopy, confirm: p.confirm, emit: p.emit,
53
+ });
54
+ return docxPath ? { mdPath, docxPath } : { mdPath };
55
+ }
56
+ /**
57
+ * The Word step, shared by every save of a document (Word copy everywhere,
58
+ * 2026-10-23). `word_copy` decides: unset → ask "Word copy too?", on →
59
+ * always, off → never. Headless never asks: the copy is written only when
60
+ * `word_copy` is on. Never overwrites, never throws: a failure is one line.
61
+ * Returns the .docx path when one was written.
62
+ */
63
+ export async function offerWordCopy(p) {
47
64
  let wantWord = p.wordCopy === 'always';
48
65
  if (p.wordCopy === 'ask' && !p.headless) {
49
66
  wantWord = isYes(await askYesNo({ question: 'Word copy too?', confirm: p.confirm }));
50
67
  }
51
68
  if (!wantWord)
52
- return { mdPath };
69
+ return undefined;
53
70
  try {
54
- const docxPath = await writeDocxNextTo(mdPath, p.markdown, meta);
71
+ const docxPath = await writeDocxNextTo(p.nextTo, p.markdown, p.meta);
55
72
  p.emit(`Word copy: ${path.resolve(docxPath)}`);
56
- return { mdPath, docxPath };
73
+ return docxPath;
57
74
  }
58
75
  catch (e) {
59
76
  p.emit(`Word copy not written. ${e.message}`);
60
- return { mdPath };
77
+ return undefined;
78
+ }
79
+ }
80
+ /** Artefact blocks (`<!-- cspeach:…-manifest … -->`, legacy `csforge:` too) are for CSPeach, not for the reader. */
81
+ const ARTIFACT_BLOCK_RE = /<!--\s*(?:csforge|cspeach):[\w-]+\s*\n[\s\S]*?-->\s*/g;
82
+ /** The YAML header at the top of a plan's handover Markdown. */
83
+ const FRONT_MATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n/;
84
+ /** The text a person reads: no artefact blocks; the YAML header only for a plan handover (final-review m-3). */
85
+ export function readerMarkdown(markdown, frontMatter = true) {
86
+ return (frontMatter ? markdown.replace(FRONT_MATTER_RE, '') : markdown).replace(ARTIFACT_BLOCK_RE, '').trim() + '\n';
87
+ }
88
+ /**
89
+ * The Word step after an artefact save ("Save as project file?" said Yes).
90
+ * The project file is the source of truth and is never touched here; the
91
+ * .docx is a copy for people and is never read back.
92
+ * - a plan: the copy is made from the plan's handover Markdown, next to it
93
+ * (that file is written by the plan save, exactly as before);
94
+ * - the others: from the answer, into cspeach-out/<date>-<kind>-<OBJECT>.docx.
95
+ * Skills not in WORD_AFTER_ARTIFACT_SAVE: nothing.
96
+ */
97
+ export async function offerWordCopyAfterArtifactSave(p) {
98
+ const kind = WORD_AFTER_ARTIFACT_SAVE[p.saveSkill];
99
+ if (!kind)
100
+ return undefined;
101
+ const handover = kind === 'plan' ? handoverPathFor(p.savedPath) : null;
102
+ let source = p.markdown;
103
+ if (handover) {
104
+ try {
105
+ source = await fs.promises.readFile(handover, 'utf8');
106
+ }
107
+ catch {
108
+ return undefined; // the plan save already said the handover was not written
109
+ }
61
110
  }
111
+ const markdown = readerMarkdown(source, handover !== null);
112
+ if (markdown.trim().length === 0)
113
+ return undefined;
114
+ const meta = metaFromMarkdown(markdown, { date: p.today, kind, ...(p.sapSystem ? { system: p.sapSystem } : {}) });
115
+ const nextTo = handover ?? path.join(p.cwd, OUT_DIR, documentFilename(kind, meta.object, p.today));
116
+ return offerWordCopy({
117
+ nextTo, markdown, meta, headless: p.headless, wordCopy: p.wordCopy, confirm: p.confirm, emit: p.emit,
118
+ });
62
119
  }
@@ -1,9 +1,40 @@
1
1
  // cspeach-cli/src/projects/document-skills.ts
2
2
  //
3
- // The two document skills, on their own so the agent loop can check a skill
3
+ // The document skills, on their own so the agent loop can check a skill
4
4
  // name without loading document-save.ts (and the docx package behind it) on
5
5
  // every turn. Type-only import: nothing heavy loads from here.
6
+ //
7
+ // Word copy everywhere (2026-10-23, owner: "add this word feature everywhere"):
8
+ // - DOCUMENT_SKILLS: skills whose answer is a document a person keeps. After
9
+ // the turn: "Save this <noun> to a file?" (cspeach-out/<date>-<kind>-<OBJECT>.md),
10
+ // then the Word step. Review and handover were the first two (2026-10-19);
11
+ // the others join with `yieldsToArtifact`: when their answer carries a
12
+ // recognizable artefact block (a plan, a clean-core triage, …) the artefact
13
+ // save runs instead, exactly as before.
14
+ // - WORD_AFTER_ARTIFACT_SAVE: artefact saves (a .cspeach.json project file)
15
+ // whose answer is also a document. After "Save as project file?" says Yes,
16
+ // the same Word step runs. The project file and its save do not change.
6
17
  export const DOCUMENT_SKILLS = {
7
18
  'abap-review': { kind: 'review', noun: 'review' },
8
19
  'abap-handover': { kind: 'handover', noun: 'handover' },
20
+ 'abap-explain': { kind: 'explanation', noun: 'explanation', yieldsToArtifact: true },
21
+ 'abap-impact': { kind: 'impact', noun: 'impact analysis', yieldsToArtifact: true },
22
+ 'abap-preflight': { kind: 'preflight', noun: 'preflight check', yieldsToArtifact: true },
23
+ 'abap-transport-analysis': { kind: 'transport-analysis', noun: 'transport analysis', yieldsToArtifact: true },
24
+ 'abap-migrate': { kind: 'migration', noun: 'migration assessment', yieldsToArtifact: true },
25
+ 'abap-clean-core': { kind: 'clean-core', noun: 'clean-core assessment', yieldsToArtifact: true },
26
+ 'abap-cca': { kind: 'custom-code-analysis', noun: 'custom code analysis', yieldsToArtifact: true },
27
+ };
28
+ /**
29
+ * Artefact saves that also get the Word step (skill name as saved → kind).
30
+ * A plan's Word copy is made from its handover Markdown (written next to the
31
+ * plan file); the others from the answer the person read.
32
+ */
33
+ export const WORD_AFTER_ARTIFACT_SAVE = {
34
+ 'abap-spec-gap': 'spec-gap',
35
+ 'abap-design': 'design',
36
+ 'abap-estimate': 'estimate',
37
+ 'abap-plan': 'plan',
38
+ 'abap-upgrade-scan': 'upgrade-scan',
39
+ 'abap-upgrade-verify': 'upgrade-report',
9
40
  };
@@ -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 });
@@ -33,7 +33,7 @@ export const BUILTIN_ENTRIES = [
33
33
  { name: '/cost', get description() { return costCommandDescription(); } },
34
34
  { name: '/usage', get description() { return usageCommandDescription(); } },
35
35
  { name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
36
- { name: '/export', description: 'Export the session audit record (/export audit [filename]) or a Word copy of a saved file (/export docx <file>.md)' },
36
+ { name: '/export', description: 'Export the session audit record (/export audit [filename]) or a Word copy: of the last answer (/export docx, or /export docx <name>), of the whole conversation (/export docx conversation) or of a Markdown file (/export docx <file>.md)' },
37
37
  { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
38
38
  { name: '/exit', description: 'Exit CSPeach' },
39
39
  { name: '/quit', description: 'Exit CSPeach (alias)' },
@@ -25,6 +25,10 @@ const BUILTIN_COMMANDS = [
25
25
  '/usage',
26
26
  '/context',
27
27
  '/export',
28
+ // 2026-10-23: Tab after `/export ` offers the subcommands.
29
+ '/export audit',
30
+ '/export docx',
31
+ '/export docx conversation',
28
32
  '/exit',
29
33
  '/quit',
30
34
  '/help',
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';
@@ -206,6 +208,17 @@ import './tools/subagent/schedule_draft_create.js';
206
208
  import './tools/subagent/agent_run.js';
207
209
  import './tools/subagent/read_agent.js';
208
210
  import { folderTag, currentFolderKey } from './session/project-match.js';
211
+ import { bindCliCardSession } from './session/cli-record.js';
212
+ /** /export docx conversation (2026-10-23) — the origin of a turn CSPeach started itself. */
213
+ async function cliTurnOrigin(planResume, queuedCmd) {
214
+ if (planResume) {
215
+ const { planStepOriginLine } = await import('./commands/plan-continue.js');
216
+ return { userMessageOrigin: { kind: 'plan-step', line: planStepOriginLine(planResume) } };
217
+ }
218
+ if (queuedCmd)
219
+ return { userMessageOrigin: { kind: 'queued', line: `CSPeach ran the next step: ${queuedCmd.trim()}` } };
220
+ return {};
221
+ }
209
222
  const HISTORY_SIZE = 500;
210
223
  // Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
211
224
  // the single writer shared with the Ink Footer (Task 8, ux-wave1).
@@ -791,6 +804,8 @@ export async function runRepl(opts) {
791
804
  // served config), marks it 'predicted', saves, then stays pinned. Every
792
805
  // resume entry (picker, --resume <id>) lands here via session/resume.ts.
793
806
  const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg), folderTag(process.cwd()));
807
+ // /export docx conversation — the plan startup, lane and journey cards are recorded on this session.
808
+ bindCliCardSession(session);
794
809
  // Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
795
810
  let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
796
811
  // Round 7 item 8c — the footer shows a resumed session's spend at once.
@@ -1155,16 +1170,18 @@ export async function runRepl(opts) {
1155
1170
  // session audit record (who did what, where, under which approval, at
1156
1171
  // what cost) to a markdown file in cwd and print the path.
1157
1172
  if (trimmed === '/export' || trimmed.startsWith('/export ')) {
1158
- const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
1159
- const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
1173
+ const { parseExportArgs, runExportAuditCommand, EXPORT_REPL_USAGE } = await import('./commands/export-audit.js');
1174
+ const { sub, filename, docxArgs } = parseExportArgs(trimmed.slice('/export'.length));
1160
1175
  // Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
1176
+ // 2026-10-23: no file → the last answer; `conversation` → the whole conversation.
1177
+ // The Word package loads only here: /export audit never needs it.
1161
1178
  if (sub === 'docx') {
1162
- const { docxFileFromReplArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
1163
- await runExportDocxCommand({ file: docxFileFromReplArgs(trimmed.slice('/export'.length)), docx: true, system: session.sap_system, emit: (l) => chunkEmitter.emit('chunk', l + '\n') });
1179
+ const { runExportDocxRepl } = await import('./commands/export-conversation.js');
1180
+ await runExportDocxRepl({ args: docxArgs ?? '', session, ...(signedInAs ? { person: signedInAs } : {}), emit: (l) => chunkEmitter.emit('chunk', l + '\n') });
1164
1181
  return;
1165
1182
  }
1166
1183
  if (sub !== 'audit') {
1167
- chunkEmitter.emit('chunk', chalk.dim('\nUsage: /export audit [filename] · /export docx <file>.md\n'));
1184
+ chunkEmitter.emit('chunk', chalk.dim(`\n${EXPORT_REPL_USAGE}\n`));
1168
1185
  return;
1169
1186
  }
1170
1187
  await runExportAuditCommand({
@@ -1376,7 +1393,11 @@ export async function runRepl(opts) {
1376
1393
  todoEmitter.emit('update', []);
1377
1394
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1378
1395
  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')));
1396
+ // 2026-10-22 — and the conversation history: the next prompt sends none of it.
1397
+ startFreshConversation(session);
1398
+ idleKeepalive.disarm(); // no idle cache ping for the old history
1399
+ await saveSession(session);
1400
+ chunkEmitter.emit('chunk', '\n' + formatInfo(freshStartText(laneSetting)));
1380
1401
  return;
1381
1402
  }
1382
1403
  // /chat [on|off] (spec 2026-10-17 D6, owner amendment) — leave a workflow,
@@ -1925,8 +1946,8 @@ export async function runRepl(opts) {
1925
1946
  // prompt is NOT replaced and the context is NOT reset — "continue"
1926
1947
  // needs the accumulated transcript.
1927
1948
  let planContinue = null;
1928
- if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
1929
- const marker = session.plan_in_flight;
1949
+ const marker = planContinuationMarker(session, skill, planResume);
1950
+ if (marker) {
1930
1951
  const { preparePlanContinuation } = await import('./commands/plan-continue.js');
1931
1952
  const r = await preparePlanContinuation({
1932
1953
  marker,
@@ -2045,6 +2066,8 @@ export async function runRepl(opts) {
2045
2066
  // turns (their post-turn persist indexes messages from turn start).
2046
2067
  autoCompact: planResume === null && planContinue === null,
2047
2068
  delegateSkill: planPhaseDelegate,
2069
+ // /export docx conversation — a plan step or a queued step is CSPeach's words, not the person's.
2070
+ ...(await cliTurnOrigin(planResume, submitOpts?.queued ? line : null)),
2048
2071
  // A4 — undefined on every non-plan turn (and when tiering is off).
2049
2072
  modelOverride: planModelOverride,
2050
2073
  // Task 9 — a tier-down, never the session role (proxy must not enforce it).
@@ -3020,6 +3043,8 @@ export async function runRepl(opts) {
3020
3043
  // Task 13 — classic in-run hop flag: set by the loop-top consumer from planHops.take().
3021
3044
  let classicPlanHop = false;
3022
3045
  let classicLineBy = 'person';
3046
+ /** The command CSPeach queued for this line (null when the person typed it). */
3047
+ let classicQueuedCmd = null;
3023
3048
  while (true) {
3024
3049
  // Re-attach the bare-Esc detector every iteration. Inquirer prompts
3025
3050
  // (slash-picker, approval, ask_question, safety-confirm) remove
@@ -3036,6 +3061,7 @@ export async function runRepl(opts) {
3036
3061
  const classicQueued = pendingDispatchAccessor.take();
3037
3062
  // Task 3 — who started this line: the CSPeach source that queued it, else the person.
3038
3063
  classicLineBy = classicQueued?.origin?.by ?? 'person';
3064
+ classicQueuedCmd = classicQueued?.origin ? classicQueued.cmd : null;
3039
3065
  if (classicQueued) {
3040
3066
  const dispatchedCmd = classicQueued.cmd;
3041
3067
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
@@ -3191,16 +3217,18 @@ export async function runRepl(opts) {
3191
3217
  // Task 4 (ux-wave3) — classic-mode /export audit [filename]. Mirrors the
3192
3218
  // Ink handler: build the signable audit record, write it to cwd, print path.
3193
3219
  if (trimmed === '/export' || trimmed.startsWith('/export ')) {
3194
- const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
3195
- const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
3220
+ const { parseExportArgs, runExportAuditCommand, EXPORT_REPL_USAGE } = await import('./commands/export-audit.js');
3221
+ const { sub, filename, docxArgs } = parseExportArgs(trimmed.slice('/export'.length));
3196
3222
  // Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
3223
+ // 2026-10-23: no file → the last answer; `conversation` → the whole conversation.
3224
+ // The Word package loads only here: /export audit never needs it.
3197
3225
  if (sub === 'docx') {
3198
- const { docxFileFromReplArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
3199
- await runExportDocxCommand({ file: docxFileFromReplArgs(trimmed.slice('/export'.length)), docx: true, system: session.sap_system, emit: (l) => console.log(l) });
3226
+ const { runExportDocxRepl } = await import('./commands/export-conversation.js');
3227
+ await runExportDocxRepl({ args: docxArgs ?? '', session, ...(signedInAs ? { person: signedInAs } : {}), emit: (l) => console.log(l) });
3200
3228
  continue;
3201
3229
  }
3202
3230
  if (sub !== 'audit') {
3203
- console.log(chalk.dim('Usage: /export audit [filename] · /export docx <file>.md'));
3231
+ console.log(chalk.dim(EXPORT_REPL_USAGE));
3204
3232
  continue;
3205
3233
  }
3206
3234
  await runExportAuditCommand({
@@ -3397,7 +3425,11 @@ export async function runRepl(opts) {
3397
3425
  session.todos = []; // P2 — /new clears the plan (spec §4)
3398
3426
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
3399
3427
  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());
3428
+ // 2026-10-22 — and the conversation history: the next prompt sends none of it.
3429
+ startFreshConversation(session);
3430
+ idleKeepalive.disarm(); // no idle cache ping for the old history
3431
+ await saveSession(session);
3432
+ console.log(formatInfo(freshStartText(laneSetting)).trimEnd());
3401
3433
  continue;
3402
3434
  }
3403
3435
  // /chat [on|off] — classic mirror of the Ink handler above.
@@ -3990,8 +4022,8 @@ export async function runRepl(opts) {
3990
4022
  // of the Ink branch; see there for the full design note: mutually
3991
4023
  // exclusive with --resume, prompt NOT replaced, context NOT reset).
3992
4024
  let planContinue = null;
3993
- if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
3994
- const marker = session.plan_in_flight;
4025
+ const marker = planContinuationMarker(session, skill, planResume);
4026
+ if (marker) {
3995
4027
  const { preparePlanContinuation } = await import('./commands/plan-continue.js');
3996
4028
  const r = await preparePlanContinuation({
3997
4029
  marker,
@@ -4123,6 +4155,8 @@ export async function runRepl(opts) {
4123
4155
  // turns (their post-turn persist indexes messages from turn start).
4124
4156
  autoCompact: planResume === null && planContinue === null,
4125
4157
  delegateSkill: planPhaseDelegate,
4158
+ // /export docx conversation — a plan step or a queued step is CSPeach's words, not the person's.
4159
+ ...(await cliTurnOrigin(planResume, classicQueuedCmd)),
4126
4160
  // A4 — undefined on every non-plan turn (and when tiering is off).
4127
4161
  modelOverride: planModelOverride,
4128
4162
  // Task 9 — a tier-down, never the session role (proxy must not enforce it).
@@ -0,0 +1,165 @@
1
+ // cspeach-cli/src/session/cli-record.ts
2
+ //
3
+ // /export docx conversation, fix round 1 (2026-10-23). Two small records kept
4
+ // on the session for the conversation export, NEVER sent to the model:
5
+ //
6
+ // - cli_messages: which user-role messages CSPeach wrote itself (a plan step
7
+ // prompt, the output-limit continuation, the watchdog note, a /compact or
8
+ // plan-boundary summary, a queued next step, a mid-turn steer). Each entry is
9
+ // the sha of the message content, not an index: compaction drops and moves
10
+ // messages, a content hash still finds the same message. The messages
11
+ // themselves are untouched, so every model request stays byte-identical.
12
+ // - cli_cards: the CLI's own cards that matter for a change ticket (the plan
13
+ // gate "Build this now?", the plan startup "Continue", the lane card, the
14
+ // journey cards), anchored by turn_seq. Never the save or Word cards.
15
+ //
16
+ // Both are optional session fields. A session file is JSON.parse'd and written
17
+ // whole (store.ts), so an older CLI that resumes the file keeps them as they are.
18
+ import crypto from 'node:crypto';
19
+ export const CLI_RECORD_CAP = 500;
20
+ export const CONTINUE_LINE = 'CSPeach asked the model to continue a long answer.';
21
+ export const WATCHDOG_LINE = 'CSPeach asked the model to wrap up a long turn.';
22
+ export const COMPACT_LINE = 'Earlier turns were summarised to keep the session small.';
23
+ export const PLAN_BOUNDARY_LINE = 'CSPeach summarised the earlier plan steps.';
24
+ function shaOf(content) {
25
+ return crypto.createHash('sha256').update(typeof content === 'string' ? content : JSON.stringify(content ?? null)).digest('hex').slice(0, 32);
26
+ }
27
+ function capped(list) {
28
+ return list.length > CLI_RECORD_CAP ? list.slice(list.length - CLI_RECORD_CAP) : list;
29
+ }
30
+ /** Note that CSPeach wrote this user-role message. Never throws. */
31
+ export function markCliMessage(session, content, kind, line) {
32
+ if (!session)
33
+ return;
34
+ try {
35
+ const s = session;
36
+ s.cli_messages = capped([...(s.cli_messages ?? []), { sha: shaOf(content), kind, line }]);
37
+ }
38
+ catch { /* the record is best-effort */ }
39
+ }
40
+ /**
41
+ * Fixed-shape CLI texts, for sessions written before the record existed (or
42
+ * a mark the cap dropped). Prefixes only; the bodies are CSPeach's own words.
43
+ */
44
+ const KNOWN_PREFIXES = [
45
+ { prefix: 'Resume execution of the project plan', kind: 'plan-step', line: 'CSPeach ran the next plan step.' },
46
+ { prefix: 'Your previous response was cut off at the output-token limit.', kind: 'continue', line: CONTINUE_LINE },
47
+ { prefix: '[CSPeach watchdog]', kind: 'watchdog', line: WATCHDOG_LINE },
48
+ { prefix: '[Context summary auto-generated', kind: 'compact', line: COMPACT_LINE },
49
+ { prefix: '[Summary of the earlier steps of this plan', kind: 'plan-boundary', line: PLAN_BOUNDARY_LINE },
50
+ ];
51
+ function prefixMark(message) {
52
+ const text = typeof message.content === 'string' ? message.content
53
+ : Array.isArray(message.content) ? message.content.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n') : '';
54
+ const t = text.trimStart();
55
+ const known = KNOWN_PREFIXES.find((k) => t.startsWith(k.prefix));
56
+ return known ? { sha: '', kind: known.kind, line: known.line } : undefined;
57
+ }
58
+ /**
59
+ * The mark of each message (undefined for the developer's own words), in
60
+ * message order. Fix round 2 (m-8): each mark is used once, in order — a
61
+ * message takes the first unused mark at or after the last one used that has
62
+ * its sha. So a command the developer retypes after CSPeach queued it, or a
63
+ * typed "continue" equal to an earlier steer, stays the developer's: that mark
64
+ * is already used. Marks of messages a compaction dropped are simply passed
65
+ * over. The prefix fallback is only for sessions with no records at all.
66
+ */
67
+ export function resolveCliMarks(session, messages) {
68
+ const marks = session?.cli_messages;
69
+ if (!marks || marks.length === 0)
70
+ return messages.map((m) => (m.role === 'user' ? prefixMark(m) : undefined));
71
+ // Final-review fix (I-1): used once, any order. Look from the last mark used
72
+ // on; when nothing matches there, look again from the start (a file an older
73
+ // CLI compacted has the summary's mark last). A used mark is never reused.
74
+ const used = new Set();
75
+ let next = 0;
76
+ return messages.map((m) => {
77
+ if (m.role !== 'user')
78
+ return undefined;
79
+ const sha = shaOf(m.content);
80
+ const at = (from, to) => {
81
+ for (let j = from; j < to; j++)
82
+ if (!used.has(j) && marks[j].sha === sha)
83
+ return j;
84
+ return -1;
85
+ };
86
+ let j = at(next, marks.length);
87
+ if (j < 0)
88
+ j = at(0, Math.min(next, marks.length));
89
+ if (j < 0)
90
+ return undefined;
91
+ used.add(j);
92
+ next = j + 1;
93
+ return marks[j];
94
+ });
95
+ }
96
+ /**
97
+ * Final-review fix (I-1): after a compaction keeps `kept` (the tail of the old
98
+ * messages, from index `keepFrom`) behind a new summary message, rebuild the
99
+ * record in message order: the summary's mark first, then the marks of the
100
+ * kept messages (recomputed from their content, so a mark found by the prefix
101
+ * fallback in an older file becomes a real one). Marks of dropped messages go.
102
+ */
103
+ export function rebuildCliMarksAfterCompact(session, oldMessages, keepFrom, summaryContent, summary) {
104
+ try {
105
+ const resolved = resolveCliMarks(session, oldMessages);
106
+ const kept = [];
107
+ for (let i = keepFrom; i < oldMessages.length; i++) {
108
+ const m = resolved[i];
109
+ if (m)
110
+ kept.push({ sha: shaOf(oldMessages[i].content), kind: m.kind, line: m.line });
111
+ }
112
+ session.cli_messages = capped([{ sha: shaOf(summaryContent), kind: summary.kind, line: summary.line }, ...kept]);
113
+ }
114
+ catch { /* the record is best-effort */ }
115
+ }
116
+ /** A turn of its own (the developer's prompt, a plan step, a queued step) vs. a message inside a turn. */
117
+ export function startsTurn(mark) {
118
+ return !mark || mark.kind === 'plan-step' || mark.kind === 'queued';
119
+ }
120
+ /* ── cards ────────────────────────────────────────────────────────────── */
121
+ let bound = null;
122
+ /** The REPL (and each top-level turn) binds the session the cards belong to. */
123
+ export function bindCliCardSession(session) { bound = session; }
124
+ /**
125
+ * The card ids the card slot records for the export: plan startup, lane,
126
+ * journey. Never save or Word cards. "Build this now?" (app-start-build) is
127
+ * recorded by afterPlanSave itself, which knows the plan file and whether the
128
+ * object-list approval was installed (fix round 2, N-1).
129
+ */
130
+ export function isRecordedCard(id) {
131
+ return id === 'plan-continue' || id === 'app-lane' || id === 'app-lane-runtime' || id.startsWith('journey-');
132
+ }
133
+ /** Record one card on the bound session (or the one given), with the time. Never throws. */
134
+ export function recordCliCard(card, session = bound) {
135
+ if (!session)
136
+ return;
137
+ try {
138
+ const s = session;
139
+ const entry = { turn_seq: s.turn_seq ?? 0, at: new Date().toISOString(), ...card };
140
+ s.cli_cards = capped([...(s.cli_cards ?? []), entry]);
141
+ }
142
+ catch { /* the record is best-effort */ }
143
+ }
144
+ /* ── once-per-object document offers ──────────────────────────────────── */
145
+ /**
146
+ * Per session (in memory): the (skill, object) keys the newer document skills
147
+ * already offered "Save…?" for. Kept here, beside the records, so /new can
148
+ * clear it (final-review m-4).
149
+ */
150
+ const documentOffers = new WeakMap();
151
+ export function documentOffersFor(session) {
152
+ let s = documentOffers.get(session);
153
+ if (!s) {
154
+ s = new Set();
155
+ documentOffers.set(session, s);
156
+ }
157
+ return s;
158
+ }
159
+ /** /new: the once-per-object offers and the records of the cleared conversation go too. */
160
+ export function clearConversationRecords(session) {
161
+ documentOffers.delete(session);
162
+ const s = session;
163
+ delete s.cli_messages;
164
+ delete s.cli_cards;
165
+ }
@@ -0,0 +1,19 @@
1
+ import { clearConversationRecords } from './cli-record.js';
2
+ export function startFreshConversation(session) {
3
+ session.messages = [];
4
+ session.turnInterrupted = false;
5
+ session.turnInterruptedReason = undefined;
6
+ delete session.last_context_tokens;
7
+ delete session.lastAutoCompactTurn;
8
+ // An open plan step does not survive: "continue" must not resume it.
9
+ session.plan_in_flight = null;
10
+ // Word export (final-review m-4): the once-per-object save offers and the
11
+ // export records describe the cleared conversation; they go with it.
12
+ clearConversationRecords(session);
13
+ }
14
+ /** The one line /new prints. */
15
+ export function freshStartText(lane) {
16
+ return lane === 'chat'
17
+ ? 'Fresh start · the conversation is cleared.'
18
+ : 'Fresh start · the conversation is cleared · the next prompt picks a skill again';
19
+ }
@@ -45,6 +45,13 @@ export const BLOCKED_BASENAME_RE = /^\.env(\.|$)/i;
45
45
  * (skill detail files) — only the rest of `.cspeach/` is blocked.
46
46
  */
47
47
  export const DENYLIST_DESCRIPTION = [...BLOCKED_PREFIXES, '.env*'].join(', ');
48
+ /**
49
+ * The paging trailer of file_read and read_document (also refused in SAP
50
+ * writes: tools/cut-markers.ts).
51
+ */
52
+ export function rereadTrailer(more, nextOffset) {
53
+ return `... (truncated, ${more} more lines — re-read with offset=${nextOffset})`;
54
+ }
48
55
  /**
49
56
  * Executable / script file extensions the filesystem write tools refuse to
50
57
  * create. Defense-in-depth: shell_exec / background_run only run a safelisted