vault-go 0.30.0 → 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 (50) 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 +30 -24
  6. package/dist/capabilities.js +7 -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.js +98 -47
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.js +15 -1
  13. package/dist/crystal-memory-mirror.d.ts +14 -1
  14. package/dist/crystal-memory-mirror.js +24 -15
  15. package/dist/crystal-tools.d.ts +24 -1
  16. package/dist/crystal-tools.js +30 -5
  17. package/dist/grok-awareness.js +54 -1
  18. package/dist/hook-install.d.ts +4 -0
  19. package/dist/hook-install.js +41 -1
  20. package/dist/hook-queue.d.ts +18 -0
  21. package/dist/hook-queue.js +115 -3
  22. package/dist/hook-runner.js +1 -1
  23. package/dist/hooks.d.ts +1 -1
  24. package/dist/hooks.js +17 -8
  25. package/dist/index.js +5 -1
  26. package/dist/installer.js +8 -1
  27. package/dist/legacy-brand.d.ts +2 -0
  28. package/dist/legacy-brand.js +5 -0
  29. package/dist/local-service.js +11 -0
  30. package/dist/memory-archive.d.ts +25 -0
  31. package/dist/memory-archive.js +65 -0
  32. package/dist/modes.d.ts +1 -1
  33. package/dist/modes.js +3 -1
  34. package/dist/opencode-plugin.d.ts +29 -1
  35. package/dist/opencode-plugin.js +36 -1
  36. package/dist/server.js +66 -0
  37. package/dist/skill-download.d.ts +6 -0
  38. package/dist/skill-download.js +48 -0
  39. package/dist/skill-library.d.ts +8 -0
  40. package/dist/skill-library.js +32 -0
  41. package/dist/skill-local.d.ts +7 -0
  42. package/dist/skill-local.js +44 -7
  43. package/dist/skill-tools.js +13 -26
  44. package/dist/skills-cli.d.ts +31 -0
  45. package/dist/skills-cli.js +90 -0
  46. package/dist/transcript-config.d.ts +1 -1
  47. package/dist/transcript-config.js +4 -3
  48. package/dist/transcript-parser.js +1 -1
  49. package/dist/transcript-tools.js +1 -1
  50. package/package.json +13 -1
package/dist/cloud.js CHANGED
@@ -1,5 +1,6 @@
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;
4
5
  const SKILL_RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
5
6
  const KNOWLEDGE_BASE_RESPONSE_LIMIT_ERROR = 'A base de conhecimento excede o limite de resposta de 8 MiB.';
@@ -43,6 +44,31 @@ function withoutClientDecisions(input) {
43
44
  return safeRecord;
44
45
  }) };
