dsh-ptc-plus 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.en.md +15 -3
  2. package/README.md +15 -3
  3. package/client.cjs +1983 -234
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +2 -2
  8. package/docs/adr/0023-global-user-bindings.md +1 -1
  9. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  10. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  11. package/docs/adr/0038-freeze-session-capability-plans.md +1 -1
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +35 -0
  13. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  14. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  15. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  16. package/docs/adr/README.md +2 -0
  17. package/docs/architecture.md +2 -2
  18. package/docs/capability-planning-components.md +2 -2
  19. package/docs/client-ui.md +13 -10
  20. package/docs/client-visual-contract.md +1 -1
  21. package/docs/durability-design.md +7 -1
  22. package/docs/installation.md +5 -3
  23. package/docs/publishing.md +2 -2
  24. package/docs/releases/v0.5.2.md +79 -0
  25. package/docs/runtime-reference.md +42 -3
  26. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  27. package/docs/verification.md +6 -2
  28. package/index.js +63 -19
  29. package/internal/bounded-literal.js +85 -0
  30. package/internal/capability-command-receipt.js +3 -1
  31. package/internal/capability-environment.js +171 -13
  32. package/internal/capability-plan.js +11 -8
  33. package/internal/capability-statistics.js +6 -2
  34. package/internal/cell-error.js +13 -0
  35. package/internal/config-spec.js +122 -2
  36. package/internal/direct-surface-owner.js +14 -10
  37. package/internal/edit-transport-owner.js +96 -17
  38. package/internal/execution-provider-owner.js +25 -0
  39. package/internal/execution-seam-compat.js +18 -0
  40. package/internal/isolated-worker.js +134 -17
  41. package/internal/kernel-worker.js +167 -3
  42. package/internal/legacy-cell-analysis.js +14 -2
  43. package/internal/legacy-repl-convenience.js +16 -3
  44. package/internal/official-repl-priority.js +51 -0
  45. package/internal/projection-retention.js +238 -0
  46. package/internal/prompt-text.js +858 -0
  47. package/internal/recovery-tips.js +50 -11
  48. package/internal/rejected-cell-editor.js +3 -2
  49. package/internal/runtime-bridge-owner.js +11 -8
  50. package/internal/runtime-config.js +2 -0
  51. package/internal/runtime-contexts.js +14 -5
  52. package/internal/runtime-messages.js +43 -15
  53. package/internal/sdk-projection.js +2 -2
  54. package/internal/session-cell-executor.js +132 -11
  55. package/internal/session-journal-recovery.js +1009 -99
  56. package/internal/session-journal-schema.js +7 -5
  57. package/internal/session-journal.js +177 -14
  58. package/internal/session-log-view.js +111 -24
  59. package/internal/session-runtime.js +674 -33
  60. package/internal/session-state.js +27 -0
  61. package/internal/stateful-root-compiler.js +11 -0
  62. package/internal/stateful-root-runtime.js +17 -0
  63. package/internal/user-binding-console.js +7 -0
  64. package/internal/user-bindings-owner.js +8 -27
  65. package/internal/user-bindings.js +2 -2
  66. package/internal/validated-parse-repair.js +6 -1
  67. package/internal/worker-client.js +27 -4
  68. package/package.json +5 -2
  69. package/src/client-activity.js +27 -6
  70. package/src/client-capabilities-view.js +774 -125
  71. package/src/client-copy.js +231 -18
  72. package/src/client-repl-view.js +6 -1
  73. package/src/client-styles.js +68 -2
  74. package/src/client-tool-view.js +9 -2
