@sean.holung/minicode 0.2.3 → 0.3.0

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 CHANGED
@@ -1,6 +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.
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.
4
4
 
5
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.
6
6
 
@@ -46,6 +46,15 @@ or you can also pass it an intial prompt from the start:
46
46
  minicode "Add error handling to src/api.ts"
47
47
  ```
48
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
+
49
58
  Run a single task and exit (useful for scripts/CI/orchestration):
50
59
 
51
60
  ```bash
@@ -79,13 +88,9 @@ npm run install:global
79
88
  - Agent loop with model tool-use support
80
89
  - In-memory session history with trimming
81
90
  - Safety guardrails for file paths and shell commands
82
- - Built-in tools:
83
- - `read_file`
84
- - `write_file`
85
- - `edit_file`
86
- - `search` (ripgrep, grep fallback)
87
- - `list_files`
88
- - `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`
89
94
  - **Context optimization:** Code map in system prompt, `read_symbol`, `find_references`, `get_dependencies`
90
95
  - **Plugin system:** Extensible language support (TypeScript built-in)
91
96
 
@@ -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
@@ -210,6 +210,11 @@ async function runOneshot(params) {
210
210
  async function main() {
211
211
  const cliArgs = parseCliArgs(process.argv);
212
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
+ }
213
218
  if (cliArgs.oneshot) {
214
219
  await runOneshot(cliArgs);
215
220
  process.exitCode = EXIT_CODE_SUCCESS;
@@ -0,0 +1,356 @@
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
+ annotations = new Map();
19
+ constructor(broadcast, verbose) {
20
+ this.broadcast = broadcast;
21
+ this.verbose = verbose;
22
+ }
23
+ addListener(fn) {
24
+ this.listeners.add(fn);
25
+ }
26
+ removeListener(fn) {
27
+ this.listeners.delete(fn);
28
+ }
29
+ emit(msg) {
30
+ this.broadcast(msg);
31
+ for (const fn of this.listeners) {
32
+ fn(msg);
33
+ }
34
+ }
35
+ async init() {
36
+ const config = await loadAgentConfig();
37
+ const modelClient = createModelClient(config);
38
+ let projectIndex;
39
+ try {
40
+ const cacheDir = getWorkspaceCacheDir(config.workspaceRoot);
41
+ const fileHashes = await computeFileHashes(config.workspaceRoot);
42
+ const cached = await loadIndex(cacheDir, fileHashes);
43
+ if (cached) {
44
+ projectIndex = cached;
45
+ }
46
+ else {
47
+ projectIndex = await buildProjectIndex(config.workspaceRoot);
48
+ await saveIndex(projectIndex, cacheDir, fileHashes);
49
+ }
50
+ }
51
+ catch {
52
+ projectIndex = undefined;
53
+ }
54
+ const toolRegistry = createToolRegistry(config, projectIndex);
55
+ // Wrap tool registry execute to inject annotations into tool results
56
+ const originalExecute = toolRegistry.execute.bind(toolRegistry);
57
+ toolRegistry.execute = async (name, input) => {
58
+ const result = await originalExecute(name, input);
59
+ return this.appendAnnotationsToResult(name, input, result);
60
+ };
61
+ this.config = config;
62
+ this.projectIndex = projectIndex;
63
+ this.buildAgent = (session, onUiUpdate) => {
64
+ return new CodingAgent({
65
+ config,
66
+ modelClient,
67
+ toolRegistry,
68
+ verbose: this.verbose,
69
+ ...(session ? { session } : {}),
70
+ ...(projectIndex !== undefined
71
+ ? { getCodeMap: (focusSymbols) => projectIndex.getCodeMap(undefined, focusSymbols) }
72
+ : {}),
73
+ onUiUpdate: onUiUpdate ?? ((event) => {
74
+ this.emit(event);
75
+ }),
76
+ getSystemPromptSuffix: () => this.buildAnnotationSuffix(),
77
+ });
78
+ };
79
+ this.agent = this.buildAgent();
80
+ }
81
+ isBusy() {
82
+ return this.busy;
83
+ }
84
+ getConfig() {
85
+ return this.config;
86
+ }
87
+ getAgent() {
88
+ return this.agent;
89
+ }
90
+ async runTurn(message) {
91
+ if (this.busy) {
92
+ throw new Error("busy");
93
+ }
94
+ this.busy = true;
95
+ this.abortController = new AbortController();
96
+ try {
97
+ this.emit({ type: "turn_start" });
98
+ const result = await this.agent.runTurn(message, {
99
+ signal: this.abortController.signal,
100
+ });
101
+ this.emit({
102
+ type: "turn_end",
103
+ text: result.text,
104
+ usage: result.usage,
105
+ });
106
+ return result;
107
+ }
108
+ catch (error) {
109
+ const msg = error instanceof Error && error.name === "AbortError"
110
+ ? "Cancelled"
111
+ : error instanceof Error
112
+ ? error.message
113
+ : "Unknown error";
114
+ this.emit({ type: "error", message: msg });
115
+ throw error;
116
+ }
117
+ finally {
118
+ this.busy = false;
119
+ this.abortController = null;
120
+ }
121
+ }
122
+ cancel() {
123
+ if (this.abortController) {
124
+ this.abortController.abort();
125
+ }
126
+ }
127
+ // Session operations
128
+ async saveSess(label) {
129
+ const annotationsObj = this.annotations.size > 0
130
+ ? Object.fromEntries(this.annotations)
131
+ : undefined;
132
+ return saveSession(this.agent.getSession(), label, annotationsObj);
133
+ }
134
+ async loadSess(label) {
135
+ const result = (await loadSessionByLabel(label)) ?? (await loadSession(label));
136
+ if (!result)
137
+ return null;
138
+ this.agent = this.buildAgent(result.session);
139
+ // Restore annotations from saved session
140
+ this.annotations.clear();
141
+ if (result.annotations) {
142
+ for (const [name, notes] of Object.entries(result.annotations)) {
143
+ this.annotations.set(name, notes);
144
+ }
145
+ }
146
+ return result;
147
+ }
148
+ async listSess() {
149
+ return listSessions();
150
+ }
151
+ // ── Project index queries ──
152
+ hasIndex() {
153
+ return this.projectIndex !== undefined;
154
+ }
155
+ getSymbols() {
156
+ if (!this.projectIndex)
157
+ return [];
158
+ const symbols = [];
159
+ for (const sym of this.projectIndex.symbols.values()) {
160
+ symbols.push({
161
+ name: sym.name,
162
+ qualifiedName: sym.qualifiedName,
163
+ kind: sym.kind,
164
+ filePath: sym.filePath,
165
+ startLine: sym.startLine,
166
+ endLine: sym.endLine,
167
+ signature: sym.signature,
168
+ exported: sym.exported,
169
+ });
170
+ }
171
+ return symbols;
172
+ }
173
+ getSymbol(name) {
174
+ if (!this.projectIndex)
175
+ return undefined;
176
+ return this.projectIndex.getSymbol(name);
177
+ }
178
+ getDependencies(symbolName, depth) {
179
+ if (!this.projectIndex)
180
+ return undefined;
181
+ const cone = this.projectIndex.getDependencyCone(symbolName, depth);
182
+ if (cone.length === 0)
183
+ return undefined;
184
+ return cone.map((sym) => ({
185
+ name: sym.name,
186
+ qualifiedName: sym.qualifiedName,
187
+ kind: sym.kind,
188
+ filePath: sym.filePath,
189
+ signature: sym.signature,
190
+ }));
191
+ }
192
+ getReferences(symbolName) {
193
+ if (!this.projectIndex)
194
+ return undefined;
195
+ const sym = this.projectIndex.getSymbol(symbolName);
196
+ if (!sym)
197
+ return undefined;
198
+ // Find all edges pointing TO this symbol
199
+ const refs = this.projectIndex.dependencyEdges
200
+ .filter((e) => e.to === sym.qualifiedName || e.to === sym.name)
201
+ .map((e) => ({ from: e.from, kind: e.kind }));
202
+ return refs;
203
+ }
204
+ getCodeMap(tokenBudget) {
205
+ if (!this.projectIndex)
206
+ return undefined;
207
+ const focus = this.pinnedSymbols.size > 0 ? this.pinnedSymbols : undefined;
208
+ return this.projectIndex.getCodeMap(tokenBudget, focus);
209
+ }
210
+ getGraph() {
211
+ if (!this.projectIndex)
212
+ return undefined;
213
+ const nodes = [];
214
+ for (const sym of this.projectIndex.symbols.values()) {
215
+ nodes.push({
216
+ id: sym.qualifiedName,
217
+ name: sym.name,
218
+ kind: sym.kind,
219
+ filePath: sym.filePath,
220
+ exported: sym.exported,
221
+ });
222
+ }
223
+ const edges = this.projectIndex.dependencyEdges.map((e) => ({
224
+ from: e.from,
225
+ to: e.to,
226
+ kind: e.kind,
227
+ }));
228
+ return { nodes, edges };
229
+ }
230
+ getPinnedSymbols() {
231
+ return [...this.pinnedSymbols];
232
+ }
233
+ pinSymbol(name) {
234
+ if (!this.projectIndex)
235
+ return false;
236
+ const sym = this.projectIndex.getSymbol(name);
237
+ if (!sym)
238
+ return false;
239
+ this.pinnedSymbols.add(sym.qualifiedName);
240
+ return true;
241
+ }
242
+ unpinSymbol(name) {
243
+ if (!this.projectIndex)
244
+ return false;
245
+ const sym = this.projectIndex.getSymbol(name);
246
+ if (!sym)
247
+ return false;
248
+ this.pinnedSymbols.delete(sym.qualifiedName);
249
+ return true;
250
+ }
251
+ // ── Annotations ──
252
+ getAnnotations() {
253
+ this.evictStaleAnnotations();
254
+ return Object.fromEntries(this.annotations);
255
+ }
256
+ getAnnotationsForSymbol(name) {
257
+ return this.annotations.get(name) ?? [];
258
+ }
259
+ addAnnotation(name, text) {
260
+ if (!this.projectIndex)
261
+ return false;
262
+ const sym = this.projectIndex.getSymbol(name);
263
+ if (!sym)
264
+ return false;
265
+ const trimmed = text.slice(0, 500).trim();
266
+ if (trimmed.length === 0)
267
+ return false;
268
+ const key = sym.qualifiedName;
269
+ const existing = this.annotations.get(key) ?? [];
270
+ existing.push(trimmed);
271
+ this.annotations.set(key, existing);
272
+ return true;
273
+ }
274
+ removeAnnotation(name, index) {
275
+ const notes = this.annotations.get(name);
276
+ if (!notes || index < 0 || index >= notes.length)
277
+ return false;
278
+ notes.splice(index, 1);
279
+ if (notes.length === 0) {
280
+ this.annotations.delete(name);
281
+ }
282
+ return true;
283
+ }
284
+ clearAnnotations(name) {
285
+ this.annotations.delete(name);
286
+ }
287
+ evictStaleAnnotations() {
288
+ if (!this.projectIndex)
289
+ return;
290
+ for (const name of [...this.annotations.keys()]) {
291
+ if (!this.projectIndex.getSymbol(name)) {
292
+ this.annotations.delete(name);
293
+ }
294
+ }
295
+ }
296
+ buildAnnotationSuffix() {
297
+ this.evictStaleAnnotations();
298
+ if (this.annotations.size === 0)
299
+ return undefined;
300
+ return `[Annotated symbols: ${[...this.annotations.keys()].join(", ")}]`;
301
+ }
302
+ appendAnnotationsToResult(toolName, input, result) {
303
+ if (this.annotations.size === 0)
304
+ return result;
305
+ const inp = input;
306
+ if (toolName === "read_symbol" || toolName === "find_references" || toolName === "get_dependencies") {
307
+ const symName = (inp.name ?? inp.symbol ?? inp.query);
308
+ if (!symName)
309
+ return result;
310
+ // Try direct match, then resolve via index
311
+ let notes = this.annotations.get(symName);
312
+ if (!notes && this.projectIndex) {
313
+ const sym = this.projectIndex.getSymbol(symName);
314
+ if (sym)
315
+ notes = this.annotations.get(sym.qualifiedName);
316
+ }
317
+ if (notes && notes.length > 0) {
318
+ return result + `\n[User annotation: ${notes.join("; ")}]`;
319
+ }
320
+ }
321
+ if (toolName === "read_file") {
322
+ const filePath = inp.path;
323
+ if (!filePath)
324
+ return result;
325
+ const fileAnnotations = [];
326
+ for (const [name, notes] of this.annotations) {
327
+ if (!this.projectIndex)
328
+ continue;
329
+ const sym = this.projectIndex.getSymbol(name);
330
+ if (sym && (sym.filePath === filePath || filePath.endsWith(sym.filePath))) {
331
+ fileAnnotations.push(`- ${sym.name}: ${notes.join("; ")}`);
332
+ }
333
+ }
334
+ if (fileAnnotations.length > 0) {
335
+ return result + `\n[User annotations for symbols in this file:]\n${fileAnnotations.join("\n")}`;
336
+ }
337
+ }
338
+ return result;
339
+ }
340
+ // ── Explain ──
341
+ async explainSymbol(name, onEvent, signal) {
342
+ if (!this.projectIndex)
343
+ throw new Error("No project index");
344
+ const sym = this.projectIndex.getSymbol(name);
345
+ if (!sym)
346
+ throw new Error(`Symbol "${name}" not found`);
347
+ const explainAgent = this.buildAgent(undefined, onEvent);
348
+ const prompt = `Explain "${sym.name}" (${sym.kind} in ${sym.filePath}).
349
+ Use read_symbol, get_dependencies, find_references to gather context.
350
+ Explain what it does, how it works, what depends on it, and key design decisions.
351
+ Be concise but thorough.`;
352
+ const opts = signal ? { signal } : undefined;
353
+ const result = await explainAgent.runTurn(prompt, opts);
354
+ return result.text;
355
+ }
356
+ }
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from "node:crypto";
2
+ function sendJson(res, status, body) {
3
+ res.writeHead(status, { "Content-Type": "application/json" });
4
+ res.end(JSON.stringify(body));
5
+ }
6
+ function readBody(req) {
7
+ return new Promise((resolve, reject) => {
8
+ const chunks = [];
9
+ req.on("data", (chunk) => chunks.push(chunk));
10
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
11
+ req.on("error", reject);
12
+ });
13
+ }
14
+ export function handleModels(_req, res) {
15
+ sendJson(res, 200, {
16
+ object: "list",
17
+ data: [
18
+ {
19
+ id: "minicode-agent",
20
+ object: "model",
21
+ created: Math.floor(Date.now() / 1000),
22
+ owned_by: "minicode",
23
+ },
24
+ ],
25
+ });
26
+ }
27
+ export async function handleChatCompletions(req, res, bridge) {
28
+ let body;
29
+ try {
30
+ const raw = await readBody(req);
31
+ body = JSON.parse(raw);
32
+ }
33
+ catch {
34
+ sendJson(res, 400, { error: { message: "Invalid JSON body", type: "invalid_request_error" } });
35
+ return;
36
+ }
37
+ const messages = body.messages;
38
+ if (!messages || messages.length === 0) {
39
+ sendJson(res, 400, { error: { message: "messages array is required", type: "invalid_request_error" } });
40
+ return;
41
+ }
42
+ // Extract last user message
43
+ let userMessage;
44
+ for (let i = messages.length - 1; i >= 0; i--) {
45
+ if (messages[i].role === "user") {
46
+ userMessage = messages[i].content;
47
+ break;
48
+ }
49
+ }
50
+ if (!userMessage) {
51
+ sendJson(res, 400, { error: { message: "No user message found", type: "invalid_request_error" } });
52
+ return;
53
+ }
54
+ if (bridge.isBusy()) {
55
+ sendJson(res, 429, { error: { message: "Agent is busy with another request. Try again later.", type: "rate_limit_error" } });
56
+ return;
57
+ }
58
+ const completionId = `chatcmpl-${randomUUID()}`;
59
+ const created = Math.floor(Date.now() / 1000);
60
+ if (body.stream) {
61
+ await handleStreaming(res, bridge, userMessage, completionId, created);
62
+ }
63
+ else {
64
+ await handleNonStreaming(res, bridge, userMessage, completionId, created);
65
+ }
66
+ }
67
+ async function handleNonStreaming(res, bridge, message, completionId, created) {
68
+ try {
69
+ const result = await bridge.runTurn(message);
70
+ sendJson(res, 200, {
71
+ id: completionId,
72
+ object: "chat.completion",
73
+ created,
74
+ model: "minicode-agent",
75
+ choices: [
76
+ {
77
+ index: 0,
78
+ message: { role: "assistant", content: result.text },
79
+ finish_reason: "stop",
80
+ },
81
+ ],
82
+ usage: result.usage
83
+ ? {
84
+ prompt_tokens: result.usage.inputTokens,
85
+ completion_tokens: result.usage.outputTokens,
86
+ total_tokens: result.usage.inputTokens + result.usage.outputTokens,
87
+ }
88
+ : undefined,
89
+ });
90
+ }
91
+ catch (error) {
92
+ const msg = error instanceof Error ? error.message : "Unknown error";
93
+ sendJson(res, 500, { error: { message: msg, type: "server_error" } });
94
+ }
95
+ }
96
+ async function handleStreaming(res, bridge, message, completionId, created) {
97
+ res.writeHead(200, {
98
+ "Content-Type": "text/event-stream",
99
+ "Cache-Control": "no-cache",
100
+ Connection: "keep-alive",
101
+ });
102
+ function sendSSE(data) {
103
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
104
+ }
105
+ // Send initial role chunk
106
+ sendSSE({
107
+ id: completionId,
108
+ object: "chat.completion.chunk",
109
+ created,
110
+ model: "minicode-agent",
111
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
112
+ });
113
+ const listener = (msg) => {
114
+ if (msg.type === "streaming_chunk" && msg.content) {
115
+ sendSSE({
116
+ id: completionId,
117
+ object: "chat.completion.chunk",
118
+ created,
119
+ model: "minicode-agent",
120
+ choices: [{ index: 0, delta: { content: msg.content }, finish_reason: null }],
121
+ });
122
+ }
123
+ };
124
+ bridge.addListener(listener);
125
+ try {
126
+ await bridge.runTurn(message);
127
+ sendSSE({
128
+ id: completionId,
129
+ object: "chat.completion.chunk",
130
+ created,
131
+ model: "minicode-agent",
132
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
133
+ });
134
+ res.write("data: [DONE]\n\n");
135
+ }
136
+ catch (error) {
137
+ const msg = error instanceof Error ? error.message : "Unknown error";
138
+ sendSSE({ error: { message: msg, type: "server_error" } });
139
+ }
140
+ finally {
141
+ bridge.removeListener(listener);
142
+ res.end();
143
+ }
144
+ }