Skip to content
20 changes: 10 additions & 10 deletions plugin/scripts/mcp-server.cjs

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions plugin/scripts/statusline-counts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bun
/**
* Statusline Counts — lightweight project-scoped observation counter
*
* Returns JSON with observation and prompt counts for the given project,
* suitable for integration into Claude Code's statusLineCommand.
*
* Usage:
* bun statusline-counts.js <cwd>
* bun statusline-counts.js /home/user/my-project
*
* Output (JSON, stdout):
* {"observations": 42, "prompts": 15, "project": "my-project"}
*
* The project name is derived from basename(cwd). Observations are counted
* with a WHERE project = ? filter so only the current project's data is
* returned — preventing inflated counts from cross-project observations.
*
* Performance: ~10ms typical (direct SQLite read, no HTTP, no worker dependency)
*/
import { Database } from "bun:sqlite";
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join, basename } from "path";

const cwd = process.argv[2] || process.env.CLAUDE_CWD || process.cwd();
const project = basename(cwd);

try {
// Resolve data directory: env var → settings.json → default
let dataDir = process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), ".claude-mem");
if (!process.env.CLAUDE_MEM_DATA_DIR) {
const settingsPath = join(dataDir, "settings.json");
if (existsSync(settingsPath)) {
try {
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
if (settings.CLAUDE_MEM_DATA_DIR) dataDir = settings.CLAUDE_MEM_DATA_DIR;
} catch { /* use default */ }
}
}

const dbPath = join(dataDir, "claude-mem.db");
if (!existsSync(dbPath)) {
console.log(JSON.stringify({ observations: 0, prompts: 0, project }));
process.exit(0);
}

const db = new Database(dbPath, { readonly: true });

const obs = db.query("SELECT COUNT(*) as c FROM observations WHERE project = ?").get(project);
// user_prompts links to projects through sdk_sessions.content_session_id
const prompts = db.query(
`SELECT COUNT(*) as c FROM user_prompts up
JOIN sdk_sessions s ON s.content_session_id = up.content_session_id
WHERE s.project = ?`
).get(project);
console.log(JSON.stringify({ observations: obs.c, prompts: prompts.c, project }));
db.close();
} catch (e) {
console.log(JSON.stringify({ observations: 0, prompts: 0, project, error: e.message }));
}
382 changes: 191 additions & 191 deletions plugin/scripts/worker-service.cjs

Large diffs are not rendered by default.

39 changes: 27 additions & 12 deletions src/cli/handlers/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
import { getProjectContext } from '../../utils/project-name.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { logger } from '../../utils/logger.js';

export const contextHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
Expand All @@ -35,20 +36,34 @@ export const contextHandler: EventHandler = {

// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
// Worker service has its own timeouts, so client-side timeout is redundant
const response = await fetch(url);
try {
const response = await fetch(url);

if (!response.ok) {
throw new Error(`Context generation failed: ${response.status}`);
}
if (!response.ok) {
// Log but don't throw — context fetch failure should not block session start
logger.warn('HOOK', 'Context generation failed, returning empty', { status: response.status });
return {
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
exitCode: HOOK_EXIT_CODES.SUCCESS
};
}

const result = await response.text();
const additionalContext = result.trim();
const result = await response.text();
const additionalContext = result.trim();

return {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext
}
};
return {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext
}
};
} catch (error) {
// Worker unreachable — return empty context gracefully
logger.warn('HOOK', 'Context fetch error, returning empty', { error: error instanceof Error ? error.message : String(error) });
return {
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
exitCode: HOOK_EXIT_CODES.SUCCESS
};
}
}
};
40 changes: 24 additions & 16 deletions src/cli/handlers/file-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,32 @@ export const fileEditHandler: EventHandler = {

// Send to worker as an observation with file edit metadata
// The observation handler on the worker will process this appropriately
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
tool_name: 'write_file',
tool_input: { filePath, edits },
tool_response: { success: true },
cwd
})
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
});
try {
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
tool_name: 'write_file',
tool_input: { filePath, edits },
tool_response: { success: true },
cwd
})
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
});

if (!response.ok) {
throw new Error(`File edit observation storage failed: ${response.status}`);
}
if (!response.ok) {
// Log but don't throw — file edit observation failure should not block editing
logger.warn('HOOK', 'File edit observation storage failed, skipping', { status: response.status, filePath });
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}

