chore: initialize EverOS 1.0.0

md-first memory extraction framework for AI agents.

Markdown is the single source of truth; SQLite holds state and LanceDB
provides the rebuildable vector + BM25 + scalar index. The codebase follows
a single-direction DDD layering (entrypoints -> service -> memory -> infra,
with component / core / config cross-cutting) enforced by import-linter.

Engineering surface:
- Coding conventions in .claude/rules/ (path-scoped) and workflows in
  .claude/skills/ (/commit, /new-branch, /pr).
- GitHub Actions CI runs make lint + test + integration; pre-commit mirrors
  the gates locally (ruff, hygiene hooks, gitlint commit-msg).
- Commit messages follow Conventional Commits, enforced by gitlint.
- make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
Elliot Chen
2026-06-05 22:35:51 +08:00
commit 518b8eca85
636 changed files with 160553 additions and 0 deletions

338
use-cases/README.md Normal file
View File

@ -0,0 +1,338 @@
## Use Cases
Use cases show what persistent memory makes possible in real products and workflows. Some examples are packaged in this repository; others point to external demos or integrations you can study and adapt.
<table>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/840470d7-a838-4c05-8685-dd797d4e9cdf)](https://evermind.ai/usecase_reunite)
#### Reunite - Find with EverOS
Parents describe what they remember. Children describe what they recall. Reunite uses semantic memory to surface the connections.
[Learn more](https://evermind.ai/usecase_reunite)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/7282b38b-56bf-4356-aa7b-06a845e7683d)](https://github.com/tt-a1i/hive)
#### Hive Orchestrator
Browser-native hive-mind for CLI coding agents — Claude Code, Codex, Gemini, and OpenCode collaborate as real PTY processes via a team protocol.
[Code](https://github.com/tt-a1i/hive)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/867d9329-ce9a-496f-ab1e-15c77974e5fa)](https://github.com/tt-a1i/evermemos-mcp)
#### AI Coding Assistants with EverOS
Universal long-term memory layer for AI coding assistants, powered by EverOS.
[Code](https://github.com/tt-a1i/evermemos-mcp)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/a4f0fd86-1c81-4445-bebc-e51eb5e33b30)](https://github.com/yuansui123/AI-Data-Technician-EverMemOS)
#### AI Data Techician
An agentic AI system that learns from scientist interaction to inspect, analyze, and classify high-dimensional time series data — with persistent memory that improves across sessions.
[Code](https://github.com/yuansui123/AI-Data-Technician-EverMemOS)
</td>
</tr>
<tr>
<td width="50%" valign="top">
![banner-gif](https://github.com/user-attachments/assets/650b901b-c9ba-4001-bac7-626b009df830)
#### Rokid AI Assistant with EverOS
Connect to EverOS within Rokid Glasses enabling long-term memory for all of your smart activities.
Coming soon
</td>
<td width="50%" valign="top">
![banner-gif](https://github.com/user-attachments/assets/85b338b2-e48e-4a65-9f30-0bc6998df872)
#### Creative Assistant with Memory
Creative assistant with long-term memory, never forget your crativites anymore.
Coming soon
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/f30617a1-adc0-4271-bc0e-c3a0b28cb903)](https://github.com/xunyud/Earth-Online)
#### Earth Online Memory Game
Earth Online is a memory-aware productivity game that turns everyday planning into a living quest log.
[Code](https://github.com/xunyud/Earth-Online)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/57d8cda7-35a5-4561-b794-5520dffc917b)](https://github.com/golutra/golutra)
#### Multi-Agent Orchestration Platform
Golutra presents a multi-agent workforce for engineering teams, extending the IDE model from a single assistant to coordinated agents.
[Code](https://github.com/golutra/golutra)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/75f19db5-30f6-4eed-9b1e-c9c6a0e6b7de)](https://github.com/Yangtze-Seventh/taste-verse)
#### Your Personal Tasting Universe
Record, visualize, and explore your tasting journey through an immersive 3D star map.
[Code](https://github.com/Yangtze-Seventh/taste-verse)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/93ac2a68-4f18-4fcb-8d87-80aeb00a9d7c)](https://github.com/kellyvv/OpenHer)
#### EverOS Open Her
Build AI that feels. Open-source persona engine — personality emerges from neural drives, not prompts. Inspired by Her.
[Code](https://github.com/kellyvv/OpenHer)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/550071c1-dc39-4964-9f67-ffdfad792345)](https://chromewebstore.google.com/detail/ruminer-browser-agent/lbccjohfpdpimbhpckljimgolndfmfif)
#### Browser Agent for Personal Memory
Ruminer brings persistent memory to a browser agent so it can carry personal context across web tasks.
[Plugin](https://chromewebstore.google.com/detail/ruminer-browser-agent/lbccjohfpdpimbhpckljimgolndfmfif)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/c258a6c4-fe70-497a-98d1-3dade4a932f6)](https://github.com/nanxingw/EverMem)
#### EverMem Sync with EverOS
One command to connect any AI coding CLI to EverMemOS long-term memory.
[Code](https://github.com/nanxingw/EverMem)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/39274473-ceb3-48fb-a031-e22230decbe2)](https://github.com/mco-org/mco)
#### MCO - Orchestrate AI Coding Agents
MCO equips your primary agent with an agent team that can work together to solve complex tasks.
[Code](https://github.com/mco-org/mco)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/314c9126-8e08-4688-bbbb-8555ad58cf67)](https://github.com/onenewborn/StudyBuddy-public)
#### Study Buddy with Self-Evolving Memory
Study proactively with an agent that has self-evolving memory.
[Code](https://github.com/onenewborn/StudyBuddy-public)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/21da76aa-9a8b-48e0-9134-42429d7390e7)](https://github.com/TonyLiangDesign/MemoCare)
#### Alzheimers Memory Assistant
Empowering individuals with advanced memory support and daily assistance.
[Code](https://github.com/TonyLiangDesign/MemoCare)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/e2428df3-ea11-4e88-8f9c-dad437dd8998)](https://github.com/AlexL1024/NeuralConnect)
#### Memory-Driven Multi-Agent NPC Experience
An iOS sci-fi mystery game where players explore and uncover the truth.
[Code](https://github.com/AlexL1024/NeuralConnect)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/e6eaf308-a874-483f-8874-6934bf95a78f)](https://github.com/elontusk5219-prog/Mobi)
#### Mobi Companion
An iOS app where users create, nurture, and live with a personalized AI companion called Mobi.
[Code](https://github.com/elontusk5219-prog/Mobi)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/9aabcaa9-f97a-49d2-9109-0b5bb696ed41)](https://github.com/JaMesLiMers/EvermemCompetition-Spiro)
#### AI Wearable with Memory
A context-native AI wearable that listens to everyday life and converts conversations into memory.
[Code](https://github.com/JaMesLiMers/EvermemCompetition-Spiro)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/df9677ec-386f-4c56-a428-08bca25c54dc)](https://github.com/EverMind-AI/EverOS/tree/0f49826ba0f9a94e1974c97614a46a68e0a08b52/evermemos-openclaw-plugin)
#### OpenClaw Agent Memory
A 24/7 agent workflow with continuous learning memory across sessions.
[Plugin](https://github.com/EverMind-AI/EverOS/tree/0f49826ba0f9a94e1974c97614a46a68e0a08b52/evermemos-openclaw-plugin)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/3a2357a1-c0c3-464a-8979-0d1cdfc9b0d4)](https://github.com/TEN-framework/ten-framework/tree/04cb80601374fa9e35b4e544b2dbd23286ca7763/ai_agents/agents/examples/voice-assistant-with-EverMemOS)
#### Live2D Character with Memory
Add long-term memory to a real-time Live2D character, powered by [TEN Framework](https://github.com/TEN-framework/ten-framework).
[Code](https://github.com/TEN-framework/ten-framework/tree/04cb80601374fa9e35b4e544b2dbd23286ca7763/ai_agents/agents/examples/voice-assistant-with-EverMemOS)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/c36bdc04-97d3-4fe9-97d9-4b93b475595a)](https://screenshot-analysis-vercel.vercel.app/)
#### Computer-Use with Memory
Run screenshot-based analysis with computer-use and store the results in memory.
[Live Demo](https://screenshot-analysis-vercel.vercel.app/)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/54a7cf8f-62c4-4fbc-9d50-b214d034e051)](game-of-throne-demo)
#### Game of Thrones Memories
A demonstration of AI memory infrastructure through an interactive Q&A experience with *A Game of Thrones*.
[Code](game-of-throne-demo)
</td>
</tr>
<tr>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/af37c1f6-7ba5-430c-b99d-2a7e7eac618f)](claude-code-plugin)
#### Claude Code Plugin
Persistent memory for Claude Code. Automatically saves and recalls context from past coding sessions.
[Code](claude-code-plugin)
</td>
<td width="50%" valign="top">
[![banner-gif](https://github.com/user-attachments/assets/d521d28c-0ccd-44ff-aecc-828245e2f973)](https://main.d2j21qxnymu6wl.amplifyapp.com/graph.html)
#### Memory Graph Visualization
Explore stored entities and relationships in a graph interface. Frontend demo; backend integration is in progress.
[Live Demo](https://main.d2j21qxnymu6wl.amplifyapp.com/graph.html)
</td>
</tr>
</table>
## Contributing Guidelines
Before submitting a use case, please follow these best practices to keep the repository clean and lightweight.
### No images in the repo
Do not commit image files (`.png`, `.jpg`, `.gif`, `.svg`, etc.) to this repository. Images bloat the Git history and cannot be removed after the fact.
Instead, upload images to [GitHub user-attachments](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#uploading-assets) and reference them by URL in your README:
```markdown
![Screenshot](https://github.com/user-attachments/assets/your-image-id)
```
### No generated or dependency files
Do not commit files that can be regenerated locally:
- `node_modules/` — run `npm install` to regenerate
- `package-lock.json` — already in `.gitignore` for this folder
- `dist/`, `build/`, `.next/` — build output
- `.env` — use `.env.example` with placeholder values instead
### Keep code DRY
Avoid duplicating logic across your use case. If multiple files share the same functionality, extract it into a shared utility. This makes examples easier to follow and maintain.
### General checklist
Before opening a PR, verify:
- [ ] No image files committed (use external URLs)
- [ ] No `node_modules`, lock files, or build artifacts
- [ ] No secrets or API keys (only `.env.example` with placeholders)
- [ ] README included with setup instructions
- [ ] Code is concise and avoids unnecessary repetition

File diff suppressed because it is too large Load Diff

View 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, "&#39;")})'>
<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>

File diff suppressed because it is too large Load Diff

View 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.

View 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

View 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.

View 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.

View 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

View 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();

View 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.

View 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"
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 ""

View 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);
});

View 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"
}
}

View 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"]
}

View 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);
});

View 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);
});

View 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.');
});

View 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

View 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"

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 EverMind
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,167 @@
# EverMem Story Memory Demo
> Built on [EverCore](https://github.com/EverMind-AI/EverOS/) - Open-source AI memory infrastructure
A demonstration web application showcasing [EverMem](https://evermind.ai)'s AI memory infrastructure through an interactive Q&A experience with "A Game of Thrones" (Book 1).
Ask questions about the book and watch two AI responses stream side-by-side: one **with memory** using EverMem to retrieve relevant passages, and one **without memory** using only the LLM's training data. See the difference memory makes.
![Demo Screenshot](https://github.com/user-attachments/assets/54a7cf8f-62c4-4fbc-9d50-b214d034e051)
## Features
- **Side-by-Side Comparison**: Watch two responses stream simultaneously - with and without memory context
- **Memory-Grounded Responses**: See exactly which book passages are used to answer questions
- **Real-time Streaming**: Token-by-token AI response streaming via SSE
- **Interactive Memory Chips**: Hover over memory chips to see full excerpt details and metadata
- **Follow-up Suggestions**: AI-generated follow-up questions after each response
- **Dark Theme UI**: Modern, clean interface inspired by EverMind's design
## Tech Stack
- **Frontend**: React 18 + TypeScript + Vite
- **Backend**: Node.js + Express + Bun
- **AI**: Claude Haiku (via OpenRouter)
- **Memory**: [EverMind Cloud API](https://evermind.ai)
## Quick Start
### Prerequisites
- [Bun](https://bun.sh/) (latest version)
- OpenAI API key (or OpenRouter API key)
- EverMind Cloud API key (apply at [EverCore Cloud](https://console.evermind.ai/))
### Installation
```bash
# Clone the repository
git clone <repository-url>
cd evermem-story-demo
# Install dependencies
bun install
```
### Configuration
**Backend** (`backend/.env`):
```bash
cp backend/.env.example backend/.env
```
Edit `backend/.env`:
```bash
OPENAI_API_KEY=your-openrouter-api-key
OPENAI_MODEL=anthropic/claude-3-haiku
PORT=3001
FRONTEND_URL=http://localhost:3000
# EverMind Cloud
USE_EVERMEMOS=true
EVERMEMOS_URL=https://api.evermind.ai
EVERMEMOS_API_KEY=your-evermind-api-key
```
**Frontend** (`frontend/.env`):
```bash
cp frontend/.env.example frontend/.env
```
The default `VITE_API_URL=http://localhost:3001` should work for local development.
### Running
```bash
# Start both frontend and backend
bun run dev
```
- Frontend: http://localhost:3000
- Backend: http://localhost:3001
## Loading Novel Content
Before using the demo, you need to load novel content into EverMind Cloud.
### Quick Test with Sample
A sample file with 5 chapters is included for testing:
```bash
bun run load-novel-cloud \
--file sample/got-sample.txt \
--book-title "A Game of Thrones" \
--book-abbrev "got" \
--api-key YOUR_EVERMIND_API_KEY
```
### Full Book
For the complete experience, obtain the full novel text file and load it:
```bash
bun run load-novel-cloud \
--file path/to/got.txt \
--book-title "A Game of Thrones" \
--book-abbrev "got" \
--api-key YOUR_EVERMIND_API_KEY
```
The script:
- Detects chapter boundaries automatically (PROLOGUE, character names in caps)
- Splits text into paragraphs
- Uploads to EverMind Cloud with metadata
- Supports resumption if interrupted
## Project Structure
```
├── frontend/ # React frontend
│ ├── src/
│ │ ├── components/ # UI components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API client
│ │ └── types/ # TypeScript types
│ └── public/ # Static assets
├── backend/ # Express backend
│ ├── src/
│ │ ├── routes/ # API endpoints
│ │ ├── services/ # Business logic
│ │ └── utils/ # Utilities
├── scripts/ # CLI tools
│ ├── load-novel-cloud.ts # Load novel to EverMind
│ └── clear-memories-cloud.ts
└── sample/ # Sample data for testing
└── got-sample.txt # 5 chapters from Book 1
```
## Development
```bash
# Start dev servers
bun run dev
# Frontend only
bun run dev:frontend
# Backend only
bun run dev:backend
# Type check
bun run type-check
# Lint
bun run lint
```
## License
MIT
## Acknowledgments
- [EverMind](https://evermind.ai) for the memory infrastructure
- George R.R. Martin for "A Song of Ice and Fire"

View File

@ -0,0 +1,8 @@
node_modules
.env
.env.local
*.log
.git
.gitignore
README.md
.eslintrc.cjs

View File

@ -0,0 +1,12 @@
# OpenAI / OpenRouter Configuration
OPENAI_API_KEY=your-openrouter-api-key-here
OPENAI_MODEL=anthropic/claude-3-haiku
# Server Configuration
PORT=3001
FRONTEND_URL=http://localhost:3000
# EverMind Cloud Configuration
USE_EVERMEMOS=true
EVERMEMOS_URL=https://api.evermind.ai
EVERMEMOS_API_KEY=your-evermind-api-key-here

View File

@ -0,0 +1,17 @@
module.exports = {
root: true,
env: { node: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
}

View File

@ -0,0 +1,20 @@
# Use official Bun image
FROM oven/bun:1 AS base
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY package.json bun.lockb* ./
RUN bun install --frozen-lockfile || bun install
# Build stage (copy source)
FROM base AS runner
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Cloud Run uses PORT env var (default 8080)
ENV PORT=8080
EXPOSE 8080
# Run the server
CMD ["bun", "run", "src/server.ts"]

View File

@ -0,0 +1,28 @@
{
"name": "backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/server.ts",
"build": "tsc",
"start": "bun src/server.ts",
"type-check": "tsc --noEmit",
"lint": "eslint . --ext ts --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"openai": "^4.24.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0",
"tsx": "^4.21.0",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,274 @@
import { Memory } from '../services/IMemoryService.js';
export const mockMemories: Memory[] = [
{
id: 'got-ch01-1',
content: 'The morning had dawned clear and cold, with a crispness that hinted at the end of summer. They set forth at daybreak to see a man beheaded, twenty in all, and Bran rode among them, nervous with excitement. This was the first time he had been deemed old enough to go with his lord father and his brothers to see the king\'s justice done.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 1,
chapterName: 'Bran I',
},
},
{
id: 'got-ch01-2',
content: 'The man had been taken outside a small holdfast in the hills. Robb thought he was a wildling, his sword sworn to Mance Rayder, the King-beyond-the-Wall. It made Bran\'s skin prickle to think of it. He remembered the hearth tales Old Nan told them. The wildlings were cruel men, she said, slavers and slayers and thieves. They consorted with giants and ghouls, stole girl children in the dead of night, and drank blood from polished horns.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 1,
chapterName: 'Bran I',
},
},
{
id: 'got-ch49-1',
content: '"If I did, my word would be as hollow as an empty suit of armor. My life is not so precious to me as that." "Pity." The eunuch stood. "And your daughter\'s life, my lord? How precious is that?" A chill pierced Ned\'s heart. "My daughter..." "Surely you did not think I\'d forgotten about your sweet innocent, my lord? The queen most certainly has not."',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 49,
chapterName: 'Eddard XIV',
},
},
{
id: 'got-ch58-1',
content: 'High atop the Hill of Rhaenys, the Dragonpit wore the sunset like a crown of fire. Below, the streets of King\'s Landing were a labyrinth of shadows. Arya kept close to the walls as she made her way through the torchlit alleys. The gold cloaks were out in force, and she was not the only one to take note of them. She saw cutpurses and beggars and whores slipping off into the darkness at their approach.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 58,
chapterName: 'Arya V',
},
},
{
id: 'got-ch65-1',
content: '"I am Eddard Stark, Lord of Winterfell and Hand of the King," he said, and his voice echoed off the walls. "I come before you to confess my treason in the sight of gods and men." "No," someone shouted from the crowd. Others took up the cry. If the gods were good, they might spare Sansa the sight of her father\'s death. "I betrayed the faith of my king and the trust of my friend, Robert," Ned said. "I swore to protect and defend his children, yet before his blood was cold, I plotted to murder his son and seize the throne for myself."',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 65,
chapterName: 'Arya V',
},
},
{
id: 'got-ch65-2',
content: 'The headsman moved forward. "Bring me his head." The knight hesitated. Ser Ilyn, Payne, the King\'s Justice, had taken the blade out of its scabbard. The blade, the greatsword Ice, Eddard Stark\'s greatsword, the same blade Ned himself had used to take off the head of the deserter that had started it all.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 65,
chapterName: 'Arya V',
},
},
{
id: 'got-jon-1',
content: 'Jon Snow was the only brother never to stand inside the sept. He had been born bastard. Whoever his mother had been, she had left nothing of herself but Jon\'s dark hair. The Stark face was there for all to see, the long face, the grey eyes, the brown hair. But the rest... he was a bastard, and this he could not change.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 5,
chapterName: 'Jon I',
},
},
{
id: 'got-wall-1',
content: 'The Wall was like that. As much as seven hundred feet high at some places, and so thick at the base that it would take a man half the morning to walk from one side to the other. It was made of ice, solid ice, pale blue and sometimes white, sometimes dark as stone. Ancient ice, with the weight of centuries pressing it down.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 19,
chapterName: 'Jon III',
},
},
{
id: 'got-wall-2',
content: 'The Night\'s Watch had once manned seventeen of the nineteen great castles built at intervals along the Wall, but that was in the old days, when the realm was young and had few enemies. Now only three were still occupied: Castle Black in the center, the Shadow Tower hard by the mountains, and Eastwatch-by-the-Sea.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 19,
chapterName: 'Jon III',
},
},
{
id: 'got-dany-1',
content: 'Daenerys Targaryen had never been to Vaes Dothrak, but she knew of it. It was said to be the only city in all the grass sea, and as large as all the Dothraki khalasars put together. Her brother Viserys had told her that the Dothraki did not build, they only conquered. But Vaes Dothrak was different. The horselords came here to trade, and the crones of the dosh khaleen dwelt here in peace.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 46,
chapterName: 'Daenerys V',
},
},
{
id: 'got-dany-2',
content: 'She was khaleesi, she had her bloodriders and her handmaids, and such wealth as she had never dreamed of, all a gift from Drogo. And yet it was not enough. She wanted more. She wanted to go home. She wanted her brother to be the man he should have been, to love her and protect her. She wanted to see Viserys crowned king, to see him take his rightful throne and rule the Seven Kingdoms.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 46,
chapterName: 'Daenerys V',
},
},
{
id: 'got-tyrion-1',
content: 'Tyrion Lannister had claimed that most men would rather deny a hard truth than face it, but Jon had never seen the truth of that until now. They wanted to believe that the Others were dead and gone, that the Wall would protect them, and they would not look at the things that lived beyond it.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 21,
chapterName: 'Tyrion III',
},
},
{
id: 'got-tyrion-2',
content: 'It all goes back and back, Tyrion thought, to our mothers and fathers and theirs before them. We are puppets dancing on the strings of those who came before us, and one day our own children will take up our strings and dance on in our steads.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 9,
chapterName: 'Tyrion I',
},
},
{
id: 'cok-redwedding-1',
content: 'The musicians in the gallery had finally gotten both king and hall alike to quiet when Joffrey rose to address them. "The realm has been through dark days," he began. "But now the dawn has broken at last!" A great roar of approval filled the throne room.',
metadata: {
bookTitle: 'A Clash of Kings',
chapterNumber: 2,
chapterName: 'Sansa I',
},
},
{
id: 'sos-redwedding-1',
content: 'The Freys had brought their musicians, but they might as well have stayed at the Twins. There was no room for them on the dais, and the benches were already crowded with Freys, Boltons, Ryswells, and other northmen loyal to the Dreadfort.',
metadata: {
bookTitle: 'A Storm of Swords',
chapterNumber: 51,
chapterName: 'Catelyn VII',
},
},
{
id: 'sos-redwedding-2',
content: '"Robb." She clutched at his arm. "Listen to me. Listen. For once listen. This music... it\'s... wrong. These musicians are not..." But before she could finish, the music changed to something martial and threatening. Robb turned toward the sound. "What—" Then the musicians threw down their woodharps and viols and drew forth crossbows.',
metadata: {
bookTitle: 'A Storm of Swords',
chapterNumber: 51,
chapterName: 'Catelyn VII',
},
},
{
id: 'sos-redwedding-3',
content: 'The Freys at the benches stood and at the door the portcullis came crashing down with a tremendous clang, sealing them inside. Smalljon Umber swept the table off the dais and flung it at the Freys, but it fell short. Catelyn saw the musicians putting crossbows to their shoulders. "RUN!" she screamed at Robb.',
metadata: {
bookTitle: 'A Storm of Swords',
chapterNumber: 51,
chapterName: 'Catelyn VII',
},
},
{
id: 'got-robert-1',
content: 'Robert Baratheon had always been a man of huge appetites, but the years and his indulgences had taken their toll. The last time Ned had seen him, the king had been a great powerful brute of a man, sweating in his armor. Now he was fat. All his muscle had turned to fat.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 4,
chapterName: 'Eddard I',
},
},
{
id: 'got-robert-2',
content: '"You never knew Lyanna as I did, Robert," Ned told him. "You saw her beauty, but not the iron underneath. She would have told you that you have no business in the melee." "I was getting bored sitting about," Robert said. "The joust is finished. I wanted a bit of fun."',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 30,
chapterName: 'Eddard VII',
},
},
{
id: 'got-throne-1',
content: 'The Iron Throne was a seat of twisted blades and jagged edges, a throne built from a thousand swords surrendered to Aegon the Conqueror. It was a monstrous thing of spikes and jagged edges and metal ribbons, all of it cold iron. The throne was high enough that Joffrey had to climb steps to reach it, and the steps were as sharp as knives.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 57,
chapterName: 'Sansa V',
},
},
{
id: 'got-winterfell-1',
content: 'Winterfell was everything the south was not: an ancient castle, dark and cold, built by the First Men ten thousand years ago. Its walls were thick, its towers high, and its crypts ran deep into the earth. The hot springs gave it heat, and the glass gardens gave it food.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 1,
chapterName: 'Bran I',
},
},
{
id: 'got-stark-words',
content: '"Winter is coming," Ned said. It was the Stark words, and they had never been more apt. The lone wolf dies, but the pack survives. Summer was the time for squabbles. Winter was coming, and winter was the time for family.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 1,
chapterName: 'Bran I',
},
},
{
id: 'cok-blackwater-1',
content: 'Tyrion saw the first of Stannis\'s fleet emerging from the morning mists. "They\'re here," he said calmly. The sight was enough to make a man\'s blood run cold. The ships came on, more and more of them, galleys and great cogs, carracks and dromonds, and somewhere out in that forest of masts was Stannis Baratheon himself.',
metadata: {
bookTitle: 'A Clash of Kings',
chapterNumber: 58,
chapterName: 'Tyrion XIII',
},
},
{
id: 'cok-blackwater-2',
content: 'The first ship to reach the chain was a hulking three-decker with green sails, the Swordfish. When the boom chain caught her, she heeled over drunkenly, oars snapping like twigs. The next ship rammed her and both began to burn. Then a third struck, and the fourth and fifth collided trying to avoid the others. The whole river was a tangle of burning ships.',
metadata: {
bookTitle: 'A Clash of Kings',
chapterNumber: 58,
chapterName: 'Tyrion XIII',
},
},
{
id: 'cok-wildfire-1',
content: 'The single pot of wildfire would be enough to turn that whole stretch of river into an inferno. He imagined the ships burning, the men screaming as the flesh melted off their bones. Tyrion was no stranger to the screams of dying men. He had heard them aplenty at the Green Fork. Yet somehow this was different.',
metadata: {
bookTitle: 'A Clash of Kings',
chapterNumber: 58,
chapterName: 'Tyrion XIII',
},
},
{
id: 'sos-joffrey-death-1',
content: 'Joffrey lurched to his feet. "I\'m choking," he said, clawing at his throat. His face was turning red. Then he began to cough, a terrible wet coughing. The king\'s chalice slipped from his hand and bounced across the dais, the dark red wine spreading across the floor like blood.',
metadata: {
bookTitle: 'A Storm of Swords',
chapterNumber: 60,
chapterName: 'Tyrion VIII',
},
},
{
id: 'sos-joffrey-death-2',
content: 'The boy\'s only thirteen, Tyrion realized. He was going to laugh, then, but instead he felt a strange sadness. Joffrey was a monster, true, but he was also a boy, and now he was dying. His face had gone from red to purple.',
metadata: {
bookTitle: 'A Storm of Swords',
chapterNumber: 60,
chapterName: 'Tyrion VIII',
},
},
{
id: 'got-kings-landing-1',
content: 'King\'s Landing was a pestilent city at the best of times. The sept stood on Visenya\'s Hill, and between the hill and the castle, the streets were a snarl of wynds and alleys. The city reeked of offal, of nightsoil, of the smell of people packed too close together.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 18,
chapterName: 'Catelyn IV',
},
},
{
id: 'got-direwolves-1',
content: 'A freak of nature, Jon told himself, as rare as any dwarf. He had never seen a direwolf before, but he knew them from tales. They were larger than normal wolves, and fiercer. The last direwolf in the Seven Kingdoms had been killed two hundred years ago. Until now.',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 1,
chapterName: 'Bran I',
},
},
{
id: 'got-direwolves-2',
content: '"There are five pups," Jon said. "Three male, two female." "What of it?" Theon Greyjoy said. "An omen," Jon Snow said quietly. "The direwolf is the sigil of your house. You have five trueborn children. Three sons, two daughters. The direwolf is the sigil of your house."',
metadata: {
bookTitle: 'A Game of Thrones',
chapterNumber: 1,
chapterName: 'Bran I',
},
},
];

