dsh-plugin-xujie-memory 0.0.0-stage → 0.5.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +223 -2
  4. package/README_EN.md +92 -0
  5. package/cordis.patch.yml +49 -0
  6. package/lib/index.js +483 -0
  7. package/package.json +31 -4
  8. package/python/specs/characters/aloof.json +14 -0
  9. package/python/specs/characters/gentle.json +14 -0
  10. package/python/specs/characters/tsundere.json +14 -0
  11. package/python/specs/debt.v1.json +31 -0
  12. package/python/specs/nickname.v1.json +16 -0
  13. package/python/specs/preference.v1.json +16 -0
  14. package/python/xujie_memory/__init__.py +123 -0
  15. package/python/xujie_memory/admission.py +211 -0
  16. package/python/xujie_memory/audit.py +377 -0
  17. package/python/xujie_memory/benchmark.py +281 -0
  18. package/python/xujie_memory/character.py +199 -0
  19. package/python/xujie_memory/console.py +41 -0
  20. package/python/xujie_memory/consolidate.py +356 -0
  21. package/python/xujie_memory/decay.py +483 -0
  22. package/python/xujie_memory/detect.py +1280 -0
  23. package/python/xujie_memory/due.py +121 -0
  24. package/python/xujie_memory/emotion.py +573 -0
  25. package/python/xujie_memory/encoder.py +1205 -0
  26. package/python/xujie_memory/engine.py +1312 -0
  27. package/python/xujie_memory/errors.py +98 -0
  28. package/python/xujie_memory/gate.py +448 -0
  29. package/python/xujie_memory/importance.py +104 -0
  30. package/python/xujie_memory/ingest.py +2490 -0
  31. package/python/xujie_memory/judge.py +350 -0
  32. package/python/xujie_memory/memory.py +222 -0
  33. package/python/xujie_memory/orchestrator.py +666 -0
  34. package/python/xujie_memory/phrasing.py +292 -0
  35. package/python/xujie_memory/profile.py +689 -0
  36. package/python/xujie_memory/recall.py +431 -0
  37. package/python/xujie_memory/schema.sql +480 -0
  38. package/python/xujie_memory/server.py +560 -0
  39. package/python/xujie_memory/specs.py +170 -0
  40. package/python/xujie_memory/summary.py +238 -0
  41. package/runtime/win32-x64/LICENSE.txt +702 -0
  42. package/runtime/win32-x64/_asyncio.pyd +0 -0
  43. package/runtime/win32-x64/_bz2.pyd +0 -0
  44. package/runtime/win32-x64/_ctypes.pyd +0 -0
  45. package/runtime/win32-x64/_decimal.pyd +0 -0
  46. package/runtime/win32-x64/_elementtree.pyd +0 -0
  47. package/runtime/win32-x64/_hashlib.pyd +0 -0
  48. package/runtime/win32-x64/_lzma.pyd +0 -0
  49. package/runtime/win32-x64/_msi.pyd +0 -0
  50. package/runtime/win32-x64/_multiprocessing.pyd +0 -0
  51. package/runtime/win32-x64/_overlapped.pyd +0 -0
  52. package/runtime/win32-x64/_queue.pyd +0 -0
  53. package/runtime/win32-x64/_socket.pyd +0 -0
  54. package/runtime/win32-x64/_sqlite3.pyd +0 -0
  55. package/runtime/win32-x64/_ssl.pyd +0 -0
  56. package/runtime/win32-x64/_uuid.pyd +0 -0
  57. package/runtime/win32-x64/_wmi.pyd +0 -0
  58. package/runtime/win32-x64/_zoneinfo.pyd +0 -0
  59. package/runtime/win32-x64/libcrypto-3.dll +0 -0
  60. package/runtime/win32-x64/libffi-8.dll +0 -0
  61. package/runtime/win32-x64/libssl-3.dll +0 -0
  62. package/runtime/win32-x64/pyexpat.pyd +0 -0
  63. package/runtime/win32-x64/python.cat +0 -0
  64. package/runtime/win32-x64/python.exe +0 -0
  65. package/runtime/win32-x64/python3.dll +0 -0
  66. package/runtime/win32-x64/python312._pth +6 -0
  67. package/runtime/win32-x64/python312.dll +0 -0
  68. package/runtime/win32-x64/python312.zip +0 -0
  69. package/runtime/win32-x64/pythonw.exe +0 -0
  70. package/runtime/win32-x64/select.pyd +0 -0
  71. package/runtime/win32-x64/sqlite3.dll +0 -0
  72. package/runtime/win32-x64/unicodedata.pyd +0 -0
  73. package/runtime/win32-x64/vcruntime140.dll +0 -0
  74. package/runtime/win32-x64/vcruntime140_1.dll +0 -0
  75. package/runtime/win32-x64/winsound.pyd +0 -0
