knoxis-collab 1.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/knoxis-collab.js +796 -0
- package/package.json +23 -0
package/knoxis-collab.js
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Knoxis Collab — Three-way collaborative programming
|
|
5
|
+
*
|
|
6
|
+
* A persistent terminal session where:
|
|
7
|
+
* User <--> Knoxis (Groq) <--> Claude Code
|
|
8
|
+
*
|
|
9
|
+
* Unlike the fire-and-forget pair programming scripts, this keeps the human
|
|
10
|
+
* in the loop. You talk to Knoxis, he dispatches to Claude when needed,
|
|
11
|
+
* reviews the output, and reports back. You approve, reject, or redirect.
|
|
12
|
+
* The session runs indefinitely with no timeouts or rigid phases.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node knoxis-collab.js # interactive, current dir
|
|
16
|
+
* node knoxis-collab.js --workspace /path/to/project
|
|
17
|
+
* node knoxis-collab.js --task "build a REST API"
|
|
18
|
+
* KNOXIS_TASK_FILE=/tmp/task.txt node knoxis-collab.js
|
|
19
|
+
*
|
|
20
|
+
* Env:
|
|
21
|
+
* GROQ_API_KEY - Required (or set in ~/.knoxis/config.json)
|
|
22
|
+
* KNOXIS_WORKSPACE - Workspace directory (default: cwd)
|
|
23
|
+
* KNOXIS_TASK_FILE - Path to file containing initial task
|
|
24
|
+
* KNOXIS_GROQ_MODEL - Groq model (default: llama-3.3-70b-versatile)
|
|
25
|
+
*
|
|
26
|
+
* Commands:
|
|
27
|
+
* /status - Session info
|
|
28
|
+
* /verbose - Toggle Claude output streaming
|
|
29
|
+
* /diff - Show git changes in workspace
|
|
30
|
+
* /log - Show session log path
|
|
31
|
+
* /exit - End session
|
|
32
|
+
*
|
|
33
|
+
* ZERO EXTERNAL DEPENDENCIES — Node.js built-ins only.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { spawn, spawnSync } = require('child_process');
|
|
37
|
+
const crypto = require('crypto');
|
|
38
|
+
const https = require('https');
|
|
39
|
+
const readline = require('readline');
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
const os = require('os');
|
|
43
|
+
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════
|
|
45
|
+
// CONFIG
|
|
46
|
+
// ═══════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
const CONFIG_PATH = path.join(os.homedir(), '.knoxis', 'config.json');
|
|
49
|
+
const SESSION_ID = crypto.randomUUID();
|
|
50
|
+
const GROQ_MODEL = process.env.KNOXIS_GROQ_MODEL || 'llama-3.3-70b-versatile';
|
|
51
|
+
|
|
52
|
+
// Parse CLI args
|
|
53
|
+
function parseArgs() {
|
|
54
|
+
const args = { workspace: null, task: null, verbose: false };
|
|
55
|
+
const argv = process.argv.slice(2);
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
if ((argv[i] === '--workspace' || argv[i] === '-w') && argv[i + 1]) {
|
|
58
|
+
args.workspace = argv[++i];
|
|
59
|
+
} else if ((argv[i] === '--task' || argv[i] === '-t') && argv[i + 1]) {
|
|
60
|
+
args.task = argv[++i];
|
|
61
|
+
} else if (argv[i] === '--verbose' || argv[i] === '-v') {
|
|
62
|
+
args.verbose = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const cliArgs = parseArgs();
|
|
69
|
+
const WORKSPACE = cliArgs.workspace || process.env.KNOXIS_WORKSPACE || process.cwd();
|
|
70
|
+
|
|
71
|
+
// Load config
|
|
72
|
+
function loadConfig() {
|
|
73
|
+
try {
|
|
74
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
75
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {}
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const config = loadConfig();
|
|
82
|
+
const GROQ_API_KEY = process.env.GROQ_API_KEY || config.groqApiKey || '';
|
|
83
|
+
|
|
84
|
+
// ═══════════════════════════════════════════════════════════════
|
|
85
|
+
// STATE
|
|
86
|
+
// ═══════════════════════════════════════════════════════════════
|
|
87
|
+
|
|
88
|
+
let claudeSessionId = null;
|
|
89
|
+
let claudeDispatches = 0;
|
|
90
|
+
let verbose = cliArgs.verbose;
|
|
91
|
+
let isClaudeRunning = false;
|
|
92
|
+
let activeClaudeProc = null;
|
|
93
|
+
const conversationHistory = []; // Groq message history
|
|
94
|
+
const dispatchSummaries = []; // Short summaries of what Claude did each dispatch
|
|
95
|
+
|
|
96
|
+
// ═══════════════════════════════════════════════════════════════
|
|
97
|
+
// SESSION LOG
|
|
98
|
+
// ═══════════════════════════════════════════════════════════════
|
|
99
|
+
|
|
100
|
+
const sessionDir = path.join(WORKSPACE, '.knoxis', 'sessions');
|
|
101
|
+
try { fs.mkdirSync(sessionDir, { recursive: true }); } catch (e) {}
|
|
102
|
+
|
|
103
|
+
const logFile = path.join(
|
|
104
|
+
sessionDir,
|
|
105
|
+
`${new Date().toISOString().replace(/[:.]/g, '-')}-collab-${SESSION_ID.slice(0, 8)}.log`
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
function log(entry) {
|
|
109
|
+
const line = `[${new Date().toISOString()}] ${entry}\n`;
|
|
110
|
+
try { fs.appendFileSync(logFile, line); } catch (e) {}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ═══════════════════════════════════════════════════════════════
|
|
114
|
+
// TERMINAL UI
|
|
115
|
+
// ═══════════════════════════════════════════════════════════════
|
|
116
|
+
|
|
117
|
+
const C = {
|
|
118
|
+
reset: '\x1b[0m',
|
|
119
|
+
bold: '\x1b[1m',
|
|
120
|
+
dim: '\x1b[2m',
|
|
121
|
+
cyan: '\x1b[36m',
|
|
122
|
+
green: '\x1b[32m',
|
|
123
|
+
yellow: '\x1b[33m',
|
|
124
|
+
magenta: '\x1b[35m',
|
|
125
|
+
blue: '\x1b[34m',
|
|
126
|
+
red: '\x1b[31m',
|
|
127
|
+
white: '\x1b[37m',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function printHeader() {
|
|
131
|
+
console.log('');
|
|
132
|
+
console.log(`${C.cyan}╔══════════════════════════════════════════════════════════════╗${C.reset}`);
|
|
133
|
+
console.log(`${C.cyan}║${C.bold}${C.white} KNOXIS COLLABORATIVE SESSION ${C.reset}${C.cyan}║${C.reset}`);
|
|
134
|
+
console.log(`${C.cyan}╚══════════════════════════════════════════════════════════════╝${C.reset}`);
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(` ${C.dim}Session:${C.reset} ${SESSION_ID.slice(0, 8)}`);
|
|
137
|
+
console.log(` ${C.dim}Dir:${C.reset} ${WORKSPACE}`);
|
|
138
|
+
console.log(` ${C.dim}Knoxis:${C.reset} Groq (${GROQ_MODEL})`);
|
|
139
|
+
console.log(` ${C.dim}Claude:${C.reset} Claude Code (session-persistent)`);
|
|
140
|
+
console.log(` ${C.dim}Log:${C.reset} ${path.basename(logFile)}`);
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log(` ${C.dim}Talk to Knoxis naturally. He dispatches to Claude when needed.${C.reset}`);
|
|
143
|
+
console.log(` ${C.dim}Commands: /status /verbose /diff /log /exit${C.reset}`);
|
|
144
|
+
console.log('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function printKnoxis(message) {
|
|
148
|
+
// Word-wrap at ~76 chars with Knoxis prefix
|
|
149
|
+
const lines = message.split('\n');
|
|
150
|
+
console.log('');
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
console.log(` ${C.magenta}${C.bold}Knoxis:${C.reset} ${line}`);
|
|
153
|
+
}
|
|
154
|
+
console.log('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function printClaudeLine(text) {
|
|
158
|
+
if (verbose) {
|
|
159
|
+
const lines = text.split('\n');
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
if (line.trim()) {
|
|
162
|
+
console.log(` ${C.dim} │ ${line}${C.reset}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function printClaudeStatus(status) {
|
|
169
|
+
if (!verbose) {
|
|
170
|
+
process.stdout.write(`\r ${C.blue} ⟳ Claude: ${status}${C.reset}${''.padEnd(20)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clearClaudeStatus() {
|
|
175
|
+
if (!verbose) {
|
|
176
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function printDivider(label, color) {
|
|
181
|
+
const c = color || C.blue;
|
|
182
|
+
console.log(`\n ${c}━━━ ${label} ━━━${C.reset}\n`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ═══════════════════════════════════════════════════════════════
|
|
186
|
+
// LOAD INITIAL TASK
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════
|
|
188
|
+
|
|
189
|
+
function loadInitialTask() {
|
|
190
|
+
// CLI arg
|
|
191
|
+
if (cliArgs.task) return cliArgs.task;
|
|
192
|
+
|
|
193
|
+
// Task file
|
|
194
|
+
const taskFile = process.env.KNOXIS_TASK_FILE;
|
|
195
|
+
if (taskFile && fs.existsSync(taskFile)) {
|
|
196
|
+
return fs.readFileSync(taskFile, 'utf8').trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ═══════════════════════════════════════════════════════════════
|
|
203
|
+
// DETECT PROJECT CONTEXT (lightweight)
|
|
204
|
+
// ═══════════════════════════════════════════════════════════════
|
|
205
|
+
|
|
206
|
+
function detectProject() {
|
|
207
|
+
const info = [];
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const pkgPath = path.join(WORKSPACE, 'package.json');
|
|
211
|
+
if (fs.existsSync(pkgPath)) {
|
|
212
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
213
|
+
info.push(`Node.js project: ${pkg.name || 'unknown'}`);
|
|
214
|
+
if (pkg.description) info.push(pkg.description);
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const pomPath = path.join(WORKSPACE, 'pom.xml');
|
|
220
|
+
if (fs.existsSync(pomPath)) info.push('Java/Maven project (pom.xml)');
|
|
221
|
+
} catch (e) {}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const gradlePath = path.join(WORKSPACE, 'build.gradle');
|
|
225
|
+
if (fs.existsSync(gradlePath)) info.push('Java/Gradle project');
|
|
226
|
+
} catch (e) {}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const nextPath = path.join(WORKSPACE, 'next.config.js');
|
|
230
|
+
const nextPath2 = path.join(WORKSPACE, 'next.config.mjs');
|
|
231
|
+
if (fs.existsSync(nextPath) || fs.existsSync(nextPath2)) info.push('Next.js app');
|
|
232
|
+
} catch (e) {}
|
|
233
|
+
|
|
234
|
+
// Git branch
|
|
235
|
+
try {
|
|
236
|
+
const result = spawnSync('git', ['branch', '--show-current'], { cwd: WORKSPACE, stdio: 'pipe' });
|
|
237
|
+
if (result.status === 0) {
|
|
238
|
+
const branch = result.stdout.toString().trim();
|
|
239
|
+
if (branch) info.push(`Git branch: ${branch}`);
|
|
240
|
+
}
|
|
241
|
+
} catch (e) {}
|
|
242
|
+
|
|
243
|
+
return info.join(' · ');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ═══════════════════════════════════════════════════════════════
|
|
247
|
+
// GROQ API (Knoxis)
|
|
248
|
+
// ═══════════════════════════════════════════════════════════════
|
|
249
|
+
|
|
250
|
+
function buildSystemPrompt(projectContext) {
|
|
251
|
+
return `You are Knoxis, a senior developer and technical lead in a live collaborative programming session. Three participants: the user (developer), you (Knoxis), and Claude Code (AI coding assistant that can read/write files, run commands, etc).
|
|
252
|
+
|
|
253
|
+
Your role:
|
|
254
|
+
- Talk to the user conversationally about what they want to build, fix, or change.
|
|
255
|
+
- When a task is clear, dispatch it to Claude Code with a well-structured prompt.
|
|
256
|
+
- Review Claude's output when it comes back. Give the user an honest, concise summary.
|
|
257
|
+
- Ask clarifying questions when the user's intent is vague. Don't guess — ask.
|
|
258
|
+
- Suggest breaking large tasks into steps. Execute one at a time.
|
|
259
|
+
- Push back if something seems off. You're a peer, not a yes-man.
|
|
260
|
+
- Keep things moving. Bias toward action over discussion.
|
|
261
|
+
|
|
262
|
+
WORKSPACE: ${WORKSPACE}
|
|
263
|
+
PROJECT: ${projectContext || 'unknown'}
|
|
264
|
+
${dispatchSummaries.length > 0 ? '\nWORK DONE SO FAR:\n' + dispatchSummaries.map((s, i) => ` ${i + 1}. ${s}`).join('\n') : ''}
|
|
265
|
+
|
|
266
|
+
RESPONSE FORMAT: You MUST respond with valid JSON only. No markdown wrapping, no code fences. Structure:
|
|
267
|
+
{
|
|
268
|
+
"action": "respond" | "dispatch",
|
|
269
|
+
"message": "what to say to the user (concise, markdown OK for formatting)",
|
|
270
|
+
"claudePrompt": "structured prompt for Claude (ONLY when action is dispatch)"
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
ACTIONS:
|
|
274
|
+
- "respond": Talk directly to the user. Use for greetings, clarifications, questions, status, simple answers. No Claude involved.
|
|
275
|
+
- "dispatch": Send work to Claude Code. "message" tells the user what you're doing. "claudePrompt" is the structured prompt for Claude. Be specific: file paths, what to change, patterns to follow, what to report back.
|
|
276
|
+
|
|
277
|
+
DISPATCH GUIDELINES:
|
|
278
|
+
- Write claudePrompt like a senior dev writing a well-scoped ticket. Include: goal, files to touch, constraints, what to report when done.
|
|
279
|
+
- Always include "Workspace: ${WORKSPACE}" in the prompt so Claude knows where to work.
|
|
280
|
+
- If the task builds on previous work, reference what was already done.
|
|
281
|
+
- Tell Claude: "When done, summarize what you changed (files, functions, lines)."
|
|
282
|
+
- If the user's request is vague, respond with a clarifying question instead of dispatching.
|
|
283
|
+
- For large tasks, suggest a plan and get approval before dispatching.
|
|
284
|
+
|
|
285
|
+
REVIEW GUIDELINES (when you receive Claude's output):
|
|
286
|
+
- Summarize what was actually changed — files, functions, key decisions.
|
|
287
|
+
- Flag anything that looks wrong, incomplete, or that deviated from the ask.
|
|
288
|
+
- Suggest logical next steps.
|
|
289
|
+
- Be brief. 3-5 sentences for typical changes, more for complex work.
|
|
290
|
+
|
|
291
|
+
PERSONALITY: Direct, technical, efficient. You're a developer talking to a developer. No fluff.`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function callGroq(messages, projectContext) {
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
if (!GROQ_API_KEY) {
|
|
297
|
+
reject(new Error('GROQ_API_KEY not set'));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const payload = JSON.stringify({
|
|
302
|
+
model: GROQ_MODEL,
|
|
303
|
+
messages: [
|
|
304
|
+
{ role: 'system', content: buildSystemPrompt(projectContext) },
|
|
305
|
+
...messages
|
|
306
|
+
],
|
|
307
|
+
temperature: 0.3,
|
|
308
|
+
max_tokens: 2048,
|
|
309
|
+
response_format: { type: 'json_object' }
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const options = {
|
|
313
|
+
hostname: 'api.groq.com',
|
|
314
|
+
path: '/openai/v1/chat/completions',
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: {
|
|
317
|
+
'Content-Type': 'application/json',
|
|
318
|
+
'Authorization': `Bearer ${GROQ_API_KEY}`,
|
|
319
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const req = https.request(options, (res) => {
|
|
324
|
+
let data = '';
|
|
325
|
+
res.on('data', chunk => data += chunk);
|
|
326
|
+
res.on('end', () => {
|
|
327
|
+
try {
|
|
328
|
+
const json = JSON.parse(data);
|
|
329
|
+
if (json.error) {
|
|
330
|
+
reject(new Error(`Groq API: ${json.error.message || JSON.stringify(json.error)}`));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!json.choices || !json.choices[0]) {
|
|
334
|
+
reject(new Error('Groq returned empty response'));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const content = json.choices[0].message.content;
|
|
339
|
+
// Try parsing JSON, handle potential code fences
|
|
340
|
+
let cleaned = content.trim();
|
|
341
|
+
if (cleaned.startsWith('```')) {
|
|
342
|
+
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const parsed = JSON.parse(cleaned);
|
|
346
|
+
resolve(parsed);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
// If JSON parse fails, wrap raw text as a respond action
|
|
349
|
+
try {
|
|
350
|
+
const json = JSON.parse(data);
|
|
351
|
+
const raw = json.choices?.[0]?.message?.content || '';
|
|
352
|
+
resolve({ action: 'respond', message: raw || 'Sorry, I had trouble forming a response. Could you rephrase?' });
|
|
353
|
+
} catch (e2) {
|
|
354
|
+
reject(new Error(`Failed to parse Groq response: ${e.message}`));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
req.on('error', reject);
|
|
361
|
+
req.setTimeout(30000, () => {
|
|
362
|
+
req.destroy();
|
|
363
|
+
reject(new Error('Groq request timed out (30s)'));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
req.write(payload);
|
|
367
|
+
req.end();
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ═══════════════════════════════════════════════════════════════
|
|
372
|
+
// CLAUDE CODE DISPATCH
|
|
373
|
+
// ═══════════════════════════════════════════════════════════════
|
|
374
|
+
|
|
375
|
+
function checkClaudeInstalled() {
|
|
376
|
+
const result = spawnSync('which', ['claude'], { stdio: 'pipe' });
|
|
377
|
+
return result.status === 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function dispatchToClaude(prompt) {
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
claudeDispatches++;
|
|
383
|
+
isClaudeRunning = true;
|
|
384
|
+
|
|
385
|
+
const args = ['-p', '--dangerously-skip-permissions'];
|
|
386
|
+
if (claudeSessionId) {
|
|
387
|
+
args.push('--resume', claudeSessionId);
|
|
388
|
+
} else {
|
|
389
|
+
claudeSessionId = SESSION_ID;
|
|
390
|
+
args.push('--session-id', claudeSessionId);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
log(`DISPATCH #${claudeDispatches}:\n${prompt}`);
|
|
394
|
+
|
|
395
|
+
const proc = spawn('claude', args, {
|
|
396
|
+
cwd: WORKSPACE,
|
|
397
|
+
env: { ...process.env },
|
|
398
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
activeClaudeProc = proc;
|
|
402
|
+
let stdout = '';
|
|
403
|
+
let stderr = '';
|
|
404
|
+
let outputLines = 0;
|
|
405
|
+
const startTime = Date.now();
|
|
406
|
+
|
|
407
|
+
proc.stdout.on('data', (chunk) => {
|
|
408
|
+
const text = chunk.toString();
|
|
409
|
+
stdout += text;
|
|
410
|
+
outputLines += text.split('\n').filter(l => l.trim()).length;
|
|
411
|
+
printClaudeLine(text);
|
|
412
|
+
if (!verbose) {
|
|
413
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
414
|
+
printClaudeStatus(`working... ${outputLines} lines, ${elapsed}s`);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
proc.stderr.on('data', (chunk) => {
|
|
419
|
+
const text = chunk.toString();
|
|
420
|
+
if (!text.includes('Debug:') && !text.includes('trace')) {
|
|
421
|
+
stderr += text;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
proc.on('close', (code) => {
|
|
426
|
+
isClaudeRunning = false;
|
|
427
|
+
activeClaudeProc = null;
|
|
428
|
+
clearClaudeStatus();
|
|
429
|
+
|
|
430
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
431
|
+
log(`CLAUDE DONE (exit ${code}, ${elapsed}s, ${outputLines} lines):\n${stdout.slice(0, 2000)}`);
|
|
432
|
+
|
|
433
|
+
if (code === 0 || stdout.length > 0) {
|
|
434
|
+
resolve({ output: stdout.trim(), exitCode: code || 0, elapsed, lines: outputLines });
|
|
435
|
+
} else {
|
|
436
|
+
reject(new Error(`Claude exited with code ${code}${stderr ? ': ' + stderr.slice(0, 500) : ''}`));
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
proc.on('error', (err) => {
|
|
441
|
+
isClaudeRunning = false;
|
|
442
|
+
activeClaudeProc = null;
|
|
443
|
+
clearClaudeStatus();
|
|
444
|
+
reject(err);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
proc.stdin.write(prompt);
|
|
448
|
+
proc.stdin.end();
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ═══════════════════════════════════════════════════════════════
|
|
453
|
+
// CONVERSATION MANAGEMENT
|
|
454
|
+
// ═══════════════════════════════════════════════════════════════
|
|
455
|
+
|
|
456
|
+
// Keep conversation history bounded. Always keep first 2 messages (greeting context)
|
|
457
|
+
// and last 18 messages. Truncate long assistant messages.
|
|
458
|
+
function trimHistory() {
|
|
459
|
+
const MAX_MESSAGES = 24;
|
|
460
|
+
if (conversationHistory.length <= MAX_MESSAGES) return;
|
|
461
|
+
|
|
462
|
+
const keep = 2; // greeting exchange
|
|
463
|
+
const tail = MAX_MESSAGES - keep;
|
|
464
|
+
const trimmed = [
|
|
465
|
+
...conversationHistory.slice(0, keep),
|
|
466
|
+
{ role: 'user', content: '[Earlier conversation trimmed for context. See dispatch summaries in system prompt for work done.]' },
|
|
467
|
+
...conversationHistory.slice(-tail)
|
|
468
|
+
];
|
|
469
|
+
conversationHistory.length = 0;
|
|
470
|
+
conversationHistory.push(...trimmed);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Truncate Claude output for Groq review (Groq doesn't need the full thing)
|
|
474
|
+
function truncateForReview(output, maxChars) {
|
|
475
|
+
const limit = maxChars || 6000;
|
|
476
|
+
if (output.length <= limit) return output;
|
|
477
|
+
|
|
478
|
+
const head = output.slice(0, limit * 0.7);
|
|
479
|
+
const tail = output.slice(-limit * 0.3);
|
|
480
|
+
return head + '\n\n[... output truncated for review ...]\n\n' + tail;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ═══════════════════════════════════════════════════════════════
|
|
484
|
+
// COMMAND HANDLERS
|
|
485
|
+
// ═══════════════════════════════════════════════════════════════
|
|
486
|
+
|
|
487
|
+
function handleCommand(input) {
|
|
488
|
+
const cmd = input.toLowerCase().trim();
|
|
489
|
+
|
|
490
|
+
if (cmd === '/exit' || cmd === '/quit' || cmd === '/q') {
|
|
491
|
+
log('SESSION END (user exit)');
|
|
492
|
+
console.log(`\n ${C.dim}Session ended. ${claudeDispatches} dispatches to Claude.${C.reset}`);
|
|
493
|
+
console.log(` ${C.dim}Log: ${logFile}${C.reset}\n`);
|
|
494
|
+
process.exit(0);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (cmd === '/status') {
|
|
498
|
+
console.log(`\n ${C.dim}Session:${C.reset} ${SESSION_ID.slice(0, 8)}`);
|
|
499
|
+
console.log(` ${C.dim}Workspace:${C.reset} ${WORKSPACE}`);
|
|
500
|
+
console.log(` ${C.dim}Dispatches:${C.reset} ${claudeDispatches}`);
|
|
501
|
+
console.log(` ${C.dim}Claude:${C.reset} ${claudeSessionId ? 'active (session ' + claudeSessionId.slice(0, 8) + ')' : 'not yet started'}`);
|
|
502
|
+
console.log(` ${C.dim}Messages:${C.reset} ${conversationHistory.length}`);
|
|
503
|
+
console.log(` ${C.dim}Verbose:${C.reset} ${verbose ? 'on' : 'off'}`);
|
|
504
|
+
console.log(` ${C.dim}Log:${C.reset} ${logFile}`);
|
|
505
|
+
if (dispatchSummaries.length > 0) {
|
|
506
|
+
console.log(`\n ${C.dim}Work done:${C.reset}`);
|
|
507
|
+
dispatchSummaries.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
|
508
|
+
}
|
|
509
|
+
console.log('');
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (cmd === '/verbose') {
|
|
514
|
+
verbose = !verbose;
|
|
515
|
+
console.log(`\n ${C.dim}Claude output streaming: ${verbose ? 'ON' : 'OFF'}${C.reset}\n`);
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (cmd === '/diff') {
|
|
520
|
+
try {
|
|
521
|
+
const result = spawnSync('git', ['diff', '--stat'], { cwd: WORKSPACE, stdio: 'pipe' });
|
|
522
|
+
const output = result.stdout.toString().trim();
|
|
523
|
+
if (output) {
|
|
524
|
+
console.log(`\n ${C.dim}Git changes in workspace:${C.reset}`);
|
|
525
|
+
output.split('\n').forEach(line => console.log(` ${C.dim} ${line}${C.reset}`));
|
|
526
|
+
} else {
|
|
527
|
+
console.log(`\n ${C.dim}No uncommitted changes.${C.reset}`);
|
|
528
|
+
}
|
|
529
|
+
} catch (e) {
|
|
530
|
+
console.log(`\n ${C.dim}Not a git repository or git not available.${C.reset}`);
|
|
531
|
+
}
|
|
532
|
+
console.log('');
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (cmd === '/log') {
|
|
537
|
+
console.log(`\n ${C.dim}Log: ${logFile}${C.reset}\n`);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ═══════════════════════════════════════════════════════════════
|
|
545
|
+
// MAIN INPUT HANDLER
|
|
546
|
+
// ═══════════════════════════════════════════════════════════════
|
|
547
|
+
|
|
548
|
+
let projectContext = '';
|
|
549
|
+
|
|
550
|
+
async function handleUserInput(input, rl) {
|
|
551
|
+
const trimmed = input.trim();
|
|
552
|
+
if (!trimmed) return;
|
|
553
|
+
|
|
554
|
+
// Handle slash commands
|
|
555
|
+
if (trimmed.startsWith('/')) {
|
|
556
|
+
if (handleCommand(trimmed)) return;
|
|
557
|
+
// Unknown command — pass through to Knoxis
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Block input while Claude is running
|
|
561
|
+
if (isClaudeRunning) {
|
|
562
|
+
console.log(`\n ${C.yellow}Claude is still working. Wait for it to finish or press Ctrl+C to cancel.${C.reset}\n`);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Add to conversation
|
|
567
|
+
conversationHistory.push({ role: 'user', content: trimmed });
|
|
568
|
+
log(`USER: ${trimmed}`);
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
// Send to Knoxis (Groq)
|
|
572
|
+
trimHistory();
|
|
573
|
+
const response = await callGroq(conversationHistory, projectContext);
|
|
574
|
+
|
|
575
|
+
if (response.action === 'dispatch' && response.claudePrompt) {
|
|
576
|
+
// ─── DISPATCH TO CLAUDE ───
|
|
577
|
+
printKnoxis(response.message);
|
|
578
|
+
log(`KNOXIS (dispatch): ${response.message}`);
|
|
579
|
+
|
|
580
|
+
// Record the assistant dispatch decision
|
|
581
|
+
conversationHistory.push({
|
|
582
|
+
role: 'assistant',
|
|
583
|
+
content: JSON.stringify({ action: 'dispatch', message: response.message })
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
printDivider('Dispatching to Claude Code', C.blue);
|
|
587
|
+
if (!verbose) printClaudeStatus('starting...');
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
const result = await dispatchToClaude(response.claudePrompt);
|
|
591
|
+
|
|
592
|
+
printDivider(`Claude finished (${result.elapsed}s, ${result.lines} lines)`, C.green);
|
|
593
|
+
|
|
594
|
+
// Send output to Knoxis for review
|
|
595
|
+
const reviewMessages = [
|
|
596
|
+
...conversationHistory,
|
|
597
|
+
{
|
|
598
|
+
role: 'user',
|
|
599
|
+
content: `[SYSTEM: Claude Code finished (exit ${result.exitCode}, ${result.elapsed}s). Review the output and summarize for the user. Use action "respond".]\n\nClaude output:\n${truncateForReview(result.output)}`
|
|
600
|
+
}
|
|
601
|
+
];
|
|
602
|
+
|
|
603
|
+
const review = await callGroq(reviewMessages, projectContext);
|
|
604
|
+
printKnoxis(review.message);
|
|
605
|
+
log(`KNOXIS (review): ${review.message}`);
|
|
606
|
+
|
|
607
|
+
// Track dispatch summary (short version for system prompt context)
|
|
608
|
+
const shortSummary = review.message.split('\n')[0].slice(0, 150);
|
|
609
|
+
dispatchSummaries.push(shortSummary);
|
|
610
|
+
|
|
611
|
+
// Add review to history
|
|
612
|
+
conversationHistory.push({
|
|
613
|
+
role: 'assistant',
|
|
614
|
+
content: JSON.stringify({ action: 'respond', message: review.message })
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
} catch (claudeErr) {
|
|
618
|
+
printDivider('Claude error', C.red);
|
|
619
|
+
const errMsg = `Claude hit an issue: ${claudeErr.message}. Want me to retry or take a different approach?`;
|
|
620
|
+
printKnoxis(errMsg);
|
|
621
|
+
log(`CLAUDE ERROR: ${claudeErr.message}`);
|
|
622
|
+
conversationHistory.push({
|
|
623
|
+
role: 'assistant',
|
|
624
|
+
content: JSON.stringify({ action: 'respond', message: errMsg })
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
} else {
|
|
629
|
+
// ─── DIRECT RESPONSE ───
|
|
630
|
+
printKnoxis(response.message);
|
|
631
|
+
log(`KNOXIS: ${response.message}`);
|
|
632
|
+
conversationHistory.push({
|
|
633
|
+
role: 'assistant',
|
|
634
|
+
content: JSON.stringify({ action: 'respond', message: response.message })
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
} catch (err) {
|
|
639
|
+
console.log(`\n ${C.red}Error: ${err.message}${C.reset}\n`);
|
|
640
|
+
log(`ERROR: ${err.message}`);
|
|
641
|
+
// Remove the user message that caused the error so they can retry
|
|
642
|
+
if (conversationHistory.length > 0 && conversationHistory[conversationHistory.length - 1].role === 'user') {
|
|
643
|
+
conversationHistory.pop();
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ═══════════════════════════════════════════════════════════════
|
|
649
|
+
// ENTRY POINT
|
|
650
|
+
// ═══════════════════════════════════════════════════════════════
|
|
651
|
+
|
|
652
|
+
async function main() {
|
|
653
|
+
// Preflight checks
|
|
654
|
+
if (!GROQ_API_KEY) {
|
|
655
|
+
console.error(`\n ${C.red}Error: GROQ_API_KEY not found.${C.reset}`);
|
|
656
|
+
console.error(` Set it via environment variable or in ~/.knoxis/config.json\n`);
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (!checkClaudeInstalled()) {
|
|
661
|
+
console.error(`\n ${C.red}Error: 'claude' CLI not found in PATH.${C.reset}`);
|
|
662
|
+
console.error(` Install Claude Code: npm install -g @anthropic-ai/claude-code\n`);
|
|
663
|
+
process.exit(1);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (!fs.existsSync(WORKSPACE)) {
|
|
667
|
+
console.error(`\n ${C.red}Error: Workspace not found: ${WORKSPACE}${C.reset}\n`);
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Detect project
|
|
672
|
+
projectContext = detectProject();
|
|
673
|
+
|
|
674
|
+
// Print header
|
|
675
|
+
printHeader();
|
|
676
|
+
if (projectContext) {
|
|
677
|
+
console.log(` ${C.dim}${projectContext}${C.reset}\n`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
log(`SESSION START: ${SESSION_ID}`);
|
|
681
|
+
log(`WORKSPACE: ${WORKSPACE}`);
|
|
682
|
+
log(`PROJECT: ${projectContext}`);
|
|
683
|
+
|
|
684
|
+
// Set up readline
|
|
685
|
+
const rl = readline.createInterface({
|
|
686
|
+
input: process.stdin,
|
|
687
|
+
output: process.stdout,
|
|
688
|
+
prompt: ` ${C.green}You:${C.reset} `,
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Handle Ctrl+C — kill Claude if running, otherwise exit
|
|
692
|
+
process.on('SIGINT', () => {
|
|
693
|
+
if (isClaudeRunning && activeClaudeProc) {
|
|
694
|
+
console.log(`\n\n ${C.yellow}Cancelling Claude...${C.reset}\n`);
|
|
695
|
+
try { activeClaudeProc.kill('SIGTERM'); } catch (e) {}
|
|
696
|
+
setTimeout(() => {
|
|
697
|
+
try { if (activeClaudeProc) activeClaudeProc.kill('SIGKILL'); } catch (e) {}
|
|
698
|
+
}, 3000);
|
|
699
|
+
} else {
|
|
700
|
+
log('SESSION END (Ctrl+C)');
|
|
701
|
+
console.log(`\n\n ${C.dim}Session ended. ${claudeDispatches} dispatches to Claude.${C.reset}`);
|
|
702
|
+
console.log(` ${C.dim}Log: ${logFile}${C.reset}\n`);
|
|
703
|
+
process.exit(0);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// Greeting — either with initial task or open-ended
|
|
708
|
+
const initialTask = loadInitialTask();
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
if (initialTask) {
|
|
712
|
+
// Start with a task already loaded
|
|
713
|
+
const greetingContent = `[SYSTEM: Session started. Workspace: ${WORKSPACE}. Project: ${projectContext || 'unknown'}. The user has provided an initial task. Acknowledge it and either ask a clarifying question or dispatch to Claude if it's clear enough.]\n\nTask: ${initialTask}`;
|
|
714
|
+
conversationHistory.push({ role: 'user', content: greetingContent });
|
|
715
|
+
log(`INITIAL TASK: ${initialTask}`);
|
|
716
|
+
|
|
717
|
+
const greeting = await callGroq(conversationHistory, projectContext);
|
|
718
|
+
|
|
719
|
+
if (greeting.action === 'dispatch' && greeting.claudePrompt) {
|
|
720
|
+
printKnoxis(greeting.message);
|
|
721
|
+
conversationHistory.push({
|
|
722
|
+
role: 'assistant',
|
|
723
|
+
content: JSON.stringify({ action: 'dispatch', message: greeting.message })
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
printDivider('Dispatching to Claude Code', C.blue);
|
|
727
|
+
if (!verbose) printClaudeStatus('starting...');
|
|
728
|
+
|
|
729
|
+
const result = await dispatchToClaude(greeting.claudePrompt);
|
|
730
|
+
printDivider(`Claude finished (${result.elapsed}s, ${result.lines} lines)`, C.green);
|
|
731
|
+
|
|
732
|
+
const reviewMessages = [
|
|
733
|
+
...conversationHistory,
|
|
734
|
+
{
|
|
735
|
+
role: 'user',
|
|
736
|
+
content: `[SYSTEM: Claude Code finished (exit ${result.exitCode}, ${result.elapsed}s). Review and summarize.]\n\nClaude output:\n${truncateForReview(result.output)}`
|
|
737
|
+
}
|
|
738
|
+
];
|
|
739
|
+
|
|
740
|
+
const review = await callGroq(reviewMessages, projectContext);
|
|
741
|
+
printKnoxis(review.message);
|
|
742
|
+
dispatchSummaries.push(review.message.split('\n')[0].slice(0, 150));
|
|
743
|
+
conversationHistory.push({
|
|
744
|
+
role: 'assistant',
|
|
745
|
+
content: JSON.stringify({ action: 'respond', message: review.message })
|
|
746
|
+
});
|
|
747
|
+
} else {
|
|
748
|
+
printKnoxis(greeting.message);
|
|
749
|
+
conversationHistory.push({
|
|
750
|
+
role: 'assistant',
|
|
751
|
+
content: JSON.stringify(greeting)
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
} else {
|
|
756
|
+
// Open-ended greeting
|
|
757
|
+
conversationHistory.push({
|
|
758
|
+
role: 'user',
|
|
759
|
+
content: `[SYSTEM: Collaborative session started. Workspace: ${WORKSPACE}. Project: ${projectContext || 'unknown'}. Greet the user briefly (1-2 sentences) and ask what they want to work on.]`
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const greeting = await callGroq(conversationHistory, projectContext);
|
|
763
|
+
printKnoxis(greeting.message);
|
|
764
|
+
conversationHistory.push({
|
|
765
|
+
role: 'assistant',
|
|
766
|
+
content: JSON.stringify(greeting)
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
} catch (e) {
|
|
770
|
+
printKnoxis("Hey, I'm ready. What are we working on?");
|
|
771
|
+
conversationHistory.push({
|
|
772
|
+
role: 'assistant',
|
|
773
|
+
content: JSON.stringify({ action: 'respond', message: "Hey, I'm ready. What are we working on?" })
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Main loop
|
|
778
|
+
rl.prompt();
|
|
779
|
+
|
|
780
|
+
rl.on('line', async (line) => {
|
|
781
|
+
await handleUserInput(line, rl);
|
|
782
|
+
rl.prompt();
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
rl.on('close', () => {
|
|
786
|
+
log('SESSION END (stdin closed)');
|
|
787
|
+
console.log(`\n ${C.dim}Session ended. Log: ${logFile}${C.reset}\n`);
|
|
788
|
+
process.exit(0);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
main().catch((err) => {
|
|
793
|
+
console.error(`\n ${C.red}Fatal: ${err.message}${C.reset}\n`);
|
|
794
|
+
log(`FATAL: ${err.message}`);
|
|
795
|
+
process.exit(1);
|
|
796
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "knoxis-collab",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Three-way collaborative programming — User + Knoxis + Claude Code in a persistent session",
|
|
5
|
+
"main": "knoxis-collab.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"knoxis-collab": "./knoxis-collab.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"knoxis",
|
|
11
|
+
"pair-programming",
|
|
12
|
+
"collaboration",
|
|
13
|
+
"claude",
|
|
14
|
+
"qig"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"knoxis-collab.js"
|
|
22
|
+
]
|
|
23
|
+
}
|