@sean.holung/minicode 0.2.2 → 0.2.4
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 +20 -12
- package/dist/src/agent/config.js +14 -2
- package/dist/src/cli/args.js +31 -0
- package/dist/src/index.js +21 -2
- package/dist/src/indexer/code-map.js +52 -5
- package/dist/src/indexer/focus-tracker.js +63 -0
- package/dist/src/indexer/project-index.js +2 -2
- package/dist/src/serve/agent-bridge.js +233 -0
- package/dist/src/serve/openai-compat.js +144 -0
- package/dist/src/serve/server.js +251 -0
- package/dist/src/serve/types.js +2 -0
- package/dist/src/serve/websocket.js +28 -0
- package/dist/src/ui/cli-ink.js +22 -2
- package/dist/src/web/app.js +350 -0
- package/dist/src/web/index.html +49 -0
- package/dist/src/web/style.css +422 -0
- package/dist/tests/agent.test.js +62 -0
- package/dist/tests/cli-args.test.js +4 -2
- package/dist/tests/serve.integration.test.js +534 -0
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +30 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +212 -8
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +10 -0
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +2 -0
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts +51 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +210 -2
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/session.test.js +75 -0
- package/node_modules/@minicode/agent-sdk/dist/tests/session.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# minicode
|
|
2
2
|
|
|
3
|
-
A lightweight
|
|
4
|
-
|
|
5
|
-
> minicode gives local models a dependency-aware map of your codebase, so agents read less, reason better, and ship changes faster.
|
|
3
|
+
A lightweight coding agent optimized for **local models** — CLI-first with a built-in web UI. Provides AST-based intelligent context for smaller models running on consumer hardware.
|
|
6
4
|
|
|
7
5
|
Read operations dominate token usage in typical agent sessions; minicode addresses this by optimizing for **specific languages** — indexing your project at startup with language plugins (TypeScript/JavaScript built-in) and injecting a compact **code map** (signatures only) into the system prompt, plus symbol-level tools (`read_symbol`, `find_references`, `get_dependencies`) so the model reads only what it needs instead of entire files. This keeps prompts lean enough for smaller models in the 20B range, with faster inference and better attention over the relevant code.
|
|
8
6
|
|
|
@@ -23,7 +21,7 @@ OPENAI_BASE_URL=http://localhost:1234/v1
|
|
|
23
21
|
OPENAI_API_KEY=
|
|
24
22
|
MAX_STEPS=50
|
|
25
23
|
MAX_TOKENS=4096
|
|
26
|
-
MAX_CONTEXT_TOKENS=
|
|
24
|
+
MAX_CONTEXT_TOKENS=24000
|
|
27
25
|
WORKSPACE_ROOT=.
|
|
28
26
|
COMMAND_TIMEOUT_MS=30000
|
|
29
27
|
MAX_FILE_SIZE_BYTES=1000000
|
|
@@ -48,6 +46,15 @@ or you can also pass it an intial prompt from the start:
|
|
|
48
46
|
minicode "Add error handling to src/api.ts"
|
|
49
47
|
```
|
|
50
48
|
|
|
49
|
+
Start the web UI (chat, session management, project graph data):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
minicode serve # http://localhost:4567
|
|
53
|
+
minicode serve --port 8080 # custom port
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The serve mode also exposes an **OpenAI-compatible API** at `/v1/chat/completions`, so you can point any client that speaks the OpenAI protocol (OpenWebUI, TypingMind, ChatGPT-Next-Web, Lobe Chat, etc.) at `http://localhost:4567/v1` and use minicode as a backend.
|
|
57
|
+
|
|
51
58
|
Run a single task and exit (useful for scripts/CI/orchestration):
|
|
52
59
|
|
|
53
60
|
```bash
|
|
@@ -81,13 +88,9 @@ npm run install:global
|
|
|
81
88
|
- Agent loop with model tool-use support
|
|
82
89
|
- In-memory session history with trimming
|
|
83
90
|
- Safety guardrails for file paths and shell commands
|
|
84
|
-
- Built-in tools:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
- `edit_file`
|
|
88
|
-
- `search` (ripgrep, grep fallback)
|
|
89
|
-
- `list_files`
|
|
90
|
-
- `run_command`
|
|
91
|
+
- Built-in tools: `read_file`, `write_file`, `edit_file`, `search`, `list_files`, `run_command`
|
|
92
|
+
- **Web UI** — `minicode serve` starts an HTTP + WebSocket server with a bundled chat client, real-time streaming, session management, and project graph data endpoints
|
|
93
|
+
- **OpenAI-compatible API** — any client that speaks the OpenAI protocol can use minicode as a backend at `/v1/chat/completions`
|
|
91
94
|
- **Context optimization:** Code map in system prompt, `read_symbol`, `find_references`, `get_dependencies`
|
|
92
95
|
- **Plugin system:** Extensible language support (TypeScript built-in)
|
|
93
96
|
|
|
@@ -194,6 +197,8 @@ Nothing is written inside your workspace; config and cache live under `~/.minico
|
|
|
194
197
|
| `CONFIRM_DESTRUCTIVE` | No | `true` | If `true`, blocks destructive shell commands unless confirmed |
|
|
195
198
|
| `KEEP_RECENT_MESSAGES` | No | `12` | Minimum number of latest messages kept during trimming |
|
|
196
199
|
| `LOOP_DETECTION_WINDOW` | No | `6` | Window for repeated tool-call loop detection |
|
|
200
|
+
| `COMPACTION_THRESHOLD` | No | `0.8` | Context fullness ratio (0–1) at which auto-compaction triggers |
|
|
201
|
+
| `COMPACTION_MODEL` | No | none | Model for LLM-based compaction summaries. When set, `/compact` and auto-compaction use this model instead of mechanical truncation. Use a small, fast model (e.g. your local model). |
|
|
197
202
|
|
|
198
203
|
|
|
199
204
|
### `agent.config.json`
|
|
@@ -215,7 +220,8 @@ Create `agent.config.json` in `~/.minicode/` for user-level defaults, or in the
|
|
|
215
220
|
"keepRecentMessages": 12,
|
|
216
221
|
"loopDetectionWindow": 6,
|
|
217
222
|
"openAiBaseUrl": "http://localhost:1234/v1",
|
|
218
|
-
"openAiApiKey": ""
|
|
223
|
+
"openAiApiKey": "",
|
|
224
|
+
"compactionModel": ""
|
|
219
225
|
}
|
|
220
226
|
```
|
|
221
227
|
|
|
@@ -235,6 +241,8 @@ Field mapping:
|
|
|
235
241
|
- `loopDetectionWindow` ↔ `LOOP_DETECTION_WINDOW`
|
|
236
242
|
- `openAiBaseUrl` ↔ `OPENAI_BASE_URL`
|
|
237
243
|
- `openAiApiKey` ↔ `OPENAI_API_KEY`
|
|
244
|
+
- `compactionThreshold` ↔ `COMPACTION_THRESHOLD`
|
|
245
|
+
- `compactionModel` ↔ `COMPACTION_MODEL`
|
|
238
246
|
|
|
239
247
|
## Usage
|
|
240
248
|
|
package/dist/src/agent/config.js
CHANGED
|
@@ -27,6 +27,11 @@ export function formatConfigForDisplay(config) {
|
|
|
27
27
|
"commandDenylist: " + config.commandDenylist.length + " patterns",
|
|
28
28
|
"openAiBaseUrl: " + config.openAiBaseUrl,
|
|
29
29
|
"openAiApiKey: " + (config.openAiApiKey ? "***" : "(unset)"),
|
|
30
|
+
"enableFileReadDedup: " + (config.enableFileReadDedup ?? false),
|
|
31
|
+
"enableAdaptiveKeepRecent: " + (config.enableAdaptiveKeepRecent ?? false),
|
|
32
|
+
"enableToolOutputTruncation: " + (config.enableToolOutputTruncation ?? false),
|
|
33
|
+
"compactionThreshold: " + (config.compactionThreshold ?? "(disabled)"),
|
|
34
|
+
"compactionModel: " + (config.compactionModel ?? "(disabled — using mechanical compaction)"),
|
|
30
35
|
];
|
|
31
36
|
return lines.join("\n");
|
|
32
37
|
}
|
|
@@ -136,7 +141,7 @@ export async function loadAgentConfig(cwd = process.cwd()) {
|
|
|
136
141
|
"zai-org/glm-4.7-flash",
|
|
137
142
|
maxSteps: parseNumber(process.env.MAX_STEPS, fileConfig.maxSteps ?? 50),
|
|
138
143
|
maxTokens: parseNumber(process.env.MAX_TOKENS, fileConfig.maxTokens ?? 4096),
|
|
139
|
-
maxContextTokens: parseNumber(process.env.MAX_CONTEXT_TOKENS, fileConfig.maxContextTokens ??
|
|
144
|
+
maxContextTokens: parseNumber(process.env.MAX_CONTEXT_TOKENS, fileConfig.maxContextTokens ?? 40_000),
|
|
140
145
|
workspaceRoot,
|
|
141
146
|
commandTimeoutMs: parseNumber(process.env.COMMAND_TIMEOUT_MS, fileConfig.commandTimeout ?? 30_000),
|
|
142
147
|
maxFileSizeBytes: parseNumber(process.env.MAX_FILE_SIZE_BYTES, fileConfig.maxFileSizeBytes ?? 1_000_000),
|
|
@@ -144,8 +149,15 @@ export async function loadAgentConfig(cwd = process.cwd()) {
|
|
|
144
149
|
confirmDestructive: parseBoolean(process.env.CONFIRM_DESTRUCTIVE, fileConfig.confirmDestructive ?? true),
|
|
145
150
|
keepRecentMessages: parseNumber(process.env.KEEP_RECENT_MESSAGES, fileConfig.keepRecentMessages ?? 12),
|
|
146
151
|
loopDetectionWindow: parseNumber(process.env.LOOP_DETECTION_WINDOW, fileConfig.loopDetectionWindow ?? 6),
|
|
147
|
-
maxToolOutputChars: parseNumber(process.env.MAX_TOOL_OUTPUT_CHARS, fileConfig.maxToolOutputChars ??
|
|
152
|
+
maxToolOutputChars: parseNumber(process.env.MAX_TOOL_OUTPUT_CHARS, fileConfig.maxToolOutputChars ?? 8_000),
|
|
148
153
|
openAiBaseUrl: rawBaseUrl,
|
|
149
154
|
...(openAiApiKey !== undefined ? { openAiApiKey } : {}),
|
|
155
|
+
enableFileReadDedup: parseBoolean(process.env.ENABLE_FILE_READ_DEDUP, fileConfig.enableFileReadDedup ?? true),
|
|
156
|
+
enableAdaptiveKeepRecent: parseBoolean(process.env.ENABLE_ADAPTIVE_KEEP_RECENT, fileConfig.enableAdaptiveKeepRecent ?? true),
|
|
157
|
+
enableToolOutputTruncation: parseBoolean(process.env.ENABLE_TOOL_OUTPUT_TRUNCATION, fileConfig.enableToolOutputTruncation ?? true),
|
|
158
|
+
compactionThreshold: parseNumber(process.env.COMPACTION_THRESHOLD, fileConfig.compactionThreshold ?? 0.8),
|
|
159
|
+
...(process.env.COMPACTION_MODEL ?? fileConfig.compactionModel
|
|
160
|
+
? { compactionModel: process.env.COMPACTION_MODEL ?? fileConfig.compactionModel }
|
|
161
|
+
: {}),
|
|
150
162
|
};
|
|
151
163
|
}
|
package/dist/src/cli/args.js
CHANGED
|
@@ -10,12 +10,38 @@ export function parseCliArgs(argv) {
|
|
|
10
10
|
let oneshot = false;
|
|
11
11
|
let json = false;
|
|
12
12
|
let outFile;
|
|
13
|
+
let serve = false;
|
|
14
|
+
let port = 4567;
|
|
13
15
|
const taskParts = [];
|
|
14
16
|
for (let i = 0; i < args.length; i += 1) {
|
|
15
17
|
const arg = args[i];
|
|
16
18
|
if (arg === undefined) {
|
|
17
19
|
continue;
|
|
18
20
|
}
|
|
21
|
+
if (arg === "serve") {
|
|
22
|
+
serve = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (arg === "--port") {
|
|
26
|
+
const value = args[i + 1];
|
|
27
|
+
if (!value || value.startsWith("-")) {
|
|
28
|
+
throw new CliUsageError("--port requires a number. Example: --port 8080");
|
|
29
|
+
}
|
|
30
|
+
port = Number(value);
|
|
31
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
32
|
+
throw new CliUsageError("--port must be a positive number. Example: --port 8080");
|
|
33
|
+
}
|
|
34
|
+
i += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (arg.startsWith("--port=")) {
|
|
38
|
+
const value = arg.slice("--port=".length).trim();
|
|
39
|
+
port = Number(value);
|
|
40
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
41
|
+
throw new CliUsageError("--port must be a positive number. Example: --port=8080");
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
19
45
|
if (arg === "--verbose" || arg === "-v") {
|
|
20
46
|
verbose = true;
|
|
21
47
|
continue;
|
|
@@ -52,6 +78,8 @@ export function parseCliArgs(argv) {
|
|
|
52
78
|
oneshot,
|
|
53
79
|
json,
|
|
54
80
|
...(outFile ? { outFile } : {}),
|
|
81
|
+
serve,
|
|
82
|
+
port,
|
|
55
83
|
task: taskParts.join(" ").trim(),
|
|
56
84
|
};
|
|
57
85
|
}
|
|
@@ -62,4 +90,7 @@ export function validateCliArgs(args) {
|
|
|
62
90
|
if (!args.oneshot && (args.json || args.outFile)) {
|
|
63
91
|
throw new CliUsageError("--json and --out are only supported with --oneshot.");
|
|
64
92
|
}
|
|
93
|
+
if (args.serve && (args.oneshot || args.json || args.outFile)) {
|
|
94
|
+
throw new CliUsageError("serve mode is mutually exclusive with --oneshot, --json, and --out.");
|
|
95
|
+
}
|
|
65
96
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -44,7 +44,7 @@ async function createAgentRuntime(verbose, onProgress) {
|
|
|
44
44
|
verbose,
|
|
45
45
|
...(session ? { session } : {}),
|
|
46
46
|
...(projectIndex !== undefined
|
|
47
|
-
? { getCodeMap: () => projectIndex.getCodeMap() }
|
|
47
|
+
? { getCodeMap: (focusSymbols) => projectIndex.getCodeMap(undefined, focusSymbols) }
|
|
48
48
|
: {}),
|
|
49
49
|
...(onProgress ? { onProgress } : {}),
|
|
50
50
|
});
|
|
@@ -98,7 +98,7 @@ async function runInteractive(verbose, initialTask) {
|
|
|
98
98
|
break;
|
|
99
99
|
}
|
|
100
100
|
if (trimmed === "/help") {
|
|
101
|
-
console.log('Commands: "/help", "/config", "/save [label]", "/load [label]", "/sessions", "/exit"');
|
|
101
|
+
console.log('Commands: "/help", "/config", "/compact", "/save [label]", "/load [label]", "/sessions", "/exit"');
|
|
102
102
|
console.log("Start with --verbose or -v to log prompts, responses, and tool calls.");
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
@@ -106,6 +106,20 @@ async function runInteractive(verbose, initialTask) {
|
|
|
106
106
|
console.log("\n" + formatConfigForDisplay(config) + "\n");
|
|
107
107
|
continue;
|
|
108
108
|
}
|
|
109
|
+
if (trimmed === "/compact") {
|
|
110
|
+
const session = agent.getSession();
|
|
111
|
+
const tokensBefore = session.getTokenEstimate();
|
|
112
|
+
const result = session.compact(config.keepRecentMessages);
|
|
113
|
+
if (result) {
|
|
114
|
+
console.log(`Compacted: ${result.removedMessages} messages summarized, ` +
|
|
115
|
+
`${result.previousTokens} → ${result.newTokens} tokens ` +
|
|
116
|
+
`(saved ${result.previousTokens - result.newTokens} tokens)`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.log(`Nothing to compact (${tokensBefore} tokens, ${session.getMessages().length} messages).`);
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
109
123
|
if (trimmed === "/save" || trimmed.startsWith("/save ")) {
|
|
110
124
|
const label = trimmed.slice("/save".length).trim() || undefined;
|
|
111
125
|
try {
|
|
@@ -196,6 +210,11 @@ async function runOneshot(params) {
|
|
|
196
210
|
async function main() {
|
|
197
211
|
const cliArgs = parseCliArgs(process.argv);
|
|
198
212
|
validateCliArgs(cliArgs);
|
|
213
|
+
if (cliArgs.serve) {
|
|
214
|
+
const { runServe } = await import("./serve/server.js");
|
|
215
|
+
await runServe(cliArgs.verbose, cliArgs.port);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
199
218
|
if (cliArgs.oneshot) {
|
|
200
219
|
await runOneshot(cliArgs);
|
|
201
220
|
process.exitCode = EXIT_CODE_SUCCESS;
|
|
@@ -12,12 +12,42 @@ function formatSymbol(symbol, indent, isMethod) {
|
|
|
12
12
|
function isEntryPointFile(filePath) {
|
|
13
13
|
return filePath === "src/index.ts" || filePath.endsWith("/index.ts");
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Build the set of symbols related to focus symbols via dependency edges.
|
|
17
|
+
* Expands 1 hop outbound (what focus symbols depend on) and 1 hop inbound
|
|
18
|
+
* (what depends on focus symbols).
|
|
19
|
+
*/
|
|
20
|
+
function expandFocusSet(focusSymbols, edges) {
|
|
21
|
+
const expanded = new Set(focusSymbols);
|
|
22
|
+
for (const edge of edges) {
|
|
23
|
+
// Outbound: focus symbol depends on something
|
|
24
|
+
if (focusSymbols.has(edge.from)) {
|
|
25
|
+
expanded.add(edge.to);
|
|
26
|
+
}
|
|
27
|
+
// Inbound: something depends on focus symbol
|
|
28
|
+
if (focusSymbols.has(edge.to)) {
|
|
29
|
+
expanded.add(edge.from);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return expanded;
|
|
33
|
+
}
|
|
34
|
+
function createSymbolRanker(edges, focusSymbols) {
|
|
16
35
|
const refCount = new Map();
|
|
17
36
|
for (const e of edges) {
|
|
18
37
|
refCount.set(e.to, (refCount.get(e.to) ?? 0) + 1);
|
|
19
38
|
}
|
|
39
|
+
// Expand focus set to include 1-hop neighbors in the dependency graph
|
|
40
|
+
const boosted = focusSymbols?.size
|
|
41
|
+
? expandFocusSet(focusSymbols, edges)
|
|
42
|
+
: undefined;
|
|
20
43
|
return (a, b) => {
|
|
44
|
+
// Focus-boosted symbols always sort first
|
|
45
|
+
if (boosted) {
|
|
46
|
+
const aFocused = boosted.has(a.qualifiedName);
|
|
47
|
+
const bFocused = boosted.has(b.qualifiedName);
|
|
48
|
+
if (aFocused !== bFocused)
|
|
49
|
+
return aFocused ? -1 : 1;
|
|
50
|
+
}
|
|
21
51
|
if (a.exported !== b.exported)
|
|
22
52
|
return a.exported ? -1 : 1;
|
|
23
53
|
const refA = refCount.get(a.qualifiedName) ?? 0;
|
|
@@ -31,20 +61,37 @@ function createSymbolRanker(edges) {
|
|
|
31
61
|
}
|
|
32
62
|
/**
|
|
33
63
|
* Generate a compact code map from symbols grouped by file.
|
|
34
|
-
* Ranks symbols by: exported > high reference count > entry points.
|
|
64
|
+
* Ranks symbols by: focus-boosted > exported > high reference count > entry points.
|
|
35
65
|
* When over budget, truncates with a footer.
|
|
66
|
+
*
|
|
67
|
+
* @param focusSymbols Optional set of symbol qualified names to boost to the top.
|
|
68
|
+
* These symbols (and their 1-hop dependency neighbors) will be ranked above all
|
|
69
|
+
* others, ensuring they survive truncation within the token budget.
|
|
36
70
|
*/
|
|
37
|
-
export function generateCodeMap(symbolsByFile, tokenBudget = DEFAULT_TOKEN_BUDGET, dependencyEdges) {
|
|
71
|
+
export function generateCodeMap(symbolsByFile, tokenBudget = DEFAULT_TOKEN_BUDGET, dependencyEdges, focusSymbols) {
|
|
38
72
|
const totalCount = [...symbolsByFile.values()].reduce((sum, syms) => sum + syms.length, 0);
|
|
39
73
|
const lines = ["# Project Code Map", ""];
|
|
40
74
|
const rank = dependencyEdges
|
|
41
|
-
? createSymbolRanker(dependencyEdges)
|
|
75
|
+
? createSymbolRanker(dependencyEdges, focusSymbols)
|
|
42
76
|
: (a, b) => (a.exported === b.exported ? 0 : a.exported ? -1 : 1);
|
|
43
77
|
let totalTokens = estimateTokens(lines.join("\n"));
|
|
44
78
|
let truncatedSymbols = 0;
|
|
45
79
|
let shownCount = 0;
|
|
46
80
|
const filesWithTruncation = new Set();
|
|
47
|
-
|
|
81
|
+
// When we have focus symbols, sort files so that files containing
|
|
82
|
+
// focused symbols come first in the code map.
|
|
83
|
+
const boosted = focusSymbols?.size && dependencyEdges
|
|
84
|
+
? expandFocusSet(focusSymbols, dependencyEdges)
|
|
85
|
+
: undefined;
|
|
86
|
+
const sortedFiles = [...symbolsByFile.keys()].sort((a, b) => {
|
|
87
|
+
if (boosted) {
|
|
88
|
+
const aHasFocus = symbolsByFile.get(a)?.some((s) => boosted.has(s.qualifiedName)) ?? false;
|
|
89
|
+
const bHasFocus = symbolsByFile.get(b)?.some((s) => boosted.has(s.qualifiedName)) ?? false;
|
|
90
|
+
if (aHasFocus !== bHasFocus)
|
|
91
|
+
return aHasFocus ? -1 : 1;
|
|
92
|
+
}
|
|
93
|
+
return a.localeCompare(b);
|
|
94
|
+
});
|
|
48
95
|
for (const filePath of sortedFiles) {
|
|
49
96
|
const symbols = symbolsByFile.get(filePath);
|
|
50
97
|
if (!symbols?.length)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks which symbols the user/agent is actively working with.
|
|
3
|
+
* Used to dynamically re-rank the code map so that relevant symbols
|
|
4
|
+
* survive truncation within the fixed token budget.
|
|
5
|
+
*
|
|
6
|
+
* Focus is derived from:
|
|
7
|
+
* - Symbol names in tool calls (read_symbol, find_references, get_dependencies)
|
|
8
|
+
* - Symbol names mentioned in user messages (fuzzy match against index)
|
|
9
|
+
*/
|
|
10
|
+
const MAX_FOCUS_SYMBOLS = 30;
|
|
11
|
+
export class FocusTracker {
|
|
12
|
+
focused = new Map();
|
|
13
|
+
generation = 0;
|
|
14
|
+
/**
|
|
15
|
+
* Record a symbol as being actively focused on.
|
|
16
|
+
* More recent additions have higher priority.
|
|
17
|
+
*/
|
|
18
|
+
addSymbol(qualifiedName) {
|
|
19
|
+
this.generation += 1;
|
|
20
|
+
this.focused.set(qualifiedName, this.generation);
|
|
21
|
+
// Evict oldest entries if we exceed the limit
|
|
22
|
+
if (this.focused.size > MAX_FOCUS_SYMBOLS) {
|
|
23
|
+
let oldestKey = null;
|
|
24
|
+
let oldestGen = Infinity;
|
|
25
|
+
for (const [key, gen] of this.focused) {
|
|
26
|
+
if (gen < oldestGen) {
|
|
27
|
+
oldestGen = gen;
|
|
28
|
+
oldestKey = key;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (oldestKey) {
|
|
32
|
+
this.focused.delete(oldestKey);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Record multiple symbols at once (e.g. from dependency expansion).
|
|
38
|
+
*/
|
|
39
|
+
addSymbols(qualifiedNames) {
|
|
40
|
+
for (const name of qualifiedNames) {
|
|
41
|
+
this.addSymbol(name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Get the current set of focused symbol qualified names.
|
|
46
|
+
*/
|
|
47
|
+
getFocusedSymbols() {
|
|
48
|
+
return new Set(this.focused.keys());
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Check if a symbol is currently focused.
|
|
52
|
+
*/
|
|
53
|
+
hasFocus(qualifiedName) {
|
|
54
|
+
return this.focused.has(qualifiedName);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Clear all focus tracking.
|
|
58
|
+
*/
|
|
59
|
+
clear() {
|
|
60
|
+
this.focused.clear();
|
|
61
|
+
this.generation = 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -66,8 +66,8 @@ export function createProjectIndex(symbols, files, dependencyEdges, plugins, pro
|
|
|
66
66
|
}
|
|
67
67
|
return [...result.values()];
|
|
68
68
|
},
|
|
69
|
-
getCodeMap(tokenBudget) {
|
|
70
|
-
return generateCodeMap(files, tokenBudget, dependencyEdges);
|
|
69
|
+
getCodeMap(tokenBudget, focusSymbols) {
|
|
70
|
+
return generateCodeMap(files, tokenBudget, dependencyEdges, focusSymbols);
|
|
71
71
|
},
|
|
72
72
|
reindexFile(filePath, content) {
|
|
73
73
|
const relPath = path.isAbsolute(filePath)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { CodingAgent, createModelClient } from "@minicode/agent-sdk";
|
|
2
|
+
import { loadAgentConfig } from "../agent/config.js";
|
|
3
|
+
import { computeFileHashes, getWorkspaceCacheDir, loadIndex, saveIndex, } from "../indexer/cache.js";
|
|
4
|
+
import { buildProjectIndex } from "../indexer/project-index.js";
|
|
5
|
+
import { createToolRegistry } from "../tools/registry.js";
|
|
6
|
+
import { listSessions, loadSession, loadSessionByLabel, saveSession, } from "../session/session-store.js";
|
|
7
|
+
export class AgentBridge {
|
|
8
|
+
agent;
|
|
9
|
+
config;
|
|
10
|
+
projectIndex;
|
|
11
|
+
buildAgent;
|
|
12
|
+
busy = false;
|
|
13
|
+
abortController = null;
|
|
14
|
+
broadcast;
|
|
15
|
+
verbose;
|
|
16
|
+
listeners = new Set();
|
|
17
|
+
pinnedSymbols = new Set();
|
|
18
|
+
constructor(broadcast, verbose) {
|
|
19
|
+
this.broadcast = broadcast;
|
|
20
|
+
this.verbose = verbose;
|
|
21
|
+
}
|
|
22
|
+
addListener(fn) {
|
|
23
|
+
this.listeners.add(fn);
|
|
24
|
+
}
|
|
25
|
+
removeListener(fn) {
|
|
26
|
+
this.listeners.delete(fn);
|
|
27
|
+
}
|
|
28
|
+
emit(msg) {
|
|
29
|
+
this.broadcast(msg);
|
|
30
|
+
for (const fn of this.listeners) {
|
|
31
|
+
fn(msg);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async init() {
|
|
35
|
+
const config = await loadAgentConfig();
|
|
36
|
+
const modelClient = createModelClient(config);
|
|
37
|
+
let projectIndex;
|
|
38
|
+
try {
|
|
39
|
+
const cacheDir = getWorkspaceCacheDir(config.workspaceRoot);
|
|
40
|
+
const fileHashes = await computeFileHashes(config.workspaceRoot);
|
|
41
|
+
const cached = await loadIndex(cacheDir, fileHashes);
|
|
42
|
+
if (cached) {
|
|
43
|
+
projectIndex = cached;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
projectIndex = await buildProjectIndex(config.workspaceRoot);
|
|
47
|
+
await saveIndex(projectIndex, cacheDir, fileHashes);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
projectIndex = undefined;
|
|
52
|
+
}
|
|
53
|
+
const toolRegistry = createToolRegistry(config, projectIndex);
|
|
54
|
+
this.config = config;
|
|
55
|
+
this.projectIndex = projectIndex;
|
|
56
|
+
this.buildAgent = (session) => {
|
|
57
|
+
return new CodingAgent({
|
|
58
|
+
config,
|
|
59
|
+
modelClient,
|
|
60
|
+
toolRegistry,
|
|
61
|
+
verbose: this.verbose,
|
|
62
|
+
...(session ? { session } : {}),
|
|
63
|
+
...(projectIndex !== undefined
|
|
64
|
+
? { getCodeMap: (focusSymbols) => projectIndex.getCodeMap(undefined, focusSymbols) }
|
|
65
|
+
: {}),
|
|
66
|
+
onUiUpdate: (event) => {
|
|
67
|
+
this.emit(event);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
this.agent = this.buildAgent();
|
|
72
|
+
}
|
|
73
|
+
isBusy() {
|
|
74
|
+
return this.busy;
|
|
75
|
+
}
|
|
76
|
+
getConfig() {
|
|
77
|
+
return this.config;
|
|
78
|
+
}
|
|
79
|
+
getAgent() {
|
|
80
|
+
return this.agent;
|
|
81
|
+
}
|
|
82
|
+
async runTurn(message) {
|
|
83
|
+
if (this.busy) {
|
|
84
|
+
throw new Error("busy");
|
|
85
|
+
}
|
|
86
|
+
this.busy = true;
|
|
87
|
+
this.abortController = new AbortController();
|
|
88
|
+
try {
|
|
89
|
+
this.emit({ type: "turn_start" });
|
|
90
|
+
const result = await this.agent.runTurn(message, {
|
|
91
|
+
signal: this.abortController.signal,
|
|
92
|
+
});
|
|
93
|
+
this.emit({
|
|
94
|
+
type: "turn_end",
|
|
95
|
+
text: result.text,
|
|
96
|
+
usage: result.usage,
|
|
97
|
+
});
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const msg = error instanceof Error && error.name === "AbortError"
|
|
102
|
+
? "Cancelled"
|
|
103
|
+
: error instanceof Error
|
|
104
|
+
? error.message
|
|
105
|
+
: "Unknown error";
|
|
106
|
+
this.emit({ type: "error", message: msg });
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
this.busy = false;
|
|
111
|
+
this.abortController = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
cancel() {
|
|
115
|
+
if (this.abortController) {
|
|
116
|
+
this.abortController.abort();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Session operations
|
|
120
|
+
async saveSess(label) {
|
|
121
|
+
return saveSession(this.agent.getSession(), label);
|
|
122
|
+
}
|
|
123
|
+
async loadSess(label) {
|
|
124
|
+
const result = (await loadSessionByLabel(label)) ?? (await loadSession(label));
|
|
125
|
+
if (!result)
|
|
126
|
+
return null;
|
|
127
|
+
this.agent = this.buildAgent(result.session);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
async listSess() {
|
|
131
|
+
return listSessions();
|
|
132
|
+
}
|
|
133
|
+
// ── Project index queries ──
|
|
134
|
+
hasIndex() {
|
|
135
|
+
return this.projectIndex !== undefined;
|
|
136
|
+
}
|
|
137
|
+
getSymbols() {
|
|
138
|
+
if (!this.projectIndex)
|
|
139
|
+
return [];
|
|
140
|
+
const symbols = [];
|
|
141
|
+
for (const sym of this.projectIndex.symbols.values()) {
|
|
142
|
+
symbols.push({
|
|
143
|
+
name: sym.name,
|
|
144
|
+
qualifiedName: sym.qualifiedName,
|
|
145
|
+
kind: sym.kind,
|
|
146
|
+
filePath: sym.filePath,
|
|
147
|
+
startLine: sym.startLine,
|
|
148
|
+
endLine: sym.endLine,
|
|
149
|
+
signature: sym.signature,
|
|
150
|
+
exported: sym.exported,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return symbols;
|
|
154
|
+
}
|
|
155
|
+
getSymbol(name) {
|
|
156
|
+
if (!this.projectIndex)
|
|
157
|
+
return undefined;
|
|
158
|
+
return this.projectIndex.getSymbol(name);
|
|
159
|
+
}
|
|
160
|
+
getDependencies(symbolName, depth) {
|
|
161
|
+
if (!this.projectIndex)
|
|
162
|
+
return undefined;
|
|
163
|
+
const cone = this.projectIndex.getDependencyCone(symbolName, depth);
|
|
164
|
+
if (cone.length === 0)
|
|
165
|
+
return undefined;
|
|
166
|
+
return cone.map((sym) => ({
|
|
167
|
+
name: sym.name,
|
|
168
|
+
qualifiedName: sym.qualifiedName,
|
|
169
|
+
kind: sym.kind,
|
|
170
|
+
filePath: sym.filePath,
|
|
171
|
+
signature: sym.signature,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
getReferences(symbolName) {
|
|
175
|
+
if (!this.projectIndex)
|
|
176
|
+
return undefined;
|
|
177
|
+
const sym = this.projectIndex.getSymbol(symbolName);
|
|
178
|
+
if (!sym)
|
|
179
|
+
return undefined;
|
|
180
|
+
// Find all edges pointing TO this symbol
|
|
181
|
+
const refs = this.projectIndex.dependencyEdges
|
|
182
|
+
.filter((e) => e.to === sym.qualifiedName || e.to === sym.name)
|
|
183
|
+
.map((e) => ({ from: e.from, kind: e.kind }));
|
|
184
|
+
return refs;
|
|
185
|
+
}
|
|
186
|
+
getCodeMap(tokenBudget) {
|
|
187
|
+
if (!this.projectIndex)
|
|
188
|
+
return undefined;
|
|
189
|
+
const focus = this.pinnedSymbols.size > 0 ? this.pinnedSymbols : undefined;
|
|
190
|
+
return this.projectIndex.getCodeMap(tokenBudget, focus);
|
|
191
|
+
}
|
|
192
|
+
getGraph() {
|
|
193
|
+
if (!this.projectIndex)
|
|
194
|
+
return undefined;
|
|
195
|
+
const nodes = [];
|
|
196
|
+
for (const sym of this.projectIndex.symbols.values()) {
|
|
197
|
+
nodes.push({
|
|
198
|
+
id: sym.qualifiedName,
|
|
199
|
+
name: sym.name,
|
|
200
|
+
kind: sym.kind,
|
|
201
|
+
filePath: sym.filePath,
|
|
202
|
+
exported: sym.exported,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const edges = this.projectIndex.dependencyEdges.map((e) => ({
|
|
206
|
+
from: e.from,
|
|
207
|
+
to: e.to,
|
|
208
|
+
kind: e.kind,
|
|
209
|
+
}));
|
|
210
|
+
return { nodes, edges };
|
|
211
|
+
}
|
|
212
|
+
getPinnedSymbols() {
|
|
213
|
+
return [...this.pinnedSymbols];
|
|
214
|
+
}
|
|
215
|
+
pinSymbol(name) {
|
|
216
|
+
if (!this.projectIndex)
|
|
217
|
+
return false;
|
|
218
|
+
const sym = this.projectIndex.getSymbol(name);
|
|
219
|
+
if (!sym)
|
|
220
|
+
return false;
|
|
221
|
+
this.pinnedSymbols.add(sym.qualifiedName);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
unpinSymbol(name) {
|
|
225
|
+
if (!this.projectIndex)
|
|
226
|
+
return false;
|
|
227
|
+
const sym = this.projectIndex.getSymbol(name);
|
|
228
|
+
if (!sym)
|
|
229
|
+
return false;
|
|
230
|
+
this.pinnedSymbols.delete(sym.qualifiedName);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
}
|