acp-kernel 0.0.99-pr.477.345 → 0.0.99-pr.490.346
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/DESIGN.md +6 -4
- package/dist/index.js +19 -75
- package/dist/index.js.map +1 -1
- package/dist/parse-compress-input.d.ts +0 -3
- package/dist/parse-compress-input.d.ts.map +1 -1
- package/package.json +1 -1
package/DESIGN.md
CHANGED
|
@@ -262,10 +262,12 @@ Copyright protects _expression_, not ideas, methods, or algorithms (17 USC §102
|
|
|
262
262
|
|
|
263
263
|
### 8.1 First-user-message pin
|
|
264
264
|
|
|
265
|
-
The session's **first user message survives prune
|
|
265
|
+
The session's **first user message survives prune when it is covered by an active block — unless dropping it cannot lose the guarantee the pin exists for.** In `rebuildMessages` (`src/prune.ts`) the pin check runs _before_ the covered-by-active-block check, but a covered pin yields when another user message still survives behind it (`firstUserDropSafe`: the earliest non-covered, non-rendered-summary message after the pin must itself be a user). In that case the fold applies fully: the covered first user drops like any other covered message, and the rebuilt wire opens with the surviving user (with the rendered summary anchored in front of it, re-voiced or stripped downstream per mode). Only the _first_ user message is pinned — later covered user messages are pruned normally.
|
|
266
266
|
|
|
267
|
-
- **Why:** strict providers reject conversations with no user message (e.g., Anthropic requires the conversation to start from the user role).
|
|
267
|
+
- **Why:** strict providers reject conversations with no user message (e.g., Anthropic requires the conversation to start from the user role). The pin guarantees the rebuilt wire retains at least one user message whenever the input had one. Deliberate since prune's first implementation (v0.0.2) — the ordering of the two checks is part of the contract, not incidental. The original unconditional form was an over-guarantee: it pinned the _specific_ first message rather than the property "a user leads". Once a covered message became the view's first user — the client trimmed earlier history before resending, or the fold targeted the opening message directly — the pin defeated coverage forever: the block stayed active while its payload re-sent itself verbatim every turn, reporting success (billion-context#1869). The conditional yield restores coverage for exactly those shapes while preserving the guarantee everywhere else.
|
|
268
|
+
- **Residual limitation (deliberate):** if no user message survives behind the covered pin (e.g. `[U1-covered, A1, U2-covered…]` where only assistant/tool cores remain), the pin holds and the fold of that range is a no-op on the wire — shipping a non-user-leading frame would reject strict providers, which outweighs the compression gain. Consumers should treat "folded the opening message and nothing user-shaped remains" as a known degenerate case.
|
|
268
269
|
- **Consequences for consumers:**
|
|
269
|
-
-
|
|
270
|
-
- Token-size and prefix-cache stability estimates must account for the pinned message staying verbatim (it is often the task description and can be long).
|
|
270
|
+
- Post-compress wire assertions about the first user message depend on what survives behind it: with a surviving user, expect byte-level disappearance plus the rendered summary; in the degenerate case above, expect the pinned message verbatim alongside the summary.
|
|
271
|
+
- Token-size and prefix-cache stability estimates must account for the pinned message staying verbatim in the degenerate case (it is often the task description and can be long).
|
|
271
272
|
- Boundary resolution compensates: `blockVisibleInRange` (`src/boundaries.ts`) treats a block as present in a range via its rendered summary **or** its earliest surviving raw — required precisely because the summary anchors _before_ the pinned raw.
|
|
273
|
+
- **Regression-tested by** `tests/state-prune.test.ts` (both the drop and the degenerate-keep shapes), `tests/orphan-fixes.test.ts` ("prune: first user message pruned when covered (no duplication)"), and `tests/issue1476-reid.test.ts` (pinned-echo id/ref stability across post-fold passes).
|
package/dist/index.js
CHANGED
|
@@ -209,6 +209,15 @@ function pairSafeAnchorIndex(messages, index) {
|
|
|
209
209
|
}
|
|
210
210
|
return safe;
|
|
211
211
|
}
|
|
212
|
+
function firstUserDropSafe(messages, firstUserIndex, coveredBases) {
|
|
213
|
+
for (let i = firstUserIndex + 1; i < messages.length; i++) {
|
|
214
|
+
const message = messages[i];
|
|
215
|
+
if (isCovered(message.id, coveredBases)) continue;
|
|
216
|
+
if (isRenderedSummaryMessage(message)) continue;
|
|
217
|
+
return message.role === "user";
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
212
221
|
function rebuildMessages(messages, coveredBases, firstUserIndex, anchors) {
|
|
213
222
|
const safeAnchors = anchors.map((anchor) => ({
|
|
214
223
|
...anchor,
|
|
@@ -224,8 +233,11 @@ function rebuildMessages(messages, coveredBases, firstUserIndex, anchors) {
|
|
|
224
233
|
result.push(renderSummary(pending.shift()));
|
|
225
234
|
}
|
|
226
235
|
if (index === firstUserIndex && firstUserIndex >= 0) {
|
|
227
|
-
|
|
228
|
-
|
|
236
|
+
const pinned = messages[index];
|
|
237
|
+
if (!isCovered(pinned.id, coveredBases) || !firstUserDropSafe(messages, index, coveredBases)) {
|
|
238
|
+
result.push(pinned);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
229
241
|
}
|
|
230
242
|
if (isCovered(messages[index].id, coveredBases)) continue;
|
|
231
243
|
if (isRenderedSummaryMessage(messages[index]) && anchoredSummaryIds.has(messages[index].id))
|
|
@@ -7529,30 +7541,12 @@ function parseStringCore(cleaned, callId, diag) {
|
|
|
7529
7541
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
7530
7542
|
return parseObjectValue(value, callId, diag);
|
|
7531
7543
|
}
|
|
7532
|
-
if (typeof value === "string") {
|
|
7533
|
-
const recovered2 = salvageLineFormEntries(value, callId);
|
|
7534
|
-
if (recovered2.ranges.length > 0)
|
|
7535
|
-
return finishLineFormSalvage(recovered2, diag, false);
|
|
7536
|
-
}
|
|
7537
7544
|
if (value !== void 0) {
|
|
7538
7545
|
diag.kind = "not-object";
|
|
7539
7546
|
return finish([], diag);
|
|
7540
7547
|
}
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
if (entries.length === 0) {
|
|
7544
|
-
entries = salvageStringSlotEntries(cleaned);
|
|
7545
|
-
fromStringSlot = entries.length > 0;
|
|
7546
|
-
}
|
|
7547
|
-
if (entries.length > 0) {
|
|
7548
|
-
const out = finishSalvage(entries, callId, diag, true);
|
|
7549
|
-
if (fromStringSlot && out.ranges.length > 0) diag.contentSalvage = true;
|
|
7550
|
-
return out;
|
|
7551
|
-
}
|
|
7552
|
-
const recovered = salvageLineFormEntries(cleaned, callId);
|
|
7553
|
-
if (recovered.ranges.length > 0)
|
|
7554
|
-
return finishLineFormSalvage(recovered, diag, looksTruncated(cleaned));
|
|
7555
|
-
return finishSalvage([], callId, diag, looksTruncated(cleaned));
|
|
7548
|
+
const entries = salvageContentEntries(cleaned);
|
|
7549
|
+
return finishSalvage(entries, callId, diag, looksTruncated(cleaned));
|
|
7556
7550
|
}
|
|
7557
7551
|
function parseObjectValue(value, callId, diag) {
|
|
7558
7552
|
diag.keys = Object.keys(value);
|
|
@@ -7574,11 +7568,11 @@ function parseObjectValue(value, callId, diag) {
|
|
|
7574
7568
|
const parsed = parseContentArray(content);
|
|
7575
7569
|
if (parsed === null || parsed.entries.length === 0 && content.trim().length > 0) {
|
|
7576
7570
|
const bare = stripJsonWrapperResidue(content);
|
|
7577
|
-
const entries2 =
|
|
7571
|
+
const entries2 = splitLineEntries(bare);
|
|
7578
7572
|
const { ranges: ranges2, invalid: invalid2, reasons: reasons2 } = validateEntries(entries2, callId);
|
|
7579
|
-
diag.invalidItems = invalid2;
|
|
7580
|
-
if (reasons2.length > 0) diag.invalidReasons = reasons2;
|
|
7581
7573
|
if (ranges2.length > 0) {
|
|
7574
|
+
diag.invalidItems = invalid2;
|
|
7575
|
+
if (reasons2.length > 0) diag.invalidReasons = reasons2;
|
|
7582
7576
|
diag.kind = "ok";
|
|
7583
7577
|
return finish(ranges2, diag);
|
|
7584
7578
|
}
|
|
@@ -8039,56 +8033,6 @@ function looksTruncated(s) {
|
|
|
8039
8033
|
}
|
|
8040
8034
|
return depth > 0 || inString;
|
|
8041
8035
|
}
|
|
8042
|
-
function inlineSingleLineEntry(entries) {
|
|
8043
|
-
if (entries.length !== 1) return entries;
|
|
8044
|
-
const e = entries[0];
|
|
8045
|
-
if (typeof e !== "string" || e.includes("\n")) return entries;
|
|
8046
|
-
const head = e.trim();
|
|
8047
|
-
const m = REF_PAIR_IN_LINE.exec(head) ?? SINGLE_REF_IN_LINE.exec(head);
|
|
8048
|
-
if (m === null) return entries;
|
|
8049
|
-
const rest = head.slice(m.index + m[0].length).trim();
|
|
8050
|
-
if (!/\p{L}|\p{N}/u.test(rest)) return entries;
|
|
8051
|
-
return [m[0] + "\n" + rest];
|
|
8052
|
-
}
|
|
8053
|
-
function salvageLineFormEntries(text, callId) {
|
|
8054
|
-
return validateEntries(
|
|
8055
|
-
inlineSingleLineEntry(splitLineEntries(stripJsonWrapperResidue(text))),
|
|
8056
|
-
callId
|
|
8057
|
-
);
|
|
8058
|
-
}
|
|
8059
|
-
function finishLineFormSalvage(result, diag, truncatedShape) {
|
|
8060
|
-
diag.stringSalvage = true;
|
|
8061
|
-
diag.invalidItems = result.invalid;
|
|
8062
|
-
if (result.reasons.length > 0) diag.invalidReasons = result.reasons;
|
|
8063
|
-
diag.kind = truncatedShape ? "truncated" : "ok";
|
|
8064
|
-
return finish(result.ranges, diag);
|
|
8065
|
-
}
|
|
8066
|
-
function salvageStringSlotEntries(raw) {
|
|
8067
|
-
const match = /"content"\s*:\s*"/.exec(raw);
|
|
8068
|
-
if (match === null) return [];
|
|
8069
|
-
const start = match.index + match[0].length;
|
|
8070
|
-
let i = start;
|
|
8071
|
-
let terminated = false;
|
|
8072
|
-
while (i < raw.length) {
|
|
8073
|
-
const ch = raw.charAt(i);
|
|
8074
|
-
if (ch === "\\") {
|
|
8075
|
-
i += 2;
|
|
8076
|
-
continue;
|
|
8077
|
-
}
|
|
8078
|
-
if (ch === '"') {
|
|
8079
|
-
terminated = true;
|
|
8080
|
-
break;
|
|
8081
|
-
}
|
|
8082
|
-
i++;
|
|
8083
|
-
}
|
|
8084
|
-
const body = raw.slice(start, i).replace(/\\n/g, "\n");
|
|
8085
|
-
const parts = splitLineEntries(body);
|
|
8086
|
-
if (!terminated) {
|
|
8087
|
-
if (parts.length <= 1) return [];
|
|
8088
|
-
parts.pop();
|
|
8089
|
-
}
|
|
8090
|
-
return parts;
|
|
8091
|
-
}
|
|
8092
8036
|
function salvageContentEntries(raw) {
|
|
8093
8037
|
const match = /"content"\s*:\s*\[/.exec(raw);
|
|
8094
8038
|
if (match === null) return [];
|