package/lib/index.js ADDED
@@ -0,0 +1,483 @@
1
+ /**
2
+ * dsh-plugin-xujie-memory — 每轮自动记忆,模型不用问。
3
+ *
4
+ * 设计要点:
5
+ * 1. 内核(Python)和解释器都**跟着插件走**:装完即用,不装 Python、
6
+ * 不 pip install、不填 repoPath。
7
+ * 2. `systemPrompt.section` 同步读缓存,`session/event` 异步写记忆。
8
+ * 3. 任何失败都降级为「这一轮没有记忆」,绝不影响对话。
9
+ *
10
+ * v0.5 变化(稳定性):
11
+ * - 内核有**看门狗**:崩了、被别的实例带走了,都会按退避自动拉起。
12
+ * - /health 核对**版本和库路径**:端口上是旧版/别人的内核就换端口,不再误连。
13
+ * - 内核带 `--parent-pid`:宿主崩溃也不会留下占端口的孤儿进程。
14
+ * - 内核日志写进 `~/.xujie-memory/kernel.log`(旧版 stdio:'ignore' 全丢)。
15
+ * - 请求带 token(`~/.xujie-memory/token`),本机网页无法往记忆里写东西。
16
+ * - 同一会话的请求按顺序串行,快速连发两句不会乱序。
17
+ * - 默认 `scope: user`:记忆跨会话有效;`scope: session` 恢复旧行为。
18
+ *
19
+ * 事件名已按真机核对:
20
+ * user/message 用户发言(真数据在 event.data.content 的 text 块里)
21
+ * 其余类型、以及 source.kind 不是 user 的 user/message → 跳过
22
+ */
23
+
24
+ import { spawn } from 'node:child_process'
25
+ import { appendFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, statSync, renameSync } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import { dirname, join, resolve } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ export const name = 'dsh-plugin-xujie-memory'
31
+ export const inject = ['systemPrompt']
32
+
33
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
34
+ const BUNDLED_KERNEL = join(PACKAGE_ROOT, 'python')
35
+ const DEFAULT_ENDPOINT = 'http://127.0.0.1:8765'
36
+ const TURN_TIMEOUT_MS = 4000
37
+ const HEALTH_TIMEOUT_MS = 800
38
+ const READY_WAIT_MS = 10000
39
+ const WATCHDOG_MS = 5000
40
+ const PORT_TRIES = 10
41
+ const MAX_TEXT_CHARS = 2000
42
+ const MAX_SESSIONS = 32
43
+ const LOG_ROTATE_BYTES = 2 * 1024 * 1024
44
+
45
+ /** 每个会话最近一轮的材料。 */
46
+ const contexts = new Map()
47
+ /** 每个会话正在进行的 /turn(串行队列的尾巴)。 */
48
+ const pending = new Map()
49
+ let lastContext = ''
50
+ let shapeLogged = false
51
+
52
+ export function apply(ctx, config = {}) {
53
+ const configured = new URL((config.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, ''))
54
+ const host = configured.hostname || '127.0.0.1'
55
+ const basePort = Number(configured.port || 8765)
56
+ const autoStart = config.autoStart !== false
57
+ const maxAskedTotal = Number.isFinite(Number(config.maxAskedTotal)) ? Number(config.maxAskedTotal) : 6
58
+ const scope = config.scope === 'session' ? 'session' : 'user'
59
+ const kernelRoot = resolveKernelRoot(config.repoPath)
60
+ const python = resolvePython(config.pythonPath)
61
+ const dbPath = resolve(str(config.dbPath) || join(homedir(), '.xujie-memory', 'memory.db'))
62
+ const dataDir = dirname(dbPath)
63
+ const tokenFile = str(config.tokenFile) || join(dataDir, 'token')
64
+ const logFile = str(config.logFile) || join(dataDir, 'kernel.log')
65
+ const statusFile = str(config.statusFile)
66
+ const expectedVersion = kernelVersion(kernelRoot)
67
+ // 宿主若会 await section.text 返回的 Promise,可以打开它:组装时等这一轮的
68
+ // /turn(最多 waitMs)。宿主不支持时会把 Promise 当字符串,**默认关闭**。
69
+ const asyncSection = config.asyncSection === true
70
+ const waitMs = Number.isFinite(Number(config.waitMs)) ? Number(config.waitMs) : 400
71
+
72
+ /** 当前在用的内核地址(可能因端口被占而换过)。 */
73
+ let endpoint = `http://${host}:${basePort}`
74
+ let token = readToken(tokenFile)
75
+
76
+ const log = (level, message) => {
77
+ const logger = ctx.logger
78
+ if (logger && typeof logger[level] === 'function') logger[level](`xujie-memory: ${message}`)
79
+ if (statusFile && level !== 'debug') {
80
+ try {
81
+ appendFileSync(statusFile, `${new Date().toISOString()} ${level} ${message}\n`, 'utf8')
82
+ } catch { /* 诊断文件写不进去不影响主流程 */ }
83
+ }
84
+ }
85
+
86
+ // ------------------------------------------------------------ 内核
87
+ if (autoStart) {
88
+ ctx.effect(() => {
89
+ let child = null
90
+ let stopped = false
91
+ let booting = null
92
+ let failures = 0
93
+ let nextAttemptAt = 0
94
+
95
+ /** 端口上这个内核能不能用:版本对、库对、token 对。 */
96
+ const probe = async (url) => {
97
+ const body = await health(url, token)
98
+ if (!body) return 'down'
99
+ if (expectedVersion && body.version !== expectedVersion) return 'foreign'
100
+ if (body.auth && !body.db) return 'foreign' // token 不对
101
+ if (body.db && resolve(body.db) !== dbPath) return 'foreign'
102
+ if (body.scope_mode && body.scope_mode !== scope) return 'foreign'
103
+ return 'ok'
104
+ }
105
+
106
+ const spawnKernel = (port) => {
107
+ mkdirSync(dataDir, { recursive: true })
108
+ rotateLog(logFile)
109
+ let fd = 'ignore'
110
+ try { fd = openSync(logFile, 'a') } catch { fd = 'ignore' }
111
+ const args = [
112
+ '-m', 'xujie_memory.server',
113
+ '--host', host, '--port', String(port),
114
+ '--db', dbPath,
115
+ '--token-file', tokenFile,
116
+ '--scope', scope,
117
+ '--parent-pid', String(process.pid),
118
+ ]
119
+ const proc = spawn(python, args, {
120
+ cwd: kernelRoot,
121
+ stdio: ['ignore', fd, fd],
122
+ windowsHide: true,
123
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONDONTWRITEBYTECODE: '1' },
124
+ })
125
+ if (typeof fd === 'number') { try { closeSync(fd) } catch { /* ignore */ } }
126
+ proc.on?.('error', (err) => log('warn', `内核启动失败:${err.message}`))
127
+ proc.on?.('exit', (code, signal) => {
128
+ if (child === proc) child = null
129
+ if (!stopped) log('warn', `内核退出(code=${code ?? 'null'} signal=${signal ?? 'null'}),看门狗会重拉;详情见 ${logFile}`)
130
+ })
131
+ return proc
132
+ }
133
+
134
+ const boot = async () => {
135
+ if (!kernelRoot) {
136
+ log('warn', '找不到记忆内核(python/xujie_memory 不存在),需要在配置里给 repoPath')
137
+ return
138
+ }
139
+ // 1. 找一个能用的端口:已经有对的内核就复用,被别人占了就往后挪。
140
+ for (let i = 0; i < PORT_TRIES && !stopped; i++) {
141
+ const port = basePort + i
142
+ const url = `http://${host}:${port}`
143
+ const state = await probe(url)
144
+ if (state === 'ok') {
145
+ endpoint = url
146
+ failures = 0
147
+ log('info', `复用已在运行的内核:${url}`)
148
+ return
149
+ }
150
+ if (state === 'foreign') {
151
+ log('warn', `端口 ${port} 上的服务不是本版本内核(或库/鉴权不一致),换下一个端口`)
152
+ continue
153
+ }
154
+ // 2. 空端口:拉起自己的内核。
155
+ if (child) { try { child.kill() } catch { /* ignore */ } }
156
+ try {
157
+ child = spawnKernel(port)
158
+ } catch (err) {
159
+ log('warn', `内核没能拉起:${err.message}`)
160
+ return markFailure()
161
+ }
162
+ log('info', `内核启动中(pid ${child.pid ?? '?'},port ${port},python=${python})`)
163
+ const startedAt = Date.now()
164
+ while (!stopped && Date.now() - startedAt < READY_WAIT_MS) {
165
+ await sleep(150)
166
+ token = readToken(tokenFile) || token
167
+ const s = await probe(url)
168
+ if (s === 'ok') {
169
+ endpoint = url
170
+ failures = 0
171
+ log('info', `内核就绪(${Date.now() - startedAt}ms):${url}`)
172
+ return
173
+ }
174
+ if (!child) break // 进程已经退出了
175
+ }
176
+ if (!stopped) log('warn', `${READY_WAIT_MS}ms 内内核未就绪(对话不受影响);详情见 ${logFile}`)
177
+ return markFailure()
178
+ }
179
+ if (!stopped) log('warn', `端口 ${basePort}-${basePort + PORT_TRIES - 1} 都不可用`)
180
+ return markFailure()
181
+ }
182
+
183
+ const markFailure = () => {
184
+ failures += 1
185
+ // 退避:2s, 4s, 8s … 封顶 5 分钟。不会因为环境坏了就疯狂重启。
186
+ const delay = Math.min(300000, 2000 * 2 ** Math.min(failures - 1, 8))
187
+ nextAttemptAt = Date.now() + delay
188
+ }
189
+
190
+ const ensure = () => {
191
+ if (stopped || booting || Date.now() < nextAttemptAt) return booting
192
+ booting = boot().catch((err) => {
193
+ log('warn', `内核引导异常:${err?.message ?? err}`)
194
+ markFailure()
195
+ }).finally(() => { booting = null })
196
+ return booting
197
+ }
198
+
199
+ kernelReady = async () => { await ensure() }
200
+ void ensure()
201
+
202
+ // 看门狗:崩溃、被别的实例带走、被手动关掉,都会被拉回来。
203
+ const timer = setInterval(async () => {
204
+ if (stopped || booting) return
205
+ if ((await probe(endpoint)) !== 'ok') {
206
+ log('warn', `内核不可用(${endpoint}),尝试恢复`)
207
+ void ensure()
208
+ }
209
+ }, WATCHDOG_MS)
210
+ timer.unref?.()
211
+
212
+ return () => {
213
+ stopped = true
214
+ clearInterval(timer)
215
+ kernelReady = null
216
+ try { child?.kill() } catch { /* 已经退出 */ }
217
+ }
218
+ }, 'xujie-memory: python service')
219
+ } else {
220
+ log('info', `autoStart=false,等待外部内核:${endpoint}`)
221
+ }
222
+
223
+ // ------------------------------------------------------------ 注入材料
224
+ ctx.inject(['systemPrompt'], (prompted) => {
225
+ const service = prompted.get('systemPrompt')
226
+ if (!service || typeof service.section !== 'function') {
227
+ log('warn', 'systemPrompt 没有 section(),这个宿主版本可能不兼容')
228
+ return
229
+ }
230
+ const pick = (assemble) => {
231
+ const raw = assemble?.agent?.session
232
+ if (raw) {
233
+ const got = contexts.get(sessionKey(raw))
234
+ if (got !== undefined) return got
235
+ }
236
+ // 没有这个会话的材料(新会话第一句之前 / 宿主内部组装):
237
+ // user 模式下记忆本来就跨会话共享,用最近一次的材料;
238
+ // session 模式下绝不串会话,返回空。
239
+ return scope === 'user' ? lastContext : ''
240
+ }
241
+ service.section({
242
+ name: 'xujie-memory',
243
+ order: 150,
244
+ text: (assemble) => {
245
+ const key = sessionKey(assemble?.agent?.session)
246
+ const inflight = pending.get(key)
247
+ if (!asyncSection) {
248
+ if (inflight) log('debug', `组装时 ${key} 的本轮记忆还没回来,用的是上一轮材料`)
249
+ return pick(assemble)
250
+ }
251
+ if (!inflight) return pick(assemble)
252
+ return Promise.race([inflight, sleep(waitMs)]).then(() => pick(assemble))
253
+ },
254
+ })
255
+ })
256
+
257
+ // ------------------------------------------------------------ 写记忆
258
+ ctx.on('session/event', (session, event) => {
259
+ const type = event?.type
260
+ if (type !== 'user/message') return
261
+
262
+ const data = event?.data
263
+ const sourceKind = data?.source?.kind
264
+ // runtime-context / agent-instructions 也是 user/message,但它们不是用户说的。
265
+ if (typeof sourceKind === 'string' && sourceKind !== 'user') {
266
+ log('debug', `event ${type}(source=${sourceKind},跳过)`)
267
+ return
268
+ }
269
+
270
+ let text = extractText(data)
271
+ if (!text) {
272
+ log('warn', `event ${type} 里没找到正文,字段是:${Object.keys(data || {}).join(',')}`)
273
+ return
274
+ }
275
+ if (text.length > MAX_TEXT_CHARS) text = text.slice(0, MAX_TEXT_CHARS)
276
+
277
+ if (!shapeLogged) {
278
+ shapeLogged = true
279
+ log('info', `user/message 形状:${shapeOf(data)}`)
280
+ }
281
+
282
+ const key = sessionKey(session)
283
+ // 同一会话串行:上一句没写完,下一句排在后面,保证顺序。
284
+ const prev = pending.get(key) || Promise.resolve()
285
+ const job = prev
286
+ .catch(() => {})
287
+ .then(() => remember(() => endpoint, () => token, session, text, maxAskedTotal, log))
288
+ .finally(() => { if (pending.get(key) === job) pending.delete(key) })
289
+ pending.set(key, job)
290
+ })
291
+
292
+ log('info', `已挂载(scope=${scope},内核 ${expectedVersion || '?'},库 ${dbPath})`)
293
+ }
294
+
295
+ /** 测试/诊断用:等内核引导结束。 */
296
+ export let kernelReady = null
297
+
298
+ // ---------------------------------------------------------------- 路径
299
+
300
+ function str(v) {
301
+ return typeof v === 'string' ? v.trim() : ''
302
+ }
303
+
304
+ /** 优先用配置里的 repoPath(开发),否则用包里自带的 python/。 */
305
+ function resolveKernelRoot(repoPath) {
306
+ const configured = str(repoPath)
307
+ if (configured && existsSync(join(configured, 'xujie_memory'))) return configured
308
+ if (existsSync(join(BUNDLED_KERNEL, 'xujie_memory'))) return BUNDLED_KERNEL
309
+ return ''
310
+ }
311
+
312
+ /** 优先用配置,其次包内解释器,最后 PATH 里的 python。 */
313
+ function resolvePython(pythonPath) {
314
+ const configured = str(pythonPath)
315
+ if (configured) return configured
316
+ const platformDir = `${process.platform}-${process.arch}`
317
+ const bundled = process.platform === 'win32'
318
+ ? join(PACKAGE_ROOT, 'runtime', platformDir, 'python.exe')
319
+ : join(PACKAGE_ROOT, 'runtime', platformDir, 'bin', 'python3')
320
+ if (existsSync(bundled)) return bundled
321
+ return process.platform === 'win32' ? 'python' : 'python3'
322
+ }
323
+
324
+ /** 包内内核的版本号;/health 对不上就说明端口上是别的内核。 */
325
+ function kernelVersion(root) {
326
+ if (!root) return ''
327
+ try {
328
+ const src = readFileSync(join(root, 'xujie_memory', '__init__.py'), 'utf8')
329
+ return src.match(/__version__\s*=\s*["']([^"']+)["']/)?.[1] ?? ''
330
+ } catch {
331
+ return ''
332
+ }
333
+ }
334
+
335
+ function readToken(file) {
336
+ try { return readFileSync(file, 'utf8').trim() } catch { return '' }
337
+ }
338
+
339
+ function rotateLog(file) {
340
+ try {
341
+ if (statSync(file).size > LOG_ROTATE_BYTES) renameSync(file, `${file}.1`)
342
+ } catch { /* 不存在或改不了名都无所谓 */ }
343
+ }
344
+
345
+ // ---------------------------------------------------------------- 工具
346
+
347
+ function sleep(ms) {
348
+ return new Promise((r) => { const t = setTimeout(r, ms); t.unref?.() })
349
+ }
350
+
351
+ async function health(url, token) {
352
+ const controller = new AbortController()
353
+ const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS)
354
+ try {
355
+ const resp = await fetch(`${url}/health`, {
356
+ signal: controller.signal,
357
+ headers: token ? { 'X-Xujie-Token': token } : {},
358
+ })
359
+ if (!resp.ok) return null
360
+ const body = await resp.json().catch(() => null)
361
+ return body?.ok ? body : null
362
+ } catch {
363
+ return null
364
+ } finally {
365
+ clearTimeout(timer)
366
+ }
367
+ }
368
+
369
+ /** 结构摘要:只出字段名和类型,不出内容。 */
370
+ function shapeOf(value, depth = 0) {
371
+ if (value === null) return 'null'
372
+ if (Array.isArray(value)) {
373
+ return depth > 2 ? 'array' : `[${value.length ? shapeOf(value[0], depth + 1) : ''}]`
374
+ }
375
+ if (typeof value !== 'object') return typeof value
376
+ if (depth > 2) return 'object'
377
+ const keys = Object.keys(value).slice(0, 10)
378
+ return `{${keys.map((k) => `${k}: ${shapeOf(value[k], depth + 1)}`).join(', ')}}`.slice(0, 240)
379
+ }
380
+
381
+ /** 从事件 data 里掏正文:真机是 content 数组里的 text 块。 */
382
+ export function extractText(data) {
383
+ const candidates = [
384
+ data?.text,
385
+ data?.content,
386
+ data?.message?.content,
387
+ data?.message?.text,
388
+ data?.payload?.text,
389
+ data?.payload?.content,
390
+ ]
391
+ for (const c of candidates) {
392
+ if (typeof c === 'string' && c.trim()) return c.trim()
393
+ if (Array.isArray(c)) {
394
+ const joined = c
395
+ .map((part) => {
396
+ if (typeof part === 'string') return part
397
+ if (!part || typeof part !== 'object') return ''
398
+ return typeof part.text === 'string' ? part.text : ''
399
+ })
400
+ .filter(Boolean)
401
+ .join('\n')
402
+ .trim()
403
+ if (joined) return joined
404
+ }
405
+ }
406
+ return ''
407
+ }
408
+
409
+ function sessionKey(session) {
410
+ return String(session?.id ?? session?.sessionId ?? session?.key ?? 'default')
411
+ }
412
+
413
+ // ---------------------------------------------------------------- 请求
414
+
415
+ async function remember(getEndpoint, getToken, session, text, maxAskedTotal, log) {
416
+ const key = sessionKey(session)
417
+ const body = {
418
+ session: key,
419
+ user: String(session?.userId ?? session?.account ?? 'user'),
420
+ text,
421
+ // 对话的另一方是助手,不是 'user'——旧版写成 'user',
422
+ // 「我欠你 20」会记成"自己欠自己"。
423
+ context: { peer_id: 'assistant' },
424
+ budget: { max_asked_total: maxAskedTotal },
425
+ }
426
+
427
+ let lastErr = null
428
+ // 失败重试一次(内核刚被看门狗拉起时最常见)。
429
+ for (let attempt = 0; attempt < 2; attempt++) {
430
+ try {
431
+ const resp = await fetchJson(`${getEndpoint()}/turn`, body, TURN_TIMEOUT_MS, getToken())
432
+ if (resp?.ok && typeof resp.context === 'string') {
433
+ contexts.delete(key)
434
+ contexts.set(key, resp.context) // 重新插入 = 移到最新
435
+ while (contexts.size > MAX_SESSIONS) {
436
+ const oldest = contexts.keys().next().value
437
+ if (oldest === undefined) break
438
+ contexts.delete(oldest)
439
+ }
440
+ lastContext = resp.context
441
+ log('debug',
442
+ `记下 ${resp.outcomes?.length ?? 0} 条判定,材料 ${resp.context.length} 字,会话 ${key}`)
443
+ return
444
+ }
445
+ lastErr = new Error(resp?.detail ? `${resp.error}: ${resp.detail}` : (resp?.error ?? 'unknown'))
446
+ // 内核已经处理过这句(4xx/5xx 都是):重试可能写两遍,不重试。
447
+ // 401 例外:token 刚被新内核换掉,重读一次再试。
448
+ if (resp?.status !== 401) break
449
+ } catch (err) {
450
+ lastErr = err
451
+ // 超时 = 内核可能已经写进去了,重试会重复记一笔,不重试。
452
+ // 只有「连不上」(内核正在被看门狗重拉)才重试。
453
+ if (err?.name === 'AbortError' || attempt === 1) break
454
+ }
455
+ await sleep(500)
456
+ }
457
+ log('warn', `这一轮没有记忆:${lastErr?.message ?? 'unknown'}`)
458
+ }
459
+
460
+ async function fetchJson(url, payload, timeoutMs, token) {
461
+ const controller = new AbortController()
462
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
463
+ try {
464
+ const headers = { 'Content-Type': 'application/json' }
465
+ if (token) headers['X-Xujie-Token'] = token
466
+ const resp = await fetch(url, {
467
+ method: 'POST',
468
+ headers,
469
+ body: JSON.stringify(payload),
470
+ signal: controller.signal,
471
+ })
472
+ const text = await resp.text()
473
+ try {
474
+ const parsed = JSON.parse(text)
475
+ if (parsed && typeof parsed === 'object') parsed.status = resp.status
476
+ return parsed
477
+ } catch {
478
+ return { ok: false, status: resp.status, error: `bad_json: ${text.slice(0, 120)}` }
479
+ }
480
+ } finally {
481
+ clearTimeout(timer)
482
+ }
483
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "dsh-plugin-xujie-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "description": "Long-term memory for DeepSeek Harness: facts, anchors, decay and a person card, injected every turn without the model having to ask. Bundles the Python kernel and runtime — install and it works.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "license": "MIT",
8
+ "scripts": {
9
+ "test": "node --test --test-concurrency=1 test/*.test.mjs"
10
+ },
11
+ "files": [
12
+ "lib",
13
+ "python",
14
+ "runtime",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "README_EN.md",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "engines": {
21
+ "node": "^22.19 || >=24"
22
+ },
23
+ "keywords": [
24
+ "dsh-plugin",
25
+ "deepseek-harness",
26
+ "agent-memory"
27
+ ],
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "aloof",
3
+ "persona": "高冷",
4
+ "anger_sensitivity": 0.8,
5
+ "anger_half_life_days": 0.5,
6
+ "sadness_half_life_days": 5.0,
7
+ "trust_damage": 1.2,
8
+ "trust_repair": 1.0,
9
+ "speak_first": false,
10
+ "hold_words": false,
11
+ "demand_explanation": true,
12
+ "intensity": "normal",
13
+ "note": "不容易被激怒(0.8),但一旦掉信任就掉得更多(1.2)。不主动开口,也不需要嘴硬——隔阂本身就是态度。"
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "gentle",
3
+ "persona": "温柔",
4
+ "anger_sensitivity": 0.5,
5
+ "anger_half_life_days": 1.0,
6
+ "sadness_half_life_days": 2.0,
7
+ "trust_damage": 0.8,
8
+ "trust_repair": 1.3,
9
+ "speak_first": true,
10
+ "hold_words": false,
11
+ "demand_explanation": false,
12
+ "intensity": "soft",
13
+ "note": "同样被冒犯,气只有别人一半(0.5);修复时回得更多(1.3)。会主动开口把话说开。"
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "tsundere",
3
+ "persona": "傲娇",
4
+ "anger_sensitivity": 1.3,
5
+ "anger_half_life_days": 0.5,
6
+ "sadness_half_life_days": 3.0,
7
+ "trust_damage": 1.0,
8
+ "trust_repair": 0.8,
9
+ "speak_first": false,
10
+ "hold_words": true,
11
+ "demand_explanation": true,
12
+ "intensity": "sharp",
13
+ "note": "气来得快去得也快(半衰期半天),但嘴上不肯先软。信任修得慢——0.8 的修复乘数意味着同样的和解,她只回八成。"
14
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "relation": "debt",
3
+ "version": "1",
4
+ "risk_tier": "R2",
5
+ "required_slots": [
6
+ "debtor",
7
+ "creditor",
8
+ "item",
9
+ "quantity"
10
+ ],
11
+ "allowed_modalities": [
12
+ "asserted",
13
+ "request"
14
+ ],
15
+ "writers": [
16
+ "debtor",
17
+ "creditor"
18
+ ],
19
+ "effect_kind": "delta",
20
+ "admission_mode": "assisted",
21
+ "value_type": "number",
22
+ "unit": null,
23
+ "allow_negative": false,
24
+ "confirmation": "explicit",
25
+ "validity": "permanent",
26
+ "conflict_policy": "dispute",
27
+ "feeds": [],
28
+ "derived_from": [],
29
+ "note": "游戏内债务。建立用 set,偿还与免除用 delta(-)。余额不得为负;累计偿付量参与修正校验。unit 不是必填,也不固定在规格里:债务可以是米、书、金币,单位由对话决定(eval 时通过上下文 unit_hint 提供)。口语里「三十」不说单位,把它当必填只会把正常句子挂起。给了但与规格不符时,由内核判 disputed。 允许 request:免除在语用上多是请求(「给我免三碗」),不是陈述事实。请求仍走 explicit 确认。 【准入决策】永久 assisted,不是\"等指标变好再升\":(1)实测准确率约 48%(EXPERIMENT §36–39),(2)R2 的错误代价不对称——多问一句是打扰,少问一句是记错钱,(3)错误集中在\"承诺 vs 已发生\"和\"疑问句确认\"两类高频语用(XM-21/22),那是能力边界,不是调参能解决的。",
30
+ "conservation": "repayment"
31
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "relation": "nickname",
3
+ "version": "1",
4
+ "risk_tier": "R1",
5
+ "required_slots": ["subject", "value"],
6
+ "allowed_modalities": ["asserted"],
7
+ "writers": ["subject"],
8
+ "effect_kind": "set",
9
+ "admission_mode": "auto",
10
+ "value_type": "string",
11
+ "allow_negative": false,
12
+ "confirmation": "implicit",
13
+ "validity": "permanent",
14
+ "conflict_policy": "last_write_wins",
15
+ "note": "称呼属于身份设定。v0.52 起用户明说「叫我X」直接生效(宿主没有确认交互,挂起等于永远记不住);改口按 last_write_wins 覆盖。"
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "relation": "preference",
3
+ "version": "1",
4
+ "risk_tier": "R0",
5
+ "required_slots": ["subject", "object"],
6
+ "allowed_modalities": ["asserted"],
7
+ "writers": ["subject"],
8
+ "effect_kind": "set",
9
+ "admission_mode": "auto",
10
+ "value_type": "string",
11
+ "allow_negative": false,
12
+ "confirmation": "implicit",
13
+ "validity": "permanent",
14
+ "conflict_policy": "last_write_wins",
15
+ "note": "低影响偏好,错误代价低、可随时纠正。"
16
+ }