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,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
};
}