chore: initialize EverOS 1.0.0

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.
This commit is contained in:
Elliot Chen
2026-06-05 22:35:51 +08:00
commit 518b8eca85
636 changed files with 160553 additions and 0 deletions

View File

@ -0,0 +1,52 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-context-wrapper.sh",
"timeout": 30
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/inject-memories.js",
"timeout": 10
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/store-memories.js",
"timeout": 30
}
]
}
],
"SessionEnd": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-summary.js",
"timeout": 30
}
]
}
]
}
}

View File

@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* Memory Plugin - UserPromptSubmit Hook
*
* This hook automatically injects relevant memories from past sessions
* into Claude's context when the user submits a prompt.
*
* Flow:
* 1. Read prompt from stdin
* 2. Skip if prompt is too short or API not configured
* 3. Search EverMem Cloud for relevant memories
* 4. Optionally filter with Claude SDK
* 5. Display summary to user (via systemMessage)
* 6. Inject context for Claude (via additionalContext)
*/
import { isConfigured } from './utils/config.js';
import { searchMemories, transformSearchResults } from './utils/evermem-api.js';
import { formatRelativeTime } from './utils/mock-store.js';
import { debug, setDebugPrefix } from './utils/debug.js';
// Set debug prefix for this script
setDebugPrefix('inject');
const MIN_WORDS = 3;
const MAX_MEMORIES = 5;
const MIN_SCORE = 0.1; // Only show memories with relevance score above this threshold
/**
* Count words/tokens in a string (multilingual support)
* - For CJK (Chinese/Japanese/Korean): counts each character as a token
* - For other languages: counts space-separated words
* - For mixed text: counts both
* @param {string} text
* @returns {number}
*/
function countWords(text) {
if (!text) return 0;
const trimmed = text.trim();
if (!trimmed) return 0;
// Regex for CJK characters (Chinese, Japanese Kanji, Korean Hanja)
// Also includes Japanese Hiragana/Katakana and Korean Hangul
const cjkRegex = /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/g;
// Count CJK characters
const cjkMatches = trimmed.match(cjkRegex);
const cjkCount = cjkMatches ? cjkMatches.length : 0;
// Remove CJK characters and count remaining space-separated words
const nonCjkText = trimmed.replace(cjkRegex, ' ').trim();
const wordCount = nonCjkText ? nonCjkText.split(/\s+/).filter(w => w.length > 0).length : 0;
return cjkCount + wordCount;
}
/**
* Main hook handler
*/
async function main() {
try {
// Read stdin
const input = await readStdin();
const data = JSON.parse(input);
const prompt = data.prompt || '';
debug('hookInput:', data);
// Set cwd from hook input for config.getGroupId()
if (data.cwd) {
process.env.EVERMEM_CWD = data.cwd;
}
// Skip short prompts silently
const wordCount = countWords(prompt);
if (wordCount < MIN_WORDS) {
debug('skipped: prompt too short', { wordCount, minWords: MIN_WORDS });
process.exit(0);
}
// Skip if not configured (silent - don't nag users)
if (!isConfigured()) {
debug('skipped: not configured');
process.exit(0);
}
// Search memories from EverMem Cloud
let memories = [];
let apiResponse = null;
try {
debug('searching memories for prompt:', prompt.slice(0, 100) + (prompt.length > 100 ? '...' : ''));
apiResponse = await searchMemories(prompt, {
topK: 15,
retrieveMethod: 'hybrid'
});
memories = transformSearchResults(apiResponse);
debug("memories:", memories);
debug('search results:', { total: memories.length, memories: memories.map(m => ({ score: m.score, subject: m.subject })) });
} catch (error) {
// Silent on API errors - don't block user workflow
debug('search error:', error.message);
process.exit(0);
}
// Filter by minimum score threshold
const relevantMemories = memories.filter(m => m.score >= MIN_SCORE);
debug('filtered memories:', { total: relevantMemories.length, minScore: MIN_SCORE });
// No relevant memories above threshold - silently exit (this is normal)
if (relevantMemories.length === 0) {
debug('skipped: no relevant memories above threshold');
process.exit(0);
}
// Take top memories
const selectedMemories = relevantMemories.slice(0, MAX_MEMORIES);
debug('selected memories:', selectedMemories.map(m => ({ score: m.score, subject: m.subject, timestamp: m.timestamp })));
// Build context for Claude
const context = buildContext(selectedMemories);
// Build display message for user
const displayMessage = buildDisplayMessage(selectedMemories);
// Output JSON with systemMessage (user display) and additionalContext (for Claude)
const output = {
systemMessage: displayMessage,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: context
}
};
debug('output:', { systemMessage: displayMessage, contextLength: context.length });
process.stdout.write(JSON.stringify(output));
process.exit(0);
} catch (error) {
// Silent on errors - don't block user workflow
debug('error:', error.message);
process.exit(0);
}
}
/**
* Read all stdin input
* @returns {Promise<string>}
*/
function readStdin() {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
data += chunk;
});
process.stdin.on('end', () => {
resolve(data);
});
process.stdin.on('error', reject);
});
}
/**
* Build display message for user (shown via systemMessage)
* @param {Object[]} memories - Selected memories
* @returns {string}
*/
function buildDisplayMessage(memories) {
const header = `📝 Memory Retrieved (${memories.length}):`;
const lines = [header];
for (const memory of memories) {
const relTime = formatRelativeTime(memory.timestamp);
const score = memory.score ? memory.score.toFixed(2) : '0.00';
// Use subject as title if available, otherwise truncate text
const title = memory.subject
? memory.subject
: (memory.text.length > 60 ? memory.text.slice(0, 60) + '...' : memory.text);
lines.push(` • [${score}] (${relTime}) ${title}`);
}
return lines.join('\n');
}
/**
* Build context string for Claude
* Memories are sorted by timestamp (most recent first) to prioritize recent context
* @param {Object[]} memories - Selected memories
* @returns {string}
*/
function buildContext(memories) {
const lines = [];
// Sort by timestamp descending (most recent first)
const sortedMemories = [...memories].sort((a, b) => {
const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return timeB - timeA;
});
lines.push('<relevant-memories>');
lines.push('The following memories from past sessions are relevant to the user\'s current task:');
lines.push('');
lines.push('IMPORTANT: Memories are ordered by recency (most recent first). When there are conflicts or updates between memories, prefer the MORE RECENT information as it likely reflects the latest decisions, code changes, or user preferences.');
lines.push('');
for (const memory of sortedMemories) {
// Format timestamp for context
const timeStr = memory.timestamp
? new Date(memory.timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC'
}) + ' UTC'
: 'Unknown time';
lines.push(`[${timeStr}]`);
lines.push(memory.text);
lines.push('');
}
lines.push('Use this context to inform your response. The user has already seen these memories displayed.');
lines.push('</relevant-memories>');
return lines.join('\n');
}
// Run
main();

