@heixui/dsh-session-suite 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +683 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +76 -4
package/lib/service.js
ADDED
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suite's operations, implemented once for both callers (the plugin UI over
|
|
3
|
+
* HTTP and the agent tools that call the same methods).
|
|
4
|
+
*
|
|
5
|
+
* Rollback is the interesting one. DSH's session log is append-only, so
|
|
6
|
+
* "return to this message" cannot delete events: it forks the exact event
|
|
7
|
+
* prefix into a fresh session, restores the workspace snapshot recorded for
|
|
8
|
+
* that turn, trims the memory ledger to the same sequence, and retires the old
|
|
9
|
+
* session. Every step is recorded in an undo bundle first, so one click can put
|
|
10
|
+
* all three back.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs/promises'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { buildOutline, curateEvents, exportFileName, renderBlockDoc, renderHtmlFromDoc, renderJson, renderMarkdown, renderText, selectEvents } from './share.js'
|
|
17
|
+
import { asString, ensureDir, formatBytes, readJson, shortId, writeJson } from './util.js'
|
|
18
|
+
|
|
19
|
+
const UNDO_TTL_MS = 10 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
export class SessionSuiteService {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} options service options
|
|
24
|
+
* @param {any} options.ctx Cordis context of the plugin
|
|
25
|
+
* @param {string} options.root suite data directory
|
|
26
|
+
* @param {any} options.store checkpoint store
|
|
27
|
+
* @param {any} options.memory memory ledger
|
|
28
|
+
* @param {(message: string, error?: unknown) => void} options.log logger
|
|
29
|
+
* @param {any} [options.config] plugin config
|
|
30
|
+
*/
|
|
31
|
+
constructor({ ctx, root, store, memory, log, config }) {
|
|
32
|
+
this.ctx = ctx
|
|
33
|
+
this.root = root
|
|
34
|
+
this.store = store
|
|
35
|
+
this.memory = memory
|
|
36
|
+
this.log = log
|
|
37
|
+
this.config = config ?? {}
|
|
38
|
+
this.dshHome = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
39
|
+
this.undoDir = path.join(root, 'undo')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} name Host service name */
|
|
43
|
+
#service(name) {
|
|
44
|
+
return this.ctx.get(name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#require(name) {
|
|
48
|
+
const service = this.ctx.get(name)
|
|
49
|
+
if (!service) throw new Error(`the ${name} service is not mounted in this profile`)
|
|
50
|
+
return service
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} sessionId session
|
|
55
|
+
* @returns {Promise<{ meta: any, events: any[], title?: string, cwd?: string }>} inspection
|
|
56
|
+
*/
|
|
57
|
+
async inspect(sessionId) {
|
|
58
|
+
const controller = this.#require('sessionController')
|
|
59
|
+
const inspection = await controller.inspect(sessionId)
|
|
60
|
+
if (!inspection) throw new Error(`session ${sessionId} was not found`)
|
|
61
|
+
const events = Array.isArray(inspection.events) ? inspection.events : []
|
|
62
|
+
return { meta: inspection.meta ?? { id: sessionId }, events, title: titleOf(events), cwd: inspection.meta?.cwd }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @returns {Promise<any[]>} visible sessions with suite annotations
|
|
67
|
+
*/
|
|
68
|
+
async listSessions() {
|
|
69
|
+
const controller = this.#service('sessionController')
|
|
70
|
+
if (!controller?.list) return []
|
|
71
|
+
const value = await controller.list({})
|
|
72
|
+
const items = Array.isArray(value?.items) ? value.items : []
|
|
73
|
+
const out = []
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
// Hide subagent children only. They are not conversations of their own —
|
|
76
|
+
// the sidebar hides them and this panel must not offer them as a session
|
|
77
|
+
// to snapshot.
|
|
78
|
+
//
|
|
79
|
+
// A `parentSessionId` is NOT enough to identify one. Every rewind, message
|
|
80
|
+
// delete, and segment delete forks the conversation and retires the
|
|
81
|
+
// original, so the session a user is actually talking in carries a parent
|
|
82
|
+
// too — it is a continuation of the same conversation, not a subagent.
|
|
83
|
+
// Filtering on the parent alone hid the live session from this panel and
|
|
84
|
+
// left it showing whichever unrelated session happened to have snapshots
|
|
85
|
+
// (「只有查询插件开发记录这个会话的快照」).
|
|
86
|
+
//
|
|
87
|
+
// `origin: 'subagent'` is the field that distinguishes them: SessionSummary
|
|
88
|
+
// declares it for exactly this purpose, while `parentSessionId` only
|
|
89
|
+
// describes lineage. The header also carries `delegationDepth`, but the
|
|
90
|
+
// summary does not project it, so it cannot be used here.
|
|
91
|
+
if (item.origin === 'subagent') continue
|
|
92
|
+
const checkpoints = await this.store.list(item.sessionId).catch(() => [])
|
|
93
|
+
const stats = await this.store.stats(item.sessionId).catch(() => undefined)
|
|
94
|
+
out.push({
|
|
95
|
+
sessionId: item.sessionId,
|
|
96
|
+
cwd: item.cwd,
|
|
97
|
+
running: item.running === true,
|
|
98
|
+
updatedAt: item.updatedAt,
|
|
99
|
+
title: titleFromProjections(item.projections) ?? item.cwd ?? item.sessionId,
|
|
100
|
+
checkpoints: checkpoints.length,
|
|
101
|
+
checkpointBytes: stats?.bytes ?? 0,
|
|
102
|
+
checkpointLabel: formatBytes(stats?.bytes ?? 0),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Turn-level outline for the share and segment dialogs.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} sessionId session
|
|
112
|
+
* @returns {Promise<any>} outline
|
|
113
|
+
*/
|
|
114
|
+
async outline(sessionId) {
|
|
115
|
+
const inspection = await this.inspect(sessionId)
|
|
116
|
+
const turns = buildOutline(inspection.events)
|
|
117
|
+
return {
|
|
118
|
+
sessionId,
|
|
119
|
+
title: inspection.title,
|
|
120
|
+
turns,
|
|
121
|
+
totalTurns: turns.length,
|
|
122
|
+
totalEvents: inspection.events.length,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Everything the settings panel needs for one session.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} sessionId session
|
|
130
|
+
* @returns {Promise<any>} detail payload
|
|
131
|
+
*/
|
|
132
|
+
async detail(sessionId) {
|
|
133
|
+
const checkpoints = await this.store.list(sessionId)
|
|
134
|
+
const stats = await this.store.stats(sessionId)
|
|
135
|
+
const memory = await this.memory.list(sessionId)
|
|
136
|
+
let inspection
|
|
137
|
+
try {
|
|
138
|
+
inspection = await this.inspect(sessionId)
|
|
139
|
+
} catch {
|
|
140
|
+
inspection = undefined
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
sessionId,
|
|
144
|
+
title: inspection?.title ?? undefined,
|
|
145
|
+
cwd: inspection?.cwd,
|
|
146
|
+
checkpoints: checkpoints.map(decorateCheckpoint),
|
|
147
|
+
memory,
|
|
148
|
+
stats: {
|
|
149
|
+
bytes: stats.bytes,
|
|
150
|
+
label: formatBytes(stats.bytes),
|
|
151
|
+
checkpoints: stats.checkpoints,
|
|
152
|
+
budget: this.config.keep ?? 10,
|
|
153
|
+
},
|
|
154
|
+
undos: await this.listUndos(sessionId),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Take a checkpoint on demand.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} sessionId session
|
|
162
|
+
* @param {{ label?: string, kind?: 'auto' | 'manual' }} [options] options
|
|
163
|
+
* @returns {Promise<any>} checkpoint summary
|
|
164
|
+
*/
|
|
165
|
+
async capture(sessionId, options = {}) {
|
|
166
|
+
const inspection = await this.inspect(sessionId)
|
|
167
|
+
const cwd = inspection.cwd
|
|
168
|
+
if (!cwd) throw new Error('this session has no workspace directory to snapshot')
|
|
169
|
+
const last = (await this.store.list(sessionId))[0]
|
|
170
|
+
const result = await this.store.capture({
|
|
171
|
+
sessionId,
|
|
172
|
+
cwd,
|
|
173
|
+
seq: lastSeqOf(inspection.events),
|
|
174
|
+
// A manual capture describes the last turn that actually finished, so it
|
|
175
|
+
// keeps the same meaning as an automatic one ("the workspace after turn N").
|
|
176
|
+
turn: lastCompletedTurn(inspection.events),
|
|
177
|
+
phase: 'end',
|
|
178
|
+
kind: options.kind ?? 'manual',
|
|
179
|
+
label: options.label,
|
|
180
|
+
limits: limitsFrom(this.config),
|
|
181
|
+
})
|
|
182
|
+
return { ...decorateCheckpoint(result.checkpoint), skipped: result.skipped.slice(0, 20), truncated: result.truncated }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Restore files only — the snapshot half of a rollback, usable on its own.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} sessionId session
|
|
189
|
+
* @param {string} checkpointId checkpoint
|
|
190
|
+
* @returns {Promise<any>} restore outcome
|
|
191
|
+
*/
|
|
192
|
+
async restoreCheckpoint(sessionId, checkpointId) {
|
|
193
|
+
const outcome = await this.store.restore({ sessionId, checkpointId })
|
|
194
|
+
return { ...outcome, message: `restored ${outcome.written} files, removed ${outcome.deleted}` }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Full rollback: conversation prefix + workspace files + memory ledger.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} input request
|
|
201
|
+
* @param {string} input.sessionId source session
|
|
202
|
+
* @param {string} [input.messageId] durable message id of the target step
|
|
203
|
+
* @param {number} [input.seq] durable sequence of the target event
|
|
204
|
+
* @param {number} [input.turn] turn of the target message
|
|
205
|
+
* @returns {Promise<any>} rollback outcome including the new session id
|
|
206
|
+
*/
|
|
207
|
+
async rollback({ sessionId, messageId, seq, turn }) {
|
|
208
|
+
const inspection = await this.inspect(sessionId)
|
|
209
|
+
const cwd = inspection.cwd
|
|
210
|
+
const controller = this.#require('sessionController')
|
|
211
|
+
let targetSeq = Number.isFinite(seq) ? Number(seq) : undefined
|
|
212
|
+
let targetTurn = Number.isFinite(turn) ? Number(turn) : undefined
|
|
213
|
+
if (targetSeq === undefined && typeof messageId === 'string') {
|
|
214
|
+
const found = findMessage(inspection.events, messageId)
|
|
215
|
+
if (!found) throw new Error('that message is not part of this session')
|
|
216
|
+
targetSeq = found.seq
|
|
217
|
+
targetTurn = found.turn
|
|
218
|
+
}
|
|
219
|
+
if (targetSeq === undefined) targetSeq = lastSeqOf(inspection.events)
|
|
220
|
+
if (targetTurn === undefined) targetTurn = turnOfSeq(inspection.events, targetSeq)
|
|
221
|
+
|
|
222
|
+
// A snapshot records the state *after* its turn. Rewinding to a reply keeps
|
|
223
|
+
// that turn's work; rewinding to the prompt means "before this turn", i.e.
|
|
224
|
+
// the state after the previous one.
|
|
225
|
+
const targetEvent = inspection.events.find(event => event.seq === targetSeq)
|
|
226
|
+
const targetRole = targetEvent?.type === 'user/message' ? 'user' : 'assistant'
|
|
227
|
+
const restoreTurn = targetRole === 'user' ? (targetTurn ?? 1) - 1 : targetTurn
|
|
228
|
+
|
|
229
|
+
// 1. Protect the current state before touching anything.
|
|
230
|
+
const undoId = `undo_${shortId(`${sessionId}:${targetSeq}`)}`
|
|
231
|
+
const undo = {
|
|
232
|
+
id: undoId,
|
|
233
|
+
sessionId,
|
|
234
|
+
seq: targetSeq,
|
|
235
|
+
turn: targetTurn ?? null,
|
|
236
|
+
createdAt: Date.now(),
|
|
237
|
+
expiresAt: Date.now() + UNDO_TTL_MS,
|
|
238
|
+
memory: await this.memory.snapshot(sessionId),
|
|
239
|
+
checkpointId: null,
|
|
240
|
+
cwd,
|
|
241
|
+
status: 'pending',
|
|
242
|
+
}
|
|
243
|
+
if (cwd) {
|
|
244
|
+
try {
|
|
245
|
+
const captured = await this.store.capture({
|
|
246
|
+
sessionId,
|
|
247
|
+
cwd,
|
|
248
|
+
seq: targetSeq,
|
|
249
|
+
turn: targetTurn,
|
|
250
|
+
kind: 'pre-rollback',
|
|
251
|
+
label: 'before rollback',
|
|
252
|
+
ttlMs: UNDO_TTL_MS,
|
|
253
|
+
limits: limitsFrom(this.config),
|
|
254
|
+
})
|
|
255
|
+
undo.checkpointId = captured.checkpoint.id
|
|
256
|
+
} catch (error) {
|
|
257
|
+
this.log('pre-rollback snapshot failed; aborting rollback', error)
|
|
258
|
+
throw new Error(`could not snapshot the workspace before rollback: ${errorMessage(error)}`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
await this.#writeUndo(undo)
|
|
262
|
+
|
|
263
|
+
// 2. Fork the exact event prefix — the conversation half of the rollback.
|
|
264
|
+
let childId
|
|
265
|
+
try {
|
|
266
|
+
const forked = await controller.fork({ sessionId, atSeq: targetSeq })
|
|
267
|
+
childId = forked?.sessionId
|
|
268
|
+
if (!childId) throw new Error('fork returned no session id')
|
|
269
|
+
} catch (error) {
|
|
270
|
+
await this.#discardUndo(undoId)
|
|
271
|
+
throw new Error(`could not fork the session at seq ${targetSeq}: ${errorMessage(error)}`)
|
|
272
|
+
}
|
|
273
|
+
undo.childId = childId
|
|
274
|
+
undo.status = 'forked'
|
|
275
|
+
await this.#writeUndo(undo)
|
|
276
|
+
|
|
277
|
+
// 3. Workspace files back to the state recorded for that turn.
|
|
278
|
+
let restore = undefined
|
|
279
|
+
const checkpoint = await this.store.checkpointAt(sessionId, restoreTurn, targetSeq)
|
|
280
|
+
if (checkpoint) {
|
|
281
|
+
try {
|
|
282
|
+
restore = await this.store.restore({ sessionId, checkpointId: checkpoint.id })
|
|
283
|
+
} catch (error) {
|
|
284
|
+
this.log(`restore of ${checkpoint.id} failed after fork; undo remains available`, error)
|
|
285
|
+
restore = { error: errorMessage(error) }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 4. Memory ledger back to the same sequence, and carried into the child so
|
|
290
|
+
// the continued conversation keeps the facts that survive the rewind.
|
|
291
|
+
const trimmed = await this.memory.trimTo(sessionId, targetSeq).catch(() => ({ removed: 0, kept: 0 }))
|
|
292
|
+
const copied = await this.memory.clone(sessionId, childId, targetSeq).catch(() => ({ copied: 0 }))
|
|
293
|
+
|
|
294
|
+
// 4b. Snapshots up to the rewind point move to the child. The parent is
|
|
295
|
+
// retired, so its snapshots would otherwise become unreachable from the
|
|
296
|
+
// conversation the user actually continues.
|
|
297
|
+
const inherited = await this.store
|
|
298
|
+
.inherit({ from: sessionId, to: childId, throughSeq: targetSeq, keep: this.config.keep })
|
|
299
|
+
.catch(error => {
|
|
300
|
+
this.log(`could not inherit checkpoints into ${childId}`, error)
|
|
301
|
+
return { inherited: 0, skipped: 0 }
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// 5. Retire the old session from the sidebar (files stay; the undo bundle owns the revert).
|
|
305
|
+
const archived = await this.#archive(sessionId, true)
|
|
306
|
+
|
|
307
|
+
undo.status = 'done'
|
|
308
|
+
undo.restore = restore ?? null
|
|
309
|
+
undo.archived = archived
|
|
310
|
+
await this.#writeUndo(undo)
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
undoId,
|
|
314
|
+
childId,
|
|
315
|
+
sessionId,
|
|
316
|
+
seq: targetSeq,
|
|
317
|
+
turn: targetTurn ?? null,
|
|
318
|
+
checkpoint: checkpoint ? decorateCheckpoint(checkpoint) : null,
|
|
319
|
+
restore: restore ?? null,
|
|
320
|
+
memory: { ...trimmed, copied: copied.copied },
|
|
321
|
+
checkpoints: inherited,
|
|
322
|
+
archived,
|
|
323
|
+
expiresInMs: UNDO_TTL_MS,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Cut the conversation at a sequence without touching the workspace.
|
|
329
|
+
*
|
|
330
|
+
* Deleting conversation content is the one operation that must not restore
|
|
331
|
+
* files: the workspace keeps whatever the session produced, and only the log
|
|
332
|
+
* and the memory ledger are trimmed. The append-only log cannot remove events
|
|
333
|
+
* in the middle, so this forks the prefix and retires the original — the same
|
|
334
|
+
* primitive as a rewind, minus the snapshot restore.
|
|
335
|
+
*
|
|
336
|
+
* @param {object} input request
|
|
337
|
+
* @param {string} input.sessionId session
|
|
338
|
+
* @param {number} [input.keepThroughSeq] last sequence to keep; omitted keeps nothing (empty session)
|
|
339
|
+
* @returns {Promise<any>} outcome
|
|
340
|
+
*/
|
|
341
|
+
async truncateConversation({ sessionId, keepThroughSeq }) {
|
|
342
|
+
const inspection = await this.inspect(sessionId)
|
|
343
|
+
const controller = this.#require('sessionController')
|
|
344
|
+
let childId
|
|
345
|
+
if (Number.isFinite(keepThroughSeq) && keepThroughSeq >= 0) {
|
|
346
|
+
const forked = await controller.fork({ sessionId, atSeq: keepThroughSeq })
|
|
347
|
+
childId = forked?.sessionId
|
|
348
|
+
} else {
|
|
349
|
+
const created = await controller.create({ cwd: inspection.cwd })
|
|
350
|
+
childId = created?.sessionId
|
|
351
|
+
}
|
|
352
|
+
if (!childId) throw new Error('could not create the trimmed conversation')
|
|
353
|
+
|
|
354
|
+
const bound = Number.isFinite(keepThroughSeq) ? keepThroughSeq : -1
|
|
355
|
+
const trimmed = await this.memory.trimTo(sessionId, bound).catch(() => ({ removed: 0, kept: 0 }))
|
|
356
|
+
const copied = await this.memory.clone(sessionId, childId, bound).catch(() => ({ copied: 0 }))
|
|
357
|
+
// The child continues the parent's conversation, so it must also continue
|
|
358
|
+
// its snapshot history: without this the panel is empty and rewinding in the
|
|
359
|
+
// continuation has no earlier state to restore.
|
|
360
|
+
const inherited = await this.store
|
|
361
|
+
.inherit({ from: sessionId, to: childId, throughSeq: keepThroughSeq, keep: this.config.keep })
|
|
362
|
+
.catch(error => {
|
|
363
|
+
this.log(`could not inherit checkpoints into ${childId}`, error)
|
|
364
|
+
return { inherited: 0, skipped: 0 }
|
|
365
|
+
})
|
|
366
|
+
const archived = await this.#archive(sessionId, true)
|
|
367
|
+
return { childId, sessionId, keepThroughSeq: Number.isFinite(keepThroughSeq) ? keepThroughSeq : null, memory: { ...trimmed, copied: copied.copied }, checkpoints: inherited, archived, filesUntouched: true }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Delete one message: the message and everything after it leave the
|
|
372
|
+
* conversation, while the workspace files stay exactly as they are.
|
|
373
|
+
*
|
|
374
|
+
* @param {object} input request
|
|
375
|
+
* @param {string} input.sessionId session
|
|
376
|
+
* @param {string} input.messageId durable message id
|
|
377
|
+
* @returns {Promise<any>} outcome
|
|
378
|
+
*/
|
|
379
|
+
async deleteMessage({ sessionId, messageId }) {
|
|
380
|
+
const inspection = await this.inspect(sessionId)
|
|
381
|
+
const target = findMessage(inspection.events, messageId)
|
|
382
|
+
if (!target) throw new Error('that message is not part of this session')
|
|
383
|
+
// Deleting a message removes its whole turn: the prompt, the reply, and the
|
|
384
|
+
// narration between them. Cutting at the message before the prompt does that
|
|
385
|
+
// in one move, and files are never touched (only a rewind restores files).
|
|
386
|
+
const promptSeq = target.role === 'assistant' ? previousUserSeq(inspection.events, target.seq) : target.seq
|
|
387
|
+
const keep = promptSeq === undefined ? undefined : previousMessageSeq(inspection.events, promptSeq)
|
|
388
|
+
const result = await this.truncateConversation({ sessionId, keepThroughSeq: keep })
|
|
389
|
+
return { deletedSession: false, fromSeq: target.seq, ...result }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Delete a selected run of turns. Files are never touched; memory entries
|
|
394
|
+
* observed inside the removed range are dropped with it.
|
|
395
|
+
*
|
|
396
|
+
* @param {object} input request
|
|
397
|
+
* @param {string} input.sessionId session
|
|
398
|
+
* @param {number} input.startSeq first sequence of the removed range
|
|
399
|
+
* @returns {Promise<any>} outcome
|
|
400
|
+
*/
|
|
401
|
+
async deleteSegment({ sessionId, startSeq }) {
|
|
402
|
+
const inspection = await this.inspect(sessionId)
|
|
403
|
+
const seq = Number(startSeq)
|
|
404
|
+
if (!Number.isFinite(seq)) throw new Error('startSeq is required')
|
|
405
|
+
const previous = previousMessageSeq(inspection.events, seq)
|
|
406
|
+
const result = await this.truncateConversation({ sessionId, keepThroughSeq: previous })
|
|
407
|
+
return { deletedSession: false, fromSeq: seq, ...result }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Resend a turn's prompt in a fork of the conversation.
|
|
412
|
+
*
|
|
413
|
+
* This is the "retry" a failed or unsatisfactory turn needs: the conversation
|
|
414
|
+
* is forked just before that turn's prompt and the same prompt is sent again
|
|
415
|
+
* in the fork, so the model answers afresh instead of being asked to continue
|
|
416
|
+
* on top of an answer the user rejected. The original session is kept as a
|
|
417
|
+
* branch, the durable log is never rewritten, and — unlike a rewind — **files
|
|
418
|
+
* are not touched**: retrying a reply is a conversation operation, and
|
|
419
|
+
* restoring the workspace would silently discard work done since.
|
|
420
|
+
*
|
|
421
|
+
* `mode: 'stage'` forks and stops there, returning the prompt text so the
|
|
422
|
+
* client can put it in the composer for the user to edit before sending. That
|
|
423
|
+
* is what "edit and resend" needs, and it avoids sending a prompt the user is
|
|
424
|
+
* about to change.
|
|
425
|
+
*
|
|
426
|
+
* @param {object} input request
|
|
427
|
+
* @param {string} input.sessionId session
|
|
428
|
+
* @param {number} input.turn turn whose prompt to resend
|
|
429
|
+
* @param {'retry' | 'stage'} [input.mode] retry sends it; stage only returns it
|
|
430
|
+
* @param {string} [input.text] replacement prompt text (defaults to the original)
|
|
431
|
+
* @returns {Promise<any>} the fork, the prompt that was or will be sent, and whether it was sent
|
|
432
|
+
*/
|
|
433
|
+
async resendTurn({ sessionId, turn, messageId, mode = 'retry', text }) {
|
|
434
|
+
const inspection = await this.inspect(sessionId)
|
|
435
|
+
|
|
436
|
+
// The chat slots hand the client a message id or a turn location, and the
|
|
437
|
+
// location's shape is not part of a stable contract, so resolve the turn
|
|
438
|
+
// here instead of trusting the caller to have found a number.
|
|
439
|
+
let target = Number(turn)
|
|
440
|
+
if (!Number.isFinite(target) && typeof messageId === 'string') {
|
|
441
|
+
target = findMessage(inspection.events, messageId)?.turn
|
|
442
|
+
}
|
|
443
|
+
if (!Number.isFinite(target)) {
|
|
444
|
+
// No turn named at all: resend the latest turn that a user prompt started.
|
|
445
|
+
// That is what "edit my last question" means, and it keeps the row usable
|
|
446
|
+
// even when the location shape is unknown.
|
|
447
|
+
target = lastUserTurn(inspection.events)
|
|
448
|
+
}
|
|
449
|
+
if (!Number.isFinite(target)) throw new Error('there is no prompt in this session to resend')
|
|
450
|
+
|
|
451
|
+
const prompt = promptOfTurn(inspection.events, target)
|
|
452
|
+
if (!prompt) throw new Error(`turn ${target} was not started by a prompt you sent`)
|
|
453
|
+
const payload = typeof text === 'string' && text.trim() ? text.trim() : prompt.text
|
|
454
|
+
|
|
455
|
+
// Resend happens in the SAME session: no fork, no new workspace, no new
|
|
456
|
+
// sidebar row. The durable log is append-only, so the previous reply cannot
|
|
457
|
+
// be erased; instead we drop the memories that turn recorded and append the
|
|
458
|
+
// edited question, which starts a fresh turn whose answer stands on its own.
|
|
459
|
+
// Snapshots are deliberately untouched — the user asked for this to be a
|
|
460
|
+
// conversation operation, and restoring files would discard work done since.
|
|
461
|
+
const trimmed = await this.memory.trimTo(sessionId, prompt.seq - 1).catch(() => ({ removed: 0, kept: 0 }))
|
|
462
|
+
if (mode === 'stage') {
|
|
463
|
+
return { sessionId, turn: target, prompt: payload, originalPrompt: prompt.text, sent: false, memory: trimmed, filesUntouched: true }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
await this.#promptInSession(sessionId, payload)
|
|
467
|
+
return {
|
|
468
|
+
sessionId,
|
|
469
|
+
turn: target,
|
|
470
|
+
prompt: payload,
|
|
471
|
+
originalPrompt: prompt.text,
|
|
472
|
+
sent: true,
|
|
473
|
+
memory: trimmed,
|
|
474
|
+
filesUntouched: true,
|
|
475
|
+
// No childId: there is no new session to switch to.
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read the prompt that would be resent, without forking or writing anything.
|
|
481
|
+
*
|
|
482
|
+
* The edit dialog needs the original text to pre-fill its box. Using
|
|
483
|
+
* `mode: 'stage'` for that was wrong: stage still forks, so merely opening the
|
|
484
|
+
* dialog created a branch (and could fail for reasons unrelated to reading a
|
|
485
|
+
* sentence). A preview is a pure read.
|
|
486
|
+
*
|
|
487
|
+
* @param {object} input request
|
|
488
|
+
* @param {string} input.sessionId session
|
|
489
|
+
* @param {number} [input.turn] turn
|
|
490
|
+
* @param {string} [input.messageId] message id
|
|
491
|
+
* @returns {Promise<{ turn: number, prompt: string }>} the prompt to edit
|
|
492
|
+
*/
|
|
493
|
+
async previewResend({ sessionId, turn, messageId }) {
|
|
494
|
+
const inspection = await this.inspect(sessionId)
|
|
495
|
+
let target = Number(turn)
|
|
496
|
+
if (!Number.isFinite(target) && typeof messageId === 'string') {
|
|
497
|
+
target = findMessage(inspection.events, messageId)?.turn
|
|
498
|
+
}
|
|
499
|
+
if (!Number.isFinite(target)) target = lastUserTurn(inspection.events)
|
|
500
|
+
if (!Number.isFinite(target)) throw new Error('there is no prompt in this session to resend')
|
|
501
|
+
const prompt = promptOfTurn(inspection.events, target)
|
|
502
|
+
if (!prompt) throw new Error(`turn ${target} was not started by a prompt you sent`)
|
|
503
|
+
return { turn: target, prompt: prompt.text }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Admit one prompt into a session.
|
|
508
|
+
*
|
|
509
|
+
* Kept private and narrow: the only caller is a resend, which has just
|
|
510
|
+
* created the session and therefore knows it is idle.
|
|
511
|
+
*
|
|
512
|
+
* @param {string} sessionId session to prompt
|
|
513
|
+
* @param {string} text prompt text
|
|
514
|
+
*/
|
|
515
|
+
async #promptInSession(sessionId, text) {
|
|
516
|
+
const controller = this.#require('sessionController')
|
|
517
|
+
if (typeof controller.prompt !== 'function') {
|
|
518
|
+
throw new Error('this Host build cannot accept a prompt programmatically')
|
|
519
|
+
}
|
|
520
|
+
const requestId = `resend-${shortId(`${sessionId}:${Date.now()}`)}`
|
|
521
|
+
const signal = typeof AbortController === 'function' ? new AbortController().signal : undefined
|
|
522
|
+
const request = {
|
|
523
|
+
requestId,
|
|
524
|
+
sessionId,
|
|
525
|
+
mode: 'queue',
|
|
526
|
+
content: [{ type: 'text', text }],
|
|
527
|
+
}
|
|
528
|
+
// The Remote signature is `prompt(request, signal)`. The Host reads
|
|
529
|
+
// `signal.throwIfAborted()` while admitting the prompt, so omitting the
|
|
530
|
+
// second argument makes it dereference `undefined` — reported as
|
|
531
|
+
// "Cannot read properties of undefined (reading 'throwIfAborted')".
|
|
532
|
+
const result = signal === undefined
|
|
533
|
+
? await controller.prompt(request)
|
|
534
|
+
: await controller.prompt(request, signal)
|
|
535
|
+
if (result && result.accepted === false) throw new Error('the Host rejected the resent prompt')
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Undo a rollback: bring back the files, the memory entries, and the session.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} undoId undo bundle id
|
|
542
|
+
* @returns {Promise<any>} undo outcome
|
|
543
|
+
*/
|
|
544
|
+
async undo(undoId) {
|
|
545
|
+
const undo = await readJson(path.join(this.undoDir, `${undoId}.json`))
|
|
546
|
+
if (!undo) throw new Error('this undo bundle no longer exists')
|
|
547
|
+
if (undo.undoneAt) throw new Error('this rollback was already undone')
|
|
548
|
+
let restore = undefined
|
|
549
|
+
if (undo.checkpointId) {
|
|
550
|
+
restore = await this.store.restore({ sessionId: undo.sessionId, checkpointId: undo.checkpointId })
|
|
551
|
+
}
|
|
552
|
+
if (undo.memory) await this.memory.restore(undo.sessionId, undo.memory)
|
|
553
|
+
await this.#unarchive(undo.sessionId)
|
|
554
|
+
if (undo.childId) await this.#archive(undo.childId, true)
|
|
555
|
+
undo.undoneAt = Date.now()
|
|
556
|
+
await this.#writeUndo(undo)
|
|
557
|
+
return { sessionId: undo.sessionId, childId: undo.childId, restore: restore ?? null }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @param {string} sessionId session
|
|
562
|
+
* @returns {Promise<any[]>} live undo bundles
|
|
563
|
+
*/
|
|
564
|
+
async listUndos(sessionId) {
|
|
565
|
+
const dir = this.undoDir
|
|
566
|
+
let names = []
|
|
567
|
+
try {
|
|
568
|
+
names = (await fs.readdir(dir)).filter(name => name.endsWith('.json'))
|
|
569
|
+
} catch {
|
|
570
|
+
return []
|
|
571
|
+
}
|
|
572
|
+
const out = []
|
|
573
|
+
for (const name of names) {
|
|
574
|
+
const undo = await readJson(path.join(dir, name)).catch(() => undefined)
|
|
575
|
+
if (!undo || undo.undoneAt) continue
|
|
576
|
+
if (undo.expiresAt && undo.expiresAt <= Date.now()) continue
|
|
577
|
+
if (sessionId && undo.sessionId === sessionId) out.push(undo)
|
|
578
|
+
else if (!sessionId) out.push(undo)
|
|
579
|
+
}
|
|
580
|
+
return out.sort((a, b) => b.createdAt - a.createdAt)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* What a delete would remove, without removing anything.
|
|
585
|
+
*
|
|
586
|
+
* A running session is refused rather than killed, so a caller can warn about
|
|
587
|
+
* that — and about the subagents a cascade would take with it — before it
|
|
588
|
+
* commits to the irreversible half.
|
|
589
|
+
*
|
|
590
|
+
* @param {string} sessionId session
|
|
591
|
+
* @returns {Promise<{ sessionId: string, title?: string, running: boolean, subagents: { sessionId: string, title?: string, running: boolean }[], totalSessions: number, willRefuse: boolean, refuseReason: string | null }>} preview
|
|
592
|
+
*/
|
|
593
|
+
async previewDelete(sessionId) {
|
|
594
|
+
const controller = this.#require('sessionController')
|
|
595
|
+
const list = await controller.list({}).catch(() => ({ items: [] }))
|
|
596
|
+
const items = Array.isArray(list?.items) ? list.items : []
|
|
597
|
+
const byId = new Map(items.filter(item => item?.sessionId).map(item => [item.sessionId, item]))
|
|
598
|
+
const summary = byId.get(sessionId)
|
|
599
|
+
const subagents = subagentDescendants(items, sessionId).map(id => {
|
|
600
|
+
const item = byId.get(id) ?? { sessionId: id }
|
|
601
|
+
return { sessionId: id, title: titleFromProjections(item.projections) ?? item.cwd ?? id, running: item.running === true }
|
|
602
|
+
})
|
|
603
|
+
// `willRefuse` answers one question only: would deleteSession refuse this
|
|
604
|
+
// because something is still running? That refusal is the whole safety
|
|
605
|
+
// contract, and the reason says whether it is the session itself or one of
|
|
606
|
+
// its subagents that has to stop first.
|
|
607
|
+
let refuseReason = null
|
|
608
|
+
if (summary?.running === true) refuseReason = 'session-running'
|
|
609
|
+
else if (subagents.some(child => child.running)) refuseReason = 'subagent-running'
|
|
610
|
+
return {
|
|
611
|
+
sessionId,
|
|
612
|
+
title: summary ? titleFromProjections(summary.projections) ?? summary.cwd ?? sessionId : undefined,
|
|
613
|
+
running: summary?.running === true,
|
|
614
|
+
subagents,
|
|
615
|
+
totalSessions: 1 + subagents.length,
|
|
616
|
+
willRefuse: refuseReason !== null,
|
|
617
|
+
refuseReason,
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Permanently delete a session: sidebar entry, log, projection cache,
|
|
623
|
+
* checkpoints, and memory.
|
|
624
|
+
*
|
|
625
|
+
* By default this also cascades to the session's subagent descendants. A
|
|
626
|
+
* subagent is not a conversation anyone can open, so leaving one behind only
|
|
627
|
+
* strands its snapshots and memory under an id nothing links to any more.
|
|
628
|
+
*
|
|
629
|
+
* Two rules keep the cascade safe:
|
|
630
|
+
*
|
|
631
|
+
* 1. Validate all, then touch: every id is checked before the first byte is
|
|
632
|
+
* removed, so a single running session — the session itself or any
|
|
633
|
+
* descendant — refuses the whole operation with `code: 'session-running'`
|
|
634
|
+
* and nothing at all is deleted. A running session is never killed to make
|
|
635
|
+
* deletion possible.
|
|
636
|
+
* 2. Descendants first, the session last, so a child never outlives its
|
|
637
|
+
* parent. A descendant that fails to delete is reported in `refused`
|
|
638
|
+
* instead of silently truncating the tree.
|
|
639
|
+
*
|
|
640
|
+
* Identity is decided by `origin === 'subagent'` alone. A `parentSessionId`
|
|
641
|
+
* proves nothing: every rewind and delete forks the conversation, so the
|
|
642
|
+
* session a user is actually talking in carries a parent too and must never
|
|
643
|
+
* be swept into a cascade (docs/pitfalls.md #48).
|
|
644
|
+
*
|
|
645
|
+
* @param {string} sessionId session
|
|
646
|
+
* @param {{ cascade?: boolean }} [options] options; `cascade: false` deletes the session alone
|
|
647
|
+
* @returns {Promise<any>} what was removed, plus `removedSubagents` and `refused`
|
|
648
|
+
*/
|
|
649
|
+
async deleteSession(sessionId, options = {}) {
|
|
650
|
+
const controller = this.#require('sessionController')
|
|
651
|
+
const cascade = options?.cascade !== false
|
|
652
|
+
const list = await controller.list({}).catch(() => ({ items: [] }))
|
|
653
|
+
const items = Array.isArray(list?.items) ? list.items : []
|
|
654
|
+
const byId = new Map(items.filter(item => item?.sessionId).map(item => [item.sessionId, item]))
|
|
655
|
+
|
|
656
|
+
// Deepest descendant first, the session itself last, so this doubles as the
|
|
657
|
+
// delete order.
|
|
658
|
+
const descendants = cascade ? subagentDescendants(items, sessionId) : []
|
|
659
|
+
|
|
660
|
+
// 1. Validate every id before touching anything.
|
|
661
|
+
for (const id of [sessionId, ...descendants]) {
|
|
662
|
+
if (byId.get(id)?.running === true) throw sessionRunningError(id)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// 2. Delete deepest-first. Partial failure is reported, never hidden.
|
|
666
|
+
const removedSubagents = []
|
|
667
|
+
const refused = []
|
|
668
|
+
for (const id of descendants) {
|
|
669
|
+
try {
|
|
670
|
+
const removed = await this.#purgeSession(id)
|
|
671
|
+
if (removed.sessionFiles.length === 0) {
|
|
672
|
+
refused.push({ sessionId: id, reason: 'no stored session files were found to delete' })
|
|
673
|
+
} else {
|
|
674
|
+
removedSubagents.push({ sessionId: id, ...removed })
|
|
675
|
+
}
|
|
676
|
+
} catch (error) {
|
|
677
|
+
this.log(`could not delete subagent ${id}`, error)
|
|
678
|
+
refused.push({ sessionId: id, reason: errorMessage(error) })
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const removed = await this.#purgeSession(sessionId)
|
|
683
|
+
if (removed.sessionFiles.length === 0) {
|
|
684
|
+
throw new Error('no stored session files were found to delete (the Session may already be gone)')
|
|
685
|
+
}
|
|
686
|
+
// Tell the live layer the session is gone: without this the sidebar keeps the
|
|
687
|
+
// row until the app restarts, even though the files no longer exist.
|
|
688
|
+
try {
|
|
689
|
+
this.ctx.emit?.('api-session/removed', sessionId)
|
|
690
|
+
} catch (error) {
|
|
691
|
+
this.log('could not announce the deleted session', error)
|
|
692
|
+
}
|
|
693
|
+
const stillListed = await controller
|
|
694
|
+
.list({})
|
|
695
|
+
.then(value => (value?.items ?? []).some(item => item.sessionId === sessionId))
|
|
696
|
+
.catch(() => false)
|
|
697
|
+
return { sessionId, ...removed, removedSubagents, refused, stillListed }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Permanently delete several sessions in one request.
|
|
702
|
+
*
|
|
703
|
+
* The value here is not speed — each session is still removed by the same
|
|
704
|
+
* path as a single delete. It is the *reporting*: with a batch, one running
|
|
705
|
+
* session must not abort the whole cleanup, and the caller has to learn which
|
|
706
|
+
* ids went and which did not. So every id is validated up front, then each is
|
|
707
|
+
* deleted independently, and the result names both sets explicitly.
|
|
708
|
+
*
|
|
709
|
+
* A running session is skipped and reported, never killed (docs/pitfalls.md
|
|
710
|
+
* on why: killing a live turn to satisfy a delete is worse than refusing).
|
|
711
|
+
*
|
|
712
|
+
* @param {readonly string[]} sessionIds sessions to delete
|
|
713
|
+
* @param {{ cascade?: boolean }} [options] passed through to deleteSession
|
|
714
|
+
* @returns {Promise<{ removed: string[], failed: { sessionId: string, reason: string }[], requested: number }>}
|
|
715
|
+
*/
|
|
716
|
+
async deleteSessions(sessionIds, options = {}) {
|
|
717
|
+
const requested = Array.isArray(sessionIds) ? [...new Set(sessionIds.filter(id => typeof id === 'string' && id))] : []
|
|
718
|
+
if (requested.length === 0) return { removed: [], failed: [], requested: 0 }
|
|
719
|
+
|
|
720
|
+
// Cap the batch. An unbounded list is usually a caller bug, and a partial
|
|
721
|
+
// apply would be far more confusing than a clear refusal.
|
|
722
|
+
const MAX_BATCH = 200
|
|
723
|
+
if (requested.length > MAX_BATCH) {
|
|
724
|
+
throw new Error(`cannot delete more than ${MAX_BATCH} sessions at once (asked for ${requested.length})`)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const removed = []
|
|
728
|
+
const failed = []
|
|
729
|
+
for (const sessionId of requested) {
|
|
730
|
+
try {
|
|
731
|
+
await this.deleteSession(sessionId, options)
|
|
732
|
+
removed.push(sessionId)
|
|
733
|
+
} catch (error) {
|
|
734
|
+
// One failure never stops the batch: the point of a batch is to clean up
|
|
735
|
+
// what can be cleaned up.
|
|
736
|
+
this.log(`could not delete ${sessionId}`, error)
|
|
737
|
+
failed.push({ sessionId, reason: errorMessage(error) })
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return { removed, failed, requested: requested.length }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Remove every trace of one session from disk and from the suite's stores.
|
|
745
|
+
*
|
|
746
|
+
* Callers validate first: this only reports what it removed, it never decides
|
|
747
|
+
* whether it was allowed to run.
|
|
748
|
+
*
|
|
749
|
+
* @param {string} sessionId session
|
|
750
|
+
* @returns {Promise<{ sessionFiles: string[], projectionCache: string[], checkpoints: boolean, memory: boolean }>} what was removed
|
|
751
|
+
*/
|
|
752
|
+
async #purgeSession(sessionId) {
|
|
753
|
+
// Retire it first so the sidebar stops offering it while files disappear.
|
|
754
|
+
await this.#archive(sessionId, true)
|
|
755
|
+
|
|
756
|
+
const removed = { sessionFiles: [], projectionCache: [], checkpoints: false, memory: false }
|
|
757
|
+
for (const dir of await this.#sessionDirs(sessionId)) {
|
|
758
|
+
try {
|
|
759
|
+
await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
|
760
|
+
removed.sessionFiles.push(dir)
|
|
761
|
+
} catch (error) {
|
|
762
|
+
this.log(`could not remove ${dir}`, error)
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const cacheFile = path.join(this.dshHome, 'storages', 'session_projcache', 'sessions', `${sessionId}.json`)
|
|
766
|
+
try {
|
|
767
|
+
await fs.rm(cacheFile, { force: true, maxRetries: 3, retryDelay: 200 })
|
|
768
|
+
removed.projectionCache.push(cacheFile)
|
|
769
|
+
} catch (error) {
|
|
770
|
+
this.log(`could not remove ${cacheFile}`, error)
|
|
771
|
+
}
|
|
772
|
+
await this.store.purgeSession(sessionId).catch(() => {})
|
|
773
|
+
removed.checkpoints = true
|
|
774
|
+
await this.memory.purge(sessionId).catch(() => {})
|
|
775
|
+
removed.memory = true
|
|
776
|
+
for (const undo of await this.listUndos(sessionId)) await this.#discardUndo(undo.id).catch(() => {})
|
|
777
|
+
|
|
778
|
+
// Finally, drop the archive mark itself.
|
|
779
|
+
//
|
|
780
|
+
// Archiving is the right *first* step — it retires the row while the files
|
|
781
|
+
// go away — but leaving the mark behind means the session id survives in the
|
|
782
|
+
// workspace registry after everything it named is gone. Other plugins read
|
|
783
|
+
// that registry, and one of them (dsh-context) keeps a per-session context
|
|
784
|
+
// cache that is only dropped on `session/disposed`. Nothing here disposes a
|
|
785
|
+
// session — the Host owns that lifecycle — so an id left in the archive set
|
|
786
|
+
// is an id a co-installed plugin can still resolve and redisplay. That is
|
|
787
|
+
// exactly the "deleted session came back" report: the files were gone, but
|
|
788
|
+
// the mark said otherwise.
|
|
789
|
+
//
|
|
790
|
+
// Unarchiving is safe here precisely because the id is a ghost: the files it
|
|
791
|
+
// referred to no longer exist, so removing the mark cannot restore anything.
|
|
792
|
+
await this.#unarchive(sessionId).catch(() => {})
|
|
793
|
+
return removed
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* @param {object} input request
|
|
798
|
+
* @param {string} input.sessionId session
|
|
799
|
+
* @param {'md' | 'txt' | 'json' | 'html' | 'blocks'} [input.format] export format
|
|
800
|
+
* @param {readonly { startSeq: number, endSeq: number }[]} [input.ranges] selected turns; omitted exports everything
|
|
801
|
+
* @param {'final' | 'all'} [input.mode] curation mode
|
|
802
|
+
* @returns {Promise<{ body: string, contentType: string, fileName: string, messages: number }>} export
|
|
803
|
+
*/
|
|
804
|
+
async share({ sessionId, format = 'md', ranges, mode = 'final' }) {
|
|
805
|
+
const inspection = await this.inspect(sessionId)
|
|
806
|
+
const title = inspection.title
|
|
807
|
+
const events = selectEvents(inspection.events, ranges)
|
|
808
|
+
const curation = mode === 'all' ? 'all' : 'final'
|
|
809
|
+
const extension = format === 'json' || format === 'blocks' ? 'json' : format === 'txt' ? 'txt' : format === 'html' ? 'html' : 'md'
|
|
810
|
+
const fileName = exportFileName(title, sessionId, extension)
|
|
811
|
+
const messages = events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length
|
|
812
|
+
if (format === 'blocks' || format === 'html') {
|
|
813
|
+
// Structured Markdown for the browser's canvas renderer: the Host parses
|
|
814
|
+
// once, so the image and the HTML page always agree.
|
|
815
|
+
const doc = await this.#withImages(renderBlockDoc({ sessionId, title, events, mode: curation }))
|
|
816
|
+
return {
|
|
817
|
+
body: format === 'html' ? renderHtmlFromDoc(doc) : JSON.stringify(doc),
|
|
818
|
+
contentType: format === 'html' ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8',
|
|
819
|
+
fileName,
|
|
820
|
+
messages: doc.count ?? messages,
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (format === 'json') {
|
|
824
|
+
return {
|
|
825
|
+
body: renderJson({ sessionId, title, events: curation === 'final' ? curateEvents(events) : events }),
|
|
826
|
+
contentType: 'application/json; charset=utf-8',
|
|
827
|
+
fileName,
|
|
828
|
+
messages,
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (format === 'txt') {
|
|
832
|
+
return {
|
|
833
|
+
body: renderText({
|
|
834
|
+
sessionId,
|
|
835
|
+
title,
|
|
836
|
+
events: curation === 'final' ? curateEvents(events) : events,
|
|
837
|
+
options: { toolDetails: false },
|
|
838
|
+
}),
|
|
839
|
+
contentType: 'text/plain; charset=utf-8',
|
|
840
|
+
fileName,
|
|
841
|
+
messages,
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
body: renderMarkdown({
|
|
846
|
+
sessionId,
|
|
847
|
+
title,
|
|
848
|
+
events: curation === 'final' ? curateEvents(events) : events,
|
|
849
|
+
options: { toolDetails: false, toolResults: false },
|
|
850
|
+
}),
|
|
851
|
+
contentType: 'text/markdown; charset=utf-8',
|
|
852
|
+
fileName,
|
|
853
|
+
messages,
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Remove expired undo bundles and enforce the per-session checkpoint budget.
|
|
859
|
+
*/
|
|
860
|
+
async sweep() {
|
|
861
|
+
const undos = await this.listUndos('')
|
|
862
|
+
const now = Date.now()
|
|
863
|
+
for (const undo of undos) {
|
|
864
|
+
if (!undo.expiresAt || undo.expiresAt > now) continue
|
|
865
|
+
if (undo.checkpointId) {
|
|
866
|
+
await this.store.removeCheckpoint(undo.sessionId, undo.checkpointId).catch(() => {})
|
|
867
|
+
}
|
|
868
|
+
if (undo.childId) await this.#unarchive(undo.childId).catch(() => {})
|
|
869
|
+
await this.#discardUndo(undo.id).catch(() => {})
|
|
870
|
+
}
|
|
871
|
+
await this.store.sweep()
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async #writeUndo(undo) {
|
|
875
|
+
await ensureDir(this.undoDir)
|
|
876
|
+
await writeJson(path.join(this.undoDir, `${undo.id}.json`), undo)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Inline the screenshots a session carries, so a shared page (and the canvas
|
|
881
|
+
* image built from the same document) shows the pictures instead of a marker.
|
|
882
|
+
*
|
|
883
|
+
* Attachments live outside the session log, which is why the export resolves
|
|
884
|
+
* them here: `attachments.imageHostPath` gives the on-disk file, and a bounded
|
|
885
|
+
* budget keeps a chatty conversation from producing a 50 MB page.
|
|
886
|
+
*
|
|
887
|
+
* @param {any} doc block document
|
|
888
|
+
* @returns {Promise<any>} the same document with data URLs
|
|
889
|
+
*/
|
|
890
|
+
async #withImages(doc) {
|
|
891
|
+
const attachments = this.#service('attachments')
|
|
892
|
+
if (!attachments?.imageHostPath) return doc
|
|
893
|
+
const limit = numberOr(this.config.imageBudget, 12)
|
|
894
|
+
let remaining = limit
|
|
895
|
+
let bytes = 0
|
|
896
|
+
const maxBytes = numberOr(this.config.imageMaxBytes, 3 * 1024 * 1024)
|
|
897
|
+
for (const message of doc.messages ?? []) {
|
|
898
|
+
const resolved = []
|
|
899
|
+
for (const image of message.images ?? []) {
|
|
900
|
+
if (remaining <= 0 || bytes >= limit * maxBytes) continue
|
|
901
|
+
try {
|
|
902
|
+
const file = attachments.imageHostPath(image)
|
|
903
|
+
if (!file) continue
|
|
904
|
+
const info = await fs.stat(file)
|
|
905
|
+
if (!info.isFile() || info.size > maxBytes) continue
|
|
906
|
+
const data = await fs.readFile(file)
|
|
907
|
+
const mediaType = image.mediaType ?? 'image/png'
|
|
908
|
+
resolved.push({ ...image, dataUrl: `data:${mediaType};base64,${data.toString('base64')}` })
|
|
909
|
+
remaining -= 1
|
|
910
|
+
bytes += info.size
|
|
911
|
+
} catch (error) {
|
|
912
|
+
this.log(`could not inline ${image.attachmentId ?? 'an image'}`, error)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
message.images = resolved
|
|
916
|
+
}
|
|
917
|
+
return doc
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async #discardUndo(undoId) {
|
|
921
|
+
await fs.rm(path.join(this.undoDir, `${undoId}.json`), { force: true })
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** @param {string} sessionId session @param {boolean} stop stop its running work */
|
|
925
|
+
async #archive(sessionId, stop) {
|
|
926
|
+
const controllers = [this.#service('workspaceController'), this.#service('workspaceRegistry')]
|
|
927
|
+
for (const controller of controllers) {
|
|
928
|
+
if (!controller?.archiveSession) continue
|
|
929
|
+
try {
|
|
930
|
+
await controller.archiveSession(sessionId, { stopActivity: stop === true })
|
|
931
|
+
return true
|
|
932
|
+
} catch (error) {
|
|
933
|
+
this.log('archiveSession failed', error)
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return false
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** @param {string} sessionId session */
|
|
940
|
+
async #unarchive(sessionId) {
|
|
941
|
+
const controllers = [this.#service('workspaceController'), this.#service('workspaceRegistry')]
|
|
942
|
+
for (const controller of controllers) {
|
|
943
|
+
if (!controller?.unarchiveSession) continue
|
|
944
|
+
try {
|
|
945
|
+
await controller.unarchiveSession(sessionId)
|
|
946
|
+
return true
|
|
947
|
+
} catch (error) {
|
|
948
|
+
this.log('unarchiveSession failed', error)
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return false
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Every on-disk directory that holds this session's log, whatever workspace
|
|
956
|
+
* folder the persistence backend chose.
|
|
957
|
+
*
|
|
958
|
+
* @param {string} sessionId session
|
|
959
|
+
* @returns {Promise<string[]>} directories
|
|
960
|
+
*/
|
|
961
|
+
async #sessionDirs(sessionId) {
|
|
962
|
+
const root = path.join(this.dshHome, 'sessions')
|
|
963
|
+
let entries = []
|
|
964
|
+
try {
|
|
965
|
+
entries = await fs.readdir(root, { withFileTypes: true })
|
|
966
|
+
} catch {
|
|
967
|
+
return []
|
|
968
|
+
}
|
|
969
|
+
const out = []
|
|
970
|
+
for (const entry of entries) {
|
|
971
|
+
if (!entry.isDirectory()) continue
|
|
972
|
+
const candidate = path.join(root, entry.name, sessionId)
|
|
973
|
+
try {
|
|
974
|
+
const info = await fs.stat(candidate)
|
|
975
|
+
if (info.isDirectory()) out.push(candidate)
|
|
976
|
+
} catch {
|
|
977
|
+
continue
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return out
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* @param {any} checkpoint stored record
|
|
986
|
+
* @returns {any} client-facing summary
|
|
987
|
+
*/
|
|
988
|
+
function decorateCheckpoint(checkpoint) {
|
|
989
|
+
return {
|
|
990
|
+
id: checkpoint.id,
|
|
991
|
+
sessionId: checkpoint.sessionId,
|
|
992
|
+
seq: checkpoint.seq,
|
|
993
|
+
turn: checkpoint.turn,
|
|
994
|
+
kind: checkpoint.kind,
|
|
995
|
+
label: checkpoint.label,
|
|
996
|
+
createdAt: checkpoint.createdAt,
|
|
997
|
+
locked: checkpoint.locked === true,
|
|
998
|
+
files: checkpoint.files,
|
|
999
|
+
bytes: checkpoint.bytes,
|
|
1000
|
+
totalBytes: checkpoint.totalBytes,
|
|
1001
|
+
size: formatBytes(checkpoint.bytes),
|
|
1002
|
+
treeSize: formatBytes(checkpoint.totalBytes),
|
|
1003
|
+
temporary: Boolean(checkpoint.expiresAt),
|
|
1004
|
+
expiresAt: checkpoint.expiresAt ?? null,
|
|
1005
|
+
skipped: checkpoint.skipped ?? 0,
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** @param {any} config plugin config @returns {{ keep: number, maxFileBytes?: number, maxFiles?: number }} limits */
|
|
1010
|
+
function limitsFrom(config) {
|
|
1011
|
+
return {
|
|
1012
|
+
keep: typeof config?.keep === 'number' ? config.keep : 10,
|
|
1013
|
+
maxFileBytes: typeof config?.maxFileBytes === 'number' ? config.maxFileBytes : undefined,
|
|
1014
|
+
maxFiles: typeof config?.maxFiles === 'number' ? config.maxFiles : undefined,
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** @param {readonly any[]} events durable events @returns {string | undefined} title */
|
|
1019
|
+
function titleOf(events) {
|
|
1020
|
+
let title
|
|
1021
|
+
for (const event of events) {
|
|
1022
|
+
if (event?.type === 'session/title' && typeof event.data?.title === 'string') title = event.data.title
|
|
1023
|
+
}
|
|
1024
|
+
return title
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** @param {any} projections projection hints @returns {string | undefined} title */
|
|
1028
|
+
function titleFromProjections(projections) {
|
|
1029
|
+
const values = projections?.values
|
|
1030
|
+
if (!values || typeof values !== 'object') return undefined
|
|
1031
|
+
const candidate = values.title ?? values['session/title']
|
|
1032
|
+
return typeof candidate === 'string' ? candidate : undefined
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Every subagent descendant of `rootId`, deepest first — a child always before
|
|
1037
|
+
* its parent, so the result can be used directly as a delete order.
|
|
1038
|
+
*
|
|
1039
|
+
* Only `origin === 'subagent'` establishes identity. A `parentSessionId` alone
|
|
1040
|
+
* does not: every rewind, message delete, and segment delete forks the
|
|
1041
|
+
* conversation and retires the original, so the session the user is actually in
|
|
1042
|
+
* carries a parent too and must never be swept into a cascade
|
|
1043
|
+
* (docs/pitfalls.md #48).
|
|
1044
|
+
*
|
|
1045
|
+
* The `seen` set makes the walk cycle-safe. A corrupt or hand-edited list could
|
|
1046
|
+
* claim A's parent is B while B's parent is A (or a session its own parent);
|
|
1047
|
+
* that must terminate instead of recursing forever.
|
|
1048
|
+
*
|
|
1049
|
+
* @param {readonly any[]} items SessionSummary list
|
|
1050
|
+
* @param {string} rootId session to start from
|
|
1051
|
+
* @returns {string[]} descendant session ids, deepest first, without duplicates
|
|
1052
|
+
*/
|
|
1053
|
+
function subagentDescendants(items, rootId) {
|
|
1054
|
+
const childrenOf = new Map()
|
|
1055
|
+
for (const item of items) {
|
|
1056
|
+
if (!item || item.origin !== 'subagent' || typeof item.sessionId !== 'string') continue
|
|
1057
|
+
const parent = typeof item.parentSessionId === 'string' ? item.parentSessionId : undefined
|
|
1058
|
+
if (!parent) continue
|
|
1059
|
+
const siblings = childrenOf.get(parent)
|
|
1060
|
+
if (siblings) siblings.push(item.sessionId)
|
|
1061
|
+
else childrenOf.set(parent, [item.sessionId])
|
|
1062
|
+
}
|
|
1063
|
+
const out = []
|
|
1064
|
+
const seen = new Set([rootId])
|
|
1065
|
+
const visit = id => {
|
|
1066
|
+
for (const child of childrenOf.get(id) ?? []) {
|
|
1067
|
+
if (seen.has(child)) continue
|
|
1068
|
+
seen.add(child)
|
|
1069
|
+
visit(child)
|
|
1070
|
+
out.push(child)
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
visit(rootId)
|
|
1074
|
+
return out
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Refusal for a session that is still running; the message matches the one the
|
|
1079
|
+
* service has always thrown, and `sessionId` names the session that has to stop
|
|
1080
|
+
* when it is a subagent rather than the requested one.
|
|
1081
|
+
*
|
|
1082
|
+
* @param {string} sessionId session that is still running
|
|
1083
|
+
* @returns {Error} refusal carrying `code: 'session-running'`
|
|
1084
|
+
*/
|
|
1085
|
+
function sessionRunningError(sessionId) {
|
|
1086
|
+
const error = new Error('this session is running; stop it before deleting')
|
|
1087
|
+
error.code = 'session-running'
|
|
1088
|
+
error.sessionId = sessionId
|
|
1089
|
+
return error
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** @param {readonly any[]} events durable events @returns {number} last seq */
|
|
1093
|
+
function lastSeqOf(events) {
|
|
1094
|
+
let seq = 0
|
|
1095
|
+
for (const event of events) if (typeof event?.seq === 'number') seq = Math.max(seq, event.seq)
|
|
1096
|
+
return seq
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** @param {readonly any[]} events durable events @returns {number | undefined} last turn */
|
|
1100
|
+
function lastTurnOf(events) {
|
|
1101
|
+
let turn
|
|
1102
|
+
for (const event of events) if (typeof event?.data?.turn === 'number') turn = event.data.turn
|
|
1103
|
+
return turn
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** @param {readonly any[]} events durable events @returns {number | undefined} last finished turn */
|
|
1107
|
+
function lastCompletedTurn(events) {
|
|
1108
|
+
let turn
|
|
1109
|
+
for (const event of events) {
|
|
1110
|
+
if (event?.type === 'turn/end' && typeof event.data?.turn === 'number') turn = event.data.turn
|
|
1111
|
+
}
|
|
1112
|
+
return turn ?? lastTurnOf(events)
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** @param {readonly any[]} events durable events @param {string} messageId target @returns {{ seq: number, turn?: number, role?: string } | undefined} match */
|
|
1116
|
+
function findMessage(events, messageId) {
|
|
1117
|
+
for (const event of events) {
|
|
1118
|
+
if (typeof event?.seq !== 'number') continue
|
|
1119
|
+
let id
|
|
1120
|
+
if (event.type === 'assistant/message') id = event.data?.message?.id
|
|
1121
|
+
else if (event.type === 'user/message') id = event.data?.id
|
|
1122
|
+
if (id !== messageId) continue
|
|
1123
|
+
const turn = typeof event.data?.turn === 'number' ? event.data.turn : turnOfSeq(events, event.seq)
|
|
1124
|
+
return { seq: event.seq, turn, role: event.type === 'assistant/message' ? 'assistant' : 'user' }
|
|
1125
|
+
}
|
|
1126
|
+
return undefined
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* The most recent turn that a user prompt started.
|
|
1131
|
+
*
|
|
1132
|
+
* Used when the caller cannot name a turn — the chat row may hand over a
|
|
1133
|
+
* location object whose shape is not a stable contract. Walking the log is
|
|
1134
|
+
* cheaper and safer than guessing at that shape, and a turn with no user prompt
|
|
1135
|
+
* (goal round, schedule) is skipped because there is nothing to edit.
|
|
1136
|
+
*
|
|
1137
|
+
* @param {readonly any[]} events durable events
|
|
1138
|
+
* @returns {number | undefined} the turn number, when one exists
|
|
1139
|
+
*/
|
|
1140
|
+
function lastUserTurn(events) {
|
|
1141
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
1142
|
+
const event = events[index]
|
|
1143
|
+
if (event?.type !== 'user/message') continue
|
|
1144
|
+
const kind = typeof event.data?.source === 'object' && event.data.source ? event.data.source.kind : undefined
|
|
1145
|
+
if (kind !== undefined && kind !== 'user') continue
|
|
1146
|
+
const turn = typeof event.data?.turn === 'number' ? event.data.turn : turnOfSeq(events, event.seq)
|
|
1147
|
+
if (typeof turn === 'number' && Number.isFinite(turn)) return turn
|
|
1148
|
+
}
|
|
1149
|
+
return undefined
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Find the prompt that started a turn, with its text.
|
|
1154
|
+
*
|
|
1155
|
+
* Only a user-authored message counts. A turn can also be started by a goal
|
|
1156
|
+
* round, a schedule, or a subagent notice, and those have no prompt to resend —
|
|
1157
|
+
* returning undefined lets the caller say so instead of sending the wrong text.
|
|
1158
|
+
*
|
|
1159
|
+
* @param {readonly any[]} events durable events
|
|
1160
|
+
* @param {number} turn turn number
|
|
1161
|
+
* @returns {{ seq: number, text: string } | undefined} the prompt, or undefined
|
|
1162
|
+
*/
|
|
1163
|
+
function promptOfTurn(events, turn) {
|
|
1164
|
+
let started = false
|
|
1165
|
+
for (const event of events) {
|
|
1166
|
+
if (typeof event?.seq !== 'number') continue
|
|
1167
|
+
if (event.type === 'turn/start') {
|
|
1168
|
+
started = typeof event.data?.turn === 'number' && event.data.turn === turn
|
|
1169
|
+
continue
|
|
1170
|
+
}
|
|
1171
|
+
// The prompt is the first user-authored message inside the turn; anything
|
|
1172
|
+
// else in the turn means this turn was not started by the user.
|
|
1173
|
+
if (!started) continue
|
|
1174
|
+
if (event.type === 'step/end' || event.type === 'turn/end' || event.type === 'turn/start') return undefined
|
|
1175
|
+
if (event.type !== 'user/message') continue
|
|
1176
|
+
const kind = typeof event.data?.source === 'object' && event.data.source ? event.data.source.kind : undefined
|
|
1177
|
+
if (kind !== undefined && kind !== 'user') continue
|
|
1178
|
+
return { seq: event.seq, text: contentText(event.data?.content) }
|
|
1179
|
+
}
|
|
1180
|
+
return undefined
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* @param {unknown} content message content blocks
|
|
1185
|
+
* @returns {string} the plain text of a message
|
|
1186
|
+
*/
|
|
1187
|
+
function contentText(content) {
|
|
1188
|
+
if (typeof content === 'string') return content
|
|
1189
|
+
if (!Array.isArray(content)) return ''
|
|
1190
|
+
return content
|
|
1191
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
1192
|
+
.map(block => block.text)
|
|
1193
|
+
.join('\n')
|
|
1194
|
+
.trim()
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* @param {readonly any[]} events durable events
|
|
1199
|
+
* @param {number} seq exclusive bound
|
|
1200
|
+
* @returns {number | undefined} sequence of the closest earlier user prompt
|
|
1201
|
+
*/
|
|
1202
|
+
function previousUserSeq(events, seq) {
|
|
1203
|
+
let found
|
|
1204
|
+
for (const event of events) {
|
|
1205
|
+
if (typeof event?.seq !== 'number' || event.seq >= seq) break
|
|
1206
|
+
if (event.type !== 'user/message') continue
|
|
1207
|
+
const kind = typeof event.data?.source === 'object' && event.data.source ? event.data.source.kind : undefined
|
|
1208
|
+
if (kind !== undefined && kind !== 'user') continue
|
|
1209
|
+
found = event.seq
|
|
1210
|
+
}
|
|
1211
|
+
return found
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* @param {readonly any[]} events durable events
|
|
1216
|
+
* @param {number} seq exclusive bound
|
|
1217
|
+
* @returns {number | undefined} sequence of the closest earlier conversation message
|
|
1218
|
+
*/
|
|
1219
|
+
function previousMessageSeq(events, seq) {
|
|
1220
|
+
let found
|
|
1221
|
+
for (const event of events) {
|
|
1222
|
+
if (typeof event?.seq !== 'number' || event.seq >= seq) break
|
|
1223
|
+
if (!isConversationMessage(event)) continue
|
|
1224
|
+
found = event.seq
|
|
1225
|
+
}
|
|
1226
|
+
return found
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Is this event a real turn message rather than harness plumbing?
|
|
1231
|
+
*
|
|
1232
|
+
* Compaction is append-only, so the log keeps the summarized events and then
|
|
1233
|
+
* adds a synthetic `user/message` carrying `source.kind === 'compact-checkpoint'`
|
|
1234
|
+
* plus a `surfaceOp` that replaces the summarized range. Treating that node as
|
|
1235
|
+
* a conversation message puts a cut point in the wrong place, so it — and any
|
|
1236
|
+
* other non-user source — is excluded here.
|
|
1237
|
+
*
|
|
1238
|
+
* @param {any} event durable event
|
|
1239
|
+
* @returns {boolean} whether the event is a user prompt or an assistant reply
|
|
1240
|
+
*/
|
|
1241
|
+
function isConversationMessage(event) {
|
|
1242
|
+
if (event?.type === 'assistant/message') return true
|
|
1243
|
+
if (event?.type !== 'user/message') return false
|
|
1244
|
+
const source = typeof event.data?.source === 'object' && event.data.source ? event.data.source : undefined
|
|
1245
|
+
if (source?.kind !== undefined && source.kind !== 'user') return false
|
|
1246
|
+
return true
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** @param {readonly any[]} events durable events @param {number} seq target @returns {number | undefined} turn */
|
|
1250
|
+
function turnOfSeq(events, seq) {
|
|
1251
|
+
let turn
|
|
1252
|
+
for (const event of events) {
|
|
1253
|
+
if (typeof event?.seq === 'number' && event.seq > seq) break
|
|
1254
|
+
if (typeof event?.data?.turn === 'number') turn = event.data.turn
|
|
1255
|
+
}
|
|
1256
|
+
return turn
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** @param {unknown} error value @returns {string} message */
|
|
1260
|
+
function errorMessage(error) {
|
|
1261
|
+
return error instanceof Error ? error.message : String(error)
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* @param {unknown} value candidate
|
|
1266
|
+
* @param {number} fallback default
|
|
1267
|
+
* @returns {number} number
|
|
1268
|
+
*/
|
|
1269
|
+
function numberOr(value, fallback) {
|
|
1270
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
export { UNDO_TTL_MS, asString }
|