refactor(prefs): remove all legacy PREFERENCES.md / preferences.md support
preferences.yaml is now the only preferences file. No fallback chains,
no .md parsing paths, no legacy path getters.
- preferences.js: remove globalPreferencesPath, globalPreferencesPathUppercase,
legacyGlobalPreferencesPath, projectPreferencesPath, projectPreferencesPathUppercase,
getLegacyGlobalSFPreferencesPath; simplify load functions to yaml-only;
parsePreferencesMarkdown kept as thin deprecated shim over parsePreferencesYaml
- commands-prefs-wizard.js: remove parseFrontmatterMap/splitFrontmatter usage,
.md branch in savePreferencesFile/ensurePreferencesFile, legacyGlobal display
- auto-dashboard.js: parsePreferencesMarkdown → parsePreferencesYaml
- guided-flow.js / worktree-root.js: remove PREFERENCES.md existence checks
- detection.js: remove .md fallbacks from all 3 detection functions
- auto-bootstrap-context.js: remove .sf/PREFERENCES.md from priority list
- auto-worktree.js: remove LEGACY_PREFERENCES_FILES array and all copy fallbacks
- deep-project-setup-policy.js: only check preferences.yaml
- gitignore.js: ensurePreferences checks yaml only
- planning-depth.js: returns plain string path (not {path,isYaml}); yaml-only
- preferences-template-upgrade.js: remove .md branch; always write raw YAML
- tests: update fixtures to preferences.yaml with plain YAML content
- docs/learning: update all remaining PREFERENCES.md references
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
48dbb175c0
commit
48a01dd764
19 changed files with 426 additions and 382 deletions
|
|
@ -23,7 +23,6 @@ const AUTO_BOOTSTRAP_SF_SPEC_FILES = [
|
|||
".sf/PRINCIPLES.md",
|
||||
".sf/TASTE.md",
|
||||
".sf/preferences.yaml",
|
||||
".sf/PREFERENCES.md",
|
||||
".sf/ANTI-GOALS.md",
|
||||
".sf/CODEBASE.md",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { getActiveHook } from "./post-unit-hooks.js";
|
|||
import {
|
||||
getGlobalSFPreferencesPath,
|
||||
getProjectSFPreferencesPath,
|
||||
parsePreferencesMarkdown,
|
||||
parsePreferencesYaml,
|
||||
} from "./preferences.js";
|
||||
import { computeProgressScore } from "./progress-score.js";
|
||||
import {
|
||||
|
|
@ -450,7 +450,7 @@ function safeReadTextFile(path) {
|
|||
function readWidgetModeFromFile(path) {
|
||||
const raw = safeReadTextFile(path);
|
||||
if (!raw) return undefined;
|
||||
const prefs = parsePreferencesMarkdown(raw);
|
||||
const prefs = parsePreferencesYaml(raw);
|
||||
const saved = prefs?.widget_mode;
|
||||
if (saved && WIDGET_MODES.includes(saved)) {
|
||||
return saved;
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ import {
|
|||
|
||||
const sfHome = process.env.SF_HOME || join(homedir(), ".sf");
|
||||
const PROJECT_PREFERENCES_FILE = "preferences.yaml";
|
||||
const LEGACY_PREFERENCES_FILES = ["PREFERENCES.md", "preferences.md"];
|
||||
// ─── Shared Constants & Helpers ─────────────────────────────────────────────
|
||||
/**
|
||||
* Root-level .sf/ state files synced between worktree and project root.
|
||||
|
|
@ -552,29 +551,23 @@ export function syncSfStateToWorktree(mainBasePath, worktreePath_) {
|
|||
}
|
||||
}
|
||||
// Forward-sync project preferences from project root to worktree (additive only).
|
||||
// Prefer the canonical yaml file, but keep legacy md fallbacks so older repos still work.
|
||||
{
|
||||
const worktreeHasPreferences =
|
||||
existsSync(join(wtSf, PROJECT_PREFERENCES_FILE)) ||
|
||||
LEGACY_PREFERENCES_FILES.some((f) => existsSync(join(wtSf, f)));
|
||||
const worktreeHasPreferences = existsSync(join(wtSf, PROJECT_PREFERENCES_FILE));
|
||||
if (!worktreeHasPreferences) {
|
||||
for (const file of [PROJECT_PREFERENCES_FILE, ...LEGACY_PREFERENCES_FILES]) {
|
||||
const src = join(mainSf, file);
|
||||
const dst = join(wtSf, file);
|
||||
if (existsSync(src)) {
|
||||
try {
|
||||
cpSync(src, dst);
|
||||
synced.push(file);
|
||||
} catch (err) {
|
||||
/* non-fatal */
|
||||
logWarning(
|
||||
"worktree",
|
||||
`preferences copy failed (${file}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
const src = join(mainSf, PROJECT_PREFERENCES_FILE);
|
||||
const dst = join(wtSf, PROJECT_PREFERENCES_FILE);
|
||||
if (existsSync(src)) {
|
||||
try {
|
||||
cpSync(src, dst);
|
||||
synced.push(PROJECT_PREFERENCES_FILE);
|
||||
} catch (err) {
|
||||
/* non-fatal */
|
||||
logWarning(
|
||||
"worktree",
|
||||
`preferences copy failed (${PROJECT_PREFERENCES_FILE}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sync milestones: copy entire milestone directories that are missing
|
||||
|
|
@ -1190,24 +1183,13 @@ function copyPlanningArtifacts(srcBase, wtPath) {
|
|||
]) {
|
||||
safeCopy(join(srcSf, file), join(dstSf, file), { force: true });
|
||||
}
|
||||
// Seed canonical preferences.yaml when available; fall back to legacy md files.
|
||||
// Seed canonical preferences.yaml when available.
|
||||
if (existsSync(join(srcSf, PROJECT_PREFERENCES_FILE))) {
|
||||
safeCopy(
|
||||
join(srcSf, PROJECT_PREFERENCES_FILE),
|
||||
join(dstSf, PROJECT_PREFERENCES_FILE),
|
||||
{ force: true },
|
||||
);
|
||||
} else {
|
||||
for (const legacyFile of LEGACY_PREFERENCES_FILES) {
|
||||
if (existsSync(join(srcSf, legacyFile))) {
|
||||
safeCopy(
|
||||
join(srcSf, legacyFile),
|
||||
join(dstSf, legacyFile),
|
||||
{ force: true },
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shared WAL (R012): worktrees use the project root's DB directly.
|
||||
// No longer copy sf.db into the worktree — the DB path resolver in
|
||||
|
|
|
|||
|
|
@ -490,6 +490,16 @@ export function registerHooks(pi, ecosystemHandlers = []) {
|
|||
} catch {
|
||||
/* non-fatal — requirement promoter must never block session start */
|
||||
}
|
||||
// Refresh model catalogs for discoverable providers in the background.
|
||||
try {
|
||||
const { scheduleModelCatalogRefresh } = await import(
|
||||
"../model-catalog-cache.js"
|
||||
);
|
||||
const { getKeyManagerAuthStorage } = await import("../key-manager.js");
|
||||
scheduleModelCatalogRefresh(process.cwd(), getKeyManagerAuthStorage());
|
||||
} catch {
|
||||
/* non-fatal — model catalog refresh must never block session start */
|
||||
}
|
||||
// Compaction should never behave like a stop boundary. If autonomous mode
|
||||
// was active when compaction happened, continue automatically on session start.
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -10,17 +10,15 @@ import { join } from "node:path";
|
|||
import { runClaudeImportFlow } from "./claude-import.js";
|
||||
import {
|
||||
loadFile,
|
||||
parseFrontmatterMap,
|
||||
saveFile,
|
||||
splitFrontmatter,
|
||||
} from "./files.js";
|
||||
import {
|
||||
getGlobalSFPreferencesPath,
|
||||
getLegacyGlobalSFPreferencesPath,
|
||||
getProjectSFPreferencesPath,
|
||||
loadEffectiveSFPreferences,
|
||||
loadGlobalSFPreferences,
|
||||
loadProjectSFPreferences,
|
||||
parsePreferencesYaml,
|
||||
resolveAllSkillReferences,
|
||||
} from "./preferences.js";
|
||||
|
||||
|
|
@ -32,32 +30,11 @@ export {
|
|||
|
||||
import { serializePreferencesToFrontmatter } from "./preferences-serializer.js";
|
||||
|
||||
/** Extract body content after frontmatter closing delimiter, or null if none. */
|
||||
function extractBodyAfterFrontmatter(content) {
|
||||
const closingIdx = content.indexOf("\n---", content.indexOf("---"));
|
||||
if (closingIdx === -1) return null;
|
||||
const afterFrontmatter = content.slice(closingIdx + 4);
|
||||
return afterFrontmatter.trim() ? afterFrontmatter : null;
|
||||
}
|
||||
/**
|
||||
* Serialize prefs and save to path, using format appropriate for file type.
|
||||
* .yaml → raw YAML (no --- markers, no Markdown body)
|
||||
* .md → YAML frontmatter + preserved or default Markdown body
|
||||
*/
|
||||
/** Serialize prefs and save to path as raw YAML. */
|
||||
async function savePreferencesFile(path, prefs) {
|
||||
prefs.version = prefs.version || 1;
|
||||
const yaml = serializePreferencesToFrontmatter(prefs);
|
||||
if (path.endsWith(".yaml")) {
|
||||
await saveFile(path, yaml);
|
||||
return;
|
||||
}
|
||||
let body =
|
||||
"\n# SF Skill Preferences\n\nSee `~/.sf/agent/extensions/sf/docs/preferences-reference.md` for full field documentation and examples.\n";
|
||||
if (existsSync(path)) {
|
||||
const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8"));
|
||||
if (preserved) body = preserved;
|
||||
}
|
||||
await saveFile(path, `---\n${yaml}---${body}`);
|
||||
await saveFile(path, yaml);
|
||||
}
|
||||
// ─── Numeric validation helpers ──────────────────────────────────────────────
|
||||
/** Parse a string as a non-negative integer, or return null on failure. */
|
||||
|
|
@ -109,9 +86,8 @@ export async function handlePrefs(args, ctx) {
|
|||
const globalPrefs = loadGlobalSFPreferences();
|
||||
const projectPrefs = loadProjectSFPreferences();
|
||||
const canonicalGlobal = getGlobalSFPreferencesPath();
|
||||
const legacyGlobal = getLegacyGlobalSFPreferencesPath();
|
||||
const globalStatus = globalPrefs
|
||||
? `present: ${globalPrefs.path}${globalPrefs.path === legacyGlobal ? " (legacy fallback)" : ""}`
|
||||
? `present: ${globalPrefs.path}`
|
||||
: `missing: ${canonicalGlobal}`;
|
||||
const projectStatus = projectPrefs
|
||||
? `present: ${projectPrefs.path}`
|
||||
|
|
@ -158,12 +134,7 @@ export async function handleImportClaude(ctx, scope) {
|
|||
const readPrefs = () => {
|
||||
if (!existsSync(path)) return { version: 1 };
|
||||
const content = readFileSync(path, "utf-8");
|
||||
if (path.endsWith(".yaml")) {
|
||||
const [frontmatterLines] = splitFrontmatter(`---\n${content}\n---`);
|
||||
return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 };
|
||||
}
|
||||
const [frontmatterLines] = splitFrontmatter(content);
|
||||
return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 };
|
||||
return parsePreferencesYaml(content) ?? { version: 1 };
|
||||
};
|
||||
const writePrefs = async (prefs) => savePreferencesFile(path, prefs);
|
||||
await runClaudeImportFlow(ctx, scope, readPrefs, writePrefs);
|
||||
|
|
@ -840,10 +811,8 @@ export async function handlePrefsWizard(ctx, scope) {
|
|||
}
|
||||
export async function ensurePreferencesFile(path, ctx, scope) {
|
||||
if (!existsSync(path)) {
|
||||
// Use yaml template for .yaml paths, markdown template for legacy .md paths
|
||||
const templateFile = path.endsWith(".yaml") ? "preferences.yaml" : "PREFERENCES.md";
|
||||
const template = await loadFile(
|
||||
join(import.meta.dirname, "templates", templateFile),
|
||||
join(import.meta.dirname, "templates", "preferences.yaml"),
|
||||
);
|
||||
if (!template) {
|
||||
ctx.ui.notify("Could not load SF preferences template.", "error");
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ export function isWorkflowPrefsCaptured(basePath) {
|
|||
const sfRootPath = sfRoot(basePath);
|
||||
const candidates = [
|
||||
join(sfRootPath, "preferences.yaml"),
|
||||
join(sfRootPath, "PREFERENCES.md"),
|
||||
join(sfRootPath, "preferences.md"),
|
||||
];
|
||||
for (const prefsPath of candidates) {
|
||||
if (!existsSync(prefsPath)) continue;
|
||||
|
|
|
|||
|
|
@ -311,9 +311,7 @@ function detectV2Sf(basePath) {
|
|||
const sfPath = sfRoot(basePath);
|
||||
if (!existsSync(sfPath)) return null;
|
||||
const hasPreferences =
|
||||
existsSync(join(sfPath, "preferences.yaml")) ||
|
||||
existsSync(join(sfPath, "PREFERENCES.md")) ||
|
||||
existsSync(join(sfPath, "preferences.md"));
|
||||
existsSync(join(sfPath, "preferences.yaml"));
|
||||
const hasContext = existsSync(join(sfPath, "CONTEXT.md"));
|
||||
let milestoneCount = 0;
|
||||
const milestonesPath = join(sfPath, "milestones");
|
||||
|
|
@ -862,11 +860,7 @@ function isMakeTestTargetSafe(basePath) {
|
|||
* Check if global SF setup exists (has ~/.sf/ with preferences).
|
||||
*/
|
||||
export function hasGlobalSetup() {
|
||||
return (
|
||||
existsSync(join(sfHome, "preferences.yaml")) ||
|
||||
existsSync(join(sfHome, "PREFERENCES.md")) ||
|
||||
existsSync(join(sfHome, "preferences.md"))
|
||||
);
|
||||
return existsSync(join(sfHome, "preferences.yaml"));
|
||||
}
|
||||
/**
|
||||
* Check if this is the very first time SF has been used on this machine.
|
||||
|
|
@ -875,11 +869,7 @@ export function hasGlobalSetup() {
|
|||
export function isFirstEverLaunch() {
|
||||
if (!existsSync(sfHome)) return true;
|
||||
// If we have preferences, not first launch
|
||||
if (
|
||||
existsSync(join(sfHome, "preferences.yaml")) ||
|
||||
existsSync(join(sfHome, "PREFERENCES.md")) ||
|
||||
existsSync(join(sfHome, "preferences.md"))
|
||||
) {
|
||||
if (existsSync(join(sfHome, "preferences.yaml"))) {
|
||||
return false;
|
||||
}
|
||||
// If we have auth.json, not first launch (onboarding.ts already ran)
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ skill_rules: []
|
|||
|
||||
Preferences are loaded from two locations and merged:
|
||||
|
||||
1. **Global:** `~/.sf/PREFERENCES.md` — applies to all projects
|
||||
2. **Project:** `.sf/PREFERENCES.md` — applies to the current project only
|
||||
1. **Global:** `~/.sf/preferences.yaml` — applies to all projects
|
||||
2. **Project:** `.sf/preferences.yaml` — applies to the current project only
|
||||
|
||||
**Merge behavior** (see `mergePreferences()` in `preferences.ts`):
|
||||
|
||||
|
|
|
|||
|
|
@ -293,14 +293,10 @@ export function untrackRuntimeFiles(basePath) {
|
|||
* Ensure basePath/.sf/preferences.yaml exists as a template.
|
||||
* Creates the file with YAML only if it doesn't exist.
|
||||
* Returns true if created, false if already exists.
|
||||
*
|
||||
* Checks yaml (canonical) and legacy .md variants to avoid duplicates.
|
||||
*/
|
||||
export function ensurePreferences(basePath) {
|
||||
const yamlPath = join(sfRoot(basePath), "preferences.yaml");
|
||||
const uppercasePath = join(sfRoot(basePath), "PREFERENCES.md");
|
||||
const legacyPath = join(sfRoot(basePath), "preferences.md");
|
||||
if (existsSync(yamlPath) || existsSync(uppercasePath) || existsSync(legacyPath)) {
|
||||
if (existsSync(yamlPath)) {
|
||||
return false;
|
||||
}
|
||||
// Auto-detect project type and seed verification_commands.
|
||||
|
|
|
|||
|
|
@ -1594,13 +1594,12 @@ export async function showWorkflowEntry(ctx, pi, basePath, options) {
|
|||
}
|
||||
// ── Detection preamble — run before any bootstrap ────────────────────
|
||||
// Check bootstrap completeness, not just .sf/ directory existence.
|
||||
// A zombie .sf/ state (symlink exists but missing preferences.yaml/PREFERENCES.md and
|
||||
// A zombie .sf/ state (symlink exists but missing preferences.yaml and
|
||||
// milestones/) must trigger the init wizard, not skip it (#2942).
|
||||
const sfPath = sfRoot(basePath);
|
||||
const hasBootstrapArtifacts =
|
||||
existsSync(sfPath) &&
|
||||
(existsSync(join(sfPath, "preferences.yaml")) ||
|
||||
existsSync(join(sfPath, "PREFERENCES.md")) ||
|
||||
existsSync(join(sfPath, "milestones")));
|
||||
if (!hasBootstrapArtifacts) {
|
||||
const detection = detectProjectState(basePath);
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
* immediately returns `null`, which surfaces as `"All providers exhausted"`
|
||||
* even when there are dozens of healthy providers available.
|
||||
*
|
||||
* ## Why this lives in the plugin, not in preferences.md
|
||||
* ## Why this lives in the plugin, not in preferences.yaml
|
||||
*
|
||||
* `~/.sf/preferences.md` tells sf which model to START a unit with — it
|
||||
* `~/.sf/preferences.yaml` tells sf which model to START a unit with — it
|
||||
* feeds `before_model_select`, which this plugin already intercepts. But
|
||||
* once dispatch begins and the LLM call 429s, ai's retry path reads
|
||||
* `~/.sf/agent/settings.json` → `fallback.chains` directly via
|
||||
* `SettingsManager.getFallbackSettings()`. Those two configs are separate
|
||||
* pipelines. preferences.md never reaches the retry walker.
|
||||
* pipelines. preferences.yaml never reaches the retry walker.
|
||||
*
|
||||
* The plugin owns this file because:
|
||||
* 1. Rankings are dynamic — Bayesian blended priors + observed outcomes
|
||||
|
|
|
|||
157
src/resources/extensions/sf/model-catalog-cache.js
Normal file
157
src/resources/extensions/sf/model-catalog-cache.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* model-catalog-cache.js — live model-list discovery with disk cache.
|
||||
*
|
||||
* Fetches model lists from providers that publish a /v1/models (or equivalent)
|
||||
* endpoint. Results are cached to .sf/runtime/model-catalog/<providerId>.json
|
||||
* with a 6-hour TTL so the model picker and preference validation see fresh
|
||||
* model IDs without blocking the event loop.
|
||||
*
|
||||
* Provider configuration (base URL, auth format, model filter patterns) comes
|
||||
* from provider-catalog-config.js — not hardcoded here.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
DISCOVERABLE_PROVIDER_IDS,
|
||||
getProviderCatalogConfig,
|
||||
getProviderModelExcludePatterns,
|
||||
} from "./provider-catalog-config.js";
|
||||
import { sfRuntimeRoot } from "./paths.js";
|
||||
|
||||
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function cacheDirPath(basePath) {
|
||||
return join(sfRuntimeRoot(basePath), "model-catalog");
|
||||
}
|
||||
|
||||
function cacheFilePath(basePath, providerId) {
|
||||
return join(cacheDirPath(basePath), `${providerId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cached model IDs for a provider. Returns null if cache is missing or stale.
|
||||
*/
|
||||
export function readCachedModelIds(basePath, providerId) {
|
||||
try {
|
||||
const path = cacheFilePath(basePath, providerId);
|
||||
if (!existsSync(path)) return null;
|
||||
const entry = JSON.parse(readFileSync(path, "utf-8"));
|
||||
if (!entry?.fetchedAt || !Array.isArray(entry.modelIds)) return null;
|
||||
if (Date.now() - new Date(entry.fetchedAt).getTime() > CATALOG_TTL_MS) return null;
|
||||
return entry.modelIds;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCacheEntry(basePath, providerId, modelIds) {
|
||||
try {
|
||||
mkdirSync(cacheDirPath(basePath), { recursive: true });
|
||||
writeFileSync(
|
||||
cacheFilePath(basePath, providerId),
|
||||
JSON.stringify({ fetchedAt: new Date().toISOString(), modelIds }),
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — never fail the caller.
|
||||
}
|
||||
}
|
||||
|
||||
function buildAuthHeaders(cfg, apiKey) {
|
||||
switch (cfg.auth.type) {
|
||||
case "bearer":
|
||||
return { Authorization: `Bearer ${apiKey}` };
|
||||
case "x-api-key":
|
||||
return { [cfg.auth.header ?? "x-api-key"]: apiKey };
|
||||
case "query-param":
|
||||
return {};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(cfg, apiKey) {
|
||||
const base = cfg.baseUrl.replace(/\/$/, "");
|
||||
const path = cfg.modelsPath;
|
||||
if (cfg.auth.type === "query-param" && cfg.auth.param) {
|
||||
return `${base}${path}?${cfg.auth.param}=${encodeURIComponent(apiKey)}`;
|
||||
}
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
function parseModelIds(cfg, json) {
|
||||
let items;
|
||||
if (cfg.type === "google") {
|
||||
// Google: { models: [{name: "models/gemini-..."}] }
|
||||
items = Array.isArray(json?.models) ? json.models : null;
|
||||
if (!items) return null;
|
||||
return items.map((m) => (m.name ?? "").replace(/^models\//, "")).filter(Boolean);
|
||||
}
|
||||
// OpenAI-compatible: { data: [{id}] } or { models: [{id}] }
|
||||
items = Array.isArray(json?.data) ? json.data : Array.isArray(json?.models) ? json.models : null;
|
||||
if (!items) return null;
|
||||
return items.map((m) => m.id ?? m.name).filter(Boolean);
|
||||
}
|
||||
|
||||
function applyModelFilter(providerId, modelIds) {
|
||||
const patterns = getProviderModelExcludePatterns(providerId);
|
||||
if (patterns.length === 0) return modelIds;
|
||||
return modelIds.filter((id) => !patterns.some((re) => re.test(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the live model list for one provider and update the disk cache.
|
||||
* Returns the filtered model ID array on success, null on any error.
|
||||
*/
|
||||
export async function refreshProviderCatalog(basePath, providerId, apiKey) {
|
||||
const cfg = getProviderCatalogConfig(providerId);
|
||||
if (!cfg?.modelsPath || !apiKey) return null;
|
||||
try {
|
||||
const url = buildUrl(cfg, apiKey);
|
||||
const res = await fetch(url, {
|
||||
headers: buildAuthHeaders(cfg, apiKey),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const raw = parseModelIds(cfg, json);
|
||||
if (!raw) return null;
|
||||
const modelIds = applyModelFilter(providerId, raw);
|
||||
writeCacheEntry(basePath, providerId, modelIds);
|
||||
return modelIds;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget background refresh for all discoverable providers that have
|
||||
* a key in auth.json and a stale or absent cache. Safe to call at session
|
||||
* start — never throws, never blocks.
|
||||
*/
|
||||
export function scheduleModelCatalogRefresh(basePath, auth) {
|
||||
setImmediate(async () => {
|
||||
for (const providerId of DISCOVERABLE_PROVIDER_IDS) {
|
||||
try {
|
||||
const creds = auth.getCredentialsForProvider(providerId);
|
||||
const apiKey = creds.find((c) => c.type === "api_key" && c.key)?.key;
|
||||
if (!apiKey) continue;
|
||||
if (readCachedModelIds(basePath, providerId) !== null) continue;
|
||||
await refreshProviderCatalog(basePath, providerId, apiKey);
|
||||
} catch {
|
||||
// Per-provider failures are silently swallowed.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the union of SDK-static model IDs and live-discovered model IDs for a
|
||||
* provider. Hot path — reads only from disk cache, no network call.
|
||||
*
|
||||
* Consumer: model picker, preference validation for unknown model IDs.
|
||||
*/
|
||||
export function getKnownModelIds(basePath, providerId, sdkModelIds = []) {
|
||||
const cached = readCachedModelIds(basePath, providerId) ?? [];
|
||||
return [...new Set([...sdkModelIds, ...cached])];
|
||||
}
|
||||
|
|
@ -2,29 +2,18 @@
|
|||
//
|
||||
// Persists the user's deep-mode opt-in across sessions. Reads the existing
|
||||
// preferences file (if any), parses its YAML, sets/updates planning_depth,
|
||||
// and writes the file back preserving body content (for legacy .md) or the full
|
||||
// YAML (for .yaml).
|
||||
// and writes the full YAML back.
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import { sfRoot } from "./paths.js";
|
||||
import { logWarning } from "./workflow-logger.js";
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
/**
|
||||
* Resolve the path to the project-level preferences file (yaml canonical, md fallback).
|
||||
* Returns { path, isYaml } where isYaml indicates the format to use for writes.
|
||||
* Resolve the path to the project-level preferences file.
|
||||
*/
|
||||
function getProjectSFPreferencesFilePath(basePath) {
|
||||
const sfRootPath = sfRoot(basePath);
|
||||
const yamlPath = join(sfRootPath, "preferences.yaml");
|
||||
if (existsSync(yamlPath)) return { path: yamlPath, isYaml: true };
|
||||
const upperMdPath = join(sfRootPath, "PREFERENCES.md");
|
||||
if (existsSync(upperMdPath)) return { path: upperMdPath, isYaml: false };
|
||||
const lowerMdPath = join(sfRootPath, "preferences.md");
|
||||
if (existsSync(lowerMdPath)) return { path: lowerMdPath, isYaml: false };
|
||||
// Default to yaml for new files
|
||||
return { path: yamlPath, isYaml: true };
|
||||
return join(sfRoot(basePath), "preferences.yaml");
|
||||
}
|
||||
/**
|
||||
* Resolve the path to the project-level .sf/runtime/research-decision.json file.
|
||||
|
|
@ -49,84 +38,53 @@ export function writeDefaultResearchSkipDecision(basePath) {
|
|||
writeFileSync(decisionPath, payload, "utf-8");
|
||||
}
|
||||
/**
|
||||
* Set planning_depth in the project's preferences file (.sf/preferences.yaml or legacy .sf/PREFERENCES.md).
|
||||
* Creates the file if it does not exist. Preserves existing frontmatter
|
||||
* keys and body content. Intended to be called when the user opts into
|
||||
* (or out of) deep mode via `/new-project --deep` or similar.
|
||||
* Set planning_depth in the project's preferences file (.sf/preferences.yaml).
|
||||
* Creates the file if it does not exist. Preserves existing YAML keys.
|
||||
* Intended to be called when the user opts into (or out of) deep mode via
|
||||
* `/new-project --deep` or similar.
|
||||
*/
|
||||
export function setPlanningDepth(basePath, depth) {
|
||||
const { path, isYaml } = getProjectSFPreferencesFilePath(basePath);
|
||||
const { frontmatter, body } = readProjectPreferencesParts(path, isYaml);
|
||||
const path = getProjectSFPreferencesFilePath(basePath);
|
||||
const { frontmatter } = readProjectPreferencesParts(path);
|
||||
frontmatter.planning_depth = depth;
|
||||
if (depth === "deep") {
|
||||
applyDeepWorkflowPreferenceDefaults(frontmatter);
|
||||
}
|
||||
writeProjectPreferencesParts(path, frontmatter, body, isYaml);
|
||||
writeProjectPreferencesParts(path, frontmatter);
|
||||
if (depth === "deep") {
|
||||
ensureResearchDecisionDefault(basePath);
|
||||
}
|
||||
}
|
||||
export function ensureWorkflowPreferencesCaptured(basePath) {
|
||||
const { path, isYaml } = getProjectSFPreferencesFilePath(basePath);
|
||||
const { frontmatter, body } = readProjectPreferencesParts(path, isYaml);
|
||||
const path = getProjectSFPreferencesFilePath(basePath);
|
||||
const { frontmatter } = readProjectPreferencesParts(path);
|
||||
frontmatter.planning_depth = "deep";
|
||||
applyDeepWorkflowPreferenceDefaults(frontmatter);
|
||||
writeProjectPreferencesParts(path, frontmatter, body, isYaml);
|
||||
writeProjectPreferencesParts(path, frontmatter);
|
||||
ensureResearchDecisionDefault(basePath);
|
||||
}
|
||||
function readProjectPreferencesParts(path, isYaml) {
|
||||
function readProjectPreferencesParts(path) {
|
||||
let frontmatter = {};
|
||||
let body = "";
|
||||
if (existsSync(path)) {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
if (isYaml) {
|
||||
// Pure YAML — no frontmatter markers
|
||||
try {
|
||||
const parsed = parseYaml(content);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
frontmatter = parsed;
|
||||
}
|
||||
} catch (err) {
|
||||
logWarning(
|
||||
"guided",
|
||||
`preferences.yaml has invalid YAML — rewriting: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
try {
|
||||
const parsed = parseYaml(content);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
frontmatter = parsed;
|
||||
}
|
||||
return { frontmatter, body: "" };
|
||||
}
|
||||
const match = content.match(FRONTMATTER_RE);
|
||||
if (match) {
|
||||
try {
|
||||
const parsed = parseYaml(match[1]);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
frontmatter = parsed;
|
||||
}
|
||||
body = match[2];
|
||||
} catch (err) {
|
||||
logWarning(
|
||||
"guided",
|
||||
`preferences file frontmatter has invalid YAML — preserving body and rewriting frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
body = content;
|
||||
}
|
||||
} else {
|
||||
body = content;
|
||||
} catch (err) {
|
||||
logWarning(
|
||||
"guided",
|
||||
`preferences.yaml has invalid YAML — rewriting: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { frontmatter, body };
|
||||
return { frontmatter };
|
||||
}
|
||||
function writeProjectPreferencesParts(path, frontmatter, body, isYaml) {
|
||||
function writeProjectPreferencesParts(path, frontmatter) {
|
||||
const yamlBlock = stringifyYaml(frontmatter).replace(/\n$/, "");
|
||||
let newContent;
|
||||
if (isYaml) {
|
||||
newContent = `${yamlBlock}\n`;
|
||||
} else {
|
||||
newContent = body
|
||||
? `---\n${yamlBlock}\n---\n\n${body.replace(/^\n+/, "")}`
|
||||
: `---\n${yamlBlock}\n---\n`;
|
||||
}
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, newContent, "utf-8");
|
||||
writeFileSync(path, `${yamlBlock}\n`, "utf-8");
|
||||
}
|
||||
function applyDeepWorkflowPreferenceDefaults(frontmatter) {
|
||||
if (frontmatter.commit_policy === undefined) {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
/**
|
||||
* Self-aligning template upgrades for SF config files.
|
||||
*
|
||||
* Goal: when sf evolves, projects' .sf/PREFERENCES.md (and friends) get
|
||||
* brought into alignment WITHOUT human intervention. The file's frontmatter
|
||||
* stamps `last_synced_with_sf: <semver>`; on every load, sf compares to its
|
||||
* own version and silently re-writes the frontmatter to match. The body
|
||||
* (Markdown after the frontmatter) is preserved verbatim — users can keep
|
||||
* notes there and sf won't clobber them.
|
||||
* Goal: when sf evolves, projects' .sf/preferences.yaml gets brought into
|
||||
* alignment WITHOUT human intervention. The file's frontmatter stamps
|
||||
* `last_synced_with_sf: <semver>`; on every load, sf compares to its own
|
||||
* version and silently re-writes the file to match.
|
||||
*
|
||||
* What this is NOT: a human-facing warning system. The end-user shouldn't
|
||||
* have to read drift advisories; sf keeps its own house in order.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { serializePreferencesToFrontmatter } from "./preferences-serializer.js";
|
||||
export function detectTemplateDrift(prefs) {
|
||||
const toVersion = process.env.SF_VERSION || "0.0.0";
|
||||
|
|
@ -49,37 +47,10 @@ export function upgradePreferencesFileIfDrifted(path, prefs) {
|
|||
last_synced_with_sf: drift.toVersion,
|
||||
};
|
||||
if (!existsSync(path)) return upgraded;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(path, "utf-8");
|
||||
} catch {
|
||||
return upgraded;
|
||||
}
|
||||
// Pure YAML file — rewrite entirely (no frontmatter markers, no body to preserve)
|
||||
if (path.endsWith(".yaml")) {
|
||||
try {
|
||||
writeFileSync(path, serializePreferencesToFrontmatter(upgraded), "utf-8");
|
||||
} catch {
|
||||
// Read-only filesystem, permission error, etc. — not fatal.
|
||||
}
|
||||
return upgraded;
|
||||
}
|
||||
const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n";
|
||||
if (!content.startsWith(startMarker)) {
|
||||
// File doesn't have the canonical frontmatter shape — leave it alone.
|
||||
return upgraded;
|
||||
}
|
||||
const endIdx = content.indexOf("\n---", startMarker.length);
|
||||
if (endIdx === -1) return upgraded;
|
||||
// Body is everything after the frontmatter's closing `---` line.
|
||||
const bodyStart = content.indexOf("\n", endIdx + 1);
|
||||
const body = bodyStart === -1 ? "" : content.slice(bodyStart);
|
||||
const newFrontmatter = `---\n${serializePreferencesToFrontmatter(upgraded)}---`;
|
||||
try {
|
||||
writeFileSync(path, newFrontmatter + body, "utf-8");
|
||||
writeFileSync(path, serializePreferencesToFrontmatter(upgraded), "utf-8");
|
||||
} catch {
|
||||
// Read-only filesystem, permission error, etc. — not fatal.
|
||||
return upgraded;
|
||||
}
|
||||
return upgraded;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,20 +78,10 @@ export {
|
|||
function sfHome() {
|
||||
return process.env.SF_HOME || join(homedir(), ".sf");
|
||||
}
|
||||
// Canonical new location — pure YAML, no frontmatter markers
|
||||
// Canonical location — pure YAML, no frontmatter markers
|
||||
function globalPreferencesYamlPath() {
|
||||
return join(sfHome(), "preferences.yaml");
|
||||
}
|
||||
// Legacy markdown paths kept for fallback reads
|
||||
function globalPreferencesPath() {
|
||||
return join(sfHome(), "preferences.md");
|
||||
}
|
||||
function globalPreferencesPathUppercase() {
|
||||
return join(sfHome(), "PREFERENCES.md");
|
||||
}
|
||||
function legacyGlobalPreferencesPath() {
|
||||
return join(homedir(), ".pi", "agent", "sf-preferences.md");
|
||||
}
|
||||
/**
|
||||
* Resolve the "project root" for preferences. When SF is running inside a
|
||||
* git worktree (e.g. `.sf/worktrees/M003/`), project-level prefs should
|
||||
|
|
@ -131,58 +121,34 @@ function projectPrefsRoot() {
|
|||
}
|
||||
return cwd;
|
||||
}
|
||||
// Canonical new location — pure YAML
|
||||
// Canonical location — pure YAML
|
||||
function projectPreferencesYamlPath() {
|
||||
return join(sfRoot(projectPrefsRoot()), "preferences.yaml");
|
||||
}
|
||||
// Legacy markdown paths kept for fallback reads
|
||||
function projectPreferencesPath() {
|
||||
return join(sfRoot(projectPrefsRoot()), "preferences.md");
|
||||
}
|
||||
function projectPreferencesPathUppercase() {
|
||||
return join(sfRoot(projectPrefsRoot()), "PREFERENCES.md");
|
||||
}
|
||||
/**
|
||||
* Get the canonical path for the global SF preferences file.
|
||||
* New installs use preferences.yaml; legacy .md files are read as fallbacks.
|
||||
* Get the canonical path for the global SF preferences file (preferences.yaml).
|
||||
*/
|
||||
export function getGlobalSFPreferencesPath() {
|
||||
return globalPreferencesYamlPath();
|
||||
}
|
||||
/**
|
||||
* Get the path to the legacy global SF preferences file (deprecated location).
|
||||
*/
|
||||
export function getLegacyGlobalSFPreferencesPath() {
|
||||
return legacyGlobalPreferencesPath();
|
||||
}
|
||||
/**
|
||||
* Get the canonical path for the project-level SF preferences file.
|
||||
* New installs use preferences.yaml; legacy .md files are read as fallbacks.
|
||||
* Get the canonical path for the project-level SF preferences file (preferences.yaml).
|
||||
*/
|
||||
export function getProjectSFPreferencesPath() {
|
||||
return projectPreferencesYamlPath();
|
||||
}
|
||||
// ─── Loading ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Load global SF preferences, trying yaml first then legacy markdown locations.
|
||||
* Load global SF preferences from preferences.yaml.
|
||||
*/
|
||||
export function loadGlobalSFPreferences() {
|
||||
return (
|
||||
loadPreferencesFile(globalPreferencesYamlPath(), "global") ??
|
||||
loadPreferencesFile(globalPreferencesPath(), "global") ??
|
||||
loadPreferencesFile(globalPreferencesPathUppercase(), "global") ??
|
||||
loadPreferencesFile(legacyGlobalPreferencesPath(), "global")
|
||||
);
|
||||
return loadPreferencesFile(globalPreferencesYamlPath(), "global");
|
||||
}
|
||||
/**
|
||||
* Load project-level SF preferences, trying yaml first then legacy markdown.
|
||||
* Load project-level SF preferences from preferences.yaml.
|
||||
*/
|
||||
export function loadProjectSFPreferences() {
|
||||
return (
|
||||
loadPreferencesFile(projectPreferencesYamlPath(), "project") ??
|
||||
loadPreferencesFile(projectPreferencesPath(), "project") ??
|
||||
loadPreferencesFile(projectPreferencesPathUppercase(), "project")
|
||||
);
|
||||
return loadPreferencesFile(projectPreferencesYamlPath(), "project");
|
||||
}
|
||||
/**
|
||||
* Load and merge global and project preferences with profile defaults and mode defaults applied.
|
||||
|
|
@ -237,10 +203,7 @@ export function loadEffectiveSFPreferences() {
|
|||
function loadPreferencesFile(path, scope) {
|
||||
if (!existsSync(path)) return null;
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
// .yaml files are pure YAML — no frontmatter extraction needed
|
||||
const preferences = path.endsWith(".yaml")
|
||||
? parsePreferencesYaml(raw)
|
||||
: parsePreferencesMarkdown(raw);
|
||||
const preferences = parsePreferencesYaml(raw);
|
||||
if (!preferences) return null;
|
||||
const validation = validatePreferences(preferences);
|
||||
// Self-align: if the file's recorded sf version drifted from current,
|
||||
|
|
@ -256,13 +219,9 @@ function loadPreferencesFile(path, scope) {
|
|||
...(allWarnings.length > 0 ? { warnings: allWarnings } : {}),
|
||||
};
|
||||
}
|
||||
let _warnedUnrecognizedFormat = false;
|
||||
let _warnedSectionParse = false;
|
||||
/** @internal Reset the warn-once flags — exported for testing only. */
|
||||
export function _resetParseWarningFlag() {
|
||||
_warnedUnrecognizedFormat = false;
|
||||
_warnedFrontmatterParse = false;
|
||||
_warnedSectionParse = false;
|
||||
// No warn-once flags remain after removal of legacy markdown parser.
|
||||
}
|
||||
/**
|
||||
* Parse preferences from a pure YAML file (no frontmatter markers needed).
|
||||
|
|
@ -281,119 +240,14 @@ export function parsePreferencesYaml(content) {
|
|||
}
|
||||
}
|
||||
/**
|
||||
* Parse preferences from markdown frontmatter or heading+list format.
|
||||
*
|
||||
* @internal Exported for testing only
|
||||
* @deprecated Use parsePreferencesYaml instead.
|
||||
* Retained for callers (e.g. auto-dashboard.js) that read .yaml content via this path.
|
||||
* Strips YAML frontmatter markers if present, then delegates to parsePreferencesYaml.
|
||||
*/
|
||||
export function parsePreferencesMarkdown(content) {
|
||||
// Use indexOf instead of [\s\S]*? regex to avoid backtracking (#468)
|
||||
const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n";
|
||||
if (content.startsWith(startMarker)) {
|
||||
const searchStart = startMarker.length;
|
||||
const endIdx = content.indexOf("\n---", searchStart);
|
||||
if (endIdx === -1) return null;
|
||||
const block = content.slice(searchStart, endIdx);
|
||||
return parseFrontmatterBlock(block.replace(/\r/g, ""));
|
||||
}
|
||||
// Fallback: heading+list format (e.g. "## Git\n- isolation: none") (#2036)
|
||||
// SF agents may write preferences files without frontmatter delimiters.
|
||||
if (/^##\s+\w/m.test(content)) {
|
||||
return parseHeadingListFormat(content);
|
||||
}
|
||||
// Warn when a non-empty file exists but lacks frontmatter delimiters (#2036).
|
||||
if (content.trim().length > 0 && !_warnedUnrecognizedFormat) {
|
||||
_warnedUnrecognizedFormat = true;
|
||||
console.warn(
|
||||
"[SF] Warning: preferences file has unrecognized format — content does not use YAML frontmatter delimiters (---). " +
|
||||
"Wrap your preferences in --- fences. See https://github.com/singularity-forge/sf-run/issues/2036",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let _warnedFrontmatterParse = false;
|
||||
function parseFrontmatterBlock(frontmatter) {
|
||||
try {
|
||||
const parsed = parseYaml(frontmatter);
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return {};
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
// Warn at most once per session to avoid flooding TUI (#3376)
|
||||
if (!_warnedFrontmatterParse) {
|
||||
_warnedFrontmatterParse = true;
|
||||
logWarning(
|
||||
"guided",
|
||||
`YAML parse error in preferences frontmatter (suppressing further): ${e.message}`,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Parse heading+list format into a nested object, then cast to SFPreferences.
|
||||
* Handles markdown like:
|
||||
* ## Git
|
||||
* - isolation: none
|
||||
* - commit_docs: true
|
||||
* ## Models
|
||||
* - planner: sonnet
|
||||
*/
|
||||
function parseHeadingListFormat(content) {
|
||||
const result = {};
|
||||
let currentSection = null;
|
||||
for (const rawLine of content.split("\n")) {
|
||||
const line = rawLine.replace(/\r$/, "");
|
||||
const headingMatch = line.match(/^##\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
currentSection = headingMatch[1]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "_");
|
||||
if (!result[currentSection]) result[currentSection] = [];
|
||||
continue;
|
||||
}
|
||||
if (currentSection && line.trim() && !line.trimStart().startsWith("#")) {
|
||||
result[currentSection].push(line);
|
||||
}
|
||||
}
|
||||
const typed = {};
|
||||
for (const [section, lines] of Object.entries(result)) {
|
||||
if (lines.length === 0) continue;
|
||||
const usesLegacyListItems = lines.every((line) =>
|
||||
/^\s*-\s+[^:]+:\s*.*$/.test(line),
|
||||
);
|
||||
const yamlBlock = usesLegacyListItems
|
||||
? lines.map((line) => line.replace(/^\s*-\s+/, "")).join("\n")
|
||||
: lines.join("\n");
|
||||
try {
|
||||
const parsed = parseYaml(yamlBlock);
|
||||
if (typeof parsed !== "object" || parsed === null) continue;
|
||||
let targetSection = section;
|
||||
let value = parsed;
|
||||
if (!Array.isArray(parsed)) {
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 1) {
|
||||
const [onlyKey] = keys;
|
||||
if (
|
||||
onlyKey === section ||
|
||||
(!KNOWN_PREFERENCE_KEYS.has(section) &&
|
||||
KNOWN_PREFERENCE_KEYS.has(onlyKey))
|
||||
) {
|
||||
targetSection = onlyKey;
|
||||
value = parsed[onlyKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
typed[targetSection] = value;
|
||||
} catch (e) {
|
||||
if (!_warnedSectionParse) {
|
||||
_warnedSectionParse = true;
|
||||
logWarning("guided", `preferences section parse failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return typed;
|
||||
// Strip optional frontmatter fences so legacy callers work with pure YAML too
|
||||
const fenced = /^---\r?\n([\s\S]*?)\n---(?:\r?\n[\s\S]*)?$/.exec(content);
|
||||
return parsePreferencesYaml(fenced ? fenced[1] : content);
|
||||
}
|
||||
// ─── Merging ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
|
|
@ -787,7 +641,7 @@ export function resolvePreDispatchHooks() {
|
|||
* Resolve the effective git isolation mode from preferences.
|
||||
* Returns "none" (default), "worktree", or "branch".
|
||||
*
|
||||
* Default is "none" so SF works out of the box without preferences.md.
|
||||
* Default is "none" so SF works out of the box without preferences.yaml.
|
||||
* Worktree isolation requires explicit opt-in because it depends on git
|
||||
* branch infrastructure that must be set up before use.
|
||||
*/
|
||||
|
|
|
|||
173
src/resources/extensions/sf/provider-catalog-config.js
Normal file
173
src/resources/extensions/sf/provider-catalog-config.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* provider-catalog-config.js — per-provider runtime configuration.
|
||||
*
|
||||
* Single source of truth for provider-level settings that go beyond key
|
||||
* management: API format, base URL, model-discovery endpoint, and rate-limit
|
||||
* semantics. Consumers: model-catalog-cache.js, doctor-providers.js (test
|
||||
* endpoints), and the backoff/retry layer.
|
||||
*
|
||||
* Adding a provider:
|
||||
* 1. Add an entry here.
|
||||
* 2. If it needs a key, add it to PROVIDER_REGISTRY in key-manager.js.
|
||||
* 3. Run `npm run copy-resources` to rebuild dist.
|
||||
*/
|
||||
|
||||
/**
|
||||
* API format families.
|
||||
* - "openai" — OpenAI REST: Bearer auth, POST /v1/chat/completions, GET /v1/models returns { data: [{id}] }
|
||||
* - "anthropic" — Anthropic REST: x-api-key header, POST /v1/messages, no public GET /v1/models
|
||||
* - "google" — Gemini REST: x-goog-api-key header, GET /v1beta/models returns { models: [{name}] }
|
||||
* - "custom" — Non-standard; model discovery not supported
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rate-limit scope.
|
||||
* - "provider" — a single 429 takes down the whole provider (back off all models)
|
||||
* - "model" — 429 is per-model; other models on the same provider remain usable
|
||||
*/
|
||||
|
||||
export const PROVIDER_CATALOG_CONFIG = {
|
||||
// ─── Anthropic ────────────────────────────────────────────────────────────
|
||||
anthropic: {
|
||||
type: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
modelsPath: null,
|
||||
auth: { type: "x-api-key", header: "x-api-key" },
|
||||
rateLimits: { scope: "model" },
|
||||
},
|
||||
|
||||
// ─── OpenAI family ───────────────────────────────────────────────────────
|
||||
openai: {
|
||||
type: "openai",
|
||||
baseUrl: "https://api.openai.com",
|
||||
modelsPath: "/v1/models",
|
||||
auth: { type: "bearer" },
|
||||
rateLimits: { scope: "model" },
|
||||
modelFilter: {
|
||||
// exclude embedding, moderation, image, audio, realtime, and legacy models
|
||||
excludePatterns: [
|
||||
/embed/i,
|
||||
/moderation/i,
|
||||
/dall-e/i,
|
||||
/tts/i,
|
||||
/whisper/i,
|
||||
/realtime/i,
|
||||
/instruct/i,
|
||||
/babbage/i,
|
||||
/davinci/i,
|
||||
/ada/i,
|
||||
/curie/i,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
groq: {
|
||||
type: "openai",
|
||||
baseUrl: "https://api.groq.com",
|
||||
modelsPath: "/openai/v1/models",
|
||||
auth: { type: "bearer" },
|
||||
rateLimits: { scope: "model" },
|
||||
modelFilter: {
|
||||
excludePatterns: [/whisper/i, /guard/i, /tool-use/i, /vision-preview/i],
|
||||
},
|
||||
},
|
||||
|
||||
mistral: {
|
||||
type: "openai",
|
||||
baseUrl: "https://api.mistral.ai",
|
||||
modelsPath: "/v1/models",
|
||||
auth: { type: "bearer" },
|
||||
rateLimits: { scope: "provider" },
|
||||
modelFilter: {
|
||||
excludePatterns: [
|
||||
/embed/i,
|
||||
/moderation/i,
|
||||
/ocr/i,
|
||||
/voxtral/i,
|
||||
/transcribe/i,
|
||||
/tts/i,
|
||||
/realtime/i,
|
||||
/^ft:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
openrouter: {
|
||||
type: "openai",
|
||||
baseUrl: "https://openrouter.ai",
|
||||
modelsPath: "/api/v1/models",
|
||||
auth: { type: "bearer" },
|
||||
// OpenRouter aggregates many providers — 429 varies by upstream but
|
||||
// the router itself rate-limits per-account, not per-model.
|
||||
rateLimits: { scope: "provider" },
|
||||
modelFilter: {
|
||||
// Only free-tier models pass the built-in policy (isModelAllowedByBuiltInProviderPolicy)
|
||||
// so no additional filter needed here; handled in preferences-models.js.
|
||||
excludePatterns: [],
|
||||
},
|
||||
},
|
||||
|
||||
cerebras: {
|
||||
type: "openai",
|
||||
baseUrl: "https://api.cerebras.ai",
|
||||
modelsPath: "/v1/models",
|
||||
auth: { type: "bearer" },
|
||||
rateLimits: { scope: "provider" },
|
||||
modelFilter: {
|
||||
excludePatterns: [],
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Google ──────────────────────────────────────────────────────────────
|
||||
google: {
|
||||
type: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
// Returns { models: [{name: "models/gemini-..."}] } — non-standard format.
|
||||
modelsPath: "/v1beta/models",
|
||||
auth: { type: "query-param", param: "key" },
|
||||
rateLimits: { scope: "model" },
|
||||
modelFilter: {
|
||||
// Gemini model list includes embedding and vision-only models.
|
||||
excludePatterns: [/embed/i, /aqa/i],
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Azure OpenAI ─────────────────────────────────────────────────────────
|
||||
// Base URL is deployment-specific; model discovery via /openai/deployments.
|
||||
"azure-openai": {
|
||||
type: "openai",
|
||||
baseUrl: null,
|
||||
modelsPath: null,
|
||||
auth: { type: "bearer" },
|
||||
rateLimits: { scope: "model" },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider IDs that support live model-list discovery (have a modelsPath).
|
||||
*/
|
||||
export const DISCOVERABLE_PROVIDER_IDS = Object.entries(PROVIDER_CATALOG_CONFIG)
|
||||
.filter(([, cfg]) => cfg.modelsPath !== null)
|
||||
.map(([id]) => id);
|
||||
|
||||
/**
|
||||
* Returns the catalog config for a provider, or null if not configured.
|
||||
*/
|
||||
export function getProviderCatalogConfig(providerId) {
|
||||
return PROVIDER_CATALOG_CONFIG[providerId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a 429 from this provider should back off all models on the
|
||||
* provider rather than just the one that triggered the response.
|
||||
*/
|
||||
export function isProviderScopedRateLimit(providerId) {
|
||||
return PROVIDER_CATALOG_CONFIG[providerId]?.rateLimits?.scope === "provider";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the exclude patterns for a provider's model list, or [].
|
||||
*/
|
||||
export function getProviderModelExcludePatterns(providerId) {
|
||||
return PROVIDER_CATALOG_CONFIG[providerId]?.modelFilter?.excludePatterns ?? [];
|
||||
}
|
||||
|
|
@ -26,10 +26,10 @@ function makePreferencesProject(globalPreferences) {
|
|||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(join(home, ".sf", "agent"), { recursive: true });
|
||||
mkdirSync(join(project, ".sf"), { recursive: true });
|
||||
writeFileSync(join(home, "preferences.md"), globalPreferences, "utf-8");
|
||||
writeFileSync(join(home, "preferences.yaml"), globalPreferences, "utf-8");
|
||||
writeFileSync(
|
||||
join(project, ".sf", "PREFERENCES.md"),
|
||||
"---\nversion: 1\nmodels: {}\n---\n",
|
||||
join(project, ".sf", "preferences.yaml"),
|
||||
"version: 1\nmodels: {}\n",
|
||||
"utf-8",
|
||||
);
|
||||
process.env.SF_HOME = home;
|
||||
|
|
@ -49,12 +49,10 @@ describe("doctor provider checks", () => {
|
|||
test("runProviderChecks_when_any_configured_llm_route_is_usable_does_not_require_every_preferred_provider", () => {
|
||||
makePreferencesProject(
|
||||
[
|
||||
"---",
|
||||
"version: 1",
|
||||
"models:",
|
||||
" execution: kimi-coding/kimi-k2.6",
|
||||
" reassess: mistral/devstral-latest",
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
|
@ -71,7 +69,6 @@ describe("doctor provider checks", () => {
|
|||
test("runProviderChecks_when_remote_questions_auto_resolve_does_not_require_channel_token", () => {
|
||||
makePreferencesProject(
|
||||
[
|
||||
"---",
|
||||
"version: 1",
|
||||
"models:",
|
||||
" execution: kimi-coding/kimi-k2.6",
|
||||
|
|
@ -80,7 +77,6 @@ describe("doctor provider checks", () => {
|
|||
" channel_id: 562797207",
|
||||
" auto_resolve_on_timeout: true",
|
||||
" auto_resolve_strategy: recommended-option",
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
|
@ -97,11 +93,9 @@ describe("doctor provider checks", () => {
|
|||
test("runProviderChecks_when_google_env_auth_is_default_off_treats_google_as_missing_required_route", () => {
|
||||
makePreferencesProject(
|
||||
[
|
||||
"---",
|
||||
"version: 1",
|
||||
"models:",
|
||||
" planning: google/gemini-2.5-pro",
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
|
@ -116,11 +110,9 @@ describe("doctor provider checks", () => {
|
|||
test("runProviderChecks_when_google_env_auth_is_enabled_accepts_google_env_key", () => {
|
||||
const project = makePreferencesProject(
|
||||
[
|
||||
"---",
|
||||
"version: 1",
|
||||
"models:",
|
||||
" planning: google/gemini-2.5-pro",
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function makePreferencesProject(projectPreferences, projectSettings) {
|
|||
mkdirSync(join(home, ".sf", "agent"), { recursive: true });
|
||||
mkdirSync(join(project, ".sf"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(project, ".sf", "PREFERENCES.md"),
|
||||
join(project, ".sf", "preferences.yaml"),
|
||||
projectPreferences,
|
||||
"utf-8",
|
||||
);
|
||||
|
|
@ -47,12 +47,10 @@ describe("preferences model resolution", () => {
|
|||
test("resolveModelWithFallbacksForUnit_when_google_env_auth_is_default_off_skips_google_auto_benchmark_candidates", () => {
|
||||
makePreferencesProject(
|
||||
[
|
||||
"---",
|
||||
"version: 1",
|
||||
"allowed_providers:",
|
||||
" - google",
|
||||
"models: {}",
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -108,8 +108,6 @@ function hasSfBootstrapArtifacts(sfPath) {
|
|||
return (
|
||||
existsSync(sfPath) &&
|
||||
(existsSync(join(sfPath, "preferences.yaml")) ||
|
||||
existsSync(join(sfPath, "PREFERENCES.md")) ||
|
||||
existsSync(join(sfPath, "preferences.md")) ||
|
||||
existsSync(join(sfPath, "milestones")))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue