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 +6 -6
- package/package.json +4 -1
- package/scripts/compile.sh +55 -0
- package/scripts/install.sh +78 -0
- package/scripts/release-binaries.sh +38 -0
- package/src/agent/format.ts +60 -0
- package/src/agent/hints.test.ts +265 -0
- package/src/agent/hints.ts +280 -0
- package/src/agent/manifest.ts +79 -0
- package/src/cli.ts +215 -141
- package/src/index.ts +162 -83
- package/src/services/AutoTagger.ts +12 -12
- package/src/services/Database.ts +2 -1
- package/src/services/EmbeddingProvider.ts +70 -0
- package/src/services/Gateway.ts +162 -0
- package/src/services/LibSQLDatabase.ts +93 -89
- package/src/services/MarkdownExtractor.ts +151 -18
- package/src/services/Ollama.ts +93 -25
- package/src/services/TaxonomyService.ts +8 -9
- package/src/types.ts +5 -0
- package/src/updater.ts +184 -0
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
|
-
|
|
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
|
-
#
|
|
84
|
-
|
|
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
|
|
87
|
-
|
|
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.
|
|
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
|
+
});
|