View File

@ -0,0 +1,14 @@
#!/bin/bash
# EverMem SessionStart Hook Wrapper
# Ensures npm dependencies are installed before running the hook
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Check if SDK is installed, if not install it silently
if [ ! -d "$PLUGIN_ROOT/node_modules/@anthropic-ai/claude-agent-sdk" ]; then
(cd "$PLUGIN_ROOT" && npm install --silent 2>/dev/null) || true
fi
# Run the actual hook script, passing stdin through
exec node "$SCRIPT_DIR/session-context.js"

View File

@ -0,0 +1,257 @@
#!/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();

View File

@ -0,0 +1,200 @@
#!/usr/bin/env node
/**
* EverMem SessionEnd Hook
* Saves session summary (first user prompt + stats) to local storage
* No AI summarization - just extracts key info from transcript
*/
import { readFileSync, appendFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { getGroupId, getConfig } from './utils/config.js';
import { debug, setDebugPrefix } from './utils/debug.js';
setDebugPrefix('session-end');
const __dirname = dirname(fileURLToPath(import.meta.url));
const SESSIONS_FILE = resolve(__dirname, '../../data/sessions.jsonl');
/**
* Read transcript and extract key content
* @param {string} transcriptPath - Path to the transcript JSONL file
* @returns {Object|null} Extracted content
*/
function extractTranscriptContent(transcriptPath) {
try {
if (!existsSync(transcriptPath)) {
return null;
}
const content = readFileSync(transcriptPath, 'utf8');
const lines = content.trim().split('\n').filter(Boolean);
let firstUserPrompt = null;
let lastUserPrompt = null;
let turnCount = 0;
let firstTimestamp = null;
let lastTimestamp = null;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Track timestamps
if (entry.timestamp) {
if (!firstTimestamp) firstTimestamp = entry.timestamp;
lastTimestamp = entry.timestamp;
}
// Count turns
if (entry.type === 'system' && entry.subtype === 'turn_duration') {
turnCount++;
}
// Extract user messages (not tool_result)
if (entry.type === 'user' && entry.message?.role === 'user') {
const msgContent = entry.message.content;
if (typeof msgContent === 'string' && msgContent.trim()) {
if (!firstUserPrompt) {
firstUserPrompt = msgContent.trim();
}
lastUserPrompt = msgContent.trim();
}
}
} catch {}
}
return {
firstUserPrompt: firstUserPrompt?.substring(0, 200) || '',
lastUserPrompt: lastUserPrompt?.substring(0, 200) || '',
turnCount,
firstTimestamp,
lastTimestamp
};
} catch {
return null;
}
}
/**
* Save session summary to local JSONL file
*/
function saveSummary(entry) {
try {
appendFileSync(SESSIONS_FILE, JSON.stringify(entry) + '\n', 'utf8');
return true;
} catch {
return false;
}
}
/**
* Check if session already has a summary
*/
function alreadySummarized(sessionId) {
try {
if (!existsSync(SESSIONS_FILE)) {
return false;
}
const content = readFileSync(SESSIONS_FILE, 'utf8');
return content.includes(`"sessionId":"${sessionId}"`);
} catch {
return false;
}
}
async function main() {
// Read hook input
let hookInput = {};
try {
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
}
if (input) {
hookInput = JSON.parse(input);
}
} catch {
process.exit(0);
}
const { session_id, transcript_path, cwd, reason } = hookInput;
// Skip if no transcript or already summarized
if (!transcript_path || !session_id) {
process.exit(0);
}
const wasAlreadySummarized = alreadySummarized(session_id);
// Set cwd for config
if (cwd) {
process.env.EVERMEM_CWD = cwd;
}
const config = getConfig();
if (!config.isConfigured) {
process.exit(0);
}
// Extract content from transcript
const content = extractTranscriptContent(transcript_path);
if (!content || content.turnCount === 0) {
process.exit(0);
}
// Use first user prompt as summary (truncated)
const summary = content.firstUserPrompt || 'Session with no text prompts';
// Calculate session duration
let durationStr = '';
if (content.firstTimestamp && content.lastTimestamp) {
const durationMs = new Date(content.lastTimestamp) - new Date(content.firstTimestamp);
const minutes = Math.floor(durationMs / 60000);
if (minutes < 1) {
durationStr = '<1min';
} else if (minutes < 60) {
durationStr = `${minutes}min`;
} else {
const hours = Math.floor(minutes / 60);
const remainMins = minutes % 60;
durationStr = remainMins > 0 ? `${hours}h${remainMins}m` : `${hours}h`;
}
}
// Truncate summary for display
const displaySummary = summary.length > 50
? summary.substring(0, 50) + '...'
: summary;
// Build output: turns, duration, summary
const parts = [`${content.turnCount} turns`];
if (durationStr) parts.push(durationStr);
// Save to local file (only if not already saved)
if (!wasAlreadySummarized) {
const entry = {
sessionId: session_id,
groupId: getGroupId(),
summary,
turnCount: content.turnCount,
reason: reason || 'unknown',
startTime: content.firstTimestamp,
endTime: content.lastTimestamp,
timestamp: new Date().toISOString()
};
saveSummary(entry);
}
// Always output session summary (whether saved or not)
const message = `📝 Session (${parts.join(', ')}): "${displaySummary}"`;
// Log to unified debug file
debug('output', message);
console.error(message); // Direct terminal output
console.log(JSON.stringify({ systemMessage: message }));
}
main().catch(() => process.exit(0));

View File

