@dafantong01/dsh-memory-palace 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +314 -2
- package/cordis.patch.yml +12 -0
- package/lib/archive.js +483 -0
- package/lib/client.js +637 -0
- package/lib/index.js +2107 -0
- package/lib/route.js +296 -0
- package/package.json +55 -4
package/lib/route.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { resolveSessionLog } from './archive.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 找会话所属工作区:优先活跃 agent,否则从会话日志头部的 session 事件里读。
|
|
7
|
+
* 非活跃会话也要能改配置,所以不能只依赖 ctx.agents。
|
|
8
|
+
* 日志扫描走 index.js 的 scanSessionLog(带 mtime+size 缓存),避免页签一次 GET 解压两遍。
|
|
9
|
+
*/
|
|
10
|
+
async function resolveTarget(ctx, sessionId, logScan) {
|
|
11
|
+
const agent = sessionId ? ctx.agents?.get?.(sessionId) : undefined
|
|
12
|
+
const liveCwd = String(agent?.session?.header?.cwd ?? '')
|
|
13
|
+
if (liveCwd) return { agent, cwd: liveCwd, win: null }
|
|
14
|
+
const logPath = sessionId ? resolveSessionLog(sessionId) : null
|
|
15
|
+
if (!logPath) return { agent: undefined, cwd: '', win: null }
|
|
16
|
+
try {
|
|
17
|
+
const scan = typeof logScan === 'function' ? await logScan(logPath) : { cwd: '', win: null }
|
|
18
|
+
return { agent: undefined, cwd: scan.cwd || '', win: scan.win ?? null }
|
|
19
|
+
} catch {
|
|
20
|
+
return { agent: undefined, cwd: '', win: null }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 读取请求体(上限 64 KB)。 */
|
|
25
|
+
function readBody(req) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
let body = ''
|
|
28
|
+
let done = false
|
|
29
|
+
const fail = (err) => { if (!done) { done = true; reject(err) } }
|
|
30
|
+
req.on('data', (c) => {
|
|
31
|
+
if (done) return
|
|
32
|
+
body += String(c)
|
|
33
|
+
if (body.length > 65536) fail(new Error('body too large'))
|
|
34
|
+
})
|
|
35
|
+
req.on('end', () => {
|
|
36
|
+
if (done) return
|
|
37
|
+
done = true
|
|
38
|
+
try { resolve(body ? JSON.parse(body) : {}) } catch { reject(new Error('invalid JSON')) }
|
|
39
|
+
})
|
|
40
|
+
req.on('error', fail)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function json(res, status, body) {
|
|
45
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
46
|
+
res.end(JSON.stringify(body))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 归档总量:索引里的行数与字节数(只统计,不清理)。 */
|
|
50
|
+
function stripBom(s) {
|
|
51
|
+
return String(s ?? '').replace(/^\uFEFF/, '')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function archiveTotals(dir) {
|
|
55
|
+
const idx = path.join(dir, 'index.jsonl')
|
|
56
|
+
if (!fs.existsSync(idx)) return { count: 0, bytes: 0 }
|
|
57
|
+
let count = 0
|
|
58
|
+
let bytes = 0
|
|
59
|
+
for (const line of stripBom(fs.readFileSync(idx, 'utf8')).split('\n')) {
|
|
60
|
+
if (!line.trim()) continue
|
|
61
|
+
try {
|
|
62
|
+
const r = JSON.parse(line)
|
|
63
|
+
count += 1
|
|
64
|
+
bytes += Number(r.bytes) || 0
|
|
65
|
+
} catch { /* ignore */ }
|
|
66
|
+
}
|
|
67
|
+
return { count, bytes }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 最近 20 条归档索引。 */
|
|
71
|
+
function recentArchives(dir, limit = 20) {
|
|
72
|
+
const idx = path.join(dir, 'index.jsonl')
|
|
73
|
+
if (!fs.existsSync(idx)) return []
|
|
74
|
+
const lines = stripBom(fs.readFileSync(idx, 'utf8')).trim().split('\n').slice(-limit)
|
|
75
|
+
const out = []
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
try { out.push(JSON.parse(line)) } catch { /* ignore */ }
|
|
78
|
+
}
|
|
79
|
+
return out.reverse()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* /api/memory-palace
|
|
84
|
+
* GET ?sessionId=... 读状态 + 最近归档
|
|
85
|
+
* POST { sessionId, action, values } 切开关 / 改阈值
|
|
86
|
+
*/
|
|
87
|
+
export function createMemoryPalaceRoute({ ctx, read, resolveConfig, readState, writeState, effectiveWindow, turnStats, archive, requestArchive, ns, getEvents, gateState, logScan, log }) {
|
|
88
|
+
return async function handler(req, res) {
|
|
89
|
+
try {
|
|
90
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
91
|
+
const sessionId = url.searchParams.get('sessionId') ?? ''
|
|
92
|
+
// 两段式:默认返回“轻量视图”(不含统计、不解码日志),页签先秒开;
|
|
93
|
+
// 客户端随后用 stats=1 再请求一次异步补统计。
|
|
94
|
+
const wantsStats = url.searchParams.get('stats') === '1'
|
|
95
|
+
if (req.method === 'GET') {
|
|
96
|
+
const target = await resolveTarget(ctx, sessionId, logScan)
|
|
97
|
+
const { agent, cwd } = target
|
|
98
|
+
if (!cwd) return json(res, 404, { error: 'session not found' })
|
|
99
|
+
const cfg = resolveConfig(read, cwd, sessionId)
|
|
100
|
+
const nsDir = cfg.dir || path.join(cwd, cfg.rootDirName)
|
|
101
|
+
const win = wantsStats
|
|
102
|
+
? ((agent && effectiveWindow ? await effectiveWindow(agent, cfg) : target.win) || cfg.contextWindowTokens)
|
|
103
|
+
: cfg.contextWindowTokens
|
|
104
|
+
const state = readState(cwd, cfg.rootDirName, sessionId)
|
|
105
|
+
const s = state[sessionId] ?? {}
|
|
106
|
+
const stats = wantsStats && turnStats ? await turnStats(sessionId, cwd, cfg) : null
|
|
107
|
+
return json(res, 200, {
|
|
108
|
+
partial: !wantsStats,
|
|
109
|
+
workspace: cwd,
|
|
110
|
+
sessionId,
|
|
111
|
+
name: cfg.name || '',
|
|
112
|
+
folder: cfg.folder,
|
|
113
|
+
nsLabel: `${cfg.rootDirName}/${cfg.folder}`,
|
|
114
|
+
enabled: cfg.enabled,
|
|
115
|
+
sessionEnabled: cfg.sessionEnabled,
|
|
116
|
+
active: Boolean(cfg.enabled && cfg.sessionEnabled && cfg.name),
|
|
117
|
+
triggerPressure: cfg.triggerPressure,
|
|
118
|
+
contextWindowTokens: win,
|
|
119
|
+
contextWindowSource: 'auto',
|
|
120
|
+
checkEveryTurns: cfg.checkEveryTurns,
|
|
121
|
+
minBatchTurns: cfg.minBatchTurns,
|
|
122
|
+
retainBytes: cfg.retainBytes,
|
|
123
|
+
retainFraction: cfg.retainFraction,
|
|
124
|
+
batchByteFraction: cfg.batchByteFraction,
|
|
125
|
+
retentionCapBytes: Math.round((win || 0) * (Number(cfg.retainFraction) || 0) * 3),
|
|
126
|
+
retentionCapTokens: Math.round((win || 0) * (Number(cfg.retainFraction) || 0)),
|
|
127
|
+
maxFileBytes: cfg.maxFileBytes,
|
|
128
|
+
selfNameSlug: cfg.selfNameSlug,
|
|
129
|
+
enableSteer: cfg.enableSteer,
|
|
130
|
+
includeReasoning: cfg.includeReasoning,
|
|
131
|
+
compactAfterArchive: cfg.compactAfterArchive === true,
|
|
132
|
+
searchTurnLimit: cfg.searchTurnLimit,
|
|
133
|
+
groupByTurn: cfg.groupByTurn !== false,
|
|
134
|
+
injectNotice: cfg.injectNotice !== false,
|
|
135
|
+
recallStrength: cfg.recallStrength,
|
|
136
|
+
recursionMaxDepth: cfg.recursionMaxDepth,
|
|
137
|
+
recursionMaxWidth: cfg.recursionMaxWidth,
|
|
138
|
+
recallBudgetRounds: cfg.recallBudgetRounds,
|
|
139
|
+
recallBudgetKeywords: cfg.recallBudgetKeywords,
|
|
140
|
+
recallPolicyText: (() => {
|
|
141
|
+
const d = Math.min(Math.max(Number(cfg.recursionMaxDepth) || 1, 1), 5)
|
|
142
|
+
const w = Math.min(Math.max(Number(cfg.recursionMaxWidth) || 1, 1), 5)
|
|
143
|
+
switch (cfg.recallStrength) {
|
|
144
|
+
case 'off': return '只归档,不读记忆;不注入任何回忆提示'
|
|
145
|
+
case 'medium': return '摘要→检索→原文;启动后首条消息先读最近摘要;不递归'
|
|
146
|
+
case 'high': return `同中,允许主动递归(≤${d} 轮、每轮 ≤${w} 个关键词)`
|
|
147
|
+
case 'forced': return `每条消息后强制检索并简短回报;无归档则如实说没有记录(递归 ≤${d} 轮、每轮 ≤${w} 个关键词)`
|
|
148
|
+
default: return '先读摘要,必要时再读原文;不递归、无额外注入'
|
|
149
|
+
}
|
|
150
|
+
})(),
|
|
151
|
+
enableSteer: cfg.enableSteer !== false,
|
|
152
|
+
events: typeof getEvents === 'function' ? getEvents() : [],
|
|
153
|
+
gate: typeof gateState === 'function' ? gateState(cfg) : null,
|
|
154
|
+
rootDirName: cfg.rootDirName,
|
|
155
|
+
lastCheckTurn: s.lastCheckTurn ?? null,
|
|
156
|
+
lastCheckAt: s.lastCheckAt ?? null,
|
|
157
|
+
lastPressure: s.lastPressure ?? null,
|
|
158
|
+
lastPressureSource: s.lastPressureSource ?? null,
|
|
159
|
+
lastArchiveRange: s.lastArchiveRange ?? null,
|
|
160
|
+
lastArchiveAt: s.lastArchiveAt ?? null,
|
|
161
|
+
archivedThroughTurn: s.archivedThroughTurn ?? 0,
|
|
162
|
+
stats,
|
|
163
|
+
archives: recentArchives(nsDir),
|
|
164
|
+
totals: archiveTotals(nsDir),
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
if (req.method === 'POST') {
|
|
168
|
+
const body = await readBody(req)
|
|
169
|
+
const sid = String(body.sessionId ?? '')
|
|
170
|
+
const { cwd } = await resolveTarget(ctx, sid, logScan)
|
|
171
|
+
if (!cwd) return json(res, 404, { error: 'session not found' })
|
|
172
|
+
const cfg = resolveConfig(read, cwd, sid)
|
|
173
|
+
const nsDir0 = cfg.dir || path.join(cwd, cfg.rootDirName)
|
|
174
|
+
if (body.action === 'archive') {
|
|
175
|
+
const cat = String(body.category ?? '').trim()
|
|
176
|
+
if (!cat) {
|
|
177
|
+
if (typeof requestArchive !== 'function') return json(res, 400, { error: '手动归档不可用' })
|
|
178
|
+
const text = await requestArchive({ cwd, sessionId: sid })
|
|
179
|
+
return json(res, 200, { ok: true, message: String(text ?? '') })
|
|
180
|
+
}
|
|
181
|
+
if (typeof archive !== 'function') return json(res, 400, { error: '归档执行器不可用' })
|
|
182
|
+
if (!/^[a-z][a-z0-9-]{1,63}$/.test(cat)) return json(res, 400, { error: '类别必须是英文小写 slug' })
|
|
183
|
+
const text = await archive({
|
|
184
|
+
cwd, sessionId: sid, category: cat,
|
|
185
|
+
fromTurn: Number.isFinite(body.fromTurn) ? Number(body.fromTurn) : undefined,
|
|
186
|
+
toTurn: Number.isFinite(body.toTurn) ? Number(body.toTurn) : undefined,
|
|
187
|
+
})
|
|
188
|
+
return json(res, 200, { ok: true, message: String(text ?? '') })
|
|
189
|
+
}
|
|
190
|
+
const file = path.join(nsDir0, 'config.json')
|
|
191
|
+
let prev = {}
|
|
192
|
+
try { prev = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { prev = {} }
|
|
193
|
+
let pendingName
|
|
194
|
+
|
|
195
|
+
if (body.action === 'on' || body.action === 'off') {
|
|
196
|
+
prev.enabled = body.action === 'on'
|
|
197
|
+
} else if (body.action === 'set') {
|
|
198
|
+
const values = body.values ?? {}
|
|
199
|
+
const numeric = ['retainBytes', 'maxFileBytes', 'checkEveryTurns', 'minBatchTurns']
|
|
200
|
+
for (const [k, v] of Object.entries(values)) {
|
|
201
|
+
// 废弃字段:旧页面仍可能带着它提交,静默忽略,不报错、也不写回。
|
|
202
|
+
if (k === 'retainTurns') continue
|
|
203
|
+
if (k === 'name') { pendingName = String(v ?? ''); continue }
|
|
204
|
+
if (k === 'enabled' || k === 'enableSteer' || k === 'includeReasoning' || k === 'compactAfterArchive' || k === 'injectNotice' || k === 'groupByTurn') {
|
|
205
|
+
prev[k] = Boolean(v)
|
|
206
|
+
} else if (k === 'searchTurnLimit') {
|
|
207
|
+
const n = Number(v)
|
|
208
|
+
if (!Number.isInteger(n) || n < 1 || n > 30) throw new Error('searchTurnLimit 必须是 1–30 的整数')
|
|
209
|
+
prev[k] = n
|
|
210
|
+
} else if (k === 'recallStrength') {
|
|
211
|
+
const allowed = ['off', 'low', 'medium', 'high', 'forced']
|
|
212
|
+
const s = String(v)
|
|
213
|
+
if (!allowed.includes(s)) throw new Error('recallStrength 必须是 off / low / medium / high / forced')
|
|
214
|
+
prev[k] = s
|
|
215
|
+
} else if (k === 'recursionMaxDepth' || k === 'recursionMaxWidth') {
|
|
216
|
+
const n = Number(v)
|
|
217
|
+
if (!Number.isInteger(n) || n < 1 || n > 5) throw new Error(`${k} 必须是 1–5 的整数`)
|
|
218
|
+
prev[k] = n
|
|
219
|
+
} else if (k === 'retainFraction') {
|
|
220
|
+
const n = Number(v)
|
|
221
|
+
if (!(n >= 0.02 && n <= 0.5)) throw new Error('retainFraction 必须在 0.02–0.5')
|
|
222
|
+
prev[k] = n
|
|
223
|
+
} else if (k === 'batchByteFraction') {
|
|
224
|
+
const n = Number(v)
|
|
225
|
+
if (!(n >= 0.05 && n <= 1)) throw new Error('batchByteFraction 必须在 0.05–1')
|
|
226
|
+
prev[k] = n
|
|
227
|
+
} else if (k === 'triggerPressure') {
|
|
228
|
+
const n = Number(v)
|
|
229
|
+
if (!(n >= 0.2 && n <= 0.95)) throw new Error('triggerPressure 必须在 0.2–0.95')
|
|
230
|
+
prev[k] = n
|
|
231
|
+
} else if (k === 'selfNameSlug') {
|
|
232
|
+
const sv = String(v)
|
|
233
|
+
if (!/^[a-z][a-z0-9-]{1,63}$/.test(sv)) throw new Error('selfNameSlug 必须是英文小写 slug')
|
|
234
|
+
prev[k] = sv
|
|
235
|
+
} else if (k === 'contextWindowTokens') {
|
|
236
|
+
throw new Error(k + ' 由插件固定/自动探测,不允许手动修改。')
|
|
237
|
+
} else if (numeric.includes(k)) {
|
|
238
|
+
const n = Number(v)
|
|
239
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`${k} 必须是非负数字`)
|
|
240
|
+
prev[k] = Math.floor(n)
|
|
241
|
+
} else {
|
|
242
|
+
throw new Error(`不允许修改的键:${k}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
return json(res, 400, { error: 'unknown action' })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 废弃键:任何一次写配置都顺手清掉,旧配置会自然收敛。
|
|
250
|
+
delete prev.retainTurns
|
|
251
|
+
|
|
252
|
+
// 命名:新名字 = 文件夹名;空名字 = 用会话ID。改名要防抢占。
|
|
253
|
+
let dir = nsDir0
|
|
254
|
+
let folder = cfg.folder
|
|
255
|
+
if (pendingName !== undefined) {
|
|
256
|
+
if (!ns || typeof ns.sanitizeName !== 'function') throw new Error('命名功能不可用')
|
|
257
|
+
const checked = ns.sanitizeName(pendingName)
|
|
258
|
+
if (!checked.ok) throw new Error(checked.error)
|
|
259
|
+
const nextFolder = checked.name || sid
|
|
260
|
+
const target = path.join(cwd, cfg.rootDirName, nextFolder)
|
|
261
|
+
const map = ns.loadNsMap(cwd, cfg.rootDirName)
|
|
262
|
+
for (const [otherSid, otherFolder] of Object.entries(map)) {
|
|
263
|
+
if (otherSid === sid) continue
|
|
264
|
+
if (String(otherFolder).toLowerCase() === nextFolder.toLowerCase()) {
|
|
265
|
+
throw new Error(`名字「${nextFolder}」已被本工作区另一个会话占用,换一个`)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (nextFolder !== cfg.folder) {
|
|
269
|
+
if (fs.existsSync(target)) throw new Error(`文件夹「${nextFolder}」已存在(可能是别的会话留下的),换一个名字`)
|
|
270
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
271
|
+
if (fs.existsSync(dir)) fs.renameSync(dir, target)
|
|
272
|
+
else fs.mkdirSync(target, { recursive: true })
|
|
273
|
+
map[sid] = nextFolder
|
|
274
|
+
ns.saveNsMap(cwd, cfg.rootDirName, map)
|
|
275
|
+
dir = target
|
|
276
|
+
folder = nextFolder
|
|
277
|
+
}
|
|
278
|
+
prev.name = checked.name
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
282
|
+
const filePath = path.join(dir, 'config.json')
|
|
283
|
+
const tmp = `${filePath}.tmp-${process.pid}`
|
|
284
|
+
fs.writeFileSync(tmp, JSON.stringify(prev, null, 2), 'utf8')
|
|
285
|
+
fs.renameSync(tmp, filePath)
|
|
286
|
+
return json(res, 200, { ok: true, workspace: cwd, name: prev.name ?? '', folder, config: prev })
|
|
287
|
+
}
|
|
288
|
+
return json(res, 405, { error: 'method not allowed' })
|
|
289
|
+
} catch (err) {
|
|
290
|
+
log?.('route error: ' + (err?.message ?? String(err)))
|
|
291
|
+
return json(res, 400, { error: String(err?.message ?? err) })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const MEMORY_PALACE_ROUTE = '/memory-palace-api'
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dafantong01/dsh-memory-palace",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "记忆回廊:会话到达阈值时,把近期保留段之前的整轮原文按分类归档到当前工作区,供后续会话按需检索。",
|
|
5
|
+
"author": "DaFanTong",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js",
|
|
10
|
+
"./client": "./lib/client.js",
|
|
11
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
15
|
+
"dsh": ">=0.2.1-rc.2 <0.3.0-0"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"dsh",
|
|
25
|
+
"dsh-plugin",
|
|
26
|
+
"deepseek-harness",
|
|
27
|
+
"deepseek-harness-plugin",
|
|
28
|
+
"memory",
|
|
29
|
+
"archive",
|
|
30
|
+
"session-archive",
|
|
31
|
+
"context-compaction",
|
|
32
|
+
"long-term-memory",
|
|
33
|
+
"local-first"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/dafantong01/dsh-memory-palace.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/dafantong01/dsh-memory-palace#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/dafantong01/dsh-memory-palace/issues"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
46
|
+
"fzstd": "^0.1.1"
|
|
47
|
+
},
|
|
48
|
+
"dsh": {
|
|
49
|
+
"bundle": {
|
|
50
|
+
"patch": "./cordis.patch.yml"
|
|
51
|
+
},
|
|
52
|
+
"client": {
|
|
53
|
+
"platform": "web",
|
|
54
|
+
"inject": []
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|