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
@@ -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
+ }
@@ -1,4 +1,6 @@
1
1
  /** Structured command outcome for locale-owned Client receipts; text remains a CLI fallback. */
2
+ import { capabilityProofText, normalizeCapabilityProof } from './capability-delivery-proof.js'
3
+
2
4
  const PREFIX = '\n\nPTC Plus capability refresh receipt:\n'
3
5
  const FALLBACKS = Object.freeze({
4
6
  'saved-only': 'Global choice saved, but the session update failed; inspect and retry session',
@@ -11,13 +13,25 @@ const FALLBACKS = Object.freeze({
11
13
  })
12
14
  const STATES = new Set(['recorded', 'pending', 'unavailable', 'usage', 'saved-only', 'paused', 'off'])
13
15
 
14
- export function capabilityCommandReceipt(state, sessionId, error, proposal) {
16
+ export function capabilityCommandReceipt(state, sessionId, error, proposal, proof) {
15
17
  if (!STATES.has(state) || typeof sessionId !== 'string' || !sessionId) throw new TypeError('invalid capability command receipt')
16
18
  if (error !== undefined && (state !== 'saved-only' || typeof error !== 'string' || !error)) throw new TypeError('invalid capability command error')
17
19
  if (proposal !== undefined && (state !== 'pending' || !proposal || typeof proposal.commandId !== 'string'
18
20
  || typeof proposal.rawInput !== 'string' || !proposal.message)) throw new TypeError('invalid capability proposal receipt')
19
- return `${FALLBACKS[state]} ${sessionId}.${error ? ` ${error}` : ''}` + PREFIX
20
- + JSON.stringify({ schema: 'ptc-capability-command/v1', state, sessionId, ...(error === undefined ? {} : { error }), ...(proposal === undefined ? {} : { proposal }) })
21
+ // A command that names one capability reports that capability's three layers
22
+ // beside the session outcome, so "saved" cannot be read as "delivered" or
23
+ // "usable". The schema stays v1 because the field is additive: receipts
24
+ // recorded before it existed still reproduce their own text.
25
+ const layers = proof === undefined ? undefined : normalizeCapabilityProof(proof)
26
+ return `${FALLBACKS[state]} ${sessionId}.${error ? ` ${error}` : ''}`
27
+ + (layers === undefined ? '' : ` ${capabilityProofText(layers)}`)
28
+ + PREFIX
29
+ + JSON.stringify({
30
+ schema: 'ptc-capability-command/v1', state, sessionId,
31
+ ...(error === undefined ? {} : { error }),
32
+ ...(proposal === undefined ? {} : { proposal }),
33
+ ...(layers === undefined ? {} : { proof: layers }),
34
+ })
21
35
  }
22
36
 
23
37
  export function readCapabilityCommandReceipt(text) {
@@ -26,6 +40,6 @@ export function readCapabilityCommandReceipt(text) {
26
40
  const receipt = JSON.parse(text.slice(text.indexOf(PREFIX) + PREFIX.length))
27
41
  return receipt?.schema === 'ptc-capability-command/v1' && STATES.has(receipt.state)
28
42
  && typeof receipt.sessionId === 'string' && receipt.sessionId
29
- && capabilityCommandReceipt(receipt.state, receipt.sessionId, receipt.error, receipt.proposal) === text ? receipt : undefined
43
+ && capabilityCommandReceipt(receipt.state, receipt.sessionId, receipt.error, receipt.proposal, receipt.proof) === text ? receipt : undefined
30
44
  } catch { return undefined }
31
45
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * One capability, three separate facts.
3
+ *
4
+ * Saving a global choice changes supply: the configuration now records a
5
+ * decision. It does not prove the model ever received that decision, and it does
6
+ * not prove a cell can reach the capability. Those are different questions with
7
+ * different owners:
8
+ *
9
+ * - **saved**: the global configuration records a choice for this capability.
10
+ * - **delivered**: the model-visible capability catalog published it at an
11
+ * accepted step, rather than waiting for the next one or never listing it.
12
+ * - **usable**: a completed execution in this session actually reached it.
13
+ *
14
+ * Usability is only ever proved by an execution result. Nothing else in the
15
+ * product can claim it: a saved switch, a published catalog entry and an
16
+ * installed tool schema are all statements about intent, not about a value a
17
+ * cell obtained. Reporting "saved, delivered, usability unproven" is therefore
18
+ * the honest answer to "did that save take effect", and it is what a layer that
19
+ * cannot prove more must say instead of claiming effect.
20
+ */
21
+ export const CAPABILITY_PROOF_SCHEMA = 'ptc-capability-proof/v1'
22
+ export const CAPABILITY_DELIVERY_STATES = new Set(['delivered', 'pending', 'absent'])
23
+ export const CAPABILITY_USABILITY_STATES = new Set(['proven', 'unproven'])
24
+
25
+ const CAPABILITY_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/
26
+
27
+ function isRecord(value) {
28
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
29
+ }
30
+
31
+ export function normalizeCapabilityProof(value) {
32
+ if (!isRecord(value) || Reflect.ownKeys(value).length !== 5
33
+ || !Object.hasOwn(value, 'schema') || !Object.hasOwn(value, 'id') || !Object.hasOwn(value, 'saved')
34
+ || !Object.hasOwn(value, 'delivery') || !Object.hasOwn(value, 'usability')) {
35
+ throw new TypeError('invalid capability proof')
36
+ }
37
+ if (value.schema !== CAPABILITY_PROOF_SCHEMA) throw new TypeError('invalid capability proof schema')
38
+ if (typeof value.id !== 'string' || !CAPABILITY_ID.test(value.id)) throw new TypeError('invalid capability proof id')
39
+ if (typeof value.saved !== 'boolean') throw new TypeError('invalid capability proof saved fact')
40
+ if (!CAPABILITY_DELIVERY_STATES.has(value.delivery)) throw new TypeError('invalid capability proof delivery state')
41
+ if (!CAPABILITY_USABILITY_STATES.has(value.usability)) throw new TypeError('invalid capability proof usability state')
42
+ return Object.freeze({
43
+ schema: CAPABILITY_PROOF_SCHEMA,
44
+ id: value.id,
45
+ saved: value.saved,
46
+ delivery: value.delivery,
47
+ usability: value.usability,
48
+ })
49
+ }
50
+
51
+ /**
52
+ * The model-visible sentence for one proof. Each layer states its own result, so
53
+ * a reader can tell which layer failed instead of reading one blanket "applied".
54
+ */
55
+ export function capabilityProofText(proof) {
56
+ const normalized = normalizeCapabilityProof(proof)
57
+ const id = normalized.id
58
+ const saved = normalized.saved
59
+ ? `Saved: yes. The global configuration records a choice for ${id}.`
60
+ : `Saved: no. The global configuration records no choice for ${id}, so this save did not happen; the session choice below is the only change.`
61
+ const delivery = {
62
+ delivered: `Delivered: yes. The model-visible capability catalog published ${id} at an accepted step.`,
63
+ pending: `Delivered: not yet. The saved configuration reaches the model-visible catalog at the next accepted step, so ${id} is not in it now.`,
64
+ absent: `Delivered: no. The published capability catalog does not list ${id}, so the model never received it.`,
65
+ }[normalized.delivery]
66
+ const usability = normalized.usability === 'proven'
67
+ ? `Usable: proven. A completed execution in this session reached ${id}.`
68
+ : `Usable: unproven. No completed execution in this session reached ${id}; only such a result would prove it, and a saved or published entry does not.`
69
+ return `${saved} ${delivery} ${usability}`
70
+ }
@@ -1,7 +1,7 @@
1
1
  import { capabilityRule, capabilityPolicyForConfig, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from './capability-policy.js'
2
2
  /** One session-owned capability view for presentation, discovery and preparation. */
3
3
  import { createHash } from 'node:crypto'
4
- import { jsonSchemaToTs, renderToolsSdk, ToolNotFoundError } from '@deepseek-ai/dsh-tools'
4
+ import { jsonSchemaToTs, renderToolsSdk, ToolNotFoundError } from './host-tool-sdk.js'
5
5
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
6
6
  import { isModelInvocable, isUserInvocable } from '@deepseek-ai/dsh-skill'
7
7
  import { createScope } from '@deepseek-ai/dsh-scope'
@@ -9,11 +9,14 @@ import { Context } from '@deepseek-ai/cordis'
9
9
  import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
10
10
  import { readSessionLog } from './session-events.js'
11
11
  import { readCapabilityCommandReceipt } from './capability-command-receipt.js'
12
+ import { normalizeCapabilityProof } from './capability-delivery-proof.js'
12
13
  import { createCapabilityPlan } from './capability-plan.js'
13
14
  import { PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
14
15
  import { deepFreeze } from './record-utils.js'
15
16
  import { capabilityProfileConfiguration } from './runtime-config.js'
16
17
  import { readRuntimeAvailability } from './runtime-messages.js'
18
+ import { PLAN_CATEGORY_LINES, PLAN_NAVIGATION, PLAN_OVERAGE, PLAN_OVERAGE_HOLE,
19
+ PROMPT_SLOT_IDS, planCategorySlotId, resolvePromptText, resolvePromptTextConfig } from './prompt-text.js'
17
20
 
18
21
  export const CAPABILITY_PLAN_FORM = 'capability-plan'
19
22
  export const CAPABILITY_EVIDENCE_KEY = 'dshPtcPlusCapabilities'
@@ -25,6 +28,54 @@ const LEGACY_NAVIGATION = 'Use capabilities.search({query}) to retrieve complete
25
28
  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
29
  const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
27
30
  const messageText = message => message?.content?.filter(block => block.type === 'text').map(block => block.text).join('\n') ?? ''
31
+ const byteLength = value => Buffer.byteLength(value, 'utf8')
32
+
33
+ /**
34
+ * Delivered plan-channel text, resolved from the prompt-text document.
35
+ *
36
+ * The planner must receive the same words the panel shows, so the navigation
37
+ * sentence, the five category lines and the overage template are read once here
38
+ * rather than defaulting inside the planner. `isDefault` decides whether the
39
+ * snapshot can keep a historical generation: only an edited channel needs the v5
40
+ * record, so an untouched configuration stays byte-identical to before.
41
+ */
42
+ function planChannelText(config) {
43
+ const prompt = resolvePromptTextConfig(config)
44
+ const navigationSentence = prompt.text(PROMPT_SLOT_IDS.planNavigationSentence)
45
+ const overageSentence = prompt.text(PROMPT_SLOT_IDS.planOverageSentence)
46
+ const categoryLines = Object.keys(PLAN_CATEGORY_LINES)
47
+ .map(category => [category, prompt.text(planCategorySlotId(category))])
48
+ const isDefault = navigationSentence === PLAN_NAVIGATION
49
+ && overageSentence === PLAN_OVERAGE
50
+ && categoryLines.every(([category, line]) => line === PLAN_CATEGORY_LINES[category])
51
+ return { navigationSentence, overageSentence, categoryLines, isDefault }
52
+ }
53
+
54
+ /**
55
+ * Directory budget for the delivered guidance.
56
+ *
57
+ * Editing a navigation sentence changes how many bytes the directory spends before
58
+ * any capability entry, so the budget moves with the text: an unchanged document
59
+ * therefore keeps today's budget exactly, and a longer sentence does not silently
60
+ * shrink the catalog.
61
+ */
62
+ const planDirectoryBudget = (configured, navigationSentence) => (
63
+ configured + byteLength(navigationSentence) - byteLength(PLAN_NAVIGATION))
64
+
65
+ /**
66
+ * Recorded plan text; a v5 snapshot carries the words and the budget it was
67
+ * composed from.
68
+ *
69
+ * The budget belongs in the record because it depends on the navigation sentence:
70
+ * replaying it from the current default literal would let a later edit of that
71
+ * literal silently change what an old snapshot replays to.
72
+ */
73
+ const planPromptText = (channel, directoryBudgetBytes) => ({ promptText: {
74
+ navigationSentence: channel.navigationSentence,
75
+ categoryLines: channel.categoryLines.map(([category, line]) => [category, line]),
76
+ overageSentence: channel.overageSentence,
77
+ directoryBudgetBytes,
78
+ } })
28
79
 
29
80
  /** Saved inputs and explicit session choices are distinct; automatic cancels a saved override. */
30
81
  export function sessionCapabilitySettings(sourceSettings, sessionOverrides = {}) {
@@ -85,9 +136,19 @@ function sourcePlan(message, id) {
85
136
  const source = message?.source
86
137
  if (source?.kind !== PTC_MESSAGE_SOURCE_KIND || source.form !== CAPABILITY_PLAN_FORM || source.sessionId !== id) return undefined
87
138
  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)
139
+ const generation = ['ptc-capability-environment/v1', 'ptc-capability-environment/v2', 'ptc-capability-environment/v3',
140
+ 'ptc-capability-environment/v4', 'ptc-capability-environment/v5'].indexOf(snapshot?.schema)
141
+ // Historical generations each had one planner generation for a given settings
142
+ // shape, so their plan schema is part of the record's identity. Generation 5
143
+ // records the plan words instead, and its plan schema follows from the recorded
144
+ // settings: the recomputation below proves the whole plan, which makes a separate
145
+ // schema guess both unnecessary and wrong (a v5 snapshot may carry context
146
+ // settings and therefore a v3 plan, or a closed plan and therefore a v2 one).
147
+ const expectedPlanSchema = generation === 2 ? 'ptc-capability-plan/v2'
148
+ : generation >= 3 && snapshot.settings.policy !== undefined ? 'ptc-capability-plan/v3' : 'ptc-capability-plan/v1'
89
149
  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')) {
150
+ || !Array.isArray(snapshot.capabilities)
151
+ || (generation < 4 && snapshot.plan?.schema !== expectedPlanSchema)) {
91
152
  throw new Error('damaged session capability plan')
92
153
  }
93
154
  if (generation >= 1) {
@@ -100,30 +161,74 @@ function sourcePlan(message, id) {
100
161
  }
101
162
  if (JSON.stringify(merged) !== JSON.stringify(snapshot.settings)) throw new Error('inconsistent session capability choices')
102
163
  }
103
- if (generation === 3 ? typeof snapshot.settings.contextEnabled !== 'boolean' : snapshot.settings.contextEnabled !== undefined) {
164
+ // v4 implies a context generation; v5 only says the plan words were edited, so it
165
+ // may or may not carry context settings.
166
+ const contextGeneration = generation === 3 ? typeof snapshot.settings.contextEnabled === 'boolean'
167
+ : generation === 4 ? true : snapshot.settings.contextEnabled === undefined
168
+ if (!contextGeneration) {
104
169
  throw new Error('inconsistent capability context generation')
105
170
  }
106
- if (generation === 2 || generation === 3 && snapshot.settings.policy !== undefined) {
171
+ if (generation === 2 || generation >= 3 && snapshot.settings.policy !== undefined) {
107
172
  if (!snapshot.settings.policy || typeof snapshot.settings.recoveryEnabled !== 'boolean'
108
173
  || typeof snapshot.settings.previewEnabled !== 'boolean') throw new Error('damaged capability policy settings')
109
174
  } 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')
175
+ // A v5 snapshot carries the plan words it was composed from; a historical
176
+ // generation may only claim the registry defaults it was delivered with.
177
+ const recorded = generation === 4 ? planRecord(snapshot) : undefined
178
+ if (recorded === undefined) {
179
+ if (snapshot.promptText !== undefined) throw new Error('inconsistent capability plan text generation')
180
+ if (snapshot.navigationText !== undefined && snapshot.navigationText !== NAVIGATION) throw new Error('unknown capability navigation contract')
181
+ } else if (snapshot.navigationText !== recorded.navigationSentence) {
182
+ throw new Error('damaged capability plan text')
183
+ }
111
184
  const expected = createCapabilityPlan({ ...snapshot.settings, capabilities: snapshot.capabilities,
112
185
  coverage: snapshot.coverage, navigationText: snapshot.navigationText ?? LEGACY_NAVIGATION,
186
+ ...(recorded === undefined ? {} : {
187
+ categories: recorded.categoryLines,
188
+ directoryBudgetBytes: recorded.directoryBudgetBytes,
189
+ }),
113
190
  configurationRevision: digest(snapshot.settings) })
114
- if (JSON.stringify(expected) !== JSON.stringify(snapshot.plan)
191
+ if ((generation >= 4 && snapshot.plan?.schema !== expected.schema)
192
+ || JSON.stringify(expected) !== JSON.stringify(snapshot.plan)
115
193
  || messageText(message) !== messageText(planMessage(snapshot, id, source.refresh === true))) {
116
194
  throw new Error('inconsistent session capability plan')
117
195
  }
118
196
  return deepFreeze(structuredClone(snapshot))
119
197
  }
120
198
 
199
+ /**
200
+ * Validated plan words of a v5 snapshot.
201
+ *
202
+ * Returns `undefined` only when the snapshot carries no record at all; a record that
203
+ * exists but cannot be replayed throws, because a half-recorded plan text would let
204
+ * a later replay silently deliver different words than the ones recorded here.
205
+ */
206
+ function planRecord(snapshot) {
207
+ const record = snapshot.promptText
208
+ if (record === undefined || record === null) return undefined
209
+ const categories = record.categoryLines
210
+ const known = Object.keys(PLAN_CATEGORY_LINES)
211
+ if (typeof record.navigationSentence !== 'string' || record.navigationSentence.length === 0
212
+ || typeof record.overageSentence !== 'string' || record.overageSentence.split(PLAN_OVERAGE_HOLE).length !== 2
213
+ || !Number.isSafeInteger(record.directoryBudgetBytes) || record.directoryBudgetBytes < 0
214
+ || !Array.isArray(categories) || categories.length !== known.length
215
+ || categories.some(pair => !Array.isArray(pair) || pair.length !== 2
216
+ || !known.includes(pair[0]) || typeof pair[1] !== 'string' || pair[1].length === 0)
217
+ || new Set(categories.map(pair => pair[0])).size !== categories.length) {
218
+ throw new Error('damaged capability plan text')
219
+ }
220
+ return { navigationSentence: record.navigationSentence, overageSentence: record.overageSentence,
221
+ directoryBudgetBytes: record.directoryBudgetBytes,
222
+ categoryLines: categories.map(([category, line]) => [category, line]) }
223
+ }
224
+
121
225
  function planMessage(snapshot, id, refresh = false) {
122
226
  const plan = snapshot.plan
227
+ const overage = snapshot.promptText?.overageSentence ?? PLAN_OVERAGE
123
228
  const text = plan.enabled
124
229
  ? ['PTC Plus session capability environment' + (refresh ? ' refreshed; replaces the preceding capability plan.' : ':'),
125
230
  plan.directoryText, plan.catalogText,
126
- ...(plan.budgets.overageBytes ? [`Explicit full contracts exceed the configured budget by ${plan.budgets.overageBytes} UTF-8 bytes.`] : [])]
231
+ ...(plan.budgets.overageBytes ? [overage.replace(PLAN_OVERAGE_HOLE, String(plan.budgets.overageBytes))] : [])]
127
232
  .filter(Boolean).join('\n\n')
128
233
  : snapshot.settings.contextEnabled === false
129
234
  ? 'PTC Plus capability and context settings are paused for this session. Use the host-provided capability contracts and result presentation.'
@@ -132,6 +237,43 @@ function planMessage(snapshot, id, refresh = false) {
132
237
  sessionId: id, refresh, digest: digest(snapshot), snapshot }, content: [{ type: 'text', text }] })
133
238
  }
134
239
 
240
+ /**
241
+ * Delivered plan text for a snapshot, without the message envelope.
242
+ *
243
+ * Replay and the surface gate both need the bytes the model reads; keeping the
244
+ * composition private to this module means a caller cannot assemble a different
245
+ * plan and call it the delivered one.
246
+ */
247
+ export function capabilityPlanText(snapshot, id, refresh = false) {
248
+ return planMessage(snapshot, id, refresh).content[0].text
249
+ }
250
+
251
+ /**
252
+ * Delivered plan message for a snapshot, envelope included.
253
+ *
254
+ * What this composition can claim is consistency, not provenance: the settings merge
255
+ * is a fixed point of the recorded settings, the recorded plan deep-equals a
256
+ * recomputation from them, the delivered text equals a recomposition of that plan and
257
+ * the snapshots it reads are deeply frozen. It recomputes the digest, so it cannot
258
+ * attest that these bytes are the ones a session log once carried.
259
+ */
260
+ export function capabilityPlanMessage(snapshot, id, refresh = false) {
261
+ return planMessage(snapshot, id, refresh)
262
+ }
263
+
264
+ /**
265
+ * Delivered plan text of a plan message, after the replay contract accepted it.
266
+ *
267
+ * The reader above stays a recognizer and hands out no composition state; a caller
268
+ * that needs the bytes the model read asks this projection instead. An unreplayable
269
+ * message throws, so a lane that compares bytes cannot pass by receiving `undefined`.
270
+ */
271
+ export function capabilityPlanTextFromMessage(message, sessionId) {
272
+ if (message?.source?.form !== CAPABILITY_PLAN_FORM) return undefined
273
+ sourcePlan(message, sessionId)
274
+ return message.content[0].text
275
+ }
276
+
135
277
  /** Recognize capability-owned presentation for bounded offline context accounting. */
136
278
  export function readCapabilityContextMessage(message, sessionId) {
137
279
  const source = message?.source
@@ -702,11 +844,22 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
702
844
  const capabilities = requiresCapabilityInventory(settings) ? await inventory(agent, sections ?? [], settings, signal) : []
703
845
  const effective = effectiveCapabilitySettings(settings)
704
846
  const coverage = (effective.policy ? effective.enabled && effective.policy.allocation.statistics : effective.statistics?.enabled === true) ? await store?.coverage(agent, capabilities, settings.statistics) : undefined
847
+ const planChannel = planChannelText(currentConfig)
848
+ const directoryBudgetBytes = planDirectoryBudget(settings.directoryBudgetBytes, planChannel.navigationSentence)
705
849
  const plan = createCapabilityPlan({ ...settings, configurationRevision: digest(settings),
706
- capabilities, coverage, navigationText: NAVIGATION })
850
+ capabilities, coverage, navigationText: planChannel.navigationSentence,
851
+ categories: planChannel.categoryLines,
852
+ directoryBudgetBytes })
707
853
  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 })
854
+ // An edited plan channel needs the v5 record, and that generation requires the
855
+ // saved inputs and session choices it was composed from.
856
+ const recordsText = !planChannel.isDefault
857
+ const snapshot = deepFreeze({ schema: recordsText ? 'ptc-capability-environment/v5'
858
+ : settings.contextEnabled !== undefined ? 'ptc-capability-environment/v4' : settings.policy ? 'ptc-capability-environment/v3'
859
+ : hasChoices ? 'ptc-capability-environment/v2' : 'ptc-capability-environment/v1',
860
+ ...(hasChoices || recordsText ? { sourceSettings, sessionOverrides } : {}),
861
+ ...(recordsText ? planPromptText(planChannel, directoryBudgetBytes) : {}),
862
+ navigationText: planChannel.navigationSentence, settings, capabilities, ...(coverage === undefined ? {} : { coverage }), plan })
710
863
  const initialized = sessionEvents(agent.session).some(event => event.type === 'system/message')
711
864
  const message = planMessage(snapshot, id, force && initialized)
712
865
  agent.signal?.throwIfAborted?.()
@@ -1154,6 +1307,42 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
1154
1307
  member === agent ? status : member.status))
