sensemaking 0.15.0 → 0.15.2

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.
@@ -1 +1 @@
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":["tags","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","list","Array","isArray","found","item","tag","push","maskCodeSpans","line","out","i","length","j","n","k","closeStart","closeEnd","m","repeat","slice","inlineTags","body","fence","fenceTracker","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":";;;;+BAqNaA;;;eAAAA;;;wBArNgB;;;;;;;;;;;;;;;;;;;;;;;;;;AAG7B,0FAA0F;AAC1F,uFAAuF;AACvF,4FAA4F;AAE5F,+DAA+D;AAC/D,IAAMC,cAAc,gBAAgB,6DAA6D;AACjG,iDAAiD;AACjD,IAAMC,cAAc,uBAAuB,6DAA6D;AACxG,iGAAiG;AACjG,IAAMC,gBAAgB;AACtB,4FAA4F;AAC5F,IAAMC,eAAe,gCAAgC,oFAAoF;AAEzI,8FAA8F;AAC9F,+FAA+F;AAC/F,iFAAiF;AACjF,IAAMC,kBAAkB,IAAIC,IAAI;IAC9B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AACD,8FAA8F;AAC9F,8DAA8D;AAC9D,IAAMC,gBAAgB,IAAID,IAAI;IAAC;IAAU;IAAO;IAAS;CAAW;AACpE,kFAAkF;AAClF,IAAME,qBAAqB;AAE3B,sFAAsF;AACtF,oEAAoE;AACpE,SAASC,aAAaC,GAAW;IAC/B,IAAMC,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,IAAML,MAAMK,iBAAAA,2BAAAA,KAAMf,IAAI;IACtB,IAAMgB,OAAOC,MAAMC,OAAO,CAACR,OAAOA,MAAM,OAAOA,QAAQ,WAAW;QAACA;KAAI,GAAG,EAAE;IAC5E,IAAMS,QAAkB,EAAE;QACrB,kCAAA,2BAAA;;QAAL,QAAK,YAAcH,yBAAd,SAAA,6BAAA,QAAA,yBAAA,iCAAoB;YAApB,IAAMI,OAAN;YACH,IAAI,OAAOA,SAAS,UAAU;YAC9B,IAAMC,MAAMZ,aAAaW;YACzB,IAAIC,KAAKF,MAAMG,IAAI,CAACD;QACtB;;QAJK;QAAA;;;iBAAA,6BAAA;gBAAA;;;gBAAA;sBAAA;;;;IAKL,OAAOF;AACT;AAEA,yFAAyF;AACzF,6FAA6F;AAC7F,qFAAqF;AACrF,SAASI,cAAcC,IAAY;IACjC,IAAIC,MAAM;IACV,IAAIC,IAAI;IACR,MAAOA,IAAIF,KAAKG,MAAM,CAAE;QACtB,IAAIH,IAAI,CAACE,EAAE,KAAK,KAAK;YACnBD,OAAOD,IAAI,CAACE,EAAE;YACdA;YACA;QACF;QACA,IAAIE,IAAIF;QACR,MAAOF,IAAI,CAACI,EAAE,KAAK,IAAKA;QACxB,IAAMC,IAAID,IAAIF;QACd,IAAII,IAAIF;QACR,IAAIG,aAAa,CAAC;QAClB,IAAIC,WAAW,CAAC;QAChB,MAAOF,IAAIN,KAAKG,MAAM,CAAE;YACtB,IAAIH,IAAI,CAACM,EAAE,KAAK,KAAK;gBACnBA;gBACA;YACF;YACA,IAAIG,IAAIH;YACR,MAAON,IAAI,CAACS,EAAE,KAAK,IAAKA;YACxB,IAAIA,IAAIH,MAAMD,GAAG;gBACfE,aAAaD;gBACbE,WAAWC;gBACX;YACF;YACAH,IAAIG;QACN;QACA,IAAIF,cAAc,GAAG;YACnBN,OAAO,IAAIS,MAAM,CAACF,WAAWN;YAC7BA,IAAIM;QACN,OAAO;YACLP,OAAOD,KAAKW,KAAK,CAACT,GAAGE;YACrBF,IAAIE;QACN;IACF;IACA,OAAOH;AACT;AAEA,gGAAgG;AAChG,yBAAyB;AACzB,SAASW,WAAWC,IAAY;IAC9B,IAAMlB,QAAkB,EAAE;IAC1B,IAAMmB,QAAQC,IAAAA,sBAAY;IAC1B,IAAIC,cAAc;IAClB,IAAIC,iBAAgC,MAAM,8DAA8D;QACnG,kCAAA,2BAAA;;QAAL,QAAK,YAAcJ,KAAKK,KAAK,CAAC,0BAAzB,SAAA,6BAAA,QAAA,yBAAA,iCAAgC;YAAhC,IAAMlB,OAAN;YACH,IAAIgB,aAAa;gBACf,wFAAwF;gBACxF,IAAIC,gBAAgB;oBAClB,IAAIA,eAAe5B,IAAI,CAACW,OAAO;wBAC7BgB,cAAc;wBACdC,iBAAiB;oBACnB;gBACF,OAAO,IAAI,YAAY5B,IAAI,CAACW,OAAO;oBACjCgB,cAAc;gBAChB;gBACA;YACF;YACA,IAAIF,MAAMK,IAAI,CAACnB,OAAO;YACtB,IAAIc,MAAMM,OAAO,EAAE;YACnB,sFAAsF;YACtF,qEAAqE;YACrE,IAAMjC,WAAWa,KAAKZ,OAAO,CAAC,YAAY;YAC1C,IAAID,QAAQ,CAAC,EAAE,KAAK,KAAK;gBACvB,IAAMkC,YAAYrC,mBAAmBsC,IAAI,CAACnC;gBAC1C,IAAIkC,WAAW;oBACb,IAAME,UAAUF,SAAS,CAAC,EAAE,CAACG,WAAW;oBACxC,IAAMC,eAAetC,QAAQ,CAAC,EAAE,KAAK;oBACrC,IAAI,CAACsC,gBAAgB1C,cAAc2C,GAAG,CAACH,UAAU;wBAC/CP,cAAc;wBACd,kFAAkF;wBAClFC,iBAAiB;wBACjB;oBACF;oBACA,IAAIpC,gBAAgB6C,GAAG,CAACH,UAAU;wBAChCP,cAAc;wBACd;oBACF;gBACF;YACF;YACA,IAAI,CAAChB,KAAK2B,QAAQ,CAAC,MAAM,UAAU,kCAAkC;YACrE,IAAIC,UAAU5B,KAAK2B,QAAQ,CAAC,OAAO5B,cAAcC,QAAQA;YACzD,IAAI4B,QAAQD,QAAQ,CAAC,OAAOC,UAAUA,QAAQxC,OAAO,CAACX,aAAa,SAACgC;uBAAM,IAAIC,MAAM,CAACD,EAAEN,MAAM;;YAC7F,IAAIyB,QAAQD,QAAQ,CAAC,OAAOC,UAAUA,QAAQxC,OAAO,CAACR,cAAc,SAAC6B;uBAAM,IAAIC,MAAM,CAACD,EAAEN,MAAM;;YAC9F,IAAIyB,QAAQD,QAAQ,CAAC,MAAMC,UAAUA,QAAQxC,OAAO,CAACV,aAAa,SAAC+B;uBAAM,IAAIC,MAAM,CAACD,EAAEN,MAAM;;gBACvF,mCAAA,4BAAA;;gBAAL,QAAK,aAAWyB,QAAQC,QAAQ,CAAClD,mCAA5B,UAAA,8BAAA,SAAA,0BAAA,kCAA4C;oBAA5C,IAAM8B,IAAN;oBACH,IAAMZ,MAAMZ,aAAawB,CAAC,CAAC,EAAE;oBAC7B,IAAIZ,KAAKF,MAAMG,IAAI,CAACD;gBACtB;;gBAHK;gBAAA;;;yBAAA,8BAAA;wBAAA;;;wBAAA;8BAAA;;;;QAIP;;QA5CK;QAAA;;;iBAAA,6BAAA;gBAAA;;;gBAAA;sBAAA;;;;IA6CL,OAAOF;AACT;AAEA,SAASmC,QAAQC,IAAY,EAAElB,IAAY,EAAEmB,OAA4C,EAAEzC,IAA8B;IACvH,OAAO,AAAC,qBAAG,IAAIT,IAAI,AAAC,qBAAGQ,gBAAgBC,cAAO,qBAAGqB,WAAWC,UAASoB,IAAI;AAC3E;AAEO,IAAMzD,OAAgB;IAC3B0D,MAAM;IACNC,QAAAA,SAAAA,OAAOC,EAAE;QACPA,GAAGd,IAAI,CAAC;QACRc,GAAGd,IAAI,CAAC;IACV;IACAQ,SAAAA;IACA,2FAA2F;IAC3F,uDAAuD;IACvDO,QAAAA,SAAAA,OAAOD,EAAE,EAAEE,IAAI,EAAEC,KAAK;QACpB,IAAI,CAACA,MAAMC,QAAQ,CAACb,QAAQ,CAACW,OAAO;QACpCF,GAAGK,OAAO,CAAC,qCAAqCC,GAAG,CAACJ;IACtD;IACAK,OAAAA,SAAAA,MAAMP,EAAE,EAAEE,IAAI,EAAEM,SAAS;QACvB,IAAMjD,QAAQiD;QACd,IAAIjD,MAAMQ,MAAM,KAAK,GAAG;YACtBiC,GAAGK,OAAO,CAAC,qCAAqCC,GAAG,CAACJ;QACtD,OAAO;gBAELF;YADA,IAAMS,eAAelD,MAAMmD,GAAG,CAAC;uBAAM;eAAKC,IAAI,CAAC;YAC/CX,CAAAA,cAAAA,GAAGK,OAAO,CAAC,AAAC,qDAAiE,OAAbI,cAAa,OAAIH,GAAG,OAApFN,aAAAA;gBAAqFE;aAAe,CAApGF,OAA2F,qBAAGzC;QAChG;QACA,IAAMqD,SAASZ,GAAGK,OAAO,CAAC;YACrB,kCAAA,2BAAA;;YAAL,QAAK,YAAa9C,0BAAb,SAAA,6BAAA,QAAA,yBAAA;gBAAA,IAAME,MAAN;gBAAoBmD,OAAON,GAAG,CAACJ,MAAMzC;;;YAArC;YAAA;;;qBAAA,6BAAA;oBAAA;;;oBAAA;0BAAA;;;;IACP;AACF"}
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":["tags","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","list","Array","isArray","found","item","tag","push","inlineTags","body","fence","fenceTracker","comment","commentTracker","inHtmlBlock","htmlBlockClose","split","line","inComment","feed","inFence","masked","includes","mask","openMatch","exec","tagName","toLowerCase","isClosingTag","has","cleaned","maskCodeSpans","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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"}
@@ -3,3 +3,10 @@ export interface FenceTracker {
3
3
  readonly inFence: boolean;
4
4
  }
5
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;
@@ -3,3 +3,10 @@ export interface FenceTracker {
3
3
  readonly inFence: boolean;
4
4
  }
5
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;
@@ -7,10 +7,24 @@
7
7
  Object.defineProperty(exports, "__esModule", {
8
8
  value: true
9
9
  });
