sensemaking 0.13.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/cjs/cli/shared.js +4 -2
  2. package/dist/cjs/cli/shared.js.map +1 -1
  3. package/dist/cjs/column-hint.d.cts +2 -0
  4. package/dist/cjs/column-hint.d.ts +2 -0
  5. package/dist/cjs/column-hint.js +104 -0
  6. package/dist/cjs/column-hint.js.map +1 -0
  7. package/dist/cjs/commands/scope.js +7 -1
  8. package/dist/cjs/commands/scope.js.map +1 -1
  9. package/dist/cjs/db/open.d.cts +1 -1
  10. package/dist/cjs/db/open.d.ts +1 -1
  11. package/dist/cjs/db/open.js +1 -1
  12. package/dist/cjs/db/open.js.map +1 -1
  13. package/dist/cjs/features/embed.js +4 -6
  14. package/dist/cjs/features/embed.js.map +1 -1
  15. package/dist/cjs/features/links.js +4 -2
  16. package/dist/cjs/features/links.js.map +1 -1
  17. package/dist/cjs/features/sections.js +4 -6
  18. package/dist/cjs/features/sections.js.map +1 -1
  19. package/dist/cjs/features/tags.js +172 -9
  20. package/dist/cjs/features/tags.js.map +1 -1
  21. package/dist/cjs/fences.d.cts +5 -0
  22. package/dist/cjs/fences.d.ts +5 -0
  23. package/dist/cjs/fences.js +54 -0
  24. package/dist/cjs/fences.js.map +1 -0
  25. package/dist/esm/cli/shared.js +4 -2
  26. package/dist/esm/cli/shared.js.map +1 -1
  27. package/dist/esm/column-hint.d.ts +2 -0
  28. package/dist/esm/column-hint.js +32 -0
  29. package/dist/esm/column-hint.js.map +1 -0
  30. package/dist/esm/commands/scope.js +7 -1
  31. package/dist/esm/commands/scope.js.map +1 -1
  32. package/dist/esm/db/open.d.ts +1 -1
  33. package/dist/esm/db/open.js +1 -1
  34. package/dist/esm/db/open.js.map +1 -1
  35. package/dist/esm/features/embed.js +4 -6
  36. package/dist/esm/features/embed.js.map +1 -1
  37. package/dist/esm/features/links.js +4 -2
  38. package/dist/esm/features/links.js.map +1 -1
  39. package/dist/esm/features/sections.js +4 -6
  40. package/dist/esm/features/sections.js.map +1 -1
  41. package/dist/esm/features/tags.js +167 -8
  42. package/dist/esm/features/tags.js.map +1 -1
  43. package/dist/esm/fences.d.ts +5 -0
  44. package/dist/esm/fences.js +43 -0
  45. package/dist/esm/fences.js.map +1 -0
  46. package/package.json +1 -1
  47. package/skills/sense/SKILL.md +2 -1
  48. package/skills/sense-setup/SKILL.md +1 -1
@@ -1,14 +1,12 @@
1
+ import { fenceTracker } from '../fences.js';
1
2
  // Headings outside fenced code blocks.
