dsh-ptc-plus 0.5.1 → 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 (85) hide show
  1. package/README.en.md +10 -4
  2. package/README.md +10 -4
  3. package/client.cjs +1623 -104
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  8. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  9. package/docs/adr/0023-global-user-bindings.md +12 -0
  10. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  11. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  12. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  13. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  14. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  15. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  16. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  17. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  18. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  19. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  20. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  21. package/docs/adr/README.md +7 -1
  22. package/docs/architecture.md +3 -3
  23. package/docs/client-ui.md +5 -2
  24. package/docs/durability-design.md +15 -2
  25. package/docs/installation.md +9 -9
  26. package/docs/releases/v0.5.2.md +91 -0
  27. package/docs/releases/v0.5.3.md +77 -0
  28. package/docs/runtime-reference.md +43 -3
  29. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  30. package/docs/user-bindings.en.md +15 -0
  31. package/docs/user-bindings.md +15 -0
  32. package/docs/v0.5.3-goals.md +117 -0
  33. package/docs/verification.md +7 -3
  34. package/index.js +100 -31
  35. package/internal/binding-adoption.js +136 -0
  36. package/internal/bounded-literal.js +85 -0
  37. package/internal/capability-command-receipt.js +18 -4
  38. package/internal/capability-delivery-proof.js +70 -0
  39. package/internal/capability-environment.js +200 -11
  40. package/internal/capability-plan.js +11 -8
  41. package/internal/cell-error.js +13 -0
  42. package/internal/config-spec.js +126 -6
  43. package/internal/cordis-tools-owner.js +2 -1
  44. package/internal/direct-surface-owner.js +39 -21
  45. package/internal/edit-transport-owner.js +81 -15
  46. package/internal/execution-provider-owner.js +6 -0
  47. package/internal/execution-seam-compat.js +25 -0
  48. package/internal/host-tool-sdk.js +308 -0
  49. package/internal/isolated-worker.js +134 -17
  50. package/internal/kernel-worker.js +167 -3
  51. package/internal/legacy-cell-analysis.js +14 -2
  52. package/internal/legacy-repl-convenience.js +16 -3
  53. package/internal/official-repl-priority.js +31 -10
  54. package/internal/program-bindings.js +12 -1
  55. package/internal/projection-retention.js +238 -0
  56. package/internal/prompt-text.js +858 -0
  57. package/internal/recovery-tips.js +50 -11
  58. package/internal/rejected-cell-editor.js +3 -2
  59. package/internal/repl-memory-projection.js +21 -2
  60. package/internal/runtime-bridge-owner.js +114 -9
  61. package/internal/runtime-config.js +2 -0
  62. package/internal/runtime-contexts.js +14 -5
  63. package/internal/runtime-messages.js +48 -15
  64. package/internal/session-cell-executor.js +132 -11
  65. package/internal/session-journal-recovery.js +1204 -116
  66. package/internal/session-journal-schema.js +10 -5
  67. package/internal/session-journal.js +214 -14
  68. package/internal/session-log-view.js +133 -26
  69. package/internal/session-runtime.js +746 -38
  70. package/internal/session-state.js +27 -0
  71. package/internal/stateful-root-compiler.js +11 -0
  72. package/internal/stateful-root-runtime.js +17 -0
  73. package/internal/user-binding-console.js +7 -0
  74. package/internal/user-bindings-owner.js +112 -6
  75. package/internal/user-bindings.js +2 -2
  76. package/internal/validated-parse-repair.js +6 -1
  77. package/internal/worker-client.js +27 -4
  78. package/package.json +16 -6
  79. package/src/client-activity.js +24 -4
  80. package/src/client-capabilities-view.js +504 -27
  81. package/src/client-copy.js +187 -2
  82. package/src/client-indicator-view.js +2 -1
  83. package/src/client-repl-view.js +35 -11
  84. package/src/client-styles.js +64 -2
  85. package/src/client.js +3 -1
@@ -1,11 +1,22 @@
1
1
  import { PTC_STATE_NAMES, recoveryTipIdentity } from './runtime-messages.js'
2
+ import {
3
+ PROMPT_SLOT_IDS, TIP_BODY, TIP_BODY_PLATFORM_DETAILED, TIP_BODY_REPEATED_BINDING,
4
+ TIP_BODY_REPEATED_BINDING_CAPABILITY, TIP_DETAIL_BINDING, TIP_DETAIL_CAPABILITY,
5
+ TIP_PREFIX_PLATFORM_COMMAND, TIP_PREFIX_REPEATED_BINDING,
6
+ } from './prompt-text.js'
2
7
 
3
8
  const PLATFORM_CAUSE_CODES = new Set(['EACCES', 'EINVAL', 'ENOTDIR', 'ENOENT', 'UNKNOWN'])
4
9
  const TIP_CONTEXT_PREFIX = 'tools:ptc-plus-tip/'
5
10
  const PLATFORM_FACTS = '`return { platform: process.platform, arch: process.arch, node: process.version, cwd: process.cwd(), execPath: process.execPath }`'
