miii-agent 0.1.6 → 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.
Files changed (3) hide show
  1. package/README.md +23 -3
  2. package/dist/cli.js +164 -33
  3. 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
  ![miii demo](demo.gif)
@@ -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 -->|"tool call"| ToolRegistry["Tool Registry\n(tools/registry.ts)"]
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 homedir2 } from "os";
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";
@@ -354,8 +354,12 @@ function filteredCommands(filter) {
354
354
  // src/session/store.ts
355
355
  import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, rmSync } from "fs";
356
356
  import { join as join2 } from "path";
357
+ import { homedir as homedir2 } from "os";
357
358
  import { randomUUID } from "crypto";
358
- var SESSION_DIR = join2(process.cwd(), ".miii", "session");
359
+ function encodeProjectDir(cwd) {
360
+ return cwd.replace(/[/\\]/g, "-").replace(/^-+/, "");
361
+ }
362
+ var SESSION_DIR = join2(homedir2(), ".miii", "projects", encodeProjectDir(process.cwd()), "session");
359
363
  function newSessionId() {
360
364
  return randomUUID();
361
365
  }
@@ -545,8 +549,8 @@ function searchFiles(cwd, query) {
545
549
  scored.sort((a, b) => a[0] - b[0] || a[1].length - b[1].length);
546
550
  return scored.slice(0, MAX_RESULTS).map(([, f]) => f);
547
551
  }
