sense

Installation
SKILL.md

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 (STORE_CAPABILITY_MISSING) 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 (STORE_CAPABILITY_MISSING) 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, or search --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; write a OR b OR c for any-word), link-graph expansion, and vector similarity, fused into one ranked list. Read via per row: match rows contained your words; vector-only rows did not. A vector-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's signals include vectors (on by default whenever the tree names an embed model); 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.
  • map answers "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; the sections and links tables 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, while COUNT(*) 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 the vectors signal on for the scope and scans them, costing about what a vector search does, not what a peek does. The model named in the config fetches once per machine at the first vector search (progress on stderr; sense download prefetches where that timing matters). A model pointing at a local directory with missing files is an error naming it, for search and related alike, not a quieter result.
  • When you know the file and need its contents, Read it. 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.

Installs
54
First Seen
Aug 8, 2026
sense — kmalakoff/sensemaking