@a13xu/lucid 1.20.0 → 1.21.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/build/index.js CHANGED
@@ -28,6 +28,7 @@ import { handleCompressText, CompressTextSchema } from "./tools/compress.js";
28
28
  import { handleBackupFile, BackupFileSchema, handleRestoreFile, RestoreFileSchema, handleCheckTruncateRisk, CheckTruncateRiskSchema, } from "./tools/backup.js";
29
29
  import { handleSessionStatus, SessionStatusSchema } from "./tools/session.js";
30
30
  import { handleDelegateLocal, DelegateLocalSchema, handleLocalLlmStatus, LocalLlmStatusSchema, } from "./tools/delegate-local.js";
31
+ import { handleIngestBook, IngestBookSchema, handleGenerateBookSkill, GenerateBookSkillSchema, handleListBooks, ListBooksSchema, runBookCli, } from "./tools/book.js";
31
32
  import { loadLocalConfig } from "./local-llm/config.js";
32
33
  // ---------------------------------------------------------------------------
33
34
  // CLI mode: lucid watch | lucid status | lucid stop
@@ -50,6 +51,13 @@ if (_cliCmd === "local") {
50
51
  const exitCode = await runLocalLlmCli(_cliArgs);
51
52
  process.exit(exitCode);
52
53
  }
54
+ if (_cliCmd === "book") {
55
+ const { initDatabase, prepareStatements } = await import("./database.js");
56
+ const bookDb = initDatabase();
57
+ const bookStmts = prepareStatements(bookDb);
58
+ const exitCode = await runBookCli(_cliArgs, bookStmts);
59
+ process.exit(exitCode);
60
+ }
53
61
  // ---------------------------------------------------------------------------
54
62
  // `lucid guard <subcmd>` — invoked from Claude Code hooks (PreToolUse, etc.)
55
63
  //
