@cspeach/cli 0.6.0
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.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { dispatchTool } from './tool-dispatch.js';
|
|
4
|
+
import { listTools, toAnthropicTools } from '../tools/index.js';
|
|
5
|
+
import { saveSession } from '../session/store.js';
|
|
6
|
+
import { loadConfig } from '../config/loader.js';
|
|
7
|
+
import { retryWithBackoff } from './retry.js';
|
|
8
|
+
import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js';
|
|
9
|
+
import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner } from '../renderer/tool-widget.js';
|
|
10
|
+
import { startThinkingHeartbeat } from '../renderer/thinking-heartbeat.js';
|
|
11
|
+
// (progress-chatter import removed 2026-05-01 — superseded by CC-style
|
|
12
|
+
// two-line dispatch renderer; re-add if a future in-place spinner returns)
|
|
13
|
+
import { ERR } from '../errors/codes.js';
|
|
14
|
+
import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
|
|
15
|
+
import { enrichUserMessage } from '../router/intent-extractor.js';
|
|
16
|
+
import { resetRule8State } from '../repl/rule8-detector.js';
|
|
17
|
+
import { runSaveCommand, runPromoteCommand } from '../projects/index.js';
|
|
18
|
+
import { parseFromFlag } from '../skills/promotion-dispatch.js';
|
|
19
|
+
import { input } from '@inquirer/prompts';
|
|
20
|
+
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
21
|
+
import { expandTextFileAttachments } from '../projects/workspace.js';
|
|
22
|
+
import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
|
|
23
|
+
import { objectKeyFromInput } from './retry-key.js';
|
|
24
|
+
import { repairPartialBlocks } from './repair-partial.js';
|
|
25
|
+
import { TurnStreamWriter } from './turn-stream.js';
|
|
26
|
+
import { applyToolResultCheckpoint } from './skill-checkpoint.js';
|
|
27
|
+
import { loadWatchdogConfig, evaluateWatchdog } from './turn-watchdog.js';
|
|
28
|
+
import { maybeBuildProjectContext } from './maybe-build-project-context.js';
|
|
29
|
+
import { buildSapConnectionContext } from './sap-connection-adapter.js';
|
|
30
|
+
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
31
|
+
import { collectTurnAssistantText } from './turn-assistant-text.js';
|
|
32
|
+
/**
|
|
33
|
+
* Author identity for project-file metadata. Reads CSPEACH_AUTHOR_NAME first,
|
|
34
|
+
* then platform USER/USERNAME, then a generic fallback. Role is fixed to
|
|
35
|
+
* 'consultant' until we add multi-role support.
|
|
36
|
+
*
|
|
37
|
+
* Exported for unit testing.
|
|
38
|
+
*/
|
|
39
|
+
export function getAuthorIdentity() {
|
|
40
|
+
const name = process.env.CSPEACH_AUTHOR_NAME ??
|
|
41
|
+
process.env.USER ??
|
|
42
|
+
process.env.USERNAME ??
|
|
43
|
+
'consultant';
|
|
44
|
+
return { name, role: 'consultant' };
|
|
45
|
+
}
|
|
46
|
+
// v0.5: design + estimate join the save-hook eligibility list. The save flow
|
|
47
|
+
// itself is artefact-agnostic — runSaveCommand dispatches by skill name via
|
|
48
|
+
// SKILL_REGISTRY and emits the right envelope shape.
|
|
49
|
+
const SAVE_HOOK_SKILLS = new Set([
|
|
50
|
+
'abap-spec-gap',
|
|
51
|
+
'abap-design',
|
|
52
|
+
'abap-estimate',
|
|
53
|
+
// 2026-05-08: upgrade pipeline manifest envelopes. Each emits a
|
|
54
|
+
// <!-- csforge:upgrade-manifest --> block that the matching
|
|
55
|
+
// extract-upgrade.* parser turns into the manifest envelope. The
|
|
56
|
+
// heavy detail (per-finding data) stays in `.abapforge/upgrades/`,
|
|
57
|
+
// referenced by detail_path inside the envelope.
|
|
58
|
+
'abap-upgrade-scan',
|
|
59
|
+
'abap-upgrade-fix',
|
|
60
|
+
'abap-upgrade-verify',
|
|
61
|
+
'abap-upgrade-merge',
|
|
62
|
+
// 2026-05-09: CCA pipeline manifest envelopes. /abap-cca emits a
|
|
63
|
+
// <!-- csforge:cca-manifest --> block wrapping the heavy project.json
|
|
64
|
+
// detail at .abapforge/cca/projects/<slug>/. /abap-cca-merge produces
|
|
65
|
+
// a consolidated cca-assessment from N parallel consultant slices.
|
|
66
|
+
'abap-cca',
|
|
67
|
+
'abap-cca-merge',
|
|
68
|
+
// 2026-05-10: v0.7 trio chain. /abap-modernize emits modernize-result;
|
|
69
|
+
// /abap-test emits test-coverage. Both accept cca-assessment via --from
|
|
70
|
+
// and can fan out into further chains after their own envelope saves.
|
|
71
|
+
'abap-modernize',
|
|
72
|
+
'abap-test',
|
|
73
|
+
]);
|
|
74
|
+
/**
|
|
75
|
+
* True when any user message in the session already carries the rendered
|
|
76
|
+
* <project_context> block. Phase 2 uses this to inject the block exactly
|
|
77
|
+
* once per session, to the first user turn that doesn't have it.
|
|
78
|
+
*
|
|
79
|
+
* Why a content scan rather than a turn counter:
|
|
80
|
+
*
|
|
81
|
+
* - On `cspeach --resume`, session.messages is rehydrated from disk
|
|
82
|
+
* with the prior process's injected block already present. A
|
|
83
|
+
* per-process counter would re-inject; the content scan correctly
|
|
84
|
+
* short-circuits.
|
|
85
|
+
*
|
|
86
|
+
* - tool_result entries push `role: 'user'` with array-shaped content.
|
|
87
|
+
* The `typeof === 'string'` check skips those without iterating
|
|
88
|
+
* multimodal blocks.
|
|
89
|
+
*
|
|
90
|
+
* Exported for unit testing only — the call site is one floor up in runTurn.
|
|
91
|
+
*/
|
|
92
|
+
export function hasProjectContextBlock(messages) {
|
|
93
|
+
for (const m of messages) {
|
|
94
|
+
if (m.role !== 'user')
|
|
95
|
+
continue;
|
|
96
|
+
if (typeof m.content === 'string' && m.content.includes('<project_context')) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
export async function maybeOfferSave(p) {
|
|
103
|
+
if (!SAVE_HOOK_SKILLS.has(p.skill))
|
|
104
|
+
return;
|
|
105
|
+
if (p.assistantText.trim().length === 0)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
await runSaveCommand({
|
|
109
|
+
skillOutput: p.assistantText,
|
|
110
|
+
skillName: p.skill,
|
|
111
|
+
skillVersion: '1.0',
|
|
112
|
+
skillInput: p.userMessage,
|
|
113
|
+
tokensUsed: p.tokensUsed,
|
|
114
|
+
model: p.model,
|
|
115
|
+
author: getAuthorIdentity(),
|
|
116
|
+
cwd: process.cwd(),
|
|
117
|
+
prompt: async (q) => withInquirer(() => input({ message: q })),
|
|
118
|
+
log: (...lines) => lines.forEach((l) => p.emit(l)),
|
|
119
|
+
promotedFrom: p.promotedFrom ?? null,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
p.emit(chalk.yellow(`\n[save] skipped: ${err?.message ?? err}`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function runTurn(params) {
|
|
127
|
+
resetRule8State(); // Rule 8 — fresh batch counter per LLM turn (= per user prompt)
|
|
128
|
+
const cfg = await loadConfig();
|
|
129
|
+
const session = params.ctx.session;
|
|
130
|
+
// Define `emit` early so the Phase J promote dispatch (below) can stream
|
|
131
|
+
// its prompt + status lines through the same Ink-aware sink the rest of
|
|
132
|
+
// runTurn uses. Originally defined later in the function — hoisted in v0.5
|
|
133
|
+
// so --from interactivity sits above the LLM stream setup.
|
|
134
|
+
function emit(line) {
|
|
135
|
+
if (params.chunkEmitter)
|
|
136
|
+
params.chunkEmitter.emit('chunk', line + '\n');
|
|
137
|
+
else
|
|
138
|
+
console.log(line);
|
|
139
|
+
}
|
|
140
|
+
// Phase J — `--from @<source>` phase promotion.
|
|
141
|
+
// If the user typed `/abap-design --from @./spec-gap.cspeach.json` (or the
|
|
142
|
+
// estimate equivalent), strip the flag, validate + snapshot the source,
|
|
143
|
+
// prepend a "Promoted from..." block to the LLM input, and stash the
|
|
144
|
+
// snapshot for the post-turn save hook so the saved envelope's
|
|
145
|
+
// promotedFrom field is populated.
|
|
146
|
+
let promotedFromForSave = null;
|
|
147
|
+
let userMessageForLLM = params.userMessage;
|
|
148
|
+
let userMessageForSave = params.userMessage;
|
|
149
|
+
{
|
|
150
|
+
const { fromPath, rest } = parseFromFlag(params.userMessage);
|
|
151
|
+
if (fromPath) {
|
|
152
|
+
const result = await runPromoteCommand({
|
|
153
|
+
sourcePath: fromPath,
|
|
154
|
+
targetSkill: params.skill,
|
|
155
|
+
prompt: async (q) => withInquirer(() => input({ message: q })),
|
|
156
|
+
log: (...lines) => lines.forEach((l) => emit(l)),
|
|
157
|
+
});
|
|
158
|
+
if (!result) {
|
|
159
|
+
// User declined, source invalid, or unsupported target — skip the turn.
|
|
160
|
+
emit(chalk.yellow('\n[--from] aborted; turn cancelled.'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
promotedFromForSave = result.promotedFrom;
|
|
164
|
+
// 2026-05-08: thread the original @<filename> token into the LLM
|
|
165
|
+
// prompt so skills that need to dispatch a follow-up command (e.g.
|
|
166
|
+
// dispatch_skill calling /abap-rap --from @<same-file>) can use the
|
|
167
|
+
// exact filename. Previously the filename was consumed by
|
|
168
|
+
// parseFromFlag and never reached the model — skills had to guess
|
|
169
|
+
// and frequently dropped the unique-id segment ("foo-d515-v1" →
|
|
170
|
+
// "foo-v1"), producing "no file matching" auto-routes. Use basename
|
|
171
|
+
// so absolute / relative paths normalise to the workspace @<token>
|
|
172
|
+
// form the picker resolves.
|
|
173
|
+
const sourceToken = `@${basename(fromPath)}`;
|
|
174
|
+
userMessageForLLM = `Source file: ${sourceToken}\n${result.extendedSkillInput}\n\n---\n\n${rest}`;
|
|
175
|
+
userMessageForSave = rest;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Phase D — `@<text-file>` attachment expansion.
|
|
179
|
+
// After --from has consumed its own @<token>, scan what's left for
|
|
180
|
+
// `@<filename>` references that resolve to .txt / .md files in the
|
|
181
|
+
// workspace. Substitute each with an <attached file="..."> block
|
|
182
|
+
// containing the file content so the skill sees the user's
|
|
183
|
+
// requirement document as part of the prompt. .cspeach.json
|
|
184
|
+
// envelopes are NOT expanded here (they go through --from / --status).
|
|
185
|
+
// The save copy keeps the original `@<filename>` token rather than
|
|
186
|
+
// the expanded body so the saved envelope's source.input stays
|
|
187
|
+
// human-readable; only the LLM sees the inflated prose.
|
|
188
|
+
userMessageForLLM = await expandTextFileAttachments(userMessageForLLM, params.ctx.cwd, (line) => emit(chalk.dim(line)));
|
|
189
|
+
// 2026-05-06: alongside the deterministic prompt-fact extraction
|
|
190
|
+
// (package / transport / etc.), inject the connected SAP system's
|
|
191
|
+
// release + platform + ABAP version. One CVERS query the first time
|
|
192
|
+
// we see an alias; cached in-memory + on disk thereafter (~30-day
|
|
193
|
+
// TTL). Skills like /abap-spec-gap stop asking "are we on S/4HANA?"
|
|
194
|
+
// when the answer is already in the prompt.
|
|
195
|
+
// Failure mode: getSapSystemInfo returns null on any error. The
|
|
196
|
+
// SAP block is then empty and the rest of enrichment proceeds as
|
|
197
|
+
// before — no behavioral regression.
|
|
198
|
+
let sapSystemBlock = '';
|
|
199
|
+
let sapSystemInfo = null;
|
|
200
|
+
if (params.ctx.adt && params.ctx.sapAlias) {
|
|
201
|
+
sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
|
|
202
|
+
sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
|
|
203
|
+
}
|
|
204
|
+
// Deterministically extract structured facts from the user's prompt
|
|
205
|
+
// (package, transport, object names, environment hint, create intent)
|
|
206
|
+
// and prepend them as an XML `<session_context>` block. The model reads
|
|
207
|
+
// these as structured data — eliminates the "skill kept asking about X
|
|
208
|
+
// when user said X" failure class without relying on prose rules.
|
|
209
|
+
// See src/router/intent-extractor.ts for the rationale.
|
|
210
|
+
//
|
|
211
|
+
// Enrich on every turn, not just the first: a classifier / reroute flow
|
|
212
|
+
// may have pushed a user message into session.messages before we get
|
|
213
|
+
// here, which would defeat a "first turn only" check. Skip only when the
|
|
214
|
+
// message already begins with <session_context> (caller enriched it) or
|
|
215
|
+
// when extraction yields nothing (enrichUserMessage returns original).
|
|
216
|
+
const enriched = userMessageForLLM.startsWith('<session_context>')
|
|
217
|
+
? userMessageForLLM
|
|
218
|
+
: enrichUserMessage(userMessageForLLM, sapSystemBlock);
|
|
219
|
+
// Phase 2 of Track B — opt-in project-context enrichment, gated by
|
|
220
|
+
// CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
|
|
221
|
+
// identical to today. When on, the rendered <project_context> block
|
|
222
|
+
// (file index, conventions, domain objects, git state) is prepended
|
|
223
|
+
// ONCE per session — to the first user message that doesn't already
|
|
224
|
+
// carry one. The hasProjectContextBlock guard scans session.messages
|
|
225
|
+
// for the literal block; this is what makes `cspeach --resume` correct
|
|
226
|
+
// (the rehydrated messages already contain the block from the prior
|
|
227
|
+
// process; the helper short-circuits cleanly). Helper handles the
|
|
228
|
+
// env-var gate, walk, and per-process cache.
|
|
229
|
+
let projectContextBlock = '';
|
|
230
|
+
if (!hasProjectContextBlock(session.messages)) {
|
|
231
|
+
// Phase 2.1 — when an alias is active, assemble the
|
|
232
|
+
// SapConnectionContext so the rendered project_context includes a
|
|
233
|
+
// populated <sap_connection> sub-block. The adapter is best-effort:
|
|
234
|
+
// missing values become null. recentObjects stays empty until the
|
|
235
|
+
// tool dispatch layer captures last-N targets (Phase 2.2+).
|
|
236
|
+
const sapConnection = params.ctx.sapAlias
|
|
237
|
+
? buildSapConnectionContext(params.ctx.sapAlias, sapSystemInfo, {
|
|
238
|
+
currentTransport: getCurrentTransport() ?? null,
|
|
239
|
+
})
|
|
240
|
+
: null;
|
|
241
|
+
projectContextBlock = await maybeBuildProjectContext({
|
|
242
|
+
cwd: params.ctx.cwd,
|
|
243
|
+
sapConnection,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const finalUserContent = projectContextBlock
|
|
247
|
+
? `${projectContextBlock}\n\n${enriched}`
|
|
248
|
+
: enriched;
|
|
249
|
+
session.messages.push({ role: 'user', content: finalUserContent });
|
|
250
|
+
session.skill = params.skill;
|
|
251
|
+
/**
|
|
252
|
+
* key: `${tool_name}:${object}` — consecutive failures per (tool, target).
|
|
253
|
+
* For object-identifying tools (sap_set_source / sap_activate / etc.) the
|
|
254
|
+
* target is `${name}:${type}`. For tools without an object identity in
|
|
255
|
+
* their args (sap_sql_query / sap_search_object), the target falls back
|
|
256
|
+
* to a stable hash of the args — so different probes/queries are NOT
|
|
257
|
+
* counted as retries of each other. See ./retry-key.ts for the rationale.
|
|
258
|
+
*/
|
|
259
|
+
const failMap = new Map();
|
|
260
|
+
const RETRY_CAP = 3;
|
|
261
|
+
// Snapshot output tokens at turn start so the post-turn save hook can
|
|
262
|
+
// report the per-turn delta (not the lifetime cumulative). usage may be
|
|
263
|
+
// missing on older session schemas; the loop initializes it before the
|
|
264
|
+
// first stream event, so 0 is a safe pre-init baseline.
|
|
265
|
+
const turnStartOutputTokens = session.usage?.output_tokens ?? 0;
|
|
266
|
+
// Snapshot session.messages.length at turn start so the save hook can
|
|
267
|
+
// walk every assistant message added during this turn — not just the
|
|
268
|
+
// final one. Skills like /abap-cca emit their manifest in an early
|
|
269
|
+
// message before a closing ask_question widget; the final wrap-up
|
|
270
|
+
// message ends the turn but doesn't carry the manifest. See
|
|
271
|
+
// ./turn-assistant-text.ts for the full rationale.
|
|
272
|
+
const turnStartMessageCount = session.messages.length;
|
|
273
|
+
// v0.6 — mirror assistant text to ~/.cspeach/streams/<sessionId>/<ts>.md
|
|
274
|
+
// so a renderer crash or terminal close never loses the on-screen prose.
|
|
275
|
+
// The graceful-error UX (T19) prints this path on any failed turn.
|
|
276
|
+
const turnStreamWriter = new TurnStreamWriter(session.id);
|
|
277
|
+
// v0.6 Layer 3 — long-turn watchdog. Tracked across the while-loop's
|
|
278
|
+
// tool-call rounds. The advisory fires AT MOST ONCE per turn between
|
|
279
|
+
// rounds so it doesn't spam the model. See agent/turn-watchdog.ts.
|
|
280
|
+
const watchdogConfig = loadWatchdogConfig();
|
|
281
|
+
const turnStartedAt = Date.now();
|
|
282
|
+
let watchdogWarned = false;
|
|
283
|
+
// (emit was hoisted to the top of the function in v0.5 so the Phase J
|
|
284
|
+
// promote dispatch could share it. Original location was here.)
|
|
285
|
+
while (true) {
|
|
286
|
+
let buffer = initialBufferState();
|
|
287
|
+
let stream;
|
|
288
|
+
// 2026-05-06: paint a "thinking" spinner during the LLM round-trip
|
|
289
|
+
// wait so the user sees activity instead of staring at a silent
|
|
290
|
+
// prompt. The function disables itself when a chunkEmitter is
|
|
291
|
+
// provided (it would otherwise race with the chunkEmitter renderer's
|
|
292
|
+
// own paint loop), so call it without one — the spinner writes
|
|
293
|
+
// directly to stdout. Stopped on the first content_block_start (so
|
|
294
|
+
// the response stream paints in its place) and on any error path
|
|
295
|
+
// below.
|
|
296
|
+
const thinkingSpinner = startThinkingSpinner({});
|
|
297
|
+
// v0.6 — guaranteed-visible heartbeat alongside the in-place spinner.
|
|
298
|
+
// The spinner self-disables on non-TTY (Windows PowerShell sometimes
|
|
299
|
+
// reports isTTY=false; resume mode also lost spinner visibility) so a
|
|
300
|
+
// 45-second wait looked like a hang. The heartbeat prints fresh lines
|
|
301
|
+
// at 3s/10s/30s/60s/90s/2m/3m/5m thresholds — works on any terminal.
|
|
302
|
+
const thinkingHeartbeat = startThinkingHeartbeat();
|
|
303
|
+
try {
|
|
304
|
+
stream = await retryWithBackoff(() => (async () => {
|
|
305
|
+
const tools = toAnthropicTools(listTools());
|
|
306
|
+
const streamParams = {
|
|
307
|
+
model: cfg.default_model,
|
|
308
|
+
// v0.3.1 — was 8192. Bumped because code-gen skills (abap-generate
|
|
309
|
+
// etc.) kept running out of budget mid-turn: adaptive thinking +
|
|
310
|
+
// multiple tool-result prompts + TL;DR mandate + summary prose
|
|
311
|
+
// + the final question widget didn't fit in 8K. Symptom: stream
|
|
312
|
+
// ended with stop_reason='length' right before the widget, and
|
|
313
|
+
// the user saw the skill "stuck" after the intro prose.
|
|
314
|
+
// 32768 is Opus 4.7's recommended ceiling for agentic turns and
|
|
315
|
+
// gives comfortable headroom for the investigate-first pattern.
|
|
316
|
+
max_tokens: params.maxTokensOverride ?? 32768,
|
|
317
|
+
messages: session.messages,
|
|
318
|
+
tools,
|
|
319
|
+
thinking: { type: 'adaptive', display: 'summarized' },
|
|
320
|
+
};
|
|
321
|
+
// Skill travels as a header per coordination doc §1.
|
|
322
|
+
// Do NOT add `skill` to streamParams — it is not part of the Anthropic SDK body schema.
|
|
323
|
+
// v0.6 — forward the per-turn abort signal so Esc / SIGINT-during-turn
|
|
324
|
+
// tears down the in-flight stream cleanly.
|
|
325
|
+
return params.provider.createStream(streamParams, {
|
|
326
|
+
headers: { 'X-CSForge-Skill': params.skill },
|
|
327
|
+
signal: params.signal,
|
|
328
|
+
});
|
|
329
|
+
})());
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
// Stop the thinking spinner before printing any error — leaving
|
|
333
|
+
// it spinning while an error message is emitted looks broken.
|
|
334
|
+
thinkingSpinner.stop();
|
|
335
|
+
thinkingHeartbeat.stop();
|
|
336
|
+
// Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
|
|
337
|
+
const status = err?.status ?? err?.response?.status;
|
|
338
|
+
const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
|
|
339
|
+
const body = typeof bodyRaw === 'string' ? JSON.parse(bodyRaw) : bodyRaw;
|
|
340
|
+
if (status === 409 && body?.error === 'upgrade_required') {
|
|
341
|
+
emit(chalk.yellow.bold(`\n⚠ This skill requires cspeach >= ${body.min_cli_version} (you have ${body.current ?? 'unknown'}).`));
|
|
342
|
+
emit(chalk.yellow(` Run: cspeach --upgrade`));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
let currentAssistantContent = [];
|
|
348
|
+
let sawEndTurn = false;
|
|
349
|
+
let sawToolUse = false;
|
|
350
|
+
// M10 — ensure session.usage is present (shipped session schema may omit it
|
|
351
|
+
// for older saved sessions; we default to zero on first turn).
|
|
352
|
+
if (!session.usage) {
|
|
353
|
+
session.usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
|
|
354
|
+
}
|
|
355
|
+
// H2 — Anthropic's `message_delta.usage.output_tokens` is CUMULATIVE for the
|
|
356
|
+
// current message (it grows monotonically as the model streams). Naively
|
|
357
|
+
// doing `session.usage.output_tokens += deltaUsage.output_tokens` on every
|
|
358
|
+
// delta event multi-counts the same tokens N times. The correct pattern is
|
|
359
|
+
// to track the last-seen value per-message and add only the increment. This
|
|
360
|
+
// scratch is reset on every `message_start`.
|
|
361
|
+
//
|
|
362
|
+
// Reference: https://docs.anthropic.com/en/api/messages-streaming (see
|
|
363
|
+
// `message_delta` section — `usage.output_tokens` is "the cumulative number
|
|
364
|
+
// of output tokens generated so far for this message").
|
|
365
|
+
let messageOutputTokensSoFar = 0; // cumulative for the CURRENT message
|
|
366
|
+
// v0.6 resilience — capture any error thrown by the provider stream so we
|
|
367
|
+
// can persist whatever the model already emitted before the crash. The old
|
|
368
|
+
// behaviour was to let the exception propagate straight out of the for-await,
|
|
369
|
+
// skipping the assistant-message persistence below entirely. That's how a
|
|
370
|
+
// 77-minute /abap-cca turn with 200K tokens of output ended up entirely
|
|
371
|
+
// discarded when Undici reported the HTTP stream as "terminated".
|
|
372
|
+
let interruptedError = null;
|
|
373
|
+
try {
|
|
374
|
+
for await (const event of stream) {
|
|
375
|
+
const type = event.type;
|
|
376
|
+
if (type === 'content_block_start') {
|
|
377
|
+
// First content event of this stream — stop the thinking spinner
|
|
378
|
+
// so the response paints in its place. Idempotent: subsequent
|
|
379
|
+
// content_block_start events (for follow-on text/tool blocks)
|
|
380
|
+
// call stop on an already-stopped handle, which is a no-op.
|
|
381
|
+
thinkingSpinner.stop();
|
|
382
|
+
thinkingHeartbeat.stop();
|
|
383
|
+
const block = event.content_block;
|
|
384
|
+
currentAssistantContent.push(block);
|
|
385
|
+
if (block.type === 'text') {
|
|
386
|
+
const sep = chalk.gray('\n');
|
|
387
|
+
if (params.chunkEmitter)
|
|
388
|
+
params.chunkEmitter.emit('chunk', sep);
|
|
389
|
+
else
|
|
390
|
+
process.stdout.write(sep);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else if (type === 'content_block_delta') {
|
|
394
|
+
const delta = event.delta;
|
|
395
|
+
const last = currentAssistantContent[currentAssistantContent.length - 1];
|
|
396
|
+
if (delta.type === 'text_delta') {
|
|
397
|
+
const { output, state } = renderChunk(delta.text, buffer);
|
|
398
|
+
buffer = state;
|
|
399
|
+
if (output) {
|
|
400
|
+
if (params.chunkEmitter)
|
|
401
|
+
params.chunkEmitter.emit('chunk', output);
|
|
402
|
+
else
|
|
403
|
+
process.stdout.write(output);
|
|
404
|
+
}
|
|
405
|
+
if (last?.type === 'text')
|
|
406
|
+
last.text = (last.text ?? '') + delta.text;
|
|
407
|
+
// v0.6 — also write the raw model text to disk so the user can
|
|
408
|
+
// recover the prose if the terminal renderer crashes or the turn
|
|
409
|
+
// aborts mid-stream. Best-effort fire-and-forget.
|
|
410
|
+
void turnStreamWriter.append(delta.text);
|
|
411
|
+
}
|
|
412
|
+
else if (delta.type === 'input_json_delta') {
|
|
413
|
+
if (last)
|
|
414
|
+
last.partial_json = (last.partial_json ?? '') + delta.partial_json;
|
|
415
|
+
}
|
|
416
|
+
else if (delta.type === 'thinking_delta') {
|
|
417
|
+
// Adaptive thinking streams content here. Must be preserved on the
|
|
418
|
+
// assistant message so the next turn doesn't reject the block with
|
|
419
|
+
// "each thinking block must contain thinking".
|
|
420
|
+
if (last?.type === 'thinking')
|
|
421
|
+
last.thinking = (last.thinking ?? '') + delta.thinking;
|
|
422
|
+
}
|
|
423
|
+
else if (delta.type === 'signature_delta') {
|
|
424
|
+
// Thinking blocks may carry a signature used for server-side verification.
|
|
425
|
+
if (last?.type === 'thinking')
|
|
426
|
+
last.signature = (last.signature ?? '') + delta.signature;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else if (type === 'content_block_stop') {
|
|
430
|
+
const last = currentAssistantContent[currentAssistantContent.length - 1];
|
|
431
|
+
if (last?.type === 'tool_use' && last.partial_json) {
|
|
432
|
+
try {
|
|
433
|
+
last.input = JSON.parse(last.partial_json);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
last.input = {};
|
|
437
|
+
}
|
|
438
|
+
delete last.partial_json;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
else if (type === 'message_start') {
|
|
442
|
+
// message_start carries the prompt usage (input tokens + any cache reads).
|
|
443
|
+
const msgUsage = event.message?.usage;
|
|
444
|
+
if (msgUsage) {
|
|
445
|
+
session.usage.input_tokens += msgUsage.input_tokens ?? 0;
|
|
446
|
+
session.usage.cache_read_input_tokens += msgUsage.cache_read_input_tokens ?? 0;
|
|
447
|
+
}
|
|
448
|
+
// New message begins — reset the per-message cumulative-output scratch.
|
|
449
|
+
messageOutputTokensSoFar = 0;
|
|
450
|
+
}
|
|
451
|
+
else if (type === 'message_delta') {
|
|
452
|
+
const stop_reason = event.delta?.stop_reason;
|
|
453
|
+
if (stop_reason === 'end_turn')
|
|
454
|
+
sawEndTurn = true;
|
|
455
|
+
if (stop_reason === 'tool_use')
|
|
456
|
+
sawToolUse = true;
|
|
457
|
+
// H2 — `deltaUsage.output_tokens` is the cumulative running total for
|
|
458
|
+
// THIS message, NOT a per-event delta. Compute the increment vs the
|
|
459
|
+
// last-seen cumulative and add only that. Guard against `null` /
|
|
460
|
+
// missing-field and against monotonic-decrease edge cases.
|
|
461
|
+
const deltaUsage = event.usage;
|
|
462
|
+
if (deltaUsage?.output_tokens != null) {
|
|
463
|
+
const inc = deltaUsage.output_tokens - messageOutputTokensSoFar;
|
|
464
|
+
if (inc > 0) {
|
|
465
|
+
session.usage.output_tokens += inc;
|
|
466
|
+
messageOutputTokensSoFar = deltaUsage.output_tokens;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
// Provider stream broke mid-flight (Undici "terminated" / network drop /
|
|
474
|
+
// AbortController / Anthropic stream timeout). DO NOT lose the partial
|
|
475
|
+
// assistant content — capture the error and fall through to the persist
|
|
476
|
+
// block below so whatever the model already streamed lands on disk.
|
|
477
|
+
interruptedError = err;
|
|
478
|
+
thinkingSpinner.stop();
|
|
479
|
+
thinkingHeartbeat.stop();
|
|
480
|
+
}
|
|
481
|
+
// Final flush: emit any pending partial line after the stream ends.
|
|
482
|
+
// Runs on both clean completion and mid-stream interruption.
|
|
483
|
+
if (buffer.pending.length > 0) {
|
|
484
|
+
const flushed = render(buffer.pending);
|
|
485
|
+
if (params.chunkEmitter)
|
|
486
|
+
params.chunkEmitter.emit('chunk', flushed);
|
|
487
|
+
else
|
|
488
|
+
process.stdout.write(flushed);
|
|
489
|
+
buffer = initialBufferState();
|
|
490
|
+
}
|
|
491
|
+
// Repair partial blocks before persistence — drop incomplete tool_use
|
|
492
|
+
// fragments so the next API call doesn't reject the assistant message.
|
|
493
|
+
// See ./repair-partial.ts for the full rationale.
|
|
494
|
+
if (interruptedError !== null) {
|
|
495
|
+
currentAssistantContent = repairPartialBlocks(currentAssistantContent);
|
|
496
|
+
}
|
|
497
|
+
// Persist the assistant message — even if it's partial. Empty content
|
|
498
|
+
// arrays are skipped so we don't push a meaningless empty assistant
|
|
499
|
+
// message that would confuse the next round.
|
|
500
|
+
if (currentAssistantContent.length > 0) {
|
|
501
|
+
session.messages.push({ role: 'assistant', content: currentAssistantContent });
|
|
502
|
+
}
|
|
503
|
+
session.last_turn_at = new Date().toISOString();
|
|
504
|
+
if (interruptedError !== null) {
|
|
505
|
+
session.turnInterrupted = true;
|
|
506
|
+
const msg = interruptedError instanceof Error ? interruptedError.message : String(interruptedError);
|
|
507
|
+
session.turnInterruptedReason = msg.slice(0, 200);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
// Clean completion — clear any stale interrupted flag from a prior turn.
|
|
511
|
+
session.turnInterrupted = false;
|
|
512
|
+
session.turnInterruptedReason = undefined;
|
|
513
|
+
}
|
|
514
|
+
await saveSession(session);
|
|
515
|
+
// Close the stream-to-disk mirror with a footer (lightweight diagnostics).
|
|
516
|
+
// Best-effort — failure here is invisible to the turn flow.
|
|
517
|
+
void turnStreamWriter.close(interruptedError !== null
|
|
518
|
+
? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
|
|
519
|
+
: undefined);
|
|
520
|
+
// Re-throw AFTER persistence so the outer REPL catch can render the
|
|
521
|
+
// graceful error UX with full session-id + log-path context.
|
|
522
|
+
if (interruptedError !== null)
|
|
523
|
+
throw interruptedError;
|
|
524
|
+
if (sawEndTurn) {
|
|
525
|
+
emit('');
|
|
526
|
+
// v0.6 Layer 3 — stream-end watchdog warning. The between-rounds
|
|
527
|
+
// watchdog in the loop body only fires for multi-round turns (heavy
|
|
528
|
+
// tool calls). A single-round text-only emission (no tool calls)
|
|
529
|
+
// bypasses that path entirely, so a model can stream past the budget
|
|
530
|
+
// unchecked. We can't inject a synthetic advisory at this point —
|
|
531
|
+
// the turn is over — but we CAN warn the user that the turn went
|
|
532
|
+
// long, so they tighten the next prompt or disable the watchdog
|
|
533
|
+
// for genuinely-long-by-design analyses.
|
|
534
|
+
if (!watchdogWarned) {
|
|
535
|
+
const outputTokensThisTurn = Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens);
|
|
536
|
+
const decision = evaluateWatchdog({
|
|
537
|
+
startedAt: turnStartedAt,
|
|
538
|
+
outputTokensThisTurn,
|
|
539
|
+
config: watchdogConfig,
|
|
540
|
+
alreadyWarned: false,
|
|
541
|
+
});
|
|
542
|
+
if (decision.shouldWarn) {
|
|
543
|
+
emit(chalk.yellow(`[watchdog] Turn ran past budget: ${decision.reason}.`));
|
|
544
|
+
emit(chalk.gray(` Consider a more focused prompt next time, or set CSPEACH_TURN_WATCHDOG=off if`));
|
|
545
|
+
emit(chalk.gray(` this skill is genuinely long-by-design (e.g. /abap-cca on a large estate).`));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// v0.3.1 — if the last assistant message ended with a question marker
|
|
549
|
+
// ("Reply with your answer..." or <!-- widget-real -->), set the flag
|
|
550
|
+
// so the REPL routes the user's next prompt back to this same skill
|
|
551
|
+
// without re-classifying. Otherwise clear the flag so normal
|
|
552
|
+
// classification resumes.
|
|
553
|
+
session.awaitingSkillAnswer = lastAssistantIsAwaitingAnswer(session.messages);
|
|
554
|
+
await saveSession(session);
|
|
555
|
+
// Handover/takeover hook (Task C5):
|
|
556
|
+
// After certain skills (currently /abap-spec-gap) render their final
|
|
557
|
+
// answer, offer to save it as a project file. The hook runs AFTER the
|
|
558
|
+
// response is rendered (emit('') above flushed) and BEFORE returning,
|
|
559
|
+
// so the user sees the answer first, then is asked whether to save.
|
|
560
|
+
// It awaits the user's y/N reply before returning — interactive, not
|
|
561
|
+
// a background block on the next REPL prompt.
|
|
562
|
+
//
|
|
563
|
+
// 2026-05-12: collect text from ALL assistant messages added during
|
|
564
|
+
// this turn, not just the final one. Skills like /abap-cca emit
|
|
565
|
+
// their manifest in an early message before a closing ask_question
|
|
566
|
+
// widget; the final wrap-up message ends the turn but doesn't
|
|
567
|
+
// carry the manifest. See ./turn-assistant-text.ts for details.
|
|
568
|
+
const assistantText = collectTurnAssistantText(session.messages, turnStartMessageCount);
|
|
569
|
+
await maybeOfferSave({
|
|
570
|
+
skill: params.skill,
|
|
571
|
+
assistantText,
|
|
572
|
+
userMessage: userMessageForSave,
|
|
573
|
+
tokensUsed: Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens),
|
|
574
|
+
model: cfg.default_model ?? session.model ?? 'unknown',
|
|
575
|
+
emit,
|
|
576
|
+
promotedFrom: promotedFromForSave,
|
|
577
|
+
});
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (!sawToolUse) {
|
|
581
|
+
// Unexpected stop reason — bail to avoid infinite loop.
|
|
582
|
+
emit(chalk.yellow('\n[unexpected stop reason — ending turn]'));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
// Dispatch each tool_use block.
|
|
586
|
+
const toolResults = [];
|
|
587
|
+
for (const block of currentAssistantContent) {
|
|
588
|
+
if (block.type === 'tool_use') {
|
|
589
|
+
// Phase 1: print the dispatch line (CC-style: ⏺ name(args)).
|
|
590
|
+
renderToolCallTop({
|
|
591
|
+
name: block.name,
|
|
592
|
+
args: (block.input ?? {}),
|
|
593
|
+
chunkEmitter: params.chunkEmitter,
|
|
594
|
+
});
|
|
595
|
+
// Phase 2a: start the peach-themed spinner. No-op outside TTY / Ink
|
|
596
|
+
// / CI — the result line still prints, just without the in-place
|
|
597
|
+
// animation. Spinner is purely a "still working" cue, not
|
|
598
|
+
// load-bearing for output.
|
|
599
|
+
const spinner = startToolSpinner({ chunkEmitter: params.chunkEmitter });
|
|
600
|
+
// Phase 2b: dispatch (may take 100ms–several seconds for write tools).
|
|
601
|
+
const dispatchStart = Date.now();
|
|
602
|
+
const result = await dispatchTool(block.name, block.input, params.ctx);
|
|
603
|
+
const durationMs = Date.now() - dispatchStart;
|
|
604
|
+
// Phase 2c: stop the spinner, which erases its line so the result
|
|
605
|
+
// row paints in place of it (no scrollback artifacts).
|
|
606
|
+
spinner.stop();
|
|
607
|
+
// Phase 3: print result line ( ⎿ ✓ summary · timing).
|
|
608
|
+
const resultSummary = result.is_error
|
|
609
|
+
? (typeof result.content === 'string' ? result.content.slice(0, 80) : 'error')
|
|
610
|
+
: undefined;
|
|
611
|
+
renderToolCallBottom({
|
|
612
|
+
durationMs,
|
|
613
|
+
isError: result.is_error ?? false,
|
|
614
|
+
resultSummary,
|
|
615
|
+
chunkEmitter: params.chunkEmitter,
|
|
616
|
+
});
|
|
617
|
+
// v0.3 (Step 28.0): populate session.toolCalls so the SessionTimeline
|
|
618
|
+
// overlay can render per-write history. Cap per-entry payload size at
|
|
619
|
+
// 4kB to bound session.json growth.
|
|
620
|
+
const completedAt = new Date().toISOString();
|
|
621
|
+
{
|
|
622
|
+
const resultText = typeof result.content === 'string'
|
|
623
|
+
? result.content
|
|
624
|
+
: JSON.stringify(result.content);
|
|
625
|
+
session.toolCalls.push({
|
|
626
|
+
tool_use_id: block.id,
|
|
627
|
+
tool: block.name,
|
|
628
|
+
args: block.input ?? {},
|
|
629
|
+
result: resultText.slice(0, 4_000),
|
|
630
|
+
is_error: result.is_error ?? false,
|
|
631
|
+
duration_ms: durationMs,
|
|
632
|
+
completed_at: completedAt,
|
|
633
|
+
completed: true,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
// Layer 2 — skill-level checkpoint. Always-on JSONL audit log of every
|
|
637
|
+
// tool call, plus optional skill-specific handlers (registered via
|
|
638
|
+
// skill-checkpoint.ts). Best-effort; failures are swallowed and never
|
|
639
|
+
// break a working turn. See agent/skill-checkpoint.ts for rationale.
|
|
640
|
+
void applyToolResultCheckpoint({
|
|
641
|
+
sessionId: session.id,
|
|
642
|
+
skill: session.skill,
|
|
643
|
+
toolName: block.name,
|
|
644
|
+
args: block.input ?? {},
|
|
645
|
+
result: result.content,
|
|
646
|
+
isError: result.is_error ?? false,
|
|
647
|
+
durationMs,
|
|
648
|
+
completedAt,
|
|
649
|
+
});
|
|
650
|
+
// Retry-cap: track consecutive failures per (tool, object) pair.
|
|
651
|
+
// This block runs BEFORE the normal toolResults.push below.
|
|
652
|
+
const objKey = objectKeyFromInput(block.name, block.input ?? {});
|
|
653
|
+
if (result.is_error) {
|
|
654
|
+
const count = (failMap.get(objKey) ?? 0) + 1;
|
|
655
|
+
failMap.set(objKey, count);
|
|
656
|
+
if (count >= RETRY_CAP) {
|
|
657
|
+
// Cap hit — push the error result, save session, and abort the turn.
|
|
658
|
+
toolResults.push({
|
|
659
|
+
type: 'tool_result',
|
|
660
|
+
tool_use_id: block.id,
|
|
661
|
+
is_error: true,
|
|
662
|
+
content: JSON.stringify({
|
|
663
|
+
error: ERR.APPROVAL_THRASH,
|
|
664
|
+
detail: `Retry cap hit after ${RETRY_CAP} consecutive failures on ${objKey}. Aborting turn.`,
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
session.messages.push({ role: 'user', content: toolResults });
|
|
668
|
+
await saveSession(session);
|
|
669
|
+
emit(chalk.red(`\n[retry cap] ${objKey} failed ${RETRY_CAP} times — aborting turn.`));
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
failMap.delete(objKey); // reset on success
|
|
675
|
+
}
|
|
676
|
+
// Normal path falls through: the existing `toolResults.push(...)` block
|
|
677
|
+
// immediately below handles both success and the not-yet-capped failure case.
|
|
678
|
+
toolResults.push({
|
|
679
|
+
type: 'tool_result',
|
|
680
|
+
tool_use_id: block.id,
|
|
681
|
+
content: result.content,
|
|
682
|
+
is_error: result.is_error,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
session.messages.push({ role: 'user', content: toolResults });
|
|
687
|
+
await saveSession(session);
|
|
688
|
+
// v0.6 Layer 3 — between-rounds watchdog check. If the turn has been
|
|
689
|
+
// running too long (wall-clock or token budget), inject a synthetic
|
|
690
|
+
// user message asking the model to checkpoint + summarise + hand back
|
|
691
|
+
// to the user. The model decides whether to wrap up or push through.
|
|
692
|
+
{
|
|
693
|
+
const outputTokensThisTurn = Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens);
|
|
694
|
+
const decision = evaluateWatchdog({
|
|
695
|
+
startedAt: turnStartedAt,
|
|
696
|
+
outputTokensThisTurn,
|
|
697
|
+
config: watchdogConfig,
|
|
698
|
+
alreadyWarned: watchdogWarned,
|
|
699
|
+
});
|
|
700
|
+
if (decision.shouldWarn) {
|
|
701
|
+
watchdogWarned = true;
|
|
702
|
+
emit(chalk.yellow(`\n[watchdog] ${decision.reason}. Asking the model to wrap up.`));
|
|
703
|
+
session.messages.push({ role: 'user', content: decision.message });
|
|
704
|
+
await saveSession(session);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Loop continues → next messages.create with the tool results.
|
|
708
|
+
}
|
|
709
|
+
}
|