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,117 @@
1
+ # v0.5.3 目标:可证明的状态
2
+
3
+ ## 0.5.2 留下了什么
4
+
5
+ 0.5.2 解决的是**状态连续**:视图变化不再重建内核,绑定不会因为一次重渲染而丢失,编辑是追加而不是改写历史。它把"REPL 环境活着的时候算数"这件事做扎实了。
6
+
7
+ 但它留下一个未回答的问题:**插件对模型说的那些话,凭什么算数?**"这个绑定可用""这条记录没被改过""这个能力已经生效"——这些都只是**声明**,不是**证明**。0.5.2 里已经能看到三种破口:
8
+
9
+ - 恢复提示说某个名字 live,下一格却 `not defined`(`PTC-R002` 收口了一部分);
10
+ - 保存了全局绑定开关,面板只能说"已保存",说不出"已投递""已可用";
11
+ - 历史记录被事后改动,插件从记录本身看不出来,因为记录与它的副本永远自洽。
12
+
13
+ 所以 0.5.3 的主题不是再加功能,而是**把声明变成证明**:
14
+
15
+ > **0.5.3:可证明的状态。** 插件对模型陈述的每一条事实,都要有它能产出、模型能检验的证据;并且永远区分"已配置""已投递""已证明"。
16
+
17
+ ## 概念基元
18
+
19
+ 四个词贯穿本版全部目标,先定义再谈功能。
20
+
21
+ | 概念 | 回答的问题 | 谁拥有 |
22
+ | --- | --- | --- |
23
+ | **供给 supply** | 产品声明存在哪些能力、绑定、能力开关 | 全局配置与目录 |
24
+ | **取用 adoption** | 这个会话的环境选择用其中哪些 | 会话 |
25
+ | **物化 materialization** | worker 真的把它装进环境了吗 | 运行时与 worker |
26
+ | **证明 proof** | 有什么可被模型检验的证据 | 日志、探测、诊断 |
27
+
28
+ 三条不变式:
29
+
30
+ - **I1 层次不可互相代答。** 供给不能代取用,取用不能代物化,物化不能代证明。
31
+ - **I2 只有证明可以宣称"可用"。** 没有证明时如实说"不可确认",而不是默认可用。
32
+ - **I3 可证明的事实必须能从会话日志重建。** 重建不出来的,要显式标注为不可重建,而不是假装它不存在。
33
+
34
+ 0.5.2 的教训正好对应 I1:全局开关是供给,会话要不要用是取用,装了没有是物化——三层混成一个"开关",就会出现"我改了全局,为什么这个会话不变"和"配置关了但绑定还在"这类说不清的状态。
35
+
36
+ ## 目标
37
+
38
+ ### G1 绑定:从"开关"到"环境成员"
39
+
40
+ **概念纠正。** 绑定不是配置项,是**环境成员**。全局决定目录供给,会话决定取用,worker 决定可用。
41
+
42
+ - 全局关闭一个条目 = 它不在目录里,会话无从取用。**"覆盖复活已删除条目"这个概念上不存在**,不需要额外的冲突规则去禁止。
43
+ - 会话取用是二态(用 / 不用)加一个缺省(未表态即全取),不需要三态哨兵,也不借用能力侧的 `automatic`——那个词的含义是"由宿主推导",绑定没有可推导的决策。
44
+ - 取用 ≠ 物化成功:装失败的名字必须点名(接 `PTC-R002`),曾经 live 后消失的必须点名(`PTC-R006`)。
45
+
46
+ **验收**:不设会话取用时行为与 0.5.2 完全一致;A 会话不用某绑定、B 会话仍可用;全局关闭后会话不可取用且无"复活"路径;重启后取用关系随日志重建;物化失败在模型可见处点出名字。
47
+
48
+ ### G2 能力与上下文:把"已保存"升级为"已投递"
49
+
50
+ **概念。** 保存设置只改变供给,不证明模型看得到、也不证明 worker 初始化过。面板与提示必须分开说这三件事。
51
+
52
+ **验收**:保存后能报告目录是否出现该能力、新单元格能否真的取到它;三种状态(已保存 / 已投递 / 已可用)在面板与模型可见提示中区分;任一层失败都指名,而不是笼统"已生效"。
53
+
54
+ ### G3 历史证据:记录必须能自证未被改动
55
+
56
+ **概念。** 一份可以被事后编辑、且编辑后仍与自身副本一致的记录,不是证据。执行时要写入**不可重算的快照**,折叠时用它比对。
57
+
58
+ 现有的半成品:`completion` 与诊断已经互相校验(0.5.2),但 `diagnostics` 被改后再重算 `completion` 就自洽了——因为事件里没有第三份独立副本(`message.content` 为空)。
59
+
60
+ **契约**:快照字段随 journal schema 一起版本化;不一致即判该格不可信;**否决必须是重放终态**,不能进入重试循环(上一次实现因此挂死)。
61
+
62
+ **验收**:篡改 completion 或 diagnostic 都使该格不可信;恢复矩阵不退化;journal 迁移对旧记录保持兼容。
63
+
64
+ ### G4 改写历史:`editRewritesHistory`
65
+
66
+ **概念纠正。** "改写历史"不是删除,而是**取代关系**。旧单元格仍然留在日志里,只是不再参与环境构建;它声明的名字以"被取代"报告,而不是以"丢失"报告。
67
+
68
+ 这比"追加"更诚实:不假装旧执行没发生过,而是显式声明谁取代了谁(日志里记为 `supersedes`:这条记录取代了哪条)。默认关闭,开启时改写才真正替换目标单元格。
69
+
70
+ **验收**:关闭时全部既有用例不变;开启时"改写 → 后续格子读到新值"成立;被取代单元格的绑定以专门诊断点名;折叠能从日志重建取代关系。
71
+
72
+ ### G5 快照归属:REPL 面板的 generation
73
+
74
+ **概念。** 面板显示的不是"当前状态",而是**某个运行期的一次观察**。需要分开两件事:
75
+
76
+ - **新鲜度**:同一运行期内的投影刷新——清单仍然有效,不该闪成"不可确认";
77
+ - **归属**:跨运行期的快照——属于上一个进程,必须判为不可确认(既有契约,`repl-memory-projection` 的用例钉着它)。
78
+
79
+ 0.5.2 的客户端兜底只解决了新鲜度,没有表达归属。0.5.3 要在数据里表达"这份清单来自哪个运行期",而不是靠随机 UUID 与对象身份比较去猜。
80
+
81
+ **验收**:两个会话并行、其中一个结束时,另一个面板不闪;真正重建后显示不可确认并说明原因;既有契约用例保持绿。
82
+
83
+ ### G6 覆盖率:证据,不是仪式
84
+
85
+ **概念。** 覆盖率是"这段代码被执行过"的证据;阈值是策略,不是证据。0.5.2 为发版引入了"按改动文件 100% + 历史欠账白名单",那是策略例外。
86
+
87
+ **验收**:全局 `lines 100 / functions 100`,白名单清空;`review:finalize` 记录对应证明;发布流程不再需要"按改动文件"的特殊分支。
88
+
89
+ ## 非目标
90
+
91
+ - 不新增 PTC 语言特性,不改 DSH 的公共契约;
92
+ - 不做跨会话共享 worker 或跨会话共享 REPL 状态;
93
+ - 不为通过门槛而放宽断言或增加 coverage ignore(除非证明不可达并写入提交信息)。
94
+
95
+ ## 发布判据
96
+
97
+ 1. 六个目标各自的验收项全部有可执行证据;
98
+ 2. `npm run check`、`npm run coverage`(全局 100%)、`semantic:check`(unknown 0)全绿;
99
+ 3. `docs/` 中受影响的 owner 文档与 ADR 同步更新;
100
+ 4. Release 工作流 `stage` 成功,npm 进入 staged 状态;
101
+ 5. 发布说明按 [release-notes.md](release-notes.md) 的用户视角重写,不以功能清单代替变化说明。
102
+
103
+ ## 发布时机
104
+
105
+ 0.5.2 已经把"提前适配"这条路的代价演示过一遍:为一个尚未发布到 GitHub 的 alpha 改代码,等对应 rc 出来又要再改一轮,而 alpha 的接口形状在正式发布前并不稳定。
106
+
107
+ 因此 0.5.3 的发布以**通道**为准,不以"某天适配好了"为准:
108
+
109
+ | 通道 | 含义 | 对发布的作用 |
110
+ | --- | --- | --- |
111
+ | `latest` | 最新的已发布 rc | 必须通过;它决定能不能发 |
112
+ | `next` | 正在推进的下一条 rc 线,GitHub 已有发布说明 | 必须通过 |
113
+ | `alpha` | 提前适配,正式发布尚未出现 | 只报告,不阻塞 |
114
+
115
+ 规则:**插件可以提前适配 alpha(那是有价值的预警),但版本发布等待包含该形状的正式 rc。** 已经 staged 的包如果对应的是 alpha 形状的实现,就撤掉重新准备,而不是"先发出去再说"。
116
+
117
+ 通道的具体版本由 `npm run host:channels:update` 从 dist-tag 解析并写入 [compat/channels.json](../compat/channels.json),不手工编辑版本号;行为代码只做形状探测,由 `npm run host:shape-check` 守住。
@@ -13,6 +13,10 @@ Backend and browser entries accept `--list`, and selections can repeat. Omitting
13
13
 
