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:
116
use-cases/claude-code-plugin/hooks/scripts/utils/config.js
Normal file
116
use-cases/claude-code-plugin/hooks/scripts/utils/config.js
Normal 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()
|
||||
};
|
||||
}
|
||||
61
use-cases/claude-code-plugin/hooks/scripts/utils/debug.js
Normal file
61
use-cases/claude-code-plugin/hooks/scripts/utils/debug.js
Normal 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;
|
||||
}
|
||||
280
use-cases/claude-code-plugin/hooks/scripts/utils/evermem-api.js
Normal file
280
use-cases/claude-code-plugin/hooks/scripts/utils/evermem-api.js
Normal 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;
|
||||
}
|
||||
152
use-cases/claude-code-plugin/hooks/scripts/utils/formatter.js
Normal file
152
use-cases/claude-code-plugin/hooks/scripts/utils/formatter.js
Normal 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;
|
||||
}
|
||||
190
use-cases/claude-code-plugin/hooks/scripts/utils/groups-store.js
Normal file
190
use-cases/claude-code-plugin/hooks/scripts/utils/groups-store.js
Normal 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`;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
183
use-cases/claude-code-plugin/hooks/scripts/utils/sdk-filter.js
Normal file
183
use-cases/claude-code-plugin/hooks/scripts/utils/sdk-filter.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user