dsh-memu-memory 1.1.0 → 1.1.1

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/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.1",
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)