@sean.holung/minicode 0.2.3 → 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 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,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
+ }
@@ -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
+ }