dsh-rewind-plugin 0.1.9 → 0.2.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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Checkpoint store — the Claude Code style file-rewind backing for dsh-rewind.
3
+ *
4
+ * Claude Code's checkpointing (see README) works like this: it creates a
5
+ * BACKUP of a file BEFORE every tracked modification, groups those backups by
6
+ * the user message they belong to (a "checkpoint"), and rewinding to a
7
+ * checkpoint restores every backup recorded at or after it — modified files
8
+ * are written back to their pre-edit content, files created after the target
9
+ * are deleted. This module is the same design, persisted on disk:
10
+ *
11
+ * - `tools/execute` captures the BEFORE state of each tracked write/edit call
12
+ * (or "created" when the file did not exist) — the capture happens at the
13
+ * around-dispatch stage, so an approval `ask` short-circuit cannot skip it
14
+ * and a denied call never records.
15
+ * - The entry is committed to disk at `tools/post-execute` under the turn's
16
+ * anchor seq: `<root>/<sessionId>/<anchorSeq>/<callId>.json`, carrying the
17
+ * path and the before content (`before: null` = the file was created).
18
+ * - Because entries live on disk under the dsh data directory, they survive a
19
+ * host restart, are bounded (the newest 100 anchor groups per session are
20
+ * kept), and restores read/write the real file system with plain `node:fs`
21
+ * — independent of the fs service.
22
+ *
23
+ * Restore semantics (identical to Claude Code): for every path with entries
24
+ * anchored at or after the target message, apply the EARLIEST entry — write
25
+ * the before content back, or delete the file when that entry recorded a
26
+ * creation. Symbolic links are skipped and reported, never written through.
27
+ *
28
+ * @module dsh-rewind/snapshot
29
+ */
30
+ /** Default store root: the dsh data directory. */
31
+ export declare const DEFAULT_SNAPSHOT_ROOT: string;
32
+ /** Environment variable overriding the store root (tests, exotic homes). */
33
+ export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
34
+ /** Number of newest anchor groups (user messages) kept per session. */
35
+ export declare const MAX_ANCHOR_GROUPS = 100;
36
+ /** One committed before-backup, keyed by tool call. */
37
+ export interface CheckpointEntry {
38
+ readonly callId: string;
39
+ /** Seq of the user message anchoring the turn in which the change happened. */
40
+ readonly anchorSeq: number;
41
+ /** Resolved display path (absolute) of the tracked file. */
42
+ readonly path: string;
43
+ /** Full content before the change; null when the file was created. */
44
+ readonly before: string | null;
45
+ /** Epoch ms the entry was committed (stable ordering within a group). */
46
+ readonly time: number;
47
+ }
48
+ /** Per-file restore impact preview (`/rewind preview @seq both`). */
49
+ export interface FileImpact {
50
+ readonly path: string;
51
+ /** `restore` = write the before content back; `delete` = remove the file. */
52
+ readonly action: 'restore' | 'delete';
53
+ }
54
+ /** Outcome of one restore pass. */
55
+ export interface RestoreOutcome {
56
+ readonly restored: readonly string[];
57
+ readonly deleted: readonly string[];
58
+ readonly skipped: readonly string[];
59
+ readonly failed: readonly {
60
+ path: string;
61
+ message: string;
62
+ }[];
63
+ }
64
+ /** Deletes one file by its real path (node:fs, bypassing the fs service). */
65
+ export type DeleteFile = (path: string) => Promise<void>;
66
+ /**
67
+ * On-disk checkpoint store. Every write goes straight through `node:fs`, so a
68
+ * restore reliably lands on the real file system.
69
+ */
70
+ export declare class SnapshotStore {
71
+ readonly root: string;
72
+ constructor(root?: string);
73
+ /** Absolute path of one anchor group directory. */
74
+ anchorDir(sessionId: string, anchorSeq: number): string;
75
+ /** Commit one before-backup under its turn's anchor group. */
76
+ recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>): Promise<void>;
77
+ /**
78
+ * All committed entries anchored at or after `targetSeq`, newest first (for
79
+ * preview ordering). The boundary is inclusive: rewinding to a message also
80
+ * reverts the changes its own turn caused (the rewind cut removes that
81
+ * turn's assistant response and tool calls), so only entries anchored at
82
+ * earlier messages survive.
83
+ */
84
+ entriesAfter(sessionId: string, targetSeq: number): Promise<CheckpointEntry[]>;
85
+ /** Per-file restore impact for the earliest entry at/after the target. */
86
+ impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
87
+ /**
88
+ * Restore the workspace to the target message's checkpoint: for every path
89
+ * with entries anchored at or after it, apply the EARLIEST entry — write the
90
+ * before content back, or delete the file when it was created after the
91
+ * target. Symbolic links are skipped (reported, never written through).
92
+ * Failures are per-file and never abort the pass.
93
+ */
94
+ restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
95
+ /**
96
+ * Drop the session's oldest anchor groups beyond `keep` (default
97
+ * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
98
+ */
99
+ prune(sessionId: string, keep?: number): Promise<void>;
100
+ /** True when a path exists on disk (used by tests and diagnostics). */
101
+ exists(path: string): Promise<boolean>;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -2,49 +2,58 @@
2
2
  /**
3
3
  * Host-half verification: boots the built plugin (`lib/index.js`) on a real
4
4
  * cordis context with a real dsh-session, then drives the `/rewind` command
5
- * handler and the tools-pipeline ledger events end to end — no model, no UI.
5
+ * handler and the checkpoint pipeline end to end — no model, no UI. Files are
6
+ * real files under a temporary directory, so a restore is verified against
7
+ * actual on-disk content, and the checkpoint store root is overridden to that
8
+ * temporary directory.
6
9
  *
7
10
  * Run: `npm run build && node scripts/verify-host.mjs`
8
11
  *
9
12
  * What it proves:
10
13
  * 1. the plugin registers a `rewind` command on the ctx;
11
- * 2. `/rewind` (no args) lists recent user messages;
14
+ * 2. `/rewind` (no args) withdraws the most recent user message;
12
15
  * 3. `/rewind @<seq> chat` cuts the surface in-place (log untouched);
13
- * 4. the ledger captures through `tools/execute` (NOT pre-execute): a
14
- * pre-execute `ask` short-circuit still gets captured after approval, and
15
- * a denied call never captures (no pending leak);
16
- * 5. relative file paths resolve against the session cwd (fs-tools rule);
17
- * 6. `/rewind preview @<seq> both` reports the file impact;
18
- * 7. `/rewind @<seq> both` restores the file and reports it.
16
+ * 4. a successful write through the tools pipeline commits a before-backup
17
+ * under the turn's anchor seq;
18
+ * 5. a denied call never commits (no phantom entry in the store);
19
+ * 6. relative file paths resolve against the session cwd (fs-tools rule);
20
+ * 7. `/rewind preview @<seq> both` reports the checkpoint impact;
21
+ * 8. `/rewind @<seq> both` restores the real file to its pre-edit content
22
+ * and deletes files created after the target;
23
+ * 9. a running agent is force-stopped before the rewind (not refused);
24
+ * 10. a cancel that never quiesces aborts the rewind (timeout path).
19
25
  */
