@sayknow-cli/coding-agent 0.6.6 → 0.6.7
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/CHANGELOG.md +19 -0
- package/dist/types/config/settings-schema.d.ts +79 -0
- package/dist/types/i18n/messages/en.d.ts +2 -0
- package/dist/types/modes/components/welcome.d.ts +21 -4
- package/dist/types/modes/interactive-mode.d.ts +2 -5
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +22 -0
- package/dist/types/sdk/broker/process-guard.d.ts +71 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/auth-storage-discovery.d.ts +15 -0
- package/dist/types/session/auto-fallback.d.ts +27 -0
- package/dist/types/session/fallback-chain-controller.d.ts +5 -0
- package/dist/types/session/response-language.d.ts +25 -0
- package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/locate-core.d.ts +97 -0
- package/dist/types/tools/locate.d.ts +38 -0
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/cli/setup-cli.ts +7 -4
- package/src/commands/sdk.ts +3 -0
- package/src/commands/setup.ts +4 -1
- package/src/config/settings-schema.ts +82 -0
- package/src/decisions/typesafe-backend.ts +38 -4
- package/src/i18n/messages/de.settings.ts +26 -0
- package/src/i18n/messages/de.ts +2 -0
- package/src/i18n/messages/en.ts +2 -0
- package/src/i18n/messages/es.settings.ts +26 -0
- package/src/i18n/messages/es.ts +2 -0
- package/src/i18n/messages/fr.settings.ts +26 -0
- package/src/i18n/messages/fr.ts +2 -0
- package/src/i18n/messages/ja.settings.ts +26 -0
- package/src/i18n/messages/ja.ts +2 -0
- package/src/i18n/messages/ko.settings.ts +25 -0
- package/src/i18n/messages/ko.ts +2 -0
- package/src/i18n/messages/zh.settings.ts +23 -0
- package/src/i18n/messages/zh.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/modes/components/welcome.ts +240 -68
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/selector-controller.ts +13 -0
- package/src/modes/interactive-mode.ts +118 -14
- package/src/modes/types.ts +2 -0
- package/src/prompts/system/system-prompt.md +7 -2
- package/src/prompts/tools/locate.md +12 -0
- package/src/sdk/broker/broker.ts +75 -1
- package/src/sdk/broker/process-guard.ts +160 -0
- package/src/sdk/broker/transport.ts +15 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session.ts +4 -1
- package/src/session/agent-session.ts +118 -2
- package/src/session/auth-storage-discovery.ts +22 -7
- package/src/session/auto-fallback.ts +59 -0
- package/src/session/fallback-chain-controller.ts +5 -0
- package/src/session/response-language.ts +71 -0
- package/src/setup/model-onboarding-guidance.ts +29 -14
- package/src/setup/provider-onboarding.ts +5 -0
- package/src/slash-commands/builtin-registry.ts +112 -2
- package/src/tools/index.ts +3 -0
- package/src/tools/locate-core.ts +720 -0
- package/src/tools/locate.ts +197 -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
|
|
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 =
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* {@link AuthStorage}'s
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
`
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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",
|
package/src/tools/index.ts
CHANGED
|
@@ -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 } : {}),
|