@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,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
|
+
}
|