@cspeach/cli 1.1.18 → 1.1.20
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/README.md +3 -4
- package/dist/agent/anthropic-provider.js +30 -10
- package/dist/agent/cache-keepalive.js +162 -0
- package/dist/agent/cold-prune.js +116 -0
- package/dist/agent/loop.js +804 -159
- package/dist/agent/provider-shape.js +263 -0
- package/dist/agent/providers/ai-hub-provider.js +17 -2
- package/dist/agent/providers/byok-provider.js +33 -3
- package/dist/agent/providers/local-provider.js +12 -2
- package/dist/agent/repair-partial.js +125 -7
- package/dist/agent/summarise-via-provider.js +6 -1
- package/dist/agent/system-prompt.js +38 -0
- package/dist/agent/tool-dispatch.js +66 -21
- package/dist/agent/tool-loading-pin.js +100 -0
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +36 -6
- package/dist/commands/auto-compact.js +33 -16
- package/dist/commands/compact.js +49 -11
- package/dist/commands/config-set.js +16 -3
- package/dist/commands/config-show.js +18 -5
- package/dist/commands/cost.js +54 -35
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-audit.js +1 -0
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +70 -5
- package/dist/cost/cost-log.js +62 -2
- package/dist/cost/pricing.js +11 -6
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lib/spill-labels.js +13 -0
- package/dist/lock-contention.js +2 -2
- package/dist/models/resolve.js +93 -2
- package/dist/models/server-config.js +158 -3
- package/dist/one-shot.js +150 -45
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/image-attachments.js +15 -2
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +100 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +154 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +269 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +55 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/current-transport.js +13 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +43 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +863 -339
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/repin-model.js +18 -0
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +84 -28
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/preamble.js +75 -0
- package/dist/skills/source-manifest.js +11 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-read.js +11 -1
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/result-spill.js +238 -0
- package/dist/tools/sap-read.js +74 -15
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +39 -12
- package/dist/tools/subagent/adt-serial.js +33 -0
- package/dist/tools/subagent/agent_run.js +2 -0
- package/dist/tools/subagent/read_agent.js +178 -0
- package/dist/tools/subagent/reader-prompt.js +48 -0
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +26 -19
- package/dist/tools/tool-loading.js +255 -0
- package/dist/tools/tool-output-read.js +117 -0
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +39 -8
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +2 -1
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +65 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +12 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { toAnthropicTools } from './index.js';
|
|
2
|
+
/** The server-side tool search tool (probe P6: this exact name). */
|
|
3
|
+
export const TOOL_SEARCH_TOOL = Object.freeze({
|
|
4
|
+
type: 'tool_search_tool_regex_20251119',
|
|
5
|
+
name: 'tool_search_tool_regex',
|
|
6
|
+
});
|
|
7
|
+
/** Beta that allows `role: 'system'` tool_addition messages (and rides with deferred tools). */
|
|
8
|
+
export const TOOL_CHANGES_BETA = 'mid-conversation-tool-changes-2026-07-01';
|
|
9
|
+
/**
|
|
10
|
+
* Spec D11 — always loaded. Names missing from the list a build is given
|
|
11
|
+
* (flag-gated `agent_run` / file tools when their flag is off; every SAP tool
|
|
12
|
+
* in standalone) are skipped, never an error (ruling F25). `read_agent`
|
|
13
|
+
* (Task 18) is core because the preamble's heavy-reading rule names it.
|
|
14
|
+
*/
|
|
15
|
+
export const CORE_TOOLS = Object.freeze([
|
|
16
|
+
'ask_question',
|
|
17
|
+
'todo_set',
|
|
18
|
+
'request_approval',
|
|
19
|
+
'dispatch_skill',
|
|
20
|
+
'agent_run',
|
|
21
|
+
'read_agent',
|
|
22
|
+
'file_read',
|
|
23
|
+
'tool_output_read',
|
|
24
|
+
'file_write',
|
|
25
|
+
'shell_exec',
|
|
26
|
+
'sap_get_source',
|
|
27
|
+
'sap_search_object',
|
|
28
|
+
'sap_object_structure',
|
|
29
|
+
'sap_sql_query',
|
|
30
|
+
'sap_syntax_check',
|
|
31
|
+
'sap_set_source',
|
|
32
|
+
'sap_update_method',
|
|
33
|
+
'sap_activate',
|
|
34
|
+
'sap_inactive_objects',
|
|
35
|
+
'sap_create_object',
|
|
36
|
+
]);
|
|
37
|
+
const FIORI_FAMILY = [
|
|
38
|
+
'fiori_apply', 'fiori_deploy_config', 'fiori_draft_smoke', 'fiori_fe_extend',
|
|
39
|
+
'fiori_list', 'fiori_metadata_audit', 'fiori_preview', 'fiori_render_smoke',
|
|
40
|
+
'fiori_sample_get', 'fiori_sample_search', 'fiori_scaffold', 'fiori_scaffold_fe',
|
|
41
|
+
];
|
|
42
|
+
const TRANSPORT_FAMILY = [
|
|
43
|
+
'sap_transport_create', 'sap_transport_for_object', 'sap_transport_list', 'sap_transport_release',
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Per-skill starter tools: the non-core tools a skill's body names as its
|
|
47
|
+
* working set (counted from `.claude/skills/<skill>/SKILL.md`). A tool left
|
|
48
|
+
* out is still reachable through tool search — a starter only saves the
|
|
49
|
+
* search round. Skills not listed start with the core set alone.
|
|
50
|
+
*/
|
|
51
|
+
export const SKILL_STARTERS = Object.freeze({
|
|
52
|
+
'abap-fiori-build': FIORI_FAMILY,
|
|
53
|
+
'abap-transport': TRANSPORT_FAMILY,
|
|
54
|
+
'abap-transport-analysis': [...TRANSPORT_FAMILY, 'sap_atc_run', 'sap_usage_references'],
|
|
55
|
+
'abap-cap': ['fiori_scaffold_fe', 'fiori_render_smoke', 'fiori_draft_smoke'],
|
|
56
|
+
'abap-atc-fix': ['sap_atc_run'],
|
|
57
|
+
'abap-cca': ['sap_atc_run', 'sap_usage_references', 'sap_api_state'],
|
|
58
|
+
'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references'],
|
|
59
|
+
'abap-data-model': ['sap_create_domain', 'sap_create_data_element'],
|
|
60
|
+
'abap-document': ['sap_usage_references'],
|
|
61
|
+
'abap-handover': ['sap_usage_references'],
|
|
62
|
+
'abap-impact': ['sap_usage_references', 'sap_atc_run'],
|
|
63
|
+
'abap-performance': ['sap_usage_references', 'sap_atc_run'],
|
|
64
|
+
'abap-migrate': ['sap_usage_references'],
|
|
65
|
+
'abap-dump': ['sap_short_dump', 'sap_snapshot_take'],
|
|
66
|
+
'abap-incident': ['sap_short_dump', 'sap_snapshot_take', 'sap_atc_run', 'sap_run_unit_test', 'sap_api_state', 'sap_diff'],
|
|
67
|
+
'abap-eml': ['sap_run_unit_test'],
|
|
68
|
+
'abap-test': ['sap_run_unit_test', 'sap_set_class_include'],
|
|
69
|
+
'abap-extend-model': ['extend_model_insert', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_set_class_include'],
|
|
70
|
+
'abap-rap': ['sap_set_class_include', 'sap_service_binding_publish', 'sap_class_includes'],
|
|
71
|
+
'abap-modernize': ['sap_api_state', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_atc_run', 'sap_transport_create'],
|
|
72
|
+
'abap-segw': ['sap_snapshot_take', 'sap_odata_test'],
|
|
73
|
+
'abap-upgrade-fix': ['sap_snapshot_take', 'glob'],
|
|
74
|
+
'abap-upgrade-scan': ['sap_atc_run'],
|
|
75
|
+
'abap-upgrade-verify': ['sap_atc_run'],
|
|
76
|
+
});
|
|
77
|
+
/** The starter tools for a skill; unknown skill (or none) ⇒ []. */
|
|
78
|
+
export function startersForSkill(skill) {
|
|
79
|
+
if (!skill)
|
|
80
|
+
return [];
|
|
81
|
+
const s = SKILL_STARTERS[skill];
|
|
82
|
+
return s ? [...s] : [];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Final review C1 (controller ruling, 2026-09-28) — ALLOW-list of the model
|
|
86
|
+
* families the deferred shapes (defer_loading entries, the tool search tool,
|
|
87
|
+
* `role: 'system'` tool_addition messages) were probe-proven on (P6, D1, D3:
|
|
88
|
+
* claude-opus-5 and claude-opus-5-5). Same matching as the other allow-lists
|
|
89
|
+
* in agent/provider-shape.ts (lowercased, trailing -YYYYMMDD stripped). Every
|
|
90
|
+
* other model — claude-opus-4-8 (the session default until the global flip),
|
|
91
|
+
* the claude-sonnet-4-6 plan/audit tier, haiku — gets the static list. The
|
|
92
|
+
* loop decides by the model actually sent on each call.
|
|
93
|
+
*/
|
|
94
|
+
export const DEFERRED_TOOL_MODELS = new Set([
|
|
95
|
+
'claude-opus-5-5', 'claude-opus-5',
|
|
96
|
+
]);
|
|
97
|
+
export function modelAcceptsDeferredTools(model) {
|
|
98
|
+
if (typeof model !== 'string')
|
|
99
|
+
return false;
|
|
100
|
+
return DEFERRED_TOOL_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Mode precedence (ruling F13): AI-hub and local are always 'static' (their
|
|
104
|
+
* wire has no deferred loading). Otherwise env `CSPEACH_TOOL_LOADING`
|
|
105
|
+
* (`deferred` | `static`) wins; then managed uses the served value (default
|
|
106
|
+
* 'static': only a proxy that serves tool_loading takes the new shapes);
|
|
107
|
+
* BYOK uses the served value when it fetched one, else 'static'.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveToolLoading(i) {
|
|
110
|
+
if (i.providerMode === 'ai-hub' || i.providerMode === 'local')
|
|
111
|
+
return 'static';
|
|
112
|
+
const raw = i.env.CSPEACH_TOOL_LOADING?.trim().toLowerCase();
|
|
113
|
+
if (raw === 'deferred' || raw === 'static')
|
|
114
|
+
return raw;
|
|
115
|
+
// Task 21 fix round (controller ruling): an old proxy serves no tool_loading
|
|
116
|
+
// and adds none of the deferred-tool betas, so 'deferred' only when served.
|
|
117
|
+
if (i.providerMode === 'managed')
|
|
118
|
+
return i.served ?? 'static';
|
|
119
|
+
if (i.providerMode === 'byok')
|
|
120
|
+
return i.served ?? 'static';
|
|
121
|
+
// Unknown/fake provider modes keep today's full list.
|
|
122
|
+
return 'static';
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Build the `tools[]` array. 'static' ⇒ exactly today's list
|
|
126
|
+
* (toAnthropicTools). 'deferred' ⇒ loaded entries (core ∪ starters, in
|
|
127
|
+
* registry order, byte-identical to today's entries), then every other tool
|
|
128
|
+
* with `defer_loading: true` (registry order), then the tool search tool —
|
|
129
|
+
* the search tool only when at least one entry is deferred.
|
|
130
|
+
*/
|
|
131
|
+
export function buildToolList(tools, opts) {
|
|
132
|
+
if (opts.mode === 'static')
|
|
133
|
+
return toAnthropicTools(tools);
|
|
134
|
+
const loadedNames = new Set([...CORE_TOOLS, ...startersForSkill(opts.skill)]);
|
|
135
|
+
const loaded = tools.filter((t) => loadedNames.has(t.name));
|
|
136
|
+
const deferred = tools.filter((t) => !loadedNames.has(t.name));
|
|
137
|
+
// Final review I1 — nothing deferred ⇒ nothing to search: no search tool
|
|
138
|
+
// (plan audit's `toolFilter: () => false` means "no tools at all" ⇒ []).
|
|
139
|
+
if (deferred.length === 0)
|
|
140
|
+
return toAnthropicTools(loaded);
|
|
141
|
+
return [
|
|
142
|
+
...toAnthropicTools(loaded),
|
|
143
|
+
...toAnthropicTools(deferred).map((t) => ({ ...t, defer_loading: true })),
|
|
144
|
+
{ ...TOOL_SEARCH_TOOL },
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
/** Names of the entries a built list declares with `defer_loading: true`. */
|
|
148
|
+
export function deferredNames(toolList) {
|
|
149
|
+
const out = new Set();
|
|
150
|
+
for (const t of toolList) {
|
|
151
|
+
if (t && t.defer_loading === true && typeof t.name === 'string')
|
|
152
|
+
out.add(t.name);
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
/** True for the tool search entry of a built list (the one predicate; provider-shape re-exports it). */
|
|
157
|
+
export function isToolSearchEntry(t) {
|
|
158
|
+
return !!t && typeof t === 'object' && t.type === TOOL_SEARCH_TOOL.type;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Probe D2 (2026-09-27): the API rejects a `role: 'system'` message followed
|
|
162
|
+
* by a user message — "role 'system' must precede an 'assistant' message or
|
|
163
|
+
* end the array" (400). A tool_addition pushed after a tool-result message
|
|
164
|
+
* can be followed by a user message when the next round adds one first (mid-
|
|
165
|
+
* turn steering, the watchdog nudge) or when the turn ended before the model
|
|
166
|
+
* answered (refusal, interruption) and the next turn's prompt is pushed, and
|
|
167
|
+
* a later skill switch can then append a second tool_addition.
|
|
168
|
+
*
|
|
169
|
+
* Only the TAIL after the last assistant message is ever touched (the whole
|
|
170
|
+
* array when there is none): it holds only user and system messages, none of
|
|
171
|
+
* which an assistant message has followed yet, so no thinking block's prefix
|
|
172
|
+
* changes (at most the uncached tail is re-written). The tail is stably
|
|
173
|
+
* partitioned: user messages first (in order), then the system messages'
|
|
174
|
+
* blocks (in order) merged into ONE system message that ends the array — the
|
|
175
|
+
* shape probe D3 proved, its next round included. Merging avoids a
|
|
176
|
+
* [system, system] run, whose acceptance was never probed; one system message
|
|
177
|
+
* with several tool_addition blocks is the shape the CLI already sends for a
|
|
178
|
+
* skill's starters. Anything before the last assistant message is left
|
|
179
|
+
* exactly as it is. Mutates `messages`; returns the number of system messages
|
|
180
|
+
* moved or merged. Idempotent: a second pass is a no-op.
|
|
181
|
+
*/
|
|
182
|
+
export function placeSystemMessagesInPlace(messages) {
|
|
183
|
+
if (!messages)
|
|
184
|
+
return 0;
|
|
185
|
+
let tailStart = messages.length;
|
|
186
|
+
while (tailStart > 0 && messages[tailStart - 1]?.role !== 'assistant')
|
|
187
|
+
tailStart--;
|
|
188
|
+
const tail = messages.slice(tailStart);
|
|
189
|
+
const users = tail.filter((m) => m?.role !== 'system');
|
|
190
|
+
const systems = tail.filter((m) => m?.role === 'system');
|
|
191
|
+
if (systems.length === 0)
|
|
192
|
+
return 0;
|
|
193
|
+
// Already in shape: users, then exactly one system message last.
|
|
194
|
+
if (systems.length === 1 && tail[tail.length - 1] === systems[0])
|
|
195
|
+
return 0;
|
|
196
|
+
const merged = {
|
|
197
|
+
role: 'system',
|
|
198
|
+
content: systems.flatMap((m) => (Array.isArray(m.content) ? m.content : [])),
|
|
199
|
+
};
|
|
200
|
+
messages.splice(tailStart, tail.length, ...users, merged);
|
|
201
|
+
return systems.length;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A `role: 'system'` message surfacing deferred tools by reference. Names not
|
|
205
|
+
* in `deferredSet` (loaded already, or unknown in this build) are dropped;
|
|
206
|
+
* nothing left ⇒ null.
|
|
207
|
+
*/
|
|
208
|
+
export function buildToolAdditionMessage(names, deferredSet) {
|
|
209
|
+
const keep = [...new Set(names)].filter((n) => deferredSet.has(n));
|
|
210
|
+
if (keep.length === 0)
|
|
211
|
+
return null;
|
|
212
|
+
return {
|
|
213
|
+
role: 'system',
|
|
214
|
+
content: keep.map((name) => ({ type: 'tool_addition', tool: { type: 'tool_reference', name } })),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Integration finding N1 (c) — the tool_addition for a skill switch: the
|
|
219
|
+
* skill's starters that the CURRENT mode's list (`toolList`, the list the
|
|
220
|
+
* next request carries) declares deferred, minus those the conversation has
|
|
221
|
+
* already surfaced. A session pinned 'static' never gets one, whatever list
|
|
222
|
+
* the caller holds (a static request cannot carry a `role: 'system'`
|
|
223
|
+
* tool_addition). Nothing to add ⇒ null.
|
|
224
|
+
*/
|
|
225
|
+
export function buildSkillToolAddition(i) {
|
|
226
|
+
if (i.toolLoading === 'static')
|
|
227
|
+
return null;
|
|
228
|
+
const surfaced = surfacedToolNames(i.messages);
|
|
229
|
+
return buildToolAdditionMessage(startersForSkill(i.skill).filter((n) => !surfaced.has(n)), deferredNames(i.toolList));
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Tool names already surfaced in a conversation: every `tool_addition`
|
|
233
|
+
* reference (`tool.name`) and every search-result reference (`tool_name`).
|
|
234
|
+
* Used so a skill switch never re-adds a tool.
|
|
235
|
+
*/
|
|
236
|
+
export function surfacedToolNames(messages) {
|
|
237
|
+
const out = new Set();
|
|
238
|
+
for (const m of messages ?? []) {
|
|
239
|
+
if (!Array.isArray(m?.content))
|
|
240
|
+
continue;
|
|
241
|
+
for (const b of m.content) {
|
|
242
|
+
if (b?.type === 'tool_addition' && typeof b.tool?.name === 'string')
|
|
243
|
+
out.add(b.tool.name);
|
|
244
|
+
if (b?.type === 'tool_search_tool_result') {
|
|
245
|
+
const refs = b.content?.tool_references;
|
|
246
|
+
if (Array.isArray(refs)) {
|
|
247
|
+
for (const r of refs)
|
|
248
|
+
if (typeof r?.tool_name === 'string')
|
|
249
|
+
out.add(r.tool_name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool_output_read — Task 17 ruling (2026-09-27).
|
|
3
|
+
*
|
|
4
|
+
* Large tool results are saved to ~/.cspeach/streams/<sessionId>/tool-<id>.txt
|
|
5
|
+
* (result-spill.ts) and the history keeps a preview naming this tool. It is
|
|
6
|
+
* always registered (not flag-gated — file_read is off in a plain SAP session),
|
|
7
|
+
* read-only, in CORE_TOOLS, and reads ONLY the current session's spill files
|
|
8
|
+
* (a reader child: also its parent session's — final review tools-I4): the
|
|
9
|
+
* path must be a tool-*.txt directly inside one of those spill dirs, and
|
|
10
|
+
* it is re-checked after realpath (a symlink cannot point it elsewhere).
|
|
11
|
+
* Anything else — including a ctx with no session id — is an error result.
|
|
12
|
+
* Its own result is never spilled; instead it has a char budget.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fsp } from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { registerTool } from './index.js';
|
|
17
|
+
import { spillDirFor, sessionIdOf, SPILL_FILE_RE, NUMBERED_SPILL_RE, SPILL_MAX_AGE_DAYS, withRealpath, } from './result-spill.js';
|
|
18
|
+
const MAX_LINES = 2000;
|
|
19
|
+
/** Total characters one call returns (fix round 1, minor 2). */
|
|
20
|
+
export const TOOL_OUTPUT_READ_MAX_CHARS = 40_000;
|
|
21
|
+
/** One returned line never carries more than this. */
|
|
22
|
+
export const TOOL_OUTPUT_READ_LINE_MAX_CHARS = 2_000;
|
|
23
|
+
const REFUSED = 'not a saved tool output of this session';
|
|
24
|
+
const err = (msg) => ({ content: `error: ${msg}`, is_error: true });
|
|
25
|
+
/** Case-insensitive file systems (win32, darwin default) compare paths case-insensitively. */
|
|
26
|
+
const norm = (p) => (process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p);
|
|
27
|
+
/** The spill file itself must sit directly in one of the session-dir spellings. */
|
|
28
|
+
function directlyIn(dirs, file) {
|
|
29
|
+
const parent = norm(path.dirname(file));
|
|
30
|
+
return dirs.some((d) => norm(d) === parent);
|
|
31
|
+
}
|
|
32
|
+
function clip(line) {
|
|
33
|
+
return line.length <= TOOL_OUTPUT_READ_LINE_MAX_CHARS
|
|
34
|
+
? line
|
|
35
|
+
: `${line.slice(0, TOOL_OUTPUT_READ_LINE_MAX_CHARS)}… [+${line.length - TOOL_OUTPUT_READ_LINE_MAX_CHARS} chars]`;
|
|
36
|
+
}
|
|
37
|
+
export async function toolOutputReadHandler(args, ctx) {
|
|
38
|
+
const sessionId = sessionIdOf(ctx);
|
|
39
|
+
if (sessionId === undefined)
|
|
40
|
+
return err(REFUSED);
|
|
41
|
+
const p = typeof args.path === 'string' ? args.path : '';
|
|
42
|
+
if (p.length === 0 || p.includes('\0') || !path.isAbsolute(p)) {
|
|
43
|
+
return err('path must be the absolute spill-file path shown in the tool result');
|
|
44
|
+
}
|
|
45
|
+
const offset = args.offset === undefined ? 0 : Number(args.offset);
|
|
46
|
+
const limitArg = args.limit === undefined ? MAX_LINES : Number(args.limit);
|
|
47
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
48
|
+
return err(`offset must be a non-negative integer (got ${String(args.offset)})`);
|
|
49
|
+
if (!Number.isInteger(limitArg) || limitArg < 1)
|
|
50
|
+
return err(`limit must be a positive integer (got ${String(args.limit)})`);
|
|
51
|
+
const limit = Math.min(limitArg, MAX_LINES);
|
|
52
|
+
// Final review tools-I4 — a reader child may also read its parent's saved
|
|
53
|
+
// outputs: that one dir only, and only on a reader ctx.
|
|
54
|
+
const parentId = ctx.readerMode && typeof ctx.spillParentSessionId === 'string' && ctx.spillParentSessionId.length > 0
|
|
55
|
+
? ctx.spillParentSessionId
|
|
56
|
+
: undefined;
|
|
57
|
+
const dirs = [
|
|
58
|
+
...withRealpath(spillDirFor(sessionId)),
|
|
59
|
+
...(parentId !== undefined ? withRealpath(spillDirFor(parentId)) : []),
|
|
60
|
+
];
|
|
61
|
+
const abs = path.resolve(p);
|
|
62
|
+
if (!SPILL_FILE_RE.test(path.basename(abs)) || !directlyIn(dirs, abs))
|
|
63
|
+
return err(REFUSED);
|
|
64
|
+
let real;
|
|
65
|
+
try {
|
|
66
|
+
real = await fsp.realpath(abs);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
return err(e.code === 'ENOENT'
|
|
70
|
+
? `saved output not found (saved outputs are kept ${SPILL_MAX_AGE_DAYS} days)`
|
|
71
|
+
: 'cannot read saved output');
|
|
72
|
+
}
|
|
73
|
+
if (!SPILL_FILE_RE.test(path.basename(real)) || !directlyIn(dirs, real))
|
|
74
|
+
return err(REFUSED);
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
raw = await fsp.readFile(real, 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return err('cannot read saved output');
|
|
81
|
+
}
|
|
82
|
+
// A `.n.txt` spill (file_read) already carries the file's line numbers.
|
|
83
|
+
const numberedAlready = NUMBERED_SPILL_RE.test(real);
|
|
84
|
+
const all = raw.replace(/\n$/, '').split('\n');
|
|
85
|
+
const end = Math.min(all.length, offset + limit);
|
|
86
|
+
const out = [];
|
|
87
|
+
let used = 0;
|
|
88
|
+
let next = offset;
|
|
89
|
+
for (let i = offset; i < end; i++) {
|
|
90
|
+
const line = clip(numberedAlready ? all[i] : `${i + 1}\t${all[i]}`);
|
|
91
|
+
if (out.length > 0 && used + line.length + 1 > TOOL_OUTPUT_READ_MAX_CHARS)
|
|
92
|
+
break;
|
|
93
|
+
out.push(line);
|
|
94
|
+
used += line.length + 1;
|
|
95
|
+
next = i + 1;
|
|
96
|
+
}
|
|
97
|
+
const rest = all.length - next;
|
|
98
|
+
return { content: out.join('\n') + (rest > 0 ? `\n... (${rest} more lines — next offset=${next})` : '') };
|
|
99
|
+
}
|
|
100
|
+
registerTool({
|
|
101
|
+
name: 'tool_output_read',
|
|
102
|
+
description: 'Read a slice of a large tool result that was saved to a file (the result names the path). '
|
|
103
|
+
+ 'Returns numbered lines. offset is 0-based; limit defaults to and caps at 2000 lines (40 000 chars). '
|
|
104
|
+
+ 'Only this session\'s saved outputs can be read.',
|
|
105
|
+
isMutating: false,
|
|
106
|
+
category: 'filesystem',
|
|
107
|
+
input_schema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
path: { type: 'string', description: 'The saved-output path from the tool result.' },
|
|
111
|
+
offset: { type: 'number', description: 'Optional 0-based line offset.' },
|
|
112
|
+
limit: { type: 'number', description: 'Optional max lines (cap 2000).' },
|
|
113
|
+
},
|
|
114
|
+
required: ['path'],
|
|
115
|
+
},
|
|
116
|
+
handler: toolOutputReadHandler,
|
|
117
|
+
});
|