@plebeiantech/lat.md 0.12.3-fork.16 → 0.12.3-fork.17

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 (121) hide show
  1. package/README.md +1 -1
  2. package/dist/src/cli/index.js +12 -6
  3. package/dist/src/cli/init.js +3 -4
  4. package/dist/src/cli/reindex.js +4 -8
  5. package/dist/src/cli/search.d.ts +3 -2
  6. package/dist/src/cli/search.js +69 -59
  7. package/dist/src/format.d.ts +5 -0
  8. package/dist/src/format.js +32 -4
  9. package/dist/src/lattice-model.d.ts +8 -1
  10. package/dist/src/lattice-model.js +4 -1
  11. package/dist/src/lattice.d.ts +2 -2
  12. package/dist/src/lattice.js +17 -34
  13. package/dist/src/markdown-analysis.d.ts +11 -0
  14. package/dist/src/markdown-analysis.js +31 -1
  15. package/dist/src/mcp/server.js +6 -6
  16. package/dist/src/parser-cache.d.ts +1 -1
  17. package/dist/src/parser-cache.js +1 -1
  18. package/dist/src/search/cache.d.ts +3 -0
  19. package/dist/src/search/cache.js +71 -0
  20. package/dist/src/search/chunks.d.ts +23 -0
  21. package/dist/src/search/chunks.js +197 -0
  22. package/dist/src/search/db.d.ts +27 -16
  23. package/dist/src/search/db.js +108 -38
  24. package/dist/src/search/index.d.ts +5 -2
  25. package/dist/src/search/index.js +173 -69
  26. package/dist/src/search/lexical.d.ts +6 -0
  27. package/dist/src/search/lexical.js +36 -0
  28. package/dist/src/search/lock.d.ts +2 -0
  29. package/dist/src/search/lock.js +23 -0
  30. package/dist/src/search/query.d.ts +2 -2
  31. package/dist/src/search/query.js +24 -9
  32. package/dist/src/search/search.d.ts +13 -8
  33. package/dist/src/search/search.js +155 -23
  34. package/dist/src/search/types.d.ts +31 -0
  35. package/dist/src/search/types.js +1 -0
  36. package/dist/src/source-formats.d.ts +1 -1
  37. package/dist/src/source-formats.js +2 -0
  38. package/dist/src/source-parser.js +197 -1
  39. package/dist/src/view/client/assets/{GraphView-BuZg_wrj.js → GraphView-BWlRdQM4.js} +2 -2
  40. package/dist/src/view/client/assets/{MarkdownEditor-BKqJtZYv.js → MarkdownEditor-MW-Ck1O0.js} +1 -1
  41. package/dist/src/view/client/assets/{abnfDiagram-VCTEODGH-CAu3KwfO.js → abnfDiagram-VCTEODGH-Bn3RshMV.js} +1 -1
  42. package/dist/src/view/client/assets/{arc-CFnPZhKM.js → arc-En9tdFQF.js} +1 -1
  43. package/dist/src/view/client/assets/{architectureDiagram-5GKGNRK7-Cf9Kd0OA.js → architectureDiagram-5GKGNRK7-B-9J1VAM.js} +1 -1
  44. package/dist/src/view/client/assets/{blockDiagram-I7D4REHJ-dPn7g_vm.js → blockDiagram-I7D4REHJ-QlznwalP.js} +1 -1
  45. package/dist/src/view/client/assets/{c4Diagram-7LVT6UL2-DdZrwaPd.js → c4Diagram-7LVT6UL2-Bp2y8B4_.js} +1 -1
  46. package/dist/src/view/client/assets/channel-uy0fFvcW.js +1 -0
  47. package/dist/src/view/client/assets/{chunk-2Q5K7J3B-C42JuteH.js → chunk-2Q5K7J3B-CnBKL-lm.js} +1 -1
  48. package/dist/src/view/client/assets/{chunk-5VM5RSS4-BKK3z8Zy.js → chunk-5VM5RSS4-LLYMi_n6.js} +1 -1
  49. package/dist/src/view/client/assets/{chunk-F27PBJKO-qZl_uKcW.js → chunk-F27PBJKO-VIeO7VF0.js} +1 -1
  50. package/dist/src/view/client/assets/{chunk-IMKFNOWR-Bs4Rnoj_.js → chunk-IMKFNOWR-D5i67DVD.js} +1 -1
  51. package/dist/src/view/client/assets/{chunk-JWPE2WC7-BpxgV2rA.js → chunk-JWPE2WC7-D4qAtoL3.js} +1 -1
  52. package/dist/src/view/client/assets/{chunk-POPQ4Y6H-hIynofdE.js → chunk-POPQ4Y6H-BMzOyiW_.js} +1 -1
  53. package/dist/src/view/client/assets/{chunk-SVP7TREG-t3gKMqeu.js → chunk-SVP7TREG-DWy5nbfp.js} +1 -1
  54. package/dist/src/view/client/assets/{chunk-TICWLB2K-B2x394mh.js → chunk-TICWLB2K-CVqCANFB.js} +1 -1
  55. package/dist/src/view/client/assets/{chunk-XXDRQBXY-CHaVKDsH.js → chunk-XXDRQBXY-Bp23h6Tf.js} +1 -1
  56. package/dist/src/view/client/assets/classDiagram-ZZMXUADV-DbSEqjA5.js +1 -0
  57. package/dist/src/view/client/assets/classDiagram-v2-VYDZK3BY-DbSEqjA5.js +1 -0
  58. package/dist/src/view/client/assets/{cose-bilkent-JH36ORCC-DE2-Jgqu.js → cose-bilkent-JH36ORCC-BCw7fCC2.js} +1 -1
  59. package/dist/src/view/client/assets/{cynefin-OW5HDTMX-BsEtOvRy.js → cynefin-OW5HDTMX--lYN4N9m.js} +1 -1
  60. package/dist/src/view/client/assets/{cynefinDiagram-5FMLGOSQ-BKYG1Xs8.js → cynefinDiagram-5FMLGOSQ-9sK5EV7v.js} +1 -1
  61. package/dist/src/view/client/assets/{dagre-GXQ25YYZ-hgrdNjl7.js → dagre-GXQ25YYZ-CoMdEFCq.js} +1 -1
  62. package/dist/src/view/client/assets/{diagram-S7CK7UJ4-VIx8zAxf.js → diagram-S7CK7UJ4-D13dyWdd.js} +1 -1
  63. package/dist/src/view/client/assets/{diagram-UQ7AKVKN-yfloehRm.js → diagram-UQ7AKVKN-PdwLPPPA.js} +1 -1
  64. package/dist/src/view/client/assets/{diagram-VSXAHHWV-BT_dA4Q9.js → diagram-VSXAHHWV-CtWWa_A7.js} +1 -1
  65. package/dist/src/view/client/assets/{diagram-VX7I27RA-Gfmwxx6u.js → diagram-VX7I27RA-BeCYcSU-.js} +1 -1
  66. package/dist/src/view/client/assets/{diagram-Z3DM3KII-C2yM7EOo.js → diagram-Z3DM3KII-DiWabOBM.js} +1 -1
  67. package/dist/src/view/client/assets/{ebnfDiagram-PWID7BFC-Bkb_Lj59.js → ebnfDiagram-PWID7BFC-CZQ3MD_K.js} +1 -1
  68. package/dist/src/view/client/assets/{erDiagram-RLTQ6QDP-C7wQHPye.js → erDiagram-RLTQ6QDP-B1AhXIrP.js} +1 -1
  69. package/dist/src/view/client/assets/{flowDiagram-HODETNUW-CXoKr3oI.js → flowDiagram-HODETNUW-Bhr0JM6P.js} +1 -1
  70. package/dist/src/view/client/assets/{ganttDiagram-EL5Y4UJY-DqqXKgLa.js → ganttDiagram-EL5Y4UJY-CbP8iAkg.js} +1 -1
  71. package/dist/src/view/client/assets/{gitGraphDiagram-WWUBYQGX-D_DAhSgZ.js → gitGraphDiagram-WWUBYQGX-B9uuGdLF.js} +1 -1
  72. package/dist/src/view/client/assets/{index-DAxFk0q-.css → index-DG0Ez15D.css} +1 -1
  73. package/dist/src/view/client/assets/index-nBmbYfun.js +13 -0
  74. package/dist/src/view/client/assets/{infoDiagram-27XIBGKW-B50EOats.js → infoDiagram-27XIBGKW-BrfwQ_Cq.js} +1 -1
  75. package/dist/src/view/client/assets/{ishikawaDiagram-5VMMS53U-D1rny9gj.js → ishikawaDiagram-5VMMS53U-CHtJ36re.js} +1 -1
  76. package/dist/src/view/client/assets/{journeyDiagram-3NMN7TZE-B5UD2Xo4.js → journeyDiagram-3NMN7TZE-BChcgX1s.js} +1 -1
  77. package/dist/src/view/client/assets/{kanban-definition-UXKFOSKX-CTgey9zw.js → kanban-definition-UXKFOSKX-BnwFsKiB.js} +1 -1
  78. package/dist/src/view/client/assets/{layout-4X2n4os-.js → layout-Dnd0pLdb.js} +1 -1
  79. package/dist/src/view/client/assets/{linear-D0RIBXsp.js → linear-CFhW2ozn.js} +1 -1
  80. package/dist/src/view/client/assets/{mermaid.core-BAnH8y-D.js → mermaid.core--OP6lXtW.js} +5 -5
  81. package/dist/src/view/client/assets/{mindmap-definition-YA3MSWOX-WiFsh3Jg.js → mindmap-definition-YA3MSWOX-D51y2zbm.js} +1 -1
  82. package/dist/src/view/client/assets/{pegDiagram-XKGWAZYB-DCJOrckP.js → pegDiagram-XKGWAZYB--zyQJDvI.js} +1 -1
  83. package/dist/src/view/client/assets/{pieDiagram-E7YTZNPT-C78IkUE2.js → pieDiagram-E7YTZNPT-Cj8mvpJy.js} +1 -1
  84. package/dist/src/view/client/assets/{quadrantDiagram-AXDQQJYC-xZ6guleB.js → quadrantDiagram-AXDQQJYC-qvhJrnBf.js} +1 -1
  85. package/dist/src/view/client/assets/{railroadDiagram-O6MQD6OU-D3PwmgX0.js → railroadDiagram-O6MQD6OU-CtWvxpSo.js} +1 -1
  86. package/dist/src/view/client/assets/{requirementDiagram-BXWQKSXE-v-I8wLGH.js → requirementDiagram-BXWQKSXE-CBxen_KG.js} +1 -1
  87. package/dist/src/view/client/assets/{sankeyDiagram-P5KCCOFB-DBY-sdO7.js → sankeyDiagram-P5KCCOFB-B_fUkmfc.js} +1 -1
  88. package/dist/src/view/client/assets/{sequenceDiagram-WJ2MYXX4-D9HApBFi.js → sequenceDiagram-WJ2MYXX4-Dclv4p68.js} +1 -1
  89. package/dist/src/view/client/assets/{sizeCapture-INFHLROL-ERAytLnQ.js → sizeCapture-INFHLROL-GfG0sMTZ.js} +1 -1
  90. package/dist/src/view/client/assets/{stateDiagram-D77RDMKH-BKef_Qwv.js → stateDiagram-D77RDMKH-YQWPnzxU.js} +1 -1
  91. package/dist/src/view/client/assets/stateDiagram-v2-MP3YSRHH-gUj6Hzgl.js +1 -0
  92. package/dist/src/view/client/assets/{swimlanes-42K2YHIH-D7dS_tnF.js → swimlanes-42K2YHIH-5yiCveVS.js} +1 -1
  93. package/dist/src/view/client/assets/swimlanesDiagram-VR7AAH4N-DR7JhrxL.js +8 -0
  94. package/dist/src/view/client/assets/{timeline-definition-24CTP7MA-Di1UCseG.js → timeline-definition-24CTP7MA-BvAbB5Zs.js} +1 -1
  95. package/dist/src/view/client/assets/{vennDiagram-4TSXK5OY-g7Je3H2H.js → vennDiagram-4TSXK5OY-guC-r60n.js} +1 -1
  96. package/dist/src/view/client/assets/{wardleyDiagram-VM6X3IG4-C_diPEka.js → wardleyDiagram-VM6X3IG4-C0yXcU2x.js} +1 -1
  97. package/dist/src/view/client/assets/{xychartDiagram-S5SC5T6Z-C8uIQy_I.js → xychartDiagram-S5SC5T6Z-RPmkPNfK.js} +1 -1
  98. package/dist/src/view/client/index.html +2 -2
  99. package/dist/src/view/highlight.js +2 -0
  100. package/dist/src/view/markdown.js +15 -0
  101. package/dist/src/view/protocol.d.ts +7 -1
  102. package/dist/src/view/references.js +3 -3
  103. package/dist/src/view/repository.js +10 -2
  104. package/dist/src/view/search-result.js +7 -2
  105. package/dist/src/view/server-build.js +1 -1
  106. package/dist/src/view/server-deployment.js +2 -1
  107. package/dist/src/view/server-index-worker.js +1 -1
  108. package/dist/src/view/source-target.d.ts +2 -0
  109. package/dist/src/view/source-target.js +24 -0
  110. package/dist/src/view/store.js +3 -3
  111. package/dist/src/view/vercel-build.js +7 -0
  112. package/package.json +6 -3
  113. package/templates/AGENTS.md +1 -1
  114. package/templates/cursor-rules.md +1 -1
  115. package/templates/skill/SKILL.md +1 -1
  116. package/dist/src/view/client/assets/channel-C-byJRR7.js +0 -1
  117. package/dist/src/view/client/assets/classDiagram-ZZMXUADV-FlqGrEwv.js +0 -1
  118. package/dist/src/view/client/assets/classDiagram-v2-VYDZK3BY-FlqGrEwv.js +0 -1
  119. package/dist/src/view/client/assets/index-DPuOWFj2.js +0 -10
  120. package/dist/src/view/client/assets/stateDiagram-v2-MP3YSRHH-BePfayWI.js +0 -1
  121. package/dist/src/view/client/assets/swimlanesDiagram-VR7AAH4N-DaqCEguT.js +0 -8
