@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +411 -372
  2. package/LICENSE +21 -21
  3. package/README.md +428 -427
  4. package/dist/adapters/aider.js +13 -13
  5. package/dist/adapters/base-adapter.js +14 -14
  6. package/dist/adapters/codex.js +8 -8
  7. package/dist/adapters/roo-code.js +18 -18
  8. package/dist/brain/adr-engine.js +11 -11
  9. package/dist/brain/analyzer.js +30 -30
  10. package/dist/brain/auto-update.js +1 -1
  11. package/dist/brain/global-brain.d.ts +55 -46
  12. package/dist/brain/global-brain.d.ts.map +1 -1
  13. package/dist/brain/global-brain.js +586 -258
  14. package/dist/brain/global-brain.js.map +1 -1
  15. package/dist/brain/health-score.js +19 -19
  16. package/dist/brain/learning-engine.js +59 -59
  17. package/dist/brain/mcp-server.d.ts +2 -0
  18. package/dist/brain/mcp-server.d.ts.map +1 -1
  19. package/dist/brain/mcp-server.js +46 -5
  20. package/dist/brain/mcp-server.js.map +1 -1
  21. package/dist/brain/plugin-system.js +23 -23
  22. package/dist/brain/pr-generator.js +19 -19
  23. package/dist/brain/project-context.js +1 -1
  24. package/dist/brain/project-context.js.map +1 -1
  25. package/dist/brain/report-generator.js +270 -270
  26. package/dist/cli.js +105 -34
  27. package/dist/cli.js.map +1 -1
  28. package/dist/dashboard/server.js +2 -2
  29. package/dist/dashboard/server.js.map +1 -1
  30. package/dist/dashboard/v6-dashboard-html.d.ts +1 -1
  31. package/dist/dashboard/v6-dashboard-html.d.ts.map +1 -1
  32. package/dist/dashboard/v6-dashboard-html.js +10 -5
  33. package/dist/dashboard/v6-dashboard-html.js.map +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/types.d.ts +50 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/watchers/git-watcher.d.ts.map +1 -1
  41. package/dist/watchers/git-watcher.js +1 -1
  42. package/dist/watchers/git-watcher.js.map +1 -1
  43. package/docs/launch/RELEASE_CHECKLIST_v7.0.0.md +24 -0
  44. package/docs/launch/RELEASE_NOTES_v7.0.0.md +44 -0
  45. package/package.json +17 -14
  46. 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 DEFAULT_DIR = path.join(os.homedir(), '.shadow-brain');
20
- const DEFAULT_DB_PATH = path.join(DEFAULT_DIR, 'global.json');
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
- * Singleton Global Brain — the source of truth for all projects + agents.
31
- *
32
- * Storage: JSON file at ~/.shadow-brain/global.json (zero new deps).
33
- * In-memory Map keyed by entry.id for O(1) ID lookups.
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.projects = new Map();
39
- this.agents = new Map();
40
- this.writeQueue = [];
123
+ this.pendingOperations = [];
41
124
  this.syncTimer = null;