@ -0,0 +1,298 @@
#!/usr/bin/env node
process.on('uncaughtException', () => process.exit(0));
process.on('unhandledRejection', () => process.exit(0));
import { readFileSync, existsSync } from 'fs';
import { isConfigured } from './utils/config.js'; // This loads .env
import { addMemory } from './utils/evermem-api.js';
import { debug, setDebugPrefix } from './utils/debug.js';
// Set debug prefix for this script
setDebugPrefix('store');
try {
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
}
const hookInput = JSON.parse(input);
debug('hookInput:', hookInput);
const transcriptPath = hookInput.transcript_path;
// Set cwd from hook input for config.getGroupId()
if (hookInput.cwd) {
process.env.EVERMEM_CWD = hookInput.cwd;
}
if (!transcriptPath || !existsSync(transcriptPath) || !isConfigured()) {
process.exit(0);
}
/**
* Read transcript file with retry logic
* Waits for turn_duration marker which indicates the turn is complete
*/
async function readTranscriptWithRetry(path, maxRetries = 5, delayMs = 100) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const content = readFileSync(path, 'utf8');
const lines = content.trim().split('\n');
// Check if the last line is turn_duration (indicates turn is complete)
let isComplete = false;
try {
const lastLine = JSON.parse(lines[lines.length - 1]);
isComplete = lastLine.type === 'system' && lastLine.subtype === 'turn_duration';
} catch {}
debug(`read attempt ${attempt}:`, {
totalLines: lines.length,
isComplete,
lastLineType: (() => {
try {
const e = JSON.parse(lines[lines.length - 1]);
return e.subtype ? `${e.type}/${e.subtype}` : e.type;
} catch { return 'unknown'; }
})()
});
if (isComplete) {
return lines;
}
if (attempt < maxRetries) {
debug(`turn not complete, waiting ${delayMs}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
// Return whatever we have after max retries
debug('max retries reached, proceeding with current content');
const content = readFileSync(path, 'utf8');
return content.trim().split('\n');
}
const lines = await readTranscriptWithRetry(transcriptPath);
// Debug: show last 3 lines of the file (just the type)
debug('last 3 lines types:', lines.slice(-3).map((line, idx) => {
try {
const e = JSON.parse(line);
return { index: lines.length - 3 + idx, type: e.type, subtype: e.subtype, hasContent: !!e.message?.content };
} catch { return { index: lines.length - 3 + idx, error: 'parse failed' }; }
}));
/**
* Check if content is meaningful (not just whitespace/newlines)
* @param {string} text
* @returns {boolean}
*/
function hasContent(text) {
return text && text.trim().length > 0;
}
/**
* Extract the last turn's user input and assistant response
*
* A Turn = User sends message → Claude responds (may include multiple tool calls)
* Turn boundary is marked by: {"type":"system","subtype":"turn_duration"}
*
* User messages may be:
* - Original input: {"type":"user","message":{"content":"string"}}
* - Tool result: {"type":"user","message":{"content":[{"type":"tool_result",...}]}}
*
* Assistant messages may contain multiple content blocks:
* - thinking: Claude's internal reasoning
* - tool_use: Tool invocations
* - text: Final response to user (this is what we want)
*/
function extractLastTurn(lines) {
// IMPORTANT: When Stop hook runs, turn_duration for current turn hasn't been written yet.
// The turn_duration marker is written AFTER the Stop hook completes.
// So current turn END is always at the end of the file.
const turnEndIndex = lines.length;
// Current turn START is right after the last turn_duration marker.
// Only turn_duration marks turn boundaries (file-history-snapshot is NOT a turn boundary).
// If no marker found, start from beginning of file.
let turnStartIndex = 0;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const e = JSON.parse(lines[i]);
if (e.type === 'system' && e.subtype === 'turn_duration') {
turnStartIndex = i + 1;
break;
}
} catch {}
}
debug('turn range:', { turnStartIndex, turnEndIndex, totalLines: lines.length });
// Collect user and assistant content from the turn
const userTexts = [];
const assistantTexts = [];
// Debug: log each line's type in the turn
const lineTypes = [];
for (let i = turnStartIndex; i < turnEndIndex; i++) {
try {
const e = JSON.parse(lines[i]);
const content = e.message?.content;
// Debug: record line type
const lineInfo = { index: i, type: e.type };
if (e.type === 'assistant' && Array.isArray(content)) {
lineInfo.contentTypes = content.map(b => b.type);
}
lineTypes.push(lineInfo);
if (e.type === 'user') {
// User message - distinguish between original input and tool_result
if (typeof content === 'string') {
// Original user input (plain string)
userTexts.push(content);
} else if (Array.isArray(content)) {
// Check if it's a tool_result (skip) or text blocks (include)
for (const block of content) {
if (block.type === 'text' && block.text) {
userTexts.push(block.text);
}
// Skip tool_result - it's part of Claude's workflow, not user input
}
}
}
if (e.type === 'assistant') {
// Assistant message - extract text blocks only
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && block.text) {
assistantTexts.push(block.text);
}
// Skip: thinking (internal), tool_use (workflow)
}
} else if (typeof content === 'string') {
assistantTexts.push(content);
}
}
} catch {}
}
// Debug: output line types
debug('line types in turn:', lineTypes);
debug('assistantTexts count:', assistantTexts.length);
return {
user: userTexts.join('\n\n'),
assistant: assistantTexts.join('\n\n')
};
}
// Extract the last turn's content
const lastTurn = extractLastTurn(lines);
const lastUser = lastTurn.user;
const lastAssistant = lastTurn.assistant;
debug('extracted:', {
userLength: lastUser?.length || 0,
assistantLength: lastAssistant?.length || 0,
userPreview: lastUser?.slice(0, 100),
assistantPreview: lastAssistant?.slice(0, 100)
});
// Run both in parallel with Promise.all
const promises = [];
const results = [];
const skipped = [];
// Check if user content is meaningful
if (lastUser) {
if (hasContent(lastUser)) {
const len = lastUser.length;
promises.push(
addMemory({ content: lastUser, role: 'user', messageId: `u_${Date.now()}` })
.then(r => results.push({ type: 'USER', len, ...r }))
.catch(e => results.push({ type: 'USER', len, ok: false, error: e.message }))
);
} else {
skipped.push({ type: 'USER', reason: 'whitespace-only content' });
}
}
// Check if assistant content is meaningful
if (lastAssistant) {
if (hasContent(lastAssistant)) {
const len = lastAssistant.length;
promises.push(
addMemory({ content: lastAssistant, role: 'assistant', messageId: `a_${Date.now()}` })
.then(r => results.push({ type: 'ASSISTANT', len, ...r }))
.catch(e => results.push({ type: 'ASSISTANT', len, ok: false, error: e.message }))
);
} else {
skipped.push({ type: 'ASSISTANT', reason: 'whitespace-only content' });
}
}
await Promise.all(promises);
// Check if all calls succeeded
const allSuccess = results.length > 0 && results.every(r => r.ok && !r.error);
// Debug output
debug('results:', results);
debug('skipped:', skipped);
// Build output message
let output = '';
if (allSuccess) {
const details = results.map(r => `${r.type.toLowerCase()}: ${r.len}`).join(', ');
output = `💾 Memory saved (${results.length}) [${details}]`;
// Add skipped info if any
if (skipped.length > 0) {
output += `\n⏭️ Skipped: ${skipped.map(s => `${s.type} (${s.reason})`).join(', ')}`;
}
process.stdout.write(JSON.stringify({ systemMessage: output }));
process.exit(0);
} else if (results.length === 0 && skipped.length > 0) {
// All content was skipped
output = `⏭️ EverMem: No content to save\n`;
for (const s of skipped) {
output += `${s.type}: ${s.reason}\n`;
}
process.stdout.write(JSON.stringify({ systemMessage: output }));
process.exit(0);
} else {
// Failure: show detailed errors via systemMessage
function truncateBody(body) {
if (!body) return body;
const copy = { ...body };
if (copy.content && typeof copy.content === 'string' && copy.content.length > 100) {
copy.content = copy.content.substring(0, 100) + '... [truncated]';
}
return copy;
}
output = '💾 EverMem: Save failed\n';
for (const r of results) {
if (r.error) {
output += `${r.type}: ERROR - ${r.error}\n`;
} else if (!r.ok) {
output += `${r.type}: FAILED (${r.status})\n`;
output += `Request: ${JSON.stringify(truncateBody(r.body), null, 2)}\n`;
output += `Response: ${JSON.stringify(r.response, null, 2)}\n`;
}
}
// Also show skipped if any
if (skipped.length > 0) {
output += `⏭️ Skipped: ${skipped.map(s => `${s.type} (${s.reason})`).join(', ')}\n`;
}
process.stdout.write(JSON.stringify({ systemMessage: output }));
}
} catch (e) {
// Silent on errors
process.exit(0);
}

View File

@ -0,0 +1,116 @@
/**
* Configuration loader for EverMem plugin
* Reads settings from .env file and environment variables
*/
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
// Load .env file from plugin root
const __dirname = dirname(fileURLToPath(import.meta.url));
const envPath = resolve(__dirname, '../../../.env');
if (existsSync(envPath)) {
const envContent = readFileSync(envPath, 'utf8');
for (const line of envContent.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, ...valueParts] = trimmed.split('=');
if (key && valueParts.length > 0) {
const value = valueParts.join('=').replace(/^["']|["']$/g, '');
if (!process.env[key]) { // Don't override existing env vars
process.env[key] = value;
}
}
}
}
const API_BASE_URL = 'https://api.evermind.ai';
/**
* Get the EverMem API key from environment
* @returns {string|null} API key or null if not set
*/
export function getApiKey() {
return process.env.EVERMEM_API_KEY || null;
}
/**
* Get the user ID for memory operations
* Defaults to 'claude-code-user' if not set
* @returns {string} User ID
*/
export function getUserId() {
return process.env.EVERMEM_USER_ID || 'claude-code-user';
}
/**
* Get the group ID for memory operations
* Uses project working directory as default group
* Format: {project_name_prefix_4}{path_hash_5} = 9 chars max
* @returns {string} Group ID
*/
export function getGroupId() {
if (process.env.EVERMEM_GROUP_ID) {
return process.env.EVERMEM_GROUP_ID;
}
// Use EVERMEM_CWD (set from hook input) or fall back to process.cwd()
const cwd = process.env.EVERMEM_CWD || process.cwd();
// Extract project name (last part of path)
const projectName = cwd.split('/').filter(Boolean).pop() || 'proj';
// Take first 4 chars of project name (lowercase, alphanumeric only)
const namePrefix = projectName.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 4) || 'proj';
// Hash the full path and take first 5 chars
const pathHash = createHash('sha256').update(cwd).digest('hex').substring(0, 5);
// Combine: 4 chars name + 5 chars hash = 9 chars
return `${namePrefix}${pathHash}`;
}
/**
* Get the API base URL
* @returns {string} Base URL
*/
export function getApiBaseUrl() {
return process.env.EVERMEM_API_URL || API_BASE_URL;
}
/**
* Check if the plugin is properly configured
* @returns {boolean} True if API key is set
*/
export function isConfigured() {
return !!getApiKey();
}
/**
* Get a hashed identifier for the API key (for local storage association)
* Uses SHA-256 hash, truncated to 12 characters for compactness
* @returns {string|null} Key ID (first 12 chars of SHA-256 hash) or null if no API key
*/
export function getKeyId() {
const apiKey = getApiKey();
if (!apiKey) {
return null;
}
const hash = createHash('sha256').update(apiKey).digest('hex');
return hash.substring(0, 12);
}
/**
* Get full configuration object
* @returns {Object} Configuration
*/
export function getConfig() {
return {
apiKey: getApiKey(),
userId: getUserId(),
groupId: getGroupId(),
apiBaseUrl: getApiBaseUrl(),
isConfigured: isConfigured()
};
}

View File

@ -0,0 +1,61 @@
/**
* Shared debug utility for EverMem hooks
*
* Usage:
* import { debug, setDebugPrefix } from './utils/debug.js';
* setDebugPrefix('inject'); // Optional: add prefix to log lines
* debug('hookInput:', data);
*
* Enable by setting EVERMEM_DEBUG=1 in .env file or environment
* Logs are written to /tmp/evermem-debug.log
*/
import { appendFileSync } from 'fs';
import { isConfigured } from './config.js'; // This loads .env
const DEBUG_LOG_PATH = '/tmp/evermem-debug.log';
// Check debug flag (after config.js loads .env)
const DEBUG = process.env.EVERMEM_DEBUG === '1';
// Optional prefix for log lines (e.g., 'inject' or 'store')
let debugPrefix = '';
/**
* Set a prefix for debug log lines
* @param {string} prefix - Prefix to add (e.g., 'inject', 'store')
*/
export function setDebugPrefix(prefix) {
debugPrefix = prefix ? `[${prefix}] ` : '';
}
/**
* Write debug message to log file
* Only writes when EVERMEM_DEBUG=1
*
* @param {...any} args - Arguments to log (objects are JSON stringified)
*/
export function debug(...args) {
if (!DEBUG) return;
const msg = args.map(a =>
typeof a === 'object' ? JSON.stringify(a, null, 2) : a
).join(' ');
const timestamp = new Date().toISOString();
const line = `[${timestamp}] ${debugPrefix}${msg}\n`;
try {
appendFileSync(DEBUG_LOG_PATH, line);
} catch (e) {
// Silent on write errors
}
}
/**
* Check if debug mode is enabled
* @returns {boolean}
*/
export function isDebugEnabled() {
return DEBUG;
}

View File

@ -0,0 +1,280 @@
/**
* EverMem Cloud API client
* Handles memory search and storage operations
*/
import { getConfig } from './config.js';
import { debug, setDebugPrefix } from './debug.js';
// Set debug prefix for this script
setDebugPrefix('EverMemAPI');
const TIMEOUT_MS = 30000; // 30 seconds
/**
* Search memories from EverMem Cloud (v1)
* @param {string} query - Search query text
* @param {Object} options - Additional options
* @param {number} options.topK - Max results (default: 10)
* @param {string} options.retrieveMethod - Search method: keyword|vector|hybrid|agentic (default: 'hybrid')
* @param {string[]} options.memoryTypes - Memory types (default: ['episodic_memory'])
* @returns {Promise<Object>} Raw API response with _debug envelope
*/
export async function searchMemories(query, options = {}) {
const config = getConfig();
if (!config.isConfigured) {
throw new Error('EverMem API key not configured');
}
const {
topK = 10,
retrieveMethod = 'hybrid',
memoryTypes = ['episodic_memory']
} = options;
const url = `${config.apiBaseUrl}/api/v1/memories/search`;
const filters = config.groupId
? { group_id: config.groupId }
: { user_id: config.userId };
const requestBody = {
query,
method: retrieveMethod,
top_k: topK,
memory_types: memoryTypes,
filters
};
debug('searchMemories request body', requestBody);
const debugEnvelope = {
url,
requestBody,
apiKeyMasked: 'API_KEY_HIDDEN'
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody),
signal: controller.signal
});
clearTimeout(timeoutId);
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
return { _debug: { ...debugEnvelope, status: response.status, rawBody: text, error: 'non-JSON response' } };
}
if (!response.ok) {
return { _debug: { ...debugEnvelope, status: response.status, error: data } };
}
data._debug = debugEnvelope;
return data;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`API timeout after ${TIMEOUT_MS}ms`);
}
return { _debug: { ...debugEnvelope, error: error.message } };
}
}
/**
* Transform v1 search API response to plugin memory format.
* v1 returns: { data: { episodes: [{ id, user_id, session_id, timestamp, summary, subject, score, participants, group_id? }], ... } }
* @param {Object} apiResponse - Raw v1 API response
* @returns {Object[]} Formatted memories sorted by score desc
*/
export function transformSearchResults(apiResponse) {
const episodes = apiResponse?.data?.episodes;
if (!Array.isArray(episodes)) {
return [];
}
const memories = [];
for (const ep of episodes) {
const content = ep.summary || '';
if (!content) continue;
memories.push({
text: content,
subject: ep.subject || '',
timestamp: ep.timestamp || new Date().toISOString(),
memoryType: ep.memory_type || 'episodic_memory',
score: ep.score || 0,
metadata: {
groupId: ep.group_id,
type: ep.memory_type,
participants: ep.participants
}
});
}
memories.sort((a, b) => b.score - a.score);
return memories;
}
/**
* Add a memory to EverMem Cloud (v1).
* Uses /api/v1/memories/group when config.groupId is set, else /api/v1/memories (personal).
* @param {Object} message - Message to store
* @param {string} message.content - Message content
* @param {string} message.role - 'user' or 'assistant'
* @param {string} [message.messageId] - (unused in v1; accepted for backward compatibility)
* @returns {Promise<Object>} Debug envelope { url, body, status, ok, response }
*/
export async function addMemory(message) {
const config = getConfig();
if (!config.isConfigured) {
throw new Error('EverMem API key not configured');
}
const role = message.role === 'assistant' ? 'assistant' : 'user';
const sender_id = role === 'assistant' ? 'claude-assistant' : config.userId;
const baseMessage = {
sender_id,
role,
timestamp: Date.now(),
content: message.content
};
let url;
let requestBody;
if (config.groupId) {
url = `${config.apiBaseUrl}/api/v1/memories/group`;
requestBody = {
group_id: config.groupId,
messages: [baseMessage],
async_mode: true
};
} else {
url = `${config.apiBaseUrl}/api/v1/memories`;
requestBody = {
user_id: config.userId,
messages: [baseMessage],
async_mode: true
};
}
let response, responseText, responseData, status, ok;
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
status = response.status;
ok = response.ok;
responseText = await response.text();
try {
responseData = JSON.parse(responseText);
} catch {}
} catch (fetchError) {
status = 0;
ok = false;
responseText = fetchError.message;
}
return {
url,
body: requestBody,
status,
ok,
response: responseData || responseText
};
}
/**
* Get memories from EverMem Cloud (v1, ordered newest first by default).
* @param {Object} options - Options
* @param {number} options.page - Page number (default: 1)
* @param {number} options.pageSize - Results per page (default: 100, max: 100)
* @param {string} options.memoryType - Memory type filter (default: 'episodic_memory')
* @returns {Promise<Object>} Raw v1 response { data: { episodes, total_count, count, ... } }
*/
export async function getMemories(options = {}) {
const config = getConfig();
if (!config.isConfigured) {
throw new Error('EverMem API key not configured');
}
const {
page = 1,
pageSize = 100,
memoryType = 'episodic_memory'
} = options;
const filters = config.groupId
? { group_id: config.groupId }
: { user_id: config.userId };
const url = `${config.apiBaseUrl}/api/v1/memories/get`;
const requestBody = {
memory_type: memoryType,
filters,
page,
page_size: pageSize,
rank_by: 'timestamp',
rank_order: 'desc'
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API error ${response.status}: ${errorText}`);
}
return await response.json();
}
/**
* Transform v1 getMemories response to simple format.
* @param {Object} apiResponse - Raw v1 API response
* @returns {Object[]} Formatted memories newest-first
*/
export function transformGetMemoriesResults(apiResponse) {
const episodes = apiResponse?.data?.episodes;
if (!Array.isArray(episodes)) {
return [];
}
const memories = episodes.map(ep => ({
text: ep.episode || ep.summary || '',
subject: ep.subject || '',
timestamp: ep.timestamp || new Date().toISOString(),
groupId: ep.group_id
})).filter(m => m.text);
memories.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
return memories;
}

