@fs/mycroft 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1324 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/config.ts
7
+ import { mkdir, readFile } from "fs/promises";
8
+ import { homedir } from "os";
9
+ import { dirname, join, resolve } from "path";
10
+ var DEFAULT_CONFIG = {
11
+ dataDir: "~/.local/share/mycroft",
12
+ askEnabled: true,
13
+ models: {
14
+ embedding: "text-embedding-3-small",
15
+ summary: "gpt-5-nano",
16
+ chat: "gpt-5.1"
17
+ }
18
+ };
19
+ var expandHome = (input) => {
20
+ if (!input.startsWith("~")) return input;
21
+ return join(homedir(), input.slice(1));
22
+ };
23
+ var resolvePath = (input) => resolve(expandHome(input));
24
+ var getConfigPath = () => {
25
+ const override = process.env.MYCROFT_CONFIG;
26
+ if (override) return resolvePath(override);
27
+ return resolvePath("~/.config/mycroft/config.json");
28
+ };
29
+ var normalizeModels = (models) => ({
30
+ embedding: models?.embedding || DEFAULT_CONFIG.models.embedding,
31
+ summary: models?.summary || DEFAULT_CONFIG.models.summary,
32
+ chat: models?.chat || DEFAULT_CONFIG.models.chat
33
+ });
34
+ var overrides = {};
35
+ var setConfigOverrides = (next) => {
36
+ overrides = { ...overrides, ...next };
37
+ };
38
+ var normalizeConfig = (input) => {
39
+ const dataDirEnv = process.env.MYCROFT_DATA_DIR;
40
+ const dataDir = overrides.dataDir || dataDirEnv || input?.dataDir || DEFAULT_CONFIG.dataDir;
41
+ return {
42
+ dataDir,
43
+ askEnabled: input?.askEnabled ?? DEFAULT_CONFIG.askEnabled,
44
+ models: normalizeModels(input?.models)
45
+ };
46
+ };
47
+ var readConfigFile = async (path) => {
48
+ try {
49
+ const contents = await readFile(path, "utf-8");
50
+ return JSON.parse(contents);
51
+ } catch {
52
+ return null;
53
+ }
54
+ };
55
+ var loadConfig = async () => {
56
+ const configPath2 = getConfigPath();
57
+ const data = await readConfigFile(configPath2);
58
+ const normalized = normalizeConfig(data);
59
+ return {
60
+ ...normalized,
61
+ dataDir: resolvePath(normalized.dataDir)
62
+ };
63
+ };
64
+ var ensureConfigDirs = async (configPath2) => {
65
+ const path = configPath2 || getConfigPath();
66
+ await mkdir(dirname(path), { recursive: true });
67
+ };
68
+ var configPath = () => getConfigPath();
69
+
70
+ // src/commands/io.ts
71
+ import chalk from "chalk";
72
+ var isTTY = () => Boolean(process.stdout.isTTY);
73
+ var isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
74
+ var formatError = (text) => isTTY() ? chalk.red(text) : text;
75
+ var formatWarn = (text) => isTTY() ? chalk.yellow(text) : text;
76
+ var stdout = (message) => {
77
+ process.stdout.write(message.endsWith("\n") ? message : `${message}
78
+ `);
79
+ };
80
+ var stderr = (message) => {
81
+ process.stderr.write(message.endsWith("\n") ? message : `${message}
82
+ `);
83
+ };
84
+ var printError = (message) => {
85
+ stderr(formatError(`Error: ${message}`));
86
+ };
87
+ var logInfo = (message) => {
88
+ stderr(message);
89
+ };
90
+ var logWarn = (message) => {
91
+ stderr(formatWarn(message));
92
+ };
93
+ var handleSigint = (onCancel) => {
94
+ const handler = () => {
95
+ if (onCancel) onCancel();
96
+ stderr("\nCancelled.");
97
+ process.exit(130);
98
+ };
99
+ process.once("SIGINT", handler);
100
+ return () => process.off("SIGINT", handler);
101
+ };
102
+
103
+ // src/cli.ts
104
+ import { readFile as readFile2 } from "fs/promises";
105
+ import { dirname as dirname2, resolve as resolve2 } from "path";
106
+ import { fileURLToPath } from "url";
107
+
108
+ // src/services/epub-parser.ts
109
+ import { initEpubFile } from "@lingo-reader/epub-parser";
110
+ import { basename } from "path";
111
+
112
+ // src/services/constants.ts
113
+ import { mkdir as mkdir2 } from "fs/promises";
114
+ var CHUNK_SIZE = 1e3;
115
+ var CHUNK_OVERLAP = 100;
116
+ var SEPARATORS = ["\n\n", "\n", ". ", " ", ""];
117
+ var SUMMARY_MAX_TOKENS = 3e4;
118
+ var SUMMARY_CONCURRENCY = 3;
119
+ var SUMMARY_TARGET_WORDS = 250;
120
+ var resolvePaths = async () => {
121
+ const config = await loadConfig();
122
+ const dataDir = config.dataDir;
123
+ return {
124
+ dataDir,
125
+ booksDir: `${dataDir}/books`,
126
+ vectorsDir: `${dataDir}/vectors`,
127
+ dbPath: `${dataDir}/metadata.db`
128
+ };
129
+ };
130
+ var ensureDataDirs = async () => {
131
+ const paths = await resolvePaths();
132
+ await mkdir2(paths.dataDir, { recursive: true });
133
+ await mkdir2(paths.booksDir, { recursive: true });
134
+ await mkdir2(paths.vectorsDir, { recursive: true });
135
+ return paths;
136
+ };
137
+ var getModels = async () => {
138
+ const config = await loadConfig();
139
+ return config.models;
140
+ };
141
+ var isAskEnabled = async () => {
142
+ const config = await loadConfig();
143
+ return config.askEnabled;
144
+ };
145
+ var requireOpenAIKey = () => {
146
+ if (!process.env.OPENAI_API_KEY) {
147
+ throw new Error("OPENAI_API_KEY is not set. Export it to use embeddings and chat.");
148
+ }
149
+ };
150
+
151
+ // src/services/epub-parser.ts
152
+ var detectNarrativeBoundaries = (chapterTitles) => {
153
+ const frontMatterPattern = /^(about|contents|table of contents|dedication|preface|foreword|title|half.?title|copyright|epigraph|frontispiece|map)/i;
154
+ const backMatterPattern = /^(acknowledgment|afterword|appendix|glossary|index|bibliography|about the author|also by|praise|copyright page|notes|bonus|preview|excerpt|major characters|locations)/i;
155
+ const narrativePattern = /^(I|II|III|IV|V|VI|VII|VIII|IX|X|XI|XII|1|2|3|4|5|6|7|8|9|one|two|three|chapter|prologue|epilogue|part\s)/i;
156
+ let start = 0;
157
+ let end = chapterTitles.length - 1;
158
+ for (let i = 0; i < chapterTitles.length; i++) {
159
+ const title = chapterTitles[i]?.trim() || "";
160
+ if (narrativePattern.test(title) && !frontMatterPattern.test(title)) {
161
+ start = i;
162
+ break;
163
+ }
164
+ if (!frontMatterPattern.test(title) && title.length > 0) {
165
+ if (title.length > 3) {
166
+ start = i;
167
+ break;
168
+ }
169
+ }
170
+ }
171
+ for (let i = chapterTitles.length - 1; i >= start; i--) {
172
+ const title = chapterTitles[i]?.trim() || "";
173
+ if (!backMatterPattern.test(title)) {
174
+ end = i;
175
+ break;
176
+ }
177
+ }
178
+ logInfo(`[EPUB Parser] Detected narrative boundaries: chapters ${start} to ${end} (out of ${chapterTitles.length} total)`);
179
+ if (start > 0) {
180
+ logInfo(`[EPUB Parser] Front matter: ${chapterTitles.slice(0, start).join(", ")}`);
181
+ }
182
+ if (end < chapterTitles.length - 1) {
183
+ logInfo(`[EPUB Parser] Back matter: ${chapterTitles.slice(end + 1).join(", ")}`);
184
+ }
185
+ return { start, end };
186
+ };
187
+ var stripHtml = (html) => html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, " ").trim();
188
+ var originalWarn = console.warn;
189
+ var createWarnFilter = () => {
190
+ const suppressedWarnings = [];
191
+ console.warn = (msg, ...args) => {
192
+ if (typeof msg === "string" && msg.includes("No element with id") && msg.includes("parsing <metadata>")) {
193
+ suppressedWarnings.push(msg);
194
+ return;
195
+ }
196
+ originalWarn(msg, ...args);
197
+ };
198
+ return suppressedWarnings;
199
+ };
200
+ var parseEpub = async (epubPath, resourceSaveDir) => {
201
+ logInfo(`[EPUB Parser] Starting parse for: ${basename(epubPath)}`);
202
+ const suppressedWarnings = createWarnFilter();
203
+ try {
204
+ const epubFile = await initEpubFile(epubPath, resourceSaveDir);
205
+ await epubFile.loadEpub();
206
+ logInfo(`[EPUB Parser] EPUB loaded successfully`);
207
+ await epubFile.parse();
208
+ if (suppressedWarnings.length > 0) {
209
+ logInfo(`[EPUB Parser] Suppressed ${suppressedWarnings.length} metadata warnings (non-critical)`);
210
+ }
211
+ logInfo(`[EPUB Parser] Parse completed`);
212
+ const fileBaseName = basename(epubPath, ".epub");
213
+ let metadata = null;
214
+ try {
215
+ metadata = epubFile.getMetadata();
216
+ } catch {
217
+ metadata = null;
218
+ }
219
+ const safeMetadata = metadata ?? {};
220
+ const spine = epubFile.getSpine();
221
+ const toc = epubFile.getToc();
222
+ logInfo(`[EPUB Parser] Found ${spine.length} spine items, ${toc.length} TOC entries`);
223
+ const titleById = /* @__PURE__ */ new Map();
224
+ const walkToc = (items) => {
225
+ items.forEach((item) => {
226
+ const resolved = epubFile.resolveHref(item.href);
227
+ if (resolved?.id) titleById.set(resolved.id, item.label);
228
+ if (item.children?.length) walkToc(item.children);
229
+ });
230
+ };
231
+ walkToc(toc);
232
+ const coverImagePath = epubFile.getCoverImage() || null;
233
+ const chapters = [];
234
+ const chapterTitles = [];
235
+ for (const [index, item] of spine.entries()) {
236
+ const chapter = await epubFile.loadChapter(item.id);
237
+ const content = stripHtml(chapter.html);
238
+ if (!content) continue;
239
+ const chapterTitle = titleById.get(item.id) || item.id || `Chapter ${index + 1}`;
240
+ chapters.push({
241
+ title: chapterTitle,
242
+ content
243
+ });
244
+ chapterTitles.push(chapterTitle);
245
+ }
246
+ epubFile.destroy();
247
+ const author = safeMetadata.creator?.[0]?.contributor ?? null;
248
+ logInfo(`[EPUB Parser] Extracted ${chapters.length} chapters with content`);
249
+ logInfo(`[EPUB Parser] Title: "${safeMetadata.title || fileBaseName || "Untitled"}", Author: "${author || "Unknown"}"`);
250
+ const { start: narrativeStartIndex, end: narrativeEndIndex } = detectNarrativeBoundaries(chapterTitles);
251
+ return {
252
+ title: safeMetadata.title || fileBaseName || "Untitled",
253
+ author,
254
+ coverImagePath,
255
+ chapters,
256
+ chapterTitles,
257
+ narrativeStartIndex,
258
+ narrativeEndIndex
259
+ };
260
+ } finally {
261
+ console.warn = originalWarn;
262
+ }
263
+ };
264
+
265
+ // src/services/ingest.ts
266
+ import { randomUUID } from "crypto";
267
+ import { mkdir as mkdir3, unlink, copyFile } from "fs/promises";
268
+
269
+ // src/services/chunker.ts
270
+ var splitRecursive = (text, separators) => {
271
+ if (text.length <= CHUNK_SIZE || separators.length === 0) return [text];
272
+ const [separator, ...rest] = separators;
273
+ if (!separator) return [text];
274
+ const parts = text.split(separator);
275
+ if (parts.length === 1) return splitRecursive(text, rest);
276
+ const chunks = [];
277
+ let current = "";
278
+ for (const part of parts) {
279
+ const next = current ? `${current}${separator}${part}` : part;
280
+ if (next.length <= CHUNK_SIZE) {
281
+ current = next;
282
+ continue;
283
+ }
284
+ if (current) chunks.push(current);
285
+ current = part;
286
+ }
287
+ if (current) chunks.push(current);
288
+ const refined = [];
289
+ for (const chunk of chunks) {
290
+ if (chunk.length <= CHUNK_SIZE) {
291
+ refined.push(chunk);
292
+ continue;
293
+ }
294
+ refined.push(...splitRecursive(chunk, rest));
295
+ }
296
+ return refined;
297
+ };
298
+ var withOverlap = (chunks) => {
299
+ if (chunks.length <= 1 || CHUNK_OVERLAP === 0) return chunks;
300
+ const merged = [];
301
+ for (let i = 0; i < chunks.length; i += 1) {
302
+ const current = chunks[i] ?? "";
303
+ const previous = merged[merged.length - 1];
304
+ if (!previous) {
305
+ merged.push(current);
306
+ continue;
307
+ }
308
+ const overlap = previous.slice(-CHUNK_OVERLAP);
309
+ merged.push(`${overlap}${current}`);
310
+ }
311
+ return merged;
312
+ };
313
+ var chunkChapters = (bookId, chapters) => {
314
+ const chunks = [];
315
+ chapters.forEach((chapter, chapterIndex) => {
316
+ const trimmed = chapter.content.trim();
317
+ if (!trimmed) return;
318
+ const rawChunks = splitRecursive(trimmed, SEPARATORS);
319
+ const overlapped = withOverlap(rawChunks);
320
+ overlapped.forEach((content, chunkIndex) => {
321
+ const normalized = content.replace(/\s+/g, " ").trim();
322
+ if (!normalized) return;
323
+ chunks.push({
324
+ id: `${bookId}-${chapterIndex}-${chunkIndex}`,
325
+ bookId,
326
+ chapterIndex,
327
+ chapterTitle: chapter.title,
328
+ chunkIndex,
329
+ content: normalized
330
+ });
331
+ });
332
+ });
333
+ return chunks;
334
+ };
335
+
336
+ // src/services/embedder.ts
337
+ import { embedMany } from "ai";
338
+ import { openai } from "@ai-sdk/openai";
339
+ var MAX_TOKENS_PER_BATCH = 25e4;
340
+ var CHARS_PER_TOKEN = 4;
341
+ var embedChunks = async (chunks) => {
342
+ if (chunks.length === 0) return [];
343
+ const batches = [];
344
+ let currentBatch = [];
345
+ let currentTokens = 0;
346
+ for (const chunk of chunks) {
347
+ const estimatedTokens = Math.ceil(chunk.content.length / CHARS_PER_TOKEN);
348
+ if (currentTokens + estimatedTokens > MAX_TOKENS_PER_BATCH && currentBatch.length > 0) {
349
+ batches.push(currentBatch);
350
+ currentBatch = [];
351
+ currentTokens = 0;
352
+ }
353
+ currentBatch.push(chunk);
354
+ currentTokens += estimatedTokens;
355
+ }
356
+ if (currentBatch.length > 0) {
357
+ batches.push(currentBatch);
358
+ }
359
+ logInfo(`[Embedder] Processing ${chunks.length} chunks in ${batches.length} batch(es)`);
360
+ const allEmbedded = [];
361
+ const models = await getModels();
362
+ for (let i = 0; i < batches.length; i++) {
363
+ const batch = batches[i];
364
+ const estimatedTokens = batch.reduce((sum, c) => sum + Math.ceil(c.content.length / CHARS_PER_TOKEN), 0);
365
+ logInfo(`[Embedder] Batch ${i + 1}/${batches.length}: ${batch.length} chunks (~${estimatedTokens.toLocaleString()} tokens)`);
366
+ const { embeddings } = await embedMany({
367
+ model: openai.embeddingModel(models.embedding),
368
+ values: batch.map((chunk) => chunk.content)
369
+ });
370
+ for (let j = 0; j < batch.length; j++) {
371
+ allEmbedded.push({
372
+ ...batch[j],
373
+ vector: embeddings[j] ?? []
374
+ });
375
+ }
376
+ }
377
+ logInfo(`[Embedder] Successfully embedded all ${allEmbedded.length} chunks`);
378
+ return allEmbedded;
379
+ };
380
+
381
+ // src/services/vector-store.ts
382
+ import { LocalIndex } from "vectra";
383
+ var indexPathForBook = async (bookId) => {
384
+ const paths = await ensureDataDirs();
385
+ return `${paths.vectorsDir}/${bookId}`;
386
+ };
387
+ var createBookIndex = async (bookId) => {
388
+ const index = new LocalIndex(await indexPathForBook(bookId));
389
+ const exists = await index.isIndexCreated();
390
+ if (!exists) {
391
+ await index.createIndex({
392
+ version: 1,
393
+ metadata_config: {
394
+ indexed: ["bookId"]
395
+ }
396
+ });
397
+ }
398
+ return index;
399
+ };
400
+ var addChunksToIndex = async (bookId, chunks) => {
401
+ const index = await createBookIndex(bookId);
402
+ await index.batchInsertItems(
403
+ chunks.map((chunk) => ({
404
+ id: chunk.id,
405
+ vector: chunk.vector,
406
+ metadata: {
407
+ bookId: chunk.bookId,
408
+ chapterIndex: chunk.chapterIndex,
409
+ chapterTitle: chunk.chapterTitle,
410
+ chunkIndex: chunk.chunkIndex,
411
+ content: chunk.content,
412
+ type: chunk.type || "chunk"
413
+ }
414
+ }))
415
+ );
416
+ };
417
+ var queryBookIndex = async (bookId, queryVector, queryText, topK, maxChapterIndex) => {
418
+ const index = await createBookIndex(bookId);
419
+ const expandedTopK = maxChapterIndex === void 0 || maxChapterIndex === null ? topK : Math.max(topK * 4, topK);
420
+ const results = await index.queryItems(queryVector, queryText, expandedTopK);
421
+ const mapped = results.map((result) => ({
422
+ id: result.item.id ?? "",
423
+ bookId,
424
+ chapterIndex: result.item.metadata?.chapterIndex ?? 0,
425
+ chapterTitle: result.item.metadata?.chapterTitle ?? "",
426
+ chunkIndex: result.item.metadata?.chunkIndex ?? 0,
427
+ content: result.item.metadata?.content ?? "",
428
+ type: result.item.metadata?.type,
429
+ score: result.score
430
+ }));
431
+ if (maxChapterIndex === void 0 || maxChapterIndex === null) {
432
+ return mapped.slice(0, topK);
433
+ }
434
+ return mapped.filter((item) => item.chapterIndex <= maxChapterIndex).slice(0, topK);
435
+ };
436
+ var deleteBookIndex = async (bookId) => {
437
+ const index = new LocalIndex(await indexPathForBook(bookId));
438
+ const exists = await index.isIndexCreated();
439
+ if (!exists) return;
440
+ await index.deleteIndex();
441
+ };
442
+
443
+ // src/services/summarizer.ts
444
+ import { generateText } from "ai";
445
+ import { openai as openai2 } from "@ai-sdk/openai";
446
+ var CHARS_PER_TOKEN2 = 4;
447
+ var estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN2);
448
+ var SUMMARY_PROMPT = (title, chapterNum, content) => `You are analyzing a chapter from a book (fiction or nonfiction). Extract key information to help readers understand the chapter's content.
449
+
450
+ Chapter Title: ${title}
451
+ Chapter Number: ${chapterNum}
452
+
453
+ ---
454
+ ${content}
455
+ ---
456
+
457
+ Extract the following information and respond ONLY with valid JSON (no markdown, no code blocks):
458
+
459
+ {
460
+ "characters": ["Name - brief description (role, traits, first appearance)", ...],
461
+ "events": "What happens in this chapter? (2-3 sentences)",
462
+ "setting": "Where does this chapter take place?",
463
+ "revelations": "Any important information revealed? (secrets, backstory, foreshadowing)"
464
+ }
465
+
466
+ Keep the total response around ${SUMMARY_TARGET_WORDS} words.`;
467
+ var splitIntoSections = (text, maxTokens) => {
468
+ const estimatedTokens = estimateTokens(text);
469
+ if (estimatedTokens <= maxTokens) {
470
+ return [text];
471
+ }
472
+ const numSections = Math.ceil(estimatedTokens / maxTokens);
473
+ const charsPerSection = Math.floor(text.length / numSections);
474
+ const sections = [];
475
+ for (let i = 0; i < numSections; i++) {
476
+ const start = i * charsPerSection;
477
+ const end = i === numSections - 1 ? text.length : (i + 1) * charsPerSection;
478
+ sections.push(text.slice(start, end));
479
+ }
480
+ return sections;
481
+ };
482
+ var summarizeSection = async (text, title, sectionNum) => {
483
+ const models = await getModels();
484
+ const { text: summary } = await generateText({
485
+ model: openai2(models.summary),
486
+ prompt: `Summarize this section from chapter "${title}" (Part ${sectionNum}). Focus on key events, characters, and revelations. Keep it concise (100-150 words):
487
+
488
+ ${text}`,
489
+ temperature: 0.3
490
+ });
491
+ return summary;
492
+ };
493
+ var generateStructuredSummary = async (content, title, chapterIndex) => {
494
+ try {
495
+ const models = await getModels();
496
+ const { text } = await generateText({
497
+ model: openai2(models.summary),
498
+ prompt: SUMMARY_PROMPT(title, chapterIndex + 1, content),
499
+ temperature: 0.3
500
+ });
501
+ let jsonText = text.trim();
502
+ if (jsonText.startsWith("```json")) {
503
+ jsonText = jsonText.slice(7, -3).trim();
504
+ } else if (jsonText.startsWith("```")) {
505
+ jsonText = jsonText.slice(3, -3).trim();
506
+ }
507
+ const parsed = JSON.parse(jsonText);
508
+ const fullSummary = `Chapter ${chapterIndex + 1}: ${title}
509
+
510
+ Characters: ${parsed.characters.join(", ")}
511
+
512
+ Events: ${parsed.events}
513
+
514
+ Setting: ${parsed.setting}
515
+
516
+ Revelations: ${parsed.revelations}`;
517
+ return {
518
+ chapterIndex,
519
+ chapterTitle: title,
520
+ characters: parsed.characters,
521
+ events: parsed.events,
522
+ setting: parsed.setting,
523
+ revelations: parsed.revelations,
524
+ fullSummary
525
+ };
526
+ } catch (error) {
527
+ logWarn(`[Summarizer] Failed to parse summary JSON for "${title}": ${error instanceof Error ? error.message : String(error)}`);
528
+ return null;
529
+ }
530
+ };
531
+ var summarizeChapter = async (chapter, chapterIndex) => {
532
+ const tokens = estimateTokens(chapter.content);
533
+ logInfo(`[Summarizer] Chapter ${chapterIndex + 1} "${chapter.title}": ~${tokens.toLocaleString()} tokens`);
534
+ try {
535
+ if (tokens < SUMMARY_MAX_TOKENS) {
536
+ return await generateStructuredSummary(chapter.content, chapter.title, chapterIndex);
537
+ }
538
+ logInfo(`[Summarizer] Chapter ${chapterIndex + 1} exceeds token limit, using two-pass approach`);
539
+ const sections = splitIntoSections(chapter.content, SUMMARY_MAX_TOKENS);
540
+ logInfo(`[Summarizer] Split into ${sections.length} sections`);
541
+ const sectionSummaries = await Promise.all(
542
+ sections.map((section, i) => summarizeSection(section, chapter.title, i + 1))
543
+ );
544
+ const combined = sectionSummaries.join("\n\n");
545
+ return await generateStructuredSummary(combined, chapter.title, chapterIndex);
546
+ } catch (error) {
547
+ logWarn(`[Summarizer] Failed to summarize chapter ${chapterIndex + 1}: ${error instanceof Error ? error.message : String(error)}`);
548
+ return null;
549
+ }
550
+ };
551
+ var summarizeAllChapters = async (chapters) => {
552
+ const summaries = [];
553
+ logInfo(`[Summarizer] Starting summarization of ${chapters.length} chapters (concurrency: ${SUMMARY_CONCURRENCY})`);
554
+ for (let i = 0; i < chapters.length; i += SUMMARY_CONCURRENCY) {
555
+ const batch = chapters.slice(i, i + SUMMARY_CONCURRENCY);
556
+ const batchPromises = batch.map((chapter, batchIndex) => summarizeChapter(chapter, i + batchIndex));
557
+ const batchResults = await Promise.all(batchPromises);
558
+ for (const summary of batchResults) {
559
+ if (summary) {
560
+ summaries.push(summary);
561
+ }
562
+ }
563
+ logInfo(`[Summarizer] Progress: ${Math.min(i + SUMMARY_CONCURRENCY, chapters.length)}/${chapters.length} chapters processed`);
564
+ }
565
+ logInfo(`[Summarizer] Completed: ${summaries.length}/${chapters.length} summaries generated`);
566
+ return summaries;
567
+ };
568
+
569
+ // src/db/schema.ts
570
+ import Database from "better-sqlite3";
571
+ var resolveDbPath = async () => {
572
+ const paths = await resolvePaths();
573
+ return paths.dbPath;
574
+ };
575
+ var createDb = async () => {
576
+ const db = new Database(await resolveDbPath());
577
+ db.exec(`
578
+ CREATE TABLE IF NOT EXISTS books (
579
+ id TEXT PRIMARY KEY,
580
+ title TEXT NOT NULL,
581
+ author TEXT,
582
+ cover_path TEXT,
583
+ chapters TEXT,
584
+ epub_path TEXT NOT NULL,
585
+ chunk_count INTEGER DEFAULT 0,
586
+ created_at INTEGER DEFAULT (strftime('%s','now')),
587
+ indexed_at INTEGER,
588
+ progress_chapter INTEGER
589
+ );
590
+ `);
591
+ const columns = db.prepare("PRAGMA table_info(books)").all().map((col) => col.name);
592
+ const ensureColumn = (name, definition) => {
593
+ if (!columns.includes(name)) {
594
+ db.exec(`ALTER TABLE books ADD COLUMN ${definition}`);
595
+ }
596
+ };
597
+ ensureColumn("chapters", "chapters TEXT");
598
+ ensureColumn("progress_chapter", "progress_chapter INTEGER");
599
+ ensureColumn("summaries", "summaries TEXT");
600
+ ensureColumn("narrative_start_index", "narrative_start_index INTEGER DEFAULT 0");
601
+ ensureColumn("narrative_end_index", "narrative_end_index INTEGER");
602
+ return db;
603
+ };
604
+
605
+ // src/db/queries.ts
606
+ var mapRow = (row) => ({
607
+ id: row.id,
608
+ title: row.title,
609
+ author: row.author ?? null,
610
+ coverPath: row.cover_path ?? null,
611
+ epubPath: row.epub_path,
612
+ chunkCount: row.chunk_count ?? 0,
613
+ createdAt: row.created_at ?? 0,
614
+ indexedAt: row.indexed_at ?? null,
615
+ chapters: row.chapters ? JSON.parse(row.chapters) : [],
616
+ progressChapter: row.progress_chapter ?? null,
617
+ narrativeStartIndex: row.narrative_start_index ?? null,
618
+ narrativeEndIndex: row.narrative_end_index ?? null
619
+ });
620
+ var dbPromise = null;
621
+ var getDb = async () => {
622
+ if (!dbPromise) {
623
+ dbPromise = createDb();
624
+ }
625
+ return dbPromise;
626
+ };
627
+ var insertBook = async (book) => {
628
+ const db = await getDb();
629
+ const statement = db.prepare(
630
+ "INSERT INTO books (id, title, author, cover_path, chapters, epub_path, chunk_count, indexed_at, progress_chapter, narrative_start_index, narrative_end_index) VALUES (@id, @title, @author, @coverPath, @chapters, @epubPath, @chunkCount, @indexedAt, @progressChapter, @narrativeStartIndex, @narrativeEndIndex)"
631
+ );
632
+ statement.run({
633
+ id: book.id,
634
+ title: book.title,
635
+ author: book.author,
636
+ coverPath: book.coverPath,
637
+ chapters: JSON.stringify(book.chapters ?? []),
638
+ epubPath: book.epubPath,
639
+ chunkCount: book.chunkCount ?? 0,
640
+ indexedAt: book.indexedAt ?? null,
641
+ progressChapter: book.progressChapter ?? null,
642
+ narrativeStartIndex: book.narrativeStartIndex ?? null,
643
+ narrativeEndIndex: book.narrativeEndIndex ?? null
644
+ });
645
+ return book.id;
646
+ };
647
+ var updateBook = async (id, updates) => {
648
+ const fields = [];
649
+ const params = { id };
650
+ if (updates.title !== void 0) {
651
+ fields.push("title = @title");
652
+ params.title = updates.title;
653
+ }
654
+ if (updates.author !== void 0) {
655
+ fields.push("author = @author");
656
+ params.author = updates.author;
657
+ }
658
+ if (updates.coverPath !== void 0) {
659
+ fields.push("cover_path = @coverPath");
660
+ params.coverPath = updates.coverPath;
661
+ }
662
+ if (updates.chapters !== void 0) {
663
+ fields.push("chapters = @chapters");
664
+ params.chapters = JSON.stringify(updates.chapters);
665
+ }
666
+ if (updates.epubPath !== void 0) {
667
+ fields.push("epub_path = @epubPath");
668
+ params.epubPath = updates.epubPath;
669
+ }
670
+ if (updates.chunkCount !== void 0) {
671
+ fields.push("chunk_count = @chunkCount");
672
+ params.chunkCount = updates.chunkCount;
673
+ }
674
+ if (updates.indexedAt !== void 0) {
675
+ fields.push("indexed_at = @indexedAt");
676
+ params.indexedAt = updates.indexedAt;
677
+ }
678
+ if (updates.progressChapter !== void 0) {
679
+ fields.push("progress_chapter = @progressChapter");
680
+ params.progressChapter = updates.progressChapter;
681
+ }
682
+ if (updates.summaries !== void 0) {
683
+ fields.push("summaries = @summaries");
684
+ params.summaries = updates.summaries;
685
+ }
686
+ if (updates.narrativeStartIndex !== void 0) {
687
+ fields.push("narrative_start_index = @narrativeStartIndex");
688
+ params.narrativeStartIndex = updates.narrativeStartIndex;
689
+ }
690
+ if (updates.narrativeEndIndex !== void 0) {
691
+ fields.push("narrative_end_index = @narrativeEndIndex");
692
+ params.narrativeEndIndex = updates.narrativeEndIndex;
693
+ }
694
+ if (fields.length === 0) return;
695
+ const db = await getDb();
696
+ db.prepare(`UPDATE books SET ${fields.join(", ")} WHERE id = @id`).run(params);
697
+ };
698
+ var getBooks = async () => {
699
+ const db = await getDb();
700
+ const rows = db.prepare("SELECT * FROM books ORDER BY created_at DESC").all();
701
+ return rows.map(mapRow);
702
+ };
703
+ var getBook = async (id) => {
704
+ const db = await getDb();
705
+ const row = db.prepare("SELECT * FROM books WHERE id = ?").get(id);
706
+ return row ? mapRow(row) : null;
707
+ };
708
+ var deleteBook = async (id) => {
709
+ const db = await getDb();
710
+ db.prepare("DELETE FROM books WHERE id = ?").run(id);
711
+ };
712
+
713
+ // src/services/ingest.ts
714
+ var formatDuration = (ms) => {
715
+ const seconds = Math.round(ms / 100) / 10;
716
+ return `${seconds}s`;
717
+ };
718
+ var ingestEpub = async (filePath, selectedChapterIndices, options) => {
719
+ const bookId = randomUUID();
720
+ const paths = await ensureDataDirs();
721
+ const fileName = `${bookId}.epub`;
722
+ const bookPath = `${paths.booksDir}/${fileName}`;
723
+ logInfo(`[Ingest] Starting ingestion for book ${bookId}`);
724
+ await mkdir3(paths.booksDir, { recursive: true });
725
+ await copyFile(filePath, bookPath);
726
+ logInfo(`[Ingest] EPUB file saved to ${bookPath}`);
727
+ const parseStart = Date.now();
728
+ const parsed = await parseEpub(bookPath);
729
+ logInfo(`[Ingest] Parsed "${parsed.title}" with ${parsed.chapters.length} chapters (${formatDuration(Date.now() - parseStart)})`);
730
+ logInfo(`[Ingest] Narrative chapters: ${parsed.narrativeStartIndex} to ${parsed.narrativeEndIndex}`);
731
+ await insertBook({
732
+ id: bookId,
733
+ title: parsed.title,
734
+ author: parsed.author,
735
+ coverPath: parsed.coverImagePath,
736
+ epubPath: bookPath,
737
+ chapters: parsed.chapterTitles,
738
+ narrativeStartIndex: parsed.narrativeStartIndex,
739
+ narrativeEndIndex: parsed.narrativeEndIndex
740
+ });
741
+ logInfo(`[Ingest] Book record inserted into database`);
742
+ try {
743
+ const chaptersToProcess = selectedChapterIndices ? parsed.chapters.filter((_, index) => selectedChapterIndices.includes(index)) : parsed.chapters.slice(parsed.narrativeStartIndex, parsed.narrativeEndIndex + 1);
744
+ const selectedIndices = selectedChapterIndices || Array.from(
745
+ { length: parsed.narrativeEndIndex - parsed.narrativeStartIndex + 1 },
746
+ (_, i) => i + parsed.narrativeStartIndex
747
+ );
748
+ logInfo(`[Ingest] Processing ${chaptersToProcess.length} selected chapters (indices: ${selectedIndices.join(", ")})`);
749
+ let adjustedSummaries = [];
750
+ if (options?.summarize !== false) {
751
+ logInfo(`[Ingest] Generating summaries for ${chaptersToProcess.length} chapters...`);
752
+ const summarizeStart = Date.now();
753
+ const summaries = await summarizeAllChapters(chaptersToProcess);
754
+ logInfo(`[Ingest] Generated ${summaries.length}/${chaptersToProcess.length} summaries (${formatDuration(Date.now() - summarizeStart)})`);
755
+ const summaryRecords = summaries.map((s, idx) => ({
756
+ ...s,
757
+ chapterIndex: selectedIndices[idx] ?? s.chapterIndex
758
+ }));
759
+ await updateBook(bookId, {
760
+ summaries: JSON.stringify(summaryRecords)
761
+ });
762
+ adjustedSummaries = summaryRecords.map((s) => ({
763
+ id: `${bookId}-summary-${s.chapterIndex}`,
764
+ bookId,
765
+ chapterIndex: s.chapterIndex,
766
+ chapterTitle: s.chapterTitle,
767
+ chunkIndex: -1,
768
+ content: s.fullSummary,
769
+ type: "summary"
770
+ }));
771
+ logInfo(`[Ingest] Created ${adjustedSummaries.length} summary chunks`);
772
+ }
773
+ const chunksToProcess = parsed.chapters.map(
774
+ (chapter, index) => selectedIndices.includes(index) ? chapter : { title: chapter.title, content: "" }
775
+ );
776
+ const chunks = chunkChapters(bookId, chunksToProcess).filter((chunk) => chunk.content.length > 0);
777
+ logInfo(`[Ingest] Created ${chunks.length} chunks from selected chapters`);
778
+ const allChunks = [...chunks, ...adjustedSummaries];
779
+ const embedStart = Date.now();
780
+ const embedded = await embedChunks(allChunks);
781
+ logInfo(`[Ingest] Embedded ${embedded.length} total chunks (${formatDuration(Date.now() - embedStart)})`);
782
+ await addChunksToIndex(bookId, embedded);
783
+ logInfo(`[Ingest] Added chunks to vector index`);
784
+ await updateBook(bookId, { chunkCount: embedded.length, indexedAt: Date.now() });
785
+ logInfo(`[Ingest] Updated book record with chunk count: ${embedded.length}`);
786
+ } catch (error) {
787
+ logWarn(`[Ingest] Error during chunking/embedding: ${error instanceof Error ? error.message : String(error)}`);
788
+ await deleteBookIndex(bookId);
789
+ await unlink(bookPath).catch(() => void 0);
790
+ await deleteBook(bookId).catch(() => void 0);
791
+ throw error;
792
+ }
793
+ logInfo(`[Ingest] Ingestion complete for ${bookId}`);
794
+ return { id: bookId };
795
+ };
796
+
797
+ // src/commands/ingest.ts
798
+ import { access } from "fs/promises";
799
+
800
+ // src/commands/prompt.ts
801
+ import { createInterface } from "readline/promises";
802
+ var prompt = async (question) => {
803
+ const release = handleSigint();
804
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
805
+ try {
806
+ const response = await rl.question(question);
807
+ return response.trim();
808
+ } finally {
809
+ rl.close();
810
+ release();
811
+ }
812
+ };
813
+ var confirm = async (question) => {
814
+ const response = await prompt(question);
815
+ const normalized = response.trim().toLowerCase();
816
+ return normalized === "y" || normalized === "yes";
817
+ };
818
+
819
+ // src/commands/ingest.ts
820
+ var parseIndexSelection = (input, max) => {
821
+ const trimmed = input.trim();
822
+ if (!trimmed) return [];
823
+ const tokens = trimmed.split(",").map((part) => part.trim()).filter(Boolean);
824
+ const indices = /* @__PURE__ */ new Set();
825
+ for (const token of tokens) {
826
+ if (token.includes("-")) {
827
+ const [startRaw, endRaw] = token.split("-");
828
+ const start = Number(startRaw);
829
+ const end = Number(endRaw);
830
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
831
+ for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
832
+ if (i >= 0 && i < max) indices.add(i);
833
+ }
834
+ } else {
835
+ const index = Number(token);
836
+ if (Number.isFinite(index) && index >= 0 && index < max) indices.add(index);
837
+ }
838
+ }
839
+ return Array.from(indices).sort((a, b) => a - b);
840
+ };
841
+ var ingestCommand = async (filePath, options) => {
842
+ requireOpenAIKey();
843
+ await ensureDataDirs();
844
+ try {
845
+ await access(filePath);
846
+ } catch {
847
+ throw new Error(`File not found: ${filePath}`);
848
+ }
849
+ let selectedChapterIndices;
850
+ if (options.manual) {
851
+ if (!isInteractive()) {
852
+ throw new Error("Manual chapter selection requires an interactive terminal.");
853
+ }
854
+ const parsed = await parseEpub(filePath);
855
+ if (parsed.chapterTitles.length === 0) {
856
+ throw new Error("No chapters found in EPUB");
857
+ }
858
+ stdout("Chapters:");
859
+ parsed.chapterTitles.forEach((title, index) => {
860
+ const marker = index >= parsed.narrativeStartIndex && index <= parsed.narrativeEndIndex ? "*" : " ";
861
+ stdout(`${marker} [${index}] ${title}`);
862
+ });
863
+ stdout("\nEnter chapter indices to ingest (e.g. 0-10,12). Press Enter for narrative range.");
864
+ const answer = await prompt("Selection: ");
865
+ const indices = parseIndexSelection(answer, parsed.chapterTitles.length);
866
+ if (indices.length > 0) {
867
+ selectedChapterIndices = indices;
868
+ } else {
869
+ selectedChapterIndices = Array.from(
870
+ { length: parsed.narrativeEndIndex - parsed.narrativeStartIndex + 1 },
871
+ (_, i) => i + parsed.narrativeStartIndex
872
+ );
873
+ }
874
+ }
875
+ const result = await ingestEpub(filePath, selectedChapterIndices, { summarize: options.summarize ?? false });
876
+ stdout(`
877
+ Done. Book indexed as ${result.id}`);
878
+ };
879
+
880
+ // src/commands/book/ingest.ts
881
+ var registerBookIngest = (program2) => {
882
+ program2.command("ingest").description("Ingest an EPUB file").argument("<path>", "Path to the EPUB file").option("--manual", "Interactive chapter selection").option("--summary", "Enable AI chapter summaries").action(async (path, options) => {
883
+ const summarize = Boolean(options.summary);
884
+ await ingestCommand(path, { manual: options.manual, summarize });
885
+ });
886
+ };
887
+
888
+ // src/commands/list.ts
889
+ var formatDate = (timestamp) => {
890
+ if (!timestamp) return "-";
891
+ return new Date(timestamp).toISOString().slice(0, 10);
892
+ };
893
+ var listCommand = async () => {
894
+ await ensureDataDirs();
895
+ const books = await getBooks();
896
+ if (books.length === 0) {
897
+ stdout("No books indexed yet.");
898
+ return;
899
+ }
900
+ stdout("ID | Title | Author | Chunks | Indexed | Status");
901
+ stdout("---------|-------|--------|--------|--------|-------");
902
+ for (const book of books) {
903
+ const shortId = book.id.slice(0, 8);
904
+ const title = book.title;
905
+ const author = book.author || "-";
906
+ const chunks = String(book.chunkCount ?? 0);
907
+ const indexed = formatDate(book.indexedAt);
908
+ const status = book.indexedAt ? "[indexed]" : "[pending]";
909
+ stdout(`${shortId} | ${title} | ${author} | ${chunks} | ${indexed} | ${status}`);
910
+ }
911
+ };
912
+
913
+ // src/commands/book/list.ts
914
+ var registerBookList = (program2) => {
915
+ program2.command("list").description("List indexed books").action(async () => {
916
+ await listCommand();
917
+ });
918
+ };
919
+
920
+ // src/commands/utils.ts
921
+ var resolveBookId = async (input) => {
922
+ const books = await getBooks();
923
+ const exact = books.find((book) => book.id === input);
924
+ if (exact) return exact.id;
925
+ const matches = books.filter((book) => book.id.startsWith(input));
926
+ if (matches.length === 1) return matches[0].id;
927
+ if (matches.length > 1) {
928
+ throw new Error(`Ambiguous id prefix "${input}" (${matches.length} matches)`);
929
+ }
930
+ return null;
931
+ };
932
+
933
+ // src/commands/show.ts
934
+ var showCommand = async (id) => {
935
+ await ensureDataDirs();
936
+ const resolvedId = await resolveBookId(id);
937
+ if (!resolvedId) {
938
+ throw new Error(`Book not found: ${id}`);
939
+ }
940
+ const book = await getBook(resolvedId);
941
+ if (!book) {
942
+ throw new Error(`Book not found: ${id}`);
943
+ }
944
+ stdout(`Title: ${book.title}`);
945
+ stdout(`Author: ${book.author ?? "-"}`);
946
+ stdout(`ID: ${book.id}`);
947
+ stdout(`Chunks: ${book.chunkCount}`);
948
+ stdout(`Indexed: ${book.indexedAt ? new Date(book.indexedAt).toISOString() : "-"}`);
949
+ stdout(`Narrative range: ${book.narrativeStartIndex ?? 0} to ${book.narrativeEndIndex ?? book.chapters.length - 1}`);
950
+ stdout(`Progress chapter: ${book.progressChapter ?? "-"}`);
951
+ stdout("\nChapters:");
952
+ book.chapters.forEach((title, index) => {
953
+ const marker = index === book.narrativeStartIndex ? "[start]" : index === book.narrativeEndIndex ? "[end]" : "";
954
+ stdout(` [${index}] ${title} ${marker}`.trim());
955
+ });
956
+ };
957
+
958
+ // src/commands/book/show.ts
959
+ var registerBookShow = (program2) => {
960
+ program2.command("show").description("Show full book metadata").argument("<id>", "Book id or prefix").action(async (id) => {
961
+ await showCommand(id);
962
+ });
963
+ };
964
+
965
+ // src/commands/ask.ts
966
+ import { embed, streamText } from "ai";
967
+ import { openai as openai3 } from "@ai-sdk/openai";
968
+ var formatContext = (chunks) => chunks.map(
969
+ (chunk, index) => `Excerpt [${index + 1}] (${chunk.chapterTitle || `Chapter ${chunk.chapterIndex + 1}`}):
970
+ ${chunk.content}`
971
+ ).join("\n\n");
972
+ var askCommand = async (id, question, options) => {
973
+ if (!await isAskEnabled()) {
974
+ throw new Error("Ask is disabled in config (askEnabled: false). Enable it to use this command.");
975
+ }
976
+ requireOpenAIKey();
977
+ await ensureDataDirs();
978
+ const resolvedId = await resolveBookId(id);
979
+ if (!resolvedId) {
980
+ throw new Error(`Book not found: ${id}`);
981
+ }
982
+ const book = await getBook(resolvedId);
983
+ if (!book) {
984
+ throw new Error(`Book not found: ${id}`);
985
+ }
986
+ const models = await getModels();
987
+ const { embedding } = await embed({
988
+ model: openai3.embeddingModel(models.embedding),
989
+ value: question
990
+ });
991
+ const narrativeStart = book.narrativeStartIndex ?? 0;
992
+ const userProgress = book.progressChapter ?? null;
993
+ const maxChapterIndex = options.maxChapter !== void 0 ? narrativeStart + options.maxChapter : userProgress !== null ? narrativeStart + userProgress : void 0;
994
+ const retrievalLimit = options.topK * 3;
995
+ const allMatches = await queryBookIndex(resolvedId, embedding, question, retrievalLimit, maxChapterIndex);
996
+ const summaries = allMatches.filter((m) => m.type === "summary");
997
+ const chunks = allMatches.filter((m) => m.type !== "summary");
998
+ const topSummaries = summaries.slice(0, 2);
999
+ const topChunks = chunks.slice(0, Math.max(0, options.topK - topSummaries.length));
1000
+ const selectedMatches = [...topSummaries, ...topChunks];
1001
+ const context = formatContext(selectedMatches);
1002
+ const releaseSigint = handleSigint();
1003
+ const stream = streamText({
1004
+ model: openai3(models.chat),
1005
+ system: `You are a reading companion helping readers understand this book.
1006
+
1007
+ Guidelines:
1008
+ - Use the provided chapter summaries and excerpts to answer questions
1009
+ - Chapter summaries provide high-level context about characters, events, and plot
1010
+ - Excerpts provide specific details and quotes
1011
+ - When asked for recaps or "what happened", synthesize from summaries
1012
+ - Don't cite table of contents, front matter, or structural elements
1013
+ - If truly unsure, briefly say so - but try to answer from available context first
1014
+ - Cite sources using [1], [2], etc. at the end of relevant sentences
1015
+ - The context may be limited to earlier chapters only - don't infer beyond what's provided`,
1016
+ prompt: `Question: ${question}
1017
+
1018
+ ${context}`
1019
+ });
1020
+ try {
1021
+ for await (const part of stream.textStream) {
1022
+ process.stdout.write(part);
1023
+ }
1024
+ } finally {
1025
+ releaseSigint();
1026
+ }
1027
+ if (selectedMatches.length > 0) {
1028
+ process.stdout.write("\n\nSources:\n");
1029
+ selectedMatches.forEach((match, index) => {
1030
+ const title = match.chapterTitle || `Chapter ${match.chapterIndex + 1}`;
1031
+ const excerpt = match.content.slice(0, 120).replace(/\s+/g, " ");
1032
+ process.stdout.write(`[${index + 1}] ${title}: ${excerpt}
1033
+ `);
1034
+ });
1035
+ }
1036
+ };
1037
+
1038
+ // src/commands/book/ask.ts
1039
+ var registerBookAsk = (program2) => {
1040
+ program2.command("ask").description("Ask a question about a book").argument("<id>", "Book id or prefix").argument("<question>", "Question to ask").option("--top-k <n>", "Number of passages to retrieve", "5").option("--max-chapter <n>", "Spoiler-free limit (0-based within narrative)").action(async (id, question, options) => {
1041
+ const topK = Number(options.topK);
1042
+ if (!Number.isFinite(topK) || topK <= 0) {
1043
+ throw new Error("--top-k must be a positive number.");
1044
+ }
1045
+ let maxChapter;
1046
+ if (options.maxChapter !== void 0) {
1047
+ const parsed = Number(options.maxChapter);
1048
+ if (!Number.isFinite(parsed) || parsed < 0) {
1049
+ throw new Error("--max-chapter must be a non-negative number.");
1050
+ }
1051
+ maxChapter = parsed;
1052
+ }
1053
+ await askCommand(id, question, { topK, maxChapter });
1054
+ });
1055
+ };
1056
+
1057
+ // src/commands/search.ts
1058
+ import { embed as embed2 } from "ai";
1059
+ import { openai as openai4 } from "@ai-sdk/openai";
1060
+ var searchCommand = async (id, query, options) => {
1061
+ requireOpenAIKey();
1062
+ await ensureDataDirs();
1063
+ const resolvedId = await resolveBookId(id);
1064
+ if (!resolvedId) {
1065
+ throw new Error(`Book not found: ${id}`);
1066
+ }
1067
+ const book = await getBook(resolvedId);
1068
+ if (!book) {
1069
+ throw new Error(`Book not found: ${id}`);
1070
+ }
1071
+ const models = await getModels();
1072
+ const { embedding } = await embed2({
1073
+ model: openai4.embeddingModel(models.embedding),
1074
+ value: query
1075
+ });
1076
+ const maxChapterIndex = options.maxChapter !== void 0 ? (book.narrativeStartIndex ?? 0) + options.maxChapter : book.progressChapter !== null ? (book.narrativeStartIndex ?? 0) + (book.progressChapter ?? 0) : void 0;
1077
+ const results = await queryBookIndex(resolvedId, embedding, query, options.topK, maxChapterIndex);
1078
+ if (results.length === 0) {
1079
+ stdout("No results.");
1080
+ return;
1081
+ }
1082
+ results.forEach((result, index) => {
1083
+ const chapterTitle = result.chapterTitle || `Chapter ${result.chapterIndex + 1}`;
1084
+ const excerpt = result.content.slice(0, 200).replace(/\s+/g, " ");
1085
+ stdout(`
1086
+ #${index + 1} score=${result.score.toFixed(4)} type=${result.type || "chunk"}`);
1087
+ stdout(`${chapterTitle} (chapter ${result.chapterIndex})`);
1088
+ stdout(excerpt);
1089
+ });
1090
+ };
1091
+
1092
+ // src/commands/book/search.ts
1093
+ var registerBookSearch = (program2) => {
1094
+ program2.command("search").description("Vector search without LLM").argument("<id>", "Book id or prefix").argument("<query>", "Search query").option("--top-k <n>", "Number of passages to retrieve", "5").option("--max-chapter <n>", "Spoiler-free limit (0-based within narrative)").action(async (id, query, options) => {
1095
+ const topK = Number(options.topK);
1096
+ if (!Number.isFinite(topK) || topK <= 0) {
1097
+ throw new Error("--top-k must be a positive number.");
1098
+ }
1099
+ let maxChapter;
1100
+ if (options.maxChapter !== void 0) {
1101
+ const parsed = Number(options.maxChapter);
1102
+ if (!Number.isFinite(parsed) || parsed < 0) {
1103
+ throw new Error("--max-chapter must be a non-negative number.");
1104
+ }
1105
+ maxChapter = parsed;
1106
+ }
1107
+ await searchCommand(id, query, { topK, maxChapter });
1108
+ });
1109
+ };
1110
+
1111
+ // src/commands/delete.ts
1112
+ import { unlink as unlink2 } from "fs/promises";
1113
+ var deleteCommand = async (id, options) => {
1114
+ await ensureDataDirs();
1115
+ const resolvedId = await resolveBookId(id);
1116
+ if (!resolvedId) {
1117
+ throw new Error(`Book not found: ${id}`);
1118
+ }
1119
+ const book = await getBook(resolvedId);
1120
+ if (!book) {
1121
+ throw new Error(`Book not found: ${id}`);
1122
+ }
1123
+ if (!options.force) {
1124
+ if (!isInteractive()) {
1125
+ throw new Error("Delete confirmation requires an interactive terminal. Use --force to bypass.");
1126
+ }
1127
+ const ok = await confirm(`Delete "${book.title}" (${book.id})? [y/N] `);
1128
+ if (!ok) {
1129
+ stdout("Cancelled.");
1130
+ return;
1131
+ }
1132
+ }
1133
+ await deleteBook(resolvedId);
1134
+ await deleteBookIndex(resolvedId);
1135
+ if (book.epubPath) {
1136
+ await unlink2(book.epubPath).catch(() => void 0);
1137
+ }
1138
+ stdout(`Deleted book ${book.id}`);
1139
+ };
1140
+
1141
+ // src/commands/book/delete.ts
1142
+ var registerBookDelete = (program2) => {
1143
+ program2.command("delete").description("Remove book, EPUB, and vectors").argument("<id>", "Book id or prefix").option("--force", "Skip confirmation").action(async (id, options) => {
1144
+ await deleteCommand(id, { force: options.force });
1145
+ });
1146
+ };
1147
+
1148
+ // src/commands/config.ts
1149
+ var configCommand = async () => {
1150
+ const path = configPath();
1151
+ stdout(path);
1152
+ };
1153
+
1154
+ // src/commands/config/path.ts
1155
+ var registerConfigPath = (program2) => {
1156
+ program2.command("path").description("Print config path").action(async () => {
1157
+ await configCommand();
1158
+ });
1159
+ };
1160
+
1161
+ // src/commands/init-config.ts
1162
+ import { mkdir as mkdir4, writeFile, access as access2 } from "fs/promises";
1163
+ var initConfigCommand = async () => {
1164
+ const path = configPath();
1165
+ await ensureConfigDirs(path);
1166
+ try {
1167
+ await access2(path);
1168
+ stdout(`Config already exists: ${path}`);
1169
+ return;
1170
+ } catch {
1171
+ }
1172
+ const resolved = await loadConfig();
1173
+ const template = {
1174
+ dataDir: "~/.local/share/mycroft",
1175
+ askEnabled: resolved.askEnabled,
1176
+ models: resolved.models
1177
+ };
1178
+ await writeFile(path, JSON.stringify(template, null, 2), "utf-8");
1179
+ await mkdir4(resolved.dataDir, { recursive: true });
1180
+ stdout(`Created config at ${path}`);
1181
+ };
1182
+
1183
+ // src/commands/config/init.ts
1184
+ var registerConfigInit = (program2) => {
1185
+ program2.command("init").description("Create default config file").action(async () => {
1186
+ await initConfigCommand();
1187
+ });
1188
+ };
1189
+
1190
+ // src/commands/resolve-config.ts
1191
+ var resolveConfigCommand = async () => {
1192
+ const path = configPath();
1193
+ const config = await loadConfig();
1194
+ stdout(`Config: ${path}`);
1195
+ stdout(`Data dir: ${config.dataDir}`);
1196
+ stdout(`Ask enabled: ${config.askEnabled}`);
1197
+ stdout(`Models: embedding=${config.models.embedding} summary=${config.models.summary} chat=${config.models.chat}`);
1198
+ };
1199
+
1200
+ // src/commands/config/resolve.ts
1201
+ var registerConfigResolve = (program2) => {
1202
+ program2.command("resolve").description("Print resolved config values").action(async () => {
1203
+ await resolveConfigCommand();
1204
+ });
1205
+ };
1206
+
1207
+ // src/commands/onboard.ts
1208
+ import { writeFile as writeFile2 } from "fs/promises";
1209
+ var isDefault = (input) => input === "" || input.toLowerCase() === "-y";
1210
+ var parseBoolean = (input, fallback) => {
1211
+ if (isDefault(input)) return fallback;
1212
+ const normalized = input.toLowerCase();
1213
+ if (["y", "yes", "true", "1"].includes(normalized)) return true;
1214
+ if (["n", "no", "false", "0"].includes(normalized)) return false;
1215
+ return fallback;
1216
+ };
1217
+ var onboardCommand = async () => {
1218
+ if (!isInteractive()) {
1219
+ throw new Error("Onboarding requires an interactive terminal.");
1220
+ }
1221
+ const defaults = await loadConfig();
1222
+ const path = configPath();
1223
+ stdout("\nEPUB RAG setup");
1224
+ stdout("Press Enter or type -y to accept defaults.");
1225
+ const dataDirInput = await prompt(`Data directory [${defaults.dataDir}]: `);
1226
+ const dataDir = isDefault(dataDirInput) ? defaults.dataDir : dataDirInput;
1227
+ const askEnabledInput = await prompt(`Enable ask (LLM answers) [${defaults.askEnabled ? "Y" : "N"}]: `);
1228
+ const askEnabled = parseBoolean(askEnabledInput, defaults.askEnabled);
1229
+ const embeddingInput = await prompt(`Embedding model [${defaults.models.embedding}]: `);
1230
+ const embedding = isDefault(embeddingInput) ? defaults.models.embedding : embeddingInput;
1231
+ const summaryInput = await prompt(`Summary model [${defaults.models.summary}]: `);
1232
+ const summary = isDefault(summaryInput) ? defaults.models.summary : summaryInput;
1233
+ const chatInput = await prompt(`Chat model [${defaults.models.chat}]: `);
1234
+ const chat = isDefault(chatInput) ? defaults.models.chat : chatInput;
1235
+ await ensureConfigDirs(path);
1236
+ await writeFile2(
1237
+ path,
1238
+ JSON.stringify(
1239
+ {
1240
+ dataDir,
1241
+ askEnabled,
1242
+ models: {
1243
+ embedding,
1244
+ summary,
1245
+ chat
1246
+ }
1247
+ },
1248
+ null,
1249
+ 2
1250
+ ),
1251
+ "utf-8"
1252
+ );
1253
+ stdout("\nSetup complete.");
1254
+ stdout(`Config: ${path}`);
1255
+ stdout(`Data dir: ${dataDir}`);
1256
+ if (!process.env.OPENAI_API_KEY) {
1257
+ stdout("\nOPENAI_API_KEY is not set.");
1258
+ stdout("Export it to enable embeddings and chat:");
1259
+ stdout(' export OPENAI_API_KEY="..."');
1260
+ }
1261
+ stdout("\nNext step:");
1262
+ stdout(" mycroft book ingest /path/to/book.epub");
1263
+ };
1264
+
1265
+ // src/commands/config/onboard.ts
1266
+ var registerConfigOnboard = (program2) => {
1267
+ program2.command("onboard").description("Initialize config and show next step").action(async () => {
1268
+ await onboardCommand();
1269
+ });
1270
+ };
1271
+
1272
+ // src/cli.ts
1273
+ var resolveVersion = async () => {
1274
+ try {
1275
+ const currentDir = dirname2(fileURLToPath(import.meta.url));
1276
+ const pkgPath = resolve2(currentDir, "../package.json");
1277
+ const raw = await readFile2(pkgPath, "utf-8");
1278
+ return JSON.parse(raw).version || "0.1.0";
1279
+ } catch {
1280
+ return "0.1.0";
1281
+ }
1282
+ };
1283
+ var program = new Command();
1284
+ var configureProgram = async () => {
1285
+ program.name("mycroft").description("Ingest EPUBs, build a local index, and answer questions").version(await resolveVersion()).option("--data-dir <path>", "Override data directory").hook("preAction", (cmd) => {
1286
+ const opts = cmd.opts();
1287
+ if (opts.dataDir) {
1288
+ setConfigOverrides({ dataDir: opts.dataDir });
1289
+ }
1290
+ });
1291
+ };
1292
+ var registerCommands = () => {
1293
+ const book = program.command("book").description("Manage books and queries");
1294
+ registerBookIngest(book);
1295
+ registerBookList(book);
1296
+ registerBookShow(book);
1297
+ registerBookAsk(book);
1298
+ registerBookSearch(book);
1299
+ registerBookDelete(book);
1300
+ const config = program.command("config").description("Manage configuration");
1301
+ registerConfigPath(config);
1302
+ registerConfigInit(config);
1303
+ registerConfigResolve(config);
1304
+ registerConfigOnboard(config);
1305
+ };
1306
+ program.exitOverride((error) => {
1307
+ if (error.code === "commander.helpDisplayed") {
1308
+ process.exit(0);
1309
+ }
1310
+ throw error;
1311
+ });
1312
+ var main = async () => {
1313
+ try {
1314
+ await configureProgram();
1315
+ registerCommands();
1316
+ await program.parseAsync(process.argv);
1317
+ } catch (error) {
1318
+ const message = error instanceof Error ? error.message : String(error);
1319
+ printError(message);
1320
+ process.exit(1);
1321
+ }
1322
+ };
1323
+ main();
1324
+ //# sourceMappingURL=cli.js.map