sensemaking 0.5.0 → 0.7.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 (108) hide show
  1. package/README.md +13 -6
  2. package/dist/cjs/cli.js +8 -1
  3. package/dist/cjs/cli.js.map +1 -1
  4. package/dist/cjs/commands/check.d.cts +3 -0
  5. package/dist/cjs/commands/check.d.ts +3 -0
  6. package/dist/cjs/commands/check.js +130 -0
  7. package/dist/cjs/commands/check.js.map +1 -0
  8. package/dist/cjs/commands/find.js +173 -10
  9. package/dist/cjs/commands/find.js.map +1 -1
  10. package/dist/cjs/commands/index.js +5 -0
  11. package/dist/cjs/commands/index.js.map +1 -1
  12. package/dist/cjs/commands/map.js +1 -1
  13. package/dist/cjs/commands/map.js.map +1 -1
  14. package/dist/cjs/commands/peek.js +1 -1
  15. package/dist/cjs/commands/peek.js.map +1 -1
  16. package/dist/cjs/commands/shared.d.cts +1 -1
  17. package/dist/cjs/commands/shared.d.ts +1 -1
  18. package/dist/cjs/commands/shared.js +166 -8
  19. package/dist/cjs/commands/shared.js.map +1 -1
  20. package/dist/cjs/commands/status.js +72 -5
  21. package/dist/cjs/commands/status.js.map +1 -1
  22. package/dist/cjs/commands/types.d.cts +1 -0
  23. package/dist/cjs/commands/types.d.ts +1 -0
  24. package/dist/cjs/config.d.cts +32 -2
  25. package/dist/cjs/config.d.ts +32 -2
  26. package/dist/cjs/config.js +199 -11
  27. package/dist/cjs/config.js.map +1 -1
  28. package/dist/cjs/db.d.cts +1 -1
  29. package/dist/cjs/db.d.ts +1 -1
  30. package/dist/cjs/db.js +47 -17
  31. package/dist/cjs/db.js.map +1 -1
  32. package/dist/cjs/errors.d.cts +1 -1
  33. package/dist/cjs/errors.d.ts +1 -1
  34. package/dist/cjs/errors.js.map +1 -1
  35. package/dist/cjs/features/embed.d.cts +15 -0
  36. package/dist/cjs/features/embed.d.ts +15 -0
  37. package/dist/cjs/features/embed.js +844 -0
  38. package/dist/cjs/features/embed.js.map +1 -0
  39. package/dist/cjs/features/index.js +3 -1
  40. package/dist/cjs/features/index.js.map +1 -1
  41. package/dist/cjs/features/types.d.cts +4 -1
  42. package/dist/cjs/features/types.d.ts +4 -1
  43. package/dist/cjs/index.d.cts +1 -1
  44. package/dist/cjs/index.d.ts +1 -1
  45. package/dist/cjs/index.js.map +1 -1
  46. package/dist/cjs/output.d.cts +5 -0
  47. package/dist/cjs/output.d.ts +5 -0
  48. package/dist/cjs/output.js +48 -9
  49. package/dist/cjs/output.js.map +1 -1
  50. package/dist/cjs/scan.d.cts +1 -1
  51. package/dist/cjs/scan.d.ts +1 -1
  52. package/dist/cjs/scan.js +17 -10
  53. package/dist/cjs/scan.js.map +1 -1
  54. package/dist/cjs/verbs.d.cts +8 -2
  55. package/dist/cjs/verbs.d.ts +8 -2
  56. package/dist/cjs/verbs.js +308 -81
  57. package/dist/cjs/verbs.js.map +1 -1
  58. package/dist/esm/cli.js +8 -1
  59. package/dist/esm/cli.js.map +1 -1
  60. package/dist/esm/commands/check.d.ts +3 -0
  61. package/dist/esm/commands/check.js +56 -0
  62. package/dist/esm/commands/check.js.map +1 -0
  63. package/dist/esm/commands/find.js +5 -4
  64. package/dist/esm/commands/find.js.map +1 -1
  65. package/dist/esm/commands/index.js +1 -0
  66. package/dist/esm/commands/index.js.map +1 -1
  67. package/dist/esm/commands/map.js +1 -3
  68. package/dist/esm/commands/map.js.map +1 -1
  69. package/dist/esm/commands/peek.js +1 -1
  70. package/dist/esm/commands/peek.js.map +1 -1
  71. package/dist/esm/commands/shared.d.ts +1 -1
  72. package/dist/esm/commands/shared.js +2 -2
  73. package/dist/esm/commands/shared.js.map +1 -1
  74. package/dist/esm/commands/status.js +43 -5
  75. package/dist/esm/commands/status.js.map +1 -1
  76. package/dist/esm/commands/types.d.ts +1 -0
  77. package/dist/esm/commands/types.js.map +1 -1
  78. package/dist/esm/config.d.ts +32 -2
  79. package/dist/esm/config.js +98 -9
  80. package/dist/esm/config.js.map +1 -1
  81. package/dist/esm/db.d.ts +1 -1
  82. package/dist/esm/db.js +20 -11
  83. package/dist/esm/db.js.map +1 -1
  84. package/dist/esm/errors.d.ts +1 -1
  85. package/dist/esm/errors.js.map +1 -1
  86. package/dist/esm/features/embed.d.ts +15 -0
  87. package/dist/esm/features/embed.js +299 -0
  88. package/dist/esm/features/embed.js.map +1 -0
  89. package/dist/esm/features/index.js +3 -1
  90. package/dist/esm/features/index.js.map +1 -1
  91. package/dist/esm/features/types.d.ts +4 -1
  92. package/dist/esm/features/types.js.map +1 -1
  93. package/dist/esm/index.d.ts +1 -1
  94. package/dist/esm/index.js.map +1 -1
  95. package/dist/esm/output.d.ts +5 -0
  96. package/dist/esm/output.js +40 -8
  97. package/dist/esm/output.js.map +1 -1
  98. package/dist/esm/scan.d.ts +1 -1
  99. package/dist/esm/scan.js +17 -10
  100. package/dist/esm/scan.js.map +1 -1
  101. package/dist/esm/verbs.d.ts +8 -2
  102. package/dist/esm/verbs.js +92 -18
  103. package/dist/esm/verbs.js.map +1 -1
  104. package/package.json +2 -1
  105. package/schema.json +35 -1
  106. package/skills/sense/EXAMPLES.md +16 -0
  107. package/skills/sense/SKILL.md +60 -20
  108. package/skills/sense-setup/SKILL.md +91 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/config.ts"],"sourcesContent":["import { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { SenseError } from './errors.ts';\n\nexport const CONFIG_FILENAME = 'sense.config.json';\nexport const STATE_DIR = '.sense';\n\n// Highest sense.config.json `version` this build understands. Older versions auto-migrate on load.\nexport const SUPPORTED_CONFIG_VERSION = 2;\n\n// Each feature owns its tables, parse-time extraction, and reconcile step; verbs degrade when one is off.\nexport const FEATURE_NAMES = ['links', 'sections', 'rank'] as const;\nexport type FeatureName = (typeof FEATURE_NAMES)[number];\n\nexport interface Config {\n // Editor-only pointer to schema.json; never read by sense.\n $schema?: string;\n version?: number;\n scan: { include: string[] };\n features?: Partial<Record<FeatureName, boolean>>;\n queries: Record<string, string>;\n}\n\nexport interface ResolvedConfig extends Config {\n baseDir: string;\n configPath: string | null;\n // Set when loadConfig auto-migrated the file on disk; cli reports it.\n migratedFrom?: number;\n}\n\n// Absent block or key means enabled -- features are opt-out. `rank` additionally requires `links`.\nexport function featureEnabled(cfg: Config, name: FeatureName): boolean {\n const enabled = cfg.features?.[name] !== false;\n if (name === 'rank') return enabled && featureEnabled(cfg, 'links');\n return enabled;\n}\n\nexport function enabledFeatures(cfg: Config): FeatureName[] {\n return FEATURE_NAMES.filter((name) => featureEnabled(cfg, name));\n}\n\n// Pure per-version steps; loadConfig chains them from the file's version up to SUPPORTED_CONFIG_VERSION.\nconst MIGRATIONS: Record<number, (cfg: Config) => Config> = {\n // v1 -> v2: features block introduced, everything enabled (matches the old implicit behavior of `links` etc. not existing).\n 1: (cfg) => ({ ...cfg, version: 2, features: Object.fromEntries(FEATURE_NAMES.map((name) => [name, true])) as Config['features'] }),\n};\n\nexport function migrateConfig(cfg: Config): { cfg: Config; from: number } {\n const from = cfg.version ?? 1;\n let current = cfg;\n for (let v = from; v < SUPPORTED_CONFIG_VERSION; v++) {\n const step = MIGRATIONS[v];\n if (!step) throw new SenseError('CONFIG_VERSION_UNSUPPORTED', `no migration from config version ${v}`);\n current = step(current);\n }\n return { cfg: current, from };\n}\n\nfunction starterConfig(): Config {\n return {\n $schema: 'https://unpkg.com/sensemaking/schema.json',\n version: SUPPORTED_CONFIG_VERSION,\n scan: { include: ['**/*.md'] },\n features: Object.fromEntries(FEATURE_NAMES.map((name) => [name, true])) as Config['features'],\n queries: {},\n };\n}\n\n// Refuses to overwrite an existing config.\nexport function initConfig(dir: string): string {\n const configPath = join(dir, CONFIG_FILENAME);\n if (existsSync(configPath)) {\n throw new SenseError('CONFIG_EXISTS', `${CONFIG_FILENAME} already exists in ${dir}`);\n }\n writeFileSync(configPath, `${JSON.stringify(starterConfig(), null, 2)}\\n`);\n return configPath;\n}\n\nexport function findConfigPath(startDir: string): string | null {\n let dir = resolve(startDir);\n for (;;) {\n const candidate = join(dir, CONFIG_FILENAME);\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n// Shape check for hand-edited files: a typo'd config fails with a named error, not a\n// TypeError from whatever code touched the missing field first. `queries` is optional\n// on disk (absent = none); `scan.include` has no usable default.\nfunction validateConfig(parsed: unknown, configPath: string): Config {\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: config must be a JSON object`);\n }\n const cfg = parsed as Record<string, unknown>;\n const scan = cfg.scan as { include?: unknown } | undefined;\n if (!scan || !Array.isArray(scan.include) || scan.include.length === 0 || !scan.include.every((g) => typeof g === 'string')) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: scan.include must be a non-empty array of glob strings`);\n }\n if (cfg.queries === undefined) cfg.queries = {};\n if (typeof cfg.queries !== 'object' || cfg.queries === null || Array.isArray(cfg.queries) || !Object.values(cfg.queries).every((q) => typeof q === 'string')) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: queries must be an object of name -> SQL string`);\n }\n if (cfg.features !== undefined && (typeof cfg.features !== 'object' || cfg.features === null || Array.isArray(cfg.features) || !Object.values(cfg.features).every((v) => typeof v === 'boolean'))) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: features must be an object of name -> boolean`);\n }\n return cfg as unknown as Config;\n}\n\nexport function loadConfig(explicitPath?: string): ResolvedConfig {\n let configPath: string;\n if (explicitPath) {\n configPath = resolve(process.cwd(), explicitPath);\n if (!existsSync(configPath)) throw new SenseError('CONFIG_NOT_FOUND', `config not found: ${configPath}`);\n } else {\n const found = findConfigPath(process.cwd());\n if (!found) {\n throw new SenseError('CONFIG_NOT_FOUND', `could not find ${CONFIG_FILENAME} in ${process.cwd()} or any parent directory`);\n }\n configPath = found;\n }\n\n const raw = readFileSync(configPath, 'utf8');\n const parsed: unknown = JSON.parse(raw);\n\n // Version gate before shape validation: a config written by a newer sense should fail\n // with \"requires a newer sense\", not with shape errors its own version may not have.\n const version = typeof parsed === 'object' && parsed !== null && typeof (parsed as { version?: unknown }).version === 'number' ? (parsed as { version: number }).version : 1;\n if (version > SUPPORTED_CONFIG_VERSION) {\n throw new SenseError('CONFIG_VERSION_UNSUPPORTED', `config version ${version} requires a newer sense`);\n }\n\n let cfg = validateConfig(parsed, configPath);\n\n let migratedFrom: number | undefined;\n if (version < SUPPORTED_CONFIG_VERSION) {\n const result = migrateConfig(cfg);\n cfg = result.cfg;\n migratedFrom = result.from;\n writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\\n`);\n }\n\n return { ...cfg, baseDir: dirname(configPath), configPath, migratedFrom };\n}\n"],"names":["existsSync","readFileSync","writeFileSync","dirname","join","resolve","SenseError","CONFIG_FILENAME","STATE_DIR","SUPPORTED_CONFIG_VERSION","FEATURE_NAMES","featureEnabled","cfg","name","enabled","features","enabledFeatures","filter","MIGRATIONS","version","Object","fromEntries","map","migrateConfig","from","current","v","step","starterConfig","$schema","scan","include","queries","initConfig","dir","configPath","JSON","stringify","findConfigPath","startDir","candidate","parent","validateConfig","parsed","Array","isArray","length","every","g","undefined","values","q","loadConfig","explicitPath","process","cwd","found","raw","parse","migratedFrom","result","baseDir"],"mappings":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/config.ts"],"sourcesContent":["import { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { SenseError } from './errors.ts';\n\nexport const CONFIG_FILENAME = 'sense.config.json';\nexport const STATE_DIR = '.sense';\n\n// Highest sense.config.json `version` this build understands. Older versions auto-migrate on load.\nexport const SUPPORTED_CONFIG_VERSION = 2;\n\n// Each feature owns its tables, parse-time extraction, and reconcile step; verbs degrade when one is off.\n// links/sections/rank are opt-out (absent = on); embed is opt-in (absent = off) -- most trees don't need vectors.\nconst FEATURE_NAMES = ['links', 'sections', 'rank', 'embed'] as const;\nconst OPT_OUT_NAMES = ['links', 'sections', 'rank'] as const;\nexport type FeatureName = (typeof FEATURE_NAMES)[number];\n\n// `true` = all defaults (static type, default model). A local `model` path skips the network.\nexport interface EmbedConfig {\n model?: string;\n type?: 'static' | 'api';\n url?: string; // api type: OpenAI-compatible base URL, e.g. http://localhost:11434/v1\n key?: string; // api type: name of the env var holding the bearer token, if any\n}\n\nexport const DEFAULT_EMBED_MODEL = 'minishlab/potion-retrieval-32M';\n\nexport interface Config {\n // Editor-only pointer to schema.json; never read by sense.\n $schema?: string;\n version?: number;\n scan: { include: string[] };\n features?: { links?: boolean; sections?: boolean; rank?: boolean; embed?: boolean | EmbedConfig };\n // Tree-declared default scope for `find`. A --where on the invocation replaces it, so a\n // caller can always reach the whole tree (`--where \"1=1\"`).\n defaults?: { find?: { where?: string } };\n queries: Record<string, string>;\n}\n\nexport interface ResolvedConfig extends Config {\n baseDir: string;\n configPath: string | null;\n // Keys the file declares that this build does not read; cli reports them.\n unknownKeys?: string[];\n // Set when loadConfig auto-migrated the file on disk; cli reports it.\n migratedFrom?: number;\n}\n\n// Opt-out features: absent block or key means enabled. `rank` additionally requires `links`.\n// `embed` is the opposite: enabled only when the config says so.\nexport function featureEnabled(cfg: Config, name: FeatureName): boolean {\n if (name === 'embed') return Boolean(cfg.features?.embed);\n const enabled = cfg.features?.[name] !== false;\n if (name === 'rank') return enabled && featureEnabled(cfg, 'links');\n return enabled;\n}\n\nexport function enabledFeatures(cfg: Config): FeatureName[] {\n return FEATURE_NAMES.filter((name) => featureEnabled(cfg, name));\n}\n\n// Every feature with its current state; verbs surface this so \"off\" and \"empty\" stay\n// distinguishable in output.\nexport function featureStates(cfg: Config): { on: FeatureName[]; off: FeatureName[] } {\n return {\n on: FEATURE_NAMES.filter((name) => featureEnabled(cfg, name)),\n off: FEATURE_NAMES.filter((name) => !featureEnabled(cfg, name)),\n };\n}\n\n// Resolved embed settings, or null when the feature is off.\nexport function embedConfig(cfg: Config): { model: string; type: 'static' | 'api'; url?: string; key?: string } | null {\n const e = cfg.features?.embed;\n if (!e) return null;\n const o = e === true ? {} : e;\n return { model: o.model ?? DEFAULT_EMBED_MODEL, type: o.type ?? 'static', url: o.url, key: o.key };\n}\n\n// Cache-key string: embed carries its type + model so a model change rebuilds like a toggle.\nexport function featureSignature(cfg: Config): string {\n return enabledFeatures(cfg)\n .map((name) => {\n if (name !== 'embed') return name;\n const e = embedConfig(cfg);\n return `embed:${e?.type}:${e?.model}`;\n })\n .join(',');\n}\n\n// Pure per-version steps; loadConfig chains them from the file's version up to SUPPORTED_CONFIG_VERSION.\nconst MIGRATIONS: Record<number, (cfg: Config) => Config> = {\n // v1 -> v2: features block introduced, opt-out features enabled (matches the old implicit\n // behavior of `links` etc. not existing). embed stays absent -- opt-in.\n 1: (cfg) => ({ ...cfg, version: 2, features: Object.fromEntries(OPT_OUT_NAMES.map((name) => [name, true])) as Config['features'] }),\n};\n\nexport function migrateConfig(cfg: Config): { cfg: Config; from: number } {\n const from = cfg.version ?? 1;\n let current = cfg;\n for (let v = from; v < SUPPORTED_CONFIG_VERSION; v++) {\n const step = MIGRATIONS[v];\n if (!step) throw new SenseError('CONFIG_VERSION_UNSUPPORTED', `no migration from config version ${v}`);\n current = step(current);\n }\n return { cfg: current, from };\n}\n\nfunction starterConfig(): Config {\n return {\n $schema: 'https://unpkg.com/sensemaking/schema.json',\n version: SUPPORTED_CONFIG_VERSION,\n scan: { include: ['**/*.md'] },\n features: Object.fromEntries(OPT_OUT_NAMES.map((name) => [name, true])) as Config['features'],\n queries: {},\n };\n}\n\n// Refuses to overwrite an existing config.\nexport function initConfig(dir: string): string {\n const configPath = join(dir, CONFIG_FILENAME);\n if (existsSync(configPath)) {\n throw new SenseError('CONFIG_EXISTS', `${CONFIG_FILENAME} already exists in ${dir}`);\n }\n writeFileSync(configPath, `${JSON.stringify(starterConfig(), null, 2)}\\n`);\n return configPath;\n}\n\nexport function findConfigPath(startDir: string): string | null {\n let dir = resolve(startDir);\n for (;;) {\n const candidate = join(dir, CONFIG_FILENAME);\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n// Shape check for hand-edited files: a typo'd config fails with a named error, not a\n// TypeError from whatever code touched the missing field first. `queries` is optional\n// on disk (absent = none); `scan.include` has no usable default.\n// Keys each block actually reads. A key outside these sets is accepted and ignored, which\n// looks identical to working -- one field report set `scan.exclude`, saw no change in doc\n// count and no warning, and reasonably concluded the filter had applied. Reported as a\n// warning rather than an error so a config carrying an unknown key still runs.\nconst KNOWN_KEYS = new Set(['$schema', 'version', 'scan', 'features', 'defaults', 'queries']);\nconst KNOWN_SCAN_KEYS = new Set(['include']);\n\nfunction unknownConfigKeys(cfg: Record<string, unknown>): string[] {\n const unknown = Object.keys(cfg)\n .filter((k) => !KNOWN_KEYS.has(k))\n .map((k) => k);\n const scan = cfg.scan as Record<string, unknown> | undefined;\n if (scan && typeof scan === 'object' && !Array.isArray(scan)) {\n unknown.push(\n ...Object.keys(scan)\n .filter((k) => !KNOWN_SCAN_KEYS.has(k))\n .map((k) => `scan.${k}`)\n );\n }\n const features = cfg.features as Record<string, unknown> | undefined;\n if (features && typeof features === 'object' && !Array.isArray(features)) {\n unknown.push(\n ...Object.keys(features)\n .filter((k) => !(FEATURE_NAMES as readonly string[]).includes(k))\n .map((k) => `features.${k}`)\n );\n }\n return unknown;\n}\n\nfunction validateConfig(parsed: unknown, configPath: string): Config {\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: config must be a JSON object`);\n }\n const cfg = parsed as Record<string, unknown>;\n const scan = cfg.scan as { include?: unknown } | undefined;\n if (!scan || !Array.isArray(scan.include) || scan.include.length === 0 || !scan.include.every((g) => typeof g === 'string')) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: scan.include must be a non-empty array of glob strings`);\n }\n if (cfg.queries === undefined) cfg.queries = {};\n if (typeof cfg.queries !== 'object' || cfg.queries === null || Array.isArray(cfg.queries) || !Object.values(cfg.queries).every((q) => typeof q === 'string')) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: queries must be an object of name -> SQL string`);\n }\n const defaults = cfg.defaults as { find?: { where?: unknown } } | undefined;\n if (defaults !== undefined && (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults) || (defaults.find !== undefined && typeof defaults.find?.where !== 'string'))) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: defaults.find.where must be a SQL condition string`);\n }\n if (cfg.features !== undefined && (typeof cfg.features !== 'object' || cfg.features === null || Array.isArray(cfg.features))) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: features must be an object of name -> boolean`);\n }\n for (const [name, value] of Object.entries(cfg.features ?? {})) {\n if (typeof value === 'boolean') continue;\n // embed alone takes an object form: { model?, type?: static|api, url?, key? }.\n const embed = value as Record<string, unknown>;\n const shapeOk = name === 'embed' && typeof value === 'object' && value !== null && !Array.isArray(value) && ['model', 'url', 'key'].every((f) => embed[f] === undefined || typeof embed[f] === 'string') && (embed.type === undefined || embed.type === 'static' || embed.type === 'api');\n if (!shapeOk) {\n throw new SenseError('CONFIG_INVALID', `${configPath}: features.${name} must be a boolean${name === 'embed' ? ' or { model?, type?: \"static\"|\"api\", url?, key? }' : ''}`);\n }\n }\n return cfg as unknown as Config;\n}\n\nexport function loadConfig(explicitPath?: string): ResolvedConfig {\n let configPath: string;\n if (explicitPath) {\n configPath = resolve(process.cwd(), explicitPath);\n if (!existsSync(configPath)) throw new SenseError('CONFIG_NOT_FOUND', `config not found: ${configPath}`);\n } else {\n const found = findConfigPath(process.cwd());\n if (!found) {\n throw new SenseError('CONFIG_NOT_FOUND', `could not find ${CONFIG_FILENAME} in ${process.cwd()} or any parent directory`);\n }\n configPath = found;\n }\n\n const raw = readFileSync(configPath, 'utf8');\n const parsed: unknown = JSON.parse(raw);\n\n // Version gate before shape validation: a config written by a newer sense should fail\n // with \"requires a newer sense\", not with shape errors its own version may not have.\n const version = typeof parsed === 'object' && parsed !== null && typeof (parsed as { version?: unknown }).version === 'number' ? (parsed as { version: number }).version : 1;\n if (version > SUPPORTED_CONFIG_VERSION) {\n throw new SenseError('CONFIG_VERSION_UNSUPPORTED', `config version ${version} requires a newer sense`);\n }\n\n let cfg = validateConfig(parsed, configPath);\n\n let migratedFrom: number | undefined;\n if (version < SUPPORTED_CONFIG_VERSION) {\n const result = migrateConfig(cfg);\n cfg = result.cfg;\n migratedFrom = result.from;\n writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\\n`);\n }\n\n const unknownKeys = unknownConfigKeys(cfg as unknown as Record<string, unknown>);\n return { ...cfg, baseDir: dirname(configPath), configPath, migratedFrom, unknownKeys: unknownKeys.length > 0 ? unknownKeys : undefined };\n}\n"],"names":["existsSync","readFileSync","writeFileSync","dirname","join","resolve","SenseError","CONFIG_FILENAME","STATE_DIR","SUPPORTED_CONFIG_VERSION","FEATURE_NAMES","OPT_OUT_NAMES","DEFAULT_EMBED_MODEL","featureEnabled","cfg","name","Boolean","features","embed","enabled","enabledFeatures","filter","featureStates","on","off","embedConfig","o","e","model","type","url","key","featureSignature","map","MIGRATIONS","version","Object","fromEntries","migrateConfig","from","current","v","step","starterConfig","$schema","scan","include","queries","initConfig","dir","configPath","JSON","stringify","findConfigPath","startDir","candidate","parent","KNOWN_KEYS","Set","KNOWN_SCAN_KEYS","unknownConfigKeys","unknown","keys","k","has","Array","isArray","push","includes","validateConfig","parsed","defaults","length","every","g","undefined","values","q","find","where","value","entries","shapeOk","f","loadConfig","explicitPath","process","cwd","found","raw","parse","migratedFrom","result","unknownKeys","baseDir"],"mappings":"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"}
package/dist/esm/db.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import type { Config, ResolvedConfig } from './config.js';
3
3
  export declare const DB_FILENAME = "cache.db";
4
- export declare const SCHEMA_VERSION = "3";
4
+ export declare const SCHEMA_VERSION = "4";
5
5
  export interface OpenResult {
6
6
  db: DatabaseSync;
7
7
  cfg: ResolvedConfig;
package/dist/esm/db.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { mkdirSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
- import { enabledFeatures, STATE_DIR } from './config.js';
4
+ import { featureSignature, STATE_DIR } from './config.js';
5
5
  import { activeFeatures } from './features/index.js';
6
6
  import { listFiles, parseFile } from './scan.js';
7
7
  // Rows -> SQLite: core schema, reconcile loop, has(). Parsing lives in scan.ts;
8
8
  // everything beyond frontmatter + content lives in src/features/.
9
9
  export const DB_FILENAME = 'cache.db';
10
10
  // Cache shape version, independent of the config's own `version`.
11
- export const SCHEMA_VERSION = '3';
11
+ export const SCHEMA_VERSION = '4'; // 4: frontmatter scalars store as INTEGER/REAL/TEXT by YAML type (was: all numbers REAL)
12
12
  function quoteIdent(name) {
13
13
  return `"${name.split('"').join('""')}"`;
14
14
  }
@@ -45,7 +45,7 @@ function ensureSchema(db, cfg) {
45
45
  db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS content USING fts5(title, summary, text, path UNINDEXED, tokenize = 'porter unicode61')`);
46
46
  for (const feature of activeFeatures(cfg))feature.schema(db);
47
47
  if (getMeta(db, 'schema_version') === null) setMeta(db, 'schema_version', SCHEMA_VERSION);
48
- if (getMeta(db, 'features') === null) setMeta(db, 'features', enabledFeatures(cfg).join(','));
48
+ if (getMeta(db, 'features') === null) setMeta(db, 'features', featureSignature(cfg));
49
49
  }
50
50
  export function getMeta(db, key) {
51
51
  const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
@@ -118,6 +118,7 @@ export function reconcile(db, cfg, baseDir) {
118
118
  const insert = db.prepare(insertSql);
119
119
  const insertBody = db.prepare(`INSERT INTO content (title, summary, text, "path") VALUES (?, ?, ?, ?)`);
120
120
  for (const doc of parsedDocs){
121
+ var _feature_store;
121
122
  const values = allColumns.map((col)=>{
122
123
  var _doc_data_col;
123
124
  if (col === 'path') return doc.relPath;
@@ -126,13 +127,17 @@ export function reconcile(db, cfg, baseDir) {
126
127
  return (_doc_data_col = doc.data[col]) !== null && _doc_data_col !== void 0 ? _doc_data_col : null;
127
128
  });
128
129
  insert.run(...values);
129
- delBody.run(doc.relPath);
130
- insertBody.run(doc.search.title, doc.search.summary, doc.search.text, doc.relPath);
131
- for (const feature of features){
132
- var _feature_remove1, _feature_store;
133
- (_feature_remove1 = feature.remove) === null || _feature_remove1 === void 0 ? void 0 : _feature_remove1.call(feature, db, doc.relPath);
134
- (_feature_store = feature.store) === null || _feature_store === void 0 ? void 0 : _feature_store.call(feature, db, doc.relPath, doc.extracted[feature.name]);
130
+ // Delete-before-insert only for docs that have rows: an FTS5 DELETE by column
131
+ // cannot use an index and scans the whole table, so running it per doc on a
132
+ // cold build (empty table, nothing to delete) made the crawl quadratic --
133
+ // measured 4x time per note-count doubling at 13k/26k notes.
134
+ if (existing.has(doc.relPath)) {
135
+ var _feature_remove1;
136
+ delBody.run(doc.relPath);
137
+ for (const feature of features)(_feature_remove1 = feature.remove) === null || _feature_remove1 === void 0 ? void 0 : _feature_remove1.call(feature, db, doc.relPath);
135
138
  }
139
+ insertBody.run(doc.search.title, doc.search.summary, doc.search.text, doc.relPath);
140
+ for (const feature of features)(_feature_store = feature.store) === null || _feature_store === void 0 ? void 0 : _feature_store.call(feature, db, doc.relPath, doc.extracted[feature.name]);
136
141
  }
137
142
  }
138
143
  for (const feature of features)(_feature_afterReconcile = feature.afterReconcile) === null || _feature_afterReconcile === void 0 ? void 0 : _feature_afterReconcile.call(feature, db, files);
@@ -154,14 +159,18 @@ export function open(cfg) {
154
159
  const dbPath = join(stateDir, DB_FILENAME);
155
160
  const db = new DatabaseSync(dbPath);
156
161
  db.exec('PRAGMA journal_mode = WAL');
157
- db.exec('PRAGMA busy_timeout = 5000');
162
+ // Covers a concurrent watcher's bulk reconcile: the write transaction for 500 changed
163
+ // files measures ~5s at 26k notes, so 5s expired exactly at the boundary and queries
164
+ // racing the watcher got SQLITE_BUSY. 30s bounds the wait at ~3x the largest measured
165
+ // reconcile; a query that outwaits it still fails loudly.
166
+ db.exec('PRAGMA busy_timeout = 30000');
158
167
  registerFunctions(db);
159
168
  db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)');
160
169
  // Schema-version or feature-set mismatch: reconcile only reparses changed files, so an
161
170
  // old cache can't be patched incrementally -- rebuild instead (cheap: nothing expensive lives here).
162
171
  const version = getMeta(db, 'schema_version');
163
172
  const features = getMeta(db, 'features');
164
- const wantFeatures = enabledFeatures(cfg).join(',');
173
+ const wantFeatures = featureSignature(cfg);
165
174
  if (version !== null && version !== SCHEMA_VERSION || features !== null && features !== wantFeatures) {
166
175
  db.close();
167
176
  rmSync(stateDir, {
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/db.ts"],"sourcesContent":["import { mkdirSync, rmSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\nimport type { Config, ResolvedConfig } from './config.ts';\nimport { enabledFeatures, STATE_DIR } from './config.ts';\nimport { activeFeatures } from './features/index.ts';\nimport type { ParsedDoc } from './scan.ts';\nimport { listFiles, parseFile } from './scan.ts';\n\n// Rows -> SQLite: core schema, reconcile loop, has(). Parsing lives in scan.ts;\n// everything beyond frontmatter + content lives in src/features/.\n\nexport const DB_FILENAME = 'cache.db';\n// Cache shape version, independent of the config's own `version`.\nexport const SCHEMA_VERSION = '3';\n\nexport interface OpenResult {\n db: DatabaseSync;\n cfg: ResolvedConfig;\n dbPath: string;\n parsed: number;\n warnings: string[];\n}\n\nfunction quoteIdent(name: string): string {\n return `\"${name.split('\"').join('\"\"')}\"`;\n}\n\n// has(field, value): JSON-array field -> membership, string field -> substring, NULL -> false.\nfunction registerFunctions(db: DatabaseSync): void {\n db.function('has', { deterministic: true, varargs: false }, (field: unknown, value: unknown): number => {\n if (field === null || field === undefined) return 0;\n\n const needle = String(value);\n\n if (typeof field === 'string') {\n if (field.startsWith('[')) {\n try {\n const parsed = JSON.parse(field);\n if (Array.isArray(parsed)) {\n return parsed.some((item) => String(item) === needle) ? 1 : 0;\n }\n } catch {}\n }\n return field.includes(needle) ? 1 : 0;\n }\n\n return String(field).includes(needle) ? 1 : 0;\n });\n}\n\nfunction getColumns(db: DatabaseSync): Set<string> {\n const rows = db.prepare('PRAGMA table_info(frontmatter)').all() as Array<{ name: string }>;\n return new Set(rows.map((r) => r.name));\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): void {\n db.exec(`CREATE TABLE IF NOT EXISTS frontmatter (\"path\" TEXT PRIMARY KEY, \"_mtime\" REAL, \"_size\" INTEGER)`);\n db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS content USING fts5(title, summary, text, path UNINDEXED, tokenize = 'porter unicode61')`);\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', enabledFeatures(cfg).join(','));\n}\n\nexport function getMeta(db: DatabaseSync, key: string): string | null {\n const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as { value: string } | undefined;\n return row ? row.value : null;\n}\n\nexport function setMeta(db: DatabaseSync, key: string, value: string | null): void {\n if (value === null) {\n db.prepare('DELETE FROM meta WHERE key = ?').run(key);\n return;\n }\n db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);\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 reconcile(db: DatabaseSync, cfg: Config, baseDir: string): { parsed: number; warnings: string[] } {\n const files = listFiles(cfg, baseDir);\n const currentSet = new Set(files.map((f) => f.relPath));\n\n const existingRows = db.prepare(`SELECT \"path\", \"_mtime\", \"_size\" FROM frontmatter`).all() as Array<{\n path: string;\n _mtime: number;\n _size: number;\n }>;\n const existing = new Map(existingRows.map((r) => [r.path, r]));\n const vanished = existingRows.filter((r) => !currentSet.has(r.path)).map((r) => r.path);\n\n const toReparse = files.filter((f) => {\n const row = existing.get(f.relPath);\n return !row || row._mtime !== f.mtimeMs || row._size !== f.size;\n });\n\n if (vanished.length === 0 && toReparse.length === 0) return { parsed: 0, warnings: [] };\n\n const features = activeFeatures(cfg);\n const seenColumns = getColumns(db);\n const newColumns: string[] = [];\n const parsedDocs: ParsedDoc[] = [];\n const warnings: string[] = [];\n\n for (const file of toReparse) {\n const { doc, warnings: fileWarnings } = parseFile(file, features);\n warnings.push(...fileWarnings);\n for (const key of Object.keys(doc.data)) {\n if (!seenColumns.has(key)) {\n seenColumns.add(key);\n newColumns.push(key);\n }\n }\n parsedDocs.push(doc);\n }\n\n const allColumns = [...seenColumns];\n const insertSql = `INSERT OR REPLACE INTO frontmatter (${allColumns.map(quoteIdent).join(', ')}) VALUES (${allColumns.map(() => '?').join(', ')})`;\n\n db.exec('BEGIN');\n try {\n for (const col of newColumns) db.exec(`ALTER TABLE frontmatter ADD COLUMN ${quoteIdent(col)}`);\n // FTS5 has no upsert, so delete-before-insert into `content`.\n const delBody = db.prepare(`DELETE FROM content WHERE \"path\" = ?`);\n if (vanished.length > 0) {\n const del = db.prepare(`DELETE FROM frontmatter WHERE \"path\" = ?`);\n for (const path of vanished) {\n del.run(path);\n delBody.run(path);\n for (const feature of features) feature.remove?.(db, path);\n }\n }\n if (parsedDocs.length > 0) {\n const insert = db.prepare(insertSql);\n const insertBody = db.prepare(`INSERT INTO content (title, summary, text, \"path\") VALUES (?, ?, ?, ?)`);\n for (const doc of parsedDocs) {\n const values = allColumns.map((col) => {\n if (col === 'path') return doc.relPath;\n if (col === '_mtime') return doc.mtimeMs;\n if (col === '_size') return doc.size;\n return doc.data[col] ?? null;\n });\n insert.run(...values);\n delBody.run(doc.relPath);\n insertBody.run(doc.search.title, doc.search.summary, doc.search.text, doc.relPath);\n for (const feature of features) {\n feature.remove?.(db, doc.relPath);\n feature.store?.(db, doc.relPath, doc.extracted[feature.name]);\n }\n }\n }\n for (const feature of features) feature.afterReconcile?.(db, files);\n db.exec('COMMIT');\n } catch (err) {\n db.exec('ROLLBACK');\n throw err;\n }\n\n return { parsed: parsedDocs.length, warnings };\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 db.exec('PRAGMA busy_timeout = 5000');\n registerFunctions(db);\n\n db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)');\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 = enabledFeatures(cfg).join(',');\n if ((version !== null && version !== SCHEMA_VERSION) || (features !== null && features !== wantFeatures)) {\n db.close();\n rmSync(stateDir, { recursive: true, force: true });\n return open(cfg);\n }\n\n ensureSchema(db, cfg);\n const { parsed, warnings } = reconcile(db, cfg, cfg.baseDir);\n\n return { db, cfg, dbPath, parsed, warnings };\n}\n\n// Manual reset for a doubted cache.\nexport function rebuild(cfg: ResolvedConfig): OpenResult {\n rmSync(join(cfg.baseDir, STATE_DIR), { recursive: true, force: true });\n return open(cfg);\n}\n"],"names":["mkdirSync","rmSync","join","DatabaseSync","enabledFeatures","STATE_DIR","activeFeatures","listFiles","parseFile","DB_FILENAME","SCHEMA_VERSION","quoteIdent","name","split","registerFunctions","db","function","deterministic","varargs","field","value","undefined","needle","String","startsWith","parsed","JSON","parse","Array","isArray","some","item","includes","getColumns","rows","prepare","all","Set","map","r","ensureSchema","cfg","exec","feature","schema","getMeta","setMeta","key","row","get","run","docCount","n","reconcile","baseDir","files","currentSet","f","relPath","existingRows","existing","Map","path","vanished","filter","has","toReparse","_mtime","mtimeMs","_size","size","length","warnings","features","seenColumns","newColumns","parsedDocs","file","doc","fileWarnings","push","Object","keys","data","add","allColumns","insertSql","col","delBody","del","remove","insert","insertBody","values","search","title","summary","text","store","extracted","afterReconcile","err","open","stateDir","recursive","dbPath","version","wantFeatures","close","force","rebuild"],"mappings":"AAAA,SAASA,SAAS,EAAEC,MAAM,QAAQ,UAAU;AAC5C,SAASC,IAAI,QAAQ,YAAY;AACjC,SAASC,YAAY,QAAQ,cAAc;AAE3C,SAASC,eAAe,EAAEC,SAAS,QAAQ,cAAc;AACzD,SAASC,cAAc,QAAQ,sBAAsB;AAErD,SAASC,SAAS,EAAEC,SAAS,QAAQ,YAAY;AAEjD,gFAAgF;AAChF,kEAAkE;AAElE,OAAO,MAAMC,cAAc,WAAW;AACtC,kEAAkE;AAClE,OAAO,MAAMC,iBAAiB,IAAI;AAUlC,SAASC,WAAWC,IAAY;IAC9B,OAAO,CAAC,CAAC,EAAEA,KAAKC,KAAK,CAAC,KAAKX,IAAI,CAAC,MAAM,CAAC,CAAC;AAC1C;AAEA,+FAA+F;AAC/F,SAASY,kBAAkBC,EAAgB;IACzCA,GAAGC,QAAQ,CAAC,OAAO;QAAEC,eAAe;QAAMC,SAAS;IAAM,GAAG,CAACC,OAAgBC;QAC3E,IAAID,UAAU,QAAQA,UAAUE,WAAW,OAAO;QAElD,MAAMC,SAASC,OAAOH;QAEtB,IAAI,OAAOD,UAAU,UAAU;YAC7B,IAAIA,MAAMK,UAAU,CAAC,MAAM;gBACzB,IAAI;oBACF,MAAMC,SAASC,KAAKC,KAAK,CAACR;oBAC1B,IAAIS,MAAMC,OAAO,CAACJ,SAAS;wBACzB,OAAOA,OAAOK,IAAI,CAAC,CAACC,OAASR,OAAOQ,UAAUT,UAAU,IAAI;oBAC9D;gBACF,EAAE,OAAM,CAAC;YACX;YACA,OAAOH,MAAMa,QAAQ,CAACV,UAAU,IAAI;QACtC;QAEA,OAAOC,OAAOJ,OAAOa,QAAQ,CAACV,UAAU,IAAI;IAC9C;AACF;AAEA,SAASW,WAAWlB,EAAgB;IAClC,MAAMmB,OAAOnB,GAAGoB,OAAO,CAAC,kCAAkCC,GAAG;IAC7D,OAAO,IAAIC,IAAIH,KAAKI,GAAG,CAAC,CAACC,IAAMA,EAAE3B,IAAI;AACvC;AAEA,2FAA2F;AAC3F,wFAAwF;AACxF,SAAS4B,aAAazB,EAAgB,EAAE0B,GAAW;IACjD1B,GAAG2B,IAAI,CAAC,CAAC,gGAAgG,CAAC;IAC1G3B,GAAG2B,IAAI,CAAC,CAAC,0HAA0H,CAAC;IACpI,KAAK,MAAMC,WAAWrC,eAAemC,KAAME,QAAQC,MAAM,CAAC7B;IAC1D,IAAI8B,QAAQ9B,IAAI,sBAAsB,MAAM+B,QAAQ/B,IAAI,kBAAkBL;IAC1E,IAAImC,QAAQ9B,IAAI,gBAAgB,MAAM+B,QAAQ/B,IAAI,YAAYX,gBAAgBqC,KAAKvC,IAAI,CAAC;AAC1F;AAEA,OAAO,SAAS2C,QAAQ9B,EAAgB,EAAEgC,GAAW;IACnD,MAAMC,MAAMjC,GAAGoB,OAAO,CAAC,wCAAwCc,GAAG,CAACF;IACnE,OAAOC,MAAMA,IAAI5B,KAAK,GAAG;AAC3B;AAEA,OAAO,SAAS0B,QAAQ/B,EAAgB,EAAEgC,GAAW,EAAE3B,KAAoB;IACzE,IAAIA,UAAU,MAAM;QAClBL,GAAGoB,OAAO,CAAC,kCAAkCe,GAAG,CAACH;QACjD;IACF;IACAhC,GAAGoB,OAAO,CAAC,qGAAqGe,GAAG,CAACH,KAAK3B;AAC3H;AAEA,OAAO,SAAS+B,SAASpC,EAAgB;IACvC,MAAMiC,MAAMjC,GAAGoB,OAAO,CAAC,yCAAyCc,GAAG;IACnE,OAAOD,IAAII,CAAC;AACd;AAEA,OAAO,SAASC,UAAUtC,EAAgB,EAAE0B,GAAW,EAAEa,OAAe;IACtE,MAAMC,QAAQhD,UAAUkC,KAAKa;IAC7B,MAAME,aAAa,IAAInB,IAAIkB,MAAMjB,GAAG,CAAC,CAACmB,IAAMA,EAAEC,OAAO;IAErD,MAAMC,eAAe5C,GAAGoB,OAAO,CAAC,CAAC,iDAAiD,CAAC,EAAEC,GAAG;IAKxF,MAAMwB,WAAW,IAAIC,IAAIF,aAAarB,GAAG,CAAC,CAACC,IAAM;YAACA,EAAEuB,IAAI;YAAEvB;SAAE;IAC5D,MAAMwB,WAAWJ,aAAaK,MAAM,CAAC,CAACzB,IAAM,CAACiB,WAAWS,GAAG,CAAC1B,EAAEuB,IAAI,GAAGxB,GAAG,CAAC,CAACC,IAAMA,EAAEuB,IAAI;IAEtF,MAAMI,YAAYX,MAAMS,MAAM,CAAC,CAACP;QAC9B,MAAMT,MAAMY,SAASX,GAAG,CAACQ,EAAEC,OAAO;QAClC,OAAO,CAACV,OAAOA,IAAImB,MAAM,KAAKV,EAAEW,OAAO,IAAIpB,IAAIqB,KAAK,KAAKZ,EAAEa,IAAI;IACjE;IAEA,IAAIP,SAASQ,MAAM,KAAK,KAAKL,UAAUK,MAAM,KAAK,GAAG,OAAO;QAAE9C,QAAQ;QAAG+C,UAAU,EAAE;IAAC;IAEtF,MAAMC,WAAWnE,eAAemC;IAChC,MAAMiC,cAAczC,WAAWlB;IAC/B,MAAM4D,aAAuB,EAAE;IAC/B,MAAMC,aAA0B,EAAE;IAClC,MAAMJ,WAAqB,EAAE;IAE7B,KAAK,MAAMK,QAAQX,UAAW;QAC5B,MAAM,EAAEY,GAAG,EAAEN,UAAUO,YAAY,EAAE,GAAGvE,UAAUqE,MAAMJ;QACxDD,SAASQ,IAAI,IAAID;QACjB,KAAK,MAAMhC,OAAOkC,OAAOC,IAAI,CAACJ,IAAIK,IAAI,EAAG;YACvC,IAAI,CAACT,YAAYT,GAAG,CAAClB,MAAM;gBACzB2B,YAAYU,GAAG,CAACrC;gBAChB4B,WAAWK,IAAI,CAACjC;YAClB;QACF;QACA6B,WAAWI,IAAI,CAACF;IAClB;IAEA,MAAMO,aAAa;WAAIX;KAAY;IACnC,MAAMY,YAAY,CAAC,oCAAoC,EAAED,WAAW/C,GAAG,CAAC3B,YAAYT,IAAI,CAAC,MAAM,UAAU,EAAEmF,WAAW/C,GAAG,CAAC,IAAM,KAAKpC,IAAI,CAAC,MAAM,CAAC,CAAC;IAElJa,GAAG2B,IAAI,CAAC;IACR,IAAI;YA+B8BC;QA9BhC,KAAK,MAAM4C,OAAOZ,WAAY5D,GAAG2B,IAAI,CAAC,CAAC,mCAAmC,EAAE/B,WAAW4E,MAAM;QAC7F,8DAA8D;QAC9D,MAAMC,UAAUzE,GAAGoB,OAAO,CAAC,CAAC,oCAAoC,CAAC;QACjE,IAAI4B,SAASQ,MAAM,GAAG,GAAG;YACvB,MAAMkB,MAAM1E,GAAGoB,OAAO,CAAC,CAAC,wCAAwC,CAAC;YACjE,KAAK,MAAM2B,QAAQC,SAAU;oBAGKpB;gBAFhC8C,IAAIvC,GAAG,CAACY;gBACR0B,QAAQtC,GAAG,CAACY;gBACZ,KAAK,MAAMnB,WAAW8B,UAAU9B,kBAAAA,QAAQ+C,MAAM,cAAd/C,sCAAAA,qBAAAA,SAAiB5B,IAAI+C;YACvD;QACF;QACA,IAAIc,WAAWL,MAAM,GAAG,GAAG;YACzB,MAAMoB,SAAS5E,GAAGoB,OAAO,CAACmD;YAC1B,MAAMM,aAAa7E,GAAGoB,OAAO,CAAC,CAAC,sEAAsE,CAAC;YACtG,KAAK,MAAM2C,OAAOF,WAAY;gBAC5B,MAAMiB,SAASR,WAAW/C,GAAG,CAAC,CAACiD;wBAItBT;oBAHP,IAAIS,QAAQ,QAAQ,OAAOT,IAAIpB,OAAO;oBACtC,IAAI6B,QAAQ,UAAU,OAAOT,IAAIV,OAAO;oBACxC,IAAImB,QAAQ,SAAS,OAAOT,IAAIR,IAAI;oBACpC,QAAOQ,gBAAAA,IAAIK,IAAI,CAACI,IAAI,cAAbT,2BAAAA,gBAAiB;gBAC1B;gBACAa,OAAOzC,GAAG,IAAI2C;gBACdL,QAAQtC,GAAG,CAAC4B,IAAIpB,OAAO;gBACvBkC,WAAW1C,GAAG,CAAC4B,IAAIgB,MAAM,CAACC,KAAK,EAAEjB,IAAIgB,MAAM,CAACE,OAAO,EAAElB,IAAIgB,MAAM,CAACG,IAAI,EAAEnB,IAAIpB,OAAO;gBACjF,KAAK,MAAMf,WAAW8B,SAAU;wBAC9B9B,kBACAA;qBADAA,mBAAAA,QAAQ+C,MAAM,cAAd/C,uCAAAA,sBAAAA,SAAiB5B,IAAI+D,IAAIpB,OAAO;qBAChCf,iBAAAA,QAAQuD,KAAK,cAAbvD,qCAAAA,oBAAAA,SAAgB5B,IAAI+D,IAAIpB,OAAO,EAAEoB,IAAIqB,SAAS,CAACxD,QAAQ/B,IAAI,CAAC;gBAC9D;YACF;QACF;QACA,KAAK,MAAM+B,WAAW8B,UAAU9B,0BAAAA,QAAQyD,cAAc,cAAtBzD,8CAAAA,6BAAAA,SAAyB5B,IAAIwC;QAC7DxC,GAAG2B,IAAI,CAAC;IACV,EAAE,OAAO2D,KAAK;QACZtF,GAAG2B,IAAI,CAAC;QACR,MAAM2D;IACR;IAEA,OAAO;QAAE5E,QAAQmD,WAAWL,MAAM;QAAEC;IAAS;AAC/C;AAEA,OAAO,SAAS8B,KAAK7D,GAAmB;IACtC,MAAM8D,WAAWrG,KAAKuC,IAAIa,OAAO,EAAEjD;IACnCL,UAAUuG,UAAU;QAAEC,WAAW;IAAK;IACtC,MAAMC,SAASvG,KAAKqG,UAAU9F;IAE9B,MAAMM,KAAK,IAAIZ,aAAasG;IAC5B1F,GAAG2B,IAAI,CAAC;IACR3B,GAAG2B,IAAI,CAAC;IACR5B,kBAAkBC;IAElBA,GAAG2B,IAAI,CAAC;IAER,uFAAuF;IACvF,qGAAqG;IACrG,MAAMgE,UAAU7D,QAAQ9B,IAAI;IAC5B,MAAM0D,WAAW5B,QAAQ9B,IAAI;IAC7B,MAAM4F,eAAevG,gBAAgBqC,KAAKvC,IAAI,CAAC;IAC/C,IAAI,AAACwG,YAAY,QAAQA,YAAYhG,kBAAoB+D,aAAa,QAAQA,aAAakC,cAAe;QACxG5F,GAAG6F,KAAK;QACR3G,OAAOsG,UAAU;YAAEC,WAAW;YAAMK,OAAO;QAAK;QAChD,OAAOP,KAAK7D;IACd;IAEAD,aAAazB,IAAI0B;IACjB,MAAM,EAAEhB,MAAM,EAAE+C,QAAQ,EAAE,GAAGnB,UAAUtC,IAAI0B,KAAKA,IAAIa,OAAO;IAE3D,OAAO;QAAEvC;QAAI0B;QAAKgE;QAAQhF;QAAQ+C;IAAS;AAC7C;AAEA,oCAAoC;AACpC,OAAO,SAASsC,QAAQrE,GAAmB;IACzCxC,OAAOC,KAAKuC,IAAIa,OAAO,EAAEjD,YAAY;QAAEmG,WAAW;QAAMK,OAAO;IAAK;IACpE,OAAOP,KAAK7D;AACd"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/db.ts"],"sourcesContent":["import { mkdirSync, rmSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\nimport type { Config, ResolvedConfig } from './config.ts';\nimport { featureSignature, STATE_DIR } from './config.ts';\nimport { activeFeatures } from './features/index.ts';\nimport type { ParsedDoc } from './scan.ts';\nimport { listFiles, parseFile } from './scan.ts';\n\n// Rows -> SQLite: core schema, reconcile loop, has(). Parsing lives in scan.ts;\n// everything beyond frontmatter + content lives in src/features/.\n\nexport const DB_FILENAME = 'cache.db';\n// Cache shape version, independent of the config's own `version`.\nexport const SCHEMA_VERSION = '4'; // 4: frontmatter scalars store as INTEGER/REAL/TEXT by YAML type (was: all numbers REAL)\n\nexport interface OpenResult {\n db: DatabaseSync;\n cfg: ResolvedConfig;\n dbPath: string;\n parsed: number;\n warnings: string[];\n}\n\nfunction quoteIdent(name: string): string {\n return `\"${name.split('\"').join('\"\"')}\"`;\n}\n\n// has(field, value): JSON-array field -> membership, string field -> substring, NULL -> false.\nfunction registerFunctions(db: DatabaseSync): void {\n db.function('has', { deterministic: true, varargs: false }, (field: unknown, value: unknown): number => {\n if (field === null || field === undefined) return 0;\n\n const needle = String(value);\n\n if (typeof field === 'string') {\n if (field.startsWith('[')) {\n try {\n const parsed = JSON.parse(field);\n if (Array.isArray(parsed)) {\n return parsed.some((item) => String(item) === needle) ? 1 : 0;\n }\n } catch {}\n }\n return field.includes(needle) ? 1 : 0;\n }\n\n return String(field).includes(needle) ? 1 : 0;\n });\n}\n\nfunction getColumns(db: DatabaseSync): Set<string> {\n const rows = db.prepare('PRAGMA table_info(frontmatter)').all() as Array<{ name: string }>;\n return new Set(rows.map((r) => r.name));\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): void {\n db.exec(`CREATE TABLE IF NOT EXISTS frontmatter (\"path\" TEXT PRIMARY KEY, \"_mtime\" REAL, \"_size\" INTEGER)`);\n db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS content USING fts5(title, summary, text, path UNINDEXED, tokenize = 'porter unicode61')`);\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 getMeta(db: DatabaseSync, key: string): string | null {\n const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as { value: string } | undefined;\n return row ? row.value : null;\n}\n\nexport function setMeta(db: DatabaseSync, key: string, value: string | null): void {\n if (value === null) {\n db.prepare('DELETE FROM meta WHERE key = ?').run(key);\n return;\n }\n db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);\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 reconcile(db: DatabaseSync, cfg: Config, baseDir: string): { parsed: number; warnings: string[] } {\n const files = listFiles(cfg, baseDir);\n const currentSet = new Set(files.map((f) => f.relPath));\n\n const existingRows = db.prepare(`SELECT \"path\", \"_mtime\", \"_size\" FROM frontmatter`).all() as Array<{\n path: string;\n _mtime: number;\n _size: number;\n }>;\n const existing = new Map(existingRows.map((r) => [r.path, r]));\n const vanished = existingRows.filter((r) => !currentSet.has(r.path)).map((r) => r.path);\n\n const toReparse = files.filter((f) => {\n const row = existing.get(f.relPath);\n return !row || row._mtime !== f.mtimeMs || row._size !== f.size;\n });\n\n if (vanished.length === 0 && toReparse.length === 0) return { parsed: 0, warnings: [] };\n\n const features = activeFeatures(cfg);\n const seenColumns = getColumns(db);\n const newColumns: string[] = [];\n const parsedDocs: ParsedDoc[] = [];\n const warnings: string[] = [];\n\n for (const file of toReparse) {\n const { doc, warnings: fileWarnings } = parseFile(file, features);\n warnings.push(...fileWarnings);\n for (const key of Object.keys(doc.data)) {\n if (!seenColumns.has(key)) {\n seenColumns.add(key);\n newColumns.push(key);\n }\n }\n parsedDocs.push(doc);\n }\n\n const allColumns = [...seenColumns];\n const insertSql = `INSERT OR REPLACE INTO frontmatter (${allColumns.map(quoteIdent).join(', ')}) VALUES (${allColumns.map(() => '?').join(', ')})`;\n\n db.exec('BEGIN');\n try {\n for (const col of newColumns) db.exec(`ALTER TABLE frontmatter ADD COLUMN ${quoteIdent(col)}`);\n // FTS5 has no upsert, so delete-before-insert into `content`.\n const delBody = db.prepare(`DELETE FROM content WHERE \"path\" = ?`);\n if (vanished.length > 0) {\n const del = db.prepare(`DELETE FROM frontmatter WHERE \"path\" = ?`);\n for (const path of vanished) {\n del.run(path);\n delBody.run(path);\n for (const feature of features) feature.remove?.(db, path);\n }\n }\n if (parsedDocs.length > 0) {\n const insert = db.prepare(insertSql);\n const insertBody = db.prepare(`INSERT INTO content (title, summary, text, \"path\") VALUES (?, ?, ?, ?)`);\n for (const doc of parsedDocs) {\n const values = allColumns.map((col) => {\n if (col === 'path') return doc.relPath;\n if (col === '_mtime') return doc.mtimeMs;\n if (col === '_size') return doc.size;\n return doc.data[col] ?? null;\n });\n insert.run(...values);\n // Delete-before-insert only for docs that have rows: an FTS5 DELETE by column\n // cannot use an index and scans the whole table, so running it per doc on a\n // cold build (empty table, nothing to delete) made the crawl quadratic --\n // measured 4x time per note-count doubling at 13k/26k notes.\n if (existing.has(doc.relPath)) {\n delBody.run(doc.relPath);\n for (const feature of features) feature.remove?.(db, doc.relPath);\n }\n insertBody.run(doc.search.title, doc.search.summary, doc.search.text, doc.relPath);\n for (const feature of features) feature.store?.(db, doc.relPath, doc.extracted[feature.name]);\n }\n }\n for (const feature of features) feature.afterReconcile?.(db, files);\n db.exec('COMMIT');\n } catch (err) {\n db.exec('ROLLBACK');\n throw err;\n }\n\n return { parsed: parsedDocs.length, warnings };\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: the write transaction for 500 changed\n // files measures ~5s at 26k notes, so 5s expired exactly at the boundary and queries\n // racing the watcher got SQLITE_BUSY. 30s bounds the wait at ~3x the largest measured\n // reconcile; a query that outwaits it still fails loudly.\n db.exec('PRAGMA busy_timeout = 30000');\n registerFunctions(db);\n\n db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)');\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 if ((version !== null && version !== SCHEMA_VERSION) || (features !== null && features !== wantFeatures)) {\n db.close();\n rmSync(stateDir, { recursive: true, force: true });\n return open(cfg);\n }\n\n ensureSchema(db, cfg);\n const { parsed, warnings } = reconcile(db, cfg, cfg.baseDir);\n\n return { db, cfg, dbPath, parsed, warnings };\n}\n\n// Manual reset for a doubted cache.\nexport function rebuild(cfg: ResolvedConfig): OpenResult {\n rmSync(join(cfg.baseDir, STATE_DIR), { recursive: true, force: true });\n return open(cfg);\n}\n"],"names":["mkdirSync","rmSync","join","DatabaseSync","featureSignature","STATE_DIR","activeFeatures","listFiles","parseFile","DB_FILENAME","SCHEMA_VERSION","quoteIdent","name","split","registerFunctions","db","function","deterministic","varargs","field","value","undefined","needle","String","startsWith","parsed","JSON","parse","Array","isArray","some","item","includes","getColumns","rows","prepare","all","Set","map","r","ensureSchema","cfg","exec","feature","schema","getMeta","setMeta","key","row","get","run","docCount","n","reconcile","baseDir","files","currentSet","f","relPath","existingRows","existing","Map","path","vanished","filter","has","toReparse","_mtime","mtimeMs","_size","size","length","warnings","features","seenColumns","newColumns","parsedDocs","file","doc","fileWarnings","push","Object","keys","data","add","allColumns","insertSql","col","delBody","del","remove","insert","insertBody","values","search","title","summary","text","store","extracted","afterReconcile","err","open","stateDir","recursive","dbPath","version","wantFeatures","close","force","rebuild"],"mappings":"AAAA,SAASA,SAAS,EAAEC,MAAM,QAAQ,UAAU;AAC5C,SAASC,IAAI,QAAQ,YAAY;AACjC,SAASC,YAAY,QAAQ,cAAc;AAE3C,SAASC,gBAAgB,EAAEC,SAAS,QAAQ,cAAc;AAC1D,SAASC,cAAc,QAAQ,sBAAsB;AAErD,SAASC,SAAS,EAAEC,SAAS,QAAQ,YAAY;AAEjD,gFAAgF;AAChF,kEAAkE;AAElE,OAAO,MAAMC,cAAc,WAAW;AACtC,kEAAkE;AAClE,OAAO,MAAMC,iBAAiB,IAAI,CAAC,yFAAyF;AAU5H,SAASC,WAAWC,IAAY;IAC9B,OAAO,CAAC,CAAC,EAAEA,KAAKC,KAAK,CAAC,KAAKX,IAAI,CAAC,MAAM,CAAC,CAAC;AAC1C;AAEA,+FAA+F;AAC/F,SAASY,kBAAkBC,EAAgB;IACzCA,GAAGC,QAAQ,CAAC,OAAO;QAAEC,eAAe;QAAMC,SAAS;IAAM,GAAG,CAACC,OAAgBC;QAC3E,IAAID,UAAU,QAAQA,UAAUE,WAAW,OAAO;QAElD,MAAMC,SAASC,OAAOH;QAEtB,IAAI,OAAOD,UAAU,UAAU;YAC7B,IAAIA,MAAMK,UAAU,CAAC,MAAM;gBACzB,IAAI;oBACF,MAAMC,SAASC,KAAKC,KAAK,CAACR;oBAC1B,IAAIS,MAAMC,OAAO,CAACJ,SAAS;wBACzB,OAAOA,OAAOK,IAAI,CAAC,CAACC,OAASR,OAAOQ,UAAUT,UAAU,IAAI;oBAC9D;gBACF,EAAE,OAAM,CAAC;YACX;YACA,OAAOH,MAAMa,QAAQ,CAACV,UAAU,IAAI;QACtC;QAEA,OAAOC,OAAOJ,OAAOa,QAAQ,CAACV,UAAU,IAAI;IAC9C;AACF;AAEA,SAASW,WAAWlB,EAAgB;IAClC,MAAMmB,OAAOnB,GAAGoB,OAAO,CAAC,kCAAkCC,GAAG;IAC7D,OAAO,IAAIC,IAAIH,KAAKI,GAAG,CAAC,CAACC,IAAMA,EAAE3B,IAAI;AACvC;AAEA,2FAA2F;AAC3F,wFAAwF;AACxF,SAAS4B,aAAazB,EAAgB,EAAE0B,GAAW;IACjD1B,GAAG2B,IAAI,CAAC,CAAC,gGAAgG,CAAC;IAC1G3B,GAAG2B,IAAI,CAAC,CAAC,0HAA0H,CAAC;IACpI,KAAK,MAAMC,WAAWrC,eAAemC,KAAME,QAAQC,MAAM,CAAC7B;IAC1D,IAAI8B,QAAQ9B,IAAI,sBAAsB,MAAM+B,QAAQ/B,IAAI,kBAAkBL;IAC1E,IAAImC,QAAQ9B,IAAI,gBAAgB,MAAM+B,QAAQ/B,IAAI,YAAYX,iBAAiBqC;AACjF;AAEA,OAAO,SAASI,QAAQ9B,EAAgB,EAAEgC,GAAW;IACnD,MAAMC,MAAMjC,GAAGoB,OAAO,CAAC,wCAAwCc,GAAG,CAACF;IACnE,OAAOC,MAAMA,IAAI5B,KAAK,GAAG;AAC3B;AAEA,OAAO,SAAS0B,QAAQ/B,EAAgB,EAAEgC,GAAW,EAAE3B,KAAoB;IACzE,IAAIA,UAAU,MAAM;QAClBL,GAAGoB,OAAO,CAAC,kCAAkCe,GAAG,CAACH;QACjD;IACF;IACAhC,GAAGoB,OAAO,CAAC,qGAAqGe,GAAG,CAACH,KAAK3B;AAC3H;AAEA,OAAO,SAAS+B,SAASpC,EAAgB;IACvC,MAAMiC,MAAMjC,GAAGoB,OAAO,CAAC,yCAAyCc,GAAG;IACnE,OAAOD,IAAII,CAAC;AACd;AAEA,OAAO,SAASC,UAAUtC,EAAgB,EAAE0B,GAAW,EAAEa,OAAe;IACtE,MAAMC,QAAQhD,UAAUkC,KAAKa;IAC7B,MAAME,aAAa,IAAInB,IAAIkB,MAAMjB,GAAG,CAAC,CAACmB,IAAMA,EAAEC,OAAO;IAErD,MAAMC,eAAe5C,GAAGoB,OAAO,CAAC,CAAC,iDAAiD,CAAC,EAAEC,GAAG;IAKxF,MAAMwB,WAAW,IAAIC,IAAIF,aAAarB,GAAG,CAAC,CAACC,IAAM;YAACA,EAAEuB,IAAI;YAAEvB;SAAE;IAC5D,MAAMwB,WAAWJ,aAAaK,MAAM,CAAC,CAACzB,IAAM,CAACiB,WAAWS,GAAG,CAAC1B,EAAEuB,IAAI,GAAGxB,GAAG,CAAC,CAACC,IAAMA,EAAEuB,IAAI;IAEtF,MAAMI,YAAYX,MAAMS,MAAM,CAAC,CAACP;QAC9B,MAAMT,MAAMY,SAASX,GAAG,CAACQ,EAAEC,OAAO;QAClC,OAAO,CAACV,OAAOA,IAAImB,MAAM,KAAKV,EAAEW,OAAO,IAAIpB,IAAIqB,KAAK,KAAKZ,EAAEa,IAAI;IACjE;IAEA,IAAIP,SAASQ,MAAM,KAAK,KAAKL,UAAUK,MAAM,KAAK,GAAG,OAAO;QAAE9C,QAAQ;QAAG+C,UAAU,EAAE;IAAC;IAEtF,MAAMC,WAAWnE,eAAemC;IAChC,MAAMiC,cAAczC,WAAWlB;IAC/B,MAAM4D,aAAuB,EAAE;IAC/B,MAAMC,aAA0B,EAAE;IAClC,MAAMJ,WAAqB,EAAE;IAE7B,KAAK,MAAMK,QAAQX,UAAW;QAC5B,MAAM,EAAEY,GAAG,EAAEN,UAAUO,YAAY,EAAE,GAAGvE,UAAUqE,MAAMJ;QACxDD,SAASQ,IAAI,IAAID;QACjB,KAAK,MAAMhC,OAAOkC,OAAOC,IAAI,CAACJ,IAAIK,IAAI,EAAG;YACvC,IAAI,CAACT,YAAYT,GAAG,CAAClB,MAAM;gBACzB2B,YAAYU,GAAG,CAACrC;gBAChB4B,WAAWK,IAAI,CAACjC;YAClB;QACF;QACA6B,WAAWI,IAAI,CAACF;IAClB;IAEA,MAAMO,aAAa;WAAIX;KAAY;IACnC,MAAMY,YAAY,CAAC,oCAAoC,EAAED,WAAW/C,GAAG,CAAC3B,YAAYT,IAAI,CAAC,MAAM,UAAU,EAAEmF,WAAW/C,GAAG,CAAC,IAAM,KAAKpC,IAAI,CAAC,MAAM,CAAC,CAAC;IAElJa,GAAG2B,IAAI,CAAC;IACR,IAAI;YAmC8BC;QAlChC,KAAK,MAAM4C,OAAOZ,WAAY5D,GAAG2B,IAAI,CAAC,CAAC,mCAAmC,EAAE/B,WAAW4E,MAAM;QAC7F,8DAA8D;QAC9D,MAAMC,UAAUzE,GAAGoB,OAAO,CAAC,CAAC,oCAAoC,CAAC;QACjE,IAAI4B,SAASQ,MAAM,GAAG,GAAG;YACvB,MAAMkB,MAAM1E,GAAGoB,OAAO,CAAC,CAAC,wCAAwC,CAAC;YACjE,KAAK,MAAM2B,QAAQC,SAAU;oBAGKpB;gBAFhC8C,IAAIvC,GAAG,CAACY;gBACR0B,QAAQtC,GAAG,CAACY;gBACZ,KAAK,MAAMnB,WAAW8B,UAAU9B,kBAAAA,QAAQ+C,MAAM,cAAd/C,sCAAAA,qBAAAA,SAAiB5B,IAAI+C;YACvD;QACF;QACA,IAAIc,WAAWL,MAAM,GAAG,GAAG;YACzB,MAAMoB,SAAS5E,GAAGoB,OAAO,CAACmD;YAC1B,MAAMM,aAAa7E,GAAGoB,OAAO,CAAC,CAAC,sEAAsE,CAAC;YACtG,KAAK,MAAM2C,OAAOF,WAAY;oBAiBIjC;gBAhBhC,MAAMkD,SAASR,WAAW/C,GAAG,CAAC,CAACiD;wBAItBT;oBAHP,IAAIS,QAAQ,QAAQ,OAAOT,IAAIpB,OAAO;oBACtC,IAAI6B,QAAQ,UAAU,OAAOT,IAAIV,OAAO;oBACxC,IAAImB,QAAQ,SAAS,OAAOT,IAAIR,IAAI;oBACpC,QAAOQ,gBAAAA,IAAIK,IAAI,CAACI,IAAI,cAAbT,2BAAAA,gBAAiB;gBAC1B;gBACAa,OAAOzC,GAAG,IAAI2C;gBACd,8EAA8E;gBAC9E,4EAA4E;gBAC5E,0EAA0E;gBAC1E,6DAA6D;gBAC7D,IAAIjC,SAASK,GAAG,CAACa,IAAIpB,OAAO,GAAG;wBAEGf;oBADhC6C,QAAQtC,GAAG,CAAC4B,IAAIpB,OAAO;oBACvB,KAAK,MAAMf,WAAW8B,UAAU9B,mBAAAA,QAAQ+C,MAAM,cAAd/C,uCAAAA,sBAAAA,SAAiB5B,IAAI+D,IAAIpB,OAAO;gBAClE;gBACAkC,WAAW1C,GAAG,CAAC4B,IAAIgB,MAAM,CAACC,KAAK,EAAEjB,IAAIgB,MAAM,CAACE,OAAO,EAAElB,IAAIgB,MAAM,CAACG,IAAI,EAAEnB,IAAIpB,OAAO;gBACjF,KAAK,MAAMf,WAAW8B,UAAU9B,iBAAAA,QAAQuD,KAAK,cAAbvD,qCAAAA,oBAAAA,SAAgB5B,IAAI+D,IAAIpB,OAAO,EAAEoB,IAAIqB,SAAS,CAACxD,QAAQ/B,IAAI,CAAC;YAC9F;QACF;QACA,KAAK,MAAM+B,WAAW8B,UAAU9B,0BAAAA,QAAQyD,cAAc,cAAtBzD,8CAAAA,6BAAAA,SAAyB5B,IAAIwC;QAC7DxC,GAAG2B,IAAI,CAAC;IACV,EAAE,OAAO2D,KAAK;QACZtF,GAAG2B,IAAI,CAAC;QACR,MAAM2D;IACR;IAEA,OAAO;QAAE5E,QAAQmD,WAAWL,MAAM;QAAEC;IAAS;AAC/C;AAEA,OAAO,SAAS8B,KAAK7D,GAAmB;IACtC,MAAM8D,WAAWrG,KAAKuC,IAAIa,OAAO,EAAEjD;IACnCL,UAAUuG,UAAU;QAAEC,WAAW;IAAK;IACtC,MAAMC,SAASvG,KAAKqG,UAAU9F;IAE9B,MAAMM,KAAK,IAAIZ,aAAasG;IAC5B1F,GAAG2B,IAAI,CAAC;IACR,sFAAsF;IACtF,qFAAqF;IACrF,sFAAsF;IACtF,0DAA0D;IAC1D3B,GAAG2B,IAAI,CAAC;IACR5B,kBAAkBC;IAElBA,GAAG2B,IAAI,CAAC;IAER,uFAAuF;IACvF,qGAAqG;IACrG,MAAMgE,UAAU7D,QAAQ9B,IAAI;IAC5B,MAAM0D,WAAW5B,QAAQ9B,IAAI;IAC7B,MAAM4F,eAAevG,iBAAiBqC;IACtC,IAAI,AAACiE,YAAY,QAAQA,YAAYhG,kBAAoB+D,aAAa,QAAQA,aAAakC,cAAe;QACxG5F,GAAG6F,KAAK;QACR3G,OAAOsG,UAAU;YAAEC,WAAW;YAAMK,OAAO;QAAK;QAChD,OAAOP,KAAK7D;IACd;IAEAD,aAAazB,IAAI0B;IACjB,MAAM,EAAEhB,MAAM,EAAE+C,QAAQ,EAAE,GAAGnB,UAAUtC,IAAI0B,KAAKA,IAAIa,OAAO;IAE3D,OAAO;QAAEvC;QAAI0B;QAAKgE;QAAQhF;QAAQ+C;IAAS;AAC7C;AAEA,oCAAoC;AACpC,OAAO,SAASsC,QAAQrE,GAAmB;IACzCxC,OAAOC,KAAKuC,IAAIa,OAAO,EAAEjD,YAAY;QAAEmG,WAAW;QAAMK,OAAO;IAAK;IACpE,OAAOP,KAAK7D;AACd"}
@@ -1,4 +1,4 @@
1
- export type SenseErrorCode = 'CONFIG_NOT_FOUND' | 'CONFIG_EXISTS' | 'CONFIG_INVALID' | 'CONFIG_VERSION_UNSUPPORTED' | 'WATCH_ACTIVE' | 'NOTE_NOT_FOUND' | 'NOTE_AMBIGUOUS';
1
+ export type SenseErrorCode = 'CONFIG_NOT_FOUND' | 'CONFIG_EXISTS' | 'CONFIG_INVALID' | 'CONFIG_VERSION_UNSUPPORTED' | 'WATCH_ACTIVE' | 'NOTE_NOT_FOUND' | 'NOTE_AMBIGUOUS' | 'EMBED_DISABLED' | 'EMBED_MODEL' | 'SEARCH_SYNTAX';
2
2
  export declare class SenseError extends Error {
3
3
  code: SenseErrorCode;
4
4
  constructor(code: SenseErrorCode, message: string);
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/errors.ts"],"sourcesContent":["// Library code throws; only cli.ts prints and exits.\nexport type SenseErrorCode = 'CONFIG_NOT_FOUND' | 'CONFIG_EXISTS' | 'CONFIG_INVALID' | 'CONFIG_VERSION_UNSUPPORTED' | 'WATCH_ACTIVE' | 'NOTE_NOT_FOUND' | 'NOTE_AMBIGUOUS';\n\nexport class SenseError extends Error {\n code: SenseErrorCode;\n\n constructor(code: SenseErrorCode, message: string) {\n super(message);\n this.name = 'SenseError';\n this.code = code;\n }\n}\n"],"names":["SenseError","Error","code","message","name"],"mappings":"AAAA,qDAAqD;AAGrD,OAAO,MAAMA,mBAAmBC;IAG9B,YAAYC,IAAoB,EAAEC,OAAe,CAAE;QACjD,KAAK,CAACA;QACN,IAAI,CAACC,IAAI,GAAG;QACZ,IAAI,CAACF,IAAI,GAAGA;IACd;AACF"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/errors.ts"],"sourcesContent":["// Library code throws; only cli.ts prints and exits.\nexport type SenseErrorCode = 'CONFIG_NOT_FOUND' | 'CONFIG_EXISTS' | 'CONFIG_INVALID' | 'CONFIG_VERSION_UNSUPPORTED' | 'WATCH_ACTIVE' | 'NOTE_NOT_FOUND' | 'NOTE_AMBIGUOUS' | 'EMBED_DISABLED' | 'EMBED_MODEL' | 'SEARCH_SYNTAX';\n\nexport class SenseError extends Error {\n code: SenseErrorCode;\n\n constructor(code: SenseErrorCode, message: string) {\n super(message);\n this.name = 'SenseError';\n this.code = code;\n }\n}\n"],"names":["SenseError","Error","code","message","name"],"mappings":"AAAA,qDAAqD;AAGrD,OAAO,MAAMA,mBAAmBC;IAG9B,YAAYC,IAAoB,EAAEC,OAAe,CAAE;QACjD,KAAK,CAACA;QACN,IAAI,CAACC,IAAI,GAAG;QACZ,IAAI,CAACF,IAAI,GAAGA;IACd;AACF"}
@@ -0,0 +1,15 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { Config } from '../config.js';
3
+ import type { Feature } from './types.js';
4
+ export interface EmbedProvider {
5
+ id: string;
6
+ dims: number;
7
+ embed(texts: string[]): Promise<Float32Array[]>;
8
+ }
9
+ export declare const embed: Feature;
10
+ export declare function embedPending(db: DatabaseSync, cfg: Config, baseDir: string): Promise<void>;
11
+ export declare function semanticCandidates(db: DatabaseSync, cfg: Config, terms: string, fetch: number): Promise<Array<{
12
+ path: string;
13
+ lines: string;
14
+ similarity: number;
15
+ }>>;
@@ -0,0 +1,299 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { embedConfig } from '../config.js';
5
+ import { SenseError } from '../errors.js';
6
+ import { parseFile } from '../scan.js';
7
+ // embeddings(path, chunk, start_line, end_line, scale, vector): heading-based chunks,
8
+ // int8 vectors with a per-vector dequantization scale, vector NULL = not yet embedded.
9
+ // Reconcile stores dirty rows inside its transaction; embedding tops up on the next
10
+ // semantic query (embedPending), so staleness costs recall, never correctness.
11
+ // Storage lever fixed by the bake-off (BENCHMARKING.md): int8 at 256 dims is
12
+ // quality-free vs f32-512 when fused. Queries stay f32 at the same dims.
13
+ const STORE_DIMS = 256;
14
+ const BATCH = 64;
15
+ // Deterministic chunker used at reconcile (line ranges stored) and at embed time (text
16
+ // re-derived from the file -- chunk text is never stored). Heading-delimited with the
17
+ // preamble kept; whole file when no headings. The title/summary prefix mirrors the
18
+ // bm25 column weighting.
19
+ function chunksOf(raw, search) {
20
+ const lines = raw.split('\n');
21
+ const starts = [];
22
+ let inFence = false;
23
+ for(let i = 0; i < lines.length; i++){
24
+ if (/^(```|~~~)/.test(lines[i])) {
25
+ inFence = !inFence;
26
+ continue;
27
+ }
28
+ if (!inFence && /^#{1,6} +/.test(lines[i])) starts.push(i + 1);
29
+ }
30
+ const bounds = starts.length === 0 ? [
31
+ 1
32
+ ] : starts[0] > 1 ? [
33
+ 1,
34
+ ...starts
35
+ ] : starts;
36
+ const prefix = [
37
+ search === null || search === void 0 ? void 0 : search.title,
38
+ search === null || search === void 0 ? void 0 : search.summary
39
+ ].filter(Boolean).join('\n');
40
+ const chunks = [];
41
+ bounds.forEach((start, i)=>{
42
+ const end = i + 1 < bounds.length ? bounds[i + 1] - 1 : lines.length;
43
+ const text = lines.slice(start - 1, end).join('\n').trim();
44
+ if (text.length > 0) chunks.push({
45
+ startLine: start,
46
+ endLine: end,
47
+ text: prefix ? `${prefix}\n${text}` : text
48
+ });
49
+ });
50
+ return chunks;
51
+ }
52
+ export const embed = {
53
+ name: 'embed',
54
+ schema (db) {
55
+ db.exec(`CREATE TABLE IF NOT EXISTS embeddings ("path" TEXT, chunk INTEGER, start_line INTEGER, end_line INTEGER, scale REAL, vector BLOB, PRIMARY KEY ("path", chunk))`);
56
+ },
57
+ extract (raw, _body, search) {
58
+ return chunksOf(raw, search);
59
+ },
60
+ remove (db, path) {
61
+ db.prepare('DELETE FROM embeddings WHERE "path" = ?').run(path);
62
+ },
63
+ store (db, path, extracted) {
64
+ const insert = db.prepare('INSERT INTO embeddings ("path", chunk, start_line, end_line, scale, vector) VALUES (?, ?, ?, ?, NULL, NULL)');
65
+ extracted.forEach((c, idx)=>insert.run(path, idx, c.startLine, c.endLine));
66
+ }
67
+ };
68
+ // --- static type: Model2Vec safetensors + pure-JS tokenizer, model cached in ~/.cache ---
69
+ // Encode convention from model2vec/model.py: no special tokens, drop unk ids, mean-pool,
70
+ // L2-normalize.
71
+ function fetchToFile(url, dest) {
72
+ return fetch(url).then(async (res)=>{
73
+ if (!res.ok) throw new SenseError('EMBED_MODEL', `model download failed: ${url} -> HTTP ${res.status}`);
74
+ writeFileSync(`${dest}.part`, Buffer.from(await res.arrayBuffer()));
75
+ renameSync(`${dest}.part`, dest);
76
+ });
77
+ }
78
+ async function staticProvider(model) {
79
+ var _ref;
80
+ var _tokenizerJson_model, _tokenizerJson_model_vocab, _tokenizerJson_model1;
81
+ let dir = model;
82
+ if (!existsSync(join(dir, 'model.safetensors'))) {
83
+ dir = join(homedir(), '.cache', 'sensemaking', 'models', model.replace(/\//g, '--'));
84
+ mkdirSync(dir, {
85
+ recursive: true
86
+ });
87
+ for (const file of [
88
+ 'model.safetensors',
89
+ 'tokenizer.json'
90
+ ]){
91
+ if (!existsSync(join(dir, file))) {
92
+ console.error(`fetching ${model}/${file} into ${dir} (once; delete to refetch)`);
93
+ await fetchToFile(`https://huggingface.co/${model}/resolve/main/${file}`, join(dir, file));
94
+ }
95
+ }
96
+ }
97
+ const raw = readFileSync(join(dir, 'model.safetensors'));
98
+ const headerLen = Number(raw.readBigUInt64LE(0));
99
+ const header = JSON.parse(raw.subarray(8, 8 + headerLen).toString('utf8'));
100
+ const entry = Object.entries(header).find(([k])=>k !== '__metadata__');
101
+ if (!entry || entry[1].dtype !== 'F32') throw new SenseError('EMBED_MODEL', `${model}: expected an F32 safetensors matrix`);
102
+ const spec = entry[1];
103
+ const dims = spec.shape[1];
104
+ const dataStart = raw.byteOffset + 8 + headerLen + spec.data_offsets[0];
105
+ const matrix = dataStart % 4 === 0 ? new Float32Array(raw.buffer, dataStart, spec.shape[0] * dims) : new Float32Array(raw.buffer.slice(dataStart, dataStart + spec.shape[0] * dims * 4));
106
+ const tokenizerJson = JSON.parse(readFileSync(join(dir, 'tokenizer.json'), 'utf8'));
107
+ // Lazy import: the tokenizer loads only on the semantic path, never at CLI startup.
108
+ const { Tokenizer } = await import('@huggingface/tokenizers');
109
+ const tok = new Tokenizer(tokenizerJson, {});
110
+ const unkId = (_ref = (_tokenizerJson_model1 = tokenizerJson.model) === null || _tokenizerJson_model1 === void 0 ? void 0 : (_tokenizerJson_model_vocab = _tokenizerJson_model1.vocab) === null || _tokenizerJson_model_vocab === void 0 ? void 0 : _tokenizerJson_model_vocab[(_tokenizerJson_model = tokenizerJson.model) === null || _tokenizerJson_model === void 0 ? void 0 : _tokenizerJson_model.unk_token]) !== null && _ref !== void 0 ? _ref : -1;
111
+ function one(text) {
112
+ // The tokenizer yields undefined (not the unk id) for tokens outside the vocab; an
113
+ // undefined id would index the matrix at NaN and poison the whole mean-pool -- and the
114
+ // int8 conversion then stores the NaN vector as all zeros, silently. Keep integers only.
115
+ const ids = tok.encode(text, {
116
+ add_special_tokens: false
117
+ }).ids.filter((id)=>Number.isInteger(id) && id !== unkId);
118
+ const v = new Float32Array(dims);
119
+ if (ids.length === 0) return v;
120
+ for (const id of ids){
121
+ const off = id * dims;
122
+ for(let d = 0; d < dims; d++)v[d] += matrix[off + d];
123
+ }
124
+ let norm = 0;
125
+ for(let d = 0; d < dims; d++){
126
+ v[d] /= ids.length;
127
+ norm += v[d] * v[d];
128
+ }
129
+ norm = Math.sqrt(norm) + 1e-32;
130
+ for(let d = 0; d < dims; d++)v[d] /= norm;
131
+ return v;
132
+ }
133
+ return {
134
+ id: `static:${model}`,
135
+ dims,
136
+ embed: async (texts)=>texts.map(one)
137
+ };
138
+ }
139
+ // --- api type: one POST against any OpenAI-compatible /embeddings endpoint ---
140
+ async function apiProvider(model, url, keyEnv) {
141
+ if (!url) throw new SenseError('EMBED_MODEL', 'features.embed.type "api" requires a url');
142
+ const base = url.replace(/\/+$/, '');
143
+ const headers = {
144
+ 'content-type': 'application/json'
145
+ };
146
+ const key = keyEnv ? process.env[keyEnv] : undefined;
147
+ if (key) headers.authorization = `Bearer ${key}`;
148
+ async function post(texts) {
149
+ const res = await fetch(`${base}/embeddings`, {
150
+ method: 'POST',
151
+ headers,
152
+ body: JSON.stringify({
153
+ model,
154
+ input: texts
155
+ })
156
+ });
157
+ if (!res.ok) throw new SenseError('EMBED_MODEL', `${base}/embeddings -> HTTP ${res.status}`);
158
+ const body = await res.json();
159
+ return body.data.map((d)=>Float32Array.from(d.embedding));
160
+ }
161
+ const dims = (await post([
162
+ 'dimension probe'
163
+ ]))[0].length;
164
+ return {
165
+ id: `api:${base}:${model}`,
166
+ dims,
167
+ embed: post
168
+ };
169
+ }
170
+ const providers = new Map();
171
+ function getProvider(cfg) {
172
+ var _e_url;
173
+ const e = embedConfig(cfg);
174
+ if (!e) throw new SenseError('EMBED_DISABLED', 'semantic expansion needs features.embed in sense.config.json (e.g. "features": { "embed": true })');
175
+ const sig = `${e.type}:${e.model}:${(_e_url = e.url) !== null && _e_url !== void 0 ? _e_url : ''}`;
176
+ let p = providers.get(sig);
177
+ if (!p) {
178
+ p = e.type === 'api' ? apiProvider(e.model, e.url, e.key) : staticProvider(e.model);
179
+ providers.set(sig, p);
180
+ }
181
+ return p;
182
+ }
183
+ // Slice + re-normalize (Matryoshka); optionally round through int8 storage.
184
+ function toStore(full, dims, int8) {
185
+ const v = new Float32Array(dims);
186
+ let norm = 0;
187
+ for(let d = 0; d < dims; d++)norm += full[d] * full[d];
188
+ norm = Math.sqrt(norm) + 1e-32;
189
+ for(let d = 0; d < dims; d++)v[d] = full[d] / norm;
190
+ if (!int8) return {
191
+ v,
192
+ scale: 1
193
+ };
194
+ let max = 0;
195
+ for(let d = 0; d < dims; d++)max = Math.max(max, Math.abs(v[d]));
196
+ return {
197
+ v,
198
+ scale: max / 127 || 1
199
+ };
200
+ }
201
+ // Embed rows whose vector is NULL, re-deriving chunk text from the files through the
202
+ // same parse + chunker that stored the rows.
203
+ export async function embedPending(db, cfg, baseDir) {
204
+ const provider = await getProvider(cfg); // throws EMBED_DISABLED before touching the table
205
+ const dirty = db.prepare('SELECT "path", chunk FROM embeddings WHERE vector IS NULL ORDER BY "path", chunk').all();
206
+ if (dirty.length === 0) return;
207
+ const storeDims = Math.min(STORE_DIMS, provider.dims);
208
+ const byPath = new Map();
209
+ for (const row of dirty){
210
+ var _byPath_get;
211
+ const list = (_byPath_get = byPath.get(row.path)) !== null && _byPath_get !== void 0 ? _byPath_get : [];
212
+ list.push(row.chunk);
213
+ byPath.set(row.path, list);
214
+ }
215
+ const jobs = [];
216
+ for (const [path, chunkIdxs] of byPath){
217
+ let chunks;
218
+ try {
219
+ chunks = parseFile({
220
+ relPath: path,
221
+ absPath: join(baseDir, path),
222
+ mtimeMs: 0,
223
+ size: 0
224
+ }, [
225
+ embed
226
+ ]).doc.extracted.embed;
227
+ } catch {
228
+ continue; // vanished since reconcile; the next reconcile removes its rows
229
+ }
230
+ for (const idx of chunkIdxs)if (chunks[idx]) jobs.push({
231
+ path,
232
+ chunk: idx,
233
+ text: chunks[idx].text
234
+ });
235
+ }
236
+ const update = db.prepare('UPDATE embeddings SET scale = ?, vector = ? WHERE "path" = ? AND chunk = ?');
237
+ for(let i = 0; i < jobs.length; i += BATCH){
238
+ const batch = jobs.slice(i, i + BATCH);
239
+ const vectors = await provider.embed(batch.map((j)=>j.text));
240
+ db.exec('BEGIN');
241
+ try {
242
+ batch.forEach((job, j)=>{
243
+ const { v, scale } = toStore(vectors[j], storeDims, true);
244
+ const q = new Int8Array(storeDims);
245
+ for(let d = 0; d < storeDims; d++)q[d] = Math.round(v[d] / scale);
246
+ update.run(scale, Buffer.from(q.buffer), job.path, job.chunk);
247
+ });
248
+ db.exec('COMMIT');
249
+ } catch (err) {
250
+ db.exec('ROLLBACK');
251
+ throw err;
252
+ }
253
+ }
254
+ }
255
+ // Top candidates by cosine for a semantic find: best chunk per file, its line range
256
+ // riding along. FTS5 operators in the terms are lexical syntax, not meaning -- stripped
257
+ // before embedding.
258
+ // Returns the cosine similarity alongside each candidate: stored and query vectors are both
259
+ // L2-normalised before quantisation, so the dot product is cosine in [-1, 1]. `find` surfaces
260
+ // it because the fused rank score cannot express match quality -- nearest-neighbour search
261
+ // always returns a nearest neighbour, so without a magnitude an agent cannot tell a real hit
262
+ // from the best of a bad lot.
263
+ export async function semanticCandidates(db, cfg, terms, fetch1) {
264
+ var _terms_match;
265
+ const baseDir = cfg.baseDir;
266
+ if (!baseDir) throw new SenseError('EMBED_MODEL', 'semantic expansion needs a config with baseDir (use loadConfig/open)');
267
+ await embedPending(db, cfg, baseDir);
268
+ const provider = await getProvider(cfg);
269
+ const storeDims = Math.min(STORE_DIMS, provider.dims);
270
+ const text = ((_terms_match = terms.match(/[\p{L}\p{N}]+/gu)) !== null && _terms_match !== void 0 ? _terms_match : []).filter((t)=>![
271
+ 'AND',
272
+ 'OR',
273
+ 'NOT',
274
+ 'NEAR'
275
+ ].includes(t)).join(' ');
276
+ const { v: qv } = toStore((await provider.embed([
277
+ text
278
+ ]))[0], storeDims, false);
279
+ const rows = db.prepare('SELECT "path", start_line, end_line, scale, vector FROM embeddings WHERE vector IS NOT NULL').all();
280
+ const best = new Map();
281
+ for (const row of rows){
282
+ const q = new Int8Array(row.vector.buffer, row.vector.byteOffset, Math.min(storeDims, row.vector.byteLength));
283
+ let dot = 0;
284
+ for(let d = 0; d < q.length; d++)dot += q[d] * qv[d];
285
+ const score = dot * row.scale;
286
+ const existing = best.get(row.path);
287
+ if (!existing || score > existing.score) best.set(row.path, {
288
+ score,
289
+ lines: `L${row.start_line}-${row.end_line}`
290
+ });
291
+ }
292
+ return [
293
+ ...best.entries()
294
+ ].sort((a, b)=>b[1].score - a[1].score).slice(0, fetch1).map(([path, b])=>({
295
+ path,
296
+ lines: b.lines,
297
+ similarity: Math.round(b.score * 1000) / 1000
298
+ }));
299
+ }