dsh-ptc-plus 0.5.2 → 0.5.3

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 (53) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/client.cjs +165 -29
  4. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  5. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  6. package/docs/adr/0023-global-user-bindings.md +12 -0
  7. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  8. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  9. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  10. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
  11. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  12. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  13. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  14. package/docs/adr/README.md +7 -1
  15. package/docs/architecture.md +1 -1
  16. package/docs/durability-design.md +9 -2
  17. package/docs/installation.md +9 -9
  18. package/docs/releases/v0.5.2.md +26 -14
  19. package/docs/releases/v0.5.3.md +77 -0
  20. package/docs/runtime-reference.md +7 -1
  21. package/docs/user-bindings.en.md +15 -0
  22. package/docs/user-bindings.md +15 -0
  23. package/docs/v0.5.3-goals.md +117 -0
  24. package/docs/verification.md +4 -4
  25. package/index.js +67 -19
  26. package/internal/binding-adoption.js +136 -0
  27. package/internal/capability-command-receipt.js +18 -4
  28. package/internal/capability-delivery-proof.js +70 -0
  29. package/internal/capability-environment.js +38 -1
  30. package/internal/config-spec.js +14 -5
  31. package/internal/cordis-tools-owner.js +2 -1
  32. package/internal/direct-surface-owner.js +27 -13
  33. package/internal/edit-transport-owner.js +6 -5
  34. package/internal/execution-provider-owner.js +6 -0
  35. package/internal/execution-seam-compat.js +25 -0
  36. package/internal/host-tool-sdk.js +308 -0
  37. package/internal/official-repl-priority.js +31 -10
  38. package/internal/program-bindings.js +12 -1
  39. package/internal/repl-memory-projection.js +21 -2
  40. package/internal/runtime-bridge-owner.js +113 -8
  41. package/internal/runtime-messages.js +5 -0
  42. package/internal/session-journal-recovery.js +210 -32
  43. package/internal/session-journal-schema.js +3 -0
  44. package/internal/session-journal.js +46 -9
  45. package/internal/session-log-view.js +22 -2
  46. package/internal/session-runtime.js +106 -39
  47. package/internal/user-bindings-owner.js +109 -6
  48. package/package.json +15 -7
  49. package/src/client-capabilities-view.js +45 -3
  50. package/src/client-copy.js +24 -0
  51. package/src/client-indicator-view.js +2 -1
  52. package/src/client-repl-view.js +35 -16
  53. package/src/client.js +3 -1