package/README.md CHANGED
@@ -103,7 +103,7 @@ Alternatively, install `@plebeiantech/lat.md` via npm/mise, run `lat init` in yo
103
103
 
104
104
  ## How it works
105
105
 
106
- Run `lat init` to scaffold a `lat.md/` directory, then write markdown files describing your architecture, business logic, test specs — whatever matters. Link between sections using `[[file#Section#Subsection]]` syntax. Link to source code symbols with `[[src/auth.ts#validateToken]]`. Annotate source code with `// @lat: [[section-id]]` (or `# @lat: [[section-id]]` in Python) comments to tie implementation back to concepts.
106
+ Run `lat init` to scaffold a `lat.md/` directory, then write markdown files describing your architecture, business logic, test specs — whatever matters. Link between sections using `[[file#Section#Subsection]]` syntax. Link to source code symbols with `[[src/auth.ts#validateToken]]`. Annotate source code with `// @lat: [[section-id]]` (or `# @lat: [[section-id]]` in Python and PHP) comments to tie implementation back to concepts.
107
107
 
108
108
  ```
109
109
  my-project/
@@ -8,7 +8,7 @@ import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { Command, InvalidArgumentError } from 'commander';
10
10
  import { resolveCheckContext, resolveContext } from './context.js';
11
- import { DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_THRESHOLD, } from '../search/search.js';
11
+ import { DEFAULT_SEARCH_LIMIT, DEFAULT_MIN_SIMILARITY, } from '../search/search.js';
12
12
  function parsePort(value) {
13
13
  if (!/^\d+$/.test(value)) {
14
14
  throw new InvalidArgumentError('port must be an integer from 1 to 65535');
@@ -34,7 +34,7 @@ function configureUiRun(command) {
34
34
  function parseSimilarityThreshold(value) {
35
35
  const threshold = Number(value);
36
36
  if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
37
- throw new InvalidArgumentError('threshold must be a number from 0 to 1');
37
+ throw new InvalidArgumentError('min-similarity must be a number from 0 to 1');
38
38
  }
39
39
  return threshold;
40
40
  }
@@ -339,11 +339,16 @@ program
339
339
  });
340
340
  program
341
341
  .command('search')
342
- .description('Semantic search across lat.md sections')
342
+ .description('Hybrid search across lat.md sections')
343
343
  .argument('[query]', 'search query in plain English')
344
344
  .option('--limit <n>', `max results (default: ${DEFAULT_SEARCH_LIMIT})`, String(DEFAULT_SEARCH_LIMIT))
345
- .option('--debug', 'show result similarity scores')
346
- .option('--threshold <score>', `minimum cosine similarity score (default: ${DEFAULT_SEARCH_THRESHOLD})`, parseSimilarityThreshold)
345
+ .option('--preview <variant>', 'preview passage, intro, or both', (value) => {
346
+ if (!['passage', 'intro', 'both'].includes(value))
347
+ throw new InvalidArgumentError('preview must be passage, intro, or both');
348
+ return value;
349
+ }, 'passage')
350
+ .option('--debug', 'show retrieval scores and candidate diagnostics')
351
+ .option('--min-similarity <score>', `minimum cosine similarity score (default: ${DEFAULT_MIN_SIMILARITY})`, parseSimilarityThreshold)
347
352
  .action(async (query, opts) => {
348
353
  const ctx = resolveContext(program.opts());
349
354
  const { searchCommand, cliProgress } = await import('./search.js');
@@ -351,7 +356,8 @@ program
351
356
  const result = await searchCommand(ctx, query, {
352
357
  limit: parseInt(opts.limit),
353
358
  debug: opts.debug,
354
- threshold: opts.threshold,
359
+ minSimilarity: opts.minSimilarity,
360
+ preview: opts.preview,
355
361
  }, progress);
356
362
  handleResult(result);
357
363
  });
@@ -8,7 +8,7 @@ import { findTemplatesDir } from './templates.js';
8
8
  import { readAgentsTemplate, readCursorRulesTemplate, readPiExtensionTemplate, readOpenCodePluginTemplate, readSkillTemplate, } from './gen.js';
9
9
  import { getLlmKey, getRepoEmbedding, setRepoEmbedding } from '../config.js';
10
10
  import { makeStyler } from './context.js';
11
- import { closeDb, ensureMeta, getStoredModel, openDb } from '../search/db.js';
11
+ import { closeDb, getStoredModel, openDb } from '../search/db.js';
12
12
  import { modelKey } from '../search/embedder.js';
13
13
  import { reindexCommand } from './reindex.js';
14
14
  import { INIT_VERSION, writeInitMeta, readInitVersion, readFileHash, contentHash, } from '../init-version.js';
@@ -668,11 +668,10 @@ async function setupCodex(root, latDir, hashes, ask, style) {
668
668
  ' Run /hooks in Codex to review and trust the project hooks.');
669
669
  }
670
670
  async function readStoredEmbeddingModel(latDir) {
671
- if (!existsSync(join(latDir, '.cache', 'vectors.db')))
671
+ if (!existsSync(join(latDir, '.cache', 'search.db')))
672
672
  return null;
673
- const db = openDb(latDir);
673
+ const db = openDb(latDir, undefined, true);
674
674
  try {
675
- await ensureMeta(db);
676
675
  return await getStoredModel(db);
677
676
  }
678
677
  finally {
@@ -1,5 +1,6 @@
1
+ import { writeIndex } from '../search/cache.js';
1
2
  import readline from 'node:readline/promises';
2
- import { openDb, ensureMeta, setStoredModel, ensureSectionsSchema, dropSections, closeDb, } from '../search/db.js';
3
+ import { ensureMeta, setStoredModel, ensureSectionsSchema, dropSections, } from '../search/db.js';
3
4
  import { embedderFromEnv, localEmbedder, modelKey, EmbeddingAuthError, } from '../search/embedder.js';
4
5
  import { getLlmKey, getRepoEmbedding, setRepoEmbedding } from '../config.js';
5
6
  import { indexSections } from '../search/index.js';
@@ -83,11 +84,9 @@ export async function reindexCommand(ctx, opts) {
83
84
  embedder = await localEmbedder();
84
85
  }
85
86
  }
86
- const db = openDb(ctx.latDir);
87
87
  const interactive = ctx.mode === 'cli' && !!process.stderr.isTTY;
88
- try {
88
+ return writeIndex(ctx.latDir, undefined, true, async (db) => {
89
89
  await ensureMeta(db);
90
- await dropSections(db);
91
90
  await ensureSectionsSchema(db, embedder.dimensions);
92
91
  const label = `Reindexing with ${embedder.name}`;
93
92
  // Interactive terminals get a live progress line driven per embed-chunk
@@ -130,8 +129,5 @@ export async function reindexCommand(ctx, opts) {
130
129
  output: s.green(`Reindexed ${stats.added} sections`) +
131
130
  ` using ${s.cyan(embedder.name)}.`,
132
131
  };
133
- }
134
- finally {
135
- await closeDb(db);
136
- }
132
+ });
137
133
  }
@@ -27,7 +27,7 @@ export declare function runSearch(latDir: string, query: string, limit: number,
27
27
  buildIndex?: boolean;
28
28
  project?: MarkdownProjectAnalysis;
29
29
  sectionById?: ReadonlyMap<string, Section>;
30
- threshold?: number;
30
+ minSimilarity?: number;
31
31
  cacheDir?: string;
32
32
  }): Promise<SearchResult>;
33
33
  /**
@@ -41,5 +41,6 @@ export declare function cliProgress(s: Styler): IndexProgress;
41
41
  export declare function searchCommand(ctx: CmdContext, query: string | undefined, opts: {
42
42
  limit: number;
43
43
  debug?: boolean;
44
- threshold?: number;
44
+ minSimilarity?: number;
45
+ preview?: 'passage' | 'intro' | 'both';
45
46
  }, progress?: IndexProgress): Promise<CmdResult>;
@@ -1,71 +1,55 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { LEXICAL_VERSION } from '../search/lexical.js';
3
+ import { hasIndex } from '../search/db.js';
4
+ import { embeddingFingerprint } from '../search/chunks.js';
5
+ import { writeIndex } from '../search/cache.js';
2
6
  import { dirname, join } from 'node:path';
3
- import { openDb, ensureMeta, getStoredModel, setStoredModel, ensureSectionsSchema, dropSections, closeDb, } from '../search/db.js';
4
- import { getLlmKey } from '../config.js';
7
+ import { openDb, getStoredModel, setStoredModel, ensureSectionsSchema, closeDb, } from '../search/db.js';
5
8
  import { embedderForIndex, modelKey, ReindexRequiredError, EmbeddingAuthError, } from '../search/embedder.js';
6
- import { indexSections } from '../search/index.js';
9
+ import { indexSections, projectFingerprint, } from '../search/index.js';
7
10
  import { searchSections } from '../search/search.js';
8
11
  import { resolveSearchMatches, searchIndexedSections, } from '../search/query.js';
9
12
  import { analyzeMarkdownProject, commandProjectAnalysis, } from '../project-analysis.js';
10
13
  import { formatSectionPreview, formatNavHints } from '../format.js';
11
14
  import { provenanceNote, formatProvenanceNote } from './check-status.js';
12
15
  async function withDb(latDir, progress, project, cacheDir, fn) {
13
- const db = openDb(latDir, cacheDir);
14
- try {
15
- await ensureMeta(db);
16
- const stored = await getStoredModel(db);
17
- // The stored model is authoritative — no silent backend switch. Throws
18
- // ReindexRequiredError if the environment can't serve the stored index.
19
- // Rebuilding / switching backends is `lat reindex`, never `lat search`.
20
- const embedder = await embedderForIndex(stored, latDir);
21
- await ensureSectionsSchema(db, embedder.dimensions);
22
- const countResult = await db.execute('SELECT COUNT(*) as n FROM sections');
23
- let isEmpty = countResult.rows[0].n === 0;
24
- // Legacy cache: a version before the model was recorded left rows behind
25
- // with no `meta.embedding_model`. Those vectors may be a different backend
26
- // (and dimension) than the resolved embedder, and `CREATE TABLE IF NOT
27
- // EXISTS` won't migrate the column width — so drop and rebuild from scratch
28
- // under the resolved backend rather than querying a mismatched table.
29
- if (!stored && !isEmpty) {
30
- await dropSections(db);
31
- await ensureSectionsSchema(db, embedder.dimensions);
32
- isEmpty = true;
33
- }
34
- // If the repo is pinned to local but a key is set, say so — otherwise it
35
- // looks like the key is being silently ignored.
36
- if (isEmpty && embedder.name.startsWith('local:') && process.stderr.isTTY) {
37
- let hasKey = false;
38
- try {
39
- hasKey = !!getLlmKey();
40
- }
41
- catch {
42
- /* key source misconfigured — irrelevant, we're local */
43
- }
44
- if (hasKey) {
45
- process.stderr.write('This repo is configured for local embeddings; ignoring LAT_LLM_KEY.\n');
46
- }
47
- }
48
- progress?.beforeIndex?.(isEmpty);
16
+ if (hasIndex(cacheDir ?? join(latDir, '.cache'))) {
17
+ const db = openDb(latDir, cacheDir, true);
49
18
  try {
50
- const stats = await indexSections(latDir, db, embedder, undefined, project);
51
- // Pin the backend only after a successful index, so a failed build never
52
- // leaves the repo wrongly pinned to an empty index.
53
- if (!stored)
54
- await setStoredModel(db, modelKey(embedder));
55
- progress?.afterIndex?.(stats, isEmpty);
19
+ const stored = await getStoredModel(db);
20
+ const embedder = await embedderForIndex(stored, latDir);
21
+ const metadata = new Map((await db.execute('SELECT key,value FROM meta')).rows.map((row) => [
22
+ row.key,
23
+ row.value,
24
+ ]));
25
+ if (metadata.get('fingerprint') !== embeddingFingerprint(embedder))
26
+ throw new ReindexRequiredError('Search chunking or embedding model changed; run lat reindex.');
27
+ if (stored &&
28
+ metadata.get('lexical_version') === LEXICAL_VERSION &&
29
+ metadata.get('project_hash') === projectFingerprint(project)) {
30
+ progress?.afterIndex?.({
31
+ added: 0,
32
+ updated: 0,
33
+ removed: 0,
34
+ unchanged: project.sections.length,
35
+ }, false);
36
+ return await fn(db, embedder, project);
37
+ }
56
38
  }
57
- catch (err) {
58
- // Failed fresh build → drop the half-created table so the next run is
59
- // truly fresh (re-resolves the backend cleanly) rather than stuck.
60
- if (!stored)
61
- await dropSections(db);
62
- throw err;
39
+ finally {
40
+ await closeDb(db);
63
41
  }
64
- return await fn(db, embedder, project);
65
- }
66
- finally {
67
- await closeDb(db);
68
42
  }
43
+ return writeIndex(latDir, cacheDir, false, async (db, stored) => {
44
+ const embedder = await embedderForIndex(stored, latDir);
45
+ await ensureSectionsSchema(db, embedder.dimensions);
46
+ const isEmpty = (await db.execute('SELECT COUNT(*) AS n FROM sections')).rows[0].n === 0;
47
+ progress?.beforeIndex?.(isEmpty);
48
+ const stats = await indexSections(latDir, db, embedder, undefined, project);
49
+ await setStoredModel(db, modelKey(embedder));
50
+ progress?.afterIndex?.(stats, isEmpty);
51
+ return fn(db, embedder, project);
52
+ });
69
53
  }
