dsh-memu-memory 1.1.0 → 1.1.2

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/lib/rpc.js CHANGED
@@ -12,6 +12,7 @@ import { privateList, privateUpdate, privateRemember, privateRecall, privateArch
12
12
  import { normalizeMemoryContext } from './context.js'
13
13
  import { requestPrivateConsolidation } from './private.js'
14
14
  import { startGlobalCuration, getCurationJob, cancelCurationJob, listCurationJobs } from './curator.js'
15
+ import { maybeMigrateLibraries } from './migrate.js'
15
16
  import { getPromptStates, savePromptOverride, resetPrompt, resetAllPrompts } from './prompts.js'
16
17
  import { statsView } from './stats.js'
17
18
  import { getMemuConfig, saveMemuConfig, setEmbeddingSource, testMemuConfig, getSummaryConfig, getVisionConfig } from './memu-config.js'
@@ -377,6 +378,11 @@ async function dispatch(ctx, endpoint, payload = {}) {
377
378
  return { revision: memoryRevision(), stamp: memoryStamp() }
378
379
  }
379
380
  if (!sessionId) throw new Error('getSidebarState requires a sessionId')
381
+ // An older version could still be on disk (the user upgraded without restarting).
382
+ // Reads are already correct — `loadPrivateIndex` normalizes the removed `session`
383
+ // level — but opening the sidebar is a good moment to persist the conversion so the
384
+ // files stop disagreeing with what is displayed. Throttled, no-op when clean.
385
+ maybeMigrateLibraries()
380
386
  const binding = getSessionBinding(sessionId)
381
387
  const folder = binding?.scopeKey ? getWorkspacePolicy(binding.scopeKey) : null
382
388
  const effective = binding ? effectiveScope(binding, folder) : null
@@ -560,7 +566,10 @@ async function dispatch(ctx, endpoint, payload = {}) {
560
566
  const allowed = new Set([
561
567
  'autoInject', 'privateTopK', 'globalTopK', 'anchorTokens', 'turnTokens',
562
568
  'relevanceThreshold', 'dedupeTurns', 'globalEveryNTurns', 'minUserChars',
563
- 'mustRememberCap', 'randomCueChance', 'newSessionPromptDismissed', 'curatorModel',
569
+ // newSessionDefault has to be writable here: "remember my choice" stores
570
+ // it, and a dropped patch silently turned every remembered answer into
571
+ // the 'both' fallback that the dismissed branch then applied.
572
+ 'mustRememberCap', 'randomCueChance', 'newSessionPromptDismissed', 'newSessionDefault', 'curatorModel',
564
573
  'autoInstallEngine', 'pipIndexes',
565
574
  'sleepConsolidation', 'sleepAfterIdleMinutes', 'autoCurateEveryHours', 'autoCurateSessionLimit',
566
575
  'memoryMode', 'memoryBackend', 'habitsInjection', 'purgeArchivedAfterDays',
@@ -723,12 +732,6 @@ async function dispatch(ctx, endpoint, payload = {}) {
723
732
  const id = String(payload?.libraryId || '')
724
733
  if (!getLibrary(id)) throw new Error('library not found')
725
734
  const layer = payload?.layer || 'long_term'
726
- // Session-scoped memory needs an owning conversation; the library browser
727
- // has none, and writing one here used to produce an entry no session can
728
- // ever see (sessionId null). Point the user at the right surface instead.
729
- if (payload?.visibility === 'session') {
730
- throw new Error('session-scoped memories belong to one conversation; add this from that conversation instead of the library browser')
731
- }
732
735
  if (layer === 'must_remember' && !String(payload?.pinReason || '').trim()) {
733
736
  throw new Error('pinReason is required for must_remember')
734
737
  }
@@ -772,12 +775,6 @@ async function dispatch(ctx, endpoint, payload = {}) {
772
775
  // needs a pin reason, which only the creation path collects. Promoting to
773
776
  // must_remember here would hit privateUpdate's refusal anyway.
774
777
  if (patch?.layer === 'must_remember') throw new Error('must_remember needs a pin_reason; write it with memu_private_remember from a conversation')
775
- // Without a session context a session-scoped edit has no owner; refuse
776
- // instead of silently orphaning the entry (visibility session + null
777
- // sessionId is unreachable from every conversation).
778
- if (payload?.libraryId && patch?.visibility === 'session') {
779
- throw new Error('session visibility can only be set from the owning conversation')
780
- }
781
778
  const args = { id }
782
779
  for (const key of allowed) if (patch[key] !== undefined) args[key] = patch[key]
783
780
  if (payload?.libraryId) args.libraryId = String(payload.libraryId)
@@ -954,7 +951,9 @@ async function dispatch(ctx, endpoint, payload = {}) {
954
951
  }
955
952
  case 'generatePrivate': {
956
953
  const sessionId = requireSessionId(payload)
957
- return requestPrivateConsolidation(ctx, sessionId)
954
+ // Explicit button press: the user already decided this conversation is worth
955
+ // summarizing, so the automatic "is there anything in it" gate does not apply.
956
+ return requestPrivateConsolidation(ctx, sessionId, { force: true })
958
957
  }
959
958
  case 'generateGlobal': {
960
959
  const sessionId = requireSessionId(payload)
package/lib/state.js CHANGED
@@ -5,6 +5,7 @@ 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 { migrateIndexVisibility } from './visibility.js'
8
9
 
9
10
  const STATE_FILE = () => path.join(dataDir(), 'state.json')
10
11
  const GLOBAL_DB = () => path.join(dataDir(), 'global.sqlite3')
@@ -74,9 +75,12 @@ export const DEFAULT_SETTINGS = {
74
75
  // new-session prompt, domestic texture, and idle consolidation are all on.
75
76
  // These stay ordinary switches, and an existing saved choice is preserved.
76
77
  sleepConsolidation: true,
77
- // Group chat arrives in bursts. 8 idle minutes is long enough to know a burst
78
- // ended, short enough that fragments do not sit unconsolidated all day.
79
- sleepAfterIdleMinutes: 8,
78
+ // Group chat arrives in bursts, but a burst is seconds apart and the per-turn
79
+ // timestamps already cover that. Three idle minutes is long enough that an
80
+ // ordinary pause (reading a diff, thinking about the next question) is not
81
+ // "gone", and short enough that a conversation which ended gets tidied in the
82
+ // same sitting instead of waiting for a quiet hour that may never arrive.
83
+ sleepAfterIdleMinutes: 3,
80
84
  autoCurateEveryHours: 0,
81
85
  autoCurateSessionLimit: 8,
82
86
  // 'local' keeps every memory on this machine; 'cloud' hands MemU's own backend
@@ -311,7 +315,7 @@ export function updateSettings(patch) {
311
315
  unpinBudgetPerSession: [1, 20], unpinCooldownMinutes: [0, 1440], sleepAfterIdleMinutes: [1, 1440],
312
316
  autoCurateEveryHours: [0, 8760], autoCurateSessionLimit: [1, 10], purgeArchivedAfterDays: [0, 36500],
313
317
  }
314
- const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], newSessionDefault: ['off', 'auto', 'both'] }
318
+ const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
315
319
  for (const [key, value] of Object.entries(patch || {})) {
316
320
  if (!Object.hasOwn(DEFAULT_SETTINGS, key)) continue
317
321
  if (enums[key]) {
@@ -979,20 +983,26 @@ export function memoryStamp() {
979
983
  return parts.join("|")
980
984
  }
981
985
 
982
- function cachedIndex(file, loader) {
986
+ function cachedIndex(file, loader, normalize) {
983
987
  const cached = indexCache.get(file)
984
988
  const stat = statOf(file)
985
- if (cached && stat && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return indexSnapshot(cached.index)
989
+ if (cached && stat && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return indexSnapshot(cached.index, normalize)
986
990
  const index = loader()
987
991
  // Never cache a fallback produced by a failed read: it would keep shadowing the
988
992
  // real file (whose mtime/size did not change) even after the read recovers.
989
993
  if (stat && !readFailure(file)) indexCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, index })
990
- return indexSnapshot(index)
994
+ return indexSnapshot(index, normalize)
991
995
  }
992
996
 
993
- function indexSnapshot(index) {
997
+ // `normalize` upgrades the *view* (a level an older version stored), while the baseline
998
+ // stays exactly what is on disk. That split is what lets an ordinary write persist the
999
+ // upgrade: `writeIndex` diffs against the baseline, sees the change, and checks the file
1000
+ // still matches what we read before accepting it.
1001
+ function indexSnapshot(index, normalize) {
1002
+ const baseline = structuredClone(index)
994
1003
  const copy = structuredClone(index)
995
- indexBases.set(copy, structuredClone(index))
1004
+ if (normalize) normalize(copy)
1005
+ indexBases.set(copy, baseline)
996
1006
  return copy
997
1007
  }
998
1008
 
@@ -1044,6 +1054,13 @@ export function loadPrivateIndex(libraryId) {
1044
1054
  let raw = readJson(file, null)
1045
1055
  if (raw !== null) raw = assertJsonShape(file, raw, validIndex)
1046
1056
  return raw && typeof raw === 'object' ? { version: 1, entries: raw.entries || {} } : { version: 1, entries: {} }
1057
+ }, (index) => {
1058
+ // An older version stored `visibility: 'session'` (a lease on one conversation).
1059
+ // That level is gone; libraries written by it are read as `shared` here — the one
1060
+ // chokepoint every reader and writer passes through — so no consumer has to know
1061
+ // the level ever existed. Only the view is upgraded; the baseline stays the raw file
1062
+ // so the next ordinary write persists the conversion (see `indexSnapshot`).
1063
+ migrateIndexVisibility(index, stamp())
1047
1064
  })
1048
1065
  }
1049
1066
 
package/lib/tools.js CHANGED
@@ -94,9 +94,12 @@ function formatPrivateItem(item, index, options = {}) {
94
94
  if (entry.disclosure && entry.disclosure !== 'can_tell') lines.push(` disclosure=${entry.disclosure}`)
95
95
  if (entry.owner && entry.owner !== 'me') lines.push(` owner=${entry.owner}`)
96
96
  if (entry.visibility && entry.visibility !== 'shared') {
97
- // 'session' is scoped to the conversation that wrote it, so say so — the model
98
- // should not expect the same memory to be reachable in the next chat.
99
- const note = entry.visibility === 'secret' ? '(只有你自己知道,别在正文里复述)' : '(只在写出它的那段对话里可见)'
97
+ // 'secret' is a presentation rule, not a lock: it stays out of the prompt and out of
98
+ // automatic recall, but anyone in the same folder can still pull it up on purpose.
99
+ // Saying so is what keeps the model from treating it as a private vault.
100
+ const note = entry.visibility === 'secret'
101
+ ? '(不进提示词、不会自动浮现;同文件夹里主动检索仍可读到,别在正文里复述)'
102
+ : ''
100
103
  lines.push(` visibility=${entry.visibility}${note}`)
101
104
  }
102
105
  if (entry.peak === true) lines.push(' peak')
@@ -178,7 +181,7 @@ export function registerTools(ctx) {
178
181
  open_loop: { type: 'boolean', description: 'Mark an unfinished promise/task/unsaid thing.' },
179
182
  disclosure: { type: 'string', description: 'can_tell, only_if_asked, or never_say.' },
180
183
  owner: { type: 'string', description: 'me, us, about_user, or about_project.' },
181
- visibility: { type: 'string', description: 'shared, session, or secret.' },
184
+ visibility: { type: 'string', description: 'shared (default: the whole folder) or secret (kept out of the prompt and out of automatic recall; still readable by an explicit recall from the same folder). Scope a memory to one channel / character / world / branch with the context field instead of trying to hide it.' },
182
185
  source: {
183
186
  type: 'string',
184
187
  description: 'Where this came from: inferred (default — you concluded it), user_said (only when the words are actually theirs and quotable), imagined, or told.',
@@ -292,7 +295,7 @@ export function registerTools(ctx) {
292
295
  open_loop: { type: 'boolean' },
293
296
  disclosure: { type: 'string', description: 'can_tell, only_if_asked, or never_say. Changing this changes how the memory is phrased when it is injected.' },
294
297
  owner: { type: 'string', description: 'me, us, about_user, or about_project.' },
295
- visibility: { type: 'string', description: 'shared, session, or secret. session pins it to the conversation that wrote it; secret keeps it out of the prompt entirely (recall only).' },
298
+ visibility: { type: 'string', description: 'shared or secret. secret keeps it out of the prompt and out of automatic recall; an explicit recall from the same folder can still read it.' },
296
299
  confidence: { type: 'number' },
297
300
  // Provenance and track are claims about what a memory *is*; both were writable
298
301
  // in memory.js and the panel while staying unreachable from the tool surface,
@@ -0,0 +1,63 @@
1
+ // Visibility used to have three levels: `shared`, `session` (a lease on the one
2
+ // conversation that wrote it) and `secret`. The session level is gone.
3
+ //
4
+ // It could never answer the question people actually ask — "can the same character in
5
+ // another conversation read this?" — because a lease that dies with its conversation
6
+ // is wrong in both of its states: while that conversation is alive it hides the memory
7
+ // from every other conversation of the same character (breaking continuity), and once
8
+ // the conversation is deleted it falls back to the whole folder (protecting nothing).
9
+ // Anything that has to stay out of another conversation is expressed with `context`
10
+ // (channelId / characterId / worldId / branchId) instead: that survives conversation
11
+ // deletion, and it is the only dimension the reader checks.
12
+ //
13
+ // Stored data from an older version is still read here, so a library that has not been
14
+ // rewritten yet behaves exactly like a migrated one: `session` is read as `shared`, and
15
+ // the entry keeps a marker saying where it came from.
16
+ export const STORED_VISIBILITIES = ['shared', 'secret']
17
+ export const LEGACY_SESSION = 'session'
18
+
19
+ export function normalizeVisibility(value) {
20
+ return value === LEGACY_SESSION ? 'shared' : value
21
+ }
22
+
23
+ export function isLegacySession(entry) {
24
+ return entry?.visibility === LEGACY_SESSION
25
+ }
26
+
27
+ // Rewrites one entry in place. The timestamp is recorded so a reader can tell a memory
28
+ // that was always shared from one that inherited its folder during the upgrade.
29
+ export function migrateEntryVisibility(entry, now) {
30
+ if (!entry || typeof entry !== 'object') return false
31
+ let changed = false
32
+ if (isLegacySession(entry)) {
33
+ entry.visibility = 'shared'
34
+ entry.migratedFromVisibility = LEGACY_SESSION
35
+ entry.visibilityMigratedAt = now
36
+ changed = true
37
+ }
38
+ // `sessionId` meant nothing outside the lease it belonged to; older writes also left
39
+ // `sessionId: null` on ordinary entries. Either way it is a dead pointer, and a later
40
+ // reader must not mistake it for a live scope.
41
+ if ('sessionId' in entry) {
42
+ delete entry.sessionId
43
+ changed = true
44
+ }
45
+ return changed
46
+ }
47
+
48
+ export function migrateIndexVisibility(index, now = new Date().toISOString()) {
49
+ const entries = index?.entries
50
+ if (!entries || typeof entries !== 'object') return 0
51
+ let migrated = 0
52
+ for (const entry of Object.values(entries)) if (migrateEntryVisibility(entry, now)) migrated += 1
53
+ return migrated
54
+ }
55
+
56
+ // Counted against the raw file, before load-time normalization, so a pass can tell a
57
+ // library that still needs rewriting from one that is already migrated. A leftover
58
+ // `sessionId` counts: it is the same old shape, just without the level that used it.
59
+ export function countLegacyInRaw(raw) {
60
+ const entries = raw?.entries
61
+ if (!entries || typeof entries !== 'object') return 0
62
+ return Object.values(entries).filter((entry) => isLegacySession(entry) || (entry && 'sessionId' in entry)).length
63
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-memu-memory",
3
3
  "icon": "./assets/icon.png",
4
- "version": "1.1.0",
4
+ "version": "1.1.2",
5
5
  "description": "面向长篇 RP 与陪伴对话的 DeepSeek Harness 记忆插件:三层记忆、逐轮召回、来源标注、关系图、图片附件与独立 WebUI。",
6
6
  "keywords": [
7
7
  "deepseek-harness",
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ // Rewrite libraries that still store the removed `visibility: 'session'` level.
3
+ //
4
+ // Reading never needs this: `loadPrivateIndex` already serves those entries as `shared`
5
+ // (see lib/visibility.js). The script exists for the disk — a library nobody writes to
6
+ // again would otherwise keep the dead level and its `sessionId` forever, and anyone
7
+ // reading the JSON would have to know about a level the code no longer has.
8
+ //
9
+ // node scripts/migrate-visibility.mjs # report only
10
+ // node scripts/migrate-visibility.mjs --apply # back up, then rewrite
11
+ //
12
+ // Exit codes: 0 nothing to do or already applied, 3 changes found but not applied,
13
+ // 1 the migration itself failed.
14
+ import path from 'node:path'
15
+ import { dataDir } from '../lib/paths.js'
16
+ import * as state from '../lib/state.js'
17
+ import { scanLegacyLibraries, migrateLibraries } from '../lib/migrate.js'
18
+
19
+ const HELP = `Usage: node scripts/migrate-visibility.mjs [--apply] [--json]
20
+
21
+ --apply rewrite the libraries (takes a backup first)
22
+ --json machine-readable report
23
+ --help this text
24
+
25
+ Memories stored as visibility "session" by an older version become "shared" in the folder
26
+ they live in. Their context (channel / character / world / branch) is kept untouched, so
27
+ a memory scoped to one character stays scoped to that character.`
28
+ const argv = process.argv.slice(2)
29
+ if (argv.includes('--help') || argv.includes('-h')) {
30
+ console.log(HELP)
31
+ process.exit(0)
32
+ }
33
+ const apply = argv.includes('--apply')
34
+ const json = argv.includes('--json')
35
+
36
+ function main() {
37
+ const pending = scanLegacyLibraries()
38
+ const report = { ok: true, applied: false, dataDir: dataDir(), pending, backup: null, libraries: [] }
39
+
40
+ if (pending.length === 0) {
41
+ report.libraries = migrateLibraries({ dryRun: true })
42
+ return report
43
+ }
44
+ if (apply) {
45
+ // A rewrite that cannot be undone is not a repair; the same backup the panel's
46
+ // maintenance button takes, with a manifest the restore path understands.
47
+ report.backup = state.backupDatabases()
48
+ report.applied = true
49
+ report.libraries = migrateLibraries()
50
+ state.flushState()
51
+ } else {
52
+ report.libraries = migrateLibraries({ dryRun: true })
53
+ }
54
+ return report
55
+ }
56
+
57
+ let report
58
+ try {
59
+ report = main()
60
+ } catch (error) {
61
+ const failed = { ok: false, error: String(error?.message || error) }
62
+ if (json) console.log(JSON.stringify(failed, null, 2))
63
+ else console.error(`migration failed: ${failed.error}`)
64
+ process.exit(1)
65
+ }
66
+
67
+ const leftovers = report.libraries.filter((lib) => lib.after > 0)
68
+ if (leftovers.length) report.ok = false
69
+
70
+ if (json) {
71
+ console.log(JSON.stringify(report, null, 2))
72
+ } else {
73
+ console.log(`data: ${report.dataDir}`)
74
+ if (!report.libraries.length) {
75
+ console.log('no library stores the removed "session" level.')
76
+ }
77
+ for (const lib of report.libraries) {
78
+ const where = lib.name ? `${lib.libraryId} (${lib.name})` : lib.libraryId
79
+ if (lib.error) console.log(` ${where}: FAILED ${lib.error}`)
80
+ else if (lib.before === 0) console.log(` ${where}: clean`)
81
+ else console.log(` ${where}: ${lib.before} legacy ${plural(lib.before)} -> shared, dead session id removed (context kept)`)
82
+ }
83
+ if (report.backup) console.log(`backup: ${path.resolve(report.backup)}`)
84
+ if (!apply && report.libraries.some((lib) => lib.before > 0)) {
85
+ console.log('nothing written; re-run with --apply to rewrite (a backup is taken first).')
86
+ }
87
+ if (report.ok && report.applied) console.log('done.')
88
+ }
89
+
90
+ function plural(n) {
91
+ return n === 1 ? 'memory' : 'memories'
92
+ }
93
+
94
+ if (!report.ok) process.exit(report.applied ? 1 : 3)