@llmtune/cli 0.1.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 +181 -0
- package/dist/agent/conversation.d.ts.map +1 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/planner.d.ts.map +1 -0
- package/dist/auth/client.d.ts.map +1 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/marketplace.d.ts.map +1 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/compact/history-store.d.ts.map +1 -0
- package/dist/compact/microcompact.d.ts.map +1 -0
- package/dist/compact/service.d.ts.map +1 -0
- package/dist/context/analyzer.d.ts.map +1 -0
- package/dist/context/builder.d.ts.map +1 -0
- package/dist/context/cache.d.ts.map +1 -0
- package/dist/context/git-context.d.ts.map +1 -0
- package/dist/context/llmtune-md.d.ts.map +1 -0
- package/dist/context/workspace.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +125 -0
- package/dist/marketplace/client.d.ts.map +1 -0
- package/dist/memory/files.d.ts.map +1 -0
- package/dist/memory/service.d.ts.map +1 -0
- package/dist/repl/repl.d.ts.map +1 -0
- package/dist/skills/args.d.ts.map +1 -0
- package/dist/skills/frontmatter.d.ts.map +1 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/registry.d.ts.map +1 -0
- package/dist/skills/signing/signer.d.ts.map +1 -0
- package/dist/skills/trust.d.ts.map +1 -0
- package/dist/telemetry/logger.d.ts.map +1 -0
- package/dist/tools/permissions.d.ts.map +1 -0
- package/dist/tools/protocol.d.ts.map +1 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/sandbox/docker.d.ts.map +1 -0
- package/dist/tools/sandbox/index.d.ts.map +1 -0
- package/dist/tools/tools/ask-user.d.ts.map +1 -0
- package/dist/tools/tools/bash.d.ts.map +1 -0
- package/dist/tools/tools/edit.d.ts.map +1 -0
- package/dist/tools/tools/glob.d.ts.map +1 -0
- package/dist/tools/tools/grep.d.ts.map +1 -0
- package/dist/tools/tools/read.d.ts.map +1 -0
- package/dist/tools/tools/web-fetch.d.ts.map +1 -0
- package/dist/tools/tools/write.d.ts.map +1 -0
- package/dist/tools/validation.d.ts.map +1 -0
- package/dist/utils/markdown.d.ts.map +1 -0
- package/dist/utils/streaming.d.ts.map +1 -0
- package/dist/utils/tokens.d.ts.map +1 -0
- package/docs/SKILL_AUTHORING.md +175 -0
- package/package.json +38 -0
- package/src/agent/conversation.ts +140 -0
- package/src/agent/loop.ts +215 -0
- package/src/agent/planner.ts +55 -0
- package/src/auth/client.ts +19 -0
- package/src/auth/config.ts +89 -0
- package/src/commands/chat.ts +28 -0
- package/src/commands/config.ts +36 -0
- package/src/commands/login.ts +63 -0
- package/src/commands/marketplace.ts +190 -0
- package/src/commands/models.ts +74 -0
- package/src/compact/history-store.ts +101 -0
- package/src/compact/microcompact.ts +49 -0
- package/src/compact/service.ts +154 -0
- package/src/context/analyzer.ts +127 -0
- package/src/context/builder.ts +123 -0
- package/src/context/cache.ts +11 -0
- package/src/context/git-context.ts +58 -0
- package/src/context/llmtune-md.ts +48 -0
- package/src/context/workspace.ts +139 -0
- package/src/index.ts +100 -0
- package/src/marketplace/client.ts +118 -0
- package/src/memory/files.ts +81 -0
- package/src/memory/service.ts +124 -0
- package/src/repl/repl.ts +400 -0
- package/src/skills/args.ts +35 -0
- package/src/skills/builtin/explain-code/SKILL.md +30 -0
- package/src/skills/frontmatter.ts +47 -0
- package/src/skills/loader.ts +25 -0
- package/src/skills/registry.ts +155 -0
- package/src/skills/signing/signer.ts +101 -0
- package/src/skills/trust.ts +50 -0
- package/src/telemetry/logger.ts +108 -0
- package/src/tools/permissions.ts +83 -0
- package/src/tools/protocol.ts +24 -0
- package/src/tools/registry.ts +93 -0
- package/src/tools/sandbox/docker.ts +225 -0
- package/src/tools/sandbox/index.ts +91 -0
- package/src/tools/tools/ask-user.ts +60 -0
- package/src/tools/tools/bash.ts +97 -0
- package/src/tools/tools/edit.ts +111 -0
- package/src/tools/tools/glob.ts +68 -0
- package/src/tools/tools/grep.ts +121 -0
- package/src/tools/tools/read.ts +57 -0
- package/src/tools/tools/web-fetch.ts +158 -0
- package/src/tools/tools/write.ts +52 -0
- package/src/tools/validation.ts +164 -0
- package/src/utils/markdown.ts +96 -0
- package/src/utils/streaming.ts +63 -0
- package/src/utils/tokens.ts +41 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Skill Authoring Guide
|
|
2
|
+
|
|
3
|
+
Skills are reusable AI workflows that extend the LLMTune CLI. Each skill is defined by a `SKILL.md` file inside a named directory.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
Create a skill in `~/.llmtune/skills/` or `.llmtune/skills/` in your project:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
~/.llmtune/skills/
|
|
11
|
+
my-skill/
|
|
12
|
+
SKILL.md
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## SKILL.md Format
|
|
16
|
+
|
|
17
|
+
A skill file has two parts: **YAML frontmatter** (optional) and **Markdown body** (the prompt template).
|
|
18
|
+
|
|
19
|
+
```markdown
|
|
20
|
+
---
|
|
21
|
+
description: "Fix ESLint errors in a file"
|
|
22
|
+
user-invocable: true
|
|
23
|
+
allowed-tools: [read, edit, glob, grep]
|
|
24
|
+
arguments: file_path
|
|
25
|
+
trust: local
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
You are a linting expert. Fix all ESLint errors in the file at `{{file_path}}`.
|
|
29
|
+
|
|
30
|
+
1. Read the file
|
|
31
|
+
2. Identify lint errors
|
|
32
|
+
3. Fix each error while preserving the original intent
|
|
33
|
+
4. Report what you changed
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Frontmatter Fields
|
|
37
|
+
|
|
38
|
+
| Field | Type | Default | Description |
|
|
39
|
+
|-------|------|---------|-------------|
|
|
40
|
+
| `description` | string | (required) | Short description shown in `/skills` list |
|
|
41
|
+
| `user-invocable` | boolean | `true` | Whether users can invoke with `/<skill-name>` |
|
|
42
|
+
| `allowed-tools` | string[] | all tools | Restrict which tools the skill can use |
|
|
43
|
+
| `arguments` | string or string[] | none | Argument names for substitution |
|
|
44
|
+
| `trust` | string | `local` | Trust level: `local`, `community`, `verified`, `signed` |
|
|
45
|
+
| `when-to-use` | string | none | Hint for when the agent should auto-invoke this skill |
|
|
46
|
+
|
|
47
|
+
## Argument Substitution
|
|
48
|
+
|
|
49
|
+
Arguments are referenced in the body using `{{arg_name}}` syntax:
|
|
50
|
+
|
|
51
|
+
```markdown
|
|
52
|
+
---
|
|
53
|
+
arguments: file_path, style
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
Refactor the file at `{{file_path}}` to use `{{style}}` coding style.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
When invoked: `/my-skill src/auth.ts functional`
|
|
60
|
+
|
|
61
|
+
- `{{file_path}}` becomes `src/auth.ts`
|
|
62
|
+
- `{{style}}` becomes `functional`
|
|
63
|
+
|
|
64
|
+
## Trust Levels
|
|
65
|
+
|
|
66
|
+
Skills have four trust levels that determine what tools they can access:
|
|
67
|
+
|
|
68
|
+
| Level | Tools Allowed | Use Case |
|
|
69
|
+
|-------|--------------|----------|
|
|
70
|
+
| **local** | All | Skills you create yourself in `~/.llmtune/skills/` |
|
|
71
|
+
| **community** | read, glob, grep only | Skills installed from marketplace |
|
|
72
|
+
| **verified** | All | Skills reviewed by the LLMTune team |
|
|
73
|
+
| **signed** | All | Cryptographically signed skills with verified authors |
|
|
74
|
+
|
|
75
|
+
## Invoking Skills
|
|
76
|
+
|
|
77
|
+
### From the CLI REPL
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
> /explain-code src/auth.ts
|
|
81
|
+
> /fix-lint src/utils/helpers.ts
|
|
82
|
+
> /generate-test src/services/user.ts
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### From Commander (non-interactive)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
llmtune skills run explain-code --args "src/auth.ts"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Built-in Skills
|
|
92
|
+
|
|
93
|
+
The LLMTune CLI includes these built-in skills:
|
|
94
|
+
|
|
95
|
+
| Skill | Description | Arguments |
|
|
96
|
+
|-------|-------------|-----------|
|
|
97
|
+
| `explain-code` | Explain code with a clear breakdown | `file_path` |
|
|
98
|
+
| `fix-lint` | Auto-fix linting errors | `file_path` |
|
|
99
|
+
| `generate-test` | Generate unit tests for a file | `file_path` |
|
|
100
|
+
| `security-review` | Review code for security issues | `file_path` |
|
|
101
|
+
|
|
102
|
+
## Publishing to the Marketplace
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Sign your skill
|
|
106
|
+
llmtune skills sign my-skill
|
|
107
|
+
|
|
108
|
+
# Publish to marketplace
|
|
109
|
+
llmtune skills publish my-skill
|
|
110
|
+
|
|
111
|
+
# Install from marketplace
|
|
112
|
+
llmtune skills install security-review
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Examples
|
|
116
|
+
|
|
117
|
+
### Code Review Skill
|
|
118
|
+
|
|
119
|
+
```markdown
|
|
120
|
+
---
|
|
121
|
+
description: "Review code for best practices and potential issues"
|
|
122
|
+
allowed-tools: [read, glob, grep]
|
|
123
|
+
arguments: file_path
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
Review the code at `{{file_path}}` for:
|
|
127
|
+
|
|
128
|
+
1. **Security vulnerabilities** - XSS, injection, auth issues
|
|
129
|
+
2. **Performance problems** - N+1 queries, unnecessary re-renders, memory leaks
|
|
130
|
+
3. **Code quality** - Naming, structure, DRY violations
|
|
131
|
+
4. **Error handling** - Missing error cases, swallowed errors
|
|
132
|
+
5. **Type safety** - Any `any` types, missing null checks
|
|
133
|
+
|
|
134
|
+
Provide specific, actionable feedback with line numbers.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Database Migration Skill
|
|
138
|
+
|
|
139
|
+
```markdown
|
|
140
|
+
---
|
|
141
|
+
description: "Generate a Prisma migration for schema changes"
|
|
142
|
+
allowed-tools: [read, write, edit, bash]
|
|
143
|
+
arguments: description
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
The user wants to make this database change: {{description}}
|
|
147
|
+
|
|
148
|
+
1. Read the current prisma/schema.prisma
|
|
149
|
+
2. Make the necessary schema changes
|
|
150
|
+
3. Generate the migration using `npx prisma migrate dev --name <descriptive-name>`
|
|
151
|
+
4. Report what changed
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### API Endpoint Skill
|
|
155
|
+
|
|
156
|
+
```markdown
|
|
157
|
+
---
|
|
158
|
+
description: "Scaffold a new REST API endpoint"
|
|
159
|
+
allowed-tools: [read, write, edit, glob, grep]
|
|
160
|
+
arguments: method, path
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
Create a new API endpoint:
|
|
164
|
+
|
|
165
|
+
- Method: `{{method}}`
|
|
166
|
+
- Path: `{{path}}`
|
|
167
|
+
|
|
168
|
+
1. Find existing route files to understand the pattern
|
|
169
|
+
2. Create the route handler
|
|
170
|
+
3. Add input validation
|
|
171
|
+
4. Add error handling
|
|
172
|
+
5. Register the route
|
|
173
|
+
|
|
174
|
+
Follow the existing code patterns in the project.
|
|
175
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@llmtune/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LLMTune CLI - AI coding agent powered by api.llmtune.io",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"llmtune": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"lint": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["llmtune", "cli", "ai", "agent", "coding"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18.0.0"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"commander": "^13.1.0",
|
|
22
|
+
"openai": "^4.78.0",
|
|
23
|
+
"chalk": "^5.3.0",
|
|
24
|
+
"ora": "^8.1.0",
|
|
25
|
+
"@inquirer/prompts": "^7.3.0",
|
|
26
|
+
"marked": "^15.0.0",
|
|
27
|
+
"marked-terminal": "^7.3.0",
|
|
28
|
+
"simple-git": "^3.27.0",
|
|
29
|
+
"glob": "^11.0.0",
|
|
30
|
+
"zod": "^3.24.0",
|
|
31
|
+
"yaml": "^2.6.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^5.7.0",
|
|
35
|
+
"tsx": "^4.19.0",
|
|
36
|
+
"@types/node": "^22.10.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import crypto from "crypto"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import os from "os"
|
|
5
|
+
|
|
6
|
+
export interface Message {
|
|
7
|
+
role: "system" | "user" | "assistant" | "tool"
|
|
8
|
+
content: string
|
|
9
|
+
toolCalls?: ToolCallMessage[]
|
|
10
|
+
toolCallId?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ToolCallMessage {
|
|
14
|
+
id: string
|
|
15
|
+
type: "function"
|
|
16
|
+
function: {
|
|
17
|
+
name: string
|
|
18
|
+
arguments: string
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ConversationMeta {
|
|
23
|
+
id: string
|
|
24
|
+
createdAt: string
|
|
25
|
+
updatedAt: string
|
|
26
|
+
model: string
|
|
27
|
+
messageCount: number
|
|
28
|
+
totalTokens?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Conversation {
|
|
32
|
+
messages: Message[] = []
|
|
33
|
+
id: string
|
|
34
|
+
model: string
|
|
35
|
+
createdAt: Date
|
|
36
|
+
totalTokens = 0
|
|
37
|
+
|
|
38
|
+
constructor(model: string) {
|
|
39
|
+
this.id = crypto.randomUUID()
|
|
40
|
+
this.model = model
|
|
41
|
+
this.createdAt = new Date()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
addSystemMessage(content: string): void {
|
|
45
|
+
this.messages.push({ role: "system", content })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
addUserMessage(content: string): void {
|
|
49
|
+
this.messages.push({ role: "user", content })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addAssistantMessage(content: string, toolCalls?: ToolCallMessage[]): void {
|
|
53
|
+
this.messages.push({ role: "assistant", content, toolCalls })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
addToolResult(toolCallId: string, content: string | Record<string, unknown>, isError?: boolean): void {
|
|
57
|
+
const text = typeof content === "string" ? content : JSON.stringify(content)
|
|
58
|
+
this.messages.push({
|
|
59
|
+
role: "tool",
|
|
60
|
+
content: isError ? `Error: ${text}` : text,
|
|
61
|
+
toolCallId,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
clear(): void {
|
|
66
|
+
this.messages = []
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getApiMessages(): Message[] {
|
|
70
|
+
return [...this.messages]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getTokenEstimate(): number {
|
|
74
|
+
return this.messages.reduce((total, msg) => {
|
|
75
|
+
const content =
|
|
76
|
+
typeof msg.content === "string"
|
|
77
|
+
? msg.content
|
|
78
|
+
: JSON.stringify(msg.content)
|
|
79
|
+
return total + Math.ceil(content.length / 4)
|
|
80
|
+
}, 0)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getMeta(): ConversationMeta {
|
|
84
|
+
return {
|
|
85
|
+
id: this.id,
|
|
86
|
+
createdAt: this.createdAt.toISOString(),
|
|
87
|
+
updatedAt: new Date().toISOString(),
|
|
88
|
+
model: this.model,
|
|
89
|
+
messageCount: this.messages.length,
|
|
90
|
+
totalTokens: this.totalTokens || undefined,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
save(sessionsDir?: string): string {
|
|
95
|
+
const dir = sessionsDir || path.join(os.homedir(), ".llmtune", "sessions")
|
|
96
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
97
|
+
const filePath = path.join(dir, `${this.id}.json`)
|
|
98
|
+
const data = {
|
|
99
|
+
meta: this.getMeta(),
|
|
100
|
+
messages: this.messages,
|
|
101
|
+
}
|
|
102
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8")
|
|
103
|
+
return filePath
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static load(filePath: string): Conversation {
|
|
107
|
+
const raw = fs.readFileSync(filePath, "utf-8")
|
|
108
|
+
const data = JSON.parse(raw) as {
|
|
109
|
+
meta: ConversationMeta
|
|
110
|
+
messages: Message[]
|
|
111
|
+
}
|
|
112
|
+
const conv = new Conversation(data.meta.model)
|
|
113
|
+
conv.id = data.meta.id
|
|
114
|
+
conv.createdAt = new Date(data.meta.createdAt)
|
|
115
|
+
conv.messages = data.messages
|
|
116
|
+
conv.totalTokens = data.meta.totalTokens || 0
|
|
117
|
+
return conv
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static listSessions(sessionsDir?: string): ConversationMeta[] {
|
|
121
|
+
const dir = sessionsDir || path.join(os.homedir(), ".llmtune", "sessions")
|
|
122
|
+
if (!fs.existsSync(dir)) return []
|
|
123
|
+
return fs
|
|
124
|
+
.readdirSync(dir)
|
|
125
|
+
.filter((f) => f.endsWith(".json"))
|
|
126
|
+
.map((f) => {
|
|
127
|
+
try {
|
|
128
|
+
const raw = fs.readFileSync(path.join(dir, f), "utf-8")
|
|
129
|
+
return (JSON.parse(raw) as { meta: ConversationMeta }).meta
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
.filter((m): m is ConversationMeta => m !== null)
|
|
135
|
+
.sort(
|
|
136
|
+
(a, b) =>
|
|
137
|
+
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import OpenAI from "openai"
|
|
2
|
+
import { ToolRegistry, type ToolContext } from "../tools/registry"
|
|
3
|
+
import { Conversation, type ToolCallMessage } from "./conversation"
|
|
4
|
+
import { buildContextPrompt } from "../context/builder"
|
|
5
|
+
import chalk from "chalk"
|
|
6
|
+
|
|
7
|
+
export interface AgentLoopConfig {
|
|
8
|
+
model?: string
|
|
9
|
+
maxTurns?: number
|
|
10
|
+
verbose?: boolean
|
|
11
|
+
cwd: string
|
|
12
|
+
workspaceRoot: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AgentLoopResult {
|
|
16
|
+
finalText: string
|
|
17
|
+
totalToolCalls: number
|
|
18
|
+
totalTokensIn: number
|
|
19
|
+
totalTokensOut: number
|
|
20
|
+
turns: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runAgentLoop(
|
|
24
|
+
client: OpenAI,
|
|
25
|
+
conversation: Conversation,
|
|
26
|
+
registry: ToolRegistry,
|
|
27
|
+
userInput: string,
|
|
28
|
+
config: AgentLoopConfig,
|
|
29
|
+
onTextChunk?: (text: string) => void
|
|
30
|
+
): Promise<AgentLoopResult> {
|
|
31
|
+
const model = config.model ?? "z-ai/GLM-5.1"
|
|
32
|
+
const maxTurns = config.maxTurns ?? 20
|
|
33
|
+
|
|
34
|
+
conversation.addUserMessage(userInput)
|
|
35
|
+
|
|
36
|
+
const toolSpecs = registry.listSpecs()
|
|
37
|
+
const openaiTools: OpenAI.ChatCompletionTool[] = toolSpecs.map((spec) => ({
|
|
38
|
+
type: "function" as const,
|
|
39
|
+
function: {
|
|
40
|
+
name: spec.name,
|
|
41
|
+
description: spec.description,
|
|
42
|
+
parameters: spec.inputSchema as OpenAI.FunctionParameters,
|
|
43
|
+
},
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
const contextResult = await buildContextPrompt(config.workspaceRoot, config.cwd)
|
|
47
|
+
const contextPrompt = contextResult.prompt
|
|
48
|
+
|
|
49
|
+
let totalToolCalls = 0
|
|
50
|
+
let totalTokensIn = 0
|
|
51
|
+
let totalTokensOut = 0
|
|
52
|
+
let turns = 0
|
|
53
|
+
let finalText = ""
|
|
54
|
+
|
|
55
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
56
|
+
const apiMessages = conversation.getApiMessages()
|
|
57
|
+
const systemMessage: OpenAI.ChatCompletionSystemMessageParam = {
|
|
58
|
+
role: "system",
|
|
59
|
+
content: contextPrompt,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const allMessages: OpenAI.ChatCompletionMessageParam[] = [
|
|
63
|
+
systemMessage,
|
|
64
|
+
...apiMessages.map((msg): OpenAI.ChatCompletionMessageParam => {
|
|
65
|
+
if (msg.role === "system") return { role: "system", content: msg.content }
|
|
66
|
+
if (msg.role === "user") return { role: "user", content: msg.content }
|
|
67
|
+
if (msg.role === "assistant") {
|
|
68
|
+
const m: OpenAI.ChatCompletionAssistantMessageParam = {
|
|
69
|
+
role: "assistant",
|
|
70
|
+
content: msg.content || null,
|
|
71
|
+
}
|
|
72
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
73
|
+
m.tool_calls = msg.toolCalls.map((tc): OpenAI.ChatCompletionMessageToolCall => ({
|
|
74
|
+
id: tc.id,
|
|
75
|
+
type: "function",
|
|
76
|
+
function: { name: tc.function.name, arguments: tc.function.arguments },
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
return m
|
|
80
|
+
}
|
|
81
|
+
if (msg.role === "tool") {
|
|
82
|
+
return {
|
|
83
|
+
role: "tool",
|
|
84
|
+
tool_call_id: msg.toolCallId ?? "",
|
|
85
|
+
content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content),
|
|
86
|
+
} as OpenAI.ChatCompletionToolMessageParam
|
|
87
|
+
}
|
|
88
|
+
return { role: "user", content: msg.content }
|
|
89
|
+
}),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
const stream = await client.chat.completions.create({
|
|
93
|
+
model,
|
|
94
|
+
messages: allMessages,
|
|
95
|
+
tools: openaiTools.length > 0 ? openaiTools : undefined,
|
|
96
|
+
stream: true,
|
|
97
|
+
temperature: 0.7,
|
|
98
|
+
max_tokens: 16384,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
let assistantContent = ""
|
|
102
|
+
const toolCalls: ToolCallMessage[] = []
|
|
103
|
+
let currentToolCall: { id: string; name: string; arguments: string } | null = null
|
|
104
|
+
|
|
105
|
+
for await (const chunk of stream) {
|
|
106
|
+
const delta = chunk.choices[0]?.delta
|
|
107
|
+
if (!delta) continue
|
|
108
|
+
|
|
109
|
+
if (delta.content) {
|
|
110
|
+
assistantContent += delta.content
|
|
111
|
+
if (onTextChunk) onTextChunk(delta.content)
|
|
112
|
+
else process.stdout.write(delta.content)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (delta.tool_calls) {
|
|
116
|
+
for (const tc of delta.tool_calls) {
|
|
117
|
+
if (tc.id && tc.function?.name) {
|
|
118
|
+
currentToolCall = {
|
|
119
|
+
id: tc.id,
|
|
120
|
+
name: tc.function.name,
|
|
121
|
+
arguments: tc.function.arguments ?? "",
|
|
122
|
+
}
|
|
123
|
+
toolCalls.push({
|
|
124
|
+
id: tc.id,
|
|
125
|
+
type: "function",
|
|
126
|
+
function: { name: tc.function.name, arguments: tc.function.arguments ?? "" },
|
|
127
|
+
})
|
|
128
|
+
} else if (currentToolCall && tc.function?.arguments) {
|
|
129
|
+
currentToolCall.arguments += tc.function.arguments
|
|
130
|
+
const last = toolCalls[toolCalls.length - 1]
|
|
131
|
+
if (last) last.function.arguments = currentToolCall.arguments
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (chunk.usage) {
|
|
137
|
+
totalTokensIn += chunk.usage.prompt_tokens ?? 0
|
|
138
|
+
totalTokensOut += chunk.usage.completion_tokens ?? 0
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!onTextChunk) console.log()
|
|
143
|
+
turns++
|
|
144
|
+
|
|
145
|
+
if (toolCalls.length === 0) {
|
|
146
|
+
conversation.addAssistantMessage(assistantContent)
|
|
147
|
+
finalText = assistantContent
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
conversation.addAssistantMessage(assistantContent, toolCalls)
|
|
152
|
+
|
|
153
|
+
for (const tc of toolCalls) {
|
|
154
|
+
totalToolCalls++
|
|
155
|
+
let toolInput: Record<string, unknown>
|
|
156
|
+
try {
|
|
157
|
+
toolInput = JSON.parse(tc.function.arguments)
|
|
158
|
+
} catch {
|
|
159
|
+
toolInput = { raw: tc.function.arguments }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const summary = summarizeToolInput(tc.function.name, toolInput)
|
|
163
|
+
console.log(chalk.cyan(` ▶ ${tc.function.name}`) + chalk.dim(` ${summary}`))
|
|
164
|
+
|
|
165
|
+
const toolCtx: ToolContext = {
|
|
166
|
+
workspaceRoot: config.workspaceRoot,
|
|
167
|
+
cwd: config.cwd,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = await registry.dispatch(tc.function.name, toolInput, toolCtx)
|
|
171
|
+
|
|
172
|
+
if (result.isError) {
|
|
173
|
+
console.log(chalk.red(` ✗ ${tc.function.name}: ${String(result.output).slice(0, 200)}`))
|
|
174
|
+
} else {
|
|
175
|
+
console.log(chalk.green(` ✓ ${summarizeToolResult(tc.function.name, result.output)}`))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const resultStr = typeof result.output === "string" ? result.output : JSON.stringify(result.output)
|
|
179
|
+
conversation.addToolResult(tc.id, resultStr)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (turn === maxTurns - 1) {
|
|
183
|
+
finalText = "[Max tool turns reached]"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { finalText, totalToolCalls, totalTokensIn, totalTokensOut, turns }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function summarizeToolInput(name: string, input: Record<string, unknown>): string {
|
|
191
|
+
const n = name.toLowerCase()
|
|
192
|
+
if (n === "bash") {
|
|
193
|
+
const cmd = String(input.command ?? "").replace(/\n/g, " ")
|
|
194
|
+
return cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd
|
|
195
|
+
}
|
|
196
|
+
if (n === "read" || n === "write" || n === "edit") {
|
|
197
|
+
return String(input.file_path ?? input.path ?? "")
|
|
198
|
+
}
|
|
199
|
+
if (n === "glob") return String(input.pattern ?? "")
|
|
200
|
+
if (n === "grep") return String(input.pattern ?? "")
|
|
201
|
+
return ""
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function summarizeToolResult(name: string, output: unknown): string {
|
|
205
|
+
if (typeof output === "string") {
|
|
206
|
+
return output.length > 100 ? output.slice(0, 97) + "..." : output
|
|
207
|
+
}
|
|
208
|
+
if (typeof output === "object" && output !== null) {
|
|
209
|
+
const obj = output as Record<string, unknown>
|
|
210
|
+
if (obj.error) return `error: ${String(obj.error).slice(0, 80)}`
|
|
211
|
+
if (obj.numFiles !== undefined) return `${name} · ${obj.numFiles} results`
|
|
212
|
+
if (obj.exit_code !== undefined) return `${name} · exit ${obj.exit_code}`
|
|
213
|
+
}
|
|
214
|
+
return `${name} completed`
|
|
215
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
|
3
|
+
|
|
4
|
+
const PLANNER_SYSTEM_PROMPT = `You are a tool-use planner. Given a user message and available tools, decide:
|
|
5
|
+
1. Does this request need any tools? (simple questions like "what is 2+2" do not)
|
|
6
|
+
2. If yes, which specific tools are needed?
|
|
7
|
+
3. What is the execution plan?
|
|
8
|
+
|
|
9
|
+
Respond in this exact JSON format:
|
|
10
|
+
{"needs_tools": boolean, "tools_needed": ["tool_name", ...], "plan": "brief description"}
|
|
11
|
+
|
|
12
|
+
Only include tools that are actually necessary. Do not over-plan.`;
|
|
13
|
+
|
|
14
|
+
export interface PlanResult {
|
|
15
|
+
needsTools: boolean;
|
|
16
|
+
toolsNeeded: string[];
|
|
17
|
+
plan: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function planToolUsage(
|
|
21
|
+
client: OpenAI,
|
|
22
|
+
model: string,
|
|
23
|
+
userMessage: string,
|
|
24
|
+
availableTools: string[]
|
|
25
|
+
): Promise<PlanResult> {
|
|
26
|
+
try {
|
|
27
|
+
const response = await client.chat.completions.create({
|
|
28
|
+
model,
|
|
29
|
+
messages: [
|
|
30
|
+
{ role: "system", content: PLANNER_SYSTEM_PROMPT },
|
|
31
|
+
{
|
|
32
|
+
role: "user",
|
|
33
|
+
content: `Available tools: ${availableTools.join(", ")}\n\nUser message: ${userMessage}`,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
max_tokens: 200,
|
|
37
|
+
temperature: 0,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const content = response.choices[0]?.message?.content?.trim() ?? "";
|
|
41
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
42
|
+
if (!jsonMatch) {
|
|
43
|
+
return { needsTools: true, toolsNeeded: [], plan: "proceed with all tools" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
47
|
+
return {
|
|
48
|
+
needsTools: parsed.needs_tools ?? true,
|
|
49
|
+
toolsNeeded: parsed.tools_needed ?? [],
|
|
50
|
+
plan: parsed.plan ?? "",
|
|
51
|
+
};
|
|
52
|
+
} catch {
|
|
53
|
+
return { needsTools: true, toolsNeeded: [], plan: "" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import OpenAI from "openai"
|
|
2
|
+
import { loadConfig, getApiBase, getDefaultModel as getDefaultModelConfig } from "./config"
|
|
3
|
+
|
|
4
|
+
export function createClient(): OpenAI {
|
|
5
|
+
const apiKey = loadConfig().apiKey as string | undefined
|
|
6
|
+
if (!apiKey) {
|
|
7
|
+
console.error("Not logged in. Run: llmtune login")
|
|
8
|
+
process.exit(1)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return new OpenAI({
|
|
12
|
+
apiKey,
|
|
13
|
+
baseURL: getApiBase(),
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getDefaultModel(): string {
|
|
18
|
+
return getDefaultModelConfig()
|
|
19
|
+
}
|