dsh-ptc-plus 0.5.3 → 0.5.4

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 (40) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/client.cjs +3326 -2881
  4. package/compiler-core.cjs +127 -127
  5. package/compiler-core.cjs.map +2 -2
  6. package/docs/README.md +1 -0
  7. package/docs/capability-planning-components.md +6 -6
  8. package/docs/capability-projection.md +1 -1
  9. package/docs/capability-surface.md +133 -0
  10. package/docs/client-ui.md +17 -15
  11. package/docs/client-visual-contract.md +7 -7
  12. package/docs/installation.md +9 -2
  13. package/docs/panel-concept.md +171 -0
  14. package/docs/releases/v0.5.4.md +75 -0
  15. package/docs/runtime-reference.md +14 -0
  16. package/docs/verification.md +1 -1
  17. package/index.js +117 -30
  18. package/internal/binding-adoption.js +103 -11
  19. package/internal/capability-command-receipt.js +99 -15
  20. package/internal/capability-environment.js +1108 -348
  21. package/internal/capability-plan.js +18 -2
  22. package/internal/capability-policy.js +13 -0
  23. package/internal/capability-selection.js +330 -0
  24. package/internal/direct-surface-owner.js +17 -4
  25. package/internal/message-sources.js +21 -0
  26. package/internal/program-bindings.js +13 -4
  27. package/internal/runtime-bridge-owner.js +5 -1
  28. package/internal/runtime-config.js +10 -0
  29. package/internal/session-log-view.js +47 -5
  30. package/internal/settings-compat.js +6 -0
  31. package/internal/user-bindings-owner.js +20 -3
  32. package/package.json +45 -31
  33. package/src/client-authoring-view.js +7 -8
  34. package/src/client-capabilities-view.js +871 -948
  35. package/src/client-capability-sort.js +22 -9
  36. package/src/client-copy.js +116 -294
  37. package/src/client-settings-view.js +90 -33
  38. package/src/client-styles.js +67 -19
  39. package/src/client.js +46 -21
  40. package/internal/capability-delivery-proof.js +0 -70
