llmnav 0.9.1 → 0.9.7
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 +23 -1
- package/README.md +2 -2
- package/ROADMAP.md +1 -1
- package/docs/api.md +12 -4
- package/examples/provider-neutral-host.d.mts +1 -0
- package/examples/provider-neutral-host.mjs +10 -8
- package/package.json +1 -1
- package/src/agent-protocol.js +11 -2
- package/src/evaluation.js +42 -18
- package/src/incremental.js +45 -14
- package/src/index.d.ts +15 -2
- package/src/parser.js +98 -3
- package/src/prompt-bundle.js +7 -1
- package/src/search.js +110 -24
- package/src/spec.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,29 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
|
|
5
5
|
The npm package follows Semantic Versioning. The `llmnav/N` source protocol is versioned independently.
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.9.7] — 2026-09-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
* Recognize Go and Rust raw string boundaries before scanning LLMNav cards, preserving real cards and leaving card-shaped literal text untouched.
|
|
12
|
+
* Rank evaluation queries with the same repository graph used by project queries and sessions.
|
|
13
|
+
* Read prompt bundles under the generation lock and let hosts refresh prompt partitions and search from one generation snapshot.
|
|
14
|
+
* Validate the primary index against its manifest before trusting cached search and graph data, while retaining manifestless schema v1 reads.
|
|
15
|
+
* Reuse persisted parsed file state only when its manifest hash, record shape, and local stat-hint generation agree; otherwise read source and rebuild safely. Explicit caller-provided state remains reusable.
|
|
16
|
+
* Classify missing and ambiguous semantic IDs consistently across show and context agent operations.
|
|
17
|
+
* Report whether retrieval evaluation passed, failed, was invalid, or lacks enough cases; allow API callers to require a minimum case count without changing the default CLI exit behavior.
|
|
18
|
+
* Evaluate explicit no-result queries separately from positive recall, and require every no-result case to return no cards before evaluation passes.
|
|
19
|
+
|
|
20
|
+
### Performance
|
|
21
|
+
|
|
22
|
+
* Narrow session ID substring checks to precomputed candidates and resolve alias targets directly, preserving direct-query results and ranking.
|
|
23
|
+
|
|
24
|
+
## [0.9.2] — 2026-09-09
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
* Recognize JavaScript/TypeScript regular-expression literals while masking source strings, so quotes and comment-like text inside regexes do not hide later cards or create false cards. Preserve division expressions and ordinary JSX closing tags.
|
|
29
|
+
* Invalidate older parsed-file caches so unchanged consumer files receive the corrected parsing behavior.
|
|
8
30
|
|
|
9
31
|
## [0.9.1] — 2026-09-07
|
|
10
32
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ It adds compact, stable metadata to a small number of architectural and behavior
|
|
|
6
6
|
|
|
7
7
|
LLMNav is not a documentation generator, an embedding database, or a reason to annotate every function. It is a zero-runtime-dependency Node.js CLI and ESM library for reducing broad repository scans, irrelevant context, stale hand-written links, repeated card tokenization, and avoidable cache invalidation.
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Current capabilities
|
|
10
10
|
|
|
11
11
|
* The backward-compatible `llmnav/1` source comment specification
|
|
12
12
|
* A parser and data-loss-resistant canonical formatter
|
|
@@ -295,7 +295,7 @@ The project uses the Node.js standard library and built-in test runner. There is
|
|
|
295
295
|
|
|
296
296
|
## Status
|
|
297
297
|
|
|
298
|
-
LLMNav is
|
|
298
|
+
LLMNav is a pre-1.0 CLI and ESM library. The source format remains `llmnav/1`, and the primary index uses schemaVersion 1. Package, source-protocol, and generated-format versions change independently; see the [compatibility policy](docs/compatibility.md) and [roadmap](ROADMAP.md) for their respective guarantees and remaining 1.0 criteria.
|
|
299
299
|
|
|
300
300
|
## License
|
|
301
301
|
|
package/ROADMAP.md
CHANGED
|
@@ -85,7 +85,7 @@ Implemented:
|
|
|
85
85
|
|
|
86
86
|
## 1.0 criteria
|
|
87
87
|
|
|
88
|
-
The source
|
|
88
|
+
The `llmnav/1` source protocol and schemaVersion 1 primary index already have compatibility guarantees. A 1.0 release requires evidence from multiple TypeScript, Go, Rust, Python, and mixed-language repositories, migration tooling, benchmark fixtures with published methodology, sustained Windows and Linux verification, and no unresolved high-severity parser or transaction ambiguity.
|
|
89
89
|
|
|
90
90
|
In progress:
|
|
91
91
|
|
package/docs/api.md
CHANGED
|
@@ -268,9 +268,15 @@ The normative source vocabulary remains available from `llmnav/spec`.
|
|
|
268
268
|
import { KEY_ORDER, EFFECT_KINDS, RISK_KINDS } from "llmnav/spec";
|
|
269
269
|
```
|
|
270
270
|
|
|
271
|
+
## Retrieval evaluation
|
|
272
|
+
|
|
273
|
+
`evaluateProject(root, { minimumCases: 1 })` requires at least one reviewed query before returning `ok: true`. The result's `status` is `passed`, `failed`, `insufficient`, `unmeasured`, or `invalid`. An empty dataset keeps the existing default `ok: true` for callers that use `eval` during setup, but always reports `status: "unmeasured"`; pass/fail gates should set `minimumCases` explicitly. The default CLI does not set this option.
|
|
274
|
+
|
|
275
|
+
Each query record requires a non-empty `query`. A positive case supplies at least one non-empty ID in `expected`; multiple IDs mean **any one** is an acceptable answer. A no-result case supplies `"expectNoResults": true` and omits `expected`. Recall@1, Recall@5, and MRR use only positive cases, while every no-result case must return zero cards for `status: "passed"`. At least one positive case is needed to pass. The current format does not require every listed ID to be found.
|
|
276
|
+
|
|
271
277
|
## Agent operation protocol
|
|
272
278
|
|
|
273
|
-
`getAgentToolDefinitions()` returns defensive copies of
|
|
279
|
+
`getAgentToolDefinitions()` returns defensive copies of five schemaVersion 1 tool definitions in fixed order. Their JSON Schema inputs reject unknown fields and deliberately omit the repository root.
|
|
274
280
|
|
|
275
281
|
```js
|
|
276
282
|
import { executeAgentOperation, getAgentToolDefinitions } from "llmnav";
|
|
@@ -283,7 +289,7 @@ const result = await executeAgentOperation(
|
|
|
283
289
|
);
|
|
284
290
|
```
|
|
285
291
|
|
|
286
|
-
The trusted wrapper binds `root`; the model supplies only the validated operation input. Results use one schemaVersion 1 envelope containing `operation`, `ok`, `data`, and `error`. Input errors use `LNVAP002`, missing IDs use `LNVAP404`, and unexpected operation failures use `LNVAP500`.
|
|
292
|
+
The trusted wrapper binds `root`; the model supplies only the validated operation input. Results use one schemaVersion 1 envelope containing `operation`, `ok`, `data`, and `error`. Input errors use `LNVAP002`, missing IDs use `LNVAP404`, ambiguous IDs use `LNVAP409`, and unexpected operation failures use `LNVAP500`.
|
|
287
293
|
|
|
288
294
|
Long-lived hosts can load one explicit snapshot for repeated navigation calls:
|
|
289
295
|
|
|
@@ -302,11 +308,13 @@ await session.refresh(); // after generation or checkout changes
|
|
|
302
308
|
|
|
303
309
|
`query`, `show`, and `context` reuse the loaded index, postings, graph, lexicon, and registry. `refresh()` replaces the complete snapshot; it never mutates one layer in place. The `check` operation still scans current source and does not use session data.
|
|
304
310
|
|
|
311
|
+
Pass `{ withPromptBundle: true }` to `createProjectSession` when a host needs prompt partitions and navigation from the same generation. The returned session exposes `promptBundle` and a content-derived `generationHash`; `refresh()` replaces both alongside the search snapshot. The hash identifies generated cache content, not whether ungenerated source edits exist in the working tree.
|
|
312
|
+
|
|
305
313
|
Sessions prepare graph adjacency and traversal order once per snapshot for reuse by `query` and `context`. Refreshing prepares a new graph, and obsolete prepared state can be garbage-collected with the previous snapshot. Direct `queryIndex` and `queryPreparedIndex` calls do not cache caller-owned graphs, so in-place changes to supplied edges remain visible on the next call.
|
|
306
314
|
|
|
307
|
-
Session queries also reuse card lookup tables, normalized IDs and aliases, and graph validation. ID substring
|
|
315
|
+
Session queries also reuse card lookup tables, normalized IDs and aliases, and graph validation. Session ID substring checks use precomputed three-code-unit candidates before exact verification; direct queries retain their full ID scan. Phrase matching still scans all documents to preserve ranking. Session `query` and `show` return detached results: caller edits cannot invalidate the private prepared snapshot. These tables are rebuilt on `refresh()` and retained only with that snapshot.
|
|
308
316
|
|
|
309
|
-
The typed `llmnav/examples/provider-neutral-host.mjs` export composes these APIs into a trusted-root closure. It exposes tool definitions, base and module-selected prompt partitions, one snapshot-backed operation executor, and an explicit refresh method without importing a model SDK.
|
|
317
|
+
The typed `llmnav/examples/provider-neutral-host.mjs` export composes these APIs into a trusted-root closure. It exposes tool definitions, base and module-selected prompt partitions, the generation hash, one snapshot-backed operation executor, and an explicit refresh method without importing a model SDK. Refresh prepares a complete replacement before switching the host's prompt and search references.
|
|
310
318
|
|
|
311
319
|
`buildPromptPrefixBundle(input)` constructs ordered package, repository, and module partitions with normalized newlines, SHA-256 content hashes, estimated token counts, and explicit cache-boundary hints. `renderPromptPrefixBundle` serializes it deterministically. `loadPromptPrefixBundle(root)` accepts only a schema-compatible artifact whose exact bytes match `manifest.json`.
|
|
312
320
|
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
export interface LlmnavHost {
|
|
9
9
|
toolDefinitions: AgentToolDefinition[];
|
|
10
10
|
basePromptPartitions: PromptPrefixPartition[];
|
|
11
|
+
generationHash: string;
|
|
11
12
|
selectPromptPartitions(moduleIds?: string[]): PromptPrefixPartition[];
|
|
12
13
|
execute(call: { name: string; input?: Record<string, unknown> }): Promise<AgentOperationResult>;
|
|
13
14
|
refresh(): Promise<LlmnavHost>;
|
|
@@ -13,15 +13,15 @@ import {
|
|
|
13
13
|
executeAgentOperation,
|
|
14
14
|
createProjectSession,
|
|
15
15
|
getAgentToolDefinitions,
|
|
16
|
-
loadPromptPrefixBundle,
|
|
17
16
|
} from "llmnav";
|
|
18
17
|
|
|
19
18
|
export async function createLlmnavHost(root) {
|
|
20
|
-
let
|
|
21
|
-
let
|
|
19
|
+
let session = await createProjectSession(root, { withPromptBundle: true });
|
|
20
|
+
let bundle = session.promptBundle;
|
|
22
21
|
const host = {
|
|
23
22
|
toolDefinitions: getAgentToolDefinitions(),
|
|
24
23
|
basePromptPartitions: selectPromptPartitions(bundle),
|
|
24
|
+
generationHash: session.generationHash,
|
|
25
25
|
selectPromptPartitions(moduleIds = []) {
|
|
26
26
|
return selectPromptPartitions(bundle, moduleIds);
|
|
27
27
|
},
|
|
@@ -29,11 +29,13 @@ export async function createLlmnavHost(root) {
|
|
|
29
29
|
return executeAgentOperation(root, call.name, call.input ?? {}, { session });
|
|
30
30
|
},
|
|
31
31
|
async refresh() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
const nextSession = await createProjectSession(root, { withPromptBundle: true });
|
|
33
|
+
const nextBundle = nextSession.promptBundle;
|
|
34
|
+
const nextBase = selectPromptPartitions(nextBundle);
|
|
35
|
+
session = nextSession;
|
|
36
|
+
bundle = nextBundle;
|
|
37
|
+
host.basePromptPartitions = nextBase;
|
|
38
|
+
host.generationHash = nextSession.generationHash;
|
|
37
39
|
return host;
|
|
38
40
|
},
|
|
39
41
|
};
|
package/package.json
CHANGED
package/src/agent-protocol.js
CHANGED
|
@@ -12,7 +12,7 @@ stability=contract
|
|
|
12
12
|
|
|
13
13
|
import { loadGraphInputs } from "./graph-input.js";
|
|
14
14
|
import { scanProject } from "./project.js";
|
|
15
|
-
import { buildContext, queryProject, showProjectCard } from "./search.js";
|
|
15
|
+
import { buildContext, queryProject, SemanticIdLookupError, showProjectCard } from "./search.js";
|
|
16
16
|
import { countDiagnostics, validateProject } from "./validator.js";
|
|
17
17
|
import { getAgentToolDefinitions } from "./agent-tools.js";
|
|
18
18
|
import { explainProjectFile } from "./audit.js";
|
|
@@ -48,7 +48,12 @@ export async function executeAgentOperation(root, name, input = {}, options = {}
|
|
|
48
48
|
const result = options.session
|
|
49
49
|
? options.session.show(input.id.trim())
|
|
50
50
|
: await showProjectCard(root, input.id.trim());
|
|
51
|
-
if (!result.card && !result.node)
|
|
51
|
+
if (!result.card && !result.node) {
|
|
52
|
+
const state = result.resolvedFrom?.state;
|
|
53
|
+
if (state === "ambiguous") return failure(operation, "LNVAP409", `Ambiguous semantic ID ${input.id}; qualify or replace it with one candidate.`, result);
|
|
54
|
+
if (state === "cycle") return failure(operation, "LNVAP500", `Registry cycle prevents resolving semantic ID ${input.id}.`, result);
|
|
55
|
+
return failure(operation, "LNVAP404", `Unknown or inactive semantic ID ${input.id}.`, result);
|
|
56
|
+
}
|
|
52
57
|
return success(operation, result);
|
|
53
58
|
}
|
|
54
59
|
if (operation === "context") {
|
|
@@ -76,6 +81,10 @@ export async function executeAgentOperation(root, name, input = {}, options = {}
|
|
|
76
81
|
error: null,
|
|
77
82
|
};
|
|
78
83
|
} catch (error) {
|
|
84
|
+
if (error instanceof SemanticIdLookupError) {
|
|
85
|
+
return failure(operation, error.reason === "not_found" ? "LNVAP404" :
|
|
86
|
+
error.reason === "ambiguous" ? "LNVAP409" : "LNVAP500", error.message);
|
|
87
|
+
}
|
|
79
88
|
return failure(operation, "LNVAP500", error instanceof Error ? error.message : String(error));
|
|
80
89
|
}
|
|
81
90
|
}
|
package/src/evaluation.js
CHANGED
|
@@ -14,54 +14,78 @@ import { loadSearchData, queryPreparedIndex } from "./search.js";
|
|
|
14
14
|
import { assertNoSymlinkTraversal, parseJsonLines, readText } from "./util.js";
|
|
15
15
|
|
|
16
16
|
export async function evaluateProject(root, options = {}) {
|
|
17
|
+
const minimumCases = options.minimumCases ?? 0;
|
|
18
|
+
if (!Number.isSafeInteger(minimumCases) || minimumCases < 0) {
|
|
19
|
+
throw new RangeError("minimumCases must be a non-negative safe integer.");
|
|
20
|
+
}
|
|
17
21
|
const { config } = await loadConfig(root);
|
|
18
|
-
const { index, lexicon, searchIndex } = await loadSearchData(root);
|
|
22
|
+
const { index, lexicon, searchIndex, graph } = await loadSearchData(root);
|
|
19
23
|
const queryPath = path.resolve(root, options.file ?? config.evaluation.queryFile);
|
|
20
24
|
await assertNoSymlinkTraversal(root, queryPath, "evaluation query file");
|
|
21
25
|
const parsed = parseJsonLines(await readText(queryPath, ""), queryPath);
|
|
22
26
|
if (parsed.errors.length > 0) {
|
|
23
|
-
return { ok: false, errors: parsed.errors, cases: [], metrics: emptyMetrics() };
|
|
27
|
+
return { ok: false, status: "invalid", minimumCases, errors: parsed.errors, cases: [], metrics: emptyMetrics() };
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
const cases = [];
|
|
27
31
|
for (const record of parsed.records) {
|
|
28
|
-
|
|
32
|
+
const expectNoResults = record?.expectNoResults === true;
|
|
33
|
+
const expected = expectNoResults ? [] : record?.expected;
|
|
34
|
+
if (!record || typeof record.query !== "string" || !record.query.trim() ||
|
|
35
|
+
(Object.hasOwn(record, "expectNoResults") && !expectNoResults) ||
|
|
36
|
+
(expectNoResults ? record.expected !== undefined :
|
|
37
|
+
!Array.isArray(expected) || expected.length === 0 ||
|
|
38
|
+
!expected.every((id) => typeof id === "string" && id.trim()))) {
|
|
29
39
|
return {
|
|
30
40
|
ok: false,
|
|
31
|
-
|
|
41
|
+
status: "invalid",
|
|
42
|
+
minimumCases,
|
|
43
|
+
errors: [`${queryPath}: each record requires a non-empty query and either non-empty expected IDs or expectNoResults:true`],
|
|
32
44
|
cases,
|
|
33
45
|
metrics: emptyMetrics(),
|
|
34
46
|
};
|
|
35
47
|
}
|
|
36
|
-
const results = queryPreparedIndex(index, searchIndex, record.query, { top: Math.max(options.top ?? 5, 5), lexicon });
|
|
48
|
+
const results = queryPreparedIndex(index, searchIndex, record.query, { top: Math.max(options.top ?? 5, 5), lexicon, graph });
|
|
37
49
|
const ids = results.map((result) => result.id);
|
|
38
|
-
const firstRank = ids.findIndex((id) =>
|
|
50
|
+
const firstRank = expectNoResults ? -1 : ids.findIndex((id) => expected.includes(id));
|
|
51
|
+
const noResultPass = expectNoResults && ids.length === 0;
|
|
39
52
|
cases.push({
|
|
40
53
|
query: record.query,
|
|
41
|
-
expected
|
|
54
|
+
expected,
|
|
55
|
+
expectNoResults,
|
|
42
56
|
actual: ids,
|
|
43
57
|
rank: firstRank < 0 ? null : firstRank + 1,
|
|
44
|
-
passAt1: firstRank === 0,
|
|
45
|
-
passAt5: firstRank >= 0 && firstRank < 5,
|
|
58
|
+
passAt1: noResultPass || firstRank === 0,
|
|
59
|
+
passAt5: noResultPass || (firstRank >= 0 && firstRank < 5),
|
|
46
60
|
});
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
const total = cases.length;
|
|
64
|
+
const positiveCases = cases.filter((item) => !item.expectNoResults);
|
|
65
|
+
const positiveTotal = positiveCases.length;
|
|
66
|
+
const negativeTotal = total - positiveTotal;
|
|
67
|
+
const negativePassed = cases.filter((item) => item.expectNoResults && item.passAt5).length;
|
|
50
68
|
const metrics = total === 0
|
|
51
69
|
? emptyMetrics()
|
|
52
70
|
: {
|
|
53
71
|
total,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
positiveTotal,
|
|
73
|
+
negativeTotal,
|
|
74
|
+
negativePassed,
|
|
75
|
+
recallAt1: positiveTotal === 0 ? 0 : positiveCases.filter((item) => item.passAt1).length / positiveTotal,
|
|
76
|
+
recallAt5: positiveTotal === 0 ? 0 : positiveCases.filter((item) => item.passAt5).length / positiveTotal,
|
|
77
|
+
meanReciprocalRank: positiveTotal === 0 ? 0 :
|
|
78
|
+
positiveCases.reduce((sum, item) => sum + (item.rank ? 1 / item.rank : 0), 0) / positiveTotal,
|
|
57
79
|
};
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
80
|
+
const thresholdsPassed = positiveTotal > 0 && negativePassed === negativeTotal &&
|
|
81
|
+
metrics.recallAt1 >= config.evaluation.minimumRecallAt1 &&
|
|
82
|
+
metrics.recallAt5 >= config.evaluation.minimumRecallAt5;
|
|
83
|
+
const status = total === 0 ? "unmeasured" :
|
|
84
|
+
total < minimumCases ? "insufficient" : thresholdsPassed ? "passed" : "failed";
|
|
85
|
+
const ok = status === "passed" || (status === "unmeasured" && minimumCases === 0);
|
|
86
|
+
return { ok, status, minimumCases, errors: [], cases, metrics, thresholds: config.evaluation };
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
function emptyMetrics() {
|
|
66
|
-
return { total: 0, recallAt1: 0, recallAt5: 0, meanReciprocalRank: 0 };
|
|
90
|
+
return { total: 0, positiveTotal: 0, negativeTotal: 0, negativePassed: 0, recallAt1: 0, recallAt5: 0, meanReciprocalRank: 0 };
|
|
67
91
|
}
|
package/src/incremental.js
CHANGED
|
@@ -21,14 +21,15 @@ import {
|
|
|
21
21
|
atomicWrite,
|
|
22
22
|
compareText,
|
|
23
23
|
readJsonSafe,
|
|
24
|
+
readText,
|
|
24
25
|
relativePosix,
|
|
25
26
|
sha256,
|
|
26
27
|
stableStringify,
|
|
27
28
|
} from "./util.js";
|
|
28
29
|
|
|
29
30
|
export const FILE_STATE_SCHEMA_VERSION = 1;
|
|
30
|
-
export const SOURCE_INDEXER_VERSION =
|
|
31
|
-
const STAT_HINTS_SCHEMA_VERSION =
|
|
31
|
+
export const SOURCE_INDEXER_VERSION = 8;
|
|
32
|
+
const STAT_HINTS_SCHEMA_VERSION = 2;
|
|
32
33
|
|
|
33
34
|
export async function scanProjectIncremental(root, options = {}) {
|
|
34
35
|
const { config, configPath } = await loadConfig(root);
|
|
@@ -37,14 +38,30 @@ export async function scanProjectIncremental(root, options = {}) {
|
|
|
37
38
|
await assertNoSymlinkTraversal(root, cacheDirectory, config.generation.cacheDirectory);
|
|
38
39
|
const fileStatePath = path.join(cacheDirectory, "file-state.json");
|
|
39
40
|
await assertNoSymlinkTraversal(root, fileStatePath, relativePosix(root, fileStatePath));
|
|
40
|
-
const
|
|
41
|
+
const fileStateSource = options.previousState
|
|
42
|
+
? renderFileState(options.previousState)
|
|
43
|
+
: await readText(fileStatePath, null);
|
|
44
|
+
let previousState = null;
|
|
45
|
+
try {
|
|
46
|
+
previousState = fileStateSource === null ? null : JSON.parse(fileStateSource);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
49
|
+
}
|
|
50
|
+
const manifest = await readJsonSafe(path.join(cacheDirectory, "manifest.json"), null);
|
|
51
|
+
const previousStateHash = fileStateSource === null ? null : sha256(fileStateSource);
|
|
52
|
+
const fileStateKey = `${relativePosix(root, cacheDirectory)}/file-state.json`;
|
|
53
|
+
const trustedPreviousState = usableFileState(previousState) &&
|
|
54
|
+
(options.previousState != null ||
|
|
55
|
+
(manifest?.schemaVersion === 1 && manifest.repositoryId === config.repositoryId &&
|
|
56
|
+
manifest.files?.[fileStateKey] === previousStateHash));
|
|
41
57
|
const hintsPath = path.join(root, ".llmnav", "state", "stat-hints.json");
|
|
42
58
|
await assertNoSymlinkTraversal(root, hintsPath, relativePosix(root, hintsPath));
|
|
43
59
|
const previousHints = options.useStatHints === false
|
|
44
60
|
? null
|
|
45
61
|
: await readJsonSafe(hintsPath, null);
|
|
46
|
-
const previousFiles =
|
|
47
|
-
const hintFiles = usableStatHints(previousHints)
|
|
62
|
+
const previousFiles = trustedPreviousState ? mapStateFiles(previousState.files) : new Map();
|
|
63
|
+
const hintFiles = trustedPreviousState && usableStatHints(previousHints) &&
|
|
64
|
+
previousHints.fileStateHash === previousStateHash ? previousHints.files : {};
|
|
48
65
|
|
|
49
66
|
const fileRecords = [];
|
|
50
67
|
const records = [];
|
|
@@ -69,13 +86,13 @@ export async function scanProjectIncremental(root, options = {}) {
|
|
|
69
86
|
const relativePath = relativePosix(root, absolutePath);
|
|
70
87
|
const details = await stat(absolutePath, { bigint: true });
|
|
71
88
|
const fingerprint = statFingerprint(details);
|
|
72
|
-
nextHintFiles[relativePath] = fingerprint;
|
|
73
89
|
seenPaths.add(relativePath);
|
|
74
90
|
const previousFile = previousFiles.get(relativePath);
|
|
75
91
|
const previousHint = hintFiles[relativePath];
|
|
76
92
|
let stateFile;
|
|
77
93
|
|
|
78
|
-
if (previousFile &&
|
|
94
|
+
if (previousFile && previousHint?.contentHash === previousFile.contentHash &&
|
|
95
|
+
fingerprintsEqual(previousHint, fingerprint)) {
|
|
79
96
|
stateFile = previousFile;
|
|
80
97
|
stats.reusedFiles += 1;
|
|
81
98
|
stats.reusedFilesByStat += 1;
|
|
@@ -97,6 +114,7 @@ export async function scanProjectIncremental(root, options = {}) {
|
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
const normalizedStateFile = normalizeStateFile(stateFile, relativePath);
|
|
117
|
+
nextHintFiles[relativePath] = { ...fingerprint, contentHash: normalizedStateFile.contentHash };
|
|
100
118
|
nextStateFiles.push(normalizedStateFile);
|
|
101
119
|
sourceBytes += normalizedStateFile.sourceBytes;
|
|
102
120
|
semanticBytes += normalizedStateFile.semanticBytes;
|
|
@@ -117,6 +135,7 @@ export async function scanProjectIncremental(root, options = {}) {
|
|
|
117
135
|
};
|
|
118
136
|
const statHints = {
|
|
119
137
|
schemaVersion: STAT_HINTS_SCHEMA_VERSION,
|
|
138
|
+
fileStateHash: sha256(renderFileState(fileState)),
|
|
120
139
|
files: Object.fromEntries(Object.entries(nextHintFiles).sort(([left], [right]) => compareText(left, right))),
|
|
121
140
|
};
|
|
122
141
|
const registry = await loadRegistry(root);
|
|
@@ -176,12 +195,22 @@ export function renderFileState(fileState) {
|
|
|
176
195
|
}
|
|
177
196
|
|
|
178
197
|
export function usableFileState(value) {
|
|
179
|
-
|
|
180
|
-
value
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
198
|
+
if (!value || value.schemaVersion !== FILE_STATE_SCHEMA_VERSION ||
|
|
199
|
+
value.indexerVersion !== SOURCE_INDEXER_VERSION || !Array.isArray(value.files)) return false;
|
|
200
|
+
const paths = new Set();
|
|
201
|
+
for (const file of value.files) {
|
|
202
|
+
if (!file || typeof file.path !== "string" || !file.path || file.path.startsWith("/") ||
|
|
203
|
+
file.path.includes("\\") || file.path.split("/").includes("..") || paths.has(file.path) ||
|
|
204
|
+
!/^[0-9a-f]{64}$/u.test(file.contentHash) ||
|
|
205
|
+
!Number.isSafeInteger(file.sourceBytes) || file.sourceBytes < 0 ||
|
|
206
|
+
!Number.isSafeInteger(file.semanticBytes) || file.semanticBytes < 0 ||
|
|
207
|
+
!Array.isArray(file.imports) || !file.imports.every((item) => typeof item === "string") ||
|
|
208
|
+
!Array.isArray(file.blocks) || !file.blocks.every((block) => block &&
|
|
209
|
+
typeof block.raw === "string" && block.card && typeof block.card.id === "string") ||
|
|
210
|
+
!Array.isArray(file.declarations) || file.declarations.length !== file.blocks.length) return false;
|
|
211
|
+
paths.add(file.path);
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
185
214
|
}
|
|
186
215
|
|
|
187
216
|
function analyzeFile(relativePath, source) {
|
|
@@ -269,5 +298,7 @@ function fingerprintsEqual(left, right) {
|
|
|
269
298
|
}
|
|
270
299
|
|
|
271
300
|
function usableStatHints(value) {
|
|
272
|
-
return Boolean(value && value.schemaVersion === STAT_HINTS_SCHEMA_VERSION &&
|
|
301
|
+
return Boolean(value && value.schemaVersion === STAT_HINTS_SCHEMA_VERSION &&
|
|
302
|
+
/^[0-9a-f]{64}$/u.test(value.fileStateHash) && value.files &&
|
|
303
|
+
typeof value.files === "object" && !Array.isArray(value.files));
|
|
273
304
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -253,6 +253,12 @@ export interface ProjectSession {
|
|
|
253
253
|
refresh(): Promise<ProjectSession>;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
export interface ProjectSessionWithPromptBundle extends ProjectSession {
|
|
257
|
+
promptBundle: PromptPrefixBundle;
|
|
258
|
+
generationHash: string;
|
|
259
|
+
refresh(): Promise<ProjectSessionWithPromptBundle>;
|
|
260
|
+
}
|
|
261
|
+
|
|
256
262
|
export interface RegistryRecord {
|
|
257
263
|
id: string;
|
|
258
264
|
state: "active" | "redirect" | "replaced" | "retired" | string;
|
|
@@ -621,10 +627,13 @@ export interface MigrationResult {
|
|
|
621
627
|
|
|
622
628
|
export interface EvaluationResult {
|
|
623
629
|
ok: boolean;
|
|
630
|
+
status: "invalid" | "unmeasured" | "insufficient" | "passed" | "failed";
|
|
631
|
+
minimumCases: number;
|
|
624
632
|
errors: string[];
|
|
625
633
|
cases: Array<{
|
|
626
634
|
query: string;
|
|
627
635
|
expected: string[];
|
|
636
|
+
expectNoResults: boolean;
|
|
628
637
|
actual: string[];
|
|
629
638
|
rank: number | null;
|
|
630
639
|
passAt1: boolean;
|
|
@@ -632,6 +641,9 @@ export interface EvaluationResult {
|
|
|
632
641
|
}>;
|
|
633
642
|
metrics: {
|
|
634
643
|
total: number;
|
|
644
|
+
positiveTotal: number;
|
|
645
|
+
negativeTotal: number;
|
|
646
|
+
negativePassed: number;
|
|
635
647
|
recallAt1: number;
|
|
636
648
|
recallAt5: number;
|
|
637
649
|
meanReciprocalRank: number;
|
|
@@ -725,7 +737,7 @@ export function findAttachedDeclaration(source: string, block: LlmnavBlock, file
|
|
|
725
737
|
export function extractImports(source: string, filePath: string): string[];
|
|
726
738
|
export function doctorProject(root: string): Promise<{ ok: boolean; checks: Array<{ name: string; ok: boolean; message: string }> }>;
|
|
727
739
|
export function migrateProject(root: string, options?: { write?: boolean; failpoint?: string; renameOptions?: Record<string, unknown>; lockOptions?: Record<string, unknown>; onTransactionPhase?: (phase: string) => void | Promise<void> }): Promise<MigrationResult>;
|
|
728
|
-
export function evaluateProject(root: string, options?: { top?: number; file?: string }): Promise<EvaluationResult>;
|
|
740
|
+
export function evaluateProject(root: string, options?: { top?: number; file?: string; minimumCases?: number }): Promise<EvaluationResult>;
|
|
729
741
|
export function collectSourceFiles(root: string, config: LlmnavConfig, requestedPaths?: string[]): Promise<string[]>;
|
|
730
742
|
export function findProjectRoot(start?: string): Promise<string>;
|
|
731
743
|
export function formatProject(root: string, options?: { check?: boolean; paths?: string[] }): Promise<{ ok: boolean; changedFiles: string[]; errors: Array<{ file: string; line: number; message: string }> }>;
|
|
@@ -757,7 +769,8 @@ export function renderRegistryRecords(records: RegistryRecord[]): string;
|
|
|
757
769
|
export function loadRegistry(root: string): Promise<Registry>;
|
|
758
770
|
export function resolveRegistryId(registry: Registry, id: string): { id: string; state: string; [key: string]: unknown };
|
|
759
771
|
export function buildContext(root: string, id: string, options?: { depth?: number; budget?: number; maxEdges?: number }): Promise<{ id: string; depth: number; budget: number; maxEdges: number; included: string[]; includedEdges: string[]; text: string }>;
|
|
760
|
-
export function createProjectSession(root: string): Promise<
|
|
772
|
+
export function createProjectSession(root: string, options: { withPromptBundle: true }): Promise<ProjectSessionWithPromptBundle>;
|
|
773
|
+
export function createProjectSession(root: string, options?: { withPromptBundle?: false }): Promise<ProjectSession>;
|
|
761
774
|
export function loadSearchData(root: string): Promise<{ index: LlmnavIndex; searchIndex: LlmnavSearchIndex; lexicon: { version?: number; aliases: Record<string, string | string[]> }; graph: RepositoryGraph | null }>;
|
|
762
775
|
export function queryIndex(index: LlmnavIndex, query: string, options?: { top?: number; lexicon?: { aliases: Record<string, string | string[]> }; invertedIndex?: LlmnavSearchIndex; metrics?: SearchMetrics; graph?: RepositoryGraph | null }): SearchResult[];
|
|
763
776
|
export function queryPreparedIndex(index: LlmnavIndex, searchIndex: LlmnavSearchIndex, query: string, options?: { top?: number; lexicon?: { aliases: Record<string, string | string[]> }; metrics?: SearchMetrics; graph?: RepositoryGraph | null }): SearchResult[];
|
package/src/parser.js
CHANGED
|
@@ -39,6 +39,11 @@ const BLOCK_PATTERNS = [
|
|
|
39
39
|
];
|
|
40
40
|
const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
|
|
41
41
|
const MAX_BLOCKS_PER_FILE = 10_000;
|
|
42
|
+
const JAVASCRIPT_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"]);
|
|
43
|
+
const REGEX_PREFIX_KEYWORDS = new Set([
|
|
44
|
+
"await", "case", "delete", "do", "else", "in", "instanceof", "new", "return", "throw", "typeof", "void", "yield",
|
|
45
|
+
]);
|
|
46
|
+
const CONTROL_PAREN_KEYWORDS = new Set(["if", "while", "for", "with", "switch", "catch"]);
|
|
42
47
|
|
|
43
48
|
export function parseLlmnavBlocks(source, filePath = "<memory>") {
|
|
44
49
|
if (Buffer.byteLength(source) > MAX_SOURCE_BYTES) {
|
|
@@ -217,8 +222,15 @@ function buildLiteralMask(source, filePath) {
|
|
|
217
222
|
const hashComments = [".py", ".rb", ".sh", ".bash", ".zsh"].includes(extension);
|
|
218
223
|
const dashComments = extension === ".sql";
|
|
219
224
|
const tripleQuotes = extension === ".py";
|
|
225
|
+
const javascript = JAVASCRIPT_EXTENSIONS.has(extension);
|
|
226
|
+
let regexAllowed = true;
|
|
227
|
+
let regexCharacterClass = false;
|
|
228
|
+
let previousToken = "";
|
|
229
|
+
const controlParentheses = [];
|
|
230
|
+
const expressionBraces = [];
|
|
220
231
|
let state = "normal";
|
|
221
232
|
let escaped = false;
|
|
233
|
+
let rustRawTerminator = "";
|
|
222
234
|
const mask = new Uint8Array(source.length);
|
|
223
235
|
|
|
224
236
|
for (let index = 0; index < source.length; index += 1) {
|
|
@@ -260,21 +272,51 @@ function buildLiteralMask(source, filePath) {
|
|
|
260
272
|
}
|
|
261
273
|
continue;
|
|
262
274
|
}
|
|
275
|
+
if (state === "regex") {
|
|
276
|
+
if (character === "\n" || character === "\r") {
|
|
277
|
+
state = "normal";
|
|
278
|
+
escaped = false;
|
|
279
|
+
} else if (escaped) escaped = false;
|
|
280
|
+
else if (character === "\\") escaped = true;
|
|
281
|
+
else if (character === "[") regexCharacterClass = true;
|
|
282
|
+
else if (character === "]") regexCharacterClass = false;
|
|
283
|
+
else if (character === "/" && !regexCharacterClass) {
|
|
284
|
+
state = "normal";
|
|
285
|
+
regexAllowed = false;
|
|
286
|
+
previousToken = "value";
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (state === "rust-raw") {
|
|
291
|
+
if (source.startsWith(rustRawTerminator, index)) {
|
|
292
|
+
state = "normal";
|
|
293
|
+
index += rustRawTerminator.length - 1;
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
263
297
|
if (state === "single" || state === "double" || state === "backtick") {
|
|
264
298
|
if (escaped) {
|
|
265
299
|
escaped = false;
|
|
266
300
|
continue;
|
|
267
301
|
}
|
|
268
|
-
if (character === "\\") {
|
|
302
|
+
if (character === "\\" && (state !== "backtick" || extension !== ".go")) {
|
|
269
303
|
escaped = true;
|
|
270
304
|
continue;
|
|
271
305
|
}
|
|
272
306
|
const terminator = state === "single" ? "'" : state === "double" ? '"' : "`";
|
|
273
|
-
if (character === terminator)
|
|
307
|
+
if (character === terminator) {
|
|
308
|
+
state = "normal";
|
|
309
|
+
regexAllowed = false;
|
|
310
|
+
previousToken = "value";
|
|
311
|
+
}
|
|
274
312
|
continue;
|
|
275
313
|
}
|
|
276
314
|
|
|
277
|
-
|
|
315
|
+
const rawTerminator = extension === ".rs" ? rustRawStringTerminator(source, index) : null;
|
|
316
|
+
if (rawTerminator !== null) {
|
|
317
|
+
state = "rust-raw";
|
|
318
|
+
rustRawTerminator = rawTerminator;
|
|
319
|
+
} else if (nextFour === "<!--") {
|
|
278
320
|
state = "html-comment";
|
|
279
321
|
index += 3;
|
|
280
322
|
} else if (character === "/" && next === "*") {
|
|
@@ -283,6 +325,10 @@ function buildLiteralMask(source, filePath) {
|
|
|
283
325
|
} else if (character === "/" && next === "/") {
|
|
284
326
|
state = "line-comment";
|
|
285
327
|
index += 1;
|
|
328
|
+
} else if (javascript && character === "/" && regexAllowed) {
|
|
329
|
+
state = "regex";
|
|
330
|
+
regexCharacterClass = false;
|
|
331
|
+
escaped = false;
|
|
286
332
|
} else if (hashComments && character === "#") {
|
|
287
333
|
state = "line-comment";
|
|
288
334
|
} else if (dashComments && character === "-" && next === "-") {
|
|
@@ -303,12 +349,61 @@ function buildLiteralMask(source, filePath) {
|
|
|
303
349
|
} else if (character === "`") {
|
|
304
350
|
state = "backtick";
|
|
305
351
|
escaped = false;
|
|
352
|
+
} else if (javascript && !/\s/u.test(character)) {
|
|
353
|
+
// Track lexical expression position without rescanning prefixes. Comments do
|
|
354
|
+
// not change it; a control-condition ')' permits a following regex statement.
|
|
355
|
+
if (/[A-Za-z_$]/u.test(character)) {
|
|
356
|
+
const start = index;
|
|
357
|
+
const memberName = previousToken === ".";
|
|
358
|
+
while (index + 1 < source.length && /[\w$]/u.test(source[index + 1])) index += 1;
|
|
359
|
+
previousToken = source.slice(start, index + 1);
|
|
360
|
+
regexAllowed = !memberName && REGEX_PREFIX_KEYWORDS.has(previousToken);
|
|
361
|
+
} else if (character === "(") {
|
|
362
|
+
controlParentheses.push(CONTROL_PAREN_KEYWORDS.has(previousToken));
|
|
363
|
+
regexAllowed = true;
|
|
364
|
+
previousToken = character;
|
|
365
|
+
} else if (character === ")") {
|
|
366
|
+
regexAllowed = controlParentheses.pop() === true;
|
|
367
|
+
previousToken = character;
|
|
368
|
+
} else if (character === "{") {
|
|
369
|
+
expressionBraces.push(regexAllowed && !["", ";", "else", "do", "try", "finally", ")"].includes(previousToken));
|
|
370
|
+
regexAllowed = true;
|
|
371
|
+
previousToken = character;
|
|
372
|
+
} else if (character === "}") {
|
|
373
|
+
regexAllowed = expressionBraces.pop() !== true;
|
|
374
|
+
previousToken = character;
|
|
375
|
+
} else if ((character === "+" || character === "-") && next === character) {
|
|
376
|
+
previousToken = character + next;
|
|
377
|
+
index += 1;
|
|
378
|
+
} else if (character === "=" && next === ">") {
|
|
379
|
+
regexAllowed = true;
|
|
380
|
+
previousToken = "=>";
|
|
381
|
+
index += 1;
|
|
382
|
+
} else {
|
|
383
|
+
regexAllowed = "=(:,!&|?;[+-*%~^>/".includes(character);
|
|
384
|
+
previousToken = character;
|
|
385
|
+
}
|
|
306
386
|
}
|
|
307
387
|
}
|
|
308
388
|
|
|
309
389
|
return mask;
|
|
310
390
|
}
|
|
311
391
|
|
|
392
|
+
function rustRawStringTerminator(source, index) {
|
|
393
|
+
let cursor;
|
|
394
|
+
if (source[index] === "r") cursor = index + 1;
|
|
395
|
+
else if (source[index] === "b" && source[index + 1] === "r") cursor = index + 2;
|
|
396
|
+
else return null;
|
|
397
|
+
if (index > 0 && /[\p{ID_Continue}]/u.test(source[index - 1])) return null;
|
|
398
|
+
let hashes = 0;
|
|
399
|
+
while (source[cursor] === "#" && hashes <= 255) {
|
|
400
|
+
hashes += 1;
|
|
401
|
+
cursor += 1;
|
|
402
|
+
}
|
|
403
|
+
if (hashes > 255 || source[cursor] !== '"') return null;
|
|
404
|
+
return `"${"#".repeat(hashes)}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
312
407
|
function looksLikeRustCharacterLiteral(source, offset) {
|
|
313
408
|
return /^'(?:\\.|[^'\\\r\n])'/u.test(source.slice(offset));
|
|
314
409
|
}
|
package/src/prompt-bundle.js
CHANGED
|
@@ -12,6 +12,7 @@ stability=architecture
|
|
|
12
12
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { loadConfig } from "./config.js";
|
|
15
|
+
import { recoverGenerationTransaction, withGenerationLock } from "./transaction.js";
|
|
15
16
|
import { approximateTokens, assertNoSymlinkTraversal, compareText, readText, sha256, stableJson, stableStringify, toPosix } from "./util.js";
|
|
16
17
|
|
|
17
18
|
export const PROMPT_BUNDLE_SCHEMA_VERSION = 1;
|
|
@@ -60,7 +61,12 @@ export function isCompatiblePromptPrefixBundle(bundle, repositoryId = undefined)
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export async function loadPromptPrefixBundle(root) {
|
|
64
|
+
return withGenerationLock(root, async (lock) => (await loadPromptPrefixBundleLocked(root, lock)).bundle);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function loadPromptPrefixBundleLocked(root, lock) {
|
|
63
68
|
const { config } = await loadConfig(root);
|
|
69
|
+
await recoverGenerationTransaction(root, { cacheDirectory: config.generation.cacheDirectory, lockOwnerId: lock.ownerId });
|
|
64
70
|
const relativePath = `${toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "")}/prompt-prefix.json`;
|
|
65
71
|
const bundlePath = path.join(root, relativePath);
|
|
66
72
|
const manifestPath = path.join(root, config.generation.cacheDirectory, "manifest.json");
|
|
@@ -80,7 +86,7 @@ export async function loadPromptPrefixBundle(root) {
|
|
|
80
86
|
const manifestContent = await readText(manifestPath, "");
|
|
81
87
|
const manifest = manifestContent ? JSON.parse(manifestContent) : null;
|
|
82
88
|
if (manifest?.files?.[relativePath] !== sha256(content)) throw new Error(`Prompt bundle hash does not match manifest.json.`);
|
|
83
|
-
return bundle;
|
|
89
|
+
return { bundle, generationHash: sha256(manifestContent) };
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
function partition(id, cacheScope, contentType, content) {
|
package/src/search.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
verifySearchIndex,
|
|
24
24
|
} from "./inverted-index.js";
|
|
25
25
|
import { normalizeSearchText, tokenize } from "./tokenizer.js";
|
|
26
|
+
import { loadPromptPrefixBundleLocked } from "./prompt-bundle.js";
|
|
26
27
|
import { recoverGenerationTransaction, withGenerationLock } from "./transaction.js";
|
|
27
28
|
import { isCompatibleRepositoryGraph, renderGraphNode, resolveGraphNode } from "./graph.js";
|
|
28
29
|
|
|
@@ -31,6 +32,14 @@ const preparedSearchIndexCache = new WeakMap();
|
|
|
31
32
|
const sessionGraphAdjacencyCache = new WeakMap();
|
|
32
33
|
const sessionSearchMetadataCache = new WeakMap();
|
|
33
34
|
|
|
35
|
+
export class SemanticIdLookupError extends Error {
|
|
36
|
+
constructor(reason, message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "SemanticIdLookupError";
|
|
39
|
+
this.reason = reason;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
export async function loadSearchData(root) {
|
|
35
44
|
return withGenerationLock(root, (lock) => loadSearchDataLocked(root, lock));
|
|
36
45
|
}
|
|
@@ -48,11 +57,33 @@ async function loadSearchDataLocked(root, lock) {
|
|
|
48
57
|
for (const managedPath of [indexPath, manifestPath, graphPath, searchPath, lexiconPath]) {
|
|
49
58
|
await assertNoSymlinkTraversal(root, managedPath, toPosix(path.relative(root, managedPath)));
|
|
50
59
|
}
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
60
|
+
const indexText = await readText(indexPath, null);
|
|
61
|
+
if (indexText === null) throw new Error("No generated index found. Run `llmnav generate` first.");
|
|
62
|
+
let index;
|
|
63
|
+
try {
|
|
64
|
+
index = JSON.parse(indexText);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error("Malformed primary index.json. Run `llmnav generate` to rebuild it.");
|
|
67
|
+
}
|
|
68
|
+
if (!isUsablePrimaryIndex(index, config.repositoryId)) {
|
|
69
|
+
throw new Error("Incompatible primary index.json. Run `llmnav generate` to rebuild it.");
|
|
70
|
+
}
|
|
53
71
|
const lexicon = await readJsonSafe(lexiconPath, { version: 1, aliases: {} });
|
|
54
|
-
const
|
|
55
|
-
const
|
|
72
|
+
const cacheRelative = toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "");
|
|
73
|
+
const manifestText = await readText(manifestPath, null);
|
|
74
|
+
let manifest = null;
|
|
75
|
+
if (manifestText !== null) {
|
|
76
|
+
try {
|
|
77
|
+
manifest = JSON.parse(manifestText);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error("Malformed generated manifest.json. Run `llmnav generate` to rebuild it.");
|
|
80
|
+
}
|
|
81
|
+
if (manifest?.repositoryId !== config.repositoryId ||
|
|
82
|
+
manifest.files?.[`${cacheRelative}/index.json`] !== sha256(indexText)) {
|
|
83
|
+
throw new Error("Primary index.json hash does not match manifest.json. Run `llmnav generate` to rebuild it.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const graphRelative = `${cacheRelative}/graph.json`;
|
|
56
87
|
const graphText = await readText(graphPath, null);
|
|
57
88
|
let graph = null;
|
|
58
89
|
if (graphText !== null) {
|
|
@@ -68,7 +99,7 @@ async function loadSearchDataLocked(root, lock) {
|
|
|
68
99
|
isCompatibleRepositoryGraph(graph, index.repositoryId),
|
|
69
100
|
);
|
|
70
101
|
if (!graphMatches) graph = null;
|
|
71
|
-
const searchRelative = `${
|
|
102
|
+
const searchRelative = `${cacheRelative}/search-index.json`;
|
|
72
103
|
const searchText = await readText(searchPath, null);
|
|
73
104
|
let searchIndex = null;
|
|
74
105
|
if (searchText !== null) {
|
|
@@ -91,15 +122,30 @@ async function loadSearchDataLocked(root, lock) {
|
|
|
91
122
|
return { index, lexicon, searchIndex, graph };
|
|
92
123
|
}
|
|
93
124
|
|
|
125
|
+
function isUsablePrimaryIndex(index, repositoryId) {
|
|
126
|
+
if (!index || index.schemaVersion !== 1 || index.repositoryId !== repositoryId || !Array.isArray(index.cards)) return false;
|
|
127
|
+
const ids = new Set();
|
|
128
|
+
for (const card of index.cards) {
|
|
129
|
+
if (!card || typeof card.id !== "string" || !card.id || typeof card.role !== "string" || ids.has(card.id)) return false;
|
|
130
|
+
ids.add(card.id);
|
|
131
|
+
for (const field of ["search", "owns", "excludes", "invariant", "effect", "risk", "rel"]) {
|
|
132
|
+
if (card[field] !== undefined && (!Array.isArray(card[field]) || card[field].some((value) => typeof value !== "string"))) return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
94
138
|
export async function queryProject(root, query, options = {}) {
|
|
95
139
|
const { index, lexicon, searchIndex, graph } = await loadSearchData(root);
|
|
96
140
|
return queryPreparedIndex(index, searchIndex, query, { ...options, lexicon, graph });
|
|
97
141
|
}
|
|
98
142
|
|
|
99
|
-
export async function createProjectSession(root) {
|
|
100
|
-
|
|
143
|
+
export async function createProjectSession(root, options = {}) {
|
|
144
|
+
const withPromptBundle = options.withPromptBundle === true;
|
|
145
|
+
let snapshot = await loadSessionSnapshot(root, { withPromptBundle });
|
|
101
146
|
const session = {
|
|
102
147
|
root,
|
|
148
|
+
...(withPromptBundle ? { promptBundle: snapshot.promptBundle, generationHash: snapshot.generationHash } : {}),
|
|
103
149
|
query(query, options = {}) {
|
|
104
150
|
return structuredClone(queryPreparedIndex(snapshot.index, snapshot.searchIndex, query, {
|
|
105
151
|
...options,
|
|
@@ -114,36 +160,74 @@ export async function createProjectSession(root) {
|
|
|
114
160
|
return buildSnapshotContext(snapshot, id, options);
|
|
115
161
|
},
|
|
116
162
|
async refresh() {
|
|
117
|
-
|
|
163
|
+
const next = await loadSessionSnapshot(root, { withPromptBundle });
|
|
164
|
+
snapshot = next;
|
|
165
|
+
if (withPromptBundle) {
|
|
166
|
+
session.promptBundle = next.promptBundle;
|
|
167
|
+
session.generationHash = next.generationHash;
|
|
168
|
+
}
|
|
118
169
|
return session;
|
|
119
170
|
},
|
|
120
171
|
};
|
|
121
172
|
return session;
|
|
122
173
|
}
|
|
123
174
|
|
|
124
|
-
async function loadSessionSnapshot(root) {
|
|
125
|
-
const snapshot = await loadProjectSnapshot(root);
|
|
175
|
+
async function loadSessionSnapshot(root, options = {}) {
|
|
176
|
+
const snapshot = await loadProjectSnapshot(root, options);
|
|
126
177
|
// Only session-owned graphs are cached: public query inputs may be mutable.
|
|
127
178
|
if (snapshot.graph) sessionGraphAdjacencyCache.set(snapshot.graph, buildGraphAdjacency(snapshot.graph));
|
|
128
|
-
sessionSearchMetadataCache.set(snapshot.index, prepareSearchMetadata(snapshot.index, snapshot.lexicon, snapshot.graph));
|
|
179
|
+
sessionSearchMetadataCache.set(snapshot.index, prepareSearchMetadata(snapshot.index, snapshot.lexicon, snapshot.graph, true));
|
|
129
180
|
return snapshot;
|
|
130
181
|
}
|
|
131
182
|
|
|
132
|
-
function prepareSearchMetadata(index, lexicon, graph) {
|
|
183
|
+
function prepareSearchMetadata(index, lexicon, graph, forSession = false) {
|
|
184
|
+
const normalizedIds = index.cards.map((card) => normalizeSearchText(card.id));
|
|
133
185
|
return {
|
|
134
186
|
byId: new Map(index.cards.map((card) => [card.id, card])),
|
|
135
187
|
cardOrder: new Map(index.cards.map((card, position) => [card.id, position])),
|
|
136
|
-
normalizedIds
|
|
188
|
+
normalizedIds,
|
|
189
|
+
idCandidates: forSession ? buildIdCandidates(normalizedIds) : null,
|
|
137
190
|
aliases: Object.entries(lexicon.aliases ?? {}).map(([alias, targets]) => [alias, targets, normalizeSearchText(alias)]),
|
|
138
191
|
graphCompatible: isCompatibleRepositoryGraph(graph, index.repositoryId),
|
|
139
192
|
};
|
|
140
193
|
}
|
|
141
194
|
|
|
142
|
-
|
|
195
|
+
function buildIdCandidates(normalizedIds) {
|
|
196
|
+
const exact = new Map();
|
|
197
|
+
const grams = new Map();
|
|
198
|
+
for (const [position, id] of normalizedIds.entries()) {
|
|
199
|
+
if (!exact.has(id)) exact.set(id, []);
|
|
200
|
+
exact.get(id).push(position);
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
for (let offset = 0; offset <= id.length - 3; offset += 1) {
|
|
203
|
+
const gram = id.slice(offset, offset + 3);
|
|
204
|
+
if (seen.has(gram)) continue;
|
|
205
|
+
seen.add(gram);
|
|
206
|
+
if (!grams.has(gram)) grams.set(gram, []);
|
|
207
|
+
grams.get(gram).push(position);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { exact, grams };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function idCandidatePositions(prepared, normalizedQuery) {
|
|
214
|
+
if (!prepared.idCandidates) return prepared.normalizedIds.keys();
|
|
215
|
+
if (normalizedQuery.length < 3) return prepared.idCandidates.exact.get(normalizedQuery) ?? [];
|
|
216
|
+
let smallest = null;
|
|
217
|
+
for (let offset = 0; offset <= normalizedQuery.length - 3; offset += 1) {
|
|
218
|
+
const positions = prepared.idCandidates.grams.get(normalizedQuery.slice(offset, offset + 3));
|
|
219
|
+
if (!positions) return [];
|
|
220
|
+
if (!smallest || positions.length < smallest.length) smallest = positions;
|
|
221
|
+
}
|
|
222
|
+
return smallest ?? [];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function loadProjectSnapshot(root, options = {}) {
|
|
143
226
|
return withGenerationLock(root, async (lock) => {
|
|
144
227
|
const { index, lexicon, searchIndex, graph } = await loadSearchDataLocked(root, lock);
|
|
145
228
|
const registry = await loadRegistry(root);
|
|
146
|
-
|
|
229
|
+
const prompt = options.withPromptBundle ? await loadPromptPrefixBundleLocked(root, lock) : null;
|
|
230
|
+
return { index, lexicon, searchIndex, graph, registry, promptBundle: prompt?.bundle, generationHash: prompt?.generationHash };
|
|
147
231
|
});
|
|
148
232
|
}
|
|
149
233
|
|
|
@@ -196,17 +280,19 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
|
|
|
196
280
|
}
|
|
197
281
|
}
|
|
198
282
|
|
|
199
|
-
for (const
|
|
283
|
+
for (const position of idCandidatePositions(prepared, normalizedQuery)) {
|
|
200
284
|
if (metrics) metrics.idDocumentsScanned += 1;
|
|
285
|
+
const card = index.cards[position];
|
|
201
286
|
const normalizedId = prepared.normalizedIds[position];
|
|
202
287
|
if (normalizedId === normalizedQuery) {
|
|
203
288
|
addScore(resultsById, card, 1000, "exact semantic ID");
|
|
204
289
|
} else if (normalizedId.includes(normalizedQuery) && normalizedQuery.length > 2) {
|
|
205
290
|
addScore(resultsById, card, 100, "semantic ID phrase");
|
|
206
291
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
292
|
+
}
|
|
293
|
+
for (const target of aliasTargets) {
|
|
294
|
+
const card = byId.get(target);
|
|
295
|
+
if (card) addScore(resultsById, card, 500, aliasReasons.get(target) ?? []);
|
|
210
296
|
}
|
|
211
297
|
|
|
212
298
|
const preparedSearchIndex = prepareCompactSearchIndex(searchIndex);
|
|
@@ -423,21 +509,21 @@ function buildSnapshotContext(snapshot, id, options = {}) {
|
|
|
423
509
|
const resolved = resolveRegistryId(registry, id);
|
|
424
510
|
if (resolved.state === "active" && index.cards.some((card) => card.id === resolved.id)) rootId = resolved.id;
|
|
425
511
|
if (resolved.state === "ambiguous") {
|
|
426
|
-
throw new
|
|
512
|
+
throw new SemanticIdLookupError("ambiguous", `Ambiguous replaced semantic ID ${id}; choose one of ${resolved.candidates.join(", ")}.`);
|
|
427
513
|
}
|
|
428
|
-
if (resolved.state === "cycle") throw new
|
|
514
|
+
if (resolved.state === "cycle") throw new SemanticIdLookupError("registry_cycle", `Registry cycle prevents resolving semantic ID ${id}.`);
|
|
429
515
|
}
|
|
430
516
|
if (isCompatibleRepositoryGraph(graph, index.repositoryId)) {
|
|
431
517
|
const resolution = rootId
|
|
432
518
|
? resolveGraphNode(graph, `${index.repositoryId}/${rootId}`, index.repositoryId)
|
|
433
519
|
: resolveGraphNode(graph, id, index.repositoryId);
|
|
434
520
|
if (resolution.state === "ambiguous") {
|
|
435
|
-
throw new
|
|
521
|
+
throw new SemanticIdLookupError("ambiguous", `Ambiguous semantic ID ${id}; qualify one of ${resolution.candidates.join(", ")}.`);
|
|
436
522
|
}
|
|
437
|
-
if (resolution.state !== "resolved") throw new
|
|
523
|
+
if (resolution.state !== "resolved") throw new SemanticIdLookupError("not_found", `Unknown or inactive semantic ID ${id}.`);
|
|
438
524
|
return buildGraphContext(index, graph, resolution.node, { depth, budget, maxEdges });
|
|
439
525
|
}
|
|
440
|
-
if (!rootId) throw new
|
|
526
|
+
if (!rootId) throw new SemanticIdLookupError("not_found", `Unknown or inactive semantic ID ${id}.`);
|
|
441
527
|
return buildLegacyContext(index, rootId, { depth, budget, maxEdges });
|
|
442
528
|
}
|
|
443
529
|
|
package/src/spec.js
CHANGED
|
@@ -10,7 +10,7 @@ rel=workflow>llmnav.rules.validate
|
|
|
10
10
|
stability=contract
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export const PACKAGE_VERSION = "0.9.
|
|
13
|
+
export const PACKAGE_VERSION = "0.9.7";
|
|
14
14
|
export const SPEC_VERSION = "1";
|
|
15
15
|
|
|
16
16
|
export const SCOPES = Object.freeze(["file", "module", "symbol"]);
|