dual-brain 0.2.30 → 0.3.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.
- package/.dual-brain/docs/claude-code-extension-points.md +32 -0
- package/.dual-brain/docs/data-tools-capabilities.md +181 -0
- package/.dual-brain/docs/ecosystem-tools.md +91 -0
- package/.dual-brain/docs/panel-handoff.md +124 -0
- package/.dual-brain/docs/ruflo-analysis.md +48 -0
- package/bin/dual-brain.mjs +56 -56
- package/dist/mcp-server/index.d.ts +27 -0
- package/dist/mcp-server/index.js +359 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/agent-protocol.d.ts +163 -0
- package/dist/src/agent-protocol.js +368 -0
- package/dist/src/agent-protocol.js.map +1 -0
- package/dist/src/agents/registry.d.ts +52 -0
- package/dist/src/agents/registry.js +393 -0
- package/dist/src/agents/registry.js.map +1 -0
- package/dist/src/awareness.d.ts +93 -0
- package/dist/src/awareness.js +406 -0
- package/dist/src/awareness.js.map +1 -0
- package/dist/src/brief.d.ts +48 -0
- package/dist/src/brief.js +179 -0
- package/dist/src/brief.js.map +1 -0
- package/dist/src/calibration.d.ts +32 -0
- package/dist/src/calibration.js +133 -0
- package/dist/src/calibration.js.map +1 -0
- package/dist/src/checkpoint.d.ts +33 -0
- package/dist/src/checkpoint.js +99 -0
- package/dist/src/checkpoint.js.map +1 -0
- package/dist/src/ci-triage.d.ts +33 -0
- package/dist/src/ci-triage.js +193 -0
- package/dist/src/ci-triage.js.map +1 -0
- package/dist/src/cognitive-loop.d.ts +56 -0
- package/dist/src/cognitive-loop.js +495 -0
- package/dist/src/cognitive-loop.js.map +1 -0
- package/dist/src/collaboration.d.ts +147 -0
- package/dist/src/collaboration.js +438 -0
- package/dist/src/collaboration.js.map +1 -0
- package/dist/src/context-intel.d.ts +47 -0
- package/dist/src/context-intel.js +156 -0
- package/dist/src/context-intel.js.map +1 -0
- package/dist/src/context.d.ts +53 -0
- package/dist/src/context.js +332 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/continuity.d.ts +89 -0
- package/dist/src/continuity.js +230 -0
- package/dist/src/continuity.js.map +1 -0
- package/dist/src/cost-tracker.d.ts +47 -0
- package/dist/src/cost-tracker.js +170 -0
- package/dist/src/cost-tracker.js.map +1 -0
- package/dist/src/debrief.d.ts +53 -0
- package/dist/src/debrief.js +222 -0
- package/dist/src/debrief.js.map +1 -0
- package/dist/src/decide.d.ts +96 -0
- package/dist/src/decide.js +744 -0
- package/dist/src/decide.js.map +1 -0
- package/dist/src/decompose.d.ts +39 -0
- package/dist/src/decompose.js +218 -0
- package/dist/src/decompose.js.map +1 -0
- package/dist/src/detect.d.ts +91 -0
- package/dist/src/detect.js +544 -0
- package/dist/src/detect.js.map +1 -0
- package/dist/src/dispatch.d.ts +154 -0
- package/dist/src/dispatch.js +1306 -0
- package/dist/src/dispatch.js.map +1 -0
- package/dist/src/doctor.d.ts +421 -0
- package/dist/src/doctor.js +1689 -0
- package/dist/src/doctor.js.map +1 -0
- package/dist/src/engine.d.ts +70 -0
- package/dist/src/engine.js +155 -0
- package/dist/src/engine.js.map +1 -0
- package/dist/src/envelope.d.ts +36 -0
- package/dist/src/envelope.js +80 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/failure-memory.d.ts +55 -0
- package/dist/src/failure-memory.js +175 -0
- package/dist/src/failure-memory.js.map +1 -0
- package/dist/src/fx.d.ts +87 -0
- package/dist/src/fx.js +272 -0
- package/dist/src/fx.js.map +1 -0
- package/dist/src/governance.d.ts +93 -0
- package/dist/src/governance.js +261 -0
- package/dist/src/governance.js.map +1 -0
- package/dist/src/handoff.d.ts +11 -0
- package/dist/src/handoff.js +90 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/head-protocol.d.ts +76 -0
- package/dist/src/head-protocol.js +109 -0
- package/dist/src/head-protocol.js.map +1 -0
- package/dist/src/head.d.ts +222 -0
- package/dist/src/head.js +765 -0
- package/dist/src/head.js.map +1 -0
- package/dist/src/health.d.ts +132 -0
- package/dist/src/health.js +435 -0
- package/dist/src/health.js.map +1 -0
- package/dist/src/inbox.d.ts +70 -0
- package/dist/src/inbox.js +218 -0
- package/dist/src/inbox.js.map +1 -0
- package/dist/src/index.d.ts +33 -0
- package/dist/src/index.js +38 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/install-hooks.d.ts +13 -0
- package/dist/src/install-hooks.js +88 -0
- package/dist/src/install-hooks.js.map +1 -0
- package/dist/src/integrity.d.ts +59 -0
- package/dist/src/integrity.js +206 -0
- package/dist/src/integrity.js.map +1 -0
- package/dist/src/intelligence.d.ts +104 -0
- package/dist/src/intelligence.js +391 -0
- package/dist/src/intelligence.js.map +1 -0
- package/dist/src/ledger.d.ts +54 -0
- package/dist/src/ledger.js +179 -0
- package/dist/src/ledger.js.map +1 -0
- package/dist/src/living-docs.d.ts +14 -0
- package/dist/src/living-docs.js +197 -0
- package/dist/src/living-docs.js.map +1 -0
- package/dist/src/memory-tiers.d.ts +37 -0
- package/dist/src/memory-tiers.js +160 -0
- package/dist/src/memory-tiers.js.map +1 -0
- package/dist/src/model-profiles.d.ts +65 -0
- package/dist/src/model-profiles.js +568 -0
- package/dist/src/model-profiles.js.map +1 -0
- package/dist/src/models.d.ts +58 -0
- package/dist/src/models.js +327 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/narrative.d.ts +54 -0
- package/dist/src/narrative.js +163 -0
- package/dist/src/narrative.js.map +1 -0
- package/dist/src/nextstep.d.ts +16 -0
- package/dist/src/nextstep.js +103 -0
- package/dist/src/nextstep.js.map +1 -0
- package/dist/src/observer.d.ts +18 -0
- package/dist/src/observer.js +251 -0
- package/dist/src/observer.js.map +1 -0
- package/dist/src/outcome.d.ts +110 -0
- package/dist/src/outcome.js +377 -0
- package/dist/src/outcome.js.map +1 -0
- package/dist/src/pipeline.d.ts +167 -0
- package/dist/src/pipeline.js +1503 -0
- package/dist/src/pipeline.js.map +1 -0
- package/dist/src/playbook.d.ts +59 -0
- package/dist/src/playbook.js +238 -0
- package/dist/src/playbook.js.map +1 -0
- package/dist/src/pr-agent.d.ts +97 -0
- package/dist/src/pr-agent.js +195 -0
- package/dist/src/pr-agent.js.map +1 -0
- package/dist/src/predictive.d.ts +57 -0
- package/dist/src/predictive.js +230 -0
- package/dist/src/predictive.js.map +1 -0
- package/dist/src/profile.d.ts +294 -0
- package/dist/src/profile.js +1347 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/prompt-audit.d.ts +22 -0
- package/dist/src/prompt-audit.js +194 -0
- package/dist/src/prompt-audit.js.map +1 -0
- package/dist/src/prompt-intel.d.ts +12 -0
- package/dist/src/prompt-intel.js +321 -0
- package/dist/src/prompt-intel.js.map +1 -0
- package/dist/src/provider-context.d.ts +121 -0
- package/dist/src/provider-context.js +222 -0
- package/dist/src/provider-context.js.map +1 -0
- package/dist/src/provider-manager.d.ts +92 -0
- package/dist/src/provider-manager.js +428 -0
- package/dist/src/provider-manager.js.map +1 -0
- package/dist/src/receipt.d.ts +87 -0
- package/dist/src/receipt.js +326 -0
- package/dist/src/receipt.js.map +1 -0
- package/dist/src/recommendations.d.ts +13 -0
- package/dist/src/recommendations.js +291 -0
- package/dist/src/recommendations.js.map +1 -0
- package/dist/src/redact.d.ts +15 -0
- package/dist/src/redact.js +129 -0
- package/dist/src/redact.js.map +1 -0
- package/dist/src/replit.d.ts +397 -0
- package/dist/src/replit.js +1160 -0
- package/dist/src/replit.js.map +1 -0
- package/dist/src/repo.d.ts +149 -0
- package/dist/src/repo.js +416 -0
- package/dist/src/repo.js.map +1 -0
- package/dist/src/revert.d.ts +30 -0
- package/dist/src/revert.js +166 -0
- package/dist/src/revert.js.map +1 -0
- package/dist/src/room.d.ts +102 -0
- package/dist/src/room.js +212 -0
- package/dist/src/room.js.map +1 -0
- package/dist/src/routing-advisor.d.ts +57 -0
- package/dist/src/routing-advisor.js +221 -0
- package/dist/src/routing-advisor.js.map +1 -0
- package/dist/src/self-correct.d.ts +40 -0
- package/dist/src/self-correct.js +137 -0
- package/dist/src/self-correct.js.map +1 -0
- package/dist/src/session-lock.d.ts +35 -0
- package/dist/src/session-lock.js +134 -0
- package/dist/src/session-lock.js.map +1 -0
- package/dist/src/session.d.ts +267 -0
- package/dist/src/session.js +1660 -0
- package/dist/src/session.js.map +1 -0
- package/dist/src/settings-tui.d.ts +5 -0
- package/dist/src/settings-tui.js +422 -0
- package/dist/src/settings-tui.js.map +1 -0
- package/dist/src/setup-flow.d.ts +63 -0
- package/dist/src/setup-flow.js +233 -0
- package/dist/src/setup-flow.js.map +1 -0
- package/dist/src/signal.d.ts +19 -0
- package/dist/src/signal.js +122 -0
- package/dist/src/signal.js.map +1 -0
- package/dist/src/simmer.d.ts +85 -0
- package/dist/src/simmer.js +224 -0
- package/dist/src/simmer.js.map +1 -0
- package/dist/src/state-export.d.ts +129 -0
- package/dist/src/state-export.js +233 -0
- package/dist/src/state-export.js.map +1 -0
- package/dist/src/strategy.d.ts +54 -0
- package/dist/src/strategy.js +95 -0
- package/dist/src/strategy.js.map +1 -0
- package/dist/src/subscription.d.ts +40 -0
- package/dist/src/subscription.js +189 -0
- package/dist/src/subscription.js.map +1 -0
- package/dist/src/templates.d.ts +208 -0
- package/dist/src/templates.js +238 -0
- package/dist/src/templates.js.map +1 -0
- package/dist/src/test.d.ts +9 -0
- package/dist/src/test.js +1173 -0
- package/dist/src/test.js.map +1 -0
- package/dist/src/think-engine.d.ts +67 -0
- package/dist/src/think-engine.js +412 -0
- package/dist/src/think-engine.js.map +1 -0
- package/dist/src/tui.d.ts +71 -0
- package/dist/src/tui.js +242 -0
- package/dist/src/tui.js.map +1 -0
- package/dist/src/types.d.ts +177 -0
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/update-check.d.ts +7 -0
- package/dist/src/update-check.js +36 -0
- package/dist/src/update-check.js.map +1 -0
- package/dist/src/wave-planner.d.ts +30 -0
- package/dist/src/wave-planner.js +281 -0
- package/dist/src/wave-planner.js.map +1 -0
- package/hooks/head-guard.sh +41 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/vibe-router.mjs +387 -0
- package/package.json +29 -153
- package/src/agents/registry.mjs +0 -405
- package/src/awareness.mjs +0 -425
- package/src/brief.mjs +0 -266
- package/src/calibration.mjs +0 -148
- package/src/checkpoint.mjs +0 -109
- package/src/ci-triage.mjs +0 -191
- package/src/cognitive-loop.mjs +0 -562
- package/src/collaboration.mjs +0 -545
- package/src/context-intel.mjs +0 -158
- package/src/context.mjs +0 -389
- package/src/continuity.mjs +0 -298
- package/src/cost-tracker.mjs +0 -184
- package/src/debrief.mjs +0 -228
- package/src/decide.mjs +0 -1099
- package/src/decompose.mjs +0 -331
- package/src/detect.mjs +0 -702
- package/src/dispatch.mjs +0 -1447
- package/src/doctor.mjs +0 -1607
- package/src/envelope.mjs +0 -139
- package/src/failure-memory.mjs +0 -178
- package/src/fx.mjs +0 -276
- package/src/governance.mjs +0 -279
- package/src/handoff.mjs +0 -87
- package/src/head-protocol.mjs +0 -128
- package/src/head.mjs +0 -952
- package/src/health.mjs +0 -528
- package/src/inbox.mjs +0 -195
- package/src/index.mjs +0 -44
- package/src/install-hooks.mjs +0 -100
- package/src/integrity.mjs +0 -245
- package/src/intelligence.mjs +0 -447
- package/src/ledger.mjs +0 -196
- package/src/living-docs.mjs +0 -210
- package/src/memory-tiers.mjs +0 -193
- package/src/models.mjs +0 -363
- package/src/narrative.mjs +0 -169
- package/src/nextstep.mjs +0 -100
- package/src/observer.mjs +0 -241
- package/src/outcome.mjs +0 -400
- package/src/pipeline.mjs +0 -1711
- package/src/playbook.mjs +0 -257
- package/src/pr-agent.mjs +0 -214
- package/src/predictive.mjs +0 -250
- package/src/profile.mjs +0 -1411
- package/src/prompt-audit.mjs +0 -231
- package/src/prompt-intel.mjs +0 -325
- package/src/provider-context.mjs +0 -257
- package/src/receipt.mjs +0 -344
- package/src/recommendations.mjs +0 -296
- package/src/redact.mjs +0 -192
- package/src/replit.mjs +0 -1210
- package/src/repo.mjs +0 -445
- package/src/revert.mjs +0 -149
- package/src/routing-advisor.mjs +0 -204
- package/src/self-correct.mjs +0 -147
- package/src/session-lock.mjs +0 -160
- package/src/session.mjs +0 -1655
- package/src/settings-tui.mjs +0 -373
- package/src/setup-flow.mjs +0 -223
- package/src/signal.mjs +0 -115
- package/src/simmer.mjs +0 -241
- package/src/strategy.mjs +0 -235
- package/src/subscription.mjs +0 -212
- package/src/templates.mjs +0 -260
- package/src/think-engine.mjs +0 -428
- package/src/tui.mjs +0 -276
- package/src/update-check.mjs +0 -35
- package/src/wave-planner.mjs +0 -294
package/src/session.mjs
DELETED
|
@@ -1,1655 +0,0 @@
|
|
|
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
|
-
const ARCHIVE_BASE = '/home/runner/workspace/.replit-tools/.session-archive/claude';
|
|
220
|
-
const ARCHIVE_PROJECTS = `${ARCHIVE_BASE}/projects/-home-runner-workspace`;
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Returns true if the text looks like a real user prompt (not a status line,
|
|
224
|
-
* slash command, paste marker, or agent-generated noise).
|
|
225
|
-
* @param {string} text
|
|
226
|
-
* @returns {boolean}
|
|
227
|
-
*/
|
|
228
|
-
function isRealPrompt(text) {
|
|
229
|
-
if (!text || !text.trim()) return false;
|
|
230
|
-
const t = text.trim();
|
|
231
|
-
if (/^[✅❌📦🔗⚠️🚀🎉🔧📝]/.test(t)) return false;
|
|
232
|
-
if (/Claude (history|binary|versions) symlink/.test(t)) return false;
|
|
233
|
-
if (t.startsWith('# AGENTS.md')) return false;
|
|
234
|
-
if (t === 'login' || t === 'logout') return false;
|
|
235
|
-
if (t.startsWith('/')) return false;
|
|
236
|
-
if (t.startsWith('[Pasted')) return false;
|
|
237
|
-
if (t.startsWith('<')) return false;
|
|
238
|
-
if (t.startsWith('[Request interrupted')) return false;
|
|
239
|
-
return true;
|
|
240
|
-
}
|
|
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
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Human-readable time-ago string from a Unix timestamp (ms).
|
|
273
|
-
* @param {number} timestamp
|
|
274
|
-
* @returns {string}
|
|
275
|
-
*/
|
|
276
|
-
function timeAgo(timestamp) {
|
|
277
|
-
const diff = Date.now() - timestamp;
|
|
278
|
-
const mins = Math.floor(diff / 60000);
|
|
279
|
-
if (mins < 1) return 'just now';
|
|
280
|
-
if (mins < 60) return `${mins}m ago`;
|
|
281
|
-
const hours = Math.floor(mins / 60);
|
|
282
|
-
if (hours < 24) return `${hours}h ago`;
|
|
283
|
-
const days = Math.floor(hours / 24);
|
|
284
|
-
return `${days}d ago`;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Import sessions from replit-tools history.jsonl.
|
|
289
|
-
* Returns an array of session summary objects, sorted most-recent first.
|
|
290
|
-
* Returns [] gracefully if replit-tools is not present.
|
|
291
|
-
*
|
|
292
|
-
* @param {string} cwd
|
|
293
|
-
* @returns {Array<{
|
|
294
|
-
* id: string, name: string, project: string,
|
|
295
|
-
* promptCount: number, lastActive: string,
|
|
296
|
-
* isActive: boolean, source: string, age: string
|
|
297
|
-
* }>}
|
|
298
|
-
*/
|
|
299
|
-
export function importReplitSessions(cwd = process.cwd()) {
|
|
300
|
-
const sessions = [];
|
|
301
|
-
|
|
302
|
-
// Check multiple possible locations for replit-tools
|
|
303
|
-
const candidates = [
|
|
304
|
-
join(cwd, '.replit-tools', '.claude-persistent'),
|
|
305
|
-
join('/home/runner/workspace', '.replit-tools', '.claude-persistent'),
|
|
306
|
-
];
|
|
307
|
-
// Deduplicate
|
|
308
|
-
const seen = new Set();
|
|
309
|
-
const replitBases = candidates.filter(p => {
|
|
310
|
-
const norm = p.replace(/\/+$/, '');
|
|
311
|
-
if (seen.has(norm)) return false;
|
|
312
|
-
seen.add(norm);
|
|
313
|
-
return true;
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
let replitBase = null;
|
|
317
|
-
for (const candidate of replitBases) {
|
|
318
|
-
if (existsSync(join(candidate, 'history.jsonl'))) {
|
|
319
|
-
replitBase = candidate;
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
if (!replitBase) return sessions;
|
|
324
|
-
|
|
325
|
-
// Read history.jsonl
|
|
326
|
-
const historyPath = join(replitBase, 'history.jsonl');
|
|
327
|
-
|
|
328
|
-
let lines;
|
|
329
|
-
try {
|
|
330
|
-
lines = readFileSync(historyPath, 'utf8').split('\n').filter(Boolean);
|
|
331
|
-
} catch { return sessions; }
|
|
332
|
-
|
|
333
|
-
const bySession = new Map(); // sessionId → { entries, firstPrompt, lastTimestamp }
|
|
334
|
-
|
|
335
|
-
for (const line of lines) {
|
|
336
|
-
try {
|
|
337
|
-
const entry = JSON.parse(line);
|
|
338
|
-
if (!entry.sessionId) continue;
|
|
339
|
-
|
|
340
|
-
if (!bySession.has(entry.sessionId)) {
|
|
341
|
-
bySession.set(entry.sessionId, {
|
|
342
|
-
sessionId: entry.sessionId,
|
|
343
|
-
project: entry.project,
|
|
344
|
-
entries: [],
|
|
345
|
-
firstPrompt: null,
|
|
346
|
-
lastTimestamp: 0,
|
|
347
|
-
});
|
|
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
|
-
|
|
354
|
-
// Find first meaningful user prompt
|
|
355
|
-
if (!sess.firstPrompt && isRealPrompt(entry.display)) {
|
|
356
|
-
sess.firstPrompt = entry.display;
|
|
357
|
-
}
|
|
358
|
-
} catch { continue; }
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Also read from the session archive as a fallback (contains cleaned-up sessions)
|
|
362
|
-
const archivePath = join(cwd, '.replit-tools', '.session-archive', 'claude', 'history.jsonl');
|
|
363
|
-
let archiveLines = [];
|
|
364
|
-
try {
|
|
365
|
-
if (existsSync(archivePath)) {
|
|
366
|
-
archiveLines = readFileSync(archivePath, 'utf8').split('\n').filter(Boolean);
|
|
367
|
-
}
|
|
368
|
-
} catch { /* non-fatal */ }
|
|
369
|
-
|
|
370
|
-
for (const line of archiveLines) {
|
|
371
|
-
try {
|
|
372
|
-
const entry = JSON.parse(line);
|
|
373
|
-
if (!entry.sessionId) continue;
|
|
374
|
-
if (bySession.has(entry.sessionId)) continue; // already indexed from main history
|
|
375
|
-
|
|
376
|
-
bySession.set(entry.sessionId, {
|
|
377
|
-
sessionId: entry.sessionId,
|
|
378
|
-
project: entry.project,
|
|
379
|
-
entries: [],
|
|
380
|
-
firstPrompt: null,
|
|
381
|
-
lastTimestamp: 0,
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
const sess = bySession.get(entry.sessionId);
|
|
385
|
-
sess.entries.push(entry);
|
|
386
|
-
if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
|
|
387
|
-
if (!sess.firstPrompt && isRealPrompt(entry.display)) {
|
|
388
|
-
sess.firstPrompt = entry.display;
|
|
389
|
-
}
|
|
390
|
-
} catch { continue; }
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// For archive sessions with multiple entries, finish accumulating them
|
|
394
|
-
// (second pass for sessions newly added from archive)
|
|
395
|
-
for (const line of archiveLines) {
|
|
396
|
-
try {
|
|
397
|
-
const entry = JSON.parse(line);
|
|
398
|
-
if (!entry.sessionId) continue;
|
|
399
|
-
const sess = bySession.get(entry.sessionId);
|
|
400
|
-
if (!sess) continue;
|
|
401
|
-
// Already pushed in first pass for new sessions; skip double-push
|
|
402
|
-
if (sess.entries.includes(entry)) continue;
|
|
403
|
-
sess.entries.push(entry);
|
|
404
|
-
if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
|
|
405
|
-
if (!sess.firstPrompt && isRealPrompt(entry.display)) {
|
|
406
|
-
sess.firstPrompt = entry.display;
|
|
407
|
-
}
|
|
408
|
-
} catch { continue; }
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Scan ~/.codex/sessions/ for codex session JSONLs (YYYY/MM/DD tree)
|
|
412
|
-
const codexSessionsDir = join(process.env.HOME || '/root', '.codex', 'sessions');
|
|
413
|
-
if (existsSync(codexSessionsDir)) {
|
|
414
|
-
try {
|
|
415
|
-
const walk = (dir) => {
|
|
416
|
-
let results = [];
|
|
417
|
-
try {
|
|
418
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
419
|
-
const full = join(dir, entry.name);
|
|
420
|
-
if (entry.isDirectory()) results = results.concat(walk(full));
|
|
421
|
-
else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(full);
|
|
422
|
-
}
|
|
423
|
-
} catch {}
|
|
424
|
-
return results;
|
|
425
|
-
};
|
|
426
|
-
|
|
427
|
-
for (const f of walk(codexSessionsDir)) {
|
|
428
|
-
try {
|
|
429
|
-
const content = readFileSync(f, 'utf8');
|
|
430
|
-
const lines = content.split('\n').filter(Boolean);
|
|
431
|
-
if (!lines.length) continue;
|
|
432
|
-
|
|
433
|
-
const meta = JSON.parse(lines[0]);
|
|
434
|
-
if (meta.type !== 'session_meta' || !meta.payload) continue;
|
|
435
|
-
if (meta.payload.cwd !== cwd && meta.payload.cwd !== '/home/runner/workspace') continue;
|
|
436
|
-
|
|
437
|
-
const id = meta.payload.id;
|
|
438
|
-
if (bySession.has(id)) continue;
|
|
439
|
-
|
|
440
|
-
let firstPrompt = null;
|
|
441
|
-
let lastTimestamp = Date.parse(meta.payload.timestamp || meta.timestamp) / 1000;
|
|
442
|
-
|
|
443
|
-
for (const ln of lines) {
|
|
444
|
-
try {
|
|
445
|
-
const j = JSON.parse(ln);
|
|
446
|
-
if (j.timestamp) {
|
|
447
|
-
const ts = Date.parse(j.timestamp) / 1000;
|
|
448
|
-
if (ts > lastTimestamp) lastTimestamp = ts;
|
|
449
|
-
}
|
|
450
|
-
if (!firstPrompt && j.type === 'event_msg' && j.payload?.type === 'user_message') {
|
|
451
|
-
const text = (j.payload.message || '').trim();
|
|
452
|
-
if (text) firstPrompt = text;
|
|
453
|
-
}
|
|
454
|
-
} catch { continue; }
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
bySession.set(id, {
|
|
458
|
-
sessionId: id,
|
|
459
|
-
project: '-home-runner-workspace',
|
|
460
|
-
entries: [],
|
|
461
|
-
firstPrompt: firstPrompt || id.slice(0, 8) + '...',
|
|
462
|
-
lastTimestamp,
|
|
463
|
-
tool: 'codex',
|
|
464
|
-
});
|
|
465
|
-
} catch { continue; }
|
|
466
|
-
}
|
|
467
|
-
} catch { /* non-fatal */ }
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Read active terminal sessions
|
|
471
|
-
// Use the same root as replitBase (go up one level from .claude-persistent)
|
|
472
|
-
const replitRoot = join(replitBase, '..');
|
|
473
|
-
const sessionsDir = join(replitRoot, '..', '.claude-sessions');
|
|
474
|
-
const activeSessionIds = new Set();
|
|
475
|
-
if (existsSync(sessionsDir)) {
|
|
476
|
-
try {
|
|
477
|
-
for (const f of readdirSync(sessionsDir)) {
|
|
478
|
-
try {
|
|
479
|
-
const data = JSON.parse(readFileSync(join(sessionsDir, f), 'utf8'));
|
|
480
|
-
if (data.sessionId) activeSessionIds.add(data.sessionId);
|
|
481
|
-
} catch { continue; }
|
|
482
|
-
}
|
|
483
|
-
} catch { /* non-fatal */ }
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Determine recency window from config (default 48 hours)
|
|
487
|
-
const configPath = join(cwd, '.replit-tools', 'config.json');
|
|
488
|
-
let windowHours = 48;
|
|
489
|
-
try {
|
|
490
|
-
if (existsSync(configPath)) {
|
|
491
|
-
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
492
|
-
windowHours = cfg.recentWindowHours || 48;
|
|
493
|
-
}
|
|
494
|
-
} catch { /* non-fatal */ }
|
|
495
|
-
const windowMs = windowHours * 60 * 60 * 1000;
|
|
496
|
-
const cutoff = Date.now() - windowMs;
|
|
497
|
-
|
|
498
|
-
// Load existing session index for smartName lookup (best-effort, non-fatal)
|
|
499
|
-
let sessionIndex = {};
|
|
500
|
-
try {
|
|
501
|
-
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
502
|
-
if (existsSync(indexPath)) {
|
|
503
|
-
sessionIndex = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
504
|
-
}
|
|
505
|
-
} catch { /* non-fatal */ }
|
|
506
|
-
|
|
507
|
-
// Build session list
|
|
508
|
-
for (const [id, sess] of bySession) {
|
|
509
|
-
// Skip sessions outside the recency window (timestamps are in ms)
|
|
510
|
-
if (sess.lastTimestamp < cutoff) continue;
|
|
511
|
-
|
|
512
|
-
// Use smartName from index if available, otherwise fall back to first prompt
|
|
513
|
-
let name = sessionIndex[id]?.smartName || null;
|
|
514
|
-
|
|
515
|
-
if (!name) {
|
|
516
|
-
// Classic fallback: first meaningful prompt
|
|
517
|
-
name = sess.firstPrompt;
|
|
518
|
-
if (!name) {
|
|
519
|
-
const firstReal = sess.entries.find(e => e.display && e.display !== 'login');
|
|
520
|
-
name = firstReal?.display || `Session ${id.slice(0, 8)}`;
|
|
521
|
-
}
|
|
522
|
-
// Truncate long names that came from raw prompts
|
|
523
|
-
if (name.length > 60) name = name.slice(0, 57) + '...';
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
sessions.push({
|
|
527
|
-
id: sess.sessionId,
|
|
528
|
-
name,
|
|
529
|
-
smartName: sessionIndex[id]?.smartName || null,
|
|
530
|
-
project: sess.project,
|
|
531
|
-
promptCount: sess.entries.length,
|
|
532
|
-
lastActive: new Date(sess.lastTimestamp).toISOString(),
|
|
533
|
-
isActive: activeSessionIds.has(id),
|
|
534
|
-
source: 'replit-tools',
|
|
535
|
-
age: timeAgo(sess.lastTimestamp),
|
|
536
|
-
tool: sess.tool || 'claude',
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// Sort by most recent first
|
|
541
|
-
sessions.sort((a, b) => new Date(b.lastActive) - new Date(a.lastActive));
|
|
542
|
-
|
|
543
|
-
return sessions;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// ─── Session metadata overlay ─────────────────────────────────────────────────
|
|
547
|
-
|
|
548
|
-
const SESSION_META_FILE = '.dualbrain/sessions.json';
|
|
549
|
-
|
|
550
|
-
function sessionMetaPath(cwd) {
|
|
551
|
-
return join(cwd ?? process.cwd(), SESSION_META_FILE);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
export function getSessionMeta(cwd = process.cwd()) {
|
|
555
|
-
const p = sessionMetaPath(cwd);
|
|
556
|
-
if (!existsSync(p)) return {};
|
|
557
|
-
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; }
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
export function saveSessionMeta(meta, cwd = process.cwd()) {
|
|
561
|
-
ensureDir(cwd);
|
|
562
|
-
const p = sessionMetaPath(cwd);
|
|
563
|
-
const tmp = p + '.tmp.' + process.pid;
|
|
564
|
-
writeFileSync(tmp, JSON.stringify(meta, null, 2) + '\n');
|
|
565
|
-
renameSync(tmp, p);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// ─── Archive support ──────────────────────────────────────────────────────────
|
|
569
|
-
|
|
570
|
-
const ARCHIVE_FILE = '.dualbrain/archive/sessions.json';
|
|
571
|
-
|
|
572
|
-
function archivePath(cwd) {
|
|
573
|
-
return join(cwd ?? process.cwd(), ARCHIVE_FILE);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Archive a session — moves it from active sessions.json to archive/sessions.json.
|
|
578
|
-
* The session data stays in the index (searchable), just flagged as archived.
|
|
579
|
-
* Non-destructive and reversible.
|
|
580
|
-
*
|
|
581
|
-
* @param {string} sessionId
|
|
582
|
-
* @param {string} [cwd]
|
|
583
|
-
*/
|
|
584
|
-
export function archiveSession(sessionId, cwd = process.cwd()) {
|
|
585
|
-
// Load active sessions meta
|
|
586
|
-
const meta = getSessionMeta(cwd);
|
|
587
|
-
const existing = meta[sessionId] ?? {};
|
|
588
|
-
|
|
589
|
-
// Load or init archive
|
|
590
|
-
const ap = archivePath(cwd);
|
|
591
|
-
mkdirSync(dirname(ap), { recursive: true });
|
|
592
|
-
let archive = [];
|
|
593
|
-
try {
|
|
594
|
-
if (existsSync(ap)) archive = JSON.parse(readFileSync(ap, 'utf8'));
|
|
595
|
-
} catch { archive = []; }
|
|
596
|
-
|
|
597
|
-
// Avoid duplicates
|
|
598
|
-
if (!archive.some(s => s.id === sessionId)) {
|
|
599
|
-
archive.push({
|
|
600
|
-
...existing,
|
|
601
|
-
id: sessionId,
|
|
602
|
-
archived: true,
|
|
603
|
-
archivedAt: new Date().toISOString(),
|
|
604
|
-
});
|
|
605
|
-
const tmp = ap + '.tmp.' + process.pid;
|
|
606
|
-
writeFileSync(tmp, JSON.stringify(archive, null, 2) + '\n');
|
|
607
|
-
renameSync(tmp, ap);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// Remove from active sessions.json
|
|
611
|
-
delete meta[sessionId];
|
|
612
|
-
saveSessionMeta(meta, cwd);
|
|
613
|
-
|
|
614
|
-
// Mark archived in the session index (best-effort)
|
|
615
|
-
try {
|
|
616
|
-
const indexPath = join(cwd ?? process.cwd(), '.dualbrain', 'session-index.json');
|
|
617
|
-
if (existsSync(indexPath)) {
|
|
618
|
-
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
619
|
-
if (index[sessionId]) {
|
|
620
|
-
index[sessionId].archived = true;
|
|
621
|
-
writeFileSync(indexPath, JSON.stringify(index, null, 2) + '\n');
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
} catch { /* non-fatal */ }
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Return all archived sessions.
|
|
629
|
-
* @param {string} [cwd]
|
|
630
|
-
* @returns {Array<object>}
|
|
631
|
-
*/
|
|
632
|
-
export function getArchivedSessions(cwd = process.cwd()) {
|
|
633
|
-
const ap = archivePath(cwd);
|
|
634
|
-
if (!existsSync(ap)) return [];
|
|
635
|
-
try { return JSON.parse(readFileSync(ap, 'utf8')); } catch { return []; }
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
export function renameSession(sessionId, name, cwd = process.cwd()) {
|
|
639
|
-
const meta = getSessionMeta(cwd);
|
|
640
|
-
meta[sessionId] = { ...meta[sessionId], name, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
|
|
641
|
-
saveSessionMeta(meta, cwd);
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
export function pinSession(sessionId, cwd = process.cwd()) {
|
|
645
|
-
const meta = getSessionMeta(cwd);
|
|
646
|
-
meta[sessionId] = { ...meta[sessionId], pinned: true, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
|
|
647
|
-
saveSessionMeta(meta, cwd);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
export function unpinSession(sessionId, cwd = process.cwd()) {
|
|
651
|
-
const meta = getSessionMeta(cwd);
|
|
652
|
-
meta[sessionId] = { ...meta[sessionId], pinned: false };
|
|
653
|
-
saveSessionMeta(meta, cwd);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
export function categorizeSession(sessionId, category, cwd = process.cwd()) {
|
|
657
|
-
const meta = getSessionMeta(cwd);
|
|
658
|
-
meta[sessionId] = { ...meta[sessionId], category, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
|
|
659
|
-
saveSessionMeta(meta, cwd);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const AUTO_LABEL_RULES = [
|
|
663
|
-
{ keywords: ['auth', 'login', 'credential', 'security', 'token'], label: 'security' },
|
|
664
|
-
{ keywords: ['ui', 'css', 'style', 'component', 'react', 'frontend'], label: 'ui' },
|
|
665
|
-
{ keywords: ['refactor', 'cleanup', 'rename', 'reorganize'], label: 'refactor' },
|
|
666
|
-
{ keywords: ['bug', 'fix', 'error', 'crash', 'broken'], label: 'bugfix' },
|
|
667
|
-
{ keywords: ['test', 'spec', 'coverage'], label: 'testing' },
|
|
668
|
-
{ keywords: ['deploy', 'ci', 'build', 'release'], label: 'devops' },
|
|
669
|
-
{ keywords: ['plan', 'design', 'architect', 'brainstorm'], label: 'planning' },
|
|
670
|
-
];
|
|
671
|
-
|
|
672
|
-
export function autoLabel(session) {
|
|
673
|
-
const text = (session.name || '').toLowerCase();
|
|
674
|
-
for (const { keywords, label } of AUTO_LABEL_RULES) {
|
|
675
|
-
if (keywords.some(kw => new RegExp(`\\b${kw}\\b`).test(text))) return label;
|
|
676
|
-
}
|
|
677
|
-
return null;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
export function enrichSessions(sessions, cwd = process.cwd()) {
|
|
681
|
-
const meta = getSessionMeta(cwd);
|
|
682
|
-
const enriched = sessions.map(sess => {
|
|
683
|
-
const overlay = meta[sess.id] ?? {};
|
|
684
|
-
const category = overlay.category ?? autoLabel({ ...sess, name: overlay.name ?? sess.name });
|
|
685
|
-
return {
|
|
686
|
-
...sess,
|
|
687
|
-
name: overlay.name ?? sess.name,
|
|
688
|
-
pinned: overlay.pinned ?? false,
|
|
689
|
-
category: category ?? null,
|
|
690
|
-
};
|
|
691
|
-
});
|
|
692
|
-
enriched.sort((a, b) => {
|
|
693
|
-
if (a.pinned && !b.pinned) return -1;
|
|
694
|
-
if (!a.pinned && b.pinned) return 1;
|
|
695
|
-
return new Date(b.lastActive) - new Date(a.lastActive);
|
|
696
|
-
});
|
|
697
|
-
return enriched;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// ─── Persistence settings ─────────────────────────────────────────────────────
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* Ensure Claude and Codex are configured to retain session history indefinitely.
|
|
704
|
-
* Mirrors what replit-tools does to prevent session cleanup/deletion.
|
|
705
|
-
*
|
|
706
|
-
* @param {string} [cwd]
|
|
707
|
-
* @returns {string[]} List of changes made (empty if already configured)
|
|
708
|
-
*/
|
|
709
|
-
export function ensurePersistence(cwd = process.cwd()) {
|
|
710
|
-
const home = process.env.HOME || '/root';
|
|
711
|
-
const results = [];
|
|
712
|
-
|
|
713
|
-
// 1. Claude: set cleanupPeriodDays
|
|
714
|
-
const claudeSettingsPaths = [
|
|
715
|
-
join(home, '.claude', 'settings.json'),
|
|
716
|
-
join(cwd, '.replit-tools', '.claude-persistent', 'settings.json'),
|
|
717
|
-
];
|
|
718
|
-
|
|
719
|
-
for (const settingsPath of claudeSettingsPaths) {
|
|
720
|
-
if (!existsSync(settingsPath)) continue;
|
|
721
|
-
try {
|
|
722
|
-
let settings = {};
|
|
723
|
-
try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
|
|
724
|
-
if (settings.cleanupPeriodDays !== 365250) {
|
|
725
|
-
settings.cleanupPeriodDays = 365250;
|
|
726
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
727
|
-
results.push('Claude cleanupPeriodDays set to 365250');
|
|
728
|
-
}
|
|
729
|
-
break; // only update one
|
|
730
|
-
} catch { continue; }
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// 2. Codex: set history.persistence and max_bytes
|
|
734
|
-
const codexConfigPaths = [
|
|
735
|
-
join(home, '.codex', 'config.toml'),
|
|
736
|
-
join(cwd, '.replit-tools', '.codex-persistent', 'config.toml'),
|
|
737
|
-
];
|
|
738
|
-
|
|
739
|
-
for (const configPath of codexConfigPaths) {
|
|
740
|
-
if (!existsSync(configPath)) continue;
|
|
741
|
-
try {
|
|
742
|
-
let content = readFileSync(configPath, 'utf8');
|
|
743
|
-
let changed = false;
|
|
744
|
-
|
|
745
|
-
if (!/\[history\]/.test(content)) {
|
|
746
|
-
content = content.trimEnd() + '\n\n[history]\npersistence = "save-all"\nmax_bytes = 104857600\n';
|
|
747
|
-
changed = true;
|
|
748
|
-
} else {
|
|
749
|
-
if (!/persistence\s*=/.test(content)) {
|
|
750
|
-
content = content.replace(/\[history\](\s*)/, '[history]$1persistence = "save-all"\n');
|
|
751
|
-
changed = true;
|
|
752
|
-
}
|
|
753
|
-
if (!/max_bytes\s*=/.test(content)) {
|
|
754
|
-
content = content.replace(/(persistence\s*=\s*"[^"]*"\s*\n)/, '$1max_bytes = 104857600\n');
|
|
755
|
-
changed = true;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
if (changed) {
|
|
760
|
-
writeFileSync(configPath, content);
|
|
761
|
-
results.push('Codex history persistence enabled');
|
|
762
|
-
}
|
|
763
|
-
break;
|
|
764
|
-
} catch { continue; }
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
return results;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// ─── Session archive mirror sync ─────────────────────────────────────────────
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Append-only mirror sync for Claude/Codex sessions (matches what replit-tools does).
|
|
774
|
-
* Files in the mirror only grow — if the source deletes a session, the mirror still has it.
|
|
775
|
-
*
|
|
776
|
-
* @param {string} [cwd]
|
|
777
|
-
* @returns {{ copied: number, grew: number, disabled?: boolean }}
|
|
778
|
-
*/
|
|
779
|
-
export function syncSessionMirror(cwd = process.cwd()) {
|
|
780
|
-
const home = process.env.HOME || '/root';
|
|
781
|
-
const mirrorBase = join(cwd, '.replit-tools', '.session-archive');
|
|
782
|
-
|
|
783
|
-
// Check if replit-tools exists
|
|
784
|
-
if (!existsSync(join(cwd, '.replit-tools'))) return { copied: 0, grew: 0 };
|
|
785
|
-
|
|
786
|
-
// Check config — mirror can be disabled
|
|
787
|
-
const configPath = join(cwd, '.replit-tools', 'config.json');
|
|
788
|
-
try {
|
|
789
|
-
if (existsSync(configPath)) {
|
|
790
|
-
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
791
|
-
if (cfg.mirror && cfg.mirror.enabled === false) return { copied: 0, grew: 0, disabled: true };
|
|
792
|
-
}
|
|
793
|
-
} catch {}
|
|
794
|
-
|
|
795
|
-
let totalCopied = 0, totalGrew = 0;
|
|
796
|
-
|
|
797
|
-
function syncTree(srcDir, destDir) {
|
|
798
|
-
if (!existsSync(srcDir)) return;
|
|
799
|
-
|
|
800
|
-
function walk(dir) {
|
|
801
|
-
let entries;
|
|
802
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
803
|
-
|
|
804
|
-
for (const entry of entries) {
|
|
805
|
-
const srcPath = join(dir, entry.name);
|
|
806
|
-
const relPath = srcPath.slice(srcDir.length);
|
|
807
|
-
const destPath = join(destDir, relPath);
|
|
808
|
-
|
|
809
|
-
if (entry.isDirectory()) {
|
|
810
|
-
try { mkdirSync(destPath, { recursive: true }); } catch {}
|
|
811
|
-
walk(srcPath);
|
|
812
|
-
} else if (entry.isFile()) {
|
|
813
|
-
let destSize = 0;
|
|
814
|
-
try { destSize = statSync(destPath).size; } catch {}
|
|
815
|
-
|
|
816
|
-
let srcSize = 0;
|
|
817
|
-
try { srcSize = statSync(srcPath).size; } catch { continue; }
|
|
818
|
-
|
|
819
|
-
// Append-only: only copy if source is larger than mirror
|
|
820
|
-
if (srcSize > destSize) {
|
|
821
|
-
try {
|
|
822
|
-
mkdirSync(dirname(destPath), { recursive: true });
|
|
823
|
-
copyFileSync(srcPath, destPath);
|
|
824
|
-
if (destSize === 0) totalCopied++;
|
|
825
|
-
else totalGrew++;
|
|
826
|
-
} catch {}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
walk(srcDir);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
try { mkdirSync(mirrorBase, { recursive: true }); } catch {}
|
|
836
|
-
|
|
837
|
-
// Sync Claude sessions
|
|
838
|
-
const claudeDir = join(home, '.claude');
|
|
839
|
-
syncTree(join(claudeDir, 'projects'), join(mirrorBase, 'claude', 'projects'));
|
|
840
|
-
// Sync history.jsonl as a single file
|
|
841
|
-
const histSrc = join(claudeDir, 'history.jsonl');
|
|
842
|
-
const histDest = join(mirrorBase, 'claude', 'history.jsonl');
|
|
843
|
-
if (existsSync(histSrc)) {
|
|
844
|
-
try {
|
|
845
|
-
const srcSize = statSync(histSrc).size;
|
|
846
|
-
let destSize = 0;
|
|
847
|
-
try { destSize = statSync(histDest).size; } catch {}
|
|
848
|
-
if (srcSize > destSize) {
|
|
849
|
-
mkdirSync(dirname(histDest), { recursive: true });
|
|
850
|
-
copyFileSync(histSrc, histDest);
|
|
851
|
-
if (destSize === 0) totalCopied++; else totalGrew++;
|
|
852
|
-
}
|
|
853
|
-
} catch {}
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// Sync Codex sessions
|
|
857
|
-
const codexDir = join(home, '.codex');
|
|
858
|
-
syncTree(join(codexDir, 'sessions'), join(mirrorBase, 'codex', 'sessions'));
|
|
859
|
-
|
|
860
|
-
return { copied: totalCopied, grew: totalGrew };
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// ─── Smart session naming ─────────────────────────────────────────────────────
|
|
864
|
-
|
|
865
|
-
/**
|
|
866
|
-
* File pattern → human label mapping (checked in order, first match wins).
|
|
867
|
-
* Each entry: { pattern: RegExp, label: string, action?: string }
|
|
868
|
-
*/
|
|
869
|
-
const FILE_PATTERN_RULES = [
|
|
870
|
-
{ pattern: /auth/i, label: 'Auth', action: 'Refactor' },
|
|
871
|
-
{ pattern: /test|spec/i, label: 'Tests', action: 'Fix' },
|
|
872
|
-
{ pattern: /dispatch/i, label: 'Dispatch', action: 'Update' },
|
|
873
|
-
{ pattern: /session/i, label: 'Session', action: 'Update' },
|
|
874
|
-
{ pattern: /profile/i, label: 'Profile', action: 'Update' },
|
|
875
|
-
{ pattern: /detect/i, label: 'Detection', action: 'Update' },
|
|
876
|
-
{ pattern: /decide/i, label: 'Routing', action: 'Update' },
|
|
877
|
-
{ pattern: /budget/i, label: 'Budget', action: 'Update' },
|
|
878
|
-
{ pattern: /hook/i, label: 'Hooks', action: 'Update' },
|
|
879
|
-
{ pattern: /install/i, label: 'Install', action: 'Update' },
|
|
880
|
-
{ pattern: /config/i, label: 'Config', action: 'Update' },
|
|
881
|
-
{ pattern: /migrate/i, label: 'Migration', action: 'Add' },
|
|
882
|
-
];
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Topic words that suggest a dominant action verb.
|
|
886
|
-
*/
|
|
887
|
-
const TOPIC_ACTION_MAP = [
|
|
888
|
-
{ words: ['fix', 'bug', 'error', 'crash', 'broken', 'fail'], action: 'Fix' },
|
|
889
|
-
{ words: ['refactor', 'cleanup', 'clean', 'reorganize'], action: 'Refactor' },
|
|
890
|
-
{ words: ['add', 'implement', 'create', 'build', 'write'], action: 'Add' },
|
|
891
|
-
{ words: ['update', 'upgrade', 'bump', 'patch'], action: 'Update' },
|
|
892
|
-
{ words: ['test', 'spec', 'coverage'], action: 'Fix' },
|
|
893
|
-
{ words: ['deploy', 'release', 'publish'], action: 'Deploy' },
|
|
894
|
-
{ words: ['audit', 'review', 'check'], action: 'Review' },
|
|
895
|
-
];
|
|
896
|
-
|
|
897
|
-
/**
|
|
898
|
-
* Convert a string to Title Case.
|
|
899
|
-
* @param {string} str
|
|
900
|
-
* @returns {string}
|
|
901
|
-
*/
|
|
902
|
-
function toTitleCase(str) {
|
|
903
|
-
return str.replace(/\b\w/g, c => c.toUpperCase());
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* Strip file extensions from a name candidate.
|
|
908
|
-
* @param {string} name
|
|
909
|
-
* @returns {string}
|
|
910
|
-
*/
|
|
911
|
-
function stripExtensions(name) {
|
|
912
|
-
return name.replace(/\.(mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/gi, '');
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* Truncate a string to maxLen characters, preserving whole words where possible.
|
|
917
|
-
* @param {string} str
|
|
918
|
-
* @param {number} maxLen
|
|
919
|
-
* @returns {string}
|
|
920
|
-
*/
|
|
921
|
-
function truncate(str, maxLen = 40) {
|
|
922
|
-
if (str.length <= maxLen) return str;
|
|
923
|
-
const cut = str.slice(0, maxLen).replace(/\s+\S*$/, '');
|
|
924
|
-
return cut || str.slice(0, maxLen);
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
/**
|
|
928
|
-
* Generate a smart human-readable session name from session index data.
|
|
929
|
-
*
|
|
930
|
-
* Priority:
|
|
931
|
-
* 1. Dominant file pattern (e.g. auth*.mjs → "Refactor Auth Module")
|
|
932
|
-
* 2. Top topics (e.g. ['auth','token','refresh'] → "Auth Token Refresh")
|
|
933
|
-
* 3. Fallback: first prompt truncated to 40 chars
|
|
934
|
-
*
|
|
935
|
-
* Rules: ≤40 chars, Title Case, no file extensions, action-prefixed when detectable.
|
|
936
|
-
*
|
|
937
|
-
* @param {{ topics?: string[], files?: string[], prompts?: { first?: string } }} sessionData
|
|
938
|
-
* @returns {string}
|
|
939
|
-
*/
|
|
940
|
-
export function generateSmartName(sessionData) {
|
|
941
|
-
const topics = sessionData.topics || [];
|
|
942
|
-
const files = sessionData.files || [];
|
|
943
|
-
const firstPrompt = sessionData.prompts?.first || '';
|
|
944
|
-
|
|
945
|
-
// ── Step 1: Detect dominant action from topics ─────────────────────────────
|
|
946
|
-
let detectedAction = null;
|
|
947
|
-
for (const { words, action } of TOPIC_ACTION_MAP) {
|
|
948
|
-
if (topics.some(t => words.includes(t))) {
|
|
949
|
-
detectedAction = action;
|
|
950
|
-
break;
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// ── Step 2: Try file pattern match ─────────────────────────────────────────
|
|
955
|
-
if (files.length > 0) {
|
|
956
|
-
// Flatten all filenames for pattern matching
|
|
957
|
-
const fileNames = files.map(f => f.split('/').pop()).join(' ');
|
|
958
|
-
|
|
959
|
-
for (const { pattern, label, action } of FILE_PATTERN_RULES) {
|
|
960
|
-
if (pattern.test(fileNames)) {
|
|
961
|
-
const actionWord = detectedAction || action || 'Update';
|
|
962
|
-
const candidate = `${actionWord} ${label}`;
|
|
963
|
-
return truncate(toTitleCase(candidate));
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// No named pattern — derive a label from the most common directory or base name
|
|
968
|
-
const basenames = files.map(f => {
|
|
969
|
-
const base = f.split('/').pop() || f;
|
|
970
|
-
// Strip extension and convert camelCase/kebab to words
|
|
971
|
-
return stripExtensions(base)
|
|
972
|
-
.replace(/[-_]/g, ' ')
|
|
973
|
-
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
974
|
-
.trim();
|
|
975
|
-
}).filter(Boolean);
|
|
976
|
-
|
|
977
|
-
if (basenames.length > 0) {
|
|
978
|
-
// Use the most common prefix or first significant basename
|
|
979
|
-
const label = basenames[0];
|
|
980
|
-
const actionWord = detectedAction || 'Update';
|
|
981
|
-
const candidate = `${actionWord} ${label}`;
|
|
982
|
-
return truncate(toTitleCase(stripExtensions(candidate)));
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// ── Step 3: Try top topics ─────────────────────────────────────────────────
|
|
987
|
-
if (topics.length >= 2) {
|
|
988
|
-
// Take top 3 topics and compose a name
|
|
989
|
-
const topTopics = topics.slice(0, 3);
|
|
990
|
-
const actionWord = detectedAction || null;
|
|
991
|
-
|
|
992
|
-
let candidate;
|
|
993
|
-
if (actionWord) {
|
|
994
|
-
// Use action + remaining topics
|
|
995
|
-
candidate = [actionWord, ...topTopics.filter(t => t !== actionWord.toLowerCase())].slice(0, 3).join(' ');
|
|
996
|
-
} else {
|
|
997
|
-
candidate = topTopics.join(' ');
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
return truncate(toTitleCase(candidate));
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
if (topics.length === 1) {
|
|
1004
|
-
const actionWord = detectedAction || 'Work on';
|
|
1005
|
-
return truncate(toTitleCase(`${actionWord} ${topics[0]}`));
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
// ── Step 4: Fallback — first prompt truncated ──────────────────────────────
|
|
1009
|
-
if (firstPrompt) {
|
|
1010
|
-
return truncate(firstPrompt);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
return 'Session';
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
// ─── Session index ────────────────────────────────────────────────────────────
|
|
1017
|
-
|
|
1018
|
-
/**
|
|
1019
|
-
* Build/update `.dualbrain/session-index.json` from Claude and Codex JSONL session files.
|
|
1020
|
-
* Extracts topics, file references, prompt snippets, and metadata per session.
|
|
1021
|
-
*
|
|
1022
|
-
* @param {string} [cwd]
|
|
1023
|
-
* @returns {object} index — keyed by session UUID
|
|
1024
|
-
*/
|
|
1025
|
-
export function buildSessionIndex(cwd = process.cwd()) {
|
|
1026
|
-
const home = process.env.HOME || '/root';
|
|
1027
|
-
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
1028
|
-
|
|
1029
|
-
// Load existing index
|
|
1030
|
-
let index = {};
|
|
1031
|
-
try {
|
|
1032
|
-
if (existsSync(indexPath)) {
|
|
1033
|
-
index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
1034
|
-
}
|
|
1035
|
-
} catch {}
|
|
1036
|
-
|
|
1037
|
-
// Find all session JSONLs
|
|
1038
|
-
const sources = [
|
|
1039
|
-
join(home, '.claude', 'projects', '-home-runner-workspace'),
|
|
1040
|
-
join(cwd, '.replit-tools', '.session-archive', 'claude', 'projects', '-home-runner-workspace'),
|
|
1041
|
-
];
|
|
1042
|
-
|
|
1043
|
-
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']);
|
|
1044
|
-
|
|
1045
|
-
for (const dir of sources) {
|
|
1046
|
-
if (!existsSync(dir)) continue;
|
|
1047
|
-
let files;
|
|
1048
|
-
try { files = readdirSync(dir); } catch { continue; }
|
|
1049
|
-
|
|
1050
|
-
for (const f of files) {
|
|
1051
|
-
if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue;
|
|
1052
|
-
const sessionId = f.replace('.jsonl', '');
|
|
1053
|
-
|
|
1054
|
-
// Skip if already indexed and file hasn't grown
|
|
1055
|
-
const filePath = join(dir, f);
|
|
1056
|
-
let fileSize = 0;
|
|
1057
|
-
try { fileSize = statSync(filePath).size; } catch { continue; }
|
|
1058
|
-
if (index[sessionId] && index[sessionId]._fileSize >= fileSize) continue;
|
|
1059
|
-
|
|
1060
|
-
// Parse session
|
|
1061
|
-
try {
|
|
1062
|
-
const content = readFileSync(filePath, 'utf8');
|
|
1063
|
-
const lines = content.split('\n').filter(Boolean);
|
|
1064
|
-
|
|
1065
|
-
const wordCounts = {};
|
|
1066
|
-
const fileSet = new Set();
|
|
1067
|
-
let firstPrompt = null;
|
|
1068
|
-
let lastPrompt = null;
|
|
1069
|
-
let lastTimestamp = 0;
|
|
1070
|
-
let messageCount = 0;
|
|
1071
|
-
|
|
1072
|
-
for (const line of lines) {
|
|
1073
|
-
try {
|
|
1074
|
-
const entry = JSON.parse(line);
|
|
1075
|
-
|
|
1076
|
-
// Track timestamps
|
|
1077
|
-
if (entry.timestamp) {
|
|
1078
|
-
const raw = typeof entry.timestamp === 'number' ? entry.timestamp : Date.parse(entry.timestamp);
|
|
1079
|
-
const ts = raw > 1e12 ? raw / 1000 : raw;
|
|
1080
|
-
if (ts > lastTimestamp) lastTimestamp = ts;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// Extract user messages
|
|
1084
|
-
let text = null;
|
|
1085
|
-
if (entry.type === 'user' && entry.message?.content) {
|
|
1086
|
-
text = typeof entry.message.content === 'string'
|
|
1087
|
-
? entry.message.content
|
|
1088
|
-
: entry.message.content?.[0]?.text;
|
|
1089
|
-
}
|
|
1090
|
-
if (entry.display) text = text || entry.display;
|
|
1091
|
-
|
|
1092
|
-
if (!text) continue;
|
|
1093
|
-
messageCount++;
|
|
1094
|
-
|
|
1095
|
-
if (!firstPrompt) firstPrompt = text.slice(0, 80);
|
|
1096
|
-
lastPrompt = text.slice(0, 80);
|
|
1097
|
-
|
|
1098
|
-
// Extract file paths
|
|
1099
|
-
const filePaths = text.match(/[\w./~-]+\.(?:mjs|js|ts|tsx|jsx|json|md|css|html|py|sh|sql|toml|yaml|yml)\b/g);
|
|
1100
|
-
if (filePaths) filePaths.forEach(p => fileSet.add(p));
|
|
1101
|
-
|
|
1102
|
-
// Count words for topics
|
|
1103
|
-
const words = text.toLowerCase().split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w));
|
|
1104
|
-
for (const w of words) {
|
|
1105
|
-
wordCounts[w] = (wordCounts[w] || 0) + 1;
|
|
1106
|
-
}
|
|
1107
|
-
} catch { continue; }
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
// Top 10 topics by frequency
|
|
1111
|
-
const topics = Object.entries(wordCounts)
|
|
1112
|
-
.sort((a, b) => b[1] - a[1])
|
|
1113
|
-
.slice(0, 10)
|
|
1114
|
-
.map(([w]) => w);
|
|
1115
|
-
|
|
1116
|
-
const sessionEntry = {
|
|
1117
|
-
id: sessionId,
|
|
1118
|
-
topics,
|
|
1119
|
-
files: [...fileSet].slice(0, 20),
|
|
1120
|
-
prompts: { first: firstPrompt || '', last: lastPrompt || '' },
|
|
1121
|
-
date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
|
|
1122
|
-
messageCount,
|
|
1123
|
-
tool: 'claude',
|
|
1124
|
-
_fileSize: fileSize,
|
|
1125
|
-
};
|
|
1126
|
-
sessionEntry.smartName = generateSmartName(sessionEntry);
|
|
1127
|
-
index[sessionId] = sessionEntry;
|
|
1128
|
-
} catch { continue; }
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// Also index codex sessions (same pattern)
|
|
1133
|
-
const codexDir = join(home, '.codex', 'sessions');
|
|
1134
|
-
if (existsSync(codexDir)) {
|
|
1135
|
-
const walk = (dir) => {
|
|
1136
|
-
let results = [];
|
|
1137
|
-
try {
|
|
1138
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1139
|
-
const full = join(dir, entry.name);
|
|
1140
|
-
if (entry.isDirectory()) results = results.concat(walk(full));
|
|
1141
|
-
else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(full);
|
|
1142
|
-
}
|
|
1143
|
-
} catch {}
|
|
1144
|
-
return results;
|
|
1145
|
-
};
|
|
1146
|
-
|
|
1147
|
-
for (const filePath of walk(codexDir)) {
|
|
1148
|
-
try {
|
|
1149
|
-
const content = readFileSync(filePath, 'utf8');
|
|
1150
|
-
const lines = content.split('\n').filter(Boolean);
|
|
1151
|
-
if (!lines.length) continue;
|
|
1152
|
-
const meta = JSON.parse(lines[0]);
|
|
1153
|
-
if (meta.type !== 'session_meta' || !meta.payload) continue;
|
|
1154
|
-
const id = meta.payload.id;
|
|
1155
|
-
if (!id || index[id]) continue;
|
|
1156
|
-
|
|
1157
|
-
let fileSize = 0;
|
|
1158
|
-
try { fileSize = statSync(filePath).size; } catch { continue; }
|
|
1159
|
-
|
|
1160
|
-
let firstPrompt = null, lastPrompt = null, messageCount = 0;
|
|
1161
|
-
let lastTimestamp = Date.parse(meta.payload.timestamp || meta.timestamp) / 1000 || 0;
|
|
1162
|
-
|
|
1163
|
-
for (const ln of lines) {
|
|
1164
|
-
try {
|
|
1165
|
-
const j = JSON.parse(ln);
|
|
1166
|
-
if (j.timestamp) {
|
|
1167
|
-
const ts = Date.parse(j.timestamp) / 1000;
|
|
1168
|
-
if (ts > lastTimestamp) lastTimestamp = ts;
|
|
1169
|
-
}
|
|
1170
|
-
if (j.type === 'event_msg' && j.payload?.type === 'user_message') {
|
|
1171
|
-
const text = (j.payload.message || '').trim();
|
|
1172
|
-
if (text) {
|
|
1173
|
-
messageCount++;
|
|
1174
|
-
if (!firstPrompt) firstPrompt = text.slice(0, 80);
|
|
1175
|
-
lastPrompt = text.slice(0, 80);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
} catch { continue; }
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
const codexEntry = {
|
|
1182
|
-
id, topics: [], files: [],
|
|
1183
|
-
prompts: { first: firstPrompt || '', last: lastPrompt || '' },
|
|
1184
|
-
date: lastTimestamp ? new Date(lastTimestamp * 1000).toISOString() : null,
|
|
1185
|
-
messageCount, tool: 'codex', _fileSize: fileSize,
|
|
1186
|
-
};
|
|
1187
|
-
codexEntry.smartName = generateSmartName(codexEntry);
|
|
1188
|
-
index[id] = codexEntry;
|
|
1189
|
-
} catch { continue; }
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
// Save index
|
|
1194
|
-
try {
|
|
1195
|
-
mkdirSync(join(cwd, '.dualbrain'), { recursive: true });
|
|
1196
|
-
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
|
1197
|
-
} catch {}
|
|
1198
|
-
|
|
1199
|
-
return index;
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
/**
|
|
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.
|
|
1208
|
-
*
|
|
1209
|
-
* @param {string} query
|
|
1210
|
-
* @param {string} [cwd]
|
|
1211
|
-
* @returns {Array<object>} sessions with `_score` field, sorted descending
|
|
1212
|
-
*/
|
|
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
|
|
1222
|
-
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
1223
|
-
let index = {};
|
|
1224
|
-
try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch {}
|
|
1225
|
-
if (Object.keys(index).length === 0) index = buildSessionIndex(cwd);
|
|
1226
|
-
|
|
1227
|
-
const results = [];
|
|
1228
|
-
for (const session of Object.values(index)) {
|
|
1229
|
-
let score = 0;
|
|
1230
|
-
const searchText = [
|
|
1231
|
-
...(session.topics || []),
|
|
1232
|
-
...(session.files || []),
|
|
1233
|
-
session.prompts?.first || '',
|
|
1234
|
-
session.prompts?.last || '',
|
|
1235
|
-
].join(' ').toLowerCase();
|
|
1236
|
-
|
|
1237
|
-
for (const term of terms) {
|
|
1238
|
-
if (searchText.includes(term)) score++;
|
|
1239
|
-
if ((session.topics || []).includes(term)) score += 2;
|
|
1240
|
-
if ((session.files || []).some(f => f.includes(term))) score += 2;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
if (score > 0) {
|
|
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
|
-
});
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
return results.sort((a, b) => b._score - a._score);
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
/**
|
|
1337
|
-
* Find sessions related to a new task prompt and file list.
|
|
1338
|
-
* Uses the session index (topics + files) — does not parse full JSONL files.
|
|
1339
|
-
*
|
|
1340
|
-
* Scoring:
|
|
1341
|
-
* +3 for each file in common between the new task and a past session
|
|
1342
|
-
* +2 for each topic keyword in common
|
|
1343
|
-
* +1 for matching intent words (fix, refactor, test, etc.)
|
|
1344
|
-
*
|
|
1345
|
-
* Returns top 3 matches with score > 3, sorted by score desc.
|
|
1346
|
-
* Excludes sessions from the last hour (likely the current session).
|
|
1347
|
-
*
|
|
1348
|
-
* @param {string} prompt New task prompt
|
|
1349
|
-
* @param {string[]} files File paths from the new task
|
|
1350
|
-
* @param {string} [cwd]
|
|
1351
|
-
* @returns {Array<{
|
|
1352
|
-
* sessionId: string, smartName: string, score: number,
|
|
1353
|
-
* matchedFiles: string[], matchedTopics: string[],
|
|
1354
|
-
* date: string|null, messageCount: number
|
|
1355
|
-
* }>}
|
|
1356
|
-
*/
|
|
1357
|
-
export function findRelatedSessions(prompt, files = [], cwd = process.cwd()) {
|
|
1358
|
-
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
1359
|
-
let index = {};
|
|
1360
|
-
try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch { return []; }
|
|
1361
|
-
|
|
1362
|
-
if (Object.keys(index).length === 0) return [];
|
|
1363
|
-
|
|
1364
|
-
// Intent words for +1 scoring
|
|
1365
|
-
const INTENT_WORDS = ['fix', 'refactor', 'test', 'add', 'update', 'review', 'debug', 'build', 'remove', 'migrate', 'deploy', 'implement', 'create'];
|
|
1366
|
-
|
|
1367
|
-
// Normalize the new task's prompt into words
|
|
1368
|
-
const promptLower = (prompt || '').toLowerCase();
|
|
1369
|
-
const promptWords = new Set(promptLower.split(/\W+/).filter(w => w.length > 3));
|
|
1370
|
-
|
|
1371
|
-
// Normalize the new task's file paths for comparison
|
|
1372
|
-
const normalizeFile = (f) => (f || '').split('/').pop().toLowerCase().replace(/\.[^.]+$/, '');
|
|
1373
|
-
const newFileNames = new Set((files || []).map(normalizeFile).filter(Boolean));
|
|
1374
|
-
|
|
1375
|
-
// One-hour cutoff for excluding likely-current session
|
|
1376
|
-
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
|
1377
|
-
|
|
1378
|
-
const results = [];
|
|
1379
|
-
|
|
1380
|
-
for (const session of Object.values(index)) {
|
|
1381
|
-
// Skip archived sessions
|
|
1382
|
-
if (session.archived) continue;
|
|
1383
|
-
|
|
1384
|
-
// Skip sessions from the last hour
|
|
1385
|
-
const sessionTs = session.date ? Date.parse(session.date) : 0;
|
|
1386
|
-
if (sessionTs > oneHourAgo) continue;
|
|
1387
|
-
|
|
1388
|
-
let score = 0;
|
|
1389
|
-
const matchedFiles = [];
|
|
1390
|
-
const matchedTopics = [];
|
|
1391
|
-
|
|
1392
|
-
// +3 for each file in common
|
|
1393
|
-
for (const sessionFile of (session.files || [])) {
|
|
1394
|
-
const sessionFileName = normalizeFile(sessionFile);
|
|
1395
|
-
if (sessionFileName && newFileNames.has(sessionFileName)) {
|
|
1396
|
-
score += 3;
|
|
1397
|
-
matchedFiles.push(sessionFile);
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
// +2 for each topic keyword in common with prompt words
|
|
1402
|
-
for (const topic of (session.topics || [])) {
|
|
1403
|
-
if (topic && promptWords.has(topic)) {
|
|
1404
|
-
score += 2;
|
|
1405
|
-
matchedTopics.push(topic);
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
// +1 for matching intent words found in both prompt and session topics/prompts
|
|
1410
|
-
const sessionText = [
|
|
1411
|
-
...(session.topics || []),
|
|
1412
|
-
session.prompts?.first || '',
|
|
1413
|
-
session.prompts?.last || '',
|
|
1414
|
-
].join(' ').toLowerCase();
|
|
1415
|
-
|
|
1416
|
-
for (const word of INTENT_WORDS) {
|
|
1417
|
-
if (promptLower.includes(word) && sessionText.includes(word)) {
|
|
1418
|
-
score += 1;
|
|
1419
|
-
break; // only +1 total for intent words
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
if (score > 3) {
|
|
1424
|
-
results.push({
|
|
1425
|
-
sessionId: session.id,
|
|
1426
|
-
smartName: session.smartName || session.prompts?.first?.slice(0, 40) || session.id.slice(0, 8),
|
|
1427
|
-
score,
|
|
1428
|
-
matchedFiles,
|
|
1429
|
-
matchedTopics,
|
|
1430
|
-
date: session.date,
|
|
1431
|
-
messageCount: session.messageCount || 0,
|
|
1432
|
-
});
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
// Return top 3 sorted by score descending
|
|
1437
|
-
return results
|
|
1438
|
-
.sort((a, b) => b.score - a.score)
|
|
1439
|
-
.slice(0, 3);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
/**
|
|
1443
|
-
* Get detailed context for a session (for smart resume preview).
|
|
1444
|
-
* Reads the last 20 lines of the session JSONL to surface the most recent prompt
|
|
1445
|
-
* and files touched.
|
|
1446
|
-
*
|
|
1447
|
-
* @param {string} sessionId
|
|
1448
|
-
* @param {string} [cwd]
|
|
1449
|
-
* @returns {{ lastPrompt: string|null, filesTouched: string[], totalLines: number }|null}
|
|
1450
|
-
*/
|
|
1451
|
-
export function getSessionContext(sessionId, cwd = process.cwd()) {
|
|
1452
|
-
const home = process.env.HOME || '/root';
|
|
1453
|
-
const paths = [
|
|
1454
|
-
join(home, '.claude', 'projects', '-home-runner-workspace', sessionId + '.jsonl'),
|
|
1455
|
-
join(cwd, '.replit-tools', '.session-archive', 'claude', 'projects', '-home-runner-workspace', sessionId + '.jsonl'),
|
|
1456
|
-
];
|
|
1457
|
-
|
|
1458
|
-
let filePath = null;
|
|
1459
|
-
for (const p of paths) {
|
|
1460
|
-
if (existsSync(p)) { filePath = p; break; }
|
|
1461
|
-
}
|
|
1462
|
-
if (!filePath) return null;
|
|
1463
|
-
|
|
1464
|
-
try {
|
|
1465
|
-
const content = readFileSync(filePath, 'utf8');
|
|
1466
|
-
const lines = content.split('\n').filter(Boolean);
|
|
1467
|
-
|
|
1468
|
-
// Read last 20 lines for recent context
|
|
1469
|
-
const recentLines = lines.slice(-20);
|
|
1470
|
-
let lastUserPrompt = null;
|
|
1471
|
-
const filesSet = new Set();
|
|
1472
|
-
|
|
1473
|
-
for (const line of recentLines) {
|
|
1474
|
-
try {
|
|
1475
|
-
const entry = JSON.parse(line);
|
|
1476
|
-
if (entry.type === 'user' && entry.message?.content) {
|
|
1477
|
-
const text = typeof entry.message.content === 'string'
|
|
1478
|
-
? entry.message.content
|
|
1479
|
-
: entry.message.content?.[0]?.text;
|
|
1480
|
-
if (text) lastUserPrompt = text.slice(0, 120);
|
|
1481
|
-
}
|
|
1482
|
-
if (entry.display) lastUserPrompt = entry.display.slice(0, 120);
|
|
1483
|
-
|
|
1484
|
-
// Look for file edits in tool use
|
|
1485
|
-
if (entry.type === 'tool_use' || entry.type === 'tool_result') {
|
|
1486
|
-
const fp = entry.tool_input?.file_path || entry.tool_input?.path;
|
|
1487
|
-
if (fp) filesSet.add(fp.split('/').pop());
|
|
1488
|
-
}
|
|
1489
|
-
} catch { continue; }
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
return {
|
|
1493
|
-
lastPrompt: lastUserPrompt,
|
|
1494
|
-
filesTouched: [...filesSet].slice(0, 5),
|
|
1495
|
-
totalLines: lines.length,
|
|
1496
|
-
};
|
|
1497
|
-
} catch { return null; }
|
|
1498
|
-
}
|
|
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
|
-
|
|
1645
|
-
// ─── CLI (direct invocation) ──────────────────────────────────────────────────
|
|
1646
|
-
|
|
1647
|
-
const isMain = process.argv[1]?.endsWith('session.mjs');
|
|
1648
|
-
if (isMain) {
|
|
1649
|
-
const session = loadSession(process.cwd());
|
|
1650
|
-
if (session) {
|
|
1651
|
-
process.stdout.write(JSON.stringify(session, null, 2) + '\n');
|
|
1652
|
-
} else {
|
|
1653
|
-
process.stdout.write('(no active session)\n');
|
|
1654
|
-
}
|
|
1655
|
-
}
|