@andespindola/brainlink 0.1.0-beta.1 → 0.1.0-beta.11
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 +46 -0
- package/README.md +241 -10
- package/dist/application/add-note.js +62 -13
- package/dist/application/analyze-vault.js +104 -9
- package/dist/application/frontend/client-css.js +154 -71
- package/dist/application/frontend/client-html.js +42 -33
- package/dist/application/frontend/client-js.js +316 -84
- package/dist/application/get-graph-layout.js +22 -7
- package/dist/application/get-graph-node.js +12 -0
- package/dist/application/get-graph-summary.js +12 -0
- package/dist/application/index-vault.js +7 -0
- package/dist/application/migrate-vault.js +91 -0
- package/dist/application/search-graph-node-ids.js +12 -0
- package/dist/application/search-knowledge.js +74 -4
- package/dist/application/server/routes.js +27 -1
- package/dist/cli/commands/agent-commands.js +412 -0
- package/dist/cli/commands/config-commands.js +167 -0
- package/dist/cli/commands/read-commands.js +25 -8
- package/dist/cli/commands/write-commands.js +173 -4
- package/dist/cli/main.js +4 -0
- package/dist/cli/runtime.js +5 -2
- package/dist/domain/embeddings.js +2 -1
- package/dist/domain/graph-layout.js +20 -14
- package/dist/domain/markdown.js +36 -4
- package/dist/infrastructure/config.js +94 -8
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/paths.js +9 -1
- package/dist/infrastructure/search-packs.js +151 -0
- package/dist/infrastructure/session-state.js +172 -0
- package/dist/infrastructure/sqlite/graph-reader.js +252 -105
- package/dist/infrastructure/sqlite/recovery.js +83 -0
- package/dist/infrastructure/sqlite/schema.js +4 -1
- package/dist/infrastructure/sqlite/search-reader.js +104 -72
- package/dist/infrastructure/sqlite-index.js +16 -3
- package/dist/mcp/main.js +11 -3
- package/dist/mcp/server.js +17 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +571 -19
- package/docs/AGENT_USAGE.md +87 -3
- package/docs/ARCHITECTURE.md +16 -1
- package/docs/QUICKSTART.md +104 -0
- package/docs/RELEASE.md +3 -3
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { sanitizeAgentId } from '../domain/agents.js';
|
|
4
|
-
import { getDefaultVaultPath } from './paths.js';
|
|
5
|
+
import { getBrainlinkHomePath, getDefaultVaultPath } from './paths.js';
|
|
5
6
|
export const defaultBrainlinkConfig = {
|
|
6
7
|
vault: getDefaultVaultPath(),
|
|
7
8
|
host: '127.0.0.1',
|
|
@@ -13,15 +14,61 @@ export const defaultBrainlinkConfig = {
|
|
|
13
14
|
defaultContextTokens: 2000,
|
|
14
15
|
embeddingProvider: 'local',
|
|
15
16
|
defaultSearchMode: 'hybrid',
|
|
16
|
-
chunkSize: 1200
|
|
17
|
+
chunkSize: 1200,
|
|
18
|
+
agentProfiles: {}
|
|
17
19
|
};
|
|
18
20
|
const configFilenames = ['brainlink.config.json', '.brainlink.json'];
|
|
21
|
+
const localConfigFilename = 'brainlink.config.json';
|
|
22
|
+
const globalConfigFilename = 'brainlink.config.json';
|
|
23
|
+
const globalConfigDirectoryMode = 0o700;
|
|
24
|
+
const globalConfigFileMode = 0o600;
|
|
25
|
+
const safeCwd = () => {
|
|
26
|
+
try {
|
|
27
|
+
return process.cwd();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return homedir();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
19
33
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
34
|
const embeddingProviders = new Set(['none', 'local']);
|
|
21
35
|
const searchModes = new Set(['fts', 'semantic', 'hybrid']);
|
|
22
36
|
const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
|
|
23
37
|
export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
|
|
24
38
|
const sanitizeAllowedVaults = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
|
|
39
|
+
const sanitizePositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
40
|
+
const sanitizeAgentProfile = (value) => {
|
|
41
|
+
if (!isRecord(value)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const defaultSearchLimit = sanitizePositiveNumber(value.defaultSearchLimit);
|
|
45
|
+
const defaultContextTokens = sanitizePositiveNumber(value.defaultContextTokens);
|
|
46
|
+
const defaultSearchMode = typeof value.defaultSearchMode === 'string' && searchModes.has(value.defaultSearchMode)
|
|
47
|
+
? value.defaultSearchMode
|
|
48
|
+
: undefined;
|
|
49
|
+
const profile = {
|
|
50
|
+
...(defaultSearchLimit ? { defaultSearchLimit } : {}),
|
|
51
|
+
...(defaultContextTokens ? { defaultContextTokens } : {}),
|
|
52
|
+
...(defaultSearchMode ? { defaultSearchMode } : {})
|
|
53
|
+
};
|
|
54
|
+
return Object.keys(profile).length > 0 ? profile : null;
|
|
55
|
+
};
|
|
56
|
+
const sanitizeAgentProfiles = (value) => {
|
|
57
|
+
if (!isRecord(value)) {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
return Object.entries(value).reduce((state, [key, profile]) => {
|
|
61
|
+
const normalizedKey = key === '*' ? '*' : sanitizeAgentId(key);
|
|
62
|
+
const sanitizedProfile = sanitizeAgentProfile(profile);
|
|
63
|
+
if (!sanitizedProfile || normalizedKey.length === 0) {
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
...state,
|
|
68
|
+
[normalizedKey]: sanitizedProfile
|
|
69
|
+
};
|
|
70
|
+
}, {});
|
|
71
|
+
};
|
|
25
72
|
const readAllowedVaultsFromEnv = () => (process.env.BRAINLINK_ALLOWED_VAULTS ?? '')
|
|
26
73
|
.split(',')
|
|
27
74
|
.map((value) => value.trim())
|
|
@@ -39,6 +86,34 @@ const readJsonConfig = async (path) => {
|
|
|
39
86
|
throw error;
|
|
40
87
|
}
|
|
41
88
|
};
|
|
89
|
+
export const getGlobalConfigPath = () => join(getBrainlinkHomePath(), globalConfigFilename);
|
|
90
|
+
export const getLocalConfigPath = (cwd = safeCwd()) => resolve(cwd, localConfigFilename);
|
|
91
|
+
export const resolveConfigPath = (scope, cwd = safeCwd()) => scope === 'global' ? getGlobalConfigPath() : getLocalConfigPath(cwd);
|
|
92
|
+
export const loadRawConfig = async (scope, cwd = safeCwd()) => readJsonConfig(resolveConfigPath(scope, cwd));
|
|
93
|
+
export const loadLegacyLocalRawConfig = async (cwd = safeCwd()) => readJsonConfig(resolve(cwd, '.brainlink.json'));
|
|
94
|
+
export const detectVaultConfigSource = async (cwd = safeCwd()) => {
|
|
95
|
+
const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
|
|
96
|
+
loadRawConfig('global', cwd),
|
|
97
|
+
loadRawConfig('local', cwd),
|
|
98
|
+
loadLegacyLocalRawConfig(cwd)
|
|
99
|
+
]);
|
|
100
|
+
if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
|
|
101
|
+
return 'local-legacy';
|
|
102
|
+
}
|
|
103
|
+
if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
|
|
104
|
+
return 'local';
|
|
105
|
+
}
|
|
106
|
+
if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
|
|
107
|
+
return 'global';
|
|
108
|
+
}
|
|
109
|
+
return 'default';
|
|
110
|
+
};
|
|
111
|
+
export const writeRawConfig = async (scope, value, cwd = safeCwd()) => {
|
|
112
|
+
const path = resolveConfigPath(scope, cwd);
|
|
113
|
+
await mkdir(dirname(path), { recursive: true, mode: globalConfigDirectoryMode });
|
|
114
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: globalConfigFileMode });
|
|
115
|
+
return path;
|
|
116
|
+
};
|
|
42
117
|
const sanitizeConfig = (value) => ({
|
|
43
118
|
...defaultBrainlinkConfig,
|
|
44
119
|
...value,
|
|
@@ -56,11 +131,22 @@ const sanitizeConfig = (value) => ({
|
|
|
56
131
|
allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
|
|
57
132
|
chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
|
|
58
133
|
embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
|
|
59
|
-
defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode)
|
|
134
|
+
defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
|
|
135
|
+
agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
|
|
60
136
|
});
|
|
61
|
-
export const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
137
|
+
export const resolveAgentRuntimeDefaults = (config, agent) => {
|
|
138
|
+
const normalizedAgent = agent?.trim().length ? sanitizeAgentId(agent) : undefined;
|
|
139
|
+
const profile = (normalizedAgent ? config.agentProfiles[normalizedAgent] : undefined) ?? config.agentProfiles['*'];
|
|
140
|
+
return {
|
|
141
|
+
defaultSearchLimit: profile?.defaultSearchLimit ?? config.defaultSearchLimit,
|
|
142
|
+
defaultContextTokens: profile?.defaultContextTokens ?? config.defaultContextTokens,
|
|
143
|
+
defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
export const loadBrainlinkConfig = async (cwd = safeCwd()) => {
|
|
147
|
+
const globalConfig = await readJsonConfig(getGlobalConfigPath());
|
|
148
|
+
const localConfigs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
|
|
149
|
+
const merged = [globalConfig, ...localConfigs].reduce((state, config) => ({
|
|
64
150
|
...state,
|
|
65
151
|
...config
|
|
66
152
|
}), {});
|
|
@@ -16,6 +16,17 @@ const walkMarkdownFiles = async (directory) => {
|
|
|
16
16
|
}));
|
|
17
17
|
return nested.flat();
|
|
18
18
|
};
|
|
19
|
+
const walkVaultFiles = async (directory) => {
|
|
20
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
21
|
+
const nested = await Promise.all(entries.map(async (entry) => {
|
|
22
|
+
const absolutePath = join(directory, entry.name);
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
return excludedDirectories.has(entry.name) ? [] : walkVaultFiles(absolutePath);
|
|
25
|
+
}
|
|
26
|
+
return entry.isFile() ? [absolutePath] : [];
|
|
27
|
+
}));
|
|
28
|
+
return nested.flat();
|
|
29
|
+
};
|
|
19
30
|
export const resolveVaultPath = (vaultPath) => isBucketVaultUri(vaultPath) ? getBucketVaultCachePath(vaultPath) : resolvePath(vaultPath);
|
|
20
31
|
export const isBucketVaultPath = (vaultPath) => isBucketVaultUri(vaultPath);
|
|
21
32
|
const isPathInside = (parent, child) => {
|
|
@@ -65,6 +76,10 @@ export const readMarkdownFiles = async (vaultPath) => {
|
|
|
65
76
|
};
|
|
66
77
|
}));
|
|
67
78
|
};
|
|
79
|
+
export const listVaultFiles = async (vaultPath) => {
|
|
80
|
+
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
81
|
+
return walkVaultFiles(absoluteVaultPath);
|
|
82
|
+
};
|
|
68
83
|
export const writeMarkdownFile = async (vaultPath, filename, content) => {
|
|
69
84
|
if (isBucketVaultUri(vaultPath)) {
|
|
70
85
|
return writeBucketMarkdownFile(vaultPath, filename, content);
|
|
@@ -2,8 +2,16 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
3
|
const defaultHomeDirectoryName = '.brainlink';
|
|
4
4
|
const defaultVaultDirectoryName = 'vault';
|
|
5
|
+
const resolveSafeCwd = () => {
|
|
6
|
+
try {
|
|
7
|
+
return process.cwd();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return homedir();
|
|
11
|
+
}
|
|
12
|
+
};
|
|
5
13
|
export const expandHomePath = (path) => path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
|
6
|
-
export const resolvePath = (path, cwd =
|
|
14
|
+
export const resolvePath = (path, cwd = resolveSafeCwd()) => {
|
|
7
15
|
const expandedPath = expandHomePath(path);
|
|
8
16
|
return isAbsolute(expandedPath) ? expandedPath : resolve(cwd, expandedPath);
|
|
9
17
|
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const packsDirectoryName = 'search-packs';
|
|
5
|
+
const manifestFileName = 'manifest.json';
|
|
6
|
+
const rowChunkSize = 5_000;
|
|
7
|
+
const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
8
|
+
const toPackDirectory = (vaultPath) => join(vaultPath, '.brainlink', packsDirectoryName);
|
|
9
|
+
const toManifestPath = (vaultPath) => join(toPackDirectory(vaultPath), manifestFileName);
|
|
10
|
+
const parseRowsFromPack = (content) => gunzipSync(content)
|
|
11
|
+
.toString('utf8')
|
|
12
|
+
.split('\n')
|
|
13
|
+
.map((line) => line.trim())
|
|
14
|
+
.filter((line) => line.length > 0)
|
|
15
|
+
.map((line) => JSON.parse(line));
|
|
16
|
+
const toRows = (documents) => documents.flatMap((document) => document.chunks.map((chunk) => ({
|
|
17
|
+
documentId: document.document.id,
|
|
18
|
+
agentId: document.document.agentId,
|
|
19
|
+
title: document.document.title,
|
|
20
|
+
path: document.document.path,
|
|
21
|
+
chunkId: chunk.id,
|
|
22
|
+
content: chunk.content,
|
|
23
|
+
tags: document.document.tags
|
|
24
|
+
})));
|
|
25
|
+
const writeManifest = async (vaultPath, manifest) => {
|
|
26
|
+
await writeFile(toManifestPath(vaultPath), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
27
|
+
};
|
|
28
|
+
const chunkRows = (rows, size) => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
for (let index = 0; index < rows.length; index += size) {
|
|
31
|
+
chunks.push(rows.slice(index, index + size));
|
|
32
|
+
}
|
|
33
|
+
return chunks;
|
|
34
|
+
};
|
|
35
|
+
const normalizeToken = (value) => value
|
|
36
|
+
.normalize('NFKD')
|
|
37
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
38
|
+
.toLowerCase();
|
|
39
|
+
const tokenize = (query) => query
|
|
40
|
+
.match(queryTokenPattern)
|
|
41
|
+
?.map(normalizeToken)
|
|
42
|
+
.filter((token) => token.length > 1) ?? [];
|
|
43
|
+
const countOccurrences = (text, token) => {
|
|
44
|
+
let hits = 0;
|
|
45
|
+
let start = 0;
|
|
46
|
+
while (start < text.length) {
|
|
47
|
+
const index = text.indexOf(token, start);
|
|
48
|
+
if (index < 0) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
hits += 1;
|
|
52
|
+
start = index + token.length;
|
|
53
|
+
}
|
|
54
|
+
return hits;
|
|
55
|
+
};
|
|
56
|
+
const computeTextScore = (row, tokens) => {
|
|
57
|
+
if (tokens.length === 0) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
const title = normalizeToken(row.title);
|
|
61
|
+
const path = normalizeToken(row.path);
|
|
62
|
+
const content = normalizeToken(row.content);
|
|
63
|
+
const tags = normalizeToken(row.tags.join(' '));
|
|
64
|
+
return tokens.reduce((score, token) => {
|
|
65
|
+
const titleHits = countOccurrences(title, token);
|
|
66
|
+
const tagHits = countOccurrences(tags, token);
|
|
67
|
+
const pathHits = countOccurrences(path, token);
|
|
68
|
+
const contentHits = countOccurrences(content, token);
|
|
69
|
+
return score + titleHits * 5 + tagHits * 4 + pathHits * 2 + Math.min(contentHits, 5);
|
|
70
|
+
}, 0);
|
|
71
|
+
};
|
|
72
|
+
const toSearchResult = (row, score) => ({
|
|
73
|
+
documentId: row.documentId,
|
|
74
|
+
agentId: row.agentId,
|
|
75
|
+
title: row.title,
|
|
76
|
+
path: row.path,
|
|
77
|
+
chunkId: row.chunkId,
|
|
78
|
+
content: row.content,
|
|
79
|
+
score,
|
|
80
|
+
textScore: score,
|
|
81
|
+
semanticScore: 0,
|
|
82
|
+
searchMode: 'fts',
|
|
83
|
+
tags: row.tags
|
|
84
|
+
});
|
|
85
|
+
const sortedPackFiles = async (vaultPath) => {
|
|
86
|
+
try {
|
|
87
|
+
const files = await readdir(toPackDirectory(vaultPath));
|
|
88
|
+
return files
|
|
89
|
+
.filter((file) => file.endsWith('.jsonl.gz'))
|
|
90
|
+
.sort((left, right) => left.localeCompare(right));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
export const buildSearchPacks = async (vaultPath, documents) => {
|
|
100
|
+
const directory = toPackDirectory(vaultPath);
|
|
101
|
+
const rows = toRows(documents);
|
|
102
|
+
await mkdir(directory, { recursive: true });
|
|
103
|
+
const current = await readdir(directory);
|
|
104
|
+
await Promise.all(current
|
|
105
|
+
.filter((name) => name.endsWith('.jsonl.gz') || name === manifestFileName)
|
|
106
|
+
.map((name) => rm(join(directory, name), { force: true })));
|
|
107
|
+
const chunks = chunkRows(rows, rowChunkSize);
|
|
108
|
+
await Promise.all(chunks.map(async (chunk, index) => {
|
|
109
|
+
const fileName = `pack-${String(index + 1).padStart(4, '0')}.jsonl.gz`;
|
|
110
|
+
const serialized = `${chunk.map((row) => JSON.stringify(row)).join('\n')}\n`;
|
|
111
|
+
const compressed = gzipSync(Buffer.from(serialized, 'utf8'), { level: 6 });
|
|
112
|
+
await writeFile(join(directory, fileName), compressed);
|
|
113
|
+
}));
|
|
114
|
+
await writeManifest(vaultPath, {
|
|
115
|
+
version: 1,
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
packCount: chunks.length,
|
|
118
|
+
recordCount: rows.length
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
packCount: chunks.length,
|
|
122
|
+
recordCount: rows.length
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
export const searchInPacks = async (vaultPath, query, limit, agentId) => {
|
|
126
|
+
const normalizedAgent = agentId?.trim();
|
|
127
|
+
const tokens = tokenize(query);
|
|
128
|
+
if (limit <= 0 || tokens.length === 0) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const files = await sortedPackFiles(vaultPath);
|
|
132
|
+
if (files.length === 0) {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
const scored = [];
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const rows = parseRowsFromPack(await readFile(join(toPackDirectory(vaultPath), file)));
|
|
138
|
+
rows.forEach((row) => {
|
|
139
|
+
if (normalizedAgent && row.agentId !== normalizedAgent) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const score = computeTextScore(row, tokens);
|
|
143
|
+
if (score > 0) {
|
|
144
|
+
scored.push(toSearchResult(row, score));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return scored
|
|
149
|
+
.sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
|
|
150
|
+
.slice(0, limit);
|
|
151
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { getBrainlinkHomePath } from './paths.js';
|
|
4
|
+
const defaultPolicy = {
|
|
5
|
+
enforceBootstrap: true,
|
|
6
|
+
enforceContextFirst: true,
|
|
7
|
+
autoBootstrapOnRead: true,
|
|
8
|
+
autoBootstrapOnStartup: true,
|
|
9
|
+
staleAfterMinutes: 120
|
|
10
|
+
};
|
|
11
|
+
const defaultState = {
|
|
12
|
+
policy: defaultPolicy,
|
|
13
|
+
bootstraps: [],
|
|
14
|
+
contexts: []
|
|
15
|
+
};
|
|
16
|
+
const sessionStatePath = () => join(getBrainlinkHomePath(), 'session-state.json');
|
|
17
|
+
const normalizeAgent = (agent) => agent?.trim() ? agent.trim() : '*';
|
|
18
|
+
const safePositive = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
19
|
+
const sanitizeState = (value) => {
|
|
20
|
+
if (typeof value !== 'object' || value === null) {
|
|
21
|
+
return defaultState;
|
|
22
|
+
}
|
|
23
|
+
const record = value;
|
|
24
|
+
const policyRecord = typeof record.policy === 'object' && record.policy !== null ? record.policy : {};
|
|
25
|
+
const rawBootstraps = Array.isArray(record.bootstraps) ? record.bootstraps : [];
|
|
26
|
+
const rawContexts = Array.isArray(record.contexts) ? record.contexts : [];
|
|
27
|
+
const bootstraps = rawBootstraps.flatMap((entry) => {
|
|
28
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const row = entry;
|
|
32
|
+
const vault = typeof row.vault === 'string' && row.vault.trim().length > 0 ? row.vault.trim() : undefined;
|
|
33
|
+
const agent = typeof row.agent === 'string' && row.agent.trim().length > 0 ? row.agent.trim() : undefined;
|
|
34
|
+
const lastBootstrappedAt = typeof row.lastBootstrappedAt === 'string' && row.lastBootstrappedAt.trim().length > 0 ? row.lastBootstrappedAt.trim() : undefined;
|
|
35
|
+
return vault && agent && lastBootstrappedAt ? [{ vault, agent, lastBootstrappedAt }] : [];
|
|
36
|
+
});
|
|
37
|
+
const contexts = rawContexts.flatMap((entry) => {
|
|
38
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const row = entry;
|
|
42
|
+
const vault = typeof row.vault === 'string' && row.vault.trim().length > 0 ? row.vault.trim() : undefined;
|
|
43
|
+
const agent = typeof row.agent === 'string' && row.agent.trim().length > 0 ? row.agent.trim() : undefined;
|
|
44
|
+
const lastContextAt = typeof row.lastContextAt === 'string' && row.lastContextAt.trim().length > 0 ? row.lastContextAt.trim() : undefined;
|
|
45
|
+
return vault && agent && lastContextAt ? [{ vault, agent, lastContextAt }] : [];
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
policy: {
|
|
49
|
+
enforceBootstrap: typeof policyRecord.enforceBootstrap === 'boolean' ? policyRecord.enforceBootstrap : defaultPolicy.enforceBootstrap,
|
|
50
|
+
enforceContextFirst: typeof policyRecord.enforceContextFirst === 'boolean'
|
|
51
|
+
? policyRecord.enforceContextFirst
|
|
52
|
+
: defaultPolicy.enforceContextFirst,
|
|
53
|
+
autoBootstrapOnRead: typeof policyRecord.autoBootstrapOnRead === 'boolean'
|
|
54
|
+
? policyRecord.autoBootstrapOnRead
|
|
55
|
+
: defaultPolicy.autoBootstrapOnRead,
|
|
56
|
+
autoBootstrapOnStartup: typeof policyRecord.autoBootstrapOnStartup === 'boolean'
|
|
57
|
+
? policyRecord.autoBootstrapOnStartup
|
|
58
|
+
: defaultPolicy.autoBootstrapOnStartup,
|
|
59
|
+
staleAfterMinutes: safePositive(policyRecord.staleAfterMinutes, defaultPolicy.staleAfterMinutes)
|
|
60
|
+
},
|
|
61
|
+
bootstraps,
|
|
62
|
+
contexts
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const readState = async () => {
|
|
66
|
+
try {
|
|
67
|
+
const content = await readFile(sessionStatePath(), 'utf8');
|
|
68
|
+
return sanitizeState(JSON.parse(content));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
72
|
+
return defaultState;
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const writeState = async (state) => {
|
|
78
|
+
const path = sessionStatePath();
|
|
79
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
80
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
81
|
+
};
|
|
82
|
+
export const getSessionStatePath = () => sessionStatePath();
|
|
83
|
+
export const getBootstrapPolicy = async () => (await readState()).policy;
|
|
84
|
+
export const setBootstrapPolicy = async (patch) => {
|
|
85
|
+
const state = await readState();
|
|
86
|
+
const next = {
|
|
87
|
+
enforceBootstrap: typeof patch.enforceBootstrap === 'boolean' ? patch.enforceBootstrap : state.policy.enforceBootstrap,
|
|
88
|
+
enforceContextFirst: typeof patch.enforceContextFirst === 'boolean' ? patch.enforceContextFirst : state.policy.enforceContextFirst,
|
|
89
|
+
autoBootstrapOnRead: typeof patch.autoBootstrapOnRead === 'boolean' ? patch.autoBootstrapOnRead : state.policy.autoBootstrapOnRead,
|
|
90
|
+
autoBootstrapOnStartup: typeof patch.autoBootstrapOnStartup === 'boolean' ? patch.autoBootstrapOnStartup : state.policy.autoBootstrapOnStartup,
|
|
91
|
+
staleAfterMinutes: safePositive(patch.staleAfterMinutes, state.policy.staleAfterMinutes)
|
|
92
|
+
};
|
|
93
|
+
await writeState({
|
|
94
|
+
...state,
|
|
95
|
+
policy: next
|
|
96
|
+
});
|
|
97
|
+
return next;
|
|
98
|
+
};
|
|
99
|
+
export const touchBootstrapSession = async (vault, agent) => {
|
|
100
|
+
const state = await readState();
|
|
101
|
+
const normalizedAgent = normalizeAgent(agent);
|
|
102
|
+
const entry = {
|
|
103
|
+
vault,
|
|
104
|
+
agent: normalizedAgent,
|
|
105
|
+
lastBootstrappedAt: new Date().toISOString()
|
|
106
|
+
};
|
|
107
|
+
const bootstraps = [
|
|
108
|
+
entry,
|
|
109
|
+
...state.bootstraps.filter((item) => !(item.vault === entry.vault && item.agent === entry.agent))
|
|
110
|
+
].slice(0, 500);
|
|
111
|
+
await writeState({
|
|
112
|
+
...state,
|
|
113
|
+
bootstraps
|
|
114
|
+
});
|
|
115
|
+
return entry;
|
|
116
|
+
};
|
|
117
|
+
export const touchContextSession = async (vault, agent) => {
|
|
118
|
+
const state = await readState();
|
|
119
|
+
const normalizedAgent = normalizeAgent(agent);
|
|
120
|
+
const entry = {
|
|
121
|
+
vault,
|
|
122
|
+
agent: normalizedAgent,
|
|
123
|
+
lastContextAt: new Date().toISOString()
|
|
124
|
+
};
|
|
125
|
+
const contexts = [
|
|
126
|
+
entry,
|
|
127
|
+
...state.contexts.filter((item) => !(item.vault === entry.vault && item.agent === entry.agent))
|
|
128
|
+
].slice(0, 500);
|
|
129
|
+
await writeState({
|
|
130
|
+
...state,
|
|
131
|
+
contexts
|
|
132
|
+
});
|
|
133
|
+
return entry;
|
|
134
|
+
};
|
|
135
|
+
export const getBootstrapSessionStatus = async (vault, agent) => {
|
|
136
|
+
const state = await readState();
|
|
137
|
+
const normalizedAgent = normalizeAgent(agent);
|
|
138
|
+
const match = state.bootstraps.find((entry) => entry.vault === vault && entry.agent === normalizedAgent);
|
|
139
|
+
if (!match) {
|
|
140
|
+
return {
|
|
141
|
+
ready: false,
|
|
142
|
+
stale: true
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const ageMinutes = Math.max(0, (Date.now() - new Date(match.lastBootstrappedAt).getTime()) / 60000);
|
|
146
|
+
const stale = ageMinutes > state.policy.staleAfterMinutes;
|
|
147
|
+
return {
|
|
148
|
+
ready: !stale,
|
|
149
|
+
stale,
|
|
150
|
+
lastBootstrappedAt: match.lastBootstrappedAt,
|
|
151
|
+
ageMinutes
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
export const getContextSessionStatus = async (vault, agent) => {
|
|
155
|
+
const state = await readState();
|
|
156
|
+
const normalizedAgent = normalizeAgent(agent);
|
|
157
|
+
const match = state.contexts.find((entry) => entry.vault === vault && entry.agent === normalizedAgent);
|
|
158
|
+
if (!match) {
|
|
159
|
+
return {
|
|
160
|
+
ready: false,
|
|
161
|
+
stale: true
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const ageMinutes = Math.max(0, (Date.now() - new Date(match.lastContextAt).getTime()) / 60000);
|
|
165
|
+
const stale = ageMinutes > state.policy.staleAfterMinutes;
|
|
166
|
+
return {
|
|
167
|
+
ready: !stale,
|
|
168
|
+
stale,
|
|
169
|
+
lastContextAt: match.lastContextAt,
|
|
170
|
+
ageMinutes
|
|
171
|
+
};
|
|
172
|
+
};
|