@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,217 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { openDb, ensureMeta, getStoredModel, setStoredModel, ensureSectionsSchema, dropSections, closeDb, } from '../search/db.js';
|
|
4
|
+
import { getLlmKey } from '../config.js';
|
|
5
|
+
import { embedderForIndex, modelKey, ReindexRequiredError, EmbeddingAuthError, } from '../search/embedder.js';
|
|
6
|
+
import { indexSections } from '../search/index.js';
|
|
7
|
+
import { searchSections } from '../search/search.js';
|
|
8
|
+
import { loadAllSections, flattenSections, } from '../lattice.js';
|
|
9
|
+
import { formatSectionPreview, formatNavHints } from '../format.js';
|
|
10
|
+
import { provenanceNote, formatProvenanceNote } from './check-status.js';
|
|
11
|
+
async function withDb(latDir, progress, fn) {
|
|
12
|
+
const db = openDb(latDir);
|
|
13
|
+
try {
|
|
14
|
+
await ensureMeta(db);
|
|
15
|
+
const stored = await getStoredModel(db);
|
|
16
|
+
// The stored model is authoritative — no silent backend switch. Throws
|
|
17
|
+
// ReindexRequiredError if the environment can't serve the stored index.
|
|
18
|
+
// Rebuilding / switching backends is `lat reindex`, never `lat search`.
|
|
19
|
+
const embedder = await embedderForIndex(stored, latDir);
|
|
20
|
+
await ensureSectionsSchema(db, embedder.dimensions);
|
|
21
|
+
const countResult = await db.execute('SELECT COUNT(*) as n FROM sections');
|
|
22
|
+
let isEmpty = countResult.rows[0].n === 0;
|
|
23
|
+
// Legacy cache: a version before the model was recorded left rows behind
|
|
24
|
+
// with no `meta.embedding_model`. Those vectors may be a different backend
|
|
25
|
+
// (and dimension) than the resolved embedder, and `CREATE TABLE IF NOT
|
|
26
|
+
// EXISTS` won't migrate the column width — so drop and rebuild from scratch
|
|
27
|
+
// under the resolved backend rather than querying a mismatched table.
|
|
28
|
+
if (!stored && !isEmpty) {
|
|
29
|
+
await dropSections(db);
|
|
30
|
+
await ensureSectionsSchema(db, embedder.dimensions);
|
|
31
|
+
isEmpty = true;
|
|
32
|
+
}
|
|
33
|
+
// If the repo is pinned to local but a key is set, say so — otherwise it
|
|
34
|
+
// looks like the key is being silently ignored.
|
|
35
|
+
if (isEmpty && embedder.name.startsWith('local:') && process.stderr.isTTY) {
|
|
36
|
+
let hasKey = false;
|
|
37
|
+
try {
|
|
38
|
+
hasKey = !!getLlmKey();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* key source misconfigured — irrelevant, we're local */
|
|
42
|
+
}
|
|
43
|
+
if (hasKey) {
|
|
44
|
+
process.stderr.write('This repo is configured for local embeddings; ignoring LAT_LLM_KEY.\n');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
progress?.beforeIndex?.(isEmpty);
|
|
48
|
+
try {
|
|
49
|
+
const stats = await indexSections(latDir, db, embedder);
|
|
50
|
+
// Pin the backend only after a successful index, so a failed build never
|
|
51
|
+
// leaves the repo wrongly pinned to an empty index.
|
|
52
|
+
if (!stored)
|
|
53
|
+
await setStoredModel(db, modelKey(embedder));
|
|
54
|
+
progress?.afterIndex?.(stats, isEmpty);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
// Failed fresh build → drop the half-created table so the next run is
|
|
58
|
+
// truly fresh (re-resolves the backend cleanly) rather than stuck.
|
|
59
|
+
if (!stored)
|
|
60
|
+
await dropSections(db);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
return await fn(db, embedder);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await closeDb(db);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Resolve raw search hits (by id) to full section matches. */
|
|
70
|
+
async function resolveMatches(latDir, results, preloadedSections) {
|
|
71
|
+
if (results.length === 0)
|
|
72
|
+
return [];
|
|
73
|
+
const allSections = preloadedSections ?? (await loadAllSections(latDir));
|
|
74
|
+
const flat = flattenSections(allSections);
|
|
75
|
+
const byId = new Map(flat.map((s) => [s.id, s]));
|
|
76
|
+
return results
|
|
77
|
+
.map((r) => byId.get(r.id))
|
|
78
|
+
.filter((s) => !!s)
|
|
79
|
+
.map((s) => ({ section: s, reason: 'semantic match' }));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Run a semantic search across lat.md sections.
|
|
83
|
+
* Handles indexing (with optional progress callback). Returns matched sections.
|
|
84
|
+
*
|
|
85
|
+
* `opts.buildIndex: false` is read-only mode (the UserPromptSubmit hook): search
|
|
86
|
+
* an existing index but never build or update it — so a user's first prompt in a
|
|
87
|
+
* fresh repo isn't blocked by a full local embed pass. Building the index is
|
|
88
|
+
* `lat search` / `lat reindex`. With nothing indexed yet, returns no matches
|
|
89
|
+
* without even loading the embedder to embed the query.
|
|
90
|
+
*/
|
|
91
|
+
export async function runSearch(latDir, query, limit, progress, opts) {
|
|
92
|
+
if (opts?.buildIndex === false) {
|
|
93
|
+
const db = openDb(latDir);
|
|
94
|
+
try {
|
|
95
|
+
await ensureMeta(db);
|
|
96
|
+
const stored = await getStoredModel(db);
|
|
97
|
+
// Never built (or a legacy pre-versioning cache) — leave building to
|
|
98
|
+
// `lat search`; don't load the embedder just to embed the query.
|
|
99
|
+
if (stored === null)
|
|
100
|
+
return { query, matches: [] };
|
|
101
|
+
const embedder = await embedderForIndex(stored, latDir);
|
|
102
|
+
await ensureSectionsSchema(db, embedder.dimensions);
|
|
103
|
+
const results = await searchSections(db, query, embedder, limit);
|
|
104
|
+
return {
|
|
105
|
+
query,
|
|
106
|
+
matches: await resolveMatches(latDir, results, opts?.preloadedSections),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await closeDb(db);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return withDb(latDir, progress, async (db, embedder) => {
|
|
114
|
+
const results = await searchSections(db, query, embedder, limit);
|
|
115
|
+
return {
|
|
116
|
+
query,
|
|
117
|
+
matches: await resolveMatches(latDir, results, opts?.preloadedSections),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Index-only mode (no query) — builds the index on first use. Rebuilding is
|
|
123
|
+
* `lat reindex`, not a flag here.
|
|
124
|
+
*/
|
|
125
|
+
export async function runIndex(latDir, progress) {
|
|
126
|
+
await withDb(latDir, progress, async () => { });
|
|
127
|
+
}
|
|
128
|
+
export function cliProgress(s) {
|
|
129
|
+
return {
|
|
130
|
+
beforeIndex(isEmpty) {
|
|
131
|
+
if (isEmpty) {
|
|
132
|
+
process.stderr.write(s.dim('Building index...'));
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
afterIndex(stats, isEmpty) {
|
|
136
|
+
if (isEmpty) {
|
|
137
|
+
process.stderr.write(s.dim(` done (${stats.added} added, ${stats.updated} updated, ${stats.removed} removed)\n`));
|
|
138
|
+
}
|
|
139
|
+
else if (stats.added + stats.updated + stats.removed > 0) {
|
|
140
|
+
process.stderr.write(s.dim(`Index updated: ${stats.added} added, ${stats.updated} updated, ${stats.removed} removed\n`));
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Render search matches with each one's provenance.
|
|
147
|
+
*
|
|
148
|
+
* `formatResultList` in format.ts renders a bare list and is shared with
|
|
149
|
+
* `lat locate` and `lat refs`. Search is the surface where an agent picks a
|
|
150
|
+
* claim to act on without opening the document, so it is the one that has to
|
|
151
|
+
* say whether a person ever checked that claim. Frontmatter is read once per
|
|
152
|
+
* file rather than once per match, since several matches routinely share one.
|
|
153
|
+
*/
|
|
154
|
+
async function formatSearchMatches(ctx, query, matches) {
|
|
155
|
+
const notes = new Map();
|
|
156
|
+
for (const match of matches) {
|
|
157
|
+
const filePath = match.section.filePath;
|
|
158
|
+
if (notes.has(filePath))
|
|
159
|
+
continue;
|
|
160
|
+
try {
|
|
161
|
+
const content = await readFile(join(ctx.projectRoot, filePath), 'utf-8');
|
|
162
|
+
notes.set(filePath, provenanceNote(content));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
notes.set(filePath, null);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const lines = ['', `## Search results for "${query}":`, ''];
|
|
169
|
+
for (let i = 0; i < matches.length; i++) {
|
|
170
|
+
if (i > 0)
|
|
171
|
+
lines.push('');
|
|
172
|
+
lines.push(formatSectionPreview(ctx, matches[i].section, {
|
|
173
|
+
reason: matches[i].reason,
|
|
174
|
+
}));
|
|
175
|
+
const note = notes.get(matches[i].section.filePath);
|
|
176
|
+
if (note)
|
|
177
|
+
lines.push(` ${formatProvenanceNote(note, ctx.styler)}`);
|
|
178
|
+
}
|
|
179
|
+
lines.push('');
|
|
180
|
+
return lines.join('\n');
|
|
181
|
+
}
|
|
182
|
+
export async function searchCommand(ctx, query, opts, progress) {
|
|
183
|
+
const s = ctx.styler;
|
|
184
|
+
try {
|
|
185
|
+
if (!query) {
|
|
186
|
+
await runIndex(ctx.latDir, progress);
|
|
187
|
+
return { output: '' };
|
|
188
|
+
}
|
|
189
|
+
const result = await runSearch(ctx.latDir, query, opts.limit, progress);
|
|
190
|
+
if (result.matches.length === 0) {
|
|
191
|
+
return { output: 'No results found.' };
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
output: (await formatSearchMatches(ctx, query, result.matches)) +
|
|
195
|
+
formatNavHints(ctx),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
// The stored index can't be served in the current environment — never
|
|
200
|
+
// switch backends silently; direct the user to `lat reindex`.
|
|
201
|
+
if (err instanceof ReindexRequiredError) {
|
|
202
|
+
return { output: s.red(err.message), isError: true };
|
|
203
|
+
}
|
|
204
|
+
if (err instanceof EmbeddingAuthError) {
|
|
205
|
+
return {
|
|
206
|
+
output: s.red(`LAT_LLM_KEY was rejected by the provider (${err.status}).`) +
|
|
207
|
+
' Run ' +
|
|
208
|
+
s.cyan('lat reindex') +
|
|
209
|
+
' to fix the key or switch to the local model.',
|
|
210
|
+
isError: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// Config/key resolution errors (e.g. empty LAT_LLM_KEY_FILE) or other
|
|
214
|
+
// failures — surface the message rather than crashing.
|
|
215
|
+
return { output: err.message, isError: true };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Section, type SectionMatch } from '../lattice.js';
|
|
2
|
+
import type { CmdContext, CmdResult } from '../context.js';
|
|
3
|
+
import { type ProvenanceNote } from './check-status.js';
|
|
4
|
+
export type CodeBackRef = {
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
snippet: string;
|
|
8
|
+
};
|
|
9
|
+
export type SourceRef = {
|
|
10
|
+
target: string;
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
endLine: number;
|
|
14
|
+
snippet: string;
|
|
15
|
+
};
|
|
16
|
+
export type SectionFound = {
|
|
17
|
+
kind: 'found';
|
|
18
|
+
section: Section;
|
|
19
|
+
content: string;
|
|
20
|
+
/** Provenance of the document holding this section, or null if unmarked. */
|
|
21
|
+
status: ProvenanceNote | null;
|
|
22
|
+
outgoingRefs: {
|
|
23
|
+
target: string;
|
|
24
|
+
resolved: Section;
|
|
25
|
+
}[];
|
|
26
|
+
outgoingSourceRefs: SourceRef[];
|
|
27
|
+
incomingRefs: SectionMatch[];
|
|
28
|
+
codeRefs: CodeBackRef[];
|
|
29
|
+
};
|
|
30
|
+
export type SectionResult = SectionFound | {
|
|
31
|
+
kind: 'no-match';
|
|
32
|
+
suggestions: SectionMatch[];
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Look up a section by id, return its content, outgoing wiki link targets,
|
|
36
|
+
* and incoming references from other sections.
|
|
37
|
+
*/
|
|
38
|
+
export declare function getSection(ctx: CmdContext, query: string): Promise<SectionResult>;
|
|
39
|
+
/**
|
|
40
|
+
* Format a successful section result with styling.
|
|
41
|
+
*/
|
|
42
|
+
export declare function formatSectionOutput(ctx: CmdContext, result: SectionFound): string;
|
|
43
|
+
export declare function sectionCommand(ctx: CmdContext, query: string): Promise<CmdResult>;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, join, relative } from 'node:path';
|
|
3
|
+
import { loadAllSections, findSections, flattenSections, extractRefs, buildFileIndex, buildSectionSlugIndex, resolveRef, listLatticeFiles, } from '../lattice.js';
|
|
4
|
+
import { scanCodeRefs } from '../code-refs.js';
|
|
5
|
+
import { SOURCE_EXTENSIONS, resolveSourceSymbol } from '../source-parser.js';
|
|
6
|
+
import { formatSectionId, formatNavHints } from '../format.js';
|
|
7
|
+
import { provenanceNote, formatProvenanceNote, } from './check-status.js';
|
|
8
|
+
/**
|
|
9
|
+
* Look up a section by id, return its content, outgoing wiki link targets,
|
|
10
|
+
* and incoming references from other sections.
|
|
11
|
+
*/
|
|
12
|
+
export async function getSection(ctx, query) {
|
|
13
|
+
query = query.replace(/^\[\[|\]\]$/g, '');
|
|
14
|
+
const allSections = await loadAllSections(ctx.latDir);
|
|
15
|
+
const matches = findSections(allSections, query);
|
|
16
|
+
if (matches.length === 0) {
|
|
17
|
+
return { kind: 'no-match', suggestions: [] };
|
|
18
|
+
}
|
|
19
|
+
// Accept the top match if confident
|
|
20
|
+
const top = matches[0];
|
|
21
|
+
const isConfident = top.reason === 'exact match' ||
|
|
22
|
+
top.reason.startsWith('file stem expanded') ||
|
|
23
|
+
top.reason === 'section name match';
|
|
24
|
+
if (!isConfident) {
|
|
25
|
+
return { kind: 'no-match', suggestions: matches };
|
|
26
|
+
}
|
|
27
|
+
const section = top.section;
|
|
28
|
+
// Read raw content between startLine and the end of the last descendant
|
|
29
|
+
const absPath = join(ctx.projectRoot, section.filePath);
|
|
30
|
+
const fileContent = await readFile(absPath, 'utf-8');
|
|
31
|
+
const lines = fileContent.split('\n');
|
|
32
|
+
const end = fullEndLine(section);
|
|
33
|
+
const content = lines.slice(section.startLine - 1, end).join('\n');
|
|
34
|
+
// Find outgoing wiki link targets within this section's content
|
|
35
|
+
const flat = flattenSections(allSections);
|
|
36
|
+
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
37
|
+
const fileIndex = buildFileIndex(allSections);
|
|
38
|
+
const slugIndex = buildSectionSlugIndex(allSections);
|
|
39
|
+
const sectionRefs = extractRefs(absPath, fileContent, ctx.projectRoot);
|
|
40
|
+
const sectionId = section.id.toLowerCase();
|
|
41
|
+
const outgoingRefs = [];
|
|
42
|
+
const outgoingSourceRefs = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const ref of sectionRefs) {
|
|
45
|
+
if (ref.fromSection.toLowerCase() !== sectionId)
|
|
46
|
+
continue;
|
|
47
|
+
// Detect source code references by file extension
|
|
48
|
+
const hashIdx = ref.target.indexOf('#');
|
|
49
|
+
const filePart = hashIdx === -1 ? ref.target : ref.target.slice(0, hashIdx);
|
|
50
|
+
const ext = extname(filePart);
|
|
51
|
+
if (SOURCE_EXTENSIONS.has(ext)) {
|
|
52
|
+
const targetLower = ref.target.toLowerCase();
|
|
53
|
+
if (!seen.has(targetLower)) {
|
|
54
|
+
seen.add(targetLower);
|
|
55
|
+
const symbolPart = hashIdx === -1 ? '' : ref.target.slice(hashIdx + 1);
|
|
56
|
+
let line = 0;
|
|
57
|
+
let endLine = 0;
|
|
58
|
+
let snippet = '';
|
|
59
|
+
if (symbolPart) {
|
|
60
|
+
const { found, symbols } = await resolveSourceSymbol(filePart, symbolPart, ctx.projectRoot);
|
|
61
|
+
if (found) {
|
|
62
|
+
const parts = symbolPart.split('#');
|
|
63
|
+
const sym = symbols.find((s) => parts.length === 1
|
|
64
|
+
? s.name === parts[0] && !s.parent
|
|
65
|
+
: s.name === parts[1] && s.parent === parts[0]);
|
|
66
|
+
if (sym) {
|
|
67
|
+
line = sym.startLine;
|
|
68
|
+
endLine = sym.endLine;
|
|
69
|
+
try {
|
|
70
|
+
const src = await readFile(join(ctx.projectRoot, filePart), 'utf-8');
|
|
71
|
+
const srcLines = src.split('\n');
|
|
72
|
+
const start = sym.startLine - 1;
|
|
73
|
+
const end = Math.min(srcLines.length, start + 5);
|
|
74
|
+
snippet = srcLines.slice(start, end).join('\n');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// file unreadable
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
outgoingSourceRefs.push({
|
|
83
|
+
target: ref.target,
|
|
84
|
+
file: filePart,
|
|
85
|
+
line,
|
|
86
|
+
endLine,
|
|
87
|
+
snippet,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const { resolved } = resolveRef(ref.target, sectionIds, fileIndex, slugIndex);
|
|
93
|
+
const resolvedLower = resolved.toLowerCase();
|
|
94
|
+
if (seen.has(resolvedLower))
|
|
95
|
+
continue;
|
|
96
|
+
seen.add(resolvedLower);
|
|
97
|
+
const targetSection = flat.find((s) => s.id.toLowerCase() === resolvedLower);
|
|
98
|
+
if (targetSection) {
|
|
99
|
+
outgoingRefs.push({ target: ref.target, resolved: targetSection });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Find incoming references: other sections that link to this one
|
|
103
|
+
const incomingRefs = [];
|
|
104
|
+
const files = await listLatticeFiles(ctx.latDir);
|
|
105
|
+
const incomingSections = new Set();
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
const fc = await readFile(file, 'utf-8');
|
|
108
|
+
const fileRefs = extractRefs(file, fc, ctx.projectRoot);
|
|
109
|
+
for (const ref of fileRefs) {
|
|
110
|
+
const { resolved } = resolveRef(ref.target, sectionIds, fileIndex, slugIndex);
|
|
111
|
+
if (resolved.toLowerCase() === sectionId &&
|
|
112
|
+
ref.fromSection.toLowerCase() !== sectionId) {
|
|
113
|
+
if (!incomingSections.has(ref.fromSection.toLowerCase())) {
|
|
114
|
+
incomingSections.add(ref.fromSection.toLowerCase());
|
|
115
|
+
const fromSection = flat.find((s) => s.id.toLowerCase() === ref.fromSection.toLowerCase());
|
|
116
|
+
if (fromSection) {
|
|
117
|
+
incomingRefs.push({ section: fromSection, reason: 'wiki link' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Find code back-references: @lat: comments pointing to this section
|
|
124
|
+
const codeRefs = [];
|
|
125
|
+
const { refs: scannedRefs } = await scanCodeRefs(ctx.projectRoot);
|
|
126
|
+
for (const ref of scannedRefs) {
|
|
127
|
+
const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex, slugIndex);
|
|
128
|
+
if (codeResolved.toLowerCase() === sectionId) {
|
|
129
|
+
const absFile = join(ctx.projectRoot, ref.file);
|
|
130
|
+
let snippet = '';
|
|
131
|
+
try {
|
|
132
|
+
const src = await readFile(absFile, 'utf-8');
|
|
133
|
+
const srcLines = src.split('\n');
|
|
134
|
+
const start = Math.max(0, ref.line - 1 - 2);
|
|
135
|
+
const end = Math.min(srcLines.length, ref.line - 1 + 3);
|
|
136
|
+
snippet = srcLines.slice(start, end).join('\n');
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// file unreadable — skip snippet
|
|
140
|
+
}
|
|
141
|
+
codeRefs.push({ file: ref.file, line: ref.line, snippet });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
kind: 'found',
|
|
146
|
+
section,
|
|
147
|
+
content,
|
|
148
|
+
status: provenanceNote(fileContent),
|
|
149
|
+
outgoingRefs,
|
|
150
|
+
outgoingSourceRefs,
|
|
151
|
+
incomingRefs,
|
|
152
|
+
codeRefs,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function fullEndLine(section) {
|
|
156
|
+
if (section.children.length === 0)
|
|
157
|
+
return section.endLine;
|
|
158
|
+
return fullEndLine(section.children[section.children.length - 1]);
|
|
159
|
+
}
|
|
160
|
+
function truncate(s, max) {
|
|
161
|
+
return s.length > max ? s.slice(0, max) + '...' : s;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Format a successful section result with styling.
|
|
165
|
+
*/
|
|
166
|
+
export function formatSectionOutput(ctx, result) {
|
|
167
|
+
const s = ctx.styler;
|
|
168
|
+
const { section, content, status, outgoingRefs, outgoingSourceRefs, incomingRefs, codeRefs, } = result;
|
|
169
|
+
const relPath = relative(process.cwd(), join(ctx.projectRoot, section.filePath));
|
|
170
|
+
const loc = `${s.cyan(relPath)}${s.dim(`:${section.startLine}-${section.endLine}`)}`;
|
|
171
|
+
const quoted = content
|
|
172
|
+
.split('\n')
|
|
173
|
+
.map((line) => (line ? `> ${line}` : '>'))
|
|
174
|
+
.join('\n');
|
|
175
|
+
const parts = [
|
|
176
|
+
`${s.bold('[[' + formatSectionId(section.id, s) + ']]')} (${loc})`,
|
|
177
|
+
];
|
|
178
|
+
// Above the text, not below it: a reader who acts on the first line of a
|
|
179
|
+
// quoted section has already acted before a trailing caveat reaches them.
|
|
180
|
+
if (status)
|
|
181
|
+
parts.push(formatProvenanceNote(status, s));
|
|
182
|
+
parts.push('', quoted);
|
|
183
|
+
if (outgoingRefs.length > 0 || outgoingSourceRefs.length > 0) {
|
|
184
|
+
parts.push('', '## This section references:', '');
|
|
185
|
+
for (const ref of outgoingRefs) {
|
|
186
|
+
const body = ref.resolved.firstParagraph
|
|
187
|
+
? ` ${s.dim('—')} ${truncate(ref.resolved.firstParagraph, 120)}`
|
|
188
|
+
: '';
|
|
189
|
+
parts.push(`${s.dim('*')} [[${formatSectionId(ref.resolved.id, s)}]]${body}`);
|
|
190
|
+
}
|
|
191
|
+
for (const ref of outgoingSourceRefs) {
|
|
192
|
+
const loc = ref.line
|
|
193
|
+
? ref.endLine && ref.endLine !== ref.line
|
|
194
|
+
? `${s.dim(` (${ref.file}:${ref.line}-${ref.endLine})`)}`
|
|
195
|
+
: `${s.dim(` (${ref.file}:${ref.line})`)}`
|
|
196
|
+
: `${s.dim(` (${ref.file})`)}`;
|
|
197
|
+
parts.push(`${s.dim('*')} [[${s.cyan(ref.target)}]]${loc}`);
|
|
198
|
+
if (ref.snippet) {
|
|
199
|
+
const snippetLines = ref.snippet.split('\n');
|
|
200
|
+
for (const line of snippetLines) {
|
|
201
|
+
parts.push(` ${s.dim('|')} ${line}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (incomingRefs.length > 0) {
|
|
207
|
+
parts.push('', '## Referenced by:', '');
|
|
208
|
+
for (const ref of incomingRefs) {
|
|
209
|
+
const body = ref.section.firstParagraph
|
|
210
|
+
? ` ${s.dim('—')} ${truncate(ref.section.firstParagraph, 120)}`
|
|
211
|
+
: '';
|
|
212
|
+
parts.push(`${s.dim('*')} [[${formatSectionId(ref.section.id, s)}]]${body}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (codeRefs.length > 0) {
|
|
216
|
+
parts.push('', '## Referenced by code:', '');
|
|
217
|
+
for (const ref of codeRefs) {
|
|
218
|
+
const codeRelPath = relative(process.cwd(), join(ctx.projectRoot, ref.file));
|
|
219
|
+
parts.push(`${s.dim('*')} ${s.cyan(codeRelPath)}${s.dim(`:${ref.line}`)}`);
|
|
220
|
+
if (ref.snippet) {
|
|
221
|
+
const snippetLines = ref.snippet.split('\n');
|
|
222
|
+
for (const line of snippetLines) {
|
|
223
|
+
parts.push(` ${s.dim('|')} ${line}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
parts.push(formatNavHints(ctx));
|
|
229
|
+
return parts.join('\n');
|
|
230
|
+
}
|
|
231
|
+
export async function sectionCommand(ctx, query) {
|
|
232
|
+
const result = await getSection(ctx, query);
|
|
233
|
+
if (result.kind === 'no-match') {
|
|
234
|
+
const s = ctx.styler;
|
|
235
|
+
if (result.suggestions.length > 0) {
|
|
236
|
+
const suggestions = result.suggestions
|
|
237
|
+
.map((m) => ` ${s.dim('*')} ${s.white(m.section.id)} ${s.dim(`(${m.reason})`)}`)
|
|
238
|
+
.join('\n');
|
|
239
|
+
return {
|
|
240
|
+
output: s.red(`No section "${query}" found.`) +
|
|
241
|
+
' Did you mean:\n' +
|
|
242
|
+
suggestions,
|
|
243
|
+
isError: true,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
output: s.red(`No sections matching "${query}"`),
|
|
248
|
+
isError: true,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return { output: formatSectionOutput(ctx, result) };
|
|
252
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface SelectOption {
|
|
2
|
+
label: string;
|
|
3
|
+
value: string;
|
|
4
|
+
/** If true, this option uses a distinct highlight style (e.g. for "done" actions). */
|
|
5
|
+
accent?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Display an interactive select menu with arrow-key navigation.
|
|
9
|
+
* Returns the selected option's value, or null if the user pressed Ctrl+C.
|
|
10
|
+
*
|
|
11
|
+
* @param defaultIndex - initial cursor position (defaults to 0)
|
|
12
|
+
*/
|
|
13
|
+
export declare function selectMenu(options: SelectOption[], prompt?: string, defaultIndex?: number): Promise<string | null>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
/**
|
|
3
|
+
* Display an interactive select menu with arrow-key navigation.
|
|
4
|
+
* Returns the selected option's value, or null if the user pressed Ctrl+C.
|
|
5
|
+
*
|
|
6
|
+
* @param defaultIndex - initial cursor position (defaults to 0)
|
|
7
|
+
*/
|
|
8
|
+
export async function selectMenu(options, prompt, defaultIndex) {
|
|
9
|
+
if (options.length === 0)
|
|
10
|
+
return null;
|
|
11
|
+
if (!process.stdin.isTTY) {
|
|
12
|
+
// Non-interactive fallback: return first option
|
|
13
|
+
return options[0].value;
|
|
14
|
+
}
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let cursor = defaultIndex ?? 0;
|
|
17
|
+
const stdin = process.stdin;
|
|
18
|
+
// Save original stdin state
|
|
19
|
+
const wasRaw = stdin.isRaw;
|
|
20
|
+
stdin.setRawMode(true);
|
|
21
|
+
stdin.resume();
|
|
22
|
+
stdin.setEncoding('utf-8');
|
|
23
|
+
function render() {
|
|
24
|
+
process.stdout.write('\x1B[?25l'); // hide cursor
|
|
25
|
+
const lines = [];
|
|
26
|
+
if (prompt) {
|
|
27
|
+
lines.push(styleText('bold', prompt));
|
|
28
|
+
}
|
|
29
|
+
for (let i = 0; i < options.length; i++) {
|
|
30
|
+
const opt = options[i];
|
|
31
|
+
const selected = i === cursor;
|
|
32
|
+
const pointer = selected ? '❯' : ' ';
|
|
33
|
+
if (selected) {
|
|
34
|
+
if (opt.accent) {
|
|
35
|
+
lines.push(` ${pointer} ${styleText(['bgGreen', 'black', 'bold'], ` ${opt.label} `)}`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
lines.push(` ${pointer} ${styleText(['bgCyan', 'black', 'bold'], ` ${opt.label} `)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
lines.push(` ${pointer} ${styleText('dim', opt.label)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
46
|
+
}
|
|
47
|
+
function clearRender() {
|
|
48
|
+
const totalLines = options.length + (prompt ? 1 : 0);
|
|
49
|
+
// Move up and clear each line
|
|
50
|
+
for (let i = 0; i < totalLines; i++) {
|
|
51
|
+
process.stdout.write('\x1B[A\x1B[2K');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function cleanup() {
|
|
55
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
56
|
+
stdin.pause();
|
|
57
|
+
process.stdout.write('\x1B[?25h'); // show cursor
|
|
58
|
+
stdin.removeListener('data', onData);
|
|
59
|
+
}
|
|
60
|
+
function onData(data) {
|
|
61
|
+
const key = data.toString();
|
|
62
|
+
// Ctrl+C
|
|
63
|
+
if (key === '\x03') {
|
|
64
|
+
clearRender();
|
|
65
|
+
cleanup();
|
|
66
|
+
console.log('');
|
|
67
|
+
process.exit(130);
|
|
68
|
+
}
|
|
69
|
+
// Enter
|
|
70
|
+
if (key === '\r' || key === '\n') {
|
|
71
|
+
clearRender();
|
|
72
|
+
cleanup();
|
|
73
|
+
const selected = options[cursor];
|
|
74
|
+
// Print the selection
|
|
75
|
+
if (prompt) {
|
|
76
|
+
console.log(styleText('bold', prompt) +
|
|
77
|
+
' ' +
|
|
78
|
+
styleText('green', selected.label));
|
|
79
|
+
}
|
|
80
|
+
resolve(selected.value);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Arrow keys (escape sequences)
|
|
84
|
+
if (key === '\x1B[A' || key === 'k') {
|
|
85
|
+
// Up
|
|
86
|
+
clearRender();
|
|
87
|
+
cursor = (cursor - 1 + options.length) % options.length;
|
|
88
|
+
render();
|
|
89
|
+
}
|
|
90
|
+
else if (key === '\x1B[B' || key === 'j') {
|
|
91
|
+
// Down
|
|
92
|
+
clearRender();
|
|
93
|
+
cursor = (cursor + 1) % options.length;
|
|
94
|
+
render();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
stdin.on('data', onData);
|
|
98
|
+
render();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function findTemplatesDir(): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export function findTemplatesDir() {
|
|
5
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
while (true) {
|
|
7
|
+
const candidate = join(dir, 'templates');
|
|
8
|
+
if (existsSync(candidate))
|
|
9
|
+
return candidate;
|
|
10
|
+
const parent = dirname(dir);
|
|
11
|
+
if (parent === dir)
|
|
12
|
+
throw new Error('templates directory not found');
|
|
13
|
+
dir = parent;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CmdContext, CmdResult } from '../context.js';
|
|
2
|
+
import { type ViewServer, type ViewServerOptions } from '../view/server.js';
|
|
3
|
+
type UiCommandOptions = ViewServerOptions & {
|
|
4
|
+
openBrowser?: (url: string) => Promise<void>;
|
|
5
|
+
onStarted?: (server: ViewServer) => void;
|
|
6
|
+
};
|
|
7
|
+
/** Launch the platform browser without passing the URL through a shell. */
|
|
8
|
+
export declare function openBrowser(url: string): Promise<void>;
|
|
9
|
+
/** Start `lat ui`, report its URL, and launch the default browser. */
|
|
10
|
+
export declare function uiCommand(ctx: CmdContext, options?: UiCommandOptions): Promise<CmdResult>;
|
|
11
|
+
export {};
|