@plebeiantech/lat.md 0.12.3-fork.1

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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/dist/src/cli/check-frontmatter.d.ts +12 -0
  4. package/dist/src/cli/check-frontmatter.js +44 -0
  5. package/dist/src/cli/check-mode.d.ts +6 -0
  6. package/dist/src/cli/check-mode.js +303 -0
  7. package/dist/src/cli/check-status.d.ts +74 -0
  8. package/dist/src/cli/check-status.js +217 -0
  9. package/dist/src/cli/check.d.ts +45 -0
  10. package/dist/src/cli/check.js +777 -0
  11. package/dist/src/cli/checklist-menu.d.ts +12 -0
  12. package/dist/src/cli/checklist-menu.js +111 -0
  13. package/dist/src/cli/comment-guard.d.ts +16 -0
  14. package/dist/src/cli/comment-guard.js +111 -0
  15. package/dist/src/cli/comment-reminder.d.ts +62 -0
  16. package/dist/src/cli/comment-reminder.js +210 -0
  17. package/dist/src/cli/context.d.ts +11 -0
  18. package/dist/src/cli/context.js +58 -0
  19. package/dist/src/cli/expand.d.ts +8 -0
  20. package/dist/src/cli/expand.js +94 -0
  21. package/dist/src/cli/gen-index.d.ts +45 -0
  22. package/dist/src/cli/gen-index.js +235 -0
  23. package/dist/src/cli/gen.d.ts +6 -0
  24. package/dist/src/cli/gen.js +42 -0
  25. package/dist/src/cli/graph.d.ts +7 -0
  26. package/dist/src/cli/graph.js +47 -0
  27. package/dist/src/cli/hook.d.ts +1 -0
  28. package/dist/src/cli/hook.js +432 -0
  29. package/dist/src/cli/index.d.ts +2 -0
  30. package/dist/src/cli/index.js +307 -0
  31. package/dist/src/cli/init.d.ts +9 -0
  32. package/dist/src/cli/init.js +1031 -0
  33. package/dist/src/cli/link-scheme.d.ts +54 -0
  34. package/dist/src/cli/link-scheme.js +101 -0
  35. package/dist/src/cli/locate.d.ts +2 -0
  36. package/dist/src/cli/locate.js +17 -0
  37. package/dist/src/cli/refs.d.ts +22 -0
  38. package/dist/src/cli/refs.js +229 -0
  39. package/dist/src/cli/reindex.d.ts +13 -0
  40. package/dist/src/cli/reindex.js +136 -0
  41. package/dist/src/cli/search.d.ts +36 -0
  42. package/dist/src/cli/search.js +217 -0
  43. package/dist/src/cli/section.d.ts +43 -0
  44. package/dist/src/cli/section.js +252 -0
  45. package/dist/src/cli/select-menu.d.ts +13 -0
  46. package/dist/src/cli/select-menu.js +100 -0
  47. package/dist/src/cli/templates.d.ts +1 -0
  48. package/dist/src/cli/templates.js +15 -0
  49. package/dist/src/cli/ui.d.ts +11 -0
  50. package/dist/src/cli/ui.js +34 -0
  51. package/dist/src/code-refs.d.ts +17 -0
  52. package/dist/src/code-refs.js +277 -0
  53. package/dist/src/config.d.ts +29 -0
  54. package/dist/src/config.js +83 -0
  55. package/dist/src/context.d.ts +23 -0
  56. package/dist/src/context.js +11 -0
  57. package/dist/src/extensions/wiki-link/from-markdown.d.ts +5 -0
  58. package/dist/src/extensions/wiki-link/from-markdown.js +34 -0
  59. package/dist/src/extensions/wiki-link/index.d.ts +4 -0
  60. package/dist/src/extensions/wiki-link/index.js +3 -0
  61. package/dist/src/extensions/wiki-link/syntax.d.ts +14 -0
  62. package/dist/src/extensions/wiki-link/syntax.js +121 -0
  63. package/dist/src/extensions/wiki-link/to-markdown.d.ts +5 -0
  64. package/dist/src/extensions/wiki-link/to-markdown.js +28 -0
  65. package/dist/src/extensions/wiki-link/types.d.ts +34 -0
  66. package/dist/src/extensions/wiki-link/types.js +1 -0
  67. package/dist/src/format.d.ts +8 -0
  68. package/dist/src/format.js +43 -0
  69. package/dist/src/graph-export.d.ts +60 -0
  70. package/dist/src/graph-export.js +309 -0
  71. package/dist/src/init-version.d.ts +10 -0
  72. package/dist/src/init-version.js +49 -0
  73. package/dist/src/knowledge/bd.d.ts +2 -0
  74. package/dist/src/knowledge/bd.js +107 -0
  75. package/dist/src/knowledge/claude-memory.d.ts +13 -0
  76. package/dist/src/knowledge/claude-memory.js +204 -0
  77. package/dist/src/knowledge/cq.d.ts +2 -0
  78. package/dist/src/knowledge/cq.js +74 -0
  79. package/dist/src/knowledge/index.d.ts +49 -0
  80. package/dist/src/knowledge/index.js +271 -0
  81. package/dist/src/knowledge/ranking.d.ts +17 -0
  82. package/dist/src/knowledge/ranking.js +62 -0
  83. package/dist/src/knowledge/session.d.ts +46 -0
  84. package/dist/src/knowledge/session.js +203 -0
  85. package/dist/src/knowledge/types.d.ts +47 -0
  86. package/dist/src/knowledge/types.js +10 -0
  87. package/dist/src/lattice.d.ts +108 -0
  88. package/dist/src/lattice.js +816 -0
  89. package/dist/src/mcp/server.d.ts +1 -0
  90. package/dist/src/mcp/server.js +58 -0
  91. package/dist/src/parser.d.ts +3 -0
  92. package/dist/src/parser.js +18 -0
  93. package/dist/src/search/db.d.ts +16 -0
  94. package/dist/src/search/db.js +54 -0
  95. package/dist/src/search/embedder.d.ts +25 -0
  96. package/dist/src/search/embedder.js +58 -0
  97. package/dist/src/search/index.d.ts +9 -0
  98. package/dist/src/search/index.js +66 -0
  99. package/dist/src/search/search.d.ts +9 -0
  100. package/dist/src/search/search.js +16 -0
  101. package/dist/src/source-parser.d.ts +27 -0
  102. package/dist/src/source-parser.js +916 -0
  103. package/dist/src/untrusted.d.ts +24 -0
  104. package/dist/src/untrusted.js +97 -0
  105. package/dist/src/version.d.ts +7 -0
  106. package/dist/src/version.js +39 -0
  107. package/dist/src/view/client/assets/index-B0n0u03y.css +1 -0
  108. package/dist/src/view/client/assets/index-xm2_px4q.js +23 -0
  109. package/dist/src/view/client/index.html +14 -0
  110. package/dist/src/view/diagnostics.d.ts +5 -0
  111. package/dist/src/view/diagnostics.js +171 -0
  112. package/dist/src/view/git-diff.d.ts +3 -0
  113. package/dist/src/view/git-diff.js +255 -0
  114. package/dist/src/view/git.d.ts +22 -0
  115. package/dist/src/view/git.js +193 -0
  116. package/dist/src/view/highlight.d.ts +2 -0
  117. package/dist/src/view/highlight.js +3767 -0
  118. package/dist/src/view/markdown.d.ts +22 -0
  119. package/dist/src/view/markdown.js +322 -0
  120. package/dist/src/view/protocol.d.ts +89 -0
  121. package/dist/src/view/protocol.js +1 -0
  122. package/dist/src/view/references.d.ts +62 -0
  123. package/dist/src/view/references.js +345 -0
  124. package/dist/src/view/repository.d.ts +12 -0
  125. package/dist/src/view/repository.js +167 -0
  126. package/dist/src/view/search.d.ts +14 -0
  127. package/dist/src/view/search.js +64 -0
  128. package/dist/src/view/server.d.ts +20 -0
  129. package/dist/src/view/server.js +233 -0
  130. package/dist/src/view/store.d.ts +57 -0
  131. package/dist/src/view/store.js +404 -0
  132. package/dist/src/walk.d.ts +18 -0
  133. package/dist/src/walk.js +27 -0
  134. package/package.json +85 -0
  135. package/templates/AGENTS.md +118 -0
  136. package/templates/README +1 -0
  137. package/templates/cursor-rules.md +77 -0
  138. package/templates/init/lat.md +1 -0
  139. package/templates/logo-dark.svg +182 -0
  140. package/templates/logo-light.svg +182 -0
  141. package/templates/logo.txt +7 -0
  142. package/templates/opencode-plugin.ts +180 -0
  143. package/templates/pi-extension.ts +364 -0
  144. package/templates/skill/SKILL.md +189 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Decide whether a Markdown link destination points inside the lattice or
