@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.
Files changed (39) hide show
  1. package/README.md +20 -12
  2. package/dist/src/agent/config.js +14 -2
  3. package/dist/src/cli/args.js +31 -0
  4. package/dist/src/index.js +21 -2
  5. package/dist/src/indexer/code-map.js +52 -5
  6. package/dist/src/indexer/focus-tracker.js +63 -0
  7. package/dist/src/indexer/project-index.js +2 -2
  8. package/dist/src/serve/agent-bridge.js +233 -0
  9. package/dist/src/serve/openai-compat.js +144 -0
  10. package/dist/src/serve/server.js +251 -0
  11. package/dist/src/serve/types.js +2 -0
  12. package/dist/src/serve/websocket.js +28 -0
  13. package/dist/src/ui/cli-ink.js +22 -2
  14. package/dist/src/web/app.js +350 -0
  15. package/dist/src/web/index.html +49 -0
  16. package/dist/src/web/style.css +422 -0
  17. package/dist/tests/agent.test.js +62 -0
  18. package/dist/tests/cli-args.test.js +4 -2
  19. package/dist/tests/serve.integration.test.js +534 -0
  20. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +30 -1
  21. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  22. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +212 -8
  23. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  24. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +10 -0
  25. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  26. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +1 -1
  27. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  28. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  29. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  30. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +2 -0
  31. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  32. package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts +51 -1
  33. package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
  34. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +210 -2
  35. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
  36. package/node_modules/@minicode/agent-sdk/dist/tests/session.test.js +75 -0
  37. package/node_modules/@minicode/agent-sdk/dist/tests/session.test.js.map +1 -1
  38. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +5 -3
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # minicode
2
2
 
3
- A lightweight CLI coding agent optimized for **local models** by providing AST-based intelligent context for smaller models running on consumer hardware.
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=60000
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
- - `read_file`
86
- - `write_file`
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
 
@@ -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 ?? 120_000),
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 ?? 15_000),
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
  }
@@ -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
- function createSymbolRanker(edges) {
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
- const sortedFiles = [...symbolsByFile.keys()].sort();
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
+ }