dsh-rewind-plugin 0.3.1 → 0.3.2
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/assets/screenshots/impact-list.png +0 -0
- package/assets/screenshots/mode-popover.png +0 -0
- package/assets/screenshots/rewind-button.png +0 -0
- package/assets/screenshots/rewind-candidates.png +0 -0
- package/lib/client.js +45 -13
- package/lib/index.js +20 -4
- package/lib/types/client/candidates.d.ts +25 -0
- package/lib/types/client/hidden.d.ts +7 -0
- package/lib/types/rewind.d.ts +35 -0
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -53,6 +53,9 @@ function hasFileImpact(text) {
|
|
|
53
53
|
function isPreviewCommand(command) {
|
|
54
54
|
return (command.args ?? "").includes("preview");
|
|
55
55
|
}
|
|
56
|
+
function isCandidateCommand(command) {
|
|
57
|
+
return (command.args ?? "").includes("__candidates");
|
|
58
|
+
}
|
|
56
59
|
function hiddenSeqsOf(snap) {
|
|
57
60
|
const hidden = /* @__PURE__ */ new Set();
|
|
58
61
|
const spans = [];
|
|
@@ -61,7 +64,7 @@ function hiddenSeqsOf(snap) {
|
|
|
61
64
|
if (node === void 0 || node.kind !== "command") continue;
|
|
62
65
|
const command = node.data;
|
|
63
66
|
if (command.name !== "rewind") continue;
|
|
64
|
-
if (isPreviewCommand(command)) {
|
|
67
|
+
if (isPreviewCommand(command) || isCandidateCommand(command)) {
|
|
65
68
|
hidden.add(command.seq);
|
|
66
69
|
continue;
|
|
67
70
|
}
|
|
@@ -87,6 +90,7 @@ function hiddenSeqsOf(snap) {
|
|
|
87
90
|
|
|
88
91
|
// src/client/candidates.ts
|
|
89
92
|
var PREVIEW_CHARS = 80;
|
|
93
|
+
var DEFAULT_CANDIDATE_LIMIT = 50;
|
|
90
94
|
function messagePreviewOf(message) {
|
|
91
95
|
const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
92
96
|
return text.length <= PREVIEW_CHARS ? text : `${text.slice(0, PREVIEW_CHARS - 1)}\u2026`;
|
|
@@ -97,7 +101,7 @@ function formatCandidateTime(time) {
|
|
|
97
101
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
98
102
|
return `${hh}:${mm}`;
|
|
99
103
|
}
|
|
100
|
-
function rewindCandidatesOf(snap, hidden, limit =
|
|
104
|
+
function rewindCandidatesOf(snap, hidden, limit = DEFAULT_CANDIDATE_LIMIT) {
|
|
101
105
|
const candidates = [];
|
|
102
106
|
for (let i = snap.order.length - 1; i >= 0 && candidates.length < limit; i--) {
|
|
103
107
|
const key = snap.order[i];
|
|
@@ -116,16 +120,30 @@ function rewindCandidatesOf(snap, hidden, limit = 10) {
|
|
|
116
120
|
function rewindCandidatesOfChat(snap) {
|
|
117
121
|
return rewindCandidatesOf(snap, hiddenSeqsOf(snap));
|
|
118
122
|
}
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
var CANDIDATE_LIST_HEADER = "candidates=";
|
|
124
|
+
function rewindCandidatesFromHostText(text) {
|
|
125
|
+
if (!text.startsWith(CANDIDATE_LIST_HEADER)) return [];
|
|
126
|
+
const lines = text.split("\n").slice(1);
|
|
127
|
+
const candidates = [];
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (line === "") continue;
|
|
130
|
+
const parts = line.split(" ");
|
|
131
|
+
if (parts.length !== 3) continue;
|
|
132
|
+
const seq = Number(parts[0]);
|
|
133
|
+
const time = Number(parts[1]);
|
|
134
|
+
const preview = parts[2] ?? "";
|
|
135
|
+
if (!Number.isSafeInteger(seq) || !Number.isFinite(time)) continue;
|
|
136
|
+
candidates.push({ seq, time, preview });
|
|
137
|
+
}
|
|
138
|
+
return candidates;
|
|
139
|
+
}
|
|
140
|
+
function rewindOptionsFromCandidates(candidates, t) {
|
|
141
|
+
return candidates.map((candidate) => ({
|
|
121
142
|
id: String(candidate.seq),
|
|
122
143
|
label: candidate.preview || t("popover.noText"),
|
|
123
144
|
detail: formatCandidateTime(candidate.time)
|
|
124
145
|
}));
|
|
125
146
|
}
|
|
126
|
-
function candidateBySeq(snap, seq) {
|
|
127
|
-
return rewindCandidatesOfChat(snap).find((candidate) => candidate.seq === seq);
|
|
128
|
-
}
|
|
129
147
|
|
|
130
148
|
// src/client/styles.ts
|
|
131
149
|
var CLASS = {
|
|
@@ -823,6 +841,15 @@ function apply(ctx) {
|
|
|
823
841
|
const chat = chatOf(sessionId);
|
|
824
842
|
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
825
843
|
};
|
|
844
|
+
const fetchHostCandidates = async (face) => {
|
|
845
|
+
const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
|
|
846
|
+
const result = await face.command("/rewind __candidates");
|
|
847
|
+
if (!result.ok || result.value?.matched !== true) return void 0;
|
|
848
|
+
const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
|
|
849
|
+
if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
|
|
850
|
+
return rewindCandidatesFromHostText(outcome.text);
|
|
851
|
+
};
|
|
852
|
+
const hostCandidatesCache = /* @__PURE__ */ new Map();
|
|
826
853
|
const composerAnchor = () => {
|
|
827
854
|
const textarea = composerTextarea();
|
|
828
855
|
const card = textarea?.closest("[data-composer-card]");
|
|
@@ -837,15 +864,20 @@ function apply(ctx) {
|
|
|
837
864
|
available: (session) => hasCandidates(session.sessionId),
|
|
838
865
|
ui: {
|
|
839
866
|
kind: "popupSelect",
|
|
840
|
-
options: (session) => {
|
|
841
|
-
const
|
|
842
|
-
|
|
867
|
+
options: async (session) => {
|
|
868
|
+
const face = sessionOf(session.sessionId);
|
|
869
|
+
if (face === void 0) return [];
|
|
870
|
+
const candidates = await fetchHostCandidates(face);
|
|
871
|
+
if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
|
|
872
|
+
return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
|
|
843
873
|
},
|
|
844
874
|
onSelect: (option, session) => {
|
|
845
875
|
const face = sessionOf(session.sessionId);
|
|
846
|
-
|
|
847
|
-
const candidate =
|
|
848
|
-
|
|
876
|
+
if (face === void 0) return;
|
|
877
|
+
const candidate = hostCandidatesCache.get(session.sessionId)?.find(
|
|
878
|
+
(candidate2) => candidate2.seq === Number(option.id)
|
|
879
|
+
);
|
|
880
|
+
if (candidate === void 0) return;
|
|
849
881
|
openPopover({
|
|
850
882
|
session: face,
|
|
851
883
|
seq: candidate.seq,
|
package/lib/index.js
CHANGED
|
@@ -80,6 +80,7 @@ var RewindError = class extends Error {
|
|
|
80
80
|
code;
|
|
81
81
|
};
|
|
82
82
|
var CANDIDATE_PREVIEW_CHARS = 80;
|
|
83
|
+
var DEFAULT_CANDIDATE_LIMIT = 50;
|
|
83
84
|
function markerTurnOf(events) {
|
|
84
85
|
let lastStarted = 0;
|
|
85
86
|
for (const event of events) {
|
|
@@ -92,6 +93,9 @@ function markerTurnOf(events) {
|
|
|
92
93
|
function isUserMessageEvent(event) {
|
|
93
94
|
return event.type === "user/message";
|
|
94
95
|
}
|
|
96
|
+
function isHumanUserMessageEvent(event) {
|
|
97
|
+
return isUserMessageEvent(event) && event.data.source.kind === "user";
|
|
98
|
+
}
|
|
95
99
|
function messagePreview(message) {
|
|
96
100
|
const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
97
101
|
return text.length <= CANDIDATE_PREVIEW_CHARS ? text : `${text.slice(0, CANDIDATE_PREVIEW_CHARS - 1)}\u2026`;
|
|
@@ -106,13 +110,13 @@ function parseRewindTarget(raw) {
|
|
|
106
110
|
const index = Number(token);
|
|
107
111
|
return Number.isSafeInteger(index) && index >= 1 ? { kind: "index", index } : void 0;
|
|
108
112
|
}
|
|
109
|
-
function listRewindCandidates(events, surface, limit =
|
|
113
|
+
function listRewindCandidates(events, surface, limit = DEFAULT_CANDIDATE_LIMIT) {
|
|
110
114
|
const surfaceIndexes = /* @__PURE__ */ new Map();
|
|
111
115
|
for (let i = 0; i < surface.length; i++) surfaceIndexes.set(surface[i], i);
|
|
112
116
|
const candidates = [];
|
|
113
117
|
for (let i = events.length - 1; i >= 0 && candidates.length < limit; i--) {
|
|
114
118
|
const event = events[i];
|
|
115
|
-
if (!
|
|
119
|
+
if (!isHumanUserMessageEvent(event)) continue;
|
|
116
120
|
if (!surfaceIndexes.has(event.seq)) continue;
|
|
117
121
|
candidates.push({
|
|
118
122
|
seq: event.seq,
|
|
@@ -123,6 +127,14 @@ function listRewindCandidates(events, surface, limit = 10) {
|
|
|
123
127
|
}
|
|
124
128
|
return candidates;
|
|
125
129
|
}
|
|
130
|
+
var CANDIDATE_LIST_HEADER = "candidates=";
|
|
131
|
+
function formatCandidateList(candidates) {
|
|
132
|
+
const lines = [`${CANDIDATE_LIST_HEADER}${candidates.length}`];
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
lines.push(`${candidate.seq} ${candidate.time} ${candidate.preview}`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
126
138
|
function planRewind(events, surface, target) {
|
|
127
139
|
let targetSeq;
|
|
128
140
|
if (target.kind === "seq") {
|
|
@@ -138,10 +150,10 @@ function planRewind(events, surface, target) {
|
|
|
138
150
|
if (targetEvent === void 0) {
|
|
139
151
|
throw new RewindError("not-a-user-message", `no session event at seq ${targetSeq}`);
|
|
140
152
|
}
|
|
141
|
-
if (!
|
|
153
|
+
if (!isHumanUserMessageEvent(targetEvent)) {
|
|
142
154
|
throw new RewindError(
|
|
143
155
|
"not-a-user-message",
|
|
144
|
-
`session event at seq ${targetSeq} is not a user message (${targetEvent.type})`
|
|
156
|
+
`session event at seq ${targetSeq} is not a human user message (${targetEvent.type})`
|
|
145
157
|
);
|
|
146
158
|
}
|
|
147
159
|
const targetIndex = surface.indexOf(targetSeq);
|
|
@@ -733,6 +745,10 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
|
733
745
|
const impacts = await store.impactsAfter(session.id, plan.targetSeq);
|
|
734
746
|
return { kind: "success", text: formatPlan(plan, impacts) };
|
|
735
747
|
}
|
|
748
|
+
if (parts[0] === "__candidates") {
|
|
749
|
+
const candidates = listRewindCandidates(session.events, session.surface.nodes);
|
|
750
|
+
return { kind: "success", text: formatCandidateList(candidates) };
|
|
751
|
+
}
|
|
736
752
|
const target = parts[0];
|
|
737
753
|
const mode = parts[1];
|
|
738
754
|
if (mode !== void 0 && mode !== "chat" && mode !== "both") {
|
|
@@ -15,6 +15,14 @@ import type { RewindKey } from './locales.ts';
|
|
|
15
15
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
16
16
|
/** Preview length cap for candidate rows (matches the host's candidate list). */
|
|
17
17
|
export declare const PREVIEW_CHARS = 80;
|
|
18
|
+
/**
|
|
19
|
+
* Default cap on how many user messages the rewind picker lists (newest kept).
|
|
20
|
+
*
|
|
21
|
+
* A fixed 10 made long sessions look "incomplete" (only the newest 10 shown).
|
|
22
|
+
* 50 keeps the picker scrollable/searchable via the popupSelect shell while
|
|
23
|
+
* covering far longer sessions; callers can still pass an explicit `limit`.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_CANDIDATE_LIMIT = 50;
|
|
18
26
|
/** One selectable rewind target. */
|
|
19
27
|
export interface RewindCandidate {
|
|
20
28
|
/** Absolute log seq of the `user/message` event. */
|
|
@@ -73,4 +81,21 @@ export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandi
|
|
|
73
81
|
export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
|
|
74
82
|
/** Resolve one candidate by log seq (the mode popover's re-entry after a pick). */
|
|
75
83
|
export declare function candidateBySeq(snap: CandidateChat, seq: number): RewindCandidate | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* Parse the host's candidate-list encoding (see `formatCandidateList` in
|
|
86
|
+
* src/rewind.ts) into typed candidates. Malformed lines are skipped; a
|
|
87
|
+
* missing/zero header yields an empty list.
|
|
88
|
+
*/
|
|
89
|
+
export declare function rewindCandidatesFromHostText(text: string): RewindCandidate[];
|
|
90
|
+
/**
|
|
91
|
+
* Map typed candidates to popupSelect rows (the host-derived path). The
|
|
92
|
+
* popupSelect sources its options from the FULL host surface via the
|
|
93
|
+
* `__candidates` channel instead of the windowed chat snapshot.
|
|
94
|
+
*/
|
|
95
|
+
export declare function rewindOptionsFromCandidates(candidates: readonly RewindCandidate[], t: Translate): SelectOption[];
|
|
96
|
+
/**
|
|
97
|
+
* Parse the host's candidate-list encoding (see `formatCandidateList` in
|
|
98
|
+
* src/rewind.ts) into popupSelect rows.
|
|
99
|
+
*/
|
|
100
|
+
export declare function rewindOptionsFromHostText(text: string, t: Translate): SelectOption[];
|
|
76
101
|
export {};
|
|
@@ -38,6 +38,13 @@ export declare function isExecutedRewindCommand(node: CommandNode, seq: number):
|
|
|
38
38
|
* always-show so a working option is never hidden on a failed probe.
|
|
39
39
|
*/
|
|
40
40
|
export declare function hasFileImpact(text: string | undefined): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when a `/rewind` command node is the internal candidate-list probe
|
|
43
|
+
* (`/rewind __candidates`) the popupSelect runs to fetch the FULL candidate
|
|
44
|
+
* list from the host. Like previews, its flow node never surfaces in the
|
|
45
|
+
* transcript — it only feeds the popup — so it is hidden in every state.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isCandidateCommand(command: CommandNode): boolean;
|
|
41
48
|
/**
|
|
42
49
|
* Anchor seqs that must be hidden from the rendered transcript so the user
|
|
43
50
|
* sees the conversation as the agent sees it: every impact-preview flow node
|
package/lib/types/rewind.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface RewindPlan {
|
|
|
60
60
|
}
|
|
61
61
|
/** Preview length cap for candidate listings. */
|
|
62
62
|
export declare const CANDIDATE_PREVIEW_CHARS = 80;
|
|
63
|
+
/**
|
|
64
|
+
* Default cap on how many user messages a candidate listing returns (newest
|
|
65
|
+
* kept). Raised from 10 so long sessions don't look incomplete; callers can
|
|
66
|
+
* still pass an explicit `limit`.
|
|
67
|
+
*/
|
|
68
|
+
export declare const DEFAULT_CANDIDATE_LIMIT = 50;
|
|
63
69
|
/**
|
|
64
70
|
* Turn number for the rewind marker.
|
|
65
71
|
*
|
|
@@ -85,6 +91,18 @@ export declare const CANDIDATE_PREVIEW_CHARS = 80;
|
|
|
85
91
|
export declare function markerTurnOf(events: readonly SessionEvent[]): number;
|
|
86
92
|
/** Narrow an event to a user message. */
|
|
87
93
|
export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
|
|
94
|
+
/**
|
|
95
|
+
* True for a HUMAN user message event — one whose `source.kind` is `'user'`.
|
|
96
|
+
*
|
|
97
|
+
* The surface can carry `user/message` events whose source is NOT the user:
|
|
98
|
+
* plugin/system context injection (including compaction checkpoints) and
|
|
99
|
+
* tool-result backfill all arrive as `user/message` with a non-`'user'`
|
|
100
|
+
* source, and the client renders those as `context` nodes, never as a user
|
|
101
|
+
* bubble. Only genuine user messages (and user steering during a running
|
|
102
|
+
* turn, which keeps `source.kind: 'user'`) are valid rewind targets — a
|
|
103
|
+
* rewind boundary must land on a human prompt, not on injected context.
|
|
104
|
+
*/
|
|
105
|
+
export declare function isHumanUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
|
|
88
106
|
/** Join the text blocks of a message into one plain string. */
|
|
89
107
|
export declare function messagePreview(message: UserMessage): string;
|
|
90
108
|
/**
|
|
@@ -104,6 +122,23 @@ export declare function parseRewindTarget(raw: string): RewindTarget | undefined
|
|
|
104
122
|
* @returns candidates numbered 1..N by recency.
|
|
105
123
|
*/
|
|
106
124
|
export declare function listRewindCandidates(events: readonly SessionEvent[], surface: readonly number[], limit?: number): RewindCandidate[];
|
|
125
|
+
/** Header line of the machine-readable candidate list (locale-independent). */
|
|
126
|
+
export declare const CANDIDATE_LIST_HEADER = "candidates=";
|
|
127
|
+
/**
|
|
128
|
+
* Encode a candidate list as the host→client machine channel (the same
|
|
129
|
+
* trailer pattern `formatPlan` uses for `impact=`). The client popupSelect
|
|
130
|
+
* parses this instead of reading the windowed chat snapshot, so the candidate
|
|
131
|
+
* list reflects the FULL host surface — not just the already-loaded history.
|
|
132
|
+
*
|
|
133
|
+
* Lines (each preview is already whitespace-collapsed and tab-free by
|
|
134
|
+
* `messagePreview`):
|
|
135
|
+
* candidates=<n>
|
|
136
|
+
* <seq>\t<time>\t<preview>
|
|
137
|
+
* … (one line per candidate, newest first, matching `listRewindCandidates`)
|
|
138
|
+
*
|
|
139
|
+
* A list with no candidates is just `candidates=0`.
|
|
140
|
+
*/
|
|
141
|
+
export declare function formatCandidateList(candidates: readonly RewindCandidate[]): string;
|
|
107
142
|
/**
|
|
108
143
|
* Resolve a target against the session log and surface into a validated plan.
|
|
109
144
|
* @param events - the full session event log.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|