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.
Files changed (2) hide show
  1. package/knoxis-collab.js +796 -0
  2. package/package.json +23 -0
@@ -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
+ }