@heixui/dsh-session-suite 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +640 -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 +75 -4
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|