70
54
  /**
71
55
  * Run a semantic search across lat.md sections.
@@ -86,7 +70,7 @@ export async function runSearch(latDir, query, limit, progress, opts) {
86
70
  })).sectionById;
87
71
  return searchIndexedSections(latDir, query, limit, sectionById, {
88
72
  cacheDir: opts.cacheDir,
89
- threshold: opts.threshold,
73
+ minSimilarity: opts.minSimilarity,
90
74
  });
91
75
  }
92
76
  const project = opts?.project ??
@@ -94,7 +78,7 @@ export async function runSearch(latDir, query, limit, progress, opts) {
94
78
  executor: 'auto',
95
79
  }));
96
80
  return withDb(latDir, progress, project, opts?.cacheDir, async (db, embedder, analyzed) => {
97
- const results = await searchSections(db, query, embedder, limit, opts?.threshold);
81
+ const results = await searchSections(db, query, embedder, limit, opts?.minSimilarity);
98
82
  return {
99
83
  query,
100
84
  matches: resolveSearchMatches(results, analyzed.sectionById),
@@ -158,7 +142,32 @@ async function formatSearchMatches(ctx, query, matches, opts) {
158
142
  lines.push('');
159
143
  lines.push(formatSectionPreview(ctx, matches[i].section, {
160
144
  reason: matches[i].reason,
161
- score: opts?.showScores ? matches[i].score : undefined,
145
+ spans: opts?.preview === 'intro'
146
+ ? undefined
147
+ : matches[i].evidence?.[0]?.spans,
148
+ score: opts?.showScores ? matches[i].rankScore : undefined,
149
+ text: opts?.preview === 'intro'
150
+ ? undefined
151
+ : matches[i].evidence?.length
152
+ ? (opts?.preview === 'both'
153
+ ? matches[i].section.firstParagraph + '\n\n'
154
+ : '') + matches[i].evidence[0].text
155
+ : undefined,
156
+ debug: opts?.showScores && matches[i].rankScore !== undefined
157
+ ? JSON.stringify({
158
+ lexicalRank: matches[i].lexicalRank,
159
+ semanticRank: matches[i].semanticRank,
160
+ lexicalScore: matches[i].lexicalScore,
161
+ semanticSimilarity: matches[i].semanticSimilarity,
162
+ lexicalContribution: matches[i].lexicalRank
163
+ ? 1 / (60 + matches[i].lexicalRank)
164
+ : 0,
165
+ semanticContribution: matches[i].semanticRank
166
+ ? 1 / (60 + matches[i].semanticRank)
167
+ : 0,
168
+ ...matches[i].diagnostics,
169
+ })
170
+ : undefined,
162
171
  }));
163
172
  const note = notes.get(matches[i].section.filePath);
164
173
  if (note)
@@ -177,7 +186,7 @@ export async function searchCommand(ctx, query, opts, progress) {
177
186
  const project = await commandProjectAnalysis(ctx);
178
187
  const result = await runSearch(ctx.latDir, query, opts.limit, progress, {
179
188
  project,
180
- threshold: opts.threshold,
189
+ minSimilarity: opts.minSimilarity,
181
190
  });
182
191
  if (result.matches.length === 0) {
183
192
  return { output: 'No results found.' };
@@ -185,6 +194,7 @@ export async function searchCommand(ctx, query, opts, progress) {
185
194
  return {
186
195
  output: (await formatSearchMatches(ctx, query, result.matches, {
187
196
  showScores: opts.debug,
197
+ preview: opts.preview ?? 'passage',
188
198
  })) + formatNavHints(ctx),
189
199
  };
190
200
  }
@@ -1,11 +1,16 @@
1
+ import type { SourceSpan } from './search/types.js';
1
2
  import type { Section, SectionMatch } from './lattice-model.js';
2
3
  import type { CmdContext, Styler } from './context.js';
3
4
  export declare function formatSectionId(id: string, s: Styler): string;
4
5
  export declare function formatSectionPreview(ctx: CmdContext, section: Section, opts?: {
5
6
  reason?: string;
6
7
  score?: number;
8
+ text?: string;
9
+ debug?: string;
10
+ spans?: SourceSpan[];
7
11
  }): string;
8
12
  export declare function formatResultList(ctx: CmdContext, header: string, matches: SectionMatch[], opts?: {
9
13
  showScores?: boolean;
14
+ preview?: 'passage' | 'intro' | 'both';
10
15
  }): string;
11
16
  export declare function formatNavHints(ctx: CmdContext): string;
@@ -12,6 +12,7 @@ export function formatSectionPreview(ctx, section, opts) {
12
12
  const kind = section.id.includes('#') ? 'Section' : 'File';
13
13
  const details = [
14
14
  opts?.reason,
15
+ opts?.debug,
15
16
  opts?.score === undefined ? undefined : `score: ${opts.score.toFixed(6)}`,
16
17
  ].filter((detail) => detail !== undefined);
17
18
  const detailsSuffix = details.length > 0 ? ' ' + s.dim(`(${details.join(', ')})`) : '';
@@ -19,9 +20,11 @@ export function formatSectionPreview(ctx, section, opts) {
19
20
  `${s.dim('*')} ${s.dim(kind + ':')} [[${formatSectionId(section.id, s)}]]${detailsSuffix}`,
20
21
  ` ${s.dim('Defined in')} ${s.cyan(relPath)}${s.dim(`:${section.startLine}-${section.endLine}`)}`,
21
22
  ];
22
- if (section.firstParagraph) {
23
- lines.push('', ` ${s.dim('>')} ${section.firstParagraph}`);
24
- }
23
+ if (opts?.spans?.length)
24
+ lines.push(` ${s.dim('Matched lines:')} ${opts.spans.map((span) => `${span.startLine}-${span.endLine}`).join(', ')}`);
25
+ const text = opts?.text ?? section.firstParagraph;
26
+ if (text)
27
+ lines.push('', ...text.split('\n').map((line) => ` ${s.dim('>')} ${line}`));
25
28
  return lines.join('\n');
26
29
  }
27
30
  export function formatResultList(ctx, header, matches, opts) {
@@ -31,7 +34,32 @@ export function formatResultList(ctx, header, matches, opts) {
31
34
  lines.push('');
32
35
  lines.push(formatSectionPreview(ctx, matches[i].section, {
33
36
  reason: matches[i].reason,
34
- score: opts?.showScores ? matches[i].score : undefined,
37
+ spans: opts?.preview === 'intro'
38
+ ? undefined
39
+ : matches[i].evidence?.[0]?.spans,
40
+ score: opts?.showScores ? matches[i].rankScore : undefined,
41
+ text: opts?.preview === 'intro'
42
+ ? undefined
43
+ : matches[i].evidence?.length
44
+ ? (opts?.preview === 'both'
45
+ ? matches[i].section.firstParagraph + '\n\n'
46
+ : '') + matches[i].evidence[0].text
47
+ : undefined,
48
+ debug: opts?.showScores && matches[i].rankScore !== undefined
49
+ ? JSON.stringify({
50
+ lexicalRank: matches[i].lexicalRank,
51
+ semanticRank: matches[i].semanticRank,
52
+ lexicalScore: matches[i].lexicalScore,
53
+ semanticSimilarity: matches[i].semanticSimilarity,
54
+ lexicalContribution: matches[i].lexicalRank
55
+ ? 1 / (60 + matches[i].lexicalRank)
56
+ : 0,
57
+ semanticContribution: matches[i].semanticRank
58
+ ? 1 / (60 + matches[i].semanticRank)
59
+ : 0,
60
+ ...matches[i].diagnostics,
61
+ })
62
+ : undefined,
35
63
  }));
36
64
  }
37
65
  lines.push('');
@@ -1,3 +1,4 @@
1
+ import type { SearchEvidence, SearchDiagnostics } from './search/types.js';
1
2
  import type { ForkFrontmatter } from './fork/frontmatter-fields.js';
2
3
  export type Section = {
3
4
  id: string;
@@ -72,6 +73,12 @@ export declare function resolveRef(target: string, sectionIds: Set<string>, file
72
73
  export type SectionMatch = {
73
74
  section: Section;
74
75
  reason: string;
75
- score?: number;
76
+ rankScore?: number;
77
+ semanticSimilarity?: number;
78
+ lexicalScore?: number;
79
+ semanticRank?: number;
80
+ lexicalRank?: number;
81
+ evidence?: SearchEvidence[];
82
+ diagnostics?: SearchDiagnostics;
76
83
  };
77
84
  export declare function findSections(sections: Section[], query: string): SectionMatch[];
@@ -94,8 +94,11 @@ export function buildSectionSlugIndex(sections) {
94
94
  const levels = [
95
95
  ...stack,
96
96
  {
97
+ id: section.id,
97
98
  depth: section.depth,
98
- literal: section.heading.toLowerCase(),
99
+ literal: section.id
100
+ .slice((stack.at(-1)?.id ?? section.file).length + 1)
101
+ .toLowerCase(),
99
102
  slug: headingSlug.toLowerCase(),
100
103
  },
101
104
  ];
@@ -1,4 +1,4 @@
1
- import type { LatFrontmatter, MdLink, Ref, Section } from './lattice-model.js';
1
+ import { type LatFrontmatter, type MdLink, type Ref, type Section } from './lattice-model.js';
2
2
  import type { Root } from 'mdast';
3
3
  import type { Profiler } from './profiler.js';
4
4
  export { buildFileIndex, buildSectionSlugIndex, findSections, flattenSections, resolveRef, } from './lattice-model.js';
@@ -9,6 +9,6 @@ export type { ForkFrontmatter, FrontmatterProblem, } from './fork/frontmatter-fi
9
9
  export declare function parseFrontmatter(content: string): LatFrontmatter;
10
10
  export declare function parseSections(filePath: string, content: string, projectRoot?: string, tree?: Root): Section[];
11
11
  export declare function loadAllSections(latticeDir: string, projectRoot?: string, profile?: Profiler): Promise<Section[]>;
12
- export declare function extractRefs(filePath: string, content: string, projectRoot?: string, tree?: Root): Ref[];
12
+ export declare function extractRefs(filePath: string, content: string, projectRoot?: string, tree?: Root, sections?: Section[]): Ref[];
13
13
  /** Extract link destinations and undefined reference syntax. */
