min-agent 0.1.4 → 0.1.6

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.
@@ -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() {
@@ -0,0 +1,185 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import path from "path";
3
+ import { getConfigDir, loadConfig } from "./config.js";
4
+ /**
5
+ * Auto-detect context window size for the current model.
6
+ *
7
+ * Resolution order:
8
+ * 1. User config: provider.contextWindow (explicit override)
9
+ * 2. Provider-specific API (OpenRouter, vLLM, Ollama)
10
+ * 3. models.dev API lookup
11
+ * 4. Fallback: 128000
12
+ */
13
+ const DEFAULT_CONTEXT_WINDOW = 128000;
14
+ const CACHE_FILE = path.join(getConfigDir(), "context-window-cache.json");
15
+ const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
16
+ function loadCache() {
17
+ if (!existsSync(CACHE_FILE))
18
+ return {};
19
+ try {
20
+ return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ function saveCache(cache) {
27
+ mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
28
+ writeFileSync(CACHE_FILE, JSON.stringify(cache), "utf-8");
29
+ }
30
+ function getCached(modelId) {
31
+ const cache = loadCache();
32
+ const entry = cache[modelId];
33
+ if (!entry)
34
+ return null;
35
+ if (Date.now() - entry.timestamp > CACHE_TTL)
36
+ return null;
37
+ return entry.contextWindow;
38
+ }
39
+ function setCache(modelId, contextWindow) {
40
+ const cache = loadCache();
41
+ cache[modelId] = { contextWindow, timestamp: Date.now() };
42
+ saveCache(cache);
43
+ }
44
+ /** Try OpenRouter: GET /api/v1/models returns context_length per model */
45
+ async function tryOpenRouter(baseURL, apiKey, modelId) {
46
+ if (!baseURL.includes("openrouter"))
47
+ return null;
48
+ try {
49
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
50
+ headers: { Authorization: `Bearer ${apiKey}` },
51
+ signal: AbortSignal.timeout(8000),
52
+ });
53
+ if (!response.ok)
54
+ return null;
55
+ const data = (await response.json());
56
+ const model = data.data?.find((m) => m.id === modelId);
57
+ return model?.context_length ?? null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** Try vLLM: GET /v1/models returns max_model_len */
64
+ async function tryVllm(baseURL, apiKey, modelId) {
65
+ try {
66
+ const url = `${baseURL.replace(/\/$/, "")}/models`;
67
+ const response = await fetch(url, {
68
+ headers: { Authorization: `Bearer ${apiKey}` },
69
+ signal: AbortSignal.timeout(5000),
70
+ });
71
+ if (!response.ok)
72
+ return null;
73
+ const data = (await response.json());
74
+ const model = (data.data ?? []).find((m) => m.id === modelId);
75
+ // vLLM exposes max_model_len on the model object
76
+ return model?.max_model_len ?? model?.max_model_length ?? null;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** Try Ollama: POST /api/show returns model info with context length */
83
+ async function tryOllama(baseURL, modelId) {
84
+ if (!baseURL.includes("localhost") && !baseURL.includes("127.0.0.1") && !baseURL.includes("ollama"))
85
+ return null;
86
+ try {
87
+ // Ollama's /api/show endpoint
88
+ const ollamaBase = baseURL.replace(/\/v1\/?$/, "");
89
+ const response = await fetch(`${ollamaBase}/api/show`, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ name: modelId }),
93
+ signal: AbortSignal.timeout(5000),
94
+ });
95
+ if (!response.ok)
96
+ return null;
97
+ const data = (await response.json());
98
+ // Ollama returns model_info with context length
99
+ const ctxLength = data.model_info?.["general.context_length"] ??
100
+ data.model_info?.context_length ??
101
+ data.parameters?.num_ctx;
102
+ return typeof ctxLength === "number" ? ctxLength : null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /** Try models.dev API */
109
+ async function tryModelsDev(modelId) {
110
+ try {
111
+ const response = await fetch("https://models.dev/api.json", {
112
+ signal: AbortSignal.timeout(10000),
113
+ });
114
+ if (!response.ok)
115
+ return null;
116
+ const providers = (await response.json());
117
+ // Search all providers for the model ID
118
+ for (const provider of Object.values(providers)) {
119
+ if (!provider.models)
120
+ continue;
121
+ const model = provider.models[modelId];
122
+ if (model?.limit?.context)
123
+ return model.limit.context;
124
+ }
125
+ // Try partial match (some providers prefix model IDs)
126
+ for (const provider of Object.values(providers)) {
127
+ if (!provider.models)
128
+ continue;
129
+ for (const [id, model] of Object.entries(provider.models)) {
130
+ if (id === modelId || id.endsWith(`/${modelId}`) || modelId.endsWith(`/${id}`)) {
131
+ if (model?.limit?.context)
132
+ return model.limit.context;
133
+ }
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ /**
143
+ * Get context window size for the current model.
144
+ * Tries multiple sources, caches the result.
145
+ */
146
+ export async function getContextWindow(modelId) {
147
+ const config = loadConfig();
148
+ // 1. Explicit user config
149
+ if (config.provider?.contextWindow)
150
+ return config.provider.contextWindow;
151
+ const id = modelId ?? config.provider?.defaultModel;
152
+ if (!id)
153
+ return DEFAULT_CONTEXT_WINDOW;
154
+ // 2. Check cache
155
+ const cached = getCached(id);
156
+ if (cached !== null)
157
+ return cached;
158
+ const baseURL = config.provider?.baseURL ?? "";
159
+ const apiKey = config.provider?.apiKey ?? "";
160
+ // 3. Provider-specific APIs
161
+ const openRouter = await tryOpenRouter(baseURL, apiKey, id);
162
+ if (openRouter) {
163
+ setCache(id, openRouter);
164
+ return openRouter;
165
+ }
166
+ const ollama = await tryOllama(baseURL, id);
167
+ if (ollama) {
168
+ setCache(id, ollama);
169
+ return ollama;
170
+ }
171
+ const vllm = await tryVllm(baseURL, apiKey, id);
172
+ if (vllm) {
173
+ setCache(id, vllm);
174
+ return vllm;
175
+ }
176
+ // 4. models.dev lookup
177
+ const modelsDev = await tryModelsDev(id);
178
+ if (modelsDev) {
179
+ setCache(id, modelsDev);
180
+ return modelsDev;
181
+ }
182
+ // 5. Fallback
183
+ setCache(id, DEFAULT_CONTEXT_WINDOW);
184
+ return DEFAULT_CONTEXT_WINDOW;
185
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Doom loop detection.
3
+ *
4
+ * Detects when the agent calls the same tool with the same arguments
5
+ * repeatedly (indicating it's stuck in a loop). After THRESHOLD consecutive
6
+ * identical calls, the loop is broken and the agent is informed.
7
+ *
8
+ * Based on opencode's processor.ts doom loop detection.
9
+ */
10
+ const THRESHOLD = 3;
11
+ export class DoomLoopDetector {
12
+ recentCalls = [];
13
+ /** Record a tool call. Returns true if a doom loop is detected. */
14
+ record(toolName, input) {
15
+ const serialized = JSON.stringify(input);
16
+ this.recentCalls.push({ toolName, input: serialized });
17
+ // Only check the last THRESHOLD calls
18
+ if (this.recentCalls.length < THRESHOLD)
19
+ return false;
20
+ const recent = this.recentCalls.slice(-THRESHOLD);
21
+ const allSame = recent.every((call) => call.toolName === recent[0].toolName && call.input === recent[0].input);
22
+ if (allSame) {
23
+ // Reset to prevent repeated warnings
24
+ this.recentCalls = [];
25
+ return true;
26
+ }
27
+ // Keep only last THRESHOLD entries to bound memory
28
+ if (this.recentCalls.length > THRESHOLD * 2) {
29
+ this.recentCalls = this.recentCalls.slice(-THRESHOLD);
30
+ }
31
+ return false;
32
+ }
33
+ reset() {
34
+ this.recentCalls = [];
35
+ }
36
+ }
@@ -11,8 +11,50 @@ import { getRulesFile, loadConfig } from "./config.js";
11
11
  *
12
12
  * Project-level rules (auto-discovered from cwd):
13
13
  * ./AGENTS.md, ./RULES.md, ./.min-agent/AGENTS.md
14
+ *
15
+ * Context-aware (like opencode):
16
+ * When the agent reads a file, nearby AGENTS.md/RULES.md are auto-loaded.
14
17
  */
15
18
  const PROJECT_FILES = ["AGENTS.md", "RULES.md", "CLAUDE.md"];
19
+ /** Tracks which instruction files have already been loaded to avoid duplicates */
20
+ export class InstructionTracker {
21
+ loaded = new Set();
22
+ isLoaded(filepath) {
23
+ return this.loaded.has(path.resolve(filepath));
24
+ }
25
+ markLoaded(filepath) {
26
+ this.loaded.add(path.resolve(filepath));
27
+ }
28
+ /**
29
+ * Context-aware: when a file is read, walk up from its directory
30
+ * looking for AGENTS.md/RULES.md that haven't been loaded yet.
31
+ * Returns new instruction content to inject.
32
+ */
33
+ resolveForFile(filepath) {
34
+ const results = [];
35
+ const root = process.cwd();
36
+ let current = path.dirname(path.resolve(filepath));
37
+ while (current.startsWith(root) && current !== path.dirname(root)) {
38
+ for (const file of PROJECT_FILES) {
39
+ const candidate = path.join(current, file);
40
+ if (existsSync(candidate) && !this.isLoaded(candidate)) {
41
+ try {
42
+ const content = readFileSync(candidate, "utf-8").trim();
43
+ if (content) {
44
+ this.markLoaded(candidate);
45
+ results.push(`Instructions from: ${candidate}\n${content}`);
46
+ }
47
+ }
48
+ catch { }
49
+ }
50
+ }
51
+ if (results.length > 0)
52
+ break;
53
+ current = path.dirname(current);
54
+ }
55
+ return results;
56
+ }
57
+ }
16
58
  function findProjectInstructions() {
17
59
  const results = [];
18
60
  let current = process.cwd();