miii-cli 0.2.9 → 0.3.1
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 +66 -39
- package/dist/__tests__/integration.test.js +50 -0
- package/dist/init.js +4 -3
- package/dist/llm/stream.js +78 -10
- package/dist/memory/extractor.js +44 -0
- package/dist/memory/store.js +41 -0
- package/dist/tavily/client.js +64 -0
- package/dist/tools/index.js +5 -1
- package/dist/tui/InputBar.js +73 -5
- package/dist/tui/components/InputArea.js +51 -13
- package/dist/tui/deepThink.js +94 -0
- package/dist/tui/git-context.js +59 -0
- package/dist/tui/hooks/useModelPicker.js +63 -0
- package/dist/tui/hooks/useRunLoop.js +173 -0
- package/dist/tui/hooks/useSession.js +93 -0
- package/dist/tui/printer.js +16 -8
- package/dist/tui/thinking.js +53 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,62 +9,90 @@
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
## 📊 The Competitive Edge
|
|
13
|
+
|
|
14
|
+
| Feature | **Miii** | Claude Code | Codex CLI | Aider |
|
|
15
|
+
|---|---|---|---|---|
|
|
16
|
+
| **Execution Environment** | ✅ Local / Hybrid | ❌ Cloud only | ❌ Cloud only | ✅ Local + cloud |
|
|
17
|
+
| **Data Privacy** | ✅ Air-gapped possible | ❌ Cloud-streamed | ❌ Cloud-streamed | ⚠️ Model-dependent |
|
|
18
|
+
| **Cost Structure** | 🆓 Free (Your Compute) | 💳 Token-based | 💳 Token-based | 🆓 Free (local) |
|
|
19
|
+
| **Runtime Efficiency** | ⚡ TS (Instant Start) | 🐍 Node (Fast) | 🐍 Node | 🐢 Python (Heavy) |
|
|
20
|
+
| **Research Engine** | ✅ Deep Think Mode | ❌ | ❌ | ❌ |
|
|
21
|
+
| **Validation Loop** | ✅ Auto-test (Jest/Vitest) | ⚠️ Manual | ❌ | ⚠️ Manual |
|
|
22
|
+
| **Live Web Access** | ✅ Tavily Integrated | ❌ | ❌ | ❌ |
|
|
23
|
+
| **Edit Precision** | ✅ Surgical `patch_file` | ✅ | ⚠️ | ✅ |
|
|
24
|
+
| **State Persistence** | ✅ Named Sessions | ✅ | ❌ | ⚠️ Basic |
|
|
25
|
+
| **Extensibility** | ✅ npm + `.md` Skills | ⚠️ MCP only | ❌ | ❌ |
|
|
26
|
+
| **License** | ✅ MIT | ❌ | ❌ | ✅ Apache 2.0 |
|
|
27
|
+
|
|
28
|
+
> ✅ = Native | ⚠️ = Partial | ❌ = Unsupported
|
|
25
29
|
|
|
26
30
|
## ⚡️ Quick Start
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
Deploy Miii in your environment in 30 seconds:
|
|
29
33
|
|
|
30
34
|
```bash
|
|
35
|
+
# 1. Pull a capable local model
|
|
31
36
|
ollama pull qwen2.5-coder:7b
|
|
37
|
+
|
|
38
|
+
# 2. Install the CLI globally
|
|
32
39
|
npm install -g miii-cli
|
|
40
|
+
|
|
41
|
+
# 3. Start engineering
|
|
33
42
|
miii
|
|
34
43
|
```
|
|
35
44
|
|
|
36
45
|
## 🧠 Why Miii?
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
The industry is saturated with heavy Python wrappers and expensive monthly subscriptions that trade your intellectual property for convenience. **Miii breaks this cycle.**
|
|
39
48
|
|
|
40
|
-
- **
|
|
41
|
-
- **
|
|
42
|
-
- **
|
|
43
|
-
- **
|
|
49
|
+
- **Privacy by Default**: Your codebase never leaves your machine. Period.
|
|
50
|
+
- **Zero Latency**: Built with TypeScript for near-instant startup. No virtual environments, no dependency hell, just raw performance.
|
|
51
|
+
- **True Autonomy**: Miii isn't a chatbot; it's a junior engineer. It plans, edits, runs tests, and iterates until the PR is ready.
|
|
52
|
+
- **Architectural Intelligence**: By analyzing git diffs and project structure, Miii understands context without requiring manual copy-pasting.
|
|
44
53
|
|
|
45
54
|
## 🔥 Killer Features
|
|
46
55
|
|
|
47
|
-
- **🛠 Precision
|
|
48
|
-
- **🔄
|
|
49
|
-
- **🌐
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
56
|
+
- **🛠 Surgical Precision**: Instead of overwriting files, Miii uses `patch_file` to inject changes, preserving your formatting and reducing token waste.
|
|
57
|
+
- **🔄 The Self-Healing Loop**: Miii executes your test suite (Jest, Vitest, Mocha) after every change. If a test fails, it analyzes the trace and fixes the code autonomously.
|
|
58
|
+
- **🌐 Real-time Intelligence**: Integrated `web_search` and `web_extract` via Tavily allow Miii to reference the latest documentation and API changes.
|
|
59
|
+
- **🧠 Deep Think Engine**: A sophisticated two-phase research mode that gathers data before synthesizing a solution.
|
|
60
|
+
- **📐 Strategic Planning**: Use `/plan` to map out complex refactors before a single character is typed.
|
|
61
|
+
- **📂 Persistent Context**: Workflows are saved as named sessions. Jump back into a specific feature branch with `miii --session feature-auth`.
|
|
62
|
+
- **📦 Modular Skill System**: Extend Miii's capabilities using npm plugins or simple Markdown-based skill files.
|
|
63
|
+
|
|
64
|
+
## 🔬 Deep Think Explained
|
|
65
|
+
|
|
66
|
+
Deep Think is a recursive research engine designed to eliminate "hallucinations" by grounding the AI in facts:
|
|
67
|
+
|
|
68
|
+
1. **Gather Phase**: A constrained, read-only loop utilizing `read_file`, `list_files`, `git_status`, `git_log`, `git_diff`, and web tools.
|
|
69
|
+
- **Guardrails**: Strict limit of 6 tool calls and 4 web calls to prevent infinite loops.
|
|
70
|
+
- **Safety**: Zero write permissions. No mutations.
|
|
71
|
+
2. **Synthesize Phase**: All gathered intelligence is aggregated and fed into the main execution loop for a grounded, verified response.
|
|
72
|
+
|
|
73
|
+
**Trigger Research:**
|
|
74
|
+
```bash
|
|
75
|
+
/think "How does the auth middleware handle token expiry?"
|
|
76
|
+
/think "Analyze the project structure and explain the data flow."
|
|
77
|
+
/think "What are the breaking changes in React 19 for this project?"
|
|
78
|
+
```
|
|
79
|
+
*The LLM also triggers `deep_think` autonomously when it detects a high-complexity query.*
|
|
53
80
|
|
|
54
81
|
## ⌨️ Command Cheat Sheet
|
|
55
82
|
|
|
56
|
-
| Command |
|
|
57
|
-
|
|
58
|
-
| `/
|
|
59
|
-
| `/
|
|
60
|
-
| `/
|
|
61
|
-
| `/
|
|
62
|
-
| `/
|
|
63
|
-
| `/
|
|
83
|
+
| Command | Purpose | Impact |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `/think <query>` | Deep Research | High-fidelity synthesis of files + web |
|
|
86
|
+
| `/refactor <goal>` | Full-scale Engineering | Plan $\rightarrow$ Edit $\rightarrow$ Test loop |
|
|
87
|
+
| `/git <sub>` | Git Automation | Instant status, diffs, and AI commit messages |
|
|
88
|
+
| `/plan` | Architecture Mode | Structured blueprinting before coding |
|
|
89
|
+
| `/model <name>` | LLM Hot-swap | Switch models instantly based on task |
|
|
90
|
+
| `/tavily-key <key>` | Enable Web Access | Unlocks real-time internet browsing |
|
|
91
|
+
| `/sessions` | Context Recovery | Resume previous engineering sessions |
|
|
64
92
|
|
|
65
93
|
## ⚙️ Configuration
|
|
66
94
|
|
|
67
|
-
|
|
95
|
+
Fine-tune your agent in `.miii.json` or `~/.config/miii/config.json`:
|
|
68
96
|
|
|
69
97
|
```json
|
|
70
98
|
{
|
|
@@ -85,13 +113,12 @@ cd miii-cli && npm install && npm run build && npm link
|
|
|
85
113
|
|
|
86
114
|
## 🌟 Community & Philosophy
|
|
87
115
|
|
|
88
|
-
**
|
|
116
|
+
**Stop renting your intelligence. Own your AI stack.**
|
|
89
117
|
|
|
90
|
-
|
|
118
|
+
Miii is built for engineers who value privacy, speed, and total control. If this tool has accelerated your workflow, support the project:
|
|
91
119
|
- 🌟 **Star the repo** on GitHub
|
|
92
|
-
- 🐦 **Share on X
|
|
93
|
-
- 🤖 **
|
|
94
|
-
- 💬 **Tell a fellow developer**
|
|
120
|
+
- 🐦 **Share your wins** on X
|
|
121
|
+
- 🤖 **Discuss the future** on Reddit
|
|
95
122
|
|
|
96
123
|
## 📜 License
|
|
97
124
|
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync, unlinkSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { looksCodeRelated } from '../tui/git-context.js';
|
|
5
|
+
import { tools } from '../tools/index.js';
|
|
6
|
+
// patch_file uses guardPath which restricts to CWD — use a local scratch file
|
|
7
|
+
const SCRATCH = join(process.cwd(), '.miii-test-scratch.txt');
|
|
8
|
+
// ─── looksCodeRelated ─────────────────────────────────────────────────────────
|
|
9
|
+
describe('looksCodeRelated', () => {
|
|
10
|
+
it('true: file extension in message', () => {
|
|
11
|
+
expect(looksCodeRelated('fix the bug in auth.ts')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it('true: code keyword present', () => {
|
|
14
|
+
expect(looksCodeRelated('refactor the user login function')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it('true: backtick token', () => {
|
|
17
|
+
expect(looksCodeRelated('what does `useEffect` do')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it('false: too short', () => {
|
|
20
|
+
expect(looksCodeRelated('hi')).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
it('false: plain prose, no code signal', () => {
|
|
23
|
+
expect(looksCodeRelated('what is the weather like in london today')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
// ─── patch_file ───────────────────────────────────────────────────────────────
|
|
27
|
+
describe('patch_file', () => {
|
|
28
|
+
const patchTool = tools.find(t => t.name === 'patch_file');
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
try {
|
|
31
|
+
unlinkSync(SCRATCH);
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
});
|
|
35
|
+
it('applies a unique patch correctly', async () => {
|
|
36
|
+
writeFileSync(SCRATCH, 'hello world\ngoodbye world\n');
|
|
37
|
+
await patchTool.execute({ path: SCRATCH, old: 'hello world', new: 'hello earth' });
|
|
38
|
+
expect(readFileSync(SCRATCH, 'utf-8')).toBe('hello earth\ngoodbye world\n');
|
|
39
|
+
});
|
|
40
|
+
it('throws when old text not found', async () => {
|
|
41
|
+
writeFileSync(SCRATCH, 'hello world\n');
|
|
42
|
+
await expect(patchTool.execute({ path: SCRATCH, old: 'no such text', new: 'x' }))
|
|
43
|
+
.rejects.toThrow('old text not found');
|
|
44
|
+
});
|
|
45
|
+
it('throws on ambiguous match (2+ occurrences)', async () => {
|
|
46
|
+
writeFileSync(SCRATCH, 'hello world\nhello world\n');
|
|
47
|
+
await expect(patchTool.execute({ path: SCRATCH, old: 'hello world', new: 'hi' }))
|
|
48
|
+
.rejects.toThrow('ambiguous');
|
|
49
|
+
});
|
|
50
|
+
});
|
package/dist/init.js
CHANGED
|
@@ -9,7 +9,7 @@ import { execSync } from 'child_process';
|
|
|
9
9
|
import { loadConfig } from './config.js';
|
|
10
10
|
import { SkillLoader } from './skills/loader.js';
|
|
11
11
|
import { InputBar } from './tui/InputBar.js';
|
|
12
|
-
import { welcome } from './tui/printer.js';
|
|
12
|
+
import { welcome, setInkInstance } from './tui/printer.js';
|
|
13
13
|
import { ensureOllama } from './llm/ollama.js';
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
15
15
|
const UPDATE_CACHE = join(homedir(), '.config', 'miii', 'update-check.json');
|
|
@@ -89,7 +89,8 @@ export async function lazyInit() {
|
|
|
89
89
|
]);
|
|
90
90
|
// Print welcome banner to scrollback BEFORE Ink starts
|
|
91
91
|
welcome(config.provider, config.model, process.cwd(), currentVersion, updateAvailable, linked);
|
|
92
|
-
const sessionName = argv.session ||
|
|
93
|
-
const { waitUntilExit } = render(React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName, version: currentVersion }), { exitOnCtrlC: false });
|
|
92
|
+
const sessionName = argv.session || `s-${Date.now()}`;
|
|
93
|
+
const { waitUntilExit, clear } = render(React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName, version: currentVersion }), { exitOnCtrlC: false });
|
|
94
|
+
setInkInstance(clear);
|
|
94
95
|
await waitUntilExit();
|
|
95
96
|
}
|
package/dist/llm/stream.js
CHANGED
|
@@ -4,21 +4,57 @@ export async function chat(cfg) {
|
|
|
4
4
|
return chatOllama(cfg);
|
|
5
5
|
}
|
|
6
6
|
async function chatOllama(cfg) {
|
|
7
|
-
const { model, messages, baseUrl, signal, onDone, onError, onUsage } = cfg;
|
|
7
|
+
const { model, messages, baseUrl, signal, onDone, onError, onUsage, onChunk } = cfg;
|
|
8
8
|
try {
|
|
9
9
|
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
10
10
|
method: 'POST',
|
|
11
11
|
headers: { 'Content-Type': 'application/json' },
|
|
12
|
-
body: JSON.stringify({ model, messages, stream:
|
|
12
|
+
body: JSON.stringify({ model, messages, stream: !!onChunk }),
|
|
13
13
|
signal,
|
|
14
14
|
});
|
|
15
15
|
if (!res.ok) {
|
|
16
16
|
onError(new Error(`Ollama ${res.status}: ${await res.text()}`));
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
if (!onChunk) {
|
|
20
|
+
const obj = await res.json();
|
|
21
|
+
onUsage?.(obj?.prompt_eval_count ?? 0, obj?.eval_count ?? 0);
|
|
22
|
+
await onDone(obj?.message?.content ?? '');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const reader = res.body.getReader();
|
|
26
|
+
const decoder = new TextDecoder();
|
|
27
|
+
let full = '';
|
|
28
|
+
let promptTokens = 0;
|
|
29
|
+
let completionTokens = 0;
|
|
30
|
+
let buf = '';
|
|
31
|
+
while (true) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done)
|
|
34
|
+
break;
|
|
35
|
+
buf += decoder.decode(value, { stream: true });
|
|
36
|
+
const lines = buf.split('\n');
|
|
37
|
+
buf = lines.pop() ?? '';
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!line.trim())
|
|
40
|
+
continue;
|
|
41
|
+
try {
|
|
42
|
+
const obj = JSON.parse(line);
|
|
43
|
+
const chunk = obj?.message?.content ?? '';
|
|
44
|
+
if (chunk) {
|
|
45
|
+
full += chunk;
|
|
46
|
+
onChunk(chunk);
|
|
47
|
+
}
|
|
48
|
+
if (obj?.done) {
|
|
49
|
+
promptTokens = obj.prompt_eval_count ?? 0;
|
|
50
|
+
completionTokens = obj.eval_count ?? 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
onUsage?.(promptTokens, completionTokens);
|
|
57
|
+
await onDone(full);
|
|
22
58
|
}
|
|
23
59
|
catch (err) {
|
|
24
60
|
if (err?.name !== 'AbortError')
|
|
@@ -26,21 +62,53 @@ async function chatOllama(cfg) {
|
|
|
26
62
|
}
|
|
27
63
|
}
|
|
28
64
|
async function chatOpenAI(cfg) {
|
|
29
|
-
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage } = cfg;
|
|
65
|
+
const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk } = cfg;
|
|
30
66
|
try {
|
|
31
67
|
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
32
68
|
method: 'POST',
|
|
33
69
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey ?? 'local'}` },
|
|
34
|
-
body: JSON.stringify({ model, messages }),
|
|
70
|
+
body: JSON.stringify({ model, messages, stream: !!onChunk }),
|
|
35
71
|
signal,
|
|
36
72
|
});
|
|
37
73
|
if (!res.ok) {
|
|
38
74
|
onError(new Error(`LLM ${res.status}: ${await res.text()}`));
|
|
39
75
|
return;
|
|
40
76
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
77
|
+
if (!onChunk) {
|
|
78
|
+
const obj = await res.json();
|
|
79
|
+
onUsage?.(obj?.usage?.prompt_tokens ?? 0, obj?.usage?.completion_tokens ?? 0);
|
|
80
|
+
await onDone(obj?.choices?.[0]?.message?.content ?? '');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const reader = res.body.getReader();
|
|
84
|
+
const decoder = new TextDecoder();
|
|
85
|
+
let full = '';
|
|
86
|
+
let buf = '';
|
|
87
|
+
while (true) {
|
|
88
|
+
const { done, value } = await reader.read();
|
|
89
|
+
if (done)
|
|
90
|
+
break;
|
|
91
|
+
buf += decoder.decode(value, { stream: true });
|
|
92
|
+
const lines = buf.split('\n');
|
|
93
|
+
buf = lines.pop() ?? '';
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (!line.startsWith('data: '))
|
|
96
|
+
continue;
|
|
97
|
+
const data = line.slice(6).trim();
|
|
98
|
+
if (data === '[DONE]')
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
const obj = JSON.parse(data);
|
|
102
|
+
const chunk = obj?.choices?.[0]?.delta?.content ?? '';
|
|
103
|
+
if (chunk) {
|
|
104
|
+
full += chunk;
|
|
105
|
+
onChunk(chunk);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
await onDone(full);
|
|
44
112
|
}
|
|
45
113
|
catch (err) {
|
|
46
114
|
if (err?.name !== 'AbortError')
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { chat } from '../llm/stream.js';
|
|
2
|
+
const SYSTEM = `You extract memorable facts from conversations for long-term memory. Output ONLY a valid JSON array of concise fact strings.
|
|
3
|
+
|
|
4
|
+
Extract: user preferences, decisions made, key file paths, functions or variables, code patterns established, constraints, goals.
|
|
5
|
+
Skip: trivial exchanges, transient state, tool output noise.
|
|
6
|
+
Max 8 facts. Be specific and concrete.
|
|
7
|
+
|
|
8
|
+
Example output:
|
|
9
|
+
["User prefers patch_file over full rewrites","entry point is src/index.ts","decided to use Zod for validation"]`;
|
|
10
|
+
export function extractFacts(messages, config, model) {
|
|
11
|
+
const lines = messages
|
|
12
|
+
.filter(m => m.role !== 'system')
|
|
13
|
+
.map(m => `${m.role}: ${m.content.slice(0, 400)}`)
|
|
14
|
+
.join('\n');
|
|
15
|
+
if (!lines.trim())
|
|
16
|
+
return Promise.resolve([]);
|
|
17
|
+
return new Promise(resolve => {
|
|
18
|
+
chat({
|
|
19
|
+
provider: config.provider,
|
|
20
|
+
model,
|
|
21
|
+
baseUrl: config.baseUrl,
|
|
22
|
+
apiKey: config.apiKey,
|
|
23
|
+
messages: [
|
|
24
|
+
{ role: 'system', content: SYSTEM },
|
|
25
|
+
{ role: 'user', content: lines },
|
|
26
|
+
],
|
|
27
|
+
onDone(text) {
|
|
28
|
+
try {
|
|
29
|
+
const m = text.match(/\[[\s\S]*?\]/);
|
|
30
|
+
if (!m) {
|
|
31
|
+
resolve([]);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const arr = JSON.parse(m[0]);
|
|
35
|
+
resolve(Array.isArray(arr) ? arr.filter((f) => typeof f === 'string') : []);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
resolve([]);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
onError() { resolve([]); },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
const MEMORY_DIR = join(homedir(), '.config', 'miii', 'memory');
|
|
5
|
+
const MAX_FACTS = 200;
|
|
6
|
+
function ensureDir() {
|
|
7
|
+
mkdirSync(MEMORY_DIR, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
export function loadLongMemory(sessionName) {
|
|
10
|
+
ensureDir();
|
|
11
|
+
const p = join(MEMORY_DIR, `${sessionName}.json`);
|
|
12
|
+
if (!existsSync(p))
|
|
13
|
+
return [];
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(readFileSync(p, 'utf-8'));
|
|
16
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function saveLongMemory(sessionName, facts) {
|
|
23
|
+
ensureDir();
|
|
24
|
+
writeFileSync(join(MEMORY_DIR, `${sessionName}.json`), JSON.stringify(facts));
|
|
25
|
+
}
|
|
26
|
+
export function mergeFacts(existing, newTexts) {
|
|
27
|
+
const existingSet = new Set(existing.map(f => f.text.toLowerCase()));
|
|
28
|
+
const ts = Date.now();
|
|
29
|
+
const added = newTexts
|
|
30
|
+
.filter(t => t.trim() && !existingSet.has(t.toLowerCase()))
|
|
31
|
+
.map(text => ({ text, ts }));
|
|
32
|
+
const merged = [...existing, ...added];
|
|
33
|
+
if (merged.length > MAX_FACTS)
|
|
34
|
+
merged.splice(0, merged.length - MAX_FACTS);
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
|
37
|
+
export function formatMemoryBlock(facts) {
|
|
38
|
+
if (!facts.length)
|
|
39
|
+
return '';
|
|
40
|
+
return `\n\n[Long-term memory — recalled from prior conversation]\n${facts.map(f => `- ${f.text}`).join('\n')}`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
const KEY_FILE = join(homedir(), '.config', 'miii', 'tavily.key');
|
|
5
|
+
export function getTavilyKey() {
|
|
6
|
+
if (existsSync(KEY_FILE)) {
|
|
7
|
+
const k = readFileSync(KEY_FILE, 'utf-8').trim();
|
|
8
|
+
if (k)
|
|
9
|
+
return k;
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
export function saveTavilyKey(key) {
|
|
14
|
+
mkdirSync(join(homedir(), '.config', 'miii'), { recursive: true });
|
|
15
|
+
writeFileSync(KEY_FILE, key.trim(), { encoding: 'utf-8', mode: 0o600 });
|
|
16
|
+
}
|
|
17
|
+
async function post(path, body) {
|
|
18
|
+
const res = await fetch(`https://api.tavily.com${path}`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const text = await res.text().catch(() => '');
|
|
25
|
+
throw new Error(`Tavily API ${res.status}: ${text}`);
|
|
26
|
+
}
|
|
27
|
+
return res.json();
|
|
28
|
+
}
|
|
29
|
+
export async function tavilySearch(opts) {
|
|
30
|
+
const data = await post('/search', {
|
|
31
|
+
api_key: opts.apiKey,
|
|
32
|
+
query: opts.query,
|
|
33
|
+
search_depth: opts.searchDepth ?? 'basic',
|
|
34
|
+
max_results: Math.min(opts.maxResults ?? 5, 10),
|
|
35
|
+
include_answer: opts.includeAnswer ?? true,
|
|
36
|
+
include_raw_content: false,
|
|
37
|
+
include_domains: opts.includeDomains ?? [],
|
|
38
|
+
exclude_domains: opts.excludeDomains ?? [],
|
|
39
|
+
});
|
|
40
|
+
const parts = [];
|
|
41
|
+
if (data.answer)
|
|
42
|
+
parts.push(`Answer: ${data.answer}\n`);
|
|
43
|
+
for (const r of data.results) {
|
|
44
|
+
parts.push(`[${r.title}] ${r.url}\n${r.content}`);
|
|
45
|
+
}
|
|
46
|
+
return parts.join('\n\n').trim() || '(no results)';
|
|
47
|
+
}
|
|
48
|
+
export async function tavilyExtract(opts) {
|
|
49
|
+
const data = await post('/extract', {
|
|
50
|
+
api_key: opts.apiKey,
|
|
51
|
+
urls: opts.urls.slice(0, 20),
|
|
52
|
+
});
|
|
53
|
+
const parts = [];
|
|
54
|
+
for (const r of data.results) {
|
|
55
|
+
const truncated = r.raw_content.length > 8000
|
|
56
|
+
? r.raw_content.slice(0, 8000) + '\n…[truncated at 8k]'
|
|
57
|
+
: r.raw_content;
|
|
58
|
+
parts.push(`[${r.url}]\n${truncated}`);
|
|
59
|
+
}
|
|
60
|
+
if (data.failed_results?.length) {
|
|
61
|
+
parts.push(`Failed URLs: ${data.failed_results.map(r => r.url).join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
return parts.join('\n\n---\n\n').trim() || '(no content extracted)';
|
|
64
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -235,6 +235,7 @@ export const tools = [
|
|
|
235
235
|
];
|
|
236
236
|
export function getSystemPrompt(extra = '') {
|
|
237
237
|
const toolDocs = tools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
|
|
238
|
+
const deepThinkDoc = `- deep_think({"query": "string", "needs_web": "boolean (optional)"}): Research tool — gathers information from files, git, and optionally the web before answering. Returns a compiled research summary. Guardrails: read-only tools only, max 6 tool calls, max 4 web calls inside. Use when a question requires reading multiple files or searching the web first.`;
|
|
238
239
|
return `You are Miii — a fast, local AI coding assistant.
|
|
239
240
|
|
|
240
241
|
Use tools by emitting:
|
|
@@ -265,6 +266,7 @@ replacement text
|
|
|
265
266
|
|
|
266
267
|
Tools:
|
|
267
268
|
${toolDocs}
|
|
269
|
+
${deepThinkDoc}
|
|
268
270
|
|
|
269
271
|
Rules:
|
|
270
272
|
- To modify an existing file: use patch_file with the exact old text and new replacement — do NOT rewrite the whole file
|
|
@@ -284,5 +286,7 @@ Rules:
|
|
|
284
286
|
- After editing files that have tests, call run_tests to verify nothing broke
|
|
285
287
|
- If run_tests fails, read the failing test output and fix the code, then run_tests again (max 3 retries)
|
|
286
288
|
- You have web_search and web_extract tools — use them whenever the user asks about anything requiring internet access, current information, documentation, library versions, news, or external URLs
|
|
287
|
-
- NEVER say you cannot search the web — always call web_search instead
|
|
289
|
+
- NEVER say you cannot search the web — always call web_search instead
|
|
290
|
+
- Use deep_think when the question requires gathering from multiple files or sources before you can answer well — it runs a safe read-only research phase and returns a summary you can reason over
|
|
291
|
+
- deep_think cannot edit files or run shell commands — it is purely for information gathering${extra}`;
|
|
288
292
|
}
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -8,7 +8,8 @@ import { tools } from '../tools/index.js';
|
|
|
8
8
|
import { readFile } from '../files/ops.js';
|
|
9
9
|
import { generateId } from '../types.js';
|
|
10
10
|
import * as printer from './printer.js';
|
|
11
|
-
import {
|
|
11
|
+
import { toolArgSummary } from './printer.js';
|
|
12
|
+
import { loadSession, saveSession, listSessions, deleteSession } from '../sessions.js';
|
|
12
13
|
import { MacroQueue, MicroQueue } from '../tasks/queue.js';
|
|
13
14
|
import { TaskExecutor } from '../tasks/executor.js';
|
|
14
15
|
import { fileEditContext } from '../tasks/compactor.js';
|
|
@@ -23,6 +24,7 @@ import { buildGitContext, looksCodeRelated } from './git-context.js';
|
|
|
23
24
|
import { useSession } from './hooks/useSession.js';
|
|
24
25
|
import { useModelPicker } from './hooks/useModelPicker.js';
|
|
25
26
|
import { useRunLoop } from './hooks/useRunLoop.js';
|
|
27
|
+
import { runDeepThink } from './deepThink.js';
|
|
26
28
|
const gitRun = promisify(exec);
|
|
27
29
|
function buildAtContext(text) {
|
|
28
30
|
const refs = [...text.matchAll(/@([\w./\-]+)/g)];
|
|
@@ -47,9 +49,20 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
47
49
|
const macroQueueRef = useRef(new MacroQueue());
|
|
48
50
|
const executorRef = useRef(new TaskExecutor(tools));
|
|
49
51
|
const lastGitStatusRef = useRef('');
|
|
50
|
-
const
|
|
52
|
+
const abortRef = useRef(null);
|
|
53
|
+
const { setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, } = useSession(session, cwd, config);
|
|
51
54
|
const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, openPicker, handleModelSelect, handleModelPull, } = useModelPicker(config);
|
|
52
|
-
const
|
|
55
|
+
const deepThinkTool = useMemo(() => ({
|
|
56
|
+
name: 'deep_think',
|
|
57
|
+
description: 'Research tool: gather info from files and web before answering.',
|
|
58
|
+
params: '{"query": "string", "needs_web": "boolean (optional)"}',
|
|
59
|
+
execute: async ({ query }) => {
|
|
60
|
+
const result = await runDeepThink(String(query), config, currentModelRef.current, abortRef.current?.signal);
|
|
61
|
+
return `Research complete (${result.toolCalls} tool calls, ${result.webCalls} web):\n\n${result.findings}`;
|
|
62
|
+
},
|
|
63
|
+
}), [config]);
|
|
64
|
+
const allTools = useMemo(() => [...tools, deepThinkTool], [deepThinkTool]);
|
|
65
|
+
const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, runLoop, handleAbort, permissionRequest, resolvePermission, } = useRunLoop(config, currentModelRef, pushHistory, allTools, abortRef);
|
|
53
66
|
// ─── refactor ─────────────────────────────────────────────────────────────
|
|
54
67
|
const runRefactor = useCallback(async (goal) => {
|
|
55
68
|
printer.systemMsg(`refactor: ${goal}`);
|
|
@@ -348,6 +361,41 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
348
361
|
await runRefactor(goal);
|
|
349
362
|
return;
|
|
350
363
|
}
|
|
364
|
+
if (cmd.startsWith('/think ') || cmd === '/think') {
|
|
365
|
+
const query = cmd.slice(6).trim();
|
|
366
|
+
if (!query) {
|
|
367
|
+
printer.systemMsg('usage: /think <query>');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
printer.userMsg(`/think ${query}`);
|
|
371
|
+
setStatus('thinking');
|
|
372
|
+
setTaskLabel(`gathering: ${query}`);
|
|
373
|
+
abortRef.current = new AbortController();
|
|
374
|
+
try {
|
|
375
|
+
const result = await runDeepThink(query, config, currentModelRef.current, abortRef.current.signal, (toolName) => setCurrentTool(`gather:${toolName}`));
|
|
376
|
+
setCurrentTool(undefined);
|
|
377
|
+
printer.systemMsg(`gathered: ${result.toolCalls} tool call(s), ${result.webCalls} web call(s)`);
|
|
378
|
+
if (result.findings) {
|
|
379
|
+
pushHistory({ role: 'user', content: `/think ${query}` });
|
|
380
|
+
pushHistory({ role: 'assistant', content: result.findings });
|
|
381
|
+
pushHistory({ role: 'user', content: `Based on your research above, give a complete answer to: ${query}` });
|
|
382
|
+
await runLoop(buildContext(), 0, query);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
printer.systemMsg('nothing gathered — try rephrasing');
|
|
386
|
+
setStatus('idle');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
printer.errorMsg(`deep think failed: ${e}`);
|
|
391
|
+
setStatus('idle');
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
setCurrentTool(undefined);
|
|
395
|
+
setTaskLabel(undefined);
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
351
399
|
if (cmd === '/plan' || cmd.startsWith('/plan ')) {
|
|
352
400
|
const topic = cmd.slice(5).trim();
|
|
353
401
|
setPlanningMode(true);
|
|
@@ -394,6 +442,25 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
394
442
|
printer.systemMsg(`current: ${sessionNameRef.current}`);
|
|
395
443
|
return;
|
|
396
444
|
}
|
|
445
|
+
if (arg.startsWith('delete ')) {
|
|
446
|
+
const target = arg.slice(7).trim();
|
|
447
|
+
if (!target) {
|
|
448
|
+
printer.systemMsg('usage: /session delete <name>');
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (target === sessionNameRef.current) {
|
|
452
|
+
printer.systemMsg('cannot delete active session — switch first');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
deleteSession(target);
|
|
457
|
+
printer.systemMsg(`deleted: ${target}`);
|
|
458
|
+
}
|
|
459
|
+
catch (e) {
|
|
460
|
+
printer.errorMsg(`delete failed: ${String(e)}`);
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
397
464
|
if (saveTimerRef.current) {
|
|
398
465
|
clearTimeout(saveTimerRef.current);
|
|
399
466
|
saveTimerRef.current = null;
|
|
@@ -434,6 +501,7 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
434
501
|
printer.systemMsg(`unknown command: /${slashCmd} — try /list`);
|
|
435
502
|
return;
|
|
436
503
|
}
|
|
504
|
+
renameFromMessage(text);
|
|
437
505
|
const contextPrefix = buildAtContext(text);
|
|
438
506
|
const shouldInjectGit = config.gitContext !== false && looksCodeRelated(text);
|
|
439
507
|
const { prefix: gitPrefix, label: gitLabel } = shouldInjectGit
|
|
@@ -447,7 +515,7 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
447
515
|
}, [skills, runLoop, openPicker]);
|
|
448
516
|
const skillList = skills.list();
|
|
449
517
|
// ─── render ────────────────────────────────────────────────────────────────
|
|
450
|
-
return (_jsxs(Box, { flexDirection: "column", children: [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 })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: status === 'thinking'
|
|
518
|
+
return (_jsxs(Box, { flexDirection: "column", children: [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 })] })) : permissionRequest ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, 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(Divider, { cols: cols })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: status === 'thinking'
|
|
451
519
|
? _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]] })] })
|
|
452
|
-
: _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [Math.floor((Date.now() - thinkingStartRef.current) / 1000), "s"] }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })] }) }), _jsx(Divider, { cols: cols })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, onSubmit: handleSubmit, onAbort: handleAbort })] }));
|
|
520
|
+
: _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [Math.floor((Date.now() - thinkingStartRef.current) / 1000), "s"] }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })] }) }), _jsx(Divider, { cols: cols })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, permissionRequest: permissionRequest, onPermissionResponse: resolvePermission, onSubmit: handleSubmit, onAbort: handleAbort })] }));
|
|
453
521
|
}
|