@sayknow-cli/coding-agent 0.5.9 → 0.5.11

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.
@@ -148,7 +148,7 @@ export async function runTelegramCommand(cmd: TelegramCommandArgs): Promise<void
148
148
  case "__gateway": {
149
149
  // Hidden entrypoint: run the Telegram Remote gateway in-process. Reached
150
150
  // only via the self-spawn from runStart()/autostart, never by users.
151
- const { loadConfigFromEnv, runService } = await import("../../../telegram-remote/src/index");
151
+ const { loadConfigFromEnv, runService } = await import("@sayknow-cli/telegram-remote");
152
152
  await runService(loadConfigFromEnv(process.env));
153
153
  return;
154
154
  }
@@ -2,11 +2,8 @@ import { ThinkingLevel } from "@sayknow-cli/agent-core";
2
2
  import type { Api, Model } from "@sayknow-cli/ai";
3
3
  import type { AgentSession } from "../session/agent-session";
4
4
  import { formatClampedModelSelector } from "../thinking";
5
- import {
6
- aggregateModelProfileRequiredProviders,
7
- formatAvailableProfileNames,
8
- resolveProfileBindings,
9
- } from "./model-profiles";
5
+ import { UnknownModelProfileError, validateModelProfileName } from "./model-profile-contract";
6
+ import { aggregateModelProfileRequiredProviders, resolveProfileBindings } from "./model-profiles";
10
7
  import {
11
8
  isAuthenticated,
12
9
  kNoAuth,
@@ -49,7 +46,7 @@ export interface PrepareModelProfileActivationOptions {
49
46
  | "resolveCanonicalModel"
50
47
  | "getCanonicalVariants"
51
48
  | "getCanonicalId"
52
- >;
49
+ > & { getError?: ModelRegistry["getError"] };
53
50
  settings: Pick<Settings, "get">;
54
51
  profileName: string;
55
52
  }
@@ -347,12 +344,11 @@ export async function prepareModelProfileActivation(
347
344
  options: PrepareModelProfileActivationOptions,
348
345
  ): Promise<PreparedModelProfileActivation> {
349
346
  const profiles = options.modelRegistry.getModelProfiles();
350
- const profileName = resolveModelProfileName(options.profileName, profiles);
347
+ // Typed contract errors (`unknown_model_profile` / `model_profile_registry_error`)
348
+ // so SDK lifecycle readiness and BrokerResponse preserve the code and details.
349
+ const profileName = validateModelProfileName(options.profileName, profiles, options.modelRegistry.getError?.());
351
350
  const profile = profiles.get(profileName) ?? options.modelRegistry.getModelProfile(profileName);
352
- if (!profile) {
353
- const available = formatAvailableProfileNames(profiles);
354
- throw new Error(`Unknown model profile "${options.profileName}". Available profiles: ${available}`);
355
- }
351
+ if (!profile) throw new UnknownModelProfileError(options.profileName, profiles);
356
352
  const profileLabel = options.profileName;
357
353
 
358
354
  const requiredProviders = aggregateModelProfileRequiredProviders(profile.requiredProviders, profile);
@@ -263,6 +263,27 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
263
263
  critic: "xai/grok-4.5:high",
264
264
  architect: "xai/grok-4.5:high",
265
265
  }),
