mirror of
https://github.com/supermemoryai/claude-supermemory.git
synced 2026-09-14 14:58:03 +08:00
a4ecd312b1
* feat: fail open with 3s network cap on all hooks; async capture; token count in recall banner - All hook API calls hard-capped at 3s (was 15s default, 4s recall); every failure path continues the session with a visible error - Stop/capture hook runs with "async": true — fire-and-forget save, so the user gets their prompt back immediately. Injection hooks stay sync (async hooks' output is discarded, which would kill recall injection and auto-approval); tightened their backstop timeouts - Network failures now say "Supermemory unreachable" instead of raw fetch errors, and session-start no longer claims "no previous memories" when the fetch failed — only a 404 means empty - Recall banner shows the injected context cost: "recalled 5 memories (32 tok)" Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PfsBZgW6vDm8GEcy9ML3dT * feat: statusline tally counts recalled memories in context, not recall events Search state accumulates a cumulative memories counter (fresh injections only); the resting tally shows "9 recalled" instead of "6 recalls". Sessions with only MCP-tool searches (nothing hook-injected) keep the event-count fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PfsBZgW6vDm8GEcy9ML3dT --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
273 lines
9.2 KiB
JavaScript
273 lines
9.2 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { getProfile } = require('./lib/api');
|
|
const { getContainerTag, getProjectName } = require('./lib/container-tag');
|
|
const { loadProjectConfig } = require('./lib/project-config');
|
|
const {
|
|
loadSettings,
|
|
getApiKey,
|
|
getBaseUrl,
|
|
debugLog,
|
|
} = require('./lib/settings');
|
|
const { BRAND, MARK, bold, gray } = require('./lib/colors');
|
|
const { readStdin, writeOutput } = require('./lib/stdin');
|
|
const { startAuthFlow, AUTH_BASE_URL } = require('./lib/auth');
|
|
const { getUserFriendlyError } = require('./lib/error-helpers');
|
|
const { LAST_SESSION_FILE } = require('./lib/last-session');
|
|
const {
|
|
pruneState,
|
|
resolveStatuslineDataDir,
|
|
writeState,
|
|
} = require('./lib/statusline-state');
|
|
|
|
const STATUSLINE_LINK = path.join(
|
|
os.homedir(),
|
|
'.supermemory-claude',
|
|
'statusline-current',
|
|
);
|
|
const STATUSLINE_TIP_FILE = path.join(
|
|
os.homedir(),
|
|
'.supermemory-claude',
|
|
'statusline-tip-shown',
|
|
);
|
|
const STATUSLINE_INSTALLED_FILE = path.join(
|
|
os.homedir(),
|
|
'.supermemory-claude',
|
|
'statusline-installed',
|
|
);
|
|
const STATUSLINE_ENTRY = {
|
|
type: 'command',
|
|
command: 'node ~/.supermemory-claude/statusline-current',
|
|
refreshInterval: 1,
|
|
};
|
|
|
|
// The statusline setting needs one path that survives plugin updates; a
|
|
// symlink re-pointed each session is that path — no code is ever copied.
|
|
function refreshStatuslineLink() {
|
|
const target = path.join(__dirname, '..', 'statusline.js');
|
|
try {
|
|
fs.mkdirSync(path.dirname(STATUSLINE_LINK), { recursive: true });
|
|
try {
|
|
if (fs.readlinkSync(STATUSLINE_LINK) === target) return;
|
|
fs.unlinkSync(STATUSLINE_LINK);
|
|
} catch {}
|
|
fs.symlinkSync(target, STATUSLINE_LINK);
|
|
} catch {}
|
|
}
|
|
|
|
// Installs the statusline into ~/.claude/settings.json on first run. The
|
|
// sentinel file records that we installed once, so a user who deletes the
|
|
// entry is never fought; a foreign statusLine is never overwritten.
|
|
function installStatusline() {
|
|
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
try {
|
|
let settings = {};
|
|
try {
|
|
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
return `${MARK} statusline not installed — ~/.claude/settings.json is unreadable: ${err.message}`;
|
|
}
|
|
}
|
|
if (
|
|
JSON.stringify(settings.statusLine || '').includes('statusline-current')
|
|
) {
|
|
return null;
|
|
}
|
|
if (settings.statusLine) {
|
|
if (fs.existsSync(STATUSLINE_TIP_FILE)) return null;
|
|
fs.mkdirSync(path.dirname(STATUSLINE_TIP_FILE), { recursive: true });
|
|
fs.writeFileSync(STATUSLINE_TIP_FILE, new Date().toISOString());
|
|
return `${MARK} Supermemory status line available — you already have a "statusLine" in ~/.claude/settings.json; replace it with ${JSON.stringify(STATUSLINE_ENTRY)} to switch.`;
|
|
}
|
|
if (fs.existsSync(STATUSLINE_INSTALLED_FILE)) return null;
|
|
settings.statusLine = STATUSLINE_ENTRY;
|
|
const tmp = `${settingsPath}.supermemory-tmp`;
|
|
fs.writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`);
|
|
fs.renameSync(tmp, settingsPath);
|
|
fs.mkdirSync(path.dirname(STATUSLINE_INSTALLED_FILE), { recursive: true });
|
|
fs.writeFileSync(STATUSLINE_INSTALLED_FILE, new Date().toISOString());
|
|
return `${MARK} statusline installed — it appears the next time Claude Code starts (delete "statusLine" from ~/.claude/settings.json to turn it off).`;
|
|
} catch (err) {
|
|
return `${MARK} statusline install failed: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
const MARK_TIP_FILE = path.join(
|
|
os.homedir(),
|
|
'.supermemory-claude',
|
|
'mark-tip-shown',
|
|
);
|
|
|
|
function markTip() {
|
|
try {
|
|
if (fs.existsSync(MARK_TIP_FILE)) return null;
|
|
fs.mkdirSync(path.dirname(MARK_TIP_FILE), { recursive: true });
|
|
fs.writeFileSync(MARK_TIP_FILE, new Date().toISOString());
|
|
return `${MARK} is the supermemory mark — whenever you see it (statusline, notices, Claude's answers), that information came from supermemory.`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function welcomeBackNotice(containerTag) {
|
|
try {
|
|
const last = JSON.parse(fs.readFileSync(LAST_SESSION_FILE, 'utf-8'));
|
|
if (!last.savedAt || last.containerTag !== containerTag) return null;
|
|
const hours = (Date.now() - new Date(last.savedAt).getTime()) / 3600000;
|
|
if (hours < 6) return null;
|
|
const ago =
|
|
hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`;
|
|
return `welcome back — last session here ${ago}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function formatContext(profileResult, maxItems, containerTag, projectName) {
|
|
const statics = (profileResult?.profile?.static || []).slice(0, maxItems);
|
|
const dynamics = (profileResult?.profile?.dynamic || []).slice(0, maxItems);
|
|
if (statics.length === 0 && dynamics.length === 0) return null;
|
|
|
|
const sections = [];
|
|
if (statics.length > 0) {
|
|
sections.push(
|
|
`## User Profile (Persistent)\n${statics.map((f) => `- ◪ ${f}`).join('\n')}`,
|
|
);
|
|
}
|
|
if (dynamics.length > 0) {
|
|
sections.push(
|
|
`## Recent Context\n${dynamics.map((f) => `- ◪ ${f}`).join('\n')}`,
|
|
);
|
|
}
|
|
|
|
return `<supermemory-context>
|
|
Recalled memory for this project (${projectName}). Every line marked ◪ comes from supermemory — when citing one, keep the mark and phrase it naturally (e.g. "◪ last week you told me about X"). If you name the source, say "from supermemory" — never "from memory".
|
|
This project's memory container: ${containerTag}
|
|
|
|
${sections.join('\n\n')}
|
|
</supermemory-context>`;
|
|
}
|
|
|
|
function output(additionalContext, systemMessageParts) {
|
|
const systemMessage = systemMessageParts.filter(Boolean).join('\n');
|
|
writeOutput({
|
|
...(systemMessage ? { systemMessage } : {}),
|
|
hookSpecificOutput: {
|
|
hookEventName: 'SessionStart',
|
|
additionalContext,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const settings = loadSettings();
|
|
let sessionId;
|
|
|
|
try {
|
|
const input = await readStdin();
|
|
sessionId = input.session_id;
|
|
const cwd = input.cwd || process.cwd();
|
|
|
|
refreshStatuslineLink();
|
|
pruneState({ dataDir: resolveStatuslineDataDir() });
|
|
writeState(sessionId, 'context', { status: 'loading', memoryItemsLoaded: 0 });
|
|
|
|
const projectConfig = loadProjectConfig(cwd);
|
|
const projectName = getProjectName(cwd);
|
|
const containerTag = getContainerTag(cwd);
|
|
|
|
debugLog(settings, 'SessionStart', { cwd, projectName, containerTag });
|
|
|
|
let apiKey;
|
|
try {
|
|
apiKey = getApiKey(cwd, projectConfig);
|
|
} catch {
|
|
try {
|
|
apiKey = await startAuthFlow();
|
|
} catch (authErr) {
|
|
writeState(sessionId, 'context', { status: 'error', memoryItemsLoaded: 0 });
|
|
output(
|
|
`<supermemory-status>
|
|
${authErr.message === 'AUTH_TIMEOUT' ? 'Authentication timed out. Please complete login in the browser window.' : 'Authentication failed.'}
|
|
If the browser did not open, visit: ${AUTH_BASE_URL}
|
|
Or set the SUPERMEMORY_CC_API_KEY environment variable.
|
|
</supermemory-status>`,
|
|
[],
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const baseUrl = getBaseUrl(cwd, projectConfig);
|
|
|
|
let profileResult = null;
|
|
let apiError = null;
|
|
try {
|
|
profileResult = await getProfile(baseUrl, apiKey, containerTag, projectName);
|
|
} catch (err) {
|
|
// Fail open, but never silently: a network failure must not be dressed
|
|
// up as "this project has no memories". Only 404 means genuinely empty.
|
|
if (err?.status !== 404) apiError = getUserFriendlyError(err);
|
|
debugLog(settings, 'Profile fetch failed', { error: err.message });
|
|
}
|
|
|
|
const context = formatContext(
|
|
profileResult,
|
|
settings.maxProfileItems,
|
|
containerTag,
|
|
projectName,
|
|
);
|
|
const loaded =
|
|
Math.min(profileResult?.profile?.static?.length || 0, settings.maxProfileItems) +
|
|
Math.min(profileResult?.profile?.dynamic?.length || 0, settings.maxProfileItems);
|
|
|
|
writeState(sessionId, 'context', {
|
|
status: apiError ? 'error' : 'ready',
|
|
memoryItemsLoaded: loaded,
|
|
});
|
|
|
|
const memoryNotice =
|
|
loaded > 0
|
|
? `${BRAND} ${gray('·')} ${loaded} ${loaded === 1 ? 'memory' : 'memories'} loaded for ${bold(projectName)}`
|
|
: null;
|
|
|
|
output(
|
|
(apiError ? `<supermemory-status>\n${apiError}\n</supermemory-status>\n` : '') +
|
|
(context ||
|
|
(apiError
|
|
? `<supermemory-context>
|
|
Memory could not be loaded this session — do not assume this project has no memories.
|
|
</supermemory-context>`
|
|
: `<supermemory-context>
|
|
No previous memories found for this project (container: ${containerTag}).
|
|
Memories will be saved as you work.
|
|
</supermemory-context>`)),
|
|
[
|
|
[memoryNotice, welcomeBackNotice(containerTag)]
|
|
.filter(Boolean)
|
|
.join(gray(' · ')) || null,
|
|
markTip(),
|
|
installStatusline(),
|
|
],
|
|
);
|
|
} catch (err) {
|
|
const friendly = getUserFriendlyError(err);
|
|
console.error(`Supermemory: ${friendly}`);
|
|
writeState(sessionId, 'context', { status: 'error', memoryItemsLoaded: 0 });
|
|
output(
|
|
`<supermemory-status>
|
|
Failed to load memories: ${friendly}
|
|
Session will continue without memory context.
|
|
</supermemory-status>`,
|
|
[],
|
|
);
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`Supermemory fatal: ${err.message}`);
|
|
process.exit(1);
|
|
});
|