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.
Files changed (49) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +21 -5
  3. package/README.zh.md +21 -5
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +32 -5
  8. package/docs/troubleshooting.md +36 -7
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +697 -49
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +36 -0
  20. package/lib/inject.js +131 -30
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/private.js +139 -13
  31. package/lib/rpc.js +45 -4
  32. package/lib/state.js +109 -161
  33. package/lib/tools.js +9 -5
  34. package/lib/vector-migration.js +878 -0
  35. package/lib/vector-profile.js +941 -0
  36. package/package.json +3 -2
  37. package/scripts/acceptance-local.mjs +5 -4
  38. package/scripts/chain-matrix.mjs +527 -0
  39. package/scripts/check-all.mjs +19 -0
  40. package/scripts/check-doc-drift.mjs +353 -0
  41. package/scripts/diagnose-bindings.mjs +176 -0
  42. package/scripts/local-runtime-install.mjs +142 -0
  43. package/scripts/local-runtime-verify.mjs +56 -0
  44. package/scripts/local-vector-verify.mjs +93 -0
  45. package/scripts/recall-bench.mjs +813 -0
  46. package/scripts/repetition-ab-scenarios.mjs +93 -0
  47. package/scripts/repetition-detector-snapshot.mjs +314 -0
  48. package/scripts/repetition-eval-live.mjs +360 -0
  49. package/scripts/repetition-eval.mjs +702 -0
@@ -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
- // Derived, not a separate stored flag: a loopback base URL is what "local"
112
- // actually means to MemU, so the toggle can never drift from the config.
113
- source: baseUrl && /^https?:\/\/(127\.0\.0\.1|localhost)(:|\/)/.test(baseUrl) ? 'local' : 'remote',
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
- // Switching source rewrites the connection fields but never the API key: leaving a
136
- // third-party key in place after moving to a loopback endpoint would send it to a
137
- // local server, and clearing it on the way out would silently break a working setup.
138
- export function setEmbeddingSource(source, { keepModel = false } = {}) {
139
- const env = readMemuEnv()
140
- if (source === 'local') {
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 run(args, opts) {
79
- return runRaw(['-m', 'memu', ...args], opts)
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 run(['retrieve', String(query), '--json'], {
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 run(['list-files', '--json'], {
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 run(['commit', file, '--json'], {
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, getMemoryBackend, loadPrivateIndex, effectiveScope } from './state.js'
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
- return { ok: true, sessionId, backend: 'api', count: memories.length }
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, so idle is the only signal that the work actually ran
294
- // before the scratch fork is thrown away.
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
- // The next assessment measures from here, not from the top of the transcript.
306
- if (outcome === 'done') setSessionBinding(sessionId, { lastSleepSeq: lastSeq })
307
- return { ok: outcome === 'done', sessionId, childId, settled: outcome === 'done', timedOut: outcome === 'timeout' }
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
  }