sensemaking 0.23.0 → 0.24.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 (193) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/index.d.cts +1 -1
  3. package/dist/cjs/cli/index.d.ts +1 -1
  4. package/dist/cjs/cli/index.js +1 -1
  5. package/dist/cjs/cli/index.js.map +1 -1
  6. package/dist/cjs/cli/named.js +6 -2
  7. package/dist/cjs/cli/named.js.map +1 -1
  8. package/dist/cjs/cli/search.js +6 -2
  9. package/dist/cjs/cli/search.js.map +1 -1
  10. package/dist/cjs/cli/shared.d.cts +2 -0
  11. package/dist/cjs/cli/shared.d.ts +2 -0
  12. package/dist/cjs/cli/shared.js +24 -0
  13. package/dist/cjs/cli/shared.js.map +1 -1
  14. package/dist/cjs/commands/map.js +2 -2
  15. package/dist/cjs/commands/map.js.map +1 -1
  16. package/dist/cjs/commands/search.d.cts +10 -0
  17. package/dist/cjs/commands/search.d.ts +10 -0
  18. package/dist/cjs/commands/search.js +488 -180
  19. package/dist/cjs/commands/search.js.map +1 -1
  20. package/dist/cjs/output/output.js +45 -8
  21. package/dist/cjs/output/output.js.map +1 -1
  22. package/dist/cjs/output/search-error.d.cts +1 -0
  23. package/dist/cjs/output/search-error.d.ts +1 -0
  24. package/dist/cjs/output/search-error.js +28 -4
  25. package/dist/cjs/output/search-error.js.map +1 -1
  26. package/dist/cjs/scan/frontmatter.js +1 -1
  27. package/dist/cjs/scan/frontmatter.js.map +1 -1
  28. package/dist/cjs/scan/index.js +1 -0
  29. package/dist/cjs/scan/index.js.map +1 -1
  30. package/dist/cjs/store/batch.d.cts +4 -0
  31. package/dist/cjs/store/batch.d.ts +4 -0
  32. package/dist/cjs/store/batch.js +80 -0
  33. package/dist/cjs/store/batch.js.map +1 -0
  34. package/dist/cjs/store/duckdb/batch.d.cts +0 -4
  35. package/dist/cjs/store/duckdb/batch.d.ts +0 -4
  36. package/dist/cjs/store/duckdb/batch.js +5 -27
  37. package/dist/cjs/store/duckdb/batch.js.map +1 -1
  38. package/dist/cjs/store/duckdb/fieldStats.js +4 -7
  39. package/dist/cjs/store/duckdb/fieldStats.js.map +1 -1
  40. package/dist/cjs/store/duckdb/lexical.d.cts +2 -2
  41. package/dist/cjs/store/duckdb/lexical.d.ts +2 -2
  42. package/dist/cjs/store/duckdb/lexical.js +215 -64
  43. package/dist/cjs/store/duckdb/lexical.js.map +1 -1
  44. package/dist/cjs/store/duckdb/reconcile.js +7 -2
  45. package/dist/cjs/store/duckdb/reconcile.js.map +1 -1
  46. package/dist/cjs/store/duckdb/store.js +1 -3
  47. package/dist/cjs/store/duckdb/store.js.map +1 -1
  48. package/dist/cjs/store/duckdb/vectors.js +13 -8
  49. package/dist/cjs/store/duckdb/vectors.js.map +1 -1
  50. package/dist/cjs/store/native.js +5 -4
  51. package/dist/cjs/store/native.js.map +1 -1
  52. package/dist/cjs/store/open.js +7 -0
  53. package/dist/cjs/store/open.js.map +1 -1
  54. package/dist/cjs/store/shared.js.map +1 -1
  55. package/dist/cjs/store/sqlite/lexical.js +1 -4
  56. package/dist/cjs/store/sqlite/lexical.js.map +1 -1
  57. package/dist/cjs/store/sqlite/store.js +1 -4
  58. package/dist/cjs/store/sqlite/store.js.map +1 -1
  59. package/dist/cjs/store/sqlite/vectors.js +78 -24
  60. package/dist/cjs/store/sqlite/vectors.js.map +1 -1
  61. package/dist/cjs/store/turso/connection.d.cts +3 -0
  62. package/dist/cjs/store/turso/connection.d.ts +3 -0
  63. package/dist/cjs/store/turso/connection.js +302 -31
  64. package/dist/cjs/store/turso/connection.js.map +1 -1
  65. package/dist/cjs/store/turso/lexical-text.d.cts +1 -0
  66. package/dist/cjs/store/turso/lexical-text.d.ts +1 -0
  67. package/dist/cjs/store/turso/lexical-text.js +40 -0
  68. package/dist/cjs/store/turso/lexical-text.js.map +1 -0
  69. package/dist/cjs/store/turso/lexical.js +89 -16
  70. package/dist/cjs/store/turso/lexical.js.map +1 -1
  71. package/dist/cjs/store/turso/native.d.cts +4 -0
  72. package/dist/cjs/store/turso/native.d.ts +4 -0
  73. package/dist/cjs/store/turso/native.js +9 -0
  74. package/dist/cjs/store/turso/native.js.map +1 -1
  75. package/dist/cjs/store/turso/open.d.cts +1 -1
  76. package/dist/cjs/store/turso/open.d.ts +1 -1
  77. package/dist/cjs/store/turso/open.js +83 -10
  78. package/dist/cjs/store/turso/open.js.map +1 -1
  79. package/dist/cjs/store/turso/reconcile.d.cts +1 -1
  80. package/dist/cjs/store/turso/reconcile.d.ts +1 -1
  81. package/dist/cjs/store/turso/reconcile.js +6 -2
  82. package/dist/cjs/store/turso/reconcile.js.map +1 -1
  83. package/dist/cjs/store/turso/sql-functions.d.cts +1 -0
  84. package/dist/cjs/store/turso/sql-functions.d.ts +1 -0
  85. package/dist/cjs/store/turso/sql-functions.js +174 -0
  86. package/dist/cjs/store/turso/sql-functions.js.map +1 -0
  87. package/dist/cjs/store/turso/store.js +105 -4
  88. package/dist/cjs/store/turso/store.js.map +1 -1
  89. package/dist/cjs/store/turso/vectors.js +50 -14
  90. package/dist/cjs/store/turso/vectors.js.map +1 -1
  91. package/dist/cjs/store/types.d.cts +1 -2
  92. package/dist/cjs/store/types.d.ts +1 -2
  93. package/dist/cjs/store/types.js +1 -4
  94. package/dist/cjs/store/types.js.map +1 -1
  95. package/dist/cjs/store/vectors.d.cts +7 -0
  96. package/dist/cjs/store/vectors.d.ts +7 -0
  97. package/dist/cjs/store/vectors.js +8 -0
  98. package/dist/cjs/store/vectors.js.map +1 -1
  99. package/dist/cjs/text/segment.d.cts +13 -0
  100. package/dist/cjs/text/segment.d.ts +13 -0
  101. package/dist/cjs/text/segment.js +109 -4
  102. package/dist/cjs/text/segment.js.map +1 -1
  103. package/dist/esm/cli/index.d.ts +1 -1
  104. package/dist/esm/cli/index.js +1 -1
  105. package/dist/esm/cli/index.js.map +1 -1
  106. package/dist/esm/cli/named.js +6 -2
  107. package/dist/esm/cli/named.js.map +1 -1
  108. package/dist/esm/cli/search.js +5 -1
  109. package/dist/esm/cli/search.js.map +1 -1
  110. package/dist/esm/cli/shared.d.ts +2 -0
  111. package/dist/esm/cli/shared.js +19 -0
  112. package/dist/esm/cli/shared.js.map +1 -1
  113. package/dist/esm/commands/map.js +2 -2
  114. package/dist/esm/commands/map.js.map +1 -1
  115. package/dist/esm/commands/search.d.ts +10 -0
  116. package/dist/esm/commands/search.js +271 -91
  117. package/dist/esm/commands/search.js.map +1 -1
  118. package/dist/esm/output/output.js +27 -4
  119. package/dist/esm/output/output.js.map +1 -1
  120. package/dist/esm/output/search-error.d.ts +1 -0
  121. package/dist/esm/output/search-error.js +17 -1
  122. package/dist/esm/output/search-error.js.map +1 -1
  123. package/dist/esm/scan/frontmatter.js +1 -1
  124. package/dist/esm/scan/frontmatter.js.map +1 -1
  125. package/dist/esm/scan/index.js +1 -0
  126. package/dist/esm/scan/index.js.map +1 -1
  127. package/dist/esm/store/batch.d.ts +4 -0
  128. package/dist/esm/store/batch.js +21 -0
  129. package/dist/esm/store/batch.js.map +1 -0
  130. package/dist/esm/store/duckdb/batch.d.ts +0 -4
  131. package/dist/esm/store/duckdb/batch.js +2 -19
  132. package/dist/esm/store/duckdb/batch.js.map +1 -1
  133. package/dist/esm/store/duckdb/fieldStats.js +4 -7
  134. package/dist/esm/store/duckdb/fieldStats.js.map +1 -1
  135. package/dist/esm/store/duckdb/lexical.d.ts +2 -2
  136. package/dist/esm/store/duckdb/lexical.js +94 -40
  137. package/dist/esm/store/duckdb/lexical.js.map +1 -1
  138. package/dist/esm/store/duckdb/reconcile.js +2 -2
  139. package/dist/esm/store/duckdb/reconcile.js.map +1 -1
  140. package/dist/esm/store/duckdb/store.js +2 -4
  141. package/dist/esm/store/duckdb/store.js.map +1 -1
  142. package/dist/esm/store/duckdb/vectors.js +26 -13
  143. package/dist/esm/store/duckdb/vectors.js.map +1 -1
  144. package/dist/esm/store/native.js +5 -4
  145. package/dist/esm/store/native.js.map +1 -1
  146. package/dist/esm/store/open.js +7 -0
  147. package/dist/esm/store/open.js.map +1 -1
  148. package/dist/esm/store/shared.js +1 -2
  149. package/dist/esm/store/shared.js.map +1 -1
  150. package/dist/esm/store/sqlite/lexical.js +2 -5
  151. package/dist/esm/store/sqlite/lexical.js.map +1 -1
  152. package/dist/esm/store/sqlite/store.js +1 -4
  153. package/dist/esm/store/sqlite/store.js.map +1 -1
  154. package/dist/esm/store/sqlite/vectors.js +48 -19
  155. package/dist/esm/store/sqlite/vectors.js.map +1 -1
  156. package/dist/esm/store/turso/connection.d.ts +3 -0
  157. package/dist/esm/store/turso/connection.js +61 -5
  158. package/dist/esm/store/turso/connection.js.map +1 -1
  159. package/dist/esm/store/turso/lexical-text.d.ts +1 -0
  160. package/dist/esm/store/turso/lexical-text.js +12 -0
  161. package/dist/esm/store/turso/lexical-text.js.map +1 -0
  162. package/dist/esm/store/turso/lexical.js +56 -21
  163. package/dist/esm/store/turso/lexical.js.map +1 -1
  164. package/dist/esm/store/turso/native.d.ts +4 -0
  165. package/dist/esm/store/turso/native.js +8 -0
  166. package/dist/esm/store/turso/native.js.map +1 -1
  167. package/dist/esm/store/turso/open.d.ts +1 -1
  168. package/dist/esm/store/turso/open.js +7 -7
  169. package/dist/esm/store/turso/open.js.map +1 -1
  170. package/dist/esm/store/turso/reconcile.d.ts +1 -1
  171. package/dist/esm/store/turso/reconcile.js +6 -2
  172. package/dist/esm/store/turso/reconcile.js.map +1 -1
  173. package/dist/esm/store/turso/sql-functions.d.ts +1 -0
  174. package/dist/esm/store/turso/sql-functions.js +179 -0
  175. package/dist/esm/store/turso/sql-functions.js.map +1 -0
  176. package/dist/esm/store/turso/store.js +32 -7
  177. package/dist/esm/store/turso/store.js.map +1 -1
  178. package/dist/esm/store/turso/vectors.js +19 -10
  179. package/dist/esm/store/turso/vectors.js.map +1 -1
  180. package/dist/esm/store/types.d.ts +1 -2
  181. package/dist/esm/store/types.js +6 -8
  182. package/dist/esm/store/types.js.map +1 -1
  183. package/dist/esm/store/vectors.d.ts +7 -0
  184. package/dist/esm/store/vectors.js +8 -2
  185. package/dist/esm/store/vectors.js.map +1 -1
  186. package/dist/esm/text/segment.d.ts +13 -0
  187. package/dist/esm/text/segment.js +70 -0
  188. package/dist/esm/text/segment.js.map +1 -1
  189. package/package.json +4 -3
  190. package/schema.json +1 -1
  191. package/skills/sense/EXAMPLES.md +3 -3
  192. package/skills/sense/SKILL.md +6 -6
  193. package/skills/sense-setup/SKILL.md +1 -1
