@fs/mycroft 0.3.0 → 0.4.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 CHANGED
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  submitBatchEmbeddings
4
- } from "./chunk-XXO66RCF.js";
4
+ } from "./chunk-VBEGUDHG.js";
5
5
  import {
6
+ CHARS_PER_TOKEN,
7
+ SUMMARY_PROMPT,
8
+ parseStructuredSummary,
9
+ splitIntoSections,
6
10
  submitBatchSummaries
7
- } from "./chunk-7DUQNGEK.js";
11
+ } from "./chunk-T6X7DRBN.js";
8
12
  import {
9
13
  CHUNK_OVERLAP,
10
14
  CHUNK_SIZE,
@@ -27,7 +31,7 @@ import {
27
31
  resolvePaths,
28
32
  setConfigOverrides,
29
33
  stdout
30
- } from "./chunk-KGG7WEYE.js";
34
+ } from "./chunk-LV52FEMB.js";
31
35
 
32
36
  // src/cli.ts
33
37
  import { Command } from "commander";
@@ -89,8 +93,9 @@ var createWarnFilter = () => {
89
93
  var parseEpub = async (epubPath, resourceSaveDir) => {
90
94
  logInfo(`[EPUB Parser] Starting parse for: ${basename(epubPath)}`);
91
95
  const suppressedWarnings = createWarnFilter();
96
+ let epubFile = null;
92
97
  try {
93
- const epubFile = await initEpubFile(epubPath, resourceSaveDir);
98
+ epubFile = await initEpubFile(epubPath, resourceSaveDir);
94
99
  await epubFile.loadEpub();
95
100
  logInfo(`[EPUB Parser] EPUB loaded successfully`);
96
101
  await epubFile.parse();
@@ -132,7 +137,6 @@ var parseEpub = async (epubPath, resourceSaveDir) => {
132
137
  });
133
138
  chapterTitles.push(chapterTitle);
134
139
  }
135
- epubFile.destroy();
136
140
  const author = safeMetadata.creator?.[0]?.contributor ?? null;
137
141
  logInfo(`[EPUB Parser] Extracted ${chapters.length} chapters with content`);
138
142
  logInfo(`[EPUB Parser] Title: "${safeMetadata.title || fileBaseName || "Untitled"}", Author: "${author || "Unknown"}"`);
@@ -147,6 +151,7 @@ var parseEpub = async (epubPath, resourceSaveDir) => {
147
151
  narrativeEndIndex
148
152
  };
149
153
  } finally {
154
+ epubFile?.destroy();
150
155
  console.warn = originalWarn;
151
156
  }
152
157
  };
