Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ Technical reference for how `claude-smart` wires Claude Code's lifecycle hooks t

## Core components

1. **6 lifecycle hooks** (`plugin/hooks/hooks.json`)
1. **5 lifecycle hooks** (`plugin/hooks/hooks.json`)
- `SessionStart` — applies claude-smart/reflexio defaults and starts supporting services; it does not retrieve memory.
- `UserPromptSubmit` — buffers each user turn, heuristically flags corrections, and searches reflexio with the prompt text to inject matching preference/skill hits as `additionalContext`.
- `PreToolUse` — searches reflexio keyed on the first line of the tool-call text (Bash command, Edit `new_string`, etc.) and injects top matches as `additionalContext`.
- `PostToolUse` — records tool invocations for later extraction.
- `Stop` — finalizes the assistant turn from the transcript, publishes to reflexio.
- `SessionEnd` — flushes the remaining buffer with `force_extraction=True`.
Expand All @@ -30,9 +29,12 @@ Claude Code session
└────────────┬────────────┘
Next user prompt / tool call → query-aware search for project-specific skills,
shared skills, and preferences
→ additionalContext injected with matching hits
Next user prompt → query-aware search for project-specific skills,
shared skills, and preferences
→ additionalContext injected with matching hits

Mid-task model call → search_learnings MCP tool with a rewritten query + cwd
→ markdown results returned directly to the model
```

## Mapping to reflexio
Expand All @@ -53,15 +55,24 @@ The adapter method names still mirror Reflexio's wire fields (`user_profiles`, `

### Per-session injection dedup

Hook searches (UserPromptSubmit and PreToolUse) fire many times per session, so
Hook searches (UserPromptSubmit) fire many times per session, so
`search_all` passes the Claude Code `session_id` to reflexio's `/api/search`.
The server remembers which rules it already returned to that session and skips
them on later searches, backfilling next-best matches instead — a turn with ten
tool calls injects each rule once, not ten times. The seen-state lives
in-memory on the backend (lost on restart, which only means an occasional
re-injection). Known limitation: after Claude Code compacts a conversation, the
`session_id` stays the same, so rules that fell out of the model's context are
not re-injected for the remainder of that session.
not re-injected by the hook for the remainder of that session. The
`search_learnings` MCP tool deliberately passes no session id, so an explicit
model search can return the best matches even if the hook injected them earlier.

For the same reason, `search_learnings` results are rendered *without* citation
ids or the citation instruction (`context_format.render_learnings_plain`): the
MCP server has no `session_id`, so its hits never enter the per-session
citation registry and any rank-id marker the model emitted could not be
resolved by the Stop hook. MCP-surfaced learnings link to the dashboard via
stable real-id routes instead, and are simply not citation-tracked.

## Extraction signals