@@ -44,6 +44,12 @@ export const SEARCH_FLAGS = {
44
44
  ...SCOPE,
45
45
  k: {
46
46
  type: 'string'
47
+ },
48
+ 'snippet-char-limit': {
49
+ type: 'string'
50
+ },
51
+ 'snippet-count-limit': {
52
+ type: 'string'
47
53
  }
48
54
  };
49
55
  // The SCOPE fragment's parsed values as the overrides every scoped command passes down.
@@ -120,6 +126,19 @@ export function parseK(k, usageError) {
120
126
  if (!Number.isInteger(parsed) || parsed <= 0) usageError(`--k expects a positive integer, got "${k}"`);
121
127
  return parsed;
122
128
  }
129
+ // Same positive-integer rule as --k, beside it since both flags come from SEARCH_FLAGS.
130
+ export function parseSnippetCharLimit(value, usageError) {
131
+ if (value === undefined) return undefined;
132
+ const parsed = Number(value);
133
+ if (!Number.isInteger(parsed) || parsed <= 0) usageError(`--snippet-char-limit expects a positive integer, got "${value}"`);
134
+ return parsed;
135
+ }
136
+ export function parseSnippetCountLimit(value, usageError) {
137
+ if (value === undefined) return undefined;
138
+ const parsed = Number(value);
139
+ if (!Number.isInteger(parsed) || parsed <= 0) usageError(`--snippet-count-limit expects a positive integer, got "${value}"`);
140
+ return parsed;
141
+ }
123
142
  // Shared open-query-close envelope for commands that touch the tree.