@@ -83,11 +83,19 @@ function createLegacyCapabilityPlan(input) {
83
83
  if (!Array.isArray(options.capabilities)) throw new TypeError('ptc-plus: capabilities must be an array')
84
84
  const ids = new Set()
85
85
  const excluded = []
86
+ // Ids whose own invocation path the inventory could not reach. The planner states
87
+ // availability from the recorded choices, so a row the inventory already proved
88
+ // unreachable is closed here too — otherwise a baseline full contract keeps instructing
89
+ // a call the composition does not deliver. The policy generation states the same fact as
90
+ // `availabilitySource: 'invocation-path'`; a recorded v1/v2 inventory carries no such
91
+ // field, so its recomposition is unchanged.
92
+ const pathClosed = new Set()
86
93
  const capabilities = options.capabilities.map(raw => {
87
94
  const value = record(raw, 'capability')
88
95
  const id = text(value.id, 'capability id')
89
96
  if (ids.has(id)) throw new TypeError(`ptc-plus: duplicate capability identity ${id}`)
90
97
  ids.add(id)
98
+ if (value.effectiveAvailability === 'closed') pathClosed.add(id)
91
99
  const revision = text(value.revision, 'capability revision')
92
100
  if (EXCLUDED_CATEGORIES.has(value.category)) {
93
101
  excluded.push({ id, revision, category: value.category })
@@ -148,8 +156,10 @@ function createLegacyCapabilityPlan(input) {
148
156
  throw new TypeError(`ptc-plus: cannot determine the MCP server owner of ${capability.id}; conflicting namespaces: ${capability.mcpServerCandidates.join(', ')}`)
149
157
  }
150
158
  const serverClosed = capability.mcpServer !== undefined && overrides[`mcpServers.${capability.mcpServer}`] === 'closed'
151
- const explicit = serverClosed ? 'closed' : Object.hasOwn(overrides, capability.id) ? overrides[capability.id] : undefined
152
- const entry = { id: capability.id, revision: capability.revision, category: capability.category, selection: explicit ?? 'on-demand', reason: serverClosed ? 'mcp-server-disabled' : explicit ? 'manual-override' : 'baseline-not-selected' }
159
+ const unreachable = pathClosed.has(capability.id)
160
+ const explicit = serverClosed || unreachable ? 'closed' : Object.hasOwn(overrides, capability.id) ? overrides[capability.id] : undefined
161
+ const entry = { id: capability.id, revision: capability.revision, category: capability.category, selection: explicit ?? 'on-demand',
162
+ reason: serverClosed ? 'mcp-server-disabled' : unreachable ? 'invocation-path' : explicit ? 'manual-override' : 'baseline-not-selected' }
153
163
  entries.push(entry)
154
164
  selections.set(capability.id, entry)
155
165
  if (explicit === 'full') full.push(capability)
@@ -235,6 +245,12 @@ function createLegacyCapabilityPlan(input) {
235
245
  schema, enabled: true, configurationRevision, entries,
236
246
  excluded: excluded.sort(compareIds), catalogText, directoryText, directoryCategories,
237
247
  budgets: { unit: 'utf8-bytes', budgetBytes, directoryBudgetBytes, contractBytes: bytes(catalogText), directoryBytes: bytes(directoryText), overageBytes: Math.max(0, bytes(catalogText) - budgetBytes) },
248
+ // Configured ids this composition resolved no definition for. The composition is built
249
+ // from the step's admission, so a configured capability that step does not deliver is
250
+ // listed here beside one that no longer exists at all — the field states what this
251
+ // plan could not resolve, not what this session may call, and no model-visible text is
252
+ // rendered from it. Reading it as the capability list would name a closed capability
253
+ // as available and an absent one as configured.
238
254
  unresolvedConfigurationIds: [...new Set([...baseline, ...Object.keys(overrides)])].filter(id => !capabilities.some(entry => entry.id === id)).sort(),
239
255
  statisticsStatus: settings.usable ? 'enabled' : settings.reason,
240
256
  })
@@ -82,6 +82,19 @@ export function capabilityRule(policy, capability) {
82
82
  return { availability, disclosure, availabilitySource, disclosureSource }
83
83
  }
84
84
 
85
+ /** The policy document a record's own words make.
86
+ *
87
+ * A configuration that saved no policy has only a synthesized document, whose bottom layer is
88
+ * shaped by switches that belong to no record. Editing a rule, or writing one saved cell, must
89
+ * start from the record's own layers instead: the declared defaults plus the layers the record
90
+ * wrote (the legacy override record and the usage-statistics switch `capabilityPolicyForConfig`
91
+ * folds into the allocation).
92
+ */
93
+ export function capabilityPolicyFromRecord(config) {
94
+ return capabilityPolicyForConfig({ ...config, capabilityPolicy: undefined,
95
+ capabilityManagementEnabled: false })
96
+ }
97
+
85
98
  /** Change one reusable dimension, removing only that dimension for automatic. */
86
99
  export function changeCapabilityRule(policy, id, dimension, choice) {
87
100
  const next = structuredClone(policy)
@@ -0,0 +1,330 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
2
+ import { CAPABILITY_PLAN_FORM, isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND, RETIRED_SURFACE_RECORD } from './message-sources.js'
3
+ import { CAPABILITY_DISCLOSURES } from './capability-policy.js'
4
+
5
+ /**
6
+ * Session capability choices: the capability decisions this session recorded.
7
+ *
8
+ * The panel states two different facts side by side. The saved column answers
9
+ * "what does the global configuration say", and this record answers "what did
10
+ * this session decide". Keeping them apart is what lets a session state a choice
11
+ * while the global configuration still reads as it did: neither answer is
12
+ * derived from the other, and neither needs a session runtime.
13
+ *
14
+ * Three states, one question: no record (`none`) means the session inherits the
15
+ * saved column; a readable newest record (`decided`) is that session's own
16
+ * answer; a newest record this build cannot parse (`unreadable`) supersedes the
17
+ * readable ones before it, so no consumer may answer from an older record in its
18
+ * place. The third state is a fact about that record, not a state of the panel:
19
+ * every row it touches keeps its control, and recording a new decision replaces
20
+ * it.
21
+ *
22
+ * A choice never claims supply. It names an entry by id and never adds or
23
+ * restores a row, because the row it applies to exists only while the host
24
+ * declares that entry. A record may therefore outlive the declaration it was
25
+ * written for: the session keeps its decision, the catalogue stops offering the
26
+ * row, and nothing reads the record as a supply. `change()` asks this module's
27
+ * vocabulary before it appends, so a decision about an entry this vocabulary
28
+ * cannot name never becomes a record.
29
+ *
30
+ * A record is not the only shape this session's decision has ever had. Before
31
+ * this record existed, the plugin wrote a session's choices into a plan snapshot —
32
+ * the one it delivered, or the one a pending command receipt carried when no step
33
+ * had accepted it yet — and those sessions have no record at all. Reading such
34
+ * a session as `none` would recompose it from an empty set and withdraw choices
35
+ * its own log still holds, so this module reads the newest delivered plan's
36
+ * `sessionOverrides` as the same statement (`decided`, marked `legacy`) when the
37
+ * session recorded none. The delivered plan is the equivalent form of one
38
+ * statement, never a second owner: a record outvotes it, and the next write
39
+ * restates every choice this read answered with. Only a plan that delivered a
40
+ * choice is such a statement — an empty override map says the session inherited
41
+ * everything, which is the state a session without a record already reads as — and
42
+ * a plan that states the map unreadably reports `unreadable` for the same reason a
43
+ * record does.
44
+ *
45
+ * A choice about this session's capability set is a statement the session makes
46
+ * about itself, not correspondence with the model, and the carrier it travels on
47
+ * decides when it may be written. It travels as the plugin record below, which the
48
+ * host retains on every read and interprets as a surface node by none: a decision
49
+ * taken before the session's first step is therefore durable at once and never
50
+ * becomes a surface node ahead of the protected head. The `user/message` form is
51
+ * the historical carrier that 0.5.4 and earlier wrote; it is still read (a session
52
+ * persisted by those releases keeps its decision), and no writer produces it.
53
+ */
54
+ export const CAPABILITY_SELECTION_FORM = 'capability-selection'
55
+ export const CAPABILITY_SELECTION_SCHEMA = 'ptc-capability-selection/v1'
56
+
57
+ /**
58
+ * The log record this session's own choice is written as.
59
+ *
60
+ * A choice about this session's capability set is the session's statement about
61
+ * itself, not correspondence with the model, so it belongs in the log beside the
62
+ * turn events rather than on the model-visible surface. A plugin record is the
63
+ * host's carrier for exactly that kind of fact: it is retained on every read,
64
+ * interpreted as a surface node by none, and it may be written at any moment —
65
+ * including before the host publishes its protected surface head, where a message
66
+ * would make the whole log unreadable.
67
+ *
68
+ * A record that exists only as a plugin record means the step that composes the
69
+ * next environment is the only reader that has to know it, which is why the panel
70
+ * and the planner read this type directly instead of waiting for a delivery.
71
+ */
72
+ export const CAPABILITY_SELECTION_RECORD = 'plugin:ptc-plus/capability-selection'
73
+
74
+ const MAX_CHOICES = 256
75
+ /** The one entry-id vocabulary a choice record may use; its writer validates the same form. */
76
+ export const CAPABILITY_ENTRY_ID = /^(tools|skills|mcp|mcpServers)\.[^\s]{1,200}$/
77
+ const ENTRY_ID = CAPABILITY_ENTRY_ID
78
+ const AVAILABILITY = ['enabled', 'closed', 'automatic']
79
+ const DISCLOSURE = ['automatic', ...CAPABILITY_DISCLOSURES]
80
+ const PREFIX = 'PTC Plus session capability choices. These are this session\'s own decisions; the global configuration still owns the saved column, and the host still owns which entries exist. Choices:'
81
+
82
+ function isRecord(value) {
83
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
84
+ }
85
+
86
+ function sortedEntries(choices) {
87
+ return Object.entries(choices).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
88
+ }
89
+
90
+ export function normalizeCapabilitySelection(value) {
91
+ if (!isRecord(value) || Reflect.ownKeys(value).length !== 2
92
+ || !Object.hasOwn(value, 'schema') || !Object.hasOwn(value, 'choices')) {
93
+ throw new TypeError('invalid capability selection')
94
+ }
95
+ if (value.schema !== CAPABILITY_SELECTION_SCHEMA) throw new TypeError('invalid capability selection schema')
96
+ if (!isRecord(value.choices) || Reflect.ownKeys(value.choices).length > MAX_CHOICES) {
97
+ throw new TypeError('invalid capability selection choices')
98
+ }
99
+ const choices = {}
100
+ for (const [id, choice] of sortedEntries(value.choices)) {
101
+ if (!ENTRY_ID.test(id) || !isRecord(choice) || Reflect.ownKeys(choice).length === 0) {
102
+ throw new TypeError('invalid capability selection entry')
103
+ }
104
+ const next = {}
105
+ for (const [dimension, selection] of Object.entries(choice)) {
106
+ const allowed = dimension === 'availability' ? AVAILABILITY
107
+ : dimension === 'disclosure' ? DISCLOSURE : undefined
108
+ if (allowed === undefined || !allowed.includes(selection)) throw new TypeError('invalid capability selection dimension')
109
+ next[dimension] = selection
110
+ }
111
+ choices[id] = next
112
+ }
113
+ return Object.freeze({ schema: CAPABILITY_SELECTION_SCHEMA,
114
+ choices: Object.freeze(Object.fromEntries(Object.entries(choices).map(([id, choice]) => [id, Object.freeze(choice)]))) })
115
+ }
116
+
117
+ /** The exact model-visible wording one selection record carries. */
118
+ export function capabilitySelectionText(selection) {
119
+ return `${PREFIX}\n${JSON.stringify(normalizeCapabilitySelection(selection).choices)}`
120
+ }
121
+
122
+ /**
123
+ * The historical message this statement was written as before the record carrier.
124
+ *
125
+ * 0.5.4 and earlier appended this message on the model-visible surface, which is what
126
+ * `scripts/repair-session-log.mjs` repairs. No writer produces it now: the record below is
127
+ * the statement, and this form survives so that a session persisted by one of those releases
128
+ * is still read, and so that the repair has the shape it retires to build a fixture from.
129
+ */
130
+ export function capabilitySelectionMessage(selection) {
131
+ const text = capabilitySelectionText(selection)
132
+ const message = createUserMessage({
133
+ source: { kind: PTC_MESSAGE_SOURCE_KIND, form: CAPABILITY_SELECTION_FORM },
134
+ content: [{ type: 'text', text }],
135
+ })
136
+ if (readCapabilitySelection(message) === undefined) throw new TypeError('invalid capability selection message')
137
+ return message
138
+ }
139
+
140
+ /** Read one committed plugin record back as this session's selection, or nothing. */
141
+ export function readCapabilitySelectionRecord(event) {
142
+ if (event?.type !== CAPABILITY_SELECTION_RECORD || event.ignorable !== true) return undefined
143
+ try {
144
+ return normalizeCapabilitySelection(event.data)
145
+ } catch {
146
+ return undefined
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Whether one event states this session's capability selection at all.
152
+ *
153
+ * The record type states it, and so does the historical message form. A repaired statement
154
+ * (`RETIRED_SURFACE_RECORD`) states it only when the payload it kept is this owner's own form:
155
+ * a repair retires every surface node ahead of the protected head, whichever owner wrote it, so
156
+ * the carrier alone says nothing about who the statement belongs to. Reading another owner's
157
+ * retired statement as this one would answer `unreadable` for a decision this session never made
158
+ * — and would then outvote the selection the session did record.
159
+ */
160
+ function statesCapabilitySelection(event) {
161
+ if (event?.type === CAPABILITY_SELECTION_RECORD) return true
162
+ const source = event?.data?.source
163
+ return isPtcMessageSource(source) && source.form === CAPABILITY_SELECTION_FORM
164
+ }
165
+
166
+ /**
167
+ * Read one selection out of either carrier.
168
+ *
169
+ * A record and the historical message state the same selection, so a caller that walks a log
170
+ * does not have to know which release wrote it — and a repaired statement answers for the owner
171
+ * whose form it carries.
172
+ */
173
+ export function readCapabilitySelectionStatement(event) {
174
+ if (event?.type === CAPABILITY_SELECTION_RECORD) return readCapabilitySelectionRecord(event)
175
+ if (event?.type === RETIRED_SURFACE_RECORD) {
176
+ return event.ignorable === true && statesCapabilitySelection(event) ? readCapabilitySelection(event.data) : undefined
177
+ }
178
+ return event?.type === 'user/message' ? readCapabilitySelection(event.data) : undefined
179
+ }
180
+
181
+ /** Read one selection record back from a message, message projection or message fact. */
182
+ export function readCapabilitySelection(value) {
183
+ const source = value?.source
184
+ if (!isPtcMessageSource(source) || source.form !== CAPABILITY_SELECTION_FORM) return undefined
185
+ if (!Array.isArray(value.content) || value.content.length !== 1 || value.content[0]?.type !== 'text') return undefined
186
+ const text = value.content[0].text
187
+ if (typeof text !== 'string' || !text.startsWith(`${PREFIX}\n`)) return undefined
188
+ try {
189
+ const choices = JSON.parse(text.slice(PREFIX.length + 1))
190
+ const selection = normalizeCapabilitySelection({ schema: CAPABILITY_SELECTION_SCHEMA, choices })
191
+ return capabilitySelectionText(selection) === text ? selection : undefined
192
+ } catch {
193
+ return undefined
194
+ }
195
+ }
196
+
197
+ /**
198
+ * How this session's newest selection record reads.
199
+ *
200
+ * The newest record is the current statement whether or not this build can parse
201
+ * it, so an unreadable one is not evidence for an earlier record either.
202
+ */
203
+ export function classifyCapabilitySelection(records) {
204
+ if (!Array.isArray(records)) return Object.freeze({ state: 'none' })
205
+ for (let index = records.length - 1; index >= 0; index -= 1) {
206
+ const record = records[index]
207
+ if (record?.form !== CAPABILITY_SELECTION_FORM) continue
208
+ try {
209
+ return Object.freeze({ state: 'decided',
210
+ selection: normalizeCapabilitySelection({ schema: CAPABILITY_SELECTION_SCHEMA, choices: record.choices }) })
211
+ } catch {
212
+ return Object.freeze({ state: 'unreadable' })
213
+ }
214
+ }
215
+ return Object.freeze({ state: 'none' })
216
+ }
217
+
218
+ /**
219
+ * The dimension rule one selection states, or `{}` when it states no decision.
220
+ *
221
+ * One selection is the vocabulary a saved decision, a command intent and a
222
+ * delivered plan's override all use; a record states dimensions. This function
223
+ * is the only place the two spellings meet, so a writer that copies a selection
224
+ * into a record and a reader that turns a delivered override back into one
225
+ * cannot disagree about what that selection meant. Anything narrower — a rule
226
+ * naming only one dimension — is already in this shape and is copied as it is.
227
+ */
228
+ export function capabilityRuleOfSelection(selection) {
229
+ if (isRecord(selection)) return { ...selection }
230
+ return selection === undefined || selection === 'inherit' ? {}
231
+ : selection === 'automatic' ? { availability: 'automatic', disclosure: 'automatic' }
232
+ : selection === 'closed' ? { availability: 'closed' } : { availability: 'enabled', disclosure: selection }
233
+ }
234
+
235
+ /**
236
+ * The dimension form of one recorded-by-plan override.
237
+ *
238
+ * A delivered plan recorded this session's choices in the compact vocabulary a
239
+ * saved configuration uses — one whole selection per entry, or the dimensions a
240
+ * policy rule states — while a record states dimensions only. The two meet in
241
+ * `capabilityRuleOfSelection`, which is why the plan is an equivalent form of one
242
+ * statement rather than a second vocabulary: every value it reads maps back to
243
+ * the same compact selection, so a choice read from a plan and restated into a
244
+ * record says what the plan said.
245
+ */
246
+ function planChoice(override) {
247
+ const choice = capabilityRuleOfSelection(override)
248
+ return Object.keys(choice).length === 0 ? undefined : choice
249
+ }
250
+
251
+ /**
252
+ * This session's statement as its newest delivered plan recorded it.
253
+ *
254
+ * Only a plan that delivered at least one choice is a statement: an empty
255
+ * override map says the session inherited everything, which is the state a
256
+ * session without a record already reads as, not a decision to migrate. A plan
257
+ * whose overrides cannot be read as this vocabulary is `unreadable` for the same
258
+ * reason a record is: it supersedes what came before it and this build cannot
259
+ * say what it decided.
260
+ */
261
+ function planSelection(message) {
262
+ const snapshot = message?.source?.snapshot
263
+ const overrides = snapshot?.sessionOverrides
264
+ // A snapshot that states no choices at all (the v1 generation) recorded nothing to
265
+ // migrate. One that states them in a shape this vocabulary cannot read is
266
+ // `unreadable`: it supersedes what came before it, and reporting `none` would let
267
+ // the composition withdraw a decision the log still holds.
268
+ if (overrides === undefined) return Object.freeze({ state: 'none' })
269
+ if (!isRecord(overrides)) return Object.freeze({ state: 'unreadable' })
270
+ try {
271
+ // The entries are collected as pairs and materialized with own data properties: an
272
+ // override key the record vocabulary cannot name (for example `__proto__`) must reach
273
+ // the vocabulary check as a name it refuses, not silently become the map's prototype
274
+ // and leave the statement reading `none` where the record branch reads `unreadable`.
275
+ const entries = []
276
+ for (const [id, override] of sortedEntries(overrides)) {
277
+ const choice = planChoice(override)
278
+ if (choice !== undefined) entries.push([id, choice])
279
+ }
280
+ if (entries.length === 0) return Object.freeze({ state: 'none' })
281
+ return Object.freeze({ state: 'decided', legacy: true,
282
+ selection: normalizeCapabilitySelection({ schema: CAPABILITY_SELECTION_SCHEMA, choices: Object.fromEntries(entries) }) })
283
+ } catch {
284
+ return Object.freeze({ state: 'unreadable' })
285
+ }
286
+ }
287
+
288
+ /**
289
+ * How this session's newest statement reads, from its own log events.
290
+ *
291
+ * One entry answers for every consumer: a record when the session wrote one,
292
+ * otherwise the newest delivered plan as the equivalent form. Reading the plan
293
+ * only when no record exists keeps the record the newest word, so a session that
294
+ * upgraded and then decided again is not answered from an older plan.
295
+ *
296
+ * A caller that knows which session the log belongs to passes that identity, and a
297
+ * delivered plan that names a different session is then not read as this session's
298
+ * statement. A plan message carries the session it was composed for, and the writers
299
+ * that restore one require that identity; a record carries none, so the log it was
300
+ * appended to is its only identity.
301
+ */
302
+ export function capabilitySelectionOf(events, sessionId) {
303
+ const records = []
304
+ let delivered
305
+ for (const event of Array.isArray(events) ? events : []) {
306
+ // A record is the statement itself; the message form is the same statement on a release
307
+ // that wrote one. A repaired statement keeps its wording and loses only the carrier, so all
308
+ // three take part in the same "newest statement wins" ordering — but only while the payload
309
+ // is this owner's own form, because the repair retires every owner's surface node.
310
+ if (event?.type === CAPABILITY_SELECTION_RECORD || event?.type === RETIRED_SURFACE_RECORD) {
311
+ if (!statesCapabilitySelection(event)) continue
312
+ records.push({ form: CAPABILITY_SELECTION_FORM, choices: readCapabilitySelectionStatement(event)?.choices })
313
+ continue
314
+ }
315
+ if (event?.type !== 'user/message') continue
316
+ const source = event.data?.source
317
+ if (!isPtcMessageSource(source)) continue
318
+ if (source.form === CAPABILITY_SELECTION_FORM) {
319
+ records.push({ form: CAPABILITY_SELECTION_FORM, choices: readCapabilitySelection(event.data)?.choices })
320
+ } else if (source.form === CAPABILITY_PLAN_FORM
321
+ && (sessionId === undefined || source.sessionId === sessionId)) delivered = event.data
322
+ }
323
+ const recorded = classifyCapabilitySelection(records)
324
+ return recorded.state === 'none' ? planSelection(delivered) : recorded
325
+ }
326
+
327
+ /** The choice this session recorded for one entry, or `undefined` when it recorded none. */
328
+ export function capabilityChoiceOf(statement, id) {
329
+ return statement?.state === 'decided' ? statement.selection.choices[id] : undefined
330
+ }
@@ -637,7 +637,15 @@ export function createDirectSurfaceOwner({
637
637
  if (!isCurrent()) return assembly
638
638
  const sessionPtc = presentation !== 'native' && id !== undefined
639
639
  const sessionPtcProjection = presentation === 'ptc' && id !== undefined
640
- if (sessionPtc) await capabilityEnvironment?.prepare(agent, assembly.sections)
640
+ // The step's admission is the delivered capability set: the Host renders its own
641
+ // `tools:sdk` block from it and builds a program's `tools` members from it, so a
642
+ // declaration composed from the registered-definitions view can name a member this
643
+ // step does not deliver. Reading it here keeps the declaration, the plan and the
644
+ // program about one set; a host that publishes no admission states no set, and the
645
+ // registry view is then the only evidence left.
646
+ const delivered = sessionPtc && context?.toolSet !== undefined
647
+ ? toolSchemasForAgent(agent, context.toolSet) : undefined
648
+ if (sessionPtc) await capabilityEnvironment?.prepare(agent, assembly.sections, false, undefined, delivered)
641
649
  const userBindings = sessionPtc ? await userBindingsForAgent(agent) : undefined
642
650
  if (!isCurrent()) return assembly
643
651
  const runCode = tools?.find(tool => tool?.name === RUN_CODE)
@@ -668,9 +676,9 @@ export function createDirectSurfaceOwner({
668
676
  && assembly.sections.some(section => section?.name === 'tools:sdk'
669
677
  || (sessionPtcProjection && section?.name === completedState.collapseSectionName))
670
678
  const sections = projectsSections
671
- ? (capabilityEnvironment?.projectSections(agent, assembly.sections) ?? assembly.sections).map(section => {
679
+ ? (capabilityEnvironment?.projectSections(agent, assembly.sections, delivered) ?? assembly.sections).map(section => {
672
680
  if (section?.name === 'tools:sdk') {
673
- return { ...section, text: capabilitySdk(capabilityEnvironment?.sdk(agent, section.text) ?? section.text,
681
+ return { ...section, text: capabilitySdk(capabilityEnvironment?.sdk(agent, section.text, delivered) ?? section.text,
674
682
  userBindings !== undefined, languageSemantics) }
675
683
  }
676
684
  if (sessionPtcProjection && section?.name === completedState.collapseSectionName) {
@@ -680,8 +688,13 @@ export function createDirectSurfaceOwner({
680
688
  })
681
689
  : assembly.sections
682
690
  if (id !== undefined) {
691
+ // A top-level call is repaired into a program call only for a member this step
692
+ // delivers: the admission is what the declaration and the program state, so a
693
+ // name the registry merely carries is a call no member answers and no schema
694
+ // proves. A step that published no admission keeps the registry view.
695
+ const callable = delivered ?? toolSchemasForAgent(agent)
683
696
  const nativeSchemas = presentation === 'ptc'
684
- ? new Map((capabilityEnvironment?.visibleSchemas(agent, toolSchemasForAgent(agent)) ?? toolSchemasForAgent(agent))
697
+ ? new Map((capabilityEnvironment?.visibleSchemas(agent, callable) ?? callable)
685
698
  .filter(schema => typeof schema?.name === 'string'
686
699
  && schema.name !== RUN_CODE && schema.name !== EDIT_RUN_CODE)
687
700
  .map(schema => [schema.name, schema]))
@@ -7,6 +7,27 @@ export function isPtcMessageSource(source) {
7
7
  || (source?.kind === 'plugin' && source.plugin === 'ptc-plus')
8
8
  }
9
9
 
10
+ /**
11
+ * The delivered capability plan form.
12
+ *
13
+ * The plan producer composes this message; naming its form here keeps the
14
+ * vocabulary of what this plugin writes in one module, so the record reader can
15
+ * recognize a delivered plan without importing the producer that composes one.
16
+ */
17
+ export const CAPABILITY_PLAN_FORM = 'capability-plan'
18
+
19
+ /**
20
+ * The record a surface statement becomes when it may no longer occupy the surface.
21
+ *
22
+ * A session written before the record carrier carries a decision as a `user/message` ahead of the
23
+ * Host's protected head, and the released format refuses such a log on every read.
24
+ * `scripts/repair-session-log.mjs` keeps the sequence number and the payload and gives up only the
25
+ * carrier. The statement is therefore still a statement of the owner whose form its payload names,
26
+ * which is why this carrier lives beside the forms rather than in either owner: two owners read it,
27
+ * and each of them reads only its own.
28
+ */
29
+ export const RETIRED_SURFACE_RECORD = 'plugin:ptc-plus/retired-surface-statement'
30
+
10
31
  /** DSH owns aggregate runtime-context messages independently of PTC messages. */
11
32
  export function isHostRuntimeContextSource(source) {
12
33
  return source?.kind === 'runtime-context'
@@ -3,10 +3,19 @@ import { record, text } from './record-utils.js'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
5
5
 
6
- /** Derive SDK contracts from the public, scope-qualified tool registry. */
7
- export function toolSdkSchemas(tools, scope) {
8
- return tools.schemas(scope).filter(schema => schema.name !== 'run_code').map(schema => {
9
- const definition = tools.get(schema.name, scope)
6
+ /**
7
+ * Derive SDK contracts from the public, scope-qualified tool registry.
8
+ *
9
+ * `toolSet` is the step's admission, and passing it is how a caller states the set the
10
+ * Host actually delivers for that step: the Host renders its own `tools:sdk` block and
11
+ * builds a program's `tools` members from the admitted definitions, so a contract list
12
+ * read from the registered-definitions view can name a member no step delivers.
13
+ */
14
+ export function toolSdkSchemas(tools, scope, toolSet = undefined) {
15
+ return tools.schemas(scope, toolSet).filter(schema => schema.name !== 'run_code').map(schema => {
16
+ // The definition is read through the same admission, so a contract describes the
17
+ // definition this step delivers rather than one the registry would resolve.
18
+ const definition = tools.get(schema.name, scope, toolSet)
10
19
  const output = snapshotJsonValue(definition?.output?.schema)
11
20
  if (output === undefined) {
12
21
  throw new TypeError(`ptc-plus: tool "${schema.name}" output schema must be lossless JSON before SDK projection`)
@@ -347,7 +347,11 @@ export function createRuntimeBridgeOwner({
347
347
  )))
348
348
  const metadata = Object.freeze([
349
349
  ...(capabilityEnvironment?.toolMetadata(executionToken?.agent, toolCapabilityMetadata(schemas, annotations), schemas) ?? toolCapabilityMetadata(schemas, annotations)),
350
- ...(capabilityEnvironment?.metadata(executionToken?.agent) ?? []),
350
+ // The same set, as the third argument's own evidence: a derived row (`skills.*`,
351
+ // `mcp.*`) states the invocation path it still has, and the operations it can name
352
+ // are the members this program was handed — not the ones the composition froze when
353
+ // the session was last composed, which a step that delivers less does not rewrite.
354
+ ...(capabilityEnvironment?.metadata(executionToken?.agent, schemas) ?? []),
351
355
  ...PROGRAM_CAPABILITY_METADATA.map(entry => entry.namespace === 'code'
352
356
  && cellConfig.userBindingsEnabled === true
353
357
  ? { ...entry, members: [...entry.members, BINDING_SUBMISSION] }
@@ -130,6 +130,16 @@ export function resolveConfig(config = {}) {
130
130
  }
131
131
  }
132
132
  resolved.capabilityPolicy = JSON.stringify(capabilityPolicyForConfig(provided.has('capabilityPolicy') ? resolved : { ...resolved, capabilityPolicy: undefined }))
133
+ // Whether the record holds this policy is a fact about the input document, not about this
134
+ // resolution's output: a configuration this function already resolved carries the answer,
135
+ // and resolving that answer's own product again must not re-infer it from the policy this
136
+ // function just wrote. Callers that reconcile by resolving a source that may itself be
137
+ // resolved (the plugin resolves its raw document and then re-resolves the result) would
138
+ // otherwise report every policy as saved, which is what makes a synthesized bottom layer
139
+ // decide the saved column. The marker only means something beside a policy the document
140
+ // itself holds, so a document that writes the marker without one states no provenance.
141
+ resolved.capabilityPolicySaved = provided.has('capabilityPolicy')
142
+ && (typeof config.capabilityPolicySaved === 'boolean' ? config.capabilityPolicySaved : true)
133
143
  if (!provided.has('capabilityPolicy') && resolved.capabilityManagementEnabled) {
134
144
  if (!provided.has('capabilityRecoveryEnabled')) resolved.capabilityRecoveryEnabled = true
135
145
  if (!provided.has('modelPreviewEnabled')) resolved.modelPreviewEnabled = true
@@ -9,9 +9,38 @@ import { deepEqualJson, isJsonValue } from '@deepseek-ai/dsh-util-values'
9
9
  import { deepFreeze, isRecord } from './record-utils.js'
10
10
  import { advanceRuntimeHistory, createRuntimeHistory } from './recovery-tips.js'
11
11
 
12
- import { isHostRuntimeContextSource } from './message-sources.js'
12
+ import { isHostRuntimeContextSource, isPtcMessageSource } from './message-sources.js'
13
+ import { BINDING_ADOPTION_FORM, readBindingAdoption } from './binding-adoption.js'
13
14
  const SYSTEM_PROMPT_CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
14
15
 
16
+ /**
17
+ * A PTC binding-adoption statement, readable or not.
18
+ *
19
+ * The fold has to recognize the statement even when this build cannot resolve its
20
+ * wording: the fact it keeps is what a restored checkpoint replays, so a fact that
21
+ * dropped the form could not reproduce the record the fold appended.
22
+ */
23
+ function isBindingAdoptionStatement(message) {
24
+ const source = message?.source
25
+ return isPtcMessageSource(source) && source.form === BINDING_ADOPTION_FORM
26
+ }
27
+
28
+ /**
29
+ * An adoption statement this build cannot resolve, kept as the statement it is.
30
+ *
31
+ * Both the panel and the `/binding` command answer from this projection, and the
32
+ * answer has to be this session's *newest* statement. A projection that dropped a
33
+ * statement it could not resolve would let the record the session superseded answer
34
+ * instead, so the statement is retained with no declined list — the absence is what
35
+ * marks it unreadable. It never reaches the model-visible surface, which is built
36
+ * from resolvable runtime messages only.
37
+ */
38
+ function unreadableBindingAdoption(message) {
39
+ if (!isBindingAdoptionStatement(message)) return undefined
40
+ if (readBindingAdoption(message) !== undefined) return undefined
41
+ return Object.freeze({ form: BINDING_ADOPTION_FORM })
42
+ }
43
+
15
44
  /** DSH's empty aggregate uses an exact owner marker without a snapshot form. */
16
45
  export function systemPromptSnapshotSections(message) {
17
46
  const source = message?.source
@@ -72,7 +101,8 @@ export function runtimeMessageFacts(records, initial = {}) {
72
101
 
73
102
  function presentationEvent(event) {
74
103
  const recognized = event?.type === 'user/message'
75
- && (systemPromptSnapshotSections(event.data) !== undefined || readRuntimeMessage(event.data) !== undefined)
104
+ && (systemPromptSnapshotSections(event.data) !== undefined || readRuntimeMessage(event.data) !== undefined
105
+ || isBindingAdoptionStatement(event.data))
76
106
  const data = recognized ? { source: event.data.source, content: event.data.content }
77
107
  : event?.type === 'user/message' && typeof event.data?.source?.kind === 'string'
78
108
  ? { source: { kind: event.data.source.kind } } : undefined
@@ -86,11 +116,14 @@ function applyLogEvent(state, event, index = event?.seq,
86
116
  timeline = advanceSessionTimeline(state.timeline, event, index)) {
87
117
  const sections = event?.type === 'user/message' ? systemPromptSnapshotSections(event.data) : undefined
88
118
  const ptc = event?.type === 'user/message' ? readRuntimeMessage(event.data) : undefined
119
+ // An adoption statement this build cannot resolve is still this session's statement,
120
+ // so it stays in the projection as the newest one rather than disappearing.
121
+ const unresolvedAdoption = ptc === undefined ? unreadableBindingAdoption(event?.data) : undefined
89
122
  const contextStep = state.contextStep + (isContextStep(event) ? 1 : 0)
90
123
  const surfaceNodes = advanceSessionSurface(state.surfaceNodes, event)
91
124
  let visibleRuntimeMessages = state.visibleRuntimeMessages
92
- if (timeline === state.timeline && sections === undefined && ptc === undefined && contextStep === state.contextStep
93
- && surfaceNodes === state.surfaceNodes) return state
125
+ if (timeline === state.timeline && sections === undefined && ptc === undefined && unresolvedAdoption === undefined
126
+ && contextStep === state.contextStep && surfaceNodes === state.surfaceNodes) return state
94
127
  const next = { ...state, timeline, contextStep,
95
128
  logFacts: [...state.logFacts, deepFreeze({ index, event: presentationEvent(event) })],
96
129
  }
@@ -110,6 +143,11 @@ function applyLogEvent(state, event, index = event?.seq,
110
143
  const record = Object.freeze({ ...ptc, index, contextStep: state.contextStep, producer: 'ptc-plus' })
111
144
  next.ptcMessages = [...state.ptcMessages, record]
112
145
  next.runtimeMessagesBySeq = { ...next.runtimeMessagesBySeq, [`k${event.seq}`]: record }
146
+ } else if (unresolvedAdoption !== undefined) {
147
+ // Listed as this session's newest statement, but not as a resolvable runtime
148
+ // message: the ordered surface below is built from `runtimeMessagesBySeq`.
149
+ next.ptcMessages = [...state.ptcMessages,
150
+ Object.freeze({ ...unresolvedAdoption, index, contextStep: state.contextStep, producer: 'ptc-plus' })]
113
151
  }
114
152
  if (event?.surfaceOp === 'append') {
115
153
  const record = next.runtimeMessagesBySeq[`k${event.seq}`]
@@ -133,7 +171,11 @@ export function createSessionLogProjection(options = {}) {
133
171
  const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
134
172
  return Object.freeze({
135
173
  key: SESSION_LOG_PROJECTION_KEY,
136
- stateVersion: 13,
174
+ // 14 records the binding-adoption statement this build cannot resolve: the fact it
175
+ // keeps now carries the form its own replay needs, and `ptcMessages` gained a record
176
+ // for it. A checkpoint written by an earlier fold answers a different question, so
177
+ // the version moves and stored checkpoints are refolded rather than admitted.
178
+ stateVersion: 14,
137
179
  stateSchema: Object.freeze({ parse(value) {
138
180
  if (!isJsonValue(value) || !isRecord(value) || !isRecord(value.timeline)
139
181
  || typeof value.timeline.openTurn !== 'boolean' || typeof value.timeline.found !== 'boolean'
@@ -74,6 +74,12 @@ export function installSettingsSectionCompat({
74
74
  && typeof writeNamespace === 'string' && writeNamespace.length > 0
75
75
  ? Object.freeze({
76
76
  update: (patch, expectedRevision) => provider.update(writeNamespace, patch, expectedRevision),
77
+ // The service applies field edits as `{ op: 'set' | 'unset', path }`; `update` can only
78
+ // restate values, so expose the primitive when it exists instead of narrowing the callers
79
+ // to a writer that cannot remove a field.
80
+ ...(typeof provider.mutate === 'function'
81
+ ? { mutate: (ops, expectedRevision) => provider.mutate(writeNamespace, ops, expectedRevision) }
82
+ : {}),
77
83
  read: () => provider.describe?.().find(descriptor => descriptor.ns === writeNamespace),
78
84
  }) : undefined
79
85
  const unbind = onProvider(provider, writer)