@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.
Files changed (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Phase 2 of Track B — opt-in project-context prompt enrichment.
3
+ *
4
+ * Builds the rendered <project_context> block when CSPEACH_PROJECT_CONTEXT
5
+ * is set to "on", otherwise returns an empty string. Caches the rendered
6
+ * output at module scope so the project-context computation runs at most
7
+ * once per CLI process.
8
+ *
9
+ * Default OFF. With the flag unset (the production state for every existing
10
+ * user) this returns '' on every call — the loop's enrichment path then
11
+ * behaves identically to today.
12
+ *
13
+ * Out of scope for Phase 2 (deferred):
14
+ *
15
+ * - **Telemetry emission.** The technical plan calls for tracking
16
+ * flag-on vs flag-off token usage and tool-call deltas. CSPeach has
17
+ * a telemetry config field but no actual emitter pluggable into the
18
+ * loop today. Building one is its own design decision; surface
19
+ * counts via existing session.usage / session.toolCalls when those
20
+ * aggregate paths exist.
21
+ *
22
+ * - **SapConnectionContext population.** getProjectContext accepts an
23
+ * optional sapConnection argument; the REPL has the sapAlias today
24
+ * but doesn't construct the full SapConnectionContext object.
25
+ * Phase 2.1 follow-up: build an adapter from session state +
26
+ * getSapSystemInfo to populate this when an alias is present.
27
+ *
28
+ * - **On-demand refresh tool.** Decision 3 (RESOLVED 2026-05-10)
29
+ * parks the project_context_get / project_context_refresh tool to
30
+ * Phase 3. Today the cache is process-lifetime; users get a fresh
31
+ * context next CLI invocation.
32
+ */
33
+ import { getProjectContext, renderProjectContext } from '../project-context/index.js';
34
+ /**
35
+ * The env-var name that gates this enrichment. Treated case-insensitively
36
+ * but value comparison is exact ("on" only — anything else is OFF).
37
+ *
38
+ * Exported so tests can reference it without hardcoding a string.
39
+ */
40
+ export const PROJECT_CONTEXT_ENV_FLAG = 'CSPEACH_PROJECT_CONTEXT';
41
+ /**
42
+ * Module-scope cache. The cached value is the rendered block (possibly
43
+ * empty when detection produced nothing actionable). `null` means
44
+ * "never computed in this process." A subsequent call short-circuits
45
+ * to whatever was cached the first time.
46
+ */
47
+ let cachedBlock = null;
48
+ /**
49
+ * One-shot warning latch — flips true after the first time we surface
50
+ * a never-throws contract violation to stderr. Subsequent errors are
51
+ * still caught + cached as empty, but we don't spam the console.
52
+ */
53
+ let warnedOnce = false;
54
+ /**
55
+ * Public cache reset. Phase 3's `project_context_refresh` tool will
56
+ * call this to invalidate a stale cache without restarting the CLI
57
+ * (e.g. after a `file_write` that creates a new controller). Also
58
+ * resets the one-shot warning latch so a fresh attempt can warn anew
59
+ * if it errors.
60
+ */
61
+ export function resetProjectContextCache() {
62
+ cachedBlock = null;
63
+ warnedOnce = false;
64
+ }
65
+ /**
66
+ * Test-only alias for `resetProjectContextCache`. Kept for readability
67
+ * at test-call-sites — `__...ForTests` makes the test intent obvious.
68
+ * Production code MUST use the unprefixed name above.
69
+ */
70
+ export function __resetProjectContextCacheForTests() {
71
+ resetProjectContextCache();
72
+ }
73
+ /**
74
+ * Returns the rendered <project_context> block for splicing into the
75
+ * user message, or '' when the flag is off / no context was detectable.
76
+ *
77
+ * Never throws — getProjectContext + renderProjectContext are both
78
+ * best-effort. Any IO error inside the project-context module returns
79
+ * the empty string, which makes the call site safely composable
80
+ * (`message = block + message`).
81
+ */
82
+ export async function maybeBuildProjectContext(options) {
83
+ if (!isFlagOn())
84
+ return '';
85
+ // Treat empty-string cwd the same as a missing one. Node fs would
86
+ // resolve '' to process.cwd(), which would silently walk wherever
87
+ // the CLI was launched from — surprising and not what we want when
88
+ // the caller failed to populate the field.
89
+ if (!options.cwd)
90
+ return '';
91
+ if (cachedBlock !== null)
92
+ return cachedBlock;
93
+ try {
94
+ const ctx = await getProjectContext(options.cwd, {
95
+ sapConnection: options.sapConnection ?? null,
96
+ });
97
+ cachedBlock = ctx ? renderProjectContext(ctx) : '';
98
+ }
99
+ catch (err) {
100
+ // getProjectContext is documented as never-throws; reaching this
101
+ // catch means the contract was violated (regression in 1F or one
102
+ // of the extractors). Emit a one-shot stderr warning so a
103
+ // dogfooder sees a real signal — without it, "the model isn't
104
+ // using project_context" debugging starts with five wrong
105
+ // hypotheses. Subsequent calls in the same process stay silent.
106
+ if (!warnedOnce) {
107
+ warnedOnce = true;
108
+ const msg = err instanceof Error ? err.message : String(err);
109
+ try {
110
+ process.stderr.write(`[project-context] disabled for this session: ${msg}\n`);
111
+ }
112
+ catch {
113
+ // stderr write itself failed (closed pipe, etc.) — nothing more we can do.
114
+ }
115
+ }
116
+ cachedBlock = '';
117
+ }
118
+ return cachedBlock;
119
+ }
120
+ /**
121
+ * Pure check on the env-var. Exported for tests + for the loop call
122
+ * site to short-circuit before calling buildSystemPrompt-style helpers.
123
+ */
124
+ export function isFlagOn() {
125
+ return process.env[PROJECT_CONTEXT_ENV_FLAG] === 'on';
126
+ }
@@ -0,0 +1,58 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import keytar from 'keytar';
3
+ import { createManifestSkillSource } from '../../skills/source-manifest.js';
4
+ import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
5
+ const KEYCHAIN_SERVICE = 'cspeach.ai-hub';
6
+ const KEYCHAIN_ACCOUNT = 'token';
7
+ export function createAiHubProvider(cfg) {
8
+ if (!cfg.llm.ai_hub_base_url) {
9
+ throw new Error('ai-hub mode requires llm.ai_hub_base_url — run: cspeach config set llm.ai_hub_base_url <url>');
10
+ }
11
+ const baseURL = cfg.llm.ai_hub_base_url;
12
+ const modelAlias = cfg.llm.ai_hub_model_alias ?? 'anthropic--claude-opus-4-6';
13
+ const skillSource = createManifestSkillSource(DEFAULT_MANIFEST_URL);
14
+ return {
15
+ name: 'sap-ai-hub',
16
+ mode: 'ai-hub',
17
+ skillSource,
18
+ async createStream(params, options = {}) {
19
+ const token = await getToken();
20
+ const sdk = new Anthropic({
21
+ apiKey: token,
22
+ baseURL,
23
+ defaultHeaders: { ...options.headers },
24
+ });
25
+ // v0.6 — pass AbortSignal so hard-interrupt tears down the stream.
26
+ return sdk.messages.stream({ ...params, model: modelAlias }, options.signal ? { signal: options.signal } : undefined);
27
+ },
28
+ async healthCheck() {
29
+ const token = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
30
+ if (!token)
31
+ return { ok: false, message: 'AI Hub token not in keychain', hint: 'Run: cspeach config set llm.ai_hub_token <token>' };
32
+ try {
33
+ const sdk = new Anthropic({ apiKey: token, baseURL });
34
+ await sdk.messages.create({
35
+ model: modelAlias,
36
+ max_tokens: 1,
37
+ messages: [{ role: 'user', content: 'hi' }],
38
+ });
39
+ }
40
+ catch (e) {
41
+ return { ok: false, message: `AI Hub unreachable or auth failed: ${e.message}`, hint: 'Check the AI Core proxy URL and your token' };
42
+ }
43
+ try {
44
+ await skillSource.listAvailableSkills();
45
+ }
46
+ catch (e) {
47
+ return { ok: false, message: `AI Hub reachable, but manifest fetch/verify failed: ${e.message}`, hint: 'Check network access to manifest.cspeach.dev' };
48
+ }
49
+ return { ok: true, message: `AI Hub reachable (${baseURL}, model=${modelAlias}) + manifest verified` };
50
+ },
51
+ };
52
+ }
53
+ async function getToken() {
54
+ const t = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
55
+ if (!t)
56
+ throw new Error('AI Hub token not configured');
57
+ return t;
58
+ }
@@ -0,0 +1,53 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import keytar from 'keytar';
3
+ import { createManifestSkillSource } from '../../skills/source-manifest.js';
4
+ import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
5
+ const KEYCHAIN_SERVICE = 'cspeach.anthropic';
6
+ const KEYCHAIN_ACCOUNT = 'api-key';
7
+ // Cheapest Anthropic model available at doctor-probe time.
8
+ const HEALTH_PROBE_MODEL = 'claude-haiku-4-6-latest';
9
+ export function createByokProvider(_cfg) {
10
+ const skillSource = createManifestSkillSource(DEFAULT_MANIFEST_URL);
11
+ return {
12
+ name: 'anthropic-byok',
13
+ mode: 'byok',
14
+ skillSource,
15
+ async createStream(params, options = {}) {
16
+ const key = await getKey();
17
+ const sdk = new Anthropic({ apiKey: key, defaultHeaders: { ...options.headers } });
18
+ // v0.6 — pass AbortSignal so the agent loop's per-turn AbortController
19
+ // can tear down the stream on Esc / SIGINT-during-turn. The Anthropic
20
+ // SDK's `signal` option propagates to the underlying fetch.
21
+ return sdk.messages.stream(params, options.signal ? { signal: options.signal } : undefined);
22
+ },
23
+ async healthCheck() {
24
+ const key = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
25
+ if (!key)
26
+ return { ok: false, message: 'Anthropic API key not in keychain', hint: 'Run: cspeach config set llm.byok_key <your-anthropic-key>' };
27
+ try {
28
+ const sdk = new Anthropic({ apiKey: key });
29
+ await sdk.messages.create({
30
+ model: HEALTH_PROBE_MODEL,
31
+ max_tokens: 1,
32
+ messages: [{ role: 'user', content: 'hi' }],
33
+ });
34
+ }
35
+ catch (e) {
36
+ return { ok: false, message: `BYOK auth failed: ${e.message}`, hint: 'Check your Anthropic key is current and has credit' };
37
+ }
38
+ try {
39
+ await skillSource.listAvailableSkills();
40
+ }
41
+ catch (e) {
42
+ return { ok: false, message: `BYOK key valid, but manifest fetch/verify failed: ${e.message}`, hint: 'Check network access to manifest.cspeach.dev; run `cspeach doctor --verbose` for the signature chain' };
43
+ }
44
+ return { ok: true, message: 'BYOK Anthropic key valid + manifest signature verified' };
45
+ },
46
+ };
47
+ }
48
+ async function getKey() {
49
+ const key = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
50
+ if (!key)
51
+ throw new Error('BYOK Anthropic API key not configured — run: cspeach config set llm.byok_key <key>');
52
+ return key;
53
+ }
@@ -0,0 +1,13 @@
1
+ import { createManagedProvider } from '../anthropic-provider.js';
2
+ import { createByokProvider } from './byok-provider.js';
3
+ import { createAiHubProvider } from './ai-hub-provider.js';
4
+ import { createLocalProvider } from './local-provider.js';
5
+ export function createProviderForMode(cfg, getBearer) {
6
+ switch (cfg.llm.mode) {
7
+ case 'managed': return createManagedProvider(cfg, getBearer);
8
+ case 'byok': return createByokProvider(cfg);
9
+ case 'ai-hub': return createAiHubProvider(cfg);
10
+ case 'local': return createLocalProvider(cfg);
11
+ default: throw new Error(`unknown llm.mode: ${cfg.llm.mode}`);
12
+ }
13
+ }
@@ -0,0 +1,125 @@
1
+ import OpenAI from 'openai';
2
+ import crypto from 'node:crypto';
3
+ import { sha256 } from '@noble/hashes/sha256';
4
+ import { bytesToHex } from '@noble/hashes/utils';
5
+ import { createBundledSkillSource } from '../../skills/source-bundled.js';
6
+ import { BUNDLED_SKILLS } from '../../skills/bundled-skills.js';
7
+ export function createLocalProvider(cfg) {
8
+ if (!cfg.llm.local_base_url) {
9
+ throw new Error('local mode requires llm.local_base_url — run: cspeach config set llm.local_base_url http://localhost:11434/v1');
10
+ }
11
+ const baseURL = cfg.llm.local_base_url;
12
+ const model = cfg.llm.local_model ?? 'llama3.1:70b';
13
+ const skillSource = createBundledSkillSource();
14
+ return {
15
+ name: 'local-openai-compat',
16
+ mode: 'local',
17
+ skillSource,
18
+ async createStream(params, options = {}) {
19
+ if (params.tools && params.tools.length > 0) {
20
+ throw new Error('Tool calling not supported in local mode (Plan A). Switch to managed/byok/ai-hub, or wait for the local-mode tool-calling follow-up PR.');
21
+ }
22
+ const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
23
+ const openAiParams = anthropicParamsToOpenAI(params, model);
24
+ // stream: true widens via object spread; the literal-true overload cannot be
25
+ // selected by structural inference, so cast to the streaming return shape.
26
+ // v0.6 — pass AbortSignal through OpenAI SDK request options.
27
+ const resp = (await sdk.chat.completions.create({ ...openAiParams, stream: true }, options.signal ? { signal: options.signal } : undefined));
28
+ return adaptOpenAiStreamToAnthropic(resp, model);
29
+ },
30
+ async healthCheck() {
31
+ try {
32
+ const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
33
+ await sdk.chat.completions.create({
34
+ model,
35
+ max_tokens: 1,
36
+ messages: [{ role: 'user', content: 'hi' }],
37
+ });
38
+ }
39
+ catch (e) {
40
+ return { ok: false, message: `Local endpoint unreachable: ${e.message}`, hint: `Is Ollama/vLLM running at ${baseURL}? Is ${model} loaded?` };
41
+ }
42
+ const bundled = Object.entries(BUNDLED_SKILLS);
43
+ if (bundled.length === 0) {
44
+ return { ok: false, message: 'No skills bundled in this CLI build', hint: 'This CLI was built without running `npm run prebuild`. Rebuild from source or install the published CLI.' };
45
+ }
46
+ const mismatches = [];
47
+ for (const [name, body] of bundled) {
48
+ const actual = bytesToHex(sha256(new TextEncoder().encode(body.body)));
49
+ if (actual !== body.sha256)
50
+ mismatches.push(`${name} (manifest=${body.sha256}, actual=${actual})`);
51
+ }
52
+ if (mismatches.length > 0) {
53
+ return {
54
+ ok: false,
55
+ message: `bundled skill(s) sha256 mismatch: ${mismatches.join('; ')}`,
56
+ hint: 'CLI binary may have been tampered with; reinstall from a trusted source.',
57
+ };
58
+ }
59
+ return { ok: true, message: `Local model ${model} at ${baseURL} responding; ${bundled.length} skills bundled (sha256 verified)` };
60
+ },
61
+ };
62
+ }
63
+ function anthropicParamsToOpenAI(p, model) {
64
+ const messages = [];
65
+ if (p.system) {
66
+ const sys = typeof p.system === 'string'
67
+ ? p.system
68
+ : p.system.map(s => s.text).join('\n');
69
+ messages.push({ role: 'system', content: sys });
70
+ }
71
+ for (const m of p.messages) {
72
+ const content = typeof m.content === 'string'
73
+ ? m.content
74
+ : Array.isArray(m.content)
75
+ ? m.content.filter(b => b.type === 'text').map(b => b.text).join('\n')
76
+ : String(m.content);
77
+ messages.push({ role: m.role, content });
78
+ }
79
+ return {
80
+ model,
81
+ max_tokens: p.max_tokens,
82
+ messages,
83
+ };
84
+ }
85
+ export async function* adaptOpenAiStreamToAnthropic(resp, model) {
86
+ const messageId = 'msg_' + crypto.randomUUID();
87
+ let outputTokens = 0;
88
+ let inputTokens = 0;
89
+ let stopReason = null;
90
+ let sentMessageStart = false;
91
+ let sentBlockStart = false;
92
+ for await (const chunk of resp) {
93
+ if (!sentMessageStart) {
94
+ sentMessageStart = true;
95
+ yield {
96
+ type: 'message_start',
97
+ message: { id: messageId, type: 'message', role: 'assistant', model, content: [], stop_reason: null, usage: { input_tokens: 0, output_tokens: 0 } },
98
+ };
99
+ yield { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } };
100
+ sentBlockStart = true;
101
+ }
102
+ const choice = chunk.choices?.[0];
103
+ const deltaText = choice?.delta?.content;
104
+ if (typeof deltaText === 'string' && deltaText.length > 0) {
105
+ yield { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: deltaText } };
106
+ }
107
+ if (choice?.finish_reason) {
108
+ stopReason = choice.finish_reason === 'stop' ? 'end_turn'
109
+ : choice.finish_reason === 'length' ? 'max_tokens'
110
+ : choice.finish_reason;
111
+ }
112
+ if (chunk.usage) {
113
+ outputTokens = chunk.usage.completion_tokens ?? outputTokens;
114
+ inputTokens = chunk.usage.prompt_tokens ?? inputTokens;
115
+ }
116
+ }
117
+ if (sentBlockStart)
118
+ yield { type: 'content_block_stop', index: 0 };
119
+ yield {
120
+ type: 'message_delta',
121
+ delta: { stop_reason: stopReason ?? 'end_turn', stop_sequence: null },
122
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
123
+ };
124
+ yield { type: 'message_stop' };
125
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Repair partial assistant content blocks before persisting them to a session.
3
+ *
4
+ * When a provider stream is interrupted mid-flight (HTTP socket dropped,
5
+ * AbortController fired, network blip), the agent loop's accumulated
6
+ * `currentAssistantContent` may include `tool_use` blocks whose `input` JSON
7
+ * never finished streaming — typically the SDK has buffered the bytes into
8
+ * `partial_json` but `JSON.parse` was never called, so `input` is undefined.
9
+ *
10
+ * Persisting an assistant message that contains an incomplete tool_use block
11
+ * causes the next API call to reject the message with:
12
+ * 400 each `tool_use` block must contain `input` (or similar)
13
+ *
14
+ * Drop incomplete tool_use blocks. The model will re-emit them on the next
15
+ * round (the prior tool round's results are already on session.messages,
16
+ * giving the model the same context). Text and thinking blocks are kept
17
+ * even if truncated — partial prose is still useful for the user to read,
18
+ * and a short paragraph fragment is a valid assistant message.
19
+ */
20
+ export function repairPartialBlocks(blocks) {
21
+ return blocks.filter((b) => {
22
+ if (b == null || typeof b !== 'object')
23
+ return false;
24
+ if (b.type === 'tool_use') {
25
+ // Complete only when input was successfully parsed (object, not undefined).
26
+ return b.input !== undefined;
27
+ }
28
+ // text, thinking, redacted_thinking, etc. — keep as-is.
29
+ return true;
30
+ });
31
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Retry-cap keying — produce a stable, distinguishing key per (tool, args)
3
+ * pair so consecutive failures of the SAME logical call accumulate, while
4
+ * independent calls (different args) are treated as independent attempts.
5
+ *
6
+ * Background
7
+ * ----------
8
+ * The agent loop caps consecutive failures per logical target to break out
9
+ * of approval-thrash / hard-loop scenarios where the model keeps re-trying
10
+ * the same broken call. The original keying used `${toolName}:${name}:${type}`
11
+ * which works for object-mutating tools (sap_set_source, sap_activate, etc.)
12
+ * — but for tools whose input doesn't carry name/type (sap_sql_query takes
13
+ * a `query`; sap_search_object takes a `query`; sap_short_dump takes a
14
+ * dump id) every call collapsed to `${toolName}::` and three independent
15
+ * probes were treated as three retries.
16
+ *
17
+ * Probe-heavy skills like /abap-cca DISCOVER (which intentionally probes
18
+ * 4 UPL table variants and skips missing ones) tripped the cap in normal
19
+ * operation.
20
+ *
21
+ * Fix
22
+ * ---
23
+ * - Prefer object identity when present (`name` / `type`, with
24
+ * `object_name` / `object_type` as alternates) — preserves original
25
+ * semantics for tools that target a named object.
26
+ * - Otherwise, key on a short stable hash of the full input JSON. Different
27
+ * queries get different hashes; different keys; independent retry tallies.
28
+ * Same query failing repeatedly still accumulates onto the same hash key.
29
+ *
30
+ * Hash: cheap djb2-style fold. We don't need crypto — only intra-turn
31
+ * uniqueness across a small handful of failed calls.
32
+ */
33
+ export function objectKeyFromInput(toolName, input) {
34
+ const safe = input ?? {};
35
+ const name = pickString(safe, 'name') ?? pickString(safe, 'object_name') ?? '';
36
+ const type = pickString(safe, 'type') ?? pickString(safe, 'object_type') ?? '';
37
+ if (name.length > 0 || type.length > 0) {
38
+ return `${toolName}:${name}:${type}`;
39
+ }
40
+ // Fallback: distinguishing hash of all args. JSON.stringify with sorted
41
+ // keys would be more deterministic across object construction orders, but
42
+ // within a single turn the model emits args via a stable schema so plain
43
+ // stringify is enough.
44
+ const argsJson = JSON.stringify(safe);
45
+ return `${toolName}:#${cheapHash(argsJson)}`;
46
+ }
47
+ function pickString(input, key) {
48
+ const v = input[key];
49
+ return typeof v === 'string' && v.length > 0 ? v : null;
50
+ }
51
+ function cheapHash(s) {
52
+ // djb2 — small, deterministic, no deps. Returned as 8-char hex.
53
+ let h = 5381;
54
+ for (let i = 0; i < s.length; i++) {
55
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
56
+ }
57
+ return (h >>> 0).toString(16).padStart(8, '0');
58
+ }
@@ -0,0 +1,17 @@
1
+ export async function retryWithBackoff(fn, maxAttempts = 3, initialDelayMs = 2000) {
2
+ let delay = initialDelayMs;
3
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4
+ try {
5
+ return await fn();
6
+ }
7
+ catch (err) {
8
+ const status = err?.status ?? err?.response?.status;
9
+ const retryable = status === 503 || status === 529;
10
+ if (!retryable || attempt === maxAttempts)
11
+ throw err;
12
+ await new Promise((r) => setTimeout(r, delay));
13
+ delay *= 4; // 2s → 8s → 32s
14
+ }
15
+ }
16
+ throw new Error('retry exhausted');
17
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Phase 2.1 of Track B — SapConnectionContext adapter.
3
+ *
4
+ * Bridges CSPeach's existing per-session SAP state (alias, getSapSystemInfo
5
+ * cache, current pinned transport) into the SapConnectionContext shape
6
+ * the project-context renderer expects. This is what makes the
7
+ * `<sap_connection>` block in the rendered project_context actually
8
+ * populated for live-SAP sessions — Phase 2 left it as null.
9
+ *
10
+ * Best-effort: every input is optional. Missing values become null in
11
+ * the output rather than blocking the call. The renderer in
12
+ * project-context/render.ts handles nulls gracefully (omits empty
13
+ * sub-elements).
14
+ *
15
+ * Out of scope (later refinements):
16
+ *
17
+ * - `recentObjects` is currently '[]'. Capturing the last-N
18
+ * sap_get_source / sap_set_source / sap_atc_run targets would
19
+ * require touching the tool dispatch layer to record per-call
20
+ * metadata. Phase 2.2+ work.
21
+ *
22
+ * - `host` (the system endpoint) is null. AdtClient knows it but
23
+ * exposing it through the public types touches the sap-client API
24
+ * surface. Adapter leaves it null until that's deliberate.
25
+ */
26
+ /**
27
+ * Assemble a SapConnectionContext from the alias, the cached
28
+ * SapSystemInfo (from getSapSystemInfo), and any per-session state
29
+ * the caller can supply. Pass an empty `state` object when nothing
30
+ * beyond the alias + system info is known.
31
+ */
32
+ export function buildSapConnectionContext(alias, systemInfo, state = {}) {
33
+ return {
34
+ alias,
35
+ system: toSummary(systemInfo),
36
+ currentTransport: state.currentTransport ?? null,
37
+ activeScope: toActiveScope(state),
38
+ recentObjects: state.recentObjects ?? [],
39
+ };
40
+ }
41
+ /* ── Internals ─────────────────────────────────────────────────────── */
42
+ function toSummary(info) {
43
+ if (!info) {
44
+ return {
45
+ sid: null,
46
+ release: null,
47
+ abapVersion: null,
48
+ platform: 'unknown',
49
+ host: null,
50
+ };
51
+ }
52
+ return {
53
+ // SapSystemInfo doesn't carry a SID today — populated when the
54
+ // future ENVIRONMENT-INFO query lands. Null is the honest default.
55
+ sid: null,
56
+ release: info.release || null,
57
+ abapVersion: info.abapVersion || null,
58
+ platform: toPlatform(info.deployment),
59
+ host: null,
60
+ };
61
+ }
62
+ function toPlatform(deployment) {
63
+ switch (deployment) {
64
+ case 'public-cloud':
65
+ return 'cloud';
66
+ case 'on-prem-or-private-cloud':
67
+ return 'on-premise';
68
+ case 'unknown':
69
+ default:
70
+ return 'unknown';
71
+ }
72
+ }
73
+ function toActiveScope(state) {
74
+ const hasPackage = typeof state.activePackage === 'string' && state.activePackage.length > 0;
75
+ const hasObject = state.activeObject != null;
76
+ if (!hasPackage && !hasObject)
77
+ return null;
78
+ return {
79
+ package: hasPackage ? state.activePackage : null,
80
+ object: hasObject ? state.activeObject : null,
81
+ };
82
+ }