@sayknow-cli/coding-agent 0.6.6 → 0.6.8

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 (65) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/config/settings-schema.d.ts +79 -0
  3. package/dist/types/i18n/messages/en.d.ts +2 -0
  4. package/dist/types/modes/components/welcome.d.ts +21 -4
  5. package/dist/types/modes/interactive-mode.d.ts +2 -5
  6. package/dist/types/modes/types.d.ts +2 -0
  7. package/dist/types/sdk/broker/broker.d.ts +22 -0
  8. package/dist/types/sdk/broker/process-guard.d.ts +71 -0
  9. package/dist/types/sdk/broker/transport.d.ts +2 -0
  10. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  11. package/dist/types/session/agent-session.d.ts +9 -0
  12. package/dist/types/session/auth-storage-discovery.d.ts +15 -0
  13. package/dist/types/session/auto-fallback.d.ts +27 -0
  14. package/dist/types/session/fallback-chain-controller.d.ts +5 -0
  15. package/dist/types/session/response-language.d.ts +25 -0
  16. package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
  17. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  18. package/dist/types/tools/index.d.ts +1 -0
  19. package/dist/types/tools/locate-core.d.ts +97 -0
  20. package/dist/types/tools/locate.d.ts +44 -0
  21. package/package.json +7 -7
  22. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  23. package/src/cli/setup-cli.ts +7 -4
  24. package/src/commands/sdk.ts +3 -0
  25. package/src/commands/setup.ts +4 -1
  26. package/src/config/settings-schema.ts +82 -0
  27. package/src/decisions/typesafe-backend.ts +38 -4
  28. package/src/i18n/messages/de.settings.ts +26 -0
  29. package/src/i18n/messages/de.ts +2 -0
  30. package/src/i18n/messages/en.ts +2 -0
  31. package/src/i18n/messages/es.settings.ts +26 -0
  32. package/src/i18n/messages/es.ts +2 -0
  33. package/src/i18n/messages/fr.settings.ts +26 -0
  34. package/src/i18n/messages/fr.ts +2 -0
  35. package/src/i18n/messages/ja.settings.ts +26 -0
  36. package/src/i18n/messages/ja.ts +2 -0
  37. package/src/i18n/messages/ko.settings.ts +25 -0
  38. package/src/i18n/messages/ko.ts +2 -0
  39. package/src/i18n/messages/zh.settings.ts +23 -0
  40. package/src/i18n/messages/zh.ts +2 -0
  41. package/src/internal-urls/docs-index.generated.ts +7 -6
  42. package/src/modes/components/welcome.ts +241 -68
  43. package/src/modes/controllers/input-controller.ts +2 -0
  44. package/src/modes/controllers/selector-controller.ts +13 -0
  45. package/src/modes/interactive-mode.ts +123 -14
  46. package/src/modes/types.ts +2 -0
  47. package/src/prompts/system/system-prompt.md +7 -2
  48. package/src/prompts/tools/locate.md +12 -0
  49. package/src/sdk/broker/broker.ts +75 -1
  50. package/src/sdk/broker/process-guard.ts +160 -0
  51. package/src/sdk/broker/transport.ts +15 -1
  52. package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
  53. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  54. package/src/sdk/session.ts +4 -1
  55. package/src/session/agent-session.ts +118 -2
  56. package/src/session/auth-storage-discovery.ts +22 -7
  57. package/src/session/auto-fallback.ts +59 -0
  58. package/src/session/fallback-chain-controller.ts +5 -0
  59. package/src/session/response-language.ts +71 -0
  60. package/src/setup/model-onboarding-guidance.ts +29 -14
  61. package/src/setup/provider-onboarding.ts +5 -0
  62. package/src/slash-commands/builtin-registry.ts +112 -2
  63. package/src/tools/index.ts +3 -0
  64. package/src/tools/locate-core.ts +720 -0
  65. package/src/tools/locate.ts +203 -0
@@ -366,6 +366,7 @@ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-
366
366
  import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../workflow/workflow-intent-diff";
367
367
  import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
368
368
  import type { AuthStorage } from "./auth-storage";
369
+ import { autoFallbackSelectors, selectorProvider } from "./auto-fallback";
369
370
  import {
370
371
  DefaultModelSelectionRecoveryError,
371
372
  type DefaultModelSelectionResult,
@@ -378,6 +379,7 @@ import {
378
379
  effectiveFallbackDelay,
379
380
  FallbackChainController,
380
381
  } from "./fallback-chain-controller";
382
+ import { buildResponseLanguageReminder, detectResponseLanguage } from "./response-language";
381
383
 
382
384
  export { DefaultModelSelectionRecoveryError } from "./default-model-selection";
383
385
 
@@ -868,6 +870,14 @@ const KIMI_CODE_FIRST_EVENT_TIMEOUT_MESSAGES = {
868
870
  ]),
869
871
  } as const;
870
872
 