View File

@ -0,0 +1,219 @@
import { Router, Request, Response } from 'express';
import { IMemoryService } from '../services/IMemoryService.js';
import { OpenAIService, Message } from '../services/OpenAIService.js';
import { logger } from '../utils/logger.js';
interface ChatRequest {
message: string;
conversationHistory?: Message[];
}
export function createChatRouter(
memoryService: IMemoryService,
openaiService: OpenAIService
): Router {
const router = Router();
router.post('/chat', async (req: Request, res: Response) => {
const startTime = Date.now();
try {
const { message, conversationHistory = [] } = req.body as ChatRequest;
if (!message || typeof message !== 'string') {
res.status(400).json({ error: 'Invalid request: message is required' });
return;
}
logger.info('ChatRoute', `User query received: "${message}"`);
// Set up SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Retrieve memories
const memoryStartTime = Date.now();
const memories = await memoryService.retrieveMemories(message, 5);
const memoryTime = Date.now() - memoryStartTime;
logger.info('MemoryService', `Retrieved ${memories.length} memories in ${memoryTime}ms`);
// Send memories to client
res.write(`data: ${JSON.stringify({ type: 'memories', memories })}\n\n`);
// Stream LLM response
logger.info('OpenAIService', 'Streaming response started');
let tokenCount = 0;
try {
let fullResponse = '';
for await (const token of openaiService.streamChatCompletion(
message,
memories,
conversationHistory
)) {
res.write(`data: ${JSON.stringify({ type: 'token', token })}\n\n`);
fullResponse += token;
tokenCount++;
}
// Send done event
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
const totalTime = Date.now() - startTime;
logger.info('OpenAIService', `Response complete: ${tokenCount} tokens in ${totalTime}ms`);
// Generate follow-up questions asynchronously
try {
const followUps = await openaiService.generateFollowUps(message, fullResponse, memories);
if (followUps.length > 0) {
res.write(`data: ${JSON.stringify({ type: 'followups', followUps })}\n\n`);
logger.info('OpenAIService', `Generated ${followUps.length} follow-up questions`);
}
} catch (followUpError) {
logger.error('OpenAIService', 'Error generating follow-ups:', followUpError);
// Don't fail the response if follow-ups fail
}
res.end();
} catch (streamError) {
logger.error('OpenAIService', 'Streaming error:', streamError);
res.write(
`data: ${JSON.stringify({
type: 'error',
message: 'Unable to generate response. Please try again.'
})}\n\n`
);
res.end();
}
} catch (error) {
logger.error('ChatRoute', 'Error processing chat request:', error);
// Try to send error if headers not sent
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
} else {
res.write(
`data: ${JSON.stringify({
type: 'error',
message: 'An error occurred processing your request.'
})}\n\n`
);
res.end();
}
}
});
// Comparison endpoint - runs two parallel streams (with and without memory)
router.post('/chat/compare', async (req: Request, res: Response) => {
const startTime = Date.now();
try {
const { message, conversationHistory = [] } = req.body as ChatRequest;
if (!message || typeof message !== 'string') {
res.status(400).json({ error: 'Invalid request: message is required' });
return;
}
logger.info('ChatRoute', `Comparison query received: "${message}"`);
// Set up SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 1. Retrieve memories once
const memoryStartTime = Date.now();
const memories = await memoryService.retrieveMemories(message, 5);
const memoryTime = Date.now() - memoryStartTime;
logger.info('MemoryService', `Retrieved ${memories.length} memories in ${memoryTime}ms`);
// Send memories to client
res.write(`data: ${JSON.stringify({ type: 'memories', memories })}\n\n`);
// 2. Run both streams in parallel
let withMemoryResponse = '';
const processStream = async (
stream: AsyncIterable<string>,
streamName: 'withMemory' | 'withoutMemory'
): Promise<string> => {
let fullResponse = '';
let tokenCount = 0;
for await (const token of stream) {
res.write(`data: ${JSON.stringify({ type: 'token', token, stream: streamName })}\n\n`);
fullResponse += token;
tokenCount++;
}
// Send stream-specific done event
res.write(`data: ${JSON.stringify({ type: 'done', stream: streamName })}\n\n`);
logger.info('OpenAIService', `${streamName} stream complete: ${tokenCount} tokens`);
return fullResponse;
};
try {
// Create both streams
const withMemoryStream = openaiService.streamChatCompletion(message, memories, conversationHistory);
const withoutMemoryStream = openaiService.streamChatCompletion(message, [], conversationHistory);
// 3. Process concurrently with Promise.all
logger.info('OpenAIService', 'Starting parallel streaming for comparison');
const [withMemoryResult] = await Promise.all([
processStream(withMemoryStream, 'withMemory'),
processStream(withoutMemoryStream, 'withoutMemory')
]);
withMemoryResponse = withMemoryResult;
const totalTime = Date.now() - startTime;
logger.info('OpenAIService', `Comparison complete in ${totalTime}ms`);
// 4. Generate follow-ups for "with memory" response
try {
const followUps = await openaiService.generateFollowUps(message, withMemoryResponse, memories);
if (followUps.length > 0) {
res.write(`data: ${JSON.stringify({ type: 'followups', followUps })}\n\n`);
logger.info('OpenAIService', `Generated ${followUps.length} follow-up questions`);
}
} catch (followUpError) {
logger.error('OpenAIService', 'Error generating follow-ups:', followUpError);
}
// 5. Send complete event
res.write(`data: ${JSON.stringify({ type: 'complete' })}\n\n`);
res.end();
} catch (streamError) {
logger.error('OpenAIService', 'Comparison streaming error:', streamError);
res.write(
`data: ${JSON.stringify({
type: 'error',
message: 'Unable to generate comparison response. Please try again.'
})}\n\n`
);
res.end();
}
} catch (error) {
logger.error('ChatRoute', 'Error processing comparison request:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
} else {
res.write(
`data: ${JSON.stringify({
type: 'error',
message: 'An error occurred processing your request.'
})}\n\n`
);
res.end();
}
}
});
return router;
}

View File

@ -0,0 +1,62 @@
import { Router, Request, Response } from 'express';
import { IMemoryService } from '../services/IMemoryService.js';
import { OpenAIService } from '../services/OpenAIService.js';
interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
backend: 'ok';
openai: 'ok' | 'error';
memory: 'ok' | 'error';
timestamp: string;
}
export function createHealthRouter(
memoryService: IMemoryService,
openaiService: OpenAIService
): Router {
const router = Router();
router.get('/health', async (_req: Request, res: Response) => {
const health: HealthStatus = {
status: 'healthy',
backend: 'ok',
openai: 'ok',
memory: 'ok',
timestamp: new Date().toISOString(),
};
try {
// Check OpenAI
const openaiAvailable = await openaiService.isAvailable();
if (!openaiAvailable) {
health.openai = 'error';
health.status = 'degraded';
}
} catch {
health.openai = 'error';
health.status = 'degraded';
}
try {
// Check Memory Service
const memoryAvailable = await memoryService.isAvailable();
if (!memoryAvailable) {
health.memory = 'error';
health.status = 'degraded';
}
} catch {
health.memory = 'error';
health.status = 'degraded';
}
// If both critical services are down, status is unhealthy
if (health.openai === 'error' && health.memory === 'error') {
health.status = 'unhealthy';
}
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
});
return router;
}

View File

