Renderizado do repositório de origem, preservando títulos, exemplos, código, tabelas, links e imagens.
sense
SQL over a markdown tree, kept fresh by a filesystem check on every query. Every file becomes rows in frontmatter (one column per key, plus path/_mtime/_ctime/_size/_rank/_parse_error; _ctime is filesystem birthtime, which a clone or copy resets just like _mtime), content (title, summary, text, path; an FTS5 index on the default store, with machine-written title_seg/summary_seg/text_seg sidecars used for matching Chinese, Japanese, Thai, Khmer, Lao, and Burmese text, not for reading), links (src, target, dst, embed; NULL dst = dead link; embed 1 for ![[...]] embeds, 0 for links; one row per distinct written target and kind with alias and anchor stripped, so [[Foo]] and [[Foo|alias]] are one row while [[Foo]] and [[notes/Foo]] are two rows that can share a dst, and a target both linked and embedded is a row of each kind; extraction matches Obsidian's own graph: comments, code, and link-syntax text yield no rows, [[#Anchor]] is a self-edge, a frontmatter value that is exactly [[X]] is a link, and a basename collision resolves to the linking note itself, else the shortest path), tags (path, tag: frontmatter and inline #tags merged and deduplicated; nested tags stored full, so book/scifi matches tag = 'book' OR tag LIKE 'book/%'), sections (heading outline with line ranges and token estimates), and preset_files (path, preset: which presets cover which files). Features add their own storage; map and status report which are on.
Stores. The config's store key picks the backing store: sqlite (default, zero-dependency, Node's built-in SQLite), or the experimental duckdb and turso (the first command that opens such a tree installs that engine's package on its own: @duckdb/node-api, a one-time native download of about 110 MB, or the much smaller @tursodatabase/database). The tables, ? placeholders, quoted identifiers, and the scope binding are the same on all three, so ordinary frontmatter SQL ports as written. Two things do not port. First, FTS5: content is an FTS5 table on sqlite and a plain table on the other two, so hand-written MATCH, snippet(), bm25(), and sqlite's date-function forms run only on sqlite (duckdb has its own fts functions and date syntax; turso has Tantivy's fts_match/fts_score), and search text under duckdb and turso rejects FTS5's prefix (foo*), boolean (AND/OR/NOT), NEAR, initial-token (^), and column-filter (title:foo) operators with a named error (STORECAPABILITYMISSING) that says how to rephrase or set store to sqlite; bare words and quoted phrases work on all three. Second, of the has/basename/segment functions, has and basename run on all three (turso rewrites them into portable SQL rather than registering them), while segment runs on sqlite and duckdb only, so a query calling segment under turso fails with a named error (STORECAPABILITYMISSING) saying to rephrase or set store to sqlite. sense watch runs on all three; under duckdb and turso, which lock the cache file per connection, a concurrent command waits out the watcher's current cycle instead of failing.
What each tool is for
Every result is a reference (path, metadata, excerpt), never file contents; prose enters context only when you Read it. Costs: map is fixed-size, a search row is tens of tokens, and a peek stays flat however large the note is. Which tool fits is a property of the question:
- A deterministic, factual answer over known fields (counts, filters, "which notes have X") is SQL:
sense sql, a saved{ sql }entry, orsearch --where. Enumerates every match; same result regardless of phrasing. - Locating notes about something is
search, one text through every engine the scope has: word match (bare words AND-join, one absent word = zero lexical rows; writea OR b OR cfor any-word), link-graph expansion, and vector similarity, fused into one ranked list. Readviaper row:matchrows contained your words;vector-only rows did not. Avector-only row means the search words don't appear in that note; it showed up because the model judged it semantically related. Vector rows are conceptual similarity, not typo-tolerance; false positives are expected, labeled, and bounded by--k, and they are the only rows a search can produce when note and query share no vocabulary at all, the paraphrase and category-for-instance cases words cannot reach. A scope searches with vectors when its preset'ssignalsincludevectors(on by default whenever the tree names anembedmodel); a preset that declares"signals": {"words": 1, "links": 1}searches on words and links only. A preset that asks for vectors when a local model path is missing its files is an error naming the fix, not a quieter result that would make the same search answer differently before and after. mapanswers "what is this tree" (fields, hub notes, recent changes) when the tree is unfamiliar.peek <path>prices a file before you pay for it: outline with[L143-162, ~380t]ranges and links both ways. Every list shows its first 20 with the true total; thesectionsandlinkstables hold the rest, so a peek costs a few hundred tokens on any note. Its link totals count distinct notes: "links out" dedupes written targets by resolved note and lists unresolved targets separately, whileCOUNT(*) FROM links WHERE src = ?counts every written target, resolved or not, so the raw count can read higher without either number being wrong.path <a> <b>walks the link graph for a chain connecting two notes, or reports none within the depth bound: it answers how they connect, not just that both exist.related <note>ranks notes near in meaning to one note that it does not already link to: the links it is missing. It reads the meaning-vectors, so it needs thevectorssignal on for the scope and scans them, costing about what a vectorsearchdoes, not what apeekdoes. The model named in the config fetches once per machine at the first vector search (progress on stderr;sense downloadprefetches where that timing matters). Amodelpointing at a local directory with missing files is an error naming it, forsearchandrelatedalike, not a quieter result.- When you know the file and need its contents,
Readit. sense adds nothing there. On large files peek's ranges let you read just one section; small files are often cheaper whole.
Output defaults to a table, built for humans; --format json returns the same rows machine-parseable, and --format csv returns one row per line for grep and awk. csv keeps every character a value holds, embedded newlines included, but cannot express NULL versus an empty string or a value's type, which json can. The commands that emit rows (sql, search, related, saved queries) take all three; map, peek, status, and path render a structure rather than a row set and take table or json. That also makes a saved query usable as a CI/hook gate with zero added mechanism: [ "$(sense <name> --format json)" = "[]" ] is true exactly when it returned no rows.
Three signals
search composes up to three engines; which ones run is the preset's signals (every signal whose prerequisites hold, by default). Each exists because it reads evidence the others cannot:
| signal | reads | uniquely finds | blind to |
|---|---|---|---|
words | the text itself (BM25) | every literal occurrence: identifiers, error strings, names, exact phrases; deterministic | anything phrased differently, e.g. a note saying "compensation floor" for the query "minimum pay" |
links | connections authors wrote | what the tree's own structure treats as related: context the matching text never restates | notes nobody linked |
vectors | per-chunk meaning-vectors | notes sharing no words with the query: paraphrase, the category for an instance, a concept restated | proving presence. A vector row cannot show the words occur anywhere; snippets can |
Each signal in the map carries a weight ({"words": 1, "links": 1, "vectors": 1} is the default when the key is omitted entirely); presence turns a signal on, and the number scales its share of the fused ranking. Equal weight (1 for everything) is what every number elsewhere in this doc describes. A weight above 1 pulls the fused ranking toward that signal's own ordering: measured on nfcorpus with the default static model and on MIRACL zh with an HTTP encoder, equal-weight fusion helps English nfcorpus but ranks the encoder's MIRACL-zh results below its own cosine-only ranking (benchmark/reports/2026-08-27-embedding-model-selection.md, weight-sweep table). There is no single weight that is right for both, so a preset that leans on a strong encoder is a candidate for a higher vectors weight, checked against that table rather than assumed.
Which signal carries a search is a property of the query, and the ends of the range are measured (BENCHMARKING.md, "Retrieval quality"): on the vocabulary-gap corpus, 31% of queries have no relevant lexical row in their top 10 and vector rows are the only recall; on a corpus whose queries quote their documents, words alone hit 99.7% and vectors add nothing. Real trees sit between. An exact identifier is words territory; "notes about X" where X could be phrased many ways is where vector rows carry; "how do these connect" is the link graph (path, peek).
Attribution is already per-row: one fused search shows which engines earned each hit (via), so reading the labels across a few queries is the cheapest way to learn which signals this tree rewards. To isolate a signal harder, declare presets differing only in signals and run the same text through both; the row diff is the excluded signal's contribution. The labels also matter downstream: relayed to a human, they distinguish "contains these words" from "related by meaning", which is the difference between a citation and a lead.
Commands
sense search "pricing OR billing OR invoicing" --where "f.status = 'active'" --k 10
sense search "sourcing quotes" --preset raw # a named settings bundle from the config
sense peek notes/pricing-model.md # a unique basename also works
sense path onboarding.md pricing-model.md # link chain between two notes, or none within the bound
sense related notes/pricing-model.md # notes similar by meaning it does not yet link to
sense map
sense sql "<statement>" [params...] # ad-hoc SQL; ? binds positional args, count-checked
sense <name> [params...] # a saved query from sense.config.json
sense --list | status | download- Terms pass verbatim to FTS5 MATCH. Bare words AND-join (one absent word means zero rows), so write
ORyourself when you want any-word matching; double-quote punctuated terms ("customer-facing","founder's"); invalid syntax is an error, not a rewrite. The same rules apply to search commands you write into subagent briefs. - When a search misses, both sides have levers. Lexical: OR-in synonyms and concrete instances (the index only knows the words in the files; a note about a specific tool rarely names its category), raise
--k(a row costs tens of tokens), widen the scope (--preset, or--includefor an ad-hoc glob). Vector: restate the concept in different words. Vectors rank the meaning of the whole query text, so a rephrase moves them even when every literal word still misses. Then pivot through the nearest hit withrelated <path>to walk its meaning-neighbourhood. Vector-only rows in a miss are themselves evidence:similarityand the snippet say whether the concept exists in the tree under other words. Each widening adds candidates and dilutes ranking, so the noise trade-off runs both ways. - A frontmatter query enumerates its matches deterministically; search ranks by term overlap, so results shift as phrasing shifts. Trade-off: a query needs a known field, search doesn't.
- The
viacolumn says what produced each row:match(words hit),link(connected to notes that hit),vector(near in meaning), and combinations. Thelinescolumn, when set, points at the section that earned the row (the best-matching chunk on vector rows, the term cluster's section on large lexical notes) and is a directReadrange; null means the whole note is the reference. A row'ssnippetsholds the marked passages that earned the lexical match ([]on a row that never contained the terms);--snippet-char-limit(default 80, the passage's rendered length, marks and ellipses counted) and--snippet-count-limit(default 1, passages per note) bound them, onsearchand a saved search alike. - Scope is one vocabulary shared by
search,map,peek,path, andrelated: bare command uses the config'sdefaultpreset;--preset <name>picks another (unknown names error, listing what's declared);--include <glob>and--exclude <glob>are ad-hoc globs for one command, each overriding its own side of the preset, so one does not clear the other;--no-excludedrops the preset'sexcludefor one command, the only way to widen past it without editing config (it widens the query scope, not the index: a file no preset covers is never indexed).--wheretakes any SQL condition against frontmatter aliasf, not only field equality:"f.status = 'active' AND has(f.tags, 'x')","datetime(f.created) >= datetime(?)". There is no whole-index flag: a broaddefaultpreset, or a declaredallpreset (include ["**/*"]), is the whole tree.sense statusshows every preset with its coverage.sqlscopes differently: it runs over the whole index by default, and--preset <name>binds the scope as a temporaryscope(path)table your statement joins, rather than filtering behind the query's back (JOIN scope ON scope."path" = f.path). Naming a preset without joiningscopeis a usage error, since it would return everything while reading as scoped. Without the flag, joinpreset_filesdirectly, which is the same coverage under a preset name you write into the SQL. scoreis a rank-fusion value: it ranks rows within one result set and is not comparable across queries, not a relevance magnitude. It encodes how many signals fired and at what rank, so a perfect lexical hit and a weak vector-only hit can read the same number. With vectors active, rows carrysimilarity: the cosine (-1 to 1) of the query against that file's best-matching chunk (the same chunk thelinesrange points at). It orders vector evidence within a result set; the range it spans depends on the corpus and the embedding model, and compresses on small trees, where even a nonsense query has a moderately near neighbour somewhere. Compare similarities within a result set rather than against a fixed cutoff carried between trees.- Vector rankings read the whole chunk, boilerplate included, so on a tree whose notes are mostly one template with a line of unique text (a directory of plugins, people, or assets), the shared scaffolding dominates every vector and
relatedreturns near-ties at the top of the range for any seed. Two signs, both visible in the output already: the topsimilarityvalues sit within a hair of each other, and the same few notes come back for unrelated seeds. Compare neighbour lists across two unlike seeds when a tree looks like this; matching lists mean the ranking is reading the template, not the content, andsearchover the distinguishing words is the answer instead. - Absence evidence lives in the labels: a preset whose
signalsexcludevectors(or a tree with noembedblock) returns 0 rows when the words are nowhere in it. Defaultsearchalways returns up tokrows (nearest-neighbour search has a nearest neighbour for any input), so a result of onlyvia: vectorrows IS the absence signal for the words themselves;similarityand the snippet are the evidence for judging whether a vector row is a real conceptual hit. - A
queriesentry names the verb it runs, mirroring the two commands:"dead-links": { "sql": "SELECT src, target FROM links WHERE dst IS NULL AND lower(target) NOT GLOB '*.[a-z0-9]*'" }runs assense dead-links, and"hot": { "search": "pricing OR billing", "preset": "raw", "k": 20 }runs assense hotwith its settings baked in, so repeat runs need no flags. An invocation-level--preset,--k, or--whereoverrides a saved search's value;--listlabels each entry(sql)or(search). - Running an entry is how it is validated: a typo'd column, stale SQL, bad FTS5 syntax or an unknown preset errors and exits nonzero. A parameterised entry validates with any argument, since SQL is prepared before parameters bind (
sense by-tag zzzreportsno such columnif the column is wrong,(0 rows)if it is right). To sweep a whole config after editing it, read the exit code: 0 ran, 2 means it needs parameters (re-run it with any argument to validate the SQL), anything else is broken.
for q in $(sense --list | awk '{print $1}'); do
sense "$q" --format json >/dev/null 2>&1
case $? in 0) ;; 2) echo "needs an argument: $q" ;; *) echo "broken: $q" ;; esac
doneWhether an empty result is good or bad is the reader's judgment: a dead-link query returning rows means broken citations to fix.
SQL
The commands are shorthands over those tables; anything they don't express, SQL does.
sense sql "SELECT name FROM pragma_table_info('frontmatter')" # what fields exist
sense sql 'SELECT path FROM frontmatter WHERE "plugin-id" IS NOT NULL' # punctuated keys need double quotes: unquoted, plugin-id reads as subtraction
sense sql "SELECT DISTINCT status FROM frontmatter" # what values a field takes
sense sql "SELECT src FROM links WHERE dst = ?" notes/pricing-model.md # backlinks
sense sql "SELECT f.path FROM frontmatter f JOIN scope ON scope.path = f.path" --preset default # scope SQL to a preset
sense sql "SELECT f.path FROM frontmatter f JOIN preset_files p ON p.path = f.path AND p.preset = 'default'" # the same, preset named in the SQL
sense sql "SELECT path FROM frontmatter WHERE path NOT IN (SELECT dst FROM links WHERE dst IS NOT NULL) AND path NOT IN (SELECT src FROM links)" # linked neither way (fine if intentional; linking is optional)
sense sql "SELECT src, target FROM links WHERE dst IS NULL AND lower(target) NOT GLOB '*.[a-z0-9]*'" # broken wikilinks, attachments excluded
sense sql "SELECT heading, start_line, tokens FROM sections WHERE path = ?" a.md # budget a read
sense sql "SELECT j.value, COUNT(*) n FROM frontmatter, json_each(frontmatter.tags) j GROUP BY j.value ORDER BY n DESC" # count per array memberpath covers the route between two notes, and search's via: link rows are the ranked neighborhood around a query; a structural k-hop walk is a bounded WITH RECURSIVE over links. Bound the depth: an unbounded walk on a densely linked tree enumerates paths exponentially. Pass these through sense sql "<sql>" <seed> or save as { "sql": "..." }.
-- notes within 2 hops of a seed, links both ways (UNION dedups, so it terminates)
WITH RECURSIVE hop(path, d) AS (
SELECT ?, 0
UNION
SELECT CASE WHEN l.src = hop.path THEN l.dst ELSE l.src END, hop.d + 1
FROM hop JOIN links l ON (l.src = hop.path OR l.dst = hop.path) AND l.dst IS NOT NULL
WHERE hop.d < 2
)
SELECT DISTINCT path FROM hop WHERE d > 0;
-- notes cited alongside a seed: they share a note that links to both (co-citation)
SELECT DISTINCT b.dst FROM links a JOIN links b ON a.src = b.src
WHERE a.dst = ? AND b.dst IS NOT NULL AND b.dst <> a.dst;- A saved
{ sql }written againstscopeis preset-agnostic:sense <name> --preset rawre-points the same statement at another layer, so one entry serves every preset instead of one copy each. content MATCHonly works against the fts5 table by its own name, never through an alias or a view:FROM content c ... WHERE c MATCH 'x'fails withno such column: c. This is why--presetbinds a table to join rather than shadowing the tables.content MATCHtakes FTS5 syntax:a OR b,"phrase",pref*,NEAR(a b, 5),summary: term. Stemmed; markdown stripped at index time. Double-quote any term with punctuation. Barecustomer-facingerrors (-reads as a column filter), bare apostrophes are syntax errors: write"customer-facing","founder's". This is the default sqlite store's grammar: underduckdbandtursothe operator forms (a OR b,pref*,NEAR,^, column filters) are a named error naming the rephrase, andMATCHitself does not run (see Stores).- A language written without word spaces (Chinese, Japanese, Thai, Khmer, Lao, Burmese) is indexed per grapheme and searched as an ordered grapheme phrase against the
_segsidecar columns: substring semantics, whatgrepgives, a query matches wherever its exact text occurs, including inside a longer run (京都matches东京都政府, correctly, because it's there at position 2), and needs no minimum length. No decision is needed for these languages. Hand-written SQL is not rewritten for you, so a rawcontent MATCH '数据库'finds nothing: writecontent MATCH segment(?)and bind the terms.segment()returns text with no such run unchanged, so it is safe to leave in a query whatever the tree's language. - Rank with
ORDER BY bm25(content, 10.0, 5.0, 1.0)(title > summary > body); the full formbm25(content, 10.0, 5.0, 1.0, 0, 10.0, 5.0, 1.0)mirrors the same weights onto the_segsidecars, so a title hit found throughtitle_segranks like one found throughtitle(the three-weight form still runs, FTS5 defaults unnamed columns to 1.0, but ranks a sidecar match at body weight). Excerpt withsnippet(content, 2, '«', '»', '…', 10), naming thetextcolumn explicitly:-1means best column, which can surface a machine-spaced sidecar as the excerpt (searchitself always names column 2). snippet() re-tokenizes each matched doc and its cost grows superlinearly with doc size, measured ~10 s per query on a tree holding one 1 MB note.searchdoes not use it at all any more, computing every excerpt in JS instead; in hand-written SQL, guard it:CASE WHEN length(text) <= 16384 THEN snippet(...) END, or selecttitle/summaryinstead of an excerpt. - Select
content.title/content.summary(always exist, empty when absent) rather thanf.title/f.summary(discovered columns; error on trees that never declare them). - Frontmatter values keep their YAML type: strings are TEXT, whole numbers and booleans are INTEGER (
truestores as 1, soWHERE flag = 1matches andWHERE flag = 'true'matches nothing), fractions are REAL, lists and maps are JSON text. On theduckdbstore the discovered columns are VARIANT: homogeneous keys, which are nearly all of them, compare identically, but a numeric predicate against a key that holds numbers in some notes and text in others raises a comparison error where sqlite orders by storage class silently.mapprints the observed type per field, and a field showing two types (integer,text) has drifted across notes. A list key written with no items (tags:above a bare-) is a list holding one null, stored as the JSON text[null]:IS NULLdoes not find it (the column holds a string),json_eachyields one empty member per such row, andmapcounts it as covered because the key is present.has(tags, 'x')reads it correctly as no match. To separate written-but-empty from absent, compare against the text:WHERE tags = '[null]'. - Dead links need the attachment filter.
dst IS NULLalone is not "broken link": a wikilink to anything that is not markdown ([[Board.base]],![[Pasted image.png]],[[spec.pdf]]) can never resolve, because sense indexes markdown and resolution only tries the exact path or+.md. Those are out of the index's universe, not broken. On a 1,400-note Obsidian vault the unfiltered query returns 143 rows where 14 are real. Exclude anything carrying a file extension, as in the recipe above, and widen the exclusion if your notes have dotted titles ([[Node.js]]carries one too, so a stricter list,'*.png','*.pdf','*.base', and whatever else your vault attaches, is safer on a tree whose titles use dots). Scope it withpreset_filesas well: template and skill files are full of[[Note Name]]examples that are deliberately unresolved. has(field, value): array membership on JSON-array fields, substring on strings, false on NULL. This is theincludes()convention. Substring meanshas(f.status, 'active')also matchesinactive; exact scalar match isf.status = ?, deliberate substring isLIKE, exact array membership isEXISTS (SELECT 1 FROM json_each(f.tags) WHERE value = ?). To aggregate per member instead, usejson_each(frontmatter.<field>)(above). GROUP BY on the raw column splits["a","b"]and["b","a"]into separate buckets.- Compare dates through
datetime(), which resolves ISO 8601 offsets to UTC:WHERE datetime(created) >= datetime(?). Bare string comparison is only safe when every note uses the same offset. - Date spellings SQLite rejects (
-0800,-08, a space separator) are normalized at index time, offset preserved. One it cannot fix is left as written and warned about by path:datetime()returns NULL there, so the row is invisible to a date comparison rather than excluded by it. List them withWHERE d IS NOT NULL AND datetime(d) IS NULL. - SQLite's `now` is UTC, so any query about "today" needs `'localtime'`.
date('now')reads as tomorrow from mid-afternoon onward in the Americas, which silently flips "scheduled today" into "overdue" every evening: writedate('now','localtime')anddatetime('now','start of day','localtime'). This only matters where the boundary carries the meaning; a'-90 day'window is unaffected by a few hours of skew. - To bound what a query puts into context:
snippet()excerpts just the matching text,LIMITcaps row counts, and selectingpath/title/summarykeeps rows small. A large result can also stay out of context entirely:--format csv > filewrites it whole, andgrep/awkover that file returns only the rows wanted.SELECT text FROM contentreturns the tree's entire prose (sense warns past 50 KB, after the rows have already printed, so the warning records the cost rather than preventing it). Aggregates (COUNT,GROUP BY) are already bounded.SELECT * FROM frontmatteris always safe: prose is not a frontmatter column.
Worked traces: EXAMPLES.md.
Setup and upkeep
- Missing CLI:
npm install -g sensemaking. Missing config:sense initat the tree root. Discovery walks up from cwd;--config <path>overrides. Setting up or restructuring a tree (presets, frontmatter conventions, note design) is thesense-setupskill. Translating an Obsidian Bases.basefile into equivalent queries is thesense-basesskill. mapandstatusreport each preset's coverage (files matched, embedded count). Indexing derives from presets, so the coverage numbers are how you see what a config actually indexes and embeds. A scope with fewer signals just uses fewer (a preset without the vectors signal searches lexically); a saved search naming an unknown preset errors when run, listing the declared ones.- Save a query into
sense.config.jsononly when it will be reused; run ad-hoc otherwise. - A one-line
summary:per note is optional and pays twice: it appears in result rows and is a weighted search field. Date comparisons work for dates written as ISO 8601 (2026-08-12, or with time and offset); other formats do not compare. Field names in examples (status,tags,created) are illustrative; your tree defines its own. - Reserved frontmatter keys (dropped with a warning):
path,_mtime,_ctime,_size,_rank,_parse_error,content,links,sections. Thetagsfrontmatter column and thetagstable coexist, mirroring Obsidian's own split: the column is the raw YAML list one note's frontmatter declares (Obsidian'stagsproperty), the table is the merged, deduplicated frontmatter+inline set per note (what Obsidian's tag pane and Bases'file.tagsread). "What is tagged X" is a table query; the column answers only what a note's frontmatter literally says. Inline tags inside%%...%%comments are indexed, and some trees run their whole maintenance-tag system in comments. - A note whose frontmatter does not parse is indexed with no frontmatter columns and
_parse_errorset to the YAML message, which carries the line. Nothing is half-recovered: a non-NULL value is a value the author wrote. So a NULL column means the key was absent or the note did not parse, and_parse_erroris how you tell:WHERE status IS NULL AND _parse_error IS NULLis "genuinely missing status". List what needs fixing withsense sql "SELECT path, _parse_error FROM frontmatter WHERE _parse_error IS NOT NULL"; fixing a file clears it on the next command.sense statusreports the count. - Exit codes:
0ok,1error (store message verbatim),2usage (unknown query, wrong param count). - Doubted cache: delete the directory
sense statusprints on itscache:line. Rarely needed; every query reconciles first.