10
- Object.defineProperty(exports, "fenceTracker", {
11
- enumerable: true,
12
- get: function() {
10
+ function _export(target, all) {
11
+ for(var name in all)Object.defineProperty(target, name, {
12
+ enumerable: true,
13
+ get: Object.getOwnPropertyDescriptor(all, name).get
14
+ });
15
+ }
16
+ _export(exports, {
17
+ get commentTracker () {
18
+ return commentTracker;
19
+ },
20
+ get fenceTracker () {
13
21
  return fenceTracker;
22
+ },
23
+ get maskCodeSpans () {
24
+ return maskCodeSpans;
25
+ },
26
+ get maskRegions () {
27
+ return maskRegions;
14
28
  }
15
29
  });
16
30
  var BACKTICK_OPEN = /^(`{3,})(.*)$/;
@@ -51,4 +65,93 @@ function fenceTracker() {
51
65
  }
52
66
  };
53
67
  }
68
+ function commentTracker() {
69
+ var inComment = false;
70
+ return {
71
+ mask: function mask(line) {
72
+ var out = '';
73
+ var i = 0;
74
+ while(i < line.length){
75
+ if (inComment) {
76
+ var end = line.indexOf('-->', i);
77
+ if (end === -1) {
78
+ out += ' '.repeat(line.length - i);
79
+ i = line.length;
80
+ } else {
81
+ out += ' '.repeat(end + 3 - i);
82
+ i = end + 3;
83
+ inComment = false;
84
+ }
85
+ continue;
86
+ }
87
+ var start = line.indexOf('<!--', i);
88
+ if (start === -1) {
89
+ out += line.slice(i);
90
+ i = line.length;
91
+ } else {
92
+ out += "".concat(line.slice(i, start), " ");
93
+ i = start + 4;
94
+ inComment = true;
95
+ }
96
+ }
97
+ return out;
98
+ },
99
+ get inComment () {
100
+ return inComment;
101
+ }
102
+ };
103
+ }
104
+ function maskRegions(body) {
105
+ if (!/[`~]/.test(body) && !body.includes('<!--')) return body;
106
+ var fence = fenceTracker();
107
+ var comment = commentTracker();
108
+ return body.split('\n').map(function(line) {
109
+ if (!comment.inComment) {
110
+ var isDelim = fence.feed(line);
111
+ if (isDelim || fence.inFence) return ' '.repeat(line.length);
112
+ }
113
+ var uncommented = comment.mask(line);
114
+ // Inline code spans hide their content too: `[[x]]` in prose is not a link.
115
+ return uncommented.includes('`') ? maskCodeSpans(uncommented) : uncommented;
116
+ }).join('\n');
117
+ }
118
+ function maskCodeSpans(line) {
119
+ var out = '';
120
+ var i = 0;
121
+ while(i < line.length){
122
+ if (line[i] !== '`') {
123
+ out += line[i];
124
+ i++;
125
+ continue;
126
+ }
127
+ var j = i;
128
+ while(line[j] === '`')j++;
129
+ var n = j - i;
130
+ var k = j;
131
+ var closeStart = -1;
132
+ var closeEnd = -1;
133
+ while(k < line.length){
134
+ if (line[k] !== '`') {
135
+ k++;
136
+ continue;
137
+ }
138
+ var m = k;
139
+ while(line[m] === '`')m++;
140
+ if (m - k === n) {
141
+ closeStart = k;
142
+ closeEnd = m;
143
+ break;
144
+ }
145
+ k = m;
146
+ }
147
+ if (closeStart >= 0) {
148
+ out += ' '.repeat(closeEnd - i);
149
+ i = closeEnd;
150
+ } else {
151
+ out += line.slice(i, j);
152
+ i = j;
153
+ }
154
+ }
155
+ return out;
156
+ }
54
157
  /* CJS INTEROP */ if (exports.__esModule && exports.default) { try { Object.defineProperty(exports.default, '__esModule', { value: true }); for (var key in exports) { exports.default[key] = exports[key]; } } catch (_) {}; module.exports = exports.default; }
@@ -1 +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":["fenceTracker","BACKTICK_OPEN","TILDE_OPEN","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;;;;;+BAU/CA;;;eAAAA;;;AAHhB,IAAMC,gBAAgB;AACtB,IAAMC,aAAa;AAEZ,SAASF;IACd,IAAIG,UAAU;IACd,IAAIC,YAAY;IAChB,IAAIC,WAAW;IACf,OAAO;QACLC,MAAAA,SAAAA,KAAKC,IAAY;YACf,IAAI,CAACJ,SAAS;gBACZ,IAAMK,KAAKP,cAAcQ,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,IAAMC,KAAKV,WAAWO,IAAI,CAACF;gBAC3B,IAAIK,IAAI;oBACNT,UAAU;oBACVC,YAAY;oBACZC,WAAWO,EAAE,CAAC,EAAE,CAACD,MAAM;oBACvB,OAAO;gBACT;gBACA,OAAO;YACT;YACA,IAAME,MAAMT,cAAc,MAAM,eAAe;YAC/C,IAAMU,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"}
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\n// Masks <!-- ... --> spans that may cross line boundaries, one line at a time so callers can\n// interleave it with their own per-line state. An unclosed <!-- masks to end of input, matching\n// CommonMark type-2 HTML comments' practical effect (everything after is swallowed).\nexport interface CommentTracker {\n mask(line: string): string;\n readonly inComment: boolean;\n}\n\nexport function commentTracker(): CommentTracker {\n let inComment = false;\n return {\n mask(line: string): string {\n let out = '';\n let i = 0;\n while (i < line.length) {\n if (inComment) {\n const end = line.indexOf('-->', i);\n if (end === -1) {\n out += ' '.repeat(line.length - i);\n i = line.length;\n } else {\n out += ' '.repeat(end + 3 - i);\n i = end + 3;\n inComment = false;\n }\n continue;\n }\n const start = line.indexOf('<!--', i);\n if (start === -1) {\n out += line.slice(i);\n i = line.length;\n } else {\n out += `${line.slice(i, start)} `;\n i = start + 4;\n inComment = true;\n }\n }\n return out;\n },\n get inComment() {\n return inComment;\n },\n };\n}\n\n// Body with fenced-code regions (delimiter and content lines) and <!-- --> comment spans\n// replaced by spaces, newlines preserved so line numbers and offsets survive. Bodies without a\n// backtick/tilde/`<!--` fast-path out untouched.\nexport function maskRegions(body: string): string {\n if (!/[`~]/.test(body) && !body.includes('<!--')) return body;\n const fence = fenceTracker();\n const comment = commentTracker();\n return body\n .split('\\n')\n .map((line) => {\n if (!comment.inComment) {\n const isDelim = fence.feed(line);\n if (isDelim || fence.inFence) return ' '.repeat(line.length);\n }\n const uncommented = comment.mask(line);\n // Inline code spans hide their content too: `[[x]]` in prose is not a link.\n return uncommented.includes('`') ? maskCodeSpans(uncommented) : uncommented;\n })\n .join('\\n');\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.\nexport function 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"],"names":["commentTracker","fenceTracker","maskCodeSpans","maskRegions","BACKTICK_OPEN","TILDE_OPEN","inFence","fenceChar","fenceLen","feed","line","bt","exec","includes","length","td","run","m","trim","inComment","mask","out","i","end","indexOf","repeat","start","slice","body","test","fence","comment","split","map","isDelim","uncommented","join","j","n","k","closeStart","closeEnd"],"mappings":"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"}
@@ -34,13 +34,13 @@ export function peek(db, cfg, pathArg, overrides = {}) {
34
34
  let backlinksTotal = 0;
35
35
  const _allowed = scopedPaths(db, cfg, overrides);
36
36
  if (featureEnabled(cfg, 'links')) {
37
- const out = db.prepare('SELECT target, dst FROM links WHERE src = ? ORDER BY target').all(path);
37
+ const out = db.prepare('SELECT target, dst FROM links WHERE src = ? AND (dst IS NULL OR dst != src) ORDER BY target').all(path);
38
38
  outbound = [
39
39
  ...new Set(out.filter((l)=>l.dst !== null).map((l)=>l.dst))
40
40
  ];
41
41
  unresolved = out.filter((l)=>l.dst === null).map((l)=>l.target);
42
- backlinksTotal = db.prepare('SELECT COUNT(DISTINCT src) AS n FROM links WHERE dst = ?').get(path).n;
43
- backlinks = db.prepare('SELECT DISTINCT src FROM links WHERE dst = ? ORDER BY src LIMIT ?').all(path, PEEK_LIST_LIMIT).map((r)=>r.src);
42
+ backlinksTotal = db.prepare('SELECT COUNT(DISTINCT src) AS n FROM links WHERE dst = ? AND src != dst').get(path).n;
43
+ backlinks = db.prepare('SELECT DISTINCT src FROM links WHERE dst = ? AND src != dst ORDER BY src LIMIT ?').all(path, PEEK_LIST_LIMIT).map((r)=>r.src);
44
44
  }
45
45
  return {
46
46
  path,
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/commands/peek.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { FeatureName, ResolvedConfig, SearchOverrides } from '../config/index.ts';\nimport { featureEnabled } from '../config/index.ts';\nimport { SenseError } from '../errors.ts';\nimport type { Row } from '../output.ts';\nimport { INTERNAL_COLUMNS, scopedPaths } from './scope.ts';\n\n// Note resolution shared by peek and path: an exact path, or a unique basename (case\n// insensitive, .md stripped).\nexport function resolveNote(paths: string[], arg: string): string {\n const exact = paths.find((p) => p === arg);\n if (exact) return exact;\n const base = posix.basename(arg).replace(/\\.md$/i, '').toLowerCase();\n const matches = paths.filter((p) => posix.basename(p).replace(/\\.md$/i, '').toLowerCase() === base);\n if (matches.length === 1) return matches[0];\n if (matches.length > 1) throw new SenseError('NOTE_AMBIGUOUS', `\"${arg}\" is ambiguous: ${matches.join(', ')}`);\n throw new SenseError('NOTE_NOT_FOUND', `no note matches \"${arg}\"`);\n}\n\nexport interface Peek {\n path: string;\n tokens: number;\n frontmatter: Row;\n // Set when the note's frontmatter was refused, so an empty frontmatter block reads as \"did\n // not parse\" rather than \"has none\". Same reason `_parse_error` sits in the row.\n parseError: string | null;\n sections: Row[];\n outbound: string[];\n backlinks: string[];\n unresolved: string[];\n // Totals before truncation: a hub can have thousands of backlinks (or a note thousands of\n // headings), and peek's whole point is bounded output. Query the sections/links tables\n // directly for the full list.\n sectionsTotal: number;\n outboundTotal: number;\n backlinksTotal: number;\n unresolvedTotal: number;\n // Bounded k-hop expansion beyond the immediate ring already shown by outbound/backlinks\n // (depth starts at 2).\n off: FeatureName[]; // disabled features whose blocks are omitted (not empty)\n}\n\nconst PEEK_LIST_LIMIT = 20;\n\n// peek: everything about one note except its prose -- frontmatter, outline with line\n// ranges + token estimates (so the follow-up Read is a range, not the file), links both ways.\nexport function peek(db: DatabaseSync, cfg: ResolvedConfig, pathArg: string, overrides: SearchOverrides = {}): Peek {\n const paths = (db.prepare('SELECT \"path\" FROM frontmatter').all() as Array<{ path: string }>).map((r) => r.path);\n const path = resolveNote(paths, pathArg);\n\n const row = db.prepare('SELECT * FROM frontmatter WHERE \"path\" = ?').get(path) as Row;\n const parseError = (row._parse_error as string | null) ?? null;\n const frontmatter: Row = {};\n for (const [key, value] of Object.entries(row)) {\n if (!INTERNAL_COLUMNS.has(key) && value !== null) frontmatter[key] = value;\n }\n\n const sectionsTotal = featureEnabled(cfg, 'sections') ? (db.prepare('SELECT COUNT(*) AS n FROM sections WHERE \"path\" = ?').get(path) as { n: number }).n : 0;\n const sections = featureEnabled(cfg, 'sections') ? (db.prepare('SELECT level, heading, start_line, end_line, tokens FROM sections WHERE \"path\" = ? ORDER BY idx LIMIT ?').all(path, PEEK_LIST_LIMIT) as Row[]) : [];\n\n let outbound: string[] = [];\n let backlinks: string[] = [];\n let unresolved: string[] = [];\n let backlinksTotal = 0;\n const _allowed = scopedPaths(db, cfg, overrides);\n if (featureEnabled(cfg, 'links')) {\n const out = db.prepare('SELECT target, dst FROM links WHERE src = ? ORDER BY target').all(path) as Array<{ target: string; dst: string | null }>;\n outbound = [...new Set(out.filter((l) => l.dst !== null).map((l) => l.dst as string))];\n unresolved = out.filter((l) => l.dst === null).map((l) => l.target);\n backlinksTotal = (db.prepare('SELECT COUNT(DISTINCT src) AS n FROM links WHERE dst = ?').get(path) as { n: number }).n;\n backlinks = (db.prepare('SELECT DISTINCT src FROM links WHERE dst = ? ORDER BY src LIMIT ?').all(path, PEEK_LIST_LIMIT) as Array<{ src: string }>).map((r) => r.src);\n }\n\n return {\n path,\n tokens: Math.ceil(((row._size as number) ?? 0) / 4),\n frontmatter,\n parseError,\n sections,\n outbound: outbound.slice(0, PEEK_LIST_LIMIT),\n backlinks,\n unresolved: unresolved.slice(0, PEEK_LIST_LIMIT),\n sectionsTotal,\n outboundTotal: outbound.length,\n backlinksTotal,\n unresolvedTotal: unresolved.length,\n off: (['sections', 'links'] as FeatureName[]).filter((name) => !featureEnabled(cfg, name)),\n };\n}\n"],"names":["posix","featureEnabled","SenseError","INTERNAL_COLUMNS","scopedPaths","resolveNote","paths","arg","exact","find","p","base","basename","replace","toLowerCase","matches","filter","length","join","PEEK_LIST_LIMIT","peek","db","cfg","pathArg","overrides","row","prepare","all","map","r","path","get","parseError","_parse_error","frontmatter","key","value","Object","entries","has","sectionsTotal","n","sections","outbound","backlinks","unresolved","backlinksTotal","_allowed","out","Set","l","dst","target","src","tokens","Math","ceil","_size","slice","outboundTotal","unresolvedTotal","off","name"],"mappings":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/commands/peek.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { FeatureName, ResolvedConfig, SearchOverrides } from '../config/index.ts';\nimport { featureEnabled } from '../config/index.ts';\nimport { SenseError } from '../errors.ts';\nimport type { Row } from '../output.ts';\nimport { INTERNAL_COLUMNS, scopedPaths } from './scope.ts';\n\n// Note resolution shared by peek and path: an exact path, or a unique basename (case\n// insensitive, .md stripped).\nexport function resolveNote(paths: string[], arg: string): string {\n const exact = paths.find((p) => p === arg);\n if (exact) return exact;\n const base = posix.basename(arg).replace(/\\.md$/i, '').toLowerCase();\n const matches = paths.filter((p) => posix.basename(p).replace(/\\.md$/i, '').toLowerCase() === base);\n if (matches.length === 1) return matches[0];\n if (matches.length > 1) throw new SenseError('NOTE_AMBIGUOUS', `\"${arg}\" is ambiguous: ${matches.join(', ')}`);\n throw new SenseError('NOTE_NOT_FOUND', `no note matches \"${arg}\"`);\n}\n\nexport interface Peek {\n path: string;\n tokens: number;\n frontmatter: Row;\n // Set when the note's frontmatter was refused, so an empty frontmatter block reads as \"did\n // not parse\" rather than \"has none\". Same reason `_parse_error` sits in the row.\n parseError: string | null;\n sections: Row[];\n outbound: string[];\n backlinks: string[];\n unresolved: string[];\n // Totals before truncation: a hub can have thousands of backlinks (or a note thousands of\n // headings), and peek's whole point is bounded output. Query the sections/links tables\n // directly for the full list.\n sectionsTotal: number;\n outboundTotal: number;\n backlinksTotal: number;\n unresolvedTotal: number;\n // Bounded k-hop expansion beyond the immediate ring already shown by outbound/backlinks\n // (depth starts at 2).\n off: FeatureName[]; // disabled features whose blocks are omitted (not empty)\n}\n\nconst PEEK_LIST_LIMIT = 20;\n\n// peek: everything about one note except its prose -- frontmatter, outline with line\n// ranges + token estimates (so the follow-up Read is a range, not the file), links both ways.\nexport function peek(db: DatabaseSync, cfg: ResolvedConfig, pathArg: string, overrides: SearchOverrides = {}): Peek {\n const paths = (db.prepare('SELECT \"path\" FROM frontmatter').all() as Array<{ path: string }>).map((r) => r.path);\n const path = resolveNote(paths, pathArg);\n\n const row = db.prepare('SELECT * FROM frontmatter WHERE \"path\" = ?').get(path) as Row;\n const parseError = (row._parse_error as string | null) ?? null;\n const frontmatter: Row = {};\n for (const [key, value] of Object.entries(row)) {\n if (!INTERNAL_COLUMNS.has(key) && value !== null) frontmatter[key] = value;\n }\n\n const sectionsTotal = featureEnabled(cfg, 'sections') ? (db.prepare('SELECT COUNT(*) AS n FROM sections WHERE \"path\" = ?').get(path) as { n: number }).n : 0;\n const sections = featureEnabled(cfg, 'sections') ? (db.prepare('SELECT level, heading, start_line, end_line, tokens FROM sections WHERE \"path\" = ? ORDER BY idx LIMIT ?').all(path, PEEK_LIST_LIMIT) as Row[]) : [];\n\n let outbound: string[] = [];\n let backlinks: string[] = [];\n let unresolved: string[] = [];\n let backlinksTotal = 0;\n const _allowed = scopedPaths(db, cfg, overrides);\n if (featureEnabled(cfg, 'links')) {\n const out = db.prepare('SELECT target, dst FROM links WHERE src = ? AND (dst IS NULL OR dst != src) ORDER BY target').all(path) as Array<{ target: string; dst: string | null }>;\n outbound = [...new Set(out.filter((l) => l.dst !== null).map((l) => l.dst as string))];\n unresolved = out.filter((l) => l.dst === null).map((l) => l.target);\n backlinksTotal = (db.prepare('SELECT COUNT(DISTINCT src) AS n FROM links WHERE dst = ? AND src != dst').get(path) as { n: number }).n;\n backlinks = (db.prepare('SELECT DISTINCT src FROM links WHERE dst = ? AND src != dst ORDER BY src LIMIT ?').all(path, PEEK_LIST_LIMIT) as Array<{ src: string }>).map((r) => r.src);\n }\n\n return {\n path,\n tokens: Math.ceil(((row._size as number) ?? 0) / 4),\n frontmatter,\n parseError,\n sections,\n outbound: outbound.slice(0, PEEK_LIST_LIMIT),\n backlinks,\n unresolved: unresolved.slice(0, PEEK_LIST_LIMIT),\n sectionsTotal,\n outboundTotal: outbound.length,\n backlinksTotal,\n unresolvedTotal: unresolved.length,\n off: (['sections', 'links'] as FeatureName[]).filter((name) => !featureEnabled(cfg, name)),\n };\n}\n"],"names":["posix","featureEnabled","SenseError","INTERNAL_COLUMNS","scopedPaths","resolveNote","paths","arg","exact","find","p","base","basename","replace","toLowerCase","matches","filter","length","join","PEEK_LIST_LIMIT","peek","db","cfg","pathArg","overrides","row","prepare","all","map","r","path","get","parseError","_parse_error","frontmatter","key","value","Object","entries","has","sectionsTotal","n","sections","outbound","backlinks","unresolved","backlinksTotal","_allowed","out","Set","l","dst","target","src","tokens","Math","ceil","_size","slice","outboundTotal","unresolvedTotal","off","name"],"mappings":"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"}
@@ -8,8 +8,8 @@ import { scopedPaths, scopeHasEmbeddings } from './scope.js';
8
8
  export async function relatedNotes(db, cfg, pathArg, overrides, k) {
9
9
  const paths = db.prepare('SELECT "path" FROM frontmatter').all().map((r)=>r.path);
10
10
  const path = resolveNote(paths, pathArg);
11
- const outbound = db.prepare('SELECT DISTINCT dst FROM links WHERE src = ? AND dst IS NOT NULL').all(path).map((r)=>r.dst);
12
- const backlinks = db.prepare('SELECT DISTINCT src FROM links WHERE dst = ?').all(path).map((r)=>r.src);
11
+ const outbound = db.prepare('SELECT DISTINCT dst FROM links WHERE src = ? AND dst IS NOT NULL AND dst != src').all(path).map((r)=>r.dst);
12
+ const backlinks = db.prepare('SELECT DISTINCT src FROM links WHERE dst = ? AND src != dst').all(path).map((r)=>r.src);
13
13
  const exclude = new Set([
14
14
  path,
15
15
  ...outbound,
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/commands/related.ts"],"sourcesContent":["import type { DatabaseSync } from 'node:sqlite';\nimport type { ResolvedConfig, SearchOverrides } from '../config/index.ts';\nimport { embedEnabled, resolveSearch } from '../config/index.ts';\nimport { SenseError } from '../errors.ts';\nimport { embedPending, hasEmbedding, modelPresent, similarNotes } from '../features/embed.ts';\nimport { resolveNote } from './peek.ts';\nimport { scopedPaths, scopeHasEmbeddings } from './scope.ts';\n\n// Notes most similar by cosine, excluding self and everything already linked either way.\n// Its own command, not a peek section: a full embeddings scan is ~480ms at 26k notes.\nexport async function relatedNotes(db: DatabaseSync, cfg: ResolvedConfig, pathArg: string, overrides: SearchOverrides, k: number): Promise<Array<{ path: string; similarity: number }>> {\n const paths = (db.prepare('SELECT \"path\" FROM frontmatter').all() as Array<{ path: string }>).map((r) => r.path);\n const path = resolveNote(paths, pathArg);\n\n const outbound = (db.prepare('SELECT DISTINCT dst FROM links WHERE src = ? AND dst IS NOT NULL').all(path) as Array<{ dst: string }>).map((r) => r.dst);\n const backlinks = (db.prepare('SELECT DISTINCT src FROM links WHERE dst = ?').all(path) as Array<{ src: string }>).map((r) => r.src);\n const exclude = new Set([path, ...outbound, ...backlinks]);\n\n // Vectors are the only signal `related` has, so every way of not having them is an error\n // naming the cause. An empty table then means one thing: nothing near in meaning that this\n // note does not already link to, which is a real answer.\n const effective = resolveSearch(cfg, overrides);\n if (!embedEnabled(cfg)) {\n throw new SenseError('EMBED_DISABLED', 'related ranks notes by meaning, and this tree has no embedding model; add an \"embed\" block naming one to sense.config.json, then run `sense download` (search works without it, on words and links)');\n }\n // search gates on the same flag (see wantsVectors above); reading it here too keeps\n // `semantic: false` meaning one thing. Without this, an overlapping semantic-on preset's\n // vectors would answer for a scope that declined them.\n if (!effective.semantic) {\n throw new SenseError('PRESET_NOT_SEMANTIC', `preset \"${effective.presetName}\" sets \"semantic\": false, so this scope has no vectors and related has no other signal; search it instead (words and links), or set semantic back on for that preset`);\n }\n if (!modelPresent(cfg)) {\n throw new SenseError('EMBED_MODEL_MISSING', 'related ranks notes by meaning, so it needs the embedding model, which is not downloaded; run `sense download` (search still works without it, on words and links)');\n }\n const allowed = scopedPaths(db, cfg, overrides);\n // Top up pending rows before the seed check, or a fresh index reports every note as\n // having no indexed text until some search has run.\n await embedPending(db, cfg, cfg.baseDir);\n if (!hasEmbedding(db, path)) {\n throw new SenseError('NOTE_NOT_EMBEDDED', `${path} has no indexed text to compare -- a note that is frontmatter only, or empty, has nothing to rank by meaning`);\n }\n if (!scopeHasEmbeddings(db, cfg, allowed)) return [];\n return similarNotes(db, cfg, path, { exclude, allowed, k });\n}\n"],"names":["embedEnabled","resolveSearch","SenseError","embedPending","hasEmbedding","modelPresent","similarNotes","resolveNote","scopedPaths","scopeHasEmbeddings","relatedNotes","db","cfg","pathArg","overrides","k","paths","prepare","all","map","r","path","outbound","dst","backlinks","src","exclude","Set","effective","semantic","presetName","allowed","baseDir"],"mappings":"AAEA,SAASA,YAAY,EAAEC,aAAa,QAAQ,qBAAqB;AACjE,SAASC,UAAU,QAAQ,eAAe;AAC1C,SAASC,YAAY,EAAEC,YAAY,EAAEC,YAAY,EAAEC,YAAY,QAAQ,uBAAuB;AAC9F,SAASC,WAAW,QAAQ,YAAY;AACxC,SAASC,WAAW,EAAEC,kBAAkB,QAAQ,aAAa;AAE7D,yFAAyF;AACzF,sFAAsF;AACtF,OAAO,eAAeC,aAAaC,EAAgB,EAAEC,GAAmB,EAAEC,OAAe,EAAEC,SAA0B,EAAEC,CAAS;IAC9H,MAAMC,QAAQ,AAACL,GAAGM,OAAO,CAAC,kCAAkCC,GAAG,GAA+BC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI;IAC/G,MAAMA,OAAOd,YAAYS,OAAOH;IAEhC,MAAMS,WAAW,AAACX,GAAGM,OAAO,CAAC,oEAAoEC,GAAG,CAACG,MAAiCF,GAAG,CAAC,CAACC,IAAMA,EAAEG,GAAG;IACtJ,MAAMC,YAAY,AAACb,GAAGM,OAAO,CAAC,gDAAgDC,GAAG,CAACG,MAAiCF,GAAG,CAAC,CAACC,IAAMA,EAAEK,GAAG;IACnI,MAAMC,UAAU,IAAIC,IAAI;QAACN;WAASC;WAAaE;KAAU;IAEzD,yFAAyF;IACzF,2FAA2F;IAC3F,yDAAyD;IACzD,MAAMI,YAAY3B,cAAcW,KAAKE;IACrC,IAAI,CAACd,aAAaY,MAAM;QACtB,MAAM,IAAIV,WAAW,kBAAkB;IACzC;IACA,oFAAoF;IACpF,yFAAyF;IACzF,uDAAuD;IACvD,IAAI,CAAC0B,UAAUC,QAAQ,EAAE;QACvB,MAAM,IAAI3B,WAAW,uBAAuB,CAAC,QAAQ,EAAE0B,UAAUE,UAAU,CAAC,oKAAoK,CAAC;IACnP;IACA,IAAI,CAACzB,aAAaO,MAAM;QACtB,MAAM,IAAIV,WAAW,uBAAuB;IAC9C;IACA,MAAM6B,UAAUvB,YAAYG,IAAIC,KAAKE;IACrC,oFAAoF;IACpF,oDAAoD;IACpD,MAAMX,aAAaQ,IAAIC,KAAKA,IAAIoB,OAAO;IACvC,IAAI,CAAC5B,aAAaO,IAAIU,OAAO;QAC3B,MAAM,IAAInB,WAAW,qBAAqB,GAAGmB,KAAK,4GAA4G,CAAC;IACjK;IACA,IAAI,CAACZ,mBAAmBE,IAAIC,KAAKmB,UAAU,OAAO,EAAE;IACpD,OAAOzB,aAAaK,IAAIC,KAAKS,MAAM;QAAEK;QAASK;QAAShB;IAAE;AAC3D"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/commands/related.ts"],"sourcesContent":["import type { DatabaseSync } from 'node:sqlite';\nimport type { ResolvedConfig, SearchOverrides } from '../config/index.ts';\nimport { embedEnabled, resolveSearch } from '../config/index.ts';\nimport { SenseError } from '../errors.ts';\nimport { embedPending, hasEmbedding, modelPresent, similarNotes } from '../features/embed.ts';\nimport { resolveNote } from './peek.ts';\nimport { scopedPaths, scopeHasEmbeddings } from './scope.ts';\n\n// Notes most similar by cosine, excluding self and everything already linked either way.\n// Its own command, not a peek section: a full embeddings scan is ~480ms at 26k notes.\nexport async function relatedNotes(db: DatabaseSync, cfg: ResolvedConfig, pathArg: string, overrides: SearchOverrides, k: number): Promise<Array<{ path: string; similarity: number }>> {\n const paths = (db.prepare('SELECT \"path\" FROM frontmatter').all() as Array<{ path: string }>).map((r) => r.path);\n const path = resolveNote(paths, pathArg);\n\n const outbound = (db.prepare('SELECT DISTINCT dst FROM links WHERE src = ? AND dst IS NOT NULL AND dst != src').all(path) as Array<{ dst: string }>).map((r) => r.dst);\n const backlinks = (db.prepare('SELECT DISTINCT src FROM links WHERE dst = ? AND src != dst').all(path) as Array<{ src: string }>).map((r) => r.src);\n const exclude = new Set([path, ...outbound, ...backlinks]);\n\n // Vectors are the only signal `related` has, so every way of not having them is an error\n // naming the cause. An empty table then means one thing: nothing near in meaning that this\n // note does not already link to, which is a real answer.\n const effective = resolveSearch(cfg, overrides);\n if (!embedEnabled(cfg)) {\n throw new SenseError('EMBED_DISABLED', 'related ranks notes by meaning, and this tree has no embedding model; add an \"embed\" block naming one to sense.config.json, then run `sense download` (search works without it, on words and links)');\n }\n // search gates on the same flag (see wantsVectors above); reading it here too keeps\n // `semantic: false` meaning one thing. Without this, an overlapping semantic-on preset's\n // vectors would answer for a scope that declined them.\n if (!effective.semantic) {\n throw new SenseError('PRESET_NOT_SEMANTIC', `preset \"${effective.presetName}\" sets \"semantic\": false, so this scope has no vectors and related has no other signal; search it instead (words and links), or set semantic back on for that preset`);\n }\n if (!modelPresent(cfg)) {\n throw new SenseError('EMBED_MODEL_MISSING', 'related ranks notes by meaning, so it needs the embedding model, which is not downloaded; run `sense download` (search still works without it, on words and links)');\n }\n const allowed = scopedPaths(db, cfg, overrides);\n // Top up pending rows before the seed check, or a fresh index reports every note as\n // having no indexed text until some search has run.\n await embedPending(db, cfg, cfg.baseDir);\n if (!hasEmbedding(db, path)) {\n throw new SenseError('NOTE_NOT_EMBEDDED', `${path} has no indexed text to compare -- a note that is frontmatter only, or empty, has nothing to rank by meaning`);\n }\n if (!scopeHasEmbeddings(db, cfg, allowed)) return [];\n return similarNotes(db, cfg, path, { exclude, allowed, k });\n}\n"],"names":["embedEnabled","resolveSearch","SenseError","embedPending","hasEmbedding","modelPresent","similarNotes","resolveNote","scopedPaths","scopeHasEmbeddings","relatedNotes","db","cfg","pathArg","overrides","k","paths","prepare","all","map","r","path","outbound","dst","backlinks","src","exclude","Set","effective","semantic","presetName","allowed","baseDir"],"mappings":"AAEA,SAASA,YAAY,EAAEC,aAAa,QAAQ,qBAAqB;AACjE,SAASC,UAAU,QAAQ,eAAe;AAC1C,SAASC,YAAY,EAAEC,YAAY,EAAEC,YAAY,EAAEC,YAAY,QAAQ,uBAAuB;AAC9F,SAASC,WAAW,QAAQ,YAAY;AACxC,SAASC,WAAW,EAAEC,kBAAkB,QAAQ,aAAa;AAE7D,yFAAyF;AACzF,sFAAsF;AACtF,OAAO,eAAeC,aAAaC,EAAgB,EAAEC,GAAmB,EAAEC,OAAe,EAAEC,SAA0B,EAAEC,CAAS;IAC9H,MAAMC,QAAQ,AAACL,GAAGM,OAAO,CAAC,kCAAkCC,GAAG,GAA+BC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI;IAC/G,MAAMA,OAAOd,YAAYS,OAAOH;IAEhC,MAAMS,WAAW,AAACX,GAAGM,OAAO,CAAC,mFAAmFC,GAAG,CAACG,MAAiCF,GAAG,CAAC,CAACC,IAAMA,EAAEG,GAAG;IACrK,MAAMC,YAAY,AAACb,GAAGM,OAAO,CAAC,+DAA+DC,GAAG,CAACG,MAAiCF,GAAG,CAAC,CAACC,IAAMA,EAAEK,GAAG;IAClJ,MAAMC,UAAU,IAAIC,IAAI;QAACN;WAASC;WAAaE;KAAU;IAEzD,yFAAyF;IACzF,2FAA2F;IAC3F,yDAAyD;IACzD,MAAMI,YAAY3B,cAAcW,KAAKE;IACrC,IAAI,CAACd,aAAaY,MAAM;QACtB,MAAM,IAAIV,WAAW,kBAAkB;IACzC;IACA,oFAAoF;IACpF,yFAAyF;IACzF,uDAAuD;IACvD,IAAI,CAAC0B,UAAUC,QAAQ,EAAE;QACvB,MAAM,IAAI3B,WAAW,uBAAuB,CAAC,QAAQ,EAAE0B,UAAUE,UAAU,CAAC,oKAAoK,CAAC;IACnP;IACA,IAAI,CAACzB,aAAaO,MAAM;QACtB,MAAM,IAAIV,WAAW,uBAAuB;IAC9C;IACA,MAAM6B,UAAUvB,YAAYG,IAAIC,KAAKE;IACrC,oFAAoF;IACpF,oDAAoD;IACpD,MAAMX,aAAaQ,IAAIC,KAAKA,IAAIoB,OAAO;IACvC,IAAI,CAAC5B,aAAaO,IAAIU,OAAO;QAC3B,MAAM,IAAInB,WAAW,qBAAqB,GAAGmB,KAAK,4GAA4G,CAAC;IACjK;IACA,IAAI,CAACZ,mBAAmBE,IAAIC,KAAKmB,UAAU,OAAO,EAAE;IACpD,OAAOzB,aAAaK,IAAIC,KAAKS,MAAM;QAAEK;QAASK;QAAShB;IAAE;AAC3D"}
@@ -1,7 +1,7 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import type { ResolvedConfig } from '../config/index.js';
3
3
  export declare const DB_FILENAME = "cache.db";
4
- export declare const SCHEMA_VERSION = "15";
4
+ export declare const SCHEMA_VERSION = "16";
5
5
  export interface OpenResult {
6
6
  db: DatabaseSync;
7
7
  cfg: ResolvedConfig;
@@ -12,7 +12,7 @@ import { getMeta, setMeta } from './shared.js';
12
12
  export const DB_FILENAME = 'cache.db';
13
13
  // Cache shape version, independent of the config's own `version`. Bumping it rebuilds
14
14
  // existing trees on first query.
15
- export const SCHEMA_VERSION = '15';
15
+ export const SCHEMA_VERSION = '16';
16
16
  // Stemming is English-only, but the segmentation underneath it is what decides coverage:
17
17
  // unicode61 splits on spaces, so a language written without them (Chinese, Japanese, Thai)
18
18
  // indexes a whole run as one token and word search finds nothing. `content.tokenize` is how
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/db/open.ts"],"sourcesContent":["// The Node floor (>=22.20) is explained here and nowhere else: 22.20 is the first release with\n// both FTS5 and row-returning INSERT ... RETURNING. Raise it only for a load-bearing capability.\nimport { mkdirSync, rmSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\nimport type { Config, ResolvedConfig } from '../config/index.ts';\nimport { contentTokenize, featureSignature, STATE_DIR } from '../config/index.ts';\nimport { SenseError } from '../errors.ts';\nimport { activeFeatures } from '../features/index.ts';\nimport { registerFunctions } from '../sql-functions.ts';\nimport { changedSignatureKeys, rebuildContentTable, reconcile, signatureDiff } from './reconcile.ts';\nimport { getMeta, setMeta } from './shared.ts';\n\nexport const DB_FILENAME = 'cache.db';\n// Cache shape version, independent of the config's own `version`. Bumping it rebuilds\n// existing trees on first query.\nexport const SCHEMA_VERSION = '15';\n\nexport interface OpenResult {\n db: DatabaseSync;\n cfg: ResolvedConfig;\n dbPath: string;\n parsed: number;\n warnings: string[];\n}\n\n// Stemming is English-only, but the segmentation underneath it is what decides coverage:\n// unicode61 splits on spaces, so a language written without them (Chinese, Japanese, Thai)\n// indexes a whole run as one token and word search finds nothing. `content.tokenize` is how\n// such a tree picks trigram instead.\nconst DEFAULT_TOKENIZE = 'porter unicode61';\n\n// FTS5 takes its tokenizer as a string literal inside DDL, where nothing can bind, so a\n// configured value has to be concatenated. Probing a throwaway table is what makes that safe\n// and is also the whole validation: anything the linked SQLite accepts passes, anything else\n// fails here with SQLite's own message rather than against the real table. It means no table\n// of which version added which tokenizer has to be maintained.\nfunction resolveTokenize(db: DatabaseSync, cfg: Config): string {\n const configured = contentTokenize(cfg);\n if (configured === undefined) return DEFAULT_TOKENIZE;\n const literal = configured.replace(/'/g, \"''\");\n try {\n db.exec('DROP TABLE IF EXISTS temp.sense_tokenize_probe');\n db.exec(`CREATE VIRTUAL TABLE temp.sense_tokenize_probe USING fts5(x, tokenize = '${literal}')`);\n db.exec('DROP TABLE IF EXISTS temp.sense_tokenize_probe');\n } catch (err) {\n throw new SenseError('CONFIG_INVALID', `content.tokenize \"${configured}\" is not a tokenizer this SQLite accepts (${(err as Error).message}); the built-in choices are unicode61, ascii, porter, and trigram, each with their own options`);\n }\n return literal;\n}\n\n// The tokenizer the content table was actually built with, from its own DDL -- the one\n// record that cannot desynchronize from the table. NULL when the table does not exist yet.\nfunction storedTokenize(db: DatabaseSync): string | null {\n const row = db.prepare(`SELECT sql FROM sqlite_master WHERE name = 'content'`).get() as { sql: string } | undefined;\n if (!row) return null;\n const m = row.sql.match(/tokenize = '((?:[^']|'')*)'/);\n return m ? m[1] : null;\n}\n\n// Content is a separate table (not a column on frontmatter) so `SELECT * FROM frontmatter`\n// can't dump file text into context. Features add their own tables after the core ones.\nfunction ensureSchema(db: DatabaseSync, cfg: Config, tokenize: string): void {\n db.exec(`CREATE TABLE IF NOT EXISTS frontmatter (\"path\" TEXT PRIMARY KEY, \"_mtime\" REAL, \"_ctime\" REAL, \"_size\" INTEGER, \"_parse_error\" TEXT)`);\n // IF NOT EXISTS is safe against a tokenizer change: open() compares the table's own DDL\n // against the resolved tokenizer before this runs, so a stale table is already gone by now.\n // The three `_seg` sidecars are appended after path, never inserted: bm25(content, ...) and\n // snippet(content, 2, ...) are documented against the first three columns and keep working\n // (FTS5 defaults the weights it was not given). Each carries its field's exploded unspaced\n // runs for text that needs it, and an empty string for text that does not.\n db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS content USING fts5(title, summary, text, path UNINDEXED, title_seg, summary_seg, text_seg, tokenize = '${tokenize}')`);\n // Coverage, not ownership: a path can appear under several presets. path leads the PK so the\n // per-doc delete is an index hit -- keyed the other way, cold builds went quadratic.\n db.exec(`CREATE TABLE IF NOT EXISTS preset_files (\"path\" TEXT, preset TEXT, PRIMARY KEY (\"path\", preset))`);\n db.exec('CREATE INDEX IF NOT EXISTS preset_files_preset ON preset_files(preset)');\n for (const feature of activeFeatures(cfg)) feature.schema(db);\n if (getMeta(db, 'schema_version') === null) setMeta(db, 'schema_version', SCHEMA_VERSION);\n if (getMeta(db, 'features') === null) setMeta(db, 'features', featureSignature(cfg));\n}\n\nexport function docCount(db: DatabaseSync): number {\n const row = db.prepare('SELECT COUNT(*) AS n FROM frontmatter').get() as { n: number };\n return row.n;\n}\n\nexport function open(cfg: ResolvedConfig): OpenResult {\n const stateDir = join(cfg.baseDir, STATE_DIR);\n mkdirSync(stateDir, { recursive: true });\n const dbPath = join(stateDir, DB_FILENAME);\n\n const db = new DatabaseSync(dbPath);\n db.exec('PRAGMA journal_mode = WAL');\n // Covers a concurrent watcher's bulk reconcile (~5s for 500 files at 26k notes). A query\n // that outwaits it still fails loudly.\n db.exec('PRAGMA busy_timeout = 30000');\n registerFunctions(db, contentTokenize(cfg) === undefined);\n\n db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)');\n\n // Before the rebuild branch below, never after: that branch deletes the cache, so a typo'd\n // tokenizer validated later would cost a full re-index (and re-embed) to reach its own error.\n const tokenize = resolveTokenize(db, cfg);\n\n // Schema-version or feature-set mismatch: reconcile only reparses changed files, so an\n // old cache can't be patched incrementally -- rebuild instead (cheap: nothing expensive lives here).\n const version = getMeta(db, 'schema_version');\n const features = getMeta(db, 'features');\n const wantFeatures = featureSignature(cfg);\n let tokenizeOnlyRebuild = false;\n if ((version !== null && version !== SCHEMA_VERSION) || (features !== null && features !== wantFeatures)) {\n // Indexing derives from presets, so a config edit rebuilding the cache must say so and\n // name what changed -- silent rebuilds make derived indexing look like a hang or a bug.\n if (version !== null && version !== SCHEMA_VERSION) {\n console.error('sense: cache format changed (new sensemaking version); rebuilding the index');\n db.close();\n clearCache(cfg);\n return open(cfg);\n }\n const changedKeys = changedSignatureKeys(features ?? '', wantFeatures);\n // Only the tokenizer moved: frontmatter, links, sections, and embeddings are file-derived\n // and tokenizer-independent, so they don't need re-deriving. Everything else -- a preset\n // edit, an embed model change -- still takes the full clear/reopen below.\n if (changedKeys.size === 1 && changedKeys.has('tokenize')) {\n console.error('sense: config change (content tokenizer) rebuilds the text index; vectors, links, and sections are kept');\n db.exec('DROP TABLE content');\n tokenizeOnlyRebuild = true;\n } else {\n const changed = signatureDiff(features ?? '', wantFeatures);\n console.error(`sense: config change (${changed}) rebuilds the index`);\n db.close();\n clearCache(cfg);\n return open(cfg);\n }\n }\n\n // Meta can lie after a crash between table creation and the signature write; the table's\n // own DDL cannot. A mismatch here rebuilds no matter what meta says. A tokenize-only rebuild\n // just dropped content above, so storedTokenize sees no table and this guard is skipped\n // naturally rather than needing its own case.\n const stored = storedTokenize(db);\n if (stored !== null && stored !== tokenize) {\n console.error('sense: cache was built with a different content tokenizer; rebuilding the index');\n db.close();\n clearCache(cfg);\n return open(cfg);\n }\n\n ensureSchema(db, cfg, tokenize);\n\n let rebuildWarnings: string[] = [];\n if (tokenizeOnlyRebuild) {\n rebuildWarnings = rebuildContentTable(db, cfg, cfg.baseDir);\n setMeta(db, 'features', wantFeatures);\n }\n\n // 3x the largest reconcile this cache has recorded, floored at 30s and capped at 10min.\n // Installed before reconcile() -- that call is the one that races a watcher's transaction.\n const recordedMaxMs = Number(getMeta(db, 'reconcile_max_ms') ?? '0');\n db.exec(`PRAGMA busy_timeout = ${Math.min(Math.max(30000, 3 * recordedMaxMs), 600_000)}`);\n\n const { parsed, warnings } = reconcile(db, cfg, cfg.baseDir);\n\n return { db, cfg, dbPath, parsed, warnings: [...rebuildWarnings, ...warnings] };\n}\n\n// Deletes the cache directory, and only that. The rebuild is not this function's job: the next\n// open() reconciles, which is what running any command already does, so a verb that bundled the\n// two described the half that was not its own. Manual reset for a doubted cache; the schema and\n// config-signature mismatches below reset themselves.\nexport function clearCache(cfg: ResolvedConfig): void {\n rmSync(join(cfg.baseDir, STATE_DIR), { recursive: true, force: true });\n}\n"],"names":["mkdirSync","rmSync","join","DatabaseSync","contentTokenize","featureSignature","STATE_DIR","SenseError","activeFeatures","registerFunctions","changedSignatureKeys","rebuildContentTable","reconcile","signatureDiff","getMeta","setMeta","DB_FILENAME","SCHEMA_VERSION","DEFAULT_TOKENIZE","resolveTokenize","db","cfg","configured","undefined","literal","replace","exec","err","message","storedTokenize","row","prepare","get","m","sql","match","ensureSchema","tokenize","feature","schema","docCount","n","open","stateDir","baseDir","recursive","dbPath","version","features","wantFeatures","tokenizeOnlyRebuild","console","error","close","clearCache","changedKeys","size","has","changed","stored","rebuildWarnings","recordedMaxMs","Number","Math","min","max","parsed","warnings","force"],"mappings":"AAAA,+FAA+F;AAC/F,iGAAiG;AACjG,SAASA,SAAS,EAAEC,MAAM,QAAQ,UAAU;AAC5C,SAASC,IAAI,QAAQ,YAAY;AACjC,SAASC,YAAY,QAAQ,cAAc;AAE3C,SAASC,eAAe,EAAEC,gBAAgB,EAAEC,SAAS,QAAQ,qBAAqB;AAClF,SAASC,UAAU,QAAQ,eAAe;AAC1C,SAASC,cAAc,QAAQ,uBAAuB;AACtD,SAASC,iBAAiB,QAAQ,sBAAsB;AACxD,SAASC,oBAAoB,EAAEC,mBAAmB,EAAEC,SAAS,EAAEC,aAAa,QAAQ,iBAAiB;AACrG,SAASC,OAAO,EAAEC,OAAO,QAAQ,cAAc;AAE/C,OAAO,MAAMC,cAAc,WAAW;AACtC,sFAAsF;AACtF,iCAAiC;AACjC,OAAO,MAAMC,iBAAiB,KAAK;AAUnC,yFAAyF;AACzF,2FAA2F;AAC3F,4FAA4F;AAC5F,qCAAqC;AACrC,MAAMC,mBAAmB;AAEzB,wFAAwF;AACxF,6FAA6F;AAC7F,6FAA6F;AAC7F,6FAA6F;AAC7F,+DAA+D;AAC/D,SAASC,gBAAgBC,EAAgB,EAAEC,GAAW;IACpD,MAAMC,aAAalB,gBAAgBiB;IACnC,IAAIC,eAAeC,WAAW,OAAOL;IACrC,MAAMM,UAAUF,WAAWG,OAAO,CAAC,MAAM;IACzC,IAAI;QACFL,GAAGM,IAAI,CAAC;QACRN,GAAGM,IAAI,CAAC,CAAC,yEAAyE,EAAEF,QAAQ,EAAE,CAAC;QAC/FJ,GAAGM,IAAI,CAAC;IACV,EAAE,OAAOC,KAAK;QACZ,MAAM,IAAIpB,WAAW,kBAAkB,CAAC,kBAAkB,EAAEe,WAAW,0CAA0C,EAAE,AAACK,IAAcC,OAAO,CAAC,8FAA8F,CAAC;IAC3O;IACA,OAAOJ;AACT;AAEA,uFAAuF;AACvF,2FAA2F;AAC3F,SAASK,eAAeT,EAAgB;IACtC,MAAMU,MAAMV,GAAGW,OAAO,CAAC,CAAC,oDAAoD,CAAC,EAAEC,GAAG;IAClF,IAAI,CAACF,KAAK,OAAO;IACjB,MAAMG,IAAIH,IAAII,GAAG,CAACC,KAAK,CAAC;IACxB,OAAOF,IAAIA,CAAC,CAAC,EAAE,GAAG;AACpB;AAEA,2FAA2F;AAC3F,wFAAwF;AACxF,SAASG,aAAahB,EAAgB,EAAEC,GAAW,EAAEgB,QAAgB;IACnEjB,GAAGM,IAAI,CAAC,CAAC,oIAAoI,CAAC;IAC9I,wFAAwF;IACxF,4FAA4F;IAC5F,4FAA4F;IAC5F,2FAA2F;IAC3F,2FAA2F;IAC3F,2EAA2E;IAC3EN,GAAGM,IAAI,CAAC,CAAC,0IAA0I,EAAEW,SAAS,EAAE,CAAC;IACjK,6FAA6F;IAC7F,qFAAqF;IACrFjB,GAAGM,IAAI,CAAC,CAAC,gGAAgG,CAAC;IAC1GN,GAAGM,IAAI,CAAC;IACR,KAAK,MAAMY,WAAW9B,eAAea,KAAMiB,QAAQC,MAAM,CAACnB;IAC1D,IAAIN,QAAQM,IAAI,sBAAsB,MAAML,QAAQK,IAAI,kBAAkBH;IAC1E,IAAIH,QAAQM,IAAI,gBAAgB,MAAML,QAAQK,IAAI,YAAYf,iBAAiBgB;AACjF;AAEA,OAAO,SAASmB,SAASpB,EAAgB;IACvC,MAAMU,MAAMV,GAAGW,OAAO,CAAC,yCAAyCC,GAAG;IACnE,OAAOF,IAAIW,CAAC;AACd;AAEA,OAAO,SAASC,KAAKrB,GAAmB;QAwETP;IAvE7B,MAAM6B,WAAWzC,KAAKmB,IAAIuB,OAAO,EAAEtC;IACnCN,UAAU2C,UAAU;QAAEE,WAAW;IAAK;IACtC,MAAMC,SAAS5C,KAAKyC,UAAU3B;IAE9B,MAAMI,KAAK,IAAIjB,aAAa2C;IAC5B1B,GAAGM,IAAI,CAAC;IACR,yFAAyF;IACzF,uCAAuC;IACvCN,GAAGM,IAAI,CAAC;IACRjB,kBAAkBW,IAAIhB,gBAAgBiB,SAASE;IAE/CH,GAAGM,IAAI,CAAC;IAER,2FAA2F;IAC3F,8FAA8F;IAC9F,MAAMW,WAAWlB,gBAAgBC,IAAIC;IAErC,uFAAuF;IACvF,qGAAqG;IACrG,MAAM0B,UAAUjC,QAAQM,IAAI;IAC5B,MAAM4B,WAAWlC,QAAQM,IAAI;IAC7B,MAAM6B,eAAe5C,iBAAiBgB;IACtC,IAAI6B,sBAAsB;IAC1B,IAAI,AAACH,YAAY,QAAQA,YAAY9B,kBAAoB+B,aAAa,QAAQA,aAAaC,cAAe;QACxG,uFAAuF;QACvF,wFAAwF;QACxF,IAAIF,YAAY,QAAQA,YAAY9B,gBAAgB;YAClDkC,QAAQC,KAAK,CAAC;YACdhC,GAAGiC,KAAK;YACRC,WAAWjC;YACX,OAAOqB,KAAKrB;QACd;QACA,MAAMkC,cAAc7C,qBAAqBsC,qBAAAA,sBAAAA,WAAY,IAAIC;QACzD,0FAA0F;QAC1F,yFAAyF;QACzF,0EAA0E;QAC1E,IAAIM,YAAYC,IAAI,KAAK,KAAKD,YAAYE,GAAG,CAAC,aAAa;YACzDN,QAAQC,KAAK,CAAC;YACdhC,GAAGM,IAAI,CAAC;YACRwB,sBAAsB;QACxB,OAAO;YACL,MAAMQ,UAAU7C,cAAcmC,qBAAAA,sBAAAA,WAAY,IAAIC;YAC9CE,QAAQC,KAAK,CAAC,CAAC,sBAAsB,EAAEM,QAAQ,oBAAoB,CAAC;YACpEtC,GAAGiC,KAAK;YACRC,WAAWjC;YACX,OAAOqB,KAAKrB;QACd;IACF;IAEA,yFAAyF;IACzF,6FAA6F;IAC7F,wFAAwF;IACxF,8CAA8C;IAC9C,MAAMsC,SAAS9B,eAAeT;IAC9B,IAAIuC,WAAW,QAAQA,WAAWtB,UAAU;QAC1Cc,QAAQC,KAAK,CAAC;QACdhC,GAAGiC,KAAK;QACRC,WAAWjC;QACX,OAAOqB,KAAKrB;IACd;IAEAe,aAAahB,IAAIC,KAAKgB;IAEtB,IAAIuB,kBAA4B,EAAE;IAClC,IAAIV,qBAAqB;QACvBU,kBAAkBjD,oBAAoBS,IAAIC,KAAKA,IAAIuB,OAAO;QAC1D7B,QAAQK,IAAI,YAAY6B;IAC1B;IAEA,wFAAwF;IACxF,2FAA2F;IAC3F,MAAMY,gBAAgBC,QAAOhD,WAAAA,QAAQM,IAAI,iCAAZN,sBAAAA,WAAmC;IAChEM,GAAGM,IAAI,CAAC,CAAC,sBAAsB,EAAEqC,KAAKC,GAAG,CAACD,KAAKE,GAAG,CAAC,OAAO,IAAIJ,gBAAgB,SAAU;IAExF,MAAM,EAAEK,MAAM,EAAEC,QAAQ,EAAE,GAAGvD,UAAUQ,IAAIC,KAAKA,IAAIuB,OAAO;IAE3D,OAAO;QAAExB;QAAIC;QAAKyB;QAAQoB;QAAQC,UAAU;eAAIP;eAAoBO;SAAS;IAAC;AAChF;AAEA,+FAA+F;AAC/F,gGAAgG;AAChG,gGAAgG;AAChG,sDAAsD;AACtD,OAAO,SAASb,WAAWjC,GAAmB;IAC5CpB,OAAOC,KAAKmB,IAAIuB,OAAO,EAAEtC,YAAY;QAAEuC,WAAW;QAAMuB,OAAO;IAAK;AACtE"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/db/open.ts"],"sourcesContent":["// The Node floor (>=22.20) is explained here and nowhere else: 22.20 is the first release with\n// both FTS5 and row-returning INSERT ... RETURNING. Raise it only for a load-bearing capability.\nimport { mkdirSync, rmSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\nimport type { Config, ResolvedConfig } from '../config/index.ts';\nimport { contentTokenize, featureSignature, STATE_DIR } from '../config/index.ts';\nimport { SenseError } from '../errors.ts';\nimport { activeFeatures } from '../features/index.ts';\nimport { registerFunctions } from '../sql-functions.ts';\nimport { changedSignatureKeys, rebuildContentTable, reconcile, signatureDiff } from './reconcile.ts';\nimport { getMeta, setMeta } from './shared.ts';\n\nexport const DB_FILENAME = 'cache.db';\n// Cache shape version, independent of the config's own `version`. Bumping it rebuilds\n// existing trees on first query.\nexport const SCHEMA_VERSION = '16';\n\nexport interface OpenResult {\n db: DatabaseSync;\n cfg: ResolvedConfig;\n dbPath: string;\n parsed: number;\n warnings: string[];\n}\n\n// Stemming is English-only, but the segmentation underneath it is what decides coverage:\n// unicode61 splits on spaces, so a language written without them (Chinese, Japanese, Thai)\n// indexes a whole run as one token and word search finds nothing. `content.tokenize` is how\n// such a tree picks trigram instead.\nconst DEFAULT_TOKENIZE = 'porter unicode61';\n\n// FTS5 takes its tokenizer as a string literal inside DDL, where nothing can bind, so a\n// configured value has to be concatenated. Probing a throwaway table is what makes that safe\n// and is also the whole validation: anything the linked SQLite accepts passes, anything else\n// fails here with SQLite's own message rather than against the real table. It means no table\n// of which version added which tokenizer has to be maintained.\nfunction resolveTokenize(db: DatabaseSync, cfg: Config): string {\n const configured = contentTokenize(cfg);\n if (configured === undefined) return DEFAULT_TOKENIZE;\n const literal = configured.replace(/'/g, \"''\");\n try {\n db.exec('DROP TABLE IF EXISTS temp.sense_tokenize_probe');\n db.exec(`CREATE VIRTUAL TABLE temp.sense_tokenize_probe USING fts5(x, tokenize = '${literal}')`);\n db.exec('DROP TABLE IF EXISTS temp.sense_tokenize_probe');\n } catch (err) {\n throw new SenseError('CONFIG_INVALID', `content.tokenize \"${configured}\" is not a tokenizer this SQLite accepts (${(err as Error).message}); the built-in choices are unicode61, ascii, porter, and trigram, each with their own options`);\n }\n return literal;\n}\n\n// The tokenizer the content table was actually built with, from its own DDL -- the one\n// record that cannot desynchronize from the table. NULL when the table does not exist yet.\nfunction storedTokenize(db: DatabaseSync): string | null {\n const row = db.prepare(`SELECT sql FROM sqlite_master WHERE name = 'content'`).get() as { sql: string } | undefined;\n if (!row) return null;\n const m = row.sql.match(/tokenize = '((?:[^']|'')*)'/);\n return m ? m[1] : null;\n}\n\n// Content is a separate table (not a column on frontmatter) so `SELECT * FROM frontmatter`\n// can't dump file text into context. Features add their own tables after the core ones.\nfunction ensureSchema(db: DatabaseSync, cfg: Config, tokenize: string): void {\n db.exec(`CREATE TABLE IF NOT EXISTS frontmatter (\"path\" TEXT PRIMARY KEY, \"_mtime\" REAL, \"_ctime\" REAL, \"_size\" INTEGER, \"_parse_error\" TEXT)`);\n // IF NOT EXISTS is safe against a tokenizer change: open() compares the table's own DDL\n // against the resolved tokenizer before this runs, so a stale table is already gone by now.\n // The three `_seg` sidecars are appended after path, never inserted: bm25(content, ...) and\n // snippet(content, 2, ...) are documented against the first three columns and keep working\n // (FTS5 defaults the weights it was not given). Each carries its field's exploded unspaced\n // runs for text that needs it, and an empty string for text that does not.\n db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS content USING fts5(title, summary, text, path UNINDEXED, title_seg, summary_seg, text_seg, tokenize = '${tokenize}')`);\n // Coverage, not ownership: a path can appear under several presets. path leads the PK so the\n // per-doc delete is an index hit -- keyed the other way, cold builds went quadratic.\n db.exec(`CREATE TABLE IF NOT EXISTS preset_files (\"path\" TEXT, preset TEXT, PRIMARY KEY (\"path\", preset))`);\n db.exec('CREATE INDEX IF NOT EXISTS preset_files_preset ON preset_files(preset)');\n for (const feature of activeFeatures(cfg)) feature.schema(db);\n if (getMeta(db, 'schema_version') === null) setMeta(db, 'schema_version', SCHEMA_VERSION);\n if (getMeta(db, 'features') === null) setMeta(db, 'features', featureSignature(cfg));\n}\n\nexport function docCount(db: DatabaseSync): number {\n const row = db.prepare('SELECT COUNT(*) AS n FROM frontmatter').get() as { n: number };\n return row.n;\n}\n\nexport function open(cfg: ResolvedConfig): OpenResult {\n const stateDir = join(cfg.baseDir, STATE_DIR);\n mkdirSync(stateDir, { recursive: true });\n const dbPath = join(stateDir, DB_FILENAME);\n\n const db = new DatabaseSync(dbPath);\n db.exec('PRAGMA journal_mode = WAL');\n // Covers a concurrent watcher's bulk reconcile (~5s for 500 files at 26k notes). A query\n // that outwaits it still fails loudly.\n db.exec('PRAGMA busy_timeout = 30000');\n registerFunctions(db, contentTokenize(cfg) === undefined);\n\n db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)');\n\n // Before the rebuild branch below, never after: that branch deletes the cache, so a typo'd\n // tokenizer validated later would cost a full re-index (and re-embed) to reach its own error.\n const tokenize = resolveTokenize(db, cfg);\n\n // Schema-version or feature-set mismatch: reconcile only reparses changed files, so an\n // old cache can't be patched incrementally -- rebuild instead (cheap: nothing expensive lives here).\n const version = getMeta(db, 'schema_version');\n const features = getMeta(db, 'features');\n const wantFeatures = featureSignature(cfg);\n let tokenizeOnlyRebuild = false;\n if ((version !== null && version !== SCHEMA_VERSION) || (features !== null && features !== wantFeatures)) {\n // Indexing derives from presets, so a config edit rebuilding the cache must say so and\n // name what changed -- silent rebuilds make derived indexing look like a hang or a bug.\n if (version !== null && version !== SCHEMA_VERSION) {\n console.error('sense: cache format changed (new sensemaking version); rebuilding the index');\n db.close();\n clearCache(cfg);\n return open(cfg);\n }\n const changedKeys = changedSignatureKeys(features ?? '', wantFeatures);\n // Only the tokenizer moved: frontmatter, links, sections, and embeddings are file-derived\n // and tokenizer-independent, so they don't need re-deriving. Everything else -- a preset\n // edit, an embed model change -- still takes the full clear/reopen below.\n if (changedKeys.size === 1 && changedKeys.has('tokenize')) {\n console.error('sense: config change (content tokenizer) rebuilds the text index; vectors, links, and sections are kept');\n db.exec('DROP TABLE content');\n tokenizeOnlyRebuild = true;\n } else {\n const changed = signatureDiff(features ?? '', wantFeatures);\n console.error(`sense: config change (${changed}) rebuilds the index`);\n db.close();\n clearCache(cfg);\n return open(cfg);\n }\n }\n\n // Meta can lie after a crash between table creation and the signature write; the table's\n // own DDL cannot. A mismatch here rebuilds no matter what meta says. A tokenize-only rebuild\n // just dropped content above, so storedTokenize sees no table and this guard is skipped\n // naturally rather than needing its own case.\n const stored = storedTokenize(db);\n if (stored !== null && stored !== tokenize) {\n console.error('sense: cache was built with a different content tokenizer; rebuilding the index');\n db.close();\n clearCache(cfg);\n return open(cfg);\n }\n\n ensureSchema(db, cfg, tokenize);\n\n let rebuildWarnings: string[] = [];\n if (tokenizeOnlyRebuild) {\n rebuildWarnings = rebuildContentTable(db, cfg, cfg.baseDir);\n setMeta(db, 'features', wantFeatures);\n }\n\n // 3x the largest reconcile this cache has recorded, floored at 30s and capped at 10min.\n // Installed before reconcile() -- that call is the one that races a watcher's transaction.\n const recordedMaxMs = Number(getMeta(db, 'reconcile_max_ms') ?? '0');\n db.exec(`PRAGMA busy_timeout = ${Math.min(Math.max(30000, 3 * recordedMaxMs), 600_000)}`);\n\n const { parsed, warnings } = reconcile(db, cfg, cfg.baseDir);\n\n return { db, cfg, dbPath, parsed, warnings: [...rebuildWarnings, ...warnings] };\n}\n\n// Deletes the cache directory, and only that. The rebuild is not this function's job: the next\n// open() reconciles, which is what running any command already does, so a verb that bundled the\n// two described the half that was not its own. Manual reset for a doubted cache; the schema and\n// config-signature mismatches below reset themselves.\nexport function clearCache(cfg: ResolvedConfig): void {\n rmSync(join(cfg.baseDir, STATE_DIR), { recursive: true, force: true });\n}\n"],"names":["mkdirSync","rmSync","join","DatabaseSync","contentTokenize","featureSignature","STATE_DIR","SenseError","activeFeatures","registerFunctions","changedSignatureKeys","rebuildContentTable","reconcile","signatureDiff","getMeta","setMeta","DB_FILENAME","SCHEMA_VERSION","DEFAULT_TOKENIZE","resolveTokenize","db","cfg","configured","undefined","literal","replace","exec","err","message","storedTokenize","row","prepare","get","m","sql","match","ensureSchema","tokenize","feature","schema","docCount","n","open","stateDir","baseDir","recursive","dbPath","version","features","wantFeatures","tokenizeOnlyRebuild","console","error","close","clearCache","changedKeys","size","has","changed","stored","rebuildWarnings","recordedMaxMs","Number","Math","min","max","parsed","warnings","force"],"mappings":"AAAA,+FAA+F;AAC/F,iGAAiG;AACjG,SAASA,SAAS,EAAEC,MAAM,QAAQ,UAAU;AAC5C,SAASC,IAAI,QAAQ,YAAY;AACjC,SAASC,YAAY,QAAQ,cAAc;AAE3C,SAASC,eAAe,EAAEC,gBAAgB,EAAEC,SAAS,QAAQ,qBAAqB;AAClF,SAASC,UAAU,QAAQ,eAAe;AAC1C,SAASC,cAAc,QAAQ,uBAAuB;AACtD,SAASC,iBAAiB,QAAQ,sBAAsB;AACxD,SAASC,oBAAoB,EAAEC,mBAAmB,EAAEC,SAAS,EAAEC,aAAa,QAAQ,iBAAiB;AACrG,SAASC,OAAO,EAAEC,OAAO,QAAQ,cAAc;AAE/C,OAAO,MAAMC,cAAc,WAAW;AACtC,sFAAsF;AACtF,iCAAiC;AACjC,OAAO,MAAMC,iBAAiB,KAAK;AAUnC,yFAAyF;AACzF,2FAA2F;AAC3F,4FAA4F;AAC5F,qCAAqC;AACrC,MAAMC,mBAAmB;AAEzB,wFAAwF;AACxF,6FAA6F;AAC7F,6FAA6F;AAC7F,6FAA6F;AAC7F,+DAA+D;AAC/D,SAASC,gBAAgBC,EAAgB,EAAEC,GAAW;IACpD,MAAMC,aAAalB,gBAAgBiB;IACnC,IAAIC,eAAeC,WAAW,OAAOL;IACrC,MAAMM,UAAUF,WAAWG,OAAO,CAAC,MAAM;IACzC,IAAI;QACFL,GAAGM,IAAI,CAAC;QACRN,GAAGM,IAAI,CAAC,CAAC,yEAAyE,EAAEF,QAAQ,EAAE,CAAC;QAC/FJ,GAAGM,IAAI,CAAC;IACV,EAAE,OAAOC,KAAK;QACZ,MAAM,IAAIpB,WAAW,kBAAkB,CAAC,kBAAkB,EAAEe,WAAW,0CAA0C,EAAE,AAACK,IAAcC,OAAO,CAAC,8FAA8F,CAAC;IAC3O;IACA,OAAOJ;AACT;AAEA,uFAAuF;AACvF,2FAA2F;AAC3F,SAASK,eAAeT,EAAgB;IACtC,MAAMU,MAAMV,GAAGW,OAAO,CAAC,CAAC,oDAAoD,CAAC,EAAEC,GAAG;IAClF,IAAI,CAACF,KAAK,OAAO;IACjB,MAAMG,IAAIH,IAAII,GAAG,CAACC,KAAK,CAAC;IACxB,OAAOF,IAAIA,CAAC,CAAC,EAAE,GAAG;AACpB;AAEA,2FAA2F;AAC3F,wFAAwF;AACxF,SAASG,aAAahB,EAAgB,EAAEC,GAAW,EAAEgB,QAAgB;IACnEjB,GAAGM,IAAI,CAAC,CAAC,oIAAoI,CAAC;IAC9I,wFAAwF;IACxF,4FAA4F;IAC5F,4FAA4F;IAC5F,2FAA2F;IAC3F,2FAA2F;IAC3F,2EAA2E;IAC3EN,GAAGM,IAAI,CAAC,CAAC,0IAA0I,EAAEW,SAAS,EAAE,CAAC;IACjK,6FAA6F;IAC7F,qFAAqF;IACrFjB,GAAGM,IAAI,CAAC,CAAC,gGAAgG,CAAC;IAC1GN,GAAGM,IAAI,CAAC;IACR,KAAK,MAAMY,WAAW9B,eAAea,KAAMiB,QAAQC,MAAM,CAACnB;IAC1D,IAAIN,QAAQM,IAAI,sBAAsB,MAAML,QAAQK,IAAI,kBAAkBH;IAC1E,IAAIH,QAAQM,IAAI,gBAAgB,MAAML,QAAQK,IAAI,YAAYf,iBAAiBgB;AACjF;AAEA,OAAO,SAASmB,SAASpB,EAAgB;IACvC,MAAMU,MAAMV,GAAGW,OAAO,CAAC,yCAAyCC,GAAG;IACnE,OAAOF,IAAIW,CAAC;AACd;AAEA,OAAO,SAASC,KAAKrB,GAAmB;QAwETP;IAvE7B,MAAM6B,WAAWzC,KAAKmB,IAAIuB,OAAO,EAAEtC;IACnCN,UAAU2C,UAAU;QAAEE,WAAW;IAAK;IACtC,MAAMC,SAAS5C,KAAKyC,UAAU3B;IAE9B,MAAMI,KAAK,IAAIjB,aAAa2C;IAC5B1B,GAAGM,IAAI,CAAC;IACR,yFAAyF;IACzF,uCAAuC;IACvCN,GAAGM,IAAI,CAAC;IACRjB,kBAAkBW,IAAIhB,gBAAgBiB,SAASE;IAE/CH,GAAGM,IAAI,CAAC;IAER,2FAA2F;IAC3F,8FAA8F;IAC9F,MAAMW,WAAWlB,gBAAgBC,IAAIC;IAErC,uFAAuF;IACvF,qGAAqG;IACrG,MAAM0B,UAAUjC,QAAQM,IAAI;IAC5B,MAAM4B,WAAWlC,QAAQM,IAAI;IAC7B,MAAM6B,eAAe5C,iBAAiBgB;IACtC,IAAI6B,sBAAsB;IAC1B,IAAI,AAACH,YAAY,QAAQA,YAAY9B,kBAAoB+B,aAAa,QAAQA,aAAaC,cAAe;QACxG,uFAAuF;QACvF,wFAAwF;QACxF,IAAIF,YAAY,QAAQA,YAAY9B,gBAAgB;YAClDkC,QAAQC,KAAK,CAAC;YACdhC,GAAGiC,KAAK;YACRC,WAAWjC;YACX,OAAOqB,KAAKrB;QACd;QACA,MAAMkC,cAAc7C,qBAAqBsC,qBAAAA,sBAAAA,WAAY,IAAIC;QACzD,0FAA0F;QAC1F,yFAAyF;QACzF,0EAA0E;QAC1E,IAAIM,YAAYC,IAAI,KAAK,KAAKD,YAAYE,GAAG,CAAC,aAAa;YACzDN,QAAQC,KAAK,CAAC;YACdhC,GAAGM,IAAI,CAAC;YACRwB,sBAAsB;QACxB,OAAO;YACL,MAAMQ,UAAU7C,cAAcmC,qBAAAA,sBAAAA,WAAY,IAAIC;YAC9CE,QAAQC,KAAK,CAAC,CAAC,sBAAsB,EAAEM,QAAQ,oBAAoB,CAAC;YACpEtC,GAAGiC,KAAK;YACRC,WAAWjC;YACX,OAAOqB,KAAKrB;QACd;IACF;IAEA,yFAAyF;IACzF,6FAA6F;IAC7F,wFAAwF;IACxF,8CAA8C;IAC9C,MAAMsC,SAAS9B,eAAeT;IAC9B,IAAIuC,WAAW,QAAQA,WAAWtB,UAAU;QAC1Cc,QAAQC,KAAK,CAAC;QACdhC,GAAGiC,KAAK;QACRC,WAAWjC;QACX,OAAOqB,KAAKrB;IACd;IAEAe,aAAahB,IAAIC,KAAKgB;IAEtB,IAAIuB,kBAA4B,EAAE;IAClC,IAAIV,qBAAqB;QACvBU,kBAAkBjD,oBAAoBS,IAAIC,KAAKA,IAAIuB,OAAO;QAC1D7B,QAAQK,IAAI,YAAY6B;IAC1B;IAEA,wFAAwF;IACxF,2FAA2F;IAC3F,MAAMY,gBAAgBC,QAAOhD,WAAAA,QAAQM,IAAI,iCAAZN,sBAAAA,WAAmC;IAChEM,GAAGM,IAAI,CAAC,CAAC,sBAAsB,EAAEqC,KAAKC,GAAG,CAACD,KAAKE,GAAG,CAAC,OAAO,IAAIJ,gBAAgB,SAAU;IAExF,MAAM,EAAEK,MAAM,EAAEC,QAAQ,EAAE,GAAGvD,UAAUQ,IAAIC,KAAKA,IAAIuB,OAAO;IAE3D,OAAO;QAAExB;QAAIC;QAAKyB;QAAQoB;QAAQC,UAAU;eAAIP;eAAoBO;SAAS;IAAC;AAChF;AAEA,+FAA+F;AAC/F,gGAAgG;AAChG,gGAAgG;AAChG,sDAAsD;AACtD,OAAO,SAASb,WAAWjC,GAAmB;IAC5CpB,OAAOC,KAAKmB,IAAIuB,OAAO,EAAEtC,YAAY;QAAEuC,WAAW;QAAMuB,OAAO;IAAK;AACtE"}
@@ -1,10 +1,23 @@
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
+ // One rule for [[inner]] text wherever it appears: alias stripped after |, a leading #
11
+ // keeps the anchor as the target (a same-note self-link needs a heading name), otherwise
12
+ // the anchor splits off. Null = not a link.
13
+ function parseWikilinkInner(inner) {
14
+ const beforeAlias = inner.split('|')[0].trim();
15
+ if (beforeAlias.startsWith('#')) return beforeAlias.length > 1 ? beforeAlias : null;
16
+ const target = beforeAlias.split('#')[0].trim();
17
+ return target || null;
18
+ }
19
+ function extract(_raw, rawBody, _search, data) {
20
+ const body = /\[\[|\]\(/.test(rawBody) ? maskRegions(rawBody) : rawBody;
8
21
  const seen = new Set();
9
22
  const results = [];
10
23
  const add = (target, embed)=>{
@@ -17,15 +30,37 @@ function extract(_raw, body) {
17
30
  });
18
31
  };
19
32
  // To the first ]], as Obsidian parses it, so a heading or alias holding a lone ] still
20
- // matches; anchor and alias split off in code, since a class-based regex stops at the ].
33
+ // matches; anchor and alias split off in parseWikilinkInner.
21
34
  for (const m of body.matchAll(/\[\[(.*?)\]\]/g)){
22
35
  var _m_index;
23
- const target = m[1].split('|')[0].split('#')[0].trim();
36
+ const target = parseWikilinkInner(m[1]);
24
37
  if (target) add(target, body[((_m_index = m.index) !== null && _m_index !== void 0 ? _m_index : 0) - 1] === '!');
25
38
  }
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] === '!');
39
+ // Any internal destination, not only .md-suffixed: Obsidian gives markdown links full
40
+ // linkpath resolution, so \](Zektor) resolves like [[Zektor]] and \](#anchor) is a
41
+ // self-edge. External URLs (a scheme anywhere survives the malformed double-paren case)
42
+ // and titled links (dest stops at whitespace) are skipped.
43
+ // A space in the destination is only valid ahead of a quoted title; a domain-shaped first
44
+ // segment (www.example.com/...) is external even without a scheme.
45
+ for (const m of body.matchAll(/(!)?\[[^\]]*\]\(((?:[^()\s]|\([^()\s]*\))+)(?:\s+(?:"[^)]*"|'[^)]*'))?\)/g)){
46
+ const dest = m[2].trim();
47
+ // External: a URI scheme prefix (mailto:, tel:, data:, https:...), protocol-relative
48
+ // //host, a www. shorthand, or a scheme anywhere (the malformed double-paren case).
49
+ if (!dest || dest.includes('://')) continue;
50
+ if (/^([a-z][a-z0-9+.-]*:|\/\/|www\.)/i.test(dest)) continue;
51
+ const target = dest.startsWith('#') ? dest : dest.split('#')[0];
52
+ if (target) add(target, m[1] === '!');
53
+ }
54
+ for (const value of Object.values(data !== null && data !== void 0 ? data : {})){
55
+ for (const item of Array.isArray(value) ? value : [
56
+ value
57
+ ]){
58
+ if (typeof item !== 'string') continue;
59
+ const m = /^\[\[(.*)\]\]$/.exec(item.trim());
60
+ if (!m) continue;
61
+ const target = parseWikilinkInner(m[1]);
62
+ if (target) add(target, false);
63
+ }
29
64
  }
30
65
  return results;
31
66
  }
@@ -36,10 +71,11 @@ function baseKey(path) {
36
71
  return posix.basename(path).replace(/\.md$/i, '').toLowerCase();
37
72
  }
38
73
  // Obsidian-style: exact relative path (with/without .md), path relative to the linking
39
- // note's directory, then basename match (lexicographically first on ties).
74
+ // note's directory, then basename match (shortest path wins on ties -- verified against a
75
+ // cold-loaded Obsidian vault on 6+ real collision pairs; byBase's lists are pre-sorted that way).
40
76
  function resolveTarget(src, target, pathSet, byBase) {
41
- var _ref;
42
- var _byBase_get;
77
+ // [[#Heading]]: a same-note anchor, always the note itself -- never depends on other files.
78
+ if (target.startsWith('#')) return src;
43
79
  const clean = cleanTarget(target);
44
80
  const fromSrc = posix.normalize(posix.join(posix.dirname(src), clean));
45
81
  for (const candidate of [
@@ -50,11 +86,18 @@ function resolveTarget(src, target, pathSet, byBase) {
50
86
  ]){
51
87
  if (pathSet.has(candidate)) return candidate;
52
88
  }
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;
89
+ const candidates = byBase.get(baseKey(clean));
90
+ if (!candidates) return null;
91
+ // The linking note itself wins a basename collision (Obsidian resolves to self before the
92
+ // shortest-path rule -- cold-load verified on the hub corpus).
93
+ return candidates.includes(src) ? src : candidates[0];
54
94
  }
95
+ // Shortest path wins a basename collision; equal lengths fall back to lexicographic, our own
96
+ // deterministic tiebreak for a case Obsidian itself leaves registration-order-dependent.
55
97
  function buildByBase(files) {
56
98
  const byBase = new Map();
57
- for (const path of files.map((f)=>f.relPath).sort()){
99
+ const paths = files.map((f)=>f.relPath).sort((a, b)=>a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
100
+ for (const path of paths){
58
101
  const key = baseKey(path);
59
102
  const list = byBase.get(key);
60
103
  if (list) list.push(path);
@@ -126,9 +169,12 @@ export function linkEdges(db) {
126
169
  // embed whose exact (src, target) also exists as a link: that second row is new with the
127
170
  // embed grain and would double an edge that used to be one row. The NOT EXISTS probes the
128
171
  // 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
172
+ // Self-edges (same-note anchors) are excluded here so PageRank mass is not self-recycled --
173
+ // Obsidian's own graph view hides self-loops too. The rows themselves stay in the table for
174
+ // backlinks/peek.
175
+ const sql = `SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0 AND src != dst
130
176
  UNION ALL
131
- SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1
177
+ SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1 AND src != dst
132
178
  AND NOT EXISTS (SELECT 1 FROM links l0 WHERE l0.src = l.src AND l0.target = l.target AND l0.embed = 0)`;
133
179
  return db.prepare(sql).all().map((r)=>[
134
180
  r.src,
@@ -188,7 +234,10 @@ export const links = {
188
234
  // Upsert preserves dst on surviving rows, so an unchanged link resolves to the same
189
235
  // value and reports no change; only genuinely new rows start at NULL.
190
236
  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);
237
+ // A same-note anchor's dst is always its own src, never another file's basename, so it
238
+ // gets no target_base -- SQL's `IN` never matches NULL, keeping it out of
239
+ // resolveIncremental's cross-file collectIn('target_base', ...) entirely.
240
+ for (const { target, embed } of targets)insert.run(path, target, target.startsWith('#') ? null : baseKey(cleanTarget(target)), embed ? 1 : 0);
192
241
  },
193
242
  afterReconcile (db, delta) {
194
243
  var _ref;