dsh-session-rewind 1.2.8 → 1.3.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.
- package/lib/client.js +53 -15
- package/locale/en.json +2 -1
- package/locale/zh.json +2 -1
- package/package.json +1 -1
- package/src/lib/inplace.js +18 -2
- package/src/lib/route.js +125 -0
- package/src/lib/surface.js +39 -0
- package/src/plugin.js +32 -3
- package/test/find-markers.mjs +42 -0
- package/test/selftest.mjs +40 -0
package/lib/client.js
CHANGED
|
@@ -65,7 +65,9 @@ loader.load({
|
|
|
65
65
|
const INPLACE_ROUTE = '/api/session-rewind.inplace';
|
|
66
66
|
const CUTS_ROUTE = '/api/session-rewind.cuts';
|
|
67
67
|
const DIAGNOSE_ROUTE = '/api/session-rewind.diagnose';
|
|
68
|
-
const
|
|
68
|
+
const TAIL_ROUTE = '/api/session-rewind.tail';
|
|
69
|
+
const BOOT_REVISION = '1.3.1';
|
|
70
|
+
const HIDDEN_ATTR = 'data-session-rewind-hidden';
|
|
69
71
|
|
|
70
72
|
/** One per-message bridge per session, shared by every TurnRewind instance. */
|
|
71
73
|
const bridgeRegistry = new Map();
|
|
@@ -236,6 +238,7 @@ loader.load({
|
|
|
236
238
|
'.dshSessionRewindRoundFiles{flex:none;font-size:10px;line-height:15px;color:var(--dsw-alias-label-secondary)}',
|
|
237
239
|
'.dshSessionRewindModeText{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);',
|
|
238
240
|
'font-family:var(--dsw-font-family);word-break:break-word;max-height:96px;overflow:auto}',
|
|
241
|
+
'[data-session-rewind-hidden="true"]{display:none !important}',
|
|
239
242
|
].join('\n');
|
|
240
243
|
//#endregion
|
|
241
244
|
|
|
@@ -767,7 +770,7 @@ loader.load({
|
|
|
767
770
|
const state = {
|
|
768
771
|
disposed: false,
|
|
769
772
|
turnToSeq: new Map(),
|
|
770
|
-
|
|
773
|
+
hiddenTurns: new Set(),
|
|
771
774
|
buttons: new Map(),
|
|
772
775
|
hiddenRows: new Map(),
|
|
773
776
|
observer: null,
|
|
@@ -867,6 +870,7 @@ loader.load({
|
|
|
867
870
|
: `${tr('notice.failed')}(HTTP ${response.status})`),
|
|
868
871
|
);
|
|
869
872
|
error.status = response.status;
|
|
873
|
+
error.code = value !== null && typeof value === 'object' && typeof value.code === 'string' ? value.code : null;
|
|
870
874
|
throw error;
|
|
871
875
|
}
|
|
872
876
|
return value;
|
|
@@ -908,24 +912,33 @@ loader.load({
|
|
|
908
912
|
};
|
|
909
913
|
|
|
910
914
|
/** Hide or restore the rendered rows at or after the executed cut. */
|
|
915
|
+
/**
|
|
916
|
+
* Hide exactly the rows of the Turns a rewind withdrew.
|
|
917
|
+
*
|
|
918
|
+
* The attribute (plus a `display: none !important` rule in SHEET) is used
|
|
919
|
+
* instead of an inline style because the Chat re-renders its rows and would
|
|
920
|
+
* drop an inline override; an unknown attribute survives that. Turns the user
|
|
921
|
+
* starts after rewinding are not in the set, so they stay visible.
|
|
922
|
+
*/
|
|
911
923
|
const applyHiding = () => {
|
|
912
924
|
if (state.disposed) return;
|
|
913
925
|
const rows = document.querySelectorAll('[data-chat-turn]');
|
|
914
926
|
for (const row of rows) {
|
|
915
927
|
const turn = Number(row.getAttribute('data-chat-turn'));
|
|
916
928
|
if (!Number.isInteger(turn)) continue;
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
if (
|
|
920
|
-
|
|
921
|
-
row.style.display = 'none';
|
|
922
|
-
} else if (!shouldHide && already !== undefined) {
|
|
923
|
-
row.style.display = already;
|
|
924
|
-
state.hiddenRows.delete(row);
|
|
929
|
+
if (state.hiddenTurns.has(turn)) {
|
|
930
|
+
if (!row.hasAttribute(HIDDEN_ATTR)) row.setAttribute(HIDDEN_ATTR, 'true');
|
|
931
|
+
} else if (row.hasAttribute(HIDDEN_ATTR)) {
|
|
932
|
+
row.removeAttribute(HIDDEN_ATTR);
|
|
925
933
|
}
|
|
926
934
|
}
|
|
927
935
|
};
|
|
928
936
|
|
|
937
|
+
const revealAll = () => {
|
|
938
|
+
state.hiddenTurns.clear();
|
|
939
|
+
for (const row of document.querySelectorAll(`[${HIDDEN_ATTR}]`)) row.removeAttribute(HIDDEN_ATTR);
|
|
940
|
+
};
|
|
941
|
+
|
|
929
942
|
const closePopover = () => {
|
|
930
943
|
if (state.popover !== null) {
|
|
931
944
|
try {
|
|
@@ -937,12 +950,19 @@ loader.load({
|
|
|
937
950
|
}
|
|
938
951
|
};
|
|
939
952
|
|
|
940
|
-
const runRewind = async (seq, restoreFiles, notice) => {
|
|
953
|
+
const runRewind = async (seq, restoreFiles, notice, fromTurn = null) => {
|
|
941
954
|
notice(tr('notice.working'));
|
|
942
955
|
try {
|
|
943
956
|
const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
|
|
944
957
|
if (state.disposed) return;
|
|
945
958
|
if (Number.isInteger(result.hiddenFromTurn)) state.cutTurn = result.hiddenFromTurn;
|
|
959
|
+
if (Array.isArray(result.hiddenTurns) && result.hiddenTurns.length > 0) {
|
|
960
|
+
for (const turn of result.hiddenTurns) {
|
|
961
|
+
if (Number.isInteger(turn)) state.hiddenTurns.add(turn);
|
|
962
|
+
}
|
|
963
|
+
} else if (Number.isInteger(result.hiddenFromTurn)) {
|
|
964
|
+
state.hiddenTurns = new Set([result.hiddenFromTurn]);
|
|
965
|
+
}
|
|
946
966
|
applyHiding();
|
|
947
967
|
const filled = setComposer(typeof result.prompt === 'string' ? result.prompt : '');
|
|
948
968
|
sendDiagnose(sessionId, {
|
|
@@ -953,7 +973,7 @@ loader.load({
|
|
|
953
973
|
promptChars: typeof result.prompt === 'string' ? result.prompt.length : 0,
|
|
954
974
|
restore: result.restore ?? null,
|
|
955
975
|
injected: state.buttons.size,
|
|
956
|
-
hiddenRows: state.
|
|
976
|
+
hiddenRows: state.hiddenTurns.size,
|
|
957
977
|
});
|
|
958
978
|
const restore = result.restore;
|
|
959
979
|
const parts = [];
|
|
@@ -963,11 +983,24 @@ loader.load({
|
|
|
963
983
|
}
|
|
964
984
|
notice(parts.length === 0 ? tr('notice.rewound') : `${tr('notice.rewound')} · ${parts.join(' · ')}`);
|
|
965
985
|
} catch (error) {
|
|
986
|
+
const code = error !== null && typeof error === 'object' ? error.code ?? null : null;
|
|
966
987
|
sendDiagnose(sessionId, {
|
|
967
988
|
reason: 'rewind-failed',
|
|
968
989
|
error: String(error && error.message ? error.message : error),
|
|
990
|
+
code,
|
|
969
991
|
sequence: seq,
|
|
970
992
|
});
|
|
993
|
+
if (code === 'not-on-surface') {
|
|
994
|
+
// Already withdrawn by an earlier rewind: hide the row and say so,
|
|
995
|
+
// instead of reporting a failure the user cannot act on.
|
|
996
|
+
const turn = Number.isInteger(fromTurn) ? fromTurn : null;
|
|
997
|
+
if (Number.isInteger(turn)) {
|
|
998
|
+
state.hiddenTurns.add(turn);
|
|
999
|
+
applyHiding();
|
|
1000
|
+
}
|
|
1001
|
+
notice(tr('notice.already'));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
971
1004
|
notice(error instanceof Error && error.message !== '' ? error.message : tr('notice.failed'));
|
|
972
1005
|
}
|
|
973
1006
|
};
|
|
@@ -1020,11 +1053,11 @@ loader.load({
|
|
|
1020
1053
|
};
|
|
1021
1054
|
popover.appendChild(makeButton(tr('popover.chat'), () => {
|
|
1022
1055
|
closePopover();
|
|
1023
|
-
void runRewind(seq, false, notice);
|
|
1056
|
+
void runRewind(seq, false, notice, turn);
|
|
1024
1057
|
}));
|
|
1025
1058
|
popover.appendChild(makeButton(tr('popover.both'), () => {
|
|
1026
1059
|
closePopover();
|
|
1027
|
-
void runRewind(seq, true, notice);
|
|
1060
|
+
void runRewind(seq, true, notice, turn);
|
|
1028
1061
|
}));
|
|
1029
1062
|
document.body.appendChild(popover);
|
|
1030
1063
|
state.popover = popover;
|
|
@@ -1152,6 +1185,7 @@ loader.load({
|
|
|
1152
1185
|
button.addEventListener('click', (event) => {
|
|
1153
1186
|
event.preventDefault();
|
|
1154
1187
|
event.stopPropagation();
|
|
1188
|
+
sendDiagnose(sessionId, { reason: 'click', turn, seq, buttons: state.buttons.size, mapped: state.turnToSeq.size });
|
|
1155
1189
|
openPopover(button, seq, notice);
|
|
1156
1190
|
});
|
|
1157
1191
|
anchor.appendChild(button);
|
|
@@ -1219,7 +1253,11 @@ loader.load({
|
|
|
1219
1253
|
try {
|
|
1220
1254
|
const value = await postJson(CUTS_ROUTE, { sessionId });
|
|
1221
1255
|
if (state.disposed) return;
|
|
1222
|
-
|
|
1256
|
+
if (Array.isArray(value.hiddenTurns)) {
|
|
1257
|
+
state.hiddenTurns = new Set(value.hiddenTurns.filter((turn) => Number.isInteger(turn)));
|
|
1258
|
+
} else if (Number.isInteger(value.hiddenFromTurn)) {
|
|
1259
|
+
state.hiddenTurns = new Set([value.hiddenFromTurn]);
|
|
1260
|
+
}
|
|
1223
1261
|
applyHiding();
|
|
1224
1262
|
} catch {
|
|
1225
1263
|
/* ignore */
|
package/locale/en.json
CHANGED
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Session Rewind",
|
|
4
4
|
"description": "Adds a rewind control to every finished Turn plus one entry in the session header. Pick a Turn to branch the conversation there into a new session, put that Turn's original prompt back in the composer so you can edit and resend it, and restore the workspace files that Turn changed to their pre-Turn content."
|
|
5
|
-
}
|
|
5
|
+
},
|
|
6
|
+
"notice.already": "That message was already rewound; its row is now hidden."
|
|
6
7
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-rewind",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Rewind a finished Turn: branch the conversation at any Turn (session header or per-Turn control) and restore every workspace file that Turn changed. · 回合回退:从任意已完成的回合重新开始,并把该回合改动过的工作区文件恢复原状。",
|
|
6
6
|
"icon": "./icon.svg",
|
package/src/lib/inplace.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
|
|
13
|
-
import { hiddenFromSeq, markerPayload, planInPlace, textOf } from './surface.js';
|
|
13
|
+
import { hiddenFromSeq, markerPayload, planInPlace, textOf, withdrawnTurns } from './surface.js';
|
|
14
14
|
|
|
15
15
|
/** Waiting budget for a cancelled Turn to reach quiescence. */
|
|
16
16
|
const IDLE_TIMEOUT_MS = 20_000;
|
|
@@ -128,7 +128,14 @@ export function describeInPlace(session, seq) {
|
|
|
128
128
|
* hiddenFromSeq: number | null, restore?: object,
|
|
129
129
|
* }>}
|
|
130
130
|
*/
|
|
131
|
-
export async function executeInPlace({
|
|
131
|
+
export async function executeInPlace({
|
|
132
|
+
ctx,
|
|
133
|
+
sessionId,
|
|
134
|
+
seq,
|
|
135
|
+
restoreTurns,
|
|
136
|
+
readEvents,
|
|
137
|
+
timeoutMs = IDLE_TIMEOUT_MS,
|
|
138
|
+
}) {
|
|
132
139
|
const agent = liveAgentOf(ctx, sessionId);
|
|
133
140
|
if (agent === undefined) {
|
|
134
141
|
const error = new Error('this session is not live, so it cannot be rewound in place');
|
|
@@ -192,12 +199,21 @@ export async function executeInPlace({ ctx, sessionId, seq, restoreTurns, timeou
|
|
|
192
199
|
hidden = plan.surfaceStart;
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
let hiddenTurns = [];
|
|
203
|
+
try {
|
|
204
|
+
const after = typeof readEvents === 'function' ? await readEvents(sessionId) : [];
|
|
205
|
+
hiddenTurns = [...withdrawnTurns(after)];
|
|
206
|
+
} catch {
|
|
207
|
+
/* the cut still applied; the client falls back to hiding from plan.turn */
|
|
208
|
+
}
|
|
209
|
+
|
|
195
210
|
return {
|
|
196
211
|
markerSeq: typeof event?.seq === 'number' ? event.seq : -1,
|
|
197
212
|
targetSeq: plan.targetSeq,
|
|
198
213
|
prompt: plan.prompt,
|
|
199
214
|
turn: plan.turn,
|
|
200
215
|
hiddenFromSeq: hidden,
|
|
216
|
+
hiddenTurns,
|
|
201
217
|
...(restore === undefined ? {} : { restore }),
|
|
202
218
|
};
|
|
203
219
|
}
|
package/src/lib/route.js
CHANGED
|
@@ -21,6 +21,8 @@ export const CANDIDATES_PATH = '/api/session-rewind.candidates';
|
|
|
21
21
|
export const INPLACE_PATH = '/api/session-rewind.inplace';
|
|
22
22
|
export const CUTS_PATH = '/api/session-rewind.cuts';
|
|
23
23
|
export const DIAGNOSE_PATH = '/api/session-rewind.diagnose';
|
|
24
|
+
export const TAIL_PATH = '/api/session-rewind.tail';
|
|
25
|
+
export const SURFACE_PATH = '/api/session-rewind.surface';
|
|
24
26
|
export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
|
|
25
27
|
|
|
26
28
|
/** A rewind request is a session id plus a Turn number or a seq. */
|
|
@@ -198,6 +200,129 @@ function targetOf(value) {
|
|
|
198
200
|
return { sessionId, turn, seq };
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Build the surface route.
|
|
205
|
+
*
|
|
206
|
+
* `planInPlace` refuses a rewind when the target Seq is not a node of the live
|
|
207
|
+
* Session's surface, so when a click is refused the only useful question is what
|
|
208
|
+
* that surface actually contains. This exposes it read-only.
|
|
209
|
+
*
|
|
210
|
+
* @param {{
|
|
211
|
+
* surfaceOf: (sessionId: string) => { nodes?: readonly unknown[]; replaceGeneration?: number; contentGeneration?: number } | null,
|
|
212
|
+
* trustedHosts?: () => readonly string[],
|
|
213
|
+
* onError?: (error: unknown) => void,
|
|
214
|
+
* }} options
|
|
215
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
export function createSurfaceHandler({ surfaceOf, trustedHosts = () => [], onError = () => {} }) {
|
|
218
|
+
return async (request, response) => {
|
|
219
|
+
try {
|
|
220
|
+
if (request.method !== 'POST') {
|
|
221
|
+
writeText(response, 405, 'method not allowed');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
225
|
+
writeText(response, 403, 'forbidden');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
229
|
+
if (mediaType !== 'application/json') {
|
|
230
|
+
writeText(response, 415, 'content type must be application/json');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const value = await readJsonBody(request);
|
|
234
|
+
if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
|
|
235
|
+
const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
|
|
236
|
+
if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
|
|
237
|
+
const live = surfaceOf(sessionId);
|
|
238
|
+
if (live === null) throw new RequestBoundaryError(409, 'this session is not open right now');
|
|
239
|
+
const nodes = Array.isArray(live.nodes) ? live.nodes.map((node) => (typeof node === 'number' ? node : null)) : [];
|
|
240
|
+
const tail = Number.isInteger(value.tail) && value.tail > 0 && value.tail <= 200 ? value.tail : 20;
|
|
241
|
+
writeJson(response, 200, {
|
|
242
|
+
ok: true,
|
|
243
|
+
count: nodes.length,
|
|
244
|
+
replaceGeneration: live.replaceGeneration ?? null,
|
|
245
|
+
contentGeneration: live.contentGeneration ?? null,
|
|
246
|
+
head: nodes.slice(0, 10),
|
|
247
|
+
tail: nodes.slice(-tail),
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
fail(response, error, onError);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build the event-tail route.
|
|
257
|
+
*
|
|
258
|
+
* Returns the last N events of a Session in reading order with just enough shape
|
|
259
|
+
* to reason about a rewind: seq, type, source kind and a short text sample. It is
|
|
260
|
+
* the only way to see whether a marker actually landed, because the on-disk log is
|
|
261
|
+
* compressed and may lag behind the live surface.
|
|
262
|
+
*
|
|
263
|
+
* @param {{
|
|
264
|
+
* events: (sessionId: string) => Promise<readonly unknown[]>,
|
|
265
|
+
* trustedHosts?: () => readonly string[],
|
|
266
|
+
* onError?: (error: unknown) => void,
|
|
267
|
+
* }} options
|
|
268
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
269
|
+
*/
|
|
270
|
+
export function createTailHandler({ events, trustedHosts = () => [], onError = () => {} }) {
|
|
271
|
+
return async (request, response) => {
|
|
272
|
+
try {
|
|
273
|
+
if (request.method !== 'POST') {
|
|
274
|
+
writeText(response, 405, 'method not allowed');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
278
|
+
writeText(response, 403, 'forbidden');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
282
|
+
if (mediaType !== 'application/json') {
|
|
283
|
+
writeText(response, 415, 'content type must be application/json');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const value = await readJsonBody(request);
|
|
287
|
+
if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
|
|
288
|
+
const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
|
|
289
|
+
if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
|
|
290
|
+
const limit = Number.isInteger(value.limit) && value.limit > 0 && value.limit <= 300 ? value.limit : 40;
|
|
291
|
+
const all = await events(sessionId);
|
|
292
|
+
const slice = all.slice(-limit);
|
|
293
|
+
writeJson(response, 200, {
|
|
294
|
+
ok: true,
|
|
295
|
+
total: all.length,
|
|
296
|
+
events: slice.map((event) => summarizeEvent(event)),
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
fail(response, error, onError);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Reduce one Session event to the fields worth inspecting.
|
|
306
|
+
*
|
|
307
|
+
* @param {unknown} event
|
|
308
|
+
* @returns {{ seq: number | null, type: string, sourceKind: string | null, role: string | null, text: string | null }}
|
|
309
|
+
*/
|
|
310
|
+
function summarizeEvent(event) {
|
|
311
|
+
const record = isRecord(event) ? event : {};
|
|
312
|
+
const data = isRecord(record.data) ? record.data : {};
|
|
313
|
+
const source = isRecord(data.source) ? data.source : {};
|
|
314
|
+
const content = Array.isArray(data.content) ? data.content : [];
|
|
315
|
+
const first = content.find((part) => isRecord(part) && part.type === 'text');
|
|
316
|
+
const text = isRecord(first) && typeof first.text === 'string' ? first.text.slice(0, 60) : null;
|
|
317
|
+
return {
|
|
318
|
+
seq: Number.isInteger(record.seq) ? record.seq : null,
|
|
319
|
+
type: typeof record.type === 'string' ? record.type : '(none)',
|
|
320
|
+
sourceKind: typeof source.kind === 'string' ? source.kind : null,
|
|
321
|
+
role: typeof data.role === 'string' ? data.role : null,
|
|
322
|
+
text,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
201
326
|
/**
|
|
202
327
|
* Build the browser-diagnostics route.
|
|
203
328
|
*
|
package/src/lib/surface.js
CHANGED
|
@@ -169,6 +169,45 @@ export function hiddenFromTurn(events) {
|
|
|
169
169
|
return Number.isInteger(turn) ? turn : null;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Turns whose rows belong to a withdrawn stretch of the log.
|
|
174
|
+
*
|
|
175
|
+
* A Session can be rewound more than once, and each rewind appends its own
|
|
176
|
+
* marker, so the withdrawn turns are the union over every marker of the turns
|
|
177
|
+
* spanned by `[surfaceOp.startSeq, markerSeq)`. Taking only the earliest marker —
|
|
178
|
+
* as the first version did — leaves the turns withdrawn later still on screen.
|
|
179
|
+
*
|
|
180
|
+
* @param {readonly unknown[]} events
|
|
181
|
+
* @returns {readonly number[]}
|
|
182
|
+
*/
|
|
183
|
+
export function withdrawnTurns(events) {
|
|
184
|
+
const spans = [];
|
|
185
|
+
for (const event of events) {
|
|
186
|
+
if (!isRecord(event) || event.type !== 'user/message') continue;
|
|
187
|
+
if (event.data?.source?.kind !== MARKER_SOURCE_KIND) continue;
|
|
188
|
+
if (typeof event.seq !== 'number') continue;
|
|
189
|
+
const op = event.surfaceOp;
|
|
190
|
+
if (!isRecord(op) || op.op !== 'replace' || typeof op.startSeq !== 'number') continue;
|
|
191
|
+
spans.push({ from: op.startSeq, before: event.seq });
|
|
192
|
+
}
|
|
193
|
+
if (spans.length === 0) return [];
|
|
194
|
+
const turns = new Set();
|
|
195
|
+
let current = null;
|
|
196
|
+
for (const event of events) {
|
|
197
|
+
if (!isRecord(event) || typeof event.seq !== 'number') continue;
|
|
198
|
+
const turn = event.data?.turn;
|
|
199
|
+
if (Number.isInteger(turn) && (event.type === 'turn/start' || event.type === 'turn/end')) current = turn;
|
|
200
|
+
if (!Number.isInteger(current)) continue;
|
|
201
|
+
for (const span of spans) {
|
|
202
|
+
if (event.seq >= span.from && event.seq < span.before) {
|
|
203
|
+
turns.add(current);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [...turns].sort((left, right) => left - right);
|
|
209
|
+
}
|
|
210
|
+
|
|
172
211
|
/**
|
|
173
212
|
* Build the marker event payload. `id` is minted here because the Host's own
|
|
174
213
|
* `createUserMessage` helper is not importable from a profile bundle.
|
package/src/plugin.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
CUTS_PATH,
|
|
30
30
|
DIAGNOSE_PATH,
|
|
31
31
|
INPLACE_PATH,
|
|
32
|
+
SURFACE_PATH,
|
|
33
|
+
TAIL_PATH,
|
|
32
34
|
PLAN_PATH,
|
|
33
35
|
RESTORE_PATH,
|
|
34
36
|
SELFCHECK_PATH,
|
|
@@ -36,12 +38,14 @@ import {
|
|
|
36
38
|
createCutsHandler,
|
|
37
39
|
createDiagnoseHandler,
|
|
38
40
|
createInplaceHandler,
|
|
41
|
+
createSurfaceHandler,
|
|
42
|
+
createTailHandler,
|
|
39
43
|
createPlanHandler,
|
|
40
44
|
createRestoreHandler,
|
|
41
45
|
createSelfcheckHandler,
|
|
42
46
|
} from './lib/route.js';
|
|
43
47
|
import { describeInPlace, executeInPlace, liveAgentOf } from './lib/inplace.js';
|
|
44
|
-
import { hiddenFromSeq, hiddenFromTurn } from './lib/surface.js';
|
|
48
|
+
import { hiddenFromSeq, hiddenFromTurn, withdrawnTurns } from './lib/surface.js';
|
|
45
49
|
|
|
46
50
|
export const name = 'session-rewind';
|
|
47
51
|
|
|
@@ -473,12 +477,17 @@ export function apply(ctx) {
|
|
|
473
477
|
restoreTurns: restoreFiles === true
|
|
474
478
|
? async (fromTurn) => restoreTurns(sessionId, fromTurn)
|
|
475
479
|
: undefined,
|
|
480
|
+
readEvents: (id) => readEvents(ctx, id),
|
|
476
481
|
});
|
|
477
482
|
|
|
478
483
|
const cutsOf = async (sessionId) => {
|
|
479
484
|
// Read the durable log: the client needs the cut after a page reload too.
|
|
480
485
|
const events = await readEvents(ctx, sessionId);
|
|
481
|
-
return {
|
|
486
|
+
return {
|
|
487
|
+
hiddenFromSeq: hiddenFromSeq(events),
|
|
488
|
+
hiddenFromTurn: hiddenFromTurn(events),
|
|
489
|
+
hiddenTurns: withdrawnTurns(events),
|
|
490
|
+
};
|
|
482
491
|
};
|
|
483
492
|
|
|
484
493
|
/**
|
|
@@ -588,7 +597,7 @@ export function apply(ctx) {
|
|
|
588
597
|
plugin: 'dsh-session-rewind',
|
|
589
598
|
archive: { directory: archive.directory, ...archiveStats },
|
|
590
599
|
client: { discovered: clientPath !== null, path: clientPath },
|
|
591
|
-
routes: [SELFCHECK_PATH, DIAGNOSE_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
|
|
600
|
+
routes: [SELFCHECK_PATH, DIAGNOSE_PATH, TAIL_PATH, SURFACE_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
|
|
592
601
|
};
|
|
593
602
|
},
|
|
594
603
|
trustedHosts,
|
|
@@ -622,6 +631,18 @@ export function apply(ctx) {
|
|
|
622
631
|
onError,
|
|
623
632
|
});
|
|
624
633
|
|
|
634
|
+
const surfaceHandler = createSurfaceHandler({
|
|
635
|
+
surfaceOf: (sessionId) => liveAgentOf(ctx, sessionId)?.session?.surface ?? null,
|
|
636
|
+
trustedHosts,
|
|
637
|
+
onError,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const tailHandler = createTailHandler({
|
|
641
|
+
events: async (sessionId) => readEvents(ctx, sessionId),
|
|
642
|
+
trustedHosts,
|
|
643
|
+
onError,
|
|
644
|
+
});
|
|
645
|
+
|
|
625
646
|
const cutsHandler = createCutsHandler({
|
|
626
647
|
cutsOf,
|
|
627
648
|
trustedHosts,
|
|
@@ -637,6 +658,14 @@ export function apply(ctx) {
|
|
|
637
658
|
() => scope.webServer.register({ kind: 'exact', path: SELFCHECK_PATH, handler: statusHandler }),
|
|
638
659
|
'session-rewind: selfcheck route',
|
|
639
660
|
);
|
|
661
|
+
scope.effect(
|
|
662
|
+
() => scope.webServer.register({ kind: 'exact', path: SURFACE_PATH, handler: surfaceHandler }),
|
|
663
|
+
'session-rewind: surface route',
|
|
664
|
+
);
|
|
665
|
+
scope.effect(
|
|
666
|
+
() => scope.webServer.register({ kind: 'exact', path: TAIL_PATH, handler: tailHandler }),
|
|
667
|
+
'session-rewind: tail route',
|
|
668
|
+
);
|
|
640
669
|
scope.effect(
|
|
641
670
|
() => scope.webServer.register({ kind: 'exact', path: CANDIDATES_PATH, handler: candidatesHandler }),
|
|
642
671
|
'session-rewind: candidates route',
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Search decompressed session logs for a needle and print the sessions + events.
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
5
|
+
|
|
6
|
+
const NEEDLE = process.argv[2] ?? 'session-rewind';
|
|
7
|
+
const ROOT = 'C:/Users/23107/.dsh/sessions';
|
|
8
|
+
|
|
9
|
+
const hits = [];
|
|
10
|
+
for (const workspace of readdirSync(ROOT)) {
|
|
11
|
+
const workspaceDir = join(ROOT, workspace);
|
|
12
|
+
if (!statSync(workspaceDir).isDirectory()) continue;
|
|
13
|
+
for (const session of readdirSync(workspaceDir)) {
|
|
14
|
+
const sessionDir = join(workspaceDir, session);
|
|
15
|
+
if (!statSync(sessionDir).isDirectory()) continue;
|
|
16
|
+
const file = join(sessionDir, 'session.v4.jsonl.zstd');
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = zstdDecompressSync(readFileSync(file)).toString('utf8');
|
|
20
|
+
} catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!text.includes(NEEDLE)) continue;
|
|
24
|
+
const lines = text.split('\n').filter((line) => line !== '');
|
|
25
|
+
const marked = lines.filter((line) => line.includes(NEEDLE));
|
|
26
|
+
hits.push({
|
|
27
|
+
session,
|
|
28
|
+
modified: statSync(file).mtime.toISOString(),
|
|
29
|
+
events: lines.length,
|
|
30
|
+
marked: marked.length,
|
|
31
|
+
samples: marked.slice(-2).map((line) => line.slice(0, 260)),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
hits.sort((a, b) => (a.modified < b.modified ? 1 : -1));
|
|
37
|
+
console.log(`sessions containing ${JSON.stringify(NEEDLE)}: ${hits.length}`);
|
|
38
|
+
for (const hit of hits.slice(0, 6)) {
|
|
39
|
+
console.log(`\n=== ${hit.session}`);
|
|
40
|
+
console.log(`modified: ${hit.modified} events: ${hit.events} marked: ${hit.marked}`);
|
|
41
|
+
for (const sample of hit.samples) console.log(` ${sample}`);
|
|
42
|
+
}
|
package/test/selftest.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
listCandidates,
|
|
21
21
|
markerPayload,
|
|
22
22
|
planInPlace,
|
|
23
|
+
withdrawnTurns,
|
|
23
24
|
} from '../src/lib/surface.js';
|
|
24
25
|
|
|
25
26
|
const events = [
|
|
@@ -95,6 +96,45 @@ assert.equal(payload.content[0].text, MARKER_TEXT);
|
|
|
95
96
|
assert.notEqual(payload.content[0].text, '');
|
|
96
97
|
assert.deepEqual(payload.source, { kind: MARKER_SOURCE_KIND });
|
|
97
98
|
|
|
99
|
+
// --- withdrawn turns ------------------------------------------------------
|
|
100
|
+
// A rewind at seq 7 withdraws turns 1-2 up to the marker; turn 3 starts after it
|
|
101
|
+
// and must stay visible.
|
|
102
|
+
{
|
|
103
|
+
const rewound = [
|
|
104
|
+
{ seq: 0, type: 'session', data: {} },
|
|
105
|
+
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
|
|
106
|
+
{ seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'one' }] } },
|
|
107
|
+
{ seq: 5, type: 'turn/end', data: { turn: 1 } },
|
|
108
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
109
|
+
{ seq: 7, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'two' }] } },
|
|
110
|
+
{ seq: 9, type: 'turn/end', data: { turn: 2 } },
|
|
111
|
+
{ seq: 10, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 7, endSeq: 9 } },
|
|
112
|
+
{ seq: 11, type: 'turn/start', data: { turn: 3 } },
|
|
113
|
+
{ seq: 12, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'three' }] } },
|
|
114
|
+
];
|
|
115
|
+
assert.deepEqual(withdrawnTurns(rewound), [2]);
|
|
116
|
+
assert.deepEqual(withdrawnTurns(events), []);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- two rewinds in one Session -------------------------------------------
|
|
120
|
+
// Each marker withdraws its own span; the union is what must disappear. Missing
|
|
121
|
+
// the second marker is what left the later turns on screen.
|
|
122
|
+
{
|
|
123
|
+
const twice = [
|
|
124
|
+
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
|
|
125
|
+
{ seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'one' }] } },
|
|
126
|
+
{ seq: 5, type: 'turn/end', data: { turn: 1 } },
|
|
127
|
+
{ seq: 6, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 5 } },
|
|
128
|
+
{ seq: 7, type: 'turn/start', data: { turn: 2 } },
|
|
129
|
+
{ seq: 8, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'two' }] } },
|
|
130
|
+
{ seq: 11, type: 'turn/end', data: { turn: 2 } },
|
|
131
|
+
{ seq: 12, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 8, endSeq: 11 } },
|
|
132
|
+
{ seq: 13, type: 'turn/start', data: { turn: 3 } },
|
|
133
|
+
{ seq: 14, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'three' }] } },
|
|
134
|
+
];
|
|
135
|
+
assert.deepEqual(withdrawnTurns(twice), [1, 2]);
|
|
136
|
+
}
|
|
137
|
+
|
|
98
138
|
// --- archive -------------------------------------------------------------
|
|
99
139
|
const root = await mkdtemp(join(tmpdir(), 'rewind-test-'));
|
|
100
140
|
const archiveDir = join(root, 'archive');
|