logger.debug('HOOK', 'File edit observation sent successfully', { filePath });
logger.debug('HOOK', 'File edit observation sent successfully', { filePath });
} catch (error) {
// Worker unreachable — skip file edit observation gracefully
logger.warn('HOOK', 'File edit observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) });
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}

return { continue: true, suppressOutput: true };
}
Expand Down
19 changes: 14 additions & 5 deletions src/cli/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import type { EventHandler } from '../types.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { contextHandler } from './context.js';
import { sessionInitHandler } from './session-init.js';
import { observationHandler } from './observation.js';
Expand Down Expand Up @@ -35,14 +36,22 @@ const handlers: Record<EventType, EventHandler> = {
/**
* Get the event handler for a given event type.
*
* Returns a no-op handler for unknown event types instead of throwing (fix #984).
* Claude Code may send new event types that the plugin doesn't handle yet —
* throwing would surface as a BLOCKING_ERROR to the user.
*
* @param eventType The type of event to handle
* @returns The appropriate EventHandler
* @throws Error if event type is not recognized
* @returns The appropriate EventHandler, or a no-op handler for unknown types
*/
export function getEventHandler(eventType: EventType): EventHandler {
const handler = handlers[eventType];
export function getEventHandler(eventType: string): EventHandler {
const handler = handlers[eventType as EventType];
if (!handler) {
throw new Error(`Unknown event type: ${eventType}`);
console.error(`[claude-mem] Unknown event type: ${eventType}, returning no-op`);
return {
async execute() {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
};
}
return handler;
}
Expand Down
40 changes: 24 additions & 16 deletions src/cli/handlers/observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,32 @@ export const observationHandler: EventHandler = {
}

// Send to worker - worker handles privacy check and database operations
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
cwd
})
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
});
try {
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
cwd
})
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
});

if (!response.ok) {
throw new Error(`Observation storage failed: ${response.status}`);
}
if (!response.ok) {
// Log but don't throw — observation storage failure should not block tool use
logger.warn('HOOK', 'Observation storage failed, skipping', { status: response.status, toolName });
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}

logger.debug('HOOK', 'Observation sent successfully', { toolName });
logger.debug('HOOK', 'Observation sent successfully', { toolName });
} catch (error) {
// Worker unreachable — skip observation gracefully
logger.warn('HOOK', 'Observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) });
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}

return { continue: true, suppressOutput: true };
}
Expand Down
6 changes: 5 additions & 1 deletion src/cli/handlers/session-complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import { logger } from '../../utils/logger.js';
export const sessionCompleteHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Ensure worker is running
await ensureWorkerRunning();
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available — skip session completion gracefully
return { continue: true, suppressOutput: true };
}

const { sessionId } = input;
const port = getWorkerPort();
Expand Down
55 changes: 32 additions & 23 deletions src/cli/handlers/user-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,46 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
export const userMessageHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Ensure worker is running
await ensureWorkerRunning();
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
// Worker not available — skip user message gracefully
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
}

const port = getWorkerPort();
const project = basename(input.cwd ?? process.cwd());

// Fetch formatted context directly from worker API
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
const response = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
{ method: 'GET' }
);

if (!response.ok) {
// Don't throw - context fetch failure should not block the user's prompt
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
try {
const response = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
{ method: 'GET' }
);

if (!response.ok) {
// Don't throw - context fetch failure should not block the user's prompt
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
}

const output = await response.text();

// Write to stderr for user visibility
// Note: Using process.stderr.write instead of console.error to avoid
// Claude Code treating this as a hook error. The actual hook output
// goes to stdout via hook-command.ts JSON serialization.
process.stderr.write(
"\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" +
output +
"\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with <private> ... </private> to prevent storing sensitive information.\n" +
"\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" +
`\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n`
);
} catch (error) {
// Worker unreachable — skip user message gracefully
// User message context error is non-critical — skip gracefully
}

const output = await response.text();

// Write to stderr for user visibility
// Note: Using process.stderr.write instead of console.error to avoid
// Claude Code treating this as a hook error. The actual hook output
// goes to stdout via hook-command.ts JSON serialization.
process.stderr.write(
"\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" +
output +
"\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with <private> ... </private> to prevent storing sensitive information.\n" +
"\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" +
`\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n`
);

return { exitCode: HOOK_EXIT_CODES.SUCCESS };
}
};
Loading
Loading