dsh-retrace 0.4.26 → 0.4.28
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.md +92 -11
- package/README.zh.md +20 -7
- package/bin/retrace.mjs +1 -1
- package/lib/adapter/contract.js +1 -1
- package/lib/adapter/dsh-writer.js +89 -36
- package/lib/adapter/dsh.js +7 -30
- package/lib/archaeology-cli.js +1 -1
- package/lib/badge.js +1 -1
- package/lib/boundary-derive.js +107 -0
- package/lib/boundary-now.js +139 -0
- package/lib/boundary-tree.js +126 -0
- package/lib/boundary-what.js +140 -0
- package/lib/client.bundle.js +1477 -498
- package/lib/client.js +2085 -565
- package/lib/close-guard.js +4 -1
- package/lib/dynamic-client.js +1477 -498
- package/lib/dynamic-host.js +458 -75
- package/lib/forkmap.js +15 -9
- package/lib/host-compat.js +231 -0
- package/lib/host-core.js +41 -17
- package/lib/http.js +133 -14
- package/lib/identity/shortcode.js +643 -0
- package/lib/index.js +71 -30
- package/lib/interrupt-guard.js +5 -4
- package/lib/llm-summary.js +289 -0
- package/lib/marker-carrier.js +98 -21
- package/lib/migration-traces.js +1 -1
- package/lib/platform/session-paths.js +61 -1
- package/lib/prewrite-guard.js +47 -4
- package/lib/projection/forkmap.js +9 -2
- package/lib/projection/versions.js +4 -0
- package/lib/rollback.js +2 -1
- package/lib/session-adapter.js +9 -6
- package/lib/summary-gate.js +160 -0
- package/lib/summary-store.js +152 -0
- package/lib/version-index.js +58 -8
- package/lib/versioning.js +197 -2
- package/lib/watchdog.js +6 -2
- package/package.json +2 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/boundary-now.js
|
|
3
|
+
*
|
|
4
|
+
* READ-SIDE "现在这条" (the counterpart that lives in the conversation TODAY).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists (real-machine reading order, 2026-09-15): an entry could say
|
|
7
|
+
* WHAT it replaced ("原来的内容:…") but never WHERE it was replaced FROM. The
|
|
8
|
+
* user's words: 「这个条目,现在是什么我并不知道」. The anchor is the message the
|
|
9
|
+
* action left behind — for an edit/resend it is OUR own `retrace-resend-*` node
|
|
10
|
+
* (measured: boundary #8699 → #8706), for a regenerate it is the assistant reply
|
|
11
|
+
* the host produced afterwards.
|
|
12
|
+
*
|
|
13
|
+
* What this module deliberately does NOT do: reconstruct the round's input. The
|
|
14
|
+
* conversation itself is readable — only the REPLACED copy is ours to keep.
|
|
15
|
+
*
|
|
16
|
+
* Rules (measured on a 27k-event / 34-boundary session):
|
|
17
|
+
* - only `edit` / `regenerate` have a counterpart. A pure `recall` retracts and
|
|
18
|
+
* is followed by later, unrelated input — picking that would be a lie.
|
|
19
|
+
* - the counterpart must be a LIVE node: anything a later boundary already
|
|
20
|
+
* replaced (`shadowedSeqs`) is excluded, otherwise the entry would point at
|
|
21
|
+
* a message that is no longer on the surface.
|
|
22
|
+
* - the boundary's OWN carrier / audit events are never the counterpart: they
|
|
23
|
+
* are the marker, not the new content.
|
|
24
|
+
*
|
|
25
|
+
* Pure (no I/O, no host access): the caller passes the log array and the shadowed
|
|
26
|
+
* set, so the seam, the HTTP route and the tests all drive the same rule.
|
|
27
|
+
*/
|
|
28
|
+
import { eventText, excerpt, roleOf } from './boundary-what.js'
|
|
29
|
+
import { isReplacementSurfaceEvent, replacedSeqsOfBoundary } from './version-index.js'
|
|
30
|
+
|
|
31
|
+
/** Our own resend node: written by the edit/resend op. */
|
|
32
|
+
export const RESEND_ID_PREFIX = 'retrace-resend-'
|
|
33
|
+
|
|
34
|
+
/** Any id we write (marker / carrier / resend / fold) — never a counterpart. */
|
|
35
|
+
const OUR_ID_PREFIX = 'retrace-'
|
|
36
|
+
|
|
37
|
+
/** `<id>` of a surface event, whichever slot the host used. */
|
|
38
|
+
function idOf(event) {
|
|
39
|
+
const id = event?.data?.id ?? event?.data?.message?.id
|
|
40
|
+
return typeof id === 'string' ? id : ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One counterpart candidate as the reader needs it (no raw seq shown). */
|
|
44
|
+
function nowOf(event) {
|
|
45
|
+
return {
|
|
46
|
+
seq: event.seq,
|
|
47
|
+
role: roleOf(event),
|
|
48
|
+
excerpt: excerpt(eventText(event)),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Index the log once: the counterpart candidates, in ascending seq order.
|
|
54
|
+
*
|
|
55
|
+
* @param {object[]} events - the session's events (ascending seq)
|
|
56
|
+
* @param {Set<number>} [shadowedSeqs] - seqs replaced by SOME boundary (ours or
|
|
57
|
+
* the host's). Those are off the surface and cannot be "现在这条".
|
|
58
|
+
* @returns {{resends: object[], replies: object[]}}
|
|
59
|
+
*/
|
|
60
|
+
export function buildNowIndex(events, shadowedSeqs = new Set()) {
|
|
61
|
+
const list = Array.isArray(events) ? events : []
|
|
62
|
+
const shadowed = shadowedSeqs instanceof Set ? shadowedSeqs : new Set()
|
|
63
|
+
const resends = []
|
|
64
|
+
const replies = []
|
|
65
|
+
for (const event of list) {
|
|
66
|
+
const seq = event?.seq
|
|
67
|
+
if (!Number.isSafeInteger(seq) || shadowed.has(seq)) continue
|
|
68
|
+
const id = idOf(event)
|
|
69
|
+
if (id.startsWith(RESEND_ID_PREFIX)) {
|
|
70
|
+
resends.push(nowOf(event))
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
// A regenerate's counterpart is the host's new assistant reply. Our own
|
|
74
|
+
// `retrace-*` nodes are markers, never the reply itself.
|
|
75
|
+
if (event.type === 'assistant/message' && !id.startsWith(OUR_ID_PREFIX)) replies.push(nowOf(event))
|
|
76
|
+
}
|
|
77
|
+
resends.sort((a, b) => a.seq - b.seq)
|
|
78
|
+
replies.sort((a, b) => a.seq - b.seq)
|
|
79
|
+
return { resends, replies }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The counterpart of ONE boundary, or null when there is honestly none.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} kind - `classifyBoundaryKind` result (`edit` / `regenerate` /
|
|
86
|
+
* `recall` / `compaction` / `replace`)
|
|
87
|
+
* @param {number} boundarySeq
|
|
88
|
+
* @param {{resends: object[], replies: object[]}} index - from `buildNowIndex`
|
|
89
|
+
* @returns {{seq:number, role:string, excerpt:string}|null}
|
|
90
|
+
*/
|
|
91
|
+
export function nowOfBoundary(kind, boundarySeq, index) {
|
|
92
|
+
if (kind !== 'edit' && kind !== 'regenerate') return null
|
|
93
|
+
if (!Number.isSafeInteger(boundarySeq) || index === null || index === undefined) return null
|
|
94
|
+
const candidates = kind === 'edit' ? index.resends : index.replies
|
|
95
|
+
if (!Array.isArray(candidates)) return null
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
if (Number.isSafeInteger(candidate?.seq) && candidate.seq > boundarySeq) return candidate
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every seq that some SURFACE REPLACEMENT took off the surface (ours AND the
|
|
104
|
+
* host's). Used to keep a counterpart from pointing at a message that is no
|
|
105
|
+
* longer live.
|
|
106
|
+
*
|
|
107
|
+
* Scope = THIS PLUGIN's own replacements (`retrace-*` markers). Two things stay
|
|
108
|
+
* out, both measured on the real session:
|
|
109
|
+
*
|
|
110
|
+
* - ordinary events that merely CITE their precursors (`tool/result` → its
|
|
111
|
+
* `tool/call`). Counting those marked 14644 seqs "shadowed" instead of 107.
|
|
112
|
+
* - other surface owners' bulk operations (another plugin's fold ranges, the
|
|
113
|
+
* host's compaction checkpoints). They hide ranges from the VIEW/CONTEXT
|
|
114
|
+
* while the messages stay in the log. Counting them made every one of the 23
|
|
115
|
+
* real edits report "the counterpart is no longer in the log", including the
|
|
116
|
+
* case this reading order was specified from: boundary #8699 → the resend
|
|
117
|
+
* node #8706.
|
|
118
|
+
*
|
|
119
|
+
* Our own later replacement is different: the marker stands in that node's place
|
|
120
|
+
* for good, so that node can never be "现在这条" again.
|
|
121
|
+
*
|
|
122
|
+
* @param {object[]} events
|
|
123
|
+
* @param {(event:object)=>boolean} [isOurs] - id predicate for this plugin's
|
|
124
|
+
* markers (defaults to the `retrace-` prefix).
|
|
125
|
+
* @returns {Set<number>}
|
|
126
|
+
*/
|
|
127
|
+
export function shadowedSeqsOf(events, isOurs = null) {
|
|
128
|
+
const owns = typeof isOurs === 'function' ? isOurs : (event) => idOf(event).startsWith(OUR_ID_PREFIX)
|
|
129
|
+
const shadowed = new Set()
|
|
130
|
+
if (!Array.isArray(events)) return shadowed
|
|
131
|
+
for (const event of events) {
|
|
132
|
+
if (!isReplacementSurfaceEvent(event)) continue
|
|
133
|
+
if (!owns(event)) continue
|
|
134
|
+
for (const seq of replacedSeqsOfBoundary(event, null)) {
|
|
135
|
+
if (Number.isSafeInteger(seq)) shadowed.add(seq)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return shadowed
|
|
139
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/boundary-tree.js
|
|
3
|
+
*
|
|
4
|
+
* The read-point outline forest: "which change landed inside which discarded
|
|
5
|
+
* set". Derived from our own boundary artifact (one record per boundary, each
|
|
6
|
+
* carrying `discardedSeqs` — the exact surface nodes that operation removed),
|
|
7
|
+
* so the client can indent nested branches without touching the projection wire.
|
|
8
|
+
*
|
|
9
|
+
* RULE (frozen 2026-09-14, corrected by measurement; DO NOT change back to set
|
|
10
|
+
* inclusion):
|
|
11
|
+
* parent(C) = the boundary B whose discarded set CONTAINS C's own event seq,
|
|
12
|
+
* `C.seq ∈ S(B)`, choosing the SMALLEST such |S(B)|.
|
|
13
|
+
*
|
|
14
|
+
* Why NOT `S(C) ⊆ S(B)` (the rule first written into the contract): measured on
|
|
15
|
+
* a real 24k-event session, the child's discarded nodes were **already dead**
|
|
16
|
+
* when the parent replaced its range (the child ran FIRST), so
|
|
17
|
+
* `|S(child) ∩ S(parent)| = 0` for every one of the 8 links of the real 9-layer
|
|
18
|
+
* chain. The parent's set only holds the child's MARKER event seq — that node was
|
|
19
|
+
* still on the surface at that moment. Inclusion therefore yields a degenerate
|
|
20
|
+
* forest (1 parent / depth 1) instead of 14 parents / depth 9. Membership is
|
|
21
|
+
* still an EXACT SET operation: no numeric interval is ever enumerated, and no
|
|
22
|
+
* `start ≤ end` ordering is assumed — a reversed range (`[16093..15458]`, real
|
|
23
|
+
* data) never enters this module.
|
|
24
|
+
*
|
|
25
|
+
* The discarded sets may be STORED SLIMMED (`S ∩ allBoundarySeqs`, see
|
|
26
|
+
* lib/versioning.js) because membership only ever asks about another boundary's
|
|
27
|
+
* seq; `discardedCount` then carries the exact `|S|`. Both forms must yield a
|
|
28
|
+
* byte-identical tree (pinned by a test).
|
|
29
|
+
*
|
|
30
|
+
* Zero imports (pure): the host, the route and the tests all share one rule.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Parse one record's discarded seqs into a Set (invalid entries dropped). */
|
|
34
|
+
function discardedSetOf(record) {
|
|
35
|
+
const raw = record?.discardedSeqs
|
|
36
|
+
if (!Array.isArray(raw)) return null
|
|
37
|
+
const set = new Set()
|
|
38
|
+
for (const seq of raw) if (Number.isSafeInteger(seq) && seq >= 0) set.add(seq)
|
|
39
|
+
return set
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Exact `|S|`: the stored `discardedCount` when present, else the set's size. */
|
|
43
|
+
function discardedCountOf(record, set) {
|
|
44
|
+
return Number.isSafeInteger(record?.discardedCount) && record.discardedCount >= 0 ? record.discardedCount : set.size
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Distance used to break equal-size parent candidates (nearest ancestor wins). */
|
|
48
|
+
function tieBreak(childSeq, a, b) {
|
|
49
|
+
const da = Math.abs(a.seq - childSeq)
|
|
50
|
+
const db = Math.abs(b.seq - childSeq)
|
|
51
|
+
if (da !== db) return da - db
|
|
52
|
+
return a.seq - b.seq
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the outline forest from artifact records.
|
|
57
|
+
*
|
|
58
|
+
* @param {object[]} records - artifact lines ({ boundarySeq, discardedSeqs, ... })
|
|
59
|
+
* @returns {{
|
|
60
|
+
* tree: Record<string, {parent:number|null, children:number[], discardedCount:number}>,
|
|
61
|
+
* maxDepth: number,
|
|
62
|
+
* withChildren: number,
|
|
63
|
+
* nodes: number
|
|
64
|
+
* } | null} null when no record carries a usable `discardedSeqs`
|
|
65
|
+
* (old artifacts, or a session that never recorded one) — the caller then
|
|
66
|
+
* OMITS the field and the client falls back to a flat list.
|
|
67
|
+
*/
|
|
68
|
+
export function boundaryTreeOf(records) {
|
|
69
|
+
if (!Array.isArray(records)) return null
|
|
70
|
+
// Latest record wins per boundary (the artifact is append-only).
|
|
71
|
+
const latest = new Map()
|
|
72
|
+
for (const record of records) {
|
|
73
|
+
if (!Number.isSafeInteger(record?.boundarySeq)) continue
|
|
74
|
+
latest.set(record.boundarySeq, record)
|
|
75
|
+
}
|
|
76
|
+
const entries = []
|
|
77
|
+
for (const [seq, record] of latest) {
|
|
78
|
+
const set = discardedSetOf(record)
|
|
79
|
+
if (set === null) continue
|
|
80
|
+
entries.push({ seq, set, count: discardedCountOf(record, set) })
|
|
81
|
+
}
|
|
82
|
+
if (entries.length === 0) return null
|
|
83
|
+
|
|
84
|
+
const childrenBySeq = new Map(entries.map((entry) => [entry.seq, []]))
|
|
85
|
+
const parentBySeq = new Map()
|
|
86
|
+
for (const child of entries) {
|
|
87
|
+
let best = null
|
|
88
|
+
for (const candidate of entries) {
|
|
89
|
+
if (candidate.seq === child.seq) continue
|
|
90
|
+
if (!candidate.set.has(child.seq)) continue
|
|
91
|
+
// Smallest containing set = the direct parent; an intermediate D would be
|
|
92
|
+
// smaller and would have won. Equal sizes fall back to the nearest seq.
|
|
93
|
+
if (best === null || candidate.count < best.count || (candidate.count === best.count && tieBreak(child.seq, candidate, best) < 0)) {
|
|
94
|
+
best = candidate
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
parentBySeq.set(child.seq, best === null ? null : best.seq)
|
|
98
|
+
if (best !== null) childrenBySeq.get(best.seq).push(child.seq)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tree = {}
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
tree[String(entry.seq)] = {
|
|
104
|
+
parent: parentBySeq.get(entry.seq) ?? null,
|
|
105
|
+
children: (childrenBySeq.get(entry.seq) ?? []).slice().sort((a, b) => a - b),
|
|
106
|
+
discardedCount: entry.count,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const depthMemo = new Map()
|
|
111
|
+
const depthOf = (seq) => {
|
|
112
|
+
if (depthMemo.has(seq)) return depthMemo.get(seq)
|
|
113
|
+
depthMemo.set(seq, 0) // cycle guard (a parent always has a larger discarded set)
|
|
114
|
+
const kids = childrenBySeq.get(seq) ?? []
|
|
115
|
+
const depth = kids.length === 0 ? 0 : 1 + Math.max(...kids.map(depthOf))
|
|
116
|
+
depthMemo.set(seq, depth)
|
|
117
|
+
return depth
|
|
118
|
+
}
|
|
119
|
+
let maxDepth = 0
|
|
120
|
+
let withChildren = 0
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
maxDepth = Math.max(maxDepth, depthOf(entry.seq))
|
|
123
|
+
if ((childrenBySeq.get(entry.seq) ?? []).length > 0) withChildren += 1
|
|
124
|
+
}
|
|
125
|
+
return { tree, maxDepth, withChildren, nodes: entries.length }
|
|
126
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/boundary-what.js
|
|
3
|
+
*
|
|
4
|
+
* The boundary digest — "what did this boundary change?" (the data gap behind the
|
|
5
|
+
* unreadable version/fork rows: the projection wire carried only `{seq, type}` /
|
|
6
|
+
* `{kind, replacedSeqs}`).
|
|
7
|
+
*
|
|
8
|
+
* It is built at OPERATION TIME, where the log is still readable: the caller
|
|
9
|
+
* passes the replaced span events it already resolved to write the marker, this
|
|
10
|
+
* module turns them into a small `what` payload, and the payload is stored in the
|
|
11
|
+
* plugin's own artifact (`lib/summary-store.js`) — NOT in the host session log
|
|
12
|
+
* (a plugin-owned event type without the `ignorable` marker makes the host
|
|
13
|
+
* persistence reader refuse the whole log) and NOT in the projection wire or the
|
|
14
|
+
* durable checkpoint (that tax is paid on every append; measured +70.8%/+93.7%).
|
|
15
|
+
*
|
|
16
|
+
* Frozen shape (2026-09-14):
|
|
17
|
+
* {
|
|
18
|
+
* op, // recall|edit|regenerate|restore|compaction|replace
|
|
19
|
+
* at?, // boundary time (epoch ms) when known
|
|
20
|
+
* new: { excerpt }, // the action's own NEW text, verbatim
|
|
21
|
+
* replaced: [ { seq, role, excerpt, summary? } ], // ≤ REPLACED_MAX
|
|
22
|
+
* replacedMore?, // count beyond the listed entries
|
|
23
|
+
* artifacts?: { created, modified, deleted } // counts only
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* Only the OLD content is summarized (the new content is still in the
|
|
27
|
+
* conversation — summarizing it would be parroting). `excerpt` and `summary` are
|
|
28
|
+
* SEPARATE fields: `excerpt` is verbatim capped log text, `summary` is filled by
|
|
29
|
+
* the optional LLM half (lib/llm-summary.js) and may be absent.
|
|
30
|
+
*
|
|
31
|
+
* Zero imports (pure) so every realm (host, dynamic sandbox, browser) can use it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Excerpt cap in characters (before the appended ellipsis).
|
|
36
|
+
*
|
|
37
|
+
* 60 fits one timeline/fork row on the target UI width without wrapping and keeps
|
|
38
|
+
* a full boundary payload (`replaced` × 3) around ~250 bytes. The long-form text
|
|
39
|
+
* stays reachable through the snapshot / event readers. Truncation appends `…` so
|
|
40
|
+
* a clipped excerpt is obvious. Note: UTF-16 code units are sliced, so a
|
|
41
|
+
* surrogate pair at the boundary can be split — acceptable for a display hint.
|
|
42
|
+
*/
|
|
43
|
+
export const EXCERPT_MAX = 60
|
|
44
|
+
|
|
45
|
+
/** How many replaced entries travel per boundary; the rest become `replacedMore`. */
|
|
46
|
+
export const REPLACED_MAX = 3
|
|
47
|
+
|
|
48
|
+
/** Message-source roles the payload reports. */
|
|
49
|
+
const ROLE_BY_TYPE = Object.freeze({
|
|
50
|
+
'user/message': 'user',
|
|
51
|
+
'assistant/message': 'assistant',
|
|
52
|
+
'tool/result': 'tool',
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/** Collapse whitespace so an excerpt is always a single line (verbatim otherwise). */
|
|
56
|
+
function flatten(text) {
|
|
57
|
+
return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Join an event's text blocks (verbatim characters, no rewriting). */
|
|
61
|
+
function textBlocks(content) {
|
|
62
|
+
if (!Array.isArray(content)) return ''
|
|
63
|
+
let out = ''
|
|
64
|
+
for (const block of content) {
|
|
65
|
+
if (block && block.type === 'text' && typeof block.text === 'string') out += (out ? ' ' : '') + block.text
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The role reported for one surface event (`unknown` when unrecognised). */
|
|
71
|
+
export function roleOf(event) {
|
|
72
|
+
return ROLE_BY_TYPE[event?.type] ?? 'unknown'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The verbatim text carried by one event:
|
|
77
|
+
* user/message → `data.content`
|
|
78
|
+
* assistant/message → `data.message.content`, else `data.content`
|
|
79
|
+
* tool/result → `data.message.content`, else the tool name
|
|
80
|
+
* Returns '' when the event carries no text (image-only prompt, bare tool row…).
|
|
81
|
+
* @param {object} event
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function eventText(event) {
|
|
85
|
+
const type = event?.type
|
|
86
|
+
const data = event?.data
|
|
87
|
+
if (!data || typeof data !== 'object') return ''
|
|
88
|
+
if (type === 'user/message') return textBlocks(data.content)
|
|
89
|
+
if (type === 'assistant/message') return textBlocks(data.message?.content) || textBlocks(data.content)
|
|
90
|
+
if (type === 'tool/result') return textBlocks(data.message?.content) || textBlocks(data.content) || (typeof data.name === 'string' ? data.name : '')
|
|
91
|
+
return ''
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Truncate one string to the payload cap. `null`/undefined → ''. Whitespace is
|
|
96
|
+
* flattened first, so the result is always a single line.
|
|
97
|
+
* @param {unknown} text
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
export function excerpt(text) {
|
|
101
|
+
const flat = flatten(text)
|
|
102
|
+
return flat.length > EXCERPT_MAX ? `${flat.slice(0, EXCERPT_MAX)}…` : flat
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Assemble the boundary `what` payload from the replaced span.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} input
|
|
109
|
+
* @param {string} input.op - boundary kind
|
|
110
|
+
* @param {number} [input.at] - boundary time (epoch ms)
|
|
111
|
+
* @param {object[]} [input.spanEvents] - the replaced surface events, surface order
|
|
112
|
+
* @param {number[]} [input.replacedSeqs] - explicit seq list (citation fallback);
|
|
113
|
+
* defaults to the span's own seqs
|
|
114
|
+
* @param {string} [input.newText] - the action's own new text (verbatim; capped)
|
|
115
|
+
* @param {{created:number, modified:number, deleted:number}} [input.artifacts]
|
|
116
|
+
*/
|
|
117
|
+
export function makeWhat({ op, at, spanEvents = [], replacedSeqs = null, newText = '', artifacts = null }) {
|
|
118
|
+
const events = Array.isArray(spanEvents) ? spanEvents.filter((event) => event && typeof event.seq === 'number') : []
|
|
119
|
+
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
120
|
+
const seqs = Array.isArray(replacedSeqs) ? replacedSeqs : events.map((event) => event.seq)
|
|
121
|
+
const listed = seqs.slice(0, REPLACED_MAX).map((seq) => {
|
|
122
|
+
const event = bySeq.get(seq)
|
|
123
|
+
return {
|
|
124
|
+
seq,
|
|
125
|
+
role: event === undefined ? 'unknown' : roleOf(event),
|
|
126
|
+
excerpt: event === undefined ? '' : excerpt(eventText(event)),
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
const what = { op: String(op) }
|
|
130
|
+
if (Number.isSafeInteger(at) && at > 0) what.at = at
|
|
131
|
+
what.new = { excerpt: excerpt(newText) }
|
|
132
|
+
what.replaced = listed
|
|
133
|
+
// Only present when something was cut off (the field is optional; omitting the
|
|
134
|
+
// zero keeps ~18 bytes off every payload).
|
|
135
|
+
if (seqs.length > listed.length) what.replacedMore = seqs.length - listed.length
|
|
136
|
+
if (artifacts && (artifacts.created > 0 || artifacts.modified > 0 || artifacts.deleted > 0)) {
|
|
137
|
+
what.artifacts = { created: artifacts.created, modified: artifacts.modified, deleted: artifacts.deleted }
|
|
138
|
+
}
|
|
139
|
+
return what
|
|
140
|
+
}
|