@@ -226,14 +231,14 @@ var chunkChapters = (bookId, chapters) => {
226
231
  import { embedMany } from "ai";
227
232
  import { openai } from "@ai-sdk/openai";
228
233
  var MAX_TOKENS_PER_BATCH = 25e4;
229
- var CHARS_PER_TOKEN = 4;
234
+ var CHARS_PER_TOKEN2 = 4;
230
235
  var embedChunks = async (chunks, options) => {
231
236
  if (chunks.length === 0) return [];
232
237
  const batches = [];
233
238
  let currentBatch = [];
234
239
  let currentTokens = 0;
235
240
  for (const chunk of chunks) {
236
- const estimatedTokens = Math.ceil(chunk.content.length / CHARS_PER_TOKEN);
241
+ const estimatedTokens = Math.ceil(chunk.content.length / CHARS_PER_TOKEN2);
237
242
  if (currentTokens + estimatedTokens > MAX_TOKENS_PER_BATCH && currentBatch.length > 0) {
238
243
  batches.push(currentBatch);
239
244
  currentBatch = [];
@@ -250,7 +255,7 @@ var embedChunks = async (chunks, options) => {
250
255
  const models = await getModels();
251
256
  for (let i = 0; i < batches.length; i++) {
252
257
  const batch = batches[i];
253
- const estimatedTokens = batch.reduce((sum, c) => sum + Math.ceil(c.content.length / CHARS_PER_TOKEN), 0);
258
+ const estimatedTokens = batch.reduce((sum, c) => sum + Math.ceil(c.content.length / CHARS_PER_TOKEN2), 0);
254
259
  logInfo(`[Embedder] Batch ${i + 1}/${batches.length}: ${batch.length} chunks (~${estimatedTokens.toLocaleString()} tokens)`);
255
260
  const { embeddings } = await embedMany({
256
261
  model: openai.embeddingModel(models.embedding),
@@ -258,9 +263,13 @@ var embedChunks = async (chunks, options) => {
258
263
  });
259
264
  const embeddedBatch = [];
260
265
  for (let j = 0; j < batch.length; j++) {
266
+ const vector = embeddings[j] ?? [];
267
+ if (vector.length === 0) {
268
+ logWarn(`[Embedder] Chunk ${allEmbedded.length + j} has empty embedding`);
269
+ }
261
270
  const embeddedChunk = {
262
271
  ...batch[j],
263
- vector: embeddings[j] ?? []
272
+ vector
264
273
  };
265
274
  embeddedBatch.push(embeddedChunk);
266
275
  allEmbedded.push({
@@ -282,11 +291,14 @@ var embedChunks = async (chunks, options) => {
282
291
 
283
292
  // src/services/vector-store.ts
284
293
  import { LocalIndex } from "vectra";
294
+ var indexCache = /* @__PURE__ */ new Map();
285
295
  var indexPathForBook = async (bookId) => {
286
296
  const paths = await ensureDataDirs();
287
297
  return `${paths.vectorsDir}/${bookId}`;
288
298
  };
289
299
  var createBookIndex = async (bookId) => {
300
+ const cached = indexCache.get(bookId);
301
+ if (cached) return cached;
290
302
  const index = new LocalIndex(await indexPathForBook(bookId));
291
303
  const exists = await index.isIndexCreated();
292
304
  if (!exists) {
@@ -297,6 +309,7 @@ var createBookIndex = async (bookId) => {
297
309
  }
298
310
  });
299
311
  }
312
+ indexCache.set(bookId, index);
300
313
  return index;
301
314
  };
302
315
  var addChunksToIndex = async (bookId, chunks) => {
@@ -336,6 +349,7 @@ var queryBookIndex = async (bookId, queryVector, queryText, topK, maxChapterInde
336
349
  return mapped.filter((item) => item.chapterIndex <= maxChapterIndex).slice(0, topK);
337
350
  };
338
351
  var deleteBookIndex = async (bookId) => {
352
+ indexCache.delete(bookId);
339
353
  const index = new LocalIndex(await indexPathForBook(bookId));
340
354
  const exists = await index.isIndexCreated();
341
355
  if (!exists) return;
@@ -345,42 +359,7 @@ var deleteBookIndex = async (bookId) => {
345
359
  // src/services/summarizer.ts
346
360
  import { generateText } from "ai";
347
361
  import { openai as openai2 } from "@ai-sdk/openai";
348
- var CHARS_PER_TOKEN2 = 4;
349
- var estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN2);
350
- 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.
351
-
352
- Chapter Title: ${title}
353
- Chapter Number: ${chapterNum}
354
-
355
- ---
356
- ${content}
357
- ---
358
-
359
- Extract the following information and respond ONLY with valid JSON (no markdown, no code blocks):
360
-
361
- {
362
- "characters": ["Name - brief description (role, traits, first appearance)", ...],
363
- "events": "What happens in this chapter? (2-3 sentences)",
364
- "setting": "Where does this chapter take place?",
365
- "revelations": "Any important information revealed? (secrets, backstory, foreshadowing)"
366
- }
367
-
368
- Keep the total response around ${SUMMARY_TARGET_WORDS} words.`;
369
- var splitIntoSections = (text, maxTokens) => {
370
- const estimatedTokens = estimateTokens(text);
371
- if (estimatedTokens <= maxTokens) {
372
- return [text];
373
- }
374
- const numSections = Math.ceil(estimatedTokens / maxTokens);
375
- const charsPerSection = Math.floor(text.length / numSections);
376
- const sections = [];
377
- for (let i = 0; i < numSections; i++) {
378
- const start = i * charsPerSection;
379
- const end = i === numSections - 1 ? text.length : (i + 1) * charsPerSection;
380
- sections.push(text.slice(start, end));
381
- }
382
- return sections;
383
- };
362
+ var estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN);
384
363
  var summarizeSection = async (text, title, sectionNum) => {
385
364
  const models = await getModels();
386
365
  const { text: summary } = await generateText({
@@ -396,33 +375,9 @@ var generateStructuredSummary = async (content, title, chapterIndex) => {
396
375
  const models = await getModels();
397
376
  const { text } = await generateText({
398
377
  model: openai2(models.summary),
399
- prompt: SUMMARY_PROMPT(title, chapterIndex + 1, content)
378
+ prompt: SUMMARY_PROMPT(title, chapterIndex + 1, content, SUMMARY_TARGET_WORDS)
400
379
  });
401
- let jsonText = text.trim();
402
- if (jsonText.startsWith("```json")) {
403
- jsonText = jsonText.slice(7, -3).trim();
404
- } else if (jsonText.startsWith("```")) {
405
- jsonText = jsonText.slice(3, -3).trim();
406
- }
407
- const parsed = JSON.parse(jsonText);
408
- const fullSummary = `Chapter ${chapterIndex + 1}: ${title}
409
-
410
- Characters: ${parsed.characters.join(", ")}
411
-
412
- Events: ${parsed.events}
413
-
414
- Setting: ${parsed.setting}
415
-
416
- Revelations: ${parsed.revelations}`;
417
- return {
418
- chapterIndex,
419
- chapterTitle: title,
420
- characters: parsed.characters,
421
- events: parsed.events,
422
- setting: parsed.setting,
423
- revelations: parsed.revelations,
424
- fullSummary
425
- };
380
+ return parseStructuredSummary(text, chapterIndex, title);
426
381
  } catch (error) {
427
382
  logWarn(`[Summarizer] Failed to parse summary JSON for "${title}": ${error instanceof Error ? error.message : String(error)}`);
428
383
  return null;
@@ -479,6 +434,7 @@ var resolveDbPath = async () => {
479
434
  };
480
435
  var createDb = async () => {
481
436
  const db = new Database(await resolveDbPath());
437
+ db.pragma("foreign_keys = ON");
482
438
  db.exec(`
483
439
  CREATE TABLE IF NOT EXISTS books (
484
440
  id TEXT PRIMARY KEY,
@@ -496,7 +452,7 @@ var createDb = async () => {
496
452
  db.exec(`
497
453
  CREATE TABLE IF NOT EXISTS chat_sessions (
498
454
  id TEXT PRIMARY KEY,
499
- book_id TEXT NOT NULL,
455
+ book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
500
456
  title TEXT,
501
457
  summary TEXT,
502
458
  created_at INTEGER DEFAULT (strftime('%s','now')),
@@ -506,7 +462,7 @@ var createDb = async () => {
506
462
  db.exec(`
507
463
  CREATE TABLE IF NOT EXISTS chat_messages (
508
464
  id TEXT PRIMARY KEY,
509
- session_id TEXT NOT NULL,
465
+ session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
510
466
  role TEXT NOT NULL,
511
467
  content TEXT NOT NULL,
512
468
  token_count INTEGER,
@@ -555,7 +511,10 @@ var mapRow = (row) => ({
555
511
  ingestState: row.ingest_state ?? null,
556
512
  ingestResumePath: row.ingest_resume_path ?? null,
557
513
  summaryBatchId: row.summary_batch_id ?? null,
558
- summaryBatchFileId: row.summary_batch_file_id ?? null
514
+ summaryBatchFileId: row.summary_batch_file_id ?? null,
515
+ summaryBatchChapters: row.summary_batch_chapters ?? null,
516
+ summaries: row.summaries ?? null,
517
+ batchChunks: row.batch_chunks ?? null
559
518
  });
560
519
  var dbPromise = null;
561
520
  var getDb = async () => {
@@ -689,9 +648,12 @@ var getBookSummaryBatchChapters = async (id) => {
689
648
  };
690
649
  var deleteBook = async (id) => {
691
650
  const db = await getDb();
692
- db.prepare("DELETE FROM chat_messages WHERE session_id IN (SELECT id FROM chat_sessions WHERE book_id = ?)").run(id);
693
- db.prepare("DELETE FROM chat_sessions WHERE book_id = ?").run(id);
694
- db.prepare("DELETE FROM books WHERE id = ?").run(id);
651
+ const deleteAll = db.transaction((bookId) => {
652
+ db.prepare("DELETE FROM chat_messages WHERE session_id IN (SELECT id FROM chat_sessions WHERE book_id = ?)").run(bookId);
653
+ db.prepare("DELETE FROM chat_sessions WHERE book_id = ?").run(bookId);
654
+ db.prepare("DELETE FROM books WHERE id = ?").run(bookId);
655
+ });
656
+ deleteAll(id);
695
657
  };
696
658
  var mapSession = (row) => ({
697
659
  id: row.id,
@@ -722,8 +684,8 @@ var insertChatSession = async (session) => {
722
684
  bookId: session.bookId,
723
685
  title: session.title ?? null,
724
686
  summary: session.summary ?? null,
725
- createdAt: session.createdAt ?? Date.now(),
726
- updatedAt: session.updatedAt ?? Date.now()
687
+ createdAt: session.createdAt ?? Math.floor(Date.now() / 1e3),
688
+ updatedAt: session.updatedAt ?? Math.floor(Date.now() / 1e3)
727
689
  });
728
690
  return session.id;
729
691
  };
@@ -768,7 +730,7 @@ var insertChatMessage = async (message) => {
768
730
  role: message.role,
769
731
  content: message.content,
770
732
  tokenCount: message.tokenCount ?? null,
771
- createdAt: message.createdAt ?? Date.now()
733
+ createdAt: message.createdAt ?? Math.floor(Date.now() / 1e3)
772
734
  });
773
735
  return message.id;
774
736
  };
@@ -927,7 +889,7 @@ var ingestEpub = async (filePath, selectedChapterIndices, options) => {
927
889
  return { id: bookId, status: "completed" };
928
890
  };
929
891
  var resumeIngest = async (bookId, storedChunks, batchId, batchFileId) => {
930
- const { checkBatchStatus, downloadBatchResults, cleanupBatchFiles } = await import("./batch-embedder-ZJZLNLOK.js");
892
+ const { checkBatchStatus, downloadBatchResults, cleanupBatchFiles } = await import("./batch-embedder-C2E6OHBQ.js");
931
893
  logInfo(`[Resume] Checking embedding batch ${batchId} for book ${bookId}`);
932
894
  const status = await checkBatchStatus(batchId);
933
895
  logInfo(`[Resume] Batch status: ${status.status} (completed: ${status.completed}/${status.total})`);
@@ -937,7 +899,7 @@ var resumeIngest = async (bookId, storedChunks, batchId, batchFileId) => {
937
899
  if (status.status === "failed" || status.status === "expired" || status.status === "cancelled") {
938
900
  logWarn(`[Resume] Batch ${batchId} ended with status "${status.status}". Re-submitting...`);
939
901
  await cleanupBatchFiles(batchFileId, status.outputFileId);
940
- const { submitBatchEmbeddings: submitBatchEmbeddings2 } = await import("./batch-embedder-ZJZLNLOK.js");
902
+ const { submitBatchEmbeddings: submitBatchEmbeddings2 } = await import("./batch-embedder-C2E6OHBQ.js");
941
903
  const { batchId: newBatchId, inputFileId: newFileId } = await submitBatchEmbeddings2(storedChunks);
942
904
  await updateBook(bookId, { batchId: newBatchId, batchFileId: newFileId });
943
905
  logInfo(`[Resume] New batch submitted (${newBatchId}). Run resume again later.`);
@@ -949,7 +911,7 @@ var resumeIngest = async (bookId, storedChunks, batchId, batchFileId) => {
949
911
  if (!status.outputFileId) {
950
912
  logWarn(`[Resume] Batch ${batchId} completed but produced no output (${status.failed}/${status.total} failed). Re-submitting...`);
951
913
  await cleanupBatchFiles(batchFileId, null);
952
- const { submitBatchEmbeddings: submitBatchEmbeddings2 } = await import("./batch-embedder-ZJZLNLOK.js");
914
+ const { submitBatchEmbeddings: submitBatchEmbeddings2 } = await import("./batch-embedder-C2E6OHBQ.js");
953
915
  const { batchId: newBatchId, inputFileId: newFileId } = await submitBatchEmbeddings2(storedChunks);
954
916
  await updateBook(bookId, { batchId: newBatchId, batchFileId: newFileId });
955
917
  logInfo(`[Resume] New batch submitted (${newBatchId}). Run resume again later.`);
@@ -970,8 +932,8 @@ var resumeIngest = async (bookId, storedChunks, batchId, batchFileId) => {
970
932
  return { status: "completed" };
971
933
  };
972
934
  var resumeSummaryBatch = async (bookId, summaryBatchId, summaryBatchFileId, storedData) => {
973
- const { checkBatchStatus, cleanupBatchFiles } = await import("./batch-embedder-ZJZLNLOK.js");
974
- const { downloadBatchSummaryResults, submitMergePass, downloadMergeResults } = await import("./batch-summarizer-BMIBVFAE.js");
935
+ const { checkBatchStatus, cleanupBatchFiles } = await import("./batch-embedder-C2E6OHBQ.js");
936
+ const { downloadBatchSummaryResults, submitMergePass, downloadMergeResults } = await import("./batch-summarizer-CM3NO7TK.js");
975
937
  logInfo(`[Resume] Checking summary batch ${summaryBatchId} for book ${bookId}`);
976
938
  const status = await checkBatchStatus(summaryBatchId);
977
939
  logInfo(`[Resume] Summary batch status: ${status.status} (completed: ${status.completed}/${status.total})`);
@@ -981,7 +943,7 @@ var resumeSummaryBatch = async (bookId, summaryBatchId, summaryBatchFileId, stor
981
943
  if (status.status === "failed" || status.status === "expired" || status.status === "cancelled") {
982
944
  logWarn(`[Resume] Summary batch ${summaryBatchId} ended with status "${status.status}". Re-submitting...`);
983
945
  await cleanupBatchFiles(summaryBatchFileId, status.outputFileId);
984
- const { submitBatchSummaries: submitBatchSummaries2 } = await import("./batch-summarizer-BMIBVFAE.js");
946
+ const { submitBatchSummaries: submitBatchSummaries2 } = await import("./batch-summarizer-CM3NO7TK.js");
985
947
  const { batchId: newBatchId, inputFileId: newFileId, metadata: newMetadata } = await submitBatchSummaries2(storedData.chapters);
986
948
  await updateBook(bookId, {
987
949
  summaryBatchId: newBatchId,
@@ -997,7 +959,7 @@ var resumeSummaryBatch = async (bookId, summaryBatchId, summaryBatchFileId, stor
997
959
  if (!status.outputFileId) {
998
960
  logWarn(`[Resume] Summary batch ${summaryBatchId} completed but produced no output (${status.failed}/${status.total} failed). Re-submitting...`);
999
961
  await cleanupBatchFiles(summaryBatchFileId, null);
1000
- const { submitBatchSummaries: submitBatchSummaries2 } = await import("./batch-summarizer-BMIBVFAE.js");
962
+ const { submitBatchSummaries: submitBatchSummaries2 } = await import("./batch-summarizer-CM3NO7TK.js");
1001
963
  const { batchId: newBatchId, inputFileId: newFileId, metadata: newMetadata } = await submitBatchSummaries2(storedData.chapters);
1002
964
  await updateBook(bookId, {
1003
965
  summaryBatchId: newBatchId,
@@ -1031,8 +993,8 @@ var resumeSummaryBatch = async (bookId, summaryBatchId, summaryBatchFileId, stor
1031
993
  return await finalizeSummariesAndSubmitEmbeddings(bookId, summaries, storedData);
1032
994
  };
1033
995
  var resumeMergeBatch = async (bookId, summaryBatchId, summaryBatchFileId, storedData) => {
1034
- const { checkBatchStatus, cleanupBatchFiles } = await import("./batch-embedder-ZJZLNLOK.js");
1035
- const { downloadMergeResults } = await import("./batch-summarizer-BMIBVFAE.js");
996
+ const { checkBatchStatus, cleanupBatchFiles } = await import("./batch-embedder-C2E6OHBQ.js");
997
+ const { downloadMergeResults } = await import("./batch-summarizer-CM3NO7TK.js");
1036
998
  logInfo(`[Resume] Checking merge batch ${summaryBatchId} for book ${bookId}`);
1037
999
  const status = await checkBatchStatus(summaryBatchId);
1038
1000
  logInfo(`[Resume] Merge batch status: ${status.status} (completed: ${status.completed}/${status.total})`);
@@ -1054,7 +1016,7 @@ var resumeMergeBatch = async (bookId, summaryBatchId, summaryBatchFileId, stored
1054
1016
  return await finalizeSummariesAndSubmitEmbeddings(bookId, allSummaries, storedData);
1055
1017
  };
1056
1018
  var finalizeSummariesAndSubmitEmbeddings = async (bookId, summaries, storedData) => {
1057
- const { submitBatchEmbeddings: submitBatchEmbeddings2 } = await import("./batch-embedder-ZJZLNLOK.js");
1019
+ const { submitBatchEmbeddings: submitBatchEmbeddings2 } = await import("./batch-embedder-C2E6OHBQ.js");
1058
1020
  const summaryRecords = summaries.map((s) => ({
1059
1021
  ...s,
1060
1022
  chapterIndex: storedData.selectedIndices[s.chapterIndex] ?? s.chapterIndex
@@ -1246,7 +1208,7 @@ NOTES
1246
1208
  // src/commands/list.ts
1247
1209
  var formatDate = (timestamp) => {
1248
1210
  if (!timestamp) return "-";
1249
- return new Date(timestamp).toISOString().slice(0, 10);
1211
+ return new Date(timestamp * 1e3).toISOString().slice(0, 10);
1250
1212
  };
1251
1213
  var listCommand = async () => {
1252
1214
  await ensureDataDirs();
@@ -1255,8 +1217,8 @@ var listCommand = async () => {
1255
1217
  stdout("No books indexed yet.");
1256
1218
  return;
1257
1219
  }
1258
- stdout("ID | Title | Author | Chunks | Indexed | Status");
1259
- stdout("---------|-------|--------|--------|--------|-------");
1220
+ stdout("ID | Title | Author | Chunks | Indexed | Status");
1221
+ stdout("---------|-------|--------|--------|------------|-------");
1260
1222
  for (const book of books) {
1261
1223
  const shortId = book.id.slice(0, 8);
1262
1224
  const title = book.title;
@@ -1324,10 +1286,38 @@ var registerBookShow = (program2) => {
1324
1286
  // src/commands/ask.ts
1325
1287
  import { embed, streamText } from "ai";
1326
1288
  import { openai as openai3 } from "@ai-sdk/openai";
1289
+
1290
+ // src/shared/utils.ts
1291
+ var CHARS_PER_TOKEN3 = 4;
1292
+ var estimateTokens2 = (text) => Math.ceil(text.length / CHARS_PER_TOKEN3);
1293
+ var renderSources = (sources) => {
1294
+ if (sources.length === 0) return "";
1295
+ const lines = sources.map((match, index) => {
1296
+ const title = match.chapterTitle || `Chapter ${match.chapterIndex + 1}`;
1297
+ const excerpt = match.content.slice(0, 120).replace(/\s+/g, " ");
1298
+ return `[${index + 1}] ${title}: ${excerpt}`;
1299
+ });
1300
+ return `
1301
+ Sources:
1302
+ ${lines.join("\n")}`;
1303
+ };
1304
+ var resolveMaxChapter = (book, maxChapterOption) => {
1305
+ const narrativeStart = book.narrativeStartIndex ?? 0;
1306
+ const userProgress = book.progressChapter ?? null;
1307
+ if (maxChapterOption !== void 0) {
1308
+ return narrativeStart + maxChapterOption;
1309
+ }
1310
+ if (userProgress !== null) {
1311
+ return narrativeStart + userProgress;
1312
+ }
1313
+ return void 0;
1314
+ };
1327
1315
  var formatContext = (chunks) => chunks.map(
1328
1316
  (chunk, index) => `Excerpt [${index + 1}] (${chunk.chapterTitle || `Chapter ${chunk.chapterIndex + 1}`}):
1329
1317
  ${chunk.content}`
1330
1318
  ).join("\n\n");
1319
+
1320
+ // src/commands/ask.ts
1331
1321
  var askCommand = async (id, question, options) => {
1332
1322
  if (!await isAskEnabled()) {
1333
1323
  throw new Error("Ask is disabled in config (askEnabled: false). Enable it to use this command.");
@@ -1347,9 +1337,7 @@ var askCommand = async (id, question, options) => {
1347
1337
  model: openai3.embeddingModel(models.embedding),
1348
1338
  value: question
1349
1339
  });
1350
- const narrativeStart = book.narrativeStartIndex ?? 0;
1351
- const userProgress = book.progressChapter ?? null;
1352
- const maxChapterIndex = options.maxChapter !== void 0 ? narrativeStart + options.maxChapter : userProgress !== null ? narrativeStart + userProgress : void 0;
1340
+ const maxChapterIndex = resolveMaxChapter(book, options.maxChapter);
1353
1341
  const retrievalLimit = options.topK * 3;
1354
1342
  const allMatches = await queryBookIndex(resolvedId, embedding, question, retrievalLimit, maxChapterIndex);
1355
1343
  const summaries = allMatches.filter((m) => m.type === "summary");
@@ -1383,28 +1371,20 @@ ${context}`
1383
1371
  } finally {
1384
1372
  releaseSigint();
1385
1373
  }
1386
- if (selectedMatches.length > 0) {
1387
- process.stdout.write("\n\nSources:\n");
1388
- selectedMatches.forEach((match, index) => {
1389
- const title = match.chapterTitle || `Chapter ${match.chapterIndex + 1}`;
1390
- const excerpt = match.content.slice(0, 120).replace(/\s+/g, " ");
1391
- process.stdout.write(`[${index + 1}] ${title}: ${excerpt}
1392
- `);
1393
- });
1394
- }
1374
+ stdout(renderSources(selectedMatches));
1395
1375
  };
1396
1376
 
1397
1377
  // src/commands/query-options.ts
1398
1378
  var parseQueryOptions = (options) => {
1399
1379
  const topK = Number(options.topK);
1400
- if (!Number.isFinite(topK) || topK <= 0) {
1401
- throw new Error("--top-k must be a positive number.");
1380
+ if (!Number.isFinite(topK) || topK <= 0 || !Number.isInteger(topK)) {
1381
+ throw new Error("--top-k must be a positive integer.");
1402
1382
  }
1403
1383
  let maxChapter;
1404
1384
  if (options.maxChapter !== void 0) {
1405
1385
  const parsed = Number(options.maxChapter);
1406
- if (!Number.isFinite(parsed) || parsed < 0) {
1407
- throw new Error("--max-chapter must be a non-negative number.");
1386
+ if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
1387
+ throw new Error("--max-chapter must be a non-negative integer.");
1408
1388
  }
1409
1389
  maxChapter = parsed;
1410
1390
  }
@@ -1413,7 +1393,14 @@ var parseQueryOptions = (options) => {
1413
1393
 
1414
1394
  // src/commands/book/ask.ts
1415
1395
  var registerBookAsk = (program2) => {
1416
- 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) => {
1396
+ 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)").addHelpText(
1397
+ "after",
1398
+ `
1399
+ EXAMPLES
1400
+ mycroft book ask 8f2c1a4b "Who is the main character?"
1401
+ mycroft book ask 8f2c1a4b "What happened in chapter 3?" --max-chapter 3
1402
+ `
1403
+ ).action(async (id, question, options) => {
1417
1404
  const { topK, maxChapter } = parseQueryOptions(options);
1418
1405
  await askCommand(id, question, { topK, maxChapter });
1419
1406
  });
@@ -1438,7 +1425,7 @@ var searchCommand = async (id, query, options) => {
1438
1425
  model: openai4.embeddingModel(models.embedding),
1439
1426
  value: query
1440
1427
  });
1441
- const maxChapterIndex = options.maxChapter !== void 0 ? (book.narrativeStartIndex ?? 0) + options.maxChapter : book.progressChapter !== null ? (book.narrativeStartIndex ?? 0) + (book.progressChapter ?? 0) : void 0;
1428
+ const maxChapterIndex = resolveMaxChapter(book, options.maxChapter);
1442
1429
  const results = await queryBookIndex(resolvedId, embedding, query, options.topK, maxChapterIndex);
1443
1430
  if (results.length === 0) {
1444
1431
  stdout("No results.");
@@ -1456,7 +1443,14 @@ var searchCommand = async (id, query, options) => {
1456
1443
 
1457
1444
  // src/commands/book/search.ts
1458
1445
  var registerBookSearch = (program2) => {
1459
- 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) => {
1446
+ 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)").addHelpText(
1447
+ "after",
1448
+ `
1449
+ EXAMPLES
1450
+ mycroft book search 8f2c1a4b "the storm scene"
1451
+ mycroft book search 8f2c1a4b "betrayal" --top-k 10
1452
+ `
1453
+ ).action(async (id, query, options) => {
1460
1454
  const { topK, maxChapter } = parseQueryOptions(options);
1461
1455
  await searchCommand(id, query, { topK, maxChapter });
1462
1456
  });
@@ -1487,14 +1481,23 @@ var deleteCommand = async (id, options) => {
1487
1481
  await deleteBook(resolvedId);
1488
1482
  await deleteBookIndex(resolvedId);
1489
1483
  if (book.epubPath) {
1490
- await unlink2(book.epubPath).catch(() => void 0);
1484
+ await unlink2(book.epubPath).catch((err) => {
1485
+ if (err.code !== "ENOENT") throw err;
1486
+ });
1491
1487
  }
1492
1488
  stdout(`Deleted book ${book.id}`);
1493
1489
  };
1494
1490
 
1495
1491
  // src/commands/book/delete.ts
1496
1492
  var registerBookDelete = (program2) => {
1497
- program2.command("delete").description("Remove book, EPUB, and vectors").argument("<id>", "Book id or prefix").option("--force", "Skip confirmation").action(async (id, options) => {
1493
+ program2.command("delete").description("Remove book, EPUB, and vectors").argument("<id>", "Book id or prefix").option("--force", "Skip confirmation").addHelpText(
1494
+ "after",
1495
+ `
1496
+ EXAMPLES
1497
+ mycroft book delete 8f2c1a4b
1498
+ mycroft book delete 8f2c1a4b --force
1499
+ `
1500
+ ).action(async (id, options) => {
1498
1501
  await deleteCommand(id, { force: options.force });
1499
1502
  });
1500
1503
  };
@@ -1521,7 +1524,12 @@ var resumeCommand = async (id) => {
1521
1524
  if (!rawData) {
1522
1525
  throw new Error(`No stored summary batch data for book "${book.title}". Re-ingest with "mycroft book ingest --batch --summary".`);
1523
1526
  }
1524
- const storedData = JSON.parse(rawData);
1527
+ let storedData;
1528
+ try {
1529
+ storedData = JSON.parse(rawData);
1530
+ } catch {
1531
+ throw new Error(`Corrupt summary batch data for book "${book.title}". Re-ingest with "mycroft book ingest --batch --summary".`);
1532
+ }
1525
1533
  let result2;
1526
1534
  if (storedData.isMergePass) {
1527
1535
  result2 = await resumeMergeBatch(resolvedId, book.summaryBatchId, book.summaryBatchFileId ?? book.summaryBatchId, storedData);
@@ -1556,7 +1564,12 @@ Summary batch still in progress (${result2.status}: ${result2.completed}/${resul
1556
1564
  if (!rawChunks) {
1557
1565
  throw new Error(`No stored chunks found for book "${book.title}". Re-ingest with "mycroft book ingest --batch".`);
1558
1566
  }
1559
- const chunks = JSON.parse(rawChunks);
1567
+ let chunks;
1568
+ try {
1569
+ chunks = JSON.parse(rawChunks);
1570
+ } catch {
1571
+ throw new Error(`Corrupt chunk data for book "${book.title}". Re-ingest with "mycroft book ingest --batch".`);
1572
+ }
1560
1573
  const result2 = await resumeIngest(resolvedId, chunks, book.batchId, book.batchFileId ?? book.batchId);
1561
1574
  if (result2.status === "completed") {
1562
1575
  stdout(`
@@ -1624,7 +1637,7 @@ Status: completed`);
1624
1637
  }
1625
1638
  if (book.summaryBatchId) {
1626
1639
  requireOpenAIKey();
1627
- const { checkBatchStatus } = await import("./batch-embedder-ZJZLNLOK.js");
1640
+ const { checkBatchStatus } = await import("./batch-embedder-C2E6OHBQ.js");
1628
1641
  const status = await checkBatchStatus(book.summaryBatchId);
1629
1642
  stdout(`
1630
1643
  Status: summary batch ${status.status}`);
@@ -1653,7 +1666,7 @@ Summary batch still processing.`);
1653
1666
  }
1654
1667
  if (book.batchId) {
1655
1668
  requireOpenAIKey();
1656
- const { checkBatchStatus } = await import("./batch-embedder-ZJZLNLOK.js");
1669
+ const { checkBatchStatus } = await import("./batch-embedder-C2E6OHBQ.js");
1657
1670
  const status = await checkBatchStatus(book.batchId);
1658
1671
  stdout(`
1659
1672
  Status: embedding batch ${status.status}`);
@@ -1835,16 +1848,11 @@ var registerConfigOnboard = (program2) => {
1835
1848
 
1836
1849
  // src/services/chat.ts
1837
1850
  import { randomUUID as randomUUID2 } from "crypto";
1838
- import { embed as embed3, generateText as generateText2 } from "ai";
1851
+ import { embed as embed3, generateText as generateText2, streamText as streamText2 } from "ai";
1839
1852
  import { openai as openai5 } from "@ai-sdk/openai";
1840
1853
  var MAX_RECENT_MESSAGES = 12;
1841
1854
  var SUMMARY_TRIGGER_MESSAGES = 24;
1842
1855
  var SUMMARY_TARGET_WORDS2 = 160;
1843
- var formatContext2 = (chunks) => chunks.map(
1844
- (chunk, index) => `Excerpt [${index + 1}] (${chunk.chapterTitle || `Chapter ${chunk.chapterIndex + 1}`}):
1845
- ${chunk.content}`
1846
- ).join("\n\n");
1847
- var estimateTokens2 = (text) => Math.ceil(text.length / 4);
1848
1856
  var summarizeMessages = async (messages) => {
1849
1857
  const transcript = messages.map((message) => `${message.role.toUpperCase()}: ${message.content}`).join("\n\n");
1850
1858
  const models = await getModels();
@@ -1908,9 +1916,7 @@ var chatAsk = async (sessionId, question, options) => {
1908
1916
  model: openai5.embeddingModel(models.embedding),
1909
1917
  value: question
1910
1918
  });
1911
- const narrativeStart = book.narrativeStartIndex ?? 0;
1912
- const userProgress = book.progressChapter ?? null;
1913
- const maxChapterIndex = options.maxChapter !== void 0 ? narrativeStart + options.maxChapter : userProgress !== null ? narrativeStart + userProgress : void 0;
1919
+ const maxChapterIndex = resolveMaxChapter(book, options.maxChapter);
1914
1920
  const retrievalLimit = options.topK * 3;
1915
1921
  const allMatches = await queryBookIndex(session.bookId, embedding, question, retrievalLimit, maxChapterIndex);
1916
1922
  const summaries = allMatches.filter((m) => m.type === "summary");
@@ -1918,26 +1924,17 @@ var chatAsk = async (sessionId, question, options) => {
1918
1924
  const topSummaries = summaries.slice(0, 2);
1919
1925
  const topChunks = chunks.slice(0, Math.max(0, options.topK - topSummaries.length));
1920
1926
  const selectedMatches = [...topSummaries, ...topChunks];
1921
- const context = formatContext2(selectedMatches);
1927
+ const context = formatContext(selectedMatches);
1922
1928
  const messages = await getChatMessages(sessionId);
1923
1929
  const conversation = buildConversationContext(session, messages);
1924
- const now = Date.now();
1925
- const userMessage = {
1926
- id: randomUUID2(),
1927
- sessionId,
1928
- role: "user",
1929
- content: question,
1930
- tokenCount: estimateTokens2(question),
1931
- createdAt: now
1932
- };
1933
- await insertChatMessage(userMessage);
1930
+ const now = Math.floor(Date.now() / 1e3);
1934
1931
  const prompt2 = [
1935
1932
  conversation ? `Conversation:
1936
1933
  ${conversation}` : "",
1937
1934
  `Question: ${question}`,
1938
1935
  context
1939
1936
  ].filter(Boolean).join("\n\n");
1940
- const { text } = await generateText2({
1937
+ const stream = streamText2({
1941
1938
  model: openai5(models.chat),
1942
1939
  system: `You are a reading companion helping readers understand this book.
1943
1940
 
@@ -1952,6 +1949,16 @@ Guidelines:
1952
1949
  - The context may be limited to earlier chapters only - don't infer beyond what's provided`,
1953
1950
  prompt: prompt2
1954
1951
  });
1952
+ const text = await stream.text;
1953
+ const userMessage = {
1954
+ id: randomUUID2(),
1955
+ sessionId,
1956
+ role: "user",
1957
+ content: question,
1958
+ tokenCount: estimateTokens2(question),
1959
+ createdAt: now
1960
+ };
1961
+ await insertChatMessage(userMessage);
1955
1962
  const assistantMessage = {
1956
1963
  id: randomUUID2(),
1957
1964
  sessionId,
@@ -1961,7 +1968,7 @@ Guidelines:
1961
1968
  createdAt: now
1962
1969
  };
1963
1970
  await insertChatMessage(assistantMessage);
1964
- const updatedAt = Date.now();
1971
+ const updatedAt = Math.floor(Date.now() / 1e3);
1965
1972
  await updateChatSession(sessionId, { updatedAt });
1966
1973
  await maybeSummarizeSession(session, [...messages, userMessage, assistantMessage], updatedAt);
1967
1974
  return { answer: text, sources: selectedMatches };
@@ -1998,21 +2005,14 @@ var registerChatAsk = (program2) => {
1998
2005
  }
1999
2006
  const { answer, sources } = await chatAsk(resolvedId, question, { topK, maxChapter });
2000
2007
  stdout(answer);
2001
- if (sources.length > 0) {
2002
- stdout("\nSources:");
2003
- sources.forEach((match, index) => {
2004
- const title = match.chapterTitle || `Chapter ${match.chapterIndex + 1}`;
2005
- const excerpt = match.content.slice(0, 120).replace(/\s+/g, " ");
2006
- stdout(`[${index + 1}] ${title}: ${excerpt}`);
2007
- });
2008
- }
2008
+ stdout(renderSources(sources));
2009
2009
  });
2010
2010
  };
2011
2011
 
2012
2012
  // src/commands/chat/list.ts
2013
2013
  var formatDate2 = (timestamp) => {
2014
2014
  if (!timestamp) return "-";
2015
- return new Date(timestamp).toISOString().slice(0, 10);
2015
+ return new Date(timestamp * 1e3).toISOString().slice(0, 10);
2016
2016
  };
2017
2017
  var registerChatList = (program2) => {
2018
2018
  program2.command("list").description("List chat sessions").action(async () => {
@@ -2092,14 +2092,7 @@ var registerChatRepl = (program2) => {
2092
2092
  const { answer, sources } = await chatAsk(session.id, question, { topK, maxChapter });
2093
2093
  stdout(`
2094
2094
  ${answer}`);
2095
- if (sources.length > 0) {
2096
- stdout("\nSources:");
2097
- sources.forEach((match, index) => {
2098
- const title = match.chapterTitle || `Chapter ${match.chapterIndex + 1}`;
2099
- const excerpt = match.content.slice(0, 120).replace(/\s+/g, " ");
2100
- stdout(`[${index + 1}] ${title}: ${excerpt}`);
2101
- });
2102
- }
2095
+ stdout(renderSources(sources));
2103
2096
  stdout("");
2104
2097
  }
2105
2098
  });