20
26
  import { Context } from '@deepseek-ai/cordis'
21
27
  import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
22
28
  import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
23
29
  import { Session, SessionId } from '@deepseek-ai/dsh-session'
30
+ import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
31
+ import { tmpdir } from 'node:os'
24
32
  import { join } from 'node:path'
25
33
  import { apply as applyRewind } from '../lib/index.js'
26
34
 
27
35
  const aborted = () => new AbortController().signal
28
36
 
29
- /** In-memory fs double with session-cwd resolution (resolve/readText/writeText/processPath). */
37
+ const tmpRoot = await mkdtemp(join(tmpdir(), 'dsh-rewind-verify-'))
38
+ const wsDir = join(tmpRoot, 'ws')
39
+ const snapRoot = join(tmpRoot, 'snapshots')
40
+ await mkdir(wsDir, { recursive: true })
41
+
42
+ /** Real-filesystem fs double: resolve returns the real display path. */
30
43
  class FakeFs extends FileSystem {
31
- files = new Map()
32
44
  async resolve(path, opts = {}) {
33
45
  const displayPath = opts?.cwd !== undefined && !path.startsWith('/') ? join(opts.cwd, path) : path
34
46
  return { targetKey: FsTargetKey(displayPath), displayPath }
35
47
  }
36
48
  processPath(target) { return target.displayPath }
37
- async readText(target) {
38
- const content = this.files.get(target.displayPath)
39
- if (content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
40
- return content
49
+ async readText(target) { return readFile(target.displayPath, 'utf8') }
50
+ async writeText(target, content) { await writeFile(target.displayPath, content, 'utf8'); return { operation: 'update', version: FsVersion('v'), before: null, after: content } }
51
+ async stat(target) {
52
+ try { await readFile(target.displayPath); return { version: FsVersion('v'), type: 'file' } } catch { return undefined }
41
53
  }
42
- async writeText(target, content) { this.files.set(target.displayPath, content); return { operation: 'update', version: FsVersion('v'), before: null, after: content } }
43
- async stat(target) { return this.files.has(target.displayPath) ? { version: FsVersion('v'), type: 'file' } : undefined }
44
54
  }
45
55
 
46
56
  const fs = new FakeFs(new Context())
47
- fs.files.set('/workspace/a.txt', 'original content')
48
57
 
49
58
  const user = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
50
59
  const assistant = text => createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'test', model: 'test' } })