14
14
  `npm run check` classifies the complete source candidate against HEAD, runs the appropriate checks below, checks source/ledger stability, and records the verified tree and verification scope for the commit hook. Run `check` alone when the ledger is absent or resolved; while findings remain unresolved, use `verify`. Do not run complete component suites around the same final command: that duplicates its work. Focused evidence is not a source-bound commit proof. Inspect a failed gate's stage/case, reproduce it with a focused entry, repair it, and rerun the final command only after the candidate is stable.
15
15
 
16
+ The full branch of `check` runs `npm run verify:check`: `review:validate`, `host:channels:verify`, `host:shape-check`, `semantic:check`, `build:check`, the syntax checks and `scripts/deterministic-test-groups.mjs --check`, which selects `npm run test:client` and `npm run test:backend:fast`. The Client units run in the gate because a change can be covered only by them; the backend tier is the set of test files `semantic-obligations.json` names as evidence for at least one obligation, excluding coverage-only paths and the verification-tooling probes that fail by construction outside their harness. It targets a few minutes so the commit gate is actually run; [ADR 0046](adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md) owns this split and its evidence.
17
+
18
+ `npm run verify` keeps the complete behavioral gate: `test:client:visual` plus the whole ordinary backend inventory. CI runs `verify:ci`; release preparation runs `verify`, and coverage stays release-only. A change that touches a semantic owner missing from the graph must extend the graph, which widens the fast tier at the same time. The browser suites are not part of the commit gate: they are the complete visual contract and run in `verify`, in CI, and on demand.
19
+
16
20
  Stage runners print inventory, concurrency and elapsed times. Start a permitted long gate with a short tool yield and retain its process/session ID. Tell the user the command, start time and expected range before waiting. Keep verbose diagnostics in ignored logs and wait on the same process in intervals no longer than 60 seconds. Report completed stages, failures or changed timing expectations. Inspect process activity and stage output before treating a slow run as stalled; elapsed time alone is insufficient. Stop only the owned process on cancellation, and never report an interrupted run as verified. Do not start another copy while waiting.
