dsh-rewind-plugin 0.1.10 → 0.2.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/README.md +182 -174
- package/README.zh.md +214 -0
- package/assets/screenshots/guard-hint.png +0 -0
- package/assets/screenshots/impact-list.png +0 -0
- package/assets/screenshots/mode-popover.png +0 -0
- package/assets/screenshots/rewind-button.png +0 -0
- package/docs/harness-reference.md +34 -0
- package/lib/client.js +139 -95
- package/lib/index.js +237 -148
- package/lib/types/client/hidden.d.ts +33 -0
- package/lib/types/client/index.d.ts +4 -4
- package/lib/types/client/locales.d.ts +6 -2
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/index.d.ts +39 -16
- package/lib/types/session-cwd.d.ts +1 -1
- package/lib/types/snapshot.d.ts +116 -0
- package/package.json +4 -3
- package/lib/types/ledger.d.ts +0 -88
- package/scripts/build.mjs +0 -89
- package/scripts/verify-host.mjs +0 -179
package/lib/types/index.d.ts
CHANGED
|
@@ -1,35 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-rewind host half: the `/rewind` command and the
|
|
3
|
-
*
|
|
4
|
-
* `src/client/`).
|
|
2
|
+
* dsh-rewind host half: the `/rewind` command and the Claude-Code-style
|
|
3
|
+
* checkpoint store, composed as one dual-face bundle row (the browser half
|
|
4
|
+
* lives in `src/client/`).
|
|
5
5
|
*
|
|
6
6
|
* Rewind mechanism: planning is pure (`src/rewind.ts`); execution appends a
|
|
7
|
-
* marker `
|
|
8
|
-
* surface node after the target message with the marker. The
|
|
9
|
-
* (and the rendered transcript) is untouched — only the
|
|
10
|
-
* is cut, so the next request derives its context from
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* marker `assistant/message` into the session log whose `surfaceOp` replaces
|
|
8
|
+
* every surface node after the target message with the marker. The
|
|
9
|
+
* append-only log (and the rendered transcript) is untouched — only the
|
|
10
|
+
* model-visible surface is cut, so the next request derives its context from
|
|
11
|
+
* the target onward.
|
|
12
|
+
*
|
|
13
|
+
* File restore (mode `both`) follows Claude Code's checkpointing: the plugin
|
|
14
|
+
* backs up each tracked write-class edit BEFORE it happens (at the
|
|
15
|
+
* `tools/execute` around-dispatch stage, so an approval short-circuit cannot
|
|
16
|
+
* skip the capture and a denied call never records), commits the backup under
|
|
17
|
+
* the turn's anchor message seq at `tools/post-execute`, and a rewind to
|
|
18
|
+
* message N restores every backup anchored at or after N — modified files are
|
|
19
|
+
* written back to their pre-edit content, files created after N are deleted.
|
|
20
|
+
* Backups persist on disk under the dsh data directory (newest 100 message
|
|
21
|
+
* groups per session), so restores work after a host restart, and they
|
|
22
|
+
* read/write the real file system with plain `node:fs` — independent of the
|
|
23
|
+
* fs service. See `src/snapshot.ts`.
|
|
13
24
|
*
|
|
14
25
|
* @module dsh-rewind
|
|
15
26
|
*/
|
|
16
27
|
import type { Context } from '@deepseek-ai/cordis';
|
|
28
|
+
export { SnapshotStore } from './snapshot.ts';
|
|
29
|
+
export type { CheckpointEntry, FileImpact, RestoreOutcome } from './snapshot.ts';
|
|
17
30
|
export declare const name = "dsh-rewind";
|
|
18
31
|
export declare const inject: string[];
|
|
32
|
+
/** Plugin config: optional override of the checkpoint store root. */
|
|
33
|
+
export interface RewindConfig {
|
|
34
|
+
/** Checkpoint store root (defaults to `~/.dsh/rewind-snapshots`). */
|
|
35
|
+
readonly snapshotDir?: string;
|
|
36
|
+
}
|
|
19
37
|
/**
|
|
20
|
-
* Register the `/rewind` command and the
|
|
38
|
+
* Register the `/rewind` command and the checkpoint pipeline (before-capture
|
|
39
|
+
* at `tools/execute`, disk commit at `tools/post-execute`).
|
|
21
40
|
*
|
|
22
|
-
* The command is fs-independent and registers immediately. The
|
|
23
|
-
* `fs
|
|
24
|
-
*
|
|
25
|
-
*
|
|
41
|
+
* The command is fs-independent and registers immediately. The checkpoint
|
|
42
|
+
* pipeline needs `fs` to resolve tracked paths to their real display paths,
|
|
43
|
+
* so it mounts through a dynamic `ctx.inject(['fs'])` — it takes effect
|
|
44
|
+
* whenever the fs service becomes available (and never fails the plugin's
|
|
45
|
+
* load when a deployment has no fs; without it, no entries are recorded and
|
|
46
|
+
* `both` restores report "no tracked changes").
|
|
26
47
|
*
|
|
27
48
|
* Capture runs in `tools/execute` (the around-dispatch stage), NOT in
|
|
28
49
|
* `tools/pre-execute`: a pre-execute `{ kind: 'ask' }` short-circuit from
|
|
29
50
|
* another plugin (e.g. dsh-edit-approval) skips later pre-execute listeners,
|
|
30
51
|
* and a denied call never dispatches — so approved calls are still captured,
|
|
31
|
-
* denied calls never leave a pending entry behind.
|
|
52
|
+
* denied calls never leave a pending entry behind. Entries are committed to
|
|
53
|
+
* disk at `tools/post-execute` under the turn's anchor message seq.
|
|
32
54
|
*
|
|
33
55
|
* @param ctx - context carrying `commands`, `tools`, and an optional `fs`.
|
|
56
|
+
* @param config - optional override of the checkpoint store root.
|
|
34
57
|
*/
|
|
35
|
-
export declare function apply(ctx: Context): void;
|
|
58
|
+
export declare function apply(ctx: Context, config?: RewindConfig): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session-cwd resolution for
|
|
2
|
+
* Session-cwd resolution for snapshot tracking reads, mirroring the fs tools'
|
|
3
3
|
* own rule (`@deepseek-ai/dsh-tool-fs/session-cwd.ts`): relative paths
|
|
4
4
|
* resolve against the calling agent's session workspace
|
|
5
5
|
* (`exec.agent.session.header.cwd`), not the server's launch dir.
|
|
@@ -0,0 +1,116 @@
|
|
|
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. Symlinked and hard-linked paths are skipped and reported, never
|
|
27
|
+
* written through.
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-rewind/snapshot
|
|
30
|
+
*/
|
|
31
|
+
/** Default store root: the dsh data directory. */
|
|
32
|
+
export declare const DEFAULT_SNAPSHOT_ROOT: string;
|
|
33
|
+
/** Environment variable overriding the store root (tests, exotic homes). */
|
|
34
|
+
export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
35
|
+
/** Number of newest anchor groups (user messages) kept per session. */
|
|
36
|
+
export declare const MAX_ANCHOR_GROUPS = 100;
|
|
37
|
+
/** One committed before-backup, keyed by tool call. */
|
|
38
|
+
export interface CheckpointEntry {
|
|
39
|
+
readonly callId: string;
|
|
40
|
+
/** Seq of the user message anchoring the turn in which the change happened. */
|
|
41
|
+
readonly anchorSeq: number;
|
|
42
|
+
/** Resolved display path (absolute) of the tracked file. */
|
|
43
|
+
readonly path: string;
|
|
44
|
+
/** Full content before the change; null when the file was created. */
|
|
45
|
+
readonly before: string | null;
|
|
46
|
+
/** Epoch ms the entry was committed (stable ordering within a group). */
|
|
47
|
+
readonly time: number;
|
|
48
|
+
}
|
|
49
|
+
/** Per-file restore impact preview (`/rewind preview @seq both`). */
|
|
50
|
+
export interface FileImpact {
|
|
51
|
+
readonly path: string;
|
|
52
|
+
/** `restore` = write the before content back; `delete` = remove the file. */
|
|
53
|
+
readonly action: 'restore' | 'delete';
|
|
54
|
+
}
|
|
55
|
+
/** Outcome of one restore pass. */
|
|
56
|
+
export interface RestoreOutcome {
|
|
57
|
+
readonly restored: readonly string[];
|
|
58
|
+
readonly deleted: readonly string[];
|
|
59
|
+
/** Symlinked or hard-linked paths left untouched. */
|
|
60
|
+
readonly skipped: readonly string[];
|
|
61
|
+
readonly failed: readonly {
|
|
62
|
+
path: string;
|
|
63
|
+
message: string;
|
|
64
|
+
}[];
|
|
65
|
+
}
|
|
66
|
+
/** Deletes one file by its real path (node:fs, bypassing the fs service). */
|
|
67
|
+
export type DeleteFile = (path: string) => Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* On-disk checkpoint store. Every write goes straight through `node:fs`, so a
|
|
70
|
+
* restore reliably lands on the real file system.
|
|
71
|
+
*/
|
|
72
|
+
export declare class SnapshotStore {
|
|
73
|
+
readonly root: string;
|
|
74
|
+
/** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
|
|
75
|
+
private static readonly PRUNE_INTERVAL_MS;
|
|
76
|
+
private lastPruneAt;
|
|
77
|
+
constructor(root?: string);
|
|
78
|
+
/** Absolute path of one session's snapshot directory (id sanitized). */
|
|
79
|
+
sessionDir(sessionId: string): string;
|
|
80
|
+
/** Absolute path of one anchor group directory. */
|
|
81
|
+
anchorDir(sessionId: string, anchorSeq: number): string;
|
|
82
|
+
/** Commit one before-backup under its turn's anchor group. */
|
|
83
|
+
recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
86
|
+
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
87
|
+
* reverts the changes its own turn caused (the rewind cut removes that
|
|
88
|
+
* turn's assistant response and tool calls), so only entries anchored at
|
|
89
|
+
* earlier messages survive.
|
|
90
|
+
*/
|
|
91
|
+
entriesAfter(sessionId: string, targetSeq: number): Promise<CheckpointEntry[]>;
|
|
92
|
+
/**
|
|
93
|
+
* Per-path EARLIEST committed entry anchored at or after the target — the
|
|
94
|
+
* single source of truth for both restore and impact preview.
|
|
95
|
+
*/
|
|
96
|
+
private earliestEntries;
|
|
97
|
+
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
98
|
+
impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
|
|
99
|
+
/**
|
|
100
|
+
* Restore the workspace to the target message's checkpoint: for every path
|
|
101
|
+
* with entries anchored at or after it, apply the EARLIEST entry — write the
|
|
102
|
+
* before content back, or delete the file when it was created after the
|
|
103
|
+
* target. Symlinked and hard-linked paths are skipped (reported, never
|
|
104
|
+
* written through); a restored file's parent directory is created when it
|
|
105
|
+
* was deleted after the backup. Failures are per-file and never abort the
|
|
106
|
+
* pass.
|
|
107
|
+
*/
|
|
108
|
+
restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
|
|
109
|
+
/**
|
|
110
|
+
* Drop the session's oldest anchor groups beyond `keep` (default
|
|
111
|
+
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
112
|
+
*/
|
|
113
|
+
prune(sessionId: string, keep?: number): Promise<void>;
|
|
114
|
+
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
115
|
+
exists(path: string): Promise<boolean>;
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -32,14 +32,15 @@
|
|
|
32
32
|
"types": "./lib/types/client/index.d.ts",
|
|
33
33
|
"default": "./lib/client.js"
|
|
34
34
|
},
|
|
35
|
-
"./src/*": "./src/*",
|
|
36
35
|
"./package.json": "./package.json"
|
|
37
36
|
},
|
|
38
37
|
"files": [
|
|
39
38
|
"lib",
|
|
40
39
|
"cordis.patch.yml",
|
|
41
|
-
"scripts",
|
|
42
40
|
"README.md",
|
|
41
|
+
"README.zh.md",
|
|
42
|
+
"docs",
|
|
43
|
+
"assets",
|
|
43
44
|
"LICENSE"
|
|
44
45
|
],
|
|
45
46
|
"dsh": {
|
package/lib/types/ledger.d.ts
DELETED
|
@@ -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
|
-
}
|
package/scripts/build.mjs
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* dsh-rewind build — produces the npm-package artifacts under lib/:
|
|
4
|
-
* lib/index.js — host half: src/index.ts bundled to plain ESM. Every
|
|
5
|
-
* `@deepseek-ai/*` import stays external: the dsh loader
|
|
6
|
-
* resolves those from the harness installation, never from
|
|
7
|
-
* this package.
|
|
8
|
-
* lib/client.js — client half: src/client/index.ts bundled to CJS, then
|
|
9
|
-
* wrapped in the web boot handoff
|
|
10
|
-
* `window.__ModuleLoader__.load({ id, factory })`, the
|
|
11
|
-
* closure-factory format every `dsh.client` package's
|
|
12
|
-
* `./client` export must use. The client half is fully
|
|
13
|
-
* self-contained (plain DOM, no React), so nothing is
|
|
14
|
-
* external and the injected `require` is never called.
|
|
15
|
-
*
|
|
16
|
-
* Type checking is a separate step (`npm run typecheck`, tsc --noEmit); this
|
|
17
|
-
* script only transpiles (esbuild) and runs smoke checks: both outputs must
|
|
18
|
-
* parse, and the host half must import with the expected plugin shape.
|
|
19
|
-
*/
|
|
20
|
-
import { build } from 'esbuild'
|
|
21
|
-
import { execSync } from 'node:child_process'
|
|
22
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
23
|
-
import { dirname, join } from 'node:path'
|
|
24
|
-
import { fileURLToPath } from 'node:url'
|
|
25
|
-
|
|
26
|
-
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
27
|
-
const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'))
|
|
28
|
-
|
|
29
|
-
await mkdir(join(ROOT, 'lib'), { recursive: true })
|
|
30
|
-
|
|
31
|
-
// ---- type declarations: host (lib/types) + client (lib/types/client) ----
|
|
32
|
-
execSync('npx tsc -p tsconfig.build.json', { cwd: ROOT, stdio: 'inherit' })
|
|
33
|
-
execSync('npx tsc -p tsconfig.client.json', { cwd: ROOT, stdio: 'inherit' })
|
|
34
|
-
|
|
35
|
-
// ---- host half: bundled TS -> ESM (@deepseek-ai/* stays external) ----
|
|
36
|
-
await build({
|
|
37
|
-
entryPoints: [join(ROOT, 'src', 'index.ts')],
|
|
38
|
-
outfile: join(ROOT, 'lib', 'index.js'),
|
|
39
|
-
format: 'esm',
|
|
40
|
-
platform: 'node',
|
|
41
|
-
target: 'es2024',
|
|
42
|
-
bundle: true,
|
|
43
|
-
external: ['@deepseek-ai/*'],
|
|
44
|
-
sourcemap: false,
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
// ---- client half: bundled TS -> CJS, then wrapped in the loader handoff ----
|
|
48
|
-
await build({
|
|
49
|
-
entryPoints: [join(ROOT, 'src', 'client', 'index.ts')],
|
|
50
|
-
outfile: join(ROOT, 'lib', '_client.js'),
|
|
51
|
-
format: 'cjs',
|
|
52
|
-
platform: 'browser',
|
|
53
|
-
target: 'es2020',
|
|
54
|
-
bundle: true,
|
|
55
|
-
external: [],
|
|
56
|
-
sourcemap: false,
|
|
57
|
-
})
|
|
58
|
-
const clientSource = await readFile(join(ROOT, 'lib', '_client.js'), 'utf8')
|
|
59
|
-
await rm(join(ROOT, 'lib', '_client.js'))
|
|
60
|
-
|
|
61
|
-
const bundle = [
|
|
62
|
-
'/* dsh-rewind client bundle — generated by scripts/build.mjs from src/client/ */',
|
|
63
|
-
'window.__ModuleLoader__.load({',
|
|
64
|
-
` id: ${JSON.stringify(pkg.name)},`,
|
|
65
|
-
' factory: (require) => {',
|
|
66
|
-
' var module = { exports: {} };',
|
|
67
|
-
' var exports = module.exports;',
|
|
68
|
-
clientSource.replace(/\s+$/, '\n'),
|
|
69
|
-
' return module.exports;',
|
|
70
|
-
' }',
|
|
71
|
-
'});',
|
|
72
|
-
'',
|
|
73
|
-
].join('\n')
|
|
74
|
-
await writeFile(join(ROOT, 'lib', 'client.js'), bundle)
|
|
75
|
-
|
|
76
|
-
// ---- smoke checks ----
|
|
77
|
-
const host = await readFile(join(ROOT, 'lib', 'index.js'), 'utf8')
|
|
78
|
-
const exportBlock = host.slice(host.lastIndexOf('export {'))
|
|
79
|
-
for (const needle of ['name', 'inject', 'apply']) {
|
|
80
|
-
if (!exportBlock.includes(needle)) throw new Error(`host bundle missing export ${needle}`)
|
|
81
|
-
}
|
|
82
|
-
for (const needle of ['window.__ModuleLoader__.load', `id: ${JSON.stringify(pkg.name)}`]) {
|
|
83
|
-
if (!bundle.includes(needle)) throw new Error(`client bundle missing ${needle}`)
|
|
84
|
-
}
|
|
85
|
-
// Declarations must exist (published tarball carries them).
|
|
86
|
-
for (const dts of ['lib/types/index.d.ts', 'lib/types/client/index.d.ts']) {
|
|
87
|
-
await readFile(join(ROOT, dts), 'utf8')
|
|
88
|
-
}
|
|
89
|
-
console.log('build ok: lib/index.js (host), lib/client.js (client), lib/types/ (declarations)')
|
package/scripts/verify-host.mjs
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Host-half verification: boots the built plugin (`lib/index.js`) on a real
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
* Run: `npm run build && node scripts/verify-host.mjs`
|
|
8
|
-
*
|
|
9
|
-
* What it proves:
|
|
10
|
-
* 1. the plugin registers a `rewind` command on the ctx;
|
|
11
|
-
* 2. `/rewind` (no args) withdraws the most recent user message;
|
|
12
|
-
* 3. `/rewind @<seq> chat` (the button's call form) 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.
|
|
19
|
-
*/
|
|
20
|
-
import { Context } from '@deepseek-ai/cordis'
|
|
21
|
-
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
|
22
|
-
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
|
-
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
24
|
-
import { join } from 'node:path'
|
|
25
|
-
import { apply as applyRewind } from '../lib/index.js'
|
|
26
|
-
|
|
27
|
-
const aborted = () => new AbortController().signal
|
|
28
|
-
|
|
29
|
-
/** In-memory fs double with session-cwd resolution (resolve/readText/writeText/processPath). */
|
|
30
|
-
class FakeFs extends FileSystem {
|
|
31
|
-
files = new Map()
|
|
32
|
-
async resolve(path, opts = {}) {
|
|
33
|
-
const displayPath = opts?.cwd !== undefined && !path.startsWith('/') ? join(opts.cwd, path) : path
|
|
34
|
-
return { targetKey: FsTargetKey(displayPath), displayPath }
|
|
35
|
-
}
|
|
36
|
-
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
|
|
41
|
-
}
|
|
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
|
-
}
|
|
45
|
-
|
|
46
|
-
const fs = new FakeFs(new Context())
|
|
47
|
-
fs.files.set('/workspace/a.txt', 'original content')
|
|
48
|
-
|
|
49
|
-
const user = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
50
|
-
const assistant = text => createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'test', model: 'test' } })
|
|
51
|
-
|
|
52
|
-
function buildSession(id, cwd) {
|
|
53
|
-
const session = Session.create(SessionId(id), undefined,
|
|
54
|
-
cwd !== undefined
|
|
55
|
-
? { version: 0, id: SessionId(id), createdAt: Date.now(), cwd }
|
|
56
|
-
: undefined)
|
|
57
|
-
session.append('user/message', user('first question'), { surfaceOp: 'append' })
|
|
58
|
-
session.append('assistant/message', { turn: 0, step: 0, message: assistant('first answer') }, { surfaceOp: 'append' })
|
|
59
|
-
session.append('user/message', user('second question'), { surfaceOp: 'append' })
|
|
60
|
-
session.append('assistant/message', { turn: 1, step: 0, message: assistant('second answer') }, { surfaceOp: 'append' })
|
|
61
|
-
return session
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const session = buildSession('verify-host')
|
|
65
|
-
const agent = { id: session.id, session, status: 'idle' }
|
|
66
|
-
|
|
67
|
-
const ctx = new Context()
|
|
68
|
-
let registered = null
|
|
69
|
-
ctx.provide('commands', {
|
|
70
|
-
register: definition => {
|
|
71
|
-
registered = definition
|
|
72
|
-
return () => {}
|
|
73
|
-
},
|
|
74
|
-
})
|
|
75
|
-
ctx.provide('fs', fs)
|
|
76
|
-
applyRewind(ctx)
|
|
77
|
-
|
|
78
|
-
const call = (agentOf, rawInput) => registered.handler({ commandId: Symbol('cid'), agent: agentOf, rawInput, signal: aborted() })
|
|
79
|
-
|
|
80
|
-
let failures = 0
|
|
81
|
-
const check = (name, ok, detail) => {
|
|
82
|
-
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ` — ${detail}`}`)
|
|
83
|
-
if (!ok) failures += 1
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 1. command registered
|
|
87
|
-
check('command registered', typeof registered?.handler === 'function' && registered.name === 'rewind', JSON.stringify(registered))
|
|
88
|
-
|
|
89
|
-
// 2. bare /rewind (manual, no parameters) withdraws the most recent user
|
|
90
|
-
// message (seq 2 "second question") and everything after it
|
|
91
|
-
const bareBefore = [...session.surface.nodes]
|
|
92
|
-
const bareResult = await call(agent, '')
|
|
93
|
-
const bareAfter = [...session.surface.nodes]
|
|
94
|
-
check('bare /rewind succeeds', bareResult.kind === 'success', bareResult.text)
|
|
95
|
-
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)}`)
|
|
96
|
-
check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
|
|
97
|
-
|
|
98
|
-
// 3. /rewind @<seq> chat (the button's exact call form) cuts the surface on a
|
|
99
|
-
// fresh session
|
|
100
|
-
const paramSession = buildSession('verify-param')
|
|
101
|
-
const paramAgent = { id: paramSession.id, session: paramSession, status: 'idle' }
|
|
102
|
-
const before = [...paramSession.surface.nodes]
|
|
103
|
-
const chatResult = await call(paramAgent, '@2 chat')
|
|
104
|
-
const after = [...paramSession.surface.nodes]
|
|
105
|
-
check('rewind chat succeeds', chatResult.kind === 'success', chatResult.text)
|
|
106
|
-
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)}`)
|
|
107
|
-
check('log stays append-only (5 events)', paramSession.events.length === 5, `events=${paramSession.events.length}`)
|
|
108
|
-
|
|
109
|
-
const writeExec = (callId, filePath, content) => ({
|
|
110
|
-
callId, name: 'write', arguments: { file_path: filePath, content }, agent, signal: aborted(),
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
// 4. a pre-execute `ask` short-circuit (dsh-edit-approval) must not skip the
|
|
114
|
-
// capture: capture happens in tools/execute, which runs after approval.
|
|
115
|
-
{
|
|
116
|
-
const exec = writeExec('c1', '/workspace/a.txt', 'rewritten')
|
|
117
|
-
// Another plugin asks at pre-execute; the user then allows it.
|
|
118
|
-
const gate = await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'ask', reason: 'approve me' }))
|
|
119
|
-
check('pre-execute gate asks', gate.kind === 'ask', JSON.stringify(gate))
|
|
120
|
-
// Approved → dispatch stage runs: capture fires here.
|
|
121
|
-
await ctx.waterfall('tools/execute', exec, async () => ({ isError: false, content: [] }))
|
|
122
|
-
await fs.writeText({ targetKey: FsTargetKey('/workspace/a.txt'), displayPath: '/workspace/a.txt' }, 'rewritten')
|
|
123
|
-
await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
|
|
124
|
-
check('file mutated on disk', fs.files.get('/workspace/a.txt') === 'rewritten', fs.files.get('/workspace/a.txt'))
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// 5. a denied call never captures (no pending leak: nothing recorded after it)
|
|
128
|
-
{
|
|
129
|
-
const exec = writeExec('c2', '/workspace/a.txt', 'denied write')
|
|
130
|
-
const gate = await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'deny', reason: 'no' }))
|
|
131
|
-
check('pre-execute gate denies', gate.kind === 'deny', JSON.stringify(gate))
|
|
132
|
-
// Denied calls do not dispatch: post-execute must record nothing for c2.
|
|
133
|
-
await ctx.waterfall('tools/post-execute', exec, { isError: true, error: { message: 'denied', info: { name: 'x', code: 'y' } }, content: [] }, async () => ({ kind: 'accept' }))
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// 6. relative paths resolve against the session cwd (fs-tools rule)
|
|
137
|
-
{
|
|
138
|
-
const cwdSession = buildSession('verify-cwd', '/workspace')
|
|
139
|
-
const cwdAgent = { id: cwdSession.id, session: cwdSession, status: 'idle' }
|
|
140
|
-
fs.files.set('/workspace/rel.txt', 'relative original')
|
|
141
|
-
const exec = { callId: 'c3', name: 'write', arguments: { file_path: 'rel.txt', content: 'relative new' }, agent: cwdAgent, signal: aborted() }
|
|
142
|
-
await ctx.waterfall('tools/execute', exec, async () => ({ isError: false, content: [] }))
|
|
143
|
-
await fs.writeText({ targetKey: FsTargetKey('/workspace/rel.txt'), displayPath: '/workspace/rel.txt' }, 'relative new')
|
|
144
|
-
await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
|
|
145
|
-
// Rewind to seq 2 in the cwd session must report the cwd-resolved path.
|
|
146
|
-
const preview = await call(cwdAgent, 'preview @2 both')
|
|
147
|
-
check('preview resolves relative path via session cwd', preview.kind === 'success' && preview.text.includes('/workspace/rel.txt'), preview.text)
|
|
148
|
-
const both = await call(cwdAgent, '@2 both')
|
|
149
|
-
check('both restores cwd-resolved file', both.kind === 'success' && fs.files.get('/workspace/rel.txt') === 'relative original', both.text)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// 7. preview reports the impact (rewind to seq 0 — still on the surface after
|
|
153
|
-
// the earlier withdraw — reverts the anchor-0 write)
|
|
154
|
-
const previewResult = await call(agent, 'preview @0 both')
|
|
155
|
-
check('preview shows file impact', previewResult.kind === 'success' && previewResult.text.includes('/workspace/a.txt'), previewResult.text)
|
|
156
|
-
|
|
157
|
-
// 8. both mode restores the file
|
|
158
|
-
const bothResult = await call(agent, '@0 both')
|
|
159
|
-
check('rewind both restores file', bothResult.kind === 'success' && bothResult.text.includes('还原 1 个文件'), bothResult.text)
|
|
160
|
-
check('file content restored', fs.files.get('/workspace/a.txt') === 'original content', fs.files.get('/workspace/a.txt'))
|
|
161
|
-
|
|
162
|
-
// 9. a running agent is force-stopped before the rewind (not refused)
|
|
163
|
-
const runningSession = buildSession('verify-running')
|
|
164
|
-
const running = {
|
|
165
|
-
...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running',
|
|
166
|
-
cancel: () => { cancelled = true; running.status = 'idle' },
|
|
167
|
-
}
|
|
168
|
-
let cancelled = false
|
|
169
|
-
const runningResult = await registered.handler({ commandId: Symbol('cid'), agent: running, rawInput: '@2 chat', signal: aborted() })
|
|
170
|
-
check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
|
|
171
|
-
check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
|
|
172
|
-
|
|
173
|
-
// 9b. a cancel that never quiesces aborts the rewind (timeout path)
|
|
174
|
-
const stuck = { ...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running', cancel: () => {} }
|
|
175
|
-
const stuckResult = await registered.handler({ commandId: Symbol('cid'), agent: stuck, rawInput: '@2 chat', signal: aborted() })
|
|
176
|
-
check('stuck agent aborts rewind', stuckResult.kind === 'error', stuckResult.text)
|
|
177
|
-
|
|
178
|
-
console.log(failures === 0 ? '\nverify-host: all checks passed' : `\nverify-host: ${failures} check(s) FAILED`)
|
|
179
|
-
process.exit(failures === 0 ? 0 : 1)
|