42
- this.initialized = false;
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
- if (!GlobalBrain.instance) {
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
- fs.mkdirSync(path.dirname(this.config.dbPath), { recursive: true });
71
- if (fs.existsSync(this.config.dbPath)) {
157
+ try {
72
158
  try {
73
- const raw = fs.readFileSync(this.config.dbPath, 'utf-8');
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
- // Corrupted file → start fresh, back up the bad one
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
- if (!this.initialized)
107
- throw new Error('GlobalBrain not initialized — call init() first');
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
- const existing = this.projects.get(id);
112
- this.projects.set(id, {
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
- const now = Date.now();
126
- const existing = this.agents.get(agent);
127
- this.agents.set(agent, {
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 — resolves true on success */
201
+ /** Queue a write. A failed attempt resolves false and retains the entry for a later sync. */
136
202
  async write(entry) {
137
- if (!this.initialized)
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
- return new Promise((resolve) => {
150
- this.writeQueue.push({ entry: fullEntry, resolve });
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
- /** Direct synchronous write — bypasses queue */
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
- const id = crypto.randomUUID();
158
- const fullEntry = {
159
- ...entry,
160
- id,
161
- createdAt: new Date(),
162
- lastAccessed: new Date(),
163
- accessCount: 0,
164
- };
165
- this.entries.set(id, fullEntry);
166
- this.dirty = true;
167
- this.stats.totalEntries = this.entries.size;
168
- this.publishWrite(fullEntry);
169
- return id;
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 instanceof Date ? entry.createdAt.getTime() : Date.now(),
245
+ ts: dateTime(entry.createdAt),
177
246
  type: 'memory-write',
178
247
  source: entry.agentTool,
179
- detail: `[${entry.category}] ${String(entry.content).slice(0, 220)}`,
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 { /* bus failures must never break a write */ }
257
+ catch { /* Activity subscribers cannot affect the outcome of a commit. */ }
189
258
  }
190
- /** Recall entries — cross-project + cross-agent */
259
+ /** Recall matching entries across projects and agents. */
191
260
  recall(opts = {}) {
192
261
  if (!this.initialized)
193
262
  return [];
194
- const limit = opts.limit ?? 20;
195
- const minImp = opts.minImportance ?? 0;
196
- const weakKeywords = new Set([
197
- 'a', 'an', 'and', 'are', 'as', 'for', 'from', 'in', 'into', 'is', 'of', 'on',
198
- 'or', 'the', 'to', 'with', 'ww2br',
199
- ]);
200
- const kwLower = opts.keywords
201
- ?.map(k => k.trim().toLowerCase())
202
- .filter(k => k && !/^\d{1,2}$/.test(k))
203
- .filter(k => k.length > 2 && !weakKeywords.has(k)) ?? null;
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 e of this.entries.values()) {
206
- if (opts.projectId && e.projectId !== 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 && e.agentTool !== opts.agentTool)
307
+ if (opts.agentTool && entry.agentTool !== opts.agentTool)
209
308
  continue;
210
- if (opts.category && e.category !== opts.category)
309
+ if (opts.category && entry.category !== opts.category)
211
310
  continue;
212
- if (e.importance < minImp)
311
+ if (boundedSignal(entry.importance) < minImportance)
213
312
  continue;
214
- let score = 0;
215
- if (kwLower?.length) {
216
- const projectName = e.projectName.toLowerCase();
217
- const agentTool = e.agentTool.toLowerCase();
218
- const category = e.category.toLowerCase();
219
- const content = e.content.toLowerCase();
220
- const metadata = JSON.stringify(e.metadata ?? {}).toLowerCase();
221
- for (const k of kwLower) {
222
- if (category.includes(k))
223
- score += 8;
224
- if (content.includes(k))
225
- score += 5;
226
- if (metadata.includes(k))
227
- score += 3;
228
- if (agentTool.includes(k))
229
- score += 1;
230
- if (projectName.includes(k))
231
- score += 1;
232
- }
233
- if (score <= 0)
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
- matches.push({ entry: e, score });
337
+ if (matchedKeywords.length)
338
+ matches.push({ entry, relevance: weight / keywords.length, matchedKeywords });
237
339
  }
238
- matches.sort((a, b) => {
239
- if (b.score !== a.score)
240
- return b.score - a.score;
241
- if (b.entry.importance !== a.entry.importance)
242
- return b.entry.importance - a.entry.importance;
243
- return b.entry.lastAccessed.getTime() - a.entry.lastAccessed.getTime();
244
- });
245
- const result = matches.slice(0, limit).map(m => m.entry);
246
- if (result.length)
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
- /** Bump access count for an entry */
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
- entry.accessCount++;
258
- entry.lastAccessed = new Date();
259
- this.dirty = true;
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 e = this.entries.get(id);
265
- if (e)
266
- out.push(e);
389
+ const entry = this.entries.get(id);
390
+ if (entry)
391
+ out.push(entry);
267
392
  }
268
393
  return out;
269
394
  }
270
- /** Chronological proof stream for dashboards, handoffs, and launch demos. */
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(e => !opts.projectId || e.projectId === opts.projectId)
277
- .filter(e => !opts.agentTool || e.agentTool === opts.agentTool)
278
- .filter(e => !opts.category || e.category === opts.category)
279
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
280
- .slice(0, limit)
281
- .map(e => ({
282
- id: e.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.refreshStats();
296
- return { ...this.stats };
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 — used by the dashboard file watcher. */
422
+ /** Absolute path to the on-disk brain store. */
299
423
  get dbPath() {
300
424
  return this.config.dbPath;
301
425
  }
302
- /** Force a sync of pending writes to disk */
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.writeQueue.length > 0) {
307
- const pending = this.writeQueue.splice(0, this.writeQueue.length);
308
- for (const { entry, resolve } of pending) {
309
- try {
310
- this.entries.set(entry.id, entry);
311
- this.publishWrite(entry);
312
- resolve(true);
313
- }
314
- catch {
315
- resolve(false);
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
- this.dirty = true;
487
+ throw failure;
319
488
  }
320
- if (this.dirty) {
321
- this.persist();
322
- this.stats.lastSync = new Date();
323
- this.dirty = false;
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
- maybeAutoMaintenance() {
328
- const sizeMB = this.estimateSizeMB();
329
- this.stats.totalSizeMB = sizeMB;
330
- if (sizeMB > this.config.autoPruneMB)
331
- this.prune();
332
- }
333
- /** Auto-prune: drop low-importance, rarely-accessed entries when over threshold */
334
- prune() {
335
- const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; // 90 days
336
- const toDelete = [];
337
- for (const [id, e] of this.entries) {
338
- if (e.importance < 0.3 && e.accessCount < 2 && e.lastAccessed.getTime() < cutoff) {
339
- toDelete.push(id);
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
- for (const id of toDelete)
343
- this.entries.delete(id);
344
- if (toDelete.length) {
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
- persist() {
350
- const shape = {
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
- startSyncLoop() {
362
- if (this.syncTimer)
363
- clearInterval(this.syncTimer);
364
- this.syncTimer = setInterval(() => {
365
- this.sync().catch(() => { });
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
- refreshStats() {
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 stat = fs.statSync(this.config.dbPath);
382
- return stat.size / (1024 * 1024);
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
- return 0;
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
- if (this.syncTimer)
707
+ this.shuttingDown = true;
708
+ if (this.syncTimer) {
390
709
  clearInterval(this.syncTimer);
391
- await this.sync();
392
- this.initialized = false;
393
- GlobalBrain.instance = null;
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;