@theihtisham/agent-shadow-brain 6.3.0 → 7.1.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 +420 -391
- package/LICENSE +21 -21
- package/README.md +450 -438
- 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/brain-portability.js +1 -1
- package/dist/brain/global-brain.d.ts +52 -63
- package/dist/brain/global-brain.d.ts.map +1 -1
- package/dist/brain/global-brain.js +577 -339
- 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 +3 -0
- package/dist/brain/mcp-server.d.ts.map +1 -1
- package/dist/brain/mcp-server.js +50 -6
- package/dist/brain/mcp-server.js.map +1 -1
- package/dist/brain/orchestrator.js +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/brain/task-workflow.d.ts +22 -0
- package/dist/brain/task-workflow.d.ts.map +1 -0
- package/dist/brain/task-workflow.js +182 -0
- package/dist/brain/task-workflow.js.map +1 -0
- package/dist/brain/workflow-skills.d.ts +86 -0
- package/dist/brain/workflow-skills.d.ts.map +1 -0
- package/dist/brain/workflow-skills.js +514 -0
- package/dist/brain/workflow-skills.js.map +1 -0
- package/dist/cli.js +107 -34
- package/dist/cli.js.map +1 -1
- package/dist/dashboard/server.js +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 +5 -5
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +28 -10
- 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/dist/workflow-cli.d.ts +3 -0
- package/dist/workflow-cli.d.ts.map +1 -0
- package/dist/workflow-cli.js +100 -0
- package/dist/workflow-cli.js.map +1 -0
- package/docs/WORKFLOW_INTEGRATION.md +105 -0
- package/docs/launch/RELEASE_CHECKLIST_v7.0.0.md +24 -0
- package/docs/launch/RELEASE_NOTES_v7.0.0.md +44 -0
- package/docs/launch/RELEASE_NOTES_v7.1.0.md +32 -0
- package/package.json +213 -204
- package/skills/shadow-debug/SKILL.md +16 -0
- package/skills/shadow-implement/SKILL.md +16 -0
- package/skills/shadow-release/SKILL.md +16 -0
- package/skills/shadow-research/SKILL.md +12 -0
- package/skills/shadow-review/SKILL.md +14 -0
- package/skills/shadow-workflow/SKILL.md +31 -0
- 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,428 +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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
348
|
-
entry.
|
|
349
|
-
|
|
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
|
|
355
|
-
if (
|
|
356
|
-
out.push(
|
|
389
|
+
const entry = this.entries.get(id);
|
|
390
|
+
if (entry)
|
|
391
|
+
out.push(entry);
|
|
357
392
|
}
|
|
358
393
|
return out;
|
|
359
394
|
}
|
|
360
|
-
/** Chronological
|
|
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(
|
|
367
|
-
.filter(
|
|
368
|
-
.filter(
|
|
369
|
-
.sort((a, b) => b.createdAt
|
|
370
|
-
.slice(0, limit)
|
|
371
|
-
.map(
|
|
372
|
-
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.
|
|
386
|
-
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
|
+
};
|
|
387
418
|
}
|
|
388
|
-
|
|
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
|
-
|
|
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.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
this.
|
|
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
|
-
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
this.
|
|
455
|
-
|
|
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
|
-
|
|
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
|
|
472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
+
this.shuttingDown = true;
|
|
708
|
+
if (this.syncTimer) {
|
|
480
709
|
clearInterval(this.syncTimer);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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;
|