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:
1101
use-cases/claude-code-plugin/README.md
Normal file
1101
use-cases/claude-code-plugin/README.md
Normal file
File diff suppressed because it is too large
Load Diff
937
use-cases/claude-code-plugin/assets/dashboard-preview.html
Normal file
937
use-cases/claude-code-plugin/assets/dashboard-preview.html
Normal file
@ -0,0 +1,937 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EverMem - Memory Hub (Preview)</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: rgba(13, 17, 23, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#header h1 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
#header .stats { font-size: 13px; color: #8b949e; margin-left: auto; }
|
||||
.preview-badge {
|
||||
background: #FFC53D22;
|
||||
color: #FFC53D;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@media (max-width: 900px) { .stats-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 600px) { .stats-grid { grid-template-columns: 1fr; } }
|
||||
.stat-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.stat-card .label { font-size: 12px; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.stat-card .value { font-size: 32px; font-weight: 600; color: #f0f6fc; }
|
||||
.stat-card .sub { font-size: 12px; color: #FFC53D; margin-top: 4px; }
|
||||
|
||||
/* Groups Section */
|
||||
.section-title { font-size: 16px; font-weight: 600; color: #f0f6fc; margin-bottom: 16px; }
|
||||
.groups-container { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Group Card */
|
||||
.group-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.group-header {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.group-header:hover { background: #1c2128; }
|
||||
.group-icon { font-size: 24px; }
|
||||
.group-info { flex: 1; min-width: 0; }
|
||||
.group-name { font-size: 15px; font-weight: 600; color: #f0f6fc; margin-bottom: 4px; }
|
||||
.group-path {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-id {
|
||||
font-size: 10px;
|
||||
color: #6e7681;
|
||||
font-family: monospace;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-id-label {
|
||||
color: #484f58;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.group-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.group-stats span { display: flex; align-items: center; gap: 4px; }
|
||||
.group-stats .count { color: #FFC53D; font-weight: 600; }
|
||||
.group-expand {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8b949e;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.group-card.expanded .group-expand { transform: rotate(180deg); }
|
||||
|
||||
/* Memories List */
|
||||
.memories-container {
|
||||
display: none;
|
||||
border-top: 1px solid #30363d;
|
||||
background: #0d1117;
|
||||
}
|
||||
.group-card.expanded .memories-container { display: block; }
|
||||
|
||||
/* Timeline within group */
|
||||
.group-timeline { padding: 16px 20px; }
|
||||
.timeline-day-section { margin-bottom: 20px; }
|
||||
.timeline-day-section:last-child { margin-bottom: 0; }
|
||||
.timeline-day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.timeline-day-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #FFC53D;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.timeline-day-dot.today { box-shadow: 0 0 8px rgba(255, 197, 61, 0.6); }
|
||||
.timeline-day-date { font-size: 13px; font-weight: 600; color: #f0f6fc; }
|
||||
.timeline-day-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
background: #FFC53D22;
|
||||
color: #FFC53D;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.timeline-day-count { font-size: 11px; color: #8b949e; margin-left: auto; }
|
||||
.timeline-memories { display: flex; flex-direction: column; gap: 8px; padding-left: 22px; }
|
||||
|
||||
.memory-item {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.memory-item:hover { border-color: #FFC53D; background: #161b22; }
|
||||
.memory-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.memory-emoji { font-size: 14px; }
|
||||
.memory-subject { font-size: 13px; font-weight: 500; color: #f0f6fc; flex: 1; }
|
||||
.memory-time { font-size: 11px; color: #8b949e; }
|
||||
.memory-preview { font-size: 12px; color: #8b949e; line-height: 1.5; max-height: 60px; overflow: hidden; }
|
||||
|
||||
.load-more {
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #21262d;
|
||||
}
|
||||
.load-more-btn {
|
||||
padding: 8px 16px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #c9d1d9;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.load-more-btn:hover { background: #30363d; }
|
||||
|
||||
/* Charts Row */
|
||||
.charts-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@media (max-width: 900px) { .charts-row { flex-direction: column; } }
|
||||
|
||||
/* Heatmap - GitHub style */
|
||||
.heatmap-section {
|
||||
flex: 2;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.heatmap-months {
|
||||
position: relative;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 36px;
|
||||
height: 16px;
|
||||
}
|
||||
.heatmap-months span { position: absolute; }
|
||||
.heatmap-body { display: flex; gap: 4px; }
|
||||
.heatmap-days {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
width: 32px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.heatmap-days span { height: 13px; line-height: 13px; }
|
||||
.heatmap-grid { display: flex; gap: 3px; }
|
||||
.heatmap-week { display: flex; flex-direction: column; gap: 3px; }
|
||||
.heatmap-cell {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.heatmap-cell:hover { transform: scale(1.3); z-index: 10; }
|
||||
.heatmap-cell.level-1 { background: #4d3a10; border-color: #4d3a10; }
|
||||
.heatmap-cell.level-2 { background: #806200; border-color: #806200; }
|
||||
.heatmap-cell.level-3 { background: #cc9a00; border-color: #cc9a00; }
|
||||
.heatmap-cell.level-4 { background: #FFC53D; border-color: #FFC53D; }
|
||||
.heatmap-tooltip {
|
||||
position: fixed;
|
||||
background: #1c2128;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #c9d1d9;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
display: none;
|
||||
}
|
||||
.heatmap-tooltip strong { color: #f0f6fc; }
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.heatmap-legend-cell { width: 12px; height: 12px; border-radius: 2px; }
|
||||
|
||||
/* Growth Chart */
|
||||
.growth-section {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.growth-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
height: 120px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.chart-bar-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.chart-bar-wrapper {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.chart-bar {
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, #e6b038, #FFC53D);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 4px;
|
||||
transition: height 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
.chart-bar:hover { filter: brightness(1.2); }
|
||||
.chart-bar .bar-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
.chart-bar:hover .bar-tooltip { opacity: 1; }
|
||||
.chart-label { font-size: 10px; color: #8b949e; margin-top: 8px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
.modal-overlay.visible { opacity: 1; visibility: visible; }
|
||||
.modal {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.modal-overlay.visible .modal { transform: scale(1); }
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.modal-header-content { flex: 1; }
|
||||
.modal-title { font-size: 16px; font-weight: 600; color: #f0f6fc; margin-bottom: 4px; }
|
||||
.modal-meta { font-size: 12px; color: #8b949e; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.modal-close:hover { color: #f0f6fc; background: #30363d; }
|
||||
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
|
||||
.modal-content { font-size: 14px; color: #c9d1d9; line-height: 1.7; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<h1>
|
||||
<span style="font-weight: 700;">EverMind</span> Memory Hub
|
||||
</h1>
|
||||
<span class="preview-badge">PREVIEW MODE</span>
|
||||
<span class="stats" id="stats">247 memories across 5 projects</span>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="modal-overlay" class="modal-overlay" onclick="closeModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="modal-header-content">
|
||||
<div class="modal-title" id="modal-title"></div>
|
||||
<div class="modal-meta" id="modal-meta"></div>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeModal()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-content" id="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heatmap-tooltip" id="heatmap-tooltip"></div>
|
||||
|
||||
<script>
|
||||
// ========== FAKE DATA ==========
|
||||
const fakeGroups = [
|
||||
{ id: 'grp_evermem_001', name: 'evermem-claude-code', path: '/Users/admin/Desktop/evermem-claude-code', sessionCount: 42 },
|
||||
{ id: 'grp_webapp_002', name: 'my-react-app', path: '/Users/admin/Projects/my-react-app', sessionCount: 28 },
|
||||
{ id: 'grp_api_003', name: 'backend-api', path: '/Users/admin/Projects/backend-api', sessionCount: 19 },
|
||||
{ id: 'grp_docs_004', name: 'documentation-site', path: '/Users/admin/Projects/documentation-site', sessionCount: 8 },
|
||||
{ id: 'grp_tools_005', name: 'dev-tools', path: '/Users/admin/Projects/dev-tools', sessionCount: 5 },
|
||||
];
|
||||
|
||||
const fakeMemoriesData = {
|
||||
'grp_evermem_001': generateFakeMemories('evermem-claude-code', 87, [
|
||||
{ subject: 'Fixed heatmap rendering bug', keywords: ['fix', 'debug'] },
|
||||
{ subject: 'Added session summary feature', keywords: ['add', 'create'] },
|
||||
{ subject: 'Refactored memory injection hooks', keywords: ['refactor'] },
|
||||
{ subject: 'Updated API endpoint to v0', keywords: ['update', 'api'] },
|
||||
{ subject: 'Debugged groups-store loading', keywords: ['debug', 'fix'] },
|
||||
{ subject: 'Created dashboard preview page', keywords: ['create', 'ui'] },
|
||||
{ subject: 'Optimized memory search performance', keywords: ['optimize'] },
|
||||
{ subject: 'Added error handling for API calls', keywords: ['add', 'error'] },
|
||||
{ subject: 'Fixed authentication flow', keywords: ['fix', 'auth'] },
|
||||
{ subject: 'Updated documentation', keywords: ['docs', 'update'] },
|
||||
]),
|
||||
'grp_webapp_002': generateFakeMemories('my-react-app', 72, [
|
||||
{ subject: 'Implemented user dashboard', keywords: ['create', 'ui'] },
|
||||
{ subject: 'Fixed navigation bug on mobile', keywords: ['fix', 'bug'] },
|
||||
{ subject: 'Added dark mode support', keywords: ['add', 'style'] },
|
||||
{ subject: 'Refactored state management', keywords: ['refactor'] },
|
||||
{ subject: 'Updated React to v18', keywords: ['update'] },
|
||||
{ subject: 'Created reusable button component', keywords: ['create', 'ui'] },
|
||||
{ subject: 'Fixed form validation errors', keywords: ['fix', 'error'] },
|
||||
{ subject: 'Added unit tests for auth', keywords: ['test', 'auth'] },
|
||||
]),
|
||||
'grp_api_003': generateFakeMemories('backend-api', 48, [
|
||||
{ subject: 'Created REST API endpoints', keywords: ['create', 'api'] },
|
||||
{ subject: 'Fixed database connection pool', keywords: ['fix', 'database'] },
|
||||
{ subject: 'Added rate limiting middleware', keywords: ['add', 'security'] },
|
||||
{ subject: 'Optimized query performance', keywords: ['optimize', 'database'] },
|
||||
{ subject: 'Implemented JWT authentication', keywords: ['auth', 'security'] },
|
||||
{ subject: 'Added API documentation', keywords: ['docs', 'api'] },
|
||||
]),
|
||||
'grp_docs_004': generateFakeMemories('documentation-site', 25, [
|
||||
{ subject: 'Created getting started guide', keywords: ['create', 'docs'] },
|
||||
{ subject: 'Updated API reference', keywords: ['update', 'docs', 'api'] },
|
||||
{ subject: 'Added code examples', keywords: ['add', 'docs'] },
|
||||
{ subject: 'Fixed broken links', keywords: ['fix'] },
|
||||
]),
|
||||
'grp_tools_005': generateFakeMemories('dev-tools', 15, [
|
||||
{ subject: 'Created CLI tool scaffold', keywords: ['create', 'build'] },
|
||||
{ subject: 'Added config file support', keywords: ['add', 'config'] },
|
||||
{ subject: 'Fixed path resolution bug', keywords: ['fix', 'bug'] },
|
||||
]),
|
||||
};
|
||||
|
||||
function generateFakeMemories(projectName, count, templates) {
|
||||
const memories = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const template = templates[i % templates.length];
|
||||
const daysAgo = Math.floor(Math.random() * 60);
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
date.setHours(Math.floor(Math.random() * 12) + 8, Math.floor(Math.random() * 60));
|
||||
|
||||
const contentVariations = [
|
||||
`Working on ${projectName}: ${template.subject}. Made progress on the implementation and tested the changes locally.`,
|
||||
`${template.subject} in ${projectName}. Updated related tests and verified everything works correctly.`,
|
||||
`Completed task: ${template.subject}. The changes have been verified and are ready for review.`,
|
||||
`${template.subject}. Investigated the issue, identified root cause, and implemented the fix.`,
|
||||
`Session focus: ${template.subject}. Collaborated with the team on the approach and finalized implementation.`,
|
||||
];
|
||||
|
||||
memories.push({
|
||||
id: `mem_${projectName}_${i}`,
|
||||
subject: template.subject + (i > templates.length ? ` (iteration ${Math.floor(i / templates.length) + 1})` : ''),
|
||||
content: contentVariations[i % contentVariations.length],
|
||||
timestamp: date.toISOString(),
|
||||
date: formatLocalDate(date),
|
||||
time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
keywords: template.keywords
|
||||
});
|
||||
}
|
||||
|
||||
return memories.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
}
|
||||
|
||||
function formatLocalDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ========== RENDERING ==========
|
||||
function renderDashboard() {
|
||||
const totalMemories = Object.values(fakeMemoriesData).reduce((sum, mems) => sum + mems.length, 0);
|
||||
const activeGroups = fakeGroups.length;
|
||||
|
||||
// Collect all memories for charts
|
||||
const allMemories = Object.values(fakeMemoriesData).flat();
|
||||
const memoriesByDate = {};
|
||||
allMemories.forEach(mem => {
|
||||
memoriesByDate[mem.date] = (memoriesByDate[mem.date] || 0) + 1;
|
||||
});
|
||||
|
||||
const activeDays = Object.keys(memoriesByDate).length;
|
||||
const avgPerDay = activeDays > 0 ? (totalMemories / activeDays).toFixed(1) : 0;
|
||||
|
||||
let html = `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Memories</div>
|
||||
<div class="value">${totalMemories}</div>
|
||||
<div class="sub">Across all projects</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Projects</div>
|
||||
<div class="value">${fakeGroups.length}</div>
|
||||
<div class="sub">${activeGroups} with memories</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Active Days</div>
|
||||
<div class="value">${activeDays}</div>
|
||||
<div class="sub">Days with memories</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Avg / Day</div>
|
||||
<div class="value">${avgPerDay}</div>
|
||||
<div class="sub">Memories per active day</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Avg / Project</div>
|
||||
<div class="value">${Math.round(totalMemories / activeGroups)}</div>
|
||||
<div class="sub">Memories per project</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-row">
|
||||
<div class="heatmap-section">
|
||||
<div class="section-title">Memory Activity (6 months)</div>
|
||||
<div class="heatmap-months" id="heatmap-months"></div>
|
||||
<div class="heatmap-body">
|
||||
<div class="heatmap-days">
|
||||
<span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span>
|
||||
</div>
|
||||
<div class="heatmap-grid" id="heatmap"></div>
|
||||
</div>
|
||||
<div class="heatmap-legend">
|
||||
<span>Less</span>
|
||||
<div class="heatmap-legend-cell" style="background: #161b22; border: 1px solid #21262d;"></div>
|
||||
<div class="heatmap-legend-cell" style="background: #4d3a10;"></div>
|
||||
<div class="heatmap-legend-cell" style="background: #806200;"></div>
|
||||
<div class="heatmap-legend-cell" style="background: #cc9a00;"></div>
|
||||
<div class="heatmap-legend-cell" style="background: #FFC53D;"></div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="growth-section">
|
||||
<div class="section-title">Last 7 Days</div>
|
||||
<div class="growth-chart" id="growth-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Your Projects</div>
|
||||
<div class="groups-container">
|
||||
`;
|
||||
|
||||
window._memoriesByDate = memoriesByDate;
|
||||
|
||||
// Sort groups by memory count
|
||||
const sortedGroups = [...fakeGroups].sort((a, b) => {
|
||||
const countA = fakeMemoriesData[a.id]?.length || 0;
|
||||
const countB = fakeMemoriesData[b.id]?.length || 0;
|
||||
return countB - countA;
|
||||
});
|
||||
|
||||
for (const group of sortedGroups) {
|
||||
const memories = fakeMemoriesData[group.id] || [];
|
||||
html += renderGroupCard(group, memories);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
document.getElementById('content').innerHTML = html;
|
||||
|
||||
buildHeatmap(memoriesByDate);
|
||||
buildGrowthChart(memoriesByDate);
|
||||
}
|
||||
|
||||
function renderGroupCard(group, memories) {
|
||||
const memCount = memories.length;
|
||||
|
||||
return `
|
||||
<div class="group-card" id="group-${encodeId(group.id)}" data-group-id="${escapeHtml(group.id)}">
|
||||
<div class="group-header" onclick="toggleGroup('${escapeHtml(group.id)}')">
|
||||
<span class="group-icon">📁</span>
|
||||
<div class="group-info">
|
||||
<div class="group-name">${escapeHtml(group.name)}</div>
|
||||
<div class="group-path">${escapeHtml(group.path)}</div>
|
||||
<div class="group-id"><span class="group-id-label">ID:</span>${escapeHtml(group.id)}</div>
|
||||
</div>
|
||||
<div class="group-stats">
|
||||
<span><span class="count">${memCount}</span> memories</span>
|
||||
<span>${group.sessionCount} sessions</span>
|
||||
</div>
|
||||
<div class="group-expand">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memories-container" id="memories-${encodeId(group.id)}">
|
||||
${renderMemoriesList(group.id, memories)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMemoriesList(groupId, memories) {
|
||||
if (memories.length === 0) {
|
||||
return '<div style="padding: 20px; text-align: center; color: #8b949e;">No memories yet</div>';
|
||||
}
|
||||
|
||||
// Group memories by date
|
||||
const byDate = {};
|
||||
memories.forEach(mem => {
|
||||
if (!byDate[mem.date]) byDate[mem.date] = [];
|
||||
byDate[mem.date].push(mem);
|
||||
});
|
||||
|
||||
const sortedDates = Object.keys(byDate).sort().reverse();
|
||||
const todayStr = formatLocalDate(new Date());
|
||||
|
||||
let html = '<div class="group-timeline">';
|
||||
|
||||
for (const date of sortedDates.slice(0, 5)) { // Show only first 5 days
|
||||
const dayMemories = byDate[date].sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
const isToday = date === todayStr;
|
||||
|
||||
const dateDisplay = new Date(date + 'T12:00:00').toLocaleDateString('en-US', {
|
||||
weekday: 'short', month: 'short', day: 'numeric'
|
||||
});
|
||||
|
||||
html += `
|
||||
<div class="timeline-day-section">
|
||||
<div class="timeline-day-header">
|
||||
<div class="timeline-day-dot${isToday ? ' today' : ''}"></div>
|
||||
<span class="timeline-day-date">${dateDisplay}</span>
|
||||
${isToday ? '<span class="timeline-day-badge">Today</span>' : ''}
|
||||
<span class="timeline-day-count">${dayMemories.length} ${dayMemories.length === 1 ? 'memory' : 'memories'}</span>
|
||||
</div>
|
||||
<div class="timeline-memories">
|
||||
`;
|
||||
|
||||
for (const mem of dayMemories.slice(0, 3)) { // Show max 3 per day
|
||||
const emoji = getEmojiForContent(mem.subject);
|
||||
const preview = mem.content.length > 120 ? mem.content.slice(0, 120) + '...' : mem.content;
|
||||
|
||||
html += `
|
||||
<div class="memory-item" onclick='openModal(${JSON.stringify(mem).replace(/'/g, "'")})'>
|
||||
<div class="memory-header">
|
||||
<span class="memory-emoji">${emoji}</span>
|
||||
<span class="memory-subject">${escapeHtml(mem.subject)}</span>
|
||||
<span class="memory-time">${mem.time}</span>
|
||||
</div>
|
||||
<div class="memory-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (sortedDates.length > 5) {
|
||||
html += `
|
||||
<div class="load-more">
|
||||
<button class="load-more-btn" onclick="alert('This is a preview - connect to API for full functionality')">
|
||||
Load more (${memories.length - 15} remaining)
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildHeatmap(memoriesByDate) {
|
||||
const heatmapContainer = document.getElementById('heatmap');
|
||||
const monthsContainer = document.getElementById('heatmap-months');
|
||||
if (!heatmapContainer) return;
|
||||
|
||||
const today = new Date();
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 182);
|
||||
while (startDate.getDay() !== 0) {
|
||||
startDate.setDate(startDate.getDate() - 1);
|
||||
}
|
||||
|
||||
const weeks = [];
|
||||
let currentWeek = [];
|
||||
const months = new Map();
|
||||
let currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= today) {
|
||||
const dateStr = formatLocalDate(currentDate);
|
||||
const monthKey = currentDate.toLocaleDateString('en-US', { month: 'short' });
|
||||
|
||||
if (!months.has(monthKey)) {
|
||||
months.set(monthKey, weeks.length);
|
||||
}
|
||||
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: memoriesByDate[dateStr] || 0,
|
||||
dayOfWeek: currentDate.getDay()
|
||||
});
|
||||
|
||||
if (currentDate.getDay() === 6) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
if (currentWeek.length > 0) {
|
||||
weeks.push(currentWeek);
|
||||
}
|
||||
|
||||
// Render month labels
|
||||
monthsContainer.innerHTML = '';
|
||||
const weekWidth = 16;
|
||||
months.forEach((weekIndex, monthName) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = monthName;
|
||||
span.style.left = `${weekIndex * weekWidth}px`;
|
||||
monthsContainer.appendChild(span);
|
||||
});
|
||||
|
||||
// Calculate thresholds
|
||||
const counts = Object.values(memoriesByDate);
|
||||
const maxCount = Math.max(...counts, 1);
|
||||
const q1 = Math.max(1, Math.ceil(maxCount * 0.25));
|
||||
const q2 = Math.max(2, Math.ceil(maxCount * 0.5));
|
||||
const q3 = Math.max(3, Math.ceil(maxCount * 0.75));
|
||||
|
||||
// Render weeks
|
||||
heatmapContainer.innerHTML = '';
|
||||
weeks.forEach((week, weekIndex) => {
|
||||
const weekDiv = document.createElement('div');
|
||||
weekDiv.className = 'heatmap-week';
|
||||
|
||||
if (weekIndex === 0 && week[0].dayOfWeek !== 0) {
|
||||
for (let i = 0; i < week[0].dayOfWeek; i++) {
|
||||
const emptyCell = document.createElement('div');
|
||||
emptyCell.className = 'heatmap-cell';
|
||||
emptyCell.style.visibility = 'hidden';
|
||||
weekDiv.appendChild(emptyCell);
|
||||
}
|
||||
}
|
||||
|
||||
week.forEach(day => {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'heatmap-cell';
|
||||
cell.dataset.date = day.date;
|
||||
cell.dataset.count = day.count;
|
||||
|
||||
if (day.count >= q3) cell.classList.add('level-4');
|
||||
else if (day.count >= q2) cell.classList.add('level-3');
|
||||
else if (day.count >= q1) cell.classList.add('level-2');
|
||||
else if (day.count >= 1) cell.classList.add('level-1');
|
||||
|
||||
cell.addEventListener('mouseenter', showHeatmapTooltip);
|
||||
cell.addEventListener('mouseleave', hideHeatmapTooltip);
|
||||
weekDiv.appendChild(cell);
|
||||
});
|
||||
|
||||
if (weekIndex === weeks.length - 1 && week[week.length - 1].dayOfWeek !== 6) {
|
||||
for (let i = week[week.length - 1].dayOfWeek + 1; i <= 6; i++) {
|
||||
const emptyCell = document.createElement('div');
|
||||
emptyCell.className = 'heatmap-cell';
|
||||
emptyCell.style.visibility = 'hidden';
|
||||
weekDiv.appendChild(emptyCell);
|
||||
}
|
||||
}
|
||||
|
||||
heatmapContainer.appendChild(weekDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function showHeatmapTooltip(e) {
|
||||
const tooltip = document.getElementById('heatmap-tooltip');
|
||||
const cell = e.target;
|
||||
const date = cell.dataset.date;
|
||||
const count = parseInt(cell.dataset.count);
|
||||
|
||||
const dateObj = new Date(date + 'T12:00:00');
|
||||
const formattedDate = dateObj.toLocaleDateString('en-US', {
|
||||
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric'
|
||||
});
|
||||
|
||||
tooltip.innerHTML = `<strong>${count} ${count === 1 ? 'memory' : 'memories'}</strong> on ${formattedDate}`;
|
||||
tooltip.style.display = 'block';
|
||||
|
||||
const rect = cell.getBoundingClientRect();
|
||||
tooltip.style.left = `${rect.left + rect.width / 2}px`;
|
||||
tooltip.style.top = `${rect.top - 40}px`;
|
||||
tooltip.style.transform = 'translateX(-50%)';
|
||||
}
|
||||
|
||||
function hideHeatmapTooltip() {
|
||||
document.getElementById('heatmap-tooltip').style.display = 'none';
|
||||
}
|
||||
|
||||
function buildGrowthChart(memoriesByDate) {
|
||||
const chartContainer = document.getElementById('growth-chart');
|
||||
if (!chartContainer) return;
|
||||
|
||||
const today = new Date();
|
||||
const dailyData = [];
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = formatLocalDate(date);
|
||||
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
dailyData.push({ date: dayName, count: memoriesByDate[dateStr] || 0 });
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...dailyData.map(d => d.count), 1);
|
||||
|
||||
chartContainer.innerHTML = '';
|
||||
dailyData.forEach(day => {
|
||||
const barContainer = document.createElement('div');
|
||||
barContainer.className = 'chart-bar-container';
|
||||
|
||||
const barWrapper = document.createElement('div');
|
||||
barWrapper.className = 'chart-bar-wrapper';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'chart-bar';
|
||||
bar.style.height = day.count > 0 ? `${(day.count / maxCount) * 100}%` : '4px';
|
||||
bar.style.opacity = day.count > 0 ? '1' : '0.2';
|
||||
|
||||
const barTooltip = document.createElement('div');
|
||||
barTooltip.className = 'bar-tooltip';
|
||||
barTooltip.textContent = `${day.count} memories`;
|
||||
bar.appendChild(barTooltip);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'chart-label';
|
||||
label.textContent = day.date;
|
||||
|
||||
barWrapper.appendChild(bar);
|
||||
barContainer.appendChild(barWrapper);
|
||||
barContainer.appendChild(label);
|
||||
chartContainer.appendChild(barContainer);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleGroup(groupId) {
|
||||
const card = document.getElementById(`group-${encodeId(groupId)}`);
|
||||
if (card) card.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
function openModal(memory) {
|
||||
const dateStr = new Date(memory.timestamp).toLocaleDateString('en-US', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
document.getElementById('modal-title').textContent = memory.subject || 'Memory';
|
||||
document.getElementById('modal-meta').textContent = dateStr;
|
||||
document.getElementById('modal-content').textContent = memory.content;
|
||||
|
||||
document.getElementById('modal-overlay').classList.add('visible');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
if (event && event.target !== event.currentTarget) return;
|
||||
document.getElementById('modal-overlay').classList.remove('visible');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
// Helpers
|
||||
function encodeId(str) {
|
||||
return btoa(str).replace(/[+/=]/g, c => ({ '+': '-', '/': '_', '=': '' }[c]));
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Emoji mapping
|
||||
const emojiKeywords = [
|
||||
['debug', '🐛'], ['bug', '🐛'], ['fix', '🔧'], ['error', '❌'],
|
||||
['test', '🧪'], ['add', '➕'], ['create', '✨'], ['new', '🆕'],
|
||||
['remove', '🗑️'], ['delete', '🗑️'], ['update', '✏️'], ['change', '📝'],
|
||||
['refactor', '♻️'], ['optimize', '⚡'], ['deploy', '🚀'], ['release', '📦'],
|
||||
['api', '🔌'], ['database', '🗄️'], ['auth', '🔐'], ['security', '🛡️'],
|
||||
['ui', '🎨'], ['style', '💅'], ['docs', '📖'], ['config', '⚙️'],
|
||||
['build', '🏗️'], ['merge', '🔀'], ['review', '👀'], ['commit', '📝'],
|
||||
];
|
||||
|
||||
function getEmojiForContent(text) {
|
||||
if (!text) return '💭';
|
||||
const lower = text.toLowerCase();
|
||||
for (const [keyword, emoji] of emojiKeywords) {
|
||||
if (lower.includes(keyword)) return emoji;
|
||||
}
|
||||
return '💭';
|
||||
}
|
||||
|
||||
// Initialize
|
||||
renderDashboard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1399
use-cases/claude-code-plugin/assets/dashboard.html
Normal file
1399
use-cases/claude-code-plugin/assets/dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
57
use-cases/claude-code-plugin/commands/ask.md
Normal file
57
use-cases/claude-code-plugin/commands/ask.md
Normal file
@ -0,0 +1,57 @@
|
||||
---
|
||||
description: Ask a question about past work. Searches memories and combines with current context to answer.
|
||||
arguments:
|
||||
- name: question
|
||||
description: The question to answer
|
||||
required: true
|
||||
---
|
||||
|
||||
# EverMem Ask
|
||||
|
||||
Answer a question using **both** memory search results **and** current conversation context.
|
||||
|
||||
## Question
|
||||
{{question}}
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Search memories** using `evermem_search` MCP tool with relevant keywords. Start with 10 results.
|
||||
|
||||
2. **Evaluate results**:
|
||||
- If memories provide useful context, note what you learned
|
||||
- If more detail needed, search again with different keywords (up to 3 searches)
|
||||
- If no relevant memories found, that's OK - proceed with what you know
|
||||
|
||||
3. **Combine sources** to answer:
|
||||
- Memory search results (past sessions)
|
||||
- Current conversation context (this session)
|
||||
- Your general knowledge (when applicable)
|
||||
|
||||
4. **Be honest about sources**:
|
||||
- "Based on our discussion on [date]..." - when citing memory
|
||||
- "From our current session..." - when citing current context
|
||||
- "I don't have any recorded information about this" - when memories don't help
|
||||
- "Based on general best practices..." - when using general knowledge
|
||||
|
||||
5. **Admit uncertainty**:
|
||||
- If memories are incomplete or unclear, say so
|
||||
- If you're inferring rather than recalling, make that clear
|
||||
- It's better to say "I don't know" than to guess
|
||||
|
||||
## Response Format
|
||||
|
||||
Start with a direct answer, then provide supporting context:
|
||||
|
||||
```
|
||||
[Direct answer to the question]
|
||||
|
||||
**From memories:**
|
||||
- [Relevant points from past sessions, with dates]
|
||||
|
||||
**Current context:**
|
||||
- [Relevant points from this session, if any]
|
||||
|
||||
**Note:** [Any caveats or gaps in knowledge]
|
||||
```
|
||||
|
||||
Now answer the user's question.
|
||||
59
use-cases/claude-code-plugin/commands/debug.md
Normal file
59
use-cases/claude-code-plugin/commands/debug.md
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
description: View EverMem debug logs to troubleshoot memory saving and retrieval issues
|
||||
---
|
||||
|
||||
# EverMem Debug Log Viewer
|
||||
|
||||
View the EverMem debug log to troubleshoot issues.
|
||||
|
||||
## Instructions
|
||||
|
||||
Show the user the recent debug log entries from `/tmp/evermem-debug.log`.
|
||||
|
||||
1. First check if debug mode is enabled by looking for `EVERMEM_DEBUG=1` in the plugin's `.env` file
|
||||
2. Read the last 50 lines of the debug log file
|
||||
3. If the file doesn't exist or is empty, inform the user how to enable debug mode
|
||||
|
||||
## Actions
|
||||
|
||||
1. Check debug mode status:
|
||||
```bash
|
||||
grep "EVERMEM_DEBUG" /path/to/plugin/.env 2>/dev/null || echo "Not configured"
|
||||
```
|
||||
|
||||
2. Show recent logs:
|
||||
```bash
|
||||
tail -50 /tmp/evermem-debug.log 2>/dev/null || echo "No debug log found"
|
||||
```
|
||||
|
||||
3. Format the output for the user, highlighting:
|
||||
- `[inject]` entries for memory retrieval
|
||||
- `[store]` entries for memory saving
|
||||
- Any errors or warnings
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
📋 EverMem Debug Log
|
||||
|
||||
Status: Debug mode [ENABLED/DISABLED]
|
||||
Log file: /tmp/evermem-debug.log
|
||||
|
||||
--- Recent Entries ---
|
||||
[timestamp] [inject] ...
|
||||
[timestamp] [store] ...
|
||||
|
||||
--- Tips ---
|
||||
• Enable debug: Add EVERMEM_DEBUG=1 to .env
|
||||
• Clear log: > /tmp/evermem-debug.log
|
||||
• Live view: tail -f /tmp/evermem-debug.log
|
||||
```
|
||||
|
||||
## Additional Options
|
||||
|
||||
If the user specifies arguments:
|
||||
- `clear` - Clear the debug log
|
||||
- `live` - Show command for live monitoring
|
||||
- `full` - Show more lines (100+)
|
||||
- `inject` - Filter to show only [inject] entries
|
||||
- `store` - Filter to show only [store] entries
|
||||
45
use-cases/claude-code-plugin/commands/help.md
Normal file
45
use-cases/claude-code-plugin/commands/help.md
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
description: Get help with EverMem plugin setup and available commands
|
||||
---
|
||||
|
||||
EverMem is a memory plugin for Claude Code that automatically stores and retrieves relevant context from your past coding sessions.
|
||||
|
||||
**How it works:**
|
||||
- When you chat with Claude, your conversations are automatically saved to EverMem Cloud
|
||||
- When you start a new session, relevant memories from past sessions are automatically injected into context
|
||||
- You can also manually search your memories using the `/evermem:search` command
|
||||
|
||||
First, check if the API key is configured:
|
||||
|
||||
```bash
|
||||
if [ -z "${EVERMEM_API_KEY:-}" ]; then
|
||||
echo "STATUS: Not configured"
|
||||
echo ""
|
||||
echo "To get started:"
|
||||
echo "1. Visit https://console.evermind.ai/ to get your API key"
|
||||
echo "2. Add to your shell config (~/.zshrc or ~/.bashrc):"
|
||||
echo " export EVERMEM_API_KEY=\"your_api_key_here\""
|
||||
echo "3. Restart Claude Code"
|
||||
else
|
||||
echo "STATUS: Configured"
|
||||
echo "API Key: ${EVERMEM_API_KEY:0:10}..."
|
||||
fi
|
||||
```
|
||||
|
||||
Present the configuration status to the user. If not configured, guide them through the setup steps.
|
||||
|
||||
**Available Commands:**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/evermem:help` | Show this help message |
|
||||
| `/evermem:search <query>` | Search your memories for specific topics |
|
||||
| `/evermem:hub` | Open the Memory Hub dashboard to visualize and explore memories |
|
||||
| `/evermem:debug` | View debug logs for troubleshooting |
|
||||
| `/evermem:projects` | View your Claude Code projects table |
|
||||
|
||||
**Automatic Features:**
|
||||
- **Memory Retrieved**: When you submit a prompt, relevant memories are automatically retrieved and shown
|
||||
- **Memory Save**: When Claude finishes responding, the conversation is automatically saved to EverMem Cloud
|
||||
|
||||
Share this information with the user in a clear, helpful format.
|
||||
21
use-cases/claude-code-plugin/commands/hub.md
Normal file
21
use-cases/claude-code-plugin/commands/hub.md
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
description: Open the EverMem Memory Hub to view statistics, search memories, and explore timeline
|
||||
---
|
||||
|
||||
When the user runs this command:
|
||||
|
||||
1. First, start the proxy server in the background using the Bash tool:
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/server/proxy.js" &
|
||||
```
|
||||
|
||||
2. Then, construct the Memory Hub URL with the actual API key using Bash:
|
||||
```bash
|
||||
echo "http://localhost:3456/?key=${EVERMEM_API_KEY}"
|
||||
```
|
||||
|
||||
3. Share a simple message with the user like:
|
||||
"Memory Hub server started. Open this URL to view your memories:
|
||||
[the URL from step 2]"
|
||||
|
||||
Do NOT show the bash commands or code blocks to the user. Just run them and share the final URL.
|
||||
71
use-cases/claude-code-plugin/commands/projects.md
Normal file
71
use-cases/claude-code-plugin/commands/projects.md
Normal file
@ -0,0 +1,71 @@
|
||||
---
|
||||
description: View your Claude Code projects tracked by EverMem
|
||||
---
|
||||
|
||||
# EverMem Projects
|
||||
View all Claude Code projects that have been tracked by EverMem.
|
||||
|
||||
## Instructions
|
||||
|
||||
Show the user their projects stored in the local groups.jsonl file.
|
||||
|
||||
1. Read the groups file from the plugin's data directory
|
||||
2. Aggregate entries by groupId (count sessions, find first/last seen)
|
||||
3. Display the project table with statistics
|
||||
4. If no groups file exists, explain that projects are tracked automatically
|
||||
|
||||
## Actions
|
||||
|
||||
Check and read the groups data file:
|
||||
|
||||
```bash
|
||||
GROUPS_FILE="${CLAUDE_PLUGIN_ROOT}/data/groups.jsonl"
|
||||
if [ -f "$GROUPS_FILE" ] && [ -s "$GROUPS_FILE" ]; then
|
||||
cat "$GROUPS_FILE"
|
||||
else
|
||||
echo "NO_GROUPS_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
**Note:** The file uses JSONL format (one JSON object per line). Each line is a session start event.
|
||||
|
||||
Entry format: `{"keyId":"...","groupId":"...","name":"...","path":"...","timestamp":"..."}`
|
||||
|
||||
- `keyId`: SHA-256 hash (first 12 chars) of the API key - associates projects with accounts
|
||||
- `groupId`: Short identifier (9 chars: project name prefix + path hash)
|
||||
|
||||
Aggregate by `keyId + groupId` when displaying:
|
||||
- Count occurrences = sessionCount
|
||||
- Earliest timestamp = firstSeen
|
||||
- Latest timestamp = lastSeen
|
||||
|
||||
## Output Format
|
||||
|
||||
If projects exist:
|
||||
```
|
||||
📁 Claude Code Projects
|
||||
|
||||
| Project | Group ID | Sessions | Last Active |
|
||||
|---------------------|------------|----------|-------------|
|
||||
| evermem-claude-code | ever8d8d5 | 42 | just now |
|
||||
| my-react-app | myrea1b2c3 | 12 | 2h ago |
|
||||
|
||||
Total: 2 projects
|
||||
```
|
||||
|
||||
If no projects file:
|
||||
```
|
||||
📁 Claude Code Projects
|
||||
|
||||
No projects tracked yet. Projects are automatically recorded when you start Claude Code sessions.
|
||||
|
||||
Each project directory creates a unique group ID for organizing memories.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Projects are identified by working directory path (hashed to 9-char ID)
|
||||
- Each project has its own memory namespace in EverMem Cloud
|
||||
- The groups.jsonl file is appended by the SessionStart hook
|
||||
- Same project used with different API keys will appear as separate entries
|
||||
- `keyId` is a SHA-256 hash (first 12 chars) of the API key - secure and unique
|
||||
89
use-cases/claude-code-plugin/commands/scripts/search-memories.js
Executable file
89
use-cases/claude-code-plugin/commands/scripts/search-memories.js
Executable file
@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Search memories from EverMem Cloud
|
||||
* Usage: node search-memories.js "query string"
|
||||
*/
|
||||
|
||||
import { getConfig, isConfigured } from '../../hooks/scripts/utils/config.js';
|
||||
import { searchMemories, transformSearchResults } from '../../hooks/scripts/utils/evermem-api.js';
|
||||
|
||||
const query = process.argv[2] || '';
|
||||
|
||||
if (!query) {
|
||||
console.log('Usage: /evermem:search <query>');
|
||||
console.log('Example: /evermem:search "how do we handle authentication"');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!isConfigured()) {
|
||||
console.log('Error: EVERMEM_API_KEY not configured');
|
||||
console.log('Set it with: export EVERMEM_API_KEY="your-key"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const config = getConfig();
|
||||
console.log('Searching EverMem Cloud...\n');
|
||||
console.log(`Query: "${query}"`);
|
||||
console.log(`User: ${config.userId}`);
|
||||
console.log(`Group: ${config.groupId}`);
|
||||
console.log('');
|
||||
|
||||
const apiResponse = await searchMemories(query, {
|
||||
topK: 10,
|
||||
retrieveMethod: 'hybrid'
|
||||
});
|
||||
|
||||
// Debug: show raw API response
|
||||
console.log('--- RAW API RESPONSE ---');
|
||||
console.log(JSON.stringify(apiResponse, null, 2));
|
||||
console.log('--- END RAW RESPONSE ---\n');
|
||||
|
||||
const memories = transformSearchResults(apiResponse);
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log('No memories found matching your query.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${memories.length} memories:`);
|
||||
console.log('='.repeat(70));
|
||||
|
||||
for (let i = 0; i < memories.length; i++) {
|
||||
const m = memories[i];
|
||||
const score = m.score ? `${(m.score * 100).toFixed(1)}%` : 'N/A';
|
||||
const date = new Date(m.timestamp).toLocaleDateString();
|
||||
const time = new Date(m.timestamp).toLocaleTimeString();
|
||||
|
||||
console.log('');
|
||||
console.log(`${i + 1}. [Score: ${score}] ${date} ${time}`);
|
||||
console.log('-'.repeat(70));
|
||||
|
||||
// Word wrap the content
|
||||
const words = m.text.split(' ');
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
if ((line + ' ' + word).length > 70) {
|
||||
console.log(line.trim());
|
||||
line = word;
|
||||
} else {
|
||||
line += ' ' + word;
|
||||
}
|
||||
}
|
||||
if (line.trim()) {
|
||||
console.log(line.trim());
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
17
use-cases/claude-code-plugin/commands/search.md
Normal file
17
use-cases/claude-code-plugin/commands/search.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Search EverMem for relevant memories from past sessions
|
||||
arguments:
|
||||
- name: query
|
||||
description: The search query to find relevant memories
|
||||
required: true
|
||||
---
|
||||
|
||||
Search EverMem Cloud for memories matching the user's query.
|
||||
|
||||
Run this command to search:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/commands/scripts/search-memories.js" "$ARGUMENTS"
|
||||
```
|
||||
|
||||
After the search completes, summarize the key findings for the user. Highlight the most relevant memories and explain how they might be useful for their current work.
|
||||
104
use-cases/claude-code-plugin/data/mock-memories.json
Normal file
104
use-cases/claude-code-plugin/data/mock-memories.json
Normal file
@ -0,0 +1,104 @@
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"text": "We decided to use JWT tokens with 15-minute expiry for authentication. Refresh tokens are stored in httpOnly cookies to prevent XSS attacks. This was chosen over session-based auth for better scalability across our microservices.",
|
||||
"timestamp": "2026-01-15T08:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed the token refresh race condition bug. When multiple API calls detected an expired token simultaneously, they all tried to refresh, causing 401 cascades. Added a mutex lock around the refresh logic and a queue for pending requests.",
|
||||
"timestamp": "2026-01-14T15:45:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented rate limiting using Redis with a sliding window algorithm. Set to 100 requests per minute per user. Chose sliding window over fixed window to prevent burst attacks at window boundaries.",
|
||||
"timestamp": "2026-01-10T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Switched from MongoDB to PostgreSQL for the user service. The relational model better fits our data with complex joins. Using Prisma as the ORM for type safety.",
|
||||
"timestamp": "2026-01-02T14:20:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added retry logic with exponential backoff for all external API calls. Max 3 retries, starting at 100ms, doubling each time. Includes jitter to prevent thundering herd.",
|
||||
"timestamp": "2025-12-28T11:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Decided on GraphQL for the mobile API, REST for internal services. GraphQL reduces over-fetching for mobile clients with limited bandwidth. REST is simpler for service-to-service communication.",
|
||||
"timestamp": "2025-12-20T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed memory leak in the WebSocket connection handler. Connections weren't being cleaned up on client disconnect. Added proper event listeners for 'close' and 'error' events.",
|
||||
"timestamp": "2026-01-13T08:15:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented database connection pooling with a max of 20 connections per service instance. This resolved the connection exhaustion issues during traffic spikes.",
|
||||
"timestamp": "2025-12-15T10:45:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added request correlation IDs for distributed tracing. Every incoming request gets a UUID that propagates through all downstream service calls. Makes debugging much easier.",
|
||||
"timestamp": "2025-12-10T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Chose bcrypt with cost factor 12 for password hashing. Argon2 was considered but bcrypt has better library support across our stack. Cost factor 12 gives ~250ms hash time.",
|
||||
"timestamp": "2025-11-25T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented graceful shutdown for the API server. On SIGTERM, stop accepting new connections, wait for in-flight requests (max 30s), then exit. Prevents dropped requests during deploys.",
|
||||
"timestamp": "2025-12-05T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed the N+1 query problem in the orders endpoint. Was making a separate DB call for each order's items. Switched to a single query with JOIN and manual result mapping.",
|
||||
"timestamp": "2026-01-08T11:20:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added circuit breaker pattern for the payment service integration. Opens after 5 failures in 30 seconds, half-open after 60 seconds. Prevents cascade failures.",
|
||||
"timestamp": "2025-12-18T15:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Decided to use UUIDs for all public-facing IDs instead of auto-increment integers. Prevents enumeration attacks and makes sharding easier in the future.",
|
||||
"timestamp": "2025-11-15T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented API versioning via URL path (/v1/, /v2/). Header-based versioning was considered but URL is more explicit and easier to debug. Old versions sunset after 6 months.",
|
||||
"timestamp": "2025-11-20T16:45:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed timezone bug in date filtering. All dates now stored as UTC in database, converted to user's timezone only in the API response layer. Using date-fns-tz for conversions.",
|
||||
"timestamp": "2026-01-12T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added structured logging with JSON format. Each log entry includes timestamp, level, correlation_id, service_name, and message. Enables better log aggregation in Elasticsearch.",
|
||||
"timestamp": "2025-12-01T11:15:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented soft deletes for user data using a deleted_at timestamp. Required for GDPR compliance - we need to retain some data for audit purposes even after user requests deletion.",
|
||||
"timestamp": "2025-11-10T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Switched from REST polling to WebSockets for real-time notifications. Reduced server load significantly. Using Socket.io for automatic reconnection and room-based broadcasting.",
|
||||
"timestamp": "2025-12-22T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added input validation using Zod schemas. All API endpoints validate request body, query params, and path params. Returns 400 with detailed error messages on validation failure.",
|
||||
"timestamp": "2026-01-05T10:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented file upload using pre-signed S3 URLs. Client uploads directly to S3, then notifies our API with the object key. Keeps large files off our servers.",
|
||||
"timestamp": "2025-12-08T15:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed race condition in inventory updates. Two concurrent orders could both see available stock and both succeed. Added optimistic locking with version numbers.",
|
||||
"timestamp": "2026-01-11T14:45:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Decided on feature flags using LaunchDarkly. Allows gradual rollouts and instant kill switches. All new features wrapped in flags by default.",
|
||||
"timestamp": "2025-11-28T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added health check endpoint at /health that verifies database connectivity, Redis connectivity, and critical external service availability. Used by load balancer and Kubernetes probes.",
|
||||
"timestamp": "2025-12-12T16:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented request rate limiting per API key, not just per IP. Prevents abuse from authenticated users. Limits stored in Redis with sliding window counters.",
|
||||
"timestamp": "2026-01-03T11:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
52
use-cases/claude-code-plugin/hooks/hooks.json
Normal file
52
use-cases/claude-code-plugin/hooks/hooks.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
239
use-cases/claude-code-plugin/hooks/scripts/inject-memories.js
Executable file
239
use-cases/claude-code-plugin/hooks/scripts/inject-memories.js
Executable 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();
|
||||
14
use-cases/claude-code-plugin/hooks/scripts/session-context-wrapper.sh
Executable file
14
use-cases/claude-code-plugin/hooks/scripts/session-context-wrapper.sh
Executable 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"
|
||||
257
use-cases/claude-code-plugin/hooks/scripts/session-context.js
Executable file
257
use-cases/claude-code-plugin/hooks/scripts/session-context.js
Executable 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();
|
||||
200
use-cases/claude-code-plugin/hooks/scripts/session-summary.js
Executable file
200
use-cases/claude-code-plugin/hooks/scripts/session-summary.js
Executable 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));
|
||||
298
use-cases/claude-code-plugin/hooks/scripts/store-memories.js
Executable file
298
use-cases/claude-code-plugin/hooks/scripts/store-memories.js
Executable 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);
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
175
use-cases/claude-code-plugin/install.sh
Executable file
175
use-cases/claude-code-plugin/install.sh
Executable file
@ -0,0 +1,175 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN} ███████╗██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███╗ ███╗${NC}"
|
||||
echo -e "${CYAN} ██╔════╝██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝████╗ ████║${NC}"
|
||||
echo -e "${CYAN} █████╗ ██║ ██║█████╗ ██████╔╝██╔████╔██║█████╗ ██╔████╔██║${NC}"
|
||||
echo -e "${CYAN} ██╔══╝ ╚██╗ ██╔╝██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║${NC}"
|
||||
echo -e "${CYAN} ███████╗ ╚████╔╝ ███████╗██║ ██║██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║${NC}"
|
||||
echo -e "${CYAN} ╚══════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW} 牛 马 日 记${NC}"
|
||||
echo ""
|
||||
echo -e " ${GREEN}Plugin for Claude Code${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if claude CLI is installed
|
||||
if ! command -v claude &> /dev/null; then
|
||||
echo -e "${RED}❌ Error: Claude Code CLI is not installed${NC}"
|
||||
echo ""
|
||||
echo "Please install Claude Code first:"
|
||||
echo " https://claude.ai/code"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓${NC} Claude Code CLI detected"
|
||||
|
||||
# Detect shell and profile
|
||||
detect_shell_profile() {
|
||||
if [ -n "$ZSH_VERSION" ] || [ "$SHELL" = "/bin/zsh" ]; then
|
||||
echo "$HOME/.zshrc"
|
||||
elif [ -n "$BASH_VERSION" ] || [ "$SHELL" = "/bin/bash" ]; then
|
||||
if [ -f "$HOME/.bash_profile" ]; then
|
||||
echo "$HOME/.bash_profile"
|
||||
else
|
||||
echo "$HOME/.bashrc"
|
||||
fi
|
||||
elif [ "$SHELL" = "/usr/bin/fish" ] || [ "$SHELL" = "/bin/fish" ]; then
|
||||
echo "$HOME/.config/fish/config.fish"
|
||||
else
|
||||
echo "$HOME/.profile"
|
||||
fi
|
||||
}
|
||||
|
||||
PROFILE=$(detect_shell_profile)
|
||||
SHELL_NAME=$(basename "$SHELL")
|
||||
|
||||
echo -e "${GREEN}✓${NC} Detected shell: $SHELL_NAME"
|
||||
echo -e "${GREEN}✓${NC} Profile file: $PROFILE"
|
||||
echo ""
|
||||
|
||||
# Prompt for API key
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${YELLOW} Step 1: Configure API Key${NC}"
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo "Get your API key from: https://console.evermind.ai/"
|
||||
echo ""
|
||||
|
||||
# Function to read user input (works when piped from curl)
|
||||
prompt_user() {
|
||||
local prompt="$1"
|
||||
local varname="$2"
|
||||
printf "%s" "$prompt"
|
||||
# Try /dev/tty first (needed when script is piped), fall back to stdin
|
||||
if [ -e /dev/tty ] && (exec </dev/tty) 2>/dev/null; then
|
||||
read "$varname" </dev/tty
|
||||
else
|
||||
read "$varname"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if API key already exists
|
||||
if [ -n "$EVERMEM_API_KEY" ]; then
|
||||
echo -e "${GREEN}✓${NC} EVERMEM_API_KEY already set in environment"
|
||||
prompt_user "Do you want to update it? (y/N): " UPDATE_KEY
|
||||
if [ "$UPDATE_KEY" != "y" ] && [ "$UPDATE_KEY" != "Y" ]; then
|
||||
echo "Keeping existing API key."
|
||||
API_KEY="$EVERMEM_API_KEY"
|
||||
else
|
||||
prompt_user "Enter your EverMem API key: " API_KEY
|
||||
fi
|
||||
else
|
||||
prompt_user "Enter your EverMem API key: " API_KEY
|
||||
fi
|
||||
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo -e "${RED}❌ API key is required${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add API key to profile
|
||||
if ! grep -q "EVERMEM_API_KEY" "$PROFILE" 2>/dev/null; then
|
||||
echo "" >> "$PROFILE"
|
||||
echo "# EverMem API Key (added by install script)" >> "$PROFILE"
|
||||
if [ "$SHELL_NAME" = "fish" ]; then
|
||||
echo "set -gx EVERMEM_API_KEY \"$API_KEY\"" >> "$PROFILE"
|
||||
else
|
||||
echo "export EVERMEM_API_KEY=\"$API_KEY\"" >> "$PROFILE"
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} API key added to $PROFILE"
|
||||
else
|
||||
# Update existing key
|
||||
if [ "$SHELL_NAME" = "fish" ]; then
|
||||
sed -i.bak "s|set -gx EVERMEM_API_KEY.*|set -gx EVERMEM_API_KEY \"$API_KEY\"|" "$PROFILE"
|
||||
else
|
||||
sed -i.bak "s|export EVERMEM_API_KEY=.*|export EVERMEM_API_KEY=\"$API_KEY\"|" "$PROFILE"
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} API key updated in $PROFILE"
|
||||
fi
|
||||
|
||||
# Export for current session
|
||||
export EVERMEM_API_KEY="$API_KEY"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${YELLOW} Step 2: Install Plugin${NC}"
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
|
||||
REPO_URL="https://github.com/EverMind-AI/evermem-claude-code"
|
||||
|
||||
# Add marketplace directly from GitHub (allows update tracking)
|
||||
echo "Adding EverMem marketplace..."
|
||||
claude plugin marketplace remove evermem 2>/dev/null || true
|
||||
if claude plugin marketplace add "$REPO_URL" 2>&1 | grep -q "Successfully"; then
|
||||
echo -e "${GREEN}✓${NC} Marketplace added"
|
||||
else
|
||||
echo -e "${RED}❌ Failed to add marketplace${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install plugin
|
||||
echo "Installing EverMem plugin..."
|
||||
claude plugin uninstall evermem@evermem 2>/dev/null || true
|
||||
if claude plugin install evermem@evermem --scope user 2>&1 | grep -q "Successfully"; then
|
||||
echo -e "${GREEN}✓${NC} Plugin installed"
|
||||
else
|
||||
echo -e "${RED}❌ Failed to install plugin${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install npm dependencies for hooks
|
||||
PLUGIN_CACHE="$HOME/.claude/plugins/cache/evermem/evermem"
|
||||
PLUGIN_DIR=$(ls -d "$PLUGIN_CACHE"/*/ 2>/dev/null | head -1)
|
||||
if [ -n "$PLUGIN_DIR" ] && [ -f "$PLUGIN_DIR/package.json" ]; then
|
||||
echo "Installing dependencies..."
|
||||
(cd "$PLUGIN_DIR" && npm install --silent 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN} 🎉 Installation Complete!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo "To activate the API key in your current terminal:"
|
||||
echo ""
|
||||
echo -e " ${BLUE}source $PROFILE${NC}"
|
||||
echo ""
|
||||
echo "Or simply restart your terminal."
|
||||
echo ""
|
||||
echo "Your conversations with Claude Code will now be remembered!"
|
||||
echo ""
|
||||
echo "Need help? Run /evermem:help in Claude Code"
|
||||
echo ""
|
||||
224
use-cases/claude-code-plugin/mcp/server.js
Executable file
224
use-cases/claude-code-plugin/mcp/server.js
Executable file
@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* EverMem MCP Server
|
||||
* Exposes memory search tool for Claude to find relevant context from past sessions
|
||||
*/
|
||||
|
||||
import { createInterface } from 'readline';
|
||||
import { searchMemories, transformSearchResults } from '../hooks/scripts/utils/evermem-api.js';
|
||||
import { getConfig } from '../hooks/scripts/utils/config.js';
|
||||
|
||||
// Tool definitions - following claude-mem's concise pattern
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'evermem_search',
|
||||
description: 'Search past conversation memories. Returns summaries with dates and relevance scores. Use when user asks about previous work, decisions, or context from past sessions. Params: query (required), limit (default: 10, max: 20)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query - use keywords, topics, or questions'
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max results to return (default: 10, max: 20)'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Format date as relative time (e.g., "2 days ago", "today")
|
||||
*/
|
||||
function formatRelativeDate(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'today';
|
||||
if (diffDays === 1) return 'yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle evermem_search tool call
|
||||
*/
|
||||
async function handleSearch(args) {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.isConfigured) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'EverMem API key not configured. Set EVERMEM_API_KEY environment variable.' }]
|
||||
};
|
||||
}
|
||||
|
||||
const query = args.query;
|
||||
if (!query) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Missing required parameter: query' }]
|
||||
};
|
||||
}
|
||||
|
||||
const limit = Math.min(args.limit || 10, 20);
|
||||
|
||||
try {
|
||||
const response = await searchMemories(query, { topK: limit });
|
||||
const memories = transformSearchResults(response);
|
||||
|
||||
if (memories.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `No memories found for: "${query}"` }]
|
||||
};
|
||||
}
|
||||
|
||||
// Format as compact table (token-efficient like claude-mem)
|
||||
const header = '| # | Score | Date | Summary |';
|
||||
const separator = '|---|-------|------|---------|';
|
||||
|
||||
const rows = memories.map((mem, i) => {
|
||||
const score = Math.round(mem.score * 100);
|
||||
const date = formatRelativeDate(mem.timestamp);
|
||||
// Use full subject field
|
||||
const summary = (mem.subject || mem.text.substring(0, 150)).replace(/\|/g, '/').replace(/\n/g, ' ');
|
||||
return `| ${i + 1} | ${score}% | ${date} | ${summary} |`;
|
||||
});
|
||||
|
||||
const table = [header, separator, ...rows].join('\n');
|
||||
|
||||
// Add context about what was found
|
||||
const resultText = `Found ${memories.length} memories for "${query}":\n\n${table}\n\nTo get full content of a specific memory, ask me to elaborate on that topic.`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: resultText }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `Search error: ${error.message}` }]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming JSON-RPC request
|
||||
*/
|
||||
async function handleRequest(request) {
|
||||
const { id, method, params } = request;
|
||||
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'evermem',
|
||||
version: '0.1.0'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'notifications/initialized':
|
||||
return null;
|
||||
|
||||
case 'tools/list':
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
tools: TOOLS
|
||||
}
|
||||
};
|
||||
|
||||
case 'tools/call':
|
||||
const { name, arguments: args } = params;
|
||||
let result;
|
||||
|
||||
switch (name) {
|
||||
case 'evermem_search':
|
||||
result = await handleSearch(args || {});
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Unknown tool: ${name}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Method not found: ${method}`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main MCP server loop
|
||||
*/
|
||||
async function main() {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
try {
|
||||
const request = JSON.parse(line);
|
||||
const response = await handleRequest(request);
|
||||
|
||||
if (response) {
|
||||
console.log(JSON.stringify(response));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: {
|
||||
code: -32700,
|
||||
message: `Parse error: ${error.message}`
|
||||
}
|
||||
};
|
||||
console.log(JSON.stringify(errorResponse));
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('MCP server error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
18
use-cases/claude-code-plugin/package.json
Normal file
18
use-cases/claude-code-plugin/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "evermem-claude-code",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "EverMem Plugin for Claude Code - automatic memory recall from past sessions",
|
||||
"scripts": {
|
||||
"test:save": "node scripts/test-save-memories.js",
|
||||
"test:retrieve": "node scripts/test-retrieve-memories.js",
|
||||
"test": "npm run test:save && npm run test:retrieve"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
||||
"@anthropic-ai/sdk": "^0.39.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
9
use-cases/claude-code-plugin/plugin.json
Normal file
9
use-cases/claude-code-plugin/plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "evermem",
|
||||
"version": "0.2.0",
|
||||
"description": "Automatically recalls relevant memories from past sessions and injects them into Claude's context",
|
||||
"author": {
|
||||
"name": "EverMem"
|
||||
},
|
||||
"keywords": ["memory", "context", "recall", "persistence"]
|
||||
}
|
||||
96
use-cases/claude-code-plugin/scripts/test-retrieve-memories.js
Executable file
96
use-cases/claude-code-plugin/scripts/test-retrieve-memories.js
Executable file
@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for retrieving memories from EverMem Cloud
|
||||
* Simulates what happens when user submits a prompt
|
||||
*
|
||||
* Usage:
|
||||
* export EVERMEM_API_KEY="your-key"
|
||||
* node scripts/test-retrieve-memories.js
|
||||
*/
|
||||
|
||||
import { getConfig, isConfigured } from '../hooks/scripts/utils/config.js';
|
||||
import { searchMemories, transformSearchResults } from '../hooks/scripts/utils/evermem-api.js';
|
||||
import { formatRelativeTime } from '../hooks/scripts/utils/mock-store.js';
|
||||
|
||||
// Test queries that should match saved memories
|
||||
const TEST_QUERIES = [
|
||||
"How do we handle authentication?",
|
||||
"What's our database setup?",
|
||||
"Tell me about rate limiting",
|
||||
"How are errors handled in the API?",
|
||||
"What was that N+1 query issue?",
|
||||
"JWT token configuration",
|
||||
"PostgreSQL connection pooling"
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 EverMem Retrieve Memory Test\n');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
// Check configuration
|
||||
if (!isConfigured()) {
|
||||
console.error('❌ EVERMEM_API_KEY not set');
|
||||
console.error(' Run: export EVERMEM_API_KEY="your-key"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
console.log(`✓ API Key: ${config.apiKey.slice(0, 10)}...`);
|
||||
console.log(`✓ User ID: ${config.userId}`);
|
||||
console.log(`✓ Group ID: ${config.groupId}`);
|
||||
console.log(`✓ API URL: ${config.apiBaseUrl}`);
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
for (const query of TEST_QUERIES) {
|
||||
console.log(`\n📝 Query: "${query}"`);
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
try {
|
||||
// Call API exactly like inject-memories.js does
|
||||
const apiResponse = await searchMemories(query, {
|
||||
topK: 5,
|
||||
retrieveMethod: 'hybrid'
|
||||
});
|
||||
|
||||
// Transform response exactly like inject-memories.js does
|
||||
const memories = transformSearchResults(apiResponse);
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(' No memories found');
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` Found ${memories.length} memories:\n`);
|
||||
|
||||
// Display like the plugin does
|
||||
for (let i = 0; i < Math.min(memories.length, 3); i++) {
|
||||
const memory = memories[i];
|
||||
const relTime = formatRelativeTime(memory.timestamp);
|
||||
const shortText = memory.text.length > 70
|
||||
? memory.text.slice(0, 70) + '...'
|
||||
: memory.text;
|
||||
|
||||
console.log(` ${i + 1}. (${relTime}) [${memory.type}]`);
|
||||
console.log(` "${shortText}"`);
|
||||
if (memory.score) {
|
||||
console.log(` Score: ${memory.score.toFixed(3)}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '=' .repeat(60));
|
||||
console.log('\n✅ Retrieval test complete!');
|
||||
console.log('\nTo test in Claude Code:');
|
||||
console.log(' claude --plugin-dir /Users/hzh/code/memory-plugin');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
110
use-cases/claude-code-plugin/scripts/test-save-memories.js
Executable file
110
use-cases/claude-code-plugin/scripts/test-save-memories.js
Executable file
@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for saving memories to EverMem Cloud
|
||||
* Simulates typical Claude Code conversations
|
||||
*
|
||||
* Usage:
|
||||
* export EVERMEM_API_KEY="your-key"
|
||||
* node scripts/test-save-memories.js
|
||||
*/
|
||||
|
||||
import { getConfig, isConfigured } from '../hooks/scripts/utils/config.js';
|
||||
import { addMemory } from '../hooks/scripts/utils/evermem-api.js';
|
||||
|
||||
// Sample conversations that might happen in Claude Code
|
||||
const TEST_CONVERSATIONS = [
|
||||
{
|
||||
user: "I need to set up authentication for our API. What approach should we use?",
|
||||
assistant: "For your API authentication, I recommend using JWT (JSON Web Tokens) with short-lived access tokens (15 minutes) and longer-lived refresh tokens stored in httpOnly cookies. This provides a good balance of security and user experience. The refresh tokens in httpOnly cookies protect against XSS attacks, while the short access token lifetime limits the damage if a token is compromised."
|
||||
},
|
||||
{
|
||||
user: "We're getting a lot of N+1 query issues in the user listing endpoint",
|
||||
assistant: "I've identified the N+1 query problem. The issue is in the UserService.getAllUsers() method where we're loading user roles in a loop. I've fixed it by adding eager loading with a JOIN query. Changed from individual role queries per user to a single query with LEFT JOIN on the roles table. This reduced the query count from 101 queries to just 2 queries for 100 users."
|
||||
},
|
||||
{
|
||||
user: "What database should we use for this project?",
|
||||
assistant: "Based on your requirements for complex queries and transactions, I recommend PostgreSQL. It offers excellent support for JSON fields if you need schema flexibility, strong ACID compliance, and great performance for read-heavy workloads with proper indexing. We should also set up connection pooling with a max of 20 connections to handle concurrent requests efficiently."
|
||||
},
|
||||
{
|
||||
user: "The API is throwing rate limit errors in production",
|
||||
assistant: "I've implemented rate limiting using Redis with a sliding window algorithm. The configuration is: 100 requests per minute for authenticated users, 20 requests per minute for anonymous users. The rate limiter returns 429 Too Many Requests with a Retry-After header. I've also added the express-rate-limit middleware with the Redis store for distributed rate limiting across multiple server instances."
|
||||
},
|
||||
{
|
||||
user: "How should we handle errors in the API?",
|
||||
assistant: "I've set up a centralized error handling middleware. All errors now follow a consistent format: { status: 'error', code: 'ERROR_CODE', message: 'Human readable message', details: {} }. We use custom error classes (ValidationError, NotFoundError, AuthenticationError) that extend a base AppError. Unhandled errors are logged to our monitoring service and return a generic 500 response to avoid leaking implementation details."
|
||||
}
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🧪 EverMem Save Memory Test\n');
|
||||
console.log('=' .repeat(50));
|
||||
|
||||
// Check configuration
|
||||
if (!isConfigured()) {
|
||||
console.error('❌ EVERMEM_API_KEY not set');
|
||||
console.error(' Run: export EVERMEM_API_KEY="your-key"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
console.log(`✓ API Key: ${config.apiKey.slice(0, 10)}...`);
|
||||
console.log(`✓ User ID: ${config.userId}`);
|
||||
console.log(`✓ Group ID: ${config.groupId}`);
|
||||
console.log(`✓ API URL: ${config.apiBaseUrl}`);
|
||||
console.log('=' .repeat(50));
|
||||
console.log('');
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (let i = 0; i < TEST_CONVERSATIONS.length; i++) {
|
||||
const conv = TEST_CONVERSATIONS[i];
|
||||
console.log(`\n📝 Conversation ${i + 1}/${TEST_CONVERSATIONS.length}`);
|
||||
console.log(` User: "${conv.user.slice(0, 50)}..."`);
|
||||
|
||||
// Save user message
|
||||
try {
|
||||
const userResult = await addMemory({
|
||||
content: conv.user,
|
||||
role: 'user',
|
||||
messageId: `test_user_${Date.now()}_${i}`
|
||||
});
|
||||
console.log(` ✓ User message saved (status: ${userResult.status || 'ok'})`);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.log(` ❌ User message failed: ${error.message}`);
|
||||
if (error.response) {
|
||||
console.log(` Response: ${JSON.stringify(error.response)}`);
|
||||
}
|
||||
failCount++;
|
||||
}
|
||||
|
||||
// Save assistant message
|
||||
try {
|
||||
const assistantResult = await addMemory({
|
||||
content: conv.assistant,
|
||||
role: 'assistant',
|
||||
messageId: `test_assistant_${Date.now()}_${i}`
|
||||
});
|
||||
console.log(` ✓ Assistant message saved (status: ${assistantResult.status || 'ok'})`);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.log(` ❌ Assistant message failed: ${error.message}`);
|
||||
if (error.response) {
|
||||
console.log(` Response: ${JSON.stringify(error.response)}`);
|
||||
}
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '=' .repeat(50));
|
||||
console.log(`\n✅ Done! ${successCount} saved, ${failCount} failed`);
|
||||
console.log('\nNow run the retrieval test:');
|
||||
console.log(' node scripts/test-retrieve-memories.js');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
195
use-cases/claude-code-plugin/server/proxy.js
Executable file
195
use-cases/claude-code-plugin/server/proxy.js
Executable file
@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* EverMem Dashboard Proxy Server
|
||||
*
|
||||
* Serves the dashboard and proxies API requests to EverMind,
|
||||
* working around the browser limitation of not supporting GET requests with body.
|
||||
*
|
||||
* Usage: node proxy.js
|
||||
* Or: EVERMEM_API_KEY=xxx node proxy.js
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PORT = process.env.EVERMEM_PROXY_PORT || 3456;
|
||||
const API_BASE = 'https://api.evermind.ai';
|
||||
const GROUPS_FILE = join(__dirname, '..', 'data', 'groups.jsonl');
|
||||
|
||||
/**
|
||||
* Compute keyId from API key (SHA-256 hash, first 12 chars)
|
||||
*/
|
||||
function computeKeyId(apiKey) {
|
||||
if (!apiKey) return null;
|
||||
const hash = createHash('sha256').update(apiKey).digest('hex');
|
||||
return hash.substring(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read groups from JSONL file and filter by keyId
|
||||
*/
|
||||
function getGroupsForKey(keyId) {
|
||||
if (!existsSync(GROUPS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(GROUPS_FILE, 'utf8');
|
||||
const lines = content.trim().split('\n').filter(Boolean);
|
||||
|
||||
// Aggregate by groupId for matching keyId
|
||||
const groupMap = new Map();
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Only include entries matching this keyId
|
||||
if (entry.keyId !== keyId) continue;
|
||||
|
||||
const existing = groupMap.get(entry.groupId);
|
||||
if (existing) {
|
||||
existing.sessionCount += 1;
|
||||
if (entry.timestamp > existing.lastSeen) {
|
||||
existing.lastSeen = entry.timestamp;
|
||||
}
|
||||
if (entry.timestamp < existing.firstSeen) {
|
||||
existing.firstSeen = entry.timestamp;
|
||||
}
|
||||
} else {
|
||||
groupMap.set(entry.groupId, {
|
||||
id: entry.groupId,
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
firstSeen: entry.timestamp,
|
||||
lastSeen: entry.timestamp,
|
||||
sessionCount: 1
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function sendCorsHeaders(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
}
|
||||
|
||||
function sendJson(res, status, data) {
|
||||
sendCorsHeaders(res);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
sendCorsHeaders(res);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward POST /api/v1/memories/{search,get} to the EverMind API
|
||||
if (req.method === 'POST' && (req.url === '/api/v1/memories/search' || req.url === '/api/v1/memories/get')) {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
|
||||
req.on('end', async () => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader) {
|
||||
sendJson(res, 401, { error: 'Missing Authorization header' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${API_BASE}${req.url}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
const text = await upstream.text();
|
||||
sendCorsHeaders(res);
|
||||
res.writeHead(upstream.status, {
|
||||
'Content-Type': upstream.headers.get('content-type') || 'application/json'
|
||||
});
|
||||
res.end(text);
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error.message);
|
||||
sendJson(res, 502, {
|
||||
error: 'Upstream request failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (req.method === 'GET' && req.url === '/health') {
|
||||
sendJson(res, 200, { status: 'ok', port: PORT });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get groups for the current API key
|
||||
if (req.method === 'GET' && req.url === '/api/groups') {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
sendJson(res, 401, { error: 'Missing or invalid Authorization header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = authHeader.replace('Bearer ', '');
|
||||
const keyId = computeKeyId(apiKey);
|
||||
const groups = getGroupsForKey(keyId);
|
||||
|
||||
sendJson(res, 200, {
|
||||
status: 'ok',
|
||||
keyId,
|
||||
groups,
|
||||
totalGroups: groups.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve dashboard HTML
|
||||
if (req.method === 'GET' && (req.url === '/' || req.url.startsWith('/?') || req.url === '/dashboard' || req.url.startsWith('/dashboard?'))) {
|
||||
try {
|
||||
const dashboardPath = join(__dirname, '..', 'assets', 'dashboard.html');
|
||||
const html = readFileSync(dashboardPath, 'utf8');
|
||||
sendCorsHeaders(res);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { error: 'Failed to load dashboard', message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 for everything else
|
||||
sendJson(res, 404, { error: 'Not found' });
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`EverMem Dashboard Proxy running on http://localhost:${PORT}`);
|
||||
console.log('');
|
||||
console.log('The dashboard can now connect to this proxy to fetch memories.');
|
||||
console.log('Press Ctrl+C to stop.');
|
||||
});
|
||||
36
use-cases/claude-code-plugin/skills/memory-tools.md
Normal file
36
use-cases/claude-code-plugin/skills/memory-tools.md
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
description: Guidance for using EverMem memory tools to recall past session context
|
||||
alwaysInclude: true
|
||||
---
|
||||
|
||||
# EverMem Memory Tools
|
||||
|
||||
You have access to memory tools that can recall context from the user's past coding sessions. Use these tools proactively when they would help provide better assistance.
|
||||
|
||||
## Available Tools
|
||||
|
||||
- **search_memories**: Search past conversations using semantic + keyword matching
|
||||
- **get_memory**: Retrieve full details of a specific memory by ID
|
||||
|
||||
## When to Use Memory Search
|
||||
|
||||
**DO search memories when:**
|
||||
- User asks about past work, decisions, or implementations ("how did we handle X?")
|
||||
- User references previous sessions ("remember when", "last time", "we discussed")
|
||||
- User is debugging something that may have been solved before
|
||||
- User asks about project patterns, conventions, or architecture decisions
|
||||
- Context from previous sessions would improve your response
|
||||
- User seems to expect you to know something from before
|
||||
|
||||
**DON'T search memories when:**
|
||||
- The question is self-contained and doesn't need historical context
|
||||
- User explicitly provides all needed context in their message
|
||||
- It's a general knowledge question unrelated to their project history
|
||||
- You've already searched for this topic in the current session
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Be selective**: Don't search for every query - only when past context adds value
|
||||
2. **Use specific queries**: Search for relevant terms, not the entire user message
|
||||
3. **Synthesize results**: When you find relevant memories, integrate them naturally into your response
|
||||
4. **Be transparent**: Mention when your response is informed by past session context
|
||||
21
use-cases/claude-code-plugin/update_local.sh
Executable file
21
use-cases/claude-code-plugin/update_local.sh
Executable file
@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update local plugin installation with current source code
|
||||
|
||||
PLUGIN_NAME="evermem"
|
||||
SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DEST_DIR="$HOME/.claude/plugins/cache/${PLUGIN_NAME}/${PLUGIN_NAME}/0.1.0"
|
||||
|
||||
if [ ! -d "$DEST_DIR" ]; then
|
||||
echo "Error: Plugin not installed at $DEST_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating: $DEST_DIR"
|
||||
|
||||
cp -r "$SOURCE_DIR/hooks" "$DEST_DIR/"
|
||||
cp -r "$SOURCE_DIR/mcp" "$DEST_DIR/" 2>/dev/null || true
|
||||
cp -r "$SOURCE_DIR/skills" "$DEST_DIR/" 2>/dev/null || true
|
||||
cp -r "$SOURCE_DIR/commands" "$DEST_DIR/" 2>/dev/null || true
|
||||
|
||||
echo "✅ Done"
|
||||
Reference in New Issue
Block a user