dual-brain 0.2.0 → 0.2.2

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/src/session.mjs CHANGED
@@ -216,6 +216,9 @@ export function formatSessionCard(session, repo, health, profile) {
216
216
 
217
217
  // ─── Replit-tools session import ──────────────────────────────────────────────
218
218
 
219
+ const ARCHIVE_BASE = '/home/runner/workspace/.replit-tools/.session-archive/claude';
220
+ const ARCHIVE_PROJECTS = `${ARCHIVE_BASE}/projects/-home-runner-workspace`;
221
+
219
222
  /**
220
223
  * Returns true if the text looks like a real user prompt (not a status line,
221
224
  * slash command, paste marker, or agent-generated noise).
@@ -231,9 +234,40 @@ function isRealPrompt(text) {
231
234
  if (t === 'login' || t === 'logout') return false;
232
235
  if (t.startsWith('/')) return false;
233
236
  if (t.startsWith('[Pasted')) return false;
237
+ if (t.startsWith('<')) return false;
238
+ if (t.startsWith('[Request interrupted')) return false;
234
239
  return true;
235
240
  }
236
241
 
242
+ /**
243
+ * Extract the text content from a user message entry.
244
+ * Handles string content and content-block arrays.
245
+ * @param {object} entry
246
+ * @returns {string}
247
+ */
248
+ function extractMessageText(entry) {
249
+ if (!entry) return '';
250
+ const content = entry.message?.content;
251
+ if (typeof content === 'string') return content;
252
+ if (Array.isArray(content)) return content.map(c => c.text || '').join(' ');
253
+ return '';
254
+ }
255
+
256
+ /**
257
+ * Compute recency multiplier: today=2x, this week=1.5x, older=1x
258
+ * @param {string|number} dateOrTs
259
+ * @returns {number}
260
+ */
261
+ function recencyMultiplier(dateOrTs) {
262
+ const ts = typeof dateOrTs === 'number' ? dateOrTs : Date.parse(dateOrTs);
263
+ if (!ts) return 1;
264
+ const age = Date.now() - ts;
265
+ const day = 86400000;
266
+ if (age < day) return 2;
267
+ if (age < 7 * day) return 1.5;
268
+ return 1;
269
+ }
270
+
237
271
  /**
238
272
  * Human-readable time-ago string from a Unix timestamp (ms).
239
273
  * @param {number} timestamp
@@ -1166,41 +1200,133 @@ export function buildSessionIndex(cwd = process.cwd()) {
1166
1200
  }
1167
1201
 
1168
1202
  /**
1169
- * Search the session index by keyword. Returns matching sessions sorted by relevance.
1203
+ * Search sessions using the replit-tools archive as primary source.
1204
+ * Falls back to the parallel session index when archive is unavailable.
1205
+ *
1206
+ * Results include: { sessionId, date, relevance, files, summary, matchingLines }
1207
+ * Sorted by relevance * recencyMultiplier descending.
1170
1208
  *
1171
1209
  * @param {string} query
1172
1210
  * @param {string} [cwd]
1173
1211
  * @returns {Array<object>} sessions with `_score` field, sorted descending
1174
1212
  */
1175
1213
  export function searchSessions(query, cwd = process.cwd()) {
1214
+ const terms = query.toLowerCase().split(/\W+/).filter(Boolean);
1215
+ if (!terms.length) return [];
1216
+
1217
+ // Try archive-backed search first
1218
+ const archiveResults = archiveBackedSearch(terms, cwd);
1219
+ if (archiveResults.length > 0) return archiveResults;
1220
+
1221
+ // Fallback: parallel index
1176
1222
  const indexPath = join(cwd, '.dualbrain', 'session-index.json');
1177
1223
  let index = {};
1178
1224
  try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
1225
+ if (Object.keys(index).length === 0) index = buildSessionIndex(cwd);
1179
1226
 
1180
- if (Object.keys(index).length === 0) {
1181
- index = buildSessionIndex(cwd);
1182
- }
1183
-
1184
- const terms = query.toLowerCase().split(/\W+/).filter(Boolean);
1185
1227
  const results = [];
1186
-
1187
1228
  for (const session of Object.values(index)) {
1188
1229
  let score = 0;
1189
1230
  const searchText = [
1190
- ...session.topics,
1191
- ...session.files,
1192
- session.prompts.first,
1193
- session.prompts.last,
1231
+ ...(session.topics || []),
1232
+ ...(session.files || []),
1233
+ session.prompts?.first || '',
1234
+ session.prompts?.last || '',
1194
1235
  ].join(' ').toLowerCase();
1195
1236
 
1196
1237
  for (const term of terms) {
1197
1238
  if (searchText.includes(term)) score++;
1198
- if (session.topics.includes(term)) score += 2;
1199
- if (session.files.some(f => f.includes(term))) score += 2;
1239
+ if ((session.topics || []).includes(term)) score += 2;
1240
+ if ((session.files || []).some(f => f.includes(term))) score += 2;
1200
1241
  }
1201
1242
 
1202
1243
  if (score > 0) {
1203
- results.push({ ...session, _score: score });
1244
+ const mult = recencyMultiplier(session.date);
1245
+ results.push({ ...session, _score: score * mult });
1246
+ }
1247
+ }
1248
+
1249
+ return results.sort((a, b) => b._score - a._score);
1250
+ }
1251
+
1252
+ /**
1253
+ * Search session JSONL files in the archive directly (streaming, no full load).
1254
+ * @param {string[]} terms
1255
+ * @param {string} cwd
1256
+ * @returns {Array<object>}
1257
+ */
1258
+ function archiveBackedSearch(terms, cwd) {
1259
+ const projectDir = existsSync(ARCHIVE_PROJECTS) ? ARCHIVE_PROJECTS
1260
+ : join(cwd, '.replit-tools', '.session-archive', 'claude', 'projects', '-home-runner-workspace');
1261
+ if (!existsSync(projectDir)) return [];
1262
+
1263
+ let files;
1264
+ try { files = readdirSync(projectDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')); }
1265
+ catch { return []; }
1266
+
1267
+ const results = [];
1268
+
1269
+ for (const file of files) {
1270
+ const sessionId = file.replace(/\.jsonl$/, '');
1271
+ const filePath = join(projectDir, file);
1272
+ let content;
1273
+ try { content = readFileSync(filePath, 'utf8'); } catch { continue; }
1274
+
1275
+ const lines = content.split('\n').filter(Boolean);
1276
+ const matchingLines = [];
1277
+ const fileSet = new Set();
1278
+ let firstPrompt = null;
1279
+ let lastTimestamp = 0;
1280
+ let messageCount = 0;
1281
+ let baseScore = 0;
1282
+
1283
+ for (const line of lines) {
1284
+ let entry;
1285
+ try { entry = JSON.parse(line); } catch { continue; }
1286
+
1287
+ // Track timestamps
1288
+ if (entry.timestamp) {
1289
+ const ts = typeof entry.timestamp === 'number'
1290
+ ? (entry.timestamp > 1e12 ? entry.timestamp : entry.timestamp * 1000)
1291
+ : Date.parse(entry.timestamp);
1292
+ if (ts > lastTimestamp) lastTimestamp = ts;
1293
+ }
1294
+
1295
+ if (entry.type !== 'user') continue;
1296
+ const text = extractMessageText(entry);
1297
+ if (!text) continue;
1298
+ messageCount++;
1299
+ if (!firstPrompt && isRealPrompt(text)) firstPrompt = text;
1300
+
1301
+ // Extract file references
1302
+ const filePaths = text.match(/[\w./~-]+\.(?:mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/g);
1303
+ if (filePaths) filePaths.forEach(p => fileSet.add(p));
1304
+
1305
+ // Score against terms
1306
+ const lower = text.toLowerCase();
1307
+ let lineScore = 0;
1308
+ for (const term of terms) {
1309
+ if (lower.includes(term)) lineScore++;
1310
+ }
1311
+ if (lineScore > 0) {
1312
+ baseScore += lineScore;
1313
+ const excerpt = text.slice(0, 500);
1314
+ matchingLines.push(excerpt);
1315
+ }
1316
+ }
1317
+
1318
+ if (baseScore > 0) {
1319
+ const mult = recencyMultiplier(lastTimestamp);
1320
+ results.push({
1321
+ sessionId,
1322
+ date: lastTimestamp ? new Date(lastTimestamp).toISOString() : null,
1323
+ relevance: baseScore,
1324
+ _score: baseScore * mult,
1325
+ files: [...fileSet].slice(0, 20),
1326
+ summary: (firstPrompt || sessionId).slice(0, 100),
1327
+ matchingLines: matchingLines.slice(0, 5),
1328
+ messageCount,
1329
+ });
1204
1330
  }
1205
1331
  }
1206
1332
 
@@ -1371,6 +1497,151 @@ export function getSessionContext(sessionId, cwd = process.cwd()) {
1371
1497
  } catch { return null; }
1372
1498
  }
1373
1499
 
1500
+ // ─── Archive-backed metadata extraction ──────────────────────────────────────
1501
+
1502
+ /**
1503
+ * Extract structured metadata from a session JSONL file.
1504
+ * Reads the file once; handles malformed entries gracefully.
1505
+ *
1506
+ * @param {string} sessionPath — absolute path to a .jsonl file
1507
+ * @returns {{ id, date, messageCount, files: string[], taskSummary, firstPrompt, lastPrompt, duration }}
1508
+ */
1509
+ export function extractSessionMeta(sessionPath) {
1510
+ const id = sessionPath.split('/').pop().replace(/\.jsonl$/, '');
1511
+ const result = { id, date: null, messageCount: 0, files: [], taskSummary: null, firstPrompt: null, lastPrompt: null, duration: null };
1512
+
1513
+ let content;
1514
+ try { content = readFileSync(sessionPath, 'utf8'); } catch { return result; }
1515
+
1516
+ const fileSet = new Set();
1517
+ let minTs = Infinity;
1518
+ let maxTs = 0;
1519
+
1520
+ for (const line of content.split('\n')) {
1521
+ if (!line) continue;
1522
+ let entry;
1523
+ try { entry = JSON.parse(line); } catch { continue; }
1524
+
1525
+ // Timestamps
1526
+ if (entry.timestamp) {
1527
+ const ts = typeof entry.timestamp === 'number'
1528
+ ? (entry.timestamp > 1e12 ? entry.timestamp : entry.timestamp * 1000)
1529
+ : Date.parse(entry.timestamp);
1530
+ if (ts && ts < minTs) minTs = ts;
1531
+ if (ts && ts > maxTs) maxTs = ts;
1532
+ }
1533
+
1534
+ if (entry.type !== 'user') continue;
1535
+ const text = extractMessageText(entry);
1536
+ if (!text || !text.trim()) continue;
1537
+
1538
+ result.messageCount++;
1539
+
1540
+ // File paths (src/, bin/, common extensions)
1541
+ const filePaths = text.match(/[\w./~-]+\.(?:mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/g);
1542
+ if (filePaths) filePaths.forEach(p => fileSet.add(p));
1543
+ // Also catch src/ or bin/ paths without extensions
1544
+ const dirPaths = text.match(/(?:src|bin|lib|test|tests|\.claude\/hooks)\/[\w./~-]+/g);
1545
+ if (dirPaths) dirPaths.forEach(p => fileSet.add(p));
1546
+
1547
+ if (isRealPrompt(text)) {
1548
+ if (!result.firstPrompt) {
1549
+ result.firstPrompt = text.slice(0, 100);
1550
+ result.taskSummary = text.slice(0, 100);
1551
+ }
1552
+ result.lastPrompt = text.slice(0, 100);
1553
+ }
1554
+ }
1555
+
1556
+ result.files = [...fileSet].slice(0, 30);
1557
+ if (maxTs) result.date = new Date(maxTs).toISOString();
1558
+ if (minTs !== Infinity && maxTs) result.duration = Math.round((maxTs - minTs) / 1000); // seconds
1559
+
1560
+ return result;
1561
+ }
1562
+
1563
+ // ─── Routing context from session history ────────────────────────────────────
1564
+
1565
+ /**
1566
+ * Build routing context from recent sessions (last 7 days) related to a task.
1567
+ * Used by the dispatch pipeline to detect prior attempts and flag risk signals.
1568
+ *
1569
+ * @param {string} cwd
1570
+ * @param {string} taskDescription
1571
+ * @returns {{ relatedSessions: [], riskSignals: [], priorAttempts: [], relevantFiles: [] }}
1572
+ */
1573
+ export function getRoutingContext(cwd, taskDescription) {
1574
+ const result = { relatedSessions: [], riskSignals: [], priorAttempts: [], relevantFiles: [] };
1575
+ const projectDir = existsSync(ARCHIVE_PROJECTS) ? ARCHIVE_PROJECTS
1576
+ : join(cwd, '.replit-tools', '.session-archive', 'claude', 'projects', '-home-runner-workspace');
1577
+ if (!existsSync(projectDir)) return result;
1578
+
1579
+ let files;
1580
+ try { files = readdirSync(projectDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')); }
1581
+ catch { return result; }
1582
+
1583
+ const taskLower = (taskDescription || '').toLowerCase();
1584
+ const taskTerms = taskLower.split(/\W+/).filter(w => w.length > 3);
1585
+ const sevenDaysAgo = Date.now() - 7 * 86400000;
1586
+ const fileSet = new Set();
1587
+
1588
+ for (const file of files) {
1589
+ const filePath = join(projectDir, file);
1590
+ let meta;
1591
+ try { meta = extractSessionMeta(filePath); } catch { continue; }
1592
+
1593
+ // Only consider last 7 days
1594
+ if (!meta.date || Date.parse(meta.date) < sevenDaysAgo) continue;
1595
+
1596
+ // Score relevance to task
1597
+ const sessionText = [meta.firstPrompt || '', meta.lastPrompt || '', ...meta.files].join(' ').toLowerCase();
1598
+ let score = 0;
1599
+ for (const term of taskTerms) {
1600
+ if (sessionText.includes(term)) score++;
1601
+ }
1602
+
1603
+ if (score === 0) continue;
1604
+
1605
+ // Collect relevant files
1606
+ meta.files.forEach(f => fileSet.add(f));
1607
+
1608
+ const sessionEntry = {
1609
+ sessionId: meta.id,
1610
+ date: meta.date,
1611
+ taskSummary: meta.taskSummary,
1612
+ score,
1613
+ messageCount: meta.messageCount,
1614
+ files: meta.files,
1615
+ };
1616
+
1617
+ result.relatedSessions.push(sessionEntry);
1618
+
1619
+ // Detect prior attempts: same task keywords, short session (< 5 min or few messages)
1620
+ if (score >= 2 && (meta.duration < 300 || meta.messageCount < 3)) {
1621
+ result.priorAttempts.push({
1622
+ sessionId: meta.id,
1623
+ date: meta.date,
1624
+ summary: meta.taskSummary,
1625
+ likelyIncomplete: true,
1626
+ });
1627
+ result.riskSignals.push(`Prior attempt on similar task may have stalled (session ${meta.id.slice(0, 8)})`);
1628
+ }
1629
+
1630
+ // Risk signal: auth/security keywords in related sessions
1631
+ if (/auth|secret|token|credential|password/.test(sessionText)) {
1632
+ result.riskSignals.push(`Related session ${meta.id.slice(0, 8)} touched auth/security code`);
1633
+ }
1634
+ }
1635
+
1636
+ // Deduplicate risk signals
1637
+ result.riskSignals = [...new Set(result.riskSignals)];
1638
+ result.relevantFiles = [...fileSet].slice(0, 20);
1639
+ result.relatedSessions.sort((a, b) => b.score - a.score);
1640
+ result.relatedSessions = result.relatedSessions.slice(0, 5);
1641
+
1642
+ return result;
1643
+ }
1644
+
1374
1645
  // ─── CLI (direct invocation) ──────────────────────────────────────────────────
1375
1646
 
1376
1647
  const isMain = process.argv[1]?.endsWith('session.mjs');