dsh-ptc-plus 0.5.0 → 0.5.2

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 (74) hide show
  1. package/README.en.md +15 -3
  2. package/README.md +15 -3
  3. package/client.cjs +1983 -234
  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 +2 -2
  8. package/docs/adr/0023-global-user-bindings.md +1 -1
  9. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  10. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  11. package/docs/adr/0038-freeze-session-capability-plans.md +1 -1
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +35 -0
  13. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  14. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  15. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  16. package/docs/adr/README.md +2 -0
  17. package/docs/architecture.md +2 -2
  18. package/docs/capability-planning-components.md +2 -2
  19. package/docs/client-ui.md +13 -10
  20. package/docs/client-visual-contract.md +1 -1
  21. package/docs/durability-design.md +7 -1
  22. package/docs/installation.md +5 -3
  23. package/docs/publishing.md +2 -2
  24. package/docs/releases/v0.5.2.md +79 -0
  25. package/docs/runtime-reference.md +42 -3
  26. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  27. package/docs/verification.md +6 -2
  28. package/index.js +63 -19
  29. package/internal/bounded-literal.js +85 -0
  30. package/internal/capability-command-receipt.js +3 -1
  31. package/internal/capability-environment.js +171 -13
  32. package/internal/capability-plan.js +11 -8
  33. package/internal/capability-statistics.js +6 -2
  34. package/internal/cell-error.js +13 -0
  35. package/internal/config-spec.js +122 -2
  36. package/internal/direct-surface-owner.js +14 -10
  37. package/internal/edit-transport-owner.js +96 -17
  38. package/internal/execution-provider-owner.js +25 -0
  39. package/internal/execution-seam-compat.js +18 -0
  40. package/internal/isolated-worker.js +134 -17
  41. package/internal/kernel-worker.js +167 -3
  42. package/internal/legacy-cell-analysis.js +14 -2
  43. package/internal/legacy-repl-convenience.js +16 -3
  44. package/internal/official-repl-priority.js +51 -0
  45. package/internal/projection-retention.js +238 -0
  46. package/internal/prompt-text.js +858 -0
  47. package/internal/recovery-tips.js +50 -11
  48. package/internal/rejected-cell-editor.js +3 -2
  49. package/internal/runtime-bridge-owner.js +11 -8
  50. package/internal/runtime-config.js +2 -0
  51. package/internal/runtime-contexts.js +14 -5
  52. package/internal/runtime-messages.js +43 -15
  53. package/internal/sdk-projection.js +2 -2
  54. package/internal/session-cell-executor.js +132 -11
  55. package/internal/session-journal-recovery.js +1009 -99
  56. package/internal/session-journal-schema.js +7 -5
  57. package/internal/session-journal.js +177 -14
  58. package/internal/session-log-view.js +111 -24
  59. package/internal/session-runtime.js +674 -33
  60. package/internal/session-state.js +27 -0
  61. package/internal/stateful-root-compiler.js +11 -0
  62. package/internal/stateful-root-runtime.js +17 -0
  63. package/internal/user-binding-console.js +7 -0
  64. package/internal/user-bindings-owner.js +8 -27
  65. package/internal/user-bindings.js +2 -2
  66. package/internal/validated-parse-repair.js +6 -1
  67. package/internal/worker-client.js +27 -4
  68. package/package.json +5 -2
  69. package/src/client-activity.js +27 -6
  70. package/src/client-capabilities-view.js +774 -125
  71. package/src/client-copy.js +231 -18
  72. package/src/client-repl-view.js +6 -1
  73. package/src/client-styles.js +68 -2
  74. package/src/client-tool-view.js +9 -2
@@ -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,
@@ -587,9 +587,10 @@ export function createRuntimeBridgeOwner({
587
587
  valueText = '; value omitted from preview'
588
588
  }
589
589
  }
590
+ const targetDescription = pendingEdit.targetDescription ?? `run_code call ${pendingEdit.targetCallSeq}`
590
591
  const status = outcome.edited
