llmnav 0.5.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/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/ROADMAP.md +71 -0
- package/bin/llmnav.js +16 -0
- package/docs/agent-integration.md +114 -0
- package/docs/api.md +290 -0
- package/docs/architecture.md +286 -0
- package/docs/benchmarking.md +164 -0
- package/docs/ci.md +196 -0
- package/docs/cli.md +233 -0
- package/docs/configuration.md +117 -0
- package/docs/editor-integration.md +29 -0
- package/docs/faq.md +59 -0
- package/docs/graph.md +92 -0
- package/docs/language-examples.md +130 -0
- package/docs/migration.md +130 -0
- package/docs/performance-v0.2.md +42 -0
- package/docs/provider-neutral-integration.md +66 -0
- package/docs/publishing.md +86 -0
- package/docs/quickstart.md +139 -0
- package/docs/research.md +31 -0
- package/docs/spec.md +424 -0
- package/examples/provider-neutral-host.d.mts +17 -0
- package/examples/provider-neutral-host.mjs +40 -0
- package/package.json +79 -0
- package/schema/config.schema.json +296 -0
- package/src/agent-protocol.js +117 -0
- package/src/agent-tools.js +61 -0
- package/src/agents.js +127 -0
- package/src/boundaries.js +50 -0
- package/src/changes.js +168 -0
- package/src/cli.js +459 -0
- package/src/config.js +305 -0
- package/src/contracts.js +70 -0
- package/src/declaration.js +334 -0
- package/src/doctor.js +124 -0
- package/src/editor.js +107 -0
- package/src/evaluation.js +67 -0
- package/src/files.js +81 -0
- package/src/formatter.js +23 -0
- package/src/generator.js +528 -0
- package/src/graph-input.js +157 -0
- package/src/graph.js +403 -0
- package/src/incremental.js +262 -0
- package/src/index.d.ts +673 -0
- package/src/index.js +115 -0
- package/src/initializer.js +137 -0
- package/src/inverted-index.js +350 -0
- package/src/parser.js +449 -0
- package/src/project.js +65 -0
- package/src/prompt-bundle.js +108 -0
- package/src/registry.js +107 -0
- package/src/sarif.js +70 -0
- package/src/search-shards.js +75 -0
- package/src/search.js +636 -0
- package/src/spec.d.ts +27 -0
- package/src/spec.js +237 -0
- package/src/tokenizer.js +37 -0
- package/src/transaction.js +557 -0
- package/src/util.js +256 -0
- package/src/validator.js +635 -0
- package/templates/file-card.txt +8 -0
- package/templates/lexicon.json +7 -0
- package/templates/line-card.txt +9 -0
- package/templates/module-card.txt +9 -0
- package/templates/queries.jsonl +1 -0
- package/templates/symbol-card.txt +10 -0
package/src/parser.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/* llmnav/1 module
|
|
2
|
+
id=llmnav.syntax.parse
|
|
3
|
+
role=Parse LLMNav source comments into deterministic semantic cards and rewrite them canonically.
|
|
4
|
+
owns=comment grammar|card materialization|canonical serialization
|
|
5
|
+
excludes=semantic validation|repository search
|
|
6
|
+
search=llmnav parser|comment grammar|canonical formatting
|
|
7
|
+
stability=architecture
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
KEY_ORDER,
|
|
12
|
+
LIST_KEYS,
|
|
13
|
+
REPEATABLE_KEYS,
|
|
14
|
+
SCALAR_KEYS,
|
|
15
|
+
SCOPES,
|
|
16
|
+
SPEC_VERSION,
|
|
17
|
+
} from "./spec.js";
|
|
18
|
+
import { detectNewline, lineAtOffset, normalizeNewlines, splitPipe } from "./util.js";
|
|
19
|
+
|
|
20
|
+
const BLOCK_PATTERNS = [
|
|
21
|
+
{
|
|
22
|
+
style: "block",
|
|
23
|
+
pattern: /\/\*\s*llmnav\/1\s+(file|module|symbol)\b([\s\S]*?)\*\//giu,
|
|
24
|
+
opening: /\/\*\s*llmnav\/1\s+(file|module|symbol)\b/giu,
|
|
25
|
+
terminator: "*/",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
style: "html",
|
|
29
|
+
pattern: /<!--\s*llmnav\/1\s+(file|module|symbol)\b([\s\S]*?)-->/giu,
|
|
30
|
+
opening: /<!--\s*llmnav\/1\s+(file|module|symbol)\b/giu,
|
|
31
|
+
terminator: "-->",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function parseLlmnavBlocks(source, filePath = "<memory>") {
|
|
36
|
+
const blocks = [];
|
|
37
|
+
for (const definition of BLOCK_PATTERNS) {
|
|
38
|
+
definition.pattern.lastIndex = 0;
|
|
39
|
+
for (const match of source.matchAll(definition.pattern)) {
|
|
40
|
+
const matchedRaw = match[0];
|
|
41
|
+
const matchStart = match.index ?? 0;
|
|
42
|
+
if (isInsideStringLiteral(source, matchStart, filePath)) continue;
|
|
43
|
+
const lineStart = source.lastIndexOf("\n", matchStart - 1) + 1;
|
|
44
|
+
const leading = source.slice(lineStart, matchStart);
|
|
45
|
+
const start = /^\s*$/u.test(leading) ? lineStart : matchStart;
|
|
46
|
+
const end = matchStart + matchedRaw.length;
|
|
47
|
+
const indent = start === lineStart ? leading : "";
|
|
48
|
+
const raw = source.slice(start, end);
|
|
49
|
+
blocks.push(
|
|
50
|
+
createBlock({
|
|
51
|
+
source,
|
|
52
|
+
filePath,
|
|
53
|
+
raw,
|
|
54
|
+
body: match[2],
|
|
55
|
+
scope: match[1].toLowerCase(),
|
|
56
|
+
style: definition.style,
|
|
57
|
+
start,
|
|
58
|
+
end,
|
|
59
|
+
indent,
|
|
60
|
+
prefix: null,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
blocks.push(...parseLineBlocks(source, filePath));
|
|
67
|
+
blocks.push(...parseUnterminatedBlockComments(source, filePath, blocks));
|
|
68
|
+
blocks.sort((left, right) => left.start - right.start);
|
|
69
|
+
|
|
70
|
+
const overlapping = [];
|
|
71
|
+
let previousEnd = -1;
|
|
72
|
+
for (const block of blocks) {
|
|
73
|
+
if (block.start < previousEnd) overlapping.push(block);
|
|
74
|
+
previousEnd = Math.max(previousEnd, block.end);
|
|
75
|
+
}
|
|
76
|
+
if (overlapping.length > 0) {
|
|
77
|
+
for (const block of overlapping) {
|
|
78
|
+
block.syntaxErrors.push({
|
|
79
|
+
line: block.startLine,
|
|
80
|
+
message: "LLMNav blocks overlap; use one comment style per card.",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return blocks;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseLineBlocks(source, filePath) {
|
|
89
|
+
const blocks = [];
|
|
90
|
+
const lines = source.split(/(?<=\n)/u);
|
|
91
|
+
const offsets = [];
|
|
92
|
+
let runningOffset = 0;
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
offsets.push(runningOffset);
|
|
95
|
+
runningOffset += line.length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
99
|
+
const line = lines[index];
|
|
100
|
+
const header = line.match(/^(\s*)(\/\/|#|--)\s*llmnav\/1\s+(file|module|symbol)\s*(?:\r?\n)?$/iu);
|
|
101
|
+
if (!header) continue;
|
|
102
|
+
|
|
103
|
+
const start = offsets[index];
|
|
104
|
+
if (isInsideStringLiteral(source, start, filePath)) continue;
|
|
105
|
+
const indent = header[1];
|
|
106
|
+
const prefix = header[2];
|
|
107
|
+
const scope = header[3].toLowerCase();
|
|
108
|
+
const escapedPrefix = prefix === "//" ? "\\/\\/" : prefix === "#" ? "#" : "--";
|
|
109
|
+
const endPattern = new RegExp(`^\\s*${escapedPrefix}\\s*\\/llmnav\\s*(?:\\r?\\n)?$`, "iu");
|
|
110
|
+
const contentPattern = new RegExp(`^\\s*${escapedPrefix}(?:\\s?)(.*?)(?:\\r?\\n)?$`, "u");
|
|
111
|
+
const bodyLines = [];
|
|
112
|
+
let cursor = index + 1;
|
|
113
|
+
let end = start + line.length;
|
|
114
|
+
let foundEnd = false;
|
|
115
|
+
|
|
116
|
+
for (; cursor < lines.length; cursor += 1) {
|
|
117
|
+
const candidate = lines[cursor];
|
|
118
|
+
if (endPattern.test(candidate)) {
|
|
119
|
+
end += candidate.length;
|
|
120
|
+
foundEnd = true;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
const content = candidate.match(contentPattern);
|
|
124
|
+
if (!content) break;
|
|
125
|
+
bodyLines.push(content[1]);
|
|
126
|
+
end += candidate.length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const raw = source.slice(start, end);
|
|
130
|
+
const block = createBlock({
|
|
131
|
+
source,
|
|
132
|
+
filePath,
|
|
133
|
+
raw,
|
|
134
|
+
body: bodyLines.join("\n"),
|
|
135
|
+
scope,
|
|
136
|
+
style: "line",
|
|
137
|
+
start,
|
|
138
|
+
end,
|
|
139
|
+
indent,
|
|
140
|
+
prefix,
|
|
141
|
+
});
|
|
142
|
+
if (!foundEnd) {
|
|
143
|
+
block.syntaxErrors.push({
|
|
144
|
+
line: block.startLine,
|
|
145
|
+
message: `Line-comment LLMNav blocks must end with ${prefix} /llmnav.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
blocks.push(block);
|
|
149
|
+
|
|
150
|
+
if (foundEnd) index = cursor;
|
|
151
|
+
else if (cursor >= lines.length) index = lines.length - 1;
|
|
152
|
+
else index = Math.max(index, cursor - 1);
|
|
153
|
+
}
|
|
154
|
+
return blocks;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseUnterminatedBlockComments(source, filePath, parsedBlocks) {
|
|
158
|
+
const blocks = [];
|
|
159
|
+
for (const definition of BLOCK_PATTERNS) {
|
|
160
|
+
definition.opening.lastIndex = 0;
|
|
161
|
+
for (const match of source.matchAll(definition.opening)) {
|
|
162
|
+
const tokenStart = match.index ?? 0;
|
|
163
|
+
if (isInsideStringLiteral(source, tokenStart, filePath)) continue;
|
|
164
|
+
if (parsedBlocks.some((block) => tokenStart >= block.start && tokenStart < block.end)) continue;
|
|
165
|
+
const bodyStart = tokenStart + match[0].length;
|
|
166
|
+
if (source.indexOf(definition.terminator, bodyStart) >= 0) continue;
|
|
167
|
+
|
|
168
|
+
const lineStart = source.lastIndexOf("\n", tokenStart - 1) + 1;
|
|
169
|
+
const leading = source.slice(lineStart, tokenStart);
|
|
170
|
+
const start = /^\s*$/u.test(leading) ? lineStart : tokenStart;
|
|
171
|
+
const indent = start === lineStart ? leading : "";
|
|
172
|
+
const end = source.length;
|
|
173
|
+
const block = createBlock({
|
|
174
|
+
source,
|
|
175
|
+
filePath,
|
|
176
|
+
raw: source.slice(start, end),
|
|
177
|
+
body: source.slice(bodyStart),
|
|
178
|
+
scope: match[1].toLowerCase(),
|
|
179
|
+
style: definition.style,
|
|
180
|
+
start,
|
|
181
|
+
end,
|
|
182
|
+
indent,
|
|
183
|
+
prefix: null,
|
|
184
|
+
});
|
|
185
|
+
block.syntaxErrors.push({
|
|
186
|
+
line: block.startLine,
|
|
187
|
+
message: `${definition.style === "html" ? "HTML-comment" : "Block-comment"} LLMNav blocks must end with ${definition.terminator}.`,
|
|
188
|
+
});
|
|
189
|
+
blocks.push(block);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return blocks;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isInsideStringLiteral(source, targetOffset, filePath) {
|
|
196
|
+
const extension = filePath.toLowerCase().match(/\.[a-z0-9]+$/u)?.[0] ?? "";
|
|
197
|
+
const hashComments = [".py", ".rb", ".sh", ".bash", ".zsh"].includes(extension);
|
|
198
|
+
const dashComments = extension === ".sql";
|
|
199
|
+
const tripleQuotes = extension === ".py";
|
|
200
|
+
let state = "normal";
|
|
201
|
+
let escaped = false;
|
|
202
|
+
|
|
203
|
+
for (let index = 0; index < targetOffset; index += 1) {
|
|
204
|
+
const character = source[index];
|
|
205
|
+
const next = source[index + 1];
|
|
206
|
+
const nextTwo = source.slice(index, index + 3);
|
|
207
|
+
const nextFour = source.slice(index, index + 4);
|
|
208
|
+
|
|
209
|
+
if (state === "line-comment") {
|
|
210
|
+
if (character === "\n") state = "normal";
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (state === "block-comment") {
|
|
214
|
+
if (character === "*" && next === "/") {
|
|
215
|
+
state = "normal";
|
|
216
|
+
index += 1;
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (state === "html-comment") {
|
|
221
|
+
if (source.slice(index, index + 3) === "-->") {
|
|
222
|
+
state = "normal";
|
|
223
|
+
index += 2;
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (state === "triple-single") {
|
|
228
|
+
if (nextTwo === "'''") {
|
|
229
|
+
state = "normal";
|
|
230
|
+
index += 2;
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (state === "triple-double") {
|
|
235
|
+
if (nextTwo === '"""') {
|
|
236
|
+
state = "normal";
|
|
237
|
+
index += 2;
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (state === "single" || state === "double" || state === "backtick") {
|
|
242
|
+
if (escaped) {
|
|
243
|
+
escaped = false;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (character === "\\") {
|
|
247
|
+
escaped = true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const terminator = state === "single" ? "'" : state === "double" ? '"' : "`";
|
|
251
|
+
if (character === terminator) state = "normal";
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (nextFour === "<!--") {
|
|
256
|
+
state = "html-comment";
|
|
257
|
+
index += 3;
|
|
258
|
+
} else if (character === "/" && next === "*") {
|
|
259
|
+
state = "block-comment";
|
|
260
|
+
index += 1;
|
|
261
|
+
} else if (character === "/" && next === "/") {
|
|
262
|
+
state = "line-comment";
|
|
263
|
+
index += 1;
|
|
264
|
+
} else if (hashComments && character === "#") {
|
|
265
|
+
state = "line-comment";
|
|
266
|
+
} else if (dashComments && character === "-" && next === "-") {
|
|
267
|
+
state = "line-comment";
|
|
268
|
+
index += 1;
|
|
269
|
+
} else if (tripleQuotes && nextTwo === "'''") {
|
|
270
|
+
state = "triple-single";
|
|
271
|
+
index += 2;
|
|
272
|
+
} else if (tripleQuotes && nextTwo === '"""') {
|
|
273
|
+
state = "triple-double";
|
|
274
|
+
index += 2;
|
|
275
|
+
} else if (character === "'" && (extension !== ".rs" || looksLikeRustCharacterLiteral(source, index))) {
|
|
276
|
+
state = "single";
|
|
277
|
+
escaped = false;
|
|
278
|
+
} else if (character === '"') {
|
|
279
|
+
state = "double";
|
|
280
|
+
escaped = false;
|
|
281
|
+
} else if (character === "`") {
|
|
282
|
+
state = "backtick";
|
|
283
|
+
escaped = false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return state !== "normal";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function looksLikeRustCharacterLiteral(source, offset) {
|
|
291
|
+
return /^'(?:\\.|[^'\\\r\n])'/u.test(source.slice(offset));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function createBlock({ source, filePath, raw, body, scope, style, start, end, indent, prefix }) {
|
|
295
|
+
const startLine = lineAtOffset(source, start);
|
|
296
|
+
const bodyStartLine = style === "line" ? startLine + 1 : startLine;
|
|
297
|
+
const parsed = parseBody(body, bodyStartLine);
|
|
298
|
+
const card = materializeCard(scope, parsed.entries);
|
|
299
|
+
return {
|
|
300
|
+
specVersion: SPEC_VERSION,
|
|
301
|
+
filePath,
|
|
302
|
+
scope,
|
|
303
|
+
card,
|
|
304
|
+
entries: parsed.entries,
|
|
305
|
+
syntaxErrors: parsed.errors,
|
|
306
|
+
style,
|
|
307
|
+
prefix,
|
|
308
|
+
indent,
|
|
309
|
+
raw,
|
|
310
|
+
start,
|
|
311
|
+
end,
|
|
312
|
+
startLine,
|
|
313
|
+
endLine: lineAtOffset(source, Math.max(start, end - 1)),
|
|
314
|
+
newline: detectNewline(raw),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseBody(body, startLine) {
|
|
319
|
+
const entries = [];
|
|
320
|
+
const errors = [];
|
|
321
|
+
const normalized = normalizeNewlines(body);
|
|
322
|
+
for (const [index, originalLine] of normalized.split("\n").entries()) {
|
|
323
|
+
const cleaned = originalLine.replace(/^\s*\*?\s?/u, "").trimEnd();
|
|
324
|
+
if (!cleaned.trim()) continue;
|
|
325
|
+
const match = cleaned.match(/^([a-z][a-z0-9_-]*)=(.*)$/u);
|
|
326
|
+
if (!match) {
|
|
327
|
+
errors.push({
|
|
328
|
+
line: startLine + index,
|
|
329
|
+
message: `Expected key=value, received ${JSON.stringify(cleaned.trim())}.`,
|
|
330
|
+
});
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
entries.push({
|
|
334
|
+
key: match[1],
|
|
335
|
+
value: match[2].trim(),
|
|
336
|
+
line: startLine + index,
|
|
337
|
+
order: entries.length,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return { entries, errors };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function materializeCard(scope, entries) {
|
|
344
|
+
const card = {
|
|
345
|
+
scope,
|
|
346
|
+
id: "",
|
|
347
|
+
role: "",
|
|
348
|
+
owns: [],
|
|
349
|
+
excludes: [],
|
|
350
|
+
search: [],
|
|
351
|
+
invariant: [],
|
|
352
|
+
effect: [],
|
|
353
|
+
risk: [],
|
|
354
|
+
rel: [],
|
|
355
|
+
stability: "",
|
|
356
|
+
unknown: [],
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
if (SCALAR_KEYS.includes(entry.key)) {
|
|
361
|
+
card[entry.key] = entry.value;
|
|
362
|
+
} else if (LIST_KEYS.includes(entry.key)) {
|
|
363
|
+
card[entry.key].push(...splitPipe(entry.value));
|
|
364
|
+
} else if (REPEATABLE_KEYS.includes(entry.key)) {
|
|
365
|
+
card[entry.key].push(entry.value);
|
|
366
|
+
} else {
|
|
367
|
+
card.unknown.push({ key: entry.key, value: entry.value, line: entry.line });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return card;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function formatLlmnavBlock(block) {
|
|
374
|
+
if (!SCOPES.includes(block.scope)) throw new Error(`Unknown LLMNav scope: ${block.scope}`);
|
|
375
|
+
const lines = [];
|
|
376
|
+
for (const key of KEY_ORDER) {
|
|
377
|
+
const value = block.card[key];
|
|
378
|
+
if (Array.isArray(value)) {
|
|
379
|
+
if (value.length === 0) continue;
|
|
380
|
+
if (REPEATABLE_KEYS.includes(key)) {
|
|
381
|
+
for (const item of value) lines.push(`${key}=${item.trim()}`);
|
|
382
|
+
} else {
|
|
383
|
+
lines.push(`${key}=${value.map((item) => item.trim()).filter(Boolean).join("|")}`);
|
|
384
|
+
}
|
|
385
|
+
} else if (typeof value === "string" && value.trim()) {
|
|
386
|
+
lines.push(`${key}=${value.trim()}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const newline = block.newline ?? "\n";
|
|
391
|
+
const indent = block.indent ?? "";
|
|
392
|
+
if (block.style === "html") {
|
|
393
|
+
return `${indent}<!-- llmnav/1 ${block.scope}${newline}${lines.map((line) => `${indent}${line}`).join(newline)}${newline}${indent}-->`;
|
|
394
|
+
}
|
|
395
|
+
if (block.style === "line") {
|
|
396
|
+
const prefix = block.prefix ?? "//";
|
|
397
|
+
const trailingNewline = block.raw.endsWith("\r\n") ? "\r\n" : block.raw.endsWith("\n") ? "\n" : "";
|
|
398
|
+
return `${indent}${prefix} llmnav/1 ${block.scope}${newline}${lines
|
|
399
|
+
.map((line) => `${indent}${prefix} ${line}`)
|
|
400
|
+
.join(newline)}${newline}${indent}${prefix} /llmnav${trailingNewline}`;
|
|
401
|
+
}
|
|
402
|
+
return `${indent}/* llmnav/1 ${block.scope}${newline}${lines.map((line) => `${indent}${line}`).join(newline)}${newline}${indent}*/`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function canonicalizeSource(source, filePath = "<memory>") {
|
|
406
|
+
const blocks = parseLlmnavBlocks(source, filePath);
|
|
407
|
+
if (blocks.length === 0) return { source, changed: false, blocks, errors: [] };
|
|
408
|
+
const errors = [];
|
|
409
|
+
const safeBlocks = [];
|
|
410
|
+
for (const block of blocks) {
|
|
411
|
+
const blockErrors = [];
|
|
412
|
+
for (const syntaxError of block.syntaxErrors) {
|
|
413
|
+
blockErrors.push({ line: syntaxError.line, message: syntaxError.message });
|
|
414
|
+
}
|
|
415
|
+
for (const unknown of block.card.unknown) {
|
|
416
|
+
blockErrors.push({ line: unknown.line, message: `Unknown field ${unknown.key} must be fixed before formatting.` });
|
|
417
|
+
}
|
|
418
|
+
const scalarCounts = new Map();
|
|
419
|
+
for (const entry of block.entries.filter((entry) => SCALAR_KEYS.includes(entry.key))) {
|
|
420
|
+
scalarCounts.set(entry.key, (scalarCounts.get(entry.key) ?? 0) + 1);
|
|
421
|
+
}
|
|
422
|
+
for (const [key, count] of scalarCounts) {
|
|
423
|
+
if (count > 1) blockErrors.push({ line: block.startLine, message: `Duplicate scalar field ${key} must be fixed before formatting.` });
|
|
424
|
+
}
|
|
425
|
+
errors.push(...blockErrors);
|
|
426
|
+
if (blockErrors.length > 0) continue;
|
|
427
|
+
safeBlocks.push(block);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let output = source;
|
|
431
|
+
for (const block of [...safeBlocks].sort((left, right) => right.start - left.start)) {
|
|
432
|
+
const formatted = formatLlmnavBlock(block);
|
|
433
|
+
output = `${output.slice(0, block.start)}${formatted}${output.slice(block.end)}`;
|
|
434
|
+
}
|
|
435
|
+
return { source: output, changed: output !== source, blocks, errors };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function cardToCanonicalObject(card) {
|
|
439
|
+
const result = { scope: card.scope };
|
|
440
|
+
for (const key of KEY_ORDER) {
|
|
441
|
+
const value = card[key];
|
|
442
|
+
if (Array.isArray(value)) {
|
|
443
|
+
if (value.length > 0) result[key] = [...value];
|
|
444
|
+
} else if (value) {
|
|
445
|
+
result[key] = value;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return result;
|
|
449
|
+
}
|
package/src/project.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { findAttachedDeclaration, extractImports } from "./declaration.js";
|
|
4
|
+
import { collectSourceFiles } from "./files.js";
|
|
5
|
+
import { parseLlmnavBlocks } from "./parser.js";
|
|
6
|
+
import { loadRegistry } from "./registry.js";
|
|
7
|
+
import { relativePosix, sha256 } from "./util.js";
|
|
8
|
+
|
|
9
|
+
export async function scanProject(root, options = {}) {
|
|
10
|
+
const { config, configPath } = await loadConfig(root);
|
|
11
|
+
const files = await collectSourceFiles(root, config, options.paths ?? []);
|
|
12
|
+
const records = [];
|
|
13
|
+
const fileRecords = [];
|
|
14
|
+
let sourceBytes = 0;
|
|
15
|
+
let semanticBytes = 0;
|
|
16
|
+
|
|
17
|
+
for (const absolutePath of files) {
|
|
18
|
+
const source = await readFile(absolutePath, "utf8");
|
|
19
|
+
const relativePath = relativePosix(root, absolutePath);
|
|
20
|
+
const blocks = parseLlmnavBlocks(source, relativePath);
|
|
21
|
+
const contentHash = sha256(source);
|
|
22
|
+
sourceBytes += Buffer.byteLength(source);
|
|
23
|
+
semanticBytes += blocks.reduce((sum, block) => sum + Buffer.byteLength(block.raw), 0);
|
|
24
|
+
const imports = extractImports(source, relativePath);
|
|
25
|
+
const fileRecord = {
|
|
26
|
+
absolutePath,
|
|
27
|
+
relativePath,
|
|
28
|
+
source,
|
|
29
|
+
contentHash,
|
|
30
|
+
bodyHash: contentHash,
|
|
31
|
+
sourceBytes: Buffer.byteLength(source),
|
|
32
|
+
semanticBytes: blocks.reduce((sum, block) => sum + Buffer.byteLength(block.raw), 0),
|
|
33
|
+
blocks,
|
|
34
|
+
imports,
|
|
35
|
+
};
|
|
36
|
+
fileRecords.push(fileRecord);
|
|
37
|
+
for (const block of blocks) {
|
|
38
|
+
const declaration = findAttachedDeclaration(source, block, relativePath);
|
|
39
|
+
records.push({
|
|
40
|
+
root,
|
|
41
|
+
absolutePath,
|
|
42
|
+
relativePath,
|
|
43
|
+
source,
|
|
44
|
+
bodyHash: declaration?.bodyHash ?? contentHash,
|
|
45
|
+
imports,
|
|
46
|
+
block,
|
|
47
|
+
card: block.card,
|
|
48
|
+
declaration,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const registry = await loadRegistry(root);
|
|
54
|
+
return {
|
|
55
|
+
root,
|
|
56
|
+
config,
|
|
57
|
+
configPath,
|
|
58
|
+
files,
|
|
59
|
+
fileRecords,
|
|
60
|
+
records,
|
|
61
|
+
registry,
|
|
62
|
+
sourceBytes,
|
|
63
|
+
semanticBytes,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/* llmnav/1 module
|
|
2
|
+
id=llmnav.agent.prompt-bundle
|
|
3
|
+
role=Assemble deterministic prompt-prefix partitions with explicit cache scope and integrity hashes.
|
|
4
|
+
owns=prompt bundle schema|partition ordering|cache boundaries|bundle integrity
|
|
5
|
+
excludes=provider cache API calls|volatile task context|source discovery
|
|
6
|
+
search=prompt prefix bundle|cache breakpoint|stable prompt partition|context assembly
|
|
7
|
+
rel=workflow>llmnav.agent.tool-schema
|
|
8
|
+
rel=workflow>llmnav.agent.install
|
|
9
|
+
rel=workflow>llmnav.index.generate
|
|
10
|
+
stability=architecture
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { loadConfig } from "./config.js";
|
|
15
|
+
import { approximateTokens, compareText, readText, sha256, stableJson, stableStringify, toPosix } from "./util.js";
|
|
16
|
+
|
|
17
|
+
export const PROMPT_BUNDLE_SCHEMA_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
export function buildPromptPrefixBundle(input) {
|
|
20
|
+
const modules = [...(input.modules ?? [])].sort((left, right) => compareText(left.id, right.id));
|
|
21
|
+
const partitions = [
|
|
22
|
+
partition("package:tool-definitions", "package", "application/json", stableStringify(input.toolDefinitions)),
|
|
23
|
+
partition("package:agent-protocol", "package", "text/markdown", input.agentProtocol),
|
|
24
|
+
partition("repository:core", "repository", "text/plain", input.repositoryCore),
|
|
25
|
+
...modules.map((item) => partition(`module:${item.id}`, "module", "text/plain", item.content)),
|
|
26
|
+
];
|
|
27
|
+
const basePartitionIds = partitions.filter((item) => item.cacheScope !== "module").map((item) => item.id);
|
|
28
|
+
const modulePartitionIds = partitions.filter((item) => item.cacheScope === "module").map((item) => item.id);
|
|
29
|
+
const bundleHash = sha256(stableJson(partitions.map((item) => [item.id, item.contentHash])));
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: PROMPT_BUNDLE_SCHEMA_VERSION,
|
|
32
|
+
repositoryId: input.repositoryId,
|
|
33
|
+
bundleHash,
|
|
34
|
+
assembly: {
|
|
35
|
+
basePartitionIds,
|
|
36
|
+
modulePartitionIds,
|
|
37
|
+
volatileContextAfter: true,
|
|
38
|
+
},
|
|
39
|
+
partitions,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderPromptPrefixBundle(bundle) {
|
|
44
|
+
return stableStringify(bundle);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isCompatiblePromptPrefixBundle(bundle, repositoryId = undefined) {
|
|
48
|
+
if (!bundle || bundle.schemaVersion !== PROMPT_BUNDLE_SCHEMA_VERSION || typeof bundle.repositoryId !== "string" ||
|
|
49
|
+
typeof bundle.bundleHash !== "string" || !bundle.assembly || !Array.isArray(bundle.partitions)) return false;
|
|
50
|
+
if (repositoryId !== undefined && bundle.repositoryId !== repositoryId) return false;
|
|
51
|
+
if (!Array.isArray(bundle.assembly.basePartitionIds) || !Array.isArray(bundle.assembly.modulePartitionIds) ||
|
|
52
|
+
bundle.assembly.volatileContextAfter !== true) return false;
|
|
53
|
+
if (new Set(bundle.partitions.map((item) => item.id)).size !== bundle.partitions.length) return false;
|
|
54
|
+
if (!bundle.partitions.every(validPartition)) return false;
|
|
55
|
+
const base = bundle.partitions.filter((item) => item.cacheScope !== "module").map((item) => item.id);
|
|
56
|
+
const modules = bundle.partitions.filter((item) => item.cacheScope === "module").map((item) => item.id);
|
|
57
|
+
if (stableJson(base) !== stableJson(bundle.assembly.basePartitionIds) ||
|
|
58
|
+
stableJson(modules) !== stableJson(bundle.assembly.modulePartitionIds)) return false;
|
|
59
|
+
return bundle.bundleHash === sha256(stableJson(bundle.partitions.map((item) => [item.id, item.contentHash])));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function loadPromptPrefixBundle(root) {
|
|
63
|
+
const { config } = await loadConfig(root);
|
|
64
|
+
const relativePath = `${toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "")}/prompt-prefix.json`;
|
|
65
|
+
const content = await readText(path.join(root, relativePath), "");
|
|
66
|
+
if (!content) throw new Error(`Missing generated prompt bundle ${relativePath}.`);
|
|
67
|
+
let bundle;
|
|
68
|
+
try {
|
|
69
|
+
bundle = JSON.parse(content);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error(`Malformed generated prompt bundle ${relativePath}.`);
|
|
72
|
+
}
|
|
73
|
+
if (!isCompatiblePromptPrefixBundle(bundle, config.repositoryId)) {
|
|
74
|
+
throw new Error(`Incompatible generated prompt bundle ${relativePath}.`);
|
|
75
|
+
}
|
|
76
|
+
const manifestContent = await readText(path.join(root, config.generation.cacheDirectory, "manifest.json"), "");
|
|
77
|
+
const manifest = manifestContent ? JSON.parse(manifestContent) : null;
|
|
78
|
+
if (manifest?.files?.[relativePath] !== sha256(content)) throw new Error(`Prompt bundle hash does not match manifest.json.`);
|
|
79
|
+
return bundle;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function partition(id, cacheScope, contentType, content) {
|
|
83
|
+
const normalizedContent = String(content).replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
cacheScope,
|
|
87
|
+
contentType,
|
|
88
|
+
contentHash: sha256(normalizedContent),
|
|
89
|
+
estimatedTokens: approximateTokens(normalizedContent),
|
|
90
|
+
cacheBoundaryAfter: true,
|
|
91
|
+
content: normalizedContent,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validPartition(item) {
|
|
96
|
+
return Boolean(
|
|
97
|
+
item &&
|
|
98
|
+
typeof item.id === "string" &&
|
|
99
|
+
["package", "repository", "module"].includes(item.cacheScope) &&
|
|
100
|
+
typeof item.contentType === "string" &&
|
|
101
|
+
typeof item.content === "string" &&
|
|
102
|
+
typeof item.contentHash === "string" &&
|
|
103
|
+
item.contentHash === sha256(item.content) &&
|
|
104
|
+
Number.isInteger(item.estimatedTokens) &&
|
|
105
|
+
item.estimatedTokens === approximateTokens(item.content) &&
|
|
106
|
+
item.cacheBoundaryAfter === true,
|
|
107
|
+
);
|
|
108
|
+
}
|