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/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
+ }