groove-dev 0.25.21 → 0.26.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@groove-dev/daemon/src/agent-loop.js +444 -0
- package/node_modules/@groove-dev/daemon/src/api.js +104 -5
- package/node_modules/@groove-dev/daemon/src/index.js +6 -1
- package/node_modules/@groove-dev/daemon/src/llama-server.js +268 -0
- package/node_modules/@groove-dev/daemon/src/model-manager.js +411 -0
- package/node_modules/@groove-dev/daemon/src/process.js +160 -9
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +51 -1
- package/node_modules/@groove-dev/daemon/src/providers/gemini.js +3 -2
- package/node_modules/@groove-dev/daemon/src/providers/index.js +4 -0
- package/node_modules/@groove-dev/daemon/src/providers/local.js +183 -0
- package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
- package/node_modules/@groove-dev/daemon/src/tool-executor.js +367 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BC2Bhfv0.js +633 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BQnZrh4f.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +7 -2
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -1
- package/node_modules/@groove-dev/gui/src/views/models.jsx +380 -0
- package/package.json +2 -2
- package/packages/daemon/src/agent-loop.js +444 -0
- package/packages/daemon/src/api.js +104 -5
- package/packages/daemon/src/index.js +6 -1
- package/packages/daemon/src/llama-server.js +268 -0
- package/packages/daemon/src/model-manager.js +411 -0
- package/packages/daemon/src/process.js +160 -9
- package/packages/daemon/src/providers/codex.js +51 -1
- package/packages/daemon/src/providers/gemini.js +3 -2
- package/packages/daemon/src/providers/index.js +4 -0
- package/packages/daemon/src/providers/local.js +183 -0
- package/packages/daemon/src/registry.js +1 -1
- package/packages/daemon/src/tool-executor.js +367 -0
- package/packages/gui/dist/assets/index-BC2Bhfv0.js +633 -0
- package/packages/gui/dist/assets/index-BQnZrh4f.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/src/app.jsx +2 -0
- package/packages/gui/src/components/agents/agent-config.jsx +7 -2
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +7 -1
- package/packages/gui/src/views/models.jsx +380 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-B1FkEzF0.js +0 -623
- package/node_modules/@groove-dev/gui/dist/assets/index-GYcMwmjs.css +0 -1
- package/packages/gui/dist/assets/index-B1FkEzF0.js +0 -623
- package/packages/gui/dist/assets/index-GYcMwmjs.css +0 -1
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// GROOVE — Agent Loop Engine (Local Model Runtime)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Core agentic runtime for local models. Manages a multi-turn conversation
|
|
5
|
+
// with tool calling against any OpenAI-compatible API. Plugs into all
|
|
6
|
+
// existing GROOVE orchestration (rotation, journalist, token tracking, routing).
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import { TOOL_DEFINITIONS, ToolExecutor } from './tool-executor.js';
|
|
10
|
+
|
|
11
|
+
export class AgentLoop extends EventEmitter {
|
|
12
|
+
constructor({ daemon, agent, loopConfig, logStream }) {
|
|
13
|
+
super();
|
|
14
|
+
this.daemon = daemon;
|
|
15
|
+
this.agent = agent;
|
|
16
|
+
this.config = loopConfig;
|
|
17
|
+
this.logStream = logStream;
|
|
18
|
+
|
|
19
|
+
// Conversation state
|
|
20
|
+
this.messages = [];
|
|
21
|
+
this.running = false;
|
|
22
|
+
this.idle = true;
|
|
23
|
+
this.abortController = null;
|
|
24
|
+
|
|
25
|
+
// Metrics
|
|
26
|
+
this.totalTokensIn = 0;
|
|
27
|
+
this.totalTokensOut = 0;
|
|
28
|
+
this.turns = 0;
|
|
29
|
+
this.toolCallCount = 0;
|
|
30
|
+
this.startedAt = Date.now();
|
|
31
|
+
|
|
32
|
+
// Tool executor — sandboxed to agent's working directory
|
|
33
|
+
this.executor = new ToolExecutor(
|
|
34
|
+
agent.workingDir || daemon.projectDir,
|
|
35
|
+
daemon,
|
|
36
|
+
agent.id,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Initialize system prompt
|
|
40
|
+
this.messages.push({
|
|
41
|
+
role: 'system',
|
|
42
|
+
content: this._buildSystemPrompt(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Lifecycle ---
|
|
47
|
+
|
|
48
|
+
async start(initialPrompt) {
|
|
49
|
+
this.running = true;
|
|
50
|
+
this._writeLog({ type: 'system', event: 'start', model: this.config.model });
|
|
51
|
+
|
|
52
|
+
if (initialPrompt) {
|
|
53
|
+
await this.sendMessage(initialPrompt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async sendMessage(content) {
|
|
58
|
+
if (!this.running) return;
|
|
59
|
+
|
|
60
|
+
this.idle = false;
|
|
61
|
+
this.messages.push({ role: 'user', content });
|
|
62
|
+
this._writeLog({ type: 'user', content: content.slice(0, 1000) });
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await this._runLoop();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this._writeLog({ type: 'error', text: err.message });
|
|
68
|
+
this.emit('error', { message: err.message });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.idle = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async stop() {
|
|
75
|
+
this.running = false;
|
|
76
|
+
if (this.abortController) {
|
|
77
|
+
this.abortController.abort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const duration = Date.now() - this.startedAt;
|
|
81
|
+
this._writeLog({
|
|
82
|
+
type: 'result',
|
|
83
|
+
result: 'Agent stopped',
|
|
84
|
+
tokensUsed: this.totalTokensIn + this.totalTokensOut,
|
|
85
|
+
duration,
|
|
86
|
+
turns: this.turns,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Record final session metrics
|
|
90
|
+
this.daemon.tokens.recordResult(this.agent.id, {
|
|
91
|
+
durationMs: duration,
|
|
92
|
+
turns: this.turns,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
this.emit('exit', { code: 0, signal: 'SIGTERM', status: 'killed' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Core Loop ---
|
|
99
|
+
|
|
100
|
+
async _runLoop() {
|
|
101
|
+
let consecutiveErrors = 0;
|
|
102
|
+
|
|
103
|
+
while (this.running) {
|
|
104
|
+
this.turns++;
|
|
105
|
+
|
|
106
|
+
const response = await this._callApi();
|
|
107
|
+
if (!response || !this.running) break;
|
|
108
|
+
|
|
109
|
+
const { content, toolCalls, usage, finishReason } = response;
|
|
110
|
+
consecutiveErrors = 0; // Reset on successful call
|
|
111
|
+
|
|
112
|
+
// Update token tracking from API response
|
|
113
|
+
if (usage) {
|
|
114
|
+
this._updateTokens(usage);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Append assistant message to conversation history
|
|
118
|
+
const assistantMsg = { role: 'assistant' };
|
|
119
|
+
if (content) assistantMsg.content = content;
|
|
120
|
+
if (toolCalls?.length > 0) {
|
|
121
|
+
assistantMsg.tool_calls = toolCalls.map((tc) => ({
|
|
122
|
+
id: tc.id,
|
|
123
|
+
type: 'function',
|
|
124
|
+
function: { name: tc.function.name, arguments: tc.function.arguments },
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
this.messages.push(assistantMsg);
|
|
128
|
+
|
|
129
|
+
// Broadcast text output to GUI
|
|
130
|
+
if (content) {
|
|
131
|
+
this._writeLog({ type: 'assistant', content: content.slice(0, 2000) });
|
|
132
|
+
this.emit('output', { type: 'activity', subtype: 'text', data: content });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// No tool calls → turn complete, go idle
|
|
136
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
137
|
+
this.emit('output', { type: 'result', data: content || 'Turn complete', turns: this.turns });
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Execute each tool call
|
|
142
|
+
for (const call of toolCalls) {
|
|
143
|
+
if (!this.running) break;
|
|
144
|
+
|
|
145
|
+
let args;
|
|
146
|
+
try {
|
|
147
|
+
args = JSON.parse(call.function.arguments);
|
|
148
|
+
} catch {
|
|
149
|
+
args = {};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const toolName = call.function.name;
|
|
153
|
+
const inputSummary = this._summarizeToolInput(toolName, args);
|
|
154
|
+
|
|
155
|
+
// Log + broadcast tool invocation
|
|
156
|
+
this._writeLog({ type: 'tool_use', tool: toolName, input: inputSummary });
|
|
157
|
+
this.emit('output', { type: 'activity', subtype: 'tool_use', data: `${toolName}: ${inputSummary}` });
|
|
158
|
+
|
|
159
|
+
// Feed classifier for adaptive routing
|
|
160
|
+
this.daemon.classifier.addEvent(this.agent.id, {
|
|
161
|
+
type: 'tool', tool: toolName,
|
|
162
|
+
input: args.path || args.command || args.pattern || '',
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Execute
|
|
166
|
+
const result = await this.executor.execute(toolName, args);
|
|
167
|
+
this.toolCallCount++;
|
|
168
|
+
|
|
169
|
+
// Log + broadcast result
|
|
170
|
+
const resultPreview = (result.result || result.error || '').slice(0, 500);
|
|
171
|
+
this._writeLog({
|
|
172
|
+
type: 'tool_result', tool: toolName,
|
|
173
|
+
success: result.success, output: resultPreview,
|
|
174
|
+
});
|
|
175
|
+
this.emit('output', {
|
|
176
|
+
type: 'activity', subtype: 'tool_result',
|
|
177
|
+
data: result.success ? `${toolName}: done` : `${toolName}: error — ${result.error}`,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!result.success) {
|
|
181
|
+
this.daemon.classifier.addEvent(this.agent.id, { type: 'error', text: result.error });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Append tool result to conversation for the model
|
|
185
|
+
this.messages.push({
|
|
186
|
+
role: 'tool',
|
|
187
|
+
tool_call_id: call.id,
|
|
188
|
+
content: result.success ? (result.result || 'Done.') : `Error: ${result.error}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Context rotation is handled by the Rotator's 15s polling loop
|
|
193
|
+
// which checks registry.contextUsage against the adaptive threshold.
|
|
194
|
+
// The journalist has full logs — no need for in-loop compaction.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- API Communication ---
|
|
199
|
+
|
|
200
|
+
async _callApi() {
|
|
201
|
+
this.abortController = new AbortController();
|
|
202
|
+
|
|
203
|
+
const body = {
|
|
204
|
+
model: this.config.model,
|
|
205
|
+
messages: this.messages,
|
|
206
|
+
tools: TOOL_DEFINITIONS,
|
|
207
|
+
tool_choice: 'auto',
|
|
208
|
+
temperature: this.config.temperature ?? 0.1,
|
|
209
|
+
max_tokens: this.config.maxResponseTokens || 4096,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Streaming for real-time output
|
|
213
|
+
if (this.config.stream !== false) {
|
|
214
|
+
body.stream = true;
|
|
215
|
+
body.stream_options = { include_usage: true };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const url = `${this.config.apiBase}/chat/completions`;
|
|
219
|
+
|
|
220
|
+
let response;
|
|
221
|
+
try {
|
|
222
|
+
response = await fetch(url, {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: {
|
|
225
|
+
'Content-Type': 'application/json',
|
|
226
|
+
...(this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}),
|
|
227
|
+
...this.config.headers,
|
|
228
|
+
},
|
|
229
|
+
body: JSON.stringify(body),
|
|
230
|
+
signal: this.abortController.signal,
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (err.name === 'AbortError') return null;
|
|
234
|
+
this._writeLog({ type: 'error', text: `API request failed: ${err.message}` });
|
|
235
|
+
this.emit('error', { message: `Inference API unreachable: ${err.message}` });
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
const text = await response.text().catch(() => '');
|
|
241
|
+
const errMsg = `API error ${response.status}: ${text.slice(0, 500)}`;
|
|
242
|
+
this._writeLog({ type: 'error', text: errMsg });
|
|
243
|
+
this.emit('error', { message: errMsg });
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Parse streaming or non-streaming response
|
|
248
|
+
if (body.stream) {
|
|
249
|
+
return this._parseSSE(response);
|
|
250
|
+
}
|
|
251
|
+
return this._parseJSON(response);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async _parseSSE(response) {
|
|
255
|
+
let content = '';
|
|
256
|
+
const toolCalls = new Map(); // index -> { id, function: { name, arguments } }
|
|
257
|
+
let usage = null;
|
|
258
|
+
let finishReason = null;
|
|
259
|
+
let buffer = '';
|
|
260
|
+
|
|
261
|
+
const reader = response.body.getReader();
|
|
262
|
+
const decoder = new TextDecoder();
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
while (true) {
|
|
266
|
+
const { done, value } = await reader.read();
|
|
267
|
+
if (done) break;
|
|
268
|
+
|
|
269
|
+
buffer += decoder.decode(value, { stream: true });
|
|
270
|
+
const lines = buffer.split('\n');
|
|
271
|
+
buffer = lines.pop() || '';
|
|
272
|
+
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
const trimmed = line.trim();
|
|
275
|
+
if (!trimmed.startsWith('data: ')) continue;
|
|
276
|
+
const payload = trimmed.slice(6);
|
|
277
|
+
if (payload === '[DONE]') continue;
|
|
278
|
+
|
|
279
|
+
let data;
|
|
280
|
+
try { data = JSON.parse(payload); } catch { continue; }
|
|
281
|
+
|
|
282
|
+
if (data.usage) usage = data.usage;
|
|
283
|
+
|
|
284
|
+
const choice = data.choices?.[0];
|
|
285
|
+
if (!choice) continue;
|
|
286
|
+
|
|
287
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
288
|
+
const delta = choice.delta || {};
|
|
289
|
+
|
|
290
|
+
// Stream text tokens to GUI in real-time
|
|
291
|
+
if (delta.content) {
|
|
292
|
+
content += delta.content;
|
|
293
|
+
this.emit('output', { type: 'activity', subtype: 'stream', data: delta.content });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Accumulate tool call deltas
|
|
297
|
+
if (delta.tool_calls) {
|
|
298
|
+
for (const tc of delta.tool_calls) {
|
|
299
|
+
const idx = tc.index ?? 0;
|
|
300
|
+
if (!toolCalls.has(idx)) {
|
|
301
|
+
toolCalls.set(idx, {
|
|
302
|
+
id: tc.id || `call_${idx}_${Date.now()}`,
|
|
303
|
+
function: { name: '', arguments: '' },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const existing = toolCalls.get(idx);
|
|
307
|
+
if (tc.id) existing.id = tc.id;
|
|
308
|
+
if (tc.function?.name) existing.function.name = tc.function.name;
|
|
309
|
+
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err.name === 'AbortError') return null;
|
|
316
|
+
this._writeLog({ type: 'error', text: `Stream parse error: ${err.message}` });
|
|
317
|
+
this.emit('error', { message: `Stream error: ${err.message}` });
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
content: content || null,
|
|
323
|
+
toolCalls: toolCalls.size > 0 ? Array.from(toolCalls.values()) : null,
|
|
324
|
+
usage,
|
|
325
|
+
finishReason,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async _parseJSON(response) {
|
|
330
|
+
const data = await response.json();
|
|
331
|
+
const choice = data.choices?.[0];
|
|
332
|
+
if (!choice) return null;
|
|
333
|
+
|
|
334
|
+
const msg = choice.message || {};
|
|
335
|
+
return {
|
|
336
|
+
content: msg.content || null,
|
|
337
|
+
toolCalls: msg.tool_calls?.map((tc) => ({
|
|
338
|
+
id: tc.id,
|
|
339
|
+
function: { name: tc.function.name, arguments: tc.function.arguments },
|
|
340
|
+
})) || null,
|
|
341
|
+
usage: data.usage || null,
|
|
342
|
+
finishReason: choice.finish_reason,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- Token Tracking ---
|
|
347
|
+
|
|
348
|
+
_updateTokens(usage) {
|
|
349
|
+
const inputTokens = usage.prompt_tokens || 0;
|
|
350
|
+
const outputTokens = usage.completion_tokens || 0;
|
|
351
|
+
const totalTokens = usage.total_tokens || (inputTokens + outputTokens);
|
|
352
|
+
|
|
353
|
+
this.totalTokensIn += inputTokens;
|
|
354
|
+
this.totalTokensOut += outputTokens;
|
|
355
|
+
|
|
356
|
+
// Context usage = how full the context window is
|
|
357
|
+
const contextWindow = this.config.contextWindow || 32768;
|
|
358
|
+
const contextUsage = contextWindow > 0 ? Math.min(inputTokens / contextWindow, 1) : 0;
|
|
359
|
+
|
|
360
|
+
// Emit token event — ProcessManager handles registry updates + subsystem feeding
|
|
361
|
+
this.emit('output', {
|
|
362
|
+
type: 'activity',
|
|
363
|
+
tokensUsed: totalTokens,
|
|
364
|
+
inputTokens,
|
|
365
|
+
outputTokens,
|
|
366
|
+
model: this.config.model,
|
|
367
|
+
contextUsage,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- System Prompt ---
|
|
372
|
+
|
|
373
|
+
_buildSystemPrompt() {
|
|
374
|
+
const parts = [];
|
|
375
|
+
const wd = this.agent.workingDir || this.daemon.projectDir;
|
|
376
|
+
|
|
377
|
+
parts.push(`You are a coding agent. Your working directory is: ${wd}`);
|
|
378
|
+
parts.push('');
|
|
379
|
+
parts.push('You have tools for reading, writing, editing, and searching files, and for running shell commands.');
|
|
380
|
+
parts.push('Work methodically: explore the codebase first, understand what exists, then make changes. Test your work when possible.');
|
|
381
|
+
parts.push('');
|
|
382
|
+
parts.push('Guidelines:');
|
|
383
|
+
parts.push('- Read files before editing them');
|
|
384
|
+
parts.push('- Make targeted edits with edit_file rather than rewriting entire files');
|
|
385
|
+
parts.push('- Run tests and builds after changes to verify correctness');
|
|
386
|
+
parts.push('- If a tool call fails, read the error and adjust your approach');
|
|
387
|
+
|
|
388
|
+
if (this.agent.scope?.length > 0) {
|
|
389
|
+
parts.push('');
|
|
390
|
+
parts.push(`File scope: You may only modify files matching these patterns: ${this.agent.scope.join(', ')}`);
|
|
391
|
+
parts.push('You can read any file, but writes outside your scope will be blocked.');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// GROOVE intro context — team awareness, coordination, project map
|
|
395
|
+
if (this.config.introContext) {
|
|
396
|
+
parts.push('');
|
|
397
|
+
parts.push(this.config.introContext);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return parts.join('\n');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// --- Logging (journalist-compatible) ---
|
|
404
|
+
|
|
405
|
+
_writeLog(entry) {
|
|
406
|
+
if (!this.logStream) return;
|
|
407
|
+
const line = JSON.stringify({ ...entry, ts: Date.now() });
|
|
408
|
+
this.logStream.write(line + '\n');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
_summarizeToolInput(toolName, args) {
|
|
412
|
+
switch (toolName) {
|
|
413
|
+
case 'read_file': {
|
|
414
|
+
let s = args.path || '';
|
|
415
|
+
if (args.offset) s += ` (from line ${args.offset})`;
|
|
416
|
+
if (args.limit) s += ` (${args.limit} lines)`;
|
|
417
|
+
return s;
|
|
418
|
+
}
|
|
419
|
+
case 'write_file': return `${args.path || ''} (${(args.content || '').split('\n').length} lines)`;
|
|
420
|
+
case 'edit_file': return args.path || '';
|
|
421
|
+
case 'run_command': return (args.command || '').slice(0, 120);
|
|
422
|
+
case 'search_files': return args.pattern || '';
|
|
423
|
+
case 'search_content': return `${args.pattern || ''} in ${args.path || '.'}`;
|
|
424
|
+
case 'list_directory': return args.path || '.';
|
|
425
|
+
default: return JSON.stringify(args).slice(0, 100);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- Status ---
|
|
430
|
+
|
|
431
|
+
getState() {
|
|
432
|
+
return {
|
|
433
|
+
running: this.running,
|
|
434
|
+
idle: this.idle,
|
|
435
|
+
turns: this.turns,
|
|
436
|
+
toolCallCount: this.toolCallCount,
|
|
437
|
+
totalTokensIn: this.totalTokensIn,
|
|
438
|
+
totalTokensOut: this.totalTokensOut,
|
|
439
|
+
messageCount: this.messages.length,
|
|
440
|
+
model: this.config.model,
|
|
441
|
+
uptime: Date.now() - this.startedAt,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
@@ -228,17 +228,99 @@ export function createApi(app, daemon) {
|
|
|
228
228
|
}
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
// --- Local Models (GGUF via HuggingFace) ---
|
|
232
|
+
|
|
233
|
+
app.get('/api/models/installed', (req, res) => {
|
|
234
|
+
const installed = daemon.modelManager.getInstalled();
|
|
235
|
+
const llamaStatus = daemon.llamaServer.getStatus();
|
|
236
|
+
res.json({ models: installed, llamaServer: llamaStatus });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
app.get('/api/models/search', async (req, res) => {
|
|
240
|
+
try {
|
|
241
|
+
const query = req.query.q || req.query.query || '';
|
|
242
|
+
if (!query) return res.status(400).json({ error: 'query parameter (q) is required' });
|
|
243
|
+
const results = await daemon.modelManager.search(query, {
|
|
244
|
+
limit: parseInt(req.query.limit) || 20,
|
|
245
|
+
});
|
|
246
|
+
res.json(results);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
res.status(500).json({ error: err.message });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
app.get('/api/models/:repoId(*)/files', async (req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
const files = await daemon.modelManager.getModelFiles(req.params.repoId);
|
|
255
|
+
res.json(files);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
res.status(500).json({ error: err.message });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
app.post('/api/models/download', async (req, res) => {
|
|
262
|
+
try {
|
|
263
|
+
const { repoId, filename } = req.body;
|
|
264
|
+
if (!repoId || !filename) return res.status(400).json({ error: 'repoId and filename are required' });
|
|
265
|
+
// Start download in background — progress via WebSocket
|
|
266
|
+
daemon.modelManager.download(repoId, filename).catch(() => {});
|
|
267
|
+
daemon.audit.log('model.download', { repoId, filename });
|
|
268
|
+
res.json({ started: true, filename, repoId });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
res.status(400).json({ error: err.message });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
app.post('/api/models/download/cancel', (req, res) => {
|
|
275
|
+
const { filename } = req.body;
|
|
276
|
+
if (!filename) return res.status(400).json({ error: 'filename is required' });
|
|
277
|
+
const cancelled = daemon.modelManager.cancelDownload(filename);
|
|
278
|
+
res.json({ cancelled });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
app.get('/api/models/downloads', (req, res) => {
|
|
282
|
+
res.json(daemon.modelManager.getActiveDownloads());
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
app.delete('/api/models/:id', (req, res) => {
|
|
286
|
+
const deleted = daemon.modelManager.deleteModel(req.params.id);
|
|
287
|
+
if (deleted) {
|
|
288
|
+
daemon.audit.log('model.delete', { id: req.params.id });
|
|
289
|
+
res.json({ ok: true });
|
|
290
|
+
} else {
|
|
291
|
+
res.status(404).json({ error: 'Model not found' });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
app.get('/api/models/recommend', (req, res) => {
|
|
296
|
+
const ramGb = parseInt(req.query.ram) || 16;
|
|
297
|
+
const quant = daemon.modelManager.recommendQuantization('7B', ramGb);
|
|
298
|
+
res.json({ recommendedQuantization: quant, ramGb });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
app.get('/api/llama/status', (req, res) => {
|
|
302
|
+
res.json(daemon.llamaServer.getStatus());
|
|
303
|
+
});
|
|
304
|
+
|
|
231
305
|
// --- Credentials ---
|
|
232
306
|
|
|
233
307
|
app.get('/api/credentials', (req, res) => {
|
|
234
308
|
res.json(daemon.credentials.listProviders());
|
|
235
309
|
});
|
|
236
310
|
|
|
237
|
-
app.post('/api/credentials/:provider', (req, res) => {
|
|
311
|
+
app.post('/api/credentials/:provider', async (req, res) => {
|
|
238
312
|
if (!req.body.key) return res.status(400).json({ error: 'key is required' });
|
|
239
313
|
daemon.credentials.setKey(req.params.provider, req.body.key);
|
|
240
314
|
daemon.audit.log('credential.set', { provider: req.params.provider });
|
|
241
|
-
|
|
315
|
+
|
|
316
|
+
// Provider-specific auth setup (e.g., Codex auto-login)
|
|
317
|
+
const provider = getProvider(req.params.provider);
|
|
318
|
+
let authResult = null;
|
|
319
|
+
if (provider?.constructor?.onKeySet) {
|
|
320
|
+
try { authResult = await provider.constructor.onKeySet(req.body.key); } catch { /* best effort */ }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
res.json({ ok: true, masked: daemon.credentials.mask(req.body.key), auth: authResult });
|
|
242
324
|
});
|
|
243
325
|
|
|
244
326
|
app.delete('/api/credentials/:provider', (req, res) => {
|
|
@@ -363,7 +445,8 @@ export function createApi(app, daemon) {
|
|
|
363
445
|
}
|
|
364
446
|
});
|
|
365
447
|
|
|
366
|
-
// Instruct an agent —
|
|
448
|
+
// Instruct an agent — send message to agent loop, resume session, or rotate
|
|
449
|
+
// Agent loop = direct message to running loop (local models)
|
|
367
450
|
// Resume = zero cold-start (uses --resume SESSION_ID)
|
|
368
451
|
// Rotation = full handoff brief (only for degradation or no session)
|
|
369
452
|
app.post('/api/agents/:id/instruct', async (req, res) => {
|
|
@@ -375,8 +458,17 @@ export function createApi(app, daemon) {
|
|
|
375
458
|
const agent = daemon.registry.get(req.params.id);
|
|
376
459
|
if (!agent) return res.status(404).json({ error: 'Agent not found' });
|
|
377
460
|
|
|
378
|
-
//
|
|
379
|
-
|
|
461
|
+
// Agent loop path — send message directly to the running loop
|
|
462
|
+
if (daemon.processes.hasAgentLoop(req.params.id)) {
|
|
463
|
+
const sent = await daemon.processes.sendMessage(req.params.id, message.trim());
|
|
464
|
+
if (sent) {
|
|
465
|
+
daemon.audit.log('agent.chat', { id: req.params.id });
|
|
466
|
+
return res.json({ id: agent.id, status: 'message_sent' });
|
|
467
|
+
}
|
|
468
|
+
// Loop exists but not running — fall through to resume/rotate
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// CLI agent path — session resume or rotation
|
|
380
472
|
const resumed = !!agent.sessionId;
|
|
381
473
|
const newAgent = resumed
|
|
382
474
|
? await daemon.processes.resume(req.params.id, message.trim())
|
|
@@ -390,6 +482,7 @@ export function createApi(app, daemon) {
|
|
|
390
482
|
});
|
|
391
483
|
|
|
392
484
|
// Query an agent (headless one-shot, agent keeps running)
|
|
485
|
+
// For agent loop agents: sends message directly to the loop
|
|
393
486
|
app.post('/api/agents/:id/query', async (req, res) => {
|
|
394
487
|
try {
|
|
395
488
|
const { message } = req.body;
|
|
@@ -399,6 +492,12 @@ export function createApi(app, daemon) {
|
|
|
399
492
|
const agent = daemon.registry.get(req.params.id);
|
|
400
493
|
if (!agent) return res.status(404).json({ error: 'Agent not found' });
|
|
401
494
|
|
|
495
|
+
// Agent loop agents: send message directly (they're interactive)
|
|
496
|
+
if (daemon.processes.hasAgentLoop(req.params.id)) {
|
|
497
|
+
const sent = await daemon.processes.sendMessage(req.params.id, message.trim());
|
|
498
|
+
return res.json({ response: sent ? 'Message sent to agent' : 'Agent not running', agentId: agent.id, agentName: agent.name });
|
|
499
|
+
}
|
|
500
|
+
|
|
402
501
|
// Build context about the agent's work
|
|
403
502
|
const activity = daemon.classifier?.agentWindows?.[agent.id] || [];
|
|
404
503
|
const recentActivity = activity.slice(-20).map((e) => e.data || e.text || '').join('\n');
|
|
@@ -34,6 +34,8 @@ import { FileWatcher } from './filewatcher.js';
|
|
|
34
34
|
import { TimelineTracker } from './timeline.js';
|
|
35
35
|
import { TerminalManager } from './terminal-pty.js';
|
|
36
36
|
import { GatewayManager } from './gateways/manager.js';
|
|
37
|
+
import { ModelManager } from './model-manager.js';
|
|
38
|
+
import { LlamaServerManager } from './llama-server.js';
|
|
37
39
|
import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
|
|
38
40
|
|
|
39
41
|
const DEFAULT_PORT = 31415;
|
|
@@ -129,6 +131,8 @@ export class Daemon {
|
|
|
129
131
|
this.fileWatcher = new FileWatcher(this);
|
|
130
132
|
this.terminalManager = new TerminalManager(this);
|
|
131
133
|
this.gateways = new GatewayManager(this);
|
|
134
|
+
this.modelManager = new ModelManager(this);
|
|
135
|
+
this.llamaServer = new LlamaServerManager(this);
|
|
132
136
|
|
|
133
137
|
// HTTP + WebSocket server
|
|
134
138
|
this.app = express();
|
|
@@ -400,8 +404,9 @@ export class Daemon {
|
|
|
400
404
|
this.fileWatcher.unwatchAll();
|
|
401
405
|
this.terminalManager.killAll();
|
|
402
406
|
|
|
403
|
-
// Kill all agent processes
|
|
407
|
+
// Kill all agent processes and stop inference servers
|
|
404
408
|
await this.processes.killAll();
|
|
409
|
+
await this.llamaServer.stopAll();
|
|
405
410
|
|
|
406
411
|
// Clean up PID and host files
|
|
407
412
|
if (existsSync(this.pidFile)) {
|