lat.md 0.6.0 → 0.7.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.
@@ -0,0 +1,49 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * Bump this number whenever `lat init` setup changes in a way that
6
+ * requires users to re-run it (e.g. new hooks, AGENTS.md changes,
7
+ * MCP config changes).
8
+ */
9
+ export const INIT_VERSION = 1;
10
+ function cachePath(latDir) {
11
+ return join(latDir, '.cache', 'lat_init.json');
12
+ }
13
+ function readMeta(latDir) {
14
+ const p = cachePath(latDir);
15
+ if (!existsSync(p))
16
+ return null;
17
+ try {
18
+ return JSON.parse(readFileSync(p, 'utf-8'));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function readInitVersion(latDir) {
25
+ const meta = readMeta(latDir);
26
+ if (!meta)
27
+ return null;
28
+ return typeof meta.init_version === 'number' ? meta.init_version : null;
29
+ }
30
+ export function readFileHash(latDir, relPath) {
31
+ const meta = readMeta(latDir);
32
+ return meta?.file_hashes?.[relPath] ?? null;
33
+ }
34
+ export function contentHash(content) {
35
+ return createHash('sha256').update(content).digest('hex');
36
+ }
37
+ export function writeInitMeta(latDir, fileHashes) {
38
+ const cacheDir = join(latDir, '.cache');
39
+ mkdirSync(cacheDir, { recursive: true });
40
+ // Merge with existing hashes so we don't lose entries from agents
41
+ // that weren't selected this run
42
+ const existing = readMeta(latDir);
43
+ const mergedHashes = { ...existing?.file_hashes, ...fileHashes };
44
+ const data = {
45
+ init_version: INIT_VERSION,
46
+ file_hashes: mergedHashes,
47
+ };
48
+ writeFileSync(cachePath(latDir), JSON.stringify(data, null, 2) + '\n');
49
+ }
@@ -7,7 +7,7 @@ export type Section = {
7
7
  children: Section[];
8
8
  startLine: number;
9
9
  endLine: number;
10
- body: string;
10
+ firstParagraph: string;
11
11
  };
12
12
  export type Ref = {
13
13
  target: string;
@@ -19,7 +19,6 @@ export type LatFrontmatter = {
19
19
  requireCodeMention?: boolean;
20
20
  };
21
21
  export declare function parseFrontmatter(content: string): LatFrontmatter;
22
- export declare function stripFrontmatter(content: string): string;
23
22
  export declare function findLatticeDir(from?: string): string | null;
24
23
  export declare function findProjectRoot(from?: string): string | null;
25
24
  export declare function listLatticeFiles(latticeDir: string): Promise<string[]>;
@@ -15,9 +15,6 @@ export function parseFrontmatter(content) {
15
15
  }
16
16
  return result;
17
17
  }
18
- export function stripFrontmatter(content) {
19
- return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
20
- }
21
18
  export function findLatticeDir(from) {
22
19
  let dir = resolve(from ?? process.cwd());
23
20
  while (true) {
@@ -69,7 +66,7 @@ function lastLine(content) {
69
66
  return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
70
67
  }
71
68
  export function parseSections(filePath, content, projectRoot) {
72
- const tree = parse(stripFrontmatter(content));
69
+ const tree = parse(content);
73
70
  const file = projectRoot
74
71
  ? relative(projectRoot, filePath).replace(/\.md$/, '')
75
72
  : basename(filePath, '.md');
@@ -98,7 +95,7 @@ export function parseSections(filePath, content, projectRoot) {
98
95
  children: [],
99
96
  startLine,
100
97
  endLine: 0,
101
- body: '',
98
+ firstParagraph: '',
102
99
  };
103
100
  if (parent) {
104
101
  parent.children.push(section);
@@ -119,7 +116,7 @@ export function parseSections(filePath, content, projectRoot) {
119
116
  flat[i].endLine = fileLastLine;
120
117
  }
121
118
  }
122
- // Extract body: first paragraph after each heading
119
+ // Extract firstParagraph: first paragraph after each heading
123
120
  const children = tree.children;
124
121
  let headingIdx = 0;
125
122
  for (let i = 0; i < children.length; i++) {
@@ -130,7 +127,7 @@ export function parseSections(filePath, content, projectRoot) {
130
127
  if (children[j].type === 'heading')
131
128
  break;
132
129
  if (children[j].type === 'paragraph') {
133
- flat[headingIdx].body = inlineText(children[j]);
130
+ flat[headingIdx].firstParagraph = inlineText(children[j]);
134
131
  break;
135
132
  }
136
133
  }
@@ -522,7 +519,7 @@ export function findSections(sections, query) {
522
519
  ];
523
520
  }
524
521
  export function extractRefs(filePath, content, projectRoot) {
525
- const tree = parse(stripFrontmatter(content));
522
+ const tree = parse(content);
526
523
  const file = projectRoot
527
524
  ? relative(projectRoot, filePath).replace(/\.md$/, '')
528
525
  : basename(filePath, '.md');
@@ -1,33 +1,18 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { dirname, join, relative } from 'node:path';
5
- import { findLatticeDir, loadAllSections, findSections, flattenSections, buildFileIndex, resolveRef, extractRefs, listLatticeFiles, } from '../lattice.js';
6
- import { scanCodeRefs } from '../code-refs.js';
7
- import { checkMd, checkCodeRefs, checkIndex } from '../cli/check.js';
8
- import { readFile } from 'node:fs/promises';
9
- function formatSection(s, projectRoot) {
10
- const relPath = relative(process.cwd(), join(projectRoot, s.filePath));
11
- const kind = s.id.includes('#') ? 'Section' : 'File';
12
- const lines = [
13
- `* ${kind}: [[${s.id}]]`,
14
- ` Defined in ${relPath}:${s.startLine}-${s.endLine}`,
15
- ];
16
- if (s.body) {
17
- const truncated = s.body.length > 200 ? s.body.slice(0, 200) + '...' : s.body;
18
- lines.push('', ` > ${truncated}`);
19
- }
20
- return lines.join('\n');
21
- }
22
- function formatMatches(header, matches, projectRoot) {
23
- const lines = [header, ''];
24
- for (let i = 0; i < matches.length; i++) {
25
- if (i > 0)
26
- lines.push('');
27
- lines.push(formatSection(matches[i].section, projectRoot) +
28
- ` (${matches[i].reason})`);
29
- }
30
- return lines.join('\n');
4
+ import { dirname } from 'node:path';
5
+ import { findLatticeDir } from '../lattice.js';
6
+ import { plainStyler } from '../context.js';
7
+ import { locateCommand } from '../cli/locate.js';
8
+ import { sectionCommand } from '../cli/section.js';
9
+ import { searchCommand } from '../cli/search.js';
10
+ import { expandCommand } from '../cli/expand.js';
11
+ import { checkAllCommand } from '../cli/check.js';
12
+ import { refsCommand } from '../cli/refs.js';
13
+ function toMcp(result) {
14
+ const content = [{ type: 'text', text: result.output }];
15
+ return result.isError ? { content, isError: true } : { content };
31
16
  }
32
17
  export async function startMcpServer() {
33
18
  const latDir = findLatticeDir();
@@ -36,32 +21,20 @@ export async function startMcpServer() {
36
21
  process.exit(1);
37
22
  }
38
23
  const projectRoot = dirname(latDir);
24
+ const ctx = {
25
+ latDir,
26
+ projectRoot,
27
+ styler: plainStyler,
28
+ mode: 'mcp',
29
+ };
39
30
  const server = new McpServer({
40
31
  name: 'lat',
41
32
  version: '1.0.0',
42
33
  });
43
- server.tool('lat_locate', 'Find sections by name (exact, fuzzy, subsequence matching)', { query: z.string().describe('Section name or id to search for') }, async ({ query }) => {
44
- const sections = await loadAllSections(latDir);
45
- const matches = findSections(sections, query.replace(/^\[\[|\]\]$/g, ''));
46
- if (matches.length === 0) {
47
- return {
48
- content: [
49
- {
50
- type: 'text',
51
- text: `No sections matching "${query}"`,
52
- },
53
- ],
54
- };
55
- }
56
- return {
57
- content: [
58
- {
59
- type: 'text',
60
- text: formatMatches(`Sections matching "${query}":`, matches, projectRoot),
61
- },
62
- ],
63
- };
64
- });
34
+ server.tool('lat_locate', 'Find sections by name (exact, fuzzy, subsequence matching)', { query: z.string().describe('Section name or id to search for') }, async ({ query }) => toMcp(await locateCommand(ctx, query)));
35
+ server.tool('lat_section', 'Show a section with its content, outgoing wiki link targets, and incoming references', {
36
+ query: z.string().describe('Section id to look up (short or full form)'),
37
+ }, async ({ query }) => toMcp(await sectionCommand(ctx, query)));
65
38
  server.tool('lat_search', 'Semantic search across lat.md sections using embeddings', {
66
39
  query: z.string().describe('Search query in natural language'),
67
40
  limit: z
@@ -69,151 +42,9 @@ export async function startMcpServer() {
69
42
  .optional()
70
43
  .default(5)
71
44
  .describe('Max results (default 5)'),
72
- }, async ({ query, limit }) => {
73
- const { getLlmKey } = await import('../config.js');
74
- let key;
75
- try {
76
- key = getLlmKey();
77
- }
78
- catch (err) {
79
- return {
80
- content: [{ type: 'text', text: err.message }],
81
- isError: true,
82
- };
83
- }
84
- if (!key) {
85
- return {
86
- content: [
87
- {
88
- type: 'text',
89
- text: 'No LLM key found. Provide a key via LAT_LLM_KEY, LAT_LLM_KEY_FILE, LAT_LLM_KEY_HELPER, or run `lat init`.',
90
- },
91
- ],
92
- isError: true,
93
- };
94
- }
95
- const { detectProvider } = await import('../search/provider.js');
96
- const { openDb, ensureSchema, closeDb } = await import('../search/db.js');
97
- const { indexSections } = await import('../search/index.js');
98
- const { searchSections } = await import('../search/search.js');
99
- const provider = detectProvider(key);
100
- const db = openDb(latDir);
101
- try {
102
- await ensureSchema(db, provider.dimensions);
103
- await indexSections(latDir, db, provider, key);
104
- const results = await searchSections(db, query, provider, key, limit);
105
- if (results.length === 0) {
106
- return {
107
- content: [{ type: 'text', text: 'No results found.' }],
108
- };
109
- }
110
- const allSections = await loadAllSections(latDir);
111
- const flat = flattenSections(allSections);
112
- const byId = new Map(flat.map((s) => [s.id, s]));
113
- const matched = results
114
- .map((r) => byId.get(r.id))
115
- .filter((s) => !!s)
116
- .map((s) => ({ section: s, reason: 'semantic match' }));
117
- return {
118
- content: [
119
- {
120
- type: 'text',
121
- text: formatMatches(`Search results for "${query}":`, matched, projectRoot),
122
- },
123
- ],
124
- };
125
- }
126
- finally {
127
- await closeDb(db);
128
- }
129
- });
130
- server.tool('lat_prompt', 'Expand [[refs]] in text to resolved lat.md section paths with context', { text: z.string().describe('Text containing [[refs]] to expand') }, async ({ text }) => {
131
- const WIKI_LINK_RE = /\[\[([^\]]+)\]\]/g;
132
- const allSections = await loadAllSections(latDir);
133
- const refs = [...text.matchAll(WIKI_LINK_RE)];
134
- if (refs.length === 0) {
135
- return {
136
- content: [{ type: 'text', text }],
137
- };
138
- }
139
- const resolved = new Map();
140
- const errors = [];
141
- for (const match of refs) {
142
- const target = match[1];
143
- if (resolved.has(target))
144
- continue;
145
- const matches = findSections(allSections, target);
146
- if (matches.length >= 1) {
147
- resolved.set(target, {
148
- target,
149
- best: matches[0],
150
- alternatives: matches.slice(1),
151
- });
152
- }
153
- else {
154
- errors.push(`No section found for [[${target}]]`);
155
- }
156
- }
157
- if (errors.length > 0) {
158
- return {
159
- content: [{ type: 'text', text: errors.join('\n') }],
160
- isError: true,
161
- };
162
- }
163
- let output = text.replace(WIKI_LINK_RE, (_match, target) => {
164
- const ref = resolved.get(target);
165
- return `[[${ref.best.section.id}]]`;
166
- });
167
- output += '\n\n<lat-context>\n';
168
- for (const ref of resolved.values()) {
169
- const isExact = ref.best.reason === 'exact match' ||
170
- ref.best.reason.startsWith('file stem expanded');
171
- const all = isExact ? [ref.best] : [ref.best, ...ref.alternatives];
172
- if (isExact) {
173
- output += `* [[${ref.target}]] is referring to:\n`;
174
- }
175
- else {
176
- output += `* [[${ref.target}]] might be referring to either of the following:\n`;
177
- }
178
- for (const m of all) {
179
- const reason = isExact ? '' : ` (${m.reason})`;
180
- const relPath = relative(process.cwd(), join(projectRoot, m.section.filePath));
181
- output += ` * [[${m.section.id}]]${reason}\n`;
182
- output += ` * ${relPath}:${m.section.startLine}-${m.section.endLine}\n`;
183
- if (m.section.body) {
184
- output += ` * ${m.section.body}\n`;
185
- }
186
- }
187
- }
188
- output += '</lat-context>\n';
189
- return {
190
- content: [{ type: 'text', text: output }],
191
- };
192
- });
193
- server.tool('lat_check', 'Validate all wiki links, code references, and directory indexes in lat.md', {}, async () => {
194
- const md = await checkMd(latDir);
195
- const code = await checkCodeRefs(latDir);
196
- const indexErrors = await checkIndex(latDir);
197
- const allErrors = [...md.errors, ...code.errors];
198
- const lines = [];
199
- for (const err of allErrors) {
200
- lines.push(`${err.file}:${err.line}: ${err.message}`);
201
- }
202
- for (const err of indexErrors) {
203
- lines.push(`${err.dir}: ${err.message}`);
204
- }
205
- const totalErrors = allErrors.length + indexErrors.length;
206
- if (totalErrors === 0) {
207
- return {
208
- content: [{ type: 'text', text: 'All checks passed' }],
209
- };
210
- }
211
- lines.push(`\n${totalErrors} error${totalErrors === 1 ? '' : 's'} found`);
212
- return {
213
- content: [{ type: 'text', text: lines.join('\n') }],
214
- isError: true,
215
- };
216
- });
45
+ }, async ({ query, limit }) => toMcp(await searchCommand(ctx, query, { limit })));
46
+ server.tool('lat_expand', 'Expand [[refs]] in text to resolved lat.md section paths with context', { text: z.string().describe('Text containing [[refs]] to expand') }, async ({ text: input }) => toMcp(await expandCommand(ctx, input)));
47
+ server.tool('lat_check', 'Validate all wiki links, code references, and directory indexes in lat.md', {}, async () => toMcp(await checkAllCommand(ctx)));
217
48
  server.tool('lat_refs', 'Find sections that reference a given section via wiki links or @lat code comments', {
218
49
  query: z.string().describe('Section id to find references for'),
219
50
  scope: z
@@ -221,91 +52,7 @@ export async function startMcpServer() {
221
52
  .optional()
222
53
  .default('md')
223
54
  .describe('Where to search: md, code, or md+code'),
224
- }, async ({ query, scope }) => {
225
- const allSections = await loadAllSections(latDir);
226
- const flat = flattenSections(allSections);
227
- const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
228
- const fileIndex = buildFileIndex(allSections);
229
- const { resolved } = resolveRef(query, sectionIds, fileIndex);
230
- const q = resolved.toLowerCase();
231
- const exactMatch = flat.find((s) => s.id.toLowerCase() === q);
232
- if (!exactMatch) {
233
- const matches = findSections(allSections, query);
234
- if (matches.length > 0) {
235
- const suggestions = matches
236
- .map((m) => ` * ${m.section.id} (${m.reason})`)
237
- .join('\n');
238
- return {
239
- content: [
240
- {
241
- type: 'text',
242
- text: `No exact section "${query}" found. Did you mean:\n${suggestions}`,
243
- },
244
- ],
245
- };
246
- }
247
- return {
248
- content: [
249
- {
250
- type: 'text',
251
- text: `No section matching "${query}"`,
252
- },
253
- ],
254
- };
255
- }
256
- const targetId = exactMatch.id.toLowerCase();
257
- const mdMatches = [];
258
- const codeLines = [];
259
- if (scope === 'md' || scope === 'md+code') {
260
- const files = await listLatticeFiles(latDir);
261
- const matchingFromSections = new Set();
262
- for (const file of files) {
263
- const content = await readFile(file, 'utf-8');
264
- const fileRefs = extractRefs(file, content, projectRoot);
265
- for (const ref of fileRefs) {
266
- const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
267
- if (refResolved.toLowerCase() === targetId) {
268
- matchingFromSections.add(ref.fromSection.toLowerCase());
269
- }
270
- }
271
- }
272
- if (matchingFromSections.size > 0) {
273
- const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
274
- for (const s of referrers) {
275
- mdMatches.push({ section: s, reason: 'wiki link' });
276
- }
277
- }
278
- }
279
- if (scope === 'code' || scope === 'md+code') {
280
- const { refs: codeRefs } = await scanCodeRefs(projectRoot);
281
- for (const ref of codeRefs) {
282
- const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
283
- if (codeResolved.toLowerCase() === targetId) {
284
- codeLines.push(`${ref.file}:${ref.line}`);
285
- }
286
- }
287
- }
288
- if (mdMatches.length === 0 && codeLines.length === 0) {
289
- return {
290
- content: [
291
- {
292
- type: 'text',
293
- text: `No references to "${exactMatch.id}" found`,
294
- },
295
- ],
296
- };
297
- }
298
- const parts = [];
299
- if (mdMatches.length > 0) {
300
- parts.push(formatMatches(`References to "${exactMatch.id}":`, mdMatches, projectRoot));
301
- }
302
- if (codeLines.length > 0) {
303
- parts.push('Code references:\n' + codeLines.map((l) => `* ${l}`).join('\n'));
304
- }
305
- return {
306
- content: [{ type: 'text', text: parts.join('\n\n') }],
307
- };
308
- });
55
+ }, async ({ query, scope }) => toMcp(await refsCommand(ctx, query, scope)));
309
56
  const transport = new StdioServerTransport();
310
57
  await server.connect(transport);
311
58
  }
@@ -1,9 +1,11 @@
1
1
  import { unified } from 'unified';
2
2
  import remarkParse from 'remark-parse';
3
3
  import remarkStringify from 'remark-stringify';
4
+ import remarkFrontmatter from 'remark-frontmatter';
4
5
  import { wikiLinkSyntax, wikiLinkFromMarkdown, wikiLinkToMarkdown, } from './extensions/wiki-link/index.js';
5
6
  const processor = unified()
6
7
  .use(remarkParse)
8
+ .use(remarkFrontmatter)
7
9
  .use(remarkStringify)
8
10
  .data('micromarkExtensions', [wikiLinkSyntax()])
9
11
  .data('fromMarkdownExtensions', [wikiLinkFromMarkdown()])