@sayknow-cli/coding-agent 0.5.20 → 0.5.22

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 (38) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/types/cli/auth-gateway-cli.d.ts +24 -0
  3. package/dist/types/cli/setup-cli.d.ts +15 -1
  4. package/dist/types/commands/auth-gateway.d.ts +2 -1
  5. package/dist/types/commands/setup.d.ts +6 -0
  6. package/dist/types/config/settings-schema.d.ts +9 -0
  7. package/dist/types/decisions/index.d.ts +18 -0
  8. package/dist/types/decisions/llm-backend.d.ts +51 -0
  9. package/dist/types/decisions/skill-routing.d.ts +8 -0
  10. package/dist/types/decisions/types.d.ts +91 -0
  11. package/dist/types/decisions/typesafe-backend.d.ts +15 -0
  12. package/dist/types/hooks/skill-state.d.ts +6 -0
  13. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  14. package/dist/types/modes/components/typesafe-key-prompt.d.ts +23 -0
  15. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +3 -1
  16. package/dist/types/session/agent-session.d.ts +0 -9
  17. package/dist/types/setup/decision-provider.d.ts +24 -0
  18. package/package.json +7 -7
  19. package/scripts/eval-skill-routing.ts +172 -0
  20. package/src/cli/auth-gateway-cli.ts +128 -85
  21. package/src/cli/setup-cli.ts +53 -1
  22. package/src/commands/auth-gateway.ts +7 -5
  23. package/src/commands/setup.ts +5 -0
  24. package/src/config/settings-schema.ts +12 -0
  25. package/src/decisions/index.ts +84 -0
  26. package/src/decisions/llm-backend.ts +356 -0
  27. package/src/decisions/skill-routing.ts +83 -0
  28. package/src/decisions/types.ts +119 -0
  29. package/src/decisions/typesafe-backend.ts +168 -0
  30. package/src/hooks/skill-keywords.ts +56 -0
  31. package/src/hooks/skill-state.ts +18 -2
  32. package/src/internal-urls/docs-index.generated.ts +1 -1
  33. package/src/modes/components/provider-onboarding-selector.ts +13 -1
  34. package/src/modes/components/typesafe-key-prompt.ts +108 -0
  35. package/src/modes/controllers/selector-controller.ts +44 -0
  36. package/src/sdk/bus/native-runtime-compatibility.ts +30 -3
  37. package/src/session/agent-session.ts +51 -1
  38. package/src/setup/decision-provider.ts +94 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Key entry for TypeSafe, reachable from the same place models are added.