@@ -0,0 +1,136 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
2
+ import { isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
3
+
4
+ /**
5
+ * Session binding adoption: which Global User Binding catalog entries this
6
+ * session uses.
7
+ *
8
+ * The question is answered per session and per entry, and the answer is a
9
+ * decision of exactly two states: this session uses the entry, or it does not.
10
+ * An entry the session never named is used, because the global catalog already
11
+ * answered the supply question. That default is the absence of a decision, not
12
+ * a third state: "use" and "never mentioned" lead to the same environment, so
13
+ * they are one state rather than two.
14
+ *
15
+ * Adoption can only take an entry away from this session. It cannot supply one,
16
+ * enable one, or name a source: an entry the global configuration disabled is
17
+ * absent from the catalog, so "adoption revives a deleted entry" has no
18
+ * encoding here and needs no rule forbidding it. Naming an entry the catalog
19
+ * does not offer is rejected at the surface that states the decision, because
20
+ * the record would otherwise claim authority over supply.
21
+ *
22
+ * The neighbour this must not be confused with is the capability pane's
23
+ * `automatic`, which means "the host derives this value". A binding has no host
24
+ * derivation to defer to, so borrowing that word would misdescribe both.
25
+ *
26
+ * The decision is recorded as one PTC message in the session log, so it is
27
+ * reconstructed by replaying that log after a restart instead of being kept in
28
+ * process memory.
29
+ */
30
+ export const BINDING_ADOPTION_FORM = 'binding-adoption'
31
+ export const BINDING_ADOPTION_SCHEMA = 'ptc-binding-adoption/v1'
32
+
33
+ const MAX_DECLINED = 64
34
+ const ENTRY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
35
+ const PREFIX = 'PTC Plus session binding adoption. This session uses every entry the global catalog supplies except the declined ones. Adoption can only withdraw an entry from this session: it cannot supply, enable or revive one, and the global configuration decides which entries exist at all. Declined entry IDs:'
36
+
37
+ function isRecord(value) {
38
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
39
+ }
40
+
41
+ function sortIds(ids) {
42
+ return [...ids].sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
43
+ }
44
+
45
+ export function normalizeBindingAdoption(value) {
46
+ if (!isRecord(value) || Reflect.ownKeys(value).length !== 2
47
+ || !Object.hasOwn(value, 'schema') || !Object.hasOwn(value, 'declined')) {
48
+ throw new TypeError('invalid binding adoption')
49
+ }
50
+ if (value.schema !== BINDING_ADOPTION_SCHEMA) throw new TypeError('invalid binding adoption schema')
51
+ if (!Array.isArray(value.declined) || value.declined.length > MAX_DECLINED) {
52
+ throw new TypeError('invalid binding adoption declined entries')
53
+ }
54
+ const declined = []
55
+ for (const id of value.declined) {
56
+ if (typeof id !== 'string' || !ENTRY_ID.test(id)) throw new TypeError('invalid binding adoption entry id')
57
+ if (declined.includes(id)) throw new TypeError('duplicate binding adoption entry id')
58
+ declined.push(id)
59
+ }
60
+ return Object.freeze({ schema: BINDING_ADOPTION_SCHEMA, declined: Object.freeze(sortIds(declined)) })
61
+ }
62
+
63
+ /** The exact model-visible wording one adoption record carries. */
64
+ export function bindingAdoptionText(adoption) {
65
+ return `${PREFIX}\n${JSON.stringify(normalizeBindingAdoption(adoption).declined)}`
66
+ }
67
+
68
+ export function bindingAdoptionMessage(adoption) {
69
+ const text = bindingAdoptionText(adoption)
70
+ const message = createUserMessage({
71
+ source: { kind: PTC_MESSAGE_SOURCE_KIND, form: BINDING_ADOPTION_FORM },
72
+ content: [{ type: 'text', text }],
73
+ })
74
+ if (readBindingAdoption(message) === undefined) throw new TypeError('invalid binding adoption message')
75
+ return message
76
+ }
77
+
78
+ /** Read one adoption record back from a message, message projection or message fact. */
79
+ export function readBindingAdoption(value) {
80
+ const source = value?.source
81
+ if (!isPtcMessageSource(source) || source.form !== BINDING_ADOPTION_FORM) return undefined
82
+ if (!Array.isArray(value.content) || value.content.length !== 1 || value.content[0]?.type !== 'text') return undefined
83
+ const text = value.content[0].text
84
+ if (typeof text !== 'string' || !text.startsWith(`${PREFIX}\n`)) return undefined
85
+ try {
86
+ const declined = JSON.parse(text.slice(PREFIX.length + 1))
87
+ const adoption = normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined })
88
+ return bindingAdoptionText(adoption) === text ? adoption : undefined
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The newest adoption this session recorded, or `undefined` while it recorded
96
+ * none. The newest record replaces earlier ones, because stating a decision
97
+ * again is how a session changes its mind.
98
+ */
99
+ export function latestBindingAdoption(records) {
100
+ if (!Array.isArray(records)) return undefined
101
+ for (let index = records.length - 1; index >= 0; index -= 1) {
102
+ const record = records[index]
103
+ if (record?.form !== BINDING_ADOPTION_FORM) continue
104
+ try {
105
+ return normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: record.declined })
106
+ } catch {
107
+ // A record that cannot be read is not evidence for an earlier record
108
+ // either: the newest statement is the current one, and it is unreadable.
109
+ return undefined
110
+ }
111
+ }
112
+ return undefined
113
+ }
114
+
115
+ /**
116
+ * The declined entry IDs of one adoption, as a set the environment uses.
117
+ * An adoption that was never stated declines nothing.
118
+ */
119
+ export function declinedBindingEntries(adoption) {
120
+ return new Set(adoption === undefined ? [] : normalizeBindingAdoption(adoption).declined)
121
+ }
122
+
123
+ /** State one entry as used (`adopt` true) or not used, leaving the rest alone. */
124
+ export function withBindingAdoption(adoption, entryId, adopt) {
125
+ if (typeof entryId !== 'string' || !ENTRY_ID.test(entryId)) throw new TypeError('invalid binding adoption entry id')
126
+ if (typeof adopt !== 'boolean') throw new TypeError('binding adoption decision must be a boolean')
127
+ const declined = declinedBindingEntries(adoption)
128
+ if (adopt) declined.delete(entryId)
129
+ else declined.add(entryId)
130
+ return normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: [...declined] })
131
+ }
132
+
133
+ /** Drop every declined entry: this session again uses the whole catalog. */
134
+ export function clearedBindingAdoption() {
135
+ return normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: [] })
136
+ }
@@ -1,4 +1,6 @@
1
1
  /** Structured command outcome for locale-owned Client receipts; text remains a CLI fallback. */
2
+ import { capabilityProofText, normalizeCapabilityProof } from './capability-delivery-proof.js'
3
+
2
4
  const PREFIX = '\n\nPTC Plus capability refresh receipt:\n'
