dsh-ptc-plus 0.2.1

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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/README.zh.md +137 -0
  4. package/assets/dsh-ptc-plus-banner.webp +0 -0
  5. package/assets/ptc-plus-repair.png +0 -0
  6. package/assets/ptc-plus-settings.png +0 -0
  7. package/client.js +366 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/README.md +21 -0
  10. package/docs/adr/0001-delegate-governance-to-dsh.md +3 -0
  11. package/docs/adr/0002-limit-work-map-scope.md +3 -0
  12. package/docs/adr/0003-prefer-native-program-surfaces.md +3 -0
  13. package/docs/adr/0004-declare-program-bindings-at-the-host.md +5 -0
  14. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +129 -0
  15. package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +45 -0
  16. package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +39 -0
  17. package/docs/adr/0008-define-agent-operating-contract.md +25 -0
  18. package/docs/adr/0009-bound-expensive-acceptance-default.md +39 -0
  19. package/docs/adr/0010-session-log-derived-recovery-tips.md +26 -0
  20. package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +20 -0
  21. package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +23 -0
  22. package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +21 -0
  23. package/docs/adr/0014-persist-cell-rewrite-policy.md +25 -0
  24. package/docs/adr/0015-preserve-source-positions-through-rewrites.md +25 -0
  25. package/docs/adr/0016-archive-resolved-review-findings.md +43 -0
  26. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +23 -0
  27. package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +25 -0
  28. package/docs/adr/0019-plugin-settings-and-kill-switch.md +32 -0
  29. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +36 -0
  30. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +68 -0
  31. package/docs/adr/README.md +18 -0
  32. package/docs/architecture.md +134 -0
  33. package/docs/capability-projection.md +48 -0
  34. package/docs/client-ui.md +47 -0
  35. package/docs/durability-design.md +265 -0
  36. package/docs/evaluation.md +62 -0
  37. package/docs/installation.md +82 -0
  38. package/docs/program-data-plane.md +61 -0
  39. package/docs/publishing.md +37 -0
  40. package/docs/runtime-reference.md +119 -0
  41. package/docs/value-wire.md +61 -0
  42. package/index.js +532 -0
  43. package/internal/ast-traversal.js +20 -0
  44. package/internal/binding-descriptors.js +95 -0
  45. package/internal/binding-pattern.js +54 -0
  46. package/internal/builtin-esm-sync.js +9 -0
  47. package/internal/cell-analysis.js +467 -0
  48. package/internal/cell-rewriter.js +520 -0
  49. package/internal/config-spec.js +179 -0
  50. package/internal/cordis-tools-owner.js +363 -0
  51. package/internal/diagnostic.js +133 -0
  52. package/internal/direct-surface-owner.js +326 -0
  53. package/internal/edit-transport-owner.js +287 -0
  54. package/internal/failure-reporting.js +139 -0
  55. package/internal/kernel-worker.js +484 -0
  56. package/internal/module-policy.js +46 -0
  57. package/internal/program-bindings.js +187 -0
  58. package/internal/record-utils.js +53 -0
  59. package/internal/recovery-tips.js +81 -0
  60. package/internal/rejected-cell-editor.js +328 -0
  61. package/internal/repl-convenience.js +201 -0
  62. package/internal/runtime-bridge-owner.js +417 -0
  63. package/internal/runtime-config.js +77 -0
  64. package/internal/runtime-contexts.js +31 -0
  65. package/internal/sdk-projection.js +88 -0
  66. package/internal/session-cell-executor.js +612 -0
  67. package/internal/session-journal.js +889 -0
  68. package/internal/session-log-view.js +80 -0
  69. package/internal/session-runtime.js +536 -0
  70. package/internal/session-state.js +45 -0
  71. package/internal/source-position-map.js +224 -0
  72. package/internal/tool-call-canonicalizer.js +339 -0
  73. package/internal/value-wire.js +421 -0
  74. package/internal/worker-client.js +218 -0
  75. package/internal/worker-cwd-virtualization.js +155 -0
  76. package/package.json +100 -0
  77. package/src/client.js +165 -0
