@theihtisham/agent-shadow-brain 6.2.0 → 7.0.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/CHANGELOG.md +411 -372
- package/LICENSE +21 -21
- package/README.md +428 -427
- package/dist/adapters/aider.js +13 -13
- package/dist/adapters/base-adapter.js +14 -14
- package/dist/adapters/codex.js +8 -8
- package/dist/adapters/roo-code.js +18 -18
- package/dist/brain/adr-engine.js +11 -11
- package/dist/brain/analyzer.js +30 -30
- package/dist/brain/auto-update.js +1 -1
- package/dist/brain/global-brain.d.ts +55 -46
- package/dist/brain/global-brain.d.ts.map +1 -1
- package/dist/brain/global-brain.js +586 -258
- package/dist/brain/global-brain.js.map +1 -1
- package/dist/brain/health-score.js +19 -19
- package/dist/brain/learning-engine.js +59 -59
- package/dist/brain/mcp-server.d.ts +2 -0
- package/dist/brain/mcp-server.d.ts.map +1 -1
- package/dist/brain/mcp-server.js +46 -5
- package/dist/brain/mcp-server.js.map +1 -1
- package/dist/brain/plugin-system.js +23 -23
- package/dist/brain/pr-generator.js +19 -19
- package/dist/brain/project-context.js +1 -1
- package/dist/brain/project-context.js.map +1 -1
- package/dist/brain/report-generator.js +270 -270
- package/dist/cli.js +105 -34
- package/dist/cli.js.map +1 -1
- package/dist/dashboard/server.js +2 -2
- package/dist/dashboard/server.js.map +1 -1
- package/dist/dashboard/v6-dashboard-html.d.ts +1 -1
- package/dist/dashboard/v6-dashboard-html.d.ts.map +1 -1
- package/dist/dashboard/v6-dashboard-html.js +10 -5
- package/dist/dashboard/v6-dashboard-html.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +50 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/watchers/git-watcher.d.ts.map +1 -1
- package/dist/watchers/git-watcher.js +1 -1
- package/dist/watchers/git-watcher.js.map +1 -1
- package/docs/launch/RELEASE_CHECKLIST_v7.0.0.md +24 -0
- package/docs/launch/RELEASE_NOTES_v7.0.0.md +44 -0
- package/package.json +17 -14
- package/docs/launch/README.md +0 -61
|
@@ -1,23 +1,18 @@
|
|
|
1
|
-
// src/brain/global-brain.ts — Singleton Global Brain (v5.2.0)
|
|
2
|
-
//
|
|
3
|
-
// One brain. Every project. Every agent. Every machine.
|
|
4
|
-
//
|
|
5
|
-
// The Global Brain is a singleton store at ~/.shadow-brain/global.json that
|
|
6
|
-
// every Shadow Brain instance reads from and writes to. Insights from project
|
|
7
|
-
// A using Cursor are immediately available to project B using Claude Code.
|
|
8
|
-
//
|
|
9
|
-
// Storage: append-friendly JSON store (no native deps), persisted on every
|
|
10
|
-
// flush. Combined with l0-cache.ts, hot recalls return in <1ms.
|
|
11
|
-
//
|
|
12
|
-
// Concurrency: write queue + atomic file replace prevents conflicts when
|
|
13
|
-
// multiple agents write simultaneously.
|
|
14
1
|
import * as fs from 'fs';
|
|
15
2
|
import * as path from 'path';
|
|
16
3
|
import * as os from 'os';
|
|
17
4
|
import * as crypto from 'crypto';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
18
6
|
import { getBrainBus } from './brain-bus.js';
|
|
19
|
-
const
|
|
20
|
-
const
|
|
7
|
+
const DEFAULT_DB_PATH = path.join(os.homedir(), '.shadow-brain', 'global.json');
|
|
8
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
9
|
+
const LOCK_POLL_MS = 20;
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
12
|
+
const STOP_WORDS = new Set([
|
|
13
|
+
'a', 'an', 'and', 'are', 'as', 'for', 'from', 'in', 'into', 'is', 'of', 'on',
|
|
14
|
+
'or', 'the', 'to', 'with', 'ww2br',
|
|
15
|
+
]);
|
|
21
16
|
const DEFAULT_CONFIG = {
|
|
22
17
|
dbPath: DEFAULT_DB_PATH,
|
|
23
18
|
walMode: true,
|
|
@@ -26,23 +21,114 @@ const DEFAULT_CONFIG = {
|
|
|
26
21
|
autoPruneMB: 1000,
|
|
27
22
|
syncIntervalMs: 5000,
|
|
28
23
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
class StorageError extends Error {
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.name = 'GlobalBrainStorageError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function errorCode(error) {
|
|
32
|
+
return error?.code;
|
|
33
|
+
}
|
|
34
|
+
function isRecord(value) {
|
|
35
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
function hasUniqueKeys(rows, key, required) {
|
|
38
|
+
const keys = new Set();
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
if (!isRecord(row) || required.some(field => typeof row[field] !== 'string'))
|
|
41
|
+
return false;
|
|
42
|
+
const id = row[key];
|
|
43
|
+
if (typeof id !== 'string' || !id || keys.has(id))
|
|
44
|
+
return false;
|
|
45
|
+
keys.add(id);
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function validateSnapshot(value) {
|
|
50
|
+
if (!isRecord(value) || !Number.isInteger(value.schemaVersion) || Number(value.schemaVersion) < 1) {
|
|
51
|
+
throw new StorageError('STORE_INVALID_SCHEMA', 'The global brain store has an invalid schema.');
|
|
52
|
+
}
|
|
53
|
+
if (value.schemaVersion !== 1) {
|
|
54
|
+
throw new StorageError('STORE_UNSUPPORTED_SCHEMA', 'The global brain store uses an unsupported schema version.');
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(value.entries) ||
|
|
57
|
+
!hasUniqueKeys(value.entries, 'id', ['projectId', 'projectName', 'agentTool', 'category', 'content']) ||
|
|
58
|
+
!Array.isArray(value.projects) ||
|
|
59
|
+
!hasUniqueKeys(value.projects, 'id', ['name', 'rootDir']) ||
|
|
60
|
+
!Array.isArray(value.agents) ||
|
|
61
|
+
!hasUniqueKeys(value.agents, 'tool', [])) {
|
|
62
|
+
throw new StorageError('STORE_INVALID_SCHEMA', 'The global brain store has an invalid schema.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function boundedSignal(value) {
|
|
66
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(value, 1)) : 0;
|
|
67
|
+
}
|
|
68
|
+
function nonNegativeInt(value, fallback = 0) {
|
|
69
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
70
|
+
? Math.max(0, Math.min(Math.trunc(value), Number.MAX_SAFE_INTEGER))
|
|
71
|
+
: fallback;
|
|
72
|
+
}
|
|
73
|
+
function dateTime(value) {
|
|
74
|
+
const timestamp = value instanceof Date
|
|
75
|
+
? value.getTime()
|
|
76
|
+
: typeof value === 'string' || typeof value === 'number' ? new Date(value).getTime() : 0;
|
|
77
|
+
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0;
|
|
78
|
+
}
|
|
79
|
+
function hydrateEntry(entry) {
|
|
80
|
+
return {
|
|
81
|
+
...entry,
|
|
82
|
+
importance: boundedSignal(entry.importance),
|
|
83
|
+
accessCount: nonNegativeInt(entry.accessCount),
|
|
84
|
+
createdAt: new Date(dateTime(entry.createdAt)),
|
|
85
|
+
lastAccessed: new Date(dateTime(entry.lastAccessed)),
|
|
86
|
+
metadata: isRecord(entry.metadata) ? entry.metadata : {},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function normalizeText(value) {
|
|
90
|
+
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
function normalizeKeywords(keywords) {
|
|
93
|
+
if (!Array.isArray(keywords))
|
|
94
|
+
return [];
|
|
95
|
+
const unique = new Set();
|
|
96
|
+
for (const value of keywords) {
|
|
97
|
+
if (typeof value !== 'string')
|
|
98
|
+
continue;
|
|
99
|
+
const keyword = normalizeText(value);
|
|
100
|
+
if (keyword.length > 2 && !STOP_WORDS.has(keyword))
|
|
101
|
+
unique.add(keyword);
|
|
102
|
+
}
|
|
103
|
+
return [...unique];
|
|
104
|
+
}
|
|
105
|
+
function searchableMetadata(metadata) {
|
|
106
|
+
try {
|
|
107
|
+
return normalizeText(JSON.stringify(metadata));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function evidenceKey(entry) {
|
|
114
|
+
return JSON.stringify([entry.projectId, entry.category, normalizeText(entry.content)]);
|
|
115
|
+
}
|
|
116
|
+
function compareIds(a, b) {
|
|
117
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
118
|
+
}
|
|
119
|
+
/** Local JSON store shared by cooperating Global Brain processes. */
|
|
35
120
|
export class GlobalBrain {
|
|
36
121
|
constructor(config = {}) {
|
|
37
122
|
this.entries = new Map();
|
|
38
|
-
this.
|
|
39
|
-
this.agents = new Map();
|
|
40
|
-
this.writeQueue = [];
|
|
123
|
+
this.pendingOperations = [];
|
|
41
124
|
this.syncTimer = null;
|
|
42
|
-
this.
|
|
43
|
-
this.dirty = false;
|
|
44
|
-
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
125
|
+
this.syncInFlight = null;
|
|
45
126
|
this.startedAt = Date.now();
|
|
127
|
+
this.initialized = false;
|
|
128
|
+
this.shuttingDown = false;
|
|
129
|
+
this.lastSuccessfulSync = null;
|
|
130
|
+
this.lastError = null;
|
|
131
|
+
this.config = { ...DEFAULT_CONFIG, ...config, dbPath: path.resolve(config.dbPath ?? DEFAULT_DB_PATH) };
|
|
46
132
|
this.stats = {
|
|
47
133
|
totalProjects: 0,
|
|
48
134
|
totalAgents: 0,
|
|
@@ -59,338 +145,580 @@ export class GlobalBrain {
|
|
|
59
145
|
};
|
|
60
146
|
}
|
|
61
147
|
static getInstance(config) {
|
|
62
|
-
|
|
63
|
-
GlobalBrain.instance = new GlobalBrain(config);
|
|
64
|
-
}
|
|
148
|
+
GlobalBrain.instance ?? (GlobalBrain.instance = new GlobalBrain(config));
|
|
65
149
|
return GlobalBrain.instance;
|
|
66
150
|
}
|
|
67
151
|
async init() {
|
|
152
|
+
this.initialize();
|
|
153
|
+
}
|
|
154
|
+
initialize() {
|
|
68
155
|
if (this.initialized)
|
|
69
156
|
return;
|
|
70
|
-
|
|
71
|
-
if (fs.existsSync(this.config.dbPath)) {
|
|
157
|
+
try {
|
|
72
158
|
try {
|
|
73
|
-
|
|
74
|
-
const parsed = JSON.parse(raw);
|
|
75
|
-
for (const e of parsed.entries ?? []) {
|
|
76
|
-
// Re-hydrate Date instances
|
|
77
|
-
this.entries.set(e.id, {
|
|
78
|
-
...e,
|
|
79
|
-
createdAt: new Date(e.createdAt),
|
|
80
|
-
lastAccessed: new Date(e.lastAccessed),
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
for (const p of parsed.projects ?? [])
|
|
84
|
-
this.projects.set(p.id, p);
|
|
85
|
-
for (const a of parsed.agents ?? [])
|
|
86
|
-
this.agents.set(a.tool, a);
|
|
159
|
+
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
|
87
160
|
}
|
|
88
|
-
catch {
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
fs.renameSync(this.config.dbPath, this.config.dbPath + '.corrupt-' + Date.now());
|
|
92
|
-
}
|
|
93
|
-
catch { /* ignore */ }
|
|
161
|
+
catch (error) {
|
|
162
|
+
throw new StorageError('STORE_READ_FAILED', `Could not open the global brain store directory (${errorCode(error) ?? 'I/O error'}).`);
|
|
94
163
|
}
|
|
164
|
+
const { data, sizeMB } = this.readLatestSnapshot();
|
|
165
|
+
this.adoptSnapshot(data, sizeMB, this.hydrateEntries(data));
|
|
166
|
+
this.initialized = true;
|
|
167
|
+
this.lastError = null;
|
|
168
|
+
this.syncTimer = setInterval(() => {
|
|
169
|
+
if (!this.syncInFlight)
|
|
170
|
+
void this.sync().catch(() => { });
|
|
171
|
+
}, this.config.syncIntervalMs);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw this.recordError(error);
|
|
95
175
|
}
|
|
96
|
-
this.startSyncLoop();
|
|
97
|
-
this.refreshStats();
|
|
98
|
-
this.initialized = true;
|
|
99
176
|
}
|
|
100
|
-
/** Generate a stable project ID from absolute path */
|
|
177
|
+
/** Generate a stable project ID from absolute path. */
|
|
101
178
|
static projectIdFor(rootDir) {
|
|
102
179
|
const abs = path.resolve(rootDir);
|
|
103
180
|
return crypto.createHash('sha1').update(abs).digest('hex').slice(0, 16);
|
|
104
181
|
}
|
|
105
182
|
registerProject(rootDir, name) {
|
|
106
|
-
|
|
107
|
-
|
|
183
|
+
this.requireWritable();
|
|
184
|
+
if (name !== undefined && typeof name !== 'string')
|
|
185
|
+
throw new TypeError('Project name must be a string');
|
|
108
186
|
const id = GlobalBrain.projectIdFor(rootDir);
|
|
109
|
-
const projectName = name || path.basename(rootDir);
|
|
110
187
|
const now = Date.now();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
id,
|
|
114
|
-
name: projectName,
|
|
115
|
-
rootDir: path.resolve(rootDir),
|
|
116
|
-
firstSeen: existing?.firstSeen ?? now,
|
|
117
|
-
lastSeen: now,
|
|
188
|
+
this.pendingOperations.push({
|
|
189
|
+
kind: 'project',
|
|
190
|
+
project: { id, name: name || path.basename(rootDir), rootDir: path.resolve(rootDir), firstSeen: now, lastSeen: now },
|
|
118
191
|
});
|
|
119
|
-
this.dirty = true;
|
|
120
192
|
return id;
|
|
121
193
|
}
|
|
122
194
|
registerAgent(agent) {
|
|
123
|
-
if (!this.initialized)
|
|
195
|
+
if (!this.initialized || this.shuttingDown)
|
|
124
196
|
return;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
this.
|
|
128
|
-
tool: agent,
|
|
129
|
-
firstSeen: existing?.firstSeen ?? now,
|
|
130
|
-
lastSeen: now,
|
|
131
|
-
sessionCount: (existing?.sessionCount ?? 0) + 1,
|
|
132
|
-
});
|
|
133
|
-
this.dirty = true;
|
|
197
|
+
if (typeof agent !== 'string' || !agent)
|
|
198
|
+
throw new TypeError('Agent tool must be a nonempty string');
|
|
199
|
+
this.pendingOperations.push({ kind: 'agent', tool: agent, at: Date.now() });
|
|
134
200
|
}
|
|
135
|
-
/** Queue a write
|
|
201
|
+
/** Queue a write. A failed attempt resolves false and retains the entry for a later sync. */
|
|
136
202
|
async write(entry) {
|
|
137
|
-
if (
|
|
138
|
-
await this.init();
|
|
139
|
-
const fullEntry = {
|
|
140
|
-
...entry,
|
|
141
|
-
id: crypto.randomUUID(),
|
|
142
|
-
createdAt: new Date(),
|
|
143
|
-
lastAccessed: new Date(),
|
|
144
|
-
accessCount: 0,
|
|
145
|
-
};
|
|
146
|
-
if (this.writeQueue.length >= this.config.writeQueueSize) {
|
|
203
|
+
if (this.shuttingDown)
|
|
147
204
|
return false;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
205
|
+
this.initialize();
|
|
206
|
+
if (this.pendingWriteCount() >= this.config.writeQueueSize)
|
|
207
|
+
return false;
|
|
208
|
+
const stored = this.prepareEntry(entry);
|
|
209
|
+
return new Promise(settle => {
|
|
210
|
+
this.pendingOperations.push({ kind: 'add', entry: stored, queued: true, settle });
|
|
151
211
|
});
|
|
152
212
|
}
|
|
153
|
-
/**
|
|
213
|
+
/** Stage an entry for later sync and return its ID immediately. */
|
|
154
214
|
writeSync(entry) {
|
|
215
|
+
this.requireWritable();
|
|
216
|
+
const stored = this.prepareEntry(entry);
|
|
217
|
+
this.pendingOperations.push({ kind: 'add', entry: stored, queued: false });
|
|
218
|
+
this.entries.set(stored.id, hydrateEntry(structuredClone(stored)));
|
|
219
|
+
return stored.id;
|
|
220
|
+
}
|
|
221
|
+
requireWritable() {
|
|
155
222
|
if (!this.initialized)
|
|
156
|
-
throw new Error('GlobalBrain not initialized');
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
223
|
+
throw new Error('GlobalBrain not initialized — call init() first');
|
|
224
|
+
if (this.shuttingDown)
|
|
225
|
+
throw new Error('GlobalBrain is shutting down');
|
|
226
|
+
}
|
|
227
|
+
prepareEntry(entry) {
|
|
228
|
+
const now = new Date();
|
|
229
|
+
// Snapshot caller input now so later mutations or serialization errors cannot block a commit.
|
|
230
|
+
try {
|
|
231
|
+
const data = JSON.parse(JSON.stringify({
|
|
232
|
+
...entry, id: crypto.randomUUID(), createdAt: now, lastAccessed: now, accessCount: 0,
|
|
233
|
+
}));
|
|
234
|
+
if (hasUniqueKeys([data], 'id', ['projectId', 'projectName', 'agentTool', 'category', 'content'])) {
|
|
235
|
+
return data;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch { /* Report an invalid input without exposing its contents. */ }
|
|
239
|
+
throw new StorageError('STORE_INVALID_ENTRY', 'The memory entry cannot be stored as JSON.');
|
|
170
240
|
}
|
|
171
|
-
/** Broadcast a memory-write to the realtime activity bus (best-effort). */
|
|
172
241
|
publishWrite(entry) {
|
|
173
242
|
try {
|
|
174
243
|
getBrainBus().publish({
|
|
175
244
|
id: `memory-write:${entry.id}`,
|
|
176
|
-
ts: entry.createdAt
|
|
245
|
+
ts: dateTime(entry.createdAt),
|
|
177
246
|
type: 'memory-write',
|
|
178
247
|
source: entry.agentTool,
|
|
179
|
-
detail: `[${entry.category}] ${
|
|
248
|
+
detail: `[${entry.category}] ${entry.content.slice(0, 220)}`,
|
|
180
249
|
meta: {
|
|
181
250
|
category: entry.category,
|
|
182
|
-
importance: entry.importance,
|
|
251
|
+
importance: boundedSignal(entry.importance),
|
|
183
252
|
projectName: entry.projectName,
|
|
184
253
|
projectId: entry.projectId,
|
|
185
254
|
},
|
|
186
255
|
});
|
|
187
256
|
}
|
|
188
|
-
catch { /*
|
|
257
|
+
catch { /* Activity subscribers cannot affect the outcome of a commit. */ }
|
|
189
258
|
}
|
|
190
|
-
/** Recall entries
|
|
259
|
+
/** Recall matching entries across projects and agents. */
|
|
191
260
|
recall(opts = {}) {
|
|
192
261
|
if (!this.initialized)
|
|
193
262
|
return [];
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
263
|
+
const keywords = normalizeKeywords(opts.keywords);
|
|
264
|
+
const result = this.findMatches(opts, keywords)
|
|
265
|
+
.sort((a, b) => b.relevance - a.relevance || this.compareEntries(a.entry, b.entry))
|
|
266
|
+
.slice(0, nonNegativeInt(opts.limit, 20))
|
|
267
|
+
.map(match => match.entry);
|
|
268
|
+
this.recordRecall(result.length);
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
/** Recall entries with heuristic ranking signals and an explanation of the match. */
|
|
272
|
+
recallScored(opts = {}) {
|
|
273
|
+
if (!this.initialized)
|
|
274
|
+
return [];
|
|
275
|
+
const keywords = normalizeKeywords(opts.keywords);
|
|
276
|
+
const candidates = this.findMatches(opts, keywords);
|
|
277
|
+
const evidence = new Map();
|
|
278
|
+
for (const { entry } of candidates) {
|
|
279
|
+
if (normalizeText(entry.content))
|
|
280
|
+
evidence.set(evidenceKey(entry), new Set());
|
|
281
|
+
}
|
|
282
|
+
for (const entry of this.entries.values()) {
|
|
283
|
+
evidence.get(evidenceKey(entry))?.add(entry.agentTool);
|
|
284
|
+
}
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
const minConfidence = boundedSignal(opts.minConfidence);
|
|
287
|
+
const result = candidates
|
|
288
|
+
.map(match => {
|
|
289
|
+
const entry = match.entry;
|
|
290
|
+
const peers = [...(evidence.get(evidenceKey(entry)) ?? [])]
|
|
291
|
+
.filter(tool => tool !== entry.agentTool)
|
|
292
|
+
.sort(compareIds);
|
|
293
|
+
return { ...entry, confidence: this.computeConfidence(match, keywords.length, peers, now) };
|
|
294
|
+
})
|
|
295
|
+
.filter(entry => entry.confidence.score >= minConfidence)
|
|
296
|
+
.sort((a, b) => b.confidence.score - a.confidence.score || this.compareEntries(a, b))
|
|
297
|
+
.slice(0, nonNegativeInt(opts.limit, 20));
|
|
298
|
+
this.recordRecall(result.length);
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
findMatches(opts, keywords) {
|
|
302
|
+
const minImportance = boundedSignal(opts.minImportance);
|
|
204
303
|
const matches = [];
|
|
205
|
-
for (const
|
|
206
|
-
if (opts.projectId &&
|
|
304
|
+
for (const entry of this.entries.values()) {
|
|
305
|
+
if (opts.projectId && entry.projectId !== opts.projectId)
|
|
207
306
|
continue;
|
|
208
|
-
if (opts.agentTool &&
|
|
307
|
+
if (opts.agentTool && entry.agentTool !== opts.agentTool)
|
|
209
308
|
continue;
|
|
210
|
-
if (opts.category &&
|
|
309
|
+
if (opts.category && entry.category !== opts.category)
|
|
211
310
|
continue;
|
|
212
|
-
if (
|
|
311
|
+
if (boundedSignal(entry.importance) < minImportance)
|
|
213
312
|
continue;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
313
|
+
if (!keywords.length) {
|
|
314
|
+
matches.push({ entry, relevance: 0.5, matchedKeywords: [] });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const content = normalizeText(entry.content);
|
|
318
|
+
const category = normalizeText(entry.category);
|
|
319
|
+
const metadata = searchableMetadata(entry.metadata);
|
|
320
|
+
const project = normalizeText(entry.projectName);
|
|
321
|
+
const tool = normalizeText(entry.agentTool);
|
|
322
|
+
const matchedKeywords = [];
|
|
323
|
+
let weight = 0;
|
|
324
|
+
for (const term of keywords) {
|
|
325
|
+
if (content.includes(term))
|
|
326
|
+
weight += 1;
|
|
327
|
+
else if (category.includes(term))
|
|
328
|
+
weight += 0.9;
|
|
329
|
+
else if (metadata.includes(term))
|
|
330
|
+
weight += 0.55;
|
|
331
|
+
else if (project.includes(term) || tool.includes(term))
|
|
332
|
+
weight += 0.25;
|
|
333
|
+
else
|
|
234
334
|
continue;
|
|
335
|
+
matchedKeywords.push(term);
|
|
235
336
|
}
|
|
236
|
-
|
|
337
|
+
if (matchedKeywords.length)
|
|
338
|
+
matches.push({ entry, relevance: weight / keywords.length, matchedKeywords });
|
|
237
339
|
}
|
|
238
|
-
matches
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (
|
|
340
|
+
return matches;
|
|
341
|
+
}
|
|
342
|
+
compareEntries(a, b) {
|
|
343
|
+
return boundedSignal(b.importance) - boundedSignal(a.importance) ||
|
|
344
|
+
dateTime(b.lastAccessed) - dateTime(a.lastAccessed) ||
|
|
345
|
+
compareIds(a.id, b.id);
|
|
346
|
+
}
|
|
347
|
+
recordRecall(count) {
|
|
348
|
+
if (count > 0)
|
|
247
349
|
this.stats.hits++;
|
|
248
350
|
else
|
|
249
351
|
this.stats.misses++;
|
|
250
|
-
return result;
|
|
251
352
|
}
|
|
252
|
-
|
|
353
|
+
computeConfidence(match, totalKeywords, peers, now) {
|
|
354
|
+
const createdAt = dateTime(match.entry.createdAt);
|
|
355
|
+
const recency = createdAt > 0 && createdAt <= now ? Math.pow(0.5, (now - createdAt) / (30 * DAY_MS)) : 0;
|
|
356
|
+
const importance = boundedSignal(match.entry.importance);
|
|
357
|
+
const corroboration = Math.min(1, peers.length / 2);
|
|
358
|
+
const usage = Math.min(1, Math.log10(nonNegativeInt(match.entry.accessCount) + 1) / Math.log10(21));
|
|
359
|
+
const round = (value) => Math.round(boundedSignal(value) * 1000) / 1000;
|
|
360
|
+
const score = round(match.relevance * 0.30 + importance * 0.25 + recency * 0.20 + usage * 0.15 + corroboration * 0.10);
|
|
361
|
+
return {
|
|
362
|
+
score,
|
|
363
|
+
level: score >= 0.66 ? 'high' : score >= 0.33 ? 'medium' : 'low',
|
|
364
|
+
signals: {
|
|
365
|
+
relevance: round(match.relevance),
|
|
366
|
+
recency: round(recency),
|
|
367
|
+
importance: round(importance),
|
|
368
|
+
corroboration: round(corroboration),
|
|
369
|
+
usage: round(usage),
|
|
370
|
+
},
|
|
371
|
+
explanation: { matchedKeywords: match.matchedKeywords, totalKeywords, corroboratingAgents: peers },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/** Bump an entry's access count without replacing its persisted snapshot. */
|
|
253
375
|
touch(id) {
|
|
376
|
+
if (!this.initialized || this.shuttingDown)
|
|
377
|
+
return;
|
|
254
378
|
const entry = this.entries.get(id);
|
|
255
379
|
if (!entry)
|
|
256
380
|
return;
|
|
257
|
-
|
|
258
|
-
entry.
|
|
259
|
-
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
entry.accessCount = nonNegativeInt(nonNegativeInt(entry.accessCount) + 1);
|
|
383
|
+
entry.lastAccessed = new Date(Math.max(dateTime(entry.lastAccessed), now));
|
|
384
|
+
this.pendingOperations.push({ kind: 'touch', id, at: now });
|
|
260
385
|
}
|
|
261
386
|
recallByIds(ids) {
|
|
262
387
|
const out = [];
|
|
263
388
|
for (const id of ids) {
|
|
264
|
-
const
|
|
265
|
-
if (
|
|
266
|
-
out.push(
|
|
389
|
+
const entry = this.entries.get(id);
|
|
390
|
+
if (entry)
|
|
391
|
+
out.push(entry);
|
|
267
392
|
}
|
|
268
393
|
return out;
|
|
269
394
|
}
|
|
270
|
-
/** Chronological
|
|
395
|
+
/** Chronological memory stream for dashboards and handoffs. */
|
|
271
396
|
timeline(opts = {}) {
|
|
272
397
|
if (!this.initialized)
|
|
273
398
|
return [];
|
|
274
|
-
const limit = opts.limit ?? 50;
|
|
275
399
|
return Array.from(this.entries.values())
|
|
276
|
-
.filter(
|
|
277
|
-
.filter(
|
|
278
|
-
.filter(
|
|
279
|
-
.sort((a, b) => b.createdAt
|
|
280
|
-
.slice(0, limit)
|
|
281
|
-
.map(
|
|
282
|
-
id
|
|
283
|
-
projectId: e.projectId,
|
|
284
|
-
projectName: e.projectName,
|
|
285
|
-
agentTool: e.agentTool,
|
|
286
|
-
category: e.category,
|
|
287
|
-
content: e.content,
|
|
288
|
-
importance: e.importance,
|
|
289
|
-
createdAt: e.createdAt,
|
|
290
|
-
lastAccessed: e.lastAccessed,
|
|
291
|
-
metadata: e.metadata,
|
|
400
|
+
.filter(entry => !opts.projectId || entry.projectId === opts.projectId)
|
|
401
|
+
.filter(entry => !opts.agentTool || entry.agentTool === opts.agentTool)
|
|
402
|
+
.filter(entry => !opts.category || entry.category === opts.category)
|
|
403
|
+
.sort((a, b) => dateTime(b.createdAt) - dateTime(a.createdAt) || compareIds(a.id, b.id))
|
|
404
|
+
.slice(0, nonNegativeInt(opts.limit, 50))
|
|
405
|
+
.map(({ id, projectId, projectName, agentTool, category, content, importance, createdAt, lastAccessed, metadata }) => ({
|
|
406
|
+
id, projectId, projectName, agentTool, category, content, importance, createdAt, lastAccessed, metadata,
|
|
292
407
|
}));
|
|
293
408
|
}
|
|
294
409
|
getStats() {
|
|
295
|
-
this.
|
|
296
|
-
return {
|
|
410
|
+
const checks = this.stats.hits + this.stats.misses;
|
|
411
|
+
return {
|
|
412
|
+
...this.stats,
|
|
413
|
+
totalEntries: this.entries.size,
|
|
414
|
+
pendingWrites: this.pendingWriteCount(),
|
|
415
|
+
hitRate: checks ? this.stats.hits / checks : 0,
|
|
416
|
+
uptime: Date.now() - this.startedAt,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
pendingWriteCount() {
|
|
420
|
+
return this.pendingOperations.filter(operation => operation.kind === 'add' && operation.queued).length;
|
|
297
421
|
}
|
|
298
|
-
/** Absolute path to the on-disk brain store
|
|
422
|
+
/** Absolute path to the on-disk brain store. */
|
|
299
423
|
get dbPath() {
|
|
300
424
|
return this.config.dbPath;
|
|
301
425
|
}
|
|
302
|
-
|
|
426
|
+
getStorageHealth() {
|
|
427
|
+
return {
|
|
428
|
+
status: this.lastError ? 'error' : this.pendingOperations.length ? 'pending' : 'healthy',
|
|
429
|
+
dbPath: this.dbPath,
|
|
430
|
+
initialized: this.initialized,
|
|
431
|
+
pendingOperations: this.pendingOperations.length,
|
|
432
|
+
lastSuccessfulSync: this.lastSuccessfulSync,
|
|
433
|
+
lastError: this.lastError ? { ...this.lastError } : null,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/** Flush pending operations as one atomic commit against the latest snapshot. */
|
|
303
437
|
async sync() {
|
|
438
|
+
if (this.syncInFlight) {
|
|
439
|
+
await this.syncInFlight;
|
|
440
|
+
return this.sync();
|
|
441
|
+
}
|
|
304
442
|
if (!this.initialized)
|
|
305
443
|
return;
|
|
306
|
-
if (this.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
444
|
+
if (!this.pendingOperations.length) {
|
|
445
|
+
this.refreshSnapshot();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
this.syncInFlight = this.commitPending();
|
|
449
|
+
try {
|
|
450
|
+
await this.syncInFlight;
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
this.syncInFlight = null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async commitPending() {
|
|
457
|
+
let releaseLock;
|
|
458
|
+
let committed;
|
|
459
|
+
try {
|
|
460
|
+
releaseLock = await this.acquireLock();
|
|
461
|
+
// Capture operations after acquiring the lock. No async work follows inside the merge.
|
|
462
|
+
const operations = this.pendingOperations;
|
|
463
|
+
const additions = operations.filter((operation) => operation.kind === 'add');
|
|
464
|
+
const data = this.mergeOperations(this.readLatestSnapshot().data, operations);
|
|
465
|
+
const { payload, pruned } = this.serializeSnapshot(data, additions);
|
|
466
|
+
const entries = this.hydrateEntries(data);
|
|
467
|
+
const sizeMB = Buffer.byteLength(payload) / BYTES_PER_MB;
|
|
468
|
+
this.replaceSnapshot(payload);
|
|
469
|
+
this.adoptSnapshot(data, sizeMB, entries);
|
|
470
|
+
const now = new Date();
|
|
471
|
+
this.stats.lastSync = now;
|
|
472
|
+
if (pruned)
|
|
473
|
+
this.stats.lastPrune = now;
|
|
474
|
+
this.lastSuccessfulSync = now.toISOString();
|
|
475
|
+
this.lastError = null;
|
|
476
|
+
committed = additions;
|
|
477
|
+
operations.length = 0;
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
const failure = this.recordError(error);
|
|
481
|
+
for (const operation of this.pendingOperations) {
|
|
482
|
+
if (operation.kind === 'add') {
|
|
483
|
+
operation.settle?.(false);
|
|
484
|
+
operation.settle = undefined;
|
|
316
485
|
}
|
|
317
486
|
}
|
|
318
|
-
|
|
487
|
+
throw failure;
|
|
319
488
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
489
|
+
finally {
|
|
490
|
+
try {
|
|
491
|
+
releaseLock?.();
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
// Lock cleanup cannot reverse a successful replacement or make its operations retryable.
|
|
495
|
+
if (committed)
|
|
496
|
+
this.recordError(error);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (const operation of committed) {
|
|
500
|
+
operation.settle?.(true);
|
|
501
|
+
this.publishWrite(operation.entry);
|
|
324
502
|
}
|
|
325
|
-
this.maybeAutoMaintenance();
|
|
326
503
|
}
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
504
|
+
recordError(error) {
|
|
505
|
+
const failure = error instanceof StorageError
|
|
506
|
+
? error
|
|
507
|
+
: new StorageError('STORE_WRITE_FAILED', 'Could not commit the global brain store.');
|
|
508
|
+
this.lastError = { code: failure.code, message: failure.message, at: new Date().toISOString() };
|
|
509
|
+
return failure;
|
|
510
|
+
}
|
|
511
|
+
readLatestSnapshot() {
|
|
512
|
+
let raw;
|
|
513
|
+
try {
|
|
514
|
+
raw = fs.readFileSync(this.dbPath, 'utf8');
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
if (errorCode(error) === 'ENOENT') {
|
|
518
|
+
return { data: { schemaVersion: 1, entries: [], projects: [], agents: [] }, sizeMB: 0 };
|
|
340
519
|
}
|
|
520
|
+
throw new StorageError('STORE_READ_FAILED', `Could not read the global brain store (${errorCode(error) ?? 'I/O error'}).`);
|
|
341
521
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
this.dirty = true;
|
|
346
|
-
this.stats.lastPrune = new Date();
|
|
522
|
+
let data;
|
|
523
|
+
try {
|
|
524
|
+
data = JSON.parse(raw);
|
|
347
525
|
}
|
|
526
|
+
catch {
|
|
527
|
+
throw new StorageError('STORE_INVALID_JSON', 'The global brain store contains invalid JSON.');
|
|
528
|
+
}
|
|
529
|
+
validateSnapshot(data);
|
|
530
|
+
return { data, sizeMB: Buffer.byteLength(raw) / BYTES_PER_MB };
|
|
348
531
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
schemaVersion: 1,
|
|
352
|
-
entries: Array.from(this.entries.values()),
|
|
353
|
-
projects: Array.from(this.projects.values()),
|
|
354
|
-
agents: Array.from(this.agents.values()),
|
|
355
|
-
};
|
|
356
|
-
// Atomic write — write to .tmp then rename
|
|
357
|
-
const tmp = this.config.dbPath + '.tmp';
|
|
358
|
-
fs.writeFileSync(tmp, JSON.stringify(shape));
|
|
359
|
-
fs.renameSync(tmp, this.config.dbPath);
|
|
532
|
+
hydrateEntries(data) {
|
|
533
|
+
return new Map(data.entries.map(entry => [entry.id, hydrateEntry(entry)]));
|
|
360
534
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
this.
|
|
365
|
-
|
|
366
|
-
}, this.config.syncIntervalMs);
|
|
535
|
+
adoptSnapshot(data, sizeMB, entries) {
|
|
536
|
+
this.entries = entries;
|
|
537
|
+
this.stats.totalProjects = data.projects.length;
|
|
538
|
+
this.stats.totalAgents = data.agents.length;
|
|
539
|
+
this.stats.totalSizeMB = sizeMB;
|
|
367
540
|
}
|
|
368
|
-
|
|
369
|
-
if (!this.initialized)
|
|
370
|
-
return;
|
|
371
|
-
this.stats.totalProjects = this.projects.size;
|
|
372
|
-
this.stats.totalAgents = this.agents.size;
|
|
373
|
-
this.stats.totalEntries = this.entries.size;
|
|
374
|
-
this.stats.pendingWrites = this.writeQueue.length;
|
|
375
|
-
const totalChecks = this.stats.hits + this.stats.misses;
|
|
376
|
-
this.stats.hitRate = totalChecks > 0 ? this.stats.hits / totalChecks : 0;
|
|
377
|
-
this.stats.uptime = Date.now() - this.startedAt;
|
|
378
|
-
}
|
|
379
|
-
estimateSizeMB() {
|
|
541
|
+
refreshSnapshot() {
|
|
380
542
|
try {
|
|
381
|
-
const
|
|
382
|
-
|
|
543
|
+
const { data, sizeMB } = this.readLatestSnapshot();
|
|
544
|
+
this.adoptSnapshot(data, sizeMB, this.hydrateEntries(data));
|
|
545
|
+
if (this.lastError?.code !== 'STORE_LOCK_FAILED')
|
|
546
|
+
this.lastError = null;
|
|
383
547
|
}
|
|
384
|
-
catch {
|
|
385
|
-
|
|
548
|
+
catch (error) {
|
|
549
|
+
throw this.recordError(error);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
mergeOperations(data, operations) {
|
|
553
|
+
const entries = new Map(data.entries.map(entry => [entry.id, entry]));
|
|
554
|
+
const projects = new Map(data.projects.map(project => [project.id, project]));
|
|
555
|
+
const agents = new Map(data.agents.map(agent => [agent.tool, agent]));
|
|
556
|
+
for (const operation of operations) {
|
|
557
|
+
switch (operation.kind) {
|
|
558
|
+
case 'add': {
|
|
559
|
+
if (!entries.has(operation.entry.id))
|
|
560
|
+
entries.set(operation.entry.id, operation.entry);
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case 'touch': {
|
|
564
|
+
const entry = entries.get(operation.id);
|
|
565
|
+
if (entry) {
|
|
566
|
+
entries.set(operation.id, {
|
|
567
|
+
...entry,
|
|
568
|
+
accessCount: nonNegativeInt(nonNegativeInt(entry.accessCount) + 1),
|
|
569
|
+
lastAccessed: new Date(Math.max(dateTime(entry.lastAccessed), operation.at)),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
case 'project': {
|
|
575
|
+
const { project } = operation;
|
|
576
|
+
const existing = projects.get(project.id);
|
|
577
|
+
const lastSeen = nonNegativeInt(existing?.lastSeen);
|
|
578
|
+
const latest = existing && lastSeen > project.lastSeen ? existing : project;
|
|
579
|
+
projects.set(project.id, {
|
|
580
|
+
...existing,
|
|
581
|
+
...latest,
|
|
582
|
+
firstSeen: Math.min(nonNegativeInt(existing?.firstSeen) || project.firstSeen, project.firstSeen),
|
|
583
|
+
lastSeen: Math.max(lastSeen, project.lastSeen),
|
|
584
|
+
});
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
case 'agent': {
|
|
588
|
+
const existing = agents.get(operation.tool);
|
|
589
|
+
agents.set(operation.tool, {
|
|
590
|
+
...existing,
|
|
591
|
+
tool: operation.tool,
|
|
592
|
+
firstSeen: Math.min(nonNegativeInt(existing?.firstSeen) || operation.at, operation.at),
|
|
593
|
+
lastSeen: Math.max(nonNegativeInt(existing?.lastSeen), operation.at),
|
|
594
|
+
sessionCount: nonNegativeInt(nonNegativeInt(existing?.sessionCount) + 1),
|
|
595
|
+
});
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return { ...data, entries: [...entries.values()], projects: [...projects.values()], agents: [...agents.values()] };
|
|
601
|
+
}
|
|
602
|
+
serializeSnapshot(data, additions) {
|
|
603
|
+
const payload = JSON.stringify(data);
|
|
604
|
+
if (Buffer.byteLength(payload) / BYTES_PER_MB <= this.config.autoPruneMB)
|
|
605
|
+
return { payload, pruned: false };
|
|
606
|
+
const cutoff = Date.now() - 90 * DAY_MS;
|
|
607
|
+
const addedIds = new Set(additions.map(({ entry }) => entry.id));
|
|
608
|
+
const retained = data.entries.filter(entry => {
|
|
609
|
+
const lastAccess = dateTime(entry.lastAccessed);
|
|
610
|
+
return addedIds.has(entry.id) || !lastAccess || lastAccess >= cutoff ||
|
|
611
|
+
boundedSignal(entry.importance) >= 0.3 || nonNegativeInt(entry.accessCount) >= 2;
|
|
612
|
+
});
|
|
613
|
+
if (retained.length === data.entries.length)
|
|
614
|
+
return { payload, pruned: false };
|
|
615
|
+
data.entries = retained;
|
|
616
|
+
return { payload: JSON.stringify(data), pruned: true };
|
|
617
|
+
}
|
|
618
|
+
async acquireLock() {
|
|
619
|
+
const lockPath = `${this.dbPath}.lock`;
|
|
620
|
+
const token = JSON.stringify({ pid: process.pid, id: crypto.randomUUID(), acquiredAt: new Date().toISOString() });
|
|
621
|
+
const deadline = performance.now() + nonNegativeInt(this.config.lockTimeoutMs, LOCK_TIMEOUT_MS);
|
|
622
|
+
for (;;) {
|
|
623
|
+
let fd;
|
|
624
|
+
try {
|
|
625
|
+
fd = fs.openSync(lockPath, 'wx', 0o600);
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
if (errorCode(error) !== 'EEXIST') {
|
|
629
|
+
throw new StorageError('STORE_LOCK_FAILED', `Could not acquire the global brain store lock (${errorCode(error) ?? 'I/O error'}).`);
|
|
630
|
+
}
|
|
631
|
+
const remaining = deadline - performance.now();
|
|
632
|
+
if (remaining <= 0)
|
|
633
|
+
break;
|
|
634
|
+
await delay(Math.min(remaining, LOCK_POLL_MS));
|
|
635
|
+
if (performance.now() >= deadline)
|
|
636
|
+
break;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
let identity;
|
|
640
|
+
try {
|
|
641
|
+
identity = fs.fstatSync(fd, { bigint: true });
|
|
642
|
+
fs.writeFileSync(fd, token, 'utf8');
|
|
643
|
+
fs.closeSync(fd);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
try {
|
|
647
|
+
fs.closeSync(fd);
|
|
648
|
+
}
|
|
649
|
+
catch { /* preserve the lock initialization error */ }
|
|
650
|
+
// A failed token write can leave partial bytes. Check the exclusive file's identity.
|
|
651
|
+
try {
|
|
652
|
+
const current = fs.lstatSync(lockPath, { bigint: true });
|
|
653
|
+
if (identity && current.dev === identity.dev && current.ino === identity.ino)
|
|
654
|
+
fs.unlinkSync(lockPath);
|
|
655
|
+
}
|
|
656
|
+
catch { /* report the lock initialization error */ }
|
|
657
|
+
throw new StorageError('STORE_LOCK_FAILED', 'Could not initialize the global brain store lock.');
|
|
658
|
+
}
|
|
659
|
+
return () => {
|
|
660
|
+
try {
|
|
661
|
+
if (fs.readFileSync(lockPath, 'utf8') === token) {
|
|
662
|
+
fs.unlinkSync(lockPath);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
throw new StorageError('STORE_LOCK_FAILED', 'Could not release the global brain store lock.');
|
|
668
|
+
}
|
|
669
|
+
throw new StorageError('STORE_LOCK_FAILED', 'Global brain store lock ownership changed before release.');
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
throw new StorageError('STORE_LOCK_TIMEOUT', 'Timed out waiting for the global brain store lock.');
|
|
673
|
+
}
|
|
674
|
+
replaceSnapshot(payload) {
|
|
675
|
+
const tmp = `${this.dbPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
676
|
+
let fd;
|
|
677
|
+
let ownsTemp = false;
|
|
678
|
+
try {
|
|
679
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
680
|
+
ownsTemp = true;
|
|
681
|
+
fs.writeFileSync(fd, payload, 'utf8');
|
|
682
|
+
fs.fsyncSync(fd);
|
|
683
|
+
fs.closeSync(fd);
|
|
684
|
+
fd = undefined;
|
|
685
|
+
fs.renameSync(tmp, this.dbPath);
|
|
686
|
+
ownsTemp = false;
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
throw new StorageError('STORE_WRITE_FAILED', `Could not replace the global brain store (${errorCode(error) ?? 'I/O error'}).`);
|
|
690
|
+
}
|
|
691
|
+
finally {
|
|
692
|
+
if (fd !== undefined) {
|
|
693
|
+
try {
|
|
694
|
+
fs.closeSync(fd);
|
|
695
|
+
}
|
|
696
|
+
catch { /* preserve the original write error */ }
|
|
697
|
+
}
|
|
698
|
+
if (ownsTemp) {
|
|
699
|
+
try {
|
|
700
|
+
fs.unlinkSync(tmp);
|
|
701
|
+
}
|
|
702
|
+
catch { /* preserve the original write error */ }
|
|
703
|
+
}
|
|
386
704
|
}
|
|
387
705
|
}
|
|
388
706
|
async shutdown() {
|
|
389
|
-
|
|
707
|
+
this.shuttingDown = true;
|
|
708
|
+
if (this.syncTimer) {
|
|
390
709
|
clearInterval(this.syncTimer);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
710
|
+
this.syncTimer = null;
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
if (this.pendingOperations.length || this.syncInFlight)
|
|
714
|
+
await this.sync();
|
|
715
|
+
this.initialized = false;
|
|
716
|
+
if (GlobalBrain.instance === this)
|
|
717
|
+
GlobalBrain.instance = null;
|
|
718
|
+
}
|
|
719
|
+
finally {
|
|
720
|
+
this.shuttingDown = false;
|
|
721
|
+
}
|
|
394
722
|
}
|
|
395
723
|
}
|
|
396
724
|
GlobalBrain.instance = null;
|