chore: initialize EverOS 1.0.0

md-first memory extraction framework for AI agents.

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
.vercel

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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