@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
@@ -2,15 +2,20 @@
2
2
  * `skc auth-gateway` command handlers.
3
3
  *
4
4
  * Boots a forward-proxy server that lets less-trusted clients (the macOS
5
- * usage widget and containerized deployments) make provider API calls without ever
6
- * seeing the access token. The gateway is itself a broker client and
7
- * resolves credentials through the configured broker (via the same
8
- * `SKC_AUTH_BROKER_URL` / `auth.broker.url` precedence used elsewhere).
5
+ * usage widget, local desktop apps and containerized deployments) make provider
6
+ * API calls without ever seeing the access token.
7
+ *
8
+ * Credential source mirrors `discoverAuthStorage()` in sdk/session.ts:
9
+ * - broker configured (`SKC_AUTH_BROKER_URL` / `auth.broker.url`) → the
10
+ * gateway is a broker client and never touches local SQLite.
11
+ * - no broker → the local SQLite store at `<agentDir>/agent.db`, so a
12
+ * single-machine user runs the gateway alone instead of also standing up
13
+ * `skc auth-broker serve`.
9
14
  *
10
15
  * Sub-verbs:
11
- * - `serve [--bind=…]` — boots the gateway against the configured broker.
16
+ * - `serve [--bind=…]` — boots the gateway against the broker or local store.
12
17
  * - `token` / `token --regenerate` — manages the gateway bearer token file.
13
- * - `status` — prints the locally-stored gateway token and bind hint.
18
+ * - `status` — prints the locally-stored gateway token and credential source.
14
19
  */
15
20
  import * as crypto from "node:crypto";
16
21
  import * as fs from "node:fs/promises";
@@ -28,7 +33,7 @@ import {
28
33
  type SnapshotResponse,
29
34
  startAuthGateway,
30
35
  } from "@sayknow-cli/ai";
31
- import { getConfigRootDir, isEnoent, VERSION } from "@sayknow-cli/utils";
36
+ import { getAgentDbPath, getAgentDir, getConfigRootDir, isEnoent, VERSION } from "@sayknow-cli/utils";
32
37
  import chalk from "chalk";
33
38
  import { type AuthBrokerClientConfig, resolveAuthBrokerConfig } from "../session/auth-broker-config";
34
39
 
@@ -128,29 +133,66 @@ async function fetchBrokerSnapshot(client: AuthBrokerClient): Promise<SnapshotRe
128
133
  return result.snapshot;
129
134
  }
130
135
 
