dsh-ptc-plus 0.4.2 → 0.4.8
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.
- package/README.en.md +170 -76
- package/README.md +171 -77
- package/assets/dsh-ptc-plus-banner-en.webp +0 -0
- package/assets/dsh-ptc-plus-banner-zh.webp +0 -0
- package/assets/ptc-plus-icon.svg +5 -0
- package/client.js +599 -310
- package/compiler-core.cjs +162 -160
- package/compiler-core.cjs.map +3 -3
- package/docs/README.md +4 -1
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +49 -11
- package/docs/adr/0008-define-agent-operating-contract.md +2 -2
- package/docs/adr/0010-session-log-derived-recovery-tips.md +7 -0
- package/docs/adr/0016-archive-resolved-review-findings.md +3 -3
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +5 -1
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +3 -3
- package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +3 -1
- package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +14 -3
- package/docs/adr/0023-global-user-bindings.md +1 -1
- package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +23 -7
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +4 -0
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +26 -6
- package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
- package/docs/adr/0031-close-semantic-obligations-by-owner.md +29 -0
- package/docs/adr/README.md +4 -4
- package/docs/architecture.md +8 -5
- package/docs/binding-review-placement-design.md +1 -1
- package/docs/client-ui.md +8 -4
- package/docs/durability-design.md +7 -7
- package/docs/installation.md +48 -12
- package/docs/publishing.md +4 -4
- package/docs/release-notes.md +109 -0
- package/docs/runtime-reference.md +25 -12
- package/docs/semantic-validation.md +7 -3
- package/docs/user-bindings.en.md +7 -6
- package/docs/user-bindings.md +6 -5
- package/docs/value-wire.md +1 -1
- package/docs/verification.md +43 -2
- package/index.js +39 -34
- package/internal/async-local-storage-intrinsics.js +12 -7
- package/internal/callable-source-facts.js +37 -1
- package/internal/cell-analysis.js +64 -9
- package/internal/config-spec.js +1 -1
- package/internal/cordis-tools-owner.js +81 -15
- package/internal/direct-surface-owner.js +331 -7
- package/internal/edit-transport-owner.js +80 -20
- package/internal/execution-seam-compat.js +192 -30
- package/internal/kernel-worker.js +162 -72
- package/internal/message-sources.js +14 -0
- package/internal/module-compilation.js +2 -1
- package/internal/rejected-cell-editor.js +11 -21
- package/internal/repl-memory-projection.js +10 -0
- package/internal/repl-scope-normalizer.js +72 -35
- package/internal/repl-value-observer.js +141 -45
- package/internal/runtime-config.js +62 -2
- package/internal/runtime-intrinsics.js +3 -0
- package/internal/runtime-messages.js +6 -4
- package/internal/session-cell-executor.js +24 -1
- package/internal/session-journal-recovery.js +267 -67
- package/internal/session-journal.js +466 -40
- package/internal/session-log-view.js +5 -4
- package/internal/session-runtime.js +45 -21
- package/internal/session-state.js +7 -29
- package/internal/settings-compat.js +66 -1
- package/internal/source-position-map.js +19 -0
- package/internal/user-binding-console-worker.js +23 -9
- package/internal/user-binding-draft-projection.js +3 -2
- package/internal/user-bindings-owner.js +2 -1
- package/internal/user-bindings.js +6 -1
- package/internal/validated-parse-repair.js +6 -2
- package/internal/value-wire.js +95 -30
- package/internal/worker-client.js +99 -8
- package/internal/worker-cwd-virtualization.js +10 -9
- package/internal/worker-output-capture.js +187 -0
- package/internal/worker-output-fence.js +26 -0
- package/internal/worker-realm-surfaces.js +197 -0
- package/internal/worker-repl-realm.js +10 -4
- package/package.json +42 -4
- package/src/client-activity.js +4 -1
- package/src/client-authoring-view.js +121 -62
- package/src/client-console.js +4 -2
- package/src/client-copy.js +4 -2
- package/src/client-feature-gates.js +7 -9
- package/src/client-host-compat.js +104 -27
- package/src/client-repl-view.js +3 -1
- package/src/client-styles.js +12 -1
- package/src/client-tool-view.js +3 -1
- package/src/client-transport.js +2 -2
- package/src/client-workbench.js +10 -7
- package/src/client.js +56 -17
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
1
2
|
import { decodeValue, encodeValue, normalizeValueWire } from './value-wire.js'
|
|
2
3
|
import { normalizeDiagnostic } from './diagnostic.js'
|
|
3
4
|
import { assertOwnFields, isRecord } from './record-utils.js'
|
|
@@ -58,8 +59,10 @@ import {
|
|
|
58
59
|
JOURNAL_VERSIONS,
|
|
59
60
|
USER_BINDINGS_REUSE_POLICIES,
|
|
60
61
|
RECOVERY_BOUNDARY_FIELDS,
|
|
62
|
+
REPL_TOOL_NAMES,
|
|
61
63
|
REWRITE_FIELDS,
|
|
62
64
|
REWRITE_KINDS,
|
|
65
|
+
usesCallSequenceConfirms,
|
|
63
66
|
} from './session-journal-schema.js'
|
|
64
67
|
|
|
65
68
|
export {
|
|
@@ -82,12 +85,16 @@ function validName(name) {
|
|
|
82
85
|
return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
export function isCanonicalSequence(value) {
|
|
89
|
+
return Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
function normalizeCalls(value) {
|
|
86
93
|
if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal calls')
|
|
87
94
|
const calls = value.map((call, index) => {
|
|
88
95
|
if (!isRecord(call) || typeof call.global !== 'string' || typeof call.member !== 'string'
|
|
89
96
|
|| !Object.hasOwn(call, 'args') || (call.ok !== true && call.ok !== false)
|
|
90
|
-
|| !
|
|
97
|
+
|| !isCanonicalSequence(call.settle)) {
|
|
91
98
|
throw new Error(`invalid dsh-ptc-plus journal call at index ${index}`)
|
|
92
99
|
}
|
|
93
100
|
assertOwnFields(call, call.ok ? CALL_SUCCESS_FIELDS : CALL_ERROR_FIELDS, `journal call at index ${index}`)
|
|
@@ -170,7 +177,7 @@ function normalizeLegacyConfirms(value, resolveLegacyConfirm) {
|
|
|
170
177
|
throw new Error('legacy dsh-ptc-plus confirmed no-op calls require session call identity')
|
|
171
178
|
}
|
|
172
179
|
const confirms = value.map(callId => resolveLegacyConfirm(callId))
|
|
173
|
-
if (confirms.some(callSeq => !
|
|
180
|
+
if (confirms.some(callSeq => !isCanonicalSequence(callSeq))) {
|
|
174
181
|
throw new Error('legacy dsh-ptc-plus confirmed no-op call is not uniquely persisted')
|
|
175
182
|
}
|
|
176
183
|
if (new Set(confirms).size !== confirms.length) {
|
|
@@ -181,7 +188,7 @@ function normalizeLegacyConfirms(value, resolveLegacyConfirm) {
|
|
|
181
188
|
|
|
182
189
|
function normalizeConfirms(value) {
|
|
183
190
|
if (value === undefined) return []
|
|
184
|
-
if (!Array.isArray(value) || value.some(callSeq => !
|
|
191
|
+
if (!Array.isArray(value) || value.some(callSeq => !isCanonicalSequence(callSeq))) {
|
|
185
192
|
throw new Error('invalid dsh-ptc-plus confirmed no-op calls')
|
|
186
193
|
}
|
|
187
194
|
const confirms = [...new Set(value)]
|
|
@@ -386,7 +393,7 @@ export function normalizeJournal(value, options = {}) {
|
|
|
386
393
|
|
|
387
394
|
/** Validate the required persisted relation that makes one edit result executable history. */
|
|
388
395
|
export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
|
|
389
|
-
if (!
|
|
396
|
+
if (!isCanonicalSequence(expectedTargetCallSeq)) {
|
|
390
397
|
throw new Error('derived edit does not identify an eligible target call')
|
|
391
398
|
}
|
|
392
399
|
if (!isRecord(meta)) throw new Error('invalid dsh-ptc-plus derived edit metadata')
|
|
@@ -394,7 +401,8 @@ export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
|
|
|
394
401
|
const derived = meta[DERIVED_RUN_KEY]
|
|
395
402
|
if (!isRecord(target)) throw new Error('invalid dsh-ptc-plus edit target metadata')
|
|
396
403
|
assertOwnFields(target, EDIT_TARGET_FIELDS, 'edit target metadata')
|
|
397
|
-
if (target.targetCallSeq
|
|
404
|
+
if (!isCanonicalSequence(target.targetCallSeq)
|
|
405
|
+
|| target.targetCallSeq !== expectedTargetCallSeq) {
|
|
398
406
|
throw new Error('derived edit target does not match the eligible target call')
|
|
399
407
|
}
|
|
400
408
|
if (!isRecord(derived) || typeof derived.code !== 'string' || typeof derived.description !== 'string') {
|
|
@@ -488,13 +496,13 @@ export function recoveryBoundariesEqual(left, right) {
|
|
|
488
496
|
|
|
489
497
|
function normalizeRecoveryBoundaryValue(value, eventSeq = undefined) {
|
|
490
498
|
if (!isRecord(value)
|
|
491
|
-
|| !
|
|
499
|
+
|| !isCanonicalSequence(value.failedCallSeq)
|
|
492
500
|
|| (value.frontierCallSeq !== null
|
|
493
|
-
&&
|
|
501
|
+
&& !isCanonicalSequence(value.frontierCallSeq))) {
|
|
494
502
|
throw new Error('invalid dsh-ptc-plus recovery boundary')
|
|
495
503
|
}
|
|
496
504
|
assertOwnFields(value, RECOVERY_BOUNDARY_FIELDS, 'recovery boundary')
|
|
497
|
-
if (eventSeq !== undefined &&
|
|
505
|
+
if (eventSeq !== undefined && !isCanonicalSequence(eventSeq)) {
|
|
498
506
|
throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
|
|
499
507
|
}
|
|
500
508
|
return {
|
|
@@ -519,30 +527,399 @@ export function withRecoveryBoundaries(meta, boundaries) {
|
|
|
519
527
|
return base
|
|
520
528
|
}
|
|
521
529
|
|
|
530
|
+
const HOST_SEQUENCE_RELATION_POLICY_V0 = new Map([
|
|
531
|
+
['agent-preset/selected', null],
|
|
532
|
+
['agent/inbox/spliced', null],
|
|
533
|
+
['approval/asked', null],
|
|
534
|
+
['approval/decided', null],
|
|
535
|
+
['approval/policy', null],
|
|
536
|
+
['assistant/chunk', null],
|
|
537
|
+
['assistant/message', null],
|
|
538
|
+
['command/done', 'command-source'],
|
|
539
|
+
['command/run', null],
|
|
540
|
+
// Released format 0 also wrote the legacy spellings of the compaction events
|
|
541
|
+
// and a steering message that its own v0-to-v1 stage normalizes to
|
|
542
|
+
// `compaction/*` and `user/message`. They belong to the same frozen
|
|
543
|
+
// vocabulary, so the retired-boundary converter accepts them with their
|
|
544
|
+
// normalized relation semantics. `request/header-delta` and `mode/set` stay
|
|
545
|
+
// unsupported because that stage refuses them as well.
|
|
546
|
+
['compact/end', null],
|
|
547
|
+
['compact/prune', 'compaction-shadow'],
|
|
548
|
+
['compact/start', null],
|
|
549
|
+
['compact/summary', 'compaction-shadow'],
|
|
550
|
+
['compaction/end', null],
|
|
551
|
+
['compaction/prune', 'compaction-shadow'],
|
|
552
|
+
['compaction/start', null],
|
|
553
|
+
['compaction/summary', 'compaction-shadow'],
|
|
554
|
+
['feedback/record', null],
|
|
555
|
+
['goal/change', null],
|
|
556
|
+
['hook/invoked', null],
|
|
557
|
+
['hook/result', null],
|
|
558
|
+
['llm/retry', null],
|
|
559
|
+
['llm/retry-started', null],
|
|
560
|
+
['model/selection', null],
|
|
561
|
+
['permission/preset', null],
|
|
562
|
+
['plan/mode', null],
|
|
563
|
+
['request/context', null],
|
|
564
|
+
['request/header', null],
|
|
565
|
+
['sandbox/mode', null],
|
|
566
|
+
['schedule/change', null],
|
|
567
|
+
['session-log-deepseek/delivery-accepted', 'delivery-through'],
|
|
568
|
+
['session/end-seed', null],
|
|
569
|
+
['session/title', 'title-messages'],
|
|
570
|
+
['session/title-llm-request', 'title-request-messages'],
|
|
571
|
+
['step/end', null],
|
|
572
|
+
['step/start', null],
|
|
573
|
+
['steering/message', null],
|
|
574
|
+
['subagent/descriptor', null],
|
|
575
|
+
['subagent/model-selection-policy', null],
|
|
576
|
+
['team/member', null],
|
|
577
|
+
['team/message/delivered', null],
|
|
578
|
+
['team/message/queued', null],
|
|
579
|
+
['team/task', null],
|
|
580
|
+
['todo/write', null],
|
|
581
|
+
['tool-workflow/agent-end', null],
|
|
582
|
+
['tool-workflow/agent-start', null],
|
|
583
|
+
['tool-workflow/run-end', null],
|
|
584
|
+
['tool-workflow/run-start', null],
|
|
585
|
+
['tool/call', null],
|
|
586
|
+
['tool/code-dispatch', null],
|
|
587
|
+
['tool/code-dispatch-start', null],
|
|
588
|
+
['tool/result', null],
|
|
589
|
+
['turn/end', null],
|
|
590
|
+
['turn/start', null],
|
|
591
|
+
['user/message', null],
|
|
592
|
+
['web/deepseek-search-llm-request', null],
|
|
593
|
+
])
|
|
594
|
+
|
|
595
|
+
const V0_SURFACE_EVENT_TYPES = new Set([
|
|
596
|
+
'user/message', 'steering/message', 'assistant/message', 'tool/result',
|
|
597
|
+
])
|
|
598
|
+
const HOST_DATA_SEQUENCE_RELATIONS_V0 = new Map([
|
|
599
|
+
['command/done', new Set(['sourceEventSeq'])],
|
|
600
|
+
['compact/prune', new Set(['shadowedSeqs'])],
|
|
601
|
+
['compact/summary', new Set(['shadowedSeqs'])],
|
|
602
|
+
['compaction/prune', new Set(['shadowedSeqs'])],
|
|
603
|
+
['compaction/summary', new Set(['shadowedSeqs'])],
|
|
604
|
+
['session-log-deepseek/delivery-accepted', new Set(['throughSeq'])],
|
|
605
|
+
['session/title', new Set(['messageSeqs'])],
|
|
606
|
+
['session/title-llm-request', new Set(['messageSeqs'])],
|
|
607
|
+
])
|
|
608
|
+
const SEQUENCE_RELATION_FIELD = /Seqs?$/u
|
|
609
|
+
|
|
610
|
+
function validateKnownDataSequenceRelations(event) {
|
|
611
|
+
if (!isRecord(event.data)) return
|
|
612
|
+
const allowed = HOST_DATA_SEQUENCE_RELATIONS_V0.get(event.type) ?? new Set()
|
|
613
|
+
for (const field of Reflect.ownKeys(event.data)) {
|
|
614
|
+
if (typeof field === 'string' && SEQUENCE_RELATION_FIELD.test(field) && !allowed.has(field)) {
|
|
615
|
+
throw new Error(`unsupported ${event.type} data sequence relation ${JSON.stringify(field)}`)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function validateEarlierEventReference(events, event, seq, label) {
|
|
621
|
+
if (!isCanonicalSequence(seq) || seq >= event.seq || events[seq]?.seq !== seq) {
|
|
622
|
+
throw new Error(`invalid ${label} event reference ${JSON.stringify(seq)} at event ${event.seq}`)
|
|
623
|
+
}
|
|
624
|
+
return events[seq]
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function validateEarlierEventReferences(events, event, seqs, label) {
|
|
628
|
+
if (!Array.isArray(seqs)) throw new Error(`invalid ${label} event references`)
|
|
629
|
+
const seen = new Set()
|
|
630
|
+
return seqs.map(seq => {
|
|
631
|
+
if (seen.has(seq)) throw new Error(`duplicate ${label} event reference ${JSON.stringify(seq)} at event ${event.seq}`)
|
|
632
|
+
seen.add(seq)
|
|
633
|
+
return validateEarlierEventReference(events, event, seq, label)
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function sameSequences(left, right) {
|
|
638
|
+
return left.length === right.length && left.every((seq, index) => seq === right[index])
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function validateSurfaceRelation(events, event, surface) {
|
|
642
|
+
const eligible = V0_SURFACE_EVENT_TYPES.has(event.type)
|
|
643
|
+
if (!eligible) {
|
|
644
|
+
if (event.surfaceOp !== undefined || event.sourceEventSeqs !== undefined) {
|
|
645
|
+
throw new Error(`session event ${JSON.stringify(event.type)} is not surface-eligible`)
|
|
646
|
+
}
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
if (event.sourceEventSeqs !== undefined) {
|
|
650
|
+
validateEarlierEventReferences(events, event, event.sourceEventSeqs, 'source')
|
|
651
|
+
}
|
|
652
|
+
if (event.sourceEventSeqs?.length === 0 && event.type !== 'assistant/message') {
|
|
653
|
+
throw new Error('source event references must not be empty except on assistant/message')
|
|
654
|
+
}
|
|
655
|
+
const op = event.surfaceOp
|
|
656
|
+
// Historical host formats predate the mandatory append marker.
|
|
657
|
+
if (op === undefined || op === 'append') {
|
|
658
|
+
surface.push(event.seq)
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
if (!isRecord(op) || op.op !== 'replace'
|
|
662
|
+
|| Reflect.ownKeys(op).length !== 3
|
|
663
|
+
|| !isCanonicalSequence(op.start)
|
|
664
|
+
|| !isCanonicalSequence(op.end)) {
|
|
665
|
+
throw new Error('invalid surface operation during recovery-boundary migration')
|
|
666
|
+
}
|
|
667
|
+
validateEarlierEventReference(events, event, op.start, 'surface replacement start')
|
|
668
|
+
validateEarlierEventReference(events, event, op.end, 'surface replacement end')
|
|
669
|
+
const start = surface.indexOf(op.start)
|
|
670
|
+
const end = surface.indexOf(op.end)
|
|
671
|
+
if (start < 0 || end < start) {
|
|
672
|
+
throw new Error(`surface replacement at event ${event.seq} does not identify a current ordered surface range`)
|
|
673
|
+
}
|
|
674
|
+
const shadowed = surface.slice(start, end + 1)
|
|
675
|
+
const provenance = new Set(event.sourceEventSeqs ?? [])
|
|
676
|
+
if (shadowed.some(seq => !provenance.has(seq))) {
|
|
677
|
+
throw new Error(`surface replacement at event ${event.seq} does not cite every replaced surface node`)
|
|
678
|
+
}
|
|
679
|
+
if (event.type === 'tool/result'
|
|
680
|
+
&& (shadowed.length !== 1 || events[shadowed[0]]?.type !== 'tool/result')) {
|
|
681
|
+
throw new Error('tool/result surface replacement must target one current tool/result')
|
|
682
|
+
}
|
|
683
|
+
if (event.type === 'tool/result') {
|
|
684
|
+
const withoutContent = candidate => {
|
|
685
|
+
const data = candidate?.data
|
|
686
|
+
const message = data?.message
|
|
687
|
+
const content = message?.content
|
|
688
|
+
if (!isRecord(data) || !isRecord(message) || !Array.isArray(content)
|
|
689
|
+
|| content.length !== 1 || !isRecord(content[0])) {
|
|
690
|
+
throw new Error('tool/result surface replacement has invalid message content')
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
...data,
|
|
694
|
+
message: { ...message, content: [{ ...content[0], content: null }] },
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (!isDeepStrictEqual(withoutContent(events[shadowed[0]]), withoutContent(event))) {
|
|
698
|
+
throw new Error('tool/result surface replacement may change only content')
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
surface.splice(start, shadowed.length, event.seq)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function validateCommandSource(events, event) {
|
|
705
|
+
if (!isRecord(event.data)) {
|
|
706
|
+
throw new Error(`command/done event ${event.seq} has invalid data`)
|
|
707
|
+
}
|
|
708
|
+
if (event.data.sourceEventSeq === undefined) return
|
|
709
|
+
if (!isRecord(event.data) || event.data.kind !== 'success') {
|
|
710
|
+
throw new Error(`command/done event ${event.seq} has a source outside a successful result`)
|
|
711
|
+
}
|
|
712
|
+
const source = validateEarlierEventReference(events, event, event.data.sourceEventSeq, 'command source')
|
|
713
|
+
if (source.type === 'command/run' || source.type === 'command/done') {
|
|
714
|
+
throw new Error(`command/done event ${event.seq} cites a command lifecycle event as its source`)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function validateTitleRequestSources(events, event) {
|
|
719
|
+
if (!isRecord(event.data) || !Array.isArray(event.data.messageSeqs)) {
|
|
720
|
+
throw new Error('invalid session/title-llm-request message references')
|
|
721
|
+
}
|
|
722
|
+
const sources = validateEarlierEventReferences(
|
|
723
|
+
events, event, event.data.messageSeqs, 'session title request message',
|
|
724
|
+
)
|
|
725
|
+
if (sources.some(source => source.type !== 'user/message')) {
|
|
726
|
+
throw new Error(`session/title-llm-request event ${event.seq} must cite earlier user/message events`)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function validateDeliveryThrough(events, event) {
|
|
731
|
+
if (!isRecord(event.data)) {
|
|
732
|
+
throw new Error(`session-log-deepseek/delivery-accepted event ${event.seq} has invalid data`)
|
|
733
|
+
}
|
|
734
|
+
validateEarlierEventReference(events, event, event.data.throughSeq, 'session delivery through')
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function validateTitleSources(events, event) {
|
|
738
|
+
if (!isRecord(event.data) || !Array.isArray(event.data.messageSeqs)) {
|
|
739
|
+
throw new Error('invalid session/title message references')
|
|
740
|
+
}
|
|
741
|
+
if ((event.data.messageSeqs.length === 0) !== (event.data.source?.kind === 'user')) {
|
|
742
|
+
const requirement = event.data.source?.kind === 'user'
|
|
743
|
+
? 'cite no message seqs'
|
|
744
|
+
: 'cite at least one message seq'
|
|
745
|
+
throw new Error(`session/title event ${event.seq} must ${requirement}`)
|
|
746
|
+
}
|
|
747
|
+
const sources = validateEarlierEventReferences(events, event, event.data.messageSeqs, 'session title message')
|
|
748
|
+
if (sources.some(source => source.type !== 'user/message' || source.data?.source?.kind !== 'user')) {
|
|
749
|
+
throw new Error(`session/title event ${event.seq} must cite earlier human user/message events`)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function validateCompactionSources(events, event, surface) {
|
|
754
|
+
if (!isRecord(event.data) || !Array.isArray(event.data.shadowedSeqs)) {
|
|
755
|
+
throw new Error(`invalid ${event.type} shadow references`)
|
|
756
|
+
}
|
|
757
|
+
const shadowed = event.data.shadowedSeqs
|
|
758
|
+
validateEarlierEventReferences(events, event, shadowed, 'compaction shadow')
|
|
759
|
+
if (event.data.shadowedRange === undefined) {
|
|
760
|
+
if (shadowed.length === 0) return
|
|
761
|
+
const start = surface.indexOf(shadowed[0])
|
|
762
|
+
if (start < 0 || !sameSequences(surface.slice(start, start + shadowed.length), shadowed)) {
|
|
763
|
+
throw new Error(`${event.type} event ${event.seq} shadow list does not match the current surface`)
|
|
764
|
+
}
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
const range = event.data.shadowedRange
|
|
768
|
+
if (!isRecord(range)) throw new Error(`invalid ${event.type} shadow range`)
|
|
769
|
+
validateEarlierEventReference(events, event, range.start, 'compaction range start')
|
|
770
|
+
validateEarlierEventReference(events, event, range.end, 'compaction range end')
|
|
771
|
+
const start = surface.indexOf(range.start)
|
|
772
|
+
const end = surface.indexOf(range.end)
|
|
773
|
+
if (start < 0 || end < start
|
|
774
|
+
|| !sameSequences(surface.slice(start, end + 1), shadowed)) {
|
|
775
|
+
throw new Error(`${event.type} event ${event.seq} shadow range does not match the current surface`)
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function validateHostSequenceRelations(events) {
|
|
780
|
+
const surface = []
|
|
781
|
+
for (const event of events) {
|
|
782
|
+
if (event.type === RECOVERY_BOUNDARY_EVENT) continue
|
|
783
|
+
if (!HOST_SEQUENCE_RELATION_POLICY_V0.has(event.type)) {
|
|
784
|
+
throw new Error(`unsupported session event type ${JSON.stringify(event.type)} during recovery-boundary migration`)
|
|
785
|
+
}
|
|
786
|
+
validateKnownDataSequenceRelations(event)
|
|
787
|
+
validateSurfaceRelation(events, event, surface)
|
|
788
|
+
const relation = HOST_SEQUENCE_RELATION_POLICY_V0.get(event.type)
|
|
789
|
+
if (relation === 'command-source') validateCommandSource(events, event)
|
|
790
|
+
else if (relation === 'title-messages') validateTitleSources(events, event)
|
|
791
|
+
else if (relation === 'title-request-messages') validateTitleRequestSources(events, event)
|
|
792
|
+
else if (relation === 'delivery-through') validateDeliveryThrough(events, event)
|
|
793
|
+
else if (relation === 'compaction-shadow') validateCompactionSources(events, event, surface)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function rewriteHostSequenceRelations(event, mapEvent) {
|
|
798
|
+
const relation = HOST_SEQUENCE_RELATION_POLICY_V0.get(event.type)
|
|
799
|
+
if (Object.hasOwn(event, 'sourceEventSeqs')) {
|
|
800
|
+
event.sourceEventSeqs = event.sourceEventSeqs.map(seq => mapEvent(seq, 'source'))
|
|
801
|
+
}
|
|
802
|
+
if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') {
|
|
803
|
+
event.surfaceOp = {
|
|
804
|
+
...event.surfaceOp,
|
|
805
|
+
start: mapEvent(event.surfaceOp.start, 'surface replacement start'),
|
|
806
|
+
end: mapEvent(event.surfaceOp.end, 'surface replacement end'),
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (relation === 'command-source') {
|
|
810
|
+
if (event.data.sourceEventSeq !== undefined) {
|
|
811
|
+
event.data = {
|
|
812
|
+
...event.data,
|
|
813
|
+
sourceEventSeq: mapEvent(event.data.sourceEventSeq, 'command source'),
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
} else if (relation === 'title-messages') {
|
|
817
|
+
event.data = {
|
|
818
|
+
...event.data,
|
|
819
|
+
messageSeqs: event.data.messageSeqs.map(seq => mapEvent(seq, 'session title message')),
|
|
820
|
+
}
|
|
821
|
+
} else if (relation === 'title-request-messages') {
|
|
822
|
+
const mapped = event.data.messageSeqs.map(seq => mapEvent(seq, 'session title request message'))
|
|
823
|
+
if (mapped.some((seq, index) => seq !== event.data.messageSeqs[index])) {
|
|
824
|
+
throw new Error('session/title-llm-request messageSeqs cannot be preserved across removed events')
|
|
825
|
+
}
|
|
826
|
+
} else if (relation === 'delivery-through') {
|
|
827
|
+
event.data = {
|
|
828
|
+
...event.data,
|
|
829
|
+
throughSeq: mapEvent(event.data.throughSeq, 'session delivery through'),
|
|
830
|
+
}
|
|
831
|
+
} else if (relation === 'compaction-shadow') {
|
|
832
|
+
const data = {
|
|
833
|
+
...event.data,
|
|
834
|
+
shadowedSeqs: event.data.shadowedSeqs.map(seq => mapEvent(seq, 'compaction shadow')),
|
|
835
|
+
}
|
|
836
|
+
if (event.data.shadowedRange !== undefined) {
|
|
837
|
+
data.shadowedRange = {
|
|
838
|
+
...event.data.shadowedRange,
|
|
839
|
+
start: mapEvent(event.data.shadowedRange.start, 'compaction range start'),
|
|
840
|
+
end: mapEvent(event.data.shadowedRange.end, 'compaction range end'),
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
event.data = data
|
|
844
|
+
}
|
|
845
|
+
return event
|
|
846
|
+
}
|
|
847
|
+
|
|
522
848
|
/**
|
|
523
849
|
* Convert the retired custom boundary event in a raw log before DSH restores it.
|
|
524
850
|
* The returned log is a detached, renumbered copy; the input is never mutated.
|
|
525
851
|
*/
|
|
526
852
|
export function migrateRecoveryBoundaryEvents(events) {
|
|
527
853
|
if (!Array.isArray(events)) throw new TypeError('recovery-boundary migration expects an event array')
|
|
854
|
+
const sequenceMap = new Map()
|
|
855
|
+
let nextSeq = 0
|
|
856
|
+
for (const [index, event] of events.entries()) {
|
|
857
|
+
if (!isCanonicalSequence(event?.seq)) {
|
|
858
|
+
throw new Error('invalid session event sequence during recovery-boundary migration')
|
|
859
|
+
}
|
|
860
|
+
if (sequenceMap.has(event.seq)) {
|
|
861
|
+
throw new Error(`duplicate session event sequence ${event.seq} during recovery-boundary migration`)
|
|
862
|
+
}
|
|
863
|
+
if (event.seq !== index) {
|
|
864
|
+
throw new Error(`session event sequence ${event.seq} at index ${index} is not contiguous from zero`)
|
|
865
|
+
}
|
|
866
|
+
const retainedSeq = event.type === RECOVERY_BOUNDARY_EVENT ? undefined : nextSeq++
|
|
867
|
+
sequenceMap.set(event.seq, { event, retainedSeq })
|
|
868
|
+
}
|
|
869
|
+
validateHostSequenceRelations(events)
|
|
870
|
+
const mapEvent = (seq, label) => {
|
|
871
|
+
if (!isCanonicalSequence(seq)) throw new Error(`invalid ${label} event reference`)
|
|
872
|
+
const target = sequenceMap.get(seq)
|
|
873
|
+
if (target === undefined || target.retainedSeq === undefined) {
|
|
874
|
+
throw new Error(`unmapped ${label} event reference ${seq}`)
|
|
875
|
+
}
|
|
876
|
+
return target.retainedSeq
|
|
877
|
+
}
|
|
878
|
+
const mapCall = (seq, resultSeq, label) => {
|
|
879
|
+
const target = sequenceMap.get(seq)
|
|
880
|
+
if (!isCanonicalSequence(seq) || target === undefined
|
|
881
|
+
|| target.retainedSeq === undefined || target.event?.type !== 'tool/call'
|
|
882
|
+
|| !REPL_TOOL_NAMES.has(target.event.data?.name) || seq >= resultSeq) {
|
|
883
|
+
throw new Error(`unproved ${label} call reference ${JSON.stringify(seq)} at event ${resultSeq}`)
|
|
884
|
+
}
|
|
885
|
+
return target.retainedSeq
|
|
886
|
+
}
|
|
887
|
+
const mapBoundary = (boundary, resultSeq) => ({
|
|
888
|
+
failedCallSeq: mapCall(boundary.failedCallSeq, resultSeq, 'recovery-boundary failure'),
|
|
889
|
+
frontierCallSeq: boundary.frontierCallSeq === null ? null : (() => {
|
|
890
|
+
if (boundary.frontierCallSeq >= boundary.failedCallSeq) {
|
|
891
|
+
throw new Error('recovery-boundary frontier must precede its failed call')
|
|
892
|
+
}
|
|
893
|
+
return mapCall(boundary.frontierCallSeq, resultSeq, 'recovery-boundary frontier')
|
|
894
|
+
})(),
|
|
895
|
+
})
|
|
528
896
|
const migrated = []
|
|
529
897
|
const pending = []
|
|
898
|
+
const boundaryOrigins = new Map()
|
|
530
899
|
for (const event of events) {
|
|
531
900
|
if (event?.type === RECOVERY_BOUNDARY_EVENT) {
|
|
532
|
-
|
|
533
|
-
throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
|
|
534
|
-
}
|
|
535
|
-
pending.push(normalizeRecoveryBoundaryValue(event.data))
|
|
901
|
+
pending.push({ boundary: normalizeRecoveryBoundaryValue(event.data), eventSeq: event.seq })
|
|
536
902
|
continue
|
|
537
903
|
}
|
|
538
904
|
const detached = cloneJson(event)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
905
|
+
const sourceSeqs = detached?.sourceEventSeqs
|
|
906
|
+
const source = Array.isArray(sourceSeqs) && sourceSeqs.length === 1
|
|
907
|
+
? sequenceMap.get(sourceSeqs[0]) : undefined
|
|
908
|
+
const carriesJournal = detached?.type === 'tool/result'
|
|
909
|
+
&& isRecord(detached.data?.meta) && Object.hasOwn(detached.data.meta, JOURNAL_KEY)
|
|
910
|
+
&& source?.retainedSeq !== undefined && source.event?.type === 'tool/call'
|
|
911
|
+
&& REPL_TOOL_NAMES.has(source.event.data?.name) && source.event.seq < event.seq
|
|
912
|
+
if (pending.length > 0 && carriesJournal) {
|
|
913
|
+
const data = { ...detached.data }
|
|
914
|
+
const meta = { ...data.meta }
|
|
542
915
|
const existing = meta[RECOVERY_BOUNDARY_KEY] === undefined
|
|
543
916
|
? []
|
|
544
917
|
: normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
|
|
545
|
-
meta[RECOVERY_BOUNDARY_KEY] = [...
|
|
918
|
+
meta[RECOVERY_BOUNDARY_KEY] = [...pending.map(item => item.boundary), ...existing]
|
|
919
|
+
boundaryOrigins.set(event.seq, {
|
|
920
|
+
pendingCount: pending.length,
|
|
921
|
+
pendingEventSeqs: pending.map(item => item.eventSeq),
|
|
922
|
+
})
|
|
546
923
|
data.meta = meta
|
|
547
924
|
detached.data = data
|
|
548
925
|
pending.length = 0
|
|
@@ -550,19 +927,53 @@ export function migrateRecoveryBoundaryEvents(events) {
|
|
|
550
927
|
migrated.push(detached)
|
|
551
928
|
}
|
|
552
929
|
if (pending.length > 0) {
|
|
553
|
-
throw new Error('recovery boundary has no later
|
|
930
|
+
throw new Error('recovery boundary has no later PTC journal result for migration')
|
|
554
931
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
932
|
+
return Object.freeze(migrated.map((event) => {
|
|
933
|
+
const oldSeq = event.seq
|
|
934
|
+
rewriteHostSequenceRelations(event, mapEvent)
|
|
935
|
+
if (event.type === 'tool/result' && isRecord(event.data?.meta)) {
|
|
936
|
+
const meta = event.data.meta
|
|
937
|
+
const journal = meta[JOURNAL_KEY]
|
|
938
|
+
if (usesCallSequenceConfirms(journal) && journal.confirms !== undefined) {
|
|
939
|
+
if (!Array.isArray(journal.confirms)) throw new Error('invalid dsh-ptc-plus confirmed no-op calls')
|
|
940
|
+
journal.confirms = journal.confirms.map(seq => mapCall(seq, oldSeq, 'journal confirmation'))
|
|
941
|
+
}
|
|
942
|
+
if (meta[EDIT_TARGET_KEY] !== undefined) {
|
|
943
|
+
if (!isRecord(meta[EDIT_TARGET_KEY])) throw new Error('invalid dsh-ptc-plus edit target')
|
|
944
|
+
meta[EDIT_TARGET_KEY].targetCallSeq = mapCall(
|
|
945
|
+
meta[EDIT_TARGET_KEY].targetCallSeq, oldSeq, 'edit target',
|
|
946
|
+
)
|
|
947
|
+
}
|
|
948
|
+
if (meta[RECOVERY_BOUNDARY_KEY] !== undefined) {
|
|
949
|
+
const origins = boundaryOrigins.get(oldSeq)
|
|
950
|
+
meta[RECOVERY_BOUNDARY_KEY] = normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
|
|
951
|
+
.map((boundary, index) => mapBoundary(boundary,
|
|
952
|
+
origins !== undefined && index < origins.pendingCount
|
|
953
|
+
? origins.pendingEventSeqs[index]
|
|
954
|
+
: oldSeq))
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
event.seq = sequenceMap.get(oldSeq).retainedSeq
|
|
958
|
+
return Object.freeze(event)
|
|
959
|
+
}))
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function currentAssistantCallSeq(events, candidates, callId, toolName) {
|
|
963
|
+
let assistantIndex = events.length - 1
|
|
964
|
+
while (assistantIndex >= 0 && events[assistantIndex]?.type !== 'assistant/message') {
|
|
965
|
+
assistantIndex -= 1
|
|
558
966
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
967
|
+
if (assistantIndex < 0) return undefined
|
|
968
|
+
const content = events[assistantIndex].data?.message?.content
|
|
969
|
+
if (!Array.isArray(content)) return undefined
|
|
970
|
+
const blocks = content.filter(part => part?.type === 'tool-call'
|
|
971
|
+
&& part.id === callId && part.name === toolName)
|
|
972
|
+
if (blocks.length !== 1) return undefined
|
|
973
|
+
const eventIndexes = new Map(events.map((event, index) => [event, index]))
|
|
974
|
+
const current = candidates.filter(event => eventIndexes.get(event) > assistantIndex
|
|
975
|
+
&& event.data.arguments === blocks[0].arguments)
|
|
976
|
+
return current.length === 1 ? current[0].seq : undefined
|
|
566
977
|
}
|
|
567
978
|
|
|
568
979
|
/** Resolve the persisted event identity for one named tool call being dispatched. */
|
|
@@ -571,27 +982,42 @@ export function liveToolCallSeq(session, callId, toolName) {
|
|
|
571
982
|
if (!Array.isArray(events) || typeof callId !== 'string' || callId.length === 0
|
|
572
983
|
|| typeof toolName !== 'string' || toolName.length === 0) return undefined
|
|
573
984
|
|
|
574
|
-
const
|
|
985
|
+
const pendingCalls = new Map()
|
|
575
986
|
for (const event of events) {
|
|
576
|
-
if (event?.type
|
|
577
|
-
|
|
578
|
-
|
|
987
|
+
if (event?.type === 'tool/call' && event.data?.callId === callId) {
|
|
988
|
+
if (event.data.name === toolName && !isCanonicalSequence(event.seq)) {
|
|
989
|
+
throw new Error(`current ${toolName} call has an invalid session event sequence`)
|
|
990
|
+
}
|
|
991
|
+
if (isCanonicalSequence(event.seq)) pendingCalls.set(event.seq, event)
|
|
992
|
+
continue
|
|
993
|
+
}
|
|
994
|
+
if (event?.type !== 'tool/result') continue
|
|
995
|
+
const sourceRelation = event.sourceEventSeqs
|
|
996
|
+
const canonicalSourceRelation = Array.isArray(sourceRelation)
|
|
997
|
+
&& sourceRelation.length === 1
|
|
998
|
+
&& isCanonicalSequence(sourceRelation[0])
|
|
999
|
+
if (canonicalSourceRelation) {
|
|
1000
|
+
pendingCalls.delete(sourceRelation[0])
|
|
1001
|
+
continue
|
|
1002
|
+
}
|
|
1003
|
+
// A damaged result cannot recover state, but its ordered call identity can
|
|
1004
|
+
// still prove that the sole preceding same-id call is no longer live.
|
|
1005
|
+
if (event.data?.message?.source?.callId === callId
|
|
1006
|
+
&& pendingCalls.size === 1) {
|
|
1007
|
+
pendingCalls.clear()
|
|
579
1008
|
}
|
|
580
1009
|
}
|
|
581
1010
|
|
|
582
|
-
const candidates = []
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if (
|
|
587
|
-
throw new Error(`current ${toolName} call has an invalid session event sequence`)
|
|
588
|
-
}
|
|
589
|
-
candidates.push(event.seq)
|
|
1011
|
+
const candidates = [...pendingCalls.values()]
|
|
1012
|
+
.filter(event => event.data?.name === toolName)
|
|
1013
|
+
if (candidates.length > 1) {
|
|
1014
|
+
const current = currentAssistantCallSeq(events, candidates, callId, toolName)
|
|
1015
|
+
if (current !== undefined) return current
|
|
590
1016
|
}
|
|
591
1017
|
if (candidates.length > 1) {
|
|
592
1018
|
throw new Error(`session log contains multiple unpaired ${toolName} calls for callId ${JSON.stringify(callId)}`)
|
|
593
1019
|
}
|
|
594
|
-
return candidates[0]
|
|
1020
|
+
return candidates[0]?.seq
|
|
595
1021
|
}
|
|
596
1022
|
|
|
597
1023
|
/** Start a mutable journal for one live cell. */
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
foldSessionTimeline,
|
|
3
3
|
} from './session-journal-recovery.js'
|
|
4
|
+
import { isCanonicalSequence } from './session-journal.js'
|
|
4
5
|
import { sessionEvents } from './session-events.js'
|
|
5
6
|
import { readRuntimeMessage } from './runtime-messages.js'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
import { isHostRuntimeContextSource } from './message-sources.js'
|
|
8
9
|
const SYSTEM_PROMPT_CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
|
|
9
10
|
|
|
10
11
|
/** DSH's empty aggregate uses an exact owner marker without a snapshot form. */
|
|
11
12
|
export function systemPromptSnapshotSections(message) {
|
|
12
13
|
const source = message?.source
|
|
13
|
-
if (source
|
|
14
|
+
if (!isHostRuntimeContextSource(source)) return undefined
|
|
14
15
|
if (source.form === undefined && Array.isArray(message.content) && message.content.length === 1
|
|
15
16
|
&& message.content[0]?.type === 'text' && message.content[0].text === SYSTEM_PROMPT_CLEARED) {
|
|
16
17
|
return Object.freeze([])
|
|
@@ -101,7 +102,7 @@ export function projectSessionLog(agent, requestedEdit) {
|
|
|
101
102
|
try {
|
|
102
103
|
const nodes = agent?.session?.surface?.nodes
|
|
103
104
|
const knownSeqs = new Set(events.map(event => event?.seq))
|
|
104
|
-
if (Array.isArray(nodes) && nodes.every(seq =>
|
|
105
|
+
if (Array.isArray(nodes) && nodes.every(seq => isCanonicalSequence(seq) && knownSeqs.has(seq))) {
|
|
105
106
|
visibleRuntimeMessages = Object.freeze(nodes.flatMap(seq => {
|
|
106
107
|
const record = runtimeMessagesBySeq.get(seq)
|
|
107
108
|
return record === undefined ? [] : [record]
|
|
@@ -126,6 +127,6 @@ export function projectSessionLog(agent, requestedEdit) {
|
|
|
126
127
|
/** Return the target snapshot captured at one persisted edit call event. */
|
|
127
128
|
export function editTargetForCall(agent, callId, callSeq) {
|
|
128
129
|
if (typeof callId !== 'string' || callId.length === 0
|
|
129
|
-
|| !
|
|
130
|
+
|| !isCanonicalSequence(callSeq)) return undefined
|
|
130
131
|
return projectSessionLog(agent, { callId, callSeq }).requestedEditTarget
|
|
131
132
|
}
|