llmnav 0.9.0 → 0.9.2
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 +26 -0
- package/docs/api.md +4 -0
- package/docs/architecture.md +6 -0
- package/docs/benchmarking.md +51 -0
- package/docs/navigation-replay.md +44 -0
- package/docs/publishing.md +36 -30
- package/package.json +3 -1
- package/src/graph.js +2 -1
- package/src/incremental.js +1 -1
- package/src/index.d.ts +4 -1
- package/src/parser.js +69 -1
- package/src/search-shards.js +17 -10
- package/src/search.js +61 -22
- package/src/spec.js +1 -1
- package/src/transaction.js +40 -17
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ The npm package follows Semantic Versioning. The `llmnav/N` source protocol is v
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.2] — 2026-09-09
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
* 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.
|
|
14
|
+
* Invalidate older parsed-file caches so unchanged consumer files receive the corrected parsing behavior.
|
|
15
|
+
|
|
16
|
+
## [0.9.1] — 2026-09-07
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
* Explain unsupported or disallowed hard-link lock publication, preserve its filesystem error, and verify installed host show, bounded context, and refresh operations.
|
|
21
|
+
* Prevent inherited npm `allow-scripts` environment policy from breaking isolated package installation smoke checks; installation lifecycle scripts remain disabled.
|
|
22
|
+
* Synchronize the public package-version constant with 0.9.1 and check both npm manifests in the normal test suite.
|
|
23
|
+
* Publish complete generation lock records atomically, clean up failed preparation, and explain repair of unreadable legacy locks.
|
|
24
|
+
* Hold the generation lock throughout search and registry snapshot reads.
|
|
25
|
+
* Apply context edge limits when falling back to semantic relations without a usable graph.
|
|
26
|
+
|
|
27
|
+
### Performance
|
|
28
|
+
|
|
29
|
+
* Distribute search postings to card-range shards in one pass while preserving byte-identical manifests and artifacts.
|
|
30
|
+
* Prepare card lookups, normalized IDs and aliases, and graph validation once per session snapshot; detach query and show results to protect cached state from caller mutation.
|
|
31
|
+
* Measure fresh-process session startup, graph-aware query and context latency, and refresh separately, with result parity checks against direct navigation APIs.
|
|
32
|
+
* Reuse graph adjacency and traversal ordering within project sessions; refresh rebuilds that state while direct query APIs continue to accept mutable graph inputs.
|
|
33
|
+
* Count removed graph partitions with keyed membership instead of scanning all current partitions for each previous key.
|
|
34
|
+
|
|
9
35
|
## [0.9.0] — 2026-08-15
|
|
10
36
|
|
|
11
37
|
### Added
|
package/docs/api.md
CHANGED
|
@@ -302,6 +302,10 @@ await session.refresh(); // after generation or checkout changes
|
|
|
302
302
|
|
|
303
303
|
`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
304
|
|
|
305
|
+
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
|
+
|
|
307
|
+
Session queries also reuse card lookup tables, normalized IDs and aliases, and graph validation. ID substring and phrase matching still scan 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
|
+
|
|
305
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.
|
|
306
310
|
|
|
307
311
|
`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`.
|
package/docs/architecture.md
CHANGED
|
@@ -197,6 +197,12 @@ Generation never mutates the live cache file by file. It builds a complete repla
|
|
|
197
197
|
|
|
198
198
|
The complete source scan and cache commit are serialized by `.llmnav/generation.lock`. The lock records a process and opaque owner ID. A reader waits for a live owner to finish and only then evaluates recovery, while an abandoned lock from a dead process can be removed without granting another process authority over an active journal.
|
|
199
199
|
|
|
200
|
+
Readers hold that lock until all snapshot artifacts, including the ID registry for sessions, show, and context, have been read. Lock ownership is published with an exclusive hard link to a fully written, synced, and closed candidate file in the same directory; the filesystem must support hard links. Failed preparation cannot leave an empty authoritative lock. Unused candidate files confer no ownership. An unreadable lock left by an older version is not automatically stolen: stop all LLMNav processes, remove `.llmnav/generation.lock`, then retry. A valid abandoned transaction is still recovered normally.
|
|
201
|
+
|
|
202
|
+
If hard-link publication fails with `ENOSYS`, `ENOTSUP`, `EOPNOTSUPP`, `EPERM`, or `EXDEV`, the error explains the filesystem and permission requirements and retains the original cause. The unpublished candidate is cleaned up; the lock is not downgraded to a non-atomic write. `EPERM` can indicate a permission restriction, not only a filesystem capability gap.
|
|
203
|
+
|
|
204
|
+
When no compatible graph is available, context falls back to semantic relations and still limits traversal with `maxEdges`, including zero. The token budget separately bounds rendered output.
|
|
205
|
+
|
|
200
206
|
```text
|
|
201
207
|
write every staged artifact
|
|
202
208
|
verify exact staged bytes
|
package/docs/benchmarking.md
CHANGED
|
@@ -63,6 +63,55 @@ LLMNAV_BENCH_FILES=500 LLMNAV_BENCH_QUERY_RUNS=5 npm run benchmark:v0.2
|
|
|
63
63
|
|
|
64
64
|
Published repository results use the defaults. The report states that each query starts a fresh Node.js process and that the operating-system filesystem cache is not flushed. That distinction prevents a warm page cache from being mislabeled as cold disk I/O.
|
|
65
65
|
|
|
66
|
+
## Graph-aware session lifecycle
|
|
67
|
+
|
|
68
|
+
`npm run benchmark:navigation` generates a temporary 100-file repository with 1,000 cards and three outgoing semantic relations per card. A fresh worker process measures initial session loading, the first query, 50 repeated queries, 50 bounded graph-context requests, and refresh with unchanged source. Three direct `queryProject` calls provide a filesystem-loading comparison. The report includes medians, p95, memory, operation counters, fixture sizes, and result digests.
|
|
69
|
+
|
|
70
|
+
The operating-system filesystem cache is not flushed. Module import and process launch costs are excluded from `initialSessionMs` but included in the total `workerWallMs`, which also includes reference checks. Fixture generation is outside both measurements. Query scores and reasons must match the direct graph-aware index path; context must include the requested root and graph evidence within its edge and token limits. CI uses a 100-card fixture to validate these contracts without imposing a machine-specific timing threshold.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
### Local baseline before session metadata preparation
|
|
74
|
+
|
|
75
|
+
Measured on 2026-09-07 with runtime source based on `5b0d837`: Windows x64, Node v24.18.0, AMD Ryzen 5 7430U with Radeon Graphics. This is one local run with an unflushed filesystem cache, not a production latency guarantee.
|
|
76
|
+
|
|
77
|
+
| Phase | Median or single sample (ms) | p95 (ms) |
|
|
78
|
+
| --- | ---: | ---: |
|
|
79
|
+
| Initial session load | 141.335 | — |
|
|
80
|
+
| First query | 11.113 | — |
|
|
81
|
+
| Repeated session query (50) | 3.859 | 7.833 |
|
|
82
|
+
| Repeated session context (50) | 2.870 | 5.484 |
|
|
83
|
+
| Refresh unchanged source | 70.303 | — |
|
|
84
|
+
| Direct query with disk loading (3) | 90.443 | 120.859 |
|
|
85
|
+
|
|
86
|
+
All 50 query rankings, scores and reasons matched the direct index path. All 50 contexts met the graph and token bounds. Query work still included 50,000 ID scans and 50,000 phrase-document scans; caching adjacency does not eliminate those passes.
|
|
87
|
+
|
|
88
|
+
Result digests for comparison with subsequent optimization:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
query d7545f3781f705eb2fdb5c7deb14004c7d74f1b9fc81fb21314e00f3f276e272
|
|
92
|
+
context 051db2dc2b802eca7a5b3a04a08c38b447ba95659ebc2a8eef2e9b6fb0e1207b
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Local session metadata result
|
|
96
|
+
|
|
97
|
+
On the same fixture and environment, one local run after session metadata preparation measured query median/p95 at 2.883/5.796 ms, with identical query and context digests above. Across 50 queries, ID normalizations, lookup-table builds, and graph validations were all zero; ID and phrase scans remain 50,000 each. The optimization does not narrow substring candidates or change ranking.
|
|
98
|
+
|
|
99
|
+
Initial loading was 159.667 ms, first query 21.485 ms, refresh 114.610 ms, context median/p95 3.617/8.979 ms, and direct-query median 102.542 ms. Preparation shifts work to loading/refresh and result detachment adds copying. These single-run timings include local load variation; they establish neither a startup improvement nor a production speed guarantee.
|
|
100
|
+
|
|
101
|
+
## Card-range shard generation
|
|
102
|
+
|
|
103
|
+
`npm run benchmark:shards` compares one-pass distribution with the pre-optimization per-shard filtering implementation on 2,000 cards and 44,286 postings. It checks exact manifest and artifact bytes. A separate regression instruments posting reads and requires one visit per posting, independent of shard count; the reference visits every posting once per shard.
|
|
104
|
+
|
|
105
|
+
One Windows x64 / Node v24.18.0 run on 2026-09-07, including serialization but excluding index construction:
|
|
106
|
+
|
|
107
|
+
| Shard count | Reference (ms) | One pass (ms) |
|
|
108
|
+
| --- | ---: | ---: |
|
|
109
|
+
| 100 | 503.116 | 169.912 |
|
|
110
|
+
| 20 | 247.831 | 170.843 |
|
|
111
|
+
| 4 | 157.325 | 191.673 |
|
|
112
|
+
|
|
113
|
+
These are single in-process samples, with the reference first, not production guarantees. Few shards can be slower because distribution adds bookkeeping. Posting distribution changes from shard-count multiplied scans to one pass, but serialization still costs the output size. Partitioned posting pairs temporarily occupy memory alongside serialized shards; completed partitions are released as serialization progresses. This benchmark does not measure peak memory. Sharding remains disabled by default.
|
|
114
|
+
|
|
66
115
|
## Measurement integrity
|
|
67
116
|
|
|
68
117
|
A benchmark result is accepted only after these checks pass:
|
|
@@ -79,6 +128,8 @@ Wall-clock speed is reported exactly as measured. It is not used to invent a spe
|
|
|
79
128
|
|
|
80
129
|
## Repository search evaluation
|
|
81
130
|
|
|
131
|
+
The [historical navigation replay pilot](navigation-replay.md) compares six fixed task paraphrases with regex file discovery. It reports candidate ranks and explicit unmeasured agent metrics; it is not a substitute for end-to-end trials.
|
|
132
|
+
|
|
82
133
|
Add real task descriptions to `.llmnav/eval/queries.jsonl`.
|
|
83
134
|
|
|
84
135
|
```jsonl
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Historical navigation replay pilot
|
|
2
|
+
|
|
3
|
+
## Result
|
|
4
|
+
|
|
5
|
+
On 2026-09-08, six curated historical changes were replayed against runtime commit `d30d80015ea10ea43a0875bc329669fa4019a457`. Both methods included the expected source file in their first five candidates for all six cases. LLMNav ranked four expected files first; the regex baseline placed one first in alphabetical file order. These are different ordering policies, not an agent success-rate comparison.
|
|
6
|
+
|
|
7
|
+
| Historical task | Source change | Expected source | Regex rank / candidates | LLMNav rank / candidates |
|
|
8
|
+
| --- | --- | --- | ---: | ---: |
|
|
9
|
+
| Filesystem capability required by generation locking | `5b0d837` | `src/transaction.js` | 2 / 2 | 2 / 20 |
|
|
10
|
+
| Context still follows edges at a zero edge limit | `34f56a6` | `src/search.js` | 4 / 4 | 2 / 23 |
|
|
11
|
+
| Explain suppressed annotation-audit candidates | `592849f` | `src/audit.js` | 3 / 6 | 1 / 17 |
|
|
12
|
+
| Resolve a Go module import to its package | `fcd40cb` | `src/module-resolution.js` | 3 / 3 | 1 / 23 |
|
|
13
|
+
| Upgrade generated formats without partial cache state | `839ab1c` | `src/migration.js` | 5 / 6 | 1 / 25 |
|
|
14
|
+
| Remove repeated posting scans during sharding | `d30d800` | `src/search-shards.js` | 1 / 1 | 1 / 23 |
|
|
15
|
+
|
|
16
|
+
Candidate counts include the complete positive-result list, not just the first five displayed paths. LLMNav returns more broad matches, while known technical keywords can narrow regex discovery much further. The two non-first LLMNav results put `src/audit.js` ahead of the locking implementation and `src/graph.js` ahead of the context-limit implementation. No query, expected target, annotation, or rank weight was tuned after this run.
|
|
17
|
+
|
|
18
|
+
## Reproduce
|
|
19
|
+
|
|
20
|
+
From the Git repository, run:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
node benchmarks/navigation-replay.js
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The script performs no model calls, network requests, source edits, or cache generation. It requires an existing generated LLMNav index and the referenced Git history. Expected targets must both exist in current source and occur in the cited historical change. The output includes source, harness, and case hashes so a rerun can distinguish changed inputs from changed ranking. Refresh the index through the normal generation workflow before comparing a later source revision.
|
|
27
|
+
|
|
28
|
+
Both output scopes contain 37 tracked JavaScript files under `src/` and `bin/`. The baseline performs one fixed case-insensitive line-regex search per case and sorts matching paths alphabetically. LLMNav performs one query, preserves score order, deduplicates paths, and restricts output to that same source scope. Its scoring still uses the complete repository index before filtering. Source annotations remain visible to the baseline. The script refuses a truncated 100-card result instead of treating hidden hits as misses.
|
|
29
|
+
|
|
30
|
+
Input identities for the recorded run:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
harness 706834dc4a0faf0b5111f11dc854d929f5c84a4b6757542bd3a98041d0b47f33
|
|
34
|
+
cases c23e467540d03544a68a4ffabc9cb4c91c2ecea87f655f6e3b55257adf404364
|
|
35
|
+
source 2a8e5f8e2bff1f6236bd2117998d134a44cacb728d1667a5f3fb747a7fff8c7b
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Evidence boundary and decision
|
|
39
|
+
|
|
40
|
+
The cases are author-curated English paraphrases of known changes, not verbatim user requests, representative traffic, Korean-language coverage, or a sealed holdout. They run on already corrected current source, not the pre-fix repository. The author knew the expected paths while constructing both symptom queries and regexes. This is a diagnostic retrieval pilot, not a claim that LLMNav outperforms a developer adaptively using `rg`.
|
|
41
|
+
|
|
42
|
+
Each method performs one search per case, but actual agent tool calls, files opened, uncached tokens, elapsed task time, edit correctness, and task completion are **unmeasured** and reported as `null`, not inferred from candidate rank. No full agent trace was captured. The pilot therefore does not complete the end-to-end evaluation described in [benchmarking](benchmarking.md).
|
|
43
|
+
|
|
44
|
+
The existing changes were delivered and their Windows/Linux CI passed before this pilot. No runtime optimization is added on the strength of these results: they do not establish that `show` or `context` latency blocks development work. Further session lookup caching and shard-memory work remain deferred. The existing ranking and substring-search behavior are unchanged.
|
package/docs/publishing.md
CHANGED
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
# Publishing `llmnav` to npm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Maintainers publish this repository through the existing tag-triggered GitHub Actions workflow. Initial repository and npm account setup belongs to the separate bootstrap section below; it is not part of every release.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Routine release
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
1. Confirm the intended version agrees across `package.json`, `package-lock.json`, `src/spec.js`, and the changelog. Review the exact diff and keep unrelated changes out of the release.
|
|
8
|
+
2. Check the remote tag and npm version before creating anything. Authentication or network errors mean unknown state, not an unpublished version. Never move an existing release tag or overwrite a published version.
|
|
9
|
+
3. Validate the exact payload using the commands below. Reuse passing checks only while their relevant inputs remain unchanged.
|
|
10
|
+
4. Commit and push the release changes to `main`. Require successful Windows/Linux and package CI for that exact commit, not a previous commit.
|
|
11
|
+
5. Create an annotated version tag on the verified commit and push only that tag. The workflow handles npm publication and GitHub Release creation.
|
|
12
|
+
6. Verify the tag commit, release workflow, npm version and integrity, provenance, GitHub Release, and an installation of the published package. Publication success and branch CI are separate checks.
|
|
8
13
|
|
|
9
14
|
```sh
|
|
10
|
-
npm
|
|
15
|
+
npm run check
|
|
16
|
+
npm run release:check
|
|
17
|
+
npm run smoke:pack
|
|
18
|
+
npm pack --dry-run
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
If dependencies are missing or the lockfile changed, install them through the approved dependency workflow before these checks. The package intentionally includes the CLI, source API, type declarations, schema, templates, documentation, the typed provider-neutral host example, README, changelog, roadmap, and license. Tests, benchmarks, and development scripts remain in GitHub rather than the installed package.
|
|
22
|
+
|
|
23
|
+
After choosing the version, replace the placeholder in both tag commands:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
git tag -a vX.Y.Z -m "llmnav vX.Y.Z"
|
|
27
|
+
git push origin refs/tags/vX.Y.Z
|
|
11
28
|
```
|
|
12
29
|
|
|
13
|
-
|
|
30
|
+
The workflow rejects a tag that does not match `package.json` or the protected `origin/main` lineage. If the version already exists in npm, it succeeds only when the registry integrity matches the tagged payload. A mismatch fails closed and requires a new version, not a forced tag. GitHub Release creation runs only after publication or matching-integrity verification succeeds.
|
|
31
|
+
|
|
32
|
+
If publication is interrupted, inspect the existing tag, registry artifact, and workflow result before retrying. Preserve the version and tag when they already identify the intended artifact; do not assume a failed workflow means nothing was published.
|
|
33
|
+
|
|
34
|
+
## One-time bootstrap for a new repository or publisher
|
|
35
|
+
|
|
36
|
+
### Claim names and set owner metadata
|
|
14
37
|
|
|
15
|
-
|
|
38
|
+
Before a first publication, create the GitHub repository and check npm name availability. An `E404` means no public package is visible at that moment, not that the name is reserved. Do not repeat owner replacement for routine releases of this configured repository.
|
|
16
39
|
|
|
17
40
|
Update only the owner-specific URLs in `package.json` and `.github/ISSUE_TEMPLATE/config.yml`.
|
|
18
41
|
Do not run a repository-wide replacement: the release checker and doctor intentionally keep the literal `OWNER` sentinel in source code.
|
|
@@ -29,27 +52,15 @@ Then replace `OWNER` only in `.github/ISSUE_TEMPLATE/config.yml` and run:
|
|
|
29
52
|
npm run release:check
|
|
30
53
|
```
|
|
31
54
|
|
|
32
|
-
|
|
55
|
+
### Configure npm authentication
|
|
33
56
|
|
|
34
57
|
The supplied release workflow uses GitHub's OIDC token through npm Trusted Publishers. The public GitHub source repository enables provenance in both `package.json` and the workflow, and the release check fails if either surface disables it. The same tag workflow creates an idempotent GitHub Release only after the npm registry check or publication succeeds.
|
|
35
58
|
|
|
36
|
-
The workflow references a GitHub environment named `npm`.
|
|
59
|
+
The workflow references a GitHub environment named `npm`. Configure that environment and its protection rules as part of bootstrap. Do not remove an existing approval gate merely to unblock a release.
|
|
37
60
|
|
|
38
61
|
A classic or granular access token can be used for a manual first publication. Do not commit `.npmrc` credentials or an npm token. npm may still require browser-backed two-factor authentication.
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
```sh
|
|
43
|
-
npm ci
|
|
44
|
-
npm run check
|
|
45
|
-
npm run release:check
|
|
46
|
-
npm run smoke:pack
|
|
47
|
-
npm pack --dry-run
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
Inspect the tarball list. The package intentionally includes the CLI, source API, type declarations, schema, templates, documentation, the typed provider-neutral host example, README, changelog, roadmap, and license. Tests, benchmark harnesses, and development scripts remain in GitHub but are not installed into consumer projects. `npm run smoke:pack` verifies the exact tarball and example export in a clean temporary project.
|
|
51
|
-
|
|
52
|
-
## 5. Publish the first release
|
|
63
|
+
### Manual first publication, only when required
|
|
53
64
|
|
|
54
65
|
Manual publication:
|
|
55
66
|
|
|
@@ -58,22 +69,17 @@ npm login
|
|
|
58
69
|
npm publish --provenance --access public
|
|
59
70
|
```
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
```sh
|
|
64
|
-
git tag vX.Y.Z
|
|
65
|
-
git push origin vX.Y.Z
|
|
66
|
-
```
|
|
72
|
+
## Verify a published version from a clean directory
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
Use an explicit published version instead of the moving default tag. The temporary project must be outside the source repository. Do not commit installed files or credentials.
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
From a source checkout, `npm run smoke:pack -- --published=X.Y.Z` runs the existing CLI/API/host assertions against that exact npm version in a temporary project with lifecycle scripts disabled. Without the option, it tests a locally packed tarball instead.
|
|
71
77
|
|
|
72
78
|
```sh
|
|
73
79
|
mkdir llmnav-smoke
|
|
74
80
|
cd llmnav-smoke
|
|
75
81
|
npm init -y
|
|
76
|
-
npm install --save-dev llmnav
|
|
82
|
+
npm install --save-dev --ignore-scripts llmnav@X.Y.Z
|
|
77
83
|
npx llmnav --version
|
|
78
84
|
npx llmnav init --agents all --package-scripts
|
|
79
85
|
npx llmnav doctor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llmnav",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "A deterministic semantic navigation layer for LLM coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"release:tag": "node ./scripts/verify-tag.js",
|
|
49
49
|
"test:performance": "node --test tests/performance.test.js",
|
|
50
50
|
"benchmark:v0.2": "node ./benchmarks/run-v0.2.js",
|
|
51
|
+
"benchmark:navigation": "node ./benchmarks/navigation.js",
|
|
52
|
+
"benchmark:shards": "node ./benchmarks/search-shards.js",
|
|
51
53
|
"smoke:pack": "node ./scripts/pack-smoke.js"
|
|
52
54
|
},
|
|
53
55
|
"keywords": [
|
package/src/graph.js
CHANGED
|
@@ -79,6 +79,7 @@ export function buildRepositoryGraphIncremental(project, index, previousState =
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
partitions.sort((left, right) => compareText(left.key, right.key));
|
|
82
|
+
const partitionKeys = new Set(partitions.map((partition) => partition.key));
|
|
82
83
|
const nodes = new Map();
|
|
83
84
|
const edges = new Map();
|
|
84
85
|
for (const partition of partitions) {
|
|
@@ -115,7 +116,7 @@ export function buildRepositoryGraphIncremental(project, index, previousState =
|
|
|
115
116
|
totalPartitions: partitions.length,
|
|
116
117
|
reusedPartitions,
|
|
117
118
|
rebuiltPartitions,
|
|
118
|
-
removedPartitions: [...previousPartitions.keys()].filter((key) => !
|
|
119
|
+
removedPartitions: [...previousPartitions.keys()].filter((key) => !partitionKeys.has(key)).length,
|
|
119
120
|
},
|
|
120
121
|
};
|
|
121
122
|
}
|
package/src/incremental.js
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from "./util.js";
|
|
28
28
|
|
|
29
29
|
export const FILE_STATE_SCHEMA_VERSION = 1;
|
|
30
|
-
export const SOURCE_INDEXER_VERSION =
|
|
30
|
+
export const SOURCE_INDEXER_VERSION = 7;
|
|
31
31
|
const STAT_HINTS_SCHEMA_VERSION = 1;
|
|
32
32
|
|
|
33
33
|
export async function scanProjectIncremental(root, options = {}) {
|
package/src/index.d.ts
CHANGED
|
@@ -231,6 +231,9 @@ export interface SearchMetrics {
|
|
|
231
231
|
phraseDocumentsScanned?: number;
|
|
232
232
|
idDocumentsScanned?: number;
|
|
233
233
|
graphEdgesVisited?: number;
|
|
234
|
+
idNormalizations?: number;
|
|
235
|
+
lookupBuilds?: number;
|
|
236
|
+
graphValidations?: number;
|
|
234
237
|
}
|
|
235
238
|
|
|
236
239
|
export interface SearchResult {
|
|
@@ -244,7 +247,7 @@ export interface SearchResult {
|
|
|
244
247
|
|
|
245
248
|
export interface ProjectSession {
|
|
246
249
|
root: string;
|
|
247
|
-
query(query: string, options?: { top?: number }): SearchResult[];
|
|
250
|
+
query(query: string, options?: { top?: number; metrics?: SearchMetrics }): SearchResult[];
|
|
248
251
|
show(id: string): { card: IndexedCard | null; node: GraphNode | null; resolvedFrom: unknown };
|
|
249
252
|
context(id: string, options?: { depth?: number; budget?: number; maxEdges?: number }): { id: string; depth: number; budget: number; maxEdges: number; included: string[]; includedEdges: string[]; text: string };
|
|
250
253
|
refresh(): Promise<ProjectSession>;
|
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,6 +222,12 @@ 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;
|
|
222
233
|
const mask = new Uint8Array(source.length);
|
|
@@ -260,6 +271,21 @@ function buildLiteralMask(source, filePath) {
|
|
|
260
271
|
}
|
|
261
272
|
continue;
|
|
262
273
|
}
|
|
274
|
+
if (state === "regex") {
|
|
275
|
+
if (character === "\n" || character === "\r") {
|
|
276
|
+
state = "normal";
|
|
277
|
+
escaped = false;
|
|
278
|
+
} else if (escaped) escaped = false;
|
|
279
|
+
else if (character === "\\") escaped = true;
|
|
280
|
+
else if (character === "[") regexCharacterClass = true;
|
|
281
|
+
else if (character === "]") regexCharacterClass = false;
|
|
282
|
+
else if (character === "/" && !regexCharacterClass) {
|
|
283
|
+
state = "normal";
|
|
284
|
+
regexAllowed = false;
|
|
285
|
+
previousToken = "value";
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
263
289
|
if (state === "single" || state === "double" || state === "backtick") {
|
|
264
290
|
if (escaped) {
|
|
265
291
|
escaped = false;
|
|
@@ -270,7 +296,11 @@ function buildLiteralMask(source, filePath) {
|
|
|
270
296
|
continue;
|
|
271
297
|
}
|
|
272
298
|
const terminator = state === "single" ? "'" : state === "double" ? '"' : "`";
|
|
273
|
-
if (character === terminator)
|
|
299
|
+
if (character === terminator) {
|
|
300
|
+
state = "normal";
|
|
301
|
+
regexAllowed = false;
|
|
302
|
+
previousToken = "value";
|
|
303
|
+
}
|
|
274
304
|
continue;
|
|
275
305
|
}
|
|
276
306
|
|
|
@@ -283,6 +313,10 @@ function buildLiteralMask(source, filePath) {
|
|
|
283
313
|
} else if (character === "/" && next === "/") {
|
|
284
314
|
state = "line-comment";
|
|
285
315
|
index += 1;
|
|
316
|
+
} else if (javascript && character === "/" && regexAllowed) {
|
|
317
|
+
state = "regex";
|
|
318
|
+
regexCharacterClass = false;
|
|
319
|
+
escaped = false;
|
|
286
320
|
} else if (hashComments && character === "#") {
|
|
287
321
|
state = "line-comment";
|
|
288
322
|
} else if (dashComments && character === "-" && next === "-") {
|
|
@@ -303,6 +337,40 @@ function buildLiteralMask(source, filePath) {
|
|
|
303
337
|
} else if (character === "`") {
|
|
304
338
|
state = "backtick";
|
|
305
339
|
escaped = false;
|
|
340
|
+
} else if (javascript && !/\s/u.test(character)) {
|
|
341
|
+
// Track lexical expression position without rescanning prefixes. Comments do
|
|
342
|
+
// not change it; a control-condition ')' permits a following regex statement.
|
|
343
|
+
if (/[A-Za-z_$]/u.test(character)) {
|
|
344
|
+
const start = index;
|
|
345
|
+
const memberName = previousToken === ".";
|
|
346
|
+
while (index + 1 < source.length && /[\w$]/u.test(source[index + 1])) index += 1;
|
|
347
|
+
previousToken = source.slice(start, index + 1);
|
|
348
|
+
regexAllowed = !memberName && REGEX_PREFIX_KEYWORDS.has(previousToken);
|
|
349
|
+
} else if (character === "(") {
|
|
350
|
+
controlParentheses.push(CONTROL_PAREN_KEYWORDS.has(previousToken));
|
|
351
|
+
regexAllowed = true;
|
|
352
|
+
previousToken = character;
|
|
353
|
+
} else if (character === ")") {
|
|
354
|
+
regexAllowed = controlParentheses.pop() === true;
|
|
355
|
+
previousToken = character;
|
|
356
|
+
} else if (character === "{") {
|
|
357
|
+
expressionBraces.push(regexAllowed && !["", ";", "else", "do", "try", "finally", ")"].includes(previousToken));
|
|
358
|
+
regexAllowed = true;
|
|
359
|
+
previousToken = character;
|
|
360
|
+
} else if (character === "}") {
|
|
361
|
+
regexAllowed = expressionBraces.pop() !== true;
|
|
362
|
+
previousToken = character;
|
|
363
|
+
} else if ((character === "+" || character === "-") && next === character) {
|
|
364
|
+
previousToken = character + next;
|
|
365
|
+
index += 1;
|
|
366
|
+
} else if (character === "=" && next === ">") {
|
|
367
|
+
regexAllowed = true;
|
|
368
|
+
previousToken = "=>";
|
|
369
|
+
index += 1;
|
|
370
|
+
} else {
|
|
371
|
+
regexAllowed = "=(:,!&|?;[+-*%~^>/".includes(character);
|
|
372
|
+
previousToken = character;
|
|
373
|
+
}
|
|
306
374
|
}
|
|
307
375
|
}
|
|
308
376
|
|
package/src/search-shards.js
CHANGED
|
@@ -24,20 +24,26 @@ export function buildSearchShards(index, searchIndex, shardSize) {
|
|
|
24
24
|
const cardsById = new Map(index.cards.map((card) => [card.id, card]));
|
|
25
25
|
const shards = new Map();
|
|
26
26
|
const records = [];
|
|
27
|
+
const partitions = Array.from({ length: Math.ceil(searchIndex.cardIds.length / shardSize) }, () => ({ tokens: [], postings: [] }));
|
|
28
|
+
for (const [tokenIndex, token] of searchIndex.tokens.entries()) {
|
|
29
|
+
const selectedByShard = new Map();
|
|
30
|
+
for (const [cardIndex, vector] of searchIndex.postings[tokenIndex] ?? []) {
|
|
31
|
+
if (!(cardIndex >= 0 && cardIndex < searchIndex.cardIds.length)) continue;
|
|
32
|
+
const ordinal = Math.floor(cardIndex / shardSize);
|
|
33
|
+
const selected = selectedByShard.get(ordinal) ?? [];
|
|
34
|
+
selected.push([cardIndex - ordinal * shardSize, vector]);
|
|
35
|
+
selectedByShard.set(ordinal, selected);
|
|
36
|
+
}
|
|
37
|
+
for (const [ordinal, selected] of selectedByShard) {
|
|
38
|
+
partitions[ordinal].tokens.push(token);
|
|
39
|
+
partitions[ordinal].postings.push(selected);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
27
42
|
for (let start = 0, ordinal = 0; start < searchIndex.cardIds.length; start += shardSize, ordinal += 1) {
|
|
28
43
|
const end = Math.min(start + shardSize, searchIndex.cardIds.length);
|
|
29
44
|
const cardIds = searchIndex.cardIds.slice(start, end);
|
|
30
45
|
const cards = cardIds.map((id) => cardsById.get(id)).filter(Boolean);
|
|
31
|
-
const tokens = [];
|
|
32
|
-
const postings = [];
|
|
33
|
-
for (const [tokenIndex, token] of searchIndex.tokens.entries()) {
|
|
34
|
-
const selected = (searchIndex.postings[tokenIndex] ?? [])
|
|
35
|
-
.filter(([cardIndex]) => cardIndex >= start && cardIndex < end)
|
|
36
|
-
.map(([cardIndex, vector]) => [cardIndex - start, vector]);
|
|
37
|
-
if (selected.length === 0) continue;
|
|
38
|
-
tokens.push(token);
|
|
39
|
-
postings.push(selected);
|
|
40
|
-
}
|
|
46
|
+
const { tokens, postings } = partitions[ordinal];
|
|
41
47
|
const shard = {
|
|
42
48
|
...searchIndex,
|
|
43
49
|
cardSetHash: searchCardSetHash(cards),
|
|
@@ -49,6 +55,7 @@ export function buildSearchShards(index, searchIndex, shardSize) {
|
|
|
49
55
|
};
|
|
50
56
|
const file = `search-shards/${String(ordinal).padStart(4, "0")}.json`;
|
|
51
57
|
const content = stableStringify(shard);
|
|
58
|
+
partitions[ordinal] = null;
|
|
52
59
|
shards.set(file, content);
|
|
53
60
|
records.push({
|
|
54
61
|
file,
|
package/src/search.js
CHANGED
|
@@ -23,15 +23,21 @@ import {
|
|
|
23
23
|
verifySearchIndex,
|
|
24
24
|
} from "./inverted-index.js";
|
|
25
25
|
import { normalizeSearchText, tokenize } from "./tokenizer.js";
|
|
26
|
-
import { recoverGenerationTransaction } from "./transaction.js";
|
|
26
|
+
import { recoverGenerationTransaction, withGenerationLock } from "./transaction.js";
|
|
27
27
|
import { isCompatibleRepositoryGraph, renderGraphNode, resolveGraphNode } from "./graph.js";
|
|
28
28
|
|
|
29
29
|
const preparedIndexCache = new WeakMap();
|
|
30
30
|
const preparedSearchIndexCache = new WeakMap();
|
|
31
|
+
const sessionGraphAdjacencyCache = new WeakMap();
|
|
32
|
+
const sessionSearchMetadataCache = new WeakMap();
|
|
31
33
|
|
|
32
34
|
export async function loadSearchData(root) {
|
|
35
|
+
return withGenerationLock(root, (lock) => loadSearchDataLocked(root, lock));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function loadSearchDataLocked(root, lock) {
|
|
33
39
|
const { config } = await loadConfig(root);
|
|
34
|
-
await recoverGenerationTransaction(root, { cacheDirectory: config.generation.cacheDirectory });
|
|
40
|
+
await recoverGenerationTransaction(root, { cacheDirectory: config.generation.cacheDirectory, lockOwnerId: lock.ownerId });
|
|
35
41
|
const cacheRoot = path.join(root, config.generation.cacheDirectory);
|
|
36
42
|
await assertNoSymlinkTraversal(root, cacheRoot, config.generation.cacheDirectory);
|
|
37
43
|
const indexPath = path.join(cacheRoot, "index.json");
|
|
@@ -91,34 +97,54 @@ export async function queryProject(root, query, options = {}) {
|
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
export async function createProjectSession(root) {
|
|
94
|
-
let snapshot = await
|
|
100
|
+
let snapshot = await loadSessionSnapshot(root);
|
|
95
101
|
const session = {
|
|
96
102
|
root,
|
|
97
103
|
query(query, options = {}) {
|
|
98
|
-
return queryPreparedIndex(snapshot.index, snapshot.searchIndex, query, {
|
|
104
|
+
return structuredClone(queryPreparedIndex(snapshot.index, snapshot.searchIndex, query, {
|
|
99
105
|
...options,
|
|
100
106
|
lexicon: snapshot.lexicon,
|
|
101
107
|
graph: snapshot.graph,
|
|
102
|
-
});
|
|
108
|
+
}));
|
|
103
109
|
},
|
|
104
110
|
show(id) {
|
|
105
|
-
return showSnapshotCard(snapshot, id);
|
|
111
|
+
return structuredClone(showSnapshotCard(snapshot, id));
|
|
106
112
|
},
|
|
107
113
|
context(id, options = {}) {
|
|
108
114
|
return buildSnapshotContext(snapshot, id, options);
|
|
109
115
|
},
|
|
110
116
|
async refresh() {
|
|
111
|
-
snapshot = await
|
|
117
|
+
snapshot = await loadSessionSnapshot(root);
|
|
112
118
|
return session;
|
|
113
119
|
},
|
|
114
120
|
};
|
|
115
121
|
return session;
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
async function loadSessionSnapshot(root) {
|
|
125
|
+
const snapshot = await loadProjectSnapshot(root);
|
|
126
|
+
// Only session-owned graphs are cached: public query inputs may be mutable.
|
|
127
|
+
if (snapshot.graph) sessionGraphAdjacencyCache.set(snapshot.graph, buildGraphAdjacency(snapshot.graph));
|
|
128
|
+
sessionSearchMetadataCache.set(snapshot.index, prepareSearchMetadata(snapshot.index, snapshot.lexicon, snapshot.graph));
|
|
129
|
+
return snapshot;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function prepareSearchMetadata(index, lexicon, graph) {
|
|
133
|
+
return {
|
|
134
|
+
byId: new Map(index.cards.map((card) => [card.id, card])),
|
|
135
|
+
cardOrder: new Map(index.cards.map((card, position) => [card.id, position])),
|
|
136
|
+
normalizedIds: index.cards.map((card) => normalizeSearchText(card.id)),
|
|
137
|
+
aliases: Object.entries(lexicon.aliases ?? {}).map(([alias, targets]) => [alias, targets, normalizeSearchText(alias)]),
|
|
138
|
+
graphCompatible: isCompatibleRepositoryGraph(graph, index.repositoryId),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
async function loadProjectSnapshot(root) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
143
|
+
return withGenerationLock(root, async (lock) => {
|
|
144
|
+
const { index, lexicon, searchIndex, graph } = await loadSearchDataLocked(root, lock);
|
|
145
|
+
const registry = await loadRegistry(root);
|
|
146
|
+
return { index, lexicon, searchIndex, graph, registry };
|
|
147
|
+
});
|
|
122
148
|
}
|
|
123
149
|
|
|
124
150
|
export function queryIndex(index, query, options = {}) {
|
|
@@ -139,11 +165,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
|
|
|
139
165
|
const metrics = options.metrics ?? null;
|
|
140
166
|
const normalizedQuery = normalizeSearchText(query);
|
|
141
167
|
const queryTokens = tokenize(query);
|
|
142
|
-
const
|
|
168
|
+
const cached = sessionSearchMetadataCache.get(index);
|
|
169
|
+
const prepared = cached ?? prepareSearchMetadata(index, lexicon, options.graph);
|
|
170
|
+
const aliases = prepared.aliases;
|
|
143
171
|
const aliasTargets = new Set();
|
|
144
172
|
const aliasReasons = new Map();
|
|
145
|
-
const byId
|
|
146
|
-
const cardOrder = new Map(index.cards.map((card, cardIndex) => [card.id, cardIndex]));
|
|
173
|
+
const { byId, cardOrder } = prepared;
|
|
147
174
|
const resultsById = new Map();
|
|
148
175
|
|
|
149
176
|
if (metrics) {
|
|
@@ -153,10 +180,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
|
|
|
153
180
|
metrics.phraseDocumentsScanned = 0;
|
|
154
181
|
metrics.idDocumentsScanned = 0;
|
|
155
182
|
metrics.graphEdgesVisited = 0;
|
|
183
|
+
metrics.idNormalizations = cached ? 0 : index.cards.length;
|
|
184
|
+
metrics.lookupBuilds = cached ? 0 : 2;
|
|
185
|
+
metrics.graphValidations = cached ? 0 : 1;
|
|
156
186
|
}
|
|
157
187
|
|
|
158
|
-
for (const [alias, targetValue] of aliases) {
|
|
159
|
-
const normalizedAlias = normalizeSearchText(alias);
|
|
188
|
+
for (const [alias, targetValue, normalizedAlias] of aliases) {
|
|
160
189
|
if (!normalizedAlias || !normalizedQuery.includes(normalizedAlias)) continue;
|
|
161
190
|
const targets = Array.isArray(targetValue) ? targetValue : [targetValue];
|
|
162
191
|
for (const target of targets) {
|
|
@@ -167,11 +196,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
|
|
|
167
196
|
}
|
|
168
197
|
}
|
|
169
198
|
|
|
170
|
-
for (const card of index.cards) {
|
|
199
|
+
for (const [position, card] of index.cards.entries()) {
|
|
171
200
|
if (metrics) metrics.idDocumentsScanned += 1;
|
|
172
|
-
|
|
201
|
+
const normalizedId = prepared.normalizedIds[position];
|
|
202
|
+
if (normalizedId === normalizedQuery) {
|
|
173
203
|
addScore(resultsById, card, 1000, "exact semantic ID");
|
|
174
|
-
} else if (
|
|
204
|
+
} else if (normalizedId.includes(normalizedQuery) && normalizedQuery.length > 2) {
|
|
175
205
|
addScore(resultsById, card, 100, "semantic ID phrase");
|
|
176
206
|
}
|
|
177
207
|
if (aliasTargets.has(card.id)) {
|
|
@@ -215,7 +245,7 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
|
|
|
215
245
|
const seeds = [...results]
|
|
216
246
|
.sort((left, right) => right.score - left.score || (cardOrder.get(left.card.id) ?? 0) - (cardOrder.get(right.card.id) ?? 0))
|
|
217
247
|
.slice(0, 3);
|
|
218
|
-
if (
|
|
248
|
+
if (prepared.graphCompatible) {
|
|
219
249
|
applyGraphBonuses(index, options.graph, seeds, byId, resultsById, metrics);
|
|
220
250
|
} else {
|
|
221
251
|
applyLegacyRelationBonuses(seeds, byId, resultsById);
|
|
@@ -491,8 +521,9 @@ function buildLegacyContext(index, rootId, options) {
|
|
|
491
521
|
const queue = [{ id: rootId, depth: 0 }];
|
|
492
522
|
const visited = new Set();
|
|
493
523
|
const selected = [];
|
|
494
|
-
|
|
495
|
-
|
|
524
|
+
let traversedEdges = 0;
|
|
525
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
526
|
+
const current = queue[cursor];
|
|
496
527
|
if (!current || visited.has(current.id)) continue;
|
|
497
528
|
visited.add(current.id);
|
|
498
529
|
const card = byId.get(current.id);
|
|
@@ -500,10 +531,16 @@ function buildLegacyContext(index, rootId, options) {
|
|
|
500
531
|
selected.push(card);
|
|
501
532
|
if (current.depth >= depth) continue;
|
|
502
533
|
for (const relation of card.rel ?? []) {
|
|
534
|
+
if (traversedEdges >= maxEdges) break;
|
|
503
535
|
const target = relation.slice(relation.indexOf(">") + 1);
|
|
504
536
|
queue.push({ id: target, depth: current.depth + 1 });
|
|
537
|
+
traversedEdges += 1;
|
|
538
|
+
}
|
|
539
|
+
for (const source of reverse.get(card.id) ?? []) {
|
|
540
|
+
if (traversedEdges >= maxEdges) break;
|
|
541
|
+
queue.push({ id: source, depth: current.depth + 1 });
|
|
542
|
+
traversedEdges += 1;
|
|
505
543
|
}
|
|
506
|
-
for (const source of reverse.get(card.id) ?? []) queue.push({ id: source, depth: current.depth + 1 });
|
|
507
544
|
}
|
|
508
545
|
|
|
509
546
|
const header = `llmnav-context/1 root=${rootId} depth=${depth}\n`;
|
|
@@ -575,6 +612,8 @@ function applyLegacyRelationBonuses(seeds, byId, resultsById) {
|
|
|
575
612
|
}
|
|
576
613
|
|
|
577
614
|
function buildGraphAdjacency(graph) {
|
|
615
|
+
const cached = sessionGraphAdjacencyCache.get(graph);
|
|
616
|
+
if (cached) return cached;
|
|
578
617
|
const adjacency = new Map();
|
|
579
618
|
for (const edge of [...graph.edges].sort(compareGraphEdgesForTraversal)) {
|
|
580
619
|
appendGraphNeighbor(adjacency, edge.from, { neighbor: edge.to, direction: "out", edge });
|
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.2";
|
|
14
14
|
export const SPEC_VERSION = "1";
|
|
15
15
|
|
|
16
16
|
export const SCOPES = Object.freeze(["file", "module", "symbol"]);
|
package/src/transaction.js
CHANGED
|
@@ -9,7 +9,7 @@ stability=architecture
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
-
import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
12
|
+
import { link, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
15
15
|
import {
|
|
@@ -32,6 +32,7 @@ const DEFAULT_RETRY_DELAYS = Object.freeze([0, 8, 16, 32, 64, 128, 256, 512]);
|
|
|
32
32
|
const JOURNAL_PHASES = new Set(["prepared", "old-moved", "new-installed", "committed"]);
|
|
33
33
|
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
|
34
34
|
const DEFAULT_LOCK_POLL_MS = 50;
|
|
35
|
+
const LOCK_LINK_CAPABILITY_ERRORS = new Set(["ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV"]);
|
|
35
36
|
const CONTROL_ARTIFACT_PATHS = new Set([".llmnav/ids.jsonl", ".llmnav/order.lock"]);
|
|
36
37
|
|
|
37
38
|
export async function withGenerationLock(root, callback, options = {}) {
|
|
@@ -54,28 +55,50 @@ export async function acquireGenerationLock(root, options = {}) {
|
|
|
54
55
|
const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
|
|
55
56
|
const delays = options.delays ?? [0, ...Array.from({ length: Math.ceil(timeoutMs / pollMs) }, () => pollMs)];
|
|
56
57
|
const openImpl = options.openImpl ?? open;
|
|
58
|
+
const linkImpl = options.linkImpl ?? link;
|
|
57
59
|
const sleepImpl = options.sleepImpl ?? sleep;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
const candidatePath = `${lockPath}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
61
|
+
// Publish only a closed, complete owner record. A crash before publication
|
|
62
|
+
// leaves an unused candidate, never an unreadable authoritative lock.
|
|
63
|
+
let candidateCreated = false;
|
|
64
|
+
try {
|
|
65
|
+
const handle = await openImpl(candidatePath, "wx");
|
|
66
|
+
candidateCreated = true;
|
|
61
67
|
try {
|
|
62
|
-
|
|
68
|
+
await handle.writeFile(stableStringify({ schemaVersion: 1, ownerId, pid: process.pid }));
|
|
69
|
+
await handle.sync();
|
|
70
|
+
} finally {
|
|
71
|
+
await handle.close();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const delay of delays) {
|
|
75
|
+
if (delay > 0) await sleepImpl(delay);
|
|
63
76
|
try {
|
|
64
|
-
await
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
await
|
|
77
|
+
await linkImpl(candidatePath, lockPath);
|
|
78
|
+
return { root, lockPath, ownerId };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error && typeof error === "object" && LOCK_LINK_CAPABILITY_ERRORS.has(error.code)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Cannot publish the LLMNav generation lock (${error.code}). The repository filesystem must support hard links and permit their creation. Check filesystem permissions or move the repository to a filesystem with hard-link support.`,
|
|
83
|
+
{ cause: error },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
|
|
87
|
+
const existing = await readJsonSafe(lockPath, null);
|
|
88
|
+
if (existing && Number.isInteger(existing.pid) && !isProcessAlive(existing.pid)) {
|
|
89
|
+
await removeOwnedLock(lockPath, existing.ownerId);
|
|
90
|
+
}
|
|
75
91
|
}
|
|
76
92
|
}
|
|
93
|
+
const existing = await readJsonSafe(lockPath, null);
|
|
94
|
+
if (!existing || !Number.isInteger(existing.pid) || !existing.ownerId) {
|
|
95
|
+
throw new Error("The LLMNav generation lock has no valid owner record. After stopping all LLMNav processes, remove .llmnav/generation.lock and retry.");
|
|
96
|
+
}
|
|
97
|
+
throw new Error("Timed out waiting for the LLMNav generation lock.");
|
|
98
|
+
} finally {
|
|
99
|
+
// Candidate cleanup must not turn successful acquisition into a leaked lock.
|
|
100
|
+
if (candidateCreated) await removeWithRetry(candidatePath).catch(() => {});
|
|
77
101
|
}
|
|
78
|
-
throw new Error("Timed out waiting for the LLMNav generation lock.");
|
|
79
102
|
}
|
|
80
103
|
|
|
81
104
|
export async function releaseGenerationLock(lock) {
|