124
143
  export function printWarnings(warnings) {
125
144
  for (const w of warnings)console.warn(w);
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/cli/shared.ts"],"sourcesContent":["import type { ParseArgsOptionsConfig } from 'node:util';\nimport { parseArgs } from 'node:util';\nimport type { ResolvedConfig, SearchOverrides } from '../config/index.ts';\nimport { resolvePreset } from '../config/index.ts';\nimport { columnHint } from '../output/column-hint.ts';\nimport type { Row, RowFormat } from '../output/output.ts';\nimport { printRowStream } from '../output/output.ts';\nimport { searchError } from '../output/search-error.ts';\nimport type { OpenResult } from '../store/index.ts';\nimport { openStore } from '../store/index.ts';\nimport type { Store } from '../store/types.ts';\nimport type { Ctx } from './types.ts';\n\n// Spreadable option fragments -- one flag name keeps one meaning across every command's table.\nexport const FORMAT: ParseArgsOptionsConfig = { format: { type: 'string', default: 'table' } };\nexport const CONFIG: ParseArgsOptionsConfig = { config: { type: 'string' } };\n// The scope vocabulary every scoped command shares: named preset, ad hoc include/exclude\n// globs, a where SQL condition. search adds k on top.\nexport const SCOPE: ParseArgsOptionsConfig = {\n where: { type: 'string' },\n preset: { type: 'string' },\n include: { type: 'string', multiple: true },\n exclude: { type: 'string', multiple: true },\n // Widening, which no other flag can express: --include and --exclude each override their\n // own side of the preset, so neither can drop an exclusion the preset declares.\n 'no-exclude': { type: 'boolean', default: false },\n};\nexport const SEARCH_FLAGS: ParseArgsOptionsConfig = { ...SCOPE, k: { type: 'string' } };\n\ntype Values = Record<string, string | boolean | string[] | undefined>;\n\n// The SCOPE fragment's parsed values as the overrides every scoped command passes down.\n// One place to read them: a new scope field is added to the fragment and here, not per-command.\nexport function scopeOf(values: Values): SearchOverrides {\n return {\n preset: values.preset as string | undefined,\n include: values.include as string[] | undefined,\n exclude: values.exclude as string[] | undefined,\n where: values.where as string | undefined,\n noExclude: values['no-exclude'] === true,\n };\n}\n\n// parseArgs is strict about flag names; this applies the same strictness to the value.\n// An unrecognised --format is a usage error, not a silent fallback to `table`.\nfunction pickFormat<T extends string>(values: Values, allowed: readonly T[]): T {\n const format = String(values.format);\n if ((allowed as readonly string[]).includes(format)) return format as T;\n console.error(`unknown --format \"${format}\"; expected ${allowed.join(', ')}`);\n process.exit(2);\n}\n\nexport function formatOf(values: Values): 'table' | 'json' {\n return pickFormat(values, ['table', 'json'] as const);\n}\n\n// csv is a row set rendered as rows; map, peek, status, and path render structures instead,\n// and take formatOf.\nexport function rowFormatOf(values: Values): RowFormat {\n return pickFormat(values, ['table', 'json', 'csv'] as const);\n}\n\n// Per-command parseArgs: strict (a foreign flag exits 2), and every table gets --help for free.\nexport function parse(argv: string[], usage: string, options: ParseArgsOptionsConfig): { values: Values; positionals: string[] } {\n let values: Values;\n let positionals: string[];\n try {\n ({ values, positionals } = parseArgs({\n args: argv,\n options: { ...options, help: { type: 'boolean', default: false, short: 'h' } },\n strict: true,\n allowPositionals: true,\n }));\n } catch (err) {\n console.error((err as Error).message);\n console.error(usage);\n process.exit(2);\n }\n if (values.help) {\n console.log(usage);\n process.exit(0);\n }\n return { values, positionals };\n}\n\n// --k must be a positive integer: SQLite reads a bound LIMIT of -1 as \"unlimited\" and 0 as \"nothing\", and parseInt would silently truncate \"5.9\".\n// All three are caller mistakes worth a usage error, matching the config-level SavedSearch validation.\nexport function parseK(k: string | undefined, usageError: (message: string) => never): number | undefined {\n if (k === undefined) return undefined;\n const parsed = Number(k);\n if (!Number.isInteger(parsed) || parsed <= 0) usageError(`--k expects a positive integer, got \"${k}\"`);\n return parsed;\n}\n\n// Shared open-query-close envelope for commands that touch the tree.\n\nexport function printWarnings(warnings: string[]): void {\n for (const w of warnings) console.warn(w);\n}\n\nexport async function withDb(ctx: Ctx, configPath: string | undefined, fn: (store: OpenResult['store'], cfg: ResolvedConfig) => void | Promise<void>): Promise<void> {\n const cfg = ctx.resolveConfig(configPath);\n const { store, warnings } = await openStore(cfg);\n printWarnings(warnings);\n try {\n await fn(store, cfg);\n } finally {\n await store.close();\n }\n}\n\n// `--preset` on SQL binds a temp `scope` table rather than transparently filtering: temp views shadowing the base tables can't cover `content`, since FTS5 MATCH treats the table name as a hidden column.\n// A flag that silently scoped three tables of four would look scoped and not be.\nasync function bindScope(store: Store, cfg: ResolvedConfig, preset: string): Promise<void> {\n const { name } = resolvePreset(cfg, preset); // unknown names throw, listing what is declared\n await store.exec('DROP TABLE IF EXISTS temp.scope');\n await store.exec('CREATE TEMP TABLE scope (\"path\" TEXT PRIMARY KEY)');\n const insert = await store.prepare('INSERT INTO temp.scope (\"path\") SELECT \"path\" FROM preset_files WHERE preset = ?');\n await insert.run(name);\n}\n\n// An unbound `?` silently binds NULL, so mismatched param counts fail loudly instead.\nexport async function runSql(cfg: ResolvedConfig, sql: string, params: string[], format: RowFormat, label: string, preset?: string): Promise<void> {\n const placeholderCount = (sql.match(/\\?/g) ?? []).length;\n if (params.length !== placeholderCount) {\n console.error(`${label} expects ${placeholderCount} parameter(s), got ${params.length}`);\n process.exit(2);\n }\n // Naming a preset and never joining it would return the whole index while reading as scoped,\n // which is the one hazard of binding rather than applying. Refuse instead.\n if (preset !== undefined && !/\\bscope\\b/i.test(sql)) {\n console.error(`--preset binds a temporary \"scope\" table of the preset's paths, and ${label} never joins it, so the preset would have no effect`);\n console.error(`add: JOIN scope ON scope.\"path\" = <table>.\"path\"`);\n process.exit(2);\n }\n const { store, warnings } = await openStore(cfg);\n printWarnings(warnings);\n if (preset !== undefined) await bindScope(store, cfg, preset);\n // Streamed, not collected: `sql`'s result size is the caller's business, not ours. columns() reads the statement's own metadata, so a 0-row csv gets a header too.\n // A mid-stream SQLite error (e.g. SQLITE_BUSY outlasting busy_timeout) may leave partial output; the nonzero exit code is the failure signal, not a completeness guarantee.\n try {\n const statement = await store.raw.prepare(sql);\n const columns = statement.columns().map((c) => c.name);\n await printRowStream(statement.iterate(...params) as AsyncIterable<Row>, format, columns);\n } catch (err) {\n const hinted = columnHint(await store.docs.columns(), err as Error); // columns read while the store is still open\n await store.close();\n // A saved query or ad-hoc SQL can carry `content MATCH ?` too, so the same FTS5 punctuation trap applies to its bound parameters.\n // SQL without MATCH gets its error verbatim; search advice on a plain typo would mislead.\n if (/\\bMATCH\\b/i.test(sql)) throw searchError(hinted, params.join(' '));\n throw hinted;\n }\n await store.close();\n}\n"],"names":["parseArgs","resolvePreset","columnHint","printRowStream","searchError","openStore","FORMAT","format","type","default","CONFIG","config","SCOPE","where","preset","include","multiple","exclude","SEARCH_FLAGS","k","scopeOf","values","noExclude","pickFormat","allowed","String","includes","console","error","join","process","exit","formatOf","rowFormatOf","parse","argv","usage","options","positionals","args","help","short","strict","allowPositionals","err","message","log","parseK","usageError","undefined","parsed","Number","isInteger","printWarnings","warnings","w","warn","withDb","ctx","configPath","fn","cfg","resolveConfig","store","close","bindScope","name","exec","insert","prepare","run","runSql","sql","params","label","placeholderCount","match","length","test","statement","raw","columns","map","c","iterate","hinted","docs"],"mappings":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/cli/shared.ts"],"sourcesContent":["import type { ParseArgsOptionsConfig } from 'node:util';\nimport { parseArgs } from 'node:util';\nimport type { ResolvedConfig, SearchOverrides } from '../config/index.ts';\nimport { resolvePreset } from '../config/index.ts';\nimport { columnHint } from '../output/column-hint.ts';\nimport type { Row, RowFormat } from '../output/output.ts';\nimport { printRowStream } from '../output/output.ts';\nimport { searchError } from '../output/search-error.ts';\nimport type { OpenResult } from '../store/index.ts';\nimport { openStore } from '../store/index.ts';\nimport type { Store } from '../store/types.ts';\nimport type { Ctx } from './types.ts';\n\n// Spreadable option fragments -- one flag name keeps one meaning across every command's table.\nexport const FORMAT: ParseArgsOptionsConfig = { format: { type: 'string', default: 'table' } };\nexport const CONFIG: ParseArgsOptionsConfig = { config: { type: 'string' } };\n// The scope vocabulary every scoped command shares: named preset, ad hoc include/exclude\n// globs, a where SQL condition. search adds k on top.\nexport const SCOPE: ParseArgsOptionsConfig = {\n where: { type: 'string' },\n preset: { type: 'string' },\n include: { type: 'string', multiple: true },\n exclude: { type: 'string', multiple: true },\n // Widening, which no other flag can express: --include and --exclude each override their\n // own side of the preset, so neither can drop an exclusion the preset declares.\n 'no-exclude': { type: 'boolean', default: false },\n};\nexport const SEARCH_FLAGS: ParseArgsOptionsConfig = { ...SCOPE, k: { type: 'string' }, 'snippet-char-limit': { type: 'string' }, 'snippet-count-limit': { type: 'string' } };\n\ntype Values = Record<string, string | boolean | string[] | undefined>;\n\n// The SCOPE fragment's parsed values as the overrides every scoped command passes down.\n// One place to read them: a new scope field is added to the fragment and here, not per-command.\nexport function scopeOf(values: Values): SearchOverrides {\n return {\n preset: values.preset as string | undefined,\n include: values.include as string[] | undefined,\n exclude: values.exclude as string[] | undefined,\n where: values.where as string | undefined,\n noExclude: values['no-exclude'] === true,\n };\n}\n\n// parseArgs is strict about flag names; this applies the same strictness to the value.\n// An unrecognised --format is a usage error, not a silent fallback to `table`.\nfunction pickFormat<T extends string>(values: Values, allowed: readonly T[]): T {\n const format = String(values.format);\n if ((allowed as readonly string[]).includes(format)) return format as T;\n console.error(`unknown --format \"${format}\"; expected ${allowed.join(', ')}`);\n process.exit(2);\n}\n\nexport function formatOf(values: Values): 'table' | 'json' {\n return pickFormat(values, ['table', 'json'] as const);\n}\n\n// csv is a row set rendered as rows; map, peek, status, and path render structures instead,\n// and take formatOf.\nexport function rowFormatOf(values: Values): RowFormat {\n return pickFormat(values, ['table', 'json', 'csv'] as const);\n}\n\n// Per-command parseArgs: strict (a foreign flag exits 2), and every table gets --help for free.\nexport function parse(argv: string[], usage: string, options: ParseArgsOptionsConfig): { values: Values; positionals: string[] } {\n let values: Values;\n let positionals: string[];\n try {\n ({ values, positionals } = parseArgs({\n args: argv,\n options: { ...options, help: { type: 'boolean', default: false, short: 'h' } },\n strict: true,\n allowPositionals: true,\n }));\n } catch (err) {\n console.error((err as Error).message);\n console.error(usage);\n process.exit(2);\n }\n if (values.help) {\n console.log(usage);\n process.exit(0);\n }\n return { values, positionals };\n}\n\n// --k must be a positive integer: SQLite reads a bound LIMIT of -1 as \"unlimited\" and 0 as \"nothing\", and parseInt would silently truncate \"5.9\".\n// All three are caller mistakes worth a usage error, matching the config-level SavedSearch validation.\nexport function parseK(k: string | undefined, usageError: (message: string) => never): number | undefined {\n if (k === undefined) return undefined;\n const parsed = Number(k);\n if (!Number.isInteger(parsed) || parsed <= 0) usageError(`--k expects a positive integer, got \"${k}\"`);\n return parsed;\n}\n\n// Same positive-integer rule as --k, beside it since both flags come from SEARCH_FLAGS.\nexport function parseSnippetCharLimit(value: string | undefined, usageError: (message: string) => never): number | undefined {\n if (value === undefined) return undefined;\n const parsed = Number(value);\n if (!Number.isInteger(parsed) || parsed <= 0) usageError(`--snippet-char-limit expects a positive integer, got \"${value}\"`);\n return parsed;\n}\n\nexport function parseSnippetCountLimit(value: string | undefined, usageError: (message: string) => never): number | undefined {\n if (value === undefined) return undefined;\n const parsed = Number(value);\n if (!Number.isInteger(parsed) || parsed <= 0) usageError(`--snippet-count-limit expects a positive integer, got \"${value}\"`);\n return parsed;\n}\n\n// Shared open-query-close envelope for commands that touch the tree.\n\nexport function printWarnings(warnings: string[]): void {\n for (const w of warnings) console.warn(w);\n}\n\nexport async function withDb(ctx: Ctx, configPath: string | undefined, fn: (store: OpenResult['store'], cfg: ResolvedConfig) => void | Promise<void>): Promise<void> {\n const cfg = ctx.resolveConfig(configPath);\n const { store, warnings } = await openStore(cfg);\n printWarnings(warnings);\n try {\n await fn(store, cfg);\n } finally {\n await store.close();\n }\n}\n\n// `--preset` on SQL binds a temp `scope` table rather than transparently filtering: temp views shadowing the base tables can't cover `content`, since FTS5 MATCH treats the table name as a hidden column.\n// A flag that silently scoped three tables of four would look scoped and not be.\nasync function bindScope(store: Store, cfg: ResolvedConfig, preset: string): Promise<void> {\n const { name } = resolvePreset(cfg, preset); // unknown names throw, listing what is declared\n await store.exec('DROP TABLE IF EXISTS temp.scope');\n await store.exec('CREATE TEMP TABLE scope (\"path\" TEXT PRIMARY KEY)');\n const insert = await store.prepare('INSERT INTO temp.scope (\"path\") SELECT \"path\" FROM preset_files WHERE preset = ?');\n await insert.run(name);\n}\n\n// An unbound `?` silently binds NULL, so mismatched param counts fail loudly instead.\nexport async function runSql(cfg: ResolvedConfig, sql: string, params: string[], format: RowFormat, label: string, preset?: string): Promise<void> {\n const placeholderCount = (sql.match(/\\?/g) ?? []).length;\n if (params.length !== placeholderCount) {\n console.error(`${label} expects ${placeholderCount} parameter(s), got ${params.length}`);\n process.exit(2);\n }\n // Naming a preset and never joining it would return the whole index while reading as scoped,\n // which is the one hazard of binding rather than applying. Refuse instead.\n if (preset !== undefined && !/\\bscope\\b/i.test(sql)) {\n console.error(`--preset binds a temporary \"scope\" table of the preset's paths, and ${label} never joins it, so the preset would have no effect`);\n console.error(`add: JOIN scope ON scope.\"path\" = <table>.\"path\"`);\n process.exit(2);\n }\n const { store, warnings } = await openStore(cfg);\n printWarnings(warnings);\n if (preset !== undefined) await bindScope(store, cfg, preset);\n // Streamed, not collected: `sql`'s result size is the caller's business, not ours. columns() reads the statement's own metadata, so a 0-row csv gets a header too.\n // A mid-stream SQLite error (e.g. SQLITE_BUSY outlasting busy_timeout) may leave partial output; the nonzero exit code is the failure signal, not a completeness guarantee.\n try {\n const statement = await store.raw.prepare(sql);\n const columns = statement.columns().map((c) => c.name);\n await printRowStream(statement.iterate(...params) as AsyncIterable<Row>, format, columns);\n } catch (err) {\n const hinted = columnHint(await store.docs.columns(), err as Error); // columns read while the store is still open\n await store.close();\n // A saved query or ad-hoc SQL can carry `content MATCH ?` too, so the same FTS5 punctuation trap applies to its bound parameters.\n // SQL without MATCH gets its error verbatim; search advice on a plain typo would mislead.\n if (/\\bMATCH\\b/i.test(sql)) throw searchError(hinted, params.join(' '));\n throw hinted;\n }\n await store.close();\n}\n"],"names":["parseArgs","resolvePreset","columnHint","printRowStream","searchError","openStore","FORMAT","format","type","default","CONFIG","config","SCOPE","where","preset","include","multiple","exclude","SEARCH_FLAGS","k","scopeOf","values","noExclude","pickFormat","allowed","String","includes","console","error","join","process","exit","formatOf","rowFormatOf","parse","argv","usage","options","positionals","args","help","short","strict","allowPositionals","err","message","log","parseK","usageError","undefined","parsed","Number","isInteger","parseSnippetCharLimit","value","parseSnippetCountLimit","printWarnings","warnings","w","warn","withDb","ctx","configPath","fn","cfg","resolveConfig","store","close","bindScope","name","exec","insert","prepare","run","runSql","sql","params","label","placeholderCount","match","length","test","statement","raw","columns","map","c","iterate","hinted","docs"],"mappings":"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"}
@@ -29,8 +29,8 @@ export async function mapTree(store, cfg, overrides = {}) {
29
29
  for (const group of chunk(columns, MAP_COLUMN_CHUNK))allFields.push(...await store.docs.fieldStats(group, scopeWhere));
30
30
  allFields.sort((a, b)=>b.coverage - a.coverage);
31
31
  const fields = allFields.slice(0, 20);
32
- const hubs = featureEnabled(cfg, 'rank') ? await (await store.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 ${scopeAnd} ORDER BY f."_rank" DESC LIMIT 8`)).all() : [];
33
- const recent = (await (await store.prepare(`SELECT "path", "_mtime" AS mtime FROM frontmatter ${scopeWhere} ORDER BY "_mtime" DESC LIMIT 5`)).all()).map((r)=>({
32
+ const hubs = featureEnabled(cfg, 'rank') ? await (await store.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 ${scopeAnd} ORDER BY f."_rank" DESC, f."path" ASC LIMIT 8`)).all() : [];
33
+ const recent = (await (await store.prepare(`SELECT "path", "_mtime" AS mtime FROM frontmatter ${scopeWhere} ORDER BY "_mtime" DESC, "path" ASC LIMIT 5`)).all()).map((r)=>({
34
34
  path: r.path,
35
35
  modified: utcSecond(r.mtime)
36
36
  }));
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/commands/map.ts"],"sourcesContent":["import type { FeatureName, ResolvedConfig, SearchOverrides } from '../config/index.ts';\nimport { featureEnabled, featureStates } from '../config/index.ts';\nimport type { Row } from '../output/output.ts';\nimport type { FieldStat, Store } from '../store/types.ts';\nimport { INTERNAL_COLUMNS, scopedPaths, setupMapScope } from './scope.ts';\nimport type { PresetCoverage } from './status.ts';\nimport { presetCoverage } from './status.ts';\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 presets: PresetCoverage[];\n hubs: Row[];\n recent: Row[];\n recentCaveat: string | null;\n}\n\n// A result row is capped at SQLITE_MAX_COLUMN (2000, default); two aggregate expressions per\n// field keeps a chunk's row width safely under that regardless of how many fields the tree has.\nconst MAP_COLUMN_CHUNK = 300;\n\n// \"YYYY-MM-DD HH:MM:SS\" UTC, the exact shape sqlite's datetime(ms / 1000, 'unixepoch') prints;\n// formatted in JS so the query stays dialect-free.\nfunction utcSecond(ms: number): string {\n return new Date(ms).toISOString().slice(0, 19).replace('T', ' ');\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n// What is this scope: fixed-size output regardless of tree size. Coverage and features stay\n// global -- they describe the tree, not the current question.\nexport async function mapTree(store: Store, cfg: ResolvedConfig, overrides: SearchOverrides = {}): Promise<TreeMap> {\n return store.transaction(async () => {\n await setupMapScope(store, await scopedPaths(store, cfg, overrides));\n const scopeWhere = 'WHERE \"path\" IN (SELECT \"path\" FROM _map_scope)';\n const scopeAnd = 'AND f.\"path\" IN (SELECT \"path\" FROM _map_scope)';\n\n const docs = (await (await store.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(\"_size\"), 0) AS bytes FROM frontmatter ${scopeWhere}`)).get()) as { count: number; bytes: number };\n\n const columns = (await store.docs.columns()).filter((name) => !INTERNAL_COLUMNS.has(name));\n // Observed types, not declared: a field can be text in most notes and numeric in a few, so\n // every distinct type observed across the scope is reported, not just the first.\n const allFields: FieldStat[] = [];\n for (const group of chunk(columns, MAP_COLUMN_CHUNK)) allFields.push(...(await store.docs.fieldStats(group, scopeWhere)));\n allFields.sort((a, b) => b.coverage - a.coverage);\n const fields = allFields.slice(0, 20) as unknown as Row[];\n\n const hubs = featureEnabled(cfg, 'rank') ? ((await (await store.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 ${scopeAnd} ORDER BY f.\"_rank\" DESC LIMIT 8`)).all()) as Row[]) : [];\n\n const recent = ((await (await store.prepare(`SELECT \"path\", \"_mtime\" AS mtime FROM frontmatter ${scopeWhere} ORDER BY \"_mtime\" DESC LIMIT 5`)).all()) as Array<{ path: string; mtime: number }>).map((r) => ({ path: r.path, modified: utcSecond(r.mtime) }));\n\n // A fresh clone/copy stamps files with checkout time, not edit history; second granularity\n // matches the `recent` table above and is coarse enough to catch that without an exact-ms match.\n const topSecond = (await (await store.prepare(`SELECT COUNT(*) AS n FROM frontmatter ${scopeWhere} GROUP BY CAST(\"_mtime\" / 1000 AS INTEGER) ORDER BY n DESC LIMIT 1`)).get()) as { n: number } | undefined;\n const recentCaveat = topSecond && docs.count > 1 && topSecond.n > docs.count / 2 ? `${topSecond.n} of ${docs.count} files share one modified second, so recency likely reflects a checkout or copy, not edit history` : null;\n\n return { docs, fields, fieldsTotal: allFields.length, features: featureStates(cfg), presets: await presetCoverage(store, cfg), hubs, recent, recentCaveat };\n });\n}\n"],"names":["featureEnabled","featureStates","INTERNAL_COLUMNS","scopedPaths","setupMapScope","presetCoverage","MAP_COLUMN_CHUNK","utcSecond","ms","Date","toISOString","slice","replace","chunk","items","size","out","i","length","push","mapTree","store","cfg","overrides","transaction","scopeWhere","scopeAnd","docs","prepare","get","columns","filter","name","has","allFields","group","fieldStats","sort","a","b","coverage","fields","hubs","all","recent","map","r","path","modified","mtime","topSecond","recentCaveat","count","n","fieldsTotal","features","presets"],"mappings":"AACA,SAASA,cAAc,EAAEC,aAAa,QAAQ,qBAAqB;AAGnE,SAASC,gBAAgB,EAAEC,WAAW,EAAEC,aAAa,QAAQ,aAAa;AAE1E,SAASC,cAAc,QAAQ,cAAc;AAa7C,6FAA6F;AAC7F,gGAAgG;AAChG,MAAMC,mBAAmB;AAEzB,+FAA+F;AAC/F,mDAAmD;AACnD,SAASC,UAAUC,EAAU;IAC3B,OAAO,IAAIC,KAAKD,IAAIE,WAAW,GAAGC,KAAK,CAAC,GAAG,IAAIC,OAAO,CAAC,KAAK;AAC9D;AAEA,SAASC,MAASC,KAAU,EAAEC,IAAY;IACxC,MAAMC,MAAa,EAAE;IACrB,IAAK,IAAIC,IAAI,GAAGA,IAAIH,MAAMI,MAAM,EAAED,KAAKF,KAAMC,IAAIG,IAAI,CAACL,MAAMH,KAAK,CAACM,GAAGA,IAAIF;IACzE,OAAOC;AACT;AAEA,4FAA4F;AAC5F,8DAA8D;AAC9D,OAAO,eAAeI,QAAQC,KAAY,EAAEC,GAAmB,EAAEC,YAA6B,CAAC,CAAC;IAC9F,OAAOF,MAAMG,WAAW,CAAC;QACvB,MAAMpB,cAAciB,OAAO,MAAMlB,YAAYkB,OAAOC,KAAKC;QACzD,MAAME,aAAa;QACnB,MAAMC,WAAW;QAEjB,MAAMC,OAAQ,MAAM,AAAC,CAAA,MAAMN,MAAMO,OAAO,CAAC,CAAC,8EAA8E,EAAEH,YAAY,CAAA,EAAGI,GAAG;QAE5I,MAAMC,UAAU,AAAC,CAAA,MAAMT,MAAMM,IAAI,CAACG,OAAO,EAAC,EAAGC,MAAM,CAAC,CAACC,OAAS,CAAC9B,iBAAiB+B,GAAG,CAACD;QACpF,2FAA2F;QAC3F,iFAAiF;QACjF,MAAME,YAAyB,EAAE;QACjC,KAAK,MAAMC,SAAStB,MAAMiB,SAASxB,kBAAmB4B,UAAUf,IAAI,IAAK,MAAME,MAAMM,IAAI,CAACS,UAAU,CAACD,OAAOV;QAC5GS,UAAUG,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAEC,QAAQ,GAAGF,EAAEE,QAAQ;QAChD,MAAMC,SAASP,UAAUvB,KAAK,CAAC,GAAG;QAElC,MAAM+B,OAAO1C,eAAesB,KAAK,UAAY,MAAM,AAAC,CAAA,MAAMD,MAAMO,OAAO,CAAC,CAAC,iKAAiK,EAAEF,SAAS,gCAAgC,CAAC,CAAA,EAAGiB,GAAG,KAAgB,EAAE;QAE9S,MAAMC,SAAS,AAAE,CAAA,MAAM,AAAC,CAAA,MAAMvB,MAAMO,OAAO,CAAC,CAAC,kDAAkD,EAAEH,WAAW,+BAA+B,CAAC,CAAA,EAAGkB,GAAG,EAAC,EAA8CE,GAAG,CAAC,CAACC,IAAO,CAAA;gBAAEC,MAAMD,EAAEC,IAAI;gBAAEC,UAAUzC,UAAUuC,EAAEG,KAAK;YAAE,CAAA;QAE1P,2FAA2F;QAC3F,iGAAiG;QACjG,MAAMC,YAAa,MAAM,AAAC,CAAA,MAAM7B,MAAMO,OAAO,CAAC,CAAC,sCAAsC,EAAEH,WAAW,kEAAkE,CAAC,CAAA,EAAGI,GAAG;QAC3K,MAAMsB,eAAeD,aAAavB,KAAKyB,KAAK,GAAG,KAAKF,UAAUG,CAAC,GAAG1B,KAAKyB,KAAK,GAAG,IAAI,GAAGF,UAAUG,CAAC,CAAC,IAAI,EAAE1B,KAAKyB,KAAK,CAAC,iGAAiG,CAAC,GAAG;QAExN,OAAO;YAAEzB;YAAMc;YAAQa,aAAapB,UAAUhB,MAAM;YAAEqC,UAAUtD,cAAcqB;YAAMkC,SAAS,MAAMnD,eAAegB,OAAOC;YAAMoB;YAAME;YAAQO;QAAa;IAC5J;AACF"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/commands/map.ts"],"sourcesContent":["import type { FeatureName, ResolvedConfig, SearchOverrides } from '../config/index.ts';\nimport { featureEnabled, featureStates } from '../config/index.ts';\nimport type { Row } from '../output/output.ts';\nimport type { FieldStat, Store } from '../store/types.ts';\nimport { INTERNAL_COLUMNS, scopedPaths, setupMapScope } from './scope.ts';\nimport type { PresetCoverage } from './status.ts';\nimport { presetCoverage } from './status.ts';\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 presets: PresetCoverage[];\n hubs: Row[];\n recent: Row[];\n recentCaveat: string | null;\n}\n\n// A result row is capped at SQLITE_MAX_COLUMN (2000, default); two aggregate expressions per\n// field keeps a chunk's row width safely under that regardless of how many fields the tree has.\nconst MAP_COLUMN_CHUNK = 300;\n\n// \"YYYY-MM-DD HH:MM:SS\" UTC, the exact shape sqlite's datetime(ms / 1000, 'unixepoch') prints;\n// formatted in JS so the query stays dialect-free.\nfunction utcSecond(ms: number): string {\n return new Date(ms).toISOString().slice(0, 19).replace('T', ' ');\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n// What is this scope: fixed-size output regardless of tree size. Coverage and features stay\n// global -- they describe the tree, not the current question.\nexport async function mapTree(store: Store, cfg: ResolvedConfig, overrides: SearchOverrides = {}): Promise<TreeMap> {\n return store.transaction(async () => {\n await setupMapScope(store, await scopedPaths(store, cfg, overrides));\n const scopeWhere = 'WHERE \"path\" IN (SELECT \"path\" FROM _map_scope)';\n const scopeAnd = 'AND f.\"path\" IN (SELECT \"path\" FROM _map_scope)';\n\n const docs = (await (await store.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(\"_size\"), 0) AS bytes FROM frontmatter ${scopeWhere}`)).get()) as { count: number; bytes: number };\n\n const columns = (await store.docs.columns()).filter((name) => !INTERNAL_COLUMNS.has(name));\n // Observed types, not declared: a field can be text in most notes and numeric in a few, so\n // every distinct type observed across the scope is reported, not just the first.\n const allFields: FieldStat[] = [];\n for (const group of chunk(columns, MAP_COLUMN_CHUNK)) allFields.push(...(await store.docs.fieldStats(group, scopeWhere)));\n allFields.sort((a, b) => b.coverage - a.coverage);\n const fields = allFields.slice(0, 20) as unknown as Row[];\n\n const hubs = featureEnabled(cfg, 'rank')\n ? ((await (await store.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 ${scopeAnd} ORDER BY f.\"_rank\" DESC, f.\"path\" ASC LIMIT 8`)).all()) as Row[])\n : [];\n\n const recent = ((await (await store.prepare(`SELECT \"path\", \"_mtime\" AS mtime FROM frontmatter ${scopeWhere} ORDER BY \"_mtime\" DESC, \"path\" ASC LIMIT 5`)).all()) as Array<{ path: string; mtime: number }>).map((r) => ({ path: r.path, modified: utcSecond(r.mtime) }));\n\n // A fresh clone/copy stamps files with checkout time, not edit history; second granularity\n // matches the `recent` table above and is coarse enough to catch that without an exact-ms match.\n const topSecond = (await (await store.prepare(`SELECT COUNT(*) AS n FROM frontmatter ${scopeWhere} GROUP BY CAST(\"_mtime\" / 1000 AS INTEGER) ORDER BY n DESC LIMIT 1`)).get()) as { n: number } | undefined;\n const recentCaveat = topSecond && docs.count > 1 && topSecond.n > docs.count / 2 ? `${topSecond.n} of ${docs.count} files share one modified second, so recency likely reflects a checkout or copy, not edit history` : null;\n\n return { docs, fields, fieldsTotal: allFields.length, features: featureStates(cfg), presets: await presetCoverage(store, cfg), hubs, recent, recentCaveat };\n });\n}\n"],"names":["featureEnabled","featureStates","INTERNAL_COLUMNS","scopedPaths","setupMapScope","presetCoverage","MAP_COLUMN_CHUNK","utcSecond","ms","Date","toISOString","slice","replace","chunk","items","size","out","i","length","push","mapTree","store","cfg","overrides","transaction","scopeWhere","scopeAnd","docs","prepare","get","columns","filter","name","has","allFields","group","fieldStats","sort","a","b","coverage","fields","hubs","all","recent","map","r","path","modified","mtime","topSecond","recentCaveat","count","n","fieldsTotal","features","presets"],"mappings":"AACA,SAASA,cAAc,EAAEC,aAAa,QAAQ,qBAAqB;AAGnE,SAASC,gBAAgB,EAAEC,WAAW,EAAEC,aAAa,QAAQ,aAAa;AAE1E,SAASC,cAAc,QAAQ,cAAc;AAa7C,6FAA6F;AAC7F,gGAAgG;AAChG,MAAMC,mBAAmB;AAEzB,+FAA+F;AAC/F,mDAAmD;AACnD,SAASC,UAAUC,EAAU;IAC3B,OAAO,IAAIC,KAAKD,IAAIE,WAAW,GAAGC,KAAK,CAAC,GAAG,IAAIC,OAAO,CAAC,KAAK;AAC9D;AAEA,SAASC,MAASC,KAAU,EAAEC,IAAY;IACxC,MAAMC,MAAa,EAAE;IACrB,IAAK,IAAIC,IAAI,GAAGA,IAAIH,MAAMI,MAAM,EAAED,KAAKF,KAAMC,IAAIG,IAAI,CAACL,MAAMH,KAAK,CAACM,GAAGA,IAAIF;IACzE,OAAOC;AACT;AAEA,4FAA4F;AAC5F,8DAA8D;AAC9D,OAAO,eAAeI,QAAQC,KAAY,EAAEC,GAAmB,EAAEC,YAA6B,CAAC,CAAC;IAC9F,OAAOF,MAAMG,WAAW,CAAC;QACvB,MAAMpB,cAAciB,OAAO,MAAMlB,YAAYkB,OAAOC,KAAKC;QACzD,MAAME,aAAa;QACnB,MAAMC,WAAW;QAEjB,MAAMC,OAAQ,MAAM,AAAC,CAAA,MAAMN,MAAMO,OAAO,CAAC,CAAC,8EAA8E,EAAEH,YAAY,CAAA,EAAGI,GAAG;QAE5I,MAAMC,UAAU,AAAC,CAAA,MAAMT,MAAMM,IAAI,CAACG,OAAO,EAAC,EAAGC,MAAM,CAAC,CAACC,OAAS,CAAC9B,iBAAiB+B,GAAG,CAACD;QACpF,2FAA2F;QAC3F,iFAAiF;QACjF,MAAME,YAAyB,EAAE;QACjC,KAAK,MAAMC,SAAStB,MAAMiB,SAASxB,kBAAmB4B,UAAUf,IAAI,IAAK,MAAME,MAAMM,IAAI,CAACS,UAAU,CAACD,OAAOV;QAC5GS,UAAUG,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAEC,QAAQ,GAAGF,EAAEE,QAAQ;QAChD,MAAMC,SAASP,UAAUvB,KAAK,CAAC,GAAG;QAElC,MAAM+B,OAAO1C,eAAesB,KAAK,UAC3B,MAAM,AAAC,CAAA,MAAMD,MAAMO,OAAO,CAAC,CAAC,iKAAiK,EAAEF,SAAS,8CAA8C,CAAC,CAAA,EAAGiB,GAAG,KAC/P,EAAE;QAEN,MAAMC,SAAS,AAAE,CAAA,MAAM,AAAC,CAAA,MAAMvB,MAAMO,OAAO,CAAC,CAAC,kDAAkD,EAAEH,WAAW,2CAA2C,CAAC,CAAA,EAAGkB,GAAG,EAAC,EAA8CE,GAAG,CAAC,CAACC,IAAO,CAAA;gBAAEC,MAAMD,EAAEC,IAAI;gBAAEC,UAAUzC,UAAUuC,EAAEG,KAAK;YAAE,CAAA;QAEtQ,2FAA2F;QAC3F,iGAAiG;QACjG,MAAMC,YAAa,MAAM,AAAC,CAAA,MAAM7B,MAAMO,OAAO,CAAC,CAAC,sCAAsC,EAAEH,WAAW,kEAAkE,CAAC,CAAA,EAAGI,GAAG;QAC3K,MAAMsB,eAAeD,aAAavB,KAAKyB,KAAK,GAAG,KAAKF,UAAUG,CAAC,GAAG1B,KAAKyB,KAAK,GAAG,IAAI,GAAGF,UAAUG,CAAC,CAAC,IAAI,EAAE1B,KAAKyB,KAAK,CAAC,iGAAiG,CAAC,GAAG;QAExN,OAAO;YAAEzB;YAAMc;YAAQa,aAAapB,UAAUhB,MAAM;YAAEqC,UAAUtD,cAAcqB;YAAMkC,SAAS,MAAMnD,eAAegB,OAAOC;YAAMoB;YAAME;YAAQO;QAAa;IAC5J;AACF"}
@@ -1,6 +1,13 @@
1
1
  import type { ResolvedConfig } from '../config/index.js';
