Files
EverOS/use-cases/game-of-throne-demo/scripts/load-novel-cloud.ts
Elliot Chen 518b8eca85 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.
2026-06-06 07:33:17 +08:00

787 lines
25 KiB
TypeScript

#!/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();