@theihtisham/agent-shadow-brain 6.3.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 (43) hide show
  1. package/CHANGELOG.md +411 -391
  2. package/LICENSE +21 -21
  3. package/README.md +428 -438
  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 +52 -63
  12. package/dist/brain/global-brain.d.ts.map +1 -1
  13. package/dist/brain/global-brain.js +577 -339
  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 +1 -0
  18. package/dist/brain/mcp-server.d.ts.map +1 -1
  19. package/dist/brain/mcp-server.js +16 -6
  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/v6-dashboard-html.d.ts +1 -1
  29. package/dist/dashboard/v6-dashboard-html.d.ts.map +1 -1
  30. package/dist/dashboard/v6-dashboard-html.js +5 -5
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/types.d.ts +28 -10
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/watchers/git-watcher.d.ts.map +1 -1
  38. package/dist/watchers/git-watcher.js +1 -1
  39. package/dist/watchers/git-watcher.js.map +1 -1
  40. package/docs/launch/RELEASE_CHECKLIST_v7.0.0.md +24 -0
  41. package/docs/launch/RELEASE_NOTES_v7.0.0.md +44 -0
  42. package/package.json +17 -14
  43. 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,428 +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
- /**
253
- * Recall entries with a per-entry confidence score (v6.3.0).
254
- *
255
- * Same matching as recall(), but each result carries a RecallConfidence so
256
- * the consuming agent can weight memories by recency, author importance,
257
- * cross-agent corroboration, prior usage, and keyword relevance rather than
258
- * trusting every hit equally. Ordering switches from raw keyword score to
259
- * blended confidence so the most trustworthy memories surface first.
260
- */
261
- recallScored(opts = {}) {
262
- if (!this.initialized)
263
- return [];
264
- const limit = opts.limit ?? 20;
265
- const minConfidence = opts.minConfidence ?? 0;
266
- // Pull a wider candidate pool (pre-confidence) so confidence re-ranking has
267
- // room to promote highly-trusted-but-lower-keyword-score memories.
268
- const candidates = this.recall({
269
- projectId: opts.projectId,
270
- agentTool: opts.agentTool,
271
- category: opts.category,
272
- keywords: opts.keywords,
273
- limit: Math.max(limit * 3, 60),
274
- minImportance: opts.minImportance,
275
- });
276
- // Corroboration baseline: distinct agents that have written in each category.
277
- const agentsPerCategory = new Map();
278
- for (const e of this.entries.values()) {
279
- let set = agentsPerCategory.get(e.category);
280
- if (!set) {
281
- set = new Set();
282
- agentsPerCategory.set(e.category, set);
283
- }
284
- set.add(e.agentTool);
285
- }
286
- const hasKeywords = (opts.keywords?.filter(k => k && k.trim().length > 2).length ?? 0) > 0;
287
- const now = Date.now();
288
- const scored = candidates
289
- .map(entry => ({
290
- ...entry,
291
- confidence: this.computeConfidence(entry, {
292
- hasKeywords,
293
- now,
294
- distinctAgentsInCategory: agentsPerCategory.get(entry.category)?.size ?? 1,
295
- }),
296
- }))
297
- .filter(e => e.confidence.score >= minConfidence);
298
- scored.sort((a, b) => b.confidence.score - a.confidence.score);
299
- return scored.slice(0, limit);
300
- }
301
- /** Blend recall signals into a single confidence score in [0,1]. */
302
- computeConfidence(entry, ctx) {
303
- const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
304
- // Relevance: when the caller supplied keywords, a match means relevance is
305
- // high; without keywords we cannot judge relevance, so use a neutral 0.5.
306
- const relevance = ctx.hasKeywords ? 0.9 : 0.5;
307
- // Recency: exponential decay with a 30-day half-life on last access.
308
- const HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
309
- const lastAccess = entry.lastAccessed instanceof Date
310
- ? entry.lastAccessed.getTime()
311
- : new Date(entry.lastAccessed).getTime();
312
- const ageMs = Math.max(0, ctx.now - lastAccess);
313
- const recency = clamp01(Math.pow(0.5, ageMs / HALF_LIFE_MS));
314
- // Importance: author-assigned, already in [0,1] by convention.
315
- const importance = clamp01(entry.importance);
316
- // Corroboration: more distinct agents writing in this category => more
317
- // cross-agent agreement. Saturates at 3 agents.
318
- const corroboration = clamp01((ctx.distinctAgentsInCategory - 1) / 2);
319
- // Usage: log-scaled prior recalls; saturates around ~20 accesses.
320
- const usage = clamp01(Math.log10(entry.accessCount + 1) / Math.log10(21));
321
- // Weighted blend. Relevance + importance dominate; recency/usage refine;
322
- // corroboration is a smaller cross-agent trust nudge.
323
- const score = clamp01(relevance * 0.30 +
324
- importance * 0.25 +
325
- recency * 0.20 +
326
- usage * 0.15 +
327
- corroboration * 0.10);
328
- const level = score >= 0.66 ? 'high' : score >= 0.33 ? 'medium' : 'low';
329
- const round = (n) => Math.round(n * 1000) / 1000;
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);
330
361
  return {
331
- score: round(score),
332
- level,
362
+ score,
363
+ level: score >= 0.66 ? 'high' : score >= 0.33 ? 'medium' : 'low',
333
364
  signals: {
334
- relevance: round(relevance),
365
+ relevance: round(match.relevance),
335
366
  recency: round(recency),
336
367
  importance: round(importance),
337
368
  corroboration: round(corroboration),
338
369
  usage: round(usage),
339
370
  },
371
+ explanation: { matchedKeywords: match.matchedKeywords, totalKeywords, corroboratingAgents: peers },
340
372
  };