View File

@ -0,0 +1,152 @@
/**
* Terminal output formatting utilities
*/
import { formatRelativeTime } from './mock-store.js';
// Memory type emoji mapping
const TYPE_ICONS = {
decision: { emoji: '\u{1F3AF}', ascii: '[DECISION]' }, // Target
bug_fix: { emoji: '\u{1F41B}', ascii: '[BUG]' }, // Bug
implementation: { emoji: '\u{1F527}', ascii: '[IMPL]' }, // Wrench
learning: { emoji: '\u{1F4A1}', ascii: '[LEARN]' }, // Lightbulb
preference: { emoji: '\u{2699}\u{FE0F}', ascii: '[PREF]' } // Gear
};
/**
* Detect if terminal likely supports Unicode
* @returns {boolean}
*/
export function supportsUnicode() {
const term = process.env.TERM || '';
const lang = process.env.LANG || '';
const lcAll = process.env.LC_ALL || '';
// Check for UTF-8 in locale settings
if (lang.includes('UTF-8') || lcAll.includes('UTF-8')) {
return true;
}
// Check for modern terminal types
if (term.includes('xterm') || term.includes('256color') || term.includes('kitty') || term.includes('alacritty')) {
return true;
}
// Default to Unicode on macOS
if (process.platform === 'darwin') {
return true;
}
return false;
}
/**
* Get icon for memory type
* @param {string} type - Memory type
* @param {boolean} useUnicode - Whether to use Unicode
* @returns {string}
*/
export function getTypeIcon(type, useUnicode = true) {
const icons = TYPE_ICONS[type] || TYPE_ICONS.implementation;
return useUnicode ? icons.emoji : icons.ascii;
}
/**
* Format the "Searching memories..." spinner
* @returns {string}
*/
export function formatSpinner() {
const useUnicode = supportsUnicode();
const icon = useUnicode ? '\u23F3' : '[...]'; // Hourglass
return `${icon} Searching memories...\n`;
}
/**
* @typedef {Object} FilteredMemory
* @property {string} text - Original memory text
* @property {string} timestamp - ISO timestamp
* @property {string} type - Memory type
*/
/**
* Format the memory summary box with original memories and timestamps
* @param {Object} result - SDK filter result
* @param {FilteredMemory[]} result.selected - Selected memories
* @param {string} result.synthesis - SDK synthesis
* @param {number} rawCount - Number of raw candidates
* @param {number} filteredCount - Number after filtering
* @returns {string}
*/
export function formatSummaryBox(result, rawCount, filteredCount) {
const useUnicode = supportsUnicode();
const divider = useUnicode ? '\u2500'.repeat(50) : '-'.repeat(50);
let output = '\n';
output += useUnicode ? '\u{1F4AD} Memory Retrieved\n' : '=== Memory Retrieved ===\n';
output += divider + '\n';
// Individual memories with original text and timestamp
for (let i = 0; i < result.selected.length; i++) {
const memory = result.selected[i];
const icon = getTypeIcon(memory.type, useUnicode);
const relativeTime = formatRelativeTime(memory.timestamp);
output += `${icon} (${relativeTime}) ${memory.text.slice(0, 80)}...\n`;
}
output += divider + '\n';
output += `${filteredCount} memories recalled\n`;
return output;
}
/**
* Format "No relevant memories" message
* @returns {string}
*/
export function formatNoMemories() {
const useUnicode = supportsUnicode();
const icon = useUnicode ? '\u{1F4AD}' : '===';
return `\n${icon} Memory Retrieved: No relevant memories found\n`;
}
/**
* Format error message
* @param {string} message - Error message
* @returns {string}
*/
export function formatError(message) {
const useUnicode = supportsUnicode();
const icon = useUnicode ? '\u26A0\u{FE0F}' : '[!]';
return `\n${icon} Memory Retrieved: ${message}\n Continuing without memory context\n`;
}
/**
* Format fallback summary (when SDK fails)
* @param {FilteredMemory[]} memories - Memory objects with text, timestamp, type
* @param {number} rawCount - Total raw candidates
* @returns {string}
*/
export function formatFallbackSummary(memories, rawCount) {
const useUnicode = supportsUnicode();
const divider = useUnicode ? '\u2500'.repeat(50) : '-'.repeat(50);
let output = '\n';
output += useUnicode ? '\u{1F4AD} Memory Retrieved (Fallback)\n' : '=== Memory Retrieved (Fallback) ===\n';
output += divider + '\n';
for (let i = 0; i < memories.length; i++) {
const memory = memories[i];
const icon = getTypeIcon(memory.type, useUnicode);
const relativeTime = formatRelativeTime(memory.timestamp);
output += `${icon} (${relativeTime}) ${memory.text.slice(0, 80)}...\n`;
}
output += divider + '\n';
output += `Showing top ${memories.length} matches (SDK unavailable)\n`;
return output;
}