591
- ? `edit_run_code settled${outcome.error === undefined ? '' : ' with execution error'}`
592
- : 'edit_run_code did not execute the accepted revision'
592
+ ? `edit_run_code settled${outcome.error === undefined ? '' : ' with execution error'} (target ${targetDescription})`
593
+ : `edit_run_code did not execute the accepted revision (target ${targetDescription})`
593
594
  const detail = [
594
595
  ...(hasRenderedContent || !Array.isArray(outcome.logs) ? [] : outcome.logs),
595
596
  ...(!hasRenderedContent && typeof outcome.error === 'string' ? [outcome.error] : []),
@@ -625,9 +626,9 @@ export function createRuntimeBridgeOwner({
625
626
  const inherited = scope.getStore()
626
627
  const ownsDerived = ownedDerivedScope(exec) !== undefined
627
628
  if ((exec.parent !== undefined && !ownsDerived) || id === undefined
628
- || inherited?.submission?.sessionId === id) return operation()
629
+ || (inherited?.exec === exec && inherited.submission?.sessionId === id)) return operation()
629
630
  const submission = sessions.beginSubmission({ id }, exec.signal)
630
- const composite = inherited?.deferredSettlement !== undefined && inherited.id === id
631
+ const composite = ownsDerived
631
632
  ? {
632
633
  persistedCallSeq: inherited.persistedCallSeq,
633
634
  sourceCallSeq: inherited.sourceCallSeq,
@@ -637,9 +638,11 @@ export function createRuntimeBridgeOwner({
637
638
  }
638
639
  : {}
639
640
  try {
640
- // Derived edits retain their proved source under a fresh admission for
641
- // the same session. Delegated agents own a fresh log and no child handoff.
642
- return await scope.run({ ...composite, submission }, () => childScope.run(undefined, operation))
641
+ // Only reentry for this exact execution shares admission. Background
642
+ // continuations can inherit a settled execution's asynchronous context.
643
+ // Only proved derived edits retain composite source and settlement under
644
+ // a fresh admission; other roots own a fresh log and no child handoff.
645
+ return await scope.run({ ...composite, exec, submission }, () => childScope.run(undefined, operation))
643
646
  } finally {
644
647
  sessions.releaseSubmission(submission)
645
648
  }
@@ -828,7 +831,7 @@ export function createRuntimeBridgeOwner({
828
831
  && deepEqualJson(meta?.[POST_EDIT_KEY], current.postEdit.provenance))
829
832
  },
830
833
  disposeAgent(agent) {
831
- return sessions.disposeSession(sessionId(agent) ?? String(agent.id))
834
+ return sessions.disposeAgent(sessionId(agent) ?? String(agent.id))
832
835
  },
833
836
  disposeSession(session) {
834
837
  return sessions.disposeSession(String(session.id))
@@ -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,4 +1,8 @@
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'
4
8
 
@@ -13,16 +17,12 @@ export const PTC_STATE_NAMES = Object.freeze([
13
17
  ])
14
18
  const MAX_STATE_CODE_UNITS = 65536
15
19
  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.'
20
+
18
21
  const PREVIOUS_CATALOG_PREFIXES = [
19
22
  '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
23
  'Global binding API catalog (PTC Plus). Replaces earlier binding catalogs only; remains applicable until the next binding catalog.',
21
24
  ]
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.'
25
+
26
26
  const AVAILABILITY_TEXT = Object.freeze({
27
27
  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
28
  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 +56,13 @@ export function projectRuntimeAvailability(messages, active, computationDeclared
56
56
  const last = messages.map(readRuntimeAvailability).filter(Boolean).at(-1)
57
57
  if (active) return last && last !== 'active' ? runtimeAvailabilityMessage('active') : undefined
58
58
  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))
59
+ const declared = computationDeclared || last === 'active' || messages.some(message => {
60
+ const recognized = isPtcMessageSource(message?.source) ? readRuntimeMessage(message) : undefined
61
+ return isPtcMessageSource(message?.source)
62
+ && (['capability-navigation', 'capability-descriptions', 'capability-contracts'].includes(message.source.form)
63
+ || message.source.form === 'capability-plan' && message.source.snapshot?.plan?.enabled === true
64
+ || recognized?.form === 'catalog' && recognized.sections.length > 0)
65
+ })
63
66
  return declared ? runtimeAvailabilityMessage('disabled') : undefined
64
67
  }
65
68
 
@@ -92,6 +95,18 @@ function stateText(sections, prefix = STATE_PREFIX) {
92
95
  : sections.map(section => section.text).join('\n\n')}`
93
96
  }
94
97
 
98
+ /**
99
+ * Historical emptiness wording a recorded catalog may carry.
100
+ *
101
+ * These are code-owned constants, not the resolved document: a session record
102
+ * written by an older build must stay readable after today's sentence changes,
103
+ * and recognizing it must never depend on the user's current override.
104
+ */
105
+ const HISTORICAL_EMPTY_CATALOGS = Object.freeze([EMPTY_CATALOG, PREVIOUS_EMPTY_CATALOG])
106
+
107
+ /** The resolved document a producer used. Recognition never depends on its text. */
108
+ const promptOf = prompt => prompt ?? resolvePromptText(PROMPT_TEXT_DEFAULT)
109
+
95
110
  /** Recognize only bounded, formed PTC records, never authoring tasks or Skills. */
96
111
  export function readRuntimeMessage(message) {
97
112
  const source = message?.source
@@ -112,7 +127,12 @@ export function readRuntimeMessage(message) {
112
127
  if (prefix === undefined) return undefined
113
128
  const body = text.slice(prefix.length + 2)
114
129
  if (body.length > 0 && body.length <= MAX_STATE_CODE_UNITS) {
115
- const empty = body === (prefix === CATALOG_PREFIX ? EMPTY_CATALOG : PREVIOUS_EMPTY_CATALOG)
130
+ // Emptiness is a property of the record (`source.empty`), so live delivery
131
+ // and log replay agree even when the empty sentence is customized. Records
132
+ // written before that flag existed are matched against the code-owned
133
+ // historical wording, never against the current resolved text.
134
+ const empty = source.empty === true
135
+ || (source.empty === undefined && HISTORICAL_EMPTY_CATALOGS.includes(body))
116
136
  return { form: 'catalog', sections: stateSections(empty ? [] : [{ name: PTC_BINDING_CATALOG, text: body }]) }
117
137
  }
118
138
  } else if (source.form === 'notice' && recoveryTipIdentity(source.summary) !== undefined
@@ -123,15 +143,23 @@ export function readRuntimeMessage(message) {
123
143
  }
124
144
 
125
145
  /** One literal configuration document, independent of runtime recovery state. */
126
- export function runtimeBindingCatalogMessage(context) {
146
+ export function runtimeBindingCatalogMessage(context, options) {
147
+ const resolved = promptOf(options?.prompt)
148
+ const catalogPrefix = resolved.text(PROMPT_SLOT_IDS.catalogPrefix)
149
+ const emptyCatalog = resolved.text(PROMPT_SLOT_IDS.catalogEmpty)
150
+ // Emptiness travels in `source.empty`, so a catalog body may legitimately equal
151
+ // the code-owned empty wording; comparing the text would abort the whole step for
152
+ // a deliverable catalog. Only the shape is rejected here.
127
153
  if (context !== undefined && (context?.name !== PTC_BINDING_CATALOG
128
154
  || 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)) {
155
+ || context.text.length > MAX_STATE_CODE_UNITS)) {
130
156
  throw new Error('invalid PTC binding catalog')
131
157
  }
132
158
  return createUserMessage({
133
- source: { kind: PTC_MESSAGE_SOURCE_KIND, form: 'catalog' },
134
- content: [{ type: 'text', text: `${CATALOG_PREFIX}\n\n${context?.text ?? EMPTY_CATALOG}` }],
159
+ // `empty` records the fact itself, so replay recognizes an empty catalog from
160
+ // the record instead of comparing it against today's sentence.
161
+ source: { kind: PTC_MESSAGE_SOURCE_KIND, form: 'catalog', empty: context === undefined },
162
+ content: [{ type: 'text', text: `${catalogPrefix}\n\n${context?.text ?? emptyCatalog}` }],
135
163
  })
136
164
  }
137
165
 
@@ -10,7 +10,7 @@ export function projectProgramSdk(nativeSdk, { languageSemantics = 'legacy-v1' }
10
10
  const fence = nativeSdk.indexOf(TYPESCRIPT_FENCE)
11
11
  if (fence < 0) return nativeSdk
12
12
  return nativeSdk.slice(0, fence).replace(
13
- '`code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped)',
14
- '`code` — a PTC Plus TypeScript cell (runtime `enum` and namespaces are supported; type annotations are advisory)',
13
+ 'the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped)',
14
+ 'a PTC Plus TypeScript cell (runtime `enum` and namespaces are supported; type annotations are advisory)',
15
15
  ) + nativeSdk.slice(fence)
16
16
  }
@@ -26,9 +26,11 @@ import {
26
26
  import { assertStateName, LIVE_USER_BINDINGS_REUSE_POLICY } from './session-journal.js'
27
27
  import { LIVE_USER_BINDINGS_SHADOW_POLICY, normalizeUserBindingNames } from './session-journal-schema.js'
28
28
  import { PreflightError, prepareProgram } from './compiler-service.js'
29
+ import { UNANSWERED_SAMPLE_CODE } from './isolated-worker.js'
29
30
  import { executionPolicies } from './binding-update-policy.js'
31
+ import { resultEnvelopeText, resolvePromptTextConfig, rewriteDescriptions } from './prompt-text.js'
30
32
  import { createReplMemorySnapshot } from './repl-memory-projection.js'
31
- import { ModuleRewriteError } from './cell-error.js'
33
+ import { CellAdmissionBlocked, ModuleRewriteError } from './cell-error.js'
32
34
  import { mapSourcePosition } from './source-position-map.js'
33
35
  import { durabilityState, transitionDurability } from './session-state.js'
34
36
  import { dynamicBindingOrigin } from './dynamic-binding-evidence.js'
@@ -38,7 +40,6 @@ import {
38
40
  selectUserBindingsSnapshot,
39
41
  } from './user-bindings.js'
40
42
 
41
- const OUTPUT_LIMIT_MESSAGE = bytes => `output exceeded ${bytes} bytes; this cell was discarded and the worker reset. Only bindings in the verified recovery frontier can be restored. External effects may already have occurred and are not undone. For future cells, bound console output and return a smaller value; do not assume this cell's values remain available.`
42
43
 
43
44
  function earlyResult(kind, message) {
44
45
  return { logs: [], error: { kind, message } }
@@ -94,7 +95,7 @@ function parseDiagnostic(error, source, position, repair) {
94
95
  : ['this cell was not executed; correct the reported syntax and retry only this cell with run_code']
95
96
  : [
96
97
  `this cell was not executed; validated syntax repair: append ${JSON.stringify(repair.delimiter)} at the end of this cell`,
97
- `when this correction matches your intent and tools.edit_run_code is available in the program SDK, use ${repair.invocation} inside a new run_code cell with its target guard; the accepted edit runs the complete corrected cell after that new cell finishes; validation proves syntax/preflight acceptance, not intended behavior; otherwise submit corrected source in run_code`,
98
+ `when this correction matches your intent, tools.edit_run_code can apply it to call_seq ${repair.targetCallSeq} (same anchor with ${JSON.stringify(repair.delimiter)} appended); the accepted edit reruns the corrected cell after this one, otherwise submit corrected source in run_code`,
98
99
  ],
99
100
  })
100
101
  }
@@ -366,6 +367,35 @@ function stateArguments(value) {
366
367
  export class SessionCellExecutor {
367
368
  constructor(kernel) {
368
369
  this.kernel = kernel
370
+ this.pendingRestore = undefined
371
+ }
372
+
373
+ /**
374
+ * Install recorded values into the worker without running any program.
375
+ *
376
+ * Recovery restores a cell the journal recorded as volatile from the values
377
+ * that cell produced. Sending its source instead would redispatch whatever
378
+ * ambient source made it volatile, so this path carries values only: the
379
+ * worker commits each one as a declaration and reports the names it could
380
+ * not install. A missing worker is reported the same way rather than thrown,
381
+ * because the caller has to tell the model which bindings it lost.
382
+ */
383
+ async restoreBindings(values, config = this.kernel.config) {
384
+ const kernel = this.kernel
385
+ const failedAll = reason => values.map(entry => ({ name: entry.name, reason }))
386
+ if (kernel.disposed) return { restored: [], failed: failedAll('session kernel disposed') }
387
+ if (kernel.client.worker === undefined) return { restored: [], failed: failedAll('no live worker') }
388
+ const id = ++kernel.sequence
389
+ const valueLimits = kernel.valueLimits(config)
390
+ return new Promise(resolve => {
391
+ this.pendingRestore = { id, resolve }
392
+ try {
393
+ kernel.client.post({ type: 'restore', id, values, valueLimits })
394
+ } catch (error) {
395
+ this.pendingRestore = undefined
396
+ resolve({ restored: [], failed: failedAll(error instanceof Error ? error.message : String(error)) })
397
+ }
398
+ })
369
399
  }
370
400
 
371
401
  async executeCell(request, replayRecord = undefined, config = this.kernel.config) {
@@ -380,6 +410,24 @@ export class SessionCellExecutor {
380
410
  kernel.completeJournal(request.journal, 'noop', result)
381
411
  return result
382
412
  }
413
+ // A restore-only request installs recorded values: it must not compile or run
414
+ // the node's program. Preparing it first could reject source that is dead by
415
+ // definition — the node is not on the path to the head, so it may reference
416
+ // bindings a recorded rewind ended — and a rejected preparation is what made
417
+ // the off-path repair install nothing. The catalog advances from the recorded
418
+ // names instead, so later cells still compile against them.
419
+ if (request.restoreValues !== undefined) {
420
+ try {
421
+ await kernel.prepareWorker()
422
+ await kernel.ensureWorker(config.maxOldGenerationSizeMb)
423
+ } catch (error) {
424
+ if (error instanceof CellAdmissionBlocked) throw error
425
+ return earlyResult('worker-exit', messageOf(error))
426
+ }
427
+ const outcome = await this.restoreBindings(request.restoreValues, config)
428
+ kernel.bindingCatalog = kernel.bindingCatalog.restore(outcome.restored)
429
+ return { logs: [], restored: outcome.restored, failed: outcome.failed }
430
+ }
383
431
 
384
432
  let userBindings
385
433
  const userBindingsShadowPolicy = replayRecord?.userBindingsShadowPolicy ?? LIVE_USER_BINDINGS_SHADOW_POLICY
@@ -425,6 +473,7 @@ export class SessionCellExecutor {
425
473
  const moduleDirectory = executionCwd ?? process.cwd()
426
474
  const moduleFilename = resolve(moduleDirectory, 'repl')
427
475
  const prepareCell = program => prepareProgram(program, {
476
+ rewriteDescriptions: rewriteDescriptions(resolvePromptTextConfig(config)),
428
477
  knownBindings: catalog.knownBindings,
429
478
  bindingPolicy,
430
479
  languageSemantics,
@@ -483,8 +532,12 @@ export class SessionCellExecutor {
483
532
  let worker
484
533
  try {
485
534
  await kernel.prepareWorker()
486
- worker = await kernel.client.ensure(config.maxOldGenerationSizeMb)
535
+ worker = await kernel.ensureWorker(config.maxOldGenerationSizeMb)
487
536
  } catch (error) {
537
+ // An admission block is the kernel's own lifecycle fact, so the kernel
538
+ // settles the cell: it must not be read as a failure of recorded history
539
+ // or of this source.
540
+ if (error instanceof CellAdmissionBlocked) throw error
488
541
  const result = earlyResult('worker-exit', messageOf(error))
489
542
  kernel.completeJournal(request.journal, 'discarded', result)
490
543
  kernel.rollbackToDurable()
@@ -510,6 +563,7 @@ export class SessionCellExecutor {
510
563
  const active = {
511
564
  id,
512
565
  started: false,
566
+ dispatched: false,
513
567
  request: { ...request, bindings: bindings.namespaces },
514
568
  finish: resolve,
515
569
  computeTimer: undefined,
@@ -561,23 +615,57 @@ export class SessionCellExecutor {
561
615
  // cache. Work that completed before this request must not be charged to
562
616
  // the new cell, and work after the sample must remain counted.
563
617
  active.wallTimer = setTimeout(() => {
618
+ // A cell that never reached the runtime has executed nothing, so this
619
+ // failure cannot discard an earlier settlement or justify rebuilding the
620
+ // worker on its own; SessionRuntime owns how many such cells it keeps.
621
+ if (active.dispatched !== true) {
622
+ kernel.abandonCell(active, earlyResult(
623
+ 'budget-unavailable',
624
+ `wall-clock ceiling reached (${config.maxWallMs}ms) before this cell was dispatched; no code from it ran; retry the cell or raise maxWallMs`,
625
+ ))
626
+ return
627
+ }
564
628
  active.resolve(earlyResult('timeout', `wall-clock ceiling reached (${config.maxWallMs}ms); split long-running work into smaller cells`), true)
565
629
  }, config.maxWallMs)
566
630
  active.budgetsReady = (async () => {
567
631
  let started
568
632
  try {
633
+ // How long this environment may wait for an answer belongs to the
634
+ // cell's configuration; the transport reads its bound per request.
635
+ if (Number.isSafeInteger(config.sampleBoundMs) && config.sampleBoundMs > 0) {
636
+ worker.sampleBoundMs = config.sampleBoundMs
637
+ }
569
638
  started = await worker.sampleUtilization()
570
639
  } catch (error) {
571
- // No fresh baseline means this request cannot be measured. Use the
572
- // existing worker-exit route instead of pretending the last cache
573
- // sample is this request's starting point.
574
- active.resolve(earlyResult('worker-exit', messageOf(error)), true)
640
+ // No fresh baseline means this request cannot be measured: the
641
+ // helper's periodic cache may predate work that already finished, so
642
+ // it is not a substitute start point. A helper that is still trusted
643
+ // has not failed, and no frame was dispatched yet, so the unmeasured
644
+ // cell settles without execution. Only a helper the transport reports
645
+ // as lost takes the worker-exit route.
646
+ if (safeProperty(error, 'code') === UNANSWERED_SAMPLE_CODE) {
647
+ kernel.abandonCell(active, earlyResult(
648
+ 'budget-unavailable',
649
+ `${messageOf(error)}; this cell was not dispatched and no code from it ran; retry the cell`,
650
+ ))
651
+ } else {
652
+ active.resolve(earlyResult('worker-exit', messageOf(error)), true)
653
+ }
575
654
  return
576
655
  }
577
656
  if (active.settled) return
578
657
  active.computeTimer = setInterval(() => {
579
658
  if (active.settled) return
580
659
  if (worker.performance.eventLoopUtilization(started).active > config.computeMs) {
660
+ // Nothing from this cell was dispatched, so an exhausted budget
661
+ // measures earlier work on this worker, not this cell's source.
662
+ if (active.dispatched !== true) {
663
+ kernel.abandonCell(active, earlyResult(
664
+ 'budget-unavailable',
665
+ `compute budget exhausted (${config.computeMs}ms event-loop active) before this cell was dispatched; no code from this cell ran; a callback from earlier work may still be blocking the worker; retry the cell or restart the session`,
666
+ ))
667
+ return
668
+ }
581
669
  active.resolve(earlyResult('timeout', `compute budget exhausted (${config.computeMs}ms event-loop active, including synchronous blocking); this measure does not establish CPU use or the specific cause. Use asynchronous waiting for long waits, or a DSH-owned managed job if available. This cell was discarded and the worker reset; external effects or processes may continue.`), true)
582
670
  }
583
671
  }, Math.min(100, config.computeMs))
@@ -604,6 +692,12 @@ export class SessionCellExecutor {
604
692
  returnSignal: prepared.returnSignal,
605
693
  asyncCompletion: prepared.asyncCompletion,
606
694
  commitSignal: prepared.commitSignal,
695
+ // Compile-time evidence that a binding's initializer is a bounded
696
+ // object literal. Nothing records from it yet; settlement will use
697
+ // it to decide whether a value can be captured without walking it.
698
+ declarationEvidence: prepared.declarations
699
+ .filter(declaration => declaration.boundedLiteral !== undefined)
700
+ .map(declaration => [declaration.name, declaration.boundedLiteral]),
607
701
  maxOutputBytes: config.maxOutputBytes,
608
702
  valueLimits,
609
703
  durability,
@@ -624,6 +718,9 @@ export class SessionCellExecutor {
624
718
  ...request.bindingDescriptors.reservedNames,
625
719
  ])],
626
720
  })
721
+ // The run frame is with the worker now, so a later failure may have
722
+ // executed this cell's source and cannot settle as an unmeasured cell.
723
+ active.dispatched = true
627
724
  } catch (error) {
628
725
  active.resolve(earlyResult('worker-exit', messageOf(error)), true)
629
726
  }
@@ -689,7 +786,7 @@ export class SessionCellExecutor {
689
786
  throw new Error(`cannot save a durable REPL state from a volatile segment; volatile reason: ${active.durability.reason ?? 'replayability is unproved'}. This save rejection leaves live bindings usable in the current worker; continue computation from them. It does not undo external effects.`)
690
787
  }
691
788
  active.control.names.add(name)
692
- active.journal.operations.push({ action, name })
789
+ active.journal.operations.push({ action, name, kind: 'checkpoint' })
693
790
  return { action, name, saved: true }
694
791
  }
695
792
  if (action === 'delete') {
@@ -703,6 +800,18 @@ export class SessionCellExecutor {
703
800
  }
704
801
 
705
802
  onMessage(message) {
803
+ if (message?.type === 'restored') {
804
+ const pending = this.pendingRestore
805
+ if (pending === undefined || pending.id !== message.id) return
806
+ this.pendingRestore = undefined
807
+ const restored = Array.isArray(message.restored) ? message.restored.filter(name => typeof name === 'string') : []
808
+ const failed = Array.isArray(message.failed)
809
+ ? message.failed.filter(entry => entry !== null && typeof entry === 'object' && typeof entry.name === 'string')
810
+ .map(entry => ({ name: entry.name, reason: typeof entry.reason === 'string' ? entry.reason : 'the worker did not report a reason' }))
811
+ : []
812
+ pending.resolve({ restored, failed })
813
+ return
814
+ }
706
815
  if (message?.type === 'observation-started') {
707
816
  const kernel = this.kernel
708
817
  const observation = kernel.workerObservation
@@ -759,7 +868,7 @@ export class SessionCellExecutor {
759
868
  const logs = Array.isArray(message.logs) && message.logs.every(log => typeof log === 'string') ? message.logs : []
760
869
  active.resolve({
761
870
  logs: limitLogs(logs),
762
- error: { kind: 'output-limit', message: OUTPUT_LIMIT_MESSAGE(active.config.maxOutputBytes) },
871
+ error: { kind: 'output-limit', message: resultEnvelopeText(resolvePromptTextConfig(active.config), active.config.maxOutputBytes) },
763
872
  }, true)
764
873
  }
765
874
 
@@ -791,6 +900,18 @@ export class SessionCellExecutor {
791
900
  reason: typeof message.volatileReason === 'string' ? message.volatileReason : undefined,
792
901
  })
793
902
  }
903
+ // The worker sends the values this settlement produced, durable or volatile, so
904
+ // the journal can keep them; recovery installs them instead of re-running the
905
+ // cell when its replay path does not reach it. A value the worker could not
906
+ // transfer arrives in `unrecorded` and is named in the record.
907
+ const recorded = message.bindingValues ?? message.volatileBindingValues
908
+ if (recorded !== undefined && typeof recorded === 'object'
909
+ && Array.isArray(recorded.values) && Array.isArray(recorded.unrecorded)
910
+ && recorded.values.every(entry => entry !== null && typeof entry === 'object'
911
+ && typeof entry.name === 'string' && entry.wire !== undefined)
912
+ && recorded.unrecorded.every(name => typeof name === 'string')) {
913
+ active.bindingValues = recorded
914
+ }
794
915
  if (active.replay !== undefined && active.durability.status !== 'durable') {
795
916
  active.resolve({ logs, error: { kind: 'recovery', message: 'durable history requested a volatile capability during replay' } }, true)
796
917
  return
@@ -851,7 +972,7 @@ export class SessionCellExecutor {
851
972
  if (bytes > active.config.maxOutputBytes) {
852
973
  active.resolve({
853
974
  logs: limitLogs(logs),
854
- error: { kind: 'output-limit', message: OUTPUT_LIMIT_MESSAGE(active.config.maxOutputBytes) },
975
+ error: { kind: 'output-limit', message: resultEnvelopeText(resolvePromptTextConfig(active.config), active.config.maxOutputBytes) },
855
976
  }, true)
856
977
  return
857
978
  }