341
373
  }
342
- /** Bump access count for an entry */
374
+ /** Bump an entry's access count without replacing its persisted snapshot. */
343
375
  touch(id) {
376
+ if (!this.initialized || this.shuttingDown)
377
+ return;
344
378
  const entry = this.entries.get(id);
345
379
  if (!entry)
346
380
  return;
347
- entry.accessCount++;
348
- entry.lastAccessed = new Date();
349
- 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 });
350
385
  }
351
386
  recallByIds(ids) {
352
387
  const out = [];
353
388
  for (const id of ids) {
354
- const e = this.entries.get(id);
355
- if (e)
356
- out.push(e);
389
+ const entry = this.entries.get(id);
390
+ if (entry)
391
+ out.push(entry);
357
392
  }
358
393
  return out;
359
394
  }
360
- /** Chronological proof stream for dashboards, handoffs, and launch demos. */
395
+ /** Chronological memory stream for dashboards and handoffs. */
361
396
  timeline(opts = {}) {
362
397
  if (!this.initialized)
363
398
  return [];
364
- const limit = opts.limit ?? 50;
365
399
  return Array.from(this.entries.values())
366
- .filter(e => !opts.projectId || e.projectId === opts.projectId)
367
- .filter(e => !opts.agentTool || e.agentTool === opts.agentTool)
368
- .filter(e => !opts.category || e.category === opts.category)
369
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
370
- .slice(0, limit)
371
- .map(e => ({
372
- id: e.id,
373
- projectId: e.projectId,
374
- projectName: e.projectName,
375
- agentTool: e.agentTool,
376
- category: e.category,
377
- content: e.content,
378
- importance: e.importance,
379
- createdAt: e.createdAt,
380
- lastAccessed: e.lastAccessed,
381
- 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,
382
407
  }));
383
408
  }
384
409
  getStats() {
385
- this.refreshStats();
386
- 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
+ };
387
418
  }
388
- /** Absolute path to the on-disk brain store — used by the dashboard file watcher. */
419
+ pendingWriteCount() {
420
+ return this.pendingOperations.filter(operation => operation.kind === 'add' && operation.queued).length;
421
+ }
422
+ /** Absolute path to the on-disk brain store. */
389
423
  get dbPath() {
390
424
  return this.config.dbPath;
391
425
  }
