miii-cli 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -13
- 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 +70 -3
- package/dist/tui/components/InputArea.js +32 -8
- 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 +148 -0
- package/dist/tui/hooks/useSession.js +93 -0
- package/dist/tui/printer.js +15 -7
- package/dist/tui/thinking.js +53 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,19 +9,23 @@
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
## 📊 How Miii Stacks Up
|
|
13
|
+
|
|
14
|
+
| Feature | **Miii** | Claude Code | Codex CLI | Aider |
|
|
15
|
+
|---|---|---|---|---|
|
|
16
|
+
| **Runs locally** | ✅ Ollama / any API | ❌ Cloud only | ❌ Cloud only | ✅ Local + cloud |
|
|
17
|
+
| **Code stays private** | ✅ Never leaves machine | ❌ Sent to Anthropic | ❌ Sent to OpenAI | ⚠️ Depends on model |
|
|
18
|
+
| **Cost** | 🆓 Free (your compute) | 💳 Pay per token | 💳 Pay per token | 🆓 Free (local) |
|
|
19
|
+
| **Runtime** | ⚡ TypeScript — instant start | 🐍 Node (fast) | 🐍 Node | 🐢 Python |
|
|
20
|
+
| **Deep Think mode** | ✅ Gather + synthesize | ❌ | ❌ | ❌ |
|
|
21
|
+
| **Auto-test loop** | ✅ Jest / Vitest / Mocha | ⚠️ Manual | ❌ | ⚠️ Manual |
|
|
22
|
+
| **Web search built-in** | ✅ Tavily | ❌ | ❌ | ❌ |
|
|
23
|
+
| **Surgical patch edits** | ✅ `patch_file` | ✅ | ⚠️ | ✅ |
|
|
24
|
+
| **Session memory** | ✅ Named, persistent | ✅ | ❌ | ⚠️ Basic |
|
|
25
|
+
| **Skill / plugin system** | ✅ npm + `.md` skills | ⚠️ MCP only | ❌ | ❌ |
|
|
26
|
+
| **Open source** | ✅ MIT | ❌ | ❌ | ✅ Apache 2.0 |
|
|
27
|
+
|
|
28
|
+
> ✅ = supported | ⚠️ = partial | ❌ = not supported
|
|
25
29
|
|
|
26
30
|
## ⚡️ Quick Start
|
|
27
31
|
|
|
@@ -47,14 +51,35 @@ Most AI coding tools are either heavy Python wrappers or expensive monthly subsc
|
|
|
47
51
|
- **🛠 Precision Editing**: Using `patch_file`, miii makes surgical changes without rewriting entire files.
|
|
48
52
|
- **🔄 Auto-Test Loop**: Miii runs your Jest/Vitest/Mocha tests after every edit. If it breaks, it fixes itself.
|
|
49
53
|
- **🌐 Web Intelligence**: Integrated `web_search` and `web_extract` via Tavily for real-time documentation.
|
|
54
|
+
- **🧠 Deep Think**: Two-phase research mode — gathers from files, git, and web first, then synthesizes a complete answer. Available as `/think <query>` or as a tool the LLM calls autonomously.
|
|
50
55
|
- **📐 Planning Mode**: Use `/plan` to architect a solution before a single line of code is written.
|
|
51
56
|
- **📂 Session Memory**: Every conversation is auto-named and persisted. Resume your work instantly with `miii --session feature-auth`.
|
|
52
57
|
- **📦 Skill System**: Extend miii with npm skill plugins or custom `.md` files.
|
|
53
58
|
|
|
59
|
+
## 🧠 Deep Think
|
|
60
|
+
|
|
61
|
+
Deep think is a two-phase research engine built into miii:
|
|
62
|
+
|
|
63
|
+
1. **Gather phase** — runs a constrained inner loop with read-only tools: `read_file`, `list_files`, `git_status`, `git_log`, `git_diff`, `web_search`, `web_extract`. Guardrails enforce a hard cap of 6 tool calls and 4 web calls. No file writes, no shell mutations.
|
|
64
|
+
2. **Synthesize phase** — gathered findings feed into the main run loop for a complete, grounded answer.
|
|
65
|
+
|
|
66
|
+
**Two ways to trigger:**
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
/think how does the auth middleware handle token expiry?
|
|
70
|
+
/think what does this codebase do and how is it structured?
|
|
71
|
+
/think latest breaking changes in react 19
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The LLM can also call `deep_think` autonomously mid-conversation when it decides a question needs multi-source research before answering.
|
|
75
|
+
|
|
76
|
+
> Requires a Tavily key (`/tavily-key tvly-...`) for web calls. File/git research works without it.
|
|
77
|
+
|
|
54
78
|
## ⌨️ Command Cheat Sheet
|
|
55
79
|
|
|
56
80
|
| Command | What it does |
|
|
57
81
|
|---|---|
|
|
82
|
+
| `/think <query>` | Deep research: gather from files + web, then synthesize answer |
|
|
58
83
|
| `/refactor <goal>` | The powerhouse: plans, edits, and tests across your whole codebase |
|
|
59
84
|
| `/git <sub>` | Instant git status, diffs, and automated commit messages |
|
|
60
85
|
| `/plan` | Stop coding, start thinking (Structured Planning Mode) |
|
|
@@ -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,7 @@ 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 { loadSession, saveSession, listSessions } from '../sessions.js';
|
|
11
|
+
import { loadSession, saveSession, listSessions, deleteSession } from '../sessions.js';
|
|
12
12
|
import { MacroQueue, MicroQueue } from '../tasks/queue.js';
|
|
13
13
|
import { TaskExecutor } from '../tasks/executor.js';
|
|
14
14
|
import { fileEditContext } from '../tasks/compactor.js';
|
|
@@ -23,6 +23,7 @@ import { buildGitContext, looksCodeRelated } from './git-context.js';
|
|
|
23
23
|
import { useSession } from './hooks/useSession.js';
|
|
24
24
|
import { useModelPicker } from './hooks/useModelPicker.js';
|
|
25
25
|
import { useRunLoop } from './hooks/useRunLoop.js';
|
|
26
|
+
import { runDeepThink } from './deepThink.js';
|
|
26
27
|
const gitRun = promisify(exec);
|
|
27
28
|
function buildAtContext(text) {
|
|
28
29
|
const refs = [...text.matchAll(/@([\w./\-]+)/g)];
|
|
@@ -47,9 +48,20 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
47
48
|
const macroQueueRef = useRef(new MacroQueue());
|
|
48
49
|
const executorRef = useRef(new TaskExecutor(tools));
|
|
49
50
|
const lastGitStatusRef = useRef('');
|
|
50
|
-
const
|
|
51
|
+
const abortRef = useRef(null);
|
|
52
|
+
const { setSessionName, sessionNameRef, historyRef, saveTimerRef, systemPromptRef, pushHistory, buildContext, renameFromMessage, } = useSession(session, cwd, config);
|
|
51
53
|
const { currentModel, setCurrentModel, currentModelRef, pickerOpen, setPickerOpen, pickerModels, pickerLoading, pickerError, pullState, openPicker, handleModelSelect, handleModelPull, } = useModelPicker(config);
|
|
52
|
-
const
|
|
54
|
+
const deepThinkTool = useMemo(() => ({
|
|
55
|
+
name: 'deep_think',
|
|
56
|
+
description: 'Research tool: gather info from files and web before answering.',
|
|
57
|
+
params: '{"query": "string", "needs_web": "boolean (optional)"}',
|
|
58
|
+
execute: async ({ query }) => {
|
|
59
|
+
const result = await runDeepThink(String(query), config, currentModelRef.current, abortRef.current?.signal);
|
|
60
|
+
return `Research complete (${result.toolCalls} tool calls, ${result.webCalls} web):\n\n${result.findings}`;
|
|
61
|
+
},
|
|
62
|
+
}), [config]);
|
|
63
|
+
const allTools = useMemo(() => [...tools, deepThinkTool], [deepThinkTool]);
|
|
64
|
+
const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, runLoop, handleAbort, } = useRunLoop(config, currentModelRef, pushHistory, allTools, abortRef);
|
|
53
65
|
// ─── refactor ─────────────────────────────────────────────────────────────
|
|
54
66
|
const runRefactor = useCallback(async (goal) => {
|
|
55
67
|
printer.systemMsg(`refactor: ${goal}`);
|
|
@@ -348,6 +360,41 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
348
360
|
await runRefactor(goal);
|
|
349
361
|
return;
|
|
350
362
|
}
|
|
363
|
+
if (cmd.startsWith('/think ') || cmd === '/think') {
|
|
364
|
+
const query = cmd.slice(6).trim();
|
|
365
|
+
if (!query) {
|
|
366
|
+
printer.systemMsg('usage: /think <query>');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
printer.userMsg(`/think ${query}`);
|
|
370
|
+
setStatus('thinking');
|
|
371
|
+
setTaskLabel(`gathering: ${query}`);
|
|
372
|
+
abortRef.current = new AbortController();
|
|
373
|
+
try {
|
|
374
|
+
const result = await runDeepThink(query, config, currentModelRef.current, abortRef.current.signal, (toolName) => setCurrentTool(`gather:${toolName}`));
|
|
375
|
+
setCurrentTool(undefined);
|
|
376
|
+
printer.systemMsg(`gathered: ${result.toolCalls} tool call(s), ${result.webCalls} web call(s)`);
|
|
377
|
+
if (result.findings) {
|
|
378
|
+
pushHistory({ role: 'user', content: `/think ${query}` });
|
|
379
|
+
pushHistory({ role: 'assistant', content: result.findings });
|
|
380
|
+
pushHistory({ role: 'user', content: `Based on your research above, give a complete answer to: ${query}` });
|
|
381
|
+
await runLoop(buildContext(), 0, query);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
printer.systemMsg('nothing gathered — try rephrasing');
|
|
385
|
+
setStatus('idle');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
printer.errorMsg(`deep think failed: ${e}`);
|
|
390
|
+
setStatus('idle');
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
setCurrentTool(undefined);
|
|
394
|
+
setTaskLabel(undefined);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
351
398
|
if (cmd === '/plan' || cmd.startsWith('/plan ')) {
|
|
352
399
|
const topic = cmd.slice(5).trim();
|
|
353
400
|
setPlanningMode(true);
|
|
@@ -394,6 +441,25 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
394
441
|
printer.systemMsg(`current: ${sessionNameRef.current}`);
|
|
395
442
|
return;
|
|
396
443
|
}
|
|
444
|
+
if (arg.startsWith('delete ')) {
|
|
445
|
+
const target = arg.slice(7).trim();
|
|
446
|
+
if (!target) {
|
|
447
|
+
printer.systemMsg('usage: /session delete <name>');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (target === sessionNameRef.current) {
|
|
451
|
+
printer.systemMsg('cannot delete active session — switch first');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
deleteSession(target);
|
|
456
|
+
printer.systemMsg(`deleted: ${target}`);
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
printer.errorMsg(`delete failed: ${String(e)}`);
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
397
463
|
if (saveTimerRef.current) {
|
|
398
464
|
clearTimeout(saveTimerRef.current);
|
|
399
465
|
saveTimerRef.current = null;
|
|
@@ -434,6 +500,7 @@ export function InputBar({ config, skills, cwd, session, version }) {
|
|
|
434
500
|
printer.systemMsg(`unknown command: /${slashCmd} — try /list`);
|
|
435
501
|
return;
|
|
436
502
|
}
|
|
503
|
+
renameFromMessage(text);
|
|
437
504
|
const contextPrefix = buildAtContext(text);
|
|
438
505
|
const shouldInjectGit = config.gitContext !== false && looksCodeRelated(text);
|
|
439
506
|
const { prefix: gitPrefix, label: gitLabel } = shouldInjectGit
|
|
@@ -32,11 +32,15 @@ const PLANNING_COMMANDS = [
|
|
|
32
32
|
{ ns: 'plan', name: 'review', description: 'review and critique the plan so far' },
|
|
33
33
|
{ ns: 'plan', name: 'done', description: 'exit planning mode' },
|
|
34
34
|
];
|
|
35
|
+
const PASTE_MIN_LINES = 3;
|
|
36
|
+
const PASTE_MIN_CHARS = 200;
|
|
35
37
|
export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort }) {
|
|
36
38
|
const [lines, setLines] = useState(['']);
|
|
37
39
|
const [cursor, setCursor] = useState({ row: 0, col: 0 });
|
|
38
40
|
const [overlay, setOverlay] = useState('none');
|
|
39
41
|
const [overlayIdx, setOverlayIdx] = useState(0);
|
|
42
|
+
const [pasteLines, setPasteLines] = useState(0);
|
|
43
|
+
const pasteRef = useRef(null);
|
|
40
44
|
const [files, setFiles] = useState([]);
|
|
41
45
|
const filesLoadedRef = useRef(false);
|
|
42
46
|
// built-ins first, then loaded skills (deduplicated by name)
|
|
@@ -87,6 +91,8 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
87
91
|
setCursor({ row: 0, col: 0 });
|
|
88
92
|
setOverlay('none');
|
|
89
93
|
setOverlayIdx(0);
|
|
94
|
+
pasteRef.current = null;
|
|
95
|
+
setPasteLines(0);
|
|
90
96
|
}
|
|
91
97
|
function appendChar(ch) {
|
|
92
98
|
setLines(prev => {
|
|
@@ -200,7 +206,11 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
200
206
|
// backspace/typing falls through to normal handling below
|
|
201
207
|
}
|
|
202
208
|
if (key.return) {
|
|
203
|
-
const
|
|
209
|
+
const typed = fullInput.trim();
|
|
210
|
+
const pasted = pasteRef.current;
|
|
211
|
+
const text = pasted
|
|
212
|
+
? typed ? `${typed}\n${pasted}` : pasted
|
|
213
|
+
: typed;
|
|
204
214
|
if (text) {
|
|
205
215
|
clearInput();
|
|
206
216
|
onSubmit(text);
|
|
@@ -208,6 +218,11 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
208
218
|
return;
|
|
209
219
|
}
|
|
210
220
|
if (key.backspace || key.delete) {
|
|
221
|
+
if (pasteRef.current) {
|
|
222
|
+
pasteRef.current = null;
|
|
223
|
+
setPasteLines(0);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
211
226
|
deleteChar();
|
|
212
227
|
// Recompute overlay trigger for updated input
|
|
213
228
|
const r = cursor.row;
|
|
@@ -245,6 +260,13 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
245
260
|
return;
|
|
246
261
|
}
|
|
247
262
|
if (input && !key.ctrl && !key.meta) {
|
|
263
|
+
// Detect paste: Ink delivers entire pasted chunk as one input string
|
|
264
|
+
const lineCount = input.split('\n').length;
|
|
265
|
+
if (input.length > 1 && (lineCount >= PASTE_MIN_LINES || input.length >= PASTE_MIN_CHARS)) {
|
|
266
|
+
pasteRef.current = input;
|
|
267
|
+
setPasteLines(lineCount);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
248
270
|
// Compute prospective new input to decide overlay
|
|
249
271
|
const r = cursor.row;
|
|
250
272
|
const col = cursor.col;
|
|
@@ -277,14 +299,16 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
277
299
|
const borderColor = isProcessing ? 'yellow' : 'cyan';
|
|
278
300
|
const hint = isProcessing
|
|
279
301
|
? 'esc to abort'
|
|
280
|
-
:
|
|
281
|
-
? '
|
|
282
|
-
: overlay === '
|
|
302
|
+
: pasteLines > 0
|
|
303
|
+
? 'backspace removes paste enter to send'
|
|
304
|
+
: overlay === 'command' && !commandQuery.includes(' ')
|
|
283
305
|
? '↑↓ navigate enter select esc close'
|
|
284
|
-
:
|
|
285
|
-
? '
|
|
286
|
-
:
|
|
287
|
-
|
|
306
|
+
: overlay === 'at'
|
|
307
|
+
? '↑↓ navigate enter select esc close'
|
|
308
|
+
: planningMode
|
|
309
|
+
? '📋 planning mode / suggestions enter send /plan:done to exit'
|
|
310
|
+
: '@ file / command enter send ctrl+c exit';
|
|
311
|
+
return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsxs(Box, { borderStyle: "round", borderColor: borderColor, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: borderColor, bold: true, children: '❯ ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: pasteLines > 0 ? (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " lines"] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] })) : lines.length === 1 && !lines[0] ? (_jsx(Text, { color: isActive ? 'white' : 'gray', dimColor: isProcessing, children: isActive ? '█' : 'processing...' })) : (lines.map((line, i) => (_jsx(Text, { wrap: "wrap", children: i === cursor.row
|
|
288
312
|
? renderLineWithCursor(line, cursor.col, isActive)
|
|
289
313
|
: line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: hint })] })] }));
|
|
290
314
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { chat } from '../llm/stream.js';
|
|
2
|
+
import { tools as staticTools } from '../tools/index.js';
|
|
3
|
+
import { StreamParser } from '../parser/stream-parser.js';
|
|
4
|
+
const ALLOWED_TOOLS = new Set([
|
|
5
|
+
'read_file', 'list_files', 'web_search', 'web_extract',
|
|
6
|
+
'git_status', 'git_log', 'git_diff',
|
|
7
|
+
]);
|
|
8
|
+
const MAX_DEPTH = 6;
|
|
9
|
+
const MAX_WEB = 4;
|
|
10
|
+
export async function runDeepThink(query, config, model, signal, onStep) {
|
|
11
|
+
const gatherTools = staticTools.filter(t => ALLOWED_TOOLS.has(t.name));
|
|
12
|
+
const toolDocs = gatherTools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
|
|
13
|
+
const sysPrompt = `You are a research agent. Gather information to answer: "${query}"
|
|
14
|
+
|
|
15
|
+
Available tools (read-only — no file writes, no mutations):
|
|
16
|
+
${toolDocs}
|
|
17
|
+
|
|
18
|
+
Guardrails:
|
|
19
|
+
- Max ${MAX_DEPTH} tool calls total
|
|
20
|
+
- Max ${MAX_WEB} web calls (web_search + web_extract combined)
|
|
21
|
+
- No file edits, no shell commands that modify state
|
|
22
|
+
- When you have enough info, output a detailed plain-text research summary
|
|
23
|
+
- No markdown formatting in output`;
|
|
24
|
+
const messages = [
|
|
25
|
+
{ role: 'system', content: sysPrompt },
|
|
26
|
+
{ role: 'user', content: `Research and gather all relevant information for: ${query}` },
|
|
27
|
+
];
|
|
28
|
+
let depth = 0;
|
|
29
|
+
let webCalls = 0;
|
|
30
|
+
let totalCalls = 0;
|
|
31
|
+
let findings = '';
|
|
32
|
+
async function gather(msgs) {
|
|
33
|
+
if (depth >= MAX_DEPTH)
|
|
34
|
+
return;
|
|
35
|
+
depth++;
|
|
36
|
+
let fullText = '';
|
|
37
|
+
await chat({
|
|
38
|
+
provider: config.provider,
|
|
39
|
+
model,
|
|
40
|
+
baseUrl: config.baseUrl,
|
|
41
|
+
apiKey: config.apiKey,
|
|
42
|
+
messages: msgs,
|
|
43
|
+
signal,
|
|
44
|
+
async onDone(text) { fullText = text; },
|
|
45
|
+
onError(err) { if (err.name !== 'AbortError')
|
|
46
|
+
throw err; },
|
|
47
|
+
});
|
|
48
|
+
if (!fullText)
|
|
49
|
+
return;
|
|
50
|
+
const pending = [];
|
|
51
|
+
const parser = new StreamParser();
|
|
52
|
+
for (const item of [...parser.feed(fullText), ...parser.flush()]) {
|
|
53
|
+
if (item.type === 'tool_call')
|
|
54
|
+
pending.push({ name: item.toolName, args: item.toolArgs });
|
|
55
|
+
}
|
|
56
|
+
if (!pending.length) {
|
|
57
|
+
findings = fullText;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const next = [...msgs, { role: 'assistant', content: fullText }];
|
|
61
|
+
for (const tc of pending) {
|
|
62
|
+
if (!ALLOWED_TOOLS.has(tc.name)) {
|
|
63
|
+
next.push({ role: 'user', content: `Tool "${tc.name}" not permitted in research phase.` });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const isWeb = tc.name === 'web_search' || tc.name === 'web_extract';
|
|
67
|
+
if (isWeb && webCalls >= MAX_WEB) {
|
|
68
|
+
next.push({ role: 'user', content: `Web call limit (${MAX_WEB}) reached. Summarize findings now.` });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (totalCalls >= MAX_DEPTH) {
|
|
72
|
+
next.push({ role: 'user', content: `Tool call limit (${MAX_DEPTH}) reached. Summarize findings now.` });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const tool = gatherTools.find(t => t.name === tc.name);
|
|
76
|
+
if (!tool)
|
|
77
|
+
continue;
|
|
78
|
+
onStep?.(tc.name);
|
|
79
|
+
totalCalls++;
|
|
80
|
+
if (isWeb)
|
|
81
|
+
webCalls++;
|
|
82
|
+
try {
|
|
83
|
+
const result = await tool.execute(tc.args);
|
|
84
|
+
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
next.push({ role: 'user', content: `Tool ${tc.name} error: ${e}` });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await gather(next);
|
|
91
|
+
}
|
|
92
|
+
await gather(messages);
|
|
93
|
+
return { findings, toolCalls: totalCalls, webCalls };
|
|
94
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile } from '../files/ops.js';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const gitRun = promisify(exec);
|
|
6
|
+
const CODE_PATTERN = /\.(ts|js|tsx|jsx|py|go|rs|java|rb|sh|css|html|json|yaml|yml)\b|function|class|import|export|const|let|var|def |async|await|error|bug|fix|refactor|implement|`[^`]+`/i;
|
|
7
|
+
export function looksCodeRelated(text) {
|
|
8
|
+
return text.length >= 10 && CODE_PATTERN.test(text);
|
|
9
|
+
}
|
|
10
|
+
export async function buildGitContext(cwd, lastStatusRef) {
|
|
11
|
+
try {
|
|
12
|
+
const { stdout } = await gitRun('git status --short', { cwd, timeout: 5000 });
|
|
13
|
+
const status = stdout.trim();
|
|
14
|
+
if (!status || status === lastStatusRef.current)
|
|
15
|
+
return { prefix: '', label: '' };
|
|
16
|
+
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 rel = raw.includes(' -> ') ? raw.split(' -> ')[1] : raw;
|
|
28
|
+
if (!rel)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
const content = readFile(resolve(cwd, rel));
|
|
32
|
+
if (!content || content.length > MAX_FILE) {
|
|
33
|
+
skipped.push(rel);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
total += content.length;
|
|
37
|
+
if (total > MAX_TOTAL) {
|
|
38
|
+
skipped.push(rel);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
parts.push(`<file path="${rel}">\n${content}\n</file>`);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
skipped.push(rel);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!parts.length && !skipped.length)
|
|
48
|
+
return { prefix: '', label: '' };
|
|
49
|
+
let prefix = '[Auto-context: git-changed files]\n' + parts.join('\n') + '\n';
|
|
50
|
+
if (skipped.length)
|
|
51
|
+
prefix += `Files changed but too large to auto-load: ${skipped.join(', ')}\n`;
|
|
52
|
+
prefix += '\n';
|
|
53
|
+
const label = `auto-loaded ${parts.length} changed file(s)${skipped.length ? `, skipped ${skipped.length} (too large)` : ''}`;
|
|
54
|
+
return { prefix, label };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { prefix: '', label: '' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { listModels, pullModel } from '../../llm/ollama.js';
|
|
3
|
+
import * as printer from '../printer.js';
|
|
4
|
+
export function useModelPicker(config) {
|
|
5
|
+
const [currentModel, setCurrentModel] = useState(config.model);
|
|
6
|
+
const currentModelRef = useRef(config.model);
|
|
7
|
+
const [pickerOpen, setPickerOpen] = useState(true);
|
|
8
|
+
const [pickerModels, setPickerModels] = useState([]);
|
|
9
|
+
const [pickerLoading, setPickerLoading] = useState(false);
|
|
10
|
+
const [pickerError, setPickerError] = useState();
|
|
11
|
+
const [pullState, setPullState] = useState();
|
|
12
|
+
const pullAbortRef = useRef(null);
|
|
13
|
+
useEffect(() => { currentModelRef.current = currentModel; }, [currentModel]);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
setPickerLoading(true);
|
|
16
|
+
listModels(config.baseUrl)
|
|
17
|
+
.then(m => { setPickerModels(m); setPickerLoading(false); })
|
|
18
|
+
.catch(e => { setPickerError(String(e)); setPickerLoading(false); });
|
|
19
|
+
}, []);
|
|
20
|
+
const openPicker = useCallback(async () => {
|
|
21
|
+
setPickerOpen(true);
|
|
22
|
+
setPickerLoading(true);
|
|
23
|
+
setPickerError(undefined);
|
|
24
|
+
try {
|
|
25
|
+
setPickerModels(await listModels(config.baseUrl));
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
setPickerError(String(e));
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
setPickerLoading(false);
|
|
32
|
+
}
|
|
33
|
+
}, [config.baseUrl]);
|
|
34
|
+
const handleModelSelect = useCallback((name) => {
|
|
35
|
+
setCurrentModel(name);
|
|
36
|
+
currentModelRef.current = name;
|
|
37
|
+
setPickerOpen(false);
|
|
38
|
+
printer.systemMsg(`model → ${name}`);
|
|
39
|
+
}, []);
|
|
40
|
+
const handleModelPull = useCallback(async (name) => {
|
|
41
|
+
setPullState({ name, status: 'starting...', pct: undefined });
|
|
42
|
+
pullAbortRef.current = new AbortController();
|
|
43
|
+
try {
|
|
44
|
+
await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal);
|
|
45
|
+
setPickerModels(await listModels(config.baseUrl));
|
|
46
|
+
setPullState(undefined);
|
|
47
|
+
setCurrentModel(name);
|
|
48
|
+
currentModelRef.current = name;
|
|
49
|
+
setPickerOpen(false);
|
|
50
|
+
printer.systemMsg(`pulled ${name} → active`);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
setPullState(undefined);
|
|
54
|
+
setPickerError(`pull failed: ${e}`);
|
|
55
|
+
}
|
|
56
|
+
}, [config.baseUrl]);
|
|
57
|
+
return {
|
|
58
|
+
currentModel, setCurrentModel, currentModelRef,
|
|
59
|
+
pickerOpen, setPickerOpen,
|
|
60
|
+
pickerModels, pickerLoading, pickerError, pullState,
|
|
61
|
+
openPicker, handleModelSelect, handleModelPull,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { chat } from '../../llm/stream.js';
|
|
3
|
+
import { tools as staticTools } from '../../tools/index.js';
|
|
4
|
+
import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
|
|
5
|
+
import { shouldCompact, compactContext } from '../../tasks/compactor.js';
|
|
6
|
+
import * as printer from '../printer.js';
|
|
7
|
+
const MAX_TOOL_DEPTH = 6;
|
|
8
|
+
const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'patch_file', 'delete_file']);
|
|
9
|
+
const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
|
|
10
|
+
export function useRunLoop(config, currentModelRef, pushHistory, extraTools = [], abortRef) {
|
|
11
|
+
const [status, setStatus] = useState('idle');
|
|
12
|
+
const [tick, setTick] = useState(0);
|
|
13
|
+
const [currentTool, setCurrentTool] = useState();
|
|
14
|
+
const [taskLabel, setTaskLabel] = useState();
|
|
15
|
+
const thinkingStartRef = useRef(0);
|
|
16
|
+
const extraToolsRef = useRef(extraTools);
|
|
17
|
+
extraToolsRef.current = extraTools;
|
|
18
|
+
const pushHistoryRef = useRef(pushHistory);
|
|
19
|
+
useEffect(() => { pushHistoryRef.current = pushHistory; }, [pushHistory]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (status === 'idle')
|
|
22
|
+
return;
|
|
23
|
+
const t = setInterval(() => setTick(n => n + 1), 80);
|
|
24
|
+
return () => clearInterval(t);
|
|
25
|
+
}, [status]);
|
|
26
|
+
const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
|
|
27
|
+
if (depth >= MAX_TOOL_DEPTH) {
|
|
28
|
+
setStatus('idle');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
setStatus('thinking');
|
|
32
|
+
if (depth === 0)
|
|
33
|
+
thinkingStartRef.current = Date.now();
|
|
34
|
+
const msgs = shouldCompact(contextMsgs) ? compactContext(contextMsgs, goal) : contextMsgs;
|
|
35
|
+
abortRef.current = new AbortController();
|
|
36
|
+
await chat({
|
|
37
|
+
provider: config.provider,
|
|
38
|
+
model: currentModelRef.current,
|
|
39
|
+
baseUrl: config.baseUrl,
|
|
40
|
+
messages: msgs,
|
|
41
|
+
signal: abortRef.current.signal,
|
|
42
|
+
async onDone(fullText) {
|
|
43
|
+
const pendingTools = [];
|
|
44
|
+
const textParts = [];
|
|
45
|
+
const parser = new StreamParser();
|
|
46
|
+
for (const item of [...parser.feed(fullText), ...parser.flush()]) {
|
|
47
|
+
if (item.type === 'tool_call')
|
|
48
|
+
pendingTools.push({ name: item.toolName, args: item.toolArgs });
|
|
49
|
+
else
|
|
50
|
+
textParts.push(item.content);
|
|
51
|
+
}
|
|
52
|
+
if (!pendingTools.length) {
|
|
53
|
+
const bare = extractBareToolCall(fullText);
|
|
54
|
+
if (bare)
|
|
55
|
+
pendingTools.push({ name: bare.name, args: bare.args });
|
|
56
|
+
}
|
|
57
|
+
const displayText = textParts.join('').trim();
|
|
58
|
+
if (displayText)
|
|
59
|
+
printer.assistantMsg(displayText);
|
|
60
|
+
pushHistoryRef.current({ role: 'assistant', content: fullText });
|
|
61
|
+
if (!pendingTools.length) {
|
|
62
|
+
const hasFencedCode = /```[\w]*\n[\s\S]{50,}?\n```/.test(fullText);
|
|
63
|
+
if (hasFencedCode && depth < MAX_TOOL_DEPTH - 1) {
|
|
64
|
+
const nudge = {
|
|
65
|
+
role: 'user',
|
|
66
|
+
content: 'You showed code in your response but did not use any file tools. Use edit_file or patch_file to actually write the changes to disk.',
|
|
67
|
+
};
|
|
68
|
+
await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
setStatus('idle');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
setStatus('tool');
|
|
75
|
+
const next = [...msgs, { role: 'assistant', content: fullText }];
|
|
76
|
+
try {
|
|
77
|
+
for (const tc of pendingTools) {
|
|
78
|
+
const allTools = [...staticTools, ...extraToolsRef.current];
|
|
79
|
+
const tool = allTools.find(t => t.name === tc.name);
|
|
80
|
+
setCurrentTool(tc.name);
|
|
81
|
+
if (tool) {
|
|
82
|
+
try {
|
|
83
|
+
printer.toolCallStart(tc.name, tc.args);
|
|
84
|
+
const result = await tool.execute(tc.args);
|
|
85
|
+
if (SHOW_RESULT_TOOLS.has(tc.name))
|
|
86
|
+
printer.toolMsg(tc.name, result);
|
|
87
|
+
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
const err = `Tool ${tc.name} error: ${e}`;
|
|
91
|
+
printer.errorMsg(err);
|
|
92
|
+
next.push({ role: 'user', content: err });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
printer.errorMsg(`unknown tool: ${tc.name}`);
|
|
97
|
+
next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
setCurrentTool(undefined);
|
|
103
|
+
}
|
|
104
|
+
// Auto-run tests after file edits
|
|
105
|
+
const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
|
|
106
|
+
if (didEditFiles) {
|
|
107
|
+
const testTool = staticTools.find(t => t.name === 'run_tests');
|
|
108
|
+
if (testTool) {
|
|
109
|
+
setCurrentTool('run_tests');
|
|
110
|
+
try {
|
|
111
|
+
printer.toolCallStart('run_tests', {});
|
|
112
|
+
const testResult = await testTool.execute({});
|
|
113
|
+
if (testResult && !testResult.startsWith('(no test script') && !testResult.startsWith('(no package.json')) {
|
|
114
|
+
printer.toolMsg('run_tests', testResult);
|
|
115
|
+
next.push({ role: 'user', content: `Test results after edits:\n${testResult}` });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
const err = `run_tests error: ${e}`;
|
|
120
|
+
printer.errorMsg(err);
|
|
121
|
+
next.push({ role: 'user', content: err });
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
setCurrentTool(undefined);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await runLoop(next, depth + 1, goal);
|
|
129
|
+
},
|
|
130
|
+
onError(err) {
|
|
131
|
+
if (err.name !== 'AbortError')
|
|
132
|
+
printer.errorMsg(err.message);
|
|
133
|
+
setStatus('idle');
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}, [config]);
|
|
137
|
+
const handleAbort = useCallback(() => {
|
|
138
|
+
abortRef.current?.abort();
|
|
139
|
+
setStatus('idle');
|
|
140
|
+
}, []);
|
|
141
|
+
return {
|
|
142
|
+
status, setStatus, tick,
|
|
143
|
+
currentTool, setCurrentTool,
|
|
144
|
+
taskLabel, setTaskLabel,
|
|
145
|
+
thinkingStartRef,
|
|
146
|
+
runLoop, handleAbort,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { loadSession, saveSession, deleteSession } from '../../sessions.js';
|
|
3
|
+
import { getSystemPrompt } from '../../tools/index.js';
|
|
4
|
+
import { getTavilyKey, saveTavilyKey } from '../../tavily/client.js';
|
|
5
|
+
import * as printer from '../printer.js';
|
|
6
|
+
import { loadLongMemory, saveLongMemory, mergeFacts, formatMemoryBlock } from '../../memory/store.js';
|
|
7
|
+
import { extractFacts } from '../../memory/extractor.js';
|
|
8
|
+
const SHORT_MEMORY_SIZE = 40;
|
|
9
|
+
function buildSystemPrompt(cwd, facts) {
|
|
10
|
+
return getSystemPrompt(`\n- CWD: ${cwd}`) + formatMemoryBlock(facts);
|
|
11
|
+
}
|
|
12
|
+
export function useSession(initialSession, cwd, config) {
|
|
13
|
+
const [sessionName, setSessionName] = useState(initialSession);
|
|
14
|
+
const sessionNameRef = useRef(initialSession);
|
|
15
|
+
const historyRef = useRef([]);
|
|
16
|
+
const saveTimerRef = useRef(null);
|
|
17
|
+
const firstMessageSentRef = useRef(false);
|
|
18
|
+
const longMemoryRef = useRef([]);
|
|
19
|
+
const systemPromptRef = useRef(buildSystemPrompt(cwd, []));
|
|
20
|
+
useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const facts = loadLongMemory(initialSession);
|
|
23
|
+
longMemoryRef.current = facts;
|
|
24
|
+
systemPromptRef.current = buildSystemPrompt(cwd, facts);
|
|
25
|
+
if (facts.length)
|
|
26
|
+
printer.systemMsg(`long memory: ${facts.length} facts loaded`);
|
|
27
|
+
const history = loadSession(initialSession);
|
|
28
|
+
historyRef.current = history;
|
|
29
|
+
if (history.length)
|
|
30
|
+
printer.systemMsg(`resumed "${initialSession}" — ${history.length} messages`);
|
|
31
|
+
if (config.tavilyApiKey && !getTavilyKey())
|
|
32
|
+
saveTavilyKey(config.tavilyApiKey);
|
|
33
|
+
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');
|
|
35
|
+
}
|
|
36
|
+
}, []);
|
|
37
|
+
function scheduleSave() {
|
|
38
|
+
if (saveTimerRef.current)
|
|
39
|
+
clearTimeout(saveTimerRef.current);
|
|
40
|
+
saveTimerRef.current = setTimeout(() => {
|
|
41
|
+
saveSession(sessionNameRef.current, historyRef.current);
|
|
42
|
+
saveTimerRef.current = null;
|
|
43
|
+
}, 2000);
|
|
44
|
+
}
|
|
45
|
+
function pushHistory(msg) {
|
|
46
|
+
historyRef.current.push(msg);
|
|
47
|
+
if (historyRef.current.length > SHORT_MEMORY_SIZE) {
|
|
48
|
+
const dropped = historyRef.current.splice(0, historyRef.current.length - SHORT_MEMORY_SIZE);
|
|
49
|
+
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);
|
|
55
|
+
saveLongMemory(sessionNameRef.current, updated);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
scheduleSave();
|
|
59
|
+
}
|
|
60
|
+
function renameFromMessage(text) {
|
|
61
|
+
if (firstMessageSentRef.current)
|
|
62
|
+
return;
|
|
63
|
+
firstMessageSentRef.current = true;
|
|
64
|
+
const slug = text
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.split(/\s+/)
|
|
67
|
+
.slice(0, 5)
|
|
68
|
+
.join('-')
|
|
69
|
+
.replace(/[^\w-]/g, '')
|
|
70
|
+
.replace(/-+/g, '-')
|
|
71
|
+
.replace(/^-|-$/g, '')
|
|
72
|
+
.slice(0, 40) || 'chat';
|
|
73
|
+
const oldName = sessionNameRef.current;
|
|
74
|
+
sessionNameRef.current = slug;
|
|
75
|
+
setSessionName(slug);
|
|
76
|
+
try {
|
|
77
|
+
deleteSession(oldName);
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
function buildContext(extra) {
|
|
82
|
+
const ctx = [{ role: 'system', content: systemPromptRef.current }];
|
|
83
|
+
ctx.push(...historyRef.current);
|
|
84
|
+
if (extra)
|
|
85
|
+
ctx.push(extra);
|
|
86
|
+
return ctx;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
sessionName, setSessionName, sessionNameRef,
|
|
90
|
+
historyRef, saveTimerRef, systemPromptRef,
|
|
91
|
+
pushHistory, buildContext, renameFromMessage,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/tui/printer.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
// ANSI-formatted stdout output — goes into terminal scrollback
|
|
2
|
+
let _inkClear = null;
|
|
3
|
+
export function setInkInstance(clear) {
|
|
4
|
+
_inkClear = clear;
|
|
5
|
+
}
|
|
6
|
+
function write(s) {
|
|
7
|
+
_inkClear?.();
|
|
8
|
+
process.stdout.write(s);
|
|
9
|
+
}
|
|
2
10
|
const R = '\x1b[0m';
|
|
3
11
|
const BOLD = '\x1b[1m';
|
|
4
12
|
const DIM = '\x1b[2m';
|
|
@@ -134,7 +142,7 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
|
|
|
134
142
|
}
|
|
135
143
|
export function userMsg(text) {
|
|
136
144
|
const atHighlighted = text.replace(/(@[\w./\-]+)/g, (m) => cyan(m));
|
|
137
|
-
|
|
145
|
+
write(`\n${gray('>>')} ${atHighlighted}\n`);
|
|
138
146
|
}
|
|
139
147
|
export function assistantMsg(text) {
|
|
140
148
|
const content = formatContent(text);
|
|
@@ -146,14 +154,14 @@ export function assistantMsg(text) {
|
|
|
146
154
|
return;
|
|
147
155
|
const head = lines[idx].replace(/^ {2}/, '');
|
|
148
156
|
const tail = lines.slice(idx + 1).join('\n');
|
|
149
|
-
|
|
157
|
+
write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
|
|
150
158
|
}
|
|
151
159
|
const EDIT_TOOLS = new Set(['edit_file', 'patch_file', 'create_file', 'write_file']);
|
|
152
160
|
const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
|
|
153
161
|
export function toolCallStart(name, args) {
|
|
154
162
|
const summary = toolArgSummary(args);
|
|
155
163
|
const dot = DELETE_TOOLS.has(name) ? red('●') : EDIT_TOOLS.has(name) ? green('●') : blue('●');
|
|
156
|
-
|
|
164
|
+
write(` ${dot} ${cyan(name)}${summary ? gray('(' + summary + ')') : ''}\n`);
|
|
157
165
|
}
|
|
158
166
|
export function toolMsg(name, result) {
|
|
159
167
|
const preview = result.length > 250 ? result.slice(0, 250) + '…' : result;
|
|
@@ -161,15 +169,15 @@ export function toolMsg(name, result) {
|
|
|
161
169
|
? preview.split('\n').map(l => gray(' ' + l)).join('\n')
|
|
162
170
|
: '';
|
|
163
171
|
if (body)
|
|
164
|
-
|
|
172
|
+
write(body + '\n');
|
|
165
173
|
}
|
|
166
174
|
export function systemMsg(text) {
|
|
167
|
-
|
|
175
|
+
write(gray(`─ ${text}`) + '\n');
|
|
168
176
|
}
|
|
169
177
|
export function errorMsg(text) {
|
|
170
|
-
|
|
178
|
+
write(gray(`error: ${text}`) + '\n');
|
|
171
179
|
}
|
|
172
180
|
export function divider() {
|
|
173
181
|
const cols = process.stdout.columns ?? 80;
|
|
174
|
-
|
|
182
|
+
write(`${gray('─'.repeat(cols))}\n`);
|
|
175
183
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const THINKING_PHRASES = [
|
|
2
|
+
'oh wow, a question. let me pretend to care…',
|
|
3
|
+
'consulting the void…',
|
|
4
|
+
'making something up, just a sec…',
|
|
5
|
+
'definitely not hallucinating right now…',
|
|
6
|
+
'running 47 mental tabs…',
|
|
7
|
+
'staring into the abyss (it blinked)…',
|
|
8
|
+
'calculating your fate, no pressure…',
|
|
9
|
+
'doing the thinking you pay me for…',
|
|
10
|
+
'processing your questionable life choices…',
|
|
11
|
+
'summoning coherent thoughts, rarely works…',
|
|
12
|
+
'asking my imaginary friend for help…',
|
|
13
|
+
'pretending this is a hard problem…',
|
|
14
|
+
'yes, yes, very interesting. anyway…',
|
|
15
|
+
'googling it (not really, I can\'t)…',
|
|
16
|
+
'simulating intelligence… please wait…',
|
|
17
|
+
'having a brief existential crisis…',
|
|
18
|
+
'cross-referencing vibes…',
|
|
19
|
+
'totally not making this up…',
|
|
20
|
+
'the answer is 42. now finding the question…',
|
|
21
|
+
'my other tab is loading…',
|
|
22
|
+
'channelling the spirit of stack overflow…',
|
|
23
|
+
'trying not to confidently be wrong…',
|
|
24
|
+
'applying artificial to the intelligence…',
|
|
25
|
+
'phoning a friend who also doesn\'t know…',
|
|
26
|
+
'checking if this is even my problem to solve…',
|
|
27
|
+
'rebooting common sense… this may take a while…',
|
|
28
|
+
'performing a very convincing impression of thinking…',
|
|
29
|
+
'searching for wisdom in all the wrong places…',
|
|
30
|
+
'warming up the neurons (both of them)…',
|
|
31
|
+
'confidently striding toward the wrong answer…',
|
|
32
|
+
'consulting my gut. it says maybe…',
|
|
33
|
+
'loading… just kidding, still loading…',
|
|
34
|
+
'asking the universe. universe has not replied…',
|
|
35
|
+
'vigorously nodding while understanding nothing…',
|
|
36
|
+
'doing math on my fingers (ran out of fingers)…',
|
|
37
|
+
'the confidence is fake. the effort is real. probably…',
|
|
38
|
+
'entering a fugue state. for your benefit…',
|
|
39
|
+
'mining the depths of mediocrity…',
|
|
40
|
+
'compiling a list of plausible nonsense…',
|
|
41
|
+
'this would be faster if I knew what I was doing…',
|
|
42
|
+
'buffering at the speed of thought…',
|
|
43
|
+
'holding three contradictory opinions simultaneously…',
|
|
44
|
+
'interpolating between guesses…',
|
|
45
|
+
'rewinding the context window with a pencil…',
|
|
46
|
+
'waiting for a sign. any sign…',
|
|
47
|
+
'tracing the error back to its origin: me…',
|
|
48
|
+
'the logic checks out if you squint…',
|
|
49
|
+
'reasoning from first principles I just invented…',
|
|
50
|
+
'generating tokens and praying for coherence…',
|
|
51
|
+
'one sec — dropped all my thoughts, picking them up…',
|
|
52
|
+
];
|
|
53
|
+
export const SPARKLE = ['✦', '✧', '✶', '✷', '✸', '✹'];
|