17
21
 
18
22
  ## Change classification
@@ -41,11 +45,11 @@ The full local and CI behavioral gates run `test:client:visual`: one Client suit
41
45
 
42
46
  `npm run verify` and the full branch of `npm run check` validate the ledger, compare generated bundles, check syntax, run Client tests, and run all ordinary backend tests through `npm run test:backend` without coverage instrumentation. The ordinary runner selects the sorted root `test/*.test.js` inventory. Coverage-only runner/report tests and collection probes live under `test/coverage/` and are excluded from daily execution. The release coverage runner includes both inventories and sets `DSH_PTC_TEST_COVERAGE_SUITE=1` for mixed instrumented cases; the ordinary runner clears that marker. Both runners share the concurrency calculation. Backend test files run in separate Node processes. Compiler boundary protocols are divided into disjoint storage, Promise and reflection files so the bounded file pool can execute them alongside the parameter/namespace/error cases without sharing their native or PTC realms. The default process count is half the available CPUs, rounded down and bounded between one and eight; each process can also own runtime Workers. Tests within each file retain their existing ordering. The ordinary runner enables module mocks without preloading coverage hooks and removes inherited coverage and temporary bytecode settings from its test-process environment.
43
47
 
44
- After the shared ledger, baseline, semantic, build and syntax prerequisites pass, `scripts/deterministic-test-groups.mjs` runs the complete Client visual and backend groups concurrently on machines with at least 12 available CPUs. Smaller machines retain serial phases; CI keeps its focused runtime group. Both concurrent groups must settle, and any nonzero exit, signal or startup failure rejects verification. Each browser pool retains its own CPU bound and process isolation.
48
+ After the shared ledger, channel, semantic, build and syntax prerequisites pass, `scripts/deterministic-test-groups.mjs` selects its groups by gate: `--check` (the commit gate) runs only the semantic-evidence backend tier, the default `verify` runs the complete Client visual and backend groups concurrently on machines with at least 12 available CPUs, and `--ci` keeps the focused runtime group. Smaller machines retain serial phases. Every selected group must settle, and any nonzero exit, signal or startup failure rejects verification. Each browser pool retains its own CPU bound and process isolation.
45
49
 
46
- The full branch of the local pre-commit `npm run check` runs the complete ordinary backend suite. Both verification scopes produce a review-bound proof. Coverage runs only before a new version is released, as specified by [ADR 0035](adr/0035-run-coverage-only-before-a-new-release.md). CI starts from a clean lockfile install and runs `verify:ci` on every supported Node and operating-system entry: frozen-baseline validation, semantic ownership, generated builds, Client tests, and focused runtime contracts covering compiler startup, helper-process lifecycle, plugin takeover, native-call transport, and durable argument diagnostics. Ubuntu Node 24 additionally exercises the packed plugin against both frozen official `latest` and `next` DSH graphs. Normal CI therefore proves portable installation and runtime integration without running coverage-specific suites, collecting coverage, or running the exhaustive semantic suite on hosted runners.
50
+ Both verification scopes produce a review-bound proof. Coverage runs only before a new version is released, as specified by [ADR 0035](adr/0035-run-coverage-only-before-a-new-release.md). CI starts from a clean lockfile install and runs `verify:ci` on every supported Node and operating-system entry: channel-manifest validation, semantic ownership, generated builds, Client tests, and focused runtime contracts covering compiler startup, helper-process lifecycle, plugin takeover, native-call transport, and durable argument diagnostics. A separate job per host channel packs the plugin and runs the host contracts against that channel's installed release. Normal CI therefore proves portable installation and runtime integration without running coverage-specific suites, collecting coverage, or running the exhaustive semantic suite on hosted runners.
47
51
 