392
- /** 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. */
393
437
  async sync() {
438
+ if (this.syncInFlight) {
439
+ await this.syncInFlight;
440
+ return this.sync();
441
+ }
394
442
  if (!this.initialized)
395
443
  return;
396
- if (this.writeQueue.length > 0) {
397
- const pending = this.writeQueue.splice(0, this.writeQueue.length);
398
- for (const { entry, resolve } of pending) {
399
- try {
400
- this.entries.set(entry.id, entry);
401
- this.publishWrite(entry);
402
- resolve(true);
403
- }
404
- catch {
405
- 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;
406
485
  }
407
486
  }
408
- this.dirty = true;
487
+ throw failure;
488
+ }
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
+ }
409
498
  }
410
- if (this.dirty) {
411
- this.persist();
412
- this.stats.lastSync = new Date();
413
- this.dirty = false;
499
+ for (const operation of committed) {
500
+ operation.settle?.(true);
501
+ this.publishWrite(operation.entry);
414
502
  }
415
- this.maybeAutoMaintenance();
416
503
  }
417
- maybeAutoMaintenance() {
418
- const sizeMB = this.estimateSizeMB();
419
- this.stats.totalSizeMB = sizeMB;
420
- if (sizeMB > this.config.autoPruneMB)
421
- this.prune();
422
- }
423
- /** Auto-prune: drop low-importance, rarely-accessed entries when over threshold */
424
- prune() {
425
- const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; // 90 days
426
- const toDelete = [];
427
- for (const [id, e] of this.entries) {
428
- if (e.importance < 0.3 && e.accessCount < 2 && e.lastAccessed.getTime() < cutoff) {
429
- 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 };
430
519
  }
520
+ throw new StorageError('STORE_READ_FAILED', `Could not read the global brain store (${errorCode(error) ?? 'I/O error'}).`);
521
+ }
522
+ let data;
523
+ try {
524
+ data = JSON.parse(raw);
431
525
  }
432
- for (const id of toDelete)
433
- this.entries.delete(id);
434
- if (toDelete.length) {
435
- this.dirty = true;
436
- this.stats.lastPrune = new Date();
526
+ catch {
527
+ throw new StorageError('STORE_INVALID_JSON', 'The global brain store contains invalid JSON.');
437
528
  }
529
+ validateSnapshot(data);
530
+ return { data, sizeMB: Buffer.byteLength(raw) / BYTES_PER_MB };
438
531
  }
439
- persist() {
440
- const shape = {
441
- schemaVersion: 1,
442
- entries: Array.from(this.entries.values()),
443
- projects: Array.from(this.projects.values()),
444
- agents: Array.from(this.agents.values()),
445
- };
446
- // Atomic write — write to .tmp then rename
447
- const tmp = this.config.dbPath + '.tmp';
448
- fs.writeFileSync(tmp, JSON.stringify(shape));
449
- fs.renameSync(tmp, this.config.dbPath);
532
+ hydrateEntries(data) {
533
+ return new Map(data.entries.map(entry => [entry.id, hydrateEntry(entry)]));
450
534
  }
451
- startSyncLoop() {
452
- if (this.syncTimer)
453
- clearInterval(this.syncTimer);
454
- this.syncTimer = setInterval(() => {
455
- this.sync().catch(() => { });
456
- }, 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;
457
540
  }
458
- refreshStats() {
459
- if (!this.initialized)
460
- return;
461
- this.stats.totalProjects = this.projects.size;
462
- this.stats.totalAgents = this.agents.size;
463
- this.stats.totalEntries = this.entries.size;
464
- this.stats.pendingWrites = this.writeQueue.length;
465
- const totalChecks = this.stats.hits + this.stats.misses;
466
- this.stats.hitRate = totalChecks > 0 ? this.stats.hits / totalChecks : 0;
467
- this.stats.uptime = Date.now() - this.startedAt;
468
- }
469
- estimateSizeMB() {
541
+ refreshSnapshot() {
470
542
  try {
471
- const stat = fs.statSync(this.config.dbPath);
472
- 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;
473
547
  }
474
- catch {
475
- 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
+ }
476
704
  }
477
705
  }
478
706
  async shutdown() {
479
- if (this.syncTimer)
707
+ this.shuttingDown = true;
708
+ if (this.syncTimer) {
480
709
  clearInterval(this.syncTimer);
481
- await this.sync();
482
- this.initialized = false;
483
- 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
+ }
484
722
  }
485
723
  }
486
724
  GlobalBrain.instance = null;