@@ -0,0 +1,889 @@
1
+ import { decodeValue, encodeValue, normalizeValueWire } from './value-wire.js'
2
+ import { normalizeDiagnostic } from './diagnostic.js'
3
+ import { assertOwnFields, isRecord } from './record-utils.js'
4
+
5
+ export const JOURNAL_KEY = 'dshPtcPlus'
6
+ export const EDIT_TARGET_KEY = 'dshPtcPlusEdit'
7
+ export const DERIVED_RUN_KEY = 'dshPtcPlusDerivedRun'
8
+ export const REWRITES_KEY = 'dshPtcPlusRewrites'
9
+ export const RECOVERY_BOUNDARY_KEY = 'dshPtcPlusRecoveryBoundaries'
10
+ export const JOURNAL_VERSION = 3
11
+ const LEGACY_JOURNAL_VERSION = 1
12
+ const INTERMEDIATE_JOURNAL_VERSION = 2
13
+ export const RECOVERY_BOUNDARY_EVENT = 'ptc-plus/recovery-boundary'
14
+
15
+ const STATUSES = new Set(['durable', 'volatile', 'discarded', 'noop'])
16
+ const BINDING_MODES = new Set(['loose', 'strict'])
17
+ const JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
18
+ const LEGACY_JOURNAL_FIELDS = new Set([...JOURNAL_FIELDS].filter(field => field !== 'rewritePolicy'))
19
+ const REWRITE_POLICY_FIELDS = new Set(['autoRewriteImports', 'autoStripExports', 'autoSplitRedeclarations'])
20
+ const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'ok', 'value', 'settle'])
21
+ const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'ok', 'error', 'settle'])
22
+ const OPERATION_FIELDS = new Set(['action', 'name'])
23
+ const RETURN_FIELDS = new Set(['kind', 'hasValue', 'value'])
24
+ const THROW_FIELDS = new Set(['kind', 'error'])
25
+ const ERROR_FIELDS = new Set(['kind', 'message'])
26
+ const EDIT_TARGET_FIELDS = new Set(['targetCallSeq'])
27
+ const DERIVED_RUN_FIELDS = new Set(['code', 'description'])
28
+
29
+ function cloneJson(value) {
30
+ if (value === undefined) return undefined
31
+ return decodeValue(encodeValue(value))
32
+ }
33
+
34
+ function validName(name) {
35
+ return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)
36
+ }
37
+
38
+ function normalizeCalls(value) {
39
+ if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal calls')
40
+ const calls = value.map((call, index) => {
41
+ if (!isRecord(call) || typeof call.global !== 'string' || typeof call.member !== 'string'
42
+ || !Object.hasOwn(call, 'args') || (call.ok !== true && call.ok !== false)
43
+ || !Number.isSafeInteger(call.settle) || call.settle < 0) {
44
+ throw new Error(`invalid dsh-ptc-plus journal call at index ${index}`)
45
+ }
46
+ assertOwnFields(call, call.ok ? CALL_SUCCESS_FIELDS : CALL_ERROR_FIELDS, `journal call at index ${index}`)
47
+ if (call.ok === true && !Object.hasOwn(call, 'value')) {
48
+ throw new Error(`journal call at index ${index} is missing its value`)
49
+ }
50
+ if (call.ok === false && typeof call.error !== 'string') {
51
+ throw new Error(`journal call at index ${index} is missing its error`)
52
+ }
53
+ return {
54
+ global: call.global,
55
+ member: call.member,
56
+ args: normalizeValueWire(call.args),
57
+ ok: call.ok,
58
+ settle: call.settle,
59
+ ...(call.ok ? { value: normalizeValueWire(call.value) } : { error: call.error }),
60
+ }
61
+ })
62
+ const order = calls.map(call => call.settle).sort((left, right) => left - right)
63
+ if (order.some((settle, index) => settle !== index)) {
64
+ throw new Error('dsh-ptc-plus journal call settlement order is not contiguous')
65
+ }
66
+ return calls
67
+ }
68
+
69
+ function normalizeOperations(value) {
70
+ if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal operations')
71
+ return value.map((operation, index) => {
72
+ if (!isRecord(operation) || !['save', 'restore', 'delete'].includes(operation.action)
73
+ || ((operation.action !== 'restore' || operation.name !== undefined) && !validName(operation.name))) {
74
+ throw new Error(`invalid dsh-ptc-plus journal operation at index ${index}`)
75
+ }
76
+ assertOwnFields(operation, OPERATION_FIELDS, `journal operation at index ${index}`)
77
+ return { action: operation.action, ...(operation.name === undefined ? {} : { name: operation.name }) }
78
+ })
79
+ }
80
+
81
+ function normalizeCompletion(value, required) {
82
+ if (value === undefined && !required) return undefined
83
+ if (!isRecord(value) || !['return', 'throw'].includes(value.kind)) {
84
+ throw new Error('invalid dsh-ptc-plus journal completion')
85
+ }
86
+ if (value.kind === 'return') {
87
+ assertOwnFields(value, RETURN_FIELDS, 'journal return completion')
88
+ if (typeof value.hasValue !== 'boolean'
89
+ || (value.hasValue ? !Object.hasOwn(value, 'value') : Object.hasOwn(value, 'value'))) {
90
+ throw new Error('invalid dsh-ptc-plus journal return value')
91
+ }
92
+ return Object.freeze({
93
+ kind: 'return',
94
+ hasValue: value.hasValue,
95
+ ...(value.hasValue ? { value: normalizeValueWire(value.value) } : {}),
96
+ })
97
+ }
98
+ if (!isRecord(value.error) || typeof value.error.kind !== 'string' || typeof value.error.message !== 'string') {
99
+ throw new Error('invalid dsh-ptc-plus journal throw completion')
100
+ }
101
+ assertOwnFields(value, THROW_FIELDS, 'journal throw completion')
102
+ assertOwnFields(value.error, ERROR_FIELDS, 'journal completion error')
103
+ return Object.freeze({
104
+ kind: 'throw',
105
+ error: Object.freeze({ kind: value.error.kind, message: value.error.message }),
106
+ })
107
+ }
108
+
109
+ const LEGACY_REWRITE_POLICY = Object.freeze({
110
+ autoRewriteImports: false,
111
+ autoStripExports: false,
112
+ autoSplitRedeclarations: false,
113
+ })
114
+
115
+ function normalizeLegacyConfirms(value, resolveLegacyConfirm) {
116
+ if (value === undefined) return []
117
+ if (!Array.isArray(value) || value.some(callId => typeof callId !== 'string' || callId.length === 0)) {
118
+ throw new Error('invalid dsh-ptc-plus confirmed no-op calls')
119
+ }
120
+ if (new Set(value).size !== value.length) throw new Error('duplicate dsh-ptc-plus confirmed no-op call')
121
+ if (value.length === 0) return []
122
+ if (typeof resolveLegacyConfirm !== 'function') {
123
+ throw new Error('legacy dsh-ptc-plus confirmed no-op calls require session call identity')
124
+ }
125
+ const confirms = value.map(callId => resolveLegacyConfirm(callId))
126
+ if (confirms.some(callSeq => !Number.isSafeInteger(callSeq) || callSeq < 0)) {
127
+ throw new Error('legacy dsh-ptc-plus confirmed no-op call is not uniquely persisted')
128
+ }
129
+ if (new Set(confirms).size !== confirms.length) {
130
+ throw new Error('duplicate dsh-ptc-plus confirmed no-op call')
131
+ }
132
+ return confirms
133
+ }
134
+
135
+ function normalizeConfirms(value) {
136
+ if (value === undefined) return []
137
+ if (!Array.isArray(value) || value.some(callSeq => !Number.isSafeInteger(callSeq) || callSeq < 0)) {
138
+ throw new Error('invalid dsh-ptc-plus confirmed no-op calls')
139
+ }
140
+ const confirms = [...new Set(value)]
141
+ if (confirms.length !== value.length) throw new Error('duplicate dsh-ptc-plus confirmed no-op call')
142
+ return confirms
143
+ }
144
+
145
+ function normalizeDiagnostics(value) {
146
+ if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal diagnostics')
147
+ return value.map((diagnostic, index) => {
148
+ try {
149
+ return normalizeDiagnostic(diagnostic)
150
+ } catch (error) {
151
+ throw new Error(`invalid dsh-ptc-plus journal diagnostic at index ${index}: ${error.message}`)
152
+ }
153
+ })
154
+ }
155
+
156
+ function normalizeRewritePolicy(value) {
157
+ if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal rewrite policy')
158
+ assertOwnFields(value, REWRITE_POLICY_FIELDS, 'journal rewrite policy')
159
+ for (const key of REWRITE_POLICY_FIELDS) {
160
+ if (typeof value[key] !== 'boolean') throw new Error(`invalid dsh-ptc-plus journal rewrite policy ${key}`)
161
+ }
162
+ return Object.freeze({
163
+ autoRewriteImports: value.autoRewriteImports,
164
+ autoStripExports: value.autoStripExports,
165
+ autoSplitRedeclarations: value.autoSplitRedeclarations,
166
+ })
167
+ }
168
+
169
+ function migrateJournal(value, resolveLegacyConfirm) {
170
+ if (value.version !== LEGACY_JOURNAL_VERSION) return value
171
+ assertOwnFields(value, LEGACY_JOURNAL_FIELDS, 'dsh-ptc-plus journal')
172
+ return {
173
+ ...value,
174
+ version: JOURNAL_VERSION,
175
+ rewritePolicy: LEGACY_REWRITE_POLICY,
176
+ confirms: normalizeLegacyConfirms(value.confirms, resolveLegacyConfirm),
177
+ }
178
+ }
179
+
180
+ /** Validate and detach one journal emitted by the runtime. */
181
+ export function normalizeJournal(value, options = {}) {
182
+ if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal')
183
+ if (![LEGACY_JOURNAL_VERSION, INTERMEDIATE_JOURNAL_VERSION, JOURNAL_VERSION].includes(value.version)
184
+ || !STATUSES.has(value.status)) {
185
+ throw new Error('invalid dsh-ptc-plus journal')
186
+ }
187
+ const migrated = migrateJournal(value, options.resolveLegacyConfirm)
188
+ assertOwnFields(migrated, JOURNAL_FIELDS, 'dsh-ptc-plus journal')
189
+ if (!BINDING_MODES.has(migrated.bindingMode)) throw new Error('invalid dsh-ptc-plus journal binding mode')
190
+ const rewritePolicy = normalizeRewritePolicy(migrated.rewritePolicy)
191
+ const calls = normalizeCalls(migrated.calls)
192
+ const operations = normalizeOperations(migrated.operations)
193
+ const confirms = normalizeConfirms(migrated.confirms)
194
+ const diagnostics = normalizeDiagnostics(migrated.diagnostics)
195
+ const completion = normalizeCompletion(
196
+ migrated.completion,
197
+ migrated.status === 'durable' || migrated.status === 'volatile',
198
+ )
199
+ if ((migrated.status === 'discarded' || migrated.status === 'noop')
200
+ && (calls.length !== 0 || operations.length !== 0)) {
201
+ throw new Error(`${migrated.status} dsh-ptc-plus journal must not contain calls or operations`)
202
+ }
203
+ if (migrated.volatileReason !== undefined && typeof migrated.volatileReason !== 'string') {
204
+ throw new Error('invalid dsh-ptc-plus volatile reason')
205
+ }
206
+ if (migrated.volatileReason !== undefined && migrated.status !== 'volatile' && migrated.status !== 'discarded') {
207
+ throw new Error('dsh-ptc-plus volatile reason requires volatile or discarded status')
208
+ }
209
+ return Object.freeze({
210
+ version: JOURNAL_VERSION,
211
+ bindingMode: migrated.bindingMode,
212
+ rewritePolicy,
213
+ status: migrated.status,
214
+ calls: Object.freeze(calls),
215
+ operations: Object.freeze(operations),
216
+ confirms: Object.freeze(confirms),
217
+ diagnostics: Object.freeze(diagnostics),
218
+ ...(completion === undefined ? {} : { completion }),
219
+ ...(migrated.volatileReason === undefined ? {} : { volatileReason: migrated.volatileReason }),
220
+ })
221
+ }
222
+
223
+ /** Validate the required persisted relation that makes one edit result executable history. */
224
+ export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
225
+ if (!Number.isSafeInteger(expectedTargetCallSeq) || expectedTargetCallSeq < 0) {
226
+ throw new Error('derived edit does not identify an eligible target call')
227
+ }
228
+ if (!isRecord(meta)) throw new Error('invalid dsh-ptc-plus derived edit metadata')
229
+ const target = meta[EDIT_TARGET_KEY]
230
+ const derived = meta[DERIVED_RUN_KEY]
231
+ if (!isRecord(target)) throw new Error('invalid dsh-ptc-plus edit target metadata')
232
+ assertOwnFields(target, EDIT_TARGET_FIELDS, 'edit target metadata')
233
+ if (target.targetCallSeq !== expectedTargetCallSeq) {
234
+ throw new Error('derived edit target does not match the eligible target call')
235
+ }
236
+ if (!isRecord(derived) || typeof derived.code !== 'string' || typeof derived.description !== 'string') {
237
+ throw new Error('invalid dsh-ptc-plus derived run metadata')
238
+ }
239
+ assertOwnFields(derived, DERIVED_RUN_FIELDS, 'derived run metadata')
240
+ const journal = normalizeJournal(meta[JOURNAL_KEY])
241
+ if (journal.status === 'noop') throw new Error('derived edit journal must not be noop')
242
+ const recoveryBoundaries = meta[RECOVERY_BOUNDARY_KEY] === undefined
243
+ ? undefined
244
+ : normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
245
+ return Object.freeze({
246
+ targetCallSeq: target.targetCallSeq,
247
+ code: derived.code,
248
+ description: derived.description,
249
+ journal,
250
+ ...(recoveryBoundaries === undefined ? {} : { recoveryBoundaries }),
251
+ })
252
+ }
253
+
254
+ /** Compare only the required persisted relation for one derived edit. */
255
+ export function derivedEditResultsEqual(leftMeta, rightMeta, expectedTargetCallSeq) {
256
+ try {
257
+ const left = encodeValue(normalizeDerivedEditResult(leftMeta, expectedTargetCallSeq))
258
+ const right = encodeValue(normalizeDerivedEditResult(rightMeta, expectedTargetCallSeq))
259
+ return JSON.stringify(left) === JSON.stringify(right)
260
+ } catch {
261
+ return false
262
+ }
263
+ }
264
+
265
+ /** Compare journal semantics without recursive traversal of nested JSON. */
266
+ export function journalsEqual(left, right) {
267
+ try {
268
+ const leftWire = encodeValue(normalizeJournal(left))
269
+ const rightWire = encodeValue(normalizeJournal(right))
270
+ return JSON.stringify(leftWire) === JSON.stringify(rightWire)
271
+ } catch {
272
+ return false
273
+ }
274
+ }
275
+
276
+ /** Compare the optional recovery frontier attached to one settlement. */
277
+ export function recoveryBoundariesEqual(left, right) {
278
+ if (left === undefined || right === undefined) return left === right
279
+ try {
280
+ return JSON.stringify(normalizeRecoveryBoundaries(left))
281
+ === JSON.stringify(normalizeRecoveryBoundaries(right))
282
+ } catch {
283
+ return false
284
+ }
285
+ }
286
+
287
+ function normalizeRecoveryBoundaryValue(value, eventSeq = undefined) {
288
+ if (!isRecord(value)
289
+ || !Number.isSafeInteger(value.failedCallSeq) || value.failedCallSeq < 0
290
+ || (value.frontierCallSeq !== null
291
+ && (!Number.isSafeInteger(value.frontierCallSeq) || value.frontierCallSeq < 0))) {
292
+ throw new Error('invalid dsh-ptc-plus recovery boundary')
293
+ }
294
+ assertOwnFields(value, new Set(['failedCallSeq', 'frontierCallSeq']), 'recovery boundary')
295
+ if (eventSeq !== undefined && (!Number.isSafeInteger(eventSeq) || eventSeq < 0)) {
296
+ throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
297
+ }
298
+ return {
299
+ failedCallSeq: value.failedCallSeq,
300
+ frontierCallSeq: value.frontierCallSeq,
301
+ ...(eventSeq === undefined ? {} : { eventSeq }),
302
+ }
303
+ }
304
+
305
+ /** Validate recovery boundaries stored on a settled tool result. */
306
+ export function normalizeRecoveryBoundaries(value, eventSeq = undefined) {
307
+ if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus recovery boundaries')
308
+ return Object.freeze(value.map(boundary => Object.freeze(
309
+ normalizeRecoveryBoundaryValue(boundary, eventSeq),
310
+ )))
311
+ }
312
+
313
+ /** Merge replay contractions into private tool-result metadata. */
314
+ export function withRecoveryBoundaries(meta, boundaries) {
315
+ const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
316
+ base[RECOVERY_BOUNDARY_KEY] = normalizeRecoveryBoundaries(boundaries)
317
+ return base
318
+ }
319
+
320
+ /**
321
+ * Convert the retired custom boundary event in a raw log before DSH restores it.
322
+ * The returned log is a detached, renumbered copy; the input is never mutated.
323
+ */
324
+ export function migrateRecoveryBoundaryEvents(events) {
325
+ if (!Array.isArray(events)) throw new TypeError('recovery-boundary migration expects an event array')
326
+ const migrated = []
327
+ const pending = []
328
+ for (const event of events) {
329
+ if (event?.type === RECOVERY_BOUNDARY_EVENT) {
330
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
331
+ throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
332
+ }
333
+ pending.push(normalizeRecoveryBoundaryValue(event.data))
334
+ continue
335
+ }
336
+ const detached = cloneJson(event)
337
+ if (pending.length > 0 && detached?.type === 'tool/result') {
338
+ const data = isRecord(detached.data) ? { ...detached.data } : {}
339
+ const meta = isRecord(data.meta) ? { ...data.meta } : {}
340
+ const existing = meta[RECOVERY_BOUNDARY_KEY] === undefined
341
+ ? []
342
+ : normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
343
+ meta[RECOVERY_BOUNDARY_KEY] = [...existing, ...pending]
344
+ data.meta = meta
345
+ detached.data = data
346
+ pending.length = 0
347
+ }
348
+ migrated.push(detached)
349
+ }
350
+ if (pending.length > 0) {
351
+ throw new Error('recovery boundary has no later tool/result settlement for migration')
352
+ }
353
+ const sequenceMap = new Map()
354
+ for (const [index, event] of migrated.entries()) {
355
+ if (Number.isSafeInteger(event?.seq) && event.seq >= 0) sequenceMap.set(event.seq, index)
356
+ }
357
+ return Object.freeze(migrated.map((event, index) => Object.freeze({
358
+ ...event,
359
+ seq: index,
360
+ ...(Array.isArray(event.sourceEventSeqs)
361
+ ? { sourceEventSeqs: event.sourceEventSeqs.map(seq => sequenceMap.get(seq) ?? seq) }
362
+ : {}),
363
+ })))
364
+ }
365
+
366
+ /** Resolve the persisted event identity for one named tool call being dispatched. */
367
+ export function liveToolCallSeq(session, callId, toolName) {
368
+ const events = session?.events
369
+ if (!Array.isArray(events) || typeof callId !== 'string' || callId.length === 0
370
+ || typeof toolName !== 'string' || toolName.length === 0) return undefined
371
+
372
+ const pairedCallSeqs = new Set()
373
+ for (const event of events) {
374
+ if (event?.type !== 'tool/result' || !Array.isArray(event.sourceEventSeqs)) continue
375
+ for (const sourceSeq of event.sourceEventSeqs) {
376
+ if (Number.isSafeInteger(sourceSeq) && sourceSeq >= 0) pairedCallSeqs.add(sourceSeq)
377
+ }
378
+ }
379
+
380
+ const candidates = []
381
+ for (const event of events) {
382
+ if (event?.type !== 'tool/call' || event.data?.name !== toolName
383
+ || event.data.callId !== callId || pairedCallSeqs.has(event.seq)) continue
384
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
385
+ throw new Error(`current ${toolName} call has an invalid session event sequence`)
386
+ }
387
+ candidates.push(event.seq)
388
+ }
389
+ if (candidates.length > 1) {
390
+ throw new Error(`session log contains multiple unpaired ${toolName} calls for callId ${JSON.stringify(callId)}`)
391
+ }
392
+ return candidates[0]
393
+ }
394
+
395
+ /** Start a mutable journal for one live cell. */
396
+ export function createJournal(confirms = [], bindingMode, rewritePolicy) {
397
+ if (!BINDING_MODES.has(bindingMode)) throw new TypeError('invalid dsh-ptc-plus journal binding mode')
398
+ return {
399
+ version: JOURNAL_VERSION,
400
+ bindingMode,
401
+ rewritePolicy: normalizeRewritePolicy(rewritePolicy),
402
+ calls: [],
403
+ operations: [],
404
+ confirms: [...confirms],
405
+ diagnostics: [],
406
+ }
407
+ }
408
+
409
+ function sourceForRunCall(call) {
410
+ try {
411
+ const args = JSON.parse(call.data.arguments)
412
+ return isRecord(args) && typeof args.code === 'string' ? args.code : undefined
413
+ } catch {
414
+ return undefined
415
+ }
416
+ }
417
+
418
+ /** Return an owned state transition; reject volatile saves and unknown named restores. */
419
+ export function reduceStateOperations({ nodes, head, checkpoints }, operations, nodeIndex) {
420
+ const nextCheckpoints = new Map(checkpoints)
421
+ let nextHead = head
422
+ let restored = false
423
+ for (const operation of operations) {
424
+ if (operation.action === 'save') {
425
+ if (nodeIndex === undefined) throw new Error('volatile journal cannot save a durable REPL state')
426
+ nextCheckpoints.set(operation.name, nodeIndex)
427
+ continue
428
+ }
429
+ if (operation.action === 'delete') {
430
+ nextCheckpoints.delete(operation.name)
431
+ continue
432
+ }
433
+ const target = operation.name === undefined
434
+ ? nodeIndex === undefined ? nextHead : nodes[nodeIndex]?.parent
435
+ : nextCheckpoints.get(operation.name)
436
+ if (operation.name !== undefined && target === undefined) {
437
+ throw new Error(`session log restores unknown REPL state "${operation.name}"`)
438
+ }
439
+ nextHead = target
440
+ restored = true
441
+ }
442
+ return { head: nextHead, checkpoints: nextCheckpoints, restored }
443
+ }
444
+
445
+ function applyOperations(state, operations, nodeIndex) {
446
+ const transition = reduceStateOperations(state, operations, nodeIndex)
447
+ state.head = transition.head
448
+ state.checkpoints = transition.checkpoints
449
+ if (transition.restored) {
450
+ state.trusted = true
451
+ state.volatileSuffix.length = 0
452
+ }
453
+ }
454
+
455
+ function applyRecord(state, record, invalidCallSeqs) {
456
+ const { call, code, result } = record
457
+ if (invalidCallSeqs.has(call.seq)) return
458
+ if (code === undefined || result?.journal === undefined) {
459
+ state.trusted = false
460
+ state.volatileSuffix.push({ seq: call.seq, code, reason: result?.error ?? 'missing dsh-ptc-plus journal result' })
461
+ return
462
+ }
463
+ const journal = result.journal
464
+ if (journal.status === 'noop') return
465
+ if (journal.status === 'discarded') {
466
+ if (journal.volatileReason !== undefined) {
467
+ state.trusted = false
468
+ state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason })
469
+ }
470
+ return
471
+ }
472
+ if (journal.status === 'volatile') {
473
+ state.trusted = false
474
+ state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason ?? 'volatile cell' })
475
+ applyOperations(state, journal.operations, undefined)
476
+ return
477
+ }
478
+ if (!state.trusted) {
479
+ state.trusted = true
480
+ state.volatileSuffix.length = 0
481
+ }
482
+ const node = Object.freeze({
483
+ code,
484
+ journal,
485
+ callSeq: call.seq,
486
+ parent: state.head,
487
+ })
488
+ const index = state.nodes.push(node) - 1
489
+ state.head = index
490
+ applyOperations(state, journal.operations, index)
491
+ }
492
+
493
+ function forceRecoveryHead(state, boundary) {
494
+ state.head = boundary.frontierCallSeq === null
495
+ ? undefined
496
+ : state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
497
+ if (state.head === -1) throw new Error('recovery boundary frontier is not reconstructable')
498
+ state.trusted = true
499
+ state.volatileSuffix = []
500
+ }
501
+
502
+ function recordEventSeq(record) {
503
+ return record.result?.eventSeq ?? record.call.seq
504
+ }
505
+
506
+ function foldRecords(records, invalidCallSeqs, boundaries = []) {
507
+ const state = {
508
+ nodes: [],
509
+ head: undefined,
510
+ checkpoints: new Map(),
511
+ volatileSuffix: [],
512
+ trusted: true,
513
+ }
514
+ let boundaryIndex = 0
515
+ for (const record of records) {
516
+ while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq < recordEventSeq(record)) {
517
+ forceRecoveryHead(state, boundaries[boundaryIndex++])
518
+ }
519
+ applyRecord(state, record, invalidCallSeqs)
520
+ }
521
+ while (boundaryIndex < boundaries.length) forceRecoveryHead(state, boundaries[boundaryIndex++])
522
+ return state
523
+ }
524
+
525
+ function dependsOn(nodes, index, ancestor) {
526
+ for (let cursor = index; cursor !== undefined; cursor = nodes[cursor]?.parent) {
527
+ if (cursor === ancestor) return true
528
+ }
529
+ return false
530
+ }
531
+
532
+ function timelineRun(call, result, eventIndex, journal) {
533
+ let args
534
+ try {
535
+ const parsed = JSON.parse(call.data.arguments)
536
+ if (isRecord(parsed)) args = Object.freeze(parsed)
537
+ } catch {
538
+ // Invalid persisted arguments cannot support derived source facts.
539
+ }
540
+ return Object.freeze({
541
+ index: eventIndex,
542
+ callSeq: Number.isSafeInteger(call.seq) ? call.seq : undefined,
543
+ args,
544
+ source: typeof args?.code === 'string' ? args.code : undefined,
545
+ journal,
546
+ rewrites: validatedRewrites(result.data?.meta),
547
+ })
548
+ }
549
+
550
+ function timelineDerivedRun(call, result, eventIndex, derived) {
551
+ return Object.freeze({
552
+ index: eventIndex,
553
+ callSeq: Number.isSafeInteger(call.seq) ? call.seq : undefined,
554
+ args: Object.freeze({ description: derived.description }),
555
+ source: derived.code,
556
+ journal: derived.journal,
557
+ rewrites: validatedRewrites(result.data?.meta),
558
+ })
559
+ }
560
+
561
+ function successfulTimelineRun(run) {
562
+ return run.journal?.completion?.kind === 'return' && run.journal.status !== 'noop'
563
+ }
564
+
565
+ /**
566
+ * Fold persisted tool events once into the call/result and edit-target timeline
567
+ * shared by prompt projection and cold journal recovery.
568
+ */
569
+ export function foldSessionTimeline(events) {
570
+ const empty = {
571
+ openTurn: false,
572
+ executableCalls: new Map(),
573
+ calls: [],
574
+ results: new Map(),
575
+ boundaries: [],
576
+ found: false,
577
+ unavailableResultSeq: undefined,
578
+ lastSuccessfulRunIndex: undefined,
579
+ latestRun: undefined,
580
+ editableRun: undefined,
581
+ editTargets: new Map(),
582
+ }
583
+ if (!Array.isArray(events)) return empty
584
+
585
+ const state = { ...empty }
586
+ const pendingByCallId = new Map()
587
+ const seenCallIds = new Set()
588
+ const claimedEditTargets = new Set()
589
+ const editClaims = new Map()
590
+ const journalResultSeqs = new Set()
591
+ let scope = 0
592
+
593
+ const resetTurn = (openTurn) => {
594
+ state.openTurn = openTurn
595
+ scope += 1
596
+ pendingByCallId.clear()
597
+ seenCallIds.clear()
598
+ claimedEditTargets.clear()
599
+ state.latestRun = undefined
600
+ state.editableRun = undefined
601
+ }
602
+
603
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
604
+ const event = events[eventIndex]
605
+ if (event?.type === 'turn/start') {
606
+ resetTurn(true)
607
+ continue
608
+ }
609
+ if (event?.type === 'turn/end') {
610
+ resetTurn(false)
611
+ continue
612
+ }
613
+ if (event?.type === RECOVERY_BOUNDARY_EVENT) {
614
+ throw new Error('legacy recovery boundary requires migration before DSH session restore')
615
+ continue
616
+ }
617
+ if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
618
+ const executable = event.data.name === 'run_code' || event.data.name === 'edit_run_code'
619
+ let entry = { event, eventIndex, scope }
620
+ if (executable) {
621
+ if (Number.isSafeInteger(event.seq) && event.seq >= 0 && state.executableCalls.has(event.seq)) {
622
+ throw new Error('session log contains a duplicate run_code call sequence')
623
+ }
624
+ if (event.data.name === 'edit_run_code') {
625
+ const targetCallSeq = state.editableRun?.callSeq
626
+ const target = targetCallSeq !== undefined && state.editableRun.source !== undefined
627
+ && !claimedEditTargets.has(targetCallSeq)
628
+ ? Object.freeze({ source: state.editableRun.source, callSeq: targetCallSeq })
629
+ : undefined
630
+ entry = { ...entry, editTarget: target }
631
+ state.editTargets.set(event.seq, target)
632
+ if (target !== undefined) {
633
+ claimedEditTargets.add(target.callSeq)
634
+ editClaims.set(event.seq, target.callSeq)
635
+ }
636
+ }
637
+ state.calls.push(event)
638
+ if (Number.isSafeInteger(event.seq) && event.seq >= 0) {
639
+ state.executableCalls.set(event.seq, entry)
640
+ }
641
+ }
642
+ if (seenCallIds.has(event.data.callId)) pendingByCallId.set(event.data.callId, null)
643
+ else {
644
+ seenCallIds.add(event.data.callId)
645
+ pendingByCallId.set(event.data.callId, entry)
646
+ }
647
+ continue
648
+ }
649
+ if (event?.type !== 'tool/result') continue
650
+
651
+ const sourceSeq = event.sourceEventSeqs?.[0]
652
+ const callId = event.data?.message?.source?.callId
653
+ let entry = Number.isSafeInteger(sourceSeq) ? state.executableCalls.get(sourceSeq) : undefined
654
+ if (entry === undefined && !Number.isSafeInteger(sourceSeq) && typeof callId === 'string') {
655
+ entry = pendingByCallId.get(callId)
656
+ }
657
+ if (typeof callId === 'string') pendingByCallId.delete(callId)
658
+ if (entry === null) {
659
+ state.latestRun = undefined
660
+ state.editableRun = undefined
661
+ continue
662
+ }
663
+
664
+ const call = entry?.event
665
+ const callSeq = call?.seq
666
+ const meta = event.data?.meta
667
+ let normalized
668
+ if (isRecord(meta) && Object.hasOwn(meta, JOURNAL_KEY)) {
669
+ if (Number.isSafeInteger(sourceSeq) && journalResultSeqs.has(sourceSeq)) {
670
+ throw new Error(`session log contains duplicate PTC journal results for call seq ${sourceSeq}`)
671
+ }
672
+ if (Number.isSafeInteger(sourceSeq)) journalResultSeqs.add(sourceSeq)
673
+ if (entry === undefined) {
674
+ if (Number.isSafeInteger(sourceSeq)) state.unavailableResultSeq ??= sourceSeq
675
+ continue
676
+ }
677
+ const resultSeq = Number.isSafeInteger(sourceSeq) ? sourceSeq : callSeq
678
+ const raw = {
679
+ meta,
680
+ eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : resultSeq,
681
+ eventIndex,
682
+ }
683
+ if (Object.hasOwn(meta, RECOVERY_BOUNDARY_KEY)) {
684
+ state.boundaries.push(...normalizeRecoveryBoundaries(
685
+ meta[RECOVERY_BOUNDARY_KEY], raw.eventSeq,
686
+ ))
687
+ }
688
+ try {
689
+ if (call.data.name === 'edit_run_code') {
690
+ const derived = normalizeDerivedEditResult(meta, entry.editTarget?.callSeq)
691
+ normalized = { ...raw, journal: derived.journal, derived }
692
+ } else if (call.data.name === 'run_code') {
693
+ const rawJournal = meta[JOURNAL_KEY]
694
+ const resolveLegacyConfirm = rawJournal?.version === LEGACY_JOURNAL_VERSION
695
+ ? legacyCallId => {
696
+ const candidates = [...state.executableCalls.values()]
697
+ .filter(candidate => candidate.eventIndex < eventIndex
698
+ && candidate.event.data?.name === 'run_code'
699
+ && candidate.event.data.callId === legacyCallId
700
+ && !state.results.has(candidate.event.seq))
701
+ return candidates.length === 1 ? candidates[0].event.seq : undefined
702
+ }
703
+ : undefined
704
+ normalized = { ...raw, journal: normalizeJournal(rawJournal, { resolveLegacyConfirm }) }
705
+ }
706
+ } catch (error) {
707
+ normalized = { ...raw, error: error.message }
708
+ }
709
+ if (Number.isSafeInteger(resultSeq)) state.results.set(resultSeq, normalized)
710
+ state.found = true
711
+ }
712
+
713
+ const claimedTarget = editClaims.get(callSeq)
714
+ if (claimedTarget !== undefined) {
715
+ editClaims.delete(callSeq)
716
+ if (normalized?.derived === undefined) claimedEditTargets.delete(claimedTarget)
717
+ }
718
+ if (entry === undefined || entry.scope !== scope) continue
719
+ if (call.data.name === 'edit_run_code') {
720
+ if (normalized?.derived !== undefined) {
721
+ const run = timelineDerivedRun(call, event, eventIndex, normalized.derived)
722
+ state.latestRun = run
723
+ state.editableRun = run
724
+ if (successfulTimelineRun(run)) state.lastSuccessfulRunIndex = eventIndex
725
+ }
726
+ continue
727
+ }
728
+ if (call.data.name !== 'run_code') {
729
+ // Unrelated native settlements do not change the current editable cell.
730
+ // The edit target is captured at dispatch time and remains valid until a
731
+ // new executable cell or turn boundary supersedes it.
732
+ continue
733
+ }
734
+ const run = timelineRun(call, event, eventIndex, normalized?.journal)
735
+ state.latestRun = run
736
+ state.editableRun = run
737
+ if (successfulTimelineRun(run)) state.lastSuccessfulRunIndex = eventIndex
738
+ }
739
+ return state
740
+ }
741
+
742
+ /** Fold the session log into the last exactly replayable frontier. */
743
+ export function recoverJournal(session, currentCallSeq, options = {}) {
744
+ const events = session?.events
745
+ if (!Array.isArray(events)) {
746
+ return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], available: true }
747
+ }
748
+ const timeline = foldSessionTimeline(events)
749
+ const calls = timeline.calls.filter(call => call.seq !== currentCallSeq)
750
+ const { executableCalls, results, found } = timeline
751
+ const extraBoundaries = options?.extraBoundaries ?? []
752
+ const boundaries = [
753
+ ...timeline.boundaries,
754
+ ...normalizeRecoveryBoundaries(extraBoundaries, undefined).map(boundary => ({
755
+ ...boundary,
756
+ eventSeq: Number.POSITIVE_INFINITY,
757
+ })),
758
+ ].sort((left, right) => left.eventSeq - right.eventSeq)
759
+ if (timeline.unavailableResultSeq !== undefined) {
760
+ throw new Error(`PTC journal result references unavailable run_code call seq ${timeline.unavailableResultSeq}`)
761
+ }
762
+
763
+ const confirmedNoops = new Set()
764
+ for (const { journal, eventIndex } of results.values()) {
765
+ for (const callSeq of journal?.confirms ?? []) {
766
+ const confirmed = executableCalls.get(callSeq)
767
+ if (confirmed === undefined || confirmed.eventIndex >= eventIndex || results.has(callSeq)) {
768
+ throw new Error(`confirmed no-op does not identify an earlier unjournaled run_code call seq ${callSeq}`)
769
+ }
770
+ confirmedNoops.add(callSeq)
771
+ }
772
+ }
773
+ const records = []
774
+ const orderedRecords = []
775
+ const invalidCallSeqs = new Set()
776
+ const appliedBoundaries = []
777
+ let state = foldRecords(records, invalidCallSeqs)
778
+ let boundaryIndex = 0
779
+ const applyBoundariesBefore = (seq) => {
780
+ while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq <= seq) {
781
+ const boundary = boundaries[boundaryIndex++]
782
+ const failedIndex = state.nodes.findIndex(node => node.callSeq === boundary.failedCallSeq)
783
+ if (failedIndex < 0) throw new Error('recovery boundary references an unavailable failed cell')
784
+ const expectedFrontier = state.nodes[failedIndex].parent
785
+ const frontierIndex = boundary.frontierCallSeq === null
786
+ ? undefined
787
+ : state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
788
+ if (frontierIndex !== expectedFrontier) {
789
+ throw new Error('recovery boundary does not identify the failed cell parent')
790
+ }
791
+ for (let index = 0; index < state.nodes.length; index += 1) {
792
+ if (dependsOn(state.nodes, index, failedIndex)) invalidCallSeqs.add(state.nodes[index].callSeq)
793
+ }
794
+ appliedBoundaries.push(boundary)
795
+ state = foldRecords(records, invalidCallSeqs, appliedBoundaries)
796
+ }
797
+ }
798
+ for (const call of calls) {
799
+ const result = results.get(call.seq)
800
+ const code = call.data?.name === 'edit_run_code' ? result?.derived?.code : sourceForRunCall(call)
801
+ const record = { call, code, result }
802
+ if (call.data?.name === 'edit_run_code' && record.code === undefined && result === undefined) continue
803
+ if (result?.journal === undefined && confirmedNoops.has(call.seq)) continue
804
+ orderedRecords.push(record)
805
+ }
806
+ orderedRecords.sort((left, right) => (
807
+ recordEventSeq(left) - recordEventSeq(right) || left.call.seq - right.call.seq
808
+ ))
809
+ for (const record of orderedRecords) {
810
+ applyBoundariesBefore(recordEventSeq(record))
811
+ records.push(record)
812
+ applyRecord(state, record, invalidCallSeqs)
813
+ }
814
+ applyBoundariesBefore(Number.POSITIVE_INFINITY)
815
+ return {
816
+ nodes: state.nodes,
817
+ head: state.head,
818
+ checkpoints: state.checkpoints,
819
+ volatileSuffix: state.volatileSuffix,
820
+ available: found,
821
+ }
822
+ }
823
+
824
+ /** Return source nodes from the empty state to a selected durable head. */
825
+ export function pathToHead(state) {
826
+ const path = []
827
+ for (let cursor = state.head; cursor !== undefined;) {
828
+ const node = state.nodes[cursor]
829
+ if (node === undefined) throw new Error('invalid dsh-ptc-plus journal head')
830
+ path.push(node)
831
+ cursor = node.parent
832
+ }
833
+ path.reverse()
834
+ return path
835
+ }
836
+
837
+ /** Merge the journal into the tool result's private metadata. */
838
+ export function withJournal(meta, journal) {
839
+ const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
840
+ base[JOURNAL_KEY] = normalizeJournal(journal)
841
+ return base
842
+ }
843
+
844
+ const REWRITE_FIELDS = new Set(['kind', 'description', 'source'])
845
+ const REWRITE_KINDS = new Set(['import', 'redeclaration', 'export'])
846
+
847
+ /** Validate and detach one rewrite record emitted by the runtime. */
848
+ export function normalizeRewrites(value) {
849
+ if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus rewrites')
850
+ return Object.freeze(value.map((rewrite, index) => {
851
+ if (!isRecord(rewrite) || !REWRITE_KINDS.has(rewrite.kind) || typeof rewrite.description !== 'string'
852
+ || rewrite.description.length === 0) {
853
+ throw new Error(`invalid dsh-ptc-plus rewrite at index ${index}`)
854
+ }
855
+ assertOwnFields(rewrite, REWRITE_FIELDS, `dsh-ptc-plus rewrite at index ${index}`)
856
+ if (rewrite.source !== undefined && typeof rewrite.source !== 'string') {
857
+ throw new Error(`invalid dsh-ptc-plus rewrite source at index ${index}`)
858
+ }
859
+ return Object.freeze({
860
+ kind: rewrite.kind,
861
+ description: rewrite.description,
862
+ ...(rewrite.source === undefined ? {} : { source: rewrite.source }),
863
+ })
864
+ }))
865
+ }
866
+
867
+ /** Read optional rewrite provenance without letting malformed metadata affect settlement. */
868
+ export function validatedRewrites(meta) {
869
+ if (!isRecord(meta) || !Object.hasOwn(meta, REWRITES_KEY)) return undefined
870
+ try {
871
+ return normalizeRewrites(meta[REWRITES_KEY])
872
+ } catch {
873
+ return undefined
874
+ }
875
+ }
876
+
877
+ /** Merge rewrite records into the tool result's private metadata. */
878
+ export function withRewrites(meta, rewrites) {
879
+ const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
880
+ base[REWRITES_KEY] = normalizeRewrites(rewrites)
881
+ return base
882
+ }
883
+
884
+ export function assertStateName(name) {
885
+ if (!validName(name)) {
886
+ throw new Error('REPL state name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}')
887
+ }
888
+ return name
889
+ }