lat.md 0.6.0 → 0.7.0

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;
@@ -98,7 +98,7 @@ export function parseSections(filePath, content, projectRoot) {
98
98
  children: [],
99
99
  startLine,
100
100
  endLine: 0,
101
- body: '',
101
+ firstParagraph: '',
102
102
  };
103
103
  if (parent) {
104
104
  parent.children.push(section);
@@ -119,7 +119,7 @@ export function parseSections(filePath, content, projectRoot) {
119
119
  flat[i].endLine = fileLastLine;
120
120
  }
121
121
  }
122
- // Extract body: first paragraph after each heading
122
+ // Extract firstParagraph: first paragraph after each heading
123
123
  const children = tree.children;
124
124
  let headingIdx = 0;
125
125
  for (let i = 0; i < children.length; i++) {
@@ -130,7 +130,7 @@ export function parseSections(filePath, content, projectRoot) {
130
130
  if (children[j].type === 'heading')
131
131
  break;
132
132
  if (children[j].type === 'paragraph') {
133
- flat[headingIdx].body = inlineText(children[j]);
133
+ flat[headingIdx].firstParagraph = inlineText(children[j]);
134
134
  break;
135
135
  }
136
136
  }
@@ -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
  }