pdf-brain 1.1.3 → 1.3.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/README.md CHANGED
@@ -27,8 +27,8 @@ Local **PDF & Markdown** knowledge base with semantic search and AI-powered enri
27
27
  ## Quick Start
28
28
 
29
29
  ```bash
30
- # 1. Install
31
- npm install -g pdf-brain
30
+ # 1. Install (standalone binary, no runtime needed)
31
+ curl -fsSL https://raw.githubusercontent.com/joelhooks/pdf-library/main/scripts/install.sh | bash
32
32
 
33
33
  # 2. Install Ollama (macOS)
34
34
  brew install ollama
@@ -80,11 +80,11 @@ ollama serve
80
80
  ### Install pdf-brain
81
81
 
82
82
  ```bash
83
- # npm
84
- npm install -g pdf-brain
83
+ # Standalone binary (no runtime needed)
84
+ curl -fsSL https://raw.githubusercontent.com/joelhooks/pdf-library/main/scripts/install.sh | bash
85
85
 
86
- # or run directly
87
- npx pdf-brain <command>
86
+ # or via npm
87
+ npm install -g pdf-brain
88
88
  ```
89
89
 
90
90
  ## CLI Reference
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pdf-brain",
3
- "version": "1.1.3",
3
+ "version": "1.3.0",
4
4
  "description": "Local PDF & Markdown knowledge base with semantic search, AI enrichment, and SKOS taxonomy",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -9,6 +9,9 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "bun build src/index.ts src/cli.ts --outdir dist --target bun",
12
+ "compile": "./scripts/compile.sh",
13
+ "compile:all": "./scripts/compile.sh --all",
14
+ "release:binaries": "./scripts/release-binaries.sh",
12
15
  "dev": "bun run src/cli.ts",
13
16
  "test": "bun test",
14
17
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ VERSION=$(node -p "require('./package.json').version")
5
+ DEFINE="--define __PDF_BRAIN_VERSION__=\"\\\"${VERSION}\\\"\""
6
+ ENTRY="src/cli.ts"
7
+ OUTDIR="dist"
8
+
9
+ # All supported targets
10
+ # bun compile docs: https://bun.sh/docs/bundler/executables
11
+ TARGETS=(
12
+ "bun-darwin-arm64"
13
+ "bun-darwin-x64"
14
+ "bun-linux-x64"
15
+ "bun-linux-arm64"
16
+ "bun-windows-x64"
17
+ )
18
+
19
+ build_target() {
20
+ local target="$1"
21
+ local suffix="${target#bun-}" # strip "bun-" prefix -> darwin-arm64, linux-x64, etc.
22
+ local ext=""
23
+ [[ "${suffix}" == windows-* ]] && ext=".exe"
24
+ local outfile="${OUTDIR}/pdf-brain-${suffix}${ext}"
25
+
26
+ echo " ${suffix}..."
27
+ eval bun build --compile --target "${target}" "${ENTRY}" --outfile "${outfile}" ${DEFINE}
28
+ echo " -> ${outfile} ($(du -sh "${outfile}" | cut -f1 | xargs))"
29
+ }
30
+
31
+ mkdir -p "${OUTDIR}"
32
+
33
+ if [ "${1:-}" = "--all" ]; then
34
+ echo "Compiling pdf-brain v${VERSION} for all platforms:"
35
+ for target in "${TARGETS[@]}"; do
36
+ build_target "${target}"
37
+ done
38
+ echo ""
39
+ echo "Done. Binaries in ${OUTDIR}/"
40
+ ls -lh "${OUTDIR}"/pdf-brain-*
41
+
42
+ elif [ -n "${1:-}" ]; then
43
+ # Build specific target, e.g. ./scripts/compile.sh linux-x64
44
+ target="bun-${1}"
45
+ echo "Compiling pdf-brain v${VERSION} for ${1}:"
46
+ build_target "${target}"
47
+
48
+ else
49
+ # Default: build for current platform
50
+ echo "Compiling pdf-brain v${VERSION} (native):"
51
+ echo " native..."
52
+ eval bun build --compile "${ENTRY}" --outfile "${OUTDIR}/pdf-brain" ${DEFINE}
53
+ SIZE=$(du -sh "${OUTDIR}/pdf-brain" | cut -f1 | xargs)
54
+ echo " -> ${OUTDIR}/pdf-brain (${SIZE})"
55
+ fi
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ REPO="joelhooks/pdf-library"
5
+ BINARY="pdf-brain"
6
+ INSTALL_DIR="${PDF_BRAIN_INSTALL_DIR:-/usr/local/bin}"
7
+
8
+ # Detect platform
9
+ OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
10
+ ARCH="$(uname -m)"
11
+
12
+ case "${OS}" in
13
+ darwin) PLATFORM="darwin" ;;
14
+ linux) PLATFORM="linux" ;;
15
+ mingw*|msys*|cygwin*) PLATFORM="windows" ;;
16
+ *) echo "Unsupported OS: ${OS}" >&2; exit 1 ;;
17
+ esac
18
+
19
+ case "${ARCH}" in
20
+ x86_64|amd64) ARCH="x64" ;;
21
+ arm64|aarch64) ARCH="arm64" ;;
22
+ *) echo "Unsupported architecture: ${ARCH}" >&2; exit 1 ;;
23
+ esac
24
+
25
+ SUFFIX="${PLATFORM}-${ARCH}"
26
+ EXT=""
27
+ [ "${PLATFORM}" = "windows" ] && EXT=".exe"
28
+ ASSET="${BINARY}-${SUFFIX}${EXT}"
29
+
30
+ # Get latest release tag (or use env override)
31
+ if [ -n "${PDF_BRAIN_VERSION:-}" ]; then
32
+ TAG="v${PDF_BRAIN_VERSION}"
33
+ else
34
+ TAG=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4)
35
+ fi
36
+
37
+ if [ -z "${TAG}" ]; then
38
+ echo "Failed to determine latest version." >&2
39
+ echo "Set PDF_BRAIN_VERSION=x.y.z to install a specific version." >&2
40
+ exit 1
41
+ fi
42
+
43
+ URL="https://github.com/${REPO}/releases/download/${TAG}/${ASSET}"
44
+
45
+ echo "Installing pdf-brain ${TAG} (${SUFFIX})..."
46
+ echo " ${URL}"
47
+ echo ""
48
+
49
+ # Download to temp file
50
+ TMP=$(mktemp)
51
+ trap 'rm -f "${TMP}"' EXIT
52
+
53
+ HTTP_CODE=$(curl -fsSL -w "%{http_code}" -o "${TMP}" "${URL}" 2>/dev/null || true)
54
+
55
+ if [ "${HTTP_CODE}" != "200" ] || [ ! -s "${TMP}" ]; then
56
+ echo "Download failed (HTTP ${HTTP_CODE})." >&2
57
+ echo "" >&2
58
+ echo "Available assets for ${TAG}:" >&2
59
+ curl -fsSL "https://api.github.com/repos/${REPO}/releases/tags/${TAG}" \
60
+ | grep '"name"' | grep 'pdf-brain' | cut -d'"' -f4 | sed 's/^/ /' >&2 2>/dev/null || true
61
+ echo "" >&2
62
+ echo "Your platform: ${SUFFIX}" >&2
63
+ exit 1
64
+ fi
65
+
66
+ # Install
67
+ chmod +x "${TMP}"
68
+
69
+ if [ -w "${INSTALL_DIR}" ]; then
70
+ mv "${TMP}" "${INSTALL_DIR}/${BINARY}"
71
+ else
72
+ echo " Need sudo to write to ${INSTALL_DIR}"
73
+ sudo mv "${TMP}" "${INSTALL_DIR}/${BINARY}"
74
+ fi
75
+
76
+ echo "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}"
77
+ echo ""
78
+ "${INSTALL_DIR}/${BINARY}" --version 2>/dev/null || true
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ VERSION=$(node -p "require('./package.json').version")
5
+ TAG="v${VERSION}"
6
+
7
+ echo "Building pdf-brain v${VERSION} binaries for release..."
8
+ echo ""
9
+
10
+ # Build all platforms
11
+ ./scripts/compile.sh --all
12
+
13
+ echo ""
14
+
15
+ # Check if release already exists
16
+ if gh release view "${TAG}" &>/dev/null; then
17
+ echo "Release ${TAG} exists. Uploading binaries..."
18
+ gh release upload "${TAG}" \
19
+ dist/pdf-brain-darwin-arm64 \
20
+ dist/pdf-brain-darwin-x64 \
21
+ dist/pdf-brain-linux-x64 \
22
+ dist/pdf-brain-linux-arm64 \
23
+ dist/pdf-brain-windows-x64.exe \
24
+ --clobber
25
+ else
26
+ echo "Creating release ${TAG} with binaries..."
27
+ gh release create "${TAG}" \
28
+ dist/pdf-brain-darwin-arm64 \
29
+ dist/pdf-brain-darwin-x64 \
30
+ dist/pdf-brain-linux-x64 \
31
+ dist/pdf-brain-linux-arm64 \
32
+ dist/pdf-brain-windows-x64.exe \
33
+ --title "pdf-brain ${TAG}" \
34
+ --generate-notes
35
+ fi
36
+
37
+ echo ""
38
+ echo "Done. https://github.com/$(gh repo view --json nameWithOwner -q .nameWithOwner)/releases/tag/${TAG}"
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Formatting utilities for HATEOAS hint blocks.
3
+ */
4
+
5
+ /**
6
+ * Strip emoji characters from text (for cleaner machine-readable output).
7
+ */
8
+ export function stripEmoji(text: string): string {
9
+ return text
10
+ .replace(
11
+ /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu,
12
+ ""
13
+ )
14
+ .replace(/\s{2,}/g, " ")
15
+ .trim();
16
+ }
17
+
18
+ /**
19
+ * Format hint strings into a markdown blockquote block.
20
+ *
21
+ * Output:
22
+ * ```
23
+ * ---
24
+ * > **Next Actions**
25
+ * > - `cmd` -- description
26
+ * > ...
27
+ * >
28
+ * > pdf-brain: N documents, M concepts. `pdf-brain --help` for full reference.
29
+ * ```
30
+ */
31
+ export function formatHintBlock(
32
+ hints: string[],
33
+ stats?: { documents: number; concepts?: number }
34
+ ): string {
35
+ if (hints.length === 0) return "";
36
+
37
+ const lines: string[] = [];
38
+ lines.push("---");
39
+ lines.push("> **Next Actions**");
40
+ for (const hint of hints) {
41
+ lines.push(`> - ${hint}`);
42
+ }
43
+ lines.push(">");
44
+
45
+ if (stats) {
46
+ const parts = [`${stats.documents} documents`];
47
+ if (stats.concepts !== undefined && stats.concepts > 0) {
48
+ parts.push(`${stats.concepts} concepts`);
49
+ }
50
+ lines.push(
51
+ `> pdf-brain: ${parts.join(", ")}. \`pdf-brain --help\` for full reference.`
52
+ );
53
+ } else {
54
+ lines.push(
55
+ `> \`pdf-brain --help\` for full reference.`
56
+ );
57
+ }
58
+
59
+ return "\n" + lines.join("\n");
60
+ }
@@ -0,0 +1,265 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateHints, type CommandResult } from "./hints.js";
3
+ import { formatHintBlock, stripEmoji } from "./format.js";
4
+
5
+ describe("generateHints", () => {
6
+ test("search with results suggests read + expand", () => {
7
+ const result: CommandResult = {
8
+ _tag: "search",
9
+ query: "error handling",
10
+ results: [
11
+ { title: "Release It!", docId: "doc-1", score: 0.85 },
12
+ { title: "DDIA", docId: "doc-2", score: 0.72 },
13
+ ],
14
+ concepts: [],
15
+ hadExpand: false,
16
+ wasFts: false,
17
+ };
18
+ const hints = generateHints(result);
19
+ expect(hints.length).toBeGreaterThan(0);
20
+ expect(hints.some((h) => h.includes("read"))).toBe(true);
21
+ expect(hints.some((h) => h.includes("--expand"))).toBe(true);
22
+ });
23
+
24
+ test("search with expand already used does not suggest --expand again", () => {
25
+ const result: CommandResult = {
26
+ _tag: "search",
27
+ query: "error handling",
28
+ results: [{ title: "Release It!", docId: "doc-1", score: 0.85 }],
29
+ concepts: [],
30
+ hadExpand: true,
31
+ wasFts: false,
32
+ };
33
+ const hints = generateHints(result);
34
+ expect(hints.some((h) => h.includes("--expand"))).toBe(false);
35
+ });
36
+
37
+ test("search with no results suggests broader query + fts", () => {
38
+ const result: CommandResult = {
39
+ _tag: "search",
40
+ query: "nonexistent topic",
41
+ results: [],
42
+ concepts: [],
43
+ hadExpand: false,
44
+ wasFts: false,
45
+ };
46
+ const hints = generateHints(result);
47
+ expect(hints.length).toBeGreaterThan(0);
48
+ expect(hints.some((h) => h.includes("--fts"))).toBe(true);
49
+ expect(hints.some((h) => h.includes("list"))).toBe(true);
50
+ });
51
+
52
+ test("search with concepts suggests taxonomy navigation", () => {
53
+ const result: CommandResult = {
54
+ _tag: "search",
55
+ query: "design patterns",
56
+ results: [{ title: "GoF", docId: "doc-1", score: 0.9 }],
57
+ concepts: [{ id: "software/design-patterns", prefLabel: "Design Patterns" }],
58
+ hadExpand: false,
59
+ wasFts: false,
60
+ };
61
+ const hints = generateHints(result);
62
+ expect(hints.some((h) => h.includes("taxonomy tree"))).toBe(true);
63
+ });
64
+
65
+ test("noResults with FTS suggests vector search", () => {
66
+ const result: CommandResult = {
67
+ _tag: "noResults",
68
+ query: "missing thing",
69
+ wasFts: true,
70
+ };
71
+ const hints = generateHints(result);
72
+ expect(hints.some((h) => !h.includes("--fts"))).toBe(true);
73
+ expect(hints.some((h) => h.includes("list"))).toBe(true);
74
+ });
75
+
76
+ test("read suggests search + tag + taxonomy", () => {
77
+ const result: CommandResult = {
78
+ _tag: "read",
79
+ title: "Release It!",
80
+ id: "doc-123",
81
+ tags: ["programming", "resilience"],
82
+ };
83
+ const hints = generateHints(result);
84
+ expect(hints.length).toBeGreaterThan(0);
85
+ expect(hints.some((h) => h.includes("search"))).toBe(true);
86
+ expect(hints.some((h) => h.includes("--tag"))).toBe(true);
87
+ expect(hints.some((h) => h.includes("taxonomy"))).toBe(true);
88
+ });
89
+
90
+ test("list suggests read + search", () => {
91
+ const result: CommandResult = {
92
+ _tag: "list",
93
+ count: 42,
94
+ firstDoc: { title: "DDIA", id: "doc-1" },
95
+ };
96
+ const hints = generateHints(result);
97
+ expect(hints.some((h) => h.includes("read"))).toBe(true);
98
+ expect(hints.some((h) => h.includes("search"))).toBe(true);
99
+ });
100
+
101
+ test("stats suggests search + list + taxonomy + doctor", () => {
102
+ const result: CommandResult = {
103
+ _tag: "stats",
104
+ documents: 100,
105
+ chunks: 5000,
106
+ embeddings: 5000,
107
+ };
108
+ const hints = generateHints(result);
109
+ expect(hints.length).toBe(4);
110
+ expect(hints.some((h) => h.includes("search"))).toBe(true);
111
+ expect(hints.some((h) => h.includes("doctor"))).toBe(true);
112
+ });
113
+
114
+ test("taxonomySearch with matches suggests tree + search", () => {
115
+ const result: CommandResult = {
116
+ _tag: "taxonomySearch",
117
+ query: "error",
118
+ matches: [{ id: "programming/error-handling", prefLabel: "Error Handling" }],
119
+ };
120
+ const hints = generateHints(result);
121
+ expect(hints.some((h) => h.includes("taxonomy tree"))).toBe(true);
122
+ expect(hints.some((h) => h.includes("search"))).toBe(true);
123
+ });
124
+
125
+ test("taxonomySearch with no matches suggests list + search", () => {
126
+ const result: CommandResult = {
127
+ _tag: "taxonomySearch",
128
+ query: "nonexistent",
129
+ matches: [],
130
+ };
131
+ const hints = generateHints(result);
132
+ expect(hints.some((h) => h.includes("taxonomy list"))).toBe(true);
133
+ });
134
+
135
+ test("taxonomyList suggests tree + search", () => {
136
+ const result: CommandResult = { _tag: "taxonomyList", count: 50 };
137
+ const hints = generateHints(result);
138
+ expect(hints.some((h) => h.includes("--tree"))).toBe(true);
139
+ expect(hints.some((h) => h.includes("taxonomy search"))).toBe(true);
140
+ });
141
+
142
+ test("taxonomyTree with rootId suggests full tree", () => {
143
+ const result: CommandResult = { _tag: "taxonomyTree", rootId: "software" };
144
+ const hints = generateHints(result);
145
+ expect(hints.some((h) => h.includes("taxonomy tree`"))).toBe(true);
146
+ });
147
+
148
+ test("add suggests read + search + tag", () => {
149
+ const result: CommandResult = { _tag: "add", title: "New Book", id: "doc-new" };
150
+ const hints = generateHints(result);
151
+ expect(hints.length).toBe(3);
152
+ expect(hints.some((h) => h.includes("read"))).toBe(true);
153
+ expect(hints.some((h) => h.includes("search"))).toBe(true);
154
+ expect(hints.some((h) => h.includes("tag"))).toBe(true);
155
+ });
156
+
157
+ test("remove suggests list + stats", () => {
158
+ const result: CommandResult = { _tag: "remove", title: "Old Book" };
159
+ const hints = generateHints(result);
160
+ expect(hints.some((h) => h.includes("list"))).toBe(true);
161
+ expect(hints.some((h) => h.includes("stats"))).toBe(true);
162
+ });
163
+
164
+ test("doctor unhealthy suggests --fix", () => {
165
+ const result: CommandResult = { _tag: "doctor", healthy: false };
166
+ const hints = generateHints(result);
167
+ expect(hints.some((h) => h.includes("--fix"))).toBe(true);
168
+ });
169
+
170
+ test("doctor healthy does not suggest --fix", () => {
171
+ const result: CommandResult = { _tag: "doctor", healthy: true };
172
+ const hints = generateHints(result);
173
+ expect(hints.some((h) => h.includes("--fix"))).toBe(false);
174
+ });
175
+
176
+ test("error suggests doctor + check + help", () => {
177
+ const result: CommandResult = {
178
+ _tag: "error",
179
+ command: "search",
180
+ message: "Connection failed",
181
+ };
182
+ const hints = generateHints(result);
183
+ expect(hints.some((h) => h.includes("doctor"))).toBe(true);
184
+ expect(hints.some((h) => h.includes("check"))).toBe(true);
185
+ expect(hints.some((h) => h.includes("--help"))).toBe(true);
186
+ });
187
+
188
+ test("every variant produces non-empty hints array", () => {
189
+ const variants: CommandResult[] = [
190
+ { _tag: "search", query: "q", results: [{ title: "T", docId: "d", score: 0.5 }], concepts: [], hadExpand: false, wasFts: false },
191
+ { _tag: "noResults", query: "q", wasFts: false },
192
+ { _tag: "read", title: "T", id: "d", tags: ["t"] },
193
+ { _tag: "list", count: 1, firstDoc: { title: "T", id: "d" } },
194
+ { _tag: "stats", documents: 1, chunks: 1, embeddings: 1 },
195
+ { _tag: "taxonomySearch", query: "q", matches: [{ id: "c", prefLabel: "C" }] },
196
+ { _tag: "taxonomyList", count: 1 },
197
+ { _tag: "taxonomyTree" },
198
+ { _tag: "add", title: "T", id: "d" },
199
+ { _tag: "remove", title: "T" },
200
+ { _tag: "tag", title: "T", tags: ["t"] },
201
+ { _tag: "doctor", healthy: true },
202
+ { _tag: "config", subcommand: "show" },
203
+ { _tag: "check", reachable: true },
204
+ { _tag: "repair", orphanedChunks: 0, orphanedEmbeddings: 0 },
205
+ { _tag: "reindex", count: 1, errors: 0 },
206
+ { _tag: "error", command: "search", message: "fail" },
207
+ ];
208
+
209
+ for (const variant of variants) {
210
+ const hints = generateHints(variant);
211
+ expect(hints.length).toBeGreaterThan(0);
212
+ }
213
+ });
214
+ });
215
+
216
+ describe("formatHintBlock", () => {
217
+ test("produces valid markdown blockquote", () => {
218
+ const block = formatHintBlock([
219
+ "`pdf-brain search \"test\"` -- Search",
220
+ "`pdf-brain list` -- Browse",
221
+ ], { documents: 42 });
222
+
223
+ expect(block).toContain("---");
224
+ expect(block).toContain("> **Next Actions**");
225
+ expect(block).toContain("> -");
226
+ expect(block).toContain("42 documents");
227
+ expect(block).toContain("`pdf-brain --help`");
228
+ });
229
+
230
+ test("includes concept count when provided", () => {
231
+ const block = formatHintBlock(["`cmd` -- desc"], {
232
+ documents: 10,
233
+ concepts: 50,
234
+ });
235
+ expect(block).toContain("50 concepts");
236
+ });
237
+
238
+ test("returns empty string for no hints", () => {
239
+ expect(formatHintBlock([])).toBe("");
240
+ });
241
+
242
+ test("works without stats", () => {
243
+ const block = formatHintBlock(["`cmd` -- desc"]);
244
+ expect(block).toContain("> **Next Actions**");
245
+ expect(block).toContain("`pdf-brain --help`");
246
+ expect(block).not.toContain("documents");
247
+ });
248
+ });
249
+
250
+ describe("stripEmoji", () => {
251
+ test("removes emoji characters", () => {
252
+ expect(stripEmoji("📚 Concepts")).toBe("Concepts");
253
+ expect(stripEmoji("🏷️ Label")).toBe("Label");
254
+ expect(stripEmoji("📄 Documents (5):")).toBe("Documents (5):");
255
+ });
256
+
257
+ test("preserves plain text", () => {
258
+ expect(stripEmoji("Hello world")).toBe("Hello world");
259
+ expect(stripEmoji("pdf-brain search")).toBe("pdf-brain search");
260
+ });
261
+
262
+ test("handles empty string", () => {
263
+ expect(stripEmoji("")).toBe("");
264
+ });
265
+ });