3
5
  const FALLBACKS = Object.freeze({
4
6
  'saved-only': 'Global choice saved, but the session update failed; inspect and retry session',
@@ -11,13 +13,25 @@ const FALLBACKS = Object.freeze({
11
13
  })
12
14
  const STATES = new Set(['recorded', 'pending', 'unavailable', 'usage', 'saved-only', 'paused', 'off'])
13
15
 
14
- export function capabilityCommandReceipt(state, sessionId, error, proposal) {
16
+ export function capabilityCommandReceipt(state, sessionId, error, proposal, proof) {
15
17
  if (!STATES.has(state) || typeof sessionId !== 'string' || !sessionId) throw new TypeError('invalid capability command receipt')
16
18
  if (error !== undefined && (state !== 'saved-only' || typeof error !== 'string' || !error)) throw new TypeError('invalid capability command error')
17
19
  if (proposal !== undefined && (state !== 'pending' || !proposal || typeof proposal.commandId !== 'string'
18
20
  || typeof proposal.rawInput !== 'string' || !proposal.message)) throw new TypeError('invalid capability proposal receipt')
19
- return `${FALLBACKS[state]} ${sessionId}.${error ? ` ${error}` : ''}` + PREFIX
20
- + JSON.stringify({ schema: 'ptc-capability-command/v1', state, sessionId, ...(error === undefined ? {} : { error }), ...(proposal === undefined ? {} : { proposal }) })
21
+ // A command that names one capability reports that capability's three layers
22
+ // beside the session outcome, so "saved" cannot be read as "delivered" or
23
+ // "usable". The schema stays v1 because the field is additive: receipts
24
+ // recorded before it existed still reproduce their own text.
25
+ const layers = proof === undefined ? undefined : normalizeCapabilityProof(proof)
26
+ return `${FALLBACKS[state]} ${sessionId}.${error ? ` ${error}` : ''}`
27
+ + (layers === undefined ? '' : ` ${capabilityProofText(layers)}`)
28
+ + PREFIX
29
+ + JSON.stringify({
30
+ schema: 'ptc-capability-command/v1', state, sessionId,
31
+ ...(error === undefined ? {} : { error }),
32
+ ...(proposal === undefined ? {} : { proposal }),
33
+ ...(layers === undefined ? {} : { proof: layers }),
34
+ })
21
35
  }
22
36
 
23
37
  export function readCapabilityCommandReceipt(text) {
@@ -26,6 +40,6 @@ export function readCapabilityCommandReceipt(text) {
26
40
  const receipt = JSON.parse(text.slice(text.indexOf(PREFIX) + PREFIX.length))
27
41
  return receipt?.schema === 'ptc-capability-command/v1' && STATES.has(receipt.state)
28
42
  && typeof receipt.sessionId === 'string' && receipt.sessionId
29
- && capabilityCommandReceipt(receipt.state, receipt.sessionId, receipt.error, receipt.proposal) === text ? receipt : undefined
43
+ && capabilityCommandReceipt(receipt.state, receipt.sessionId, receipt.error, receipt.proposal, receipt.proof) === text ? receipt : undefined
30
44
  } catch { return undefined }
31
45
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * One capability, three separate facts.
3
+ *
4
+ * Saving a global choice changes supply: the configuration now records a
5
+ * decision. It does not prove the model ever received that decision, and it does
6
+ * not prove a cell can reach the capability. Those are different questions with
7
+ * different owners:
8
+ *
9
+ * - **saved**: the global configuration records a choice for this capability.
10
+ * - **delivered**: the model-visible capability catalog published it at an
11
+ * accepted step, rather than waiting for the next one or never listing it.
12
+ * - **usable**: a completed execution in this session actually reached it.
13
+ *
14
+ * Usability is only ever proved by an execution result. Nothing else in the
15
+ * product can claim it: a saved switch, a published catalog entry and an
16
+ * installed tool schema are all statements about intent, not about a value a
17
+ * cell obtained. Reporting "saved, delivered, usability unproven" is therefore
18
+ * the honest answer to "did that save take effect", and it is what a layer that
19
+ * cannot prove more must say instead of claiming effect.
20
+ */
21
+ export const CAPABILITY_PROOF_SCHEMA = 'ptc-capability-proof/v1'
22
+ export const CAPABILITY_DELIVERY_STATES = new Set(['delivered', 'pending', 'absent'])
23
+ export const CAPABILITY_USABILITY_STATES = new Set(['proven', 'unproven'])
24
+
25
+ const CAPABILITY_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/
26
+
27
+ function isRecord(value) {
28
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
29
+ }
30
+
31
+ export function normalizeCapabilityProof(value) {
32
+ if (!isRecord(value) || Reflect.ownKeys(value).length !== 5
33
+ || !Object.hasOwn(value, 'schema') || !Object.hasOwn(value, 'id') || !Object.hasOwn(value, 'saved')
34
+ || !Object.hasOwn(value, 'delivery') || !Object.hasOwn(value, 'usability')) {
35
+ throw new TypeError('invalid capability proof')
36
+ }
37
+ if (value.schema !== CAPABILITY_PROOF_SCHEMA) throw new TypeError('invalid capability proof schema')
38
+ if (typeof value.id !== 'string' || !CAPABILITY_ID.test(value.id)) throw new TypeError('invalid capability proof id')
39
+ if (typeof value.saved !== 'boolean') throw new TypeError('invalid capability proof saved fact')
40
+ if (!CAPABILITY_DELIVERY_STATES.has(value.delivery)) throw new TypeError('invalid capability proof delivery state')
41
+ if (!CAPABILITY_USABILITY_STATES.has(value.usability)) throw new TypeError('invalid capability proof usability state')
42
+ return Object.freeze({
43
+ schema: CAPABILITY_PROOF_SCHEMA,
44
+ id: value.id,
45
+ saved: value.saved,
46
+ delivery: value.delivery,
47
+ usability: value.usability,
48
+ })
49
+ }
50
+
51
+ /**
52
+ * The model-visible sentence for one proof. Each layer states its own result, so
53
+ * a reader can tell which layer failed instead of reading one blanket "applied".
54
+ */
55
+ export function capabilityProofText(proof) {
56
+ const normalized = normalizeCapabilityProof(proof)
57
+ const id = normalized.id
58
+ const saved = normalized.saved
59
+ ? `Saved: yes. The global configuration records a choice for ${id}.`
60
+ : `Saved: no. The global configuration records no choice for ${id}, so this save did not happen; the session choice below is the only change.`
61
+ const delivery = {
62
+ delivered: `Delivered: yes. The model-visible capability catalog published ${id} at an accepted step.`,
63
+ pending: `Delivered: not yet. The saved configuration reaches the model-visible catalog at the next accepted step, so ${id} is not in it now.`,
64
+ absent: `Delivered: no. The published capability catalog does not list ${id}, so the model never received it.`,
65
+ }[normalized.delivery]
66
+ const usability = normalized.usability === 'proven'
67
+ ? `Usable: proven. A completed execution in this session reached ${id}.`
68
+ : `Usable: unproven. No completed execution in this session reached ${id}; only such a result would prove it, and a saved or published entry does not.`
69
+ return `${saved} ${delivery} ${usability}`
70
+ }
@@ -1,7 +1,7 @@
1
1
  import { capabilityRule, capabilityPolicyForConfig, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from './capability-policy.js'
2
2
  /** One session-owned capability view for presentation, discovery and preparation. */
3
3
  import { createHash } from 'node:crypto'
4
- import { jsonSchemaToTs, renderToolsSdk, ToolNotFoundError } from '@deepseek-ai/dsh-tools'
4
+ import { jsonSchemaToTs, renderToolsSdk, ToolNotFoundError } from './host-tool-sdk.js'
5
5
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
6
6
  import { isModelInvocable, isUserInvocable } from '@deepseek-ai/dsh-skill'
7
7
  import { createScope } from '@deepseek-ai/dsh-scope'
@@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
9
9
  import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
10
10
  import { readSessionLog } from './session-events.js'
11
11
  import { readCapabilityCommandReceipt } from './capability-command-receipt.js'
12
+ import { normalizeCapabilityProof } from './capability-delivery-proof.js'
12
13
  import { createCapabilityPlan } from './capability-plan.js'
13
14
  import { PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
14
15
  import { deepFreeze } from './record-utils.js'
@@ -1306,6 +1307,42 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
1306
1307
  member === agent ? status : member.status))
1307
1308
  }
1308
1309
  },
1310
+ /**
1311
+ * The saved, delivered and usable facts of one capability. A caller that
1312
+ * only has "the save succeeded" must not report more than this returns.
1313
+ */
1314
+ describeCapabilityProof(agent, id) {
1315
+ if (typeof id !== 'string' || id.length === 0) throw new TypeError('ptc-plus: capability proof requires a capability id')
1316
+ const state = stateFor(agent)
1317
+ const sourceSettings = state?.snapshot.sourceSettings ?? state?.snapshot.settings
1318
+ ?? capabilityConfiguration(currentConfig)
1319
+ // A global choice is recorded either as a legacy override or as a rule in the
1320
+ // policy this owner writes for `/capabilities set global`, so both spellings
1321
+ // prove the same save.
1322
+ const capability = state?.snapshot.capabilities.find(item => item.id === id)
1323
+ const recorded = capability === undefined || sourceSettings.policy === undefined
1324
+ ? undefined : capabilityRule(sourceSettings.policy, capability)
1325
+ const saved = Object.hasOwn(sourceSettings.manual?.overrides ?? {}, id)
1326
+ || recorded?.availabilitySource === `item:${id}`
1327
+ || recorded?.disclosureSource === `item:${id}`
1328
+ // Delivery is a statement about the model-visible catalog. A plan that holds an
1329
+ // entry whose choice is closed describes an entry the catalog does not list, so
1330
+ // holding it proves nothing was delivered.
1331
+ const entry = state?.snapshot.plan.entries.find(item => item.id === id)
1332
+ const listed = entry !== undefined && entry.selection !== 'closed'
1333
+ const delivery = !listed ? 'absent'
1334
+ : state.message === undefined || planCommitted(agent, state.message) ? 'delivered' : 'pending'
1335
+ return normalizeCapabilityProof({
1336
+ schema: 'ptc-capability-proof/v1',
1337
+ id,
1338
+ saved,
1339
+ delivery,
1340
+ // Only a completed execution in this session proves usability; the
1341
+ // worker's installed schemas and the published catalog prove nothing
1342
+ // about what a cell actually obtained.
1343
+ usability: state?.used?.has(id) === true ? 'proven' : 'unproven',
1344
+ })
1345
+ },
1309
1346
  describe(agent) {
1310
1347
  const state = stateFor(agent)
1311
1348
  return state === undefined ? { configured: capabilityConfiguration(currentConfig), effective: null }
@@ -130,10 +130,10 @@ export const CONFIG_FIELDS = Object.freeze([
130
130
  type: 'enum',
131
131
  options: Object.freeze(['stateful', 'protected']),
132
132
  default: 'stateful',
133
- label: '允许重声明和覆盖',
134
- labelEn: 'Allow redeclarations and overrides',
135
- description: '同一作用域的声明和赋值可更新变量、函数、类及导入。关闭后检测跨 cell 重声明,并保护新建的 const 和 import;静态 import/export 始终可用。',
136
- descriptionEn: 'Declarations and assignments update variables, functions, classes and imports in the same scope. Disabling detects cross-cell redeclarations and protects newly created const and import bindings; static import/export remains available.',
133
+ label: '跨 cell 声明策略',
134
+ labelEn: 'Cross-cell declaration policy',
135
+ description: '同一作用域的声明和赋值可更新变量、函数、类及导入。选“启用名称保护”时改为检测跨 cell 重声明,并保护新建的 const 和 import;静态 import/export 始终可用。',
136
+ descriptionEn: 'Declarations and assignments update variables, functions, classes and imports in the same scope. With “Protect names” selected, cross-cell redeclarations are detected instead and newly created const and import bindings are protected; static import/export remains available.',
137
137
  },
138
138
  {
139
139
  key: 'canonicalizeToolCalls',
@@ -287,6 +287,15 @@ export const CONFIG_FIELDS = Object.freeze([
287
287
  description: '检测到官方持久 REPL 模式时由官方运行时接管 PTC 会话执行;PTC Plus 让出 run_code 及其进程内会话状态语义,关闭时始终由 PTC Plus 接管。',
288
288
  descriptionEn: 'Lets the official runtime own PTC session execution when the official persistent REPL mode is active; PTC Plus then leaves run_code and its in-process session-state semantics to DSH. Disabled keeps PTC Plus in charge.',
289
289
  },
290
+ {
291
+ key: 'editRewritesHistory',
292
+ type: 'boolean',
293
+ default: false,
294
+ label: '编辑时替换原单元格',
295
+ labelEn: 'Replace the edited cell in place',
296
+ description: '默认关闭,一般也不需要打开:编辑只会追加一次执行,原单元格和它创建的绑定都还在。开启后,编辑会替换原单元格,之后的重建按新版本进行,新版本里不再声明的名字会被点名"已被替换",而不是悄悄消失。只有当你希望环境表现得"这一格一直就是新版本"时才需要打开。',
297
+ descriptionEn: 'Off by default, and usually unnecessary: an edit appends one more execution, leaving the original cell and the bindings it created in place. When on, the edit replaces the original cell, later rebuilds follow the new version, and a name the new version no longer declares is reported as replaced rather than silently disappearing. Turn it on only when the environment should behave as if that cell had always been the new version.',
298
+ },
290
299
  {
291
300
  key: 'tipsEnabled',
292
301
  type: 'boolean',
@@ -483,7 +492,7 @@ export const CONFIG_GROUPS = Object.freeze([
483
492
  key: 'recovery',
484
493
  label: '状态与恢复',
485
494
  labelEn: 'State and recovery',
486
- fields: Object.freeze(['durableReplay', 'unmeasuredCellLimit', 'officialReplPriority', 'tipsEnabled', 'tipCooldownMessages', 'tipEscalationFailures']),
495
+ fields: Object.freeze(['durableReplay', 'editRewritesHistory', 'unmeasuredCellLimit', 'officialReplPriority', 'tipsEnabled', 'tipCooldownMessages', 'tipEscalationFailures']),
487
496
  },
488
497
  {
489
498
  key: 'extensions',
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module'
5
5
  import { dirname, isAbsolute, join } from 'node:path'
6
6
  import { isDeepStrictEqual } from 'node:util'
7
7
  import { RUN_CODE } from './runtime-bridge-owner.js'
8
+ import { sessionExecutionAvailable } from './host-tool-sdk.js'
8
9
 
9
10
  const CORDIS_PRESET_ID = 'cordis'
10
11
  const CORDIS_SKILL_NAME = 'cordis-plugin-development'
@@ -111,7 +112,7 @@ function requireFiberServices(agent, fiber, owner) {
111
112
  function absentCompanionSurfaces(agent) {
112
113
  if (typeof agent?.ctx?.get !== 'function') return undefined
113
114
  const missing = []
114
- if (agent.ctx.tools?.get?.(RUN_CODE, agent) === undefined) missing.push(`the ${RUN_CODE} tool`)
115
+ if (!sessionExecutionAvailable(agent.ctx.tools, agent, RUN_CODE)) missing.push(`the ${RUN_CODE} tool`)
115
116
  if (agent.ctx.tools?.get?.(COMPANION_SKILL_TOOL, agent) === undefined) {
116
117
  missing.push(`the ${COMPANION_SKILL_TOOL} tool`)
117
118
  }
@@ -27,7 +27,7 @@ const INVALID_TOOL_JSON_FAILURE = Object.freeze({
27
27
  const MALFORMED_TOOL_REJECTION_ID = 'dsh-ptc-plus-malformed-tool-json'
28
28
  const MALFORMED_TOOL_REJECTION_ARGUMENTS = '{"dsh_ptc_plus_malformed_tool_json":'
29
29
 
30
- function adaptRunCodeSchema(tool, promptText) {
30
+ export function adaptRunCodeSchema(tool, promptText) {
31
31
  const parameters = tool.parameters
32
32
  const properties = isRecord(parameters) ? parameters.properties : undefined
33
33
  const code = isRecord(properties) ? properties.code : undefined
@@ -109,9 +109,14 @@ export function createCordisRecoveryPolicy(initiallyEnabled) {
109
109
  })
110
110
  }
111
111
 
112
- function presentationState(assembly) {
113
- const tools = Array.isArray(assembly?.tools) ? assembly.tools : []
114
- if (!tools.some(tool => tool?.name === RUN_CODE)) {
112
+ function presentationState(assembly, declaresRunCode, agent) {
113
+ const tools = Array.isArray(assembly?.tools) ? assembly.tools : undefined
114
+ // A host that declares tools after the presentation waterfall carries no
115
+ // array here; the transport it publishes is then the same fact.
116
+ const present = tools === undefined
117
+ ? declaresRunCode?.(agent) === true
118
+ : tools.some(tool => tool?.name === RUN_CODE)
119
+ if (!present) {
115
120
  return { presentation: 'native', ownerProven: false, collapseSectionName: undefined }
116
121
  }
117
122
  const collapse = PTC_COLLAPSE_SECTION_NAMES
@@ -453,6 +458,7 @@ export function createDirectSurfaceOwner({
453
458
  canonicalizeToolCalls,
454
459
  sessionId,
455
460
  toolSchemasForAgent,
461
+ declaresRunCode,
456
462
  userBindingsForAgent = async () => undefined,
457
463
  setAgentPresentation = async () => {},
458
464
  sessionLogView,
@@ -578,7 +584,7 @@ export function createDirectSurfaceOwner({
578
584
  const requestOwner = id === undefined ? undefined : sessionOwner(id)
579
585
  const isCurrent = () => !disposed && (requestOwner === undefined
580
586
  || (requestOwner.active && sessions.get(id) === requestOwner))
581
- const initialState = presentationState(initialAssembly)
587
+ const initialState = presentationState(initialAssembly, declaresRunCode, agent)
582
588
  let composition = compositions.get(agent)
583
589
  if (composition === undefined && id !== undefined
584
590
  && (initialState.ownerProven || initialState.presentation === 'ptc')) {
@@ -590,11 +596,17 @@ export function createDirectSurfaceOwner({
590
596
 
591
597
  const assembly = await next()
592
598
  if (!isCurrent()) return assembly
599
+ // A host may declare the model-facing tool array after this waterfall, in
600
+ // which case the assembly carries none. The declaration surface is then
601
+ // host-owned: the plugin still owns presentation, sections, contexts and
602
+ // request policy, but it must not claim to have replaced declarations the
603
+ // host has not asked it for. A present but non-array declaration is still
604
+ // a malformed assembly.
593
605
  const tools = assembly.tools
594
- if (!Array.isArray(tools)) {
606
+ if (tools !== undefined && !Array.isArray(tools)) {
595
607
  throw new Error('ptc-plus: incompatible prompt assembly; expected a tools array')
596
608
  }
597
- const completedState = presentationState(assembly)
609
+ const completedState = presentationState(assembly, declaresRunCode, agent)
598
610
  if (composition === undefined && id !== undefined) {
599
611
  const state = initialState.ownerProven ? initialState : completedState
600
612
  composition = captureComposition(agent, id, state.presentation)
@@ -607,7 +619,7 @@ export function createDirectSurfaceOwner({
607
619
  const requestSignal = context?.signal !== null && typeof context?.signal === 'object'
608
620
  ? context.signal
609
621
  : undefined
610
- if (!tools.some(tool => tool?.name === RUN_CODE)) {
622
+ if (completedState.presentation === 'native') {
611
623
  if (presentation !== 'native') {
612
624
  throw new Error(`ptc-plus: ${presentation} agent composition assembled without run_code`)
613
625
  }
@@ -628,12 +640,14 @@ export function createDirectSurfaceOwner({
628
640
  if (sessionPtc) await capabilityEnvironment?.prepare(agent, assembly.sections)
629
641
  const userBindings = sessionPtc ? await userBindingsForAgent(agent) : undefined
630
642
  if (!isCurrent()) return assembly
631
- const runCode = tools.find(tool => tool?.name === RUN_CODE)
643
+ const runCode = tools?.find(tool => tool?.name === RUN_CODE)
632
644
  const promptText = currentPromptText
633
- let directTools = sessionPtc ? capabilityEnvironment?.visibleSchemas(agent, tools) ?? tools : tools
645
+ const hostDeclaresTools = tools !== undefined
646
+ let directTools = tools === undefined ? undefined
647
+ : sessionPtc ? capabilityEnvironment?.visibleSchemas(agent, tools) ?? tools : tools
634
648
  if (sessionPtcProjection) {
635
649
  editTransport.ensureInstalled(agent)
636
- directTools = [adaptRunCodeSchema(runCode, promptText)]
650
+ if (hostDeclaresTools) directTools = [adaptRunCodeSchema(runCode, promptText)]
637
651
  }
638
652
  const runtimeContexts = sessionPtc
639
653
  ? sessionRuntimeContexts(agent, tipConfig, {
@@ -685,11 +699,11 @@ export function createDirectSurfaceOwner({
685
699
 
686
700
  return {
687
701
  ...assembly,
688
- tools: sessionPtcProjection
702
+ ...(directTools === undefined ? {} : { tools: sessionPtcProjection
689
703
  ? directTools
690
704
  : directTools.map(tool => tool?.name === RUN_CODE
691
705
  ? adaptRunCodeSchema(tool, promptText)
692
- : tool),
706
+ : tool) }),
693
707
  sections,
694
708
  }
695
709
  },
@@ -12,7 +12,7 @@ import {
12
12
  normalizeRecoveryBoundaries,
13
13
  normalizeRewrites,
14
14
  validatedRewrites,
15
- withDiagnosticWording,
15
+ withSettlementProof,
16
16
  } from './session-journal.js'
17
17
  import {
18
18
  EXPECTED_TARGET_CALL_SEQ,
@@ -29,6 +29,7 @@ import { sessionEvents } from './session-events.js'
29
29
  import { deepEqualJson, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
30
30
  import { scopeTarget } from '@deepseek-ai/dsh-scope'
31
31
  import { RUN_CODE } from './runtime-bridge-owner.js'
32
+ import { dispatchToolCall } from './host-tool-sdk.js'
32
33
  import { CAPABILITY_EVIDENCE_KEY } from './capability-environment.js'
33
34
  import { isRecord } from './record-utils.js'
34
35
  import {
@@ -199,9 +200,9 @@ export function createEditTransportOwner(ctx, {
199
200
  derived.draftCapability,
200
201
  presentationGeneration,
201
202
  )
202
- // The derived settlement records its own diagnostic wording, so a replay reads
203
- // the same pairing the root path publishes.
204
- const withWording = withDiagnosticWording(withDraft, withDraft[JOURNAL_KEY])
203
+ // The derived settlement proves its own facts, so a replay reads the same
204
+ // settlement the root path publishes.
205
+ const withWording = withSettlementProof(withDraft, withDraft[JOURNAL_KEY])
205
206
  return derived.replMemory === undefined
206
207
  ? withWording
207
208
  : withReplMemorySnapshot(withWording, derived.replMemory, presentationGeneration)
@@ -310,7 +311,7 @@ export function createEditTransportOwner(ctx, {
310
311
  const executionStart = agent.session.append('tool/ptc-dispatch-start', data)
311
312
  const derivedExec = { callId: data.subCallId, rootCallId: data.rootCallId,
312
313
  parent: rootExec.token, name: RUN_CODE, arguments: argumentsValue, agent, signal: rootSignal }
313
- const dispatch = () => ctx.tools.execute(derivedExec)
314
+ const dispatch = () => dispatchToolCall(ctx.tools, derivedExec)
314
315
  const tentative = await executeTentative(executionStart.seq, start.rootCallSeq,
315
316
  dispatch, executionLog, derivedExec)
316
317
  const inner = tentative.result
@@ -1,4 +1,5 @@
1
1
  import { createExecutionSeam, EXECUTION_SEAM_SERVICE, LEGACY_EXECUTION_SEAM_SERVICE } from './execution-seam-compat.js'
2
+ import { providerRetainsState } from './official-repl-priority.js'
2
3
 
3
4
  export const name = 'ptc-plus-execution-provider'
4
5
  export const inject = ['loader']
@@ -70,6 +71,11 @@ export function apply(ctx, config = {}) {
70
71
  get language() { return readOriginal().language },
71
72
  get isolation() { return readOriginal().isolation },
72
73
  get programFormat() { return readOriginal().programFormat ?? 'function-body' },
74
+ // This composition isolates state owners and forwards every run to the
75
+ // original provider, so the original's retention declaration is the
76
+ // truthful answer. A host plugin that reads it must not see a stateful
77
+ // environment reported as stateless.
78
+ get retainsState() { return providerRetainsState(readOriginal()) },
73
79
  get executionInstructions() { return readOriginal().executionInstructions },
74
80
  get sandboxMode() { return readOriginal().sandboxMode },
75
81
  get timeout() { return readOriginal().timeout },
@@ -1,4 +1,6 @@
1
1
  /** Resolved-spec Cordis execution contract. */
2
+ import { providerRetainsState } from './official-repl-priority.js'
3
+
2
4
  export const EXECUTION_SEAM_SERVICE = 'ptcRuntime'
3
5
 
4
6
  /** Request-only Cordis execution contract. */
@@ -63,7 +65,24 @@ export function createExecutionSeam(service, serviceName) {
63
65
  // Source evaluation form belongs to the provider that actually receives
64
66
  // the program. Program-format consumers delegate to the original provider,
65
67
  // so this descriptor always follows the original generation.
68
+ //
69
+ // Retention is different: while an installed adapter runs the program this
70
+ // provider keeps a per-session environment, so the answer describes what
71
+ // this provider does, not what the original would do. The ownership decision
72
+ // asks the original through `seam.originalRetainsState` instead.
66
73
  get programFormat() { requireCompatible(); return service.programFormat ?? 'function-body' },
74
+ // Retention belongs to the provider that actually receives the program, so
75
+ // this descriptor follows the original generation exactly like the source
76
+ // form. A host plugin that reads it must not see a stateful environment
77
+ // reported as stateless.
78
+ get retainsState() {
79
+ requireCompatible()
80
+ // While the installed adapter runs the program, this provider retains a
81
+ // per-session environment, whatever the original would do. Reporting the
82
+ // original's answer here would describe a stateful environment as
83
+ // stateless to every host plugin that asks.
84
+ return installed === undefined ? providerRetainsState(service) : true
85
+ },
67
86
  get executionInstructions() { requireCompatible(); return installed === undefined ? service.executionInstructions ?? '' : '' },
68
87
  get sandboxMode() { requireCompatible(); return installed === undefined ? service.sandboxMode : undefined },
69
88
  get timeout() { requireCompatible(); return installed === undefined ? service.timeout : undefined },
@@ -93,6 +112,12 @@ export function createExecutionSeam(service, serviceName) {
93
112
  })
94
113
  const seam = Object.freeze({
95
114
  serviceName,
115
+ /**
116
+ * Whether the original provider retains state per owner. Execution ownership
117
+ * is decided against the original — the provider that would receive the
118
+ * program if this attachment stood down — not against the composition.
119
+ */
120
+ get originalRetainsState() { requireAvailable(); return providerRetainsState(service) },
96
121
  get language() { requireAvailable(); return service.language },
97
122
  provider,
98
123
  get active() { return installed !== undefined && service.language === installed.language },