otoro-cli 1.5.0 → 2.0.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/bin/otoro.js +82 -114
- package/lib/agent-loop.js +230 -0
- package/lib/chat.js +92 -176
- package/lib/tools.js +275 -147
- package/package.json +3 -7
package/bin/otoro.js
CHANGED
|
@@ -1,153 +1,121 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const { program } = require('commander')
|
|
3
|
-
const { initConfig, login, logout, isLoggedIn, requireAuth } = require('../lib/config')
|
|
4
|
-
const chalk = require('chalk')
|
|
5
|
-
const pkg = require('../package.json')
|
|
6
2
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
program
|
|
13
|
-
.command('login')
|
|
14
|
-
.description('Sign in with your Otoro account')
|
|
15
|
-
.action(async () => {
|
|
16
|
-
await login()
|
|
17
|
-
})
|
|
3
|
+
const { Command } = require('commander')
|
|
4
|
+
const { interactiveChat } = require('../lib/chat')
|
|
5
|
+
const { agentLoop } = require('../lib/agent-loop')
|
|
6
|
+
const { getConfig, login } = require('../lib/config')
|
|
7
|
+
const chalk = require('chalk')
|
|
18
8
|
|
|
19
|
-
program
|
|
20
|
-
.command('logout')
|
|
21
|
-
.description('Sign out and clear credentials')
|
|
22
|
-
.action(async () => {
|
|
23
|
-
await logout()
|
|
24
|
-
})
|
|
9
|
+
const program = new Command()
|
|
25
10
|
|
|
26
11
|
program
|
|
27
|
-
.
|
|
28
|
-
.description('
|
|
29
|
-
.
|
|
30
|
-
await initConfig()
|
|
31
|
-
console.log(chalk.green(' Run `otoro` to start coding.\n'))
|
|
32
|
-
})
|
|
12
|
+
.name('otoro')
|
|
13
|
+
.description('Otoro AGI — AI coding assistant for your terminal')
|
|
14
|
+
.version('2.0.0')
|
|
33
15
|
|
|
16
|
+
// Default — interactive chat (like Claude Code)
|
|
34
17
|
program
|
|
35
|
-
.command('chat')
|
|
36
|
-
.alias('c')
|
|
37
|
-
.description('Start interactive chat')
|
|
38
18
|
.action(async () => {
|
|
39
|
-
|
|
40
|
-
const { startChat } = require('../lib/chat')
|
|
41
|
-
await startChat()
|
|
19
|
+
await interactiveChat()
|
|
42
20
|
})
|
|
43
21
|
|
|
22
|
+
// Ask — one-shot question with agentic tools
|
|
44
23
|
program
|
|
45
|
-
.command('ask <
|
|
46
|
-
.description('Ask Otoro a
|
|
47
|
-
.action(async (
|
|
48
|
-
|
|
49
|
-
const {
|
|
50
|
-
|
|
24
|
+
.command('ask <question...>')
|
|
25
|
+
.description('Ask Otoro a question (uses tools)')
|
|
26
|
+
.action(async (words) => {
|
|
27
|
+
const question = words.join(' ')
|
|
28
|
+
const { response } = await agentLoop(question)
|
|
29
|
+
if (response) console.log('\n' + response + '\n')
|
|
51
30
|
})
|
|
52
31
|
|
|
32
|
+
// Run — full agentic task
|
|
53
33
|
program
|
|
54
34
|
.command('run <task...>')
|
|
55
|
-
.description('Run a
|
|
56
|
-
.action(async (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
await
|
|
35
|
+
.description('Run a task (Otoro uses tools autonomously)')
|
|
36
|
+
.action(async (words) => {
|
|
37
|
+
const task = words.join(' ')
|
|
38
|
+
console.log(chalk.cyan('\n 🐙 Running task: ') + chalk.white(task) + '\n')
|
|
39
|
+
const { response, iterations } = await agentLoop(task)
|
|
40
|
+
if (response) console.log('\n' + response)
|
|
41
|
+
console.log(chalk.gray(`\n Completed in ${iterations} steps\n`))
|
|
60
42
|
})
|
|
61
43
|
|
|
44
|
+
// Login
|
|
62
45
|
program
|
|
63
|
-
.command('
|
|
64
|
-
.description('
|
|
65
|
-
.action(async (
|
|
66
|
-
|
|
67
|
-
const { generateImage } = require('../lib/image')
|
|
68
|
-
await generateImage(prompt.join(' '))
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
program
|
|
72
|
-
.command('computer <task...>')
|
|
73
|
-
.alias('use')
|
|
74
|
-
.description('Computer Use — Otoro sees your screen and controls mouse/keyboard')
|
|
75
|
-
.action(async (task) => {
|
|
76
|
-
requireAuth()
|
|
77
|
-
const { runComputerTask } = require('../lib/computer')
|
|
78
|
-
await runComputerTask(task.join(' '))
|
|
46
|
+
.command('login')
|
|
47
|
+
.description('Login to Otoro')
|
|
48
|
+
.action(async () => {
|
|
49
|
+
await login()
|
|
79
50
|
})
|
|
80
51
|
|
|
52
|
+
// Init — scan project
|
|
81
53
|
program
|
|
82
|
-
.command('
|
|
83
|
-
.description('
|
|
54
|
+
.command('init')
|
|
55
|
+
.description('Initialize Otoro in current project')
|
|
84
56
|
.action(async () => {
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
spinner.succeed(chalk.green('Screen analyzed'))
|
|
93
|
-
console.log(chalk.cyan('\n What Otoro sees:\n'))
|
|
94
|
-
console.log(' ' + result.description.split('\n').join('\n ') + '\n')
|
|
95
|
-
} else {
|
|
96
|
-
spinner.fail(chalk.red(result.error))
|
|
97
|
-
}
|
|
57
|
+
const { executeTool } = require('../lib/tools')
|
|
58
|
+
const result = await executeTool('project_context', {})
|
|
59
|
+
console.log(chalk.cyan('\n 🐙 Otoro initialized'))
|
|
60
|
+
console.log(chalk.gray(` Project type: ${result.type}`))
|
|
61
|
+
console.log(chalk.gray(` Files: ${result.file_count}`))
|
|
62
|
+
console.log(chalk.gray(` Directory: ${result.directory}`))
|
|
63
|
+
console.log(chalk.gray('\n Run `otoro` to start coding\n'))
|
|
98
64
|
})
|
|
99
65
|
|
|
66
|
+
// Status
|
|
100
67
|
program
|
|
101
|
-
.command('
|
|
102
|
-
.description('
|
|
68
|
+
.command('status')
|
|
69
|
+
.description('Show project and agent status')
|
|
103
70
|
.action(async () => {
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
await
|
|
71
|
+
const { executeTool } = require('../lib/tools')
|
|
72
|
+
const git = await executeTool('git_status', {})
|
|
73
|
+
const proj = await executeTool('project_context', {})
|
|
74
|
+
console.log(chalk.cyan('\n 🐙 Otoro Status'))
|
|
75
|
+
console.log(chalk.gray(` Project: ${proj.type} (${proj.file_count} files)`))
|
|
76
|
+
if (!git.error) {
|
|
77
|
+
console.log(chalk.gray(` Branch: ${git.branch}`))
|
|
78
|
+
console.log(chalk.gray(` Git: ${git.status}`))
|
|
79
|
+
}
|
|
80
|
+
console.log('')
|
|
107
81
|
})
|
|
108
82
|
|
|
83
|
+
// Image gen
|
|
109
84
|
program
|
|
110
|
-
.command('
|
|
111
|
-
.description('
|
|
112
|
-
.action(async () => {
|
|
113
|
-
|
|
114
|
-
const config =
|
|
85
|
+
.command('image <prompt...>')
|
|
86
|
+
.description('Generate an image')
|
|
87
|
+
.action(async (words) => {
|
|
88
|
+
const prompt = words.join(' ')
|
|
89
|
+
const config = getConfig()
|
|
115
90
|
const http = require('http')
|
|
116
|
-
const
|
|
117
|
-
|
|
91
|
+
const fs = require('fs')
|
|
92
|
+
|
|
93
|
+
console.log(chalk.cyan('\n 🎨 Generating image...'))
|
|
94
|
+
const body = JSON.stringify({ prompt, size: '1024x1024' })
|
|
95
|
+
const url = new URL(`${config.gpu_url}/v1/images/generations`)
|
|
96
|
+
const req = http.request(url, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.api_key}` },
|
|
99
|
+
timeout: 120000,
|
|
100
|
+
}, (res) => {
|
|
118
101
|
let data = ''
|
|
119
102
|
res.on('data', c => data += c)
|
|
120
103
|
res.on('end', () => {
|
|
121
104
|
try {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
105
|
+
const result = JSON.parse(data)
|
|
106
|
+
if (result.data?.[0]?.b64_json) {
|
|
107
|
+
const filename = result.data[0].filename || `otoro-${Date.now()}.png`
|
|
108
|
+
fs.writeFileSync(filename, Buffer.from(result.data[0].b64_json, 'base64'))
|
|
109
|
+
console.log(chalk.green(` Saved: ${filename}\n`))
|
|
126
110
|
} else {
|
|
127
|
-
|
|
128
|
-
console.log(chalk.green(` ● ${a.hostname}`) + chalk.gray(` — ${a.cwd} (${a.platform})`))
|
|
129
|
-
}
|
|
130
|
-
console.log()
|
|
111
|
+
console.log(chalk.red(' Failed to generate image\n'))
|
|
131
112
|
}
|
|
132
|
-
} catch { console.log(chalk.red('
|
|
113
|
+
} catch { console.log(chalk.red(' Error parsing response\n')) }
|
|
133
114
|
})
|
|
134
|
-
})
|
|
115
|
+
})
|
|
116
|
+
req.on('error', (e) => console.log(chalk.red(` Error: ${e.message}\n`)))
|
|
117
|
+
req.write(body)
|
|
118
|
+
req.end()
|
|
135
119
|
})
|
|
136
120
|
|
|
137
|
-
|
|
138
|
-
if (process.argv.length <= 2) {
|
|
139
|
-
if (!isLoggedIn()) {
|
|
140
|
-
console.log(chalk.cyan.bold('\n 🐙 Otoro AGI\n'))
|
|
141
|
-
console.log(chalk.yellow(' Not logged in. Run:\n'))
|
|
142
|
-
console.log(chalk.white(' otoro login — Sign in with your Otoro account'))
|
|
143
|
-
console.log(chalk.white(' otoro init — Initialize a project'))
|
|
144
|
-
console.log(chalk.white(' otoro — Start coding\n'))
|
|
145
|
-
console.log(chalk.gray(' Don\'t have an account? Sign up at https://otoroagi.com\n'))
|
|
146
|
-
} else {
|
|
147
|
-
requireAuth()
|
|
148
|
-
const { startChat } = require('../lib/chat')
|
|
149
|
-
startChat()
|
|
150
|
-
}
|
|
151
|
-
} else {
|
|
152
|
-
program.parse()
|
|
153
|
-
}
|
|
121
|
+
program.parse()
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const http = require('http')
|
|
3
|
+
const { getConfig } = require('./config')
|
|
4
|
+
const { getToolDefinitions, executeTool } = require('./tools')
|
|
5
|
+
|
|
6
|
+
// ═══════════════════════════════════════════════════
|
|
7
|
+
// OTORO AGENTIC LOOP — Claude Code-style tool use
|
|
8
|
+
// ═══════════════════════════════════════════════════
|
|
9
|
+
|
|
10
|
+
const MAX_ITERATIONS = 25
|
|
11
|
+
const SYSTEM_PROMPT = `You are Otoro, an expert AI coding assistant running in the user's terminal.
|
|
12
|
+
You have access to tools that let you read files, write files, edit files, search code, run commands, and more.
|
|
13
|
+
|
|
14
|
+
IMPORTANT RULES:
|
|
15
|
+
- Read files before editing them. Never guess file contents.
|
|
16
|
+
- Use glob/grep to find files before reading them.
|
|
17
|
+
- Run tests after making changes to verify they work.
|
|
18
|
+
- Be concise in your responses. Show what you did, not what you plan to do.
|
|
19
|
+
- When you're done with a task, stop calling tools and respond to the user.
|
|
20
|
+
- Never ask the user to do something you can do with tools.
|
|
21
|
+
- Use bash for git, npm, pip, build commands, etc.
|
|
22
|
+
- Always use the edit tool for small changes (not write).
|
|
23
|
+
- Use project_context to understand the project before making changes.
|
|
24
|
+
|
|
25
|
+
Current directory: ${process.cwd()}
|
|
26
|
+
Platform: ${process.platform}
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
async function callModel(messages, tools) {
|
|
30
|
+
const config = getConfig()
|
|
31
|
+
const body = JSON.stringify({
|
|
32
|
+
model: 'otoro-1',
|
|
33
|
+
messages,
|
|
34
|
+
tools,
|
|
35
|
+
max_tokens: 8192,
|
|
36
|
+
temperature: 0.2,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const url = new URL(`${config.gpu_url}/v1/chat/completions`)
|
|
41
|
+
const req = http.request(url, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'Authorization': `Bearer ${config.api_key}`,
|
|
46
|
+
},
|
|
47
|
+
timeout: 180000,
|
|
48
|
+
}, (res) => {
|
|
49
|
+
let data = ''
|
|
50
|
+
res.on('data', c => data += c)
|
|
51
|
+
res.on('end', () => {
|
|
52
|
+
try {
|
|
53
|
+
const result = JSON.parse(data)
|
|
54
|
+
if (result.error) reject(new Error(result.error.message || JSON.stringify(result.error)))
|
|
55
|
+
else resolve(result)
|
|
56
|
+
} catch (e) {
|
|
57
|
+
reject(new Error('Invalid response from model'))
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
req.on('error', reject)
|
|
62
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')) })
|
|
63
|
+
req.write(body)
|
|
64
|
+
req.end()
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatToolResult(name, result) {
|
|
69
|
+
const str = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
70
|
+
// Truncate very large results
|
|
71
|
+
if (str.length > 15000) {
|
|
72
|
+
return str.slice(0, 14000) + '\n\n... (truncated, ' + str.length + ' chars total)'
|
|
73
|
+
}
|
|
74
|
+
return str
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function printToolCall(name, args) {
|
|
78
|
+
const icons = {
|
|
79
|
+
read: '📖', write: '✏️ ', edit: '🔧', glob: '🔍', grep: '🔎',
|
|
80
|
+
bash: '💻', git_status: '📊', project_context: '📁', web_search: '🌐',
|
|
81
|
+
}
|
|
82
|
+
const icon = icons[name] || '🔧'
|
|
83
|
+
|
|
84
|
+
switch (name) {
|
|
85
|
+
case 'read':
|
|
86
|
+
console.log(chalk.cyan(` ${icon} Read ${chalk.white(args.file_path)}`))
|
|
87
|
+
break
|
|
88
|
+
case 'write':
|
|
89
|
+
console.log(chalk.green(` ${icon} Write ${chalk.white(args.file_path)} (${args.content?.length || 0} chars)`))
|
|
90
|
+
break
|
|
91
|
+
case 'edit':
|
|
92
|
+
console.log(chalk.yellow(` ${icon} Edit ${chalk.white(args.file_path)}`))
|
|
93
|
+
break
|
|
94
|
+
case 'glob':
|
|
95
|
+
console.log(chalk.blue(` ${icon} Glob ${chalk.white(args.pattern)} ${args.directory ? 'in ' + args.directory : ''}`))
|
|
96
|
+
break
|
|
97
|
+
case 'grep':
|
|
98
|
+
console.log(chalk.blue(` ${icon} Grep ${chalk.white(args.pattern)} ${args.glob ? '(' + args.glob + ')' : ''}`))
|
|
99
|
+
break
|
|
100
|
+
case 'bash':
|
|
101
|
+
const cmd = args.command.length > 80 ? args.command.slice(0, 77) + '...' : args.command
|
|
102
|
+
console.log(chalk.magenta(` ${icon} ${chalk.white(cmd)}`))
|
|
103
|
+
break
|
|
104
|
+
case 'git_status':
|
|
105
|
+
console.log(chalk.cyan(` ${icon} Git status`))
|
|
106
|
+
break
|
|
107
|
+
case 'project_context':
|
|
108
|
+
console.log(chalk.cyan(` ${icon} Scanning project`))
|
|
109
|
+
break
|
|
110
|
+
case 'web_search':
|
|
111
|
+
console.log(chalk.blue(` ${icon} Search: ${chalk.white(args.query)}`))
|
|
112
|
+
break
|
|
113
|
+
default:
|
|
114
|
+
console.log(chalk.gray(` ${icon} ${name}`))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function agentLoop(userMessage, conversationHistory = []) {
|
|
119
|
+
const tools = getToolDefinitions()
|
|
120
|
+
const messages = [
|
|
121
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
122
|
+
...conversationHistory,
|
|
123
|
+
{ role: 'user', content: userMessage },
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
let iteration = 0
|
|
127
|
+
let finalResponse = ''
|
|
128
|
+
|
|
129
|
+
while (iteration < MAX_ITERATIONS) {
|
|
130
|
+
iteration++
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const result = await callModel(messages, tools)
|
|
134
|
+
const choice = result.choices?.[0]
|
|
135
|
+
|
|
136
|
+
if (!choice) {
|
|
137
|
+
console.log(chalk.red(' No response from model'))
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const message = choice.message
|
|
142
|
+
|
|
143
|
+
// Check for tool calls
|
|
144
|
+
if (message.tool_calls && message.tool_calls.length > 0) {
|
|
145
|
+
// Add assistant message with tool calls to history
|
|
146
|
+
messages.push({
|
|
147
|
+
role: 'assistant',
|
|
148
|
+
content: message.content || null,
|
|
149
|
+
tool_calls: message.tool_calls,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Print any text content the model included
|
|
153
|
+
if (message.content) {
|
|
154
|
+
console.log(chalk.white(message.content))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Execute each tool call
|
|
158
|
+
for (const tc of message.tool_calls) {
|
|
159
|
+
const toolName = tc.function.name
|
|
160
|
+
let toolArgs = {}
|
|
161
|
+
try {
|
|
162
|
+
toolArgs = JSON.parse(tc.function.arguments || '{}')
|
|
163
|
+
} catch {
|
|
164
|
+
toolArgs = {}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Print tool call
|
|
168
|
+
printToolCall(toolName, toolArgs)
|
|
169
|
+
|
|
170
|
+
// Execute
|
|
171
|
+
const toolResult = await executeTool(toolName, toolArgs)
|
|
172
|
+
const formatted = formatToolResult(toolName, toolResult)
|
|
173
|
+
|
|
174
|
+
// Print errors
|
|
175
|
+
if (toolResult.error) {
|
|
176
|
+
console.log(chalk.red(` Error: ${toolResult.error}`))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Add tool result to messages
|
|
180
|
+
messages.push({
|
|
181
|
+
role: 'tool',
|
|
182
|
+
tool_call_id: tc.id,
|
|
183
|
+
content: formatted,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Continue the loop — model will see tool results and decide what to do next
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// No tool calls — model is done, this is the final response
|
|
192
|
+
finalResponse = message.content || ''
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.log(chalk.red(` Error: ${err.message}`))
|
|
197
|
+
// If model doesn't support tool_calls, fall back to plain response
|
|
198
|
+
if (err.message.includes('tool') || err.message.includes('function')) {
|
|
199
|
+
// Retry without tools
|
|
200
|
+
try {
|
|
201
|
+
const result = await callModel(messages.map(m => {
|
|
202
|
+
const { tool_calls, ...rest } = m
|
|
203
|
+
return rest
|
|
204
|
+
}).filter(m => m.role !== 'tool'), null)
|
|
205
|
+
finalResponse = result.choices?.[0]?.message?.content || ''
|
|
206
|
+
} catch {
|
|
207
|
+
finalResponse = 'Sorry, I encountered an error. Please try again.'
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (iteration >= MAX_ITERATIONS) {
|
|
215
|
+
console.log(chalk.yellow(`\n Reached max iterations (${MAX_ITERATIONS})`))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Add final response to conversation history
|
|
219
|
+
if (finalResponse) {
|
|
220
|
+
messages.push({ role: 'assistant', content: finalResponse })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
response: finalResponse,
|
|
225
|
+
iterations: iteration,
|
|
226
|
+
messages, // Return full history for multi-turn
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { agentLoop }
|
package/lib/chat.js
CHANGED
|
@@ -1,217 +1,133 @@
|
|
|
1
1
|
const readline = require('readline')
|
|
2
2
|
const chalk = require('chalk')
|
|
3
|
-
const
|
|
3
|
+
const { agentLoop } = require('./agent-loop')
|
|
4
|
+
const { getConfig } = require('./config')
|
|
4
5
|
const path = require('path')
|
|
5
|
-
const
|
|
6
|
-
const { chatCompletion, executeCode, cveCheck } = require('./api')
|
|
7
|
-
const { readFile, writeFile, editFile, listFiles, runCommand, searchCode } = require('./tools')
|
|
6
|
+
const os = require('os')
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
async function interactiveChat() {
|
|
9
|
+
const config = getConfig()
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
1. READ FILE: <tool:read path="filename"/>
|
|
16
|
-
2. WRITE FILE: <tool:write path="filename">content here</tool:write>
|
|
17
|
-
3. EDIT FILE: <tool:edit path="filename" old="old text" new="new text"/>
|
|
18
|
-
4. RUN COMMAND: <tool:run cmd="command here"/>
|
|
19
|
-
5. SEARCH CODE: <tool:search pattern="regex" dir="."/>
|
|
20
|
-
6. LIST FILES: <tool:ls dir="." pattern=""/>
|
|
21
|
-
|
|
22
|
-
RULES:
|
|
23
|
-
- Always read files before editing them
|
|
24
|
-
- Show the user what you're doing
|
|
25
|
-
- Write clean, working code
|
|
26
|
-
- Be direct and concise — no fluff
|
|
27
|
-
- When writing files, use <tool:write> tags
|
|
28
|
-
- When you need to see a file, use <tool:read>
|
|
29
|
-
- NEVER guess file contents — always read first
|
|
30
|
-
- Speak English only`
|
|
31
|
-
|
|
32
|
-
const history = []
|
|
33
|
-
|
|
34
|
-
async function startChat() {
|
|
35
|
-
console.log(chalk.cyan.bold('\n 🐙 Otoro AGI'))
|
|
36
|
-
console.log(chalk.gray(` Working in: ${process.cwd()}`))
|
|
11
|
+
if (!config.api_key) {
|
|
12
|
+
console.log(chalk.red('\n Not logged in. Run: otoro login\n'))
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
37
15
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
console.log(chalk.gray(`
|
|
42
|
-
console.log(chalk.gray(
|
|
16
|
+
console.log('')
|
|
17
|
+
console.log(chalk.bold(' 🐙 Otoro AGI'))
|
|
18
|
+
console.log(chalk.gray(` ${process.cwd()}`))
|
|
19
|
+
console.log(chalk.gray(` Model: otoro-1 | Tools: read, write, edit, glob, grep, bash, git`))
|
|
20
|
+
console.log(chalk.gray(` Type /help for commands, Ctrl+C to exit`))
|
|
21
|
+
console.log('')
|
|
43
22
|
|
|
44
23
|
const rl = readline.createInterface({
|
|
45
24
|
input: process.stdin,
|
|
46
25
|
output: process.stdout,
|
|
47
|
-
prompt: chalk.
|
|
26
|
+
prompt: chalk.cyan(' ❯ '),
|
|
48
27
|
})
|
|
49
28
|
|
|
29
|
+
let conversationHistory = []
|
|
30
|
+
let compactMode = false
|
|
31
|
+
|
|
50
32
|
rl.prompt()
|
|
51
33
|
|
|
52
|
-
rl.on('line', async (
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
34
|
+
rl.on('line', async (line) => {
|
|
35
|
+
const input = line.trim()
|
|
36
|
+
if (!input) { rl.prompt(); return }
|
|
55
37
|
|
|
56
|
-
|
|
57
|
-
|
|
38
|
+
// Commands
|
|
39
|
+
if (input === '/help') {
|
|
40
|
+
console.log(chalk.gray(`
|
|
58
41
|
Commands:
|
|
59
42
|
/help Show this help
|
|
60
|
-
/
|
|
61
|
-
/
|
|
62
|
-
/
|
|
63
|
-
/
|
|
43
|
+
/clear Clear conversation history
|
|
44
|
+
/compact Toggle compact mode
|
|
45
|
+
/context Scan project structure
|
|
46
|
+
/status Git status
|
|
64
47
|
/exit Exit Otoro
|
|
65
|
-
`))
|
|
48
|
+
`))
|
|
66
49
|
rl.prompt(); return
|
|
67
50
|
}
|
|
68
|
-
if (
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
console.log(chalk.gray(`\n ${r.files.slice(0, 40).join('\n ')}\n ... ${r.total} total\n`))
|
|
51
|
+
if (input === '/exit' || input === '/quit') { process.exit(0) }
|
|
52
|
+
if (input === '/clear') {
|
|
53
|
+
conversationHistory = []
|
|
54
|
+
console.log(chalk.gray(' Conversation cleared'))
|
|
73
55
|
rl.prompt(); return
|
|
74
56
|
}
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
else console.log(chalk.red(` Error: ${f.error}`))
|
|
57
|
+
if (input === '/compact') {
|
|
58
|
+
compactMode = !compactMode
|
|
59
|
+
console.log(chalk.gray(` Compact mode: ${compactMode ? 'on' : 'off'}`))
|
|
79
60
|
rl.prompt(); return
|
|
80
61
|
}
|
|
81
|
-
if (
|
|
82
|
-
const
|
|
83
|
-
|
|
62
|
+
if (input === '/context') {
|
|
63
|
+
const { executeTool } = require('./tools')
|
|
64
|
+
const result = await executeTool('project_context', {})
|
|
65
|
+
console.log(chalk.cyan(`\n Project: ${result.type}`))
|
|
66
|
+
console.log(chalk.gray(` Files: ${result.file_count}`))
|
|
67
|
+
if (result.package_json?.name) console.log(chalk.gray(` Name: ${result.package_json.name}`))
|
|
68
|
+
console.log('')
|
|
84
69
|
rl.prompt(); return
|
|
85
70
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
contextMsg += `\n\n[File: ${ref}]\n\`\`\`\n${content.content}\n\`\`\``
|
|
95
|
-
}
|
|
71
|
+
if (input === '/status') {
|
|
72
|
+
const { executeTool } = require('./tools')
|
|
73
|
+
const result = await executeTool('git_status', {})
|
|
74
|
+
if (result.error) console.log(chalk.red(` ${result.error}`))
|
|
75
|
+
else {
|
|
76
|
+
console.log(chalk.cyan(`\n Branch: ${result.branch}`))
|
|
77
|
+
console.log(chalk.gray(` ${result.status}`))
|
|
78
|
+
console.log(chalk.gray(` ${result.recent_commits}`))
|
|
96
79
|
}
|
|
80
|
+
console.log('')
|
|
81
|
+
rl.prompt(); return
|
|
97
82
|
}
|
|
98
83
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
84
|
+
// Pause readline while processing
|
|
85
|
+
rl.pause()
|
|
86
|
+
console.log('')
|
|
102
87
|
|
|
103
88
|
try {
|
|
104
|
-
const
|
|
105
|
-
const result = await chatCompletion(messages)
|
|
106
|
-
const response = result.choices?.[0]?.message?.content || 'No response'
|
|
107
|
-
spinner.stop()
|
|
89
|
+
const { response, iterations, messages } = await agentLoop(input, conversationHistory)
|
|
108
90
|
|
|
109
|
-
history
|
|
91
|
+
// Update conversation history (keep last 20 messages to manage context)
|
|
92
|
+
conversationHistory = messages.slice(1) // Remove system prompt
|
|
93
|
+
if (conversationHistory.length > 40) {
|
|
94
|
+
conversationHistory = conversationHistory.slice(-30)
|
|
95
|
+
}
|
|
110
96
|
|
|
111
|
-
//
|
|
112
|
-
|
|
97
|
+
// Print response
|
|
98
|
+
if (response) {
|
|
99
|
+
console.log('')
|
|
100
|
+
// Format markdown-style output
|
|
101
|
+
const lines = response.split('\n')
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (line.startsWith('```')) {
|
|
104
|
+
console.log(chalk.gray(` ${line}`))
|
|
105
|
+
} else if (line.startsWith('#')) {
|
|
106
|
+
console.log(chalk.bold.white(` ${line}`))
|
|
107
|
+
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
|
108
|
+
console.log(chalk.white(` ${line}`))
|
|
109
|
+
} else {
|
|
110
|
+
console.log(chalk.white(` ${line}`))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
if (iterations > 1) {
|
|
115
|
+
console.log(chalk.gray(`\n (${iterations} steps)`))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.log(chalk.red(` Error: ${err.message}`))
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
console.log('')
|
|
123
|
+
rl.resume()
|
|
118
124
|
rl.prompt()
|
|
119
125
|
})
|
|
120
|
-
}
|
|
121
126
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// Process <tool:read> tags
|
|
127
|
-
const reads = response.matchAll(/<tool:read\s+path="([^"]+)"\s*\/>/g)
|
|
128
|
-
for (const match of reads) {
|
|
129
|
-
const result = readFile(match[1])
|
|
130
|
-
if (result.success) {
|
|
131
|
-
console.log(chalk.gray(` 📄 Read: ${match[1]} (${result.lines} lines)`))
|
|
132
|
-
history.push({ role: 'user', content: `[File ${match[1]}]:\n\`\`\`\n${result.content.slice(0, 8000)}\n\`\`\`` })
|
|
133
|
-
}
|
|
134
|
-
display = display.replace(match[0], '')
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Process <tool:write> tags
|
|
138
|
-
const writes = response.matchAll(/<tool:write\s+path="([^"]+)">([\s\S]*?)<\/tool:write>/g)
|
|
139
|
-
for (const match of writes) {
|
|
140
|
-
const [, filePath, content] = match
|
|
141
|
-
console.log(chalk.yellow(` ✏️ Write: ${filePath}`))
|
|
142
|
-
const result = writeFile(filePath, content.trim())
|
|
143
|
-
if (result.success) console.log(chalk.green(` ✓ Written ${result.lines} lines to ${filePath}`))
|
|
144
|
-
else console.log(chalk.red(` ✗ ${result.error}`))
|
|
145
|
-
display = display.replace(match[0], `[Written to ${filePath}]`)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Process <tool:edit> tags
|
|
149
|
-
const edits = response.matchAll(/<tool:edit\s+path="([^"]+)"\s+old="([^"]*?)"\s+new="([^"]*?)"\s*\/>/g)
|
|
150
|
-
for (const match of edits) {
|
|
151
|
-
const [, filePath, oldStr, newStr] = match
|
|
152
|
-
console.log(chalk.yellow(` ✏️ Edit: ${filePath}`))
|
|
153
|
-
const result = editFile(filePath, oldStr, newStr)
|
|
154
|
-
if (result.success) console.log(chalk.green(` ✓ Edited ${filePath}`))
|
|
155
|
-
else console.log(chalk.red(` ✗ ${result.error}`))
|
|
156
|
-
display = display.replace(match[0], `[Edited ${filePath}]`)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Process <tool:run> tags
|
|
160
|
-
const runs = response.matchAll(/<tool:run\s+cmd="([^"]+)"\s*\/>/g)
|
|
161
|
-
for (const match of runs) {
|
|
162
|
-
console.log(chalk.yellow(` ⚡ Run: ${match[1]}`))
|
|
163
|
-
const result = runCommand(match[1])
|
|
164
|
-
if (result.success) {
|
|
165
|
-
console.log(chalk.gray(` ${result.output.slice(0, 500)}`))
|
|
166
|
-
} else {
|
|
167
|
-
console.log(chalk.red(` ${result.error}`))
|
|
168
|
-
}
|
|
169
|
-
display = display.replace(match[0], '')
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Process <tool:search> tags
|
|
173
|
-
const searches = response.matchAll(/<tool:search\s+pattern="([^"]+)"(?:\s+dir="([^"]*)")?\s*\/>/g)
|
|
174
|
-
for (const match of searches) {
|
|
175
|
-
const result = searchCode(match[1], match[2] || '.')
|
|
176
|
-
if (result.results) console.log(chalk.gray(` 🔍 ${result.results.slice(0, 500)}`))
|
|
177
|
-
display = display.replace(match[0], '')
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Process <tool:ls> tags
|
|
181
|
-
const lsMatches = response.matchAll(/<tool:ls(?:\s+dir="([^"]*)")?(?:\s+pattern="([^"]*)")?\s*\/>/g)
|
|
182
|
-
for (const match of lsMatches) {
|
|
183
|
-
const result = listFiles(match[1] || '.', match[2] || '')
|
|
184
|
-
console.log(chalk.gray(` 📁 ${result.files?.slice(0, 20).join(', ')}`))
|
|
185
|
-
display = display.replace(match[0], '')
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Clean remaining tags and display
|
|
189
|
-
display = display.replace(/<tool:[^>]*>/g, '').replace(/<\/tool:\w+>/g, '').trim()
|
|
190
|
-
if (display) {
|
|
191
|
-
// Format code blocks nicely
|
|
192
|
-
display = display.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
193
|
-
return chalk.gray(` ┌─ ${lang || 'code'} ─`) + '\n' +
|
|
194
|
-
code.split('\n').map(l => chalk.white(` │ ${l}`)).join('\n') + '\n' +
|
|
195
|
-
chalk.gray(' └───')
|
|
196
|
-
})
|
|
197
|
-
console.log('\n' + display.split('\n').map(l => ` ${l}`).join('\n') + '\n')
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function askOnce(prompt) {
|
|
202
|
-
const spinner = ora({ text: chalk.gray('Thinking...'), color: 'cyan' }).start()
|
|
203
|
-
try {
|
|
204
|
-
const messages = [
|
|
205
|
-
{ role: 'system', content: SYSTEM_PROMPT },
|
|
206
|
-
{ role: 'user', content: prompt },
|
|
207
|
-
]
|
|
208
|
-
const result = await chatCompletion(messages)
|
|
209
|
-
const response = result.choices?.[0]?.message?.content || 'No response'
|
|
210
|
-
spinner.stop()
|
|
211
|
-
await processResponse(response)
|
|
212
|
-
} catch (e) {
|
|
213
|
-
spinner.fail(chalk.red(`Error: ${e.message}`))
|
|
214
|
-
}
|
|
127
|
+
rl.on('close', () => {
|
|
128
|
+
console.log(chalk.gray('\n Goodbye! 🐙\n'))
|
|
129
|
+
process.exit(0)
|
|
130
|
+
})
|
|
215
131
|
}
|
|
216
132
|
|
|
217
|
-
module.exports = {
|
|
133
|
+
module.exports = { interactiveChat }
|
package/lib/tools.js
CHANGED
|
@@ -1,173 +1,301 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
|
-
const { execSync } = require('child_process')
|
|
4
|
-
const
|
|
3
|
+
const { execSync, exec } = require('child_process')
|
|
4
|
+
const os = require('os')
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const content = fs.readFileSync(resolved, 'utf8')
|
|
10
|
-
return { success: true, path: resolved, content, lines: content.split('\n').length }
|
|
11
|
-
} catch (e) {
|
|
12
|
-
return { success: false, error: e.message }
|
|
13
|
-
}
|
|
14
|
-
}
|
|
6
|
+
// ═══════════════════════════════════════════════════
|
|
7
|
+
// OTORO TOOL SYSTEM — Claude Code-style agentic tools
|
|
8
|
+
// ═══════════════════════════════════════════════════
|
|
15
9
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
10
|
+
const TOOLS = {
|
|
11
|
+
// ─── File System ────────────────────────────────
|
|
12
|
+
read: {
|
|
13
|
+
name: 'read',
|
|
14
|
+
description: 'Read a file from the filesystem',
|
|
15
|
+
parameters: { file_path: 'string', offset: 'number?', limit: 'number?' },
|
|
16
|
+
execute({ file_path, offset, limit }) {
|
|
17
|
+
const abs = path.resolve(file_path)
|
|
18
|
+
if (!fs.existsSync(abs)) return { error: `File not found: ${abs}` }
|
|
19
|
+
const stat = fs.statSync(abs)
|
|
20
|
+
if (stat.isDirectory()) return { error: `${abs} is a directory, not a file` }
|
|
21
|
+
if (stat.size > 5 * 1024 * 1024) return { error: `File too large: ${(stat.size / 1024 / 1024).toFixed(1)}MB` }
|
|
22
|
+
const content = fs.readFileSync(abs, 'utf8')
|
|
23
|
+
const lines = content.split('\n')
|
|
24
|
+
const start = (offset || 1) - 1
|
|
25
|
+
const end = limit ? start + limit : lines.length
|
|
26
|
+
const numbered = lines.slice(start, end).map((l, i) => `${start + i + 1}\t${l}`).join('\n')
|
|
27
|
+
return { content: numbered, total_lines: lines.length, path: abs }
|
|
28
|
+
}
|
|
29
|
+
},
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
write: {
|
|
32
|
+
name: 'write',
|
|
33
|
+
description: 'Write content to a file (creates or overwrites)',
|
|
34
|
+
parameters: { file_path: 'string', content: 'string' },
|
|
35
|
+
execute({ file_path, content }) {
|
|
36
|
+
const abs = path.resolve(file_path)
|
|
37
|
+
const dir = path.dirname(abs)
|
|
38
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
39
|
+
fs.writeFileSync(abs, content, 'utf8')
|
|
40
|
+
return { success: true, path: abs, bytes: Buffer.byteLength(content) }
|
|
34
41
|
}
|
|
35
|
-
|
|
36
|
-
fs.writeFileSync(resolved, content)
|
|
37
|
-
return { success: true, path: resolved }
|
|
38
|
-
} catch (e) {
|
|
39
|
-
return { success: false, error: e.message }
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
+
},
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} else {
|
|
58
|
-
if (!pattern || rel.includes(pattern) || e.name.match(pattern)) files.push(rel)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
44
|
+
edit: {
|
|
45
|
+
name: 'edit',
|
|
46
|
+
description: 'Replace exact string in a file',
|
|
47
|
+
parameters: { file_path: 'string', old_string: 'string', new_string: 'string' },
|
|
48
|
+
execute({ file_path, old_string, new_string }) {
|
|
49
|
+
const abs = path.resolve(file_path)
|
|
50
|
+
if (!fs.existsSync(abs)) return { error: `File not found: ${abs}` }
|
|
51
|
+
let content = fs.readFileSync(abs, 'utf8')
|
|
52
|
+
const count = content.split(old_string).length - 1
|
|
53
|
+
if (count === 0) return { error: `String not found in ${abs}` }
|
|
54
|
+
if (count > 1) return { error: `String found ${count} times — be more specific` }
|
|
55
|
+
content = content.replace(old_string, new_string)
|
|
56
|
+
fs.writeFileSync(abs, content, 'utf8')
|
|
57
|
+
return { success: true, path: abs, replacements: 1 }
|
|
61
58
|
}
|
|
62
|
-
|
|
63
|
-
return { success: true, files: files.slice(0, 200), total: files.length }
|
|
64
|
-
} catch (e) {
|
|
65
|
-
return { success: false, error: e.message }
|
|
66
|
-
}
|
|
67
|
-
}
|
|
59
|
+
},
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
61
|
+
// ─── Search ─────────────────────────────────────
|
|
62
|
+
glob: {
|
|
63
|
+
name: 'glob',
|
|
64
|
+
description: 'Find files matching a glob pattern',
|
|
65
|
+
parameters: { pattern: 'string', directory: 'string?' },
|
|
66
|
+
execute({ pattern, directory }) {
|
|
67
|
+
const dir = directory || process.cwd()
|
|
68
|
+
try {
|
|
69
|
+
// Use find for glob-like matching
|
|
70
|
+
const cmd = `find ${JSON.stringify(dir)} -name ${JSON.stringify(pattern)} -type f 2>/dev/null | head -50`
|
|
71
|
+
const result = execSync(cmd, { encoding: 'utf8', timeout: 10000 }).trim()
|
|
72
|
+
const files = result ? result.split('\n') : []
|
|
73
|
+
return { files, count: files.length }
|
|
74
|
+
} catch {
|
|
75
|
+
return { files: [], count: 0 }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
grep: {
|
|
81
|
+
name: 'grep',
|
|
82
|
+
description: 'Search file contents for a pattern (regex supported)',
|
|
83
|
+
parameters: { pattern: 'string', directory: 'string?', glob: 'string?' },
|
|
84
|
+
execute({ pattern, directory, glob: fileGlob }) {
|
|
85
|
+
const dir = directory || process.cwd()
|
|
86
|
+
try {
|
|
87
|
+
let cmd = `grep -rn --include="${fileGlob || '*'}" ${JSON.stringify(pattern)} ${JSON.stringify(dir)} 2>/dev/null | head -30`
|
|
88
|
+
const result = execSync(cmd, { encoding: 'utf8', timeout: 10000 }).trim()
|
|
89
|
+
const matches = result ? result.split('\n').map(line => {
|
|
90
|
+
const [fileLine, ...rest] = line.split(':')
|
|
91
|
+
const parts = fileLine.match(/^(.+):(\d+)$/)
|
|
92
|
+
return parts ? { file: parts[1], line: parseInt(parts[2]), content: rest.join(':').trim() } : { raw: line }
|
|
93
|
+
}) : []
|
|
94
|
+
return { matches, count: matches.length }
|
|
95
|
+
} catch {
|
|
96
|
+
return { matches: [], count: 0 }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
88
100
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
// ─── Shell ──────────────────────────────────────
|
|
102
|
+
bash: {
|
|
103
|
+
name: 'bash',
|
|
104
|
+
description: 'Execute a bash command',
|
|
105
|
+
parameters: { command: 'string', timeout: 'number?' },
|
|
106
|
+
execute({ command, timeout }) {
|
|
107
|
+
const timeoutMs = Math.min((timeout || 30) * 1000, 120000)
|
|
108
|
+
try {
|
|
109
|
+
const result = execSync(command, {
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
timeout: timeoutMs,
|
|
112
|
+
maxBuffer: 1024 * 1024,
|
|
113
|
+
cwd: process.cwd(),
|
|
114
|
+
env: { ...process.env, TERM: 'dumb' },
|
|
115
|
+
})
|
|
116
|
+
return { stdout: result.slice(0, 10000), exit_code: 0 }
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return {
|
|
119
|
+
stdout: (e.stdout || '').slice(0, 5000),
|
|
120
|
+
stderr: (e.stderr || '').slice(0, 5000),
|
|
121
|
+
exit_code: e.status || 1,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
92
126
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
// ─── Git ────────────────────────────────────────
|
|
128
|
+
git_status: {
|
|
129
|
+
name: 'git_status',
|
|
130
|
+
description: 'Get git status, diff, and recent log',
|
|
131
|
+
parameters: {},
|
|
132
|
+
execute() {
|
|
133
|
+
try {
|
|
134
|
+
const status = execSync('git status --short 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim()
|
|
135
|
+
const branch = execSync('git branch --show-current 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim()
|
|
136
|
+
const log = execSync('git log --oneline -5 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim()
|
|
137
|
+
return { branch, status: status || '(clean)', recent_commits: log }
|
|
138
|
+
} catch {
|
|
139
|
+
return { error: 'Not a git repository' }
|
|
140
|
+
}
|
|
99
141
|
}
|
|
100
|
-
}
|
|
142
|
+
},
|
|
101
143
|
|
|
102
|
-
|
|
103
|
-
|
|
144
|
+
// ─── Project Context ────────────────────────────
|
|
145
|
+
project_context: {
|
|
146
|
+
name: 'project_context',
|
|
147
|
+
description: 'Scan project structure and detect type',
|
|
148
|
+
parameters: { directory: 'string?' },
|
|
149
|
+
execute({ directory }) {
|
|
150
|
+
const dir = directory || process.cwd()
|
|
151
|
+
const files = []
|
|
152
|
+
try {
|
|
153
|
+
const ls = execSync(`find ${JSON.stringify(dir)} -maxdepth 3 -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/__pycache__/*" -not -path "*/dist/*" -not -path "*/.next/*" 2>/dev/null | head -100`, { encoding: 'utf8', timeout: 10000 })
|
|
154
|
+
files.push(...ls.trim().split('\n').filter(Boolean))
|
|
155
|
+
} catch {}
|
|
104
156
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
// Detect project type
|
|
158
|
+
const has = (f) => files.some(p => p.endsWith(f))
|
|
159
|
+
let type = 'unknown'
|
|
160
|
+
if (has('package.json')) type = has('next.config.js') || has('next.config.ts') ? 'nextjs' : has('tsconfig.json') ? 'typescript' : 'nodejs'
|
|
161
|
+
else if (has('requirements.txt') || has('setup.py') || has('pyproject.toml')) type = 'python'
|
|
162
|
+
else if (has('Cargo.toml')) type = 'rust'
|
|
163
|
+
else if (has('go.mod')) type = 'go'
|
|
164
|
+
else if (has('Gemfile')) type = 'ruby'
|
|
165
|
+
|
|
166
|
+
// Read key files
|
|
167
|
+
const readme = files.find(f => f.toLowerCase().includes('readme'))
|
|
168
|
+
const pkg = files.find(f => f.endsWith('package.json') && !f.includes('node_modules'))
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
type,
|
|
172
|
+
directory: dir,
|
|
173
|
+
file_count: files.length,
|
|
174
|
+
files: files.map(f => path.relative(dir, f)).slice(0, 60),
|
|
175
|
+
package_json: pkg ? JSON.parse(fs.readFileSync(pkg, 'utf8').slice(0, 2000)) : null,
|
|
176
|
+
}
|
|
117
177
|
}
|
|
118
|
-
|
|
119
|
-
return { success: true, app: appName, platform }
|
|
120
|
-
} catch (e) {
|
|
121
|
-
return { success: false, error: e.message, platform }
|
|
122
|
-
}
|
|
123
|
-
}
|
|
178
|
+
},
|
|
124
179
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
180
|
+
// ─── Web Search ─────────────────────────────────
|
|
181
|
+
web_search: {
|
|
182
|
+
name: 'web_search',
|
|
183
|
+
description: 'Search the web for information',
|
|
184
|
+
parameters: { query: 'string' },
|
|
185
|
+
async execute({ query }) {
|
|
186
|
+
const { getConfig } = require('./config')
|
|
187
|
+
const config = getConfig()
|
|
188
|
+
const http = require('http')
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
const body = JSON.stringify({ query, max_results: 5 })
|
|
191
|
+
const url = new URL(`${config.gpu_url}/v1/agent/search`)
|
|
192
|
+
const req = http.request(url, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.api_key}` },
|
|
195
|
+
timeout: 10000,
|
|
196
|
+
}, (res) => {
|
|
197
|
+
let data = ''
|
|
198
|
+
res.on('data', c => data += c)
|
|
199
|
+
res.on('end', () => {
|
|
200
|
+
try { resolve(JSON.parse(data)) } catch { resolve({ error: 'Bad response' }) }
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
req.on('error', () => resolve({ error: 'Search unavailable' }))
|
|
204
|
+
req.write(body)
|
|
205
|
+
req.end()
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
},
|
|
137
209
|
}
|
|
138
210
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
211
|
+
// ─── Tool definitions for the model ───────────────
|
|
212
|
+
function getToolDefinitions() {
|
|
213
|
+
return [
|
|
214
|
+
{
|
|
215
|
+
type: 'function',
|
|
216
|
+
function: {
|
|
217
|
+
name: 'read',
|
|
218
|
+
description: 'Read a file. Returns numbered lines.',
|
|
219
|
+
parameters: { type: 'object', properties: { file_path: { type: 'string' }, offset: { type: 'integer' }, limit: { type: 'integer' } }, required: ['file_path'] }
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
type: 'function',
|
|
224
|
+
function: {
|
|
225
|
+
name: 'write',
|
|
226
|
+
description: 'Write/create a file.',
|
|
227
|
+
parameters: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] }
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
type: 'function',
|
|
232
|
+
function: {
|
|
233
|
+
name: 'edit',
|
|
234
|
+
description: 'Replace an exact string in a file. old_string must be unique.',
|
|
235
|
+
parameters: { type: 'object', properties: { file_path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' } }, required: ['file_path', 'old_string', 'new_string'] }
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
type: 'function',
|
|
240
|
+
function: {
|
|
241
|
+
name: 'glob',
|
|
242
|
+
description: 'Find files by name pattern (e.g. "*.tsx", "*.py").',
|
|
243
|
+
parameters: { type: 'object', properties: { pattern: { type: 'string' }, directory: { type: 'string' } }, required: ['pattern'] }
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
type: 'function',
|
|
248
|
+
function: {
|
|
249
|
+
name: 'grep',
|
|
250
|
+
description: 'Search file contents for a regex pattern.',
|
|
251
|
+
parameters: { type: 'object', properties: { pattern: { type: 'string' }, directory: { type: 'string' }, glob: { type: 'string' } }, required: ['pattern'] }
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
type: 'function',
|
|
256
|
+
function: {
|
|
257
|
+
name: 'bash',
|
|
258
|
+
description: 'Run a shell command. Use for: npm, git, python, build, test, etc.',
|
|
259
|
+
parameters: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'integer' } }, required: ['command'] }
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
type: 'function',
|
|
264
|
+
function: {
|
|
265
|
+
name: 'git_status',
|
|
266
|
+
description: 'Get git branch, status, and recent commits.',
|
|
267
|
+
parameters: { type: 'object', properties: {} }
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
type: 'function',
|
|
272
|
+
function: {
|
|
273
|
+
name: 'project_context',
|
|
274
|
+
description: 'Scan project structure: file list, type detection, package.json.',
|
|
275
|
+
parameters: { type: 'object', properties: { directory: { type: 'string' } } }
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
type: 'function',
|
|
280
|
+
function: {
|
|
281
|
+
name: 'web_search',
|
|
282
|
+
description: 'Search the web for current information.',
|
|
283
|
+
parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
]
|
|
153
287
|
}
|
|
154
288
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
289
|
+
// ─── Execute a tool call ──────────────────────────
|
|
290
|
+
async function executeTool(name, args) {
|
|
291
|
+
const tool = TOOLS[name]
|
|
292
|
+
if (!tool) return { error: `Unknown tool: ${name}` }
|
|
158
293
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
else if (platform === 'win32') cmd = `powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::PrimaryScreen | ForEach-Object { $bitmap = New-Object System.Drawing.Bitmap($_.Bounds.Width, $_.Bounds.Height); $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($_.Bounds.Location, [System.Drawing.Point]::Empty, $_.Bounds.Size); $bitmap.Save('${tmpFile}'); }"`
|
|
162
|
-
else cmd = `import -window root "${tmpFile}" 2>/dev/null || gnome-screenshot -f "${tmpFile}" 2>/dev/null || scrot "${tmpFile}" 2>/dev/null`
|
|
163
|
-
execSync(cmd, { timeout: 10000 })
|
|
164
|
-
if (fs.existsSync(tmpFile)) {
|
|
165
|
-
return { success: true, path: tmpFile, size: fs.statSync(tmpFile).size }
|
|
166
|
-
}
|
|
167
|
-
return { success: false, error: 'Screenshot file not created' }
|
|
294
|
+
const result = await tool.execute(args)
|
|
295
|
+
return result
|
|
168
296
|
} catch (e) {
|
|
169
|
-
return {
|
|
297
|
+
return { error: e.message }
|
|
170
298
|
}
|
|
171
299
|
}
|
|
172
300
|
|
|
173
|
-
module.exports = {
|
|
301
|
+
module.exports = { TOOLS, getToolDefinitions, executeTool }
|
package/package.json
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "otoro-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Otoro AGI — AI coding assistant for your terminal.
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Otoro AGI — AI coding assistant for your terminal. Like Claude Code, but open.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"otoro": "./bin/otoro.js"
|
|
8
8
|
},
|
|
9
|
-
"keywords": ["ai", "coding", "assistant", "cli", "otoro", "agi", "code-generation", "developer-tools"],
|
|
9
|
+
"keywords": ["ai", "coding", "assistant", "cli", "otoro", "agi", "code-generation", "developer-tools", "claude-code"],
|
|
10
10
|
"author": "Otoro Team",
|
|
11
11
|
"license": "MIT",
|
|
12
|
-
"repository": {
|
|
13
|
-
"type": "git",
|
|
14
|
-
"url": "https://github.com/otoroagi/cli"
|
|
15
|
-
},
|
|
16
12
|
"homepage": "https://otoroagi.com",
|
|
17
13
|
"dependencies": {
|
|
18
14
|
"chalk": "^4.1.2",
|