48
- `host:baseline:verify`, which is part of deterministic verification, is offline. It requires the installed root DSH `next` cohort to match `package-lock.json` and validates the tracked `compat/latest` declaration and lock without installing it. CI separately runs `test:host-contract`: the root graph exercises `next`, while a temporary consumer created from `compat/latest/package-lock.json` exercises `latest` against the packed plugin. The tracked `compat/current` graph additionally captures the newest published official release, even when its distribution is on another npm channel; its isolated packed consumer is part of `test:host-contract`. Registry and official release lookup and lock regeneration belong only to `host:baseline:update` and the scheduled baseline-refresh workflow. That workflow publishes a changed frozen candidate as a pull request and explicitly dispatches this CI workflow once because its `GITHUB_TOKEN`-authored events cannot start another run; ordinary gates never install a floating channel.
52
+ `host:channels:verify`, which is part of deterministic verification, is offline: it validates the tracked channel manifest — schema, per-channel dist-tag and blocking role, full coverage of the probed host packages, one release per pinned cohort, and a recorded integrity for each — without resolving a tag or installing a host. CI runs `test:host-contract` as one job per channel. Each job installs its channel on demand from the manifest into an ignored cache, proves the installed versions against the manifest, packs the plugin, and runs the host contracts through a repository-external consumer; the `alpha` job reports without failing the workflow. Registry resolution belongs only to `host:channels:update` and the scheduled channel-refresh workflow. That workflow publishes a changed manifest as a pull request and explicitly dispatches this CI workflow once because its `GITHUB_TOKEN`-authored events cannot start another run; ordinary gates never install a floating channel.
49
53
 
50
54
  ## Release coverage
51
55
 
package/index.js CHANGED
@@ -13,13 +13,15 @@ import { CapabilityStatisticsStore } from './internal/capability-statistics-stor
13
13
  import { createCapabilityEnvironment } from './internal/capability-environment.js'
14
14
  import { capabilityCommandReceipt } from './internal/capability-command-receipt.js'
15
15
  import { toolSdkSchemas } from './internal/program-bindings.js'
16
+ import { hostTransportDefinition, hostTransportUnreadable } from './internal/host-tool-sdk.js'
16
17
  import { createRuntimeMessageOwner, handBackRuntimeSessions } from './internal/runtime-contexts.js'
17
- import { createSessionLogOwner } from './internal/session-log-view.js'
18
+ import { createSessionLogOwner, projectionRegistrationDisposer } from './internal/session-log-view.js'
18
19
  import { PTC_DELIVERY_CONTEXT } from './internal/runtime-messages.js'
19
20
  import { createCordisToolsOwner } from './internal/cordis-tools-owner.js'
20
21
  import { createEditTransportOwner, EDIT_RUN_CODE } from './internal/edit-transport-owner.js'
21
22
  import { createRuntimeBridgeOwner, RUN_CODE } from './internal/runtime-bridge-owner.js'
22
23
  import { resolveConfig, watchVolatileConfig } from './internal/runtime-config.js'
24
+ import { configureRetention } from './internal/projection-retention.js'
23
25
  import {
24
26
  CONFIG_FIELDS,
25
27
  SETTINGS_NAMESPACE,
@@ -31,10 +33,12 @@ import { valueLimitsFromConfig } from './internal/value-wire-schema.js'
31
33
  import { createReplMemoryProjection } from './internal/repl-memory-projection.js'
32
34
  import { createUserBindingDraftProjection } from './internal/user-binding-draft-projection.js'
33
35
  import { createUserBindingsOwner } from './internal/user-bindings-owner.js'
36
+ import { latestBindingAdoption } from './internal/binding-adoption.js'
34
37
  import { createHostRpc } from './internal/host-rpc.js'
35
38
  import { RPC_CONTRACTS } from './internal/rpc-contract.js'
36
39
  import { createReplObservationInterest } from './internal/repl-observation-interest.js'
37
40
  import { guidancePolicies } from './internal/binding-update-policy.js'
41
+ import { PROMPT_SLOT_IDS, resolvePromptTextConfig } from './internal/prompt-text.js'
38
42
  import * as dshSettings from '@deepseek-ai/dsh-settings'
39
43
 
40
44
  const INSTALL_CLEANUP = Symbol('ptc-plus install cleanup')
@@ -59,7 +63,7 @@ export const Config = Schema.object(Object.fromEntries(
59
63
  */
60
64
  export const inject = ['tools', 'systemPrompt', 'agents']
61
65
 
62
- function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durableReplay, cordisToolsEnabled }) {
66
+ function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durableReplay, cordisToolsEnabled }, promptText) {
63
67
  const looseTopLevelRedeclarations = bindingPolicy.variableRedeclarations
64
68
  const looseTopLevelFunctionClassRedeclarations = bindingPolicy.functionClassRedeclarations
65
69
  const autoRewriteImports = rewritesEnabled.autoRewriteImports
@@ -92,22 +96,35 @@ function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durab
92
96
  const cordisRecovery = cordisToolsEnabled
93
97
  ? '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
98
  : ''
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.
99
+ // Each paragraph is an editable asset; a derived policy sentence is appended to
100
+ // the paragraph that owns it, so rewriting prose can never contradict the
101
+ // configuration that produced it. Omitting a paragraph keeps its derived clause.
102
+ const prose = id => (promptText.omitted(id) ? '' : promptText.text(id))
103
+ const withDerived = (id, derived) => {
104
+ const base = prose(id)
105
+ return base === '' ? derived : `${base} ${derived}`
106
+ }
107
+ const paragraphs = [
108
+ prose(PROMPT_SLOT_IDS.guidanceOpening),
109
+ prose(PROMPT_SLOT_IDS.guidanceErrorRecovery),
110
+ withDerived(PROMPT_SLOT_IDS.guidanceCellConventions, cellConventions),
111
+ prose(PROMPT_SLOT_IDS.guidanceCapabilities),
112
+ withDerived(PROMPT_SLOT_IDS.guidanceExecutionWorld, `${recovery}${cordisToolsEnabled ? ` ${cordisRecovery}` : ''}`),
113
+ ]
114
+ return paragraphs.filter(paragraph => paragraph.trim().length > 0).join('\n\n')
115
+ }
105
116
 
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}` : ''}`
117
+ /**
118
+ * The reserved `run_code` transport definition, from whichever surface this
119
+ * host publishes it on. Session execution is available exactly when one of
120
+ * those surfaces answers.
121
+ */
122
+ function runCodeDefinitionFor(tools, scope) {
123
+ return hostTransportDefinition(tools, scope, RUN_CODE)
107
124
  }
