dual-brain 0.1.7 → 0.1.8
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 +1016 -24
- package/package.json +1 -1
- package/src/decide.mjs +241 -6
- package/src/dispatch.mjs +43 -0
- package/src/session.mjs +106 -0
package/package.json
CHANGED
package/src/decide.mjs
CHANGED
|
@@ -6,21 +6,40 @@
|
|
|
6
6
|
* to use and explains why in one sentence.
|
|
7
7
|
*
|
|
8
8
|
* Exports: decideRoute, getModelCapabilities, getAvailableModels,
|
|
9
|
-
* estimateBudgetPressure, shouldDualBrain, explainDecision, getFailoverOrder
|
|
9
|
+
* estimateBudgetPressure, shouldDualBrain, explainDecision, getFailoverOrder,
|
|
10
|
+
* getOptimalSub
|
|
10
11
|
*
|
|
11
12
|
* CLI: node src/decide.mjs --profile /path/to/profile.json \
|
|
12
13
|
* --detection '{"intent":"edit","risk":"low","complexity":"simple","effort":"medium","tier":"execute"}'
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
import { existsSync, readFileSync } from 'fs';
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
|
|
16
17
|
import { join, dirname } from 'path';
|
|
17
18
|
import { fileURLToPath } from 'url';
|
|
18
19
|
import { getProviderScore, checkCooldown } from './health.mjs';
|
|
19
20
|
|
|
20
|
-
const __dirname
|
|
21
|
-
const WORKSPACE
|
|
22
|
-
const USAGE_DIR
|
|
23
|
-
const
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const WORKSPACE = join(__dirname, '..');
|
|
23
|
+
const USAGE_DIR = join(WORKSPACE, '.dualbrain', 'usage');
|
|
24
|
+
const AUDIT_DIR = join(WORKSPACE, '.dualbrain', 'audit');
|
|
25
|
+
const FIVE_HRS_MS = 5 * 60 * 60 * 1000;
|
|
26
|
+
const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
// ─── Subscription token quotas (mirrors budget-balancer.mjs) ─────────────────
|
|
29
|
+
|
|
30
|
+
/** Per-plan aggregate token budgets for the two rolling windows. */
|
|
31
|
+
const SUB_QUOTAS = {
|
|
32
|
+
claude: {
|
|
33
|
+
'$20': { fiveHr: 402_500, weekly: 2_750_000 },
|
|
34
|
+
'$100': { fiveHr: 1_638_000, weekly: 11_100_000 },
|
|
35
|
+
'$200': { fiveHr: 4_120_000, weekly: 27_500_000 },
|
|
36
|
+
},
|
|
37
|
+
openai: {
|
|
38
|
+
'$20': { fiveHr: 400_000, weekly: 2_700_000 },
|
|
39
|
+
'$100': { fiveHr: 1_050_000, weekly: 6_750_000 },
|
|
40
|
+
'$200': { fiveHr: 1_900_000, weekly: 17_500_000 },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
24
43
|
|
|
25
44
|
// ─── Slim Model Capabilities (routing-relevant only) ─────────────────────────
|
|
26
45
|
|
|
@@ -470,6 +489,217 @@ export function parsePreferences(preferences) {
|
|
|
470
489
|
return signals;
|
|
471
490
|
}
|
|
472
491
|
|
|
492
|
+
// ─── Exported: getOptimalSub ─────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Read usage log entries within a given window (ms).
|
|
496
|
+
* Scans .dualbrain/usage/usage-YYYY-MM-DD.jsonl files.
|
|
497
|
+
* @param {number} windowMs
|
|
498
|
+
* @returns {Array<object>}
|
|
499
|
+
*/
|
|
500
|
+
function _readUsageInWindow(windowMs) {
|
|
501
|
+
const now = Date.now();
|
|
502
|
+
const cutoff = now - windowMs;
|
|
503
|
+
const entries = [];
|
|
504
|
+
const daysBack = Math.ceil(windowMs / 86_400_000) + 1;
|
|
505
|
+
const seen = new Set();
|
|
506
|
+
for (let i = 0; i < daysBack; i++) {
|
|
507
|
+
const date = new Date(now - i * 86_400_000).toISOString().slice(0, 10);
|
|
508
|
+
if (seen.has(date)) continue;
|
|
509
|
+
seen.add(date);
|
|
510
|
+
const file = join(USAGE_DIR, `usage-${date}.jsonl`);
|
|
511
|
+
if (!existsSync(file)) continue;
|
|
512
|
+
let raw;
|
|
513
|
+
try { raw = readFileSync(file, 'utf8'); } catch { continue; }
|
|
514
|
+
for (const line of raw.split('\n')) {
|
|
515
|
+
if (!line.trim()) continue;
|
|
516
|
+
let rec;
|
|
517
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
518
|
+
const ts = Date.parse(rec.timestamp);
|
|
519
|
+
if (!isNaN(ts) && ts >= cutoff) entries.push(rec);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return entries;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Sum tokens used by a specific provider from usage entries.
|
|
527
|
+
* @param {Array<object>} entries
|
|
528
|
+
* @param {string} provider
|
|
529
|
+
* @returns {number}
|
|
530
|
+
*/
|
|
531
|
+
function _sumProviderTokens(entries, provider) {
|
|
532
|
+
let total = 0;
|
|
533
|
+
for (const e of entries) {
|
|
534
|
+
if (e.provider !== provider) continue;
|
|
535
|
+
const inp = e.input_tokens ?? 0;
|
|
536
|
+
const out = e.output_tokens ?? 0;
|
|
537
|
+
total += inp + out > 0 ? inp + out : 8_000; // fallback estimate
|
|
538
|
+
}
|
|
539
|
+
return total;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Log an autopilot routing decision to .dualbrain/audit/budget-autopilot.jsonl.
|
|
544
|
+
* @param {object} entry
|
|
545
|
+
*/
|
|
546
|
+
function _logAutopilot(entry) {
|
|
547
|
+
try {
|
|
548
|
+
if (!existsSync(AUDIT_DIR)) mkdirSync(AUDIT_DIR, { recursive: true });
|
|
549
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
|
|
550
|
+
appendFileSync(join(AUDIT_DIR, 'budget-autopilot.jsonl'), line + '\n', 'utf8');
|
|
551
|
+
} catch {
|
|
552
|
+
// Non-fatal: logging should never block routing
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Given a provider and profile, pick the subscription with the most remaining
|
|
558
|
+
* quota — use-it-or-lose-it scoring: `remaining * (1 / hoursUntilReset)`.
|
|
559
|
+
*
|
|
560
|
+
* Handles single-sub and multi-sub profiles uniformly. When only one sub
|
|
561
|
+
* exists, returns it immediately (no log written for single-sub case since
|
|
562
|
+
* there is no real routing decision to make).
|
|
563
|
+
*
|
|
564
|
+
* @param {'claude'|'openai'} provider
|
|
565
|
+
* @param {'search'|'execute'|'think'} tier
|
|
566
|
+
* @param {object} profile
|
|
567
|
+
* @returns {{
|
|
568
|
+
* subIndex: number,
|
|
569
|
+
* plan: string,
|
|
570
|
+
* label: string|null,
|
|
571
|
+
* fiveHrUsed: number,
|
|
572
|
+
* weeklyUsed: number,
|
|
573
|
+
* fiveHrQuota: number,
|
|
574
|
+
* weeklyQuota: number,
|
|
575
|
+
* fiveHrRemaining: number,
|
|
576
|
+
* weeklyRemaining: number,
|
|
577
|
+
* score: number,
|
|
578
|
+
* reason: string,
|
|
579
|
+
* }|null}
|
|
580
|
+
*/
|
|
581
|
+
export function getOptimalSub(provider, tier, profile) {
|
|
582
|
+
const providerCfg = profile?.providers?.[provider];
|
|
583
|
+
if (!providerCfg) return null;
|
|
584
|
+
|
|
585
|
+
// Normalise to an array of subs
|
|
586
|
+
const subs = providerCfg.subs?.length
|
|
587
|
+
? providerCfg.subs
|
|
588
|
+
: providerCfg.plan
|
|
589
|
+
? [{ plan: providerCfg.plan, label: providerCfg.label || null }]
|
|
590
|
+
: [];
|
|
591
|
+
|
|
592
|
+
if (subs.length === 0) return null;
|
|
593
|
+
|
|
594
|
+
// Short-circuit for single-sub: skip usage read and logging overhead
|
|
595
|
+
if (subs.length === 1) {
|
|
596
|
+
const s = subs[0];
|
|
597
|
+
const plan = s.plan || '$100';
|
|
598
|
+
const quotas = SUB_QUOTAS[provider]?.[plan] ?? { fiveHr: 1_000_000, weekly: 7_000_000 };
|
|
599
|
+
return {
|
|
600
|
+
subIndex: 0,
|
|
601
|
+
plan,
|
|
602
|
+
label: s.label ?? null,
|
|
603
|
+
fiveHrUsed: 0,
|
|
604
|
+
weeklyUsed: 0,
|
|
605
|
+
fiveHrQuota: quotas.fiveHr,
|
|
606
|
+
weeklyQuota: quotas.weekly,
|
|
607
|
+
fiveHrRemaining: quotas.fiveHr,
|
|
608
|
+
weeklyRemaining: quotas.weekly,
|
|
609
|
+
score: quotas.fiveHr,
|
|
610
|
+
reason: 'only sub available',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Multi-sub: read usage logs once for both windows
|
|
615
|
+
const fiveHrEntries = _readUsageInWindow(FIVE_HRS_MS);
|
|
616
|
+
const weeklyEntries = _readUsageInWindow(SEVEN_DAY_MS);
|
|
617
|
+
|
|
618
|
+
// We cannot distinguish sub-level usage from the log (no subIndex field),
|
|
619
|
+
// so we divide total provider usage evenly across subs as a best-effort proxy.
|
|
620
|
+
const fiveHrTotal = _sumProviderTokens(fiveHrEntries, provider);
|
|
621
|
+
const weeklyTotal = _sumProviderTokens(weeklyEntries, provider);
|
|
622
|
+
const perSubFiveHr = Math.round(fiveHrTotal / subs.length);
|
|
623
|
+
const perSubWeekly = Math.round(weeklyTotal / subs.length);
|
|
624
|
+
|
|
625
|
+
// Score each sub
|
|
626
|
+
const now = Date.now();
|
|
627
|
+
const fiveHrResetMs = FIVE_HRS_MS; // window always resets from now in a rolling sense
|
|
628
|
+
const weeklyResetMs = SEVEN_DAY_MS;
|
|
629
|
+
|
|
630
|
+
let best = null;
|
|
631
|
+
let bestScore = -Infinity;
|
|
632
|
+
const alternatives = [];
|
|
633
|
+
|
|
634
|
+
subs.forEach((s, i) => {
|
|
635
|
+
const plan = s.plan || '$100';
|
|
636
|
+
const quotas = SUB_QUOTAS[provider]?.[plan] ?? { fiveHr: 1_000_000, weekly: 7_000_000 };
|
|
637
|
+
|
|
638
|
+
const fiveHrRemaining = Math.max(0, quotas.fiveHr - perSubFiveHr);
|
|
639
|
+
const weeklyRemaining = Math.max(0, quotas.weekly - perSubWeekly);
|
|
640
|
+
|
|
641
|
+
// Binding constraint: whichever window is tighter
|
|
642
|
+
const remaining = Math.min(fiveHrRemaining, weeklyRemaining);
|
|
643
|
+
|
|
644
|
+
// Hours until the tighter window resets (rolling → effectively "now + window")
|
|
645
|
+
const hoursUntilReset = fiveHrRemaining <= weeklyRemaining
|
|
646
|
+
? (fiveHrResetMs / 3_600_000)
|
|
647
|
+
: (weeklyResetMs / 3_600_000);
|
|
648
|
+
|
|
649
|
+
// Use-it-or-lose-it: higher score = more remaining + resets sooner
|
|
650
|
+
const score = hoursUntilReset > 0 ? remaining * (1 / hoursUntilReset) : remaining;
|
|
651
|
+
|
|
652
|
+
const pctRemaining = quotas.fiveHr > 0
|
|
653
|
+
? Math.round((fiveHrRemaining / quotas.fiveHr) * 100)
|
|
654
|
+
: 100;
|
|
655
|
+
const resets = fiveHrRemaining <= weeklyRemaining ? '5h' : '7d';
|
|
656
|
+
const reason = `${pctRemaining}% remaining, resets in ${resets === '5h' ? 5 : 168}h`;
|
|
657
|
+
|
|
658
|
+
const info = {
|
|
659
|
+
subIndex: i,
|
|
660
|
+
plan,
|
|
661
|
+
label: s.label ?? null,
|
|
662
|
+
fiveHrUsed: perSubFiveHr,
|
|
663
|
+
weeklyUsed: perSubWeekly,
|
|
664
|
+
fiveHrQuota: quotas.fiveHr,
|
|
665
|
+
weeklyQuota: quotas.weekly,
|
|
666
|
+
fiveHrRemaining,
|
|
667
|
+
weeklyRemaining,
|
|
668
|
+
score,
|
|
669
|
+
reason,
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
alternatives.push(info);
|
|
673
|
+
|
|
674
|
+
if (score > bestScore) {
|
|
675
|
+
bestScore = score;
|
|
676
|
+
best = info;
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// Log the autopilot decision
|
|
681
|
+
if (best) {
|
|
682
|
+
_logAutopilot({
|
|
683
|
+
provider,
|
|
684
|
+
tier,
|
|
685
|
+
subIndex: best.subIndex,
|
|
686
|
+
plan: best.plan,
|
|
687
|
+
label: best.label,
|
|
688
|
+
reason: best.reason,
|
|
689
|
+
alternatives: alternatives.map(a => ({
|
|
690
|
+
subIndex: a.subIndex,
|
|
691
|
+
plan: a.plan,
|
|
692
|
+
label: a.label,
|
|
693
|
+
score: Math.round(a.score),
|
|
694
|
+
fiveHrRemaining: a.fiveHrRemaining,
|
|
695
|
+
weeklyRemaining: a.weeklyRemaining,
|
|
696
|
+
})),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return best;
|
|
701
|
+
}
|
|
702
|
+
|
|
473
703
|
// ─── Internal: safety floor for critical-risk tasks ───────────────────────────
|
|
474
704
|
|
|
475
705
|
/**
|
|
@@ -582,6 +812,9 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
|
|
|
582
812
|
);
|
|
583
813
|
const degradedDualBrain = !!(dual && detection.designImpact && !hasBothProviders);
|
|
584
814
|
|
|
815
|
+
// Budget autopilot: pick optimal sub when multiple subs exist for chosen provider
|
|
816
|
+
const optimalSub = getOptimalSub(provider, tier, profile);
|
|
817
|
+
|
|
585
818
|
const decision = {
|
|
586
819
|
provider,
|
|
587
820
|
model,
|
|
@@ -589,6 +822,8 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
|
|
|
589
822
|
tier,
|
|
590
823
|
dualBrain: dual,
|
|
591
824
|
...(degradedDualBrain && { degradedDualBrain: true }),
|
|
825
|
+
...(optimalSub && optimalSub.label != null && { subLabel: optimalSub.label }),
|
|
826
|
+
...(optimalSub && { subIndex: optimalSub.subIndex }),
|
|
592
827
|
modes,
|
|
593
828
|
sandbox,
|
|
594
829
|
explanation: '',
|
package/src/dispatch.mjs
CHANGED
|
@@ -644,6 +644,23 @@ function _prependDispatchMarker(prompt) {
|
|
|
644
644
|
return `<!-- dual-brain-dispatch: ${runId} -->\n${prompt}`;
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
+
// ─── Related session age label ────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Human-readable age label for a related session date string.
|
|
651
|
+
* @param {string} isoDate
|
|
652
|
+
* @returns {string}
|
|
653
|
+
*/
|
|
654
|
+
function _relatedSessionAge(isoDate) {
|
|
655
|
+
const diff = Date.now() - Date.parse(isoDate);
|
|
656
|
+
const mins = Math.floor(diff / 60000);
|
|
657
|
+
if (mins < 60) return `${mins}m ago`;
|
|
658
|
+
const hours = Math.floor(mins / 60);
|
|
659
|
+
if (hours < 24) return `${hours}h ago`;
|
|
660
|
+
const days = Math.floor(hours / 24);
|
|
661
|
+
return `${days}d ago`;
|
|
662
|
+
}
|
|
663
|
+
|
|
647
664
|
// ─── Main dispatch ────────────────────────────────────────────────────────────
|
|
648
665
|
async function dispatch(input = {}) {
|
|
649
666
|
const { files = [], cwd = process.cwd(), dryRun = false, verbose = false } = input;
|
|
@@ -655,6 +672,32 @@ async function dispatch(input = {}) {
|
|
|
655
672
|
// Safety gate: redact secrets before anything reaches a subprocess or log
|
|
656
673
|
prompt = redact(prompt);
|
|
657
674
|
|
|
675
|
+
// ── Related session context injection ────────────────────────────────────────
|
|
676
|
+
// Find past sessions related to this task and prepend a context block.
|
|
677
|
+
// Only injected when confidence is high (score > 5). Fast: index-only, no JSONL parsing.
|
|
678
|
+
if (!input._skipRelatedContext) {
|
|
679
|
+
try {
|
|
680
|
+
const { findRelatedSessions } = await import('./session.mjs');
|
|
681
|
+
const related = findRelatedSessions(prompt, files, cwd);
|
|
682
|
+
const highConfidence = related.filter(r => r.score > 5);
|
|
683
|
+
if (highConfidence.length > 0) {
|
|
684
|
+
const lines = highConfidence.map(r => {
|
|
685
|
+
const dateLabel = r.date ? _relatedSessionAge(r.date) : null;
|
|
686
|
+
const datePart = dateLabel ? `, ${dateLabel}` : '';
|
|
687
|
+
const msgPart = r.messageCount > 0 ? `, ${r.messageCount} messages` : '';
|
|
688
|
+
const fileList = r.matchedFiles.length > 0
|
|
689
|
+
? `: touched ${r.matchedFiles.map(f => f.split('/').pop()).join(', ')}`
|
|
690
|
+
: '';
|
|
691
|
+
return `- "${r.smartName}"${datePart}${msgPart}${fileList}`;
|
|
692
|
+
});
|
|
693
|
+
const contextBlock = `[Prior context from related sessions:]\n${lines.join('\n')}\n[End prior context]\n\n`;
|
|
694
|
+
prompt = contextBlock + prompt;
|
|
695
|
+
if (verbose) process.stderr.write(`[dual-brain] injected related session context (${highConfidence.length} sessions)\n`);
|
|
696
|
+
}
|
|
697
|
+
} catch { /* non-fatal — never block dispatch */ }
|
|
698
|
+
}
|
|
699
|
+
// ── End related session context ──────────────────────────────────────────────
|
|
700
|
+
|
|
658
701
|
// Stamp the prompt with the dispatch marker so enforce-tier.mjs can recognise
|
|
659
702
|
// that this agent call came through the official pipeline.
|
|
660
703
|
prompt = _prependDispatchMarker(prompt);
|
package/src/session.mjs
CHANGED
|
@@ -1207,6 +1207,112 @@ export function searchSessions(query, cwd = process.cwd()) {
|
|
|
1207
1207
|
return results.sort((a, b) => b._score - a._score);
|
|
1208
1208
|
}
|
|
1209
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
|
+
|
|
1210
1316
|
/**
|
|
1211
1317
|
* Get detailed context for a session (for smart resume preview).
|
|
1212
1318
|
* Reads the last 20 lines of the session JSONL to surface the most recent prompt
|