45
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
+ }
46
72
  export class VaultCloudClient {
47
73
  home;
48
74
  cloudFetch;
@@ -104,7 +130,7 @@ export class VaultCloudClient {
104
130
  return { deleted: true, id };
105
131
  }
106
132
  async search(input) {
107
- return this.request('/memory/search', { method: 'POST', body: input });
133
+ return this.request('/memory/search', { retryRead: true, method: 'POST', body: input });
108
134
  }
109
135
  async retrieve(input) {
110
136
  return this.request('/memory/retrieve', { method: 'POST', body: input });
@@ -116,19 +142,19 @@ export class VaultCloudClient {
116
142
  return this.request('/memory/decisions/evaluate', { method: 'POST', body: input });
117
143
  }
118
144
  async searchIndex(input) {
119
- return this.request('/memory/search/index', { method: 'POST', body: input });
145
+ return this.request('/memory/search/index', { retryRead: true, method: 'POST', body: input });
120
146
  }
121
147
  async observations(ids) {
122
- return this.request('/memory/memories/batch', { method: 'POST', body: { ids } });
148
+ return this.request('/memory/memories/batch', { retryRead: true, method: 'POST', body: { ids } });
123
149
  }
124
150
  async timeline(input) {
125
- return this.request('/memory/timeline', { method: 'POST', body: input });
151
+ return this.request('/memory/timeline', { retryRead: true, method: 'POST', body: input });
126
152
  }
127
153
  async context(input) {
128
- return this.request('/memory/context', { method: 'POST', body: input });
154
+ return this.request('/memory/context', { retryRead: true, method: 'POST', body: input });
129
155
  }
130
156
  async fileContext(input) {
131
- return this.request('/memory/files/context', { method: 'POST', body: input });
157
+ return this.request('/memory/files/context', { retryRead: true, method: 'POST', body: input });
132
158
  }
133
159
  async stats() {
134
160
  return this.request('/memory/stats');
@@ -196,6 +222,7 @@ export class VaultCloudClient {
196
222
  }
197
223
  async queryKnowledgeBase(id, input) {
198
224
  return this.request(`/memory/knowledge-bases/${encodeURIComponent(id)}/query`, {
225
+ retryRead: true,
199
226
  method: 'POST',
200
227
  body: input,
201
228
  });
@@ -242,7 +269,7 @@ export class VaultCloudClient {
242
269
  return this.request(`/memory/memories/${encodeURIComponent(id)}`);
243
270
  }
244
271
  async toolUses(input) {
245
- return this.request('/memory/events/batch', { method: 'POST', body: input });
272
+ return this.request('/memory/events/batch', { retryRead: true, method: 'POST', body: input });
246
273
  }
247
274
  async graph() { return this.request('/memory/graph'); }
248
275
  async graphPresets() { return this.request('/memory/graph/presets'); }
@@ -261,50 +288,59 @@ export class VaultCloudClient {
261
288
  const prefix = base.pathname.replace(/\/$/u, '');
262
289
  return new URL(`${prefix}${path}`, base.origin);
263
290
  }
264
- async accessToken(forceRefresh = false) {
265
- const environmentToken = process.env['VAULT_GO_TOKEN'];
266
- if (environmentToken)
267
- return environmentToken;
268
- const tokens = loadTokens(this.home);
269
- 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)
270
297
  throw new Error('Vault não autenticado. Execute `bunx --bun vault-go@latest login`.');
271
- }
272
- const valid = !forceRefresh &&
273
- (!tokens.expiresAt || Date.now() < tokens.expiresAt - 60_000);
274
- 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))
275
302
  return tokens.accessToken;
276
- if (!tokens.refreshToken) {
303
+ if (session.refreshing)
304
+ return session.refreshing;
305
+ if (!tokens.refreshToken)
277
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;
278
333
  }
279
- const response = await this.cloudFetch(this.endpoint('/auth/refresh'), {
280
- method: 'POST',
281
- redirect: 'error',
282
- headers: { 'content-type': 'application/json' },
283
- body: JSON.stringify({ refreshToken: tokens.refreshToken }),
284
- signal: AbortSignal.timeout(10_000),
285
- });
286
- if (!response.ok) {
287
- 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;
288
337
  }
289
- const refreshed = (await response.json());
290
- // A refresh already in flight must not overwrite a newer login or logout.
291
- const latest = loadTokens(this.home);
292
- if (!latest)
293
- throw new Error('Vault session was disconnected.');
294
- if (latest.accessToken !== tokens.accessToken || latest.refreshToken !== tokens.refreshToken)
295
- return latest.accessToken;
296
- const stored = {
297
- ...refreshed,
298
- expiresAt: Date.now() + (refreshed.expiresIn ?? 900) * 1000,
299
- };
300
- saveTokens(stored, this.home);
301
- return stored.accessToken;
302
338
  }
303
339
  async request(path, options = {}) {
304
340
  const start = Date.now();
305
341
  let ok = false;
306
342
  try {
307
- const result = await this.performRequest(path, options);
343
+ const result = await this.performRequest(path, options, sessionFor(this.home));
308
344
  ok = true;
309
345
  return result;
310
346
  }
@@ -313,14 +349,16 @@ export class VaultCloudClient {
313
349
  recordActivity(this.home, (options.method ?? 'GET') + ' ' + path.split('?')[0], ok, Date.now() - start);
314
350
  }
315
351
  }
