sensemaking 0.14.0 → 0.15.1

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 (40) hide show
  1. package/dist/cjs/commands/peek.js +3 -3
  2. package/dist/cjs/commands/peek.js.map +1 -1
  3. package/dist/cjs/commands/related.js +2 -2
  4. package/dist/cjs/commands/related.js.map +1 -1
  5. package/dist/cjs/db/open.d.cts +1 -1
  6. package/dist/cjs/db/open.d.ts +1 -1
  7. package/dist/cjs/db/open.js +1 -1
  8. package/dist/cjs/db/open.js.map +1 -1
  9. package/dist/cjs/features/embed.js +4 -6
  10. package/dist/cjs/features/embed.js.map +1 -1
  11. package/dist/cjs/features/links.js +100 -16
  12. package/dist/cjs/features/links.js.map +1 -1
  13. package/dist/cjs/features/sections.js +4 -6
  14. package/dist/cjs/features/sections.js.map +1 -1
  15. package/dist/cjs/features/tags.js +129 -11
  16. package/dist/cjs/features/tags.js.map +1 -1
  17. package/dist/cjs/fences.d.cts +12 -0
  18. package/dist/cjs/fences.d.ts +12 -0
  19. package/dist/cjs/fences.js +157 -0
  20. package/dist/cjs/fences.js.map +1 -0
  21. package/dist/esm/commands/peek.js +3 -3
  22. package/dist/esm/commands/peek.js.map +1 -1
  23. package/dist/esm/commands/related.js +2 -2
  24. package/dist/esm/commands/related.js.map +1 -1
  25. package/dist/esm/db/open.d.ts +1 -1
  26. package/dist/esm/db/open.js +1 -1
  27. package/dist/esm/db/open.js.map +1 -1
  28. package/dist/esm/features/embed.js +4 -6
  29. package/dist/esm/features/embed.js.map +1 -1
  30. package/dist/esm/features/links.js +63 -15
  31. package/dist/esm/features/links.js.map +1 -1
  32. package/dist/esm/features/sections.js +4 -6
  33. package/dist/esm/features/sections.js.map +1 -1
  34. package/dist/esm/features/tags.js +127 -9
  35. package/dist/esm/features/tags.js.map +1 -1
  36. package/dist/esm/fences.d.ts +12 -0
  37. package/dist/esm/fences.js +138 -0
  38. package/dist/esm/fences.js.map +1 -0
  39. package/package.json +1 -1
  40. package/skills/sense/SKILL.md +1 -1
@@ -1,10 +1,14 @@
1
1
  import posix from 'node:path/posix';
2
+ import { maskRegions } from '../fences.js';
2
3
  // links(src, target, target_base, dst, embed): target as written, target_base its baseKey
3
4
  // (indexed, for the incremental resolve below), dst the resolved path or NULL (a
4
5
  // queryable dead link), embed whether it's `![[x]]`/`![](x.md)` rather than `[[x]]`/`[](x.md)`
5
6
  // -- Obsidian's grain: a target both linked and embedded in the same note is two rows.