2
3
  function extract(raw) {
3
4
  const lines = raw.split('\n');
4
5
  const found = [];
5
- let inFence = false;
6
+ const fence = fenceTracker();
6
7
  for(let i = 0; i < lines.length; i++){
7
- if (/^(```|~~~)/.test(lines[i])) {
8
- inFence = !inFence;
9
- continue;
10
- }
11
- if (inFence) continue;
8
+ if (fence.feed(lines[i])) continue;
9
+ if (fence.inFence) continue;
12
10
  const m = lines[i].match(/^(#{1,6}) +(.*)/);
13
11
  if (m) found.push({
14
12
  level: m[1].length,
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/sections.ts"],"sourcesContent":["import type { Feature } from './types.ts';\n\n// sections(path, idx, level, heading, start_line, end_line, tokens): the heading outline,\n// 1-indexed over the raw file so a row is a direct Read range; tokens is a chars/4 estimate.\n\nexport interface Section {\n level: number;\n heading: string;\n startLine: number;\n endLine: number;\n tokens: number;\n}\n\n// Headings outside fenced code blocks.\nfunction extract(raw: string): Section[] {\n const lines = raw.split('\\n');\n const found: Section[] = [];\n let inFence = false;\n for (let i = 0; i < lines.length; i++) {\n if (/^(```|~~~)/.test(lines[i])) {\n inFence = !inFence;\n continue;\n }\n if (inFence) continue;\n const m = lines[i].match(/^(#{1,6}) +(.*)/);\n if (m) found.push({ level: m[1].length, heading: m[2].trim(), startLine: i + 1, endLine: lines.length, tokens: 0 });\n }\n for (let s = 0; s < found.length; s++) {\n if (s + 1 < found.length) found[s].endLine = found[s + 1].startLine - 1;\n const chars = lines.slice(found[s].startLine - 1, found[s].endLine).join('\\n').length;\n found[s].tokens = Math.ceil(chars / 4);\n }\n return found;\n}\n\nexport const sections: Feature = {\n name: 'sections',\n schema(db) {\n db.exec(`CREATE TABLE IF NOT EXISTS sections (\"path\" TEXT, idx INTEGER, level INTEGER, heading TEXT, start_line INTEGER, end_line INTEGER, tokens INTEGER, PRIMARY KEY (\"path\", idx))`);\n },\n extract,\n remove(db, path) {\n db.prepare('DELETE FROM sections WHERE \"path\" = ?').run(path);\n },\n store(db, path, extracted) {\n const insert = db.prepare('INSERT INTO sections (\"path\", idx, level, heading, start_line, end_line, tokens) VALUES (?, ?, ?, ?, ?, ?, ?)');\n (extracted as Section[]).forEach((s, idx) => insert.run(path, idx, s.level, s.heading, s.startLine, s.endLine, s.tokens));\n },\n};\n"],"names":["extract","raw","lines","split","found","inFence","i","length","test","m","match","push","level","heading","trim","startLine","endLine","tokens","s","chars","slice","join","Math","ceil","sections","name","schema","db","exec","remove","path","prepare","run","store","extracted","insert","forEach","idx"],"mappings":"AAaA,uCAAuC;AACvC,SAASA,QAAQC,GAAW;IAC1B,MAAMC,QAAQD,IAAIE,KAAK,CAAC;IACxB,MAAMC,QAAmB,EAAE;IAC3B,IAAIC,UAAU;IACd,IAAK,IAAIC,IAAI,GAAGA,IAAIJ,MAAMK,MAAM,EAAED,IAAK;QACrC,IAAI,aAAaE,IAAI,CAACN,KAAK,CAACI,EAAE,GAAG;YAC/BD,UAAU,CAACA;YACX;QACF;QACA,IAAIA,SAAS;QACb,MAAMI,IAAIP,KAAK,CAACI,EAAE,CAACI,KAAK,CAAC;QACzB,IAAID,GAAGL,MAAMO,IAAI,CAAC;YAAEC,OAAOH,CAAC,CAAC,EAAE,CAACF,MAAM;YAAEM,SAASJ,CAAC,CAAC,EAAE,CAACK,IAAI;YAAIC,WAAWT,IAAI;YAAGU,SAASd,MAAMK,MAAM;YAAEU,QAAQ;QAAE;IACnH;IACA,IAAK,IAAIC,IAAI,GAAGA,IAAId,MAAMG,MAAM,EAAEW,IAAK;QACrC,IAAIA,IAAI,IAAId,MAAMG,MAAM,EAAEH,KAAK,CAACc,EAAE,CAACF,OAAO,GAAGZ,KAAK,CAACc,IAAI,EAAE,CAACH,SAAS,GAAG;QACtE,MAAMI,QAAQjB,MAAMkB,KAAK,CAAChB,KAAK,CAACc,EAAE,CAACH,SAAS,GAAG,GAAGX,KAAK,CAACc,EAAE,CAACF,OAAO,EAAEK,IAAI,CAAC,MAAMd,MAAM;QACrFH,KAAK,CAACc,EAAE,CAACD,MAAM,GAAGK,KAAKC,IAAI,CAACJ,QAAQ;IACtC;IACA,OAAOf;AACT;AAEA,OAAO,MAAMoB,WAAoB;IAC/BC,MAAM;IACNC,QAAOC,EAAE;QACPA,GAAGC,IAAI,CAAC,CAAC,4KAA4K,CAAC;IACxL;IACA5B;IACA6B,QAAOF,EAAE,EAAEG,IAAI;QACbH,GAAGI,OAAO,CAAC,yCAAyCC,GAAG,CAACF;IAC1D;IACAG,OAAMN,EAAE,EAAEG,IAAI,EAAEI,SAAS;QACvB,MAAMC,SAASR,GAAGI,OAAO,CAAC;QACzBG,UAAwBE,OAAO,CAAC,CAAClB,GAAGmB,MAAQF,OAAOH,GAAG,CAACF,MAAMO,KAAKnB,EAAEN,KAAK,EAAEM,EAAEL,OAAO,EAAEK,EAAEH,SAAS,EAAEG,EAAEF,OAAO,EAAEE,EAAED,MAAM;IACzH;AACF,EAAE"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/sections.ts"],"sourcesContent":["import { fenceTracker } from '../fences.ts';\nimport type { Feature } from './types.ts';\n\n// sections(path, idx, level, heading, start_line, end_line, tokens): the heading outline,\n// 1-indexed over the raw file so a row is a direct Read range; tokens is a chars/4 estimate.\n\nexport interface Section {\n level: number;\n heading: string;\n startLine: number;\n endLine: number;\n tokens: number;\n}\n\n// Headings outside fenced code blocks.\nfunction extract(raw: string): Section[] {\n const lines = raw.split('\\n');\n const found: Section[] = [];\n const fence = fenceTracker();\n for (let i = 0; i < lines.length; i++) {\n if (fence.feed(lines[i])) continue;\n if (fence.inFence) continue;\n const m = lines[i].match(/^(#{1,6}) +(.*)/);\n if (m) found.push({ level: m[1].length, heading: m[2].trim(), startLine: i + 1, endLine: lines.length, tokens: 0 });\n }\n for (let s = 0; s < found.length; s++) {\n if (s + 1 < found.length) found[s].endLine = found[s + 1].startLine - 1;\n const chars = lines.slice(found[s].startLine - 1, found[s].endLine).join('\\n').length;\n found[s].tokens = Math.ceil(chars / 4);\n }\n return found;\n}\n\nexport const sections: Feature = {\n name: 'sections',\n schema(db) {\n db.exec(`CREATE TABLE IF NOT EXISTS sections (\"path\" TEXT, idx INTEGER, level INTEGER, heading TEXT, start_line INTEGER, end_line INTEGER, tokens INTEGER, PRIMARY KEY (\"path\", idx))`);\n },\n extract,\n remove(db, path) {\n db.prepare('DELETE FROM sections WHERE \"path\" = ?').run(path);\n },\n store(db, path, extracted) {\n const insert = db.prepare('INSERT INTO sections (\"path\", idx, level, heading, start_line, end_line, tokens) VALUES (?, ?, ?, ?, ?, ?, ?)');\n (extracted as Section[]).forEach((s, idx) => insert.run(path, idx, s.level, s.heading, s.startLine, s.endLine, s.tokens));\n },\n};\n"],"names":["fenceTracker","extract","raw","lines","split","found","fence","i","length","feed","inFence","m","match","push","level","heading","trim","startLine","endLine","tokens","s","chars","slice","join","Math","ceil","sections","name","schema","db","exec","remove","path","prepare","run","store","extracted","insert","forEach","idx"],"mappings":"AAAA,SAASA,YAAY,QAAQ,eAAe;AAc5C,uCAAuC;AACvC,SAASC,QAAQC,GAAW;IAC1B,MAAMC,QAAQD,IAAIE,KAAK,CAAC;IACxB,MAAMC,QAAmB,EAAE;IAC3B,MAAMC,QAAQN;IACd,IAAK,IAAIO,IAAI,GAAGA,IAAIJ,MAAMK,MAAM,EAAED,IAAK;QACrC,IAAID,MAAMG,IAAI,CAACN,KAAK,CAACI,EAAE,GAAG;QAC1B,IAAID,MAAMI,OAAO,EAAE;QACnB,MAAMC,IAAIR,KAAK,CAACI,EAAE,CAACK,KAAK,CAAC;QACzB,IAAID,GAAGN,MAAMQ,IAAI,CAAC;YAAEC,OAAOH,CAAC,CAAC,EAAE,CAACH,MAAM;YAAEO,SAASJ,CAAC,CAAC,EAAE,CAACK,IAAI;YAAIC,WAAWV,IAAI;YAAGW,SAASf,MAAMK,MAAM;YAAEW,QAAQ;QAAE;IACnH;IACA,IAAK,IAAIC,IAAI,GAAGA,IAAIf,MAAMG,MAAM,EAAEY,IAAK;QACrC,IAAIA,IAAI,IAAIf,MAAMG,MAAM,EAAEH,KAAK,CAACe,EAAE,CAACF,OAAO,GAAGb,KAAK,CAACe,IAAI,EAAE,CAACH,SAAS,GAAG;QACtE,MAAMI,QAAQlB,MAAMmB,KAAK,CAACjB,KAAK,CAACe,EAAE,CAACH,SAAS,GAAG,GAAGZ,KAAK,CAACe,EAAE,CAACF,OAAO,EAAEK,IAAI,CAAC,MAAMf,MAAM;QACrFH,KAAK,CAACe,EAAE,CAACD,MAAM,GAAGK,KAAKC,IAAI,CAACJ,QAAQ;IACtC;IACA,OAAOhB;AACT;AAEA,OAAO,MAAMqB,WAAoB;IAC/BC,MAAM;IACNC,QAAOC,EAAE;QACPA,GAAGC,IAAI,CAAC,CAAC,4KAA4K,CAAC;IACxL;IACA7B;IACA8B,QAAOF,EAAE,EAAEG,IAAI;QACbH,GAAGI,OAAO,CAAC,yCAAyCC,GAAG,CAACF;IAC1D;IACAG,OAAMN,EAAE,EAAEG,IAAI,EAAEI,SAAS;QACvB,MAAMC,SAASR,GAAGI,OAAO,CAAC;QACzBG,UAAwBE,OAAO,CAAC,CAAClB,GAAGmB,MAAQF,OAAOH,GAAG,CAACF,MAAMO,KAAKnB,EAAEN,KAAK,EAAEM,EAAEL,OAAO,EAAEK,EAAEH,SAAS,EAAEG,EAAEF,OAAO,EAAEE,EAAED,MAAM;IACzH;AACF,EAAE"}
@@ -1,10 +1,92 @@
1
+ import { fenceTracker } from '../fences.js';
1
2
  // tags(path, tag): Obsidian's file.tags grain -- frontmatter list/string tags plus inline
2
3
  // #tags from the prose, deduplicated, source not distinguished. Nested tags store full
3
4
  // (book/scifi); `tag = 'book' OR tag LIKE 'book/%'` is how a caller matches the parent too.
4
- const FENCE_RE = /^(```|~~~)/;
5
- const INLINE_CODE_RE = /`[^`]*`/g;
5
+ // Obsidian treats [[#Heading]] as a same-note link, not a tag.
6
+ const WIKILINK_RE = /\[\[.*?\]\]/g; // to the first ]], so a heading holding a lone ] still masks
7
+ // Obsidian doesn't read tags inside HTML markup.
8
+ const HTML_TAG_RE = /<\/?[a-zA-Z][^>]*>/g; // tag-shaped only: a comparison's `< 5` must not open a span
6
9
  // Anchors on start-of-line or a preceding whitespace/(/[ so `a#b` and URL fragments don't count.
7
10
  const INLINE_TAG_RE = /(?:^|[\s([])#([\p{L}\p{N}_/-]+)/gu;
11
+ // A markdown link destination `](...)` -- `[text](#anchor)` is a same-page link, not a tag.
12
+ const LINK_DEST_RE = /\]\((?:[^()]|\([^()]*\))*\)/g; // one paren-nesting level, as CommonMark destinations allow: (https://x/a_(b)#frag)
13
+ // CommonMark's HTML-block type-6 list (fixed by the spec, not a drifting enumeration): a line
14
+ // starting with an open or close tag of one of these, at column 0, opens a block that swallows
15
+ // following lines -- including any #tag in them -- until a blank line closes it.
16
+ const HTML_BLOCK_TAGS = new Set([
17
+ 'address',
18
+ 'article',
19
+ 'aside',
20
+ 'base',
21
+ 'basefont',
22
+ 'blockquote',
23
+ 'body',
24
+ 'caption',
25
+ 'center',
26
+ 'col',
27
+ 'colgroup',
28
+ 'dd',
29
+ 'details',
30
+ 'dialog',
31
+ 'dir',
32
+ 'div',
33
+ 'dl',
34
+ 'dt',
35
+ 'fieldset',
36
+ 'figcaption',
37
+ 'figure',
38
+ 'footer',
39
+ 'form',
40
+ 'frame',
41
+ 'frameset',
42
+ 'h1',
43
+ 'h2',
44
+ 'h3',
45
+ 'h4',
46
+ 'h5',
47
+ 'h6',
48
+ 'head',
49
+ 'header',
50
+ 'hr',
51
+ 'html',
52
+ 'iframe',
53
+ 'legend',
54
+ 'li',
55
+ 'link',
56
+ 'main',
57
+ 'menu',
58
+ 'menuitem',
59
+ 'nav',
60
+ 'noframes',
61
+ 'ol',
62
+ 'optgroup',
63
+ 'option',
64
+ 'p',
65
+ 'param',
66
+ 'search',
67
+ 'section',
68
+ 'summary',
69
+ 'table',
70
+ 'tbody',
71
+ 'td',
72
+ 'tfoot',
73
+ 'th',
74
+ 'thead',
75
+ 'title',
76
+ 'tr',
77
+ 'track',
78
+ 'ul'
79
+ ]);
80
+ // Type-1 blocks (script/pre/style/textarea): closes on the line holding the matching end tag,
81
+ // not on a blank line, and that line is the last one skipped.
82
+ const HTML_PRE_TAGS = new Set([
83
+ 'script',
84
+ 'pre',
85
+ 'style',
86
+ 'textarea'
87
+ ]);
88
+ // An opening or closing tag at column 0, tag name captured for the lookups above.
89
+ const HTML_BLOCK_OPEN_RE = /^<\/?([a-zA-Z][a-zA-Z0-9]*)(?:[ \t]|\/?>|$)/;
8
90
  // Strips a leading # (frontmatter entries may carry one) and a trailing /; rejects an
9
91
  // all-digit result -- a tag needs at least one non-digit character.
10
92
  function normalizeTag(raw) {
@@ -27,18 +109,95 @@ function frontmatterTags(data) {
27
109
  }
28
110
  return found;
29
111
  }
30
- // #tag tokens outside fenced code blocks and inline code spans.
112
+ // A code span opens on a run of N backticks and closes at the next run of exactly N -- a
113
+ // shorter or longer run in between is literal text, not a delimiter (CommonMark code spans).
114
+ // Masked with spaces so column positions and tag-boundary whitespace are unaffected.
115
+ function maskCodeSpans(line) {
116
+ let out = '';
117
+ let i = 0;
118
+ while(i < line.length){
119
+ if (line[i] !== '`') {
120
+ out += line[i];
121
+ i++;
122
+ continue;
123
+ }
124
+ let j = i;
125
+ while(line[j] === '`')j++;
126
+ const n = j - i;
127
+ let k = j;
128
+ let closeStart = -1;
129
+ let closeEnd = -1;
130
+ while(k < line.length){
131
+ if (line[k] !== '`') {
132
+ k++;
133
+ continue;
134
+ }
135
+ let m = k;
136
+ while(line[m] === '`')m++;
137
+ if (m - k === n) {
138
+ closeStart = k;
139
+ closeEnd = m;
140
+ break;
141
+ }
142
+ k = m;
143
+ }
144
+ if (closeStart >= 0) {
145
+ out += ' '.repeat(closeEnd - i);
146
+ i = closeEnd;
147
+ } else {
148
+ out += line.slice(i, j);
149
+ i = j;
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+ // #tag tokens outside fenced code blocks, inline code spans, wikilinks, HTML tags, HTML blocks,
155
+ // and link destinations.
31
156
  function inlineTags(body) {
32
157
  const found = [];
33
- let inFence = false;
158
+ const fence = fenceTracker();
159
+ let inHtmlBlock = false;
160
+ let htmlBlockClose = null; // set while inside a type-1 (script/pre/style/textarea) block
34
161
  for (const line of body.split('\n')){
35
- if (FENCE_RE.test(line)) {
36
- inFence = !inFence;
162
+ if (inHtmlBlock) {
163
+ // A fence-like line here is still HTML-block content -- the block wins until it closes.
164
+ if (htmlBlockClose) {
165
+ if (htmlBlockClose.test(line)) {
166
+ inHtmlBlock = false;
167
+ htmlBlockClose = null;
168
+ }
169
+ } else if (/^[ \t>]*$/.test(line)) {
170
+ inHtmlBlock = false;
171
+ }
37
172
  continue;
38
173
  }
39
- if (inFence) continue;
174
+ if (fence.feed(line)) continue;
175
+ if (fence.inFence) continue;
176
+ // Indented or blockquoted HTML blocks still swallow their content in Obsidian, so the
177
+ // opener test runs after stripping leading whitespace and > markers.
178
+ const stripped = line.replace(/^[ \t>]*/, '');
179
+ if (stripped[0] === '<') {
180
+ const openMatch = HTML_BLOCK_OPEN_RE.exec(stripped);
181
+ if (openMatch) {
182
+ const tagName = openMatch[1].toLowerCase();
183
+ const isClosingTag = stripped[1] === '/';
184
+ if (!isClosingTag && HTML_PRE_TAGS.has(tagName)) {
185
+ inHtmlBlock = true;
186
+ // Any of the four type-1 closers ends the block, not only the tag that opened it.
187
+ htmlBlockClose = /<\/(?:script|pre|style|textarea)>/i;
188
+ continue;
189
+ }
190
+ if (HTML_BLOCK_TAGS.has(tagName)) {
191
+ inHtmlBlock = true;
192
+ continue;
193
+ }
194
+ }
195
+ }
40
196
  if (!line.includes('#')) continue; // most lines; skip the regex work
41
- const cleaned = line.includes('`') ? line.replace(INLINE_CODE_RE, (m)=>' '.repeat(m.length)) : line;
197
+ let cleaned = line.includes('`') ? maskCodeSpans(line) : line;
198
+ if (cleaned.includes('[[')) cleaned = cleaned.replace(WIKILINK_RE, (m)=>' '.repeat(m.length));
199
+ if (cleaned.includes('](')) cleaned = cleaned.replace(LINK_DEST_RE, (m)=>' '.repeat(m.length));
200
+ if (cleaned.includes('<')) cleaned = cleaned.replace(HTML_TAG_RE, (m)=>' '.repeat(m.length));
42
201
  for (const m of cleaned.matchAll(INLINE_TAG_RE)){
43
202
  const tag = normalizeTag(m[1]);
44
203
  if (tag) found.push(tag);
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/tags.ts"],"sourcesContent":["import type { Feature } from './types.ts';\n\n// tags(path, tag): Obsidian's file.tags grain -- frontmatter list/string tags plus inline\n// #tags from the prose, deduplicated, source not distinguished. Nested tags store full\n// (book/scifi); `tag = 'book' OR tag LIKE 'book/%'` is how a caller matches the parent too.\n\nconst FENCE_RE = /^(```|~~~)/;\nconst INLINE_CODE_RE = /`[^`]*`/g;\n// Anchors on start-of-line or a preceding whitespace/(/[ so `a#b` and URL fragments don't count.\nconst INLINE_TAG_RE = /(?:^|[\\s([])#([\\p{L}\\p{N}_/-]+)/gu;\n\n// Strips a leading # (frontmatter entries may carry one) and a trailing /; rejects an\n// all-digit result -- a tag needs at least one non-digit character.\nfunction normalizeTag(raw: string): string | null {\n const stripped = raw.replace(/^#/, '').replace(/\\/+$/, '');\n if (!stripped || /^\\d+$/.test(stripped)) return null;\n return stripped;\n}\n\n// data.tags: a YAML list (Obsidian also accepts a bare string). Null members and non-string\n// members are skipped rather than throwing -- `tags:\\n -` parses to [null].\nfunction frontmatterTags(data?: Record<string, unknown>): string[] {\n const raw = data?.tags;\n const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];\n const found: string[] = [];\n for (const item of list) {\n if (typeof item !== 'string') continue;\n const tag = normalizeTag(item);\n if (tag) found.push(tag);\n }\n return found;\n}\n\n// #tag tokens outside fenced code blocks and inline code spans.\nfunction inlineTags(body: string): string[] {\n const found: string[] = [];\n let inFence = false;\n for (const line of body.split('\\n')) {\n if (FENCE_RE.test(line)) {\n inFence = !inFence;\n continue;\n }\n if (inFence) continue;\n if (!line.includes('#')) continue; // most lines; skip the regex work\n const cleaned = line.includes('`') ? line.replace(INLINE_CODE_RE, (m) => ' '.repeat(m.length)) : line;\n for (const m of cleaned.matchAll(INLINE_TAG_RE)) {\n const tag = normalizeTag(m[1]);\n if (tag) found.push(tag);\n }\n }\n return found;\n}\n\nfunction extract(_raw: string, body: string, _search?: { title: string; summary: string }, data?: Record<string, unknown>): string[] {\n return [...new Set([...frontmatterTags(data), ...inlineTags(body)])].sort();\n}\n\nexport const tags: Feature = {\n name: 'tags',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS tags (\"path\" TEXT, tag TEXT, PRIMARY KEY (\"path\", tag))');\n db.exec('CREATE INDEX IF NOT EXISTS tags_tag ON tags(tag)');\n },\n extract,\n // Per-file rows with nothing else to resolve, so only a vanished file needs a delete here;\n // store() below handles a reparse's stale rows itself.\n remove(db, path, delta) {\n if (!delta.vanished.includes(path)) return;\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n },\n store(db, path, extracted) {\n const found = extracted as string[];\n if (found.length === 0) {\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n } else {\n const placeholders = found.map(() => '?').join(', ');\n db.prepare(`DELETE FROM tags WHERE \"path\" = ? AND tag NOT IN (${placeholders})`).run(path, ...found);\n }\n const insert = db.prepare('INSERT OR IGNORE INTO tags (\"path\", tag) VALUES (?, ?)');\n for (const tag of found) insert.run(path, tag);\n },\n};\n"],"names":["FENCE_RE","INLINE_CODE_RE","INLINE_TAG_RE","normalizeTag","raw","stripped","replace","test","frontmatterTags","data","tags","list","Array","isArray","found","item","tag","push","inlineTags","body","inFence","line","split","includes","cleaned","m","repeat","length","matchAll","extract","_raw","_search","Set","sort","name","schema","db","exec","remove","path","delta","vanished","prepare","run","store","extracted","placeholders","map","join","insert"],"mappings":"AAEA,0FAA0F;AAC1F,uFAAuF;AACvF,4FAA4F;AAE5F,MAAMA,WAAW;AACjB,MAAMC,iBAAiB;AACvB,iGAAiG;AACjG,MAAMC,gBAAgB;AAEtB,sFAAsF;AACtF,oEAAoE;AACpE,SAASC,aAAaC,GAAW;IAC/B,MAAMC,WAAWD,IAAIE,OAAO,CAAC,MAAM,IAAIA,OAAO,CAAC,QAAQ;IACvD,IAAI,CAACD,YAAY,QAAQE,IAAI,CAACF,WAAW,OAAO;IAChD,OAAOA;AACT;AAEA,4FAA4F;AAC5F,6EAA6E;AAC7E,SAASG,gBAAgBC,IAA8B;IACrD,MAAML,MAAMK,iBAAAA,2BAAAA,KAAMC,IAAI;IACtB,MAAMC,OAAOC,MAAMC,OAAO,CAACT,OAAOA,MAAM,OAAOA,QAAQ,WAAW;QAACA;KAAI,GAAG,EAAE;IAC5E,MAAMU,QAAkB,EAAE;IAC1B,KAAK,MAAMC,QAAQJ,KAAM;QACvB,IAAI,OAAOI,SAAS,UAAU;QAC9B,MAAMC,MAAMb,aAAaY;QACzB,IAAIC,KAAKF,MAAMG,IAAI,CAACD;IACtB;IACA,OAAOF;AACT;AAEA,gEAAgE;AAChE,SAASI,WAAWC,IAAY;IAC9B,MAAML,QAAkB,EAAE;IAC1B,IAAIM,UAAU;IACd,KAAK,MAAMC,QAAQF,KAAKG,KAAK,CAAC,MAAO;QACnC,IAAItB,SAASO,IAAI,CAACc,OAAO;YACvBD,UAAU,CAACA;YACX;QACF;QACA,IAAIA,SAAS;QACb,IAAI,CAACC,KAAKE,QAAQ,CAAC,MAAM,UAAU,kCAAkC;QACrE,MAAMC,UAAUH,KAAKE,QAAQ,CAAC,OAAOF,KAAKf,OAAO,CAACL,gBAAgB,CAACwB,IAAM,IAAIC,MAAM,CAACD,EAAEE,MAAM,KAAKN;QACjG,KAAK,MAAMI,KAAKD,QAAQI,QAAQ,CAAC1B,eAAgB;YAC/C,MAAMc,MAAMb,aAAasB,CAAC,CAAC,EAAE;YAC7B,IAAIT,KAAKF,MAAMG,IAAI,CAACD;QACtB;IACF;IACA,OAAOF;AACT;AAEA,SAASe,QAAQC,IAAY,EAAEX,IAAY,EAAEY,OAA4C,EAAEtB,IAA8B;IACvH,OAAO;WAAI,IAAIuB,IAAI;eAAIxB,gBAAgBC;eAAUS,WAAWC;SAAM;KAAE,CAACc,IAAI;AAC3E;AAEA,OAAO,MAAMvB,OAAgB;IAC3BwB,MAAM;IACNC,QAAOC,EAAE;QACPA,GAAGC,IAAI,CAAC;QACRD,GAAGC,IAAI,CAAC;IACV;IACAR;IACA,2FAA2F;IAC3F,uDAAuD;IACvDS,QAAOF,EAAE,EAAEG,IAAI,EAAEC,KAAK;QACpB,IAAI,CAACA,MAAMC,QAAQ,CAAClB,QAAQ,CAACgB,OAAO;QACpCH,GAAGM,OAAO,CAAC,qCAAqCC,GAAG,CAACJ;IACtD;IACAK,OAAMR,EAAE,EAAEG,IAAI,EAAEM,SAAS;QACvB,MAAM/B,QAAQ+B;QACd,IAAI/B,MAAMa,MAAM,KAAK,GAAG;YACtBS,GAAGM,OAAO,CAAC,qCAAqCC,GAAG,CAACJ;QACtD,OAAO;YACL,MAAMO,eAAehC,MAAMiC,GAAG,CAAC,IAAM,KAAKC,IAAI,CAAC;YAC/CZ,GAAGM,OAAO,CAAC,CAAC,kDAAkD,EAAEI,aAAa,CAAC,CAAC,EAAEH,GAAG,CAACJ,SAASzB;QAChG;QACA,MAAMmC,SAASb,GAAGM,OAAO,CAAC;QAC1B,KAAK,MAAM1B,OAAOF,MAAOmC,OAAON,GAAG,CAACJ,MAAMvB;IAC5C;AACF,EAAE"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/tags.ts"],"sourcesContent":["import { fenceTracker } from '../fences.ts';\nimport type { Feature } from './types.ts';\n\n// tags(path, tag): Obsidian's file.tags grain -- frontmatter list/string tags plus inline\n// #tags from the prose, deduplicated, source not distinguished. Nested tags store full\n// (book/scifi); `tag = 'book' OR tag LIKE 'book/%'` is how a caller matches the parent too.\n\n// Obsidian treats [[#Heading]] as a same-note link, not a tag.\nconst WIKILINK_RE = /\\[\\[.*?\\]\\]/g; // to the first ]], so a heading holding a lone ] still masks\n// Obsidian doesn't read tags inside HTML markup.\nconst HTML_TAG_RE = /<\\/?[a-zA-Z][^>]*>/g; // tag-shaped only: a comparison's `< 5` must not open a span\n// Anchors on start-of-line or a preceding whitespace/(/[ so `a#b` and URL fragments don't count.\nconst INLINE_TAG_RE = /(?:^|[\\s([])#([\\p{L}\\p{N}_/-]+)/gu;\n// A markdown link destination `](...)` -- `[text](#anchor)` is a same-page link, not a tag.\nconst LINK_DEST_RE = /\\]\\((?:[^()]|\\([^()]*\\))*\\)/g; // one paren-nesting level, as CommonMark destinations allow: (https://x/a_(b)#frag)\n\n// CommonMark's HTML-block type-6 list (fixed by the spec, not a drifting enumeration): a line\n// starting with an open or close tag of one of these, at column 0, opens a block that swallows\n// following lines -- including any #tag in them -- until a blank line closes it.\nconst HTML_BLOCK_TAGS = new Set([\n 'address',\n 'article',\n 'aside',\n 'base',\n 'basefont',\n 'blockquote',\n 'body',\n 'caption',\n 'center',\n 'col',\n 'colgroup',\n 'dd',\n 'details',\n 'dialog',\n 'dir',\n 'div',\n 'dl',\n 'dt',\n 'fieldset',\n 'figcaption',\n 'figure',\n 'footer',\n 'form',\n 'frame',\n 'frameset',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'head',\n 'header',\n 'hr',\n 'html',\n 'iframe',\n 'legend',\n 'li',\n 'link',\n 'main',\n 'menu',\n 'menuitem',\n 'nav',\n 'noframes',\n 'ol',\n 'optgroup',\n 'option',\n 'p',\n 'param',\n 'search',\n 'section',\n 'summary',\n 'table',\n 'tbody',\n 'td',\n 'tfoot',\n 'th',\n 'thead',\n 'title',\n 'tr',\n 'track',\n 'ul',\n]);\n// Type-1 blocks (script/pre/style/textarea): closes on the line holding the matching end tag,\n// not on a blank line, and that line is the last one skipped.\nconst HTML_PRE_TAGS = new Set(['script', 'pre', 'style', 'textarea']);\n// An opening or closing tag at column 0, tag name captured for the lookups above.\nconst HTML_BLOCK_OPEN_RE = /^<\\/?([a-zA-Z][a-zA-Z0-9]*)(?:[ \\t]|\\/?>|$)/;\n\n// Strips a leading # (frontmatter entries may carry one) and a trailing /; rejects an\n// all-digit result -- a tag needs at least one non-digit character.\nfunction normalizeTag(raw: string): string | null {\n const stripped = raw.replace(/^#/, '').replace(/\\/+$/, '');\n if (!stripped || /^\\d+$/.test(stripped)) return null;\n return stripped;\n}\n\n// data.tags: a YAML list (Obsidian also accepts a bare string). Null members and non-string\n// members are skipped rather than throwing -- `tags:\\n -` parses to [null].\nfunction frontmatterTags(data?: Record<string, unknown>): string[] {\n const raw = data?.tags;\n const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];\n const found: string[] = [];\n for (const item of list) {\n if (typeof item !== 'string') continue;\n const tag = normalizeTag(item);\n if (tag) found.push(tag);\n }\n return found;\n}\n\n// A code span opens on a run of N backticks and closes at the next run of exactly N -- a\n// shorter or longer run in between is literal text, not a delimiter (CommonMark code spans).\n// Masked with spaces so column positions and tag-boundary whitespace are unaffected.\nfunction maskCodeSpans(line: string): string {\n let out = '';\n let i = 0;\n while (i < line.length) {\n if (line[i] !== '`') {\n out += line[i];\n i++;\n continue;\n }\n let j = i;\n while (line[j] === '`') j++;\n const n = j - i;\n let k = j;\n let closeStart = -1;\n let closeEnd = -1;\n while (k < line.length) {\n if (line[k] !== '`') {\n k++;\n continue;\n }\n let m = k;\n while (line[m] === '`') m++;\n if (m - k === n) {\n closeStart = k;\n closeEnd = m;\n break;\n }\n k = m;\n }\n if (closeStart >= 0) {\n out += ' '.repeat(closeEnd - i);\n i = closeEnd;\n } else {\n out += line.slice(i, j);\n i = j;\n }\n }\n return out;\n}\n\n// #tag tokens outside fenced code blocks, inline code spans, wikilinks, HTML tags, HTML blocks,\n// and link destinations.\nfunction inlineTags(body: string): string[] {\n const found: string[] = [];\n const fence = fenceTracker();\n let inHtmlBlock = false;\n let htmlBlockClose: RegExp | null = null; // set while inside a type-1 (script/pre/style/textarea) block\n for (const line of body.split('\\n')) {\n if (inHtmlBlock) {\n // A fence-like line here is still HTML-block content -- the block wins until it closes.\n if (htmlBlockClose) {\n if (htmlBlockClose.test(line)) {\n inHtmlBlock = false;\n htmlBlockClose = null;\n }\n } else if (/^[ \\t>]*$/.test(line)) {\n inHtmlBlock = false;\n }\n continue;\n }\n if (fence.feed(line)) continue;\n if (fence.inFence) continue;\n // Indented or blockquoted HTML blocks still swallow their content in Obsidian, so the\n // opener test runs after stripping leading whitespace and > markers.\n const stripped = line.replace(/^[ \\t>]*/, '');\n if (stripped[0] === '<') {\n const openMatch = HTML_BLOCK_OPEN_RE.exec(stripped);\n if (openMatch) {\n const tagName = openMatch[1].toLowerCase();\n const isClosingTag = stripped[1] === '/';\n if (!isClosingTag && HTML_PRE_TAGS.has(tagName)) {\n inHtmlBlock = true;\n // Any of the four type-1 closers ends the block, not only the tag that opened it.\n htmlBlockClose = /<\\/(?:script|pre|style|textarea)>/i;\n continue;\n }\n if (HTML_BLOCK_TAGS.has(tagName)) {\n inHtmlBlock = true;\n continue;\n }\n }\n }\n if (!line.includes('#')) continue; // most lines; skip the regex work\n let cleaned = line.includes('`') ? maskCodeSpans(line) : line;\n if (cleaned.includes('[[')) cleaned = cleaned.replace(WIKILINK_RE, (m) => ' '.repeat(m.length));\n if (cleaned.includes('](')) cleaned = cleaned.replace(LINK_DEST_RE, (m) => ' '.repeat(m.length));\n if (cleaned.includes('<')) cleaned = cleaned.replace(HTML_TAG_RE, (m) => ' '.repeat(m.length));\n for (const m of cleaned.matchAll(INLINE_TAG_RE)) {\n const tag = normalizeTag(m[1]);\n if (tag) found.push(tag);\n }\n }\n return found;\n}\n\nfunction extract(_raw: string, body: string, _search?: { title: string; summary: string }, data?: Record<string, unknown>): string[] {\n return [...new Set([...frontmatterTags(data), ...inlineTags(body)])].sort();\n}\n\nexport const tags: Feature = {\n name: 'tags',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS tags (\"path\" TEXT, tag TEXT, PRIMARY KEY (\"path\", tag))');\n db.exec('CREATE INDEX IF NOT EXISTS tags_tag ON tags(tag)');\n },\n extract,\n // Per-file rows with nothing else to resolve, so only a vanished file needs a delete here;\n // store() below handles a reparse's stale rows itself.\n remove(db, path, delta) {\n if (!delta.vanished.includes(path)) return;\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n },\n store(db, path, extracted) {\n const found = extracted as string[];\n if (found.length === 0) {\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n } else {\n const placeholders = found.map(() => '?').join(', ');\n db.prepare(`DELETE FROM tags WHERE \"path\" = ? AND tag NOT IN (${placeholders})`).run(path, ...found);\n }\n const insert = db.prepare('INSERT OR IGNORE INTO tags (\"path\", tag) VALUES (?, ?)');\n for (const tag of found) insert.run(path, tag);\n },\n};\n"],"names":["fenceTracker","WIKILINK_RE","HTML_TAG_RE","INLINE_TAG_RE","LINK_DEST_RE","HTML_BLOCK_TAGS","Set","HTML_PRE_TAGS","HTML_BLOCK_OPEN_RE","normalizeTag","raw","stripped","replace","test","frontmatterTags","data","tags","list","Array","isArray","found","item","tag","push","maskCodeSpans","line","out","i","length","j","n","k","closeStart","closeEnd","m","repeat","slice","inlineTags","body","fence","inHtmlBlock","htmlBlockClose","split","feed","inFence","openMatch","exec","tagName","toLowerCase","isClosingTag","has","includes","cleaned","matchAll","extract","_raw","_search","sort","name","schema","db","remove","path","delta","vanished","prepare","run","store","extracted","placeholders","map","join","insert"],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ export interface FenceTracker {
2
+ feed(line: string): boolean;
3
+ readonly inFence: boolean;
4
+ }
5
+ export declare function fenceTracker(): FenceTracker;
@@ -0,0 +1,43 @@
1
+ // Shared line-fence tracker for tags/sections/embed. Opener: >=3 backticks or tildes at column
2
+ // 0 -- no indent allowance, a deliberate divergence from CommonMark's up-to-3-space rule (see
3
+ // test/unit/fences.test.ts DIVERGENCES table). A backtick opener's info string must not itself
4
+ // contain a backtick (spec rule). A closer is a run of the SAME character, length >= the
5
+ // opener's, holding nothing but trailing spaces after the run.
6
+ const BACKTICK_OPEN = /^(`{3,})(.*)$/;
7
+ const TILDE_OPEN = /^(~{3,})(.*)$/;
8
+ export function fenceTracker() {
9
+ let inFence = false;
10
+ let fenceChar = '';
11
+ let fenceLen = 0;
12
+ return {
13
+ feed (line) {
14
+ if (!inFence) {
15
+ const bt = BACKTICK_OPEN.exec(line);
16
+ if (bt && !bt[2].includes('`')) {
17
+ inFence = true;
18
+ fenceChar = '`';
19
+ fenceLen = bt[1].length;
20
+ return true;
21
+ }
22
+ const td = TILDE_OPEN.exec(line);
23
+ if (td) {
24
+ inFence = true;
25
+ fenceChar = '~';
26
+ fenceLen = td[1].length;
27
+ return true;
28
+ }
29
+ return false;
30
+ }
31
+ const run = fenceChar === '`' ? /^(`+)(.*)$/ : /^(~+)(.*)$/;
32
+ const m = run.exec(line);
33
+ if (m && m[1].length >= fenceLen && m[2].trim() === '') {
34
+ inFence = false;
35
+ return true;
36
+ }
37
+ return false;
38
+ },
39
+ get inFence () {
40
+ return inFence;
41
+ }
42
+ };
43
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/fences.ts"],"sourcesContent":["// Shared line-fence tracker for tags/sections/embed. Opener: >=3 backticks or tildes at column\n// 0 -- no indent allowance, a deliberate divergence from CommonMark's up-to-3-space rule (see\n// test/unit/fences.test.ts DIVERGENCES table). A backtick opener's info string must not itself\n// contain a backtick (spec rule). A closer is a run of the SAME character, length >= the\n// opener's, holding nothing but trailing spaces after the run.\n\nexport interface FenceTracker {\n feed(line: string): boolean; // true iff this line is a fence delimiter (open or close)\n readonly inFence: boolean;\n}\n\nconst BACKTICK_OPEN = /^(`{3,})(.*)$/;\nconst TILDE_OPEN = /^(~{3,})(.*)$/;\n\nexport function fenceTracker(): FenceTracker {\n let inFence = false;\n let fenceChar = '';\n let fenceLen = 0;\n return {\n feed(line: string): boolean {\n if (!inFence) {\n const bt = BACKTICK_OPEN.exec(line);\n if (bt && !bt[2].includes('`')) {\n inFence = true;\n fenceChar = '`';\n fenceLen = bt[1].length;\n return true;\n }\n const td = TILDE_OPEN.exec(line);\n if (td) {\n inFence = true;\n fenceChar = '~';\n fenceLen = td[1].length;\n return true;\n }\n return false;\n }\n const run = fenceChar === '`' ? /^(`+)(.*)$/ : /^(~+)(.*)$/;\n const m = run.exec(line);\n if (m && m[1].length >= fenceLen && m[2].trim() === '') {\n inFence = false;\n return true;\n }\n return false;\n },\n get inFence() {\n return inFence;\n },\n };\n}\n"],"names":["BACKTICK_OPEN","TILDE_OPEN","fenceTracker","inFence","fenceChar","fenceLen","feed","line","bt","exec","includes","length","td","run","m","trim"],"mappings":"AAAA,+FAA+F;AAC/F,8FAA8F;AAC9F,+FAA+F;AAC/F,yFAAyF;AACzF,+DAA+D;AAO/D,MAAMA,gBAAgB;AACtB,MAAMC,aAAa;AAEnB,OAAO,SAASC;IACd,IAAIC,UAAU;IACd,IAAIC,YAAY;IAChB,IAAIC,WAAW;IACf,OAAO;QACLC,MAAKC,IAAY;YACf,IAAI,CAACJ,SAAS;gBACZ,MAAMK,KAAKR,cAAcS,IAAI,CAACF;gBAC9B,IAAIC,MAAM,CAACA,EAAE,CAAC,EAAE,CAACE,QAAQ,CAAC,MAAM;oBAC9BP,UAAU;oBACVC,YAAY;oBACZC,WAAWG,EAAE,CAAC,EAAE,CAACG,MAAM;oBACvB,OAAO;gBACT;gBACA,MAAMC,KAAKX,WAAWQ,IAAI,CAACF;gBAC3B,IAAIK,IAAI;oBACNT,UAAU;oBACVC,YAAY;oBACZC,WAAWO,EAAE,CAAC,EAAE,CAACD,MAAM;oBACvB,OAAO;gBACT;gBACA,OAAO;YACT;YACA,MAAME,MAAMT,cAAc,MAAM,eAAe;YAC/C,MAAMU,IAAID,IAAIJ,IAAI,CAACF;YACnB,IAAIO,KAAKA,CAAC,CAAC,EAAE,CAACH,MAAM,IAAIN,YAAYS,CAAC,CAAC,EAAE,CAACC,IAAI,OAAO,IAAI;gBACtDZ,UAAU;gBACV,OAAO;YACT;YACA,OAAO;QACT;QACA,IAAIA,WAAU;YACZ,OAAOA;QACT;IACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sensemaking",
3
- "version": "0.13.2",
3
+ "version": "0.15.0",
4
4
  "description": "Query and search your markdown notes with context-aware progressive disclosure: SQL over frontmatter, links, and text, plus semantic search and link-graph ranking. No server, no build step",
5
5
  "keywords": [
6
6
  "markdown",
@@ -61,6 +61,7 @@ The commands are shorthands over those tables; anything they don't express, SQL
61
61
 
62
62
  ```
63
63
  sense sql "SELECT name FROM pragma_table_info('frontmatter')" # what fields exist
64
+ sense sql 'SELECT path FROM frontmatter WHERE "plugin-id" IS NOT NULL' # punctuated keys need double quotes: unquoted, plugin-id reads as subtraction
64
65
  sense sql "SELECT DISTINCT status FROM frontmatter" # what values a field takes
65
66
  sense sql "SELECT src FROM links WHERE dst = ?" notes/pricing-model.md # backlinks
66
67
  sense sql "SELECT f.path FROM frontmatter f JOIN scope ON scope.path = f.path" --preset default # scope SQL to a preset
@@ -111,7 +112,7 @@ Worked traces: [EXAMPLES.md](EXAMPLES.md).
111
112
  - `map` and `status` report 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 semantic-off preset searches lexically); a saved search naming an unknown preset errors when run, listing the declared ones.
112
113
  - Save a query into `sense.config.json` only when it will be reused; run ad-hoc otherwise.
113
114
  - 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.
114
- - Reserved frontmatter keys (dropped with a warning): `path`, `_mtime`, `_ctime`, `_size`, `_rank`, `_parse_error`, `content`, `links`, `sections`. The `tags` frontmatter column and the `tags` table coexist: the column holds the raw YAML list, the table the merged frontmatter+inline set.
115
+ - Reserved frontmatter keys (dropped with a warning): `path`, `_mtime`, `_ctime`, `_size`, `_rank`, `_parse_error`, `content`, `links`, `sections`. The `tags` frontmatter column and the `tags` table coexist, mirroring Obsidian's own split: the column is the raw YAML list one note's frontmatter declares (Obsidian's `tags` property), the table is the merged, deduplicated frontmatter+inline set per note (what Obsidian's tag pane and Bases' `file.tags` read). "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 -- some trees run their whole maintenance-tag system in comments.
115
116
  - A note whose frontmatter does not parse is indexed with **no** frontmatter columns and `_parse_error` set 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_error` is how you tell: `WHERE status IS NULL AND _parse_error IS NULL` is "genuinely missing status". List what needs fixing with `sense sql "SELECT path, _parse_error FROM frontmatter WHERE _parse_error IS NOT NULL"`; fixing a file clears it on the next command. `sense status` reports the count.
116
117
  - Exit codes: `0` ok, `1` error (SQLite message verbatim), `2` usage (unknown query, wrong param count).
117
118
  - Doubted cache: delete the directory `sense status` prints on its `cache:` line. Rarely needed; every query reconciles first.
@@ -30,7 +30,7 @@ A preset is a named, self-contained bundle of settings. `default` (required) is
30
30
  - Presets may overlap; they are views, not partitions.
31
31
  - Global `features` (`links`, `sections`, `rank`) still toggle tree-wide; most trees never touch them.
32
32
 
33
- **Vectors take two decisions, in two places.** The top-level `"embed": { "model", "type": "static"|"api", "url", "key" }` block names the model and says whether the tree has vectors at all. A preset's `semantic` says whether that scope uses them: a layer searched for exact wording (ingested sources, archives, generated output) sets `semantic: false`, costs no embedding, and its searches run on words and links. That is the main scale lever, and it is the llm-wiki split: compiled pages searched by meaning, raw sources searched for the phrasing you are citing. `static` is the built-in pure-JS Model2Vec loader and handles paraphrase and reworded concepts; tight domain jargon ("heart attack" for "myocardial infarction") is where an `api` transformer model tends to do better, measured in BENCHMARKING.md, "Retrieval quality". Nothing downloads the model implicitly: `sense download` fetches it once per machine into the cache (`$XDG_CACHE_HOME/sensemaking/models`, else `~/.cache/...`), one directory per model, so several models coexist and switching between them rebuilds the index rather than mixing vector spaces. A `model` holding a path instead of a Hugging Face id points at a local directory, which `sense download` reports as nothing to fetch. The first search after that embeds the tree (progress on stderr; minutes on tens of thousands of notes, seconds on small trees).
33
+ **Vectors take two decisions, in two places.** The top-level `"embed": { "model", "type": "static"|"api", "url", "key" }` block names the model and says whether the tree has vectors at all. A preset's `semantic` says whether that scope uses them: a layer searched for exact wording (ingested sources, archives, generated output) sets `semantic: false`, costs no embedding, and its searches run on words and links. That is the main scale lever, and it is the llm-wiki split: compiled pages searched by meaning, raw sources searched for the phrasing you are citing. `static` is the built-in pure-JS Model2Vec loader and handles paraphrase and reworded concepts; tight domain jargon ("heart attack" for "myocardial infarction") is where an `api` transformer model tends to do better, measured in BENCHMARKING.md, "Retrieval quality". Nothing downloads the model implicitly: `sense download` fetches it once per machine into the cache (`$XDG_CACHE_HOME/sensemaking/models`, else `~/.cache/...`), one directory per model, so several models coexist and switching between them rebuilds the index rather than mixing vector spaces. A `model` holding a path instead of a Hugging Face id points at a local directory, which `sense download` reports as nothing to fetch. The first search after that embeds the tree (progress on stderr; minutes on tens of thousands of notes, seconds on small trees). A config edit that changes coverage or features rebuilds the cache, vectors included, so settle presets before the first semantic search on a large tree or that embedding run is paid twice.
34
34
 
35
35
  **Large vaults**: everything except the vector build is measured linear to 100k notes with no tuning (BENCHMARKING.md). The knobs that matter are `k` (more, smaller results; rows carry `lines` section ranges, so agents read sections, not files) and `semantic: false` on the layers that do not earn vectors.
36
36