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 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 BOOT_REVISION = '1.2.5';
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
- cutTurn: null,
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
- const shouldHide = state.cutTurn !== null && turn >= state.cutTurn;
918
- const already = state.hiddenRows.get(row);
919
- if (shouldHide && already === undefined) {
920
- state.hiddenRows.set(row, row.style.display);
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.hiddenRows.size,
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
- state.cutTurn = Number.isInteger(value.hiddenFromTurn) ? value.hiddenFromTurn : null;
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.2.8",
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",
@@ -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({ ctx, sessionId, seq, restoreTurns, timeoutMs = IDLE_TIMEOUT_MS }) {
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
  *
@@ -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 { hiddenFromSeq: hiddenFromSeq(events), hiddenFromTurn: hiddenFromTurn(events) };
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');