miii-cli 0.2.1 → 0.2.2
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/package.json +6 -1
- package/.claude/settings.local.json +0 -28
- package/CONTRIBUTING.md +0 -55
- package/Makefile +0 -13
- package/install.sh +0 -6
- package/mii-cli.gif +0 -0
- package/src/config.ts +0 -32
- package/src/files/ops.ts +0 -89
- package/src/index.ts +0 -11
- package/src/init.ts +0 -41
- package/src/llm/ollama.ts +0 -110
- package/src/llm/stream.ts +0 -55
- package/src/parser/stream-parser.ts +0 -196
- package/src/sessions.ts +0 -54
- package/src/skills/loader.ts +0 -144
- package/src/tools/index.ts +0 -151
- package/src/tui/App.tsx +0 -355
- package/src/tui/InputBar.tsx +0 -381
- package/src/tui/components/AtPicker.tsx +0 -49
- package/src/tui/components/CommandPalette.tsx +0 -50
- package/src/tui/components/InputArea.tsx +0 -297
- package/src/tui/components/MessageList.tsx +0 -219
- package/src/tui/components/ModelPicker.tsx +0 -134
- package/src/tui/components/StatusBar.tsx +0 -36
- package/src/tui/printer.ts +0 -130
- package/src/types.ts +0 -26
- package/src/workers/context.worker.ts +0 -66
- package/src/workers/diff.worker.ts +0 -20
- package/src/workers/spawn.ts +0 -19
- package/tsconfig.json +0 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miii-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Claude Code-level terminal workflows powered by your local models.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
"terminal",
|
|
25
25
|
"local-ai"
|
|
26
26
|
],
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
27
32
|
"bin": {
|
|
28
33
|
"miii": "dist/index.js"
|
|
29
34
|
},
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(git -C /Users/akshay/Desktop/essentials/akshay-workspace/miii-cli status)",
|
|
5
|
-
"Bash(npm install *)",
|
|
6
|
-
"Bash(npx tsc *)",
|
|
7
|
-
"Bash(node --input-type=module)",
|
|
8
|
-
"Bash(chmod +x dist/index.js)",
|
|
9
|
-
"Bash(node dist/index.js --help)",
|
|
10
|
-
"Bash(node dist/index.js)",
|
|
11
|
-
"Bash(chmod +x install.sh)",
|
|
12
|
-
"Bash(npm whoami *)",
|
|
13
|
-
"Bash(npm run *)",
|
|
14
|
-
"Bash(npm publish *)",
|
|
15
|
-
"Bash(npm pkg *)",
|
|
16
|
-
"Bash(npm version *)",
|
|
17
|
-
"Bash(chmod +x *)",
|
|
18
|
-
"Bash(git grep *)",
|
|
19
|
-
"Bash(npm link *)",
|
|
20
|
-
"Bash(node -e \"const {appendFileSync} = require\\('fs'\\); appendFileSync\\('/tmp/miii-test.log', 'test\\\\n'\\); console.log\\('ok'\\)\")",
|
|
21
|
-
"Read(//tmp/**)",
|
|
22
|
-
"Read(//private/tmp/**)",
|
|
23
|
-
"Read(//Users/akshay/**)",
|
|
24
|
-
"Bash(tsx --version)",
|
|
25
|
-
"Bash(node -e ' *)"
|
|
26
|
-
]
|
|
27
|
-
}
|
|
28
|
-
}
|
package/CONTRIBUTING.md
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# Contributing to miii-cli
|
|
2
|
-
|
|
3
|
-
We welcome contributions! miii-cli is a community-driven project, and your help is invaluable whether you're fixing a bug, adding a feature, or improving documentation.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
### 🚀 How to Contribute
|
|
8
|
-
|
|
9
|
-
1. **Fork** the repository.
|
|
10
|
-
2. **Clone** your fork locally.
|
|
11
|
-
3. **Create a new branch** (`git checkout -b feature/my-awesome-feature`).
|
|
12
|
-
4. **Commit** your changes and **Push** to the branch.
|
|
13
|
-
5. **Open a Pull Request (PR)** against the `main` branch.
|
|
14
|
-
|
|
15
|
-
### 🤝 Guidelines
|
|
16
|
-
|
|
17
|
-
* **Code Style:** Please follow standard JavaScript/TypeScript best practices.
|
|
18
|
-
* **Testing:** All new features must include accompanying unit or integration tests.
|
|
19
|
-
* **Documentation:** If you change an API or add complex logic, please update the relevant documentation files.
|
|
20
|
-
|
|
21
|
-
### 📝 Workflow
|
|
22
|
-
|
|
23
|
-
* **Bugs:** If you find a bug, please report it on the Issues page with a clear reproduction guide and expected behavior.
|
|
24
|
-
* **Features:** Before starting a large feature, please open an Issue to discuss the scope and design with the core team.
|
|
25
|
-
|
|
26
|
-
### 🐛 Bug Reporting
|
|
27
|
-
|
|
28
|
-
When reporting a bug, please include:
|
|
29
|
-
|
|
30
|
-
1. **Title:** A concise summary of the issue.
|
|
31
|
-
2. **Environment:** (e.g., Node.js version, OS, browser)
|
|
32
|
-
3. **Steps to Reproduce:** A numbered list of exact steps to trigger the bug.
|
|
33
|
-
4. **Expected Result:** What should have happened.
|
|
34
|
-
5. **Actual Result:** What actually happened.
|
|
35
|
-
|
|
36
|
-
***
|
|
37
|
-
|
|
38
|
-
### 🛠 Development Setup
|
|
39
|
-
|
|
40
|
-
To work on miii-cli locally, please ensure you have the necessary dependencies installed.
|
|
41
|
-
|
|
42
|
-
1. **Install Dependencies:**
|
|
43
|
-
`npm install`
|
|
44
|
-
|
|
45
|
-
2. **Common Commands:**
|
|
46
|
-
We use a Makefile to streamline common tasks:
|
|
47
|
-
|
|
48
|
-
Development/Live Run: `make dev` (Runs the application in development mode)
|
|
49
|
-
Build Project: `make build` (Compiles the TypeScript source code)
|
|
50
|
-
Install Globally: `make install` (Links the project using `npm link`)
|
|
51
|
-
Clean Build: `make clean` (Removes compiled output)
|
|
52
|
-
|
|
53
|
-
### 🧪 Testing
|
|
54
|
-
|
|
55
|
-
When writing tests, please ensure they cover both the happy path and expected edge cases.
|
package/Makefile
DELETED
package/install.sh
DELETED
package/mii-cli.gif
DELETED
|
Binary file
|
package/src/config.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'fs'
|
|
2
|
-
import { homedir } from 'os'
|
|
3
|
-
import { join } from 'path'
|
|
4
|
-
import type { Config } from './types.js'
|
|
5
|
-
|
|
6
|
-
const defaults: Config = {
|
|
7
|
-
model: 'llama3.2',
|
|
8
|
-
provider: 'ollama',
|
|
9
|
-
baseUrl: 'http://localhost:11434',
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const ALLOWED_KEYS = new Set<keyof Config>(['model', 'provider', 'baseUrl', 'systemPrompt', 'apiKey'])
|
|
13
|
-
|
|
14
|
-
export function loadConfig(): Config {
|
|
15
|
-
const candidates = [
|
|
16
|
-
join(process.cwd(), '.miii.json'),
|
|
17
|
-
join(homedir(), '.config', 'miii', 'config.json'),
|
|
18
|
-
]
|
|
19
|
-
for (const p of candidates) {
|
|
20
|
-
if (existsSync(p)) {
|
|
21
|
-
try {
|
|
22
|
-
const raw: Record<string, unknown> = JSON.parse(readFileSync(p, 'utf-8'))
|
|
23
|
-
const safe: Partial<Config> = {}
|
|
24
|
-
for (const key of ALLOWED_KEYS) {
|
|
25
|
-
if (key in raw) (safe as Record<string, unknown>)[key] = raw[key]
|
|
26
|
-
}
|
|
27
|
-
return { ...defaults, ...safe }
|
|
28
|
-
} catch {}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return { ...defaults }
|
|
32
|
-
}
|
package/src/files/ops.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
readFileSync, writeFileSync, unlinkSync,
|
|
3
|
-
mkdirSync, readdirSync, statSync, existsSync, renameSync,
|
|
4
|
-
} from 'fs'
|
|
5
|
-
import { join, dirname, relative, extname, resolve, sep } from 'path'
|
|
6
|
-
|
|
7
|
-
export function guardPath(p: string, base = process.cwd()): string {
|
|
8
|
-
const abs = resolve(base, p)
|
|
9
|
-
const root = resolve(base)
|
|
10
|
-
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
11
|
-
throw new Error(`path outside working directory: ${p}`)
|
|
12
|
-
}
|
|
13
|
-
return abs
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const SKIP_DIRS = new Set([
|
|
17
|
-
'node_modules', 'dist', 'build', '.git', '.next', '.nuxt', '.svelte-kit',
|
|
18
|
-
'out', '__pycache__', '.cache', 'coverage', '.nyc_output', 'vendor',
|
|
19
|
-
'target', '.turbo', '.vercel', 'generated', '.gradle', '.expo',
|
|
20
|
-
'bin', 'obj', '.idea', '.vscode', 'tmp', 'temp', 'logs',
|
|
21
|
-
])
|
|
22
|
-
|
|
23
|
-
const SKIP_EXTS = new Set([
|
|
24
|
-
'.map', '.lock',
|
|
25
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff',
|
|
26
|
-
'.mp4', '.mp3', '.wav', '.ogg', '.pdf',
|
|
27
|
-
'.zip', '.tar', '.gz', '.rar', '.7z',
|
|
28
|
-
'.exe', '.dll', '.so', '.dylib', '.wasm', '.class', '.pyc',
|
|
29
|
-
'.ttf', '.woff', '.woff2', '.eot',
|
|
30
|
-
])
|
|
31
|
-
|
|
32
|
-
const SKIP_NAMES = new Set([
|
|
33
|
-
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock',
|
|
34
|
-
'poetry.lock', 'Gemfile.lock', 'composer.lock',
|
|
35
|
-
'.DS_Store', 'Thumbs.db', '.env.local', 'LICENSE', 'LICENSE.md',
|
|
36
|
-
])
|
|
37
|
-
|
|
38
|
-
export function readFile(p: string): string {
|
|
39
|
-
if (!existsSync(p)) return ''
|
|
40
|
-
return readFileSync(p, 'utf-8')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function writeFile(p: string, content: string): void {
|
|
44
|
-
mkdirSync(dirname(p), { recursive: true })
|
|
45
|
-
writeFileSync(p, content, 'utf-8')
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function deleteFile(p: string): void {
|
|
49
|
-
unlinkSync(p)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function createDir(p: string): void {
|
|
53
|
-
mkdirSync(p, { recursive: true })
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function moveFile(from: string, to: string): void {
|
|
57
|
-
mkdirSync(dirname(to), { recursive: true })
|
|
58
|
-
renameSync(from, to)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface FileEntry {
|
|
62
|
-
name: string
|
|
63
|
-
path: string
|
|
64
|
-
rel: string
|
|
65
|
-
type: 'file' | 'dir'
|
|
66
|
-
size?: number
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function listFiles(dir: string, recursive = false, cwd = process.cwd()): FileEntry[] {
|
|
70
|
-
if (!existsSync(dir)) return []
|
|
71
|
-
const entries: FileEntry[] = []
|
|
72
|
-
for (const name of readdirSync(dir)) {
|
|
73
|
-
if (name.startsWith('.')) continue
|
|
74
|
-
if (SKIP_NAMES.has(name)) continue
|
|
75
|
-
if (SKIP_DIRS.has(name)) continue
|
|
76
|
-
const ext = extname(name)
|
|
77
|
-
if (SKIP_EXTS.has(ext)) continue
|
|
78
|
-
if (name.endsWith('.d.ts') || name.endsWith('.js.map')) continue
|
|
79
|
-
let stat
|
|
80
|
-
try { stat = statSync(join(dir, name)) } catch { continue }
|
|
81
|
-
const full = join(dir, name)
|
|
82
|
-
const type = stat.isDirectory() ? 'dir' : 'file'
|
|
83
|
-
entries.push({ name, path: full, rel: relative(cwd, full), type, size: stat.isFile() ? stat.size : undefined })
|
|
84
|
-
if (recursive && type === 'dir') {
|
|
85
|
-
entries.push(...listFiles(full, true, cwd))
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return entries
|
|
89
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Minimal entry — heavy imports lazy-loaded
|
|
3
|
-
async function main() {
|
|
4
|
-
const { lazyInit } = await import('./init.js')
|
|
5
|
-
await lazyInit()
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
main().catch(err => {
|
|
9
|
-
process.stderr.write(`fatal: ${err.message}\n`)
|
|
10
|
-
process.exit(1)
|
|
11
|
-
})
|
package/src/init.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { render } from 'ink'
|
|
2
|
-
import React from 'react'
|
|
3
|
-
import minimist from 'minimist'
|
|
4
|
-
import { loadConfig } from './config.js'
|
|
5
|
-
import { SkillLoader } from './skills/loader.js'
|
|
6
|
-
import { InputBar } from './tui/InputBar.js'
|
|
7
|
-
import { welcome } from './tui/printer.js'
|
|
8
|
-
import { ensureOllama } from './llm/ollama.js'
|
|
9
|
-
|
|
10
|
-
export async function lazyInit(): Promise<void> {
|
|
11
|
-
const argv = minimist(process.argv.slice(2), {
|
|
12
|
-
string: ['model', 'url', 'provider', 'session'],
|
|
13
|
-
alias: { m: 'model', u: 'url', p: 'provider', s: 'session' },
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
const config = loadConfig()
|
|
17
|
-
if (argv.model) config.model = argv.model
|
|
18
|
-
if (argv.url) config.baseUrl = argv.url
|
|
19
|
-
if (argv.provider) config.provider = argv.provider as typeof config.provider
|
|
20
|
-
|
|
21
|
-
if (config.provider === 'ollama') {
|
|
22
|
-
await ensureOllama(config.baseUrl)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const skills = new SkillLoader()
|
|
26
|
-
await skills.loadAll()
|
|
27
|
-
|
|
28
|
-
// Print welcome banner to scrollback BEFORE Ink starts
|
|
29
|
-
welcome(config.provider, config.model, process.cwd())
|
|
30
|
-
|
|
31
|
-
// Ink renders ONLY the input bar (small footprint at bottom)
|
|
32
|
-
// patchConsole: true (default) ensures console.log output appears above Ink
|
|
33
|
-
const sessionName = (argv.session as string) || 'default'
|
|
34
|
-
|
|
35
|
-
const { waitUntilExit } = render(
|
|
36
|
-
React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName }),
|
|
37
|
-
{ exitOnCtrlC: false }
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
await waitUntilExit()
|
|
41
|
-
}
|
package/src/llm/ollama.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { execSync, spawn } from 'child_process'
|
|
2
|
-
|
|
3
|
-
export interface OllamaModel {
|
|
4
|
-
name: string
|
|
5
|
-
size: number
|
|
6
|
-
modified_at: string
|
|
7
|
-
digest?: string
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export async function listModels(baseUrl: string): Promise<OllamaModel[]> {
|
|
11
|
-
const res = await fetch(`${baseUrl}/api/tags`)
|
|
12
|
-
if (!res.ok) throw new Error(`Ollama ${res.status}: ${await res.text()}`)
|
|
13
|
-
const data = (await res.json()) as { models?: OllamaModel[] }
|
|
14
|
-
return data.models ?? []
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export async function pullModel(
|
|
18
|
-
baseUrl: string,
|
|
19
|
-
name: string,
|
|
20
|
-
onProgress: (status: string, pct: number | undefined) => void,
|
|
21
|
-
signal?: AbortSignal,
|
|
22
|
-
): Promise<void> {
|
|
23
|
-
const res = await fetch(`${baseUrl}/api/pull`, {
|
|
24
|
-
method: 'POST',
|
|
25
|
-
headers: { 'Content-Type': 'application/json' },
|
|
26
|
-
body: JSON.stringify({ name, stream: true }),
|
|
27
|
-
signal,
|
|
28
|
-
})
|
|
29
|
-
if (!res.ok) throw new Error(`pull failed: ${res.status} ${await res.text()}`)
|
|
30
|
-
|
|
31
|
-
const reader = res.body!.getReader()
|
|
32
|
-
const dec = new TextDecoder()
|
|
33
|
-
let buf = ''
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
while (true) {
|
|
37
|
-
const { done, value } = await reader.read()
|
|
38
|
-
if (done) break
|
|
39
|
-
buf += dec.decode(value, { stream: true })
|
|
40
|
-
const lines = buf.split('\n')
|
|
41
|
-
buf = lines.pop() ?? ''
|
|
42
|
-
for (const line of lines) {
|
|
43
|
-
if (!line.trim()) continue
|
|
44
|
-
try {
|
|
45
|
-
const obj = JSON.parse(line) as { status?: string; completed?: number; total?: number }
|
|
46
|
-
const pct = obj.total ? Math.round(((obj.completed ?? 0) / obj.total) * 100) : undefined
|
|
47
|
-
onProgress(obj.status ?? '', pct)
|
|
48
|
-
} catch {}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
} finally {
|
|
52
|
-
reader.releaseLock()
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export async function ensureOllama(baseUrl: string): Promise<void> {
|
|
57
|
-
if (await isReachable(baseUrl)) return
|
|
58
|
-
|
|
59
|
-
if (!isBinaryInstalled()) {
|
|
60
|
-
process.stderr.write('\nOllama not found. Install it:\n\n')
|
|
61
|
-
if (process.platform === 'darwin') {
|
|
62
|
-
process.stderr.write(' brew install ollama\n')
|
|
63
|
-
process.stderr.write(' — or download: https://ollama.ai/download\n')
|
|
64
|
-
} else if (process.platform === 'linux') {
|
|
65
|
-
process.stderr.write(' curl -fsSL https://ollama.ai/install.sh | sh\n')
|
|
66
|
-
} else {
|
|
67
|
-
process.stderr.write(' https://ollama.ai/download\n')
|
|
68
|
-
}
|
|
69
|
-
process.stderr.write('\nThen run: ollama serve\n\n')
|
|
70
|
-
process.exit(1)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
process.stderr.write('Ollama not running — starting ollama serve...\n')
|
|
74
|
-
spawn('ollama', ['serve'], { detached: true, stdio: 'ignore' }).unref()
|
|
75
|
-
|
|
76
|
-
for (let i = 0; i < 12; i++) {
|
|
77
|
-
await new Promise(r => setTimeout(r, 500))
|
|
78
|
-
if (await isReachable(baseUrl)) {
|
|
79
|
-
process.stderr.write('Ollama ready.\n')
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
process.stderr.write('Could not start Ollama. Run manually: ollama serve\n')
|
|
85
|
-
process.exit(1)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function isReachable(baseUrl: string): Promise<boolean> {
|
|
89
|
-
try {
|
|
90
|
-
const res = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(2000) })
|
|
91
|
-
return res.ok
|
|
92
|
-
} catch {
|
|
93
|
-
return false
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function isBinaryInstalled(): boolean {
|
|
98
|
-
try {
|
|
99
|
-
execSync(process.platform === 'win32' ? 'where ollama' : 'which ollama', { stdio: 'ignore' })
|
|
100
|
-
return true
|
|
101
|
-
} catch {
|
|
102
|
-
return false
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function fmtSize(bytes: number): string {
|
|
107
|
-
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)}GB`
|
|
108
|
-
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)}MB`
|
|
109
|
-
return `${(bytes / 1e3).toFixed(0)}KB`
|
|
110
|
-
}
|
package/src/llm/stream.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import type { ChatMessage } from '../types.js'
|
|
2
|
-
|
|
3
|
-
export interface ChatConfig {
|
|
4
|
-
provider: 'ollama' | 'openai-compat'
|
|
5
|
-
model: string
|
|
6
|
-
baseUrl: string
|
|
7
|
-
apiKey?: string
|
|
8
|
-
messages: ChatMessage[]
|
|
9
|
-
signal?: AbortSignal
|
|
10
|
-
onDone: (fullText: string) => void | Promise<void>
|
|
11
|
-
onError: (err: Error) => void
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function chat(cfg: ChatConfig): Promise<void> {
|
|
15
|
-
if (cfg.provider === 'openai-compat') return chatOpenAI(cfg)
|
|
16
|
-
return chatOllama(cfg)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async function chatOllama(cfg: ChatConfig): Promise<void> {
|
|
20
|
-
const { model, messages, baseUrl, signal, onDone, onError } = cfg
|
|
21
|
-
try {
|
|
22
|
-
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
23
|
-
method: 'POST',
|
|
24
|
-
headers: { 'Content-Type': 'application/json' },
|
|
25
|
-
body: JSON.stringify({ model, messages, stream: false }),
|
|
26
|
-
signal,
|
|
27
|
-
})
|
|
28
|
-
if (!res.ok) { onError(new Error(`Ollama ${res.status}: ${await res.text()}`)); return }
|
|
29
|
-
const obj = await res.json()
|
|
30
|
-
await onDone(obj?.message?.content ?? '')
|
|
31
|
-
} catch (err) {
|
|
32
|
-
if ((err as Error)?.name !== 'AbortError') onError(toError(err))
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function chatOpenAI(cfg: ChatConfig): Promise<void> {
|
|
37
|
-
const { model, messages, baseUrl, apiKey, signal, onDone, onError } = cfg
|
|
38
|
-
try {
|
|
39
|
-
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
40
|
-
method: 'POST',
|
|
41
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey ?? 'local'}` },
|
|
42
|
-
body: JSON.stringify({ model, messages }),
|
|
43
|
-
signal,
|
|
44
|
-
})
|
|
45
|
-
if (!res.ok) { onError(new Error(`LLM ${res.status}: ${await res.text()}`)); return }
|
|
46
|
-
const obj = await res.json()
|
|
47
|
-
await onDone(obj?.choices?.[0]?.message?.content ?? '')
|
|
48
|
-
} catch (err) {
|
|
49
|
-
if ((err as Error)?.name !== 'AbortError') onError(toError(err))
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function toError(e: unknown): Error {
|
|
54
|
-
return e instanceof Error ? e : new Error(String(e))
|
|
55
|
-
}
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { appendFileSync } from 'fs'
|
|
2
|
-
|
|
3
|
-
export interface ParsedText { type: 'text'; content: string }
|
|
4
|
-
export interface ParsedTool { type: 'tool_call'; content: string; toolName: string; toolArgs: Record<string, unknown> }
|
|
5
|
-
export type ParsedItem = ParsedText | ParsedTool
|
|
6
|
-
|
|
7
|
-
const OPEN = '<tool_call>'
|
|
8
|
-
const CLOSE = '</tool_call>'
|
|
9
|
-
const CTAG_OPEN = '<content>'
|
|
10
|
-
const CTAG_CLOSE = '</content>'
|
|
11
|
-
const OLD_OPEN = '<old>'
|
|
12
|
-
const OLD_CLOSE = '</old>'
|
|
13
|
-
const NEW_OPEN = '<new>'
|
|
14
|
-
const NEW_CLOSE = '</new>'
|
|
15
|
-
const DEBUG_LOG = '/tmp/miii-debug.log'
|
|
16
|
-
|
|
17
|
-
function dbg(msg: string) {
|
|
18
|
-
try { appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`) } catch {}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Fix literal newlines/tabs inside JSON string values — common LLM output mistake
|
|
22
|
-
function sanitizeJson(s: string): string {
|
|
23
|
-
let result = ''
|
|
24
|
-
let inString = false
|
|
25
|
-
let escaped = false
|
|
26
|
-
for (const ch of s) {
|
|
27
|
-
if (escaped) {
|
|
28
|
-
result += ch
|
|
29
|
-
escaped = false
|
|
30
|
-
} else if (ch === '\\' && inString) {
|
|
31
|
-
result += ch
|
|
32
|
-
escaped = true
|
|
33
|
-
} else if (ch === '"') {
|
|
34
|
-
result += ch
|
|
35
|
-
inString = !inString
|
|
36
|
-
} else if (inString && ch === '\n') {
|
|
37
|
-
result += '\\n'
|
|
38
|
-
} else if (inString && ch === '\r') {
|
|
39
|
-
result += '\\r'
|
|
40
|
-
} else if (inString && ch === '\t') {
|
|
41
|
-
result += '\\t'
|
|
42
|
-
} else {
|
|
43
|
-
result += ch
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return result
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function parseToolJson(s: string): { name: string; args?: Record<string, unknown> } {
|
|
50
|
-
try { return JSON.parse(s) }
|
|
51
|
-
catch { return JSON.parse(sanitizeJson(s)) }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Find end of a JSON object starting at `from`, correctly tracking strings
|
|
55
|
-
function findJsonEnd(text: string, from: number): number {
|
|
56
|
-
let depth = 0, inStr = false, escaped = false
|
|
57
|
-
for (let i = from; i < text.length; i++) {
|
|
58
|
-
const ch = text[i]
|
|
59
|
-
if (escaped) { escaped = false; continue }
|
|
60
|
-
if (ch === '\\' && inStr) { escaped = true; continue }
|
|
61
|
-
if (ch === '"') { inStr = !inStr; continue }
|
|
62
|
-
if (inStr) continue
|
|
63
|
-
if (ch === '{') depth++
|
|
64
|
-
else if (ch === '}') { depth--; if (depth === 0) return i }
|
|
65
|
-
}
|
|
66
|
-
return -1
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// For file-writing tools: content field may have unescaped chars — extract with lastIndexOf heuristic
|
|
70
|
-
function extractFileToolArgs(text: string, toolName: string): Record<string, unknown> | null {
|
|
71
|
-
if (!text.includes(`"${toolName}"`)) return null
|
|
72
|
-
const args: Record<string, string> = {}
|
|
73
|
-
|
|
74
|
-
const pathM = text.match(/"path"\s*:\s*"([^"]*)"/)
|
|
75
|
-
if (pathM) args.path = pathM[1]
|
|
76
|
-
|
|
77
|
-
// content is always the last string field — find its opening quote, take to last " before final }}
|
|
78
|
-
const ctIdx = text.indexOf('"content"')
|
|
79
|
-
if (ctIdx !== -1) {
|
|
80
|
-
const colon = text.indexOf(':', ctIdx)
|
|
81
|
-
const openQ = text.indexOf('"', colon + 1)
|
|
82
|
-
const lastBrace = text.lastIndexOf('}')
|
|
83
|
-
const closeQ = text.lastIndexOf('"', lastBrace - 1)
|
|
84
|
-
if (openQ !== -1 && closeQ > openQ) {
|
|
85
|
-
const raw = text.slice(openQ + 1, closeQ)
|
|
86
|
-
args.content = raw
|
|
87
|
-
.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t')
|
|
88
|
-
.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// For patch_file: extract old/new fields
|
|
93
|
-
const oldM = text.match(/"old"\s*:\s*"([\s\S]*?)"(?:\s*,|\s*\})/)
|
|
94
|
-
if (oldM) args.old = oldM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
|
|
95
|
-
const newM = text.match(/"new"\s*:\s*"([\s\S]*?)"(?:\s*,|\s*\})/)
|
|
96
|
-
if (newM) args.new = newM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
|
|
97
|
-
|
|
98
|
-
return Object.keys(args).length > 0 ? args : null
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Extract a bare tool-call JSON from arbitrary text (LLM skipped <tool_call> wrapper)
|
|
102
|
-
export function extractBareToolCall(text: string): { name: string; args: Record<string, unknown> } | null {
|
|
103
|
-
// First try standard JSON parsing
|
|
104
|
-
let pos = 0
|
|
105
|
-
while (true) {
|
|
106
|
-
const start = text.indexOf('{"name"', pos)
|
|
107
|
-
if (start === -1) break
|
|
108
|
-
const end = findJsonEnd(text, start)
|
|
109
|
-
if (end === -1) break
|
|
110
|
-
try {
|
|
111
|
-
const obj = parseToolJson(text.slice(start, end + 1))
|
|
112
|
-
if (typeof obj.name === 'string') return { name: obj.name, args: (obj.args ?? {}) as Record<string, unknown> }
|
|
113
|
-
} catch {}
|
|
114
|
-
pos = start + 1
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Fallback: content-aware extraction for file-writing tools (immune to unescaped chars)
|
|
118
|
-
for (const name of ['edit_file', 'create_file', 'patch_file']) {
|
|
119
|
-
const args = extractFileToolArgs(text, name)
|
|
120
|
-
if (args) return { name, args }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return null
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export class StreamParser {
|
|
127
|
-
private buf = ''
|
|
128
|
-
private inTool = false
|
|
129
|
-
|
|
130
|
-
feed(token: string): ParsedItem[] {
|
|
131
|
-
this.buf += token
|
|
132
|
-
const out: ParsedItem[] = []
|
|
133
|
-
|
|
134
|
-
while (true) {
|
|
135
|
-
if (this.inTool) {
|
|
136
|
-
const end = this.buf.indexOf(CLOSE)
|
|
137
|
-
if (end === -1) break
|
|
138
|
-
const raw = this.buf.slice(0, end).trim()
|
|
139
|
-
this.buf = this.buf.slice(end + CLOSE.length)
|
|
140
|
-
this.inTool = false
|
|
141
|
-
try {
|
|
142
|
-
dbg(`raw block (${raw.length} chars): ${raw.slice(0, 300)}`)
|
|
143
|
-
// Extract named content blocks so file content never needs JSON escaping
|
|
144
|
-
const extraArgs: Record<string, string> = {}
|
|
145
|
-
let jsonPart = raw
|
|
146
|
-
|
|
147
|
-
function extractBlock(open: string, close: string, key: string): void {
|
|
148
|
-
const s = raw.indexOf(open), e = raw.indexOf(close)
|
|
149
|
-
if (s === -1 || e === -1 || e <= s) return
|
|
150
|
-
let val = raw.slice(s + open.length, e)
|
|
151
|
-
if (val.startsWith('\n')) val = val.slice(1)
|
|
152
|
-
if (val.endsWith('\n')) val = val.slice(0, -1)
|
|
153
|
-
extraArgs[key] = val
|
|
154
|
-
// shrink jsonPart to before the first block
|
|
155
|
-
const blockStart = raw.indexOf(open)
|
|
156
|
-
if (blockStart < jsonPart.length) jsonPart = raw.slice(0, blockStart).trim()
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
extractBlock(CTAG_OPEN, CTAG_CLOSE, 'content')
|
|
160
|
-
extractBlock(OLD_OPEN, OLD_CLOSE, 'old')
|
|
161
|
-
extractBlock(NEW_OPEN, NEW_CLOSE, 'new')
|
|
162
|
-
|
|
163
|
-
const obj = parseToolJson(jsonPart)
|
|
164
|
-
obj.args = { ...(obj.args ?? {}), ...extraArgs }
|
|
165
|
-
dbg(`parsed ok: name=${obj.name} args_keys=${Object.keys(obj.args).join(',')}`)
|
|
166
|
-
out.push({ type: 'tool_call', content: raw, toolName: obj.name, toolArgs: obj.args })
|
|
167
|
-
} catch (e) {
|
|
168
|
-
dbg(`parse FAILED: ${e} | raw: ${raw.slice(0, 300)}`)
|
|
169
|
-
out.push({ type: 'text', content: `${OPEN}${raw}${CLOSE}` })
|
|
170
|
-
}
|
|
171
|
-
} else {
|
|
172
|
-
const start = this.buf.indexOf(OPEN)
|
|
173
|
-
if (start === -1) {
|
|
174
|
-
const safe = this.buf.length > OPEN.length ? this.buf.slice(0, -OPEN.length) : ''
|
|
175
|
-
if (safe) { out.push({ type: 'text', content: safe }); this.buf = this.buf.slice(safe.length) }
|
|
176
|
-
break
|
|
177
|
-
}
|
|
178
|
-
if (start > 0) {
|
|
179
|
-
out.push({ type: 'text', content: this.buf.slice(0, start) })
|
|
180
|
-
this.buf = this.buf.slice(start)
|
|
181
|
-
}
|
|
182
|
-
this.buf = this.buf.slice(OPEN.length)
|
|
183
|
-
this.inTool = true
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return out
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
flush(): ParsedItem[] {
|
|
190
|
-
const out: ParsedItem[] = []
|
|
191
|
-
if (this.buf.trim()) out.push({ type: 'text', content: this.buf })
|
|
192
|
-
this.buf = ''
|
|
193
|
-
this.inTool = false
|
|
194
|
-
return out
|
|
195
|
-
}
|
|
196
|
-
}
|