@sema-agent/core 7.18.0 → 7.19.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/dist/brain/anthropic.js +6 -3
  3. package/dist/brain/open-responses.js +10 -4
  4. package/dist/brain/openai.js +5 -2
  5. package/dist/brain/reasoning.d.ts +13 -13
  6. package/dist/brain/reasoning.js +2 -2
  7. package/dist/brain/stream-engine.js +2 -1
  8. package/dist/brain/stream-shared.d.ts +3 -0
  9. package/dist/brain/stream-shared.js +1 -0
  10. package/dist/core/ask-origin.d.ts +16 -0
  11. package/dist/core/ask-origin.js +8 -0
  12. package/dist/core/auto-mode-arming.d.ts +6 -0
  13. package/dist/core/auto-mode-arming.js +5 -5
  14. package/dist/core/auto-mode-defaults.d.ts +43 -15
  15. package/dist/core/auto-mode-defaults.js +4 -1
  16. package/dist/core/auto-mode.d.ts +43 -14
  17. package/dist/core/auto-mode.js +79 -10
  18. package/dist/core/checkpoint-store.d.ts +7 -0
  19. package/dist/core/checkpoint-store.js +2 -2
  20. package/dist/core/engine-notice.d.ts +30 -0
  21. package/dist/core/gate-fold.js +5 -1
  22. package/dist/core/gate-lanes.js +7 -7
  23. package/dist/core/gate-outcome.d.ts +25 -0
  24. package/dist/core/gate-outcome.js +7 -0
  25. package/dist/core/governance-codes.d.ts +1 -1
  26. package/dist/core/governance-codes.js +6 -0
  27. package/dist/core/hooks.d.ts +1 -1
  28. package/dist/core/read-root-candidate.d.ts +12 -0
  29. package/dist/core/read-root-candidate.js +24 -0
  30. package/dist/core/roles.d.ts +9 -1
  31. package/dist/core/roles.js +4 -0
  32. package/dist/core/runner/classifier-ladder.d.ts +149 -11
  33. package/dist/core/runner/classifier-ladder.js +159 -10
  34. package/dist/core/runner/contracts.d.ts +2 -0
  35. package/dist/core/runner/denial-limit-arms.js +2 -2
  36. package/dist/core/runner/prepare-ask-lane.js +1 -0
  37. package/dist/core/runner/prepare-caps-and-workflow.js +20 -31
  38. package/dist/core/runner/prepare-config-doors.d.ts +6 -0
  39. package/dist/core/runner/prepare-config-doors.js +8 -10
  40. package/dist/core/runner/prepare-project-context.d.ts +5 -1
  41. package/dist/core/runner/prepare-project-context.js +3 -2
  42. package/dist/core/runner/prepare-task.js +1 -1
  43. package/dist/core/runner/resume-apply.js +3 -3
  44. package/dist/core/runner/run-leg.js +10 -4
  45. package/dist/core/runner/run-turn-boundary.js +12 -4
  46. package/dist/core/runner/synthetic-tools.d.ts +67 -29
  47. package/dist/core/runner/synthetic-tools.js +51 -22
  48. package/dist/core/runner/turn-attachments.d.ts +43 -12
  49. package/dist/core/runner/turn-attachments.js +58 -14
  50. package/dist/core/sensitive-path-policy.d.ts +58 -17
  51. package/dist/core/sensitive-path-policy.js +33 -5
  52. package/dist/core/skill-listing-budget.d.ts +112 -0
  53. package/dist/core/skill-listing-budget.js +58 -0
  54. package/dist/core/task-spec.d.ts +26 -0
  55. package/dist/core/tool-policy.d.ts +19 -4
  56. package/dist/core/tool-policy.js +6 -7
  57. package/dist/core/tool-spec.d.ts +37 -4
  58. package/dist/core/types.d.ts +1 -1
  59. package/dist/core/write-protect.d.ts +8 -3
  60. package/dist/core/write-protect.js +8 -5
  61. package/dist/index.d.ts +4 -4
  62. package/dist/index.js +3 -3
  63. package/dist/tools/fs/bash-readonly-classifier.d.ts +27 -0
  64. package/dist/tools/fs/bash-readonly-classifier.js +59 -6
  65. package/dist/tools/fs/fs-bash.js +7 -3
  66. package/dist/tools/fs/read-deny.d.ts +4 -3
  67. package/dist/tools/fs/safety.d.ts +15 -0
  68. package/dist/tools/fs/safety.js +7 -0
  69. package/package.json +1 -1
  70. package/test/export-surface.snapshot.json +15 -5
  71. package/wire-changes/core@7.18.0.json +1273 -0
  72. package/wire-changes/core@7.19.0.json +457 -0
@@ -574,7 +574,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
574
574
  deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId });
575
575
  }
576
576
  }
577
- const projectContext = await prepareProjectContext({ deps, spec, internals, session, sessionId, hostTaskId, taskRootFinal, handsEnabled, delegation, seedContextFiles, memoryBlockFromEngine, skillScope, roster, toolFaceSnapshot, onceLedger, memoryTools, memoryEngineSession, memorySearchToolsPlanned, memoryPairExcludedName, memoryPairOccupiedName, resume });
577
+ const projectContext = await prepareProjectContext({ deps, spec, internals, session, sessionId, hostTaskId, taskRootFinal, handsEnabled, delegation, seedContextFiles, memoryBlockFromEngine, skillScope, roster, toolFaceSnapshot, onceLedger, memoryTools, memoryEngineSession, memorySearchToolsPlanned, memoryPairExcludedName, memoryPairOccupiedName, resume, skillListingBudget: doors.skillListingBudget });
578
578
  const { memoryBlock: memoryBlockWithProject, instructionSources, projectInstructionContent, hasSkillManifest, skillsListing, sharedMemoryPairMounted, memoryEnginePairMounted } = projectContext;
579
579
  const { provider, featureFlags, userSystemPrompt, userAppendSystemPrompt, mcpInstructionsBlock, envFacts, tzValid, userTz, gitStatusRef } = await preparePromptInputs({ spec, deps, failClosedReason: selfOrchestrationFailClosedReason(spec, deps), lockedPreflight, selfOrchestrationActive, executionEnv, reminderMark, resolvedReadFace, resolvedWriteFace, toolFaceSnapshot, promptProfile, fableMitigations, internals, thinking, ownedEnv, delegation, resolvedRole, mcp, model, externalContentTargetActive, resume, handsEnabled, taskRootFinal, additionalRootsCanonical, additionalReadRootsCanonical, sessionId, ...(advertisedWritableDirs !== undefined ? { advertisedWritableDirs } : {}) });
