feat: Add dual-tag system for meta-observation control (#153)

* 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>
This commit is contained in:
Alex Newman
2025-11-30 22:57:26 -05:00
committed by GitHub
parent 7cad4f0114
commit 2b223b7cd9
18 changed files with 1226 additions and 141 deletions
+21 -2
View File
@@ -39,6 +39,7 @@ 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;
@@ -47,6 +48,7 @@ export interface UserPromptSubmitInput {
[key: string]: any;
}
/**
* New Hook Main Logic
*/
@@ -88,8 +90,25 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
const sessionDbId = db.createSDKSession(session_id, project, prompt);
const promptNumber = db.incrementPromptCounter(sessionDbId);
// Save raw user prompt for full-text search
db.saveUserPrompt(session_id, promptNumber, prompt);
// 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}`);
+46 -2
View File
@@ -8,6 +8,8 @@ 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;
@@ -27,6 +29,7 @@ const SKIP_TOOLS = new Set([
'AskUserQuestion' // User interaction, not substantive work
]);
/**
* Save Hook Main Logic
*/
@@ -50,6 +53,22 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
// 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);
@@ -62,13 +81,38 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
});
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: tool_input !== undefined ? JSON.stringify(tool_input) : '{}',
tool_response: tool_response !== undefined ? JSON.stringify(tool_response) : '{}',
tool_input: cleanedToolInput,
tool_response: cleanedToolResponse,
prompt_number: promptNumber,
cwd: cwd || ''
}),
+14
View File
@@ -141,6 +141,20 @@ async function summaryHook(input?: StopInput): Promise<void> {
const sessionDbId = db.createSDKSession(session_id, '', '');
const promptNumber = db.getPromptCounter(sessionDbId);
// Skip summary 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 memory operations including summaries
const userPrompt = db.getUserPrompt(session_id, promptNumber);
if (!userPrompt || userPrompt.trim() === '') {
silentDebug('[summary-hook] Skipping summary - user prompt was entirely private', {
session_id,
promptNumber
});
db.close();
console.log(createHookResponse('Stop', true));
return;
}
// DIAGNOSTIC: Check session and observations
const sessionInfo = db.db.prepare(`
SELECT id, claude_session_id, sdk_session_id, project