1155
1308
  }
1156
1309
  },
1310
+ /**
1311
+ * The saved, delivered and usable facts of one capability. A caller that
1312
+ * only has "the save succeeded" must not report more than this returns.
1313
+ */
1314
+ describeCapabilityProof(agent, id) {
1315
+ if (typeof id !== 'string' || id.length === 0) throw new TypeError('ptc-plus: capability proof requires a capability id')
1316
+ const state = stateFor(agent)
1317
+ const sourceSettings = state?.snapshot.sourceSettings ?? state?.snapshot.settings
1318
+ ?? capabilityConfiguration(currentConfig)
1319
+ // A global choice is recorded either as a legacy override or as a rule in the
1320
+ // policy this owner writes for `/capabilities set global`, so both spellings
1321
+ // prove the same save.
1322
+ const capability = state?.snapshot.capabilities.find(item => item.id === id)
1323
+ const recorded = capability === undefined || sourceSettings.policy === undefined
1324
+ ? undefined : capabilityRule(sourceSettings.policy, capability)
1325
+ const saved = Object.hasOwn(sourceSettings.manual?.overrides ?? {}, id)
1326
+ || recorded?.availabilitySource === `item:${id}`
1327
+ || recorded?.disclosureSource === `item:${id}`
1328
+ // Delivery is a statement about the model-visible catalog. A plan that holds an
1329
+ // entry whose choice is closed describes an entry the catalog does not list, so
1330
+ // holding it proves nothing was delivered.
1331
+ const entry = state?.snapshot.plan.entries.find(item => item.id === id)
1332
+ const listed = entry !== undefined && entry.selection !== 'closed'
1333
+ const delivery = !listed ? 'absent'
1334
+ : state.message === undefined || planCommitted(agent, state.message) ? 'delivered' : 'pending'
1335
+ return normalizeCapabilityProof({
1336
+ schema: 'ptc-capability-proof/v1',
1337
+ id,
1338
+ saved,
1339
+ delivery,
1340
+ // Only a completed execution in this session proves usability; the
1341
+ // worker's installed schemas and the published catalog prove nothing
1342
+ // about what a cell actually obtained.
1343
+ usability: state?.used?.has(id) === true ? 'proven' : 'unproven',
1344
+ })
1345
+ },
1157
1346
  describe(agent) {
1158
1347
  const state = stateFor(agent)
1159
1348
  return state === undefined ? { configured: capabilityConfiguration(currentConfig), effective: null }
@@ -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
+ }