miii-agent 0.1.7 → 0.1.8
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 +23 -3
- package/dist/cli.js +159 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# miii
|
|
2
2
|
|
|
3
|
+
> small · simple · smart · strategic · semantic
|
|
4
|
+
>
|
|
3
5
|
> Your code never leaves your machine. No API keys. No cloud. No bullshit.
|
|
4
6
|
|
|
5
7
|
**miii** is a local-first AI coding agent that lives in your terminal. Powered by [Ollama](https://ollama.com), it reads your code, writes features, runs tests, and fixes bugs — entirely on your hardware, at native speed.
|
|
@@ -10,6 +12,18 @@
|
|
|
10
12
|
|
|
11
13
|
---
|
|
12
14
|
|
|
15
|
+
## The name
|
|
16
|
+
|
|
17
|
+
**miii** stands for five principles it's built around:
|
|
18
|
+
|
|
19
|
+
- **small** — tight codebase, no bloat. You can read the whole thing.
|
|
20
|
+
- **simple** — no API keys, no accounts, no config ceremony. Just run it.
|
|
21
|
+
- **smart** — decomposes problems and verifies its own work like an engineer.
|
|
22
|
+
- **strategic** — plans before it acts; tools are gated, paths are confined.
|
|
23
|
+
- **semantic** — works from the meaning of your code, not blind text matching.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
13
27
|
## Demo
|
|
14
28
|
|
|
15
29
|

|
|
@@ -115,7 +129,7 @@ miii ships with a built-in tool suite the agent can invoke autonomously:
|
|
|
115
129
|
| `grep` | Regex search across files |
|
|
116
130
|
| `run_bash` | Execute shell commands |
|
|
117
131
|
|
|
118
|
-
Every sensitive operation is gated by a permission system — you approve what the agent can touch.
|
|
132
|
+
Every sensitive operation is gated by a permission system — you approve what the agent can touch, and "always" approvals persist to `~/.miii/permissions.json` so you're never asked twice. File tools are confined to your working directory; `../` traversal and absolute paths outside it are rejected.
|
|
119
133
|
|
|
120
134
|
---
|
|
121
135
|
|
|
@@ -137,8 +151,9 @@ graph TD
|
|
|
137
151
|
|
|
138
152
|
subgraph Agent ["Agent Layer"]
|
|
139
153
|
AgentLoop -->|"chat request"| Adapter["Ollama Adapter\n(agent/adapter.ts)"]
|
|
140
|
-
AgentLoop -->|"
|
|
141
|
-
AgentLoop -->|"permission check"| Policy["Permission Policy\n(permissions/policy.ts)"]
|
|
154
|
+
AgentLoop -->|"1. validate input"| Validate["Input Validator\n(tools/validate.ts)"]
|
|
155
|
+
AgentLoop -->|"2. permission check"| Policy["Permission Policy\n(permissions/policy.ts)"]
|
|
156
|
+
AgentLoop -->|"3. tool call"| ToolRegistry["Tool Registry\n(tools/registry.ts)"]
|
|
142
157
|
AgentLoop -->|"events"| EventBus["Event Bus\n(hooks/bus.ts)"]
|
|
143
158
|
end
|
|
144
159
|
|
|
@@ -149,6 +164,9 @@ graph TD
|
|
|
149
164
|
ToolRegistry --> Glob["glob"]
|
|
150
165
|
ToolRegistry --> Grep["grep"]
|
|
151
166
|
ToolRegistry --> RunBash["run_bash"]
|
|
167
|
+
ReadFile -.-> Confine["Path Confinement\n(tools/paths.ts)"]
|
|
168
|
+
WriteFile -.-> Confine
|
|
169
|
+
EditFile -.-> Confine
|
|
152
170
|
end
|
|
153
171
|
|
|
154
172
|
Adapter -->|"HTTP streaming"| Ollama["Ollama\n(local LLM server)"]
|
|
@@ -159,9 +177,11 @@ graph TD
|
|
|
159
177
|
|
|
160
178
|
subgraph Storage ["Local Storage"]
|
|
161
179
|
Config["~/.miii/config.json\n(model, host, effort)"]
|
|
180
|
+
Rules["~/.miii/permissions.json\n(saved allow rules)"]
|
|
162
181
|
end
|
|
163
182
|
|
|
164
183
|
App -.->|"reads"| Config
|
|
184
|
+
Policy -.->|"reads / persists 'always'"| Rules
|
|
165
185
|
```
|
|
166
186
|
|
|
167
187
|
---
|
package/dist/cli.js
CHANGED
|
@@ -7,8 +7,8 @@ import { createElement } from "react";
|
|
|
7
7
|
// src/ui/App.tsx
|
|
8
8
|
import { useState as useState4, useEffect as useEffect3 } from "react";
|
|
9
9
|
import { Box as Box10, Text as Text10, useApp } from "ink";
|
|
10
|
-
import { homedir as
|
|
11
|
-
import { sep } from "path";
|
|
10
|
+
import { homedir as homedir4 } from "os";
|
|
11
|
+
import { sep as sep2 } from "path";
|
|
12
12
|
|
|
13
13
|
// src/ollama/client.ts
|
|
14
14
|
import { Ollama } from "ollama";
|
|
@@ -549,8 +549,8 @@ function searchFiles(cwd, query) {
|
|
|
549
549
|
scored.sort((a, b) => a[0] - b[0] || a[1].length - b[1].length);
|
|
550
550
|
return scored.slice(0, MAX_RESULTS).map(([, f]) => f);
|
|
551
551
|
}
|
|
552
|
-
function FilePicker({ matches, cursor }) {
|
|
553
|
-
if (
|
|
552
|
+
function FilePicker({ matches: matches2, cursor }) {
|
|
553
|
+
if (matches2.length === 0) return null;
|
|
554
554
|
return /* @__PURE__ */ jsxs7(
|
|
555
555
|
Box7,
|
|
556
556
|
{
|
|
@@ -561,7 +561,7 @@ function FilePicker({ matches, cursor }) {
|
|
|
561
561
|
marginBottom: 0,
|
|
562
562
|
paddingX: 1,
|
|
563
563
|
children: [
|
|
564
|
-
|
|
564
|
+
matches2.map((f, i) => {
|
|
565
565
|
const active = i === cursor;
|
|
566
566
|
return /* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsxs7(Text7, { bold: active, color: active ? "blue" : void 0, dimColor: !active, children: [
|
|
567
567
|
active ? "\u276F " : " ",
|
|
@@ -846,6 +846,7 @@ function PermissionPrompt({ req, cursor }) {
|
|
|
846
846
|
const label = TOOL_LABEL[req.toolName] ?? req.toolName;
|
|
847
847
|
const options = [
|
|
848
848
|
{ label: "Yes", key: "yes" },
|
|
849
|
+
{ label: "Yes, don't ask again for this", key: "always" },
|
|
849
850
|
{ label: "No", key: "no" }
|
|
850
851
|
];
|
|
851
852
|
const summary = summarizeInput(req.input);
|
|
@@ -914,6 +915,23 @@ import { useState as useState3, useRef } from "react";
|
|
|
914
915
|
|
|
915
916
|
// src/tools/edit_file.ts
|
|
916
917
|
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
918
|
+
|
|
919
|
+
// src/tools/paths.ts
|
|
920
|
+
import { resolve, relative as relative2, isAbsolute, sep } from "path";
|
|
921
|
+
function confinePath(p) {
|
|
922
|
+
if (typeof p !== "string" || p.length === 0) {
|
|
923
|
+
throw new Error("Path is required.");
|
|
924
|
+
}
|
|
925
|
+
const root = process.cwd();
|
|
926
|
+
const abs = resolve(root, p);
|
|
927
|
+
const rel = relative2(root, abs);
|
|
928
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
|
|
929
|
+
throw new Error(`Path "${p}" is outside the working directory (${root}). Access denied.`);
|
|
930
|
+
}
|
|
931
|
+
return abs;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/tools/edit_file.ts
|
|
917
935
|
var edit_file = {
|
|
918
936
|
name: "edit_file",
|
|
919
937
|
description: "Replace an exact string in a file. old_str must be unique.",
|
|
@@ -927,16 +945,21 @@ var edit_file = {
|
|
|
927
945
|
required: ["path", "old_str", "new_str"]
|
|
928
946
|
},
|
|
929
947
|
handler: ({ path, old_str, new_str }) => {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
948
|
+
try {
|
|
949
|
+
const abs = confinePath(path);
|
|
950
|
+
const src = readFileSync3(abs, "utf-8");
|
|
951
|
+
const first = src.indexOf(old_str);
|
|
952
|
+
if (first === -1) {
|
|
953
|
+
return { content: `old_str not found in ${path}`, is_error: true };
|
|
954
|
+
}
|
|
955
|
+
if (src.indexOf(old_str, first + 1) !== -1) {
|
|
956
|
+
return { content: `old_str not unique in ${path}`, is_error: true };
|
|
957
|
+
}
|
|
958
|
+
writeFileSync3(abs, src.slice(0, first) + new_str + src.slice(first + old_str.length), "utf-8");
|
|
959
|
+
return { content: `Edited ${path}` };
|
|
960
|
+
} catch (err) {
|
|
961
|
+
return { content: err instanceof Error ? err.message : String(err), is_error: true };
|
|
937
962
|
}
|
|
938
|
-
writeFileSync3(path, src.slice(0, first) + new_str + src.slice(first + old_str.length), "utf-8");
|
|
939
|
-
return { content: `Edited ${path}` };
|
|
940
963
|
}
|
|
941
964
|
};
|
|
942
965
|
|
|
@@ -955,7 +978,7 @@ var read_file = {
|
|
|
955
978
|
handler: ({ path }) => {
|
|
956
979
|
try {
|
|
957
980
|
const MAX = 2e5;
|
|
958
|
-
const raw = readFileSync4(path, "utf-8");
|
|
981
|
+
const raw = readFileSync4(confinePath(path), "utf-8");
|
|
959
982
|
const truncated = raw.length > MAX;
|
|
960
983
|
const body = truncated ? raw.slice(0, MAX) + `
|
|
961
984
|
[truncated: ${raw.length - MAX} more chars]` : raw;
|
|
@@ -982,8 +1005,9 @@ var write_file = {
|
|
|
982
1005
|
},
|
|
983
1006
|
handler: ({ path, content }) => {
|
|
984
1007
|
try {
|
|
985
|
-
|
|
986
|
-
|
|
1008
|
+
const abs = confinePath(path);
|
|
1009
|
+
mkdirSync3(dirname(abs), { recursive: true });
|
|
1010
|
+
writeFileSync4(abs, content, "utf-8");
|
|
987
1011
|
return { content: `Wrote ${path} (${content.length} bytes)` };
|
|
988
1012
|
} catch (err) {
|
|
989
1013
|
return { content: err instanceof Error ? err.message : String(err), is_error: true };
|
|
@@ -1161,6 +1185,42 @@ function toOllamaTools(tools = TOOLS) {
|
|
|
1161
1185
|
}));
|
|
1162
1186
|
}
|
|
1163
1187
|
|
|
1188
|
+
// src/tools/validate.ts
|
|
1189
|
+
import { z } from "zod";
|
|
1190
|
+
function propSchema(spec) {
|
|
1191
|
+
if (spec.enum && spec.enum.length) return z.enum(spec.enum);
|
|
1192
|
+
switch (spec.type) {
|
|
1193
|
+
case "string":
|
|
1194
|
+
return z.string();
|
|
1195
|
+
case "number":
|
|
1196
|
+
return z.number();
|
|
1197
|
+
case "integer":
|
|
1198
|
+
return z.number().int();
|
|
1199
|
+
case "boolean":
|
|
1200
|
+
return z.boolean();
|
|
1201
|
+
case "array":
|
|
1202
|
+
return z.array(z.unknown());
|
|
1203
|
+
case "object":
|
|
1204
|
+
return z.record(z.unknown());
|
|
1205
|
+
default:
|
|
1206
|
+
return z.unknown();
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
function toZod(schema) {
|
|
1210
|
+
const required = new Set(schema.required ?? []);
|
|
1211
|
+
const shape = {};
|
|
1212
|
+
for (const [key, spec] of Object.entries(schema.properties)) {
|
|
1213
|
+
shape[key] = required.has(key) ? propSchema(spec) : z.unknown().optional();
|
|
1214
|
+
}
|
|
1215
|
+
return z.object(shape).passthrough();
|
|
1216
|
+
}
|
|
1217
|
+
function validateInput(schema, input) {
|
|
1218
|
+
const result = toZod(schema).safeParse(input ?? {});
|
|
1219
|
+
if (result.success) return null;
|
|
1220
|
+
const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
1221
|
+
return `Invalid arguments: ${issues}`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1164
1224
|
// src/prompt/system.ts
|
|
1165
1225
|
function buildSystemPrompt(tools, cwd) {
|
|
1166
1226
|
const toolLines = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
|
|
@@ -1235,16 +1295,65 @@ ${toolLines}
|
|
|
1235
1295
|
- Treat a green test run or a successful command as the completion signal. If it fails, fix and re-run.
|
|
1236
1296
|
|
|
1237
1297
|
# Permissions
|
|
1238
|
-
-
|
|
1298
|
+
- File tools are confined to the working directory; paths outside it are denied.
|
|
1299
|
+
- Each tool call may prompt the user for approval. If they choose "don't ask again", the exact command or path is persisted to ~/.miii/permissions.json and the same call is auto-allowed thereafter.
|
|
1239
1300
|
`;
|
|
1240
1301
|
}
|
|
1241
1302
|
|
|
1242
1303
|
// src/permissions/policy.ts
|
|
1243
|
-
|
|
1304
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4, existsSync as existsSync3, renameSync } from "fs";
|
|
1305
|
+
import { join as join4 } from "path";
|
|
1306
|
+
import { homedir as homedir3 } from "os";
|
|
1307
|
+
var RULES_DIR = join4(homedir3(), ".miii");
|
|
1308
|
+
var RULES_PATH = join4(RULES_DIR, "permissions.json");
|
|
1309
|
+
function loadRules() {
|
|
1310
|
+
if (!existsSync3(RULES_PATH)) return [];
|
|
1311
|
+
try {
|
|
1312
|
+
const data = JSON.parse(readFileSync5(RULES_PATH, "utf-8"));
|
|
1313
|
+
return Array.isArray(data.rules) ? data.rules : [];
|
|
1314
|
+
} catch {
|
|
1315
|
+
return [];
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
function saveRules(rules) {
|
|
1319
|
+
mkdirSync4(RULES_DIR, { recursive: true });
|
|
1320
|
+
const tmp = RULES_PATH + ".tmp";
|
|
1321
|
+
writeFileSync5(tmp, JSON.stringify({ rules }, null, 2), "utf-8");
|
|
1322
|
+
renameSync(tmp, RULES_PATH);
|
|
1323
|
+
}
|
|
1324
|
+
function addRule(tool, pattern) {
|
|
1325
|
+
const rules = loadRules();
|
|
1326
|
+
if (rules.some((r) => r.tool === tool && r.pattern === pattern)) return;
|
|
1327
|
+
rules.push({ tool, pattern });
|
|
1328
|
+
saveRules(rules);
|
|
1329
|
+
}
|
|
1330
|
+
function subjectFor(toolName, input) {
|
|
1331
|
+
const obj = input ?? {};
|
|
1332
|
+
if (toolName === "run_bash") return typeof obj.command === "string" ? obj.command : "";
|
|
1333
|
+
if (typeof obj.path === "string") return obj.path;
|
|
1334
|
+
return "";
|
|
1335
|
+
}
|
|
1336
|
+
function globToRegExp(glob2) {
|
|
1337
|
+
const escaped = glob2.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1338
|
+
const pattern = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
1339
|
+
return new RegExp(`^${pattern}$`);
|
|
1340
|
+
}
|
|
1341
|
+
function matches(rule, toolName, subject) {
|
|
1342
|
+
if (rule.tool !== toolName) return false;
|
|
1343
|
+
try {
|
|
1344
|
+
return globToRegExp(rule.pattern).test(subject);
|
|
1345
|
+
} catch {
|
|
1346
|
+
return false;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1244
1349
|
async function check(toolName, input, ctx) {
|
|
1245
|
-
|
|
1350
|
+
const subject = subjectFor(toolName, input);
|
|
1351
|
+
const rules = loadRules();
|
|
1352
|
+
if (rules.some((r) => matches(r, toolName, subject))) return "allow";
|
|
1246
1353
|
const answer = await ctx.ask(toolName, input);
|
|
1247
|
-
|
|
1354
|
+
if (answer === "no") return "deny";
|
|
1355
|
+
if (answer === "always") addRule(toolName, subject);
|
|
1356
|
+
return "allow";
|
|
1248
1357
|
}
|
|
1249
1358
|
|
|
1250
1359
|
// src/agent/adapter.ts
|
|
@@ -1494,6 +1603,18 @@ async function* runAgent(opts) {
|
|
|
1494
1603
|
yield { type: "tool-result", block: r2 };
|
|
1495
1604
|
continue;
|
|
1496
1605
|
}
|
|
1606
|
+
const invalid = validateInput(tool.input_schema, use.input);
|
|
1607
|
+
if (invalid) {
|
|
1608
|
+
const r2 = {
|
|
1609
|
+
type: "tool_result",
|
|
1610
|
+
tool_use_id: use.id,
|
|
1611
|
+
content: `${invalid} for ${use.name}.`,
|
|
1612
|
+
is_error: true
|
|
1613
|
+
};
|
|
1614
|
+
results.push(r2);
|
|
1615
|
+
yield { type: "tool-result", block: r2 };
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1497
1618
|
const decision = await check(use.name, use.input, permissions);
|
|
1498
1619
|
if (decision === "deny") {
|
|
1499
1620
|
const r2 = {
|
|
@@ -1507,7 +1628,10 @@ async function* runAgent(opts) {
|
|
|
1507
1628
|
yield { type: "tool-result", block: r2 };
|
|
1508
1629
|
continue;
|
|
1509
1630
|
}
|
|
1510
|
-
|
|
1631
|
+
try {
|
|
1632
|
+
await hooks?.firePre(use);
|
|
1633
|
+
} catch {
|
|
1634
|
+
}
|
|
1511
1635
|
let r;
|
|
1512
1636
|
try {
|
|
1513
1637
|
const out = await tool.handler(use.input);
|
|
@@ -1525,7 +1649,10 @@ async function* runAgent(opts) {
|
|
|
1525
1649
|
is_error: true
|
|
1526
1650
|
};
|
|
1527
1651
|
}
|
|
1528
|
-
|
|
1652
|
+
try {
|
|
1653
|
+
await hooks?.firePost(use, r);
|
|
1654
|
+
} catch {
|
|
1655
|
+
}
|
|
1529
1656
|
results.push(r);
|
|
1530
1657
|
yield { type: "tool-result", block: r };
|
|
1531
1658
|
}
|
|
@@ -1556,8 +1683,8 @@ function useAgentRunner(model, activeCtx) {
|
|
|
1556
1683
|
const abortRef = useRef(null);
|
|
1557
1684
|
const pendingPermissionRef = useRef(null);
|
|
1558
1685
|
function askPermission(toolName, input) {
|
|
1559
|
-
return new Promise((
|
|
1560
|
-
const req = { toolName, input, resolve };
|
|
1686
|
+
return new Promise((resolve2) => {
|
|
1687
|
+
const req = { toolName, input, resolve: resolve2 };
|
|
1561
1688
|
pendingPermissionRef.current = req;
|
|
1562
1689
|
setPermissionCursor(0);
|
|
1563
1690
|
setPendingPermission(req);
|
|
@@ -1566,7 +1693,7 @@ function useAgentRunner(model, activeCtx) {
|
|
|
1566
1693
|
function resolvePermission(cursor) {
|
|
1567
1694
|
const req = pendingPermissionRef.current;
|
|
1568
1695
|
if (!req) return;
|
|
1569
|
-
const answers = ["yes", "no"];
|
|
1696
|
+
const answers = ["yes", "always", "no"];
|
|
1570
1697
|
pendingPermissionRef.current = null;
|
|
1571
1698
|
setPendingPermission(null);
|
|
1572
1699
|
req.resolve(answers[cursor]);
|
|
@@ -1902,7 +2029,7 @@ function useKeyboard(opts) {
|
|
|
1902
2029
|
return;
|
|
1903
2030
|
}
|
|
1904
2031
|
if (key.downArrow) {
|
|
1905
|
-
setPermissionCursor((i) => Math.min(
|
|
2032
|
+
setPermissionCursor((i) => Math.min(2, i + 1));
|
|
1906
2033
|
return;
|
|
1907
2034
|
}
|
|
1908
2035
|
if (key.return) {
|
|
@@ -1914,7 +2041,7 @@ function useKeyboard(opts) {
|
|
|
1914
2041
|
if (state === "ready") {
|
|
1915
2042
|
if (busyRef.current) return;
|
|
1916
2043
|
const paletteOpen = input.startsWith("/");
|
|
1917
|
-
const
|
|
2044
|
+
const matches2 = paletteOpen ? filteredCommands(input) : [];
|
|
1918
2045
|
const mention = !paletteOpen ? parseMention(input) : null;
|
|
1919
2046
|
const fileMatches = mention ? searchFiles(process.cwd(), mention.query) : [];
|
|
1920
2047
|
const fileOpen = mention !== null && fileMatches.length > 0;
|
|
@@ -1923,11 +2050,11 @@ function useKeyboard(opts) {
|
|
|
1923
2050
|
return;
|
|
1924
2051
|
}
|
|
1925
2052
|
if (paletteOpen && key.downArrow) {
|
|
1926
|
-
setPaletteCursor((i) => Math.min(
|
|
2053
|
+
setPaletteCursor((i) => Math.min(matches2.length - 1, i + 1));
|
|
1927
2054
|
return;
|
|
1928
2055
|
}
|
|
1929
|
-
if (paletteOpen && (key.tab || key.return) &&
|
|
1930
|
-
setInput(() =>
|
|
2056
|
+
if (paletteOpen && (key.tab || key.return) && matches2[paletteCursor] && input !== matches2[paletteCursor].name) {
|
|
2057
|
+
setInput(() => matches2[paletteCursor].name);
|
|
1931
2058
|
setPaletteCursor(() => 0);
|
|
1932
2059
|
return;
|
|
1933
2060
|
}
|
|
@@ -2046,7 +2173,7 @@ async function checkForUpdate() {
|
|
|
2046
2173
|
import { Fragment as Fragment2, jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
|
|
2047
2174
|
function App() {
|
|
2048
2175
|
const { exit } = useApp();
|
|
2049
|
-
const cwd = process.cwd().replace(
|
|
2176
|
+
const cwd = process.cwd().replace(homedir4(), "~").split(sep2).join("/");
|
|
2050
2177
|
const [cfg, setCfg] = useState4(loadConfig());
|
|
2051
2178
|
const [models, setModels] = useState4([]);
|
|
2052
2179
|
const [contexts, setContexts] = useState4({});
|