dsh-ptc-plus 0.5.3 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/client.cjs +3326 -2881
- package/compiler-core.cjs +127 -127
- package/compiler-core.cjs.map +2 -2
- package/docs/README.md +1 -0
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -2
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -4
- package/docs/architecture.md +1 -1
- package/docs/capability-planning-components.md +6 -6
- package/docs/capability-projection.md +1 -1
- package/docs/capability-surface.md +133 -0
- package/docs/client-ui.md +17 -15
- package/docs/client-visual-contract.md +7 -7
- package/docs/installation.md +14 -7
- package/docs/panel-concept.md +171 -0
- package/docs/publishing.md +1 -1
- package/docs/release-notes.md +1 -1
- package/docs/releases/v0.5.4.md +79 -0
- package/docs/releases/v0.5.5.md +41 -0
- package/docs/releases/v0.5.6.md +41 -0
- package/docs/runtime-reference.md +14 -0
- package/docs/verification.md +1 -1
- package/index.js +117 -30
- package/internal/binding-adoption.js +103 -11
- package/internal/capability-command-receipt.js +99 -15
- package/internal/capability-environment.js +1108 -348
- package/internal/capability-plan.js +18 -2
- package/internal/capability-policy.js +13 -0
- package/internal/capability-selection.js +330 -0
- package/internal/direct-surface-owner.js +17 -4
- package/internal/edit-transport-owner.js +3 -2
- package/internal/host-tool-sdk.js +17 -29
- package/internal/message-sources.js +21 -0
- package/internal/program-bindings.js +13 -4
- package/internal/runtime-bridge-owner.js +9 -9
- package/internal/runtime-config.js +10 -0
- package/internal/session-log-view.js +47 -5
- package/internal/settings-compat.js +6 -0
- package/internal/user-bindings-owner.js +20 -3
- package/package.json +45 -31
- package/src/client-authoring-view.js +7 -8
- package/src/client-capabilities-view.js +871 -948
- package/src/client-capability-sort.js +22 -9
- package/src/client-copy.js +116 -294
- package/src/client-settings-view.js +90 -33
- package/src/client-styles.js +67 -19
- package/src/client.js +46 -21
- 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
|
|
152
|
-
const
|
|
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
|
-
|
|
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,
|
|
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]))
|
|
@@ -310,8 +310,9 @@ export function createEditTransportOwner(ctx, {
|
|
|
310
310
|
subCallId: `${String(exec.callId)}:derived`, name: RUN_CODE, arguments: argumentsValue }
|
|
311
311
|
const executionStart = agent.session.append('tool/ptc-dispatch-start', data)
|
|
312
312
|
const derivedExec = { callId: data.subCallId, rootCallId: data.rootCallId,
|
|
313
|
-
parent: rootExec.token, name: RUN_CODE, arguments: argumentsValue, agent, signal: rootSignal
|
|
314
|
-
|
|
313
|
+
parent: rootExec.token, name: RUN_CODE, arguments: argumentsValue, agent, signal: rootSignal,
|
|
314
|
+
...(rootExec.toolSet === undefined ? {} : { toolSet: rootExec.toolSet }) }
|
|
315
|
+
const dispatch = () => dispatchToolCall(ctx.tools, derivedExec, ctx)
|
|
315
316
|
const tentative = await executeTentative(executionStart.seq, start.rootCallSeq,
|
|
316
317
|
dispatch, executionLog, derivedExec)
|
|
317
318
|
const inner = tentative.result
|
|
@@ -260,41 +260,29 @@ export function sessionExecutionAvailable(tools, scope, name = 'run_code') {
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
/**
|
|
263
|
-
*
|
|
263
|
+
* Dispatch one tool call through the Host's invocation policy.
|
|
264
264
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*/
|
|
270
|
-
const batchSubmissions = new WeakSet()
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Whether this execution was submitted as a host batch call.
|
|
274
|
-
* @param {object} execution - an execution object this plugin built.
|
|
275
|
-
* @returns {boolean} true when the batch contract dispatched it.
|
|
276
|
-
*/
|
|
277
|
-
export function isBatchSubmission(execution) {
|
|
278
|
-
return execution !== null && typeof execution === 'object' && batchSubmissions.has(execution)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Dispatch one tool call through the host registry.
|
|
283
|
-
*
|
|
284
|
-
* Two dispatch contracts are published. A registry host accepts one call and
|
|
285
|
-
* resolves to that call's settled result. An executor host accepts a complete
|
|
286
|
-
* model batch, assigns the execution identity itself, and resolves to the
|
|
287
|
-
* ordered outcomes — so the caller supplies only the call it wants run, and the
|
|
288
|
-
* executor's own token is the one its observers see.
|
|
265
|
+
* Registry Hosts accept an execution input directly. Executor Hosts reserve
|
|
266
|
+
* batches for root calls; nested inputs use the published invocation pipeline
|
|
267
|
+
* so the Host retains their actual parent token and enclosing root identity.
|
|
268
|
+
* The Host owns validation, policy, cancellation and result materialization.
|
|
289
269
|
* @param {object} tools - the host tool registry (`ctx.tools`).
|
|
290
270
|
* @param {object} execution - call identity, arguments, agent and signal.
|
|
271
|
+
* @param {object} [ctx] - the owning plugin context, required for nested execution.
|
|
291
272
|
* @returns {Promise<object>} this call's settled result.
|
|
292
273
|
*/
|
|
293
|
-
export async function dispatchToolCall(tools, execution) {
|
|
274
|
+
export async function dispatchToolCall(tools, execution, ctx) {
|
|
294
275
|
if (typeof tools?.selectExecutor !== 'function') return tools.execute(execution)
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
276
|
+
if (execution.parent !== undefined) {
|
|
277
|
+
if (ctx === undefined) throw new Error('ptc-plus: nested Host execution requires its owning context')
|
|
278
|
+
const { ToolExecutionPipeline } = await import('@deepseek-ai/dsh-tools/pipeline')
|
|
279
|
+
const pipeline = new ToolExecutionPipeline(ctx, {
|
|
280
|
+
get: (name, scope) => hostTransportDefinition(tools, scope, name),
|
|
281
|
+
})
|
|
282
|
+
const prepared = await pipeline.prepare(execution)
|
|
283
|
+
const outcome = prepared.kind === 'dispatch' ? await pipeline.dispatch(prepared.exec) : prepared
|
|
284
|
+
return pipeline.complete(prepared.exec, outcome)
|
|
285
|
+
}
|
|
298
286
|
const settled = await tools.execute({
|
|
299
287
|
calls: [{ id: execution.callId, name: execution.name, arguments: JSON.stringify(execution.arguments) }],
|
|
300
288
|
signal: execution.signal,
|
|
@@ -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
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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`)
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from './program-bindings.js'
|
|
26
26
|
import { deepFreeze } from './record-utils.js'
|
|
27
27
|
import { adaptRunCodeSchema } from './direct-surface-owner.js'
|
|
28
|
-
import { hostTransportDefinition
|
|
28
|
+
import { hostTransportDefinition } from './host-tool-sdk.js'
|
|
29
29
|
import { REPL_MEMORY_META_KEY, withReplMemorySnapshot } from './repl-memory-projection.js'
|
|
30
30
|
import { withUserBindingDraftCapability } from './user-binding-draft-projection.js'
|
|
31
31
|
import { POST_EDIT_KEY } from './session-journal-schema.js'
|
|
@@ -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
|
-
|
|
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] }
|
|
@@ -698,17 +702,13 @@ function applyEditableSlots(definition, promptText, slotPatches) {
|
|
|
698
702
|
/**
|
|
699
703
|
* Whether this execution is the derived execution the edit transport submitted.
|
|
700
704
|
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
* root call is whichever call that host treats as direct. What survives there is
|
|
705
|
-
* the call id this plugin chose, which the host matched the batch result back to,
|
|
706
|
-
* together with the dispatch record; the derived source is compared beside it.
|
|
705
|
+
* Both Host invocation contracts preserve the submitted call id, enclosing
|
|
706
|
+
* root and actual parent token. All three must match; a root batch submission
|
|
707
|
+
* cannot establish a nested execution merely by choosing the same call id.
|
|
707
708
|
*/
|
|
708
709
|
const isDerivedExecution = (derived, exec) => {
|
|
709
710
|
if (derived === null || typeof derived !== 'object' || exec === null || typeof exec !== 'object') return false
|
|
710
711
|
if (derived.callId !== exec.callId) return false
|
|
711
|
-
if (isBatchSubmission(derived)) return true
|
|
712
712
|
return exec.parent !== undefined
|
|
713
713
|
&& derived.rootCallId === exec.rootCallId && derived.parent === exec.parent
|
|
714
714
|
}
|
|
@@ -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
|