brain-dev 0.1.2 → 0.2.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/bin/brain-tools.cjs +17 -1
- package/bin/lib/bridge.cjs +47 -0
- package/bin/lib/commands/auto.cjs +337 -0
- package/bin/lib/commands/dashboard.cjs +177 -0
- package/bin/lib/commands/execute.cjs +18 -0
- package/bin/lib/commands/progress.cjs +37 -1
- package/bin/lib/commands/recover.cjs +155 -0
- package/bin/lib/commands/verify.cjs +15 -5
- package/bin/lib/commands.cjs +18 -0
- package/bin/lib/config.cjs +23 -2
- package/bin/lib/context.cjs +397 -0
- package/bin/lib/cost.cjs +273 -0
- package/bin/lib/dashboard-collector.cjs +98 -0
- package/bin/lib/dashboard-server.cjs +33 -0
- package/bin/lib/hook-dispatcher.cjs +99 -0
- package/bin/lib/lock.cjs +163 -0
- package/bin/lib/logger.cjs +18 -0
- package/bin/lib/recovery.cjs +468 -0
- package/bin/lib/security.cjs +16 -2
- package/bin/lib/state.cjs +118 -8
- package/bin/lib/stuck.cjs +269 -0
- package/bin/lib/tokens.cjs +32 -0
- package/commands/brain/auto.md +31 -0
- package/commands/brain/dashboard.md +18 -0
- package/commands/brain/recover.md +19 -0
- package/hooks/bootstrap.sh +15 -1
- package/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readState } = require('./state.cjs');
|
|
6
|
+
const { readMetrics, checkBudget } = require('./cost.cjs');
|
|
7
|
+
const { readBridge } = require('./bridge.cjs');
|
|
8
|
+
const { readLock, isLockStale } = require('./lock.cjs');
|
|
9
|
+
const { tailLog } = require('./logger.cjs');
|
|
10
|
+
const { checkTimeouts, detectProgress } = require('./stuck.cjs');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Collect a full DashboardState snapshot from all data sources.
|
|
14
|
+
* Safe: all reads are wrapped so a single source failure does not
|
|
15
|
+
* break the entire snapshot.
|
|
16
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
17
|
+
* @returns {object} DashboardState snapshot
|
|
18
|
+
*/
|
|
19
|
+
function collectState(brainDir) {
|
|
20
|
+
const state = safeRead(() => readState(brainDir), {});
|
|
21
|
+
const metrics = safeRead(() => readMetrics(brainDir), { totals: {} });
|
|
22
|
+
const contextBridge = readBridge(brainDir, 'context');
|
|
23
|
+
const stuckBridge = readBridge(brainDir, 'stuck');
|
|
24
|
+
const lock = readLock(brainDir);
|
|
25
|
+
const currentPhase = state.phase?.current || 1;
|
|
26
|
+
const events = safeRead(() => tailLog(brainDir, currentPhase, null), []);
|
|
27
|
+
const budgetStatus = safeRead(() => checkBudget(metrics), { status: 'ok', spent: 0 });
|
|
28
|
+
const timeoutStatus = safeRead(() => checkTimeouts(brainDir, state), { tier: 'none' });
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
project: {
|
|
33
|
+
name: state.project?.name || 'Unknown',
|
|
34
|
+
mode: state.mode || 'interactive',
|
|
35
|
+
depth: state.depth || 'standard'
|
|
36
|
+
},
|
|
37
|
+
milestone: state.milestone || { current: 'v1.0' },
|
|
38
|
+
phases: {
|
|
39
|
+
current: currentPhase,
|
|
40
|
+
total: state.phase?.total || 0,
|
|
41
|
+
status: state.phase?.status || 'initialized',
|
|
42
|
+
list: (state.phase?.phases || []).map(p => ({
|
|
43
|
+
number: p.number || p,
|
|
44
|
+
name: p.name || `Phase ${p.number || p}`,
|
|
45
|
+
status: p.status || 'pending'
|
|
46
|
+
}))
|
|
47
|
+
},
|
|
48
|
+
auto: {
|
|
49
|
+
active: state.auto?.active || false,
|
|
50
|
+
started_at: state.auto?.started_at || null,
|
|
51
|
+
current_step: state.auto?.current_step || null,
|
|
52
|
+
phases_completed: state.auto?.phases_completed || 0
|
|
53
|
+
},
|
|
54
|
+
timing: {
|
|
55
|
+
phaseStart: state.phase?.execution_started_at || null,
|
|
56
|
+
phaseElapsed: state.phase?.execution_started_at
|
|
57
|
+
? Math.round((Date.now() - new Date(state.phase.execution_started_at).getTime()) / 1000)
|
|
58
|
+
: 0
|
|
59
|
+
},
|
|
60
|
+
context: {
|
|
61
|
+
remainingPct: contextBridge?.remaining_pct || null,
|
|
62
|
+
level: contextBridge?.level || 'unknown'
|
|
63
|
+
},
|
|
64
|
+
cost: {
|
|
65
|
+
spent: metrics.totals?.estimated_cost_usd || 0,
|
|
66
|
+
ceiling: state.budget?.ceiling_usd || null,
|
|
67
|
+
pctUsed: budgetStatus.pct_used || 0,
|
|
68
|
+
status: budgetStatus.status || 'ok'
|
|
69
|
+
},
|
|
70
|
+
stuck: {
|
|
71
|
+
detected: stuckBridge?.timeout_tier === 'soft' ||
|
|
72
|
+
stuckBridge?.timeout_tier === 'idle' ||
|
|
73
|
+
stuckBridge?.timeout_tier === 'hard',
|
|
74
|
+
tier: stuckBridge?.timeout_tier || timeoutStatus.tier || 'none',
|
|
75
|
+
message: stuckBridge?.message || timeoutStatus.message || null,
|
|
76
|
+
idleMinutes: stuckBridge?.idle_minutes || 0
|
|
77
|
+
},
|
|
78
|
+
lock: {
|
|
79
|
+
active: !!lock,
|
|
80
|
+
operation: lock?.operation || null,
|
|
81
|
+
stale: lock ? isLockStale(lock).stale : false
|
|
82
|
+
},
|
|
83
|
+
events: events.slice(-50),
|
|
84
|
+
health: { stateValid: !!state.$schema }
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Safely invoke a reader function, returning fallback on any error.
|
|
90
|
+
* @param {Function} fn - Reader function
|
|
91
|
+
* @param {*} fallback - Value to return on failure
|
|
92
|
+
* @returns {*}
|
|
93
|
+
*/
|
|
94
|
+
function safeRead(fn, fallback) {
|
|
95
|
+
try { return fn(); } catch { return fallback; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { collectState };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Dashboard web server - WebSocket-based real-time dashboard
|
|
4
|
+
// Full implementation deferred - TUI mode (dashboard.cjs) is the primary interface
|
|
5
|
+
|
|
6
|
+
const http = require('node:http');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Start a WebSocket-based dashboard server.
|
|
12
|
+
* TODO: Implement following storm.cjs pattern (parseFrame, buildFrame, findAvailablePort).
|
|
13
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
14
|
+
* @param {number} port - Port to listen on
|
|
15
|
+
* @param {boolean} autoOpen - Whether to open browser automatically
|
|
16
|
+
* @throws {Error} Always — not yet implemented
|
|
17
|
+
*/
|
|
18
|
+
async function startDashboardServer(brainDir, port, autoOpen) {
|
|
19
|
+
// TODO: Implement WebSocket server following storm.cjs pattern
|
|
20
|
+
// For now, return a stub
|
|
21
|
+
throw new Error('Web dashboard not yet implemented. Use brain-dev dashboard (TUI mode) instead.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Stop a running dashboard server by removing its port file.
|
|
26
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
27
|
+
*/
|
|
28
|
+
function stopDashboardServer(brainDir) {
|
|
29
|
+
const portFile = path.join(brainDir, '.dashboard-port');
|
|
30
|
+
try { fs.unlinkSync(portFile); } catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { startDashboardServer, stopDashboardServer };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { readState } = require('./state.cjs');
|
|
4
|
+
const { readBridge, writeBridge } = require('./bridge.cjs');
|
|
5
|
+
const { checkTimeouts, updateStuckBridge } = require('./stuck.cjs');
|
|
6
|
+
const { readLock } = require('./lock.cjs');
|
|
7
|
+
const { readMetrics } = require('./cost.cjs');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Handle StatusLine hook — aggregate state into a compact status string.
|
|
11
|
+
* Called on every status line refresh by the hook system.
|
|
12
|
+
* @param {object|null} inputJson - Parsed input from the hook (may contain context_window)
|
|
13
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
14
|
+
* @returns {string} Formatted status line text
|
|
15
|
+
*/
|
|
16
|
+
function onStatusLine(inputJson, brainDir) {
|
|
17
|
+
const state = readState(brainDir);
|
|
18
|
+
const results = {};
|
|
19
|
+
|
|
20
|
+
// 1. Context monitoring — determine level and persist to bridge
|
|
21
|
+
results.context = {
|
|
22
|
+
remaining_pct: inputJson?.context_window?.remaining_percentage || null,
|
|
23
|
+
level: determineContextLevel(inputJson?.context_window?.remaining_percentage)
|
|
24
|
+
};
|
|
25
|
+
writeBridge(brainDir, 'context', results.context);
|
|
26
|
+
|
|
27
|
+
// 2. Stuck detection — update bridge when phase is active
|
|
28
|
+
if (state.phase?.status === 'executing' || state.phase?.status === 'verifying') {
|
|
29
|
+
updateStuckBridge(brainDir, state);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 3. Cost summary for statusline
|
|
33
|
+
try {
|
|
34
|
+
const metrics = readMetrics(brainDir);
|
|
35
|
+
results.cost = {
|
|
36
|
+
spent: metrics.totals?.estimated_cost_usd || 0,
|
|
37
|
+
ceiling: state.budget?.ceiling_usd || null
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
results.cost = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 4. Lock status
|
|
44
|
+
const lock = readLock(brainDir);
|
|
45
|
+
results.lock = lock
|
|
46
|
+
? { active: true, operation: lock.operation }
|
|
47
|
+
: { active: false };
|
|
48
|
+
|
|
49
|
+
// Build statusline text
|
|
50
|
+
const parts = [];
|
|
51
|
+
parts.push('brain');
|
|
52
|
+
if (state.phase) parts.push(`P${state.phase.current}: ${state.phase.status}`);
|
|
53
|
+
if (results.cost?.spent > 0) {
|
|
54
|
+
let costStr = `$${results.cost.spent.toFixed(2)}`;
|
|
55
|
+
if (results.cost.ceiling) costStr += `/$${results.cost.ceiling}`;
|
|
56
|
+
parts.push(costStr);
|
|
57
|
+
}
|
|
58
|
+
if (lock) parts.push('auto');
|
|
59
|
+
|
|
60
|
+
return parts.join(' | ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handle PostToolUse hook — return warnings that should be injected.
|
|
65
|
+
* Called after each tool use by the hook system.
|
|
66
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
67
|
+
* @returns {string[]} Array of warning messages (may be empty)
|
|
68
|
+
*/
|
|
69
|
+
function onPostToolUse(brainDir) {
|
|
70
|
+
const warnings = [];
|
|
71
|
+
|
|
72
|
+
// Check stuck detection bridge
|
|
73
|
+
const stuckBridge = readBridge(brainDir, 'stuck');
|
|
74
|
+
if (stuckBridge?.message && stuckBridge.timeout_tier !== 'none') {
|
|
75
|
+
warnings.push(stuckBridge.message);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check context bridge
|
|
79
|
+
const contextBridge = readBridge(brainDir, 'context');
|
|
80
|
+
if (contextBridge?.level === 'critical') {
|
|
81
|
+
warnings.push('[brain] Context window critically low. Consider /brain:pause to save progress.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return warnings;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Determine context window health level from remaining percentage.
|
|
89
|
+
* @param {number|null|undefined} remainingPct
|
|
90
|
+
* @returns {'normal'|'warning'|'critical'|'unknown'}
|
|
91
|
+
*/
|
|
92
|
+
function determineContextLevel(remainingPct) {
|
|
93
|
+
if (remainingPct === null || remainingPct === undefined) return 'unknown';
|
|
94
|
+
if (remainingPct <= 25) return 'critical';
|
|
95
|
+
if (remainingPct <= 50) return 'warning';
|
|
96
|
+
return 'normal';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { onStatusLine, onPostToolUse, determineContextLevel };
|
package/bin/lib/lock.cjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { atomicWriteSync } = require('./state.cjs');
|
|
6
|
+
|
|
7
|
+
const LOCK_FILE = '.auto.lock';
|
|
8
|
+
const DEFAULT_STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Acquire the auto lock. Creates .brain/.auto.lock atomically.
|
|
12
|
+
* If a stale lock exists, it is cleared and re-acquired.
|
|
13
|
+
* @param {string} brainDir - Path to .brain/
|
|
14
|
+
* @param {object} context - { phase, plan, operation, agent }
|
|
15
|
+
* @returns {{ acquired: boolean, staleRecovered: boolean, previousLock: object|null }}
|
|
16
|
+
*/
|
|
17
|
+
function acquireLock(brainDir, context) {
|
|
18
|
+
const lockPath = path.join(brainDir, LOCK_FILE);
|
|
19
|
+
const existing = readLock(brainDir);
|
|
20
|
+
|
|
21
|
+
if (existing) {
|
|
22
|
+
const { stale } = isLockStale(existing);
|
|
23
|
+
if (!stale) {
|
|
24
|
+
return { acquired: false, staleRecovered: false, previousLock: existing };
|
|
25
|
+
}
|
|
26
|
+
// Stale lock — back it up and clear
|
|
27
|
+
clearStaleLock(brainDir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lockData = {
|
|
31
|
+
pid: process.pid,
|
|
32
|
+
sessionId: new Date().toISOString().replace(/:/g, '-').slice(0, 19),
|
|
33
|
+
phase: context.phase || null,
|
|
34
|
+
plan: context.plan || null,
|
|
35
|
+
operation: context.operation || null,
|
|
36
|
+
agent: context.agent || null,
|
|
37
|
+
acquiredAt: new Date().toISOString(),
|
|
38
|
+
heartbeat: new Date().toISOString()
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
atomicWriteSync(lockPath, JSON.stringify(lockData, null, 2));
|
|
42
|
+
return { acquired: true, staleRecovered: !!existing, previousLock: existing || null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Release the lock. Only releases if PID matches current process.
|
|
47
|
+
* @param {string} brainDir
|
|
48
|
+
* @returns {boolean} true if released
|
|
49
|
+
*/
|
|
50
|
+
function releaseLock(brainDir) {
|
|
51
|
+
const lockPath = path.join(brainDir, LOCK_FILE);
|
|
52
|
+
const existing = readLock(brainDir);
|
|
53
|
+
if (!existing) return false;
|
|
54
|
+
if (existing.pid !== process.pid) return false;
|
|
55
|
+
try {
|
|
56
|
+
fs.unlinkSync(lockPath);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read the current lock file without modifying it.
|
|
65
|
+
* @param {string} brainDir
|
|
66
|
+
* @returns {object|null} Lock data or null
|
|
67
|
+
*/
|
|
68
|
+
function readLock(brainDir) {
|
|
69
|
+
try {
|
|
70
|
+
const lockPath = path.join(brainDir, LOCK_FILE);
|
|
71
|
+
return JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if a lock is stale. Uses dual check: PID alive AND heartbeat fresh.
|
|
79
|
+
* @param {object} lockData - Parsed lock contents
|
|
80
|
+
* @param {number} [thresholdMs] - Staleness threshold (default 5 min)
|
|
81
|
+
* @returns {{ stale: boolean, reason: string }}
|
|
82
|
+
*/
|
|
83
|
+
function isLockStale(lockData, thresholdMs = DEFAULT_STALE_THRESHOLD_MS) {
|
|
84
|
+
if (!lockData || !lockData.pid) return { stale: true, reason: 'Invalid lock data' };
|
|
85
|
+
|
|
86
|
+
// Check 1: Is the PID still alive?
|
|
87
|
+
try {
|
|
88
|
+
process.kill(lockData.pid, 0); // Signal 0 = existence check
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (e.code === 'ESRCH') {
|
|
91
|
+
return { stale: true, reason: `PID ${lockData.pid} is not running` };
|
|
92
|
+
}
|
|
93
|
+
// EPERM means process exists but we can't signal it — treat as alive
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check 2: Is the heartbeat fresh?
|
|
97
|
+
if (lockData.heartbeat) {
|
|
98
|
+
const heartbeatAge = Date.now() - new Date(lockData.heartbeat).getTime();
|
|
99
|
+
if (heartbeatAge > thresholdMs) {
|
|
100
|
+
return { stale: true, reason: `Heartbeat ${Math.round(heartbeatAge / 1000)}s old (threshold: ${Math.round(thresholdMs / 1000)}s)` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { stale: false, reason: '' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Update heartbeat timestamp in the lock file.
|
|
109
|
+
* Only updates if lock is owned by current process.
|
|
110
|
+
* @param {string} brainDir
|
|
111
|
+
* @returns {boolean} true if updated
|
|
112
|
+
*/
|
|
113
|
+
function heartbeat(brainDir) {
|
|
114
|
+
const lockPath = path.join(brainDir, LOCK_FILE);
|
|
115
|
+
const existing = readLock(brainDir);
|
|
116
|
+
if (!existing || existing.pid !== process.pid) return false;
|
|
117
|
+
existing.heartbeat = new Date().toISOString();
|
|
118
|
+
try {
|
|
119
|
+
atomicWriteSync(lockPath, JSON.stringify(existing, null, 2));
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Force-remove a stale lock. Backs up to .auto.lock.stale first.
|
|
128
|
+
* @param {string} brainDir
|
|
129
|
+
* @returns {object|null} The stale lock data that was removed
|
|
130
|
+
*/
|
|
131
|
+
function clearStaleLock(brainDir) {
|
|
132
|
+
const lockPath = path.join(brainDir, LOCK_FILE);
|
|
133
|
+
const stalePath = lockPath + '.stale';
|
|
134
|
+
const existing = readLock(brainDir);
|
|
135
|
+
if (!existing) return null;
|
|
136
|
+
try {
|
|
137
|
+
// Backup stale lock for forensics
|
|
138
|
+
fs.copyFileSync(lockPath, stalePath);
|
|
139
|
+
fs.unlinkSync(lockPath);
|
|
140
|
+
} catch { /* best effort */ }
|
|
141
|
+
return existing;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if a lock currently exists (any state).
|
|
146
|
+
* @param {string} brainDir
|
|
147
|
+
* @returns {boolean}
|
|
148
|
+
*/
|
|
149
|
+
function isLocked(brainDir) {
|
|
150
|
+
return fs.existsSync(path.join(brainDir, LOCK_FILE));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
acquireLock,
|
|
155
|
+
releaseLock,
|
|
156
|
+
readLock,
|
|
157
|
+
isLockStale,
|
|
158
|
+
heartbeat,
|
|
159
|
+
clearStaleLock,
|
|
160
|
+
isLocked,
|
|
161
|
+
LOCK_FILE,
|
|
162
|
+
DEFAULT_STALE_THRESHOLD_MS
|
|
163
|
+
};
|
package/bin/lib/logger.cjs
CHANGED
|
@@ -57,6 +57,23 @@ function readLog(brainDir, phaseNumber) {
|
|
|
57
57
|
}, []);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Read log events newer than a given timestamp.
|
|
62
|
+
* Efficient: reads from end of file to avoid full-file scans.
|
|
63
|
+
* @param {string} brainDir
|
|
64
|
+
* @param {number} phaseNumber
|
|
65
|
+
* @param {string} [afterTimestamp] - ISO timestamp; returns events after this time. If null, returns last 20 events.
|
|
66
|
+
* @returns {Array<object>}
|
|
67
|
+
*/
|
|
68
|
+
function tailLog(brainDir, phaseNumber, afterTimestamp) {
|
|
69
|
+
const events = readLog(brainDir, phaseNumber);
|
|
70
|
+
if (!afterTimestamp) {
|
|
71
|
+
return events.slice(-20);
|
|
72
|
+
}
|
|
73
|
+
const afterTime = new Date(afterTimestamp).getTime();
|
|
74
|
+
return events.filter(e => e.timestamp && new Date(e.timestamp).getTime() > afterTime);
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* Archive a phase log and clean up old archives beyond keepLast.
|
|
62
79
|
*
|
|
@@ -96,5 +113,6 @@ function archiveLogs(brainDir, phaseNumber, keepLast = 3) {
|
|
|
96
113
|
module.exports = {
|
|
97
114
|
logEvent,
|
|
98
115
|
readLog,
|
|
116
|
+
tailLog,
|
|
99
117
|
archiveLogs
|
|
100
118
|
};
|