fix: context hook updates and cleanup (#150)

* fix(context-hook): update savings message to reference mem-search skill

Changed "Use claude-mem search" to "Use the mem-search skill" for clarity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: delete outdated docs, experiments, and test results

Removed:
- docs/context/ (moved to private/)
- experiment/ (obsolete)
- test-results/ (stale)
- tests/ (outdated)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(user-message-hook): update support link to Discord community

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2025-11-28 20:17:44 -05:00
committed by GitHub
parent 155465f52a
commit 01be3156fb
87 changed files with 7 additions and 22289 deletions
-182
View File
@@ -1,182 +0,0 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
/**
* CWD Propagation Tests
*
* These tests verify that the working directory (cwd) context flows correctly
* from hook input through the worker service to the SDK agent prompts.
*/
describe('CWD Propagation Tests', () => {
test('save-hook should extract cwd from input', () => {
// Test that PostToolUseInput interface includes cwd
const mockInput = {
session_id: 'test-session',
cwd: '/home/user/project',
tool_name: 'ReadTool',
tool_input: { path: 'README.md' },
tool_response: { content: 'test' }
};
// Verify the shape matches PostToolUseInput
assert.strictEqual(typeof mockInput.cwd, 'string');
assert.strictEqual(mockInput.cwd, '/home/user/project');
});
test('ObservationData should include cwd field', () => {
// Import the type to ensure it compiles with cwd
type ObservationData = {
tool_name: string;
tool_input: any;
tool_response: any;
prompt_number: number;
cwd?: string;
};
const mockData: ObservationData = {
tool_name: 'ReadTool',
tool_input: { path: 'test.ts' },
tool_response: { content: 'test' },
prompt_number: 1,
cwd: '/test/project'
};
assert.strictEqual(mockData.cwd, '/test/project');
});
test('PendingMessage should include cwd field', () => {
// Import the type to ensure it compiles with cwd
type PendingMessage = {
type: 'observation' | 'summarize';
tool_name?: string;
tool_input?: any;
tool_response?: any;
prompt_number?: number;
cwd?: string;
};
const mockMessage: PendingMessage = {
type: 'observation',
tool_name: 'ReadTool',
tool_input: { path: 'test.ts' },
tool_response: { content: 'test' },
prompt_number: 1,
cwd: '/test/workspace'
};
assert.strictEqual(mockMessage.cwd, '/test/workspace');
});
test('buildObservationPrompt should include tool_cwd when present', () => {
// Mock implementation of what buildObservationPrompt does
const mockObservation = {
id: 1,
tool_name: 'ReadTool',
tool_input: JSON.stringify({ path: 'test.ts' }),
tool_output: JSON.stringify({ content: 'test' }),
created_at_epoch: Date.now(),
cwd: '/home/user/my-project'
};
// Simulate the prompt generation
const promptSegment = mockObservation.cwd
? `\n <tool_cwd>${mockObservation.cwd}</tool_cwd>`
: '';
// Verify cwd is included in the prompt
assert.ok(promptSegment.includes('<tool_cwd>'));
assert.ok(promptSegment.includes('/home/user/my-project'));
});
test('buildObservationPrompt should handle missing cwd gracefully', () => {
// Mock observation without cwd
const mockObservation = {
id: 1,
tool_name: 'ReadTool',
tool_input: JSON.stringify({ path: 'test.ts' }),
tool_output: JSON.stringify({ content: 'test' }),
created_at_epoch: Date.now()
};
// Simulate the prompt generation (no cwd)
const promptSegment = mockObservation.cwd
? `\n <tool_cwd>${mockObservation.cwd}</tool_cwd>`
: '';
// Verify no tool_cwd element when cwd is undefined
assert.strictEqual(promptSegment, '');
});
test('worker API body should include cwd field', () => {
// Mock worker API request body
const requestBody = {
tool_name: 'ReadTool',
tool_input: JSON.stringify({ path: 'test.ts' }),
tool_response: JSON.stringify({ content: 'test' }),
prompt_number: 1,
cwd: '/workspace/project'
};
// Verify all expected fields are present
assert.strictEqual(requestBody.tool_name, 'ReadTool');
assert.strictEqual(requestBody.prompt_number, 1);
assert.strictEqual(requestBody.cwd, '/workspace/project');
});
test('buildInitPrompt should mention spatial awareness', () => {
// Mock the init prompt check
const initPromptSnippet = `SPATIAL AWARENESS: Tool executions include the working directory (tool_cwd) to help you understand:
- Which repository/project is being worked on
- Where files are located relative to the project root
- How to match requested paths to actual execution paths`;
// Verify the prompt explains spatial awareness
assert.ok(initPromptSnippet.includes('SPATIAL AWARENESS'));
assert.ok(initPromptSnippet.includes('tool_cwd'));
assert.ok(initPromptSnippet.includes('working directory'));
});
test('cwd should flow from hook to worker to SDK agent', () => {
// End-to-end flow test (conceptual)
const hookInput = {
session_id: 'test-123',
cwd: '/home/developer/awesome-project',
tool_name: 'ReadTool',
tool_input: { path: 'src/index.ts' },
tool_response: { content: 'export default...' }
};
// Step 1: Hook extracts cwd
const extractedCwd = hookInput.cwd;
assert.strictEqual(extractedCwd, '/home/developer/awesome-project');
// Step 2: Worker receives cwd in observation data
const observationData = {
tool_name: hookInput.tool_name,
tool_input: hookInput.tool_input,
tool_response: hookInput.tool_response,
prompt_number: 1,
cwd: extractedCwd
};
assert.strictEqual(observationData.cwd, extractedCwd);
// Step 3: SDK agent includes cwd in observation prompt
const sdkObservation = {
id: 0,
tool_name: observationData.tool_name,
tool_input: JSON.stringify(observationData.tool_input),
tool_output: JSON.stringify(observationData.tool_response),
created_at_epoch: Date.now(),
cwd: observationData.cwd
};
assert.strictEqual(sdkObservation.cwd, extractedCwd);
// Step 4: Prompt includes tool_cwd element
const promptSnippet = sdkObservation.cwd
? `<tool_cwd>${sdkObservation.cwd}</tool_cwd>`
: '';
assert.ok(promptSnippet.includes('<tool_cwd>'));
assert.ok(promptSnippet.includes(extractedCwd));
});
});
-332
View File
@@ -1,332 +0,0 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
import Database from 'better-sqlite3';
import { SessionSearch } from '../src/services/sqlite/SessionSearch';
import fs from 'fs';
import path from 'path';
const TEST_DB_DIR = '/tmp/claude-mem-test';
const TEST_DB_PATH = path.join(TEST_DB_DIR, 'test.db');
describe('SessionSearch FTS5 Injection Tests', () => {
let search: SessionSearch;
let db: Database.Database;
// Setup test database before each test
function setupTestDB() {
// Clean up any existing test database
if (fs.existsSync(TEST_DB_DIR)) {
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
}
fs.mkdirSync(TEST_DB_DIR, { recursive: true });
// Create database with required schema
db = new Database(TEST_DB_PATH);
db.pragma('journal_mode = WAL');
// Create minimal schema needed for search tests
// Note: Using claude_session_id to match SessionSearch expectations
db.exec(`
CREATE TABLE sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL,
project TEXT NOT NULL,
started_at_epoch INTEGER DEFAULT ((unixepoch() * 1000))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT NOT NULL,
prompt_number INTEGER DEFAULT 1,
type TEXT NOT NULL,
title TEXT,
subtitle TEXT,
narrative TEXT,
text TEXT,
facts TEXT,
concepts TEXT,
files_read TEXT,
files_modified TEXT,
project TEXT,
created_at_epoch INTEGER DEFAULT ((unixepoch() * 1000)),
FOREIGN KEY (claude_session_id) REFERENCES sdk_sessions(claude_session_id)
);
CREATE TABLE session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT NOT NULL,
prompt_number INTEGER DEFAULT 1,
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
notes TEXT,
files_read TEXT,
files_edited TEXT,
project TEXT,
created_at_epoch INTEGER DEFAULT ((unixepoch() * 1000)),
FOREIGN KEY (claude_session_id) REFERENCES sdk_sessions(claude_session_id)
);
CREATE TABLE user_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT NOT NULL,
prompt_number INTEGER DEFAULT 1,
prompt_text TEXT NOT NULL,
created_at_epoch INTEGER DEFAULT ((unixepoch() * 1000)),
FOREIGN KEY (claude_session_id) REFERENCES sdk_sessions(claude_session_id)
);
-- Create FTS5 tables manually
CREATE VIRTUAL TABLE observations_fts USING fts5(
title,
subtitle,
narrative,
text,
facts,
concepts,
content='observations',
content_rowid='id'
);
CREATE VIRTUAL TABLE session_summaries_fts USING fts5(
request,
investigated,
learned,
completed,
next_steps,
notes,
content='session_summaries',
content_rowid='id'
);
CREATE VIRTUAL TABLE user_prompts_fts USING fts5(
prompt_text,
content='user_prompts',
content_rowid='id'
);
-- Create triggers for observations
CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
END;
CREATE TRIGGER observations_ad AFTER DELETE ON observations BEGIN
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
END;
CREATE TRIGGER observations_au AFTER UPDATE ON observations BEGIN
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
END;
-- Create triggers for session_summaries
CREATE TRIGGER session_summaries_ai AFTER INSERT ON session_summaries BEGIN
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
END;
CREATE TRIGGER session_summaries_ad AFTER DELETE ON session_summaries BEGIN
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
END;
CREATE TRIGGER session_summaries_au AFTER UPDATE ON session_summaries BEGIN
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
END;
-- Create triggers for user_prompts
CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
END;
CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
`);
db.close();
// Create SessionSearch instance
return new SessionSearch(TEST_DB_PATH);
}
function teardownTestDB() {
if (search) {
search.close();
search = null;
}
if (fs.existsSync(TEST_DB_DIR)) {
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
}
}
test('should escape double quotes in search queries', () => {
search = setupTestDB();
// Insert test data
const db = new Database(TEST_DB_PATH);
db.exec(`
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-1', 'test-project');
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
VALUES ('test-session-1', 1, 'feature', 'Test observation', 'A test "quoted" narrative', 'Some text', '[]', '[]', '[]', '[]', 'test-project');
`);
db.close();
// Test query with double quotes - should not cause injection
const maliciousQuery = 'test" OR 1=1 --';
// This should not throw an error and should search safely
const results = search.searchObservations(maliciousQuery);
// With proper escaping, this should return 0 results (no match for the literal string)
// Without escaping, it could match everything due to OR 1=1
assert.strictEqual(Array.isArray(results), true, 'Should return an array');
teardownTestDB();
});
test('should handle FTS5 special operators safely', () => {
search = setupTestDB();
// Insert test data
const db = new Database(TEST_DB_PATH);
db.exec(`
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-2', 'test-project');
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
VALUES ('test-session-2', 1, 'feature', 'Security feature', 'Implements security', 'Authentication system', '[]', '[]', '[]', '[]', 'test-project');
`);
db.close();
// Test queries with FTS5 operators that should be escaped
const testQueries = [
'AND OR NOT', // Boolean operators
'(parentheses)', // Grouping
'asterisk*', // Wildcard
'column:value', // Column filter attempt
];
testQueries.forEach(query => {
// Should not throw an error
const results = search.searchObservations(query);
assert.strictEqual(Array.isArray(results), true, `Should return array for query: ${query}`);
});
teardownTestDB();
});
test('should find exact phrase matches when properly escaped', () => {
search = setupTestDB();
// Insert test data
const db = new Database(TEST_DB_PATH);
db.exec(`
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-3', 'test-project');
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
VALUES ('test-session-3', 1, 'feature', 'Hello world', 'This is a hello world example', 'Hello world program', '[]', '[]', '[]', '[]', 'test-project');
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
VALUES ('test-session-3', 2, 'feature', 'Goodbye moon', 'This is something else', 'Different content', '[]', '[]', '[]', '[]', 'test-project');
`);
db.close();
// Search for exact phrase
const results = search.searchObservations('hello world');
assert.strictEqual(Array.isArray(results), true, 'Should return an array');
assert.ok(results.length > 0, 'Should find at least one result');
assert.ok(
results.some(r => r.title?.toLowerCase().includes('hello') || r.narrative?.toLowerCase().includes('hello')),
'Should find observation with "hello"'
);
teardownTestDB();
});
test('should handle empty and special character queries safely', () => {
search = setupTestDB();
// Insert test data
const db = new Database(TEST_DB_PATH);
db.exec(`
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-4', 'test-project');
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
VALUES ('test-session-4', 1, 'feature', 'Test', 'Test observation', 'Test content', '[]', '[]', '[]', '[]', 'test-project');
`);
db.close();
// Test edge cases
const edgeCases = [
'""', // Empty quoted string
' ', // Whitespace only
'!!!', // Special characters
'@#$%', // More special characters
];
edgeCases.forEach(query => {
// Should not throw an error
const results = search.searchObservations(query);
assert.strictEqual(Array.isArray(results), true, `Should return array for edge case: "${query}"`);
});
teardownTestDB();
});
test('should search session summaries safely', () => {
search = setupTestDB();
// Insert test data
const db = new Database(TEST_DB_PATH);
db.exec(`
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-5', 'test-project');
INSERT INTO session_summaries (claude_session_id, prompt_number, request, investigated, learned, completed, next_steps, notes, files_read, files_edited, project)
VALUES ('test-session-5', 1, 'Implement feature', 'Looked into options', 'Learned new approach', 'Completed task', 'Next: testing', 'Notes here', '[]', '[]', 'test-project');
`);
db.close();
// Test with potential injection
const maliciousQuery = 'feature" OR type:*';
const results = search.searchSessions(maliciousQuery);
assert.strictEqual(Array.isArray(results), true, 'Should return an array');
teardownTestDB();
});
test('should search user prompts safely', () => {
search = setupTestDB();
// Insert test data
const db = new Database(TEST_DB_PATH);
db.exec(`
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-6', 'test-project');
INSERT INTO user_prompts (claude_session_id, prompt_number, prompt_text)
VALUES ('test-session-6', 1, 'Please implement authentication');
`);
db.close();
// Test with potential injection
const maliciousQuery = 'authentication" AND request:*';
const results = search.searchUserPrompts(maliciousQuery);
assert.strictEqual(Array.isArray(results), true, 'Should return an array');
teardownTestDB();
});
});
-95
View File
@@ -1,95 +0,0 @@
#!/bin/bash
# Test script to verify process cleanup
# This script tests that uvx/python processes are properly cleaned up
set -e
echo "=== Process Cleanup Test ==="
echo ""
# Function to count uvx/python processes
count_processes() {
local count=$(ps aux | grep -E "(uvx|python.*chroma)" | grep -v grep | wc -l)
echo "$count"
}
# Initial count
echo "1. Initial process count:"
initial=$(count_processes)
echo " uvx/python/chroma processes: $initial"
echo ""
# Start a node process that creates ChromaSync
echo "2. Starting test process that creates ChromaSync..."
cat > /tmp/test-chroma-cleanup.mjs << 'EOF'
import { ChromaSync } from './src/services/sync/ChromaSync.js';
const sync = new ChromaSync('test-project');
console.log('[TEST] ChromaSync created, connecting...');
// Try to connect (this spawns uvx process)
try {
await sync.ensureBackfilled();
console.log('[TEST] Backfill started');
} catch (error) {
console.log('[TEST] Backfill failed (expected if no data):', error.message);
}
// Wait a bit for process to start
await new Promise(resolve => setTimeout(resolve, 2000));
const countBefore = parseInt(process.env.COUNT_BEFORE || '0');
const countAfter = process.argv[2];
console.log('[TEST] Process count before:', countBefore);
// Close the sync (should terminate uvx process)
console.log('[TEST] Closing ChromaSync...');
await sync.close();
// Wait for process to terminate
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('[TEST] ChromaSync closed, process should be terminated');
process.exit(0);
EOF
# Run test
COUNT_BEFORE=$initial node /tmp/test-chroma-cleanup.mjs 2>&1 &
TEST_PID=$!
# Wait for process to spawn
sleep 3
# Count during execution
during=$(count_processes)
echo " During execution: $during processes"
echo ""
# Wait for test to complete
wait $TEST_PID 2>/dev/null || true
# Wait a bit for cleanup
sleep 2
# Final count
echo "3. Final process count:"
final=$(count_processes)
echo " uvx/python/chroma processes: $final"
echo ""
# Check if we leaked processes
leaked=$((final - initial))
if [ $leaked -gt 0 ]; then
echo "❌ FAIL: Leaked $leaked process(es)"
echo ""
echo "Current processes:"
ps aux | grep -E "(uvx|python.*chroma)" | grep -v grep
exit 1
else
echo "✅ PASS: No process leaks detected"
fi
# Cleanup
rm -f /tmp/test-chroma-cleanup.mjs