dsh-session-rewind 0.0.0-stage → 1.2.7
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/LICENSE +21 -0
- package/README.en.md +153 -0
- package/README.md +163 -3
- package/cordis.patch.yml +9 -0
- package/icon.svg +7 -0
- package/lib/client.js +1714 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
- package/src/index.js +10 -0
- package/src/lib/archive.js +399 -0
- package/src/lib/inplace.js +205 -0
- package/src/lib/restore.js +117 -0
- package/src/lib/route.js +594 -0
- package/src/lib/surface.js +185 -0
- package/src/plugin.js +667 -0
- package/test/client-smoke.mjs +440 -0
- package/test/route-smoke.mjs +272 -0
- package/test/selftest.mjs +262 -0
|
@@ -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
|
+
}
|