dsh-last-turn-delete 0.1.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/LICENSE +21 -0
- package/README.i18n.yaml +5 -0
- package/README.md +74 -0
- package/README.zh.md +74 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +704 -0
- package/lib/core.js +252 -0
- package/lib/index.js +338 -0
- package/package.json +57 -0
package/lib/core.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-last-turn-delete — host-side pure logic (no DSH runtime imports).
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a plain function over the durable Session event log so
|
|
5
|
+
* it can be unit-tested without a DSH host:
|
|
6
|
+
*
|
|
7
|
+
* - {@link isDeleteMarker} recognizes the marker user/message this plugin
|
|
8
|
+
* appends as a surface replacement when a turn is deleted.
|
|
9
|
+
* - {@link findTailDeletionTarget} computes the *current* deletable tail:
|
|
10
|
+
* the last on-surface `user/message` whose `source.kind === 'user'`.
|
|
11
|
+
* - {@link collectDeletedMessages} re-derives every deleted user message id
|
|
12
|
+
* from the log (each marker replacement's shadowed span contains exactly
|
|
13
|
+
* one genuine user message — the one the deletion removed).
|
|
14
|
+
* - {@link collectFailedCompactionGroups} / {@link newestDeletable} decide the
|
|
15
|
+
* host-authoritative deletable tail: the last on-surface user message, or a
|
|
16
|
+
* failed `/compact` card that sits after it (failed compactions are not
|
|
17
|
+
* boundaries and may be deleted themselves).
|
|
18
|
+
*
|
|
19
|
+
* Deleting works on the *model-visible surface* (compaction's mechanism), so
|
|
20
|
+
* durable history is never rewritten: the user message and all its model
|
|
21
|
+
* responses stop being folded into future model context, while the original
|
|
22
|
+
* events stay in the log untouched (soft delete; nothing is lost).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Package name — also the identity stored on every deletion marker. */
|
|
26
|
+
export const PKG_NAME = "dsh-last-turn-delete";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The marker user/message's source identity. The chat transcript folds only
|
|
30
|
+
* append-surface user messages, so this replacement never renders a row; it
|
|
31
|
+
* only occupies the surface slot where the deleted turn used to be.
|
|
32
|
+
*/
|
|
33
|
+
export function deletionMarkerSource() {
|
|
34
|
+
return {
|
|
35
|
+
kind: "plugin",
|
|
36
|
+
name: PKG_NAME,
|
|
37
|
+
operation: "delete"
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether an event is one of this plugin's deletion markers: a
|
|
43
|
+
* `user/message` surface replacement carrying the plugin's source identity.
|
|
44
|
+
* @param event - one durable Session event.
|
|
45
|
+
* @returns true for marker replacements appended by this plugin.
|
|
46
|
+
*/
|
|
47
|
+
export function isDeleteMarker(event) {
|
|
48
|
+
if (event === null || typeof event !== "object") return false;
|
|
49
|
+
if (event.type !== "user/message") return false;
|
|
50
|
+
const op = event.surfaceOp;
|
|
51
|
+
if (op === null || typeof op !== "object" || op.op !== "replace") return false;
|
|
52
|
+
const source = event.data?.source;
|
|
53
|
+
if (source === null || typeof source !== "object") return false;
|
|
54
|
+
return source.kind === "plugin" && source.name === PKG_NAME && source.operation === "delete";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Locate the current deletable tail turn inside a session surface.
|
|
59
|
+
*
|
|
60
|
+
* The surface node list is the model-visible conversation in order
|
|
61
|
+
* (compaction already removed pre-boundary history from it, so "the last
|
|
62
|
+
* user message on the surface" is by construction *after* the newest compact
|
|
63
|
+
* boundary — deletion stops exactly at the boundary).
|
|
64
|
+
*
|
|
65
|
+
* @param nodes - current surface node seqs, in model-visible order
|
|
66
|
+
* (`session.surface.nodes`).
|
|
67
|
+
* @param eventAt - maps a seq back to its durable event.
|
|
68
|
+
* @param requestedMessageId - the message id the client believes is the
|
|
69
|
+
* deletable tail; used to reject a stale click.
|
|
70
|
+
* @returns
|
|
71
|
+
* - `{ status: "ok", messageId, startSeq, endSeq, shadowedSeqs }` when the
|
|
72
|
+
* requested message is the current tail user message;
|
|
73
|
+
* - `{ status: "stale", currentMessageId }` when the tail user message moved
|
|
74
|
+
* (its id differs from the request);
|
|
75
|
+
* - `{ status: "none" }` when there is no deletable tail user message (all
|
|
76
|
+
* content is before the compact boundary, or the log has none).
|
|
77
|
+
*/
|
|
78
|
+
export function findTailDeletionTarget(nodes, eventAt, requestedMessageId) {
|
|
79
|
+
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
80
|
+
const seq = nodes[index];
|
|
81
|
+
const event = eventAt(seq);
|
|
82
|
+
if (event === null || typeof event !== "object") continue;
|
|
83
|
+
if (event.type !== "user/message") continue;
|
|
84
|
+
const source = event.data?.source;
|
|
85
|
+
if (source?.kind !== "user") continue; // injected context / marker nodes
|
|
86
|
+
if (requestedMessageId !== event.data?.id) {
|
|
87
|
+
return {
|
|
88
|
+
status: "stale",
|
|
89
|
+
currentMessageId: event.data?.id ?? null
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const shadowedSeqs = nodes.slice(index);
|
|
93
|
+
return {
|
|
94
|
+
status: "ok",
|
|
95
|
+
messageId: event.data.id,
|
|
96
|
+
startSeq: nodes[index],
|
|
97
|
+
endSeq: shadowedSeqs[shadowedSeqs.length - 1],
|
|
98
|
+
shadowedSeqs
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { status: "none" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Re-derive every deleted user message from a full durable event log.
|
|
106
|
+
*
|
|
107
|
+
* Each deletion marker replacement shadows one span; the span contains
|
|
108
|
+
* exactly one genuine (`source.kind === "user"`) `user/message` — the deleted
|
|
109
|
+
* prompt. Later deletions shadow later tails that may already include
|
|
110
|
+
* earlier markers, so each marker still yields its own deleted message.
|
|
111
|
+
* @param events - durable events in seq order.
|
|
112
|
+
* @returns deleted user messages sorted by seq: `{ messageId, seq }`.
|
|
113
|
+
*/
|
|
114
|
+
export function collectDeletedMessages(events) {
|
|
115
|
+
const found = [];
|
|
116
|
+
if (!Array.isArray(events)) return found;
|
|
117
|
+
for (const event of events) {
|
|
118
|
+
if (!isDeleteMarker(event)) continue;
|
|
119
|
+
const shadowed = event.sourceEventSeqs;
|
|
120
|
+
if (!Array.isArray(shadowed)) continue;
|
|
121
|
+
for (const seq of shadowed) {
|
|
122
|
+
const inner = events[seq];
|
|
123
|
+
if (inner === null || typeof inner !== "object") continue;
|
|
124
|
+
if (inner.type !== "user/message") continue;
|
|
125
|
+
const source = inner.data?.source;
|
|
126
|
+
if (source?.kind !== "user") continue;
|
|
127
|
+
found.push({
|
|
128
|
+
messageId: inner.data.id,
|
|
129
|
+
seq: inner.seq
|
|
130
|
+
});
|
|
131
|
+
break; // one genuine user message per deleted span
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
found.sort((left, right) => left.seq - right.seq);
|
|
135
|
+
return found;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build the marker `user/message` that replaces a deleted turn on the
|
|
140
|
+
* surface. Its content is a short note (the fold derives the replacement
|
|
141
|
+
* into the model surface; durable payloads are never removed).
|
|
142
|
+
* @param messageId - new message id for the marker.
|
|
143
|
+
* @param text - optional marker text; defaults to a bilingual note.
|
|
144
|
+
* @returns the marker message payload.
|
|
145
|
+
*/
|
|
146
|
+
export function deletionMarkerMessage(messageId, text) {
|
|
147
|
+
return {
|
|
148
|
+
role: "user",
|
|
149
|
+
id: messageId,
|
|
150
|
+
content: [{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: text ?? "(本条消息及其全部回复已被用户删除,请忽略 / This message and all its replies were deleted by the user — please ignore)"
|
|
153
|
+
}],
|
|
154
|
+
source: deletionMarkerSource()
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The current deletable tail: the last on-surface genuine user message, or
|
|
160
|
+
* null when none exists (no user message, or everything is before the newest
|
|
161
|
+
* compact boundary / already soft-deleted). Host-authoritative answer to
|
|
162
|
+
* "where should the delete button sit".
|
|
163
|
+
* @param nodes - current surface node seqs in model-visible order.
|
|
164
|
+
* @param eventAt - maps a seq back to its durable event.
|
|
165
|
+
* @returns `{ messageId, seq } | null`.
|
|
166
|
+
*/
|
|
167
|
+
export function currentTailUserMessage(nodes, eventAt) {
|
|
168
|
+
if (!Array.isArray(nodes)) return null;
|
|
169
|
+
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
170
|
+
const event = eventAt(nodes[index]);
|
|
171
|
+
if (event === null || typeof event !== "object") continue;
|
|
172
|
+
if (event.type !== "user/message") continue;
|
|
173
|
+
const source = event.data?.source;
|
|
174
|
+
if (source?.kind !== "user") continue;
|
|
175
|
+
return {
|
|
176
|
+
messageId: event.data?.id ?? null,
|
|
177
|
+
seq: nodes[index]
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A manual compaction group failed when the user's `/compact` command did not
|
|
185
|
+
* land a summary: its `command/done` outcome is an error (the engine may have
|
|
186
|
+
* appended a `compaction/start`/`compaction/end` error pair and no
|
|
187
|
+
* `compaction/summary`). Failed compactions never touched the surface, so they
|
|
188
|
+
* are not a compact boundary — the history below them stays deletable.
|
|
189
|
+
* @param events - durable events in seq order.
|
|
190
|
+
* @returns failed groups as `{ commandId, runSeq }[]` in seq order.
|
|
191
|
+
*/
|
|
192
|
+
export function collectFailedCompactionGroups(events) {
|
|
193
|
+
const groups = [];
|
|
194
|
+
if (!Array.isArray(events)) return groups;
|
|
195
|
+
const runsByCommand = new Map(); // commandId -> run seq
|
|
196
|
+
const done = new Map(); // commandId -> { kind, doneSeq }
|
|
197
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
198
|
+
const event = events[index];
|
|
199
|
+
if (event === null || typeof event !== "object") continue;
|
|
200
|
+
if (event.type === "command/run") {
|
|
201
|
+
const commandId = event.data?.commandId;
|
|
202
|
+
if (typeof commandId === "string" && event.data?.name === "compact") {
|
|
203
|
+
runsByCommand.set(commandId, index);
|
|
204
|
+
}
|
|
205
|
+
} else if (event.type === "command/done") {
|
|
206
|
+
const commandId = event.data?.commandId;
|
|
207
|
+
if (typeof commandId === "string") {
|
|
208
|
+
done.set(commandId, event.data?.kind ?? "success");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// A `/compact` whose command ended in error (possibly with an aborted
|
|
213
|
+
// compaction/start…end pair and no compaction/summary) is a failed group;
|
|
214
|
+
// a command that completed successfully never produces an error outcome.
|
|
215
|
+
for (const [commandId, runSeq] of runsByCommand) {
|
|
216
|
+
if (done.get(commandId) !== "error") continue;
|
|
217
|
+
groups.push({ commandId, runSeq });
|
|
218
|
+
}
|
|
219
|
+
groups.sort((left, right) => left.runSeq - right.runSeq);
|
|
220
|
+
return groups;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The newest deletable item of a session: either the last on-surface genuine
|
|
225
|
+
* user message (a normal turn), or — when a failed `/compact` group is the
|
|
226
|
+
* newest content — that failed-compaction card itself. Successful compaction
|
|
227
|
+
* boundaries are never returned here; the surface already dropped everything
|
|
228
|
+
* before them.
|
|
229
|
+
* @param events - durable events in seq order (for failed-group discovery).
|
|
230
|
+
* @param nodes - current surface node seqs (model-visible user/assistant/tool).
|
|
231
|
+
* @param eventAt - maps a seq to its durable event.
|
|
232
|
+
* @param deletedCompactIds - previously soft-deleted failed `/compact` command ids.
|
|
233
|
+
* @returns `{ kind: "message", messageId, seq } | { kind: "compact", commandId, seq } | null`.
|
|
234
|
+
*/
|
|
235
|
+
export function newestDeletable(events, nodes, eventAt, deletedCompactIds = new Set()) {
|
|
236
|
+
const messageTail = currentTailUserMessage(nodes, eventAt);
|
|
237
|
+
const userSeq = messageTail === null ? -1 : messageTail.seq;
|
|
238
|
+
const groups = collectFailedCompactionGroups(events).filter((group) => !deletedCompactIds.has(group.commandId));
|
|
239
|
+
const newestGroup = groups.length === 0 ? null : groups[groups.length - 1];
|
|
240
|
+
if (newestGroup === null || newestGroup.runSeq < userSeq) {
|
|
241
|
+
return messageTail === null ? null : {
|
|
242
|
+
kind: "message",
|
|
243
|
+
messageId: messageTail.messageId,
|
|
244
|
+
seq: messageTail.seq
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
kind: "compact",
|
|
249
|
+
commandId: newestGroup.commandId,
|
|
250
|
+
seq: newestGroup.runSeq
|
|
251
|
+
};
|
|
252
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-last-turn-delete — host Remote service.
|
|
3
|
+
*
|
|
4
|
+
* Two kinds of "delete the last thing" are supported:
|
|
5
|
+
*
|
|
6
|
+
* 1. Soft-delete the last *user turn* (the shipped mechanism): inside the
|
|
7
|
+
* agent's serialized idle maintenance phase the service appends one
|
|
8
|
+
* `user/message` surface replacement that shadows the target user message
|
|
9
|
+
* and every later surface node (its model responses) up to the log tail.
|
|
10
|
+
* Durable history is never rewritten — deleted content stops entering
|
|
11
|
+
* future model context while the original events stay in the log.
|
|
12
|
+
*
|
|
13
|
+
* 2. Soft-delete a *failed `/compact` card*: a manual compaction whose
|
|
14
|
+
* `command/done` outcome was an error never touched the model surface, so
|
|
15
|
+
* it is not a compact boundary. It is recorded in this plugin's private
|
|
16
|
+
* storage-domain sidecar and simply stops showing in the delete chain —
|
|
17
|
+
* the chain then walks past it to the previous user message.
|
|
18
|
+
*
|
|
19
|
+
* Remote group: `lastTurnDelete`
|
|
20
|
+
* - `delete({ sessionId, messageId })` soft-deletes the tail user turn.
|
|
21
|
+
* - `deleteCompact({ sessionId })` forgets the newest failed `/compact` card.
|
|
22
|
+
* - `tail({ sessionId })` host-authoritative newest deletable item plus the
|
|
23
|
+
* agent `running` flag
|
|
24
|
+
* (`{ kind: "message", messageId } | { kind: "compact", commandId } | { kind: null }`, all `+ running`).
|
|
25
|
+
* - `deleted({ sessionId })` previously deleted user messages + compact ids.
|
|
26
|
+
*
|
|
27
|
+
* Methods are registered with @deepseek-ai/dsh-typert-protocol `Remote`
|
|
28
|
+
* markers; this file ships as plain ESM, so the marker initializers are
|
|
29
|
+
* applied with the equivalent manual calls against a throwaway instance.
|
|
30
|
+
*/
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
33
|
+
import { foldSurface } from "@deepseek-ai/dsh-session";
|
|
34
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
35
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
import {
|
|
38
|
+
collectDeletedMessages,
|
|
39
|
+
deletionMarkerMessage,
|
|
40
|
+
findTailDeletionTarget,
|
|
41
|
+
newestDeletable
|
|
42
|
+
} from "./core.js";
|
|
43
|
+
|
|
44
|
+
const GROUP = "lastTurnDelete";
|
|
45
|
+
const DOMAIN_NAME = "dsh_last_turn_delete";
|
|
46
|
+
|
|
47
|
+
/** Sidecar row schema: per-session id, identity-guarded, list of forgotten failed `/compact` command ids. */
|
|
48
|
+
const compactRowSchema = z.object({
|
|
49
|
+
session: z.object({
|
|
50
|
+
createdAt: z.number().int().nonnegative(),
|
|
51
|
+
cwd: z.string().optional()
|
|
52
|
+
}),
|
|
53
|
+
compactCommandIds: z.array(z.string())
|
|
54
|
+
});
|
|
55
|
+
const compactDomainSpec = defineDomain({
|
|
56
|
+
name: DOMAIN_NAME,
|
|
57
|
+
version: 0,
|
|
58
|
+
tables: { sessions: domainTable(compactRowSchema) }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mark `name` as a Remote method on the given service class.
|
|
63
|
+
*
|
|
64
|
+
* `Remote` is a TC39 standard method decorator: it registers a class-extra
|
|
65
|
+
* initializer that runs with `this` bound to an *instance* (the compiled
|
|
66
|
+
* `__runInitializers(this, _instanceExtraInitializers)` call happens in the
|
|
67
|
+
* constructor) and then marks `Object.getPrototypeOf(this)` — the class
|
|
68
|
+
* prototype. Plain ESM has no decorator syntax, so emulate exactly that:
|
|
69
|
+
* run the initializer against a throwaway instance.
|
|
70
|
+
*/
|
|
71
|
+
function markRemoteMethod(serviceClass, name) {
|
|
72
|
+
const receiver = Object.create(serviceClass.prototype);
|
|
73
|
+
Remote(name)(undefined, {
|
|
74
|
+
kind: "method",
|
|
75
|
+
name,
|
|
76
|
+
static: false,
|
|
77
|
+
private: false,
|
|
78
|
+
addInitializer(run) {
|
|
79
|
+
run.call(receiver);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function success(value) {
|
|
85
|
+
return Object.freeze({ ok: true, value });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rejected(error) {
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
ok: false,
|
|
91
|
+
error: Object.freeze(error)
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function failure(code, message, details = {}) {
|
|
96
|
+
return rejected({ code, message, ...details });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function identityOf(header) {
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
createdAt: header.createdAt,
|
|
102
|
+
...header.cwd === void 0 ? {} : { cwd: header.cwd }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sameIdentity(row, header) {
|
|
107
|
+
return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One durable Session lifecycle bound to a storage-domain sidecar row. */
|
|
111
|
+
function snapshotRow(header, ids) {
|
|
112
|
+
const copied = [...ids];
|
|
113
|
+
Object.freeze(copied);
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
session: identityOf(header),
|
|
116
|
+
compactCommandIds: copied
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The deletion Remote service. Injected services mirror the
|
|
122
|
+
* dsh-message-feedback consumer pattern (agents / sessions /
|
|
123
|
+
* sessionPersistence) plus the storage-domain sidecar.
|
|
124
|
+
*/
|
|
125
|
+
class LastTurnDeleteService extends TypertRemoteService {
|
|
126
|
+
static inject = ["agents", "sessions", "sessionPersistence", "storageDomain"];
|
|
127
|
+
|
|
128
|
+
table;
|
|
129
|
+
|
|
130
|
+
constructor(ctx) {
|
|
131
|
+
super(ctx, GROUP);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Open and own the one compact-forget sidecar domain. */
|
|
135
|
+
async [Service.init]() {
|
|
136
|
+
const domain = await this.ctx.storageDomain.open(compactDomainSpec);
|
|
137
|
+
this.ctx.effect(() => async () => {
|
|
138
|
+
await domain.close();
|
|
139
|
+
}, `${DOMAIN_NAME}.domainClose`);
|
|
140
|
+
this.table = domain.table("sessions");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve a session's durable events + identity (live agent first, cold
|
|
145
|
+
* persistence otherwise). `eventAt`/nodes derive from the same array.
|
|
146
|
+
* @returns `{ header, events } | null`.
|
|
147
|
+
*/
|
|
148
|
+
async resolveInspection(sessionId) {
|
|
149
|
+
const live = this.ctx.agents.get(sessionId);
|
|
150
|
+
if (live !== void 0) {
|
|
151
|
+
return {
|
|
152
|
+
sessionId,
|
|
153
|
+
header: live.session.header,
|
|
154
|
+
events: live.session.snapshotEvents(),
|
|
155
|
+
nodes: live.session.surface.nodes
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const inspection = await this.ctx.sessionPersistence.inspect(sessionId);
|
|
159
|
+
if (inspection?.events === void 0) return null;
|
|
160
|
+
return {
|
|
161
|
+
sessionId,
|
|
162
|
+
header: inspection.meta,
|
|
163
|
+
events: inspection.events,
|
|
164
|
+
nodes: foldSurface(inspection.events).nodes
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Stored (forgotten) failed-compact ids guarded by session identity. */
|
|
169
|
+
storedCompactIds(sessionId, header) {
|
|
170
|
+
const row = this.requireTable().get(sessionId);
|
|
171
|
+
if (row === void 0 || !sameIdentity(row, header)) return [];
|
|
172
|
+
return [...row.compactCommandIds];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async persistCompactIds(sessionId, header, ids) {
|
|
176
|
+
await this.requireTable().put(sessionId, snapshotRow(header, ids));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
requireTable() {
|
|
180
|
+
if (this.table === void 0) throw new Error("last-turn-delete: sidecar domain is not initialized");
|
|
181
|
+
return this.table;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Soft-delete the current tail user turn.
|
|
186
|
+
* The agent must own the session and be idle; the call runs inside
|
|
187
|
+
* `agent.runMaintenance` so queued waking input parks until the marker is
|
|
188
|
+
* durably checkpointed.
|
|
189
|
+
* @param request - session and the client-observed tail message id.
|
|
190
|
+
* @returns `{ ok: true, value }` or an explicit business failure.
|
|
191
|
+
*/
|
|
192
|
+
async delete(request) {
|
|
193
|
+
const { sessionId, messageId } = request ?? {};
|
|
194
|
+
if (typeof sessionId !== "string" || typeof messageId !== "string") {
|
|
195
|
+
return failure("bad-request", "sessionId and messageId are required");
|
|
196
|
+
}
|
|
197
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
198
|
+
if (agent === void 0) {
|
|
199
|
+
return failure("session-not-found", "no live agent owns this session", { sessionId });
|
|
200
|
+
}
|
|
201
|
+
let task;
|
|
202
|
+
try {
|
|
203
|
+
task = agent.runMaintenance(async () => {
|
|
204
|
+
const session = agent.session;
|
|
205
|
+
const target = findTailDeletionTarget(
|
|
206
|
+
session.surface.nodes,
|
|
207
|
+
(seq) => session.eventAt(seq) ?? null,
|
|
208
|
+
messageId
|
|
209
|
+
);
|
|
210
|
+
if (target.status === "none") {
|
|
211
|
+
return failure("no-target", "no deletable tail user message (compact boundary or empty)", { sessionId });
|
|
212
|
+
}
|
|
213
|
+
if (target.status === "stale") {
|
|
214
|
+
return failure("stale", "the tail user message changed; refresh and retry", {
|
|
215
|
+
sessionId,
|
|
216
|
+
currentMessageId: target.currentMessageId
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const marker = deletionMarkerMessage(randomUUID());
|
|
220
|
+
session.append("user/message", marker, {
|
|
221
|
+
surfaceOp: { op: "replace", start: target.startSeq, end: target.endSeq },
|
|
222
|
+
sourceEventSeqs: target.shadowedSeqs
|
|
223
|
+
});
|
|
224
|
+
await this.ctx.sessions.flush(session);
|
|
225
|
+
return success({
|
|
226
|
+
sessionId,
|
|
227
|
+
messageId: target.messageId,
|
|
228
|
+
startSeq: target.startSeq,
|
|
229
|
+
endSeq: target.endSeq,
|
|
230
|
+
shadowedCount: target.shadowedSeqs.length
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
} catch (error) {
|
|
234
|
+
// runMaintenance throws synchronously when another driver or
|
|
235
|
+
// maintenance task owns the agent (busy / mid-stream).
|
|
236
|
+
return failure("busy", "the agent is running; retry once it is idle", { sessionId, cause: error?.message });
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return await task;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return failure("internal", "deletion failed", { sessionId, cause: error?.message });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Forget one failed `/compact` card (the newest deletable item). A failed
|
|
247
|
+
* compaction never changed the model surface, so nothing is shadowed — the
|
|
248
|
+
* card is recorded in the sidecar and the delete chain moves past it.
|
|
249
|
+
* @param request - session whose newest deletable item is a failed card.
|
|
250
|
+
* @returns `{ ok: true, value: { commandId } }` or a business failure.
|
|
251
|
+
*/
|
|
252
|
+
async deleteCompact(request) {
|
|
253
|
+
const { sessionId } = request ?? {};
|
|
254
|
+
if (typeof sessionId !== "string") return failure("bad-request", "sessionId is required");
|
|
255
|
+
try {
|
|
256
|
+
const inspection = await this.resolveInspection(sessionId);
|
|
257
|
+
if (inspection === null) {
|
|
258
|
+
return failure("session-not-found", "no session or persistence record", { sessionId });
|
|
259
|
+
}
|
|
260
|
+
const { events, nodes, header } = inspection;
|
|
261
|
+
const eventAt = (seq) => events[seq] ?? null;
|
|
262
|
+
const candidate = newestDeletable(events, nodes, eventAt, new Set(this.storedCompactIds(inspection.sessionId, header)));
|
|
263
|
+
if (candidate === null || candidate.kind !== "compact") {
|
|
264
|
+
return failure("no-target", "no deletable failed compaction card", { sessionId });
|
|
265
|
+
}
|
|
266
|
+
const commandId = candidate.commandId;
|
|
267
|
+
await this.persistCompactIds(inspection.sessionId, inspection.header, [
|
|
268
|
+
...this.storedCompactIds(inspection.sessionId, inspection.header),
|
|
269
|
+
commandId
|
|
270
|
+
]);
|
|
271
|
+
return success({ sessionId, commandId });
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return failure("internal", "could not delete the compaction card", { sessionId, cause: error?.message });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Host-authoritative newest deletable item. Normal tail user message, or a
|
|
279
|
+
* failed `/compact` card that sits after it (both may be walked through).
|
|
280
|
+
* @param request - session to inspect.
|
|
281
|
+
* @returns `{ ok: true, value }` where value is
|
|
282
|
+
* `{ kind: "message", messageId } | { kind: "compact", commandId } | { kind: null, messageId: null }`.
|
|
283
|
+
*/
|
|
284
|
+
async tail(request) {
|
|
285
|
+
const { sessionId } = request ?? {};
|
|
286
|
+
if (typeof sessionId !== "string") return failure("bad-request", "sessionId is required");
|
|
287
|
+
try {
|
|
288
|
+
const inspection = await this.resolveInspection(sessionId);
|
|
289
|
+
if (inspection === null) {
|
|
290
|
+
return failure("session-not-found", "no session or persistence record", { sessionId });
|
|
291
|
+
}
|
|
292
|
+
const { events, nodes, header } = inspection;
|
|
293
|
+
const liveAgent = this.ctx.agents.get(sessionId);
|
|
294
|
+
const running = liveAgent !== void 0 && liveAgent.status === "running";
|
|
295
|
+
const candidate = newestDeletable(events, nodes, (seq) => events[seq] ?? null, new Set(this.storedCompactIds(inspection.sessionId, header)));
|
|
296
|
+
if (candidate === null) {
|
|
297
|
+
return success({ kind: null, messageId: null, running });
|
|
298
|
+
}
|
|
299
|
+
if (candidate.kind === "compact") {
|
|
300
|
+
return success({ kind: "compact", commandId: candidate.commandId, running });
|
|
301
|
+
}
|
|
302
|
+
return success({ kind: "message", messageId: candidate.messageId, running });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return failure("internal", "could not resolve the deletable tail", { sessionId, cause: error?.message });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* List every previously soft-deleted item of a session: user messages
|
|
310
|
+
* (derived from log markers) and forgotten failed-compact command ids
|
|
311
|
+
* (sidecar).
|
|
312
|
+
* @param request - session to inspect.
|
|
313
|
+
* @returns `{ ok: true, value: { items, compactCommandIds } }`.
|
|
314
|
+
*/
|
|
315
|
+
async deleted(request) {
|
|
316
|
+
const { sessionId } = request ?? {};
|
|
317
|
+
if (typeof sessionId !== "string") return failure("bad-request", "sessionId is required");
|
|
318
|
+
try {
|
|
319
|
+
const inspection = await this.resolveInspection(sessionId);
|
|
320
|
+
if (inspection === null) {
|
|
321
|
+
return failure("session-not-found", "no session or persistence record", { sessionId });
|
|
322
|
+
}
|
|
323
|
+
return success({
|
|
324
|
+
items: collectDeletedMessages(inspection.events),
|
|
325
|
+
compactCommandIds: this.storedCompactIds(inspection.sessionId, inspection.header)
|
|
326
|
+
});
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return failure("internal", "could not list deletions", { sessionId, cause: error?.message });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
markRemoteMethod(LastTurnDeleteService, "delete");
|
|
334
|
+
markRemoteMethod(LastTurnDeleteService, "deleteCompact");
|
|
335
|
+
markRemoteMethod(LastTurnDeleteService, "deleted");
|
|
336
|
+
markRemoteMethod(LastTurnDeleteService, "tail");
|
|
337
|
+
|
|
338
|
+
export { LastTurnDeleteService, LastTurnDeleteService as default };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-last-turn-delete",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness (DSH) bundle: a delete button on the last user message that soft-deletes that turn — user message plus all its model replies — keeping it out of future model context, stepping back to the previous user message until a compact boundary or none remain (最后一条用户消息上的删除按钮:软删除该用户消息及其全部模型回复,不再进入后续模型上下文;按钮随后移到上一条用户消息,直到 compact 边界或无消息)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": {
|
|
10
|
+
"default": "./lib/client.js"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib/index.js",
|
|
16
|
+
"lib/core.js",
|
|
17
|
+
"lib/client.js",
|
|
18
|
+
"cordis.patch.yml"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"zod": "^4.4.3"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
25
|
+
"@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
|
|
26
|
+
"@deepseek-ai/dsh-storage-domain": "^0.1.2-alpha.5",
|
|
27
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.2-alpha.5"
|
|
28
|
+
},
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
},
|
|
33
|
+
"client": {
|
|
34
|
+
"platform": "web",
|
|
35
|
+
"inject": [
|
|
36
|
+
"@deepseek-ai/dsh-client-connection",
|
|
37
|
+
"@deepseek-ai/dsh-client-locale",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"keywords": [
|
|
45
|
+
"dsh",
|
|
46
|
+
"deepseek-harness",
|
|
47
|
+
"plugin",
|
|
48
|
+
"bundle",
|
|
49
|
+
"client",
|
|
50
|
+
"delete",
|
|
51
|
+
"message",
|
|
52
|
+
"turn"
|
|
53
|
+
],
|
|
54
|
+
"scripts": {
|
|
55
|
+
"test": "node --test lib/core.test.mjs"
|
|
56
|
+
}
|
|
57
|
+
}
|