@andespindola/brainlink 0.1.0-beta.0 → 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +252 -19
  3. package/dist/application/add-note.js +62 -13
  4. package/dist/application/analyze-vault.js +104 -9
  5. package/dist/application/frontend/client-css.js +154 -71
  6. package/dist/application/frontend/client-html.js +42 -33
  7. package/dist/application/frontend/client-js.js +255 -70
  8. package/dist/application/get-graph-layout.js +6 -3
  9. package/dist/application/get-graph-node.js +12 -0
  10. package/dist/application/get-graph-summary.js +12 -0
  11. package/dist/application/migrate-vault.js +91 -0
  12. package/dist/application/search-graph-node-ids.js +12 -0
  13. package/dist/application/search-knowledge.js +56 -1
  14. package/dist/application/server/routes.js +27 -1
  15. package/dist/cli/commands/agent-commands.js +412 -0
  16. package/dist/cli/commands/config-commands.js +167 -0
  17. package/dist/cli/commands/read-commands.js +25 -8
  18. package/dist/cli/commands/write-commands.js +191 -7
  19. package/dist/cli/main.js +4 -0
  20. package/dist/cli/runtime.js +5 -2
  21. package/dist/domain/embeddings.js +2 -1
  22. package/dist/domain/graph-layout.js +20 -14
  23. package/dist/domain/markdown.js +36 -4
  24. package/dist/infrastructure/config.js +96 -8
  25. package/dist/infrastructure/file-system-vault.js +15 -0
  26. package/dist/infrastructure/paths.js +9 -1
  27. package/dist/infrastructure/session-state.js +172 -0
  28. package/dist/infrastructure/sqlite/graph-reader.js +252 -105
  29. package/dist/infrastructure/sqlite/recovery.js +83 -0
  30. package/dist/infrastructure/sqlite/schema.js +4 -1
  31. package/dist/infrastructure/sqlite/search-reader.js +104 -72
  32. package/dist/infrastructure/sqlite-index.js +16 -3
  33. package/dist/mcp/main.js +11 -3
  34. package/dist/mcp/server.js +22 -2
  35. package/dist/mcp/startup.js +35 -0
  36. package/dist/mcp/tools.js +617 -21
  37. package/docs/AGENT_USAGE.md +95 -6
  38. package/docs/ARCHITECTURE.md +15 -1
  39. package/docs/QUICKSTART.md +104 -0
  40. package/docs/RELEASE.md +3 -3
  41. package/package.json +1 -1
@@ -77,11 +77,13 @@ export const extractWikiLinkReferences = (content) => visibleMarkdownLines(conte
77
77
  }));
78
78
  });
79
79
  const priorityFromWeight = (weight) => weight >= 8 ? 'critical' : weight >= 4 ? 'high' : 'normal';
