vault-go 0.31.2 → 0.33.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.
package/README.md CHANGED
@@ -53,7 +53,9 @@ The setup wizard:
53
53
  3. Leaves other client config in place.
54
54
  4. Installs the local dashboard on port `38850` (starts at login on macOS).
55
55
 
56
- Supported clients: Codex, ChatGPT Desktop, Claude Code, Claude Desktop, Grok, Agy / Antigravity, Gemini CLI, Cursor, Windsurf, VS Code + GitHub Copilot, Copilot CLI, Roo Code, OpenCode, and Vault Desktop (Crystal).
56
+ Supported clients: Codex, ChatGPT Desktop, Claude Code, Claude Desktop, Grok, Agy / Antigravity, Gemini CLI, Cursor, Windsurf, VS Code + GitHub Copilot, Copilot CLI, Roo Code, OpenCode, Warp, Goose, Hermes, and Vault Desktop (Crystal).
57
+
58
+ Goose and Hermes are configured by editing a single entry in their `config.yaml` (`extensions.vault-go` and `mcp_servers.vault-go`). Comments and other lines are left as they are. A file with a shape that cannot be edited safely, such as an inline mapping, is left unchanged and reported.
57
59
 
58
60
  ```sh
59
61
  vault-go setup
@@ -152,6 +154,8 @@ vault-go engine key openrouter
152
154
 
153
155
  MCP tools never accept secrets. Store keys with the CLI or the local dashboard. Provider usage limits apply. Managed Vault AI generation still depends on server configuration and quota.
154
156
 
157
+ When the background queue hits an exhausted quota, Vault AI Resume and the Claude subscription stand in for each other. Other failures never switch engines. Set `VAULT_GO_ENGINE_FALLBACK=off` to disable this.
158
+
155
159
  ## MCP tools
156
160
 
157
161
  Account and health: `vault_go_status`, `vault_go_cloud_health`, `vault_go_preferences`, `vault_go_preferences_update`.
@@ -212,6 +216,10 @@ The worker drains a private queue with the selected engine. Successful generatio
212
216
 
213
217
  Opt-in Claude/Codex transcript watchers and project context chains are configured with MCP/CLI and stay bound to the current account.
214
218
 
219
+ Helper threads that clients generate themselves are never captured: Codex titles, suggestions and memory consolidation, Grok research briefings, and turns that are only a `<task-notification>`.
220
+
221
+ **File Read Gate (Claude Code).** In some cases a whole-file `Read` is denied, and the denial lists the file's memories. This happens only when the file is a code file of 32 KB or more, inside the workspace, and Vault memories about it are newer than its last change. Claude is pointed to `vault_go_smart_outline` / `vault_go_smart_unfold` or to a `Read` with `offset`/`limit` instead; partial reads always pass. Subagents and other clients are never gated, and any lookup failure lets the read through. Turn it off with `VAULT_GO_FILE_READ_GATE=off` or the `fileReadGate: false` preference.
222
+
215
223
  ## Automatic updates
216
224
 
217
225
  The worker checks the official npm registry daily for a stable `vault-go` release, stages an exact version under `~/.memvault/releases` without running package install scripts, validates it, and activates when idle. A failed start rolls back. Auth, preferences, and the queue are kept. Open MCP clients must reconnect to load new tools.
@@ -1 +1,3 @@
1
+ /** True when nothing remains once memory tags are removed: such text must not be stored at all. */
2
+ export declare function isFullyPrivate(value: string): boolean;
1
3
  export declare function redact(value: unknown): unknown;
@@ -1,4 +1,24 @@
1
1
  const SENSITIVE_KEY = /api[_-]?key|secret|token|password|authorization|cookie|credential|private[_-]?key/i;
2
+ const MEMORY_TAGS = `(private|vault-private|${["claude", "mem", "context"].join("-")}|system_instruction|system-instruction|persisted-output|system-reminder)`;
3
+ // Credentials pasted in free text have no key to match. `(?!\w)` instead of a
4
+ // closing `\b`: these classes include `-`, and `\b` after a final `-` needs a
5
+ // following word character, so such a key was left (or kept its last `-`).
6
+ const TOKEN_PATTERNS = [
7
+ /AKIA[0-9A-Z]{16}/g,
8
+ /\bgh[oprsu]_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b/g,
9
+ /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}(?!\w)/g,
10
+ /\bsk-ant-[A-Za-z0-9_-]{20,}(?!\w)/g,
11
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}(?!\w)/g,
12
+ /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?!\w)/g,
13
+ /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g,
14
+ /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g,
15
+ /\bAIza[0-9A-Za-z_-]{35}(?!\w)/g,
16
+ /\bvg_device_[a-f0-9]{64}\b/g,
17
+ ];
18
+ /** True when nothing remains once memory tags are removed: such text must not be stored at all. */
19
+ export function isFullyPrivate(value) {
20
+ return value.trim() !== '' && !value.replace(new RegExp(`<${MEMORY_TAGS}\\b[^>]*>[\\s\\S]*?(?:<\\/\\1\\s*>|$)`, "gi"), "").trim();
21
+ }
2
22
  /** Keep quoted values together: whitespace/escaped quotes must not leave a secret tail. */
3
23
  function redactText(value, depth = 0) {
4
24
  // Nested stringified payloads are untrusted. Do not recurse indefinitely.
@@ -13,8 +33,8 @@ function redactText(value, depth = 0) {
13
33
  }
14
34
  catch { /* Partial JSON and pasted logs still use the text filter below. */ }
15
35
  }
16
- return value
17
- .replace(new RegExp(`<(private|vault-private|${["claude", "mem", "context"].join("-")}|system_instruction|system-instruction|persisted-output|system-reminder)\\b[^>]*>[\\s\\S]*?(?:<\\/\\1\\s*>|$)`, "gi"), "[redacted]")
36
+ return TOKEN_PATTERNS.reduce((text, pattern) => text.replace(pattern, '[redacted]'), value)
37
+ .replace(new RegExp(`<${MEMORY_TAGS}\\b[^>]*>[\\s\\S]*?(?:<\\/\\1\\s*>|$)`, "gi"), "[redacted]")
18
38
  // Raw HTTP headers can contain spaces, auth schemes and several cookies.
19
39
  // Stop at a shell argument's quote or a new line, preserving adjacent diagnostics.
20
40
  .replace(/(\b(?:authorization|proxy-authorization|cookie|set-cookie)[ \t]*:[ \t]*)[^\r\n'\"]+/gi, '$1[redacted]')
package/dist/cli.js CHANGED
@@ -358,7 +358,7 @@ Uso:
358
358
  Captura opt-in de transcripts pelo worker local, com checkpoints e privacidade.
359
359
  Setup aceita --watch-transcripts claude|codex --workspace /projeto --consent.
360
360
 
361
- vault-go context-chain status|set|clear --workspace /projeto [--roots /a,/b] [--worktree-parent] [--consent]
361
+ vault-go context-chain status|set|clear --workspace /projeto [--roots /a,/b] [--worktree-parent] [--all-projects] [--consent]
362
362
  Contexto do SessionStart em projetos relacionados; orçamento total e conta preservados.
363
363
 
364
364
  vault-go hook <adaptador> <evento>
@@ -493,7 +493,7 @@ export async function runContextChain(args, home = vaultHome()) {
493
493
  const workspace = optionValue(args, '--workspace');
494
494
  if (!workspace)
495
495
  throw new Error('Informe --workspace /projeto.');
496
- result = configureContextChain(home, { workspace, roots: command === 'clear' ? [] : (optionValue(args, '--roots') ?? '').split(',').filter(Boolean), consent: args.includes('--consent'), includeWorktreeParent: command === 'set' && args.includes('--worktree-parent') });
496
+ result = configureContextChain(home, { workspace, roots: command === 'clear' ? [] : (optionValue(args, '--roots') ?? '').split(',').filter(Boolean), consent: args.includes('--consent'), includeWorktreeParent: command === 'set' && args.includes('--worktree-parent'), allProjects: command === 'set' && args.includes('--all-projects') });
497
497
  }
498
498
  else
499
499
  throw new Error('vault-go context-chain status|set|clear');
@@ -0,0 +1,2 @@
1
+ /** Whether a prompt was generated by the client itself; the same text from another client is a person's prompt. */
2
+ export declare function isClientInternalPrompt(adapter: string, text: string): boolean;
@@ -0,0 +1,34 @@
1
+ // Clients send their own helper threads through the same prompt hook as a
2
+ // person's turn. Storing them creates sessions and spends generation on the
3
+ // client's internals, so they are recognised by their fixed opening lines,
4
+ // never by phrases a person would type.
5
+ const CODEX_INTERNAL_PROMPT_OPENINGS = [
6
+ // Codex app: names a task.
7
+ /^You are a helpful assistant\. You will be presented with a user prompt, and your job is to provide a short title/,
8
+ // Codex memories: the consolidation pass.
9
+ /^## Memory Writing Agent: Phase 2 \(Consolidation\)/,
10
+ // Codex app: onboarding suggestions.
11
+ /^# Overview\r?\n(?:\r?\n)?Generate 0 to 3 hyperpersonalized suggestions/,
12
+ // Codex app: suggests the next message for the empty composer.
13
+ /^This is an app-generated request for a suggested next user message\. The user did not write this message\./,
14
+ // Codex memories: folds rollout summaries into memory_summary.md.
15
+ /^Consolidate the supplied rollout summaries into `memory_summary\.md`/,
16
+ ];
17
+ const GROK_INTERNAL_PROMPT_OPENINGS = [
18
+ // Research sub-agent briefings.
19
+ /^Independently verify every candidate claim in the JSON packet below/,
20
+ /^Rewrite the verified research findings/,
21
+ /^Investigate the JSON-encoded question/,
22
+ /^Break the JSON-encoded research query/,
23
+ ];
24
+ // A turn made only of a protocol envelope (e.g. a background task notice) carries no request from a person.
25
+ const PROTOCOL_ONLY = /^\s*<(task-notification)\b[^>]*>(?:(?!<\1\b|<\/\1\b)[\s\S])*<\/\1\s*>\s*$/;
26
+ const MAX_PROTOCOL_PAYLOAD_CHARS = 256 * 1024;
27
+ /** Whether a prompt was generated by the client itself; the same text from another client is a person's prompt. */
28
+ export function isClientInternalPrompt(adapter, text) {
29
+ if (text.length <= MAX_PROTOCOL_PAYLOAD_CHARS && PROTOCOL_ONLY.test(text))
30
+ return true;
31
+ const opening = text.trimStart();
32
+ const openings = adapter === 'codex' ? CODEX_INTERNAL_PROMPT_OPENINGS : adapter === 'grok' ? GROK_INTERNAL_PROMPT_OPENINGS : [];
33
+ return openings.some(pattern => pattern.test(opening));
34
+ }
@@ -11,6 +11,7 @@ interface Link {
11
11
  workspace: string;
12
12
  roots: string[];
13
13
  includeWorktreeParent: boolean;
14
+ allProjects?: boolean;
14
15
  }
15
16
  export declare function contextChainStatus(home: string): {
16
17
  chains: Link[];
@@ -22,6 +23,7 @@ export declare function configureContextChain(home: string, input: {
22
23
  roots: string[];
23
24
  consent?: boolean;
24
25
  includeWorktreeParent?: boolean;
26
+ allProjects?: boolean;
25
27
  }): {
26
28
  chains: Link[];
27
29
  maxProjects: number;
@@ -16,7 +16,7 @@ function configuration(home) {
16
16
  }
17
17
  export function contextChainStatus(home) { return { chains: configuration(home).chains, maxProjects: 5, default: 'workspace-only' }; }
18
18
  export function configureContextChain(home, input) {
19
- if ((input.roots.length || input.includeWorktreeParent) && input.consent !== true)
19
+ if ((input.roots.length || input.includeWorktreeParent || input.allProjects) && input.consent !== true)
20
20
  throw new Error('Relacionar projetos exige consent=true.');
21
21
  if (!captureOwner(home))
22
22
  throw new Error('Autentique a conta antes de relacionar projetos.');
@@ -27,8 +27,9 @@ export function configureContextChain(home, input) {
27
27
  throw new Error('Diretório de projeto inexistente.');
28
28
  const config = configuration(home);
29
29
  config.chains = config.chains.filter(c => c.workspace !== workspace);
30
- if (roots.length || input.includeWorktreeParent)
31
- config.chains.push({ workspace, roots, includeWorktreeParent: input.includeWorktreeParent === true });
30
+ // allProjects is the account-wide opt-in: every non-private project of this account joins the context budget.
31
+ if (roots.length || input.includeWorktreeParent || input.allProjects)
32
+ config.chains.push({ workspace, roots, includeWorktreeParent: input.includeWorktreeParent === true, ...(input.allProjects === true ? { allProjects: true } : {}) });
32
33
  if (config.chains.length > 50)
33
34
  throw new Error('Limite de 50 cadeias de contexto.');
34
35
  privateJson(home, 'context-chains.json', config);
@@ -74,7 +75,14 @@ export async function loadProjectContext(home, cloud, input) {
74
75
  if (project && project.private !== true && project.isPrivate !== true && project.is_private !== true && !selected.some(p => p.id === project.id))
75
76
  selected.push(project);
76
77
  }
77
- // A worktree shares its canonical project; extra projects still require explicit links.
78
+ if (chain?.allProjects === true) {
79
+ for (const item of input.projects) {
80
+ const project = item && typeof item === 'object' ? item : undefined;
81
+ if (project && typeof project.id === 'string' && project.private !== true && project.isPrivate !== true && project.is_private !== true && !selected.some(p => p.id === project.id))
82
+ selected.push(project);
83
+ }
84
+ }
85
+ // A worktree shares its canonical project; extra projects still require explicit links or the allProjects opt-in.
78
86
  if (selected[0]?.id !== input.primary.id)
79
87
  return '';
80
88
  const projects = selected.slice(0, Math.min(5, input.limit)), parts = [];
@@ -9,6 +9,11 @@ export interface EngineModelOption {
9
9
  aliases?: readonly string[];
10
10
  }
11
11
  export declare const ENGINE_MODELS: Record<ContextEngineId, readonly EngineModelOption[]>;
12
+ export declare class ContextQuotaError extends Error {
13
+ readonly engine: ContextEngineId;
14
+ constructor(engine: ContextEngineId);
15
+ }
16
+ export declare function isQuotaFailure(error: unknown): boolean;
12
17
  export interface ContextResult {
13
18
  title: string;
14
19
  content: string;
@@ -78,3 +83,4 @@ export interface GenerateContextOptions {
78
83
  }) => Promise<unknown>;
79
84
  }
80
85
  export declare function generateContext(home: string, engine: unknown, text: unknown, options?: GenerateContextOptions): Promise<ContextResult>;
86
+ export declare function generateContextWithFallback(home: string, engine: unknown, text: unknown, options?: GenerateContextOptions): Promise<ContextResult>;
@@ -54,6 +54,23 @@ const KEY_ENV = {
54
54
  openrouter: ["VAULT_GO_OPENROUTER_API_KEY", "OPENROUTER_API_KEY"],
55
55
  gemini: ["VAULT_GO_GEMINI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"],
56
56
  };
57
+ // Provider notices that mean the allowance ran out rather than a bad request.
58
+ // A spent Anthropic API key is reported as a 400 "credit balance is too low".
59
+ const QUOTA_NOTICE = /credit balance is too low|usage limit|rate[ _]limit|limit reached|quota|too many requests/i;
60
+ export class ContextQuotaError extends Error {
61
+ engine;
62
+ constructor(engine) {
63
+ super("Context engine quota exhausted.");
64
+ this.engine = engine;
65
+ this.name = "ContextQuotaError";
66
+ }
67
+ }
68
+ export function isQuotaFailure(error) {
69
+ if (error instanceof ContextQuotaError)
70
+ return true;
71
+ const status = error && typeof error === "object" ? error.status : undefined;
72
+ return status === 429 || status === 402 || (error instanceof Error && QUOTA_NOTICE.test(error.message));
73
+ }
57
74
  export function isContextEngine(value) {
58
75
  return (typeof value === "string" &&
59
76
  CONTEXT_ENGINE_IDS.includes(value));
@@ -267,7 +284,9 @@ export const runContextCommand = (request) => new Promise((resolve, reject) => {
267
284
  child.on("close", (code) => finish(failure ??
268
285
  (code === 0
269
286
  ? undefined
270
- : new Error("Context CLI failed. Check its login and subscription limits."))));
287
+ : new Error(QUOTA_NOTICE.test(stdout + stderr)
288
+ ? "Context CLI reported an exhausted quota."
289
+ : "Context CLI failed. Check its login and subscription limits."))));
271
290
  child.stdin.end(request.input ?? "");
272
291
  if (request.signal?.aborted)
273
292
  abort();
@@ -539,6 +558,8 @@ async function generateApiContext(home, engine, text, options, signal) {
539
558
  ? error
540
559
  : new Error("Context engine request failed.");
541
560
  }
561
+ if (response.status === 429 || response.status === 402)
562
+ throw new ContextQuotaError(engine);
542
563
  if (!response.ok) {
543
564
  throw new Error(engine === "openrouter"
544
565
  ? "OpenRouter request failed. Check the API key and usage limits."
@@ -582,7 +603,7 @@ export async function generateContext(home, engine, text, options = {}) {
582
603
  signal.addEventListener("abort", abort, { once: true });
583
604
  void Promise.resolve()
584
605
  .then(() => options.managedGenerate(text, { signal, ...(modeGuidance ? { modeGuidance } : {}) }))
585
- .then(resolve, reject)
606
+ .then(resolve, (error) => reject(isQuotaFailure(error) ? new ContextQuotaError(engine) : error))
586
607
  .finally(() => signal.removeEventListener("abort", abort));
587
608
  });
588
609
  return validateContextResult(value, engine);
@@ -658,10 +679,11 @@ export async function generateContext(home, engine, text, options = {}) {
658
679
  timeoutMs: 90000,
659
680
  signal,
660
681
  });
661
- let parsed;
682
+ let parsed, quota = false;
662
683
  try {
663
684
  const output = JSON.parse(result.stdout);
664
685
  if (engine === "claude-subscription") {
686
+ quota = output["is_error"] === true && QUOTA_NOTICE.test(String(output["result"]));
665
687
  if (output["is_error"] === true)
666
688
  throw new Error();
667
689
  parsed =
@@ -671,6 +693,8 @@ export async function generateContext(home, engine, text, options = {}) {
671
693
  parsed = output;
672
694
  }
673
695
  catch {
696
+ if (quota)
697
+ throw new ContextQuotaError(engine);
674
698
  throw new Error("Context engine returned an invalid response.");
675
699
  }
676
700
  return validateContextResult(parsed, engine);
@@ -679,3 +703,31 @@ export async function generateContext(home, engine, text, options = {}) {
679
703
  rmSync(cwd, { recursive: true, force: true });
680
704
  }
681
705
  }
706
+ // The two Anthropic allowances stand in for each other: the managed Vault AI
707
+ // Resume (an API key held by the server) and this machine's Claude subscription
708
+ // login. Only an exhausted quota hands over; any other failure surfaces as is.
709
+ const QUOTA_FALLBACK = {
710
+ "vault-ai-resume": "claude-subscription",
711
+ "claude-subscription": "vault-ai-resume",
712
+ };
713
+ export async function generateContextWithFallback(home, engine, text, options = {}) {
714
+ try {
715
+ return await generateContext(home, engine, text, options);
716
+ }
717
+ catch (error) {
718
+ const fallback = isContextEngine(engine) ? QUOTA_FALLBACK[engine] : undefined;
719
+ if (!fallback ||
720
+ !isQuotaFailure(error) ||
721
+ options.signal?.aborted ||
722
+ (options.env ?? process.env).VAULT_GO_ENGINE_FALLBACK === "off" ||
723
+ (fallback === "vault-ai-resume" && !options.managedGenerate))
724
+ throw error;
725
+ try {
726
+ return await generateContext(home, fallback, text, options);
727
+ }
728
+ catch {
729
+ // The stand-in being unavailable must not hide why the selected engine stopped.
730
+ throw error;
731
+ }
732
+ }
733
+ }
@@ -9,7 +9,8 @@ function readJsonObject(path) {
9
9
  return {};
10
10
  let parsed;
11
11
  try {
12
- parsed = JSON.parse(readFileSync(path, "utf8"));
12
+ // Windows editors may save a UTF-8 byte order mark, which JSON.parse rejects.
13
+ parsed = JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, ""));
13
14
  }
14
15
  catch (error) {
15
16
  throw new Error(`JSON inválido em ${path}; o arquivo não foi alterado.`, {
@@ -98,7 +99,9 @@ function agyHandler(event, configHome = vaultHome()) {
98
99
  }
99
100
  function agyHookManifest(configHome = vaultHome()) {
100
101
  return {
101
- PreInvocation: [agyHandler("context", configHome)],
102
+ // SessionStart injects memory once per session. On PreInvocation it was re-sent
103
+ // before every model call, invalidating the provider prompt cache each step.
104
+ SessionStart: [agyHandler("context", configHome)],
102
105
  PreToolUse: [{ matcher: "*", hooks: [agyHandler("file-context", configHome)] }],
103
106
  PostToolUse: [
104
107
  { matcher: "*", hooks: [agyHandler("observation", configHome)] },
@@ -1,5 +1,5 @@
1
1
  import { VaultCloudClient } from './cloud.js';
2
- import { generateContext, type ContextResult } from './context-engines.js';
2
+ import { generateContextWithFallback, type ContextResult } from './context-engines.js';
3
3
  import { type TelegramObservation } from './telegram.js';
4
4
  import { privateStore } from './private-store.js';
5
5
  import { type CrystalMirrorRecord } from './crystal-memory-mirror.js';
@@ -43,7 +43,7 @@ export declare function generationQueueStatus(home?: string): {
43
43
  legacyAwaitingOwnership: number;
44
44
  heldForOtherAccount: number;
45
45
  };
46
- export declare function processGenerationQueue(home?: string, cloud?: VaultCloudClient, generate?: typeof generateContext, options?: {
46
+ export declare function processGenerationQueue(home?: string, cloud?: VaultCloudClient, generate?: typeof generateContextWithFallback, options?: {
47
47
  notifyObservation?: (observation: TelegramObservation) => Promise<unknown>;
48
48
  crystalMirrorStore?: CrystalMirrorStoreFactory;
49
49
  }): Promise<boolean>;
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { join } from 'node:path';
4
4
  import { VaultCloudClient } from './cloud.js';
5
5
  import { loadConfig, loadTokens, vaultHome } from './config.js';
6
- import { generateContext, selectedContextEngine } from './context-engines.js';
6
+ import { generateContextWithFallback, isQuotaFailure, selectedContextEngine } from './context-engines.js';
7
7
  import { notifyTelegramObservation } from './telegram.js';
8
8
  import { pushAwareness } from './grok-awareness.js';
9
9
  import { privateStore } from './private-store.js';
@@ -88,6 +88,8 @@ function isUnusableGenerationText(text) {
88
88
  }
89
89
  function generationFailureCode(error) {
90
90
  const message = error instanceof Error ? error.message : '';
91
+ if (isQuotaFailure(error))
92
+ return 'engine_quota';
91
93
  if (message.includes('Official CLI or subscription login unavailable'))
92
94
  return 'engine_cli_unavailable';
93
95
  if (message.includes('Context engine timed out'))
@@ -238,7 +240,7 @@ export function generationQueueStatus(home = vaultHome()) {
238
240
  const jobs = readQueue(home), owner = ownerKey(home);
239
241
  return { pending: jobs.filter(j => j.owner === owner || !j.owner).length, failed: jobs.filter(j => j.owner === owner && j.lastError).length, legacyAwaitingOwnership: jobs.filter(j => !j.owner).length, heldForOtherAccount: jobs.filter(j => j.owner && j.owner !== owner).length };
240
242
  }
241
- export async function processGenerationQueue(home = vaultHome(), cloud = new VaultCloudClient(home), generate = generateContext, options = {}) {
243
+ export async function processGenerationQueue(home = vaultHome(), cloud = new VaultCloudClient(home), generate = generateContextWithFallback, options = {}) {
242
244
  const release = await lock(home, 'generation-worker', false);
243
245
  if (!release)
244
246
  return false;
package/dist/hooks.js CHANGED
@@ -1,4 +1,6 @@
1
- import { redact } from './capture-privacy.js';
1
+ import { isFullyPrivate, redact } from './capture-privacy.js';
2
+ import { isClientInternalPrompt } from './client-prompts.js';
3
+ import { newestMemoryEpoch, readGateCandidate, readGateReason } from './read-gate.js';
2
4
  export { redact } from './capture-privacy.js';
3
5
  import { createHash, randomUUID } from "node:crypto";
4
6
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
@@ -214,6 +216,9 @@ export async function executeHook(adapter, event, value, cloud = new VaultCloudC
214
216
  const sessionId = string(input.session_id);
215
217
  if (!reading && !sessionId)
216
218
  return hookOutput(event, "", adapter);
219
+ // Client helper threads never become sessions, projects or memories.
220
+ if (event === "session-init" && isClientInternalPrompt(adapter, string(input.prompt)))
221
+ return hookOutput(event, "", adapter);
217
222
  const workspace = root(string(input.cwd, input.workspace) || process.cwd());
218
223
  const roots = projectRoots(workspace);
219
224
  const projects = await cloud.projects();
@@ -254,6 +259,11 @@ export async function executeHook(adapter, event, value, cloud = new VaultCloudC
254
259
  })
255
260
  : { context: await loadProjectContext(home, cloud, { workspace, primary: { id: project.id }, projects, ...limits }) });
256
261
  let context = string(result.context).slice(0, limits.maxChars);
262
+ if (event === "file-context" && context) {
263
+ const denial = await fullReadDenial(cloud, { adapter, toolName, tool, agentId: string(input.agent_id, input.agentId), workspace, prefs, memoryIds: result.memoryIds ?? result.memory_ids, context, file });
264
+ if (denial)
265
+ return denial;
266
+ }
257
267
  if (event === "context") {
258
268
  // Skills do projeto entram depois da memória e só no espaço que sobrar; falha aqui nunca derruba o contexto.
259
269
  try {
@@ -276,8 +286,10 @@ export async function executeHook(adapter, event, value, cloud = new VaultCloudC
276
286
  }));
277
287
  if (typeof session.id !== "string")
278
288
  throw new Error("Sessão inválida");
279
- const prompt = string(input.prompt);
280
- const response = string(input.last_assistant_message);
289
+ // Text that is entirely <private> (or other memory tags) is dropped, never stored as a "[redacted]" placeholder.
290
+ const visible = (value) => isFullyPrivate(value) ? "" : value;
291
+ const prompt = visible(string(input.prompt));
292
+ const response = visible(string(input.last_assistant_message));
281
293
  const toolPayload = record(redact({
282
294
  toolName,
283
295
  toolInput: tool,
@@ -351,6 +363,31 @@ export async function executeHook(adapter, event, value, cloud = new VaultCloudC
351
363
  await cloud.endSession(session.id);
352
364
  return hookOutput(event, "", adapter);
353
365
  }
366
+ // Fails open: any missing evidence or lookup error lets the Read through.
367
+ async function fullReadDenial(cloud, request) {
368
+ const enabled = request.prefs.fileReadGate !== false && process.env.VAULT_GO_FILE_READ_GATE !== "off";
369
+ const candidate = readGateCandidate({ adapter: request.adapter, toolName: request.toolName, toolInput: request.tool, agentId: request.agentId, workspace: request.workspace, enabled });
370
+ const ids = Array.isArray(request.memoryIds) ? request.memoryIds.filter((id) => typeof id === "string").slice(0, 20) : [];
371
+ if (!candidate || !ids.length)
372
+ return undefined;
373
+ try {
374
+ // Memories older than the file's last change describe code that may be gone: read it.
375
+ if (newestMemoryEpoch(await cloud.observations(ids)) <= candidate.mtimeMs)
376
+ return undefined;
377
+ }
378
+ catch {
379
+ return undefined;
380
+ }
381
+ return {
382
+ continue: true,
383
+ suppressOutput: true,
384
+ hookSpecificOutput: {
385
+ hookEventName: "PreToolUse",
386
+ permissionDecision: "deny",
387
+ permissionDecisionReason: readGateReason(request.file, ids, request.context),
388
+ },
389
+ };
390
+ }
354
391
  function boundedPreference(value, fallback, min, max) {
355
392
  return typeof value === 'number' && Number.isInteger(value) ? Math.max(min, Math.min(max, value)) : fallback;
356
393
  }
@@ -1,6 +1,6 @@
1
1
  export declare const MCP_SERVER_NAME = "vault-go";
2
2
  export declare const MCP_SERVER_COMMAND: readonly ["bunx", "--bun", "vault-go@latest", "serve"];
3
- export declare const MCP_CLIENTS: readonly ["codex", "claude", "claude-desktop", "cursor", "vscode", "copilot", "windsurf", "roo", "opencode", "grok", "agy", "gemini", "vault-desktop"];
3
+ export declare const MCP_CLIENTS: readonly ["codex", "claude", "claude-desktop", "cursor", "vscode", "copilot", "windsurf", "roo", "opencode", "grok", "agy", "gemini", "warp", "goose", "hermes", "vault-desktop"];
4
4
  export type McpClient = (typeof MCP_CLIENTS)[number];
5
5
  export interface DetectedClient {
6
6
  client: McpClient;
package/dist/installer.js CHANGED
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { vaultHome } from './config.js';
6
6
  import { stageLocalRuntime } from './local-install.js';
7
7
  import { installCaptureHooks, installClientHooks, installOpenCodePlugin } from './hook-install.js';
8
+ import { upsertYamlEntry } from './yaml-entry.js';
8
9
  import { installManagedSkills } from './skill-install.js';
9
10
  export const MCP_SERVER_NAME = 'vault-go';
10
11
  export const MCP_SERVER_COMMAND = ['bunx', '--bun', 'vault-go@latest', 'serve'];
@@ -21,6 +22,9 @@ export const MCP_CLIENTS = [
21
22
  'grok',
22
23
  'agy',
23
24
  'gemini',
25
+ 'warp',
26
+ 'goose',
27
+ 'hermes',
24
28
  'vault-desktop',
25
29
  ];
26
30
  const CLIENT_ALIASES = {
@@ -34,7 +38,8 @@ function readJsonObject(path) {
34
38
  return {};
35
39
  let parsed;
36
40
  try {
37
- parsed = JSON.parse(readFileSync(path, 'utf8'));
41
+ // Windows editors may save a UTF-8 byte order mark, which JSON.parse rejects.
42
+ parsed = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
38
43
  }
39
44
  catch (error) {
40
45
  throw new Error(`JSON inválido em ${path}; o arquivo não foi alterado.`, { cause: error });
@@ -62,7 +67,8 @@ function formatTomlValue(value) {
62
67
  return `[${value.map((item) => JSON.stringify(item)).join(', ')}]`;
63
68
  }
64
69
  function upsertTomlTable(path, table, assignments) {
65
- const previous = existsSync(path) ? readFileSync(path, 'utf8') : '';
70
+ // A byte order mark would hide a table on the first line and duplicate it on append.
71
+ const previous = existsSync(path) ? readFileSync(path, 'utf8').replace(/^\uFEFF/, '') : '';
66
72
  const body = Object.entries(assignments)
67
73
  .map(([key, value]) => `${key} = ${formatTomlValue(value)}`)
68
74
  .join('\n');
@@ -76,6 +82,21 @@ function upsertTomlTable(path, table, assignments) {
76
82
  : block;
77
83
  atomicWriteText(path, next);
78
84
  }
85
+ function upsertYamlFile(path, topKey, fields) {
86
+ const previous = existsSync(path) ? readFileSync(path, 'utf8') : '';
87
+ let next;
88
+ try {
89
+ next = upsertYamlEntry(previous, topKey, MCP_SERVER_NAME, fields);
90
+ }
91
+ catch (error) {
92
+ throw new Error(`${error instanceof Error ? error.message : 'YAML inválido'} (${path})`, { cause: error });
93
+ }
94
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
95
+ const temporary = join(dirname(path), `.${process.pid}.${Date.now()}.tmp`);
96
+ writeFileSync(temporary, next, { mode: 0o600 });
97
+ renameSync(temporary, path);
98
+ chmodSync(path, 0o600);
99
+ }
79
100
  function standardServer() {
80
101
  return {
81
102
  command: MCP_SERVER_COMMAND[0],
@@ -219,6 +240,21 @@ export function installMcpClient(client, options = {}) {
219
240
  installClientHooks('gemini', home);
220
241
  return { client, status: 'installed', destination: path };
221
242
  }
243
+ if (client === 'goose') {
244
+ // Goose starts MCP servers only from the top-level `extensions` mapping, never `mcpServers`.
245
+ const destination = join(home, '.config', 'goose', 'config.yaml');
246
+ upsertYamlFile(destination, 'extensions', {
247
+ enabled: true, type: 'stdio', name: MCP_SERVER_NAME,
248
+ cmd: MCP_SERVER_COMMAND[0], args: MCP_SERVER_COMMAND.slice(1), envs: {}, timeout: 300,
249
+ });
250
+ return { client, status: 'installed', destination };
251
+ }
252
+ if (client === 'hermes') {
253
+ const destination = join(home, '.hermes', 'config.yaml');
254
+ // MCP only: Hermes loads tools from `mcp_servers`; its capture uses a provider plugin vault-go does not ship.
255
+ upsertYamlFile(destination, 'mcp_servers', { command: MCP_SERVER_COMMAND[0], args: MCP_SERVER_COMMAND.slice(1) });
256
+ return { client, status: 'installed', destination };
257
+ }
222
258
  if (client === 'vault-desktop') {
223
259
  const destination = vaultDesktopMcpPath(home);
224
260
  mergeServer(destination);
@@ -236,6 +272,9 @@ export function installMcpClient(client, options = {}) {
236
272
  path = join(home, '.codeium', 'windsurf', 'mcp_config.json');
237
273
  else if (client === 'roo')
238
274
  path = resolve(cwd, '.roo', 'mcp.json');
275
+ // Warp reads global MCP servers from the dotfile ~/.warp/.mcp.json, not ~/.warp/mcp.json.
276
+ else if (client === 'warp')
277
+ path = join(home, '.warp', '.mcp.json');
239
278
  else
240
279
  path = join(home, '.config', 'opencode', 'opencode.json');
241
280
  if (client === 'opencode') {
@@ -284,6 +323,9 @@ export function detectMcpClients(home = homedir()) {
284
323
  ['grok', ['grok'], [join(home, '.grok')]],
285
324
  ['agy', ['agy'], [join(home, '.gemini', 'antigravity-cli'), join(home, '.gemini', 'config'), join(home, '.gemini', 'antigravity')]],
286
325
  ['gemini', ['gemini'], [join(home, '.gemini', 'settings.json')]],
326
+ ['warp', [], [join(home, '.warp'), '/Applications/Warp.app']],
327
+ ['goose', ['goose'], [join(home, '.config', 'goose')]],
328
+ ['hermes', ['hermes'], [join(home, '.hermes')]],
287
329
  ['vault-desktop', [], [vaultDesktopMcpPath(home), vaultDesktopDotMcpPath(home), '/Applications/Vault Desktop.app']],
288
330
  ];
289
331
  return definitions.map(([client, commands, paths]) => {