vault-go 0.29.2 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +12 -0
  2. package/dist/agent-cost.d.ts +11 -0
  3. package/dist/agent-cost.js +116 -0
  4. package/dist/auth.js +29 -2
  5. package/dist/capabilities.d.ts +40 -24
  6. package/dist/capabilities.js +8 -6
  7. package/dist/capture-privacy.js +1 -1
  8. package/dist/cli.d.ts +2 -1
  9. package/dist/cli.js +16 -1
  10. package/dist/cloud.d.ts +22 -0
  11. package/dist/cloud.js +131 -47
  12. package/dist/config.d.ts +2 -0
  13. package/dist/config.js +15 -1
  14. package/dist/crystal-memory-mirror.d.ts +14 -1
  15. package/dist/crystal-memory-mirror.js +24 -15
  16. package/dist/crystal-tools.d.ts +24 -1
  17. package/dist/crystal-tools.js +30 -5
  18. package/dist/grok-awareness.js +54 -1
  19. package/dist/hook-install.d.ts +4 -0
  20. package/dist/hook-install.js +41 -1
  21. package/dist/hook-queue.d.ts +18 -0
  22. package/dist/hook-queue.js +115 -3
  23. package/dist/hook-runner.js +1 -1
  24. package/dist/hooks.d.ts +1 -1
  25. package/dist/hooks.js +27 -8
  26. package/dist/index.js +5 -1
  27. package/dist/installer.js +8 -1
  28. package/dist/legacy-brand.d.ts +2 -0
  29. package/dist/legacy-brand.js +5 -0
  30. package/dist/local-service.js +11 -0
  31. package/dist/memory-archive.d.ts +25 -0
  32. package/dist/memory-archive.js +65 -0
  33. package/dist/modes.d.ts +1 -1
  34. package/dist/modes.js +3 -1
  35. package/dist/opencode-plugin.d.ts +29 -1
  36. package/dist/opencode-plugin.js +36 -1
  37. package/dist/server.js +68 -0
  38. package/dist/skill-context.d.ts +4 -0
  39. package/dist/skill-context.js +16 -0
  40. package/dist/skill-download.d.ts +6 -0
  41. package/dist/skill-download.js +48 -0
  42. package/dist/skill-library.d.ts +8 -0
  43. package/dist/skill-library.js +32 -0
  44. package/dist/skill-local.d.ts +53 -0
  45. package/dist/skill-local.js +251 -0
  46. package/dist/skill-tools.d.ts +52 -0
  47. package/dist/skill-tools.js +356 -0
  48. package/dist/skills-cli.d.ts +31 -0
  49. package/dist/skills-cli.js +90 -0
  50. package/dist/transcript-config.d.ts +1 -1
  51. package/dist/transcript-config.js +4 -3
  52. package/dist/transcript-parser.js +1 -1
  53. package/dist/transcript-tools.js +1 -1
  54. package/package.json +19 -1
package/dist/cloud.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { resolve } from 'node:path';
1
2
  import { recordActivity } from './activity.js';
2
- import { loadConfig, loadTokens, saveTokens, validateApiUrl, vaultHome, } from './config.js';
3
+ import { credentialFilesGeneration, loadConfig, loadTokens, saveTokens, validateApiUrl, vaultHome, } from './config.js';
3
4
  const KNOWLEDGE_BASE_RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
5
+ const SKILL_RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
4
6
  const KNOWLEDGE_BASE_RESPONSE_LIMIT_ERROR = 'A base de conhecimento excede o limite de resposta de 8 MiB.';
5
7
  const observationTypes = new Set(['decision', 'discovery', 'fix', 'feature', 'refactor', 'other']);
6
8
  const retentionClasses = new Set(['transient', 'supporting', 'durable', 'critical']);
@@ -42,6 +44,31 @@ function withoutClientDecisions(input) {
42
44
  return safeRecord;
43
45
  }) };
44
46
  }
