dual-brain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +97 -0
  2. package/CLAUDE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +197 -0
  5. package/agents/implementer.md +22 -0
  6. package/agents/researcher.md +25 -0
  7. package/agents/verifier.md +30 -0
  8. package/bin/dual-brain.mjs +2868 -0
  9. package/hooks/auto-update-wrapper.mjs +102 -0
  10. package/hooks/auto-update.sh +67 -0
  11. package/hooks/budget-balancer.mjs +679 -0
  12. package/hooks/control-panel.mjs +1195 -0
  13. package/hooks/cost-logger.mjs +286 -0
  14. package/hooks/cost-report.mjs +351 -0
  15. package/hooks/decision-ledger.mjs +299 -0
  16. package/hooks/dual-brain-review.mjs +404 -0
  17. package/hooks/dual-brain-think.mjs +393 -0
  18. package/hooks/enforce-tier.mjs +469 -0
  19. package/hooks/failure-detector.mjs +138 -0
  20. package/hooks/gpt-work-dispatcher.mjs +512 -0
  21. package/hooks/head-guard.mjs +105 -0
  22. package/hooks/health-check.mjs +444 -0
  23. package/hooks/install-git-hooks.mjs +106 -0
  24. package/hooks/model-registry.mjs +859 -0
  25. package/hooks/plan-generator.mjs +544 -0
  26. package/hooks/profiles.mjs +254 -0
  27. package/hooks/quality-gate.mjs +355 -0
  28. package/hooks/risk-classifier.mjs +41 -0
  29. package/hooks/session-report.mjs +514 -0
  30. package/hooks/setup-wizard.mjs +130 -0
  31. package/hooks/summary-checkpoint.mjs +432 -0
  32. package/hooks/task-classifier.mjs +328 -0
  33. package/hooks/test-orchestrator.mjs +1077 -0
  34. package/hooks/vibe-memory.mjs +463 -0
  35. package/hooks/vibe-router.mjs +387 -0
  36. package/hooks/wave-orchestrator.mjs +1397 -0
  37. package/install.mjs +1541 -0
  38. package/mcp-server/README.md +81 -0
  39. package/mcp-server/index.mjs +388 -0
  40. package/orchestrator.json +215 -0
  41. package/package.json +108 -0
  42. package/playbooks/debug.json +49 -0
  43. package/playbooks/refactor.json +57 -0
  44. package/playbooks/security-audit.json +57 -0
  45. package/playbooks/security.json +38 -0
  46. package/playbooks/test-gen.json +48 -0
  47. package/plugin.json +22 -0
  48. package/review-rules.md +17 -0
  49. package/shell-hook.sh +26 -0
  50. package/skills/go.md +22 -0
  51. package/skills/review.md +19 -0
  52. package/skills/status.md +13 -0
  53. package/skills/think.md +22 -0
  54. package/src/brief.mjs +266 -0
  55. package/src/decide.mjs +635 -0
  56. package/src/decompose.mjs +331 -0
  57. package/src/detect.mjs +345 -0
  58. package/src/dispatch.mjs +942 -0
  59. package/src/health.mjs +253 -0
  60. package/src/index.mjs +44 -0
  61. package/src/install-hooks.mjs +100 -0
  62. package/src/playbook.mjs +257 -0
  63. package/src/profile.mjs +990 -0
  64. package/src/redact.mjs +192 -0
  65. package/src/repo.mjs +292 -0
  66. package/src/session.mjs +1036 -0
  67. package/src/tui.mjs +197 -0
  68. package/src/update-check.mjs +35 -0