11
+ /**
12
+ * The delivered prefixes, one slot per trigger kind.
13
+ *
14
+ * Tip identity comes from the notice name, so a prefix carries no recognizer
15
+ * contract; both are read-only registry literals so their bytes are accounted.
16
+ */
6
17
  const TIP_PREFIXES = Object.freeze({
7
- 'repeated-binding-failure': 'The same binding failure has recurred.',
8
- 'platform-command-failure': 'An executable, shell, or path failed in the current execution world.',
18
+ 'repeated-binding-failure': TIP_PREFIX_REPEATED_BINDING,
19
+ 'platform-command-failure': TIP_PREFIX_PLATFORM_COMMAND,
9
20
  })
10
21
 
11
22
  // TODO(dsh-tips-api): replace this local provider with an adapter after dsh-tips publishes a stable facts and decision interface.
@@ -91,18 +102,46 @@ function tipCandidate(view) {
91
102
  return undefined
92
103
  }
93
104
 
94
- function renderTip(id, detailed, candidate = {}) {
105
+ const TIP_BODY_SLOTS = Object.freeze({
106
+ platform: PROMPT_SLOT_IDS.tipBodyPlatform,
107
+ platformDetailed: PROMPT_SLOT_IDS.tipBodyPlatformDetailed,
108
+ repeatedBinding: PROMPT_SLOT_IDS.tipBodyRepeatedBinding,
109
+ repeatedBindingCapability: PROMPT_SLOT_IDS.tipBodyRepeatedBindingCapability,
110
+ })
111
+
112
+ const TIP_DETAIL_SLOTS = Object.freeze({
113
+ binding: PROMPT_SLOT_IDS.tipDetailBinding,
114
+ capability: PROMPT_SLOT_IDS.tipDetailCapability,
115
+ })
116
+
117
+ const TIP_PREFIX_SLOTS = Object.freeze({
118
+ 'repeated-binding-failure': PROMPT_SLOT_IDS.tipPrefixRepeatedBinding,
119
+ 'platform-command-failure': PROMPT_SLOT_IDS.tipPrefixPlatformCommand,
120
+ })
121
+
122
+ /**
123
+ * Every delivered tip body reads its own registry slot; escalation only decides
124
+ * whether the `{detail}` hole carries its sentence, and which of the two registered
125
+ * detail slots fills it. Prefix and detail slots are read-only, so a customised body
126
+ * cannot move tip identity or escalation.
127
+ */
128
+ function renderTip(id, detailed, candidate = {}, prompt) {
129
+ const body = (slotId, fallback) => prompt?.text(slotId) ?? fallback
130
+ const prefix = body(TIP_PREFIX_SLOTS[id], TIP_PREFIXES[id])
95
131
  if (id === 'platform-command-failure') {
96
- return detailed
97
- ? `${TIP_PREFIXES[id]} Re-check the active execution world and the actual executable before retrying. First inspect bounded runtime facts with ${PLATFORM_FACTS}; use the result to choose the executable and path. Use direct argv for a normal executable; use a shell only when its syntax or resolution is required. Windows, WSL, POSIX, and package shims have different paths and launch rules. Do not dump process.env.`
98
- : `${TIP_PREFIXES[id]} Inspect bounded runtime facts with ${PLATFORM_FACTS}, then choose the executable and path in the current execution world. Use direct argv or a shell only when required; do not assume Windows, WSL, POSIX, or one shell. Do not dump process.env.`
132
+ const template = detailed
133
+ ? body(TIP_BODY_SLOTS.platformDetailed, TIP_BODY_PLATFORM_DETAILED)
134
+ : body(TIP_BODY_SLOTS.platform, TIP_BODY)
135
+ return `${prefix} ${template.replace('{platformFacts}', PLATFORM_FACTS)}`
99
136
  }
100
137
  if (!candidate.capability) {
101
- return `${TIP_PREFIXES[id]} Inspect the visible cell source and results for the local name, scope, initialization, or declaration conflict. Correct that expression before continuing; capability discovery does not list session-local variables.${detailed ? ' Earlier statements may have run; use current expression evidence without assuming a failed declaration initialized its binding.' : ''}`
138
+ const template = body(TIP_BODY_SLOTS.repeatedBinding, TIP_BODY_REPEATED_BINDING)
139
+ const detail = detailed ? body(TIP_DETAIL_SLOTS.binding, TIP_DETAIL_BINDING) : ''
140
+ return `${prefix} ${template.replace('{detail}', detail)}`
102
141
  }
103
- return detailed
104
- ? `${TIP_PREFIXES[id]} Inspect the live request with \`capabilities.tree()\`, \`capabilities.find()\`, or \`capabilities.inspect()\`, then call the typed member through \`tools.*\`. Do not invent hidden bindings or repeat the same failing expression.`
105
- : `${TIP_PREFIXES[id]} Inspect the live request with \`capabilities.tree()\`, \`capabilities.find()\`, or \`capabilities.inspect()\`, then call the typed member through \`tools.*\`.`
142
+ const template = body(TIP_BODY_SLOTS.repeatedBindingCapability, TIP_BODY_REPEATED_BINDING_CAPABILITY)
143
+ const detail = detailed ? body(TIP_DETAIL_SLOTS.capability, TIP_DETAIL_CAPABILITY) : ''
144
+ return `${prefix} ${template.replace('{detail}', detail)}`
106
145
  }
107
146
 
108
147
  export function latestRecoveryTip(view, config) {
@@ -117,6 +156,6 @@ export function latestRecoveryTip(view, config) {
117
156
  if (!Number.isSafeInteger(ordinal)) return undefined
118
157
  return {
119
158
  name: `${TIP_CONTEXT_PREFIX}${candidate.id}/${ordinal}`,
120
- text: renderTip(candidate.id, (lastTip?.unresolved ?? 0) >= config.escalationFailures, candidate),
159
+ text: renderTip(candidate.id, (lastTip?.unresolved ?? 0) >= config.escalationFailures, candidate, config.prompt),
121
160
  }
122
161
  }
@@ -1,3 +1,4 @@
1
+ import { EDIT_RUN_CODE_DESCRIPTION } from './prompt-text.js'
1
2
  import { runInNewContext } from 'node:vm'
2
3
 
3
4
  const DEFAULT_REGEX_TIMEOUT_MS = 100
@@ -252,10 +253,10 @@ function assemble(source, replacements) {
252
253
  return changed ? { code: chunks.join('') } : { error: 'edits must change at least one matched fragment' }
253
254
  }
254
255
 
255
- export function editRunCodeSchema() {
256
+ export function editRunCodeSchema(description = EDIT_RUN_CODE_DESCRIPTION) {
256
257
  return {
257
258
  name: 'edit_run_code',
258
- description: 'Call this from within a run_code cell. It accepts an edit of the most recent eligible cell captured at nested dispatch and returns { accepted, scheduled, target_call_seq } when scheduled. The corrected cell runs after the current run_code cell releases its worker; its result is reported with that cell, and a successful edit becomes the next target. This reruns the entire target cell, including prior effects; use it after pre-execution rejection or when repeating those effects is safe under the called API\'s retry rules. Otherwise continue in a new run_code cell using retained values. A diagnostic may supply a localized correction; use it when it matches your intent and preserve its expected_target_call_seq. Send exactly one edits or regex_edits array: edits replaces unique literal fragments; regex_edits replaces a counted set of pattern matches. All ranges must be non-overlapping in the original cell. Regex replacements use JavaScript $1/$<name> captures, not \\1.',
259
+ description,
259
260
  parameters: {
260
261
  type: 'object',
261
262
  additionalProperties: false,
@@ -23,6 +23,7 @@ const COUNTED_ENTRY_FIELDS = new Set([...ENTRY_FIELDS, 'reuseCount'])
23
23
  const DEFINITION_FIELDS = new Set(['source', 'line', 'column'])
24
24
  const MEMORY_META_FIELDS = new Set(['version', 'generation', 'memory'])
25
25
  const PROJECTION_STATE_FIELDS = new Set(['generation', 'memory', 'pendingReplCalls'])
26
+ const VIEW_FIELDS = new Set(['runtime', 'memory'])
26
27
  const PENDING_CALL_FIELDS = new Set(['callId', 'seq'])
27
28
  const REPL_MEMORY_META_VERSION = 5
28
29
 
@@ -205,6 +206,24 @@ export function normalizeReplMemorySnapshot(value) {
205
206
  })
206
207
  }
207
208
 
209
+ /**
210
+ * One runtime's own observation of its environment, together with the owner that
211
+ * produced it.
212
+ *
213
+ * A panel shows an observation, not "the current state". Two questions then have
214
+ * to stay separate, and only the owner answers them: a projection refresh inside
215
+ * one runtime keeps the inventory valid (freshness), while a snapshot from a
216
+ * previous runtime can no longer be confirmed (provenance). Carrying the owner
217
+ * in the view lets a reader decide by data instead of by object identity.
218
+ */
219
+ export function normalizeReplMemoryView(value) {
220
+ if (!exactFields(value, VIEW_FIELDS)) throw new Error('invalid dsh-ptc-plus REPL memory view')
221
+ return Object.freeze({
222
+ runtime: normalizeGeneration(value.runtime),
223
+ memory: normalizeReplMemorySnapshot(value.memory),
224
+ })
225
+ }
226
+
208
227
  /** Reuse fields are version 5 additions; an older metadata version cannot own them. */
209
228
  function countedMemory(memory) {
210
229
  return memory?.reuseTotal !== undefined
@@ -349,8 +368,8 @@ export function createReplMemoryProjection(generation) {
349
368
  return state
350
369
  },
351
370
  wire: Object.freeze({
352
- viewSchema: Object.freeze({ parse: normalizeReplMemorySnapshot }),
353
- view: state => state.memory,
371
+ viewSchema: Object.freeze({ parse: normalizeReplMemoryView }),
372
+ view: state => Object.freeze({ runtime: state.generation, memory: state.memory }),
354
373
  }),
355
374
  })
356
375
  }
@@ -24,6 +24,8 @@ import {
24
24
  toolCapabilityMetadata,
25
25
  } from './program-bindings.js'
26
26
  import { deepFreeze } from './record-utils.js'
27
+ import { adaptRunCodeSchema } from './direct-surface-owner.js'
28
+ import { hostTransportDefinition, isBatchSubmission } from './host-tool-sdk.js'
27
29
  import { REPL_MEMORY_META_KEY, withReplMemorySnapshot } from './repl-memory-projection.js'
28
30
  import { withUserBindingDraftCapability } from './user-binding-draft-projection.js'
29
31
  import { POST_EDIT_KEY } from './session-journal-schema.js'
@@ -160,6 +162,7 @@ export function createRuntimeBridgeOwner({
160
162
  userBindingDraftForAgent,
161
163
  bindingSubmissionForAgent,
162
164
  capabilityEnvironment,
165
+ readPromptText = () => undefined,
163
166
  }) {
164
167
  const scope = new AsyncLocalStorage()
165
168
  const childScope = new AsyncLocalStorage()
@@ -185,6 +188,8 @@ export function createRuntimeBridgeOwner({
185
188
  })
186
189
  let currentConfig = sessions.config
187
190
  const patchedDefinitions = new Map()
191
+ /** The host descriptors this attachment replaced on each definition's slots. */
192
+ const slotPatches = new Map()
188
193
  const pending = new WeakMap()
189
194
  const nativeRunCodeHandoffs = new Set()
190
195
  let active = true
@@ -393,14 +398,94 @@ export function createRuntimeBridgeOwner({
393
398
  return { request: { ...request, bindings: projected }, release }
394
399
  }
395
400
 
401
+ /**
402
+ * Write one editable run_code slot onto the host's definition.
403
+ *
404
+ * A host may publish the slot as writable data or as a getter-only accessor over
405
+ * its own text, and strict-mode assignment throws on the latter. An owned property
406
+ * definition reaches both shapes, so the plugin never fails silently to deliver the
407
+ * text it was asked to deliver.
408
+ * @param {object} definition - the host's live run_code definition.
409
+ * @param {string} key - the definition field being replaced.
410
+ * @param {unknown} value - the text the plugin owns.
411
+ * @returns {boolean} whether the field now holds that value.
412
+ */
413
+ function writeSlot(definition, key, value) {
414
+ const own = Object.getOwnPropertyDescriptor(definition, key)
415
+ try {
416
+ Object.defineProperty(definition, key, {
417
+ configurable: own?.configurable ?? true,
418
+ enumerable: own?.enumerable ?? true,
419
+ writable: true,
420
+ value,
421
+ })
422
+ return true
423
+ } catch {
424
+ return false
425
+ }
426
+ }
427
+
428
+ /** Put one slot back to the descriptor the host published before this plugin. */
429
+ function restoreSlot(definition, key, descriptor) {
430
+ try {
431
+ if (descriptor === undefined) delete definition[key]
432
+ else Object.defineProperty(definition, key, descriptor)
433
+ } catch {
434
+ // A host that made the slot non-configurable after the plugin replaced it keeps
435
+ // the last written text; the restore cannot be forced, and the caller that
436
+ // rewrote it owns the consequence.
437
+ }
438
+ }
439
+
440
+ /**
441
+ * The editable run_code slots have to reach whichever definition the host lets this
442
+ * plugin own. Two writes, one decision: if the host's parameter texts cannot be
443
+ * replaced, the definition keeps all of its own text rather than half of ours. The
444
+ * descriptors the write replaced are recorded once, so re-applying the current
445
+ * prompt document on every request can never mistake the plugin's own text for the
446
+ * host's, and disposal can restore what the host still publishes.
447
+ */
448
+ function applyEditableSlots(definition, promptText, slotPatches) {
449
+ if (promptText === undefined) return
450
+ let adapted
451
+ try {
452
+ adapted = adaptRunCodeSchema(definition, promptText)
453
+ } catch {
454
+ return
455
+ }
456
+ const recorded = slotPatches.get(definition)
457
+ if (recorded !== undefined) {
458
+ // Re-applied on every request: the descriptors recorded by the first write are
459
+ // the ones disposal restores, so a later application can never mistake the
460
+ // plugin's own text for the host's. A slot the host has since made unreplaceable
461
+ // keeps the text this attachment already delivered.
462
+ writeSlot(definition, 'parameters', adapted.parameters)
463
+ writeSlot(definition, 'description', adapted.description)
464
+ return
465
+ }
466
+ const ownParameters = Object.getOwnPropertyDescriptor(definition, 'parameters')
467
+ const ownDescription = Object.getOwnPropertyDescriptor(definition, 'description')
468
+ if (!writeSlot(definition, 'parameters', adapted.parameters)) return
469
+ if (!writeSlot(definition, 'description', adapted.description)) {
470
+ restoreSlot(definition, 'parameters', ownParameters)
471
+ return
472
+ }
473
+ slotPatches.set(definition, new Map([['parameters', ownParameters], ['description', ownDescription]]))
474
+ }
475
+
396
476
  const patchRunCodeDefinition = (agent) => {
397
- const definition = ctx.tools.get(RUN_CODE, agent)
477
+ const definition = hostTransportDefinition(ctx.tools, agent, RUN_CODE)
398
478
  if (definition === undefined) {
399
479
  throw new Error('ptc-plus: run_code definition is unavailable for the owning session')
400
480
  }
401
481
  if (definition.output === undefined) {
402
482
  throw new Error('ptc-plus: run_code definition has no output projection')
403
483
  }
484
+ // A host that declares its own model-facing tools never hands this plugin a
485
+ // tools array to adapt, so the editable prompt slots reach the model only on
486
+ // the definition the executor projects. Re-applied on every request, because
487
+ // the prompt document can change while the definition stays the same object.
488
+ applyEditableSlots(definition, readPromptText(), slotPatches)
404
489
  if (patchedDefinitions.has(definition)) return
405
490
  const output = definition.output
406
491
  const ownPresentationMeta = Object.getOwnPropertyDescriptor(output, 'presentationMeta')
@@ -463,10 +548,10 @@ export function createRuntimeBridgeOwner({
463
548
  // nested success. Capture its canonical value before policy; the private
464
549
  // relation is admitted only if policy retains that same frozen value.
465
550
  if (current?.deferredSettlement?.current === current && derived !== undefined
466
- && current.exec === exec && exec.token !== undefined && exec.parent !== undefined
551
+ && current.exec === exec && exec.token !== undefined
467
552
  && current.id === sessionId(exec.agent) && derived.agent === exec.agent
468
- && derived.callId === exec.callId && derived.rootCallId === exec.rootCallId
469
- && derived.parent === exec.parent && deepEqualJson(derived.arguments, exec.arguments)
553
+ && isDerivedExecution(derived, exec)
554
+ && deepEqualJson(derived.arguments, exec.arguments)
470
555
  && current.presentationMetaCalls === undefined && result.isError === false
471
556
  && result.value !== null && typeof result.value === 'object'
472
557
  && Object.isFrozen(result.value) && result.meta?.[JOURNAL_KEY] === undefined
@@ -610,13 +695,30 @@ export function createRuntimeBridgeOwner({
610
695
 
611
696
  const releaseSeam = seam.installExecute(patchedRun)
612
697
 
698
+ /**
699
+ * Whether this execution is the derived execution the edit transport submitted.
700
+ *
701
+ * A registry host runs the very object the plugin built, so the call id, the root
702
+ * call and the parent token it carries all prove the relation. The batch contract
703
+ * assigns its own identity instead: the parent token is the host's own and the
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.
707
+ */
708
+ const isDerivedExecution = (derived, exec) => {
709
+ if (derived === null || typeof derived !== 'object' || exec === null || typeof exec !== 'object') return false
710
+ if (derived.callId !== exec.callId) return false
711
+ if (isBatchSubmission(derived)) return true
712
+ return exec.parent !== undefined
713
+ && derived.rootCallId === exec.rootCallId && derived.parent === exec.parent
714
+ }
715
+
613
716
  const ownedDerivedScope = (exec, executionArguments = exec.arguments) => {
614
717
  const inherited = scope.getStore()
615
718
  const derived = inherited?.derivedExecution
616
719
  if (inherited?.deferredSettlement === undefined
617
720
  || inherited.id !== sessionId(exec.agent) || derived?.agent !== exec.agent
618
- || derived.callId !== exec.callId || derived.rootCallId !== exec.rootCallId
619
- || derived.parent !== exec.parent || exec.name !== RUN_CODE
721
+ || !isDerivedExecution(derived, exec) || exec.name !== RUN_CODE
620
722
  || !deepEqualJson(derived.arguments, executionArguments)) return undefined
621
723
  return inherited
622
724
  }
@@ -800,8 +902,7 @@ export function createRuntimeBridgeOwner({
800
902
  const derived = current.derivedExecution
801
903
  const privateMetadata = projection !== undefined && projection.exec === exec
802
904
  && deferred.current === current && current.exec === exec && current.id === id
803
- && derived?.agent === exec.agent && derived.callId === exec.callId
804
- && derived.rootCallId === exec.rootCallId && derived.parent === exec.parent
905
+ && derived?.agent === exec.agent && isDerivedExecution(derived, exec)
805
906
  && deepEqualJson(derived.arguments, exec.arguments)
806
907
  && projection.token === exec.token && projection.value === result?.value
807
908
  && result.isError === false && deepEqualJson(projection.meta, result.meta)
@@ -831,7 +932,7 @@ export function createRuntimeBridgeOwner({
831
932
  && deepEqualJson(meta?.[POST_EDIT_KEY], current.postEdit.provenance))
832
933
  },
833
934
  disposeAgent(agent) {
834
- return sessions.disposeSession(sessionId(agent) ?? String(agent.id))
935
+ return sessions.disposeAgent(sessionId(agent) ?? String(agent.id))
835
936
  },
836
937
  disposeSession(session) {
837
938
  return sessions.disposeSession(String(session.id))
@@ -868,6 +969,10 @@ export function createRuntimeBridgeOwner({
868
969
  }
869
970
  }
870
971
  patchedDefinitions.clear()
972
+ for (const [definition, recorded] of slotPatches) {
973
+ for (const [key, descriptor] of recorded) restoreSlot(definition, key, descriptor)
974
+ }
975
+ slotPatches.clear()
871
976
  try {
872
977
  await sessions.dispose()
873
978
  } catch (error) {
@@ -1,4 +1,5 @@
1
1
  import { capabilityPolicyForConfig } from './capability-policy.js'
2
+ import { validatePromptTextDocument } from './prompt-text.js'
2
3
  import {
3
4
  CONFIG_DEFAULTS,
4
5
  CONFIG_FIELDS,
@@ -117,6 +118,7 @@ export function resolveConfig(config = {}) {
117
118
  }
118
119
  for (const field of CONFIG_FIELDS) assertField(field, resolved[field.key])
119
120
  validateCapabilityProfiles(JSON.parse(resolved.capabilityProfiles))
121
+ validatePromptTextDocument(resolved.promptText)
120
122
  for (const [key, array] of [['capabilityBaseline', true], ['capabilityOverrides', false]]) {
121
123
  let value
122
124
  try { value = JSON.parse(resolved[key]) } catch { throw new TypeError(`ptc-plus: ${key} must contain valid JSON`) }
@@ -1,4 +1,5 @@
1
1
  import { latestRecoveryTip, runtimeHistoryForView } from './recovery-tips.js'
2
+ import { HOT_CELL_DROPPED, HOT_CELL_DROPPED_EN } from './projection-retention.js'
2
3
  import { foldSurface, deriveEventMessage, snapshotSessionEvent } from '@deepseek-ai/dsh-session'
3
4
  import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections'
4
5
  import { readSessionLog } from './session-events.js'
@@ -75,6 +76,12 @@ export async function handBackRuntimeSessions(ctx, owners) {
75
76
  function continuationFeedback(view) {
76
77
  const run = view.latestRun
77
78
  const rewrites = run?.rewrites
79
+ if (run === undefined && view.hotCellDropped === true) {
80
+ // The newest cell lost its payload to the retention budget. Silence here reads as
81
+ // "nothing to advise", which is indistinguishable from a healthy session; tell the
82
+ // model that the evidence was removed rather than absent.
83
+ return `${HOT_CELL_DROPPED} / ${HOT_CELL_DROPPED_EN} Inspect the tool result of the dropped cell before continuing; do not replay it automatically.`
84
+ }
78
85
  if (rewrites === undefined || rewrites.length === 0 || run.journal !== undefined) return undefined
79
86
  const descriptions = rewrites.map(rewrite => rewrite.description).join('; ')
80
87
  const details = descriptions.length <= 2048 ? descriptions
@@ -100,7 +107,7 @@ export function sessionRuntimeContexts(agent, tipConfig, options = {}) {
100
107
  }
101
108
 
102
109
  /** Project messages without treating an uncommitted proposal as delivery. */
103
- export function projectRuntimeMessages(view, contexts, pending = []) {
110
+ export function projectRuntimeMessages(view, contexts, pending = [], options) {
104
111
  if (view.visibleRuntimeMessages === undefined) return []
105
112
  const sections = contexts.filter(context => PTC_STATE_NAMES.includes(context.name) && context.name !== PTC_BINDING_CATALOG)
106
113
  const catalog = contexts.find(context => context.name === PTC_BINDING_CATALOG)
@@ -114,7 +121,7 @@ export function projectRuntimeMessages(view, contexts, pending = []) {
114
121
  ?? (pendingAggregate ?? legacy?.sections)?.filter(section => PTC_STATE_NAMES.includes(section.name))
115
122
  const hadState = history.hadState
116
123
  const messages = []
117
- const proposed = pending.map(readRuntimeMessage).filter(record => record !== undefined)
124
+ const proposed = pending.map(record => readRuntimeMessage(record)).filter(record => record !== undefined)
118
125
  const pendingState = proposed.filter(record => record.form === 'snapshot').at(-1)
119
126
  const previous = pendingState?.sections ?? retained
120
127
  if ((sections.length > 0 || (previous !== undefined && previous.length > 0) || (previous === undefined && hadState))
@@ -126,7 +133,7 @@ export function projectRuntimeMessages(view, contexts, pending = []) {
126
133
  const hadCatalog = history.hadCatalog
127
134
  if ((catalog !== undefined || previousCatalog !== undefined || hadCatalog)
128
135
  && JSON.stringify(previousCatalog?.sections) !== JSON.stringify(catalog === undefined ? [] : [catalog])) {
129
- messages.push(runtimeBindingCatalogMessage(catalog))
136
+ messages.push(runtimeBindingCatalogMessage(catalog, options))
130
137
  }
131
138
  for (const tip of contexts.filter(context => context.name.startsWith('tools:ptc-plus-tip/'))) {
132
139
  if (proposed.some(record => record.form === 'notice' && record.name === tip.name)) continue
@@ -137,7 +144,7 @@ export function projectRuntimeMessages(view, contexts, pending = []) {
137
144
  }
138
145
 
139
146
  /** Passive message delivery follows the actual public assembly and accepted step. */
140
- export function createRuntimeMessageOwner(contextsForRequest, sessionLogView = projectSessionLog, activeForRequest, sessionQuery) {
147
+ export function createRuntimeMessageOwner(contextsForRequest, sessionLogView = projectSessionLog, activeForRequest, sessionQuery, promptForRequest) {
141
148
  const requests = new WeakMap()
142
149
  const disposedAgents = new WeakSet()
143
150
  let disposed = false
@@ -162,7 +169,9 @@ export function createRuntimeMessageOwner(contextsForRequest, sessionLogView = p
162
169
  if (disposed || decision.kind !== 'enter' || payload.signal?.aborted === true
163
170
  || request?.allowed !== true || request.context.signal !== payload.signal) return decision
164
171
  const view = sessionLogView(payload.agent)
165
- const messages = projectRuntimeMessages(view, contextsForRequest(request.context), decision.messages)
172
+ const resolved = promptForRequest === undefined ? undefined : promptForRequest(request.context)
173
+ const messages = projectRuntimeMessages(view, contextsForRequest(request.context), decision.messages,
174
+ resolved === undefined ? undefined : { prompt: resolved })
166
175
  if (activeForRequest !== undefined) {
167
176
  const log = await readSessionLog(payload.agent.session, sessionQuery, payload.signal)
168
177
  const availability = projectRuntimeAvailability([
@@ -1,6 +1,11 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
2
+ import {
3
+ CATALOG_PREFIX, EMPTY_CATALOG, LEGACY_STATE_PREFIX, PREVIOUS_EMPTY_CATALOG, PREVIOUS_STATE_PREFIX,
4
+ PROMPT_SLOT_IDS, PROMPT_TEXT_DEFAULT, STATE_PREFIX, resolvePromptText,
5
+ } from './prompt-text.js'
2
6
 
3
7
  import { isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
8
+ import { BINDING_ADOPTION_FORM, readBindingAdoption } from './binding-adoption.js'
4
9
 
5
10
  export const PTC_DELIVERY_CONTEXT = 'tools:ptc-plus-message-delivery'
6
11
  export const PTC_BINDING_CATALOG = 'tools:ptc-plus-user-binding-defaults'
@@ -13,16 +18,12 @@ export const PTC_STATE_NAMES = Object.freeze([
13
18
  ])
14
19
  const MAX_STATE_CODE_UNITS = 65536
15
20
  const MAX_NOTICE_CODE_UNITS = 8192
16
- const LEGACY_STATE_PREFIX = 'PTC Plus current state. This replaces only earlier PTC state snapshots and PTC sections in historical aggregate snapshots; it does not replace tasks, Skill instructions, tool results, or other producers.'
17
- const PREVIOUS_STATE_PREFIX = 'PTC Plus runtime recovery state. This replaces only earlier PTC state snapshots and PTC sections in historical aggregate snapshots; it does not replace the global binding API catalog, tasks, Skill instructions, tool results, or other producers.'
21
+
18
22
  const PREVIOUS_CATALOG_PREFIXES = [
19
23
  'Global binding API catalog (PTC Plus). Only a later global binding API catalog replaces this catalog. Host runtime-context snapshots and PTC recovery snapshots do not withdraw it. This describes configured APIs, not successful initialization or current runtime values.',
20
24
  'Global binding API catalog (PTC Plus). Replaces earlier binding catalogs only; remains applicable until the next binding catalog.',
21
25
  ]
22
- const STATE_PREFIX = 'PTC Plus recovery status. Replaces earlier PTC recovery status, including PTC state in runtime-context snapshots. Global binding APIs are unchanged.'
23
- const CATALOG_PREFIX = 'Global binding API reference for run_code (replaces the previous global binding reference):'
24
- const EMPTY_CATALOG = 'No entries documented.'
25
- const PREVIOUS_EMPTY_CATALOG = 'No global binding API documentation is currently configured. Earlier binding catalogs no longer apply.'
26
+
26
27
  const AVAILABILITY_TEXT = Object.freeze({
27
28
  active: 'PTC Plus is the current continuous-computation provider. Follow its current tool schemas and capability plan; earlier withdrawals do not restore old computation state.',
28
29
  disabled: 'PTC Plus is disabled. Earlier PTC Plus continuous-computation instructions, capability plans, discovery instructions, calling-contract disclosures and global binding references no longer describe the current runtime. Use the current Host tools and contracts. Historical computations and effects remain historical; no old PTC Plus heap has been restored. Tasks, Skill instructions, other producers and tool results are unchanged.',
@@ -56,10 +57,13 @@ export function projectRuntimeAvailability(messages, active, computationDeclared
56
57
  const last = messages.map(readRuntimeAvailability).filter(Boolean).at(-1)
57
58
  if (active) return last && last !== 'active' ? runtimeAvailabilityMessage('active') : undefined
58
59
  if (last === 'disabled' || last === 'unloaded') return undefined
59
- const declared = computationDeclared || last === 'active' || messages.some(message => isPtcMessageSource(message?.source)
60
- && (['capability-navigation', 'capability-descriptions', 'capability-contracts'].includes(message.source.form)
61
- || message.source.form === 'capability-plan' && message.source.snapshot?.plan?.enabled === true
62
- || readRuntimeMessage(message)?.form === 'catalog' && readRuntimeMessage(message).sections.length > 0))
60
+ const declared = computationDeclared || last === 'active' || messages.some(message => {
61
+ const recognized = isPtcMessageSource(message?.source) ? readRuntimeMessage(message) : undefined
62
+ return isPtcMessageSource(message?.source)
63
+ && (['capability-navigation', 'capability-descriptions', 'capability-contracts'].includes(message.source.form)
64
+ || message.source.form === 'capability-plan' && message.source.snapshot?.plan?.enabled === true
65
+ || recognized?.form === 'catalog' && recognized.sections.length > 0)
66
+ })
63
67
  return declared ? runtimeAvailabilityMessage('disabled') : undefined
64
68
  }
65
69
 
@@ -92,6 +96,18 @@ function stateText(sections, prefix = STATE_PREFIX) {
92
96
  : sections.map(section => section.text).join('\n\n')}`
93
97
  }
94
98
 
99
+ /**
100
+ * Historical emptiness wording a recorded catalog may carry.
101
+ *
102
+ * These are code-owned constants, not the resolved document: a session record
103
+ * written by an older build must stay readable after today's sentence changes,
104
+ * and recognizing it must never depend on the user's current override.
105
+ */
106
+ const HISTORICAL_EMPTY_CATALOGS = Object.freeze([EMPTY_CATALOG, PREVIOUS_EMPTY_CATALOG])
107
+
108
+ /** The resolved document a producer used. Recognition never depends on its text. */
109
+ const promptOf = prompt => prompt ?? resolvePromptText(PROMPT_TEXT_DEFAULT)
110
+
95
111
  /** Recognize only bounded, formed PTC records, never authoring tasks or Skills. */
96
112
  export function readRuntimeMessage(message) {
97
113
  const source = message?.source
@@ -101,6 +117,10 @@ export function readRuntimeMessage(message) {
101
117
  const text = message.content[0].text
102
118
  const availability = readRuntimeAvailability(message)
103
119
  if (availability !== undefined) return { form: 'runtime-availability', state: availability }
120
+ // A session's binding adoption is recorded as one of its own messages, so the
121
+ // log is its only owner and replay reconstructs it without process memory.
122
+ const adoption = readBindingAdoption(message)
123
+ if (adoption !== undefined) return { form: BINDING_ADOPTION_FORM, declined: [...adoption.declined] }
104
124
  if (source.form === 'snapshot') {
105
125
  const sections = stateSections(source.sections)
106
126
  if (sections !== undefined && [STATE_PREFIX, PREVIOUS_STATE_PREFIX, LEGACY_STATE_PREFIX]
@@ -112,7 +132,12 @@ export function readRuntimeMessage(message) {
112
132
  if (prefix === undefined) return undefined
113
133
  const body = text.slice(prefix.length + 2)
114
134
  if (body.length > 0 && body.length <= MAX_STATE_CODE_UNITS) {
115
- const empty = body === (prefix === CATALOG_PREFIX ? EMPTY_CATALOG : PREVIOUS_EMPTY_CATALOG)
135
+ // Emptiness is a property of the record (`source.empty`), so live delivery
136
+ // and log replay agree even when the empty sentence is customized. Records
137
+ // written before that flag existed are matched against the code-owned
138
+ // historical wording, never against the current resolved text.
139
+ const empty = source.empty === true
140
+ || (source.empty === undefined && HISTORICAL_EMPTY_CATALOGS.includes(body))
116
141
  return { form: 'catalog', sections: stateSections(empty ? [] : [{ name: PTC_BINDING_CATALOG, text: body }]) }
117
142
  }
118
143
  } else if (source.form === 'notice' && recoveryTipIdentity(source.summary) !== undefined
@@ -123,15 +148,23 @@ export function readRuntimeMessage(message) {
123
148
  }
124
149
 
125
150
  /** One literal configuration document, independent of runtime recovery state. */
126
- export function runtimeBindingCatalogMessage(context) {
151
+ export function runtimeBindingCatalogMessage(context, options) {
152
+ const resolved = promptOf(options?.prompt)
153
+ const catalogPrefix = resolved.text(PROMPT_SLOT_IDS.catalogPrefix)
154
+ const emptyCatalog = resolved.text(PROMPT_SLOT_IDS.catalogEmpty)
155
+ // Emptiness travels in `source.empty`, so a catalog body may legitimately equal
156
+ // the code-owned empty wording; comparing the text would abort the whole step for
157
+ // a deliverable catalog. Only the shape is rejected here.
127
158
  if (context !== undefined && (context?.name !== PTC_BINDING_CATALOG
128
159
  || typeof context.text !== 'string' || context.text.length === 0
129
- || context.text.length > MAX_STATE_CODE_UNITS || context.text === EMPTY_CATALOG || context.text === PREVIOUS_EMPTY_CATALOG)) {
160
+ || context.text.length > MAX_STATE_CODE_UNITS)) {
130
161
  throw new Error('invalid PTC binding catalog')
131
162
  }
132
163
  return createUserMessage({
133
- source: { kind: PTC_MESSAGE_SOURCE_KIND, form: 'catalog' },
134
- content: [{ type: 'text', text: `${CATALOG_PREFIX}\n\n${context?.text ?? EMPTY_CATALOG}` }],
164
+ // `empty` records the fact itself, so replay recognizes an empty catalog from
165
+ // the record instead of comparing it against today's sentence.
166
+ source: { kind: PTC_MESSAGE_SOURCE_KIND, form: 'catalog', empty: context === undefined },
167
+ content: [{ type: 'text', text: `${catalogPrefix}\n\n${context?.text ?? emptyCatalog}` }],
135
168
  })
136
169
  }
137
170