dsh-memu-memory 1.1.1 → 1.1.3
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/AGENTS.md +8 -5
- package/README.md +22 -5
- package/README.zh.md +21 -4
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +41 -1
- package/docs/troubleshooting.md +395 -350
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +703 -50
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +61 -4
- package/lib/inject.js +195 -49
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +121 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
package/lib/memu-config.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { dataDir, ensureDir, stamp, withFileLock } from './paths.js'
|
|
5
|
-
import { getSettings } from './state.js'
|
|
5
|
+
import { getSettings, updateSettings } from './state.js'
|
|
6
6
|
import { commit, retrieve } from './memu.js'
|
|
7
7
|
|
|
8
8
|
export const MEMU_CONFIG_KEYS = [
|
|
@@ -108,9 +108,9 @@ export function getMemuConfig() {
|
|
|
108
108
|
baseUrl,
|
|
109
109
|
hasKey,
|
|
110
110
|
providers: Object.keys(PROVIDER_ENV),
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
source:
|
|
111
|
+
// Source is a persisted route choice, not an inference from connection fields.
|
|
112
|
+
// Toggling vector mode must never rewrite a saved remote endpoint or key.
|
|
113
|
+
source: getSettings().vectorSource === 'api' ? 'api' : 'local',
|
|
114
114
|
localBaseUrl: LOCAL_EMBED_BASE_URL,
|
|
115
115
|
localModel: LOCAL_EMBED_MODEL,
|
|
116
116
|
}
|
|
@@ -132,24 +132,12 @@ export function getSummaryConfig({ withKey = false } = {}) {
|
|
|
132
132
|
return withKey ? { ...view, apiKey } : view
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
env.MEMU_EMBED_PROVIDER = 'openai'
|
|
142
|
-
env.MEMU_BASE_URL = String(env.MEMU_BASE_URL || '').includes('127.0.0.1') || String(env.MEMU_BASE_URL || '').includes('localhost')
|
|
143
|
-
? env.MEMU_BASE_URL
|
|
144
|
-
: LOCAL_EMBED_BASE_URL
|
|
145
|
-
if (!keepModel && !String(env.MEMU_EMBED_MODEL || '')) env.MEMU_EMBED_MODEL = LOCAL_EMBED_MODEL
|
|
146
|
-
} else if (source === 'remote') {
|
|
147
|
-
if (String(env.MEMU_BASE_URL || '') === LOCAL_EMBED_BASE_URL) delete env.MEMU_BASE_URL
|
|
148
|
-
if (String(env.MEMU_EMBED_MODEL || '') === LOCAL_EMBED_MODEL) delete env.MEMU_EMBED_MODEL
|
|
149
|
-
} else {
|
|
150
|
-
throw new Error('source must be local or remote')
|
|
151
|
-
}
|
|
152
|
-
writeMemuEnv(env)
|
|
135
|
+
// Switch only the vector route. Connection fields belong to the selected profile and
|
|
136
|
+
// must survive a route toggle; callers may explicitly edit them through saveMemuConfig.
|
|
137
|
+
export function setEmbeddingSource(source) {
|
|
138
|
+
const normalized = source === 'remote' ? 'api' : source
|
|
139
|
+
if (normalized !== 'local' && normalized !== 'api') throw new Error('source must be local or api')
|
|
140
|
+
updateSettings({ vectorSource: normalized })
|
|
153
141
|
return getMemuConfig()
|
|
154
142
|
}
|
|
155
143
|
|
package/lib/memu.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import { dataDir, ensureDir, redactSecrets } from './paths.js'
|
|
4
|
+
import { dataDir, ensureDir, redactSecrets, windowsSystemEnv } from './paths.js'
|
|
5
5
|
import { readCurrent, verifyEngine } from './engine.js'
|
|
6
6
|
import { getSettings } from './state.js'
|
|
7
|
+
import { activeProfile, captureVectorGeneration, assertVectorGeneration, assertProfileVerified, assertMigrationProfile } from './vector-profile.js'
|
|
8
|
+
import { embeddingWorkerManager } from './local-embedding-manager.js'
|
|
7
9
|
|
|
8
10
|
// MemU's own storage mode. 'local' keeps every row in this plugin's sqlite files;
|
|
9
11
|
// 'cloud' hands storage to MemU's backend and needs the vendor credentials in
|
|
@@ -36,6 +38,7 @@ function runRaw(pythonArgs, { env, timeoutMs = 90_000, cwd } = {}) {
|
|
|
36
38
|
const child = spawn(current.python, pythonArgs, {
|
|
37
39
|
cwd,
|
|
38
40
|
env: {
|
|
41
|
+
...windowsSystemEnv(),
|
|
39
42
|
...process.env,
|
|
40
43
|
PYTHONUTF8: '1',
|
|
41
44
|
PYTHONIOENCODING: 'utf-8',
|
|
@@ -75,11 +78,31 @@ function runRaw(pythonArgs, { env, timeoutMs = 90_000, cwd } = {}) {
|
|
|
75
78
|
})
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
function
|
|
79
|
-
|
|
81
|
+
async function runConfigured(args, options = {}) {
|
|
82
|
+
const route = options.route || (options.vectorProfile ? 'managed-local' : 'remote-api')
|
|
83
|
+
const useManagedLocal = route === 'managed-local'
|
|
84
|
+
const migration = options.vectorProfile ? assertMigrationProfile({ profile: options.vectorProfile, db: options.db }) : null
|
|
85
|
+
const generation = useManagedLocal && !options.vectorProfile && activeProfile() ? captureVectorGeneration() : null
|
|
86
|
+
if (generation && options.db && ![generation.global, ...Object.values(generation.libraries)].includes(options.db)) {
|
|
87
|
+
throw new MemuError('database does not belong to the captured vector generation', 'VECTOR_DB_MISMATCH')
|
|
88
|
+
}
|
|
89
|
+
const env = await envFor({ ...options, route })
|
|
90
|
+
if (generation) assertVectorGeneration(generation)
|
|
91
|
+
if (migration) assertMigrationProfile(migration)
|
|
92
|
+
const result = await runRaw(['-m', 'memu', ...args], { env, timeoutMs: options.timeoutMs })
|
|
93
|
+
if (generation) assertVectorGeneration(generation)
|
|
94
|
+
if (migration) assertMigrationProfile(migration)
|
|
95
|
+
return result
|
|
80
96
|
}
|
|
81
97
|
|
|
82
|
-
function envFor({ db, provider, embedModel, baseUrl, apiKey }) {
|
|
98
|
+
async function envFor({ db, provider, embedModel, baseUrl, apiKey, vectorProfile, route }) {
|
|
99
|
+
const profile = route === 'managed-local' ? (vectorProfile || activeProfile()) : null
|
|
100
|
+
if (profile) {
|
|
101
|
+
assertProfileVerified(profile)
|
|
102
|
+
const endpoint = await embeddingWorkerManager().acquire(profile)
|
|
103
|
+
return localEmbeddingEnv({ ...endpoint, db })
|
|
104
|
+
}
|
|
105
|
+
if (route === 'managed-local') throw new MemuError('managed local vector profile is not active', 'VECTOR_NOT_READY')
|
|
83
106
|
const env = {}
|
|
84
107
|
if (db) env.MEMU_DB = db
|
|
85
108
|
if (provider) env.MEMU_EMBED_PROVIDER = provider
|
|
@@ -89,6 +112,26 @@ function envFor({ db, provider, embedModel, baseUrl, apiKey }) {
|
|
|
89
112
|
return env
|
|
90
113
|
}
|
|
91
114
|
|
|
115
|
+
// §14.3 strict-local embedding wiring. The plugin injects this into each MemU
|
|
116
|
+
// child instead of asking the user to export anything: the loopback endpoint, the
|
|
117
|
+
// per-process random token (env only, never argv/log/config) and the pinned
|
|
118
|
+
// embedding model id. Telemetry is switched off explicitly, because "local mode"
|
|
119
|
+
// alone is not proof that nothing leaves the machine.
|
|
120
|
+
export function localEmbeddingEnv({ baseUrl, token, model, db } = {}) {
|
|
121
|
+
const env = {
|
|
122
|
+
MEMU_MEMORY_MODE: 'local',
|
|
123
|
+
MEMU_EMBED_PROVIDER: 'openai',
|
|
124
|
+
MEMU_TELEMETRY: '0',
|
|
125
|
+
DO_NOT_TRACK: '1',
|
|
126
|
+
MEMU_EVENTS_BASE_URL: '',
|
|
127
|
+
}
|
|
128
|
+
if (typeof baseUrl === 'string' && baseUrl.trim()) env.MEMU_BASE_URL = baseUrl.trim()
|
|
129
|
+
if (typeof token === 'string' && token.trim()) env.MEMU_API_KEY = token.trim()
|
|
130
|
+
if (typeof model === 'string' && model.trim()) env.MEMU_EMBED_MODEL = model.trim()
|
|
131
|
+
if (typeof db === 'string' && db.trim()) env.MEMU_DB = db.trim()
|
|
132
|
+
return env
|
|
133
|
+
}
|
|
134
|
+
|
|
92
135
|
export function parseJson(text) {
|
|
93
136
|
const raw = String(text || '').trim()
|
|
94
137
|
if (!raw) return null
|
|
@@ -101,14 +144,11 @@ export function parseJson(text) {
|
|
|
101
144
|
return null
|
|
102
145
|
}
|
|
103
146
|
|
|
104
|
-
export async function retrieve({ query, db, provider, embedModel, baseUrl, apiKey, timeoutMs = 60_000 }) {
|
|
147
|
+
export async function retrieve({ query, db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs = 60_000 }) {
|
|
105
148
|
if (!query || !String(query).trim()) {
|
|
106
149
|
throw new MemuError('retrieve requires a non-empty query', 'MEMU_BAD_ARGUMENT')
|
|
107
150
|
}
|
|
108
|
-
const res = await
|
|
109
|
-
env: envFor({ db, provider, embedModel, baseUrl, apiKey }),
|
|
110
|
-
timeoutMs,
|
|
111
|
-
})
|
|
151
|
+
const res = await runConfigured(['retrieve', String(query), '--json'], { db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs })
|
|
112
152
|
const data = parseJson(res.stdout)
|
|
113
153
|
if (data === null) {
|
|
114
154
|
return { raw: res.stdout.trim(), parseWarning: 'memu retrieve returned non-JSON' }
|
|
@@ -116,11 +156,8 @@ export async function retrieve({ query, db, provider, embedModel, baseUrl, apiKe
|
|
|
116
156
|
return data
|
|
117
157
|
}
|
|
118
158
|
|
|
119
|
-
export async function listFiles({ db, provider, embedModel, baseUrl, apiKey, timeoutMs = 60_000 } = {}) {
|
|
120
|
-
const res = await
|
|
121
|
-
env: envFor({ db, provider, embedModel, baseUrl, apiKey }),
|
|
122
|
-
timeoutMs,
|
|
123
|
-
})
|
|
159
|
+
export async function listFiles({ db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs = 60_000 } = {}) {
|
|
160
|
+
const res = await runConfigured(['list-files', '--json'], { db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs })
|
|
124
161
|
const data = parseJson(res.stdout)
|
|
125
162
|
if (data === null) {
|
|
126
163
|
return { recall_files: [], raw: res.stdout.trim(), parseWarning: 'memu list-files returned non-JSON' }
|
|
@@ -128,15 +165,12 @@ export async function listFiles({ db, provider, embedModel, baseUrl, apiKey, tim
|
|
|
128
165
|
return data
|
|
129
166
|
}
|
|
130
167
|
|
|
131
|
-
export async function commit({ payload, db, provider, embedModel, baseUrl, apiKey, timeoutMs = 180_000 }) {
|
|
168
|
+
export async function commit({ payload, db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs = 180_000 }) {
|
|
132
169
|
const tmpDir = ensureDir(path.join(dataDir(), 'tmp'))
|
|
133
170
|
const file = path.join(tmpDir, `commit-${process.pid}-${Date.now()}.json`)
|
|
134
171
|
try {
|
|
135
172
|
fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8')
|
|
136
|
-
const res = await
|
|
137
|
-
env: envFor({ db, provider, embedModel, baseUrl, apiKey }),
|
|
138
|
-
timeoutMs,
|
|
139
|
-
})
|
|
173
|
+
const res = await runConfigured(['commit', file, '--json'], { db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs })
|
|
140
174
|
const data = parseJson(res.stdout)
|
|
141
175
|
if (data === null) {
|
|
142
176
|
return { raw: res.stdout.trim(), parseWarning: 'memu commit returned non-JSON' }
|
package/lib/paths.js
CHANGED
|
@@ -243,6 +243,30 @@ export function stamp() {
|
|
|
243
243
|
return new Date().toISOString()
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Windows 子进程的系统变量补齐。
|
|
248
|
+
*
|
|
249
|
+
* 宿主在工具化执行里把 `process.env` 清空后(docs/env-and-commands.md §9.1),注入进程的
|
|
250
|
+
* 组件(输入法云缓存、DirectWrite 字体缓存)用 `%SystemDrive%\ProgramData\...` 拼路径时会
|
|
251
|
+
* 展开失败,于是整串被当成相对路径写进**当前工作目录** —— 仓库里出现过字面量
|
|
252
|
+
* `%SystemDrive%/ProgramData/...` 就是这么来的。
|
|
253
|
+
*
|
|
254
|
+
* 只补缺失的键:宿主给了正确的值就绝不覆盖。返回的是 overlay,调用方按
|
|
255
|
+
* `{ ...overlay, ...process.env, ...explicit }` 的顺序合并。
|
|
256
|
+
*/
|
|
257
|
+
export function windowsSystemEnv(platform = process.platform, env = process.env) {
|
|
258
|
+
if (platform !== 'win32') return {}
|
|
259
|
+
const source = env || {}
|
|
260
|
+
const drive = String(source.SystemDrive || source.HOMEDRIVE || 'C:')
|
|
261
|
+
const root = String(source.SystemRoot || source.windir || source.WINDIR || (drive.replace(/\\$/, '') + '\\Windows')).replace(/\\/g, '/')
|
|
262
|
+
const overlay = {}
|
|
263
|
+
if (!source.SystemDrive) overlay.SystemDrive = drive
|
|
264
|
+
if (!source.SystemRoot) overlay.SystemRoot = root
|
|
265
|
+
if (!source.windir && !source.WINDIR) overlay.windir = root
|
|
266
|
+
if (!source.PATH && !source.Path) overlay.PATH = root + '/system32'
|
|
267
|
+
return overlay
|
|
268
|
+
}
|
|
269
|
+
|
|
246
270
|
export function safeSegment(value, fallback = 'unknown') {
|
|
247
271
|
const text = String(value ?? '').trim()
|
|
248
272
|
if (!text) return fallback
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Preset conversations (an `agentPreset` whose cordis.yml lists its own model-facing
|
|
2
|
+
// plugins) live in a scope subtree the host composition cannot see: `agent/pre-step`
|
|
3
|
+
// never reaches the host-level listener, so no binding is ever created and the memory
|
|
4
|
+
// anchor never appears. Nothing in the host API lets a plugin append rows to someone
|
|
5
|
+
// else's preset, so the only way in is the agent's *own* context — the same object the
|
|
6
|
+
// runtime hands to `agents.get(id)?.ctx`.
|
|
7
|
+
//
|
|
8
|
+
// The trap is double injection: for a normal conversation the host-level listener
|
|
9
|
+
// already covers it, and attaching again on the agent ctx would inject twice. The test
|
|
10
|
+
// is structural, not aesthetic — walk the agent's fiber chain and ask whether our own
|
|
11
|
+
// fiber is in it. Same tree means "already covered; do not attach".
|
|
12
|
+
|
|
13
|
+
import { registerInjection } from './inject.js'
|
|
14
|
+
|
|
15
|
+
export const ATTACH_MS = 10_000
|
|
16
|
+
|
|
17
|
+
// One registry per process: attachPresetAgents must see what previous passes attached,
|
|
18
|
+
// otherwise every tick would stack another listener. Keyed by agent context.
|
|
19
|
+
const attachments = new Map()
|
|
20
|
+
let warnedNoCtx = false
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when `agentCtx` sits inside the caller's own scope tree — the case the host
|
|
24
|
+
* listener already serves. Walks `fiber.parent.fiber`, stopping at the root.
|
|
25
|
+
*/
|
|
26
|
+
export function sameScopeTree(agentCtx, hostCtx) {
|
|
27
|
+
let fiber = agentCtx?.fiber
|
|
28
|
+
const own = hostCtx?.fiber
|
|
29
|
+
if (!fiber || !own) return false
|
|
30
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
31
|
+
if (fiber === own) return true
|
|
32
|
+
const parent = fiber.parent?.fiber
|
|
33
|
+
if (!parent || parent === fiber) return false
|
|
34
|
+
fiber = parent
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Attach the memory pre-step listener to every live conversation that the host
|
|
41
|
+
* listener cannot see. Idempotent: one disposal per agent context, dropped when the
|
|
42
|
+
* agent goes away.
|
|
43
|
+
*
|
|
44
|
+
* Returns the number of conversations newly attached this pass.
|
|
45
|
+
*/
|
|
46
|
+
export function attachPresetAgents(ctx, { log = () => {}, attached = attachments } = {}) {
|
|
47
|
+
let agents
|
|
48
|
+
try {
|
|
49
|
+
agents = ctx.get('agents')
|
|
50
|
+
} catch {
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
if (!agents?.list) return 0
|
|
54
|
+
let live = []
|
|
55
|
+
try {
|
|
56
|
+
live = agents.list() || []
|
|
57
|
+
} catch {
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
let added = 0
|
|
62
|
+
let resolved = 0
|
|
63
|
+
for (const raw of live) {
|
|
64
|
+
// `agents.list()` may hand back bare agent objects or the registry's
|
|
65
|
+
// `{ agent, owner, ctx }` records, and `agent.ctx` is only guaranteed on the
|
|
66
|
+
// record. Accept both shapes instead of betting on one host version.
|
|
67
|
+
const agent = raw?.agent && raw?.ctx ? raw.agent : raw
|
|
68
|
+
const sessionId = agent?.session?.header?.id
|
|
69
|
+
if (!sessionId) continue
|
|
70
|
+
if (agent?.session?.header?.origin === 'subagent') continue
|
|
71
|
+
const actx = raw?.ctx || agent?.ctx || (() => {
|
|
72
|
+
try { return agents.get?.(sessionId)?.ctx } catch { return undefined }
|
|
73
|
+
})()
|
|
74
|
+
if (!actx) continue
|
|
75
|
+
resolved += 1
|
|
76
|
+
seen.add(actx)
|
|
77
|
+
if (!attached.has(actx) && !sameScopeTree(actx, ctx)) {
|
|
78
|
+
try {
|
|
79
|
+
const dispose = registerInjection(actx, { guide: false, guideMessage: true })
|
|
80
|
+
attached.set(actx, dispose)
|
|
81
|
+
added += 1
|
|
82
|
+
log(`attached memory injection to preset conversation ${sessionId}`)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
log(`could not attach memory injection to ${sessionId}: ${error?.message || error}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const [key, dispose] of attached.entries()) {
|
|
89
|
+
if (seen.has(key)) continue
|
|
90
|
+
try { dispose?.() } catch {}
|
|
91
|
+
attached.delete(key)
|
|
92
|
+
}
|
|
93
|
+
// One quiet line the first time the host hands us agents without a context: without
|
|
94
|
+
// it the whole adaptation fails silently and looks exactly like "nothing happened".
|
|
95
|
+
if (live.length > 0 && resolved === 0 && !warnedNoCtx) {
|
|
96
|
+
warnedNoCtx = true
|
|
97
|
+
log('agents.list() exposed no agent context on this host; preset conversations cannot be attached')
|
|
98
|
+
}
|
|
99
|
+
return added
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Start the attach loop; returns a disposer that stops it and detaches every agent. */
|
|
103
|
+
export function registerPresetAttachment(ctx, { log = () => {}, intervalMs = ATTACH_MS } = {}) {
|
|
104
|
+
const attached = attachments
|
|
105
|
+
const run = () => {
|
|
106
|
+
try {
|
|
107
|
+
attachPresetAgents(ctx, { log, attached })
|
|
108
|
+
} catch (error) {
|
|
109
|
+
log(`preset attachment pass failed: ${error?.message || error}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
run()
|
|
113
|
+
const timer = setInterval(run, intervalMs)
|
|
114
|
+
timer.unref?.()
|
|
115
|
+
return () => {
|
|
116
|
+
clearInterval(timer)
|
|
117
|
+
for (const [, dispose] of attached.entries()) {
|
|
118
|
+
try { dispose?.() } catch {}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/lib/private.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
2
2
|
import crypto from 'node:crypto'
|
|
3
|
-
import { getSessionBinding, getLibrary, getSettings, setSessionBinding,
|
|
3
|
+
import { getSessionBinding, getLibrary, getSettings, setSessionBinding, loadPrivateIndex, effectiveScope } from './state.js'
|
|
4
4
|
import { domainOf, privateRemember, isMemoryEligible, visibleToSession } from './memory.js'
|
|
5
5
|
import { loadPromptForDomain, CONSOLIDATION_CONTRACTS } from './prompts.js'
|
|
6
6
|
import { summarizeRemote } from './remote-summary.js'
|
|
7
7
|
import { condenseEvents, readSessionSnapshot } from './curator.js'
|
|
8
|
+
import { record } from './activity-log.js'
|
|
8
9
|
|
|
9
10
|
// Short-term entries expire on a clock, so the idle pass is the last moment anyone
|
|
10
11
|
// can decide what they were worth. The list is generated in code rather than added
|
|
@@ -141,7 +142,6 @@ export async function requestPrivateConsolidation(ctx, sessionId, options = {})
|
|
|
141
142
|
const binding = getSessionBinding(sessionId)
|
|
142
143
|
if (!binding) return { ok: false, error: 'session is not bound to a memory library; bind it first' }
|
|
143
144
|
if (!binding.libraryId || !effectiveScope(binding).useLibrary) return { ok: false, error: 'private memory is disabled for this session or folder' }
|
|
144
|
-
if (getMemoryBackend() === 'api') return requestRemoteConsolidation(ctx, sessionId, options)
|
|
145
145
|
// Always a fork, never the live session's own inbox. `agent.followup` on the
|
|
146
146
|
// chat the user is looking at does queue a real turn — but the host renders a
|
|
147
147
|
// queued next-turn message as a pending draft above the composer (editable,
|
|
@@ -192,8 +192,19 @@ export async function requestRemoteConsolidation(ctx, sessionId, { signal, timeo
|
|
|
192
192
|
})
|
|
193
193
|
}
|
|
194
194
|
setSessionBinding(sessionId, { lastSleepSeq: events[events.length - 1]?.seq })
|
|
195
|
-
|
|
195
|
+
const result = { ok: true, sessionId, backend: 'api', count: memories.length }
|
|
196
|
+
record({
|
|
197
|
+
kind: 'consolidate', scope: 'private', sessionId, libraryId: binding.libraryId || null, ok: true,
|
|
198
|
+
message: `整理完成(远程)· 写入 ${memories.length}`,
|
|
199
|
+
detail: { ...result, forced: force },
|
|
200
|
+
})
|
|
201
|
+
return result
|
|
196
202
|
} catch (error) {
|
|
203
|
+
record({
|
|
204
|
+
kind: 'consolidate', scope: 'private', sessionId, libraryId: binding.libraryId || null, ok: false, level: 'error',
|
|
205
|
+
message: '整理失败(远程):' + String(error?.message || error),
|
|
206
|
+
detail: { forced: force },
|
|
207
|
+
})
|
|
197
208
|
return { ok: false, error: String(error?.message || error) }
|
|
198
209
|
}
|
|
199
210
|
}
|
|
@@ -210,6 +221,47 @@ async function loadBuildForkSeed() {
|
|
|
210
221
|
}
|
|
211
222
|
}
|
|
212
223
|
|
|
224
|
+
function agentOptionsFromConfig(config) {
|
|
225
|
+
if (!config || typeof config !== 'object') return null
|
|
226
|
+
const provider = typeof config.provider === 'string' ? config.provider.trim() : ''
|
|
227
|
+
const model = typeof config.model === 'string' ? config.model.trim() : ''
|
|
228
|
+
if (!provider || !model) return null
|
|
229
|
+
const options = { provider, model }
|
|
230
|
+
if (typeof config.reasoningEffort === 'string' && config.reasoningEffort) options.reasoningEffort = config.reasoningEffort
|
|
231
|
+
if (Number.isSafeInteger(config.maxTokens) && config.maxTokens > 0) options.maxTokens = config.maxTokens
|
|
232
|
+
return options
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// A fork starts a fresh agent loop, so it does not inherit the parent's Agent options.
|
|
236
|
+
// The parent's live request header is the route that actually served its latest turn;
|
|
237
|
+
// an older request/header event is the only equivalent for a closed session. Without
|
|
238
|
+
// one of these, the standard preset cannot resolve {{model}} and every fork dies before
|
|
239
|
+
// its first step, so refuse to create that fork instead of queueing another doomed seed.
|
|
240
|
+
function inheritedAgentOptions(ctx, sessionId, events) {
|
|
241
|
+
let live
|
|
242
|
+
try { live = ctx.get?.('agents')?.get?.(sessionId) } catch {}
|
|
243
|
+
try {
|
|
244
|
+
const fromRequest = agentOptionsFromConfig(live?.session?.requestHeader?.()?.config)
|
|
245
|
+
if (fromRequest) return fromRequest
|
|
246
|
+
} catch {}
|
|
247
|
+
const fromLive = agentOptionsFromConfig(live?.options)
|
|
248
|
+
if (fromLive) return fromLive
|
|
249
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
250
|
+
if (events[i]?.type !== 'request/header') continue
|
|
251
|
+
const fromEvent = agentOptionsFromConfig(events[i]?.data?.header?.config)
|
|
252
|
+
if (fromEvent) return fromEvent
|
|
253
|
+
}
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function turnEndFailure(event) {
|
|
258
|
+
const reason = event?.data?.reason
|
|
259
|
+
if (!reason || typeof reason !== 'object') return 'the fork produced no turn/end event'
|
|
260
|
+
if (reason.kind === 'completed') return null
|
|
261
|
+
const message = reason?.error?.message || reason?.reason?.message || reason?.message
|
|
262
|
+
return 'the fork turn ended ' + (reason.kind || 'without a reason') + (message ? ': ' + message : '')
|
|
263
|
+
}
|
|
264
|
+
|
|
213
265
|
const inFlight = new Map()
|
|
214
266
|
|
|
215
267
|
// Every agent-backend consolidation runs here, live sessions included: the same
|
|
@@ -230,29 +282,56 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
|
|
|
230
282
|
const abort = new AbortController()
|
|
231
283
|
inFlight.set(sessionId, abort)
|
|
232
284
|
let handle = null
|
|
285
|
+
let offSession = null
|
|
286
|
+
let turnEnd = null
|
|
287
|
+
// 每次整理都要在活动日志里留一条:谁触发(手动/空闲)、fork 有没有建、结果是完成、
|
|
288
|
+
// 超时还是失败。否则"点了没反应"只能靠翻会话文件才知道发生了什么。
|
|
289
|
+
const finish = (result) => {
|
|
290
|
+
const ok = result?.ok === true
|
|
291
|
+
const skipped = Boolean(result?.skipped)
|
|
292
|
+
record({
|
|
293
|
+
kind: 'consolidate',
|
|
294
|
+
scope: 'private',
|
|
295
|
+
sessionId,
|
|
296
|
+
libraryId: binding.libraryId || null,
|
|
297
|
+
ok,
|
|
298
|
+
level: ok ? (skipped ? 'info' : 'info') : (result?.timedOut ? 'warn' : 'error'),
|
|
299
|
+
message: '整理' + (ok ? (skipped ? '跳过(没有值得记的内容)' : '完成') : (result?.timedOut ? '超时' : '失败'))
|
|
300
|
+
+ (force ? ' · 手动' : ' · 空闲')
|
|
301
|
+
+ (result?.childId ? ' · fork ' + result.childId.slice(-8) : '')
|
|
302
|
+
+ (result?.error ? ':' + String(result.error) : '')
|
|
303
|
+
+ (result?.count !== undefined ? ' · 写入 ' + result.count : ''),
|
|
304
|
+
detail: { force, ...result },
|
|
305
|
+
refs: [],
|
|
306
|
+
})
|
|
307
|
+
return result
|
|
308
|
+
}
|
|
233
309
|
try {
|
|
234
310
|
const sessionQuery = ctx.get?.('sessionQuery')
|
|
235
311
|
const agents = ctx.get?.('agents')
|
|
236
312
|
if (!sessionQuery?.readSession || typeof agents?.create !== 'function') {
|
|
237
|
-
return { ok: false, error: 'host has no readSession / agents.create; historical consolidation is unavailable' }
|
|
313
|
+
return finish({ ok: false, error: 'host has no readSession / agents.create; historical consolidation is unavailable' })
|
|
238
314
|
}
|
|
239
315
|
const buildForkSeed = await loadBuildForkSeed()
|
|
240
|
-
if (!buildForkSeed) return { ok: false, error: 'dsh-session buildForkSeed is unavailable' }
|
|
316
|
+
if (!buildForkSeed) return finish({ ok: false, error: 'dsh-session buildForkSeed is unavailable' })
|
|
241
317
|
|
|
242
318
|
const childId = `memu-consolidate-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`
|
|
243
319
|
const snapshot = await sessionQuery.readSession(sessionId)
|
|
244
320
|
const events = Array.isArray(snapshot?.events) ? snapshot.events : []
|
|
245
321
|
const lastSeq = events[events.length - 1]?.seq
|
|
246
|
-
if (events.length === 0 || lastSeq === undefined) return { ok: false, error: 'this session has no content to consolidate' }
|
|
322
|
+
if (events.length === 0 || lastSeq === undefined) return finish({ ok: false, error: 'this session has no content to consolidate' })
|
|
247
323
|
// The scheduler asks "is this conversation idle", which is not the same as
|
|
248
324
|
// "did anything happen that deserves a memory". Answer the second question
|
|
249
325
|
// before paying for a fork; a manual consolidation passes force and skips it.
|
|
250
326
|
if (!force) {
|
|
251
327
|
const value = assessConsolidationValue(unconsolidatedEvents(binding, events))
|
|
252
|
-
if (!value.worth) return { ok: true, sessionId, skipped: value.reason, settled: true, assessment: value }
|
|
328
|
+
if (!value.worth) return finish({ ok: true, sessionId, skipped: value.reason, settled: true, assessment: value })
|
|
253
329
|
}
|
|
254
330
|
const header = snapshot?.session || {}
|
|
255
331
|
|
|
332
|
+
const agentOptions = inheritedAgentOptions(ctx, sessionId, events)
|
|
333
|
+
if (!agentOptions) return finish({ ok: false, error: 'cannot inherit the parent model/provider; consolidation fork was not created' })
|
|
334
|
+
|
|
256
335
|
// `inheritedEventCount` is a TOP-LEVEL CreateAgentOptions field, NOT session
|
|
257
336
|
// metadata: SessionStore.prepare() rebuilds the header from a fixed meta
|
|
258
337
|
// whitelist (version, id, createdAt, cwd, parentSession, isSeeded, origin,
|
|
@@ -273,9 +352,37 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
|
|
|
273
352
|
// The fork is the parent's transcript, so it inherits every event through lastSeq.
|
|
274
353
|
inheritedEventCount: lastSeq + 1,
|
|
275
354
|
seed: buildForkSeed(events, lastSeq),
|
|
355
|
+
agentOptions,
|
|
276
356
|
signal: abort.signal,
|
|
277
357
|
})
|
|
278
358
|
|
|
359
|
+
// 先记"已开始"再等结果:一个大会话的 fork 可能跑好几分钟,没有这条记录,
|
|
360
|
+
// 侧边栏和日志页在等待期间只会显示上一次的结果,用户会以为按钮没生效。
|
|
361
|
+
record({
|
|
362
|
+
kind: 'consolidate',
|
|
363
|
+
scope: 'private',
|
|
364
|
+
sessionId,
|
|
365
|
+
libraryId: binding.libraryId || null,
|
|
366
|
+
ok: true,
|
|
367
|
+
message: '整理已开始 · ' + (force ? '手动' : '空闲') + ' · fork ' + childId.slice(-8) + ' · ' + events.length + ' 条事件',
|
|
368
|
+
detail: { childId, force, events: events.length, phase: 'started' },
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// turn/end is the authoritative outcome of the fork's first turn. whenIdle()
|
|
372
|
+
// only means the driver stopped: an error turn also resolves it, so without
|
|
373
|
+
// this check a failed consolidation would advance lastSleepSeq and look like
|
|
374
|
+
// success to the idle sweep. The listener is public and session-scoped; we
|
|
375
|
+
// register before followup so the event cannot race the wait.
|
|
376
|
+
const childSession = handle?.agent?.session
|
|
377
|
+
if (typeof ctx.on === 'function' && childSession) {
|
|
378
|
+
try {
|
|
379
|
+
offSession = ctx.on('session/event', (session, event) => {
|
|
380
|
+
if (session !== childSession || event?.type !== 'turn/end') return
|
|
381
|
+
turnEnd = event
|
|
382
|
+
})
|
|
383
|
+
} catch {}
|
|
384
|
+
}
|
|
385
|
+
|
|
279
386
|
// The fork must write into the parent's library, not derive one from its own
|
|
280
387
|
// freshly minted session id.
|
|
281
388
|
setSessionBinding(childId, {
|
|
@@ -290,8 +397,8 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
|
|
|
290
397
|
})
|
|
291
398
|
|
|
292
399
|
handle.agent.followup(noticeMessage(buildPrivateConsolidationPrompt(sessionId), '整理这场对话的私有记忆'))
|
|
293
|
-
// followup only queues
|
|
294
|
-
//
|
|
400
|
+
// followup only queues. whenIdle says the driver stopped; turn/end says
|
|
401
|
+
// whether that turn actually succeeded, so both are checked below.
|
|
295
402
|
const idle = typeof handle.agent.whenIdle === 'function' ? handle.agent.whenIdle() : Promise.resolve()
|
|
296
403
|
let timer = null
|
|
297
404
|
let outcome
|
|
@@ -302,14 +409,33 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
|
|
|
302
409
|
// A timeout is not success: the fork may still be mid-write. Report it as
|
|
303
410
|
// unsettled so the scheduler can retry, instead of silently dropping the
|
|
304
411
|
// consolidation.
|
|
305
|
-
//
|
|
306
|
-
if (outcome === 'done')
|
|
307
|
-
|
|
412
|
+
// On success, the next assessment measures from here, not from this transcript top.
|
|
413
|
+
if (outcome === 'done') {
|
|
414
|
+
const failure = turnEndFailure(turnEnd)
|
|
415
|
+
if (failure) return finish({ ok: false, error: 'consolidation fork failed: ' + failure, sessionId, childId, settled: false, timedOut: false })
|
|
416
|
+
setSessionBinding(sessionId, { lastSleepSeq: lastSeq })
|
|
417
|
+
}
|
|
418
|
+
// A timeout is not success: the fork may still be mid-write. It has to come
|
|
419
|
+
// back with a message, not just `timedOut` — the settings button renders
|
|
420
|
+
// `result.error` and an empty string there is indistinguishable from a click
|
|
421
|
+
// that did nothing at all (observed: two clicks, no feedback, no memories).
|
|
422
|
+
if (outcome === 'timeout') {
|
|
423
|
+
return finish({
|
|
424
|
+
ok: false,
|
|
425
|
+
sessionId,
|
|
426
|
+
childId,
|
|
427
|
+
settled: false,
|
|
428
|
+
timedOut: true,
|
|
429
|
+
error: `consolidation timed out after ${Math.round(maxWaitMs / 1000)}s; the fork was stopped (memories written before the timeout are kept). Retry, or consolidate a shorter conversation.`,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
return finish({ ok: outcome === 'done', sessionId, childId, settled: outcome === 'done', timedOut: false })
|
|
308
433
|
} catch (error) {
|
|
309
|
-
return { ok: false, error: String(error?.message || error) }
|
|
434
|
+
return finish({ ok: false, error: String(error?.message || error) })
|
|
310
435
|
} finally {
|
|
311
436
|
inFlight.delete(sessionId)
|
|
312
437
|
abort.abort()
|
|
438
|
+
try { offSession?.() } catch {}
|
|
313
439
|
try { await handle?.dispose?.() } catch {}
|
|
314
440
|
}
|
|
315
441
|
}
|