@@ -0,0 +1,1036 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session.mjs — Persist task state between terminal sessions.
4
+ *
5
+ * Exports:
6
+ * loadSession(cwd) → session state or null (if stale/missing)
7
+ * saveSession(state, cwd) → write session atomically
8
+ * updateSession(patch, cwd) → merge partial update into existing session
9
+ * clearSession(cwd) → delete session file
10
+ * formatSessionCard(session, repo, health) → compact status card string (≤5 lines)
11
+ */
12
+
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, renameSync, readdirSync, statSync, copyFileSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+
16
+ // ─── Constants ────────────────────────────────────────────────────────────────
17
+
18
+ const SESSION_FILE = '.dualbrain/session.json';
19
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
20
+
21
+ // ─── File I/O ─────────────────────────────────────────────────────────────────
22
+
23
+ function sessionPath(cwd) {
24
+ return join(cwd ?? process.cwd(), SESSION_FILE);
25
+ }
26
+
27
+ function ensureDir(cwd) {
28
+ mkdirSync(join(cwd ?? process.cwd(), '.dualbrain'), { recursive: true });
29
+ }
30
+
31
+ // ─── Schema defaults ──────────────────────────────────────────────────────────
32
+
33
+ function defaultSession() {
34
+ const now = new Date().toISOString();
35
+ return {
36
+ startedAt: now,
37
+ updatedAt: now,
38
+ objective: null,
39
+ branch: null,
40
+ filesChanged: [],
41
+ commandsRun: [],
42
+ lastResult: null,
43
+ provider: null,
44
+ nextAction: null,
45
+ };
46
+ }
47
+
48
+ // ─── Exports ──────────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Load the session file. Returns null if missing or older than 24 hours.
52
+ * @param {string} [cwd]
53
+ * @returns {object|null}
54
+ */
55
+ export function loadSession(cwd = process.cwd()) {
56
+ const p = sessionPath(cwd);
57
+ if (!existsSync(p)) return null;
58
+ try {
59
+ const data = JSON.parse(readFileSync(p, 'utf8'));
60
+ const age = Date.now() - Date.parse(data.updatedAt || data.startedAt || 0);
61
+ if (age > SESSION_TTL_MS) return null;
62
+ return data;
63
+ } catch { return null; }
64
+ }
65
+
66
+ /**
67
+ * Write session state atomically (tmp + rename).
68
+ * @param {object} state
69
+ * @param {string} [cwd]
70
+ */
71
+ export function saveSession(state, cwd = process.cwd()) {
72
+ ensureDir(cwd);
73
+ const p = sessionPath(cwd);
74
+ const tmp = p + '.tmp.' + process.pid;
75
+ const data = {
76
+ ...defaultSession(),
77
+ ...state,
78
+ updatedAt: new Date().toISOString(),
79
+ };
80
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
81
+ renameSync(tmp, p);
82
+ return data;
83
+ }
84
+
85
+ /**
86
+ * Merge a partial update into the existing session (or create a new one).
87
+ * @param {object} patch
88
+ * @param {string} [cwd]
89
+ */
90
+ export function updateSession(patch, cwd = process.cwd()) {
91
+ const existing = loadSession(cwd) || defaultSession();
92
+ const updated = { ...existing, ...patch };
93
+
94
+ // Arrays: append, don't replace
95
+ if (patch.filesChanged) {
96
+ const combined = [...(existing.filesChanged || []), ...(patch.filesChanged || [])];
97
+ updated.filesChanged = [...new Set(combined)]; // deduplicate
98
+ }
99
+ if (patch.commandsRun) {
100
+ updated.commandsRun = [...(existing.commandsRun || []), ...(patch.commandsRun || [])];
101
+ }
102
+
103
+ return saveSession(updated, cwd);
104
+ }
105
+
106
+ /**
107
+ * Delete the session file.
108
+ * @param {string} [cwd]
109
+ */
110
+ export function clearSession(cwd = process.cwd()) {
111
+ const p = sessionPath(cwd);
112
+ if (existsSync(p)) {
113
+ try { unlinkSync(p); } catch { /* non-fatal */ }
114
+ }
115
+ }
116
+
117
+ // ─── Session card formatting ──────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Format a compact status card (≤5 lines) for display when running `dual-brain`.
121
+ *
122
+ * @param {object|null} session — from loadSession()
123
+ * @param {object} repo — from detectRepo() / loadRepoCache()
124
+ * @param {object} health — from getHealth() (shape: { states: {}, session: {} })
125
+ * @param {object} [profile] — optional profile for enabled-state checks
126
+ * @returns {string}
127
+ */
128
+ export function formatSessionCard(session, repo, health, profile) {
129
+ const lines = [];
130
+
131
+ // Line 1: Repo identity
132
+ const repoParts = [];
133
+ if (repo.name) repoParts.push(repo.name);
134
+ if (repo.type !== 'unknown') {
135
+ const typeLabel = repo.type.charAt(0).toUpperCase() + repo.type.slice(1);
136
+ repoParts.push(typeLabel);
137
+ }
138
+ if (repo.packageManager) repoParts.push(repo.packageManager);
139
+
140
+ // Detect test runner label (Vitest, Jest, pytest, etc.)
141
+ const testCmd = repo.commands?.test || '';
142
+ let testLabel = null;
143
+ if (testCmd.includes('vitest')) testLabel = 'Vitest';
144
+ else if (testCmd.includes('jest')) testLabel = 'Jest';
145
+ else if (testCmd.includes('mocha')) testLabel = 'Mocha';
146
+ else if (testCmd.includes('pytest')) testLabel = 'Pytest';
147
+ else if (testCmd.includes('rspec')) testLabel = 'RSpec';
148
+ else if (testCmd.includes('go test')) testLabel = 'go test';
149
+ else if (testCmd.includes('cargo test')) testLabel = 'cargo test';
150
+ if (testLabel) repoParts.push(testLabel);
151
+
152
+ lines.push(`dual-brain ready`);
153
+ lines.push(`Repo: ${repoParts.join(' / ') || 'unknown'}`);
154
+
155
+ // Line 3: Branch + dirty status
156
+ if (repo.branch) {
157
+ const dirtyNote = repo.dirty ? ` (uncommitted changes)` : '';
158
+ lines.push(`Branch: ${repo.branch}${dirtyNote}`);
159
+ }
160
+
161
+ // Line 4: Health summary — only show enabled providers
162
+ const { states = {} } = health || {};
163
+ const claudeProviderEnabled = profile?.providers?.claude?.enabled !== false;
164
+ const openaiProviderEnabled = profile?.providers?.openai?.enabled !== false;
165
+
166
+ function providerStatus(name) {
167
+ const entries = Object.entries(states).filter(([k]) => k.startsWith(`${name}:`));
168
+ if (entries.length === 0) return 'healthy';
169
+ const statuses = entries.map(([, v]) => v.status);
170
+ if (statuses.includes('hot')) return 'hot';
171
+ if (statuses.includes('degraded')) return 'degraded';
172
+ if (statuses.includes('probing')) return 'probing';
173
+ return 'healthy';
174
+ }
175
+
176
+ const healthParts = [];
177
+ if (claudeProviderEnabled) {
178
+ const claudeStatus = providerStatus('claude');
179
+ healthParts.push(claudeStatus === 'healthy' ? 'Claude healthy' : `Claude ${claudeStatus}`);
180
+ } else {
181
+ healthParts.push('Claude disabled');
182
+ }
183
+ if (openaiProviderEnabled) {
184
+ const openaiStatus = providerStatus('openai');
185
+ healthParts.push(openaiStatus === 'healthy' ? 'OpenAI healthy' : `OpenAI ${openaiStatus}`);
186
+ } else {
187
+ healthParts.push('OpenAI disabled');
188
+ }
189
+ lines.push(`Health: ${healthParts.join(', ')}`);
190
+
191
+ // Line 5: Last task summary (only if session exists)
192
+ if (session) {
193
+ const parts = [];
194
+ if (session.objective) parts.push(session.objective);
195
+ if (session.filesChanged?.length) {
196
+ const fc = session.filesChanged.length;
197
+ parts.push(`edited ${fc} file${fc !== 1 ? 's' : ''}`);
198
+ }
199
+ if (session.lastResult?.status === 'failure' && session.lastResult?.summary) {
200
+ parts.push(session.lastResult.summary);
201
+ } else if (session.lastResult?.summary) {
202
+ // include brief result note if compact
203
+ const summary = session.lastResult.summary;
204
+ if (summary.length <= 40) parts.push(summary);
205
+ }
206
+ if (parts.length > 0) {
207
+ lines.push(`Last: ${parts.join(', ')}`);
208
+ }
209
+ }
210
+
211
+ // Tip line: always show a call-to-action so non-TTY output is actionable
212
+ lines.push(`Tip: run "dual-brain --help" or "dual-brain go \\"task\\""`);
213
+
214
+ return lines.join('\n');
215
+ }
216
+
217
+ // ─── Replit-tools session import ──────────────────────────────────────────────
218
+
219
+ /**
220
+ * Returns true if the text looks like a real user prompt (not a status line,
221
+ * slash command, paste marker, or agent-generated noise).
222
+ * @param {string} text
223
+ * @returns {boolean}
224
+ */
225
+ function isRealPrompt(text) {
226
+ if (!text || !text.trim()) return false;
227
+ const t = text.trim();
228
+ if (/^[✅❌📦🔗⚠️🚀🎉🔧📝]/.test(t)) return false;
229
+ if (/Claude (history|binary|versions) symlink/.test(t)) return false;
230
+ if (t.startsWith('# AGENTS.md')) return false;
231
+ if (t === 'login' || t === 'logout') return false;
232
+ if (t.startsWith('/')) return false;
233
+ if (t.startsWith('[Pasted')) return false;
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Human-readable time-ago string from a Unix timestamp (ms).
239
+ * @param {number} timestamp
240
+ * @returns {string}
241
+ */
242
+ function timeAgo(timestamp) {
243
+ const diff = Date.now() - timestamp;
244
+ const mins = Math.floor(diff / 60000);
245
+ if (mins < 1) return 'just now';
246
+ if (mins < 60) return `${mins}m ago`;
247
+ const hours = Math.floor(mins / 60);
248
+ if (hours < 24) return `${hours}h ago`;
249
+ const days = Math.floor(hours / 24);
250
+ return `${days}d ago`;
251
+ }
252
+
253
+ /**
254
+ * Import sessions from replit-tools history.jsonl.
255
+ * Returns an array of session summary objects, sorted most-recent first.
256
+ * Returns [] gracefully if replit-tools is not present.
257
+ *
258
+ * @param {string} cwd
259
+ * @returns {Array<{
260
+ * id: string, name: string, project: string,
261
+ * promptCount: number, lastActive: string,
262
+ * isActive: boolean, source: string, age: string
263
+ * }>}
264
+ */
265
+ export function importReplitSessions(cwd = process.cwd()) {
266
+ const sessions = [];
267
+
268
+ // Check multiple possible locations for replit-tools
269
+ const candidates = [
270
+ join(cwd, '.replit-tools', '.claude-persistent'),
271
+ join('/home/runner/workspace', '.replit-tools', '.claude-persistent'),
272
+ ];
273
+ // Deduplicate
274
+ const seen = new Set();
275
+ const replitBases = candidates.filter(p => {
276
+ const norm = p.replace(/\/+$/, '');
277
+ if (seen.has(norm)) return false;
278
+ seen.add(norm);
279
+ return true;
280
+ });
281
+
282
+ let replitBase = null;
283
+ for (const candidate of replitBases) {
284
+ if (existsSync(join(candidate, 'history.jsonl'))) {
285
+ replitBase = candidate;
286
+ break;
287
+ }
288
+ }
289
+ if (!replitBase) return sessions;
290
+
291
+ // Read history.jsonl
292
+ const historyPath = join(replitBase, 'history.jsonl');
293
+
294
+ let lines;
295
+ try {
296
+ lines = readFileSync(historyPath, 'utf8').split('\n').filter(Boolean);
297
+ } catch { return sessions; }
298
+
299
+ const bySession = new Map(); // sessionId → { entries, firstPrompt, lastTimestamp }
300
+
301
+ for (const line of lines) {
302
+ try {
303
+ const entry = JSON.parse(line);
304
+ if (!entry.sessionId) continue;
305
+
306
+ if (!bySession.has(entry.sessionId)) {
307
+ bySession.set(entry.sessionId, {
308
+ sessionId: entry.sessionId,
309
+ project: entry.project,
310
+ entries: [],
311
+ firstPrompt: null,
312
+ lastTimestamp: 0,
313
+ });
314
+ }
315
+
316
+ const sess = bySession.get(entry.sessionId);
317
+ sess.entries.push(entry);
318
+ if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
319
+
320
+ // Find first meaningful user prompt
321
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
322
+ sess.firstPrompt = entry.display;
323
+ }
324
+ } catch { continue; }
325
+ }
326
+
327
+ // Also read from the session archive as a fallback (contains cleaned-up sessions)
328
+ const archivePath = join(cwd, '.replit-tools', '.session-archive', 'claude', 'history.jsonl');
329
+ let archiveLines = [];
330
+ try {
331
+ if (existsSync(archivePath)) {
332
+ archiveLines = readFileSync(archivePath, 'utf8').split('\n').filter(Boolean);
333
+ }
334
+ } catch { /* non-fatal */ }
335
+
336
+ for (const line of archiveLines) {
337
+ try {
338
+ const entry = JSON.parse(line);
339
+ if (!entry.sessionId) continue;
340
+ if (bySession.has(entry.sessionId)) continue; // already indexed from main history
341
+
342
+ bySession.set(entry.sessionId, {
343
+ sessionId: entry.sessionId,
344
+ project: entry.project,
345
+ entries: [],
346
+ firstPrompt: null,
347
+ lastTimestamp: 0,
348
+ });
349
+
350
+ const sess = bySession.get(entry.sessionId);
351
+ sess.entries.push(entry);
352
+ if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
353
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
354
+ sess.firstPrompt = entry.display;
355
+ }
356
+ } catch { continue; }
357
+ }
358
+
359
+ // For archive sessions with multiple entries, finish accumulating them
360
+ // (second pass for sessions newly added from archive)
361
+ for (const line of archiveLines) {
362
+ try {
363
+ const entry = JSON.parse(line);
364
+ if (!entry.sessionId) continue;
365
+ const sess = bySession.get(entry.sessionId);
366
+ if (!sess) continue;
367
+ // Already pushed in first pass for new sessions; skip double-push
368
+ if (sess.entries.includes(entry)) continue;
369
+ sess.entries.push(entry);
370
+ if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
371
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
372
+ sess.firstPrompt = entry.display;
373
+ }
374
+ } catch { continue; }
375
+ }
376
+
377
+ // Scan ~/.codex/sessions/ for codex session JSONLs (YYYY/MM/DD tree)
378
+ const codexSessionsDir = join(process.env.HOME || '/root', '.codex', 'sessions');
379
+ if (existsSync(codexSessionsDir)) {
380
+ try {
381
+ const walk = (dir) => {
382
+ let results = [];
383
+ try {
384
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
385
+ const full = join(dir, entry.name);
386
+ if (entry.isDirectory()) results = results.concat(walk(full));
387
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(full);
388
+ }
389
+ } catch {}
390
+ return results;
391
+ };
392
+
393
+ for (const f of walk(codexSessionsDir)) {
394
+ try {
395
+ const content = readFileSync(f, 'utf8');
396
+ const lines = content.split('\n').filter(Boolean);
397
+ if (!lines.length) continue;
398
+
399
+ const meta = JSON.parse(lines[0]);
400
+ if (meta.type !== 'session_meta' || !meta.payload) continue;
401
+ if (meta.payload.cwd !== cwd && meta.payload.cwd !== '/home/runner/workspace') continue;
402
+
403
+ const id = meta.payload.id;
404
+ if (bySession.has(id)) continue;
405
+
406
+ let firstPrompt = null;
407
+ let lastTimestamp = Date.parse(meta.payload.timestamp || meta.timestamp) / 1000;
408
+
409
+ for (const ln of lines) {
410
+ try {
411
+ const j = JSON.parse(ln);
412
+ if (j.timestamp) {
413
+ const ts = Date.parse(j.timestamp) / 1000;
414
+ if (ts > lastTimestamp) lastTimestamp = ts;
415
+ }
416
+ if (!firstPrompt && j.type === 'event_msg' && j.payload?.type === 'user_message') {
417
+ const text = (j.payload.message || '').trim();
418
+ if (text) firstPrompt = text;
419
+ }
420
+ } catch { continue; }
421
+ }
422
+
423
+ bySession.set(id, {
424
+ sessionId: id,
425
+ project: '-home-runner-workspace',
426
+ entries: [],
427
+ firstPrompt: firstPrompt || id.slice(0, 8) + '...',
428
+ lastTimestamp,
429
+ tool: 'codex',
430
+ });
431
+ } catch { continue; }
432
+ }
433
+ } catch { /* non-fatal */ }
434
+ }
435
+
436
+ // Read active terminal sessions
437
+ // Use the same root as replitBase (go up one level from .claude-persistent)
438
+ const replitRoot = join(replitBase, '..');
439
+ const sessionsDir = join(replitRoot, '..', '.claude-sessions');
440
+ const activeSessionIds = new Set();
441
+ if (existsSync(sessionsDir)) {
442
+ try {
443
+ for (const f of readdirSync(sessionsDir)) {
444
+ try {
445
+ const data = JSON.parse(readFileSync(join(sessionsDir, f), 'utf8'));
446
+ if (data.sessionId) activeSessionIds.add(data.sessionId);
447
+ } catch { continue; }
448
+ }
449
+ } catch { /* non-fatal */ }
450
+ }
451
+
452
+ // Determine recency window from config (default 48 hours)
453
+ const configPath = join(cwd, '.replit-tools', 'config.json');
454
+ let windowHours = 48;
455
+ try {
456
+ if (existsSync(configPath)) {
457
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
458
+ windowHours = cfg.recentWindowHours || 48;
459
+ }
460
+ } catch { /* non-fatal */ }
461
+ const windowMs = windowHours * 60 * 60 * 1000;
462
+ const cutoff = Date.now() - windowMs;
463
+
464
+ // Build session list
465
+ for (const [id, sess] of bySession) {
466
+ // Skip sessions outside the recency window (timestamps are in ms)
467
+ if (sess.lastTimestamp < cutoff) continue;
468
+ // Derive display name
469
+ let name = sess.firstPrompt;
470
+ if (!name) {
471
+ // Fallback: use first non-login display
472
+ const firstReal = sess.entries.find(e => e.display && e.display !== 'login');
473
+ name = firstReal?.display || `Session ${id.slice(0, 8)}`;
474
+ }
475
+ // Truncate long names
476
+ if (name.length > 60) name = name.slice(0, 57) + '...';
477
+
478
+ sessions.push({
479
+ id: sess.sessionId,
480
+ name,
481
+ project: sess.project,
482
+ promptCount: sess.entries.length,
483
+ lastActive: new Date(sess.lastTimestamp).toISOString(),
484
+ isActive: activeSessionIds.has(id),
485
+ source: 'replit-tools',
486
+ age: timeAgo(sess.lastTimestamp),
487
+ tool: sess.tool || 'claude',
488
+ });
489
+ }
490
+
491
+ // Sort by most recent first
492
+ sessions.sort((a, b) => new Date(b.lastActive) - new Date(a.lastActive));
493
+
494
+ return sessions;
495
+ }
496
+
497
+ // ─── Session metadata overlay ─────────────────────────────────────────────────
498
+
499
+ const SESSION_META_FILE = '.dualbrain/sessions.json';
500
+
501
+ function sessionMetaPath(cwd) {
502
+ return join(cwd ?? process.cwd(), SESSION_META_FILE);
503
+ }
504
+
505
+ export function getSessionMeta(cwd = process.cwd()) {
506
+ const p = sessionMetaPath(cwd);
507
+ if (!existsSync(p)) return {};
508
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; }
509
+ }
510
+
511
+ function saveSessionMeta(meta, cwd = process.cwd()) {
512
+ ensureDir(cwd);
513
+ const p = sessionMetaPath(cwd);
514
+ const tmp = p + '.tmp.' + process.pid;
515
+ writeFileSync(tmp, JSON.stringify(meta, null, 2) + '\n');
516
+ renameSync(tmp, p);
517
+ }
518
+
519
+ export function renameSession(sessionId, name, cwd = process.cwd()) {
520
+ const meta = getSessionMeta(cwd);
521
+ meta[sessionId] = { ...meta[sessionId], name, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
522
+ saveSessionMeta(meta, cwd);
523
+ }
524
+
525
+ export function pinSession(sessionId, cwd = process.cwd()) {
526
+ const meta = getSessionMeta(cwd);
527
+ meta[sessionId] = { ...meta[sessionId], pinned: true, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
528
+ saveSessionMeta(meta, cwd);
529
+ }
530
+
531
+ export function unpinSession(sessionId, cwd = process.cwd()) {
532
+ const meta = getSessionMeta(cwd);
533
+ meta[sessionId] = { ...meta[sessionId], pinned: false };
534
+ saveSessionMeta(meta, cwd);
535
+ }
536
+
537
+ export function categorizeSession(sessionId, category, cwd = process.cwd()) {
538
+ const meta = getSessionMeta(cwd);
539
+ meta[sessionId] = { ...meta[sessionId], category, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
540
+ saveSessionMeta(meta, cwd);
541
+ }
542
+
543
+ const AUTO_LABEL_RULES = [
544
+ { keywords: ['auth', 'login', 'credential', 'security', 'token'], label: 'security' },
545
+ { keywords: ['ui', 'css', 'style', 'component', 'react', 'frontend'], label: 'ui' },
546
+ { keywords: ['refactor', 'cleanup', 'rename', 'reorganize'], label: 'refactor' },
547
+ { keywords: ['bug', 'fix', 'error', 'crash', 'broken'], label: 'bugfix' },
548
+ { keywords: ['test', 'spec', 'coverage'], label: 'testing' },
549
+ { keywords: ['deploy', 'ci', 'build', 'release'], label: 'devops' },
550
+ { keywords: ['plan', 'design', 'architect', 'brainstorm'], label: 'planning' },
551
+ ];
552
+
553
+ export function autoLabel(session) {
554
+ const text = (session.name || '').toLowerCase();
555
+ for (const { keywords, label } of AUTO_LABEL_RULES) {
556
+ if (keywords.some(kw => new RegExp(`\\b${kw}\\b`).test(text))) return label;
557
+ }
558
+ return null;
559
+ }
560
+
561
+ export function enrichSessions(sessions, cwd = process.cwd()) {
562
+ const meta = getSessionMeta(cwd);
563
+ const enriched = sessions.map(sess => {
564
+ const overlay = meta[sess.id] ?? {};
565
+ const category = overlay.category ?? autoLabel({ ...sess, name: overlay.name ?? sess.name });
566
+ return {
567
+ ...sess,
568
+ name: overlay.name ?? sess.name,
569
+ pinned: overlay.pinned ?? false,
570
+ category: category ?? null,
571
+ };
572
+ });
573
+ enriched.sort((a, b) => {
574
+ if (a.pinned && !b.pinned) return -1;
575
+ if (!a.pinned && b.pinned) return 1;
576
+ return new Date(b.lastActive) - new Date(a.lastActive);
577
+ });
578
+ return enriched;
579
+ }
580
+
581
+ // ─── Persistence settings ─────────────────────────────────────────────────────
582
+
583
+ /**
584
+ * Ensure Claude and Codex are configured to retain session history indefinitely.
585
+ * Mirrors what replit-tools does to prevent session cleanup/deletion.
586
+ *
587
+ * @param {string} [cwd]
588
+ * @returns {string[]} List of changes made (empty if already configured)
589
+ */
590
+ export function ensurePersistence(cwd = process.cwd()) {
591
+ const home = process.env.HOME || '/root';
592
+ const results = [];
593
+
594
+ // 1. Claude: set cleanupPeriodDays
595
+ const claudeSettingsPaths = [
596
+ join(home, '.claude', 'settings.json'),
597
+ join(cwd, '.replit-tools', '.claude-persistent', 'settings.json'),
598
+ ];
599
+
600
+ for (const settingsPath of claudeSettingsPaths) {
601
+ if (!existsSync(settingsPath)) continue;
602
+ try {
603
+ let settings = {};
604
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
605
+ if (settings.cleanupPeriodDays !== 365250) {
606
+ settings.cleanupPeriodDays = 365250;
607
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
608
+ results.push('Claude cleanupPeriodDays set to 365250');
609
+ }
610
+ break; // only update one
611
+ } catch { continue; }
612
+ }
613
+
614
+ // 2. Codex: set history.persistence and max_bytes
615
+ const codexConfigPaths = [
616
+ join(home, '.codex', 'config.toml'),
617
+ join(cwd, '.replit-tools', '.codex-persistent', 'config.toml'),
618
+ ];
619
+
620
+ for (const configPath of codexConfigPaths) {
621
+ if (!existsSync(configPath)) continue;
622
+ try {
623
+ let content = readFileSync(configPath, 'utf8');
624
+ let changed = false;
625
+
626
+ if (!/\[history\]/.test(content)) {
627
+ content = content.trimEnd() + '\n\n[history]\npersistence = "save-all"\nmax_bytes = 104857600\n';
628
+ changed = true;
629
+ } else {
630
+ if (!/persistence\s*=/.test(content)) {
631
+ content = content.replace(/\[history\](\s*)/, '[history]$1persistence = "save-all"\n');
632
+ changed = true;
633
+ }
634
+ if (!/max_bytes\s*=/.test(content)) {
635
+ content = content.replace(/(persistence\s*=\s*"[^"]*"\s*\n)/, '$1max_bytes = 104857600\n');
636
+ changed = true;
637
+ }
638
+ }
639
+
640
+ if (changed) {
641
+ writeFileSync(configPath, content);
642
+ results.push('Codex history persistence enabled');
643
+ }
644
+ break;
645
+ } catch { continue; }
646
+ }
647
+
648
+ return results;
649
+ }
650
+
651
+ // ─── Session archive mirror sync ─────────────────────────────────────────────
652
+
653
+ /**
654
+ * Append-only mirror sync for Claude/Codex sessions (matches what replit-tools does).
655
+ * Files in the mirror only grow — if the source deletes a session, the mirror still has it.
656
+ *
657
+ * @param {string} [cwd]
658
+ * @returns {{ copied: number, grew: number, disabled?: boolean }}
659
+ */
660
+ export function syncSessionMirror(cwd = process.cwd()) {
661
+ const home = process.env.HOME || '/root';
662
+ const mirrorBase = join(cwd, '.replit-tools', '.session-archive');
663
+
664
+ // Check if replit-tools exists
665
+ if (!existsSync(join(cwd, '.replit-tools'))) return { copied: 0, grew: 0 };
666
+
667
+ // Check config — mirror can be disabled
668
+ const configPath = join(cwd, '.replit-tools', 'config.json');
669
+ try {
670
+ if (existsSync(configPath)) {
671
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
672
+ if (cfg.mirror && cfg.mirror.enabled === false) return { copied: 0, grew: 0, disabled: true };
673
+ }
674
+ } catch {}
675
+
676
+ let totalCopied = 0, totalGrew = 0;
677
+
678
+ function syncTree(srcDir, destDir) {
679
+ if (!existsSync(srcDir)) return;
680
+
681
+ function walk(dir) {
682
+ let entries;
683
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
684
+
685
+ for (const entry of entries) {
686
+ const srcPath = join(dir, entry.name);
687
+ const relPath = srcPath.slice(srcDir.length);
688
+ const destPath = join(destDir, relPath);
689
+
690
+ if (entry.isDirectory()) {
691
+ try { mkdirSync(destPath, { recursive: true }); } catch {}
692
+ walk(srcPath);
693
+ } else if (entry.isFile()) {
694
+ let destSize = 0;
695
+ try { destSize = statSync(destPath).size; } catch {}
696
+
697
+ let srcSize = 0;
698
+ try { srcSize = statSync(srcPath).size; } catch { continue; }
699
+
700
+ // Append-only: only copy if source is larger than mirror
701
+ if (srcSize > destSize) {
702
+ try {
703
+ mkdirSync(dirname(destPath), { recursive: true });
704
+ copyFileSync(srcPath, destPath);
705
+ if (destSize === 0) totalCopied++;
706
+ else totalGrew++;
707
+ } catch {}
708
+ }
709
+ }
710
+ }
711
+ }
712
+
713
+ walk(srcDir);
714
+ }
715
+
716
+ try { mkdirSync(mirrorBase, { recursive: true }); } catch {}
717
+
718
+ // Sync Claude sessions
719
+ const claudeDir = join(home, '.claude');
720
+ syncTree(join(claudeDir, 'projects'), join(mirrorBase, 'claude', 'projects'));
721
+ // Sync history.jsonl as a single file
722
+ const histSrc = join(claudeDir, 'history.jsonl');
723
+ const histDest = join(mirrorBase, 'claude', 'history.jsonl');
724
+ if (existsSync(histSrc)) {
725
+ try {
726
+ const srcSize = statSync(histSrc).size;
727
+ let destSize = 0;
728
+ try { destSize = statSync(histDest).size; } catch {}
729
+ if (srcSize > destSize) {
730
+ mkdirSync(dirname(histDest), { recursive: true });
731
+ copyFileSync(histSrc, histDest);
732
+ if (destSize === 0) totalCopied++; else totalGrew++;
733
+ }
734
+ } catch {}
735
+ }
736
+
737
+ // Sync Codex sessions
738
+ const codexDir = join(home, '.codex');
739
+ syncTree(join(codexDir, 'sessions'), join(mirrorBase, 'codex', 'sessions'));
740
+
741
+ return { copied: totalCopied, grew: totalGrew };
742
+ }
743
+
744
+ // ─── Session index ────────────────────────────────────────────────────────────
745
+
746
+ /**
747
+ * Build/update `.dualbrain/session-index.json` from Claude and Codex JSONL session files.
748
+ * Extracts topics, file references, prompt snippets, and metadata per session.
749
+ *
750
+ * @param {string} [cwd]
751
+ * @returns {object} index — keyed by session UUID
752
+ */
753
+ export function buildSessionIndex(cwd = process.cwd()) {
754
+ const home = process.env.HOME || '/root';
755
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
756
+
757
+ // Load existing index
758
+ let index = {};
759
+ try {
760
+ if (existsSync(indexPath)) {
761
+ index = JSON.parse(readFileSync(indexPath, 'utf8'));
762
+ }
763
+ } catch {}
764
+
765
+ // Find all session JSONLs
766
+ const sources = [
767
+ join(home, '.claude', 'projects', '-home-runner-workspace'),
768
+ join(cwd, '.replit-tools', '.session-archive', 'claude', 'projects', '-home-runner-workspace'),
769
+ ];
770
+
771
+ const STOP_WORDS = new Set(['the','and','this','that','with','from','have','been','will','would','could','should','just','also','into','about','some','what','when','where','which','their','there','then','than','them','these','those','other','more','only','very','each','most','like','make','want','need','does','dont','didnt','cant','wont','your','they','were','are','for','not','but','was','you','all','can','had','her','one','our','out','use','its','let','get','has','him','his','how','did','got','may','new','now','old','see','way','who','any','few','said']);
772
+
773
+ for (const dir of sources) {
774
+ if (!existsSync(dir)) continue;
775
+ let files;
776
+ try { files = readdirSync(dir); } catch { continue; }
777
+
778
+ for (const f of files) {
779
+ if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue;
780
+ const sessionId = f.replace('.jsonl', '');
781
+
782
+ // Skip if already indexed and file hasn't grown
783
+ const filePath = join(dir, f);
784
+ let fileSize = 0;
785
+ try { fileSize = statSync(filePath).size; } catch { continue; }
786
+ if (index[sessionId] && index[sessionId]._fileSize >= fileSize) continue;
787
+
788
+ // Parse session
789
+ try {
790
+ const content = readFileSync(filePath, 'utf8');
791
+ const lines = content.split('\n').filter(Boolean);
792
+
793
+ const wordCounts = {};
794
+ const fileSet = new Set();
795
+ let firstPrompt = null;
796
+ let lastPrompt = null;
797
+ let lastTimestamp = 0;
798
+ let messageCount = 0;
799
+
800
+ for (const line of lines) {
801
+ try {
802
+ const entry = JSON.parse(line);
803
+
804
+ // Track timestamps
805
+ if (entry.timestamp) {
806
+ const raw = typeof entry.timestamp === 'number' ? entry.timestamp : Date.parse(entry.timestamp);
807
+ const ts = raw > 1e12 ? raw / 1000 : raw;
808
+ if (ts > lastTimestamp) lastTimestamp = ts;
809
+ }
810
+
811
+ // Extract user messages
812
+ let text = null;
813
+ if (entry.type === 'user' && entry.message?.content) {
814
+ text = typeof entry.message.content === 'string'
815
+ ? entry.message.content
816
+ : entry.message.content?.[0]?.text;
817
+ }
818
+ if (entry.display) text = text || entry.display;
819
+
820
+ if (!text) continue;
821
+ messageCount++;
822
+
823
+ if (!firstPrompt) firstPrompt = text.slice(0, 80);
824
+ lastPrompt = text.slice(0, 80);
825
+
826
+ // Extract file paths
827
+ const filePaths = text.match(/[\w./~-]+\.(?:mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/g);
828
+ if (filePaths) filePaths.forEach(p => fileSet.add(p));
829
+
830
+ // Count words for topics
831
+ const words = text.toLowerCase().split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w));
832
+ for (const w of words) {
833
+ wordCounts[w] = (wordCounts[w] || 0) + 1;
834
+ }
835
+ } catch { continue; }
836
+ }
837
+
838
+ // Top 10 topics by frequency
839
+ const topics = Object.entries(wordCounts)
840
+ .sort((a, b) => b[1] - a[1])
841
+ .slice(0, 10)
842
+ .map(([w]) => w);
843
+
844
+ index[sessionId] = {
845
+ id: sessionId,
846
+ topics,
847
+ files: [...fileSet].slice(0, 20),
848
+ prompts: { first: firstPrompt || '', last: lastPrompt || '' },
849
+ date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
850
+ messageCount,
851
+ tool: 'claude',
852
+ _fileSize: fileSize,
853
+ };
854
+ } catch { continue; }
855
+ }
856
+ }
857
+
858
+ // Also index codex sessions (same pattern)
859
+ const codexDir = join(home, '.codex', 'sessions');
860
+ if (existsSync(codexDir)) {
861
+ const walk = (dir) => {
862
+ let results = [];
863
+ try {
864
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
865
+ const full = join(dir, entry.name);
866
+ if (entry.isDirectory()) results = results.concat(walk(full));
867
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(full);
868
+ }
869
+ } catch {}
870
+ return results;
871
+ };
872
+
873
+ for (const filePath of walk(codexDir)) {
874
+ try {
875
+ const content = readFileSync(filePath, 'utf8');
876
+ const lines = content.split('\n').filter(Boolean);
877
+ if (!lines.length) continue;
878
+ const meta = JSON.parse(lines[0]);
879
+ if (meta.type !== 'session_meta' || !meta.payload) continue;
880
+ const id = meta.payload.id;
881
+ if (!id || index[id]) continue;
882
+
883
+ let fileSize = 0;
884
+ try { fileSize = statSync(filePath).size; } catch { continue; }
885
+
886
+ let firstPrompt = null, lastPrompt = null, messageCount = 0;
887
+ let lastTimestamp = Date.parse(meta.payload.timestamp || meta.timestamp) / 1000 || 0;
888
+
889
+ for (const ln of lines) {
890
+ try {
891
+ const j = JSON.parse(ln);
892
+ if (j.timestamp) {
893
+ const ts = Date.parse(j.timestamp) / 1000;
894
+ if (ts > lastTimestamp) lastTimestamp = ts;
895
+ }
896
+ if (j.type === 'event_msg' && j.payload?.type === 'user_message') {
897
+ const text = (j.payload.message || '').trim();
898
+ if (text) {
899
+ messageCount++;
900
+ if (!firstPrompt) firstPrompt = text.slice(0, 80);
901
+ lastPrompt = text.slice(0, 80);
902
+ }
903
+ }
904
+ } catch { continue; }
905
+ }
906
+
907
+ index[id] = {
908
+ id, topics: [], files: [],
909
+ prompts: { first: firstPrompt || '', last: lastPrompt || '' },
910
+ date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
911
+ messageCount, tool: 'codex', _fileSize: fileSize,
912
+ };
913
+ } catch { continue; }
914
+ }
915
+ }
916
+
917
+ // Save index
918
+ try {
919
+ mkdirSync(join(cwd, '.dualbrain'), { recursive: true });
920
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
921
+ } catch {}
922
+
923
+ return index;
924
+ }
925
+
926
+ /**
927
+ * Search the session index by keyword. Returns matching sessions sorted by relevance.
928
+ *
929
+ * @param {string} query
930
+ * @param {string} [cwd]
931
+ * @returns {Array<object>} sessions with `_score` field, sorted descending
932
+ */
933
+ export function searchSessions(query, cwd = process.cwd()) {
934
+ const indexPath = join(cwd, '.dualbrain', 'session-index.json');
935
+ let index = {};
936
+ try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
937
+
938
+ if (Object.keys(index).length === 0) {
939
+ index = buildSessionIndex(cwd);
940
+ }
941
+
942
+ const terms = query.toLowerCase().split(/\W+/).filter(Boolean);
943
+ const results = [];
944
+
945
+ for (const session of Object.values(index)) {
946
+ let score = 0;
947
+ const searchText = [
948
+ ...session.topics,
949
+ ...session.files,
950
+ session.prompts.first,
951
+ session.prompts.last,
952
+ ].join(' ').toLowerCase();
953
+
954
+ for (const term of terms) {
955
+ if (searchText.includes(term)) score++;
956
+ if (session.topics.includes(term)) score += 2;
957
+ if (session.files.some(f => f.includes(term))) score += 2;
958
+ }
959
+
960
+ if (score > 0) {
961
+ results.push({ ...session, _score: score });
962
+ }
963
+ }
964
+
965
+ return results.sort((a, b) => b._score - a._score);
966
+ }
967
+
968
+ /**
969
+ * Get detailed context for a session (for smart resume preview).
970
+ * Reads the last 20 lines of the session JSONL to surface the most recent prompt
971
+ * and files touched.
972
+ *
973
+ * @param {string} sessionId
974
+ * @param {string} [cwd]
975
+ * @returns {{ lastPrompt: string|null, filesTouched: string[], totalLines: number }|null}
976
+ */
977
+ export function getSessionContext(sessionId, cwd = process.cwd()) {
978
+ const home = process.env.HOME || '/root';
979
+ const paths = [
980
+ join(home, '.claude', 'projects', '-home-runner-workspace', sessionId + '.jsonl'),
981
+ join(cwd, '.replit-tools', '.session-archive', 'claude', 'projects', '-home-runner-workspace', sessionId + '.jsonl'),
982
+ ];
983
+
984
+ let filePath = null;
985
+ for (const p of paths) {
986
+ if (existsSync(p)) { filePath = p; break; }
987
+ }
988
+ if (!filePath) return null;
989
+
990
+ try {
991
+ const content = readFileSync(filePath, 'utf8');
992
+ const lines = content.split('\n').filter(Boolean);
993
+
994
+ // Read last 20 lines for recent context
995
+ const recentLines = lines.slice(-20);
996
+ let lastUserPrompt = null;
997
+ const filesSet = new Set();
998
+
999
+ for (const line of recentLines) {
1000
+ try {
1001
+ const entry = JSON.parse(line);
1002
+ if (entry.type === 'user' && entry.message?.content) {
1003
+ const text = typeof entry.message.content === 'string'
1004
+ ? entry.message.content
1005
+ : entry.message.content?.[0]?.text;
1006
+ if (text) lastUserPrompt = text.slice(0, 120);
1007
+ }
1008
+ if (entry.display) lastUserPrompt = entry.display.slice(0, 120);
1009
+
1010
+ // Look for file edits in tool use
1011
+ if (entry.type === 'tool_use' || entry.type === 'tool_result') {
1012
+ const fp = entry.tool_input?.file_path || entry.tool_input?.path;
1013
+ if (fp) filesSet.add(fp.split('/').pop());
1014
+ }
1015
+ } catch { continue; }
1016
+ }
1017
+
1018
+ return {
1019
+ lastPrompt: lastUserPrompt,
1020
+ filesTouched: [...filesSet].slice(0, 5),
1021
+ totalLines: lines.length,
1022
+ };
1023
+ } catch { return null; }
1024
+ }
1025
+
1026
+ // ─── CLI (direct invocation) ──────────────────────────────────────────────────
1027
+
1028
+ const isMain = process.argv[1]?.endsWith('session.mjs');
1029
+ if (isMain) {
1030
+ const session = loadSession(process.cwd());
1031
+ if (session) {
1032
+ process.stdout.write(JSON.stringify(session, null, 2) + '\n');
1033
+ } else {
1034
+ process.stdout.write('(no active session)\n');
1035
+ }
1036
+ }