SLOPSHOPPER

jev-vault-gate

Uses Jev to spot durable knowledge in a turn and auto-capture it into ~/vault/raw/ for /compile.

newnetwork
v0.3.0MITupdated 2026-09-20sohryuu101/jev-vault-gate
A shopper browsing a rack in a slop shop
README

jev-vault-gate

Claude Code plugin that watches every main-loop turn and, when it looks like it holds durable knowledge, auto-captures it into ~/vault/raw/ for the /compile skill to pick up later.

What Jev actually does here

Jev only returns numeric answers (a probability, or a pick from a fixed list) — it cannot draft prose. So it does two things, not three:

  1. Gate: a noul question — "does this turn hold durable knowledge worth saving?" — scored 0–1 against threshold (default 0.6).
  2. Route (if the vault's INDEX.md exists): a choice question picking which existing vault page this content most belongs under, or new_topic.

What it does not do: write the vault page itself. The raw capture is the user/assistant text verbatim (bounded to ~4000 chars each), not a summary — drafting the actual page still happens when you run /compile, which uses a full model, not Jev.

Behavior

  • On turn.complete (main loop only, not subagents): if the answer is long enough (minAnswerChars, default 200) and the gate passes, writes ~/vault/raw/<date>-<slug>.md with the turn's user/assistant text and a capture comment (score, topic guess).
  • On the next prompt, if anything was captured since the last prompt, injects a one-line reminder to run /compile.
  • Failures (missing key, Jev error, missing vault) are logged and swallowed — never blocks or alters the actual turn.

Manual invocation

/vault-check runs the same gate + topic-route logic by hand, against a chosen exchange, without waiting for a real turn — useful for testing or for content the automatic hook already passed by. It's a separate plain-JS script (hooks/vault-check-cli.mjs), since a slash command runs outside the plugin engine and can only shell out; keep it in sync with vault-gate.ts if the gate logic changes.

/audit-sessions runs the same gate logic across every archived Claude Code session transcript (~/.claude/projects/*/*.jsonl), not just live turns. Per session, isolated per-request, Jev scores two things: vault_worthy (same as the live gate) and safe_to_delete (nothing unique or still-needed lives only in this transcript). Vault-worthy sessions are auto-captured into vault/raw/ the same way. Nothing is ever deleted automatically — delete candidates are only reported; deleting them is a separate, explicit, user-confirmed step the command walks through.

Install

export TYPESAFE_API_KEY=...
claude plugin marketplace add .
claude plugin install jev-vault-gate

Configure vaultPath if it's not ~/vault, via /plugin configure jev-vault-gate.

Note

Every turn long enough to check gets sent to the external Jev API (user text

  • assistant answer, truncated). Same trust boundary as fast-jev-compaction

and jev-harness-audit — don't install this if you don't want turn content leaving the machine for that classification.

Source 1 files
hooks/vault-gate.ts 244 lines
1import type { On, PluginOptions, Register, SessionMessage, TurnCompleteInput } from 'claude-code';
2
3const DEFAULTS = {
4  vaultPath: '~/vault',
5  threshold: 0.6,
6  minAnswerChars: 200,
7  model: 'jev-latest',
8};
9
10const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone';
11const MAX_TEXT_CHARS = 4000;
12const MAX_TOPICS = 20;
13
14type NoulAnswer = { noul: number };
15type ChoiceAnswer = { choice: string; confidence: number };
16type JevAnswer = NoulAnswer | ChoiceAnswer;
17type JevQuestions = Record<string, { type: 'noul' | 'choice'; instructions: string; criteria?: Record<string, string | null> }>;
18
19interface Config {
20  apiKey?: string;
21  vaultPath: string;
22  threshold: number;
23  minAnswerChars: number;
24  model: string;
25}
26
27function optionNumber(options: PluginOptions, key: string, fallback: number): number {
28  const value = options[key];
29  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
30}
31
32function optionString(options: PluginOptions, key: string, fallback: string): string {
33  const value = options[key];
34  return typeof value === 'string' && value.length > 0 ? value : fallback;
35}
36
37function resolveConfig(options: PluginOptions): Config {
38  const apiKey = optionString(options, 'apiKey', '');
39  return {
40    apiKey: apiKey || undefined,
41    vaultPath: optionString(options, 'vaultPath', DEFAULTS.vaultPath),
42    threshold: optionNumber(options, 'threshold', DEFAULTS.threshold),
43    minAnswerChars: optionNumber(options, 'minAnswerChars', DEFAULTS.minAnswerChars),
44    model: optionString(options, 'model', DEFAULTS.model),
45  };
46}
47
48function expandHome(path: string, home: string): string {
49  return path === '~' || path.startsWith('~/') ? home + path.slice(1) : path;
50}
51
52function truncate(text: string, max: number): string {
53  return text.length <= max ? text : `${text.slice(0, max)}\n[...truncated...]`;
54}
55
56/**
57 * Deterministic, local redaction applied ONLY to what leaves the machine for
58 * Jev — the gate/route questions never need the real value, just that a
59 * secret-shaped thing is there. The unredacted text still goes into
60 * vault/raw/ untouched; that's the whole point of the vault.
61 */
62function scrubSecrets(text: string): string {
63  return text
64    // key=value / key: value credential assignments (api key, token, password, secret, community string, bearer)
65    .replace(
66      /\b((?:api[_-]?key|access[_-]?key|secret[_-]?key|client[_-]?secret|auth[_-]?token|bearer|password|passwd|pwd|community(?:[_-]?string)?)\s*[:=]\s*)(\S+)/gi,
67      '$1[REDACTED]',
68    )
69    // PEM-style private key / certificate blocks
70    .replace(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, '[REDACTED PEM BLOCK]')
71    // AWS-style access key ids
72    .replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED_AWS_KEY]')
73    // long bearer/JWT-looking tokens standing alone
74    .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED_JWT]')
75    // MAC addresses
76    .replace(/\b([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}\b/g, '[MAC]')
77    // IPv4 addresses (network inventory is the main thing this vault handles)
78    .replace(/\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, '[IP]');
79}
80
81/** Pulls `[[page-slug]]` wikilinks out of INDEX.md as candidate topic buckets. */
82function extractTopics(indexMd: string): string[] {
83  const found = new Set<string>();
84  for (const match of indexMd.matchAll(/\[\[([a-z0-9-]+)\]\]/g)) {
85    found.add(match[1]!);
86    if (found.size >= MAX_TOPICS) break;
87  }
88  return [...found];
89}
90
91function lastUserText(messages: readonly SessionMessage[]): string {
92  for (let i = messages.length - 1; i >= 0; i--) {
93    const message = messages[i]!;
94    if (message.role === 'user' && message.text.trim().length > 0) return message.text;
95  }
96  return '';
97}
98
99async function askJev(
100  fetchFn: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number; ok: boolean; text: string }>,
101  config: Config,
102  state: unknown,
103  questions: JevQuestions,
104): Promise<Record<string, JevAnswer>> {
105  const response = await fetchFn(SYSTEM_ONE_URL, {
106    method: 'POST',
107    headers: { authorization: `Bearer ${config.apiKey}`, 'content-type': 'application/json' },
108    body: JSON.stringify({ model: config.model, state, questions }),
109  });
110  if (!response.ok) throw new Error(`Jev request failed (${response.status}): ${response.text.slice(0, 200)}`);
111  const parsed = JSON.parse(response.text) as { answers?: Record<string, JevAnswer> };
112  if (!parsed.answers) throw new Error('Jev response is missing answers');
113  return parsed.answers;
114}
115
116function slugifyTitle(text: string, maxWords: number): string {
117  return text
118    .split(/\s+/)
119    .slice(0, maxWords)
120    .join(' ')
121    .toLowerCase()
122    .replace(/[^a-z0-9]+/g, '-')
123    .replace(/^-+|-+$/g, '')
124    .slice(0, 60);
125}
126
127export const register: Register = (on: On, options: PluginOptions) => {
128  const configured = resolveConfig(options);
129
130  on('turn.complete', async ($, event: TurnCompleteInput, next) => {
131    if (event.agentId) return next(event); // subagent turns aren't the user's own work log
132    if (event.reason !== 'answer') return next(event);
133    if (event.answer.length < configured.minAnswerChars) return next(event);
134
135    try {
136      const apiKey = configured.apiKey ?? (await $.env.get('TYPESAFE_API_KEY'));
137      if (!apiKey) {
138        $.ui.log('jev-vault-gate: TYPESAFE_API_KEY not configured, skipping capture check');
139        return next(event);
140      }
141      const config = { ...configured, apiKey };
142      const home = (await $.env.get('HOME')) ?? '';
143      const vaultPath = expandHome(config.vaultPath, home);
144
145      let topics: string[] = [];
146      try {
147        const indexMd = await $.fs.read(`${vaultPath}/INDEX.md`);
148        topics = extractTopics(indexMd);
149      } catch {
150        // no INDEX.md yet, or vault path not set up; capture still works without topic routing
151      }
152
153      const messages = await $.session.messages();
154      const userText = truncate(lastUserText(messages), MAX_TEXT_CHARS);
155      const assistantText = truncate(event.answer, MAX_TEXT_CHARS);
156
157      const state = {
158        context:
159          'One turn of a technical work session: a user message and the assistant\'s reply, with ' +
160          'credential- and address-shaped values already redacted before reaching you — judge the ' +
161          'structure and topic, not the exact values. Judge whether it holds durable knowledge ' +
162          'worth keeping in a personal engineering vault (facts: inventory, configs, architecture; ' +
163          'lessons: methods, root causes, gotchas, fixes) that would still be useful weeks or ' +
164          'months later. Most turns are ordinary back-and-forth, task chatter, or acknowledgements ' +
165          'and do not qualify.',
166        user: scrubSecrets(userText),
167        assistant: scrubSecrets(assistantText),
168      };
169
170      const questions: JevQuestions = {
171        worthy: {
172          type: 'noul',
173          instructions:
174            'This turn holds durable knowledge worth saving to the vault, per the context above. ' +
175            'Most turns do not; only answer high when the content itself shows a fact or lesson, ' +
176            'not because the turn is long or technical-sounding.',
177        },
178      };
179      if (topics.length > 0) {
180        const criteria: Record<string, string | null> = { new_topic: 'None of the existing pages fit this content.' };
181        for (const topic of topics) criteria[topic] = null;
182        questions['topic'] = {
183          type: 'choice',
184          instructions: 'Which existing vault page this turn\'s content most belongs under, or new_topic.',
185          criteria,
186        };
187      }
188
189      const answers = await askJev(
190        async (url, init) => {
191          const response = await $.http.fetch(url, init);
192          return { status: response.status, ok: response.ok, text: response.text };
193        },
194        config,
195        state,
196        questions,
197      );
198
199      const worthy = answers['worthy'] as NoulAnswer | undefined;
200      const score = worthy?.noul ?? 0;
201      if (!Number.isFinite(score) || score < config.threshold) return next(event);
202
203      const topicAnswer = answers['topic'] as ChoiceAnswer | undefined;
204      const date = new Date().toISOString().slice(0, 10);
205      const slug = slugifyTitle(userText || assistantText, 8) || event.turnId.slice(0, 8);
206      const path = `${vaultPath}/raw/${date}-${slug}.md`;
207      if (await $.fs.exists(path)) return next(event); // already captured (hot reload re-run, etc.)
208
209      const header = [
210        `<!-- captured by jev-vault-gate: turn ${event.turnId}, score ${score.toFixed(2)}` +
211          (topicAnswer ? `, topic ${topicAnswer.choice} (${topicAnswer.confidence.toFixed(2)})` : '') +
212          ' -->',
213        `# ${date} ${slug.replace(/-/g, ' ')}`,
214        '',
215        '## User',
216        '',
217        userText,
218        '',
219        '## Assistant',
220        '',
221        assistantText,
222        '',
223      ].join('\n');
224      await $.fs.write(path, header);
225
226      const pending = Number((await $.store.get('pendingCount')) ?? 0) + 1;
227      await $.store.set('pendingCount', pending);
228      $.ui.log(`jev-vault-gate: captured to ${path} (score ${score.toFixed(2)})`);
229    } catch (error) {
230      $.ui.log(`jev-vault-gate: capture check skipped (${error instanceof Error ? error.message : String(error)})`);
231    }
232    return next(event);
233  });
234
235  on('classic.UserPromptSubmit', async ($, e, next) => {
236    const result = await next(e);
237    const pending = Number((await $.store.get('pendingCount')) ?? 0);
238    if (pending === 0) return result;
239    await $.store.set('pendingCount', 0);
240    const note = `jev-vault-gate: ${pending} new vault raw file(s) captured since last prompt — run /compile when convenient.`;
241    return { ...result, additionalContext: [...(result.additionalContext ?? []), note] };
242  });
243};
244