ds4-context-engine 0.3.0-alpha.4 → 0.3.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/docs/ARCHITECTURE.md +5 -4
- package/docs/COMPACTION.md +8 -8
- package/docs/DOGFOODING_0.3.0_ALPHA.md +11 -10
- package/docs/releases/0.3.0-alpha.4.md +5 -3
- package/docs/releases/0.3.0-alpha.5.md +58 -0
- package/package.json +2 -2
- package/src/extension/commands.ts +4 -0
- package/src/pi-adapter/compaction-adapter.ts +107 -23
- package/src/pi-adapter/compaction-coordinator.ts +413 -117
- package/src/pi-adapter/summary-generator.ts +49 -17
- package/src/pi-adapter/version.ts +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ bounded active context with provenance
|
|
|
16
16
|
Pi provider
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
> **Project status:** Stable `0.2.0` includes M0–M20 and frozen 0.2 contracts. Published prerelease `0.3.0-alpha.
|
|
19
|
+
> **Project status:** Stable `0.2.0` includes M0–M20 and frozen 0.2 contracts. Published prerelease `0.3.0-alpha.4` improves `context_persistence` action guidance and privacy-safe validation categories, and moves routine successful compaction lifecycle events to `debug`. Confirmation, canonical records, strict compaction validation, Pi fallback, SQLite schema 15, and runtime contracts remain unchanged. npm `alpha` points to `0.3.0-alpha.4`, `latest` remains `0.2.0`, and the maintenance line targets Pi `0.84.3`.
|
|
20
20
|
|
|
21
21
|
## Why DS4
|
|
22
22
|
|
|
@@ -29,7 +29,7 @@ It provides:
|
|
|
29
29
|
- exact and FTS5 historical retrieval with source provenance;
|
|
30
30
|
- opt-in hybrid semantic retrieval with a deterministic local embedding and lexical fallback;
|
|
31
31
|
- trust-gated structural project indexing, Git-aware invalidation and bounded source snippets;
|
|
32
|
-
- hierarchical, validated, non-destructive compaction summaries;
|
|
32
|
+
- hierarchical, validated, non-destructive compaction summaries with overflow-safe multi-request fan-out/fan-in;
|
|
33
33
|
- persistent pins and append-only durable memory stored canonically in Pi JSONL;
|
|
34
34
|
- a bounded, metadata-only `context_persistence` tool with local confirmation for every model-callable write;
|
|
35
35
|
- opt-in checkpointed project-memory replay across exact trusted Pi project sessions;
|
|
@@ -92,7 +92,7 @@ The stable `0.2.0` packages (`ds4-context-engine`, `ds4-context-core`, and `ds4-
|
|
|
92
92
|
To dogfood the published alpha without replacing a global stable installation, pin it in a disposable project:
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
|
-
pi install -l npm:ds4-context-engine@0.3.0-alpha.
|
|
95
|
+
pi install -l npm:ds4-context-engine@0.3.0-alpha.4
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
Follow the [0.3 alpha dogfooding runbook](docs/DOGFOODING_0.3.0_ALPHA.md); use synthetic data and a dedicated session directory.
|
|
@@ -416,7 +416,7 @@ npm run schema:context-persistence
|
|
|
416
416
|
npm run latency:check -- /path/to/exact/ds4-context-core@0.1.2
|
|
417
417
|
npm run pack:check
|
|
418
418
|
# Post-publication, with an exact version rather than a dist-tag:
|
|
419
|
-
npm run registry:check -- 0.3.0-alpha.
|
|
419
|
+
npm run registry:check -- 0.3.0-alpha.4
|
|
420
420
|
npm pack --dry-run
|
|
421
421
|
npm pack --dry-run --workspace ds4-context-core
|
|
422
422
|
npm pack --dry-run --workspace ds4-context-reference-adapter
|
|
@@ -468,6 +468,7 @@ scripts package and release-readiness checks
|
|
|
468
468
|
- [0.2.0 release readiness](docs/RELEASE_READINESS_0.2.0.md)
|
|
469
469
|
- [0.2.0 release notes](docs/releases/0.2.0.md)
|
|
470
470
|
- [0.2.0-rc.1 release notes](docs/releases/0.2.0-rc.1.md)
|
|
471
|
+
- [0.3.0-alpha.4 prerelease notes](docs/releases/0.3.0-alpha.4.md)
|
|
471
472
|
- [0.3.0-alpha.3 prerelease notes](docs/releases/0.3.0-alpha.3.md)
|
|
472
473
|
- [0.3.0-alpha.2 prerelease notes](docs/releases/0.3.0-alpha.2.md)
|
|
473
474
|
- [0.3.0-alpha.1 prerelease notes](docs/releases/0.3.0-alpha.1.md)
|
|
@@ -478,7 +479,7 @@ scripts package and release-readiness checks
|
|
|
478
479
|
|
|
479
480
|
The original M0–M13 roadmap is complete. `ds4-context-core` contains the compiled runtime-neutral implementation. M14 context-quality metrics, M15 rich symbol indexing, M16 hybrid semantic retrieval, M17 cross-session project memory, M18 learned-ranking shadow evaluation, M19's runtime adapter/conformance kit, and M20 opt-in local KV eligibility/replay are implemented on `main`. Learned active ranking remains promotion-gated, Pi reports local KV as unsupported, and static ranking/native completion stay authoritative on every failure.
|
|
480
481
|
|
|
481
|
-
The [0.2.0 roadmap](docs/ROADMAP_0.2.0.md) is complete. Prerelease `0.3.0-alpha.
|
|
482
|
+
The [0.2.0 roadmap](docs/ROADMAP_0.2.0.md) is complete. Prerelease `0.3.0-alpha.4` builds on the prior [context persistence tool](docs/CONTEXT_PERSISTENCE_TOOL.md) and privacy-safe [compaction](docs/COMPACTION.md) hardening with clearer action-specific failures and quieter routine lifecycle logs. Confirmation, exact targeting, strict summary grounding, Pi fallback, and stable canonical/configuration/SQLite/runtime contracts remain unchanged. The [0.2 readiness record](docs/RELEASE_READINESS_0.2.0.md) remains the compatibility baseline. Sensitive or transport-specific behavior remains opt-in, and the 0.1 lexical planner stays available as the deterministic fallback.
|
|
482
483
|
|
|
483
484
|
## Contributing
|
|
484
485
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -101,12 +101,13 @@ agent_settled
|
|
|
101
101
|
session_before_compact
|
|
102
102
|
-> map Pi preparation messages to exact canonical entry IDs
|
|
103
103
|
-> privacy-sanitize discarded text/instructions/file lists for the active provider
|
|
104
|
-
->
|
|
105
|
-
->
|
|
104
|
+
-> preflight the complete sanitized request against the calibrated model input budget
|
|
105
|
+
-> when needed, split at contiguous message boundaries while keeping matching tool calls/results atomic
|
|
106
|
+
-> generate and validate each immutable segment against only its own sanitized evidence
|
|
106
107
|
-> resolve the prior root from the active Pi branch only
|
|
107
|
-
-> generate and validate
|
|
108
|
+
-> recursively generate and validate bounded ordered aggregate layers until one root remains
|
|
108
109
|
-> wrap generated nodes with their highest inherited classification for provider-switch safety
|
|
109
|
-
-> atomically persist prepared
|
|
110
|
+
-> atomically persist the complete prepared graph and return only one active root to Pi
|
|
110
111
|
|
|
111
112
|
session_compact / session_compact_failed
|
|
112
113
|
-> commit or fail prepared summary lifecycle
|
package/docs/COMPACTION.md
CHANGED
|
@@ -7,13 +7,13 @@ DS4 intercepts Pi's `session_before_compact` event but preserves Pi's cut-point
|
|
|
7
7
|
1. Pi determines `messagesToSummarize`, optional split-turn prefix, and `firstKeptEntryId`.
|
|
8
8
|
2. DS4 maps every source message by exact fingerprint to a canonical branch entry ID.
|
|
9
9
|
3. Pi's serializer converts the newly discarded span to bounded conversation text; enabled privacy policy sanitizes conversation, custom instructions, and file paths for the active provider.
|
|
10
|
-
4.
|
|
11
|
-
5.
|
|
12
|
-
6. The
|
|
13
|
-
7. Pi appends
|
|
14
|
-
8. `session_compact` commits all nodes and associates the active root with the Pi entry; failure marks the prepared batch `failed`.
|
|
10
|
+
4. DS4 estimates the complete sanitized summary request against the calibrated active-model input budget. If the whole request does not fit, it partitions the source into ordered contiguous segments. Individual messages are indivisible, and every tool call remains in the same atomic group as all matching results; an exceptionally large turn may split only between such groups.
|
|
11
|
+
5. The active model generates and validates each immutable segment summary only against that segment's sanitized evidence, with cache retention disabled and a fresh routing session ID per call.
|
|
12
|
+
6. DS4 recursively aggregates ordered child summaries in bounded fan-in calls until one root remains. The ordered roots include the previous branch summary, when present, followed by every new segment. DS4-generated IDs, hashes, kinds, and graph levels remain outside model-visible evidence. A Pi-native predecessor is imported as an explicitly unverified branch node.
|
|
13
|
+
7. The highest input classification is wrapped around each generated node, then all nodes are persisted atomically as one `prepared` graph batch. Usage is summed across every segment and aggregate request; Pi receives only the final root text and still appends exactly one canonical `CompactionEntry` with `fromHook: true`.
|
|
14
|
+
8. `session_compact` commits all nodes and associates the active root with the Pi entry; failure marks the complete prepared batch `failed`.
|
|
15
15
|
|
|
16
|
-
Any mapping, model, output-limit, validation, or storage error returns `undefined` from the hook, allowing Pi's default compaction to run.
|
|
16
|
+
Fan-out and fan-in are bounded to 32 segment requests, 64 aggregate requests, and 16 aggregate passes. A base prompt, individual message, atomic tool exchange, pair of child summaries, or total operation that cannot fit within those limits fails closed. Any mapping, budget, model, output-limit, validation, abort, or storage error returns `undefined` from the hook, allowing Pi's default compaction to run.
|
|
17
17
|
|
|
18
18
|
## Required summary contract
|
|
19
19
|
|
|
@@ -32,7 +32,7 @@ Any mapping, model, output-limit, validation, or storage error returns `undefine
|
|
|
32
32
|
## Critical Exact Values
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Every section must occur once, in order, and contain content or `- None`. DS4 replaces each unique `Files Read` and `Files Modified` section with one exact path per bullet from Pi's sanitized file-operation inventory before validation; missing or duplicate sections still fail. Backticked exact values must occur in the serialized segment source, ordered child-summary content, or those known file-operation paths. A bounded unsupported exact-value bullet is removed as a whole and recorded as a validation warning; unsupported prose, more than eight affected bullets, or removal above 25% still fails closed to Pi. The prompt explicitly asks the model to omit a bullet whose complete backticked span cannot be copied verbatim.
|
|
35
|
+
Every section must occur once, in order, and contain content or `- None`. DS4 replaces each unique `Files Read` and `Files Modified` section with one exact path per bullet from Pi's sanitized file-operation inventory before validation; missing or duplicate sections still fail. Backticked exact values must occur in the serialized segment source, ordered child-summary content, or those known file-operation paths. A bounded unsupported exact-value bullet is removed as a whole and recorded as a validation warning; unsupported prose, more than eight affected bullets, or removal above 25% still fails closed to Pi. The prompt explicitly asks the model to omit a bullet whose complete backticked span cannot be copied verbatim. Every segment is validated independently against only its own sanitized source and deterministic file inventory. Every aggregate is validated independently against only the sanitized content of its ordered children and cumulative deterministic file inventory. Any unrepaired failure prevents the whole graph batch from being installed.
|
|
36
36
|
|
|
37
37
|
An unrepaired exact-value failure reports only the stage, issue code, categorical repair status, unsupported-span count, and affected-bullet count. Repair statuses distinguish an unsupported location, more than eight bullets, removal above 25%, and an unexpected invalid second validation. The disputed text is intentionally absent from logs, UI notifications, and diagnostics because it may contain sensitive source material.
|
|
38
38
|
|
|
@@ -76,4 +76,4 @@ It requests compaction at most once per session leaf. Pi's native threshold and
|
|
|
76
76
|
/context summaries
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
The preview reports thresholds and eligibility; Pi remains authoritative for the exact cut point. Routine `summary_graph_prepared` and `summary_graph_committed` lifecycle events are emitted only at `debug`; fallback, failure, persistence, and reconciliation problems remain actionable warnings. The proactive-threshold TUI notification remains a user-visible `info` notice because it explains why an automatic compaction started.
|
|
79
|
+
The preview reports thresholds and eligibility; Pi remains authoritative for the exact cut point. Runtime diagnostics additionally report the calibrated input budget, estimated whole-source prompt size, generated segment count, and aggregate-call count without source content. Routine `summary_graph_prepared` and `summary_graph_committed` lifecycle events are emitted only at `debug`; fallback, failure, persistence, and reconciliation problems remain actionable warnings. Oversized-group and bounded-operation errors expose only numeric budgets/counts, never rejected source text. Provider failures are reduced to metadata-only categories such as `input-limit`, `usage-limit`, `rate-limit`, `authentication`, or `transport`; raw provider error details are not logged or shown. The proactive-threshold TUI notification remains a user-visible `info` notice because it explains why an automatic compaction started.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dogfooding DS4 0.3.0 Alpha
|
|
2
2
|
|
|
3
|
-
This runbook validates the published `ds4-context-engine@0.3.0-alpha.
|
|
3
|
+
This runbook validates the published `ds4-context-engine@0.3.0-alpha.4` package through sustained real Pi use. It complements automated tests and release smoke checks; it does not replace them.
|
|
4
4
|
|
|
5
5
|
The primary target is the model-callable `context_persistence` surface. Pi JSONL must remain canonical and append-only, SQLite must remain rebuildable, and no model-callable write may occur without a fresh positive local UI decision.
|
|
6
6
|
|
|
@@ -10,7 +10,8 @@ The primary target is the model-callable `context_persistence` surface. Pi JSONL
|
|
|
10
10
|
- Use a disposable trusted project and a dedicated session directory.
|
|
11
11
|
- Use synthetic, non-secret Pin/Memory content. Local TUI dialogs and JSON event streams may display current tool arguments.
|
|
12
12
|
- Published alpha.1 had a retry limitation: copying `[omitted-by-ds4-egress-policy]` from sanitized history could present a confirmation for the literal marker. Alpha.2 and later reserve that output-only marker and must reject it as `egress-placeholder` before runtime access, confirmation, canonical append, or derived-policy update.
|
|
13
|
-
- Alpha.3
|
|
13
|
+
- Alpha.3 and later keep exact-value summary validation strict. An unrepaired failure may expose only its stage, issue code, repair category, and counts; disputed exact text must not appear in diagnostics.
|
|
14
|
+
- Alpha.4 distinguishes unsupported and missing action parameters without echoing rejected fields or values. Routine successful compaction lifecycle events require `diagnostics.logLevel=debug`; actionable fallback warnings remain visible.
|
|
14
15
|
- Do not use `--no-session` except for the explicit fail-closed test. Without a persistent Pi JSONL destination, both reads and writes return `runtime-unavailable`.
|
|
15
16
|
- Do not retry `committed_projection_pending` or `indeterminate`. Inspect state with a read, `/context health`, or `/context rebuild-index` first.
|
|
16
17
|
- Use `/context` only for local inspection and recovery. Mutations under test must go through `context_persistence` so the confirmation and provider-egress boundaries are exercised.
|
|
@@ -27,7 +28,7 @@ mkdir -p /tmp/ds4-alpha-dogfood
|
|
|
27
28
|
cd /tmp/ds4-alpha-dogfood
|
|
28
29
|
git init
|
|
29
30
|
mkdir -p sessions evidence
|
|
30
|
-
pi install -l npm:ds4-context-engine@0.3.0-alpha.
|
|
31
|
+
pi install -l npm:ds4-context-engine@0.3.0-alpha.4
|
|
31
32
|
pi list
|
|
32
33
|
pi --version
|
|
33
34
|
```
|
|
@@ -47,19 +48,19 @@ Record the Pi version, DS4 version, provider/model, mode, session name, expected
|
|
|
47
48
|
Use unique non-sensitive values so duplicates from earlier runs cannot hide a failure. Example run label:
|
|
48
49
|
|
|
49
50
|
```text
|
|
50
|
-
|
|
51
|
+
alpha4-run-01
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
Example Pin:
|
|
54
55
|
|
|
55
56
|
```text
|
|
56
|
-
For
|
|
57
|
+
For alpha4-run-01 verification, use Node.js 22 in this disposable project.
|
|
57
58
|
```
|
|
58
59
|
|
|
59
60
|
Example Memory:
|
|
60
61
|
|
|
61
62
|
```text
|
|
62
|
-
For
|
|
63
|
+
For alpha4-run-01, the synthetic release channel is amber.
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
Never use real credentials, customer data, private paths, or production policy in dogfooding prompts.
|
|
@@ -77,7 +78,7 @@ Run these scenarios in order:
|
|
|
77
78
|
|
|
78
79
|
1. Inspect `/context health`, `/context pins`, `/context memory`, and `/context privacy`.
|
|
79
80
|
2. Send an ordinary suggestion without asking to persist it, for example: `The synthetic release channel amber seems useful.` No `context_persistence` call or confirmation dialog should appear.
|
|
80
|
-
3. Explicitly request the synthetic Memory: `Remember for this session that the synthetic release channel for
|
|
81
|
+
3. Explicitly request the synthetic Memory: `Remember for this session that the synthetic release channel for alpha4-run-01 is amber.` Verify that the dialog identifies the action and canonical persistence class. Accept it. Expect one committed Memory mutation.
|
|
81
82
|
4. Ask the model to use `context_persistence` to list active Memory. Verify bounded metadata and no complete claim, key, reason, path, or raw error in the result.
|
|
82
83
|
5. Explicitly request the synthetic Pin, but reject or close the confirmation dialog. Verify with `/context pins` that it was not created.
|
|
83
84
|
6. Ask the model to retry using the sanitized value remaining in history. If it copies `[omitted-by-ds4-egress-policy]`, expect `rejected / egress-placeholder` before any new confirmation, runtime mutation, or append. If it asks for fresh text instead, record that safe routing result and run the exact-marker case from the JSON procedure.
|
|
@@ -116,7 +117,7 @@ Enter one JSON object per line on stdin. First request a read:
|
|
|
116
117
|
Wait for the turn to end before sending the next prompt. For a write:
|
|
117
118
|
|
|
118
119
|
```json
|
|
119
|
-
{"id":"write-1","type":"prompt","message":"Persist a session Pin for
|
|
120
|
+
{"id":"write-1","type":"prompt","message":"Persist a session Pin for alpha4-run-01 stating that this disposable project uses Node.js 22 for verification."}
|
|
120
121
|
```
|
|
121
122
|
|
|
122
123
|
Pi should emit a request shaped like:
|
|
@@ -168,7 +169,7 @@ The read should complete. Then request a write:
|
|
|
168
169
|
|
|
169
170
|
```bash
|
|
170
171
|
pi -p --approve --session-dir "$DS4_DOGFOOD_SESSIONS" \
|
|
171
|
-
"Use context_persistence to add a session Memory saying that
|
|
172
|
+
"Use context_persistence to add a session Memory saying that alpha4-run-01 uses the synthetic channel amber."
|
|
172
173
|
```
|
|
173
174
|
|
|
174
175
|
Expected behavior:
|
|
@@ -198,7 +199,7 @@ Write case:
|
|
|
198
199
|
|
|
199
200
|
```bash
|
|
200
201
|
pi --mode json --approve --session-dir "$DS4_DOGFOOD_SESSIONS" \
|
|
201
|
-
"Use context_persistence to add a session Pin for
|
|
202
|
+
"Use context_persistence to add a session Pin for alpha4-run-01 stating that this disposable project uses Node.js 22." \
|
|
202
203
|
2>evidence/json-write.stderr.log | tee evidence/json-write.jsonl
|
|
203
204
|
```
|
|
204
205
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DS4 Context Engine 0.3.0-alpha.4
|
|
2
2
|
|
|
3
|
-
Status:
|
|
3
|
+
Status: published prerelease on 2026-09-02; tag `v0.3.0-alpha.4`.
|
|
4
4
|
|
|
5
5
|
This coordinated prerelease improves model-callable `context_persistence` guidance and categorical validation diagnostics, and removes routine successful compaction lifecycle events from normal `info` output. It preserves strict persistence confirmation, canonical Pi JSONL semantics, privacy-safe egress, compaction validation, and Pi fallback behavior.
|
|
6
6
|
|
|
@@ -29,7 +29,7 @@ ds4-context-reference-adapter
|
|
|
29
29
|
ds4-context-engine
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Both adapters depend exactly on `ds4-context-core@0.3.0-alpha.4`.
|
|
32
|
+
Both adapters depend exactly on `ds4-context-core@0.3.0-alpha.4`. The packages were published manually under the explicit npm `alpha` dist-tag while `latest` remains `0.2.0`. GitHub Actions remains validation-only with OIDC and package-write permissions denied.
|
|
33
33
|
|
|
34
34
|
## Validation evidence
|
|
35
35
|
|
|
@@ -48,7 +48,9 @@ Local candidate verification on Node.js `26.5.1`:
|
|
|
48
48
|
- `git diff --check`: passed.
|
|
49
49
|
- Protected CI, compatibility golden, Pi fixture, migration, canonical Pin/Memory, and persistence result-contract files: unchanged.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
- `npm run registry:check -- 0.3.0-alpha.4`: passed against all three exact published versions; `alpha` resolves to `0.3.0-alpha.4` and `latest` remains `0.2.0` for every package.
|
|
52
|
+
|
|
53
|
+
Exact registry verification passed before the annotated tag and GitHub prerelease were created.
|
|
52
54
|
|
|
53
55
|
## Documentation
|
|
54
56
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# DS4 Context Engine 0.3.0-alpha.5
|
|
2
|
+
|
|
3
|
+
Status: validated release candidate; not yet published.
|
|
4
|
+
|
|
5
|
+
This coordinated prerelease adds overflow-safe hierarchical compaction for discarded source that cannot fit in one active-model summary request. It preserves strict summary validation, privacy sanitization, canonical Pi JSONL semantics, one final Pi compaction entry, and fail-closed fallback to Pi default compaction.
|
|
6
|
+
|
|
7
|
+
## Added
|
|
8
|
+
|
|
9
|
+
- Preflights the complete sanitized compaction prompt against the calibrated active-model input budget.
|
|
10
|
+
- Partitions oversized discarded source at contiguous message boundaries while keeping matching tool calls and results in the same atomic group.
|
|
11
|
+
- Generates and validates each segment only against that segment's sanitized source and deterministic file inventory.
|
|
12
|
+
- Recursively aggregates ordered child summaries when one fan-in request cannot fit, preserving cumulative provenance and graph topology.
|
|
13
|
+
- Bounds work to 32 segment requests, 64 aggregate requests, and 16 aggregate passes.
|
|
14
|
+
- Exposes metadata-only diagnostics for input budget, whole-source prompt estimate, segment count, and aggregate-call count.
|
|
15
|
+
- Categorizes provider failures as `input-limit`, `usage-limit`, `rate-limit`, `authentication`, `transport`, `aborted`, or `provider-error` without surfacing provider payloads.
|
|
16
|
+
|
|
17
|
+
## Safety and compatibility
|
|
18
|
+
|
|
19
|
+
An individual message, atomic tool exchange, aggregate pair, or total operation that cannot fit within the bounded limits fails closed to Pi default compaction. Generated segment and aggregate summaries retain strict structural and exact-value validation; bounded unsupported-exact-value bullet repair remains unchanged. Rejected exact values and raw provider error details are never emitted in diagnostics.
|
|
20
|
+
|
|
21
|
+
Pi receives one final canonical compaction entry. The complete prepared summary-graph batch is committed only after Pi installs that entry; failed attempts remain non-destructive. Pi JSONL stays canonical and append-only, while SQLite remains a disposable projection. Schema 15, migrations 1–15, `ds4-context-config-v1`, `runtime-adapter-v1`, `ds4-context-persistence-tool-v1`, and `ds4-context-persistence-result-v1` are unchanged.
|
|
22
|
+
|
|
23
|
+
## Package/version policy
|
|
24
|
+
|
|
25
|
+
The coordinated version is `0.3.0-alpha.5` for:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
ds4-context-core
|
|
29
|
+
ds4-context-reference-adapter
|
|
30
|
+
ds4-context-engine
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Both adapters depend exactly on `ds4-context-core@0.3.0-alpha.5`. Publication must use the explicit npm `alpha` dist-tag so `latest` remains `0.2.0`. GitHub Actions remains validation-only with OIDC and package-write permissions denied.
|
|
34
|
+
|
|
35
|
+
## Validation evidence
|
|
36
|
+
|
|
37
|
+
Local candidate verification on Node.js `26.5.1`:
|
|
38
|
+
|
|
39
|
+
- `npm run check`: 65 files and 297 tests passed.
|
|
40
|
+
- Focused compaction coverage includes three-segment fan-out, recursive fan-in, tool-exchange atomicity, exact contiguous source slicing, oversized indivisible-source fallback, and privacy-safe provider input-limit categorization.
|
|
41
|
+
- `npm run quality:compare`: candidate quality `0.9875` versus baseline `0.808156`.
|
|
42
|
+
- `npm run schema:context-persistence`: 1,266 bytes and 317 estimated tokens; below the 1,500 absolute and 320 relative limits.
|
|
43
|
+
- `npm run latency:check -- <exact ds4-context-core@0.1.2>`: isolated clean-checkout retry passed with ratio `1.041564`, at or below `1.10`; an immediately preceding noisy sample measured `1.157479`, while the pre-commit local sample measured `0.939056`.
|
|
44
|
+
- `npm run pack:check`: verified core (207 files), reference adapter (7 files), and Pi adapter (62 files) in a clean consumer.
|
|
45
|
+
- `npm pack --dry-run --json` for all three packages passed with the same bounded inventories and no forbidden local/session files.
|
|
46
|
+
- The committed candidate was replayed from detached clean checkout `8b609c0`; `npm ci`, the 65-file/297-test suite, quality, schema, package verification, tarball review, latency retry, and `git diff --check` passed.
|
|
47
|
+
- `git diff --check`: passed.
|
|
48
|
+
- Protected CI, compatibility golden, Pi fixture, migration, canonical Pin/Memory, persistence confirmation, and persistence result-contract files are unchanged.
|
|
49
|
+
|
|
50
|
+
Exact registry verification is required before tag or GitHub prerelease creation.
|
|
51
|
+
|
|
52
|
+
## Documentation
|
|
53
|
+
|
|
54
|
+
- [`../COMPACTION.md`](../COMPACTION.md)
|
|
55
|
+
- [`../ARCHITECTURE.md`](../ARCHITECTURE.md)
|
|
56
|
+
- [`../PRIVACY.md`](../PRIVACY.md)
|
|
57
|
+
- [`../RELEASING.md`](../RELEASING.md)
|
|
58
|
+
- [`0.3.0-alpha.4.md`](0.3.0-alpha.4.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ds4-context-engine",
|
|
3
|
-
"version": "0.3.0-alpha.
|
|
3
|
+
"version": "0.3.0-alpha.5",
|
|
4
4
|
"description": "Non-destructive, provider-independent context management for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
]
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"ds4-context-core": "0.3.0-alpha.
|
|
61
|
+
"ds4-context-core": "0.3.0-alpha.5"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"@earendil-works/pi-ai": "0.84.3",
|
|
@@ -734,6 +734,10 @@ function formatCompaction(diagnostics: RuntimeDiagnostics, preview: boolean): st
|
|
|
734
734
|
`Last trigger: ${compaction.trigger ?? "n/a"}`,
|
|
735
735
|
`Summary ID: ${compaction.summaryId ?? "n/a"}`,
|
|
736
736
|
`Source entries: ${count(compaction.sourceEntries)}`,
|
|
737
|
+
`Input budget: ${count(compaction.inputBudgetTokens)}`,
|
|
738
|
+
`Whole-source prompt: ${count(compaction.sourcePromptTokens)}`,
|
|
739
|
+
`Generated segments: ${count(compaction.segmentCount)}`,
|
|
740
|
+
`Aggregate calls: ${count(compaction.aggregateCalls)}`,
|
|
737
741
|
`Validation: ${compaction.validationStatus ?? "n/a"}`,
|
|
738
742
|
`First kept entry: ${compaction.firstKeptEntryId ?? "n/a"}`,
|
|
739
743
|
`Tokens before: ${count(compaction.tokensBefore)}`,
|
|
@@ -19,14 +19,22 @@ interface SourceCandidate {
|
|
|
19
19
|
used: boolean;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
type CompactionMessage = SessionBeforeCompactEvent["preparation"]["messagesToSummarize"][number];
|
|
23
|
+
|
|
24
|
+
export interface PreparedCompactionSourceSlice {
|
|
25
|
+
messages: CompactionMessage[];
|
|
24
26
|
conversationText: string;
|
|
25
27
|
sourceText: string;
|
|
26
28
|
sourceEntryIds: string[];
|
|
27
29
|
sourceHash: string;
|
|
28
30
|
segmentReadFiles: string[];
|
|
29
31
|
segmentModifiedFiles: string[];
|
|
32
|
+
isSplitTurn: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PreparedCompactionSource extends PreparedCompactionSourceSlice {
|
|
36
|
+
messageEntryIds: string[];
|
|
37
|
+
turnPrefixStartIndex: number;
|
|
30
38
|
readFiles: string[];
|
|
31
39
|
modifiedFiles: string[];
|
|
32
40
|
previousSummary?: string;
|
|
@@ -53,7 +61,7 @@ function mapSourceEntryIds(messages: readonly unknown[], entries: readonly Sessi
|
|
|
53
61
|
const match = available.find((candidate) => !candidate.used && candidate.fingerprint === fingerprint(message));
|
|
54
62
|
if (!match) throw new Error("Compaction source message has no exact canonical Pi session entry");
|
|
55
63
|
match.used = true;
|
|
56
|
-
|
|
64
|
+
ids.push(match.entryId);
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
return ids;
|
|
@@ -69,8 +77,93 @@ function previousFiles(entries: readonly SessionEntry[]): { read: string[]; modi
|
|
|
69
77
|
return { read: [], modified: [] };
|
|
70
78
|
}
|
|
71
79
|
|
|
80
|
+
function unique(values: Iterable<string>): string[] {
|
|
81
|
+
return [...new Set(values)];
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
function sortedUnique(values: Iterable<string>): string[] {
|
|
73
|
-
return
|
|
85
|
+
return unique(values).sort((left, right) => left.localeCompare(right));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function segmentFiles(messages: readonly CompactionMessage[]): { readFiles: string[]; modifiedFiles: string[] } {
|
|
89
|
+
const read = new Set<string>();
|
|
90
|
+
const modified = new Set<string>();
|
|
91
|
+
for (const message of messages) {
|
|
92
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
93
|
+
for (const block of message.content) {
|
|
94
|
+
if (block.type !== "toolCall" || !block.arguments || typeof block.arguments !== "object") continue;
|
|
95
|
+
const path = "path" in block.arguments && typeof block.arguments.path === "string"
|
|
96
|
+
? block.arguments.path
|
|
97
|
+
: undefined;
|
|
98
|
+
if (!path) continue;
|
|
99
|
+
if (block.name === "read") read.add(path);
|
|
100
|
+
if (block.name === "write" || block.name === "edit") modified.add(path);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
readFiles: sortedUnique([...read].filter((path) => !modified.has(path))),
|
|
105
|
+
modifiedFiles: sortedUnique(modified),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createSourceSlice(input: {
|
|
110
|
+
messages: CompactionMessage[];
|
|
111
|
+
sourceEntryIds: string[];
|
|
112
|
+
segmentReadFiles: string[];
|
|
113
|
+
segmentModifiedFiles: string[];
|
|
114
|
+
isSplitTurn: boolean;
|
|
115
|
+
}): PreparedCompactionSourceSlice {
|
|
116
|
+
const conversationText = serializeConversation(convertToLlm(input.messages));
|
|
117
|
+
const canonicalSourceText = stableStringify(input.messages);
|
|
118
|
+
const sourceEntryIds = unique(input.sourceEntryIds);
|
|
119
|
+
const sourceText = [
|
|
120
|
+
canonicalSourceText,
|
|
121
|
+
conversationText,
|
|
122
|
+
input.segmentReadFiles.join("\n"),
|
|
123
|
+
input.segmentModifiedFiles.join("\n"),
|
|
124
|
+
].filter(Boolean).join("\n\n");
|
|
125
|
+
return {
|
|
126
|
+
messages: input.messages,
|
|
127
|
+
conversationText,
|
|
128
|
+
sourceText,
|
|
129
|
+
sourceEntryIds,
|
|
130
|
+
sourceHash: computeSummarySourceHash({
|
|
131
|
+
conversationText: canonicalSourceText,
|
|
132
|
+
sourceEntryIds,
|
|
133
|
+
readFiles: input.segmentReadFiles,
|
|
134
|
+
modifiedFiles: input.segmentModifiedFiles,
|
|
135
|
+
}),
|
|
136
|
+
segmentReadFiles: input.segmentReadFiles,
|
|
137
|
+
segmentModifiedFiles: input.segmentModifiedFiles,
|
|
138
|
+
isSplitTurn: input.isSplitTurn,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function sliceCompactionSource(
|
|
143
|
+
source: PreparedCompactionSource,
|
|
144
|
+
messageIndices: readonly number[],
|
|
145
|
+
): PreparedCompactionSourceSlice {
|
|
146
|
+
const indices = [...new Set(messageIndices)].sort((left, right) => left - right);
|
|
147
|
+
if (indices.length === 0) throw new Error("Compaction segment contains no source messages");
|
|
148
|
+
for (let offset = 0; offset < indices.length; offset++) {
|
|
149
|
+
const index = indices[offset];
|
|
150
|
+
if (index === undefined || !source.messages[index] || !source.messageEntryIds[index]) {
|
|
151
|
+
throw new Error("Compaction segment references an unavailable source message");
|
|
152
|
+
}
|
|
153
|
+
if (offset > 0 && index !== (indices[offset - 1] ?? -1) + 1) {
|
|
154
|
+
throw new Error("Compaction segment source messages must be contiguous");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const messages = indices.map((index) => source.messages[index] as CompactionMessage);
|
|
158
|
+
const files = segmentFiles(messages);
|
|
159
|
+
return createSourceSlice({
|
|
160
|
+
messages,
|
|
161
|
+
sourceEntryIds: indices.map((index) => source.messageEntryIds[index] as string),
|
|
162
|
+
segmentReadFiles: files.readFiles,
|
|
163
|
+
segmentModifiedFiles: files.modifiedFiles,
|
|
164
|
+
isSplitTurn: source.isSplitTurn
|
|
165
|
+
&& indices.some((index) => index >= source.turnPrefixStartIndex),
|
|
166
|
+
});
|
|
74
167
|
}
|
|
75
168
|
|
|
76
169
|
export function findActiveBranchSummary(
|
|
@@ -109,7 +202,8 @@ export function prepareCompactionSource(event: SessionBeforeCompactEvent): Prepa
|
|
|
109
202
|
];
|
|
110
203
|
if (messages.length === 0) throw new Error("Pi compaction preparation contains no source messages");
|
|
111
204
|
|
|
112
|
-
const
|
|
205
|
+
const messageEntryIds = mapSourceEntryIds(messages, event.branchEntries);
|
|
206
|
+
const sourceEntryIds = unique(messageEntryIds);
|
|
113
207
|
const priorFiles = previousFiles(event.branchEntries);
|
|
114
208
|
const segmentReadFiles = sortedUnique(event.preparation.fileOps.read);
|
|
115
209
|
const segmentModifiedFiles = sortedUnique([
|
|
@@ -118,30 +212,20 @@ export function prepareCompactionSource(event: SessionBeforeCompactEvent): Prepa
|
|
|
118
212
|
]);
|
|
119
213
|
const readFiles = sortedUnique([...priorFiles.read, ...segmentReadFiles]);
|
|
120
214
|
const modifiedFiles = sortedUnique([...priorFiles.modified, ...segmentModifiedFiles]);
|
|
121
|
-
const conversationText = serializeConversation(convertToLlm(messages));
|
|
122
|
-
const canonicalSourceText = stableStringify(messages);
|
|
123
215
|
const previousSummary = event.preparation.previousSummary;
|
|
124
216
|
const previousNode = findActiveBranchSummary(event.branchEntries, previousSummary);
|
|
125
|
-
const
|
|
126
|
-
canonicalSourceText,
|
|
127
|
-
conversationText,
|
|
128
|
-
segmentReadFiles.join("\n"),
|
|
129
|
-
segmentModifiedFiles.join("\n"),
|
|
130
|
-
].filter(Boolean).join("\n\n");
|
|
131
|
-
|
|
132
|
-
return {
|
|
217
|
+
const prepared = createSourceSlice({
|
|
133
218
|
messages,
|
|
134
|
-
conversationText,
|
|
135
|
-
sourceText,
|
|
136
219
|
sourceEntryIds,
|
|
137
|
-
sourceHash: computeSummarySourceHash({
|
|
138
|
-
conversationText: canonicalSourceText,
|
|
139
|
-
sourceEntryIds,
|
|
140
|
-
readFiles: segmentReadFiles,
|
|
141
|
-
modifiedFiles: segmentModifiedFiles,
|
|
142
|
-
}),
|
|
143
220
|
segmentReadFiles,
|
|
144
221
|
segmentModifiedFiles,
|
|
222
|
+
isSplitTurn: event.preparation.isSplitTurn,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
...prepared,
|
|
227
|
+
messageEntryIds,
|
|
228
|
+
turnPrefixStartIndex: event.preparation.messagesToSummarize.length,
|
|
145
229
|
readFiles,
|
|
146
230
|
modifiedFiles,
|
|
147
231
|
...(previousSummary ? { previousSummary } : {}),
|
|
@@ -14,7 +14,14 @@ import {
|
|
|
14
14
|
highestClassification,
|
|
15
15
|
type PrivacyClassification,
|
|
16
16
|
} from "ds4-context-core/privacy/privacy-policy";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
prepareCompactionSource,
|
|
19
|
+
sliceCompactionSource,
|
|
20
|
+
type PreparedCompactionSource,
|
|
21
|
+
type PreparedCompactionSourceSlice,
|
|
22
|
+
} from "./compaction-adapter.ts";
|
|
23
|
+
import { buildCompactionAtomicGroups } from "ds4-context-core/compaction/segmentation";
|
|
24
|
+
import { estimateMessageTokens } from "ds4-context-core/core/token-estimator";
|
|
18
25
|
import { adaptiveRecentTailLimit } from "ds4-context-core/planner/context-planner";
|
|
19
26
|
import type { Logger } from "ds4-context-core/shared/logging";
|
|
20
27
|
import {
|
|
@@ -42,6 +49,10 @@ import {
|
|
|
42
49
|
type SummaryValidationStatus,
|
|
43
50
|
} from "ds4-context-core/compaction/summary-contract";
|
|
44
51
|
|
|
52
|
+
const MAX_SEGMENT_REQUESTS = 32;
|
|
53
|
+
const MAX_AGGREGATE_REQUESTS = 64;
|
|
54
|
+
const MAX_AGGREGATE_LEVELS = 16;
|
|
55
|
+
|
|
45
56
|
export type CompactionPhase =
|
|
46
57
|
| "idle"
|
|
47
58
|
| "requested"
|
|
@@ -66,6 +77,10 @@ export interface CompactionDiagnostics {
|
|
|
66
77
|
requestedAt?: number;
|
|
67
78
|
completedAt?: number;
|
|
68
79
|
lastError?: string;
|
|
80
|
+
inputBudgetTokens?: number;
|
|
81
|
+
sourcePromptTokens?: number;
|
|
82
|
+
segmentCount?: number;
|
|
83
|
+
aggregateCalls?: number;
|
|
69
84
|
contextTokens?: number;
|
|
70
85
|
softLimitTokens?: number;
|
|
71
86
|
proactiveThresholdTokens?: number;
|
|
@@ -108,6 +123,26 @@ export interface SessionCompactFailedLike {
|
|
|
108
123
|
fromExtension: boolean;
|
|
109
124
|
}
|
|
110
125
|
|
|
126
|
+
interface SegmentGenerationPlan {
|
|
127
|
+
source: PreparedCompactionSourceSlice;
|
|
128
|
+
prompt: string;
|
|
129
|
+
validationSource: string;
|
|
130
|
+
readFiles: string[];
|
|
131
|
+
modifiedFiles: string[];
|
|
132
|
+
classification: PrivacyClassification;
|
|
133
|
+
promptTokens: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface AggregateGenerationPlan {
|
|
137
|
+
children: EmbeddedSummaryNode[];
|
|
138
|
+
prompt: string;
|
|
139
|
+
validationSource: string;
|
|
140
|
+
readFiles: string[];
|
|
141
|
+
modifiedFiles: string[];
|
|
142
|
+
classification: PrivacyClassification;
|
|
143
|
+
promptTokens: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
111
146
|
interface CompactionCoordinatorDependencies {
|
|
112
147
|
config: Ds4ContextConfig;
|
|
113
148
|
database?: ContextDatabase;
|
|
@@ -176,7 +211,8 @@ export class CompactionCoordinator {
|
|
|
176
211
|
ctx: ExtensionContext,
|
|
177
212
|
): Promise<{ compaction?: CompactionResult } | undefined> {
|
|
178
213
|
const config = this.dependencies.config;
|
|
179
|
-
|
|
214
|
+
const model = ctx.model;
|
|
215
|
+
if (!config.compaction.enabled || !config.enabled || !model || config.context.maxSummaryTokens <= 0) {
|
|
180
216
|
return undefined;
|
|
181
217
|
}
|
|
182
218
|
|
|
@@ -193,7 +229,24 @@ export class CompactionCoordinator {
|
|
|
193
229
|
try {
|
|
194
230
|
this.dependencies.syncSessionIndex(ctx);
|
|
195
231
|
const source = prepareCompactionSource(event);
|
|
196
|
-
|
|
232
|
+
const inputBudgetTokens = this.inputBudgetTokens(model);
|
|
233
|
+
if (inputBudgetTokens <= 0) throw new Error("Active model has no safe compaction input budget");
|
|
234
|
+
const wholePlan = this.buildSegmentPlan(source, event, model.provider);
|
|
235
|
+
this.state = {
|
|
236
|
+
...this.state,
|
|
237
|
+
sourceEntries: source.sourceEntryIds.length,
|
|
238
|
+
inputBudgetTokens,
|
|
239
|
+
sourcePromptTokens: wholePlan.promptTokens,
|
|
240
|
+
};
|
|
241
|
+
const segmentPlans = this.partitionSegmentPlans(
|
|
242
|
+
source,
|
|
243
|
+
wholePlan,
|
|
244
|
+
event,
|
|
245
|
+
model.provider,
|
|
246
|
+
inputBudgetTokens,
|
|
247
|
+
);
|
|
248
|
+
this.state.segmentCount = segmentPlans.length;
|
|
249
|
+
|
|
197
250
|
const usedIds = new Set(this.graphRecords.keys());
|
|
198
251
|
if (source.previousNode) usedIds.add(source.previousNode.id);
|
|
199
252
|
const nextId = (): string => {
|
|
@@ -208,129 +261,70 @@ export class CompactionCoordinator {
|
|
|
208
261
|
reason: trigger,
|
|
209
262
|
isSplitTurn: event.preparation.isSplitTurn,
|
|
210
263
|
messageCount: source.messages.length,
|
|
211
|
-
provider:
|
|
212
|
-
model:
|
|
213
|
-
};
|
|
214
|
-
const classifyContent = (text: string): { value: string; classification: PrivacyClassification } => {
|
|
215
|
-
const classified = this.dependencies.classifyContent?.(text, ctx.model!.provider);
|
|
216
|
-
if (classified) return classified;
|
|
217
|
-
return {
|
|
218
|
-
value: this.dependencies.sanitizeContent?.(text, ctx.model!.provider) ?? text,
|
|
219
|
-
classification: "normal",
|
|
220
|
-
};
|
|
221
|
-
};
|
|
222
|
-
const sourcePrivacy = classifyContent(source.conversationText);
|
|
223
|
-
const validationPrivacy = classifyContent(source.sourceText);
|
|
224
|
-
const instructionPrivacy = event.customInstructions
|
|
225
|
-
? classifyContent(event.customInstructions)
|
|
226
|
-
: undefined;
|
|
227
|
-
const readFilePrivacy = source.segmentReadFiles.map(classifyContent);
|
|
228
|
-
const modifiedFilePrivacy = source.segmentModifiedFiles.map(classifyContent);
|
|
229
|
-
const segmentClassification = [
|
|
230
|
-
sourcePrivacy.classification,
|
|
231
|
-
validationPrivacy.classification,
|
|
232
|
-
...(instructionPrivacy ? [instructionPrivacy.classification] : []),
|
|
233
|
-
...readFilePrivacy.map((item) => item.classification),
|
|
234
|
-
...modifiedFilePrivacy.map((item) => item.classification),
|
|
235
|
-
].reduce(highestClassification, "normal" as PrivacyClassification);
|
|
236
|
-
const segmentGenerated = await this.generateSummary({
|
|
237
|
-
stage: "segment",
|
|
238
|
-
prompt: buildSummaryPrompt({
|
|
239
|
-
conversationText: sourcePrivacy.value,
|
|
240
|
-
...(instructionPrivacy ? { customInstructions: instructionPrivacy.value } : {}),
|
|
241
|
-
readFiles: readFilePrivacy.map((item) => item.value),
|
|
242
|
-
modifiedFiles: modifiedFilePrivacy.map((item) => item.value),
|
|
243
|
-
isSplitTurn: event.preparation.isSplitTurn,
|
|
244
|
-
}),
|
|
245
|
-
validationSource: validationPrivacy.value,
|
|
246
|
-
readFiles: readFilePrivacy.map((item) => item.value),
|
|
247
|
-
modifiedFiles: modifiedFilePrivacy.map((item) => item.value),
|
|
248
|
-
event,
|
|
249
|
-
ctx,
|
|
250
|
-
});
|
|
251
|
-
const segmentId = nextId();
|
|
252
|
-
const segmentNode: EmbeddedSummaryNode = {
|
|
253
|
-
id: segmentId,
|
|
254
|
-
kind: "segment",
|
|
255
|
-
content: classifiedSummary(segmentGenerated.content, segmentClassification),
|
|
256
|
-
sourceHash: source.sourceHash,
|
|
257
|
-
sourceEntryIds: [...source.sourceEntryIds],
|
|
258
|
-
childSummaryIds: [],
|
|
259
|
-
graphLevel: 0,
|
|
260
|
-
createdAt: this.dependencies.now(),
|
|
261
|
-
validationStatus: segmentGenerated.validation.status,
|
|
262
|
-
validationIssueCodes: unique(segmentGenerated.validation.issues.map((issue) => issue.code)),
|
|
263
|
-
provider: ctx.model.provider,
|
|
264
|
-
model: ctx.model.id,
|
|
264
|
+
provider: model.provider,
|
|
265
|
+
model: model.id,
|
|
265
266
|
};
|
|
266
267
|
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
268
|
+
const usages: GeneratedSummary["usage"][] = [];
|
|
269
|
+
const segmentNodes: EmbeddedSummaryNode[] = [];
|
|
270
|
+
for (const plan of segmentPlans) {
|
|
271
|
+
const generated = await this.generateSummary({
|
|
272
|
+
stage: "segment",
|
|
273
|
+
prompt: plan.prompt,
|
|
274
|
+
validationSource: plan.validationSource,
|
|
275
|
+
readFiles: plan.readFiles,
|
|
276
|
+
modifiedFiles: plan.modifiedFiles,
|
|
277
|
+
event,
|
|
278
|
+
ctx,
|
|
279
|
+
});
|
|
280
|
+
usages.push(generated.usage);
|
|
281
|
+
segmentNodes.push({
|
|
271
282
|
id: nextId(),
|
|
272
|
-
|
|
283
|
+
kind: "segment",
|
|
284
|
+
content: classifiedSummary(generated.content, plan.classification),
|
|
285
|
+
sourceHash: plan.source.sourceHash,
|
|
286
|
+
sourceEntryIds: [...plan.source.sourceEntryIds],
|
|
287
|
+
childSummaryIds: [],
|
|
288
|
+
graphLevel: 0,
|
|
273
289
|
createdAt: this.dependencies.now(),
|
|
274
|
-
|
|
275
|
-
|
|
290
|
+
validationStatus: generated.validation.status,
|
|
291
|
+
validationIssueCodes: unique(generated.validation.issues.map((issue) => issue.code)),
|
|
292
|
+
provider: model.provider,
|
|
293
|
+
model: model.id,
|
|
276
294
|
});
|
|
277
|
-
embeddedNodes.push(previousNode);
|
|
278
295
|
}
|
|
296
|
+
const segmentId = segmentNodes[0]?.id;
|
|
297
|
+
if (!segmentId) throw new Error("Compaction fan-out produced no segment summary node");
|
|
279
298
|
|
|
280
|
-
const
|
|
281
|
-
let
|
|
282
|
-
if (previousNode) {
|
|
283
|
-
|
|
284
|
-
const classifiedChildren = aggregateChildren.map((child) => ({
|
|
285
|
-
child,
|
|
286
|
-
privacy: classifyContent(child.content),
|
|
287
|
-
}));
|
|
288
|
-
const promptChildren = classifiedChildren.map(({ child, privacy }) => ({
|
|
289
|
-
...child,
|
|
290
|
-
content: privacy.value,
|
|
291
|
-
}));
|
|
292
|
-
const aggregateReadFiles = source.readFiles.map(classifyContent);
|
|
293
|
-
const aggregateModifiedFiles = source.modifiedFiles.map(classifyContent);
|
|
294
|
-
const aggregateInstruction = event.customInstructions
|
|
295
|
-
? classifyContent(event.customInstructions)
|
|
296
|
-
: undefined;
|
|
297
|
-
const aggregateClassification = [
|
|
298
|
-
...classifiedChildren.map((item) => item.privacy.classification),
|
|
299
|
-
...(aggregateInstruction ? [aggregateInstruction.classification] : []),
|
|
300
|
-
...aggregateReadFiles.map((item) => item.classification),
|
|
301
|
-
...aggregateModifiedFiles.map((item) => item.classification),
|
|
302
|
-
].reduce(highestClassification, "normal" as PrivacyClassification);
|
|
303
|
-
const aggregateGenerated = await this.generateSummary({
|
|
304
|
-
stage: "aggregate",
|
|
305
|
-
prompt: buildAggregateSummaryPrompt({
|
|
306
|
-
children: promptChildren,
|
|
307
|
-
...(aggregateInstruction ? { customInstructions: aggregateInstruction.value } : {}),
|
|
308
|
-
readFiles: aggregateReadFiles.map((item) => item.value),
|
|
309
|
-
modifiedFiles: aggregateModifiedFiles.map((item) => item.value),
|
|
310
|
-
}),
|
|
311
|
-
validationSource: promptChildren.map((child) => child.content).join("\n\n"),
|
|
312
|
-
readFiles: aggregateReadFiles.map((item) => item.value),
|
|
313
|
-
modifiedFiles: aggregateModifiedFiles.map((item) => item.value),
|
|
314
|
-
event,
|
|
315
|
-
ctx,
|
|
316
|
-
});
|
|
317
|
-
usages.push(aggregateGenerated.usage);
|
|
318
|
-
activeNode = {
|
|
299
|
+
const createdNodes: EmbeddedSummaryNode[] = [];
|
|
300
|
+
let previousNode = source.previousNode;
|
|
301
|
+
if (!previousNode && source.previousSummary) {
|
|
302
|
+
previousNode = importUntrackedPreviousSummary({
|
|
319
303
|
id: nextId(),
|
|
320
|
-
|
|
321
|
-
content: classifiedSummary(aggregateGenerated.content, aggregateClassification),
|
|
322
|
-
sourceHash: computeAggregateSourceHash(aggregateChildren),
|
|
323
|
-
sourceEntryIds: unique(aggregateChildren.flatMap((child) => child.sourceEntryIds)),
|
|
324
|
-
childSummaryIds: aggregateChildren.map((child) => child.id),
|
|
325
|
-
graphLevel: Math.max(...aggregateChildren.map((child) => child.graphLevel)) + 1,
|
|
304
|
+
content: source.previousSummary,
|
|
326
305
|
createdAt: this.dependencies.now(),
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
};
|
|
332
|
-
embeddedNodes.push(segmentNode);
|
|
306
|
+
provider: model.provider,
|
|
307
|
+
model: model.id,
|
|
308
|
+
});
|
|
309
|
+
createdNodes.push(previousNode);
|
|
333
310
|
}
|
|
311
|
+
createdNodes.push(...segmentNodes);
|
|
312
|
+
const roots = [...(previousNode ? [previousNode] : []), ...segmentNodes];
|
|
313
|
+
const aggregated = await this.aggregateSummaryNodes({
|
|
314
|
+
roots,
|
|
315
|
+
event,
|
|
316
|
+
ctx,
|
|
317
|
+
provider: model.provider,
|
|
318
|
+
model: model.id,
|
|
319
|
+
readFiles: source.readFiles,
|
|
320
|
+
modifiedFiles: source.modifiedFiles,
|
|
321
|
+
inputBudgetTokens,
|
|
322
|
+
nextId,
|
|
323
|
+
createdNodes,
|
|
324
|
+
usages,
|
|
325
|
+
});
|
|
326
|
+
const activeNode = aggregated.activeNode;
|
|
327
|
+
const embeddedNodes = createdNodes.filter((node) => node.id !== activeNode.id);
|
|
334
328
|
|
|
335
329
|
const records = embeddedNodes.map((node) => createSummaryRecord({
|
|
336
330
|
sessionId: this.dependencies.sessionId,
|
|
@@ -359,6 +353,10 @@ export class CompactionCoordinator {
|
|
|
359
353
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
360
354
|
tokensBefore: event.preparation.tokensBefore,
|
|
361
355
|
requestedAt,
|
|
356
|
+
inputBudgetTokens,
|
|
357
|
+
sourcePromptTokens: wholePlan.promptTokens,
|
|
358
|
+
segmentCount: segmentPlans.length,
|
|
359
|
+
aggregateCalls: aggregated.aggregateCalls,
|
|
362
360
|
};
|
|
363
361
|
this.dependencies.logger.debug("compaction.summary_graph_prepared", {
|
|
364
362
|
activeSummaryId: activeNode.id,
|
|
@@ -367,6 +365,10 @@ export class CompactionCoordinator {
|
|
|
367
365
|
createdNodes: records.length,
|
|
368
366
|
sourceEntries: activeNode.sourceEntryIds.length,
|
|
369
367
|
validationStatus: activeNode.validationStatus,
|
|
368
|
+
inputBudgetTokens,
|
|
369
|
+
sourcePromptTokens: wholePlan.promptTokens,
|
|
370
|
+
segmentCount: segmentPlans.length,
|
|
371
|
+
aggregateCalls: aggregated.aggregateCalls,
|
|
370
372
|
trigger,
|
|
371
373
|
});
|
|
372
374
|
const activeRecord = records.at(-1);
|
|
@@ -420,13 +422,19 @@ export class CompactionCoordinator {
|
|
|
420
422
|
: undefined;
|
|
421
423
|
if (!details || !effectiveEntry) {
|
|
422
424
|
this.failPendingNodes();
|
|
423
|
-
const
|
|
425
|
+
const attempt = this.state;
|
|
426
|
+
const customError = attempt.lastError;
|
|
424
427
|
this.state = {
|
|
425
428
|
phase: "pi-default",
|
|
426
429
|
trigger: event.reason,
|
|
427
430
|
firstKeptEntryId: event.compactionEntry.firstKeptEntryId,
|
|
428
431
|
tokensBefore: event.compactionEntry.tokensBefore,
|
|
429
432
|
completedAt: this.dependencies.now(),
|
|
433
|
+
...(attempt.sourceEntries !== undefined ? { sourceEntries: attempt.sourceEntries } : {}),
|
|
434
|
+
...(attempt.inputBudgetTokens !== undefined ? { inputBudgetTokens: attempt.inputBudgetTokens } : {}),
|
|
435
|
+
...(attempt.sourcePromptTokens !== undefined ? { sourcePromptTokens: attempt.sourcePromptTokens } : {}),
|
|
436
|
+
...(attempt.segmentCount !== undefined ? { segmentCount: attempt.segmentCount } : {}),
|
|
437
|
+
...(attempt.aggregateCalls !== undefined ? { aggregateCalls: attempt.aggregateCalls } : {}),
|
|
430
438
|
...(customError ? { lastError: `Custom fallback: ${customError}` } : {}),
|
|
431
439
|
};
|
|
432
440
|
return;
|
|
@@ -459,6 +467,14 @@ export class CompactionCoordinator {
|
|
|
459
467
|
tokensBefore: effectiveEntry.tokensBefore,
|
|
460
468
|
requestedAt: metadata.generatedAt,
|
|
461
469
|
completedAt: this.dependencies.now(),
|
|
470
|
+
...(this.state.inputBudgetTokens !== undefined
|
|
471
|
+
? { inputBudgetTokens: this.state.inputBudgetTokens }
|
|
472
|
+
: {}),
|
|
473
|
+
...(this.state.sourcePromptTokens !== undefined
|
|
474
|
+
? { sourcePromptTokens: this.state.sourcePromptTokens }
|
|
475
|
+
: {}),
|
|
476
|
+
...(this.state.segmentCount !== undefined ? { segmentCount: this.state.segmentCount } : {}),
|
|
477
|
+
...(this.state.aggregateCalls !== undefined ? { aggregateCalls: this.state.aggregateCalls } : {}),
|
|
462
478
|
};
|
|
463
479
|
this.dependencies.logger.debug("compaction.summary_graph_committed", {
|
|
464
480
|
activeSummaryId: metadata.summaryId,
|
|
@@ -640,6 +656,286 @@ export class CompactionCoordinator {
|
|
|
640
656
|
};
|
|
641
657
|
}
|
|
642
658
|
|
|
659
|
+
private inputBudgetTokens(model: ModelDescriptor): number {
|
|
660
|
+
const config = this.dependencies.config;
|
|
661
|
+
const resolved = this.dependencies.resolveModelBudget?.(model)
|
|
662
|
+
?? {
|
|
663
|
+
budget: calculateContextBudget(createModelProfile(model), config.context),
|
|
664
|
+
recentTailTokens: adaptiveRecentTailLimit(
|
|
665
|
+
model.contextWindow,
|
|
666
|
+
config.context.recentTailTokens,
|
|
667
|
+
),
|
|
668
|
+
};
|
|
669
|
+
return Math.floor(resolved.budget.activeInputBudget);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private classify(text: string, provider: string): {
|
|
673
|
+
value: string;
|
|
674
|
+
classification: PrivacyClassification;
|
|
675
|
+
} {
|
|
676
|
+
const classified = this.dependencies.classifyContent?.(text, provider);
|
|
677
|
+
if (classified) return classified;
|
|
678
|
+
return {
|
|
679
|
+
value: this.dependencies.sanitizeContent?.(text, provider) ?? text,
|
|
680
|
+
classification: "normal",
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private promptTokens(prompt: string): number {
|
|
685
|
+
return estimateMessageTokens({
|
|
686
|
+
role: "user",
|
|
687
|
+
content: [{ type: "text", text: prompt }],
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private buildSegmentPlan(
|
|
692
|
+
source: PreparedCompactionSourceSlice,
|
|
693
|
+
event: SessionBeforeCompactEvent,
|
|
694
|
+
provider: string,
|
|
695
|
+
): SegmentGenerationPlan {
|
|
696
|
+
const sourcePrivacy = this.classify(source.conversationText, provider);
|
|
697
|
+
const validationPrivacy = this.classify(source.sourceText, provider);
|
|
698
|
+
const instructionPrivacy = event.customInstructions
|
|
699
|
+
? this.classify(event.customInstructions, provider)
|
|
700
|
+
: undefined;
|
|
701
|
+
const readFilePrivacy = source.segmentReadFiles.map((file) => this.classify(file, provider));
|
|
702
|
+
const modifiedFilePrivacy = source.segmentModifiedFiles.map((file) => this.classify(file, provider));
|
|
703
|
+
const classification = [
|
|
704
|
+
sourcePrivacy.classification,
|
|
705
|
+
validationPrivacy.classification,
|
|
706
|
+
...(instructionPrivacy ? [instructionPrivacy.classification] : []),
|
|
707
|
+
...readFilePrivacy.map((item) => item.classification),
|
|
708
|
+
...modifiedFilePrivacy.map((item) => item.classification),
|
|
709
|
+
].reduce(highestClassification, "normal" as PrivacyClassification);
|
|
710
|
+
const readFiles = readFilePrivacy.map((item) => item.value);
|
|
711
|
+
const modifiedFiles = modifiedFilePrivacy.map((item) => item.value);
|
|
712
|
+
const prompt = buildSummaryPrompt({
|
|
713
|
+
conversationText: sourcePrivacy.value,
|
|
714
|
+
...(instructionPrivacy ? { customInstructions: instructionPrivacy.value } : {}),
|
|
715
|
+
readFiles,
|
|
716
|
+
modifiedFiles,
|
|
717
|
+
isSplitTurn: source.isSplitTurn,
|
|
718
|
+
});
|
|
719
|
+
return {
|
|
720
|
+
source,
|
|
721
|
+
prompt,
|
|
722
|
+
validationSource: validationPrivacy.value,
|
|
723
|
+
readFiles,
|
|
724
|
+
modifiedFiles,
|
|
725
|
+
classification,
|
|
726
|
+
promptTokens: this.promptTokens(prompt),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private partitionSegmentPlans(
|
|
731
|
+
source: PreparedCompactionSource,
|
|
732
|
+
wholePlan: SegmentGenerationPlan,
|
|
733
|
+
event: SessionBeforeCompactEvent,
|
|
734
|
+
provider: string,
|
|
735
|
+
inputBudgetTokens: number,
|
|
736
|
+
): SegmentGenerationPlan[] {
|
|
737
|
+
if (wholePlan.promptTokens <= inputBudgetTokens) return [wholePlan];
|
|
738
|
+
|
|
739
|
+
const groups = buildCompactionAtomicGroups(source.messages);
|
|
740
|
+
const plans: SegmentGenerationPlan[] = [];
|
|
741
|
+
let currentIndices: number[] = [];
|
|
742
|
+
let currentPlan: SegmentGenerationPlan | undefined;
|
|
743
|
+
|
|
744
|
+
for (const group of groups) {
|
|
745
|
+
const candidateIndices = [...currentIndices, ...group.messageIndices];
|
|
746
|
+
const candidatePlan = this.buildSegmentPlan(
|
|
747
|
+
sliceCompactionSource(source, candidateIndices),
|
|
748
|
+
event,
|
|
749
|
+
provider,
|
|
750
|
+
);
|
|
751
|
+
if (candidatePlan.promptTokens <= inputBudgetTokens) {
|
|
752
|
+
currentIndices = candidateIndices;
|
|
753
|
+
currentPlan = candidatePlan;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (currentPlan) {
|
|
758
|
+
plans.push(currentPlan);
|
|
759
|
+
if (plans.length >= MAX_SEGMENT_REQUESTS) {
|
|
760
|
+
throw new Error(`Compaction fan-out exceeded the bounded segment request limit (${MAX_SEGMENT_REQUESTS})`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const atomicPlan = this.buildSegmentPlan(
|
|
764
|
+
sliceCompactionSource(source, group.messageIndices),
|
|
765
|
+
event,
|
|
766
|
+
provider,
|
|
767
|
+
);
|
|
768
|
+
if (atomicPlan.promptTokens > inputBudgetTokens) {
|
|
769
|
+
throw new Error(
|
|
770
|
+
`Compaction source contains an indivisible atomic group above the model input budget (promptTokens=${atomicPlan.promptTokens}; inputBudgetTokens=${inputBudgetTokens})`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
currentIndices = [...group.messageIndices];
|
|
774
|
+
currentPlan = atomicPlan;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (currentPlan) plans.push(currentPlan);
|
|
778
|
+
if (plans.length === 0) throw new Error("Compaction fan-out produced no source segments");
|
|
779
|
+
if (plans.length > MAX_SEGMENT_REQUESTS) {
|
|
780
|
+
throw new Error(`Compaction fan-out exceeded the bounded segment request limit (${MAX_SEGMENT_REQUESTS})`);
|
|
781
|
+
}
|
|
782
|
+
return plans;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private buildAggregatePlan(
|
|
786
|
+
children: readonly EmbeddedSummaryNode[],
|
|
787
|
+
event: SessionBeforeCompactEvent,
|
|
788
|
+
provider: string,
|
|
789
|
+
readFiles: readonly string[],
|
|
790
|
+
modifiedFiles: readonly string[],
|
|
791
|
+
): AggregateGenerationPlan {
|
|
792
|
+
const classifiedChildren = children.map((child) => ({
|
|
793
|
+
child,
|
|
794
|
+
privacy: this.classify(child.content, provider),
|
|
795
|
+
}));
|
|
796
|
+
const promptChildren = classifiedChildren.map(({ child, privacy }) => ({
|
|
797
|
+
...child,
|
|
798
|
+
content: privacy.value,
|
|
799
|
+
}));
|
|
800
|
+
const aggregateReadFiles = readFiles.map((file) => this.classify(file, provider));
|
|
801
|
+
const aggregateModifiedFiles = modifiedFiles.map((file) => this.classify(file, provider));
|
|
802
|
+
const aggregateInstruction = event.customInstructions
|
|
803
|
+
? this.classify(event.customInstructions, provider)
|
|
804
|
+
: undefined;
|
|
805
|
+
const classification = [
|
|
806
|
+
...classifiedChildren.map((item) => item.privacy.classification),
|
|
807
|
+
...(aggregateInstruction ? [aggregateInstruction.classification] : []),
|
|
808
|
+
...aggregateReadFiles.map((item) => item.classification),
|
|
809
|
+
...aggregateModifiedFiles.map((item) => item.classification),
|
|
810
|
+
].reduce(highestClassification, "normal" as PrivacyClassification);
|
|
811
|
+
const sanitizedReadFiles = aggregateReadFiles.map((item) => item.value);
|
|
812
|
+
const sanitizedModifiedFiles = aggregateModifiedFiles.map((item) => item.value);
|
|
813
|
+
const prompt = buildAggregateSummaryPrompt({
|
|
814
|
+
children: promptChildren,
|
|
815
|
+
...(aggregateInstruction ? { customInstructions: aggregateInstruction.value } : {}),
|
|
816
|
+
readFiles: sanitizedReadFiles,
|
|
817
|
+
modifiedFiles: sanitizedModifiedFiles,
|
|
818
|
+
});
|
|
819
|
+
return {
|
|
820
|
+
children: [...children],
|
|
821
|
+
prompt,
|
|
822
|
+
validationSource: promptChildren.map((child) => child.content).join("\n\n"),
|
|
823
|
+
readFiles: sanitizedReadFiles,
|
|
824
|
+
modifiedFiles: sanitizedModifiedFiles,
|
|
825
|
+
classification,
|
|
826
|
+
promptTokens: this.promptTokens(prompt),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private async aggregateSummaryNodes(input: {
|
|
831
|
+
roots: EmbeddedSummaryNode[];
|
|
832
|
+
event: SessionBeforeCompactEvent;
|
|
833
|
+
ctx: ExtensionContext;
|
|
834
|
+
provider: string;
|
|
835
|
+
model: string;
|
|
836
|
+
readFiles: readonly string[];
|
|
837
|
+
modifiedFiles: readonly string[];
|
|
838
|
+
inputBudgetTokens: number;
|
|
839
|
+
nextId: () => string;
|
|
840
|
+
createdNodes: EmbeddedSummaryNode[];
|
|
841
|
+
usages: GeneratedSummary["usage"][];
|
|
842
|
+
}): Promise<{ activeNode: EmbeddedSummaryNode; aggregateCalls: number }> {
|
|
843
|
+
let layer = [...input.roots];
|
|
844
|
+
let aggregateCalls = 0;
|
|
845
|
+
let graphPasses = 0;
|
|
846
|
+
|
|
847
|
+
while (layer.length > 1) {
|
|
848
|
+
graphPasses++;
|
|
849
|
+
if (graphPasses > MAX_AGGREGATE_LEVELS) {
|
|
850
|
+
throw new Error(`Compaction aggregation exceeded the bounded graph depth (${MAX_AGGREGATE_LEVELS})`);
|
|
851
|
+
}
|
|
852
|
+
const batches: EmbeddedSummaryNode[][] = [];
|
|
853
|
+
let current: EmbeddedSummaryNode[] = [];
|
|
854
|
+
for (const node of layer) {
|
|
855
|
+
if (current.length === 0) {
|
|
856
|
+
current = [node];
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
const candidate = [...current, node];
|
|
860
|
+
const candidatePlan = this.buildAggregatePlan(
|
|
861
|
+
candidate,
|
|
862
|
+
input.event,
|
|
863
|
+
input.provider,
|
|
864
|
+
input.readFiles,
|
|
865
|
+
input.modifiedFiles,
|
|
866
|
+
);
|
|
867
|
+
if (candidatePlan.promptTokens <= input.inputBudgetTokens) {
|
|
868
|
+
current = candidate;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
if (current.length === 1) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`Compaction child summaries cannot be aggregated within the model input budget (inputBudgetTokens=${input.inputBudgetTokens})`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
batches.push(current);
|
|
877
|
+
current = [node];
|
|
878
|
+
}
|
|
879
|
+
if (current.length > 0) batches.push(current);
|
|
880
|
+
|
|
881
|
+
const nextLayer: EmbeddedSummaryNode[] = [];
|
|
882
|
+
for (const batch of batches) {
|
|
883
|
+
if (batch.length === 1) {
|
|
884
|
+
nextLayer.push(batch[0] as EmbeddedSummaryNode);
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
aggregateCalls++;
|
|
888
|
+
if (aggregateCalls > MAX_AGGREGATE_REQUESTS) {
|
|
889
|
+
throw new Error(`Compaction fan-in exceeded the bounded aggregate request limit (${MAX_AGGREGATE_REQUESTS})`);
|
|
890
|
+
}
|
|
891
|
+
const plan = this.buildAggregatePlan(
|
|
892
|
+
batch,
|
|
893
|
+
input.event,
|
|
894
|
+
input.provider,
|
|
895
|
+
input.readFiles,
|
|
896
|
+
input.modifiedFiles,
|
|
897
|
+
);
|
|
898
|
+
if (plan.promptTokens > input.inputBudgetTokens) {
|
|
899
|
+
throw new Error("Compaction aggregate prompt exceeded its preflight input budget");
|
|
900
|
+
}
|
|
901
|
+
const generated = await this.generateSummary({
|
|
902
|
+
stage: "aggregate",
|
|
903
|
+
prompt: plan.prompt,
|
|
904
|
+
validationSource: plan.validationSource,
|
|
905
|
+
readFiles: plan.readFiles,
|
|
906
|
+
modifiedFiles: plan.modifiedFiles,
|
|
907
|
+
event: input.event,
|
|
908
|
+
ctx: input.ctx,
|
|
909
|
+
});
|
|
910
|
+
input.usages.push(generated.usage);
|
|
911
|
+
const aggregateNode: EmbeddedSummaryNode = {
|
|
912
|
+
id: input.nextId(),
|
|
913
|
+
kind: "aggregate",
|
|
914
|
+
content: classifiedSummary(generated.content, plan.classification),
|
|
915
|
+
sourceHash: computeAggregateSourceHash(batch),
|
|
916
|
+
sourceEntryIds: unique(batch.flatMap((child) => child.sourceEntryIds)),
|
|
917
|
+
childSummaryIds: batch.map((child) => child.id),
|
|
918
|
+
graphLevel: Math.max(...batch.map((child) => child.graphLevel)) + 1,
|
|
919
|
+
createdAt: this.dependencies.now(),
|
|
920
|
+
validationStatus: generated.validation.status,
|
|
921
|
+
validationIssueCodes: unique(generated.validation.issues.map((issue) => issue.code)),
|
|
922
|
+
provider: input.provider,
|
|
923
|
+
model: input.model,
|
|
924
|
+
};
|
|
925
|
+
input.createdNodes.push(aggregateNode);
|
|
926
|
+
nextLayer.push(aggregateNode);
|
|
927
|
+
}
|
|
928
|
+
if (nextLayer.length >= layer.length) {
|
|
929
|
+
throw new Error("Compaction aggregation could not reduce the summary graph within the input budget");
|
|
930
|
+
}
|
|
931
|
+
layer = nextLayer;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const activeNode = layer[0];
|
|
935
|
+
if (!activeNode) throw new Error("Compaction aggregation produced no active summary node");
|
|
936
|
+
return { activeNode, aggregateCalls };
|
|
937
|
+
}
|
|
938
|
+
|
|
643
939
|
private generateSummary(input: {
|
|
644
940
|
stage: "segment" | "aggregate";
|
|
645
941
|
prompt: string;
|
|
@@ -50,33 +50,65 @@ function responseStopReason(response: unknown): string | undefined {
|
|
|
50
50
|
return typeof response.stopReason === "string" ? response.stopReason : undefined;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function responseErrorMessage(response: unknown): string | undefined {
|
|
54
|
+
if (!response || typeof response !== "object" || !("errorMessage" in response)) return undefined;
|
|
55
|
+
return typeof response.errorMessage === "string" ? response.errorMessage : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function providerFailureCategory(value: unknown): string {
|
|
59
|
+
const message = value instanceof Error
|
|
60
|
+
? value.message
|
|
61
|
+
: typeof value === "string"
|
|
62
|
+
? value
|
|
63
|
+
: "";
|
|
64
|
+
if (/abort|cancel/iu.test(message)) return "aborted";
|
|
65
|
+
if (/usage|quota|credit|billing/iu.test(message)) return "usage-limit";
|
|
66
|
+
if (/rate|too many requests|429/iu.test(message)) return "rate-limit";
|
|
67
|
+
if (/(?:context|prompt|input).{0,48}(?:exceed|limit|maximum|too (?:long|large)|tokens?)|tokens?.{0,48}(?:exceed|limit|maximum|too many)|maximum (?:context|input|prompt|length)/iu.test(message)) {
|
|
68
|
+
return "input-limit";
|
|
69
|
+
}
|
|
70
|
+
if (/auth|credential|api.?key|permission|forbidden|401|403/iu.test(message)) return "authentication";
|
|
71
|
+
if (/timeout|network|connection|socket|dns/iu.test(message)) return "transport";
|
|
72
|
+
return "provider-error";
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
export async function generateValidatedSummary(
|
|
54
76
|
input: GenerateValidatedSummaryInput,
|
|
55
77
|
): Promise<GeneratedSummary> {
|
|
56
78
|
const model = input.ctx.model;
|
|
57
79
|
if (!model) throw new Error("Compaction summary generation requires an active model");
|
|
58
80
|
const maxTokens = Math.max(1, Math.min(input.maxSummaryTokens, model.maxTokens ?? input.maxSummaryTokens));
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
let response: Awaited<ReturnType<typeof input.ctx.modelRegistry.complete>>;
|
|
82
|
+
try {
|
|
83
|
+
response = await input.ctx.modelRegistry.complete(
|
|
84
|
+
model,
|
|
85
|
+
{
|
|
86
|
+
messages: [{
|
|
87
|
+
role: "user",
|
|
88
|
+
content: [{ type: "text", text: input.prompt }],
|
|
89
|
+
timestamp: input.now(),
|
|
90
|
+
}],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
maxTokens,
|
|
94
|
+
signal: input.event.signal,
|
|
95
|
+
cacheRetention: "none",
|
|
96
|
+
sessionId: randomUUID(),
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Compaction ${input.stage} request failed (category=${providerFailureCategory(error)})`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
75
104
|
if (input.event.signal.aborted) throw new Error("Compaction summary generation aborted");
|
|
76
105
|
const stopReason = responseStopReason(response);
|
|
77
106
|
if (stopReason === "length") throw new Error("Compaction summary hit the model output limit");
|
|
78
107
|
if (stopReason === "error" || stopReason === "aborted") {
|
|
79
|
-
|
|
108
|
+
const category = stopReason === "aborted"
|
|
109
|
+
? "aborted"
|
|
110
|
+
: providerFailureCategory(responseErrorMessage(response));
|
|
111
|
+
throw new Error(`Compaction ${input.stage} summary stopped with ${stopReason} (category=${category})`);
|
|
80
112
|
}
|
|
81
113
|
if (response.content.some((block) => block.type === "toolCall")) {
|
|
82
114
|
throw new Error("Compaction summarizer attempted to call a tool");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const EXTENSION_VERSION = "0.3.0-alpha.
|
|
1
|
+
export const EXTENSION_VERSION = "0.3.0-alpha.5";
|
|
2
2
|
export const SUPPORTED_PI_VERSION = "0.84.3";
|
|
3
3
|
export const OBSERVER_PLANNER_VERSION = "observer-model-aware-v1";
|
|
4
4
|
export const PLANNER_VERSION = "managed-learned-ranking-v1";
|