dsh-session-rewind 0.0.0-stage → 1.2.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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * In-place rewind planning, and the client-side visibility input it produces.
3
+ *
4
+ * DSH appends to an immutable session log; the model-visible "surface" is an
5
+ * ordered subset of that log. A rewind appends ONE `user/message` whose
6
+ * `surfaceOp` replaces the target user message and every surface node after it,
7
+ * so the next request derives its context from before the target. The log itself
8
+ * is never modified — the rendered transcript and the audit view still hold
9
+ * everything.
10
+ *
11
+ * These functions are pure: they take the committed events and the ordered
12
+ * surface seqs and return a plan, so they are unit-testable without a session.
13
+ */
14
+
15
+ /** Producer kind carried by every marker this plugin appends. */
16
+ export const MARKER_SOURCE_KIND = 'session-rewind';
17
+
18
+ /**
19
+ * The constant marker body.
20
+ *
21
+ * It must be non-empty: a strict OpenAI-compatible gateway rejects an empty user
22
+ * message with HTTP 400, and the marker derives to itself at the surface tail.
23
+ */
24
+ export const MARKER_TEXT = '(session-rewind)';
25
+
26
+ function isRecord(value) {
27
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ /**
31
+ * A genuinely user-authored message: runtime context, instruction files and
32
+ * projected notes are all `user/message` events with a different source kind.
33
+ * @param {object} event
34
+ * @returns {boolean}
35
+ */
36
+ export function isHumanUserMessage(event) {
37
+ return isRecord(event)
38
+ && event.type === 'user/message'
39
+ && isRecord(event.data)
40
+ && isRecord(event.data.source)
41
+ && event.data.source.kind === 'user';
42
+ }
43
+
44
+ /**
45
+ * Join the text blocks of one message content array.
46
+ * @param {unknown} content
47
+ * @returns {string}
48
+ */
49
+ export function textOf(content) {
50
+ if (!Array.isArray(content)) return '';
51
+ const parts = [];
52
+ for (const block of content) {
53
+ if (!isRecord(block)) continue;
54
+ if (block.type !== undefined && block.type !== 'text') continue;
55
+ if (typeof block.text === 'string' && block.text !== '') parts.push(block.text);
56
+ }
57
+ return parts.join('\n').trim();
58
+ }
59
+
60
+ /**
61
+ * Every user-authored message currently on the surface, newest first.
62
+ * @param {readonly object[]} events
63
+ * @param {readonly number[]} surface
64
+ * @param {number} [limit]
65
+ * @returns {Array<{ seq: number, time: number, preview: string, index: number }>}
66
+ */
67
+ export function listCandidates(events, surface, limit = 100) {
68
+ const onSurface = new Set(Array.isArray(surface) ? surface : []);
69
+ const ordered = [];
70
+ for (const event of events) {
71
+ if (!isHumanUserMessage(event)) continue;
72
+ if (typeof event.seq !== 'number' || !onSurface.has(event.seq)) continue;
73
+ ordered.push(event);
74
+ }
75
+ const result = [];
76
+ for (let index = ordered.length - 1; index >= 0 && result.length < limit; index -= 1) {
77
+ const event = ordered[index];
78
+ result.push({
79
+ seq: event.seq,
80
+ time: typeof event.time === 'number' ? event.time : 0,
81
+ preview: textOf(event.data?.content),
82
+ index: result.length + 1,
83
+ });
84
+ }
85
+ return result;
86
+ }
87
+
88
+ /**
89
+ * Plan one in-place rewind.
90
+ *
91
+ * `surfaceStart` is the target's own surface seq and `surfaceEnd` is the LAST
92
+ * surface node — not the target — because the replace op shadows everything the
93
+ * model still sees after the target as well.
94
+ *
95
+ * @param {readonly object[]} events
96
+ * @param {readonly number[]} surface
97
+ * @param {number} seq
98
+ * @returns {{ targetSeq: number, surfaceStart: number, surfaceEnd: number, shadowedSeqs: number[] }}
99
+ * @throws {Error & { code: string }}
100
+ */
101
+ export function planInPlace(events, surface, seq) {
102
+ const fail = (code, message) => {
103
+ const error = new Error(message);
104
+ error.code = code;
105
+ return error;
106
+ };
107
+ const nodes = Array.isArray(surface) ? surface : [];
108
+ if (nodes.length === 0) throw fail('no-user-messages', 'this session has no rewritable message');
109
+ const target = events.find((event) => event?.seq === seq);
110
+ if (target === undefined) throw fail('not-a-user-message', 'that message is not in this session');
111
+ if (!isHumanUserMessage(target)) {
112
+ throw fail('not-a-user-message', 'only your own messages can be rewound to');
113
+ }
114
+ const targetIndex = nodes.indexOf(seq);
115
+ if (targetIndex < 0) {
116
+ throw fail('not-on-surface', 'that message is no longer part of the model context');
117
+ }
118
+ const shadowedSeqs = nodes.slice(targetIndex);
119
+ return {
120
+ targetSeq: seq,
121
+ surfaceStart: shadowedSeqs[0],
122
+ surfaceEnd: shadowedSeqs[shadowedSeqs.length - 1],
123
+ shadowedSeqs,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The earliest surface cut this plugin has already executed, or null.
129
+ *
130
+ * The Chat view renders append-origin nodes only, so a declared rewind does not
131
+ * remove the withdrawn rows by itself; the client hides every node at or after
132
+ * this seq. Hiding is monotonic — an older cut always wins — so one number is
133
+ * enough to describe any number of executed rewinds.
134
+ *
135
+ * @param {readonly object[]} events
136
+ * @returns {number | null}
137
+ */
138
+ export function hiddenFromSeq(events) {
139
+ let earliest = null;
140
+ for (const event of events) {
141
+ if (!isRecord(event) || event.type !== 'user/message') continue;
142
+ if (event.data?.source?.kind !== MARKER_SOURCE_KIND) continue;
143
+ const start = event.surfaceOp;
144
+ const startSeq = isRecord(start) && start.op === 'replace' ? start.startSeq : undefined;
145
+ if (typeof startSeq !== 'number') continue;
146
+ if (earliest === null || startSeq < earliest) earliest = startSeq;
147
+ }
148
+ return earliest;
149
+ }
150
+
151
+ /**
152
+ * The Turn of the earliest executed cut, for views that can only address whole
153
+ * Turns (the rendered Chat rows carry `data-chat-turn`, not a seq).
154
+ * @param {readonly object[]} events
155
+ * @returns {number | null}
156
+ */
157
+ export function hiddenFromTurn(events) {
158
+ const start = hiddenFromSeq(events);
159
+ if (start === null) return null;
160
+ let turn = null;
161
+ let inside = null;
162
+ for (const event of events) {
163
+ if (!isRecord(event)) continue;
164
+ if (typeof event.seq !== 'number' || event.seq > start) break;
165
+ if (event.type === 'turn/start' && Number.isInteger(event.data?.turn)) inside = event.data.turn;
166
+ else if (event.type === 'turn/end' && Number.isInteger(event.data?.turn)) inside = null;
167
+ }
168
+ turn = inside;
169
+ return Number.isInteger(turn) ? turn : null;
170
+ }
171
+
172
+ /**
173
+ * Build the marker event payload. `id` is minted here because the Host's own
174
+ * `createUserMessage` helper is not importable from a profile bundle.
175
+ * @param {(id: string) => string} randomId
176
+ * @returns {object}
177
+ */
178
+ export function markerPayload(randomId) {
179
+ return {
180
+ role: 'user',
181
+ id: randomId(`session-rewind-${Date.now()}`),
182
+ content: [{ type: 'text', text: MARKER_TEXT }],
183
+ source: { kind: MARKER_SOURCE_KIND },
184
+ };
185
+ }