dsh-ptc-plus 0.5.1 → 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 (58) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/client.cjs +1467 -84
  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/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  8. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  9. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  10. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  11. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  12. package/docs/architecture.md +2 -2
  13. package/docs/client-ui.md +5 -2
  14. package/docs/durability-design.md +7 -1
  15. package/docs/releases/v0.5.2.md +79 -0
  16. package/docs/runtime-reference.md +36 -2
  17. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  18. package/docs/verification.md +6 -2
  19. package/index.js +36 -15
  20. package/internal/bounded-literal.js +85 -0
  21. package/internal/capability-environment.js +162 -10
  22. package/internal/capability-plan.js +11 -8
  23. package/internal/cell-error.js +13 -0
  24. package/internal/config-spec.js +113 -2
  25. package/internal/direct-surface-owner.js +14 -10
  26. package/internal/edit-transport-owner.js +79 -14
  27. package/internal/isolated-worker.js +134 -17
  28. package/internal/kernel-worker.js +167 -3
  29. package/internal/legacy-cell-analysis.js +14 -2
  30. package/internal/legacy-repl-convenience.js +16 -3
  31. package/internal/projection-retention.js +238 -0
  32. package/internal/prompt-text.js +858 -0
  33. package/internal/recovery-tips.js +50 -11
  34. package/internal/rejected-cell-editor.js +3 -2
  35. package/internal/runtime-bridge-owner.js +1 -1
  36. package/internal/runtime-config.js +2 -0
  37. package/internal/runtime-contexts.js +14 -5
  38. package/internal/runtime-messages.js +43 -15
  39. package/internal/session-cell-executor.js +132 -11
  40. package/internal/session-journal-recovery.js +1009 -99
  41. package/internal/session-journal-schema.js +7 -5
  42. package/internal/session-journal.js +177 -14
  43. package/internal/session-log-view.js +111 -24
  44. package/internal/session-runtime.js +674 -33
  45. package/internal/session-state.js +27 -0
  46. package/internal/stateful-root-compiler.js +11 -0
  47. package/internal/stateful-root-runtime.js +17 -0
  48. package/internal/user-binding-console.js +7 -0
  49. package/internal/user-bindings-owner.js +3 -0
  50. package/internal/user-bindings.js +2 -2
  51. package/internal/validated-parse-repair.js +6 -1
  52. package/internal/worker-client.js +27 -4
  53. package/package.json +3 -1
  54. package/src/client-activity.js +24 -4
  55. package/src/client-capabilities-view.js +459 -24
  56. package/src/client-copy.js +163 -2
  57. package/src/client-repl-view.js +6 -1
  58. package/src/client-styles.js +64 -2
package/index.js CHANGED
@@ -20,6 +20,7 @@ import { createCordisToolsOwner } from './internal/cordis-tools-owner.js'
20
20
  import { createEditTransportOwner, EDIT_RUN_CODE } from './internal/edit-transport-owner.js'
21
21
  import { createRuntimeBridgeOwner, RUN_CODE } from './internal/runtime-bridge-owner.js'
22
22
  import { resolveConfig, watchVolatileConfig } from './internal/runtime-config.js'
