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 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 unconditionally** — even when it is covered by an active block. In `rebuildMessages` (`src/prune.ts`) the pin check runs _before_ the covered-by-active-block check, so after compressing a range that includes the first user message, the rebuilt wire contains **both** the rendered summary **and** the first user message verbatim; all other covered messages drop as usual. Only the _first_ user message is pinned — later covered user messages are pruned normally.
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). Pinning 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. Regression-tested by `tests/state-prune.test.ts` ("prune preserves first user message even when covered") and `tests/orphan-fixes.test.ts` ("prune: first user message pruned when covered (no duplication)").
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
- - Consumers cannot assert byte-level disappearance of the first user message after compression; post-compress wire assertions must expect the rendered summary **plus** the pinned message verbatim.
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
- result.push(messages[index]);
228
- continue;
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
- let entries = salvageContentEntries(cleaned);
7542
- let fromStringSlot = false;
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 = inlineSingleLineEntry(splitLineEntries(bare));
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 [];