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:
338
use-cases/README.md
Normal file
338
use-cases/README.md
Normal 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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||

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

|
||||
|
||||
#### 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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](https://github.com/TonyLiangDesign/MemoCare)
|
||||
|
||||
#### Alzheimer’s Memory Assistant
|
||||
|
||||
Empowering individuals with advanced memory support and daily assistance.
|
||||
|
||||
[Code](https://github.com/TonyLiangDesign/MemoCare)
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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">
|
||||
|
||||
[](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
|
||||

|
||||
```
|
||||
|
||||
### 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
|
||||
1101
use-cases/claude-code-plugin/README.md
Normal file
1101
use-cases/claude-code-plugin/README.md
Normal file
File diff suppressed because it is too large
Load Diff
937
use-cases/claude-code-plugin/assets/dashboard-preview.html
Normal file
937
use-cases/claude-code-plugin/assets/dashboard-preview.html
Normal file
@ -0,0 +1,937 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EverMem - Memory Hub (Preview)</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: rgba(13, 17, 23, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#header h1 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
#header .stats { font-size: 13px; color: #8b949e; margin-left: auto; }
|
||||
.preview-badge {
|
||||
background: #FFC53D22;
|
||||
color: #FFC53D;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@media (max-width: 900px) { .stats-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 600px) { .stats-grid { grid-template-columns: 1fr; } }
|
||||
.stat-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.stat-card .label { font-size: 12px; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.stat-card .value { font-size: 32px; font-weight: 600; color: #f0f6fc; }
|
||||
.stat-card .sub { font-size: 12px; color: #FFC53D; margin-top: 4px; }
|
||||
|
||||
/* Groups Section */
|
||||
.section-title { font-size: 16px; font-weight: 600; color: #f0f6fc; margin-bottom: 16px; }
|
||||
.groups-container { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Group Card */
|
||||
.group-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.group-header {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.group-header:hover { background: #1c2128; }
|
||||
.group-icon { font-size: 24px; }
|
||||
.group-info { flex: 1; min-width: 0; }
|
||||
.group-name { font-size: 15px; font-weight: 600; color: #f0f6fc; margin-bottom: 4px; }
|
||||
.group-path {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-id {
|
||||
font-size: 10px;
|
||||
color: #6e7681;
|
||||
font-family: monospace;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-id-label {
|
||||
color: #484f58;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.group-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.group-stats span { display: flex; align-items: center; gap: 4px; }
|
||||
.group-stats .count { color: #FFC53D; font-weight: 600; }
|
||||
.group-expand {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8b949e;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.group-card.expanded .group-expand { transform: rotate(180deg); }
|
||||
|
||||
/* Memories List */
|
||||
.memories-container {
|
||||
display: none;
|
||||
border-top: 1px solid #30363d;
|
||||
background: #0d1117;
|
||||
}
|
||||
.group-card.expanded .memories-container { display: block; }
|
||||
|
||||
/* Timeline within group */
|
||||
.group-timeline { padding: 16px 20px; }
|
||||
.timeline-day-section { margin-bottom: 20px; }
|
||||
.timeline-day-section:last-child { margin-bottom: 0; }
|
||||
.timeline-day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.timeline-day-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #FFC53D;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.timeline-day-dot.today { box-shadow: 0 0 8px rgba(255, 197, 61, 0.6); }
|
||||
.timeline-day-date { font-size: 13px; font-weight: 600; color: #f0f6fc; }
|
||||
.timeline-day-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
background: #FFC53D22;
|
||||
color: #FFC53D;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.timeline-day-count { font-size: 11px; color: #8b949e; margin-left: auto; }
|
||||
.timeline-memories { display: flex; flex-direction: column; gap: 8px; padding-left: 22px; }
|
||||
|
||||
.memory-item {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.memory-item:hover { border-color: #FFC53D; background: #161b22; }
|
||||
.memory-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.memory-emoji { font-size: 14px; }
|
||||
.memory-subject { font-size: 13px; font-weight: 500; color: #f0f6fc; flex: 1; }
|
||||
.memory-time { font-size: 11px; color: #8b949e; }
|
||||
.memory-preview { font-size: 12px; color: #8b949e; line-height: 1.5; max-height: 60px; overflow: hidden; }
|
||||
|
||||
.load-more {
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #21262d;
|
||||
}
|
||||
.load-more-btn {
|
||||
padding: 8px 16px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #c9d1d9;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.load-more-btn:hover { background: #30363d; }
|
||||
|
||||
/* Charts Row */
|
||||
.charts-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@media (max-width: 900px) { .charts-row { flex-direction: column; } }
|
||||
|
||||
/* Heatmap - GitHub style */
|
||||
.heatmap-section {
|
||||
flex: 2;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.heatmap-months {
|
||||
position: relative;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 36px;
|
||||
height: 16px;
|
||||
}
|
||||
.heatmap-months span { position: absolute; }
|
||||
.heatmap-body { display: flex; gap: 4px; }
|
||||
.heatmap-days {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
width: 32px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.heatmap-days span { height: 13px; line-height: 13px; }
|
||||
.heatmap-grid { display: flex; gap: 3px; }
|
||||
.heatmap-week { display: flex; flex-direction: column; gap: 3px; }
|
||||
.heatmap-cell {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.heatmap-cell:hover { transform: scale(1.3); z-index: 10; }
|
||||
.heatmap-cell.level-1 { background: #4d3a10; border-color: #4d3a10; }
|
||||
.heatmap-cell.level-2 { background: #806200; border-color: #806200; }
|
||||
.heatmap-cell.level-3 { background: #cc9a00; border-color: #cc9a00; }
|
||||
.heatmap-cell.level-4 { background: #FFC53D; border-color: #FFC53D; }
|
||||
.heatmap-tooltip {
|
||||
position: fixed;
|
||||
background: #1c2128;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #c9d1d9;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
display: none;
|
||||
}
|
||||
.heatmap-tooltip strong { color: #f0f6fc; }
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.heatmap-legend-cell { width: 12px; height: 12px; border-radius: 2px; }
|
||||
|
||||
/* Growth Chart */
|
||||
.growth-section {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.growth-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
height: 120px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.chart-bar-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.chart-bar-wrapper {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.chart-bar {
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, #e6b038, #FFC53D);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 4px;
|
||||
transition: height 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
.chart-bar:hover { filter: brightness(1.2); }
|
||||
.chart-bar .bar-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
.chart-bar:hover .bar-tooltip { opacity: 1; }
|
||||
.chart-label { font-size: 10px; color: #8b949e; margin-top: 8px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
.modal-overlay.visible { opacity: 1; visibility: visible; }
|
||||
.modal {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.modal-overlay.visible .modal { transform: scale(1); }
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.modal-header-content { flex: 1; }
|
||||
.modal-title { font-size: 16px; font-weight: 600; color: #f0f6fc; margin-bottom: 4px; }
|
||||
.modal-meta { font-size: 12px; color: #8b949e; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.modal-close:hover { color: #f0f6fc; background: #30363d; }
|
||||
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
|
||||
.modal-content { font-size: 14px; color: #c9d1d9; line-height: 1.7; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<h1>
|
||||
<span style="font-weight: 700;">EverMind</span> Memory Hub
|
||||
</h1>
|
||||
<span class="preview-badge">PREVIEW MODE</span>
|
||||
<span class="stats" id="stats">247 memories across 5 projects</span>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="modal-overlay" class="modal-overlay" onclick="closeModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="modal-header-content">
|
||||
<div class="modal-title" id="modal-title"></div>
|
||||
<div class="modal-meta" id="modal-meta"></div>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeModal()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-content" id="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heatmap-tooltip" id="heatmap-tooltip"></div>
|
||||
|
||||
<script>
|
||||
// ========== FAKE DATA ==========
|
||||
const fakeGroups = [
|
||||
{ id: 'grp_evermem_001', name: 'evermem-claude-code', path: '/Users/admin/Desktop/evermem-claude-code', sessionCount: 42 },
|
||||
{ id: 'grp_webapp_002', name: 'my-react-app', path: '/Users/admin/Projects/my-react-app', sessionCount: 28 },
|
||||
{ id: 'grp_api_003', name: 'backend-api', path: '/Users/admin/Projects/backend-api', sessionCount: 19 },
|
||||
{ id: 'grp_docs_004', name: 'documentation-site', path: '/Users/admin/Projects/documentation-site', sessionCount: 8 },
|
||||
{ id: 'grp_tools_005', name: 'dev-tools', path: '/Users/admin/Projects/dev-tools', sessionCount: 5 },
|
||||
];
|
||||
|
||||
const fakeMemoriesData = {
|
||||
'grp_evermem_001': generateFakeMemories('evermem-claude-code', 87, [
|
||||
{ subject: 'Fixed heatmap rendering bug', keywords: ['fix', 'debug'] },
|
||||
{ subject: 'Added session summary feature', keywords: ['add', 'create'] },
|
||||
{ subject: 'Refactored memory injection hooks', keywords: ['refactor'] },
|
||||
{ subject: 'Updated API endpoint to v0', keywords: ['update', 'api'] },
|
||||
{ subject: 'Debugged groups-store loading', keywords: ['debug', 'fix'] },
|
||||
{ subject: 'Created dashboard preview page', keywords: ['create', 'ui'] },
|
||||
{ subject: 'Optimized memory search performance', keywords: ['optimize'] },
|
||||
{ subject: 'Added error handling for API calls', keywords: ['add', 'error'] },
|
||||
{ subject: 'Fixed authentication flow', keywords: ['fix', 'auth'] },
|
||||
{ subject: 'Updated documentation', keywords: ['docs', 'update'] },
|
||||
]),
|
||||
'grp_webapp_002': generateFakeMemories('my-react-app', 72, [
|
||||
{ subject: 'Implemented user dashboard', keywords: ['create', 'ui'] },
|
||||
{ subject: 'Fixed navigation bug on mobile', keywords: ['fix', 'bug'] },
|
||||
{ subject: 'Added dark mode support', keywords: ['add', 'style'] },
|
||||
{ subject: 'Refactored state management', keywords: ['refactor'] },
|
||||
{ subject: 'Updated React to v18', keywords: ['update'] },
|
||||
{ subject: 'Created reusable button component', keywords: ['create', 'ui'] },
|
||||
{ subject: 'Fixed form validation errors', keywords: ['fix', 'error'] },
|
||||
{ subject: 'Added unit tests for auth', keywords: ['test', 'auth'] },
|
||||
]),
|
||||
'grp_api_003': generateFakeMemories('backend-api', 48, [
|
||||
{ subject: 'Created REST API endpoints', keywords: ['create', 'api'] },
|
||||
{ subject: 'Fixed database connection pool', keywords: ['fix', 'database'] },
|
||||
{ subject: 'Added rate limiting middleware', keywords: ['add', 'security'] },
|
||||
{ subject: 'Optimized query performance', keywords: ['optimize', 'database'] },
|
||||
{ subject: 'Implemented JWT authentication', keywords: ['auth', 'security'] },
|
||||
{ subject: 'Added API documentation', keywords: ['docs', 'api'] },
|
||||
]),
|
||||
'grp_docs_004': generateFakeMemories('documentation-site', 25, [
|
||||
{ subject: 'Created getting started guide', keywords: ['create', 'docs'] },
|
||||
{ subject: 'Updated API reference', keywords: ['update', 'docs', 'api'] },
|
||||
{ subject: 'Added code examples', keywords: ['add', 'docs'] },
|
||||
{ subject: 'Fixed broken links', keywords: ['fix'] },
|
||||
]),
|
||||
'grp_tools_005': generateFakeMemories('dev-tools', 15, [
|
||||
{ subject: 'Created CLI tool scaffold', keywords: ['create', 'build'] },
|
||||
{ subject: 'Added config file support', keywords: ['add', 'config'] },
|
||||
{ subject: 'Fixed path resolution bug', keywords: ['fix', 'bug'] },
|
||||
]),
|
||||
};
|
||||
|
||||
function generateFakeMemories(projectName, count, templates) {
|
||||
const memories = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const template = templates[i % templates.length];
|
||||
const daysAgo = Math.floor(Math.random() * 60);
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
date.setHours(Math.floor(Math.random() * 12) + 8, Math.floor(Math.random() * 60));
|
||||
|
||||
const contentVariations = [
|
||||
`Working on ${projectName}: ${template.subject}. Made progress on the implementation and tested the changes locally.`,
|
||||
`${template.subject} in ${projectName}. Updated related tests and verified everything works correctly.`,
|
||||
`Completed task: ${template.subject}. The changes have been verified and are ready for review.`,
|
||||
`${template.subject}. Investigated the issue, identified root cause, and implemented the fix.`,
|
||||
`Session focus: ${template.subject}. Collaborated with the team on the approach and finalized implementation.`,
|
||||
];
|
||||
|
||||
memories.push({
|
||||
id: `mem_${projectName}_${i}`,
|
||||
subject: template.subject + (i > templates.length ? ` (iteration ${Math.floor(i / templates.length) + 1})` : ''),
|
||||
content: contentVariations[i % contentVariations.length],
|
||||
timestamp: date.toISOString(),
|
||||
date: formatLocalDate(date),
|
||||
time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
keywords: template.keywords
|
||||
});
|
||||
}
|
||||
|
||||
return memories.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
}
|
||||
|
||||
function formatLocalDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ========== RENDERING ==========
|
||||
function renderDashboard() {
|
||||
const totalMemories = Object.values(fakeMemoriesData).reduce((sum, mems) => sum + mems.length, 0);
|
||||
const activeGroups = fakeGroups.length;
|
||||
|
||||
// Collect all memories for charts
|
||||
const allMemories = Object.values(fakeMemoriesData).flat();
|
||||
const memoriesByDate = {};
|
||||
allMemories.forEach(mem => {
|
||||
memoriesByDate[mem.date] = (memoriesByDate[mem.date] || 0) + 1;
|
||||
});
|
||||
|
||||
const activeDays = Object.keys(memoriesByDate).length;
|
||||
const avgPerDay = activeDays > 0 ? (totalMemories / activeDays).toFixed(1) : 0;
|
||||
|
||||
let html = `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Memories</div>
|
||||
<div class="value">${totalMemories}</div>
|
||||
<div class="sub">Across all projects</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Projects</div>
|
||||
<div class="value">${fakeGroups.length}</div>
|
||||
<div class="sub">${activeGroups} with memories</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Active Days</div>
|
||||
<div class="value">${activeDays}</div>
|
||||
<div class="sub">Days with memories</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Avg / Day</div>
|
||||
<div class="value">${avgPerDay}</div>
|
||||
<div class="sub">Memories per active day</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Avg / Project</div>
|
||||
<div class="value">${Math.round(totalMemories / activeGroups)}</div>
|
||||
<div class="sub">Memories per project</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-row">
|
||||
<div class="heatmap-section">
|
||||
<div class="section-title">Memory Activity (6 months)</div>
|
||||
<div class="heatmap-months" id="heatmap-months"></div>
|
||||
<div class="heatmap-body">
|
||||
<div class="heatmap-days">
|
||||
<span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span>
|
||||
</div>
|
||||
<div class="heatmap-grid" id="heatmap"></div>
|
||||
</div>
|
||||
<div class="heatmap-legend">
|
||||
<span>Less</span>
|
||||
<div class="heatmap-legend-cell" style="background: #161b22; border: 1px solid #21262d;"></div>
|
||||
<div class="heatmap-legend-cell" style="background: #4d3a10;"></div>
|
||||
<div class="heatmap-legend-cell" style="background: #806200;"></div>
|
||||
<div class="heatmap-legend-cell" style="background: #cc9a00;"></div>
|
||||
<div class="heatmap-legend-cell" style="background: #FFC53D;"></div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="growth-section">
|
||||
<div class="section-title">Last 7 Days</div>
|
||||
<div class="growth-chart" id="growth-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Your Projects</div>
|
||||
<div class="groups-container">
|
||||
`;
|
||||
|
||||
window._memoriesByDate = memoriesByDate;
|
||||
|
||||
// Sort groups by memory count
|
||||
const sortedGroups = [...fakeGroups].sort((a, b) => {
|
||||
const countA = fakeMemoriesData[a.id]?.length || 0;
|
||||
const countB = fakeMemoriesData[b.id]?.length || 0;
|
||||
return countB - countA;
|
||||
});
|
||||
|
||||
for (const group of sortedGroups) {
|
||||
const memories = fakeMemoriesData[group.id] || [];
|
||||
html += renderGroupCard(group, memories);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
document.getElementById('content').innerHTML = html;
|
||||
|
||||
buildHeatmap(memoriesByDate);
|
||||
buildGrowthChart(memoriesByDate);
|
||||
}
|
||||
|
||||
function renderGroupCard(group, memories) {
|
||||
const memCount = memories.length;
|
||||
|
||||
return `
|
||||
<div class="group-card" id="group-${encodeId(group.id)}" data-group-id="${escapeHtml(group.id)}">
|
||||
<div class="group-header" onclick="toggleGroup('${escapeHtml(group.id)}')">
|
||||
<span class="group-icon">📁</span>
|
||||
<div class="group-info">
|
||||
<div class="group-name">${escapeHtml(group.name)}</div>
|
||||
<div class="group-path">${escapeHtml(group.path)}</div>
|
||||
<div class="group-id"><span class="group-id-label">ID:</span>${escapeHtml(group.id)}</div>
|
||||
</div>
|
||||
<div class="group-stats">
|
||||
<span><span class="count">${memCount}</span> memories</span>
|
||||
<span>${group.sessionCount} sessions</span>
|
||||
</div>
|
||||
<div class="group-expand">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memories-container" id="memories-${encodeId(group.id)}">
|
||||
${renderMemoriesList(group.id, memories)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMemoriesList(groupId, memories) {
|
||||
if (memories.length === 0) {
|
||||
return '<div style="padding: 20px; text-align: center; color: #8b949e;">No memories yet</div>';
|
||||
}
|
||||
|
||||
// Group memories by date
|
||||
const byDate = {};
|
||||
memories.forEach(mem => {
|
||||
if (!byDate[mem.date]) byDate[mem.date] = [];
|
||||
byDate[mem.date].push(mem);
|
||||
});
|
||||
|
||||
const sortedDates = Object.keys(byDate).sort().reverse();
|
||||
const todayStr = formatLocalDate(new Date());
|
||||
|
||||
let html = '<div class="group-timeline">';
|
||||
|
||||
for (const date of sortedDates.slice(0, 5)) { // Show only first 5 days
|
||||
const dayMemories = byDate[date].sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
const isToday = date === todayStr;
|
||||
|
||||
const dateDisplay = new Date(date + 'T12:00:00').toLocaleDateString('en-US', {
|
||||
weekday: 'short', month: 'short', day: 'numeric'
|
||||
});
|
||||
|
||||
html += `
|
||||
<div class="timeline-day-section">
|
||||
<div class="timeline-day-header">
|
||||
<div class="timeline-day-dot${isToday ? ' today' : ''}"></div>
|
||||
<span class="timeline-day-date">${dateDisplay}</span>
|
||||
${isToday ? '<span class="timeline-day-badge">Today</span>' : ''}
|
||||
<span class="timeline-day-count">${dayMemories.length} ${dayMemories.length === 1 ? 'memory' : 'memories'}</span>
|
||||
</div>
|
||||
<div class="timeline-memories">
|
||||
`;
|
||||
|
||||
for (const mem of dayMemories.slice(0, 3)) { // Show max 3 per day
|
||||
const emoji = getEmojiForContent(mem.subject);
|
||||
const preview = mem.content.length > 120 ? mem.content.slice(0, 120) + '...' : mem.content;
|
||||
|
||||
html += `
|
||||
<div class="memory-item" onclick='openModal(${JSON.stringify(mem).replace(/'/g, "'")})'>
|
||||
<div class="memory-header">
|
||||
<span class="memory-emoji">${emoji}</span>
|
||||
<span class="memory-subject">${escapeHtml(mem.subject)}</span>
|
||||
<span class="memory-time">${mem.time}</span>
|
||||
</div>
|
||||
<div class="memory-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (sortedDates.length > 5) {
|
||||
html += `
|
||||
<div class="load-more">
|
||||
<button class="load-more-btn" onclick="alert('This is a preview - connect to API for full functionality')">
|
||||
Load more (${memories.length - 15} remaining)
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildHeatmap(memoriesByDate) {
|
||||
const heatmapContainer = document.getElementById('heatmap');
|
||||
const monthsContainer = document.getElementById('heatmap-months');
|
||||
if (!heatmapContainer) return;
|
||||
|
||||
const today = new Date();
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 182);
|
||||
while (startDate.getDay() !== 0) {
|
||||
startDate.setDate(startDate.getDate() - 1);
|
||||
}
|
||||
|
||||
const weeks = [];
|
||||
let currentWeek = [];
|
||||
const months = new Map();
|
||||
let currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= today) {
|
||||
const dateStr = formatLocalDate(currentDate);
|
||||
const monthKey = currentDate.toLocaleDateString('en-US', { month: 'short' });
|
||||
|
||||
if (!months.has(monthKey)) {
|
||||
months.set(monthKey, weeks.length);
|
||||
}
|
||||
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: memoriesByDate[dateStr] || 0,
|
||||
dayOfWeek: currentDate.getDay()
|
||||
});
|
||||
|
||||
if (currentDate.getDay() === 6) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
if (currentWeek.length > 0) {
|
||||
weeks.push(currentWeek);
|
||||
}
|
||||
|
||||
// Render month labels
|
||||
monthsContainer.innerHTML = '';
|
||||
const weekWidth = 16;
|
||||
months.forEach((weekIndex, monthName) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = monthName;
|
||||
span.style.left = `${weekIndex * weekWidth}px`;
|
||||
monthsContainer.appendChild(span);
|
||||
});
|
||||
|
||||
// Calculate thresholds
|
||||
const counts = Object.values(memoriesByDate);
|
||||
const maxCount = Math.max(...counts, 1);
|
||||
const q1 = Math.max(1, Math.ceil(maxCount * 0.25));
|
||||
const q2 = Math.max(2, Math.ceil(maxCount * 0.5));
|
||||
const q3 = Math.max(3, Math.ceil(maxCount * 0.75));
|
||||
|
||||
// Render weeks
|
||||
heatmapContainer.innerHTML = '';
|
||||
weeks.forEach((week, weekIndex) => {
|
||||
const weekDiv = document.createElement('div');
|
||||
weekDiv.className = 'heatmap-week';
|
||||
|
||||
if (weekIndex === 0 && week[0].dayOfWeek !== 0) {
|
||||
for (let i = 0; i < week[0].dayOfWeek; i++) {
|
||||
const emptyCell = document.createElement('div');
|
||||
emptyCell.className = 'heatmap-cell';
|
||||
emptyCell.style.visibility = 'hidden';
|
||||
weekDiv.appendChild(emptyCell);
|
||||
}
|
||||
}
|
||||
|
||||
week.forEach(day => {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'heatmap-cell';
|
||||
cell.dataset.date = day.date;
|
||||
cell.dataset.count = day.count;
|
||||
|
||||
if (day.count >= q3) cell.classList.add('level-4');
|
||||
else if (day.count >= q2) cell.classList.add('level-3');
|
||||
else if (day.count >= q1) cell.classList.add('level-2');
|
||||
else if (day.count >= 1) cell.classList.add('level-1');
|
||||
|
||||
cell.addEventListener('mouseenter', showHeatmapTooltip);
|
||||
cell.addEventListener('mouseleave', hideHeatmapTooltip);
|
||||
weekDiv.appendChild(cell);
|
||||
});
|
||||
|
||||
if (weekIndex === weeks.length - 1 && week[week.length - 1].dayOfWeek !== 6) {
|
||||
for (let i = week[week.length - 1].dayOfWeek + 1; i <= 6; i++) {
|
||||
const emptyCell = document.createElement('div');
|
||||
emptyCell.className = 'heatmap-cell';
|
||||
emptyCell.style.visibility = 'hidden';
|
||||
weekDiv.appendChild(emptyCell);
|
||||
}
|
||||
}
|
||||
|
||||
heatmapContainer.appendChild(weekDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function showHeatmapTooltip(e) {
|
||||
const tooltip = document.getElementById('heatmap-tooltip');
|
||||
const cell = e.target;
|
||||
const date = cell.dataset.date;
|
||||
const count = parseInt(cell.dataset.count);
|
||||
|
||||
const dateObj = new Date(date + 'T12:00:00');
|
||||
const formattedDate = dateObj.toLocaleDateString('en-US', {
|
||||
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric'
|
||||
});
|
||||
|
||||
tooltip.innerHTML = `<strong>${count} ${count === 1 ? 'memory' : 'memories'}</strong> on ${formattedDate}`;
|
||||
tooltip.style.display = 'block';
|
||||
|
||||
const rect = cell.getBoundingClientRect();
|
||||
tooltip.style.left = `${rect.left + rect.width / 2}px`;
|
||||
tooltip.style.top = `${rect.top - 40}px`;
|
||||
tooltip.style.transform = 'translateX(-50%)';
|
||||
}
|
||||
|
||||
function hideHeatmapTooltip() {
|
||||
document.getElementById('heatmap-tooltip').style.display = 'none';
|
||||
}
|
||||
|
||||
function buildGrowthChart(memoriesByDate) {
|
||||
const chartContainer = document.getElementById('growth-chart');
|
||||
if (!chartContainer) return;
|
||||
|
||||
const today = new Date();
|
||||
const dailyData = [];
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = formatLocalDate(date);
|
||||
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
dailyData.push({ date: dayName, count: memoriesByDate[dateStr] || 0 });
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...dailyData.map(d => d.count), 1);
|
||||
|
||||
chartContainer.innerHTML = '';
|
||||
dailyData.forEach(day => {
|
||||
const barContainer = document.createElement('div');
|
||||
barContainer.className = 'chart-bar-container';
|
||||
|
||||
const barWrapper = document.createElement('div');
|
||||
barWrapper.className = 'chart-bar-wrapper';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'chart-bar';
|
||||
bar.style.height = day.count > 0 ? `${(day.count / maxCount) * 100}%` : '4px';
|
||||
bar.style.opacity = day.count > 0 ? '1' : '0.2';
|
||||
|
||||
const barTooltip = document.createElement('div');
|
||||
barTooltip.className = 'bar-tooltip';
|
||||
barTooltip.textContent = `${day.count} memories`;
|
||||
bar.appendChild(barTooltip);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'chart-label';
|
||||
label.textContent = day.date;
|
||||
|
||||
barWrapper.appendChild(bar);
|
||||
barContainer.appendChild(barWrapper);
|
||||
barContainer.appendChild(label);
|
||||
chartContainer.appendChild(barContainer);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleGroup(groupId) {
|
||||
const card = document.getElementById(`group-${encodeId(groupId)}`);
|
||||
if (card) card.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
function openModal(memory) {
|
||||
const dateStr = new Date(memory.timestamp).toLocaleDateString('en-US', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
document.getElementById('modal-title').textContent = memory.subject || 'Memory';
|
||||
document.getElementById('modal-meta').textContent = dateStr;
|
||||
document.getElementById('modal-content').textContent = memory.content;
|
||||
|
||||
document.getElementById('modal-overlay').classList.add('visible');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
if (event && event.target !== event.currentTarget) return;
|
||||
document.getElementById('modal-overlay').classList.remove('visible');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
// Helpers
|
||||
function encodeId(str) {
|
||||
return btoa(str).replace(/[+/=]/g, c => ({ '+': '-', '/': '_', '=': '' }[c]));
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Emoji mapping
|
||||
const emojiKeywords = [
|
||||
['debug', '🐛'], ['bug', '🐛'], ['fix', '🔧'], ['error', '❌'],
|
||||
['test', '🧪'], ['add', '➕'], ['create', '✨'], ['new', '🆕'],
|
||||
['remove', '🗑️'], ['delete', '🗑️'], ['update', '✏️'], ['change', '📝'],
|
||||
['refactor', '♻️'], ['optimize', '⚡'], ['deploy', '🚀'], ['release', '📦'],
|
||||
['api', '🔌'], ['database', '🗄️'], ['auth', '🔐'], ['security', '🛡️'],
|
||||
['ui', '🎨'], ['style', '💅'], ['docs', '📖'], ['config', '⚙️'],
|
||||
['build', '🏗️'], ['merge', '🔀'], ['review', '👀'], ['commit', '📝'],
|
||||
];
|
||||
|
||||
function getEmojiForContent(text) {
|
||||
if (!text) return '💭';
|
||||
const lower = text.toLowerCase();
|
||||
for (const [keyword, emoji] of emojiKeywords) {
|
||||
if (lower.includes(keyword)) return emoji;
|
||||
}
|
||||
return '💭';
|
||||
}
|
||||
|
||||
// Initialize
|
||||
renderDashboard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1399
use-cases/claude-code-plugin/assets/dashboard.html
Normal file
1399
use-cases/claude-code-plugin/assets/dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
57
use-cases/claude-code-plugin/commands/ask.md
Normal file
57
use-cases/claude-code-plugin/commands/ask.md
Normal file
@ -0,0 +1,57 @@
|
||||
---
|
||||
description: Ask a question about past work. Searches memories and combines with current context to answer.
|
||||
arguments:
|
||||
- name: question
|
||||
description: The question to answer
|
||||
required: true
|
||||
---
|
||||
|
||||
# EverMem Ask
|
||||
|
||||
Answer a question using **both** memory search results **and** current conversation context.
|
||||
|
||||
## Question
|
||||
{{question}}
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Search memories** using `evermem_search` MCP tool with relevant keywords. Start with 10 results.
|
||||
|
||||
2. **Evaluate results**:
|
||||
- If memories provide useful context, note what you learned
|
||||
- If more detail needed, search again with different keywords (up to 3 searches)
|
||||
- If no relevant memories found, that's OK - proceed with what you know
|
||||
|
||||
3. **Combine sources** to answer:
|
||||
- Memory search results (past sessions)
|
||||
- Current conversation context (this session)
|
||||
- Your general knowledge (when applicable)
|
||||
|
||||
4. **Be honest about sources**:
|
||||
- "Based on our discussion on [date]..." - when citing memory
|
||||
- "From our current session..." - when citing current context
|
||||
- "I don't have any recorded information about this" - when memories don't help
|
||||
- "Based on general best practices..." - when using general knowledge
|
||||
|
||||
5. **Admit uncertainty**:
|
||||
- If memories are incomplete or unclear, say so
|
||||
- If you're inferring rather than recalling, make that clear
|
||||
- It's better to say "I don't know" than to guess
|
||||
|
||||
## Response Format
|
||||
|
||||
Start with a direct answer, then provide supporting context:
|
||||
|
||||
```
|
||||
[Direct answer to the question]
|
||||
|
||||
**From memories:**
|
||||
- [Relevant points from past sessions, with dates]
|
||||
|
||||
**Current context:**
|
||||
- [Relevant points from this session, if any]
|
||||
|
||||
**Note:** [Any caveats or gaps in knowledge]
|
||||
```
|
||||
|
||||
Now answer the user's question.
|
||||
59
use-cases/claude-code-plugin/commands/debug.md
Normal file
59
use-cases/claude-code-plugin/commands/debug.md
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
description: View EverMem debug logs to troubleshoot memory saving and retrieval issues
|
||||
---
|
||||
|
||||
# EverMem Debug Log Viewer
|
||||
|
||||
View the EverMem debug log to troubleshoot issues.
|
||||
|
||||
## Instructions
|
||||
|
||||
Show the user the recent debug log entries from `/tmp/evermem-debug.log`.
|
||||
|
||||
1. First check if debug mode is enabled by looking for `EVERMEM_DEBUG=1` in the plugin's `.env` file
|
||||
2. Read the last 50 lines of the debug log file
|
||||
3. If the file doesn't exist or is empty, inform the user how to enable debug mode
|
||||
|
||||
## Actions
|
||||
|
||||
1. Check debug mode status:
|
||||
```bash
|
||||
grep "EVERMEM_DEBUG" /path/to/plugin/.env 2>/dev/null || echo "Not configured"
|
||||
```
|
||||
|
||||
2. Show recent logs:
|
||||
```bash
|
||||
tail -50 /tmp/evermem-debug.log 2>/dev/null || echo "No debug log found"
|
||||
```
|
||||
|
||||
3. Format the output for the user, highlighting:
|
||||
- `[inject]` entries for memory retrieval
|
||||
- `[store]` entries for memory saving
|
||||
- Any errors or warnings
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
📋 EverMem Debug Log
|
||||
|
||||
Status: Debug mode [ENABLED/DISABLED]
|
||||
Log file: /tmp/evermem-debug.log
|
||||
|
||||
--- Recent Entries ---
|
||||
[timestamp] [inject] ...
|
||||
[timestamp] [store] ...
|
||||
|
||||
--- Tips ---
|
||||
• Enable debug: Add EVERMEM_DEBUG=1 to .env
|
||||
• Clear log: > /tmp/evermem-debug.log
|
||||
• Live view: tail -f /tmp/evermem-debug.log
|
||||
```
|
||||
|
||||
## Additional Options
|
||||
|
||||
If the user specifies arguments:
|
||||
- `clear` - Clear the debug log
|
||||
- `live` - Show command for live monitoring
|
||||
- `full` - Show more lines (100+)
|
||||
- `inject` - Filter to show only [inject] entries
|
||||
- `store` - Filter to show only [store] entries
|
||||
45
use-cases/claude-code-plugin/commands/help.md
Normal file
45
use-cases/claude-code-plugin/commands/help.md
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
description: Get help with EverMem plugin setup and available commands
|
||||
---
|
||||
|
||||
EverMem is a memory plugin for Claude Code that automatically stores and retrieves relevant context from your past coding sessions.
|
||||
|
||||
**How it works:**
|
||||
- When you chat with Claude, your conversations are automatically saved to EverMem Cloud
|
||||
- When you start a new session, relevant memories from past sessions are automatically injected into context
|
||||
- You can also manually search your memories using the `/evermem:search` command
|
||||
|
||||
First, check if the API key is configured:
|
||||
|
||||
```bash
|
||||
if [ -z "${EVERMEM_API_KEY:-}" ]; then
|
||||
echo "STATUS: Not configured"
|
||||
echo ""
|
||||
echo "To get started:"
|
||||
echo "1. Visit https://console.evermind.ai/ to get your API key"
|
||||
echo "2. Add to your shell config (~/.zshrc or ~/.bashrc):"
|
||||
echo " export EVERMEM_API_KEY=\"your_api_key_here\""
|
||||
echo "3. Restart Claude Code"
|
||||
else
|
||||
echo "STATUS: Configured"
|
||||
echo "API Key: ${EVERMEM_API_KEY:0:10}..."
|
||||
fi
|
||||
```
|
||||
|
||||
Present the configuration status to the user. If not configured, guide them through the setup steps.
|
||||
|
||||
**Available Commands:**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/evermem:help` | Show this help message |
|
||||
| `/evermem:search <query>` | Search your memories for specific topics |
|
||||
| `/evermem:hub` | Open the Memory Hub dashboard to visualize and explore memories |
|
||||
| `/evermem:debug` | View debug logs for troubleshooting |
|
||||
| `/evermem:projects` | View your Claude Code projects table |
|
||||
|
||||
**Automatic Features:**
|
||||
- **Memory Retrieved**: When you submit a prompt, relevant memories are automatically retrieved and shown
|
||||
- **Memory Save**: When Claude finishes responding, the conversation is automatically saved to EverMem Cloud
|
||||
|
||||
Share this information with the user in a clear, helpful format.
|
||||
21
use-cases/claude-code-plugin/commands/hub.md
Normal file
21
use-cases/claude-code-plugin/commands/hub.md
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
description: Open the EverMem Memory Hub to view statistics, search memories, and explore timeline
|
||||
---
|
||||
|
||||
When the user runs this command:
|
||||
|
||||
1. First, start the proxy server in the background using the Bash tool:
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/server/proxy.js" &
|
||||
```
|
||||
|
||||
2. Then, construct the Memory Hub URL with the actual API key using Bash:
|
||||
```bash
|
||||
echo "http://localhost:3456/?key=${EVERMEM_API_KEY}"
|
||||
```
|
||||
|
||||
3. Share a simple message with the user like:
|
||||
"Memory Hub server started. Open this URL to view your memories:
|
||||
[the URL from step 2]"
|
||||
|
||||
Do NOT show the bash commands or code blocks to the user. Just run them and share the final URL.
|
||||
71
use-cases/claude-code-plugin/commands/projects.md
Normal file
71
use-cases/claude-code-plugin/commands/projects.md
Normal file
@ -0,0 +1,71 @@
|
||||
---
|
||||
description: View your Claude Code projects tracked by EverMem
|
||||
---
|
||||
|
||||
# EverMem Projects
|
||||
View all Claude Code projects that have been tracked by EverMem.
|
||||
|
||||
## Instructions
|
||||
|
||||
Show the user their projects stored in the local groups.jsonl file.
|
||||
|
||||
1. Read the groups file from the plugin's data directory
|
||||
2. Aggregate entries by groupId (count sessions, find first/last seen)
|
||||
3. Display the project table with statistics
|
||||
4. If no groups file exists, explain that projects are tracked automatically
|
||||
|
||||
## Actions
|
||||
|
||||
Check and read the groups data file:
|
||||
|
||||
```bash
|
||||
GROUPS_FILE="${CLAUDE_PLUGIN_ROOT}/data/groups.jsonl"
|
||||
if [ -f "$GROUPS_FILE" ] && [ -s "$GROUPS_FILE" ]; then
|
||||
cat "$GROUPS_FILE"
|
||||
else
|
||||
echo "NO_GROUPS_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
**Note:** The file uses JSONL format (one JSON object per line). Each line is a session start event.
|
||||
|
||||
Entry format: `{"keyId":"...","groupId":"...","name":"...","path":"...","timestamp":"..."}`
|
||||
|
||||
- `keyId`: SHA-256 hash (first 12 chars) of the API key - associates projects with accounts
|
||||
- `groupId`: Short identifier (9 chars: project name prefix + path hash)
|
||||
|
||||
Aggregate by `keyId + groupId` when displaying:
|
||||
- Count occurrences = sessionCount
|
||||
- Earliest timestamp = firstSeen
|
||||
- Latest timestamp = lastSeen
|
||||
|
||||
## Output Format
|
||||
|
||||
If projects exist:
|
||||
```
|
||||
📁 Claude Code Projects
|
||||
|
||||
| Project | Group ID | Sessions | Last Active |
|
||||
|---------------------|------------|----------|-------------|
|
||||
| evermem-claude-code | ever8d8d5 | 42 | just now |
|
||||
| my-react-app | myrea1b2c3 | 12 | 2h ago |
|
||||
|
||||
Total: 2 projects
|
||||
```
|
||||
|
||||
If no projects file:
|
||||
```
|
||||
📁 Claude Code Projects
|
||||
|
||||
No projects tracked yet. Projects are automatically recorded when you start Claude Code sessions.
|
||||
|
||||
Each project directory creates a unique group ID for organizing memories.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Projects are identified by working directory path (hashed to 9-char ID)
|
||||
- Each project has its own memory namespace in EverMem Cloud
|
||||
- The groups.jsonl file is appended by the SessionStart hook
|
||||
- Same project used with different API keys will appear as separate entries
|
||||
- `keyId` is a SHA-256 hash (first 12 chars) of the API key - secure and unique
|
||||
89
use-cases/claude-code-plugin/commands/scripts/search-memories.js
Executable file
89
use-cases/claude-code-plugin/commands/scripts/search-memories.js
Executable file
@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Search memories from EverMem Cloud
|
||||
* Usage: node search-memories.js "query string"
|
||||
*/
|
||||
|
||||
import { getConfig, isConfigured } from '../../hooks/scripts/utils/config.js';
|
||||
import { searchMemories, transformSearchResults } from '../../hooks/scripts/utils/evermem-api.js';
|
||||
|
||||
const query = process.argv[2] || '';
|
||||
|
||||
if (!query) {
|
||||
console.log('Usage: /evermem:search <query>');
|
||||
console.log('Example: /evermem:search "how do we handle authentication"');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!isConfigured()) {
|
||||
console.log('Error: EVERMEM_API_KEY not configured');
|
||||
console.log('Set it with: export EVERMEM_API_KEY="your-key"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const config = getConfig();
|
||||
console.log('Searching EverMem Cloud...\n');
|
||||
console.log(`Query: "${query}"`);
|
||||
console.log(`User: ${config.userId}`);
|
||||
console.log(`Group: ${config.groupId}`);
|
||||
console.log('');
|
||||
|
||||
const apiResponse = await searchMemories(query, {
|
||||
topK: 10,
|
||||
retrieveMethod: 'hybrid'
|
||||
});
|
||||
|
||||
// Debug: show raw API response
|
||||
console.log('--- RAW API RESPONSE ---');
|
||||
console.log(JSON.stringify(apiResponse, null, 2));
|
||||
console.log('--- END RAW RESPONSE ---\n');
|
||||
|
||||
const memories = transformSearchResults(apiResponse);
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log('No memories found matching your query.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${memories.length} memories:`);
|
||||
console.log('='.repeat(70));
|
||||
|
||||
for (let i = 0; i < memories.length; i++) {
|
||||
const m = memories[i];
|
||||
const score = m.score ? `${(m.score * 100).toFixed(1)}%` : 'N/A';
|
||||
const date = new Date(m.timestamp).toLocaleDateString();
|
||||
const time = new Date(m.timestamp).toLocaleTimeString();
|
||||
|
||||
console.log('');
|
||||
console.log(`${i + 1}. [Score: ${score}] ${date} ${time}`);
|
||||
console.log('-'.repeat(70));
|
||||
|
||||
// Word wrap the content
|
||||
const words = m.text.split(' ');
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
if ((line + ' ' + word).length > 70) {
|
||||
console.log(line.trim());
|
||||
line = word;
|
||||
} else {
|
||||
line += ' ' + word;
|
||||
}
|
||||
}
|
||||
if (line.trim()) {
|
||||
console.log(line.trim());
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
17
use-cases/claude-code-plugin/commands/search.md
Normal file
17
use-cases/claude-code-plugin/commands/search.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Search EverMem for relevant memories from past sessions
|
||||
arguments:
|
||||
- name: query
|
||||
description: The search query to find relevant memories
|
||||
required: true
|
||||
---
|
||||
|
||||
Search EverMem Cloud for memories matching the user's query.
|
||||
|
||||
Run this command to search:
|
||||
|
||||
```bash
|
||||
node "${CLAUDE_PLUGIN_ROOT}/commands/scripts/search-memories.js" "$ARGUMENTS"
|
||||
```
|
||||
|
||||
After the search completes, summarize the key findings for the user. Highlight the most relevant memories and explain how they might be useful for their current work.
|
||||
104
use-cases/claude-code-plugin/data/mock-memories.json
Normal file
104
use-cases/claude-code-plugin/data/mock-memories.json
Normal file
@ -0,0 +1,104 @@
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"text": "We decided to use JWT tokens with 15-minute expiry for authentication. Refresh tokens are stored in httpOnly cookies to prevent XSS attacks. This was chosen over session-based auth for better scalability across our microservices.",
|
||||
"timestamp": "2026-01-15T08:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed the token refresh race condition bug. When multiple API calls detected an expired token simultaneously, they all tried to refresh, causing 401 cascades. Added a mutex lock around the refresh logic and a queue for pending requests.",
|
||||
"timestamp": "2026-01-14T15:45:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented rate limiting using Redis with a sliding window algorithm. Set to 100 requests per minute per user. Chose sliding window over fixed window to prevent burst attacks at window boundaries.",
|
||||
"timestamp": "2026-01-10T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Switched from MongoDB to PostgreSQL for the user service. The relational model better fits our data with complex joins. Using Prisma as the ORM for type safety.",
|
||||
"timestamp": "2026-01-02T14:20:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added retry logic with exponential backoff for all external API calls. Max 3 retries, starting at 100ms, doubling each time. Includes jitter to prevent thundering herd.",
|
||||
"timestamp": "2025-12-28T11:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Decided on GraphQL for the mobile API, REST for internal services. GraphQL reduces over-fetching for mobile clients with limited bandwidth. REST is simpler for service-to-service communication.",
|
||||
"timestamp": "2025-12-20T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed memory leak in the WebSocket connection handler. Connections weren't being cleaned up on client disconnect. Added proper event listeners for 'close' and 'error' events.",
|
||||
"timestamp": "2026-01-13T08:15:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented database connection pooling with a max of 20 connections per service instance. This resolved the connection exhaustion issues during traffic spikes.",
|
||||
"timestamp": "2025-12-15T10:45:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added request correlation IDs for distributed tracing. Every incoming request gets a UUID that propagates through all downstream service calls. Makes debugging much easier.",
|
||||
"timestamp": "2025-12-10T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Chose bcrypt with cost factor 12 for password hashing. Argon2 was considered but bcrypt has better library support across our stack. Cost factor 12 gives ~250ms hash time.",
|
||||
"timestamp": "2025-11-25T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented graceful shutdown for the API server. On SIGTERM, stop accepting new connections, wait for in-flight requests (max 30s), then exit. Prevents dropped requests during deploys.",
|
||||
"timestamp": "2025-12-05T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed the N+1 query problem in the orders endpoint. Was making a separate DB call for each order's items. Switched to a single query with JOIN and manual result mapping.",
|
||||
"timestamp": "2026-01-08T11:20:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added circuit breaker pattern for the payment service integration. Opens after 5 failures in 30 seconds, half-open after 60 seconds. Prevents cascade failures.",
|
||||
"timestamp": "2025-12-18T15:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Decided to use UUIDs for all public-facing IDs instead of auto-increment integers. Prevents enumeration attacks and makes sharding easier in the future.",
|
||||
"timestamp": "2025-11-15T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented API versioning via URL path (/v1/, /v2/). Header-based versioning was considered but URL is more explicit and easier to debug. Old versions sunset after 6 months.",
|
||||
"timestamp": "2025-11-20T16:45:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed timezone bug in date filtering. All dates now stored as UTC in database, converted to user's timezone only in the API response layer. Using date-fns-tz for conversions.",
|
||||
"timestamp": "2026-01-12T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added structured logging with JSON format. Each log entry includes timestamp, level, correlation_id, service_name, and message. Enables better log aggregation in Elasticsearch.",
|
||||
"timestamp": "2025-12-01T11:15:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented soft deletes for user data using a deleted_at timestamp. Required for GDPR compliance - we need to retain some data for audit purposes even after user requests deletion.",
|
||||
"timestamp": "2025-11-10T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Switched from REST polling to WebSockets for real-time notifications. Reduced server load significantly. Using Socket.io for automatic reconnection and room-based broadcasting.",
|
||||
"timestamp": "2025-12-22T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added input validation using Zod schemas. All API endpoints validate request body, query params, and path params. Returns 400 with detailed error messages on validation failure.",
|
||||
"timestamp": "2026-01-05T10:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented file upload using pre-signed S3 URLs. Client uploads directly to S3, then notifies our API with the object key. Keeps large files off our servers.",
|
||||
"timestamp": "2025-12-08T15:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Fixed race condition in inventory updates. Two concurrent orders could both see available stock and both succeed. Added optimistic locking with version numbers.",
|
||||
"timestamp": "2026-01-11T14:45:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Decided on feature flags using LaunchDarkly. Allows gradual rollouts and instant kill switches. All new features wrapped in flags by default.",
|
||||
"timestamp": "2025-11-28T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Added health check endpoint at /health that verifies database connectivity, Redis connectivity, and critical external service availability. Used by load balancer and Kubernetes probes.",
|
||||
"timestamp": "2025-12-12T16:30:00Z"
|
||||
},
|
||||
{
|
||||
"text": "Implemented request rate limiting per API key, not just per IP. Prevents abuse from authenticated users. Limits stored in Redis with sliding window counters.",
|
||||
"timestamp": "2026-01-03T11:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
52
use-cases/claude-code-plugin/hooks/hooks.json
Normal file
52
use-cases/claude-code-plugin/hooks/hooks.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-context-wrapper.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/inject-memories.js",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/store-memories.js",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-summary.js",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
239
use-cases/claude-code-plugin/hooks/scripts/inject-memories.js
Executable file
239
use-cases/claude-code-plugin/hooks/scripts/inject-memories.js
Executable file
@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Memory Plugin - UserPromptSubmit Hook
|
||||
*
|
||||
* This hook automatically injects relevant memories from past sessions ,
|
||||
* into Claude's context when the user submits a prompt.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Read prompt from stdin
|
||||
* 2. Skip if prompt is too short or API not configured
|
||||
* 3. Search EverMem Cloud for relevant memories
|
||||
* 4. Optionally filter with Claude SDK
|
||||
* 5. Display summary to user (via systemMessage)
|
||||
* 6. Inject context for Claude (via additionalContext)
|
||||
*/
|
||||
|
||||
import { isConfigured } from './utils/config.js';
|
||||
import { searchMemories, transformSearchResults } from './utils/evermem-api.js';
|
||||
import { formatRelativeTime } from './utils/mock-store.js';
|
||||
import { debug, setDebugPrefix } from './utils/debug.js';
|
||||
|
||||
// Set debug prefix for this script
|
||||
setDebugPrefix('inject');
|
||||
|
||||
const MIN_WORDS = 3;
|
||||
const MAX_MEMORIES = 5;
|
||||
const MIN_SCORE = 0.1; // Only show memories with relevance score above this threshold
|
||||
|
||||
/**
|
||||
* Count words/tokens in a string (multilingual support)
|
||||
* - For CJK (Chinese/Japanese/Korean): counts each character as a token
|
||||
* - For other languages: counts space-separated words
|
||||
* - For mixed text: counts both
|
||||
* @param {string} text
|
||||
* @returns {number}
|
||||
*/
|
||||
function countWords(text) {
|
||||
if (!text) return 0;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return 0;
|
||||
|
||||
// Regex for CJK characters (Chinese, Japanese Kanji, Korean Hanja)
|
||||
// Also includes Japanese Hiragana/Katakana and Korean Hangul
|
||||
const cjkRegex = /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/g;
|
||||
|
||||
// Count CJK characters
|
||||
const cjkMatches = trimmed.match(cjkRegex);
|
||||
const cjkCount = cjkMatches ? cjkMatches.length : 0;
|
||||
|
||||
// Remove CJK characters and count remaining space-separated words
|
||||
const nonCjkText = trimmed.replace(cjkRegex, ' ').trim();
|
||||
const wordCount = nonCjkText ? nonCjkText.split(/\s+/).filter(w => w.length > 0).length : 0;
|
||||
|
||||
return cjkCount + wordCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook handler
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
// Read stdin
|
||||
const input = await readStdin();
|
||||
const data = JSON.parse(input);
|
||||
const prompt = data.prompt || '';
|
||||
|
||||
debug('hookInput:', data);
|
||||
|
||||
// Set cwd from hook input for config.getGroupId()
|
||||
if (data.cwd) {
|
||||
process.env.EVERMEM_CWD = data.cwd;
|
||||
}
|
||||
|
||||
// Skip short prompts silently
|
||||
const wordCount = countWords(prompt);
|
||||
if (wordCount < MIN_WORDS) {
|
||||
debug('skipped: prompt too short', { wordCount, minWords: MIN_WORDS });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Skip if not configured (silent - don't nag users)
|
||||
if (!isConfigured()) {
|
||||
debug('skipped: not configured');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Search memories from EverMem Cloud
|
||||
let memories = [];
|
||||
let apiResponse = null;
|
||||
try {
|
||||
debug('searching memories for prompt:', prompt.slice(0, 100) + (prompt.length > 100 ? '...' : ''));
|
||||
apiResponse = await searchMemories(prompt, {
|
||||
topK: 15,
|
||||
retrieveMethod: 'hybrid'
|
||||
});
|
||||
memories = transformSearchResults(apiResponse);
|
||||
debug("memories:", memories);
|
||||
debug('search results:', { total: memories.length, memories: memories.map(m => ({ score: m.score, subject: m.subject })) });
|
||||
} catch (error) {
|
||||
// Silent on API errors - don't block user workflow
|
||||
debug('search error:', error.message);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Filter by minimum score threshold
|
||||
const relevantMemories = memories.filter(m => m.score >= MIN_SCORE);
|
||||
debug('filtered memories:', { total: relevantMemories.length, minScore: MIN_SCORE });
|
||||
|
||||
// No relevant memories above threshold - silently exit (this is normal)
|
||||
if (relevantMemories.length === 0) {
|
||||
debug('skipped: no relevant memories above threshold');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Take top memories
|
||||
const selectedMemories = relevantMemories.slice(0, MAX_MEMORIES);
|
||||
debug('selected memories:', selectedMemories.map(m => ({ score: m.score, subject: m.subject, timestamp: m.timestamp })));
|
||||
|
||||
// Build context for Claude
|
||||
const context = buildContext(selectedMemories);
|
||||
|
||||
// Build display message for user
|
||||
const displayMessage = buildDisplayMessage(selectedMemories);
|
||||
|
||||
// Output JSON with systemMessage (user display) and additionalContext (for Claude)
|
||||
const output = {
|
||||
systemMessage: displayMessage,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: context
|
||||
}
|
||||
};
|
||||
|
||||
debug('output:', { systemMessage: displayMessage, contextLength: context.length });
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
// Silent on errors - don't block user workflow
|
||||
debug('error:', error.message);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all stdin input
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
function readStdin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
resolve(data);
|
||||
});
|
||||
|
||||
process.stdin.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build display message for user (shown via systemMessage)
|
||||
* @param {Object[]} memories - Selected memories
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildDisplayMessage(memories) {
|
||||
const header = `📝 Memory Retrieved (${memories.length}):`;
|
||||
|
||||
const lines = [header];
|
||||
|
||||
for (const memory of memories) {
|
||||
const relTime = formatRelativeTime(memory.timestamp);
|
||||
const score = memory.score ? memory.score.toFixed(2) : '0.00';
|
||||
// Use subject as title if available, otherwise truncate text
|
||||
const title = memory.subject
|
||||
? memory.subject
|
||||
: (memory.text.length > 60 ? memory.text.slice(0, 60) + '...' : memory.text);
|
||||
lines.push(` • [${score}] (${relTime}) ${title}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context string for Claude
|
||||
* Memories are sorted by timestamp (most recent first) to prioritize recent context
|
||||
* @param {Object[]} memories - Selected memories
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildContext(memories) {
|
||||
const lines = [];
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
const sortedMemories = [...memories].sort((a, b) => {
|
||||
const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||||
const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
lines.push('<relevant-memories>');
|
||||
lines.push('The following memories from past sessions are relevant to the user\'s current task:');
|
||||
lines.push('');
|
||||
lines.push('IMPORTANT: Memories are ordered by recency (most recent first). When there are conflicts or updates between memories, prefer the MORE RECENT information as it likely reflects the latest decisions, code changes, or user preferences.');
|
||||
lines.push('');
|
||||
|
||||
for (const memory of sortedMemories) {
|
||||
// Format timestamp for context
|
||||
const timeStr = memory.timestamp
|
||||
? new Date(memory.timestamp).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC'
|
||||
}) + ' UTC'
|
||||
: 'Unknown time';
|
||||
|
||||
lines.push(`[${timeStr}]`);
|
||||
lines.push(memory.text);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('Use this context to inform your response. The user has already seen these memories displayed.');
|
||||
lines.push('</relevant-memories>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Run
|
||||
main();
|
||||
14
use-cases/claude-code-plugin/hooks/scripts/session-context-wrapper.sh
Executable file
14
use-cases/claude-code-plugin/hooks/scripts/session-context-wrapper.sh
Executable file
@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# EverMem SessionStart Hook Wrapper
|
||||
# Ensures npm dependencies are installed before running the hook
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Check if SDK is installed, if not install it silently
|
||||
if [ ! -d "$PLUGIN_ROOT/node_modules/@anthropic-ai/claude-agent-sdk" ]; then
|
||||
(cd "$PLUGIN_ROOT" && npm install --silent 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
# Run the actual hook script, passing stdin through
|
||||
exec node "$SCRIPT_DIR/session-context.js"
|
||||
257
use-cases/claude-code-plugin/hooks/scripts/session-context.js
Executable file
257
use-cases/claude-code-plugin/hooks/scripts/session-context.js
Executable file
@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* EverMem SessionStart Hook
|
||||
* Retrieves recent memories and displays last session summary
|
||||
* No AI summarization - uses local data only
|
||||
*/
|
||||
|
||||
// Check Node.js version early
|
||||
const nodeVersion = process.versions?.node;
|
||||
if (!nodeVersion) {
|
||||
console.error(JSON.stringify({
|
||||
continue: true,
|
||||
systemMessage: '⚠️ EverMem: Node.js environment not detected. Please install Node.js 18+ to use EverMem.'
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const [major] = nodeVersion.split('.').map(Number);
|
||||
if (major < 18) {
|
||||
console.error(JSON.stringify({
|
||||
continue: true,
|
||||
systemMessage: `⚠️ EverMem: Node.js ${nodeVersion} is too old. Please upgrade to Node.js 18+.`
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getMemories, transformGetMemoriesResults } from './utils/evermem-api.js';
|
||||
import { getConfig, getGroupId } from './utils/config.js';
|
||||
import { saveGroup } from './utils/groups-store.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SESSIONS_FILE = resolve(__dirname, '../../data/sessions.jsonl');
|
||||
|
||||
const RECENT_MEMORY_COUNT = 5; // Number of recent memories to load
|
||||
const PAGE_SIZE = 100; // Fetch more to get the latest (API returns old to new)
|
||||
|
||||
/**
|
||||
* Get the most recent session summary for current group
|
||||
* @param {string} groupId - The group ID to filter by
|
||||
* @returns {Object|null} Most recent session summary or null
|
||||
*/
|
||||
function getLastSessionSummary(groupId) {
|
||||
try {
|
||||
if (!existsSync(SESSIONS_FILE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(SESSIONS_FILE, 'utf8');
|
||||
const lines = content.trim().split('\n').filter(Boolean);
|
||||
|
||||
// Search from end (most recent first)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
if (entry.groupId === groupId) {
|
||||
return entry;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2h ago", "1d ago")
|
||||
*/
|
||||
function formatRelativeTime(isoTime) {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoTime).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
const hours = Math.floor(diffMs / 3600000);
|
||||
const days = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return `${Math.floor(days / 30)}mo ago`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Read hook input to get cwd
|
||||
let hookInput = {};
|
||||
try {
|
||||
let input = '';
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk;
|
||||
}
|
||||
if (input) {
|
||||
hookInput = JSON.parse(input);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log(JSON.stringify({
|
||||
continue: true,
|
||||
systemMessage: `⚠️ EverMem: Failed to parse hook input - ${parseError.message}`
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set cwd from hook input for config.getGroupId()
|
||||
if (hookInput.cwd) {
|
||||
process.env.EVERMEM_CWD = hookInput.cwd;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Save group to local storage (track which projects use EverMem)
|
||||
if (hookInput.cwd) {
|
||||
try {
|
||||
saveGroup(getGroupId(), hookInput.cwd);
|
||||
} catch (groupError) {
|
||||
// Non-blocking, but log for debugging
|
||||
console.error(`EverMem groups-store error: ${groupError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.isConfigured) {
|
||||
// Silently skip if not configured
|
||||
console.log(JSON.stringify({ continue: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const groupId = getGroupId();
|
||||
|
||||
// Fetch memories (API returns old to new, we'll reverse and take latest)
|
||||
const response = await getMemories({ pageSize: PAGE_SIZE });
|
||||
const memories = transformGetMemoriesResults(response);
|
||||
|
||||
// Get last session summary from local storage
|
||||
const lastSession = getLastSessionSummary(groupId);
|
||||
|
||||
if (memories.length === 0 && !lastSession) {
|
||||
// No memories and no last session, skip
|
||||
console.log(JSON.stringify({ continue: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Take the most recent memories
|
||||
const recentMemories = memories.slice(0, RECENT_MEMORY_COUNT);
|
||||
|
||||
// Build context message for Claude (no AI summarization)
|
||||
let contextParts = [];
|
||||
|
||||
// Add last session info if available
|
||||
if (lastSession) {
|
||||
const timeAgo = formatRelativeTime(lastSession.timestamp);
|
||||
contextParts.push(`Last session (${timeAgo}, ${lastSession.turnCount} turns): ${lastSession.summary}`);
|
||||
}
|
||||
|
||||
// Add recent memories if available
|
||||
if (recentMemories.length > 0) {
|
||||
const memoriesText = recentMemories.map((m, i) => {
|
||||
const date = new Date(m.timestamp).toLocaleDateString();
|
||||
return `[${i + 1}] (${date}) ${m.subject}\n${m.text}`;
|
||||
}).join('\n\n---\n\n');
|
||||
contextParts.push(`Recent memories (${recentMemories.length}):\n\n${memoriesText}`);
|
||||
}
|
||||
|
||||
const contextMessage = `<session-context>\n${contextParts.join('\n\n')}\n</session-context>`;
|
||||
|
||||
// Build display output - show meaningful content, concise but informative
|
||||
let displayOutput;
|
||||
if (lastSession) {
|
||||
// Show last session: time, turns, summary
|
||||
const truncatedSummary = lastSession.summary.length > 40
|
||||
? lastSession.summary.substring(0, 40) + '...'
|
||||
: lastSession.summary;
|
||||
const timeAgo = formatRelativeTime(lastSession.timestamp);
|
||||
displayOutput = `💡 EverMem: Last (${timeAgo}, ${lastSession.turnCount} turns): "${truncatedSummary}"`;
|
||||
|
||||
// Add memory preview if available
|
||||
if (recentMemories.length > 0) {
|
||||
const memorySubjects = recentMemories.slice(0, 2).map(m => {
|
||||
const subj = m.subject || '';
|
||||
return subj.length > 15 ? subj.substring(0, 15) + '..' : subj;
|
||||
}).join(', ');
|
||||
displayOutput += ` | ${recentMemories.length} memories: ${memorySubjects}`;
|
||||
}
|
||||
} else if (recentMemories.length > 0) {
|
||||
// No last session, show recent memories with subjects
|
||||
const memorySubjects = recentMemories.slice(0, 3).map(m => {
|
||||
const subj = m.subject || '';
|
||||
return subj.length > 20 ? subj.substring(0, 20) + '..' : subj;
|
||||
}).join(', ');
|
||||
displayOutput = `💡 EverMem: ${recentMemories.length} memories: ${memorySubjects}`;
|
||||
} else {
|
||||
displayOutput = `💡 EverMem: Ready`;
|
||||
}
|
||||
|
||||
// Output: display to user and add to context
|
||||
console.log(JSON.stringify({
|
||||
continue: true,
|
||||
systemMessage: displayOutput,
|
||||
systemPrompt: contextMessage
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
// Don't block session start on errors, but provide detailed error info
|
||||
const errorDetails = {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
name: error.name
|
||||
};
|
||||
|
||||
// Provide user-friendly error messages
|
||||
let userMessage = '⚠️ EverMem: ';
|
||||
if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
||||
userMessage += `Network error - cannot reach EverMem server. Check your internet connection.`;
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
userMessage += `Request timeout - EverMem server is slow or unreachable.`;
|
||||
} else if (error.message?.includes('401') || error.message?.includes('Unauthorized')) {
|
||||
userMessage += `Authentication failed. Check your EVERMEM_API_KEY in .env file.`;
|
||||
} else if (error.message?.includes('404')) {
|
||||
userMessage += `API endpoint not found. Check EVERMEM_BASE_URL in .env file.`;
|
||||
} else if (error.message?.includes('ENOENT')) {
|
||||
userMessage += `File not found: ${error.path || 'unknown'}`;
|
||||
} else {
|
||||
userMessage += `${error.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
continue: true,
|
||||
systemMessage: userMessage
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level error handler for uncaught exceptions during module load
|
||||
process.on('uncaughtException', (error) => {
|
||||
let userMessage = '⚠️ EverMem SessionStart failed: ';
|
||||
|
||||
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
||||
const moduleName = error.message.match(/Cannot find package '([^']+)'/)?.[1] || 'unknown';
|
||||
userMessage += `Missing dependency '${moduleName}'. Run: cd ${process.cwd()} && npm install`;
|
||||
} else if (error.code === 'ERR_REQUIRE_ESM') {
|
||||
userMessage += `Module format error. Ensure package.json has "type": "module"`;
|
||||
} else {
|
||||
userMessage += `${error.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
continue: true,
|
||||
systemMessage: userMessage
|
||||
}));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
main();
|
||||
200
use-cases/claude-code-plugin/hooks/scripts/session-summary.js
Executable file
200
use-cases/claude-code-plugin/hooks/scripts/session-summary.js
Executable file
@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* EverMem SessionEnd Hook
|
||||
* Saves session summary (first user prompt + stats) to local storage
|
||||
* No AI summarization - just extracts key info from transcript
|
||||
*/
|
||||
|
||||
import { readFileSync, appendFileSync, existsSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getGroupId, getConfig } from './utils/config.js';
|
||||
import { debug, setDebugPrefix } from './utils/debug.js';
|
||||
|
||||
setDebugPrefix('session-end');
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SESSIONS_FILE = resolve(__dirname, '../../data/sessions.jsonl');
|
||||
|
||||
/**
|
||||
* Read transcript and extract key content
|
||||
* @param {string} transcriptPath - Path to the transcript JSONL file
|
||||
* @returns {Object|null} Extracted content
|
||||
*/
|
||||
function extractTranscriptContent(transcriptPath) {
|
||||
try {
|
||||
if (!existsSync(transcriptPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(transcriptPath, 'utf8');
|
||||
const lines = content.trim().split('\n').filter(Boolean);
|
||||
|
||||
let firstUserPrompt = null;
|
||||
let lastUserPrompt = null;
|
||||
let turnCount = 0;
|
||||
let firstTimestamp = null;
|
||||
let lastTimestamp = null;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Track timestamps
|
||||
if (entry.timestamp) {
|
||||
if (!firstTimestamp) firstTimestamp = entry.timestamp;
|
||||
lastTimestamp = entry.timestamp;
|
||||
}
|
||||
|
||||
// Count turns
|
||||
if (entry.type === 'system' && entry.subtype === 'turn_duration') {
|
||||
turnCount++;
|
||||
}
|
||||
|
||||
// Extract user messages (not tool_result)
|
||||
if (entry.type === 'user' && entry.message?.role === 'user') {
|
||||
const msgContent = entry.message.content;
|
||||
if (typeof msgContent === 'string' && msgContent.trim()) {
|
||||
if (!firstUserPrompt) {
|
||||
firstUserPrompt = msgContent.trim();
|
||||
}
|
||||
lastUserPrompt = msgContent.trim();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
firstUserPrompt: firstUserPrompt?.substring(0, 200) || '',
|
||||
lastUserPrompt: lastUserPrompt?.substring(0, 200) || '',
|
||||
turnCount,
|
||||
firstTimestamp,
|
||||
lastTimestamp
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session summary to local JSONL file
|
||||
*/
|
||||
function saveSummary(entry) {
|
||||
try {
|
||||
appendFileSync(SESSIONS_FILE, JSON.stringify(entry) + '\n', 'utf8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session already has a summary
|
||||
*/
|
||||
function alreadySummarized(sessionId) {
|
||||
try {
|
||||
if (!existsSync(SESSIONS_FILE)) {
|
||||
return false;
|
||||
}
|
||||
const content = readFileSync(SESSIONS_FILE, 'utf8');
|
||||
return content.includes(`"sessionId":"${sessionId}"`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Read hook input
|
||||
let hookInput = {};
|
||||
try {
|
||||
let input = '';
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk;
|
||||
}
|
||||
if (input) {
|
||||
hookInput = JSON.parse(input);
|
||||
}
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { session_id, transcript_path, cwd, reason } = hookInput;
|
||||
|
||||
// Skip if no transcript or already summarized
|
||||
if (!transcript_path || !session_id) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const wasAlreadySummarized = alreadySummarized(session_id);
|
||||
|
||||
// Set cwd for config
|
||||
if (cwd) {
|
||||
process.env.EVERMEM_CWD = cwd;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.isConfigured) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Extract content from transcript
|
||||
const content = extractTranscriptContent(transcript_path);
|
||||
if (!content || content.turnCount === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Use first user prompt as summary (truncated)
|
||||
const summary = content.firstUserPrompt || 'Session with no text prompts';
|
||||
|
||||
// Calculate session duration
|
||||
let durationStr = '';
|
||||
if (content.firstTimestamp && content.lastTimestamp) {
|
||||
const durationMs = new Date(content.lastTimestamp) - new Date(content.firstTimestamp);
|
||||
const minutes = Math.floor(durationMs / 60000);
|
||||
if (minutes < 1) {
|
||||
durationStr = '<1min';
|
||||
} else if (minutes < 60) {
|
||||
durationStr = `${minutes}min`;
|
||||
} else {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainMins = minutes % 60;
|
||||
durationStr = remainMins > 0 ? `${hours}h${remainMins}m` : `${hours}h`;
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate summary for display
|
||||
const displaySummary = summary.length > 50
|
||||
? summary.substring(0, 50) + '...'
|
||||
: summary;
|
||||
|
||||
// Build output: turns, duration, summary
|
||||
const parts = [`${content.turnCount} turns`];
|
||||
if (durationStr) parts.push(durationStr);
|
||||
|
||||
// Save to local file (only if not already saved)
|
||||
if (!wasAlreadySummarized) {
|
||||
const entry = {
|
||||
sessionId: session_id,
|
||||
groupId: getGroupId(),
|
||||
summary,
|
||||
turnCount: content.turnCount,
|
||||
reason: reason || 'unknown',
|
||||
startTime: content.firstTimestamp,
|
||||
endTime: content.lastTimestamp,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
saveSummary(entry);
|
||||
}
|
||||
|
||||
// Always output session summary (whether saved or not)
|
||||
const message = `📝 Session (${parts.join(', ')}): "${displaySummary}"`;
|
||||
|
||||
// Log to unified debug file
|
||||
debug('output', message);
|
||||
|
||||
console.error(message); // Direct terminal output
|
||||
console.log(JSON.stringify({ systemMessage: message }));
|
||||
}
|
||||
|
||||
main().catch(() => process.exit(0));
|
||||
298
use-cases/claude-code-plugin/hooks/scripts/store-memories.js
Executable file
298
use-cases/claude-code-plugin/hooks/scripts/store-memories.js
Executable file
@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
process.on('uncaughtException', () => process.exit(0));
|
||||
process.on('unhandledRejection', () => process.exit(0));
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { isConfigured } from './utils/config.js'; // This loads .env
|
||||
import { addMemory } from './utils/evermem-api.js';
|
||||
import { debug, setDebugPrefix } from './utils/debug.js';
|
||||
|
||||
// Set debug prefix for this script
|
||||
setDebugPrefix('store');
|
||||
|
||||
try {
|
||||
let input = '';
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk;
|
||||
}
|
||||
|
||||
const hookInput = JSON.parse(input);
|
||||
debug('hookInput:', hookInput);
|
||||
const transcriptPath = hookInput.transcript_path;
|
||||
|
||||
// Set cwd from hook input for config.getGroupId()
|
||||
if (hookInput.cwd) {
|
||||
process.env.EVERMEM_CWD = hookInput.cwd;
|
||||
}
|
||||
|
||||
if (!transcriptPath || !existsSync(transcriptPath) || !isConfigured()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read transcript file with retry logic
|
||||
* Waits for turn_duration marker which indicates the turn is complete
|
||||
*/
|
||||
async function readTranscriptWithRetry(path, maxRetries = 5, delayMs = 100) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const content = readFileSync(path, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// Check if the last line is turn_duration (indicates turn is complete)
|
||||
let isComplete = false;
|
||||
try {
|
||||
const lastLine = JSON.parse(lines[lines.length - 1]);
|
||||
isComplete = lastLine.type === 'system' && lastLine.subtype === 'turn_duration';
|
||||
} catch {}
|
||||
|
||||
debug(`read attempt ${attempt}:`, {
|
||||
totalLines: lines.length,
|
||||
isComplete,
|
||||
lastLineType: (() => {
|
||||
try {
|
||||
const e = JSON.parse(lines[lines.length - 1]);
|
||||
return e.subtype ? `${e.type}/${e.subtype}` : e.type;
|
||||
} catch { return 'unknown'; }
|
||||
})()
|
||||
});
|
||||
|
||||
if (isComplete) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
debug(`turn not complete, waiting ${delayMs}ms before retry...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Return whatever we have after max retries
|
||||
debug('max retries reached, proceeding with current content');
|
||||
const content = readFileSync(path, 'utf8');
|
||||
return content.trim().split('\n');
|
||||
}
|
||||
|
||||
const lines = await readTranscriptWithRetry(transcriptPath);
|
||||
|
||||
// Debug: show last 3 lines of the file (just the type)
|
||||
debug('last 3 lines types:', lines.slice(-3).map((line, idx) => {
|
||||
try {
|
||||
const e = JSON.parse(line);
|
||||
return { index: lines.length - 3 + idx, type: e.type, subtype: e.subtype, hasContent: !!e.message?.content };
|
||||
} catch { return { index: lines.length - 3 + idx, error: 'parse failed' }; }
|
||||
}));
|
||||
|
||||
/**
|
||||
* Check if content is meaningful (not just whitespace/newlines)
|
||||
* @param {string} text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasContent(text) {
|
||||
return text && text.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last turn's user input and assistant response
|
||||
*
|
||||
* A Turn = User sends message → Claude responds (may include multiple tool calls)
|
||||
* Turn boundary is marked by: {"type":"system","subtype":"turn_duration"}
|
||||
*
|
||||
* User messages may be:
|
||||
* - Original input: {"type":"user","message":{"content":"string"}}
|
||||
* - Tool result: {"type":"user","message":{"content":[{"type":"tool_result",...}]}}
|
||||
*
|
||||
* Assistant messages may contain multiple content blocks:
|
||||
* - thinking: Claude's internal reasoning
|
||||
* - tool_use: Tool invocations
|
||||
* - text: Final response to user (this is what we want)
|
||||
*/
|
||||
function extractLastTurn(lines) {
|
||||
// IMPORTANT: When Stop hook runs, turn_duration for current turn hasn't been written yet.
|
||||
// The turn_duration marker is written AFTER the Stop hook completes.
|
||||
// So current turn END is always at the end of the file.
|
||||
const turnEndIndex = lines.length;
|
||||
|
||||
// Current turn START is right after the last turn_duration marker.
|
||||
// Only turn_duration marks turn boundaries (file-history-snapshot is NOT a turn boundary).
|
||||
// If no marker found, start from beginning of file.
|
||||
let turnStartIndex = 0;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const e = JSON.parse(lines[i]);
|
||||
if (e.type === 'system' && e.subtype === 'turn_duration') {
|
||||
turnStartIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
debug('turn range:', { turnStartIndex, turnEndIndex, totalLines: lines.length });
|
||||
|
||||
// Collect user and assistant content from the turn
|
||||
const userTexts = [];
|
||||
const assistantTexts = [];
|
||||
|
||||
// Debug: log each line's type in the turn
|
||||
const lineTypes = [];
|
||||
|
||||
for (let i = turnStartIndex; i < turnEndIndex; i++) {
|
||||
try {
|
||||
const e = JSON.parse(lines[i]);
|
||||
const content = e.message?.content;
|
||||
|
||||
// Debug: record line type
|
||||
const lineInfo = { index: i, type: e.type };
|
||||
if (e.type === 'assistant' && Array.isArray(content)) {
|
||||
lineInfo.contentTypes = content.map(b => b.type);
|
||||
}
|
||||
lineTypes.push(lineInfo);
|
||||
|
||||
if (e.type === 'user') {
|
||||
// User message - distinguish between original input and tool_result
|
||||
if (typeof content === 'string') {
|
||||
// Original user input (plain string)
|
||||
userTexts.push(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
// Check if it's a tool_result (skip) or text blocks (include)
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
userTexts.push(block.text);
|
||||
}
|
||||
// Skip tool_result - it's part of Claude's workflow, not user input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.type === 'assistant') {
|
||||
// Assistant message - extract text blocks only
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
assistantTexts.push(block.text);
|
||||
}
|
||||
// Skip: thinking (internal), tool_use (workflow)
|
||||
}
|
||||
} else if (typeof content === 'string') {
|
||||
assistantTexts.push(content);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Debug: output line types
|
||||
debug('line types in turn:', lineTypes);
|
||||
debug('assistantTexts count:', assistantTexts.length);
|
||||
|
||||
return {
|
||||
user: userTexts.join('\n\n'),
|
||||
assistant: assistantTexts.join('\n\n')
|
||||
};
|
||||
}
|
||||
|
||||
// Extract the last turn's content
|
||||
const lastTurn = extractLastTurn(lines);
|
||||
const lastUser = lastTurn.user;
|
||||
const lastAssistant = lastTurn.assistant;
|
||||
|
||||
debug('extracted:', {
|
||||
userLength: lastUser?.length || 0,
|
||||
assistantLength: lastAssistant?.length || 0,
|
||||
userPreview: lastUser?.slice(0, 100),
|
||||
assistantPreview: lastAssistant?.slice(0, 100)
|
||||
});
|
||||
|
||||
// Run both in parallel with Promise.all
|
||||
const promises = [];
|
||||
const results = [];
|
||||
const skipped = [];
|
||||
|
||||
// Check if user content is meaningful
|
||||
if (lastUser) {
|
||||
if (hasContent(lastUser)) {
|
||||
const len = lastUser.length;
|
||||
promises.push(
|
||||
addMemory({ content: lastUser, role: 'user', messageId: `u_${Date.now()}` })
|
||||
.then(r => results.push({ type: 'USER', len, ...r }))
|
||||
.catch(e => results.push({ type: 'USER', len, ok: false, error: e.message }))
|
||||
);
|
||||
} else {
|
||||
skipped.push({ type: 'USER', reason: 'whitespace-only content' });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if assistant content is meaningful
|
||||
if (lastAssistant) {
|
||||
if (hasContent(lastAssistant)) {
|
||||
const len = lastAssistant.length;
|
||||
promises.push(
|
||||
addMemory({ content: lastAssistant, role: 'assistant', messageId: `a_${Date.now()}` })
|
||||
.then(r => results.push({ type: 'ASSISTANT', len, ...r }))
|
||||
.catch(e => results.push({ type: 'ASSISTANT', len, ok: false, error: e.message }))
|
||||
);
|
||||
} else {
|
||||
skipped.push({ type: 'ASSISTANT', reason: 'whitespace-only content' });
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Check if all calls succeeded
|
||||
const allSuccess = results.length > 0 && results.every(r => r.ok && !r.error);
|
||||
|
||||
// Debug output
|
||||
debug('results:', results);
|
||||
debug('skipped:', skipped);
|
||||
|
||||
// Build output message
|
||||
let output = '';
|
||||
|
||||
if (allSuccess) {
|
||||
const details = results.map(r => `${r.type.toLowerCase()}: ${r.len}`).join(', ');
|
||||
output = `💾 Memory saved (${results.length}) [${details}]`;
|
||||
// Add skipped info if any
|
||||
if (skipped.length > 0) {
|
||||
output += `\n⏭️ Skipped: ${skipped.map(s => `${s.type} (${s.reason})`).join(', ')}`;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ systemMessage: output }));
|
||||
process.exit(0);
|
||||
} else if (results.length === 0 && skipped.length > 0) {
|
||||
// All content was skipped
|
||||
output = `⏭️ EverMem: No content to save\n`;
|
||||
for (const s of skipped) {
|
||||
output += ` • ${s.type}: ${s.reason}\n`;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ systemMessage: output }));
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Failure: show detailed errors via systemMessage
|
||||
function truncateBody(body) {
|
||||
if (!body) return body;
|
||||
const copy = { ...body };
|
||||
if (copy.content && typeof copy.content === 'string' && copy.content.length > 100) {
|
||||
copy.content = copy.content.substring(0, 100) + '... [truncated]';
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
output = '💾 EverMem: Save failed\n';
|
||||
for (const r of results) {
|
||||
if (r.error) {
|
||||
output += `${r.type}: ERROR - ${r.error}\n`;
|
||||
} else if (!r.ok) {
|
||||
output += `${r.type}: FAILED (${r.status})\n`;
|
||||
output += `Request: ${JSON.stringify(truncateBody(r.body), null, 2)}\n`;
|
||||
output += `Response: ${JSON.stringify(r.response, null, 2)}\n`;
|
||||
}
|
||||
}
|
||||
// Also show skipped if any
|
||||
if (skipped.length > 0) {
|
||||
output += `⏭️ Skipped: ${skipped.map(s => `${s.type} (${s.reason})`).join(', ')}\n`;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ systemMessage: output }));
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Silent on errors
|
||||
process.exit(0);
|
||||
}
|
||||
116
use-cases/claude-code-plugin/hooks/scripts/utils/config.js
Normal file
116
use-cases/claude-code-plugin/hooks/scripts/utils/config.js
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Configuration loader for EverMem plugin
|
||||
* Reads settings from .env file and environment variables
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
// Load .env file from plugin root
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = resolve(__dirname, '../../../.env');
|
||||
|
||||
if (existsSync(envPath)) {
|
||||
const envContent = readFileSync(envPath, 'utf8');
|
||||
for (const line of envContent.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const [key, ...valueParts] = trimmed.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
const value = valueParts.join('=').replace(/^["']|["']$/g, '');
|
||||
if (!process.env[key]) { // Don't override existing env vars
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE_URL = 'https://api.evermind.ai';
|
||||
|
||||
/**
|
||||
* Get the EverMem API key from environment
|
||||
* @returns {string|null} API key or null if not set
|
||||
*/
|
||||
export function getApiKey() {
|
||||
return process.env.EVERMEM_API_KEY || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user ID for memory operations
|
||||
* Defaults to 'claude-code-user' if not set
|
||||
* @returns {string} User ID
|
||||
*/
|
||||
export function getUserId() {
|
||||
return process.env.EVERMEM_USER_ID || 'claude-code-user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the group ID for memory operations
|
||||
* Uses project working directory as default group
|
||||
* Format: {project_name_prefix_4}{path_hash_5} = 9 chars max
|
||||
* @returns {string} Group ID
|
||||
*/
|
||||
export function getGroupId() {
|
||||
if (process.env.EVERMEM_GROUP_ID) {
|
||||
return process.env.EVERMEM_GROUP_ID;
|
||||
}
|
||||
// Use EVERMEM_CWD (set from hook input) or fall back to process.cwd()
|
||||
const cwd = process.env.EVERMEM_CWD || process.cwd();
|
||||
|
||||
// Extract project name (last part of path)
|
||||
const projectName = cwd.split('/').filter(Boolean).pop() || 'proj';
|
||||
// Take first 4 chars of project name (lowercase, alphanumeric only)
|
||||
const namePrefix = projectName.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 4) || 'proj';
|
||||
|
||||
// Hash the full path and take first 5 chars
|
||||
const pathHash = createHash('sha256').update(cwd).digest('hex').substring(0, 5);
|
||||
|
||||
// Combine: 4 chars name + 5 chars hash = 9 chars
|
||||
return `${namePrefix}${pathHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API base URL
|
||||
* @returns {string} Base URL
|
||||
*/
|
||||
export function getApiBaseUrl() {
|
||||
return process.env.EVERMEM_API_URL || API_BASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the plugin is properly configured
|
||||
* @returns {boolean} True if API key is set
|
||||
*/
|
||||
export function isConfigured() {
|
||||
return !!getApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a hashed identifier for the API key (for local storage association)
|
||||
* Uses SHA-256 hash, truncated to 12 characters for compactness
|
||||
* @returns {string|null} Key ID (first 12 chars of SHA-256 hash) or null if no API key
|
||||
*/
|
||||
export function getKeyId() {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
const hash = createHash('sha256').update(apiKey).digest('hex');
|
||||
return hash.substring(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full configuration object
|
||||
* @returns {Object} Configuration
|
||||
*/
|
||||
export function getConfig() {
|
||||
return {
|
||||
apiKey: getApiKey(),
|
||||
userId: getUserId(),
|
||||
groupId: getGroupId(),
|
||||
apiBaseUrl: getApiBaseUrl(),
|
||||
isConfigured: isConfigured()
|
||||
};
|
||||
}
|
||||
61
use-cases/claude-code-plugin/hooks/scripts/utils/debug.js
Normal file
61
use-cases/claude-code-plugin/hooks/scripts/utils/debug.js
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Shared debug utility for EverMem hooks
|
||||
*
|
||||
* Usage:
|
||||
* import { debug, setDebugPrefix } from './utils/debug.js';
|
||||
* setDebugPrefix('inject'); // Optional: add prefix to log lines
|
||||
* debug('hookInput:', data);
|
||||
*
|
||||
* Enable by setting EVERMEM_DEBUG=1 in .env file or environment
|
||||
* Logs are written to /tmp/evermem-debug.log
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'fs';
|
||||
import { isConfigured } from './config.js'; // This loads .env
|
||||
|
||||
const DEBUG_LOG_PATH = '/tmp/evermem-debug.log';
|
||||
|
||||
// Check debug flag (after config.js loads .env)
|
||||
const DEBUG = process.env.EVERMEM_DEBUG === '1';
|
||||
|
||||
// Optional prefix for log lines (e.g., 'inject' or 'store')
|
||||
let debugPrefix = '';
|
||||
|
||||
/**
|
||||
* Set a prefix for debug log lines
|
||||
* @param {string} prefix - Prefix to add (e.g., 'inject', 'store')
|
||||
*/
|
||||
export function setDebugPrefix(prefix) {
|
||||
debugPrefix = prefix ? `[${prefix}] ` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write debug message to log file
|
||||
* Only writes when EVERMEM_DEBUG=1
|
||||
*
|
||||
* @param {...any} args - Arguments to log (objects are JSON stringified)
|
||||
*/
|
||||
export function debug(...args) {
|
||||
if (!DEBUG) return;
|
||||
|
||||
const msg = args.map(a =>
|
||||
typeof a === 'object' ? JSON.stringify(a, null, 2) : a
|
||||
).join(' ');
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const line = `[${timestamp}] ${debugPrefix}${msg}\n`;
|
||||
|
||||
try {
|
||||
appendFileSync(DEBUG_LOG_PATH, line);
|
||||
} catch (e) {
|
||||
// Silent on write errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDebugEnabled() {
|
||||
return DEBUG;
|
||||
}
|
||||
280
use-cases/claude-code-plugin/hooks/scripts/utils/evermem-api.js
Normal file
280
use-cases/claude-code-plugin/hooks/scripts/utils/evermem-api.js
Normal file
@ -0,0 +1,280 @@
|
||||
/**
|
||||
* EverMem Cloud API client
|
||||
* Handles memory search and storage operations
|
||||
*/
|
||||
|
||||
import { getConfig } from './config.js';
|
||||
import { debug, setDebugPrefix } from './debug.js';
|
||||
|
||||
// Set debug prefix for this script
|
||||
setDebugPrefix('EverMemAPI');
|
||||
const TIMEOUT_MS = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Search memories from EverMem Cloud (v1)
|
||||
* @param {string} query - Search query text
|
||||
* @param {Object} options - Additional options
|
||||
* @param {number} options.topK - Max results (default: 10)
|
||||
* @param {string} options.retrieveMethod - Search method: keyword|vector|hybrid|agentic (default: 'hybrid')
|
||||
* @param {string[]} options.memoryTypes - Memory types (default: ['episodic_memory'])
|
||||
* @returns {Promise<Object>} Raw API response with _debug envelope
|
||||
*/
|
||||
export async function searchMemories(query, options = {}) {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.isConfigured) {
|
||||
throw new Error('EverMem API key not configured');
|
||||
}
|
||||
|
||||
const {
|
||||
topK = 10,
|
||||
retrieveMethod = 'hybrid',
|
||||
memoryTypes = ['episodic_memory']
|
||||
} = options;
|
||||
|
||||
const url = `${config.apiBaseUrl}/api/v1/memories/search`;
|
||||
const filters = config.groupId
|
||||
? { group_id: config.groupId }
|
||||
: { user_id: config.userId };
|
||||
|
||||
const requestBody = {
|
||||
query,
|
||||
method: retrieveMethod,
|
||||
top_k: topK,
|
||||
memory_types: memoryTypes,
|
||||
filters
|
||||
};
|
||||
|
||||
debug('searchMemories request body', requestBody);
|
||||
|
||||
const debugEnvelope = {
|
||||
url,
|
||||
requestBody,
|
||||
apiKeyMasked: 'API_KEY_HIDDEN'
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return { _debug: { ...debugEnvelope, status: response.status, rawBody: text, error: 'non-JSON response' } };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { _debug: { ...debugEnvelope, status: response.status, error: data } };
|
||||
}
|
||||
|
||||
data._debug = debugEnvelope;
|
||||
return data;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error(`API timeout after ${TIMEOUT_MS}ms`);
|
||||
}
|
||||
return { _debug: { ...debugEnvelope, error: error.message } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform v1 search API response to plugin memory format.
|
||||
* v1 returns: { data: { episodes: [{ id, user_id, session_id, timestamp, summary, subject, score, participants, group_id? }], ... } }
|
||||
* @param {Object} apiResponse - Raw v1 API response
|
||||
* @returns {Object[]} Formatted memories sorted by score desc
|
||||
*/
|
||||
export function transformSearchResults(apiResponse) {
|
||||
const episodes = apiResponse?.data?.episodes;
|
||||
if (!Array.isArray(episodes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const memories = [];
|
||||
for (const ep of episodes) {
|
||||
const content = ep.summary || '';
|
||||
if (!content) continue;
|
||||
|
||||
memories.push({
|
||||
text: content,
|
||||
subject: ep.subject || '',
|
||||
timestamp: ep.timestamp || new Date().toISOString(),
|
||||
memoryType: ep.memory_type || 'episodic_memory',
|
||||
score: ep.score || 0,
|
||||
metadata: {
|
||||
groupId: ep.group_id,
|
||||
type: ep.memory_type,
|
||||
participants: ep.participants
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
memories.sort((a, b) => b.score - a.score);
|
||||
return memories;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a memory to EverMem Cloud (v1).
|
||||
* Uses /api/v1/memories/group when config.groupId is set, else /api/v1/memories (personal).
|
||||
* @param {Object} message - Message to store
|
||||
* @param {string} message.content - Message content
|
||||
* @param {string} message.role - 'user' or 'assistant'
|
||||
* @param {string} [message.messageId] - (unused in v1; accepted for backward compatibility)
|
||||
* @returns {Promise<Object>} Debug envelope { url, body, status, ok, response }
|
||||
*/
|
||||
export async function addMemory(message) {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.isConfigured) {
|
||||
throw new Error('EverMem API key not configured');
|
||||
}
|
||||
|
||||
const role = message.role === 'assistant' ? 'assistant' : 'user';
|
||||
const sender_id = role === 'assistant' ? 'claude-assistant' : config.userId;
|
||||
|
||||
const baseMessage = {
|
||||
sender_id,
|
||||
role,
|
||||
timestamp: Date.now(),
|
||||
content: message.content
|
||||
};
|
||||
|
||||
let url;
|
||||
let requestBody;
|
||||
|
||||
if (config.groupId) {
|
||||
url = `${config.apiBaseUrl}/api/v1/memories/group`;
|
||||
requestBody = {
|
||||
group_id: config.groupId,
|
||||
messages: [baseMessage],
|
||||
async_mode: true
|
||||
};
|
||||
} else {
|
||||
url = `${config.apiBaseUrl}/api/v1/memories`;
|
||||
requestBody = {
|
||||
user_id: config.userId,
|
||||
messages: [baseMessage],
|
||||
async_mode: true
|
||||
};
|
||||
}
|
||||
|
||||
let response, responseText, responseData, status, ok;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
status = response.status;
|
||||
ok = response.ok;
|
||||
responseText = await response.text();
|
||||
try {
|
||||
responseData = JSON.parse(responseText);
|
||||
} catch {}
|
||||
} catch (fetchError) {
|
||||
status = 0;
|
||||
ok = false;
|
||||
responseText = fetchError.message;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
body: requestBody,
|
||||
status,
|
||||
ok,
|
||||
response: responseData || responseText
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memories from EverMem Cloud (v1, ordered newest first by default).
|
||||
* @param {Object} options - Options
|
||||
* @param {number} options.page - Page number (default: 1)
|
||||
* @param {number} options.pageSize - Results per page (default: 100, max: 100)
|
||||
* @param {string} options.memoryType - Memory type filter (default: 'episodic_memory')
|
||||
* @returns {Promise<Object>} Raw v1 response { data: { episodes, total_count, count, ... } }
|
||||
*/
|
||||
export async function getMemories(options = {}) {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.isConfigured) {
|
||||
throw new Error('EverMem API key not configured');
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
memoryType = 'episodic_memory'
|
||||
} = options;
|
||||
|
||||
const filters = config.groupId
|
||||
? { group_id: config.groupId }
|
||||
: { user_id: config.userId };
|
||||
|
||||
const url = `${config.apiBaseUrl}/api/v1/memories/get`;
|
||||
const requestBody = {
|
||||
memory_type: memoryType,
|
||||
filters,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
rank_by: 'timestamp',
|
||||
rank_order: 'desc'
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API error ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform v1 getMemories response to simple format.
|
||||
* @param {Object} apiResponse - Raw v1 API response
|
||||
* @returns {Object[]} Formatted memories newest-first
|
||||
*/
|
||||
export function transformGetMemoriesResults(apiResponse) {
|
||||
const episodes = apiResponse?.data?.episodes;
|
||||
if (!Array.isArray(episodes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const memories = episodes.map(ep => ({
|
||||
text: ep.episode || ep.summary || '',
|
||||
subject: ep.subject || '',
|
||||
timestamp: ep.timestamp || new Date().toISOString(),
|
||||
groupId: ep.group_id
|
||||
})).filter(m => m.text);
|
||||
|
||||
memories.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
return memories;
|
||||
}
|
||||
152
use-cases/claude-code-plugin/hooks/scripts/utils/formatter.js
Normal file
152
use-cases/claude-code-plugin/hooks/scripts/utils/formatter.js
Normal file
@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Terminal output formatting utilities
|
||||
*/
|
||||
|
||||
import { formatRelativeTime } from './mock-store.js';
|
||||
|
||||
// Memory type emoji mapping
|
||||
const TYPE_ICONS = {
|
||||
decision: { emoji: '\u{1F3AF}', ascii: '[DECISION]' }, // Target
|
||||
bug_fix: { emoji: '\u{1F41B}', ascii: '[BUG]' }, // Bug
|
||||
implementation: { emoji: '\u{1F527}', ascii: '[IMPL]' }, // Wrench
|
||||
learning: { emoji: '\u{1F4A1}', ascii: '[LEARN]' }, // Lightbulb
|
||||
preference: { emoji: '\u{2699}\u{FE0F}', ascii: '[PREF]' } // Gear
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if terminal likely supports Unicode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function supportsUnicode() {
|
||||
const term = process.env.TERM || '';
|
||||
const lang = process.env.LANG || '';
|
||||
const lcAll = process.env.LC_ALL || '';
|
||||
|
||||
// Check for UTF-8 in locale settings
|
||||
if (lang.includes('UTF-8') || lcAll.includes('UTF-8')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for modern terminal types
|
||||
if (term.includes('xterm') || term.includes('256color') || term.includes('kitty') || term.includes('alacritty')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default to Unicode on macOS
|
||||
if (process.platform === 'darwin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for memory type
|
||||
* @param {string} type - Memory type
|
||||
* @param {boolean} useUnicode - Whether to use Unicode
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getTypeIcon(type, useUnicode = true) {
|
||||
const icons = TYPE_ICONS[type] || TYPE_ICONS.implementation;
|
||||
return useUnicode ? icons.emoji : icons.ascii;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the "Searching memories..." spinner
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSpinner() {
|
||||
const useUnicode = supportsUnicode();
|
||||
const icon = useUnicode ? '\u23F3' : '[...]'; // Hourglass
|
||||
return `${icon} Searching memories...\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} FilteredMemory
|
||||
* @property {string} text - Original memory text
|
||||
* @property {string} timestamp - ISO timestamp
|
||||
* @property {string} type - Memory type
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format the memory summary box with original memories and timestamps
|
||||
* @param {Object} result - SDK filter result
|
||||
* @param {FilteredMemory[]} result.selected - Selected memories
|
||||
* @param {string} result.synthesis - SDK synthesis
|
||||
* @param {number} rawCount - Number of raw candidates
|
||||
* @param {number} filteredCount - Number after filtering
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSummaryBox(result, rawCount, filteredCount) {
|
||||
const useUnicode = supportsUnicode();
|
||||
const divider = useUnicode ? '\u2500'.repeat(50) : '-'.repeat(50);
|
||||
|
||||
let output = '\n';
|
||||
output += useUnicode ? '\u{1F4AD} Memory Retrieved\n' : '=== Memory Retrieved ===\n';
|
||||
output += divider + '\n';
|
||||
|
||||
// Individual memories with original text and timestamp
|
||||
for (let i = 0; i < result.selected.length; i++) {
|
||||
const memory = result.selected[i];
|
||||
const icon = getTypeIcon(memory.type, useUnicode);
|
||||
const relativeTime = formatRelativeTime(memory.timestamp);
|
||||
|
||||
output += `${icon} (${relativeTime}) ${memory.text.slice(0, 80)}...\n`;
|
||||
}
|
||||
|
||||
output += divider + '\n';
|
||||
output += `${filteredCount} memories recalled\n`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format "No relevant memories" message
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatNoMemories() {
|
||||
const useUnicode = supportsUnicode();
|
||||
const icon = useUnicode ? '\u{1F4AD}' : '===';
|
||||
|
||||
return `\n${icon} Memory Retrieved: No relevant memories found\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message
|
||||
* @param {string} message - Error message
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatError(message) {
|
||||
const useUnicode = supportsUnicode();
|
||||
const icon = useUnicode ? '\u26A0\u{FE0F}' : '[!]';
|
||||
|
||||
return `\n${icon} Memory Retrieved: ${message}\n Continuing without memory context\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format fallback summary (when SDK fails)
|
||||
* @param {FilteredMemory[]} memories - Memory objects with text, timestamp, type
|
||||
* @param {number} rawCount - Total raw candidates
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatFallbackSummary(memories, rawCount) {
|
||||
const useUnicode = supportsUnicode();
|
||||
const divider = useUnicode ? '\u2500'.repeat(50) : '-'.repeat(50);
|
||||
|
||||
let output = '\n';
|
||||
output += useUnicode ? '\u{1F4AD} Memory Retrieved (Fallback)\n' : '=== Memory Retrieved (Fallback) ===\n';
|
||||
output += divider + '\n';
|
||||
|
||||
for (let i = 0; i < memories.length; i++) {
|
||||
const memory = memories[i];
|
||||
const icon = getTypeIcon(memory.type, useUnicode);
|
||||
const relativeTime = formatRelativeTime(memory.timestamp);
|
||||
|
||||
output += `${icon} (${relativeTime}) ${memory.text.slice(0, 80)}...\n`;
|
||||
}
|
||||
|
||||
output += divider + '\n';
|
||||
output += `Showing top ${memories.length} matches (SDK unavailable)\n`;
|
||||
|
||||
return output;
|
||||
}
|
||||
190
use-cases/claude-code-plugin/hooks/scripts/utils/groups-store.js
Normal file
190
use-cases/claude-code-plugin/hooks/scripts/utils/groups-store.js
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Groups Store - Local persistence for memory groups (JSONL format)
|
||||
*
|
||||
* Each groupId+keyId combination is stored only once (no duplicates).
|
||||
* Format: {"keyId":"...","groupId":"...","name":"...","path":"...","timestamp":"..."}
|
||||
*
|
||||
* keyId: SHA-256 hash (first 12 chars) of the API key - identifies which account owns this group
|
||||
*/
|
||||
|
||||
import { readFileSync, appendFileSync, existsSync } from 'fs';
|
||||
import { resolve, dirname, basename } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getKeyId } from './config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const GROUPS_FILE = resolve(__dirname, '../../../data/groups.jsonl');
|
||||
|
||||
/**
|
||||
* Check if the groupId+keyId combination already exists in the file
|
||||
* @param {string} groupId - The group ID to check
|
||||
* @param {string} keyId - The key ID (hashed API key) to check
|
||||
* @returns {boolean} True if already exists (should skip)
|
||||
*/
|
||||
function alreadyExists(groupId, keyId) {
|
||||
try {
|
||||
if (!existsSync(GROUPS_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = readFileSync(GROUPS_FILE, 'utf8');
|
||||
const lines = content.trim().split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Match both groupId AND keyId (same project + same API key)
|
||||
if (entry.groupId === groupId && entry.keyId === keyId) {
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a group entry to the JSONL file
|
||||
* Only records if the groupId+keyId combination doesn't already exist
|
||||
* @param {string} groupId - The group ID
|
||||
* @param {string} cwd - The working directory path
|
||||
* @returns {Object|null} The entry if saved, null if skipped or error
|
||||
*/
|
||||
export function saveGroup(groupId, cwd) {
|
||||
try {
|
||||
const keyId = getKeyId();
|
||||
|
||||
// Skip if this groupId+keyId already exists
|
||||
if (alreadyExists(groupId, keyId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
keyId, // Hashed API key identifier (null if not configured)
|
||||
groupId,
|
||||
name: basename(cwd),
|
||||
path: cwd,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
appendFileSync(GROUPS_FILE, JSON.stringify(entry) + '\n', 'utf8');
|
||||
return entry;
|
||||
} catch (e) {
|
||||
// Silent on errors
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and aggregate groups from the JSONL file
|
||||
* @param {string} [filterKeyId] - Optional keyId to filter by (only show groups for this API key)
|
||||
* @returns {Array} Aggregated list of groups
|
||||
*/
|
||||
export function getGroups(filterKeyId = null) {
|
||||
try {
|
||||
if (!existsSync(GROUPS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = readFileSync(GROUPS_FILE, 'utf8');
|
||||
const lines = content.trim().split('\n').filter(Boolean);
|
||||
|
||||
// Aggregate by groupId+keyId (composite key)
|
||||
const groupMap = new Map();
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Skip if filtering by keyId and this entry doesn't match
|
||||
if (filterKeyId && entry.keyId !== filterKeyId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use composite key: keyId:groupId (to separate same project under different accounts)
|
||||
const compositeKey = `${entry.keyId || 'none'}:${entry.groupId}`;
|
||||
const existing = groupMap.get(compositeKey);
|
||||
|
||||
if (existing) {
|
||||
existing.sessionCount += 1;
|
||||
// Update lastSeen if this timestamp is newer
|
||||
if (entry.timestamp > existing.lastSeen) {
|
||||
existing.lastSeen = entry.timestamp;
|
||||
}
|
||||
// Update firstSeen if this timestamp is older
|
||||
if (entry.timestamp < existing.firstSeen) {
|
||||
existing.firstSeen = entry.timestamp;
|
||||
}
|
||||
} else {
|
||||
groupMap.set(compositeKey, {
|
||||
id: entry.groupId,
|
||||
keyId: entry.keyId || null,
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
firstSeen: entry.timestamp,
|
||||
lastSeen: entry.timestamp,
|
||||
sessionCount: 1
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Convert to array and sort by lastSeen (most recent first)
|
||||
return Array.from(groupMap.values()).sort((a, b) =>
|
||||
new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime()
|
||||
);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get groups for the current API key only
|
||||
* @returns {Array} Aggregated list of groups for current keyId
|
||||
*/
|
||||
export function getMyGroups() {
|
||||
const keyId = getKeyId();
|
||||
return getGroups(keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific group by ID (optionally filtered by current keyId)
|
||||
* @param {string} groupId - The group ID
|
||||
* @param {boolean} [filterByKey=true] - Whether to filter by current API key
|
||||
* @returns {Object|null} The group or null if not found
|
||||
*/
|
||||
export function getGroup(groupId, filterByKey = true) {
|
||||
const keyId = filterByKey ? getKeyId() : null;
|
||||
const groups = getGroups(keyId);
|
||||
return groups.find(g => g.id === groupId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load raw groups data (for backward compatibility)
|
||||
* @returns {Object} Groups data in old format
|
||||
*/
|
||||
export function loadGroups() {
|
||||
return { groups: getGroups() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2h ago", "1d ago")
|
||||
* @param {string} isoTime - ISO timestamp
|
||||
* @returns {string} Relative time string
|
||||
*/
|
||||
export function formatRelativeTime(isoTime) {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoTime).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
const hours = Math.floor(diffMs / 3600000);
|
||||
const days = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return `${Math.floor(days / 30)}mo ago`;
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Simple substring search for mock memories
|
||||
* To be replaced with semantic retrieval in production
|
||||
*/
|
||||
|
||||
const MAX_CANDIDATES = 15;
|
||||
|
||||
/**
|
||||
* @typedef {Object} Memory
|
||||
* @property {string} text - The memory content
|
||||
* @property {string} timestamp - ISO timestamp when memory was created
|
||||
*/
|
||||
|
||||
/**
|
||||
* Search memories for matches to query terms
|
||||
* @param {string} query - User's prompt
|
||||
* @param {Memory[]} memories - Array of memory objects
|
||||
* @returns {Memory[]} Matching memories (max 15)
|
||||
*/
|
||||
export function searchMemories(query, memories) {
|
||||
if (!query || !memories || memories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split query into terms, filter out very short terms
|
||||
const queryTerms = query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(term => term.length > 2);
|
||||
|
||||
if (queryTerms.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find memories that match any query term
|
||||
const matches = memories.filter(memory => {
|
||||
const memoryLower = memory.text.toLowerCase();
|
||||
return queryTerms.some(term => memoryLower.includes(term));
|
||||
});
|
||||
|
||||
// Return up to MAX_CANDIDATES
|
||||
return matches.slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count words/tokens in a string (multilingual support)
|
||||
* - For CJK (Chinese/Japanese/Korean): counts each character as a token
|
||||
* - For other languages: counts space-separated words
|
||||
* - For mixed text: counts both
|
||||
* @param {string} text - Input text
|
||||
* @returns {number} Word/token count
|
||||
*/
|
||||
export function countWords(text) {
|
||||
if (!text) return 0;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return 0;
|
||||
|
||||
// Regex for CJK characters (Chinese, Japanese Kanji, Korean Hanja)
|
||||
// Also includes Japanese Hiragana/Katakana and Korean Hangul
|
||||
const cjkRegex = /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/g;
|
||||
|
||||
// Count CJK characters
|
||||
const cjkMatches = trimmed.match(cjkRegex);
|
||||
const cjkCount = cjkMatches ? cjkMatches.length : 0;
|
||||
|
||||
// Remove CJK characters and count remaining space-separated words
|
||||
const nonCjkText = trimmed.replace(cjkRegex, ' ').trim();
|
||||
const wordCount = nonCjkText ? nonCjkText.split(/\s+/).filter(w => w.length > 0).length : 0;
|
||||
|
||||
return cjkCount + wordCount;
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const DATA_PATH = join(__dirname, '..', '..', '..', 'data', 'mock-memories.json');
|
||||
|
||||
let memoriesCache = null;
|
||||
|
||||
/**
|
||||
* @typedef {Object} Memory
|
||||
* @property {string} text - The memory content
|
||||
* @property {string} timestamp - ISO timestamp when memory was created
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load mock memories from JSON file
|
||||
* @returns {Memory[]} Array of memory objects with text and timestamp
|
||||
*/
|
||||
export function loadMemories() {
|
||||
if (memoriesCache !== null) {
|
||||
return memoriesCache;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = readFileSync(DATA_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(data);
|
||||
memoriesCache = parsed.memories || [];
|
||||
return memoriesCache;
|
||||
} catch (error) {
|
||||
console.error(`[Memory Plugin] Failed to load memories: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp as relative time (e.g., "2h ago", "3 days ago")
|
||||
* @param {string} isoTimestamp - ISO timestamp string
|
||||
* @returns {string} Relative time string
|
||||
*/
|
||||
export function formatRelativeTime(isoTimestamp) {
|
||||
const now = new Date();
|
||||
const then = new Date(isoTimestamp);
|
||||
const diffMs = now - then;
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const weeks = Math.floor(days / 7);
|
||||
const months = Math.floor(days / 30);
|
||||
|
||||
if (months > 0) {
|
||||
return months === 1 ? '1 month ago' : `${months} months ago`;
|
||||
}
|
||||
if (weeks > 0) {
|
||||
return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
|
||||
}
|
||||
if (days > 0) {
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? '1 hour ago' : `${hours}h ago`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? '1 min ago' : `${minutes}m ago`;
|
||||
}
|
||||
return 'just now';
|
||||
}
|
||||
183
use-cases/claude-code-plugin/hooks/scripts/utils/sdk-filter.js
Normal file
183
use-cases/claude-code-plugin/hooks/scripts/utils/sdk-filter.js
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* SDK-based filtering and summarization of memories
|
||||
* Uses Claude Agent SDK to intelligently filter and summarize relevant memories
|
||||
* Inherits authentication from Claude Code (no API key needed)
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const MAX_MEMORIES = 5;
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* @typedef {Object} Memory
|
||||
* @property {string} text - The memory content
|
||||
* @property {string} timestamp - ISO timestamp when memory was created
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FilteredMemory
|
||||
* @property {string} text - Original memory text
|
||||
* @property {string} timestamp - Original timestamp
|
||||
* @property {string} type - Inferred type (decision, bug_fix, etc.)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Find the Claude Code executable path
|
||||
* @returns {string|null} Path to claude executable or null if not found
|
||||
*/
|
||||
function findClaudeExecutable() {
|
||||
try {
|
||||
// Try 'which' on Unix-like systems
|
||||
const result = execSync('which claude', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return result.trim();
|
||||
} catch {
|
||||
try {
|
||||
// Try 'where' on Windows
|
||||
const result = execSync('where claude', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return result.trim().split('\n')[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter memories using Claude Agent SDK
|
||||
* @param {string} prompt - User's current prompt
|
||||
* @param {Memory[]} candidates - Array of candidate memory objects
|
||||
* @returns {Promise<Object>} Filtered result with original memories and types
|
||||
*/
|
||||
export async function filterAndSummarize(prompt, candidates) {
|
||||
const claudePath = findClaudeExecutable();
|
||||
|
||||
if (!claudePath) {
|
||||
throw new Error('Claude Code executable not found');
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a JSON-only memory filter. You MUST respond with ONLY a JSON object, nothing else. No explanations, no markdown, no text before or after the JSON. Just the raw JSON object starting with { and ending with }.`;
|
||||
|
||||
const filterPrompt = `Filter these memories for relevance to the user's prompt.
|
||||
|
||||
USER PROMPT: "${prompt}"
|
||||
|
||||
CANDIDATE MEMORIES:
|
||||
${candidates.map((c, i) => `[${i + 1}] ${c.text}`).join('\n\n')}
|
||||
|
||||
OUTPUT FORMAT (respond with ONLY this JSON, nothing else):
|
||||
{"selected": [{"index": N, "type": "TYPE"}], "synthesis": "NARRATIVE"}
|
||||
|
||||
RULES:
|
||||
- index is the memory number (1-based)
|
||||
- type must be one of: decision, bug_fix, implementation, learning, preference
|
||||
- Maximum ${MAX_MEMORIES} memories
|
||||
- If no memories are relevant: {"selected": [], "synthesis": null}
|
||||
- ONLY output the JSON object, no other text`;
|
||||
|
||||
// Create abort controller for timeout
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
let responseText = '';
|
||||
|
||||
// Use Agent SDK query with Claude Code executable
|
||||
const queryResult = query({
|
||||
prompt: filterPrompt,
|
||||
options: {
|
||||
pathToClaudeCodeExecutable: claudePath,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt,
|
||||
allowedTools: [], // No tools needed for filtering
|
||||
abortController,
|
||||
maxTurns: 1 // Single turn only
|
||||
}
|
||||
});
|
||||
|
||||
// Collect response text from the async generator
|
||||
for await (const message of queryResult) {
|
||||
if (message.type === 'assistant' && message.message?.content) {
|
||||
for (const block of message.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Extract JSON from response (handle potential text wrapping)
|
||||
let jsonText = responseText.trim();
|
||||
|
||||
// Remove markdown code block if present
|
||||
if (jsonText.includes('```')) {
|
||||
const match = jsonText.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
if (match) {
|
||||
jsonText = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find JSON object in the response
|
||||
const jsonMatch = jsonText.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
jsonText = jsonMatch[0];
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
const parsed = JSON.parse(jsonText);
|
||||
|
||||
// Validate structure
|
||||
if (!parsed.selected || !Array.isArray(parsed.selected)) {
|
||||
throw new Error('Invalid response structure');
|
||||
}
|
||||
|
||||
// Map back to original memories with type info
|
||||
const selected = parsed.selected
|
||||
.slice(0, MAX_MEMORIES)
|
||||
.map(item => {
|
||||
const originalMemory = candidates[item.index - 1];
|
||||
if (!originalMemory) return null;
|
||||
return {
|
||||
text: originalMemory.text,
|
||||
timestamp: originalMemory.timestamp,
|
||||
type: item.type || 'implementation'
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
selected,
|
||||
synthesis: parsed.synthesis
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('SDK timeout');
|
||||
}
|
||||
|
||||
throw new Error(`SDK filter failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fallback result from raw candidates
|
||||
* @param {Memory[]} candidates - Raw memory candidates
|
||||
* @param {number} limit - Max memories to return
|
||||
* @returns {Object} Fallback result structure
|
||||
*/
|
||||
export function createFallbackResult(candidates, limit = 3) {
|
||||
const selected = candidates.slice(0, limit).map(memory => ({
|
||||
text: memory.text,
|
||||
timestamp: memory.timestamp,
|
||||
type: 'implementation' // Default type
|
||||
}));
|
||||
|
||||
return {
|
||||
selected,
|
||||
synthesis: null,
|
||||
isFallback: true
|
||||
};
|
||||
}
|
||||
175
use-cases/claude-code-plugin/install.sh
Executable file
175
use-cases/claude-code-plugin/install.sh
Executable file
@ -0,0 +1,175 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN} ███████╗██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███╗ ███╗${NC}"
|
||||
echo -e "${CYAN} ██╔════╝██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝████╗ ████║${NC}"
|
||||
echo -e "${CYAN} █████╗ ██║ ██║█████╗ ██████╔╝██╔████╔██║█████╗ ██╔████╔██║${NC}"
|
||||
echo -e "${CYAN} ██╔══╝ ╚██╗ ██╔╝██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║${NC}"
|
||||
echo -e "${CYAN} ███████╗ ╚████╔╝ ███████╗██║ ██║██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║${NC}"
|
||||
echo -e "${CYAN} ╚══════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW} 牛 马 日 记${NC}"
|
||||
echo ""
|
||||
echo -e " ${GREEN}Plugin for Claude Code${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if claude CLI is installed
|
||||
if ! command -v claude &> /dev/null; then
|
||||
echo -e "${RED}❌ Error: Claude Code CLI is not installed${NC}"
|
||||
echo ""
|
||||
echo "Please install Claude Code first:"
|
||||
echo " https://claude.ai/code"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓${NC} Claude Code CLI detected"
|
||||
|
||||
# Detect shell and profile
|
||||
detect_shell_profile() {
|
||||
if [ -n "$ZSH_VERSION" ] || [ "$SHELL" = "/bin/zsh" ]; then
|
||||
echo "$HOME/.zshrc"
|
||||
elif [ -n "$BASH_VERSION" ] || [ "$SHELL" = "/bin/bash" ]; then
|
||||
if [ -f "$HOME/.bash_profile" ]; then
|
||||
echo "$HOME/.bash_profile"
|
||||
else
|
||||
echo "$HOME/.bashrc"
|
||||
fi
|
||||
elif [ "$SHELL" = "/usr/bin/fish" ] || [ "$SHELL" = "/bin/fish" ]; then
|
||||
echo "$HOME/.config/fish/config.fish"
|
||||
else
|
||||
echo "$HOME/.profile"
|
||||
fi
|
||||
}
|
||||
|
||||
PROFILE=$(detect_shell_profile)
|
||||
SHELL_NAME=$(basename "$SHELL")
|
||||
|
||||
echo -e "${GREEN}✓${NC} Detected shell: $SHELL_NAME"
|
||||
echo -e "${GREEN}✓${NC} Profile file: $PROFILE"
|
||||
echo ""
|
||||
|
||||
# Prompt for API key
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${YELLOW} Step 1: Configure API Key${NC}"
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo "Get your API key from: https://console.evermind.ai/"
|
||||
echo ""
|
||||
|
||||
# Function to read user input (works when piped from curl)
|
||||
prompt_user() {
|
||||
local prompt="$1"
|
||||
local varname="$2"
|
||||
printf "%s" "$prompt"
|
||||
# Try /dev/tty first (needed when script is piped), fall back to stdin
|
||||
if [ -e /dev/tty ] && (exec </dev/tty) 2>/dev/null; then
|
||||
read "$varname" </dev/tty
|
||||
else
|
||||
read "$varname"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if API key already exists
|
||||
if [ -n "$EVERMEM_API_KEY" ]; then
|
||||
echo -e "${GREEN}✓${NC} EVERMEM_API_KEY already set in environment"
|
||||
prompt_user "Do you want to update it? (y/N): " UPDATE_KEY
|
||||
if [ "$UPDATE_KEY" != "y" ] && [ "$UPDATE_KEY" != "Y" ]; then
|
||||
echo "Keeping existing API key."
|
||||
API_KEY="$EVERMEM_API_KEY"
|
||||
else
|
||||
prompt_user "Enter your EverMem API key: " API_KEY
|
||||
fi
|
||||
else
|
||||
prompt_user "Enter your EverMem API key: " API_KEY
|
||||
fi
|
||||
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo -e "${RED}❌ API key is required${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add API key to profile
|
||||
if ! grep -q "EVERMEM_API_KEY" "$PROFILE" 2>/dev/null; then
|
||||
echo "" >> "$PROFILE"
|
||||
echo "# EverMem API Key (added by install script)" >> "$PROFILE"
|
||||
if [ "$SHELL_NAME" = "fish" ]; then
|
||||
echo "set -gx EVERMEM_API_KEY \"$API_KEY\"" >> "$PROFILE"
|
||||
else
|
||||
echo "export EVERMEM_API_KEY=\"$API_KEY\"" >> "$PROFILE"
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} API key added to $PROFILE"
|
||||
else
|
||||
# Update existing key
|
||||
if [ "$SHELL_NAME" = "fish" ]; then
|
||||
sed -i.bak "s|set -gx EVERMEM_API_KEY.*|set -gx EVERMEM_API_KEY \"$API_KEY\"|" "$PROFILE"
|
||||
else
|
||||
sed -i.bak "s|export EVERMEM_API_KEY=.*|export EVERMEM_API_KEY=\"$API_KEY\"|" "$PROFILE"
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} API key updated in $PROFILE"
|
||||
fi
|
||||
|
||||
# Export for current session
|
||||
export EVERMEM_API_KEY="$API_KEY"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${YELLOW} Step 2: Install Plugin${NC}"
|
||||
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
|
||||
REPO_URL="https://github.com/EverMind-AI/evermem-claude-code"
|
||||
|
||||
# Add marketplace directly from GitHub (allows update tracking)
|
||||
echo "Adding EverMem marketplace..."
|
||||
claude plugin marketplace remove evermem 2>/dev/null || true
|
||||
if claude plugin marketplace add "$REPO_URL" 2>&1 | grep -q "Successfully"; then
|
||||
echo -e "${GREEN}✓${NC} Marketplace added"
|
||||
else
|
||||
echo -e "${RED}❌ Failed to add marketplace${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install plugin
|
||||
echo "Installing EverMem plugin..."
|
||||
claude plugin uninstall evermem@evermem 2>/dev/null || true
|
||||
if claude plugin install evermem@evermem --scope user 2>&1 | grep -q "Successfully"; then
|
||||
echo -e "${GREEN}✓${NC} Plugin installed"
|
||||
else
|
||||
echo -e "${RED}❌ Failed to install plugin${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install npm dependencies for hooks
|
||||
PLUGIN_CACHE="$HOME/.claude/plugins/cache/evermem/evermem"
|
||||
PLUGIN_DIR=$(ls -d "$PLUGIN_CACHE"/*/ 2>/dev/null | head -1)
|
||||
if [ -n "$PLUGIN_DIR" ] && [ -f "$PLUGIN_DIR/package.json" ]; then
|
||||
echo "Installing dependencies..."
|
||||
(cd "$PLUGIN_DIR" && npm install --silent 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN} 🎉 Installation Complete!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo "To activate the API key in your current terminal:"
|
||||
echo ""
|
||||
echo -e " ${BLUE}source $PROFILE${NC}"
|
||||
echo ""
|
||||
echo "Or simply restart your terminal."
|
||||
echo ""
|
||||
echo "Your conversations with Claude Code will now be remembered!"
|
||||
echo ""
|
||||
echo "Need help? Run /evermem:help in Claude Code"
|
||||
echo ""
|
||||
224
use-cases/claude-code-plugin/mcp/server.js
Executable file
224
use-cases/claude-code-plugin/mcp/server.js
Executable file
@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* EverMem MCP Server
|
||||
* Exposes memory search tool for Claude to find relevant context from past sessions
|
||||
*/
|
||||
|
||||
import { createInterface } from 'readline';
|
||||
import { searchMemories, transformSearchResults } from '../hooks/scripts/utils/evermem-api.js';
|
||||
import { getConfig } from '../hooks/scripts/utils/config.js';
|
||||
|
||||
// Tool definitions - following claude-mem's concise pattern
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'evermem_search',
|
||||
description: 'Search past conversation memories. Returns summaries with dates and relevance scores. Use when user asks about previous work, decisions, or context from past sessions. Params: query (required), limit (default: 10, max: 20)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query - use keywords, topics, or questions'
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max results to return (default: 10, max: 20)'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Format date as relative time (e.g., "2 days ago", "today")
|
||||
*/
|
||||
function formatRelativeDate(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'today';
|
||||
if (diffDays === 1) return 'yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle evermem_search tool call
|
||||
*/
|
||||
async function handleSearch(args) {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.isConfigured) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'EverMem API key not configured. Set EVERMEM_API_KEY environment variable.' }]
|
||||
};
|
||||
}
|
||||
|
||||
const query = args.query;
|
||||
if (!query) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Missing required parameter: query' }]
|
||||
};
|
||||
}
|
||||
|
||||
const limit = Math.min(args.limit || 10, 20);
|
||||
|
||||
try {
|
||||
const response = await searchMemories(query, { topK: limit });
|
||||
const memories = transformSearchResults(response);
|
||||
|
||||
if (memories.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `No memories found for: "${query}"` }]
|
||||
};
|
||||
}
|
||||
|
||||
// Format as compact table (token-efficient like claude-mem)
|
||||
const header = '| # | Score | Date | Summary |';
|
||||
const separator = '|---|-------|------|---------|';
|
||||
|
||||
const rows = memories.map((mem, i) => {
|
||||
const score = Math.round(mem.score * 100);
|
||||
const date = formatRelativeDate(mem.timestamp);
|
||||
// Use full subject field
|
||||
const summary = (mem.subject || mem.text.substring(0, 150)).replace(/\|/g, '/').replace(/\n/g, ' ');
|
||||
return `| ${i + 1} | ${score}% | ${date} | ${summary} |`;
|
||||
});
|
||||
|
||||
const table = [header, separator, ...rows].join('\n');
|
||||
|
||||
// Add context about what was found
|
||||
const resultText = `Found ${memories.length} memories for "${query}":\n\n${table}\n\nTo get full content of a specific memory, ask me to elaborate on that topic.`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: resultText }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `Search error: ${error.message}` }]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming JSON-RPC request
|
||||
*/
|
||||
async function handleRequest(request) {
|
||||
const { id, method, params } = request;
|
||||
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'evermem',
|
||||
version: '0.1.0'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'notifications/initialized':
|
||||
return null;
|
||||
|
||||
case 'tools/list':
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
tools: TOOLS
|
||||
}
|
||||
};
|
||||
|
||||
case 'tools/call':
|
||||
const { name, arguments: args } = params;
|
||||
let result;
|
||||
|
||||
switch (name) {
|
||||
case 'evermem_search':
|
||||
result = await handleSearch(args || {});
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Unknown tool: ${name}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Method not found: ${method}`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main MCP server loop
|
||||
*/
|
||||
async function main() {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
try {
|
||||
const request = JSON.parse(line);
|
||||
const response = await handleRequest(request);
|
||||
|
||||
if (response) {
|
||||
console.log(JSON.stringify(response));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: {
|
||||
code: -32700,
|
||||
message: `Parse error: ${error.message}`
|
||||
}
|
||||
};
|
||||
console.log(JSON.stringify(errorResponse));
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('MCP server error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
18
use-cases/claude-code-plugin/package.json
Normal file
18
use-cases/claude-code-plugin/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "evermem-claude-code",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "EverMem Plugin for Claude Code - automatic memory recall from past sessions",
|
||||
"scripts": {
|
||||
"test:save": "node scripts/test-save-memories.js",
|
||||
"test:retrieve": "node scripts/test-retrieve-memories.js",
|
||||
"test": "npm run test:save && npm run test:retrieve"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
||||
"@anthropic-ai/sdk": "^0.39.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
9
use-cases/claude-code-plugin/plugin.json
Normal file
9
use-cases/claude-code-plugin/plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "evermem",
|
||||
"version": "0.2.0",
|
||||
"description": "Automatically recalls relevant memories from past sessions and injects them into Claude's context",
|
||||
"author": {
|
||||
"name": "EverMem"
|
||||
},
|
||||
"keywords": ["memory", "context", "recall", "persistence"]
|
||||
}
|
||||
96
use-cases/claude-code-plugin/scripts/test-retrieve-memories.js
Executable file
96
use-cases/claude-code-plugin/scripts/test-retrieve-memories.js
Executable file
@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for retrieving memories from EverMem Cloud
|
||||
* Simulates what happens when user submits a prompt
|
||||
*
|
||||
* Usage:
|
||||
* export EVERMEM_API_KEY="your-key"
|
||||
* node scripts/test-retrieve-memories.js
|
||||
*/
|
||||
|
||||
import { getConfig, isConfigured } from '../hooks/scripts/utils/config.js';
|
||||
import { searchMemories, transformSearchResults } from '../hooks/scripts/utils/evermem-api.js';
|
||||
import { formatRelativeTime } from '../hooks/scripts/utils/mock-store.js';
|
||||
|
||||
// Test queries that should match saved memories
|
||||
const TEST_QUERIES = [
|
||||
"How do we handle authentication?",
|
||||
"What's our database setup?",
|
||||
"Tell me about rate limiting",
|
||||
"How are errors handled in the API?",
|
||||
"What was that N+1 query issue?",
|
||||
"JWT token configuration",
|
||||
"PostgreSQL connection pooling"
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 EverMem Retrieve Memory Test\n');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
// Check configuration
|
||||
if (!isConfigured()) {
|
||||
console.error('❌ EVERMEM_API_KEY not set');
|
||||
console.error(' Run: export EVERMEM_API_KEY="your-key"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
console.log(`✓ API Key: ${config.apiKey.slice(0, 10)}...`);
|
||||
console.log(`✓ User ID: ${config.userId}`);
|
||||
console.log(`✓ Group ID: ${config.groupId}`);
|
||||
console.log(`✓ API URL: ${config.apiBaseUrl}`);
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
for (const query of TEST_QUERIES) {
|
||||
console.log(`\n📝 Query: "${query}"`);
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
try {
|
||||
// Call API exactly like inject-memories.js does
|
||||
const apiResponse = await searchMemories(query, {
|
||||
topK: 5,
|
||||
retrieveMethod: 'hybrid'
|
||||
});
|
||||
|
||||
// Transform response exactly like inject-memories.js does
|
||||
const memories = transformSearchResults(apiResponse);
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(' No memories found');
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` Found ${memories.length} memories:\n`);
|
||||
|
||||
// Display like the plugin does
|
||||
for (let i = 0; i < Math.min(memories.length, 3); i++) {
|
||||
const memory = memories[i];
|
||||
const relTime = formatRelativeTime(memory.timestamp);
|
||||
const shortText = memory.text.length > 70
|
||||
? memory.text.slice(0, 70) + '...'
|
||||
: memory.text;
|
||||
|
||||
console.log(` ${i + 1}. (${relTime}) [${memory.type}]`);
|
||||
console.log(` "${shortText}"`);
|
||||
if (memory.score) {
|
||||
console.log(` Score: ${memory.score.toFixed(3)}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '=' .repeat(60));
|
||||
console.log('\n✅ Retrieval test complete!');
|
||||
console.log('\nTo test in Claude Code:');
|
||||
console.log(' claude --plugin-dir /Users/hzh/code/memory-plugin');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
110
use-cases/claude-code-plugin/scripts/test-save-memories.js
Executable file
110
use-cases/claude-code-plugin/scripts/test-save-memories.js
Executable file
@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for saving memories to EverMem Cloud
|
||||
* Simulates typical Claude Code conversations
|
||||
*
|
||||
* Usage:
|
||||
* export EVERMEM_API_KEY="your-key"
|
||||
* node scripts/test-save-memories.js
|
||||
*/
|
||||
|
||||
import { getConfig, isConfigured } from '../hooks/scripts/utils/config.js';
|
||||
import { addMemory } from '../hooks/scripts/utils/evermem-api.js';
|
||||
|
||||
// Sample conversations that might happen in Claude Code
|
||||
const TEST_CONVERSATIONS = [
|
||||
{
|
||||
user: "I need to set up authentication for our API. What approach should we use?",
|
||||
assistant: "For your API authentication, I recommend using JWT (JSON Web Tokens) with short-lived access tokens (15 minutes) and longer-lived refresh tokens stored in httpOnly cookies. This provides a good balance of security and user experience. The refresh tokens in httpOnly cookies protect against XSS attacks, while the short access token lifetime limits the damage if a token is compromised."
|
||||
},
|
||||
{
|
||||
user: "We're getting a lot of N+1 query issues in the user listing endpoint",
|
||||
assistant: "I've identified the N+1 query problem. The issue is in the UserService.getAllUsers() method where we're loading user roles in a loop. I've fixed it by adding eager loading with a JOIN query. Changed from individual role queries per user to a single query with LEFT JOIN on the roles table. This reduced the query count from 101 queries to just 2 queries for 100 users."
|
||||
},
|
||||
{
|
||||
user: "What database should we use for this project?",
|
||||
assistant: "Based on your requirements for complex queries and transactions, I recommend PostgreSQL. It offers excellent support for JSON fields if you need schema flexibility, strong ACID compliance, and great performance for read-heavy workloads with proper indexing. We should also set up connection pooling with a max of 20 connections to handle concurrent requests efficiently."
|
||||
},
|
||||
{
|
||||
user: "The API is throwing rate limit errors in production",
|
||||
assistant: "I've implemented rate limiting using Redis with a sliding window algorithm. The configuration is: 100 requests per minute for authenticated users, 20 requests per minute for anonymous users. The rate limiter returns 429 Too Many Requests with a Retry-After header. I've also added the express-rate-limit middleware with the Redis store for distributed rate limiting across multiple server instances."
|
||||
},
|
||||
{
|
||||
user: "How should we handle errors in the API?",
|
||||
assistant: "I've set up a centralized error handling middleware. All errors now follow a consistent format: { status: 'error', code: 'ERROR_CODE', message: 'Human readable message', details: {} }. We use custom error classes (ValidationError, NotFoundError, AuthenticationError) that extend a base AppError. Unhandled errors are logged to our monitoring service and return a generic 500 response to avoid leaking implementation details."
|
||||
}
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🧪 EverMem Save Memory Test\n');
|
||||
console.log('=' .repeat(50));
|
||||
|
||||
// Check configuration
|
||||
if (!isConfigured()) {
|
||||
console.error('❌ EVERMEM_API_KEY not set');
|
||||
console.error(' Run: export EVERMEM_API_KEY="your-key"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
console.log(`✓ API Key: ${config.apiKey.slice(0, 10)}...`);
|
||||
console.log(`✓ User ID: ${config.userId}`);
|
||||
console.log(`✓ Group ID: ${config.groupId}`);
|
||||
console.log(`✓ API URL: ${config.apiBaseUrl}`);
|
||||
console.log('=' .repeat(50));
|
||||
console.log('');
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (let i = 0; i < TEST_CONVERSATIONS.length; i++) {
|
||||
const conv = TEST_CONVERSATIONS[i];
|
||||
console.log(`\n📝 Conversation ${i + 1}/${TEST_CONVERSATIONS.length}`);
|
||||
console.log(` User: "${conv.user.slice(0, 50)}..."`);
|
||||
|
||||
// Save user message
|
||||
try {
|
||||
const userResult = await addMemory({
|
||||
content: conv.user,
|
||||
role: 'user',
|
||||
messageId: `test_user_${Date.now()}_${i}`
|
||||
});
|
||||
console.log(` ✓ User message saved (status: ${userResult.status || 'ok'})`);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.log(` ❌ User message failed: ${error.message}`);
|
||||
if (error.response) {
|
||||
console.log(` Response: ${JSON.stringify(error.response)}`);
|
||||
}
|
||||
failCount++;
|
||||
}
|
||||
|
||||
// Save assistant message
|
||||
try {
|
||||
const assistantResult = await addMemory({
|
||||
content: conv.assistant,
|
||||
role: 'assistant',
|
||||
messageId: `test_assistant_${Date.now()}_${i}`
|
||||
});
|
||||
console.log(` ✓ Assistant message saved (status: ${assistantResult.status || 'ok'})`);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.log(` ❌ Assistant message failed: ${error.message}`);
|
||||
if (error.response) {
|
||||
console.log(` Response: ${JSON.stringify(error.response)}`);
|
||||
}
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '=' .repeat(50));
|
||||
console.log(`\n✅ Done! ${successCount} saved, ${failCount} failed`);
|
||||
console.log('\nNow run the retrieval test:');
|
||||
console.log(' node scripts/test-retrieve-memories.js');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
195
use-cases/claude-code-plugin/server/proxy.js
Executable file
195
use-cases/claude-code-plugin/server/proxy.js
Executable file
@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* EverMem Dashboard Proxy Server
|
||||
*
|
||||
* Serves the dashboard and proxies API requests to EverMind,
|
||||
* working around the browser limitation of not supporting GET requests with body.
|
||||
*
|
||||
* Usage: node proxy.js
|
||||
* Or: EVERMEM_API_KEY=xxx node proxy.js
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PORT = process.env.EVERMEM_PROXY_PORT || 3456;
|
||||
const API_BASE = 'https://api.evermind.ai';
|
||||
const GROUPS_FILE = join(__dirname, '..', 'data', 'groups.jsonl');
|
||||
|
||||
/**
|
||||
* Compute keyId from API key (SHA-256 hash, first 12 chars)
|
||||
*/
|
||||
function computeKeyId(apiKey) {
|
||||
if (!apiKey) return null;
|
||||
const hash = createHash('sha256').update(apiKey).digest('hex');
|
||||
return hash.substring(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read groups from JSONL file and filter by keyId
|
||||
*/
|
||||
function getGroupsForKey(keyId) {
|
||||
if (!existsSync(GROUPS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(GROUPS_FILE, 'utf8');
|
||||
const lines = content.trim().split('\n').filter(Boolean);
|
||||
|
||||
// Aggregate by groupId for matching keyId
|
||||
const groupMap = new Map();
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Only include entries matching this keyId
|
||||
if (entry.keyId !== keyId) continue;
|
||||
|
||||
const existing = groupMap.get(entry.groupId);
|
||||
if (existing) {
|
||||
existing.sessionCount += 1;
|
||||
if (entry.timestamp > existing.lastSeen) {
|
||||
existing.lastSeen = entry.timestamp;
|
||||
}
|
||||
if (entry.timestamp < existing.firstSeen) {
|
||||
existing.firstSeen = entry.timestamp;
|
||||
}
|
||||
} else {
|
||||
groupMap.set(entry.groupId, {
|
||||
id: entry.groupId,
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
firstSeen: entry.timestamp,
|
||||
lastSeen: entry.timestamp,
|
||||
sessionCount: 1
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Sort by lastSeen (most recent first)
|
||||
return Array.from(groupMap.values()).sort((a, b) =>
|
||||
new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime()
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function sendCorsHeaders(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
}
|
||||
|
||||
function sendJson(res, status, data) {
|
||||
sendCorsHeaders(res);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
sendCorsHeaders(res);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward POST /api/v1/memories/{search,get} to the EverMind API
|
||||
if (req.method === 'POST' && (req.url === '/api/v1/memories/search' || req.url === '/api/v1/memories/get')) {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
|
||||
req.on('end', async () => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader) {
|
||||
sendJson(res, 401, { error: 'Missing Authorization header' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${API_BASE}${req.url}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
const text = await upstream.text();
|
||||
sendCorsHeaders(res);
|
||||
res.writeHead(upstream.status, {
|
||||
'Content-Type': upstream.headers.get('content-type') || 'application/json'
|
||||
});
|
||||
res.end(text);
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error.message);
|
||||
sendJson(res, 502, {
|
||||
error: 'Upstream request failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (req.method === 'GET' && req.url === '/health') {
|
||||
sendJson(res, 200, { status: 'ok', port: PORT });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get groups for the current API key
|
||||
if (req.method === 'GET' && req.url === '/api/groups') {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
sendJson(res, 401, { error: 'Missing or invalid Authorization header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = authHeader.replace('Bearer ', '');
|
||||
const keyId = computeKeyId(apiKey);
|
||||
const groups = getGroupsForKey(keyId);
|
||||
|
||||
sendJson(res, 200, {
|
||||
status: 'ok',
|
||||
keyId,
|
||||
groups,
|
||||
totalGroups: groups.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve dashboard HTML
|
||||
if (req.method === 'GET' && (req.url === '/' || req.url.startsWith('/?') || req.url === '/dashboard' || req.url.startsWith('/dashboard?'))) {
|
||||
try {
|
||||
const dashboardPath = join(__dirname, '..', 'assets', 'dashboard.html');
|
||||
const html = readFileSync(dashboardPath, 'utf8');
|
||||
sendCorsHeaders(res);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { error: 'Failed to load dashboard', message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 for everything else
|
||||
sendJson(res, 404, { error: 'Not found' });
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`EverMem Dashboard Proxy running on http://localhost:${PORT}`);
|
||||
console.log('');
|
||||
console.log('The dashboard can now connect to this proxy to fetch memories.');
|
||||
console.log('Press Ctrl+C to stop.');
|
||||
});
|
||||
36
use-cases/claude-code-plugin/skills/memory-tools.md
Normal file
36
use-cases/claude-code-plugin/skills/memory-tools.md
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
description: Guidance for using EverMem memory tools to recall past session context
|
||||
alwaysInclude: true
|
||||
---
|
||||
|
||||
# EverMem Memory Tools
|
||||
|
||||
You have access to memory tools that can recall context from the user's past coding sessions. Use these tools proactively when they would help provide better assistance.
|
||||
|
||||
## Available Tools
|
||||
|
||||
- **search_memories**: Search past conversations using semantic + keyword matching
|
||||
- **get_memory**: Retrieve full details of a specific memory by ID
|
||||
|
||||
## When to Use Memory Search
|
||||
|
||||
**DO search memories when:**
|
||||
- User asks about past work, decisions, or implementations ("how did we handle X?")
|
||||
- User references previous sessions ("remember when", "last time", "we discussed")
|
||||
- User is debugging something that may have been solved before
|
||||
- User asks about project patterns, conventions, or architecture decisions
|
||||
- Context from previous sessions would improve your response
|
||||
- User seems to expect you to know something from before
|
||||
|
||||
**DON'T search memories when:**
|
||||
- The question is self-contained and doesn't need historical context
|
||||
- User explicitly provides all needed context in their message
|
||||
- It's a general knowledge question unrelated to their project history
|
||||
- You've already searched for this topic in the current session
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Be selective**: Don't search for every query - only when past context adds value
|
||||
2. **Use specific queries**: Search for relevant terms, not the entire user message
|
||||
3. **Synthesize results**: When you find relevant memories, integrate them naturally into your response
|
||||
4. **Be transparent**: Mention when your response is informed by past session context
|
||||
21
use-cases/claude-code-plugin/update_local.sh
Executable file
21
use-cases/claude-code-plugin/update_local.sh
Executable file
@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update local plugin installation with current source code
|
||||
|
||||
PLUGIN_NAME="evermem"
|
||||
SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DEST_DIR="$HOME/.claude/plugins/cache/${PLUGIN_NAME}/${PLUGIN_NAME}/0.1.0"
|
||||
|
||||
if [ ! -d "$DEST_DIR" ]; then
|
||||
echo "Error: Plugin not installed at $DEST_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating: $DEST_DIR"
|
||||
|
||||
cp -r "$SOURCE_DIR/hooks" "$DEST_DIR/"
|
||||
cp -r "$SOURCE_DIR/mcp" "$DEST_DIR/" 2>/dev/null || true
|
||||
cp -r "$SOURCE_DIR/skills" "$DEST_DIR/" 2>/dev/null || true
|
||||
cp -r "$SOURCE_DIR/commands" "$DEST_DIR/" 2>/dev/null || true
|
||||
|
||||
echo "✅ Done"
|
||||
21
use-cases/game-of-throne-demo/LICENSE
Normal file
21
use-cases/game-of-throne-demo/LICENSE
Normal 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.
|
||||
167
use-cases/game-of-throne-demo/README.md
Normal file
167
use-cases/game-of-throne-demo/README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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"
|
||||
8
use-cases/game-of-throne-demo/backend/.dockerignore
Normal file
8
use-cases/game-of-throne-demo/backend/.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.eslintrc.cjs
|
||||
12
use-cases/game-of-throne-demo/backend/.env.example
Normal file
12
use-cases/game-of-throne-demo/backend/.env.example
Normal 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
|
||||
17
use-cases/game-of-throne-demo/backend/.eslintrc.cjs
Normal file
17
use-cases/game-of-throne-demo/backend/.eslintrc.cjs
Normal 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: '^_' }],
|
||||
},
|
||||
}
|
||||
20
use-cases/game-of-throne-demo/backend/Dockerfile
Normal file
20
use-cases/game-of-throne-demo/backend/Dockerfile
Normal 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"]
|
||||
28
use-cases/game-of-throne-demo/backend/package.json
Normal file
28
use-cases/game-of-throne-demo/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
274
use-cases/game-of-throne-demo/backend/src/data/mockMemories.ts
Normal file
274
use-cases/game-of-throne-demo/backend/src/data/mockMemories.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
];
|
||||
219
use-cases/game-of-throne-demo/backend/src/routes/chat.ts
Normal file
219
use-cases/game-of-throne-demo/backend/src/routes/chat.ts
Normal 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;
|
||||
}
|
||||
62
use-cases/game-of-throne-demo/backend/src/routes/health.ts
Normal file
62
use-cases/game-of-throne-demo/backend/src/routes/health.ts
Normal 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;
|
||||
}
|
||||
74
use-cases/game-of-throne-demo/backend/src/server.ts
Normal file
74
use-cases/game-of-throne-demo/backend/src/server.ts
Normal 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}`);
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
use-cases/game-of-throne-demo/backend/src/utils/logger.ts
Normal file
22
use-cases/game-of-throne-demo/backend/src/utils/logger.ts
Normal 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();
|
||||
24
use-cases/game-of-throne-demo/backend/tsconfig.json
Normal file
24
use-cases/game-of-throne-demo/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
use-cases/game-of-throne-demo/frontend/.dockerignore
Normal file
8
use-cases/game-of-throne-demo/frontend/.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
deploy.sh
|
||||
5
use-cases/game-of-throne-demo/frontend/.env.example
Normal file
5
use-cases/game-of-throne-demo/frontend/.env.example
Normal 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
|
||||
18
use-cases/game-of-throne-demo/frontend/.eslintrc.cjs
Normal file
18
use-cases/game-of-throne-demo/frontend/.eslintrc.cjs
Normal 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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
1
use-cases/game-of-throne-demo/frontend/.gitignore
vendored
Normal file
1
use-cases/game-of-throne-demo/frontend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.vercel
|
||||
17
use-cases/game-of-throne-demo/frontend/Dockerfile
Normal file
17
use-cases/game-of-throne-demo/frontend/Dockerfile
Normal 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;"]
|
||||
13
use-cases/game-of-throne-demo/frontend/index.html
Normal file
13
use-cases/game-of-throne-demo/frontend/index.html
Normal 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>
|
||||
21
use-cases/game-of-throne-demo/frontend/nginx.conf
Normal file
21
use-cases/game-of-throne-demo/frontend/nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
30
use-cases/game-of-throne-demo/frontend/package.json
Normal file
30
use-cases/game-of-throne-demo/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1287
use-cases/game-of-throne-demo/frontend/src/App.css
Normal file
1287
use-cases/game-of-throne-demo/frontend/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
35
use-cases/game-of-throne-demo/frontend/src/App.tsx
Normal file
35
use-cases/game-of-throne-demo/frontend/src/App.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
143
use-cases/game-of-throne-demo/frontend/src/hooks/useChat.ts
Normal file
143
use-cases/game-of-throne-demo/frontend/src/hooks/useChat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
9
use-cases/game-of-throne-demo/frontend/src/main.tsx
Normal file
9
use-cases/game-of-throne-demo/frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
205
use-cases/game-of-throne-demo/frontend/src/services/api.ts
Normal file
205
use-cases/game-of-throne-demo/frontend/src/services/api.ts
Normal 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();
|
||||
}
|
||||
48
use-cases/game-of-throne-demo/frontend/src/types/index.ts
Normal file
48
use-cases/game-of-throne-demo/frontend/src/types/index.ts
Normal 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;
|
||||
}
|
||||
1
use-cases/game-of-throne-demo/frontend/src/vite-env.d.ts
vendored
Normal file
1
use-cases/game-of-throne-demo/frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
use-cases/game-of-throne-demo/frontend/tsconfig.json
Normal file
25
use-cases/game-of-throne-demo/frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
use-cases/game-of-throne-demo/frontend/tsconfig.node.json
Normal file
10
use-cases/game-of-throne-demo/frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
9
use-cases/game-of-throne-demo/frontend/vite.config.ts
Normal file
9
use-cases/game-of-throne-demo/frontend/vite.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
})
|
||||
27
use-cases/game-of-throne-demo/package.json
Normal file
27
use-cases/game-of-throne-demo/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
93
use-cases/game-of-throne-demo/sample/got-sample.txt
Normal file
93
use-cases/game-of-throne-demo/sample/got-sample.txt
Normal 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.
|
||||
325
use-cases/game-of-throne-demo/scripts/clear-memories-cloud.ts
Normal file
325
use-cases/game-of-throne-demo/scripts/clear-memories-cloud.ts
Normal 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);
|
||||
});
|
||||
323
use-cases/game-of-throne-demo/scripts/get-memories-cloud.ts
Normal file
323
use-cases/game-of-throne-demo/scripts/get-memories-cloud.ts
Normal 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);
|
||||
});
|
||||
786
use-cases/game-of-throne-demo/scripts/load-novel-cloud.ts
Normal file
786
use-cases/game-of-throne-demo/scripts/load-novel-cloud.ts
Normal 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();
|
||||
28
use-cases/openher/.env.example
Normal file
28
use-cases/openher/.env.example
Normal 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
285
use-cases/openher/README.md
Normal 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)
|
||||
362
use-cases/openher/demo/evermemos_demo.py
Normal file
362
use-cases/openher/demo/evermemos_demo.py
Normal 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())
|
||||
66
use-cases/openher/integration/context_features.py
Normal file
66
use-cases/openher/integration/context_features.py
Normal 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.
|
||||
193
use-cases/openher/integration/evermemos_mixin.py
Normal file
193
use-cases/openher/integration/evermemos_mixin.py
Normal 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
|
||||
66
use-cases/openher/integration/memory_types.py
Normal file
66
use-cases/openher/integration/memory_types.py
Normal 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
|
||||
Reference in New Issue
Block a user