dsh-as-aistudio 0.2.13 → 0.3.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/CHANGELOG.md +31 -0
- package/README.md +5 -5
- package/package.json +17 -12
- package/src/client.js +3362 -225
- package/src/components.js +1 -1
- package/src/index.js +26 -1
- package/src/shell.js +30 -5
- package/src/vendor/host/_platform.js +132 -0
- package/src/vendor/host/dsh-delete-turn.js +661 -0
- package/src/vendor/host/dsh-delete-turn.logic.js +655 -0
- package/src/vendor/host/dsh-edit-turn.js +2813 -0
- package/src/vendor/host/dsh-edit-turn.patch.js +285 -0
- package/src/vendor/host/dsh-markdown-bubble.js +37 -0
- package/src/vendor/host/dsh-rerun-turn.js +2784 -0
- package/src/vendor/versions.json +6 -0
- package/docs/INTEROP.md +0 -230
- package/src/studio.js +0 -553
- package/src/vendor/dsh-delete-turn.js +0 -1174
- package/src/vendor/dsh-edit-turn.js +0 -2626
- package/src/vendor/dsh-markdown-bubble.js +0 -872
- package/src/vendor/dsh-rerun-turn.js +0 -1014
- package/tools/build.mjs +0 -87
- package/tools/bump.mjs +0 -82
- package/tools/head.txt +0 -13
- package/tools/tail.txt +0 -5
- package/tools/tail2.txt +0 -4
- package/tools/vendor.mjs +0 -125
- package/tools/verify-manifest.mjs +0 -77
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
// dsh-delete-turn host half (dsh-delete-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
|
+
//
|
|
3
|
+
// Source: ../dsh-delete-turn/src/index.js (v0.1.15, sha256 75377700ba2a6436)
|
|
4
|
+
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
|
+
//
|
|
6
|
+
// src/shell.js imports the copy named after the component, which is why the
|
|
7
|
+
// studio installs with no runtime dependency on the component package.
|
|
8
|
+
// dsh-delete-turn - host half.
|
|
9
|
+
//
|
|
10
|
+
// Two loopback-only JSON routes:
|
|
11
|
+
//
|
|
12
|
+
// GET /dsh-delete-turn/state?sessionId=<id>
|
|
13
|
+
// POST /dsh-delete-turn/delete { sessionId, mode, seq?, messageId?, turn? }
|
|
14
|
+
//
|
|
15
|
+
// A deletion appends ONE turn-less `user/message` carrier carrying the official
|
|
16
|
+
// surface intent `{ surfaceOp: { op: 'replace', startSeq, endSeq } }` and the
|
|
17
|
+
// complete shadowed-node list in `sourceEventSeqs`. The carrier's content is
|
|
18
|
+
// empty (or one zero-width space, see below), so the addressed content leaves
|
|
19
|
+
// the derived context while the append-only log keeps every original byte. The
|
|
20
|
+
// carrier source names this plugin, which is how the browser half rebuilds its
|
|
21
|
+
// hidden-row ledger after a reload without any private sidecar.
|
|
22
|
+
//
|
|
23
|
+
// The fourth action (`mode: 'turn'`) deletes a WHOLE turn - prompt included - as
|
|
24
|
+
// one ordinary range replace: every live surface node of that turn (prompt,
|
|
25
|
+
// injected context, reply steps and tool results) is shadowed by a single
|
|
26
|
+
// turn-less carrier, and the later turns stay exactly where they are. No replay
|
|
27
|
+
// and no renumbering: the official surface contract needs neither, so the log
|
|
28
|
+
// grows by one event and nothing else.
|
|
29
|
+
//
|
|
30
|
+
// The module imports nothing from the DSH SDK: `sessions`, `sessionQuery` and
|
|
31
|
+
// `sessionController` are resolved through the cordis context at call time, so
|
|
32
|
+
// the plugin loads on any profile and degrades to a clear HTTP failure when a
|
|
33
|
+
// service is absent.
|
|
34
|
+
import { randomUUID } from 'node:crypto'
|
|
35
|
+
import { readFileSync } from 'node:fs'
|
|
36
|
+
import { createRequire } from 'node:module'
|
|
37
|
+
import { dirname } from 'node:path'
|
|
38
|
+
import {
|
|
39
|
+
PLUGIN_ID,
|
|
40
|
+
PlanError,
|
|
41
|
+
contentEditPairs,
|
|
42
|
+
deletableReplyTurns,
|
|
43
|
+
foldSurface,
|
|
44
|
+
hiddenEntriesOfFold,
|
|
45
|
+
isBusy,
|
|
46
|
+
openTurn,
|
|
47
|
+
planRange,
|
|
48
|
+
turnableSeqs,
|
|
49
|
+
} from './dsh-delete-turn.logic.js'
|
|
50
|
+
|
|
51
|
+
export const name = PLUGIN_ID
|
|
52
|
+
|
|
53
|
+
const ROUTE_PREFIX = '/dsh-delete-turn'
|
|
54
|
+
// A session id is an OPAQUE string to DSH: the host's own schema is
|
|
55
|
+
// `z.string().min(1)`, and a named session created in the GUI (`bili-whale`,
|
|
56
|
+
// stored as ~/.dsh/storages/<profile>/sessions/bili-whale.json) is exactly as
|
|
57
|
+
// real as a UUID one. A UUID-only test rejected those with a 400 - the panel
|
|
58
|
+
// then answered "状态加载失败" for a session that plainly exists, and a session
|
|
59
|
+
// list filtered by it hid them. The character class still refuses everything
|
|
60
|
+
// that could leave a path or a URL: `/`, `\`, `..` (the first character must be
|
|
61
|
+
// alphanumeric, so a leading dot is already out), whitespace and control
|
|
62
|
+
// characters.
|
|
63
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
64
|
+
|
|
65
|
+
// Stopping a running turn before the delete: the controller acknowledges the
|
|
66
|
+
// cancel at once, but the loop still has to unwind - the provider stream is
|
|
67
|
+
// cancelled, started tool calls commit their results in order, the partial
|
|
68
|
+
// answer lands as `interrupted: true`, `turn/end` is appended and flushed. The
|
|
69
|
+
// route therefore polls the log until the turn actually closes.
|
|
70
|
+
const STOP_TIMEOUT_MS = 15000
|
|
71
|
+
const STOP_POLL_MS = 250
|
|
72
|
+
let stopWait = { timeoutMs: STOP_TIMEOUT_MS, pollMs: STOP_POLL_MS }
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Override the stop-and-delete wait budget. Production keeps the defaults; the
|
|
76
|
+
* suite shrinks them so the timeout branch stays cheap to test.
|
|
77
|
+
* @param options - `{ timeoutMs?, pollMs? }`.
|
|
78
|
+
*/
|
|
79
|
+
export function configureStopWait(options) {
|
|
80
|
+
stopWait = {
|
|
81
|
+
timeoutMs: options && typeof options.timeoutMs === 'number' ? options.timeoutMs : STOP_TIMEOUT_MS,
|
|
82
|
+
pollMs: options && typeof options.pollMs === 'number' ? options.pollMs : STOP_POLL_MS,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// `turn` is the fourth action: the widest ordinary range replace (every live
|
|
86
|
+
// surface node of one turn). It dispatches through the same route and the same
|
|
87
|
+
// planner as the other three; nothing here is special-cased.
|
|
88
|
+
const MODES = new Set(['message', 'step', 'reply', 'turn'])
|
|
89
|
+
|
|
90
|
+
// The one adapter line that makes a deletion invisible: `dsh-llm-pi-ai` drops a
|
|
91
|
+
// user message whose converted content is empty (the official DeepSeek adapter
|
|
92
|
+
// already does this natively; upstream has not added the pi-ai skip yet). When
|
|
93
|
+
// that line is present, the carrier can be an empty content list and never
|
|
94
|
+
// reaches any model.
|
|
95
|
+
//
|
|
96
|
+
// Otherwise the carrier MUST carry a zero-width space. An empty content list is
|
|
97
|
+
// not a cheaper alternative, and this was measured rather than assumed: the
|
|
98
|
+
// session append accepts `content: []` (it derives to a user message with no
|
|
99
|
+
// blocks, priced at 4 tokens), but the adapter then hands the provider
|
|
100
|
+
// `{ role: 'user', content: '' }` and the provider refuses the whole request.
|
|
101
|
+
// Probed 2026-10-03 against the provider this repository deploys on
|
|
102
|
+
// (cline-pass -> api.cline.bot -> Vercel deepseek/deepseek-v4.1-flash):
|
|
103
|
+
//
|
|
104
|
+
// { role: 'user', content: '' } -> HTTP 400
|
|
105
|
+
// {"message":"user message must have content", "type":"invalid_request_error"}
|
|
106
|
+
// the same shape midway through a conversation -> HTTP 400, same error
|
|
107
|
+
// a normal user message through the same route -> past validation
|
|
108
|
+
// (fails only because max_tokens:1 produced no text)
|
|
109
|
+
//
|
|
110
|
+
// So the zero-width space is load-bearing, and its ~9 heuristic tokens (4 role
|
|
111
|
+
// framing + 4 block overhead + 1 char) are the price of a deletion every
|
|
112
|
+
// provider will accept. Dropping the character instead of the message is not an
|
|
113
|
+
// option: an empty string is what the provider rejects.
|
|
114
|
+
//
|
|
115
|
+
// A global capability probe cannot relax this either. The flag is decided once
|
|
116
|
+
// at load while the adapter in play depends on the session's provider, so a
|
|
117
|
+
// profile holding both a DeepSeek-adapter provider and an unpatched pi-ai one
|
|
118
|
+
// would write an empty carrier for the latter and break its deletions. The
|
|
119
|
+
// empty carrier is therefore used only when the one adapter this plugin can
|
|
120
|
+
// prove skips it is patched. The probe reads the installed adapter once at
|
|
121
|
+
// load; any failure falls back to the safe carrier.
|
|
122
|
+
const ADAPTER_PATCH_MARKER = 'dsh-delete-turn:skip-empty-user'
|
|
123
|
+
|
|
124
|
+
function adapterDropsEmptyUserContent() {
|
|
125
|
+
try {
|
|
126
|
+
const entry = process.argv[1]
|
|
127
|
+
const require = createRequire(entry ?? import.meta.url)
|
|
128
|
+
const resolved = require.resolve('@deepseek-ai/dsh-llm-pi-ai', { paths: entry ? [dirname(entry)] : [] })
|
|
129
|
+
return readFileSync(resolved, 'utf8').includes(ADAPTER_PATCH_MARKER)
|
|
130
|
+
} catch {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const SILENT_CARRIER = adapterDropsEmptyUserContent()
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The provider a session's last model request used, from its own log. The
|
|
139
|
+
* carrier shape depends on the ADAPTER behind that provider, so the decision has
|
|
140
|
+
* to be per session rather than once at load.
|
|
141
|
+
*
|
|
142
|
+
* @param events - the session's committed events.
|
|
143
|
+
* @returns the provider id, or null when the log does not name one.
|
|
144
|
+
*/
|
|
145
|
+
export function providerOf(events) {
|
|
146
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
147
|
+
const event = events[index]
|
|
148
|
+
if (event === null || typeof event !== 'object') continue
|
|
149
|
+
const data = event.data
|
|
150
|
+
if (data === null || typeof data !== 'object') continue
|
|
151
|
+
if (event.type === 'model/selection' && typeof data.provider === 'string' && data.provider !== '') return data.provider
|
|
152
|
+
const header = data.header
|
|
153
|
+
const config = header !== null && typeof header === 'object' ? header.config : null
|
|
154
|
+
if (event.type === 'request/header' && config !== null && typeof config === 'object' && typeof config.provider === 'string' && config.provider !== '') {
|
|
155
|
+
return config.provider
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether the adapter serving `provider` drops an empty user message before it
|
|
163
|
+
* reaches the wire. The DeepSeek adapter does it natively (its converter skips
|
|
164
|
+
* `role === "user" | "assistant"` with no content) and is the one adapter whose
|
|
165
|
+
* extension hooks include `prepareExtensions`; pi-ai carries the same
|
|
166
|
+
* `resolveImageAccess` but no `prepareExtensions`.
|
|
167
|
+
*
|
|
168
|
+
* @param ctx - plugin context, for the `llm` registry.
|
|
169
|
+
* @param provider - provider id from the session log.
|
|
170
|
+
* @returns true only when the adapter in play is the proven one.
|
|
171
|
+
*/
|
|
172
|
+
export function adapterDropsEmptyUserContentFor(ctx, provider) {
|
|
173
|
+
if (typeof provider !== 'string' || provider === '') return false
|
|
174
|
+
try {
|
|
175
|
+
const llm = ctx.get('llm')
|
|
176
|
+
const adapters = llm === null || llm === undefined ? null : llm.adapters
|
|
177
|
+
if (adapters === null || adapters === undefined || typeof adapters.get !== 'function') return false
|
|
178
|
+
const entry = adapters.get(provider)
|
|
179
|
+
const adapter = entry === null || entry === undefined ? null : entry.adapter
|
|
180
|
+
return adapter !== null && adapter !== undefined && typeof adapter.prepareExtensions === 'function'
|
|
181
|
+
} catch {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The carrier shape for one deletion: an empty content list when this session's
|
|
188
|
+
* adapter provably drops it, one zero-width space otherwise (the safe shape for
|
|
189
|
+
* an adapter that would send `{ role: 'user', content: '' }` and get a 400).
|
|
190
|
+
*
|
|
191
|
+
* @param ctx - plugin context.
|
|
192
|
+
* @param events - the session's committed events.
|
|
193
|
+
* @returns true when the empty carrier is safe for this session.
|
|
194
|
+
*/
|
|
195
|
+
export function silentCarrierFor(ctx, events) {
|
|
196
|
+
if (adapterDropsEmptyUserContentFor(ctx, providerOf(events))) return true
|
|
197
|
+
// The load-time proof covers a pi-ai host with the local skip patch.
|
|
198
|
+
return SILENT_CARRIER
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class HttpError extends Error {
|
|
202
|
+
constructor(status, code, message, details) {
|
|
203
|
+
super(message)
|
|
204
|
+
this.name = 'HttpError'
|
|
205
|
+
this.status = status
|
|
206
|
+
this.code = code
|
|
207
|
+
this.details = details
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A session id travels in two spellings: the raw uuid and `session-<uuid>`.
|
|
212
|
+
// The store, the persistence directories and the workspace rows disagree about
|
|
213
|
+
// which one they hold, so every lookup tries both.
|
|
214
|
+
function idVariants(sessionId) {
|
|
215
|
+
const out = new Set([sessionId])
|
|
216
|
+
if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
|
|
217
|
+
else out.add(`session-${sessionId}`)
|
|
218
|
+
return [...out]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function findLiveSession(ctx, sessionId) {
|
|
222
|
+
const sessions = ctx.get('sessions')
|
|
223
|
+
if (!sessions || typeof sessions.get !== 'function') return undefined
|
|
224
|
+
for (const variant of idVariants(sessionId)) {
|
|
225
|
+
const found = sessions.get(variant)
|
|
226
|
+
if (found) return found
|
|
227
|
+
}
|
|
228
|
+
return undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Resolve the live Session that owns the append. An already-open session is
|
|
232
|
+
// used directly; a cold one is resumed through the official controller, which
|
|
233
|
+
// is exactly what the web UI does when the user opens it.
|
|
234
|
+
async function resolveSession(ctx, sessionId) {
|
|
235
|
+
const live = findLiveSession(ctx, sessionId)
|
|
236
|
+
if (live) return live
|
|
237
|
+
const controller = ctx.get('sessionController')
|
|
238
|
+
if (controller && typeof controller.resolveAgent === 'function') {
|
|
239
|
+
try {
|
|
240
|
+
const result = await controller.resolveAgent(sessionId)
|
|
241
|
+
if (result && result.agent && result.agent.session) return result.agent.session
|
|
242
|
+
} catch {
|
|
243
|
+
// fall through to the explicit failure below
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return undefined
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function eventsFromLive(session) {
|
|
250
|
+
if (session && typeof session.snapshotEvents === 'function') {
|
|
251
|
+
try {
|
|
252
|
+
const events = session.snapshotEvents()
|
|
253
|
+
if (Array.isArray(events)) return events
|
|
254
|
+
} catch {
|
|
255
|
+
// fall through to the query service
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return undefined
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Live-preferred read through the public query service; the live session's own
|
|
262
|
+
// snapshot is the fallback when the service is absent.
|
|
263
|
+
async function readEvents(ctx, sessionId) {
|
|
264
|
+
const query = ctx.get('sessionQuery')
|
|
265
|
+
if (query && typeof query.readSession === 'function') {
|
|
266
|
+
try {
|
|
267
|
+
const snapshot = await query.readSession(sessionId)
|
|
268
|
+
if (snapshot && Array.isArray(snapshot.events)) return snapshot.events
|
|
269
|
+
} catch {
|
|
270
|
+
// fall through to the live snapshot
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const events = eventsFromLive(findLiveSession(ctx, sessionId))
|
|
274
|
+
return events ?? null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function surfaceOf(ctx, sessionId, events) {
|
|
278
|
+
const live = findLiveSession(ctx, sessionId)
|
|
279
|
+
const nodes = live && live.surface && Array.isArray(live.surface.nodes) ? live.surface.nodes : undefined
|
|
280
|
+
return nodes ?? foldSurface(events).nodes
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The append is committed in memory the moment `session.append` returns; the
|
|
284
|
+
// persistence writer buffers asynchronously. Await the official durability
|
|
285
|
+
// checkpoint so a reload or a DSH restart still sees the deletion. A failed
|
|
286
|
+
// flush is not fatal — the event is already committed — but the request waits
|
|
287
|
+
// for the checkpoint when one exists.
|
|
288
|
+
async function flushSession(ctx, session) {
|
|
289
|
+
const errors = []
|
|
290
|
+
const sessions = ctx.get('sessions')
|
|
291
|
+
if (sessions && typeof sessions.flush === 'function') {
|
|
292
|
+
try {
|
|
293
|
+
await Promise.race([sessions.flush(session), new Promise((resolve) => setTimeout(resolve, 5000))])
|
|
294
|
+
return { flushed: true }
|
|
295
|
+
} catch (error) {
|
|
296
|
+
errors.push(String((error && error.message) || error))
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
errors.push('sessions.flush unavailable')
|
|
300
|
+
}
|
|
301
|
+
const persistence = ctx.get('sessionPersistence')
|
|
302
|
+
if (persistence && typeof persistence.flush === 'function') {
|
|
303
|
+
try {
|
|
304
|
+
await Promise.race([persistence.flush(), new Promise((resolve) => setTimeout(resolve, 5000))])
|
|
305
|
+
return { flushed: true }
|
|
306
|
+
} catch (error) {
|
|
307
|
+
errors.push(String((error && error.message) || error))
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
errors.push('sessionPersistence.flush unavailable')
|
|
311
|
+
}
|
|
312
|
+
return { flushed: false, flushError: errors.join(' | ') }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- operations --------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
// Turns opened purely as deletion bookkeeping: each carries one empty carrier
|
|
318
|
+
// system message and no real content. The host still renders a process row
|
|
319
|
+
// ("用时 N 秒") for them, so the browser half needs their numbers to hide them.
|
|
320
|
+
function markerTurnsOf(events) {
|
|
321
|
+
const turns = new Set()
|
|
322
|
+
for (const event of events) {
|
|
323
|
+
if (event.type !== 'system/message') continue
|
|
324
|
+
const data = event.data || {}
|
|
325
|
+
const source = (data.message && data.message.source) || {}
|
|
326
|
+
if (source.kind !== 'system-prompt' || source.plugin !== PLUGIN_ID) continue
|
|
327
|
+
if (typeof data.turn === 'number') turns.add(data.turn)
|
|
328
|
+
}
|
|
329
|
+
return [...turns].sort((a, b) => a - b)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function stateOf(ctx, sessionId) {
|
|
333
|
+
const events = await readEvents(ctx, sessionId)
|
|
334
|
+
if (!events) {
|
|
335
|
+
// A named session with nothing in it yet is an EMPTY session, not a failed
|
|
336
|
+
// one: no row offers a delete, and the next refresh picks the log up once it
|
|
337
|
+
// exists. Answering 404 here is what the panel printed as
|
|
338
|
+
// "状态加载失败,稍后重试。".
|
|
339
|
+
return {
|
|
340
|
+
hidden: [],
|
|
341
|
+
surface: [],
|
|
342
|
+
replyTurns: [],
|
|
343
|
+
turnSeqs: [],
|
|
344
|
+
edits: [],
|
|
345
|
+
markerTurns: [],
|
|
346
|
+
live: false,
|
|
347
|
+
busy: false,
|
|
348
|
+
lastSeq: -1,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const folded = foldSurface(events)
|
|
352
|
+
// The live surface is what a delete will actually be validated against, so
|
|
353
|
+
// expose exactly that when the session is open.
|
|
354
|
+
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
355
|
+
return {
|
|
356
|
+
hidden: hiddenEntriesOfFold(folded, events),
|
|
357
|
+
// The current surface lets the browser half tell a row that still has
|
|
358
|
+
// context content from one whose content a compaction already removed;
|
|
359
|
+
// `replyTurns` narrows that to turns with an actually deletable reply.
|
|
360
|
+
surface: surfaceNodes,
|
|
361
|
+
replyTurns: deletableReplyTurns(events, surfaceNodes),
|
|
362
|
+
// Rows that may offer the turn-delete action. The list is produced by the
|
|
363
|
+
// SAME planner check the route uses (see turnableSeqs), so a row never
|
|
364
|
+
// advertises a turn delete the host would refuse.
|
|
365
|
+
turnSeqs: turnableSeqs(events, surfaceNodes),
|
|
366
|
+
// In-place rewrites by other producers (dsh-edit-turn): the transcript row
|
|
367
|
+
// stays anchored to the original seq while the context holds the
|
|
368
|
+
// replacement, so the browser half needs the chain to keep the row's
|
|
369
|
+
// action and to address the live node.
|
|
370
|
+
edits: contentEditPairs(folded, events),
|
|
371
|
+
// Bookkeeping turns a deletion opened (they must not show as empty
|
|
372
|
+
// process rows in the transcript).
|
|
373
|
+
markerTurns: markerTurnsOf(events),
|
|
374
|
+
live: Boolean(findLiveSession(ctx, sessionId)),
|
|
375
|
+
busy: isBusy(events),
|
|
376
|
+
lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// The user pressed delete while a turn was still generating: stopping that turn
|
|
381
|
+
// is part of the click. The official entry is the session controller's `cancel`
|
|
382
|
+
// (it resolves the live Agent and calls `agent.cancel({ kind: 'user' },
|
|
383
|
+
// { keepInbox: true })`), so queued prompts survive and only the running turn is
|
|
384
|
+
// aborted. `cancel` needs the session ATTACHED, which `resolveSession` above
|
|
385
|
+
// already ensured. A compaction bracket has no stop semantics: it keeps the old
|
|
386
|
+
// busy refusal.
|
|
387
|
+
async function stopActiveTurn(ctx, sessionId, events) {
|
|
388
|
+
if (openTurn(events) === null) {
|
|
389
|
+
throw new HttpError(409, 'busy', 'the session is compacting; wait for it to finish')
|
|
390
|
+
}
|
|
391
|
+
const controller = ctx.get('sessionController')
|
|
392
|
+
if (!controller || typeof controller.cancel !== 'function') {
|
|
393
|
+
throw new HttpError(409, 'busy', 'the running turn cannot be stopped here')
|
|
394
|
+
}
|
|
395
|
+
let acknowledged = false
|
|
396
|
+
let lastError = null
|
|
397
|
+
// The controller keys agents by one spelling of the id; try both, exactly like
|
|
398
|
+
// every other lookup in this file.
|
|
399
|
+
for (const variant of idVariants(sessionId)) {
|
|
400
|
+
try {
|
|
401
|
+
await controller.cancel({ sessionId: variant })
|
|
402
|
+
acknowledged = true
|
|
403
|
+
break
|
|
404
|
+
} catch (error) {
|
|
405
|
+
lastError = error
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (!acknowledged) {
|
|
409
|
+
throw new HttpError(409, 'busy', `the stop request was refused: ${String((lastError && lastError.message) || lastError)}`)
|
|
410
|
+
}
|
|
411
|
+
const deadline = Date.now() + stopWait.timeoutMs
|
|
412
|
+
for (;;) {
|
|
413
|
+
await new Promise((resolve) => setTimeout(resolve, stopWait.pollMs))
|
|
414
|
+
const current = await readEvents(ctx, sessionId)
|
|
415
|
+
if (current && !isBusy(current)) return
|
|
416
|
+
if (Date.now() >= deadline) {
|
|
417
|
+
throw new HttpError(409, 'stop-timeout', 'the running turn did not stop in time')
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function deleteTarget(ctx, sessionId, body) {
|
|
423
|
+
const mode = typeof body.mode === 'string' ? body.mode : ''
|
|
424
|
+
if (!MODES.has(mode)) throw new HttpError(400, 'invalid', 'mode must be message, step, reply or turn')
|
|
425
|
+
const session = await resolveSession(ctx, sessionId)
|
|
426
|
+
if (!session || typeof session.append !== 'function') {
|
|
427
|
+
throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
|
|
428
|
+
}
|
|
429
|
+
let events = await readEvents(ctx, sessionId)
|
|
430
|
+
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
431
|
+
if (isBusy(events)) {
|
|
432
|
+
// A running turn is stopped first (the dialog says so), then the delete
|
|
433
|
+
// proceeds against the post-stop log.
|
|
434
|
+
await stopActiveTurn(ctx, sessionId, events)
|
|
435
|
+
events = await readEvents(ctx, sessionId)
|
|
436
|
+
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
437
|
+
if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
441
|
+
let plan
|
|
442
|
+
try {
|
|
443
|
+
plan = planRange(events, surfaceNodes, {
|
|
444
|
+
mode,
|
|
445
|
+
seq: typeof body.seq === 'number' ? body.seq : undefined,
|
|
446
|
+
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
447
|
+
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
448
|
+
})
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (error instanceof PlanError) {
|
|
451
|
+
const status = error.code === 'not-deletable' ? 400 : 409
|
|
452
|
+
throw new HttpError(status, error.code, error.message)
|
|
453
|
+
}
|
|
454
|
+
throw error
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The window may also cover nodes that belong to no part of the target (a
|
|
458
|
+
// foreign injected row, say). Deleting them is not refused - it is a decision
|
|
459
|
+
// the user must make explicitly: without `force` the route reports how many
|
|
460
|
+
// there are, and the client turns that into a second, forced confirmation.
|
|
461
|
+
if (plan.foreign.length > 0 && body.force !== true) {
|
|
462
|
+
throw new HttpError(409, 'range-not-clean', `the window would also remove ${plan.foreign.length} unrelated node(s)`, { foreign: plan.foreign.length })
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The live surface is the append authority; a node that vanished between the
|
|
466
|
+
// read and this check means another writer landed first.
|
|
467
|
+
for (const seq of plan.shadowed) {
|
|
468
|
+
if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// The carrier must be a turn-less `user/message`.
|
|
472
|
+
//
|
|
473
|
+
// Opening a synthetic turn+step to host a model-invisible empty
|
|
474
|
+
// `system/message` corrupts the log: the agent loop advances its turn counter
|
|
475
|
+
// only from the turns it opens itself, so its next real turn reuses the
|
|
476
|
+
// number this plugin burned (`turn/start does not open the expected turn` on
|
|
477
|
+
// the next cold read) and every turn-number-keyed client hiding then swallows
|
|
478
|
+
// the reused turn's rows. That failure was reproduced in production; do not
|
|
479
|
+
// reintroduce a synthetic turn here.
|
|
480
|
+
//
|
|
481
|
+
// What the carrier holds depends on the installed pi-ai adapter: when it
|
|
482
|
+
// drops empty user content (see {@link adapterDropsEmptyUserContent}) the
|
|
483
|
+
// carrier is an empty content list and never reaches any model; otherwise the
|
|
484
|
+
// safest accepted content is a single ZERO-WIDTH SPACE (a readable marker
|
|
485
|
+
// like "[deleted]" is quoted back by the model, and empty content is refused
|
|
486
|
+
// by gateways whose adapter predates that skip).
|
|
487
|
+
let replacement
|
|
488
|
+
try {
|
|
489
|
+
replacement = session.append(
|
|
490
|
+
'user/message',
|
|
491
|
+
{
|
|
492
|
+
id: randomUUID(),
|
|
493
|
+
role: 'user',
|
|
494
|
+
content: silentCarrierFor(ctx, events) ? [] : [{ type: 'text', text: '\u200b' }],
|
|
495
|
+
// v4 format: plugin wrappers are retired; the producer kind carries the id.
|
|
496
|
+
source: { kind: `plugin:${PLUGIN_ID}` },
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
|
|
500
|
+
sourceEventSeqs: plan.shadowed,
|
|
501
|
+
},
|
|
502
|
+
)
|
|
503
|
+
} catch (error) {
|
|
504
|
+
throw new HttpError(409, 'stale', `the surface refused the replacement: ${String((error && error.message) || error)}`)
|
|
505
|
+
}
|
|
506
|
+
const flush = await flushSession(ctx, session)
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
replacementSeq: replacement.seq,
|
|
510
|
+
...flush,
|
|
511
|
+
hidden: plan.shadowed.map((seq) => ({ seq, mode: plan.mode })),
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --- http --------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
function isLoopbackAddress(address) {
|
|
518
|
+
if (typeof address !== 'string' || address.length === 0) return false
|
|
519
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isLocalHostHeader(host) {
|
|
523
|
+
if (typeof host !== 'string' || host.length === 0) return false
|
|
524
|
+
const name = host.split(':')[0].replace(/^\[|\]$/g, '').toLowerCase()
|
|
525
|
+
return name === 'localhost' || name === '127.0.0.1' || name === '::1'
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function sendJson(res, status, body) {
|
|
529
|
+
const payload = JSON.stringify(body)
|
|
530
|
+
res.writeHead(status, {
|
|
531
|
+
'content-type': 'application/json; charset=utf-8',
|
|
532
|
+
'content-length': Buffer.byteLength(payload),
|
|
533
|
+
})
|
|
534
|
+
res.end(payload)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function readBody(req) {
|
|
538
|
+
return new Promise((resolve, reject) => {
|
|
539
|
+
let data = ''
|
|
540
|
+
req.on('data', (chunk) => {
|
|
541
|
+
data += chunk
|
|
542
|
+
if (data.length > 1e6) req.destroy()
|
|
543
|
+
})
|
|
544
|
+
req.on('end', () => resolve(data))
|
|
545
|
+
req.on('error', reject)
|
|
546
|
+
req.on('aborted', () => reject(new Error('aborted')))
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Context rewriting is destructive for the model: loopback socket, loopback
|
|
551
|
+
// Host header, and a same-origin check when the browser sends Origin.
|
|
552
|
+
function guard(req, res) {
|
|
553
|
+
if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
|
|
554
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
|
|
555
|
+
return false
|
|
556
|
+
}
|
|
557
|
+
const host = req.headers.host
|
|
558
|
+
if (!isLocalHostHeader(host)) {
|
|
559
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
|
|
560
|
+
return false
|
|
561
|
+
}
|
|
562
|
+
const origin = req.headers.origin
|
|
563
|
+
if (typeof origin === 'string' && origin.length > 0) {
|
|
564
|
+
let originHost = null
|
|
565
|
+
try {
|
|
566
|
+
originHost = new URL(origin).host
|
|
567
|
+
} catch {
|
|
568
|
+
originHost = null
|
|
569
|
+
}
|
|
570
|
+
if (originHost !== host) {
|
|
571
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
|
|
572
|
+
return false
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return true
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function sessionIdFromQuery(url) {
|
|
579
|
+
try {
|
|
580
|
+
const value = new URL(url, 'http://localhost').searchParams.get('sessionId') || ''
|
|
581
|
+
return value.trim()
|
|
582
|
+
} catch {
|
|
583
|
+
return ''
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function requireSessionId(value) {
|
|
588
|
+
if (!value) throw new HttpError(400, 'invalid', 'sessionId required')
|
|
589
|
+
if (!SESSION_ID_RE.test(value)) throw new HttpError(400, 'invalid', 'invalid session id')
|
|
590
|
+
return value
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// --- plugin ------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
export function apply(ctx) {
|
|
596
|
+
const registerRoutes = (webServer, fiber) => {
|
|
597
|
+
fiber.effect(() =>
|
|
598
|
+
webServer.register({
|
|
599
|
+
kind: 'exact',
|
|
600
|
+
path: `${ROUTE_PREFIX}/state`,
|
|
601
|
+
handler: async (req, res) => {
|
|
602
|
+
if (!guard(req, res)) return
|
|
603
|
+
if (req.method !== 'GET') {
|
|
604
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
const sessionId = requireSessionId(sessionIdFromQuery(req.url))
|
|
609
|
+
sendJson(res, 200, { ok: true, ...(await stateOf(ctx, sessionId)) })
|
|
610
|
+
} catch (error) {
|
|
611
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
612
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
613
|
+
const details = error instanceof HttpError && error.details !== undefined ? { details: error.details } : {}
|
|
614
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error), ...details })
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
}),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
fiber.effect(() =>
|
|
621
|
+
webServer.register({
|
|
622
|
+
kind: 'exact',
|
|
623
|
+
path: `${ROUTE_PREFIX}/delete`,
|
|
624
|
+
handler: async (req, res) => {
|
|
625
|
+
if (!guard(req, res)) return
|
|
626
|
+
if (req.method !== 'POST') {
|
|
627
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
let body = {}
|
|
631
|
+
try {
|
|
632
|
+
const raw = await readBody(req)
|
|
633
|
+
if (raw) body = JSON.parse(raw)
|
|
634
|
+
} catch {
|
|
635
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
const sessionId = requireSessionId(typeof body.sessionId === 'string' ? body.sessionId.trim() : '')
|
|
640
|
+
// Every mode - including the whole-turn delete - is one range
|
|
641
|
+
// replace, so one planner and one append path serve them all.
|
|
642
|
+
const result = await deleteTarget(ctx, sessionId, body)
|
|
643
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
644
|
+
} catch (error) {
|
|
645
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
646
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
647
|
+
const details = error instanceof HttpError && error.details !== undefined ? { details: error.details } : {}
|
|
648
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error), ...details })
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
}),
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const webServer = ctx.get('webServer')
|
|
656
|
+
if (webServer) {
|
|
657
|
+
registerRoutes(webServer, ctx)
|
|
658
|
+
} else {
|
|
659
|
+
ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
|
|
660
|
+
}
|
|
661
|
+
}
|