23
+ import { configureRetention } from './internal/projection-retention.js'
23
24
  import {
24
25
  CONFIG_FIELDS,
25
26
  SETTINGS_NAMESPACE,
@@ -35,6 +36,7 @@ import { createHostRpc } from './internal/host-rpc.js'
35
36
  import { RPC_CONTRACTS } from './internal/rpc-contract.js'
36
37
  import { createReplObservationInterest } from './internal/repl-observation-interest.js'
37
38
  import { guidancePolicies } from './internal/binding-update-policy.js'
39
+ import { PROMPT_SLOT_IDS, resolvePromptTextConfig } from './internal/prompt-text.js'
38
40
  import * as dshSettings from '@deepseek-ai/dsh-settings'
39
41
 
40
42
  const INSTALL_CLEANUP = Symbol('ptc-plus install cleanup')
@@ -59,7 +61,7 @@ export const Config = Schema.object(Object.fromEntries(
59
61
  */
60
62
  export const inject = ['tools', 'systemPrompt', 'agents']
61
63
 
62
- function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durableReplay, cordisToolsEnabled }) {
64
+ function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durableReplay, cordisToolsEnabled }, promptText) {
63
65
  const looseTopLevelRedeclarations = bindingPolicy.variableRedeclarations
64
66
  const looseTopLevelFunctionClassRedeclarations = bindingPolicy.functionClassRedeclarations
65
67
  const autoRewriteImports = rewritesEnabled.autoRewriteImports
@@ -92,18 +94,22 @@ function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durab
92
94
  const cordisRecovery = cordisToolsEnabled
93
95
  ? 'Keep large Cordis plugin source in a binding before the tool call so it can be reused after an error. A Cordis parse or validation failure can follow external effects; repeat the call only when its retry rules and current results establish that it is safe.'
94
96
  : ''
95
- return `\`run_code\` continues one persistent PTC REPL. Use Node.js modules in the cell; use external processes when their environment is needed. Ordinary top-level bindings remain available to later cells, so reuse them instead of resending setup code. Choose the smallest cell that answers the request and return only the value the next step needs.
96
-
97
- After an execution error, earlier statements may have taken effect. Inspect relevant values in a short cell, then continue from them; repeat external operations only when their retry rules and current results establish safety. \`edit_run_code\` edits and reruns a complete cell.
98
-
99
- ## Cell conventions
100
- Use \`return\` for an explicit result and \`console\` for logs. Return supported values directly, including undefined, NaN, infinities, -0 and BigInt; JSON.stringify loses distinctions or rejects BigInt. Do not mix BigInt with Number arithmetic; use a BigInt literal or convert explicitly. Keep functions, Date, Map and other unsupported values in bindings; return needed data or type observations. Native \`tools.*\` arguments must satisfy their own JSON schemas; PTC value transport does not widen those contracts. Keep large inspection results in bindings or reduce them to targeted excerpts: \`tools.read\` is bounded inspection, not a lossless whole-file reader. Relative filesystem, path and default child-process operations use the current working directory at the time they run; a successful in-cell \`tools.working_directory({ cd })\` applies to the rest of that cell, while cell imports and \`import.meta\` keep the directory the cell started in, and \`process.chdir\` is unavailable. ${cellConventions}
101
-
102
- ## Available capabilities
103
- Use \`capabilities.tree()\`, \`capabilities.find()\`, and \`capabilities.inspect()\` for unfamiliar program APIs; Reserve \`code.run\` for isolated execution of source held as data.
104
- Program API references expire when their cell ends. Reusable helpers must read the current namespace when called, for example \`async function readNow(args) { return tools.read(args) }\`, instead of retaining an earlier \`tools\` object or method. Completed-cell callbacks cannot call program APIs.
105
-
106
- Native tool availability depends on the current DSH profile and execution world; inspect executables, shells, and paths instead of assuming Windows, WSL, POSIX, or a particular shell. ${recovery}${cordisToolsEnabled ? ` ${cordisRecovery}` : ''}`
97
+ // Each paragraph is an editable asset; a derived policy sentence is appended to
98
+ // the paragraph that owns it, so rewriting prose can never contradict the
99
+ // configuration that produced it. Omitting a paragraph keeps its derived clause.
100
+ const prose = id => (promptText.omitted(id) ? '' : promptText.text(id))
101
+ const withDerived = (id, derived) => {
102
+ const base = prose(id)
103
+ return base === '' ? derived : `${base} ${derived}`
104
+ }
105
+ const paragraphs = [
106
+ prose(PROMPT_SLOT_IDS.guidanceOpening),
107
+ prose(PROMPT_SLOT_IDS.guidanceErrorRecovery),
108
+ withDerived(PROMPT_SLOT_IDS.guidanceCellConventions, cellConventions),
109
+ prose(PROMPT_SLOT_IDS.guidanceCapabilities),
110
+ withDerived(PROMPT_SLOT_IDS.guidanceExecutionWorld, `${recovery}${cordisToolsEnabled ? ` ${cordisRecovery}` : ''}`),
111
+ ]
112
+ return paragraphs.filter(paragraph => paragraph.trim().length > 0).join('\n\n')
107
113
  }
108
114
 
109
115
  /** Register the session-bound REPL runtime. */
@@ -345,6 +351,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
345
351
  maxWallMs: activeConfig.maxWallMs,
346
352
  maxOutputBytes: activeConfig.maxOutputBytes,
347
353
  maxOldGenerationSizeMb: activeConfig.maxOldGenerationSizeMb,
354
+ workerReclaimMs: activeConfig.workerReclaimMs,
348
355
  valueLimits: valueLimitsFromConfig(activeConfig),
349
356
  })
350
357
  capabilityEnvironment = createCapabilityEnvironment({ ctx, config: activeConfig,
@@ -375,6 +382,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
375
382
  presentationGeneration,
376
383
  sessionId,
377
384
  toolSchemasForAgent,
385
+ promptText: resolvePromptTextConfig(activeConfig),
378
386
  })
379
387
  directSurface = createDirectSurfaceOwner({
380
388
  sessionLogView: sessionLog.project,
@@ -394,7 +402,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
394
402
  order: 98,
395
403
  text: context => {
396
404
  if (ctx.tools.get(RUN_CODE, context?.scope) === undefined) return ''
397
- return replGuidance(guidancePolicies(activeConfig))
405
+ return replGuidance(guidancePolicies(activeConfig), resolvePromptTextConfig(activeConfig))
398
406
  },
399
407
  }))