131
- async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
136
+ /**
137
+ * Credential source the gateway is serving from. `broker` mirrors the previous
138
+ * behaviour; `local` is the single-machine path that makes `skc auth-broker
139
+ * serve` optional.
140
+ */
141
+ export interface GatewayCredentialSource {
142
+ storage: AuthStorage;
143
+ kind: "broker" | "local";
144
+ /** Broker URL in broker mode, `null` in local mode. */
145
+ brokerUrl: string | null;
146
+ /** `<agentDir>/agent.db` in local mode, `null` in broker mode. */
147
+ dbPath: string | null;
148
+ /** `broker <url>` / `local <dbPath>` — also used as the AuthStorage sourceLabel. */
149
+ label: string;
150
+ }
151
+
152
+ /**
153
+ * Open the credential store the gateway should serve from.
154
+ *
155
+ * Same precedence as `discoverAuthStorage()` in sdk/session.ts: a configured
156
+ * broker wins, otherwise fall back to the local SQLite store. Callers own the
157
+ * returned `storage` and must `close()` it.
158
+ */
159
+ export async function openGatewayCredentialSource(): Promise<GatewayCredentialSource> {
132
160
  const brokerConfig = await resolveAuthBrokerConfig();
133
- if (!brokerConfig) {
134
- throw new Error(
135
- "`skc auth-gateway serve` requires SKC_AUTH_BROKER_URL (or `auth.broker.url`/`auth.broker.token` in config.yml). The gateway is itself a broker client.",
136
- );
161
+ if (brokerConfig) {
162
+ // Refresh + usage both flow through the store's broker hooks automatically —
163
+ // `RemoteAuthCredentialStore.refreshOAuthCredential` and `.fetchUsageReports`.
164
+ // AuthStorage discovers them when no explicit option overrides them, so the
165
+ // gateway only needs to construct the store and pass it in.
166
+ const client = createBrokerClient(brokerConfig);
167
+ const initialSnapshot = await fetchBrokerSnapshot(client);
168
+ const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
169
+ const label = `broker ${brokerConfig.url}`;
170
+ const storage = new AuthStorage(store, { sourceLabel: label });
171
+ try {
172
+ await storage.reload();
173
+ } catch (error) {
174
+ try {
175
+ storage.close();
176
+ } catch {
177
+ // Preserve the initial reload failure.
178
+ }
179
+ throw error;
180
+ }
181
+ return { storage, kind: "broker", brokerUrl: brokerConfig.url, dbPath: null, label };
137
182
  }
183
+ const dbPath = getAgentDbPath(getAgentDir());
184
+ const label = `local ${dbPath}`;
185
+ // `AuthStorage.create` opens the store and reloads it in one step.
186
+ const storage = await AuthStorage.create(dbPath, { sourceLabel: label });
187
+ return { storage, kind: "local", brokerUrl: null, dbPath, label };
188
+ }
189
+
190
+ async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
138
191
  const bind = flags.bind ?? DEFAULT_AUTH_GATEWAY_BIND;
139
192
  const gatewayToken = flags.noAuth ? null : await ensureToken();
140
193
 
141
- // Build a broker-backed AuthStorage — same pattern as discoverAuthStorage()
142
- // in sdk/session.ts. The gateway never touches local SQLite.
143
- const client = createBrokerClient(brokerConfig);
144
- const initialSnapshot = await fetchBrokerSnapshot(client);
145
- const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
146
- // Refresh + usage both flow through the store's broker hooks automatically —
147
- // `RemoteAuthCredentialStore.refreshOAuthCredential` and `.fetchUsageReports`.
148
- // AuthStorage discovers them when no explicit option overrides them, so the
149
- // gateway only needs to construct the store and pass it in.
150
- const storage = new AuthStorage(store, {
151
- sourceLabel: `broker ${brokerConfig.url}`,
152
- });
153
- await storage.reload();
194
+ const source = await openGatewayCredentialSource();
195
+ const storage = source.storage;
154
196
 
155
197
  // Build the model resolver + catalog from pi-ai's bundled metadata, scoped
156
198
  // to providers we hold credentials for. Format handlers ask `resolveModel`
@@ -183,7 +225,11 @@ async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
183
225
  } else {
184
226
  process.stdout.write(`auth: disabled (--no-auth) — any client can call this gateway\n`);
185
227
  }
186
- process.stdout.write(`upstream broker: ${brokerConfig.url}\n`);
228
+ if (source.kind === "broker") {
229
+ process.stdout.write(`upstream broker: ${source.brokerUrl}\n`);
230
+ } else {
231
+ process.stdout.write(`credentials: local ${source.dbPath}\n`);
232
+ }
187
233
 
188
234
  const stopped = Promise.withResolvers<void>();
189
235
  let shutdownStarted = false;