2
2
  import type { Row } from '../output/output.js';
3
3
  import type { Store } from '../store/types.js';
4
+ export declare const SNIPPET_CHAR_LIMIT_DEFAULT = 80;
5
+ export declare const SNIPPET_COUNT_LIMIT_DEFAULT = 1;
6
+ export declare function computeSnippets(text: string, terms: string[], charLimit: number, countLimit: number): {
7
+ snippets: string[];
8
+ offset: number;
9
+ };
10
+ export declare function lineNumberAt(text: string, offset: number): number;
4
11
  export interface SearchOptions {
5
12
  k?: number;
6
13
  where?: string;
@@ -8,5 +15,8 @@ export interface SearchOptions {
8
15
  include?: string[];
9
16
  exclude?: string[];
10
17
  noExclude?: boolean;
18
+ snippetCharLimit?: number;
19
+ snippetCountLimit?: number;
11
20
  }
21
+ export declare function hydrateSearchRows(store: Store, cfg: ResolvedConfig, rows: Row[], matchedPaths: Set<string>, terms: string, opts?: Pick<SearchOptions, 'snippetCharLimit' | 'snippetCountLimit'>): Promise<void>;
12
22
  export declare function search(store: Store, cfg: ResolvedConfig, terms: string, opts?: SearchOptions): Promise<Row[]>;
@@ -1,96 +1,266 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { stemmer } from 'stemmer';
3
4
  import { embedConfig, featureEnabled, resolveSearch } from '../config/index.js';
4
5
  import { localModelMissing, MODEL_FILENAMES } from '../embed/store.js';
5
6
  import { SenseError } from '../errors.js';
6
- import { searchError } from '../output/search-error.js';
7
+ import { bareTermSyntaxError, searchError } from '../output/search-error.js';
8
+ import { foldForSearch, hasUnspacedRun, searchTokens, unspacedRuns } from '../text/segment.js';
7
9
  import { materializeScope, narrowByWhere, rawScope, scopeHasEmbeddings } from './scope.js';
8
10
  import { linksCandidates, vectorsCandidates, wordsCandidates } from './signals.js';
9
- // snippet() re-tokenizes each candidate doc, superlinearly: ~10s for one 1MB doc
10
- // (benchmark/reports/2026-08-23-0.13.2-hub-release-battery.md). Past this bound, rows get the JS excerpt.
11
- const EXCERPT_WINDOW = 160;
12
- // Bare terms from an FTS5 query string: strips operators/quoting so the oversized-doc excerpt
13
- // scan matches the same words the query matched on, not FTS5 syntax.
11
+ // Default budget behind --snippet-char-limit/--snippet-count-limit: the limit is whatever the
12
+ // caller passes, these only say what happens when they pass nothing (DESIGN.md "Search snippets").
13
+ export const SNIPPET_CHAR_LIMIT_DEFAULT = 80;
14
+ export const SNIPPET_COUNT_LIMIT_DEFAULT = 1;
15
+ // Bare terms from an FTS5 query string: strips operators/quoting, not real terms, so the
16
+ // snippet scan matches what the query matched -- FTS5 operators are uppercase-only, so this is too.
14
17
  function extractBareTerms(query) {
15
- const cleaned = query.replace(/"/g, ' ').replace(/[()*]/g, ' ').replace(/\b(AND|OR|NOT|NEAR)\b(\/\d+)?/gi, ' ');
18
+ const cleaned = query.replace(/"/g, ' ').replace(/[()*]/g, ' ').replace(/\b(AND|OR|NOT|NEAR)\b(\/\d+)?/g, ' ');
16
19
  return cleaned.split(/\s+/).map((tok)=>tok.replace(/^[A-Za-z_]\w*:/, '')) // column filter, e.g. title:term
17
- .map((tok)=>tok.toLowerCase().trim()).filter((tok)=>tok.length > 0);
20
+ .flatMap((tok)=>searchTokens(tok).map(({ text })=>text)).map((tok)=>tok.toLowerCase().trim()).filter((tok)=>tok.length > 0);
18
21
  }
19
- function findOccurrences(haystackLower, terms) {
22
+ // House convention (src/chunk/group.ts): Intl.Segmenter word boundaries, not a bare \b, which
23
+ // is ASCII-only. Built on first use: construction is a few ms, paid only when a term matches.
24
+ let wordSegmenter;
25
+ function findOccurrences(text, terms) {
20
26
  const occ = [];
21
- for (const term of terms){
22
- let idx = 0;
23
- for(;;){
24
- const found = haystackLower.indexOf(term, idx);
25
- if (found === -1) break;
26
- occ.push({
27
- start: found,
28
- end: found + term.length,
27
+ const textLower = text.toLowerCase();
28
+ // Unspaced scripts (UNSPACED_SCRIPTS) mark no word boundaries: the sense-setup skill promises
29
+ // substring semantics there, so a matching term is found wherever it occurs in the run.
30
+ const unspacedTerms = terms.filter(hasUnspacedRun);
31
+ if (unspacedTerms.length > 0) {
32
+ for (const run of unspacedRuns(text)){
33
+ const runLower = textLower.slice(run.start, run.end);
34
+ for (const term of unspacedTerms){
35
+ let idx = 0;
36
+ for(;;){
37
+ const found = runLower.indexOf(term, idx);
38
+ if (found === -1) break;
39
+ occ.push({
40
+ start: run.start + found,
41
+ end: run.start + found + term.length,
42
+ term
43
+ });
44
+ idx = found + term.length;
45
+ }
46
+ }
47
+ }
48
+ }
49
+ // Spaced scripts: a word is marked when its porter stem equals a query term's stem, the same
50
+ // algorithm both indexes tokenize with (sqlite's TOKENIZE = 'porter unicode61'; duckdb's fts default).
51
+ const spacedTerms = terms.filter((t)=>!hasUnspacedRun(t));
52
+ if (spacedTerms.length > 0) {
53
+ const termByStem = new Map(spacedTerms.map((t)=>[
54
+ stemmer(foldForSearch(t)),
55
+ t
56
+ ]));
57
+ const stemInitials = new Set(Array.from(termByStem.keys(), (term)=>term[0]));
58
+ for (const match of searchTokens(text)){
59
+ const segment = match.text;
60
+ if (match.unspaced) continue;
61
+ const folded = foldForSearch(segment);
62
+ // Porter stemming changes suffixes only, so a different initial cannot match a query stem.
63
+ if (!stemInitials.has(folded[0])) continue;
64
+ const term = termByStem.get(stemmer(folded));
65
+ if (term !== undefined) occ.push({
66
+ start: match.start,
67
+ end: match.end,
29
68
  term
30
69
  });
31
- idx = found + term.length;
32
70
  }
33
71
  }
34
- // Longest span first at equal start, so "tests" beats its substring "test" and the
35
- // whole word gets highlighted; the emit loop then absorbs the shorter overlap.
36
72
  return occ.sort((a, b)=>a.start - b.start || b.end - a.end);
37
73
  }
38
- // Slides a window over the sorted occurrences, same semantics as FTS5's own best-window
39
- // pick: most distinct terms, ties broken by most total hits.
40
- function bestWindowStart(occ, windowSize) {
41
- let best = occ[0].start;
42
- let bestDistinct = 0;
43
- let bestCount = 0;
74
+ // Segment boundaries of the whole text, word granularity: the only cut points a snippet edge
75
+ // may land on. One rule for every script (DESIGN.md "Cutting") -- a regex \b is ASCII-only and
76
+ // would mis-cut Japanese, Chinese and Thai, which Intl.Segmenter divides by dictionary instead.
77
+ function wordBoundaries(text) {
78
+ wordSegmenter !== null && wordSegmenter !== void 0 ? wordSegmenter : wordSegmenter = new Intl.Segmenter('en', {
79
+ granularity: 'word'
80
+ });
81
+ const bounds = [
82
+ 0
83
+ ];
84
+ for (const { index, segment } of wordSegmenter.segment(text))bounds.push(index + segment.length);
85
+ return bounds;
86
+ }
87
+ // bounds is sorted: snaps are binary searches, since each runs once per candidate window.
88
+ function snapForward(bounds, pos) {
89
+ let lo = 0;
90
+ let hi = bounds.length - 1;
91
+ while(lo < hi){
92
+ const mid = lo + hi >> 1;
93
+ if (bounds[mid] < pos) lo = mid + 1;
94
+ else hi = mid;
95
+ }
96
+ return bounds[lo];
97
+ }
98
+ function snapBackward(bounds, pos) {
99
+ let lo = 0;
100
+ let hi = bounds.length - 1;
101
+ let result = bounds[0];
102
+ while(lo < hi){
103
+ const mid = lo + hi + 1 >> 1;
104
+ if (bounds[mid] <= pos) {
105
+ lo = mid;
106
+ result = bounds[mid];
107
+ } else hi = mid - 1;
108
+ }
109
+ return result;
110
+ }
111
+ function firstBoundAfter(bounds, pos) {
112
+ let lo = 0;
113
+ let hi = bounds.length;
114
+ while(lo < hi){
115
+ const mid = lo + hi >> 1;
116
+ if (bounds[mid] <= pos) lo = mid + 1;
117
+ else hi = mid;
118
+ }
119
+ return bounds[lo];
120
+ }
121
+ // A raw, unmarked upper bound: [start, end) inside [anchor, anchor+charLimit), edges on
122
+ // boundaries. Markup (guillemets, ellipses) added on top of this can push the rendered result
123
+ // past charLimit, which is what fitEnd() below corrects for; this is only the starting guess.
124
+ // A single segment spanning the whole target window (a word longer than the char limit) is the
125
+ // one case where holding the boundary rule means the budget is not a hard ceiling.
126
+ function cutWindow(text, bounds, anchor, charLimit) {
127
+ var _firstBoundAfter;
128
+ const rawEnd = Math.min(text.length, anchor + charLimit);
129
+ const start = snapForward(bounds, anchor);
130
+ let end = snapBackward(bounds, rawEnd);
131
+ if (end <= start) end = (_firstBoundAfter = firstBoundAfter(bounds, start)) !== null && _firstBoundAfter !== void 0 ? _firstBoundAfter : text.length;
132
+ return {
133
+ start,
134
+ end: Math.min(end, text.length)
135
+ };
136
+ }
137
+ // The largest boundary <= upperBound, > floor, whose rendered [*, end) fits charLimit. Markup
138
+ // only ever adds characters as end grows, so rendered length is non-decreasing in end and
139
+ // shrinking from upperBound is guaranteed to find the true fit rather than guess at it.
140
+ // `floor` is the one end this never shrinks past (the anchor's own marked word, or the first
141
+ // word in the unmarked fallback): DESIGN.md's budget is a ceiling except here, where returning
142
+ // nothing is worse than a single word that overruns it.
143
+ function fitEnd(render, bounds, floor, upperBound, charLimit) {
144
+ let lo = 0;
145
+ let hi = bounds.length - 1;
146
+ while(lo < hi){
147
+ const mid = lo + hi + 1 >> 1;
148
+ if (bounds[mid] <= upperBound) lo = mid;
149
+ else hi = mid - 1;
150
+ }
151
+ for(; lo >= 0 && bounds[lo] > floor; lo--){
152
+ if (render(bounds[lo]).length <= charLimit) return bounds[lo];
153
+ }
154
+ return floor;
155
+ }
156
+ // occ is start-sorted: a window's first candidate occurrence is a binary search, not a walk.
157
+ function firstOccAtOrAfter(occ, pos) {
158
+ let lo = 0;
159
+ let hi = occ.length;
160
+ while(lo < hi){
161
+ const mid = lo + hi >> 1;
162
+ if (occ[mid].start < pos) lo = mid + 1;
163
+ else hi = mid;
164
+ }
165
+ return lo;
166
+ }
167
+ function renderWindow(text, occ, win) {
168
+ let out = '';
169
+ let cursor = win.start;
170
+ for(let i = firstOccAtOrAfter(occ, win.start); i < occ.length && occ[i].start < win.end; i++){
171
+ const o = occ[i];
172
+ if (o.end > win.end) continue;
173
+ // Occurrences of duplicate or substring-overlapping terms ("test tests") can overlap;
174
+ // emitting each would duplicate document text. Keep the first, absorb the rest.
175
+ if (o.start < cursor) continue;
176
+ out += `${text.slice(cursor, o.start)}«${text.slice(o.start, o.end)}»`;
177
+ cursor = o.end;
178
+ }
179
+ out += text.slice(cursor, win.end);
180
+ const prefix = win.start > 0 ? '…' : '';
181
+ const suffix = win.end < text.length ? '…' : '';
182
+ return `${prefix}${out.replace(/\s+/g, ' ')}${suffix}`;
183
+ }
184
+ // Scores every occurrence-anchored candidate window: most distinct terms wins, ties broken by
185
+ // most total hits -- coverage now that occurrences are real words, not the false-fragment counts
186
+ // a substring scan produced. Selection itself is unevaluated (DESIGN.md "Selection, unsettled").
187
+ // The window's `end` is fitted to the *rendered* text, markup included, not the raw span: a
188
+ // window dense with marks (every guillemet pair adds two characters) shrinks to fit the same
189
+ // budget a sparse one fills almost exactly.
190
+ function scoreWindows(occ, bounds, text, charLimit) {
191
+ const candidates = [];
44
192
  for(let i = 0; i < occ.length; i++){
45
- const winEnd = occ[i].start + windowSize;
193
+ const winEnd = occ[i].start + charLimit;
46
194
  const seen = new Set();
47
195
  let count = 0;
48
196
  for(let j = i; j < occ.length && occ[j].start < winEnd; j++){
49
197
  seen.add(occ[j].term);
50
198
  count++;
51
199
  }
52
- if (seen.size > bestDistinct || seen.size === bestDistinct && count > bestCount) {
53
- best = occ[i].start;
54
- bestDistinct = seen.size;
55
- bestCount = count;
56
- }
200
+ const start = snapForward(bounds, occ[i].start);
201
+ const floor = Math.max(start, occ[i].end);
202
+ const upperBound = Math.max(floor, cutWindow(text, bounds, occ[i].start, charLimit).end);
203
+ const end = fitEnd((e)=>renderWindow(text, occ, {
204
+ start,
205
+ end: e
206
+ }), bounds, floor, upperBound, charLimit);
207
+ candidates.push({
208
+ start,
209
+ end,
210
+ distinct: seen.size,
211
+ count
212
+ });
213
+ }
214
+ return candidates;
215
+ }
216
+ function overlaps(a, b) {
217
+ return a.start < b.end && b.start < a.end;
218
+ }
219
+ // Same scoring function as a single snippet, kept rather than discarded (DESIGN.md "Several
220
+ // snippets per note"): rank by score, take the top non-overlapping windows, then reorder to the
221
+ // note's own order, since several passages from one note read as an abridged note.
222
+ function selectWindows(candidates, countLimit) {
223
+ const ranked = candidates.slice().sort((a, b)=>b.distinct - a.distinct || b.count - a.count);
224
+ const chosen = [];
225
+ for (const c of ranked){
226
+ if (chosen.length >= countLimit) break;
227
+ if (chosen.some((s)=>overlaps(s, c))) continue;
228
+ chosen.push(c);
57
229
  }
58
- return best;
230
+ chosen.sort((a, b)=>a.start - b.start);
231
+ return {
232
+ chosen,
233
+ best: ranked[0]
234
+ };
59
235
  }
60
- // snippet()-shaped excerpt: matched terms wrapped in «», … at cut edges. Linear in doc
61
- // length, only run for rows actually returned (<= k), unlike snippet()'s per-row cost.
62
- function computeExcerpt(text, terms) {
63
- const occ = findOccurrences(text.toLowerCase(), terms);
236
+ // Builds bounded marked passages and returns the best window's offset for section lookup.
237
+ export function computeSnippets(text, terms, charLimit, countLimit) {
238
+ const occ = findOccurrences(text, terms);
239
+ const bounds = wordBoundaries(text);
64
240
  if (occ.length === 0) {
65
- // Raw substring scan, not porter-stemmed: a doc matched only through a stemmed variant
66
- // finds no occurrence here, so fall back to the doc's start, unmarked.
67
- const end = Math.min(text.length, EXCERPT_WINDOW);
241
+ var _bounds_find;
242
+ // A term matched only via title/summary, not this body text, finds no occurrence here;
243
+ // fall back to the doc's start, unmarked.
244
+ const render = (end)=>`${text.slice(0, end).replace(/\s+/g, ' ').trim()}${end < text.length ? '…' : ''}`;
245
+ const floor = (_bounds_find = bounds.find((b)=>b > 0)) !== null && _bounds_find !== void 0 ? _bounds_find : text.length;
246
+ const upperBound = Math.max(floor, cutWindow(text, bounds, 0, charLimit).end);
247
+ const end = fitEnd(render, bounds, floor, upperBound, charLimit);
68
248
  return {
69
- excerpt: `${text.slice(0, end).replace(/\s+/g, ' ').trim()}${end < text.length ? '…' : ''}`,
249
+ snippets: [
250
+ render(end)
251
+ ],
70
252
  offset: 0
71
253
  };
72
254
  }
73
- const start = bestWindowStart(occ, EXCERPT_WINDOW);
74
- const end = Math.min(text.length, start + EXCERPT_WINDOW);
75
- let out = '';
76
- let cursor = start;
77
- for (const o of occ){
78
- if (o.start < start || o.end > end) continue;
79
- // Occurrences of duplicate or substring-overlapping terms ("test tests") can overlap;
80
- // emitting each would duplicate document text. Keep the first, absorb the rest.
81
- if (o.start < cursor) continue;
82
- out += `${text.slice(cursor, o.start)}«${text.slice(o.start, o.end)}»`;
83
- cursor = o.end;
84
- }
85
- out += text.slice(cursor, end);
86
- const prefix = start > 0 ? '…' : '';
87
- const suffix = end < text.length ? '…' : '';
255
+ const candidates = scoreWindows(occ, bounds, text, charLimit);
256
+ const { chosen, best } = selectWindows(candidates, countLimit);
88
257
  return {
89
- excerpt: `${prefix}${out.replace(/\s+/g, ' ')}${suffix}`,
90
- offset: start
258
+ snippets: chosen.map((win)=>renderWindow(text, occ, win)),
259
+ offset: best.start
91
260
  };
92
261
  }
93
- function lineNumberAt(text, offset) {
262
+ // Converts an offset in the original text to its one-based line number.
263
+ export function lineNumberAt(text, offset) {
94
264
  let line = 1;
95
265
  for(let i = 0; i < offset; i++)if (text.charCodeAt(i) === 10) line++;
96
266
  return line;
@@ -100,6 +270,26 @@ async function lineRangeFor(store, path, line) {
100
270
  const row = await stmt.get(path, line, line);
101
271
  return row ? `L${row.start_line}-${row.end_line}` : null;
102
272
  }
273
+ // Hydrates already-ranked rows with snippets and containing-section ranges without reranking.
274
+ export async function hydrateSearchRows(store, cfg, rows, matchedPaths, terms, opts = {}) {
275
+ var _opts_snippetCharLimit, _opts_snippetCountLimit;
276
+ if (matchedPaths.size === 0) return;
277
+ const bareTerms = extractBareTerms(terms);
278
+ const charLimit = (_opts_snippetCharLimit = opts.snippetCharLimit) !== null && _opts_snippetCharLimit !== void 0 ? _opts_snippetCharLimit : SNIPPET_CHAR_LIMIT_DEFAULT;
279
+ const countLimit = (_opts_snippetCountLimit = opts.snippetCountLimit) !== null && _opts_snippetCountLimit !== void 0 ? _opts_snippetCountLimit : SNIPPET_COUNT_LIMIT_DEFAULT;
280
+ for (const row of rows){
281
+ if (!matchedPaths.has(row.path)) continue;
282
+ let text;
283
+ try {
284
+ text = readFileSync(join(cfg.baseDir, row.path), 'utf8');
285
+ } catch {
286
+ continue; // vanished since the match; leave snippets/lines empty rather than throw
287
+ }
288
+ const { snippets, offset } = computeSnippets(text, bareTerms, charLimit, countLimit);
289
+ row.snippets = snippets;
290
+ if (row.lines == null) row.lines = featureEnabled(cfg, 'sections') ? await lineRangeFor(store, row.path, lineNumberAt(text, offset)) : null;
291
+ }
292
+ }
103
293
  // The declared or defaulted signals compose via RRF; `via` names which ones produced each row.
104
294
  // `opts` arrives already resolved (config.ts:resolveSearch).
105
295
  export async function search(store, cfg, terms, opts = {}) {
@@ -138,22 +328,24 @@ export async function search(store, cfg, terms, opts = {}) {
138
328
  const scopeCond = scopeActive ? `AND content.path IN (SELECT "path" FROM _search_scope)` : '';
139
329
  const candidates = new Map();
140
330
  let matchRows = [];
141
- // A query that is only whitespace has no searchable words: zero lexical rows, and the other
142
- // signals compose normally.
143
- if (signals.words !== undefined && terms.trim() !== '') {
331
+ const hasEmptyPhrase = [
332
+ ...terms.matchAll(/"([^"]*)"/g)
333
+ ].some((match)=>!/[\p{L}\p{N}_]/u.test(match[1]));
334
+ const hasBareSyntaxOnly = /[()]/u.test(terms.replace(/"[^"]*"/g, ' '));
335
+ // A query that is only whitespace or punctuation has no lexical rows; other declared signals
336
+ // compose normally. In particular, unspaced-script punctuation can rewrite to an empty MATCH.
337
+ if (signals.words !== undefined && terms.trim() !== '' && (/[\p{L}\p{N}_]/u.test(terms) || hasBareSyntaxOnly) && !hasEmptyPhrase) {
338
+ const syntaxError = bareTermSyntaxError(terms);
339
+ if (syntaxError) throw syntaxError;
144
340
  try {
145
341
  matchRows = await wordsCandidates(store, candidates, terms, whereJoin, whereCond, scopeCond, fetch, signals.words);
146
342
  } catch (err) {
147
343
  throw searchError(err, terms, scope);
148
344
  }
149
345
  }
150
- const hits = new Map(matchRows.map((r)=>[
151
- r.path,
152
- r.hit
153
- ]));
154
- // hit === null here means the bound suppressed snippet(), not "no match" -- distinguish
155
- // from via='link' rows (never in matchRows, so absent from this set) below.
156
- const oversized = new Set(matchRows.filter((r)=>r.hit === null).map((r)=>r.path));
346
+ // Every store's lexical `snippets` is NULL; the snippet text is always computed below.
347
+ // Distinguish from via='link' rows, which never appear in matchRows.
348
+ const matchedPaths = new Set(matchRows.map((r)=>r.path));
157
349
  if (signals.links !== undefined) await linksCandidates(store, candidates, matchRows, allPaths, allowedPaths, fetch, signals.links);
158
350
  let chunkLines = new Map();
159
351
  let chunkSimilarity = new Map();
@@ -163,17 +355,16 @@ export async function search(store, cfg, terms, opts = {}) {
163
355
  await store.exec('DROP TABLE IF EXISTS _search');
164
356
  // DOUBLE, not REAL: sqlite's REAL is an 8-byte double but duckdb's is a 4-byte float, and the
165
357
  // rounded score/similarity are printed at full precision in rows.
166
- await store.exec('CREATE TEMP TABLE _search ("path" TEXT PRIMARY KEY, score DOUBLE, via TEXT, hit TEXT, lines TEXT, similarity DOUBLE)');
358
+ await store.exec('CREATE TEMP TABLE _search ("path" TEXT PRIMARY KEY, score DOUBLE, via TEXT, lines TEXT, similarity DOUBLE)');
167
359
  if (candidates.size > 0) {
168
- await store.runBatch('INSERT INTO _search ("path", score, via, hit, lines, similarity) VALUES (?, ?, ?, ?, ?, ?)', [
360
+ await store.runBatch('INSERT INTO _search ("path", score, via, lines, similarity) VALUES (?, ?, ?, ?, ?)', [
169
361
  ...candidates
170
362
  ].map(([path, c])=>{
171
- var _hits_get, _chunkLines_get, _chunkSimilarity_get;
363
+ var _chunkLines_get, _chunkSimilarity_get;
172
364
  return [
173
365
  path,
174
366
  c.score,
175
367
  c.via,
176
- (_hits_get = hits.get(path)) !== null && _hits_get !== void 0 ? _hits_get : null,
177
368
  (_chunkLines_get = chunkLines.get(path)) !== null && _chunkLines_get !== void 0 ? _chunkLines_get : null,
178
369
  (_chunkSimilarity_get = chunkSimilarity.get(path)) !== null && _chunkSimilarity_get !== void 0 ? _chunkSimilarity_get : null
179
370
  ];
@@ -182,27 +373,16 @@ export async function search(store, cfg, terms, opts = {}) {
182
373
  // Reapplies --where even though _search is already scope+where filtered, since the join to
183
374
  // frontmatter is already needed for the path column.
184
375
  const where = scope ? `WHERE (${scope})` : '';
185
- // lines: semantic rows carry their chunk's range, oversized-doc lexical rows gain one
186
- // below, everything else stays null; similarity stays semantic-only.
376
+ // lines: semantic rows carry their chunk's range, lexical rows gain one below,
377
+ // everything else stays null; similarity stays semantic-only.
187
378
  const similarityCol = semanticEnabled ? ', _search.similarity' : '';
188
- const selectStmt = await store.prepare(`SELECT f."path" AS path, content.title, content.summary, _search.hit, _search.via, round(_search.score, 4) AS score, _search.lines${similarityCol}
379
+ // NULL AS snippets holds this column's position (between summary and via) for every row;
380
+ // overwritten below, always to an array, never left null (DESIGN.md).
381
+ const selectStmt = await store.prepare(`SELECT f."path" AS path, content.title, content.summary, NULL AS snippets, _search.via, round(_search.score, 4) AS score, _search.lines${similarityCol}
189
382
  FROM _search JOIN frontmatter f ON f."path" = _search."path" JOIN content ON content.path = _search."path"
190
383
  ${where} ORDER BY _search.score DESC LIMIT ?`);
191
384
  const rows = await selectStmt.all(k);
192
- if (oversized.size > 0) {
193
- const bareTerms = extractBareTerms(terms);
194
- for (const row of rows){
195
- if (row.hit !== null || !oversized.has(row.path)) continue;
196
- let text;
197
- try {
198
- text = readFileSync(join(cfg.baseDir, row.path), 'utf8');
199
- } catch {
200
- continue; // vanished since the match; leave hit/lines null rather than throw
201
- }
202
- const { excerpt, offset } = computeExcerpt(text, bareTerms);
203
- row.hit = excerpt;
204
- if (row.lines == null) row.lines = featureEnabled(cfg, 'sections') ? await lineRangeFor(store, row.path, lineNumberAt(text, offset)) : null;
205
- }
206
- }
385
+ for (const row of rows)row.snippets = [];
386
+ await hydrateSearchRows(store, cfg, rows, matchedPaths, terms, opts);
207
387
  return rows;
208
388
  }