3
+ *
4
+ * TypeSafe is not a chat model, so it never appears in the model picker — but the place
5
+ * users look when they want to "add a model with a key" is this onboarding list, and
6
+ * making them find a CLI subcommand instead would mean most users never enable it.
7
+ *
8
+ * The key is taken through {@link SecretInput} and consumed once: it is never rendered,
9
+ * never placed in a flag, and never written anywhere but the credential store.
10
+ */
11
+ import { Container, type Input, matchesKey, SecretInput, Spacer, Text, TruncatedText } from "@sayknow-cli/tui";
12
+ import { theme } from "../theme/theme";
13
+ import { matchesSelectCancel } from "../utils/keybinding-matchers";
14
+ import { DynamicBorder } from "./dynamic-border";
15
+
16
+ export interface TypeSafeKeyPromptResult {
17
+ apiKey: string;
18
+ }
19
+
20
+ export class TypeSafeKeyPromptComponent extends Container {
21
+ #content: Container;
22
+ #input: SecretInput | null = null;
23
+ #onSubmit: (result: TypeSafeKeyPromptResult) => void;
24
+ #onCancel: () => void;
25
+ #onRender: () => void;
26
+ #busy = false;
27
+ #error: string | null = null;
28
+
29
+ constructor(
30
+ onSubmit: (result: TypeSafeKeyPromptResult) => void,
31
+ onCancel: () => void,
32
+ onRender: () => void = () => {},
33
+ ) {
34
+ super();
35
+ this.#onSubmit = onSubmit;
36
+ this.#onCancel = onCancel;
37
+ this.#onRender = onRender;
38
+ this.#content = new Container();
39
+ this.addChild(new DynamicBorder());
40
+ this.addChild(new Spacer(1));
41
+ this.addChild(new TruncatedText(theme.bold("TypeSafe (typed decisions)")));
42
+ this.addChild(
43
+ new TruncatedText(
44
+ theme.fg(
45
+ "muted",
46
+ " Routes workflow decisions through the hosted System One model instead of your chat model.",
47
+ ),
48
+ 0,
49
+ 0,
50
+ ),
51
+ );
52
+ this.addChild(
53
+ new TruncatedText(theme.fg("muted", " Without a key this stays off and nothing else changes."), 0, 0),
54
+ );
55
+ this.addChild(new Spacer(1));
56
+ this.addChild(this.#content);
57
+ this.addChild(new Spacer(1));
58
+ this.addChild(new DynamicBorder());
59
+ this.#render();
60
+ }
61
+
62
+ /** Shown while the key is being checked against the live API. */
63
+ setBusy(busy: boolean): void {
64
+ this.#busy = busy;
65
+ this.#render();
66
+ }
67
+
68
+ /** Keeps the prompt open so a rejected key can be corrected without restarting. */
69
+ setError(message: string): void {
70
+ this.#busy = false;
71
+ this.#error = message;
72
+ this.#render();
73
+ }
74
+
75
+ #render(): void {
76
+ this.#content.clear();
77
+ if (this.#busy) {
78
+ this.#input = null;
79
+ this.#content.addChild(new Text(theme.fg("muted", "Verifying key against TypeSafe…"), 0, 0));
80
+ this.#onRender();
81
+ return;
82
+ }
83
+ if (this.#error) {
84
+ this.#content.addChild(new Text(theme.fg("error", this.#error), 0, 0));
85
+ this.#content.addChild(new Spacer(1));
86
+ }
87
+ this.#content.addChild(new Text("Paste your TypeSafe API key:", 0, 0));
88
+ this.#content.addChild(new Spacer(1));
89
+ const input = new SecretInput();
90
+ input.onSubmit = secret => {
91
+ const apiKey = secret.consume().trim();
92
+ if (!apiKey) return;
93
+ this.#error = null;
94
+ this.#onSubmit({ apiKey });
95
+ };
96
+ this.#input = input;
97
+ this.#content.addChild(input);
98
+ this.#onRender();
99
+ }
100
+
101
+ handleInput(keyData: string): void {
102
+ if (matchesSelectCancel(keyData) || matchesKey(keyData, "escape")) {
103
+ this.#onCancel();
104
+ return;
105
+ }
106
+ (this.#input as Input | SecretInput | null)?.handleInput(keyData);
107
+ }
108
+ }
@@ -150,6 +150,7 @@ import { ToolExecutionComponent } from "../components/tool-execution";
150
150
  import type { StatusLineSettings } from "../components/tool-status-header";
151
151
  import { TranscriptViewerOverlay, transcriptViewerEntries } from "../components/transcript-viewer-overlay";
152
152
  import { TreeSelectorComponent } from "../components/tree-selector";
153
+ import { TypeSafeKeyPromptComponent } from "../components/typesafe-key-prompt";
153
154
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
154
155
  import type { JobsObserver } from "../jobs-observer";
155
156
  import type { SessionObserverRegistry } from "../session-observer-registry";
@@ -879,6 +880,8 @@ export class SelectorController {
879
880
  void this.showOAuthSelector("login");
880
881
  } else if (action === "import-credentials") {
881
882
  void this.#handleCredentialImport();
883
+ } else if (action === "typesafe-key") {
884
+ this.#showTypeSafeKeyPrompt();
882
885
  } else {
883
886
  this.ctx.showStatus(formatProviderOnboardingCommandGuide());
884
887
  }
@@ -892,6 +895,47 @@ export class SelectorController {
892
895
  });
893
896
  }
894
897
 
898
+ /**
899
+ * Take a TypeSafe key and verify it before storing.
900
+ *
901
+ * Verification is not optional here. The decision service fails open by design, so an
902
+ * unverified bad key produces no error anywhere: decisions silently keep coming from
903
+ * the user's own model while the UI claims TypeSafe is on. Better to keep the prompt
904
+ * open and say the key was rejected.
905
+ */
906
+ #showTypeSafeKeyPrompt(): void {
907
+ this.showSelector(done => {
908
+ let prompt: TypeSafeKeyPromptComponent | undefined;
909
+ prompt = new TypeSafeKeyPromptComponent(
910
+ ({ apiKey }) => {
911
+ prompt?.setBusy(true);
912
+ void (async () => {
913
+ try {
914
+ const { formatTypeSafeKeyResult, setTypeSafeKey } = await import("../../setup/decision-provider");
915
+ const result = await setTypeSafeKey({ apiKey });
916
+ if (result.error) {
917
+ prompt?.setError(result.error);
918
+ this.ctx.ui.requestRender();
919
+ return;
920
+ }
921
+ done();
922
+ this.ctx.showStatus(formatTypeSafeKeyResult(result));
923
+ } catch (error) {
924
+ prompt?.setError(error instanceof Error ? error.message : String(error));
925
+ this.ctx.ui.requestRender();
926
+ }
927
+ })();
928
+ },
929
+ () => {
930
+ done();
931
+ this.ctx.ui.requestRender();
932
+ },
933
+ () => this.ctx.ui.requestRender(),
934
+ );
935
+ return { component: prompt, focus: prompt };
936
+ });
937
+ }
938
+
895
939
  async #handleCredentialImport(): Promise<void> {