108
125
 
109
126
  /** Register the session-bound REPL runtime. */
110
- function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, settingsForCommand) {
127
+ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, settingsForCommand, readPromptText) {
111
128
  const presentationGeneration = randomUUID()
112
129
  const replMemoryProjection = createReplMemoryProjection(presentationGeneration)
113
130
  const userBindingDraftProjection = createUserBindingDraftProjection(presentationGeneration)
@@ -241,11 +258,17 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
241
258
  const cancelCordisActivation = () => pendingCordisActivation?.cancel()
242
259
  const registerProjection = (projection, label) => {
243
260
  try {
244
- const unregister = projectionService?.register?.(projection)
245
- if (typeof unregister !== 'function') {
246
- throw new Error('ptc-plus: sessionProjections.register did not return a disposer')
261
+ // A newer host returns a ProjectionHandle (`stateOf`/`readerFor`/`dispose`,
262
+ // possibly async); earlier hosts returned a disposer. Accept either shape so
263
+ // the projection is not silently dropped on the newer contract.
264
+ const dispose = projectionRegistrationDisposer(projectionService?.register?.(projection))
265
+ if (dispose === undefined) {
266
+ throw new Error('ptc-plus: sessionProjections.register returned neither a disposer nor a handle')
247
267
  }
248
- return unregister
268
+ // Unregistration failures belong to the caller that is suspending or
269
+ // reloading the projection: it retries and reports them. Swallowing one
270
+ // here would turn a failed disposal into a silent duplicate projection.
271
+ return () => dispose()
249
272
  } catch (error) {
250
273
  ctx.logger?.warn?.(`ptc-plus: ${label} projection unavailable`, error)
251
274
  return undefined
@@ -342,9 +365,13 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
342
365
  readSessionLog: sessionLog.read,
343
366
  enabled: activeConfig.userBindingsEnabled,
344
367
  draftProjectionAvailable,
368
+ // A session's adoption is its own recorded decision, so it is read from
369
+ // that session's log instead of a process-local map.
370
+ bindingAdoptionForAgent: agent => latestBindingAdoption(sessionLog.project(agent)?.ptcMessages ?? []),
345
371
  maxWallMs: activeConfig.maxWallMs,
346
372
  maxOutputBytes: activeConfig.maxOutputBytes,
347
373
  maxOldGenerationSizeMb: activeConfig.maxOldGenerationSizeMb,
374
+ workerReclaimMs: activeConfig.workerReclaimMs,
348
375
  valueLimits: valueLimitsFromConfig(activeConfig),
349
376
  })
350
377
  capabilityEnvironment = createCapabilityEnvironment({ ctx, config: activeConfig,
@@ -353,6 +380,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
353
380
  runtimeBridge = createRuntimeBridgeOwner({
354
381
  ctx,
355
382
  seam,
383
+ readPromptText,
356
384
  observeSession: id => observationInterest.has(id),
357
385
  readSessionLog: sessionLog.read,
358
386
  sessionConfig: activeConfig,
@@ -375,6 +403,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
375
403
  presentationGeneration,
376
404
  sessionId,
377
405
  toolSchemasForAgent,
406
+ promptText: resolvePromptTextConfig(activeConfig),
378
407
  })
379
408
  directSurface = createDirectSurfaceOwner({
380
409
  sessionLogView: sessionLog.project,
@@ -384,7 +413,8 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
384
413
  sessionId,
385
414
  toolSchemasForAgent,
386
415
  capabilityEnvironment,
387
- userBindingsForAgent: () => userBindings.snapshot(),
416
+ declaresRunCode: agent => runCodeDefinitionFor(ctx.tools, agent) !== undefined,
417
+ userBindingsForAgent: agent => userBindings.snapshotForAgent(agent),
388
418
  setAgentPresentation: (agent, presentation) => (
389
419
  userBindings.setAgentPresentation(agent, presentation)
390
420
  ),
@@ -393,25 +423,33 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
393
423
  name: 'tools:ptc-plus-repl',
394
424
  order: 98,
395
425
  text: context => {
396
- if (ctx.tools.get(RUN_CODE, context?.scope) === undefined) return ''
397
- return replGuidance(guidancePolicies(activeConfig))
426
+ if (runCodeDefinitionFor(ctx.tools, context?.scope) === undefined) return ''
427
+ return replGuidance(guidancePolicies(activeConfig), resolvePromptTextConfig(activeConfig))
398
428
  },
399
429
  }))
430
+ let unreadableTransportReported = false
400
431
  disposers.push(ctx.on('system-prompt/assemble', async (assembly, context, next) => {
401
432
  const projected = await directSurface.assemble(assembly, context, next)
402
- if (ctx.tools.get(RUN_CODE, context?.agent) !== undefined) runtimeBridge.prepareAgent(context?.agent)
433
+ if (runCodeDefinitionFor(ctx.tools, context?.agent) !== undefined) runtimeBridge.prepareAgent(context?.agent)
434
+ else if (!unreadableTransportReported && hostTransportUnreadable(ctx.tools, context?.agent)) {
435
+ // The host declares the reserved transport and this plugin cannot read it.
436
+ // Every gate that needs execution answers "unavailable", so the operator is
437
+ // told which of the two facts holds instead of finding the features gone.
438
+ unreadableTransportReported = true
439
+ ctx.logger?.warn?.('ptc-plus: the host tool executor declares run_code but publishes no transport definition this plugin can read; session execution stays unavailable')
440
+ }
403
441
  return projected
404
442
  }))
405
443
  disposers.push(() => capabilityEnvironment.dispose())
406
444
  disposers.push(ctx.on('agent/pre-step', (payload, next) => capabilityEnvironment.preStep(payload, next), { prepend: true }))
407
445
  disposers.push(ctx.on('agent/created', ({ agent }) => {
408
- if (ctx.tools.get(RUN_CODE, agent) !== undefined) {
446
+ if (runCodeDefinitionFor(ctx.tools, agent) !== undefined) {
409
447
  runtimeBridge.prepareAgent(agent)
410
448
  editTransport.ensureInstalled(agent)
411
449
  }
412
450
  }))
413
451
  for (const agent of ctx.agents?.list?.() ?? []) {
414
- if (ctx.tools.get(RUN_CODE, agent) !== undefined) {
452
+ if (runCodeDefinitionFor(ctx.tools, agent) !== undefined) {
415
453
  runtimeBridge.prepareAgent(agent)
416
454
  editTransport.ensureInstalled(agent)
417
455
  }
@@ -440,17 +478,22 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
440
478
  input: { hint: 'refresh | list [all|skills|tools|mcp|mcp_tools] [query] | set session|global <id> [availability|disclosure] <choice>' },
441
479
  handler: async ({ commandId, agent, rawInput, signal }) => {
442
480
  const input = rawInput.trim()
443
- const receipt = (state, error) => capabilityCommandReceipt(state, String(agent.session.id), error,
481
+ const receipt = (state, error, proof) => capabilityCommandReceipt(state, String(agent.session.id), error,
444
482
  state === 'pending' && capabilityEnvironment.pendingChoiceMessage(agent)
445
- ? { commandId, rawInput, message: capabilityEnvironment.pendingChoiceMessage(agent) } : undefined)
483
+ ? { commandId, rawInput, message: capabilityEnvironment.pendingChoiceMessage(agent) } : undefined,
484
+ proof)
446
485
  const selection = /^set (session|global) (\S+) (?:(availability|disclosure) )?(full|on-demand|closed|automatic|inherit|enabled|host|adaptive)$/.exec(input)
447
486
  if (selection) {
448
487
  if (!capabilityEnvironment.canRefresh(agent)) return { kind: 'error', text: receipt('unavailable') }
449
488
  const changed = await capabilityEnvironment.change(agent, {
450
489
  scope: selection[1], id: decodeURIComponent(selection[2]), dimension: selection[3], selection: selection[4], commandId,
451
490
  }, signal, settingsForCommand?.())
491
+ // A save states what it changed and what it did not prove: the
492
+ // model-visible catalog and a cell that reached the capability are
493
+ // separate facts, reported by the layer that owns each one.
494
+ const proof = capabilityEnvironment.describeCapabilityProof(agent, decodeURIComponent(selection[2]))
452
495
  return { kind: changed.sessionApplied ? 'success' : 'error',
453
- text: receipt(changed.sessionApplied ? changed.publication === 'pending' ? 'pending' : 'recorded' : 'saved-only', changed.error) }
496
+ text: receipt(changed.sessionApplied ? changed.publication === 'pending' ? 'pending' : 'recorded' : 'saved-only', changed.error, proof) }
454
497
  }
455
498
  const listing = /^list(?: (all|skills|tools|mcp|mcp_tools))?(?: (.*))?$/.exec(input)
456
499
  if (listing) {
@@ -624,8 +667,11 @@ export const apply = (ctx, config = {}) => {
624
667
  // Configuration is validated before the plugin waits for the host's seam, so
625
668
  // an unusable configuration fails at the load call rather than later.
626
669
  const resolvedConfig = resolveConfig(config)
670
+ // The retention window is user configuration; install it before any fold can run.
671
+ configureRetention(resolvedConfig)
627
672
  return installExecutionSeam(ctx, {
628
673
  services: inject,
674
+ attachmentTimeoutMs: resolvedConfig.executionSeamTimeoutMs,
629
675
  attach: (scope, seam) => applyWithExecutionSeam(scope, resolvedConfig, seam, ctx, config),
630
676
  reportFailure: (error, seamName) => ctx.logger?.warn?.(
631
677
  `ptc-plus: execution seam ${seamName} failed to attach`,
@@ -719,7 +765,9 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
719
765
  // Execution ownership is a per-attachment property: `owned` installs the
720
766
  // session runtime, `deferred` leaves run_code to the official provider.
721
767
  const executionShape = nextConfig => (
722
- defersToOfficialRepl(nextConfig, seam.provider) ? 'deferred' : 'owned'
768
+ defersToOfficialRepl(nextConfig, {
769
+ get retainsState() { return seam.originalRetainsState },
770
+ }) ? 'deferred' : 'owned'
723
771
  )
724
772
  const startActivation = record => {
725
773
  if (record.cancelled || disposed || activating !== record) {
@@ -733,7 +781,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
733
781
  try {
734
782
  candidate = record.shape === 'deferred'
735
783
  ? createDeferredRuntime()
736
- : installPtCRuntime(ctx, record.config, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, () => settingsWriter)
784
+ : installPtCRuntime(ctx, record.config, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, () => settingsWriter, currentPromptText)
737
785
  record.runtime = candidate
738
786
  } catch (error) {
739
787
  const failedRuntime = error?.[INSTALL_CLEANUP]
@@ -886,9 +934,17 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
886
934
  ctx.effect(() => async () => controller.dispose(), 'ptc-plus runtime lifecycle')
887
935
 
888
936
  const handbackOwners = Object.fromEntries(['sessions', 'sessionPersistence', 'sessionQuery'].map(name => [name, ctx.get(name)]))
937
+ // One spelling of "the current document": the same resolved configuration the
938
+ // reconciliation loop applies, so a delivered message and the assembled prompt can
939
+ // never read two different prompt-text documents. One holder per attachment, handed
940
+ // to the runtime it installs, so two attachments in one process keep their own
941
+ // document. Read lazily: the settings section replaces `configSource` after
942
+ // activation.
943
+ const currentPromptText = () => resolvePromptTextConfig(configSource())
889
944
  const messageOwner = createRuntimeMessageOwner(context => (
890
945
  committed?.retiring ? [] : committed?.runtime.contextsForRequest(context) ?? []
891
- ), sessionLog.project, () => committed !== undefined && committed.retiring !== true, handbackOwners.sessionQuery)
946
+ ), sessionLog.project, () => committed !== undefined && committed.retiring !== true, handbackOwners.sessionQuery,
947
+ currentPromptText)
892
948
  ctx.effect(() => ctx.systemPrompt.context({
893
949
  name: PTC_DELIVERY_CONTEXT, order: 98, text: '',
894
950
  }), 'ptc-plus dynamic message delivery witness')
@@ -901,10 +957,21 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
901
957
  ctx.effect(() => ctx.on('agent/disposed', ({ agent }) => messageOwner.disposeAgent(agent)), 'ptc-plus message agent disposal')
902
958
  ctx.effect(() => () => messageOwner.dispose(), 'ptc-plus dynamic message lifecycle')
903
959
  ctx.effect(() => async () => {
904
- await controller.dispose()
960
+ // The model-visible handback states a fact about execution, so it must not
961
+ // ride on whether every projection unregistered: a failed disposal is
962
+ // reported by the caller that retries it, and the runtime is gone either way.
963
+ let disposalFailure
964
+ try {
965
+ await controller.dispose()
966
+ } catch (error) {
967
+ disposalFailure = error
968
+ }
905
969
  await handBackRuntimeSessions(ctx, handbackOwners)
970
+ if (disposalFailure !== undefined) throw disposalFailure
906
971
  }, 'ptc-plus provider handback')
907
972
 
973
+ // The settings section replaces this source after activation; the prompt-text
974
+ // accessor above reads it lazily for exactly that reason.
908
975
  let configSource = () => resolveConfig(volatileConfig ?? resolvedConfig)
909
976
  let configurationGeneration = 0
910
977
  let configurationRollback = false
@@ -935,6 +1002,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
935
1002
  }
936
1003
  const reconcile = (propagateFailure = false) => {
937
1004
  const current = resolveConfig(configSource())
1005
+ configureRetention(current)
938
1006
  const generation = ++configurationGeneration
939
1007
  if (!current.enabled) {
940
1008
  const operation = controller.uninstall()
@@ -982,6 +1050,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
982
1050
  }
983
1051
  ctx.effect(() => seam.onSourceChange(() => {
984
1052
  const current = resolveConfig(configSource())
1053
+ configureRetention(current)
985
1054
  if (!current.enabled) return undefined
986
1055
  if (seam.language !== 'typescript') {
987
1056
  const language = seam.language
@@ -0,0 +1,136 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
2
+ import { isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
3
+
4
+ /**
5
+ * Session binding adoption: which Global User Binding catalog entries this
6
+ * session uses.
7
+ *
8
+ * The question is answered per session and per entry, and the answer is a
9
+ * decision of exactly two states: this session uses the entry, or it does not.
10
+ * An entry the session never named is used, because the global catalog already
11
+ * answered the supply question. That default is the absence of a decision, not
12
+ * a third state: "use" and "never mentioned" lead to the same environment, so
13
+ * they are one state rather than two.
14
+ *
15
+ * Adoption can only take an entry away from this session. It cannot supply one,
16
+ * enable one, or name a source: an entry the global configuration disabled is
17
+ * absent from the catalog, so "adoption revives a deleted entry" has no
18
+ * encoding here and needs no rule forbidding it. Naming an entry the catalog
19
+ * does not offer is rejected at the surface that states the decision, because
20
+ * the record would otherwise claim authority over supply.
21
+ *
22
+ * The neighbour this must not be confused with is the capability pane's
23
+ * `automatic`, which means "the host derives this value". A binding has no host
24
+ * derivation to defer to, so borrowing that word would misdescribe both.
25
+ *
26
+ * The decision is recorded as one PTC message in the session log, so it is
27
+ * reconstructed by replaying that log after a restart instead of being kept in
28
+ * process memory.
29
+ */
30
+ export const BINDING_ADOPTION_FORM = 'binding-adoption'
31
+ export const BINDING_ADOPTION_SCHEMA = 'ptc-binding-adoption/v1'
32
+
33
+ const MAX_DECLINED = 64
34
+ const ENTRY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
35
+ const PREFIX = 'PTC Plus session binding adoption. This session uses every entry the global catalog supplies except the declined ones. Adoption can only withdraw an entry from this session: it cannot supply, enable or revive one, and the global configuration decides which entries exist at all. Declined entry IDs:'
36
+
37
+ function isRecord(value) {
38
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
39
+ }
40
+
41
+ function sortIds(ids) {
42
+ return [...ids].sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
43
+ }
44
+
45
+ export function normalizeBindingAdoption(value) {
46
+ if (!isRecord(value) || Reflect.ownKeys(value).length !== 2
47
+ || !Object.hasOwn(value, 'schema') || !Object.hasOwn(value, 'declined')) {
48
+ throw new TypeError('invalid binding adoption')
49
+ }
50
+ if (value.schema !== BINDING_ADOPTION_SCHEMA) throw new TypeError('invalid binding adoption schema')
51
+ if (!Array.isArray(value.declined) || value.declined.length > MAX_DECLINED) {
52
+ throw new TypeError('invalid binding adoption declined entries')
53
+ }
54
+ const declined = []
55
+ for (const id of value.declined) {
56
+ if (typeof id !== 'string' || !ENTRY_ID.test(id)) throw new TypeError('invalid binding adoption entry id')
57
+ if (declined.includes(id)) throw new TypeError('duplicate binding adoption entry id')
58
+ declined.push(id)
59
+ }
60
+ return Object.freeze({ schema: BINDING_ADOPTION_SCHEMA, declined: Object.freeze(sortIds(declined)) })
61
+ }
62
+
63
+ /** The exact model-visible wording one adoption record carries. */
64
+ export function bindingAdoptionText(adoption) {
65
+ return `${PREFIX}\n${JSON.stringify(normalizeBindingAdoption(adoption).declined)}`
66
+ }
67
+
68
+ export function bindingAdoptionMessage(adoption) {
69
+ const text = bindingAdoptionText(adoption)
70
+ const message = createUserMessage({
71
+ source: { kind: PTC_MESSAGE_SOURCE_KIND, form: BINDING_ADOPTION_FORM },
72
+ content: [{ type: 'text', text }],
73
+ })
74
+ if (readBindingAdoption(message) === undefined) throw new TypeError('invalid binding adoption message')
75
+ return message
76
+ }
77
+
78
+ /** Read one adoption record back from a message, message projection or message fact. */
79
+ export function readBindingAdoption(value) {
80
+ const source = value?.source
81
+ if (!isPtcMessageSource(source) || source.form !== BINDING_ADOPTION_FORM) return undefined
82
+ if (!Array.isArray(value.content) || value.content.length !== 1 || value.content[0]?.type !== 'text') return undefined
83
+ const text = value.content[0].text
84
+ if (typeof text !== 'string' || !text.startsWith(`${PREFIX}\n`)) return undefined
85
+ try {
86
+ const declined = JSON.parse(text.slice(PREFIX.length + 1))
87
+ const adoption = normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined })
88
+ return bindingAdoptionText(adoption) === text ? adoption : undefined
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The newest adoption this session recorded, or `undefined` while it recorded
96
+ * none. The newest record replaces earlier ones, because stating a decision
97
+ * again is how a session changes its mind.
98
+ */
99
+ export function latestBindingAdoption(records) {
100
+ if (!Array.isArray(records)) return undefined
101
+ for (let index = records.length - 1; index >= 0; index -= 1) {
102
+ const record = records[index]
103
+ if (record?.form !== BINDING_ADOPTION_FORM) continue
104
+ try {
105
+ return normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: record.declined })
106
+ } catch {
107
+ // A record that cannot be read is not evidence for an earlier record
108
+ // either: the newest statement is the current one, and it is unreadable.
109
+ return undefined
110
+ }
111
+ }
112
+ return undefined
113
+ }
114
+
115
+ /**
116
+ * The declined entry IDs of one adoption, as a set the environment uses.
117
+ * An adoption that was never stated declines nothing.
118
+ */
119
+ export function declinedBindingEntries(adoption) {
120
+ return new Set(adoption === undefined ? [] : normalizeBindingAdoption(adoption).declined)
121
+ }
122
+
123
+ /** State one entry as used (`adopt` true) or not used, leaving the rest alone. */
124
+ export function withBindingAdoption(adoption, entryId, adopt) {
125
+ if (typeof entryId !== 'string' || !ENTRY_ID.test(entryId)) throw new TypeError('invalid binding adoption entry id')
126
+ if (typeof adopt !== 'boolean') throw new TypeError('binding adoption decision must be a boolean')
127
+ const declined = declinedBindingEntries(adoption)
128
+ if (adopt) declined.delete(entryId)
129
+ else declined.add(entryId)
130
+ return normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: [...declined] })
131
+ }
132
+
133
+ /** Drop every declined entry: this session again uses the whole catalog. */
134
+ export function clearedBindingAdoption() {
135
+ return normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: [] })
136
+ }