80
+ const normalizeAccumulatedWeight = (weight) => Math.max(1, Math.min(12, weight));
80
81
  export const extractWikiLinkWeights = (content) => {
81
82
  const weights = extractWikiLinkReferences(content).reduce((state, reference) => {
82
83
  const titleKey = reference.title.toLowerCase();
83
84
  const current = state.get(titleKey);
84
- const weight = (current?.weight ?? 0) + reference.weight;
85
+ const rawWeight = (current?.weight ?? 0) + reference.weight;
86
+ const weight = normalizeAccumulatedWeight(rawWeight);
85
87
  const explicitPriority = reference.priority
86
88
  ? maxPriority(current?.priority ?? reference.priority, reference.priority)
87
89
  : current?.priority;
@@ -116,10 +118,38 @@ const normalizeChunkContent = (content) => content
116
118
  .join('\n')
117
119
  .replace(/\n{3,}/g, '\n\n')
118
120
  .trim();
121
+ const splitLongParagraph = (paragraph, maxCharacters) => {
122
+ if (paragraph.length <= maxCharacters) {
123
+ return [paragraph];
124
+ }
125
+ const sentences = paragraph
126
+ .split(/(?<=[.!?])\s+/)
127
+ .map((sentence) => sentence.trim())
128
+ .filter(Boolean);
129
+ if (sentences.length <= 1) {
130
+ const chunks = [];
131
+ for (let index = 0; index < paragraph.length; index += maxCharacters) {
132
+ chunks.push(paragraph.slice(index, index + maxCharacters).trim());
133
+ }
134
+ return chunks.filter(Boolean);
135
+ }
136
+ return sentences.reduce((state, sentence) => {
137
+ const last = state.at(-1);
138
+ if (!last) {
139
+ return [sentence];
140
+ }
141
+ const merged = `${last} ${sentence}`;
142
+ if (merged.length <= maxCharacters) {
143
+ return [...state.slice(0, -1), merged];
144
+ }
145
+ return [...state, sentence];
146
+ }, []);
147
+ };
119
148
  export const splitIntoChunks = (documentId, content, maxCharacters = 1200) => {
120
149
  const paragraphs = normalizeChunkContent(stripFrontmatter(content))
121
150
  .split(/\n{2,}/)
122
- .filter(Boolean);
151
+ .filter(Boolean)
152
+ .flatMap((paragraph) => splitLongParagraph(paragraph, maxCharacters));
123
153
  const chunks = paragraphs.reduce((state, paragraph) => {
124
154
  const lastChunk = state.at(-1);
125
155
  if (!lastChunk) {
@@ -162,13 +192,15 @@ export const parseMarkdownDocument = (input) => {
162
192
  export const createIndexedDocument = (document, titleToDocumentId, maxChunkCharacters = 1200) => {
163
193
  const chunks = splitIntoChunks(document.id, document.content, maxChunkCharacters);
164
194
  const linkWeights = new Map(extractWikiLinkWeights(document.content).map((link) => [link.title.toLowerCase(), link]));
165
- const links = document.links.map((toTitle) => ({
195
+ const links = document.links
196
+ .map((toTitle) => ({
166
197
  fromDocumentId: document.id,
167
198
  toTitle,
168
199
  toDocumentId: titleToDocumentId.get(toTitle.toLowerCase()) ?? null,
169
200
  weight: linkWeights.get(toTitle.toLowerCase())?.weight ?? 1,
170
201
  priority: linkWeights.get(toTitle.toLowerCase())?.priority ?? 'normal'
171
- }));
202
+ }))
203
+ .filter((link) => link.toDocumentId !== document.id);
172
204
  return {
173
205
  document,
174
206
  chunks,
@@ -1,26 +1,74 @@
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',
8
9
  port: 4321,
9
10
  allowedVaults: [],
10
11
  defaultAgent: undefined,
12
+ autoIndexOnWrite: true,
11
13
  defaultSearchLimit: 10,
12
14
  defaultContextTokens: 2000,
13
15
  embeddingProvider: 'local',
14
16
  defaultSearchMode: 'hybrid',
15
- chunkSize: 1200
17
+ chunkSize: 1200,
18
+ agentProfiles: {}
16
19
  };
17
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
+ };
18
33
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
19
34
  const embeddingProviders = new Set(['none', 'local']);
20
35
  const searchModes = new Set(['fts', 'semantic', 'hybrid']);
21
36
  const sanitizeEmbeddingProvider = (value) => typeof value === 'string' && embeddingProviders.has(value) ? value : defaultBrainlinkConfig.embeddingProvider;
22
37
  export const sanitizeSearchMode = (value, fallback = defaultBrainlinkConfig.defaultSearchMode) => typeof value === 'string' && searchModes.has(value) ? value : fallback;
23
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
+ };
24
72
  const readAllowedVaultsFromEnv = () => (process.env.BRAINLINK_ALLOWED_VAULTS ?? '')
25
73
  .split(',')
26
74
  .map((value) => value.trim())
@@ -38,6 +86,34 @@ const readJsonConfig = async (path) => {
38
86
  throw error;
39
87
  }
40
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
+ };
41
117
  const sanitizeConfig = (value) => ({
42
118
  ...defaultBrainlinkConfig,
43
119
  ...value,
@@ -45,6 +121,7 @@ const sanitizeConfig = (value) => ({
45
121
  defaultAgent: typeof value.defaultAgent === 'string' && value.defaultAgent.trim().length > 0
46
122
  ? sanitizeAgentId(value.defaultAgent)
47
123
  : defaultBrainlinkConfig.defaultAgent,
124
+ autoIndexOnWrite: typeof value.autoIndexOnWrite === 'boolean' ? value.autoIndexOnWrite : defaultBrainlinkConfig.autoIndexOnWrite,
48
125
  defaultSearchLimit: typeof value.defaultSearchLimit === 'number' && value.defaultSearchLimit > 0
49
126
  ? value.defaultSearchLimit
50
127
  : defaultBrainlinkConfig.defaultSearchLimit,
@@ -54,11 +131,22 @@ const sanitizeConfig = (value) => ({
54
131
  allowedVaults: [...sanitizeAllowedVaults(value.allowedVaults), ...readAllowedVaultsFromEnv()],
55
132
  chunkSize: typeof value.chunkSize === 'number' && value.chunkSize > 0 ? value.chunkSize : defaultBrainlinkConfig.chunkSize,
56
133
  embeddingProvider: sanitizeEmbeddingProvider(value.embeddingProvider),
57
- defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode)
134
+ defaultSearchMode: sanitizeSearchMode(value.defaultSearchMode),
135
+ agentProfiles: sanitizeAgentProfiles(value.agentProfiles)
58
136
  });
59
- export const loadBrainlinkConfig = async (cwd = process.cwd()) => {
60
- const configs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
61
- const merged = configs.reduce((state, config) => ({
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) => ({
62
150
  ...state,
63
151
  ...config
64
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 = process.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,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
+ };