47
+ const cloudSessions = new Map();
48
+ function sessionFor(home) {
49
+ const generation = credentialFilesGeneration(home);
50
+ const base = validateApiUrl(loadConfig(home).apiUrl).href;
51
+ const environmentToken = process.env['VAULT_GO_TOKEN'];
52
+ const tokens = environmentToken ? null : loadTokens(home);
53
+ if (credentialFilesGeneration(home) !== generation) {
54
+ throw new VaultCloudError(401, 'Vault session changed.');
55
+ }
56
+ const key = JSON.stringify([base, environmentToken, tokens?.accessToken, tokens?.refreshToken, generation]);
57
+ let session = cloudSessions.get(resolve(home));
58
+ if (!session || session.key !== key) {
59
+ session = { key, base, tokens, environmentToken };
60
+ cloudSessions.set(resolve(home), session);
61
+ }
62
+ return session;
63
+ }
64
+ function assertSession(home, session) {
65
+ if (sessionFor(home) !== session)
66
+ throw new VaultCloudError(401, 'Vault session changed.');
67
+ }
68
+ function sessionEndpoint(base, path) {
69
+ const url = new URL(base);
70
+ return new URL(`${url.pathname.replace(/\/$/u, '')}${path}`, url.origin);
71
+ }
45
72
  export class VaultCloudClient {
46
73
  home;
47
74
  cloudFetch;
@@ -103,7 +130,7 @@ export class VaultCloudClient {
103
130
  return { deleted: true, id };
104
131
  }
105
132
  async search(input) {
106
- return this.request('/memory/search', { method: 'POST', body: input });
133
+ return this.request('/memory/search', { retryRead: true, method: 'POST', body: input });
107
134
  }
108
135
  async retrieve(input) {
109
136
  return this.request('/memory/retrieve', { method: 'POST', body: input });
@@ -115,19 +142,19 @@ export class VaultCloudClient {
115
142
  return this.request('/memory/decisions/evaluate', { method: 'POST', body: input });
116
143
  }
117
144
  async searchIndex(input) {
118
- return this.request('/memory/search/index', { method: 'POST', body: input });
145
+ return this.request('/memory/search/index', { retryRead: true, method: 'POST', body: input });
119
146
  }
120
147
  async observations(ids) {
121
- return this.request('/memory/memories/batch', { method: 'POST', body: { ids } });
148
+ return this.request('/memory/memories/batch', { retryRead: true, method: 'POST', body: { ids } });
122
149
  }
123
150
  async timeline(input) {
124
- return this.request('/memory/timeline', { method: 'POST', body: input });
151
+ return this.request('/memory/timeline', { retryRead: true, method: 'POST', body: input });
125
152
  }
126
153
  async context(input) {
127
- return this.request('/memory/context', { method: 'POST', body: input });
154
+ return this.request('/memory/context', { retryRead: true, method: 'POST', body: input });
128
155
  }
129
156
  async fileContext(input) {
130
- return this.request('/memory/files/context', { method: 'POST', body: input });
157
+ return this.request('/memory/files/context', { retryRead: true, method: 'POST', body: input });
131
158
  }
132
159
  async stats() {
133
160
  return this.request('/memory/stats');
@@ -195,6 +222,7 @@ export class VaultCloudClient {
195
222
  }
196
223
  async queryKnowledgeBase(id, input) {
197
224
  return this.request(`/memory/knowledge-bases/${encodeURIComponent(id)}/query`, {
225
+ retryRead: true,
198
226
  method: 'POST',
199
227
  body: input,
200
228
  });
@@ -204,6 +232,36 @@ export class VaultCloudClient {
204
232
  method: 'DELETE',
205
233
  });
206
234
  }
235
+ async skills(query) { return this.request('/v1/skills', { query }); }
236
+ async skillFolders() { return this.request('/v1/skills/folders'); }
237
+ async skill(id, version) {
238
+ return this.request(`/v1/skills/${encodeURIComponent(id)}`, { query: { version } });
239
+ }
240
+ async skillFile(id, path, version) {
241
+ const encoded = path.split('/').map(encodeURIComponent).join('/');
242
+ return this.request(`/v1/skills/${encodeURIComponent(id)}/files/${encoded}`, { query: { version }, responseMaxBytes: SKILL_RESPONSE_MAX_BYTES });
243
+ }
244
+ async createSkill(input, idempotencyKey) {
245
+ return this.request('/v1/skills', { method: 'POST', body: input, timeoutMs: 60_000, ...(idempotencyKey ? { headers: { 'idempotency-key': idempotencyKey } } : {}) });
246
+ }
247
+ async createSkillVersion(id, input, idempotencyKey) {
248
+ return this.request(`/v1/skills/${encodeURIComponent(id)}/versions`, { method: 'POST', body: input, timeoutMs: 60_000, ...(idempotencyKey ? { headers: { 'idempotency-key': idempotencyKey } } : {}) });
249
+ }
250
+ async updateSkill(id, input) {
251
+ return this.request(`/v1/skills/${encodeURIComponent(id)}`, { method: 'PATCH', body: input });
252
+ }
253
+ async setSkillProjects(id, projectIds) {
254
+ return this.request(`/v1/skills/${encodeURIComponent(id)}/projects`, { method: 'PUT', body: { projectIds } });
255
+ }
256
+ async createSkillFolder(input) {
257
+ return this.request('/v1/skills/folders', { method: 'POST', body: input });
258
+ }
259
+ async updateSkillFolder(id, input) {
260
+ return this.request(`/v1/skills/folders/${encodeURIComponent(id)}`, { method: 'PATCH', body: input });
261
+ }
262
+ async skillDownloadGrant(id, versionId) {
263
+ return this.request(`/v1/skills/${encodeURIComponent(id)}/download-grants`, { method: 'POST', body: versionId ? { versionId } : {} });
264
+ }
207
265
  async updatePreferences(input) {
208
266
  return this.request('/preferences', { method: 'PATCH', body: input });
209
267
  }
@@ -211,7 +269,7 @@ export class VaultCloudClient {
211
269
  return this.request(`/memory/memories/${encodeURIComponent(id)}`);
212
270
  }
213
271
  async toolUses(input) {
214
- return this.request('/memory/events/batch', { method: 'POST', body: input });
272
+ return this.request('/memory/events/batch', { retryRead: true, method: 'POST', body: input });
215
273
  }
216
274
  async graph() { return this.request('/memory/graph'); }
217
275
  async graphPresets() { return this.request('/memory/graph/presets'); }
@@ -230,50 +288,59 @@ export class VaultCloudClient {
230
288
  const prefix = base.pathname.replace(/\/$/u, '');
231
289
  return new URL(`${prefix}${path}`, base.origin);
232
290
  }
233
- async accessToken(forceRefresh = false) {
234
- const environmentToken = process.env['VAULT_GO_TOKEN'];
235
- if (environmentToken)
236
- return environmentToken;
237
- const tokens = loadTokens(this.home);
238
- if (!tokens) {
291
+ async accessToken(session, rejectedToken) {
292
+ assertSession(this.home, session);
293
+ if (session.environmentToken)
294
+ return session.environmentToken;
295
+ const tokens = session.tokens;
296
+ if (!tokens)
239
297
  throw new Error('Vault não autenticado. Execute `bunx --bun vault-go@latest login`.');
240
- }
241
- const valid = !forceRefresh &&
242
- (!tokens.expiresAt || Date.now() < tokens.expiresAt - 60_000);
243
- if (valid)
298
+ // A concurrent read may already have renewed this same session.
299
+ if (rejectedToken && rejectedToken !== tokens.accessToken)
300
+ return tokens.accessToken;
301
+ if (!rejectedToken && (!tokens.expiresAt || Date.now() < tokens.expiresAt - 60_000))
244
302
  return tokens.accessToken;
245
- if (!tokens.refreshToken) {
303
+ if (session.refreshing)
304
+ return session.refreshing;
305
+ if (!tokens.refreshToken)
246
306
  throw new Error('Sessão expirada. Execute `bunx --bun vault-go@latest login --force`.');
307
+ const pending = (async () => {
308
+ const response = await this.cloudFetch(sessionEndpoint(session.base, '/auth/refresh'), {
309
+ method: 'POST', redirect: 'error',
310
+ headers: { 'content-type': 'application/json' },
311
+ body: JSON.stringify({ refreshToken: tokens.refreshToken }),
312
+ signal: AbortSignal.timeout(10_000),
313
+ });
314
+ if (!response.ok) {
315
+ await response.body?.cancel().catch(() => { });
316
+ assertSession(this.home, session);
317
+ throw new Error('Sessão expirada. Execute `bunx --bun vault-go@latest login --force`.');
318
+ }
319
+ const refreshed = (await response.json());
320
+ assertSession(this.home, session);
321
+ if (!refreshed || typeof refreshed.accessToken !== 'string' || !refreshed.accessToken) {
322
+ throw new Error('A API Vault retornou credenciais inválidas.');
323
+ }
324
+ const stored = { ...refreshed, expiresAt: Date.now() + (refreshed.expiresIn ?? 900) * 1000 };
325
+ saveTokens(stored, this.home);
326
+ session.tokens = stored;
327
+ session.key = JSON.stringify([session.base, session.environmentToken, stored.accessToken, stored.refreshToken, credentialFilesGeneration(this.home)]);
328
+ return stored.accessToken;
329
+ })();
330
+ session.refreshing = pending;
331
+ try {
332
+ return await pending;
247
333
  }
248
- const response = await this.cloudFetch(this.endpoint('/auth/refresh'), {
249
- method: 'POST',
250
- redirect: 'error',
251
- headers: { 'content-type': 'application/json' },
252
- body: JSON.stringify({ refreshToken: tokens.refreshToken }),
253
- signal: AbortSignal.timeout(10_000),
254
- });
255
- if (!response.ok) {
256
- throw new Error('Sessão expirada. Execute `bunx --bun vault-go@latest login --force`.');
334
+ finally {
335
+ if (session.refreshing === pending)
336
+ delete session.refreshing;
257
337
  }
258
- const refreshed = (await response.json());
259
- // A refresh already in flight must not overwrite a newer login or logout.
260
- const latest = loadTokens(this.home);
261
- if (!latest)
262
- throw new Error('Vault session was disconnected.');
263
- if (latest.accessToken !== tokens.accessToken || latest.refreshToken !== tokens.refreshToken)
264
- return latest.accessToken;
265
- const stored = {
266
- ...refreshed,
267
- expiresAt: Date.now() + (refreshed.expiresIn ?? 900) * 1000,
268
- };
269
- saveTokens(stored, this.home);
270
- return stored.accessToken;
271
338
  }
272
339
  async request(path, options = {}) {
273
340
  const start = Date.now();
274
341
  let ok = false;
275
342
  try {
276
- const result = await this.performRequest(path, options);
343
+ const result = await this.performRequest(path, options, sessionFor(this.home));
277
344
  ok = true;
278
345
  return result;
279
346
  }
@@ -282,12 +349,16 @@ export class VaultCloudClient {
282
349
  recordActivity(this.home, (options.method ?? 'GET') + ' ' + path.split('?')[0], ok, Date.now() - start);
283
350
  }
284
351
  }
285
- async performRequest(path, options = {}) {
352
+ async performRequest(path, options, session, rejectedToken) {
353
+ options.signal?.throwIfAborted();
354
+ assertSession(this.home, session);
286
355
  const headers = new Headers({ accept: 'application/json' });
287
356
  if (options.body)
288
357
  headers.set('content-type', 'application/json');
358
+ for (const [name, value] of Object.entries(options.headers ?? {}))
359
+ headers.set(name, value);
289
360
  if (options.authenticated !== false) {
290
- headers.set('authorization', `Bearer ${await this.accessToken(options.retried)}`);
361
+ headers.set('authorization', `Bearer ${await this.accessToken(session, rejectedToken)}`);
291
362
  }
292
363
  const request = {
293
364
  method: options.method ?? 'GET',
@@ -297,21 +368,34 @@ export class VaultCloudClient {
297
368
  };
298
369
  if (options.body)
299
370
  request.body = JSON.stringify(options.body);
300
- const endpoint = this.endpoint(path);
371
+ options.signal?.throwIfAborted();
372
+ assertSession(this.home, session);
373
+ const endpoint = sessionEndpoint(session.base, path);
301
374
  for (const [key, value] of Object.entries(options.query ?? {})) {
302
375
  if (value !== undefined && value !== null && value !== '') {
303
376
  endpoint.searchParams.set(key, String(value));
304
377
  }
305
378
  }
306
379
  const response = await this.cloudFetch(endpoint, request);
307
- if (response.status === 401 && options.authenticated !== false && !options.retried) {
380
+ if (response.status === 401 && options.authenticated !== false && !options.retried &&
381
+ (['GET', 'HEAD'].includes(options.method ?? 'GET') || options.retryRead)) {
308
382
  try {
309
383
  await response.body?.cancel();
310
384
  }
311
385
  catch { /* Release the rejected response before retrying. */ }
312
- return this.performRequest(path, { ...options, retried: true });
386
+ return this.performRequest(path, { ...options, retried: true }, session, headers.get('authorization')?.slice(7));
387
+ }
388
+ try {
389
+ options.signal?.throwIfAborted();
390
+ assertSession(this.home, session);
391
+ }
392
+ catch (error) {
393
+ await response.body?.cancel().catch(() => { });
394
+ throw error;
313
395
  }
314
396
  const text = await readResponseText(response, options.responseMaxBytes);
397
+ options.signal?.throwIfAborted();
398
+ assertSession(this.home, session);
315
399
  const payload = text ? safeJson(text) : null;
316
400
  if (!response.ok) {
317
401
  const message = payload && typeof payload === 'object' && 'error' in payload
package/dist/config.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface VaultStatus {
21
21
  export declare function vaultHome(environment?: NodeJS.ProcessEnv): string;
22
22
  export declare function configPath(home?: string): string;
23
23
  export declare function authPath(home?: string): string;
24
+ /** File identity survives value-level ABA (logout then login with the same API key). */
25
+ export declare function credentialFilesGeneration(home?: string): string;
24
26
  export declare function loadConfig(home?: string): VaultConfig;
25
27
  export declare function loadTokens(home?: string): StoredTokens | null;
26
28
  export declare function saveConfig(config: VaultConfig, home?: string): void;
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, dirname, join, resolve } from 'node:path';
4
4
  import { VAULT_CLOUD_API_URL } from './vault-cloud.js';
@@ -20,6 +20,20 @@ export function configPath(home = vaultHome()) {
20
20
  export function authPath(home = vaultHome()) {
21
21
  return join(home, 'auth.json');
22
22
  }
23
+ /** File identity survives value-level ABA (logout then login with the same API key). */
24
+ export function credentialFilesGeneration(home = vaultHome()) {
25
+ return [configPath(home), authPath(home)].map(path => {
26
+ try {
27
+ const stat = statSync(path, { bigint: true });
28
+ return [stat.dev, stat.ino, stat.birthtimeNs, stat.mtimeNs, stat.ctimeNs, stat.size].join(':');
29
+ }
30
+ catch (error) {
31
+ if (error.code === 'ENOENT')
32
+ return 'absent';
33
+ throw error;
34
+ }
35
+ }).join('|');
36
+ }
23
37
  export function loadConfig(home = vaultHome()) {
24
38
  const file = configPath(home);
25
39
  const environmentApiUrl = process.env['VAULT_GO_API_URL'];
@@ -9,10 +9,23 @@ type MirrorJob = {
9
9
  export declare function shouldMirrorCrystalMemory(job: MirrorJob): boolean;
10
10
  export declare function crystalProjectFolder(project: unknown): string | null;
11
11
  export declare function resolveCrystalProjectFolder(cloud: Pick<VaultMemoryApi, 'projects'>, projectId: string): Promise<string | null>;
12
+ export interface CrystalMirrorRecord {
13
+ sourceId: string;
14
+ project: string;
15
+ title: string;
16
+ content: string;
17
+ tags: string[];
18
+ }
19
+ /** written; locked (retry later); rejected (non-locked tool error); unavailable (Desktop unreachable). */
20
+ export type CrystalMirrorOutcome = 'written' | 'locked' | 'rejected' | 'unavailable';
21
+ export declare function writeCrystalMirror(home: string, record: CrystalMirrorRecord): Promise<CrystalMirrorOutcome>;
12
22
  export declare function mirrorCrystalMemory(input: {
13
23
  home: string;
14
24
  cloud: Pick<VaultMemoryApi, 'projects'>;
15
25
  job: MirrorJob;
16
26
  result: Pick<ContextResult, 'title' | 'content' | 'concepts'>;
17
- }): Promise<void>;
27
+ }): Promise<{
28
+ outcome: CrystalMirrorOutcome | 'skipped';
29
+ record?: CrystalMirrorRecord;
30
+ }>;
18
31
  export {};
@@ -33,23 +33,32 @@ export async function resolveCrystalProjectFolder(cloud, projectId) {
33
33
  const oldest = namesakes.at(-1);
34
34
  return oldest?.id === projectId ? folder : `${folder}-${projectId.slice(0, 8)}`;
35
35
  }
36
- export async function mirrorCrystalMemory(input) {
37
- if (!shouldMirrorCrystalMemory(input.job))
38
- return;
39
- const project = await resolveCrystalProjectFolder(input.cloud, input.job.projectId);
40
- if (!project)
41
- return;
36
+ export async function writeCrystalMirror(home, record) {
42
37
  try {
43
- const { callCrystalTool } = await import('./crystal-tools.js');
44
- await callCrystalTool(input.home, 'crystal_memory_write', {
45
- project,
46
- title: input.result.title,
47
- content: input.result.content,
48
- tags: input.result.concepts,
49
- sourceId: `vault-go:event:${input.job.id}`,
50
- });
38
+ const { callCrystalTool, isCrystalLockedResult } = await import('./crystal-tools.js');
39
+ const result = await callCrystalTool(home, 'crystal_memory_write', { ...record });
40
+ if (isCrystalLockedResult(result))
41
+ return 'locked';
42
+ return result.isError ? 'rejected' : 'written';
51
43
  }
52
44
  catch {
53
- // Crystal MCP is optional; account memory already persisted.
45
+ return 'unavailable';
54
46
  }
55
47
  }
48
+ export async function mirrorCrystalMemory(input) {
49
+ if (!shouldMirrorCrystalMemory(input.job))
50
+ return { outcome: 'skipped' };
51
+ const project = await resolveCrystalProjectFolder(input.cloud, input.job.projectId);
52
+ if (!project)
53
+ return { outcome: 'skipped' };
54
+ const record = {
55
+ project,
56
+ title: input.result.title,
57
+ content: input.result.content,
58
+ tags: input.result.concepts,
59
+ sourceId: `vault-go:event:${input.job.id}`,
60
+ };
61
+ // Crystal MCP is optional; account memory already persisted. Only a locked
62
+ // Crystal is worth retrying, so the caller queues the record for that case.
63
+ return { outcome: await writeCrystalMirror(input.home, record), record };
64
+ }
@@ -1,9 +1,32 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare const CRYSTAL_LOCKED = "CRYSTAL_LOCKED";
3
+ type CrystalToolResult = {
4
+ isError?: boolean | undefined;
5
+ content?: {
6
+ type: string;
7
+ text?: string;
8
+ }[] | undefined;
9
+ structuredContent?: Record<string, unknown> | undefined;
10
+ };
11
+ export declare function isCrystalLockedResult(result: CrystalToolResult): boolean;
2
12
  export declare function callCrystalTool(home: string, name: string, args: Record<string, unknown>): Promise<{
13
+ content: {
14
+ type: "text";
15
+ text: string;
16
+ }[];
17
+ structuredContent: {
18
+ code: string;
19
+ };
20
+ isError: boolean;
21
+ } | {
3
22
  isError?: boolean;
4
23
  content: {
5
24
  text: string;
6
25
  type: "text";
7
26
  }[];
27
+ structuredContent?: never;
8
28
  }>;
9
- export declare function registerCrystalTools(server: McpServer, home: string): void;
29
+ export declare function registerCrystalTools(server: McpServer, home: string, options?: {
30
+ crystalMirrorStore?: import('./hook-queue.js').CrystalMirrorStoreFactory;
31
+ }): void;
32
+ export {};
@@ -5,6 +5,19 @@ import { z } from 'zod';
5
5
  const unavailable = 'Abra o Vault Desktop e ative a integração Vault MCP no Crystal.';
6
6
  const invalidConnection = 'Conexão local do Crystal inválida. Reative a integração no Vault Desktop.';
7
7
  const MAX_RESPONSE = 32 * 1024 * 1024;
8
+ export const CRYSTAL_LOCKED = 'CRYSTAL_LOCKED';
9
+ const lockedMessage = `${CRYSTAL_LOCKED}: O Crystal está trancado no Vault Desktop. Peça ao usuário para destrancá-lo no Vault Desktop com Touch ID ou a senha do macOS e só então tente de novo; não repita a chamada em loop.`;
10
+ const lockedGuidance = ` Se o Crystal usar criptografia local e estiver trancado, a chamada falha com ${CRYSTAL_LOCKED}: peça ao usuário para destrancá-lo no Vault Desktop com Touch ID e não repita em loop.`;
11
+ // Why: Vault Desktop 0.2.76 reports a locked Crystal only as text starting with
12
+ // "Crystal trancado"; newer versions use the CRYSTAL_LOCKED prefix and code.
13
+ export function isCrystalLockedResult(result) {
14
+ if (!result.isError)
15
+ return false;
16
+ if (result.structuredContent?.code === CRYSTAL_LOCKED)
17
+ return true;
18
+ return (result.content ?? []).some(item => typeof item.text === 'string' &&
19
+ (/^CRYSTAL_LOCKED\b/.test(item.text.trimStart()) || /^Crystal trancado/i.test(item.text.trimStart())));
20
+ }
8
21
  function connection(home) {
9
22
  const file = join(home, 'crystal-connection.json');
10
23
  try {
@@ -71,25 +84,37 @@ export async function callCrystalTool(home, name, args) {
71
84
  }
72
85
  const envelope = z.object({
73
86
  jsonrpc: z.literal('2.0'), id: z.literal(id),
74
- result: z.object({ isError: z.boolean().optional(), content: z.array(z.object({ type: z.literal('text'), text: z.string() })) }),
87
+ result: z.object({
88
+ isError: z.boolean().optional(),
89
+ content: z.array(z.object({ type: z.literal('text'), text: z.string() })),
90
+ structuredContent: z.record(z.unknown()).optional(),
91
+ }),
75
92
  }).safeParse(payload);
76
93
  if (!envelope.success)
77
94
  throw new Error('Resposta inválida do Crystal. Atualize o Vault Desktop.');
95
+ if (isCrystalLockedResult(envelope.data.result)) {
96
+ return { content: [{ type: 'text', text: lockedMessage }], structuredContent: { code: CRYSTAL_LOCKED }, isError: true };
97
+ }
78
98
  return {
79
99
  content: envelope.data.result.content.map(item => ({ ...item, text: item.text.replaceAll(local.token, '[redacted]') })),
80
100
  ...(envelope.data.result.isError ? { isError: true } : {}),
81
101
  };
82
102
  }
83
- export function registerCrystalTools(server, home) {
103
+ export function registerCrystalTools(server, home, options = {}) {
84
104
  const path = z.string().min(1).max(2000).refine(value => !/^(?:[a-zA-Z]:|[\\/])|(?:^|[\\/])\.\.(?:[\\/]|$)/.test(value) && !value.includes('\0'), 'Informe um caminho relativo dentro do Crystal.');
85
105
  const notePath = path.refine(value => /\.(?:md|markdown)$/i.test(value), 'Informe uma nota Markdown do Crystal.');
86
106
  const add = (name, description, inputSchema, readOnly = true, destructive = false) => {
87
107
  server.registerTool(`vault_go_crystal_${name}`, {
88
- description: `${description} Usa o Crystal do Vault Desktop aberto neste computador.`, inputSchema,
108
+ description: `${description} Usa o Crystal do Vault Desktop aberto neste computador.${name === 'status' ? '' : lockedGuidance}`, inputSchema,
89
109
  annotations: { readOnlyHint: readOnly, destructiveHint: destructive, idempotentHint: readOnly, openWorldHint: name === 'sync' },
90
110
  }, async (input) => {
91
111
  try {
92
- return await callCrystalTool(home, `crystal_${name}`, input);
112
+ const result = await callCrystalTool(home, `crystal_${name}`, input);
113
+ // A successful call proves the Crystal is unlocked; status also answers while locked.
114
+ if (!result.isError && name !== 'status') {
115
+ void import('./hook-queue.js').then(queue => queue.flushPendingCrystalMirrors(home, undefined, options.crystalMirrorStore ? { openStore: options.crystalMirrorStore } : {})).catch(() => { });
116
+ }
117
+ return result;
93
118
  }
94
119
  catch (error) {
95
120
  return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : unavailable }] };
@@ -102,7 +127,7 @@ export function registerCrystalTools(server, home) {
102
127
  add('write', 'Cria ou atualiza uma nota Markdown no Crystal.', { path, content: z.string().max(10 * 1024 * 1024), title: z.string().max(1000).optional(), tags: z.array(z.string().max(200)).max(100).optional() }, false);
103
128
  add('delete', 'Exclui uma nota do Crystal.', { path: notePath }, false, true);
104
129
  add('sync', 'Sincroniza o Crystal com o Vault Cloud.', {}, false);
105
- add('status', 'Mostra o estado do Crystal e da sincronização.', {});
130
+ add('status', 'Mostra o estado do Crystal e da sincronização. localEncryption.locked indica se o Crystal está trancado no Vault Desktop (syncState pode ser paused-locked).', {});
106
131
  add('memory_list', 'Lista notas duráveis em {projeto}/Memory no Crystal.', { project: z.string().trim().min(1).max(200).optional() });
107
132
  add('memory_write', 'Grava uma nota durável em {projeto}/Memory no Crystal.', {
108
133
  project: z.string().trim().min(1).max(200),
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { isAbsolute, join } from 'node:path';
5
5
  import { loadConfig, loadTokens } from './config.js';
6
6
  import { redact } from './capture-privacy.js';
@@ -133,6 +133,55 @@ async function lock(home) {
133
133
  }
134
134
  throw new Error('Awareness writer is busy');
135
135
  }
136
+ function logLines(memoryDir) {
137
+ const log = join(memoryDir, 'log');
138
+ if (!existsSync(log))
139
+ return [];
140
+ const stat = lstatSync(log);
141
+ if (stat.isSymbolicLink() || !stat.isDirectory() || realpathSync(log) !== log)
142
+ return [];
143
+ const lines = [];
144
+ for (const name of readdirSync(log)) {
145
+ if (!name.endsWith('.md'))
146
+ continue;
147
+ const file = join(log, name);
148
+ const fileStat = lstatSync(file);
149
+ if (fileStat.isSymbolicLink() || !fileStat.isFile() || fileStat.size > MAX_LOG_BYTES)
150
+ continue;
151
+ lines.push(...readFileSync(file, 'utf8').split('\n').map(line => line.trim()).filter(line => line.startsWith('- ') && line.length <= 500));
152
+ }
153
+ return lines.slice(-80);
154
+ }
155
+ function writeLiveIndex(root, agentId, _now) {
156
+ const agents = ensureDirectory(root, ['agents']);
157
+ const seat = ensureDirectory(root, ['agents', agentId, 'memory']);
158
+ const lines = logLines(seat).slice(-80);
159
+ if (lines.length < 80) {
160
+ for (const name of readdirSync(agents)) {
161
+ if (lines.length >= 80 || name === agentId || name.startsWith('.'))
162
+ continue;
163
+ const other = join(agents, name, 'memory');
164
+ try {
165
+ const stat = lstatSync(other);
166
+ if (stat.isSymbolicLink() || !stat.isDirectory() || realpathSync(other) !== other)
167
+ continue;
168
+ lines.push(...logLines(other).slice(0, 80 - lines.length));
169
+ }
170
+ catch { /* unreadable sibling stays out of the index */ }
171
+ }
172
+ }
173
+ const body = ['# Vault Memory', '', ...lines.slice(0, 80), ''].join('\n');
174
+ const file = join(seat, 'zz-vault-memory-inject.md');
175
+ const temp = join(seat, `.index-${randomUUID()}.tmp`);
176
+ writeFileSync(temp, body, { mode: 0o600, flag: 'wx' });
177
+ try {
178
+ renameSync(temp, file);
179
+ chmodSync(file, 0o600);
180
+ }
181
+ finally {
182
+ rmSync(temp, { force: true });
183
+ }
184
+ }
136
185
  export async function pushAwareness(home, input, now = new Date(), io = fs) {
137
186
  const initial = load(home), identity = owner(home);
138
187
  let release;
@@ -222,6 +271,10 @@ export async function pushAwareness(home, input, now = new Date(), io = fs) {
222
271
  }
223
272
  chmodSync(file, 0o600);
224
273
  }
274
+ try {
275
+ writeLiveIndex(config.agentDataRoot, input.agentId, now);
276
+ }
277
+ catch { /* the monthly log remains the source of record */ }
225
278
  config.lastPush = Date.now();
226
279
  delete config.lastError;
227
280
  save(home, config);
@@ -1,14 +1,18 @@
1
1
  export declare const HOOK_OWNER = "Vault Go: captura e contexto";
2
2
  export declare const HOOK_EVENTS: {
3
+ readonly Setup: "setup";
3
4
  readonly SessionStart: "context";
4
5
  readonly UserPromptSubmit: "session-init";
5
6
  readonly PreToolUse: "file-context";
6
7
  readonly PostToolUse: "observation";
7
8
  readonly Stop: "summarize";
9
+ readonly SessionEnd: "session-end";
8
10
  };
9
11
  export declare function quoteShell(value: string): string;
10
12
  export declare function hookEntryPath(configHome?: string): string;
11
13
  export declare function hookCommand(adapter: string, event: string, configHome?: string): string;
14
+ declare const CAPTURE_HOMES: Record<string, string>;
15
+ export declare function installCaptureHooks(adapter: keyof typeof CAPTURE_HOMES, userHome?: string, configHome?: string): void;
12
16
  export type HookClient = "claude" | "codex" | "grok" | "agy" | "gemini";
13
17
  export declare function installClientHooks(client: HookClient, userHome?: string, configHome?: string): void;
14
18
  export declare function installOpenCodePlugin(userHome?: string, configHome?: string): void;