@a13xu/lucid 1.19.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 +37 -7
- package/build/tools/book.d.ts +58 -0
- package/build/tools/book.js +446 -0
- package/build/tools/init.js +1 -1
- package/build/tools/sync.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
+
`;
|
package/build/tools/init.js
CHANGED
package/build/tools/sync.js
CHANGED
|
@@ -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.
|
|
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": {
|