3
+ * out at the wider world.
4
+ *
5
+ * This exists as its own module because two callers need the same answer and
6
+ * a second, differently-behaved copy of the rule is exactly how the two
7
+ * drift: `parseIndexEntries` in check.ts decides which index bullets are
8
+ * generated entries, and `spliceIndexContent` in gen-index.ts decides which
9
+ * bullets belong to the region `--fix` is allowed to rewrite. If those two
10
+ * ever disagree about a line, `--fix` rewrites a bullet the checker does not
11
+ * track, or leaves one it does.
12
+ *
13
+ * `localLinkTarget` in check.ts applies the same rule inline for
14
+ * relative-link validation. It is upstream code and is deliberately left
15
+ * alone here; if this ever moves upstream, that is the call site to fold in.
16
+ */
17
+ /**
18
+ * True when `url` carries a URL scheme (`https:`, `mailto:`, `file:`) and is
19
+ * therefore not a path inside the lattice.
20
+ *
21
+ * A Windows drive letter is the one case that looks like a scheme and is not
22
+ * — `C:\docs\a.md` matches the scheme grammar because a drive letter is a
23
+ * single ASCII letter followed by a colon. Treat it as a path, the same way
24
+ * `localLinkTarget` does, so the two agree.
25
+ */
26
+ export declare function hasUrlScheme(url: string): boolean;
27
+ /**
28
+ * True when `url` is a destination the lattice can own: no scheme, and not
29
+ * rooted at the filesystem root. A root-absolute path is excluded for the
30
+ * same reason `localLinkTarget` excludes it — it names a location outside
31
+ * the lattice tree, so it is never a child entry of a directory index.
32
+ */
33
+ export declare function isLatticeLocalDest(url: string): boolean;
34
+ /**
35
+ * Reduce a Markdown link destination to the name of the directory child it
36
+ * refers to, or null when it refers to no child of this directory at all.
37
+ *
38
+ * The naive reading — take the first `/`-separated segment — is wrong in
39
+ * four distinct ways, and each one was observed producing a bogus entry
40
+ * name that `lat check` then reported as a stale entry that "does not
41
+ * exist":
42
+ *
43
+ * https://example.com/docs -> "https:" a scheme is not a path segment
44
+ * ./notes.md -> "." a no-op prefix is not a child
45
+ * ../siblings/x.md -> ".." this escapes the directory
46
+ * notes.md#intro -> "notes.md#intro" fragments are not names
47
+ *
48
+ * Order is load-bearing. The fragment and query are split off BEFORE
49
+ * percent-decoding, and the path is split on `/` BEFORE decoding too, for
50
+ * the same reason `localLinkTarget` does it: `%23` and `%2F` decode to `#`
51
+ * and `/`, and decoding first would let a filename that legitimately
52
+ * contains either one truncate or split itself.
53
+ */
54
+ export declare function indexEntryNameFromDest(dest: string): string | null;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Decide whether a Markdown link destination points inside the lattice or
3
+ * out at the wider world.
4
+ *
5
+ * This exists as its own module because two callers need the same answer and
6
+ * a second, differently-behaved copy of the rule is exactly how the two
7
+ * drift: `parseIndexEntries` in check.ts decides which index bullets are
8
+ * generated entries, and `spliceIndexContent` in gen-index.ts decides which
9
+ * bullets belong to the region `--fix` is allowed to rewrite. If those two
10
+ * ever disagree about a line, `--fix` rewrites a bullet the checker does not
11
+ * track, or leaves one it does.
12
+ *
13
+ * `localLinkTarget` in check.ts applies the same rule inline for
14
+ * relative-link validation. It is upstream code and is deliberately left
15
+ * alone here; if this ever moves upstream, that is the call site to fold in.
16
+ */
17
+ /**
18
+ * True when `url` carries a URL scheme (`https:`, `mailto:`, `file:`) and is
19
+ * therefore not a path inside the lattice.
20
+ *
21
+ * A Windows drive letter is the one case that looks like a scheme and is not
22
+ * — `C:\docs\a.md` matches the scheme grammar because a drive letter is a
23
+ * single ASCII letter followed by a colon. Treat it as a path, the same way
24
+ * `localLinkTarget` does, so the two agree.
25
+ */
26
+ export function hasUrlScheme(url) {
27
+ const u = url.trim();
28
+ if (/^[a-zA-Z]:\\/.test(u))
29
+ return false;
30
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(u);
31
+ }
32
+ /**
33
+ * True when `url` is a destination the lattice can own: no scheme, and not
34
+ * rooted at the filesystem root. A root-absolute path is excluded for the
35
+ * same reason `localLinkTarget` excludes it — it names a location outside
36
+ * the lattice tree, so it is never a child entry of a directory index.
37
+ */
38
+ export function isLatticeLocalDest(url) {
39
+ const u = url.trim();
40
+ if (u === '')
41
+ return false;
42
+ if (u.startsWith('/'))
43
+ return false;
44
+ return !hasUrlScheme(u);
45
+ }
46
+ /**
47
+ * Reduce a Markdown link destination to the name of the directory child it
48
+ * refers to, or null when it refers to no child of this directory at all.
49
+ *
50
+ * The naive reading — take the first `/`-separated segment — is wrong in
51
+ * four distinct ways, and each one was observed producing a bogus entry
52
+ * name that `lat check` then reported as a stale entry that "does not
53
+ * exist":
54
+ *
55
+ * https://example.com/docs -> "https:" a scheme is not a path segment
56
+ * ./notes.md -> "." a no-op prefix is not a child
57
+ * ../siblings/x.md -> ".." this escapes the directory
58
+ * notes.md#intro -> "notes.md#intro" fragments are not names
59
+ *
60
+ * Order is load-bearing. The fragment and query are split off BEFORE
61
+ * percent-decoding, and the path is split on `/` BEFORE decoding too, for
62
+ * the same reason `localLinkTarget` does it: `%23` and `%2F` decode to `#`
63
+ * and `/`, and decoding first would let a filename that legitimately
64
+ * contains either one truncate or split itself.
65
+ */
66
+ export function indexEntryNameFromDest(dest) {
67
+ if (!isLatticeLocalDest(dest))
68
+ return null;
69
+ const u = dest.trim();
70
+ const queryAt = u.indexOf('?');
71
+ const fragmentAt = u.indexOf('#');
72
+ const pathEnd = Math.min(queryAt === -1 ? u.length : queryAt, fragmentAt === -1 ? u.length : fragmentAt);
73
+ let path = u.slice(0, pathEnd);
74
+ // `./` contributes nothing to the path, and may be repeated.
75
+ while (path.startsWith('./'))
76
+ path = path.slice(2);
77
+ // `..` leaves the directory whose index this is, so whatever it names is
78
+ // by definition not a child of it.
79
+ if (path === '..' || path.startsWith('../'))
80
+ return null;
81
+ if (path === '' || path === '.')
82
+ return null;
83
+ const segment = path.split('/')[0];
84
+ if (segment === '')
85
+ return null;
86
+ let decoded;
87
+ try {
88
+ decoded = decodeURIComponent(segment);
89
+ }
90
+ catch {
91
+ decoded = segment;
92
+ }
93
+ // A percent-encoded `.` or `..` (e.g. `%2e%2e`) decodes to the same
94
+ // traversal segment the raw check above already rejects. Re-check after
95
+ // decoding so both spellings agree — this is a consistency fix, not a
96
+ // security one: parseIndexEntries only compares names, it never builds a
97
+ // filesystem path from one.
98
+ if (decoded === '.' || decoded === '..')
99
+ return null;
100
+ return decoded;
101
+ }
@@ -0,0 +1,2 @@
1
+ import type { CmdContext, CmdResult } from '../context.js';
2
+ export declare function locateCommand(ctx: CmdContext, query: string): Promise<CmdResult>;
@@ -0,0 +1,17 @@
1
+ import { loadAllSections, findSections } from '../lattice.js';
2
+ import { formatResultList } from '../format.js';
3
+ export async function locateCommand(ctx, query) {
4
+ const stripped = query.replace(/^\[\[|\]\]$/g, '');
5
+ const sections = await loadAllSections(ctx.latDir);
6
+ const matches = findSections(sections, stripped);
7
+ if (matches.length === 0) {
8
+ const s = ctx.styler;
9
+ return {
10
+ output: s.red(`No sections matching "${stripped}" (no exact, substring, or fuzzy matches)`),
11
+ isError: true,
12
+ };
13
+ }
14
+ return {
15
+ output: formatResultList(ctx, `Sections matching "${stripped}":`, matches),
16
+ };
17
+ }
@@ -0,0 +1,22 @@
1
+ import { type Section, type SectionMatch } from '../lattice.js';
2
+ import type { CmdContext, CmdResult } from '../context.js';
3
+ export type Scope = 'md' | 'code' | 'md+code';
4
+ export type RefsFound = {
5
+ kind: 'found';
6
+ target: Section;
7
+ mdRefs: SectionMatch[];
8
+ codeRefs: string[];
9
+ };
10
+ export type RefsError = {
11
+ kind: 'no-match';
12
+ suggestions: SectionMatch[];
13
+ };
14
+ export type RefsResult = RefsFound | RefsError;
15
+ /**
16
+ * Find all sections and code locations that reference a given section or
17
+ * source file. Accepts section ids (full-path, short-form) and source file
18
+ * paths (e.g. src/app.rs#foo). Source file queries match wiki links directly
19
+ * without section resolution.
20
+ */
21
+ export declare function findRefs(ctx: CmdContext, query: string, scope: Scope): Promise<RefsResult>;
22
+ export declare function refsCommand(ctx: CmdContext, query: string, scope: Scope): Promise<CmdResult>;
@@ -0,0 +1,229 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { extname, join, relative } from 'node:path';
4
+ import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, buildFileIndex, buildSectionSlugIndex, resolveRef, } from '../lattice.js';
5
+ import { formatResultList } from '../format.js';
6
+ import { scanCodeRefs } from '../code-refs.js';
7
+ /** Extensions recognized as source code for ref queries. */
8
+ const SOURCE_EXTS = new Set([
9
+ '.ts',
10
+ '.tsx',
11
+ '.js',
12
+ '.jsx',
13
+ '.py',
14
+ '.rs',
15
+ '.go',
16
+ '.c',
17
+ '.h',
18
+ ]);
19
+ /**
20
+ * Check if a query looks like a source file path (has a recognized extension
21
+ * and the file exists on disk).
22
+ */
23
+ function isSourceQuery(query, projectRoot) {
24
+ const hashIdx = query.indexOf('#');
25
+ const filePart = hashIdx === -1 ? query : query.slice(0, hashIdx);
26
+ const symbolPart = hashIdx === -1 ? '' : query.slice(hashIdx + 1);
27
+ const ext = extname(filePart);
28
+ if (!SOURCE_EXTS.has(ext))
29
+ return null;
30
+ if (!existsSync(join(projectRoot, filePart)))
31
+ return null;
32
+ return { filePart, symbolPart };
33
+ }
34
+ /**
35
+ * Find references to a source file or symbol across lat.md and code files.
36
+ * For file-level queries (no #symbol), matches all wiki links targeting
37
+ * that file or any symbol in it.
38
+ */
39
+ async function findSourceRefs(latDir, projectRoot, query, scope) {
40
+ const hashIdx = query.indexOf('#');
41
+ const filePart = hashIdx === -1 ? query : query.slice(0, hashIdx);
42
+ const isFileLevel = hashIdx === -1;
43
+ const queryLower = query.toLowerCase();
44
+ const fileLower = filePart.toLowerCase();
45
+ // Build a synthetic Section for the target
46
+ const target = {
47
+ id: query,
48
+ heading: hashIdx === -1 ? filePart : query.slice(hashIdx + 1),
49
+ depth: 0,
50
+ file: filePart,
51
+ filePath: filePart,
52
+ children: [],
53
+ startLine: 0,
54
+ endLine: 0,
55
+ firstParagraph: '',
56
+ };
57
+ // Try to get real line info from the source parser
58
+ try {
59
+ const { resolveSourceSymbol } = await import('../source-parser.js');
60
+ if (hashIdx !== -1) {
61
+ const symbolPart = query.slice(hashIdx + 1);
62
+ const { found, symbols } = await resolveSourceSymbol(filePart, symbolPart, projectRoot);
63
+ if (found) {
64
+ const parts = symbolPart.split('#');
65
+ const sym = symbols.find((s) => parts.length === 1
66
+ ? s.name === parts[0] && !s.parent
67
+ : s.name === parts[1] && s.parent === parts[0]);
68
+ if (sym) {
69
+ target.startLine = sym.startLine;
70
+ target.endLine = sym.endLine;
71
+ target.firstParagraph = sym.signature;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ catch {
77
+ // source parser unavailable — proceed without line info
78
+ }
79
+ const allSections = await loadAllSections(latDir);
80
+ const flat = flattenSections(allSections);
81
+ const mdRefs = [];
82
+ const codeRefs = [];
83
+ if (scope === 'md' || scope === 'md+code') {
84
+ const files = await listLatticeFiles(latDir);
85
+ const matchingFromSections = new Set();
86
+ for (const file of files) {
87
+ const content = await readFile(file, 'utf-8');
88
+ const fileRefs = extractRefs(file, content, projectRoot);
89
+ for (const ref of fileRefs) {
90
+ const targetLower = ref.target.toLowerCase();
91
+ const matches = isFileLevel
92
+ ? targetLower === fileLower || targetLower.startsWith(fileLower + '#')
93
+ : targetLower === queryLower;
94
+ if (matches) {
95
+ matchingFromSections.add(ref.fromSection.toLowerCase());
96
+ }
97
+ }
98
+ }
99
+ if (matchingFromSections.size > 0) {
100
+ const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
101
+ for (const s of referrers) {
102
+ mdRefs.push({ section: s, reason: 'wiki link' });
103
+ }
104
+ }
105
+ }
106
+ if (scope === 'code' || scope === 'md+code') {
107
+ const { refs: scannedRefs } = await scanCodeRefs(projectRoot);
108
+ for (const ref of scannedRefs) {
109
+ const targetLower = ref.target.toLowerCase();
110
+ const matches = isFileLevel
111
+ ? targetLower === fileLower || targetLower.startsWith(fileLower + '#')
112
+ : targetLower === queryLower;
113
+ if (matches) {
114
+ const displayPath = relative(process.cwd(), join(projectRoot, ref.file));
115
+ codeRefs.push(`${displayPath}:${ref.line}`);
116
+ }
117
+ }
118
+ }
119
+ return { kind: 'found', target, mdRefs, codeRefs };
120
+ }
121
+ /**
122
+ * Find all sections and code locations that reference a given section or
123
+ * source file. Accepts section ids (full-path, short-form) and source file
124
+ * paths (e.g. src/app.rs#foo). Source file queries match wiki links directly
125
+ * without section resolution.
126
+ */
127
+ export async function findRefs(ctx, query, scope) {
128
+ query = query.replace(/^\[\[|\]\]$/g, '');
129
+ // Source file queries bypass section resolution
130
+ if (isSourceQuery(query, ctx.projectRoot)) {
131
+ return findSourceRefs(ctx.latDir, ctx.projectRoot, query, scope);
132
+ }
133
+ const allSections = await loadAllSections(ctx.latDir);
134
+ const flat = flattenSections(allSections);
135
+ const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
136
+ const fileIndex = buildFileIndex(allSections);
137
+ const slugIndex = buildSectionSlugIndex(allSections);
138
+ const { resolved } = resolveRef(query, sectionIds, fileIndex, slugIndex);
139
+ const q = resolved.toLowerCase();
140
+ let exactMatch = flat.find((s) => s.id.toLowerCase() === q);
141
+ // If resolveRef didn't land on an exact id, use findSections as fallback
142
+ const matches = !exactMatch ? findSections(allSections, query) : [];
143
+ if (!exactMatch && matches.length >= 1) {
144
+ const top = matches[0];
145
+ const isConfident = top.reason === 'exact match' ||
146
+ top.reason.startsWith('file stem expanded') ||
147
+ top.reason === 'section name match';
148
+ if (isConfident) {
149
+ exactMatch = top.section;
150
+ }
151
+ }
152
+ if (!exactMatch) {
153
+ const suggestions = matches.length > 0 ? matches : findSections(allSections, query);
154
+ return { kind: 'no-match', suggestions };
155
+ }
156
+ const targetId = exactMatch.id.toLowerCase();
157
+ const mdRefs = [];
158
+ const codeRefs = [];
159
+ if (scope === 'md' || scope === 'md+code') {
160
+ const files = await listLatticeFiles(ctx.latDir);
161
+ const matchingFromSections = new Set();
162
+ for (const file of files) {
163
+ const content = await readFile(file, 'utf-8');
164
+ const fileRefs = extractRefs(file, content, ctx.projectRoot);
165
+ for (const ref of fileRefs) {
166
+ const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex, slugIndex);
167
+ if (refResolved.toLowerCase() === targetId) {
168
+ matchingFromSections.add(ref.fromSection.toLowerCase());
169
+ }
170
+ }
171
+ }
172
+ if (matchingFromSections.size > 0) {
173
+ const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
174
+ for (const s of referrers) {
175
+ mdRefs.push({ section: s, reason: 'wiki link' });
176
+ }
177
+ }
178
+ }
179
+ if (scope === 'code' || scope === 'md+code') {
180
+ const { refs: scannedRefs } = await scanCodeRefs(ctx.projectRoot);
181
+ for (const ref of scannedRefs) {
182
+ const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex, slugIndex);
183
+ if (codeResolved.toLowerCase() === targetId) {
184
+ const displayPath = relative(process.cwd(), join(ctx.projectRoot, ref.file));
185
+ codeRefs.push(`${displayPath}:${ref.line}`);
186
+ }
187
+ }
188
+ }
189
+ return { kind: 'found', target: exactMatch, mdRefs, codeRefs };
190
+ }
191
+ export async function refsCommand(ctx, query, scope) {
192
+ const result = await findRefs(ctx, query, scope);
193
+ if (result.kind === 'no-match') {
194
+ const s = ctx.styler;
195
+ if (result.suggestions.length > 0) {
196
+ const suggestions = result.suggestions
197
+ .map((m) => ` ${s.dim('*')} ${s.white(m.section.id)} ${s.dim(`(${m.reason})`)}`)
198
+ .join('\n');
199
+ return {
200
+ output: s.red(`No section "${query}" found.`) +
201
+ ' Did you mean:\n' +
202
+ suggestions,
203
+ isError: true,
204
+ };
205
+ }
206
+ return {
207
+ output: s.red(`No section matching "${query}"`),
208
+ isError: true,
209
+ };
210
+ }
211
+ const { target, mdRefs, codeRefs } = result;
212
+ if (mdRefs.length === 0 && codeRefs.length === 0) {
213
+ return {
214
+ output: ctx.styler.yellow(`No references to "${target.id}" found`),
215
+ isError: true,
216
+ };
217
+ }
218
+ const s = ctx.styler;
219
+ const parts = [];
220
+ if (mdRefs.length > 0) {
221
+ parts.push(formatResultList(ctx, `References to "${target.id}":`, mdRefs));
222
+ }
223
+ if (codeRefs.length > 0) {
224
+ parts.push('## Code references:' +
225
+ '\n\n' +
226
+ codeRefs.map((l) => `${s.dim('*')} ${l}`).join('\n'));
227
+ }
228
+ return { output: parts.join('\n') };
229
+ }
@@ -0,0 +1,13 @@
1
+ import type { CmdContext, CmdResult } from '../context.js';
2
+ /**
3
+ * Rebuild the embedding index. Honors the durable per-repo backend preference:
4
+ * a repo pinned to local rebuilds local and ignores the key. `--local` forces
5
+ * local; `--remote` forces re-resolving from the key (the escape hatch back to
6
+ * hosted); a bare run on an unpinned repo decides from the env. If a key is set
7
+ * but rejected, offers to switch to local (`--yes` answers non-interactively).
8
+ */
9
+ export declare function reindexCommand(ctx: CmdContext, opts: {
10
+ local?: boolean;
11
+ remote?: boolean;
12
+ yes?: boolean;
13
+ }): Promise<CmdResult>;
@@ -0,0 +1,136 @@
1
+ import readline from 'node:readline/promises';
2
+ import { openDb, ensureMeta, setStoredModel, ensureSectionsSchema, dropSections, closeDb, } from '../search/db.js';
3
+ import { embedderFromEnv, localEmbedder, modelKey, EmbeddingAuthError, } from '../search/embedder.js';
4
+ import { getLlmKey, getRepoEmbedding, setRepoEmbedding } from '../config.js';
5
+ import { indexSections } from '../search/index.js';
6
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+ async function confirmUseLocal(ctx, status, assumeYes) {
8
+ const s = ctx.styler;
9
+ process.stderr.write(s.yellow(`LAT_LLM_KEY was rejected by the provider (${status}).`) + '\n');
10
+ if (assumeYes)
11
+ return true;
12
+ // Can't prompt when not attached to a terminal (agents, CI, MCP).
13
+ if (ctx.mode !== 'cli' || !process.stdin.isTTY)
14
+ return false;
15
+ const rl = readline.createInterface({
16
+ input: process.stdin,
17
+ output: process.stderr,
18
+ });
19
+ const ans = (await rl.question('Use local offline embeddings instead? [Y/n] '))
20
+ .trim()
21
+ .toLowerCase();
22
+ rl.close();
23
+ return ans === '' || ans === 'y' || ans === 'yes';
24
+ }
25
+ /**
26
+ * Rebuild the embedding index. Honors the durable per-repo backend preference:
27
+ * a repo pinned to local rebuilds local and ignores the key. `--local` forces
28
+ * local; `--remote` forces re-resolving from the key (the escape hatch back to
29
+ * hosted); a bare run on an unpinned repo decides from the env. If a key is set
30
+ * but rejected, offers to switch to local (`--yes` answers non-interactively).
31
+ */
32
+ export async function reindexCommand(ctx, opts) {
33
+ const s = ctx.styler;
34
+ let key;
35
+ try {
36
+ key = getLlmKey();
37
+ }
38
+ catch (err) {
39
+ return { output: err.message, isError: true };
40
+ }
41
+ if (opts.remote && !key) {
42
+ return {
43
+ output: s.red('--remote requires LAT_LLM_KEY to be set.'),
44
+ isError: true,
45
+ };
46
+ }
47
+ const pinnedLocal = getRepoEmbedding(ctx.latDir) === 'local';
48
+ let embedder;
49
+ if (opts.local || (pinnedLocal && !opts.remote)) {
50
+ // Explicit --local, or the repo is already pinned to local: use local and
51
+ // ignore the key. Say so when a key is present, so it's not a silent drop.
52
+ if (!opts.local && key) {
53
+ process.stderr.write(s.dim('Local embeddings configured for this repo; ignoring LAT_LLM_KEY.\n'));
54
+ }
55
+ embedder = await localEmbedder();
56
+ }
57
+ else if (!key) {
58
+ embedder = await localEmbedder();
59
+ }
60
+ else {
61
+ // Resolve from the env key (bare + unpinned, or explicit --remote). Verify
62
+ // it with a tiny probe first, so an invalid key doesn't wipe a working index.
63
+ try {
64
+ const remote = await embedderFromEnv();
65
+ await remote.embed(['lat reindex: verifying embedding key']);
66
+ embedder = remote;
67
+ }
68
+ catch (err) {
69
+ // A malformed/unsupported key prefix throws from embedderFromEnv (a plain
70
+ // Error, not EmbeddingAuthError) — surface it cleanly instead of crashing.
71
+ if (!(err instanceof EmbeddingAuthError)) {
72
+ return { output: err.message, isError: true };
73
+ }
74
+ if (!(await confirmUseLocal(ctx, err.status, !!opts.yes))) {
75
+ return {
76
+ output: ' Fix the key, or re-run ' +
77
+ s.cyan('lat reindex --local') +
78
+ ' to switch to the offline model.',
79
+ isError: true,
80
+ };
81
+ }
82
+ embedder = await localEmbedder();
83
+ }
84
+ }
85
+ const db = openDb(ctx.latDir);
86
+ const interactive = ctx.mode === 'cli' && !!process.stderr.isTTY;
87
+ try {
88
+ await ensureMeta(db);
89
+ await dropSections(db);
90
+ await ensureSectionsSchema(db, embedder.dimensions);
91
+ const label = `Reindexing with ${embedder.name}`;
92
+ // Interactive terminals get a live progress line driven per embed-chunk
93
+ // (frame + done/total). Elsewhere (agents, CI, MCP) a single plain line
94
+ // keeps logs clean. Progress is event-driven, not timer-based, so it stays
95
+ // accurate even though the local WASM forward pass is synchronous.
96
+ let frame = 0;
97
+ const onProgress = interactive
98
+ ? (done, total) => {
99
+ const f = SPINNER[frame++ % SPINNER.length];
100
+ process.stderr.write(`\r\x1b[K${f} ${s.dim(`${label} — ${done}/${total}`)}`);
101
+ }
102
+ : undefined;
103
+ if (interactive)
104
+ process.stderr.write(`${SPINNER[0]} ${s.dim(label + '…')}`);
105
+ else
106
+ process.stderr.write(s.dim(label + '…\n'));
107
+ let stats;
108
+ try {
109
+ stats = await indexSections(ctx.latDir, db, embedder, onProgress);
110
+ // Pin the backend only after a successful build, so a failed reindex never
111
+ // records a model that doesn't match a completed index.
112
+ await setStoredModel(db, modelKey(embedder));
113
+ }
114
+ catch (err) {
115
+ // Build failed: drop the half-built table so the DB is left clean. `meta`
116
+ // still holds the prior model, so the next run rebuilds it consistently.
117
+ await dropSections(db);
118
+ throw err;
119
+ }
120
+ finally {
121
+ if (interactive)
122
+ process.stderr.write('\r\x1b[K'); // clear the progress line
123
+ }
124
+ // Durably remember the choice so it survives a `.cache` wipe / fresh clone:
125
+ // local pins local (ignore the key thereafter); remote clears the pin so the
126
+ // env decides again.
127
+ setRepoEmbedding(ctx.latDir, embedder.name.startsWith('local:') ? 'local' : null);
128
+ return {
129
+ output: s.green(`Reindexed ${stats.added} sections`) +
130
+ ` using ${s.cyan(embedder.name)}.`,
131
+ };
132
+ }
133
+ finally {
134
+ await closeDb(db);
135
+ }
136
+ }
@@ -0,0 +1,36 @@
1
+ import type { CmdContext, CmdResult, Styler } from '../context.js';
2
+ import { type IndexStats } from '../search/index.js';
3
+ import { type Section, type SectionMatch } from '../lattice.js';
4
+ export type SearchResult = {
5
+ query: string;
6
+ matches: SectionMatch[];
7
+ };
8
+ export type IndexProgress = {
9
+ /** Called before indexing starts. `isEmpty` is true on first run. */
10
+ beforeIndex?: (isEmpty: boolean) => void;
11
+ /** Called after indexing completes with stats. */
12
+ afterIndex?: (stats: IndexStats, isEmpty: boolean) => void;
13
+ };
14
+ /**
15
+ * Run a semantic search across lat.md sections.
16
+ * Handles indexing (with optional progress callback). Returns matched sections.
17
+ *
18
+ * `opts.buildIndex: false` is read-only mode (the UserPromptSubmit hook): search
19
+ * an existing index but never build or update it — so a user's first prompt in a
20
+ * fresh repo isn't blocked by a full local embed pass. Building the index is
21
+ * `lat search` / `lat reindex`. With nothing indexed yet, returns no matches
22
+ * without even loading the embedder to embed the query.
23
+ */
24
+ export declare function runSearch(latDir: string, query: string, limit: number, progress?: IndexProgress, opts?: {
25
+ buildIndex?: boolean;
26
+ preloadedSections?: Section[];
27
+ }): Promise<SearchResult>;
28
+ /**
29
+ * Index-only mode (no query) — builds the index on first use. Rebuilding is
30
+ * `lat reindex`, not a flag here.
31
+ */
32
+ export declare function runIndex(latDir: string, progress?: IndexProgress): Promise<void>;
33
+ export declare function cliProgress(s: Styler): IndexProgress;
34
+ export declare function searchCommand(ctx: CmdContext, query: string | undefined, opts: {
35
+ limit: number;
36
+ }, progress?: IndexProgress): Promise<CmdResult>;