dsh-memu-memory 1.1.2 → 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 -6
- package/README.md +21 -5
- package/README.zh.md +21 -5
- 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 +32 -5
- package/docs/troubleshooting.md +36 -7
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +697 -49
- 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 +36 -0
- package/lib/inject.js +131 -30
- 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/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
|
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
|
}
|
package/lib/rpc.js
CHANGED
|
@@ -16,9 +16,11 @@ import { maybeMigrateLibraries } from './migrate.js'
|
|
|
16
16
|
import { getPromptStates, savePromptOverride, resetPrompt, resetAllPrompts } from './prompts.js'
|
|
17
17
|
import { statsView } from './stats.js'
|
|
18
18
|
import { getMemuConfig, saveMemuConfig, setEmbeddingSource, testMemuConfig, getSummaryConfig, getVisionConfig } from './memu-config.js'
|
|
19
|
+
import { getLocalVectorState, downloadLocalVectorModel, installLocalVectorRuntime, selectLocalVectorModel, switchLocalVectorProfile, rollbackLocalVectorProfile, abortLocalVectorMigration, testLocalVector } from './local-vector-service.js'
|
|
19
20
|
import { webUiHtml, WEBUI_PATH, standaloneHtml, standaloneAsset, SETTINGS_PATH } from './webui.js'
|
|
20
21
|
import { getGraph, listEntities, createEntity, renameEntity, linkEntries, unlinkEntry, getRelationship, updateRelationship, graphExport, restoreGraphData } from './graph.js'
|
|
21
22
|
import { getAttachment, attachmentThumbnail, attachmentPreview, exportMediaBundle, importMediaBundle, streamMediaExport, importMediaStream } from './media.js'
|
|
23
|
+
import { listEvents, logStats, clearEvents } from './activity-log.js'
|
|
22
24
|
|
|
23
25
|
const ENDPOINT = '/dsh-memu-memory'
|
|
24
26
|
|
|
@@ -95,7 +97,7 @@ async function privateListView(scope) {
|
|
|
95
97
|
content: String(content ?? e?.content ?? '').slice(0, 4000),
|
|
96
98
|
})
|
|
97
99
|
try {
|
|
98
|
-
const result = await privateList(scope)
|
|
100
|
+
const result = await privateList({ ...scope, preferIndex: true })
|
|
99
101
|
return {
|
|
100
102
|
libraryId: result.libraryId,
|
|
101
103
|
source: result.source || 'engine',
|
|
@@ -492,6 +494,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
492
494
|
memuConfig: getMemuConfig(),
|
|
493
495
|
summaryConfig: getSummaryConfig(),
|
|
494
496
|
visionConfig: getVisionConfig(),
|
|
497
|
+
localVector: getLocalVectorState(),
|
|
495
498
|
settings: getSettings(),
|
|
496
499
|
libraries: libraryView(),
|
|
497
500
|
archivedLibraries: archivedLibraryView(),
|
|
@@ -524,6 +527,9 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
524
527
|
newSessionDefault: getSettings().newSessionDefault || 'both',
|
|
525
528
|
},
|
|
526
529
|
libraries: listLibraries().map(({ id, name, scopeKey: key }) => ({ id, name, scopeKey: key })),
|
|
530
|
+
// Memory domains accepted by setDomain. Prompt page types are a different
|
|
531
|
+
// concept (getPromptDomains), so this stays a fixed list.
|
|
532
|
+
domains: ['rp', 'coding', 'general', 'mixed'],
|
|
527
533
|
// A folder with both layers off never prompts: there is nothing to ask.
|
|
528
534
|
folder,
|
|
529
535
|
shouldPrompt: folder.useLibrary !== false || folder.useGlobal !== false,
|
|
@@ -550,7 +556,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
550
556
|
case 'resetSettings': {
|
|
551
557
|
const patch = {}
|
|
552
558
|
for (const key of [
|
|
553
|
-
'autoInject', 'privateTopK', 'globalTopK', 'anchorTokens', 'turnTokens', 'dedupeTurns',
|
|
559
|
+
'autoInject', 'privateTopK', 'globalTopK', 'anchorTokens', 'turnTokens', 'injectionTotalTokens', 'dedupeTurns',
|
|
554
560
|
'globalEveryNTurns', 'minUserChars', 'mustRememberCap', 'randomCueChance',
|
|
555
561
|
'autoInstallEngine', 'newSessionPromptDismissed', 'newSessionDefault', 'habitsInjection',
|
|
556
562
|
'sleepConsolidation', 'sleepAfterIdleMinutes', 'autoCurateEveryHours',
|
|
@@ -564,7 +570,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
564
570
|
case 'setSettings': {
|
|
565
571
|
const patch = payload?.patch && typeof payload.patch === 'object' ? payload.patch : {}
|
|
566
572
|
const allowed = new Set([
|
|
567
|
-
'autoInject', 'privateTopK', 'globalTopK', 'anchorTokens', 'turnTokens',
|
|
573
|
+
'autoInject', 'privateTopK', 'globalTopK', 'anchorTokens', 'turnTokens', 'injectionTotalTokens',
|
|
568
574
|
'relevanceThreshold', 'dedupeTurns', 'globalEveryNTurns', 'minUserChars',
|
|
569
575
|
// newSessionDefault has to be writable here: "remember my choice" stores
|
|
570
576
|
// it, and a dropped patch silently turned every remembered answer into
|
|
@@ -880,8 +886,10 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
880
886
|
const sessionId = payload?.sessionId || null
|
|
881
887
|
const exec = sessionId ? execFor(sessionId) : { agent: null }
|
|
882
888
|
try {
|
|
883
|
-
const result = await globalList({ exec, domain: payload?.domain })
|
|
889
|
+
const result = await globalList({ exec, domain: payload?.domain, preferIndex: true })
|
|
884
890
|
return {
|
|
891
|
+
warning: result.warning || null,
|
|
892
|
+
source: result.source || null,
|
|
885
893
|
files: (result.files || []).map((f) => ({
|
|
886
894
|
id: f.name,
|
|
887
895
|
title: f.entry?.title || f.description || '',
|
|
@@ -1086,6 +1094,24 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
1086
1094
|
})
|
|
1087
1095
|
case 'testMemuConfig':
|
|
1088
1096
|
return testMemuConfig()
|
|
1097
|
+
case 'getLocalVectorState':
|
|
1098
|
+
return getLocalVectorState()
|
|
1099
|
+
case 'downloadLocalVectorModel':
|
|
1100
|
+
return await downloadLocalVectorModel(String(payload?.id || ''), { confirmed: payload?.confirmed === true })
|
|
1101
|
+
case 'selectLocalVectorModel':
|
|
1102
|
+
return selectLocalVectorModel(String(payload?.id || ''))
|
|
1103
|
+
case 'installLocalVectorRuntime':
|
|
1104
|
+
return await installLocalVectorRuntime({ modelId: payload?.id || null, confirmed: payload?.confirmed === true })
|
|
1105
|
+
case 'switchLocalVectorProfile':
|
|
1106
|
+
if (payload?.confirmed !== true) throw new Error('switching the local vector profile requires explicit confirmation')
|
|
1107
|
+
return await switchLocalVectorProfile({ id: payload?.id })
|
|
1108
|
+
case 'rollbackLocalVectorProfile':
|
|
1109
|
+
if (payload?.confirmed !== true) throw new Error('rolling back the local vector profile requires explicit confirmation')
|
|
1110
|
+
return await rollbackLocalVectorProfile()
|
|
1111
|
+
case 'abortLocalVectorMigration':
|
|
1112
|
+
return abortLocalVectorMigration()
|
|
1113
|
+
case 'testLocalVector':
|
|
1114
|
+
return await testLocalVector()
|
|
1089
1115
|
case 'savePrompt': {
|
|
1090
1116
|
const key = String(payload?.key || '')
|
|
1091
1117
|
const text = String(payload?.text ?? '')
|
|
@@ -1104,6 +1130,21 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
1104
1130
|
return { sizes: engineVersionSizes() }
|
|
1105
1131
|
case 'getEngine':
|
|
1106
1132
|
return engineView()
|
|
1133
|
+
case 'getActivityLog':
|
|
1134
|
+
return {
|
|
1135
|
+
events: listEvents({
|
|
1136
|
+
limit: payload?.limit,
|
|
1137
|
+
kind: payload?.kind,
|
|
1138
|
+
level: payload?.level,
|
|
1139
|
+
scope: payload?.scope,
|
|
1140
|
+
sessionId: payload?.sessionId,
|
|
1141
|
+
since: payload?.since,
|
|
1142
|
+
q: payload?.q,
|
|
1143
|
+
}),
|
|
1144
|
+
stats: logStats(),
|
|
1145
|
+
}
|
|
1146
|
+
case 'clearActivityLog':
|
|
1147
|
+
return clearEvents()
|
|
1107
1148
|
default:
|
|
1108
1149
|
throw new Error(`unknown endpoint: ${endpoint}`)
|
|
1109
1150
|
}
|