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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user