min-agent 0.1.3 → 0.1.5

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.
@@ -0,0 +1,166 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import path from "path";
4
+ export function scanProject() {
5
+ const cwd = process.cwd();
6
+ const isGitRepo = existsSync(path.join(cwd, ".git"));
7
+ let branch;
8
+ if (isGitRepo) {
9
+ try {
10
+ branch = execSync("git branch --show-current", { encoding: "utf-8", cwd }).trim();
11
+ }
12
+ catch { }
13
+ }
14
+ const languages = [];
15
+ const configFiles = [];
16
+ const entryFiles = [];
17
+ // Detect by config files
18
+ const checks = [
19
+ { file: "package.json", lang: "TypeScript/JavaScript", pm: "npm" },
20
+ { file: "bun.lock", lang: "TypeScript/JavaScript", pm: "bun" },
21
+ { file: "yarn.lock", lang: "TypeScript/JavaScript", pm: "yarn" },
22
+ { file: "pnpm-lock.yaml", lang: "TypeScript/JavaScript", pm: "pnpm" },
23
+ { file: "tsconfig.json", lang: "TypeScript" },
24
+ { file: "Cargo.toml", lang: "Rust", pm: "cargo" },
25
+ { file: "go.mod", lang: "Go" },
26
+ { file: "pyproject.toml", lang: "Python", pm: "pip/uv" },
27
+ { file: "requirements.txt", lang: "Python", pm: "pip" },
28
+ { file: "Gemfile", lang: "Ruby", pm: "bundler" },
29
+ { file: "pom.xml", lang: "Java", pm: "maven" },
30
+ { file: "build.gradle", lang: "Java/Kotlin", pm: "gradle" },
31
+ { file: "composer.json", lang: "PHP", pm: "composer" },
32
+ { file: "Makefile", lang: "" },
33
+ { file: "Dockerfile", lang: "" },
34
+ ];
35
+ let packageManager;
36
+ let framework;
37
+ for (const check of checks) {
38
+ if (existsSync(path.join(cwd, check.file))) {
39
+ configFiles.push(check.file);
40
+ if (check.lang && !languages.includes(check.lang))
41
+ languages.push(check.lang);
42
+ if (check.pm && !packageManager)
43
+ packageManager = check.pm;
44
+ }
45
+ }
46
+ // Detect framework from package.json
47
+ if (existsSync(path.join(cwd, "package.json"))) {
48
+ try {
49
+ const pkg = JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf-8"));
50
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
51
+ if (allDeps["next"])
52
+ framework = "Next.js";
53
+ else if (allDeps["nuxt"])
54
+ framework = "Nuxt";
55
+ else if (allDeps["@angular/core"])
56
+ framework = "Angular";
57
+ else if (allDeps["vue"])
58
+ framework = "Vue";
59
+ else if (allDeps["react"])
60
+ framework = "React";
61
+ else if (allDeps["svelte"])
62
+ framework = "Svelte";
63
+ else if (allDeps["express"])
64
+ framework = "Express";
65
+ else if (allDeps["fastify"])
66
+ framework = "Fastify";
67
+ else if (allDeps["hono"])
68
+ framework = "Hono";
69
+ else if (allDeps["effect"])
70
+ framework = "Effect";
71
+ }
72
+ catch { }
73
+ }
74
+ // Find entry files
75
+ const entryPatterns = [
76
+ "src/index.ts", "src/index.js", "src/main.ts", "src/main.js",
77
+ "src/app.ts", "src/app.js", "index.ts", "index.js",
78
+ "main.ts", "main.js", "app.ts", "app.js",
79
+ "src/lib.rs", "main.go", "main.py", "app.py",
80
+ ];
81
+ for (const p of entryPatterns) {
82
+ if (existsSync(path.join(cwd, p)))
83
+ entryFiles.push(p);
84
+ }
85
+ // Build summary
86
+ const parts = [];
87
+ parts.push(`Directory: ${cwd}`);
88
+ if (isGitRepo)
89
+ parts.push(`Git: yes (branch: ${branch ?? "unknown"})`);
90
+ if (languages.length)
91
+ parts.push(`Languages: ${languages.join(", ")}`);
92
+ if (framework)
93
+ parts.push(`Framework: ${framework}`);
94
+ if (packageManager)
95
+ parts.push(`Package manager: ${packageManager}`);
96
+ if (configFiles.length)
97
+ parts.push(`Config files: ${configFiles.join(", ")}`);
98
+ if (entryFiles.length)
99
+ parts.push(`Entry points: ${entryFiles.join(", ")}`);
100
+ // List top-level directory structure
101
+ try {
102
+ const items = readdirSync(cwd)
103
+ .filter((f) => !f.startsWith(".") || f === ".env.example")
104
+ .filter((f) => f !== "node_modules" && f !== ".git")
105
+ .slice(0, 30)
106
+ .map((f) => {
107
+ const stat = statSync(path.join(cwd, f));
108
+ return stat.isDirectory() ? `${f}/` : f;
109
+ });
110
+ parts.push(`Structure: ${items.join(", ")}`);
111
+ }
112
+ catch { }
113
+ return {
114
+ directory: cwd,
115
+ isGitRepo,
116
+ branch,
117
+ languages,
118
+ framework,
119
+ packageManager,
120
+ entryFiles,
121
+ configFiles,
122
+ summary: parts.join("\n"),
123
+ };
124
+ }
125
+ export function buildCodeSystemPrompt(project, instructions) {
126
+ const parts = [
127
+ "You are an expert AI coding assistant. You help users with software engineering tasks including writing code, debugging, refactoring, and architecture decisions.",
128
+ "",
129
+ "# Principles",
130
+ "- Be concise and direct. Minimize output tokens while maintaining quality.",
131
+ "- NEVER add comments to code unless asked. Write self-documenting code.",
132
+ "- Follow existing code conventions in the project. Mimic style, libraries, and patterns.",
133
+ "- NEVER assume a library is available — check package.json/Cargo.toml/etc first.",
134
+ "- Prefer editing existing files over creating new ones.",
135
+ "- After making changes, run lint/typecheck/build if available.",
136
+ "- NEVER commit unless explicitly asked.",
137
+ "",
138
+ "# Workflow",
139
+ "- Use search tools (grep, glob) to understand the codebase before making changes.",
140
+ "- Use the edit tool for precise changes instead of rewriting entire files.",
141
+ "- When fixing bugs: read the relevant code, understand the issue, fix it, verify.",
142
+ "- When adding features: explore existing patterns first, then implement consistently.",
143
+ "- Run tests after changes when a test command is available.",
144
+ "",
145
+ "# Environment",
146
+ `<env>`,
147
+ ` Working directory: ${project.directory}`,
148
+ ` Git repo: ${project.isGitRepo ? `yes (branch: ${project.branch ?? "unknown"})` : "no"}`,
149
+ ` Platform: ${process.platform}`,
150
+ ` Date: ${new Date().toDateString()}`,
151
+ project.languages.length ? ` Languages: ${project.languages.join(", ")}` : "",
152
+ project.framework ? ` Framework: ${project.framework}` : "",
153
+ project.packageManager ? ` Package manager: ${project.packageManager}` : "",
154
+ `</env>`,
155
+ "",
156
+ "# Project Structure",
157
+ "```",
158
+ project.summary,
159
+ "```",
160
+ ].filter(Boolean);
161
+ if (instructions.length > 0) {
162
+ parts.push("", "# User Instructions", "");
163
+ parts.push(...instructions);
164
+ }
165
+ return parts.join("\n");
166
+ }
@@ -1,29 +1,94 @@
1
1
  import { generateText } from "ai";
