sensemaking 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +16 -7
  2. package/dist/cjs/cli.js +5 -1
  3. package/dist/cjs/cli.js.map +1 -1
  4. package/dist/cjs/commands/find.js +173 -10
  5. package/dist/cjs/commands/find.js.map +1 -1
  6. package/dist/cjs/commands/map.js +1 -1
  7. package/dist/cjs/commands/map.js.map +1 -1
  8. package/dist/cjs/commands/peek.js +1 -1
  9. package/dist/cjs/commands/peek.js.map +1 -1
  10. package/dist/cjs/commands/shared.d.cts +1 -1
  11. package/dist/cjs/commands/shared.d.ts +1 -1
  12. package/dist/cjs/commands/shared.js +166 -8
  13. package/dist/cjs/commands/shared.js.map +1 -1
  14. package/dist/cjs/commands/status.js +10 -1
  15. package/dist/cjs/commands/status.js.map +1 -1
  16. package/dist/cjs/commands/types.d.cts +1 -0
  17. package/dist/cjs/commands/types.d.ts +1 -0
  18. package/dist/cjs/config.d.cts +26 -2
  19. package/dist/cjs/config.d.ts +26 -2
  20. package/dist/cjs/config.js +171 -10
  21. package/dist/cjs/config.js.map +1 -1
  22. package/dist/cjs/db.js +46 -16
  23. package/dist/cjs/db.js.map +1 -1
  24. package/dist/cjs/errors.d.cts +1 -1
  25. package/dist/cjs/errors.d.ts +1 -1
  26. package/dist/cjs/errors.js.map +1 -1
  27. package/dist/cjs/features/embed.d.cts +14 -0
  28. package/dist/cjs/features/embed.d.ts +14 -0
  29. package/dist/cjs/features/embed.js +840 -0
  30. package/dist/cjs/features/embed.js.map +1 -0
  31. package/dist/cjs/features/index.js +3 -1
  32. package/dist/cjs/features/index.js.map +1 -1
  33. package/dist/cjs/features/types.d.cts +4 -1
  34. package/dist/cjs/features/types.d.ts +4 -1
  35. package/dist/cjs/index.d.cts +3 -10
  36. package/dist/cjs/index.d.ts +3 -10
  37. package/dist/cjs/index.js +2 -52
  38. package/dist/cjs/index.js.map +1 -1
  39. package/dist/cjs/output.d.cts +5 -0
  40. package/dist/cjs/output.d.ts +5 -0
  41. package/dist/cjs/output.js +14 -1
  42. package/dist/cjs/output.js.map +1 -1
  43. package/dist/cjs/scan.js +9 -8
  44. package/dist/cjs/scan.js.map +1 -1
  45. package/dist/cjs/verbs.d.cts +8 -2
  46. package/dist/cjs/verbs.d.ts +8 -2
  47. package/dist/cjs/verbs.js +263 -76
  48. package/dist/cjs/verbs.js.map +1 -1
  49. package/dist/esm/cli.js +5 -1
  50. package/dist/esm/cli.js.map +1 -1
  51. package/dist/esm/commands/find.js +5 -4
  52. package/dist/esm/commands/find.js.map +1 -1
  53. package/dist/esm/commands/map.js +1 -3
  54. package/dist/esm/commands/map.js.map +1 -1
  55. package/dist/esm/commands/peek.js +1 -1
  56. package/dist/esm/commands/peek.js.map +1 -1
  57. package/dist/esm/commands/shared.d.ts +1 -1
  58. package/dist/esm/commands/shared.js +2 -2
  59. package/dist/esm/commands/shared.js.map +1 -1
  60. package/dist/esm/commands/status.js +8 -1
  61. package/dist/esm/commands/status.js.map +1 -1
  62. package/dist/esm/commands/types.d.ts +1 -0
  63. package/dist/esm/commands/types.js.map +1 -1
  64. package/dist/esm/config.d.ts +26 -2
  65. package/dist/esm/config.js +88 -10
  66. package/dist/esm/config.js.map +1 -1
  67. package/dist/esm/db.js +19 -10
  68. package/dist/esm/db.js.map +1 -1
  69. package/dist/esm/errors.d.ts +1 -1
  70. package/dist/esm/errors.js.map +1 -1
  71. package/dist/esm/features/embed.d.ts +14 -0
  72. package/dist/esm/features/embed.js +290 -0
  73. package/dist/esm/features/embed.js.map +1 -0
  74. package/dist/esm/features/index.js +3 -1
  75. package/dist/esm/features/index.js.map +1 -1
  76. package/dist/esm/features/types.d.ts +4 -1
  77. package/dist/esm/features/types.js.map +1 -1
  78. package/dist/esm/index.d.ts +3 -10
  79. package/dist/esm/index.js +4 -6
  80. package/dist/esm/index.js.map +1 -1
  81. package/dist/esm/output.d.ts +5 -0
  82. package/dist/esm/output.js +11 -1
  83. package/dist/esm/output.js.map +1 -1
  84. package/dist/esm/scan.js +9 -8
  85. package/dist/esm/scan.js.map +1 -1
  86. package/dist/esm/verbs.d.ts +8 -2
  87. package/dist/esm/verbs.js +51 -13
  88. package/dist/esm/verbs.js.map +1 -1
  89. package/package.json +2 -1
  90. package/schema.json +17 -1
  91. package/skills/sense/EXAMPLES.md +16 -0
  92. package/skills/sense/SKILL.md +41 -19
  93. package/skills/sense-setup/SKILL.md +73 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/scan.ts"],"sourcesContent":["import { readFileSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\nimport fastGlob from 'fast-glob';\nimport removeMarkdown from 'remove-markdown';\nimport { parseDocument } from 'yaml';\nimport type { Config } from './config.ts';\nimport type { Feature } from './features/types.ts';\n\n// Filesystem -> rows. Pure data in, data + warnings out; db.ts does the SQL.\n\n// Reserved: colliding with these would clash with the `frontmatter` table's own columns or the other tables (`content`, `links`, `sections`).\nconst RESERVED_COLUMNS = new Set(['path', '_mtime', '_size', '_rank', 'content', 'links', 'sections']);\n\nfunction normalizeText(value: unknown): string {\n if (value === null || value === undefined) return '';\n return String(value).replace(/\\s+/g, ' ').trim();\n}\n\n// Markdown is stripped at index time so snippets read as clean prose (also improves matching, e.g. `**bold**` indexes as `bold`).\n// remove-markdown doesn't cover Obsidian wikilinks or table scaffolding, so those are handled first/after.\nfunction stripText(value: string): string {\n const withoutWikilinks = value.replace(/\\[\\[([^\\]|]+)\\|([^\\]]+)\\]\\]/g, '$2').replace(/\\[\\[([^\\]]+)\\]\\]/g, '$1');\n const withoutMarkdown = removeMarkdown(withoutWikilinks);\n const withoutTables = withoutMarkdown.replace(/^\\s*\\|?[-\\s|:]+\\|\\s*$/gm, '').replace(/\\|/g, ' ');\n return normalizeText(withoutTables);\n}\n\nexport interface FileStat {\n relPath: string;\n absPath: string;\n mtimeMs: number;\n size: number;\n}\n\nexport function listFiles(cfg: Config, baseDir: string): FileStat[] {\n const relPaths = fastGlob.sync(cfg.scan.include, { cwd: baseDir }).sort();\n return relPaths.map((relPath) => {\n const absPath = join(baseDir, relPath);\n const st = statSync(absPath);\n return { relPath, absPath, mtimeMs: st.mtimeMs, size: st.size };\n });\n}\n\nexport interface ParsedDoc {\n relPath: string;\n mtimeMs: number;\n size: number;\n data: Record<string, string | number | null>;\n // title/summary are duplicated from frontmatter so bm25() can weight them above the body text.\n search: { title: string; summary: string; text: string };\n // Per-feature extraction results, keyed by feature name; features store them at reconcile.\n extracted: Record<string, unknown>;\n}\n\nfunction mapValue(value: unknown): string | number | null {\n if (value === null || value === undefined) return null;\n if (typeof value === 'boolean') return value ? 1 : 0;\n if (typeof value === 'string' || typeof value === 'number') return value;\n return JSON.stringify(value);\n}\n\n// The delimiter split is all this package used gray-matter for.\nfunction splitFrontmatter(raw: string): { fm: string | null; body: string } {\n const open = raw.match(/^---\\r?\\n/);\n if (!open) return { fm: null, body: raw };\n const rest = raw.slice(open[0].length);\n const close = rest.match(/^---\\r?(\\n|$)/m);\n if (!close || close.index === undefined) return { fm: null, body: raw };\n return { fm: rest.slice(0, close.index), body: rest.slice(close.index + close[0].length) };\n}\n\n// Lenient by design: parseDocument collects syntax errors as data and still yields values,\n// so Obsidian-style frontmatter (e.g. an alias starting with @) survives with a warning.\nfunction parseFrontmatter(relPath: string, fm: string, warnings: string[]): Record<string, unknown> {\n const doc = parseDocument(fm);\n if (doc.errors.length > 0) {\n warnings.push(`warning: ${relPath} frontmatter has ${doc.errors.length} syntax error(s) (${doc.errors[0].message.split('\\n')[0]}); parsed leniently`);\n }\n let data: unknown;\n try {\n data = doc.toJS();\n } catch (err) {\n warnings.push(`warning: ${relPath} has unparseable frontmatter (${(err as Error).message.split('\\n')[0]}); indexing without it`);\n return {};\n }\n if (data === null || data === undefined) return {};\n if (typeof data !== 'object' || Array.isArray(data)) {\n warnings.push(`warning: ${relPath} frontmatter is not a key-value mapping; ignoring it`);\n return {};\n }\n return data as Record<string, unknown>;\n}\n\nexport function parseFile(file: FileStat, extractors: Feature[] = []): { doc: ParsedDoc; warnings: string[] } {\n const raw = readFileSync(file.absPath, 'utf8');\n const warnings: string[] = [];\n\n const { fm, body: content } = splitFrontmatter(raw);\n const data = fm === null ? {} : parseFrontmatter(file.relPath, fm, warnings);\n const mapped: Record<string, string | number | null> = {};\n\n for (const key of Object.keys(data)) {\n if (RESERVED_COLUMNS.has(key)) {\n warnings.push(`warning: ${file.relPath} has a frontmatter key named \"${key}\", which is reserved; ignoring it`);\n continue;\n }\n mapped[key] = mapValue(data[key]);\n }\n\n return {\n doc: {\n relPath: file.relPath,\n mtimeMs: file.mtimeMs,\n size: file.size,\n data: mapped,\n search: {\n // title/summary are plain YAML strings -- whitespace-collapse only;\n // the prose gets the full markdown strip.\n title: normalizeText(data.title),\n summary: normalizeText(data.summary),\n text: stripText(content),\n },\n extracted: Object.fromEntries(extractors.filter((f) => f.extract).map((f) => [f.name, f.extract?.(raw, content)])),\n },\n warnings,\n };\n}\n"],"names":["readFileSync","statSync","join","fastGlob","removeMarkdown","parseDocument","RESERVED_COLUMNS","Set","normalizeText","value","undefined","String","replace","trim","stripText","withoutWikilinks","withoutMarkdown","withoutTables","listFiles","cfg","baseDir","relPaths","sync","scan","include","cwd","sort","map","relPath","absPath","st","mtimeMs","size","mapValue","JSON","stringify","splitFrontmatter","raw","open","match","fm","body","rest","slice","length","close","index","parseFrontmatter","warnings","doc","errors","push","message","split","data","toJS","err","Array","isArray","parseFile","file","extractors","content","mapped","key","Object","keys","has","search","title","summary","text","extracted","fromEntries","filter","f","extract","name"],"mappings":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/scan.ts"],"sourcesContent":["import { readFileSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\nimport fastGlob from 'fast-glob';\nimport removeMarkdown from 'remove-markdown';\nimport { parseDocument } from 'yaml';\nimport type { Config } from './config.ts';\nimport type { Feature } from './features/types.ts';\n\n// Filesystem -> rows. Pure data in, data + warnings out; db.ts does the SQL.\n\n// Reserved: colliding with these would clash with the `frontmatter` table's own columns or the other tables (`content`, `links`, `sections`).\nconst RESERVED_COLUMNS = new Set(['path', '_mtime', '_size', '_rank', 'content', 'links', 'sections']);\n\nfunction normalizeText(value: unknown): string {\n if (value === null || value === undefined) return '';\n return String(value).replace(/\\s+/g, ' ').trim();\n}\n\n// Markdown is stripped at index time so snippets read as clean prose (also improves matching, e.g. `**bold**` indexes as `bold`).\n// remove-markdown doesn't cover Obsidian wikilinks or table scaffolding, so those are handled first/after.\nfunction stripText(value: string): string {\n const withoutWikilinks = value.replace(/\\[\\[([^\\]|]+)\\|([^\\]]+)\\]\\]/g, '$2').replace(/\\[\\[([^\\]]+)\\]\\]/g, '$1');\n const withoutMarkdown = removeMarkdown(withoutWikilinks);\n const withoutTables = withoutMarkdown.replace(/^\\s*\\|?[-\\s|:]+\\|\\s*$/gm, '').replace(/\\|/g, ' ');\n return normalizeText(withoutTables);\n}\n\nexport interface FileStat {\n relPath: string;\n absPath: string;\n mtimeMs: number;\n size: number;\n}\n\nexport function listFiles(cfg: Config, baseDir: string): FileStat[] {\n const relPaths = fastGlob.sync(cfg.scan.include, { cwd: baseDir }).sort();\n return relPaths.map((relPath) => {\n const absPath = join(baseDir, relPath);\n const st = statSync(absPath);\n return { relPath, absPath, mtimeMs: st.mtimeMs, size: st.size };\n });\n}\n\nexport interface ParsedDoc {\n relPath: string;\n mtimeMs: number;\n size: number;\n data: Record<string, string | number | null>;\n // title/summary are duplicated from frontmatter so bm25() can weight them above the body text.\n search: { title: string; summary: string; text: string };\n // Per-feature extraction results, keyed by feature name; features store them at reconcile.\n extracted: Record<string, unknown>;\n}\n\nfunction mapValue(value: unknown): string | number | null {\n if (value === null || value === undefined) return null;\n if (typeof value === 'boolean') return value ? 1 : 0;\n if (typeof value === 'string' || typeof value === 'number') return value;\n return JSON.stringify(value);\n}\n\n// The delimiter split is all this package used gray-matter for.\nfunction splitFrontmatter(raw: string): { fm: string | null; body: string } {\n const open = raw.match(/^---\\r?\\n/);\n if (!open) return { fm: null, body: raw };\n const rest = raw.slice(open[0].length);\n const close = rest.match(/^---\\r?(\\n|$)/m);\n if (!close || close.index === undefined) return { fm: null, body: raw };\n return { fm: rest.slice(0, close.index), body: rest.slice(close.index + close[0].length) };\n}\n\n// Lenient by design: parseDocument collects syntax errors as data and still yields values,\n// so Obsidian-style frontmatter (e.g. an alias starting with @) survives with a warning.\nfunction parseFrontmatter(relPath: string, fm: string, warnings: string[]): Record<string, unknown> {\n const doc = parseDocument(fm);\n if (doc.errors.length > 0) {\n warnings.push(`warning: ${relPath} frontmatter has ${doc.errors.length} syntax error(s) (${doc.errors[0].message.split('\\n')[0]}); parsed leniently`);\n }\n let data: unknown;\n try {\n data = doc.toJS();\n } catch (err) {\n warnings.push(`warning: ${relPath} has unparseable frontmatter (${(err as Error).message.split('\\n')[0]}); indexing without it`);\n return {};\n }\n if (data === null || data === undefined) return {};\n if (typeof data !== 'object' || Array.isArray(data)) {\n warnings.push(`warning: ${relPath} frontmatter is not a key-value mapping; ignoring it`);\n return {};\n }\n return data as Record<string, unknown>;\n}\n\nexport function parseFile(file: FileStat, extractors: Feature[] = []): { doc: ParsedDoc; warnings: string[] } {\n const raw = readFileSync(file.absPath, 'utf8');\n const warnings: string[] = [];\n\n const { fm, body: content } = splitFrontmatter(raw);\n const data = fm === null ? {} : parseFrontmatter(file.relPath, fm, warnings);\n const mapped: Record<string, string | number | null> = {};\n\n for (const key of Object.keys(data)) {\n if (RESERVED_COLUMNS.has(key)) {\n warnings.push(`warning: ${file.relPath} has a frontmatter key named \"${key}\", which is reserved; ignoring it`);\n continue;\n }\n mapped[key] = mapValue(data[key]);\n }\n\n // title/summary are plain YAML strings -- whitespace-collapse only;\n // the prose gets the full markdown strip.\n const search = { title: normalizeText(data.title), summary: normalizeText(data.summary), text: stripText(content) };\n\n return {\n doc: {\n relPath: file.relPath,\n mtimeMs: file.mtimeMs,\n size: file.size,\n data: mapped,\n search,\n extracted: Object.fromEntries(extractors.filter((f) => f.extract).map((f) => [f.name, f.extract?.(raw, content, search)])),\n },\n warnings,\n };\n}\n"],"names":["readFileSync","statSync","join","fastGlob","removeMarkdown","parseDocument","RESERVED_COLUMNS","Set","normalizeText","value","undefined","String","replace","trim","stripText","withoutWikilinks","withoutMarkdown","withoutTables","listFiles","cfg","baseDir","relPaths","sync","scan","include","cwd","sort","map","relPath","absPath","st","mtimeMs","size","mapValue","JSON","stringify","splitFrontmatter","raw","open","match","fm","body","rest","slice","length","close","index","parseFrontmatter","warnings","doc","errors","push","message","split","data","toJS","err","Array","isArray","parseFile","file","extractors","content","mapped","key","Object","keys","has","search","title","summary","text","extracted","fromEntries","filter","f","extract","name"],"mappings":"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"}
@@ -1,11 +1,12 @@
1
1
  import type { DatabaseSync } from 'node:sqlite';
2
- import type { Config } from './config.js';
2
+ import type { Config, FeatureName } from './config.js';
3
3
  import type { Row } from './output.js';
4
4
  export interface FindOptions {
5
5
  k?: number;
6
6
  where?: string;
7
+ semantic?: boolean;
7
8
  }
8
- export declare function find(db: DatabaseSync, cfg: Config, terms: string, opts?: FindOptions): Row[];
9
+ export declare function find(db: DatabaseSync, cfg: Config, terms: string, opts?: FindOptions): Promise<Row[]>;
9
10
  export interface TreeMap {
10
11
  docs: {
11
12
  count: number;
@@ -13,6 +14,10 @@ export interface TreeMap {
13
14
  };
14
15
  fields: Row[];
15
16
  fieldsTotal: number;
17
+ features: {
18
+ on: FeatureName[];
19
+ off: FeatureName[];
20
+ };
16
21
  hubs: Row[];
17
22
  recent: Row[];
18
23
  }
@@ -28,5 +33,6 @@ export interface Peek {
28
33
  outboundTotal: number;
29
34
  backlinksTotal: number;
30
35
  unresolvedTotal: number;
36
+ off: FeatureName[];
31
37
  }
32
38
  export declare function peek(db: DatabaseSync, cfg: Config, pathArg: string): Peek;
package/dist/esm/verbs.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import posix from 'node:path/posix';
2
- import { featureEnabled } from './config.js';
2
+ import { featureEnabled, featureStates } from './config.js';
3
3
  import { SenseError } from './errors.js';
4
+ import { semanticCandidates } from './features/embed.js';
4
5
  import { linkEdges } from './features/index.js';
5
6
  import { personalizedRank } from './graph.js';
6
7
  // The three layer verbs: mapTree (orient), find (locate), peek (structure).
@@ -9,13 +10,19 @@ const WEIGHTED_BM25 = 'bm25(content, 10.0, 5.0, 1.0)';
9
10
  const RRF_K = 60;
10
11
  // Layer 1: BM25 + link-graph expansion, fused by reciprocal rank. `via` says which
11
12
  // signal produced each row so the agent knows what evidence it is trusting.
12
- export function find(db, cfg, terms, opts = {}) {
13
- var _opts_k, _hits_get;
13
+ // Semantic expansion is per-query opt-in: without opts.semantic the result is
14
+ // byte-for-byte independent of the embed feature.
15
+ export async function find(db, cfg, terms, opts = {}) {
16
+ var _opts_k, _hits_get, _chunkLines_get;
14
17
  const k = (_opts_k = opts.k) !== null && _opts_k !== void 0 ? _opts_k : 10;
15
18
  const fetch = Math.max(k * 3, 30);
16
19
  // Terms pass verbatim to FTS5 MATCH: bare words AND-join, operators are the caller's.
17
20
  // Invalid syntax propagates as an error, zero matches return zero -- no silent rewrites.
18
- const matchSql = `SELECT content.path AS path, snippet(content, -1, '«', '»', '…', 10) AS hit FROM content WHERE content MATCH ? ORDER BY ${WEIGHTED_BM25} LIMIT ${fetch}`;
21
+ // --where applies inside the candidate query (a post-filter over the top-N would drop
22
+ // matches ranked past the pool) and again on the final select for link-derived rows.
23
+ const whereJoin = opts.where ? `JOIN frontmatter f ON f."path" = content.path` : '';
24
+ const whereCond = opts.where ? `AND (${opts.where})` : '';
25
+ const matchSql = `SELECT content.path AS path, snippet(content, -1, '«', '»', '…', 10) AS hit FROM content ${whereJoin} WHERE content MATCH ? ${whereCond} ORDER BY ${WEIGHTED_BM25} LIMIT ${fetch}`;
19
26
  const matchRows = db.prepare(matchSql).all(terms);
20
27
  const hits = new Map(matchRows.map((r)=>[
21
28
  r.path,
@@ -28,20 +35,26 @@ export function find(db, cfg, terms, opts = {}) {
28
35
  via: 'match'
29
36
  });
30
37
  });
31
- if (featureEnabled(cfg, 'links') && matchRows.length > 0) {
38
+ const edges = featureEnabled(cfg, 'links') && matchRows.length > 0 ? linkEdges(db) : [];
39
+ if (edges.length > 0) {
40
+ // `linked` gates the label only, not the score: PPR restart mass gives every seed a
41
+ // nonzero rank even without an incident edge, which is not link evidence — but dropping
42
+ // that mass from the score list reweights fusion toward connectivity and measurably
43
+ // wrecks ranking on link-dense corpora (FEVER hit@10 0.997 -> 0.907; fusion-tuning.md).
44
+ const linked = new Set(edges.flat());
32
45
  const nodes = db.prepare('SELECT "path" FROM frontmatter').all().map((r)=>r.path);
33
46
  const seeds = new Map(matchRows.map((r, i)=>[
34
47
  r.path,
35
48
  1 / (i + 1)
36
49
  ]));
37
50
  const ranked = [
38
- ...personalizedRank(nodes, linkEdges(db), seeds)
51
+ ...personalizedRank(nodes, edges, seeds)
39
52
  ].filter(([, score])=>score > 1e-9).sort((a, b)=>b[1] - a[1]).slice(0, fetch);
40
53
  ranked.forEach(([path], i)=>{
41
54
  const existing = candidates.get(path);
42
55
  if (existing) {
43
56
  existing.score += 1 / (RRF_K + i);
44
- existing.via = 'match+link';
57
+ if (linked.has(path)) existing.via = 'match+link';
45
58
  } else {
46
59
  candidates.set(path, {
47
60
  score: 1 / (RRF_K + i),
@@ -50,12 +63,32 @@ export function find(db, cfg, terms, opts = {}) {
50
63
  }
51
64
  });
52
65
  }
53
- db.exec('CREATE TEMP TABLE IF NOT EXISTS _find ("path" TEXT PRIMARY KEY, score REAL, via TEXT, hit TEXT)');
54
- db.exec('DELETE FROM _find');
55
- const insert = db.prepare('INSERT INTO _find ("path", score, via, hit) VALUES (?, ?, ?, ?)');
56
- for (const [path, c] of candidates)insert.run(path, c.score, c.via, (_hits_get = hits.get(path)) !== null && _hits_get !== void 0 ? _hits_get : null);
66
+ // Vector expansion, invoked only: a third RRF list at the swept flat-region constants
67
+ // (weight 1, pool = fetch). Each row carries its best chunk's line range.
68
+ const chunkLines = new Map();
69
+ if (opts.semantic) {
70
+ const vec = await semanticCandidates(db, cfg, terms, fetch);
71
+ vec.forEach(({ path, lines }, i)=>{
72
+ chunkLines.set(path, lines);
73
+ const existing = candidates.get(path);
74
+ if (existing) {
75
+ existing.score += 1 / (RRF_K + i);
76
+ existing.via = `${existing.via}+vector`;
77
+ } else {
78
+ candidates.set(path, {
79
+ score: 1 / (RRF_K + i),
80
+ via: 'vector'
81
+ });
82
+ }
83
+ });
84
+ }
85
+ db.exec('DROP TABLE IF EXISTS _find');
86
+ db.exec('CREATE TEMP TABLE _find ("path" TEXT PRIMARY KEY, score REAL, via TEXT, hit TEXT, lines TEXT)');
87
+ const insert = db.prepare('INSERT INTO _find ("path", score, via, hit, lines) VALUES (?, ?, ?, ?, ?)');
88
+ for (const [path, c] of candidates)insert.run(path, c.score, c.via, (_hits_get = hits.get(path)) !== null && _hits_get !== void 0 ? _hits_get : null, (_chunkLines_get = chunkLines.get(path)) !== null && _chunkLines_get !== void 0 ? _chunkLines_get : null);
57
89
  const where = opts.where ? `WHERE ${opts.where}` : '';
58
- return db.prepare(`SELECT f."path" AS path, content.title, content.summary, _find.hit, _find.via, round(_find.score, 4) AS score
90
+ const linesCol = opts.semantic ? ', _find.lines' : '';
91
+ return db.prepare(`SELECT f."path" AS path, content.title, content.summary, _find.hit, _find.via, round(_find.score, 4) AS score${linesCol}
59
92
  FROM _find JOIN frontmatter f ON f."path" = _find."path" JOIN content ON content.path = _find."path"
60
93
  ${where} ORDER BY _find.score DESC LIMIT ?`).all(k);
61
94
  }
@@ -83,6 +116,7 @@ export function mapTree(db, cfg) {
83
116
  docs,
84
117
  fields,
85
118
  fieldsTotal: allFields.length,
119
+ features: featureStates(cfg),
86
120
  hubs,
87
121
  recent
88
122
  };
@@ -130,6 +164,10 @@ export function peek(db, cfg, pathArg) {
130
164
  unresolved: unresolved.slice(0, PEEK_LINK_LIMIT),
131
165
  outboundTotal: outbound.length,
132
166
  backlinksTotal,
133
- unresolvedTotal: unresolved.length
167
+ unresolvedTotal: unresolved.length,
168
+ off: [
169
+ 'sections',
170
+ 'links'
171
+ ].filter((name)=>!featureEnabled(cfg, name))
134
172
  };
135
173
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/verbs.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { Config } from './config.ts';\nimport { featureEnabled } from './config.ts';\nimport { SenseError } from './errors.ts';\nimport { linkEdges } from './features/index.ts';\nimport { personalizedRank } from './graph.ts';\nimport type { Row } from './output.ts';\n\n// The three layer verbs: mapTree (orient), find (locate), peek (structure).\n// Each returns data; cli.ts renders. All of them degrade when a feature is off.\n\nconst WEIGHTED_BM25 = 'bm25(content, 10.0, 5.0, 1.0)';\nconst RRF_K = 60;\n\nexport interface FindOptions {\n k?: number;\n where?: string; // SQL fragment against frontmatter alias `f`, e.g. \"f.status = 'active'\"\n}\n\n// Layer 1: BM25 + link-graph expansion, fused by reciprocal rank. `via` says which\n// signal produced each row so the agent knows what evidence it is trusting.\nexport function find(db: DatabaseSync, cfg: Config, terms: string, opts: FindOptions = {}): Row[] {\n const k = opts.k ?? 10;\n const fetch = Math.max(k * 3, 30);\n\n // Terms pass verbatim to FTS5 MATCH: bare words AND-join, operators are the caller's.\n // Invalid syntax propagates as an error, zero matches return zero -- no silent rewrites.\n const matchSql = `SELECT content.path AS path, snippet(content, -1, '«', '»', '…', 10) AS hit FROM content WHERE content MATCH ? ORDER BY ${WEIGHTED_BM25} LIMIT ${fetch}`;\n const matchRows = db.prepare(matchSql).all(terms) as Array<{ path: string; hit: string }>;\n\n const hits = new Map(matchRows.map((r) => [r.path, r.hit]));\n const candidates = new Map<string, { score: number; via: string }>();\n matchRows.forEach((r, i) => {\n candidates.set(r.path, { score: 1 / (RRF_K + i), via: 'match' });\n });\n\n if (featureEnabled(cfg, 'links') && matchRows.length > 0) {\n const nodes = (db.prepare('SELECT \"path\" FROM frontmatter').all() as Array<{ path: string }>).map((r) => r.path);\n const seeds = new Map(matchRows.map((r, i) => [r.path, 1 / (i + 1)]));\n const ranked = [...personalizedRank(nodes, linkEdges(db), seeds)]\n .filter(([, score]) => score > 1e-9)\n .sort((a, b) => b[1] - a[1])\n .slice(0, fetch);\n ranked.forEach(([path], i) => {\n const existing = candidates.get(path);\n if (existing) {\n existing.score += 1 / (RRF_K + i);\n existing.via = 'match+link';\n } else {\n candidates.set(path, { score: 1 / (RRF_K + i), via: 'link' });\n }\n });\n }\n\n db.exec('CREATE TEMP TABLE IF NOT EXISTS _find (\"path\" TEXT PRIMARY KEY, score REAL, via TEXT, hit TEXT)');\n db.exec('DELETE FROM _find');\n const insert = db.prepare('INSERT INTO _find (\"path\", score, via, hit) VALUES (?, ?, ?, ?)');\n for (const [path, c] of candidates) insert.run(path, c.score, c.via, hits.get(path) ?? null);\n\n const where = opts.where ? `WHERE ${opts.where}` : '';\n return db\n .prepare(\n `SELECT f.\"path\" AS path, content.title, content.summary, _find.hit, _find.via, round(_find.score, 4) AS score\n FROM _find JOIN frontmatter f ON f.\"path\" = _find.\"path\" JOIN content ON content.path = _find.\"path\"\n ${where} ORDER BY _find.score DESC LIMIT ?`\n )\n .all(k) as Row[];\n}\n\nexport interface TreeMap {\n docs: { count: number; bytes: number };\n fields: Row[]; // top 20 by coverage; fieldsTotal carries the real count\n fieldsTotal: number;\n hubs: Row[];\n recent: Row[];\n}\n\nconst INTERNAL_COLUMNS = new Set(['path', '_mtime', '_size', '_rank']);\n\n// Layer 0: what is this tree. Fixed-size output regardless of tree size.\nexport function mapTree(db: DatabaseSync, cfg: Config): TreeMap {\n const docs = db.prepare('SELECT COUNT(*) AS count, COALESCE(SUM(\"_size\"), 0) AS bytes FROM frontmatter').get() as { count: number; bytes: number };\n\n const columns = (db.prepare('PRAGMA table_info(frontmatter)').all() as Array<{ name: string }>).map((r) => r.name).filter((name) => !INTERNAL_COLUMNS.has(name));\n const allFields = columns\n .map((name) => {\n const { n } = db.prepare(`SELECT COUNT(\"${name.split('\"').join('\"\"')}\") AS n FROM frontmatter`).get() as { n: number };\n return { field: name, coverage: n };\n })\n .sort((a, b) => (b.coverage as number) - (a.coverage as number)) as Row[];\n const fields = allFields.slice(0, 20);\n\n const hubs = featureEnabled(cfg, 'rank') ? (db.prepare(`SELECT f.\"path\" AS path, round(f.\"_rank\" * 100, 2) AS rank, content.title FROM frontmatter f JOIN content ON content.path = f.\"path\" WHERE f.\"_rank\" IS NOT NULL ORDER BY f.\"_rank\" DESC LIMIT 8`).all() as Row[]) : [];\n\n const recent = db.prepare(`SELECT \"path\", datetime(\"_mtime\" / 1000, 'unixepoch') AS modified FROM frontmatter ORDER BY \"_mtime\" DESC LIMIT 5`).all() as Row[];\n\n return { docs, fields, fieldsTotal: allFields.length, hubs, recent };\n}\n\nexport interface Peek {\n path: string;\n tokens: number;\n frontmatter: Row;\n sections: Row[];\n outbound: string[];\n backlinks: string[];\n unresolved: string[];\n // Totals before truncation: a hub can have thousands of backlinks, and peek's whole\n // point is bounded output. Query the links table directly for the full list.\n outboundTotal: number;\n backlinksTotal: number;\n unresolvedTotal: number;\n}\n\nconst PEEK_LINK_LIMIT = 20;\n\n// Layer 2: 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: Config, pathArg: string): Peek {\n const paths = (db.prepare('SELECT \"path\" FROM frontmatter').all() as Array<{ path: string }>).map((r) => r.path);\n let path = paths.find((p) => p === pathArg);\n if (!path) {\n const base = posix.basename(pathArg).replace(/\\.md$/i, '').toLowerCase();\n const matches = paths.filter((p) => posix.basename(p).replace(/\\.md$/i, '').toLowerCase() === base);\n if (matches.length === 1) path = matches[0];\n else if (matches.length > 1) throw new SenseError('NOTE_AMBIGUOUS', `\"${pathArg}\" is ambiguous: ${matches.join(', ')}`);\n else throw new SenseError('NOTE_NOT_FOUND', `no note matches \"${pathArg}\"`);\n }\n\n const row = db.prepare('SELECT * FROM frontmatter WHERE \"path\" = ?').get(path) as Row;\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 sections = featureEnabled(cfg, 'sections') ? (db.prepare('SELECT level, heading, start_line, end_line, tokens FROM sections WHERE \"path\" = ? ORDER BY idx').all(path) as Row[]) : [];\n\n let outbound: string[] = [];\n let backlinks: string[] = [];\n let unresolved: string[] = [];\n let backlinksTotal = 0;\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_LINK_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 sections,\n outbound: outbound.slice(0, PEEK_LINK_LIMIT),\n backlinks,\n unresolved: unresolved.slice(0, PEEK_LINK_LIMIT),\n outboundTotal: outbound.length,\n backlinksTotal,\n unresolvedTotal: unresolved.length,\n };\n}\n"],"names":["posix","featureEnabled","SenseError","linkEdges","personalizedRank","WEIGHTED_BM25","RRF_K","find","db","cfg","terms","opts","hits","k","fetch","Math","max","matchSql","matchRows","prepare","all","Map","map","r","path","hit","candidates","forEach","i","set","score","via","length","nodes","seeds","ranked","filter","sort","a","b","slice","existing","get","exec","insert","c","run","where","INTERNAL_COLUMNS","Set","mapTree","docs","columns","name","has","allFields","n","split","join","field","coverage","fields","hubs","recent","fieldsTotal","PEEK_LINK_LIMIT","peek","pathArg","row","paths","p","base","basename","replace","toLowerCase","matches","frontmatter","key","value","Object","entries","sections","outbound","backlinks","unresolved","backlinksTotal","out","l","dst","target","src","tokens","ceil","_size","outboundTotal","unresolvedTotal"],"mappings":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/verbs.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { Config, FeatureName } from './config.ts';\nimport { featureEnabled, featureStates } from './config.ts';\nimport { SenseError } from './errors.ts';\nimport { semanticCandidates } from './features/embed.ts';\nimport { linkEdges } from './features/index.ts';\nimport { personalizedRank } from './graph.ts';\nimport type { Row } from './output.ts';\n\n// The three layer verbs: mapTree (orient), find (locate), peek (structure).\n// Each returns data; cli.ts renders. All of them degrade when a feature is off.\n\nconst WEIGHTED_BM25 = 'bm25(content, 10.0, 5.0, 1.0)';\nconst RRF_K = 60;\n\nexport interface FindOptions {\n k?: number;\n where?: string; // SQL fragment against frontmatter alias `f`, e.g. \"f.status = 'active'\"\n semantic?: boolean; // invoke vector expansion (requires features.embed); rows gain via 'vector' and a lines column\n}\n\n// Layer 1: BM25 + link-graph expansion, fused by reciprocal rank. `via` says which\n// signal produced each row so the agent knows what evidence it is trusting.\n// Semantic expansion is per-query opt-in: without opts.semantic the result is\n// byte-for-byte independent of the embed feature.\nexport async function find(db: DatabaseSync, cfg: Config, terms: string, opts: FindOptions = {}): Promise<Row[]> {\n const k = opts.k ?? 10;\n const fetch = Math.max(k * 3, 30);\n\n // Terms pass verbatim to FTS5 MATCH: bare words AND-join, operators are the caller's.\n // Invalid syntax propagates as an error, zero matches return zero -- no silent rewrites.\n // --where applies inside the candidate query (a post-filter over the top-N would drop\n // matches ranked past the pool) and again on the final select for link-derived rows.\n const whereJoin = opts.where ? `JOIN frontmatter f ON f.\"path\" = content.path` : '';\n const whereCond = opts.where ? `AND (${opts.where})` : '';\n const matchSql = `SELECT content.path AS path, snippet(content, -1, '«', '»', '…', 10) AS hit FROM content ${whereJoin} WHERE content MATCH ? ${whereCond} ORDER BY ${WEIGHTED_BM25} LIMIT ${fetch}`;\n const matchRows = db.prepare(matchSql).all(terms) as Array<{ path: string; hit: string }>;\n\n const hits = new Map(matchRows.map((r) => [r.path, r.hit]));\n const candidates = new Map<string, { score: number; via: string }>();\n matchRows.forEach((r, i) => {\n candidates.set(r.path, { score: 1 / (RRF_K + i), via: 'match' });\n });\n\n const edges = featureEnabled(cfg, 'links') && matchRows.length > 0 ? linkEdges(db) : [];\n if (edges.length > 0) {\n // `linked` gates the label only, not the score: PPR restart mass gives every seed a\n // nonzero rank even without an incident edge, which is not link evidence — but dropping\n // that mass from the score list reweights fusion toward connectivity and measurably\n // wrecks ranking on link-dense corpora (FEVER hit@10 0.997 -> 0.907; fusion-tuning.md).\n const linked = new Set(edges.flat());\n const nodes = (db.prepare('SELECT \"path\" FROM frontmatter').all() as Array<{ path: string }>).map((r) => r.path);\n const seeds = new Map(matchRows.map((r, i) => [r.path, 1 / (i + 1)]));\n const ranked = [...personalizedRank(nodes, edges, seeds)]\n .filter(([, score]) => score > 1e-9)\n .sort((a, b) => b[1] - a[1])\n .slice(0, fetch);\n ranked.forEach(([path], i) => {\n const existing = candidates.get(path);\n if (existing) {\n existing.score += 1 / (RRF_K + i);\n if (linked.has(path)) existing.via = 'match+link';\n } else {\n candidates.set(path, { score: 1 / (RRF_K + i), via: 'link' });\n }\n });\n }\n\n // Vector expansion, invoked only: a third RRF list at the swept flat-region constants\n // (weight 1, pool = fetch). Each row carries its best chunk's line range.\n const chunkLines = new Map<string, string>();\n if (opts.semantic) {\n const vec = await semanticCandidates(db, cfg, terms, fetch);\n vec.forEach(({ path, lines }, i) => {\n chunkLines.set(path, lines);\n const existing = candidates.get(path);\n if (existing) {\n existing.score += 1 / (RRF_K + i);\n existing.via = `${existing.via}+vector`;\n } else {\n candidates.set(path, { score: 1 / (RRF_K + i), via: 'vector' });\n }\n });\n }\n\n db.exec('DROP TABLE IF EXISTS _find');\n db.exec('CREATE TEMP TABLE _find (\"path\" TEXT PRIMARY KEY, score REAL, via TEXT, hit TEXT, lines TEXT)');\n const insert = db.prepare('INSERT INTO _find (\"path\", score, via, hit, lines) VALUES (?, ?, ?, ?, ?)');\n for (const [path, c] of candidates) insert.run(path, c.score, c.via, hits.get(path) ?? null, chunkLines.get(path) ?? null);\n\n const where = opts.where ? `WHERE ${opts.where}` : '';\n const linesCol = opts.semantic ? ', _find.lines' : '';\n return db\n .prepare(\n `SELECT f.\"path\" AS path, content.title, content.summary, _find.hit, _find.via, round(_find.score, 4) AS score${linesCol}\n FROM _find JOIN frontmatter f ON f.\"path\" = _find.\"path\" JOIN content ON content.path = _find.\"path\"\n ${where} ORDER BY _find.score DESC LIMIT ?`\n )\n .all(k) as Row[];\n}\n\nexport interface TreeMap {\n docs: { count: number; bytes: number };\n fields: Row[]; // top 20 by coverage; fieldsTotal carries the real count\n fieldsTotal: number;\n features: { on: FeatureName[]; off: FeatureName[] };\n hubs: Row[];\n recent: Row[];\n}\n\nconst INTERNAL_COLUMNS = new Set(['path', '_mtime', '_size', '_rank']);\n\n// Layer 0: what is this tree. Fixed-size output regardless of tree size.\nexport function mapTree(db: DatabaseSync, cfg: Config): TreeMap {\n const docs = db.prepare('SELECT COUNT(*) AS count, COALESCE(SUM(\"_size\"), 0) AS bytes FROM frontmatter').get() as { count: number; bytes: number };\n\n const columns = (db.prepare('PRAGMA table_info(frontmatter)').all() as Array<{ name: string }>).map((r) => r.name).filter((name) => !INTERNAL_COLUMNS.has(name));\n const allFields = columns\n .map((name) => {\n const { n } = db.prepare(`SELECT COUNT(\"${name.split('\"').join('\"\"')}\") AS n FROM frontmatter`).get() as { n: number };\n return { field: name, coverage: n };\n })\n .sort((a, b) => (b.coverage as number) - (a.coverage as number)) as Row[];\n const fields = allFields.slice(0, 20);\n\n const hubs = featureEnabled(cfg, 'rank') ? (db.prepare(`SELECT f.\"path\" AS path, round(f.\"_rank\" * 100, 2) AS rank, content.title FROM frontmatter f JOIN content ON content.path = f.\"path\" WHERE f.\"_rank\" IS NOT NULL ORDER BY f.\"_rank\" DESC LIMIT 8`).all() as Row[]) : [];\n\n const recent = db.prepare(`SELECT \"path\", datetime(\"_mtime\" / 1000, 'unixepoch') AS modified FROM frontmatter ORDER BY \"_mtime\" DESC LIMIT 5`).all() as Row[];\n\n return { docs, fields, fieldsTotal: allFields.length, features: featureStates(cfg), hubs, recent };\n}\n\nexport interface Peek {\n path: string;\n tokens: number;\n frontmatter: Row;\n sections: Row[];\n outbound: string[];\n backlinks: string[];\n unresolved: string[];\n // Totals before truncation: a hub can have thousands of backlinks, and peek's whole\n // point is bounded output. Query the links table directly for the full list.\n outboundTotal: number;\n backlinksTotal: number;\n unresolvedTotal: number;\n off: FeatureName[]; // disabled features whose blocks are omitted (not empty)\n}\n\nconst PEEK_LINK_LIMIT = 20;\n\n// Layer 2: 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: Config, pathArg: string): Peek {\n const paths = (db.prepare('SELECT \"path\" FROM frontmatter').all() as Array<{ path: string }>).map((r) => r.path);\n let path = paths.find((p) => p === pathArg);\n if (!path) {\n const base = posix.basename(pathArg).replace(/\\.md$/i, '').toLowerCase();\n const matches = paths.filter((p) => posix.basename(p).replace(/\\.md$/i, '').toLowerCase() === base);\n if (matches.length === 1) path = matches[0];\n else if (matches.length > 1) throw new SenseError('NOTE_AMBIGUOUS', `\"${pathArg}\" is ambiguous: ${matches.join(', ')}`);\n else throw new SenseError('NOTE_NOT_FOUND', `no note matches \"${pathArg}\"`);\n }\n\n const row = db.prepare('SELECT * FROM frontmatter WHERE \"path\" = ?').get(path) as Row;\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 sections = featureEnabled(cfg, 'sections') ? (db.prepare('SELECT level, heading, start_line, end_line, tokens FROM sections WHERE \"path\" = ? ORDER BY idx').all(path) as Row[]) : [];\n\n let outbound: string[] = [];\n let backlinks: string[] = [];\n let unresolved: string[] = [];\n let backlinksTotal = 0;\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_LINK_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 sections,\n outbound: outbound.slice(0, PEEK_LINK_LIMIT),\n backlinks,\n unresolved: unresolved.slice(0, PEEK_LINK_LIMIT),\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","featureStates","SenseError","semanticCandidates","linkEdges","personalizedRank","WEIGHTED_BM25","RRF_K","find","db","cfg","terms","opts","hits","chunkLines","k","fetch","Math","max","whereJoin","where","whereCond","matchSql","matchRows","prepare","all","Map","map","r","path","hit","candidates","forEach","i","set","score","via","edges","length","linked","Set","flat","nodes","seeds","ranked","filter","sort","a","b","slice","existing","get","has","semantic","vec","lines","exec","insert","c","run","linesCol","INTERNAL_COLUMNS","mapTree","docs","columns","name","allFields","n","split","join","field","coverage","fields","hubs","recent","fieldsTotal","features","PEEK_LINK_LIMIT","peek","pathArg","row","paths","p","base","basename","replace","toLowerCase","matches","frontmatter","key","value","Object","entries","sections","outbound","backlinks","unresolved","backlinksTotal","out","l","dst","target","src","tokens","ceil","_size","outboundTotal","unresolvedTotal","off"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sensemaking",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Query a knowledge base you build with an agent: filter notes by frontmatter, then search inside them",
5
5
  "keywords": [
6
6
  "markdown",
@@ -47,6 +47,7 @@
47
47
  "version": "tsds version"
48
48
  },
49
49
  "dependencies": {
50
+ "@huggingface/tokenizers": "^0.1.3",
50
51
  "fast-glob": "^3.3.3",
51
52
  "remove-markdown": "^0.6.4",
52
53
  "yaml": "^2.9.0"
package/schema.json CHANGED
@@ -45,12 +45,28 @@
45
45
  "rank": {
46
46
  "type": "boolean",
47
47
  "description": "PageRank over resolved links into `frontmatter._rank` at reconcile -- a static importance prior. Powers `map`'s hub list; usable in any ORDER BY. Requires `links`."
48
+ },
49
+ "embed": {
50
+ "description": "Semantic vectors, opt-in (absent = off; most trees don't need them). When on, vectors are computed and kept fresh at reconcile; expansion runs only on `find --semantic` invocations -- default `find` results are unchanged. `true` = defaults (static type, minishlab/potion-retrieval-32M, downloaded to ~/.cache/sensemaking on first use). Object form: `model` (Hugging Face id or local path), `type` (`static` = built-in pure-JS Model2Vec loader; `api` = OpenAI-compatible POST /embeddings), `url` (api base, e.g. http://localhost:11434/v1), `key` (name of the env var holding the bearer token). Changing model or type rebuilds the cache like a feature toggle.",
51
+ "oneOf": [
52
+ { "type": "boolean" },
53
+ {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "properties": {
57
+ "model": { "type": "string" },
58
+ "type": { "type": "string", "enum": ["static", "api"] },
59
+ "url": { "type": "string" },
60
+ "key": { "type": "string" }
61
+ }
62
+ }
63
+ ]
48
64
  }
49
65
  }
50
66
  },
51
67
  "queries": {
52
68
  "type": "object",
53
- "description": "Named SQL queries runnable as `sense <name> [params...]`. Tables: `frontmatter` (one row per file, one column per discovered frontmatter key, plus `path`/`_mtime`/`_size`/`_rank`), `content` (FTS5: `title`, `summary`, `text`, `path`), `links` (`src`, `target`, `dst`), and `sections` (`path`, `idx`, `level`, `heading`, `start_line`, `end_line`, `tokens`). `?` placeholders bind to CLI positional args in order. `has(field, value)`: array membership on a JSON-array field, substring match on a string, false on NULL. Canonical query: `SELECT f.path, content.title, content.summary, snippet(content, -1, '«', '»', '…', 10) AS hit FROM frontmatter f JOIN content ON content.path = f.path WHERE content MATCH ? ORDER BY bm25(content, 10.0, 5.0, 1.0) LIMIT 10`. Reserved frontmatter keys: `path`, `_mtime`, `_size`, `_rank`, `content`, `links`, `sections`. Reserved query names (unreachable as subcommands): `init`, `query`, `find`, `map`, `peek`, `watch`, `status`, `rebuild`.",
69
+ "description": "Named SQL queries runnable as `sense <name> [params...]`. Tables: `frontmatter` (one row per file, one column per discovered frontmatter key, plus `path`/`_mtime`/`_size`/`_rank`), `content` (FTS5: `title`, `summary`, `text`, `path`), `links` (`src`, `target`, `dst`), and `sections` (`path`, `idx`, `level`, `heading`, `start_line`, `end_line`, `tokens`). `?` placeholders bind to CLI positional args in order. `has(field, value)`: array membership on a JSON-array field, substring match on a string (so has(f.status, 'active') also matches 'inactive'), false on NULL. Exact matches: `=` for scalars, `EXISTS (SELECT 1 FROM json_each(f.tags) WHERE value = ?)` for array members. Canonical query: `SELECT f.path, content.title, content.summary, snippet(content, -1, '«', '»', '…', 10) AS hit FROM frontmatter f JOIN content ON content.path = f.path WHERE content MATCH ? ORDER BY bm25(content, 10.0, 5.0, 1.0) LIMIT 10`. Reserved frontmatter keys: `path`, `_mtime`, `_size`, `_rank`, `content`, `links`, `sections`. Reserved query names (unreachable as subcommands): `init`, `query`, `find`, `map`, `peek`, `watch`, `status`, `rebuild`.",
54
70
  "additionalProperties": { "type": "string" }
55
71
  }
56
72
  }
@@ -75,6 +75,22 @@ sense --list # named queries someone already saved
75
75
  sense query "SELECT DISTINCT type FROM frontmatter" # what a field's values are
76
76
  ```
77
77
 
78
+ ## F. "The notes say it in different words"
79
+
80
+ ```
81
+ sense find "children dying from poor nutrition" --semantic --k 3 --format json
82
+ ```
83
+
84
+ ```json
85
+ [
86
+ { "path": "notes/malnutrition-outcomes.md", "title": "Malnutrition outcomes",
87
+ "summary": "Stunting and mortality by region", "hit": null, "via": "vector", "score": 0.0167, "lines": "L14-52" }
88
+ ]
89
+ ```
90
+
91
+ A `via: "vector"` row never contained the terms — it is semantically near them; `lines` is the
92
+ best-matching section, a direct `Read` range. Only on trees whose config enables `features.embed`.
93
+
78
94
  ## Consequences
79
95
 
80
96
  | query | what happens | bounded alternative |
@@ -10,22 +10,32 @@ SQL over a markdown tree, kept fresh by a filesystem check on every query. Four
10
10
  `title`, `summary`, `text`), `links` (`src`, `target`, `dst` — `NULL` dst = dead link),
11
11
  `sections` (heading outline with line ranges and token estimates).
12
12
 
13
- ## The descent
13
+ ## What each tool is for
14
14
 
15
- Spend tokens in this order (progressive disclosure: metadata first, payloads just in time); go
16
- only as deep as the question needs.
15
+ Every result is a reference (path, metadata, excerpt), never file contents; prose enters
16
+ context only when you Read it. Costs: `map` is fixed-size, a `find` row ~30 tokens, `peek`
17
+ ~17% of reading the file. Which tool fits is a property of the question:
17
18
 
18
- 1. `sense map` orient once: fields, hub notes, recent changes. Fixed-size output.
19
- 2. `sense find "<terms>"` locate: ranked references with excerpts, ~30 tokens/row.
20
- FTS5 standard: bare words AND-join (one absent word = zero rows) — write
21
- `a OR b OR c` for any-word matching.
22
- 3. `sense peek <path>` structure before reading: outline with `[L143-162, ~380t]` ranges,
23
- links both ways. ~17% the cost of reading the file.
24
- 4. `Read` the payload. On large files, peek's line ranges let you read just the section
25
- you need; small files are often cheaper to read whole.
19
+ - A deterministic, factual answer over known fields counts, filters, "which notes have
20
+ X" — is SQL: `sense query`, a named query, or `find --where`. Enumerates every match;
21
+ same result regardless of phrasing.
22
+ - Locating notes by words in their prose is `find` ranked lexical match. Results shift as
23
+ phrasing shifts, and bare words AND-join (one absent word = zero rows): write
24
+ `a OR b OR c` for any-word matching.
25
+ - A conceptual question the notes phrase in different words is `find --semantic` (exists only
26
+ on trees whose config enables `embed`): adds meaning-based candidates labeled `via: vector`.
27
+ Conceptual similarity, not typo-tolerance; false positives are expected, labeled, and
28
+ bounded by `--k`.
29
+ - `map` answers "what is this tree" — fields, hub notes, recent changes — when the tree is
30
+ unfamiliar.
31
+ - `peek <path>` prices a file before you pay for it: outline with `[L143-162, ~380t]`
32
+ ranges, links both ways.
33
+ - When you know the file and need its contents, `Read` it — sense adds nothing there. On
34
+ large files peek's ranges let you read just one section; small files are often cheaper
35
+ whole.
26
36
 
27
- Every result is a reference, never file contents. Output defaults to a table, built for
28
- humans; `--format json` returns the same rows machine-parseable.
37
+ Output defaults to a table, built for humans; `--format json` returns the same rows
38
+ machine-parseable.
29
39
 
30
40
  ## Verbs
31
41
 
@@ -44,15 +54,20 @@ sense --list | status | rebuild
44
54
  rules apply to search commands you write into subagent briefs.
45
55
  - When a search misses, the recall levers are: OR-in synonyms and concrete instances (the
46
56
  index only knows the words in the files — a note about a specific tool rarely names its
47
- category), and raise `--k` (a row costs ~30 tokens). Each widening adds candidates and
48
- dilutes ranking, so the noise trade-off runs both ways.
57
+ category), raise `--k` (a row costs ~30 tokens), and on embed-enabled trees `--semantic`
58
+ (matches meaning where term overlap fails). Each widening adds candidates and dilutes
59
+ ranking, so the noise trade-off runs both ways.
49
60
  - A frontmatter query enumerates its matches deterministically; search ranks by term overlap,
50
61
  so results shift as phrasing shifts. Trade-off: a query needs a known field, search doesn't.
51
62
  - `find` fuses BM25 with link-graph expansion; the `via` column says what produced each row —
52
- `match` (terms hit), `link` (connected to notes that hit), `match+link` (both).
63
+ `match` (terms hit), `link` (connected to notes that hit), `match+link` (both). With
64
+ `--semantic`, `vector` joins the composition and rows gain a `lines` column pointing at the
65
+ best-matching section, a direct `Read` range.
53
66
  - `--where` takes a frontmatter condition against alias `f`, e.g. `"f.status = 'active' AND has(f.tags, 'x')"`.
54
67
 
55
- ## SQL, when the verbs aren't enough
68
+ ## SQL
69
+
70
+ The verbs are shorthands over the same four tables; anything they don't express, SQL does.
56
71
 
57
72
  ```
58
73
  sense query "SELECT name FROM pragma_table_info('frontmatter')" # what fields exist
@@ -72,7 +87,10 @@ sense query "SELECT j.value, COUNT(*) n FROM frontmatter, json_each(frontmatter.
72
87
  `snippet(content, -1, '«', '»', '…', 10)`.
73
88
  - Select `content.title`/`content.summary` (always exist, empty when absent) rather than
74
89
  `f.title`/`f.summary` (discovered columns — error on trees that never declare them).
75
- - `has(field, value)`: array membership on JSON-array fields, substring on strings, false on NULL.
90
+ - `has(field, value)`: array membership on JSON-array fields, substring on strings, false on NULL
91
+ — the `includes()` convention. Substring means `has(f.status, 'active')` also matches
92
+ `inactive`; exact scalar match is `f.status = ?`, deliberate substring is `LIKE`, exact array
93
+ membership is `EXISTS (SELECT 1 FROM json_each(f.tags) WHERE value = ?)`.
76
94
  To aggregate per member instead, use `json_each(frontmatter.<field>)` (above) -- GROUP BY on the
77
95
  raw column splits `["a","b"]` and `["b","a"]` into separate buckets.
78
96
  - Date fields are stored as written. Compare through `datetime()`, which normalizes ISO 8601
@@ -89,7 +107,11 @@ Worked traces: [EXAMPLES.md](EXAMPLES.md).
89
107
  ## Setup and upkeep
90
108
 
91
109
  - Missing CLI: `npm install -g sensemaking`. Missing config: `sense init` at the tree root.
92
- Discovery walks up from cwd; `--config <path>` overrides.
110
+ Discovery walks up from cwd; `--config <path>` overrides. Setting up or restructuring a
111
+ tree (features, frontmatter conventions, note design) is the `sense-setup` skill.
112
+ - `map` and `status` report feature state (`features: links, sections, rank · off: embed
113
+ (features.embed)`). Invoking a capability whose feature is off is an error naming the
114
+ config key to enable — nothing silently falls back.
93
115
  - Save a query into `sense.config.json` only when it will be reused; run ad-hoc otherwise.
94
116
  - A one-line `summary:` per note is optional and pays twice: it appears in result rows and is a
95
117
  weighted search field. Date comparisons work for dates written as ISO 8601 (`2026-08-12`, or
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: sense-setup
3
+ description: Set up the sense CLI on a markdown tree and make the tree-design decisions that shape it — sense init, enabling features (links, sections, rank, embed), and the trade-offs of frontmatter conventions, summaries, folder layout, and note size. Use when creating or restructuring a markdown knowledge base, running sense init, editing sense.config.json, enabling semantic search, or deciding how notes should be written for an agent to query later.
4
+ ---
5
+
6
+ # sense: setup and tree design
7
+
8
+ Querying an existing tree is the `sense` skill. This one covers making a tree:
9
+ installing, configuring features, and the design decisions a tree owner faces.
10
+
11
+ ## Setup
12
+
13
+ - `npm install -g sensemaking`, then `sense init` at the tree root writes
14
+ `sense.config.json` (all opt-out features on, `embed` off). Config discovery
15
+ walks up from cwd; `--config <path>` overrides.
16
+ - `scan.include` globs resolve relative to the config file, never the cwd.
17
+ - `sense map` and `sense status` report feature state
18
+ (`features: links, sections, rank · off: embed (features.embed)`), so the
19
+ current config is always discoverable from output.
20
+
21
+ ## Features
22
+
23
+ | feature | powers | cost when on | config key |
24
+ |---|---|---|---|
25
+ | `links` | backlinks, dead-link queries, `find`'s link expansion, `peek`'s link lists | link re-resolution at reconcile | `features.links` (default on) |
26
+ | `sections` | `peek`'s outline, line-range reads, per-section token estimates | heading extraction at parse | `features.sections` (default on) |
27
+ | `rank` | `map`'s hubs, `_rank` in any ORDER BY | PageRank pass at reconcile; requires `links` | `features.rank` (default on) |
28
+ | `embed` | `find --semantic` (meaning-based expansion) | vectors computed at reconcile; model download to `~/.cache/sensemaking` on first use; ~40 ms model load per semantic invocation | `features.embed` (default off) |
29
+
30
+ - `embed` accepts `true` (built-in static model) or an object: `model` (Hugging
31
+ Face id or local path), `type` (`static` pure-JS built-in, or `api` for any
32
+ OpenAI-compatible `/v1/embeddings` endpoint — Ollama, LM Studio, llama.cpp,
33
+ hosted), `url`, `key` (env var name). A local `model` path is fully offline.
34
+ - Enabling `embed` changes no default `find` result — expansion runs only when
35
+ a query passes `--semantic`. Invoking `--semantic` on a tree without `embed`
36
+ is an error naming the config key.
37
+ - Toggling any feature rebuilds the cache on the next query (safe, automatic).
38
+ - Disabled features degrade output, visibly: `peek` prints
39
+ `sections: off (features.sections)` rather than an empty outline.
40
+
41
+ ## Tree design decisions
42
+
43
+ These belong to the tree's owner. sense works with any of them and reads no
44
+ instruction files of its own; each choice below only changes what queries can
45
+ do, and every consequence is listed so the choice can be made deliberately.
46
+
47
+ - **Frontmatter fields.** Columns are discovered per tree — whatever keys notes
48
+ declare become queryable. Consistent fields across notes make SQL filters
49
+ and named queries possible (`WHERE status = 'active'`); inconsistent fields
50
+ still work but produce sparse columns that filter less of the tree. Reserved
51
+ keys (dropped with a warning): `path`, `_mtime`, `_size`, `_rank`, `content`,
52
+ `links`, `sections`.
53
+ - **Dates.** `datetime()` comparisons work for dates written as ISO 8601 —
54
+ the only format it parses. A tree that mixes date formats can store them,
55
+ but can't compare them in SQL.
56
+ - **Summaries.** A one-line `summary:` is optional and pays twice: it shows in
57
+ every result row (often answering a question with no file read) and is a
58
+ weighted search field ranked above body text. The cost is writing and
59
+ maintaining the line as notes change.
60
+ - **Folder shape.** sense is structure-indifferent: globs find the files,
61
+ paths are queryable text, links resolve by basename at any depth. Folders
62
+ are for the humans and agents navigating the tree, not for the index —
63
+ flat and nested trees query identically.
64
+ - **Note size.** Many small notes: precise `find` hits, whole-file reads stay
65
+ cheap, more links to maintain. Fewer large notes: `sections` and `peek`
66
+ carry the cost down to line-range reads. Both work; per-section token
67
+ estimates exist either way.
68
+ - **Where decisions live.** Choices that should outlive one conversation can
69
+ be recorded in the agent's own instruction or skill files, or in a note in
70
+ the tree itself; a one-off search over an existing corpus needs none of
71
+ that. Whether to settle a choice with the user or proceed on the corpus as
72
+ found depends on whether the agent is only querying or also authoring —
73
+ an authoring agent's choices compound; a querying agent's don't.