400
408
  disposers.push(ctx.on('system-prompt/assemble', async (assembly, context, next) => {
@@ -624,8 +632,11 @@ export const apply = (ctx, config = {}) => {
624
632
  // Configuration is validated before the plugin waits for the host's seam, so
625
633
  // an unusable configuration fails at the load call rather than later.
626
634
  const resolvedConfig = resolveConfig(config)
635
+ // The retention window is user configuration; install it before any fold can run.
636
+ configureRetention(resolvedConfig)
627
637
  return installExecutionSeam(ctx, {
628
638
  services: inject,
639
+ attachmentTimeoutMs: resolvedConfig.executionSeamTimeoutMs,
629
640
  attach: (scope, seam) => applyWithExecutionSeam(scope, resolvedConfig, seam, ctx, config),
630
641
  reportFailure: (error, seamName) => ctx.logger?.warn?.(
631
642
  `ptc-plus: execution seam ${seamName} failed to attach`,
@@ -886,9 +897,15 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
886
897
  ctx.effect(() => async () => controller.dispose(), 'ptc-plus runtime lifecycle')
887
898
 
888
899
  const handbackOwners = Object.fromEntries(['sessions', 'sessionPersistence', 'sessionQuery'].map(name => [name, ctx.get(name)]))
900
+ // One spelling of "the current document": the same resolved configuration the
901
+ // reconciliation loop applies, so a delivered message and the assembled prompt can
902
+ // never read two different prompt-text documents. Read lazily: the settings section
903
+ // replaces `configSource` after activation.
904
+ const currentPromptText = () => resolvePromptTextConfig(configSource())
889
905
  const messageOwner = createRuntimeMessageOwner(context => (
890
906
  committed?.retiring ? [] : committed?.runtime.contextsForRequest(context) ?? []
891
- ), sessionLog.project, () => committed !== undefined && committed.retiring !== true, handbackOwners.sessionQuery)
907
+ ), sessionLog.project, () => committed !== undefined && committed.retiring !== true, handbackOwners.sessionQuery,
908
+ currentPromptText)
892
909
  ctx.effect(() => ctx.systemPrompt.context({
893
910
  name: PTC_DELIVERY_CONTEXT, order: 98, text: '',
894
911
  }), 'ptc-plus dynamic message delivery witness')
@@ -905,6 +922,8 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
905
922
  await handBackRuntimeSessions(ctx, handbackOwners)
906
923
  }, 'ptc-plus provider handback')
907
924
 
925
+ // The settings section replaces this source after activation; the prompt-text
926
+ // accessor above reads it lazily for exactly that reason.
908
927
  let configSource = () => resolveConfig(volatileConfig ?? resolvedConfig)
909
928
  let configurationGeneration = 0
910
929
  let configurationRollback = false
@@ -935,6 +954,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
935
954
  }
936
955
  const reconcile = (propagateFailure = false) => {
937
956
  const current = resolveConfig(configSource())
957
+ configureRetention(current)
938
958
  const generation = ++configurationGeneration
939
959
  if (!current.enabled) {
940
960
  const operation = controller.uninstall()
@@ -982,6 +1002,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
982
1002
  }
983
1003
  ctx.effect(() => seam.onSourceChange(() => {
984
1004
  const current = resolveConfig(configSource())
1005
+ configureRetention(current)
985
1006
  if (!current.enabled) return undefined
986
1007
  if (seam.language !== 'typescript') {
987
1008
  const language = seam.language
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Compile-time evidence that a declaration's initializer is a bounded object
3
+ * literal.
4
+ *
5
+ * Recovery records a binding's value at settlement so it can be installed
6
+ * without re-executing the cell. Recording an arbitrary object is unsafe: the
7
+ * value codec enumerates an object's own keys before it can apply any budget,
8
+ * and enumerating a graph with millions of keys exhausts the worker heap before
9
+ * a cap can reject it (a four-million-key object aborts a 128 MiB worker).
10
+ *
11
+ * This module decides the question where it is cheap — on the syntax tree of the
12
+ * initializer — and returns the proven shape only for literals whose size is
13
+ * fixed by their source: an object literal with a bounded number of plain,
14
+ * non-computed properties whose values are primitives, array literals, or other
15
+ * such literals up to a fixed depth. Everything else (an identifier, a call, a
16
+ * class instance) yields no evidence, and its binding stays unrecorded and is
17
+ * named by recovery instead of being captured.
18
+ */
19
+
20
+ /** Maximum number of own properties a recordable literal may declare. */
21
+ export const RECORDED_LITERAL_MAX_PROPERTIES = 32
22
+
23
+ /** Maximum nesting depth of recordable literals, counting the outer object. */
24
+ export const RECORDED_LITERAL_MAX_DEPTH = 3
25
+
26
+ /** Maximum number of array elements a recordable array literal may declare. */
27
+ export const RECORDED_LITERAL_MAX_ELEMENTS = 64
28
+
29
+ const OBJECT_PROPERTY_TYPES = new Set(['ObjectProperty', 'Property'])
30
+ const LITERAL_KEY_TYPES = new Set(['StringLiteral', 'NumericLiteral', 'Literal', 'Identifier'])
31
+ const PRIMITIVE_LITERAL_TYPES = new Set(['StringLiteral', 'NumericLiteral', 'BooleanLiteral', 'NullLiteral',
32
+ 'BigIntLiteral', 'RegExpLiteral'])
33
+
34
+ function isRecordableValue(node, depth) {
35
+ if (node === null || node === undefined) return false
36
+ // ESTree parsers emit `Literal`; Babel emits the typed variants above.
37
+ if (node.type === 'Literal') {
38
+ const value = node.value
39
+ return value === null || ['number', 'string', 'boolean', 'bigint'].includes(typeof value)
40
+ || node.regex !== undefined
41
+ }
42
+ if (PRIMITIVE_LITERAL_TYPES.has(node.type)) return true
43
+ if (node.type === 'TemplateLiteral') return node.expressions.length === 0
44
+ if (node.type === 'UnaryExpression') {
45
+ return ['-', '+', '!', 'void'].includes(node.operator) && isRecordableValue(node.argument, depth)
46
+ }
47
+ if (node.type === 'ObjectExpression') return boundedLiteralEvidence(node, depth) !== undefined
48
+ if (node.type === 'ArrayExpression') {
49
+ return node.elements.length <= RECORDED_LITERAL_MAX_ELEMENTS
50
+ && node.elements.every(element => isRecordableValue(element, depth))
51
+ }
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * Evidence for one object literal, or `undefined` when the literal's size is
57
+ * not fixed by its source.
58
+ *
59
+ * @param {object} node an ESTree/Babel `ObjectExpression`
60
+ * @param {number} [depth] nest level of this literal, 1 for the top object
61
+ * @returns {{ properties: number, depth: number } | undefined}
62
+ */
63
+ export function boundedLiteralEvidence(node, depth = 1) {
64
+ if (node?.type !== 'ObjectExpression') return undefined
65
+ if (depth > RECORDED_LITERAL_MAX_DEPTH) return undefined
66
+ if (node.properties.length > RECORDED_LITERAL_MAX_PROPERTIES) return undefined
67
+ let proven = 0
68
+ for (const property of node.properties) {
69
+ // `kind` is 'init' on most Babel versions and absent on some parses; an
70
+ // accessor or method arrives as `ObjectMethod`, which this branch rejects
71
+ // through the `type` check above.
72
+ if (!OBJECT_PROPERTY_TYPES.has(property.type) || property.computed === true
73
+ || (property.kind !== undefined && property.kind !== 'init') || property.method === true) return undefined
74
+ const key = property.key
75
+ if (!LITERAL_KEY_TYPES.has(key.type)) return undefined
76
+ const valueNode = property.value
77
+ if (valueNode.type === 'ObjectExpression') {
78
+ if (boundedLiteralEvidence(valueNode, depth + 1) === undefined) return undefined
79
+ } else if (!isRecordableValue(valueNode, depth)) {
80
+ return undefined
81
+ }
82
+ proven += 1
83
+ }
84
+ return { properties: proven, depth }
85
+ }
@@ -14,6 +14,8 @@ import { PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
14
14
  import { deepFreeze } from './record-utils.js'
15
15
  import { capabilityProfileConfiguration } from './runtime-config.js'
16
16
  import { readRuntimeAvailability } from './runtime-messages.js'
17
+ import { PLAN_CATEGORY_LINES, PLAN_NAVIGATION, PLAN_OVERAGE, PLAN_OVERAGE_HOLE,
18
+ PROMPT_SLOT_IDS, planCategorySlotId, resolvePromptText, resolvePromptTextConfig } from './prompt-text.js'
17
19
 
18
20
  export const CAPABILITY_PLAN_FORM = 'capability-plan'
19
21
  export const CAPABILITY_EVIDENCE_KEY = 'dshPtcPlusCapabilities'
@@ -25,6 +27,54 @@ const LEGACY_NAVIGATION = 'Use capabilities.search({query}) to retrieve complete
25
27
  const NAVIGATION = 'Use capabilities.search({query}) to retrieve complete tool, Skill and MCP contracts. Unlisted capabilities remain discoverable. Skill descriptions are summaries; follow each Skill\'s declared invocation eligibility to load its body before following it.'
26
28
  const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
27
29
  const messageText = message => message?.content?.filter(block => block.type === 'text').map(block => block.text).join('\n') ?? ''
30
+ const byteLength = value => Buffer.byteLength(value, 'utf8')
31
+
32
+ /**
33
+ * Delivered plan-channel text, resolved from the prompt-text document.
34
+ *
35
+ * The planner must receive the same words the panel shows, so the navigation
36
+ * sentence, the five category lines and the overage template are read once here
37
+ * rather than defaulting inside the planner. `isDefault` decides whether the
38
+ * snapshot can keep a historical generation: only an edited channel needs the v5
39
+ * record, so an untouched configuration stays byte-identical to before.
40
+ */
41
+ function planChannelText(config) {
42
+ const prompt = resolvePromptTextConfig(config)
43
+ const navigationSentence = prompt.text(PROMPT_SLOT_IDS.planNavigationSentence)
44
+ const overageSentence = prompt.text(PROMPT_SLOT_IDS.planOverageSentence)
45
+ const categoryLines = Object.keys(PLAN_CATEGORY_LINES)
46
+ .map(category => [category, prompt.text(planCategorySlotId(category))])
47
+ const isDefault = navigationSentence === PLAN_NAVIGATION
48
+ && overageSentence === PLAN_OVERAGE
49
+ && categoryLines.every(([category, line]) => line === PLAN_CATEGORY_LINES[category])
50
+ return { navigationSentence, overageSentence, categoryLines, isDefault }
51
+ }
52
+
53
+ /**
54
+ * Directory budget for the delivered guidance.
55
+ *
56
+ * Editing a navigation sentence changes how many bytes the directory spends before
57
+ * any capability entry, so the budget moves with the text: an unchanged document
58
+ * therefore keeps today's budget exactly, and a longer sentence does not silently
59
+ * shrink the catalog.
60
+ */
61
+ const planDirectoryBudget = (configured, navigationSentence) => (
62
+ configured + byteLength(navigationSentence) - byteLength(PLAN_NAVIGATION))
63
+
64
+ /**
65
+ * Recorded plan text; a v5 snapshot carries the words and the budget it was
66
+ * composed from.
67
+ *
68
+ * The budget belongs in the record because it depends on the navigation sentence:
69
+ * replaying it from the current default literal would let a later edit of that
70
+ * literal silently change what an old snapshot replays to.
71
+ */
72
+ const planPromptText = (channel, directoryBudgetBytes) => ({ promptText: {
73
+ navigationSentence: channel.navigationSentence,
74
+ categoryLines: channel.categoryLines.map(([category, line]) => [category, line]),
75
+ overageSentence: channel.overageSentence,
76
+ directoryBudgetBytes,
77
+ } })
28
78
 
29
79
  /** Saved inputs and explicit session choices are distinct; automatic cancels a saved override. */
30
80
  export function sessionCapabilitySettings(sourceSettings, sessionOverrides = {}) {
@@ -85,9 +135,19 @@ function sourcePlan(message, id) {
85
135
  const source = message?.source
86
136
  if (source?.kind !== PTC_MESSAGE_SOURCE_KIND || source.form !== CAPABILITY_PLAN_FORM || source.sessionId !== id) return undefined
87
137
  const snapshot = source.snapshot
88
- const generation = ['ptc-capability-environment/v1', 'ptc-capability-environment/v2', 'ptc-capability-environment/v3', 'ptc-capability-environment/v4'].indexOf(snapshot?.schema)
138
+ const generation = ['ptc-capability-environment/v1', 'ptc-capability-environment/v2', 'ptc-capability-environment/v3',
139
+ 'ptc-capability-environment/v4', 'ptc-capability-environment/v5'].indexOf(snapshot?.schema)
140
+ // Historical generations each had one planner generation for a given settings
141
+ // shape, so their plan schema is part of the record's identity. Generation 5
142
+ // records the plan words instead, and its plan schema follows from the recorded
143
+ // settings: the recomputation below proves the whole plan, which makes a separate
144
+ // schema guess both unnecessary and wrong (a v5 snapshot may carry context
145
+ // settings and therefore a v3 plan, or a closed plan and therefore a v2 one).
146
+ const expectedPlanSchema = generation === 2 ? 'ptc-capability-plan/v2'
147
+ : generation >= 3 && snapshot.settings.policy !== undefined ? 'ptc-capability-plan/v3' : 'ptc-capability-plan/v1'
89
148
  if (source.digest !== digest(snapshot) || generation < 0
90
- || !Array.isArray(snapshot.capabilities) || snapshot.plan?.schema !== (generation === 3 ? 'ptc-capability-plan/v3' : generation === 2 ? 'ptc-capability-plan/v2' : 'ptc-capability-plan/v1')) {
149
+ || !Array.isArray(snapshot.capabilities)
150
+ || (generation < 4 && snapshot.plan?.schema !== expectedPlanSchema)) {
91
151
  throw new Error('damaged session capability plan')
92
152
  }
93
153
  if (generation >= 1) {
@@ -100,30 +160,74 @@ function sourcePlan(message, id) {
100
160
  }
101
161
  if (JSON.stringify(merged) !== JSON.stringify(snapshot.settings)) throw new Error('inconsistent session capability choices')
102
162
  }
103
- if (generation === 3 ? typeof snapshot.settings.contextEnabled !== 'boolean' : snapshot.settings.contextEnabled !== undefined) {
163
+ // v4 implies a context generation; v5 only says the plan words were edited, so it
164
+ // may or may not carry context settings.
165
+ const contextGeneration = generation === 3 ? typeof snapshot.settings.contextEnabled === 'boolean'
166
+ : generation === 4 ? true : snapshot.settings.contextEnabled === undefined
167
+ if (!contextGeneration) {
104
168
  throw new Error('inconsistent capability context generation')
105
169
  }
106
- if (generation === 2 || generation === 3 && snapshot.settings.policy !== undefined) {
170
+ if (generation === 2 || generation >= 3 && snapshot.settings.policy !== undefined) {
107
171
  if (!snapshot.settings.policy || typeof snapshot.settings.recoveryEnabled !== 'boolean'
108
172
  || typeof snapshot.settings.previewEnabled !== 'boolean') throw new Error('damaged capability policy settings')
109
173
  } else if (snapshot.settings.policy !== undefined) throw new Error('inconsistent historical capability generation')
110
- if (snapshot.navigationText !== undefined && snapshot.navigationText !== NAVIGATION) throw new Error('unknown capability navigation contract')
174
+ // A v5 snapshot carries the plan words it was composed from; a historical
175
+ // generation may only claim the registry defaults it was delivered with.
176
+ const recorded = generation === 4 ? planRecord(snapshot) : undefined
177
+ if (recorded === undefined) {
178
+ if (snapshot.promptText !== undefined) throw new Error('inconsistent capability plan text generation')
179
+ if (snapshot.navigationText !== undefined && snapshot.navigationText !== NAVIGATION) throw new Error('unknown capability navigation contract')
180
+ } else if (snapshot.navigationText !== recorded.navigationSentence) {
181
+ throw new Error('damaged capability plan text')
182
+ }
111
183
  const expected = createCapabilityPlan({ ...snapshot.settings, capabilities: snapshot.capabilities,
112
184
  coverage: snapshot.coverage, navigationText: snapshot.navigationText ?? LEGACY_NAVIGATION,
185
+ ...(recorded === undefined ? {} : {
186
+ categories: recorded.categoryLines,
187
+ directoryBudgetBytes: recorded.directoryBudgetBytes,
188
+ }),
113
189
  configurationRevision: digest(snapshot.settings) })
114
- if (JSON.stringify(expected) !== JSON.stringify(snapshot.plan)
190
+ if ((generation >= 4 && snapshot.plan?.schema !== expected.schema)
191
+ || JSON.stringify(expected) !== JSON.stringify(snapshot.plan)
115
192
  || messageText(message) !== messageText(planMessage(snapshot, id, source.refresh === true))) {
116
193
  throw new Error('inconsistent session capability plan')
117
194
  }
118
195
  return deepFreeze(structuredClone(snapshot))
119
196
  }
120
197
 
198
+ /**
199
+ * Validated plan words of a v5 snapshot.
200
+ *
201
+ * Returns `undefined` only when the snapshot carries no record at all; a record that
202
+ * exists but cannot be replayed throws, because a half-recorded plan text would let
203
+ * a later replay silently deliver different words than the ones recorded here.
204
+ */
205
+ function planRecord(snapshot) {
206
+ const record = snapshot.promptText
207
+ if (record === undefined || record === null) return undefined
208
+ const categories = record.categoryLines
209
+ const known = Object.keys(PLAN_CATEGORY_LINES)
210
+ if (typeof record.navigationSentence !== 'string' || record.navigationSentence.length === 0
211
+ || typeof record.overageSentence !== 'string' || record.overageSentence.split(PLAN_OVERAGE_HOLE).length !== 2
212
+ || !Number.isSafeInteger(record.directoryBudgetBytes) || record.directoryBudgetBytes < 0
213
+ || !Array.isArray(categories) || categories.length !== known.length
214
+ || categories.some(pair => !Array.isArray(pair) || pair.length !== 2
215
+ || !known.includes(pair[0]) || typeof pair[1] !== 'string' || pair[1].length === 0)
216
+ || new Set(categories.map(pair => pair[0])).size !== categories.length) {
217
+ throw new Error('damaged capability plan text')
218
+ }
219
+ return { navigationSentence: record.navigationSentence, overageSentence: record.overageSentence,
220
+ directoryBudgetBytes: record.directoryBudgetBytes,
221
+ categoryLines: categories.map(([category, line]) => [category, line]) }
222
+ }
223
+
121
224
  function planMessage(snapshot, id, refresh = false) {
122
225
  const plan = snapshot.plan
226
+ const overage = snapshot.promptText?.overageSentence ?? PLAN_OVERAGE
123
227
  const text = plan.enabled
124
228
  ? ['PTC Plus session capability environment' + (refresh ? ' refreshed; replaces the preceding capability plan.' : ':'),
125
229
  plan.directoryText, plan.catalogText,
126
- ...(plan.budgets.overageBytes ? [`Explicit full contracts exceed the configured budget by ${plan.budgets.overageBytes} UTF-8 bytes.`] : [])]
230
+ ...(plan.budgets.overageBytes ? [overage.replace(PLAN_OVERAGE_HOLE, String(plan.budgets.overageBytes))] : [])]
127
231
  .filter(Boolean).join('\n\n')
128
232
  : snapshot.settings.contextEnabled === false
129
233
  ? 'PTC Plus capability and context settings are paused for this session. Use the host-provided capability contracts and result presentation.'
@@ -132,6 +236,43 @@ function planMessage(snapshot, id, refresh = false) {
132
236
  sessionId: id, refresh, digest: digest(snapshot), snapshot }, content: [{ type: 'text', text }] })
133
237
  }
134
238
 
239
+ /**
240
+ * Delivered plan text for a snapshot, without the message envelope.
241
+ *
242
+ * Replay and the surface gate both need the bytes the model reads; keeping the
243
+ * composition private to this module means a caller cannot assemble a different
244
+ * plan and call it the delivered one.
245
+ */
246
+ export function capabilityPlanText(snapshot, id, refresh = false) {
247
+ return planMessage(snapshot, id, refresh).content[0].text
248
+ }
249
+
250
+ /**
251
+ * Delivered plan message for a snapshot, envelope included.
252
+ *
253
+ * What this composition can claim is consistency, not provenance: the settings merge
254
+ * is a fixed point of the recorded settings, the recorded plan deep-equals a
255
+ * recomputation from them, the delivered text equals a recomposition of that plan and
256
+ * the snapshots it reads are deeply frozen. It recomputes the digest, so it cannot
257
+ * attest that these bytes are the ones a session log once carried.
258
+ */
259
+ export function capabilityPlanMessage(snapshot, id, refresh = false) {
260
+ return planMessage(snapshot, id, refresh)
261
+ }
262
+
263
+ /**
264
+ * Delivered plan text of a plan message, after the replay contract accepted it.
265
+ *
266
+ * The reader above stays a recognizer and hands out no composition state; a caller
267
+ * that needs the bytes the model read asks this projection instead. An unreplayable
268
+ * message throws, so a lane that compares bytes cannot pass by receiving `undefined`.
269
+ */
270
+ export function capabilityPlanTextFromMessage(message, sessionId) {
271
+ if (message?.source?.form !== CAPABILITY_PLAN_FORM) return undefined
272
+ sourcePlan(message, sessionId)
273
+ return message.content[0].text
274
+ }
275
+
135
276
  /** Recognize capability-owned presentation for bounded offline context accounting. */
136
277
  export function readCapabilityContextMessage(message, sessionId) {
137
278
  const source = message?.source
@@ -702,11 +843,22 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
702
843
  const capabilities = requiresCapabilityInventory(settings) ? await inventory(agent, sections ?? [], settings, signal) : []
703
844
  const effective = effectiveCapabilitySettings(settings)
704
845
  const coverage = (effective.policy ? effective.enabled && effective.policy.allocation.statistics : effective.statistics?.enabled === true) ? await store?.coverage(agent, capabilities, settings.statistics) : undefined
846
+ const planChannel = planChannelText(currentConfig)
847
+ const directoryBudgetBytes = planDirectoryBudget(settings.directoryBudgetBytes, planChannel.navigationSentence)
705
848
  const plan = createCapabilityPlan({ ...settings, configurationRevision: digest(settings),
706
- capabilities, coverage, navigationText: NAVIGATION })
849
+ capabilities, coverage, navigationText: planChannel.navigationSentence,
850
+ categories: planChannel.categoryLines,
851
+ directoryBudgetBytes })
707
852
  const hasChoices = settings.contextEnabled !== undefined || settings.policy !== undefined || choice !== undefined || previous?.schema === 'ptc-capability-environment/v2'
708
- const snapshot = deepFreeze({ schema: settings.contextEnabled !== undefined ? 'ptc-capability-environment/v4' : settings.policy ? 'ptc-capability-environment/v3' : hasChoices ? 'ptc-capability-environment/v2' : 'ptc-capability-environment/v1',
709
- ...(hasChoices ? { sourceSettings, sessionOverrides } : {}), navigationText: NAVIGATION, settings, capabilities, ...(coverage === undefined ? {} : { coverage }), plan })
853
+ // An edited plan channel needs the v5 record, and that generation requires the
854
+ // saved inputs and session choices it was composed from.
855
+ const recordsText = !planChannel.isDefault
856
+ const snapshot = deepFreeze({ schema: recordsText ? 'ptc-capability-environment/v5'
857
+ : settings.contextEnabled !== undefined ? 'ptc-capability-environment/v4' : settings.policy ? 'ptc-capability-environment/v3'
858
+ : hasChoices ? 'ptc-capability-environment/v2' : 'ptc-capability-environment/v1',
859
+ ...(hasChoices || recordsText ? { sourceSettings, sessionOverrides } : {}),
860
+ ...(recordsText ? planPromptText(planChannel, directoryBudgetBytes) : {}),
861
+ navigationText: planChannel.navigationSentence, settings, capabilities, ...(coverage === undefined ? {} : { coverage }), plan })
710
862
  const initialized = sessionEvents(agent.session).some(event => event.type === 'system/message')
711
863
  const message = planMessage(snapshot, id, force && initialized)
712
864
  agent.signal?.throwIfAborted?.()
@@ -1,16 +1,12 @@
1
1
  import { capabilityRule, validateCapabilityPolicy, effectiveCapabilitySettings } from './capability-policy.js'
2
2
  import { Buffer } from 'node:buffer'
3
+ import { PLAN_CATEGORY_LINES } from './prompt-text.js'
3
4
 
4
5
  const MANAGED_CATEGORIES = new Set(['tool', 'skill', 'resource', 'resource-template', 'task'])
5
6
  const EXCLUDED_CATEGORIES = new Set(['language', 'node', 'repl', 'ptc-plus', 'global-binding'])
6
7
  const SELECTIONS = new Set(['full', 'on-demand', 'closed'])
7
- const CATEGORY_NAVIGATION = new Map([
8
- ['tool', 'Tools: search by task or tools.<name> for parameters and results.'],
9
- ['skill', 'Skills: search by task or skills.<name> for when to use and how to load instructions.'],
10
- ['resource', 'MCP resources: search by server or resource purpose.'],
11
- ['resource-template', 'MCP templates: search by server or resource purpose.'],
12
- ['task', 'Tasks: search by the operation you need.'],
13
- ])
8
+ /** Category guidance defaults; the registry owns the delivered literals. */
9
+ const CATEGORY_NAVIGATION = new Map(Object.entries(PLAN_CATEGORY_LINES))
14
10
 
15
11
  function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
16
12
 
@@ -121,6 +117,13 @@ function createLegacyCapabilityPlan(input) {
121
117
  const budgetBytes = integer(options.budgetBytes, 'contract budget bytes')
122
118
  const directoryBudgetBytes = integer(options.directoryBudgetBytes, 'directory budget bytes')
123
119
  const navigationText = text(options.navigationText, 'bounded discovery guidance')
120
+ const categoryNavigation = options.categories === undefined
121
+ ? CATEGORY_NAVIGATION
122
+ : new Map(options.categories.map(pair => {
123
+ if (!Array.isArray(pair) || pair.length !== 2) throw new TypeError('ptc-plus: capability categories must be [category, guidance] pairs')
124
+ const category = text(pair[0], `capability category ${pair[0]}`)
125
+ return [category, text(pair[1], `capability guidance for ${category}`)]
126
+ }))
124
127
  if (bytes(navigationText) > directoryBudgetBytes) throw new RangeError('ptc-plus: discovery guidance exceeds directory byte budget')
125
128
  const manual = record(options.manual ?? {}, 'manual capability settings')
126
129
  const overrides = record(manual.overrides ?? {}, 'capability overrides')
@@ -220,7 +223,7 @@ function createLegacyCapabilityPlan(input) {
220
223
  const directoryCategories = []
221
224
  const availableCategories = new Set(capabilities.filter(capability => selections.get(capability.id).selection !== 'closed')
222
225
  .map(capability => capability.category))
223
- for (const [category, guidance] of CATEGORY_NAVIGATION) {
226
+ for (const [category, guidance] of categoryNavigation) {
224
227
  if (!availableCategories.has(category)) continue
225
228
  const proposed = `${directoryText}\n${guidance}`
226
229
  if (bytes(proposed) <= directoryBudgetBytes) {
@@ -5,3 +5,16 @@ export class ModuleRewriteError extends SyntaxError {
5
5
  if (cellPosition !== undefined) this.cellPosition = cellPosition
6
6
  }
7
7
  }
8
+
9
+ /**
10
+ * The kernel refuses to admit a cell because a previous helper was not reclaimed.
11
+ *
12
+ * This is a fact about the kernel's own lifecycle, not about the recorded history
13
+ * or the submitted source, so consumers must not contract a frontier or blame a
14
+ * cell for it.
15
+ */
16
+ export class CellAdmissionBlocked extends AggregateError {
17
+ constructor(failures) {
18
+ super(failures, 'previous computation helper reclamation failed; dispose the session before continuing')
19
+ }
20
+ }