6
- // Wikilinks ([[target]], [[target#anchor|alias]], embeds) plus relative markdown links to .md files.
7
- function extract(_raw, body) {
7
+ // Wikilinks ([[target]], [[target#anchor|alias]], embeds), internal markdown links, and
8
+ // frontmatter values that are exactly a wikilink ("[[X]]"; mid-string and ![[...]] forms are
9
+ // not links there -- Obsidian's rule, probe-verified).
10
+ function extract(_raw, rawBody, _search, data) {
11
+ const body = /\[\[|\]\(/.test(rawBody) ? maskRegions(rawBody) : rawBody;
8
12
  const seen = new Set();
9
13
  const results = [];
10
14
  const add = (target, embed)=>{
@@ -20,12 +24,42 @@ function extract(_raw, body) {
20
24
  // matches; anchor and alias split off in code, since a class-based regex stops at the ].
21
25
  for (const m of body.matchAll(/\[\[(.*?)\]\]/g)){
22
26
  var _m_index;
23
- const target = m[1].split('|')[0].split('#')[0].trim();
24
- if (target) add(target, body[((_m_index = m.index) !== null && _m_index !== void 0 ? _m_index : 0) - 1] === '!');
27
+ const embed = body[((_m_index = m.index) !== null && _m_index !== void 0 ? _m_index : 0) - 1] === '!';
28
+ // [[#Heading]]: a same-note anchor link, which Obsidian resolves to a self-edge. Keep the
29
+ // target as written (leading #) so resolveTarget can special-case it.
30
+ const beforeAlias = m[1].split('|')[0].trim();
31
+ if (beforeAlias.startsWith('#')) {
32
+ if (beforeAlias.length > 1) add(beforeAlias, embed);
33
+ continue;
34
+ }
35
+ const target = beforeAlias.split('#')[0].trim();
36
+ if (target) add(target, embed);
37
+ }
38
+ // Any internal destination, not only .md-suffixed: Obsidian gives markdown links full
39
+ // linkpath resolution, so \](Zektor) resolves like [[Zektor]] and \](#anchor) is a
40
+ // self-edge. External URLs (a scheme anywhere survives the malformed double-paren case)
41
+ // and titled links (dest stops at whitespace) are skipped.
42
+ // A space in the destination is only valid ahead of a quoted title; a domain-shaped first
43
+ // segment (www.example.com/...) is external even without a scheme.
44
+ for (const m of body.matchAll(/(!)?\[[^\]]*\]\(((?:[^()\s]|\([^()\s]*\))+)(?:\s+(?:"[^)]*"|'[^)]*'))?\)/g)){
45
+ const dest = m[2].trim();
46
+ if (!dest || dest.includes('://')) continue;
47
+ if (/^www\./i.test(dest)) continue;
48
+ const target = dest.startsWith('#') ? dest : dest.split('#')[0];
49
+ if (target) add(target, m[1] === '!');
25
50
  }
26
- for (const m of body.matchAll(/(!)?\[[^\]]*\]\(([^)]+\.md)(?:#[^)]*)?\)/g)){
27
- const target = m[2].trim();
28
- if (target && !/^[a-z]+:\/\//i.test(target)) add(target, m[1] === '!');
51
+ for (const value of Object.values(data !== null && data !== void 0 ? data : {})){
52
+ for (const item of Array.isArray(value) ? value : [
53
+ value
54
+ ]){
55
+ if (typeof item !== 'string') continue;
56
+ const m = /^\[\[(.*)\]\]$/.exec(item.trim());
57
+ if (!m) continue;
58
+ const inner = m[1].split('|')[0].trim();
59
+ const target = inner.split('#')[0].trim();
60
+ if (target) add(target, false);
61
+ else if (inner.startsWith('#') && inner.length > 1) add(inner, false);
62
+ }
29
63
  }
30
64
  return results;
31
65
  }
@@ -36,10 +70,11 @@ function baseKey(path) {
36
70
  return posix.basename(path).replace(/\.md$/i, '').toLowerCase();
37
71
  }
38
72
  // Obsidian-style: exact relative path (with/without .md), path relative to the linking
39
- // note's directory, then basename match (lexicographically first on ties).
73
+ // note's directory, then basename match (shortest path wins on ties -- verified against a
74
+ // cold-loaded Obsidian vault on 6+ real collision pairs; byBase's lists are pre-sorted that way).
40
75
  function resolveTarget(src, target, pathSet, byBase) {
41
- var _ref;
42
- var _byBase_get;
76
+ // [[#Heading]]: a same-note anchor, always the note itself -- never depends on other files.
77
+ if (target.startsWith('#')) return src;
43
78
  const clean = cleanTarget(target);
44
79
  const fromSrc = posix.normalize(posix.join(posix.dirname(src), clean));
45
80
  for (const candidate of [
@@ -50,11 +85,18 @@ function resolveTarget(src, target, pathSet, byBase) {
50
85
  ]){
51
86
  if (pathSet.has(candidate)) return candidate;
52
87
  }
53
- return (_ref = (_byBase_get = byBase.get(baseKey(clean))) === null || _byBase_get === void 0 ? void 0 : _byBase_get[0]) !== null && _ref !== void 0 ? _ref : null;
88
+ const candidates = byBase.get(baseKey(clean));
89
+ if (!candidates) return null;
90
+ // The linking note itself wins a basename collision (Obsidian resolves to self before the
91
+ // shortest-path rule -- cold-load verified on the hub corpus).
92
+ return candidates.includes(src) ? src : candidates[0];
54
93
  }
94
+ // Shortest path wins a basename collision; equal lengths fall back to lexicographic, our own
95
+ // deterministic tiebreak for a case Obsidian itself leaves registration-order-dependent.
55
96
  function buildByBase(files) {
56
97
  const byBase = new Map();
57
- for (const path of files.map((f)=>f.relPath).sort()){
98
+ const paths = files.map((f)=>f.relPath).sort((a, b)=>a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
99
+ for (const path of paths){
58
100
  const key = baseKey(path);
59
101
  const list = byBase.get(key);
60
102
  if (list) list.push(path);
@@ -126,9 +168,12 @@ export function linkEdges(db) {
126
168
  // embed whose exact (src, target) also exists as a link: that second row is new with the
127
169
  // embed grain and would double an edge that used to be one row. The NOT EXISTS probes the
128
170
  // primary key and fires only for embed rows; both branches still scan the table.
129
- const sql = `SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0
171
+ // Self-edges (same-note anchors) are excluded here so PageRank mass is not self-recycled --
172
+ // Obsidian's own graph view hides self-loops too. The rows themselves stay in the table for
173
+ // backlinks/peek.
174
+ const sql = `SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0 AND src != dst
130
175
  UNION ALL
131
- SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1
176
+ SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1 AND src != dst
132
177
  AND NOT EXISTS (SELECT 1 FROM links l0 WHERE l0.src = l.src AND l0.target = l.target AND l0.embed = 0)`;
133
178
  return db.prepare(sql).all().map((r)=>[
134
179
  r.src,
@@ -188,7 +233,10 @@ export const links = {
188
233
  // Upsert preserves dst on surviving rows, so an unchanged link resolves to the same
189
234
  // value and reports no change; only genuinely new rows start at NULL.
190
235
  const insert = db.prepare('INSERT INTO links (src, target, target_base, dst, embed) VALUES (?, ?, ?, NULL, ?) ON CONFLICT(src, target, embed) DO UPDATE SET target_base = excluded.target_base');
191
- for (const { target, embed } of targets)insert.run(path, target, baseKey(cleanTarget(target)), embed ? 1 : 0);
236
+ // A same-note anchor's dst is always its own src, never another file's basename, so it
237
+ // gets no target_base -- SQL's `IN` never matches NULL, keeping it out of
238
+ // resolveIncremental's cross-file collectIn('target_base', ...) entirely.
239
+ for (const { target, embed } of targets)insert.run(path, target, target.startsWith('#') ? null : baseKey(cleanTarget(target)), embed ? 1 : 0);
192
240
  },
193
241
  afterReconcile (db, delta) {
194
242
  var _ref;
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/links.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { FileStat } from '../scan.ts';\nimport type { Feature, ReconcileDelta } from './types.ts';\n\n// links(src, target, target_base, dst, embed): target as written, target_base its baseKey\n// (indexed, for the incremental resolve below), dst the resolved path or NULL (a\n// queryable dead link), embed whether it's `![[x]]`/`![](x.md)` rather than `[[x]]`/`[](x.md)`\n// -- Obsidian's grain: a target both linked and embedded in the same note is two rows.\n\n// Wikilinks ([[target]], [[target#anchor|alias]], embeds) plus relative markdown links to .md files.\nfunction extract(_raw: string, body: string): Array<{ target: string; embed: boolean }> {\n const seen = new Set<string>();\n const results: Array<{ target: string; embed: boolean }> = [];\n const add = (target: string, embed: boolean) => {\n const key = `${target}\\0${embed ? '1' : '0'}`;\n if (seen.has(key)) return;\n seen.add(key);\n results.push({ target, embed });\n };\n // To the first ]], as Obsidian parses it, so a heading or alias holding a lone ] still\n // matches; anchor and alias split off in code, since a class-based regex stops at the ].\n for (const m of body.matchAll(/\\[\\[(.*?)\\]\\]/g)) {\n const target = m[1].split('|')[0].split('#')[0].trim();\n if (target) add(target, body[(m.index ?? 0) - 1] === '!');\n }\n for (const m of body.matchAll(/(!)?\\[[^\\]]*\\]\\(([^)]+\\.md)(?:#[^)]*)?\\)/g)) {\n const target = m[2].trim();\n if (target && !/^[a-z]+:\\/\\//i.test(target)) add(target, m[1] === '!');\n }\n return results;\n}\n\nfunction cleanTarget(target: string): string {\n return target.replace(/\\\\/g, '/').replace(/^\\.\\//, '');\n}\n\nfunction baseKey(path: string): string {\n return posix.basename(path).replace(/\\.md$/i, '').toLowerCase();\n}\n\n// Obsidian-style: exact relative path (with/without .md), path relative to the linking\n// note's directory, then basename match (lexicographically first on ties).\nfunction resolveTarget(src: string, target: string, pathSet: Set<string>, byBase: Map<string, string[]>): string | null {\n const clean = cleanTarget(target);\n const fromSrc = posix.normalize(posix.join(posix.dirname(src), clean));\n for (const candidate of [clean, `${clean}.md`, fromSrc, `${fromSrc}.md`]) {\n if (pathSet.has(candidate)) return candidate;\n }\n return byBase.get(baseKey(clean))?.[0] ?? null;\n}\n\nfunction buildByBase(files: FileStat[]): Map<string, string[]> {\n const byBase = new Map<string, string[]>();\n for (const path of files.map((f) => f.relPath).sort()) {\n const key = baseKey(path);\n const list = byBase.get(key);\n if (list) list.push(path);\n else byBase.set(key, [path]);\n }\n return byBase;\n}\n\ninterface LinkRow {\n src: string;\n target: string;\n dst: string | null;\n embed: number;\n}\n\n// Applies resolveTarget to `rows`, writing only rows whose dst actually changes. The UPDATE\n// keys on (src, target): a link/embed sibling pair shares one resolution, so either row's\n// mismatch rewrites both.\nfunction resolveRows(db: DatabaseSync, rows: LinkRow[], pathSet: Set<string>, byBase: Map<string, string[]>): boolean {\n const update = db.prepare('UPDATE links SET dst = ? WHERE src = ? AND target = ?');\n let changed = false;\n for (const row of rows) {\n const dst = resolveTarget(row.src, row.target, pathSet, byBase);\n if (dst !== row.dst) {\n update.run(dst, row.src, row.target);\n changed = true;\n }\n }\n return changed;\n}\n\n// A new or deleted file can change any note's resolution, so re-resolve the whole table.\n// Fallback for cold builds and large deltas -- see afterReconcile's threshold.\nfunction resolveAll(db: DatabaseSync, files: FileStat[]): boolean {\n const pathSet = new Set(files.map((f) => f.relPath));\n const byBase = buildByBase(files);\n const rows = db.prepare('SELECT src, target, dst, embed FROM links').all() as unknown as LinkRow[];\n return resolveRows(db, rows, pathSet, byBase);\n}\n\n// Re-resolve only what this reconcile could have touched: re-stored sources, links whose\n// target basename matches an added/vanished file, and links whose dst just vanished. All\n// three are indexed lookups, so this never reads the whole table.\nfunction resolveIncremental(db: DatabaseSync, delta: ReconcileDelta): boolean {\n const pathSet = new Set(delta.files.map((f) => f.relPath));\n const byBase = buildByBase(delta.files);\n\n const changedBasenames = new Set<string>();\n for (const p of delta.added) changedBasenames.add(baseKey(p));\n for (const p of delta.vanished) changedBasenames.add(baseKey(p));\n\n // \\0 can't appear in a path or target, so the key never collides; a space can.\n const candidates = new Map<string, LinkRow>();\n const collect = (rows: LinkRow[]) => {\n for (const row of rows) candidates.set(`${row.src}\\0${row.target}\\0${row.embed}`, row);\n };\n // Chunked so a large-but-under-threshold delta never exceeds SQLite's bound-variable\n // limit (SQLITE_MAX_VARIABLE_NUMBER, 32766).\n const collectIn = (column: string, keys: string[]) => {\n for (let i = 0; i < keys.length; i += 500) {\n const chunk = keys.slice(i, i + 500);\n const placeholders = chunk.map(() => '?').join(', ');\n collect(db.prepare(`SELECT src, target, dst, embed FROM links WHERE ${column} IN (${placeholders})`).all(...chunk) as unknown as LinkRow[]);\n }\n };\n\n collectIn('src', delta.reparsed);\n collectIn('target_base', [...changedBasenames]);\n collectIn('dst', delta.vanished);\n\n return resolveRows(db, [...candidates.values()], pathSet, byBase);\n}\n\n// Resolved edges, for rank and for search's graph expansion.\nexport function linkEdges(db: DatabaseSync): [string, string][] {\n // One edge per row, as 0.12's grain always had it -- two written targets resolving to one\n // dst stay two edges (a weight the fusion evals were gated on). The only exclusion is an\n // embed whose exact (src, target) also exists as a link: that second row is new with the\n // embed grain and would double an edge that used to be one row. The NOT EXISTS probes the\n // primary key and fires only for embed rows; both branches still scan the table.\n const sql = `SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0\n UNION ALL\n SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1\n AND NOT EXISTS (SELECT 1 FROM links l0 WHERE l0.src = l.src AND l0.target = l.target AND l0.embed = 0)`;\n return (db.prepare(sql).all() as Array<{ src: string; dst: string }>).map((r) => [r.src, r.dst]);\n}\n\n// remove() has already deleted the rows by the time afterReconcile runs, so it records here\n// whether they carried edges. Keyed on the delta object, so the state dies with the reconcile.\nconst removedWithLinks = new WeakMap<ReconcileDelta, Set<string>>();\n\nfunction recordRemoved(delta: ReconcileDelta, path: string): void {\n let set = removedWithLinks.get(delta);\n if (!set) {\n set = new Set();\n removedWithLinks.set(delta, set);\n }\n set.add(path);\n}\n\n// remove() runs per path; the vanished list is an array, so cache the Set per delta.\nconst vanishedSets = new WeakMap<ReconcileDelta, Set<string>>();\nfunction vanishedSet(delta: ReconcileDelta): Set<string> {\n let set = vanishedSets.get(delta);\n if (!set) {\n set = new Set(delta.vanished);\n vanishedSets.set(delta, set);\n }\n return set;\n}\n\nexport const links: Feature = {\n name: 'links',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS links (src TEXT, target TEXT, target_base TEXT, dst TEXT, embed INTEGER, PRIMARY KEY (src, target, embed))');\n db.exec('CREATE INDEX IF NOT EXISTS links_dst ON links(dst)');\n db.exec('CREATE INDEX IF NOT EXISTS links_target_base ON links(target_base)');\n },\n extract,\n // Reparsed docs are diffed in store() below instead of wiped here: deleting and\n // re-inserting resets dst to NULL, which made every touch-only reparse read as an edge\n // change and recompute PageRank -- measured as most of the remaining update cost.\n remove(db, path, delta) {\n if (!vanishedSet(delta).has(path)) return;\n const result = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n if (Number(result.changes) > 0) recordRemoved(delta, path);\n },\n store(db, path, extracted, delta) {\n const targets = extracted as Array<{ target: string; embed: boolean }>;\n // Stale rows (target/embed pairs the new parse no longer contains) are real edge removals.\n // \\0 can't appear in a target, so the composite key never collides.\n let stale: ReturnType<ReturnType<DatabaseSync['prepare']>['run']>;\n if (targets.length === 0) {\n stale = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n } else {\n const placeholders = targets.map(() => '?').join(', ');\n const keys = targets.map((t) => `${t.target}\\0${t.embed ? '1' : '0'}`);\n stale = db.prepare(`DELETE FROM links WHERE src = ? AND (target || char(0) || embed) NOT IN (${placeholders})`).run(path, ...keys);\n }\n if (Number(stale.changes) > 0) recordRemoved(delta, path);\n // Upsert preserves dst on surviving rows, so an unchanged link resolves to the same\n // value and reports no change; only genuinely new rows start at NULL.\n const insert = db.prepare('INSERT INTO links (src, target, target_base, dst, embed) VALUES (?, ?, ?, NULL, ?) ON CONFLICT(src, target, embed) DO UPDATE SET target_base = excluded.target_base');\n for (const { target, embed } of targets) insert.run(path, target, baseKey(cleanTarget(target)), embed ? 1 : 0);\n },\n afterReconcile(db, delta) {\n // Any deleted rows that carried edges mean the edge set shrank -- whether the file\n // vanished or was reparsed down to fewer/no links. dstChanged alone misses the\n // reparsed-to-zero-links case (no surviving rows to re-resolve).\n const removeHadLinks = (removedWithLinks.get(delta)?.size ?? 0) > 0;\n\n const churn = delta.reparsed.length + delta.vanished.length;\n // Past this share of the tree, a full pass is the only one guaranteed to match\n // resolveTarget's ambiguity rules. A cold build clears the threshold on its own.\n const large = delta.files.length === 0 || churn > 0.2 * delta.files.length;\n\n const dstChanged = large ? resolveAll(db, delta.files) : resolveIncremental(db, delta);\n delta.linksChanged = dstChanged || removeHadLinks;\n },\n};\n"],"names":["posix","extract","_raw","body","seen","Set","results","add","target","embed","key","has","push","m","matchAll","split","trim","index","test","cleanTarget","replace","baseKey","path","basename","toLowerCase","resolveTarget","src","pathSet","byBase","clean","fromSrc","normalize","join","dirname","candidate","get","buildByBase","files","Map","map","f","relPath","sort","list","set","resolveRows","db","rows","update","prepare","changed","row","dst","run","resolveAll","all","resolveIncremental","delta","changedBasenames","p","added","vanished","candidates","collect","collectIn","column","keys","i","length","chunk","slice","placeholders","reparsed","values","linkEdges","sql","r","removedWithLinks","WeakMap","recordRemoved","vanishedSets","vanishedSet","links","name","schema","exec","remove","result","Number","changes","store","extracted","targets","stale","t","insert","afterReconcile","removeHadLinks","size","churn","large","dstChanged","linksChanged"],"mappings":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/links.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport { maskRegions } from '../fences.ts';\nimport type { FileStat } from '../scan.ts';\nimport type { Feature, ReconcileDelta } from './types.ts';\n\n// links(src, target, target_base, dst, embed): target as written, target_base its baseKey\n// (indexed, for the incremental resolve below), dst the resolved path or NULL (a\n// queryable dead link), embed whether it's `![[x]]`/`![](x.md)` rather than `[[x]]`/`[](x.md)`\n// -- Obsidian's grain: a target both linked and embedded in the same note is two rows.\n\n// Wikilinks ([[target]], [[target#anchor|alias]], embeds), internal markdown links, and\n// frontmatter values that are exactly a wikilink (\"[[X]]\"; mid-string and ![[...]] forms are\n// not links there -- Obsidian's rule, probe-verified).\nfunction extract(_raw: string, rawBody: string, _search?: { title: string; summary: string }, data?: Record<string, unknown>): Array<{ target: string; embed: boolean }> {\n const body = /\\[\\[|\\]\\(/.test(rawBody) ? maskRegions(rawBody) : rawBody;\n const seen = new Set<string>();\n const results: Array<{ target: string; embed: boolean }> = [];\n const add = (target: string, embed: boolean) => {\n const key = `${target}\\0${embed ? '1' : '0'}`;\n if (seen.has(key)) return;\n seen.add(key);\n results.push({ target, embed });\n };\n // To the first ]], as Obsidian parses it, so a heading or alias holding a lone ] still\n // matches; anchor and alias split off in code, since a class-based regex stops at the ].\n for (const m of body.matchAll(/\\[\\[(.*?)\\]\\]/g)) {\n const embed = body[(m.index ?? 0) - 1] === '!';\n // [[#Heading]]: a same-note anchor link, which Obsidian resolves to a self-edge. Keep the\n // target as written (leading #) so resolveTarget can special-case it.\n const beforeAlias = m[1].split('|')[0].trim();\n if (beforeAlias.startsWith('#')) {\n if (beforeAlias.length > 1) add(beforeAlias, embed);\n continue;\n }\n const target = beforeAlias.split('#')[0].trim();\n if (target) add(target, embed);\n }\n // Any internal destination, not only .md-suffixed: Obsidian gives markdown links full\n // linkpath resolution, so \\](Zektor) resolves like [[Zektor]] and \\](#anchor) is a\n // self-edge. External URLs (a scheme anywhere survives the malformed double-paren case)\n // and titled links (dest stops at whitespace) are skipped.\n // A space in the destination is only valid ahead of a quoted title; a domain-shaped first\n // segment (www.example.com/...) is external even without a scheme.\n for (const m of body.matchAll(/(!)?\\[[^\\]]*\\]\\(((?:[^()\\s]|\\([^()\\s]*\\))+)(?:\\s+(?:\"[^)]*\"|'[^)]*'))?\\)/g)) {\n const dest = m[2].trim();\n if (!dest || dest.includes('://')) continue;\n if (/^www\\./i.test(dest)) continue;\n const target = dest.startsWith('#') ? dest : dest.split('#')[0];\n if (target) add(target, m[1] === '!');\n }\n for (const value of Object.values(data ?? {})) {\n for (const item of Array.isArray(value) ? value : [value]) {\n if (typeof item !== 'string') continue;\n const m = /^\\[\\[(.*)\\]\\]$/.exec(item.trim());\n if (!m) continue;\n const inner = m[1].split('|')[0].trim();\n const target = inner.split('#')[0].trim();\n if (target) add(target, false);\n else if (inner.startsWith('#') && inner.length > 1) add(inner, false);\n }\n }\n return results;\n}\n\nfunction cleanTarget(target: string): string {\n return target.replace(/\\\\/g, '/').replace(/^\\.\\//, '');\n}\n\nfunction baseKey(path: string): string {\n return posix.basename(path).replace(/\\.md$/i, '').toLowerCase();\n}\n\n// Obsidian-style: exact relative path (with/without .md), path relative to the linking\n// note's directory, then basename match (shortest path wins on ties -- verified against a\n// cold-loaded Obsidian vault on 6+ real collision pairs; byBase's lists are pre-sorted that way).\nfunction resolveTarget(src: string, target: string, pathSet: Set<string>, byBase: Map<string, string[]>): string | null {\n // [[#Heading]]: a same-note anchor, always the note itself -- never depends on other files.\n if (target.startsWith('#')) return src;\n const clean = cleanTarget(target);\n const fromSrc = posix.normalize(posix.join(posix.dirname(src), clean));\n for (const candidate of [clean, `${clean}.md`, fromSrc, `${fromSrc}.md`]) {\n if (pathSet.has(candidate)) return candidate;\n }\n const candidates = byBase.get(baseKey(clean));\n if (!candidates) return null;\n // The linking note itself wins a basename collision (Obsidian resolves to self before the\n // shortest-path rule -- cold-load verified on the hub corpus).\n return candidates.includes(src) ? src : candidates[0];\n}\n\n// Shortest path wins a basename collision; equal lengths fall back to lexicographic, our own\n// deterministic tiebreak for a case Obsidian itself leaves registration-order-dependent.\nfunction buildByBase(files: FileStat[]): Map<string, string[]> {\n const byBase = new Map<string, string[]>();\n const paths = files.map((f) => f.relPath).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));\n for (const path of paths) {\n const key = baseKey(path);\n const list = byBase.get(key);\n if (list) list.push(path);\n else byBase.set(key, [path]);\n }\n return byBase;\n}\n\ninterface LinkRow {\n src: string;\n target: string;\n dst: string | null;\n embed: number;\n}\n\n// Applies resolveTarget to `rows`, writing only rows whose dst actually changes. The UPDATE\n// keys on (src, target): a link/embed sibling pair shares one resolution, so either row's\n// mismatch rewrites both.\nfunction resolveRows(db: DatabaseSync, rows: LinkRow[], pathSet: Set<string>, byBase: Map<string, string[]>): boolean {\n const update = db.prepare('UPDATE links SET dst = ? WHERE src = ? AND target = ?');\n let changed = false;\n for (const row of rows) {\n const dst = resolveTarget(row.src, row.target, pathSet, byBase);\n if (dst !== row.dst) {\n update.run(dst, row.src, row.target);\n changed = true;\n }\n }\n return changed;\n}\n\n// A new or deleted file can change any note's resolution, so re-resolve the whole table.\n// Fallback for cold builds and large deltas -- see afterReconcile's threshold.\nfunction resolveAll(db: DatabaseSync, files: FileStat[]): boolean {\n const pathSet = new Set(files.map((f) => f.relPath));\n const byBase = buildByBase(files);\n const rows = db.prepare('SELECT src, target, dst, embed FROM links').all() as unknown as LinkRow[];\n return resolveRows(db, rows, pathSet, byBase);\n}\n\n// Re-resolve only what this reconcile could have touched: re-stored sources, links whose\n// target basename matches an added/vanished file, and links whose dst just vanished. All\n// three are indexed lookups, so this never reads the whole table.\nfunction resolveIncremental(db: DatabaseSync, delta: ReconcileDelta): boolean {\n const pathSet = new Set(delta.files.map((f) => f.relPath));\n const byBase = buildByBase(delta.files);\n\n const changedBasenames = new Set<string>();\n for (const p of delta.added) changedBasenames.add(baseKey(p));\n for (const p of delta.vanished) changedBasenames.add(baseKey(p));\n\n // \\0 can't appear in a path or target, so the key never collides; a space can.\n const candidates = new Map<string, LinkRow>();\n const collect = (rows: LinkRow[]) => {\n for (const row of rows) candidates.set(`${row.src}\\0${row.target}\\0${row.embed}`, row);\n };\n // Chunked so a large-but-under-threshold delta never exceeds SQLite's bound-variable\n // limit (SQLITE_MAX_VARIABLE_NUMBER, 32766).\n const collectIn = (column: string, keys: string[]) => {\n for (let i = 0; i < keys.length; i += 500) {\n const chunk = keys.slice(i, i + 500);\n const placeholders = chunk.map(() => '?').join(', ');\n collect(db.prepare(`SELECT src, target, dst, embed FROM links WHERE ${column} IN (${placeholders})`).all(...chunk) as unknown as LinkRow[]);\n }\n };\n\n collectIn('src', delta.reparsed);\n collectIn('target_base', [...changedBasenames]);\n collectIn('dst', delta.vanished);\n\n return resolveRows(db, [...candidates.values()], pathSet, byBase);\n}\n\n// Resolved edges, for rank and for search's graph expansion.\nexport function linkEdges(db: DatabaseSync): [string, string][] {\n // One edge per row, as 0.12's grain always had it -- two written targets resolving to one\n // dst stay two edges (a weight the fusion evals were gated on). The only exclusion is an\n // embed whose exact (src, target) also exists as a link: that second row is new with the\n // embed grain and would double an edge that used to be one row. The NOT EXISTS probes the\n // primary key and fires only for embed rows; both branches still scan the table.\n // Self-edges (same-note anchors) are excluded here so PageRank mass is not self-recycled --\n // Obsidian's own graph view hides self-loops too. The rows themselves stay in the table for\n // backlinks/peek.\n const sql = `SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0 AND src != dst\n UNION ALL\n SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1 AND src != dst\n AND NOT EXISTS (SELECT 1 FROM links l0 WHERE l0.src = l.src AND l0.target = l.target AND l0.embed = 0)`;\n return (db.prepare(sql).all() as Array<{ src: string; dst: string }>).map((r) => [r.src, r.dst]);\n}\n\n// remove() has already deleted the rows by the time afterReconcile runs, so it records here\n// whether they carried edges. Keyed on the delta object, so the state dies with the reconcile.\nconst removedWithLinks = new WeakMap<ReconcileDelta, Set<string>>();\n\nfunction recordRemoved(delta: ReconcileDelta, path: string): void {\n let set = removedWithLinks.get(delta);\n if (!set) {\n set = new Set();\n removedWithLinks.set(delta, set);\n }\n set.add(path);\n}\n\n// remove() runs per path; the vanished list is an array, so cache the Set per delta.\nconst vanishedSets = new WeakMap<ReconcileDelta, Set<string>>();\nfunction vanishedSet(delta: ReconcileDelta): Set<string> {\n let set = vanishedSets.get(delta);\n if (!set) {\n set = new Set(delta.vanished);\n vanishedSets.set(delta, set);\n }\n return set;\n}\n\nexport const links: Feature = {\n name: 'links',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS links (src TEXT, target TEXT, target_base TEXT, dst TEXT, embed INTEGER, PRIMARY KEY (src, target, embed))');\n db.exec('CREATE INDEX IF NOT EXISTS links_dst ON links(dst)');\n db.exec('CREATE INDEX IF NOT EXISTS links_target_base ON links(target_base)');\n },\n extract,\n // Reparsed docs are diffed in store() below instead of wiped here: deleting and\n // re-inserting resets dst to NULL, which made every touch-only reparse read as an edge\n // change and recompute PageRank -- measured as most of the remaining update cost.\n remove(db, path, delta) {\n if (!vanishedSet(delta).has(path)) return;\n const result = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n if (Number(result.changes) > 0) recordRemoved(delta, path);\n },\n store(db, path, extracted, delta) {\n const targets = extracted as Array<{ target: string; embed: boolean }>;\n // Stale rows (target/embed pairs the new parse no longer contains) are real edge removals.\n // \\0 can't appear in a target, so the composite key never collides.\n let stale: ReturnType<ReturnType<DatabaseSync['prepare']>['run']>;\n if (targets.length === 0) {\n stale = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n } else {\n const placeholders = targets.map(() => '?').join(', ');\n const keys = targets.map((t) => `${t.target}\\0${t.embed ? '1' : '0'}`);\n stale = db.prepare(`DELETE FROM links WHERE src = ? AND (target || char(0) || embed) NOT IN (${placeholders})`).run(path, ...keys);\n }\n if (Number(stale.changes) > 0) recordRemoved(delta, path);\n // Upsert preserves dst on surviving rows, so an unchanged link resolves to the same\n // value and reports no change; only genuinely new rows start at NULL.\n const insert = db.prepare('INSERT INTO links (src, target, target_base, dst, embed) VALUES (?, ?, ?, NULL, ?) ON CONFLICT(src, target, embed) DO UPDATE SET target_base = excluded.target_base');\n // A same-note anchor's dst is always its own src, never another file's basename, so it\n // gets no target_base -- SQL's `IN` never matches NULL, keeping it out of\n // resolveIncremental's cross-file collectIn('target_base', ...) entirely.\n for (const { target, embed } of targets) insert.run(path, target, target.startsWith('#') ? null : baseKey(cleanTarget(target)), embed ? 1 : 0);\n },\n afterReconcile(db, delta) {\n // Any deleted rows that carried edges mean the edge set shrank -- whether the file\n // vanished or was reparsed down to fewer/no links. dstChanged alone misses the\n // reparsed-to-zero-links case (no surviving rows to re-resolve).\n const removeHadLinks = (removedWithLinks.get(delta)?.size ?? 0) > 0;\n\n const churn = delta.reparsed.length + delta.vanished.length;\n // Past this share of the tree, a full pass is the only one guaranteed to match\n // resolveTarget's ambiguity rules. A cold build clears the threshold on its own.\n const large = delta.files.length === 0 || churn > 0.2 * delta.files.length;\n\n const dstChanged = large ? resolveAll(db, delta.files) : resolveIncremental(db, delta);\n delta.linksChanged = dstChanged || removeHadLinks;\n },\n};\n"],"names":["posix","maskRegions","extract","_raw","rawBody","_search","data","body","test","seen","Set","results","add","target","embed","key","has","push","m","matchAll","index","beforeAlias","split","trim","startsWith","length","dest","includes","value","Object","values","item","Array","isArray","exec","inner","cleanTarget","replace","baseKey","path","basename","toLowerCase","resolveTarget","src","pathSet","byBase","clean","fromSrc","normalize","join","dirname","candidate","candidates","get","buildByBase","files","Map","paths","map","f","relPath","sort","a","b","list","set","resolveRows","db","rows","update","prepare","changed","row","dst","run","resolveAll","all","resolveIncremental","delta","changedBasenames","p","added","vanished","collect","collectIn","column","keys","i","chunk","slice","placeholders","reparsed","linkEdges","sql","r","removedWithLinks","WeakMap","recordRemoved","vanishedSets","vanishedSet","links","name","schema","remove","result","Number","changes","store","extracted","targets","stale","t","insert","afterReconcile","removeHadLinks","size","churn","large","dstChanged","linksChanged"],"mappings":"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"}
@@ -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,14 +1,92 @@
1
+ import { commentTracker, fenceTracker, maskCodeSpans } 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;
6
5
  // Obsidian treats [[#Heading]] as a same-note link, not a tag.
7
6
  const WIKILINK_RE = /\[\[.*?\]\]/g; // to the first ]], so a heading holding a lone ] still masks
8
7
  // Obsidian doesn't read tags inside HTML markup.
9
8
  const HTML_TAG_RE = /<\/?[a-zA-Z][^>]*>/g; // tag-shaped only: a comparison's `< 5` must not open a span
10
9
  // Anchors on start-of-line or a preceding whitespace/(/[ so `a#b` and URL fragments don't count.
11
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]|\/?>|$)/;
12
90
  // Strips a leading # (frontmatter entries may carry one) and a trailing /; rejects an
13
91
  // all-digit result -- a tag needs at least one non-digit character.
14
92
  function normalizeTag(raw) {
@@ -31,19 +109,59 @@ function frontmatterTags(data) {
31
109
  }
32
110
  return found;
33
111
  }
34
- // #tag tokens outside fenced code blocks, inline code spans, wikilinks, and HTML tags.
112
+ // #tag tokens outside fenced code blocks, inline code spans, wikilinks, HTML tags, HTML blocks,
113
+ // <!-- --> comments, and link destinations.
35
114
  function inlineTags(body) {
36
115
  const found = [];
37
- let inFence = false;
116
+ const fence = fenceTracker();
117
+ const comment = commentTracker();
118
+ let inHtmlBlock = false;
119
+ let htmlBlockClose = null; // set while inside a type-1 (script/pre/style/textarea) block
38
120
  for (const line of body.split('\n')){
39
- if (FENCE_RE.test(line)) {
40
- inFence = !inFence;
121
+ if (inHtmlBlock) {
122
+ // A fence-like line here is still HTML-block content -- the block wins until it closes.
123
+ if (htmlBlockClose) {
124
+ if (htmlBlockClose.test(line)) {
125
+ inHtmlBlock = false;
126
+ htmlBlockClose = null;
127
+ }
128
+ } else if (/^[ \t>]*$/.test(line)) {
129
+ inHtmlBlock = false;
130
+ }
41
131
  continue;
42
132
  }
43
- if (inFence) continue;
44
- if (!line.includes('#')) continue; // most lines; skip the regex work
45
- let cleaned = line.includes('`') ? line.replace(INLINE_CODE_RE, (m)=>' '.repeat(m.length)) : line;
133
+ if (!comment.inComment) {
134
+ if (fence.feed(line)) continue;
135
+ if (fence.inFence) continue;
136
+ }
137
+ // Obsidian parses nothing inside a <!-- --> comment; mask its span (which may open, close,
138
+ // or run the whole line) before any other check sees this line's text. Skipped when there
139
+ // is no comment marker anywhere near this line -- most lines, so worth the branch.
140
+ const masked = comment.inComment || line.includes('<!--') ? comment.mask(line) : line;
141
+ // Indented or blockquoted HTML blocks still swallow their content in Obsidian, so the
142
+ // opener test runs after stripping leading whitespace and > markers.
143
+ const stripped = masked.replace(/^[ \t>]*/, '');
144
+ if (stripped[0] === '<') {
145
+ const openMatch = HTML_BLOCK_OPEN_RE.exec(stripped);
146
+ if (openMatch) {
147
+ const tagName = openMatch[1].toLowerCase();
148
+ const isClosingTag = stripped[1] === '/';
149
+ if (!isClosingTag && HTML_PRE_TAGS.has(tagName)) {
150
+ inHtmlBlock = true;
151
+ // Any of the four type-1 closers ends the block, not only the tag that opened it.
152
+ htmlBlockClose = /<\/(?:script|pre|style|textarea)>/i;
153
+ continue;
154
+ }
155
+ if (HTML_BLOCK_TAGS.has(tagName)) {
156
+ inHtmlBlock = true;
157
+ continue;
158
+ }
159
+ }
160
+ }
161
+ if (!masked.includes('#')) continue; // most lines; skip the regex work
162
+ let cleaned = masked.includes('`') ? maskCodeSpans(masked) : masked;
46
163
  if (cleaned.includes('[[')) cleaned = cleaned.replace(WIKILINK_RE, (m)=>' '.repeat(m.length));
164
+ if (cleaned.includes('](')) cleaned = cleaned.replace(LINK_DEST_RE, (m)=>' '.repeat(m.length));
47
165
  if (cleaned.includes('<')) cleaned = cleaned.replace(HTML_TAG_RE, (m)=>' '.repeat(m.length));
48
166
  for (const m of cleaned.matchAll(INLINE_TAG_RE)){
49
167
  const tag = normalizeTag(m[1]);
@@ -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// 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\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, inline code spans, wikilinks, and HTML tags.\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 let cleaned = line.includes('`') ? line.replace(INLINE_CODE_RE, (m) => ' '.repeat(m.length)) : line;\n if (cleaned.includes('[[')) cleaned = cleaned.replace(WIKILINK_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":["FENCE_RE","INLINE_CODE_RE","WIKILINK_RE","HTML_TAG_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":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/tags.ts"],"sourcesContent":["import { commentTracker, fenceTracker, maskCodeSpans } 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// #tag tokens outside fenced code blocks, inline code spans, wikilinks, HTML tags, HTML blocks,\n// <!-- --> comments, and link destinations.\nfunction inlineTags(body: string): string[] {\n const found: string[] = [];\n const fence = fenceTracker();\n const comment = commentTracker();\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 (!comment.inComment) {\n if (fence.feed(line)) continue;\n if (fence.inFence) continue;\n }\n // Obsidian parses nothing inside a <!-- --> comment; mask its span (which may open, close,\n // or run the whole line) before any other check sees this line's text. Skipped when there\n // is no comment marker anywhere near this line -- most lines, so worth the branch.\n const masked = comment.inComment || line.includes('<!--') ? comment.mask(line) : line;\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 = masked.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 (!masked.includes('#')) continue; // most lines; skip the regex work\n let cleaned = masked.includes('`') ? maskCodeSpans(masked) : masked;\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":["commentTracker","fenceTracker","maskCodeSpans","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","inlineTags","body","fence","comment","inHtmlBlock","htmlBlockClose","line","split","inComment","feed","inFence","masked","includes","mask","openMatch","exec","tagName","toLowerCase","isClosingTag","has","cleaned","m","repeat","length","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,12 @@
1
+ export interface FenceTracker {
2
+ feed(line: string): boolean;
3
+ readonly inFence: boolean;
4
+ }
5
+ export declare function fenceTracker(): FenceTracker;
6
+ export interface CommentTracker {
7
+ mask(line: string): string;
8
+ readonly inComment: boolean;
9
+ }
10
+ export declare function commentTracker(): CommentTracker;
11
+ export declare function maskRegions(body: string): string;
12
+ export declare function maskCodeSpans(line: string): string;