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.
787 lines
25 KiB
TypeScript
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();
|