@@ -73,10 +82,20 @@ ctx.provide('commands', {
73
82
  },
74
83
  })
75
84
  ctx.provide('fs', fs)
76
- applyRewind(ctx)
85
+ applyRewind(ctx, { snapshotDir: snapRoot })
77
86
 
78
87
  const call = (agentOf, rawInput) => registered.handler({ commandId: Symbol('cid'), agent: agentOf, rawInput, signal: aborted() })
79
88
 
89
+ /** Simulate one tracked tool call: before-capture, dispatch writes the file, post-execute commits. */
90
+ async function runWrite(agentOf, callId, filePath, content) {
91
+ const exec = { callId, name: 'write', arguments: { file_path: filePath, content }, agent: agentOf, signal: aborted() }
92
+ await ctx.waterfall('tools/execute', exec, async () => {
93
+ await fs.writeText({ targetKey: FsTargetKey(filePath), displayPath: filePath }, content)
94
+ return { isError: false, content: [] }
95
+ })
96
+ await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
97
+ }
98
+
80
99
  let failures = 0
81
100
  const check = (name, ok, detail) => {
82
101
  console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ` — ${detail}`}`)
@@ -86,86 +105,102 @@ const check = (name, ok, detail) => {
86
105
  // 1. command registered
87
106
  check('command registered', typeof registered?.handler === 'function' && registered.name === 'rewind', JSON.stringify(registered))
88
107
 
89
- // 2. bare /rewind lists candidates
90
- const listResult = await call(agent, '')
91
- check('bare /rewind lists candidates', listResult.kind === 'success' && listResult.text.includes('second question') && listResult.text.includes('first question'), listResult.text)
108
+ // 2. bare /rewind (manual, no parameters) withdraws the most recent user
109
+ // message (seq 2 "second question") and everything after it
110
+ const bareBefore = [...session.surface.nodes]
111
+ const bareResult = await call(agent, '')
112
+ const bareAfter = [...session.surface.nodes]
113
+ check('bare /rewind succeeds', bareResult.kind === 'success', bareResult.text)
114
+ check('bare /rewind withdraws the latest message', bareAfter.length === 3 && bareAfter[0] === 0 && bareAfter[1] === 1 && bareAfter[2] > 3, `before ${JSON.stringify(bareBefore)} -> after ${JSON.stringify(bareAfter)}`)
115
+ check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
92
116
 
93
- // 3. /rewind @2 chat withdraws the target (seq 2) and everything after it
94
- const before = [...session.surface.nodes]
95
- const chatResult = await call(agent, '@2 chat')
96
- const after = [...session.surface.nodes]
117
+ // 3. /rewind @<seq> chat (the button's exact call form) cuts the surface on a
118
+ // fresh session
119
+ const paramSession = buildSession('verify-param')
120
+ const paramAgent = { id: paramSession.id, session: paramSession, status: 'idle' }
121
+ const before = [...paramSession.surface.nodes]
122
+ const chatResult = await call(paramAgent, '@2 chat')
123
+ const after = [...paramSession.surface.nodes]
97
124
  check('rewind chat succeeds', chatResult.kind === 'success', chatResult.text)
98
125
  check('surface cut to [0,1,marker] (target withdrawn)', after.length === 3 && after[0] === 0 && after[1] === 1 && after[2] > 3, `before ${JSON.stringify(before)} -> after ${JSON.stringify(after)}`)
99
- check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
100
-
101
- const writeExec = (callId, filePath, content) => ({
102
- callId, name: 'write', arguments: { file_path: filePath, content }, agent, signal: aborted(),
103
- })
126
+ check('log stays append-only (5 events)', paramSession.events.length === 5, `events=${paramSession.events.length}`)
104
127
 
105
- // 4. a pre-execute `ask` short-circuit (dsh-edit-approval) must not skip the
106
- // capture: capture happens in tools/execute, which runs after approval.
128
+ // 4. a tracked write commits a before-backup; rewinding both restores the
129
+ // real file and deletes files created after the target
107
130
  {
108
- const exec = writeExec('c1', '/workspace/a.txt', 'rewritten')
109
- // Another plugin asks at pre-execute; the user then allows it.
110
- const gate = await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'ask', reason: 'approve me' }))
111
- check('pre-execute gate asks', gate.kind === 'ask', JSON.stringify(gate))
112
- // Approved dispatch stage runs: capture fires here.
113
- await ctx.waterfall('tools/execute', exec, async () => ({ isError: false, content: [] }))
114
- await fs.writeText({ targetKey: FsTargetKey('/workspace/a.txt'), displayPath: '/workspace/a.txt' }, 'rewritten')
115
- await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
116
- check('file mutated on disk', fs.files.get('/workspace/a.txt') === 'rewritten', fs.files.get('/workspace/a.txt'))
131
+ const aPath = join(wsDir, 'a.txt')
132
+ await writeFile(aPath, 'original content', 'utf8')
133
+ // The next user message anchors the turn that will edit a.txt (seq 5).
134
+ session.append('user/message', user('third question'), { surfaceOp: 'append' })
135
+ const anchorSeq = 5
136
+ await runWrite(agent, 'c1', aPath, 'rewritten') // before-capture: 'original content'
137
+ const createdPath = join(wsDir, 'created.txt')
138
+ await runWrite(agent, 'c2', createdPath, 'new') // file did not exist: before-capture = created
139
+ await writeFile(aPath, 'v3', 'utf8') // later edit lands after the backups
140
+
141
+ const preview = await call(agent, `preview @${anchorSeq} both`)
142
+ check('preview reports the file impact', preview.kind === 'success' && preview.text.includes(aPath) && preview.text.includes('还原'), preview.text)
143
+
144
+ const both = await call(agent, `@${anchorSeq} both`)
145
+ check('rewind both succeeds', both.kind === 'success' && both.text.includes('还原 1 个文件') && both.text.includes('删除 1 个文件'), both.text)
146
+ check('modified file restored to pre-edit content', await readFile(aPath, 'utf8') === 'original content', await readFile(aPath, 'utf8'))
147
+ let createdGone = false
148
+ try { await readFile(createdPath, 'utf8') } catch { createdGone = true }
149
+ check('created file deleted', createdGone, `exists=${!createdGone}`)
117
150
  }
118
151
 
119
- // 5. a denied call never captures (no pending leak: nothing recorded after it)
152
+ // 5. a denied call never commits (no phantom entry)
120
153
  {
121
- const exec = writeExec('c2', '/workspace/a.txt', 'denied write')
122
- const gate = await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'deny', reason: 'no' }))
123
- check('pre-execute gate denies', gate.kind === 'deny', JSON.stringify(gate))
124
- // Denied calls do not dispatch: post-execute must record nothing for c2.
154
+ const deniedPath = join(wsDir, 'denied.txt')
155
+ await writeFile(deniedPath, 'x', 'utf8')
156
+ const exec = { callId: 'c3', name: 'write', arguments: { file_path: deniedPath, content: 'denied write' }, agent, signal: aborted() }
157
+ await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'deny', reason: 'no' }))
125
158
  await ctx.waterfall('tools/post-execute', exec, { isError: true, error: { message: 'denied', info: { name: 'x', code: 'y' } }, content: [] }, async () => ({ kind: 'accept' }))
159
+ const preview = await call(agent, 'preview @5 both')
160
+ check('denied call is not in the impact list', !preview.text.includes(deniedPath), preview.text)
126
161
  }
127
162
 
128
163
  // 6. relative paths resolve against the session cwd (fs-tools rule)
129
164
  {
130
- const cwdSession = buildSession('verify-cwd', '/workspace')
165
+ const cwdSession = buildSession('verify-cwd', wsDir)
131
166
  const cwdAgent = { id: cwdSession.id, session: cwdSession, status: 'idle' }
132
- fs.files.set('/workspace/rel.txt', 'relative original')
133
- const exec = { callId: 'c3', name: 'write', arguments: { file_path: 'rel.txt', content: 'relative new' }, agent: cwdAgent, signal: aborted() }
134
- await ctx.waterfall('tools/execute', exec, async () => ({ isError: false, content: [] }))
135
- await fs.writeText({ targetKey: FsTargetKey('/workspace/rel.txt'), displayPath: '/workspace/rel.txt' }, 'relative new')
136
- await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
137
- // Rewind to seq 2 in the cwd session must report the cwd-resolved path.
138
- const preview = await call(cwdAgent, 'preview @2 both')
139
- check('preview resolves relative path via session cwd', preview.kind === 'success' && preview.text.includes('/workspace/rel.txt'), preview.text)
140
- const both = await call(cwdAgent, '@2 both')
141
- check('both restores cwd-resolved file', both.kind === 'success' && fs.files.get('/workspace/rel.txt') === 'relative original', both.text)
167
+ const relPath = join(wsDir, 'rel.txt')
168
+ await writeFile(relPath, 'relative original', 'utf8')
169
+ cwdSession.append('user/message', user('relative question'), { surfaceOp: 'append' })
170
+ await runWrite(cwdAgent, 'c4', 'rel.txt', 'relative new') // relative path
171
+
172
+ const preview = await call(cwdAgent, 'preview @4 both')
173
+ check('relative path resolved via session cwd', preview.kind === 'success' && preview.text.includes(relPath), preview.text)
174
+ const both = await call(cwdAgent, '@4 both')
175
+ check('both restores cwd-resolved file', both.kind === 'success' && await readFile(relPath, 'utf8') === 'relative original', both.text)
176
+ }
177
+
178
+ // 7. preview on a message with no recorded changes reports none
179
+ {
180
+ const cleanSession = buildSession('verify-norec')
181
+ const cleanAgent = { id: cleanSession.id, session: cleanSession, status: 'idle' }
182
+ const previewResult = await call(cleanAgent, 'preview @2 both')
183
+ check('preview with no entries reports no changes', previewResult.kind === 'success' && previewResult.text.includes('无需还原文件'), previewResult.text)
142
184
  }
143
185
 
144
- // 7. preview reports the impact (rewind to seq 0 still on the surface after
145
- // the earlier withdraw — reverts the anchor-0 write)
146
- const previewResult = await call(agent, 'preview @0 both')
147
- check('preview shows file impact', previewResult.kind === 'success' && previewResult.text.includes('/workspace/a.txt'), previewResult.text)
148
-
149
- // 8. both mode restores the file
150
- const bothResult = await call(agent, '@0 both')
151
- check('rewind both restores file', bothResult.kind === 'success' && bothResult.text.includes('还原 1 个文件'), bothResult.text)
152
- check('file content restored', fs.files.get('/workspace/a.txt') === 'original content', fs.files.get('/workspace/a.txt'))
153
-
154
- // 9. a running agent is force-stopped before the rewind (not refused)
155
- const runningSession = buildSession('verify-running')
156
- const running = {
157
- ...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running',
158
- cancel: () => { cancelled = true; running.status = 'idle' },
186
+ // 8. a running agent is force-stopped before the rewind (not refused)
187
+ {
188
+ const runningSession = buildSession('verify-running')
189
+ let cancelled = false
190
+ const running = { id: runningSession.id, session: runningSession, status: 'running', cancel: () => { cancelled = true; running.status = 'idle' } }
191
+ const runningResult = await call(running, '@2 chat')
192
+ check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
193
+ check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
159
194
  }
160
- let cancelled = false
161
- const runningResult = await registered.handler({ commandId: Symbol('cid'), agent: running, rawInput: '@2 chat', signal: aborted() })
162
- check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
163
- check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
164
195
 
165
- // 9b. a cancel that never quiesces aborts the rewind (timeout path)
166
- const stuck = { ...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running', cancel: () => {} }
167
- const stuckResult = await registered.handler({ commandId: Symbol('cid'), agent: stuck, rawInput: '@2 chat', signal: aborted() })
168
- check('stuck agent aborts rewind', stuckResult.kind === 'error', stuckResult.text)
196
+ // 9. a cancel that never quiesces aborts the rewind (timeout path)
197
+ {
198
+ const stuckSession = buildSession('verify-stuck')
199
+ const stuck = { id: stuckSession.id, session: stuckSession, status: 'running', cancel: () => {} }
200
+ const stuckResult = await call(stuck, '@2 chat')
201
+ check('stuck agent aborts rewind', stuckResult.kind === 'error', stuckResult.text)
202
+ }
169
203
 
204
+ await rm(tmpRoot, { recursive: true, force: true })
170
205
  console.log(failures === 0 ? '\nverify-host: all checks passed' : `\nverify-host: ${failures} check(s) FAILED`)
171
206
  process.exit(failures === 0 ? 0 : 1)
@@ -1,88 +0,0 @@
1
- /**
2
- * In-memory change ledger: records every write-class tool mutation that
3
- * happened while the plugin was running, so a "rewind conversation and code"
4
- * can reverse the changes that followed a target message.
5
- *
6
- * Scope (v0.1): the ledger covers only `write` / `edit` / `str_replace_editor`
7
- * mutations observed through the tools pipeline while the plugin is loaded.
8
- * Changes made by bash or external programs are not recorded and cannot be
9
- * restored; a git-first snapshot layer is a v2 option.
10
- *
11
- * @module dsh-rewind/ledger
12
- */
13
- import type { FileSystem } from '@deepseek-ai/dsh-fs';
14
- /** One recorded write-class mutation. */
15
- export interface LedgerEntry {
16
- /** Tool that made the change: `write` | `edit` | `str_replace_editor`. */
17
- readonly toolName: string;
18
- /** Seq of the user message anchoring the turn in which the change happened. */
19
- readonly anchorSeq: number;
20
- /** Display path (model/UI-facing), as resolved at record time. */
21
- readonly path: string;
22
- /** Full file content before the change; undefined when the file was created. */
23
- readonly before: string | undefined;
24
- /** Full file content after the change. */
25
- readonly after: string;
26
- }
27
- /** Unique per-file impact of rewinding past a target message. */
28
- export interface FileImpact {
29
- readonly path: string;
30
- /** `restore` = the file existed before the target; `delete` = created after it. */
31
- readonly action: 'restore' | 'delete';
32
- }
33
- /** Result of one reverse restore pass. */
34
- export interface RestoreOutcome {
35
- readonly restored: readonly string[];
36
- readonly deleted: readonly string[];
37
- readonly failed: readonly {
38
- path: string;
39
- message: string;
40
- }[];
41
- }
42
- /** Deletes one file by its process path (the host supplies the backend-appropriate delete). */
43
- export type DeleteFile = (processPath: string) => Promise<void>;
44
- /**
45
- * Per-session cap on recorded entries. The ledger is intentionally bounded so
46
- * an extremely long session cannot grow one entry list without limit; the
47
- * oldest entries are dropped first, so rewinds to very early messages in a
48
- * pathological session may lose the earliest file history (a declared
49
- * tradeoff, see README).
50
- */
51
- export declare const MAX_LEDGER_ENTRIES = 2000;
52
- /**
53
- * Append-only change ledger. Entries are recorded in commit order; a rewind
54
- * replays them in reverse for the affected range. Bounded per session to
55
- * {@link MAX_LEDGER_ENTRIES} (oldest dropped first).
56
- */
57
- export declare class RewindLedger {
58
- private readonly entries;
59
- /** Record one committed mutation, dropping the oldest entry when over the cap. */
60
- record(entry: LedgerEntry): void;
61
- /**
62
- * All entries anchored at or after `targetSeq`, newest first. The boundary
63
- * is inclusive: rewinding to a message also reverts the changes its own
64
- * turn caused (the rewind cut removes that turn's assistant response and
65
- * tool calls), so only changes anchored at earlier messages survive.
66
- */
67
- changesAfter(targetSeq: number): readonly LedgerEntry[];
68
- /**
69
- * Unique per-file impact for preview. A file whose earliest affected change
70
- * created it (`before === undefined`) is deleted on restore; any other file
71
- * is written back to its pre-target content.
72
- */
73
- impactsAfter(targetSeq: number): readonly FileImpact[];
74
- /**
75
- * Reverse every change anchored at or after `targetSeq`. Each entry writes
76
- * its pre-change content back; a file that did not exist before the target
77
- * is deleted instead. Failures are collected per file and never abort the pass.
78
- * @param fs - the filesystem service (resolve/readText/writeText/processPath).
79
- * @param deleteFile - backend-appropriate file deletion by process path.
80
- * @param targetSeq - the rewind target; only later changes are reverted.
81
- * @param options - session workspace cwd (relative ledger paths resolve
82
- * against it, mirroring the fs tools) and an optional abort signal.
83
- */
84
- restoreAfter(fs: FileSystem, deleteFile: DeleteFile, targetSeq: number, options?: {
85
- cwd?: string;
86
- signal?: AbortSignal;
87
- }): Promise<RestoreOutcome>;
88
- }