266
+ profile("grok-46-eco", ["xai"], {
267
+ default: "xai/grok-4.6:low",
268
+ executor: "xai/grok-4.6:low",
269
+ planner: "xai/grok-4.6:low",
270
+ critic: "xai/grok-4.6:medium",
271
+ architect: "xai/grok-4.6:high",
272
+ }),
273
+ profile("grok-46-medium", ["xai"], {
274
+ default: "xai/grok-4.6:medium",
275
+ executor: "xai/grok-4.6:low",
276
+ planner: "xai/grok-4.6:medium",
277
+ critic: "xai/grok-4.6:high",
278
+ architect: "xai/grok-4.6:xhigh",
279
+ }),
280
+ profile("grok-46-pro", ["xai"], {
281
+ default: "xai/grok-4.6:xhigh",
282
+ executor: "xai/grok-4.6:medium",
283
+ planner: "xai/grok-4.6:high",
284
+ critic: "xai/grok-4.6:xhigh",
285
+ architect: "xai/grok-4.6:xhigh",
286
+ }),
266
287
  profile("grok-build-pro", ["grok-build"], {
267
288
  default: "grok-build/grok-composer-2.5-fast",
268
289
  executor: "grok-build/grok-build",
@@ -397,6 +418,9 @@ const PROFILE_PRESENTATION: Record<string, ModelProfilePresentation> = {
397
418
  "grok-45-eco": { displayName: "Grok 4.5 Eco", providerGroup: "GROK" },
398
419
  "grok-45-medium": { displayName: "Grok 4.5 Medium", providerGroup: "GROK" },
399
420
  "grok-45-pro": { displayName: "Grok 4.5 Pro", providerGroup: "GROK" },
421
+ "grok-46-eco": { displayName: "Grok 4.6 Eco", providerGroup: "GROK" },
422
+ "grok-46-medium": { displayName: "Grok 4.6 Medium", providerGroup: "GROK" },
423
+ "grok-46-pro": { displayName: "Grok 4.6 Pro", providerGroup: "GROK" },
400
424
  "grok-build-pro": { displayName: "Grok Build Pro", providerGroup: "GROK" },
401
425
  "cursor-eco": { displayName: "Cursor Eco", providerGroup: "CURSOR" },
402
426
  "cursor-medium": { displayName: "Cursor Medium", providerGroup: "CURSOR" },
@@ -444,7 +468,7 @@ const PROFILE_RECOMMENDATIONS: Record<string, string> = {
444
468
  "xiaomi-token-plan-sgp": "mimo-medium",
445
469
  "xiaomi-token-plan-ams": "mimo-medium",
446
470
  "xiaomi-token-plan-cn": "mimo-medium",
447
- xai: "grok-medium",
471
+ xai: "grok-46-medium",
448
472
  "grok-build": "grok-build-pro",
449
473
  cursor: "cursor-medium",
450
474
  omlx: "macos-omlx-balanced",
@@ -69,6 +69,36 @@ import { type Settings, settings } from "./settings";
69
69
 
70
70
  export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig };
71
71
 
72
+ /**
73
+ * Strip userinfo and query strings from a discovery URL before it is surfaced
74
+ * in an error message, so credentials embedded in the URL never reach logs or UI.
75
+ */
76
+ function redactDiscoveryUrl(value: string | URL): string {
77
+ try {
78
+ const url = typeof value === "string" ? new URL(value) : value;
79
+ return `${url.origin}${url.pathname}`;
80
+ } catch {
81
+ return "(invalid URL)";
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Fingerprint of the environment variables that can change provider
87
+ * availability without touching AuthStorage. Keys the `getAvailable()` cache
88
+ * so a process.env mutation invalidates it.
89
+ */
90
+ function envAvailabilityFingerprint(): string {
91
+ return Object.entries(process.env)
92
+ .filter(
93
+ ([name]) =>
94
+ /(?:_API_KEY|_OAUTH_TOKEN|_ACCESS_TOKEN)$/.test(name) ||
95
+ /^(?:GH_TOKEN|GITHUB_TOKEN|HF_TOKEN|COPILOT_GITHUB_TOKEN)$/.test(name),
96
+ )
97
+ .sort(([left], [right]) => left.localeCompare(right))
98
+ .map(([name, value]) => `${name}=${value ?? ""}`)
99
+ .join("\u0000");
100
+ }
101
+
72
102
  export const kNoAuth = "N/A";
73
103
 
74
104
  export function isAuthenticated(apiKey: string | undefined | null): apiKey is string {
@@ -544,6 +574,8 @@ export interface ProviderDiscoveryState {
544
574
  export interface CanonicalModelQueryOptions {
545
575
  availableOnly?: boolean;
546
576
  candidates?: readonly Model<Api>[];
577
+ /** Session whose canonical stickiness should scope the lookup, when the caller has one. */
578
+ sessionId?: string;
547
579
  }
548
580
 
549
581
  /** Result of loading custom models from models.json */
@@ -1021,6 +1053,9 @@ export class ModelRegistry {
1021
1053
  #providerWebSearchModes: Map<string, WebSearchMode> = new Map();
1022
1054
  #keylessProviders: Set<string> = new Set();
1023
1055
  #discoverableProviders: DiscoveryProviderConfig[] = [];
1056
+ #availableModelsCache: Model<Api>[] | undefined;
1057
+ #availableModelsDisabledProviders: string | undefined;
1058
+ #availableModelsEnvFingerprint: string | undefined;
1024
1059
  #customModelOverlays: CustomModelOverlay[] = [];
1025
1060
  #providerOverrides: Map<string, ProviderOverride> = new Map();
1026
1061
  #modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
@@ -1074,6 +1109,8 @@ export class ModelRegistry {
1074
1109
  const keyConfig = this.#customProviderApiKeys.get(provider);
1075
1110
  return keyConfig;
1076
1111
  });
1112
+ // Any credential mutation (runtime/config keys, OAuth refresh) changes availability.
1113
+ this.authStorage.onGenerationChanged(() => this.#invalidateAvailableModels());
1077
1114
  // Load models synchronously in constructor
1078
1115
  this.#loadModels();
1079
1116
  }
@@ -2232,7 +2269,17 @@ export class ModelRegistry {
2232
2269
  headers,
2233
2270
  signal: AbortSignal.timeout(providerConfig.provider === "sglang" ? 500 : 250),
2234
2271
  fetch: (input, init) => fetch(input, { ...init, redirect: "error" }),
2235
- throwOnStatus: response => new Error(`HTTP ${response.status} from ${baseUrl}/models`),
2272
+ throwOnStatus: response => {
2273
+ const modelsUrl = redactDiscoveryUrl(`${baseUrl}/models`);
2274
+ if (response.status === 401 || response.status === 403) {
2275
+ // Redacted by construction: name the provider, endpoint, and the
2276
+ // config surface to fix, never the resolved key.
2277
+ return new Error(
2278
+ `HTTP ${response.status} from ${modelsUrl}: provider "${providerConfig.provider}" credential was rejected for OpenAI models-list discovery; check providers.${providerConfig.provider}.apiKey/apiKeyEnv.`,
2279
+ );
2280
+ }
2281
+ return new Error(`HTTP ${response.status} from ${modelsUrl}`);
2282
+ },
2236
2283
  mapModel: (item, defaults) => ({
2237
2284
  ...defaults,
2238
2285
  reasoning: isOmlx,
@@ -2434,6 +2481,9 @@ export class ModelRegistry {
2434
2481
  }
2435
2482
 
2436
2483
  #rebuildCanonicalIndex(): void {
2484
+ // #models has already changed by the time a rebuild is requested; drop the
2485
+ // availability cache even when the index rebuild itself is deferred.
2486
+ this.#invalidateAvailableModels();
2437
2487
  if (this.#rebuildSuspended > 0) {
2438
2488
  this.#rebuildPending = true;
2439
2489
  return;
@@ -2442,6 +2492,12 @@ export class ModelRegistry {
2442
2492
  this.#rebuildPending = false;
2443
2493
  }
2444
2494
 
2495
+ #invalidateAvailableModels(): void {
2496
+ this.#availableModelsCache = undefined;
2497
+ this.#availableModelsDisabledProviders = undefined;
2498
+ this.#availableModelsEnvFingerprint = undefined;
2499
+ }
2500
+
2445
2501
  #suspendRebuild(): void {
2446
2502
  this.#rebuildSuspended += 1;
2447
2503
  }
@@ -2453,6 +2509,7 @@ export class ModelRegistry {
2453
2509
  if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
2454
2510
  this.#rebuildPending = false;
2455
2511
  this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
2512
+ this.#invalidateAvailableModels();
2456
2513
  }
2457
2514
  }
2458
2515
 
@@ -2503,8 +2560,10 @@ export class ModelRegistry {
2503
2560
  return this.#models;
2504
2561
  }
2505
2562
 
2506
- #isModelAvailable(model: Model<Api>): boolean {
2507
- const disabledProviders = getDisabledProviderIdsFromSettings();
2563
+ #isModelAvailable(
2564
+ model: Model<Api>,
2565
+ disabledProviders: ReadonlySet<string> = getDisabledProviderIdsFromSettings(),
2566
+ ): boolean {
2508
2567
  return (
2509
2568
  !disabledProviders.has(model.provider) &&
2510
2569
  (this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider))
@@ -2643,7 +2702,20 @@ export class ModelRegistry {
2643
2702
  * This is a fast check that doesn't refresh OAuth tokens.
2644
2703
  */
2645
2704
  getAvailable(): Model<Api>[] {
2646
- return this.#models.filter(model => this.#isModelAvailable(model));
2705
+ const disabledProviders = getDisabledProviderIdsFromSettings();
2706
+ const disabledProviderKey = [...disabledProviders].sort().join("\u0000");
2707
+ const envFingerprint = envAvailabilityFingerprint();
2708
+ if (
2709
+ this.#availableModelsCache &&
2710
+ this.#availableModelsDisabledProviders === disabledProviderKey &&
2711
+ this.#availableModelsEnvFingerprint === envFingerprint
2712
+ ) {
2713
+ return this.#availableModelsCache;
2714
+ }
2715
+ this.#availableModelsCache = this.#models.filter(model => this.#isModelAvailable(model, disabledProviders));
2716
+ this.#availableModelsDisabledProviders = disabledProviderKey;
2717
+ this.#availableModelsEnvFingerprint = envFingerprint;
2718
+ return this.#availableModelsCache;
2647
2719
  }
2648
2720
 
2649
2721
  /**
@@ -355,7 +355,7 @@ function findExactCanonicalModelMatch(
355
355
  modelReference: string,
356
356
  availableModels: Model<Api>[],
357
357
  modelRegistry: CanonicalModelRegistry | undefined,
358
- _sessionId?: string,
358
+ sessionId?: string,
359
359
  ): Model<Api> | undefined {
360
360
  if (!modelRegistry) {
361
361
  return undefined;
@@ -367,6 +367,7 @@ function findExactCanonicalModelMatch(
367
367
  return modelRegistry.resolveCanonicalModel?.(trimmedReference, {
368
368
  availableOnly: false,
369
369
  candidates: availableModels,
370
+ sessionId,
370
371
  });
371
372
  }
372
373
 
@@ -378,7 +379,7 @@ function findExactEquivalentModelMatch(
378
379
  modelReference: string,
379
380
  availableModels: Model<Api>[],
380
381
  modelRegistry: CanonicalModelRegistry | undefined,
381
- _sessionId?: string,
382
+ sessionId?: string,
382
383
  ): Model<Api> | undefined {
383
384
  if (!modelRegistry?.getCanonicalId || !modelRegistry.resolveCanonicalModel) return undefined;
384
385
  const trimmedReference = modelReference.trim();
@@ -394,6 +395,7 @@ function findExactEquivalentModelMatch(
394
395
  return modelRegistry.resolveCanonicalModel([...canonicalIds][0]!, {
395
396
  availableOnly: false,
396
397
  candidates: availableModels,
398
+ sessionId,
397
399
  });
398
400
  }
399
401
 
@@ -9,6 +9,7 @@ const COST_BUILD = { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0.2 };
9
9
  const COST_COMPOSER_FAST = { input: 3, output: 15, cacheRead: 0.5, cacheWrite: 0 };
10
10
  const COST_43 = { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 };
11
11
  const COST_45 = { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 };
12
+ const COST_46 = { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 };
12
13
  const COST_420 = { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 };
13
14
 
14
15
  // ─── Model type ───────────────────────────────────────────────────────────────
@@ -85,6 +86,19 @@ const FALLBACK_MODELS: GrokCliModelConfig[] = [
85
86
  // https://docs.x.ai/developers/model-capabilities/text/reasoning caps Grok 4.5 at high.
86
87
  maxReasoningEffort: Effort.High,
87
88
  },
89
+ {
90
+ // Official metadata and pricing: https://docs.x.ai/developers/models/grok-4.6
91
+ // Reasoning effort: https://docs.x.ai/developers/model-capabilities/text/reasoning
92
+ id: 'grok-4.6',
93
+ name: 'Grok 4.6',
94
+ reasoning: true,
95
+ input: ['text', 'image'],
96
+ cost: COST_46,
97
+ contextWindow: 500_000,
98
+ maxTokens: 30_000,
99
+ // grok-4.6 adds xhigh; grok-4.5 remains capped at high.
100
+ maxReasoningEffort: Effort.XHigh,
101
+ },
88
102
  {
89
103
  id: 'grok-4.20-0309-reasoning',
90
104
  name: 'Grok 4.20 Reasoning',
@@ -122,9 +136,10 @@ const FALLBACK_MODELS: GrokCliModelConfig[] = [
122
136
  },
123
137
  ];
124
138
 
125
- // Official aliases: https://docs.x.ai/developers/models/grok-4.5
139
+ // Official aliases: https://docs.x.ai/developers/models/grok-4.6
126
140
  const MODEL_ALIASES: Readonly<Record<string, string>> = {
127
141
  'grok-4.5-latest': 'grok-4.5',
142
+ 'grok-4.6-latest': 'grok-4.6',
128
143
  'grok-build-latest': 'grok-4.5',
129
144
  };
130
145
 
@@ -145,7 +160,13 @@ export function getMaxReasoningEffort(modelId: string): Effort | undefined {
145
160
  )?.maxReasoningEffort;
146
161
  }
147
162
 
148
- const EFFORT_CAPABLE_PREFIXES = ['grok-3-mini', 'grok-4.20-multi-agent', 'grok-4.3', 'grok-4.5'];
163
+ const EFFORT_CAPABLE_PREFIXES = [
164
+ 'grok-3-mini',
165
+ 'grok-4.20-multi-agent',
166
+ 'grok-4.3',
167
+ 'grok-4.5',
168
+ 'grok-4.6',
169
+ ];
149
170
 
150
171
  export function supportsReasoningEffort(modelId: string): boolean {
151
172
  const name = getCanonicalModelName(modelId);
@@ -635,6 +635,18 @@ A transition occurs whenever the band changes versus the prior scored round —
635
635
 
636
636
  **Bookkeeping:** record each convened panel in `state.lateral_reviews` (round, milestone transition or pre-answer trigger, personas dispatched, findings folded). On panel spawn or validation failure, fall back silently to the normal generated question and increment `lateral_panel_failures`; do not expose tool noise unless it changes the next user-facing question. The panel is a prompt-budgeted assist layer — summarize oversized context before dispatch.
637
637
 
638
+ ### Per-question advisory fanout lanes (distinct from the milestone panel)
639
+
640
+ Separate from the milestone-triggered lateral panel above, a lightweight **advisory fanout** may assist any single question the main session is about to synthesize or route — especially when the user is terse, uncertain, or would benefit from selectable options instead of another open-ended prompt. Adopted from ouroboros's ooo interview, the standard lanes are:
641
+
642
+ - `code_context` — inspect repo-local facts and reuse existing exploration before asking the user.
643
+ - `web_context` — browse/search only when current external facts genuinely affect the answer.
644
+ - `ambiguity_contrarian` — find hidden assumptions, vague terms, missing decisions, and risky defaults.
645
+ - `answer_simplifier` — turn the question into 2-3 easy choices or one concise draft answer.
646
+ - `architecture_implications` — check whether the answer changes ownership, interfaces, rollout, or system shape.
647
+
648
+ Advisory fanout is an assist layer, not a decision maker: it never replaces or delays the single user-facing question, never adds a second question, and never forwards a synthesized answer without the user's approval, edit, or explicit auto-confirm request. It differs from the milestone panel in trigger (per-question, not band-transition) and intent (help the human answer this one question). When both would fire on the same round, run the milestone panel and fold advisory lanes into the same single question. Runtimes without a parallel subagent primitive process lanes sequentially; on lane failure, fall back silently to the normal generated question.
649
+
638
650
  ## Phase 4: Crystallize Spec
639
651
 
640
652
  When ambiguity ≤ threshold (or hard cap / early exit):