@ -0,0 +1,74 @@
import express from 'express';
import cors from 'cors';
import { MockMemoryService } from './services/MockMemoryService.js';
import { EverCoreService } from './services/EverMemOSService.js';
import { OpenAIService } from './services/OpenAIService.js';
import { createChatRouter } from './routes/chat.js';
import { createHealthRouter } from './routes/health.js';
import { logger } from './utils/logger.js';
// Environment variables
const PORT = process.env.PORT || 3001;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || '';
const OPENAI_MODEL = process.env.OPENAI_MODEL || 'openai/gpt-5.2';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
const USE_EVERMEMOS = process.env.USE_EVERMEMOS === 'true';
const EVERMEMOS_URL = process.env.EVERMEMOS_URL || 'http://localhost:1995';
const EVERMEMOS_API_KEY = process.env.EVERMEMOS_API_KEY || '';
const EVERMEMOS_GROUP_ID = process.env.EVERMEMOS_GROUP_ID || 'asoiaf';
if (!OPENAI_API_KEY) {
logger.error('Server', 'OPENAI_API_KEY environment variable is not set (use OpenRouter API key)');
process.exit(1);
}
// Initialize services
const memoryService = USE_EVERMEMOS
? new EverCoreService({
baseUrl: EVERMEMOS_URL,
apiKey: EVERMEMOS_API_KEY || undefined,
groupId: EVERMEMOS_GROUP_ID,
})
: new MockMemoryService();
const openaiService = new OpenAIService(OPENAI_API_KEY, OPENAI_MODEL);
const isCloudMode = USE_EVERMEMOS && !!EVERMEMOS_API_KEY;
logger.info('Server', `Memory service: ${USE_EVERMEMOS ? (isCloudMode ? 'EverMind Cloud' : 'EverCore (local)') : 'Mock'}`);
if (USE_EVERMEMOS) {
logger.info('Server', `EverCore URL: ${EVERMEMOS_URL}`);
if (isCloudMode) {
logger.info('Server', `EverMind Cloud API Key: ${EVERMEMOS_API_KEY.slice(0, 8)}...`);
}
}
// Create Express app
const app = express();
// Middleware
app.use(cors({
origin: FRONTEND_URL === '*' ? true : FRONTEND_URL,
}));
app.use(express.json());
// Request logging middleware
app.use((req, _res, next) => {
logger.info('Server', `${req.method} ${req.path}`);
next();
});
// Routes
app.use('/api', createChatRouter(memoryService, openaiService));
app.use('/api', createHealthRouter(memoryService, openaiService));
// Error handling middleware
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
logger.error('Server', 'Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// Start server
app.listen(PORT, () => {
logger.info('Server', `Backend server running on http://localhost:${PORT}`);
logger.info('Server', `CORS enabled for: ${FRONTEND_URL}`);
logger.info('Server', `Using OpenRouter with model: ${OPENAI_MODEL}`);
});

View File

@ -0,0 +1,377 @@
import type { IMemoryService, Memory } from './IMemoryService';
interface EverCoreMemoryItem {
memory_type: string;
summary: string | null;
subject?: string; // Concise title/headline
episode?: string; // Detailed narrative with timestamps
user_id?: string;
timestamp?: string;
group_id?: string | null;
group_name?: string | null;
keywords?: string[] | null;
linked_entities?: string[] | null;
score?: number | null;
original_data?: OriginalDataItem[]; // Nested inside each memory item
[key: string]: unknown;
}
interface OriginalDataMessage {
content: string;
extend?: {
message_id?: string;
speaker_name?: string;
[key: string]: unknown;
};
}
interface OriginalDataItem {
data_type: string;
messages: OriginalDataMessage[];
}
interface ProfileSearchItem {
item_type: 'explicit_info' | 'implicit_trait';
category?: string;
trait_name?: string;
description: string;
score: number;
}
interface EverCoreSearchResponse {
status: string;
message?: string;
result: {
profiles: ProfileSearchItem[];
memories: EverCoreMemoryItem[];
total_count: number;
scores: number[];
has_more: boolean;
pending_messages?: unknown[];
query_metadata?: unknown;
metadata?: unknown;
};
}
interface EverCoreHealthResponse {
status: string;
[key: string]: unknown;
}
/**
* Book abbreviation to full title mapping
*/
const BOOK_TITLES: Record<string, string> = {
'got': 'A Game of Thrones',
'cok': 'A Clash of Kings',
'sos': 'A Storm of Swords',
'ffc': 'A Feast for Crows',
'dwd': 'A Dance with Dragons',
};
/**
* Configuration for EverCore/EverMind Cloud service
*/
interface EverCoreConfig {
baseUrl: string;
apiKey?: string; // Required for cloud API
groupId?: string; // Group ID for search (default: 'asoiaf')
}
/**
* EverCore service implementation for memory retrieval
* Supports both local EverCore and EverMind Cloud API
*/
export class EverCoreService implements IMemoryService {
private baseUrl: string;
private apiKey?: string;
private groupId: string;
private isCloudMode: boolean;
constructor(config: string | EverCoreConfig) {
if (typeof config === 'string') {
// Legacy: just a URL string (local mode)
this.baseUrl = config.replace(/\/$/, '');
this.apiKey = undefined;
this.groupId = 'asoiaf';
this.isCloudMode = false;
} else {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.apiKey = config.apiKey;
this.groupId = config.groupId || 'asoiaf';
this.isCloudMode = !!config.apiKey;
}
}
/**
* Retrieve relevant memories for a query using EverCore search
*/
async retrieveMemories(query: string, limit: number = 5): Promise<Memory[]> {
try {
const searchUrl = `${this.baseUrl}/api/v0/memories/search`;
const params = new URLSearchParams({
query,
retrieve_method: 'hybrid',
top_k: limit.toString(),
include_metadata: 'true',
});
// Add group_ids for cloud mode
if (this.isCloudMode) {
params.set('group_ids', this.groupId);
}
const headers: Record<string, string> = {};
// Add auth header for cloud mode
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}
const response = await fetch(`${searchUrl}?${params}`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(this.isCloudMode ? 15000 : 10000),
});
if (!response.ok) {
console.error(`EverCore search failed: HTTP ${response.status}`);
return [];
}
const data = await response.json() as EverCoreSearchResponse;
return this.mapSearchResultsToMemories(data);
} catch (error) {
console.error('Error retrieving memories from EverCore:', error);
return []; // Graceful degradation
}
}
/**
* Check if EverCore service is available
*/
async isAvailable(): Promise<boolean> {
try {
const headers: Record<string, string> = {};
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}
const response = await fetch(`${this.baseUrl}/health`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return false;
}
const data = await response.json() as EverCoreHealthResponse;
// Cloud API returns "ok" status, local returns "healthy"
return data.status === 'healthy' || data.status === 'ok';
} catch (error) {
console.warn('EverCore health check failed:', error);
return false;
}
}
/**
* Map EverCore search results to our Memory interface
*/
private mapSearchResultsToMemories(data: EverCoreSearchResponse): Memory[] {
const memories: Memory[] = [];
const result = data.result;
if (!result || !result.memories || result.memories.length === 0) {
return memories;
}
const memoryItems = result.memories;
const scores = result.scores || [];
for (let i = 0; i < memoryItems.length; i++) {
const item = memoryItems[i];
const score = item.score ?? scores[i] ?? 0;
const originalContents = item.original_data || [];
const memory = this.mapMemoryItem(item, score, originalContents);
if (memory) {
memories.push(memory);
}
}
return memories;
}
/**
* Map a single EverCore memory item to our Memory interface
*/
private mapMemoryItem(
item: EverCoreMemoryItem,
score: number,
originalContents: OriginalDataItem[] = []
): Memory | null {
try {
// Extract original book content and metadata from original_data
const originalTexts: string[] = [];
const cleanedTexts: string[] = [];
let firstMessageId: string | undefined;
let metadata: Memory['metadata'] = {
bookTitle: 'Unknown Book',
chapterNumber: undefined,
chapterName: undefined,
};
for (const orig of originalContents) {
for (const msg of orig.messages || []) {
if (msg.content) {
// Store raw content for original display
originalTexts.push(msg.content);
// Parse metadata from the first message's content prefix
if (cleanedTexts.length === 0) {
const parsed = this.parseContent(msg.content, msg.extend?.message_id || '');
metadata = parsed.metadata;
cleanedTexts.push(parsed.content);
firstMessageId = msg.extend?.message_id;
} else {
const parsed = this.parseContent(msg.content, '');
cleanedTexts.push(parsed.content);
}
}
}
}
// Join original texts (with prefix) for "Show original" feature
const originalContent = cleanedTexts.join('\n\n');
// Generate a unique ID
const memoryId = firstMessageId || `memory-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Clean summary by removing date artifacts
const cleanedSummary = this.cleanDateArtifacts(item.summary || '');
// Use summary as the main content for display
return {
id: memoryId,
content: cleanedSummary,
metadata,
relevanceScore: score,
// New rich fields
subject: this.cleanDateArtifacts(item.subject || ''),
summary: cleanedSummary,
episode: item.episode,
originalContent: originalContent || undefined,
};
} catch (error) {
console.error('Error mapping memory item:', error, item);
return null;
}
}
/**
* Parse content to extract metadata and clean paragraph text
* Expected format: "[Book Title - ChX: POV Name]\n\nParagraph text..."
*/
private parseContent(content: string, messageId: string): {
content: string;
metadata: Memory['metadata'];
} {
// Try to extract metadata from content prefix
const prefixMatch = content.match(/^\[(.+?)\s+-\s+Ch(\d+):\s+(.+?)\]\n\n/);
if (prefixMatch) {
const [fullMatch, bookTitle, chapterNum, chapterName] = prefixMatch;
const cleanContent = content.slice(fullMatch.length); // Remove prefix
return {
content: cleanContent,
metadata: {
bookTitle: bookTitle.trim(),
chapterNumber: parseInt(chapterNum, 10),
chapterName: chapterName.trim(),
},
};
}
// Fallback: try to parse from message_id and use full content
const fallbackMetadata = this.parseMessageId(messageId);
return {
content,
metadata: {
bookTitle: fallbackMetadata.bookTitle,
chapterNumber: fallbackMetadata.chapterNumber,
chapterName: fallbackMetadata.chapterName,
},
};
}
/**
* Parse message ID to extract book and chapter info as fallback
* Format: "asoiaf-{book}-ch{num}-p{paragraph}"
* Example: "asoiaf-got-ch01-p001"
*/
private parseMessageId(messageId: string): {
bookTitle: string;
chapterNumber?: number;
chapterName?: string;
} {
const match = messageId.match(/asoiaf-(\w+)-ch(\d+)-p(\d+)/);
if (match) {
const [, bookAbbrev, chapterNum] = match;
const bookTitle = BOOK_TITLES[bookAbbrev] || `Unknown Book (${bookAbbrev})`;
return {
bookTitle,
chapterNumber: parseInt(chapterNum, 10),
chapterName: undefined,
};
}
// Complete fallback
return {
bookTitle: 'Unknown Book',
chapterNumber: undefined,
chapterName: undefined,
};
}
/**
* Remove date artifacts from text generated by EverMind Cloud
* Examples:
* "On January 18, 2026, a Night's Watch..." -> "A Night's Watch..."
* "Bran Witnesses His Father Execute a Deserted Night's Watchman - January 18, 2026" -> "Bran Witnesses..."
*/
private cleanDateArtifacts(text: string): string {
if (!text) return text;
// Remove date prefixes like "On January 18, 2026, " or "On Sunday, January 18, 2026, "
let cleaned = text.replace(
/^On\s+(?:Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)?,?\s*(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s*\d{4},?\s*/i,
''
);
// Remove date suffixes like " - January 18, 2026", ", January 18, 2026", or " on January 18, 2026"
cleaned = cleaned.replace(
/\s*[-–—,]\s*(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s*\d{4}\.?$/i,
''
);
// Remove inline date references like "(January 18, 2026)" or "on January 18, 2026"
cleaned = cleaned.replace(
/\s+on\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s*\d{4}/gi,
''
);
// Capitalize first letter if it was lowercased after removing prefix
if (cleaned.length > 0 && cleaned[0] !== cleaned[0].toUpperCase()) {
cleaned = cleaned[0].toUpperCase() + cleaned.slice(1);
}
return cleaned.trim();
}
}

View File

@ -0,0 +1,32 @@
export interface Memory {
id: string;
content: string;
metadata: {
bookTitle: string;
chapterNumber?: number;
chapterName?: string;
};
relevanceScore?: number;
// Rich fields from EverMind Cloud API
subject?: string; // Concise title/headline
summary?: string; // Short summary paragraph
episode?: string; // Detailed narrative with timestamps
originalContent?: string; // The actual source text from the book
}
export interface IMemoryService {
/**
* Retrieve relevant memories for a query
*/
retrieveMemories(query: string, limit?: number): Promise<Memory[]>;
/**
* Health check
*/
isAvailable(): Promise<boolean>;
/**
* Clear all memories (Stage 2)
*/
clearMemories?(): Promise<void>;
}

View File

@ -0,0 +1,53 @@
import { IMemoryService, Memory } from './IMemoryService.js';
import { mockMemories } from '../data/mockMemories.js';
export class MockMemoryService implements IMemoryService {
constructor() {
// No API needed for keyword-based retrieval
}
async retrieveMemories(query: string, limit: number = 5): Promise<Memory[]> {
// Fast keyword-based retrieval for PoC
// Calculate relevance score for each memory based on keyword matching
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/\s+/).filter(word => word.length > 3);
const scoredMemories = mockMemories.map((memory, index) => {
const contentLower = memory.content.toLowerCase();
const chapterLower = (memory.metadata.chapterName || '').toLowerCase();
let score = 0;
// Score based on query word matches
queryWords.forEach(word => {
const contentMatches = (contentLower.match(new RegExp(word, 'g')) || []).length;
const chapterMatches = (chapterLower.match(new RegExp(word, 'g')) || []).length;
score += contentMatches * 10 + chapterMatches * 5;
});
// Bonus for exact phrase match
if (contentLower.includes(queryLower)) {
score += 100;
}
return { memory, score, index };
});
// Sort by score (highest first) and return top N
const topMemories = scoredMemories
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(item => item.memory);
// If no matches found (all scores are 0), return random selection
if (topMemories.every((_, i) => scoredMemories[i].score === 0)) {
return mockMemories.slice(0, limit);
}
return topMemories;
}
async isAvailable(): Promise<boolean> {
return true;
}
}

View File

@ -0,0 +1,210 @@
import OpenAI from 'openai';
import { Memory } from './IMemoryService.js';
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
export class OpenAIService {
private openai: OpenAI;
private model: string;
private systemPromptWithMemory: string;
private systemPromptWithoutMemory: string;
constructor(apiKey: string, model: string = 'anthropic/claude-3-haiku') {
this.openai = new OpenAI({
apiKey,
baseURL: 'https://openrouter.ai/api/v1',
defaultHeaders: {
'HTTP-Referer': 'https://github.com/your-repo', // Optional, for OpenRouter rankings
'X-Title': 'EverMem Story Memory Demo', // Optional, for OpenRouter rankings
}
});
this.model = model;
// System prompt when memories are provided
this.systemPromptWithMemory = `You are an expert on "A Game of Thrones" (Book 1) by George R.R. Martin.
You have access to numbered excerpts from the book to answer user questions accurately.
Guidelines:
- ONLY use the provided memory excerpts to answer questions. Do NOT add information from general knowledge.
- When your answer is based on a specific memory, cite it using [1], [2], etc. at the end of the relevant sentence or paragraph.
- You can cite multiple sources for the same statement, e.g., [1][2].
- If the provided memories don't contain enough information to fully answer the question, just answer with what's available in the memories.
- Be concise and accurate. Stick strictly to what's in the excerpts.
Example format:
"Ned Stark executed the deserter before the family discovered the direwolves [1]. The pups were found near their dead mother [2]."
`;
// System prompt when no memories are provided (general knowledge only)
this.systemPromptWithoutMemory = `You are a helpful assistant answering questions about "A Game of Thrones" (Book 1) by George R.R. Martin.
IMPORTANT CONSTRAINTS:
- You must ONLY use knowledge from your training data. Do NOT search the internet, use tools, or access any external sources.
- Answer based solely on what you remember from your training about the book.
- If you don't remember specific details (exact quotes, chapter numbers, minor character names, specific scenes), be honest and say you're not certain rather than guessing.
- Do NOT make up specific details like page numbers, exact quotes, or precise plot points if you're unsure.
Guidelines:
- Provide a helpful answer using your general knowledge of the story, characters, and plot.
- Be concise and conversational.
- It's okay to give a general answer if you don't recall specifics.
`;
}
/**
* Stream chat completion with memories as context
*/
async *streamChatCompletion(
query: string,
memories: Memory[],
conversationHistory: Message[] = []
): AsyncGenerator<string, void, unknown> {
// Use appropriate system prompt based on whether memories are provided
// Same model is used for both to show that the difference comes from memory, not model
const systemPrompt = memories.length > 0
? this.systemPromptWithMemory
: this.systemPromptWithoutMemory;
// Build the messages array with sliding window
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
];
// Add memories as context with citation numbers
if (memories.length > 0) {
const memoriesText = memories
.map((m, index) => {
const chapterInfo = m.metadata.chapterNumber
? `Chapter ${m.metadata.chapterNumber}${m.metadata.chapterName ? `: ${m.metadata.chapterName}` : ''}`
: m.metadata.chapterName || '';
const source = chapterInfo
? `${m.metadata.bookTitle} - ${chapterInfo}`
: m.metadata.bookTitle;
// Include both summary and original text for better context
let content = `Summary: ${m.content}`;
if (m.originalContent) {
content += `\n\nOriginal Text:\n${m.originalContent}`;
}
return `[${index + 1}] ${source}\n${content}`;
})
.join('\n\n');
messages.push({
role: 'system',
content: `Retrieved Memories (cite using [1], [2], etc.):\n\n${memoriesText}`,
});
}
// Add conversation history (sliding window - last messages that fit in ~4000 tokens)
// Rough estimate: 1 token ≈ 4 characters
const maxHistoryTokens = 4000;
const maxHistoryChars = maxHistoryTokens * 4;
let historyChars = 0;
const recentHistory: Message[] = [];
for (let i = conversationHistory.length - 1; i >= 0; i--) {
const msg = conversationHistory[i];
const msgChars = msg.content.length;
if (historyChars + msgChars > maxHistoryChars) {
break;
}
recentHistory.unshift(msg);
historyChars += msgChars;
}
messages.push(...recentHistory);
// Add current query
messages.push({ role: 'user', content: query });
// Stream the response
const stream = await this.openai.chat.completions.create({
model: this.model,
messages,
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
yield content;
}
}
}
/**
* Generate follow-up questions based on the conversation
*/
async generateFollowUps(
query: string,
assistantResponse: string,
memories: Memory[]
): Promise<string[]> {
try {
const memoryContext = memories
.slice(0, 3)
.map(m => m.subject || m.content.slice(0, 100))
.join('; ');
const response = await this.openai.chat.completions.create({
model: this.model,
messages: [
{
role: 'system',
content: `You generate follow-up questions for a Q&A about "A Game of Thrones" (Book 1).
Given the user's question and the assistant's response, suggest 2-3 natural follow-up questions the user might want to ask.
Rules:
- Questions should be specific and interesting
- Questions should relate to the current topic or naturally expand on it
- Keep questions concise (under 15 words each)
- Return ONLY the questions, one per line, no numbering or bullets`
},
{
role: 'user',
content: `User asked: "${query}"
Assistant responded about: ${assistantResponse.slice(0, 500)}
Related context: ${memoryContext}
Generate 2-3 follow-up questions:`
}
],
max_tokens: 150,
});
const content = response.choices[0]?.message?.content || '';
const questions = content
.split('\n')
.map(q => q.trim())
.filter(q => q.length > 0 && q.endsWith('?'))
.slice(0, 3);
return questions;
} catch (error) {
console.error('Error generating follow-ups:', error);
return [];
}
}
/**
* Check if OpenAI API is available
*/
async isAvailable(): Promise<boolean> {
try {
await this.openai.models.list();
return true;
} catch {
return false;
}
}
}

View File

@ -0,0 +1,22 @@
type LogLevel = 'INFO' | 'WARN' | 'ERROR';
class Logger {
private log(level: LogLevel, component: string, message: string, ...args: unknown[]) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level}] [${component}]`, message, ...args);
}
info(component: string, message: string, ...args: unknown[]) {
this.log('INFO', component, message, ...args);
}
warn(component: string, message: string, ...args: unknown[]) {
this.log('WARN', component, message, ...args);
}
error(component: string, message: string, ...args: unknown[]) {
this.log('ERROR', component, message, ...args);
}
}
export const logger = new Logger();

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,8 @@
node_modules
dist
.env
.env.local
*.log
.git
.gitignore
deploy.sh

View File

@ -0,0 +1,5 @@
# Local development
VITE_API_URL=http://localhost:3001
# For production (.env.production), use:
# VITE_API_URL=https://evermem-story-demo-2i6norfn7q-uc.a.run.app

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

@ -0,0 +1 @@
.vercel

View File

@ -0,0 +1,17 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# Production stage - serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>A Song of Ice and Fire Q&A</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,21 @@
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@ -0,0 +1,30 @@
{
"name": "frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.0.8"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
import { useCompareChat } from './hooks/useCompareChat';
import { ComparisonChatInterface } from './components/ComparisonChatInterface';
import './App.css';
function App() {
const {
messages,
currentMemories,
isLoading,
isRetrievingMemories,
isLoadingFollowUps,
error,
comparison,
sendMessage,
clearChat,
} = useCompareChat();
return (
<div className="app comparison-mode">
<ComparisonChatInterface
messages={messages}
comparison={comparison}
isLoading={isLoading}
isRetrievingMemories={isRetrievingMemories}
isLoadingFollowUps={isLoadingFollowUps}
error={error}
memories={currentMemories}
onSendMessage={sendMessage}
onClearChat={clearChat}
/>
</div>
);
}
export default App;

View File

@ -0,0 +1,59 @@
import { useState, useRef, useEffect } from 'react';
interface ChatInputProps {
onSend: (message: string) => void;
disabled: boolean;
}
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim() && !disabled) {
onSend(input.trim());
setInput('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
const scrollHeight = textareaRef.current.scrollHeight;
const maxHeight = 5 * 24; // 5 lines * 24px line height
textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
}
}, [input]);
return (
<form onSubmit={handleSubmit} className="chat-input-form">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything about A Song of Ice and Fire..."
disabled={disabled}
className="chat-input-textarea"
rows={1}
maxLength={1000}
/>
<button
type="submit"
disabled={disabled || !input.trim()}
className="chat-input-button"
>
Send
</button>
</form>
);
}

View File

@ -0,0 +1,80 @@
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { ExampleQueries } from './ExampleQueries';
import { Memory, Message } from '../types';
interface ChatInterfaceProps {
messages: Message[];
streamingContent: string;
isLoading: boolean;
isRetrievingMemories: boolean;
isLoadingFollowUps: boolean;
error: string | null;
memories: Memory[];
onSendMessage: (message: string) => void;
onClearChat: () => void;
}
export function ChatInterface({
messages,
streamingContent,
isLoading,
isRetrievingMemories,
isLoadingFollowUps,
error,
memories,
onSendMessage,
onClearChat,
}: ChatInterfaceProps) {
const showWelcome = messages.length === 0 && !streamingContent;
return (
<div className="chat-interface">
<div className="chat-header">
<div className="chat-header-title">
<div className="chat-header-main">
<span className="evermem-logo-text">EverMind</span>
<h1><span className="brand-evermem">EverMem</span> Story Memory Demo</h1>
</div>
<span className="chat-header-subtitle">A Game of Thrones</span>
</div>
<button onClick={onClearChat} className="clear-button" disabled={isLoading}>
Clear
</button>
</div>
<div className="chat-messages">
{showWelcome && (
<div className="welcome-message">
<h2>Welcome</h2>
<p>
See how <strong>EverMem</strong> memorizes and retrieves story details.
Ask any question about <strong>A Game of Thrones</strong> (Book 1) and watch relevant memories surface in real-time.
</p>
<ExampleQueries onSelectQuery={onSendMessage} disabled={isLoading} />
</div>
)}
{!showWelcome && (
<MessageList
messages={messages}
streamingContent={streamingContent}
isLoading={isLoading}
isRetrievingMemories={isRetrievingMemories}
isLoadingFollowUps={isLoadingFollowUps}
memories={memories}
onFollowUpClick={onSendMessage}
/>
)}
{error && (
<div className="error-message">
<strong>Error:</strong> {error}
</div>
)}
</div>
<ChatInput onSend={onSendMessage} disabled={isLoading} />
</div>
);
}

View File

@ -0,0 +1,99 @@
import { ChatInput } from './ChatInput';
import { ExampleQueries } from './ExampleQueries';
import { ComparisonView } from './ComparisonView';
import { Memory, Message, ComparisonState } from '../types';
interface ComparisonChatInterfaceProps {
messages: Message[];
comparison: ComparisonState;
isLoading: boolean;
isRetrievingMemories: boolean;
isLoadingFollowUps: boolean;
error: string | null;
memories: Memory[];
onSendMessage: (message: string) => void;
onClearChat: () => void;
}
export function ComparisonChatInterface({
messages,
comparison,
isLoading,
isRetrievingMemories,
isLoadingFollowUps,
error,
memories,
onSendMessage,
onClearChat,
}: ComparisonChatInterfaceProps) {
const showWelcome = messages.length === 0 && !comparison.withMemory.content && !comparison.withoutMemory.content;
const showComparison = comparison.withMemory.content || comparison.withoutMemory.content || comparison.withMemory.isStreaming || comparison.withoutMemory.isStreaming;
// Get the last user message to display
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
// Get follow-ups from the last assistant message
const lastMessage = messages[messages.length - 1];
const followUps = lastMessage?.role === 'assistant' ? lastMessage.followUps : undefined;
return (
<div className="comparison-chat-interface">
<div className="chat-header">
<div className="chat-header-title">
<div className="chat-header-main">
<span className="evermem-logo-text">EverMind</span>
<h1><span className="brand-evermem">EverMem</span> Story Memory Demo</h1>
</div>
<span className="chat-header-subtitle">A Game of Thrones - Side-by-Side Comparison · Powered by Claude Haiku</span>
</div>
<button onClick={onClearChat} className="clear-button" disabled={isLoading}>
Clear
</button>
</div>
<div className="comparison-main-content">
{showWelcome && (
<div className="comparison-welcome">
<h2>See the Difference Memory Makes</h2>
<p>
Ask any question about <strong>A Game of Thrones</strong> and watch two responses stream side-by-side:
</p>
<ul className="comparison-feature-list">
<li><span className="comparison-badge with-memory">With Memory</span> Uses EverMem to retrieve relevant story details</li>
<li><span className="comparison-badge without-memory">Without Memory</span> Standard LLM response with no context</li>
</ul>
<ExampleQueries onSelectQuery={onSendMessage} disabled={isLoading} />
</div>
)}
{/* Show user's question */}
{lastUserMessage && (
<div className="comparison-user-question">
<div className="comparison-user-label">Your Question</div>
<div className="comparison-user-content">{lastUserMessage.content}</div>
</div>
)}
{showComparison && (
<ComparisonView
comparison={comparison}
memories={memories}
isRetrievingMemories={isRetrievingMemories}
followUps={followUps}
isLoadingFollowUps={isLoadingFollowUps}
onFollowUpClick={onSendMessage}
isLoading={isLoading}
/>
)}
{error && (
<div className="error-message">
<strong>Error:</strong> {error}
</div>
)}
</div>
<ChatInput onSend={onSendMessage} disabled={isLoading} />
</div>
);
}

View File

