@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/src/cli/check-frontmatter.d.ts +12 -0
- package/dist/src/cli/check-frontmatter.js +44 -0
- package/dist/src/cli/check-mode.d.ts +6 -0
- package/dist/src/cli/check-mode.js +303 -0
- package/dist/src/cli/check-status.d.ts +74 -0
- package/dist/src/cli/check-status.js +217 -0
- package/dist/src/cli/check.d.ts +45 -0
- package/dist/src/cli/check.js +777 -0
- package/dist/src/cli/checklist-menu.d.ts +12 -0
- package/dist/src/cli/checklist-menu.js +111 -0
- package/dist/src/cli/comment-guard.d.ts +16 -0
- package/dist/src/cli/comment-guard.js +111 -0
- package/dist/src/cli/comment-reminder.d.ts +62 -0
- package/dist/src/cli/comment-reminder.js +210 -0
- package/dist/src/cli/context.d.ts +11 -0
- package/dist/src/cli/context.js +58 -0
- package/dist/src/cli/expand.d.ts +8 -0
- package/dist/src/cli/expand.js +94 -0
- package/dist/src/cli/gen-index.d.ts +45 -0
- package/dist/src/cli/gen-index.js +235 -0
- package/dist/src/cli/gen.d.ts +6 -0
- package/dist/src/cli/gen.js +42 -0
- package/dist/src/cli/graph.d.ts +7 -0
- package/dist/src/cli/graph.js +47 -0
- package/dist/src/cli/hook.d.ts +1 -0
- package/dist/src/cli/hook.js +432 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +307 -0
- package/dist/src/cli/init.d.ts +9 -0
- package/dist/src/cli/init.js +1031 -0
- package/dist/src/cli/link-scheme.d.ts +54 -0
- package/dist/src/cli/link-scheme.js +101 -0
- package/dist/src/cli/locate.d.ts +2 -0
- package/dist/src/cli/locate.js +17 -0
- package/dist/src/cli/refs.d.ts +22 -0
- package/dist/src/cli/refs.js +229 -0
- package/dist/src/cli/reindex.d.ts +13 -0
- package/dist/src/cli/reindex.js +136 -0
- package/dist/src/cli/search.d.ts +36 -0
- package/dist/src/cli/search.js +217 -0
- package/dist/src/cli/section.d.ts +43 -0
- package/dist/src/cli/section.js +252 -0
- package/dist/src/cli/select-menu.d.ts +13 -0
- package/dist/src/cli/select-menu.js +100 -0
- package/dist/src/cli/templates.d.ts +1 -0
- package/dist/src/cli/templates.js +15 -0
- package/dist/src/cli/ui.d.ts +11 -0
- package/dist/src/cli/ui.js +34 -0
- package/dist/src/code-refs.d.ts +17 -0
- package/dist/src/code-refs.js +277 -0
- package/dist/src/config.d.ts +29 -0
- package/dist/src/config.js +83 -0
- package/dist/src/context.d.ts +23 -0
- package/dist/src/context.js +11 -0
- package/dist/src/extensions/wiki-link/from-markdown.d.ts +5 -0
- package/dist/src/extensions/wiki-link/from-markdown.js +34 -0
- package/dist/src/extensions/wiki-link/index.d.ts +4 -0
- package/dist/src/extensions/wiki-link/index.js +3 -0
- package/dist/src/extensions/wiki-link/syntax.d.ts +14 -0
- package/dist/src/extensions/wiki-link/syntax.js +121 -0
- package/dist/src/extensions/wiki-link/to-markdown.d.ts +5 -0
- package/dist/src/extensions/wiki-link/to-markdown.js +28 -0
- package/dist/src/extensions/wiki-link/types.d.ts +34 -0
- package/dist/src/extensions/wiki-link/types.js +1 -0
- package/dist/src/format.d.ts +8 -0
- package/dist/src/format.js +43 -0
- package/dist/src/graph-export.d.ts +60 -0
- package/dist/src/graph-export.js +309 -0
- package/dist/src/init-version.d.ts +10 -0
- package/dist/src/init-version.js +49 -0
- package/dist/src/knowledge/bd.d.ts +2 -0
- package/dist/src/knowledge/bd.js +107 -0
- package/dist/src/knowledge/claude-memory.d.ts +13 -0
- package/dist/src/knowledge/claude-memory.js +204 -0
- package/dist/src/knowledge/cq.d.ts +2 -0
- package/dist/src/knowledge/cq.js +74 -0
- package/dist/src/knowledge/index.d.ts +49 -0
- package/dist/src/knowledge/index.js +271 -0
- package/dist/src/knowledge/ranking.d.ts +17 -0
- package/dist/src/knowledge/ranking.js +62 -0
- package/dist/src/knowledge/session.d.ts +46 -0
- package/dist/src/knowledge/session.js +203 -0
- package/dist/src/knowledge/types.d.ts +47 -0
- package/dist/src/knowledge/types.js +10 -0
- package/dist/src/lattice.d.ts +108 -0
- package/dist/src/lattice.js +816 -0
- package/dist/src/mcp/server.d.ts +1 -0
- package/dist/src/mcp/server.js +58 -0
- package/dist/src/parser.d.ts +3 -0
- package/dist/src/parser.js +18 -0
- package/dist/src/search/db.d.ts +16 -0
- package/dist/src/search/db.js +54 -0
- package/dist/src/search/embedder.d.ts +25 -0
- package/dist/src/search/embedder.js +58 -0
- package/dist/src/search/index.d.ts +9 -0
- package/dist/src/search/index.js +66 -0
- package/dist/src/search/search.d.ts +9 -0
- package/dist/src/search/search.js +16 -0
- package/dist/src/source-parser.d.ts +27 -0
- package/dist/src/source-parser.js +916 -0
- package/dist/src/untrusted.d.ts +24 -0
- package/dist/src/untrusted.js +97 -0
- package/dist/src/version.d.ts +7 -0
- package/dist/src/version.js +39 -0
- package/dist/src/view/client/assets/index-B0n0u03y.css +1 -0
- package/dist/src/view/client/assets/index-xm2_px4q.js +23 -0
- package/dist/src/view/client/index.html +14 -0
- package/dist/src/view/diagnostics.d.ts +5 -0
- package/dist/src/view/diagnostics.js +171 -0
- package/dist/src/view/git-diff.d.ts +3 -0
- package/dist/src/view/git-diff.js +255 -0
- package/dist/src/view/git.d.ts +22 -0
- package/dist/src/view/git.js +193 -0
- package/dist/src/view/highlight.d.ts +2 -0
- package/dist/src/view/highlight.js +3767 -0
- package/dist/src/view/markdown.d.ts +22 -0
- package/dist/src/view/markdown.js +322 -0
- package/dist/src/view/protocol.d.ts +89 -0
- package/dist/src/view/protocol.js +1 -0
- package/dist/src/view/references.d.ts +62 -0
- package/dist/src/view/references.js +345 -0
- package/dist/src/view/repository.d.ts +12 -0
- package/dist/src/view/repository.js +167 -0
- package/dist/src/view/search.d.ts +14 -0
- package/dist/src/view/search.js +64 -0
- package/dist/src/view/server.d.ts +20 -0
- package/dist/src/view/server.js +233 -0
- package/dist/src/view/store.d.ts +57 -0
- package/dist/src/view/store.js +404 -0
- package/dist/src/walk.d.ts +18 -0
- package/dist/src/walk.js +27 -0
- package/package.json +85 -0
- package/templates/AGENTS.md +118 -0
- package/templates/README +1 -0
- package/templates/cursor-rules.md +77 -0
- package/templates/init/lat.md +1 -0
- package/templates/logo-dark.svg +182 -0
- package/templates/logo-light.svg +182 -0
- package/templates/logo.txt +7 -0
- package/templates/opencode-plugin.ts +180 -0
- package/templates/pi-extension.ts +364 -0
- 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,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>;
|