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