873
+ /**
874
+ * Configured default-chain origins that mean "exactly this model": a subagent call
875
+ * composed by the task router, and an explicit `--model` at startup. These never get
876
+ * `fallback.models` or automatic entries appended. (A temporary pick installs its own
877
+ * runtime controller and never reaches the configured-chain path at all.)
878
+ */
879
+ const EXACT_FALLBACK_CHAIN_ORIGINS: ReadonlySet<string> = new Set(["subagent", "startup-override"]);
880
+
871
881
  const ALIBABA_TOKEN_PLAN_PROVIDER = "alibaba-token-plan";
872
882
  const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MESSAGES = {
873
883
  "openai-responses": new Set([
@@ -7832,6 +7842,7 @@ export class AgentSession {
7832
7842
  const eagerTodoPrelude =
7833
7843
  !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
7834
7844
  const uiSkillPrelude = options?.synthetic ? undefined : this.#createUiSkillPrelude(expandedText);
7845
+ const languagePrelude = options?.synthetic ? undefined : this.#createResponseLanguagePrelude(expandedText);
7835
7846
 
7836
7847
  const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
7837
7848
  if (options?.images) {
@@ -7861,8 +7872,9 @@ export class AgentSession {
7861
7872
  await this.#promptWithMessage(message, expandedText, {
7862
7873
  ...options,
7863
7874
  prependMessages:
7864
- eagerTodoPrelude || uiSkillPrelude
7875
+ eagerTodoPrelude || uiSkillPrelude || languagePrelude
7865
7876
  ? [
7877
+ ...(languagePrelude ? [languagePrelude] : []),
7866
7878
  ...(uiSkillPrelude ? [uiSkillPrelude] : []),
7867
7879
  ...(eagerTodoPrelude ? [eagerTodoPrelude.message] : []),
7868
7880
  ]
@@ -12136,6 +12148,21 @@ export class AgentSession {
12136
12148
  * routing and therefore costs nothing extra. The patterns stay in front of it: when
12137
12149
  * they match, the model is never asked.
12138
12150
  */
12151
+ /**
12152
+ * Name the user's language at the start of the turn when their message is clearly in a
12153
+ * non-Latin script, so the final report after long English tool output stays in it.
12154
+ */
12155
+ #createResponseLanguagePrelude(promptText: string): AgentMessage | undefined {
12156
+ const language = detectResponseLanguage(promptText);
12157
+ if (!language) return undefined;
12158
+ return {
12159
+ role: "developer",
12160
+ content: [{ type: "text", text: buildResponseLanguageReminder(language) }],
12161
+ attribution: "agent",
12162
+ timestamp: Date.now(),
12163
+ };
12164
+ }
12165
+
12139
12166
  #createUiSkillPrelude(promptText: string): AgentMessage | undefined {
12140
12167
  if (this.#planModeState?.enabled) return undefined;
12141
12168
  const matched = buildUiSkillActivationContext(promptText);
@@ -13903,10 +13930,28 @@ export class AgentSession {
13903
13930
  controller.seedResolution(activeIndex, [...controller.skips, ...resolution.skips]);
13904
13931
  }
13905
13932
  if (!resolution.model) throw new Error(this.#fallbackExhaustionError(controller));
13933
+ if ((controller.chain.appendedFrom ?? controller.chain.entries.length) < 2) {
13934
+ // A one-model configuration is a chain only through appended fallbacks. With its
13935
+ // model usable and the session not sitting on an appended fallback, leave the live
13936
+ // model alone exactly as before anything was appended: a context promotion or a
13937
+ // temporary scope stays, and an unchanged model is not re-set (which would close
13938
+ // provider sessions such as the Codex websocket on every prompt).
13939
+ if (activeIndex === resolutionStart && !this.#isOnAppendedFallback(controller)) return;
13940
+ if (this.model && modelsAreEqual(this.model, resolution.model)) return;
13941
+ }
13906
13942
  this.#setModelAuthoritatively(resolution.model, "restore");
13907
13943
  this.setThinkingLevel(resolution.explicitThinkingLevel ? resolution.thinkingLevel : this.thinkingLevel);
13908
13944
  }
13909
13945
 
13946
+ /** True when the live model is one of the entries appended after the configured chain. */
13947
+ #isOnAppendedFallback(controller: FallbackChainController): boolean {
13948
+ const model = this.model;
13949
+ const appendedFrom = controller.chain.appendedFrom;
13950
+ if (!model || appendedFrom === undefined) return false;
13951
+ const key = `${model.provider}/${model.id}`;
13952
+ return controller.chain.entries.slice(appendedFrom).some(entry => entry === key || entry.startsWith(`${key}:`));
13953
+ }
13954
+
13910
13955
  /**
13911
13956
  * Materialize the default controller from the persisted configured-chain
13912
13957
  * metadata. Consumers seed only resolution state; role/origin/identity stay
@@ -13925,11 +13970,12 @@ export class AgentSession {
13925
13970
  if (materializeSettingsChain) {
13926
13971
  this.setConfiguredModelChain("default", settingsEntries, "modelRoles");
13927
13972
  }
13928
- const chain: ConfiguredFallbackChain = materializeSettingsChain
13973
+ const configured: ConfiguredFallbackChain = materializeSettingsChain
13929
13974
  ? { role: "default", entries: settingsEntries, origin: "modelRoles", explicitHead: true }
13930
13975
  : configuredChain
13931
13976
  ? { ...configuredChain, entries: [...configuredChain.entries] }
13932
13977
  : { role: "default", entries: settingsEntries, origin: "session", explicitHead: true };
13978
+ const chain = this.#withFallbackExtras(configured);
13933
13979
  const existing = this.#defaultFallbackController;
13934
13980
  if (
13935
13981
  existing &&
@@ -13943,6 +13989,76 @@ export class AgentSession {
13943
13989
  return this.#defaultFallbackController;
13944
13990
  }
13945
13991
 
13992
+ /**
13993
+ * Append what runs after the configured chain when it is blocked: the user's
13994
+ * `fallback.models`, then (with `fallback.auto`) one model from each other
13995
+ * logged-in provider. Extras live only in the controller; the configured chain
13996
+ * the session persists is untouched. Temporary picks, subagent calls and an
13997
+ * explicit `--model` stay exactly what was asked for.
13998
+ */
13999
+ #withFallbackExtras(chain: ConfiguredFallbackChain): ConfiguredFallbackChain {
14000
+ if (EXACT_FALLBACK_CHAIN_ORIGINS.has(chain.origin)) return chain;
14001
+ const entries = [...chain.entries];
14002
+ const seen = new Set(entries);
14003
+ const push = (selector: string): void => {
14004
+ const trimmed = selector.trim();
14005
+ if (!trimmed || seen.has(trimmed)) return;
14006
+ seen.add(trimmed);
14007
+ entries.push(trimmed);
14008
+ };
14009
+ for (const selector of this.settings.get("fallback.models")) push(selector);
14010
+ // Automatic picks stay out of an `enabledModels` allow-list (a deliberate scope) and
14011
+ // out of an explicit `retry.enabled: false` (the user asked for failures to surface).
14012
+ if (
14013
+ this.settings.get("fallback.auto") &&
14014
+ this.settings.get("retry.enabled") !== false &&
14015
+ this.settings.get("enabledModels").length === 0
14016
+ ) {
14017
+ // Depends only on configured intent (never the live model), so the chain stays
14018
+ // stable while a fallback is active and when the primary comes back.
14019
+ const excludeProviders = new Set<string>();
14020
+ for (const entry of entries) {
14021
+ const provider = selectorProvider(entry);
14022
+ if (provider) excludeProviders.add(provider);
14023
+ }
14024
+ for (const selector of this.#autoFallbackTail(excludeProviders)) push(selector);
14025
+ }
14026
+ return entries.length === chain.entries.length
14027
+ ? chain
14028
+ : { ...chain, entries, appendedFrom: chain.entries.length };
14029
+ }
14030
+
14031
+ #autoFallbackTail(excludeProviders: ReadonlySet<string>): string[] {
14032
+ try {
14033
+ const registry = this.#modelRegistry;
14034
+ return autoFallbackSelectors({
14035
+ available: registry.getAvailable(),
14036
+ hasCredentials: provider => registry.authStorage.hasAuth(provider),
14037
+ excludeProviders,
14038
+ usageOrder: this.settings.getStorage()?.getModelUsageOrder(),
14039
+ });
14040
+ } catch (error) {
14041
+ logger.debug("Automatic model fallback candidates unavailable", {
14042
+ error: error instanceof Error ? error.message : String(error),
14043
+ });
14044
+ return [];
14045
+ }
14046
+ }
14047
+
14048
+ /**
14049
+ * The default fallback chain the next prompt walks, automatic entries included,
14050
+ * and the position currently in use.
14051
+ */
14052
+ getDefaultFallbackChain(): { entries: readonly string[]; activeIndex: number; appendedFrom: number } {
14053
+ const controller = this.#defaultFallbackChain();
14054
+ const entries = [...controller.chain.entries];
14055
+ return {
14056
+ entries,
14057
+ activeIndex: controller.activeIndex,
14058
+ appendedFrom: controller.chain.appendedFrom ?? entries.length,
14059
+ };
14060
+ }
14061
+
13946
14062
  async #handleManagedAttemptOutcome(outcome: ManagedAttemptOutcome): Promise<ManagedAttemptDecision> {
13947
14063
  if (outcome.type === "run_terminal") {
13948
14064
  this.#defaultFallbackChain().resetAttemptBudget();
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { getAgentDbPath, getAgentDir } from "@sayknow-cli/utils";
11
11
  import { resolveConfigValue } from "../config/resolve-config-value";
12
+ import type { Settings } from "../config/settings";
12
13
  import { resolveAuthBrokerConfig } from "./auth-broker-config";
13
14
  import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./auth-storage";
14
15
 
@@ -25,7 +26,7 @@ import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./auth
25
26
  */
26
27
  export async function discoverAuthStorage(agentDir: string = getAgentDir()): Promise<AuthStorage> {
27
28
  const brokerConfig = await resolveAuthBrokerConfig();
28
- const credentialRankingMode = resolveCredentialRankingMode();
29
+ const credentialRankingMode = credentialRankingModeFromEnv();
29
30
  if (brokerConfig) {
30
31
  const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
31
32
  const initialResult = await client.fetchSnapshot();
@@ -70,14 +71,28 @@ export async function discoverAuthStorage(agentDir: string = getAgentDir()): Pro
70
71
  }
71
72
 
72
73
  /**
73
- * Opt-in multi-account credential ranking mode, read from the
74
- * `SKC_CREDENTIAL_RANKING_MODE` env var. Unset/unknown → `undefined`, leaving
75
- * {@link AuthStorage}'s default (`balanced`) untouched. `earliest-reset`
76
- * switches to earliest-expiry-first selection so soon-to-reset tumbling-window
77
- * quota is drained before it is lost.
74
+ * Per-machine multi-account ranking override from `SKC_CREDENTIAL_RANKING_MODE`.
75
+ * Unset/unknown → `undefined`, so the `auth.credentialRankingMode` setting (or
76
+ * {@link AuthStorage}'s `balanced` default) decides. `earliest-reset` switches to
77
+ * earliest-expiry-first selection so soon-to-reset tumbling-window quota is
78
+ * drained before it is lost.
78
79
  */
79
- function resolveCredentialRankingMode(): "balanced" | "earliest-reset" | undefined {
80
+ export function credentialRankingModeFromEnv(): "balanced" | "earliest-reset" | undefined {
80
81
  const raw = process.env.SKC_CREDENTIAL_RANKING_MODE?.trim();
81
82
  if (raw === "balanced" || raw === "earliest-reset") return raw;
82
83
  return undefined;
83
84
  }
85
+
86
+ /**
87
+ * Apply the `auth.credentialRankingMode` setting to a credential store. The env
88
+ * var still wins, so a machine that pins a mode keeps it whatever config says.
89
+ * Returns the mode now in effect.
90
+ */
91
+ export function applyCredentialRankingModeSetting(
92
+ storage: Pick<AuthStorage, "setCredentialRankingMode">,
93
+ settings: Pick<Settings, "get">,
94
+ ): "balanced" | "earliest-reset" {
95
+ const mode = credentialRankingModeFromEnv() ?? settings.get("auth.credentialRankingMode");
96
+ storage.setCredentialRankingMode(mode);
97
+ return mode;
98
+ }
@@ -0,0 +1,59 @@
1
+ import type { Api, Model } from "@sayknow-cli/ai";
2
+ import { defaultModelPerProvider } from "../config/model-resolver";
3
+
4
+ /**
5
+ * How many other providers the automatic tail may add. Each entry can cost up to
6
+ * `fallback.maxAttempts` tries before the chain moves on, so the tail stays short.
7
+ */
8
+ export const AUTO_FALLBACK_PROVIDER_LIMIT = 3;
9
+
10
+ /** Provider of a `provider/model[:level]` selector, or undefined for bare aliases. */
11
+ export function selectorProvider(selector: string): string | undefined {
12
+ const slash = selector.indexOf("/");
13
+ return slash > 0 ? selector.slice(0, slash) : undefined;
14
+ }
15
+
16
+ export interface AutoFallbackInput {
17
+ /** Models whose provider has auth configured (`ModelRegistry.getAvailable()`). */
18
+ available: readonly Model<Api>[];
19
+ /** True when the provider has a stored credential or API key (keyless local providers do not count). */
20
+ hasCredentials: (provider: string) => boolean;
21
+ /** Providers already in the chain; the tail adds a different provider or nothing. */
22
+ excludeProviders: ReadonlySet<string>;
23
+ /** Most-recently-used `provider/id` keys, newest first. */
24
+ usageOrder?: readonly string[];
25
+ limit?: number;
26
+ }
27
+
28
+ /**
29
+ * One model per other logged-in provider, for the automatic tail of the default
30
+ * fallback chain. Within a provider the model the user used most recently wins,
31
+ * then the provider's curated default; a provider with neither is skipped rather
32
+ * than guessed, because catalog order would land on an old model. Providers used
33
+ * recently come first, then the rest in curated-default order.
34
+ */
35
+ export function autoFallbackSelectors(input: AutoFallbackInput): string[] {
36
+ const limit = input.limit ?? AUTO_FALLBACK_PROVIDER_LIMIT;
37
+ if (limit <= 0) return [];
38
+ const availableKeys = new Set(input.available.map(model => `${model.provider}/${model.id}`));
39
+ const picked = new Map<string, { selector: string; rank: number }>();
40
+ const eligible = (provider: string): boolean =>
41
+ !picked.has(provider) && !input.excludeProviders.has(provider) && input.hasCredentials(provider);
42
+
43
+ const usageOrder = input.usageOrder ?? [];
44
+ usageOrder.forEach((key, index) => {
45
+ const provider = selectorProvider(key);
46
+ if (!provider || !availableKeys.has(key) || !eligible(provider)) return;
47
+ picked.set(provider, { selector: key, rank: index });
48
+ });
49
+ Object.entries(defaultModelPerProvider).forEach(([provider, modelId], index) => {
50
+ const key = `${provider}/${modelId}`;
51
+ if (!availableKeys.has(key) || !eligible(provider)) return;
52
+ picked.set(provider, { selector: key, rank: usageOrder.length + index });
53
+ });
54
+
55
+ return [...picked.values()]
56
+ .sort((left, right) => left.rank - right.rank)
57
+ .slice(0, limit)
58
+ .map(entry => entry.selector);
59
+ }
@@ -7,6 +7,11 @@ export interface ConfiguredFallbackChain {
7
7
  origin: string;
8
8
  identity?: string;
9
9
  explicitHead: boolean;
10
+ /**
11
+ * Index of the first entry appended at runtime after the configured intent
12
+ * (`fallback.models`, then automatic picks). Absent when nothing was appended.
13
+ */
14
+ appendedFrom?: number;
10
15
  }
11
16
 
12
17
  export interface FallbackFailure {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Which language to answer in, detected from the script of the user's message.
3
+ *
4
+ * Long tool-heavy turns drift into English: tool output, logs, skill text and context
5
+ * summaries are English, and the final report follows them. The system prompt states
6
+ * the rule once; this adds a one-line reminder at the start of each turn, naming the
7
+ * language, when the message is clearly written in a non-Latin script. Latin-script
8
+ * languages (English, Spanish, French, German, …) cannot be told apart by script alone,
9
+ * so they get no reminder and rely on the system prompt rule.
10
+ */
11
+
12
+ export interface DetectedLanguage {
13
+ /** BCP 47 code. */
14
+ code: string;
15
+ /** English name, as used in the reminder. */
16
+ name: string;
17
+ }
18
+
19
+ /** Scripts that name one language well enough for a reminder. Kana wins over Han: Japanese mixes both. */
20
+ const SCRIPTS: ReadonlyArray<{ pattern: RegExp; language: DetectedLanguage }> = [
21
+ { pattern: /\p{Script=Hangul}/gu, language: { code: "ko", name: "Korean" } },
22
+ { pattern: /[\p{Script=Hiragana}\p{Script=Katakana}]/gu, language: { code: "ja", name: "Japanese" } },
23
+ { pattern: /\p{Script=Han}/gu, language: { code: "zh", name: "Chinese" } },
24
+ { pattern: /\p{Script=Cyrillic}/gu, language: { code: "ru", name: "Russian" } },
25
+ { pattern: /\p{Script=Arabic}/gu, language: { code: "ar", name: "Arabic" } },
26
+ { pattern: /\p{Script=Hebrew}/gu, language: { code: "he", name: "Hebrew" } },
27
+ { pattern: /\p{Script=Thai}/gu, language: { code: "th", name: "Thai" } },
28
+ { pattern: /\p{Script=Devanagari}/gu, language: { code: "hi", name: "Hindi" } },
29
+ { pattern: /\p{Script=Greek}/gu, language: { code: "el", name: "Greek" } },
30
+ ];
31
+
32
+ /** Code, quoted output and links say nothing about the language the user writes in. */
33
+ function proseOnly(text: string): string {
34
+ return text
35
+ .replace(/```[\s\S]*?```/g, " ")
36
+ .replace(/`[^`\n]*`/g, " ")
37
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, " ")
38
+ .replace(/https?:\/\/\S+/g, " ")
39
+ .split("\n")
40
+ .filter(line => !/^\s*>/.test(line))
41
+ .join("\n");
42
+ }
43
+
44
+ /**
45
+ * The language of `text` when its prose is clearly in a non-Latin script, else
46
+ * undefined. "Clearly": at least two characters of the script, and — since one CJK
47
+ * character carries about a word — at least a quarter as many as Latin letters, so a
48
+ * pasted English log with one Korean word does not count.
49
+ */
50
+ export function detectResponseLanguage(text: string): DetectedLanguage | undefined {
51
+ const prose = proseOnly(text);
52
+ const latin = prose.match(/\p{Script=Latin}/gu)?.length ?? 0;
53
+ const kana = prose.match(SCRIPTS[1]!.pattern)?.length ?? 0;
54
+ for (const { pattern, language } of SCRIPTS) {
55
+ let count = prose.match(pattern)?.length ?? 0;
56
+ if (language.code === "zh" && kana > 0) continue;
57
+ // Japanese needs kana to be told from Chinese; with it, its kanji count too.
58
+ if (language.code === "ja" && kana > 0) count += prose.match(/\p{Script=Han}/gu)?.length ?? 0;
59
+ if (count >= 2 && count * 4 >= latin) return language;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /** The per-turn reminder for a detected language. */
65
+ export function buildResponseLanguageReminder(language: DetectedLanguage): string {
66
+ return [
67
+ "<system-reminder>",
68
+ `The user wrote in ${language.name}. Write every user-facing message this turn in ${language.name} — progress notes and the final report after tool work included — unless they asked for another language. Keep code, commands, paths and identifiers as they are.`,
69
+ "</system-reminder>",
70
+ ].join("\n");
71
+ }
@@ -1,8 +1,11 @@
1
1
  import { formatProviderCredentialHint } from "@sayknow-cli/ai/stream";
2
+ import { getOAuthProviders } from "@sayknow-cli/ai/utils/oauth";
3
+ import { formatProviderPresetChoices } from "./provider-onboarding";
2
4
 
3
5
  export const MODEL_ONBOARDING_API_PROVIDER_COMMAND =
4
6
  "/provider add --compat <openai|anthropic> --provider <id> --base-url <url> --api-key-env <ENV> --model <model>";
5
- export const MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND = "/provider add --preset <minimax|minimax-cn|glm>";
7
+ /** Built from the bundled preset catalog, so a new preset shows up here without editing copy. */
8
+ export const MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND = `/provider add --preset <${formatProviderPresetChoices()}>`;
6
9
 
7
10
  export const MODEL_ONBOARDING_SETUP_COMMAND = "skc setup provider";
8
11
  export const MODEL_ONBOARDING_OAUTH_COMMAND = "/provider login [provider-id] or /login [provider-id]";
@@ -11,40 +14,52 @@ export const MODEL_ONBOARDING_OAUTH_COMMAND = "/provider login [provider-id] or
11
14
  * user learns it exists is from the surfaces where they go to add credentials.
12
15
  */
13
16
  export const MODEL_ONBOARDING_TYPESAFE_COMMAND = "/provider typesafe";
17
+ /** Where the models tried after a blocked one are shown and edited. */
18
+ export const MODEL_ONBOARDING_FALLBACK_COMMAND = "/fallback";
14
19
 
15
20
  export function formatModelOnboardingGuidance(): string {
16
21
  return [
17
22
  "Model selection only shows configured providers.",
18
23
  "Assignment targets are DEFAULT plus the SKC role agents: EXECUTOR, ARCHITECT, PLANNER, and CRITIC.",
19
24
  "Legacy model-role aliases are compatibility-only and are not shown as assignment targets.",
25
+ `OAuth/subscription providers: ${MODEL_ONBOARDING_OAUTH_COMMAND}.`,
20
26
  `Provider presets: ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND} --preset <preset>).`,
21
27
  `API-compatible custom providers: ${MODEL_ONBOARDING_API_PROVIDER_COMMAND}.`,
22
- `OAuth/subscription providers: ${MODEL_ONBOARDING_OAUTH_COMMAND}.`,
23
28
  `Typed decisions (not a chat model): ${MODEL_ONBOARDING_TYPESAFE_COMMAND} adds a TypeSafe key.`,
24
- "Then run /model to select a configured model or assign it to a target.",
29
+ `Then run /model to select a configured model or assign it to a target, and ${MODEL_ONBOARDING_FALLBACK_COMMAND} to see what runs when it is blocked.`,
25
30
  ].join("\n");
26
31
  }
27
32
 
28
33
  export function formatModelOnboardingInlineHint(): string {
29
- return `Add MiniMax/GLM presets with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND}; custom API providers with ${MODEL_ONBOARDING_API_PROVIDER_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND}); OAuth/subscription with ${MODEL_ONBOARDING_OAUTH_COMMAND}; TypeSafe typed decisions with ${MODEL_ONBOARDING_TYPESAFE_COMMAND}; then run /model for DEFAULT, EXECUTOR, ARCHITECT, PLANNER, and CRITIC.`;
34
+ return `Log in with ${MODEL_ONBOARDING_OAUTH_COMMAND}; presets with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND}; custom API providers with ${MODEL_ONBOARDING_API_PROVIDER_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND}); TypeSafe typed decisions with ${MODEL_ONBOARDING_TYPESAFE_COMMAND}; then run /model for DEFAULT, EXECUTOR, ARCHITECT, PLANNER, and CRITIC, and ${MODEL_ONBOARDING_FALLBACK_COMMAND} for what runs when a model is blocked.`;
30
35
  }
31
36
 
32
37
  export function formatNoModelOnboardingError(): string {
33
38
  return `No model selected.\n\n${formatModelOnboardingGuidance()}`;
34
39
  }
35
40
 
41
+ /**
42
+ * Missing credentials for the provider the session tried to call. The fix for that
43
+ * provider comes first; the ways to add a different provider follow as alternatives.
44
+ */
36
45
  export function formatNoCredentialOnboardingError(providerId: string): string {
37
- const lines = [
38
- `No credentials found for ${providerId}.`,
39
- "",
40
- `For MiniMax/GLM presets, configure credentials with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND} --preset <preset>).`,
41
- `For custom API-compatible providers, use ${MODEL_ONBOARDING_API_PROVIDER_COMMAND}.`,
42
- `For OAuth/subscription providers, use ${MODEL_ONBOARDING_OAUTH_COMMAND} (interactive; not available in headless/print mode).`,
43
- ];
44
- const headlessHint = formatProviderCredentialHint(providerId);
45
- if (headlessHint) lines.push(headlessHint);
46
+ const lines = [`No credentials found for ${providerId}.`, ""];
47
+ const canLogIn = getOAuthProviders().some(provider => provider.id === providerId);
48
+ const credentialHint = formatProviderCredentialHint(providerId);
49
+ if (canLogIn) {
50
+ lines.push(`Log in to ${providerId}: /login ${providerId} (interactive; not available in headless/print mode).`);
51
+ }
52
+ if (credentialHint) lines.push(credentialHint);
53
+ if (!canLogIn && !credentialHint) {
54
+ lines.push(`Add an API key for ${providerId} (the env var or apiKey its models.yml entry names).`);
55
+ }
46
56
  lines.push(
47
- "Then run /model to select a configured model or assign it to DEFAULT, EXECUTOR, ARCHITECT, PLANNER, or CRITIC.",
57
+ "",
58
+ "Or use another provider:",
59
+ `- OAuth/subscription: ${MODEL_ONBOARDING_OAUTH_COMMAND} (interactive; not available in headless/print mode).`,
60
+ `- Presets: ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND} --preset <preset>).`,
61
+ `- API-compatible: ${MODEL_ONBOARDING_API_PROVIDER_COMMAND}.`,
62
+ `Then run /model to select a configured model or assign it to DEFAULT, EXECUTOR, ARCHITECT, PLANNER, or CRITIC. With another provider logged in, a blocked model falls back automatically (${MODEL_ONBOARDING_FALLBACK_COMMAND}).`,
48
63
  );
49
64
  return lines.join("\n");
50
65
  }
@@ -103,6 +103,11 @@ export function formatProviderPresetList(): string {
103
103
  .join("\n");
104
104
  }
105
105
 
106
+ /** `minimax|minimax-cn|…` — the preset ids for a `--preset <…>` usage line, in catalog order. */
107
+ export function formatProviderPresetChoices(): string {
108
+ return PROVIDER_PRESETS.map(preset => preset.id).join("|");
109
+ }
110
+
106
111
  export function parseModelList(values: readonly string[]): string[] {
107
112
  const models = values
108
113
  .flatMap(value => value.split(","))
@@ -45,6 +45,7 @@ import { formatSessionImportSummary, runSessionImportCommand } from "../session-
45
45
  import { formatModelOnboardingGuidance } from "../setup/model-onboarding-guidance";
46
46
  import {
47
47
  addApiCompatibleProvider,
48
+ formatProviderPresetChoices,
48
49
  formatProviderPresetList,
49
50
  formatProviderSetupResult,
50
51
  parseProviderCompatibility,
@@ -207,6 +208,102 @@ async function updateSessionStar(
207
208
  return commandConsumed();
208
209
  }
209
210
 
211
+ const FALLBACK_USAGE =
212
+ "Usage: /fallback [add <model> | remove <number|model> | clear | auto on|off]\n" +
213
+ "Models listed here run, in order, after the default model when it is blocked (no login, quota, auth or server errors).";
214
+
215
+ /** Show or edit what runs when the default model is blocked. */
216
+ async function handleFallbackCommand(
217
+ command: ParsedSlashCommand,
218
+ runtime: SlashCommandRuntime,
219
+ ): Promise<SlashCommandResult> {
220
+ const { verb, rest } = parseSubcommand(command.args);
221
+ const added = runtime.settings.get("fallback.models");
222
+ const save = async (models: string[], message: string): Promise<SlashCommandResult> => {
223
+ runtime.settings.set("fallback.models", models);
224
+ await runtime.notifyConfigChanged?.();
225
+ await runtime.output(`${message}\n\n${formatFallbackChain(runtime)}`);
226
+ return commandConsumed();
227
+ };
228
+ switch (verb) {
229
+ case "":
230
+ case "show":
231
+ case "list":
232
+ await runtime.output(formatFallbackChain(runtime));
233
+ return commandConsumed();
234
+ case "add": {
235
+ if (!rest) return usage(FALLBACK_USAGE, runtime);
236
+ const resolution = await resolveModelCommandSelection(runtime, rest);
237
+ if (!resolution.ok) return usage(resolution.failure.message, runtime);
238
+ const { model, thinkingLevel } = resolution.selection;
239
+ const selector = formatModelSelectorValue(`${model.provider}/${model.id}`, thinkingLevel);
240
+ if (added.includes(selector)) {
241
+ await runtime.output(`${selector} is already a fallback.\n\n${formatFallbackChain(runtime)}`);
242
+ return commandConsumed();
243
+ }
244
+ return save([...added, selector], `Added fallback: ${selector}`);
245
+ }
246
+ case "remove":
247
+ case "rm": {
248
+ const index = /^\d+$/.test(rest) ? Number(rest) - 1 : added.indexOf(rest);
249
+ const target = added[index];
250
+ if (!rest || target === undefined) {
251
+ return usage(
252
+ added.length === 0
253
+ ? "No fallback models are set. Add one with /fallback add <model>."
254
+ : `Remove by number (1-${added.length}) or exact selector: ${added.join(", ")}`,
255
+ runtime,
256
+ );
257
+ }
258
+ return save(
259
+ added.filter((_, position) => position !== index),
260
+ `Removed fallback: ${target}`,
261
+ );
262
+ }
263
+ case "clear":
264
+ return save([], "Cleared fallback models.");
265
+ case "auto": {
266
+ const value = rest.toLowerCase();
267
+ if (value !== "on" && value !== "off") return usage("Usage: /fallback auto on|off", runtime);
268
+ runtime.settings.set("fallback.auto", value === "on");
269
+ await runtime.notifyConfigChanged?.();
270
+ await runtime.output(`Automatic fallback ${value}.\n\n${formatFallbackChain(runtime)}`);
271
+ return commandConsumed();
272
+ }
273
+ default:
274
+ return usage(FALLBACK_USAGE, runtime);
275
+ }
276
+ }
277
+
278
+ function formatFallbackChain(runtime: SlashCommandRuntime): string {
279
+ const auto = runtime.settings.get("fallback.auto");
280
+ const added = new Set(runtime.settings.get("fallback.models"));
281
+ const { entries, activeIndex, appendedFrom } = runtime.session.getDefaultFallbackChain();
282
+ const width = Math.max(0, ...entries.map(entry => entry.length));
283
+ const lines = [`Model fallback (automatic: ${auto ? "on" : "off"})`];
284
+ if (entries.length === 0) lines.push(" (no default model)");
285
+ entries.forEach((entry, index) => {
286
+ const source = index < appendedFrom ? (index === 0 ? "default" : "chain") : added.has(entry) ? "added" : "auto";
287
+ const inUse = index === activeIndex ? " ← in use" : "";
288
+ lines.push(` ${index + 1}. ${entry.padEnd(width)} ${source}${inUse}`);
289
+ });
290
+ if (entries.length === 1) {
291
+ lines.push(
292
+ auto
293
+ ? " Nothing to fall back to yet: log in to another provider (/login) or add one with /fallback add <model>."
294
+ : " Nothing to fall back to: add a model with /fallback add <model> or turn on /fallback auto on.",
295
+ );
296
+ }
297
+ if (auto && runtime.settings.get("enabledModels").length > 0) {
298
+ lines.push(" enabledModels restricts this session, so automatic picks are off; added models still apply.");
299
+ }
300
+ lines.push(
301
+ `Each model gets ${runtime.settings.get("fallback.maxAttempts")} tries; a blocked or exhausted account first hands over to the provider's next logged-in account (order: ${runtime.session.modelRegistry.authStorage.getCredentialRankingMode()}).`,
302
+ "/fallback add <model> · remove <n> · clear · auto on|off",
303
+ );
304
+ return lines.join("\n");
305
+ }
306
+
210
307
  function parseProviderSetupSlashArgs(args: string): {
211
308
  preset?: string;
212
309
  compat?: string;
@@ -273,8 +370,7 @@ function parseProviderSetupSlashArgs(args: string): {
273
370
  function providerSetupUsage(): string {
274
371
  return [
275
372
  "Provider onboarding",
276
- "Presets: /provider add --preset <minimax|minimax-cn|glm> [--force]",
277
- "Aliases: /provider add minimax, /provider add minimax-cn, /provider add glm, /provider add zai (writes glm-proxy)",
373
+ `Presets: /provider add --preset <${formatProviderPresetChoices()}> [--force]`,
278
374
  "API providers: /provider add --compat <openai|anthropic> --provider <id> --base-url <url> --api-key-env <ENV> --model <model> [--force]",
279
375
  `Available presets:\n${formatProviderPresetList()}`,
280
376
  "OAuth/subscription providers: /provider login [provider-id] or /login [provider-id]",
@@ -931,6 +1027,20 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
931
1027
  runtime.ctx.editor.setText("");
932
1028
  },
933
1029
  },
1030
+ {
1031
+ name: "fallback",
1032
+ description: "Show or edit the models tried when the default model is blocked",
1033
+ inlineHint: "[add <model> | remove <n> | clear | auto on|off]",
1034
+ acpInputHint: "[add <model> | remove <n> | clear | auto on|off]",
1035
+ subcommands: [
1036
+ { name: "add", description: "Add a fallback model after the default chain" },
1037
+ { name: "remove", description: "Remove an added fallback by number or selector" },
1038
+ { name: "clear", description: "Remove every added fallback" },
1039
+ { name: "auto", description: "Turn automatic fallback to other logged-in providers on or off" },
1040
+ ],
1041
+ allowArgs: true,
1042
+ handle: handleFallbackCommand,
1043
+ },
934
1044
  {
935
1045
  name: "effort",
936
1046
  description: "Show or set model reasoning effort",
@@ -47,6 +47,7 @@ import { FindTool } from "./find";
47
47
  import { GithubTool } from "./gh";
48
48
  import { IrcTool } from "./irc";
49
49
  import { JobTool } from "./job";
50
+ import { LocateTool } from "./locate";
50
51
  import { MonitorTool } from "./monitor";
51
52
  import { wrapToolWithMetaNotice } from "./output-meta";
52
53
  import { ReadTool } from "./read";
@@ -88,6 +89,7 @@ export * from "./gh";
88
89
  export * from "./image-gen";
89
90
  export * from "./irc";
90
91
  export * from "./job";
92
+ export * from "./locate";
91
93
  export * from "./monitor";
92
94
  export * from "./read";
93
95
  export * from "./recipe";
@@ -473,6 +475,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
473
475
  github: GithubTool.createIf,
474
476
  find: s => new FindTool(s),
475
477
  search: s => new SearchTool(s),
478
+ locate: LocateTool.createIf,
476
479
  lsp: LspTool.createIf,
477
480
  browser: s => new BrowserTool(s),
478
481
  ...(isComputerLoadablePlatform() ? { computer: ComputerTool.createIf } : {}),