dual-brain 7.1.21 → 7.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dual-brain.mjs +2580 -717
- package/hooks/budget-balancer.mjs +104 -266
- package/hooks/wave-orchestrator.mjs +29 -26
- package/package.json +13 -3
- package/scripts/verify-publish.mjs +26 -0
- package/src/context.mjs +389 -0
- package/src/decide.mjs +283 -60
- package/src/detect.mjs +133 -1
- package/src/dispatch.mjs +175 -30
- package/src/doctor.mjs +577 -0
- package/src/failure-memory.mjs +178 -0
- package/src/nextstep.mjs +100 -0
- package/src/observer.mjs +241 -0
- package/src/outcome.mjs +256 -0
- package/src/pipeline.mjs +759 -0
- package/src/profile.mjs +357 -485
- package/src/receipt.mjs +131 -0
- package/src/session.mjs +358 -10
package/src/receipt.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const DIM = '\x1b[2m';
|
|
2
|
+
const GREEN = '\x1b[32m';
|
|
3
|
+
const YELLOW= '\x1b[33m';
|
|
4
|
+
const RED = '\x1b[31m';
|
|
5
|
+
const RESET = '\x1b[0m';
|
|
6
|
+
|
|
7
|
+
const SEP = `${DIM}──────────────────────────────────${RESET}`;
|
|
8
|
+
|
|
9
|
+
const AUTH_PAT = /\b(auth|credential|secret|token|password|encrypt|permission|oauth|jwt|api.?key)\b/i;
|
|
10
|
+
|
|
11
|
+
function classifyRisk(plan, result) {
|
|
12
|
+
if (plan.risk) return plan.risk;
|
|
13
|
+
const files = result.filesChanged ?? [];
|
|
14
|
+
if (files.some(f => AUTH_PAT.test(f))) return 'critical';
|
|
15
|
+
if (plan.tier === 'think') return 'high';
|
|
16
|
+
if (plan.tier === 'execute') return 'medium';
|
|
17
|
+
return 'low';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function classifyChallenger(plan, result) {
|
|
21
|
+
const policy = plan.challengerPolicy;
|
|
22
|
+
if (!plan.useChallenger && (!policy || policy === 'none')) return 'not used';
|
|
23
|
+
if (!result.success) return 'blocked';
|
|
24
|
+
if (result.output && /concern|issue|warn|problem/i.test(String(result.output))) return 'concerns raised';
|
|
25
|
+
return 'pass';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nextStep(result, plan, verification) {
|
|
29
|
+
const files = result.filesChanged ?? [];
|
|
30
|
+
const changed = files.length > 0;
|
|
31
|
+
const authFiles = files.some(f => AUTH_PAT.test(f));
|
|
32
|
+
|
|
33
|
+
if (!result.success) {
|
|
34
|
+
const retry = result.error && /test/i.test(String(result.error));
|
|
35
|
+
return retry ? 'fix failing tests' : 'retry with deeper analysis';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (authFiles) return 'security review recommended';
|
|
39
|
+
|
|
40
|
+
if (changed) {
|
|
41
|
+
if (!verification.testsRun) return 'run tests to verify';
|
|
42
|
+
if (verification.testsPassed === false) return 'fix failing tests';
|
|
43
|
+
if (verification.testsPassed === true) return 'commit this patch';
|
|
44
|
+
return 'review the diff';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return 'review the output';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildReceipt(result, plan, verification) {
|
|
51
|
+
const files = result.filesChanged ?? [];
|
|
52
|
+
const changed = files.length > 0 ? files.join(', ') : 'no files changed';
|
|
53
|
+
|
|
54
|
+
let verified;
|
|
55
|
+
if (verification.testsPassed === true) verified = 'tests passed';
|
|
56
|
+
else if (verification.filesVerified) verified = 'files confirmed changed';
|
|
57
|
+
else verified = 'not verified';
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
changed,
|
|
61
|
+
verified,
|
|
62
|
+
risk: classifyRisk(plan, result),
|
|
63
|
+
challenger: classifyChallenger(plan, result),
|
|
64
|
+
next: nextStep(result, plan, verification),
|
|
65
|
+
success: result.success ?? false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function colorRisk(risk) {
|
|
70
|
+
if (risk === 'low') return `${GREEN}${risk}${RESET}`;
|
|
71
|
+
if (risk === 'medium') return `${YELLOW}${risk}${RESET}`;
|
|
72
|
+
return `${RED}${risk}${RESET}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function colorChallenger(ch) {
|
|
76
|
+
if (ch === 'pass') return `${GREEN}${ch}${RESET}`;
|
|
77
|
+
if (ch === 'concerns raised') return `${YELLOW}${ch}${RESET}`;
|
|
78
|
+
if (ch === 'blocked') return `${RED}${ch}${RESET}`;
|
|
79
|
+
return `${DIM}${ch}${RESET}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatReceipt(receipt) {
|
|
83
|
+
return [
|
|
84
|
+
SEP,
|
|
85
|
+
` Changed: ${receipt.changed}`,
|
|
86
|
+
` Verified: ${receipt.verified}`,
|
|
87
|
+
` Risk: ${colorRisk(receipt.risk)}`,
|
|
88
|
+
` Challenger: ${colorChallenger(receipt.challenger)}`,
|
|
89
|
+
` Next: ${receipt.next}`,
|
|
90
|
+
SEP,
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatFailureReceipt(receipt, failureContext) {
|
|
95
|
+
const errorLine = failureContext ? ` Error: ${failureContext}` : null;
|
|
96
|
+
const lines = [
|
|
97
|
+
SEP,
|
|
98
|
+
` Changed: ${receipt.changed}`,
|
|
99
|
+
` Verified: ${receipt.verified}`,
|
|
100
|
+
` Risk: ${colorRisk(receipt.risk)}`,
|
|
101
|
+
` Challenger: ${colorChallenger(receipt.challenger)}`,
|
|
102
|
+
];
|
|
103
|
+
if (errorLine) lines.push(errorLine);
|
|
104
|
+
lines.push(` Next: ${receipt.next}`, SEP);
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildReceiptFromOutcome(outcome = {}) {
|
|
109
|
+
const result = {
|
|
110
|
+
success: outcome.success ?? outcome.result?.success ?? false,
|
|
111
|
+
filesChanged: outcome.filesChanged ?? outcome.result?.filesChanged ?? [],
|
|
112
|
+
error: outcome.error ?? outcome.result?.error ?? null,
|
|
113
|
+
duration: outcome.duration ?? outcome.result?.duration ?? 0,
|
|
114
|
+
output: outcome.output ?? null,
|
|
115
|
+
};
|
|
116
|
+
const plan = {
|
|
117
|
+
primaryModel: outcome.primaryModel ?? '',
|
|
118
|
+
reasoningDepth: outcome.reasoningDepth ?? '',
|
|
119
|
+
challengerPolicy: outcome.challengerPolicy ?? 'none',
|
|
120
|
+
useChallenger: !!(outcome.challengerPolicy && outcome.challengerPolicy !== 'none'),
|
|
121
|
+
tier: outcome.tier ?? '',
|
|
122
|
+
workStyle: outcome.workStyle ?? '',
|
|
123
|
+
risk: outcome.risk ?? '',
|
|
124
|
+
};
|
|
125
|
+
const verification = {
|
|
126
|
+
filesVerified: outcome.verification?.filesVerified ?? false,
|
|
127
|
+
testsRun: outcome.verification?.testsRun ?? false,
|
|
128
|
+
testsPassed: outcome.verification?.testsPassed ?? null,
|
|
129
|
+
};
|
|
130
|
+
return buildReceipt(result, plan, verification);
|
|
131
|
+
}
|
package/src/session.mjs
CHANGED
|
@@ -461,23 +461,38 @@ export function importReplitSessions(cwd = process.cwd()) {
|
|
|
461
461
|
const windowMs = windowHours * 60 * 60 * 1000;
|
|
462
462
|
const cutoff = Date.now() - windowMs;
|
|
463
463
|
|
|
464
|
+
// Load existing session index for smartName lookup (best-effort, non-fatal)
|
|
465
|
+
let sessionIndex = {};
|
|
466
|
+
try {
|
|
467
|
+
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
468
|
+
if (existsSync(indexPath)) {
|
|
469
|
+
sessionIndex = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
470
|
+
}
|
|
471
|
+
} catch { /* non-fatal */ }
|
|
472
|
+
|
|
464
473
|
// Build session list
|
|
465
474
|
for (const [id, sess] of bySession) {
|
|
466
475
|
// Skip sessions outside the recency window (timestamps are in ms)
|
|
467
476
|
if (sess.lastTimestamp < cutoff) continue;
|
|
468
|
-
|
|
469
|
-
|
|
477
|
+
|
|
478
|
+
// Use smartName from index if available, otherwise fall back to first prompt
|
|
479
|
+
let name = sessionIndex[id]?.smartName || null;
|
|
480
|
+
|
|
470
481
|
if (!name) {
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
name
|
|
482
|
+
// Classic fallback: first meaningful prompt
|
|
483
|
+
name = sess.firstPrompt;
|
|
484
|
+
if (!name) {
|
|
485
|
+
const firstReal = sess.entries.find(e => e.display && e.display !== 'login');
|
|
486
|
+
name = firstReal?.display || `Session ${id.slice(0, 8)}`;
|
|
487
|
+
}
|
|
488
|
+
// Truncate long names that came from raw prompts
|
|
489
|
+
if (name.length > 60) name = name.slice(0, 57) + '...';
|
|
474
490
|
}
|
|
475
|
-
// Truncate long names
|
|
476
|
-
if (name.length > 60) name = name.slice(0, 57) + '...';
|
|
477
491
|
|
|
478
492
|
sessions.push({
|
|
479
493
|
id: sess.sessionId,
|
|
480
494
|
name,
|
|
495
|
+
smartName: sessionIndex[id]?.smartName || null,
|
|
481
496
|
project: sess.project,
|
|
482
497
|
promptCount: sess.entries.length,
|
|
483
498
|
lastActive: new Date(sess.lastTimestamp).toISOString(),
|
|
@@ -508,7 +523,7 @@ export function getSessionMeta(cwd = process.cwd()) {
|
|
|
508
523
|
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; }
|
|
509
524
|
}
|
|
510
525
|
|
|
511
|
-
function saveSessionMeta(meta, cwd = process.cwd()) {
|
|
526
|
+
export function saveSessionMeta(meta, cwd = process.cwd()) {
|
|
512
527
|
ensureDir(cwd);
|
|
513
528
|
const p = sessionMetaPath(cwd);
|
|
514
529
|
const tmp = p + '.tmp.' + process.pid;
|
|
@@ -516,6 +531,76 @@ function saveSessionMeta(meta, cwd = process.cwd()) {
|
|
|
516
531
|
renameSync(tmp, p);
|
|
517
532
|
}
|
|
518
533
|
|
|
534
|
+
// ─── Archive support ──────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
const ARCHIVE_FILE = '.dualbrain/archive/sessions.json';
|
|
537
|
+
|
|
538
|
+
function archivePath(cwd) {
|
|
539
|
+
return join(cwd ?? process.cwd(), ARCHIVE_FILE);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Archive a session — moves it from active sessions.json to archive/sessions.json.
|
|
544
|
+
* The session data stays in the index (searchable), just flagged as archived.
|
|
545
|
+
* Non-destructive and reversible.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} sessionId
|
|
548
|
+
* @param {string} [cwd]
|
|
549
|
+
*/
|
|
550
|
+
export function archiveSession(sessionId, cwd = process.cwd()) {
|
|
551
|
+
// Load active sessions meta
|
|
552
|
+
const meta = getSessionMeta(cwd);
|
|
553
|
+
const existing = meta[sessionId] ?? {};
|
|
554
|
+
|
|
555
|
+
// Load or init archive
|
|
556
|
+
const ap = archivePath(cwd);
|
|
557
|
+
mkdirSync(dirname(ap), { recursive: true });
|
|
558
|
+
let archive = [];
|
|
559
|
+
try {
|
|
560
|
+
if (existsSync(ap)) archive = JSON.parse(readFileSync(ap, 'utf8'));
|
|
561
|
+
} catch { archive = []; }
|
|
562
|
+
|
|
563
|
+
// Avoid duplicates
|
|
564
|
+
if (!archive.some(s => s.id === sessionId)) {
|
|
565
|
+
archive.push({
|
|
566
|
+
...existing,
|
|
567
|
+
id: sessionId,
|
|
568
|
+
archived: true,
|
|
569
|
+
archivedAt: new Date().toISOString(),
|
|
570
|
+
});
|
|
571
|
+
const tmp = ap + '.tmp.' + process.pid;
|
|
572
|
+
writeFileSync(tmp, JSON.stringify(archive, null, 2) + '\n');
|
|
573
|
+
renameSync(tmp, ap);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Remove from active sessions.json
|
|
577
|
+
delete meta[sessionId];
|
|
578
|
+
saveSessionMeta(meta, cwd);
|
|
579
|
+
|
|
580
|
+
// Mark archived in the session index (best-effort)
|
|
581
|
+
try {
|
|
582
|
+
const indexPath = join(cwd ?? process.cwd(), '.dualbrain', 'session-index.json');
|
|
583
|
+
if (existsSync(indexPath)) {
|
|
584
|
+
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
585
|
+
if (index[sessionId]) {
|
|
586
|
+
index[sessionId].archived = true;
|
|
587
|
+
writeFileSync(indexPath, JSON.stringify(index, null, 2) + '\n');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} catch { /* non-fatal */ }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Return all archived sessions.
|
|
595
|
+
* @param {string} [cwd]
|
|
596
|
+
* @returns {Array<object>}
|
|
597
|
+
*/
|
|
598
|
+
export function getArchivedSessions(cwd = process.cwd()) {
|
|
599
|
+
const ap = archivePath(cwd);
|
|
600
|
+
if (!existsSync(ap)) return [];
|
|
601
|
+
try { return JSON.parse(readFileSync(ap, 'utf8')); } catch { return []; }
|
|
602
|
+
}
|
|
603
|
+
|
|
519
604
|
export function renameSession(sessionId, name, cwd = process.cwd()) {
|
|
520
605
|
const meta = getSessionMeta(cwd);
|
|
521
606
|
meta[sessionId] = { ...meta[sessionId], name, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
|
|
@@ -741,6 +826,159 @@ export function syncSessionMirror(cwd = process.cwd()) {
|
|
|
741
826
|
return { copied: totalCopied, grew: totalGrew };
|
|
742
827
|
}
|
|
743
828
|
|
|
829
|
+
// ─── Smart session naming ─────────────────────────────────────────────────────
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* File pattern → human label mapping (checked in order, first match wins).
|
|
833
|
+
* Each entry: { pattern: RegExp, label: string, action?: string }
|
|
834
|
+
*/
|
|
835
|
+
const FILE_PATTERN_RULES = [
|
|
836
|
+
{ pattern: /auth/i, label: 'Auth', action: 'Refactor' },
|
|
837
|
+
{ pattern: /test|spec/i, label: 'Tests', action: 'Fix' },
|
|
838
|
+
{ pattern: /dispatch/i, label: 'Dispatch', action: 'Update' },
|
|
839
|
+
{ pattern: /session/i, label: 'Session', action: 'Update' },
|
|
840
|
+
{ pattern: /profile/i, label: 'Profile', action: 'Update' },
|
|
841
|
+
{ pattern: /detect/i, label: 'Detection', action: 'Update' },
|
|
842
|
+
{ pattern: /decide/i, label: 'Routing', action: 'Update' },
|
|
843
|
+
{ pattern: /budget/i, label: 'Budget', action: 'Update' },
|
|
844
|
+
{ pattern: /hook/i, label: 'Hooks', action: 'Update' },
|
|
845
|
+
{ pattern: /install/i, label: 'Install', action: 'Update' },
|
|
846
|
+
{ pattern: /config/i, label: 'Config', action: 'Update' },
|
|
847
|
+
{ pattern: /migrate/i, label: 'Migration', action: 'Add' },
|
|
848
|
+
];
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Topic words that suggest a dominant action verb.
|
|
852
|
+
*/
|
|
853
|
+
const TOPIC_ACTION_MAP = [
|
|
854
|
+
{ words: ['fix', 'bug', 'error', 'crash', 'broken', 'fail'], action: 'Fix' },
|
|
855
|
+
{ words: ['refactor', 'cleanup', 'clean', 'reorganize'], action: 'Refactor' },
|
|
856
|
+
{ words: ['add', 'implement', 'create', 'build', 'write'], action: 'Add' },
|
|
857
|
+
{ words: ['update', 'upgrade', 'bump', 'patch'], action: 'Update' },
|
|
858
|
+
{ words: ['test', 'spec', 'coverage'], action: 'Fix' },
|
|
859
|
+
{ words: ['deploy', 'release', 'publish'], action: 'Deploy' },
|
|
860
|
+
{ words: ['audit', 'review', 'check'], action: 'Review' },
|
|
861
|
+
];
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Convert a string to Title Case.
|
|
865
|
+
* @param {string} str
|
|
866
|
+
* @returns {string}
|
|
867
|
+
*/
|
|
868
|
+
function toTitleCase(str) {
|
|
869
|
+
return str.replace(/\b\w/g, c => c.toUpperCase());
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Strip file extensions from a name candidate.
|
|
874
|
+
* @param {string} name
|
|
875
|
+
* @returns {string}
|
|
876
|
+
*/
|
|
877
|
+
function stripExtensions(name) {
|
|
878
|
+
return name.replace(/\.(mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/gi, '');
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Truncate a string to maxLen characters, preserving whole words where possible.
|
|
883
|
+
* @param {string} str
|
|
884
|
+
* @param {number} maxLen
|
|
885
|
+
* @returns {string}
|
|
886
|
+
*/
|
|
887
|
+
function truncate(str, maxLen = 40) {
|
|
888
|
+
if (str.length <= maxLen) return str;
|
|
889
|
+
const cut = str.slice(0, maxLen).replace(/\s+\S*$/, '');
|
|
890
|
+
return cut || str.slice(0, maxLen);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Generate a smart human-readable session name from session index data.
|
|
895
|
+
*
|
|
896
|
+
* Priority:
|
|
897
|
+
* 1. Dominant file pattern (e.g. auth*.mjs → "Refactor Auth Module")
|
|
898
|
+
* 2. Top topics (e.g. ['auth','token','refresh'] → "Auth Token Refresh")
|
|
899
|
+
* 3. Fallback: first prompt truncated to 40 chars
|
|
900
|
+
*
|
|
901
|
+
* Rules: ≤40 chars, Title Case, no file extensions, action-prefixed when detectable.
|
|
902
|
+
*
|
|
903
|
+
* @param {{ topics?: string[], files?: string[], prompts?: { first?: string } }} sessionData
|
|
904
|
+
* @returns {string}
|
|
905
|
+
*/
|
|
906
|
+
export function generateSmartName(sessionData) {
|
|
907
|
+
const topics = sessionData.topics || [];
|
|
908
|
+
const files = sessionData.files || [];
|
|
909
|
+
const firstPrompt = sessionData.prompts?.first || '';
|
|
910
|
+
|
|
911
|
+
// ── Step 1: Detect dominant action from topics ─────────────────────────────
|
|
912
|
+
let detectedAction = null;
|
|
913
|
+
for (const { words, action } of TOPIC_ACTION_MAP) {
|
|
914
|
+
if (topics.some(t => words.includes(t))) {
|
|
915
|
+
detectedAction = action;
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ── Step 2: Try file pattern match ─────────────────────────────────────────
|
|
921
|
+
if (files.length > 0) {
|
|
922
|
+
// Flatten all filenames for pattern matching
|
|
923
|
+
const fileNames = files.map(f => f.split('/').pop()).join(' ');
|
|
924
|
+
|
|
925
|
+
for (const { pattern, label, action } of FILE_PATTERN_RULES) {
|
|
926
|
+
if (pattern.test(fileNames)) {
|
|
927
|
+
const actionWord = detectedAction || action || 'Update';
|
|
928
|
+
const candidate = `${actionWord} ${label}`;
|
|
929
|
+
return truncate(toTitleCase(candidate));
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// No named pattern — derive a label from the most common directory or base name
|
|
934
|
+
const basenames = files.map(f => {
|
|
935
|
+
const base = f.split('/').pop() || f;
|
|
936
|
+
// Strip extension and convert camelCase/kebab to words
|
|
937
|
+
return stripExtensions(base)
|
|
938
|
+
.replace(/[-_]/g, ' ')
|
|
939
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
940
|
+
.trim();
|
|
941
|
+
}).filter(Boolean);
|
|
942
|
+
|
|
943
|
+
if (basenames.length > 0) {
|
|
944
|
+
// Use the most common prefix or first significant basename
|
|
945
|
+
const label = basenames[0];
|
|
946
|
+
const actionWord = detectedAction || 'Update';
|
|
947
|
+
const candidate = `${actionWord} ${label}`;
|
|
948
|
+
return truncate(toTitleCase(stripExtensions(candidate)));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ── Step 3: Try top topics ─────────────────────────────────────────────────
|
|
953
|
+
if (topics.length >= 2) {
|
|
954
|
+
// Take top 3 topics and compose a name
|
|
955
|
+
const topTopics = topics.slice(0, 3);
|
|
956
|
+
const actionWord = detectedAction || null;
|
|
957
|
+
|
|
958
|
+
let candidate;
|
|
959
|
+
if (actionWord) {
|
|
960
|
+
// Use action + remaining topics
|
|
961
|
+
candidate = [actionWord, ...topTopics.filter(t => t !== actionWord.toLowerCase())].slice(0, 3).join(' ');
|
|
962
|
+
} else {
|
|
963
|
+
candidate = topTopics.join(' ');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return truncate(toTitleCase(candidate));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (topics.length === 1) {
|
|
970
|
+
const actionWord = detectedAction || 'Work on';
|
|
971
|
+
return truncate(toTitleCase(`${actionWord} ${topics[0]}`));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ── Step 4: Fallback — first prompt truncated ──────────────────────────────
|
|
975
|
+
if (firstPrompt) {
|
|
976
|
+
return truncate(firstPrompt);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return 'Session';
|
|
980
|
+
}
|
|
981
|
+
|
|
744
982
|
// ─── Session index ────────────────────────────────────────────────────────────
|
|
745
983
|
|
|
746
984
|
/**
|
|
@@ -841,7 +1079,7 @@ export function buildSessionIndex(cwd = process.cwd()) {
|
|
|
841
1079
|
.slice(0, 10)
|
|
842
1080
|
.map(([w]) => w);
|
|
843
1081
|
|
|
844
|
-
|
|
1082
|
+
const sessionEntry = {
|
|
845
1083
|
id: sessionId,
|
|
846
1084
|
topics,
|
|
847
1085
|
files: [...fileSet].slice(0, 20),
|
|
@@ -851,6 +1089,8 @@ export function buildSessionIndex(cwd = process.cwd()) {
|
|
|
851
1089
|
tool: 'claude',
|
|
852
1090
|
_fileSize: fileSize,
|
|
853
1091
|
};
|
|
1092
|
+
sessionEntry.smartName = generateSmartName(sessionEntry);
|
|
1093
|
+
index[sessionId] = sessionEntry;
|
|
854
1094
|
} catch { continue; }
|
|
855
1095
|
}
|
|
856
1096
|
}
|
|
@@ -904,12 +1144,14 @@ export function buildSessionIndex(cwd = process.cwd()) {
|
|
|
904
1144
|
} catch { continue; }
|
|
905
1145
|
}
|
|
906
1146
|
|
|
907
|
-
|
|
1147
|
+
const codexEntry = {
|
|
908
1148
|
id, topics: [], files: [],
|
|
909
1149
|
prompts: { first: firstPrompt || '', last: lastPrompt || '' },
|
|
910
1150
|
date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
|
|
911
1151
|
messageCount, tool: 'codex', _fileSize: fileSize,
|
|
912
1152
|
};
|
|
1153
|
+
codexEntry.smartName = generateSmartName(codexEntry);
|
|
1154
|
+
index[id] = codexEntry;
|
|
913
1155
|
} catch { continue; }
|
|
914
1156
|
}
|
|
915
1157
|
}
|
|
@@ -965,6 +1207,112 @@ export function searchSessions(query, cwd = process.cwd()) {
|
|
|
965
1207
|
return results.sort((a, b) => b._score - a._score);
|
|
966
1208
|
}
|
|
967
1209
|
|
|
1210
|
+
/**
|
|
1211
|
+
* Find sessions related to a new task prompt and file list.
|
|
1212
|
+
* Uses the session index (topics + files) — does not parse full JSONL files.
|
|
1213
|
+
*
|
|
1214
|
+
* Scoring:
|
|
1215
|
+
* +3 for each file in common between the new task and a past session
|
|
1216
|
+
* +2 for each topic keyword in common
|
|
1217
|
+
* +1 for matching intent words (fix, refactor, test, etc.)
|
|
1218
|
+
*
|
|
1219
|
+
* Returns top 3 matches with score > 3, sorted by score desc.
|
|
1220
|
+
* Excludes sessions from the last hour (likely the current session).
|
|
1221
|
+
*
|
|
1222
|
+
* @param {string} prompt New task prompt
|
|
1223
|
+
* @param {string[]} files File paths from the new task
|
|
1224
|
+
* @param {string} [cwd]
|
|
1225
|
+
* @returns {Array<{
|
|
1226
|
+
* sessionId: string, smartName: string, score: number,
|
|
1227
|
+
* matchedFiles: string[], matchedTopics: string[],
|
|
1228
|
+
* date: string|null, messageCount: number
|
|
1229
|
+
* }>}
|
|
1230
|
+
*/
|
|
1231
|
+
export function findRelatedSessions(prompt, files = [], cwd = process.cwd()) {
|
|
1232
|
+
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
1233
|
+
let index = {};
|
|
1234
|
+
try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch { return []; }
|
|
1235
|
+
|
|
1236
|
+
if (Object.keys(index).length === 0) return [];
|
|
1237
|
+
|
|
1238
|
+
// Intent words for +1 scoring
|
|
1239
|
+
const INTENT_WORDS = ['fix', 'refactor', 'test', 'add', 'update', 'review', 'debug', 'build', 'remove', 'migrate', 'deploy', 'implement', 'create'];
|
|
1240
|
+
|
|
1241
|
+
// Normalize the new task's prompt into words
|
|
1242
|
+
const promptLower = (prompt || '').toLowerCase();
|
|
1243
|
+
const promptWords = new Set(promptLower.split(/\W+/).filter(w => w.length > 3));
|
|
1244
|
+
|
|
1245
|
+
// Normalize the new task's file paths for comparison
|
|
1246
|
+
const normalizeFile = (f) => (f || '').split('/').pop().toLowerCase().replace(/\.[^.]+$/, '');
|
|
1247
|
+
const newFileNames = new Set((files || []).map(normalizeFile).filter(Boolean));
|
|
1248
|
+
|
|
1249
|
+
// One-hour cutoff for excluding likely-current session
|
|
1250
|
+
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
|
1251
|
+
|
|
1252
|
+
const results = [];
|
|
1253
|
+
|
|
1254
|
+
for (const session of Object.values(index)) {
|
|
1255
|
+
// Skip archived sessions
|
|
1256
|
+
if (session.archived) continue;
|
|
1257
|
+
|
|
1258
|
+
// Skip sessions from the last hour
|
|
1259
|
+
const sessionTs = session.date ? Date.parse(session.date) : 0;
|
|
1260
|
+
if (sessionTs > oneHourAgo) continue;
|
|
1261
|
+
|
|
1262
|
+
let score = 0;
|
|
1263
|
+
const matchedFiles = [];
|
|
1264
|
+
const matchedTopics = [];
|
|
1265
|
+
|
|
1266
|
+
// +3 for each file in common
|
|
1267
|
+
for (const sessionFile of (session.files || [])) {
|
|
1268
|
+
const sessionFileName = normalizeFile(sessionFile);
|
|
1269
|
+
if (sessionFileName && newFileNames.has(sessionFileName)) {
|
|
1270
|
+
score += 3;
|
|
1271
|
+
matchedFiles.push(sessionFile);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// +2 for each topic keyword in common with prompt words
|
|
1276
|
+
for (const topic of (session.topics || [])) {
|
|
1277
|
+
if (topic && promptWords.has(topic)) {
|
|
1278
|
+
score += 2;
|
|
1279
|
+
matchedTopics.push(topic);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// +1 for matching intent words found in both prompt and session topics/prompts
|
|
1284
|
+
const sessionText = [
|
|
1285
|
+
...(session.topics || []),
|
|
1286
|
+
session.prompts?.first || '',
|
|
1287
|
+
session.prompts?.last || '',
|
|
1288
|
+
].join(' ').toLowerCase();
|
|
1289
|
+
|
|
1290
|
+
for (const word of INTENT_WORDS) {
|
|
1291
|
+
if (promptLower.includes(word) && sessionText.includes(word)) {
|
|
1292
|
+
score += 1;
|
|
1293
|
+
break; // only +1 total for intent words
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (score > 3) {
|
|
1298
|
+
results.push({
|
|
1299
|
+
sessionId: session.id,
|
|
1300
|
+
smartName: session.smartName || session.prompts?.first?.slice(0, 40) || session.id.slice(0, 8),
|
|
1301
|
+
score,
|
|
1302
|
+
matchedFiles,
|
|
1303
|
+
matchedTopics,
|
|
1304
|
+
date: session.date,
|
|
1305
|
+
messageCount: session.messageCount || 0,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Return top 3 sorted by score descending
|
|
1311
|
+
return results
|
|
1312
|
+
.sort((a, b) => b.score - a.score)
|
|
1313
|
+
.slice(0, 3);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
968
1316
|
/**
|
|
969
1317
|
* Get detailed context for a session (for smart resume preview).
|
|
970
1318
|
* Reads the last 20 lines of the session JSONL to surface the most recent prompt
|