Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vale/styles/config/vocabularies/Bobbin/accept.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@ MCP
mdbook
middleware
monorepo
[Mm]ultimodal
Namespace
OAuth
Onboarding
param
params
PDFs
pensieve
Pensieve
polars
Expand All @@ -104,6 +106,7 @@ ripgrep
rmcp
rrf_k
rst
runbooks
rustup
save_session
search_beads
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Multimodal ingest — PDF text (bo-j5r0)** — opt-in `[index] multimodal`
flag. When enabled, `bobbin index` also walks `**/*.pdf`, extracts text via a
pure-Rust extractor (`pdf-extract`; no Python/native toolchain), and chunks it
like a plain-text document (`language = "pdf"`) so runbooks, design docs, and
specs become searchable. Off by default — no change to the default
dep/behavior profile. Image captioning (vision LLM) is tracked as a follow-up.

## [0.4.0] - 2026-06-27

Search quality, knowledge-graph ranking, workflow telemetry, and a bead
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ axum = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
urlencoding = "2"
pdf-extract = "0.12"

[features]
knowledge = ["dep:quipu"]
Expand Down
20 changes: 20 additions & 0 deletions docs/book/src/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ exclude = [
]

use_gitignore = true

# Multimodal ingest (opt-in). When true, the indexer also walks PDFs,
# extracts their text, and chunks it like a plain-text document.
multimodal = false
```

## Options
Expand All @@ -49,9 +53,25 @@ use_gitignore = true
| `include` | string[] | See above | Glob patterns for files to include |
| `exclude` | string[] | See above | Additional exclusion patterns (on top of `.gitignore`) |
| `use_gitignore` | bool | `true` | Whether to respect `.gitignore` files |
| `multimodal` | bool | `false` | Enable multimodal ingest (PDF text extraction). See below. |

## Notes

- **Include patterns** determine which file extensions are parsed and indexed. Add patterns to index additional file types.
- **Exclude patterns** are applied in addition to `.gitignore`. Use them to skip generated code, vendor directories, or other non-useful content.
- When `use_gitignore` is `true`, files matched by `.gitignore` are automatically excluded even if they match an include pattern.

## Multimodal ingest

By default bobbin indexes code, markdown, and beads. Set `multimodal = true` to
also ingest **PDFs** (runbooks, design docs, specs):

- The indexer automatically walks `**/*.pdf` — you do **not** need to add it to
`include`. Toggling the flag is the only knob.
- Text is extracted with a pure-Rust extractor (no Python, no native toolchain)
and chunked like a plain-text document. Chunks are tagged with
`language = "pdf"`, so you can filter on them in search.
- Image-only or encrypted PDFs may yield little or no text; those files are
skipped the same way an empty file is.
- Image captioning (vision LLM) is **not** yet supported and is tracked as a
follow-up.
57 changes: 51 additions & 6 deletions src/cli/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,7 @@ pub async fn run(args: IndexArgs, output: OutputConfig) -> Result<()> {
.to_string();

if !args.force {
let content = std::fs::read_to_string(file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
let content = read_indexable_content(file_path, &config)?;
let hash = compute_hash(&content);

if let Some(stored_hash) = metadata_store.get_file_hash(&rel_path)? {
Expand Down Expand Up @@ -391,7 +390,7 @@ pub async fn run(args: IndexArgs, output: OutputConfig) -> Result<()> {
.to_string();

let t_read = Instant::now();
let content = match std::fs::read_to_string(file_path) {
let content = match read_indexable_content(file_path, &config) {
Ok(c) => c,
Err(e) => {
errors.push((rel_path.clone(), e.to_string()));
Expand Down Expand Up @@ -1362,13 +1361,35 @@ pub async fn run(args: IndexArgs, output: OutputConfig) -> Result<()> {
Ok(())
}

/// Read a file's text content for indexing.
///
/// For multimodal-enabled file types (currently PDFs) the text is extracted via
/// [`crate::index::multimodal`]; everything else is read as UTF-8. The
/// multimodal branch only activates when `index.multimodal` is set, so default
/// indexing behavior is unchanged.
fn read_indexable_content(path: &Path, config: &Config) -> Result<String> {
if config.index.multimodal && crate::index::multimodal::is_multimodal_file(path) {
crate::index::multimodal::extract_text(path)
} else {
std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))
}
}

/// Collect all files to index based on configuration patterns
fn collect_files(repo_root: &Path, config: &Config) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();

let include_patterns: Vec<glob::Pattern> = config
.index
.include
let mut include_globs: Vec<String> = config.index.include.clone();
if config.index.multimodal {
// Opt-in: also walk multimodal file types (PDFs) so the extractor can
// turn them into searchable text. Kept separate from the default
// include list so toggling the flag is the only knob users need.
for ext in crate::index::multimodal::MULTIMODAL_EXTENSIONS {
include_globs.push(format!("**/*.{ext}"));
}
}
let include_patterns: Vec<glob::Pattern> = include_globs
.iter()
.filter_map(|p| glob::Pattern::new(p).ok())
.collect();
Expand Down Expand Up @@ -1771,6 +1792,30 @@ mod tests {
assert!(txt_files.is_empty());
}