896
940
  this.ctx.showStatus("Scanning for existing Claude Code / Codex CLI credentials…");
897
941
  const preview = await runExternalCredentialAutoImport({
@@ -1,3 +1,5 @@
1
+ import { fileURLToPath } from "node:url";
2
+
1
3
  const REQUIRED_WORKFLOW_ARBITRATION_METHODS = ["registerArbitratedAsk", "retireIfUnclaimed", "stopAndWait"] as const;
2
4
 
3
5
  export class NativeRuntimeCompatibilityError extends Error {
@@ -8,12 +10,20 @@ export class NativeRuntimeCompatibilityError extends Error {
8
10
  readonly runtimeVersion: string,
9
11
  readonly nativeVersion: string,
10
12
  readonly workflowArbitrationAvailable: boolean,
13
+ readonly nativeModulePath: string | null = null,
11
14
  ) {
15
+ const loadedFrom = nativeModulePath ? ` (loaded from ${nativeModulePath})` : "";
16
+ const causes = [
17
+ ...(runtimeVersion === nativeVersion
18
+ ? []
19
+ : [`loaded native version is ${nativeVersion}${loadedFrom}, expected ${runtimeVersion}`]),
20
+ ...(workflowArbitrationAvailable ? [] : [`required workflow arbitration methods are missing${loadedFrom}`]),
21
+ ];
12
22
  super(
13
23
  `Incompatible @sayknow-cli/natives for @sayknow-cli/coding-agent@${runtimeVersion}: ` +
14
- `loaded native version is ${nativeVersion}, and required workflow arbitration methods are ` +
15
- `${workflowArbitrationAvailable ? "available" : "missing"}. ` +
16
- `Reinstall matching @sayknow-cli/coding-agent and @sayknow-cli/natives packages.`,
24
+ `${causes.join("; ")}. ` +
25
+ `Reinstall matching @sayknow-cli/coding-agent and @sayknow-cli/natives packages, ` +
26
+ `and remove any stale nested node_modules copy of @sayknow-cli/natives that shadows it.`,
17
27
  );
18
28
  this.name = "NativeRuntimeCompatibilityError";
19
29
  }
@@ -27,10 +37,26 @@ function hasWorkflowArbitrationMethods(notificationServer: unknown): boolean {
27
37
  );
28
38
  }
29
39
 
40
+ /**
41
+ * Where the process actually loaded `@sayknow-cli/natives` from. Diagnostic only:
42
+ * a nested `node_modules` copy wins resolution over a workspace link, so the path
43
+ * is the difference between "reinstall something" and a one-line fix.
44
+ */
45
+ function resolveNativeModulePath(): string | null {
46
+ try {
47
+ const resolved = import.meta.resolve?.("@sayknow-cli/natives");
48
+ if (typeof resolved !== "string") return null;
49
+ return resolved.startsWith("file:") ? fileURLToPath(resolved) : resolved;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
30
55
  export function assertNativeRuntimeCompatibility(input: {
31
56
  runtimeVersion: string;
32
57
  nativeVersion: string;
33
58
  notificationServer: unknown;
59
+ nativeModulePath?: string | null;
34
60
  }): void {
35
61
  const workflowArbitrationAvailable = hasWorkflowArbitrationMethods(input.notificationServer);
36
62
  if (input.runtimeVersion !== input.nativeVersion || !workflowArbitrationAvailable)
@@ -38,5 +64,6 @@ export function assertNativeRuntimeCompatibility(input: {
38
64
  input.runtimeVersion,
39
65
  input.nativeVersion,
40
66
  workflowArbitrationAvailable,
67
+ input.nativeModulePath ?? resolveNativeModulePath(),
41
68
  );
42
69
  }
@@ -202,6 +202,8 @@ import { onAppendOnlyModeChanged } from "../config/settings";
202
202
  import type { SettingPath } from "../config/settings-schema";
203
203
  import { getDefault } from "../config/settings-schema";
204
204
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
205
+ import { createDecisionService } from "../decisions";
206
+ import { createSemanticSkillRouter, type SkillRouter } from "../decisions/skill-routing";
205
207
  import { loadCapability } from "../discovery";
206
208
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
207
209
  import { MAX_EDIT_FILE_BYTES } from "../edit/read-file";
@@ -260,7 +262,11 @@ import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slas
260
262
  import { GoalRuntime } from "../goals/runtime";
261
263
  import type { Goal, GoalModeState } from "../goals/state";
262
264
  import type { HindsightSessionState } from "../hindsight/state";
263
- import { buildSkillStopOutput, ensureWorkflowSkillActivationState } from "../hooks/skill-state";
265
+ import {
266
+ buildSkillStopOutput,
267
+ detectPrimarySkillKeyword,
268
+ ensureWorkflowSkillActivationState,
269
+ } from "../hooks/skill-state";
264
270
  import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
265
271
  import { shutdownAll as shutdownAllLspClients } from "../lsp/client";
266
272
  import { resolveMemoryBackend } from "../memory-backend";
@@ -1842,6 +1848,9 @@ export class AgentSession {
1842
1848
  // Model registry for API key resolution
1843
1849
  #modelRegistry: ModelRegistry;
1844
1850
 
1851
+ /** Built on first use; the decision service resolves model and credential lazily. */
1852
+ #semanticSkillRouter?: SkillRouter;
1853
+
1845
1854
  // Tool registry and prompt builder for extensions
1846
1855
  #toolRegistry: Map<string, AgentTool>;
1847
1856
  #workflowGateToolSession: ToolSession | undefined;
@@ -7548,6 +7557,41 @@ export class AgentSession {
7548
7557
  * @throws Error if streaming and no streamingBehavior specified
7549
7558
  * @throws Error if no model selected or no API key available (when not streaming)
7550
7559
  */
7560
+ /**
7561
+ * Second-stage workflow routing for prompts the keyword table cannot see.
7562
+ *
7563
+ * Runs in this process, not the hook process: the hook only receives paths and
7564
+ * config, so it has no model registry and no credentials to call anything with.
7565
+ *
7566
+ * Deliberately best-effort — a disabled setting, a missing credential, a timeout or
7567
+ * a nonsense answer all resolve to "no activation", which is precisely the
7568
+ * behaviour before this stage existed.
7569
+ */
7570
+ async #routeWorkflowSemantically(text: string): Promise<void> {
7571
+ if (!this.settings.get("decisions.enabled")) return;
7572
+ if (detectPrimarySkillKeyword(text)) return; // deterministic stage already decided
7573
+ try {
7574
+ this.#semanticSkillRouter ??= createSemanticSkillRouter(
7575
+ createDecisionService({
7576
+ registry: this.#modelRegistry,
7577
+ settings: this.settings,
7578
+ sessionId: this.sessionManager.getSessionId(),
7579
+ enabled: true,
7580
+ }),
7581
+ );
7582
+ const skill = await this.#semanticSkillRouter(text);
7583
+ if (!skill) return;
7584
+ await ensureWorkflowSkillActivationState({
7585
+ cwd: this.sessionManager.getCwd(),
7586
+ skill,
7587
+ sessionId: this.sessionManager.getSessionId(),
7588
+ });
7589
+ this.#attachAskTool();
7590
+ } catch (error) {
7591
+ logger.debug("agent-session: semantic workflow routing failed", { error: String(error) });
7592
+ }
7593
+ }
7594
+
7551
7595
  async prompt(text: string, options?: PromptOptions): Promise<void> {
7552
7596
  this.#assertRecoveryHydrationPromoted();
7553
7597
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
@@ -7606,6 +7650,12 @@ export class AgentSession {
7606
7650
  const deepInterviewUserIntentEpoch =
7607
7651
  claimsGenuineUserIntent && !this.isStreaming ? this.#claimDeepInterviewUserIntent() : undefined;
7608
7652
 
7653
+ // The keyword table in `hooks/skill-keywords.ts` is thirteen literal strings, so a
7654
+ // Korean phrasing of "plan this before you touch code" activates nothing. Ask a
7655
+ // cheap model only when the deterministic stage found nothing, and only for real
7656
+ // user turns. Any failure leaves routing to the system prompt, exactly as before.
7657
+ if (claimsGenuineUserIntent && !this.isStreaming) await this.#routeWorkflowSemantically(expandedText);
7658
+
7609
7659
  // If streaming, queue via steer() or followUp() based on option
7610
7660
  if (this.isStreaming) {
7611
7661
  if (!options?.streamingBehavior) {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Store a TypeSafe key so the typed-decision service can use the hosted model.
3
+ *
4
+ * TypeSafe is **not** a chat provider: no streaming, no messages, no text output. So it
5
+ * must not be written into `models.yml` the way `addApiCompatibleProvider` writes
6
+ * OpenAI-compatible endpoints — that would invent a chat model that cannot answer a
7
+ * chat request and would show up in the model picker as something users could select.
8
+ *
9
+ * Only the credential is stored. The decision backend reads it through the same
10
+ * `getApiKeyForProvider` path every other provider uses, so adding a key turns the
11
+ * backend on and removing it turns the backend off, with nothing else to configure.
12
+ */
13
+ import { AuthStorage } from "@sayknow-cli/ai";
14
+ import { getAgentDbPath } from "@sayknow-cli/utils";
15
+ import { TYPESAFE_PROVIDER } from "../decisions";
16
+
17
+ const VERIFY_ENDPOINT = "https://api.typesafe.ai/v1/models";
18
+ const VERIFY_TIMEOUT_MS = 10_000;
19
+
20
+ export interface TypeSafeKeySetupResult {
21
+ provider: string;
22
+ verified: boolean;
23
+ /** Present when verification ran and failed; the key is not stored in that case. */
24
+ error?: string;
25
+ }
26
+
27
+ /**
28
+ * Verify a key against the live API before storing it.
29
+ *
30
+ * Storing an unverified key is worse than refusing it: the decision service fails open,
31
+ * so a bad key produces no error anywhere — it silently falls back to the user's own
32
+ * model forever, and the user believes TypeSafe is active.
33
+ */
34
+ export async function verifyTypeSafeKey(apiKey: string, fetchImpl: typeof fetch = fetch): Promise<string | null> {
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
37
+ try {
38
+ const response = await fetchImpl(VERIFY_ENDPOINT, {
39
+ headers: { Authorization: `Bearer ${apiKey}` },
40
+ signal: controller.signal,
41
+ });
42
+ if (response.ok) return null;
43
+ if (response.status === 401 || response.status === 403) return "key rejected by TypeSafe (401/403)";
44
+ return `TypeSafe returned ${response.status}`;
45
+ } catch (error) {
46
+ return `could not reach TypeSafe: ${error instanceof Error ? error.message : String(error)}`;
47
+ } finally {
48
+ clearTimeout(timer);
49
+ }
50
+ }
51
+
52
+ export interface SetTypeSafeKeyOptions {
53
+ apiKey: string;
54
+ /** Skip the live check. Only for offline setup; the key may be wrong. */
55
+ skipVerify?: boolean;
56
+ fetchImpl?: typeof fetch;
57
+ dbPath?: string;
58
+ }
59
+
60
+ export async function setTypeSafeKey(options: SetTypeSafeKeyOptions): Promise<TypeSafeKeySetupResult> {
61
+ const apiKey = options.apiKey.trim();
62
+ if (!apiKey) throw new Error("TypeSafe API key must not be empty");
63
+
64
+ let verified = false;
65
+ if (!options.skipVerify) {
66
+ const failure = await verifyTypeSafeKey(apiKey, options.fetchImpl ?? fetch);
67
+ if (failure) return { provider: TYPESAFE_PROVIDER, verified: false, error: failure };
68
+ verified = true;
69
+ }
70
+
71
+ const authStorage = await AuthStorage.create(options.dbPath ?? getAgentDbPath());
72
+ try {
73
+ await authStorage.set(TYPESAFE_PROVIDER, { type: "api_key", key: apiKey });
74
+ } finally {
75
+ authStorage.close();
76
+ }
77
+ return { provider: TYPESAFE_PROVIDER, verified };
78
+ }
79
+
80
+ export async function removeTypeSafeKey(dbPath?: string): Promise<void> {
81
+ const authStorage = await AuthStorage.create(dbPath ?? getAgentDbPath());
82
+ try {
83
+ await authStorage.remove(TYPESAFE_PROVIDER);
84
+ } finally {
85
+ authStorage.close();
86
+ }
87
+ }
88
+
89
+ export function formatTypeSafeKeyResult(result: TypeSafeKeySetupResult): string {
90
+ if (result.error) return `✗ TypeSafe key not stored: ${result.error}`;
91
+ return result.verified
92
+ ? "✓ TypeSafe key verified and stored. Typed decisions will use the hosted model."
93
+ : "✓ TypeSafe key stored (unverified). Typed decisions will try the hosted model.";
94
+ }