context-loader 1.3.2__tar.gz → 1.3.3__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.3.2 → context_loader-1.3.3}/PKG-INFO +69 -19
- {context_loader-1.3.2 → context_loader-1.3.3}/README.md +68 -18
- {context_loader-1.3.2 → context_loader-1.3.3}/context_loader/__init__.py +1 -1
- {context_loader-1.3.2 → context_loader-1.3.3}/context_loader/application.py +16 -7
- {context_loader-1.3.2 → context_loader-1.3.3}/context_loader/collect.py +171 -34
- {context_loader-1.3.2 → context_loader-1.3.3}/context_loader/filesystem.py +23 -11
- {context_loader-1.3.2 → context_loader-1.3.3}/context_loader/git.py +62 -0
- {context_loader-1.3.2 → context_loader-1.3.3}/context_loader/model.py +4 -4
- {context_loader-1.3.2 → context_loader-1.3.3}/context_loader/render.py +82 -45
- {context_loader-1.3.2 → context_loader-1.3.3}/pyproject.toml +1 -1
- {context_loader-1.3.2 → context_loader-1.3.3}/pyproject.toml.orig +1 -1
- {context_loader-1.3.2 → context_loader-1.3.3}/tool_cli_contract.json +1 -1
- {context_loader-1.3.2 → context_loader-1.3.3}/LICENSE +0 -0
- {context_loader-1.3.2 → context_loader-1.3.3}/context_loader/cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: context-loader
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.3
|
|
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.3.
|
|
222
|
+
Current stable release: **1.3.3**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
|
|
223
223
|
|
|
224
224
|
```bash
|
|
225
|
-
uv tool install 'context-loader==1.3.
|
|
225
|
+
uv tool install 'context-loader==1.3.3'
|
|
226
226
|
```
|
|
227
227
|
|
|
228
228
|
```bash
|
|
229
|
-
pip install 'context-loader==1.3.
|
|
229
|
+
pip install 'context-loader==1.3.3'
|
|
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.3.
|
|
242
|
+
Source version: `1.3.3`. 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.3.
|
|
247
|
+
uv tool install 'context-loader==1.3.3'
|
|
248
248
|
```
|
|
249
249
|
|
|
250
250
|
Install via `pip`:
|
|
251
251
|
|
|
252
252
|
```bash
|
|
253
|
-
pip install 'context-loader==1.3.
|
|
253
|
+
pip install 'context-loader==1.3.3'
|
|
254
254
|
```
|
|
255
255
|
|
|
256
|
-
For development or source-based installs tracking current repository (`1.3.
|
|
256
|
+
For development or source-based installs tracking current repository (`1.3.3`):
|
|
257
257
|
|
|
258
258
|
```bash
|
|
259
259
|
uv tool install git+https://github.com/xuanheng-tech/context-loader.git
|
|
@@ -350,7 +350,7 @@ set differs, as described under “Compact model consumption”. The declared co
|
|
|
350
350
|
"schema_version": 3,
|
|
351
351
|
"tool": {
|
|
352
352
|
"name": "context-loader",
|
|
353
|
-
"version": "1.3.
|
|
353
|
+
"version": "1.3.3"
|
|
354
354
|
},
|
|
355
355
|
"repository": {
|
|
356
356
|
"requested_path": "/canonical/requested/path",
|
|
@@ -397,7 +397,14 @@ context, in assembly order, after the existing newline normalization and truncat
|
|
|
397
397
|
distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
|
|
398
398
|
only `repository` sources and does not add any global-file discovery.
|
|
399
399
|
|
|
400
|
-
`statuses` lists
|
|
400
|
+
`statuses` lists skipped or absent sources, truncated sources, unreadable tree entries,
|
|
401
|
+
directories whose listing exceeds its retention cap, globally omitted sections, and truncated
|
|
402
|
+
working-tree, command, tree or nested-`AGENTS.md` listings. A skipped or absent source reports
|
|
403
|
+
the observed condition (`not_present`, `skipped_symlink`, `skipped_not_regular`,
|
|
404
|
+
`skipped_encoding`, `skipped_unreadable`); its sentence in `context` comes from that same
|
|
405
|
+
condition. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two
|
|
406
|
+
`nested_agents_*` codes mirror the `nested_context` truncation booleans; the complete object
|
|
407
|
+
remains a separate field. Callers can ignore `statuses` safely.
|
|
401
408
|
|
|
402
409
|
The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
|
|
403
410
|
contain heading, heading level, and fixed selection reasons; it never contains the original focus or
|
|
@@ -417,7 +424,7 @@ separator, with the array's brackets charged to the first entry — so the emitt
|
|
|
417
424
|
more than the budget it declares. Because quoting is counted, an array already near the cap can
|
|
418
425
|
report one path fewer than an older release did, and then says so through `list_truncated`.
|
|
419
426
|
`scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, a directory
|
|
420
|
-
held more entries than one
|
|
427
|
+
held more entries than one listing retains (1,024), or a directory or entry could not be
|
|
421
428
|
read, so absence of a path is not proof of absence of the file. Nested paths
|
|
422
429
|
are sanitized with the same escaping the Markdown uses for repository-derived text, so a nested name
|
|
423
430
|
the filesystem could not decode never breaks the document;
|
|
@@ -426,6 +433,16 @@ see “Limits” for the root paths that are emitted verbatim. The corresponding
|
|
|
426
433
|
Presence is not instruction: whether a nested file applies, and its text, remain the caller's
|
|
427
434
|
judgment; Markdown output is unchanged.
|
|
428
435
|
|
|
436
|
+
Escaped names remain reversible and distinct: a literal backslash becomes `\\`, a backtick
|
|
437
|
+
becomes `\x60`, ASCII controls and undecodable filesystem bytes become `\xNN`, and other
|
|
438
|
+
non-printable Unicode characters become `\uNNNN` or `\UNNNNNNNN`. Printable Unicode stays
|
|
439
|
+
unchanged. After JSON decoding, recover POSIX path bytes by reading escapes from left to right:
|
|
440
|
+
`\\` yields one backslash byte, `\xNN` yields that byte, and Unicode escapes and ordinary
|
|
441
|
+
characters yield their UTF-8 bytes. Thus a backtick, a literal `\x60`, an undecodable byte
|
|
442
|
+
`0x80`, and Unicode `U+0080` all have different representations. This applies to nested paths
|
|
443
|
+
and displayed tree/change/status names; the verbatim root/source paths retain their existing
|
|
444
|
+
semantics.
|
|
445
|
+
|
|
429
446
|
### Compact model consumption (`--format json-compact`)
|
|
430
447
|
|
|
431
448
|
`--format json-compact` emits a `schema_version` 4 document: exactly the version-3 document with
|
|
@@ -453,7 +470,7 @@ although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward eac
|
|
|
453
470
|
names exactly one key set, and a new number is issued whenever a key set would otherwise be reused.
|
|
454
471
|
|
|
455
472
|
The JSON schema version and package version are independent: this build emits `schema_version` `3`
|
|
456
|
-
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.
|
|
473
|
+
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.3`. Version 1
|
|
457
474
|
and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
|
|
458
475
|
`nested_context` changes the default document's key set, version 3 names the current full-document
|
|
459
476
|
key set and version 4 names the compact projection.
|
|
@@ -527,10 +544,11 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
527
544
|
16 KiB, including a 4-KiB maximum small head
|
|
528
545
|
- `README.md`: 16 KiB
|
|
529
546
|
- Each entry file: 8 KiB
|
|
530
|
-
- All entry-file bodies: 24 KiB
|
|
547
|
+
- All entry-file bodies: 24 KiB, including truncation markers. Room is reserved for markers
|
|
548
|
+
on later text sources, so exhausting the budget never hides that their bodies were cut.
|
|
531
549
|
- Declared commands: 8 KiB
|
|
532
550
|
- Directory tree: 12 KiB of listing body, 300 entries, and depth 2. Each directory is
|
|
533
|
-
|
|
551
|
+
retains and classifies up to 512 entries, keeping the alphabetically first names so the listing never
|
|
534
552
|
depends on operating system order. A directory that holds more is reported by a single
|
|
535
553
|
`Listing incomplete:` line stating the total, the limit and up to three names, and by at most
|
|
536
554
|
eight `directory_listing_incomplete` status entries naming individual directories plus one
|
|
@@ -541,8 +559,10 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
541
559
|
alphabetical prefix and the entry budget kept it, so capped siblings displaced by the budget are
|
|
542
560
|
disclosed as their parent's truncation rather than as separate numbers.
|
|
543
561
|
Three limits act in sequence and a name survives only if all three leave room. The 512-entry
|
|
544
|
-
|
|
545
|
-
prefix is
|
|
562
|
+
retention limit decides which names a directory offers at all; above it only an alphabetical
|
|
563
|
+
prefix is classified. All names must still be scanned to find that prefix, subject to the
|
|
564
|
+
separate hard name-scan bound below; the retention cap bounds memory and classification work.
|
|
565
|
+
The 300-entry item budget is then shared between a directory's
|
|
546
566
|
subdirectories and its non-directories: each may take up to half of what remains before the other
|
|
547
567
|
claims the surplus, which stops alphabetically-earlier directories from evicting every file, but
|
|
548
568
|
it is a share rather than a floor -- with a single slot left the non-directories take it. The 12
|
|
@@ -561,8 +581,14 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
561
581
|
fence say so. The heading and the code-fence lines are not metered, so the whole Markdown
|
|
562
582
|
section can exceed 12 KiB by that fixed few dozen bytes.
|
|
563
583
|
- Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths, a
|
|
564
|
-
4 KiB budget metered on the emitted array, and at most 1,024 entries
|
|
565
|
-
|
|
584
|
+
4 KiB budget metered on the emitted array, and at most 1,024 entries retained and classified
|
|
585
|
+
per directory. Names are scanned to select that prefix, subject to the separate hard
|
|
586
|
+
name-scan bound below; file contents are never read
|
|
587
|
+
- Directory name scans stop on the first entry beyond 65,536 names per directory. An
|
|
588
|
+
over-limit directory's entire partial prefix is discarded to keep results independent of
|
|
589
|
+
operating system order. The tree emits `Skipped: directory name scan limit reached.` and
|
|
590
|
+
a `directory_scan_limit_exceeded` status; the nested presence scan sets `scan_truncated`.
|
|
591
|
+
This bounds name iteration, not elapsed time on a blocked filesystem call.
|
|
566
592
|
- Working-tree changes: 100 paths and 4 KiB
|
|
567
593
|
- Recent commits: 8
|
|
568
594
|
- JSON-only listings have no byte budget of their own: `statuses` subjects are escaped names bounded
|
|
@@ -570,6 +596,7 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
570
596
|
repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
|
|
571
597
|
still renders
|
|
572
598
|
- Git subprocess output: 16 MiB (bounded while reading)
|
|
599
|
+
- Git status preflight: at most 128 repositories, including initialized nested submodules
|
|
573
600
|
- Candidate file validation: 16 MiB hard read bound
|
|
574
601
|
|
|
575
602
|
Truncation occurs only at complete UTF-8 and line boundaries and is marked explicitly.
|
|
@@ -593,6 +620,13 @@ the remote.
|
|
|
593
620
|
this is normal after cloning an empty remote.
|
|
594
621
|
- `Ahead / behind: not available` means the commit relationship cannot currently be computed.
|
|
595
622
|
Counts are reported only when the configured upstream commit is available locally.
|
|
623
|
+
- Executable Git clean/process filters can run during `status`, even with optional locks
|
|
624
|
+
disabled. Before reading status, the loader checks effective repository configuration,
|
|
625
|
+
including includes, worktree configuration and initialized nested submodules. A non-empty
|
|
626
|
+
clean/process command causes a safe error with no stdout, even if no current attribute uses
|
|
627
|
+
it. Disabling a driver silently would change cleanliness semantics. Filter-dependent repositories
|
|
628
|
+
(including locally configured Git LFS drivers) therefore need ordinary Git inspection outside
|
|
629
|
+
this loader. Git protocols are also disabled to prevent lazy object retrieval.
|
|
596
630
|
|
|
597
631
|
## Exit Codes
|
|
598
632
|
|
|
@@ -605,7 +639,7 @@ or candidate-file content.
|
|
|
605
639
|
|
|
606
640
|
## Not Included
|
|
607
641
|
|
|
608
|
-
Version 1.3.
|
|
642
|
+
Version 1.3.3 does not provide AI summaries, project-type detection, loading or transport of
|
|
609
643
|
nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
|
|
610
644
|
Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
|
|
611
645
|
network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
|
|
@@ -618,6 +652,17 @@ Run the complete local check:
|
|
|
618
652
|
just check
|
|
619
653
|
```
|
|
620
654
|
|
|
655
|
+
The lockfile uses public PyPI artifact URLs; no organization mirror or local Renovate preset
|
|
656
|
+
is needed. Renovate keeps scheduled, rate-limited updates and manual merges using a public
|
|
657
|
+
recommended preset plus the repository's explicit settings. Python 3.12 remains the tested
|
|
658
|
+
runtime; widening that range requires testing those interpreters first.
|
|
659
|
+
|
|
660
|
+
Git test fixtures discard inherited `GIT_*` routing/configuration before creating repositories.
|
|
661
|
+
Permission-sensitive tests must run as a non-root user. Gitea uses `just ci-check`: non-root
|
|
662
|
+
runners execute directly; root containers use `runuser` with an existing account named by
|
|
663
|
+
`CONTEXT_LOADER_CI_USER` (default `ubuntu`). That account must be able to execute the installed
|
|
664
|
+
`uv`, Python and `just`. No `sudo` dependency is required for this CI step.
|
|
665
|
+
|
|
621
666
|
## Version maintenance
|
|
622
667
|
|
|
623
668
|
The versions in `pyproject.toml` and `context_loader/__init__.py`, the `tool_version` in
|
|
@@ -638,6 +683,11 @@ only verifies the public release, mirrors its exact formal tag object if missing
|
|
|
638
683
|
the Gitea Release record. It builds no package and has no PyPI publishing credentials.
|
|
639
684
|
No private endpoint or credential is needed on GitHub.
|
|
640
685
|
|
|
686
|
+
For environments that permit direct public reads but share an exhausted proxy quota,
|
|
687
|
+
set the Gitea Actions repository variable `RELEASE_PUBLIC_API_DIRECT=true`. The record
|
|
688
|
+
step appends only public GitHub API/PyPI hosts to the existing proxy bypass lists. Without
|
|
689
|
+
this explicit option, the workflow uses its inherited proxy settings.
|
|
690
|
+
|
|
641
691
|
Release checks use Python 3.12, Git, and the runner's existing OpenSSL CLI. They compare downloaded
|
|
642
692
|
PyPI file bytes, metadata and SHA-256, plus the publisher/tag/commit claims in PyPI's HTTPS-served
|
|
643
693
|
provenance. This is an identity check, not an independent cryptographic Sigstore verifier.
|
|
@@ -8,14 +8,14 @@ Python standard library.
|
|
|
8
8
|
|
|
9
9
|
## Open-source quick start
|
|
10
10
|
|
|
11
|
-
Current stable release: **1.3.
|
|
11
|
+
Current stable release: **1.3.3**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
uv tool install 'context-loader==1.3.
|
|
14
|
+
uv tool install 'context-loader==1.3.3'
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
pip install 'context-loader==1.3.
|
|
18
|
+
pip install 'context-loader==1.3.3'
|
|
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.3.
|
|
31
|
+
Source version: `1.3.3`. 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.3.
|
|
36
|
+
uv tool install 'context-loader==1.3.3'
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Install via `pip`:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
pip install 'context-loader==1.3.
|
|
42
|
+
pip install 'context-loader==1.3.3'
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
For development or source-based installs tracking current repository (`1.3.
|
|
45
|
+
For development or source-based installs tracking current repository (`1.3.3`):
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
uv tool install git+https://github.com/xuanheng-tech/context-loader.git
|
|
@@ -139,7 +139,7 @@ set differs, as described under “Compact model consumption”. The declared co
|
|
|
139
139
|
"schema_version": 3,
|
|
140
140
|
"tool": {
|
|
141
141
|
"name": "context-loader",
|
|
142
|
-
"version": "1.3.
|
|
142
|
+
"version": "1.3.3"
|
|
143
143
|
},
|
|
144
144
|
"repository": {
|
|
145
145
|
"requested_path": "/canonical/requested/path",
|
|
@@ -186,7 +186,14 @@ context, in assembly order, after the existing newline normalization and truncat
|
|
|
186
186
|
distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
|
|
187
187
|
only `repository` sources and does not add any global-file discovery.
|
|
188
188
|
|
|
189
|
-
`statuses` lists
|
|
189
|
+
`statuses` lists skipped or absent sources, truncated sources, unreadable tree entries,
|
|
190
|
+
directories whose listing exceeds its retention cap, globally omitted sections, and truncated
|
|
191
|
+
working-tree, command, tree or nested-`AGENTS.md` listings. A skipped or absent source reports
|
|
192
|
+
the observed condition (`not_present`, `skipped_symlink`, `skipped_not_regular`,
|
|
193
|
+
`skipped_encoding`, `skipped_unreadable`); its sentence in `context` comes from that same
|
|
194
|
+
condition. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two
|
|
195
|
+
`nested_agents_*` codes mirror the `nested_context` truncation booleans; the complete object
|
|
196
|
+
remains a separate field. Callers can ignore `statuses` safely.
|
|
190
197
|
|
|
191
198
|
The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
|
|
192
199
|
contain heading, heading level, and fixed selection reasons; it never contains the original focus or
|
|
@@ -206,7 +213,7 @@ separator, with the array's brackets charged to the first entry — so the emitt
|
|
|
206
213
|
more than the budget it declares. Because quoting is counted, an array already near the cap can
|
|
207
214
|
report one path fewer than an older release did, and then says so through `list_truncated`.
|
|
208
215
|
`scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, a directory
|
|
209
|
-
held more entries than one
|
|
216
|
+
held more entries than one listing retains (1,024), or a directory or entry could not be
|
|
210
217
|
read, so absence of a path is not proof of absence of the file. Nested paths
|
|
211
218
|
are sanitized with the same escaping the Markdown uses for repository-derived text, so a nested name
|
|
212
219
|
the filesystem could not decode never breaks the document;
|
|
@@ -215,6 +222,16 @@ see “Limits” for the root paths that are emitted verbatim. The corresponding
|
|
|
215
222
|
Presence is not instruction: whether a nested file applies, and its text, remain the caller's
|
|
216
223
|
judgment; Markdown output is unchanged.
|
|
217
224
|
|
|
225
|
+
Escaped names remain reversible and distinct: a literal backslash becomes `\\`, a backtick
|
|
226
|
+
becomes `\x60`, ASCII controls and undecodable filesystem bytes become `\xNN`, and other
|
|
227
|
+
non-printable Unicode characters become `\uNNNN` or `\UNNNNNNNN`. Printable Unicode stays
|
|
228
|
+
unchanged. After JSON decoding, recover POSIX path bytes by reading escapes from left to right:
|
|
229
|
+
`\\` yields one backslash byte, `\xNN` yields that byte, and Unicode escapes and ordinary
|
|
230
|
+
characters yield their UTF-8 bytes. Thus a backtick, a literal `\x60`, an undecodable byte
|
|
231
|
+
`0x80`, and Unicode `U+0080` all have different representations. This applies to nested paths
|
|
232
|
+
and displayed tree/change/status names; the verbatim root/source paths retain their existing
|
|
233
|
+
semantics.
|
|
234
|
+
|
|
218
235
|
### Compact model consumption (`--format json-compact`)
|
|
219
236
|
|
|
220
237
|
`--format json-compact` emits a `schema_version` 4 document: exactly the version-3 document with
|
|
@@ -242,7 +259,7 @@ although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward eac
|
|
|
242
259
|
names exactly one key set, and a new number is issued whenever a key set would otherwise be reused.
|
|
243
260
|
|
|
244
261
|
The JSON schema version and package version are independent: this build emits `schema_version` `3`
|
|
245
|
-
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.
|
|
262
|
+
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.3`. Version 1
|
|
246
263
|
and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
|
|
247
264
|
`nested_context` changes the default document's key set, version 3 names the current full-document
|
|
248
265
|
key set and version 4 names the compact projection.
|
|
@@ -316,10 +333,11 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
316
333
|
16 KiB, including a 4-KiB maximum small head
|
|
317
334
|
- `README.md`: 16 KiB
|
|
318
335
|
- Each entry file: 8 KiB
|
|
319
|
-
- All entry-file bodies: 24 KiB
|
|
336
|
+
- All entry-file bodies: 24 KiB, including truncation markers. Room is reserved for markers
|
|
337
|
+
on later text sources, so exhausting the budget never hides that their bodies were cut.
|
|
320
338
|
- Declared commands: 8 KiB
|
|
321
339
|
- Directory tree: 12 KiB of listing body, 300 entries, and depth 2. Each directory is
|
|
322
|
-
|
|
340
|
+
retains and classifies up to 512 entries, keeping the alphabetically first names so the listing never
|
|
323
341
|
depends on operating system order. A directory that holds more is reported by a single
|
|
324
342
|
`Listing incomplete:` line stating the total, the limit and up to three names, and by at most
|
|
325
343
|
eight `directory_listing_incomplete` status entries naming individual directories plus one
|
|
@@ -330,8 +348,10 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
330
348
|
alphabetical prefix and the entry budget kept it, so capped siblings displaced by the budget are
|
|
331
349
|
disclosed as their parent's truncation rather than as separate numbers.
|
|
332
350
|
Three limits act in sequence and a name survives only if all three leave room. The 512-entry
|
|
333
|
-
|
|
334
|
-
prefix is
|
|
351
|
+
retention limit decides which names a directory offers at all; above it only an alphabetical
|
|
352
|
+
prefix is classified. All names must still be scanned to find that prefix, subject to the
|
|
353
|
+
separate hard name-scan bound below; the retention cap bounds memory and classification work.
|
|
354
|
+
The 300-entry item budget is then shared between a directory's
|
|
335
355
|
subdirectories and its non-directories: each may take up to half of what remains before the other
|
|
336
356
|
claims the surplus, which stops alphabetically-earlier directories from evicting every file, but
|
|
337
357
|
it is a share rather than a floor -- with a single slot left the non-directories take it. The 12
|
|
@@ -350,8 +370,14 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
350
370
|
fence say so. The heading and the code-fence lines are not metered, so the whole Markdown
|
|
351
371
|
section can exceed 12 KiB by that fixed few dozen bytes.
|
|
352
372
|
- Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths, a
|
|
353
|
-
4 KiB budget metered on the emitted array, and at most 1,024 entries
|
|
354
|
-
|
|
373
|
+
4 KiB budget metered on the emitted array, and at most 1,024 entries retained and classified
|
|
374
|
+
per directory. Names are scanned to select that prefix, subject to the separate hard
|
|
375
|
+
name-scan bound below; file contents are never read
|
|
376
|
+
- Directory name scans stop on the first entry beyond 65,536 names per directory. An
|
|
377
|
+
over-limit directory's entire partial prefix is discarded to keep results independent of
|
|
378
|
+
operating system order. The tree emits `Skipped: directory name scan limit reached.` and
|
|
379
|
+
a `directory_scan_limit_exceeded` status; the nested presence scan sets `scan_truncated`.
|
|
380
|
+
This bounds name iteration, not elapsed time on a blocked filesystem call.
|
|
355
381
|
- Working-tree changes: 100 paths and 4 KiB
|
|
356
382
|
- Recent commits: 8
|
|
357
383
|
- JSON-only listings have no byte budget of their own: `statuses` subjects are escaped names bounded
|
|
@@ -359,6 +385,7 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
359
385
|
repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
|
|
360
386
|
still renders
|
|
361
387
|
- Git subprocess output: 16 MiB (bounded while reading)
|
|
388
|
+
- Git status preflight: at most 128 repositories, including initialized nested submodules
|
|
362
389
|
- Candidate file validation: 16 MiB hard read bound
|
|
363
390
|
|
|
364
391
|
Truncation occurs only at complete UTF-8 and line boundaries and is marked explicitly.
|
|
@@ -382,6 +409,13 @@ the remote.
|
|
|
382
409
|
this is normal after cloning an empty remote.
|
|
383
410
|
- `Ahead / behind: not available` means the commit relationship cannot currently be computed.
|
|
384
411
|
Counts are reported only when the configured upstream commit is available locally.
|
|
412
|
+
- Executable Git clean/process filters can run during `status`, even with optional locks
|
|
413
|
+
disabled. Before reading status, the loader checks effective repository configuration,
|
|
414
|
+
including includes, worktree configuration and initialized nested submodules. A non-empty
|
|
415
|
+
clean/process command causes a safe error with no stdout, even if no current attribute uses
|
|
416
|
+
it. Disabling a driver silently would change cleanliness semantics. Filter-dependent repositories
|
|
417
|
+
(including locally configured Git LFS drivers) therefore need ordinary Git inspection outside
|
|
418
|
+
this loader. Git protocols are also disabled to prevent lazy object retrieval.
|
|
385
419
|
|
|
386
420
|
## Exit Codes
|
|
387
421
|
|
|
@@ -394,7 +428,7 @@ or candidate-file content.
|
|
|
394
428
|
|
|
395
429
|
## Not Included
|
|
396
430
|
|
|
397
|
-
Version 1.3.
|
|
431
|
+
Version 1.3.3 does not provide AI summaries, project-type detection, loading or transport of
|
|
398
432
|
nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
|
|
399
433
|
Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
|
|
400
434
|
network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
|
|
@@ -407,6 +441,17 @@ Run the complete local check:
|
|
|
407
441
|
just check
|
|
408
442
|
```
|
|
409
443
|
|
|
444
|
+
The lockfile uses public PyPI artifact URLs; no organization mirror or local Renovate preset
|
|
445
|
+
is needed. Renovate keeps scheduled, rate-limited updates and manual merges using a public
|
|
446
|
+
recommended preset plus the repository's explicit settings. Python 3.12 remains the tested
|
|
447
|
+
runtime; widening that range requires testing those interpreters first.
|
|
448
|
+
|
|
449
|
+
Git test fixtures discard inherited `GIT_*` routing/configuration before creating repositories.
|
|
450
|
+
Permission-sensitive tests must run as a non-root user. Gitea uses `just ci-check`: non-root
|
|
451
|
+
runners execute directly; root containers use `runuser` with an existing account named by
|
|
452
|
+
`CONTEXT_LOADER_CI_USER` (default `ubuntu`). That account must be able to execute the installed
|
|
453
|
+
`uv`, Python and `just`. No `sudo` dependency is required for this CI step.
|
|
454
|
+
|
|
410
455
|
## Version maintenance
|
|
411
456
|
|
|
412
457
|
The versions in `pyproject.toml` and `context_loader/__init__.py`, the `tool_version` in
|
|
@@ -427,6 +472,11 @@ only verifies the public release, mirrors its exact formal tag object if missing
|
|
|
427
472
|
the Gitea Release record. It builds no package and has no PyPI publishing credentials.
|
|
428
473
|
No private endpoint or credential is needed on GitHub.
|
|
429
474
|
|
|
475
|
+
For environments that permit direct public reads but share an exhausted proxy quota,
|
|
476
|
+
set the Gitea Actions repository variable `RELEASE_PUBLIC_API_DIRECT=true`. The record
|
|
477
|
+
step appends only public GitHub API/PyPI hosts to the existing proxy bypass lists. Without
|
|
478
|
+
this explicit option, the workflow uses its inherited proxy settings.
|
|
479
|
+
|
|
430
480
|
Release checks use Python 3.12, Git, and the runner's existing OpenSSL CLI. They compare downloaded
|
|
431
481
|
PyPI file bytes, metadata and SHA-256, plus the publisher/tag/commit claims in PyPI's HTTPS-served
|
|
432
482
|
provenance. This is an identity check, not an independent cryptographic Sigstore verifier.
|
|
@@ -26,7 +26,6 @@ from .model import (
|
|
|
26
26
|
from .render import (
|
|
27
27
|
RenderedSourceBody,
|
|
28
28
|
render_markdown_with_details,
|
|
29
|
-
rendered_source_contents,
|
|
30
29
|
)
|
|
31
30
|
|
|
32
31
|
# Each value names one exact document shape and is never reused: 1 and 2 are the
|
|
@@ -70,9 +69,7 @@ class ContextStatus:
|
|
|
70
69
|
code: str
|
|
71
70
|
subject_kind: str
|
|
72
71
|
subject: str
|
|
73
|
-
#
|
|
74
|
-
# deduplication must key on this and never on `subject`, or two different directories
|
|
75
|
-
# could collapse into one claim. It is not serialized.
|
|
72
|
+
# Deduplicate raw facts independently of display formatting. Not serialized.
|
|
76
73
|
identity: str = field(default="", compare=False)
|
|
77
74
|
|
|
78
75
|
|
|
@@ -191,7 +188,19 @@ def _build_statuses(
|
|
|
191
188
|
for entry in project.directory_tree.entries:
|
|
192
189
|
if entry.kind == "unreadable_directory":
|
|
193
190
|
subject = entry.path if entry.path else "."
|
|
194
|
-
statuses.append(
|
|
191
|
+
statuses.append(
|
|
192
|
+
_status("unreadable", "tree_entry", display_text(subject), identity=subject)
|
|
193
|
+
)
|
|
194
|
+
elif entry.kind == "scan_limit_directory":
|
|
195
|
+
subject = entry.path if entry.path else "."
|
|
196
|
+
statuses.append(
|
|
197
|
+
_status(
|
|
198
|
+
"directory_scan_limit_exceeded",
|
|
199
|
+
"tree_entry",
|
|
200
|
+
display_text(subject),
|
|
201
|
+
identity=subject,
|
|
202
|
+
)
|
|
203
|
+
)
|
|
195
204
|
tree = project.directory_tree
|
|
196
205
|
named = list(dict.fromkeys(tree.incomplete_directories))
|
|
197
206
|
for path in named:
|
|
@@ -212,7 +221,7 @@ def _build_statuses(
|
|
|
212
221
|
"directory_listing_incomplete",
|
|
213
222
|
"tree",
|
|
214
223
|
f"{tree.incomplete_count} directories exceeded the "
|
|
215
|
-
f"{tree.enumeration_limit}-entry per-directory
|
|
224
|
+
f"{tree.enumeration_limit}-entry per-directory retention limit; "
|
|
216
225
|
f"{unnamed} further {stem} not named individually",
|
|
217
226
|
identity=f"aggregate:{tree.incomplete_count}:{unnamed}",
|
|
218
227
|
)
|
|
@@ -254,7 +263,7 @@ def load_project_context(
|
|
|
254
263
|
raise ContextLoaderError(str(exc), exit_code=2) from None
|
|
255
264
|
rendered = render_markdown_with_details(state, project)
|
|
256
265
|
context = rendered.output.decode("utf-8")
|
|
257
|
-
rendered_bodies =
|
|
266
|
+
rendered_bodies = rendered.source_bodies
|
|
258
267
|
sources = _sources(state.repository, rendered_bodies)
|
|
259
268
|
statuses = _build_statuses(
|
|
260
269
|
project,
|
|
@@ -13,6 +13,7 @@ from dataclasses import dataclass, replace
|
|
|
13
13
|
from pathlib import Path, PurePosixPath
|
|
14
14
|
|
|
15
15
|
from .filesystem import (
|
|
16
|
+
DirectoryScanLimitError,
|
|
16
17
|
enumerate_directory,
|
|
17
18
|
open_bounded_regular_file,
|
|
18
19
|
open_child_directory,
|
|
@@ -185,6 +186,7 @@ def _ordered_reasons(reasons: set[str] | frozenset[str]) -> tuple[str, ...]:
|
|
|
185
186
|
|
|
186
187
|
|
|
187
188
|
_AUDIT_EMPTY_LIST_LINE = "- None."
|
|
189
|
+
_AUDIT_NO_INDEX_LINE = "- Omitted content has no available heading index; read the source path."
|
|
188
190
|
|
|
189
191
|
|
|
190
192
|
def _audit_heading(heading: str) -> str:
|
|
@@ -198,6 +200,11 @@ def _audit_index_line(entry: AgentsSectionAuditEntry) -> str:
|
|
|
198
200
|
return f"- H{entry.heading_level} {_audit_heading(entry.heading)}"
|
|
199
201
|
|
|
200
202
|
|
|
203
|
+
def _audit_selected_line(entry: AgentsSectionAuditEntry) -> str:
|
|
204
|
+
label = "Document head" if entry.heading_level == 0 else _audit_heading(entry.heading)
|
|
205
|
+
return f"- {label} — `{', '.join(entry.reasons)}`"
|
|
206
|
+
|
|
207
|
+
|
|
201
208
|
def render_agents_selection_audit(audit: AgentsSelectionAudit) -> str:
|
|
202
209
|
"""Render the bounded, prompt-free AGENTS selection audit and fail-safe index."""
|
|
203
210
|
lines = [
|
|
@@ -212,9 +219,7 @@ def render_agents_selection_audit(audit: AgentsSelectionAudit) -> str:
|
|
|
212
219
|
]
|
|
213
220
|
if audit.selected_sections:
|
|
214
221
|
for entry in audit.selected_sections:
|
|
215
|
-
|
|
216
|
-
reasons = ", ".join(entry.reasons)
|
|
217
|
-
lines.append(f"- {label} — `{reasons}`")
|
|
222
|
+
lines.append(_audit_selected_line(entry))
|
|
218
223
|
else:
|
|
219
224
|
lines.append(_AUDIT_EMPTY_LIST_LINE)
|
|
220
225
|
lines.extend(
|
|
@@ -229,7 +234,7 @@ def render_agents_selection_audit(audit: AgentsSelectionAudit) -> str:
|
|
|
229
234
|
else:
|
|
230
235
|
lines.append(_AUDIT_EMPTY_LIST_LINE)
|
|
231
236
|
if audit.truncated and not audit.index_truncated:
|
|
232
|
-
lines.append(
|
|
237
|
+
lines.append(_AUDIT_NO_INDEX_LINE)
|
|
233
238
|
if audit.index_truncated:
|
|
234
239
|
lines.append("- Additional headings omitted from this index by the AGENTS budget.")
|
|
235
240
|
if audit.parse_fallback:
|
|
@@ -329,7 +334,11 @@ def _small_head_end(content: str, sections: tuple[MarkdownSection, ...]) -> int:
|
|
|
329
334
|
def _section_relevance(
|
|
330
335
|
section: MarkdownSection, signals: _SelectionSignals
|
|
331
336
|
) -> tuple[int, frozenset[str]]:
|
|
332
|
-
heading_tokens =
|
|
337
|
+
heading_tokens = frozenset(
|
|
338
|
+
token
|
|
339
|
+
for token in section.normalized_heading.split()
|
|
340
|
+
if len(token) >= 3 and token not in _MATCH_TOKEN_STOPWORDS
|
|
341
|
+
)
|
|
333
342
|
section_tokens = _matching_tokens(section.text, _MATCH_TOKEN_STOPWORDS)
|
|
334
343
|
body_tokens = section_tokens - heading_tokens
|
|
335
344
|
score = 0
|
|
@@ -343,9 +352,9 @@ def _section_relevance(
|
|
|
343
352
|
|
|
344
353
|
path_heading = signals.path_tokens & heading_tokens
|
|
345
354
|
path_body = signals.path_tokens & body_tokens
|
|
346
|
-
normalized_section = _normalized_text(section.text)
|
|
347
355
|
exact_path = bool(
|
|
348
|
-
signals.normalized_path
|
|
356
|
+
signals.normalized_path
|
|
357
|
+
and _normalized_text(signals.normalized_path) in _normalized_text(section.text)
|
|
349
358
|
)
|
|
350
359
|
if exact_path or path_heading or path_body:
|
|
351
360
|
reasons.add("path_match")
|
|
@@ -522,50 +531,172 @@ def _select_agents_content(source: CollectedFile, signals: _SelectionSignals) ->
|
|
|
522
531
|
return _bounded_head_fallback(source, parse_fallback=True)
|
|
523
532
|
|
|
524
533
|
head_end = _small_head_end(source.content, sections)
|
|
525
|
-
selected_indices
|
|
526
|
-
reasons: dict[int, set[str]] = {}
|
|
534
|
+
selected_indices = {index for index, section in enumerate(sections) if section.end <= head_end}
|
|
535
|
+
reasons: dict[int, set[str]] = {index: {"head"} for index in selected_indices}
|
|
527
536
|
ranked: list[tuple[int, int, frozenset[str]]] = []
|
|
528
537
|
for index, section in enumerate(sections):
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
538
|
+
if signals.focus_tokens or signals.path_tokens or signals.normalized_path:
|
|
539
|
+
score, section_reasons = _section_relevance(section, signals)
|
|
540
|
+
if score >= 4:
|
|
541
|
+
ranked.append((score, index, section_reasons))
|
|
532
542
|
ranked.sort(key=lambda item: (-item[0], sections[item[1]].start))
|
|
533
543
|
|
|
544
|
+
# Sections are disjoint. Candidate admission therefore needs only the bytes,
|
|
545
|
+
# characters and audit lines changed by that section and its (at most six)
|
|
546
|
+
# parents, rather than rebuilding every section/index for every candidate.
|
|
547
|
+
extra_bytes = [
|
|
548
|
+
len(source.content[max(head_end, section.start) : section.end].encode("utf-8"))
|
|
549
|
+
if section.end > head_end
|
|
550
|
+
else 0
|
|
551
|
+
for section in sections
|
|
552
|
+
]
|
|
553
|
+
index_sizes = [
|
|
554
|
+
len(
|
|
555
|
+
_audit_index_line(AgentsSectionAuditEntry(s.heading, s.heading_level, ())).encode(
|
|
556
|
+
"utf-8"
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
+ 1
|
|
560
|
+
for s in sections
|
|
561
|
+
]
|
|
562
|
+
selected_sizes = {
|
|
563
|
+
index: len(
|
|
564
|
+
_audit_selected_line(
|
|
565
|
+
AgentsSectionAuditEntry(
|
|
566
|
+
sections[index].heading, sections[index].heading_level, ("head",)
|
|
567
|
+
)
|
|
568
|
+
).encode("utf-8")
|
|
569
|
+
)
|
|
570
|
+
+ 1
|
|
571
|
+
for index in selected_indices
|
|
572
|
+
}
|
|
573
|
+
selected_lines = sum(selected_sizes.values())
|
|
574
|
+
document_head_end = sections[0].start if sections else len(source.content)
|
|
575
|
+
if document_head_end and head_end:
|
|
576
|
+
selected_lines += (
|
|
577
|
+
len(
|
|
578
|
+
_audit_selected_line(AgentsSectionAuditEntry("Document head", 0, ("head",))).encode(
|
|
579
|
+
"utf-8"
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
+ 1
|
|
583
|
+
)
|
|
584
|
+
content_bytes = len(source.content[:head_end].encode("utf-8"))
|
|
585
|
+
chars_selected = head_end
|
|
586
|
+
index_count = len(sections) - len(selected_indices)
|
|
587
|
+
index_lines = sum(
|
|
588
|
+
size for index, size in enumerate(index_sizes) if index not in selected_indices
|
|
589
|
+
)
|
|
590
|
+
first_index = len(selected_indices)
|
|
591
|
+
placeholder_bytes = len(_AUDIT_EMPTY_LIST_LINE.encode("utf-8")) + 1
|
|
592
|
+
no_index_bytes = len(_AUDIT_NO_INDEX_LINE.encode("utf-8")) + 1
|
|
593
|
+
empty_sizes = {
|
|
594
|
+
(truncated, index_truncated): len(
|
|
595
|
+
render_agents_selection_audit(
|
|
596
|
+
AgentsSelectionAudit(
|
|
597
|
+
source="AGENTS.md",
|
|
598
|
+
selected_sections=(),
|
|
599
|
+
indexed_only_sections=(),
|
|
600
|
+
chars_selected=0,
|
|
601
|
+
chars_omitted=0,
|
|
602
|
+
truncated=truncated,
|
|
603
|
+
source_scan_truncated=source.truncated,
|
|
604
|
+
index_truncated=index_truncated,
|
|
605
|
+
)
|
|
606
|
+
).encode("utf-8")
|
|
607
|
+
)
|
|
608
|
+
for truncated in (False, True)
|
|
609
|
+
for index_truncated in (False, True)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
def minimum_cost(
|
|
613
|
+
body_bytes: int,
|
|
614
|
+
chars: int,
|
|
615
|
+
selected_line_bytes: int,
|
|
616
|
+
remaining_count: int,
|
|
617
|
+
remaining_lines: int,
|
|
618
|
+
first_remaining: int,
|
|
619
|
+
) -> int:
|
|
620
|
+
omitted = max(0, source.source_characters - chars)
|
|
621
|
+
truncated = omitted > 0 or source.truncated
|
|
622
|
+
common = body_bytes + len(str(chars)) + len(str(omitted)) - 2
|
|
623
|
+
if selected_line_bytes:
|
|
624
|
+
common += selected_line_bytes - placeholder_bytes
|
|
625
|
+
full = common + empty_sizes[truncated, False]
|
|
626
|
+
if remaining_count:
|
|
627
|
+
full += remaining_lines - placeholder_bytes
|
|
628
|
+
if truncated:
|
|
629
|
+
full -= no_index_bytes
|
|
630
|
+
fitted = common + empty_sizes[truncated, True]
|
|
631
|
+
if remaining_count > 1:
|
|
632
|
+
# A very short first index line can cost less than the empty placeholder.
|
|
633
|
+
fitted += min(0, index_sizes[first_remaining] - placeholder_bytes)
|
|
634
|
+
return min(full, fitted)
|
|
635
|
+
|
|
534
636
|
budget_fallback: set[int] = set()
|
|
535
637
|
for _score, index, section_reasons in ranked:
|
|
536
|
-
|
|
537
|
-
trial_reasons = {key: set(value) for key, value in reasons.items()}
|
|
538
|
-
trial_indices.add(index)
|
|
539
|
-
trial_reasons.setdefault(index, set()).update(section_reasons)
|
|
638
|
+
trial_reasons = {index: reasons.get(index, set()) | set(section_reasons)}
|
|
540
639
|
parent = sections[index].parent_index
|
|
541
640
|
while parent is not None:
|
|
542
|
-
|
|
543
|
-
trial_reasons.setdefault(parent, set()).add("parent_context")
|
|
641
|
+
trial_reasons[parent] = reasons.get(parent, set()) | {"parent_context"}
|
|
544
642
|
parent = sections[parent].parent_index
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
head_end,
|
|
549
|
-
frozenset(trial_indices),
|
|
550
|
-
trial_reasons,
|
|
551
|
-
frozenset(budget_fallback),
|
|
552
|
-
parse_fallback=False,
|
|
553
|
-
source_scan_truncated=source.truncated,
|
|
554
|
-
source_characters=source.source_characters,
|
|
643
|
+
new_indices = trial_reasons.keys() - selected_indices
|
|
644
|
+
trial_bytes = content_bytes + sum(extra_bytes[item] for item in new_indices)
|
|
645
|
+
trial_chars = chars_selected + sum(
|
|
646
|
+
sections[item].end - max(head_end, sections[item].start) for item in new_indices
|
|
555
647
|
)
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
648
|
+
trial_sizes = {
|
|
649
|
+
item: len(
|
|
650
|
+
_audit_selected_line(
|
|
651
|
+
AgentsSectionAuditEntry(
|
|
652
|
+
sections[item].heading,
|
|
653
|
+
sections[item].heading_level,
|
|
654
|
+
_ordered_reasons(item_reasons),
|
|
655
|
+
)
|
|
656
|
+
).encode("utf-8")
|
|
657
|
+
)
|
|
658
|
+
+ 1
|
|
659
|
+
for item, item_reasons in trial_reasons.items()
|
|
660
|
+
}
|
|
661
|
+
trial_selected_lines = selected_lines + sum(
|
|
662
|
+
size - selected_sizes.get(item, 0) for item, size in trial_sizes.items()
|
|
663
|
+
)
|
|
664
|
+
trial_index_count = index_count - len(new_indices)
|
|
665
|
+
trial_index_lines = index_lines - sum(index_sizes[item] for item in new_indices)
|
|
666
|
+
trial_first = first_index
|
|
667
|
+
while trial_first < len(sections) and (
|
|
668
|
+
trial_first in selected_indices or trial_first in new_indices
|
|
669
|
+
):
|
|
670
|
+
trial_first += 1
|
|
671
|
+
if (
|
|
672
|
+
minimum_cost(
|
|
673
|
+
trial_bytes,
|
|
674
|
+
trial_chars,
|
|
675
|
+
trial_selected_lines,
|
|
676
|
+
trial_index_count,
|
|
677
|
+
trial_index_lines,
|
|
678
|
+
trial_first,
|
|
679
|
+
)
|
|
680
|
+
> AGENTS_LIMIT_BYTES
|
|
681
|
+
):
|
|
559
682
|
budget_fallback.add(index)
|
|
560
683
|
else:
|
|
561
|
-
selected_indices
|
|
562
|
-
reasons
|
|
684
|
+
selected_indices.update(new_indices)
|
|
685
|
+
reasons.update(trial_reasons)
|
|
686
|
+
selected_sizes.update(trial_sizes)
|
|
687
|
+
content_bytes, chars_selected = trial_bytes, trial_chars
|
|
688
|
+
selected_lines = trial_selected_lines
|
|
689
|
+
index_count, index_lines, first_index = (
|
|
690
|
+
trial_index_count,
|
|
691
|
+
trial_index_lines,
|
|
692
|
+
trial_first,
|
|
693
|
+
)
|
|
563
694
|
|
|
564
695
|
content, audit = _selection_state(
|
|
565
696
|
source.content,
|
|
566
697
|
sections,
|
|
567
698
|
head_end,
|
|
568
|
-
selected_indices,
|
|
699
|
+
frozenset(selected_indices),
|
|
569
700
|
reasons,
|
|
570
701
|
frozenset(budget_fallback),
|
|
571
702
|
parse_fallback=False,
|
|
@@ -837,6 +968,9 @@ def _collect_directory_tree(root: Path) -> DirectoryTree:
|
|
|
837
968
|
continue
|
|
838
969
|
try:
|
|
839
970
|
walk(child_descriptor, entry_path, depth + 1)
|
|
971
|
+
except DirectoryScanLimitError:
|
|
972
|
+
collected[index] = TreeEntry(entry_path, "scan_limit_directory")
|
|
973
|
+
truncated = True
|
|
840
974
|
except OSError:
|
|
841
975
|
mark_unreadable(index, entry_path)
|
|
842
976
|
finally:
|
|
@@ -849,6 +983,9 @@ def _collect_directory_tree(root: Path) -> DirectoryTree:
|
|
|
849
983
|
try:
|
|
850
984
|
try:
|
|
851
985
|
walk(root_descriptor, "", 0)
|
|
986
|
+
except DirectoryScanLimitError:
|
|
987
|
+
collected.append(TreeEntry("", "scan_limit_directory"))
|
|
988
|
+
truncated = True
|
|
852
989
|
except OSError:
|
|
853
990
|
if not collected:
|
|
854
991
|
collected.append(TreeEntry("", "unreadable_directory"))
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Safe, bounded filesystem primitives shared by every context collection scan.
|
|
2
2
|
|
|
3
3
|
Everything here is descriptor-relative, never follows a symlink across a boundary it
|
|
4
|
-
does not report, and
|
|
5
|
-
|
|
4
|
+
does not report, and bounds retained entries and file reads. It reports observations
|
|
5
|
+
as typed availability reasons rather than as
|
|
6
6
|
prose, so callers cannot lose the difference between absence and a bounded claim.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ from dataclasses import dataclass
|
|
|
18
18
|
from heapq import nsmallest
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
-
from .model import FILE_SCAN_LIMIT_BYTES, Availability
|
|
21
|
+
from .model import DIRECTORY_NAME_SCAN_LIMIT_ENTRIES, FILE_SCAN_LIMIT_BYTES, Availability
|
|
22
22
|
|
|
23
23
|
DIRECTORY_OPEN_FLAGS = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
|
|
24
24
|
REGULAR_FILE_OPEN_FLAGS = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | getattr(os, "O_NOFOLLOW", 0)
|
|
@@ -38,31 +38,37 @@ class DirectoryEntryFacts:
|
|
|
38
38
|
class DirectoryListing:
|
|
39
39
|
"""A sorted, capped enumeration of one directory.
|
|
40
40
|
|
|
41
|
-
``capped``
|
|
42
|
-
|
|
41
|
+
``capped`` means more names were scanned than were retained and classified,
|
|
42
|
+
so a caller can never present this listing as complete.
|
|
43
43
|
"""
|
|
44
44
|
|
|
45
45
|
entries: tuple[DirectoryEntryFacts, ...]
|
|
46
46
|
capped: bool
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
class DirectoryScanLimitError(OSError):
|
|
50
|
+
"""The name scan exceeded its hard cap; no alphabetical prefix is trustworthy."""
|
|
51
|
+
|
|
52
|
+
|
|
49
53
|
def display_text(value: str) -> str:
|
|
50
54
|
r"""Render one filesystem or Git string as printable, encodable ASCII-safe text.
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
Escape backslashes as well as backticks and non-printable codepoints so names
|
|
57
|
+
remain distinguishable. ``\x80`` through ``\xff`` represent undecodable bytes;
|
|
58
|
+
Unicode controls above ASCII use ``\uNNNN`` instead of sharing those escapes.
|
|
55
59
|
"""
|
|
56
60
|
rendered: list[str] = []
|
|
57
61
|
for character in value:
|
|
58
62
|
codepoint = ord(character)
|
|
59
|
-
if character == "
|
|
63
|
+
if character == "\\":
|
|
64
|
+
rendered.append(r"\\")
|
|
65
|
+
elif character == "`":
|
|
60
66
|
rendered.append(r"\x60")
|
|
61
67
|
elif 0xDC80 <= codepoint <= 0xDCFF:
|
|
62
68
|
rendered.append(f"\\x{codepoint - 0xDC00:02x}")
|
|
63
69
|
elif character.isprintable():
|
|
64
70
|
rendered.append(character)
|
|
65
|
-
elif codepoint <=
|
|
71
|
+
elif codepoint <= 0x7F:
|
|
66
72
|
rendered.append(f"\\x{codepoint:02x}")
|
|
67
73
|
elif codepoint <= 0xFFFF:
|
|
68
74
|
rendered.append(f"\\u{codepoint:04x}")
|
|
@@ -93,6 +99,8 @@ def open_child_directory(parent_descriptor: int, name: str) -> int:
|
|
|
93
99
|
def _counted(entries: Iterator[os.DirEntry[str]], counter: list[int]) -> Iterator[os.DirEntry[str]]:
|
|
94
100
|
for entry in entries:
|
|
95
101
|
counter[0] += 1
|
|
102
|
+
if counter[0] > DIRECTORY_NAME_SCAN_LIMIT_ENTRIES:
|
|
103
|
+
raise DirectoryScanLimitError("directory name scan exceeded the safety limit")
|
|
96
104
|
yield entry
|
|
97
105
|
|
|
98
106
|
|
|
@@ -117,7 +125,11 @@ def enumerate_directory(directory_descriptor: int, limit: int) -> DirectoryListi
|
|
|
117
125
|
rather than whatever the operating system happened to yield first, so a capped
|
|
118
126
|
listing is reproducible. Memory stays bounded by ``limit`` because entries are
|
|
119
127
|
selected while the scan iterator is consumed, and only the retained entries are
|
|
120
|
-
classified.
|
|
128
|
+
classified. All names must be scanned to establish that alphabetical prefix;
|
|
129
|
+
``limit`` bounds retention/classification, not iteration or elapsed time. The
|
|
130
|
+
independent name-scan cap raises ``DirectoryScanLimitError`` and discards the
|
|
131
|
+
entire prefix rather than reporting an OS-order-dependent partial result.
|
|
132
|
+
``OSError`` propagates when the directory itself cannot be read, so
|
|
121
133
|
each caller keeps its own meaning for an unreadable directory.
|
|
122
134
|
"""
|
|
123
135
|
if limit < 1:
|
|
@@ -15,6 +15,7 @@ GIT_EXECUTABLE = "/usr/bin/git"
|
|
|
15
15
|
GIT_TIMEOUT_SECONDS = 30
|
|
16
16
|
MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
17
17
|
MAX_RECENT_COMMITS = 8
|
|
18
|
+
MAX_STATUS_REPOSITORIES = 128
|
|
18
19
|
|
|
19
20
|
_OID_PATTERN = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
|
|
20
21
|
_GIT_PREFIX = (
|
|
@@ -37,10 +38,13 @@ _GIT_PREFIX = (
|
|
|
37
38
|
"i18n.logOutputEncoding=UTF-8",
|
|
38
39
|
"-c",
|
|
39
40
|
"log.showSignature=false",
|
|
41
|
+
"-c",
|
|
42
|
+
"protocol.allow=never",
|
|
40
43
|
)
|
|
41
44
|
_GIT_ENVIRONMENT = MappingProxyType(
|
|
42
45
|
{
|
|
43
46
|
"GIT_ATTR_NOSYSTEM": "1",
|
|
47
|
+
"GIT_ALLOW_PROTOCOL": "",
|
|
44
48
|
"GIT_CONFIG_COUNT": "0",
|
|
45
49
|
"GIT_CONFIG_GLOBAL": "/dev/null",
|
|
46
50
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
@@ -314,7 +318,65 @@ def _upstream_state(repo: Path, branch_name: str | None) -> tuple[str, str]:
|
|
|
314
318
|
return upstream, f"{counts[0].decode('ascii')} / {counts[1].decode('ascii')}"
|
|
315
319
|
|
|
316
320
|
|
|
321
|
+
def _ensure_safe_status(repo: Path) -> None:
|
|
322
|
+
"""Reject executable conversions before status can refresh any index entry.
|
|
323
|
+
|
|
324
|
+
Attributes can select clean/process drivers even during a read-only status.
|
|
325
|
+
Ignoring those drivers would change Git's cleanliness semantics, so fail closed
|
|
326
|
+
instead. Status also inspects initialized submodules; check their own config
|
|
327
|
+
without invoking `submodule foreach` or any repository-supplied command.
|
|
328
|
+
"""
|
|
329
|
+
pending = [repo]
|
|
330
|
+
seen: set[Path] = set()
|
|
331
|
+
while pending:
|
|
332
|
+
current = pending.pop()
|
|
333
|
+
identity = current.resolve()
|
|
334
|
+
if identity in seen:
|
|
335
|
+
raise ContextLoaderError("unable to safely inspect cyclic submodule state")
|
|
336
|
+
seen.add(identity)
|
|
337
|
+
if len(seen) > MAX_STATUS_REPOSITORIES:
|
|
338
|
+
raise ContextLoaderError("repository submodule inspection exceeded the safety limit")
|
|
339
|
+
filters = _run_git(
|
|
340
|
+
current,
|
|
341
|
+
("config", "--null", "--get-regexp", r"^filter\..*\.(clean|process)$"),
|
|
342
|
+
check=False,
|
|
343
|
+
)
|
|
344
|
+
if filters.returncode not in (0, 1):
|
|
345
|
+
raise ContextLoaderError("unable to safely inspect repository Git filters")
|
|
346
|
+
filter_commands: dict[bytes, bytes] = {}
|
|
347
|
+
for record in filters.stdout.split(b"\0"):
|
|
348
|
+
if not record:
|
|
349
|
+
continue
|
|
350
|
+
key, separator, value = record.partition(b"\n")
|
|
351
|
+
if not key or not separator:
|
|
352
|
+
raise ContextLoaderError("unable to safely inspect repository Git filters")
|
|
353
|
+
filter_commands[key] = value
|
|
354
|
+
if any(filter_commands.values()):
|
|
355
|
+
raise ContextLoaderError(
|
|
356
|
+
"repository or submodule config enables Git clean/process filters; "
|
|
357
|
+
"unable to safely inspect working-tree state"
|
|
358
|
+
)
|
|
359
|
+
index = _run_git(current, ("ls-files", "--stage", "-z"))
|
|
360
|
+
children: set[Path] = set()
|
|
361
|
+
for record in index.stdout.split(b"\0"):
|
|
362
|
+
metadata, separator, raw_path = record.partition(b"\t")
|
|
363
|
+
if metadata.split(b" ", 1)[0] != b"160000":
|
|
364
|
+
continue
|
|
365
|
+
if not separator or not raw_path:
|
|
366
|
+
raise ContextLoaderError("unable to parse repository submodule state")
|
|
367
|
+
path = Path(_decode_path(raw_path))
|
|
368
|
+
if path.is_absolute() or ".." in path.parts:
|
|
369
|
+
raise ContextLoaderError("unable to safely inspect repository submodule state")
|
|
370
|
+
child = current / path
|
|
371
|
+
if (child / ".git").exists():
|
|
372
|
+
children.add(child)
|
|
373
|
+
pending.extend(sorted(children))
|
|
374
|
+
if len(seen) + len(pending) > MAX_STATUS_REPOSITORIES:
|
|
375
|
+
raise ContextLoaderError("repository submodule inspection exceeded the safety limit")
|
|
376
|
+
|
|
377
|
+
|
|
317
378
|
def _working_tree_changes(repo: Path) -> tuple[WorkingTreeChange, ...]:
|
|
379
|
+
_ensure_safe_status(repo)
|
|
318
380
|
result = _run_git(
|
|
319
381
|
repo,
|
|
320
382
|
(
|
|
@@ -22,9 +22,10 @@ DECLARED_COMMANDS_LIMIT_BYTES = 8 * 1024
|
|
|
22
22
|
DIRECTORY_TREE_LIMIT_BYTES = 12 * 1024
|
|
23
23
|
DIRECTORY_TREE_MAX_ITEMS = 300
|
|
24
24
|
DIRECTORY_TREE_MAX_DEPTH = 2
|
|
25
|
+
DIRECTORY_NAME_SCAN_LIMIT_ENTRIES = 65_536
|
|
25
26
|
# Deliberately above the item budget: which of a directory's own entries are shown must
|
|
26
27
|
# be decided by the item budget and its root-file guarantee, not by enumeration order.
|
|
27
|
-
#
|
|
28
|
+
# This caps retention/classification; choosing the prefix still scans all names.
|
|
28
29
|
DIRECTORY_TREE_MAX_ENTRIES_PER_DIRECTORY = 512
|
|
29
30
|
# A capped directory is reported once as a number plus a bounded set of examples, so the
|
|
30
31
|
# completeness claim cannot itself inflate the section it describes.
|
|
@@ -35,13 +36,12 @@ NESTED_AGENTS_MAX_DIRECTORIES = 2_000
|
|
|
35
36
|
NESTED_AGENTS_MAX_FILES = 32
|
|
36
37
|
NESTED_AGENTS_MAX_LIST_BYTES = 4 * 1024
|
|
37
38
|
# The presence scan inspects names only and lists no directory entries at all, so it can
|
|
38
|
-
#
|
|
39
|
+
# retain a wider prefix per directory than the tree does; exceeding it is scan truncation.
|
|
39
40
|
NESTED_AGENTS_MAX_ENTRIES_PER_DIRECTORY = 1_024
|
|
40
41
|
NESTED_AGENTS_EXCLUDED_DIRECTORIES = frozenset(
|
|
41
42
|
{".git", ".venv", "node_modules", "site-packages", "venv"}
|
|
42
43
|
)
|
|
43
44
|
FILE_SCAN_LIMIT_BYTES = 16 * 1024 * 1024
|
|
44
|
-
MAX_FILE_SCAN_BYTES = FILE_SCAN_LIMIT_BYTES
|
|
45
45
|
TRUNCATION_MARKER = "… truncated by context-loader …"
|
|
46
46
|
|
|
47
47
|
ENTRY_FILE_SPECS = (
|
|
@@ -148,7 +148,7 @@ class DirectoryTree:
|
|
|
148
148
|
"""A bounded directory listing that never overstates what it examined.
|
|
149
149
|
|
|
150
150
|
``truncated`` claims that entries exist beyond this listing. A directory holding more
|
|
151
|
-
entries than one
|
|
151
|
+
entries than one listing retains is counted in ``incomplete_count`` against
|
|
152
152
|
``enumeration_limit``, and ``incomplete_directories`` retains only the first
|
|
153
153
|
``DIRECTORY_TREE_INCOMPLETE_STATUS_LIMIT`` of them, so a partial listing of a directory is
|
|
154
154
|
never presented as a complete one and the claim stays bounded however many hit the cap.
|
|
@@ -41,6 +41,7 @@ class MarkdownRender:
|
|
|
41
41
|
changes_truncated: bool
|
|
42
42
|
commands_truncated: bool
|
|
43
43
|
tree_truncated: bool
|
|
44
|
+
source_bodies: tuple[RenderedSourceBody, ...]
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
@dataclass(frozen=True, slots=True)
|
|
@@ -125,11 +126,10 @@ def _file_body(source: CollectedFile, limit: int) -> tuple[str, bool] | None:
|
|
|
125
126
|
return body, truncated
|
|
126
127
|
|
|
127
128
|
|
|
128
|
-
def _file_payload(source: CollectedFile,
|
|
129
|
-
body = _file_body(source, limit)
|
|
129
|
+
def _file_payload(source: CollectedFile, body: RenderedSourceBody | None) -> str:
|
|
130
130
|
if body is None:
|
|
131
131
|
return _unavailable_sentence(source.reason)
|
|
132
|
-
return _fenced(source.language, body
|
|
132
|
+
return _fenced(source.language, body.body)
|
|
133
133
|
|
|
134
134
|
|
|
135
135
|
def _change_lines(state: RepositoryState) -> tuple[list[str], bool]:
|
|
@@ -170,13 +170,15 @@ def _render_git_state(state: RepositoryState) -> str:
|
|
|
170
170
|
return "\n".join(lines)
|
|
171
171
|
|
|
172
172
|
|
|
173
|
-
def _render_source_section(
|
|
173
|
+
def _render_source_section(
|
|
174
|
+
title: str, source: CollectedFile, body: RenderedSourceBody | None
|
|
175
|
+
) -> str:
|
|
174
176
|
parts = [
|
|
175
177
|
f"## {title}",
|
|
176
178
|
"",
|
|
177
179
|
f"Source: `{source.name}`",
|
|
178
180
|
"",
|
|
179
|
-
_file_payload(source,
|
|
181
|
+
_file_payload(source, body),
|
|
180
182
|
]
|
|
181
183
|
if source.selection is not None:
|
|
182
184
|
parts.extend(("", render_agents_selection_audit(source.selection)))
|
|
@@ -226,30 +228,44 @@ def _entry_body(source: CollectedFile, remaining: int) -> tuple[str | None, int,
|
|
|
226
228
|
if not source.is_text:
|
|
227
229
|
return None, 0, False
|
|
228
230
|
encoded_size = len(source.content.encode())
|
|
229
|
-
|
|
230
|
-
needs_marker = source.truncated or
|
|
231
|
+
limit = max(0, min(remaining, ENTRY_FILE_LIMIT_BYTES))
|
|
232
|
+
needs_marker = source.truncated or encoded_size > limit
|
|
231
233
|
marker_size = len(TRUNCATION_MARKER.encode()) if needs_marker else 0
|
|
232
|
-
content_limit =
|
|
234
|
+
content_limit = limit - marker_size
|
|
233
235
|
visible, was_cut = _truncate_at_line_boundary(source.content, max(0, content_limit))
|
|
234
236
|
needs_marker = needs_marker or was_cut
|
|
235
|
-
|
|
236
|
-
|
|
237
|
+
marker = TRUNCATION_MARKER if needs_marker and limit >= marker_size else ""
|
|
238
|
+
body = f"{visible}{marker}"
|
|
239
|
+
return body, len(body.encode()), needs_marker
|
|
237
240
|
|
|
238
241
|
|
|
239
|
-
def
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
def _entry_bodies(entry_files: tuple[CollectedFile, ...]) -> tuple[RenderedSourceBody, ...]:
|
|
243
|
+
"""Meter actual bodies, reserving room for later sources' truncation markers."""
|
|
244
|
+
marker_size = len(TRUNCATION_MARKER.encode())
|
|
245
|
+
reserved = sum(
|
|
246
|
+
marker_size
|
|
247
|
+
for source in entry_files
|
|
248
|
+
if source.is_text and (source.content or source.truncated)
|
|
249
|
+
)
|
|
250
|
+
used = 0
|
|
251
|
+
rendered: list[RenderedSourceBody] = []
|
|
252
|
+
for source in entry_files:
|
|
253
|
+
if source.is_text and (source.content or source.truncated):
|
|
254
|
+
reserved -= marker_size
|
|
255
|
+
remaining = max(0, ENTRY_FILES_TOTAL_LIMIT_BYTES - used - reserved)
|
|
256
|
+
body, consumed, truncated = _entry_body(source, remaining)
|
|
257
|
+
used += consumed
|
|
258
|
+
if body is not None:
|
|
259
|
+
rendered.append(RenderedSourceBody(source, body, truncated))
|
|
260
|
+
return tuple(rendered)
|
|
244
261
|
|
|
245
262
|
|
|
246
|
-
def _render_entry_files(
|
|
263
|
+
def _render_entry_files(
|
|
264
|
+
entry_files: tuple[CollectedFile, ...], bodies: dict[str, RenderedSourceBody]
|
|
265
|
+
) -> str:
|
|
247
266
|
parts = ["## Project Entry Files"]
|
|
248
|
-
used_content = 0
|
|
249
267
|
for source in entry_files:
|
|
250
|
-
|
|
251
|
-
payload, consumed = _entry_payload(source, remaining)
|
|
252
|
-
used_content += consumed
|
|
268
|
+
payload = _file_payload(source, bodies.get(source.name))
|
|
253
269
|
parts.extend(("", f"### `{source.name}`", "", payload))
|
|
254
270
|
return "\n".join(parts)
|
|
255
271
|
|
|
@@ -286,6 +302,9 @@ def _tree_line(entry: TreeEntry) -> str:
|
|
|
286
302
|
return f"{path}@"
|
|
287
303
|
if entry.kind == "unreadable_directory":
|
|
288
304
|
return f"{path}/ [Skipped: unreadable.]" if path else "Skipped: unreadable directory."
|
|
305
|
+
if entry.kind == "scan_limit_directory":
|
|
306
|
+
note = "Skipped: directory name scan limit reached."
|
|
307
|
+
return f"{path}/ [{note}]" if path else note
|
|
289
308
|
return path
|
|
290
309
|
|
|
291
310
|
|
|
@@ -310,8 +329,8 @@ def _incomplete_directory_note(tree: DirectoryTree) -> str:
|
|
|
310
329
|
limit = tree.enumeration_limit
|
|
311
330
|
return (
|
|
312
331
|
f"Listing incomplete: {tree.incomplete_count} {stem} held more entries than the "
|
|
313
|
-
f"{limit}-entry per-directory
|
|
314
|
-
f"prefix
|
|
332
|
+
f"{limit}-entry per-directory retention limit. All names were scanned to select an "
|
|
333
|
+
f"alphabetically first prefix; only that prefix was retained and classified. This "
|
|
315
334
|
f"section also keeps only as many entries as its own entry and byte budgets allow. "
|
|
316
335
|
f"Named here: {examples}."
|
|
317
336
|
)
|
|
@@ -361,6 +380,8 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
361
380
|
commands_section, commands_truncated = _render_commands(project.commands)
|
|
362
381
|
changes_truncated = _change_lines(state)[1]
|
|
363
382
|
tree_section, tree_truncated = _render_directory_tree(project.directory_tree)
|
|
383
|
+
all_bodies = _source_bodies(project)
|
|
384
|
+
bodies = {body.source.name: body for body in all_bodies}
|
|
364
385
|
sections: list[tuple[str, str | None]] = [
|
|
365
386
|
("Git State", _render_git_state(state)),
|
|
366
387
|
(
|
|
@@ -368,15 +389,17 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
368
389
|
_render_source_section(
|
|
369
390
|
"Development Instructions",
|
|
370
391
|
project.instructions,
|
|
371
|
-
|
|
392
|
+
bodies.get(project.instructions.name),
|
|
372
393
|
),
|
|
373
394
|
),
|
|
374
395
|
(
|
|
375
396
|
"Project Overview",
|
|
376
|
-
_render_source_section(
|
|
397
|
+
_render_source_section(
|
|
398
|
+
"Project Overview", project.overview, bodies.get(project.overview.name)
|
|
399
|
+
),
|
|
377
400
|
),
|
|
378
401
|
("Declared Commands", commands_section),
|
|
379
|
-
("Project Entry Files", _render_entry_files(project.entry_files)),
|
|
402
|
+
("Project Entry Files", _render_entry_files(project.entry_files, bodies)),
|
|
380
403
|
("Recent Commits", _render_recent_commits(state.commits)),
|
|
381
404
|
("Directory Tree", tree_section),
|
|
382
405
|
]
|
|
@@ -417,34 +440,48 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
417
440
|
changes_truncated=changes_truncated,
|
|
418
441
|
tree_truncated=tree_truncated,
|
|
419
442
|
commands_truncated=commands_truncated,
|
|
443
|
+
source_bodies=_included_source_bodies(all_bodies, tuple(included_sections)),
|
|
420
444
|
)
|
|
421
445
|
|
|
422
446
|
|
|
423
|
-
def
|
|
424
|
-
project: ProjectContext, included_sections: tuple[str, ...]
|
|
425
|
-
) -> tuple[RenderedSourceBody, ...]:
|
|
426
|
-
"""Return source-file bodies that actually appear in the final Markdown context."""
|
|
427
|
-
included = frozenset(included_sections)
|
|
447
|
+
def _source_bodies(project: ProjectContext) -> tuple[RenderedSourceBody, ...]:
|
|
428
448
|
rendered: list[RenderedSourceBody] = []
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
body = _file_body(project.overview, README_LIMIT_BYTES)
|
|
449
|
+
for source, limit in (
|
|
450
|
+
(project.instructions, AGENTS_LIMIT_BYTES),
|
|
451
|
+
(project.overview, README_LIMIT_BYTES),
|
|
452
|
+
):
|
|
453
|
+
body = _file_body(source, limit)
|
|
435
454
|
if body is not None:
|
|
436
|
-
rendered.append(RenderedSourceBody(
|
|
437
|
-
|
|
438
|
-
used_content = 0
|
|
439
|
-
for source in project.entry_files:
|
|
440
|
-
remaining = max(0, ENTRY_FILES_TOTAL_LIMIT_BYTES - used_content)
|
|
441
|
-
body, consumed, truncated = _entry_body(source, remaining)
|
|
442
|
-
used_content += consumed
|
|
443
|
-
if body is not None:
|
|
444
|
-
rendered.append(RenderedSourceBody(source, body, truncated))
|
|
455
|
+
rendered.append(RenderedSourceBody(source, body[0], body[1]))
|
|
456
|
+
rendered.extend(_entry_bodies(project.entry_files))
|
|
445
457
|
return tuple(rendered)
|
|
446
458
|
|
|
447
459
|
|
|
460
|
+
def _included_source_bodies(
|
|
461
|
+
bodies: tuple[RenderedSourceBody, ...], included_sections: tuple[str, ...]
|
|
462
|
+
) -> tuple[RenderedSourceBody, ...]:
|
|
463
|
+
included = frozenset(included_sections)
|
|
464
|
+
return tuple(
|
|
465
|
+
body
|
|
466
|
+
for body in bodies
|
|
467
|
+
if (
|
|
468
|
+
"Development Instructions"
|
|
469
|
+
if body.source.name == "AGENTS.md"
|
|
470
|
+
else "Project Overview"
|
|
471
|
+
if body.source.name == "README.md"
|
|
472
|
+
else "Project Entry Files"
|
|
473
|
+
)
|
|
474
|
+
in included
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def rendered_source_contents(
|
|
479
|
+
project: ProjectContext, included_sections: tuple[str, ...]
|
|
480
|
+
) -> tuple[RenderedSourceBody, ...]:
|
|
481
|
+
"""Return source-file bodies under the same budgets used by the Markdown renderer."""
|
|
482
|
+
return _included_source_bodies(_source_bodies(project), included_sections)
|
|
483
|
+
|
|
484
|
+
|
|
448
485
|
def render_markdown(state: RepositoryState, project: ProjectContext) -> bytes:
|
|
449
486
|
"""Render all sections in fixed order without exceeding the global byte limit."""
|
|
450
487
|
return render_markdown_with_details(state, project).output
|
|
File without changes
|
|
File without changes
|