@sayknow-cli/coding-agent 0.5.10 → 0.5.13
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 +42 -0
- package/dist/types/config/file-lock.d.ts +8 -0
- package/dist/types/config/model-profile-activation.d.ts +3 -1
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/session-import/redact.d.ts +1 -1
- package/dist/types/skc-runtime/ultragoal-runtime.d.ts +13 -1
- package/dist/types/skc-runtime/ultragoal-succession.d.ts +251 -0
- package/dist/types/skc-runtime/workflow-placeholder.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +460 -172
- package/package.json +7 -7
- package/src/cli/telegram-cli.ts +1 -1
- package/src/config/file-lock-gc.ts +39 -2
- package/src/config/file-lock.ts +22 -1
- package/src/config/model-profile-activation.ts +7 -11
- package/src/config/model-registry.ts +192 -44
- package/src/config/model-resolver.ts +4 -2
- package/src/config/models-config-schema.ts +1 -0
- package/src/defaults/skc/skills/deep-interview/SKILL.md +12 -0
- package/src/defaults/skc/skills/ultragoal/SKILL.md +3 -0
- package/src/modes/components/settings-selector.ts +39 -21
- package/src/modes/controllers/selector-controller.ts +35 -15
- package/src/modes/rpc/rpc-client.ts +20 -4
- package/src/sdk/bus/index.ts +1 -3
- package/src/session-import/redact.ts +6 -3
- package/src/skc-runtime/state-writer.ts +24 -27
- package/src/skc-runtime/ultragoal-runtime.ts +125 -45
- package/src/skc-runtime/ultragoal-succession.ts +1667 -0
- package/src/skc-runtime/workflow-placeholder.ts +33 -0
- package/src/tools/ask.ts +366 -108
- package/src/tools/bisect.ts +4 -1
package/src/cli/telegram-cli.ts
CHANGED
|
@@ -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("
|
|
151
|
+
const { loadConfigFromEnv, runService } = await import("@sayknow-cli/telegram-remote");
|
|
152
152
|
await runService(loadConfigFromEnv(process.env));
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
@@ -58,9 +58,33 @@ function keptMalformedRecord(lockDir: string): GcRecord {
|
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function emptyLockDirRecord(lockDir: string): GcRecord {
|
|
62
|
+
return {
|
|
63
|
+
store: "file_locks",
|
|
64
|
+
id: lockDir,
|
|
65
|
+
path: lockDir,
|
|
66
|
+
pid_status: "none",
|
|
67
|
+
status: "stale",
|
|
68
|
+
stale: true,
|
|
69
|
+
removable: true,
|
|
70
|
+
action: "none",
|
|
71
|
+
reason: "empty_file_lock_dir",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
61
75
|
async function collectLockRecord(lockDir: string, ctx: GcContext): Promise<GcRecord> {
|
|
62
76
|
const info = await readFileLockInfoForGc(lockDir);
|
|
63
|
-
if (!info)
|
|
77
|
+
if (!info) {
|
|
78
|
+
// mkdir-before-info crash leftover: an empty `.lock` dir has no owner token
|
|
79
|
+
// and is safe to reclaim. Any other info-less shape stays fail-closed.
|
|
80
|
+
try {
|
|
81
|
+
const entries = await fs.readdir(lockDir);
|
|
82
|
+
if (entries.length === 0) return emptyLockDirRecord(lockDir);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!isEnoent(error)) throw error;
|
|
85
|
+
}
|
|
86
|
+
return keptMalformedRecord(lockDir);
|
|
87
|
+
}
|
|
64
88
|
|
|
65
89
|
const probeResult = ctx.probe(info.pid);
|
|
66
90
|
const pidStatus = gcPidStatusLabel(probeResult);
|
|
@@ -165,7 +189,20 @@ export const fileLocksGcAdapter: GcStoreAdapter = {
|
|
|
165
189
|
async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
|
|
166
190
|
const lockDir = record.path ?? record.id;
|
|
167
191
|
const info = await readFileLockInfoForGc(lockDir);
|
|
168
|
-
if (!info)
|
|
192
|
+
if (!info) {
|
|
193
|
+
if (record.reason !== "empty_file_lock_dir") {
|
|
194
|
+
return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const entries = await fs.readdir(lockDir);
|
|
198
|
+
if (entries.length !== 0) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
|
|
199
|
+
await fs.rmdir(lockDir);
|
|
200
|
+
return { removed: true };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (isEnoent(error)) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
|
|
203
|
+
return { removed: false, error: errorMessage(error) };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
169
206
|
|
|
170
207
|
const probeResult = ctx.probe(info.pid);
|
|
171
208
|
if (probeResult.status !== "dead") {
|
package/src/config/file-lock.ts
CHANGED
|
@@ -14,6 +14,21 @@ const DEFAULT_OPTIONS: Required<FileLockOptions> = {
|
|
|
14
14
|
retries: 50,
|
|
15
15
|
retryDelayMs: 100,
|
|
16
16
|
};
|
|
17
|
+
export class FileLockAcquireError extends Error {
|
|
18
|
+
readonly code = "acquire_timeout";
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
readonly filePath: string,
|
|
22
|
+
readonly lockPath: string,
|
|
23
|
+
readonly attempts: number,
|
|
24
|
+
readonly holder: string,
|
|
25
|
+
) {
|
|
26
|
+
super(
|
|
27
|
+
`Failed to acquire lock for ${filePath} after ${attempts} attempts: ${holder} (${lockPath}); a live owner is never displaced`,
|
|
28
|
+
);
|
|
29
|
+
this.name = "FileLockAcquireError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
17
32
|
|
|
18
33
|
type LockInfo = FileLockOwnerToken;
|
|
19
34
|
|
|
@@ -243,6 +258,12 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken): Promise
|
|
|
243
258
|
const outcome = await removeFileLockDirForGc(lockPath, owner);
|
|
244
259
|
if (outcome !== "removed") throw new Error(`Failed to release file lock: ${outcome}.`);
|
|
245
260
|
}
|
|
261
|
+
async function lockHolderDescription(lockPath: string): Promise<string> {
|
|
262
|
+
const info = await readLockInfo(lockPath);
|
|
263
|
+
if (!info) return "unknown holder";
|
|
264
|
+
return `pid ${info.pid}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
246
267
|
async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
|
|
247
268
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
248
269
|
const lockPath = getLockPath(filePath);
|
|
@@ -255,7 +276,7 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
|
|
|
255
276
|
if (await removeStaleLockForAcquire(lockPath, stale)) continue;
|
|
256
277
|
await Bun.sleep(opts.retryDelayMs);
|
|
257
278
|
}
|
|
258
|
-
throw new
|
|
279
|
+
throw new FileLockAcquireError(filePath, lockPath, opts.retries, await lockHolderDescription(lockPath));
|
|
259
280
|
}
|
|
260
281
|
|
|
261
282
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
UNK_MAX_TOKENS,
|
|
30
30
|
unregisterCustomApis,
|
|
31
31
|
} from "@sayknow-cli/ai";
|
|
32
|
+
import { detectDiscoveredApiFamily } from "@sayknow-cli/ai/utils/discovery/openai-compatible";
|
|
32
33
|
|
|
33
34
|
// Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
|
|
34
35
|
// any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
|
|
@@ -69,6 +70,36 @@ import { type Settings, settings } from "./settings";
|
|
|
69
70
|
|
|
70
71
|
export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig };
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Strip userinfo and query strings from a discovery URL before it is surfaced
|
|
75
|
+
* in an error message, so credentials embedded in the URL never reach logs or UI.
|
|
76
|
+
*/
|
|
77
|
+
function redactDiscoveryUrl(value: string | URL): string {
|
|
78
|
+
try {
|
|
79
|
+
const url = typeof value === "string" ? new URL(value) : value;
|
|
80
|
+
return `${url.origin}${url.pathname}`;
|
|
81
|
+
} catch {
|
|
82
|
+
return "(invalid URL)";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fingerprint of the environment variables that can change provider
|
|
88
|
+
* availability without touching AuthStorage. Keys the `getAvailable()` cache
|
|
89
|
+
* so a process.env mutation invalidates it.
|
|
90
|
+
*/
|
|
91
|
+
function envAvailabilityFingerprint(): string {
|
|
92
|
+
return Object.entries(process.env)
|
|
93
|
+
.filter(
|
|
94
|
+
([name]) =>
|
|
95
|
+
/(?:_API_KEY|_OAUTH_TOKEN|_ACCESS_TOKEN)$/.test(name) ||
|
|
96
|
+
/^(?:GH_TOKEN|GITHUB_TOKEN|HF_TOKEN|COPILOT_GITHUB_TOKEN)$/.test(name),
|
|
97
|
+
)
|
|
98
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
99
|
+
.map(([name, value]) => `${name}=${value ?? ""}`)
|
|
100
|
+
.join("\u0000");
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
export const kNoAuth = "N/A";
|
|
73
104
|
|
|
74
105
|
export function isAuthenticated(apiKey: string | undefined | null): apiKey is string {
|
|
@@ -182,6 +213,36 @@ type ProviderValidationMode = "models-config" | "runtime-register";
|
|
|
182
213
|
|
|
183
214
|
const OPENAI_REQUEST_TRANSFORM_APIS = new Set<Api>(["openai-completions", "openai-responses"]);
|
|
184
215
|
|
|
216
|
+
const OPENAI_FAMILY_APIS = new Set<Api>([
|
|
217
|
+
"openai-completions",
|
|
218
|
+
"openai-responses",
|
|
219
|
+
"openai-codex-responses",
|
|
220
|
+
"azure-openai-responses",
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
function isOpenAIFamilyApi(api: Api): boolean {
|
|
224
|
+
return OPENAI_FAMILY_APIS.has(api);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dominantOpenAIFamilyModelApi(models: readonly { api?: string }[] | undefined): Api | undefined {
|
|
228
|
+
if (!models || models.length === 0) return undefined;
|
|
229
|
+
const counts = new Map<Api, number>();
|
|
230
|
+
for (const model of models) {
|
|
231
|
+
const api = model.api as Api | undefined;
|
|
232
|
+
if (api === undefined || !isOpenAIFamilyApi(api)) continue;
|
|
233
|
+
counts.set(api, (counts.get(api) ?? 0) + 1);
|
|
234
|
+
}
|
|
235
|
+
let best: Api | undefined;
|
|
236
|
+
let bestCount = 0;
|
|
237
|
+
for (const [api, count] of counts) {
|
|
238
|
+
if (count > bestCount) {
|
|
239
|
+
best = api;
|
|
240
|
+
bestCount = count;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return best;
|
|
244
|
+
}
|
|
245
|
+
|
|
185
246
|
function getKnownProviderApis(providerName: string): Set<Api> {
|
|
186
247
|
const apis = new Set<Api>();
|
|
187
248
|
for (const model of getBundledModels(providerName as Parameters<typeof getBundledModels>[0])) {
|
|
@@ -544,6 +605,8 @@ export interface ProviderDiscoveryState {
|
|
|
544
605
|
export interface CanonicalModelQueryOptions {
|
|
545
606
|
availableOnly?: boolean;
|
|
546
607
|
candidates?: readonly Model<Api>[];
|
|
608
|
+
/** Session whose canonical stickiness should scope the lookup, when the caller has one. */
|
|
609
|
+
sessionId?: string;
|
|
547
610
|
}
|
|
548
611
|
|
|
549
612
|
/** Result of loading custom models from models.json */
|
|
@@ -1021,6 +1084,9 @@ export class ModelRegistry {
|
|
|
1021
1084
|
#providerWebSearchModes: Map<string, WebSearchMode> = new Map();
|
|
1022
1085
|
#keylessProviders: Set<string> = new Set();
|
|
1023
1086
|
#discoverableProviders: DiscoveryProviderConfig[] = [];
|
|
1087
|
+
#availableModelsCache: Model<Api>[] | undefined;
|
|
1088
|
+
#availableModelsDisabledProviders: string | undefined;
|
|
1089
|
+
#availableModelsEnvFingerprint: string | undefined;
|
|
1024
1090
|
#customModelOverlays: CustomModelOverlay[] = [];
|
|
1025
1091
|
#providerOverrides: Map<string, ProviderOverride> = new Map();
|
|
1026
1092
|
#modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
|
|
@@ -1074,6 +1140,8 @@ export class ModelRegistry {
|
|
|
1074
1140
|
const keyConfig = this.#customProviderApiKeys.get(provider);
|
|
1075
1141
|
return keyConfig;
|
|
1076
1142
|
});
|
|
1143
|
+
// Any credential mutation (runtime/config keys, OAuth refresh) changes availability.
|
|
1144
|
+
this.authStorage.onGenerationChanged(() => this.#invalidateAvailableModels());
|
|
1077
1145
|
// Load models synchronously in constructor
|
|
1078
1146
|
this.#loadModels();
|
|
1079
1147
|
}
|
|
@@ -1526,17 +1594,30 @@ export class ModelRegistry {
|
|
|
1526
1594
|
keylessProviders.add(providerName);
|
|
1527
1595
|
}
|
|
1528
1596
|
|
|
1529
|
-
|
|
1597
|
+
const effectiveDiscoveryBaseUrl = providerConfig.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName);
|
|
1598
|
+
const providerApi =
|
|
1599
|
+
(providerConfig.api as Api | undefined) ??
|
|
1600
|
+
dominantOpenAIFamilyModelApi(providerConfig.models as { api?: string }[] | undefined);
|
|
1601
|
+
const autoDiscovery: ProviderDiscovery | undefined =
|
|
1602
|
+
!providerConfig.discovery &&
|
|
1603
|
+
!localOpenAICompat &&
|
|
1604
|
+
providerApi !== undefined &&
|
|
1605
|
+
isOpenAIFamilyApi(providerApi) &&
|
|
1606
|
+
effectiveDiscoveryBaseUrl !== undefined
|
|
1607
|
+
? { type: "openai-models-list" }
|
|
1608
|
+
: undefined;
|
|
1609
|
+
const effectiveDiscovery = providerConfig.discovery ?? autoDiscovery;
|
|
1610
|
+
if (effectiveDiscovery && providerApi) {
|
|
1530
1611
|
discoverableProviders.push({
|
|
1531
1612
|
provider: providerName,
|
|
1532
|
-
api:
|
|
1533
|
-
baseUrl:
|
|
1613
|
+
api: providerApi,
|
|
1614
|
+
baseUrl: effectiveDiscoveryBaseUrl,
|
|
1534
1615
|
headers: providerConfig.headers,
|
|
1535
1616
|
compat: providerConfig.compat,
|
|
1536
1617
|
requestTransform: providerConfig.requestTransform,
|
|
1537
1618
|
cacheRetention: providerConfig.cacheRetention,
|
|
1538
|
-
discovery:
|
|
1539
|
-
optional:
|
|
1619
|
+
discovery: effectiveDiscovery,
|
|
1620
|
+
optional: !providerConfig.discovery,
|
|
1540
1621
|
});
|
|
1541
1622
|
}
|
|
1542
1623
|
|
|
@@ -2232,51 +2313,93 @@ export class ModelRegistry {
|
|
|
2232
2313
|
headers,
|
|
2233
2314
|
signal: AbortSignal.timeout(providerConfig.provider === "sglang" ? 500 : 250),
|
|
2234
2315
|
fetch: (input, init) => fetch(input, { ...init, redirect: "error" }),
|
|
2235
|
-
throwOnStatus: response =>
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
:
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
maxTokens:
|
|
2257
|
-
parseDiscoveryLimit(item.max_completion_tokens) ??
|
|
2258
|
-
parseDiscoveryLimit(item.max_tokens) ??
|
|
2259
|
-
parseDiscoveryLimit(item.max_output_tokens) ??
|
|
2260
|
-
UNK_MAX_TOKENS,
|
|
2261
|
-
headers,
|
|
2262
|
-
compat: {
|
|
2263
|
-
...(providerConfig.compat ?? {}),
|
|
2264
|
-
supportsStore: false,
|
|
2265
|
-
supportsDeveloperRole: false,
|
|
2266
|
-
supportsReasoningEffort: isOmlx,
|
|
2316
|
+
throwOnStatus: response => {
|
|
2317
|
+
const modelsUrl = redactDiscoveryUrl(`${baseUrl}/models`);
|
|
2318
|
+
if (response.status === 401 || response.status === 403) {
|
|
2319
|
+
// Redacted by construction: name the provider, endpoint, and the
|
|
2320
|
+
// config surface to fix, never the resolved key.
|
|
2321
|
+
return new Error(
|
|
2322
|
+
`HTTP ${response.status} from ${modelsUrl}: provider "${providerConfig.provider}" credential was rejected for OpenAI models-list discovery; check providers.${providerConfig.provider}.apiKey/apiKeyEnv.`,
|
|
2323
|
+
);
|
|
2324
|
+
}
|
|
2325
|
+
return new Error(`HTTP ${response.status} from ${modelsUrl}`);
|
|
2326
|
+
},
|
|
2327
|
+
mapModel: (item, defaults) => {
|
|
2328
|
+
const api = this.#resolveDiscoveredModelApi(
|
|
2329
|
+
providerConfig,
|
|
2330
|
+
typeof item.id === "string" ? item.id : "",
|
|
2331
|
+
item,
|
|
2332
|
+
);
|
|
2333
|
+
return {
|
|
2334
|
+
...defaults,
|
|
2335
|
+
api,
|
|
2336
|
+
reasoning: isOmlx,
|
|
2267
2337
|
...(isOmlx
|
|
2268
2338
|
? {
|
|
2269
|
-
|
|
2270
|
-
|
|
2339
|
+
thinking: {
|
|
2340
|
+
mode: "effort" as const,
|
|
2341
|
+
minLevel: Effort.Low,
|
|
2342
|
+
maxLevel: Effort.High,
|
|
2343
|
+
defaultLevel: Effort.Medium,
|
|
2344
|
+
levels: [Effort.Low, Effort.Medium, Effort.High],
|
|
2345
|
+
},
|
|
2271
2346
|
}
|
|
2272
2347
|
: {}),
|
|
2273
|
-
|
|
2274
|
-
|
|
2348
|
+
contextWindow:
|
|
2349
|
+
parseDiscoveryLimit(item.max_model_len) ??
|
|
2350
|
+
parseDiscoveryLimit(item.context_length) ??
|
|
2351
|
+
parseDiscoveryLimit(item.context_window) ??
|
|
2352
|
+
parseDiscoveryLimit(item.max_context_length) ??
|
|
2353
|
+
UNK_CONTEXT_WINDOW,
|
|
2354
|
+
maxTokens:
|
|
2355
|
+
parseDiscoveryLimit(item.max_completion_tokens) ??
|
|
2356
|
+
parseDiscoveryLimit(item.max_tokens) ??
|
|
2357
|
+
parseDiscoveryLimit(item.max_output_tokens) ??
|
|
2358
|
+
UNK_MAX_TOKENS,
|
|
2359
|
+
headers,
|
|
2360
|
+
compat: {
|
|
2361
|
+
...(providerConfig.compat ?? {}),
|
|
2362
|
+
supportsStore: false,
|
|
2363
|
+
supportsDeveloperRole: false,
|
|
2364
|
+
supportsReasoningEffort: isOmlx,
|
|
2365
|
+
...(isOmlx
|
|
2366
|
+
? {
|
|
2367
|
+
thinkingFormat: "qwen-chat-template" as const,
|
|
2368
|
+
reasoningContentField: "reasoning_content" as const,
|
|
2369
|
+
}
|
|
2370
|
+
: {}),
|
|
2371
|
+
},
|
|
2372
|
+
};
|
|
2373
|
+
},
|
|
2275
2374
|
});
|
|
2276
2375
|
if (discovered === null) throw new Error(`Invalid OpenAI-compatible model catalog from ${baseUrl}`);
|
|
2277
2376
|
return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
|
|
2278
2377
|
}
|
|
2279
2378
|
|
|
2379
|
+
#resolveDiscoveredModelApi(
|
|
2380
|
+
providerConfig: DiscoveryProviderConfig,
|
|
2381
|
+
modelId: string,
|
|
2382
|
+
entry?: { id?: unknown; owned_by?: unknown },
|
|
2383
|
+
): Api {
|
|
2384
|
+
let matchedPrefixLength = -1;
|
|
2385
|
+
let prefixApi: Api | undefined;
|
|
2386
|
+
for (const [prefix, routedApi] of Object.entries(providerConfig.discovery.apiByModelPrefix ?? {})) {
|
|
2387
|
+
if (modelId.startsWith(prefix) && prefix.length > matchedPrefixLength) {
|
|
2388
|
+
prefixApi = routedApi as Api;
|
|
2389
|
+
matchedPrefixLength = prefix.length;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
if (prefixApi !== undefined) return prefixApi;
|
|
2393
|
+
if (providerConfig.discovery.type === "openai-models-list") {
|
|
2394
|
+
const detected = detectDiscoveredApiFamily(entry ?? { id: modelId });
|
|
2395
|
+
if (detected === "anthropic-messages") return "anthropic-messages";
|
|
2396
|
+
if (detected === "openai-completions") {
|
|
2397
|
+
return isOpenAIFamilyApi(providerConfig.api) ? providerConfig.api : "openai-completions";
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
return providerConfig.api;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2280
2403
|
#normalizeLlamaCppBaseUrl(baseUrl?: string): string {
|
|
2281
2404
|
const defaultBaseUrl = "http://127.0.0.1:8080";
|
|
2282
2405
|
const raw = baseUrl || defaultBaseUrl;
|
|
@@ -2434,6 +2557,9 @@ export class ModelRegistry {
|
|
|
2434
2557
|
}
|
|
2435
2558
|
|
|
2436
2559
|
#rebuildCanonicalIndex(): void {
|
|
2560
|
+
// #models has already changed by the time a rebuild is requested; drop the
|
|
2561
|
+
// availability cache even when the index rebuild itself is deferred.
|
|
2562
|
+
this.#invalidateAvailableModels();
|
|
2437
2563
|
if (this.#rebuildSuspended > 0) {
|
|
2438
2564
|
this.#rebuildPending = true;
|
|
2439
2565
|
return;
|
|
@@ -2442,6 +2568,12 @@ export class ModelRegistry {
|
|
|
2442
2568
|
this.#rebuildPending = false;
|
|
2443
2569
|
}
|
|
2444
2570
|
|
|
2571
|
+
#invalidateAvailableModels(): void {
|
|
2572
|
+
this.#availableModelsCache = undefined;
|
|
2573
|
+
this.#availableModelsDisabledProviders = undefined;
|
|
2574
|
+
this.#availableModelsEnvFingerprint = undefined;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2445
2577
|
#suspendRebuild(): void {
|
|
2446
2578
|
this.#rebuildSuspended += 1;
|
|
2447
2579
|
}
|
|
@@ -2453,6 +2585,7 @@ export class ModelRegistry {
|
|
|
2453
2585
|
if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
|
|
2454
2586
|
this.#rebuildPending = false;
|
|
2455
2587
|
this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
|
|
2588
|
+
this.#invalidateAvailableModels();
|
|
2456
2589
|
}
|
|
2457
2590
|
}
|
|
2458
2591
|
|
|
@@ -2503,8 +2636,10 @@ export class ModelRegistry {
|
|
|
2503
2636
|
return this.#models;
|
|
2504
2637
|
}
|
|
2505
2638
|
|
|
2506
|
-
#isModelAvailable(
|
|
2507
|
-
|
|
2639
|
+
#isModelAvailable(
|
|
2640
|
+
model: Model<Api>,
|
|
2641
|
+
disabledProviders: ReadonlySet<string> = getDisabledProviderIdsFromSettings(),
|
|
2642
|
+
): boolean {
|
|
2508
2643
|
return (
|
|
2509
2644
|
!disabledProviders.has(model.provider) &&
|
|
2510
2645
|
(this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider))
|
|
@@ -2643,7 +2778,20 @@ export class ModelRegistry {
|
|
|
2643
2778
|
* This is a fast check that doesn't refresh OAuth tokens.
|
|
2644
2779
|
*/
|
|
2645
2780
|
getAvailable(): Model<Api>[] {
|
|
2646
|
-
|
|
2781
|
+
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
2782
|
+
const disabledProviderKey = [...disabledProviders].sort().join("\u0000");
|
|
2783
|
+
const envFingerprint = envAvailabilityFingerprint();
|
|
2784
|
+
if (
|
|
2785
|
+
this.#availableModelsCache &&
|
|
2786
|
+
this.#availableModelsDisabledProviders === disabledProviderKey &&
|
|
2787
|
+
this.#availableModelsEnvFingerprint === envFingerprint
|
|
2788
|
+
) {
|
|
2789
|
+
return this.#availableModelsCache;
|
|
2790
|
+
}
|
|
2791
|
+
this.#availableModelsCache = this.#models.filter(model => this.#isModelAvailable(model, disabledProviders));
|
|
2792
|
+
this.#availableModelsDisabledProviders = disabledProviderKey;
|
|
2793
|
+
this.#availableModelsEnvFingerprint = envFingerprint;
|
|
2794
|
+
return this.#availableModelsCache;
|
|
2647
2795
|
}
|
|
2648
2796
|
|
|
2649
2797
|
/**
|
|
@@ -355,7 +355,7 @@ function findExactCanonicalModelMatch(
|
|
|
355
355
|
modelReference: string,
|
|
356
356
|
availableModels: Model<Api>[],
|
|
357
357
|
modelRegistry: CanonicalModelRegistry | undefined,
|
|
358
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -185,6 +185,7 @@ export type ModelOverride = z.infer<typeof ModelOverrideSchema>;
|
|
|
185
185
|
|
|
186
186
|
export const ProviderDiscoverySchema = z.object({
|
|
187
187
|
type: z.enum(["ollama", "llama.cpp", "lm-studio", "omlx", "sglang", "openai-models-list"]),
|
|
188
|
+
apiByModelPrefix: z.record(z.string().min(1), z.string().min(1)).optional(),
|
|
188
189
|
});
|
|
189
190
|
|
|
190
191
|
const LocalOpenAICompatSchema = z
|
|
@@ -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):
|
|
@@ -37,6 +37,9 @@ skc ultragoal complete-goals --retry-failed
|
|
|
37
37
|
skc ultragoal checkpoint --goal-id <id> --status complete --evidence "<evidence>" --quality-gate-json <quality-gate-json-or-path>
|
|
38
38
|
skc ultragoal checkpoint --goal-id <id> --status failed --evidence "<blocker/evidence>"
|
|
39
39
|
skc ultragoal record-review-blockers --goal-id <id> --title "Resolve final review blockers" --objective "<blocker-resolution objective>" --evidence "<review findings>"
|
|
40
|
+
skc ultragoal succession offer --target-repo <path> --goal-id <id> --authorize "<statement>" --authorized-by <identity>
|
|
41
|
+
skc ultragoal succession adopt --offer <path>
|
|
42
|
+
skc ultragoal succession status --json
|
|
40
43
|
```
|
|
41
44
|
|
|
42
45
|
Use these exact goal-tool calls for the inline goal state:
|