@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.
Files changed (102) hide show
  1. package/README.md +181 -0
  2. package/dist/agent/conversation.d.ts.map +1 -0
  3. package/dist/agent/loop.d.ts.map +1 -0
  4. package/dist/agent/planner.d.ts.map +1 -0
  5. package/dist/auth/client.d.ts.map +1 -0
  6. package/dist/auth/config.d.ts.map +1 -0
  7. package/dist/commands/chat.d.ts.map +1 -0
  8. package/dist/commands/config.d.ts.map +1 -0
  9. package/dist/commands/login.d.ts.map +1 -0
  10. package/dist/commands/marketplace.d.ts.map +1 -0
  11. package/dist/commands/models.d.ts.map +1 -0
  12. package/dist/compact/history-store.d.ts.map +1 -0
  13. package/dist/compact/microcompact.d.ts.map +1 -0
  14. package/dist/compact/service.d.ts.map +1 -0
  15. package/dist/context/analyzer.d.ts.map +1 -0
  16. package/dist/context/builder.d.ts.map +1 -0
  17. package/dist/context/cache.d.ts.map +1 -0
  18. package/dist/context/git-context.d.ts.map +1 -0
  19. package/dist/context/llmtune-md.d.ts.map +1 -0
  20. package/dist/context/workspace.d.ts.map +1 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +125 -0
  23. package/dist/marketplace/client.d.ts.map +1 -0
  24. package/dist/memory/files.d.ts.map +1 -0
  25. package/dist/memory/service.d.ts.map +1 -0
  26. package/dist/repl/repl.d.ts.map +1 -0
  27. package/dist/skills/args.d.ts.map +1 -0
  28. package/dist/skills/frontmatter.d.ts.map +1 -0
  29. package/dist/skills/loader.d.ts.map +1 -0
  30. package/dist/skills/registry.d.ts.map +1 -0
  31. package/dist/skills/signing/signer.d.ts.map +1 -0
  32. package/dist/skills/trust.d.ts.map +1 -0
  33. package/dist/telemetry/logger.d.ts.map +1 -0
  34. package/dist/tools/permissions.d.ts.map +1 -0
  35. package/dist/tools/protocol.d.ts.map +1 -0
  36. package/dist/tools/registry.d.ts.map +1 -0
  37. package/dist/tools/sandbox/docker.d.ts.map +1 -0
  38. package/dist/tools/sandbox/index.d.ts.map +1 -0
  39. package/dist/tools/tools/ask-user.d.ts.map +1 -0
  40. package/dist/tools/tools/bash.d.ts.map +1 -0
  41. package/dist/tools/tools/edit.d.ts.map +1 -0
  42. package/dist/tools/tools/glob.d.ts.map +1 -0
  43. package/dist/tools/tools/grep.d.ts.map +1 -0
  44. package/dist/tools/tools/read.d.ts.map +1 -0
  45. package/dist/tools/tools/web-fetch.d.ts.map +1 -0
  46. package/dist/tools/tools/write.d.ts.map +1 -0
  47. package/dist/tools/validation.d.ts.map +1 -0
  48. package/dist/utils/markdown.d.ts.map +1 -0
  49. package/dist/utils/streaming.d.ts.map +1 -0
  50. package/dist/utils/tokens.d.ts.map +1 -0
  51. package/docs/SKILL_AUTHORING.md +175 -0
  52. package/package.json +38 -0
  53. package/src/agent/conversation.ts +140 -0
  54. package/src/agent/loop.ts +215 -0
  55. package/src/agent/planner.ts +55 -0
  56. package/src/auth/client.ts +19 -0
  57. package/src/auth/config.ts +89 -0
  58. package/src/commands/chat.ts +28 -0
  59. package/src/commands/config.ts +36 -0
  60. package/src/commands/login.ts +63 -0
  61. package/src/commands/marketplace.ts +190 -0
  62. package/src/commands/models.ts +74 -0
  63. package/src/compact/history-store.ts +101 -0
  64. package/src/compact/microcompact.ts +49 -0
  65. package/src/compact/service.ts +154 -0
  66. package/src/context/analyzer.ts +127 -0
  67. package/src/context/builder.ts +123 -0
  68. package/src/context/cache.ts +11 -0
  69. package/src/context/git-context.ts +58 -0
  70. package/src/context/llmtune-md.ts +48 -0
  71. package/src/context/workspace.ts +139 -0
  72. package/src/index.ts +100 -0
  73. package/src/marketplace/client.ts +118 -0
  74. package/src/memory/files.ts +81 -0
  75. package/src/memory/service.ts +124 -0
  76. package/src/repl/repl.ts +400 -0
  77. package/src/skills/args.ts +35 -0
  78. package/src/skills/builtin/explain-code/SKILL.md +30 -0
  79. package/src/skills/frontmatter.ts +47 -0
  80. package/src/skills/loader.ts +25 -0
  81. package/src/skills/registry.ts +155 -0
  82. package/src/skills/signing/signer.ts +101 -0
  83. package/src/skills/trust.ts +50 -0
  84. package/src/telemetry/logger.ts +108 -0
  85. package/src/tools/permissions.ts +83 -0
  86. package/src/tools/protocol.ts +24 -0
  87. package/src/tools/registry.ts +93 -0
  88. package/src/tools/sandbox/docker.ts +225 -0
  89. package/src/tools/sandbox/index.ts +91 -0
  90. package/src/tools/tools/ask-user.ts +60 -0
  91. package/src/tools/tools/bash.ts +97 -0
  92. package/src/tools/tools/edit.ts +111 -0
  93. package/src/tools/tools/glob.ts +68 -0
  94. package/src/tools/tools/grep.ts +121 -0
  95. package/src/tools/tools/read.ts +57 -0
  96. package/src/tools/tools/web-fetch.ts +158 -0
  97. package/src/tools/tools/write.ts +52 -0
  98. package/src/tools/validation.ts +164 -0
  99. package/src/utils/markdown.ts +96 -0
  100. package/src/utils/streaming.ts +63 -0
  101. package/src/utils/tokens.ts +41 -0
  102. 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
+ }