Merge main into thedotmack/file-read-timeline-inject

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-05 03:00:06 -07:00
95 changed files with 11818 additions and 5886 deletions
+3 -1
View File
@@ -3,6 +3,7 @@ import { claudeCodeAdapter } from './claude-code.js';
import { cursorAdapter } from './cursor.js';
import { geminiCliAdapter } from './gemini-cli.js';
import { rawAdapter } from './raw.js';
import { windsurfAdapter } from './windsurf.js';
export function getPlatformAdapter(platform: string): PlatformAdapter {
switch (platform) {
@@ -10,10 +11,11 @@ export function getPlatformAdapter(platform: string): PlatformAdapter {
case 'cursor': return cursorAdapter;
case 'gemini':
case 'gemini-cli': return geminiCliAdapter;
case 'windsurf': return windsurfAdapter;
case 'raw': return rawAdapter;
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
default: return rawAdapter;
}
}
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter };
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
+79
View File
@@ -0,0 +1,79 @@
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
// Maps Windsurf stdin format — JSON envelope with agent_action_name + tool_info payload
//
// Common envelope (all hooks):
// { agent_action_name, trajectory_id, execution_id, timestamp, tool_info: { ... } }
//
// Event-specific tool_info payloads:
// pre_user_prompt: { user_prompt: string }
// post_write_code: { file_path, edits: [{ old_string, new_string }] }
// post_run_command: { command_line, cwd }
// post_mcp_tool_use: { mcp_server_name, mcp_tool_name, mcp_tool_arguments, mcp_result }
// post_cascade_response: { response }
export const windsurfAdapter: PlatformAdapter = {
normalizeInput(raw) {
const r = (raw ?? {}) as any;
const toolInfo = r.tool_info ?? {};
const actionName: string = r.agent_action_name ?? '';
const base: NormalizedHookInput = {
sessionId: r.trajectory_id ?? r.execution_id,
cwd: toolInfo.cwd ?? process.cwd(),
platform: 'windsurf',
};
switch (actionName) {
case 'pre_user_prompt':
return {
...base,
prompt: toolInfo.user_prompt,
};
case 'post_write_code':
return {
...base,
toolName: 'Write',
filePath: toolInfo.file_path,
edits: toolInfo.edits,
toolInput: {
file_path: toolInfo.file_path,
edits: toolInfo.edits,
},
};
case 'post_run_command':
return {
...base,
cwd: toolInfo.cwd ?? base.cwd,
toolName: 'Bash',
toolInput: { command: toolInfo.command_line },
};
case 'post_mcp_tool_use':
return {
...base,
toolName: toolInfo.mcp_tool_name ?? 'mcp_tool',
toolInput: toolInfo.mcp_tool_arguments,
toolResponse: toolInfo.mcp_result,
};
case 'post_cascade_response':
return {
...base,
toolName: 'cascade_response',
toolResponse: toolInfo.response,
};
default:
// Unknown action — pass through what we can
return base;
}
},
formatOutput(result) {
// Windsurf exit codes: 0 = success, 2 = block (pre-hooks only)
// The CLI layer handles exit codes; here we just return a simple continue flag
return { continue: result.continue ?? true };
},
};
+1 -1
View File
@@ -39,7 +39,7 @@ export const contextHandler: EventHandler = {
// Pass all projects (parent + worktree if applicable) for unified timeline
const projectsParam = context.allProjects.join(',');
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
const colorApiPath = `${apiPath}&colors=true`;
const colorApiPath = input.platform === 'claude-code' ? `${apiPath}&colors=true` : apiPath;
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
// Worker service has its own timeouts, so client-side timeout is redundant
+50 -5
View File
@@ -87,17 +87,18 @@ export const sessionInitHandler: EventHandler = {
// Skip SDK agent re-initialization if context was already injected for this session (#1079)
// The prompt was already saved to the database by /api/sessions/init above —
// no need to re-start the SDK agent on every turn
if (initResult.contextInjected) {
// no need to re-start the SDK agent on every turn.
// Note: we do NOT return here — semantic injection below must run on every prompt.
const skipAgentInit = Boolean(initResult.contextInjected);
if (skipAgentInit) {
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | skipped_agent_init=true | reason=context_already_injected`, {
sessionId: sessionDbId
});
return { continue: true, suppressOutput: true };
}
// Only initialize SDK agent for Claude Code (not Cursor)
// Cursor doesn't use the SDK agent - it only needs session/observation storage
if (input.platform !== 'cursor' && sessionDbId) {
if (!skipAgentInit && input.platform !== 'cursor' && sessionDbId) {
// Strip leading slash from commands for memory agent
// /review 101 -> review 101 (more semantic for observations)
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
@@ -115,14 +116,58 @@ export const sessionInitHandler: EventHandler = {
// Log but don't throw - SDK agent failure should not block the user's prompt
logger.failure('HOOK', `SDK agent start failed: ${response.status}`, { sessionDbId, promptNumber });
}
} else if (input.platform === 'cursor') {
} else if (!skipAgentInit && input.platform === 'cursor') {
logger.debug('HOOK', 'session-init: Skipping SDK agent init for Cursor platform', { sessionDbId, promptNumber });
}
// Semantic context injection: query Chroma for relevant past observations
// and inject as additionalContext so Claude receives relevant memory each prompt.
// Controlled by CLAUDE_MEM_SEMANTIC_INJECT setting (default: true).
const semanticInject =
String(settings.CLAUDE_MEM_SEMANTIC_INJECT).toLowerCase() === 'true';
let additionalContext = '';
if (semanticInject && prompt && prompt.length >= 20 && prompt !== '[media prompt]') {
try {
const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5';
const semanticRes = await workerHttpRequest('/api/context/semantic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: prompt, project, limit })
});
if (semanticRes.ok) {
const data = await semanticRes.json() as { context: string; count: number };
if (data.context) {
additionalContext = data.context;
logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, {
sessionId: sessionDbId, count: data.count
});
}
}
} catch (e) {
// Graceful degradation — semantic injection is optional
logger.debug('HOOK', 'Semantic injection unavailable', {
error: e instanceof Error ? e.message : String(e)
});
}
}
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | project=${project}`, {
sessionId: sessionDbId
});
// Return with semantic context if available
if (additionalContext) {
return {
continue: true,
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext
}
};
}
return { continue: true, suppressOutput: true };
}
};
+63 -6
View File
@@ -1,9 +1,16 @@
/**
* Summarize Handler - Stop
*
* Extracted from summary-hook.ts - sends summary request to worker.
* Transcript parsing stays in the hook because only the hook has access to
* the transcript file path.
* Runs in the Stop hook (120s timeout, not capped like SessionEnd).
* This is the ONLY place where we can reliably wait for async work.
*
* Flow:
* 1. Queue summarize request to worker
* 2. Poll worker until summary processing completes
* 3. Call /api/sessions/complete to clean up session
*
* SessionEnd (1.5s cap from Claude Code) is just a lightweight fallback —
* all real work must happen here in Stop.
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
@@ -13,6 +20,8 @@ import { extractLastMessage } from '../../shared/transcript-parser.js';
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
const POLL_INTERVAL_MS = 500;
const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120s timeout
export const summarizeHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -43,11 +52,21 @@ export const summarizeHandler: EventHandler = {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
// Skip summary if transcript has no assistant message (prevents repeated
// empty summarize requests that pollute logs — upstream bug)
if (!lastAssistantMessage || !lastAssistantMessage.trim()) {
logger.debug('HOOK', 'No assistant message in transcript - skipping summary', {
sessionId,
transcriptPath
});
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
logger.dataIn('HOOK', 'Stop: Requesting summary', {
hasLastAssistantMessage: !!lastAssistantMessage
});
// Send to worker - worker handles privacy check and database operations
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
const response = await workerHttpRequest('/api/sessions/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -59,11 +78,49 @@ export const summarizeHandler: EventHandler = {
});
if (!response.ok) {
// Return standard response even on failure (matches original behavior)
return { continue: true, suppressOutput: true };
}
logger.debug('HOOK', 'Summary request sent successfully');
logger.debug('HOOK', 'Summary request queued, waiting for completion');
// 2. Poll worker until pending work for this session is done.
// This keeps the Stop hook alive (120s timeout) so the SDK agent
// can finish processing the summary before SessionEnd kills the session.
const waitStart = Date.now();
while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
try {
const statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, {
timeoutMs: 5000
});
if (statusResponse.ok) {
const status = await statusResponse.json() as { queueLength?: number };
if ((status.queueLength ?? 0) === 0) {
logger.info('HOOK', 'Summary processing complete', {
waitedMs: Date.now() - waitStart
});
break;
}
}
} catch {
// Worker may be busy — keep polling
}
}
// 3. Complete the session — clean up active sessions map.
// This runs here in Stop (120s timeout) instead of SessionEnd (1.5s cap)
// so it reliably fires after summary work is done.
try {
await workerHttpRequest('/api/sessions/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentSessionId: sessionId }),
timeoutMs: 10_000
});
logger.info('HOOK', 'Session completed in Stop hook', { contentSessionId: sessionId });
} catch (err) {
logger.warn('HOOK', `Stop hook: session-complete failed: ${err instanceof Error ? err.message : err}`);
}
return { continue: true, suppressOutput: true };
}
+3 -1
View File
@@ -23,9 +23,11 @@ export const userMessageHandler: EventHandler = {
const project = basename(input.cwd ?? process.cwd());
// Fetch formatted context directly from worker API
// Only request ANSI colors for platforms that render them (claude-code)
const colorsParam = input.platform === 'claude-code' ? '&colors=true' : '';
try {
const response = await workerHttpRequest(
`/api/context/inject?project=${encodeURIComponent(project)}&colors=true`
`/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}`
);
if (!response.ok) {
+3 -1
View File
@@ -1,7 +1,7 @@
export interface NormalizedHookInput {
sessionId: string;
cwd: string;
platform?: string; // 'claude-code' or 'cursor'
platform?: string; // 'claude-code', 'cursor', 'gemini-cli', etc.
prompt?: string;
toolName?: string;
toolInput?: unknown;
@@ -10,6 +10,8 @@ export interface NormalizedHookInput {
// Cursor-specific fields
filePath?: string; // afterFileEdit
edits?: unknown[]; // afterFileEdit
// Platform-specific metadata (source, reason, trigger, mcp_context, etc.)
metadata?: Record<string, unknown>;
}
export interface HookResult {
+366
View File
@@ -0,0 +1,366 @@
/**
* OpenCode Plugin for claude-mem
*
* Integrates claude-mem persistent memory with OpenCode (110k+ stars).
* Runs inside OpenCode's Bun-based plugin runtime.
*
* Plugin hooks:
* - tool.execute.after: Captures tool execution observations
* - Bus events: session.created, message.updated, session.compacted,
* file.edited, session.deleted
*
* Custom tool:
* - claude_mem_search: Search memory database from within OpenCode
*/
// ============================================================================
// Minimal type declarations for OpenCode Plugin SDK
// These match the runtime API provided by @opencode-ai/plugin
// ============================================================================
interface OpenCodeProject {
name?: string;
path?: string;
}
interface OpenCodePluginContext {
client: unknown;
project: OpenCodeProject;
directory: string;
worktree: string;
serverUrl: URL;
$: unknown; // BunShell
}
interface ToolExecuteAfterInput {
tool: string;
sessionID: string;
callID: string;
args: Record<string, unknown>;
}
interface ToolExecuteAfterOutput {
title: string;
output: string;
metadata: Record<string, unknown>;
}
interface ToolDefinition {
description: string;
args: Record<string, unknown>;
execute: (args: Record<string, unknown>, context: unknown) => Promise<string>;
}
// Bus event payloads
interface SessionCreatedEvent {
event: {
sessionID: string;
directory?: string;
project?: string;
};
}
interface MessageUpdatedEvent {
event: {
sessionID: string;
role: string;
content: string;
};
}
interface SessionCompactedEvent {
event: {
sessionID: string;
summary?: string;
messageCount?: number;
};
}
interface FileEditedEvent {
event: {
sessionID: string;
path: string;
diff?: string;
};
}
interface SessionDeletedEvent {
event: {
sessionID: string;
};
}
// ============================================================================
// Constants
// ============================================================================
const WORKER_BASE_URL = "http://127.0.0.1:37777";
const MAX_TOOL_RESPONSE_LENGTH = 1000;
// ============================================================================
// Worker HTTP Client
// ============================================================================
async function workerPost(
path: string,
body: Record<string, unknown>,
): Promise<Record<string, unknown> | null> {
try {
const response = await fetch(`${WORKER_BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);
return null;
}
return (await response.json()) as Record<string, unknown>;
} catch (error: unknown) {
// Gracefully handle ECONNREFUSED — worker may not be running
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("ECONNREFUSED")) {
console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
}
return null;
}
}
function workerPostFireAndForget(
path: string,
body: Record<string, unknown>,
): void {
fetch(`${WORKER_BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("ECONNREFUSED")) {
console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
}
});
}
async function workerGetText(path: string): Promise<string | null> {
try {
const response = await fetch(`${WORKER_BASE_URL}${path}`);
if (!response.ok) {
console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);
return null;
}
return await response.text();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("ECONNREFUSED")) {
console.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);
}
return null;
}
}
// ============================================================================
// Session tracking
// ============================================================================
const contentSessionIdsByOpenCodeSessionId = new Map<string, string>();
const MAX_SESSION_MAP_ENTRIES = 1000;
function getOrCreateContentSessionId(openCodeSessionId: string): string {
if (!contentSessionIdsByOpenCodeSessionId.has(openCodeSessionId)) {
// Evict oldest entries when the map exceeds the cap (Map preserves insertion order)
while (contentSessionIdsByOpenCodeSessionId.size >= MAX_SESSION_MAP_ENTRIES) {
const oldestKey = contentSessionIdsByOpenCodeSessionId.keys().next().value;
if (oldestKey !== undefined) {
contentSessionIdsByOpenCodeSessionId.delete(oldestKey);
} else {
break;
}
}
contentSessionIdsByOpenCodeSessionId.set(
openCodeSessionId,
`opencode-${openCodeSessionId}-${Date.now()}`,
);
}
return contentSessionIdsByOpenCodeSessionId.get(openCodeSessionId)!;
}
// ============================================================================
// Plugin Entry Point
// ============================================================================
export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => {
const projectName = ctx.project?.name || "opencode";
console.log(`[claude-mem] OpenCode plugin loading (project: ${projectName})`);
return {
// ------------------------------------------------------------------
// Direct interceptor hooks
// ------------------------------------------------------------------
hooks: {
tool: {
execute: {
after: (
input: ToolExecuteAfterInput,
output: ToolExecuteAfterOutput,
) => {
const contentSessionId = getOrCreateContentSessionId(input.sessionID);
// Truncate long tool output
let toolResponseText = output.output || "";
if (toolResponseText.length > MAX_TOOL_RESPONSE_LENGTH) {
toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
}
workerPostFireAndForget("/api/sessions/observations", {
contentSessionId,
tool_name: input.tool,
tool_input: input.args || {},
tool_response: toolResponseText,
cwd: ctx.directory,
});
},
},
},
},
// ------------------------------------------------------------------
// Bus event handlers
// ------------------------------------------------------------------
event: (eventName: string, payload: unknown) => {
switch (eventName) {
case "session.created": {
const { event } = payload as SessionCreatedEvent;
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
workerPostFireAndForget("/api/sessions/init", {
contentSessionId,
project: projectName,
prompt: "",
});
break;
}
case "message.updated": {
const { event } = payload as MessageUpdatedEvent;
// Only capture assistant messages as observations
if (event.role !== "assistant") break;
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
let messageText = event.content || "";
if (messageText.length > MAX_TOOL_RESPONSE_LENGTH) {
messageText = messageText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
}
workerPostFireAndForget("/api/sessions/observations", {
contentSessionId,
tool_name: "assistant_message",
tool_input: {},
tool_response: messageText,
cwd: ctx.directory,
});
break;
}
case "session.compacted": {
const { event } = payload as SessionCompactedEvent;
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
workerPostFireAndForget("/api/sessions/summarize", {
contentSessionId,
last_assistant_message: event.summary || "",
});
break;
}
case "file.edited": {
const { event } = payload as FileEditedEvent;
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
workerPostFireAndForget("/api/sessions/observations", {
contentSessionId,
tool_name: "file_edit",
tool_input: { path: event.path },
tool_response: event.diff
? event.diff.slice(0, MAX_TOOL_RESPONSE_LENGTH)
: `File edited: ${event.path}`,
cwd: ctx.directory,
});
break;
}
case "session.deleted": {
const { event } = payload as SessionDeletedEvent;
const contentSessionId = contentSessionIdsByOpenCodeSessionId.get(
event.sessionID,
);
if (contentSessionId) {
workerPostFireAndForget("/api/sessions/complete", {
contentSessionId,
});
contentSessionIdsByOpenCodeSessionId.delete(event.sessionID);
}
break;
}
}
},
// ------------------------------------------------------------------
// Custom tools
// ------------------------------------------------------------------
tool: {
claude_mem_search: {
description:
"Search claude-mem memory database for past observations, sessions, and context",
args: {
query: {
type: "string",
description: "Search query for memory observations",
},
},
async execute(
args: Record<string, unknown>,
): Promise<string> {
const query = String(args.query || "");
if (!query) {
return "Please provide a search query.";
}
const text = await workerGetText(
`/api/search/observations?query=${encodeURIComponent(query)}&limit=10`,
);
if (!text) {
return "claude-mem worker is not running. Start it with: npx claude-mem start";
}
try {
const data = JSON.parse(text);
const items = Array.isArray(data.items) ? data.items : [];
if (items.length === 0) {
return `No results found for "${query}".`;
}
return items
.slice(0, 10)
.map((item: Record<string, unknown>, index: number) => {
const title = String(item.title || item.subtitle || "Untitled");
const project = item.project ? ` [${String(item.project)}]` : "";
return `${index + 1}. ${title}${project}`;
})
.join("\n");
} catch {
return "Failed to parse search results.";
}
},
} satisfies ToolDefinition,
},
};
};
export default ClaudeMemPlugin;
+173
View File
@@ -0,0 +1,173 @@
/**
* IDE Auto-Detection
*
* Detects which AI coding IDEs / tools are installed on the system by
* probing known config directories and checking for binaries in PATH.
*
* Pure Node.js — no Bun APIs used.
*/
import { execSync } from 'child_process';
import { existsSync, readdirSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { IS_WINDOWS } from '../utils/paths.js';
// ---------------------------------------------------------------------------
// IDE type and metadata
// ---------------------------------------------------------------------------
export interface IDEInfo {
/** Machine-readable identifier. */
id: string;
/** Human-readable label for display in prompts. */
label: string;
/** Whether the IDE was detected on this system. */
detected: boolean;
/** Whether claude-mem has implemented setup for this IDE. */
supported: boolean;
/** Short hint text shown in the multi-select. */
hint?: string;
}
// ---------------------------------------------------------------------------
// PATH helper
// ---------------------------------------------------------------------------
function isCommandInPath(command: string): boolean {
try {
const whichCommand = IS_WINDOWS ? 'where' : 'which';
execSync(`${whichCommand} ${command}`, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// VS Code extension directory scanner
// ---------------------------------------------------------------------------
function hasVscodeExtension(extensionNameFragment: string): boolean {
const extensionsDirectory = join(homedir(), '.vscode', 'extensions');
if (!existsSync(extensionsDirectory)) return false;
try {
const entries = readdirSync(extensionsDirectory);
return entries.some((entry) => entry.toLowerCase().includes(extensionNameFragment.toLowerCase()));
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Detection map
// ---------------------------------------------------------------------------
/**
* Detect all known IDEs and return an array of `IDEInfo` objects.
* Each entry indicates whether the IDE was found and whether claude-mem
* currently supports setting it up.
*/
export function detectInstalledIDEs(): IDEInfo[] {
const home = homedir();
return [
{
id: 'claude-code',
label: 'Claude Code',
detected: existsSync(join(home, '.claude')),
supported: true,
hint: 'recommended',
},
{
id: 'gemini-cli',
label: 'Gemini CLI',
detected: existsSync(join(home, '.gemini')),
supported: true,
},
{
id: 'opencode',
label: 'OpenCode',
detected:
existsSync(join(home, '.config', 'opencode')) || isCommandInPath('opencode'),
supported: true,
hint: 'plugin-based integration',
},
{
id: 'openclaw',
label: 'OpenClaw',
detected: existsSync(join(home, '.openclaw')),
supported: true,
hint: 'plugin-based integration',
},
{
id: 'windsurf',
label: 'Windsurf',
detected: existsSync(join(home, '.codeium', 'windsurf')),
supported: true,
},
{
id: 'codex-cli',
label: 'Codex CLI',
detected: existsSync(join(home, '.codex')),
supported: true,
hint: 'transcript-based integration',
},
{
id: 'cursor',
label: 'Cursor',
detected: existsSync(join(home, '.cursor')),
supported: true,
hint: 'hooks + MCP integration',
},
{
id: 'copilot-cli',
label: 'Copilot CLI',
detected: isCommandInPath('copilot'),
supported: true,
hint: 'MCP-based integration',
},
{
id: 'antigravity',
label: 'Antigravity',
detected: existsSync(join(home, '.gemini', 'antigravity')),
supported: true,
hint: 'MCP-based integration',
},
{
id: 'goose',
label: 'Goose',
detected:
existsSync(join(home, '.config', 'goose')) || isCommandInPath('goose'),
supported: true,
hint: 'MCP-based integration',
},
{
id: 'crush',
label: 'Crush',
detected: isCommandInPath('crush'),
supported: true,
hint: 'MCP-based integration',
},
{
id: 'roo-code',
label: 'Roo Code',
detected: hasVscodeExtension('roo-code'),
supported: true,
hint: 'MCP-based integration',
},
{
id: 'warp',
label: 'Warp',
detected: existsSync(join(home, '.warp')) || isCommandInPath('warp'),
supported: true,
hint: 'MCP-based integration',
},
];
}
/**
* Return only the IDEs that were detected on this system.
*/
export function getDetectedIDEs(): IDEInfo[] {
return detectInstalledIDEs().filter((ide) => ide.detected);
}
+564
View File
@@ -0,0 +1,564 @@
/**
* Install command for `npx claude-mem install`.
*
* Replaces the git-clone + build workflow. The npm package already ships
* a pre-built `plugin/` directory; this command copies it into the right
* locations and registers it with Claude Code.
*
* Pure Node.js — no Bun APIs used.
*/
import * as p from '@clack/prompts';
import pc from 'picocolors';
import { execSync } from 'child_process';
import { cpSync, existsSync, readFileSync, rmSync } from 'fs';
import { join } from 'path';
// Non-TTY detection: @clack/prompts crashes with ENOENT in non-TTY environments
const isInteractive = process.stdin.isTTY === true;
/** Run a list of tasks, falling back to plain console.log when non-TTY */
interface TaskDescriptor {
title: string;
task: (message: (msg: string) => void) => Promise<string>;
}
async function runTasks(tasks: TaskDescriptor[]): Promise<void> {
if (isInteractive) {
await p.tasks(tasks);
} else {
for (const t of tasks) {
const result = await t.task((msg: string) => console.log(` ${msg}`));
console.log(` ${result}`);
}
}
}
/** Log helpers that fall back to console.log in non-TTY */
const log = {
info: (msg: string) => isInteractive ? p.log.info(msg) : console.log(` ${msg}`),
success: (msg: string) => isInteractive ? p.log.success(msg) : console.log(` ${msg}`),
warn: (msg: string) => isInteractive ? p.log.warn(msg) : console.warn(` ${msg}`),
error: (msg: string) => isInteractive ? p.log.error(msg) : console.error(` ${msg}`),
};
import {
claudeSettingsPath,
ensureDirectoryExists,
installedPluginsPath,
IS_WINDOWS,
knownMarketplacesPath,
marketplaceDirectory,
npmPackagePluginDirectory,
npmPackageRootDirectory,
pluginCacheDirectory,
pluginsDirectory,
readPluginVersion,
writeJsonFileAtomic,
} from '../utils/paths.js';
import { readJsonSafe } from '../../utils/json-utils.js';
import { detectInstalledIDEs } from './ide-detection.js';
// ---------------------------------------------------------------------------
// Registration helpers
// ---------------------------------------------------------------------------
function registerMarketplace(): void {
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
knownMarketplaces['thedotmack'] = {
source: {
source: 'github',
repo: 'thedotmack/claude-mem',
},
installLocation: marketplaceDirectory(),
lastUpdated: new Date().toISOString(),
autoUpdate: true,
};
ensureDirectoryExists(pluginsDirectory());
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
}
function registerPlugin(version: string): void {
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
if (!installedPlugins.version) installedPlugins.version = 2;
if (!installedPlugins.plugins) installedPlugins.plugins = {};
const cachePath = pluginCacheDirectory(version);
const now = new Date().toISOString();
installedPlugins.plugins['claude-mem@thedotmack'] = [
{
scope: 'user',
installPath: cachePath,
version,
installedAt: now,
lastUpdated: now,
},
];
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
}
function enablePluginInClaudeSettings(): void {
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
if (!settings.enabledPlugins) settings.enabledPlugins = {};
settings.enabledPlugins['claude-mem@thedotmack'] = true;
writeJsonFileAtomic(claudeSettingsPath(), settings);
}
// ---------------------------------------------------------------------------
// IDE setup dispatcher
// ---------------------------------------------------------------------------
/** Returns a list of IDE IDs that failed setup. */
async function setupIDEs(selectedIDEs: string[]): Promise<string[]> {
const failedIDEs: string[] = [];
for (const ideId of selectedIDEs) {
switch (ideId) {
case 'claude-code': {
// Claude Code uses its native plugin CLI — two commands handle
// marketplace registration, plugin installation, and enablement.
try {
execSync(
'claude plugin marketplace add thedotmack/claude-mem && claude plugin install claude-mem',
{ stdio: 'inherit' },
);
log.success('Claude Code: plugin installed via CLI.');
} catch {
log.error('Claude Code: plugin install failed. Is `claude` CLI on your PATH?');
failedIDEs.push(ideId);
}
break;
}
case 'cursor': {
const { installCursorHooks, configureCursorMcp } = await import('../../services/integrations/CursorHooksInstaller.js');
const cursorResult = await installCursorHooks('user');
if (cursorResult === 0) {
const mcpResult = configureCursorMcp('user');
if (mcpResult === 0) {
log.success('Cursor: hooks + MCP installed.');
} else {
log.success('Cursor: hooks installed (MCP setup failed — run `npx claude-mem cursor mcp` to retry).');
}
} else {
log.error('Cursor: hook installation failed.');
failedIDEs.push(ideId);
}
break;
}
case 'gemini-cli': {
const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
const geminiResult = await installGeminiCliHooks();
if (geminiResult === 0) {
log.success('Gemini CLI: hooks installed.');
} else {
log.error('Gemini CLI: hook installation failed.');
failedIDEs.push(ideId);
}
break;
}
case 'opencode': {
const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js');
const openCodeResult = await installOpenCodeIntegration();
if (openCodeResult === 0) {
log.success('OpenCode: plugin installed.');
} else {
log.error('OpenCode: plugin installation failed.');
failedIDEs.push(ideId);
}
break;
}
case 'windsurf': {
const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
const windsurfResult = await installWindsurfHooks();
if (windsurfResult === 0) {
log.success('Windsurf: hooks installed.');
} else {
log.error('Windsurf: hook installation failed.');
failedIDEs.push(ideId);
}
break;
}
case 'openclaw': {
const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js');
const openClawResult = await installOpenClawIntegration();
if (openClawResult === 0) {
log.success('OpenClaw: plugin installed.');
} else {
log.error('OpenClaw: plugin installation failed.');
failedIDEs.push(ideId);
}
break;
}
case 'codex-cli': {
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
const codexResult = await installCodexCli();
if (codexResult === 0) {
log.success('Codex CLI: transcript watching configured.');
} else {
log.error('Codex CLI: integration setup failed.');
failedIDEs.push(ideId);
}
break;
}
case 'copilot-cli':
case 'antigravity':
case 'goose':
case 'crush':
case 'roo-code':
case 'warp': {
const { MCP_IDE_INSTALLERS } = await import('../../services/integrations/McpIntegrations.js');
const mcpInstaller = MCP_IDE_INSTALLERS[ideId];
if (mcpInstaller) {
const mcpResult = await mcpInstaller();
const allIDEs = detectInstalledIDEs();
const ideInfo = allIDEs.find((i) => i.id === ideId);
const ideLabel = ideInfo?.label ?? ideId;
if (mcpResult === 0) {
log.success(`${ideLabel}: MCP integration installed.`);
} else {
log.error(`${ideLabel}: MCP integration failed.`);
failedIDEs.push(ideId);
}
}
break;
}
default: {
const allIDEs = detectInstalledIDEs();
const ide = allIDEs.find((i) => i.id === ideId);
if (ide && !ide.supported) {
log.warn(`Support for ${ide.label} coming soon.`);
}
break;
}
}
}
return failedIDEs;
}
// ---------------------------------------------------------------------------
// Interactive IDE selection
// ---------------------------------------------------------------------------
async function promptForIDESelection(): Promise<string[]> {
const detectedIDEs = detectInstalledIDEs();
const detected = detectedIDEs.filter((ide) => ide.detected);
if (detected.length === 0) {
log.warn('No supported IDEs detected. Installing for Claude Code by default.');
return ['claude-code'];
}
const options = detected.map((ide) => ({
value: ide.id,
label: ide.label,
hint: ide.supported ? ide.hint : 'coming soon',
}));
const result = await p.multiselect({
message: 'Which IDEs do you use?',
options,
initialValues: detected
.filter((ide) => ide.supported)
.map((ide) => ide.id),
required: true,
});
if (p.isCancel(result)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
return result as string[];
}
// ---------------------------------------------------------------------------
// Core copy logic
// ---------------------------------------------------------------------------
function copyPluginToMarketplace(): void {
const marketplaceDir = marketplaceDirectory();
const packageRoot = npmPackageRootDirectory();
ensureDirectoryExists(marketplaceDir);
// Only copy directories/files that are actually needed at runtime.
// The npm package ships plugin/, package.json, node_modules/, openclaw/, dist/.
// When running from a dev checkout, the root contains many extra dirs
// (.claude, .agents, src, docs, etc.) that must NOT be copied.
const allowedTopLevelEntries = [
'plugin',
'package.json',
'package-lock.json',
'node_modules',
'openclaw',
'dist',
'LICENSE',
'README.md',
'CHANGELOG.md',
];
for (const entry of allowedTopLevelEntries) {
const sourcePath = join(packageRoot, entry);
const destPath = join(marketplaceDir, entry);
if (!existsSync(sourcePath)) continue;
// Clean replace: remove stale files from previous installs before copying
if (existsSync(destPath)) {
rmSync(destPath, { recursive: true, force: true });
}
cpSync(sourcePath, destPath, {
recursive: true,
force: true,
});
}
}
function copyPluginToCache(version: string): void {
const sourcePluginDirectory = npmPackagePluginDirectory();
const cachePath = pluginCacheDirectory(version);
// Clean replace: remove stale cache before copying
rmSync(cachePath, { recursive: true, force: true });
ensureDirectoryExists(cachePath);
cpSync(sourcePluginDirectory, cachePath, { recursive: true, force: true });
}
// ---------------------------------------------------------------------------
// npm install in marketplace dir
// ---------------------------------------------------------------------------
function runNpmInstallInMarketplace(): void {
const marketplaceDir = marketplaceDirectory();
const packageJsonPath = join(marketplaceDir, 'package.json');
if (!existsSync(packageJsonPath)) return;
execSync('npm install --production', {
cwd: marketplaceDir,
stdio: 'pipe',
...(IS_WINDOWS ? { shell: true as const } : {}),
});
}
// ---------------------------------------------------------------------------
// Trigger smart-install for Bun / uv
// ---------------------------------------------------------------------------
function runSmartInstall(): boolean {
const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js');
if (!existsSync(smartInstallPath)) {
log.warn('smart-install.js not found — skipping Bun/uv auto-install.');
return false;
}
try {
execSync(`node "${smartInstallPath}"`, {
stdio: 'inherit',
...(IS_WINDOWS ? { shell: true as const } : {}),
});
return true;
} catch {
log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.');
return false;
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface InstallOptions {
/** When provided, skip the interactive IDE multi-select and use this IDE. */
ide?: string;
}
export async function runInstallCommand(options: InstallOptions = {}): Promise<void> {
const version = readPluginVersion();
if (isInteractive) {
p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
} else {
console.log('claude-mem install');
}
log.info(`Version: ${pc.cyan(version)}`);
log.info(`Platform: ${process.platform} (${process.arch})`);
// Check for existing installation
const marketplaceDir = marketplaceDirectory();
const alreadyInstalled = existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'));
if (alreadyInstalled) {
// Read existing version
try {
const existingPluginJson = JSON.parse(
readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'),
);
log.warn(`Existing installation detected (v${existingPluginJson.version ?? 'unknown'}).`);
} catch {
log.warn('Existing installation detected.');
}
if (process.stdin.isTTY) {
const shouldContinue = await p.confirm({
message: 'Overwrite existing installation?',
initialValue: true,
});
if (p.isCancel(shouldContinue) || !shouldContinue) {
p.cancel('Installation cancelled.');
process.exit(0);
}
}
}
// IDE selection
let selectedIDEs: string[];
if (options.ide) {
selectedIDEs = [options.ide];
const allIDEs = detectInstalledIDEs();
const match = allIDEs.find((i) => i.id === options.ide);
if (match && !match.supported) {
log.error(`Support for ${match.label} coming soon.`);
process.exit(1);
}
if (!match) {
log.error(`Unknown IDE: ${options.ide}`);
log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
process.exit(1);
}
} else if (process.stdin.isTTY) {
selectedIDEs = await promptForIDESelection();
} else {
// Non-interactive: default to claude-code
selectedIDEs = ['claude-code'];
}
// Non-Claude-Code IDEs need the manual file copy / registration flow.
// Claude Code handles its own installation via `claude plugin install`.
const needsManualInstall = selectedIDEs.some((id) => id !== 'claude-code');
if (needsManualInstall) {
await runTasks([
{
title: 'Copying plugin files',
task: async (message) => {
message('Copying to marketplace directory...');
copyPluginToMarketplace();
return `Plugin files copied ${pc.green('OK')}`;
},
},
{
title: 'Caching plugin version',
task: async (message) => {
message(`Caching v${version}...`);
copyPluginToCache(version);
return `Plugin cached (v${version}) ${pc.green('OK')}`;
},
},
{
title: 'Registering marketplace',
task: async () => {
registerMarketplace();
return `Marketplace registered ${pc.green('OK')}`;
},
},
{
title: 'Registering plugin',
task: async () => {
registerPlugin(version);
return `Plugin registered ${pc.green('OK')}`;
},
},
{
title: 'Enabling plugin in Claude settings',
task: async () => {
enablePluginInClaudeSettings();
return `Plugin enabled ${pc.green('OK')}`;
},
},
{
title: 'Installing dependencies',
task: async (message) => {
message('Running npm install...');
try {
runNpmInstallInMarketplace();
return `Dependencies installed ${pc.green('OK')}`;
} catch {
return `Dependencies may need manual install ${pc.yellow('!')}`;
}
},
},
{
title: 'Setting up Bun and uv',
task: async (message) => {
message('Running smart-install...');
return runSmartInstall()
? `Runtime dependencies ready ${pc.green('OK')}`
: `Runtime setup may need attention ${pc.yellow('!')}`;
},
},
]);
}
// IDE-specific setup
const failedIDEs = await setupIDEs(selectedIDEs);
// Summary
const installStatus = failedIDEs.length > 0 ? 'Installation Partial' : 'Installation Complete';
const summaryLines = [
`Version: ${pc.cyan(version)}`,
`Plugin dir: ${pc.cyan(marketplaceDir)}`,
`IDEs: ${pc.cyan(selectedIDEs.join(', '))}`,
];
if (failedIDEs.length > 0) {
summaryLines.push(`Failed: ${pc.red(failedIDEs.join(', '))}`);
}
if (isInteractive) {
p.note(summaryLines.join('\n'), installStatus);
} else {
console.log(`\n ${installStatus}`);
summaryLines.forEach(l => console.log(` ${l}`));
}
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
const nextSteps = [
'Open Claude Code and start a conversation -- memory is automatic!',
`View your memories: ${pc.underline(`http://localhost:${workerPort}`)}`,
`Search past work: use ${pc.bold('/mem-search')} in Claude Code`,
`Start worker: ${pc.bold('npx claude-mem start')}`,
];
if (isInteractive) {
p.note(nextSteps.join('\n'), 'Next Steps');
if (failedIDEs.length > 0) {
p.outro(pc.yellow('claude-mem installed with some IDE setup failures.'));
} else {
p.outro(pc.green('claude-mem installed successfully!'));
}
} else {
console.log('\n Next Steps');
nextSteps.forEach(l => console.log(` ${l}`));
if (failedIDEs.length > 0) {
console.log('\nclaude-mem installed with some IDE setup failures.');
process.exitCode = 1;
} else {
console.log('\nclaude-mem installed successfully!');
}
}
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Runtime command routing for `npx claude-mem start|stop|restart|status|search|transcript`.
*
* These commands delegate to the installed plugin's worker-service.cjs via Bun,
* or hit the worker's HTTP API directly (for `search`).
*
* Pure Node.js — no Bun APIs used.
*/
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import pc from 'picocolors';
import { resolveBunBinaryPath } from '../utils/bun-resolver.js';
import { isPluginInstalled, marketplaceDirectory } from '../utils/paths.js';
// ---------------------------------------------------------------------------
// Installation guard
// ---------------------------------------------------------------------------
function ensureInstalledOrExit(): void {
if (!isPluginInstalled()) {
console.error(pc.red('claude-mem is not installed.'));
console.error(`Run: ${pc.bold('npx claude-mem install')}`);
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Bun guard
// ---------------------------------------------------------------------------
function resolveBunOrExit(): string {
const bunPath = resolveBunBinaryPath();
if (!bunPath) {
console.error(pc.red('Bun not found.'));
console.error('Install Bun: https://bun.sh');
console.error('After installation, restart your terminal.');
process.exit(1);
}
return bunPath;
}
// ---------------------------------------------------------------------------
// Worker-service path
// ---------------------------------------------------------------------------
function workerServiceScriptPath(): string {
return join(marketplaceDirectory(), 'plugin', 'scripts', 'worker-service.cjs');
}
// ---------------------------------------------------------------------------
// Spawn helper
// ---------------------------------------------------------------------------
function spawnBunWorkerCommand(command: string, extraArgs: string[] = []): void {
ensureInstalledOrExit();
const bunPath = resolveBunOrExit();
const workerScript = workerServiceScriptPath();
if (!existsSync(workerScript)) {
console.error(pc.red(`Worker script not found at: ${workerScript}`));
console.error('The installation may be corrupted. Try: npx claude-mem install');
process.exit(1);
}
const args = [workerScript, command, ...extraArgs];
const child = spawn(bunPath, args, {
stdio: 'inherit',
cwd: marketplaceDirectory(),
env: process.env,
});
child.on('error', (error) => {
console.error(pc.red(`Failed to start Bun: ${error.message}`));
process.exit(1);
});
child.on('close', (exitCode) => {
process.exit(exitCode ?? 0);
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function runStartCommand(): void {
spawnBunWorkerCommand('start');
}
export function runStopCommand(): void {
spawnBunWorkerCommand('stop');
}
export function runRestartCommand(): void {
spawnBunWorkerCommand('restart');
}
export function runStatusCommand(): void {
spawnBunWorkerCommand('status');
}
/**
* Search the worker API at `GET /api/search?q=<query>`.
*/
export async function runSearchCommand(queryParts: string[]): Promise<void> {
ensureInstalledOrExit();
const query = queryParts.join(' ').trim();
if (!query) {
console.error(pc.red('Usage: npx claude-mem search <query>'));
process.exit(1);
}
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
const searchUrl = `http://127.0.0.1:${workerPort}/api/search?q=${encodeURIComponent(query)}`;
try {
const response = await fetch(searchUrl);
if (!response.ok) {
if (response.status === 404) {
console.error(pc.red('Search endpoint not found. Is the worker running?'));
console.error(`Try: ${pc.bold('npx claude-mem start')}`);
process.exit(1);
}
console.error(pc.red(`Search failed: HTTP ${response.status}`));
process.exit(1);
}
const data = await response.json();
if (typeof data === 'object' && data !== null) {
console.log(JSON.stringify(data, null, 2));
} else {
console.log(data);
}
} catch (error: any) {
if (error?.cause?.code === 'ECONNREFUSED' || error?.message?.includes('ECONNREFUSED')) {
console.error(pc.red('Worker is not running.'));
console.error(`Start it with: ${pc.bold('npx claude-mem start')}`);
process.exit(1);
}
console.error(pc.red(`Search failed: ${error.message}`));
process.exit(1);
}
}
/**
* Start the transcript watcher via Bun.
*/
export function runTranscriptWatchCommand(): void {
ensureInstalledOrExit();
const bunPath = resolveBunOrExit();
const transcriptWatcherPath = join(
marketplaceDirectory(),
'plugin',
'scripts',
'transcript-watcher.cjs',
);
if (!existsSync(transcriptWatcherPath)) {
// Fall back to worker-service with transcript subcommand
spawnBunWorkerCommand('transcript', ['watch']);
return;
}
const child = spawn(bunPath, [transcriptWatcherPath, 'watch'], {
stdio: 'inherit',
cwd: marketplaceDirectory(),
env: process.env,
});
child.on('error', (error) => {
console.error(pc.red(`Failed to start transcript watcher: ${error.message}`));
process.exit(1);
});
child.on('close', (exitCode) => {
process.exit(exitCode ?? 0);
});
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Uninstall command for `npx claude-mem uninstall`.
*
* Removes the plugin from the marketplace directory, cache, plugin
* registrations, and Claude settings. Optionally cleans up IDE-specific
* configurations.
*
* Pure Node.js — no Bun APIs used.
*/
import * as p from '@clack/prompts';
import pc from 'picocolors';
import { existsSync, rmSync } from 'fs';
import { join } from 'path';
import {
claudeSettingsPath,
installedPluginsPath,
isPluginInstalled,
knownMarketplacesPath,
marketplaceDirectory,
pluginsDirectory,
writeJsonFileAtomic,
} from '../utils/paths.js';
import { readJsonSafe } from '../../utils/json-utils.js';
// ---------------------------------------------------------------------------
// Cleanup helpers
// ---------------------------------------------------------------------------
function removeMarketplaceDirectory(): boolean {
const marketplaceDir = marketplaceDirectory();
if (existsSync(marketplaceDir)) {
rmSync(marketplaceDir, { recursive: true, force: true });
return true;
}
return false;
}
function removeCacheDirectory(): boolean {
const cacheDirectory = join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem');
if (existsSync(cacheDirectory)) {
rmSync(cacheDirectory, { recursive: true, force: true });
return true;
}
return false;
}
function removeFromKnownMarketplaces(): void {
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
if (knownMarketplaces['thedotmack']) {
delete knownMarketplaces['thedotmack'];
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
}
}
function removeFromInstalledPlugins(): void {
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
if (installedPlugins.plugins?.['claude-mem@thedotmack']) {
delete installedPlugins.plugins['claude-mem@thedotmack'];
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
}
}
function removeFromClaudeSettings(): void {
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
delete settings.enabledPlugins['claude-mem@thedotmack'];
writeJsonFileAtomic(claudeSettingsPath(), settings);
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function runUninstallCommand(): Promise<void> {
p.intro(pc.bgRed(pc.white(' claude-mem uninstall ')));
if (!isPluginInstalled()) {
p.log.warn('claude-mem does not appear to be installed.');
// Still offer to clean up partial state
if (process.stdin.isTTY) {
const shouldCleanup = await p.confirm({
message: 'Clean up any remaining registration data anyway?',
initialValue: false,
});
if (p.isCancel(shouldCleanup) || !shouldCleanup) {
p.outro('Nothing to do.');
return;
}
} else {
p.outro('Nothing to do.');
return;
}
} else if (process.stdin.isTTY) {
const shouldContinue = await p.confirm({
message: 'Are you sure you want to uninstall claude-mem?',
initialValue: false,
});
if (p.isCancel(shouldContinue) || !shouldContinue) {
p.cancel('Uninstall cancelled.');
return;
}
}
// Stop the worker and wait for it to exit before deleting files
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
try {
await fetch(`http://127.0.0.1:${workerPort}/api/admin/shutdown`, {
method: 'POST',
signal: AbortSignal.timeout(5000),
});
// Poll health endpoint until worker is gone (max 10s)
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 500));
try {
await fetch(`http://127.0.0.1:${workerPort}/api/health`, {
signal: AbortSignal.timeout(1000),
});
// Still alive — keep waiting
} catch {
break; // Connection refused = worker is gone
}
}
p.log.info('Worker service stopped.');
} catch {
// Worker may not be running — that is fine
}
await p.tasks([
{
title: 'Removing marketplace directory',
task: async () => {
const removed = removeMarketplaceDirectory();
return removed
? `Marketplace directory removed ${pc.green('OK')}`
: `Marketplace directory not found ${pc.dim('skipped')}`;
},
},
{
title: 'Removing cache directory',
task: async () => {
const removed = removeCacheDirectory();
return removed
? `Cache directory removed ${pc.green('OK')}`
: `Cache directory not found ${pc.dim('skipped')}`;
},
},
{
title: 'Removing marketplace registration',
task: async () => {
removeFromKnownMarketplaces();
return `Marketplace registration removed ${pc.green('OK')}`;
},
},
{
title: 'Removing plugin registration',
task: async () => {
removeFromInstalledPlugins();
return `Plugin registration removed ${pc.green('OK')}`;
},
},
{
title: 'Removing from Claude settings',
task: async () => {
removeFromClaudeSettings();
return `Claude settings updated ${pc.green('OK')}`;
},
},
]);
// Remove IDE-specific hooks and config (best-effort, each is independent)
const ideCleanups: Array<{ label: string; fn: () => Promise<number> | number }> = [
{ label: 'Gemini CLI hooks', fn: async () => {
const { uninstallGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
return uninstallGeminiCliHooks();
}},
{ label: 'Windsurf hooks', fn: async () => {
const { uninstallWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
return uninstallWindsurfHooks();
}},
{ label: 'OpenCode plugin', fn: async () => {
const { uninstallOpenCodePlugin } = await import('../../services/integrations/OpenCodeInstaller.js');
return uninstallOpenCodePlugin();
}},
{ label: 'OpenClaw plugin', fn: async () => {
const { uninstallOpenClawPlugin } = await import('../../services/integrations/OpenClawInstaller.js');
return uninstallOpenClawPlugin();
}},
{ label: 'Codex CLI', fn: async () => {
const { uninstallCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
return uninstallCodexCli();
}},
];
for (const { label, fn } of ideCleanups) {
try {
const result = await fn();
if (result === 0) {
p.log.info(`${label}: removed.`);
}
} catch {
// IDE not configured or uninstaller errored — skip silently
}
}
p.note(
[
`Your data directory at ${pc.cyan('~/.claude-mem')} was preserved.`,
'To remove it manually: rm -rf ~/.claude-mem',
].join('\n'),
'Note',
);
p.outro(pc.green('claude-mem has been uninstalled.'));
}
+174
View File
@@ -0,0 +1,174 @@
/**
* NPX CLI entry point for claude-mem.
*
* Usage:
* npx claude-mem → interactive install
* npx claude-mem install → interactive install
* npx claude-mem install --ide <id> → direct IDE setup
* npx claude-mem update → update to latest version
* npx claude-mem uninstall → remove plugin and IDE configs
* npx claude-mem version → print version
* npx claude-mem start → start worker service
* npx claude-mem stop → stop worker service
* npx claude-mem restart → restart worker service
* npx claude-mem status → show worker status
* npx claude-mem search <query> → search observations
* npx claude-mem transcript watch → start transcript watcher
*
* This file is pure Node.js — Bun is NOT required for install commands.
* Runtime commands (`start`, `stop`, etc.) delegate to Bun via the installed plugin.
*/
import pc from 'picocolors';
import { readPluginVersion } from './utils/paths.js';
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
const command = args[0]?.toLowerCase() ?? '';
// ---------------------------------------------------------------------------
// Help text
// ---------------------------------------------------------------------------
function printHelp(): void {
const version = readPluginVersion();
console.log(`
${pc.bold('claude-mem')} v${version} — persistent memory for AI coding assistants
${pc.bold('Install Commands')} (no Bun required):
${pc.cyan('npx claude-mem')} Interactive install
${pc.cyan('npx claude-mem install')} Interactive install
${pc.cyan('npx claude-mem install --ide <id>')} Install for specific IDE
${pc.cyan('npx claude-mem update')} Update to latest version
${pc.cyan('npx claude-mem uninstall')} Remove plugin and configs
${pc.cyan('npx claude-mem version')} Print version
${pc.bold('Runtime Commands')} (requires Bun, delegates to installed plugin):
${pc.cyan('npx claude-mem start')} Start worker service
${pc.cyan('npx claude-mem stop')} Stop worker service
${pc.cyan('npx claude-mem restart')} Restart worker service
${pc.cyan('npx claude-mem status')} Show worker status
${pc.cyan('npx claude-mem search <query>')} Search observations
${pc.cyan('npx claude-mem transcript watch')} Start transcript watcher
${pc.bold('IDE Identifiers')}:
claude-code, cursor, gemini-cli, opencode, openclaw,
windsurf, codex-cli, copilot-cli, antigravity, goose,
crush, roo-code, warp
`);
}
// ---------------------------------------------------------------------------
// Command routing
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
switch (command) {
// -- No command: default to install ------------------------------------
case '': {
const { runInstallCommand } = await import('./commands/install.js');
await runInstallCommand();
break;
}
// -- Install -----------------------------------------------------------
case 'install': {
const ideIndex = args.indexOf('--ide');
const ideValue = ideIndex !== -1 ? args[ideIndex + 1] : undefined;
const { runInstallCommand } = await import('./commands/install.js');
await runInstallCommand({ ide: ideValue });
break;
}
// -- Update (alias for install — overwrite with latest) ----------------
case 'update':
case 'upgrade': {
const { runInstallCommand } = await import('./commands/install.js');
await runInstallCommand();
break;
}
// -- Uninstall ---------------------------------------------------------
case 'uninstall':
case 'remove': {
const { runUninstallCommand } = await import('./commands/uninstall.js');
await runUninstallCommand();
break;
}
// -- Version -----------------------------------------------------------
case 'version':
case '--version':
case '-v': {
console.log(readPluginVersion());
break;
}
// -- Help --------------------------------------------------------------
case 'help':
case '--help':
case '-h': {
printHelp();
break;
}
// -- Runtime: start / stop / restart / status --------------------------
case 'start': {
const { runStartCommand } = await import('./commands/runtime.js');
runStartCommand();
break;
}
case 'stop': {
const { runStopCommand } = await import('./commands/runtime.js');
runStopCommand();
break;
}
case 'restart': {
const { runRestartCommand } = await import('./commands/runtime.js');
runRestartCommand();
break;
}
case 'status': {
const { runStatusCommand } = await import('./commands/runtime.js');
runStatusCommand();
break;
}
// -- Search ------------------------------------------------------------
case 'search': {
const { runSearchCommand } = await import('./commands/runtime.js');
await runSearchCommand(args.slice(1));
break;
}
// -- Transcript --------------------------------------------------------
case 'transcript': {
const subCommand = args[1]?.toLowerCase();
if (subCommand === 'watch') {
const { runTranscriptWatchCommand } = await import('./commands/runtime.js');
runTranscriptWatchCommand();
} else {
console.error(pc.red(`Unknown transcript subcommand: ${subCommand ?? '(none)'}`));
console.error(`Usage: npx claude-mem transcript watch`);
process.exit(1);
}
break;
}
// -- Unknown -----------------------------------------------------------
default: {
console.error(pc.red(`Unknown command: ${command}`));
console.error(`Run ${pc.bold('npx claude-mem --help')} for usage information.`);
process.exit(1);
}
}
}
main().catch((error) => {
console.error(pc.red('Fatal error:'), error.message || error);
process.exit(1);
});
+85
View File
@@ -0,0 +1,85 @@
/**
* Bun binary resolution utility.
*
* Extracted from `plugin/scripts/bun-runner.js` so that the NPX CLI
* can locate Bun without duplicating the search logic.
*
* Pure Node.js — no Bun APIs used.
*/
import { spawnSync } from 'child_process';
import { existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { IS_WINDOWS } from './paths.js';
/**
* Well-known locations where Bun might be installed, beyond PATH.
* Order matches the search priority in bun-runner.js and smart-install.js.
*/
function bunCandidatePaths(): string[] {
if (IS_WINDOWS) {
return [
join(homedir(), '.bun', 'bin', 'bun.exe'),
join(process.env.USERPROFILE || homedir(), '.bun', 'bin', 'bun.exe'),
];
}
return [
join(homedir(), '.bun', 'bin', 'bun'),
'/usr/local/bin/bun',
'/opt/homebrew/bin/bun',
'/home/linuxbrew/.linuxbrew/bin/bun',
];
}
/**
* Attempt to locate the Bun executable.
*
* 1. Check PATH via `which` / `where`.
* 2. Probe well-known installation directories.
*
* Returns the absolute path to the binary, `'bun'` if it is in PATH,
* or `null` if Bun cannot be found.
*/
export function resolveBunBinaryPath(): string | null {
// Try PATH first
const whichCommand = IS_WINDOWS ? 'where' : 'which';
const pathCheck = spawnSync(whichCommand, ['bun'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
shell: IS_WINDOWS,
});
if (pathCheck.status === 0 && pathCheck.stdout.trim()) {
return 'bun'; // Available in PATH — use short name
}
// Probe known install locations
for (const candidatePath of bunCandidatePaths()) {
if (existsSync(candidatePath)) {
return candidatePath;
}
}
return null;
}
/**
* Get the installed Bun version string (e.g. `"1.2.3"`), or `null`
* if Bun is not available.
*/
export function getBunVersionString(): string | null {
const bunPath = resolveBunBinaryPath();
if (!bunPath) return null;
try {
const result = spawnSync(bunPath, ['--version'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
shell: IS_WINDOWS,
});
return result.status === 0 ? result.stdout.trim() : null;
} catch {
return null;
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Shared path utilities for the NPX CLI.
*
* All platform-specific path logic is centralized here so that every command
* resolves directories in exactly the same way, regardless of OS.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
// ---------------------------------------------------------------------------
// Platform detection
// ---------------------------------------------------------------------------
export const IS_WINDOWS = process.platform === 'win32';
// ---------------------------------------------------------------------------
// Core paths
// ---------------------------------------------------------------------------
/** Root of the Claude Code config directory. */
export function claudeConfigDirectory(): string {
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
}
/** Marketplace install directory for thedotmack. */
export function marketplaceDirectory(): string {
return join(claudeConfigDirectory(), 'plugins', 'marketplaces', 'thedotmack');
}
/** Top-level plugins directory. */
export function pluginsDirectory(): string {
return join(claudeConfigDirectory(), 'plugins');
}
/** Path to `known_marketplaces.json`. */
export function knownMarketplacesPath(): string {
return join(pluginsDirectory(), 'known_marketplaces.json');
}
/** Path to `installed_plugins.json`. */
export function installedPluginsPath(): string {
return join(pluginsDirectory(), 'installed_plugins.json');
}
/** Path to `~/.claude/settings.json`. */
export function claudeSettingsPath(): string {
return join(claudeConfigDirectory(), 'settings.json');
}
/** Plugin cache directory for a specific version. */
export function pluginCacheDirectory(version: string): string {
return join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem', version);
}
/** claude-mem data directory (default `~/.claude-mem`). */
export function claudeMemDataDirectory(): string {
return join(homedir(), '.claude-mem');
}
// ---------------------------------------------------------------------------
// NPM package root (where the NPX package lives on disk)
// ---------------------------------------------------------------------------
/**
* Resolve the root of the installed npm package.
*
* After bundling, the CLI entry point lives at `<pkg>/dist/npx-cli/index.js`.
* Walking up 2 levels from `import.meta.url` reaches the package root
* where `plugin/` and `package.json` can be found.
*/
export function npmPackageRootDirectory(): string {
const currentFilePath = fileURLToPath(import.meta.url);
// <pkg>/dist/npx-cli/index.js -> up 2 levels -> <pkg>
const root = join(dirname(currentFilePath), '..', '..');
if (!existsSync(join(root, 'package.json'))) {
throw new Error(
`npmPackageRootDirectory: expected package.json at ${root}. ` +
`Bundle structure may have changed — update the path walk.`,
);
}
return root;
}
/**
* Path to the `plugin/` directory bundled inside the npm package.
*/
export function npmPackagePluginDirectory(): string {
return join(npmPackageRootDirectory(), 'plugin');
}
// ---------------------------------------------------------------------------
// Version helpers
// ---------------------------------------------------------------------------
/**
* Read the current plugin version from the npm package's
* `plugin/.claude-plugin/plugin.json` (preferred) or from `package.json`.
*/
export function readPluginVersion(): string {
// Try plugin.json first (authoritative for plugin version)
const pluginJsonPath = join(npmPackagePluginDirectory(), '.claude-plugin', 'plugin.json');
if (existsSync(pluginJsonPath)) {
try {
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
if (pluginJson.version) return pluginJson.version;
} catch {
// Fall through to package.json
}
}
// Fall back to package.json at package root
const packageJsonPath = join(npmPackageRootDirectory(), 'package.json');
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
if (packageJson.version) return packageJson.version;
} catch {
// Unable to read
}
}
return '0.0.0';
}
// ---------------------------------------------------------------------------
// Installation detection
// ---------------------------------------------------------------------------
/** Returns true if the plugin appears to be installed in the marketplace dir. */
export function isPluginInstalled(): boolean {
const marketplaceDir = marketplaceDirectory();
return existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'));
}
// ---------------------------------------------------------------------------
// JSON file helpers
// ---------------------------------------------------------------------------
export function ensureDirectoryExists(directoryPath: string): void {
if (!existsSync(directoryPath)) {
mkdirSync(directoryPath, { recursive: true });
}
}
/**
* @deprecated Use `readJsonSafe` from `../../utils/json-utils.js` instead.
* Kept as re-export for backward compatibility.
*/
export { readJsonSafe } from '../../utils/json-utils.js';
export function writeJsonFileAtomic(filepath: string, data: any): void {
ensureDirectoryExists(dirname(filepath));
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
+1 -1
View File
@@ -75,7 +75,7 @@ export function parseObservations(text: string, correlationId?: string): ParsedO
const cleanedConcepts = concepts.filter(c => c !== finalType);
if (cleanedConcepts.length !== concepts.length) {
logger.error('PARSER', 'Removed observation type from concepts array', {
logger.debug('PARSER', 'Removed observation type from concepts array', {
correlationId,
type: finalType,
originalConcepts: concepts,
+6 -2
View File
@@ -116,7 +116,11 @@ export function buildObservationPrompt(obs: Observation): string {
<occurred_at>${new Date(obs.created_at_epoch).toISOString()}</occurred_at>${obs.cwd ? `\n <working_directory>${obs.cwd}</working_directory>` : ''}
<parameters>${JSON.stringify(toolInput, null, 2)}</parameters>
<outcome>${JSON.stringify(toolOutput, null, 2)}</outcome>
</observed_from_primary_session>`;
</observed_from_primary_session>
Return either one or more <observation>...</observation> blocks, or an empty response if this tool use should be skipped.
Concrete debugging findings from logs, queue state, database rows, session routing, or code-path inspection count as durable discoveries and should be recorded.
Never reply with prose such as "Skipping", "No substantive tool executions", or any explanation outside XML. Non-XML text is discarded.`;
}
/**
@@ -235,4 +239,4 @@ ${mode.prompts.format_examples}
${mode.prompts.footer}
${mode.prompts.header_memory_continued}`;
}
}
+35 -8
View File
@@ -10,6 +10,7 @@
*/
import path from 'path';
import net from 'net';
import { readFileSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { MARKETPLACE_ROOT } from '../../shared/paths.js';
@@ -35,17 +36,43 @@ async function httpRequestToWorker(
}
/**
* Check if a port is in use by querying the health endpoint
* Check if a port is in use by attempting an atomic socket bind.
* More reliable than HTTP health check for daemon spawn guards —
* prevents TOCTOU race where two daemons both see "port free" via
* HTTP and then both try to listen() (upstream bug workaround).
*
* Falls back to HTTP health check on Windows where socket bind
* behavior differs.
*/
export async function isPortInUse(port: number): Promise<boolean> {
try {
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
return response.ok;
} catch (error) {
// [ANTI-PATTERN IGNORED]: Health check polls every 500ms, logging would flood
return false;
if (process.platform === 'win32') {
// APPROVED OVERRIDE: Windows keeps HTTP health check because socket bind
// semantics differ (SO_REUSEADDR defaults, firewall prompts). The TOCTOU
// race remains on Windows but is an accepted limitation — the atomic
// socket approach would cause false positives or UAC popups.
try {
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
return response.ok;
} catch {
return false;
}
}
// Unix: atomic socket bind check — no TOCTOU race
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
resolve(true);
} else {
resolve(false);
}
});
server.once('listening', () => {
server.close(() => resolve(false));
});
server.listen(port, '127.0.0.1');
});
}
/**
@@ -0,0 +1,373 @@
/**
* CodexCliInstaller - Codex CLI integration for claude-mem
*
* Uses transcript-only watching (no notify hook). The watcher infrastructure
* already exists in src/services/transcripts/. This installer:
*
* 1. Writes/merges transcript-watch config to ~/.claude-mem/transcript-watch.json
* 2. Sets up watch for ~/.codex/sessions/**\/*.jsonl using existing watcher
* 3. Injects context via ~/.codex/AGENTS.md (Codex reads this natively)
*
* Anti-patterns:
* - Does NOT add notify hooks -- transcript watching is sufficient
* - Does NOT modify existing transcript watcher infrastructure
* - Does NOT overwrite existing transcript-watch.json -- merges only
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { replaceTaggedContent } from '../../utils/claude-md-utils.js';
import {
DEFAULT_CONFIG_PATH,
DEFAULT_STATE_PATH,
SAMPLE_CONFIG,
} from '../transcripts/config.js';
import type { TranscriptWatchConfig, WatchTarget } from '../transcripts/types.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CODEX_DIR = path.join(homedir(), '.codex');
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
const CLAUDE_MEM_DIR = path.join(homedir(), '.claude-mem');
/**
* The watch name used to identify the Codex CLI entry in transcript-watch.json.
* Must match the name in SAMPLE_CONFIG for merging to work correctly.
*/
const CODEX_WATCH_NAME = 'codex';
// ---------------------------------------------------------------------------
// Transcript Watch Config Merging
// ---------------------------------------------------------------------------
/**
* Load existing transcript-watch.json, or return an empty config scaffold.
* Never throws -- returns a valid empty config on any parse error.
*/
function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig {
const configPath = DEFAULT_CONFIG_PATH;
if (!existsSync(configPath)) {
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
}
try {
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
// Ensure required fields exist
if (!parsed.version) parsed.version = 1;
if (!parsed.watches) parsed.watches = [];
if (!parsed.schemas) parsed.schemas = {};
if (!parsed.stateFile) parsed.stateFile = DEFAULT_STATE_PATH;
return parsed;
} catch (parseError) {
logger.error('CODEX', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error);
// Back up corrupt file
const backupPath = `${configPath}.backup.${Date.now()}`;
writeFileSync(backupPath, readFileSync(configPath));
console.warn(` Backed up corrupt transcript-watch.json to ${backupPath}`);
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
}
}
/**
* Merge Codex watch configuration into existing transcript-watch.json.
*
* - If a watch with name 'codex' already exists, it is replaced in-place.
* - If the 'codex' schema already exists, it is replaced in-place.
* - All other watches and schemas are preserved untouched.
*/
function mergeCodexWatchConfig(existingConfig: TranscriptWatchConfig): TranscriptWatchConfig {
const merged = { ...existingConfig };
// Merge schemas: add/replace the codex schema
merged.schemas = { ...merged.schemas };
const codexSchema = SAMPLE_CONFIG.schemas?.[CODEX_WATCH_NAME];
if (codexSchema) {
merged.schemas[CODEX_WATCH_NAME] = codexSchema;
}
// Merge watches: add/replace the codex watch entry
const codexWatchFromSample = SAMPLE_CONFIG.watches.find(
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
);
if (codexWatchFromSample) {
const existingWatchIndex = merged.watches.findIndex(
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
);
if (existingWatchIndex !== -1) {
// Replace existing codex watch in-place
merged.watches[existingWatchIndex] = codexWatchFromSample;
} else {
// Append new codex watch
merged.watches.push(codexWatchFromSample);
}
}
return merged;
}
/**
* Write the merged transcript-watch.json config atomically.
*/
function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void {
mkdirSync(CLAUDE_MEM_DIR, { recursive: true });
writeFileSync(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
}
// ---------------------------------------------------------------------------
// Context Injection (AGENTS.md)
// ---------------------------------------------------------------------------
/**
* Inject claude-mem context section into ~/.codex/AGENTS.md.
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md and GEMINI.md.
* Preserves any existing user content outside the tags.
*/
function injectCodexAgentsMdContext(): void {
try {
mkdirSync(CODEX_DIR, { recursive: true });
let existingContent = '';
if (existsSync(CODEX_AGENTS_MD_PATH)) {
existingContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
}
// Initial placeholder content -- will be populated after first session
const contextContent = [
'# Recent Activity',
'',
'<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->',
'',
'*No context yet. Complete your first session and context will appear here.*',
].join('\n');
const finalContent = replaceTaggedContent(existingContent, contextContent);
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent);
console.log(` Injected context placeholder into ${CODEX_AGENTS_MD_PATH}`);
} catch (error) {
// Non-fatal -- transcript watching still works without context injection
logger.warn('CODEX', 'Failed to inject AGENTS.md context', { error: (error as Error).message });
console.warn(` Warning: Could not inject context into AGENTS.md: ${(error as Error).message}`);
}
}
/**
* Remove claude-mem context section from AGENTS.md.
* Preserves user content outside the <claude-mem-context> tags.
*/
function removeCodexAgentsMdContext(): void {
try {
if (!existsSync(CODEX_AGENTS_MD_PATH)) return;
const content = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
const startTag = '<claude-mem-context>';
const endTag = '</claude-mem-context>';
const startIdx = content.indexOf(startTag);
const endIdx = content.indexOf(endTag);
if (startIdx === -1 || endIdx === -1) return;
// Remove the tagged section and any surrounding blank lines
const before = content.substring(0, startIdx).replace(/\n+$/, '');
const after = content.substring(endIdx + endTag.length).replace(/^\n+/, '');
const finalContent = (before + (after ? '\n\n' + after : '')).trim();
if (finalContent) {
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent + '\n');
} else {
// File would be empty -- leave it empty rather than deleting
// (user may have other tooling that expects it to exist)
writeFileSync(CODEX_AGENTS_MD_PATH, '');
}
console.log(` Removed context section from ${CODEX_AGENTS_MD_PATH}`);
} catch (error) {
logger.warn('CODEX', 'Failed to clean AGENTS.md context', { error: (error as Error).message });
}
}
// ---------------------------------------------------------------------------
// Public API: Install
// ---------------------------------------------------------------------------
/**
* Install Codex CLI integration for claude-mem.
*
* 1. Merges Codex transcript-watch config into ~/.claude-mem/transcript-watch.json
* 2. Injects context placeholder into ~/.codex/AGENTS.md
*
* @returns 0 on success, 1 on failure
*/
export async function installCodexCli(): Promise<number> {
console.log('\nInstalling Claude-Mem for Codex CLI (transcript watching)...\n');
try {
// Step 1: Merge transcript-watch config
const existingConfig = loadExistingTranscriptWatchConfig();
const mergedConfig = mergeCodexWatchConfig(existingConfig);
writeTranscriptWatchConfig(mergedConfig);
console.log(` Updated ${DEFAULT_CONFIG_PATH}`);
console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`);
console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`);
// Step 2: Inject context into AGENTS.md
injectCodexAgentsMdContext();
console.log(`
Installation complete!
Transcript watch config: ${DEFAULT_CONFIG_PATH}
Context file: ${CODEX_AGENTS_MD_PATH}
How it works:
- claude-mem watches Codex session JSONL files for new activity
- No hooks needed -- transcript watching is fully automatic
- Context from past sessions is injected via ${CODEX_AGENTS_MD_PATH}
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Use Codex CLI as usual -- memory capture is automatic!
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ---------------------------------------------------------------------------
// Public API: Uninstall
// ---------------------------------------------------------------------------
/**
* Remove Codex CLI integration from claude-mem.
*
* 1. Removes the codex watch and schema from transcript-watch.json (preserves others)
* 2. Removes context section from AGENTS.md (preserves user content)
*
* @returns 0 on success, 1 on failure
*/
export function uninstallCodexCli(): number {
console.log('\nUninstalling Claude-Mem Codex CLI integration...\n');
try {
// Step 1: Remove codex watch from transcript-watch.json
if (existsSync(DEFAULT_CONFIG_PATH)) {
const config = loadExistingTranscriptWatchConfig();
// Remove codex watch
config.watches = config.watches.filter(
(w: WatchTarget) => w.name !== CODEX_WATCH_NAME,
);
// Remove codex schema
if (config.schemas) {
delete config.schemas[CODEX_WATCH_NAME];
}
writeTranscriptWatchConfig(config);
console.log(` Removed codex watch from ${DEFAULT_CONFIG_PATH}`);
} else {
console.log(' No transcript-watch.json found -- nothing to remove.');
}
// Step 2: Remove context section from AGENTS.md
removeCodexAgentsMdContext();
console.log('\nUninstallation complete!');
console.log('Restart claude-mem worker to apply changes.\n');
return 0;
} catch (error) {
console.error(`\nUninstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ---------------------------------------------------------------------------
// Public API: Status Check
// ---------------------------------------------------------------------------
/**
* Check Codex CLI integration status.
*
* @returns 0 always (informational)
*/
export function checkCodexCliStatus(): number {
console.log('\nClaude-Mem Codex CLI Integration Status\n');
// Check transcript-watch.json
if (!existsSync(DEFAULT_CONFIG_PATH)) {
console.log('Status: Not installed');
console.log(` No transcript watch config at ${DEFAULT_CONFIG_PATH}`);
console.log('\nRun: npx claude-mem install --ide codex-cli\n');
return 0;
}
try {
const config = loadExistingTranscriptWatchConfig();
const codexWatch = config.watches.find(
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
);
const codexSchema = config.schemas?.[CODEX_WATCH_NAME];
if (!codexWatch) {
console.log('Status: Not installed');
console.log(' transcript-watch.json exists but no codex watch configured.');
console.log('\nRun: npx claude-mem install --ide codex-cli\n');
return 0;
}
console.log('Status: Installed');
console.log(` Config: ${DEFAULT_CONFIG_PATH}`);
console.log(` Watch path: ${codexWatch.path}`);
console.log(` Schema: ${codexSchema ? `codex (v${codexSchema.version ?? '?'})` : 'missing'}`);
console.log(` Start at end: ${codexWatch.startAtEnd ?? false}`);
// Check context config
if (codexWatch.context) {
console.log(` Context mode: ${codexWatch.context.mode}`);
console.log(` Context path: ${codexWatch.context.path ?? 'default'}`);
console.log(` Context updates on: ${codexWatch.context.updateOn?.join(', ') ?? 'none'}`);
}
// Check AGENTS.md
if (existsSync(CODEX_AGENTS_MD_PATH)) {
const mdContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
if (mdContent.includes('<claude-mem-context>')) {
console.log(` Context: Active (${CODEX_AGENTS_MD_PATH})`);
} else {
console.log(` Context: AGENTS.md exists but no context tags`);
}
} else {
console.log(` Context: No AGENTS.md file`);
}
// Check if ~/.codex/sessions exists (indicates Codex has been used)
const sessionsDir = path.join(CODEX_DIR, 'sessions');
if (existsSync(sessionsDir)) {
console.log(` Sessions directory: exists`);
} else {
console.log(` Sessions directory: not yet created (use Codex CLI to generate sessions)`);
}
} catch {
console.log('Status: Unknown');
console.log(' Could not parse transcript-watch.json.');
}
console.log('');
return 0;
}
@@ -133,9 +133,7 @@ export function findMcpServerPath(): string | null {
const possiblePaths = [
// Marketplace install location
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'mcp-server.cjs'),
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
path.join(path.dirname(__filename), 'mcp-server.cjs'),
// Alternative dev location
// Development/source location
path.join(process.cwd(), 'plugin', 'scripts', 'mcp-server.cjs'),
];
@@ -155,9 +153,7 @@ export function findWorkerServicePath(): string | null {
const possiblePaths = [
// Marketplace install location
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs'),
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
path.join(path.dirname(__filename), 'worker-service.cjs'),
// Alternative dev location
// Development/source location
path.join(process.cwd(), 'plugin', 'scripts', 'worker-service.cjs'),
];
@@ -0,0 +1,513 @@
/**
* GeminiCliHooksInstaller - Gemini CLI integration for claude-mem
*
* Installs hooks into ~/.gemini/settings.json using the unified CLI:
* bun worker-service.cjs hook gemini-cli <event>
*
* This routes through the hook-command.ts framework:
* readJsonFromStdin() → gemini-cli adapter → event handler → POST to worker
*
* Gemini CLI supports 11 lifecycle hooks; we register 8 that map to
* useful memory events. See src/cli/adapters/gemini-cli.ts for the
* adapter that normalizes Gemini's stdin JSON to NormalizedHookInput.
*
* Hook config format (verified against Gemini CLI source):
* {
* "hooks": {
* "AfterTool": [{
* "matcher": "*",
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000 }]
* }]
* }
* }
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { findWorkerServicePath, findBunPath } from './CursorHooksInstaller.js';
// ============================================================================
// Types
// ============================================================================
/** A single hook entry in a Gemini CLI hook group */
interface GeminiHookEntry {
name: string;
type: 'command';
command: string;
timeout: number;
}
/** A hook group — matcher selects which tools/events this applies to */
interface GeminiHookGroup {
matcher: string;
hooks: GeminiHookEntry[];
}
/** The hooks section in ~/.gemini/settings.json */
interface GeminiHooksConfig {
[eventName: string]: GeminiHookGroup[];
}
/** Full ~/.gemini/settings.json structure (partial — we only care about hooks) */
interface GeminiSettingsJson {
hooks?: GeminiHooksConfig;
[key: string]: unknown;
}
// ============================================================================
// Constants
// ============================================================================
const GEMINI_CONFIG_DIR = path.join(homedir(), '.gemini');
const GEMINI_SETTINGS_PATH = path.join(GEMINI_CONFIG_DIR, 'settings.json');
const GEMINI_MD_PATH = path.join(GEMINI_CONFIG_DIR, 'GEMINI.md');
const HOOK_NAME = 'claude-mem';
const HOOK_TIMEOUT_MS = 10000;
/**
* Mapping from Gemini CLI hook events to internal claude-mem event types.
*
* These events are processed by hookCommand() in src/cli/hook-command.ts,
* which reads stdin via readJsonFromStdin(), normalizes through the
* gemini-cli adapter, and dispatches to the matching event handler.
*
* Events NOT mapped (too chatty for memory capture):
* BeforeModel, AfterModel, BeforeToolSelection
*/
const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = {
'SessionStart': 'context',
'BeforeAgent': 'user-message',
'AfterAgent': 'observation',
'BeforeTool': 'observation',
'AfterTool': 'observation',
'PreCompress': 'summarize',
'Notification': 'observation',
'SessionEnd': 'session-complete',
};
// ============================================================================
// Hook Command Builder
// ============================================================================
/**
* Build the hook command string for a given Gemini CLI event.
*
* The command invokes worker-service.cjs with the `hook` subcommand,
* which delegates to hookCommand('gemini-cli', event) — the same
* framework used by Claude Code and Cursor hooks.
*
* Pipeline: bun worker-service.cjs hook gemini-cli <event>
* → worker-service.ts parses args, ensures worker daemon is running
* → hookCommand('gemini-cli', '<event>')
* → readJsonFromStdin() reads Gemini's JSON payload
* → geminiCliAdapter.normalizeInput() → NormalizedHookInput
* → eventHandler.execute(input)
* → geminiCliAdapter.formatOutput(result)
* → JSON.stringify to stdout
*/
function buildHookCommand(
bunPath: string,
workerServicePath: string,
geminiEventName: string,
): string {
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[geminiEventName];
if (!internalEvent) {
throw new Error(`Unknown Gemini CLI event: ${geminiEventName}`);
}
// Double-escape backslashes intentionally: this command string is embedded inside
// a JSON value, so `\\` in the source becomes `\` when the JSON is parsed by the
// IDE. Without double-escaping, Windows paths like C:\Users would lose their
// backslashes and break when the IDE deserializes the hook configuration.
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${internalEvent}`;
}
/**
* Create a hook group entry for a Gemini CLI event.
* Uses matcher "*" to match all tools/contexts for that event.
*/
function createHookGroup(hookCommand: string): GeminiHookGroup {
return {
matcher: '*',
hooks: [{
name: HOOK_NAME,
type: 'command',
command: hookCommand,
timeout: HOOK_TIMEOUT_MS,
}],
};
}
// ============================================================================
// Settings JSON Management
// ============================================================================
/**
* Read ~/.gemini/settings.json, returning empty object if missing.
* Throws on corrupt JSON to prevent silent data loss.
*/
function readGeminiSettings(): GeminiSettingsJson {
if (!existsSync(GEMINI_SETTINGS_PATH)) {
return {};
}
const content = readFileSync(GEMINI_SETTINGS_PATH, 'utf-8');
try {
return JSON.parse(content) as GeminiSettingsJson;
} catch (error) {
throw new Error(`Corrupt JSON in ${GEMINI_SETTINGS_PATH}, refusing to overwrite user settings`);
}
}
/**
* Write settings back to ~/.gemini/settings.json.
* Creates the directory if it doesn't exist.
*/
function writeGeminiSettings(settings: GeminiSettingsJson): void {
mkdirSync(GEMINI_CONFIG_DIR, { recursive: true });
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
}
/**
* Deep-merge claude-mem hooks into existing settings.
*
* For each event:
* - If the event already has a hook group with a claude-mem hook, update it
* - Otherwise, append a new hook group
*
* Preserves all non-claude-mem hooks and all non-hook settings.
*/
function mergeHooksIntoSettings(
existingSettings: GeminiSettingsJson,
newHooks: GeminiHooksConfig,
): GeminiSettingsJson {
const settings = { ...existingSettings };
if (!settings.hooks) {
settings.hooks = {};
}
for (const [eventName, newGroups] of Object.entries(newHooks)) {
const existingGroups: GeminiHookGroup[] = settings.hooks[eventName] ?? [];
// For each new hook group, check if there's already a group
// containing a claude-mem hook — update it in place
for (const newGroup of newGroups) {
const existingGroupIndex = existingGroups.findIndex((group: GeminiHookGroup) =>
group.hooks.some((hook: GeminiHookEntry) => hook.name === HOOK_NAME)
);
if (existingGroupIndex >= 0) {
// Update existing group: replace the claude-mem hook entry
const existingGroup: GeminiHookGroup = existingGroups[existingGroupIndex];
const hookIndex = existingGroup.hooks.findIndex((hook: GeminiHookEntry) => hook.name === HOOK_NAME);
if (hookIndex >= 0) {
existingGroup.hooks[hookIndex] = newGroup.hooks[0];
} else {
existingGroup.hooks.push(newGroup.hooks[0]);
}
} else {
// No existing claude-mem group — append
existingGroups.push(newGroup);
}
}
settings.hooks[eventName] = existingGroups;
}
return settings;
}
// ============================================================================
// GEMINI.md Context Injection
// ============================================================================
/**
* Append or update the claude-mem context section in ~/.gemini/GEMINI.md.
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md.
*/
function setupGeminiMdContextSection(): void {
const contextTag = '<claude-mem-context>';
const contextEndTag = '</claude-mem-context>';
const placeholder = `${contextTag}
# Memory Context from Past Sessions
*No context yet. Complete your first session and context will appear here.*
${contextEndTag}`;
let content = '';
if (existsSync(GEMINI_MD_PATH)) {
content = readFileSync(GEMINI_MD_PATH, 'utf-8');
}
if (content.includes(contextTag)) {
// Already has claude-mem section — leave it alone (may have real context)
return;
}
// Append the section
const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : content.length > 0 ? '\n' : '';
const newContent = content + separator + placeholder + '\n';
mkdirSync(GEMINI_CONFIG_DIR, { recursive: true });
writeFileSync(GEMINI_MD_PATH, newContent);
}
// ============================================================================
// Public API
// ============================================================================
/**
* Install claude-mem hooks into ~/.gemini/settings.json.
*
* Merges hooks non-destructively: existing settings and non-claude-mem
* hooks are preserved. Existing claude-mem hooks are updated in place.
*
* @returns 0 on success, 1 on failure
*/
export async function installGeminiCliHooks(): Promise<number> {
console.log('\nInstalling Claude-Mem Gemini CLI hooks...\n');
// Find required paths
const workerServicePath = findWorkerServicePath();
if (!workerServicePath) {
console.error('Could not find worker-service.cjs');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs');
return 1;
}
const bunPath = findBunPath();
console.log(` Using Bun runtime: ${bunPath}`);
console.log(` Worker service: ${workerServicePath}`);
try {
// Build hook commands for all mapped events
const hooksConfig: GeminiHooksConfig = {};
for (const geminiEvent of Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT)) {
const command = buildHookCommand(bunPath, workerServicePath, geminiEvent);
hooksConfig[geminiEvent] = [createHookGroup(command)];
}
// Read existing settings and merge
const existingSettings = readGeminiSettings();
const mergedSettings = mergeHooksIntoSettings(existingSettings, hooksConfig);
// Write back
writeGeminiSettings(mergedSettings);
console.log(` Merged hooks into ${GEMINI_SETTINGS_PATH}`);
// Setup GEMINI.md context injection
setupGeminiMdContextSection();
console.log(` Setup context injection in ${GEMINI_MD_PATH}`);
// List installed events
const eventNames = Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT);
console.log(` Registered ${eventNames.length} hook events:`);
for (const event of eventNames) {
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event];
console.log(` ${event}${internalEvent}`);
}
console.log(`
Installation complete!
Hooks installed to: ${GEMINI_SETTINGS_PATH}
Using unified CLI: bun worker-service.cjs hook gemini-cli <event>
Next steps:
1. Start claude-mem worker: claude-mem start
2. Restart Gemini CLI to load the hooks
3. Memory will be captured automatically during sessions
Context Injection:
Context from past sessions is injected via ~/.gemini/GEMINI.md
and automatically included in Gemini CLI conversations.
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
/**
* Uninstall claude-mem hooks from ~/.gemini/settings.json.
*
* Removes only claude-mem hooks — other hooks and settings are preserved.
*
* @returns 0 on success, 1 on failure
*/
export function uninstallGeminiCliHooks(): number {
console.log('\nUninstalling Claude-Mem Gemini CLI hooks...\n');
try {
if (!existsSync(GEMINI_SETTINGS_PATH)) {
console.log(' No Gemini CLI settings found — nothing to uninstall.');
return 0;
}
const settings = readGeminiSettings();
if (!settings.hooks) {
console.log(' No hooks found in Gemini CLI settings — nothing to uninstall.');
return 0;
}
let removedCount = 0;
// Remove claude-mem hooks from within each group, preserving other hooks
for (const [eventName, groups] of Object.entries(settings.hooks)) {
const filteredGroups = groups
.map(group => {
const remainingHooks = group.hooks.filter(hook => hook.name !== HOOK_NAME);
removedCount += group.hooks.length - remainingHooks.length;
return { ...group, hooks: remainingHooks };
})
.filter(group => group.hooks.length > 0);
if (filteredGroups.length > 0) {
settings.hooks[eventName] = filteredGroups;
} else {
delete settings.hooks[eventName];
}
}
// Clean up empty hooks object
if (Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
writeGeminiSettings(settings);
console.log(` Removed ${removedCount} claude-mem hook(s) from ${GEMINI_SETTINGS_PATH}`);
// Remove claude-mem context section from GEMINI.md
if (existsSync(GEMINI_MD_PATH)) {
let mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
const contextRegex = /\n?<claude-mem-context>[\s\S]*?<\/claude-mem-context>\n?/;
if (contextRegex.test(mdContent)) {
mdContent = mdContent.replace(contextRegex, '');
writeFileSync(GEMINI_MD_PATH, mdContent);
console.log(` Removed context section from ${GEMINI_MD_PATH}`);
}
}
console.log('\nUninstallation complete!\n');
console.log('Restart Gemini CLI to apply changes.');
return 0;
} catch (error) {
console.error(`\nUninstallation failed: ${(error as Error).message}`);
return 1;
}
}
/**
* Check Gemini CLI hooks installation status.
*
* @returns 0 always (informational)
*/
export function checkGeminiCliHooksStatus(): number {
console.log('\nClaude-Mem Gemini CLI Hooks Status\n');
if (!existsSync(GEMINI_SETTINGS_PATH)) {
console.log('Gemini CLI settings: Not found');
console.log(` Expected at: ${GEMINI_SETTINGS_PATH}\n`);
console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
return 0;
}
let settings: GeminiSettingsJson;
try {
settings = readGeminiSettings();
} catch (error) {
console.log(`Gemini CLI settings: ${(error as Error).message}\n`);
return 0;
}
if (!settings.hooks) {
console.log('Gemini CLI settings: Found, but no hooks configured\n');
console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
return 0;
}
// Check for claude-mem hooks
const installedEvents: string[] = [];
for (const [eventName, groups] of Object.entries(settings.hooks)) {
const hasClaudeMem = groups.some(group =>
group.hooks.some(hook => hook.name === HOOK_NAME)
);
if (hasClaudeMem) {
installedEvents.push(eventName);
}
}
if (installedEvents.length === 0) {
console.log('Gemini CLI settings: Found, but no claude-mem hooks\n');
console.log('Run: claude-mem install --ide gemini-cli\n');
return 0;
}
console.log(`Settings: ${GEMINI_SETTINGS_PATH}`);
console.log(`Mode: Unified CLI (bun worker-service.cjs hook gemini-cli)`);
console.log(`Events: ${installedEvents.length} of ${Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT).length} mapped`);
for (const event of installedEvents) {
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event] ?? 'unknown';
console.log(` ${event}${internalEvent}`);
}
// Check GEMINI.md context
if (existsSync(GEMINI_MD_PATH)) {
const mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
if (mdContent.includes('<claude-mem-context>')) {
console.log(`Context: Active (${GEMINI_MD_PATH})`);
} else {
console.log('Context: GEMINI.md exists but missing claude-mem section');
}
} else {
console.log('Context: No GEMINI.md found');
}
console.log('');
return 0;
}
/**
* Handle gemini-cli subcommand for hooks management.
*/
export async function handleGeminiCliCommand(subcommand: string, _args: string[]): Promise<number> {
switch (subcommand) {
case 'install':
return installGeminiCliHooks();
case 'uninstall':
return uninstallGeminiCliHooks();
case 'status':
return checkGeminiCliHooksStatus();
default:
console.log(`
Claude-Mem Gemini CLI Integration
Usage: claude-mem gemini-cli <command>
Commands:
install Install hooks into ~/.gemini/settings.json
uninstall Remove claude-mem hooks (preserves other hooks)
status Check installation status
Examples:
claude-mem gemini-cli install # Install hooks
claude-mem gemini-cli status # Check if installed
claude-mem gemini-cli uninstall # Remove hooks
For more info: https://docs.claude-mem.ai/usage/gemini-provider
`);
return 0;
}
}
@@ -0,0 +1,358 @@
/**
* McpIntegrations - MCP-based IDE integrations for claude-mem
*
* Handles MCP config writing and context injection for IDEs that support
* the Model Context Protocol. These are "MCP-only" integrations: they provide
* search tools and context injection but do NOT capture transcripts.
*
* Supported IDEs:
* - Copilot CLI
* - Antigravity (Gemini)
* - Goose
* - Crush
* - Roo Code
* - Warp
*
* All IDEs point to the same MCP server: plugin/scripts/mcp-server.cjs
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { findMcpServerPath } from './CursorHooksInstaller.js';
import { readJsonSafe } from '../../utils/json-utils.js';
import { injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
// ============================================================================
// Shared Constants
// ============================================================================
const PLACEHOLDER_CONTEXT = `# claude-mem: Cross-Session Memory
*No context yet. Complete your first session and context will appear here.*
Use claude-mem's MCP search tools for manual memory queries.`;
// ============================================================================
// Shared Utilities
// ============================================================================
/**
* Build the standard MCP server entry that all IDEs use.
* Points to the same mcp-server.cjs script.
*/
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
return {
command: process.execPath,
args: [mcpServerPath],
};
}
/**
* Write a standard MCP JSON config file, merging with existing config.
* Supports both { "mcpServers": { ... } } and { "servers": { ... } } formats.
*/
function writeMcpJsonConfig(
configFilePath: string,
mcpServerPath: string,
serversKeyName: string = 'mcpServers',
): void {
const parentDirectory = path.dirname(configFilePath);
mkdirSync(parentDirectory, { recursive: true });
const existingConfig = readJsonSafe<Record<string, any>>(configFilePath, {});
if (!existingConfig[serversKeyName]) {
existingConfig[serversKeyName] = {};
}
existingConfig[serversKeyName]['claude-mem'] = buildMcpServerEntry(mcpServerPath);
writeFileSync(configFilePath, JSON.stringify(existingConfig, null, 2) + '\n');
}
// ============================================================================
// MCP Installer Factory (Phase 1D)
// ============================================================================
/**
* Configuration for a JSON-based MCP IDE integration.
*/
interface McpInstallerConfig {
ideId: string;
ideLabel: string;
configPath: string;
configKey: 'servers' | 'mcpServers';
contextFile?: {
path: string;
isWorkspaceRelative: boolean;
};
}
/**
* Factory function that creates an MCP installer for any JSON-config-based IDE.
* Handles MCP config writing and optional context injection.
*/
function installMcpIntegration(config: McpInstallerConfig): () => Promise<number> {
return async (): Promise<number> => {
console.log(`\nInstalling Claude-Mem MCP integration for ${config.ideLabel}...\n`);
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
try {
// Write MCP config
const configPath = config.configPath;
// Warp special case: skip config write if ~/.warp/ doesn't exist
if (config.ideId === 'warp' && !existsSync(path.dirname(configPath))) {
console.log(` Note: ~/.warp/ not found. MCP may need to be configured via Warp Drive UI.`);
} else {
writeMcpJsonConfig(configPath, mcpServerPath, config.configKey);
console.log(` MCP config written to: ${configPath}`);
}
// Inject context if configured
let contextPath: string | undefined;
if (config.contextFile) {
contextPath = config.contextFile.path;
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
console.log(` Context placeholder written to: ${contextPath}`);
}
// Print summary
const summaryLines = [`\nInstallation complete!\n`];
summaryLines.push(`MCP config: ${configPath}`);
if (contextPath) {
summaryLines.push(`Context: ${contextPath}`);
}
summaryLines.push('');
summaryLines.push(`Note: This is an MCP-only integration providing search tools and context.`);
summaryLines.push(`Transcript capture is not available for ${config.ideLabel}.`);
if (config.ideId === 'warp') {
summaryLines.push('If MCP config via file is not supported, configure MCP through Warp Drive UI.');
}
summaryLines.push('');
summaryLines.push('Next steps:');
summaryLines.push(' 1. Start claude-mem worker: npx claude-mem start');
summaryLines.push(` 2. Restart ${config.ideLabel} to pick up the MCP server`);
summaryLines.push('');
console.log(summaryLines.join('\n'));
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
};
}
// ============================================================================
// Factory Configs for JSON-based IDEs
// ============================================================================
const COPILOT_CLI_CONFIG: McpInstallerConfig = {
ideId: 'copilot-cli',
ideLabel: 'Copilot CLI',
configPath: path.join(homedir(), '.github', 'copilot', 'mcp.json'),
configKey: 'servers',
contextFile: {
path: path.join(process.cwd(), '.github', 'copilot-instructions.md'),
isWorkspaceRelative: true,
},
};
const ANTIGRAVITY_CONFIG: McpInstallerConfig = {
ideId: 'antigravity',
ideLabel: 'Antigravity',
configPath: path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json'),
configKey: 'mcpServers',
contextFile: {
path: path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md'),
isWorkspaceRelative: true,
},
};
const CRUSH_CONFIG: McpInstallerConfig = {
ideId: 'crush',
ideLabel: 'Crush',
configPath: path.join(homedir(), '.config', 'crush', 'mcp.json'),
configKey: 'mcpServers',
};
const ROO_CODE_CONFIG: McpInstallerConfig = {
ideId: 'roo-code',
ideLabel: 'Roo Code',
configPath: path.join(process.cwd(), '.roo', 'mcp.json'),
configKey: 'mcpServers',
contextFile: {
path: path.join(process.cwd(), '.roo', 'rules', 'claude-mem-context.md'),
isWorkspaceRelative: true,
},
};
const WARP_CONFIG: McpInstallerConfig = {
ideId: 'warp',
ideLabel: 'Warp',
configPath: path.join(homedir(), '.warp', 'mcp.json'),
configKey: 'mcpServers',
contextFile: {
path: path.join(process.cwd(), 'WARP.md'),
isWorkspaceRelative: true,
},
};
// ============================================================================
// Goose (YAML-based — separate handler)
// ============================================================================
/**
* Get the Goose config path.
* Goose stores its config at ~/.config/goose/config.yaml.
*/
function getGooseConfigPath(): string {
return path.join(homedir(), '.config', 'goose', 'config.yaml');
}
/**
* Check if a YAML string already has a claude-mem entry under mcpServers.
* Uses string matching to avoid needing a YAML parser.
*/
function gooseConfigHasClaudeMemEntry(yamlContent: string): boolean {
// Look for "claude-mem:" indented under mcpServers
return yamlContent.includes('claude-mem:') &&
yamlContent.includes('mcpServers:');
}
/**
* Build the Goose YAML MCP server block as a string.
* Produces properly indented YAML without needing a parser.
*/
function buildGooseMcpYamlBlock(mcpServerPath: string): string {
// Goose expects the mcpServers section at the top level
return [
'mcpServers:',
' claude-mem:',
` command: ${process.execPath}`,
' args:',
` - ${mcpServerPath}`,
].join('\n');
}
/**
* Build just the claude-mem server entry (for appending under existing mcpServers).
*/
function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string {
return [
' claude-mem:',
` command: ${process.execPath}`,
' args:',
` - ${mcpServerPath}`,
].join('\n');
}
/**
* Install claude-mem MCP integration for Goose.
*
* - Writes/merges MCP config into ~/.config/goose/config.yaml
* - Uses string manipulation for YAML (no parser dependency)
*
* @returns 0 on success, 1 on failure
*/
export async function installGooseMcpIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem MCP integration for Goose...\n');
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
try {
const configPath = getGooseConfigPath();
const configDirectory = path.dirname(configPath);
mkdirSync(configDirectory, { recursive: true });
if (existsSync(configPath)) {
let yamlContent = readFileSync(configPath, 'utf-8');
if (gooseConfigHasClaudeMemEntry(yamlContent)) {
// Already configured — replace the claude-mem block
// Find the claude-mem entry and replace it
const claudeMemPattern = /( {2}claude-mem:\n(?:.*\n)*?(?= {2}\S|\n\n|^\S|$))/m;
const newEntry = buildGooseClaudeMemEntryYaml(mcpServerPath) + '\n';
if (claudeMemPattern.test(yamlContent)) {
yamlContent = yamlContent.replace(claudeMemPattern, newEntry);
}
writeFileSync(configPath, yamlContent);
console.log(` Updated existing claude-mem entry in: ${configPath}`);
} else if (yamlContent.includes('mcpServers:')) {
// mcpServers section exists but no claude-mem entry — append under it
const mcpServersIndex = yamlContent.indexOf('mcpServers:');
const insertionPoint = mcpServersIndex + 'mcpServers:'.length;
const newEntry = '\n' + buildGooseClaudeMemEntryYaml(mcpServerPath);
yamlContent =
yamlContent.slice(0, insertionPoint) +
newEntry +
yamlContent.slice(insertionPoint);
writeFileSync(configPath, yamlContent);
console.log(` Added claude-mem to existing mcpServers in: ${configPath}`);
} else {
// No mcpServers section — append the entire block
const mcpBlock = '\n' + buildGooseMcpYamlBlock(mcpServerPath) + '\n';
yamlContent = yamlContent.trimEnd() + '\n' + mcpBlock;
writeFileSync(configPath, yamlContent);
console.log(` Appended mcpServers section to: ${configPath}`);
}
} else {
// File doesn't exist — create from template
const templateContent = buildGooseMcpYamlBlock(mcpServerPath) + '\n';
writeFileSync(configPath, templateContent);
console.log(` Created config with MCP server: ${configPath}`);
}
console.log(`
Installation complete!
MCP config: ${configPath}
Note: This is an MCP-only integration providing search tools and context.
Transcript capture is not available for Goose.
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart Goose to pick up the MCP server
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
// ============================================================================
// Unified Installer (used by npx install command)
// ============================================================================
/**
* Map of IDE identifiers to their install functions.
* Used by the install command to dispatch to the correct integration.
*/
export const MCP_IDE_INSTALLERS: Record<string, () => Promise<number>> = {
'copilot-cli': installMcpIntegration(COPILOT_CLI_CONFIG),
'antigravity': installMcpIntegration(ANTIGRAVITY_CONFIG),
'goose': installGooseMcpIntegration,
'crush': installMcpIntegration(CRUSH_CONFIG),
'roo-code': installMcpIntegration(ROO_CODE_CONFIG),
'warp': installMcpIntegration(WARP_CONFIG),
};
@@ -0,0 +1,430 @@
/**
* OpenClawInstaller - OpenClaw gateway integration installer for claude-mem
*
* Installs the pre-built claude-mem plugin into OpenClaw's extension directory
* and registers it in ~/.openclaw/openclaw.json.
*
* Install strategy: File-based
* - Copies the pre-built plugin from the npm package's openclaw/dist/ directory
* to ~/.openclaw/extensions/claude-mem/dist/
* - Registers the plugin in openclaw.json under plugins.entries.claude-mem
* - Sets the memory slot to claude-mem
*
* Important: The OpenClaw plugin ships pre-built from the npm package.
* It must NOT be rebuilt at install time.
*/
import path from 'path';
import { homedir } from 'os';
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
cpSync,
rmSync,
unlinkSync,
} from 'fs';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Path Resolution
// ============================================================================
/**
* Resolve the OpenClaw config directory (~/.openclaw).
*/
export function getOpenClawConfigDirectory(): string {
return path.join(homedir(), '.openclaw');
}
/**
* Resolve the OpenClaw extensions directory where plugins are installed.
*/
export function getOpenClawExtensionsDirectory(): string {
return path.join(getOpenClawConfigDirectory(), 'extensions');
}
/**
* Resolve the claude-mem extension install directory.
*/
export function getOpenClawClaudeMemExtensionDirectory(): string {
return path.join(getOpenClawExtensionsDirectory(), 'claude-mem');
}
/**
* Resolve the path to openclaw.json config file.
*/
export function getOpenClawConfigFilePath(): string {
return path.join(getOpenClawConfigDirectory(), 'openclaw.json');
}
// ============================================================================
// Pre-built Plugin Location
// ============================================================================
/**
* Find the pre-built OpenClaw plugin bundle in the npm package.
* Searches in: openclaw/dist/index.js relative to package root,
* then the marketplace install location.
*/
export function findPreBuiltPluginDirectory(): string | null {
const possibleRoots = [
// Marketplace install location (production — after `npx claude-mem install`)
path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
'plugins', 'marketplaces', 'thedotmack',
),
// Development location (relative to project root)
process.cwd(),
];
for (const root of possibleRoots) {
const openclawDistDirectory = path.join(root, 'openclaw', 'dist');
const pluginEntryPoint = path.join(openclawDistDirectory, 'index.js');
if (existsSync(pluginEntryPoint)) {
return openclawDistDirectory;
}
}
return null;
}
/**
* Find the openclaw.plugin.json file for copying alongside the plugin.
*/
export function findPluginManifestPath(): string | null {
const possibleRoots = [
path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
'plugins', 'marketplaces', 'thedotmack',
),
process.cwd(),
];
for (const root of possibleRoots) {
const manifestPath = path.join(root, 'openclaw', 'openclaw.plugin.json');
if (existsSync(manifestPath)) {
return manifestPath;
}
}
return null;
}
/**
* Find the openclaw skills directory for copying alongside the plugin.
*/
export function findPluginSkillsDirectory(): string | null {
const possibleRoots = [
path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
'plugins', 'marketplaces', 'thedotmack',
),
process.cwd(),
];
for (const root of possibleRoots) {
const skillsDirectory = path.join(root, 'openclaw', 'skills');
if (existsSync(skillsDirectory)) {
return skillsDirectory;
}
}
return null;
}
// ============================================================================
// OpenClaw Config (openclaw.json) Management
// ============================================================================
/**
* Read openclaw.json safely, returning an empty object if missing or invalid.
*/
function readOpenClawConfig(): Record<string, any> {
const configFilePath = getOpenClawConfigFilePath();
if (!existsSync(configFilePath)) return {};
try {
return JSON.parse(readFileSync(configFilePath, 'utf-8'));
} catch {
return {};
}
}
/**
* Write openclaw.json atomically, creating the directory if needed.
*/
function writeOpenClawConfig(config: Record<string, any>): void {
const configDirectory = getOpenClawConfigDirectory();
mkdirSync(configDirectory, { recursive: true });
writeFileSync(getOpenClawConfigFilePath(), JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
/**
* Register claude-mem in openclaw.json by merging into the existing config.
* Does NOT overwrite the entire file -- only touches the claude-mem entry
* and the memory slot.
*/
function registerPluginInOpenClawConfig(
workerPort: number = 37777,
project: string = 'openclaw',
syncMemoryFile: boolean = true,
): void {
const config = readOpenClawConfig();
// Ensure the plugins structure exists
if (!config.plugins) config.plugins = {};
if (!config.plugins.slots) config.plugins.slots = {};
if (!config.plugins.entries) config.plugins.entries = {};
// Set the memory slot to claude-mem
config.plugins.slots.memory = 'claude-mem';
// Create or update the claude-mem plugin entry
if (!config.plugins.entries['claude-mem']) {
config.plugins.entries['claude-mem'] = {
enabled: true,
config: {
workerPort,
project,
syncMemoryFile,
},
};
} else {
// Merge: enable and update config without losing existing user settings
config.plugins.entries['claude-mem'].enabled = true;
if (!config.plugins.entries['claude-mem'].config) {
config.plugins.entries['claude-mem'].config = {};
}
const existingPluginConfig = config.plugins.entries['claude-mem'].config;
// Only set defaults if not already configured
if (existingPluginConfig.workerPort === undefined) existingPluginConfig.workerPort = workerPort;
if (existingPluginConfig.project === undefined) existingPluginConfig.project = project;
if (existingPluginConfig.syncMemoryFile === undefined) existingPluginConfig.syncMemoryFile = syncMemoryFile;
}
writeOpenClawConfig(config);
}
/**
* Remove claude-mem from openclaw.json without deleting other config.
*/
function unregisterPluginFromOpenClawConfig(): void {
const configFilePath = getOpenClawConfigFilePath();
if (!existsSync(configFilePath)) return;
const config = readOpenClawConfig();
// Remove claude-mem entry
if (config.plugins?.entries?.['claude-mem']) {
delete config.plugins.entries['claude-mem'];
}
// Clear memory slot if it points to claude-mem
if (config.plugins?.slots?.memory === 'claude-mem') {
delete config.plugins.slots.memory;
}
writeOpenClawConfig(config);
}
// ============================================================================
// Plugin Installation
// ============================================================================
/**
* Install the claude-mem plugin into OpenClaw's extensions directory.
* Copies the pre-built plugin bundle and registers it in openclaw.json.
*
* @returns 0 on success, 1 on failure
*/
export function installOpenClawPlugin(): number {
const preBuiltDistDirectory = findPreBuiltPluginDirectory();
if (!preBuiltDistDirectory) {
console.error('Could not find pre-built OpenClaw plugin bundle.');
console.error(' Expected at: openclaw/dist/index.js');
console.error(' Ensure the npm package includes the openclaw directory.');
return 1;
}
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
const destinationDistDirectory = path.join(extensionDirectory, 'dist');
try {
// Create the extension directory structure
mkdirSync(destinationDistDirectory, { recursive: true });
// Copy pre-built dist files
cpSync(preBuiltDistDirectory, destinationDistDirectory, { recursive: true, force: true });
console.log(` Plugin dist copied to: ${destinationDistDirectory}`);
// Copy openclaw.plugin.json if available
const manifestPath = findPluginManifestPath();
if (manifestPath) {
const destinationManifest = path.join(extensionDirectory, 'openclaw.plugin.json');
cpSync(manifestPath, destinationManifest, { force: true });
console.log(` Plugin manifest copied to: ${destinationManifest}`);
}
// Copy skills directory if available
const skillsDirectory = findPluginSkillsDirectory();
if (skillsDirectory) {
const destinationSkills = path.join(extensionDirectory, 'skills');
cpSync(skillsDirectory, destinationSkills, { recursive: true, force: true });
console.log(` Skills copied to: ${destinationSkills}`);
}
// Create a minimal package.json for the extension (OpenClaw expects this)
const extensionPackageJson = {
name: 'claude-mem',
version: '1.0.0',
type: 'module',
main: 'dist/index.js',
openclaw: { extensions: ['./dist/index.js'] },
};
writeFileSync(
path.join(extensionDirectory, 'package.json'),
JSON.stringify(extensionPackageJson, null, 2) + '\n',
'utf-8',
);
// Register in openclaw.json (merge, not overwrite)
registerPluginInOpenClawConfig();
console.log(` Registered in openclaw.json`);
logger.info('OPENCLAW', 'Plugin installed', { destination: extensionDirectory });
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to install OpenClaw plugin: ${message}`);
return 1;
}
}
// ============================================================================
// Uninstallation
// ============================================================================
/**
* Remove the claude-mem plugin from OpenClaw.
* Removes extension files and unregisters from openclaw.json.
*
* @returns 0 on success, 1 on failure
*/
export function uninstallOpenClawPlugin(): number {
let hasErrors = false;
// Remove extension directory
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
if (existsSync(extensionDirectory)) {
try {
rmSync(extensionDirectory, { recursive: true, force: true });
console.log(` Removed extension: ${extensionDirectory}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Failed to remove extension directory: ${message}`);
hasErrors = true;
}
}
// Unregister from openclaw.json
try {
unregisterPluginFromOpenClawConfig();
console.log(` Unregistered from openclaw.json`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Failed to update openclaw.json: ${message}`);
hasErrors = true;
}
return hasErrors ? 1 : 0;
}
// ============================================================================
// Status Check
// ============================================================================
/**
* Check OpenClaw integration status.
*
* @returns 0 always (informational only)
*/
export function checkOpenClawStatus(): number {
console.log('\nClaude-Mem OpenClaw Integration Status\n');
const configDirectory = getOpenClawConfigDirectory();
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
const configFilePath = getOpenClawConfigFilePath();
const pluginEntryPoint = path.join(extensionDirectory, 'dist', 'index.js');
console.log(`Config directory: ${configDirectory}`);
console.log(` Exists: ${existsSync(configDirectory) ? 'yes' : 'no'}`);
console.log('');
console.log(`Extension directory: ${extensionDirectory}`);
console.log(` Exists: ${existsSync(extensionDirectory) ? 'yes' : 'no'}`);
console.log(` Plugin entry: ${existsSync(pluginEntryPoint) ? 'yes' : 'no'}`);
console.log('');
console.log(`Config (openclaw.json): ${configFilePath}`);
if (existsSync(configFilePath)) {
const config = readOpenClawConfig();
const isRegistered = config.plugins?.entries?.['claude-mem'] !== undefined;
const isEnabled = config.plugins?.entries?.['claude-mem']?.enabled === true;
const isMemorySlot = config.plugins?.slots?.memory === 'claude-mem';
console.log(` Exists: yes`);
console.log(` Registered: ${isRegistered ? 'yes' : 'no'}`);
console.log(` Enabled: ${isEnabled ? 'yes' : 'no'}`);
console.log(` Memory slot: ${isMemorySlot ? 'yes' : 'no'}`);
if (isRegistered) {
const pluginConfig = config.plugins.entries['claude-mem'].config;
if (pluginConfig) {
console.log(` Worker port: ${pluginConfig.workerPort ?? 'default'}`);
console.log(` Project: ${pluginConfig.project ?? 'default'}`);
console.log(` Sync MEMORY.md: ${pluginConfig.syncMemoryFile ?? 'default'}`);
}
}
} else {
console.log(` Exists: no`);
}
console.log('');
return 0;
}
// ============================================================================
// Full Install Flow (used by npx install command)
// ============================================================================
/**
* Run the full OpenClaw installation: copy plugin + register in config.
*
* @returns 0 on success, 1 on failure
*/
export async function installOpenClawIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem for OpenClaw...\n');
// Step 1: Install plugin files and register in config
const pluginResult = installOpenClawPlugin();
if (pluginResult !== 0) {
return pluginResult;
}
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
console.log(`
Installation complete!
Plugin installed to: ${extensionDirectory}
Config updated: ${getOpenClawConfigFilePath()}
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart OpenClaw to load the plugin
3. Memory capture is automatic from then on
`);
return 0;
}
@@ -0,0 +1,372 @@
/**
* OpenCodeInstaller - OpenCode IDE integration installer for claude-mem
*
* Installs the claude-mem plugin into OpenCode's plugin directory and
* sets up context injection via AGENTS.md.
*
* Install strategy: File-based (Option A)
* - Copies the built plugin to the OpenCode plugins directory
* - Plugins in that directory are auto-loaded at startup
*
* Context injection:
* - Appends/updates <claude-mem-context> section in AGENTS.md
*
* Respects OPENCODE_CONFIG_DIR env var for config directory resolution.
*/
import path from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE, injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
import { getWorkerPort } from '../../shared/worker-utils.js';
// ============================================================================
// Path Resolution
// ============================================================================
/**
* Resolve the OpenCode config directory.
* Respects OPENCODE_CONFIG_DIR env var, falls back to ~/.config/opencode.
*/
export function getOpenCodeConfigDirectory(): string {
if (process.env.OPENCODE_CONFIG_DIR) {
return process.env.OPENCODE_CONFIG_DIR;
}
return path.join(homedir(), '.config', 'opencode');
}
/**
* Resolve the OpenCode plugins directory.
*/
export function getOpenCodePluginsDirectory(): string {
return path.join(getOpenCodeConfigDirectory(), 'plugins');
}
/**
* Resolve the AGENTS.md path for context injection.
*/
export function getOpenCodeAgentsMdPath(): string {
return path.join(getOpenCodeConfigDirectory(), 'AGENTS.md');
}
/**
* Resolve the path to the installed plugin file.
*/
export function getInstalledPluginPath(): string {
return path.join(getOpenCodePluginsDirectory(), 'claude-mem.js');
}
// ============================================================================
// Plugin Installation
// ============================================================================
/**
* Find the built OpenCode plugin bundle.
* Searches in: dist/opencode-plugin/index.js (built output),
* then marketplace location.
*/
export function findBuiltPluginPath(): string | null {
const possiblePaths = [
// Marketplace install location (production)
path.join(
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
'plugins', 'marketplaces', 'thedotmack',
'dist', 'opencode-plugin', 'index.js',
),
// Development location (relative to this module's package root)
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'dist', 'opencode-plugin', 'index.js'),
];
for (const candidatePath of possiblePaths) {
if (existsSync(candidatePath)) {
return candidatePath;
}
}
return null;
}
/**
* Install the claude-mem plugin into OpenCode's plugins directory.
* Copies the built plugin bundle to ~/.config/opencode/plugins/claude-mem.js
*
* @returns 0 on success, 1 on failure
*/
export function installOpenCodePlugin(): number {
const builtPluginPath = findBuiltPluginPath();
if (!builtPluginPath) {
console.error('Could not find built OpenCode plugin bundle.');
console.error(' Expected at: dist/opencode-plugin/index.js');
console.error(' Run the build first: npm run build');
return 1;
}
const pluginsDirectory = getOpenCodePluginsDirectory();
const destinationPath = getInstalledPluginPath();
try {
// Create plugins directory if needed
mkdirSync(pluginsDirectory, { recursive: true });
// Copy plugin bundle
copyFileSync(builtPluginPath, destinationPath);
console.log(` Plugin installed to: ${destinationPath}`);
logger.info('OPENCODE', 'Plugin installed', { destination: destinationPath });
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to install OpenCode plugin: ${message}`);
return 1;
}
}
// ============================================================================
// Context Injection (AGENTS.md)
// ============================================================================
/**
* Inject or update claude-mem context in OpenCode's AGENTS.md file.
*
* If the file doesn't exist, creates it with the context section.
* If the file exists, replaces the existing <claude-mem-context> section
* or appends one at the end.
*
* @param contextContent - The context content to inject (without tags)
* @returns 0 on success, 1 on failure
*/
export function injectContextIntoAgentsMd(contextContent: string): number {
const agentsMdPath = getOpenCodeAgentsMdPath();
try {
injectContextIntoMarkdownFile(agentsMdPath, contextContent, '# Claude-Mem Memory Context');
logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath });
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to inject context into AGENTS.md: ${message}`);
return 1;
}
}
/**
* Sync context from the worker into OpenCode's AGENTS.md.
* Fetches context from the worker API and writes it to AGENTS.md.
*
* @param port - Worker port number
* @param project - Project name for context filtering
*/
export async function syncContextToAgentsMd(
port: number,
project: string,
): Promise<void> {
try {
const response = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}`,
);
if (!response.ok) return;
const contextText = await response.text();
if (contextText && contextText.trim()) {
const injectResult = injectContextIntoAgentsMd(contextText);
if (injectResult !== 0) {
logger.warn('OPENCODE', 'Failed to inject context into AGENTS.md during sync');
}
}
} catch {
// Worker not available — non-critical
}
}
// ============================================================================
// Uninstallation
// ============================================================================
/**
* Remove the claude-mem plugin from OpenCode.
* Removes the plugin file and cleans up the AGENTS.md context section.
*
* @returns 0 on success, 1 on failure
*/
export function uninstallOpenCodePlugin(): number {
let hasErrors = false;
// Remove plugin file
const pluginPath = getInstalledPluginPath();
if (existsSync(pluginPath)) {
try {
unlinkSync(pluginPath);
console.log(` Removed plugin: ${pluginPath}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Failed to remove plugin: ${message}`);
hasErrors = true;
}
}
// Remove context section from AGENTS.md
const agentsMdPath = getOpenCodeAgentsMdPath();
if (existsSync(agentsMdPath)) {
try {
let content = readFileSync(agentsMdPath, 'utf-8');
const tagStartIndex = content.indexOf(CONTEXT_TAG_OPEN);
const tagEndIndex = content.indexOf(CONTEXT_TAG_CLOSE);
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
content =
content.slice(0, tagStartIndex).trimEnd() +
'\n' +
content.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length).trimStart();
// If the file is now essentially empty or only has our header, remove it
const trimmedContent = content.trim();
if (
trimmedContent.length === 0 ||
trimmedContent === '# Claude-Mem Memory Context'
) {
unlinkSync(agentsMdPath);
console.log(` Removed empty AGENTS.md`);
} else {
writeFileSync(agentsMdPath, trimmedContent + '\n', 'utf-8');
console.log(` Cleaned context from AGENTS.md`);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Failed to clean AGENTS.md: ${message}`);
hasErrors = true;
}
}
return hasErrors ? 1 : 0;
}
// ============================================================================
// Status Check
// ============================================================================
/**
* Check OpenCode integration status.
*
* @returns 0 always (informational only)
*/
export function checkOpenCodeStatus(): number {
console.log('\nClaude-Mem OpenCode Integration Status\n');
const configDirectory = getOpenCodeConfigDirectory();
const pluginPath = getInstalledPluginPath();
const agentsMdPath = getOpenCodeAgentsMdPath();
console.log(`Config directory: ${configDirectory}`);
console.log(` Exists: ${existsSync(configDirectory) ? 'yes' : 'no'}`);
console.log('');
console.log(`Plugin: ${pluginPath}`);
console.log(` Installed: ${existsSync(pluginPath) ? 'yes' : 'no'}`);
console.log('');
console.log(`Context (AGENTS.md): ${agentsMdPath}`);
if (existsSync(agentsMdPath)) {
const content = readFileSync(agentsMdPath, 'utf-8');
const hasContextTags = content.includes(CONTEXT_TAG_OPEN);
console.log(` Exists: yes`);
console.log(` Has claude-mem context: ${hasContextTags ? 'yes' : 'no'}`);
} else {
console.log(` Exists: no`);
}
console.log('');
return 0;
}
// ============================================================================
// Full Install Flow (used by npx install command)
// ============================================================================
/**
* Run the full OpenCode installation: plugin + context injection.
*
* @returns 0 on success, 1 on failure
*/
export async function installOpenCodeIntegration(): Promise<number> {
console.log('\nInstalling Claude-Mem for OpenCode...\n');
// Step 1: Install plugin
const pluginResult = installOpenCodePlugin();
if (pluginResult !== 0) {
return pluginResult;
}
// Step 2: Create initial context in AGENTS.md
const placeholderContext = `# Memory Context from Past Sessions
*No context yet. Complete your first session and context will appear here.*
Use claude-mem search tools for manual memory queries.`;
// Try to fetch real context from worker first
try {
const workerPort = getWorkerPort();
const healthResponse = await fetch(`http://127.0.0.1:${workerPort}/api/readiness`);
if (healthResponse.ok) {
const contextResponse = await fetch(
`http://127.0.0.1:${workerPort}/api/context/inject?project=opencode`,
);
if (contextResponse.ok) {
const realContext = await contextResponse.text();
if (realContext && realContext.trim()) {
const injectResult = injectContextIntoAgentsMd(realContext);
if (injectResult !== 0) {
logger.warn('OPENCODE', 'Failed to inject real context into AGENTS.md during install');
} else {
console.log(' Context injected from existing memory');
}
} else {
const injectResult = injectContextIntoAgentsMd(placeholderContext);
if (injectResult !== 0) {
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
} else {
console.log(' Placeholder context created (will populate after first session)');
}
}
} else {
const injectResult = injectContextIntoAgentsMd(placeholderContext);
if (injectResult !== 0) {
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
}
}
} else {
const injectResult = injectContextIntoAgentsMd(placeholderContext);
if (injectResult !== 0) {
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
} else {
console.log(' Placeholder context created (worker not running)');
}
}
} catch {
const injectResult = injectContextIntoAgentsMd(placeholderContext);
if (injectResult !== 0) {
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
} else {
console.log(' Placeholder context created (worker not running)');
}
}
console.log(`
Installation complete!
Plugin installed to: ${getInstalledPluginPath()}
Context file: ${getOpenCodeAgentsMdPath()}
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Restart OpenCode to load the plugin
3. Memory capture is automatic from then on
`);
return 0;
}
@@ -0,0 +1,514 @@
/**
* WindsurfHooksInstaller - Windsurf IDE integration for claude-mem
*
* Handles:
* - Windsurf hooks installation/uninstallation to ~/.codeium/windsurf/hooks.json
* - Context file generation (.windsurf/rules/claude-mem-context.md)
* - Project registry management for auto-context updates
*
* Windsurf hooks.json format:
* {
* "hooks": {
* "<event_name>": [{ "command": "...", "show_output": false, "working_directory": "..." }]
* }
* }
*
* Events registered (all post-action, non-blocking):
* - pre_user_prompt — session init + context injection
* - post_write_code — code generation observation
* - post_run_command — command execution observation
* - post_mcp_tool_use — MCP tool results
* - post_cascade_response — full AI response
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync } from 'fs';
import { logger } from '../../utils/logger.js';
import { getWorkerPort } from '../../shared/worker-utils.js';
import { DATA_DIR } from '../../shared/paths.js';
import { findBunPath, findWorkerServicePath } from './CursorHooksInstaller.js';
// ============================================================================
// Types
// ============================================================================
interface WindsurfHookEntry {
command: string;
show_output: boolean;
working_directory: string;
}
interface WindsurfHooksJson {
hooks: {
[eventName: string]: WindsurfHookEntry[];
};
}
interface WindsurfProjectRegistry {
[workspacePath: string]: {
installedAt: string;
};
}
// ============================================================================
// Constants
// ============================================================================
/** User-level hooks config — global coverage across all Windsurf workspaces */
const WINDSURF_HOOKS_DIR = path.join(homedir(), '.codeium', 'windsurf');
const WINDSURF_HOOKS_JSON_PATH = path.join(WINDSURF_HOOKS_DIR, 'hooks.json');
/** Windsurf context rule limit: 6,000 chars per file */
const WINDSURF_CONTEXT_CHAR_LIMIT = 6000;
/** Registry file for tracking projects with Windsurf hooks */
const WINDSURF_REGISTRY_FILE = path.join(DATA_DIR, 'windsurf-projects.json');
/** Hook events we register */
const WINDSURF_HOOK_EVENTS = [
'pre_user_prompt',
'post_write_code',
'post_run_command',
'post_mcp_tool_use',
'post_cascade_response',
] as const;
// ============================================================================
// Project Registry
// ============================================================================
/**
* Read the Windsurf project registry
*/
export function readWindsurfRegistry(): WindsurfProjectRegistry {
try {
if (!existsSync(WINDSURF_REGISTRY_FILE)) return {};
return JSON.parse(readFileSync(WINDSURF_REGISTRY_FILE, 'utf-8'));
} catch (error) {
logger.error('WINDSURF', 'Failed to read registry, using empty', {
file: WINDSURF_REGISTRY_FILE,
}, error as Error);
return {};
}
}
/**
* Write the Windsurf project registry
*/
export function writeWindsurfRegistry(registry: WindsurfProjectRegistry): void {
const dir = path.dirname(WINDSURF_REGISTRY_FILE);
mkdirSync(dir, { recursive: true });
writeFileSync(WINDSURF_REGISTRY_FILE, JSON.stringify(registry, null, 2));
}
/**
* Register a project for auto-context updates.
* Keys by full workspacePath to avoid collisions between directories with the same basename.
*/
export function registerWindsurfProject(workspacePath: string): void {
const registry = readWindsurfRegistry();
registry[workspacePath] = {
installedAt: new Date().toISOString(),
};
writeWindsurfRegistry(registry);
logger.info('WINDSURF', 'Registered project for auto-context updates', { workspacePath });
}
/**
* Unregister a project from auto-context updates
*/
export function unregisterWindsurfProject(workspacePath: string): void {
const registry = readWindsurfRegistry();
if (registry[workspacePath]) {
delete registry[workspacePath];
writeWindsurfRegistry(registry);
logger.info('WINDSURF', 'Unregistered project', { workspacePath });
}
}
/**
* Update Windsurf context files for a registered project.
* Called by SDK agents after saving a summary.
*/
export async function updateWindsurfContextForProject(projectName: string, workspacePath: string, port: number): Promise<void> {
const registry = readWindsurfRegistry();
const entry = registry[workspacePath];
if (!entry) return; // Project doesn't have Windsurf hooks installed
try {
const response = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
);
if (!response.ok) return;
const context = await response.text();
if (!context || !context.trim()) return;
writeWindsurfContextFile(workspacePath, context);
logger.debug('WINDSURF', 'Updated context file', { projectName, workspacePath });
} catch (error) {
// Background context update — failure is non-critical
logger.error('WINDSURF', 'Failed to update context file', { projectName, workspacePath }, error as Error);
}
}
// ============================================================================
// Context File
// ============================================================================
/**
* Write context to the workspace-level Windsurf rules directory.
* Windsurf rules are workspace-scoped: .windsurf/rules/claude-mem-context.md
* Rule file limit: 6,000 chars per file.
*/
export function writeWindsurfContextFile(workspacePath: string, context: string): void {
const rulesDir = path.join(workspacePath, '.windsurf', 'rules');
const rulesFile = path.join(rulesDir, 'claude-mem-context.md');
const tempFile = `${rulesFile}.tmp`;
mkdirSync(rulesDir, { recursive: true });
let content = `# Memory Context from Past Sessions
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
${context}
---
*Auto-updated by claude-mem after each session. Use MCP search tools for detailed queries.*
`;
// Enforce Windsurf's 6K char limit
if (content.length > WINDSURF_CONTEXT_CHAR_LIMIT) {
content = content.slice(0, WINDSURF_CONTEXT_CHAR_LIMIT - 50) +
'\n\n*[Truncated — use MCP search for full history]*\n';
}
// Atomic write: temp file + rename
writeFileSync(tempFile, content);
renameSync(tempFile, rulesFile);
}
// ============================================================================
// Hook Installation
// ============================================================================
/**
* Build the hook command string for a given event.
* Uses bun to run worker-service.cjs with the windsurf platform adapter.
*/
function buildHookCommand(bunPath: string, workerServicePath: string, eventName: string): string {
// Map Windsurf event names to unified CLI hook commands
const eventToCommand: Record<string, string> = {
'pre_user_prompt': 'session-init',
'post_write_code': 'file-edit',
'post_run_command': 'observation',
'post_mcp_tool_use': 'observation',
'post_cascade_response': 'observation',
};
const hookCommand = eventToCommand[eventName] ?? 'observation';
return `"${bunPath}" "${workerServicePath}" hook windsurf ${hookCommand}`;
}
/**
* Read existing hooks.json, merge our hooks, and write back.
* Preserves any existing hooks from other tools.
*/
function mergeAndWriteHooksJson(
bunPath: string,
workerServicePath: string,
workingDirectory: string,
): void {
mkdirSync(WINDSURF_HOOKS_DIR, { recursive: true });
// Read existing hooks.json if present
let existingConfig: WindsurfHooksJson = { hooks: {} };
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
try {
existingConfig = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
if (!existingConfig.hooks) {
existingConfig.hooks = {};
}
} catch (error) {
throw new Error(`Corrupt hooks.json at ${WINDSURF_HOOKS_JSON_PATH}, refusing to overwrite`);
}
}
// For each event, add our hook entry (remove any previous claude-mem entries first)
for (const eventName of WINDSURF_HOOK_EVENTS) {
const command = buildHookCommand(bunPath, workerServicePath, eventName);
const hookEntry: WindsurfHookEntry = {
command,
show_output: false,
working_directory: workingDirectory,
};
// Get existing hooks for this event, filtering out old claude-mem ones
const existingHooks = (existingConfig.hooks[eventName] ?? []).filter(
(hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf')
);
existingConfig.hooks[eventName] = [...existingHooks, hookEntry];
}
writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(existingConfig, null, 2));
}
/**
* Install Windsurf hooks to ~/.codeium/windsurf/hooks.json (user-level).
* Merges with existing hooks.json to preserve other integrations.
*/
export async function installWindsurfHooks(): Promise<number> {
console.log('\nInstalling Claude-Mem Windsurf hooks (user level)...\n');
// Find the worker-service.cjs path
const workerServicePath = findWorkerServicePath();
if (!workerServicePath) {
console.error('Could not find worker-service.cjs');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs');
return 1;
}
// Find bun executable — required because worker-service.cjs uses bun:sqlite
const bunPath = findBunPath();
if (!bunPath) {
console.error('Could not find Bun runtime');
console.error(' Install Bun: curl -fsSL https://bun.sh/install | bash');
return 1;
}
// IMPORTANT: Tilde expansion is NOT supported in working_directory — use absolute paths
const workingDirectory = path.dirname(workerServicePath);
try {
console.log(` Using Bun runtime: ${bunPath}`);
console.log(` Worker service: ${workerServicePath}`);
// Merge our hooks into the existing hooks.json
mergeAndWriteHooksJson(bunPath, workerServicePath, workingDirectory);
console.log(` Created/merged hooks.json`);
// Set up initial context for the current workspace
const workspaceRoot = process.cwd();
await setupWindsurfProjectContext(workspaceRoot);
console.log(`
Installation complete!
Hooks installed to: ${WINDSURF_HOOKS_JSON_PATH}
Using unified CLI: bun worker-service.cjs hook windsurf <command>
Events registered:
- pre_user_prompt (session init + context injection)
- post_write_code (code generation observation)
- post_run_command (command execution observation)
- post_mcp_tool_use (MCP tool results)
- post_cascade_response (full AI response)
Next steps:
1. Start claude-mem worker: claude-mem start
2. Restart Windsurf to load the hooks
3. Context is injected via .windsurf/rules/claude-mem-context.md (workspace-level)
`);
return 0;
} catch (error) {
console.error(`\nInstallation failed: ${(error as Error).message}`);
return 1;
}
}
/**
* Setup initial context file for a Windsurf workspace
*/
async function setupWindsurfProjectContext(workspaceRoot: string): Promise<void> {
const port = getWorkerPort();
const projectName = path.basename(workspaceRoot);
let contextGenerated = false;
console.log(` Generating initial context...`);
try {
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`);
if (healthResponse.ok) {
const contextResponse = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
);
if (contextResponse.ok) {
const context = await contextResponse.text();
if (context && context.trim()) {
writeWindsurfContextFile(workspaceRoot, context);
contextGenerated = true;
console.log(` Generated initial context from existing memory`);
}
}
}
} catch (error) {
// Worker not running during install — non-critical
logger.debug('WINDSURF', 'Worker not running during install', {}, error as Error);
}
if (!contextGenerated) {
// Create placeholder context file
const rulesDir = path.join(workspaceRoot, '.windsurf', 'rules');
mkdirSync(rulesDir, { recursive: true });
const rulesFile = path.join(rulesDir, 'claude-mem-context.md');
const placeholderContent = `# Memory Context from Past Sessions
*No context yet. Complete your first session and context will appear here.*
Use claude-mem's MCP search tools for manual memory queries.
`;
writeFileSync(rulesFile, placeholderContent);
console.log(` Created placeholder context file (will populate after first session)`);
}
// Register project for automatic context updates after summaries
registerWindsurfProject(workspaceRoot);
console.log(` Registered for auto-context updates`);
}
/**
* Uninstall Windsurf hooks — removes claude-mem entries from hooks.json
*/
export function uninstallWindsurfHooks(): number {
console.log('\nUninstalling Claude-Mem Windsurf hooks...\n');
try {
// Remove our entries from hooks.json (preserve other integrations)
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
try {
const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
for (const eventName of WINDSURF_HOOK_EVENTS) {
if (config.hooks[eventName]) {
config.hooks[eventName] = config.hooks[eventName].filter(
(hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf')
);
// Remove empty arrays
if (config.hooks[eventName].length === 0) {
delete config.hooks[eventName];
}
}
}
// If no hooks remain, remove the file entirely
if (Object.keys(config.hooks).length === 0) {
unlinkSync(WINDSURF_HOOKS_JSON_PATH);
console.log(` Removed hooks.json (no hooks remaining)`);
} else {
writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(config, null, 2));
console.log(` Removed claude-mem entries from hooks.json (other hooks preserved)`);
}
} catch (error) {
console.log(` Warning: could not parse hooks.json — leaving file intact to preserve other hooks`);
}
} else {
console.log(` No hooks.json found`);
}
// Remove context file from the current workspace
const workspaceRoot = process.cwd();
const contextFile = path.join(workspaceRoot, '.windsurf', 'rules', 'claude-mem-context.md');
if (existsSync(contextFile)) {
unlinkSync(contextFile);
console.log(` Removed context file`);
}
// Unregister project
unregisterWindsurfProject(workspaceRoot);
console.log(` Unregistered from auto-context updates`);
console.log(`\nUninstallation complete!\n`);
console.log('Restart Windsurf to apply changes.');
return 0;
} catch (error) {
console.error(`\nUninstallation failed: ${(error as Error).message}`);
return 1;
}
}
/**
* Check Windsurf hooks installation status
*/
export function checkWindsurfHooksStatus(): number {
console.log('\nClaude-Mem Windsurf Hooks Status\n');
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
console.log(`User-level: Installed`);
console.log(` Config: ${WINDSURF_HOOKS_JSON_PATH}`);
try {
const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
const registeredEvents = WINDSURF_HOOK_EVENTS.filter(
(event) => config.hooks[event]?.some(
(hook) => hook.command.includes('worker-service') && hook.command.includes('windsurf')
)
);
console.log(` Events: ${registeredEvents.length}/${WINDSURF_HOOK_EVENTS.length} registered`);
for (const event of registeredEvents) {
console.log(` - ${event}`);
}
} catch {
console.log(` Mode: Unable to parse hooks.json`);
}
// Check for context file in current workspace
const contextFile = path.join(process.cwd(), '.windsurf', 'rules', 'claude-mem-context.md');
if (existsSync(contextFile)) {
console.log(` Context: Active (current workspace)`);
} else {
console.log(` Context: Not yet generated for this workspace`);
}
} else {
console.log(`User-level: Not installed`);
console.log(`\nNo hooks installed. Run: claude-mem windsurf install\n`);
}
console.log('');
return 0;
}
/**
* Handle windsurf subcommand for hooks installation
*/
export async function handleWindsurfCommand(subcommand: string, _args: string[]): Promise<number> {
switch (subcommand) {
case 'install':
return installWindsurfHooks();
case 'uninstall':
return uninstallWindsurfHooks();
case 'status':
return checkWindsurfHooksStatus();
default: {
console.log(`
Claude-Mem Windsurf Integration
Usage: claude-mem windsurf <command>
Commands:
install Install Windsurf hooks (user-level, ~/.codeium/windsurf/hooks.json)
uninstall Remove Windsurf hooks
status Check installation status
Examples:
claude-mem windsurf install # Install hooks globally
claude-mem windsurf uninstall # Remove hooks
claude-mem windsurf status # Check if hooks are installed
For more info: https://docs.claude-mem.ai/windsurf
`);
return 0;
}
}
}
+7 -1
View File
@@ -1,6 +1,12 @@
/**
* Integrations module - IDE integrations (Cursor, etc.)
* Integrations module - IDE integrations (Cursor, Gemini CLI, OpenCode, Windsurf, etc.)
*/
export * from './types.js';
export * from './CursorHooksInstaller.js';
export * from './GeminiCliHooksInstaller.js';
export * from './OpenCodeInstaller.js';
export * from './WindsurfHooksInstaller.js';
export * from './OpenClawInstaller.js';
export * from './CodexCliInstaller.js';
export * from './McpIntegrations.js';
@@ -397,6 +397,19 @@ export class PendingMessageStore {
return result.count;
}
/**
* Peek at pending message types for a session (for tier routing).
* Returns list of { message_type, tool_name } without claiming.
*/
peekPendingTypes(sessionDbId: number): Array<{ message_type: string; tool_name: string | null }> {
const stmt = this.db.prepare(`
SELECT message_type, tool_name FROM pending_messages
WHERE session_db_id = ? AND status IN ('pending', 'processing')
ORDER BY id ASC
`);
return stmt.all(sessionDbId) as Array<{ message_type: string; tool_name: string | null }>;
}
/**
* Check if any session has pending work.
* Excludes 'processing' messages stuck for >5 minutes (resets them to 'pending' as a side effect).
+34 -1
View File
@@ -509,6 +509,38 @@ export const migration007: Migration = {
};
/**
* All migrations in order
*/
/**
* Migration 008: Observation feedback table for tracking observation usage
*
* Tracks how observations are used (semantic injection hits, search access,
* explicit retrieval). Foundation for future Thompson Sampling optimization.
*/
export const migration008: Migration = {
version: 25,
up: (db: Database) => {
db.run(`
CREATE TABLE IF NOT EXISTS observation_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observation_id INTEGER NOT NULL,
signal_type TEXT NOT NULL,
session_db_id INTEGER,
created_at_epoch INTEGER NOT NULL,
metadata TEXT,
FOREIGN KEY (observation_id) REFERENCES observations(id) ON DELETE CASCADE
)
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_feedback_observation ON observation_feedback(observation_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_feedback_signal ON observation_feedback(signal_type)`);
console.log('✅ Created observation_feedback table for usage tracking');
},
down: (db: Database) => {
db.run(`DROP TABLE IF EXISTS observation_feedback`);
}
};
/**
* All migrations in order
*/
@@ -519,5 +551,6 @@ export const migrations: Migration[] = [
migration004,
migration005,
migration006,
migration007
migration007,
migration008
];
+28
View File
@@ -34,6 +34,7 @@ export class MigrationRunner {
this.addOnUpdateCascadeToForeignKeys();
this.addObservationContentHashColumn();
this.addSessionCustomTitleColumn();
this.createObservationFeedbackTable();
}
/**
@@ -863,4 +864,31 @@ export class MigrationRunner {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
}
/**
* Create observation_feedback table for tracking observation usage signals.
* Foundation for tier routing optimization and future Thompson Sampling.
* Schema version 24.
*/
private createObservationFeedbackTable(): void {
const applied = this.db.query('SELECT 1 FROM schema_versions WHERE version = 24').get();
if (applied) return;
this.db.run(`
CREATE TABLE IF NOT EXISTS observation_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observation_id INTEGER NOT NULL,
signal_type TEXT NOT NULL,
session_db_id INTEGER,
created_at_epoch INTEGER NOT NULL,
metadata TEXT,
FOREIGN KEY (observation_id) REFERENCES observations(id) ON DELETE CASCADE
)
`);
this.db.run('CREATE INDEX IF NOT EXISTS idx_feedback_observation ON observation_feedback(observation_id)');
this.db.run('CREATE INDEX IF NOT EXISTS idx_feedback_signal ON observation_feedback(signal_type)');
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
logger.debug('DB', 'Created observation_feedback table for usage tracking');
}
}
+35 -5
View File
@@ -283,11 +283,41 @@ export class ChromaSync {
metadatas: cleanMetadatas
});
} catch (error) {
logger.error('CHROMA_SYNC', 'Batch add failed, continuing with remaining batches', {
collection: this.collectionName,
batchStart: i,
batchSize: batch.length
}, error as Error);
const errMsg = error instanceof Error ? error.message : String(error);
// APPROVED OVERRIDE: Duplicate IDs from partial write before timeout/crash.
// chroma_update_documents only updates *existing* IDs — it silently ignores
// missing ones. So we delete-then-add to guarantee all IDs are written.
if (errMsg.includes('already exist')) {
try {
await chromaMcp.callTool('chroma_delete_documents', {
collection_name: this.collectionName,
ids: batch.map(d => d.id)
});
await chromaMcp.callTool('chroma_add_documents', {
collection_name: this.collectionName,
ids: batch.map(d => d.id),
documents: batch.map(d => d.document),
metadatas: cleanMetadatas
});
logger.info('CHROMA_SYNC', 'Batch reconciled via delete+add after duplicate conflict', {
collection: this.collectionName,
batchStart: i,
batchSize: batch.length
});
} catch (reconcileError) {
logger.error('CHROMA_SYNC', 'Batch reconcile (delete+add) failed', {
collection: this.collectionName,
batchStart: i,
batchSize: batch.length
}, reconcileError as Error);
}
} else {
logger.error('CHROMA_SYNC', 'Batch add failed, continuing with remaining batches', {
collection: this.collectionName,
batchStart: i,
batchSize: batch.length
}, error as Error);
}
}
}
+6 -4
View File
@@ -8,7 +8,7 @@ export const DEFAULT_STATE_PATH = join(homedir(), '.claude-mem', 'transcript-wat
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
name: 'codex',
version: '0.2',
version: '0.3',
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
events: [
{
@@ -46,13 +46,14 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
},
{
name: 'tool-use',
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call'] },
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call', 'exec_command'] },
action: 'tool_use',
fields: {
toolId: 'payload.call_id',
toolName: {
coalesce: [
'payload.name',
'payload.type',
{ value: 'web_search' }
]
},
@@ -60,6 +61,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
coalesce: [
'payload.arguments',
'payload.input',
'payload.command',
'payload.action'
]
}
@@ -67,7 +69,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
},
{
name: 'tool-result',
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output'] },
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output', 'exec_command_output'] },
action: 'tool_result',
fields: {
toolId: 'payload.call_id',
@@ -76,7 +78,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
},
{
name: 'session-end',
match: { path: 'payload.type', equals: 'turn_aborted' },
match: { path: 'payload.type', in: ['turn_aborted', 'turn_completed'] },
action: 'session_end'
}
]
+42 -2
View File
@@ -101,6 +101,9 @@ import {
updateCursorContextForProject,
handleCursorCommand
} from './integrations/CursorHooksInstaller.js';
import {
handleGeminiCliCommand
} from './integrations/GeminiCliHooksInstaller.js';
// Service layer imports
import { DatabaseManager } from './worker/DatabaseManager.js';
@@ -128,6 +131,10 @@ import { MemoryRoutes } from './worker/http/routes/MemoryRoutes.js';
// Process management for zombie cleanup (Issue #737)
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js';
// Transcript watcher for external CLI session monitoring
import { TranscriptWatcher } from './transcripts/watcher.js';
import { loadTranscriptWatchConfig, expandHomePath, DEFAULT_CONFIG_PATH as TRANSCRIPT_CONFIG_PATH } from './transcripts/config.js';
/**
* Build JSON status output for hook framework communication.
* This is a pure function extracted for testability.
@@ -189,6 +196,9 @@ export class WorkerService {
// Stale session reaper interval (Issue #1168)
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null;
// Transcript watcher for external CLI sessions (e.g. Codex, Gemini)
private transcriptWatcher: TranscriptWatcher | null = null;
// AI interaction tracking for health endpoint
private lastAiInteraction: {
timestamp: number;
@@ -421,6 +431,22 @@ export class WorkerService {
this.resolveInitialization();
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
// Auto-start transcript watchers if configured
if (existsSync(TRANSCRIPT_CONFIG_PATH)) {
try {
const transcriptConfig = loadTranscriptWatchConfig(TRANSCRIPT_CONFIG_PATH);
if (transcriptConfig.watches.length > 0) {
const transcriptStatePath = expandHomePath(transcriptConfig.stateFile ?? '~/.claude-mem/transcript-watch-state.json');
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, transcriptStatePath);
await this.transcriptWatcher.start();
logger.info('SYSTEM', `Transcript watcher started with ${transcriptConfig.watches.length} watch target(s)`);
}
} catch (transcriptError) {
logger.warn('SYSTEM', 'Failed to start transcript watcher (non-fatal)', {}, transcriptError as Error);
// Non-fatal — worker continues without transcript watching
}
}
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
if (this.chromaMcpManager) {
ChromaSync.backfillAllProjects().then(() => {
@@ -922,6 +948,13 @@ export class WorkerService {
this.staleSessionReaperInterval = null;
}
// Stop transcript watcher
if (this.transcriptWatcher) {
this.transcriptWatcher.stop();
this.transcriptWatcher = null;
logger.info('SYSTEM', 'Transcript watcher stopped');
}
await performGracefulShutdown({
server: this.server.getHttpServer(),
sessionManager: this.sessionManager,
@@ -1174,14 +1207,21 @@ async function main() {
break;
}
case 'gemini-cli': {
const geminiSubcommand = process.argv[3];
const geminiResult = await handleGeminiCliCommand(geminiSubcommand, process.argv.slice(4));
process.exit(geminiResult);
break;
}
case 'hook': {
// Validate CLI args first (before any I/O)
const platform = process.argv[3];
const event = process.argv[4];
if (!platform || !event) {
console.error('Usage: claude-mem hook <platform> <event>');
console.error('Platforms: claude-code, cursor, raw');
console.error('Events: context, session-init, observation, summarize, session-complete');
console.error('Platforms: claude-code, cursor, gemini-cli, raw');
console.error('Events: context, session-init, observation, summarize, session-complete, user-message');
process.exit(1);
}
+2
View File
@@ -40,6 +40,8 @@ export interface ActiveSession {
// CLAIM-CONFIRM FIX: Track IDs of messages currently being processed
// These IDs will be confirmed (deleted) after successful storage
processingMessageIds: number[];
// Tier routing: model override per session based on queue complexity
modelOverride?: string;
}
export interface PendingMessage {
+2 -2
View File
@@ -49,8 +49,8 @@ export class SDKAgent {
// Find Claude executable
const claudePath = this.findClaudeExecutable();
// Get model ID and disallowed tools
const modelId = this.getModelId();
// Get model ID (tier routing override takes precedence)
const modelId = session.modelOverride || this.getModelId();
// Memory agent is OBSERVER ONLY - no tools allowed
const disallowedTools = [
'Bash', // Prevent infinite loops
@@ -68,6 +68,19 @@ export async function processAgentResponse(
const observations = parseObservations(text, session.contentSessionId);
const summary = parseSummary(text, session.sessionDbId);
if (
text.trim() &&
observations.length === 0 &&
!summary &&
!/<observation>|<summary>|<skip_summary\b/.test(text)
) {
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
logger.warn('PARSER', `${agentName} returned non-XML response; observation content was discarded`, {
sessionId: session.sessionDbId,
preview
});
}
// Convert nullable fields to empty strings for storeSummary (if summary exists)
const summaryForStore = normalizeSummaryForStorage(summary);
@@ -38,6 +38,7 @@ export class SearchRoutes extends BaseRouteHandler {
app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this));
app.get('/api/context/preview', this.handleContextPreview.bind(this));
app.get('/api/context/inject', this.handleContextInject.bind(this));
app.post('/api/context/semantic', this.handleSemanticContext.bind(this));
// Timeline and help endpoints
app.get('/api/timeline/by-query', this.handleGetTimelineByQuery.bind(this));
@@ -246,6 +247,54 @@ export class SearchRoutes extends BaseRouteHandler {
res.send(contextText);
});
/**
* Semantic context search for per-prompt injection
* POST /api/context/semantic { q, project?, limit? }
*
* Queries Chroma for observations semantically similar to the user's prompt.
* Returns compact markdown for injection as additionalContext.
*/
private handleSemanticContext = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const query = (req.body?.q || req.query.q) as string;
const project = (req.body?.project || req.query.project) as string;
const limit = Math.min(Math.max(parseInt(String(req.body?.limit || req.query.limit || '5'), 10) || 5, 1), 20);
if (!query || query.length < 20) {
res.json({ context: '', count: 0 });
return;
}
try {
const result = await this.searchManager.search({
query,
type: 'observations',
project,
limit: String(limit),
format: 'json'
});
const observations = (result as any)?.observations || [];
if (!observations.length) {
res.json({ context: '', count: 0 });
return;
}
// Format as compact markdown for context injection
const lines: string[] = ['## Relevant Past Work (semantic match)\n'];
for (const obs of observations.slice(0, limit)) {
const date = obs.created_at?.slice(0, 10) || '';
lines.push(`### ${obs.title || 'Observation'} (${date})`);
if (obs.narrative) lines.push(obs.narrative);
lines.push('');
}
res.json({ context: lines.join('\n'), count: observations.length });
} catch (error) {
logger.error('SEARCH', 'Semantic context query failed', {}, error as Error);
res.json({ context: '', count: 0 });
}
});
/**
* Get timeline by query (search first, then get timeline around best match)
* GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10
@@ -106,6 +106,8 @@ export class SessionRoutes extends BaseRouteHandler {
// Start generator if not running
if (!session.generatorPromise) {
// Apply tier routing before starting the generator
this.applyTierRouting(session);
this.spawnInProgress.set(sessionDbId, true);
this.startGeneratorWithProvider(session, selectedProvider, source);
return;
@@ -126,6 +128,7 @@ export class SessionRoutes extends BaseRouteHandler {
session.abortController = new AbortController();
session.lastGeneratorActivity = Date.now();
// Start a fresh generator
this.applyTierRouting(session);
this.spawnInProgress.set(sessionDbId, true);
this.startGeneratorWithProvider(session, selectedProvider, 'stale-recovery');
return;
@@ -283,6 +286,7 @@ export class SessionRoutes extends BaseRouteHandler {
this.crashRecoveryScheduled.delete(sessionDbId);
const stillExists = this.sessionManager.getSession(sessionDbId);
if (stillExists && !stillExists.generatorPromise) {
this.applyTierRouting(stillExists);
this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery');
}
}, backoffMs);
@@ -321,6 +325,7 @@ export class SessionRoutes extends BaseRouteHandler {
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
app.get('/api/sessions/status', this.handleStatusByClaudeId.bind(this));
}
/**
@@ -631,6 +636,39 @@ export class SessionRoutes extends BaseRouteHandler {
res.json({ status: 'queued' });
});
/**
* Get session status by contentSessionId (summarize handler polls this)
* GET /api/sessions/status?contentSessionId=...
*
* Returns queue depth so the Stop hook can wait for summary completion.
*/
private handleStatusByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const contentSessionId = req.query.contentSessionId as string;
if (!contentSessionId) {
return this.badRequest(res, 'Missing contentSessionId query parameter');
}
const store = this.dbManager.getSessionStore();
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
const session = this.sessionManager.getSession(sessionDbId);
if (!session) {
res.json({ status: 'not_found', queueLength: 0 });
return;
}
const pendingStore = this.sessionManager.getPendingMessageStore();
const queueLength = pendingStore.getPendingCount(sessionDbId);
res.json({
status: 'active',
sessionDbId,
queueLength,
uptime: Date.now() - session.startTime
});
});
/**
* Complete session by contentSessionId (session-complete hook uses this)
* POST /api/sessions/complete
@@ -669,6 +707,8 @@ export class SessionRoutes extends BaseRouteHandler {
}
// Complete the session (removes from active sessions map)
// Note: The Stop hook (summarize handler) waits for pending work before calling
// this endpoint. No polling here — that's the hook's responsibility.
await this.completionHandler.completeByDbId(sessionDbId);
logger.info('SESSION', 'Session completed via API', {
@@ -777,4 +817,60 @@ export class SessionRoutes extends BaseRouteHandler {
contextInjected
});
});
// Simple tool names that produce low-complexity observations
private static readonly SIMPLE_TOOLS = new Set([
'Read', 'Glob', 'Grep', 'LS', 'ListMcpResourcesTool'
]);
/**
* Apply tier routing: select model based on pending queue complexity.
* - Summarize in queue summary model (e.g., Opus)
* - All simple tools simple model (e.g., Haiku)
* - Otherwise default model (no override)
*/
private applyTierRouting(session: NonNullable<ReturnType<typeof this.sessionManager.getSession>>): void {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
if (settings.CLAUDE_MEM_TIER_ROUTING_ENABLED === 'false') {
session.modelOverride = undefined;
return;
}
// Clear stale override before re-evaluating — prevents previous tier
// from persisting when queue composition changes between spawns.
session.modelOverride = undefined;
const pendingStore = this.sessionManager.getPendingMessageStore();
const pending = pendingStore.peekPendingTypes(session.sessionDbId);
if (pending.length === 0) {
session.modelOverride = undefined;
return;
}
const hasSummarize = pending.some(m => m.message_type === 'summarize');
const allSimple = pending.every(m =>
m.message_type === 'observation' && m.tool_name && SessionRoutes.SIMPLE_TOOLS.has(m.tool_name)
);
if (hasSummarize) {
const summaryModel = settings.CLAUDE_MEM_TIER_SUMMARY_MODEL;
if (summaryModel) {
session.modelOverride = summaryModel;
logger.debug('SESSION', `Tier routing: summary model`, {
sessionId: session.sessionDbId, model: summaryModel
});
}
} else if (allSimple) {
const simpleModel = settings.CLAUDE_MEM_TIER_SIMPLE_MODEL;
if (simpleModel) {
session.modelOverride = simpleModel;
logger.debug('SESSION', `Tier routing: simple model`, {
sessionId: session.sessionDbId, model: simpleModel
});
}
} else {
session.modelOverride = undefined;
}
}
}
@@ -24,9 +24,30 @@ export class SessionCompletionHandler {
* Used by DELETE /api/sessions/:id and POST /api/sessions/:id/complete
*/
async completeByDbId(sessionDbId: number): Promise<void> {
// Delete from session manager (aborts SDK agent)
// Delete from session manager (aborts SDK agent via SIGTERM)
await this.sessionManager.deleteSession(sessionDbId);
// Drain orphaned pending messages left by SIGTERM.
// When deleteSession() aborts the generator, pending messages in the queue
// are never processed. Without drain, they stay in 'pending' status forever
// since no future generator will pick them up for a completed session.
// Note: this is best-effort — if a generator outlives the 30s SIGTERM timeout
// (SessionManager.deleteSession), it may enqueue messages after this drain.
// In practice this race is rare (zero orphans over 23 days, 3400+ observations).
try {
const pendingStore = this.sessionManager.getPendingMessageStore();
const drainedCount = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
if (drainedCount > 0) {
logger.warn('SESSION', `Drained ${drainedCount} orphaned pending messages on session completion`, {
sessionId: sessionDbId, drainedCount
});
}
} catch (e) {
logger.debug('SESSION', 'Failed to drain pending queue on session completion', {
sessionId: sessionDbId, error: e instanceof Error ? e.message : String(e)
});
}
// Broadcast session completed event
this.eventBroadcaster.broadcastSessionCompleted(sessionDbId);
}
+14
View File
@@ -54,6 +54,13 @@ export interface SettingsDefaults {
// Exclusion Settings
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
// Semantic Context Injection (per-prompt via Chroma)
CLAUDE_MEM_SEMANTIC_INJECT: string; // 'true' | 'false' - inject relevant observations on each prompt
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: string; // Max observations to inject per prompt
// Tier Routing (model selection by queue complexity)
CLAUDE_MEM_TIER_ROUTING_ENABLED: string; // 'true' | 'false' - enable model tier routing
CLAUDE_MEM_TIER_SIMPLE_MODEL: string; // Tier alias or model ID for simple tool observations (Read, Glob, Grep)
CLAUDE_MEM_TIER_SUMMARY_MODEL: string; // Tier alias or model ID for session summaries
// Chroma Vector Database Configuration
CLAUDE_MEM_CHROMA_ENABLED: string; // 'true' | 'false' - set to 'false' for SQLite-only mode
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
@@ -113,6 +120,13 @@ export class SettingsDefaultsManager {
// Exclusion Settings
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
// Semantic Context Injection (per-prompt via Chroma vector search)
CLAUDE_MEM_SEMANTIC_INJECT: 'true', // Inject relevant past observations on every UserPromptSubmit
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: '5', // Top-N most relevant observations to inject per prompt
// Tier Routing (model selection by queue complexity)
CLAUDE_MEM_TIER_ROUTING_ENABLED: 'true', // Route observations to models by complexity
CLAUDE_MEM_TIER_SIMPLE_MODEL: 'haiku', // Portable tier alias — works across Direct API, Bedrock, Vertex, Azure (see #1463)
CLAUDE_MEM_TIER_SUMMARY_MODEL: '', // Empty = use default model for summaries
// Chroma Vector Database Configuration
CLAUDE_MEM_CHROMA_ENABLED: 'true', // Set to 'false' to disable Chroma and use SQLite-only search
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' uses persistent chroma-mcp via uvx, 'remote' connects to existing server
+68
View File
@@ -0,0 +1,68 @@
/**
* Shared context injection utilities for claude-mem.
*
* Provides tag constants and a function to inject or update a
* <claude-mem-context> section in any markdown file. Used by
* MCP integrations and OpenCode installer.
*/
import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
// ============================================================================
// Tag Constants
// ============================================================================
export const CONTEXT_TAG_OPEN = '<claude-mem-context>';
export const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
// ============================================================================
// Context Injection
// ============================================================================
/**
* Inject or update a <claude-mem-context> section in a markdown file.
* Creates the file if it doesn't exist. Preserves content outside the tags.
*
* @param filePath - Absolute path to the target markdown file.
* @param contextContent - The content to place between the context tags.
* @param headerLine - Optional first line written when creating a new file
* (e.g. `"# Claude-Mem Memory Context"` for AGENTS.md).
*/
export function injectContextIntoMarkdownFile(
filePath: string,
contextContent: string,
headerLine?: string,
): void {
const parentDirectory = path.dirname(filePath);
mkdirSync(parentDirectory, { recursive: true });
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
if (existsSync(filePath)) {
let existingContent = readFileSync(filePath, 'utf-8');
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
// Replace existing section
existingContent =
existingContent.slice(0, tagStartIndex) +
wrappedContent +
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
} else {
// Append section
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
}
writeFileSync(filePath, existingContent, 'utf-8');
} else {
// Create new file
if (headerLine) {
writeFileSync(filePath, `${headerLine}\n\n${wrappedContent}\n`, 'utf-8');
} else {
writeFileSync(filePath, wrappedContent + '\n', 'utf-8');
}
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Shared JSON file utilities for claude-mem.
*
* Provides safe read/write helpers used across the CLI and services.
*/
import { existsSync, readFileSync } from 'fs';
import { logger } from './logger.js';
/**
* Read a JSON file safely, returning a default value if the file
* does not exist. Throws on corrupt JSON to prevent silent data loss
* when callers merge and write back.
*
* @param filePath - Absolute path to the JSON file.
* @param defaultValue - Value returned when the file is missing.
* @returns The parsed JSON content, or `defaultValue` when file is missing.
* @throws {Error} When the file exists but contains invalid JSON.
*/
export function readJsonSafe<T>(filePath: string, defaultValue: T): T {
if (!existsSync(filePath)) return defaultValue;
try {
return JSON.parse(readFileSync(filePath, 'utf-8'));
} catch (error) {
throw new Error(`Corrupt JSON file, refusing to overwrite: ${filePath}`);
}
}