580
580
  const { deferred, memoryBlock, toolSearch: toolSearchPolicy } = prepareDeferClassify({ spec, deps, roster, toolFaceSnapshot, handsEnabled, fullShellReachable, promptProfile, model, mcp, a2a, sharedMemoryPairMounted, memoryEnginePairMounted, memoryBlock: memoryBlockWithProject, memoryRecallSegment, onceLedger, sessionId });
@@ -2,6 +2,7 @@ import { snapshotActorAssertion } from "../../internal/llm.js";
2
2
  import { isAskOrigin } from "../ask-origin.js";
3
3
  import { ASK_USER_QUESTION_TOOL_NAME } from "../ask-question.js";
4
4
  import { CheckpointError, remainingBudgetMicroUsd } from "../checkpoint-store.js";
5
+ import { settledByPerson } from "../gate-outcome.js";
5
6
  import { cloneObserverInput, mintHookInvocationIdentity, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
6
7
  import { checkToolPolicyProjection, refuseOutOfContractDecision } from "../tool-policy.js";
7
8
  import { callFaceSeat, parkedCallFaceOf } from "../tool-roster.js";
@@ -170,10 +171,9 @@ export async function resumeApply(input) {
170
171
  throw new CheckpointError("checkpoint.unsupported_version", "this checkpoint's pending approval carries no origin word (a row minted before the ask's origin was persisted on the park) — its settlement cannot be recorded; the checkpoint stays pending", { reason: "origin_missing" });
171
172
  }
172
173
  const facts = plainPolicyOutcome.hostDecision;
173
- const approverCell = facts.approver !== undefined ? { approver: facts.approver } : {};
174
174
  const settlement = facts.decidedBy === "sla_timeout"
175
- ? { kind: "park_sla_expired", who: { party: "host", ...approverCell }, when: Date.now() }
176
- : { kind: plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", who: { party: "person", ...approverCell }, when: Date.now(), ...(plainPolicyOutcome.decision === "deny" && plainPolicyOutcome.reason !== undefined ? { note: plainPolicyOutcome.reason } : {}) };
175
+ ? { kind: "park_sla_expired", who: { party: "host", ...(facts.approver !== undefined ? { approver: facts.approver } : {}) }, when: Date.now() }
176
+ : settledByPerson(plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", facts.approver, plainPolicyOutcome.decision === "deny" ? plainPolicyOutcome.reason : undefined);
177
177
  plainPolicyOutcome = { ...plainPolicyOutcome, gateOutcome: mintGateOutcome({ ...(plainPolicyOutcome.decision === "deny" ? { deniedBy: "ask_resolution" } : {}), settled: { settlement, origin: parkedOrigin } }) };
178
178
  }
179
179
  const outcomeForStore = plainPolicyOutcome ?? plainReviewOutcome ?? plainParkOutcome;
@@ -26,7 +26,8 @@ import { maybeHumanRejectionHalt } from "./prepare-gate-stations.js";
26
26
  import { nextHumanInputSeq } from "./steer-admission.js";
27
27
  import { settleTeardownLeg } from "./teardown-bounded.js";
28
28
  import { toolEndBodyFrom, toolResultMsg } from "./tool-end-body.js";
29
- import { attachmentEnvelopeTags, commitAgentListing, commitSkillsListing, reduceToolEnd, renderAgentListingDelta, renderOrphanedBackgroundTasks, renderSkillsListingDelta, stampWriteAnchor } from "./turn-attachments.js";
29
+ import { announceSkillListingTruncation } from "../skill-listing-budget.js";
30
+ import { attachmentEnvelopeTags, commitAgentListing, commitSkillsListing, reduceToolEnd, renderAgentListingDelta, renderOrphanedBackgroundTasks, renderSkillsListingFrame, stampWriteAnchor } from "./turn-attachments.js";
30
31
  export async function runLeg(input) {
31
32
  const { spec, queue, prepared, resume, rs, isDelegatedChild, entryActor, effectiveTimeoutMs, timeout, pushContent, ident, emitCommitted, writeFamilyOf, startedToolCallIds, announceWorkspaceMove, withBrainSinks, flushGitMirror, loopLatch, notificationLane, unsubscribeTaskNotifications, unsubGitRetry, unsubBoundary, unsub, runner, next } = input;
32
33
  let final;
@@ -331,6 +332,7 @@ export async function runLeg(input) {
331
332
  }
332
333
  }
333
334
  const firstFrames = [];
335
+ let skillsListingCost;
334
336
  if (rs.attach.attachState !== undefined) {
335
337
  const attach = rs.attach.attachState;
336
338
  if (rs.attach.agentListingOn && prepared.agentListing !== undefined) {
@@ -351,14 +353,15 @@ export async function runLeg(input) {
351
353
  }
352
354
  if (rs.attach.skillsListingOn && prepared.skillsListing !== undefined) {
353
355
  const listing = prepared.skillsListing;
354
- const body = renderSkillsListingDelta(attach, listing.entries);
355
- if (body !== undefined) {
356
+ const frame = renderSkillsListingFrame(attach, listing.entries, listing.budget);
357
+ if (frame !== undefined) {
356
358
  firstFrames.push({
357
359
  source: "skills_listing",
358
- body,
360
+ body: frame.text,
359
361
  commit: () => {
360
362
  commitSkillsListing(attach, listing.entries);
361
363
  prepared.announcedListingsRef.skills = listing.entries.map((e) => e.name);
364
+ skillsListingCost = { total: frame.listed, nameOnly: frame.nameOnly, dropped: frame.dropped, budget: listing.budget };
362
365
  },
363
366
  });
364
367
  }
@@ -432,6 +435,9 @@ export async function runLeg(input) {
432
435
  rs.attach.attachmentsInjected += firstFrames.length;
433
436
  if (firstFrames.length > 0 && prepared.announcedSnapshotRecovered)
434
437
  await prepared.session.appendAnnouncedListing(prepared.announcedListingsRef).catch(() => undefined);
438
+ if (skillsListingCost !== undefined) {
439
+ announceSkillListingTruncation(runner.deps.onNotice, { ...skillsListingCost, sessionId: prepared.sessionId });
440
+ }
435
441
  const enginePrefixChars = effectiveObjective.length - projectedObjective.length;
436
442
  queue.push({
437
443
  ...buildHumanInputEvent({
@@ -4,7 +4,8 @@ import { eventDefaultOn } from "../../prompt-assembly/event-registry.js";
4
4
  import { DEFAULT_COMPACTION_INSTRUCTIONS, isCompactionManualCancel, maybeCompact, nextTrimForceBackoff, recordCompactionAndCheckRapidRefill } from "../auto-compaction.js";
5
5
  import { emitTrace } from "../trace.js";
6
6
  import { redactThenCut } from "../../agents/subagent-steps.js";
7
- import { ATTACHMENT_BYTE_CAP, CHANGED_FILES_MAX, TOTAL_TOKENS_REMINDER_DEFAULT_MODE, attachmentEnvelopeTags, clipToBytes, collectDateChange, collectDueAttachments, collectInstructionsChange, commitAgentListing, commitInstructionsChange, commitSkillsListing, rebaseCadenceWindows, renderAgentListingDelta, renderMcpDroppedTools, renderMcpInstructionsDelta, selectMcpDroppedBatch, renderSkillsListingDelta, renderToolsDelta } from "./turn-attachments.js";
7
+ import { ATTACHMENT_BYTE_CAP, CHANGED_FILES_MAX, TOTAL_TOKENS_REMINDER_DEFAULT_MODE, attachmentEnvelopeTags, clipToBytes, collectDateChange, collectDueAttachments, collectInstructionsChange, commitAgentListing, commitInstructionsChange, commitSkillsListing, rebaseCadenceWindows, renderAgentListingDelta, renderMcpDroppedTools, renderMcpInstructionsDelta, selectMcpDroppedBatch, renderSkillsListingFrame, renderToolsDelta } from "./turn-attachments.js";
8
+ import { announceSkillListingTruncation } from "../skill-listing-budget.js";
8
9
  import { buildWorkingFileAttachments, centerAdoptionOption, contextInstructionFilesOption, emitInputTruncated, forkContextOption } from "./compaction-call-options.js";
9
10
  import { hookSeatExpiredError, runHookSeat } from "../hooks.js";
10
11
  import { sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
@@ -202,6 +203,7 @@ export function createTurnBoundary(input) {
202
203
  }
203
204
  let boundaryAttachmentBytes = 0;
204
205
  let attachmentsPayload;
206
+ let skillsListingCost;
205
207
  if ((rs.attach.dateState !== undefined ||
206
208
  rs.attach.instrState !== undefined ||
207
209
  rs.attach.sizeGuidelineState !== undefined ||
@@ -313,7 +315,9 @@ export function createTurnBoundary(input) {
313
315
  ...(prepared.agentListing.models !== undefined ? { agentModels: prepared.agentListing.models } : {}),
314
316
  }
315
317
  : {}),
316
- ...(rs.attach.skillsListingOn && prepared.skillsListing !== undefined ? { skillsListing: prepared.skillsListing.entries } : {}),
318
+ ...(rs.attach.skillsListingOn && prepared.skillsListing !== undefined
319
+ ? { skillsListing: prepared.skillsListing.entries, skillsListingBudget: prepared.skillsListing.budget }
320
+ : {}),
317
321
  ...(mcpAdds !== undefined || mcpRemovals !== undefined
318
322
  ? { mcpInstructions: { added: mcpAdds ?? [], removed: mcpRemovals ?? [] } }
319
323
  : {}),
@@ -377,9 +381,10 @@ export function createTurnBoundary(input) {
377
381
  }
378
382
  if (rs.attach.skillsListingOn && prepared.skillsListing !== undefined && due.some((a) => a.source === "skills_listing")) {
379
383
  const listing = prepared.skillsListing;
380
- const intactBody = renderSkillsListingDelta(rs.attach.attachState, listing.entries);
381
- if (intactBody !== undefined && due.some((a) => a.source === "skills_listing" && a.body === intactBody)) {
384
+ const intact = renderSkillsListingFrame(rs.attach.attachState, listing.entries, listing.budget);
385
+ if (intact !== undefined && due.some((a) => a.source === "skills_listing" && a.body === intact.text)) {
382
386
  commitSkillsListing(rs.attach.attachState, listing.entries);
387
+ skillsListingCost = { total: intact.listed, nameOnly: intact.nameOnly, dropped: intact.dropped, budget: listing.budget };
383
388
  prepared.announcedListingsRef.skills = listing.entries.map((e) => e.name);
384
389
  if (prepared.announcedSnapshotRecovered)
385
390
  await prepared.session.appendAnnouncedListing(prepared.announcedListingsRef).catch(() => undefined);
@@ -474,6 +479,9 @@ export function createTurnBoundary(input) {
474
479
  if ((attachmentsPayload !== undefined || batchContextBlock !== undefined) && prepared.batchHaltRef.current === undefined) {
475
480
  const payload = attachmentsPayload !== undefined && batchContextBlock !== undefined ? `${attachmentsPayload}\n${batchContextBlock}` : (attachmentsPayload ?? batchContextBlock);
476
481
  void prepared.harness.steer(payload, { engineMinted: true }).catch(() => { });
482
+ if (skillsListingCost !== undefined) {
483
+ announceSkillListingTruncation(deps.runner.deps.onNotice, { ...skillsListingCost, sessionId: prepared.sessionId });
484
+ }
477
485
  }
478
486
  const forceManual = manualCompactRef.requested;
479
487
  if ((!withinTaskCompaction && !forceManual) ||
@@ -1,6 +1,7 @@
1
1
  import { type TSchema } from "typebox";
2
2
  import type { AgentTool } from "../../internal/harness-types.js";
3
3
  import type { SkillManifest } from "../types.js";
4
+ import { type ResolvedSkillListingBudget } from "../skill-listing-budget.js";
4
5
  import type { ActiveSkillScope } from "./active-skill-scope.js";
5
6
  /** Reserved name of the injected structured-output tool (CC 2.1.187 `StructuredOutput`; batch-A rename — old
6
7
  * "submit_output" is canonicalized for pre-rename durable state). */
@@ -57,15 +58,11 @@ export declare const SKILL_TOOL_NAME = "Skill";
57
58
  * an oversized skill is excluded from the disclosure block and the tool, with an operator-visible config error. The compaction-time retention caps are a
58
59
  * different door, deliberately. */
59
60
  export declare const SKILL_CONTENT_MAX_CHARS: number;
60
- /** Total listing-block cap in UTF-8 BYTES — [c209-C] R2 C4①: the old 8000-CHAR cap measured a
61
- * different coordinate than the bundle's `ATTACHMENT_BYTE_CAP` (8192 UTF-8 bytes) and counted only
62
- * the description lines (fence framing + name-only tail rode free), so a near-cap frame could
63
- * exceed the bundle cap, get clip-mangled every boundary, never pass the intact-survival commit
64
- * predicate, and REPLAY FOREVER. The cap is now enforced byte-exact over the WHOLE rendered block
65
- * (fences, headers, markers, joiners included), so `buildSkillsBlock` output always fits the
66
- * bundle cap solo and an intact commit is always reachable. MUST stay ≤ ATTACHMENT_BYTE_CAP
67
- * (turn-attachments.ts; not imported here — that module imports this one — pinned by test instead). */
68
- export declare const SKILLS_BLOCK_MAX_BYTES = 8000;
61
+ /** The listing's budget vocabulary lives in `core/skill-listing-budget.ts` (the seat reader that lowers
62
+ * the two `TaskSpec` knobs to these numbers); re-exported here because this module is where every
63
+ * existing consumer of the cap imports it from, and the ceiling's structural reason is a property of
64
+ * the delivery lane rather than of the renderer. */
65
+ export { SKILLS_BLOCK_MAX_BYTES } from "../skill-listing-budget.js";
69
66
  /** Total per-invoke cap for a skill's inlined attachments (`SkillSpec.files`) — an ATTACHMENT-face budget, distinct from the (gone) body truncation door:
70
67
  * the body is delivered untruncated at invoke time (CC parity), while attachments ride along bounded and truncation-disclosed. */
71
68
  export declare const SKILL_ATTACHMENTS_MAX_CHARS = 50000;
@@ -123,31 +120,73 @@ export declare const SKILLS_LISTING_PROBE_HEADER = "The following skills are ava
123
120
  /** [c209-C] One skill's disclosure line — `- name: description[(files 尾缀)]` with the per-skill
124
121
  * description cap. Single-sourced so the skills_listing DELTA frame (turn-attachments) renders adds
125
122
  * with byte-identical line shape to the initial `<skills>` block. */
126
- export declare function skillListingLine(s: SkillListingEntry): string;
123
+ export declare function skillListingLine(s: SkillListingEntry, descMaxChars?: number): string;
124
+ /** What one {@link renderSkillsBlock} call produced: the block, and which skills the budget degraded. */
125
+ export interface SkillsBlockRender {
126
+ /** The rendered block — what {@link buildSkillsBlock} returns. */
127
+ text: string;
128
+ /** Skills listed WITHOUT their description (tier B's unspent candidates, or every listed name under
129
+ * tier C), in display order. Raw names: this is host-facing structured data, not prompt text. */
130
+ nameOnly: readonly string[];
131
+ /** Skills the block could not list at all (tier C's overflow tail). The `Skill` tool still serves them. */
132
+ dropped: readonly string[];
133
+ }
134
+ /** The text-only face of {@link renderSkillsBlock} — the `<skills>` BLOCK's face, not the listing
135
+ * family's: the drift frame renders its own arms through the shared tier engine
136
+ * ({@link renderSkillLinesWithin}), which is where "one ruler for every frame" lives. */
137
+ export declare function buildSkillsBlock(skills: ReadonlyArray<SkillListingEntry>, budget?: ResolvedSkillListingBudget): string | undefined;
127
138
  /** Render the `<skills>` disclosure block (metadata only — bodies stay behind the tool).
128
139
  * [c209-C] REVERSAL: no longer composed into the stable system prompt — this is now the RENDERER for
129
140
  * the `skills_listing` initial frame (first-user-turn reminder / boundary re-announce), so the
130
141
  * cached prefix carries zero skill bytes (design/C §2.3; CC 209 classic ships the same listing as a
131
142
  * first-turn reminder; Q6: 8KB cap ≈ CC's observed 5.9KB listing).
132
143
  *
133
- * [c209-C] R2 C4① — the cap is BYTE-EXACT over the whole rendered block (see
134
- * {@link SKILLS_BLOCK_MAX_BYTES}): three honesty tiers, each chosen with the LATER tiers' fixed
135
- * overhead (cap marker / name-only tail / overflow count line) already reserved so the final
136
- * render can never blow the budget it was sized against —
137
- * A. everything fits → every skill gets its full `- name: description` line (byte-identical to
138
- * the pre-C4 render for every block that was legal under the old cap);
139
- * B. (CC 220 `sbs` @317547-317586) — reserve the cap marker + EVERY skill's name-only line
140
- * first, then spend the remaining slack on descriptions in PRIORITY order
141
- * ({@link SkillListingEntry.declaredRank}), admitting each description whose incremental cost
142
- * still fits and skipping (not stopping at) the ones that do not. Every skill stays listed;
143
- * the ones that lose their description are the low-priority ones, not the alphabetically late
144
- * ones. This replaces the prior "longest full-line PREFIX" cut, which had two defects the
145
- * anchor does not: the surviving set was decided by name sort order (a skill named `zip-deploy`
146
- * lost its description to `aardvark-lint` for no reason connected to either skill), and one
147
- * oversized description truncated the block at itself instead of being skipped over;
148
- * C. even all-name-only overflows (pathological name census) → list what fits and close with an
149
- * honest `(… +N more skills …)` count line — the tool still serves all of them. */
150
- export declare function buildSkillsBlock(skills: ReadonlyArray<SkillListingEntry>): string | undefined;
144
+ * The fence and its four header lines are this lane's frame; the budget arithmetic and the three
145
+ * degradation tiers belong to {@link renderSkillLinesWithin}, which the drift frame renders through
146
+ * as well. {@link buildSkillsBlock} is the text-only face; this one additionally reports what the
147
+ * budget cost, for the disclosure lane. */
148
+ export declare function renderSkillsBlock(skills: ReadonlyArray<SkillListingEntry>, budget?: ResolvedSkillListingBudget): SkillsBlockRender | undefined;
149
+ /**
150
+ * The three honesty tiers over ONE set of skills, measured byte-exact inside a caller-supplied FRAME —
151
+ * the engine both {@link renderSkillsBlock} and the drift frame's ADD arm render through, so the
152
+ * initial `<skills>` block and a later "new skills are now available" announcement degrade by the same
153
+ * rules against the same number. A lane that folded by its own rules (or by none) renders a body the
154
+ * bundle cap CLIPS, and a clipped body can never equal the intact-survival re-render, so the
155
+ * announcement replays at every boundary for the life of the task — the failure
156
+ * `@contract skills.listing_budget_ceiling` names, on whichever lane skipped the measurement.
157
+ *
158
+ * `frame` renders the body lines into the shape its lane carries; its fixed cost is priced once, by
159
+ * rendering it empty, so the tiers below decide against the bytes that actually ship.
160
+ *
161
+ * [c209-C] R2 C4① — the cap is BYTE-EXACT over the whole rendered frame (see
162
+ * {@link SKILLS_BLOCK_MAX_BYTES}): three honesty tiers, each chosen with the LATER tiers' fixed
163
+ * overhead (cap marker / name-only tail / overflow count line) already reserved so the final
164
+ * render can never blow the budget it was sized against —
165
+ * A. everything fits → every skill gets its full `- name: description` line (byte-identical to
166
+ * the pre-C4 render for every block that was legal under the old cap);
167
+ * B. (CC 220 `sbs` @317547-317586) — reserve the cap marker + EVERY skill's name-only line
168
+ * first, then spend the remaining slack on descriptions in PRIORITY order
169
+ * ({@link SkillListingEntry.declaredRank}), admitting each description whose incremental cost
170
+ * still fits and skipping (not stopping at) the ones that do not. Every skill stays listed;
171
+ * the ones that lose their description are the low-priority ones, not the alphabetically late
172
+ * ones. This replaces the prior "longest full-line PREFIX" cut, which had two defects the
173
+ * anchor does not: the surviving set was decided by name sort order (a skill named `zip-deploy`
174
+ * lost its description to `aardvark-lint` for no reason connected to either skill), and one
175
+ * oversized description truncated the block at itself instead of being skipped over;
176
+ * C. even all-name-only overflows (pathological name census) → list what fits and close with an
177
+ * honest `(… +N more skills …)` count line — the tool still serves all of them.
178
+ *
179
+ * Pure: the same entries, budget and frame always render the same bytes, which is what the
180
+ * prompt-prefix stability contract requires of the listing frames.
181
+ */
182
+ export declare function renderSkillLinesWithin(skills: ReadonlyArray<SkillListingEntry>, opts: {
183
+ /** The whole frame's byte budget. Re-clamped to {@link SKILLS_BLOCK_MAX_BYTES} here, not only in
184
+ * the seat reader: this renderer is reachable from every listing lane, and the ceiling invariant
185
+ * has to hold for a caller that computed its own number. */
186
+ blockMaxBytes: number;
187
+ descMaxChars: number;
188
+ frame: (lines: readonly string[]) => string;
189
+ }): SkillsBlockRender | undefined;
151
190
  /**
152
191
  * The injected `skill` tool: returns a skill's full body as a tool result.
153
192
  *
@@ -164,4 +203,3 @@ export declare function buildSkillsBlock(skills: ReadonlyArray<SkillListingEntry
164
203
  * widening, so it stays safe-by-construction.
165
204
  */
166
205
  export declare function createSkillTool(skills: SkillLike[], scope?: ActiveSkillScope): AgentTool;
167
- export {};
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool } from "../tools.js";
3
3
  import { sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
4
+ import { SKILLS_BLOCK_MAX_BYTES as SKILL_LISTING_CEILING_BYTES, SKILL_LISTING_DESC_MAX_CHARS_DEFAULT, } from "../skill-listing-budget.js";
4
5
  import { toolFace } from "../tool-catalog-entries.js";
5
6
  import { substituteSkillRoot, substituteSkillRootBounded } from "./skill-base-dir.js";
6
7
  export const OUTPUT_TOOL_NAME = "StructuredOutput";
@@ -85,8 +86,7 @@ export function createReportFindingsTool() {
85
86
  }
86
87
  export const SKILL_TOOL_NAME = "Skill";
87
88
  export const SKILL_CONTENT_MAX_CHARS = 1024 * 1024;
88
- const SKILL_DESC_MAX_CHARS = 1_536;
89
- export const SKILLS_BLOCK_MAX_BYTES = 8_000;
89
+ export { SKILLS_BLOCK_MAX_BYTES } from "../skill-listing-budget.js";
90
90
  export const SKILL_ATTACHMENTS_MAX_CHARS = 50_000;
91
91
  export const SKILL_ATTACHMENT_PATH_MAX_CHARS = 512;
92
92
  const SKILL_FILES_LIST_MAX_CHARS = 200;
@@ -121,18 +121,18 @@ export function normalizeSkills(skills) {
121
121
  return out.toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
122
122
  }
123
123
  export const SKILLS_LISTING_PROBE_HEADER = "The following skills are available for this task.";
124
- export function skillListingLine(s) {
125
- const desc = s.description.length > SKILL_DESC_MAX_CHARS
126
- ? `${s.description.slice(0, SKILL_DESC_MAX_CHARS - 1)}…`
127
- : s.description;
124
+ export function skillListingLine(s, descMaxChars = SKILL_LISTING_DESC_MAX_CHARS_DEFAULT) {
125
+ const desc = s.description.length > descMaxChars ? `${s.description.slice(0, descMaxChars - 1)}…` : s.description;
128
126
  const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
129
127
  return `- ${safe(s.name)}: ${safe(desc)}${safe(skillFilesSuffix(s.files))}`;
130
128
  }
131
129
  const SKILLS_BLOCK_CAP_MARKER = "(block size cap reached — lower-priority skills are listed name-only; descriptions via the tool)";
132
- export function buildSkillsBlock(skills) {
130
+ export function buildSkillsBlock(skills, budget) {
131
+ return renderSkillsBlock(skills, budget)?.text;
132
+ }
133
+ export function renderSkillsBlock(skills, budget) {
133
134
  if (skills.length === 0)
134
135
  return undefined;
135
- const bytes = (s) => Buffer.byteLength(s, "utf8");
136
136
  const headerLines = [
137
137
  "<skills>",
138
138
  `${SKILLS_LISTING_PROBE_HEADER} A skill is a named package of instructions;`,
@@ -140,18 +140,34 @@ export function buildSkillsBlock(skills) {
140
140
  `instructions by calling the \`${SKILL_TOOL_NAME}\` tool with \`skill: "<name>"\` (plus optional \`args\`).`,
141
141
  "",
142
142
  ];
143
- const render = (lines) => [...headerLines, ...lines, "</skills>"].join("\n");
143
+ return renderSkillLinesWithin(skills, {
144
+ blockMaxBytes: budget?.blockMaxBytes ?? SKILL_LISTING_CEILING_BYTES,
145
+ descMaxChars: budget?.descMaxChars ?? SKILL_LISTING_DESC_MAX_CHARS_DEFAULT,
146
+ frame: (lines) => [...headerLines, ...lines, "</skills>"].join("\n"),
147
+ });
148
+ }
149
+ export function renderSkillLinesWithin(skills, opts) {
150
+ if (skills.length === 0)
151
+ return undefined;
152
+ const blockMaxBytes = Math.min(opts.blockMaxBytes, SKILL_LISTING_CEILING_BYTES);
153
+ const descMaxChars = opts.descMaxChars;
154
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
155
+ const render = opts.frame;
144
156
  const lineCost = (l) => bytes(l) + 1;
145
- const budget = SKILLS_BLOCK_MAX_BYTES - bytes(render([]));
146
- const fullLines = skills.map((s) => skillListingLine(s));
147
- const nameLines = skills.map((s) => `- ${sanitizeUntrustedText(s.name, SHELLED_BODY_ENVELOPE_TAGS)}`);
157
+ const bodyBudget = blockMaxBytes - bytes(render([]));
158
+ const fullLines = skills.map((s) => skillListingLine(s, descMaxChars));
148
159
  const fullTotal = fullLines.reduce((acc, l) => acc + lineCost(l), 0);
149
- if (fullTotal <= budget)
150
- return render(fullLines);
160
+ if (fullTotal <= bodyBudget)
161
+ return { text: render(fullLines), nameOnly: [], dropped: [] };
162
+ const nameLines = skills.map((s) => `- ${sanitizeUntrustedText(s.name, SHELLED_BODY_ENVELOPE_TAGS)}`);
163
+ const namePrefix = new Array(nameLines.length + 1);
164
+ namePrefix[0] = 0;
165
+ for (let i = 0; i < nameLines.length; i++)
166
+ namePrefix[i + 1] = namePrefix[i] + lineCost(nameLines[i]);
151
167
  const markerCost = lineCost(SKILLS_BLOCK_CAP_MARKER);
152
- const floorCost = markerCost + nameLines.reduce((acc, l) => acc + lineCost(l), 0);
153
- if (floorCost <= budget) {
154
- let slack = budget - floorCost;
168
+ const floorCost = markerCost + namePrefix[nameLines.length];
169
+ if (floorCost <= bodyBudget) {
170
+ let slack = bodyBudget - floorCost;
155
171
  const byPriority = skills
156
172
  .map((s, i) => ({ i, rank: s.declaredRank ?? i }))
157
173
  .toSorted((a, b) => a.rank - b.rank || a.i - b.i);
@@ -163,15 +179,28 @@ export function buildSkillsBlock(skills) {
163
179
  slack -= delta;
164
180
  }
165
181
  }
166
- return render([SKILLS_BLOCK_CAP_MARKER, ...skills.map((_s, i) => (withDescription.has(i) ? fullLines[i] : nameLines[i]))]);
182
+ return {
183
+ text: render([SKILLS_BLOCK_CAP_MARKER, ...skills.map((_s, i) => (withDescription.has(i) ? fullLines[i] : nameLines[i]))]),
184
+ nameOnly: skills.filter((_s, i) => !withDescription.has(i)).map((s) => s.name),
185
+ dropped: [],
186
+ };
167
187
  }
168
188
  for (let m = skills.length - 1; m >= 0; m--) {
169
189
  const overflowLine = `(… +${skills.length - m} more skills not listed — the ${SKILL_TOOL_NAME} tool serves them all)`;
170
- const cost = markerCost + nameLines.slice(0, m).reduce((acc, l) => acc + lineCost(l), 0) + lineCost(overflowLine);
171
- if (cost <= budget)
172
- return render([SKILLS_BLOCK_CAP_MARKER, ...nameLines.slice(0, m), overflowLine]);
190
+ const cost = markerCost + namePrefix[m] + lineCost(overflowLine);
191
+ if (cost <= bodyBudget) {
192
+ return {
193
+ text: render([SKILLS_BLOCK_CAP_MARKER, ...nameLines.slice(0, m), overflowLine]),
194
+ nameOnly: skills.slice(0, m).map((s) => s.name),
195
+ dropped: skills.slice(m).map((s) => s.name),
196
+ };
197
+ }
173
198
  }
174
- return render([SKILLS_BLOCK_CAP_MARKER, `(… +${skills.length} more skills not listed — the ${SKILL_TOOL_NAME} tool serves them all)`]);
199
+ return {
200
+ text: render([SKILLS_BLOCK_CAP_MARKER, `(… +${skills.length} more skills not listed — the ${SKILL_TOOL_NAME} tool serves them all)`]),
201
+ nameOnly: [],
202
+ dropped: skills.map((s) => s.name),
203
+ };
175
204
  }
176
205
  export function createSkillTool(skills, scope) {
177
206
  const byName = new Map(skills.map((s) => [s.name, s]));
@@ -1,6 +1,7 @@
1
1
  import type { McpDroppedTool } from "../mcp.js";
2
2
  import type { TaskSpec } from "../types.js";
3
- import { type SkillListingEntry } from "./synthetic-tools.js";
3
+ import { type SkillListingEntry, type SkillsBlockRender } from "./synthetic-tools.js";
4
+ import { type ResolvedSkillListingBudget } from "../skill-listing-budget.js";
4
5
  /**
5
6
  * Turn-boundary attachment seam: the PURE producer layer (CC `getContextAttachments`
6
7
  * parity). This module is the single home of the CC-parity cadence judgment + reminder copy (提示词主权批:
@@ -545,6 +546,9 @@ export interface AttachmentInputs {
545
546
  * the delta path serves dynamic-installation deployments and tests, same posture as
546
547
  * `agentListing`). Only passed when `config.skillsListing` is on. */
547
548
  skillsListing?: ReadonlyArray<SkillListingEntry>;
549
+ /** The lowered skills-listing budget seats, resolved once at the config door. Absent ⇒ the module
550
+ * defaults, i.e. byte-identical to the pre-seat render. */
551
+ skillsListingBudget?: ResolvedSkillListingBudget;
548
552
  /** G1 续批 mcp_instructions: pending deltas snapshotted from `MaterializedMcp.instructionsDelta`.
549
553
  * CALLER-owned consumption on intact cap survival (same `newTools` discipline). */
550
554
  mcpInstructions?: {
@@ -849,21 +853,48 @@ export declare function commitAgentListing(state: AttachmentState, entries: Read
849
853
  export declare const SKILLS_LISTING_DELTA_HEADER = "New skills are now available:";
850
854
  /** [c209-C] skills removal-arm header — mirrors the agent-listing removal arm's phrasing. */
851
855
  export declare const SKILLS_LISTING_REMOVED_HEADER = "The following skills are no longer available:";
856
+ /** What one {@link renderSkillsListingFrame} call produced: the render's own report, plus the
857
+ * denominator it is against. */
858
+ export interface SkillsListingFrameRender extends SkillsBlockRender {
859
+ /** How many skills this frame set out to list — the whole roster on an initial frame, the ADDED set
860
+ * on a drift frame. The disclosure's denominator, read off the frame that was actually delivered
861
+ * rather than re-derived from the roster. */
862
+ readonly listed: number;
863
+ }
852
864
  /**
853
- * [c209-C] skills_listing producer — the skills counterpart of {@link renderAgentListingDelta}.
854
- * First call (no announced map) = the full `<skills>` disclosure block VERBATIM (buildSkillsBlock —
855
- * the exact bytes that used to sit in the stable system prompt now ride the reminder lane; CC 209
856
- * classic ships its skills listing the same way, as a first-user-turn reminder ~5.9KB, report §5.1).
857
- * Later calls announce only drift, name-keyed like the agent arm: adds under
858
- * {@link SKILLS_LISTING_DELTA_HEADER} with the SAME `- name: description` line shape the block uses,
859
- * removals under {@link SKILLS_LISTING_REMOVED_HEADER} + `- <name>` lines, followed by the
860
- * CC-verbatim {@link AMBIENT_CONTEXT_NOTE} (agent-removal-arm parity). A description change on an
861
- * existing name is not re-announced (name-keyed, agent-arm parity).
865
+ * [c209-C] skills_listing producer — the skills counterpart of {@link renderAgentListingDelta}, with
866
+ * the degradation report the disclosure lane announces. First call (no announced map) = the full
867
+ * `<skills>` disclosure block VERBATIM (the exact bytes that used to sit in the stable system prompt
868
+ * now ride the reminder lane; CC 209 classic ships its skills listing the same way, as a
869
+ * first-user-turn reminder ~5.9KB, report §5.1). Later calls announce only drift, name-keyed like the
870
+ * agent arm: adds under {@link SKILLS_LISTING_DELTA_HEADER} with the SAME `- name: description` line
871
+ * shape the block uses, removals under {@link SKILLS_LISTING_REMOVED_HEADER} + `- <name>` lines,
872
+ * followed by the CC-verbatim {@link AMBIENT_CONTEXT_NOTE} (agent-removal-arm parity). A description
873
+ * change on an existing name is not re-announced (name-keyed, agent-arm parity).
874
+ *
875
+ * ONE RULER FOR EVERY FRAME: the drift frame is measured against the SAME resolved budget as the
876
+ * initial block, arms and joiners included, and its ADD arm degrades through the same three tiers
877
+ * ({@link renderSkillLinesWithin}). An unmeasured drift frame is the block's own failure mode one lane
878
+ * over: a roster's worth of new skills renders past `ATTACHMENT_BYTE_CAP`, `capAttachments` clips the
879
+ * body, a clipped body never equals the intact-survival re-render, and the frame is re-rendered and
880
+ * re-clipped at every boundary for the life of the task. The split: the ambient tail and the removal
881
+ * arm's one-line floor are reserved first (both constants), the ADD arm degrades into what is left,
882
+ * and the removal arm spends whatever the add arm did not — so the frame's size is the resolved budget
883
+ * or, when even the two floors do not fit it, a small constant, never a function of the roster.
884
+ *
885
+ * The report covers the ADD arm only: `nameOnly` / `dropped` name skills this frame listed without a
886
+ * description or could not list at all. A departed name that did not fit is not a listed skill.
862
887
  *
863
888
  * MED-3① discipline: PURE — the announced-set advance is CALLER-owned via {@link commitSkillsListing},
864
- * committed only on first-frame delivery or intact cap survival at a boundary.
889
+ * committed only on first-frame delivery or intact cap survival at a boundary. The commit records
890
+ * EVERY entry, including one this frame dropped: the alternative is a frame re-rendering the same
891
+ * un-listable tail at every boundary, which is the replay this budget exists to end. The `Skill` tool
892
+ * serves a dropped skill, and `skills.listing_truncated` tells the deployment it happened.
865
893
  */
866
- export declare function renderSkillsListingDelta(state: AttachmentState, entries: ReadonlyArray<SkillListingEntry>): string | undefined;
894
+ export declare function renderSkillsListingFrame(state: AttachmentState, entries: ReadonlyArray<SkillListingEntry>, budget?: ResolvedSkillListingBudget): SkillsListingFrameRender | undefined;
895
+ /** The text-only face of {@link renderSkillsListingFrame} — what the delivery lanes render, and what
896
+ * the intact-survival predicate re-renders to compare. */
897
+ export declare function renderSkillsListingDelta(state: AttachmentState, entries: ReadonlyArray<SkillListingEntry>, budget?: ResolvedSkillListingBudget): string | undefined;
867
898
  /** [c209-C] commit half of {@link renderSkillsListingDelta} — same MED-3① contract as
868
899
  * {@link commitAgentListing}: called by the run loop only on first-frame delivery or when the
869
900
  * `skills_listing` block survived the capped bundle INTACT; a clipped/dropped block leaves the
@@ -1,7 +1,8 @@
1
1
  import { PRESENT_PLAN_TOOL_NAME } from "../present-plan-tool.js";
2
2
  import { delimitUntrustedWithClip, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
3
3
  import { protocolOf } from "../protocol-table.js";
4
- import { buildSkillsBlock, skillListingLine } from "./synthetic-tools.js";
4
+ import { renderSkillLinesWithin, renderSkillsBlock } from "./synthetic-tools.js";
5
+ import { SKILL_LISTING_DESC_MAX_CHARS_DEFAULT, SKILLS_BLOCK_MAX_BYTES } from "../skill-listing-budget.js";
5
6
  import { TOOL_SEARCH_NAME as TOOL_SEARCH_TOOL_NAME } from "./tool-disclosure.js";
6
7
  export const TODO_REMINDER_CONFIG = {
7
8
  TURNS_SINCE_WRITE: 10,
@@ -209,7 +210,9 @@ export function collectDueAttachments(state, inp) {
209
210
  const agentListingBody = inp.config.agentListing && inp.agentListing !== undefined
210
211
  ? renderAgentListingDelta(state, inp.agentListing, inp.agentToolName ?? "Agent", inp.agentModels)
211
212
  : undefined;
212
- const skillsListingBody = inp.config.skillsListing && inp.skillsListing !== undefined ? renderSkillsListingDelta(state, inp.skillsListing) : undefined;
213
+ const skillsListingBody = inp.config.skillsListing && inp.skillsListing !== undefined
214
+ ? renderSkillsListingDelta(state, inp.skillsListing, inp.skillsListingBudget)
215
+ : undefined;
213
216
  const listingPair = [];
214
217
  if (agentListingBody !== undefined)
215
218
  listingPair.push({ source: "agent_listing", body: agentListingBody });
@@ -545,10 +548,9 @@ export function renderAgentListingDelta(state, entries, toolName, models) {
545
548
  return blocks.join("\n\n");
546
549
  }
547
550
  const announcedIds = new Set([...announced.keys()].map(listingIdentity));
551
+ const entryIds = new Set(entries.map((e) => listingIdentity(e.name)));
548
552
  const added = entries.filter((e) => !announcedIds.has(listingIdentity(e.name))).toSorted((a, b) => a.name.localeCompare(b.name));
549
- const removed = [...announced.keys()]
550
- .filter((n) => !entries.some((e) => listingIdentity(e.name) === listingIdentity(n)))
551
- .sort();
553
+ const removed = [...announced.keys()].filter((n) => !entryIds.has(listingIdentity(n))).sort();
552
554
  const modelsDrifted = state.announcedModels !== undefined &&
553
555
  models !== undefined &&
554
556
  (state.announcedModels.length !== models.length ||
@@ -594,26 +596,68 @@ export function commitAgentListing(state, entries, models) {
594
596
  }
595
597
  export const SKILLS_LISTING_DELTA_HEADER = "New skills are now available:";
596
598
  export const SKILLS_LISTING_REMOVED_HEADER = "The following skills are no longer available:";
597
- export function renderSkillsListingDelta(state, entries) {
599
+ const SKILLS_DELTA_BLOCK_JOINER = "\n\n";
600
+ function skillsRemovalArmFloor(count) {
601
+ return [SKILLS_LISTING_REMOVED_HEADER, `(… +${count} more are no longer available)`].join("\n");
602
+ }
603
+ function renderSkillsRemovalArm(removed, maxBytes) {
604
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
605
+ const lines = removed.map((n) => `- ${sanitizeUntrustedText(n, SHELLED_BODY_ENVELOPE_TAGS)}`);
606
+ const head = bytes(SKILLS_LISTING_REMOVED_HEADER);
607
+ const lineCost = (l) => bytes(l) + 1;
608
+ const prefix = new Array(lines.length + 1);
609
+ prefix[0] = 0;
610
+ for (let i = 0; i < lines.length; i++)
611
+ prefix[i + 1] = prefix[i] + lineCost(lines[i]);
612
+ if (head + prefix[lines.length] <= maxBytes)
613
+ return [SKILLS_LISTING_REMOVED_HEADER, ...lines].join("\n");
614
+ for (let m = lines.length - 1; m >= 0; m--) {
615
+ const tail = `(… +${lines.length - m} more are no longer available)`;
616
+ if (head + prefix[m] + lineCost(tail) <= maxBytes)
617
+ return [SKILLS_LISTING_REMOVED_HEADER, ...lines.slice(0, m), tail].join("\n");
618
+ }
619
+ return skillsRemovalArmFloor(lines.length);
620
+ }
621
+ export function renderSkillsListingFrame(state, entries, budget) {
598
622
  const announced = state.announcedSkills;
599
623
  if (announced === undefined) {
600
624
  if (entries.length === 0)
601
625
  return undefined;
602
- return buildSkillsBlock(entries);
626
+ const block = renderSkillsBlock(entries, budget);
627
+ return block === undefined ? undefined : { ...block, listed: entries.length };
603
628
  }
604
629
  const announcedIds = new Set([...announced.keys()].map(listingIdentity));
630
+ const entryIds = new Set(entries.map((e) => listingIdentity(e.name)));
605
631
  const added = entries.filter((e) => !announcedIds.has(listingIdentity(e.name)));
606
- const removed = [...announced.keys()].filter((n) => !entries.some((e) => listingIdentity(e.name) === listingIdentity(n)));
632
+ const removed = [...announced.keys()].filter((n) => !entryIds.has(listingIdentity(n)));
607
633
  if (added.length === 0 && removed.length === 0)
608
634
  return undefined;
609
- const blocks = [];
610
- if (added.length > 0)
611
- blocks.push(`${SKILLS_LISTING_DELTA_HEADER}\n${added.map((e) => skillListingLine(e)).join("\n")}`);
635
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
636
+ const joiner = bytes(SKILLS_DELTA_BLOCK_JOINER);
637
+ const frameMaxBytes = Math.min(budget?.blockMaxBytes ?? SKILLS_BLOCK_MAX_BYTES, SKILLS_BLOCK_MAX_BYTES);
638
+ const ambientCost = removed.length > 0 ? joiner + bytes(AMBIENT_CONTEXT_NOTE) : 0;
639
+ const removalReserve = removed.length > 0 ? joiner + bytes(skillsRemovalArmFloor(removed.length)) : 0;
640
+ const addArm = added.length > 0
641
+ ? renderSkillLinesWithin(added, {
642
+ blockMaxBytes: frameMaxBytes - ambientCost - removalReserve,
643
+ descMaxChars: budget?.descMaxChars ?? SKILL_LISTING_DESC_MAX_CHARS_DEFAULT,
644
+ frame: (lines) => [SKILLS_LISTING_DELTA_HEADER, ...lines].join("\n"),
645
+ })
646
+ : undefined;
647
+ const addCost = addArm === undefined ? 0 : bytes(addArm.text) + (removed.length > 0 ? joiner : 0);
648
+ const blocks = addArm === undefined ? [] : [addArm.text];
612
649
  if (removed.length > 0) {
613
- blocks.push(`${SKILLS_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${sanitizeUntrustedText(n, SHELLED_BODY_ENVELOPE_TAGS)}`).join("\n")}`);
614
- blocks.push(AMBIENT_CONTEXT_NOTE);
650
+ blocks.push(renderSkillsRemovalArm(removed, frameMaxBytes - ambientCost - addCost), AMBIENT_CONTEXT_NOTE);
615
651
  }
616
- return blocks.join("\n\n");
652
+ return {
653
+ text: blocks.join(SKILLS_DELTA_BLOCK_JOINER),
654
+ nameOnly: addArm?.nameOnly ?? [],
655
+ dropped: addArm?.dropped ?? [],
656
+ listed: added.length,
657
+ };
658
+ }
659
+ export function renderSkillsListingDelta(state, entries, budget) {
660
+ return renderSkillsListingFrame(state, entries, budget)?.text;
617
661
  }
618
662
  export function commitSkillsListing(state, entries) {
619
663
  state.announcedSkills = new Map(entries.map((e) => [e.name, e.description]));