@@ -243,24 +289,35 @@ async function runToken(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
243
289
 
244
290
  async function runStatus(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
245
291
  const token = await readToken();
246
- const brokerConfig = await resolveAuthBrokerConfig();
247
292
  const tokenFile = getTokenFilePath();
248
- if (!brokerConfig) {
293
+ const tokenPresent = token !== null;
294
+
295
+ let source: GatewayCredentialSource;
296
+ try {
297
+ source = await openGatewayCredentialSource();
298
+ } catch (error) {
299
+ // Broker unreachable, or the local SQLite store failed to open.
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ const brokerConfig = await resolveAuthBrokerConfig().catch(() => null);
249
302
  const status = {
250
303
  ready: false,
251
- reason: "not_configured",
304
+ reason: brokerConfig ? "broker_unavailable" : "local_store_unavailable",
305
+ source: brokerConfig ? "broker" : "local",
252
306
  tokenFile,
253
- tokenPresent: token !== null,
254
- broker: null,
255
- brokerConfigured: false,
307
+ tokenPresent,
308
+ broker: brokerConfig?.url ?? null,
309
+ brokerConfigured: brokerConfig !== null,
256
310
  brokerAuthenticated: false,
311
+ dbPath: brokerConfig ? null : getAgentDbPath(getAgentDir()),
312
+ error: message,
257
313
  };
258
314
  if (flags.json) {
259
315
  process.stdout.write(`${JSON.stringify(status)}\n`);
260
316
  } else {
261
- process.stdout.write(`${chalk.yellow("No broker configured.")} Set SKC_AUTH_BROKER_URL.\n`);
317
+ const what = brokerConfig ? `upstream broker: ${brokerConfig.url}` : `local store: ${status.dbPath}`;
318
+ process.stdout.write(`${chalk.red("FAILED")} ${what}: ${message}\n`);
262
319
  process.stdout.write(
263
- `token: ${status.tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${status.tokenFile}\n`,
320
+ `token: ${tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${tokenFile}\n`,
264
321
  );
265
322
  }
266
323
  process.exitCode = 1;
@@ -268,56 +325,50 @@ async function runStatus(flags: AuthGatewayCommandArgs["flags"]): Promise<void>
268
325
  }
269
326
 
270
327
  try {
271
- const snapshot = await fetchBrokerSnapshot(createBrokerClient(brokerConfig));
272
- const tokenPresent = token !== null;
328
+ const credentialCount = source.storage.exportSnapshot().credentials.length;
329
+ // Ready means a client can actually get an answer: it needs a bearer
330
+ // token to present, and we need at least one provider credential to serve.
331
+ const ready = tokenPresent && credentialCount > 0;
273
332
  const status = {
274
- ready: tokenPresent,
275
- reason: tokenPresent ? null : "token_missing",
333
+ ready,
334
+ reason: !tokenPresent ? "token_missing" : credentialCount === 0 ? "no_credentials" : null,
335
+ source: source.kind,
276
336
  tokenFile,
277
337
  tokenPresent,
278
- broker: brokerConfig.url,
279
- brokerConfigured: true,
280
- brokerAuthenticated: true,
281
- credentialCount: snapshot.credentials.length,
338
+ broker: source.brokerUrl,
339
+ brokerConfigured: source.kind === "broker",
340
+ brokerAuthenticated: source.kind === "broker",
341
+ dbPath: source.dbPath,
342
+ credentialCount,
282
343
  };
283
344
  if (flags.json) {
284
345
  process.stdout.write(`${JSON.stringify(status)}\n`);
285
346
  } else {
286
- const brokerLine = `upstream broker: ${brokerConfig.url} (${snapshot.credentials.length} credential${
287
- snapshot.credentials.length === 1 ? "" : "s"
288
- })`;
289
- process.stdout.write(`${tokenPresent ? chalk.green("ready") : chalk.yellow("not ready")} ${brokerLine}\n`);
347
+ const plural = credentialCount === 1 ? "" : "s";
348
+ const sourceLine =
349
+ source.kind === "broker"
350
+ ? `upstream broker: ${source.brokerUrl} (${credentialCount} credential${plural})`
351
+ : `local store: ${source.dbPath} (${credentialCount} credential${plural})`;
352
+ process.stdout.write(`${ready ? chalk.green("ready") : chalk.yellow("not ready")} ${sourceLine}\n`);
290
353
  process.stdout.write(
291
- `token: ${tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${status.tokenFile}\n`,
354
+ `token: ${tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${tokenFile}\n`,
292
355
  );
293
356
  if (!tokenPresent) {
294
357
  process.stdout.write(
295
358
  "Run `skc auth-gateway token` or `skc auth-gateway serve` to create a bearer token.\n",
296
359
  );
297
360
  }
361
+ if (credentialCount === 0) {
362
+ process.stdout.write(
363
+ source.kind === "broker"
364
+ ? "The broker holds no credentials. Log in on the broker host.\n"
365
+ : "No local credentials. Run `skc auth-broker login <provider>` (e.g. anthropic).\n",
366
+ );
367
+ }
298
368
  }
299
- if (!tokenPresent) process.exitCode = 1;
300
- } catch (error) {
301
- const message = error instanceof Error ? error.message : String(error);
302
- const status = {
303
- ready: false,
304
- reason: "broker_unavailable",
305
- tokenFile,
306
- tokenPresent: token !== null,
307
- broker: brokerConfig.url,
308
- brokerConfigured: true,
309
- brokerAuthenticated: false,
310
- error: message,
311
- };
312
- if (flags.json) {
313
- process.stdout.write(`${JSON.stringify(status)}\n`);
314
- } else {
315
- process.stdout.write(`${chalk.red("FAILED")} upstream broker: ${brokerConfig.url}: ${message}\n`);
316
- process.stdout.write(
317
- `token: ${status.tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${status.tokenFile}\n`,
318
- );
319
- }
320
- process.exitCode = 1;
369
+ if (!ready) process.exitCode = 1;
370
+ } finally {
371
+ source.storage.close();
321
372
  }
322
373
  }
323
374
 
@@ -343,30 +394,22 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
343
394
  }
344
395
 
345
396
  /**
346
- * `skc auth-gateway check` — probe each broker-supplied credential and print
347
- * per-credential auth health. Use this when the gateway is returning 401s and
397
+ * `skc auth-gateway check` — probe each credential the gateway would serve and
398
+ * print per-credential auth health. Use this when the gateway is returning 401s
348
399
  * you need to find which row in a multi-account pool is the bad one. The
349
400
  * aggregate `/v1/usage` endpoint silently drops failed credentials, so a
350
401
  * dedicated diagnostic is the only way to see which credentials failed.
351
402
  */
352
403
  async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
353
- const brokerConfig = await resolveAuthBrokerConfig();
354
- if (!brokerConfig) {
355
- throw new Error(
356
- "`skc auth-gateway check` requires SKC_AUTH_BROKER_URL (or `auth.broker.url`/`auth.broker.token` in config.yml). It probes the same credentials the gateway would serve.",
357
- );
358
- }
359
-
360
- const client = createBrokerClient(brokerConfig);
361
- const initialSnapshot = await fetchBrokerSnapshot(client);
362
- const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
363
- const storage = new AuthStorage(store, { sourceLabel: `broker ${brokerConfig.url}` });
404
+ const source = await openGatewayCredentialSource();
405
+ const storage = source.storage;
364
406
  try {
365
- await storage.reload();
366
407
  const results = await storage.checkCredentials();
367
408
 
368
409
  if (flags.json) {
369
- process.stdout.write(`${JSON.stringify({ broker: brokerConfig.url, credentials: results }, null, 2)}\n`);
410
+ process.stdout.write(
411
+ `${JSON.stringify({ source: source.kind, broker: source.brokerUrl, dbPath: source.dbPath, credentials: results }, null, 2)}\n`,
412
+ );
370
413
  } else {
371
414
  const grouped = new Map<string, typeof results>();
372
415
  for (const row of results) {
@@ -375,7 +418,7 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
375
418
  grouped.set(row.provider, list);
376
419
  }
377
420
  const providers = [...grouped.keys()].sort();
378
- process.stdout.write(`broker: ${brokerConfig.url}\n`);
421
+ process.stdout.write(`${source.label}\n`);
379
422
  for (const provider of providers) {
380
423
  const rows = grouped.get(provider) ?? [];
381
424
  process.stdout.write(`\n${chalk.bold(provider)} (${rows.length})\n`);
@@ -48,6 +48,7 @@ export type SetupComponent =
48
48
  | "provider"
49
49
  | "python"
50
50
  | "stt"
51
+ | "typesafe"
51
52
  | "ui-skills";
52
53
 
53
54
  export interface SetupCommandArgs {
@@ -81,6 +82,8 @@ export interface SetupCommandArgs {
81
82
  yes?: boolean;
82
83
  dryRun?: boolean;
83
84
  keychain?: boolean;
85
+ skipVerify?: boolean;
86
+ remove?: boolean;
84
87
  };
85
88
  }
86
89
 
@@ -94,6 +97,7 @@ const VALID_COMPONENTS: SetupComponent[] = [
94
97
  "provider",
95
98
  "python",
96
99
  "stt",
100
+ "typesafe",
97
101
  ];
98
102
 
99
103
  function hasProviderSetupFlags(flags: SetupCommandArgs["flags"]): boolean {
@@ -188,8 +192,12 @@ export function parseSetupArgs(args: string[]): SetupCommandArgs | undefined {
188
192
  } else if (arg === "--base-url") {
189
193
  flags.baseUrl = args[++i];
190
194
  } else if (arg === "--api-key") {
191
- console.error(chalk.red("Provider setup rejects raw --api-key values; use --api-key-env <ENV> instead."));
195
+ console.error(chalk.red("Setup rejects raw --api-key values; pass the key through an environment variable."));
192
196
  process.exit(1);
197
+ } else if (arg === "--skip-verify") {
198
+ flags.skipVerify = true;
199
+ } else if (arg === "--remove") {
200
+ flags.remove = true;
193
201
  } else if (arg === "--api-key-env") {
194
202
  flags.apiKeyEnv = args[++i];
195
203
  } else if (arg === "--model" || arg === "--models") {
@@ -289,6 +297,9 @@ export async function runSetupCommand(cmd: SetupCommandArgs): Promise<void> {
289
297
  case "stt":
290
298
  await handleSttSetup(cmd.flags);
291
299
  break;
300
+ case "typesafe":
301
+ await handleTypeSafeSetup(cmd.flags);
302
+ break;
292
303
  case "credentials":
293
304
  await handleCredentialsSetup(cmd.flags);
294
305
  break;
@@ -802,3 +813,44 @@ ${chalk.bold("Examples:")}
802
813
  ${APP_NAME} setup credentials --yes Import without an interactive prompt
803
814
  `);
804
815
  }
816
+
817
+ /**
818
+ * `skc setup typesafe` — enable the hosted System One model for typed decisions.
819
+ *
820
+ * The key is read from `TYPESAFE_API_KEY`, never from a flag: this repo already refuses
821
+ * raw `--api-key` values because they land in shell history and in the process list of
822
+ * every user on the machine. Same rule applies here.
823
+ *
824
+ * Verified against the live API before storing. The decision service fails open, so an
825
+ * unverified bad key would be swallowed forever — the user would believe TypeSafe was
826
+ * active while every decision quietly came from their own model.
827
+ */
828
+ export async function handleTypeSafeSetup(flags: SetupCommandArgs["flags"]): Promise<void> {
829
+ const { formatTypeSafeKeyResult, removeTypeSafeKey, setTypeSafeKey } = await import("../setup/decision-provider");
830
+
831
+ if (flags.remove) {
832
+ await removeTypeSafeKey();
833
+ process.stdout.write("TypeSafe key removed. Typed decisions fall back to your logged-in model.\n");
834
+ return;
835
+ }
836
+
837
+ const apiKey = process.env.TYPESAFE_API_KEY?.trim();
838
+ if (!apiKey) {
839
+ process.stdout.write(
840
+ `Usage: TYPESAFE_API_KEY=<key> ${APP_NAME} setup typesafe [--skip-verify]\n` +
841
+ ` ${APP_NAME} setup typesafe --remove\n\n` +
842
+ "The key is taken from the environment on purpose: a flag would leak it into\n" +
843
+ "shell history and the process list.\n",
844
+ );
845
+ process.exitCode = 1;
846
+ return;
847
+ }
848
+
849
+ const result = await setTypeSafeKey({ apiKey, skipVerify: flags.skipVerify });
850
+ if (flags.json) {
851
+ process.stdout.write(`${JSON.stringify(result)}\n`);
852
+ } else {
853
+ process.stdout.write(`${formatTypeSafeKeyResult(result)}\n`);
854
+ }
855
+ if (result.error) process.exitCode = 1;
856
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `skc auth-gateway` — run a forward proxy that injects auth from the broker.
2
+ * `skc auth-gateway` — run a forward proxy that injects auth from the broker,
3
+ * or from the local credential store when no broker is configured.
3
4
  */
4
5
  import { Args, Command, Flags, renderCommandHelp } from "@sayknow-cli/utils/cli";
5
6
  import {
@@ -11,7 +12,8 @@ import {
11
12
  import { initTheme } from "../modes/theme/theme";
12
13
 
13
14
  export default class AuthGateway extends Command {
14
- static description = "Run an auth-gateway forward proxy backed by the configured broker";
15
+ static description =
16
+ "Run an auth-gateway forward proxy backed by the configured broker, or local credentials when no broker is set";
15
17
 
16
18
  static args = {
17
19
  action: Args.string({
@@ -32,13 +34,13 @@ export default class AuthGateway extends Command {
32
34
  };
33
35
 
34
36
  static examples = [
35
- "# Boot the gateway against the configured broker\n skc auth-gateway serve",
37
+ "# Boot the gateway (broker when configured, else local credentials)\n skc auth-gateway serve",
36
38
  "# Boot on a non-default port\n skc auth-gateway serve --bind=127.0.0.1:4000",
37
39
  "# Print the gateway bearer token (creates one on first run)\n skc auth-gateway token",
38
40
  "# Rotate the gateway bearer token\n skc auth-gateway token --regenerate",
39
41
  "# Run on loopback without any bearer (anyone on this host can call)\n skc auth-gateway serve --no-auth",
40
- "# Show local gateway + broker config status\n skc auth-gateway status",
41
- "# Probe each broker credential to see which one is producing 401s\n skc auth-gateway check",
42
+ "# Show gateway token + credential source status\n skc auth-gateway status",
43
+ "# Probe each credential to see which one is producing 401s\n skc auth-gateway check",
42
44
  "# Same, machine-readable for scripts\n skc auth-gateway check --json",
43
45
  ];
44
46
 
@@ -15,6 +15,7 @@ const COMPONENTS: SetupComponent[] = [
15
15
  "provider",
16
16
  "python",
17
17
  "stt",
18
+ "typesafe",
18
19
  "ui-skills",
19
20
  ];
20
21
 
@@ -60,6 +61,8 @@ export default class Setup extends Command {
60
61
  "models-path": Flags.string({ description: "Override models config path" }),
61
62
  yes: Flags.boolean({ char: "y", description: "Import discovered credentials without an interactive prompt" }),
62
63
  "dry-run": Flags.boolean({ description: "Preview discovered credentials without importing" }),
64
+ "skip-verify": Flags.boolean({ description: "Store the TypeSafe key without checking it against the live API" }),
65
+ remove: Flags.boolean({ description: "Remove the stored TypeSafe key" }),
63
66
  };
64
67
 
65
68
  async run(): Promise<void> {
@@ -94,6 +97,8 @@ export default class Setup extends Command {
94
97
  profileDir: flags["profile-dir"],
95
98
  yes: flags.yes,
96
99
  dryRun: flags["dry-run"],
100
+ skipVerify: flags["skip-verify"],
101
+ remove: flags.remove,
97
102
  },
98
103
  };
99
104
  await initTheme();
@@ -1999,6 +1999,18 @@ export const SETTINGS_SCHEMA = {
1999
1999
  "hindsight.mentalModelRefreshIntervalMs": { type: "number", default: 5 * 60 * 1000 },
2000
2000
  "hindsight.mentalModelMaxRenderChars": { type: "number", default: 16_000 },
2001
2001
 
2002
+ // Typed decisions
2003
+ "decisions.enabled": {
2004
+ type: "boolean",
2005
+ default: false,
2006
+ ui: {
2007
+ tab: "context",
2008
+ label: "Typed decisions",
2009
+ description:
2010
+ "Let a cheap model answer typed questions the deterministic rules cannot. Currently routes workflow skills when the keyword table finds nothing — which is every non-English phrasing. Costs one small model call on those prompts; every failure falls back to today's behaviour.",
2011
+ },
2012
+ },
2013
+
2002
2014
  // TTSR
2003
2015
  "ttsr.enabled": {
2004
2016
  type: "boolean",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Typed decisions — entry point.
3
+ *
4
+ * Call sites ask for a judgment and get a typed answer or nothing. They never learn
5
+ * which backend answered, and they never have to handle a transport error: every
6
+ * failure path resolves `null`. That is deliberate — a decision service is an
7
+ * *enhancement* to code that already works, so an outage must degrade behaviour to
8
+ * the previous default rather than break the turn.
9
+ */
10
+ import { logger } from "@sayknow-cli/utils";
11
+ import { createLlmDecisionBackend, type LlmBackendDeps } from "./llm-backend";
12
+ import type { DecisionBackend, DecisionRequest, DecisionResult } from "./types";
13
+ import { createTypeSafeDecisionBackend } from "./typesafe-backend";
14
+
15
+ export { createLlmDecisionBackend } from "./llm-backend";
16
+ export * from "./types";
17
+ export { createTypeSafeDecisionBackend, TYPESAFE_PROVIDER } from "./typesafe-backend";
18
+
19
+ /** Hard ceiling. A decision that takes longer than this is worthless to the caller. */
20
+ const DEFAULT_TIMEOUT_MS = 8_000;
21
+
22
+ export interface DecisionServiceOptions extends LlmBackendDeps {
23
+ /** Off by default; callers opt in per feature. */
24
+ enabled?: boolean;
25
+ timeoutMs?: number;
26
+ /** Injection point for tests and for the self-hosted/hosted backends. */
27
+ backends?: DecisionBackend[];
28
+ }
29
+
30
+ export interface DecisionService {
31
+ readonly enabled: boolean;
32
+ /** Resolves null when disabled, unavailable, timed out, or the model misbehaved. */
33
+ decide(request: DecisionRequest): Promise<DecisionResult | null>;
34
+ }
35
+
36
+ export function createDecisionService(options: DecisionServiceOptions): DecisionService {
37
+ const enabled = options.enabled ?? false;
38
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
39
+ /**
40
+ * Order matters and is not configurable by accident.
41
+ *
42
+ * TypeSafe first *when a key exists*: it is the only backend that returns calibrated
43
+ * probabilities, and it resolves `null` immediately when no key is stored, so users
44
+ * who never added one pay nothing for it being in the list.
45
+ *
46
+ * The user's logged-in model is the fallback and the default experience: no extra
47
+ * vendor, no extra key, works offline of TypeSafe entirely.
48
+ */
49
+ const backends = options.backends ?? [createTypeSafeDecisionBackend(options), createLlmDecisionBackend(options)];
50
+
51
+ return {
52
+ enabled,
53
+ async decide(request: DecisionRequest): Promise<DecisionResult | null> {
54
+ if (!enabled || backends.length === 0) return null;
55
+ for (const backend of backends) {
56
+ const controller = new AbortController();
57
+ const abortOnCallerSignal = () => controller.abort();
58
+ request.signal?.addEventListener("abort", abortOnCallerSignal, { once: true });
59
+ let timer: ReturnType<typeof setTimeout> | undefined;
60
+ try {
61
+ // The deadline must be a race, not just an abort. A provider that ignores
62
+ // its signal would otherwise hang the caller's turn forever — and the
63
+ // caller here is the user's prompt, so "forever" means a frozen session.
64
+ const deadline = new Promise<null>(resolve => {
65
+ timer = setTimeout(() => {
66
+ controller.abort();
67
+ resolve(null);
68
+ }, timeoutMs);
69
+ });
70
+ const result = await Promise.race([backend.decide({ ...request, signal: controller.signal }), deadline]);
71
+ if (result) return result;
72
+ } catch (error) {
73
+ // Fail open: log and try the next backend, then give up quietly.
74
+ logger.debug("decisions: backend failed", { backend: backend.name, error: String(error) });
75
+ } finally {
76
+ if (timer) clearTimeout(timer);
77
+ controller.abort();
78
+ request.signal?.removeEventListener("abort", abortOnCallerSignal);
79
+ }
80
+ }
81
+ return null;
82
+ },
83
+ };
84
+ }