API
Types and functions exported by vectorless-js.
Everything comes from two entry points. vectorless-js holds the index, the tools, and the agent. Its only dependency is a tokenizer, which loads lazily and never on the deterministic path. vectorless-js/parse holds the PDF reader and needs the optional peer dependency.
Types
interface TreeNode {
title: string;
node_id: string; // "0000", DFS pre-order
text: string; // this heading down to the next one
line_num: number; // 1-indexed
summary?: string; // leaf nodes, after addNodeSummaries
prefix_summary?: string; // parent nodes, after addNodeSummaries
nodes?: TreeNode[]; // absent on leaves
}
interface IndexedDoc {
doc_id: string;
doc_name: string;
doc_description?: string; // after generateDocDescription
type: "md";
line_count: number;
structure: TreeNode[];
}
type Corpus = Record<string, IndexedDoc>; // keyed by doc_id
type Complete = (prompt: string) => Promise<string>;
interface Citation { doc_id: string; pages: string }
interface AskResult { answer: string; citations: Citation[] }
Complete is how you hand an LLM to the library. It keeps the core free of any fetch or provider code, so you can point it anywhere or stub it in tests.
Building an index
function buildIndex(markdown: string, docName: string, docId?: string): IndexedDoc;
Builds the tree from Markdown headings. No LLM, no network, same output every time. docId defaults to docName.
function buildIndexAuto(
markdown: string,
docName: string,
complete: Complete,
docId?: string,
): Promise<IndexedDoc>;
Runs buildIndex first. If the result is flat, it repairs it, and how it repairs depends on why the tree is flat. A document with headings that all landed at one level goes to assignHeadingLevels, which nests the titles that are already there. A document with no headings goes to reconstructTree, which infers the outline from the body. This is the entry point to reach for when a corpus mixes tidy Markdown with heading-less contracts.
function assignHeadingLevels(markdown: string, complete: Complete): Promise<TreeNode[]>;
Nests headings the document already has. Some PDFs give the parser no font-size hierarchy to work from, so every heading parses at one level: the titles and line numbers are correct and only the nesting is missing. This asks the model for depths alone, in batches, and builds the tree from them deterministically. On a 448 KB filing that is a few hundred tokens per call rather than the ~133k reconstructTree would send.
Headings the model skips default to top-level, so a poor answer degrades to the flat tree you already had rather than a wrong one.
function isFlatTree(doc: IndexedDoc): boolean;
True when the tree is empty, or has depth 1 with more than three nodes. buildIndexAuto uses it to decide.
Enriching a tree
function addNodeSummaries(
doc: IndexedDoc,
complete: Complete,
summaryTokenThreshold?: number, // default 200
countTokensFn?: (text: string) => number | Promise<number>,
): Promise<IndexedDoc>;
Nodes under the threshold keep their own text as the summary and cost nothing. Longer ones go to the LLM. Leaves get summary, parents get prefix_summary. All of them run concurrently. Token counting uses o200k unless you override it.
function generateDocDescription(doc: IndexedDoc, complete: Complete): Promise<IndexedDoc>;
Writes one sentence describing the document, from titles and summaries only. The router reads this later, so run it if you plan to route.
function thinTree(
nodes: TreeNode[],
minTokens: number,
countTokensFn?: (text: string) => number | Promise<number>,
): Promise<TreeNode[]>;
Collapses any subtree under minTokens into a single node and renumbers what’s left. Useful when a parser produces a lot of tiny fragments.
function countTokens(text: string): Promise<number>;
o200k count. Lazily loads the tokenizer, so it only downloads if you actually call it.
Reconstruction
function reconstructTree(markdown: string, complete: Complete): Promise<TreeNode[]>;
Line-numbers the text, asks the LLM for an outline, and builds a tree from the answer. The tree assembly is deterministic. The outline is only as good as the model, so check it against real documents before you trust it.
interface TocItem { structure: string; title: string; line: number }
function structureListToTree(items: TocItem[], lines: string[]): TreeNode[];
The deterministic half, exposed on its own. structure is a dotted index like 1, 1.1, 1.2, which is what sets the nesting.
Retrieval
Configuring the LLM
Both ask and complete take the same thing: either a bare URL, or a config.
The shape mirrors the OpenAI client, so the standard environment variables drop straight in.
interface LLMConfig {
baseURL: string; // "https://api.openai.com/v1", /chat/completions is appended
apiKey?: string; // sent as Authorization: Bearer
model?: string; // exactly as your provider names it
maxTokens?: number;
headers?: Record<string, string>; // e.g. a shared secret your proxy checks
}
type LLMInput = string | LLMConfig; // a bare URL means { baseURL }
From a server, talk to the provider directly:
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_API_KEY=sk-...
export OPENAI_MODEL=gpt-4o
const llm: LLMConfig = {
baseURL: process.env.OPENAI_BASE_URL!,
apiKey: process.env.OPENAI_API_KEY,
model: process.env.OPENAI_MODEL,
};
From a browser, leave apiKey out and let a proxy hold it, because a key in the page is a key anyone can read:
const llm = "/api/llm"; // shorthand for { baseURL: "/api/llm" }
Anything speaking OpenAI chat completions works, including a local server. If you hand it a URL that already ends in /chat/completions it is used as-is, so proxies that do not follow the /v1 layout still work.
ask
function ask(
docOrCorpus: IndexedDoc | Corpus,
question: string,
llm: LLMInput,
onEvent?: (e: { type: "text"; text: string } | { type: "tool"; name: string; input: Record<string, unknown> }) => void,
): Promise<AskResult>;
Runs the tool-use loop and returns the answer with citations. Pass a single doc or a whole corpus. onEvent fires on every tool call and every bit of text, which is how you show the agent’s reasoning as it works. The loop stops after 12 passes.
function routeDocuments(corpus: Corpus, query: string, complete: Complete): Promise<string[]>;
Reads the descriptions and returns the doc_ids worth opening. Unknown ids get dropped, and if nothing matches you get the whole corpus back rather than an empty result.
function complete(prompt: string, llm: LLMInput): Promise<string>;
One-shot completion with no tools. Bind it to supply Complete:
const c: Complete = (p) => complete(p, llm);
maxTokens defaults to 16384 here, and it is worth understanding why it is that high. A reasoning model’s thinking counts against the same budget as its answer, and the thinking is invisible in the response. Asking Gemini to level 318 headings in one call, we measured 15.7k tokens of reasoning against a 16384 cap, leaving so little room that the answer was cut off mid-array with finish_reason: length. A low cap does not return an error. It returns a truncated answer that fails to parse.
Tools
These run locally against the tree. The agent calls them through runTool, but they are exported so you can drive retrieval yourself.
function listDocuments(corpus: Corpus): string;
function getDocument(doc: IndexedDoc): string;
function getDocumentStructure(doc: IndexedDoc): string; // tree with text stripped
function getPageContent(doc: IndexedDoc, pages: string): string; // "5-7" | "3,8" | "12"
function runTool(corpus: Corpus, name: string, input: Record<string, unknown>): string;
Each returns a JSON string, because that is what goes back to the model as a tool result. getDocumentStructure is the one that matters: it strips every text field, which is why the model sees a table of contents instead of a document.
Parsing
import { initParser, pdfToMarkdown } from "vectorless-js/parse";
function initParser(wasm?: InitInput): Promise<void>;
function pdfToMarkdown(bytes: Uint8Array, config?: LiteParseConfig): Promise<string>;
Call initParser once with the wasm file the way your bundler resolves it. The library will not guess for you, which is what lets the same code run in a browser, in Bun, and on an edge runtime.
// Vite and friends
import wasmUrl from "@llamaindex/liteparse-wasm/liteparse_wasm_bg.wasm?url";
await initParser(wasmUrl);
// Bun or Node
await initParser(await Bun.file("node_modules/@llamaindex/liteparse-wasm/pkg/liteparse_wasm_bg.wasm").arrayBuffer());
Called with no argument it tries to find the wasm next to its own glue code. That usually survives a dev server and usually 404s in a production build, so pass it.
OCR is off. Born-digital PDFs read fine; scanned pages come back empty and need a JS OCR engine wired in through config.