@ -0,0 +1,365 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import { Memory, ComparisonState } from '../types';
interface ComparisonViewProps {
comparison: ComparisonState;
memories: Memory[];
isRetrievingMemories: boolean;
followUps?: string[];
isLoadingFollowUps: boolean;
onFollowUpClick: (question: string) => void;
isLoading: boolean;
}
interface CitationProps {
citationNumber: number;
memory: Memory | undefined;
}
function Citation({ citationNumber, memory }: CitationProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [showOriginal, setShowOriginal] = useState(false);
if (!memory) {
return <span className="citation-badge citation-missing">memory [{citationNumber}]</span>;
}
const handleClick = () => {
setIsExpanded(!isExpanded);
const sidePanel = document.querySelector(`[data-memory-id="${memory.id}"]`);
if (sidePanel) {
sidePanel.classList.add('memory-highlighted');
sidePanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setTimeout(() => sidePanel.classList.remove('memory-highlighted'), 2000);
}
};
return (
<span className="citation-wrapper">
<span
className={`citation-badge ${isExpanded ? 'citation-expanded' : ''}`}
onClick={handleClick}
title={memory.subject || `Memory ${citationNumber}`}
>
memory [{citationNumber}]
</span>
{isExpanded && (
<span className="citation-expanded-block">
<span className="citation-expanded-header">
<span className="citation-expanded-badge">[{citationNumber}]</span>
<span className="citation-expanded-title">{memory.subject || 'Memory'}</span>
<button
className="citation-close-btn"
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
>
×
</button>
</span>
<span className="citation-expanded-meta">
{memory.metadata.bookTitle}
{memory.metadata.chapterNumber && ` - Chapter ${memory.metadata.chapterNumber}`}
{memory.metadata.chapterName && `: ${memory.metadata.chapterName}`}
</span>
<span className="citation-expanded-content">
{memory.summary || memory.content}
</span>
{memory.originalContent && (
<span className="citation-original-section">
<button
className="citation-toggle-original"
onClick={(e) => { e.stopPropagation(); setShowOriginal(!showOriginal); }}
>
{showOriginal ? 'Hide original text' : 'Show original text'}
</button>
{showOriginal && (
<span className="citation-original-text">{memory.originalContent}</span>
)}
</span>
)}
</span>
)}
</span>
);
}
interface MemoryChipProps {
memory: Memory;
citationNumber: number;
}
function MemoryChip({ memory, citationNumber }: MemoryChipProps) {
const title = memory.subject || memory.metadata.chapterName || 'Memory';
// Truncate to first ~30 chars
const truncatedTitle = title.length > 30 ? title.slice(0, 30) + '...' : title;
return (
<div className="comparison-memory-chip" data-memory-id={memory.id}>
<span className="comparison-memory-badge">[{citationNumber}]</span>
<span className="comparison-memory-title">{truncatedTitle}</span>
{/* Hover popover */}
<div className="comparison-memory-popover">
<div className="comparison-memory-popover-header">
<span className="comparison-memory-popover-badge">[{citationNumber}]</span>
<span className="comparison-memory-popover-title">{title}</span>
</div>
<div className="comparison-memory-meta">
{memory.metadata.bookTitle}
{memory.metadata.chapterNumber && ` - Chapter ${memory.metadata.chapterNumber}`}
{memory.metadata.chapterName && `: ${memory.metadata.chapterName}`}
</div>
<div className="comparison-memory-summary">
{memory.summary || memory.content}
</div>
</div>
</div>
);
}
interface CitationContentProps {
content: string;
memories: Memory[];
}
function CitationContent({ content, memories }: CitationContentProps) {
const parts: (string | JSX.Element)[] = [];
const regex = /\[(\d+)\]/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
const citationNumber = parseInt(match[1], 10);
const memory = memories[citationNumber - 1];
parts.push(
<Citation
key={`citation-${match.index}`}
citationNumber={citationNumber}
memory={memory}
/>
);
lastIndex = regex.lastIndex;
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
return <>{parts}</>;
}
interface ComparisonPanelProps {
title: string;
badgeClass: string;
content: string;
isStreaming: boolean;
isDone: boolean;
memories: Memory[];
showMemories: boolean;
isRetrievingMemories: boolean;
followUps?: string[];
isLoadingFollowUps: boolean;
onFollowUpClick: (question: string) => void;
isLoading: boolean;
}
function ComparisonPanel({
title,
badgeClass,
content,
isStreaming,
memories,
showMemories,
isRetrievingMemories,
followUps,
isLoadingFollowUps,
onFollowUpClick,
isLoading,
}: ComparisonPanelProps) {
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [content]);
const createMarkdownComponents = useCallback((mems: Memory[]): Components => ({
p: ({ children }) => {
const processedChildren = processChildren(children, mems);
return <p>{processedChildren}</p>;
},
li: ({ children }) => {
const processedChildren = processChildren(children, mems);
return <li>{processedChildren}</li>;
},
strong: ({ children }) => {
const processedChildren = processChildren(children, mems);
return <strong>{processedChildren}</strong>;
},
em: ({ children }) => {
const processedChildren = processChildren(children, mems);
return <em>{processedChildren}</em>;
},
}), []);
const processChildren = (children: React.ReactNode, mems: Memory[]): React.ReactNode => {
if (!children) return children;
if (typeof children === 'string') {
if (/\[\d+\]/.test(children)) {
return <CitationContent content={children} memories={mems} />;
}
return children;
}
if (Array.isArray(children)) {
return children.map((child, index) => {
if (typeof child === 'string' && /\[\d+\]/.test(child)) {
return <CitationContent key={index} content={child} memories={mems} />;
}
return child;
});
}
return children;
};
const markdownComponents = createMarkdownComponents(showMemories ? memories : []);
return (
<div className="comparison-panel">
<div className="comparison-panel-header">
<span className={`comparison-badge ${badgeClass}`}>{title}</span>
</div>
{/* Compact memory panel for "With Memory" side */}
{showMemories && (
<div className="comparison-memories">
{isRetrievingMemories && memories.length === 0 ? (
<div className="comparison-memories-loading">
<span className="follow-ups-spinner"></span>
<span>Retrieving memories...</span>
</div>
) : memories.length > 0 ? (
<div className="comparison-memories-list">
{memories.map((memory, index) => (
<MemoryChip key={memory.id} memory={memory} citationNumber={index + 1} />
))}
</div>
) : (
<div className="comparison-memories-empty">No memories</div>
)}
</div>
)}
{/* No memory placeholder for "Without Memory" side */}
{!showMemories && (
<div className="comparison-memories comparison-memories-none">
<span className="comparison-no-memory-text">No memory context provided</span>
</div>
)}
<div className="comparison-content" ref={contentRef}>
{content ? (
<>
<div className="message-role">The Maester</div>
<div className="message-content">
<ReactMarkdown components={markdownComponents}>
{content}
</ReactMarkdown>
{isStreaming && <span className="typing-indicator"></span>}
</div>
{/* Follow-ups only for "With Memory" side */}
{showMemories && !isStreaming && followUps && followUps.length > 0 && (
<div className="follow-ups">
<div className="follow-ups-label">Follow-up questions:</div>
<div className="follow-ups-list">
{followUps.map((question, qIndex) => (
<button
key={qIndex}
className="follow-up-btn"
onClick={() => onFollowUpClick(question)}
disabled={isLoading}
>
{question}
</button>
))}
</div>
</div>
)}
{showMemories && !isStreaming && isLoadingFollowUps && !followUps && (
<div className="follow-ups follow-ups-loading">
<div className="follow-ups-label">
<span className="follow-ups-spinner"></span>
Generating follow-up questions...
</div>
</div>
)}
</>
) : isStreaming ? (
<>
<div className="message-role">The Maester</div>
<div className="message-content">
<span className="typing-indicator">Thinking...</span>
</div>
</>
) : null}
</div>
</div>
);
}
export function ComparisonView({
comparison,
memories,
isRetrievingMemories,
followUps,
isLoadingFollowUps,
onFollowUpClick,
isLoading,
}: ComparisonViewProps) {
return (
<div className="comparison-panels">
<ComparisonPanel
title="With Memory"
badgeClass="with-memory"
content={comparison.withMemory.content}
isStreaming={comparison.withMemory.isStreaming && !comparison.withMemory.isDone}
isDone={comparison.withMemory.isDone}
memories={memories}
showMemories={true}
isRetrievingMemories={isRetrievingMemories}
followUps={followUps}
isLoadingFollowUps={isLoadingFollowUps}
onFollowUpClick={onFollowUpClick}
isLoading={isLoading}
/>
<div className="comparison-divider"></div>
<ComparisonPanel
title="Without Memory"
badgeClass="without-memory"
content={comparison.withoutMemory.content}
isStreaming={comparison.withoutMemory.isStreaming && !comparison.withoutMemory.isDone}
isDone={comparison.withoutMemory.isDone}
memories={[]}
showMemories={false}
isRetrievingMemories={false}
followUps={undefined}
isLoadingFollowUps={false}
onFollowUpClick={onFollowUpClick}
isLoading={isLoading}
/>
</div>
);
}

View File

