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.
- package/dist/llm/stream.js +67 -12
- package/dist/memory/store.js +9 -11
- package/dist/sessions.js +29 -20
- package/dist/tasks/compactor.js +14 -4
- package/dist/tui/InputBar.js +25 -9
- package/dist/tui/git-context.js +5 -40
- package/dist/tui/hooks/useRunLoop.js +13 -4
- package/dist/tui/hooks/useSession.js +29 -16
- package/dist/tui/hooks/useSubmit.js +46 -20
- package/dist/tui/printer.js +2 -6
- package/package.json +1 -1
package/dist/llm/stream.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
});
|
|
199
|
+
}, signal, onRetry);
|
|
145
200
|
if (!res.ok) {
|
|
146
201
|
onError(new Error(`Anthropic ${res.status}: ${await res.text()}`));
|
|
147
202
|
return;
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
|
7
|
-
|
|
4
|
+
function memoryPath(projectDir) {
|
|
5
|
+
return join(projectDir, 'memory.json');
|
|
8
6
|
}
|
|
9
|
-
export function loadLongMemory(
|
|
10
|
-
|
|
11
|
-
const p =
|
|
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(
|
|
23
|
-
|
|
24
|
-
writeFileSync(
|
|
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
|
|
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
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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(
|
|
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
|
-
|
|
35
|
-
const p = join(
|
|
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
|
-
|
|
54
|
+
export function saveSession(projectDir, name, messages) {
|
|
55
|
+
ensureProjectDir(projectDir);
|
|
48
56
|
try {
|
|
49
|
-
writeFileSync(join(
|
|
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(
|
|
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
|
-
|
|
60
|
-
const
|
|
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(
|
|
76
|
+
unlinkSync(join(dir, f));
|
|
68
77
|
count++;
|
|
69
78
|
}
|
|
70
79
|
catch { }
|
package/dist/tasks/compactor.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { chat } from '../llm/stream.js';
|
|
2
|
-
|
|
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
|
|
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
|
|
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);
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useState, useRef, useMemo, useEffect
|
|
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
|
|
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(() => {
|
|
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, "
|
|
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
|
}
|
package/dist/tui/git-context.js
CHANGED
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
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 =
|
|
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
|
|
65
|
+
setCompactRequest({ messageCount: Math.round(contextSize(contextMsgs) / 1000) });
|
|
61
66
|
});
|
|
62
67
|
if (approved) {
|
|
63
68
|
printer.systemMsg('compacting context…');
|
|
64
|
-
|
|
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(
|
|
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(`
|
|
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 /
|
|
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 (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
179
|
+
const cfg = {
|
|
176
180
|
provider: config.provider,
|
|
177
181
|
model: currentModelRef.current,
|
|
178
182
|
baseUrl: config.baseUrl,
|
|
179
183
|
apiKey: config.apiKey,
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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}
|
|
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 ')) {
|
package/dist/tui/printer.js
CHANGED
|
@@ -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('/
|
|
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(
|
|
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')
|