2
2
  /**
3
- * Context compaction system.
3
+ * Context compaction system — modeled after opencode's SessionCompaction.
4
4
  *
5
- * When conversation history exceeds a token threshold, older messages are
6
- * summarized into a compact form to free up context window space.
7
- *
8
- * Strategy:
9
- * 1. Estimate token count of messages (rough: 1 token 4 chars for English, 2 chars for CJK)
10
- * 2. When over threshold, take older messages and summarize them via the LLM
11
- * 3. Replace old messages with a single system summary message
12
- * 4. Keep recent N turns verbatim for continuity
5
+ * Features:
6
+ * 1. Real token tracking from API responses
7
+ * 2. Structured summary template (Goal/Progress/Decisions/Files)
8
+ * 3. Incremental summaries (update previous summary instead of rewriting)
9
+ * 4. Tool output pruning (trim old tool results to save space)
10
+ * 5. Auto-continue after compaction
11
+ * 6. Token-budget-aware tail preservation
13
12
  */
14
- const COMPACTION_PROMPT = `You are a conversation summarizer. Summarize the following conversation history into a concise but complete summary that preserves:
15
- - Key decisions made
16
- - Important context and facts discussed
17
- - Current state of any tasks in progress
18
- - User preferences mentioned
19
- - File paths, code snippets, or technical details that are still relevant
13
+ // ─── Structured Summary Template (from opencode) ───────────────────────────
14
+ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown below. Keep the section order unchanged.
15
+
16
+ ## Goal
17
+ - [single-sentence task summary]
18
+
19
+ ## Constraints & Preferences
20
+ - [user constraints, preferences, specs, or "(none)"]
21
+
22
+ ## Progress
23
+ ### Done
24
+ - [completed work or "(none)"]
25
+
26
+ ### In Progress
27
+ - [current work or "(none)"]
28
+
29
+ ### Blocked
30
+ - [blockers or "(none)"]
20
31
 
21
- Be concise but don't lose critical information. Output only the summary, no preamble.`;
22
- // Default: trigger compaction at ~80% of context window
32
+ ## Key Decisions
33
+ - [decision and why, or "(none)"]
34
+
35
+ ## Next Steps
36
+ - [ordered next actions or "(none)"]
37
+
38
+ ## Critical Context
39
+ - [important technical facts, errors, open questions, or "(none)"]
40
+
41
+ ## Relevant Files
42
+ - [file or directory path: why it matters, or "(none)"]
43
+
44
+ Rules:
45
+ - Keep every section, even when empty.
46
+ - Use terse bullets, not prose paragraphs.
47
+ - Preserve exact file paths, commands, error strings, and identifiers when known.
48
+ - Do not mention the summary process or that context was compacted.`;
49
+ // ─── Constants ─────────────────────────────────────────────────────────────
23
50
  const DEFAULT_MAX_TOKENS = 128000;
24
51
  const COMPACTION_RATIO = 0.75;
25
- const KEEP_RECENT_TURNS = 4; // Keep last N user+assistant pairs verbatim
26
- /** Rough token estimation */
52
+ const DEFAULT_TAIL_TURNS = 2;
53
+ const TAIL_TOKEN_BUDGET_RATIO = 0.25;
54
+ const MIN_TAIL_BUDGET = 2000;
55
+ const MAX_TAIL_BUDGET = 8000;
56
+ const PRUNE_PROTECT_TOKENS = 40000;
57
+ const PRUNE_MIN_SAVINGS = 20000;
58
+ const TOOL_OUTPUT_MAX_CHARS = 2000;
59
+ // ─── Token Tracker ─────────────────────────────────────────────────────────
60
+ export class TokenTracker {
61
+ _lastInputTokens = 0;
62
+ _totalOutputTokens = 0;
63
+ _totalInputTokens = 0;
64
+ _totalCacheRead = 0;
65
+ update(usage) {
66
+ const input = usage.inputTokens ?? 0;
67
+ const output = usage.outputTokens ?? 0;
68
+ const cacheRead = usage.cachedInputTokens ?? 0;
69
+ this._lastInputTokens = input;
70
+ this._totalInputTokens += input;
71
+ this._totalOutputTokens += output;
72
+ this._totalCacheRead += cacheRead;
73
+ }
74
+ get lastInputTokens() {
75
+ return this._lastInputTokens;
76
+ }
77
+ get totalInputTokens() {
78
+ return this._totalInputTokens;
79
+ }
80
+ get totalOutputTokens() {
81
+ return this._totalOutputTokens;
82
+ }
83
+ summary() {
84
+ return `context: ${this._lastInputTokens} | total in: ${this._totalInputTokens} out: ${this._totalOutputTokens}`;
85
+ }
86
+ resetContext() {
87
+ this._lastInputTokens = 0;
88
+ }
89
+ }
90
+ // ─── Token Estimation ──────────────────────────────────────────────────────
91
+ /** Rough token estimation (fallback) */
27
92
  export function estimateTokens(messages) {
28
93
  let chars = 0;
29
94
  for (const msg of messages) {
@@ -38,51 +103,180 @@ export function estimateTokens(messages) {
38
103
  }
39
104
  }
40
105
  }
41
- // Rough estimate: mix of English (~4 chars/token) and CJK (~2 chars/token)
42
106
  return Math.ceil(chars / 3);
43
107
  }
44
- /** Check if compaction is needed */
45
- export function needsCompaction(messages, config) {
108
+ function estimateMessageTokens(msg) {
109
+ if (typeof msg.content === "string")
110
+ return Math.ceil(msg.content.length / 3);
111
+ if (Array.isArray(msg.content)) {
112
+ let chars = 0;
113
+ for (const part of msg.content) {
114
+ if ("text" in part && typeof part.text === "string")
115
+ chars += part.text.length;
116
+ }
117
+ return Math.ceil(chars / 3);
118
+ }
119
+ return 0;
120
+ }
121
+ // ─── Compaction Check ──────────────────────────────────────────────────────
122
+ export function needsCompaction(messages, tracker, config) {
46
123
  const maxTokens = config?.maxTokens ?? DEFAULT_MAX_TOKENS;
47
124
  const threshold = maxTokens * COMPACTION_RATIO;
125
+ if (tracker && tracker.lastInputTokens > 0) {
126
+ return tracker.lastInputTokens > threshold;
127
+ }
48
128
  return estimateTokens(messages) > threshold;
49
129
  }
50
- /** Compact messages by summarizing older history */
130
+ // ─── Tool Output Pruning ───────────────────────────────────────────────────
131
+ /**
132
+ * Prune old tool outputs in-place to free context space.
133
+ * Keeps recent tool outputs intact, trims older ones to a short summary.
134
+ * Returns the estimated tokens saved.
135
+ */
136
+ export function pruneToolOutputs(messages) {
137
+ let totalTokens = 0;
138
+ let saved = 0;
139
+ let turns = 0;
140
+ // Walk backwards, skip recent 2 turns
141
+ for (let i = messages.length - 1; i >= 0; i--) {
142
+ const msg = messages[i];
143
+ if (msg.role === "user")
144
+ turns++;
145
+ if (turns < 2)
146
+ continue;
147
+ // Prune tool results in older messages
148
+ if (msg.role === "tool" || (Array.isArray(msg.content) && msg.content.some((p) => p.type === "tool-result"))) {
149
+ const content = typeof msg.content === "string" ? msg.content : "";
150
+ const estimate = Math.ceil(content.length / 3);
151
+ totalTokens += estimate;
152
+ if (totalTokens > PRUNE_PROTECT_TOKENS && content.length > TOOL_OUTPUT_MAX_CHARS) {
153
+ const truncated = content.slice(0, TOOL_OUTPUT_MAX_CHARS) + "\n\n[... output truncated during compaction ...]";
154
+ msg.content = truncated;
155
+ saved += estimate - Math.ceil(truncated.length / 3);
156
+ }
157
+ }
158
+ }
159
+ return saved;
160
+ }
161
+ /**
162
+ * Select how many recent turns to keep verbatim based on token budget.
163
+ * Similar to opencode's select() function.
164
+ */
165
+ function selectTail(messages, config) {
166
+ const maxTokens = config?.maxTokens ?? DEFAULT_MAX_TOKENS;
167
+ const tailTurns = config?.keepRecentTurns ?? DEFAULT_TAIL_TURNS;
168
+ const budget = Math.min(MAX_TAIL_BUDGET, Math.max(MIN_TAIL_BUDGET, Math.floor(maxTokens * TAIL_TOKEN_BUDGET_RATIO)));
169
+ // Find user message boundaries (turns)
170
+ const turnStarts = [];
171
+ for (let i = 0; i < messages.length; i++) {
172
+ if (messages[i].role === "user")
173
+ turnStarts.push(i);
174
+ }
175
+ if (turnStarts.length <= 1) {
176
+ return { headEnd: 0, tailStart: 0 };
177
+ }
178
+ // Try to keep the last N turns within budget
179
+ let tokensUsed = 0;
180
+ let tailStart = messages.length;
181
+ const recentTurns = turnStarts.slice(-tailTurns);
182
+ for (let i = recentTurns.length - 1; i >= 0; i--) {
183
+ const turnStart = recentTurns[i];
184
+ const turnEnd = i < recentTurns.length - 1 ? recentTurns[i + 1] : messages.length;
185
+ let turnTokens = 0;
186
+ for (let j = turnStart; j < turnEnd; j++) {
187
+ turnTokens += estimateMessageTokens(messages[j]);
188
+ }
189
+ if (tokensUsed + turnTokens > budget && tokensUsed > 0)
190
+ break;
191
+ tokensUsed += turnTokens;
192
+ tailStart = turnStart;
193
+ }
194
+ if (tailStart >= messages.length)
195
+ tailStart = messages.length - 2;
196
+ if (tailStart < 0)
197
+ tailStart = 0;
198
+ return { headEnd: tailStart, tailStart };
199
+ }
200
+ // ─── Compaction Agent Prompt ────────────────────────────────────────────────
201
+ const COMPACTION_AGENT_SYSTEM = `You are an anchored context summarization assistant for coding sessions.
202
+
203
+ Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
204
+
205
+ If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
206
+
207
+ Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
208
+
209
+ Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`;
210
+ /** Previous summary stored in the first system message if present */
211
+ function extractPreviousSummary(messages) {
212
+ const first = messages[0];
213
+ if (first?.role === "system" && typeof first.content === "string" && first.content.includes("[Context Summary")) {
214
+ // Extract just the summary content after the header
215
+ const match = first.content.match(/\[Context Summary[^\]]*\]\n\n([\s\S]*)/);
216
+ return match?.[1];
217
+ }
218
+ return undefined;
219
+ }
220
+ function buildCompactionPrompt(previousSummary) {
221
+ const anchor = previousSummary
222
+ ? [
223
+ "Update the anchored summary below using the conversation history above.",
224
+ "Preserve still-true details, remove stale details, and merge in the new facts.",
225
+ "",
226
+ "<previous-summary>",
227
+ previousSummary,
228
+ "</previous-summary>",
229
+ ].join("\n")
230
+ : "Create a new anchored summary from the conversation history above.";
231
+ return [anchor, "", SUMMARY_TEMPLATE].join("\n");
232
+ }
233
+ function messageToText(msg) {
234
+ if (typeof msg.content === "string")
235
+ return msg.content;
236
+ if (Array.isArray(msg.content)) {
237
+ return msg.content
238
+ .filter((p) => "text" in p && typeof p.text === "string")
239
+ .map((p) => p.text)
240
+ .join("\n");
241
+ }
242
+ return "";
243
+ }
244
+ /**
245
+ * Compact messages by summarizing older history with structured template.
246
+ * Supports incremental summaries and token-budget tail preservation.
247
+ */
51
248
  export async function compactMessages(messages, model, config) {
52
- const keepTurns = config?.keepRecentTurns ?? KEEP_RECENT_TURNS;
53
- if (messages.length <= keepTurns * 2) {
54
- // Not enough messages to compact
55
- return { messages, compacted: false };
56
- }
57
- // Split: older messages to summarize, recent messages to keep
58
- const splitIdx = messages.length - keepTurns * 2;
59
- const toSummarize = messages.slice(0, splitIdx);
60
- const toKeep = messages.slice(splitIdx);
61
- // Build conversation text for summarization
249
+ // Step 1: Prune old tool outputs first
250
+ pruneToolOutputs(messages);
251
+ // Step 2: Select tail (recent turns to keep verbatim)
252
+ const { headEnd, tailStart } = selectTail(messages, config);
253
+ if (headEnd <= 1) {
254
+ return { messages, compacted: false, shouldContinue: false };
255
+ }
256
+ const toSummarize = messages.slice(0, headEnd);
257
+ const toKeep = messages.slice(tailStart);
258
+ // Step 3: Check for previous summary (incremental)
259
+ const previousSummary = extractPreviousSummary(toSummarize);
260
+ // Step 4: Build conversation text for summarization
62
261
  const conversationText = toSummarize
63
262
  .map((msg) => {
64
263
  const role = msg.role;
65
- const content = typeof msg.content === "string"
66
- ? msg.content
67
- : Array.isArray(msg.content)
68
- ? msg.content
69
- .filter((p) => "text" in p)
70
- .map((p) => p.text)
71
- .join("\n")
72
- : "";
73
- return `[${role}]: ${content.slice(0, 2000)}`;
264
+ const text = messageToText(msg);
265
+ // Limit each message to avoid overwhelming the summarizer
266
+ return `[${role}]: ${text.slice(0, 3000)}`;
74
267
  })
75
268
  .join("\n\n");
269
+ // Step 5: Generate structured summary using dedicated compaction agent
76
270
  try {
271
+ const prompt = buildCompactionPrompt(previousSummary);
77
272
  const result = await generateText({
78
273
  model,
274
+ system: COMPACTION_AGENT_SYSTEM,
79
275
  messages: [
80
- { role: "system", content: COMPACTION_PROMPT },
81
- { role: "user", content: `Summarize this conversation:\n\n${conversationText}` },
276
+ { role: "user", content: conversationText + "\n\n" + prompt },
82
277
  ],
83
278
  });
84
279
  const summary = result.text;
85
- // Build compacted message list
86
280
  const compactedMessages = [
87
281
  {
88
282
  role: "system",
@@ -90,10 +284,11 @@ export async function compactMessages(messages, model, config) {
90
284
  },
91
285
  ...toKeep,
92
286
  ];
93
- return { messages: compactedMessages, compacted: true };
287
+ const shouldContinue = config?.autoContinue !== false;
288
+ return { messages: compactedMessages, compacted: true, shouldContinue };
94
289
  }
95
290
  catch {
96
- // If summarization fails, just truncate older messages
97
- return { messages: toKeep, compacted: true };
291
+ // Fallback: just keep the tail
292
+ return { messages: toKeep, compacted: true, shouldContinue: false };
98
293
  }
99
294
  }
package/dist/config.js CHANGED
@@ -56,20 +56,47 @@ function ask(rl, question, defaultValue) {
56
56
  });
57
57
  });
58
58
  }
59
+ const MODELS_CACHE_FILE = path.join(CONFIG_DIR, "models-cache.json");
60
+ function loadModelsCache() {
61
+ if (!existsSync(MODELS_CACHE_FILE))
62
+ return [];
63
+ try {
64
+ return JSON.parse(readFileSync(MODELS_CACHE_FILE, "utf-8"));
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ }
70
+ function saveModelsCache(models) {
71
+ mkdirSync(CONFIG_DIR, { recursive: true });
72
+ writeFileSync(MODELS_CACHE_FILE, JSON.stringify(models), "utf-8");
73
+ }
59
74
  export async function fetchModels(baseURL, apiKey) {
60
75
  try {
61
76
  const trimmed = baseURL.replace(/\/$/, "");
62
- const primary = await fetchModelsFromURL(`${trimmed}/models`, apiKey);
63
- if (primary.length > 0)
64
- return primary;
77
+ let models = await fetchModelsFromURL(`${trimmed}/models`, apiKey);
65
78
  // Ollama users often provide host without /v1; auto-retry that variant.
66
- if (!trimmed.endsWith("/v1")) {
67
- return await fetchModelsFromURL(`${trimmed}/v1/models`, apiKey);
79
+ if (models.length === 0 && !trimmed.endsWith("/v1")) {
80
+ models = await fetchModelsFromURL(`${trimmed}/v1/models`, apiKey);
68
81
  }
69
- return [];
82
+ if (models.length > 0) {
83
+ saveModelsCache(models);
84
+ return models;
85
+ }
86
+ // Fallback to cache if live fetch returned nothing
87
+ const cached = loadModelsCache();
88
+ if (cached.length > 0) {
89
+ console.log("\x1b[90m (using cached model list)\x1b[0m");
90
+ }
91
+ return cached;
70
92
  }
71
93
  catch {
72
- return [];
94
+ // Network error — fallback to cache
95
+ const cached = loadModelsCache();
96
+ if (cached.length > 0) {
97
+ console.log("\x1b[90m (using cached model list — network unavailable)\x1b[0m");
98
+ }
99
+ return cached;
73
100
  }
74
101
  }
75
102
  export async function runSetup() {