context-loader 1.2.0__tar.gz → 1.3.1__tar.gz
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.
- {context_loader-1.2.0 → context_loader-1.3.1}/PKG-INFO +98 -32
- {context_loader-1.2.0 → context_loader-1.3.1}/README.md +97 -31
- {context_loader-1.2.0 → context_loader-1.3.1}/context_loader/__init__.py +1 -1
- {context_loader-1.2.0 → context_loader-1.3.1}/context_loader/application.py +34 -4
- {context_loader-1.2.0 → context_loader-1.3.1}/context_loader/cli.py +2 -2
- {context_loader-1.2.0 → context_loader-1.3.1}/context_loader/collect.py +129 -0
- {context_loader-1.2.0 → context_loader-1.3.1}/context_loader/render.py +11 -29
- {context_loader-1.2.0 → context_loader-1.3.1}/pyproject.toml +1 -1
- {context_loader-1.2.0 → context_loader-1.3.1}/pyproject.toml.orig +1 -1
- {context_loader-1.2.0 → context_loader-1.3.1}/tool_cli_contract.json +1 -1
- {context_loader-1.2.0 → context_loader-1.3.1}/LICENSE +0 -0
- {context_loader-1.2.0 → context_loader-1.3.1}/context_loader/git.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: context-loader
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Render deterministic local Git context for any executor
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -219,14 +219,14 @@ Python standard library.
|
|
|
219
219
|
|
|
220
220
|
## Open-source quick start
|
|
221
221
|
|
|
222
|
-
Current stable release: **1.
|
|
222
|
+
Current stable release: **1.3.1**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
|
|
223
223
|
|
|
224
224
|
```bash
|
|
225
|
-
uv tool install 'context-loader==1.
|
|
225
|
+
uv tool install 'context-loader==1.3.1'
|
|
226
226
|
```
|
|
227
227
|
|
|
228
228
|
```bash
|
|
229
|
-
pip install 'context-loader==1.
|
|
229
|
+
pip install 'context-loader==1.3.1'
|
|
230
230
|
```
|
|
231
231
|
|
|
232
232
|
```bash
|
|
@@ -239,21 +239,21 @@ project-context --repo /path/to/repo --format json
|
|
|
239
239
|
|
|
240
240
|
## Install
|
|
241
241
|
|
|
242
|
-
Source version: `1.
|
|
242
|
+
Source version: `1.3.1`. Install its matching published release or an exact source commit.
|
|
243
243
|
|
|
244
244
|
Install via `uv`:
|
|
245
245
|
|
|
246
246
|
```bash
|
|
247
|
-
uv tool install 'context-loader==1.
|
|
247
|
+
uv tool install 'context-loader==1.3.1'
|
|
248
248
|
```
|
|
249
249
|
|
|
250
250
|
Install via `pip`:
|
|
251
251
|
|
|
252
252
|
```bash
|
|
253
|
-
pip install 'context-loader==1.
|
|
253
|
+
pip install 'context-loader==1.3.1'
|
|
254
254
|
```
|
|
255
255
|
|
|
256
|
-
For development or source-based installs tracking current repository (`1.
|
|
256
|
+
For development or source-based installs tracking current repository (`1.3.1`):
|
|
257
257
|
|
|
258
258
|
```bash
|
|
259
259
|
uv tool install git+https://github.com/xuanheng-tech/context-loader.git
|
|
@@ -264,8 +264,8 @@ The repository also retains `./project-context` as a direct development entry po
|
|
|
264
264
|
### Distributions
|
|
265
265
|
|
|
266
266
|
The sole distribution is **`context-loader`**, containing the `context_loader` runtime
|
|
267
|
-
and the `project-context` console script. Version `1.0.0`
|
|
268
|
-
heading contract
|
|
267
|
+
and the `project-context` console script. Version `1.0.0` broke the CLI name and Markdown
|
|
268
|
+
heading contract while leaving the then-current JSON schema unchanged. Terminal and any executor call this same
|
|
269
269
|
entry point with explicit repository and focus arguments; no provider adapter or private
|
|
270
270
|
session state is involved.
|
|
271
271
|
|
|
@@ -338,16 +338,17 @@ The output has no generated timestamp, AI summary, architecture inference, or di
|
|
|
338
338
|
## JSON Output
|
|
339
339
|
|
|
340
340
|
`--format json` writes exactly one separator-compact UTF-8 JSON document plus one trailing newline
|
|
341
|
-
to stdout
|
|
342
|
-
uses the identical serialization; only the field
|
|
343
|
-
consumption”. The declared contract for
|
|
341
|
+
to stdout, within the JSON document bound under “Limits”. Keys are serialized in sorted order
|
|
342
|
+
with `ensure_ascii=False`. `--format json-compact` uses the identical serialization; only the field
|
|
343
|
+
set differs, as described under “Compact model consumption”. The declared contract for
|
|
344
|
+
`--format json` is:
|
|
344
345
|
|
|
345
346
|
```json
|
|
346
347
|
{
|
|
347
|
-
"schema_version":
|
|
348
|
+
"schema_version": 3,
|
|
348
349
|
"tool": {
|
|
349
350
|
"name": "context-loader",
|
|
350
|
-
"version": "1.
|
|
351
|
+
"version": "1.3.1"
|
|
351
352
|
},
|
|
352
353
|
"repository": {
|
|
353
354
|
"requested_path": "/canonical/requested/path",
|
|
@@ -377,6 +378,11 @@ consumption”. The declared contract for `--format json` is:
|
|
|
377
378
|
"context": "the same assembled Markdown context",
|
|
378
379
|
"context_sha256": "sha256-hex",
|
|
379
380
|
"statuses": [],
|
|
381
|
+
"nested_context": {
|
|
382
|
+
"files": ["docs/AGENTS.md"],
|
|
383
|
+
"list_truncated": false,
|
|
384
|
+
"scan_truncated": false
|
|
385
|
+
},
|
|
380
386
|
"warnings": []
|
|
381
387
|
}
|
|
382
388
|
```
|
|
@@ -389,31 +395,65 @@ context, in assembly order, after the existing newline normalization and truncat
|
|
|
389
395
|
distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
|
|
390
396
|
only `repository` sources and does not add any global-file discovery.
|
|
391
397
|
|
|
392
|
-
`statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget,
|
|
398
|
+
`statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown — plus the nested-context truncation flags, which exist only in machine form: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget, truncated working-tree or declared-command listings, and truncated nested-`AGENTS.md` presence reports. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two `nested_agents_*` codes mirror the `nested_context` truncation booleans for consumers that branch only on `statuses`; the full `nested_context` object remains a first-class field, never folded into `statuses`. Callers can ignore `statuses` safely.
|
|
393
399
|
|
|
394
400
|
The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
|
|
395
401
|
contain heading, heading level, and fixed selection reasons; it never contains the original focus or
|
|
396
|
-
target path. Existing source fields
|
|
402
|
+
target path. Existing per-source fields remain unchanged; the document's exact key
|
|
403
|
+
set (now including `nested_context`) is named by its `schema_version`, described below.
|
|
404
|
+
|
|
405
|
+
`nested_context` is always present and existence-only: `files` lists repository-relative paths of
|
|
406
|
+
non-directory `AGENTS.md` entries under subdirectories, found by a bounded scan that enumerates
|
|
407
|
+
directory entries, never opens or reads a candidate file and never traverses a symlink; it carries
|
|
408
|
+
no sizes, no mtimes and no contents. A symlinked (even dangling) `AGENTS.md` is listed, because its
|
|
409
|
+
existence comes from the directory entry itself; the scan never resolves what it points at. Any
|
|
410
|
+
`.git`, `.venv`, `venv`, `node_modules` or `site-packages` directory is skipped at every depth:
|
|
411
|
+
package-manager and interpreter-managed trees are not authored repository instructions.
|
|
412
|
+
`list_truncated` means more matching entries exist beyond the report caps: 32 paths, and a 4 KiB
|
|
413
|
+
budget metered on the emitted array itself — each path's escaped, JSON-serialized string plus its
|
|
414
|
+
separator, with the array's brackets charged to the first entry — so the emitted array never costs
|
|
415
|
+
more than the budget it declares. Because quoting is counted, an array already near the cap can
|
|
416
|
+
report one path fewer than an older release did, and then says so through `list_truncated`.
|
|
417
|
+
`scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, or a directory
|
|
418
|
+
or entry could not be read, so absence of a path is not proof of absence of the file. Nested paths
|
|
419
|
+
are sanitized with the same escaping the Markdown uses for repository-derived text, so a nested name
|
|
420
|
+
the filesystem could not decode never breaks the document;
|
|
421
|
+
see “Limits” for the root paths that are emitted verbatim. The corresponding
|
|
422
|
+
`nested_agents_list_truncated` and `nested_agents_scan_truncated` status entries mirror both flags.
|
|
423
|
+
Presence is not instruction: whether a nested file applies, and its text, remain the caller's
|
|
424
|
+
judgment; Markdown output is unchanged.
|
|
397
425
|
|
|
398
426
|
### Compact model consumption (`--format json-compact`)
|
|
399
427
|
|
|
400
|
-
`--format json-compact` emits a `schema_version`
|
|
428
|
+
`--format json-compact` emits a `schema_version` 4 document: exactly the version-3 document with
|
|
401
429
|
`sources[*].content` omitted. Every other field — `context`, `context_sha256`, `statuses`,
|
|
402
|
-
`warnings`, `tool`, `repository`, and each source's `ordinal`, `kind`, `scope`,
|
|
403
|
-
`content_sha256` and optional `selection` — is identical to `--format json` for the same
|
|
430
|
+
`nested_context`, `warnings`, `tool`, `repository`, and each source's `ordinal`, `kind`, `scope`,
|
|
431
|
+
`path`, `content_sha256` and optional `selection` — is identical to `--format json` for the same
|
|
432
|
+
arguments.
|
|
433
|
+
`sources` is provenance and index metadata, not the place a body must be fetched from: every omitted
|
|
434
|
+
body is already inside `context` verbatim, so consumers should read `context` for rule text and
|
|
435
|
+
reopen a source file only when the rendered body was truncated or selected away — never to recover a
|
|
436
|
+
body that the document already carried.
|
|
404
437
|
The projection exists because the selected source bodies already occur verbatim inside `context`,
|
|
405
438
|
so carrying them again in `sources` duplicates a large share of the document bytes (measured up to
|
|
406
439
|
~44% on real worktrees, scaling with how much of `context` those bodies occupy) for a model
|
|
407
440
|
consumer without adding information. Nothing is lost: each omitted body remains inside `context`,
|
|
408
441
|
`content_sha256` still fingerprints that rendered body, and `path` plus `canonical_root` locate the
|
|
409
442
|
underlying file for a bounded manual re-read (whose raw bytes may differ from the rendered body as
|
|
410
|
-
defined above). `schema_version` is an exact document selector, not an upgrade marker: version
|
|
411
|
-
a field-set projection of version
|
|
412
|
-
`>=`-superset reasoning.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
443
|
+
defined above). `schema_version` is an exact document selector, not an upgrade marker: version 4 is
|
|
444
|
+
a field-set projection of version 3, so consumers must branch on the value and must not apply
|
|
445
|
+
`>=`-superset reasoning. Introducing this compact format does
|
|
446
|
+
not otherwise change default Markdown bytes, exit codes or determinism.
|
|
447
|
+
|
|
448
|
+
One historical exception is acknowledged: releases 1.0.0 and 1.1.0 both emitted `schema_version` 1
|
|
449
|
+
although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward each emitted number
|
|
450
|
+
names exactly one key set, and the `nested_context` renumber below enforces that rule going forward.
|
|
451
|
+
|
|
452
|
+
The JSON schema version and package version are independent: this build emits `schema_version` `3`
|
|
453
|
+
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.1`. Version 1
|
|
454
|
+
and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
|
|
455
|
+
`nested_context` changes the default document's key set, version 3 names the current full-document
|
|
456
|
+
key set and version 4 names the compact projection.
|
|
417
457
|
Callers must depend only on fields declared above.
|
|
418
458
|
The document contains no generated time or random identifier, so unchanged input produces identical
|
|
419
459
|
JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
|
|
@@ -428,7 +468,9 @@ Resolving an instruction hierarchy stays with the calling agent harness. That in
|
|
|
428
468
|
shared or user-level instruction file outside the repository, nested or scoped `AGENTS.md` files
|
|
429
469
|
under subdirectories, and any include or import directive written inside an instruction file:
|
|
430
470
|
such a directive is transported as literal text and is never followed. `--path` selects sections
|
|
431
|
-
of the repository-root `AGENTS.md` only; it never changes which files are read.
|
|
471
|
+
of the repository-root `AGENTS.md` only; it never changes which files are read. The JSON-only
|
|
472
|
+
`nested_context` field reports the existence of nested `AGENTS.md` paths without reading them;
|
|
473
|
+
deciding whether any of them applies stays with the calling agent harness exactly as before.
|
|
432
474
|
|
|
433
475
|
A successful run therefore proves that the bounded root context was collected and rendered. It
|
|
434
476
|
does not prove that every instruction applicable to a task has been loaded.
|
|
@@ -461,7 +503,23 @@ scan that ends before EOF still reports the characters it could not select.
|
|
|
461
503
|
|
|
462
504
|
## Limits
|
|
463
505
|
|
|
464
|
-
|
|
506
|
+
Final output bounds differ per format. The two stdout bounds below are enforced on the bytes written
|
|
507
|
+
to stdout and the nested report cap on the bytes the document emits for that array; every other
|
|
508
|
+
bullet is a per-component, read or capture bound, as each one states:
|
|
509
|
+
|
|
510
|
+
- Markdown stdout: 98,304 bytes. A section that cannot fit is replaced by an omission notice, and so
|
|
511
|
+
is every later section, so the limit holds without cutting a section mid-body.
|
|
512
|
+
- JSON and JSON-compact stdout: 8,388,608 bytes (8 MiB) for the serialized document, including its
|
|
513
|
+
trailing newline. This bound is fail-closed: if a repository's evidence cannot fit, the tool exits
|
|
514
|
+
1, writes nothing to stdout and prints `error: JSON output exceeded the 8388608 byte document
|
|
515
|
+
limit`. It never emits a truncated document. The component budgets below keep legitimate
|
|
516
|
+
documents far inside it — the largest escape-dense repository audited for this release emitted
|
|
517
|
+
263 KiB, of which 248 KiB was escaped `statuses` subjects — so it is a final backstop rather
|
|
518
|
+
than an expected truncation point.
|
|
519
|
+
- Component budgets below are metered on the Markdown `context` content, and that same string is the
|
|
520
|
+
JSON `context` value; because JSON escaping expands quotes, backslashes and control characters,
|
|
521
|
+
the serialized bytes can be several times larger. The JSON document also repeats the source
|
|
522
|
+
bodies, so no single component figure bounds JSON stdout.
|
|
465
523
|
- `AGENTS.md`: 256-KiB bounded source scan; selected source plus selection audit remains at most
|
|
466
524
|
16 KiB, including a 4-KiB maximum small head
|
|
467
525
|
- `README.md`: 16 KiB
|
|
@@ -469,8 +527,14 @@ scan that ends before EOF still reports the characters it could not select.
|
|
|
469
527
|
- All entry-file bodies: 24 KiB
|
|
470
528
|
- Declared commands: 8 KiB
|
|
471
529
|
- Directory tree: 12 KiB, 300 entries, and depth 2
|
|
530
|
+
- Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths and
|
|
531
|
+
a 4 KiB budget metered on the emitted array; file contents are never read
|
|
472
532
|
- Working-tree changes: 100 paths and 4 KiB
|
|
473
533
|
- Recent commits: 8
|
|
534
|
+
- JSON-only listings have no byte budget of their own: `statuses` subjects are escaped names bounded
|
|
535
|
+
by the entry counts above, while `repository` and `sources[*].path` are emitted verbatim, so a
|
|
536
|
+
repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
|
|
537
|
+
still renders
|
|
474
538
|
- Git subprocess output: 16 MiB (bounded while reading)
|
|
475
539
|
- Candidate file validation: 16 MiB hard read bound
|
|
476
540
|
|
|
@@ -507,7 +571,8 @@ or candidate-file content.
|
|
|
507
571
|
|
|
508
572
|
## Not Included
|
|
509
573
|
|
|
510
|
-
Version 1.
|
|
574
|
+
Version 1.3.1 does not provide AI summaries, project-type detection, loading or transport of
|
|
575
|
+
nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
|
|
511
576
|
Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
|
|
512
577
|
network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
|
|
513
578
|
|
|
@@ -521,8 +586,9 @@ just check
|
|
|
521
586
|
|
|
522
587
|
## Version maintenance
|
|
523
588
|
|
|
524
|
-
The versions in `pyproject.toml` and `context_loader/__init__.py`, the
|
|
525
|
-
section, required tests, and the README
|
|
589
|
+
The versions in `pyproject.toml` and `context_loader/__init__.py`, the `tool_version` in
|
|
590
|
+
`tool_cli_contract.json`, the matching `CHANGELOG.md` section, required tests, and the README
|
|
591
|
+
current-stable declaration plus `context-loader==X.Y.Z`
|
|
526
592
|
install pins must change in the same release-preparation batch. `CHANGELOG.md` is the
|
|
527
593
|
authoritative version-change record. `scripts/release.py` rejects README stable/install
|
|
528
594
|
pins that disagree with the intended package version; historical changelog entries are
|
|
@@ -8,14 +8,14 @@ Python standard library.
|
|
|
8
8
|
|
|
9
9
|
## Open-source quick start
|
|
10
10
|
|
|
11
|
-
Current stable release: **1.
|
|
11
|
+
Current stable release: **1.3.1**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
uv tool install 'context-loader==1.
|
|
14
|
+
uv tool install 'context-loader==1.3.1'
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
pip install 'context-loader==1.
|
|
18
|
+
pip install 'context-loader==1.3.1'
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
```bash
|
|
@@ -28,21 +28,21 @@ project-context --repo /path/to/repo --format json
|
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
31
|
-
Source version: `1.
|
|
31
|
+
Source version: `1.3.1`. Install its matching published release or an exact source commit.
|
|
32
32
|
|
|
33
33
|
Install via `uv`:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
uv tool install 'context-loader==1.
|
|
36
|
+
uv tool install 'context-loader==1.3.1'
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Install via `pip`:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
pip install 'context-loader==1.
|
|
42
|
+
pip install 'context-loader==1.3.1'
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
For development or source-based installs tracking current repository (`1.
|
|
45
|
+
For development or source-based installs tracking current repository (`1.3.1`):
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
uv tool install git+https://github.com/xuanheng-tech/context-loader.git
|
|
@@ -53,8 +53,8 @@ The repository also retains `./project-context` as a direct development entry po
|
|
|
53
53
|
### Distributions
|
|
54
54
|
|
|
55
55
|
The sole distribution is **`context-loader`**, containing the `context_loader` runtime
|
|
56
|
-
and the `project-context` console script. Version `1.0.0`
|
|
57
|
-
heading contract
|
|
56
|
+
and the `project-context` console script. Version `1.0.0` broke the CLI name and Markdown
|
|
57
|
+
heading contract while leaving the then-current JSON schema unchanged. Terminal and any executor call this same
|
|
58
58
|
entry point with explicit repository and focus arguments; no provider adapter or private
|
|
59
59
|
session state is involved.
|
|
60
60
|
|
|
@@ -127,16 +127,17 @@ The output has no generated timestamp, AI summary, architecture inference, or di
|
|
|
127
127
|
## JSON Output
|
|
128
128
|
|
|
129
129
|
`--format json` writes exactly one separator-compact UTF-8 JSON document plus one trailing newline
|
|
130
|
-
to stdout
|
|
131
|
-
uses the identical serialization; only the field
|
|
132
|
-
consumption”. The declared contract for
|
|
130
|
+
to stdout, within the JSON document bound under “Limits”. Keys are serialized in sorted order
|
|
131
|
+
with `ensure_ascii=False`. `--format json-compact` uses the identical serialization; only the field
|
|
132
|
+
set differs, as described under “Compact model consumption”. The declared contract for
|
|
133
|
+
`--format json` is:
|
|
133
134
|
|
|
134
135
|
```json
|
|
135
136
|
{
|
|
136
|
-
"schema_version":
|
|
137
|
+
"schema_version": 3,
|
|
137
138
|
"tool": {
|
|
138
139
|
"name": "context-loader",
|
|
139
|
-
"version": "1.
|
|
140
|
+
"version": "1.3.1"
|
|
140
141
|
},
|
|
141
142
|
"repository": {
|
|
142
143
|
"requested_path": "/canonical/requested/path",
|
|
@@ -166,6 +167,11 @@ consumption”. The declared contract for `--format json` is:
|
|
|
166
167
|
"context": "the same assembled Markdown context",
|
|
167
168
|
"context_sha256": "sha256-hex",
|
|
168
169
|
"statuses": [],
|
|
170
|
+
"nested_context": {
|
|
171
|
+
"files": ["docs/AGENTS.md"],
|
|
172
|
+
"list_truncated": false,
|
|
173
|
+
"scan_truncated": false
|
|
174
|
+
},
|
|
169
175
|
"warnings": []
|
|
170
176
|
}
|
|
171
177
|
```
|
|
@@ -178,31 +184,65 @@ context, in assembly order, after the existing newline normalization and truncat
|
|
|
178
184
|
distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
|
|
179
185
|
only `repository` sources and does not add any global-file discovery.
|
|
180
186
|
|
|
181
|
-
`statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget,
|
|
187
|
+
`statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown — plus the nested-context truncation flags, which exist only in machine form: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget, truncated working-tree or declared-command listings, and truncated nested-`AGENTS.md` presence reports. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two `nested_agents_*` codes mirror the `nested_context` truncation booleans for consumers that branch only on `statuses`; the full `nested_context` object remains a first-class field, never folded into `statuses`. Callers can ignore `statuses` safely.
|
|
182
188
|
|
|
183
189
|
The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
|
|
184
190
|
contain heading, heading level, and fixed selection reasons; it never contains the original focus or
|
|
185
|
-
target path. Existing source fields
|
|
191
|
+
target path. Existing per-source fields remain unchanged; the document's exact key
|
|
192
|
+
set (now including `nested_context`) is named by its `schema_version`, described below.
|
|
193
|
+
|
|
194
|
+
`nested_context` is always present and existence-only: `files` lists repository-relative paths of
|
|
195
|
+
non-directory `AGENTS.md` entries under subdirectories, found by a bounded scan that enumerates
|
|
196
|
+
directory entries, never opens or reads a candidate file and never traverses a symlink; it carries
|
|
197
|
+
no sizes, no mtimes and no contents. A symlinked (even dangling) `AGENTS.md` is listed, because its
|
|
198
|
+
existence comes from the directory entry itself; the scan never resolves what it points at. Any
|
|
199
|
+
`.git`, `.venv`, `venv`, `node_modules` or `site-packages` directory is skipped at every depth:
|
|
200
|
+
package-manager and interpreter-managed trees are not authored repository instructions.
|
|
201
|
+
`list_truncated` means more matching entries exist beyond the report caps: 32 paths, and a 4 KiB
|
|
202
|
+
budget metered on the emitted array itself — each path's escaped, JSON-serialized string plus its
|
|
203
|
+
separator, with the array's brackets charged to the first entry — so the emitted array never costs
|
|
204
|
+
more than the budget it declares. Because quoting is counted, an array already near the cap can
|
|
205
|
+
report one path fewer than an older release did, and then says so through `list_truncated`.
|
|
206
|
+
`scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, or a directory
|
|
207
|
+
or entry could not be read, so absence of a path is not proof of absence of the file. Nested paths
|
|
208
|
+
are sanitized with the same escaping the Markdown uses for repository-derived text, so a nested name
|
|
209
|
+
the filesystem could not decode never breaks the document;
|
|
210
|
+
see “Limits” for the root paths that are emitted verbatim. The corresponding
|
|
211
|
+
`nested_agents_list_truncated` and `nested_agents_scan_truncated` status entries mirror both flags.
|
|
212
|
+
Presence is not instruction: whether a nested file applies, and its text, remain the caller's
|
|
213
|
+
judgment; Markdown output is unchanged.
|
|
186
214
|
|
|
187
215
|
### Compact model consumption (`--format json-compact`)
|
|
188
216
|
|
|
189
|
-
`--format json-compact` emits a `schema_version`
|
|
217
|
+
`--format json-compact` emits a `schema_version` 4 document: exactly the version-3 document with
|
|
190
218
|
`sources[*].content` omitted. Every other field — `context`, `context_sha256`, `statuses`,
|
|
191
|
-
`warnings`, `tool`, `repository`, and each source's `ordinal`, `kind`, `scope`,
|
|
192
|
-
`content_sha256` and optional `selection` — is identical to `--format json` for the same
|
|
219
|
+
`nested_context`, `warnings`, `tool`, `repository`, and each source's `ordinal`, `kind`, `scope`,
|
|
220
|
+
`path`, `content_sha256` and optional `selection` — is identical to `--format json` for the same
|
|
221
|
+
arguments.
|
|
222
|
+
`sources` is provenance and index metadata, not the place a body must be fetched from: every omitted
|
|
223
|
+
body is already inside `context` verbatim, so consumers should read `context` for rule text and
|
|
224
|
+
reopen a source file only when the rendered body was truncated or selected away — never to recover a
|
|
225
|
+
body that the document already carried.
|
|
193
226
|
The projection exists because the selected source bodies already occur verbatim inside `context`,
|
|
194
227
|
so carrying them again in `sources` duplicates a large share of the document bytes (measured up to
|
|
195
228
|
~44% on real worktrees, scaling with how much of `context` those bodies occupy) for a model
|
|
196
229
|
consumer without adding information. Nothing is lost: each omitted body remains inside `context`,
|
|
197
230
|
`content_sha256` still fingerprints that rendered body, and `path` plus `canonical_root` locate the
|
|
198
231
|
underlying file for a bounded manual re-read (whose raw bytes may differ from the rendered body as
|
|
199
|
-
defined above). `schema_version` is an exact document selector, not an upgrade marker: version
|
|
200
|
-
a field-set projection of version
|
|
201
|
-
`>=`-superset reasoning.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
232
|
+
defined above). `schema_version` is an exact document selector, not an upgrade marker: version 4 is
|
|
233
|
+
a field-set projection of version 3, so consumers must branch on the value and must not apply
|
|
234
|
+
`>=`-superset reasoning. Introducing this compact format does
|
|
235
|
+
not otherwise change default Markdown bytes, exit codes or determinism.
|
|
236
|
+
|
|
237
|
+
One historical exception is acknowledged: releases 1.0.0 and 1.1.0 both emitted `schema_version` 1
|
|
238
|
+
although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward each emitted number
|
|
239
|
+
names exactly one key set, and the `nested_context` renumber below enforces that rule going forward.
|
|
240
|
+
|
|
241
|
+
The JSON schema version and package version are independent: this build emits `schema_version` `3`
|
|
242
|
+
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.1`. Version 1
|
|
243
|
+
and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
|
|
244
|
+
`nested_context` changes the default document's key set, version 3 names the current full-document
|
|
245
|
+
key set and version 4 names the compact projection.
|
|
206
246
|
Callers must depend only on fields declared above.
|
|
207
247
|
The document contains no generated time or random identifier, so unchanged input produces identical
|
|
208
248
|
JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
|
|
@@ -217,7 +257,9 @@ Resolving an instruction hierarchy stays with the calling agent harness. That in
|
|
|
217
257
|
shared or user-level instruction file outside the repository, nested or scoped `AGENTS.md` files
|
|
218
258
|
under subdirectories, and any include or import directive written inside an instruction file:
|
|
219
259
|
such a directive is transported as literal text and is never followed. `--path` selects sections
|
|
220
|
-
of the repository-root `AGENTS.md` only; it never changes which files are read.
|
|
260
|
+
of the repository-root `AGENTS.md` only; it never changes which files are read. The JSON-only
|
|
261
|
+
`nested_context` field reports the existence of nested `AGENTS.md` paths without reading them;
|
|
262
|
+
deciding whether any of them applies stays with the calling agent harness exactly as before.
|
|
221
263
|
|
|
222
264
|
A successful run therefore proves that the bounded root context was collected and rendered. It
|
|
223
265
|
does not prove that every instruction applicable to a task has been loaded.
|
|
@@ -250,7 +292,23 @@ scan that ends before EOF still reports the characters it could not select.
|
|
|
250
292
|
|
|
251
293
|
## Limits
|
|
252
294
|
|
|
253
|
-
|
|
295
|
+
Final output bounds differ per format. The two stdout bounds below are enforced on the bytes written
|
|
296
|
+
to stdout and the nested report cap on the bytes the document emits for that array; every other
|
|
297
|
+
bullet is a per-component, read or capture bound, as each one states:
|
|
298
|
+
|
|
299
|
+
- Markdown stdout: 98,304 bytes. A section that cannot fit is replaced by an omission notice, and so
|
|
300
|
+
is every later section, so the limit holds without cutting a section mid-body.
|
|
301
|
+
- JSON and JSON-compact stdout: 8,388,608 bytes (8 MiB) for the serialized document, including its
|
|
302
|
+
trailing newline. This bound is fail-closed: if a repository's evidence cannot fit, the tool exits
|
|
303
|
+
1, writes nothing to stdout and prints `error: JSON output exceeded the 8388608 byte document
|
|
304
|
+
limit`. It never emits a truncated document. The component budgets below keep legitimate
|
|
305
|
+
documents far inside it — the largest escape-dense repository audited for this release emitted
|
|
306
|
+
263 KiB, of which 248 KiB was escaped `statuses` subjects — so it is a final backstop rather
|
|
307
|
+
than an expected truncation point.
|
|
308
|
+
- Component budgets below are metered on the Markdown `context` content, and that same string is the
|
|
309
|
+
JSON `context` value; because JSON escaping expands quotes, backslashes and control characters,
|
|
310
|
+
the serialized bytes can be several times larger. The JSON document also repeats the source
|
|
311
|
+
bodies, so no single component figure bounds JSON stdout.
|
|
254
312
|
- `AGENTS.md`: 256-KiB bounded source scan; selected source plus selection audit remains at most
|
|
255
313
|
16 KiB, including a 4-KiB maximum small head
|
|
256
314
|
- `README.md`: 16 KiB
|
|
@@ -258,8 +316,14 @@ scan that ends before EOF still reports the characters it could not select.
|
|
|
258
316
|
- All entry-file bodies: 24 KiB
|
|
259
317
|
- Declared commands: 8 KiB
|
|
260
318
|
- Directory tree: 12 KiB, 300 entries, and depth 2
|
|
319
|
+
- Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths and
|
|
320
|
+
a 4 KiB budget metered on the emitted array; file contents are never read
|
|
261
321
|
- Working-tree changes: 100 paths and 4 KiB
|
|
262
322
|
- Recent commits: 8
|
|
323
|
+
- JSON-only listings have no byte budget of their own: `statuses` subjects are escaped names bounded
|
|
324
|
+
by the entry counts above, while `repository` and `sources[*].path` are emitted verbatim, so a
|
|
325
|
+
repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
|
|
326
|
+
still renders
|
|
263
327
|
- Git subprocess output: 16 MiB (bounded while reading)
|
|
264
328
|
- Candidate file validation: 16 MiB hard read bound
|
|
265
329
|
|
|
@@ -296,7 +360,8 @@ or candidate-file content.
|
|
|
296
360
|
|
|
297
361
|
## Not Included
|
|
298
362
|
|
|
299
|
-
Version 1.
|
|
363
|
+
Version 1.3.1 does not provide AI summaries, project-type detection, loading or transport of
|
|
364
|
+
nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
|
|
300
365
|
Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
|
|
301
366
|
network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
|
|
302
367
|
|
|
@@ -310,8 +375,9 @@ just check
|
|
|
310
375
|
|
|
311
376
|
## Version maintenance
|
|
312
377
|
|
|
313
|
-
The versions in `pyproject.toml` and `context_loader/__init__.py`, the
|
|
314
|
-
section, required tests, and the README
|
|
378
|
+
The versions in `pyproject.toml` and `context_loader/__init__.py`, the `tool_version` in
|
|
379
|
+
`tool_cli_contract.json`, the matching `CHANGELOG.md` section, required tests, and the README
|
|
380
|
+
current-stable declaration plus `context-loader==X.Y.Z`
|
|
315
381
|
install pins must change in the same release-preparation batch. `CHANGELOG.md` is the
|
|
316
382
|
authoritative version-change record. `scripts/release.py` rejects README stable/install
|
|
317
383
|
pins that disagree with the intended package version; historical changelog entries are
|
|
@@ -20,14 +20,22 @@ from .collect import (
|
|
|
20
20
|
AgentsSelectionAudit,
|
|
21
21
|
AgentsSelectionInputError,
|
|
22
22
|
CollectedFile,
|
|
23
|
+
NestedContextPresence,
|
|
23
24
|
ProjectContext,
|
|
24
25
|
collect_project_context,
|
|
26
|
+
display_text,
|
|
25
27
|
)
|
|
26
28
|
from .git import ContextLoaderError, collect_repository
|
|
27
29
|
from .render import render_markdown_with_details, rendered_source_contents
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
# Each value names one exact document shape and is never reused: 1 and 2 are the
|
|
32
|
+
# shapes published in release 1.2.0, so adding the nested_context object to both
|
|
33
|
+
# formats moved the default document to 3 and the compact projection to 4.
|
|
34
|
+
JSON_SCHEMA_VERSION = 3
|
|
35
|
+
COMPACT_JSON_SCHEMA_VERSION = 4
|
|
36
|
+
# Both machine formats share this final bound on the serialized document: output is
|
|
37
|
+
# either valid JSON within it or a fail-closed error, never a truncated document.
|
|
38
|
+
JSON_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024
|
|
31
39
|
TOOL_NAME = "context-loader"
|
|
32
40
|
|
|
33
41
|
_STATUS_CODE_BY_MESSAGE = {
|
|
@@ -81,6 +89,7 @@ class ProjectContextResult:
|
|
|
81
89
|
context_sha256: str
|
|
82
90
|
warnings: tuple[str, ...]
|
|
83
91
|
statuses: tuple[ContextStatus, ...]
|
|
92
|
+
nested_context: NestedContextPresence
|
|
84
93
|
|
|
85
94
|
|
|
86
95
|
def _text_sha256(content: str) -> str:
|
|
@@ -185,11 +194,16 @@ def _build_statuses(
|
|
|
185
194
|
for entry in project.directory_tree.entries:
|
|
186
195
|
if entry.kind == "unreadable_directory":
|
|
187
196
|
subject = entry.path if entry.path else "."
|
|
188
|
-
statuses.append(_status("unreadable", "tree_entry", subject))
|
|
197
|
+
statuses.append(_status("unreadable", "tree_entry", display_text(subject)))
|
|
189
198
|
|
|
190
199
|
for title in omitted_sections:
|
|
191
200
|
statuses.append(_status("section_omitted", "section", title))
|
|
192
201
|
|
|
202
|
+
if project.nested_context.list_truncated:
|
|
203
|
+
statuses.append(_status("nested_agents_list_truncated", "nested_context", "AGENTS.md"))
|
|
204
|
+
if project.nested_context.scan_truncated:
|
|
205
|
+
statuses.append(_status("nested_agents_scan_truncated", "nested_context", "AGENTS.md"))
|
|
206
|
+
|
|
193
207
|
deduped: list[ContextStatus] = []
|
|
194
208
|
seen: set[tuple[str, str, str]] = set()
|
|
195
209
|
for status in statuses:
|
|
@@ -239,6 +253,7 @@ def load_project_context(
|
|
|
239
253
|
context_sha256=hashlib.sha256(rendered.output).hexdigest(),
|
|
240
254
|
warnings=(),
|
|
241
255
|
statuses=statuses,
|
|
256
|
+
nested_context=project.nested_context,
|
|
242
257
|
)
|
|
243
258
|
|
|
244
259
|
|
|
@@ -294,6 +309,9 @@ def render_json(result: ProjectContextResult, *, compact: bool = False) -> bytes
|
|
|
294
309
|
``compact`` projects away the duplicated source bodies only at this
|
|
295
310
|
boundary: statuses and context were computed from the full model at load
|
|
296
311
|
time, so both documents agree on everything except ``sources[*].content``.
|
|
312
|
+
|
|
313
|
+
The bound is on the final serialized document: a repository whose evidence
|
|
314
|
+
cannot fit fails closed with no output rather than emitting a partial file.
|
|
297
315
|
"""
|
|
298
316
|
document = {
|
|
299
317
|
"schema_version": (COMPACT_JSON_SCHEMA_VERSION if compact else result.schema_version),
|
|
@@ -309,6 +327,13 @@ def render_json(result: ProjectContextResult, *, compact: bool = False) -> bytes
|
|
|
309
327
|
_source_document(source, include_content=not compact) for source in result.sources
|
|
310
328
|
],
|
|
311
329
|
"statuses": [_status_document(status) for status in result.statuses],
|
|
330
|
+
"nested_context": {
|
|
331
|
+
# Filesystem names arrive undecoded (surrogate-escaped); sanitize exactly like
|
|
332
|
+
# tree/change paths so no raw byte can reach the UTF-8 document.
|
|
333
|
+
"files": [display_text(path) for path in result.nested_context.files],
|
|
334
|
+
"list_truncated": result.nested_context.list_truncated,
|
|
335
|
+
"scan_truncated": result.nested_context.scan_truncated,
|
|
336
|
+
},
|
|
312
337
|
"context": result.context,
|
|
313
338
|
"context_sha256": result.context_sha256,
|
|
314
339
|
"warnings": list(result.warnings),
|
|
@@ -319,4 +344,9 @@ def render_json(result: ProjectContextResult, *, compact: bool = False) -> bytes
|
|
|
319
344
|
sort_keys=True,
|
|
320
345
|
separators=(",", ":"),
|
|
321
346
|
)
|
|
322
|
-
|
|
347
|
+
encoded = f"{serialized}\n".encode()
|
|
348
|
+
if len(encoded) > JSON_OUTPUT_LIMIT_BYTES:
|
|
349
|
+
raise ContextLoaderError(
|
|
350
|
+
f"JSON output exceeded the {JSON_OUTPUT_LIMIT_BYTES} byte document limit"
|
|
351
|
+
)
|
|
352
|
+
return encoded
|
|
@@ -39,8 +39,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
39
39
|
choices=("markdown", "json", "json-compact"),
|
|
40
40
|
default="markdown",
|
|
41
41
|
help=(
|
|
42
|
-
"output format (default: markdown); json-compact
|
|
43
|
-
"
|
|
42
|
+
"output format (default: markdown); json-compact omits the "
|
|
43
|
+
"duplicated source bodies (see README, Compact model consumption)"
|
|
44
44
|
),
|
|
45
45
|
)
|
|
46
46
|
return parser
|
|
@@ -26,6 +26,13 @@ DECLARED_COMMANDS_LIMIT_BYTES = 8 * 1024
|
|
|
26
26
|
DIRECTORY_TREE_LIMIT_BYTES = 12 * 1024
|
|
27
27
|
DIRECTORY_TREE_MAX_ITEMS = 300
|
|
28
28
|
DIRECTORY_TREE_MAX_DEPTH = 2
|
|
29
|
+
NESTED_AGENTS_MAX_DEPTH = 4
|
|
30
|
+
NESTED_AGENTS_MAX_DIRECTORIES = 2_000
|
|
31
|
+
NESTED_AGENTS_MAX_FILES = 32
|
|
32
|
+
NESTED_AGENTS_MAX_LIST_BYTES = 4 * 1024
|
|
33
|
+
NESTED_AGENTS_EXCLUDED_DIRECTORIES = frozenset(
|
|
34
|
+
{".git", ".venv", "node_modules", "site-packages", "venv"}
|
|
35
|
+
)
|
|
29
36
|
FILE_SCAN_LIMIT_BYTES = 16 * 1024 * 1024
|
|
30
37
|
MAX_FILE_SCAN_BYTES = FILE_SCAN_LIMIT_BYTES
|
|
31
38
|
TRUNCATION_MARKER = "… truncated by context-loader …"
|
|
@@ -190,6 +197,15 @@ class DirectoryTree:
|
|
|
190
197
|
truncated: bool = False
|
|
191
198
|
|
|
192
199
|
|
|
200
|
+
@dataclass(frozen=True, slots=True)
|
|
201
|
+
class NestedContextPresence:
|
|
202
|
+
"""Existence-only index of nested AGENTS.md files; their contents are never read."""
|
|
203
|
+
|
|
204
|
+
files: tuple[str, ...]
|
|
205
|
+
list_truncated: bool = False
|
|
206
|
+
scan_truncated: bool = False
|
|
207
|
+
|
|
208
|
+
|
|
193
209
|
@dataclass(frozen=True, slots=True)
|
|
194
210
|
class ProjectContext:
|
|
195
211
|
instructions: CollectedFile
|
|
@@ -197,6 +213,7 @@ class ProjectContext:
|
|
|
197
213
|
entry_files: tuple[CollectedFile, ...]
|
|
198
214
|
commands: tuple[DeclaredCommand, ...]
|
|
199
215
|
directory_tree: DirectoryTree
|
|
216
|
+
nested_context: NestedContextPresence
|
|
200
217
|
|
|
201
218
|
|
|
202
219
|
@dataclass(frozen=True, slots=True)
|
|
@@ -975,6 +992,117 @@ def _collect_directory_tree(root: Path) -> DirectoryTree:
|
|
|
975
992
|
return DirectoryTree(tuple(collected), truncated)
|
|
976
993
|
|
|
977
994
|
|
|
995
|
+
def display_text(value: str) -> str:
|
|
996
|
+
r"""Render one filesystem or Git string as printable, encodable ASCII-safe text.
|
|
997
|
+
|
|
998
|
+
Backticks, lone surrogates produced by ``surrogateescape`` decoding, and every
|
|
999
|
+
other non-printable codepoint become ``\xNN``/``\uNNNN``/``\UNNNNNNNN`` escapes so
|
|
1000
|
+
the character can cross the JSON and Markdown serialization boundaries intact.
|
|
1001
|
+
"""
|
|
1002
|
+
rendered: list[str] = []
|
|
1003
|
+
for character in value:
|
|
1004
|
+
codepoint = ord(character)
|
|
1005
|
+
if character == "`":
|
|
1006
|
+
rendered.append(r"\x60")
|
|
1007
|
+
elif 0xDC80 <= codepoint <= 0xDCFF:
|
|
1008
|
+
rendered.append(f"\\x{codepoint - 0xDC00:02x}")
|
|
1009
|
+
elif character.isprintable():
|
|
1010
|
+
rendered.append(character)
|
|
1011
|
+
elif codepoint <= 0xFF:
|
|
1012
|
+
rendered.append(f"\\x{codepoint:02x}")
|
|
1013
|
+
elif codepoint <= 0xFFFF:
|
|
1014
|
+
rendered.append(f"\\u{codepoint:04x}")
|
|
1015
|
+
else:
|
|
1016
|
+
rendered.append(f"\\U{codepoint:08x}")
|
|
1017
|
+
return "".join(rendered)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def serialized_display_length(value: str) -> int:
|
|
1021
|
+
"""Return the UTF-8 byte size of ``value`` once escaped and JSON-serialized.
|
|
1022
|
+
|
|
1023
|
+
This is the exact bytes one ``nested_context`` entry costs, quotes included, so the
|
|
1024
|
+
report budget is metered on emitted output rather than on raw path bytes.
|
|
1025
|
+
"""
|
|
1026
|
+
return len(json.dumps(display_text(value), ensure_ascii=False).encode("utf-8"))
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def collect_nested_agents_presence(repository: Path) -> NestedContextPresence:
|
|
1030
|
+
"""List nested AGENTS.md paths under a bounded, contents-blind directory scan.
|
|
1031
|
+
|
|
1032
|
+
The scan reads directory entries only: it never opens a candidate file, never
|
|
1033
|
+
traverses a symlink, and reports truncation honestly so presence claims stay
|
|
1034
|
+
auditable. Any non-directory entry named AGENTS.md is listed, including
|
|
1035
|
+
symlinked ones, because existence comes from enumeration alone; entry types
|
|
1036
|
+
are never resolved beyond directory-vs-not, and contents remain the caller's
|
|
1037
|
+
responsibility to read.
|
|
1038
|
+
"""
|
|
1039
|
+
files: list[str] = []
|
|
1040
|
+
reported_bytes = 0
|
|
1041
|
+
state = {"list_truncated": False, "scan_truncated": False, "directories": 0}
|
|
1042
|
+
|
|
1043
|
+
def walk(directory_descriptor: int, prefix: str, depth: int) -> None:
|
|
1044
|
+
nonlocal reported_bytes
|
|
1045
|
+
if depth > NESTED_AGENTS_MAX_DEPTH or state["directories"] >= NESTED_AGENTS_MAX_DIRECTORIES:
|
|
1046
|
+
state["scan_truncated"] = True
|
|
1047
|
+
return
|
|
1048
|
+
state["directories"] += 1
|
|
1049
|
+
try:
|
|
1050
|
+
with os.scandir(directory_descriptor) as iterator:
|
|
1051
|
+
entries = sorted(iterator, key=lambda entry: entry.name)
|
|
1052
|
+
except OSError:
|
|
1053
|
+
state["scan_truncated"] = True
|
|
1054
|
+
return
|
|
1055
|
+
subdirectories: list[str] = []
|
|
1056
|
+
for entry in entries:
|
|
1057
|
+
try:
|
|
1058
|
+
is_directory = entry.is_dir(follow_symlinks=False)
|
|
1059
|
+
except OSError:
|
|
1060
|
+
state["scan_truncated"] = True
|
|
1061
|
+
continue
|
|
1062
|
+
path = f"{prefix}/{entry.name}" if prefix else entry.name
|
|
1063
|
+
if is_directory:
|
|
1064
|
+
if entry.name not in NESTED_AGENTS_EXCLUDED_DIRECTORIES:
|
|
1065
|
+
subdirectories.append(path)
|
|
1066
|
+
continue
|
|
1067
|
+
if entry.name != "AGENTS.md" or not prefix:
|
|
1068
|
+
continue
|
|
1069
|
+
if len(files) >= NESTED_AGENTS_MAX_FILES:
|
|
1070
|
+
state["list_truncated"] = True
|
|
1071
|
+
continue
|
|
1072
|
+
# Charge the array's two bracket bytes to the first entry, so the budget
|
|
1073
|
+
# covers everything this list makes the document emit.
|
|
1074
|
+
name_bytes = serialized_display_length(path) + (1 if files else 2)
|
|
1075
|
+
if reported_bytes + name_bytes > NESTED_AGENTS_MAX_LIST_BYTES:
|
|
1076
|
+
state["list_truncated"] = True
|
|
1077
|
+
continue
|
|
1078
|
+
reported_bytes += name_bytes
|
|
1079
|
+
files.append(path)
|
|
1080
|
+
for path in subdirectories:
|
|
1081
|
+
flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
|
|
1082
|
+
try:
|
|
1083
|
+
child_descriptor = os.open(
|
|
1084
|
+
path.rsplit("/", 1)[-1], flags, dir_fd=directory_descriptor
|
|
1085
|
+
)
|
|
1086
|
+
except OSError:
|
|
1087
|
+
state["scan_truncated"] = True
|
|
1088
|
+
continue
|
|
1089
|
+
try:
|
|
1090
|
+
walk(child_descriptor, path, depth + 1)
|
|
1091
|
+
finally:
|
|
1092
|
+
os.close(child_descriptor)
|
|
1093
|
+
|
|
1094
|
+
flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
|
|
1095
|
+
try:
|
|
1096
|
+
root_descriptor = os.open(repository, flags)
|
|
1097
|
+
except OSError:
|
|
1098
|
+
return NestedContextPresence((), False, True)
|
|
1099
|
+
try:
|
|
1100
|
+
walk(root_descriptor, "", 0)
|
|
1101
|
+
finally:
|
|
1102
|
+
os.close(root_descriptor)
|
|
1103
|
+
return NestedContextPresence(tuple(files), state["list_truncated"], state["scan_truncated"])
|
|
1104
|
+
|
|
1105
|
+
|
|
978
1106
|
def collect_project_context(
|
|
979
1107
|
repository: Path,
|
|
980
1108
|
*,
|
|
@@ -1001,4 +1129,5 @@ def collect_project_context(
|
|
|
1001
1129
|
entry_files=entry_files,
|
|
1002
1130
|
commands=_collect_commands(entry_files),
|
|
1003
1131
|
directory_tree=_collect_directory_tree(repository),
|
|
1132
|
+
nested_context=collect_nested_agents_presence(repository),
|
|
1004
1133
|
)
|
|
@@ -19,6 +19,7 @@ from .collect import (
|
|
|
19
19
|
DirectoryTree,
|
|
20
20
|
ProjectContext,
|
|
21
21
|
TreeEntry,
|
|
22
|
+
display_text,
|
|
22
23
|
render_agents_selection_audit,
|
|
23
24
|
)
|
|
24
25
|
from .git import RecentCommit, RepositoryState
|
|
@@ -39,30 +40,11 @@ class MarkdownRender:
|
|
|
39
40
|
commands_truncated: bool
|
|
40
41
|
|
|
41
42
|
|
|
42
|
-
def _display(value: str) -> str:
|
|
43
|
-
rendered: list[str] = []
|
|
44
|
-
for character in value:
|
|
45
|
-
codepoint = ord(character)
|
|
46
|
-
if character == "`":
|
|
47
|
-
rendered.append(r"\x60")
|
|
48
|
-
elif 0xDC80 <= codepoint <= 0xDCFF:
|
|
49
|
-
rendered.append(f"\\x{codepoint - 0xDC00:02x}")
|
|
50
|
-
elif character.isprintable():
|
|
51
|
-
rendered.append(character)
|
|
52
|
-
elif codepoint <= 0xFF:
|
|
53
|
-
rendered.append(f"\\x{codepoint:02x}")
|
|
54
|
-
elif codepoint <= 0xFFFF:
|
|
55
|
-
rendered.append(f"\\u{codepoint:04x}")
|
|
56
|
-
else:
|
|
57
|
-
rendered.append(f"\\U{codepoint:08x}")
|
|
58
|
-
return "".join(rendered)
|
|
59
|
-
|
|
60
|
-
|
|
61
43
|
def _display_limited(value: str, limit: int) -> str | None:
|
|
62
44
|
rendered: list[str] = []
|
|
63
45
|
used = 0
|
|
64
46
|
for character in value:
|
|
65
|
-
piece =
|
|
47
|
+
piece = display_text(character)
|
|
66
48
|
size = len(piece.encode())
|
|
67
49
|
if used + size > limit:
|
|
68
50
|
return None
|
|
@@ -121,7 +103,7 @@ def _change_lines(state: RepositoryState) -> tuple[list[str], bool]:
|
|
|
121
103
|
for change in state.changes:
|
|
122
104
|
if len(lines) == MAX_CHANGE_ENTRIES:
|
|
123
105
|
return lines, True
|
|
124
|
-
line = f"- `{change.status} {
|
|
106
|
+
line = f"- `{change.status} {display_text(change.path)}`"
|
|
125
107
|
line_bytes = len(f"{line}\n".encode())
|
|
126
108
|
if used_bytes + line_bytes > MAX_CHANGE_MARKDOWN_BYTES:
|
|
127
109
|
return lines, True
|
|
@@ -134,10 +116,10 @@ def _render_git_state(state: RepositoryState) -> str:
|
|
|
134
116
|
lines = [
|
|
135
117
|
"## Git State",
|
|
136
118
|
"",
|
|
137
|
-
f"- Branch: `{
|
|
138
|
-
f"- HEAD: `{
|
|
139
|
-
f"- Upstream: `{
|
|
140
|
-
f"- Ahead / behind: `{
|
|
119
|
+
f"- Branch: `{display_text(state.branch)}`",
|
|
120
|
+
f"- HEAD: `{display_text(state.head)}`",
|
|
121
|
+
f"- Upstream: `{display_text(state.upstream)}`",
|
|
122
|
+
f"- Ahead / behind: `{display_text(state.ahead_behind)}`",
|
|
141
123
|
f"- Worktree: `{state.worktree}`",
|
|
142
124
|
"",
|
|
143
125
|
"### Working Tree Changes",
|
|
@@ -170,10 +152,10 @@ def _command_line(command: DeclaredCommand) -> str:
|
|
|
170
152
|
if command.parse_error:
|
|
171
153
|
return f"- `{command.source}`: unable to parse command declarations."
|
|
172
154
|
assert command.invocation is not None
|
|
173
|
-
invocation =
|
|
155
|
+
invocation = display_text(command.invocation)
|
|
174
156
|
if command.target is None:
|
|
175
157
|
return f"- `{invocation}`"
|
|
176
|
-
return f"- `{invocation}` → `{
|
|
158
|
+
return f"- `{invocation}` → `{display_text(command.target)}`"
|
|
177
159
|
|
|
178
160
|
|
|
179
161
|
def _bounded_lines(
|
|
@@ -262,7 +244,7 @@ def _render_recent_commits(commits: tuple[RecentCommit, ...]) -> str | None:
|
|
|
262
244
|
|
|
263
245
|
|
|
264
246
|
def _tree_line(entry: TreeEntry) -> str:
|
|
265
|
-
path =
|
|
247
|
+
path = display_text(entry.path)
|
|
266
248
|
if entry.kind == "directory":
|
|
267
249
|
return f"{path}/"
|
|
268
250
|
if entry.kind == "symlink":
|
|
@@ -295,7 +277,7 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
295
277
|
"# Project Context",
|
|
296
278
|
"",
|
|
297
279
|
f"- Schema: `{SCHEMA}`",
|
|
298
|
-
f"- Repository: `{
|
|
280
|
+
f"- Repository: `{display_text(os.fspath(state.repository))}`",
|
|
299
281
|
)
|
|
300
282
|
)
|
|
301
283
|
commands_section, commands_truncated = _render_commands(project.commands)
|
|
File without changes
|
|
File without changes
|