miii-cli 1.1.2 → 1.1.3

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.
@@ -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;
@@ -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,29 @@ 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
+ ensureProjectDir(projectDir);
48
56
  try {
49
- writeFileSync(join(SESSIONS_DIR, `${sanitizeName(name)}.json`), JSON.stringify(messages), { mode: 0o600 });
57
+ writeFileSync(join(sessionsDir(projectDir), `${sanitizeName(name)}.json`), JSON.stringify(messages), { mode: 0o600 });
50
58
  }
51
59
  catch { }
52
60
  }
53
- export function deleteSession(name) {
54
- const p = join(SESSIONS_DIR, `${sanitizeName(name)}.json`);
61
+ export function deleteSession(projectDir, name) {
62
+ const p = join(sessionsDir(projectDir), `${sanitizeName(name)}.json`);
55
63
  if (existsSync(p))
56
64
  unlinkSync(p);
57
65
  }
58
- export function deleteAllSessions(exceptName) {
59
- ensureDir();
60
- const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
66
+ export function deleteAllSessions(projectDir, exceptName) {
67
+ ensureProjectDir(projectDir);
68
+ const dir = sessionsDir(projectDir);
69
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
61
70
  let count = 0;
62
71
  for (const f of files) {
63
72
  const name = f.replace('.json', '');
64
73
  if (exceptName && name === exceptName)
65
74
  continue;
66
75
  try {
67
- unlinkSync(join(SESSIONS_DIR, f));
76
+ unlinkSync(join(dir, f));
68
77
  count++;
69
78
  }
70
79
  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);
@@ -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';
@@ -21,6 +21,7 @@ import { setInkInstance } from './printer.js';
21
21
  import { createSearchCodebaseTool } from '../index/tool.js';
22
22
  import { saveConfig } from '../config.js';
23
23
  import { getTavilyKey, saveTavilyKey } from '../tavily/client.js';
24
+ import { warmup } from '../llm/stream.js';
24
25
  function formatElapsed(ms) {
25
26
  const s = Math.floor(ms / 1000);
26
27
  if (s < 60)
@@ -29,11 +30,27 @@ function formatElapsed(ms) {
29
30
  const rem = s % 60;
30
31
  return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
31
32
  }
33
+ const MAX_DIFF_LINES = 5;
34
+ function DiffPreview({ toolName, args }) {
35
+ if (toolName === 'patch_file' && (args.old || args.new)) {
36
+ const oldLines = String(args.old ?? '').split('\n');
37
+ const newLines = String(args.new ?? '').split('\n');
38
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [oldLines.slice(0, MAX_DIFF_LINES).map((line, i) => (_jsxs(Text, { color: "red", dimColor: true, children: ["- ", line.slice(0, 72)] }, `o${i}`))), oldLines.length > MAX_DIFF_LINES && (_jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", oldLines.length - MAX_DIFF_LINES, " more"] })), newLines.slice(0, MAX_DIFF_LINES).map((line, i) => (_jsxs(Text, { color: "green", dimColor: true, children: ["+ ", line.slice(0, 72)] }, `n${i}`))), newLines.length > MAX_DIFF_LINES && (_jsxs(Text, { color: "gray", dimColor: true, children: [" \u2026", newLines.length - MAX_DIFF_LINES, " more"] }))] }));
39
+ }
40
+ if ((toolName === 'edit_file' || toolName === 'create_file') && args.content) {
41
+ const n = String(args.content).split('\n').length;
42
+ return (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "gray", dimColor: true, children: [n, " line", n === 1 ? '' : 's'] }) }));
43
+ }
44
+ return null;
45
+ }
32
46
  export function InputBar({ config: initialConfig, skills, cwd, session, version, mcpTools = [] }) {
33
47
  const [config, setConfig] = useState(initialConfig);
34
48
  const { stdout, write: stdoutWrite } = useStdout();
35
49
  const cols = stdout.columns ?? 80;
36
- useEffect(() => { setInkInstance(stdoutWrite); }, []);
50
+ useEffect(() => {
51
+ setInkInstance(stdoutWrite);
52
+ warmup(initialConfig.provider, initialConfig.baseUrl, initialConfig.model);
53
+ }, []);
37
54
  const phraseSeq = useMemo(() => Array.from({ length: 100 }, () => Math.floor(Math.random() * THINKING_PHRASES.length)), []);
38
55
  const [planningMode, setPlanningMode] = useState(false);
39
56
  const [configOpen, setConfigOpen] = useState(false);
@@ -42,8 +59,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
42
59
  const executorRef = useRef(new TaskExecutor(tools));
43
60
  const lastGitStatusRef = useRef('');
44
61
  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]);
62
+ const { projectDir, setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, updateMemory, } = useSession(session, cwd, config, mcpTools);
47
63
  const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, handleModelSelect, handleModelPull, } = useModelPicker(config);
48
64
  const deepThinkTool = useMemo(() => ({
49
65
  name: 'deep_think',
@@ -64,13 +80,13 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
64
80
  });
65
81
  const { handleGit } = useGit({ pushHistory, buildContext, runLoop });
66
82
  const { handleSubmit } = useSubmit({
67
- config, skills, cwd, version, currentModelRef, setCurrentModel,
83
+ config, skills, cwd, projectDir, version, currentModelRef, setCurrentModel,
68
84
  historyRef, sessionNameRef, saveTimerRef, systemPromptRef, abortRef,
69
85
  setPlanningMode, runLoop, buildContext, pushHistory,
70
86
  setSessionName, renameFromMessage,
71
87
  setStatus, setTaskLabel, setCurrentTool,
72
88
  runRefactor, handleGit, lastGitStatusRef, mcpTools, setConfig,
73
- setConfigOpen,
89
+ setConfigOpen, updateMemory,
74
90
  });
75
91
  const skillList = skills.list();
76
92
  return (_jsxs(Box, { flexDirection: "column", children: [configOpen ? (_jsxs(_Fragment, { children: [_jsx(ConfigPicker, { config: config, currentModel: currentModel, tavilyKey: tavilyKey, onUpdate: ({ model, ...configPatch }) => {
@@ -80,7 +96,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
80
96
  setConfig(c => ({ ...c, ...configPatch }));
81
97
  saveConfig(configPatch);
82
98
  }
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'
99
+ }, 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
100
  ? _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
101
  : _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) })] }));
86
102
  }
@@ -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, } = 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 ')) {
@@ -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.1.3",
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",