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>
151 lines
4.9 KiB
TypeScript
151 lines
4.9 KiB
TypeScript
/**
|
|
* Save Hook - PostToolUse
|
|
* Consolidated entry point + logic
|
|
*/
|
|
|
|
import { stdin } from 'process';
|
|
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
|
import { createHookResponse } from './hook-response.js';
|
|
import { logger } from '../utils/logger.js';
|
|
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
|
|
import { silentDebug } from '../utils/silent-debug.js';
|
|
import { stripMemoryTagsFromJson } from '../utils/tag-stripping.js';
|
|
|
|
export interface PostToolUseInput {
|
|
session_id: string;
|
|
cwd: string;
|
|
tool_name: string;
|
|
tool_input: any;
|
|
tool_response: any;
|
|
[key: string]: any;
|
|
}
|
|
|
|
// Tools to skip (low value or too frequent)
|
|
const SKIP_TOOLS = new Set([
|
|
'ListMcpResourcesTool', // MCP infrastructure
|
|
'SlashCommand', // Command invocation (observe what it produces, not the call)
|
|
'Skill', // Skill invocation (observe what it produces, not the call)
|
|
'TodoWrite', // Task management meta-tool
|
|
'AskUserQuestion' // User interaction, not substantive work
|
|
]);
|
|
|
|
|
|
/**
|
|
* Save Hook Main Logic
|
|
*/
|
|
async function saveHook(input?: PostToolUseInput): Promise<void> {
|
|
if (!input) {
|
|
throw new Error('saveHook requires input');
|
|
}
|
|
|
|
const { session_id, cwd, tool_name, tool_input, tool_response } = input;
|
|
|
|
if (SKIP_TOOLS.has(tool_name)) {
|
|
console.log(createHookResponse('PostToolUse', true));
|
|
return;
|
|
}
|
|
|
|
// Ensure worker is running
|
|
await ensureWorkerRunning();
|
|
|
|
const db = new SessionStore();
|
|
|
|
// Get or create session
|
|
const sessionDbId = db.createSDKSession(session_id, '', '');
|
|
const promptNumber = db.getPromptCounter(sessionDbId);
|
|
|
|
// Skip observation if user prompt was entirely private
|
|
// This respects the user's intent: if they marked the entire prompt as <private>,
|
|
// they don't want ANY observations from that interaction
|
|
const userPrompt = db.getUserPrompt(session_id, promptNumber);
|
|
if (!userPrompt || userPrompt.trim() === '') {
|
|
silentDebug('[save-hook] Skipping observation - user prompt was entirely private', {
|
|
session_id,
|
|
promptNumber,
|
|
tool_name
|
|
});
|
|
db.close();
|
|
console.log(createHookResponse('PostToolUse', true));
|
|
return;
|
|
}
|
|
|
|
db.close();
|
|
|
|
const toolStr = logger.formatTool(tool_name, tool_input);
|
|
|
|
const port = getWorkerPort();
|
|
|
|
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
|
|
sessionId: sessionDbId,
|
|
workerPort: port
|
|
});
|
|
|
|
try {
|
|
// Serialize and strip memory tags from tool_input and tool_response
|
|
// This prevents recursive storage of context and respects <private> tags
|
|
let cleanedToolInput = '{}';
|
|
let cleanedToolResponse = '{}';
|
|
|
|
try {
|
|
cleanedToolInput = tool_input !== undefined
|
|
? stripMemoryTagsFromJson(JSON.stringify(tool_input))
|
|
: '{}';
|
|
} catch (error) {
|
|
// Handle circular references or other JSON.stringify errors
|
|
silentDebug('[save-hook] Failed to stringify tool_input:', { error, tool_name });
|
|
cleanedToolInput = '{"error": "Failed to serialize tool_input"}';
|
|
}
|
|
|
|
try {
|
|
cleanedToolResponse = tool_response !== undefined
|
|
? stripMemoryTagsFromJson(JSON.stringify(tool_response))
|
|
: '{}';
|
|
} catch (error) {
|
|
// Handle circular references or other JSON.stringify errors
|
|
silentDebug('[save-hook] Failed to stringify tool_response:', { error, tool_name });
|
|
cleanedToolResponse = '{"error": "Failed to serialize tool_response"}';
|
|
}
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/observations`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tool_name,
|
|
tool_input: cleanedToolInput,
|
|
tool_response: cleanedToolResponse,
|
|
prompt_number: promptNumber,
|
|
cwd: cwd || ''
|
|
}),
|
|
signal: AbortSignal.timeout(2000)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
logger.failure('HOOK', 'Failed to send observation', {
|
|
sessionId: sessionDbId,
|
|
status: response.status
|
|
}, errorText);
|
|
throw new Error(`Failed to send observation to worker: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
logger.debug('HOOK', 'Observation sent successfully', { sessionId: sessionDbId, toolName: tool_name });
|
|
} 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('PostToolUse', true));
|
|
}
|
|
|
|
// Entry Point
|
|
let input = '';
|
|
stdin.on('data', (chunk) => input += chunk);
|
|
stdin.on('end', async () => {
|
|
const parsed = input ? JSON.parse(input) : undefined;
|
|
await saveHook(parsed);
|
|
});
|