dsh-plugin-xujie-memory 0.0.0-stage → 0.6.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/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +248 -2
- package/README_EN.md +115 -0
- package/cordis.patch.yml +49 -0
- package/lib/index.js +604 -0
- package/package.json +31 -4
- package/python/specs/characters/aloof.json +14 -0
- package/python/specs/characters/gentle.json +14 -0
- package/python/specs/characters/tsundere.json +14 -0
- package/python/specs/debt.v1.json +31 -0
- package/python/specs/nickname.v1.json +16 -0
- package/python/specs/preference.v1.json +16 -0
- package/python/xujie_memory/__init__.py +123 -0
- package/python/xujie_memory/admission.py +211 -0
- package/python/xujie_memory/audit.py +377 -0
- package/python/xujie_memory/benchmark.py +281 -0
- package/python/xujie_memory/character.py +199 -0
- package/python/xujie_memory/console.py +41 -0
- package/python/xujie_memory/consolidate.py +356 -0
- package/python/xujie_memory/decay.py +483 -0
- package/python/xujie_memory/detect.py +1280 -0
- package/python/xujie_memory/due.py +121 -0
- package/python/xujie_memory/emotion.py +573 -0
- package/python/xujie_memory/encoder.py +1205 -0
- package/python/xujie_memory/engine.py +1312 -0
- package/python/xujie_memory/errors.py +98 -0
- package/python/xujie_memory/gate.py +448 -0
- package/python/xujie_memory/importance.py +104 -0
- package/python/xujie_memory/ingest.py +2490 -0
- package/python/xujie_memory/judge.py +350 -0
- package/python/xujie_memory/memory.py +277 -0
- package/python/xujie_memory/orchestrator.py +690 -0
- package/python/xujie_memory/phrasing.py +292 -0
- package/python/xujie_memory/profile.py +689 -0
- package/python/xujie_memory/recall.py +431 -0
- package/python/xujie_memory/schema.sql +480 -0
- package/python/xujie_memory/server.py +584 -0
- package/python/xujie_memory/specs.py +170 -0
- package/python/xujie_memory/summary.py +238 -0
- package/runtime/win32-x64/LICENSE.txt +702 -0
- package/runtime/win32-x64/_asyncio.pyd +0 -0
- package/runtime/win32-x64/_bz2.pyd +0 -0
- package/runtime/win32-x64/_ctypes.pyd +0 -0
- package/runtime/win32-x64/_decimal.pyd +0 -0
- package/runtime/win32-x64/_elementtree.pyd +0 -0
- package/runtime/win32-x64/_hashlib.pyd +0 -0
- package/runtime/win32-x64/_lzma.pyd +0 -0
- package/runtime/win32-x64/_msi.pyd +0 -0
- package/runtime/win32-x64/_multiprocessing.pyd +0 -0
- package/runtime/win32-x64/_overlapped.pyd +0 -0
- package/runtime/win32-x64/_queue.pyd +0 -0
- package/runtime/win32-x64/_socket.pyd +0 -0
- package/runtime/win32-x64/_sqlite3.pyd +0 -0
- package/runtime/win32-x64/_ssl.pyd +0 -0
- package/runtime/win32-x64/_uuid.pyd +0 -0
- package/runtime/win32-x64/_wmi.pyd +0 -0
- package/runtime/win32-x64/_zoneinfo.pyd +0 -0
- package/runtime/win32-x64/libcrypto-3.dll +0 -0
- package/runtime/win32-x64/libffi-8.dll +0 -0
- package/runtime/win32-x64/libssl-3.dll +0 -0
- package/runtime/win32-x64/pyexpat.pyd +0 -0
- package/runtime/win32-x64/python.cat +0 -0
- package/runtime/win32-x64/python.exe +0 -0
- package/runtime/win32-x64/python3.dll +0 -0
- package/runtime/win32-x64/python312._pth +6 -0
- package/runtime/win32-x64/python312.dll +0 -0
- package/runtime/win32-x64/python312.zip +0 -0
- package/runtime/win32-x64/pythonw.exe +0 -0
- package/runtime/win32-x64/select.pyd +0 -0
- package/runtime/win32-x64/sqlite3.dll +0 -0
- package/runtime/win32-x64/unicodedata.pyd +0 -0
- package/runtime/win32-x64/vcruntime140.dll +0 -0
- package/runtime/win32-x64/vcruntime140_1.dll +0 -0
- package/runtime/win32-x64/winsound.pyd +0 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
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 TOOL_TIMEOUT_MS = 5000
|
|
38
|
+
const HEALTH_TIMEOUT_MS = 800
|
|
39
|
+
const READY_WAIT_MS = 10000
|
|
40
|
+
const WATCHDOG_MS = 5000
|
|
41
|
+
const PORT_TRIES = 10
|
|
42
|
+
const MAX_TEXT_CHARS = 2000
|
|
43
|
+
const MAX_SESSIONS = 32
|
|
44
|
+
const LOG_ROTATE_BYTES = 2 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
/** 每个会话最近一轮的材料。 */
|
|
47
|
+
const contexts = new Map()
|
|
48
|
+
/** 每个会话正在进行的 /turn(串行队列的尾巴)。 */
|
|
49
|
+
const pending = new Map()
|
|
50
|
+
let lastContext = ''
|
|
51
|
+
let shapeLogged = false
|
|
52
|
+
|
|
53
|
+
export function apply(ctx, config = {}) {
|
|
54
|
+
const configured = new URL((config.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, ''))
|
|
55
|
+
const host = configured.hostname || '127.0.0.1'
|
|
56
|
+
const basePort = Number(configured.port || 8765)
|
|
57
|
+
const autoStart = config.autoStart !== false
|
|
58
|
+
const maxAskedTotal = Number.isFinite(Number(config.maxAskedTotal)) ? Number(config.maxAskedTotal) : 6
|
|
59
|
+
const scope = config.scope === 'session' ? 'session' : 'user'
|
|
60
|
+
const kernelRoot = resolveKernelRoot(config.repoPath)
|
|
61
|
+
const python = resolvePython(config.pythonPath)
|
|
62
|
+
const dbPath = resolve(str(config.dbPath) || join(homedir(), '.xujie-memory', 'memory.db'))
|
|
63
|
+
const dataDir = dirname(dbPath)
|
|
64
|
+
const tokenFile = str(config.tokenFile) || join(dataDir, 'token')
|
|
65
|
+
const logFile = str(config.logFile) || join(dataDir, 'kernel.log')
|
|
66
|
+
const statusFile = str(config.statusFile)
|
|
67
|
+
const expectedVersion = kernelVersion(kernelRoot)
|
|
68
|
+
// 宿主若会 await section.text 返回的 Promise,可以打开它:组装时等这一轮的
|
|
69
|
+
// /turn(最多 waitMs)。宿主不支持时会把 Promise 当字符串,**默认关闭**。
|
|
70
|
+
const asyncSection = config.asyncSection === true
|
|
71
|
+
const waitMs = Number.isFinite(Number(config.waitMs)) ? Number(config.waitMs) : 400
|
|
72
|
+
|
|
73
|
+
/** 当前在用的内核地址(可能因端口被占而换过)。 */
|
|
74
|
+
let endpoint = `http://${host}:${basePort}`
|
|
75
|
+
let token = readToken(tokenFile)
|
|
76
|
+
|
|
77
|
+
const log = (level, message) => {
|
|
78
|
+
const logger = ctx.logger
|
|
79
|
+
if (logger && typeof logger[level] === 'function') logger[level](`xujie-memory: ${message}`)
|
|
80
|
+
if (statusFile && level !== 'debug') {
|
|
81
|
+
try {
|
|
82
|
+
appendFileSync(statusFile, `${new Date().toISOString()} ${level} ${message}\n`, 'utf8')
|
|
83
|
+
} catch { /* 诊断文件写不进去不影响主流程 */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ------------------------------------------------------------ 内核
|
|
88
|
+
if (autoStart) {
|
|
89
|
+
ctx.effect(() => {
|
|
90
|
+
let child = null
|
|
91
|
+
let stopped = false
|
|
92
|
+
let booting = null
|
|
93
|
+
let failures = 0
|
|
94
|
+
let nextAttemptAt = 0
|
|
95
|
+
|
|
96
|
+
/** 端口上这个内核能不能用:版本对、库对、token 对。 */
|
|
97
|
+
const probe = async (url) => {
|
|
98
|
+
const body = await health(url, token)
|
|
99
|
+
if (!body) return 'down'
|
|
100
|
+
if (expectedVersion && body.version !== expectedVersion) return 'foreign'
|
|
101
|
+
if (body.auth && !body.db) return 'foreign' // token 不对
|
|
102
|
+
if (body.db && resolve(body.db) !== dbPath) return 'foreign'
|
|
103
|
+
if (body.scope_mode && body.scope_mode !== scope) return 'foreign'
|
|
104
|
+
return 'ok'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const spawnKernel = (port) => {
|
|
108
|
+
mkdirSync(dataDir, { recursive: true })
|
|
109
|
+
rotateLog(logFile)
|
|
110
|
+
let fd = 'ignore'
|
|
111
|
+
try { fd = openSync(logFile, 'a') } catch { fd = 'ignore' }
|
|
112
|
+
const args = [
|
|
113
|
+
'-m', 'xujie_memory.server',
|
|
114
|
+
'--host', host, '--port', String(port),
|
|
115
|
+
'--db', dbPath,
|
|
116
|
+
'--token-file', tokenFile,
|
|
117
|
+
'--scope', scope,
|
|
118
|
+
'--parent-pid', String(process.pid),
|
|
119
|
+
]
|
|
120
|
+
const proc = spawn(python, args, {
|
|
121
|
+
cwd: kernelRoot,
|
|
122
|
+
stdio: ['ignore', fd, fd],
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONDONTWRITEBYTECODE: '1' },
|
|
125
|
+
})
|
|
126
|
+
if (typeof fd === 'number') { try { closeSync(fd) } catch { /* ignore */ } }
|
|
127
|
+
proc.on?.('error', (err) => log('warn', `内核启动失败:${err.message}`))
|
|
128
|
+
proc.on?.('exit', (code, signal) => {
|
|
129
|
+
if (child === proc) child = null
|
|
130
|
+
if (!stopped) log('warn', `内核退出(code=${code ?? 'null'} signal=${signal ?? 'null'}),看门狗会重拉;详情见 ${logFile}`)
|
|
131
|
+
})
|
|
132
|
+
return proc
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const boot = async () => {
|
|
136
|
+
if (!kernelRoot) {
|
|
137
|
+
log('warn', '找不到记忆内核(python/xujie_memory 不存在),需要在配置里给 repoPath')
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
// 1. 找一个能用的端口:已经有对的内核就复用,被别人占了就往后挪。
|
|
141
|
+
for (let i = 0; i < PORT_TRIES && !stopped; i++) {
|
|
142
|
+
const port = basePort + i
|
|
143
|
+
const url = `http://${host}:${port}`
|
|
144
|
+
const state = await probe(url)
|
|
145
|
+
if (state === 'ok') {
|
|
146
|
+
endpoint = url
|
|
147
|
+
failures = 0
|
|
148
|
+
log('info', `复用已在运行的内核:${url}`)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if (state === 'foreign') {
|
|
152
|
+
log('warn', `端口 ${port} 上的服务不是本版本内核(或库/鉴权不一致),换下一个端口`)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
// 2. 空端口:拉起自己的内核。
|
|
156
|
+
if (child) { try { child.kill() } catch { /* ignore */ } }
|
|
157
|
+
try {
|
|
158
|
+
child = spawnKernel(port)
|
|
159
|
+
} catch (err) {
|
|
160
|
+
log('warn', `内核没能拉起:${err.message}`)
|
|
161
|
+
return markFailure()
|
|
162
|
+
}
|
|
163
|
+
log('info', `内核启动中(pid ${child.pid ?? '?'},port ${port},python=${python})`)
|
|
164
|
+
const startedAt = Date.now()
|
|
165
|
+
while (!stopped && Date.now() - startedAt < READY_WAIT_MS) {
|
|
166
|
+
await sleep(150)
|
|
167
|
+
token = readToken(tokenFile) || token
|
|
168
|
+
const s = await probe(url)
|
|
169
|
+
if (s === 'ok') {
|
|
170
|
+
endpoint = url
|
|
171
|
+
failures = 0
|
|
172
|
+
log('info', `内核就绪(${Date.now() - startedAt}ms):${url}`)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (!child) break // 进程已经退出了
|
|
176
|
+
}
|
|
177
|
+
if (!stopped) log('warn', `${READY_WAIT_MS}ms 内内核未就绪(对话不受影响);详情见 ${logFile}`)
|
|
178
|
+
return markFailure()
|
|
179
|
+
}
|
|
180
|
+
if (!stopped) log('warn', `端口 ${basePort}-${basePort + PORT_TRIES - 1} 都不可用`)
|
|
181
|
+
return markFailure()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const markFailure = () => {
|
|
185
|
+
failures += 1
|
|
186
|
+
// 退避:2s, 4s, 8s … 封顶 5 分钟。不会因为环境坏了就疯狂重启。
|
|
187
|
+
const delay = Math.min(300000, 2000 * 2 ** Math.min(failures - 1, 8))
|
|
188
|
+
nextAttemptAt = Date.now() + delay
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const ensure = () => {
|
|
192
|
+
if (stopped || booting || Date.now() < nextAttemptAt) return booting
|
|
193
|
+
booting = boot().catch((err) => {
|
|
194
|
+
log('warn', `内核引导异常:${err?.message ?? err}`)
|
|
195
|
+
markFailure()
|
|
196
|
+
}).finally(() => { booting = null })
|
|
197
|
+
return booting
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
kernelReady = async () => { await ensure() }
|
|
201
|
+
void ensure()
|
|
202
|
+
|
|
203
|
+
// 看门狗:崩溃、被别的实例带走、被手动关掉,都会被拉回来。
|
|
204
|
+
const timer = setInterval(async () => {
|
|
205
|
+
if (stopped || booting) return
|
|
206
|
+
if ((await probe(endpoint)) !== 'ok') {
|
|
207
|
+
log('warn', `内核不可用(${endpoint}),尝试恢复`)
|
|
208
|
+
void ensure()
|
|
209
|
+
}
|
|
210
|
+
}, WATCHDOG_MS)
|
|
211
|
+
timer.unref?.()
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
stopped = true
|
|
215
|
+
clearInterval(timer)
|
|
216
|
+
kernelReady = null
|
|
217
|
+
try { child?.kill() } catch { /* 已经退出 */ }
|
|
218
|
+
}
|
|
219
|
+
}, 'xujie-memory: python service')
|
|
220
|
+
} else {
|
|
221
|
+
log('info', `autoStart=false,等待外部内核:${endpoint}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ------------------------------------------------------------ 注入材料
|
|
225
|
+
ctx.inject(['systemPrompt'], (prompted) => {
|
|
226
|
+
const service = prompted.get('systemPrompt')
|
|
227
|
+
if (!service || typeof service.section !== 'function') {
|
|
228
|
+
log('warn', 'systemPrompt 没有 section(),这个宿主版本可能不兼容')
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const pick = (assemble) => {
|
|
232
|
+
const raw = assemble?.agent?.session
|
|
233
|
+
if (raw) {
|
|
234
|
+
const got = contexts.get(sessionKey(raw))
|
|
235
|
+
if (got !== undefined) return got
|
|
236
|
+
}
|
|
237
|
+
// 没有这个会话的材料(新会话第一句之前 / 宿主内部组装):
|
|
238
|
+
// user 模式下记忆本来就跨会话共享,用最近一次的材料;
|
|
239
|
+
// session 模式下绝不串会话,返回空。
|
|
240
|
+
return scope === 'user' ? lastContext : ''
|
|
241
|
+
}
|
|
242
|
+
service.section({
|
|
243
|
+
name: 'xujie-memory',
|
|
244
|
+
order: 150,
|
|
245
|
+
text: (assemble) => {
|
|
246
|
+
const key = sessionKey(assemble?.agent?.session)
|
|
247
|
+
const inflight = pending.get(key)
|
|
248
|
+
if (!asyncSection) {
|
|
249
|
+
if (inflight) log('debug', `组装时 ${key} 的本轮记忆还没回来,用的是上一轮材料`)
|
|
250
|
+
return pick(assemble)
|
|
251
|
+
}
|
|
252
|
+
if (!inflight) return pick(assemble)
|
|
253
|
+
return Promise.race([inflight, sleep(waitMs)]).then(() => pick(assemble))
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// ------------------------------------------------------------ 工具
|
|
259
|
+
// 注入全自动,但"主动回忆"和"记错了删掉"必须给模型一只手:
|
|
260
|
+
// 只会被动接收召回结果 = 只许它看,不许它查、不许它改。
|
|
261
|
+
// 三个都是可解释的低危操作:搜(只读)、忘(软删,可审计)、看账(只读)。
|
|
262
|
+
const toolDisposers = []
|
|
263
|
+
ctx.inject(['tools'], (toolCtx) => {
|
|
264
|
+
// 宿主没给 tools 服务(老版本 / 测试替身)时静默跳过:
|
|
265
|
+
// 工具是增强,不能因为它让整块记忆插件挂不上。
|
|
266
|
+
if (typeof toolCtx?.tools?.register !== 'function') {
|
|
267
|
+
log('warn', '宿主没有 tools 服务,检索/删除工具不会注册(注入照常)')
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
const sessionOf = (exec) => exec?.agent?.session ?? exec?.session
|
|
271
|
+
const call = async (path, payload, exec) => {
|
|
272
|
+
const resp = await fetchJson(
|
|
273
|
+
`${endpoint}${path}`,
|
|
274
|
+
{ session: sessionKey(sessionOf(exec)), user: String(sessionOf(exec)?.userId ?? sessionOf(exec)?.account ?? 'user'), ...payload },
|
|
275
|
+
TOOL_TIMEOUT_MS,
|
|
276
|
+
token,
|
|
277
|
+
)
|
|
278
|
+
if (!resp || resp.ok !== true) {
|
|
279
|
+
throw new Error(`xujie-memory: ${resp?.detail ?? resp?.error ?? 'memory kernel did not respond'}`)
|
|
280
|
+
}
|
|
281
|
+
return resp
|
|
282
|
+
}
|
|
283
|
+
const text = (value) => [{ type: 'text', text: value }]
|
|
284
|
+
|
|
285
|
+
toolDisposers.push(toolCtx.tools.register({
|
|
286
|
+
name: 'memory_search',
|
|
287
|
+
description: '在用户的长期记忆里检索他说过的话(支持短词与错字模糊匹配)。只读。返回原话、trace_id 与权重点。要删除某条记忆前,先用它拿到 trace_id。',
|
|
288
|
+
parameters: {
|
|
289
|
+
type: 'object',
|
|
290
|
+
properties: {
|
|
291
|
+
query: { type: 'string', description: '要检索的内容;两个字也能搜,错一个字也能命中' },
|
|
292
|
+
limit: { type: 'number', description: '最多返回几条,默认 5(1-20)' },
|
|
293
|
+
},
|
|
294
|
+
required: ['query'],
|
|
295
|
+
},
|
|
296
|
+
output: {
|
|
297
|
+
schema: { type: 'object' },
|
|
298
|
+
render: (_args, value) => {
|
|
299
|
+
const hits = value?.hits ?? []
|
|
300
|
+
if (hits.length === 0) return text(`没有搜到与「${value?.query ?? ''}」相关的记忆。`)
|
|
301
|
+
const lines = hits.map((h) => {
|
|
302
|
+
const tags = [h.anchor ? '锚点' : '', h.importance ? `重要度 ${h.importance}` : ''].filter(Boolean)
|
|
303
|
+
return `- ${h.content}(trace_id: ${h.trace_id}${tags.length ? ',' + tags.join(',') : ''})`
|
|
304
|
+
})
|
|
305
|
+
return text(`搜到 ${hits.length} 条:\n${lines.join('\n')}`)
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
execute: async (args, exec) => {
|
|
309
|
+
const query = String(args?.query ?? '').trim().slice(0, MAX_TEXT_CHARS)
|
|
310
|
+
if (!query) throw new Error('memory_search: query 不能为空')
|
|
311
|
+
const limit = Math.max(1, Math.min(20, Math.round(Number(args?.limit) || 5)))
|
|
312
|
+
const resp = await call('/memory/search', { query, limit }, exec)
|
|
313
|
+
return { ok: true, query: resp.query ?? query, hits: resp.hits ?? [] }
|
|
314
|
+
},
|
|
315
|
+
}))
|
|
316
|
+
|
|
317
|
+
toolDisposers.push(toolCtx.tools.register({
|
|
318
|
+
name: 'memory_forget',
|
|
319
|
+
description: '删掉一条记忆(软删:内容清空、FTS 移除、由它推出的事实一起失效并重算;库里保留 erased_at 供审计,不可恢复)。trace_id 用 memory_search 取。用户说「忘掉这条」时用。',
|
|
320
|
+
parameters: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
trace_id: { type: 'string', description: '要删的记忆条目 id(memory_search 返回的 trace_id)' },
|
|
324
|
+
},
|
|
325
|
+
required: ['trace_id'],
|
|
326
|
+
},
|
|
327
|
+
output: {
|
|
328
|
+
schema: { type: 'object' },
|
|
329
|
+
render: (_args, value) => {
|
|
330
|
+
if (!value?.erased) return text('删除失败:内核没有确认。')
|
|
331
|
+
const tail = value.already_erased ? '(这条之前已经删过)' : ''
|
|
332
|
+
return text(`已删除 ${value.trace_id}${tail},连带失效 ${value.state_keys?.length ?? 0} 组状态。`)
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
execute: async (args, exec) => {
|
|
336
|
+
const traceId = String(args?.trace_id ?? '').trim()
|
|
337
|
+
if (!traceId) throw new Error('memory_forget: trace_id 不能为空')
|
|
338
|
+
await Promise.allSettled([...pending.values()])
|
|
339
|
+
const result = await call('/memory/forget', { trace_id: traceId }, exec)
|
|
340
|
+
contexts.clear()
|
|
341
|
+
lastContext = ''
|
|
342
|
+
return result
|
|
343
|
+
},
|
|
344
|
+
}))
|
|
345
|
+
|
|
346
|
+
toolDisposers.push(toolCtx.tools.register({
|
|
347
|
+
name: 'memory_status',
|
|
348
|
+
description: '看记忆库现状:原话条数、已删条数、锚点数、未确认项,以及内核健康信息。用户问「你记得我什么」「记忆正常吗」时用。只读。',
|
|
349
|
+
parameters: { type: 'object', properties: {} },
|
|
350
|
+
output: {
|
|
351
|
+
schema: { type: 'object' },
|
|
352
|
+
render: (_args, value) => {
|
|
353
|
+
const kernel = value?.kernel ?? {}
|
|
354
|
+
const head = `记忆库:原话 ${value?.traces ?? 0} 条(已删 ${value?.traces_erased ?? 0} 条),`
|
|
355
|
+
+ `状态 ${value?.states ?? 0} 条,锚点 ${value?.anchors ?? 0} 个,`
|
|
356
|
+
+ `未确认 ${value?.clarifications_open ?? 0} 个。`
|
|
357
|
+
const tail = kernel.ok
|
|
358
|
+
? `内核:pid ${kernel.pid},版本 ${kernel.version},scope=${kernel.scope_mode},库 ${kernel.db}`
|
|
359
|
+
: '内核:当前连不上(注入会降级为「这一轮没有记忆」)。'
|
|
360
|
+
return text(`${head}\n${tail}`)
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
execute: async (_args, exec) => {
|
|
364
|
+
const [stats, kernel] = await Promise.all([
|
|
365
|
+
call('/memory/stats', {}, exec),
|
|
366
|
+
health(endpoint, token),
|
|
367
|
+
])
|
|
368
|
+
return { ...stats, kernel }
|
|
369
|
+
},
|
|
370
|
+
}))
|
|
371
|
+
})
|
|
372
|
+
ctx.effect(() => () => {
|
|
373
|
+
for (const dispose of toolDisposers.splice(0)) {
|
|
374
|
+
try { dispose?.() } catch { /* 已释放 */ }
|
|
375
|
+
}
|
|
376
|
+
}, 'xujie-memory: tools')
|
|
377
|
+
|
|
378
|
+
// ------------------------------------------------------------ 写记忆
|
|
379
|
+
ctx.on('session/event', (session, event) => {
|
|
380
|
+
const type = event?.type
|
|
381
|
+
if (type !== 'user/message') return
|
|
382
|
+
|
|
383
|
+
const data = event?.data
|
|
384
|
+
const sourceKind = data?.source?.kind
|
|
385
|
+
// runtime-context / agent-instructions 也是 user/message,但它们不是用户说的。
|
|
386
|
+
if (typeof sourceKind === 'string' && sourceKind !== 'user') {
|
|
387
|
+
log('debug', `event ${type}(source=${sourceKind},跳过)`)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let text = extractText(data)
|
|
392
|
+
if (!text) {
|
|
393
|
+
log('warn', `event ${type} 里没找到正文,字段是:${Object.keys(data || {}).join(',')}`)
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
if (text.length > MAX_TEXT_CHARS) text = text.slice(0, MAX_TEXT_CHARS)
|
|
397
|
+
|
|
398
|
+
if (!shapeLogged) {
|
|
399
|
+
shapeLogged = true
|
|
400
|
+
log('info', `user/message 形状:${shapeOf(data)}`)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const key = sessionKey(session)
|
|
404
|
+
// 同一会话串行:上一句没写完,下一句排在后面,保证顺序。
|
|
405
|
+
const prev = pending.get(key) || Promise.resolve()
|
|
406
|
+
const job = prev
|
|
407
|
+
.catch(() => {})
|
|
408
|
+
.then(() => remember(() => endpoint, () => token, session, text, maxAskedTotal, log))
|
|
409
|
+
.finally(() => { if (pending.get(key) === job) pending.delete(key) })
|
|
410
|
+
pending.set(key, job)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
log('info', `已挂载(scope=${scope},内核 ${expectedVersion || '?'},库 ${dbPath})`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 测试/诊断用:等内核引导结束。 */
|
|
417
|
+
export let kernelReady = null
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------- 路径
|
|
420
|
+
|
|
421
|
+
function str(v) {
|
|
422
|
+
return typeof v === 'string' ? v.trim() : ''
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 优先用配置里的 repoPath(开发),否则用包里自带的 python/。 */
|
|
426
|
+
function resolveKernelRoot(repoPath) {
|
|
427
|
+
const configured = str(repoPath)
|
|
428
|
+
if (configured && existsSync(join(configured, 'xujie_memory'))) return configured
|
|
429
|
+
if (existsSync(join(BUNDLED_KERNEL, 'xujie_memory'))) return BUNDLED_KERNEL
|
|
430
|
+
return ''
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 优先用配置,其次包内解释器,最后 PATH 里的 python。 */
|
|
434
|
+
function resolvePython(pythonPath) {
|
|
435
|
+
const configured = str(pythonPath)
|
|
436
|
+
if (configured) return configured
|
|
437
|
+
const platformDir = `${process.platform}-${process.arch}`
|
|
438
|
+
const bundled = process.platform === 'win32'
|
|
439
|
+
? join(PACKAGE_ROOT, 'runtime', platformDir, 'python.exe')
|
|
440
|
+
: join(PACKAGE_ROOT, 'runtime', platformDir, 'bin', 'python3')
|
|
441
|
+
if (existsSync(bundled)) return bundled
|
|
442
|
+
return process.platform === 'win32' ? 'python' : 'python3'
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 包内内核的版本号;/health 对不上就说明端口上是别的内核。 */
|
|
446
|
+
function kernelVersion(root) {
|
|
447
|
+
if (!root) return ''
|
|
448
|
+
try {
|
|
449
|
+
const src = readFileSync(join(root, 'xujie_memory', '__init__.py'), 'utf8')
|
|
450
|
+
return src.match(/__version__\s*=\s*["']([^"']+)["']/)?.[1] ?? ''
|
|
451
|
+
} catch {
|
|
452
|
+
return ''
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function readToken(file) {
|
|
457
|
+
try { return readFileSync(file, 'utf8').trim() } catch { return '' }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function rotateLog(file) {
|
|
461
|
+
try {
|
|
462
|
+
if (statSync(file).size > LOG_ROTATE_BYTES) renameSync(file, `${file}.1`)
|
|
463
|
+
} catch { /* 不存在或改不了名都无所谓 */ }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------- 工具
|
|
467
|
+
|
|
468
|
+
function sleep(ms) {
|
|
469
|
+
return new Promise((r) => { const t = setTimeout(r, ms); t.unref?.() })
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function health(url, token) {
|
|
473
|
+
const controller = new AbortController()
|
|
474
|
+
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS)
|
|
475
|
+
try {
|
|
476
|
+
const resp = await fetch(`${url}/health`, {
|
|
477
|
+
signal: controller.signal,
|
|
478
|
+
headers: token ? { 'X-Xujie-Token': token } : {},
|
|
479
|
+
})
|
|
480
|
+
if (!resp.ok) return null
|
|
481
|
+
const body = await resp.json().catch(() => null)
|
|
482
|
+
return body?.ok ? body : null
|
|
483
|
+
} catch {
|
|
484
|
+
return null
|
|
485
|
+
} finally {
|
|
486
|
+
clearTimeout(timer)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** 结构摘要:只出字段名和类型,不出内容。 */
|
|
491
|
+
function shapeOf(value, depth = 0) {
|
|
492
|
+
if (value === null) return 'null'
|
|
493
|
+
if (Array.isArray(value)) {
|
|
494
|
+
return depth > 2 ? 'array' : `[${value.length ? shapeOf(value[0], depth + 1) : ''}]`
|
|
495
|
+
}
|
|
496
|
+
if (typeof value !== 'object') return typeof value
|
|
497
|
+
if (depth > 2) return 'object'
|
|
498
|
+
const keys = Object.keys(value).slice(0, 10)
|
|
499
|
+
return `{${keys.map((k) => `${k}: ${shapeOf(value[k], depth + 1)}`).join(', ')}}`.slice(0, 240)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** 从事件 data 里掏正文:真机是 content 数组里的 text 块。 */
|
|
503
|
+
export function extractText(data) {
|
|
504
|
+
const candidates = [
|
|
505
|
+
data?.text,
|
|
506
|
+
data?.content,
|
|
507
|
+
data?.message?.content,
|
|
508
|
+
data?.message?.text,
|
|
509
|
+
data?.payload?.text,
|
|
510
|
+
data?.payload?.content,
|
|
511
|
+
]
|
|
512
|
+
for (const c of candidates) {
|
|
513
|
+
if (typeof c === 'string' && c.trim()) return c.trim()
|
|
514
|
+
if (Array.isArray(c)) {
|
|
515
|
+
const joined = c
|
|
516
|
+
.map((part) => {
|
|
517
|
+
if (typeof part === 'string') return part
|
|
518
|
+
if (!part || typeof part !== 'object') return ''
|
|
519
|
+
return typeof part.text === 'string' ? part.text : ''
|
|
520
|
+
})
|
|
521
|
+
.filter(Boolean)
|
|
522
|
+
.join('\n')
|
|
523
|
+
.trim()
|
|
524
|
+
if (joined) return joined
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return ''
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function sessionKey(session) {
|
|
531
|
+
return String(session?.id ?? session?.sessionId ?? session?.key ?? 'default')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------- 请求
|
|
535
|
+
|
|
536
|
+
async function remember(getEndpoint, getToken, session, text, maxAskedTotal, log) {
|
|
537
|
+
const key = sessionKey(session)
|
|
538
|
+
const body = {
|
|
539
|
+
session: key,
|
|
540
|
+
user: String(session?.userId ?? session?.account ?? 'user'),
|
|
541
|
+
text,
|
|
542
|
+
// 对话的另一方是助手,不是 'user'——旧版写成 'user',
|
|
543
|
+
// 「我欠你 20」会记成"自己欠自己"。
|
|
544
|
+
context: { peer_id: 'assistant' },
|
|
545
|
+
budget: { max_asked_total: maxAskedTotal },
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let lastErr = null
|
|
549
|
+
// 失败重试一次(内核刚被看门狗拉起时最常见)。
|
|
550
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
551
|
+
try {
|
|
552
|
+
const resp = await fetchJson(`${getEndpoint()}/turn`, body, TURN_TIMEOUT_MS, getToken())
|
|
553
|
+
if (resp?.ok && typeof resp.context === 'string') {
|
|
554
|
+
contexts.delete(key)
|
|
555
|
+
contexts.set(key, resp.context) // 重新插入 = 移到最新
|
|
556
|
+
while (contexts.size > MAX_SESSIONS) {
|
|
557
|
+
const oldest = contexts.keys().next().value
|
|
558
|
+
if (oldest === undefined) break
|
|
559
|
+
contexts.delete(oldest)
|
|
560
|
+
}
|
|
561
|
+
lastContext = resp.context
|
|
562
|
+
log('debug',
|
|
563
|
+
`记下 ${resp.outcomes?.length ?? 0} 条判定,材料 ${resp.context.length} 字,会话 ${key}`)
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
lastErr = new Error(resp?.detail ? `${resp.error}: ${resp.detail}` : (resp?.error ?? 'unknown'))
|
|
567
|
+
// 内核已经处理过这句(4xx/5xx 都是):重试可能写两遍,不重试。
|
|
568
|
+
// 401 例外:token 刚被新内核换掉,重读一次再试。
|
|
569
|
+
if (resp?.status !== 401) break
|
|
570
|
+
} catch (err) {
|
|
571
|
+
lastErr = err
|
|
572
|
+
// 超时 = 内核可能已经写进去了,重试会重复记一笔,不重试。
|
|
573
|
+
// 只有「连不上」(内核正在被看门狗重拉)才重试。
|
|
574
|
+
if (err?.name === 'AbortError' || attempt === 1) break
|
|
575
|
+
}
|
|
576
|
+
await sleep(500)
|
|
577
|
+
}
|
|
578
|
+
log('warn', `这一轮没有记忆:${lastErr?.message ?? 'unknown'}`)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function fetchJson(url, payload, timeoutMs, token) {
|
|
582
|
+
const controller = new AbortController()
|
|
583
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
584
|
+
try {
|
|
585
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
586
|
+
if (token) headers['X-Xujie-Token'] = token
|
|
587
|
+
const resp = await fetch(url, {
|
|
588
|
+
method: 'POST',
|
|
589
|
+
headers,
|
|
590
|
+
body: JSON.stringify(payload),
|
|
591
|
+
signal: controller.signal,
|
|
592
|
+
})
|
|
593
|
+
const text = await resp.text()
|
|
594
|
+
try {
|
|
595
|
+
const parsed = JSON.parse(text)
|
|
596
|
+
if (parsed && typeof parsed === 'object') parsed.status = resp.status
|
|
597
|
+
return parsed
|
|
598
|
+
} catch {
|
|
599
|
+
return { ok: false, status: resp.status, error: `bad_json: ${text.slice(0, 120)}` }
|
|
600
|
+
}
|
|
601
|
+
} finally {
|
|
602
|
+
clearTimeout(timer)
|
|
603
|
+
}
|
|
604
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-xujie-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.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
|
+
}
|