@@ -0,0 +1,238 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /**
4
+ * Retention window for the host-only session-log projection.
5
+ *
6
+ * The projection is a fold shortcut, not the session log. Keeping the payload of
7
+ * every call would make a checkpoint proportional to how many bytes the session
8
+ * wrote (issue #10: a 77 MB state that the host cloned and pretty-printed until
9
+ * it aborted). The window is therefore bounded by default: the newest clusters
10
+ * keep their argument text so tips and the current edit target stay synchronous,
11
+ * and every older cluster keeps its identity, its observations and its call
12
+ * record while its payload text becomes a digest. Only a cluster beyond
13
+ * `retentionClusters` is folded into the chained summary, and a cluster whose
14
+ * payload alone exceeds the byte budget keeps its identity with a digested
15
+ * payload — the bound wins over the convenience of the hot cell, and the edit
16
+ * path refolds that cell from the session log.
17
+ *
18
+ * `retentionPayloadBytes` bounds the retained payload (the settlement and
19
+ * executable copies). A checkpoint additionally carries the selected run's
20
+ * copies, so its serialized total can approach roughly twice the budget plus the
21
+ * per-cluster metadata; that is the shipped bound, stated in ADR 0032.
22
+ *
23
+ * The environment overrides may only *lower* the window for tests; a value that
24
+ * would disable the bound is ignored so a deployment cannot silently return to
25
+ * an unbounded checkpoint.
26
+ */
27
+ export const RETENTION_BOUNDS = Object.freeze({
28
+ runs: Object.freeze({ min: 8, max: 512 }),
29
+ bytes: Object.freeze({ min: 1024 * 1024, max: 16 * 1024 * 1024 }),
30
+ // 0 is the only unlimited spelling; a cluster cap of 0 means the count window
31
+ // never folds anything and only the payload budget bounds the checkpoint.
32
+ clusters: Object.freeze({ min: 0, max: 50000 }),
33
+ })
34
+
35
+ export const DEFAULT_RETENTION_LIMITS = Object.freeze({
36
+ runs: 8,
37
+ bytes: 8 * 1024 * 1024,
38
+ // The count window defaults to the payload window: cells beyond it fold into the
39
+ // chained summary instead of keeping a per-cluster record, which is what keeps a
40
+ // long session from growing linearly. A larger value keeps more recovery evidence
41
+ // without a log refold at the cost of a bigger checkpoint.
42
+ clusters: 8,
43
+ })
44
+
45
+ /**
46
+ * The environment levers exist for tests and for a deployment that wants a
47
+ * *smaller* window. They fill a field only when configuration leaves it absent
48
+ * and are clamped to the configured or default value, so no environment can
49
+ * widen the bound beyond what the settings say.
50
+ */
51
+ const ENVIRONMENT_KEYS = Object.freeze({
52
+ runs: 'DSH_PTC_RETENTION_RUNS',
53
+ bytes: 'DSH_PTC_RETENTION_BYTES',
54
+ clusters: 'DSH_PTC_RETENTION_CLUSTERS',
55
+ })
56
+
57
+ function normalizeLimit(kind, raw, fallback) {
58
+ if (raw === undefined || raw === null || raw === '') return fallback
59
+ const value = Number(raw)
60
+ const { min, max } = RETENTION_BOUNDS[kind]
61
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) return fallback
62
+ // 0 means unlimited for clusters; every other value is clamped to the fallback
63
+ // so an environment lever can never widen the window past the configuration.
64
+ return kind === 'clusters' && value === 0 ? 0 : Math.min(value, fallback)
65
+ }
66
+
67
+ /**
68
+ * A configured window is honoured as written: it is the user's setting, bounded by
69
+ * `RETENTION_BOUNDS` only. The clamp in `normalizeLimit` exists for the environment
70
+ * lever, which may tighten a configured window but never widen it.
71
+ */
72
+ function normalizeConfigured(kind, raw) {
73
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_RETENTION_LIMITS[kind]
74
+ const value = Number(raw)
75
+ const { min, max } = RETENTION_BOUNDS[kind]
76
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) {
77
+ return DEFAULT_RETENTION_LIMITS[kind]
78
+ }
79
+ return value
80
+ }
81
+
82
+ let activeLimits = resolveRetentionLimits({})
83
+
84
+ /** The limits the current fold prunes against. */
85
+ export function retentionLimits() {
86
+ return activeLimits
87
+ }
88
+
89
+ /**
90
+ * Resolve the effective limits from plugin configuration: configuration wins,
91
+ * the environment fills an absent field, and anything invalid falls back to the
92
+ * default rather than to an unbounded checkpoint.
93
+ */
94
+ export function resolveRetentionLimits(config = {}) {
95
+ const configured = {
96
+ runs: config.retentionPayloadRuns,
97
+ bytes: config.retentionPayloadBytes,
98
+ clusters: config.retentionClusters,
99
+ }
100
+ const resolved = {}
101
+ // The cluster window follows the payload window unless it is configured: a user
102
+ // who raises the retained cells expects the same number of cells to keep their
103
+ // cluster records, not eight of them.
104
+ if (configured.clusters === undefined || configured.clusters === null || configured.clusters === '') {
105
+ configured.clusters = normalizeConfigured('runs', configured.runs)
106
+ }
107
+ for (const kind of ['runs', 'bytes', 'clusters']) {
108
+ const base = normalizeConfigured(kind, configured[kind])
109
+ // The environment may only tighten the configured bound, never widen it.
110
+ resolved[kind] = normalizeLimit(kind, process.env[ENVIRONMENT_KEYS[kind]], base)
111
+ }
112
+ return Object.freeze(resolved)
113
+ }
114
+
115
+ /** Install the limits for the folds that follow; returns the previous ones. */
116
+ export function configureRetention(config = {}) {
117
+ const previous = activeLimits
118
+ activeLimits = resolveRetentionLimits(config)
119
+ return previous
120
+ }
121
+
122
+ /** The count of clusters kept when the configuration folds by count. */
123
+ export function retainedClusterCount(limits = activeLimits) {
124
+ if (limits.clusters === 0) return Number.POSITIVE_INFINITY
125
+ return Math.min(limits.runs, limits.clusters)
126
+ }
127
+
128
+ /** Shout when the retention window cannot hold the payload a consumer needs. */
129
+ export const PRUNED_EDIT = '该单元格超出保留窗口,已不可编辑'
130
+ export const PRUNED_EDIT_EN = 'This cell is outside the retention window and can no longer be edited'
131
+
132
+ /**
133
+ * The checkpoint's summary and the session log disagree about a dropped cluster.
134
+ * Neither side may be used: the edit must be refused rather than applied to a cell
135
+ * the log and the checkpoint describe differently.
136
+ */
137
+ export const PRUNED_SUMMARY_MISMATCH = '保留摘要与被重折的结算不一致'
138
+ export const PRUNED_SUMMARY_MISMATCH_EN = 'Retention summary disagrees with the refolded settlement'
139
+
140
+ /** The newest cell alone exceeded the byte budget and was dropped from the checkpoint. */
141
+ export const HOT_CELL_DROPPED = '最新单元格超出保留预算,已从检查点移除'
142
+ export const HOT_CELL_DROPPED_EN = 'The newest cell exceeded the retention budget and was removed from the checkpoint'
143
+
144
+ export function digestOf(value) {
145
+ return createHash('sha256').update(JSON.stringify(value ?? null, (_key, field) => (
146
+ isPlainRecord(field) ? Object.fromEntries(Object.keys(field).sort().map(key => [key, field[key]])) : field
147
+ ))).digest('hex')
148
+ }
149
+
150
+ function isPlainRecord(value) {
151
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
152
+ }
153
+
154
+ export function byteLength(value) {
155
+ if (typeof value === 'string') return Buffer.byteLength(value, 'utf8')
156
+ if (value === undefined || value === null) return 0
157
+ return Buffer.byteLength(JSON.stringify(value), 'utf8')
158
+ }
159
+
160
+ /**
161
+ * Identity of one settlement for the retention summary.
162
+ *
163
+ * A dropped settlement keeps three facts: its result position, its call position
164
+ * and the digest of the argument text the log still holds. Those three are exactly
165
+ * what a fold of the session log can reproduce, so the chain over them can be
166
+ * recomputed from the log; the normalized digest is recorded for diagnostics but
167
+ * stays out of the chain, because a settlement that was compacted earlier recorded
168
+ * only its payload digest rather than the normalized object itself.
169
+ */
170
+ export function summaryEntry(settlement, resultSeq) {
171
+ return {
172
+ resultSeq,
173
+ callSeq: settlement?.call?.seq,
174
+ argumentsDigest: settlement?.call?.data?.argumentsDigest ?? digestOf(settlement?.call?.data?.arguments ?? null),
175
+ normalizedDigest: settlement?.normalized?.payloadDigest ?? digestOf(settlement?.normalized ?? null),
176
+ }
177
+ }
178
+
179
+ /** The part of an entry the chain commits to: reproducible from the log alone. */
180
+ function chainEntry(entry) {
181
+ return { callSeq: entry.callSeq, resultSeq: entry.resultSeq, argumentsDigest: entry.argumentsDigest }
182
+ }
183
+
184
+ const EMPTY_CHAIN = digestOf(null)
185
+
186
+ /**
187
+ * Extend a chained retention summary with the settlements it just dropped.
188
+ *
189
+ * The summary keeps one compact record per dropped cluster rather than only a
190
+ * rolling digest. Admission receives no session log, so it cannot recompute the
191
+ * chain from the events; what it can do is prove the summary is internally
192
+ * consistent (count, ranges, chain over the stored records). The record itself is
193
+ * what makes the later guarantee possible: when a dropped settlement is refolded
194
+ * from the log, its digests are compared against the stored record and a mismatch
195
+ * is reported instead of being used.
196
+ */
197
+ export function extendSummary(previous, entries) {
198
+ if (entries.length === 0) return previous
199
+ const records = [...(previous?.entries ?? [])]
200
+ let chain = previous?.chainDigest ?? EMPTY_CHAIN
201
+ let count = previous?.count ?? 0
202
+ let fromSeq = previous?.fromSeq
203
+ let toSeq = previous?.toSeq
204
+ for (const entry of entries) {
205
+ const record = chainEntry(entry)
206
+ records.push(record)
207
+ chain = digestOf({ chain, entry: record })
208
+ count += 1
209
+ fromSeq = fromSeq === undefined ? entry.resultSeq : Math.min(fromSeq, entry.resultSeq)
210
+ toSeq = toSeq === undefined ? entry.resultSeq : Math.max(toSeq, entry.resultSeq)
211
+ }
212
+ records.sort((left, right) => left.resultSeq - right.resultSeq)
213
+ return { fromSeq, toSeq, count, chainDigest: chain, entries: records }
214
+ }
215
+
216
+ /** Recompute the chain committed by a summary's own records. */
217
+ export function chainOfEntries(records) {
218
+ let chain = EMPTY_CHAIN
219
+ for (const record of records) chain = digestOf({ chain, entry: { callSeq: record.callSeq, resultSeq: record.resultSeq, argumentsDigest: record.argumentsDigest } })
220
+ return chain
221
+ }
222
+
223
+ /** The record a dropped cluster must match when it is refolded from the log. */
224
+ export function summaryRecordOf(summary, resultSeq) {
225
+ if (!isPlainRecord(summary) || !Array.isArray(summary.entries)) return undefined
226
+ return summary.entries.find(record => record?.resultSeq === resultSeq)
227
+ }
228
+
229
+ /** Payload bytes a settlement still retains, used to decide pruning. */
230
+ export function settlementBytes(settlement) {
231
+ let bytes = 0
232
+ const args = settlement?.call?.data?.arguments
233
+ if (typeof args === 'string') bytes += byteLength(args)
234
+ const normalized = settlement?.normalized
235
+ if (normalized !== undefined) bytes += byteLength(normalized)
236
+ if (settlement?.publication !== undefined) bytes += byteLength(settlement.publication)
237
+ return bytes
238
+ }