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
package/lib/state.js CHANGED
@@ -5,12 +5,20 @@ import {
5
5
  dataDir, librariesDir, indexDir, backupsDir,
6
6
  ensureDir, readJson, readFailure, readFailuresUnder, writeJson, withFileLock, assertJsonShape, warn, stamp, shortHash, libraryIdForScope, isSafeId,
7
7
  } from './paths.js'
8
+ import {
9
+ activeLeases, createBackup, deleteBackupEntry, listBackupEntries,
10
+ pruneBackups, restoreFromBackup, withBackupLock,
11
+ } from './backup.js'
8
12
  import { migrateIndexVisibility } from './visibility.js'
13
+ import { memuDbFor, memuGlobalDbFor } from './vector-profile.js'
9
14
 
10
15
  const STATE_FILE = () => path.join(dataDir(), 'state.json')
11
16
  const GLOBAL_DB = () => path.join(dataDir(), 'global.sqlite3')
12
17
  const GLOBAL_INDEX = () => path.join(indexDir(), 'global.json')
13
18
 
19
+ // Observable outcome of the last backup action; see listBackupEntries().
20
+ let lastBackupReport = null
21
+
14
22
  export const DEFAULT_SETTINGS = {
15
23
  sidebarSettings: false,
16
24
  visionCaptionEnabled: true,
@@ -24,12 +32,17 @@ export const DEFAULT_SETTINGS = {
24
32
  // "too little memory", it is noise: a per-turn block sized for paragraph-long
25
33
  // prompts drowns the conversation it was meant to help. So the per-turn budgets
26
34
  // are small, and the windows that stop repetition are measured in many turns.
27
- privateTopK: 3,
28
- globalTopK: 2,
29
- anchorTokens: 700,
30
- // 640 tokens per user turn. A dense chat calls recall on almost every message,
31
- // so the injected block has to stay clearly smaller than the message it serves.
32
- turnTokens: 640,
35
+ privateTopK: 5,
36
+ globalTopK: 5,
37
+ anchorTokens: 800,
38
+ // 800 tokens for one recall block. A dense chat calls recall on almost every
39
+ // message, so the injected block has to stay clearly smaller than the message it
40
+ // serves. This is the recall budget, not the whole plugin-message ceiling.
41
+ turnTokens: 800,
42
+ // Whole plugin-message ceiling: anchor + recall + optional cue/texture. It must
43
+ // be at least the sum of the sub-budgets the user wants to allow; the default leaves
44
+ // room for the default anchor and recall blocks.
45
+ injectionTotalTokens: 2000,
33
46
  relevanceThreshold: 0.4,
34
47
  // 16 turns is a couple of minutes of group traffic. The old 8 expired inside a
35
48
  // single burst, so the same entry came back every minute or two.
@@ -90,6 +103,7 @@ export const DEFAULT_SETTINGS = {
90
103
  // agent writes and summarizes into the JSON index, with no MemU engine and no
91
104
  // embedding API. 'api' hands the work to the configured remote MemU service.
92
105
  memoryBackend: 'agent',
106
+ vectorSource: 'local',
93
107
  habitsInjection: true,
94
108
  // A positive age marks cleanup candidates; deletion always needs a user action.
95
109
  purgeArchivedAfterDays: 0,
@@ -110,10 +124,14 @@ function defaultState() {
110
124
  function mergeState(raw) {
111
125
  const base = defaultState()
112
126
  if (!raw || typeof raw !== 'object') return base
127
+ const rawSettings = raw.settings && typeof raw.settings === 'object' ? raw.settings : {}
128
+ const legacyVectorSource = rawSettings.vectorSource === 'api' || rawSettings.vectorSource === 'local'
129
+ ? rawSettings.vectorSource
130
+ : (rawSettings.memoryMode === 'cloud' ? 'api' : 'local')
113
131
  return {
114
132
  ...base,
115
133
  ...raw,
116
- settings: { ...base.settings, ...(raw.settings || {}) },
134
+ settings: { ...base.settings, ...rawSettings, vectorSource: legacyVectorSource },
117
135
  libraries: raw.libraries && typeof raw.libraries === 'object' ? raw.libraries : {},
118
136
  sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},
119
137
  workspacePolicies: raw.workspacePolicies && typeof raw.workspacePolicies === 'object' ? raw.workspacePolicies : {},
@@ -306,16 +324,27 @@ export function getMemoryBackend() {
306
324
  return loadState().settings?.memoryBackend === 'api' ? 'api' : 'agent'
307
325
  }
308
326
 
327
+ // Single route authority for automatic memory reads and writes.
328
+ export function effectiveVectorRoute(settings = loadState().settings) {
329
+ if (settings?.memoryBackend !== 'api') return 'lexical'
330
+ return settings?.vectorSource === 'api' ? 'remote-api' : 'managed-local'
331
+ }
332
+
333
+ export function vectorBackendEnabled(settings = loadState().settings) {
334
+ return effectiveVectorRoute(settings) !== 'lexical'
335
+ }
336
+
309
337
  export function updateSettings(patch) {
310
338
  const clean = {}
311
339
  const ranges = {
312
340
  privateTopK: [1, 20], globalTopK: [1, 20], anchorTokens: [160, 8000], turnTokens: [80, 8000],
341
+ injectionTotalTokens: [160, 16000],
313
342
  relevanceThreshold: [0, 1], dedupeTurns: [0, 200], globalEveryNTurns: [0, 200], minUserChars: [0, 1000],
314
343
  mustRememberCap: [1, 100], randomCueChance: [0, 1], shortTermTtlHours: [0, 8760],
315
344
  unpinBudgetPerSession: [1, 20], unpinCooldownMinutes: [0, 1440], sleepAfterIdleMinutes: [1, 1440],
316
345
  autoCurateEveryHours: [0, 8760], autoCurateSessionLimit: [1, 10], purgeArchivedAfterDays: [0, 36500],
317
346
  }
318
- const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
347
+ const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], vectorSource: ['local', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
319
348
  for (const [key, value] of Object.entries(patch || {})) {
320
349
  if (!Object.hasOwn(DEFAULT_SETTINGS, key)) continue
321
350
  if (enums[key]) {
@@ -906,12 +935,12 @@ export function markAnniversaryShown(sessionId, memoryId, dayKey) {
906
935
 
907
936
  export function memoryDbPath(libraryId) {
908
937
  ensureDir(librariesDir())
909
- return path.join(librariesDir(), `${libraryId}.sqlite3`)
938
+ return effectiveVectorRoute() === 'managed-local' ? memuDbFor(libraryId) : path.join(librariesDir(), `${libraryId}.sqlite3`)
910
939
  }
911
940
 
912
941
  export function globalDbPath() {
913
942
  ensureDir(dataDir())
914
- return GLOBAL_DB()
943
+ return effectiveVectorRoute() === 'managed-local' ? memuGlobalDbFor() : GLOBAL_DB()
915
944
  }
916
945
 
917
946
  // Index files are parsed on every folder in the sidebar, and the parse (full
@@ -1144,175 +1173,94 @@ export function newMemoryId(prefix = 'mem') {
1144
1173
  return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`
1145
1174
  }
1146
1175
 
1147
- export function backupDatabases() {
1176
+ /**
1177
+ * Creates a managed backup of the local memory stores (plugin state, indexes,
1178
+ * prompts, attachments, the vector journal and every MemU database), then keeps
1179
+ * the newest BACKUP_KEEP complete ones. Retention, staging, checksums, atomic
1180
+ * publish and the shared lock all live in lib/backup.js so there is exactly one
1181
+ * place that decides what a backup is and which backup may be deleted.
1182
+ *
1183
+ * Returns the published directory: callers (engine switch, library delete,
1184
+ * scripts) treat the return value as a path.
1185
+ */
1186
+ export function backupDatabases(options = {}) {
1148
1187
  // Pending bookkeeping changes live in memory for up to 50ms; a backup taken
1149
1188
  // before a flush would silently capture the previous state.
1150
- flushState()
1151
- const dest = path.join(backupsDir(), stamp().replace(/[:.]/g, '-'))
1152
- ensureDir(dest)
1153
- ensureDir(path.join(dest, 'libraries'))
1154
- ensureDir(path.join(dest, 'index'))
1155
- // The prompt manifest copy below silently no-opped without this directory —
1156
- // an empty catch swallowed the ENOENT and backups shipped without it.
1157
- ensureDir(path.join(dest, 'prompts'))
1158
- const copy = (from, to) => {
1159
- let info
1160
- try { info = fs.lstatSync(from) } catch (error) { if (error.code === 'ENOENT') return; throw error }
1161
- if (!info.isFile()) throw new Error(`backup refuses non-file: ${path.basename(from)}`)
1162
- fs.copyFileSync(from, to)
1163
- }
1164
- copy(STATE_FILE(), path.join(dest, 'state.json'))
1165
- copy(GLOBAL_DB(), path.join(dest, 'global.sqlite3'))
1166
- copy(GLOBAL_INDEX(), path.join(dest, 'index', 'global.json'))
1167
- copy(path.join(dataDir(), 'prompts', 'manifest.json'), path.join(dest, 'prompts', 'manifest.json'))
1189
+ let report
1168
1190
  try {
1169
- for (const name of fs.readdirSync(librariesDir())) {
1170
- if (name.endsWith('.sqlite3') || name.endsWith('.sqlite3-wal') || name.endsWith('.sqlite3-shm')) {
1171
- copy(path.join(librariesDir(), name), path.join(dest, 'libraries', name))
1172
- }
1173
- }
1174
- } catch (error) { if (error.code !== 'ENOENT') throw error }
1175
- try {
1176
- for (const name of fs.readdirSync(indexDir())) {
1177
- if (name.endsWith('.json')) copy(path.join(indexDir(), name), path.join(dest, 'index', name))
1178
- }
1179
- } catch (error) { if (error.code !== 'ENOENT') throw error }
1180
- const copyTree = (source, target) => {
1181
- let names
1182
- try { names = fs.readdirSync(source, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return; throw error }
1183
- ensureDir(target)
1184
- for (const item of names) {
1185
- if (item.isSymbolicLink()) throw new Error('backup refuses symbolic links')
1186
- if (item.isDirectory()) copyTree(path.join(source, item.name), path.join(target, item.name))
1187
- else copy(path.join(source, item.name), path.join(target, item.name))
1188
- }
1189
- }
1190
- copyTree(path.join(dataDir(), 'attachments'), path.join(dest, 'attachments'))
1191
- copyTree(path.join(dataDir(), 'prompts'), path.join(dest, 'prompts'))
1192
-
1193
- // A backup whose integrity nobody can check is only a hope. Hash every file so
1194
- // a restore can refuse a truncated or half-written copy.
1195
- const files = {}
1196
- const walk = (dir, prefix = '') => {
1197
- let names = []
1198
- try { names = fs.readdirSync(dir) } catch { return }
1199
- for (const name of names) {
1200
- const full = path.join(dir, name)
1201
- let stat = null
1202
- try { stat = fs.statSync(full) } catch { continue }
1203
- if (stat.isDirectory()) { walk(full, `${prefix}${name}/`); continue }
1204
- let sha256 = null
1205
- try { sha256 = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex') } catch {}
1206
- files[`${prefix}${name}`] = { bytes: stat.size, sha256 }
1191
+ report = createBackup({ ...options, flushState })
1192
+ } catch (error) {
1193
+ // A refusal is the safety property working, not something to swallow: a
1194
+ // database the engine could not snapshot aborts the backup, and the reason
1195
+ // has to stay visible on the same channel the panel already reads.
1196
+ lastBackupReport = {
1197
+ at: stamp(),
1198
+ action: 'create',
1199
+ status: 'failed',
1200
+ code: error?.code || null,
1201
+ error: String(error?.message || error),
1202
+ name: null,
1203
+ dir: null,
1204
+ files: 0,
1205
+ bytes: 0,
1206
+ snapshots: [],
1207
+ pruned: [],
1208
+ pruneFailed: [],
1209
+ pendingCleanup: 0,
1210
+ notice: null,
1207
1211
  }
1212
+ throw error
1208
1213
  }
1209
- walk(dest)
1210
- const current = engineCurrentSafe()
1211
- writeJson(path.join(dest, 'manifest.json'), {
1212
- version: 1,
1213
- createdAt: stamp(),
1214
- engine: current ? { version: current.version, schemaVersion: current.schemaVersion || null } : null,
1215
- files,
1216
- })
1217
- return dest
1214
+ lastBackupReport = { at: stamp(), action: 'create', ...report }
1215
+ return report.dir
1218
1216
  }
1219
1217
 
1220
- function engineCurrentSafe() {
1221
- try {
1222
- return JSON.parse(fs.readFileSync(path.join(dataDir(), 'engine', 'current.json'), 'utf8'))
1223
- } catch {
1224
- return null
1225
- }
1218
+ /** What the last backup/restore/prune did, so the outcome stays observable. */
1219
+ export function lastBackupReportView() {
1220
+ return lastBackupReport ? { ...lastBackupReport } : null
1226
1221
  }
1227
1222
 
1223
+ /** Newest first, including entries that failed to complete (reported, never deleted). */
1228
1224
  export function listBackups() {
1229
- let names = []
1230
- try { names = fs.readdirSync(backupsDir()) } catch { return [] }
1231
- return names
1232
- .map((name) => {
1233
- const dir = path.join(backupsDir(), name)
1234
- let stat = null
1235
- try { stat = fs.statSync(dir) } catch { return null }
1236
- if (!stat.isDirectory()) return null
1237
- const manifest = readJson(path.join(dir, 'manifest.json'), null)
1238
- const fileCount = manifest?.files ? Object.keys(manifest.files).length : null
1239
- const bytes = manifest?.files
1240
- ? Object.values(manifest.files).reduce((sum, f) => sum + Number(f.bytes || 0), 0)
1241
- : null
1242
- return {
1243
- name,
1244
- dir,
1245
- createdAt: manifest?.createdAt || stat.mtime.toISOString(),
1246
- engine: manifest?.engine || null,
1247
- files: fileCount,
1248
- bytes,
1249
- hasManifest: Boolean(manifest),
1250
- }
1251
- })
1252
- .filter(Boolean)
1253
- .sort((a, b) => String(b.name).localeCompare(String(a.name)))
1225
+ return listBackupEntries()
1254
1226
  }
1255
1227
 
1256
1228
  /** Removes one backup directory. The name is validated, never used as a path. */
1257
1229
  export function deleteBackup(name) {
1258
- if (!isSafeId(name)) throw new Error('invalid backup name')
1259
- const dir = path.join(backupsDir(), name)
1260
- if (!fs.existsSync(dir)) throw new Error(`backup not found: ${name}`)
1261
- fs.rmSync(dir, { recursive: true, force: true })
1262
- return { deleted: name }
1230
+ const result = deleteBackupEntry(name)
1231
+ lastBackupReport = { at: stamp(), action: 'delete', ...result }
1232
+ return { deleted: result.deleted, status: result.status }
1263
1233
  }
1264
1234
 
1265
1235
  /**
1266
- * Puts a backup back. The current state is backed up first, so a restore is
1267
- * itself reversible. Nothing is written until the target verifies, and the
1268
- * in-memory caches are dropped so the next read sees the restored files.
1236
+ * Applies the retention rule on demand. Creation already prunes, so this exists
1237
+ * for the paths that must reclaim the space a protected restore target kept
1238
+ * alive, and for callers that want the report without making a backup.
1269
1239
  */
1270
- export function restoreBackup(name, { beforeRestore = null } = {}) {
1271
- if (!isSafeId(name)) throw new Error('invalid backup name')
1272
- const dir = path.join(backupsDir(), name)
1273
- if (!fs.existsSync(dir)) throw new Error(`backup not found: ${name}`)
1274
- const manifest = readJson(path.join(dir, 'manifest.json'), null)
1275
- if (manifest?.files) {
1276
- for (const [rel, info] of Object.entries(manifest.files)) {
1277
- const full = path.join(dir, rel)
1278
- if (!full.startsWith(path.resolve(dir) + path.sep) || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) throw new Error('invalid backup manifest path')
1279
- if (!fs.existsSync(full)) throw new Error(`backup is incomplete: ${rel} is missing`)
1280
- if (info.sha256) {
1281
- const actual = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')
1282
- if (actual !== info.sha256) throw new Error(`backup is corrupt: ${rel} failed its checksum`)
1283
- }
1284
- }
1285
- }
1286
- const safety = typeof beforeRestore === 'function' ? beforeRestore() : backupDatabases()
1287
- const copy = (from, to) => {
1288
- if (!fs.existsSync(from)) return
1289
- ensureDir(path.dirname(to))
1290
- fs.copyFileSync(from, to)
1291
- }
1292
- copy(path.join(dir, 'state.json'), STATE_FILE())
1293
- copy(path.join(dir, 'global.sqlite3'), GLOBAL_DB())
1294
- copy(path.join(dir, 'index', 'global.json'), GLOBAL_INDEX())
1295
- copy(path.join(dir, 'prompts', 'manifest.json'), path.join(dataDir(), 'prompts', 'manifest.json'))
1296
- for (const [sub, target] of [['index', indexDir()], ['libraries', librariesDir()]]) {
1297
- let names = []
1298
- try { names = fs.readdirSync(path.join(dir, sub)) } catch { continue }
1299
- for (const file of names) copy(path.join(dir, sub, file), path.join(target, file))
1300
- }
1301
- const restoreTree = (from, target) => {
1302
- let items
1303
- try { items = fs.readdirSync(from, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return; throw error }
1304
- for (const item of items) {
1305
- if (item.isSymbolicLink()) throw new Error('restore refuses symbolic links')
1306
- if (item.isDirectory()) restoreTree(path.join(from, item.name), path.join(target, item.name))
1307
- else copy(path.join(from, item.name), path.join(target, item.name))
1308
- }
1309
- }
1310
- restoreTree(path.join(dir, 'attachments'), path.join(dataDir(), 'attachments'))
1311
- restoreTree(path.join(dir, 'prompts'), path.join(dataDir(), 'prompts'))
1312
- // The restored files are the truth now; anything cached describes the state
1313
- // that was just replaced.
1314
- clearCaches()
1315
- return { restored: name, safety }
1240
+ export function pruneOldBackups(options = {}) {
1241
+ const result = pruneBackups(options)
1242
+ lastBackupReport = { at: stamp(), action: 'prune', ...result }
1243
+ return result
1244
+ }
1245
+
1246
+ /** Names of backups that a running restore or migration is reading right now. */
1247
+ export function leasedBackups() {
1248
+ return Object.keys(activeLeases())
1249
+ }
1250
+
1251
+ /**
1252
+ * Puts a backup back. The target is leased and its whole inventory is verified
1253
+ * before anything is written; the live root is saved as a safety backup next;
1254
+ * only then are files replaced. Failure leaves a recovery path: the safety
1255
+ * backup holds the state that was replaced and retention may not delete it.
1256
+ * A backup that predates the vector journal also retires the stale live
1257
+ * vector-active pointer, so recall falls back to text instead of querying
1258
+ * databases that no longer match the restored index.
1259
+ */
1260
+ export function restoreBackup(name, { beforeRestore = null, keep = undefined } = {}) {
1261
+ const result = restoreFromBackup(name, { beforeRestore, flushState, clearCaches, keep })
1262
+ lastBackupReport = { at: stamp(), action: 'restore', ...result }
1263
+ return result
1316
1264
  }
1317
1265
 
1318
1266
  /** Drops the in-memory mirrors so the next read comes from disk. */
package/lib/tools.js CHANGED
@@ -190,7 +190,7 @@ export function registerTools(ctx) {
190
190
  mood_valence: { type: 'number', description: 'How this moment felt, -1 (bad) to 1 (good). Omit to inherit the current conversation mood.' },
191
191
  arousal: { type: 'number', description: 'How strongly it hit, 0-1.' },
192
192
  peak: { type: 'boolean', description: 'A high point or low point of the arc — peaks and endings stay easier to recall.' },
193
- salient_date: { type: 'string', description: 'MM-DD for something that comes back every year (a birthday, an anniversary of an event); it resurfaces on that day each year. YYYY-MM-DD for a one-off dated commitment or appointment: it is raised the day before and on the day itself, and never again after it has passed. Use the annual form only when the day really does recur. A relative day (今天/明天/后天/today/tomorrow) must be resolved to the absolute date it points at: sending a bare MM-DD turns one appointment into a yearly reminder.' },
193
+ salient_date: { type: 'string', description: 'MM-DD for something that comes back every year (a birthday, an anniversary of an event); it resurfaces on that day each year. YYYY-MM-DD for a one-off dated commitment or appointment: it is raised the day before and on the day itself, and never again after it has passed. Use the annual form only when the day really does recur. A relative day (今天/明天/后天/today/tomorrow) must be resolved to the absolute date it points at: sending a bare MM-DD turns one appointment into a yearly reminder. If the content itself names a date, pass it here too: when this field is empty the plugin only falls back to a single unambiguous YYYY-MM-DD found in the text.' },
194
194
  track: { type: 'string', description: 'memory (default) or habit for an aggregated expression/sticker habit; do not write one habit per occurrence.' },
195
195
  attachments: { type: 'array', items: { type: 'object', properties: { sha256: { type: 'string', required: true } }, additionalProperties: true }, description: 'At most 8 sha256 references received in this conversation. Never supply paths or bytes. Ordinary image memories default to short_term if layer is omitted; choose long_term explicitly only when important.' },
196
196
  context: { type: 'object', properties: {
@@ -252,11 +252,14 @@ export function registerTools(ctx) {
252
252
  },
253
253
  async (args, exec) => {
254
254
  const result = await privateRecall({ exec, query: args.query, topK: args.topK })
255
+ // Surface a degraded semantic leg on the explicit tool path too; silently
256
+ // returning fewer hits would make a failed backend look like an empty library.
257
+ const warning = (result.errors || []).length ? `\n注意:本次召回已降级:${(result.errors || []).join('; ')}` : ''
255
258
  // Read-only: never materialize a binding/library from a recall header.
256
259
  const binding = peekBinding(exec)
257
260
  const header = `私有记忆(library=${binding?.libraryId || '?'}, domain=${domainOf(binding)})`
258
- if (!result.files?.length) return `${header}\n没有命中。`
259
- return [header, ...result.files.map((f, i) => formatPrivateItem(f, i + 1, { query: args.query }))].join('\n\n')
261
+ if (!result.files?.length) return `${header}\n没有命中。${warning}`
262
+ return [header, ...result.files.map((f, i) => formatPrivateItem(f, i + 1, { query: args.query }))].join('\n\n') + warning
260
263
  },
261
264
  { isConcurrencySafe: () => true },
262
265
  ))
@@ -270,14 +273,15 @@ export function registerTools(ctx) {
270
273
  // instead of ensureSession() silently creating a placeholder library.
271
274
  if (!peekBinding(exec)?.libraryId) return '当前对话没有绑定记忆库。'
272
275
  const result = await privateList({ exec })
273
- if (!result.files?.length) return `私有记忆库 ${result.libraryId} 为空。`
276
+ const warning = result.warning ? `\n注意:${result.warning}` : ''
277
+ if (!result.files?.length) return `私有记忆库 ${result.libraryId} 为空。${warning}`
274
278
  const lines = [`私有记忆库 ${result.libraryId}(${result.files.length} 条)`]
275
279
  for (const file of result.files) {
276
280
  const e = file.entry || {}
277
281
  const tag = salientTag(e)
278
282
  lines.push(`- [${file.name}] ${e.title || file.description || '(untitled)'} · ${e.layer || 'long_term'} · ${e.status || 'active'}${e.marginalized ? ' · marginalized' : ''}${tag ? ` · ${tag}` : ''}`)
279
283
  }
280
- return lines.join('\n')
284
+ return lines.join('\n') + warning
281
285
  },
282
286
  { isConcurrencySafe: () => true },
283
287
  ))