@andespindola/brainlink 0.1.0-beta.99 → 1.0.1
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/AGENTS.md +6 -6
- package/CHANGELOG.md +14 -0
- package/README.md +198 -38
- package/dist/application/add-note.js +13 -44
- package/dist/application/analyze-vault.js +1 -1
- package/dist/application/auto-migrate-configured-vault.js +37 -0
- package/dist/application/build-context.js +119 -20
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/delete-note.js +80 -0
- package/dist/application/frontend/client-css.js +212 -42
- package/dist/application/frontend/client-html.js +42 -28
- package/dist/application/frontend/client-js.js +1294 -3222
- package/dist/application/frontend/client-render-worker-js.js +676 -0
- package/dist/application/get-graph-contexts.js +33 -0
- package/dist/application/get-graph-layout.js +62 -8
- package/dist/application/get-graph-stream-chunk.js +326 -0
- package/dist/application/get-graph-view.js +246 -0
- package/dist/application/graph-view-state.js +66 -0
- package/dist/application/import-legacy-sqlite.js +3 -33
- package/dist/application/index-vault.js +35 -22
- package/dist/application/migrate-context-links.js +79 -0
- package/dist/application/search-graph-node-ids.js +63 -3
- package/dist/application/server/routes.js +197 -12
- package/dist/cli/commands/read-commands.js +39 -3
- package/dist/cli/commands/vault-commands.js +182 -0
- package/dist/cli/commands/write-commands.js +172 -12
- package/dist/cli/main.js +2 -0
- package/dist/cli/runtime.js +10 -2
- package/dist/domain/context.js +1 -0
- package/dist/domain/graph-contexts.js +180 -0
- package/dist/domain/graph-layout.js +347 -21
- package/dist/domain/markdown.js +53 -9
- package/dist/infrastructure/config.js +105 -6
- package/dist/infrastructure/context-packs.js +122 -0
- package/dist/infrastructure/file-index.js +6 -3
- package/dist/infrastructure/file-system-vault.js +21 -1
- package/dist/infrastructure/index-state.js +2 -0
- package/dist/infrastructure/vault-migration-state.js +69 -0
- package/dist/infrastructure/volatile-memory.js +100 -0
- package/dist/mcp/http-server.js +97 -0
- package/dist/mcp/runtime.js +20 -0
- package/dist/mcp/server.js +41 -13
- package/dist/mcp/tools.js +226 -14
- package/docs/AGENT_USAGE.md +60 -5
- package/docs/ARCHITECTURE.md +11 -0
- package/docs/QUICKSTART.md +3 -1
- package/docs/RELEASE.md +4 -3
- package/package.json +3 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
const stateVersion = 1;
|
|
4
|
+
const graphViewStatePath = (vaultPath) => join(vaultPath, '.brainlink', 'graph-view-state.json');
|
|
5
|
+
const stateKey = (input) => [input.signature, input.agentId ?? 'all-agents', input.context ?? 'all-contexts'].join(':');
|
|
6
|
+
const emptyPersistedState = () => ({
|
|
7
|
+
version: stateVersion,
|
|
8
|
+
states: {}
|
|
9
|
+
});
|
|
10
|
+
const readPersistedState = async (vaultPath) => {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(await readFile(graphViewStatePath(vaultPath), 'utf8'));
|
|
13
|
+
return parsed.version === stateVersion && parsed.states && typeof parsed.states === 'object' ? parsed : emptyPersistedState();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return emptyPersistedState();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const writePersistedState = async (vaultPath, state) => {
|
|
20
|
+
const target = graphViewStatePath(vaultPath);
|
|
21
|
+
const temp = `${target}.tmp`;
|
|
22
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
23
|
+
await writeFile(temp, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
24
|
+
await rename(temp, target);
|
|
25
|
+
};
|
|
26
|
+
const normalizePositions = (positions) => positions.flatMap((position) => {
|
|
27
|
+
const id = typeof position.id === 'string' ? position.id.trim() : '';
|
|
28
|
+
const x = Number(position.x);
|
|
29
|
+
const y = Number(position.y);
|
|
30
|
+
return id && Number.isFinite(x) && Number.isFinite(y) ? [{ id, x, y }] : [];
|
|
31
|
+
});
|
|
32
|
+
export const getGraphViewState = async (vaultPath, input) => {
|
|
33
|
+
const persisted = await readPersistedState(vaultPath);
|
|
34
|
+
const state = persisted.states[stateKey(input)];
|
|
35
|
+
return state ?? {
|
|
36
|
+
...input,
|
|
37
|
+
positions: []
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export const saveGraphViewState = async (vaultPath, input) => {
|
|
41
|
+
const persisted = await readPersistedState(vaultPath);
|
|
42
|
+
const nextState = {
|
|
43
|
+
...input,
|
|
44
|
+
positions: normalizePositions(input.positions)
|
|
45
|
+
};
|
|
46
|
+
await writePersistedState(vaultPath, {
|
|
47
|
+
version: stateVersion,
|
|
48
|
+
states: {
|
|
49
|
+
...persisted.states,
|
|
50
|
+
[stateKey(input)]: nextState
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return nextState;
|
|
54
|
+
};
|
|
55
|
+
export const deleteGraphViewState = async (vaultPath, input) => {
|
|
56
|
+
const persisted = await readPersistedState(vaultPath);
|
|
57
|
+
const { [stateKey(input)]: _removed, ...states } = persisted.states;
|
|
58
|
+
await writePersistedState(vaultPath, {
|
|
59
|
+
version: stateVersion,
|
|
60
|
+
states
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
...input,
|
|
64
|
+
positions: []
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -3,7 +3,7 @@ import { access } from 'node:fs/promises';
|
|
|
3
3
|
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { extractTags
|
|
6
|
+
import { extractTags } from '../domain/markdown.js';
|
|
7
7
|
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
8
8
|
import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
9
9
|
import { getBrainlinkHomePath } from '../infrastructure/paths.js';
|
|
@@ -17,9 +17,6 @@ const agentColumnCandidates = ['agent', 'agent_id', 'namespace', 'scope'];
|
|
|
17
17
|
const tagColumnCandidates = ['tags', 'tag_list', 'keywords'];
|
|
18
18
|
const createdColumnCandidates = ['created_at', 'createdat', 'created', 'ctime'];
|
|
19
19
|
const updatedColumnCandidates = ['updated_at', 'updatedat', 'updated', 'mtime'];
|
|
20
|
-
const systemHubTitle = 'Memory Hub';
|
|
21
|
-
const systemRootTitle = 'Knowledge Root';
|
|
22
|
-
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
23
20
|
const slugify = (title) => title
|
|
24
21
|
.normalize('NFKD')
|
|
25
22
|
.replace(/[\u0300-\u036f]/g, '')
|
|
@@ -203,31 +200,6 @@ const reserveUniquePath = (agentId, title, reserved) => {
|
|
|
203
200
|
}
|
|
204
201
|
throw new Error(`Could not allocate unique path for imported note: ${title}`);
|
|
205
202
|
};
|
|
206
|
-
const ensureSystemNote = async (vaultPath, reserved, created, agentId, title, content, dryRun) => {
|
|
207
|
-
const filename = noteRelativePath(agentId, slugify(title));
|
|
208
|
-
if (reserved.has(filename)) {
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
reserved.add(filename);
|
|
212
|
-
created.add(filename);
|
|
213
|
-
if (dryRun) {
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
await writeMarkdownFile(vaultPath, filename, buildNote(title, content, agentId));
|
|
217
|
-
};
|
|
218
|
-
const applyConnectivityRule = async (vaultPath, reserved, created, title, content, agentId, dryRun) => {
|
|
219
|
-
const links = extractWikiLinks(content).filter((link) => normalizeTitle(link) !== normalizeTitle(title));
|
|
220
|
-
if (links.length > 0) {
|
|
221
|
-
return content.trim();
|
|
222
|
-
}
|
|
223
|
-
const normalized = normalizeTitle(title);
|
|
224
|
-
if (normalized === normalizeTitle(systemHubTitle)) {
|
|
225
|
-
await ensureSystemNote(vaultPath, reserved, created, agentId, systemRootTitle, `Entry point for agent memory. [[${systemHubTitle}]] #memory #root`, dryRun);
|
|
226
|
-
return `${content.trim()}\n\nRelated: [[${systemRootTitle}]]`;
|
|
227
|
-
}
|
|
228
|
-
await ensureSystemNote(vaultPath, reserved, created, agentId, systemHubTitle, 'Central memory index for this agent namespace. #memory #hub', dryRun);
|
|
229
|
-
return `${content.trim()}\n\nRelated: [[${systemHubTitle}]]`;
|
|
230
|
-
};
|
|
231
203
|
const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserved) => {
|
|
232
204
|
const limit = Number.isFinite(options.limit) && (options.limit ?? 0) > 0 ? Math.floor(options.limit ?? 0) : undefined;
|
|
233
205
|
const sql = [
|
|
@@ -243,7 +215,6 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
|
|
|
243
215
|
...(limit ? [`LIMIT ${limit}`] : [])
|
|
244
216
|
].join(' ');
|
|
245
217
|
const rows = await runSqliteQuery(dbPath, sql);
|
|
246
|
-
const createdSystemNotes = new Set();
|
|
247
218
|
const importedFiles = [];
|
|
248
219
|
let imported = 0;
|
|
249
220
|
let skipped = 0;
|
|
@@ -256,8 +227,7 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
|
|
|
256
227
|
const agentId = sanitizeAgentId(options.agentOverride || row.agent || sharedAgentId);
|
|
257
228
|
const filename = reserveUniquePath(agentId, row.title, reserved);
|
|
258
229
|
const mergedContent = appendMissingTags(row.content, row.tags);
|
|
259
|
-
const
|
|
260
|
-
const note = buildNote(row.title, connectedContent, agentId);
|
|
230
|
+
const note = buildNote(row.title, mergedContent.trim(), agentId);
|
|
261
231
|
if (options.dryRun !== true) {
|
|
262
232
|
await writeMarkdownFile(vaultPath, filename, note);
|
|
263
233
|
}
|
|
@@ -268,7 +238,7 @@ const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserv
|
|
|
268
238
|
rowsRead: rows.length,
|
|
269
239
|
imported,
|
|
270
240
|
skipped,
|
|
271
|
-
createdSystemNotes:
|
|
241
|
+
createdSystemNotes: 0,
|
|
272
242
|
importedFiles
|
|
273
243
|
};
|
|
274
244
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { createIndexedDocument, parseMarkdownDocument } from '../domain/markdown.js';
|
|
2
|
+
import { createIndexedDocument, graphLinkModelVersion, parseMarkdownDocument } from '../domain/markdown.js';
|
|
3
3
|
import { sharedAgentId } from '../domain/agents.js';
|
|
4
4
|
import { createEmbeddingProvider } from '../domain/embeddings.js';
|
|
5
5
|
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
@@ -85,8 +85,8 @@ const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
|
|
|
85
85
|
})));
|
|
86
86
|
return new Map(parsed.map((document) => [document.path, document]));
|
|
87
87
|
};
|
|
88
|
-
export const indexVault = async (vaultPath) => {
|
|
89
|
-
return indexVaultWithOptions(vaultPath,
|
|
88
|
+
export const indexVault = async (vaultPath, options = {}) => {
|
|
89
|
+
return indexVaultWithOptions(vaultPath, options);
|
|
90
90
|
};
|
|
91
91
|
export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
92
92
|
const startedAt = process.hrtime.bigint();
|
|
@@ -113,6 +113,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
113
113
|
markdownFiles: summaries.length,
|
|
114
114
|
hasPreviousState: previousState != null
|
|
115
115
|
});
|
|
116
|
+
const fullReindex = options.full === true;
|
|
116
117
|
const index = openFileIndex(absoluteVaultPath);
|
|
117
118
|
try {
|
|
118
119
|
const existingIndexedDocuments = await index.getIndexedDocuments();
|
|
@@ -120,9 +121,13 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
120
121
|
const currentSnapshot = toSnapshot(summaries);
|
|
121
122
|
const currentSnapshotMap = createSnapshotMap(currentSnapshot);
|
|
122
123
|
const previousSnapshotMap = createSnapshotMap(previousState?.files ?? []);
|
|
124
|
+
const graphLinkModelChanged = previousState != null &&
|
|
125
|
+
previousState.graphLinkModelVersion !== graphLinkModelVersion;
|
|
126
|
+
const fullSourceReindex = fullReindex || graphLinkModelChanged;
|
|
123
127
|
const settingsChanged = previousState == null ||
|
|
124
128
|
previousState.chunkSize !== config.chunkSize ||
|
|
125
|
-
previousState.embeddingProvider !== config.embeddingProvider
|
|
129
|
+
previousState.embeddingProvider !== config.embeddingProvider ||
|
|
130
|
+
graphLinkModelChanged;
|
|
126
131
|
const packSettingsChanged = previousState == null ||
|
|
127
132
|
previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
|
|
128
133
|
previousState.searchPackCompressionLevel !== config.searchPack.compressionLevel ||
|
|
@@ -131,7 +136,8 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
131
136
|
for (let index = 0; index < summaries.length; index += 1) {
|
|
132
137
|
const summary = summaries[index];
|
|
133
138
|
const previous = previousSnapshotMap.get(summary.relativePath);
|
|
134
|
-
const changed =
|
|
139
|
+
const changed = fullSourceReindex ||
|
|
140
|
+
settingsChanged ||
|
|
135
141
|
previous == null ||
|
|
136
142
|
previous.mtimeMs !== summary.updatedAt.getTime() ||
|
|
137
143
|
previous.size !== summary.size ||
|
|
@@ -147,7 +153,8 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
147
153
|
if (changedPaths.size === 0 &&
|
|
148
154
|
!hasDeletes &&
|
|
149
155
|
existingIndexedDocuments.length === summaries.length &&
|
|
150
|
-
previousState != null
|
|
156
|
+
previousState != null &&
|
|
157
|
+
!fullReindex) {
|
|
151
158
|
const result = {
|
|
152
159
|
...toIndexResult(existingIndexedDocuments),
|
|
153
160
|
elapsedMs: elapsedMs(),
|
|
@@ -204,7 +211,6 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
204
211
|
return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
|
|
205
212
|
});
|
|
206
213
|
emit('persist', 'start', 'Persisting index');
|
|
207
|
-
await index.reset();
|
|
208
214
|
await index.saveDocuments(indexedDocuments);
|
|
209
215
|
emit('persist', 'finish', 'Index persisted', {
|
|
210
216
|
indexedDocuments: indexedDocuments.length
|
|
@@ -216,6 +222,8 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
216
222
|
const previousPendingPackChanges = previousState?.pendingPackChanges ?? 0;
|
|
217
223
|
const pendingPackChanges = previousPendingPackChanges + changedCount;
|
|
218
224
|
const shouldRebuildPacks = !existingPackManifest ||
|
|
225
|
+
fullReindex ||
|
|
226
|
+
graphLinkModelChanged ||
|
|
219
227
|
settingsChanged ||
|
|
220
228
|
packSettingsChanged ||
|
|
221
229
|
hasDeletes ||
|
|
@@ -225,21 +233,25 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
225
233
|
let packResult;
|
|
226
234
|
const packReason = !existingPackManifest
|
|
227
235
|
? 'Missing pack manifest'
|
|
228
|
-
:
|
|
229
|
-
? '
|
|
230
|
-
:
|
|
231
|
-
? '
|
|
232
|
-
:
|
|
233
|
-
? '
|
|
234
|
-
:
|
|
235
|
-
? '
|
|
236
|
-
:
|
|
237
|
-
? '
|
|
238
|
-
:
|
|
239
|
-
? '
|
|
240
|
-
:
|
|
241
|
-
? '
|
|
242
|
-
:
|
|
236
|
+
: fullReindex
|
|
237
|
+
? 'Full reindex requested'
|
|
238
|
+
: graphLinkModelChanged
|
|
239
|
+
? 'Graph link model changed'
|
|
240
|
+
: manifestRecovery.repaired
|
|
241
|
+
? 'Pack manifest repaired from existing packs'
|
|
242
|
+
: settingsChanged
|
|
243
|
+
? 'Index settings changed'
|
|
244
|
+
: packSettingsChanged
|
|
245
|
+
? 'Search pack settings changed'
|
|
246
|
+
: hasDeletes
|
|
247
|
+
? 'Document deletions detected'
|
|
248
|
+
: changedCount >= 400
|
|
249
|
+
? 'Changed file count threshold reached'
|
|
250
|
+
: changeRatio >= 0.04
|
|
251
|
+
? 'Change ratio threshold reached'
|
|
252
|
+
: pendingPackChanges >= 1200
|
|
253
|
+
? 'Pending pack changes threshold reached'
|
|
254
|
+
: 'Pack rebuild skipped';
|
|
243
255
|
if (shouldRebuildPacks) {
|
|
244
256
|
emit('packs', 'start', 'Rebuilding compressed search packs', {
|
|
245
257
|
reason: packReason
|
|
@@ -271,6 +283,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
271
283
|
await writeIndexState(absoluteVaultPath, {
|
|
272
284
|
chunkSize: config.chunkSize,
|
|
273
285
|
embeddingProvider: config.embeddingProvider,
|
|
286
|
+
graphLinkModelVersion,
|
|
274
287
|
searchPackRowChunkSize: config.searchPack.rowChunkSize,
|
|
275
288
|
searchPackCompressionLevel: config.searchPack.compressionLevel,
|
|
276
289
|
searchPackUseDictionary: config.searchPack.useDictionary,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
3
|
+
import { extractContextLinkWeights, extractWikiLinkWeights, hasContextLinksSection, parseMarkdownDocument } from '../domain/markdown.js';
|
|
4
|
+
const defaultContextLinkLimit = 5;
|
|
5
|
+
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
6
|
+
const formatPriority = (priority) => priority === 'normal' ? '' : ` priority: ${priority}`;
|
|
7
|
+
const formatContextLinksSection = (links) => [
|
|
8
|
+
'## Context Links',
|
|
9
|
+
'',
|
|
10
|
+
...links.map((link) => `- [[${link.title}]]${formatPriority(link.priority)}`)
|
|
11
|
+
].join('\n');
|
|
12
|
+
const appendContextLinksSection = (content, links) => `${content.trimEnd()}\n\n${formatContextLinksSection(links)}\n`;
|
|
13
|
+
const selectContextLinkCandidates = (content, title, limit) => extractWikiLinkWeights(content)
|
|
14
|
+
.filter((link) => normalizeTitle(link.title) !== normalizeTitle(title))
|
|
15
|
+
.slice(0, limit)
|
|
16
|
+
.map((link) => ({
|
|
17
|
+
title: link.title,
|
|
18
|
+
priority: link.priority,
|
|
19
|
+
weight: link.weight
|
|
20
|
+
}));
|
|
21
|
+
export const migrateContextLinks = async (vaultPath, options = {}) => {
|
|
22
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
23
|
+
const limit = Math.max(1, Math.floor(options.limit ?? defaultContextLinkLimit));
|
|
24
|
+
const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
|
|
25
|
+
const entries = [];
|
|
26
|
+
for (const summary of summaries) {
|
|
27
|
+
const content = await readFile(summary.absolutePath, 'utf8');
|
|
28
|
+
const document = parseMarkdownDocument({
|
|
29
|
+
absolutePath: summary.absolutePath,
|
|
30
|
+
vaultPath: absoluteVaultPath,
|
|
31
|
+
content,
|
|
32
|
+
createdAt: summary.createdAt,
|
|
33
|
+
updatedAt: summary.updatedAt
|
|
34
|
+
});
|
|
35
|
+
if (options.agentId && document.agentId !== options.agentId) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (hasContextLinksSection(content)) {
|
|
39
|
+
entries.push({
|
|
40
|
+
path: summary.relativePath,
|
|
41
|
+
title: document.title,
|
|
42
|
+
changed: false,
|
|
43
|
+
reason: 'already-has-context-links',
|
|
44
|
+
links: extractContextLinkWeights(content)
|
|
45
|
+
});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const links = selectContextLinkCandidates(content, document.title, limit);
|
|
49
|
+
if (links.length === 0) {
|
|
50
|
+
entries.push({
|
|
51
|
+
path: summary.relativePath,
|
|
52
|
+
title: document.title,
|
|
53
|
+
changed: false,
|
|
54
|
+
reason: 'no-link-candidates',
|
|
55
|
+
links
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!options.dryRun) {
|
|
60
|
+
await writeMarkdownFile(vaultPath, summary.relativePath, appendContextLinksSection(content, links));
|
|
61
|
+
}
|
|
62
|
+
entries.push({
|
|
63
|
+
path: summary.relativePath,
|
|
64
|
+
title: document.title,
|
|
65
|
+
changed: true,
|
|
66
|
+
reason: 'added-context-links',
|
|
67
|
+
links
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const changed = entries.filter((entry) => entry.changed).length;
|
|
71
|
+
return {
|
|
72
|
+
dryRun: options.dryRun === true,
|
|
73
|
+
scanned: entries.length,
|
|
74
|
+
changed,
|
|
75
|
+
skipped: entries.length - changed,
|
|
76
|
+
limit,
|
|
77
|
+
entries
|
|
78
|
+
};
|
|
79
|
+
};
|
|
@@ -1,10 +1,70 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
1
2
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
-
import { openFileIndex } from '../infrastructure/file-index.js';
|
|
3
|
-
|
|
3
|
+
import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
|
|
4
|
+
import { getGraphLayout } from './get-graph-layout.js';
|
|
5
|
+
const graphSearchCacheTtlMs = 20_000;
|
|
6
|
+
const graphSearchCacheMaxEntries = 120;
|
|
7
|
+
const graphSearchCache = new Map();
|
|
8
|
+
const readIndexSignature = async (vaultPath) => {
|
|
9
|
+
try {
|
|
10
|
+
const info = await stat(indexStoragePath(vaultPath));
|
|
11
|
+
return `${Math.floor(info.mtimeMs)}:${info.size}`;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return '0:0';
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const cacheKey = (vaultPath, query, limit, agentId, context) => JSON.stringify({
|
|
18
|
+
vaultPath,
|
|
19
|
+
query: query.trim().toLowerCase(),
|
|
20
|
+
limit,
|
|
21
|
+
agentId: agentId?.trim().toLowerCase() ?? '*',
|
|
22
|
+
context: context?.trim().toLowerCase() ?? '*'
|
|
23
|
+
});
|
|
24
|
+
const readCached = (key, indexSignature) => {
|
|
25
|
+
const entry = graphSearchCache.get(key);
|
|
26
|
+
if (!entry) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const fresh = Date.now() - entry.createdAt <= graphSearchCacheTtlMs && entry.indexSignature === indexSignature;
|
|
30
|
+
if (!fresh) {
|
|
31
|
+
graphSearchCache.delete(key);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return entry.nodeIds;
|
|
35
|
+
};
|
|
36
|
+
const writeCached = (key, entry) => {
|
|
37
|
+
graphSearchCache.set(key, entry);
|
|
38
|
+
if (graphSearchCache.size <= graphSearchCacheMaxEntries) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const overflow = graphSearchCache.size - graphSearchCacheMaxEntries;
|
|
42
|
+
Array.from(graphSearchCache.keys()).slice(0, overflow).forEach((cacheKey) => graphSearchCache.delete(cacheKey));
|
|
43
|
+
};
|
|
44
|
+
export const searchGraphNodeIds = async (vaultPath, query, limit, agentId, context) => {
|
|
4
45
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
46
|
+
const indexSignature = await readIndexSignature(absoluteVaultPath);
|
|
47
|
+
const key = cacheKey(absoluteVaultPath, query, limit, agentId, context);
|
|
48
|
+
const cached = readCached(key, indexSignature);
|
|
49
|
+
if (cached) {
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
const contextNodeIds = context
|
|
53
|
+
? new Set((await getGraphLayout(absoluteVaultPath, { agentId, context })).layout.nodes.map((node) => node.id))
|
|
54
|
+
: new Set();
|
|
5
55
|
const index = openFileIndex(absoluteVaultPath);
|
|
6
56
|
try {
|
|
7
|
-
|
|
57
|
+
const searchLimit = context ? Math.max(limit, 5000) : limit;
|
|
58
|
+
const foundNodeIds = await index.searchGraphNodeIds(query, searchLimit, agentId);
|
|
59
|
+
const nodeIds = context
|
|
60
|
+
? foundNodeIds.filter((nodeId) => contextNodeIds.has(nodeId)).slice(0, limit)
|
|
61
|
+
: foundNodeIds;
|
|
62
|
+
writeCached(key, {
|
|
63
|
+
createdAt: Date.now(),
|
|
64
|
+
indexSignature,
|
|
65
|
+
nodeIds
|
|
66
|
+
});
|
|
67
|
+
return nodeIds;
|
|
8
68
|
}
|
|
9
69
|
finally {
|
|
10
70
|
index.close();
|