14
14
  export declare function extractLinks(content: string, tree?: Root): MdLink[];
@@ -6,6 +6,7 @@ import { parse } from './parser.js';
6
6
  import { toPosix } from './path.js';
7
7
  import { visit } from 'unist-util-visit';
8
8
  import { listLatticeFiles } from './project-discovery.js';
9
+ import { flattenSections, } from './lattice-model.js';
9
10
  import { LAT_FIELDS, } from './fork/frontmatter-fields.js';
10
11
  export { buildFileIndex, buildSectionSlugIndex, findSections, flattenSections, resolveRef, } from './lattice-model.js';
11
12
  export { findLatticeDir, findProjectRoot, listLatticeFiles, } from './project-discovery.js';
@@ -56,14 +57,8 @@ export function parseFrontmatter(content) {
56
57
  result.problems = problems;
57
58
  return result;
58
59
  }
59
- function headingText(node) {
60
- return node.children
61
- .filter((c) => c.type === 'text')
62
- .map((c) => c.value)
63
- .join('');
64
- }
65
60
  /** Extract the rendered text GitHub uses as input to its heading slugger. */
66
- function githubHeadingText(node) {
61
+ function headingText(node) {
67
62
  if (!node || typeof node !== 'object')
68
63
  return '';
69
64
  const value = node;
@@ -77,7 +72,7 @@ function githubHeadingText(node) {
77
72
  return value.data?.alias ?? value.value ?? '';
78
73
  }
79
74
  if (value.children) {
80
- return value.children.map(githubHeadingText).join('');
75
+ return value.children.map(headingText).join('');
81
76
  }
82
77
  return '';
83
78
  }
@@ -112,6 +107,8 @@ export function parseSections(filePath, content, projectRoot, tree = parse(conte
112
107
  const stack = [];
113
108
  const flat = [];
114
109
  const slugger = new GithubSlugger();
110
+ const usedIds = new Set();
111
+ const nextSuffix = new Map();
115
112
  visit(tree, 'heading', (node) => {
116
113
  const heading = headingText(node);
117
114
  const depth = node.depth;
@@ -121,7 +118,14 @@ export function parseSections(filePath, content, projectRoot, tree = parse(conte
121
118
  stack.pop();
122
119
  }
123
120
  const parent = stack.length > 0 ? stack[stack.length - 1] : null;
124
- const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
121
+ const baseId = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
122
+ let id = baseId;
123
+ const key = baseId.toLowerCase();
124
+ let suffix = nextSuffix.get(key) ?? 1;
125
+ while (usedIds.has(id.toLowerCase()))
126
+ id = `${baseId}-${suffix++}`;
127
+ nextSuffix.set(key, suffix);
128
+ usedIds.add(id.toLowerCase());
125
129
  const section = {
126
130
  id,
127
131
  heading,
@@ -132,7 +136,7 @@ export function parseSections(filePath, content, projectRoot, tree = parse(conte
132
136
  startLine,
133
137
  endLine: 0,
134
138
  firstParagraph: '',
135
- githubSlug: slugger.slug(githubHeadingText(node)),
139
+ githubSlug: slugger.slug(headingText(node)),
136
140
  };
137
141
  if (parent) {
138
142
  parent.children.push(section);
@@ -190,34 +194,13 @@ export async function loadAllSections(latticeDir, projectRoot = dirname(latticeD
190
194
  }
191
195
  return all;
192
196
  }
193
- export function extractRefs(filePath, content, projectRoot, tree = parse(content)) {
197
+ export function extractRefs(filePath, content, projectRoot, tree = parse(content), sections = parseSections(filePath, content, projectRoot, tree)) {
194
198
  const file = projectRoot
195
199
  ? toPosix(relative(projectRoot, filePath)).replace(/\.md$/, '')
196
200
  : basename(filePath, '.md');
197
201
  const refs = [];
198
- // Build a flat list of sections to determine enclosing section for each wiki link
199
- const flat = [];
200
- visit(tree, 'heading', (node) => {
201
- flat.push({
202
- id: '', // filled below
203
- startLine: node.position.start.line,
204
- });
205
- });
206
- // Re-derive ids using the same logic as parseSections
207
- const stack = [];
208
- let idx = 0;
209
- visit(tree, 'heading', (node) => {
210
- const heading = headingText(node);
211
- const depth = node.depth;
212
- while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
213
- stack.pop();
214
- }
215
- const parent = stack.length > 0 ? stack[stack.length - 1] : null;
216
- const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
217
- flat[idx].id = id;
218
- stack.push({ id, depth });
219
- idx++;
220
- });
202
+ // Reuse canonical identities, including duplicate suffixes and formatted text.
203
+ const flat = flattenSections(sections);
221
204
  visit(tree, 'wikiLink', (node) => {
222
205
  const line = node.position.start.line;
223
206
  // Find enclosing section: last heading with startLine <= link line
@@ -6,6 +6,16 @@ export type MarkdownParagraph = {
6
6
  endLine: number;
7
7
  text: string;
8
8
  };
9
+ /** Source-positioned block facts; never retains the parser AST. */
10
+ export type MarkdownBlock = {
11
+ type: string;
12
+ start: number;
13
+ end: number;
14
+ startLine: number;
15
+ endLine: number;
16
+ language?: string;
17
+ children: MarkdownBlock[];
18
+ };
9
19
  export type MarkdownDestinationLink = {
10
20
  kind: 'image' | 'link';
11
21
  line: number;
@@ -37,6 +47,7 @@ export type MarkdownFileAnalysis = {
37
47
  headingTitles: string[];
38
48
  wikiRefs: Ref[];
39
49
  paragraphs: MarkdownParagraph[];
50
+ blocks: MarkdownBlock[];
40
51
  markdownLinks: MarkdownDestinationLink[];
41
52
  validationLinks: MdLink[];
42
53
  indexEntries: string[];
@@ -5,6 +5,35 @@ import { extractLinks, extractRefs, parseFrontmatter, parseSections, } from './l
5
5
  import { parse } from './parser.js';
6
6
  import { toPosix } from './path.js';
7
7
  import { analyzeLocalMarkdownDiagnostics, } from './markdown-validation.js';
8
+ function extractBlocks(tree) {
9
+ const block = (node) => {
10
+ if (node.position?.start.offset === undefined ||
11
+ node.position?.end.offset === undefined)
12
+ return;
13
+ return {
14
+ type: node.type,
15
+ start: node.position.start.offset,
16
+ end: node.position.end.offset,
17
+ startLine: node.position.start.line,
18
+ endLine: node.position.end.line,
19
+ ...('lang' in node && node.lang ? { language: node.lang } : {}),
20
+ children: [
21
+ 'list',
22
+ 'listItem',
23
+ 'blockquote',
24
+ 'table',
25
+ 'tableRow',
26
+ ].includes(node.type)
27
+ ? ('children' in node ? node.children : [])
28
+ .map(block)
29
+ .filter((item) => !!item)
30
+ : [],
31
+ };
32
+ };
33
+ return tree.children
34
+ .map(block)
35
+ .filter((item) => !!item);
36
+ }
8
37
  function elapsed(work) {
9
38
  const start = performance.now();
10
39
  return [work(), performance.now() - start];
@@ -96,7 +125,7 @@ function extractIndexEntries(content) {
96
125
  export function analyzeMarkdownFile(absolutePath, content, latDir, projectRoot) {
97
126
  const [tree, parseMs] = elapsed(() => parse(content));
98
127
  const [sections, sectionsMs] = elapsed(() => parseSections(absolutePath, content, projectRoot, tree));
99
- const [wikiRefs, refsMs] = elapsed(() => extractRefs(absolutePath, content, projectRoot, tree));
128
+ const [wikiRefs, refsMs] = elapsed(() => extractRefs(absolutePath, content, projectRoot, tree, sections));
100
129
  const [validationLinks, linksMs] = elapsed(() => extractLinks(content, tree));
101
130
  const [[paragraphs, markdownLinks, headingTitles], paragraphsMs] = elapsed(() => [
102
131
  extractParagraphs(content, tree),
@@ -116,6 +145,7 @@ export function analyzeMarkdownFile(absolutePath, content, latDir, projectRoot)
116
145
  headingTitles,
117
146
  wikiRefs,
118
147
  paragraphs,
148
+ blocks: extractBlocks(tree),
119
149
  markdownLinks,
120
150
  validationLinks,
121
151
  indexEntries,
@@ -7,7 +7,7 @@ import { plainStyler } from '../context.js';
7
7
  import { locateCommand } from '../cli/locate.js';
8
8
  import { sectionCommand } from '../cli/section.js';
9
9
  import { searchCommand } from '../cli/search.js';
10
- import { DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_THRESHOLD, } from '../search/search.js';
10
+ import { DEFAULT_SEARCH_LIMIT, DEFAULT_MIN_SIMILARITY, } from '../search/search.js';
11
11
  import { expandCommand } from '../cli/expand.js';
12
12
  import { checkAllCommand } from '../cli/check.js';
13
13
  import { refsCommand } from '../cli/refs.js';
@@ -40,21 +40,21 @@ export async function startMcpServer() {
40
40
  .string()
41
41
  .describe('Local section id or exact handle:path#fragment target'),
42
42
  }, async ({ query }) => toMcp(await sectionCommand(requestContext(), query)));
43
- server.tool('lat_search', 'Semantic search across lat.md sections using embeddings', {
43
+ server.tool('lat_search', 'Hybrid lexical and semantic search across lat.md sections', {
44
44
  query: z.string().describe('Search query in natural language'),
45
45
  limit: z
46
46
  .number()
47
47
  .optional()
48
48
  .default(DEFAULT_SEARCH_LIMIT)
49
49
  .describe(`Max results (default ${DEFAULT_SEARCH_LIMIT})`),
50
- threshold: z
50
+ minSimilarity: z
51
51
  .number()
52
52
  .min(0)
53
53
  .max(1)
54
54
  .optional()
55
- .default(DEFAULT_SEARCH_THRESHOLD)
56
- .describe('Minimum cosine similarity score (default 0.35)'),
57
- }, async ({ query, limit, threshold }) => toMcp(await searchCommand(requestContext(), query, { limit, threshold })));
55
+ .default(DEFAULT_MIN_SIMILARITY)
56
+ .describe('Minimum cosine similarity score (default 0.20)'),
57
+ }, async ({ query, limit, minSimilarity }) => toMcp(await searchCommand(requestContext(), query, { limit, minSimilarity })));
58
58
  server.tool('lat_expand', 'Expand [[refs]] in text to resolved lat.md section paths with context', { text: z.string().describe('Text containing [[refs]] to expand') }, async ({ text: input }) => toMcp(await expandCommand(requestContext(), input)));
59
59
  server.tool('lat_check', 'Run full lat.md validation: links, code references, indexes, and section structure', {}, async () => toMcp(await checkAllCommand(requestContext())));
60
60
  server.tool('lat_refs', 'Find sections that reference a given section via wiki links or @lat code comments', {