#[test]
fn test_collect_files_multimodal_flag_gates_pdf() {
let dir = tempdir().unwrap();
let root = dir.path();

std::fs::write(root.join("main.rs"), "fn main() {}").unwrap();
std::fs::write(root.join("runbook.pdf"), b"%PDF-1.4 dummy").unwrap();

// Off by default: PDFs are not walked.
let mut config = Config::default();
assert!(!config.index.multimodal);
let files = collect_files(root, &config).unwrap();
assert!(!files
.iter()
.any(|p| p.extension().map(|e| e == "pdf").unwrap_or(false)));

// Opt-in: the flag makes the walker pick up PDFs.
config.index.multimodal = true;
let files = collect_files(root, &config).unwrap();
assert!(files
.iter()
.any(|p| p.file_name().map(|n| n == "runbook.pdf").unwrap_or(false)));
}

#[test]
fn test_collect_files_excludes_patterns() {
let dir = tempdir().unwrap();
Expand Down
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ pub struct IndexConfig {
/// Overlapping lines between consecutive line-based chunks. Default: 10.
/// Must be smaller than `chunk_size`.
pub chunk_overlap: usize,
/// Enable multimodal ingest (currently PDF text extraction). When true, the
/// indexer also walks `**/*.pdf`, extracts their text via a pure-Rust
/// extractor, and chunks it like a plain-text document (language = "pdf").
/// Default: false — opt-in so the default behavior/dep profile is unchanged.
pub multimodal: bool,
}

impl Default for IndexConfig {
Expand Down Expand Up @@ -110,6 +115,7 @@ impl Default for IndexConfig {
use_gitignore: true,
chunk_size: 50,
chunk_overlap: 10,
multimodal: false,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/index/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod coverage;
pub mod cross_repo;
pub mod embedder;
pub mod git;
pub mod multimodal;
pub mod parser;
pub mod resolver;

Expand Down
93 changes: 93 additions & 0 deletions src/index/multimodal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//! Multimodal ingest: extract searchable text from non-source file types.
//!
//! Today this covers PDFs (runbooks, design docs) via the pure-Rust
//! `pdf-extract` crate — no Python, no native toolchain. The extracted text is
//! handed back to the normal chunking pipeline (language = "pdf") so PDFs become
//! searchable and graph-extractable alongside code and markdown.
//!
//! Image captioning (vision LLM) is intentionally out of scope here: bobbin has
//! no chat/vision provider path yet, so it is tracked as a follow-up bead. This
//! module is the seam where that second extractor will slot in.

use std::path::Path;

use anyhow::{Context, Result};

/// File extensions handled by the multimodal extractor.
///
/// Centralized so the indexer's include-pattern injection, the per-file routing
/// decision, and any future additions all agree on one list.
pub const MULTIMODAL_EXTENSIONS: &[&str] = &["pdf"];

/// Returns true if `path` has an extension the multimodal extractor handles.
/// Comparison is case-insensitive (`.PDF` counts).
pub fn is_multimodal_file(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => {
let ext = ext.to_ascii_lowercase();
MULTIMODAL_EXTENSIONS.contains(&ext.as_str())
}
None => false,
}
}

/// Extract plain text from a multimodal file for indexing.
///
/// Currently only PDFs are supported; callers gate this behind
/// [`is_multimodal_file`] and the `index.multimodal` config flag, so an
/// unsupported extension here is a programming error, not user input.
pub fn extract_text(path: &Path) -> Result<String> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();

match ext.as_str() {
"pdf" => extract_pdf_text(path),
other => anyhow::bail!("multimodal extractor: unsupported extension {other:?}"),
}
}

/// Extract text from a PDF using the pure-Rust `pdf-extract` crate.
///
/// Returns the concatenated text content. Encrypted or image-only PDFs may
/// yield little or no text; callers treat empty output the same as an empty
/// file (skipped), so this only errors on genuine parse failures.
fn extract_pdf_text(path: &Path) -> Result<String> {
pdf_extract::extract_text(path)
.with_context(|| format!("failed to extract text from PDF {}", path.display()))
}

#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;

#[test]
fn detects_pdf_extension_case_insensitively() {
assert!(is_multimodal_file(&PathBuf::from("docs/runbook.pdf")));
assert!(is_multimodal_file(&PathBuf::from("docs/RUNBOOK.PDF")));
assert!(!is_multimodal_file(&PathBuf::from("src/main.rs")));
assert!(!is_multimodal_file(&PathBuf::from("README.md")));
assert!(!is_multimodal_file(&PathBuf::from("no_extension")));
}

#[test]
fn extract_text_rejects_unsupported_extension() {
let err = extract_text(&PathBuf::from("photo.png")).unwrap_err();
assert!(err.to_string().contains("unsupported extension"));
}

#[test]
fn extracts_text_from_sample_pdf() {
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sample.pdf");
let text = extract_text(&fixture).expect("extract sample PDF");
// Whitespace/layout is extractor-dependent; assert the words survive.
let normalized: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
normalized.contains("Hello Bobbin multimodal PDF ingest"),
"extracted text was: {text:?}",
);
}
}
3 changes: 3 additions & 0 deletions src/index/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,9 @@ fn detect_language(path: &Path) -> Option<String> {
"c" | "h" => Some("c".to_string()),
"cpp" | "cc" | "hpp" => Some("cpp".to_string()),
"md" => Some("markdown".to_string()),
// PDF text (extracted upstream by index::multimodal). Falls through to
// line-based chunking in parse_file, tagged with language = "pdf".
"pdf" => Some("pdf".to_string()),
_ => None,
}
}
Expand Down
Binary file added tests/fixtures/sample.pdf
Binary file not shown.
Loading