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/AGENTS.md +3 -1
- package/README.md +5 -3
- package/README.zh.md +4 -2
- package/docs/memory-semantics.md +57 -0
- package/docs/troubleshooting.md +366 -315
- package/lib/client.js +78 -51
- package/lib/cycle.js +53 -7
- package/lib/inbound.js +9 -1
- package/lib/index.js +25 -4
- package/lib/inject.js +66 -21
- package/lib/memory.js +41 -34
- package/lib/migrate.js +119 -0
- package/lib/preset-scope.js +121 -0
- package/lib/private.js +97 -6
- package/lib/prompts/private-policy.coding.md +1 -1
- package/lib/prompts/private-policy.md +1 -1
- package/lib/prompts/private-policy.mixed.md +1 -1
- package/lib/prompts/private-policy.rp.md +1 -1
- package/lib/prompts/system-guide.md +1 -1
- package/lib/remote-summary.js +5 -1
- package/lib/rpc.js +13 -14
- package/lib/state.js +26 -9
- package/lib/tools.js +8 -5
- package/lib/visibility.js +63 -0
- package/package.json +1 -1
- package/scripts/migrate-visibility.mjs +94 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
//
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// '
|
|
98
|
-
//
|
|
99
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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)
|