md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
258 lines
8.4 KiB
JavaScript
Executable File
258 lines
8.4 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* EverMem SessionStart Hook
|
|
* Retrieves recent memories and displays last session summary
|
|
* No AI summarization - uses local data only
|
|
*/
|
|
|
|
// Check Node.js version early
|
|
const nodeVersion = process.versions?.node;
|
|
if (!nodeVersion) {
|
|
console.error(JSON.stringify({
|
|
continue: true,
|
|
systemMessage: '⚠️ EverMem: Node.js environment not detected. Please install Node.js 18+ to use EverMem.'
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
|
|
const [major] = nodeVersion.split('.').map(Number);
|
|
if (major < 18) {
|
|
console.error(JSON.stringify({
|
|
continue: true,
|
|
systemMessage: `⚠️ EverMem: Node.js ${nodeVersion} is too old. Please upgrade to Node.js 18+.`
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
|
|
import { readFileSync, existsSync } from 'fs';
|
|
import { resolve, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { getMemories, transformGetMemoriesResults } from './utils/evermem-api.js';
|
|
import { getConfig, getGroupId } from './utils/config.js';
|
|
import { saveGroup } from './utils/groups-store.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const SESSIONS_FILE = resolve(__dirname, '../../data/sessions.jsonl');
|
|
|
|
const RECENT_MEMORY_COUNT = 5; // Number of recent memories to load
|
|
const PAGE_SIZE = 100; // Fetch more to get the latest (API returns old to new)
|
|
|
|
/**
|
|
* Get the most recent session summary for current group
|
|
* @param {string} groupId - The group ID to filter by
|
|
* @returns {Object|null} Most recent session summary or null
|
|
*/
|
|
function getLastSessionSummary(groupId) {
|
|
try {
|
|
if (!existsSync(SESSIONS_FILE)) {
|
|
return null;
|
|
}
|
|
|
|
const content = readFileSync(SESSIONS_FILE, 'utf8');
|
|
const lines = content.trim().split('\n').filter(Boolean);
|
|
|
|
// Search from end (most recent first)
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
const entry = JSON.parse(lines[i]);
|
|
if (entry.groupId === groupId) {
|
|
return entry;
|
|
}
|
|
} catch {}
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format relative time (e.g., "2h ago", "1d ago")
|
|
*/
|
|
function formatRelativeTime(isoTime) {
|
|
const now = Date.now();
|
|
const then = new Date(isoTime).getTime();
|
|
const diffMs = now - then;
|
|
|
|
const minutes = Math.floor(diffMs / 60000);
|
|
const hours = Math.floor(diffMs / 3600000);
|
|
const days = Math.floor(diffMs / 86400000);
|
|
|
|
if (minutes < 1) return 'just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
if (days < 30) return `${days}d ago`;
|
|
return `${Math.floor(days / 30)}mo ago`;
|
|
}
|
|
|
|
async function main() {
|
|
// Read hook input to get cwd
|
|
let hookInput = {};
|
|
try {
|
|
let input = '';
|
|
for await (const chunk of process.stdin) {
|
|
input += chunk;
|
|
}
|
|
if (input) {
|
|
hookInput = JSON.parse(input);
|
|
}
|
|
} catch (parseError) {
|
|
console.log(JSON.stringify({
|
|
continue: true,
|
|
systemMessage: `⚠️ EverMem: Failed to parse hook input - ${parseError.message}`
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Set cwd from hook input for config.getGroupId()
|
|
if (hookInput.cwd) {
|
|
process.env.EVERMEM_CWD = hookInput.cwd;
|
|
}
|
|
|
|
const config = getConfig();
|
|
|
|
// Save group to local storage (track which projects use EverMem)
|
|
if (hookInput.cwd) {
|
|
try {
|
|
saveGroup(getGroupId(), hookInput.cwd);
|
|
} catch (groupError) {
|
|
// Non-blocking, but log for debugging
|
|
console.error(`EverMem groups-store error: ${groupError.message}`);
|
|
}
|
|
}
|
|
|
|
if (!config.isConfigured) {
|
|
// Silently skip if not configured
|
|
console.log(JSON.stringify({ continue: true }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const groupId = getGroupId();
|
|
|
|
// Fetch memories (API returns old to new, we'll reverse and take latest)
|
|
const response = await getMemories({ pageSize: PAGE_SIZE });
|
|
const memories = transformGetMemoriesResults(response);
|
|
|
|
// Get last session summary from local storage
|
|
const lastSession = getLastSessionSummary(groupId);
|
|
|
|
if (memories.length === 0 && !lastSession) {
|
|
// No memories and no last session, skip
|
|
console.log(JSON.stringify({ continue: true }));
|
|
return;
|
|
}
|
|
|
|
// Take the most recent memories
|
|
const recentMemories = memories.slice(0, RECENT_MEMORY_COUNT);
|
|
|
|
// Build context message for Claude (no AI summarization)
|
|
let contextParts = [];
|
|
|
|
// Add last session info if available
|
|
if (lastSession) {
|
|
const timeAgo = formatRelativeTime(lastSession.timestamp);
|
|
contextParts.push(`Last session (${timeAgo}, ${lastSession.turnCount} turns): ${lastSession.summary}`);
|
|
}
|
|
|
|
// Add recent memories if available
|
|
if (recentMemories.length > 0) {
|
|
const memoriesText = recentMemories.map((m, i) => {
|
|
const date = new Date(m.timestamp).toLocaleDateString();
|
|
return `[${i + 1}] (${date}) ${m.subject}\n${m.text}`;
|
|
}).join('\n\n---\n\n');
|
|
contextParts.push(`Recent memories (${recentMemories.length}):\n\n${memoriesText}`);
|
|
}
|
|
|
|
const contextMessage = `<session-context>\n${contextParts.join('\n\n')}\n</session-context>`;
|
|
|
|
// Build display output - show meaningful content, concise but informative
|
|
let displayOutput;
|
|
if (lastSession) {
|
|
// Show last session: time, turns, summary
|
|
const truncatedSummary = lastSession.summary.length > 40
|
|
? lastSession.summary.substring(0, 40) + '...'
|
|
: lastSession.summary;
|
|
const timeAgo = formatRelativeTime(lastSession.timestamp);
|
|
displayOutput = `💡 EverMem: Last (${timeAgo}, ${lastSession.turnCount} turns): "${truncatedSummary}"`;
|
|
|
|
// Add memory preview if available
|
|
if (recentMemories.length > 0) {
|
|
const memorySubjects = recentMemories.slice(0, 2).map(m => {
|
|
const subj = m.subject || '';
|
|
return subj.length > 15 ? subj.substring(0, 15) + '..' : subj;
|
|
}).join(', ');
|
|
displayOutput += ` | ${recentMemories.length} memories: ${memorySubjects}`;
|
|
}
|
|
} else if (recentMemories.length > 0) {
|
|
// No last session, show recent memories with subjects
|
|
const memorySubjects = recentMemories.slice(0, 3).map(m => {
|
|
const subj = m.subject || '';
|
|
return subj.length > 20 ? subj.substring(0, 20) + '..' : subj;
|
|
}).join(', ');
|
|
displayOutput = `💡 EverMem: ${recentMemories.length} memories: ${memorySubjects}`;
|
|
} else {
|
|
displayOutput = `💡 EverMem: Ready`;
|
|
}
|
|
|
|
// Output: display to user and add to context
|
|
console.log(JSON.stringify({
|
|
continue: true,
|
|
systemMessage: displayOutput,
|
|
systemPrompt: contextMessage
|
|
}));
|
|
|
|
} catch (error) {
|
|
// Don't block session start on errors, but provide detailed error info
|
|
const errorDetails = {
|
|
message: error.message,
|
|
code: error.code,
|
|
name: error.name
|
|
};
|
|
|
|
// Provide user-friendly error messages
|
|
let userMessage = '⚠️ EverMem: ';
|
|
if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
|
userMessage += `Network error - cannot reach EverMem server. Check your internet connection.`;
|
|
} else if (error.code === 'ETIMEDOUT') {
|
|
userMessage += `Request timeout - EverMem server is slow or unreachable.`;
|
|
} else if (error.message?.includes('401') || error.message?.includes('Unauthorized')) {
|
|
userMessage += `Authentication failed. Check your EVERMEM_API_KEY in .env file.`;
|
|
} else if (error.message?.includes('404')) {
|
|
userMessage += `API endpoint not found. Check EVERMEM_BASE_URL in .env file.`;
|
|
} else if (error.message?.includes('ENOENT')) {
|
|
userMessage += `File not found: ${error.path || 'unknown'}`;
|
|
} else {
|
|
userMessage += `${error.name}: ${error.message}`;
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
continue: true,
|
|
systemMessage: userMessage
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Top-level error handler for uncaught exceptions during module load
|
|
process.on('uncaughtException', (error) => {
|
|
let userMessage = '⚠️ EverMem SessionStart failed: ';
|
|
|
|
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
const moduleName = error.message.match(/Cannot find package '([^']+)'/)?.[1] || 'unknown';
|
|
userMessage += `Missing dependency '${moduleName}'. Run: cd ${process.cwd()} && npm install`;
|
|
} else if (error.code === 'ERR_REQUIRE_ESM') {
|
|
userMessage += `Module format error. Ensure package.json has "type": "module"`;
|
|
} else {
|
|
userMessage += `${error.name}: ${error.message}`;
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
continue: true,
|
|
systemMessage: userMessage
|
|
}));
|
|
process.exit(0);
|
|
});
|
|
|
|
main();
|