316
- async performRequest(path, options = {}) {
352
+ async performRequest(path, options, session, rejectedToken) {
353
+ options.signal?.throwIfAborted();
354
+ assertSession(this.home, session);
317
355
  const headers = new Headers({ accept: 'application/json' });
318
356
  if (options.body)
319
357
  headers.set('content-type', 'application/json');
320
358
  for (const [name, value] of Object.entries(options.headers ?? {}))
321
359
  headers.set(name, value);
322
360
  if (options.authenticated !== false) {
323
- headers.set('authorization', `Bearer ${await this.accessToken(options.retried)}`);
361
+ headers.set('authorization', `Bearer ${await this.accessToken(session, rejectedToken)}`);
324
362
  }
325
363
  const request = {
326
364
  method: options.method ?? 'GET',
@@ -330,21 +368,34 @@ export class VaultCloudClient {
330
368
  };
331
369
  if (options.body)
332
370
  request.body = JSON.stringify(options.body);
333
- const endpoint = this.endpoint(path);
371
+ options.signal?.throwIfAborted();
372
+ assertSession(this.home, session);
373
+ const endpoint = sessionEndpoint(session.base, path);
334
374
  for (const [key, value] of Object.entries(options.query ?? {})) {
335
375
  if (value !== undefined && value !== null && value !== '') {
336
376
  endpoint.searchParams.set(key, String(value));
337
377
  }
338
378
  }
339
379
  const response = await this.cloudFetch(endpoint, request);
340
- 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)) {
341
382
  try {
342
383
  await response.body?.cancel();
343
384
  }
344
385
  catch { /* Release the rejected response before retrying. */ }
345
- 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;
346
395
  }
347
396
  const text = await readResponseText(response, options.responseMaxBytes);
397
+ options.signal?.throwIfAborted();
398
+ assertSession(this.home, session);
348
399
  const payload = text ? safeJson(text) : null;
349
400
  if (!response.ok) {
350
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;
@@ -35,11 +35,13 @@ function atomicWriteText(path, text) {
35
35
  }
36
36
  export const HOOK_OWNER = "Vault Go: captura e contexto";
37
37
  export const HOOK_EVENTS = {
38
+ Setup: "setup",
38
39
  SessionStart: "context",
39
40
  UserPromptSubmit: "session-init",
40
41
  PreToolUse: "file-context",
41
42
  PostToolUse: "observation",
42
43
  Stop: "summarize",
44
+ SessionEnd: "session-end",
43
45
  };
44
46
  const GEMINI_EVENTS = {
45
47
  SessionStart: "context",
@@ -77,7 +79,7 @@ function hookManifest(adapter, configHome = vaultHome()) {
77
79
  timeout: event === "context" ? 60 : 20,
78
80
  statusMessage: HOOK_OWNER,
79
81
  }),
80
- ...(adapter !== "gemini" && (event === "observation" || event === "summarize")
82
+ ...(adapter !== "gemini" && (event === "observation" || event === "summarize" || event === "session-end")
81
83
  ? { async: true }
82
84
  : {}),
83
85
  },
@@ -102,8 +104,37 @@ function agyHookManifest(configHome = vaultHome()) {
102
104
  { matcher: "*", hooks: [agyHandler("observation", configHome)] },
103
105
  ],
104
106
  Stop: [agyHandler("summarize", configHome)],
107
+ SessionEnd: [agyHandler("session-end", configHome)],
105
108
  };
106
109
  }
110
+ const CAPTURE_HOMES = {
111
+ cursor: ".cursor/hooks.json",
112
+ windsurf: ".codeium/windsurf/hooks.json",
113
+ openclaw: ".openclaw/hooks.json",
114
+ hermes: ".hermes/hooks.json",
115
+ };
116
+ export function installCaptureHooks(adapter, userHome = homedir(), configHome = vaultHome()) {
117
+ const relative = CAPTURE_HOMES[adapter];
118
+ if (!relative)
119
+ throw new Error("Adaptador de captura desconhecido.");
120
+ const path = join(userHome, relative);
121
+ const config = existsSync(path) ? readJsonObject(path) : {};
122
+ const hooks = {
123
+ ...(config.hooks && typeof config.hooks === "object" && !Array.isArray(config.hooks) ? config.hooks : {}),
124
+ };
125
+ const desired = hookManifest(adapter, configHome).hooks;
126
+ for (const event of Object.keys(desired)) {
127
+ const current = hooks[event];
128
+ const retained = Array.isArray(current)
129
+ ? current.filter((group) => {
130
+ const text = JSON.stringify(group);
131
+ return !text.includes(HOOK_OWNER);
132
+ })
133
+ : [];
134
+ hooks[event] = [...retained, ...(desired[event] || [])];
135
+ }
136
+ atomicWriteJson(path, { ...config, hooks });
137
+ }
107
138
  export function installClientHooks(client, userHome = homedir(), configHome = vaultHome()) {
108
139
  if (client === "grok") {
109
140
  atomicWriteJson(join(userHome, ".grok", "hooks", "vault-go.json"), hookManifest("grok", configHome));
@@ -173,6 +204,15 @@ export function repairAdditionalClientHooks(userHome = homedir(), configHome = v
173
204
  if (server && server.enabled !== false)
174
205
  installOpenCodePlugin(userHome, configHome);
175
206
  }, report);
207
+ for (const adapter of Object.keys(CAPTURE_HOMES)) {
208
+ const relative = CAPTURE_HOMES[adapter];
209
+ if (!relative)
210
+ continue;
211
+ const directory = join(userHome, relative.split("/").slice(0, -1).join("/"));
212
+ if (existsSync(directory)) {
213
+ repairClient(adapter, () => installCaptureHooks(adapter, userHome, configHome), report);
214
+ }
215
+ }
176
216
  }
177
217
  function reportRepairWarning(message) {
178
218
  process.stderr.write(message + "\n");