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.
- package/README.md +95 -194
- package/bin/min-agent.js +0 -0
- package/dist/agent.js +431 -28
- package/dist/cli.js +177 -27
- package/dist/clipboard.js +106 -0
- package/dist/code-mode.js +166 -0
- package/dist/compaction.js +243 -48
- package/dist/config.js +34 -7
- package/dist/context-window.js +185 -0
- package/dist/doom-loop.js +36 -0
- package/dist/instructions.js +42 -0
- package/dist/mcp.js +82 -28
- package/dist/output.js +15 -2
- package/dist/paste-handler.js +41 -0
- package/dist/serve.js +351 -3
- package/dist/sessions.js +13 -4
- package/dist/skills.js +15 -7
- package/dist/structured-output.js +29 -0
- package/dist/title-gen.js +48 -0
- package/dist/tools/bash.js +85 -74
- package/dist/tools/code_search.js +91 -0
- package/dist/tools/explore.js +104 -0
- package/dist/tools/index.js +10 -1
- package/dist/tools/question.js +53 -0
- package/dist/tools/read.js +14 -3
- package/dist/tools/task.js +98 -0
- package/dist/tools/todo.js +88 -0
- package/docs/API.md +219 -123
- package/package.json +1 -1
package/dist/compaction.js
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
26
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const toSummarize = messages.slice(0,
|
|
60
|
-
const toKeep = messages.slice(
|
|
61
|
-
//
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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: "
|
|
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
|
-
|
|
287
|
+
const shouldContinue = config?.autoContinue !== false;
|
|
288
|
+
return { messages: compactedMessages, compacted: true, shouldContinue };
|
|
94
289
|
}
|
|
95
290
|
catch {
|
|
96
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
if (models.length === 0 && !trimmed.endsWith("/v1")) {
|
|
80
|
+
models = await fetchModelsFromURL(`${trimmed}/v1/models`, apiKey);
|
|
68
81
|
}
|
|
69
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/instructions.js
CHANGED
|
@@ -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();
|