@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.
@@ -0,0 +1,77 @@
1
+ # dsh-session-suite — session checkpoints, rewind, delete, share, and long-term memory.
2
+ #
3
+ # One row: the Host half registers the authenticated Fetch route, turns the
4
+ # per-turn workspace snapshots, and runs the long-term memory engine; the Client
5
+ # half adds the sidebar rows, the message actions, and the settings pages.
6
+ - insert:
7
+ - id: session-suite
8
+ name: dsh-session-suite
9
+ config:
10
+ # ---------- session snapshots ----------
11
+ # Automatic checkpoints kept per session (locked ones do not count).
12
+ keep: 10
13
+ # Capture a workspace checkpoint when each turn finishes.
14
+ autoCheckpoint: true
15
+ # Files larger than this are skipped.
16
+ maxFileBytes: 2097152
17
+ # Hard cap on tracked files per checkpoint.
18
+ maxFiles: 4000
19
+ # How often expired undo bundles are swept.
20
+ sweepIntervalMs: 300000
21
+ # Extra ignore patterns on top of the built-in list.
22
+ ignore: []
23
+
24
+ # ---------- output hygiene ----------
25
+ # Strip the four internal step tags from streamed text/reasoning before
26
+ # they can reach the assistant message, the transcript, share exports,
27
+ # or a later replayed request. Host-side `llm/stream` filter; set false
28
+ # to pass model output through untouched.
29
+ stripStepTags: true
30
+
31
+ # ---------- long-term memory ----------
32
+ # Turn the whole memory engine on or off.
33
+ longTermMemory: true
34
+ # Model chain used for implicit extraction (one cheap call per turn).
35
+ # Tried in order until one answers; a 429 or an unreachable provider just
36
+ # moves on to the next entry. All of these are free tiers.
37
+ memoryModels:
38
+ - provider: agnes
39
+ model: agnes-3.0-flash
40
+ - provider: agnes-backup
41
+ model: agnes-3.0-flash
42
+ - provider: zhipu
43
+ model: glm-4.7-flash
44
+ - provider: siliconflow
45
+ model: Qwen/Qwen2.5-7B-Instruct
46
+ # How many memories one recall may inject into the prompt. This is a
47
+ # ceiling, not a quota: a weak question injects fewer, because filling
48
+ # the budget would claim six memories are relevant when the evidence
49
+ # says otherwise.
50
+ recallLimit: 6
51
+ # How many of the most important memories ride along regardless of the
52
+ # question. Without a resident layer, an ordinary follow-up like "继续"
53
+ # shares no vocabulary with any memory and a strict relevance filter
54
+ # would inject nothing at all, which reads as the feature being broken.
55
+ residentLimit: 2
56
+ # Use the general-purpose Chinese word list as a third retrieval route.
57
+ # It fixes character-bigram over-matching across word boundaries; the
58
+ # ablation in tools/ablate-word-route.mjs shows it raises MRR without
59
+ # lowering recall@5. Set false to run the two lexical routes alone.
60
+ wordRoute: true
61
+ # BM25 term-frequency saturation and length normalisation, and the RRF
62
+ # smoothing constant. Defaults are the customary ones; lower bm25K1
63
+ # saturates repeated terms sooner, bm25B 0 disables length normalisation.
64
+ bm25K1: 1.5
65
+ bm25B: 0.75
66
+ rrfK: 60
67
+ # Similarity above which a new memory reinforces an existing one.
68
+ duplicateSimilarity: 0.82
69
+ # Similarity above which two memories may join one cluster.
70
+ clusterSimilarity: 0.5
71
+ # Cluster size that triggers compression into one summary.
72
+ clusterMinSize: 2
73
+ # Number of same-tag memories that triggers a rule-type abstraction.
74
+ ruleMinSize: 3
75
+ # Images inlined into a shared page, and the per-image ceiling.
76
+ imageBudget: 12
77
+ imageMaxBytes: 3145728
package/icon.svg ADDED
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Session suite">
2
+ <defs>
3
+ <linearGradient id="suiteBg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0%" stop-color="#1f4fd8"/>
5
+ <stop offset="100%" stop-color="#4f8cff"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="url(#suiteBg)"/>
9
+ <g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-width="3.4">
10
+ <path d="M45.5 32a13.5 13.5 0 1 1-4.2-9.8"/>
11
+ <path d="M42.6 15.4v7.9h-7.9"/>
12
+ </g>
13
+ <circle cx="32" cy="32" r="3.4" fill="#ffffff"/>
14
+ <g fill="#ffffff" opacity="0.55">
15
+ <circle cx="16.5" cy="47.5" r="2.1"/>
16
+ <circle cx="23.5" cy="51" r="2.1"/>
17
+ <circle cx="31" cy="52.5" r="2.1"/>
18
+ </g>
19
+ </svg>
package/index.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * dsh-session-suite — Host half.
3
+ *
4
+ * Registers, for the desktop/web profile:
5
+ *
6
+ * - incremental workspace checkpoints captured at the start of every turn;
7
+ * - the rollback operation (conversation prefix + files + memory, with an undo
8
+ * bundle that expires after ten minutes);
9
+ * - permanent session deletion (log, projection cache, checkpoints, memory);
10
+ * - session sharing as Markdown / text / JSON;
11
+ * - a per-session memory ledger that participates in rollback;
12
+ * - the authenticated Fetch route the browser half talks to.
13
+ *
14
+ * The module intentionally depends on `node:` builtins only: a bundle installed
15
+ * into a profile is resolved from the profile, not from the DSH installation,
16
+ * so importing @deepseek-ai runtime packages would not resolve.
17
+ */
18
+
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import { API_PATH, createRoute } from './lib/api.js'
22
+ import { CheckpointStore } from './lib/checkpoints.js'
23
+ import { MemoryLedger } from './lib/memory.js'
24
+ import { installMemory } from './lib/memory-host.js'
25
+ import { SessionSuiteService } from './lib/service.js'
26
+ import { stripStepTagsFromStream } from './lib/step-tag-filter.js'
27
+
28
+ export const name = 'session-suite'
29
+
30
+ /** The HTTP carrier is the only hard requirement; the rest degrade gracefully. */
31
+ export const inject = ['connection']
32
+
33
+ /**
34
+ * @param {any} ctx plugin context
35
+ * @param {any} [config] row config from cordis.patch.yml
36
+ */
37
+ export function apply(ctx, config = {}) {
38
+ const log = (message, error) => {
39
+ if (error) ctx.logger?.warn?.(`[session-suite] ${message}`, error)
40
+ else ctx.logger?.info?.(`[session-suite] ${message}`)
41
+ }
42
+ const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
43
+ const root = path.join(home, 'storages', 'dsh-session-suite')
44
+ const store = new CheckpointStore({
45
+ root,
46
+ log,
47
+ ignores: Array.isArray(config.ignore) ? config.ignore.filter(item => typeof item === 'string') : [],
48
+ })
49
+ const memory = new MemoryLedger({ root })
50
+ const service = new SessionSuiteService({ ctx, root, store, memory, log, config })
51
+
52
+ // The HTTP route comes first and unconditionally: session delete, share, and
53
+ // rewind must keep working even if an optional integration below fails.
54
+ ctx.effect(
55
+ () => ctx.connection.fetch.register(createRoute(service, { log })),
56
+ 'session-suite: http route',
57
+ )
58
+ log(`http route ready at ${API_PATH}`)
59
+
60
+ // Output hygiene: models occasionally emit the four internal step tags
61
+ // (`<发>` / `<执行>` / `<好>` / `<现在>`, U+003C + word + U+003E) as a long run
62
+ // inside an ordinary assistant reply. Left alone they land in the durable
63
+ // assistant message, the live transcript, share exports, and every later
64
+ // request that replays history. The `llm/stream` waterfall is the one layer
65
+ // that covers all of those at once: the filter removes the tags from token
66
+ // deltas and from the terminal block text the assembler persists. It is
67
+ // config-gated (`stripStepTags: false` disables it) and never rewrites any
68
+ // other angle-bracket text. A failure here is reported and skipped rather
69
+ // than taking the bundle down.
70
+ if (config.stripStepTags !== false) {
71
+ try {
72
+ ctx.effect(
73
+ () =>
74
+ ctx.on('llm/stream', (options, next) => {
75
+ const stream = next()
76
+ if (
77
+ stream === null ||
78
+ stream === undefined ||
79
+ typeof stream[Symbol.asyncIterator] !== 'function'
80
+ ) {
81
+ return stream
82
+ }
83
+ return stripStepTagsFromStream(stream)
84
+ }),
85
+ 'session-suite: step tag filter',
86
+ )
87
+ log('step tag filter ready')
88
+ } catch (error) {
89
+ log('step tag filter could not be installed; continuing without it', error)
90
+ }
91
+ }
92
+
93
+ // Long-term memory: recall into the prompt, explicit writes, implicit
94
+ // suggestions, the `memory` tool, and its management surface. A failure here
95
+ // is reported and skipped rather than taking the whole bundle down.
96
+ if (config.longTermMemory !== false) {
97
+ try {
98
+ const installed = installMemory({ ctx, root, config, log })
99
+ service.longTerm = installed.engine
100
+ log('long-term memory engine ready')
101
+ } catch (error) {
102
+ log('long-term memory failed to install; continuing without it', error)
103
+ }
104
+ }
105
+
106
+ // One automatic checkpoint per turn, taken when the turn finishes: a snapshot
107
+ // then represents "the workspace after turn N", which is exactly the state a
108
+ // rewind to turn N must restore.
109
+ if (config.autoCheckpoint !== false) {
110
+ ctx.effect(
111
+ () =>
112
+ ctx.on('session/event', (session, event) => {
113
+ if (!event || (event.type !== 'turn/end' && event.type !== 'turn/start')) return
114
+ // Subagent children are not conversations a user can open. Capturing
115
+ // their workspace state produces snapshot directories nobody can
116
+ // reach from the panel — the list already hides them — and costs a
117
+ // full tree walk on every one of their turns.
118
+ if (session?.header?.origin === 'subagent' || session?.origin === 'subagent') return
119
+ const sessionId = session?.id
120
+ if (typeof sessionId !== 'string') return
121
+ const cwd = resolveCwd(ctx, session)
122
+ if (!cwd) return
123
+ const limits = {
124
+ keep: numberOr(config.keep, 10),
125
+ maxFileBytes: numberOr(config.maxFileBytes, undefined),
126
+ maxFiles: numberOr(config.maxFiles, undefined),
127
+ }
128
+ if (event.type === 'turn/end') {
129
+ const turn = typeof event.data?.turn === 'number' ? event.data.turn : undefined
130
+ void store
131
+ .capture({
132
+ sessionId,
133
+ cwd,
134
+ seq: typeof event.seq === 'number' ? event.seq : undefined,
135
+ turn,
136
+ phase: 'end',
137
+ kind: 'auto',
138
+ label: `after turn ${turn ?? '?'}`,
139
+ limits,
140
+ })
141
+ .catch(error => log(`automatic checkpoint failed for ${sessionId}`, error))
142
+ return
143
+ }
144
+ // Baseline: the state this session started from, captured before the
145
+ // first turn touches anything. Rewinding to the very first prompt needs
146
+ // it, and it is taken once per session.
147
+ const startTurn = typeof event.data?.turn === 'number' ? event.data.turn : 1
148
+ void store
149
+ .list(sessionId)
150
+ .then(list => {
151
+ if (list.length > 0) return undefined
152
+ return store.capture({
153
+ sessionId,
154
+ cwd,
155
+ seq: typeof event.seq === 'number' ? event.seq : undefined,
156
+ turn: startTurn - 1,
157
+ phase: 'end',
158
+ kind: 'auto',
159
+ label: 'baseline',
160
+ limits,
161
+ })
162
+ })
163
+ .catch(error => log(`baseline checkpoint failed for ${sessionId}`, error))
164
+ }),
165
+ 'session-suite: turn checkpoints',
166
+ )
167
+ }
168
+
169
+ // Expired undo bundles and the per-session budget are swept on a slow timer.
170
+ const sweepMs = numberOr(config.sweepIntervalMs, 5 * 60 * 1000)
171
+ if (sweepMs > 0) {
172
+ ctx.effect(() => {
173
+ const timer = setInterval(() => {
174
+ void service.sweep().catch(error => log('sweep failed', error))
175
+ }, sweepMs)
176
+ timer.unref?.()
177
+ return () => clearInterval(timer)
178
+ }, 'session-suite: sweep timer')
179
+ }
180
+
181
+ // A cold start should not leave expired bundles behind.
182
+ void service.sweep().catch(error => log('initial sweep failed', error))
183
+ }
184
+
185
+ /**
186
+ * @param {any} ctx plugin context
187
+ * @param {any} session live session
188
+ * @returns {string | undefined} workspace directory
189
+ */
190
+ function resolveCwd(ctx, session) {
191
+ const workingDirectory = ctx.get('workingDirectory')
192
+ if (workingDirectory?.get) {
193
+ try {
194
+ const value = workingDirectory.get(session)
195
+ if (typeof value === 'string' && value.length > 0) return value
196
+ } catch {
197
+ // fall through to the session header
198
+ }
199
+ }
200
+ const meta = session?.meta ?? session?.header
201
+ return typeof meta?.cwd === 'string' ? meta.cwd : undefined
202
+ }
203
+
204
+ /**
205
+ * @param {unknown} value candidate
206
+ * @param {number | undefined} fallback default
207
+ * @returns {number | undefined} number
208
+ */
209
+ function numberOr(value, fallback) {
210
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
211
+ }
package/lib/api.js ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The HTTP surface of dsh-session-suite.
3
+ *
4
+ * Connection mounts this exact Fetch route behind its Host/Origin and
5
+ * browser-session checks (the same carrier the shipped session-log export
6
+ * uses), so the browser half never needs a bespoke RPC channel: it posts JSON
7
+ * to a document-relative `api/session-suite` URL.
8
+ */
9
+
10
+ /** Absolute path Connection registers. */
11
+ export const API_PATH = '/api/session-suite'
12
+
13
+ const MAX_BODY_BYTES = 256 * 1024
14
+
15
+ /**
16
+ * @param {import('./service.js').SessionSuiteService} service suite operations
17
+ * @param {{ log: (message: string, error?: unknown) => void }} deps dependencies
18
+ * @returns {{ path: string, methods: readonly string[], requestBody: string, fetch: (request: Request) => Promise<Response> }} route
19
+ */
20
+ export function createRoute(service, deps) {
21
+ return {
22
+ path: API_PATH,
23
+ methods: ['GET', 'POST'],
24
+ requestBody: 'buffered',
25
+ fetch: request => handle(service, deps, request),
26
+ }
27
+ }
28
+
29
+ /**
30
+ * @param {import('./service.js').SessionSuiteService} service suite operations
31
+ * @param {{ log: (message: string, error?: unknown) => void }} deps dependencies
32
+ * @param {Request} request inbound request
33
+ * @returns {Promise<Response>} response
34
+ */
35
+ async function handle(service, deps, request) {
36
+ try {
37
+ // `await` is load-bearing, not stylistic. `respond` is async, so returning
38
+ // its promise without awaiting lets every rejection escape this try/catch:
39
+ // a service error would surface as an unhandled rejection instead of a 400,
40
+ // and the client would lose `error.code` — which is what it matches on to
41
+ // tell "this session is running" apart from a generic failure.
42
+ if (request.method === 'GET' || request.method === 'HEAD') {
43
+ const url = new URL(request.url)
44
+ const action = url.searchParams.get('action') ?? 'ping'
45
+ return await respond(service, deps, action, paramsOf(url.searchParams))
46
+ }
47
+ if (request.method !== 'POST') return json({ ok: false, error: 'method not allowed' }, 405)
48
+ const body = await readJsonBody(request)
49
+ const action = typeof body?.action === 'string' ? body.action : 'ping'
50
+ return await respond(service, deps, action, body ?? {})
51
+ } catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error)
53
+ deps.log(`request failed: ${message}`, error)
54
+ return json({ ok: false, error: message, code: /** @type {any} */ (error)?.code }, 400)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {import('./service.js').SessionSuiteService} service suite operations
60
+ * @param {{ log: (message: string, error?: unknown) => void }} deps dependencies
61
+ * @param {string} action action name
62
+ * @param {any} input request payload
63
+ * @returns {Promise<Response>} response
64
+ */
65
+ async function respond(service, deps, action, input) {
66
+ switch (action) {
67
+ case 'ping':
68
+ return json({ ok: true, data: { ready: true, version: 1 } })
69
+ case 'list':
70
+ return json({ ok: true, data: await service.listSessions() })
71
+ case 'detail':
72
+ return json({ ok: true, data: await service.detail(requireSession(input)) })
73
+ case 'capture':
74
+ return json({ ok: true, data: await service.capture(requireSession(input), { label: input.label, kind: 'manual' }) })
75
+ case 'restore':
76
+ return json({ ok: true, data: await service.restoreCheckpoint(requireSession(input), requireCheckpoint(input)) })
77
+ case 'rollback':
78
+ return json({
79
+ ok: true,
80
+ data: await service.rollback({
81
+ sessionId: requireSession(input),
82
+ messageId: typeof input.messageId === 'string' ? input.messageId : undefined,
83
+ seq: numberOrUndefined(input.seq),
84
+ turn: numberOrUndefined(input.turn),
85
+ }),
86
+ })
87
+ case 'deleteMessage':
88
+ return json({
89
+ ok: true,
90
+ data: await service.deleteMessage({
91
+ sessionId: requireSession(input),
92
+ messageId: requireText(input, 'messageId'),
93
+ }),
94
+ })
95
+ case 'resendTurn.preview':
96
+ return json({
97
+ ok: true,
98
+ data: await service.previewResend({
99
+ sessionId: requireSession(input),
100
+ turn: numberOrUndefined(input.turn),
101
+ messageId: typeof input.messageId === 'string' ? input.messageId : undefined,
102
+ }),
103
+ })
104
+ case 'resendTurn':
105
+ return json({
106
+ ok: true,
107
+ data: await service.resendTurn({
108
+ sessionId: requireSession(input),
109
+ turn: numberOrUndefined(input.turn),
110
+ messageId: typeof input.messageId === 'string' ? input.messageId : undefined,
111
+ mode: input.mode === 'stage' ? 'stage' : 'retry',
112
+ text: typeof input.text === 'string' ? input.text : undefined,
113
+ }),
114
+ })
115
+ case 'deleteSegment':
116
+ return json({
117
+ ok: true,
118
+ data: await service.deleteSegment({
119
+ sessionId: requireSession(input),
120
+ startSeq: Number(input.startSeq),
121
+ }),
122
+ })
123
+ case 'outline':
124
+ return json({ ok: true, data: await service.outline(requireSession(input)) })
125
+ case 'removeCheckpoint':
126
+ return json({
127
+ ok: true,
128
+ data: await service.store.removeCheckpoint(requireSession(input), requireCheckpoint(input)),
129
+ })
130
+ case 'setLocked':
131
+ return json({
132
+ ok: true,
133
+ data: await service.store.setLocked(requireSession(input), requireCheckpoint(input), input.locked === true),
134
+ })
135
+ case 'undo':
136
+ return json({ ok: true, data: await service.undo(requireText(input, 'undoId')) })
137
+ case 'previewDelete':
138
+ return json({ ok: true, data: await service.previewDelete(requireSession(input)) })
139
+ case 'deleteSessions':
140
+ return json({
141
+ ok: true,
142
+ data: await service.deleteSessions(
143
+ Array.isArray(input.sessionIds) ? input.sessionIds : [],
144
+ { cascade: input.cascade !== false },
145
+ ),
146
+ })
147
+ case 'deleteSession':
148
+ return json({
149
+ ok: true,
150
+ data: await service.deleteSession(requireSession(input), { cascade: input.cascade !== false }),
151
+ })
152
+ case 'export': {
153
+ const ranges = Array.isArray(input.ranges)
154
+ ? input.ranges
155
+ .map(range => ({ startSeq: Number(range?.startSeq), endSeq: Number(range?.endSeq) }))
156
+ .filter(range => Number.isFinite(range.startSeq) && Number.isFinite(range.endSeq))
157
+ : undefined
158
+ const exported = await service.share({
159
+ sessionId: requireSession(input),
160
+ format:
161
+ input.format === 'json' || input.format === 'txt' || input.format === 'html' || input.format === 'blocks'
162
+ ? input.format
163
+ : 'md',
164
+ ranges: ranges && ranges.length > 0 ? ranges : undefined,
165
+ mode: input.mode === 'all' ? 'all' : 'final',
166
+ })
167
+ return json({ ok: true, data: exported })
168
+ }
169
+ case 'share': {
170
+ const ranges = Array.isArray(input.ranges)
171
+ ? input.ranges
172
+ .map(range => ({ startSeq: Number(range?.startSeq), endSeq: Number(range?.endSeq) }))
173
+ .filter(range => Number.isFinite(range.startSeq) && Number.isFinite(range.endSeq))
174
+ : undefined
175
+ const exported = await service.share({
176
+ sessionId: requireSession(input),
177
+ format:
178
+ input.format === 'json' || input.format === 'txt' || input.format === 'html' || input.format === 'blocks'
179
+ ? input.format
180
+ : 'md',
181
+ ranges: ranges && ranges.length > 0 ? ranges : undefined,
182
+ mode: input.mode === 'all' ? 'all' : 'final',
183
+ })
184
+ const download = input.download === true || input.download === '1' || input.download === 'true'
185
+ return new Response(exported.body, {
186
+ status: 200,
187
+ headers: {
188
+ 'content-type': exported.contentType,
189
+ 'cache-control': 'no-store',
190
+ ...(download
191
+ ? { 'content-disposition': `attachment; filename="${escapeFileName(exported.fileName)}"` }
192
+ : {}),
193
+ },
194
+ })
195
+ }
196
+ case 'memory.list':
197
+ return json({ ok: true, data: await service.memory.list(requireSession(input)) })
198
+ case 'memory.add':
199
+ return json({
200
+ ok: true,
201
+ data: await service.memory.add(requireSession(input), {
202
+ text: requireText(input, 'text'),
203
+ tags: input.tags,
204
+ seq: numberOrUndefined(input.seq),
205
+ turn: numberOrUndefined(input.turn),
206
+ source: 'user',
207
+ }),
208
+ })
209
+ case 'memory.update':
210
+ return json({
211
+ ok: true,
212
+ data: await service.memory.update(requireSession(input), requireText(input, 'id'), {
213
+ text: input.text,
214
+ tags: input.tags,
215
+ pinned: input.pinned,
216
+ }),
217
+ })
218
+ case 'memory.remove':
219
+ return json({ ok: true, data: { removed: await service.memory.remove(requireSession(input), requireText(input, 'id')) } })
220
+ case 'sweep':
221
+ await service.sweep()
222
+ return json({ ok: true, data: { swept: true } })
223
+ // ------------------------------------------------------- long-term memory
224
+ case 'longterm.list':
225
+ return json({
226
+ ok: true,
227
+ data: await service.longTerm.list({
228
+ cwd: workspaceOf(input),
229
+ scope: scopeOf(input),
230
+ tag: typeof input.tag === 'string' ? input.tag : undefined,
231
+ sinceDays: numberOrUndefined(input.sinceDays),
232
+ }),
233
+ })
234
+ case 'longterm.remember':
235
+ return json({
236
+ ok: true,
237
+ data: await service.longTerm.remember({
238
+ scope: scopeOf(input) === 'global' ? 'global' : 'workspace',
239
+ cwd: workspaceOf(input),
240
+ text: requireText(input, 'text'),
241
+ tags: Array.isArray(input.tags) ? input.tags : undefined,
242
+ affect: typeof input.affect === 'number' ? input.affect : undefined,
243
+ source: 'user',
244
+ }),
245
+ })
246
+ case 'longterm.update':
247
+ return json({
248
+ ok: true,
249
+ data: await service.longTerm.update({
250
+ scope: scopeOf(input),
251
+ cwd: workspaceOf(input),
252
+ id: requireText(input, 'id'),
253
+ patch: {
254
+ frozen: typeof input.frozen === 'boolean' ? input.frozen : undefined,
255
+ touch: input.touch === true,
256
+ text: typeof input.text === 'string' ? input.text : undefined,
257
+ },
258
+ }),
259
+ })
260
+ case 'longterm.remove':
261
+ return json({
262
+ ok: true,
263
+ data: await service.longTerm.remove({
264
+ scope: scopeOf(input),
265
+ cwd: workspaceOf(input),
266
+ id: typeof input.id === 'string' ? input.id : undefined,
267
+ }),
268
+ })
269
+ case 'longterm.recall':
270
+ return json({
271
+ ok: true,
272
+ data: await service.longTerm.recall({
273
+ cwd: workspaceOf(input),
274
+ query: typeof input.query === 'string' ? input.query : '',
275
+ limit: numberOrUndefined(input.limit),
276
+ touch: false,
277
+ }),
278
+ })
279
+ case 'longterm.explain':
280
+ // Answer "which memories would this question pull in, and why were the
281
+ // rest dropped" without touching anything: `touch: false` keeps the access
282
+ // counters and decay clocks exactly as they were.
283
+ return json({
284
+ ok: true,
285
+ data: await service.longTerm.select({
286
+ cwd: workspaceOf(input),
287
+ query: typeof input.query === 'string' ? input.query : '',
288
+ limit: numberOrUndefined(input.limit),
289
+ touch: false,
290
+ }),
291
+ })
292
+ case 'longterm.pending':
293
+ return json({ ok: true, data: await service.longTerm.pending(workspaceOf(input)) })
294
+ case 'longterm.settle':
295
+ return json({
296
+ ok: true,
297
+ data: await service.longTerm.settle(requireText(input, 'id'), input.accept === true),
298
+ })
299
+ case 'longterm.maintain':
300
+ return json({ ok: true, data: await service.longTerm.maintain(workspaceOf(input)) })
301
+ case 'longterm.status':
302
+ return json({ ok: true, data: await service.longTerm.status() })
303
+ case 'longterm.workspaces':
304
+ return json({ ok: true, data: await service.longTerm.workspaces() })
305
+ case 'longterm.settings':
306
+ return json(
307
+ input.patch
308
+ ? { ok: true, data: await service.longTerm.updateSettings(input.patch) }
309
+ : { ok: true, data: await service.longTerm.settings() },
310
+ )
311
+ case 'longterm.export':
312
+ return json({ ok: true, data: await service.longTerm.export({ cwd: workspaceOf(input) }) })
313
+ default:
314
+ deps.log(`unknown action ${action}`)
315
+ return json({ ok: false, error: `unknown action: ${action}` }, 400)
316
+ }
317
+ }
318
+
319
+ /**
320
+ * @param {Request} request inbound request
321
+ * @returns {Promise<any>} parsed JSON body
322
+ */
323
+ async function readJsonBody(request) {
324
+ const text = await request.text()
325
+ if (text.length > MAX_BODY_BYTES) throw new Error('request body is too large')
326
+ if (!text.trim()) return {}
327
+ try {
328
+ return JSON.parse(text)
329
+ } catch {
330
+ throw new Error('request body is not valid JSON')
331
+ }
332
+ }
333
+
334
+ /** @param {URLSearchParams} search query */
335
+ function paramsOf(search) {
336
+ const out = {}
337
+ for (const [key, value] of search.entries()) out[key] = value
338
+ return out
339
+ }
340
+
341
+ /** @param {any} input payload @returns {string | undefined} workspace directory */
342
+ function workspaceOf(input) {
343
+ return typeof input?.cwd === 'string' && input.cwd.length > 0 ? input.cwd : undefined
344
+ }
345
+
346
+ /** @param {any} input payload @returns {'global' | 'workspace' | undefined} memory scope */
347
+ function scopeOf(input) {
348
+ if (input?.scope === 'global') return 'global'
349
+ if (input?.scope === 'workspace') return 'workspace'
350
+ return undefined
351
+ }
352
+
353
+ /** @param {any} input payload @param {string} field field name @returns {string} value */
354
+ function requireText(input, field) {
355
+ const value = input?.[field]
356
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`${field} is required`)
357
+ return value
358
+ }
359
+
360
+ /** @param {any} input payload @returns {string} session id */
361
+ function requireSession(input) {
362
+ return requireText(input, 'sessionId')
363
+ }
364
+
365
+ /** @param {any} input payload @returns {string} checkpoint id */
366
+ function requireCheckpoint(input) {
367
+ return requireText(input, 'checkpointId')
368
+ }
369
+
370
+ /** @param {any} value candidate @returns {number | undefined} number */
371
+ function numberOrUndefined(value) {
372
+ const number = Number(value)
373
+ return Number.isFinite(number) ? number : undefined
374
+ }
375
+
376
+ /** @param {string} name file name @returns {string} header-safe name */
377
+ function escapeFileName(name) {
378
+ return name.replace(/["\r\n]/g, '_')
379
+ }
380
+
381
+ /** @param {unknown} payload response body @param {number} [status] status */
382
+ function json(payload, status = 200) {
383
+ return new Response(JSON.stringify(payload), {
384
+ status,
385
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
386
+ })
387
+ }