View File

@ -0,0 +1,190 @@
/**
* Groups Store - Local persistence for memory groups (JSONL format)
*
* Each groupId+keyId combination is stored only once (no duplicates).
* Format: {"keyId":"...","groupId":"...","name":"...","path":"...","timestamp":"..."}
*
* keyId: SHA-256 hash (first 12 chars) of the API key - identifies which account owns this group
*/
import { readFileSync, appendFileSync, existsSync } from 'fs';
import { resolve, dirname, basename } from 'path';
import { fileURLToPath } from 'url';
import { getKeyId } from './config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const GROUPS_FILE = resolve(__dirname, '../../../data/groups.jsonl');
/**
* Check if the groupId+keyId combination already exists in the file
* @param {string} groupId - The group ID to check
* @param {string} keyId - The key ID (hashed API key) to check
* @returns {boolean} True if already exists (should skip)
*/
function alreadyExists(groupId, keyId) {
try {
if (!existsSync(GROUPS_FILE)) {
return false;
}
const content = readFileSync(GROUPS_FILE, 'utf8');
const lines = content.trim().split('\n').filter(Boolean);
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Match both groupId AND keyId (same project + same API key)
if (entry.groupId === groupId && entry.keyId === keyId) {
return true;
}
} catch {}
}
return false;
} catch {
return false;
}
}
/**
* Append a group entry to the JSONL file
* Only records if the groupId+keyId combination doesn't already exist
* @param {string} groupId - The group ID
* @param {string} cwd - The working directory path
* @returns {Object|null} The entry if saved, null if skipped or error
*/
export function saveGroup(groupId, cwd) {
try {
const keyId = getKeyId();
// Skip if this groupId+keyId already exists
if (alreadyExists(groupId, keyId)) {
return null;
}
const entry = {
keyId, // Hashed API key identifier (null if not configured)
groupId,
name: basename(cwd),
path: cwd,
timestamp: new Date().toISOString()
};
appendFileSync(GROUPS_FILE, JSON.stringify(entry) + '\n', 'utf8');
return entry;
} catch (e) {
// Silent on errors
return null;
}
}
/**
* Load and aggregate groups from the JSONL file
* @param {string} [filterKeyId] - Optional keyId to filter by (only show groups for this API key)
* @returns {Array} Aggregated list of groups
*/
export function getGroups(filterKeyId = null) {
try {
if (!existsSync(GROUPS_FILE)) {
return [];
}
const content = readFileSync(GROUPS_FILE, 'utf8');
const lines = content.trim().split('\n').filter(Boolean);
// Aggregate by groupId+keyId (composite key)
const groupMap = new Map();
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Skip if filtering by keyId and this entry doesn't match
if (filterKeyId && entry.keyId !== filterKeyId) {
continue;
}
// Use composite key: keyId:groupId (to separate same project under different accounts)
const compositeKey = `${entry.keyId || 'none'}:${entry.groupId}`;
const existing = groupMap.get(compositeKey);
if (existing) {
existing.sessionCount += 1;
// Update lastSeen if this timestamp is newer
if (entry.timestamp > existing.lastSeen) {
existing.lastSeen = entry.timestamp;
}
// Update firstSeen if this timestamp is older
if (entry.timestamp < existing.firstSeen) {
existing.firstSeen = entry.timestamp;
}
} else {
groupMap.set(compositeKey, {
id: entry.groupId,
keyId: entry.keyId || null,
name: entry.name,
path: entry.path,
firstSeen: entry.timestamp,
lastSeen: entry.timestamp,
sessionCount: 1
});
}
} catch {}
}
// Convert to array and sort by lastSeen (most recent first)
return Array.from(groupMap.values()).sort((a, b) =>
new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime()
);
} catch (e) {
return [];
}
}
/**
* Get groups for the current API key only
* @returns {Array} Aggregated list of groups for current keyId
*/
export function getMyGroups() {
const keyId = getKeyId();
return getGroups(keyId);
}
/**
* Get a specific group by ID (optionally filtered by current keyId)
* @param {string} groupId - The group ID
* @param {boolean} [filterByKey=true] - Whether to filter by current API key
* @returns {Object|null} The group or null if not found
*/
export function getGroup(groupId, filterByKey = true) {
const keyId = filterByKey ? getKeyId() : null;
const groups = getGroups(keyId);
return groups.find(g => g.id === groupId) || null;
}
/**
* Load raw groups data (for backward compatibility)
* @returns {Object} Groups data in old format
*/
export function loadGroups() {
return { groups: getGroups() };
}
/**
* Format relative time (e.g., "2h ago", "1d ago")
* @param {string} isoTime - ISO timestamp
* @returns {string} Relative time string
*/
export 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`;
}

View File

@ -0,0 +1,71 @@
/**
* Simple substring search for mock memories
* To be replaced with semantic retrieval in production
*/
const MAX_CANDIDATES = 15;
/**
* @typedef {Object} Memory
* @property {string} text - The memory content
* @property {string} timestamp - ISO timestamp when memory was created
*/
/**
* Search memories for matches to query terms
* @param {string} query - User's prompt
* @param {Memory[]} memories - Array of memory objects
* @returns {Memory[]} Matching memories (max 15)
*/
export function searchMemories(query, memories) {
if (!query || !memories || memories.length === 0) {
return [];
}
// Split query into terms, filter out very short terms
const queryTerms = query
.toLowerCase()
.split(/\s+/)
.filter(term => term.length > 2);
if (queryTerms.length === 0) {
return [];
}
// Find memories that match any query term
const matches = memories.filter(memory => {
const memoryLower = memory.text.toLowerCase();
return queryTerms.some(term => memoryLower.includes(term));
});
// Return up to MAX_CANDIDATES
return matches.slice(0, MAX_CANDIDATES);
}
/**
* Count words/tokens in a string (multilingual support)
* - For CJK (Chinese/Japanese/Korean): counts each character as a token
* - For other languages: counts space-separated words
* - For mixed text: counts both
* @param {string} text - Input text
* @returns {number} Word/token count
*/
export function countWords(text) {
if (!text) return 0;
const trimmed = text.trim();
if (!trimmed) return 0;
// Regex for CJK characters (Chinese, Japanese Kanji, Korean Hanja)
// Also includes Japanese Hiragana/Katakana and Korean Hangul
const cjkRegex = /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/g;
// Count CJK characters
const cjkMatches = trimmed.match(cjkRegex);
const cjkCount = cjkMatches ? cjkMatches.length : 0;
// Remove CJK characters and count remaining space-separated words
const nonCjkText = trimmed.replace(cjkRegex, ' ').trim();
const wordCount = nonCjkText ? nonCjkText.split(/\s+/).filter(w => w.length > 0).length : 0;
return cjkCount + wordCount;
}

View File

@ -0,0 +1,71 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DATA_PATH = join(__dirname, '..', '..', '..', 'data', 'mock-memories.json');
let memoriesCache = null;
/**
* @typedef {Object} Memory
* @property {string} text - The memory content
* @property {string} timestamp - ISO timestamp when memory was created
*/
/**
* Load mock memories from JSON file
* @returns {Memory[]} Array of memory objects with text and timestamp
*/
export function loadMemories() {
if (memoriesCache !== null) {
return memoriesCache;
}
try {
const data = readFileSync(DATA_PATH, 'utf-8');
const parsed = JSON.parse(data);
memoriesCache = parsed.memories || [];
return memoriesCache;
} catch (error) {
console.error(`[Memory Plugin] Failed to load memories: ${error.message}`);
return [];
}
}
/**
* Format a timestamp as relative time (e.g., "2h ago", "3 days ago")
* @param {string} isoTimestamp - ISO timestamp string
* @returns {string} Relative time string
*/
export function formatRelativeTime(isoTimestamp) {
const now = new Date();
const then = new Date(isoTimestamp);
const diffMs = now - then;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (months > 0) {
return months === 1 ? '1 month ago' : `${months} months ago`;
}
if (weeks > 0) {
return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
}
if (days > 0) {
return days === 1 ? '1 day ago' : `${days} days ago`;
}
if (hours > 0) {
return hours === 1 ? '1 hour ago' : `${hours}h ago`;
}
if (minutes > 0) {
return minutes === 1 ? '1 min ago' : `${minutes}m ago`;
}
return 'just now';
}

View File

@ -0,0 +1,183 @@
/**
* SDK-based filtering and summarization of memories
* Uses Claude Agent SDK to intelligently filter and summarize relevant memories
* Inherits authentication from Claude Code (no API key needed)
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import { execSync } from 'child_process';
const MAX_MEMORIES = 5;
const TIMEOUT_MS = 10000;
/**
* @typedef {Object} Memory
* @property {string} text - The memory content
* @property {string} timestamp - ISO timestamp when memory was created
*/
/**
* @typedef {Object} FilteredMemory
* @property {string} text - Original memory text
* @property {string} timestamp - Original timestamp
* @property {string} type - Inferred type (decision, bug_fix, etc.)
*/
/**
* Find the Claude Code executable path
* @returns {string|null} Path to claude executable or null if not found
*/
function findClaudeExecutable() {
try {
// Try 'which' on Unix-like systems
const result = execSync('which claude', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
return result.trim();
} catch {
try {
// Try 'where' on Windows
const result = execSync('where claude', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
return result.trim().split('\n')[0];
} catch {
return null;
}
}
}
/**
* Filter memories using Claude Agent SDK
* @param {string} prompt - User's current prompt
* @param {Memory[]} candidates - Array of candidate memory objects
* @returns {Promise<Object>} Filtered result with original memories and types
*/
export async function filterAndSummarize(prompt, candidates) {
const claudePath = findClaudeExecutable();
if (!claudePath) {
throw new Error('Claude Code executable not found');
}
const systemPrompt = `You are a JSON-only memory filter. You MUST respond with ONLY a JSON object, nothing else. No explanations, no markdown, no text before or after the JSON. Just the raw JSON object starting with { and ending with }.`;
const filterPrompt = `Filter these memories for relevance to the user's prompt.
USER PROMPT: "${prompt}"
CANDIDATE MEMORIES:
${candidates.map((c, i) => `[${i + 1}] ${c.text}`).join('\n\n')}
OUTPUT FORMAT (respond with ONLY this JSON, nothing else):
{"selected": [{"index": N, "type": "TYPE"}], "synthesis": "NARRATIVE"}
RULES:
- index is the memory number (1-based)
- type must be one of: decision, bug_fix, implementation, learning, preference
- Maximum ${MAX_MEMORIES} memories
- If no memories are relevant: {"selected": [], "synthesis": null}
- ONLY output the JSON object, no other text`;
// Create abort controller for timeout
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), TIMEOUT_MS);
try {
let responseText = '';
// Use Agent SDK query with Claude Code executable
const queryResult = query({
prompt: filterPrompt,
options: {
pathToClaudeCodeExecutable: claudePath,
model: 'claude-sonnet-4-20250514',
systemPrompt,
allowedTools: [], // No tools needed for filtering
abortController,
maxTurns: 1 // Single turn only
}
});
// Collect response text from the async generator
for await (const message of queryResult) {
if (message.type === 'assistant' && message.message?.content) {
for (const block of message.message.content) {
if (block.type === 'text') {
responseText += block.text;
}
}
}
}
clearTimeout(timeoutId);
// Extract JSON from response (handle potential text wrapping)
let jsonText = responseText.trim();
// Remove markdown code block if present
if (jsonText.includes('```')) {
const match = jsonText.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (match) {
jsonText = match[1];
}
}
// Try to find JSON object in the response
const jsonMatch = jsonText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
jsonText = jsonMatch[0];
}
// Parse JSON response
const parsed = JSON.parse(jsonText);
// Validate structure
if (!parsed.selected || !Array.isArray(parsed.selected)) {
throw new Error('Invalid response structure');
}
// Map back to original memories with type info
const selected = parsed.selected
.slice(0, MAX_MEMORIES)
.map(item => {
const originalMemory = candidates[item.index - 1];
if (!originalMemory) return null;
return {
text: originalMemory.text,
timestamp: originalMemory.timestamp,
type: item.type || 'implementation'
};
})
.filter(Boolean);
return {
selected,
synthesis: parsed.synthesis
};
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error('SDK timeout');
}
throw new Error(`SDK filter failed: ${error.message}`);
}
}
/**
* Create fallback result from raw candidates
* @param {Memory[]} candidates - Raw memory candidates
* @param {number} limit - Max memories to return
* @returns {Object} Fallback result structure
*/
export function createFallbackResult(candidates, limit = 3) {
const selected = candidates.slice(0, limit).map(memory => ({
text: memory.text,
timestamp: memory.timestamp,
type: 'implementation' // Default type
}));
return {
selected,
synthesis: null,
isFallback: true
};
}