Expand Down
205 changes: 200 additions & 5 deletions bin/claude-smart.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const SUPPORTED_HOSTS = [HOST_CLAUDE_CODE, HOST_CODEX, HOST_OPENCODE];
const DEFAULT_CLAUDE_SMART_HOST = HOST_CLAUDE_CODE;
const REFLEXIO_DIR = join(homedir(), ".reflexio");
const CLAUDE_SMART_STATE_DIR = join(homedir(), ".claude-smart");
const CLAUDE_SMART_INSTALL_LOG_PATH = join(CLAUDE_SMART_STATE_DIR, "install.log");
const INSTALL_FAILURE_MARKER = join(CLAUDE_SMART_STATE_DIR, "install-failed");
const OPENCODE_LOCAL_PACKAGE_DIR = join(CLAUDE_SMART_STATE_DIR, "opencode", "claude-smart");
const OPENCODE_PACKAGE_LOCK_TIMEOUT_MS = 120_000;
Expand All @@ -84,6 +85,14 @@ const CODEX_PLUGIN_CACHE_DIR = join(
CODEX_MARKETPLACE_NAME,
"claude-smart",
);
const CLAUDE_CODE_PLUGIN_CACHE_DIR = join(
homedir(),
".claude",
"plugins",
"cache",
CODEX_MARKETPLACE_NAME,
"claude-smart",
);
const LOCAL_DATA_NOTICE = [
"Local data was kept so reinstalling claude-smart can reuse your learned rules, sessions, logs, and local Reflexio data.",
"Kept folders:",
Expand Down Expand Up @@ -115,6 +124,8 @@ const COPYTREE_IGNORE_NAMES = new Set([
".git",
"node_modules",
".next",
".coverage",
"htmlcov",
]);
const LOCAL_DEFAULT_ENV_ENTRIES = [
[
Expand Down Expand Up @@ -146,6 +157,18 @@ function shouldCopyPath(src) {
return true;
}

function appendInstallLog(message, fields = {}) {
mkdirSync(CLAUDE_SMART_STATE_DIR, { recursive: true });
const payload = {
ts: new Date().toISOString(),
message,
...fields,
};
writeFileSync(CLAUDE_SMART_INSTALL_LOG_PATH, JSON.stringify(payload) + "\n", {
flag: "a",
});
}

function runClaude(args, { spinnerLabel } = {}) {
const useSpinner = Boolean(spinnerLabel) && process.stdout.isTTY && !process.env.CI;
return new Promise((resolve) => {
Expand Down Expand Up @@ -709,7 +732,7 @@ function opencodePrerequisiteError() {
}

function findClaudeCodePluginRoot() {
const cacheRoot = join(homedir(), ".claude", "plugins", "cache", CODEX_MARKETPLACE_NAME, "claude-smart");
const cacheRoot = CLAUDE_CODE_PLUGIN_CACHE_DIR;
const candidates = [];
try {
for (const entry of readdirSync(cacheRoot, { withFileTypes: true })) {
Expand Down Expand Up @@ -753,6 +776,43 @@ function findClaudeCodePluginRoot() {
return null;
}

function claudeCodePluginVersion(pluginRoot) {
try {
const manifest = JSON.parse(
readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8"),
);
return typeof manifest.version === "string" && manifest.version
? manifest.version
: null;
} catch {
return null;
}
}

function installClaudeCodePluginCache(pluginRoot) {
const version = claudeCodePluginVersion(pluginRoot);
if (!version) {
throw new Error(`missing version in ${join(pluginRoot, ".claude-plugin", "plugin.json")}`);
}
const cacheDir = join(CLAUDE_CODE_PLUGIN_CACHE_DIR, version);
rmSync(cacheDir, { recursive: true, force: true });
mkdirSync(dirname(cacheDir), { recursive: true });
cpSync(pluginRoot, cacheDir, {
recursive: true,
force: true,
verbatimSymlinks: false,
filter: shouldCopyPath,
});
patchClaudeCodeMcpConfig(cacheDir);
appendInstallLog("installed Claude Code plugin cache from local package", {
host: HOST_CLAUDE_CODE,
version,
cacheDir,
source: pluginRoot,
});
return cacheDir;
}

function semverLikePathName(path) {
const base = String(path).split(/[\\/]/).pop() || "";
const match = base.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
Expand Down Expand Up @@ -937,10 +997,7 @@ async function bootstrapClaudeCodeInstall() {
);
}
forcePluginRoot(pluginRoot);
const bash = resolveCommand(isWindows() ? ["bash.exe", "bash"] : ["bash"]);
if (!bash) {
throw new Error("bash is required to bootstrap claude-smart dependencies");
}
const bash = requireMcpBash();
const code = await runChecked(bash, [join(pluginRoot, "scripts", "smart-install.sh")], {
cwd: pluginRoot,
});
Expand All @@ -951,6 +1008,100 @@ async function bootstrapClaudeCodeInstall() {
return pluginRoot;
}

function bashShellPath(path) {
return isWindows() ? String(path).replace(/\\/g, "/") : String(path);
}

function shellQuote(value) {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}

function resolveMcpBash() {
return resolveUsableBash();
}

function requireMcpBash() {
const bash = resolveMcpBash();
if (bash) return bash;
throw new Error(
isWindows()
? "Git Bash is required for claude-smart MCP tools on Windows. Install Git for Windows and ensure bash.exe is on PATH, or run from WSL"
: "bash is required for claude-smart MCP tools but was not found on PATH",
);
}

function learningsMcpShellScript(pluginRoot, bash) {
const bashForShell = shellQuote(bashShellPath(bash));
const pluginRootForShell = shellQuote(bashShellPath(pluginRoot));
return [
`_B=${bashForShell}`,
'_R=$(readlink "$HOME/.reflexio/plugin-root" 2>/dev/null || true)',
'if [ -z "$_R" ] && [ -f "$HOME/.reflexio/plugin-root.txt" ]; then _R=$(cat "$HOME/.reflexio/plugin-root.txt" 2>/dev/null || true); fi',
`if [ -z "$_R" ] || [ ! -f "\${_R%/}/scripts/mcp-server.sh" ]; then _R=${pluginRootForShell}; fi`,
'if [ -z "$_R" ] || [ ! -f "${_R%/}/scripts/mcp-server.sh" ]; then _R=$(ls -dt "$HOME/.claude/plugins/cache/reflexioai/claude-smart"/*/ "$HOME/.codex/plugins/cache/reflexioai/claude-smart"/*/ 2>/dev/null | head -n 1); fi',
'[ -n "$_R" ] || exit 1',
'CLAUDE_SMART_BASH=$_B exec "$_B" "${_R%/}/scripts/mcp-server.sh"',
].join("; ");
}

function claudeCodeLearningsMcpConfig(pluginRoot) {
const bash = requireMcpBash();
const script = learningsMcpShellScript(pluginRoot, bash);
return {
command: bash,
args: ["-lc", script],
};
}

function patchMcpServerConfig(pluginRoot, configPath, serverPath) {
const config = JSON.parse(readFileSync(configPath, "utf8"));
const target = serverPath.reduce((value, key) => value && value[key], config);
if (!target || typeof target !== "object") {
throw new Error(`missing learnings MCP server config in ${configPath}`);
}
Object.assign(target, claudeCodeLearningsMcpConfig(pluginRoot));
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
}

function patchClaudeCodeMcpConfig(pluginRoot) {
patchMcpServerConfig(pluginRoot, join(pluginRoot, ".mcp.json"), [
"mcpServers",
"learnings",
]);
}

function patchCodexMcpConfig(pluginRoot) {
patchMcpServerConfig(pluginRoot, join(pluginRoot, "hooks", "codex-mcp.json"), [
"learnings",
]);
}

async function registerClaudeCodeLearningsMcp(pluginRoot) {
const config = claudeCodeLearningsMcpConfig(pluginRoot);
await runClaude(["mcp", "remove", "learnings", "--scope", "user"]);
const code = await runClaude([
"mcp",
"add-json",
"--scope",
"user",
"learnings",
JSON.stringify(config),
]);
appendInstallLog("registered Claude Code learnings MCP", {
host: HOST_CLAUDE_CODE,
pluginRoot,
status: code === 0 ? "ok" : "failed",
});
if (code !== 0) {
process.stderr.write(
"warning: could not register Claude Code user-scope MCP `learnings`; " +
"native plugin MCP may still work, or run `claude mcp add-json --scope user learnings ...` manually.\n",
);
return false;
}
return true;
}

function isWindows() {
return currentPlatform() === "win32";
}
Expand Down Expand Up @@ -2011,7 +2162,14 @@ function installCodexPluginCache(pluginRoot) {
force: true,
verbatimSymlinks: false,
});
patchCodexMcpConfig(cacheDir);
setCodexPluginEnabled();
appendInstallLog("installed Codex plugin cache from local package", {
host: HOST_CODEX,
version,
cacheDir,
source: pluginRoot,
});
return cacheDir;
}

Expand Down Expand Up @@ -2102,6 +2260,20 @@ async function runUninstall(args) {
);
process.exit(code);
}
// Symmetric with registerClaudeCodeLearningsMcp: without this, the
// user-scope registration outlives the plugin caches and every future
// session shows a failing `learnings` MCP server.
const mcpRemoveCode = await runClaude([
"mcp",
"remove",
"learnings",
"--scope",
"user",
]);
appendInstallLog("removed Claude Code learnings MCP", {
host: HOST_CLAUDE_CODE,
status: mcpRemoveCode === 0 ? "ok" : "failed",
});
stopClaudeSmartServices(join(PACKAGE_ROOT, "plugin"));

process.stdout.write(
Expand Down Expand Up @@ -2181,17 +2353,29 @@ async function runInstall(args, options = {}) {
}

try {
installClaudeCodePluginCache(join(PACKAGE_ROOT, "plugin"));
const pluginRoot = await bootstrapClaudeCodeInstall();
restorePublishHooksFromSource(pluginRoot);
if (readOnly) {
prunePublishHooksForReadOnly(pluginRoot);
process.stdout.write("Installed read-only hook manifest; publish interactions hooks are disabled.\n");
}
process.stdout.write(`Prepared claude-smart runtime at ${pluginRoot}.\n`);
if (await registerClaudeCodeLearningsMcp(pluginRoot)) {
process.stdout.write("Registered Claude Code search_learnings MCP tool.\n");
}
if (startBackendService(pluginRoot, HOST_CLAUDE_CODE)) {
appendInstallLog("started claude-smart backend service", {
host: HOST_CLAUDE_CODE,
pluginRoot,
});
process.stdout.write("Started claude-smart backend service.\n");
}
if (refreshDashboardService(pluginRoot)) {
appendInstallLog("refreshed claude-smart dashboard service", {
host: HOST_CLAUDE_CODE,
pluginRoot,
});
process.stdout.write("Refreshed claude-smart dashboard service.\n");
}
} catch (err) {
Expand Down Expand Up @@ -2272,9 +2456,17 @@ async function runInstallCodex(args) {
process.stdout.write("Installed read-only hook manifest; publish interactions hooks are disabled.\n");
}
if (startBackendService(cacheDir, HOST_CODEX)) {
appendInstallLog("started claude-smart backend service", {
host: HOST_CODEX,
pluginRoot: cacheDir,
});
process.stdout.write("Started claude-smart backend service.\n");
}
if (refreshDashboardService(cacheDir)) {
appendInstallLog("refreshed claude-smart dashboard service", {
host: HOST_CODEX,
pluginRoot: cacheDir,
});
process.stdout.write("Refreshed claude-smart dashboard service.\n");
}
} catch (err) {
Expand Down Expand Up @@ -2512,4 +2704,7 @@ module.exports = {
prunePublishHooksForReadOnly,
restorePublishHooksFromSource,
stripJsonc,
claudeCodeLearningsMcpConfig,
installClaudeCodePluginCache,
registerClaudeCodeLearningsMcp,
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
"plugin",
"!plugin/**/__pycache__",
"!plugin/**/*.py[cod]",
"!plugin/.coverage",
"!plugin/htmlcov",
"!plugin/**/.pytest_cache",
"!plugin/**/.ruff_cache",
"!plugin/**/.mypy_cache",
Expand Down
1 change: 1 addition & 0 deletions plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"author": {
"name": "Yi Lu"
},
"mcpServers": "./.mcp.json",
"keywords": [
"claude",
"claude-code",
Expand Down
1 change: 1 addition & 0 deletions plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
],
"skills": "./skills/",
"hooks": "./hooks/codex-hooks.json",
"mcpServers": "./hooks/codex-mcp.json",
"interface": {
"displayName": "claude-smart",
"shortDescription": "Persistent learning across coding sessions",
Expand Down
10 changes: 10 additions & 0 deletions plugin/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"mcpServers": {
"learnings": {
"command": "bash",
"args": [
"${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.sh"
]
}
}
}
Loading
Loading