2b223b7cd9
* feat: Add dual-tag system for meta-observation control Implements <private> and <claude-mem-context> tag stripping at hook layer to give users fine-grained control over what gets persisted in observations and enable future real-time context injection without recursive storage. **Features:** - stripMemoryTags() function in save-hook.ts - Strips both <private> and <claude-mem-context> tags before sending to worker - Always active (no configuration needed) - Comprehensive test suite (19 tests, all passing) - User documentation for <private> tag - Technical architecture documentation **Architecture:** - Edge processing pattern (filter at hook, not worker) - Defensive type handling with silentDebug - Supports multiline, nested, and multiple tags - Enables strategic orchestration for internal tools **User-Facing:** - <private> tag for manual privacy control (documented) - Prevents sensitive data from persisting in observations **Infrastructure:** - <claude-mem-context> tag ready for real-time context feature - Prevents recursive storage when context injection ships **Files:** - src/hooks/save-hook.ts: Core implementation - tests/strip-memory-tags.test.ts: Test suite (19/19 passing) - docs/public/usage/private-tags.mdx: User guide - docs/public/docs.json: Navigation update - docs/context/dual-tag-system-architecture.md: Technical docs - plugin/scripts/save-hook.js: Built hook 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Strip private tags from user prompts and skip memory ops for fully private prompts Fixes critical privacy bug where <private> tags were not being stripped from user prompts before storage in user_prompts table, making private content searchable via mem-search. Changes: 1. new-hook.ts: Skip memory operations for fully private prompts - If cleaned prompt is empty after stripping tags, skip saveUserPrompt - Skip worker init to avoid wasting resources on empty prompts - Logs: "(fully private - skipped)" 2. save-hook.ts: Skip observations for fully private prompts - Check if user prompt was entirely private before creating observations - Respects user intent: fully private prompt = no observations at all - Prevents "thoughts pop up" issue where private prompts create public observations 3. SessionStore.ts: Add getUserPrompt() method - Retrieves prompt text by session_id and prompt_number - Used by save-hook to check if prompt was private 4. Tests: Added 4 new tests for fully private prompt detection (16 total, all passing) 5. Docs: Updated private-tags.mdx to reflect correct behavior - User prompts ARE now filtered before storage - Private content never reaches database or search indices Privacy Protection: - Fully private prompts: No user_prompt saved, no worker init, no observations - Partially private prompts: Tags stripped, content sanitized before storage - Zero leaks: Private content never indexed or searchable Addresses reviewer feedback on PR #153 about user prompt filtering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Enhance memory tag handling and indexing in user prompts - Added a new index `idx_user_prompts_lookup` on `user_prompts` for improved query performance based on `claude_session_id` and `prompt_number`. - Refactored memory tag stripping functionality into dedicated utility functions: `stripMemoryTagsFromJson` and `stripMemoryTagsFromPrompt` for better separation of concerns and reusability. - Updated hooks (`new-hook.ts` and `save-hook.ts`) to utilize the new tag stripping functions, ensuring private content is not stored or searchable. - Removed redundant inline tag stripping functions from hooks to streamline code. - Added tests for the new tag stripping utilities to ensure functionality and prevent regressions. --------- Co-authored-by: Claude <noreply@anthropic.com>
155 lines
5.4 KiB
TypeScript
155 lines
5.4 KiB
TypeScript
/**
|
|
* New Hook - UserPromptSubmit
|
|
*
|
|
* DUAL PURPOSE HOOK: Handles BOTH session initialization AND continuation
|
|
* ==========================================================================
|
|
*
|
|
* CRITICAL ARCHITECTURE FACTS (NEVER FORGET):
|
|
*
|
|
* 1. SESSION ID THREADING - The Single Source of Truth
|
|
* - Claude Code assigns ONE session_id per conversation
|
|
* - ALL hooks in that conversation receive the SAME session_id
|
|
* - We ALWAYS use this session_id - NEVER generate our own
|
|
* - This is how NEW hook, SAVE hook, and SUMMARY hook stay connected
|
|
*
|
|
* 2. NO EXISTENCE CHECKS NEEDED
|
|
* - createSDKSession is idempotent (INSERT OR IGNORE)
|
|
* - Prompt #1: Creates new database row, returns new ID
|
|
* - Prompt #2+: Row exists, returns existing ID
|
|
* - We NEVER need to check "does session exist?" - just use the session_id
|
|
*
|
|
* 3. CONTINUATION LOGIC LOCATION
|
|
* - This hook does NOT contain continuation prompt logic
|
|
* - That lives in SDKAgent.ts (lines 125-127)
|
|
* - SDKAgent checks promptNumber to choose init vs continuation prompt
|
|
* - BOTH prompts receive the SAME session_id from this hook
|
|
*
|
|
* 4. UNIFIED WITH SAVE HOOK
|
|
* - SAVE hook uses: db.createSDKSession(session_id, '', '')
|
|
* - NEW hook uses: db.createSDKSession(session_id, project, prompt)
|
|
* - Both use session_id from hook context - this keeps everything connected
|
|
*
|
|
* This is KISS in action: Use the session_id we're given, trust idempotent
|
|
* database operations, and let SDKAgent handle init vs continuation logic.
|
|
*/
|
|
|
|
import path from 'path';
|
|
import { stdin } from 'process';
|
|
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
|
import { createHookResponse } from './hook-response.js';
|
|
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
|
|
import { silentDebug } from '../utils/silent-debug.js';
|
|
import { stripMemoryTagsFromPrompt } from '../utils/tag-stripping.js';
|
|
|
|
export interface UserPromptSubmitInput {
|
|
session_id: string;
|
|
cwd: string;
|
|
prompt: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
|
|
/**
|
|
* New Hook Main Logic
|
|
*/
|
|
async function newHook(input?: UserPromptSubmitInput): Promise<void> {
|
|
if (!input) {
|
|
throw new Error('newHook requires input');
|
|
}
|
|
|
|
const { session_id, cwd, prompt } = input;
|
|
|
|
// Debug: Log what we received
|
|
silentDebug('[new-hook] Input received', {
|
|
session_id,
|
|
cwd,
|
|
cwd_type: typeof cwd,
|
|
cwd_length: cwd?.length,
|
|
has_cwd: !!cwd,
|
|
prompt_length: prompt?.length
|
|
});
|
|
|
|
const project = path.basename(cwd);
|
|
|
|
silentDebug('[new-hook] Project extracted', {
|
|
project,
|
|
project_type: typeof project,
|
|
project_length: project?.length,
|
|
is_empty: project === '',
|
|
cwd_was: cwd
|
|
});
|
|
|
|
// Ensure worker is running
|
|
await ensureWorkerRunning();
|
|
|
|
const db = new SessionStore();
|
|
|
|
// CRITICAL: Use session_id from hook as THE source of truth
|
|
// createSDKSession is idempotent - creates new or returns existing
|
|
// This is how ALL hooks stay connected to the same session
|
|
const sessionDbId = db.createSDKSession(session_id, project, prompt);
|
|
const promptNumber = db.incrementPromptCounter(sessionDbId);
|
|
|
|
// Strip memory tags before saving user prompt to prevent privacy leaks
|
|
// Tags like <private> and <claude-mem-context> should not be stored or searchable
|
|
const cleanedUserPrompt = stripMemoryTagsFromPrompt(prompt);
|
|
|
|
// Skip memory operations for fully private prompts
|
|
// If the entire prompt was wrapped in <private> tags, don't create any observations
|
|
if (!cleanedUserPrompt || cleanedUserPrompt.trim() === '') {
|
|
silentDebug('[new-hook] Prompt entirely private, skipping memory operations', {
|
|
session_id,
|
|
promptNumber,
|
|
originalLength: prompt.length
|
|
});
|
|
db.close();
|
|
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber} (fully private - skipped)`);
|
|
console.log(createHookResponse('UserPromptSubmit', true));
|
|
return;
|
|
}
|
|
|
|
db.saveUserPrompt(session_id, promptNumber, cleanedUserPrompt);
|
|
|
|
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber}`);
|
|
|
|
db.close();
|
|
|
|
const port = getWorkerPort();
|
|
|
|
// Strip leading slash from commands for memory agent
|
|
// /review 101 → review 101 (more semantic for observations)
|
|
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
|
|
|
|
try {
|
|
// Initialize session via HTTP
|
|
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ project, userPrompt: cleanedPrompt, promptNumber }),
|
|
signal: AbortSignal.timeout(5000)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to initialize session: ${response.status} ${errorText}`);
|
|
}
|
|
} catch (error: any) {
|
|
// Only show restart message for connection errors, not HTTP errors
|
|
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
|
|
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
|
|
}
|
|
// Re-throw HTTP errors and other errors as-is
|
|
throw error;
|
|
}
|
|
|
|
console.log(createHookResponse('UserPromptSubmit', true));
|
|
}
|
|
|
|
// Entry Point
|
|
let input = '';
|
|
stdin.on('data', (chunk) => input += chunk);
|
|
stdin.on('end', async () => {
|
|
const parsed = input ? JSON.parse(input) : undefined;
|
|
await newHook(parsed);
|
|
});
|