@@ -108,14 +116,13 @@ async function guardPreEdit(stmts, flagArgs) {
108
116
  toolName = payload.tool_name ?? "Write";
109
117
  const ti = payload.tool_input ?? {};
110
118
  path = ti.file_path ?? ti.path;
111
- if (typeof ti.content === "string")
119
+ // Only `Write` carries the full new file content. `Edit`/`MultiEdit`
120
+ // carry replacement fragments, not the full post-write state — assessing
121
+ // shrinkage on those produces false MAJOR_SHRINK blocks on every edit
122
+ // of a non-tiny file. Leave content=null so assessTruncate skips the
123
+ // size-based rules and only the cascade lock can apply.
124
+ if (toolName === "Write" && typeof ti.content === "string") {
112
125
  content = ti.content;
113
- else if (Array.isArray(ti.edits)) {
114
- // MultiEdit — sum up final state crudely: use new_strings concatenated.
115
- content = ti.edits.map((e) => e.new_string ?? "").join("\n");
116
- }
117
- else if (typeof ti.new_string === "string") {
118
- content = ti.new_string;
119
126
  }
120
127
  }
121
128
  catch {
@@ -670,6 +677,29 @@ server.registerTool("plan_update_task", {
670
677
  inputSchema: PlanUpdateTaskSchema.shape,
671
678
  }, tx("plan_update_task", (args) => handlePlanUpdateTask(stmts, args)));
672
679
  // ---------------------------------------------------------------------------
680
+ // Tools — Book Ingestion (PDF/EPUB/DOCX → markdown chunks → skill router)
681
+ // ---------------------------------------------------------------------------
682
+ server.registerTool("ingest_book", {
683
+ title: "Ingest Book",
684
+ description: "Convert a book (PDF, EPUB, DOCX, or Markdown) into chunked markdown files " +
685
+ "under ./books/<slug>/ and index every chunk into Lucid. Requires user-installed " +
686
+ "converter (pymupdf4llm for PDF, pandoc for EPUB/DOCX). Pair with generate_book_skill " +
687
+ "to make the corpus auto-load as a Claude Code skill.",
688
+ inputSchema: IngestBookSchema.shape,
689
+ }, tx("ingest_book", (args) => handleIngestBook(stmts, args)));
690
+ server.registerTool("generate_book_skill", {
691
+ title: "Generate Book Skill",
692
+ description: "Emit a thin SKILL.md router into ~/.claude/skills/book-<slug>/ (or .claude/skills/ " +
693
+ "for project scope). The skill auto-loads (~100 tokens) when its trigger topics come up " +
694
+ "and delegates retrieval to smart_context. Run after ingest_book.",
695
+ inputSchema: GenerateBookSkillSchema.shape,
696
+ }, tx("generate_book_skill", (args) => handleGenerateBookSkill(args)));
697
+ server.registerTool("list_books", {
698
+ title: "List Books",
699
+ description: "List ingested books under ./books/ with chunk counts and ingestion dates.",
700
+ inputSchema: ListBooksSchema.shape,
701
+ }, tx("list_books", (args) => handleListBooks(args)));
702
+ // ---------------------------------------------------------------------------
673
703
  // Tools — Updater
674
704
  // ---------------------------------------------------------------------------
675
705
  server.registerTool("update_lucid", {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Book ingestion pipeline — PDF/EPUB → Markdown chunks → Lucid index → Claude
3
+ * Code Skill router. Conversion shells out to user-installed tools
4
+ * (pymupdf4llm for PDF, pandoc for EPUB); Lucid itself stays dependency-free.
5
+ *
6
+ * Three handlers:
7
+ * - handleIngestBook : convert + chunk + index
8
+ * - handleGenerateBookSkill: emit ~/.claude/skills/book-<slug>/SKILL.md
9
+ * - handleListBooks : list indexed books
10
+ */
11
+ import { z } from "zod";
12
+ import type { Statements } from "../database.js";
13
+ export declare const IngestBookSchema: z.ZodObject<{
14
+ path: z.ZodString;
15
+ title: z.ZodOptional<z.ZodString>;
16
+ out_dir: z.ZodOptional<z.ZodString>;
17
+ chunker: z.ZodDefault<z.ZodOptional<z.ZodEnum<["heading", "page", "none"]>>>;
18
+ index: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ path: string;
21
+ chunker: "none" | "page" | "heading";
22
+ index: boolean;
23
+ title?: string | undefined;
24
+ out_dir?: string | undefined;
25
+ }, {
26
+ path: string;
27
+ title?: string | undefined;
28
+ out_dir?: string | undefined;
29
+ chunker?: "none" | "page" | "heading" | undefined;
30
+ index?: boolean | undefined;
31
+ }>;
32
+ export declare const GenerateBookSkillSchema: z.ZodObject<{
33
+ slug: z.ZodString;
34
+ title: z.ZodOptional<z.ZodString>;
35
+ topics: z.ZodOptional<z.ZodString>;
36
+ scope: z.ZodDefault<z.ZodOptional<z.ZodEnum<["user", "project"]>>>;
37
+ }, "strip", z.ZodTypeAny, {
38
+ slug: string;
39
+ scope: "project" | "user";
40
+ title?: string | undefined;
41
+ topics?: string | undefined;
42
+ }, {
43
+ slug: string;
44
+ title?: string | undefined;
45
+ topics?: string | undefined;
46
+ scope?: "project" | "user" | undefined;
47
+ }>;
48
+ export declare const ListBooksSchema: z.ZodObject<{
49
+ dir: z.ZodOptional<z.ZodString>;
50
+ }, "strip", z.ZodTypeAny, {
51
+ dir?: string | undefined;
52
+ }, {
53
+ dir?: string | undefined;
54
+ }>;
55
+ export declare function handleIngestBook(stmts: Statements, args: z.infer<typeof IngestBookSchema>): string;
56
+ export declare function handleGenerateBookSkill(args: z.infer<typeof GenerateBookSkillSchema>): string;
57
+ export declare function handleListBooks(args: z.infer<typeof ListBooksSchema>): string;
58
+ export declare function runBookCli(args: string[], stmts: Statements): Promise<number>;
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Book ingestion pipeline — PDF/EPUB → Markdown chunks → Lucid index → Claude
3
+ * Code Skill router. Conversion shells out to user-installed tools
4
+ * (pymupdf4llm for PDF, pandoc for EPUB); Lucid itself stays dependency-free.
5
+ *
6
+ * Three handlers:
7
+ * - handleIngestBook : convert + chunk + index
8
+ * - handleGenerateBookSkill: emit ~/.claude/skills/book-<slug>/SKILL.md
9
+ * - handleListBooks : list indexed books
10
+ */
11
+ import { z } from "zod";
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, } from "fs";
13
+ import { join, resolve, extname, basename } from "path";
14
+ import { homedir } from "os";
15
+ import { spawnSync } from "child_process";
16
+ import { indexFile, upsertFileIndex } from "../indexer/file.js";
17
+ // ---------------------------------------------------------------------------
18
+ // Schemas
19
+ // ---------------------------------------------------------------------------
20
+ export const IngestBookSchema = z.object({
21
+ path: z.string().min(1).describe("Path to the source book (.pdf, .epub, .docx, or .md)."),
22
+ title: z.string().optional().describe("Display title. Defaults to the filename."),
23
+ out_dir: z.string().optional().describe("Output directory for chunked markdown. Defaults to ./books/<slug>/"),
24
+ chunker: z.enum(["heading", "page", "none"]).optional().default("heading")
25
+ .describe("How to split: by H1/H2 headings (default), by source page, or single file."),
26
+ index: z.boolean().optional().default(true).describe("Index chunks into Lucid immediately."),
27
+ });
28
+ export const GenerateBookSkillSchema = z.object({
29
+ slug: z.string().min(1).describe("Book slug (the directory name produced by ingest_book)."),
30
+ title: z.string().optional().describe("Display title shown in the skill description."),
31
+ topics: z.string().optional().describe("Comma-separated topics that should trigger the skill."),
32
+ scope: z.enum(["user", "project"]).optional().default("user")
33
+ .describe("Where to install the skill: ~/.claude/skills/ (user) or .claude/skills/ (project)."),
34
+ });
35
+ export const ListBooksSchema = z.object({
36
+ dir: z.string().optional().describe("Root directory to scan. Defaults to ./books/"),
37
+ });
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+ function slugify(input) {
42
+ return input
43
+ .toLowerCase()
44
+ .normalize("NFKD")
45
+ .replace(/[̀-ͯ]/g, "")
46
+ .replace(/[^a-z0-9]+/g, "-")
47
+ .replace(/^-+|-+$/g, "")
48
+ .slice(0, 60) || "book";
49
+ }
50
+ function which(cmd) {
51
+ const probe = process.platform === "win32" ? "where" : "which";
52
+ const r = spawnSync(probe, [cmd], { stdio: "ignore" });
53
+ return r.status === 0;
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Converters — shell out to user tools; never bundle binaries
57
+ // ---------------------------------------------------------------------------
58
+ function convertPdfToMarkdown(src) {
59
+ // Preferred: pymupdf4llm (fastest for native-text PDFs, no GPU, no ML)
60
+ if (which("python") || which("python3")) {
61
+ const py = which("python3") ? "python3" : "python";
62
+ const script = `import sys\n` +
63
+ `try:\n` +
64
+ ` import pymupdf4llm\n` +
65
+ ` sys.stdout.write(pymupdf4llm.to_markdown(sys.argv[1]))\n` +
66
+ `except ImportError:\n` +
67
+ ` sys.stderr.write("pymupdf4llm not installed\\n")\n` +
68
+ ` sys.exit(2)\n`;
69
+ const r = spawnSync(py, ["-c", script, src], { encoding: "utf-8", maxBuffer: 200 * 1024 * 1024 });
70
+ if (r.status === 0 && r.stdout)
71
+ return r.stdout;
72
+ }
73
+ // Fallback: marker_single (datalab-to/marker)
74
+ if (which("marker_single")) {
75
+ const tmpDir = join(homedir(), ".lucid", "tmp-marker", `${Date.now()}`);
76
+ mkdirSync(tmpDir, { recursive: true });
77
+ const r = spawnSync("marker_single", [src, "--output_format", "markdown", "--output_dir", tmpDir], { encoding: "utf-8" });
78
+ if (r.status === 0) {
79
+ const produced = readdirSync(tmpDir, { recursive: true })
80
+ .filter((p) => typeof p === "string" && p.endsWith(".md"));
81
+ if (produced.length > 0)
82
+ return readFileSync(join(tmpDir, produced[0]), "utf-8");
83
+ }
84
+ }
85
+ throw new Error(`Cannot convert PDF — install one of:\n` +
86
+ ` pip install pymupdf4llm # fast, native PDFs (recommended)\n` +
87
+ ` pip install marker-pdf # best quality, supports scans`);
88
+ }
89
+ function convertEpubToMarkdown(src) {
90
+ if (which("pandoc")) {
91
+ const r = spawnSync("pandoc", ["-f", "epub", "-t", "gfm", src], { encoding: "utf-8", maxBuffer: 200 * 1024 * 1024 });
92
+ if (r.status === 0 && r.stdout)
93
+ return r.stdout;
94
+ }
95
+ throw new Error(`Cannot convert EPUB — install pandoc: https://pandoc.org/installing.html`);
96
+ }
97
+ function convertDocxToMarkdown(src) {
98
+ if (which("pandoc")) {
99
+ const r = spawnSync("pandoc", ["-f", "docx", "-t", "gfm", src], { encoding: "utf-8", maxBuffer: 200 * 1024 * 1024 });
100
+ if (r.status === 0 && r.stdout)
101
+ return r.stdout;
102
+ }
103
+ throw new Error(`Cannot convert DOCX — install pandoc.`);
104
+ }
105
+ function loadSource(path) {
106
+ const ext = extname(path).toLowerCase();
107
+ if (!existsSync(path))
108
+ throw new Error(`Source not found: ${path}`);
109
+ if (ext === ".md" || ext === ".markdown" || ext === ".txt")
110
+ return readFileSync(path, "utf-8");
111
+ if (ext === ".pdf")
112
+ return convertPdfToMarkdown(path);
113
+ if (ext === ".epub")
114
+ return convertEpubToMarkdown(path);
115
+ if (ext === ".docx")
116
+ return convertDocxToMarkdown(path);
117
+ throw new Error(`Unsupported source format: ${ext}. Use .pdf, .epub, .docx, .md, or .txt.`);
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Chunking
121
+ // ---------------------------------------------------------------------------
122
+ function chunkByHeading(md) {
123
+ // Split on H1 (#) primarily; if a single chunk grows beyond 80KB, fall back to H2.
124
+ const HARD_LIMIT = 80 * 1024;
125
+ const lines = md.split(/\r?\n/);
126
+ const out = [];
127
+ let cur = null;
128
+ const push = () => {
129
+ if (cur && cur.buf.length > 0) {
130
+ out.push({ index: out.length + 1, title: cur.title, content: cur.buf.join("\n").trim() });
131
+ }
132
+ };
133
+ for (const line of lines) {
134
+ const h1 = /^# +(.+)$/.exec(line);
135
+ if (h1) {
136
+ push();
137
+ cur = { title: h1[1].trim(), buf: [line] };
138
+ continue;
139
+ }
140
+ if (!cur)
141
+ cur = { title: "Preface", buf: [] };
142
+ cur.buf.push(line);
143
+ // Soft split on H2 if the current chunk is getting too big
144
+ const h2 = /^## +(.+)$/.exec(line);
145
+ if (h2 && cur.buf.join("\n").length > HARD_LIMIT) {
146
+ // Drop the trailing line, push, restart with the H2 as new chunk title.
147
+ cur.buf.pop();
148
+ push();
149
+ cur = { title: h2[1].trim(), buf: [line] };
150
+ }
151
+ }
152
+ push();
153
+ // If no headings were found at all → fall back to a single chunk
154
+ if (out.length === 0)
155
+ out.push({ index: 1, title: "Body", content: md.trim() });
156
+ return out;
157
+ }
158
+ function chunkByPage(md) {
159
+ // pymupdf4llm separates pages with -----\n; treat each as a chunk
160
+ const parts = md.split(/\n-{3,}\n/);
161
+ return parts
162
+ .map((p, i) => ({ index: i + 1, title: `Page ${i + 1}`, content: p.trim() }))
163
+ .filter((c) => c.content.length > 0);
164
+ }
165
+ function chunkAll(md, mode) {
166
+ if (mode === "none")
167
+ return [{ index: 1, title: "Full", content: md.trim() }];
168
+ if (mode === "page")
169
+ return chunkByPage(md);
170
+ return chunkByHeading(md);
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // handleIngestBook
174
+ // ---------------------------------------------------------------------------
175
+ export function handleIngestBook(stmts, args) {
176
+ const src = resolve(args.path);
177
+ const title = args.title ?? basename(src, extname(src));
178
+ const slug = slugify(title);
179
+ const outDir = resolve(args.out_dir ?? join(process.cwd(), "books", slug));
180
+ mkdirSync(outDir, { recursive: true });
181
+ const raw = loadSource(src);
182
+ const chunks = chunkAll(raw, args.chunker ?? "heading");
183
+ // Write a manifest so generate_book_skill knows what was produced
184
+ const manifest = {
185
+ slug, title,
186
+ source: src,
187
+ chunker: args.chunker ?? "heading",
188
+ chunks: chunks.map((c) => ({ index: c.index, title: c.title, file: chunkFilename(c) })),
189
+ created_at: new Date().toISOString(),
190
+ };
191
+ // Write chunks
192
+ for (const c of chunks) {
193
+ const file = join(outDir, chunkFilename(c));
194
+ const header = `---\nbook: ${title}\nslug: ${slug}\nchunk: ${c.index}\ntitle: ${c.title.replace(/"/g, '\\"')}\n---\n\n`;
195
+ writeFileSync(file, header + c.content, "utf-8");
196
+ }
197
+ writeFileSync(join(outDir, "_manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
198
+ // Index into Lucid via existing pipeline (sync_file is .ts/.py only — call indexer directly)
199
+ let indexed = 0;
200
+ if (args.index !== false) {
201
+ for (const c of chunks) {
202
+ const file = join(outDir, chunkFilename(c));
203
+ const idx = indexFile(file);
204
+ if (!idx)
205
+ continue;
206
+ const r = upsertFileIndex(idx, readFileSync(file, "utf-8"), stmts);
207
+ if (r.stored)
208
+ indexed++;
209
+ }
210
+ }
211
+ return [
212
+ `📚 Ingested: "${title}" → ${outDir}`,
213
+ ` chunks: ${chunks.length} (${args.chunker ?? "heading"})`,
214
+ args.index !== false ? ` indexed: ${indexed} new chunk(s) into Lucid` : ` indexing skipped`,
215
+ ``,
216
+ `Next: generate the auto-loaded skill router with:`,
217
+ ` lucid book skill ${slug} --topics "<comma,separated,topics>"`,
218
+ ].join("\n");
219
+ }
220
+ function chunkFilename(c) {
221
+ const safe = slugify(c.title);
222
+ return `${String(c.index).padStart(3, "0")}-${safe}.md`;
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // handleGenerateBookSkill
226
+ // ---------------------------------------------------------------------------
227
+ export function handleGenerateBookSkill(args) {
228
+ const slug = args.slug;
229
+ const booksRoot = join(process.cwd(), "books", slug);
230
+ const manifestPath = join(booksRoot, "_manifest.json");
231
+ let title = args.title ?? slug;
232
+ let chunkCount = 0;
233
+ let chunkTitles = [];
234
+ if (existsSync(manifestPath)) {
235
+ try {
236
+ const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
237
+ title = args.title ?? m.title;
238
+ chunkCount = m.chunks.length;
239
+ chunkTitles = m.chunks.map((c) => c.title).slice(0, 12);
240
+ }
241
+ catch { /* ignore — manifest optional */ }
242
+ }
243
+ const topics = (args.topics ?? "").split(",").map((t) => t.trim()).filter(Boolean);
244
+ const skillRoot = args.scope === "project"
245
+ ? join(process.cwd(), ".claude", "skills", `book-${slug}`)
246
+ : join(homedir(), ".claude", "skills", `book-${slug}`);
247
+ mkdirSync(skillRoot, { recursive: true });
248
+ const description = topics.length > 0
249
+ ? `Use when the user asks about ${topics.join(", ")} — or any concept from "${title}". Retrieves passages via Lucid smart_context.`
250
+ : `Use for questions whose answers should cite "${title}". Retrieves passages via Lucid smart_context.`;
251
+ const tocPreview = chunkTitles.length > 0
252
+ ? chunkTitles.map((t, i) => ` ${String(i + 1).padStart(2, "0")}. ${t}`).join("\n")
253
+ : " (run `lucid book ingest` first to populate the table of contents)";
254
+ const body = `---
255
+ name: book-${slug}
256
+ description: ${description}
257
+ ---
258
+
259
+ # ${title} — Lucid skill router
260
+
261
+ This skill does NOT contain the book text. It delegates retrieval to Lucid's
262
+ indexed corpus so passages are pulled on demand instead of being loaded into
263
+ every prompt.
264
+
265
+ ## When invoked
266
+
267
+ 1. Identify the user's question or the topic at hand.
268
+ 2. Call \`mcp__lucid__smart_context\` with:
269
+ - \`query\`: the user's question, or the topic phrased as a search
270
+ - \`task_type\`: "moderate" for explanations, "complex" for multi-chapter synthesis
271
+ 3. The retrieved chunks come from \`books/${slug}/\` (${chunkCount} indexed chunk${chunkCount === 1 ? "" : "s"}).
272
+ 4. Quote chapter / chunk titles when you cite, so the source stays verifiable.
273
+ 5. After answering, call \`mcp__lucid__reward\` if the passages were on-point
274
+ so future queries on this topic rank them higher (decay half-life ~14 days).
275
+
276
+ ## Table of contents (first ${Math.min(chunkTitles.length, 12)} chunks)
277
+
278
+ ${tocPreview}
279
+
280
+ ## Notes
281
+
282
+ - If \`smart_context\` returns nothing, the corpus may not be indexed. Run
283
+ \`lucid book ingest <source>\` then \`sync_project\` to rebuild.
284
+ - This skill is a thin router (~100 tokens at scan time). Adding more books
285
+ here does not bloat Claude Code's startup.
286
+ `;
287
+ const skillFile = join(skillRoot, "SKILL.md");
288
+ writeFileSync(skillFile, body, "utf-8");
289
+ return [
290
+ `🧠 Skill generated: ${skillFile}`,
291
+ ` description trigger: ${description}`,
292
+ ` scope: ${args.scope ?? "user"}`,
293
+ chunkCount > 0 ? ` linked to ${chunkCount} chunk(s) in books/${slug}/` : ` ⚠️ no chunks indexed yet`,
294
+ ].join("\n");
295
+ }
296
+ // ---------------------------------------------------------------------------
297
+ // handleListBooks
298
+ // ---------------------------------------------------------------------------
299
+ export function handleListBooks(args) {
300
+ const root = resolve(args.dir ?? join(process.cwd(), "books"));
301
+ if (!existsSync(root))
302
+ return `No books directory at ${root}. Run \`lucid book ingest\` first.`;
303
+ const entries = readdirSync(root, { withFileTypes: true })
304
+ .filter((d) => d.isDirectory())
305
+ .map((d) => d.name);
306
+ if (entries.length === 0)
307
+ return `No books indexed under ${root}.`;
308
+ const lines = [`📚 Books in ${root}:`];
309
+ for (const name of entries) {
310
+ const manifestPath = join(root, name, "_manifest.json");
311
+ if (existsSync(manifestPath)) {
312
+ try {
313
+ const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
314
+ lines.push(` • ${name} — "${m.title}" (${m.chunks.length} chunks, ingested ${m.created_at.slice(0, 10)})`);
315
+ }
316
+ catch {
317
+ lines.push(` • ${name} — (manifest unreadable)`);
318
+ }
319
+ }
320
+ else {
321
+ const mdCount = readdirSync(join(root, name)).filter((f) => f.endsWith(".md")).length;
322
+ lines.push(` • ${name} — ${mdCount} .md file(s), no manifest`);
323
+ }
324
+ }
325
+ return lines.join("\n");
326
+ }
327
+ // ---------------------------------------------------------------------------
328
+ // CLI entry — `lucid book <subcmd>`
329
+ // ---------------------------------------------------------------------------
330
+ export async function runBookCli(args, stmts) {
331
+ const sub = args[0];
332
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
333
+ process.stdout.write(BOOK_HELP);
334
+ return 0;
335
+ }
336
+ if (sub === "ingest") {
337
+ const rest = args.slice(1);
338
+ if (rest.includes("--help") || rest.includes("-h") || rest.length === 0) {
339
+ process.stdout.write(INGEST_HELP);
340
+ return rest.length === 0 ? 64 : 0;
341
+ }
342
+ const path = rest.find((a) => !a.startsWith("--"));
343
+ if (!path) {
344
+ process.stderr.write("Missing source path.\n");
345
+ return 64;
346
+ }
347
+ const opts = {
348
+ path,
349
+ title: getFlag(rest, "--title"),
350
+ out_dir: getFlag(rest, "--out"),
351
+ chunker: getFlag(rest, "--chunker") ?? "heading",
352
+ index: !rest.includes("--no-index"),
353
+ };
354
+ process.stdout.write(handleIngestBook(stmts, IngestBookSchema.parse(opts)) + "\n");
355
+ return 0;
356
+ }
357
+ if (sub === "skill") {
358
+ const rest = args.slice(1);
359
+ if (rest.includes("--help") || rest.includes("-h") || rest.length === 0) {
360
+ process.stdout.write(SKILL_HELP);
361
+ return rest.length === 0 ? 64 : 0;
362
+ }
363
+ const slug = rest.find((a) => !a.startsWith("--"));
364
+ if (!slug) {
365
+ process.stderr.write("Missing book slug.\n");
366
+ return 64;
367
+ }
368
+ const opts = {
369
+ slug,
370
+ title: getFlag(rest, "--title"),
371
+ topics: getFlag(rest, "--topics"),
372
+ scope: getFlag(rest, "--scope") ?? "user",
373
+ };
374
+ process.stdout.write(handleGenerateBookSkill(GenerateBookSkillSchema.parse(opts)) + "\n");
375
+ return 0;
376
+ }
377
+ if (sub === "list") {
378
+ const rest = args.slice(1);
379
+ if (rest.includes("--help") || rest.includes("-h")) {
380
+ process.stdout.write(LIST_HELP);
381
+ return 0;
382
+ }
383
+ process.stdout.write(handleListBooks({ dir: getFlag(rest, "--dir") }) + "\n");
384
+ return 0;
385
+ }
386
+ process.stderr.write(`Unknown subcommand: ${sub}\n\n${BOOK_HELP}`);
387
+ return 64;
388
+ }
389
+ function getFlag(args, name) {
390
+ const i = args.indexOf(name);
391
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
392
+ }
393
+ // ---------------------------------------------------------------------------
394
+ // Help text
395
+ // ---------------------------------------------------------------------------
396
+ const BOOK_HELP = `lucid book — convert books to indexed markdown + auto-load as Claude Code skills
397
+
398
+ USAGE
399
+ lucid book <command> [options]
400
+
401
+ COMMANDS
402
+ ingest <path> Convert a PDF/EPUB/DOCX/MD into chunked markdown and index it.
403
+ skill <slug> Emit a SKILL.md router into ~/.claude/skills/ for a book.
404
+ list List ingested books and their chunk counts.
405
+
406
+ Run \`lucid book <command> --help\` for command-specific options.
407
+
408
+ EXAMPLES
409
+ lucid book ingest ./clean-code.pdf --title "Clean Code"
410
+ lucid book skill clean-code --topics "naming,refactor,functions,code review"
411
+ lucid book list
412
+
413
+ DEPENDENCIES (install only what you need)
414
+ PDF pip install pymupdf4llm # native-text PDFs, fastest
415
+ pip install marker-pdf # OCR-quality, supports scans
416
+ EPUB pandoc # https://pandoc.org/installing.html
417
+ DOCX pandoc
418
+ `;
419
+ const INGEST_HELP = `lucid book ingest <path> [options]
420
+
421
+ Convert a book into chunked markdown and index it in Lucid.
422
+
423
+ OPTIONS
424
+ --title TEXT Display title. Defaults to filename without extension.
425
+ --out DIR Output directory. Defaults to ./books/<slug>/
426
+ --chunker MODE heading (default) | page | none
427
+ --no-index Skip indexing into Lucid (just produce markdown).
428
+
429
+ OUTPUT
430
+ ./books/<slug>/001-<title>.md, 002-<title>.md, ..., _manifest.json
431
+ `;
432
+ const SKILL_HELP = `lucid book skill <slug> [options]
433
+
434
+ Generate a SKILL.md router that auto-loads when relevant topics come up.
435
+ The skill itself is a thin router (~100 tokens) — retrieval happens via Lucid.
436
+
437
+ OPTIONS
438
+ --title TEXT Override display title (defaults to manifest).
439
+ --topics LIST Comma-separated trigger topics (e.g. "naming,functions").
440
+ --scope SCOPE user (default, ~/.claude/skills) | project (.claude/skills)
441
+ `;
442
+ const LIST_HELP = `lucid book list [options]
443
+
444
+ OPTIONS
445
+ --dir DIR Books root. Defaults to ./books/
446
+ `;
@@ -53,7 +53,7 @@ const LUCID_PRE_EDIT_HOOK = {
53
53
  hooks: [
54
54
  {
55
55
  type: "command",
56
- command: `lucid guard pre-edit 2>&1 # ${LUCID_GUARD_MARKER}`,
56
+ command: `lucid guard pre-edit # ${LUCID_GUARD_MARKER}`,
57
57
  },
58
58
  ],
59
59
  };
@@ -8,7 +8,7 @@ import { decompress } from "../store/content.js";
8
8
  import { implicitRewardFromSync } from "../memory/experience.js";
9
9
  import { indexFileInQdrant } from "../retrieval/qdrant.js";
10
10
  import { loadConfig, getQdrantConfig } from "../config.js";
11
- const SUPPORTED_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".vue", ".py", ".go", ".rs"]);
11
+ const SUPPORTED_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".vue", ".py", ".go", ".rs", ".md"]);
12
12
  // ---------------------------------------------------------------------------
13
13
  // sync_file
14
14
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a13xu/lucid",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Token-efficient memory, code indexing, and validation for Claude Code agents — SQLite + FTS5, TF-IDF + Qdrant retrieval, AST skeleton pruning, diff-aware context, Logic Guardian drift detection",
5
5
  "type": "module",
6
6
  "bin": {