dsh-session-rewind 1.2.8 → 1.3.0
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 +36 -12
- package/package.json +1 -1
- package/src/lib/inplace.js +18 -2
- package/src/lib/route.js +72 -0
- package/src/lib/surface.js +35 -0
- package/src/plugin.js +20 -3
- package/test/find-markers.mjs +42 -0
- package/test/selftest.mjs +21 -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.0';
|
|
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,
|
|
@@ -908,24 +911,33 @@ loader.load({
|
|
|
908
911
|
};
|
|
909
912
|
|
|
910
913
|
/** Hide or restore the rendered rows at or after the executed cut. */
|
|
914
|
+
/**
|
|
915
|
+
* Hide exactly the rows of the Turns a rewind withdrew.
|
|
916
|
+
*
|
|
917
|
+
* The attribute (plus a `display: none !important` rule in SHEET) is used
|
|
918
|
+
* instead of an inline style because the Chat re-renders its rows and would
|
|
919
|
+
* drop an inline override; an unknown attribute survives that. Turns the user
|
|
920
|
+
* starts after rewinding are not in the set, so they stay visible.
|
|
921
|
+
*/
|
|
911
922
|
const applyHiding = () => {
|
|
912
923
|
if (state.disposed) return;
|
|
913
924
|
const rows = document.querySelectorAll('[data-chat-turn]');
|
|
914
925
|
for (const row of rows) {
|
|
915
926
|
const turn = Number(row.getAttribute('data-chat-turn'));
|
|
916
927
|
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);
|
|
928
|
+
if (state.hiddenTurns.has(turn)) {
|
|
929
|
+
if (!row.hasAttribute(HIDDEN_ATTR)) row.setAttribute(HIDDEN_ATTR, 'true');
|
|
930
|
+
} else if (row.hasAttribute(HIDDEN_ATTR)) {
|
|
931
|
+
row.removeAttribute(HIDDEN_ATTR);
|
|
925
932
|
}
|
|
926
933
|
}
|
|
927
934
|
};
|
|
928
935
|
|
|
936
|
+
const revealAll = () => {
|
|
937
|
+
state.hiddenTurns.clear();
|
|
938
|
+
for (const row of document.querySelectorAll(`[${HIDDEN_ATTR}]`)) row.removeAttribute(HIDDEN_ATTR);
|
|
939
|
+
};
|
|
940
|
+
|
|
929
941
|
const closePopover = () => {
|
|
930
942
|
if (state.popover !== null) {
|
|
931
943
|
try {
|
|
@@ -943,6 +955,13 @@ loader.load({
|
|
|
943
955
|
const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
|
|
944
956
|
if (state.disposed) return;
|
|
945
957
|
if (Number.isInteger(result.hiddenFromTurn)) state.cutTurn = result.hiddenFromTurn;
|
|
958
|
+
if (Array.isArray(result.hiddenTurns) && result.hiddenTurns.length > 0) {
|
|
959
|
+
for (const turn of result.hiddenTurns) {
|
|
960
|
+
if (Number.isInteger(turn)) state.hiddenTurns.add(turn);
|
|
961
|
+
}
|
|
962
|
+
} else if (Number.isInteger(result.hiddenFromTurn)) {
|
|
963
|
+
state.hiddenTurns = new Set([result.hiddenFromTurn]);
|
|
964
|
+
}
|
|
946
965
|
applyHiding();
|
|
947
966
|
const filled = setComposer(typeof result.prompt === 'string' ? result.prompt : '');
|
|
948
967
|
sendDiagnose(sessionId, {
|
|
@@ -953,7 +972,7 @@ loader.load({
|
|
|
953
972
|
promptChars: typeof result.prompt === 'string' ? result.prompt.length : 0,
|
|
954
973
|
restore: result.restore ?? null,
|
|
955
974
|
injected: state.buttons.size,
|
|
956
|
-
hiddenRows: state.
|
|
975
|
+
hiddenRows: state.hiddenTurns.size,
|
|
957
976
|
});
|
|
958
977
|
const restore = result.restore;
|
|
959
978
|
const parts = [];
|
|
@@ -1152,6 +1171,7 @@ loader.load({
|
|
|
1152
1171
|
button.addEventListener('click', (event) => {
|
|
1153
1172
|
event.preventDefault();
|
|
1154
1173
|
event.stopPropagation();
|
|
1174
|
+
sendDiagnose(sessionId, { reason: 'click', turn, seq, buttons: state.buttons.size, mapped: state.turnToSeq.size });
|
|
1155
1175
|
openPopover(button, seq, notice);
|
|
1156
1176
|
});
|
|
1157
1177
|
anchor.appendChild(button);
|
|
@@ -1219,7 +1239,11 @@ loader.load({
|
|
|
1219
1239
|
try {
|
|
1220
1240
|
const value = await postJson(CUTS_ROUTE, { sessionId });
|
|
1221
1241
|
if (state.disposed) return;
|
|
1222
|
-
|
|
1242
|
+
if (Array.isArray(value.hiddenTurns)) {
|
|
1243
|
+
state.hiddenTurns = new Set(value.hiddenTurns.filter((turn) => Number.isInteger(turn)));
|
|
1244
|
+
} else if (Number.isInteger(value.hiddenFromTurn)) {
|
|
1245
|
+
state.hiddenTurns = new Set([value.hiddenFromTurn]);
|
|
1246
|
+
}
|
|
1223
1247
|
applyHiding();
|
|
1224
1248
|
} catch {
|
|
1225
1249
|
/* ignore */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-rewind",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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,7 @@ 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';
|
|
24
25
|
export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
|
|
25
26
|
|
|
26
27
|
/** A rewind request is a session id plus a Turn number or a seq. */
|
|
@@ -198,6 +199,77 @@ function targetOf(value) {
|
|
|
198
199
|
return { sessionId, turn, seq };
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Build the event-tail route.
|
|
204
|
+
*
|
|
205
|
+
* Returns the last N events of a Session in reading order with just enough shape
|
|
206
|
+
* to reason about a rewind: seq, type, source kind and a short text sample. It is
|
|
207
|
+
* the only way to see whether a marker actually landed, because the on-disk log is
|
|
208
|
+
* compressed and may lag behind the live surface.
|
|
209
|
+
*
|
|
210
|
+
* @param {{
|
|
211
|
+
* events: (sessionId: string) => Promise<readonly unknown[]>,
|
|
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 createTailHandler({ events, 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 limit = Number.isInteger(value.limit) && value.limit > 0 && value.limit <= 300 ? value.limit : 40;
|
|
238
|
+
const all = await events(sessionId);
|
|
239
|
+
const slice = all.slice(-limit);
|
|
240
|
+
writeJson(response, 200, {
|
|
241
|
+
ok: true,
|
|
242
|
+
total: all.length,
|
|
243
|
+
events: slice.map((event) => summarizeEvent(event)),
|
|
244
|
+
});
|
|
245
|
+
} catch (error) {
|
|
246
|
+
fail(response, error, onError);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Reduce one Session event to the fields worth inspecting.
|
|
253
|
+
*
|
|
254
|
+
* @param {unknown} event
|
|
255
|
+
* @returns {{ seq: number | null, type: string, sourceKind: string | null, role: string | null, text: string | null }}
|
|
256
|
+
*/
|
|
257
|
+
function summarizeEvent(event) {
|
|
258
|
+
const record = isRecord(event) ? event : {};
|
|
259
|
+
const data = isRecord(record.data) ? record.data : {};
|
|
260
|
+
const source = isRecord(data.source) ? data.source : {};
|
|
261
|
+
const content = Array.isArray(data.content) ? data.content : [];
|
|
262
|
+
const first = content.find((part) => isRecord(part) && part.type === 'text');
|
|
263
|
+
const text = isRecord(first) && typeof first.text === 'string' ? first.text.slice(0, 60) : null;
|
|
264
|
+
return {
|
|
265
|
+
seq: Number.isInteger(record.seq) ? record.seq : null,
|
|
266
|
+
type: typeof record.type === 'string' ? record.type : '(none)',
|
|
267
|
+
sourceKind: typeof source.kind === 'string' ? source.kind : null,
|
|
268
|
+
role: typeof data.role === 'string' ? data.role : null,
|
|
269
|
+
text,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
201
273
|
/**
|
|
202
274
|
* Build the browser-diagnostics route.
|
|
203
275
|
*
|
package/src/lib/surface.js
CHANGED
|
@@ -169,6 +169,41 @@ 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
|
+
* `hiddenFromSeq` gives the first withdrawn Seq, but the Chat view keys rows by
|
|
176
|
+
* Turn, not Seq — and a plain "turn >= first" rule would also hide the turns the
|
|
177
|
+
* user starts *after* rewinding. The exact set is the turns spanned by the events
|
|
178
|
+
* in `[startSeq, markerSeq)`: everything the marker replaced, and nothing after it.
|
|
179
|
+
*
|
|
180
|
+
* @param {readonly unknown[]} events
|
|
181
|
+
* @returns {readonly number[]}
|
|
182
|
+
*/
|
|
183
|
+
export function withdrawnTurns(events) {
|
|
184
|
+
const startSeq = hiddenFromSeq(events);
|
|
185
|
+
if (startSeq === null) return [];
|
|
186
|
+
let markerSeq = null;
|
|
187
|
+
for (const event of events) {
|
|
188
|
+
if (!isRecord(event) || event.type !== 'user/message') continue;
|
|
189
|
+
if (event.data?.source?.kind !== MARKER_SOURCE_KIND) continue;
|
|
190
|
+
if (typeof event.seq === 'number' && (markerSeq === null || event.seq < markerSeq)) markerSeq = event.seq;
|
|
191
|
+
}
|
|
192
|
+
if (markerSeq === null || markerSeq <= startSeq) return [];
|
|
193
|
+
const turns = new Set();
|
|
194
|
+
let current = null;
|
|
195
|
+
for (const event of events) {
|
|
196
|
+
if (!isRecord(event) || typeof event.seq !== 'number') continue;
|
|
197
|
+
const turn = event.data?.turn;
|
|
198
|
+
if (Number.isInteger(turn) && event.type === 'turn/start') current = turn;
|
|
199
|
+
else if (Number.isInteger(turn) && event.type === 'turn/end') current = turn;
|
|
200
|
+
if (event.seq < startSeq) continue;
|
|
201
|
+
if (event.seq >= markerSeq) break;
|
|
202
|
+
if (Number.isInteger(current)) turns.add(current);
|
|
203
|
+
}
|
|
204
|
+
return [...turns].sort((left, right) => left - right);
|
|
205
|
+
}
|
|
206
|
+
|
|
172
207
|
/**
|
|
173
208
|
* Build the marker event payload. `id` is minted here because the Host's own
|
|
174
209
|
* `createUserMessage` helper is not importable from a profile bundle.
|
package/src/plugin.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
CUTS_PATH,
|
|
30
30
|
DIAGNOSE_PATH,
|
|
31
31
|
INPLACE_PATH,
|
|
32
|
+
TAIL_PATH,
|
|
32
33
|
PLAN_PATH,
|
|
33
34
|
RESTORE_PATH,
|
|
34
35
|
SELFCHECK_PATH,
|
|
@@ -36,12 +37,13 @@ import {
|
|
|
36
37
|
createCutsHandler,
|
|
37
38
|
createDiagnoseHandler,
|
|
38
39
|
createInplaceHandler,
|
|
40
|
+
createTailHandler,
|
|
39
41
|
createPlanHandler,
|
|
40
42
|
createRestoreHandler,
|
|
41
43
|
createSelfcheckHandler,
|
|
42
44
|
} from './lib/route.js';
|
|
43
45
|
import { describeInPlace, executeInPlace, liveAgentOf } from './lib/inplace.js';
|
|
44
|
-
import { hiddenFromSeq, hiddenFromTurn } from './lib/surface.js';
|
|
46
|
+
import { hiddenFromSeq, hiddenFromTurn, withdrawnTurns } from './lib/surface.js';
|
|
45
47
|
|
|
46
48
|
export const name = 'session-rewind';
|
|
47
49
|
|
|
@@ -473,12 +475,17 @@ export function apply(ctx) {
|
|
|
473
475
|
restoreTurns: restoreFiles === true
|
|
474
476
|
? async (fromTurn) => restoreTurns(sessionId, fromTurn)
|
|
475
477
|
: undefined,
|
|
478
|
+
readEvents: (id) => readEvents(ctx, id),
|
|
476
479
|
});
|
|
477
480
|
|
|
478
481
|
const cutsOf = async (sessionId) => {
|
|
479
482
|
// Read the durable log: the client needs the cut after a page reload too.
|
|
480
483
|
const events = await readEvents(ctx, sessionId);
|
|
481
|
-
return {
|
|
484
|
+
return {
|
|
485
|
+
hiddenFromSeq: hiddenFromSeq(events),
|
|
486
|
+
hiddenFromTurn: hiddenFromTurn(events),
|
|
487
|
+
hiddenTurns: withdrawnTurns(events),
|
|
488
|
+
};
|
|
482
489
|
};
|
|
483
490
|
|
|
484
491
|
/**
|
|
@@ -588,7 +595,7 @@ export function apply(ctx) {
|
|
|
588
595
|
plugin: 'dsh-session-rewind',
|
|
589
596
|
archive: { directory: archive.directory, ...archiveStats },
|
|
590
597
|
client: { discovered: clientPath !== null, path: clientPath },
|
|
591
|
-
routes: [SELFCHECK_PATH, DIAGNOSE_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
|
|
598
|
+
routes: [SELFCHECK_PATH, DIAGNOSE_PATH, TAIL_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
|
|
592
599
|
};
|
|
593
600
|
},
|
|
594
601
|
trustedHosts,
|
|
@@ -622,6 +629,12 @@ export function apply(ctx) {
|
|
|
622
629
|
onError,
|
|
623
630
|
});
|
|
624
631
|
|
|
632
|
+
const tailHandler = createTailHandler({
|
|
633
|
+
events: async (sessionId) => readEvents(ctx, sessionId),
|
|
634
|
+
trustedHosts,
|
|
635
|
+
onError,
|
|
636
|
+
});
|
|
637
|
+
|
|
625
638
|
const cutsHandler = createCutsHandler({
|
|
626
639
|
cutsOf,
|
|
627
640
|
trustedHosts,
|
|
@@ -637,6 +650,10 @@ export function apply(ctx) {
|
|
|
637
650
|
() => scope.webServer.register({ kind: 'exact', path: SELFCHECK_PATH, handler: statusHandler }),
|
|
638
651
|
'session-rewind: selfcheck route',
|
|
639
652
|
);
|
|
653
|
+
scope.effect(
|
|
654
|
+
() => scope.webServer.register({ kind: 'exact', path: TAIL_PATH, handler: tailHandler }),
|
|
655
|
+
'session-rewind: tail route',
|
|
656
|
+
);
|
|
640
657
|
scope.effect(
|
|
641
658
|
() => scope.webServer.register({ kind: 'exact', path: CANDIDATES_PATH, handler: candidatesHandler }),
|
|
642
659
|
'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,26 @@ 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
|
+
|
|
98
119
|
// --- archive -------------------------------------------------------------
|
|
99
120
|
const root = await mkdtemp(join(tmpdir(), 'rewind-test-'));
|
|
100
121
|
const archiveDir = join(root, 'archive');
|