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.
- package/README.md +269 -164
- package/bin/min-agent.js +6 -0
- package/dist/agent.js +383 -16
- package/dist/cli.js +20 -1
- 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 +28 -16
- package/dist/output.js +15 -2
- package/dist/serve.js +351 -3
- package/dist/sessions.js +13 -4
- package/dist/structured-output.js +29 -0
- package/dist/title-gen.js +48 -0
- package/dist/tools/bash.js +81 -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 +298 -111
- package/package.json +3 -2
|
@@ -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();
|
package/dist/mcp.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2
2
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
4
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
5
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
5
6
|
import { tool, jsonSchema } from "ai";
|
|
6
7
|
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs";
|
|
7
8
|
import path from "path";
|
|
@@ -52,7 +53,7 @@ async function openStdioMcpServer(name, config) {
|
|
|
52
53
|
env: { ...process.env, ...(config.environment ?? {}) },
|
|
53
54
|
stderr: "pipe",
|
|
54
55
|
});
|
|
55
|
-
const client = new Client({ name: "agent
|
|
56
|
+
const client = new Client({ name: "min-agent", version: "0.1.0" });
|
|
56
57
|
await client.connect(transport);
|
|
57
58
|
const { tools } = await client.listTools();
|
|
58
59
|
return { client, transport, tools };
|
|
@@ -75,36 +76,47 @@ async function openRemoteMcpServer(name, config) {
|
|
|
75
76
|
const requestInit = buildRemoteRequestInit(config);
|
|
76
77
|
const mode = config.remoteTransport ?? "auto";
|
|
77
78
|
const connectStreamable = async () => {
|
|
78
|
-
const client = new Client({ name: "agent
|
|
79
|
+
const client = new Client({ name: "min-agent", version: "0.1.0" });
|
|
79
80
|
const transport = new StreamableHTTPClientTransport(baseUrl, requestInit ? { requestInit } : undefined);
|
|
80
81
|
await client.connect(transport);
|
|
81
82
|
const { tools } = await client.listTools();
|
|
82
83
|
return { client, transport, tools };
|
|
83
84
|
};
|
|
84
85
|
const connectSse = async () => {
|
|
85
|
-
const client = new Client({ name: "agent
|
|
86
|
+
const client = new Client({ name: "min-agent", version: "0.1.0" });
|
|
86
87
|
const transport = new SSEClientTransport(baseUrl, requestInit ? { requestInit } : undefined);
|
|
87
88
|
await client.connect(transport);
|
|
88
89
|
const { tools } = await client.listTools();
|
|
89
90
|
return { client, transport, tools };
|
|
90
91
|
};
|
|
91
|
-
if (mode === "streamable-http") {
|
|
92
|
-
return await connectStreamable();
|
|
93
|
-
}
|
|
94
|
-
if (mode === "sse") {
|
|
95
|
-
return await connectSse();
|
|
96
|
-
}
|
|
97
|
-
// auto: prefer streamable-http, fall back to sse (older servers)
|
|
98
92
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
try {
|
|
93
|
+
if (mode === "streamable-http")
|
|
94
|
+
return await connectStreamable();
|
|
95
|
+
if (mode === "sse")
|
|
103
96
|
return await connectSse();
|
|
97
|
+
// auto: prefer streamable-http, fall back to sse
|
|
98
|
+
try {
|
|
99
|
+
return await connectStreamable();
|
|
100
|
+
}
|
|
101
|
+
catch (firstErr) {
|
|
102
|
+
try {
|
|
103
|
+
return await connectSse();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw firstErr;
|
|
107
|
+
}
|
|
104
108
|
}
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// Handle OAuth/Unauthorized errors
|
|
112
|
+
if (err instanceof UnauthorizedError || err?.message?.includes("Unauthorized") || err?.message?.includes("401")) {
|
|
113
|
+
if (config.oauth === false) {
|
|
114
|
+
throw new Error(`MCP "${name}" requires authentication but OAuth is disabled in config`);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`MCP "${name}" requires authentication. Add a "token" field to the server config, or configure OAuth:\n` +
|
|
117
|
+
` min-agent mcp add ${name} --url ${rawUrl} --token YOUR_TOKEN`);
|
|
107
118
|
}
|
|
119
|
+
throw new Error(`MCP "${name}" remote connection failed: ${err?.message ?? String(err)}`);
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
122
|
async function openMcpServer(name, config) {
|
package/dist/output.js
CHANGED
|
@@ -32,11 +32,24 @@ export function printToolResult(name, result) {
|
|
|
32
32
|
const suffix = truncated ? ` (${lines.length - maxLines} more lines)` : "";
|
|
33
33
|
console.log(`${COLORS.green} ✓${COLORS.reset} ${COLORS.dim}${display}${suffix}${COLORS.reset}\n`);
|
|
34
34
|
}
|
|
35
|
-
export function printDone(steps, usage) {
|
|
35
|
+
export function printDone(steps, usage, contextWindow) {
|
|
36
36
|
const input = usage.inputTokens ?? 0;
|
|
37
37
|
const output = usage.outputTokens ?? 0;
|
|
38
38
|
const total = input + output;
|
|
39
|
-
|
|
39
|
+
let contextInfo = "";
|
|
40
|
+
if (input > 0 && contextWindow && contextWindow > 0) {
|
|
41
|
+
const pct = Math.round((input / contextWindow) * 100);
|
|
42
|
+
const bar = renderBar(pct);
|
|
43
|
+
contextInfo = ` | Context: ${bar} ${pct}%`;
|
|
44
|
+
}
|
|
45
|
+
console.log(`${COLORS.dim}Done in ${steps} step(s) | Tokens: ${input} in / ${output} out / ${total} total${contextInfo}${COLORS.reset}`);
|
|
46
|
+
}
|
|
47
|
+
function renderBar(pct) {
|
|
48
|
+
const width = 10;
|
|
49
|
+
const filled = Math.round((pct / 100) * width);
|
|
50
|
+
const empty = width - filled;
|
|
51
|
+
const color = pct >= 80 ? "\x1b[31m" : pct >= 50 ? "\x1b[33m" : "\x1b[32m";
|
|
52
|
+
return `${color}${"█".repeat(filled)}${"░".repeat(empty)}\x1b[0m\x1b[90m`;
|
|
40
53
|
}
|
|
41
54
|
function formatArgs(args) {
|
|
42
55
|
if (!args || typeof args !== "object")
|