A walkthrough for adding blink-query to your own app, CLI, or script. No agent integration here — for that see CONNECTING_TO_AGENT.md.
npm install blink-queryRequirements: Node.js 18+ and macOS / Linux / Windows. The library bundles better-sqlite3 so there's nothing else to install.
import { Blink } from 'blink-query';
const blink = new Blink({ dbPath: './my-wiki.db' });
// Save a record
blink.save({
namespace: 'projects',
title: 'Orpheus',
type: 'SUMMARY',
summary: 'A model-runner service for local LLMs. Multi-server architecture, session affinity.',
tags: ['service', 'oss'],
});
// Resolve by path — deterministic O(1) lookup
const record = blink.resolve('projects/orpheus');
console.log(record.status, record.record?.summary);
// → OK A model-runner service for local LLMs...
// Search — title-weighted BM25
const hits = blink.search('orpheus model runner');
console.log(hits.map(r => r.path));
blink.close();That's the entire surface for basic use. Save → resolve → search.
The type is a consumption instruction — it tells the agent how to use the record, not what it's about.
| Type | When to use | Agent behavior |
|---|---|---|
| SUMMARY | Canonical "what is X" pages, processed wiki entries | Read summary, you have the answer |
| META | Configuration, log entries, structured state, frontmatter-heavy files | Parse content as JSON |
| SOURCE | References to external docs, URLs, git files, papers | Read summary first; fetch the source if you need depth |
| ALIAS | Cross-links between concepts; auto-generated from [[wikilinks]] on ingest |
Follow the redirect to the target record |
| COLLECTION | Namespace indexes (auto-generated for namespaces with no direct record) | Browse children; pick what's relevant |
Full schema and worked examples in BLINK_WIKI.md.
import { Blink } from 'blink-query';
const blink = new Blink({ dbPath: './wiki.db' });
const result = await blink.ingestDirectory('./my-notes', {
namespacePrefix: 'wiki',
});
console.log(`ingested ${result.records.length} records, ${result.errors.length} errors`);
// Now query
const hits = blink.search('typescript generics');ingestDirectory walks the directory recursively, parses YAML frontmatter on .md files, derives namespaces from the directory structure, and uses the library's defaultClassify to type each record. No further configuration needed for most cases.
If your folder structure is wiki/concepts/auth.md, the record gets namespace: wiki/concepts, title: auth, path: wiki/concepts/auth.
import { Blink, GITHUB_DERIVERS } from 'blink-query';
const blink = new Blink({ dbPath: './issues.db' });
await blink.ingestFromGitHub(
{
owner: 'vercel',
repo: 'next.js',
state: 'open',
labels: ['bug'],
maxIssues: 500,
},
GITHUB_DERIVERS,
);
// Each issue becomes a typed record
const bugs = blink.query('vercel/next.js where tags contains "bug" limit 10');Requires a GitHub token in GITHUB_TOKEN env var.
import { Blink, POSTGRES_DERIVERS } from 'blink-query';
const blink = new Blink({ dbPath: './pg-mirror.db' });
await blink.ingestFromPostgres(
{
connectionString: 'postgres://...',
table: 'documents',
textColumn: 'body',
idColumn: 'id',
},
POSTGRES_DERIVERS,
);For larger tables use ingestFromPostgresProgressive which auto-detects columns and ingests in batches.
import { Blink, GIT_DERIVERS } from 'blink-query';
const blink = new Blink({ dbPath: './gitdocs.db' });
await blink.ingestFromGit(
{
repo: 'https://github.com/obsidianmd/obsidian-help.git',
subdir: 'en',
extensions: ['.md'],
},
GIT_DERIVERS,
);Clones the repo to a temp directory, walks the subdir, ingests markdown files. The repo gets cleaned up after.
The library's defaults handle most corpora, but you can override any deriver:
import { Blink, RecordType } from 'blink-query';
await blink.ingestDirectory('./my-corpus', {
classify: (text, metadata): RecordType => {
const fn = (metadata.file_name as string).toLowerCase();
if (fn === 'config.yaml' || fn === 'package.json') return 'META';
if (fn.startsWith('howto-')) return 'SUMMARY';
if (fn.startsWith('ref-')) return 'SOURCE';
return 'SOURCE';
},
summarize: async (text) => {
// Your own summarizer — could be an LLM call, an extractive summarizer, anything
return text.slice(0, 800);
},
deriveNamespace: (metadata) => {
const path = metadata.file_path as string;
return `mycorpus/${path.split('/')[0]}`;
},
});The default classifier (defaultClassify) reads metadata.is_hub from the directory walk and promotes canonical hub pages (index.md / README.md whose parent dir contains other subdirs) to SUMMARY. For most corpora this is enough.
Four ways to read records, in order of preference:
const response = blink.resolve('wiki/mcp-protocol');
// response.status = 'OK' | 'NXDOMAIN' | 'STALE' | 'ALIAS_LOOP'
if (response.status === 'OK') {
console.log(response.record.summary);
}Fastest path. Use when you know the exact namespace + slug.
const hits = blink.search('how to configure auth');
// returns top-10 ranked records (or pass { limit: N })Title matches are weighted 10× over body matches. SUMMARY-typed records get a rank boost. Use when you have a natural-language query.
// All SUMMARY records in the wiki namespace, sorted by hit count
const top = blink.query('wiki where type = "SUMMARY" order by hit_count desc limit 10');
// Records tagged with "bug" updated in the last day
const recent = blink.query('issues where tags contains "bug" since "1 day ago"');Use for structured filters, sorting, range conditions. The DSL grammar is in src/grammar/query.peggy.
const records = blink.list('wiki/concepts', 'recent', { limit: 20 });Use to enumerate everything in a namespace.
// Create or update (idempotent on path)
blink.save({
namespace: 'wiki/concepts',
title: 'OAuth Flow',
type: 'SUMMARY',
summary: '...',
tags: ['auth'],
});
// Move
blink.move('wiki/concepts/old-name', 'wiki/concepts/new-name');
// Delete
blink.delete('wiki/concepts/outdated');All synchronous. Wrapped in transactions internally — partial failures don't leave the DB in a half-state.
A zone is a top-level namespace with declared metadata: a description, a default TTL, and optional required tags. Records saved into a zone inherit its defaults and must include any required tags.
blink.createZone({
namespace: 'decisions',
description: 'Architecture decision records',
defaultTtl: 31536000, // 1 year in seconds
requiredTags: ['adr'],
});
// This now succeeds with the inherited TTL and validated tags
blink.save({
namespace: 'decisions',
title: 'Use SQLite for the index',
type: 'META',
tags: ['adr', 'storage'],
});
// This throws — missing required tag
blink.save({
namespace: 'decisions',
title: 'Use Redis for cache',
type: 'META',
tags: ['storage'], // missing 'adr'
});List zones: blink.zones(). Get one: blink.getZone('decisions').
const blink = new Blink({ dbPath: './wiki.db' });The path is just a SQLite file. You can:
- Open it directly with any SQLite client (
sqlite3 wiki.db) and inspect therecordstable - Back it up with
cp wiki.db wiki.db.bak(better-sqlite3 supportsdb.backup()for online backups) - Gitignore it —
wiki.dbis binary, doesn't merge well, regenerable from your markdown source - Default location: if you don't pass
dbPath, blink uses~/.blink/blink.db
For the MCP server use case, the env var BLINK_DB_PATH sets the location. See CONNECTING_TO_AGENT.md.
const blink = new Blink({ dbPath: './wiki.db' });
try {
// ... your operations
} finally {
blink.close();
}Blink holds a SQLite handle. Call close() when you're done. In a long-running process (server, CLI daemon), keep one instance for the lifetime of the process.
You passed a namespace with .. in it (path traversal guard). Most often this happens when you derive a namespace from a directory containing literal .. in the name (e.g., MDN's for...in). Sanitize: dir.replace(/\.\.+/g, '_').
Pre-v2.0.0 issue with 8-char IDs at large scale. Fixed in v2.0.0 (shortId extended to 16 hex chars). Upgrade.
You saved a record into a zone that requires tags you didn't include. Either add the missing tag to your tags: [...] array, or change the zone's requiredTags.
Three things to check:
- Did you actually call
ingestfirst?blink.list('your/namespace')should show records. - Are your records' titles meaningful? If every file is
index.md, run with the library's defaultfilesystemTitlewhich uses parent-dir name. - Title weight is 10× body weight. If your query terms are only in the body, search results may rank by something else.
The record's TTL has expired. Either re-ingest or save a fresh version. STALE doesn't mean the record is gone — response.record still has the data, you just know it's old.
BLINK_WIKI.md— full schema doc and worked examples for using blink-query as an LLM wikiexamples/llm-wiki/— a runnable end-to-end example: 30-file MCP ecosystem corpusbenchmark/README.md— methodology and reference numbers for the universal benchmarkCONNECTING_TO_AGENT.md— plug blink-query into Claude Desktop / Code / Cursor / Codex