@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
@@ -1,85 +1,189 @@
1
- import { createHash } from 'node:crypto';
2
- import { dirname, join } from 'node:path';
1
+ import { PARSER_CACHE_VERSION } from '../parser-cache.js';
2
+ import { synchronizeLexical } from './lexical.js';
3
+ import { dirname } from 'node:path';
4
+ import { CREATE_PASSAGE_FTS } from './db.js';
3
5
  import { analyzeMarkdownProject, } from '../project-analysis.js';
4
- function hashContent(text) {
5
- return createHash('sha256').update(text).digest('hex');
6
+ import { chunkFile, digest, embeddingFingerprint } from './chunks.js';
7
+ export function projectFingerprint(project) {
8
+ return digest(`parser:${PARSER_CACHE_VERSION}\n` +
9
+ [...project.files.values()]
10
+ .map((f) => `${f.projectPath}\0${digest(f.content)}`)
11
+ .sort()
12
+ .join('\n'));
6
13
  }
7
- /**
8
- * Read each lat.md file once, keyed by project-relative path. Section text is
9
- * sliced from these lines by range: reading the file per section instead is
10
- * O(sections × file size) — a 3.5 MB file holding 12k sections was re-read 12k
11
- * times (~42 GB of string work) on every search.
12
- */
13
- function loadFileLines(sections, projectRoot, project) {
14
- const lines = new Map();
15
- for (const { filePath } of sections) {
16
- if (lines.has(filePath))
17
- continue;
18
- const content = project.filesByAbsolutePath.get(join(projectRoot, filePath))?.content;
19
- if (content === undefined)
20
- throw new Error(`Analyzed Markdown file not found: ${filePath}`);
21
- lines.set(filePath, content.split('\n'));
22
- }
23
- return lines;
24
- }
25
- function sectionContent(section, lines) {
26
- return lines
27
- .get(section.filePath)
28
- .slice(section.startLine - 1, section.endLine)
29
- .join('\n');
14
+ /** Exact identifier tokens complement FTS's punctuation and long-token handling. */
15
+ export function identifierTokens(text) {
16
+ return [
17
+ ...new Set((text.match(/[\p{L}\p{N}_][\p{L}\p{N}_./:#@-]*/gu) ?? [])
18
+ .map((t) => t.toLowerCase())
19
+ .filter((t) => /[_./:#@-]/.test(t) || t.length >= 40)),
20
+ ];
30
21
  }
31
22
  export async function indexSections(latDir, db, embedder, onProgress, analyzedProject) {
32
- const projectRoot = dirname(latDir);
33
23
  const project = analyzedProject ??
34
- (await analyzeMarkdownProject(latDir, projectRoot, { executor: 'auto' }));
35
- const flat = project.sections;
36
- // Build current state: id -> { section, content, hash }
37
- const current = new Map();
38
- const lines = loadFileLines(flat, projectRoot, project);
39
- for (const s of flat) {
40
- const text = sectionContent(s, lines);
41
- current.set(s.id, { section: s, content: text, hash: hashContent(text) });
24
+ (await analyzeMarkdownProject(latDir, dirname(latDir), {
25
+ executor: 'auto',
26
+ }));
27
+ const fingerprint = embeddingFingerprint(embedder);
28
+ const projectHash = projectFingerprint(project);
29
+ const oldFingerprint = (await db.execute("SELECT value FROM meta WHERE key='fingerprint'")).rows[0]?.value;
30
+ if (oldFingerprint && oldFingerprint !== fingerprint)
31
+ throw new Error('Search chunking or embedding model changed; run lat reindex.');
32
+ const byFile = new Map();
33
+ for (const s of project.sections) {
34
+ const list = byFile.get(s.filePath) ?? [];
35
+ list.push(s);
36
+ byFile.set(s.filePath, list);
37
+ }
38
+ const files = new Map([...project.files.values()].map((f) => [f.projectPath, f]));
39
+ const passages = [...byFile].flatMap(([path, sections]) => {
40
+ const file = files.get(path);
41
+ if (!file)
42
+ throw new Error(`Missing analyzed file: ${path}`);
43
+ return chunkFile(file, sections, embedder);
44
+ });
45
+ const existing = new Map((await db.execute('SELECT id,content_hash FROM sections')).rows.map((r) => [
46
+ r.id,
47
+ r.content_hash,
48
+ ]));
49
+ const sectionHashes = new Map();
50
+ const owned = new Map();
51
+ for (const p of passages) {
52
+ const list = owned.get(p.sectionId) ?? [];
53
+ list.push(p);
54
+ owned.set(p.sectionId, list);
42
55
  }
43
- // Get existing hashes from DB
44
- const existing = new Map();
45
- const rows = await db.execute('SELECT id, content_hash FROM sections');
46
- for (const row of rows.rows) {
47
- existing.set(row.id, row.content_hash);
56
+ for (const s of project.sections) {
57
+ sectionHashes.set(s.id, digest(JSON.stringify([
58
+ { ...s, children: undefined },
59
+ (owned.get(s.id) ?? []).map((p) => [p.inputHash, p.spans]),
60
+ ])));
48
61
  }
49
- // Partition into new, changed, unchanged, deleted
50
- const toEmbed = [];
51
- let unchanged = 0;
52
- for (const [id, entry] of current) {
53
- const existingHash = existing.get(id);
54
- if (existingHash === entry.hash) {
55
- unchanged++;
62
+ const changed = new Set([...sectionHashes]
63
+ .filter(([id, hash]) => existing.get(id) !== hash)
64
+ .map(([id]) => id));
65
+ const removed = [...existing.keys()].filter((id) => !sectionHashes.has(id));
66
+ const stats = {
67
+ added: [...changed].filter((id) => !existing.has(id)).length,
68
+ updated: [...changed].filter((id) => existing.has(id)).length,
69
+ removed: removed.length,
70
+ unchanged: project.sections.length - changed.size,
71
+ };
72
+ if (!changed.size && !removed.length && oldFingerprint === fingerprint) {
73
+ // Lexical maintenance can repair old generations without embedding work.
74
+ await db.execute('BEGIN');
75
+ try {
76
+ await synchronizeLexical(db);
77
+ await db.execute({
78
+ sql: "UPDATE meta SET value=? WHERE key='project_hash' AND value<>?",
79
+ args: [projectHash, projectHash],
80
+ });
81
+ await db.execute('COMMIT');
56
82
  }
57
- else {
58
- toEmbed.push({ id, content: entry.content, section: entry.section });
83
+ catch (error) {
84
+ await db.execute('ROLLBACK');
85
+ throw error;
59
86
  }
87
+ return stats;
60
88
  }
61
- const toDelete = [...existing.keys()].filter((id) => !current.has(id));
62
- // Embed new/changed sections
63
- if (toEmbed.length > 0) {
64
- const texts = toEmbed.map((e) => e.content);
65
- const vectors = await embedder.embed(texts, onProgress);
66
- const now = Date.now();
67
- for (let i = 0; i < toEmbed.length; i++) {
68
- const { id, content, section } = toEmbed[i];
69
- const hash = current.get(id).hash;
70
- const vecJson = JSON.stringify(vectors[i]);
89
+ const storedHashes = new Set((await db.execute('SELECT hash FROM embeddings')).rows.map((r) => r.hash));
90
+ const missing = new Map(passages
91
+ .filter((p) => !storedHashes.has(p.inputHash))
92
+ .map((p) => [p.inputHash, p.input]));
93
+ const entries = [...missing];
94
+ const vectors = entries.length
95
+ ? await embedder.embed(entries.map(([, input]) => input), onProgress)
96
+ : [];
97
+ if (vectors.length !== entries.length ||
98
+ vectors.some((v) => v.length !== embedder.dimensions || v.some((n) => !Number.isFinite(n))))
99
+ throw new Error('Embedding backend returned invalid vectors');
100
+ const rebuildFts = !existing.size ||
101
+ // Tantivy retains deleted versions in BM25 statistics until rebuilt.
102
+ stats.updated > 0 ||
103
+ removed.length > 0 ||
104
+ passages.filter((p) => changed.has(p.sectionId)).length > 512;
105
+ await db.execute('BEGIN');
106
+ try {
107
+ if (rebuildFts)
108
+ await db.execute('DROP INDEX IF EXISTS chunks_fts');
109
+ for (let i = 0; i < entries.length; i++)
110
+ await db.execute({
111
+ sql: 'INSERT INTO embeddings VALUES (?,vector32(?))',
112
+ args: [entries[i][0], JSON.stringify(vectors[i])],
113
+ });
114
+ for (const id of [...changed, ...removed]) {
115
+ await db.execute({
116
+ sql: 'DELETE FROM lexical_chunks WHERE id IN (SELECT id FROM chunks WHERE section_id=?)',
117
+ args: [id],
118
+ });
119
+ await db.execute({
120
+ sql: 'DELETE FROM identifiers WHERE chunk_id IN (SELECT id FROM chunks WHERE section_id=?)',
121
+ args: [id],
122
+ });
71
123
  await db.execute({
72
- sql: `INSERT OR REPLACE INTO sections (id, file, heading, content, content_hash, embedding, updated_at)
73
- VALUES (?, ?, ?, ?, ?, vector(?), ?)`,
74
- args: [id, section.file, section.heading, content, hash, vecJson, now],
124
+ sql: 'DELETE FROM chunks WHERE section_id=?',
125
+ args: [id],
75
126
  });
127
+ await db.execute({ sql: 'DELETE FROM sections WHERE id=?', args: [id] });
76
128
  }
129
+ const parents = new Map();
130
+ for (const s of project.sections)
131
+ for (const child of s.children)
132
+ parents.set(child.id, s.id);
133
+ for (const s of project.sections)
134
+ if (changed.has(s.id))
135
+ await db.execute({
136
+ sql: 'INSERT INTO sections VALUES (?,?,?,?,?,?,?,?)',
137
+ args: [
138
+ s.id,
139
+ s.file,
140
+ s.heading,
141
+ s.firstParagraph,
142
+ sectionHashes.get(s.id),
143
+ parents.get(s.id) ?? null,
144
+ s.startLine,
145
+ s.endLine,
146
+ ],
147
+ });
148
+ for (const p of passages)
149
+ if (changed.has(p.sectionId)) {
150
+ const row = (await db.execute({
151
+ sql: 'INSERT INTO chunks(source_id,section_id,ordinal,type,spans,body,heading,path,input_hash) VALUES (?,?,?,?,?,?,?,?,?) RETURNING id',
152
+ args: [
153
+ p.id,
154
+ p.sectionId,
155
+ p.ordinal,
156
+ p.type,
157
+ JSON.stringify(p.spans),
158
+ p.text,
159
+ p.heading,
160
+ p.path,
161
+ p.inputHash,
162
+ ],
163
+ })).rows[0];
164
+ for (const token of identifierTokens(`${p.sectionId}\n${project.sectionById.get(p.sectionId.toLowerCase())?.filePath ?? ''}\n${p.heading}\n${p.path}\n${p.text}`))
165
+ await db.execute({
166
+ sql: 'INSERT INTO identifiers VALUES (?,?)',
167
+ args: [token, row.id],
168
+ });
169
+ }
170
+ await db.execute('DELETE FROM embeddings WHERE hash NOT IN (SELECT input_hash FROM chunks)');
171
+ await db.execute({
172
+ sql: 'INSERT OR REPLACE INTO meta VALUES (?,?)',
173
+ args: ['fingerprint', fingerprint],
174
+ });
175
+ await db.execute({
176
+ sql: 'INSERT OR REPLACE INTO meta VALUES (?,?)',
177
+ args: ['project_hash', projectHash],
178
+ });
179
+ await synchronizeLexical(db);
180
+ if (rebuildFts)
181
+ await db.execute(CREATE_PASSAGE_FTS);
182
+ await db.execute('COMMIT');
77
183
  }
78
- // Delete removed sections
79
- for (const id of toDelete) {
80
- await db.execute({ sql: 'DELETE FROM sections WHERE id = ?', args: [id] });
184
+ catch (error) {
185
+ await db.execute('ROLLBACK');
186
+ throw error;
81
187
  }
82
- const added = toEmbed.filter((e) => !existing.has(e.id)).length;
83
- const updated = toEmbed.filter((e) => existing.has(e.id)).length;
84
- return { added, updated, removed: toDelete.length, unchanged };
188
+ return stats;
85
189
  }
@@ -0,0 +1,6 @@
1
+ import { type SearchDb } from './db.js';
2
+ export declare const LEXICAL_VERSION = "snowball-english-rust-stemmers-1.2.0-v1:unicode-words-v1:live-statistics-v1";
3
+ /** Same analysis for indexed fields and queries; exact identifiers bypass it. */
4
+ export declare function lexicalTokens(text: string): string[];
5
+ /** Populate derived lexical rows, reusing original passages and vectors. */
6
+ export declare function synchronizeLexical(db: SearchDb): Promise<void>;
@@ -0,0 +1,36 @@
1
+ import { stemWords, STEMMER_VERSION } from '@lat.md/stemmer';
2
+ import { CREATE_PASSAGE_FTS } from './db.js';
3
+ export const LEXICAL_VERSION = `${STEMMER_VERSION}:unicode-words-v1:live-statistics-v1`;
4
+ /** Same analysis for indexed fields and queries; exact identifiers bypass it. */
5
+ export function lexicalTokens(text) {
6
+ const tokens = text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
7
+ const english = tokens.filter((token) => /^[a-z]+$/.test(token));
8
+ const stems = stemWords(english);
9
+ let i = 0;
10
+ return tokens.map((token) => (/^[a-z]+$/.test(token) ? stems[i++] : token));
11
+ }
12
+ /** Populate derived lexical rows, reusing original passages and vectors. */
13
+ export async function synchronizeLexical(db) {
14
+ const version = (await db.execute("SELECT value FROM meta WHERE key='lexical_version'")).rows[0]?.value;
15
+ if (version !== LEXICAL_VERSION) {
16
+ await db.execute('DROP INDEX IF EXISTS chunks_fts');
17
+ await db.execute('DELETE FROM lexical_chunks');
18
+ }
19
+ await db.execute('DELETE FROM lexical_chunks WHERE id NOT IN (SELECT id FROM chunks)');
20
+ const rows = (await db.execute('SELECT id,body,heading,path FROM chunks WHERE id NOT IN (SELECT id FROM lexical_chunks)')).rows;
21
+ for (const row of rows)
22
+ await db.execute({
23
+ sql: 'INSERT INTO lexical_chunks VALUES (?,?,?,?)',
24
+ args: [
25
+ row.id,
26
+ ...[row.body, row.heading, row.path].map((text) => lexicalTokens(text).join(' ')),
27
+ ],
28
+ });
29
+ if (version !== LEXICAL_VERSION) {
30
+ await db.execute(CREATE_PASSAGE_FTS);
31
+ await db.execute({
32
+ sql: 'INSERT OR REPLACE INTO meta VALUES (?,?)',
33
+ args: ['lexical_version', LEXICAL_VERSION],
34
+ });
35
+ }
36
+ }
@@ -0,0 +1,2 @@
1
+ /** The persistent file must never be unlinked: all writers lock the same inode. */
2
+ export declare function acquireSearchLock(cacheDir: string, timeoutMs?: number): Promise<() => Promise<void>>;
@@ -0,0 +1,23 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { setTimeout } from 'node:timers/promises';
4
+ /** The persistent file must never be unlinked: all writers lock the same inode. */
5
+ export async function acquireSearchLock(cacheDir, timeoutMs = 120_000) {
6
+ const { tryLock } = await import('fs-native-extensions');
7
+ const handle = await open(join(cacheDir, 'search-write.lock'), 'a+');
8
+ const deadline = performance.now() + timeoutMs;
9
+ try {
10
+ while (!tryLock(handle.fd)) {
11
+ const remaining = deadline - performance.now();
12
+ if (remaining <= 0)
13
+ throw new Error('Search index writer is busy; retry shortly.');
14
+ await setTimeout(Math.min(100, remaining));
15
+ }
16
+ // Closing releases the kernel lock, including on abnormal process exit.
17
+ return () => handle.close();
18
+ }
19
+ catch (error) {
20
+ await handle.close();
21
+ throw error;
22
+ }
23
+ }
@@ -6,7 +6,7 @@ export type IndexedSearchResult = {
6
6
  matches: SectionMatch[];
7
7
  };
8
8
  export type IndexedSearchSession = {
9
- search: (query: string, limit: number, threshold?: number) => Promise<SearchResult[]>;
9
+ search: (query: string, limit: number, minSimilarity?: number) => Promise<SearchResult[]>;
10
10
  close: () => Promise<void>;
11
11
  };
12
12
  /** Resolve scored index rows against one already-analyzed project snapshot. */
@@ -19,5 +19,5 @@ export declare function openIndexedSearchSession(latDir: string, options?: {
19
19
  /** Query a finished index and resolve its ids from precomputed section data. */
20
20
  export declare function searchIndexedSections(latDir: string, query: string, limit: number, sectionById: ReadonlyMap<string, Section>, options?: {
21
21
  cacheDir?: string;
22
- threshold?: number;
22
+ minSimilarity?: number;
23
23
  }): Promise<IndexedSearchResult>;
@@ -1,4 +1,9 @@
1
- import { closeDb, ensureMeta, ensureSectionsSchema, getStoredModel, openDb, } from './db.js';
1
+ import { LEXICAL_VERSION } from './lexical.js';
2
+ import { ReindexRequiredError } from './embedder.js';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { hasIndex } from './db.js';
6
+ import { closeDb, getStoredModel, openDb } from './db.js';
2
7
  import { embedderForIndex } from './embedder.js';
3
8
  import { searchSections } from './search.js';
4
9
  /** Resolve scored index rows against one already-analyzed project snapshot. */
@@ -9,8 +14,14 @@ export function resolveSearchMatches(results, sectionById) {
9
14
  ? [
10
15
  {
11
16
  section,
12
- reason: 'semantic match',
13
- score: result.score,
17
+ reason: 'hybrid match',
18
+ rankScore: result.rankScore,
19
+ semanticSimilarity: result.semanticSimilarity,
20
+ lexicalScore: result.lexicalScore,
21
+ semanticRank: result.semanticRank,
22
+ lexicalRank: result.lexicalRank,
23
+ evidence: result.evidence,
24
+ diagnostics: result.diagnostics,
14
25
  },
15
26
  ]
16
27
  : [];
@@ -18,7 +29,10 @@ export function resolveSearchMatches(results, sectionById) {
18
29
  }
19
30
  /** Open one reusable database and embedder for a sequence of index queries. */
20
31
  export async function openIndexedSearchSession(latDir, options = {}) {
21
- const db = openDb(latDir, options.cacheDir);
32
+ const cacheDir = options.cacheDir ?? join(latDir, '.cache');
33
+ if (!existsSync(cacheDir) || !hasIndex(cacheDir))
34
+ return { search: async () => [], close: async () => { } };
35
+ const db = openDb(latDir, options.cacheDir, true);
22
36
  let closed = false;
23
37
  const close = async () => {
24
38
  if (closed)
@@ -27,7 +41,6 @@ export async function openIndexedSearchSession(latDir, options = {}) {
27
41
  await closeDb(db);
28
42
  };
29
43
  try {
30
- await ensureMeta(db);
31
44
  const stored = await getStoredModel(db);
32
45
  if (stored === null) {
33
46
  return {
@@ -39,13 +52,15 @@ export async function openIndexedSearchSession(latDir, options = {}) {
39
52
  close,
40
53
  };
41
54
  }
55
+ const lexicalVersion = (await db.execute("SELECT value FROM meta WHERE key='lexical_version'")).rows[0]?.value;
56
+ if (lexicalVersion !== LEXICAL_VERSION)
57
+ throw new ReindexRequiredError('Search lexical index changed; run lat search to update it.');
42
58
  const embedder = await embedderForIndex(stored, latDir, options.createSearchEngine);
43
- await ensureSectionsSchema(db, embedder.dimensions);
44
59
  return {
45
- async search(query, limit, threshold) {
60
+ async search(query, limit, minSimilarity) {
46
61
  if (closed)
47
62
  throw new Error('Search session is closed');
48
- return searchSections(db, query, embedder, limit, threshold);
63
+ return searchSections(db, query, embedder, limit, minSimilarity);
49
64
  },
50
65
  close,
51
66
  };
@@ -59,7 +74,7 @@ export async function openIndexedSearchSession(latDir, options = {}) {
59
74
  export async function searchIndexedSections(latDir, query, limit, sectionById, options = {}) {
60
75
  const session = await openIndexedSearchSession(latDir, options);
61
76
  try {
62
- const results = await session.search(query, limit, options.threshold);
77
+ const results = await session.search(query, limit, options.minSimilarity);
63
78
  return {
64
79
  query,
65
80
  matches: resolveSearchMatches(results, sectionById),
@@ -1,12 +1,17 @@
1
- import type { Client } from '@libsql/client';
1
+ import type { SearchDb } from './db.js';
2
2
  import type { Embedder } from './embedder.js';
3
+ import type { SearchResult } from './types.js';
4
+ export type { SearchResult, SearchEvidence, SearchDiagnostics, } from './types.js';
3
5
  export declare const DEFAULT_SEARCH_LIMIT = 5;
4
- export declare const DEFAULT_SEARCH_THRESHOLD = 0.35;
5
- export type SearchResult = {
6
- id: string;
7
- file: string;
8
- heading: string;
9
- content: string;
6
+ export declare const DEFAULT_MIN_SIMILARITY = 0.2;
7
+ export type Candidate = {
8
+ id: number;
9
+ sectionId: string;
10
10
  score: number;
11
+ exact?: boolean;
11
12
  };
12
- export declare function searchSections(db: Client, query: string, embedder: Embedder, limit?: number, threshold?: number): Promise<SearchResult[]>;
13
+ export declare function collapse(candidates: Candidate[]): Map<string, Candidate & {
14
+ rank: number;
15
+ }>;
16
+ export declare function literalFtsQuery(query: string): string;
17
+ export declare function searchSections(db: SearchDb, query: string, embedder: Embedder, limit?: number, minSimilarity?: number): Promise<SearchResult[]>;
@@ -1,25 +1,157 @@
1
+ import { lexicalTokens } from './lexical.js';
1
2
  export const DEFAULT_SEARCH_LIMIT = 5;
2
- export const DEFAULT_SEARCH_THRESHOLD = 0.35;
3
- export async function searchSections(db, query, embedder, limit = DEFAULT_SEARCH_LIMIT, threshold = DEFAULT_SEARCH_THRESHOLD) {
4
- const [queryVec] = await embedder.embed([query]);
5
- const vecJson = JSON.stringify(queryVec);
6
- const rows = await db.execute({
7
- sql: `SELECT s.id, s.file, s.heading, s.content,
8
- 1.0 - vector_distance_cos(s.embedding, vector(?)) AS score
9
- FROM vector_top_k('sections_vec_idx', vector(?), ?) AS v
10
- JOIN sections AS s ON s.rowid = v.id
11
- ORDER BY score DESC`,
12
- args: [vecJson, vecJson, limit],
13
- });
14
- const results = rows.rows.map((row) => {
15
- const score = Number(row.score);
16
- return {
17
- id: row.id,
18
- file: row.file,
19
- heading: row.heading,
20
- content: row.content,
21
- score: Number.isFinite(score) ? score : 0,
22
- };
23
- });
24
- return results.filter((result) => result.score >= threshold);
3
+ export const DEFAULT_MIN_SIMILARITY = 0.2;
4
+ export function collapse(candidates) {
5
+ const unique = new Map();
6
+ for (const c of candidates)
7
+ if (!unique.has(c.sectionId))
8
+ unique.set(c.sectionId, c);
9
+ let rank = 0, previous;
10
+ return new Map([...unique].map(([id, c]) => {
11
+ if (!previous ||
12
+ c.score !== previous.score ||
13
+ !!c.exact !== !!previous.exact)
14
+ rank++;
15
+ previous = c;
16
+ return [id, { ...c, rank }];
17
+ }));
18
+ }
19
+ export function literalFtsQuery(query) {
20
+ const stopwords = new Set('a an and are as at be by do does for from how i in is it of on or our that the their this to we what when where which who why with you your use uses used have has had can could would should'.split(' '));
21
+ return (query.match(/[\p{L}\p{N}_]+/gu) ?? [])
22
+ .filter((t) => !stopwords.has(t.toLowerCase()))
23
+ .flatMap((t) => lexicalTokens(t))
24
+ .map((t) => `"${t.replaceAll('"', '\\"')}"`)
25
+ .join(' OR ');
26
+ }
27
+ export async function searchSections(db, query, embedder, limit = DEFAULT_SEARCH_LIMIT, minSimilarity = DEFAULT_MIN_SIMILARITY) {
28
+ query = query.trim();
29
+ if (!query)
30
+ return [];
31
+ if (!Number.isInteger(limit) || limit < 1)
32
+ throw new Error('limit must be a positive integer');
33
+ if (!Number.isFinite(minSimilarity) || minSimilarity < 0 || minSimilarity > 1)
34
+ throw new Error('min-similarity must be a number from 0 to 1');
35
+ if (embedder.countTokens(query) > embedder.maxInputTokens)
36
+ throw new Error('Search query exceeds the embedding model token limit; shorten the query.');
37
+ const [vector] = await embedder.embed([query]);
38
+ if (!vector ||
39
+ vector.length !== embedder.dimensions ||
40
+ vector.some((n) => !Number.isFinite(n)))
41
+ throw new Error('Embedding backend returned an invalid query vector');
42
+ const vectorJson = JSON.stringify(vector), fts = literalFtsQuery(query);
43
+ const target = Math.max(50, limit), cap = 10 * target;
44
+ const owners = new Map((await db.execute('SELECT id,section_id FROM chunks')).rows.map((r) => [
45
+ r.id,
46
+ r.section_id,
47
+ ]));
48
+ const exactRows = (await db.execute({
49
+ sql: 'SELECT chunk_id FROM identifiers WHERE token=? LIMIT ?',
50
+ args: [query.toLowerCase(), cap],
51
+ })).rows;
52
+ const exact = exactRows.map((r) => ({
53
+ id: r.chunk_id,
54
+ sectionId: owners.get(r.chunk_id),
55
+ score: 0,
56
+ exact: true,
57
+ }));
58
+ async function retrieve(channel) {
59
+ let count = 2 * target, candidates = [], capped = false;
60
+ while (true) {
61
+ const rows = channel === 'lexical'
62
+ ? fts
63
+ ? (await db.execute({
64
+ sql: 'SELECT id,fts_score(body,heading,path,?) AS score FROM lexical_chunks ORDER BY score DESC LIMIT ?',
65
+ args: [fts, count],
66
+ })).rows
67
+ : []
68
+ : (await db.execute({
69
+ sql: 'SELECT c.id,1-vector_distance_cos(e.embedding,vector32(?)) AS score FROM chunks c JOIN embeddings e ON c.input_hash=e.hash ORDER BY score DESC LIMIT ?',
70
+ args: [vectorJson, count],
71
+ })).rows;
72
+ const hits = rows
73
+ .filter((r) => channel === 'lexical' ? r.score > 0 : r.score >= minSimilarity)
74
+ .map((r) => ({
75
+ id: r.id,
76
+ sectionId: owners.get(r.id),
77
+ score: Number(r.score),
78
+ }));
79
+ if (channel === 'lexical') {
80
+ const exactIds = new Set(exact.map((c) => c.id));
81
+ candidates = [...exact, ...hits.filter((c) => !exactIds.has(c.id))];
82
+ }
83
+ else
84
+ candidates = hits;
85
+ candidates.sort((a, b) => Number(!!b.exact) - Number(!!a.exact) ||
86
+ b.score - a.score ||
87
+ a.sectionId.localeCompare(b.sectionId) ||
88
+ a.id - b.id);
89
+ if (new Set(candidates.map((c) => c.sectionId)).size >= target ||
90
+ rows.length < count ||
91
+ hits.length < rows.length ||
92
+ !rows.length)
93
+ break;
94
+ if (count >= cap) {
95
+ capped = true;
96
+ break;
97
+ }
98
+ count = Math.min(cap, count * 2);
99
+ }
100
+ return { list: collapse(candidates), capped, count: candidates.length };
101
+ }
102
+ const lexical = await retrieve('lexical'), semantic = await retrieve('semantic');
103
+ const all = new Set([...lexical.list.keys(), ...semantic.list.keys()]);
104
+ const ranked = [...all]
105
+ .map((id) => ({ id, l: lexical.list.get(id), v: semantic.list.get(id) }))
106
+ .map((r) => ({
107
+ ...r,
108
+ rankScore: (r.l ? 1 / (60 + r.l.rank) : 0) + (r.v ? 1 / (60 + r.v.rank) : 0),
109
+ }))
110
+ .sort((a, b) => b.rankScore - a.rankScore || a.id.localeCompare(b.id))
111
+ .slice(0, limit);
112
+ const results = [];
113
+ for (const r of ranked) {
114
+ const s = (await db.execute({
115
+ sql: 'SELECT * FROM sections WHERE id=?',
116
+ args: [r.id],
117
+ })).rows[0];
118
+ if (!s)
119
+ continue;
120
+ const channels = [
121
+ ...(r.l ? [{ candidate: r.l, channel: 'lexical' }] : []),
122
+ ...(r.v ? [{ candidate: r.v, channel: 'semantic' }] : []),
123
+ ].sort((a, b) => a.candidate.rank - b.candidate.rank);
124
+ const seen = new Set(), evidence = [];
125
+ for (const { candidate, channel } of channels)
126
+ if (!seen.has(candidate.id)) {
127
+ seen.add(candidate.id);
128
+ const p = (await db.execute({
129
+ sql: 'SELECT source_id,body,spans FROM chunks WHERE id=?',
130
+ args: [candidate.id],
131
+ })).rows[0];
132
+ evidence.push({
133
+ chunkId: p.source_id,
134
+ text: p.body,
135
+ spans: JSON.parse(p.spans),
136
+ channel,
137
+ });
138
+ }
139
+ results.push({
140
+ id: s.id,
141
+ file: s.file,
142
+ heading: s.heading,
143
+ content: s.content,
144
+ rankScore: r.rankScore,
145
+ ...(r.v ? { semanticSimilarity: r.v.score, semanticRank: r.v.rank } : {}),
146
+ ...(r.l ? { lexicalScore: r.l.score, lexicalRank: r.l.rank } : {}),
147
+ evidence,
148
+ diagnostics: {
149
+ lexicalCapped: lexical.capped,
150
+ semanticCapped: semantic.capped,
151
+ lexicalCandidates: lexical.count,
152
+ semanticCandidates: semantic.count,
153
+ },
154
+ });
155
+ }
156
+ return results;
25
157
  }