548
- function FilePicker({ matches, cursor }) {
549
- if (matches.length === 0) return null;
552
+ function FilePicker({ matches: matches2, cursor }) {
553
+ if (matches2.length === 0) return null;
550
554
  return /* @__PURE__ */ jsxs7(
551
555
  Box7,
552
556
  {
@@ -557,7 +561,7 @@ function FilePicker({ matches, cursor }) {
557
561
  marginBottom: 0,
558
562
  paddingX: 1,
559
563
  children: [
560
- matches.map((f, i) => {
564
+ matches2.map((f, i) => {
561
565
  const active = i === cursor;
562
566
  return /* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsxs7(Text7, { bold: active, color: active ? "blue" : void 0, dimColor: !active, children: [
563
567
  active ? "\u276F " : " ",
@@ -842,6 +846,7 @@ function PermissionPrompt({ req, cursor }) {
842
846
  const label = TOOL_LABEL[req.toolName] ?? req.toolName;
843
847
  const options = [
844
848
  { label: "Yes", key: "yes" },
849
+ { label: "Yes, don't ask again for this", key: "always" },
845
850
  { label: "No", key: "no" }
846
851
  ];
847
852
  const summary = summarizeInput(req.input);
@@ -910,6 +915,23 @@ import { useState as useState3, useRef } from "react";
910
915
 
911
916
  // src/tools/edit_file.ts
912
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
913
935
  var edit_file = {
914
936
  name: "edit_file",
915
937
  description: "Replace an exact string in a file. old_str must be unique.",
@@ -923,16 +945,21 @@ var edit_file = {
923
945
  required: ["path", "old_str", "new_str"]
924
946
  },
925
947
  handler: ({ path, old_str, new_str }) => {
926
- const src = readFileSync3(path, "utf-8");
927
- const first = src.indexOf(old_str);
928
- if (first === -1) {
929
- return { content: `old_str not found in ${path}`, is_error: true };
930
- }
931
- if (src.indexOf(old_str, first + 1) !== -1) {
932
- return { content: `old_str not unique in ${path}`, is_error: true };
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 };
933
962
  }
934
- writeFileSync3(path, src.slice(0, first) + new_str + src.slice(first + old_str.length), "utf-8");
935
- return { content: `Edited ${path}` };
936
963
  }
937
964
  };
938
965
 
@@ -951,7 +978,7 @@ var read_file = {
951
978
  handler: ({ path }) => {
952
979
  try {
953
980
  const MAX = 2e5;
954
- const raw = readFileSync4(path, "utf-8");
981
+ const raw = readFileSync4(confinePath(path), "utf-8");
955
982
  const truncated = raw.length > MAX;
956
983
  const body = truncated ? raw.slice(0, MAX) + `
957
984
  [truncated: ${raw.length - MAX} more chars]` : raw;
@@ -978,8 +1005,9 @@ var write_file = {
978
1005
  },
979
1006
  handler: ({ path, content }) => {
980
1007
  try {
981
- mkdirSync3(dirname(path), { recursive: true });
982
- writeFileSync4(path, content, "utf-8");
1008
+ const abs = confinePath(path);
1009
+ mkdirSync3(dirname(abs), { recursive: true });
1010
+ writeFileSync4(abs, content, "utf-8");
983
1011
  return { content: `Wrote ${path} (${content.length} bytes)` };
984
1012
  } catch (err) {
985
1013
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
@@ -1157,6 +1185,42 @@ function toOllamaTools(tools = TOOLS) {
1157
1185
  }));
1158
1186
  }
1159
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
+
1160
1224
  // src/prompt/system.ts
1161
1225
  function buildSystemPrompt(tools, cwd) {
1162
1226
  const toolLines = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
@@ -1231,16 +1295,65 @@ ${toolLines}
1231
1295
  - Treat a green test run or a successful command as the completion signal. If it fails, fix and re-run.
1232
1296
 
1233
1297
  # Permissions
1234
- - When a new bash command pattern, file path, or glob pattern is needed, ask the user once; on approval it persists as a Tool(pattern) rule (e.g. Bash(npm test *), WriteFile(src/*)).
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.
1235
1300
  `;
1236
1301
  }
1237
1302
 
1238
1303
  // src/permissions/policy.ts
1239
- var DEFAULT_ALLOW = /* @__PURE__ */ new Set(["read_file"]);
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
+ }
1240
1349
  async function check(toolName, input, ctx) {
1241
- if (DEFAULT_ALLOW.has(toolName)) return "allow";
1350
+ const subject = subjectFor(toolName, input);
1351
+ const rules = loadRules();
1352
+ if (rules.some((r) => matches(r, toolName, subject))) return "allow";
1242
1353
  const answer = await ctx.ask(toolName, input);
1243
- return answer === "no" ? "deny" : "allow";
1354
+ if (answer === "no") return "deny";
1355
+ if (answer === "always") addRule(toolName, subject);
1356
+ return "allow";
1244
1357
  }
1245
1358
 
1246
1359
  // src/agent/adapter.ts
@@ -1490,6 +1603,18 @@ async function* runAgent(opts) {
1490
1603
  yield { type: "tool-result", block: r2 };
1491
1604
  continue;
1492
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
+ }
1493
1618
  const decision = await check(use.name, use.input, permissions);
1494
1619
  if (decision === "deny") {
1495
1620
  const r2 = {
@@ -1503,7 +1628,10 @@ async function* runAgent(opts) {
1503
1628
  yield { type: "tool-result", block: r2 };
1504
1629
  continue;
1505
1630
  }
1506
- await hooks?.firePre(use);
1631
+ try {
1632
+ await hooks?.firePre(use);
1633
+ } catch {
1634
+ }
1507
1635
  let r;
1508
1636
  try {
1509
1637
  const out = await tool.handler(use.input);
@@ -1521,7 +1649,10 @@ async function* runAgent(opts) {
1521
1649
  is_error: true
1522
1650
  };
1523
1651
  }
1524
- await hooks?.firePost(use, r);
1652
+ try {
1653
+ await hooks?.firePost(use, r);
1654
+ } catch {
1655
+ }
1525
1656
  results.push(r);
1526
1657
  yield { type: "tool-result", block: r };
1527
1658
  }
@@ -1552,8 +1683,8 @@ function useAgentRunner(model, activeCtx) {
1552
1683
  const abortRef = useRef(null);
1553
1684
  const pendingPermissionRef = useRef(null);
1554
1685
  function askPermission(toolName, input) {
1555
- return new Promise((resolve) => {
1556
- const req = { toolName, input, resolve };
1686
+ return new Promise((resolve2) => {
1687
+ const req = { toolName, input, resolve: resolve2 };
1557
1688
  pendingPermissionRef.current = req;
1558
1689
  setPermissionCursor(0);
1559
1690
  setPendingPermission(req);
@@ -1562,7 +1693,7 @@ function useAgentRunner(model, activeCtx) {
1562
1693
  function resolvePermission(cursor) {
1563
1694
  const req = pendingPermissionRef.current;
1564
1695
  if (!req) return;
1565
- const answers = ["yes", "no"];
1696
+ const answers = ["yes", "always", "no"];
1566
1697
  pendingPermissionRef.current = null;
1567
1698
  setPendingPermission(null);
1568
1699
  req.resolve(answers[cursor]);
@@ -1898,7 +2029,7 @@ function useKeyboard(opts) {
1898
2029
  return;
1899
2030
  }
1900
2031
  if (key.downArrow) {
1901
- setPermissionCursor((i) => Math.min(1, i + 1));
2032
+ setPermissionCursor((i) => Math.min(2, i + 1));
1902
2033
  return;
1903
2034
  }
1904
2035
  if (key.return) {
@@ -1910,7 +2041,7 @@ function useKeyboard(opts) {
1910
2041
  if (state === "ready") {
1911
2042
  if (busyRef.current) return;
1912
2043
  const paletteOpen = input.startsWith("/");
1913
- const matches = paletteOpen ? filteredCommands(input) : [];
2044
+ const matches2 = paletteOpen ? filteredCommands(input) : [];
1914
2045
  const mention = !paletteOpen ? parseMention(input) : null;
1915
2046
  const fileMatches = mention ? searchFiles(process.cwd(), mention.query) : [];
1916
2047
  const fileOpen = mention !== null && fileMatches.length > 0;
@@ -1919,11 +2050,11 @@ function useKeyboard(opts) {
1919
2050
  return;
1920
2051
  }
1921
2052
  if (paletteOpen && key.downArrow) {
1922
- setPaletteCursor((i) => Math.min(matches.length - 1, i + 1));
2053
+ setPaletteCursor((i) => Math.min(matches2.length - 1, i + 1));
1923
2054
  return;
1924
2055
  }
1925
- if (paletteOpen && (key.tab || key.return) && matches[paletteCursor] && input !== matches[paletteCursor].name) {
1926
- setInput(() => matches[paletteCursor].name);
2056
+ if (paletteOpen && (key.tab || key.return) && matches2[paletteCursor] && input !== matches2[paletteCursor].name) {
2057
+ setInput(() => matches2[paletteCursor].name);
1927
2058
  setPaletteCursor(() => 0);
1928
2059
  return;
1929
2060
  }
@@ -2042,7 +2173,7 @@ async function checkForUpdate() {
2042
2173
  import { Fragment as Fragment2, jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
2043
2174
  function App() {
2044
2175
  const { exit } = useApp();
2045
- const cwd = process.cwd().replace(homedir2(), "~").split(sep).join("/");
2176
+ const cwd = process.cwd().replace(homedir4(), "~").split(sep2).join("/");
2046
2177
  const [cfg, setCfg] = useState4(loadConfig());
2047
2178
  const [models, setModels] = useState4([]);
2048
2179
  const [contexts, setContexts] = useState4({});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Terminal AI coding agent powered by Ollama",
5
5
  "type": "module",
6
6
  "bin": {