miii-cli 1.1.2 → 1.2.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
@@ -164,6 +164,7 @@ No API keys. No account. No sign-up form. First run walks you through setup inte
164
164
  | `/plan <topic>` | Structured planning mode before you write a line |
165
165
  | `/model <name>` | Hot-swap your LLM mid-conversation |
166
166
  | `/session <name>` | Switch between named project sessions |
167
+ | `/watch <path>` | Monitor files for changes and trigger agent reactions |
167
168
  | `@filename` | Inject any file directly into context |
168
169
 
169
170
  ---
package/dist/init.js CHANGED
@@ -15,7 +15,7 @@ import { loadMCPTools } from './mcp/client.js';
15
15
  import { needsSetup, runSetup } from './setup.js';
16
16
  const require = createRequire(import.meta.url);
17
17
  const UPDATE_CACHE = join(homedir(), '.config', 'miii', 'update-check.json');
18
- const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
18
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h
19
19
  function semverGt(a, b) {
20
20
  const pa = a.split('.').map(Number);
21
21
  const pb = b.split('.').map(Number);
@@ -1,3 +1,61 @@
1
+ // Transient errors worth retrying: rate limits + server-side faults
2
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 529]);
3
+ const MAX_RETRIES = 4;
4
+ const MAX_DELAY_MS = 30_000;
5
+ function retryDelay(attempt) {
6
+ // Exponential backoff: 1s → 2s → 4s → 8s, capped at 30s, ±20% jitter
7
+ const base = 1_000 * Math.pow(2, attempt);
8
+ const capped = Math.min(base, MAX_DELAY_MS);
9
+ return Math.round(capped * (0.8 + Math.random() * 0.4));
10
+ }
11
+ function sleep(ms, signal) {
12
+ return new Promise((resolve, reject) => {
13
+ if (signal?.aborted) {
14
+ reject(new DOMException('Aborted', 'AbortError'));
15
+ return;
16
+ }
17
+ const t = setTimeout(resolve, ms);
18
+ signal?.addEventListener('abort', () => { clearTimeout(t); reject(new DOMException('Aborted', 'AbortError')); }, { once: true });
19
+ });
20
+ }
21
+ async function fetchWithRetry(url, init, signal, onRetry) {
22
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
23
+ let res;
24
+ try {
25
+ res = await fetch(url, { ...init, signal });
26
+ }
27
+ catch (err) {
28
+ if (err?.name === 'AbortError')
29
+ throw err;
30
+ if (attempt === MAX_RETRIES)
31
+ throw err;
32
+ const delayMs = retryDelay(attempt);
33
+ onRetry?.(attempt + 1, MAX_RETRIES, delayMs);
34
+ await sleep(delayMs, signal);
35
+ continue;
36
+ }
37
+ if (res.ok || !RETRYABLE_STATUS.has(res.status) || attempt === MAX_RETRIES)
38
+ return res;
39
+ const retryAfterSec = Number(res.headers.get('retry-after') ?? 0);
40
+ const delayMs = retryAfterSec > 0 ? retryAfterSec * 1000 : retryDelay(attempt);
41
+ onRetry?.(attempt + 1, MAX_RETRIES, delayMs);
42
+ await sleep(delayMs, signal);
43
+ }
44
+ throw new Error('fetchWithRetry: exhausted retries without returning');
45
+ }
46
+ export async function warmup(provider, baseUrl, model) {
47
+ if (provider !== 'ollama')
48
+ return;
49
+ try {
50
+ await fetch(`${baseUrl}/api/generate`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ model, keep_alive: '10m' }),
54
+ signal: AbortSignal.timeout(30_000),
55
+ });
56
+ }
57
+ catch { }
58
+ }
1
59
  export async function chat(cfg) {
2
60
  if (cfg.provider === 'anthropic')
3
61
  return chatAnthropic(cfg);
@@ -6,14 +64,13 @@ export async function chat(cfg) {
6
64
  return chatOllama(cfg);
7
65
  }
8
66
  async function chatOllama(cfg) {
9
- const { model, messages, baseUrl, signal, onDone, onError, onUsage, onChunk } = cfg;
67
+ const { model, messages, baseUrl, signal, onDone, onError, onUsage, onChunk, onRetry } = cfg;
10
68
  try {
11
- const res = await fetch(`${baseUrl}/api/chat`, {
69
+ const res = await fetchWithRetry(`${baseUrl}/api/chat`, {
12
70
  method: 'POST',
13
71
  headers: { 'Content-Type': 'application/json' },
14
72
  body: JSON.stringify({ model, messages, stream: !!onChunk }),
15
- signal,
16
- });
73
+ }, signal, onRetry);
17
74
  if (!res.ok) {
18
75
  onError(new Error(`Ollama ${res.status}: ${await res.text()}`));
19
76
  return;
@@ -64,14 +121,13 @@ async function chatOllama(cfg) {
64
121
  }
65
122
  }
66
123
  async function chatOpenAI(cfg) {
67
- const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk } = cfg;
124
+ const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk, onRetry } = cfg;
68
125
  try {
69
- const res = await fetch(`${baseUrl}/v1/chat/completions`, {
126
+ const res = await fetchWithRetry(`${baseUrl}/v1/chat/completions`, {
70
127
  method: 'POST',
71
128
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey ?? 'local'}` },
72
129
  body: JSON.stringify({ model, messages, stream: !!onChunk }),
73
- signal,
74
- });
130
+ }, signal, onRetry);
75
131
  if (!res.ok) {
76
132
  onError(new Error(`LLM ${res.status}: ${await res.text()}`));
77
133
  return;
@@ -118,7 +174,7 @@ async function chatOpenAI(cfg) {
118
174
  }
119
175
  }
120
176
  async function chatAnthropic(cfg) {
121
- const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage } = cfg;
177
+ const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onRetry } = cfg;
122
178
  const url = baseUrl && baseUrl !== 'http://localhost:11434'
123
179
  ? `${baseUrl}/v1/messages`
124
180
  : 'https://api.anthropic.com/v1/messages';
@@ -132,7 +188,7 @@ async function chatAnthropic(cfg) {
132
188
  };
133
189
  if (systemParts.length)
134
190
  body.system = systemParts.join('\n\n');
135
- const res = await fetch(url, {
191
+ const res = await fetchWithRetry(url, {
136
192
  method: 'POST',
137
193
  headers: {
138
194
  'content-type': 'application/json',
@@ -140,8 +196,7 @@ async function chatAnthropic(cfg) {
140
196
  'anthropic-version': '2023-06-01',
141
197
  },
142
198
  body: JSON.stringify(body),
143
- signal,
144
- });
199
+ }, signal, onRetry);
145
200
  if (!res.ok) {
146
201
  onError(new Error(`Anthropic ${res.status}: ${await res.text()}`));
147
202
  return;
@@ -68,9 +68,13 @@ export class MCPClient {
68
68
  send(method, params) {
69
69
  return new Promise((resolve, reject) => {
70
70
  const id = this.nextId++;
71
- this.pending.set(id, { resolve, reject });
71
+ let timer;
72
+ this.pending.set(id, {
73
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
74
+ reject: (e) => { clearTimeout(timer); reject(e); },
75
+ });
72
76
  this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
73
- setTimeout(() => {
77
+ timer = setTimeout(() => {
74
78
  if (this.pending.has(id)) {
75
79
  this.pending.delete(id);
76
80
  reject(new Error(`MCP timeout: ${method}`));
@@ -1,14 +1,12 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { homedir } from 'os';
4
- const MEMORY_DIR = join(homedir(), '.config', 'miii', 'memory');
5
3
  const MAX_FACTS = 200;
6
- function ensureDir() {
7
- mkdirSync(MEMORY_DIR, { recursive: true });
4
+ function memoryPath(projectDir) {
5
+ return join(projectDir, 'memory.json');
8
6
  }
9
- export function loadLongMemory(sessionName) {
10
- ensureDir();
11
- const p = join(MEMORY_DIR, `${sessionName}.json`);
7
+ export function loadLongMemory(projectDir) {
8
+ mkdirSync(projectDir, { recursive: true });
9
+ const p = memoryPath(projectDir);
12
10
  if (!existsSync(p))
13
11
  return [];
14
12
  try {
@@ -19,9 +17,9 @@ export function loadLongMemory(sessionName) {
19
17
  return [];
20
18
  }
21
19
  }
22
- export function saveLongMemory(sessionName, facts) {
23
- ensureDir();
24
- writeFileSync(join(MEMORY_DIR, `${sessionName}.json`), JSON.stringify(facts));
20
+ export function saveLongMemory(projectDir, facts) {
21
+ mkdirSync(projectDir, { recursive: true });
22
+ writeFileSync(memoryPath(projectDir), JSON.stringify(facts));
25
23
  }
26
24
  export function mergeFacts(existing, newTexts) {
27
25
  const existingSet = new Set(existing.map(f => f.text.toLowerCase()));
@@ -37,5 +35,5 @@ export function mergeFacts(existing, newTexts) {
37
35
  export function formatMemoryBlock(facts) {
38
36
  if (!facts.length)
39
37
  return '';
40
- return `\n\n[Long-term memory — recalled from prior conversation]\n${facts.map(f => `- ${f.text}`).join('\n')}`;
38
+ return `\n\n[Long-term memory — recalled from prior sessions in this project]\n${facts.map(f => `- ${f.text}`).join('\n')}`;
41
39
  }
package/dist/sessions.js CHANGED
@@ -1,23 +1,31 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, statSync, existsSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
- const SESSIONS_DIR = join(homedir(), '.config', 'miii', 'sessions');
5
- function ensureDir() {
6
- mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
7
- chmodSync(SESSIONS_DIR, 0o700);
4
+ const PROJECTS_DIR = join(homedir(), '.config', 'miii', 'projects');
5
+ export function getProjectDir(cwd) {
6
+ const slug = cwd.replace(/\//g, '-').replace(/^-/, '').replace(/[^a-zA-Z0-9_.-]/g, '-') || 'default';
7
+ return join(PROJECTS_DIR, slug);
8
+ }
9
+ function sessionsDir(projectDir) {
10
+ return join(projectDir, 'sessions');
11
+ }
12
+ function ensureProjectDir(projectDir) {
13
+ mkdirSync(sessionsDir(projectDir), { recursive: true, mode: 0o700 });
14
+ chmodSync(projectDir, 0o700);
8
15
  }
9
16
  function sanitizeName(name) {
10
17
  if (!/^[\w-]+$/.test(name))
11
18
  throw new Error(`invalid session name: ${name}`);
12
19
  return name;
13
20
  }
14
- export function listSessions() {
15
- ensureDir();
16
- return readdirSync(SESSIONS_DIR)
21
+ export function listSessions(projectDir) {
22
+ ensureProjectDir(projectDir);
23
+ const dir = sessionsDir(projectDir);
24
+ return readdirSync(dir)
17
25
  .filter(f => f.endsWith('.json'))
18
26
  .map(f => {
19
27
  const name = f.replace('.json', '');
20
- const p = join(SESSIONS_DIR, f);
28
+ const p = join(dir, f);
21
29
  let messageCount = 0;
22
30
  let updatedAt = 0;
23
31
  try {
@@ -30,9 +38,9 @@ export function listSessions() {
30
38
  })
31
39
  .sort((a, b) => b.updatedAt - a.updatedAt);
32
40
  }
33
- export function loadSession(name) {
34
- ensureDir();
35
- const p = join(SESSIONS_DIR, `${sanitizeName(name)}.json`);
41
+ export function loadSession(projectDir, name) {
42
+ ensureProjectDir(projectDir);
43
+ const p = join(sessionsDir(projectDir), `${sanitizeName(name)}.json`);
36
44
  if (!existsSync(p))
37
45
  return [];
38
46
  try {
@@ -43,28 +51,30 @@ export function loadSession(name) {
43
51
  return [];
44
52
  }
45
53
  }
46
- export function saveSession(name, messages) {
47
- ensureDir();
54
+ export function saveSession(projectDir, name, messages) {
55
+ const safeName = sanitizeName(name);
56
+ ensureProjectDir(projectDir);
48
57
  try {
49
- writeFileSync(join(SESSIONS_DIR, `${sanitizeName(name)}.json`), JSON.stringify(messages), { mode: 0o600 });
58
+ writeFileSync(join(sessionsDir(projectDir), `${safeName}.json`), JSON.stringify(messages), { mode: 0o600 });
50
59
  }
51
60
  catch { }
52
61
  }
53
- export function deleteSession(name) {
54
- const p = join(SESSIONS_DIR, `${sanitizeName(name)}.json`);
62
+ export function deleteSession(projectDir, name) {
63
+ const p = join(sessionsDir(projectDir), `${sanitizeName(name)}.json`);
55
64
  if (existsSync(p))
56
65
  unlinkSync(p);
57
66
  }
58
- export function deleteAllSessions(exceptName) {
59
- ensureDir();
60
- const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
67
+ export function deleteAllSessions(projectDir, exceptName) {
68
+ ensureProjectDir(projectDir);
69
+ const dir = sessionsDir(projectDir);
70
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
61
71
  let count = 0;
62
72
  for (const f of files) {
63
73
  const name = f.replace('.json', '');
64
74
  if (exceptName && name === exceptName)
65
75
  continue;
66
76
  try {
67
- unlinkSync(join(SESSIONS_DIR, f));
77
+ unlinkSync(join(dir, f));
68
78
  count++;
69
79
  }
70
80
  catch { }
@@ -1,8 +1,13 @@
1
1
  import { chat } from '../llm/stream.js';
2
- const COMPACT_THRESHOLD = 18;
2
+ // ~4 chars per token heuristic. 40K chars ≈ 10K tokens — safe floor for 7B local models.
3
+ // Cloud providers can handle more but compacting early keeps responses fast regardless.
4
+ const COMPACT_CHAR_THRESHOLD = 40_000;
3
5
  const KEEP_RECENT = 6;
6
+ export function contextSize(messages) {
7
+ return messages.reduce((sum, m) => sum + m.content.length, 0);
8
+ }
4
9
  export function shouldCompact(messages) {
5
- return messages.length > COMPACT_THRESHOLD;
10
+ return contextSize(messages) > COMPACT_CHAR_THRESHOLD;
6
11
  }
7
12
  const COMPACT_SYSTEM = `You are a context summarizer for an AI coding agent session.
8
13
  Your job: produce a dense, structured summary of the conversation so the agent can continue the task without losing context.
@@ -26,7 +31,7 @@ Any constraints, errors encountered, important facts the agent must remember to
26
31
 
27
32
  Be factual. No padding. Include file paths, error messages, and command outputs verbatim when relevant.`;
28
33
  export async function compactContext(messages, cfg, goal) {
29
- if (messages.length <= COMPACT_THRESHOLD)
34
+ if (contextSize(messages) <= COMPACT_CHAR_THRESHOLD)
30
35
  return messages;
31
36
  const system = messages[0]?.role === 'system' ? messages[0] : null;
32
37
  const recent = messages.slice(messages.length - KEEP_RECENT);
@@ -42,6 +47,7 @@ export async function compactContext(messages, cfg, goal) {
42
47
  `Conversation to summarize:\n\n${transcript}`,
43
48
  ].join('');
44
49
  let summary = '';
50
+ let compactErr = '';
45
51
  await chat({
46
52
  ...cfg,
47
53
  messages: [
@@ -49,8 +55,10 @@ export async function compactContext(messages, cfg, goal) {
49
55
  { role: 'user', content: userPrompt },
50
56
  ],
51
57
  onDone: (text) => { summary = text.trim(); },
52
- onError: () => { },
58
+ onError: (err) => { compactErr = err.message; },
53
59
  });
60
+ if (compactErr)
61
+ console.error(`[compactor] LLM error: ${compactErr}`);
54
62
  // Fallback to dumb compaction if LLM fails
55
63
  if (!summary)
56
64
  return dumbCompact(messages, goal);
@@ -65,6 +73,8 @@ export async function compactContext(messages, cfg, goal) {
65
73
  ];
66
74
  }
67
75
  function dumbCompact(messages, goal) {
76
+ if (contextSize(messages) <= COMPACT_CHAR_THRESHOLD)
77
+ return messages;
68
78
  const system = messages[0]?.role === 'system' ? messages[0] : null;
69
79
  const userGoal = messages.find(m => m.role === 'user' && !m.content.startsWith('['));
70
80
  const recent = messages.slice(messages.length - KEEP_RECENT);
@@ -68,8 +68,10 @@ export const tools = [
68
68
  execute: async ({ path, old: oldStr, new: newStr }) => {
69
69
  const safe = guardPath(path);
70
70
  const current = readFile(safe);
71
- if (!current)
72
- throw new Error(`file not found or empty: ${path}`);
71
+ if (current === null)
72
+ throw new Error(`file not found: ${path}`);
73
+ if (current === '')
74
+ throw new Error(`file empty: ${path}`);
73
75
  const old = oldStr;
74
76
  const count = current.split(old).length - 1;
75
77
  if (count === 0) {
@@ -79,7 +81,7 @@ export const tools = [
79
81
  if (count > 1) {
80
82
  throw new Error(`ambiguous: ${count} matches found in ${path} — extend <old> block with more surrounding lines to make it unique`);
81
83
  }
82
- const updated = current.replace(old, newStr);
84
+ const updated = current.replace(old, String(newStr));
83
85
  writeFile(safe, updated);
84
86
  // Compute affected line range for the snippet
85
87
  const startLine = current.slice(0, current.indexOf(old)).split('\n').length;
@@ -1,11 +1,11 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useRef, useMemo, useEffect } from 'react';
3
3
  import { Box, Text, useStdout } from 'ink';
4
4
  import { InputArea } from './components/InputArea.js';
5
5
  import { ModelPicker } from './components/ModelPicker.js';
6
6
  import { ConfigPicker } from './components/ConfigPicker.js';
7
7
  import { Divider } from './components/StatusBar.js';
8
- import { tools, getSystemPrompt } from '../tools/index.js';
8
+ import { tools } from '../tools/index.js';
9
9
  import { toolArgSummary } from './printer.js';
10
10
  import { MacroQueue } from '../tasks/queue.js';
11
11
  import { TaskExecutor } from '../tasks/executor.js';
@@ -16,11 +16,13 @@ import { useRunLoop } from './hooks/useRunLoop.js';
16
16
  import { useRefactor } from './hooks/useRefactor.js';
17
17
  import { useGit } from './hooks/useGit.js';
18
18
  import { useSubmit } from './hooks/useSubmit.js';
19
+ import { useWatch } from './hooks/useWatch.js';
19
20
  import { runDeepThink } from './deepThink.js';
20
21
  import { setInkInstance } from './printer.js';
21
22
  import { createSearchCodebaseTool } from '../index/tool.js';
22
23
  import { saveConfig } from '../config.js';
23
24
  import { getTavilyKey, saveTavilyKey } from '../tavily/client.js';
25
+ import { warmup } from '../llm/stream.js';
24
26
  function formatElapsed(ms) {
25
27
  const s = Math.floor(ms / 1000);
26
28
  if (s < 60)
@@ -29,11 +31,71 @@ function formatElapsed(ms) {
29
31
  const rem = s % 60;
30
32
  return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
31
33
  }
34
+ const MAX_DIFF_LINES = 40;
35
+ const DIFF_CTX = 2;
36
+ function lineDiff(oldText, newText) {
37
+ const a = oldText.split('\n');
38
+ const b = newText.split('\n');
39
+ const m = a.length, n = b.length;
40
+ if (m * n > 10000) {
41
+ return [...a.map(line => ({ type: 'del', line })), ...b.map(line => ({ type: 'add', line }))];
42
+ }
43
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
44
+ for (let i = m - 1; i >= 0; i--)
45
+ for (let j = n - 1; j >= 0; j--)
46
+ dp[i][j] = a[i] === b[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
47
+ const result = [];
48
+ let i = 0, j = 0;
49
+ while (i < m || j < n) {
50
+ if (i < m && j < n && a[i] === b[j]) {
51
+ result.push({ type: 'eq', line: a[i++] });
52
+ j++;
53
+ }
54
+ else if (j < n && (i >= m || dp[i + 1][j] <= dp[i][j + 1])) {
55
+ result.push({ type: 'add', line: b[j++] });
56
+ }
57
+ else {
58
+ result.push({ type: 'del', line: a[i++] });
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+ function diffHunks(diff) {
64
+ const changedIdxs = diff.reduce((acc, d, i) => { if (d.type !== 'eq')
65
+ acc.push(i); return acc; }, []);
66
+ if (!changedIdxs.length)
67
+ return [];
68
+ const inHunk = new Set();
69
+ for (const ci of changedIdxs)
70
+ for (let k = Math.max(0, ci - DIFF_CTX); k <= Math.min(diff.length - 1, ci + DIFF_CTX); k++)
71
+ inHunk.add(k);
72
+ return diff.filter((_, i) => inHunk.has(i));
73
+ }
74
+ function DiffPreview({ toolName, args }) {
75
+ if (toolName === 'patch_file' && (args.old != null || args.new != null)) {
76
+ const path = String(args.path ?? '');
77
+ const diff = diffHunks(lineDiff(String(args.old ?? ''), String(args.new ?? '')));
78
+ const visible = diff.slice(0, MAX_DIFF_LINES);
79
+ const hidden = diff.length - visible.length;
80
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", path] }), visible.map((d, i) => (_jsxs(Text, { color: d.type === 'del' ? 'red' : d.type === 'add' ? 'green' : 'gray', dimColor: d.type === 'eq', children: [d.type === 'del' ? '- ' : d.type === 'add' ? '+ ' : ' ', d.line.slice(0, 76)] }, i))), hidden > 0 && _jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", hidden, " more line", hidden === 1 ? '' : 's'] })] }));
81
+ }
82
+ if ((toolName === 'edit_file' || toolName === 'create_file') && args.content) {
83
+ const path = String(args.path ?? '');
84
+ const lines = String(args.content).split('\n');
85
+ const visible = lines.slice(0, MAX_DIFF_LINES);
86
+ const hidden = lines.length - visible.length;
87
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [" ", path] }), visible.map((line, i) => (_jsxs(Text, { color: "green", children: ["+ ", line.slice(0, 76)] }, i))), hidden > 0 && _jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", hidden, " more line", hidden === 1 ? '' : 's'] })] }));
88
+ }
89
+ return null;
90
+ }
32
91
  export function InputBar({ config: initialConfig, skills, cwd, session, version, mcpTools = [] }) {
33
92
  const [config, setConfig] = useState(initialConfig);
34
93
  const { stdout, write: stdoutWrite } = useStdout();
35
94
  const cols = stdout.columns ?? 80;
36
- useEffect(() => { setInkInstance(stdoutWrite); }, []);
95
+ useEffect(() => {
96
+ setInkInstance(stdoutWrite);
97
+ warmup(initialConfig.provider, initialConfig.baseUrl, initialConfig.model);
98
+ }, []);
37
99
  const phraseSeq = useMemo(() => Array.from({ length: 100 }, () => Math.floor(Math.random() * THINKING_PHRASES.length)), []);
38
100
  const [planningMode, setPlanningMode] = useState(false);
39
101
  const [configOpen, setConfigOpen] = useState(false);
@@ -42,8 +104,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
42
104
  const executorRef = useRef(new TaskExecutor(tools));
43
105
  const lastGitStatusRef = useRef('');
44
106
  const abortRef = useRef(null);
45
- const { setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, } = useSession(session, cwd, config, mcpTools);
46
- const sysPrompt = useCallback((extra = '') => getSystemPrompt(`\n- CWD: ${cwd}${extra}`, mcpTools), [mcpTools, cwd]);
107
+ const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools);
47
108
  const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
48
109
  const deepThinkTool = useMemo(() => ({
49
110
  name: 'deep_think',
@@ -63,14 +124,16 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
63
124
  setStatus, setTaskLabel, setCurrentTool, pushHistory,
64
125
  });
65
126
  const { handleGit } = useGit({ pushHistory, buildContext, runLoop });
127
+ const { watchActive, startWatch, stopWatch } = useWatch(cwd, { runLoop, buildContext, pushHistory });
66
128
  const { handleSubmit } = useSubmit({
67
- config, skills, cwd, version, currentModelRef, setCurrentModel,
129
+ config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel,
68
130
  historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef,
69
131
  setPlanningMode, runLoop, buildContext, pushHistory,
70
132
  setSessionName, renameFromMessage,
71
133
  setStatus, setTaskLabel, setCurrentTool,
72
134
  runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig,
73
- setConfigOpen,
135
+ setConfigOpen, updateMemory,
136
+ startWatch, stopWatch, watchActive,
74
137
  });
75
138
  const skillList = skills.list();
76
139
  return (_jsxs(Box, { flexDirection: "column", children: [configOpen ? (_jsxs(_Fragment, { children: [_jsx(ConfigPicker, { config: config, currentModel: currentModel, tavilyKey: tavilyKey, onUpdate: ({ model, ...configPatch }) => {
@@ -80,7 +143,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
80
143
  setConfig(c => ({ ...c, ...configPatch }));
81
144
  saveConfig(configPatch);
82
145
  }
83
- }, onTavilyKey: (key) => { saveTavilyKey(key); setTavilyKey(key); }, onClose: () => { setConfigOpen(false); } }), _jsx(Divider, { cols: cols })] })) : pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); } }), _jsx(Divider, { cols: cols })] })) : compactRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: "context is large" }), _jsxs(Text, { color: "gray", children: ["(", compactRequest.messageCount, " messages)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: status === 'thinking'
146
+ }, onTavilyKey: (key) => { saveTavilyKey(key); setTavilyKey(key); }, onClose: () => { setConfigOpen(false); } }), _jsx(Divider, { cols: cols })] })) : pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); } }), _jsx(Divider, { cols: cols })] })) : compactRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: "context is large" }), _jsxs(Text, { color: "gray", children: ["(~", compactRequest.messageCount, "k chars)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: status === 'thinking'
84
147
  ? _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", children: [SPARKLE[tick % SPARKLE.length], " "] }), _jsx(Text, { color: "gray", dimColor: true, italic: true, children: THINKING_PHRASES[phraseSeq[Math.floor(tick / 62) % phraseSeq.length]] })] })
85
- : _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, permissionRequest: permissionRequest, onPermissionResponse: resolvePermission, compactRequest: compactRequest, onCompactResponse: resolveCompact, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content) })] }));
148
+ : _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, permissionRequest: permissionRequest, onPermissionResponse: resolvePermission, compactRequest: compactRequest, onCompactResponse: resolveCompact, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content), watchActive: watchActive })] }));
86
149
  }
@@ -23,6 +23,7 @@ const BUILTIN_COMMANDS = [
23
23
  { ns: 'builtin', name: 'plan', description: 'enter planning mode — AI helps think through a goal step-by-step' },
24
24
  { ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor — plans, reads, then edits — /refactor <goal>' },
25
25
  { ns: 'builtin', name: 'think', description: 'deep research before answering — reads files + optional web — /think <query>' },
26
+ { ns: 'builtin', name: 'watch', description: 'watch for file changes, run tests, auto-fix failures — /watch stop to cancel' },
26
27
  // ── Git ───────────────────────────────────────────────────────────────────
27
28
  { ns: 'git', name: 'status', description: 'show git working tree status (modified, staged, untracked)' },
28
29
  { ns: 'git', name: 'diff', description: 'show unstaged changes as a diff' },
@@ -55,7 +56,7 @@ function wordEndAfter(line, col) {
55
56
  i++;
56
57
  return i;
57
58
  }
58
- export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [] }) {
59
+ export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, compactRequest, onCompactResponse, onSubmit, onAbort, history = [], watchActive = false }) {
59
60
  const [lines, setLines] = useState(['']);
60
61
  const [cursor, setCursor] = useState({ row: 0, col: 0 });
61
62
  const [overlay, setOverlay] = useState('none');
@@ -468,11 +469,15 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
468
469
  ? `history ${historyIdx + 1}/${history.length} ↑↓ navigate esc clear`
469
470
  : planningMode
470
471
  ? 'planning mode /plan:done exit'
471
- : '? for shortcuts';
472
+ : watchActive
473
+ ? 'watch active /watch stop to cancel'
474
+ : '? for shortcuts';
472
475
  const pastePreview = pasteRef.current
473
476
  ? pasteRef.current.split('\n')[0].slice(0, cols - 6)
474
477
  : '';
475
- return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_jsxs(Box, { gap: 3, children: [_jsx(Text, { color: "green", bold: true, children: "y once" }), _jsx(Text, { color: "cyan", bold: true, children: "a session" }), _jsx(Text, { color: "red", bold: true, children: "n deny" })] })) : compactRequest ? (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "green", bold: true, children: "y yes" }), _jsx(Text, { color: "red", bold: true, children: "n no" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (_jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
478
+ return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_jsxs(Box, { gap: 3, children: [_jsx(Text, { color: "green", bold: true, children: "y once" }), _jsx(Text, { color: "cyan", bold: true, children: "a session" }), _jsx(Text, { color: "red", bold: true, children: "n deny" })] })) : compactRequest ? (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "green", bold: true, children: "y yes" }), _jsx(Text, { color: "red", bold: true, children: "n no" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (watchActive && isActive
479
+ ? _jsxs(Text, { children: [_jsx(Text, { color: "cyan", dimColor: true, children: "watching\u2026 " }), _jsx(Text, { children: "\u2588" })] })
480
+ : _jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
476
481
  ? viewportLine(line, cursor.col, availWidth, isActive)
477
482
  : line.length > availWidth ? '…' + line.slice(line.length - availWidth + 1) : line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Text, { color: "gray", dimColor: true, children: [" ", hint] })] }));
478
483
  }
@@ -1,5 +1,3 @@
1
- import { readFile } from '../files/ops.js';
2
- import { resolve } from 'path';
3
1
  import { exec } from 'child_process';
4
2
  import { promisify } from 'util';
5
3
  const gitRun = promisify(exec);
@@ -14,44 +12,11 @@ export async function buildGitContext(cwd, lastStatusRef) {
14
12
  if (!status || status === lastStatusRef.current)
15
13
  return { prefix: '', label: '' };
16
14
  lastStatusRef.current = status;
17
- const MAX_TOTAL = 40_000;
18
- const MAX_FILE = 15_000;
19
- let total = 0;
20
- const parts = [];
21
- const skipped = [];
22
- for (const line of status.split('\n')) {
23
- const code = line.slice(0, 2);
24
- if (code.includes('D'))
25
- continue;
26
- const raw = line.slice(3).trim().replace(/^"|"$/g, '');
27
- const arrowIdx = raw.lastIndexOf(' -> ');
28
- const rel = arrowIdx !== -1 ? raw.slice(arrowIdx + 4) : raw;
29
- if (!rel)
30
- continue;
31
- try {
32
- const content = readFile(resolve(cwd, rel));
33
- if (!content || content.length > MAX_FILE) {
34
- skipped.push(rel);
35
- continue;
36
- }
37
- total += content.length;
38
- if (total > MAX_TOTAL) {
39
- skipped.push(rel);
40
- continue;
41
- }
42
- parts.push(`<file path="${rel}">\n${content}\n</file>`);
43
- }
44
- catch {
45
- skipped.push(rel);
46
- }
47
- }
48
- if (!parts.length && !skipped.length)
49
- return { prefix: '', label: '' };
50
- let prefix = '[Auto-context: git-changed files]\n' + parts.join('\n') + '\n';
51
- if (skipped.length)
52
- prefix += `Files changed but too large to auto-load: ${skipped.join(', ')}\n`;
53
- prefix += '\n';
54
- const label = `auto-loaded ${parts.length} changed file(s)${skipped.length ? `, skipped ${skipped.length} (too large)` : ''}`;
15
+ const files = status.split('\n')
16
+ .map(l => l.slice(3).trim().replace(/^"|"$/g, ''))
17
+ .filter(Boolean);
18
+ const prefix = `[Git: ${files.length} changed file(s)]\n${status}\n\n`;
19
+ const label = `git: ${files.length} changed file(s) in context`;
55
20
  return { prefix, label };
56
21
  }
57
22
  catch {
@@ -3,13 +3,18 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
3
3
  import { chat } from '../../llm/stream.js';
4
4
  import { tools as staticTools } from '../../tools/index.js';
5
5
  import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
6
- import { shouldCompact, compactContext } from '../../tasks/compactor.js';
6
+ import { shouldCompact, compactContext, contextSize } from '../../tasks/compactor.js';
7
7
  import * as printer from '../printer.js';
8
- const MAX_TOOL_DEPTH = 6;
8
+ const MAX_TOOL_DEPTH = 10;
9
9
  const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'patch_file', 'delete_file']);
10
10
  const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
11
11
  const PERMISSION_TOOLS = new Set(['edit_file', 'patch_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
12
12
  const CHECKPOINT_TOOLS = new Set(['edit_file', 'patch_file', 'create_file', 'delete_file']);
13
+ // Tool result messages that are ephemeral — never worth storing in memory or compact summaries
14
+ const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted/;
15
+ export function stripEphemeral(messages) {
16
+ return messages.filter(m => m.role !== 'user' || !EPHEMERAL_PATTERN.test(m.content));
17
+ }
13
18
  export function useRunLoop(config, currentModelRef, pushHistory, extraTools = [], abortRef) {
14
19
  const [status, setStatus] = useState('idle');
15
20
  const [tick, setTick] = useState(0);
@@ -57,11 +62,12 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
57
62
  if (shouldCompact(contextMsgs)) {
58
63
  const approved = await new Promise(resolve => {
59
64
  compactResolveRef.current = resolve;
60
- setCompactRequest({ messageCount: contextMsgs.length });
65
+ setCompactRequest({ messageCount: Math.round(contextSize(contextMsgs) / 1000) });
61
66
  });
62
67
  if (approved) {
63
68
  printer.systemMsg('compacting context…');
64
- msgs = await compactContext(contextMsgs, {
69
+ const toCompact = stripEphemeral(contextMsgs);
70
+ msgs = await compactContext(toCompact, {
65
71
  provider: config.provider,
66
72
  model: currentModelRef.current,
67
73
  baseUrl: config.baseUrl,
@@ -80,6 +86,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
80
86
  baseUrl: config.baseUrl,
81
87
  messages: msgs,
82
88
  signal: abortRef.current.signal,
89
+ onRetry(attempt, max, delayMs) {
90
+ printer.systemMsg(`retry ${attempt}/${max} — waiting ${Math.round(delayMs / 1000)}s`);
91
+ },
83
92
  async onDone(fullText) {
84
93
  const pendingTools = [];
85
94
  const textParts = [];
@@ -1,5 +1,5 @@
1
1
  import { useState, useRef, useEffect } from 'react';
2
- import { loadSession, saveSession, deleteSession } from '../../sessions.js';
2
+ import { getProjectDir, loadSession, saveSession, deleteSession } from '../../sessions.js';
3
3
  import { getSystemPrompt } from '../../tools/index.js';
4
4
  import { getTavilyKey, saveTavilyKey } from '../../tavily/client.js';
5
5
  import * as printer from '../printer.js';
@@ -10,6 +10,7 @@ function buildSystemPrompt(cwd, facts, extraTools = []) {
10
10
  return getSystemPrompt(`\n- CWD: ${cwd}`, extraTools) + formatMemoryBlock(facts);
11
11
  }
12
12
  export function useSession(initialSession, cwd, config, extraTools = []) {
13
+ const projectDir = getProjectDir(cwd);
13
14
  const [sessionName, setSessionName] = useState(initialSession);
14
15
  const sessionNameRef = useRef(initialSession);
15
16
  const historyRef = useRef([]);
@@ -17,43 +18,45 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
17
18
  const firstMessageSentRef = useRef(false);
18
19
  const longMemoryRef = useRef([]);
19
20
  const systemPromptRef = useRef(buildSystemPrompt(cwd, [], extraTools));
21
+ const extractingRef = useRef(false);
20
22
  useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
21
23
  useEffect(() => {
22
- const facts = loadLongMemory(initialSession);
24
+ const facts = loadLongMemory(projectDir);
23
25
  longMemoryRef.current = facts;
24
26
  systemPromptRef.current = buildSystemPrompt(cwd, facts, extraTools);
25
27
  if (facts.length)
26
- printer.systemMsg(`long memory: ${facts.length} facts loaded`);
27
- const history = loadSession(initialSession);
28
+ printer.systemMsg(`project memory: ${facts.length} facts`);
29
+ const history = loadSession(projectDir, initialSession);
28
30
  historyRef.current = history;
29
31
  if (history.length)
30
32
  printer.systemMsg(`resumed "${initialSession}" — ${history.length} messages`);
31
33
  if (config.tavilyApiKey && !getTavilyKey())
32
34
  saveTavilyKey(config.tavilyApiKey);
33
35
  if (!getTavilyKey()) {
34
- printer.systemMsg('Tavily API key not set — web search disabled. Run /tavily-key <key> to enable. Get a free key at https://tavily.com');
36
+ printer.systemMsg('Tavily API key not set — web search disabled. Run /config to enable.');
35
37
  }
36
38
  }, []);
37
39
  function scheduleSave() {
38
40
  if (saveTimerRef.current)
39
41
  clearTimeout(saveTimerRef.current);
40
42
  saveTimerRef.current = setTimeout(() => {
41
- saveSession(sessionNameRef.current, historyRef.current);
43
+ saveSession(projectDir, sessionNameRef.current, historyRef.current);
42
44
  saveTimerRef.current = null;
43
45
  }, 2000);
44
46
  }
45
47
  function pushHistory(msg) {
46
48
  historyRef.current.push(msg);
47
- if (historyRef.current.length > SHORT_MEMORY_SIZE) {
49
+ if (historyRef.current.length > SHORT_MEMORY_SIZE && !extractingRef.current) {
48
50
  const dropped = historyRef.current.splice(0, historyRef.current.length - SHORT_MEMORY_SIZE);
51
+ extractingRef.current = true;
49
52
  extractFacts(dropped, config, config.model).then(newFacts => {
50
- if (!newFacts.length)
51
- return;
52
- const updated = mergeFacts(longMemoryRef.current, newFacts);
53
- longMemoryRef.current = updated;
54
- systemPromptRef.current = buildSystemPrompt(cwd, updated, extraTools);
55
- saveLongMemory(sessionNameRef.current, updated);
56
- });
53
+ if (newFacts.length) {
54
+ const updated = mergeFacts(longMemoryRef.current, newFacts);
55
+ longMemoryRef.current = updated;
56
+ systemPromptRef.current = buildSystemPrompt(cwd, updated, extraTools);
57
+ saveLongMemory(projectDir, updated);
58
+ }
59
+ }).finally(() => { extractingRef.current = false; });
57
60
  }
58
61
  scheduleSave();
59
62
  }
@@ -74,7 +77,7 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
74
77
  sessionNameRef.current = slug;
75
78
  setSessionName(slug);
76
79
  try {
77
- deleteSession(oldName);
80
+ deleteSession(projectDir, oldName);
78
81
  }
79
82
  catch { }
80
83
  }
@@ -85,9 +88,19 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
85
88
  ctx.push(extra);
86
89
  return ctx;
87
90
  }
91
+ function updateMemory(newFacts) {
92
+ if (!newFacts.length)
93
+ return;
94
+ const updated = mergeFacts(longMemoryRef.current, newFacts);
95
+ longMemoryRef.current = updated;
96
+ systemPromptRef.current = buildSystemPrompt(cwd, updated, extraTools);
97
+ saveLongMemory(projectDir, updated);
98
+ }
88
99
  return {
100
+ projectDir,
89
101
  sessionName, setSessionName, sessionNameRef,
90
102
  historyRef, saveTimerRef, systemPromptRef,
91
- pushHistory, buildContext, renameFromMessage,
103
+ longMemoryRef,
104
+ pushHistory, buildContext, renameFromMessage, updateMemory,
92
105
  };
93
106
  }
@@ -4,6 +4,8 @@ import { getSystemPrompt } from '../../tools/index.js';
4
4
  import { saveConfig } from '../../config.js';
5
5
  import { loadSession, saveSession, listSessions, deleteSession, deleteAllSessions } from '../../sessions.js';
6
6
  import { compactContext } from '../../tasks/compactor.js';
7
+ import { extractFacts } from '../../memory/extractor.js';
8
+ import { stripEphemeral } from './useRunLoop.js';
7
9
  import { runDeepThink } from '../deepThink.js';
8
10
  import { buildGitContext, looksCodeRelated } from '../git-context.js';
9
11
  import { buildIndex } from '../../index/indexer.js';
@@ -35,7 +37,7 @@ export function useSubmit(deps) {
35
37
  const depsRef = useRef(deps);
36
38
  depsRef.current = deps;
37
39
  const handleSubmit = useCallback(async (text) => {
38
- const { config, skills, cwd, version, currentModelRef, setCurrentModel, historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef, setPlanningMode, runLoop, buildContext, pushHistory, setSessionName, renameFromMessage, setStatus, setTaskLabel, setCurrentTool, runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig, setConfigOpen, } = depsRef.current;
40
+ const { config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel, historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef, setPlanningMode, runLoop, buildContext, pushHistory, setSessionName, renameFromMessage, setStatus, setTaskLabel, setCurrentTool, runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig, setConfigOpen, updateMemory, startWatch, stopWatch, } = depsRef.current;
39
41
  const cmd = text.trim();
40
42
  if (cmd === '?') {
41
43
  printer.systemMsg('shortcuts:\n' +
@@ -164,23 +166,37 @@ export function useSubmit(deps) {
164
166
  return;
165
167
  }
166
168
  if (cmd === '/compact') {
167
- const full = buildContext();
168
- if (full.length <= 2) {
169
+ // Strip ephemeral tool noise (read_file, list_files, run_tests results, injected state)
170
+ const meaningful = stripEphemeral(historyRef.current);
171
+ if (!meaningful.length) {
169
172
  printer.systemMsg('nothing to compact');
170
173
  return;
171
174
  }
172
- printer.systemMsg(`compacting ${full.length} messages…`);
175
+ const before = historyRef.current.length;
176
+ printer.systemMsg(`compacting ${before} messages (${before - meaningful.length} ephemeral dropped)…`);
173
177
  setStatus('thinking');
174
178
  try {
175
- const compacted = await compactContext(full, {
179
+ const cfg = {
176
180
  provider: config.provider,
177
181
  model: currentModelRef.current,
178
182
  baseUrl: config.baseUrl,
179
183
  apiKey: config.apiKey,
180
- }, undefined);
181
- historyRef.current = compacted.filter(m => m.role !== 'system');
182
- saveSession(sessionNameRef.current, historyRef.current);
183
- printer.systemMsg(`compacted: ${full.length} ${compacted.length} messages`);
184
+ };
185
+ // Run both in parallel: LLM summary + fact extraction
186
+ const [compacted, facts] = await Promise.all([
187
+ compactContext([{ role: 'system', content: '' }, ...meaningful], cfg),
188
+ extractFacts(meaningful, config, currentModelRef.current),
189
+ ]);
190
+ // Update long-term memory with extracted facts
191
+ if (facts.length) {
192
+ updateMemory(facts);
193
+ printer.systemMsg(`memory: +${facts.length} fact${facts.length === 1 ? '' : 's'} saved`);
194
+ }
195
+ // Replace history with just the compact summary (no system msg)
196
+ const summaryOnly = compacted.filter(m => m.role !== 'system');
197
+ historyRef.current = summaryOnly;
198
+ saveSession(projectDir, sessionNameRef.current, summaryOnly);
199
+ printer.systemMsg(`compacted: ${before} → ${summaryOnly.length} message${summaryOnly.length === 1 ? '' : 's'}`);
184
200
  }
185
201
  catch (e) {
186
202
  printer.errorMsg(`compact failed: ${e}`);
@@ -195,7 +211,7 @@ export function useSubmit(deps) {
195
211
  clearTimeout(saveTimerRef.current);
196
212
  saveTimerRef.current = null;
197
213
  }
198
- saveSession(sessionNameRef.current, historyRef.current);
214
+ saveSession(projectDir, sessionNameRef.current, historyRef.current);
199
215
  const newName = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
200
216
  historyRef.current = [];
201
217
  setSessionName(newName);
@@ -206,7 +222,7 @@ export function useSubmit(deps) {
206
222
  }
207
223
  if (cmd === '/clear') {
208
224
  historyRef.current = [];
209
- saveSession(sessionNameRef.current, []);
225
+ saveSession(projectDir, sessionNameRef.current, []);
210
226
  setPlanningMode(false);
211
227
  systemPromptRef.current = getSystemPrompt(`\n- CWD: ${cwd}`, mcpTools);
212
228
  printer.systemMsg('chat cleared');
@@ -295,12 +311,22 @@ export function useSubmit(deps) {
295
311
  }
296
312
  }
297
313
  if (cmd === '/sessions') {
298
- const sessions = listSessions();
314
+ const sessions = listSessions(projectDir);
315
+ const shortCwd = cwd.replace(process.env.HOME ?? '', '~');
299
316
  if (!sessions.length) {
300
- printer.systemMsg('no saved sessions');
317
+ printer.systemMsg(`project: ${shortCwd}\nno saved sessions\n\n` +
318
+ ` /new start fresh session\n` +
319
+ ` /session <name> switch to session\n` +
320
+ ` /session delete <name> delete session\n` +
321
+ ` /session delete all delete all sessions in this project`);
301
322
  return;
302
323
  }
303
- printer.systemMsg(sessions.map(s => `${s.name === sessionNameRef.current ? '▶ ' : ' '}${s.name} (${s.messageCount} msgs)`).join('\n'));
324
+ const rows = sessions.map(s => ` ${s.name === sessionNameRef.current ? '▶' : ' '} ${s.name.padEnd(32)} ${s.messageCount} msg${s.messageCount === 1 ? '' : 's'}`).join('\n');
325
+ printer.systemMsg(`project: ${shortCwd} (${sessions.length} session${sessions.length === 1 ? '' : 's'})\n${rows}\n\n` +
326
+ ` /session <name> switch session\n` +
327
+ ` /session delete <name> delete session\n` +
328
+ ` /session delete all delete all sessions in this project\n` +
329
+ ` /new start fresh session`);
304
330
  return;
305
331
  }
306
332
  if (cmd.startsWith('/session')) {
@@ -316,8 +342,8 @@ export function useSubmit(deps) {
316
342
  return;
317
343
  }
318
344
  if (target === 'all') {
319
- const count = deleteAllSessions(sessionNameRef.current);
320
- printer.systemMsg(`deleted ${count} session(s) — kept active: ${sessionNameRef.current}`);
345
+ const count = deleteAllSessions(projectDir, sessionNameRef.current);
346
+ printer.systemMsg(`deleted ${count} session${count === 1 ? '' : 's'} — kept active: ${sessionNameRef.current}`);
321
347
  return;
322
348
  }
323
349
  if (target === sessionNameRef.current) {
@@ -325,7 +351,7 @@ export function useSubmit(deps) {
325
351
  return;
326
352
  }
327
353
  try {
328
- deleteSession(target);
354
+ deleteSession(projectDir, target);
329
355
  printer.systemMsg(`deleted: ${target}`);
330
356
  }
331
357
  catch (e) {
@@ -337,10 +363,10 @@ export function useSubmit(deps) {
337
363
  clearTimeout(saveTimerRef.current);
338
364
  saveTimerRef.current = null;
339
365
  }
340
- saveSession(sessionNameRef.current, historyRef.current);
341
- historyRef.current = loadSession(arg);
366
+ saveSession(projectDir, sessionNameRef.current, historyRef.current);
367
+ historyRef.current = loadSession(projectDir, arg);
342
368
  setSessionName(arg);
343
- printer.systemMsg(`session → ${arg} (${historyRef.current.length} messages)`);
369
+ printer.systemMsg(`session → ${arg} (${historyRef.current.length} message${historyRef.current.length === 1 ? '' : 's'})`);
344
370
  return;
345
371
  }
346
372
  if (cmd === '/index' || cmd.startsWith('/index ')) {
@@ -405,6 +431,15 @@ export function useSubmit(deps) {
405
431
  printer.systemMsg('usage: /index build | /index status | /index search <query> | /index clear');
406
432
  return;
407
433
  }
434
+ if (cmd === '/watch' || cmd.startsWith('/watch ')) {
435
+ const sub = cmd.slice(6).trim();
436
+ if (sub === 'stop') {
437
+ stopWatch();
438
+ return;
439
+ }
440
+ startWatch();
441
+ return;
442
+ }
408
443
  if (text.startsWith('/')) {
409
444
  const [slashCmd, ...rest] = text.slice(1).split(' ');
410
445
  const skill = skills.get(slashCmd);
@@ -0,0 +1,119 @@
1
+ import { useRef, useState, useCallback, useEffect } from 'react';
2
+ import { watch } from 'fs';
3
+ import { tools as staticTools } from '../../tools/index.js';
4
+ import * as printer from '../printer.js';
5
+ const WATCH_DEBOUNCE_MS = 600;
6
+ const IGNORE_DIRS = new Set([
7
+ 'node_modules', '.git', 'dist', '.next', 'build', 'coverage',
8
+ '__pycache__', '.turbo', '.cache', '.parcel-cache', 'out',
9
+ ]);
10
+ const WATCH_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cpp|h|hpp|css|scss)$/;
11
+ function testsFailed(output) {
12
+ // Explicit pass with no failures → passing
13
+ if (/\b0 fail/i.test(output))
14
+ return false;
15
+ if (/\d+ pass/i.test(output) && !/\d+ fail/i.test(output))
16
+ return false;
17
+ return /\d+ fail|FAIL\b|✕|✗|\bfailing\b|AssertionError/i.test(output);
18
+ }
19
+ export function useWatch(cwd, deps) {
20
+ const [watchActive, setWatchActive] = useState(false);
21
+ const watcherRef = useRef(null);
22
+ const debounceRef = useRef(null);
23
+ const changedRef = useRef(new Set());
24
+ const fixRunningRef = useRef(false);
25
+ // Always-fresh deps via ref — watcher callback is set up once
26
+ const depsRef = useRef(deps);
27
+ useEffect(() => { depsRef.current = deps; });
28
+ const stopWatch = useCallback(() => {
29
+ if (debounceRef.current) {
30
+ clearTimeout(debounceRef.current);
31
+ debounceRef.current = null;
32
+ }
33
+ watcherRef.current?.close();
34
+ watcherRef.current = null;
35
+ changedRef.current.clear();
36
+ fixRunningRef.current = false;
37
+ setWatchActive(false);
38
+ printer.systemMsg('watch: stopped');
39
+ }, []);
40
+ const startWatch = useCallback(() => {
41
+ if (watcherRef.current) {
42
+ printer.systemMsg('watch: already active — /watch stop to cancel');
43
+ return;
44
+ }
45
+ let watcher;
46
+ try {
47
+ watcher = watch(cwd, { recursive: true }, (_event, filename) => {
48
+ if (!filename)
49
+ return;
50
+ const parts = filename.split('/');
51
+ if (parts.some(p => IGNORE_DIRS.has(p) || p.startsWith('.')))
52
+ return;
53
+ if (!WATCH_EXT.test(filename))
54
+ return;
55
+ changedRef.current.add(filename);
56
+ if (debounceRef.current)
57
+ clearTimeout(debounceRef.current);
58
+ debounceRef.current = setTimeout(async () => {
59
+ debounceRef.current = null;
60
+ const changed = [...changedRef.current];
61
+ changedRef.current.clear();
62
+ const testTool = staticTools.find(t => t.name === 'run_tests');
63
+ if (!testTool)
64
+ return;
65
+ const label = changed.length > 3
66
+ ? `${changed.slice(0, 3).join(', ')} +${changed.length - 3} more`
67
+ : changed.join(', ');
68
+ printer.systemMsg(`watch: ${label} — running tests`);
69
+ if (fixRunningRef.current) {
70
+ printer.systemMsg('watch: fix in progress — skipping this cycle');
71
+ return;
72
+ }
73
+ fixRunningRef.current = true;
74
+ try {
75
+ const result = await testTool.execute({});
76
+ if (!result || result.startsWith('(no '))
77
+ return;
78
+ if (testsFailed(result)) {
79
+ printer.systemMsg('watch: tests failing — triggering fix');
80
+ const { pushHistory, buildContext, runLoop } = depsRef.current;
81
+ const fixMsg = `Tests are failing after changes to: ${changed.join(', ')}\n\n` +
82
+ `Test output:\n${result}\n\n` +
83
+ `Read the failing files and fix the issues.`;
84
+ pushHistory({ role: 'user', content: fixMsg });
85
+ await runLoop(buildContext(), 0, 'fix failing tests');
86
+ }
87
+ else {
88
+ printer.systemMsg('watch: tests passing');
89
+ }
90
+ }
91
+ catch (e) {
92
+ printer.errorMsg(`watch: ${e}`);
93
+ }
94
+ finally {
95
+ fixRunningRef.current = false;
96
+ }
97
+ }, WATCH_DEBOUNCE_MS);
98
+ });
99
+ }
100
+ catch (e) {
101
+ printer.errorMsg(`watch: failed to start: ${e}`);
102
+ return;
103
+ }
104
+ watcher.on('error', (err) => {
105
+ printer.errorMsg(`watch: ${err.message}`);
106
+ stopWatch();
107
+ });
108
+ watcherRef.current = watcher;
109
+ setWatchActive(true);
110
+ printer.systemMsg(`watch: active — monitoring ${cwd.replace(process.env.HOME ?? '', '~')}`);
111
+ }, [cwd, stopWatch]);
112
+ // Cleanup on unmount
113
+ useEffect(() => () => {
114
+ watcherRef.current?.close();
115
+ if (debounceRef.current)
116
+ clearTimeout(debounceRef.current);
117
+ }, []);
118
+ return { watchActive, startWatch, stopWatch };
119
+ }
@@ -24,9 +24,6 @@ const gray = (s) => col(90, s);
24
24
  const yellow = (s) => col(93, s);
25
25
  const purple = (s) => col(95, s);
26
26
  const red = (s) => col(91, s);
27
- function indent(text, pad = ' ') {
28
- return text.split('\n').map(l => pad + l).join('\n');
29
- }
30
27
  function stripMarkdown(s) {
31
28
  return s
32
29
  .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
@@ -104,7 +101,6 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
104
101
  const top = gray('╭') + gray('─') + bold(cyan(` MIII - CLI${versionStr} `)) + gray('─'.repeat(dashCount) + '╮');
105
102
  const bottom = gray('╰' + '─'.repeat(innerW) + '╯');
106
103
  const shortCwd = cwd.replace(process.env.HOME ?? '', '~');
107
- const username = process.env.USER ?? 'there';
108
104
  const miniArt = [
109
105
  ` ${purple(' ● ● ')}`,
110
106
  ` ${purple(' ╱ ╲ ╱ ╲ ')}`,
@@ -124,7 +120,7 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
124
120
  ` ${bold(yellow('Tips for getting started'))}`,
125
121
  ` Type ${cyan('@filename')} to inject file into context`,
126
122
  ` Use ${cyan('/skill')} to run a skill or command`,
127
- ` Use ${cyan('/models')} to switch or pull models`,
123
+ ` Use ${cyan('/config')} to switch provider, model, or API key`,
128
124
  '',
129
125
  ];
130
126
  const maxLen = Math.max(leftLines.length, rightLines.length);
@@ -266,7 +262,7 @@ export function toolResultSummary(name, args, result) {
266
262
  if (summary)
267
263
  write(gray(` ${summary}`) + '\n');
268
264
  }
269
- export function toolMsg(name, result) {
265
+ export function toolMsg(_name, result) {
270
266
  const preview = result.length > 600 ? result.slice(0, 600) + '…' : result;
271
267
  const body = preview.trim()
272
268
  ? preview.split('\n').map(l => gray(' ' + l)).join('\n')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "The high-performance local AI coding agent for your terminal. Automate complex workflows with local LLMs.",
6
6
  "license": "MIT",