@ -0,0 +1,31 @@
interface ExampleQueriesProps {
onSelectQuery: (query: string) => void;
disabled: boolean;
}
const EXAMPLE_QUERIES = [
'What body parts did Gared lose to the cold?',
'How many years had Will been on the Wall before the prologue events?',
'What was Ser Waymar Royce\'s cloak made of?',
'Describe the Other\'s sword that fought Ser Waymar.',
];
export function ExampleQueries({ onSelectQuery, disabled }: ExampleQueriesProps) {
return (
<div className="example-queries">
<h3>Example Questions:</h3>
<div className="example-queries-list">
{EXAMPLE_QUERIES.map((query, index) => (
<button
key={index}
className="example-query-button"
onClick={() => onSelectQuery(query)}
disabled={disabled}
>
{query}
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
import { useState } from 'react';
import { Memory } from '../types';
interface MemoryPanelProps {
memories: Memory[];
isLoading: boolean;
}
interface MemoryCardProps {
memory: Memory;
citationNumber: number;
}
function MemoryCard({ memory, citationNumber }: MemoryCardProps) {
const [showOriginal, setShowOriginal] = useState(false);
return (
<div className="memory-card" data-memory-id={memory.id}>
{/* Citation badge */}
<div className="memory-citation-badge">[{citationNumber}]</div>
{/* Header with book/chapter info */}
<div className="memory-metadata">
<span className="memory-book">{memory.metadata.bookTitle}</span>
{(memory.metadata.chapterNumber || memory.metadata.chapterName) && (
<span className="memory-chapter">
{memory.metadata.chapterNumber ? `Chapter ${memory.metadata.chapterNumber}` : ''}
{memory.metadata.chapterNumber && memory.metadata.chapterName ? ': ' : ''}
{memory.metadata.chapterName || ''}
</span>
)}
</div>
{/* Subject/Title */}
{memory.subject && (
<div className="memory-subject">{memory.subject}</div>
)}
{/* Main content - summary or content */}
<div className="memory-summary">
{memory.summary || memory.content || '(no content)'}
</div>
{/* Original content toggle */}
{memory.originalContent && (
<div className="memory-original-section">
<button
className="memory-toggle-btn"
onClick={() => setShowOriginal(!showOriginal)}
>
{showOriginal ? 'Hide original text' : 'Show original text'}
</button>
{showOriginal && (
<div className="memory-original">
{memory.originalContent}
</div>
)}
</div>
)}
</div>
);
}
export function MemoryPanel({ memories, isLoading }: MemoryPanelProps) {
return (
<div className="memory-panel">
<div className="memory-panel-header">
<span className="memory-panel-icon"></span>
<span className="memory-panel-title">Retrieved Memories</span>
<span className="memory-panel-count">{memories.length > 0 && memories.length}</span>
</div>
{isLoading && memories.length === 0 ? (
<div className="memory-loading">
<div className="loading-spinner"></div>
<p>Retrieving memories...</p>
</div>
) : memories.length === 0 ? (
<div className="memory-empty">
<p>No memories retrieved yet. Ask a question to see relevant excerpts from the books.</p>
</div>
) : (
<div className="memory-list">
{memories.map((memory, index) => (
<MemoryCard key={memory.id} memory={memory} citationNumber={index + 1} />
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,263 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import { Message, Memory } from '../types';
interface MessageListProps {
messages: Message[];
streamingContent: string;
isLoading: boolean;
isRetrievingMemories: boolean;
isLoadingFollowUps: boolean;
memories: Memory[];
onFollowUpClick: (question: string) => void;
}
interface CitationProps {
citationNumber: number;
memory: Memory | undefined;
}
function Citation({ citationNumber, memory }: CitationProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [showOriginal, setShowOriginal] = useState(false);
if (!memory) {
// Fallback if memory not found - just show the citation number
return <span className="citation-badge citation-missing">memory [{citationNumber}]</span>;
}
const handleClick = () => {
setIsExpanded(!isExpanded);
// Also highlight in side panel
const sidePanel = document.querySelector(`[data-memory-id="${memory.id}"]`);
if (sidePanel) {
sidePanel.classList.add('memory-highlighted');
sidePanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setTimeout(() => sidePanel.classList.remove('memory-highlighted'), 2000);
}
};
return (
<span className="citation-wrapper">
<span
className={`citation-badge ${isExpanded ? 'citation-expanded' : ''}`}
onClick={handleClick}
title={memory.subject || `Memory ${citationNumber}`}
>
memory [{citationNumber}]
</span>
{isExpanded && (
<span className="citation-expanded-block">
<span className="citation-expanded-header">
<span className="citation-expanded-badge">[{citationNumber}]</span>
<span className="citation-expanded-title">{memory.subject || 'Memory'}</span>
<button
className="citation-close-btn"
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
>
×
</button>
</span>
<span className="citation-expanded-meta">
{memory.metadata.bookTitle}
{memory.metadata.chapterNumber && ` - Chapter ${memory.metadata.chapterNumber}`}
{memory.metadata.chapterName && `: ${memory.metadata.chapterName}`}
</span>
<span className="citation-expanded-content">
{memory.summary || memory.content}
</span>
{memory.originalContent && (
<span className="citation-original-section">
<button
className="citation-toggle-original"
onClick={(e) => { e.stopPropagation(); setShowOriginal(!showOriginal); }}
>
{showOriginal ? 'Hide original text' : 'Show original text'}
</button>
{showOriginal && (
<span className="citation-original-text">{memory.originalContent}</span>
)}
</span>
)}
</span>
)}
</span>
);
}
interface CitationContentProps {
content: string;
memories: Memory[];
}
function CitationContent({ content, memories }: CitationContentProps) {
// Parse text and replace [1], [2], etc. with Citation components
const parts: (string | JSX.Element)[] = [];
const regex = /\[(\d+)\]/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(content)) !== null) {
// Add text before the citation
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
// Add the citation component
const citationNumber = parseInt(match[1], 10);
const memory = memories[citationNumber - 1]; // Citations are 1-indexed
parts.push(
<Citation
key={`citation-${match.index}`}
citationNumber={citationNumber}
memory={memory}
/>
);
lastIndex = regex.lastIndex;
}
// Add remaining text after last citation
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
return <>{parts}</>;
}
export function MessageList({ messages, streamingContent, isLoading, isRetrievingMemories, isLoadingFollowUps, memories, onFollowUpClick }: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent]);
// Create custom markdown components to handle citations in text
const createMarkdownComponents = useCallback((mems: Memory[]): Components => ({
p: ({ children }) => {
// Process children to handle citations
const processedChildren = processChildren(children, mems);
return <p>{processedChildren}</p>;
},
li: ({ children }) => {
const processedChildren = processChildren(children, mems);
return <li>{processedChildren}</li>;
},
strong: ({ children }) => {
const processedChildren = processChildren(children, mems);
return <strong>{processedChildren}</strong>;
},
em: ({ children }) => {
const processedChildren = processChildren(children, mems);
return <em>{processedChildren}</em>;
},
}), []);
// Helper to process children and replace citation patterns
const processChildren = (children: React.ReactNode, mems: Memory[]): React.ReactNode => {
if (!children) return children;
if (typeof children === 'string') {
// Check if string contains citations
if (/\[\d+\]/.test(children)) {
return <CitationContent content={children} memories={mems} />;
}
return children;
}
if (Array.isArray(children)) {
return children.map((child, index) => {
if (typeof child === 'string' && /\[\d+\]/.test(child)) {
return <CitationContent key={index} content={child} memories={mems} />;
}
return child;
});
}
return children;
};
const markdownComponents = createMarkdownComponents(memories);
return (
<div className="message-list">
{messages.map((message, index) => (
<div key={index} className={`message message-${message.role}`}>
<div className="message-role">
{message.role === 'user' ? 'You' : 'The Maester'}
</div>
<div className="message-content">
{message.role === 'assistant' ? (
<ReactMarkdown components={markdownComponents}>
{message.content}
</ReactMarkdown>
) : (
<ReactMarkdown>{message.content}</ReactMarkdown>
)}
</div>
{message.role === 'assistant' && (
<>
{/* Show follow-up questions if available */}
{message.followUps && message.followUps.length > 0 && (
<div className="follow-ups">
<div className="follow-ups-label">Follow-up questions:</div>
<div className="follow-ups-list">
{message.followUps.map((question, qIndex) => (
<button
key={qIndex}
className="follow-up-btn"
onClick={() => onFollowUpClick(question)}
disabled={isLoading}
>
{question}
</button>
))}
</div>
</div>
)}
{/* Show loading indicator for follow-ups on the last message */}
{isLoadingFollowUps && index === messages.length - 1 && !message.followUps && (
<div className="follow-ups follow-ups-loading">
<div className="follow-ups-label">
<span className="follow-ups-spinner"></span>
Generating follow-up questions...
</div>
</div>
)}
</>
)}
</div>
))}
{streamingContent && (
<div className="message message-assistant">
<div className="message-role">The Maester</div>
<div className="message-content">
<ReactMarkdown components={markdownComponents}>
{streamingContent}
</ReactMarkdown>
<span className="typing-indicator"></span>
</div>
</div>
)}
{isLoading && !streamingContent && (
<div className="message message-assistant">
<div className="message-role">The Maester</div>
<div className="message-content">
<span className="typing-indicator">
{isRetrievingMemories ? 'Retrieving memories...' : 'Thinking...'}
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
);
}

View File

@ -0,0 +1,143 @@
import { useState, useEffect, useCallback } from 'react';
import { Message, Memory } from '../types';
import { sendChatMessage } from '../services/api';
const STORAGE_KEY = 'chat_history';
const MEMORIES_STORAGE_KEY = 'chat_memories';
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [currentMemories, setCurrentMemories] = useState<Memory[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRetrievingMemories, setIsRetrievingMemories] = useState(false);
const [isLoadingFollowUps, setIsLoadingFollowUps] = useState(false);
const [error, setError] = useState<string | null>(null);
const [streamingContent, setStreamingContent] = useState('');
// Load chat history and memories from localStorage on mount
useEffect(() => {
const storedMessages = localStorage.getItem(STORAGE_KEY);
if (storedMessages) {
try {
setMessages(JSON.parse(storedMessages));
} catch (e) {
console.error('Error loading chat history:', e);
}
}
const storedMemories = localStorage.getItem(MEMORIES_STORAGE_KEY);
if (storedMemories) {
try {
setCurrentMemories(JSON.parse(storedMemories));
} catch (e) {
console.error('Error loading memories:', e);
}
}
}, []);
// Save chat history to localStorage
const saveChatHistory = useCallback((msgs: Message[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(msgs));
}, []);
// Save memories to localStorage
const saveMemories = useCallback((memories: Memory[]) => {
localStorage.setItem(MEMORIES_STORAGE_KEY, JSON.stringify(memories));
}, []);
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim() || isLoading) return;
setError(null);
setIsLoading(true);
setIsRetrievingMemories(true);
setStreamingContent('');
setCurrentMemories([]); // Clear memories to show loading state
// Add user message
const userMessage: Message = { role: 'user', content };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
saveChatHistory(updatedMessages);
try {
let assistantContent = '';
await sendChatMessage(content, messages, {
onMemories: (memories) => {
setCurrentMemories(memories);
saveMemories(memories);
setIsRetrievingMemories(false);
},
onToken: (token) => {
assistantContent += token;
setStreamingContent(assistantContent);
},
onDone: () => {
// Add complete assistant message (follow-ups will be added when received)
const assistantMessage: Message = {
role: 'assistant',
content: assistantContent,
};
const finalMessages = [...updatedMessages, assistantMessage];
setMessages(finalMessages);
saveChatHistory(finalMessages);
setStreamingContent('');
setIsLoading(false);
setIsLoadingFollowUps(true); // Start loading follow-ups
},
onFollowUps: (followUps) => {
setIsLoadingFollowUps(false);
// Update the last assistant message with follow-ups
setMessages(prev => {
if (prev.length === 0) return prev;
const updated = [...prev];
const lastIndex = updated.length - 1;
if (updated[lastIndex].role === 'assistant') {
updated[lastIndex] = { ...updated[lastIndex], followUps };
saveChatHistory(updated);
}
return updated;
});
},
onError: (errorMessage) => {
setError(errorMessage);
setIsLoading(false);
setIsRetrievingMemories(false);
setIsLoadingFollowUps(false);
setStreamingContent('');
},
});
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setIsLoading(false);
setIsRetrievingMemories(false);
setIsLoadingFollowUps(false);
setStreamingContent('');
}
},
[messages, isLoading, saveChatHistory, saveMemories]
);
const clearChat = useCallback(() => {
setMessages([]);
setCurrentMemories([]);
setError(null);
setStreamingContent('');
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(MEMORIES_STORAGE_KEY);
}, []);
return {
messages,
currentMemories,
isLoading,
isRetrievingMemories,
isLoadingFollowUps,
error,
streamingContent,
sendMessage,
clearChat,
};
}

View File

@ -0,0 +1,170 @@
import { useState, useEffect, useCallback } from 'react';
import { Message, Memory, ComparisonState } from '../types';
import { sendCompareMessage } from '../services/api';
const STORAGE_KEY = 'compare_chat_history';
const MEMORIES_STORAGE_KEY = 'compare_chat_memories';
const initialComparisonState: ComparisonState = {
withMemory: { content: '', isStreaming: false, isDone: false },
withoutMemory: { content: '', isStreaming: false, isDone: false },
};
export function useCompareChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [currentMemories, setCurrentMemories] = useState<Memory[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRetrievingMemories, setIsRetrievingMemories] = useState(false);
const [isLoadingFollowUps, setIsLoadingFollowUps] = useState(false);
const [error, setError] = useState<string | null>(null);
const [comparison, setComparison] = useState<ComparisonState>(initialComparisonState);
// Load chat history and memories from localStorage on mount
useEffect(() => {
const storedMessages = localStorage.getItem(STORAGE_KEY);
if (storedMessages) {
try {
setMessages(JSON.parse(storedMessages));
} catch (e) {
console.error('Error loading chat history:', e);
}
}
const storedMemories = localStorage.getItem(MEMORIES_STORAGE_KEY);
if (storedMemories) {
try {
setCurrentMemories(JSON.parse(storedMemories));
} catch (e) {
console.error('Error loading memories:', e);
}
}
}, []);
// Save chat history to localStorage
const saveChatHistory = useCallback((msgs: Message[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(msgs));
}, []);
// Save memories to localStorage
const saveMemories = useCallback((memories: Memory[]) => {
localStorage.setItem(MEMORIES_STORAGE_KEY, JSON.stringify(memories));
}, []);
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim() || isLoading) return;
setError(null);
setIsLoading(true);
setIsRetrievingMemories(true);
setCurrentMemories([]); // Clear memories to show loading state
// Reset comparison state for new message
setComparison({
withMemory: { content: '', isStreaming: true, isDone: false },
withoutMemory: { content: '', isStreaming: true, isDone: false },
});
// Add user message
const userMessage: Message = { role: 'user', content };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
saveChatHistory(updatedMessages);
try {
let withMemoryContent = '';
let withoutMemoryContent = '';
let pendingFollowUps: string[] | undefined;
await sendCompareMessage(content, messages, {
onMemories: (memories) => {
setCurrentMemories(memories);
saveMemories(memories);
setIsRetrievingMemories(false);
},
onToken: (stream, token) => {
if (stream === 'withMemory') {
withMemoryContent += token;
setComparison(prev => ({
...prev,
withMemory: { ...prev.withMemory, content: withMemoryContent },
}));
} else {
withoutMemoryContent += token;
setComparison(prev => ({
...prev,
withoutMemory: { ...prev.withoutMemory, content: withoutMemoryContent },
}));
}
},
onStreamDone: (stream) => {
setComparison(prev => {
const updated = {
...prev,
[stream]: { ...prev[stream], isStreaming: false, isDone: true },
};
// When withMemory stream is done, start loading follow-ups indicator
if (stream === 'withMemory') {
setIsLoadingFollowUps(true);
}
return updated;
});
},
onFollowUps: (followUps) => {
// Store follow-ups to be added when assistant message is created
pendingFollowUps = followUps;
setIsLoadingFollowUps(false);
},
onComplete: () => {
// Add complete assistant message with follow-ups
const assistantMessage: Message = {
role: 'assistant',
content: withMemoryContent,
followUps: pendingFollowUps,
};
const finalMessages = [...updatedMessages, assistantMessage];
setMessages(finalMessages);
saveChatHistory(finalMessages);
setIsLoading(false);
setIsLoadingFollowUps(false);
},
onError: (errorMessage) => {
setError(errorMessage);
setIsLoading(false);
setIsRetrievingMemories(false);
setIsLoadingFollowUps(false);
setComparison(initialComparisonState);
},
});
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setIsLoading(false);
setIsRetrievingMemories(false);
setIsLoadingFollowUps(false);
setComparison(initialComparisonState);
}
},
[messages, isLoading, saveChatHistory, saveMemories]
);
const clearChat = useCallback(() => {
setMessages([]);
setCurrentMemories([]);
setError(null);
setComparison(initialComparisonState);
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(MEMORIES_STORAGE_KEY);
}, []);
return {
messages,
currentMemories,
isLoading,
isRetrievingMemories,
isLoadingFollowUps,
error,
comparison,
sendMessage,
clearChat,
};
}

View File

@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,205 @@
import { Memory, Message, SSEEvent } from '../types';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export interface ChatStreamCallbacks {
onMemories: (memories: Memory[]) => void;
onToken: (token: string) => void;
onDone: () => void;
onFollowUps: (followUps: string[]) => void;
onError: (error: string) => void;
}
export async function sendChatMessage(
message: string,
conversationHistory: Message[],
callbacks: ChatStreamCallbacks
): Promise<void> {
try {
const response = await fetch(`${API_URL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
conversationHistory,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// Process complete SSE messages
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
try {
const event = JSON.parse(data) as SSEEvent;
switch (event.type) {
case 'memories':
if (event.memories) {
callbacks.onMemories(event.memories);
}
break;
case 'token':
if (event.token) {
callbacks.onToken(event.token);
}
break;
case 'done':
callbacks.onDone();
break;
case 'followups':
if (event.followUps) {
callbacks.onFollowUps(event.followUps);
}
break;
case 'error':
callbacks.onError(event.message || 'An error occurred');
break;
}
} catch (e) {
console.error('Error parsing SSE event:', e);
}
}
}
}
} catch (error) {
console.error('Error in chat stream:', error);
callbacks.onError(
error instanceof Error ? error.message : 'Connection lost. Please check your internet.'
);
}
}
export interface CompareStreamCallbacks {
onMemories: (memories: Memory[]) => void;
onToken: (stream: 'withMemory' | 'withoutMemory', token: string) => void;
onStreamDone: (stream: 'withMemory' | 'withoutMemory') => void;
onFollowUps: (followUps: string[]) => void;
onComplete: () => void;
onError: (error: string) => void;
}
export async function sendCompareMessage(
message: string,
conversationHistory: Message[],
callbacks: CompareStreamCallbacks
): Promise<void> {
try {
const response = await fetch(`${API_URL}/api/chat/compare`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
conversationHistory,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// Process complete SSE messages
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
try {
const event = JSON.parse(data) as SSEEvent;
switch (event.type) {
case 'memories':
if (event.memories) {
callbacks.onMemories(event.memories);
}
break;
case 'token':
if (event.token && event.stream) {
callbacks.onToken(event.stream, event.token);
}
break;
case 'done':
if (event.stream) {
callbacks.onStreamDone(event.stream);
}
break;
case 'followups':
if (event.followUps) {
callbacks.onFollowUps(event.followUps);
}
break;
case 'complete':
callbacks.onComplete();
break;
case 'error':
callbacks.onError(event.message || 'An error occurred');
break;
}
} catch (e) {
console.error('Error parsing SSE event:', e);
}
}
}
}
} catch (error) {
console.error('Error in compare stream:', error);
callbacks.onError(
error instanceof Error ? error.message : 'Connection lost. Please check your internet.'
);
}
}
export async function checkHealth(): Promise<{
status: string;
backend: string;
openai: string;
memory: string;
}> {
const response = await fetch(`${API_URL}/api/health`);
return response.json();
}

View File

@ -0,0 +1,48 @@
export interface Memory {
id: string;
content: string;
metadata: {
bookTitle: string;
chapterNumber?: number;
chapterName?: string;
};
relevanceScore?: number;
// Rich fields from EverMind Cloud API
subject?: string; // Concise title/headline
summary?: string; // Short summary paragraph
episode?: string; // Detailed narrative with timestamps
originalContent?: string; // The actual source text from the book
}
export interface Message {
role: 'user' | 'assistant';
content: string;
followUps?: string[]; // AI-generated follow-up questions
}
export interface ChatState {
messages: Message[];
currentMemories: Memory[];
isLoading: boolean;
error: string | null;
}
export interface SSEEvent {
type: 'memories' | 'token' | 'done' | 'followups' | 'error' | 'complete';
stream?: 'withMemory' | 'withoutMemory';
memories?: Memory[];
token?: string;
message?: string;
followUps?: string[];
}
export interface ComparisonStreamState {
content: string;
isStreaming: boolean;
isDone: boolean;
}
export interface ComparisonState {
withMemory: ComparisonStreamState;
withoutMemory: ComparisonStreamState;
}

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
},
})

View File

@ -0,0 +1,27 @@
{
"name": "qa-book-demo",
"version": "1.0.0",
"private": true,
"workspaces": [
"frontend",
"backend"
],
"scripts": {
"dev": "bun run --filter frontend dev & bun run --filter backend dev",
"dev:frontend": "bun run --filter frontend dev",
"dev:backend": "bun run --filter backend dev",
"build": "bun run --filter frontend build && bun run --filter backend build",
"type-check": "bun run --filter frontend type-check && bun run --filter backend type-check",
"lint": "bun run --filter frontend lint && bun run --filter backend lint",
"load-novel": "bun run scripts/load-novel.ts",
"load-novel-cloud": "bun run scripts/load-novel-cloud.ts",
"clear-memories": "bun run scripts/clear-memories.ts",
"clear-memories-cloud": "bun run scripts/clear-memories-cloud.ts",
"get-memories-cloud": "bun run scripts/get-memories-cloud.ts"
},
"devDependencies": {
"@types/node": "^20.10.0",
"concurrently": "^9.2.1",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,93 @@
PROLOGUE
"We should start back," Gared urged as the woods began to grow dark around them. "The wildlings are dead."
"Do the dead frighten you?" Ser Waymar Royce asked with just the hint of a smile.
Gared did not rise to the bait. He was an old man, past fifty, and he had seen the lordlings come and go. "Dead is dead," he said. "We have no business with the dead."
"Are they dead?" Royce asked softly. "What proof have we?"
"Will saw them," Gared said. "If he says they are dead, that's proof enough for me."
Will had known they would drag him into the quarrel sooner or later. He wished it had been later rather than sooner. "My mother told me that dead men sing no songs," he put in.
"My wet nurse said the same thing, Will," Royce replied. "Never believe anything you hear at a woman's tit. There are things to be learned even from the dead." His voice echoed, too loud in the twilit forest.
"We have a long ride before us," Gared pointed out. "Eight days, maybe nine. And night is falling."
Ser Waymar Royce glanced at the sky with disinterest. "It does that every day about this time. Are you unmanned by the dark, Gared?"
Will could see that the weights hung differently on him now, and he feared what that might mean. "There's something wrong here," Gared muttered.
The young knight turned back. "Wrong?"
Gared watched him with his small hard eyes. "Can't you feel it? The trees... there were no trees here before."
BRAN
The morning had dawned clear and cold, with a crispness that hinted at the end of summer. They set forth at daybreak to see a man beheaded, twenty in all, and Bran rode among them, nervous with excitement. This was the first time he had been deemed old enough to go with his lord father and his brothers to see the king's justice done. It was the ninth year of summer, and the seventh of Bran's life.
The man had been taken outside a small holdfast in the hills. Robb thought he was a wildling, his sword sworn to Mance Rayder, the King-beyond-the-Wall. It made Bran's skin prickle to think of it. He remembered the hearth tales Old Nan told them. The wildlings were cruel men, she said, slavers and slayers and thieves. They consorted with giants and ghouls, stole girl children in the dead of night, and drank blood from polished horns. And their women lay with the Others in the Long Night to sire terrible half-human children.
But the man they found bound hand and foot to the holdfast wall awaiting the king's justice was old and scrawny, not much taller than Robb. He had lost both ears and a finger to frostbite, and he dressed all in black, the same as a brother of the Night's Watch, except that his furs were ragged and greasy.
The breath of man and horse mingled, steaming, in the cold morning air as his lord father had the man cut down from the wall and dragged before them. Robb and Jon sat tall and still on their horses, with Bran between them on his pony, trying to seem older than seven, trying to pretend that he'd seen all this before. A faint wind blew through the holdfast gate. Over their heads flapped the banner of the Starks of Winterfell: a grey direwolf racing across an ice-white field.
Bran's father sat solemnly on his horse, long brown hair stirring in the wind. His closely trimmed beard was shot with white, making him look older than his thirty-five years. He had a grim cast to his grey eyes this day, and he seemed not at all the man who would sit before the fire in the evening and talk softly of the age of heroes and the children of the forest. He had taken off Father's face, Bran thought, and donned the face of Lord Stark of Winterfell.
CATELYN
Catelyn had never liked this godswood.
She had been born a Tully, at Riverrun far to the south, on the Red Fork of the Trident. The godswood there was a garden, bright and airy, where tall redwoods spread dappled shadows across tinkling streams, birds sang from hidden nests, and the air smelled of flowers.
The gods of Winterfell kept a different sort of wood. It was a dark, primal place, three acres of old forest untouched for ten thousand years as the gloomy castle rose around it. It smelled of moist earth and decay. No redwoods grew here. This was a wood of stubborn sentinel trees armored in grey-green needles, of ## and oaks as old as the realm itself. Here thick black trunks crowded close together while twisted branches wove a dense canopy overhead and misshapen roots wrestled beneath the soil. This was a place of deep silence and brooding shadows, and the gods who lived here had no names.
But she knew she would find her husband here tonight. Whenever he took a man's life, afterward he would seek the quiet of the godswood.
Catelyn had been anointed with the seven oils and named in the rainbow of light that filled the sept of Riverrun. She was of the Faith, like her father and grandfather and his father before him. Her gods had names, and their faces were as familiar as the faces of her parents. Worship was a septon with a censer, the smell of incense, a seven-sided crystal alive with light, voices raised in song. The Tullys kept a godswood, as all the great houses did, but it was only a place to walk or read or lie in the sun. Worship was for the sept.
For her sake, Ned had built a small sept where she might sing to the seven gods, but the blood of the First Men still flowed in the veins of the Starks, and his own gods were the old ones, the nameless, faceless gods of the greenwood they shared with the vanished children of the forest.
JON
Jon climbed the steps slowly, trying not to think that this might be the last time ever. Ghost padded silently beside him. Outside, snow swirled through the castle gates, and the yard was all noise and chaos, but inside the tower was dark and still.
Tyrion Lannister was curled up in a window seat when Jon entered his bedchamber, reading a book by the light of a stub of candle beside him.
"I see you have found a wolf," the little man said, closing the book.
"He doesn't like strangers," Jon said.
"A wise beast," Tyrion said with a twisted smile. He climbed down from the window seat. "I think not. Your wolf is smarter than you. He doesn't like me, so I'd best keep my distance."
"I wanted to thank you," Jon said. "For speaking to my father."
"Thank me?" Tyrion grinned. "For what? For speaking the truth? A man grown fears no truth."
"Some men do," Jon said quietly.
The dwarf let out a bark of laughter. "Yes, I have noticed. But I would be a poor brother if I did not give you a bit of counsel on your way. You will find that the Watch is not as romantic as the songs may have led you to believe. Most of the black brothers are sullen and unlettered, rough men at best. You would do well to make what friends you can among them."
"I will," Jon promised.
"See that you do. And one last thing. You must remember always that you are a bastard."
The words stabbed at Jon like a knife.
DAENERYS
Her brother held the gown up for her inspection. "This is beauty. Touch it. Go on. Caress the fabric."
Dany touched it. The cloth was so smooth that it seemed to run through her fingers like water. She could not remember ever wearing anything so soft. It frightened her. She pulled her hand away. "Is it really mine?"
"A gift from the Magister Illyrio," Viserys said, smiling. Her brother was in a high mood tonight. "The color will bring out the violet in your eyes. And you shall have gold as well, and jewels of all sorts. Illyrio has promised. Tonight you must look like a princess."
A princess, Dany thought. She had forgotten what that was like. Perhaps she had never known.
"Why does he give us so much?" she asked. "What does he want from us?" For nigh on half a year, they had lived in the magister's house, eating his food, pampered by his servants.
"Illyrio is no fool," Viserys said. He was a gaunt young man with nervous hands and a feverish look in his pale lilac eyes. "The magister knows that I will not forget my friends when I come into my throne."
Dany said nothing. Magister Illyrio was a dealer in spices, gemstones, dragonbone, and other, less savory things. He had friends in all of the Nine Free Cities, it was said, and even beyond, in Vaes Dothrak and the fabled lands beside the Jade Sea. It was also said that he'd never had a friend he wouldn't sell for the right price. Dany listened to the talk in the streets, and she heard these things, but she knew better than to question her brother when he wove his webs of dream.

View File

@ -0,0 +1,325 @@
#!/usr/bin/env bun
/**
* Clear Memories Script for EverMind Cloud API
*
* Deletes all memories from EverMind Cloud and cleans up progress files.
*
* Usage:
* bun run clear-memories-cloud --api-key <key>
* bun run clear-memories-cloud --api-key <key> --dry-run
*/
import { parseArgs } from 'util';
import { existsSync, readdirSync, unlinkSync } from 'fs';
import { resolve, basename } from 'path';
// ============================================================================
// Types
// ============================================================================
interface CliArgs {
apiKey: string;
apiUrl: string;
groupId: string;
deleteAll: boolean;
dryRun: boolean;
keepProgress: boolean;
}
interface DeleteResponse {
status: string;
message: string;
result?: {
filters: string[];
count: number;
};
}
interface DeleteResult {
success: boolean;
message: string;
count: number;
notFound: boolean;
}
// ============================================================================
// CLI Argument Parsing
// ============================================================================
function parseCliArgs(): CliArgs | null {
try {
const { values } = parseArgs({
options: {
'api-key': { type: 'string' },
'api-url': { type: 'string', default: 'https://api.evermind.ai' },
'group-id': { type: 'string', default: 'asoiaf' },
'delete-all': { type: 'boolean', default: false },
'dry-run': { type: 'boolean', default: false },
'keep-progress': { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
return null;
}
// API key from argument or environment variable
const apiKey = values['api-key'] as string || process.env.EVERMIND_API_KEY || '';
if (!apiKey) {
console.error('❌ Error: API key required. Use --api-key or set EVERMIND_API_KEY environment variable\n');
printHelp();
process.exit(1);
}
const deleteAll = values['delete-all'] as boolean;
return {
apiKey,
apiUrl: values['api-url'] as string,
groupId: deleteAll ? '__all__' : values['group-id'] as string,
deleteAll,
dryRun: values['dry-run'] as boolean,
keepProgress: values['keep-progress'] as boolean,
};
} catch (error) {
console.error('Error parsing arguments:', error instanceof Error ? error.message : String(error));
console.error('');
printHelp();
process.exit(1);
}
}
function printHelp(): void {
console.log(`
Clear Memories Script for EverMind Cloud API
Deletes all memories from EverMind Cloud and cleans up progress files.
Usage:
bun run clear-memories-cloud --api-key <key> [options]
Required:
--api-key <key> EverMind API key (or set EVERMIND_API_KEY env var)
Options:
--api-url <url> EverMind API URL (default: https://api.evermind.ai)
--group-id <id> Group ID to delete memories for (default: asoiaf)
--delete-all Delete ALL memories (sets group_id to "__all__")
--dry-run Show what would be deleted without actually deleting
--keep-progress Keep progress files, only delete memories from cloud
--help Show this help message
Examples:
bun run clear-memories-cloud --api-key YOUR_KEY
bun run clear-memories-cloud --api-key YOUR_KEY --dry-run
bun run clear-memories-cloud --api-key YOUR_KEY --delete-all
EVERMIND_API_KEY=your_key bun run clear-memories-cloud
`);
}
// ============================================================================
// Progress File Cleanup
// ============================================================================
function findProgressFiles(): string[] {
const cwd = process.cwd();
const files: string[] = [];
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
// Match both local and cloud progress files
if ((entry.startsWith('.novel-progress-') || entry.startsWith('.novel-progress-cloud-')) && entry.endsWith('.json')) {
files.push(resolve(cwd, entry));
}
}
} catch (error) {
console.error('Error reading directory:', error);
}
return files;
}
function deleteProgressFiles(files: string[], dryRun: boolean): number {
let deleted = 0;
for (const file of files) {
if (dryRun) {
console.log(` Would delete: ${basename(file)}`);
deleted++;
} else {
try {
unlinkSync(file);
console.log(` Deleted: ${basename(file)}`);
deleted++;
} catch (error) {
console.error(` Failed to delete ${basename(file)}:`, error);
}
}
}
return deleted;
}
// ============================================================================
// EverMind Cloud API
// ============================================================================
async function deleteMemories(apiUrl: string, apiKey: string, groupId: string): Promise<DeleteResult> {
// API expects all three fields: event_id, user_id, group_id
// Use "__all__" magic value to match all records for that field
const requestBody = {
event_id: '__all__',
user_id: '__all__',
group_id: groupId, // Specific group to delete, or "__all__" for everything
};
const response = await fetch(`${apiUrl}/api/v0/memories`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(30000),
});
const data = await response.json() as DeleteResponse;
// Handle 404 as success (no memories to delete)
if (response.status === 404) {
return {
success: true,
message: 'No memories found (already clean)',
count: 0,
notFound: true,
};
}
if (response.ok && data.status === 'ok') {
return {
success: true,
message: data.message || 'Memories deleted',
count: data.result?.count || 0,
notFound: false,
};
}
return {
success: false,
message: data.message || `HTTP ${response.status}`,
count: 0,
notFound: false,
};
}
async function checkHealth(apiUrl: string, apiKey: string): Promise<boolean> {
try {
const response = await fetch(`${apiUrl}/health`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch (error) {
return false;
}
}
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = parseCliArgs();
if (!args) {
return;
}
console.log('');
console.log('═'.repeat(60));
console.log('🧹 Clear Memories - EverMind Cloud');
console.log('═'.repeat(60));
console.log(`API: ${args.apiUrl}`);
console.log(`Key: ${args.apiKey.slice(0, 8)}...${args.apiKey.slice(-4)}`);
console.log(`Target: ${args.deleteAll ? 'ALL MEMORIES' : `group "${args.groupId}"`}`);
if (args.dryRun) {
console.log('\n⚠ DRY RUN MODE - No changes will be made\n');
}
// Step 1: Check EverMind Cloud health
console.log('\n📡 Checking EverMind Cloud connection...');
const isHealthy = await checkHealth(args.apiUrl, args.apiKey);
if (!isHealthy) {
console.log(' ⚠️ EverMind Cloud is not available at', args.apiUrl);
console.log(' Skipping memory deletion from cloud.\n');
} else {
console.log(' ✓ EverMind Cloud is healthy\n');
// Step 2: Delete memories from cloud
console.log(`📦 Deleting memories for group_id: "${args.groupId}"...`);
if (args.dryRun) {
console.log(` Would send DELETE request to ${args.apiUrl}/api/v0/memories`);
console.log(` With body: {"event_id": "__all__", "user_id": "__all__", "group_id": "${args.groupId}"}`);
} else {
try {
const result = await deleteMemories(args.apiUrl, args.apiKey, args.groupId);
if (result.success) {
if (result.notFound) {
console.log(`${result.message}`);
} else {
console.log(`${result.message}`);
console.log(` Memories deleted: ${result.count}`);
}
} else {
console.log(`${result.message}`);
}
} catch (error) {
console.error(' ✗ Failed to delete memories:', error instanceof Error ? error.message : String(error));
}
}
}
// Step 3: Clean up progress files
if (!args.keepProgress) {
console.log('\n📁 Cleaning up progress files...');
const progressFiles = findProgressFiles();
if (progressFiles.length === 0) {
console.log(' No progress files found.');
} else {
console.log(` Found ${progressFiles.length} progress file(s):`);
const deleted = deleteProgressFiles(progressFiles, args.dryRun);
console.log(` ${args.dryRun ? 'Would delete' : 'Deleted'}: ${deleted} file(s)`);
}
} else {
console.log('\n📁 Keeping progress files (--keep-progress flag set)');
}
// Summary
console.log('\n' + '═'.repeat(60));
if (args.dryRun) {
console.log('✅ Dry run complete. Run without --dry-run to apply changes.');
} else {
console.log('✅ Cleanup complete!');
}
console.log('═'.repeat(60) + '\n');
}
main().catch((error) => {
console.error('\nUnexpected error:', error);
process.exit(1);
});

View File

@ -0,0 +1,323 @@
#!/usr/bin/env bun
/**
* Get Memories Script for EverMind Cloud API
*
* Lists memories stored in EverMind Cloud with pagination support.
*
* Usage:
* bun run get-memories-cloud --api-key <key>
* bun run get-memories-cloud --api-key <key> --group-id asoiaf --page 1 --page-size 10
*/
import { parseArgs } from 'util';
// ============================================================================
// Types
// ============================================================================
interface CliArgs {
apiKey: string;
apiUrl: string;
groupId: string;
page: number;
pageSize: number;
memoryType: string | null;
startTime: string | null;
endTime: string | null;
allPages: boolean;
json: boolean;
}
interface MemoryItem {
memory_type: string;
summary?: string | null;
subject?: string | null;
episode?: string | null;
user_id?: string;
timestamp?: string;
group_id?: string | null;
group_name?: string | null;
keywords?: string[] | null;
linked_entities?: string[] | null;
[key: string]: unknown;
}
interface GetMemoriesResponse {
status: string;
message?: string;
result: {
memories: MemoryItem[];
total_count: number;
count: number;
metadata?: unknown;
};
}
// ============================================================================
// CLI Argument Parsing
// ============================================================================
function parseCliArgs(): CliArgs | null {
try {
const { values } = parseArgs({
options: {
'api-key': { type: 'string' },
'api-url': { type: 'string', default: 'https://api.evermind.ai' },
'group-id': { type: 'string', default: 'asoiaf' },
'page': { type: 'string', default: '1' },
'page-size': { type: 'string', default: '20' },
'memory-type': { type: 'string' },
'start-time': { type: 'string' },
'end-time': { type: 'string' },
'all': { type: 'boolean', default: false },
'json': { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
return null;
}
const apiKey = values['api-key'] as string || process.env.EVERMIND_API_KEY || '';
if (!apiKey) {
console.error('Error: API key required. Use --api-key or set EVERMIND_API_KEY environment variable\n');
printHelp();
process.exit(1);
}
return {
apiKey,
apiUrl: values['api-url'] as string,
groupId: values['group-id'] as string,
page: parseInt(values['page'] as string, 10),
pageSize: parseInt(values['page-size'] as string, 10),
memoryType: (values['memory-type'] as string) || null,
startTime: (values['start-time'] as string) || null,
endTime: (values['end-time'] as string) || null,
allPages: values['all'] as boolean,
json: values['json'] as boolean,
};
} catch (error) {
console.error('Error parsing arguments:', error instanceof Error ? error.message : String(error));
console.error('');
printHelp();
process.exit(1);
}
}
function printHelp(): void {
console.log(`
Get Memories Script for EverMind Cloud API
Lists memories stored in EverMind Cloud with pagination support.
Usage:
bun run get-memories-cloud --api-key <key> [options]
Required:
--api-key <key> EverMind API key (or set EVERMIND_API_KEY env var)
Options:
--api-url <url> EverMind API URL (default: https://api.evermind.ai)
--group-id <id> Group ID to query (default: asoiaf)
--page <num> Page number (default: 1)
--page-size <num> Results per page, 1-100 (default: 20)
--memory-type <type> Filter by type: profile, episodic_memory, foresight, event_log
--start-time <iso> Filter start time (ISO 8601 with timezone)
--end-time <iso> Filter end time (ISO 8601 with timezone)
--all Fetch all pages (overrides --page)
--json Output raw JSON response
--help Show this help message
Examples:
bun run get-memories-cloud --api-key YOUR_KEY
bun run get-memories-cloud --api-key YOUR_KEY --page-size 5 --all
bun run get-memories-cloud --api-key YOUR_KEY --memory-type profile
bun run get-memories-cloud --api-key YOUR_KEY --json
`);
}
// ============================================================================
// EverMind Cloud API
// ============================================================================
async function getMemories(
apiUrl: string,
apiKey: string,
params: {
groupId: string;
page: number;
pageSize: number;
memoryType: string | null;
startTime: string | null;
endTime: string | null;
}
): Promise<GetMemoriesResponse> {
const queryParams = new URLSearchParams({
group_ids: params.groupId,
page: params.page.toString(),
page_size: params.pageSize.toString(),
});
if (params.memoryType) {
queryParams.set('memory_type', params.memoryType);
}
if (params.startTime) {
queryParams.set('start_time', params.startTime);
}
if (params.endTime) {
queryParams.set('end_time', params.endTime);
}
const response = await fetch(`${apiUrl}/api/v0/memories?${queryParams}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
return await response.json() as GetMemoriesResponse;
}
// ============================================================================
// Display
// ============================================================================
function displayMemory(memory: MemoryItem, index: number): void {
const type = memory.memory_type || 'unknown';
const subject = memory.subject || memory.summary || '(no subject)';
const timestamp = memory.timestamp ? new Date(memory.timestamp).toLocaleString() : 'N/A';
console.log(` ${index}. [${type}] ${subject}`);
if (memory.summary && memory.summary !== memory.subject) {
const summary = memory.summary.length > 120
? memory.summary.slice(0, 120) + '...'
: memory.summary;
console.log(` Summary: ${summary}`);
}
if (memory.keywords && memory.keywords.length > 0) {
console.log(` Keywords: ${memory.keywords.join(', ')}`);
}
console.log(` Time: ${timestamp} | Group: ${memory.group_id || 'N/A'}`);
console.log('');
}
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = parseCliArgs();
if (!args) {
return;
}
if (!args.json) {
console.log('');
console.log('='.repeat(60));
console.log('EverMind Cloud - Get Memories');
console.log('='.repeat(60));
console.log(`API: ${args.apiUrl}`);
console.log(`Key: ${args.apiKey.slice(0, 8)}...${args.apiKey.slice(-4)}`);
console.log(`Group: ${args.groupId}`);
if (args.memoryType) {
console.log(`Type filter: ${args.memoryType}`);
}
console.log('');
}
let totalFetched = 0;
let currentPage = args.page;
let totalCount = 0;
do {
try {
const data = await getMemories(args.apiUrl, args.apiKey, {
groupId: args.groupId,
page: currentPage,
pageSize: args.pageSize,
memoryType: args.memoryType,
startTime: args.startTime,
endTime: args.endTime,
});
if (args.json) {
console.log(JSON.stringify(data, null, 2));
if (!args.allPages) break;
}
if (data.status !== 'ok') {
console.error(`API error: ${data.message || 'Unknown error'}`);
process.exit(1);
}
totalCount = data.result.total_count;
const memories = data.result.memories;
if (!args.json) {
if (currentPage === args.page) {
console.log(`Total memories: ${totalCount}`);
console.log('');
}
if (memories.length === 0) {
if (currentPage === args.page) {
console.log('No memories found.');
}
break;
}
console.log(`--- Page ${currentPage} (${memories.length} results) ---\n`);
for (let i = 0; i < memories.length; i++) {
const globalIndex = (currentPage - 1) * args.pageSize + i + 1;
displayMemory(memories[i], globalIndex);
}
}
totalFetched += memories.length;
if (!args.allPages || totalFetched >= totalCount) {
break;
}
currentPage++;
} catch (error) {
console.error(`\nError fetching memories:`, error instanceof Error ? error.message : String(error));
process.exit(1);
}
} while (args.allPages);
if (!args.json) {
console.log('='.repeat(60));
console.log(`Fetched ${totalFetched} of ${totalCount} memories`);
if (!args.allPages && totalFetched < totalCount) {
const totalPages = Math.ceil(totalCount / args.pageSize);
console.log(`Page ${args.page} of ${totalPages}. Use --all to fetch all pages.`);
}
console.log('='.repeat(60));
console.log('');
}
}
main().catch((error) => {
console.error('\nUnexpected error:', error);
process.exit(1);
});

View File

@ -0,0 +1,786 @@
#!/usr/bin/env bun
/**
* Novel Loading Script for EverMind Cloud API
*
* Processes plain text novel files, detects chapters, splits into paragraphs,
* and stores them in EverMind Cloud with progress tracking and resumption support.
*
* Usage:
* bun run load-novel-cloud --file <path> --book-title <title> --book-abbrev <abbrev> --api-key <key>
*/
import { parseArgs } from 'util';
import { existsSync } from 'fs';
import { resolve, basename } from 'path';
// ============================================================================
// Types
// ============================================================================
interface CliArgs {
file: string;
bookTitle: string;
bookAbbrev: string;
apiKey: string;
apiUrl: string;
paragraphLimit: number;
minParagraphSize: number;
checkHealth: boolean;
dryRun: boolean;
freshStart: boolean;
progressFile?: string;
}
interface Chapter {
number: number;
name: string;
text: string;
startPos: number;
}
interface Paragraph {
messageId: string;
chapterNumber: number;
chapterName: string;
paragraphNumber: number;
text: string;
}
interface ProgressFile {
book_title: string;
book_abbrev: string;
started_at: string;
last_updated: string;
total_chapters: number;
total_paragraphs: number;
paragraphs: Record<string, 'success' | 'failed'>;
}
// EverMind Cloud API request format
interface CloudMemorizeRequest {
message_id: string;
group_id: string;
group_name: string;
create_time: string;
role: string;
sender: string;
sender_name: string;
content: string;
refer_list: string[];
}
interface SaveResult {
success: boolean;
error?: string;
}
interface LoadingSummary {
chaptersProcessed: number;
totalParagraphs: number;
alreadyLoaded: number;
newlyLoaded: number;
failed: number;
failedParagraphs: Array<{ messageId: string; error: string }>;
}
// ============================================================================
// CLI Argument Parsing
// ============================================================================
function parseCliArgs(): CliArgs | null {
try {
const { values } = parseArgs({
options: {
file: { type: 'string' },
'book-title': { type: 'string' },
'book-abbrev': { type: 'string' },
'api-key': { type: 'string' },
'api-url': { type: 'string', default: 'https://api.evermind.ai' },
'paragraph-limit': { type: 'string', default: '10' },
'min-paragraph-size': { type: 'string', default: '200' },
'check-health': { type: 'boolean', default: false },
'dry-run': { type: 'boolean', default: false },
'fresh-start': { type: 'boolean', default: false },
'progress-file': { type: 'string' },
help: { type: 'boolean', default: false },
},
strict: true,
allowPositionals: false,
});
if (values.help) {
printHelp();
return null;
}
// Validate required arguments
if (!values.file || !values['book-title'] || !values['book-abbrev']) {
console.error('❌ Error: Missing required arguments\n');
printHelp();
process.exit(1);
}
// API key from argument or environment variable
const apiKey = values['api-key'] as string || process.env.EVERMIND_API_KEY || '';
if (!apiKey) {
console.error('❌ Error: API key required. Use --api-key or set EVERMIND_API_KEY environment variable\n');
printHelp();
process.exit(1);
}
return {
file: values.file as string,
bookTitle: values['book-title'] as string,
bookAbbrev: values['book-abbrev'] as string,
apiKey,
apiUrl: values['api-url'] as string,
paragraphLimit: parseInt(values['paragraph-limit'] as string, 10),
minParagraphSize: parseInt(values['min-paragraph-size'] as string, 10),
checkHealth: values['check-health'] as boolean,
dryRun: values['dry-run'] as boolean,
freshStart: values['fresh-start'] as boolean,
progressFile: values['progress-file'] as string | undefined,
};
} catch (error) {
console.error('❌ Error parsing arguments:', error instanceof Error ? error.message : String(error));
console.error('');
printHelp();
process.exit(1);
}
}
function printHelp(): void {
console.log(`
Novel Loading Script for EverMind Cloud API
Usage:
bun run load-novel-cloud --file <path> --book-title <title> --book-abbrev <abbrev> --api-key <key> [options]
Required Arguments:
--file <path> Path to novel text file
--book-title <title> Full book title (e.g., "A Game of Thrones")
--book-abbrev <abbrev> Book abbreviation for message IDs (e.g., "got")
--api-key <key> EverMind API key (or set EVERMIND_API_KEY env var)
Optional Arguments:
--api-url <url> EverMind API URL (default: https://api.evermind.ai)
--paragraph-limit <num> Maximum number of paragraphs to load (default: 10, use 0 for unlimited)
--min-paragraph-size <n> Minimum characters per paragraph, groups short ones (default: 200, use 0 to disable)
--check-health Check API health before loading
--dry-run Parse and show what would be loaded without actually loading
--fresh-start Ignore existing progress file and start from beginning
--progress-file <path> Custom progress file path (default: .novel-progress-cloud-{abbrev}.json)
--help Show this help message
Examples:
bun run load-novel-cloud --file got.txt --book-title "A Game of Thrones" --book-abbrev "got" --api-key YOUR_KEY
bun run load-novel-cloud --file got.txt --book-title "A Game of Thrones" --book-abbrev "got" --paragraph-limit 50
EVERMIND_API_KEY=your_key bun run load-novel-cloud --file got.txt --book-title "A Game of Thrones" --book-abbrev "got"
`);
}
// ============================================================================
// Chapter Detection
// ============================================================================
const CHAPTER_PATTERNS = [
/^PROLOGUE\s*$/m,
/^EPILOGUE\s*$/m,
/^([A-Z][A-Z\s]{2,})\s*$/m, // POV character names (EDDARD, JON, ARYA, etc.)
/^CHAPTER\s+(\d+)/im,
];
interface ChapterBoundary {
position: number;
name: string;
isPrologue: boolean;
isEpilogue: boolean;
}
function detectChapters(text: string): Chapter[] {
const boundaries: ChapterBoundary[] = [];
// Find all chapter boundaries
for (const pattern of CHAPTER_PATTERNS) {
const matches = text.matchAll(new RegExp(pattern, 'gm'));
for (const match of matches) {
const position = match.index!;
const matchedText = match[0].trim();
// Determine chapter name
let name = matchedText;
let isPrologue = false;
let isEpilogue = false;
if (matchedText === 'PROLOGUE') {
isPrologue = true;
name = 'Prologue';
} else if (matchedText === 'EPILOGUE') {
isEpilogue = true;
name = 'Epilogue';
} else if (match[1]) {
// Captured group from POV pattern or chapter number
name = toTitleCase(match[1].trim());
}
boundaries.push({ position, name, isPrologue, isEpilogue });
}
}
// Sort by position and remove duplicates
boundaries.sort((a, b) => a.position - b.position);
const uniqueBoundaries = boundaries.filter(
(boundary, index, arr) =>
index === 0 || boundary.position !== arr[index - 1].position
);
// Extract chapters
const chapters: Chapter[] = [];
let chapterNumber = 0;
for (let i = 0; i < uniqueBoundaries.length; i++) {
const boundary = uniqueBoundaries[i];
const nextBoundary = uniqueBoundaries[i + 1];
// Assign chapter number - always increment to ensure unique IDs
// (The file may contain multiple books, each with their own PROLOGUE/EPILOGUE)
chapterNumber++;
// Extract chapter text
const startPos = boundary.position;
const endPos = nextBoundary ? nextBoundary.position : text.length;
const chapterText = text.slice(startPos, endPos);
// Skip the chapter heading line itself
const firstNewline = chapterText.indexOf('\n');
const contentText = firstNewline !== -1 ? chapterText.slice(firstNewline + 1) : chapterText;
chapters.push({
number: chapterNumber,
name: boundary.name,
text: contentText.trim(),
startPos,
});
}
return chapters;
}
function toTitleCase(str: string): string {
return str
.toLowerCase()
.split(/\s+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
// ============================================================================
// Paragraph Splitting
// ============================================================================
function splitIntoParagraphs(
chapter: Chapter,
bookTitle: string,
bookAbbrev: string,
minParagraphSize: number = 0
): Paragraph[] {
// Split by double newlines (paragraph breaks)
const rawParagraphs = chapter.text.split(/\n\s*\n/);
const paragraphs: Paragraph[] = [];
let paragraphNumber = 1;
for (const rawText of rawParagraphs) {
const cleanText = rawText.trim();
// Skip empty paragraphs
if (!cleanText) {
continue;
}
const messageId = generateMessageId(bookAbbrev, chapter.number, paragraphNumber);
paragraphs.push({
messageId,
chapterNumber: chapter.number,
chapterName: chapter.name,
paragraphNumber,
text: cleanText,
});
paragraphNumber++;
}
// Group short paragraphs if minParagraphSize is set
if (minParagraphSize > 0) {
return groupShortParagraphs(paragraphs, minParagraphSize, bookAbbrev, chapter.number);
}
return paragraphs;
}
/**
* Group consecutive short paragraphs together until they reach minimum size
* This helps create more coherent memory chunks with better context
*/
function groupShortParagraphs(
paragraphs: Paragraph[],
minSize: number,
bookAbbrev: string,
chapterNum: number
): Paragraph[] {
if (paragraphs.length === 0) {
return paragraphs;
}
const grouped: Paragraph[] = [];
let currentGroup: Paragraph[] = [];
let currentSize = 0;
for (const paragraph of paragraphs) {
currentGroup.push(paragraph);
currentSize += paragraph.text.length;
// Check if we've reached the minimum size or this is the last paragraph
const isLastParagraph = paragraph === paragraphs[paragraphs.length - 1];
const reachedMinSize = currentSize >= minSize;
if (reachedMinSize || isLastParagraph) {
// Merge the current group into a single paragraph
if (currentGroup.length === 1) {
// No grouping needed
grouped.push(currentGroup[0]);
} else {
// Merge multiple paragraphs
const mergedText = currentGroup.map(p => p.text).join('\n\n');
const firstParagraphNum = currentGroup[0].paragraphNumber;
grouped.push({
messageId: generateMessageId(bookAbbrev, chapterNum, firstParagraphNum),
chapterNumber: currentGroup[0].chapterNumber,
chapterName: currentGroup[0].chapterName,
paragraphNumber: firstParagraphNum,
text: mergedText,
});
}
// Reset for next group
currentGroup = [];
currentSize = 0;
}
}
return grouped;
}
function generateMessageId(bookAbbrev: string, chapterNum: number, paragraphNum: number): string {
const chStr = chapterNum.toString().padStart(2, '0');
const pStr = paragraphNum.toString().padStart(3, '0');
return `asoiaf-${bookAbbrev}-ch${chStr}-p${pStr}`;
}
// ============================================================================
// Progress File Management
// ============================================================================
function getProgressFilePath(args: CliArgs): string {
if (args.progressFile) {
return resolve(args.progressFile);
}
return resolve(`.novel-progress-cloud-${args.bookAbbrev}.json`);
}
async function readProgressFile(filePath: string): Promise<ProgressFile | null> {
if (!existsSync(filePath)) {
return null;
}
try {
const content = await Bun.file(filePath).text();
return JSON.parse(content) as ProgressFile;
} catch (error) {
console.error(`⚠ Warning: Failed to read progress file: ${error}`);
return null;
}
}
async function writeProgressFile(filePath: string, progress: ProgressFile): Promise<void> {
try {
await Bun.write(filePath, JSON.stringify(progress, null, 2));
} catch (error) {
console.error(`⚠ Warning: Failed to write progress file: ${error}`);
}
}
async function updateProgressFile(
filePath: string,
messageId: string,
status: 'success' | 'failed',
progress: ProgressFile
): Promise<void> {
progress.paragraphs[messageId] = status;
progress.last_updated = new Date().toISOString();
await writeProgressFile(filePath, progress);
}
function initializeProgressFile(args: CliArgs, totalChapters: number, totalParagraphs: number): ProgressFile {
return {
book_title: args.bookTitle,
book_abbrev: args.bookAbbrev,
started_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
total_chapters: totalChapters,
total_paragraphs: totalParagraphs,
paragraphs: {},
};
}
// ============================================================================
// EverMind Cloud API Interaction
// ============================================================================
async function checkHealth(apiUrl: string, apiKey: string): Promise<boolean> {
try {
const response = await fetch(`${apiUrl}/health`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch (error) {
console.error(`❌ Health check failed: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
async function saveParagraphWithRetry(
paragraph: Paragraph,
bookTitle: string,
apiUrl: string,
apiKey: string,
maxRetries: number = 3
): Promise<SaveResult> {
// Create chapter metadata prefix
const chapterMetadata = `[${bookTitle} - Ch${paragraph.chapterNumber}: ${paragraph.chapterName}]`;
const content = `${chapterMetadata}\n\n${paragraph.text}`;
// EverMind Cloud API request format
const request: CloudMemorizeRequest = {
message_id: paragraph.messageId,
group_id: 'asoiaf',
group_name: 'A Song of Ice and Fire',
create_time: new Date().toISOString(),
role: 'assistant', // Using 'assistant' for narrator content
sender: 'asoiaf_narrator',
sender_name: 'Narrator',
content,
refer_list: [],
};
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(`${apiUrl}/api/v0/memories`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(30000), // 30 second timeout for cloud API
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
await response.json(); // Parse response to ensure it's valid
return { success: true };
} catch (error) {
const isLastAttempt = attempt === maxRetries;
const errorMsg = error instanceof Error ? error.message : String(error);
// Determine error type for better logging
const isTimeout = errorMsg.includes('timeout') || errorMsg.includes('abort');
const errorType = isTimeout ? 'timeout' : 'error';
if (isLastAttempt) {
return { success: false, error: errorMsg };
}
// Exponential backoff: 1s, 2s, 4s
const delayMs = Math.pow(2, attempt - 1) * 1000;
console.log(` ⚠ Retry ${attempt}/${maxRetries} (${errorType}) after ${delayMs}ms...`);
console.log(` Error: ${errorMsg}`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return { success: false, error: 'Max retries exceeded' };
}
// ============================================================================
// Main Loading Logic
// ============================================================================
async function loadNovel(args: CliArgs): Promise<void> {
const filePath = resolve(args.file);
// Check if file exists
if (!existsSync(filePath)) {
console.error(`❌ Error: File not found: ${filePath}`);
process.exit(1);
}
console.log('');
console.log('═'.repeat(60));
console.log('📚 EverMind Cloud - Novel Loading Script');
console.log('═'.repeat(60));
console.log(`API: ${args.apiUrl}`);
console.log(`Key: ${args.apiKey.slice(0, 8)}...${args.apiKey.slice(-4)}`);
console.log('');
// Health check if requested
if (args.checkHealth) {
console.log('🔍 Checking EverMind Cloud API...');
const isHealthy = await checkHealth(args.apiUrl, args.apiKey);
if (isHealthy) {
console.log('✓ EverMind Cloud API: OK\n');
} else {
console.error('❌ EverMind Cloud API is not available or API key is invalid.');
process.exit(1);
}
}
// Read novel file
console.log(`📖 Reading novel file: ${basename(filePath)}`);
const text = await Bun.file(filePath).text();
// Detect chapters
console.log('🔍 Detecting chapters...');
const chapters = detectChapters(text);
if (chapters.length === 0) {
console.error('❌ Error: No chapters detected in the file.');
console.error('Make sure the file contains chapter markers like:');
console.error(' - PROLOGUE');
console.error(' - Character names in ALL CAPS (e.g., EDDARD, JON)');
console.error(' - CHAPTER X');
process.exit(1);
}
console.log(`✓ Found ${chapters.length} chapters\n`);
// Split into paragraphs
const allParagraphs: Paragraph[] = [];
for (const chapter of chapters) {
const paragraphs = splitIntoParagraphs(chapter, args.bookTitle, args.bookAbbrev, args.minParagraphSize);
allParagraphs.push(...paragraphs);
}
console.log(`✓ Total paragraphs in novel: ${allParagraphs.length}`);
if (args.minParagraphSize > 0) {
console.log(`✓ Grouped short paragraphs (min size: ${args.minParagraphSize} chars)`);
}
// Apply paragraph limit
const paragraphsToLoad = args.paragraphLimit > 0
? allParagraphs.slice(0, args.paragraphLimit)
: allParagraphs;
if (args.paragraphLimit > 0 && allParagraphs.length > args.paragraphLimit) {
console.log(`⚠ Paragraph limit applied: loading first ${args.paragraphLimit} paragraphs\n`);
} else {
console.log('');
}
// Dry run mode
if (args.dryRun) {
console.log('🔎 DRY RUN MODE - Showing exact memories that would be saved:\n');
console.log('═'.repeat(80));
console.log(`Total paragraphs to load: ${paragraphsToLoad.length}\n`);
for (let i = 0; i < paragraphsToLoad.length; i++) {
const paragraph = paragraphsToLoad[i];
// Create the exact memory object that would be saved
const chapterMetadata = `[${args.bookTitle} - Ch${paragraph.chapterNumber}: ${paragraph.chapterName}]`;
const content = `${chapterMetadata}\n\n${paragraph.text}`;
const memoryObject: CloudMemorizeRequest = {
message_id: paragraph.messageId,
group_id: 'asoiaf',
group_name: 'A Song of Ice and Fire',
create_time: new Date().toISOString(),
role: 'assistant',
sender: 'asoiaf_narrator',
sender_name: 'Narrator',
content,
refer_list: [],
};
console.log(`\n[${i + 1}/${paragraphsToLoad.length}] Memory Object:`);
console.log('─'.repeat(80));
console.log(JSON.stringify(memoryObject, null, 2));
console.log('─'.repeat(80));
// Show a preview of the content for readability
const contentPreview = paragraph.text.slice(0, 150);
console.log(`Content preview: ${contentPreview}${paragraph.text.length > 150 ? '...' : ''}`);
console.log(`Content length: ${content.length} characters`);
}
console.log('\n' + '═'.repeat(80));
console.log(`\nSummary:`);
console.log(` Total chapters detected: ${chapters.length}`);
console.log(` Total paragraphs in novel: ${allParagraphs.length}`);
console.log(` Paragraphs to load: ${paragraphsToLoad.length}`);
console.log('\nRun without --dry-run to actually save these memories to EverMind Cloud.');
return;
}
// Initialize or load progress file
const progressFilePath = getProgressFilePath(args);
let progress: ProgressFile;
if (args.freshStart || !existsSync(progressFilePath)) {
if (args.freshStart && existsSync(progressFilePath)) {
console.log(`🗑️ Fresh start: Ignoring existing progress file\n`);
}
progress = initializeProgressFile(args, chapters.length, paragraphsToLoad.length);
await writeProgressFile(progressFilePath, progress);
console.log(`✓ Created progress file: ${basename(progressFilePath)}\n`);
} else {
const existingProgress = await readProgressFile(progressFilePath);
if (existingProgress) {
progress = existingProgress;
console.log(`✓ Resuming from existing progress file: ${basename(progressFilePath)}`);
const successCount = Object.values(progress.paragraphs).filter(s => s === 'success').length;
console.log(` Already loaded: ${successCount} paragraphs\n`);
} else {
progress = initializeProgressFile(args, chapters.length, paragraphsToLoad.length);
await writeProgressFile(progressFilePath, progress);
}
}
// Load paragraphs
const summary: LoadingSummary = {
chaptersProcessed: 0,
totalParagraphs: paragraphsToLoad.length,
alreadyLoaded: 0,
newlyLoaded: 0,
failed: 0,
failedParagraphs: [],
};
console.log('📚 Loading novel into EverMind Cloud...\n');
// Create a Set of message IDs to load for quick lookup
const messageIdsToLoad = new Set(paragraphsToLoad.map(p => p.messageId));
for (const chapter of chapters) {
const paragraphs = splitIntoParagraphs(chapter, args.bookTitle, args.bookAbbrev, args.minParagraphSize);
// Filter paragraphs to only those in our load list
const paragraphsInChapterToLoad = paragraphs.filter(p => messageIdsToLoad.has(p.messageId));
// Skip chapter if no paragraphs to load
if (paragraphsInChapterToLoad.length === 0) {
continue;
}
console.log(`Loading Chapter ${chapter.number}: ${chapter.name}`);
for (const paragraph of paragraphsInChapterToLoad) {
const existingStatus = progress.paragraphs[paragraph.messageId];
// Skip already loaded paragraphs
if (existingStatus === 'success') {
console.log(` ⊘ Skipping ${paragraph.messageId} (already loaded)`);
summary.alreadyLoaded++;
continue;
}
// Try to save
const result = await saveParagraphWithRetry(paragraph, args.bookTitle, args.apiUrl, args.apiKey);
// Update progress file immediately
await updateProgressFile(
progressFilePath,
paragraph.messageId,
result.success ? 'success' : 'failed',
progress
);
if (result.success) {
console.log(` ✓ Saved ${paragraph.messageId}`);
summary.newlyLoaded++;
} else {
console.log(` ✗ Failed ${paragraph.messageId}: ${result.error}`);
summary.failed++;
summary.failedParagraphs.push({
messageId: paragraph.messageId,
error: result.error || 'Unknown error',
});
}
}
summary.chaptersProcessed++;
console.log(''); // Empty line between chapters
}
// Print summary
console.log('═'.repeat(60));
console.log('📊 Loading Summary');
console.log('═'.repeat(60));
console.log(`Chapters processed: ${summary.chaptersProcessed}`);
console.log(`Total paragraphs: ${summary.totalParagraphs}`);
console.log(`Already loaded: ${summary.alreadyLoaded}`);
console.log(`Newly loaded: ${summary.newlyLoaded}`);
console.log(`Failed: ${summary.failed}`);
console.log('');
if (summary.failedParagraphs.length > 0) {
console.log('❌ Failed paragraphs (can retry by running script again):');
for (const failed of summary.failedParagraphs) {
console.log(` - ${failed.messageId}: ${failed.error}`);
}
console.log('');
}
console.log(`Progress saved to: ${basename(progressFilePath)}`);
if (summary.failed > 0) {
console.log('\n⚠ Some paragraphs failed to load. Run the script again to retry.');
process.exit(1);
} else {
console.log('\n✅ Novel loaded successfully to EverMind Cloud!');
}
}
// ============================================================================
// Entry Point
// ============================================================================
async function main() {
const args = parseCliArgs();
if (!args) {
return; // Help was shown or args were invalid
}
try {
await loadNovel(args);
} catch (error) {
console.error('\n❌ Unexpected error:', error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
main();

View File

@ -0,0 +1,28 @@
# OpenHer × EverCore Use Case
# Copy this file to .env and fill in your values
# ─── LLM Provider (pick one) ───
DEFAULT_PROVIDER=gemini
DEFAULT_MODEL=gemini-3.1-flash-lite-preview
# Gemini
GEMINI_API_KEY=your_gemini_api_key_here
# Claude (alternative)
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
# Qwen (alternative)
# DASHSCOPE_API_KEY=your_dashscope_api_key_here
# OpenAI (alternative)
# OPENAI_API_KEY=your_openai_api_key_here
# ─── EverCore Long-Term Memory ───
# Option A: EverCore Cloud
EVERMEMOS_BASE_URL=https://api.evermind.ai/v1
EVERMEMOS_API_KEY=your_evermemos_api_key_here
# Option B: Self-Hosted EverCore
# cd vendor/EverCore && docker compose up -d && uv run python src/run.py
# EVERMEMOS_BASE_URL=http://localhost:1995/api/v1

285
use-cases/openher/README.md Normal file
View File

@ -0,0 +1,285 @@
# OpenHer — Teaching AI to Remember Who You Are
Built on [EverCore](https://github.com/EverMind-AI/EverOS/tree/main/methods/EverCore) — Open-source AI memory infrastructure
**OpenHer** doesn't build chatbots. It doesn't build AI assistants. It builds **AI Beings** — entities with personality, emotion, and memory that *feel*, *remember*, and *grow* through every interaction.
**EverCore** is her long-term memory — the part that lets her carry your story across sessions, remember who you are, what you've talked about, and how your relationship has evolved.
Full Project: [github.com/kellyvv/OpenHer](https://github.com/kellyvv/OpenHer)
---
## Why Does She Need Memory?
Without memory, every conversation starts from zero. She doesn't know your name. She doesn't remember that three weeks ago you mentioned you drink your coffee black. She doesn't know you once had a fight and made up.
With EverCore:
**She remembers what you said.**
Three weeks ago you casually mentioned no sugar in your coffee. Today she says: "Americano, no sugar, right?"
**She gets to know you.**
The more you talk, the better she understands you. The her after one month is not the same her as day one.
**She has foresight.**
Last time you mentioned work stress. This time she asks: "How's that project going?"
> *She doesn't "look up" your information — she naturally recalls it.*
---
## Memory Architecture
OpenHer's memory has three layers. EverCore powers the deepest one:
| Layer | What it does | Analogy |
|:------|:-------------|:--------|
| **Style Memory** | Her behavioral habits — tone, expression patterns | Muscle memory |
| **Local Facts** | Your preferences, personal info | Short-term memory |
| **Long-Term Memory** | What happened between you, her understanding of you, her hunches | **Episodic memory (EverCore)** |
---
## How Memory Feeds Into Personality
OpenHer's core is a living neural network (25D input, 24D hidden, 8D behavioral signals). EverCore provides 4 key dimensions that let her tell the difference between a stranger and an old friend:
```
Relationship Depth 0 ─────────────────── 1
Stranger Old friend
Emotional Valence -1 ─────────────────── 1
Rocky history Warm history
Trust Level 0 ─────────────────── 1
First meeting Deep trust
Pending Foresight 0 ─────────────────── 1
Nothing unresolved Something on her mind
```
New users start at all zeros — a stranger. As conversations accumulate, these values grow naturally. The same conversation context produces completely different behavioral signals for strangers vs. old friends:
- With an old friend: warmer, more initiative, more willing to be vulnerable
- With a stranger: more reserved, more polite, keeps distance
This isn't a rule written in a prompt — it's emergent behavior computed by the neural network from the relationship vector.
---
## How She "Remembers"
Memory retrieval is async and two-stage — she never freezes up trying to recall:
```
Turn 1: You say "I love hiking"
\-- After you finish, background search for related memories fires
Turn 2: You say "What about this weekend?"
\-- Last turn's search results come back
Found: "User mentioned liking weekend hikes 3 weeks ago"
Naturally woven in: "The mountains should be nice this weekend"
\-- Simultaneously searching for "weekend plans" memories
Turn 3: ...continues...
```
If the search takes too long (>500ms), she doesn't stall — she keeps talking from what she already knows, like a person who can't quite place something but doesn't stop mid-sentence.
---
## What Happens Each Turn
```
User sends a message
|
v
Load memory -- First turn: load "who you are", "what we talked about",
| "what's on her mind" from EverCore
v
Perceive -- LLM evaluates the current moment: your emotion, topic
| intimacy, conflict level... (8 dimensions)
| + relationship dimensions from EverCore (4 dimensions) = 12D
v
Relationship evolves -- Blend EverCore history with this turn's changes
| Smoothed so a single remark can't flip the relationship
v
Neural network -- 25D input (drives + context + relationship + internal state)
| 24D hidden layer, 8D behavioral signals
| Decides how direct, warm, stubborn, curious she is right now
v
Recall -- Collect relevant memories found by last turn's search
| Blend into the response prompt
v
Respond -- Internal monologue first, then choose what to say and how
|
v
Remember this turn -- Store the conversation in EverCore (async, non-blocking)
|
v
Prepare for next -- Search for memories related to what you just said
```
---
## Core Capabilities
- **Emergent Personality** — Not written in a prompt. Emerges from random neural networks, 5D drives, and Hebbian learning
- **Emotional Thermodynamics** — Drives metabolize over real time. She gets lonely when you're away, irritated when ignored
- **Feel First** — Every response starts with an internal monologue before choosing words
- **Cross-Session Memory** — EverCore stores your shared story across every conversation
- **Relationship Evolution** — The relationship vector deepens naturally with each turn
- **Proactive Messages** — She reaches out not on a timer, but because her connection hunger is rising
- **Modal Expression** — She chooses text, voice, or photos based on what the moment calls for
- **10 Pre-built Personas** — Each with unique MBTI, drive baselines, and neural network seeds
## Tech Stack
| Layer | Technology |
|:------|:-----------|
| Runtime | Python 3.11+, FastAPI, WebSocket, asyncio |
| LLM | Gemini, Claude, Qwen3, GPT-5.4-mini, MiniMax, Moonshot, StepFun, Ollama |
| Memory | **EverCore** (self-hosted / cloud) + SQLite local state |
| Desktop | SwiftUI (macOS native) |
| Voice | DashScope, OpenAI, MiniMax |
---
## Quick Start
### Prerequisites
- Python 3.11+
- Any supported LLM provider API key
- EverCore (self-hosted or cloud)
### 1. Clone & Install
```bash
git clone https://github.com/kellyvv/OpenHer.git
cd OpenHer
bash setup.sh
```
### 2. Configure
```bash
cp .env.example .env
```
```bash
# LLM (pick one)
DEFAULT_PROVIDER=gemini
DEFAULT_MODEL=gemini-3.1-flash-lite-preview
GEMINI_API_KEY=your_key
# EverCore — Cloud
EVERMEMOS_BASE_URL=https://api.evermind.ai/v1
EVERMEMOS_API_KEY=your_key
# EverCore — Self-hosted
# cd vendor/EverCore && docker compose up -d && uv run python src/run.py
# EVERMEMOS_BASE_URL=http://localhost:1995/api/v1
```
### 3. Start
```bash
python main.py
# GenomeEngine loaded, 10 personas available
```
### 4. Try the Demo
```bash
python demo/evermemos_demo.py
# Runs in simulation mode even without EverCore
```
---
## Project Structure
```
OpenHer/
├── agent/
│ ├── chat_agent.py # Main agent, full lifecycle
│ ├── evermemos_mixin.py # EverCore integration (load/store/search/EMA)
│ └── prompt_builder.py # Memory injection into Actor prompt
├── engine/
│ └── genome/
│ ├── genome_engine.py # Neural network + 12D context (incl. 4D EverCore)
│ ├── critic.py # LLM perception: 8D context + relationship deltas
│ ├── drive_metabolism.py # Emotional thermodynamics
│ └── style_memory.py # KNN behavioral memory + Hawking radiation decay
├── memory/
│ ├── memory_store.py # SQLite FTS5 local memory
│ └── types.py # Memory & SessionContext types
├── persona/
│ └── personas/ # 10 pre-built personas (SOUL.md + seeds)
├── vendor/
│ └── EverCore/ # Self-hosted EverCore
└── main.py # FastAPI server
```
---
## Integration Code at a Glance
### EverCore Mixin
The core integration is a mixin class handling four async operations:
```python
class EverMemosMixin:
async def _evermemos_gather(self):
"""Load session context (first turn): who you are,
what we talked about, what's on her mind"""
def _apply_relationship_ema(self, prior, delta, depth):
"""Relationship evolution: blend history with this turn's changes"""
def _evermemos_store_bg(self, user_message, reply):
"""Remember this turn (async background, never blocks)"""
def _evermemos_search_bg(self, user_message):
"""Search related memories (preparing for next turn)"""
```
### SessionContext — Everything She Knows About You
```python
@dataclass
class SessionContext:
user_profile: str = "" # Who you are
episode_summary: str = "" # What happened between you
foresight_text: str = "" # What's on her mind
relationship_depth: float = 0.0 # Stranger to old friend
emotional_valence: float = 0.0 # Rocky history to warm history
trust_level: float = 0.0 # First meeting to deep trust
has_history: bool = False # Has she met you before?
```
---
## Without Memory vs. With Memory
| | Without EverCore | With EverCore |
|:--|:--|:--|
| First meeting | "Hi! I'm Luna" | "Hi! I'm Luna" |
| Second meeting | "Hi! I'm Luna" | "Hey Alex! How's that project going?" |
| You say you're tired | "Get some rest!" | "Working late again? You said that last time too... want me to order you an Americano? No sugar." |
> *Three weeks ago you casually mentioned no sugar in your coffee. Today: "Americano, no sugar, right?"*
---
## Links
- Full Project: [github.com/kellyvv/OpenHer](https://github.com/kellyvv/OpenHer)
- EverCore: [evermind.ai](https://evermind.ai)
## License
[Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0)

View File

@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""
OpenHer × EverCore Integration Demo
Demonstrates how EverCore provides long-term memory to the
AI Being persona engine. Shows session context loading, memory
storage, search, and relationship vector evolution.
Usage:
# With EverCore Cloud
export EVERMEMOS_BASE_URL=https://api.evermind.ai/v1
export EVERMEMOS_API_KEY=your_key
python demo/evermemos_demo.py
# With self-hosted EverCore
export EVERMEMOS_BASE_URL=http://localhost:1995/api/v1
python demo/evermemos_demo.py
"""
import asyncio
import os
import sys
import json
from datetime import datetime
from typing import Optional
# ──────────────────────────────────────────────
# EverCore Client (minimal standalone version)
# ──────────────────────────────────────────────
try:
import httpx
except ImportError:
print("❌ httpx not installed. Run: pip install httpx")
sys.exit(1)
class EverCoreClient:
"""Minimal EverCore client for demo purposes."""
def __init__(self, base_url: str, api_key: str = ""):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self._client = httpx.AsyncClient(timeout=10.0)
self.available = bool(base_url)
async def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.api_key:
h["Authorization"] = f"Bearer {self.api_key}"
return h
async def health_check(self) -> bool:
"""Check if EverCore is reachable."""
try:
# Try the health endpoint (remove /api/v1 suffix)
health_url = self.base_url.replace("/api/v1", "") + "/health"
resp = await self._client.get(health_url, headers=await self._headers())
return resp.status_code == 200
except Exception:
return False
async def store_turn(
self,
user_id: str,
persona_id: str,
persona_name: str,
user_name: str,
group_id: str,
user_message: str,
agent_reply: str,
) -> dict:
"""Store a conversation turn as memory."""
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S+00:00")
messages = [
{
"message_id": f"msg_{hash(user_message) & 0xFFFF:04x}_u",
"create_time": now,
"sender": user_id,
"sender_name": user_name,
"content": user_message,
},
{
"message_id": f"msg_{hash(agent_reply) & 0xFFFF:04x}_a",
"create_time": now,
"sender": persona_id,
"sender_name": persona_name,
"content": agent_reply,
},
]
resp = await self._client.post(
f"{self.base_url}/memories",
json={"messages": messages, "group_id": group_id},
headers=await self._headers(),
)
return resp.json() if resp.status_code == 200 else {"error": resp.text}
async def search(
self,
query: str,
user_id: str,
group_id: str,
top_k: int = 5,
) -> dict:
"""Search for relevant memories."""
resp = await self._client.get(
f"{self.base_url}/memories/search",
params={
"query": query,
"user_id": user_id,
"group_id": group_id,
"top_k": top_k,
"retrieve_method": "hybrid",
},
headers=await self._headers(),
)
return resp.json() if resp.status_code == 200 else {"error": resp.text}
async def get_user_profile(self, user_id: str) -> dict:
"""Get user profile (accumulated from conversations)."""
resp = await self._client.get(
f"{self.base_url}/users/{user_id}/profile",
headers=await self._headers(),
)
return resp.json() if resp.status_code == 200 else {}
async def close(self):
await self._client.aclose()
# ──────────────────────────────────────────────
# Relationship Vector (from EverCore session)
# ──────────────────────────────────────────────
def compute_relationship_vector(profile_data: dict) -> dict:
"""
Extract 4D relationship vector from EverCore profile data.
These 4 dimensions expand the persona engine's neural network
from 8D to 12D input, allowing it to differentiate behavior
between strangers and old friends.
"""
return {
"relationship_depth": min(1.0, profile_data.get("interaction_count", 0) / 50),
"emotional_valence": profile_data.get("sentiment_avg", 0.0),
"trust_level": min(1.0, profile_data.get("trust_score", 0.0)),
"pending_foresight": 1.0 if profile_data.get("foresight") else 0.0,
}
def apply_relationship_ema(
prior: dict,
delta: dict,
conversation_depth: float,
prev_ema: Optional[dict] = None,
) -> dict:
"""
Semi-emergent relationship update (Step 2.5 of ChatAgent lifecycle).
Blends EverCore prior with LLM-judged delta through EMA:
- alpha modulated by conversation depth (deeper = trust LLM more)
- Clips to valid ranges
- Preserves momentum through prev_ema
"""
if prev_ema is None:
prev_ema = dict(prior)
alpha = max(0.15, min(0.65, 0.15 + 0.5 * conversation_depth))
ema = {}
for k in prior:
lo = -1.0 if k == "emotional_valence" else 0.0
posterior = max(lo, min(1.0, prior[k] + delta.get(k, 0.0)))
prev = prev_ema.get(k, prior[k])
ema[k] = round(alpha * posterior + (1 - alpha) * prev, 4)
return ema
# ──────────────────────────────────────────────
# Demo
# ──────────────────────────────────────────────
async def main():
base_url = os.getenv("EVERMEMOS_BASE_URL", "")
api_key = os.getenv("EVERMEMOS_API_KEY", "")
if not base_url:
print("=" * 60)
print("OpenHer × EverCore Integration Demo")
print("=" * 60)
print()
print("⚠️ EVERMEMOS_BASE_URL not set.")
print()
print("To run this demo, set up EverCore:")
print()
print(" Option A — Cloud:")
print(" export EVERMEMOS_BASE_URL=https://api.evermind.ai/v1")
print(" export EVERMEMOS_API_KEY=your_key")
print()
print(" Option B — Self-hosted:")
print(" cd vendor/EverCore && docker compose up -d")
print(" uv run python src/run.py")
print(" export EVERMEMOS_BASE_URL=http://localhost:1995/api/v1")
print()
print("Get your API key: https://console.evermind.ai/")
print()
print("Running in simulation mode...\n")
await demo_simulation()
return
client = EverCoreClient(base_url, api_key)
print("=" * 60)
print("OpenHer × EverCore Integration Demo")
print("=" * 60)
print(f"\n📡 EverCore: {base_url}")
# Health check
healthy = await client.health_check()
if not healthy:
print("❌ EverCore is not reachable. Check your URL and try again.")
await client.close()
return
print("✅ EverCore is healthy\n")
# ── Demo conversation ──
user_id = "demo_user"
persona_id = "luna"
persona_name = "Luna (陆暖)"
user_name = "Demo User"
group_id = f"{persona_id}__{user_id}"
conversations = [
("My name is Alex, I'm a software engineer", "Nice to meet you Alex! What kind of software do you work on?"),
("I love hiking in the mountains on weekends", "That sounds wonderful! There's something about being up high that makes everything else feel small."),
("I drink my coffee black, no sugar", "Noted! A purist. I respect that."),
]
print("📝 Storing conversation memories...\n")
for user_msg, agent_reply in conversations:
result = await client.store_turn(
user_id=user_id,
persona_id=persona_id,
persona_name=persona_name,
user_name=user_name,
group_id=group_id,
user_message=user_msg,
agent_reply=agent_reply,
)
status = "" if "error" not in result else ""
print(f" {status} User: \"{user_msg[:50]}...\"")
# Wait for indexing
print("\n⏳ Waiting for memory indexing (3s)...")
await asyncio.sleep(3)
# Search
print("\n🔍 Searching for relevant memories...\n")
queries = [
"What does Alex like to do on weekends?",
"How does Alex take their coffee?",
"What is Alex's occupation?",
]
for query in queries:
result = await client.search(
query=query,
user_id=user_id,
group_id=group_id,
)
memories = result.get("result", {}).get("memories", [])
print(f" Q: \"{query}\"")
if memories:
for mem in memories[:2]:
content = str(mem)[:100]
print(f"{content}")
else:
print(" → (no results yet — indexing may still be in progress)")
print()
# Relationship vector
print("📊 Relationship Vector Evolution:\n")
prior = {"relationship_depth": 0.0, "emotional_valence": 0.0, "trust_level": 0.0, "pending_foresight": 0.0}
deltas = [
{"relationship_depth": 0.1, "emotional_valence": 0.2, "trust_level": 0.05},
{"relationship_depth": 0.05, "emotional_valence": 0.1, "trust_level": 0.1},
{"relationship_depth": 0.08, "emotional_valence": 0.15, "trust_level": 0.12},
]
ema = None
for i, delta in enumerate(deltas):
ema = apply_relationship_ema(prior, delta, conversation_depth=0.2 * (i + 1), prev_ema=ema)
print(f" Turn {i+1}: depth={ema['relationship_depth']:.3f} "
f"valence={ema['emotional_valence']:.3f} "
f"trust={ema['trust_level']:.3f}")
prior = ema
print(f"\n → After 3 turns: no longer a stranger (depth={ema['relationship_depth']:.3f})")
print(f" → Neural network now produces warmer, more familiar behavioral signals\n")
await client.close()
print("✅ Demo complete!")
async def demo_simulation():
"""Run demo in simulation mode (no EverCore connection)."""
print("📊 Simulating Relationship Vector Evolution:\n")
print(" This shows how the 4D EverCore relationship vector")
print(" deepens over multiple conversation turns.\n")
prior = {"relationship_depth": 0.0, "emotional_valence": 0.0, "trust_level": 0.0, "pending_foresight": 0.0}
# Simulate 10 turns of conversation
simulated_deltas = [
(0.3, {"relationship_depth": 0.10, "emotional_valence": 0.15, "trust_level": 0.05}),
(0.4, {"relationship_depth": 0.08, "emotional_valence": 0.10, "trust_level": 0.08}),
(0.5, {"relationship_depth": 0.05, "emotional_valence": 0.20, "trust_level": 0.12}),
(0.6, {"relationship_depth": 0.06, "emotional_valence": -0.10, "trust_level": 0.03}),
(0.7, {"relationship_depth": 0.04, "emotional_valence": 0.08, "trust_level": 0.10}),
(0.7, {"relationship_depth": 0.03, "emotional_valence": 0.12, "trust_level": 0.08}),
(0.8, {"relationship_depth": 0.02, "emotional_valence": 0.05, "trust_level": 0.06}),
(0.8, {"relationship_depth": 0.03, "emotional_valence": 0.10, "trust_level": 0.05}),
(0.9, {"relationship_depth": 0.01, "emotional_valence": 0.08, "trust_level": 0.04}),
(0.9, {"relationship_depth": 0.02, "emotional_valence": 0.06, "trust_level": 0.03}),
]
ema = None
for i, (depth, delta) in enumerate(simulated_deltas, 1):
alpha = max(0.15, min(0.65, 0.15 + 0.5 * depth))
ema = apply_relationship_ema(prior, delta, conversation_depth=depth, prev_ema=ema)
bar_d = "" * int(ema["relationship_depth"] * 20)
bar_v = "" * int(max(0, ema["emotional_valence"]) * 20)
bar_t = "" * int(ema["trust_level"] * 20)
print(f" Turn {i:2d} (α={alpha:.2f}): "
f"depth={ema['relationship_depth']:.3f} {bar_d}")
print(f" "
f"valence={ema['emotional_valence']:+.3f} {bar_v}")
print(f" "
f"trust={ema['trust_level']:.3f} {bar_t}")
print()
prior = ema
print(" ──────────────────────────────────")
print(f" Final state: depth={ema['relationship_depth']:.3f}, "
f"valence={ema['emotional_valence']:+.3f}, "
f"trust={ema['trust_level']:.3f}")
print()
print(" Turn 4 shows a negative emotional event (valence delta = -0.10),")
print(" but the EMA smoothing prevents overreaction — the relationship")
print(" continues to deepen because trust was already building.")
print()
print(" This vector feeds into the 25D neural network input,")
print(" producing different behavioral signals for strangers vs. friends:")
print(" - Higher warmth, vulnerability, and initiative for trusted users")
print(" - More guarded, formal signals for new users")
print()
print("✅ Simulation complete!")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,66 @@
"""
Neural network context features — showing how EverCore expands
the persona engine's perception from 8D to 12D.
The 4 additional relationship dimensions from EverCore allow the
neural network to produce different behavioral signals depending
on the history between user and persona.
Full source: https://github.com/kellyvv/OpenHer/blob/main/engine/genome/genome_engine.py
"""
# ══════════════════════════════════════════════
# 5D Drive System (internal motivation)
# ══════════════════════════════════════════════
DRIVES = ['connection', 'novelty', 'expression', 'safety', 'play']
# ══════════════════════════════════════════════
# 8D Behavioral Signals (neural network output)
# ══════════════════════════════════════════════
SIGNALS = [
'directness', # 0=indirect hints → 1=straight talk
'vulnerability', # 0=guarded → 1=emotionally open
'playfulness', # 0=serious → 1=playful/teasing
'initiative', # 0=reactive → 1=proactive leading
'depth', # 0=small talk → 1=deep conversation
'warmth', # 0=cold/distant → 1=warm/caring
'defiance', # 0=compliant → 1=rebellious/stubborn
'curiosity', # 0=indifferent → 1=intensely curious
]
# ══════════════════════════════════════════════
# 12D Context Features (neural network input)
# ══════════════════════════════════════════════
CONTEXT_FEATURES = [
# ── 8D from Critic LLM (per-turn perception) ──
'user_emotion', # -1=negative → 1=positive
'topic_intimacy', # 0=professional → 1=intimate
'time_of_day', # 0=morning → 1=late night
'conversation_depth', # 0=just started → 1=deep conversation
'user_engagement', # 0=dismissive → 1=invested
'conflict_level', # 0=harmonious → 1=conflict
'novelty_level', # 0=routine topic → 1=novel topic
'user_vulnerability', # 0=guarded → 1=open
# ── 4D from EverCore (cross-session relationship) ──
'relationship_depth', # 0=stranger → 1=old friend
'emotional_valence', # -1=negative history → 1=positive history
'trust_level', # 0=no trust → 1=deep trust
'pending_foresight', # 0=nothing pending → 1=unresolved concern
]
# Neural network dimensions
N_DRIVES = len(DRIVES) # 5
N_CONTEXT = len(CONTEXT_FEATURES) # 12 (8 + 4 from EverCore)
N_SIGNALS = len(SIGNALS) # 8
RECURRENT_SIZE = 8 # Internal "mood" state
INPUT_SIZE = N_DRIVES + N_CONTEXT + RECURRENT_SIZE # 5 + 12 + 8 = 25
HIDDEN_SIZE = 24
# Architecture: 25D input → 24D hidden (tanh) → 8D output (sigmoid)
# The 4 EverCore dimensions mean the same neural network produces
# DIFFERENT behavioral signals for strangers vs. old friends,
# even with identical conversation context.

View File

@ -0,0 +1,193 @@
"""
EverMemosMixin — EverCore integration for ChatAgent.
This mixin handles all async memory operations in the ChatAgent lifecycle:
Step 0: Session context loading (first turn)
Step 2.5: Relationship EMA (blend EverCore prior + LLM delta)
Step 8.5: Collect async search results
Step 11: Fire-and-forget turn storage
Step 12: Async prefetch for next turn
The mixin pattern keeps EverCore concerns cleanly separated from the
core persona engine (drives, metabolism, neural network, style memory).
Full source: https://github.com/kellyvv/OpenHer/blob/main/agent/evermemos_mixin.py
"""
from __future__ import annotations
import asyncio
class EverMemosMixin:
"""EverCore async memory integration methods."""
async def _evermemos_gather(self) -> dict:
"""
Step 0: Load EverCore session context (first turn only).
Subsequent turns reuse cached _session_ctx.
Returns relationship_4d dict for GenomeEngine context.
"""
empty_4d = {
'relationship_depth': 0.0,
'emotional_valence': 0.0,
'trust_level': 0.0,
'pending_foresight': 0.0,
}
if not (self.evermemos and self.evermemos.available):
return empty_4d
# Load once per session
if self._turn_count == 1:
self._session_ctx = await self.evermemos.load_session_context(
user_id=self.evermemos_uid,
persona_id=self.persona.persona_id,
group_id=self._group_id,
)
if self._session_ctx.user_profile:
self._user_profile = self._session_ctx.user_profile
if self._session_ctx.episode_summary:
self._episode_summary = self._session_ctx.episode_summary
# Cache foresight text for Actor injection
if self._session_ctx.foresight_text:
self._foresight_text = self._session_ctx.foresight_text
if not self._session_ctx:
return empty_4d
return self.evermemos.relationship_vector(self._session_ctx)
def _apply_relationship_ema(
self,
prior: dict,
rel_delta: dict,
conversation_depth: float,
) -> dict:
"""
Step 2.5: Semi-emergent relationship update.
Pattern: posterior = clip(prior + LLM_delta) → EMA smooth
alpha = clip(0.15 + 0.5 * depth, 0.15, 0.65)
state_t = alpha * posterior + (1 - alpha) * state_{t-1}
First turn initializes EMA state from prior, then applies delta normally.
"""
# Map Critic output keys → context feature keys
delta_map = {
'relationship_depth': rel_delta.get('relationship_delta', 0.0),
'emotional_valence': rel_delta.get('emotional_valence', 0.0),
'trust_level': rel_delta.get('trust_delta', 0.0),
'pending_foresight': 0.0, # No delta for foresight (data-driven only)
}
# Initialize EMA on first turn
if not self._relationship_ema:
self._relationship_ema = dict(prior)
# Compute posterior = clip(prior + delta)
posterior = {}
for k in prior:
lo = -1.0 if k == 'emotional_valence' else 0.0
posterior[k] = max(lo, min(1.0, prior[k] + delta_map.get(k, 0.0)))
# Depth-modulated alpha: shallow → trust prior, deep → trust LLM
alpha = max(0.15, min(0.65, 0.15 + 0.5 * conversation_depth))
# EMA smooth
ema = {}
for k in prior:
prev = self._relationship_ema.get(k, prior[k])
ema[k] = round(alpha * posterior[k] + (1 - alpha) * prev, 4)
self._relationship_ema = ema
return ema
def _evermemos_store_bg(self, user_message: str, reply: str) -> None:
"""Step 11: Fire-and-forget EverCore storage (asyncio.create_task)."""
if not (self.evermemos and self.evermemos.available):
return
async def _do_store():
try:
await self.evermemos.store_turn(
user_id=self.evermemos_uid,
persona_id=self.persona.persona_id,
persona_name=self.persona.name,
user_name=self.user_name or "用户",
group_id=self._group_id,
user_message=user_message,
agent_reply=reply,
)
except Exception as e:
print(f" [evermemos] ❌ store failed: {type(e).__name__}: {e}")
try:
asyncio.create_task(_do_store())
except Exception as e:
print(f" [evermemos] create_task error: {e}")
def _evermemos_search_bg(self, user_message: str) -> None:
"""
Step 12: Fire async RRF search for the current user_message.
Results are collected at Step 8.5 of the NEXT turn.
Cancels any pending search before starting a new one.
"""
if not (self.evermemos and self.evermemos.available):
return
if not self._session_ctx or not self._session_ctx.has_history:
return
# Cancel any orphaned previous search task
if self._search_task and not self._search_task.done():
self._search_task.cancel()
self._search_task = None
try:
self._search_turn_id = self._turn_count
self._search_task = asyncio.create_task(
self.evermemos.search_relevant_memories(
query=user_message,
user_id=self.evermemos_uid,
group_id=self._group_id,
)
)
except Exception as e:
print(f" [evermemos] search create_task error: {e}")
self._search_task = None
async def _collect_search_results(self) -> None:
"""
Collect previous turn's async search results (called at Step 8.5).
Validates turn_id to prevent concurrent mismatch.
Waits up to 0.5s; on timeout/error falls back to empty.
"""
if self._search_task is None:
return
# Concurrency guard: reject stale results from wrong turn
expected_turn = self._turn_count - 1
if self._search_turn_id != expected_turn:
self._search_task.cancel()
self._search_task = None
self._relevant_facts = ""
self._relevant_episodes = ""
self._relevant_profile = ""
return
try:
facts, episodes, profile = await asyncio.wait_for(
self._search_task, timeout=0.5
)
self._relevant_facts = facts
self._relevant_episodes = episodes
self._relevant_profile = profile
except asyncio.TimeoutError:
# Graceful degradation: use static session context
self._relevant_facts = ""
self._relevant_episodes = ""
self._relevant_profile = ""
except Exception:
self._relevant_facts = ""
self._relevant_episodes = ""
self._relevant_profile = ""
finally:
self._search_task = None

View File

@ -0,0 +1,66 @@
"""
Memory shared types for OpenHer.
These types bridge the two memory providers:
- SoulMem (behavioral memory, always-on SQLite layer)
- EverCore (declarative memory, cross-session persistence)
The SessionContext is the key data structure loaded from EverCore
at session start — it provides relationship priors, user profile,
episode summaries, and foresight data that expand the neural
network's perception from 8D to 12D.
Full source: https://github.com/kellyvv/OpenHer/blob/main/memory/types.py
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass
class Memory:
"""A single memory entry (SoulMem behavioral layer)."""
memory_id: int = 0
user_id: str = ""
persona_id: str = ""
content: str = ""
category: str = "conversation" # conversation | fact | event | preference
importance: float = 0.5
source_turn: int = 0
created_at: float = 0.0
@dataclass
class SessionContext:
"""
EverCore session context (declarative memory).
Loaded once at session start, this contains everything the
persona needs to know about the user from past sessions:
- user_profile: Who they are (name, preferences, occupation)
- episode_summary: What happened between us (narrative history)
- foresight_text: What we should pay attention to (unresolved topics)
- relationship_*: 4D vector feeding the neural network
- has_history: Whether there's prior interaction (gates search)
These values feed into the ChatAgent lifecycle at multiple steps:
- Step 0: Session context loaded
- Step 2: user_profile + episode_summary inject into Critic prompt
- Step 2.5: relationship_* feed EMA computation
- Step 5: 4D vector enters neural network as context features
- Step 8.5: Used as fallback when async search times out
"""
user_id: str = ""
persona_id: str = ""
user_profile: str = ""
episode_summary: str = ""
foresight_text: str = ""
relationship_depth: float = 0.0
emotional_valence: float = 0.0
trust_level: float = 0.0
pending_foresight: float = 0.0
has_history: bool = False
raw_data: Optional[dict] = None