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/bin/dual-brain.mjs +56 -0
- package/package.json +4 -2
- package/src/awareness.mjs +17 -0
- package/src/decide.mjs +46 -2
- package/src/detect.mjs +119 -4
- package/src/dispatch.mjs +21 -2
- package/src/doctor.mjs +316 -1
- package/src/health.mjs +82 -0
- package/src/index.mjs +1 -1
- package/src/intelligence.mjs +25 -1
- package/src/pipeline.mjs +70 -6
- package/src/profile.mjs +28 -0
- package/src/replit.mjs +1210 -0
- package/src/session.mjs +285 -14
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
|
|
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
|
|
1193
|
-
session.prompts
|
|
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
|
-
|
|
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');
|