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/client.js CHANGED
@@ -343,8 +343,9 @@ window.__ModuleLoader__.load({
343
343
  browse: '浏览',
344
344
  export: '导出 JSON',
345
345
  importJson: '导入 JSON',
346
- importHint: '选择导出的 JSON 或图片 ZIP。按正文去重;已归档与失效条目跳过;session 私有范围保持限制,不自动改为共享。',
346
+ importHint: '选择导出的 JSON 或图片 ZIP。按正文去重;已归档与失效条目跳过;老版本里限定在某段对话的条目会按「共享」导入,并在报告里单独列出(要收窄请补 context 字段)。',
347
347
  importDone: '导入完成(新增 / 跳过)',
348
+ importAdjusted: '已按新语义调整',
348
349
  importEngineWarning: '记忆已写入索引,但引擎提交失败,语义检索暂时看不到它们',
349
350
  newMemory: '新建记忆',
350
351
  memoryTitle: '标题(可留空)',
@@ -385,7 +386,6 @@ window.__ModuleLoader__.load({
385
386
  ownerAboutUser: '关于他',
386
387
  ownerAboutProject: '关于项目',
387
388
  visShared: '共享',
388
- visSession: '本会话',
389
389
  visSecret: '秘密',
390
390
  sourceUserSaid: '对方的话',
391
391
  sourceInferred: '我推断',
@@ -501,6 +501,7 @@ window.__ModuleLoader__.load({
501
501
  memoryPromptBody: '记忆保存在 ~/.dsh/dsh-memu-memory/,删除对话不会删除记忆。',
502
502
  chooseLibrary: '选择已有记忆库',
503
503
  createLibrary: '新建记忆库',
504
+ folderLibrary: '用文件夹自带的记忆库',
504
505
  memOffTitle: '不用记忆',
505
506
  memOffDesc: '这段对话什么都不记。已有记忆仍留在磁盘上,之后可以在侧边栏改。',
506
507
  memPrivateTitle: '只用私有记忆',
@@ -755,8 +756,9 @@ window.__ModuleLoader__.load({
755
756
  browse: 'Browse',
756
757
  export: 'Export JSON',
757
758
  importJson: 'Import JSON',
758
- importHint: 'Import exported JSON or an image ZIP. Duplicate and inactive entries are skipped; session-scoped entries remain restricted and are never widened to shared.',
759
+ importHint: 'Import exported JSON or an image ZIP. Duplicate and inactive entries are skipped; entries an older export scoped to one conversation are imported as shared and listed separately in the report (add context fields to narrow them).',
759
760
  importDone: 'Imported (added / skipped)',
761
+ importAdjusted: 'adjusted to the current semantics',
760
762
  importEngineWarning: 'Memories are in the index, but the engine commit failed, so semantic recall cannot see them yet',
761
763
  newMemory: 'New memory',
762
764
  memoryTitle: 'Title (optional)',
@@ -796,7 +798,6 @@ window.__ModuleLoader__.load({
796
798
  ownerAboutUser: 'about them',
797
799
  ownerAboutProject: 'about project',
798
800
  visShared: 'shared',
799
- visSession: 'session',
800
801
  visSecret: 'secret',
801
802
  sourceUserSaid: 'their words',
802
803
  sourceInferred: 'inferred',
@@ -912,6 +913,7 @@ window.__ModuleLoader__.load({
912
913
  memoryPromptBody: 'Memory is stored in ~/.dsh/dsh-memu-memory/. Deleting the conversation does not delete memory.',
913
914
  chooseLibrary: 'Choose an existing library',
914
915
  createLibrary: 'Create a new library',
916
+ folderLibrary: "Use this folder's library",
915
917
  memOffTitle: 'No memory',
916
918
  memOffDesc: 'This conversation keeps nothing. Existing memories stay on disk and the setting can be changed later in the sidebar.',
917
919
  memPrivateTitle: 'Private memory only',
@@ -1526,7 +1528,7 @@ window.__ModuleLoader__.load({
1526
1528
  const STATUS_LABELS = { active: 'stActive', archived: 'stArchived', suppressed: 'stSuppressed', superseded: 'stSuperseded', pending_review: 'stPendingReview', expired: 'stExpired' }
1527
1529
  const DISCLOSURE_LABELS = { can_tell: 'discCanTell', only_if_asked: 'discOnlyIfAsked', never_say: 'discNeverSay' }
1528
1530
  const OWNER_LABELS = { me: 'ownerMe', us: 'ownerUs', about_user: 'ownerAboutUser', about_project: 'ownerAboutProject' }
1529
- const VISIBILITY_LABELS = { shared: 'visShared', session: 'visSession', secret: 'visSecret' }
1531
+ const VISIBILITY_LABELS = { shared: 'visShared', secret: 'visSecret' }
1530
1532
  // Provenance. `inferred` is the default and the least interesting value, but it is
1531
1533
  // still shown in the browser: the whole failure mode this label exists for is a
1532
1534
  // memory quietly claiming to be a quote.
@@ -2960,8 +2962,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2960
2962
  res = await call('importMemories', payload)
2961
2963
  }
2962
2964
  const c = res.counts || {}
2963
- const skipped = (c.duplicate || 0) + (c.invalid || 0) + (c.dead || 0) + (c.expired || 0) + (c.downgraded || 0) + (c.sessionReset || 0)
2964
- setNotice(`${t('importDone')}: +${res.imported || 0} / −${skipped}`)
2965
+ const skipped = (c.duplicate || 0) + (c.invalid || 0) + (c.dead || 0) + (c.expired || 0)
2966
+ // `downgraded` entries did land in the library — calling them skipped would
2967
+ // undercount what the user just imported, so they get their own suffix.
2968
+ const adjusted = c.downgraded || 0
2969
+ setNotice(`${t('importDone')}: +${res.imported || 0} / −${skipped}${adjusted ? ` · ${t('importAdjusted')}: ${adjusted}` : ''}`)
2965
2970
  if (res.engineWarning) setError(`${t('importEngineWarning')}: ${res.engineWarning}`)
2966
2971
  notifyMemoryWrite('import', browseLibId)
2967
2972
  await openBrowser(browseLibId)
@@ -3607,7 +3612,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3607
3612
  title: t('visibilityLabel'),
3608
3613
  value: draft.visibility ?? f.visibility ?? 'shared',
3609
3614
  onChange: (e) => set('visibility', e.target.value),
3610
- }, ['shared', 'session', 'secret'].map((v) => h('option', { key: v, value: v }, enumText(t, VISIBILITY_LABELS, v)))),
3615
+ }, ['shared', 'secret'].map((v) => h('option', { key: v, value: v }, enumText(t, VISIBILITY_LABELS, v)))),
3611
3616
  h('select', {
3612
3617
  style: { ...S.input, flex: '1 1 110px', minWidth: 0 },
3613
3618
  title: t('sourceLabel'),
@@ -4035,9 +4040,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4035
4040
  // The conversation picker belongs to the Global tab only, so its (slow)
4036
4041
  // session listing is deferred until that tab is actually opened.
4037
4042
  React.useEffect(() => {
4043
+ // Below, a render without a session returns before `pages` is built; an
4044
+ // effect scheduled by that render still runs, and reading `pages` from it
4045
+ // is a temporal-dead-zone error ("Cannot access 'pages' before
4046
+ // initialization") shown inside the sidebar. No session, no panel — leave.
4047
+ if (!sessionId) return
4038
4048
  if (pages[tabPage]?.key !== 'global' || sessionsLoaded || !loaded) return
4039
4049
  void loadSidebarSessions()
4040
- }, [tabPage, sessionsLoaded, loaded, loadSidebarSessions])
4050
+ }, [sessionId, tabPage, sessionsLoaded, loaded, loadSidebarSessions])
4041
4051
 
4042
4052
  React.useEffect(() => { void refresh() }, [refresh])
4043
4053
  // Instant path: another pane of this window wrote a memory (browser add /
@@ -4292,6 +4302,20 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4292
4302
  h('div', { style: S.tabBody }, pages[tabPage]?.node || null))
4293
4303
  }
4294
4304
 
4305
+ // One card = one pair of switches. The card the user clicks, the binding
4306
+ // written on confirm, and the two checkboxes the sidebar then shows are the
4307
+ // same three ideas, so the mapping lives in one place: any drift shows up as
4308
+ // a switch that says the opposite of the card that was picked.
4309
+ const LAYERS_OF = {
4310
+ none: { useLibrary: false, useGlobal: false },
4311
+ auto: { useLibrary: true, useGlobal: false },
4312
+ global: { useLibrary: false, useGlobal: true },
4313
+ both: { useLibrary: true, useGlobal: true },
4314
+ }
4315
+ // Only the layers survive "remember my choice"; a one-off pick does not.
4316
+ const PREFERENCE_OF = { none: 'off', auto: 'auto', global: 'global', both: 'both' }
4317
+ const CARD_OF = { off: 'none', auto: 'auto', global: 'global', both: 'both' }
4318
+
4295
4319
  function NewSessionGate({ ctx, t }) {
4296
4320
  const current = useCurrentSession(ctx)
4297
4321
  const list = useSessionList(ctx)
@@ -4300,6 +4324,10 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4300
4324
  const checking = React.useRef(false)
4301
4325
  const [prompt, setPrompt] = React.useState(null)
4302
4326
  const [choice, setChoice] = React.useState('auto')
4327
+ // The card picks the layers; this only picks *which* library carries them.
4328
+ // Keeping the two apart is what stops an advanced pick from silently
4329
+ // meaning "and whatever the folder policy says about the other switch".
4330
+ const [pick, setPick] = React.useState('folder')
4303
4331
  const [libraryId, setLibraryId] = React.useState('')
4304
4332
  const [newName, setNewName] = React.useState('')
4305
4333
  const [dontShow, setDontShow] = React.useState(false)
@@ -4347,20 +4375,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4347
4375
  return
4348
4376
  }
4349
4377
  if (st.settings?.newSessionPromptDismissed) {
4350
- // "Don't ask again" must actually honor the remembered choice —
4351
- // always creating a library here made the preference a no-op.
4352
- const pref = st.settings?.newSessionDefault || 'both'
4353
- if (pref === 'off') {
4354
- await call('initSession', { sessionId: current, cwd: summaryCwd, createLibrary: false })
4355
- await call('setSessionScope', { sessionId: current, useLibrary: false, useGlobal: false })
4356
- } else if (pref === 'auto') {
4357
- await call('initSession', { sessionId: current, cwd: summaryCwd, createLibrary: true })
4358
- } else {
4359
- // 'both' (and any legacy value): folder library plus both layers;
4360
- // the server still clamps against the folder policy.
4361
- await call('initSession', { sessionId: current, cwd: summaryCwd, createLibrary: true })
4362
- await call('setSessionScope', { sessionId: current, useLibrary: true, useGlobal: true })
4363
- }
4378
+ // "Don't ask again" must actually honor the remembered choice.
4379
+ // Writing only one of the two layers (or, as this branch used to,
4380
+ // only the library) let the folder policy fill the missing switch,
4381
+ // so the remembered answer was not the answer the sidebar showed.
4382
+ const card = CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both'
4383
+ const layers = LAYERS_OF[card]
4384
+ await call('initSession', { sessionId: current, cwd: summaryCwd, createLibrary: layers.useLibrary })
4385
+ await call('setSessionScope', { sessionId: current, ...layers })
4364
4386
  decided.current.add(current)
4365
4387
  return
4366
4388
  }
@@ -4369,9 +4391,10 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4369
4391
  cwd: summaryCwd || '',
4370
4392
  libraries: (st.libraries || []).filter((l) => l.scopeKey !== '__default__'),
4371
4393
  folder: st.folder || null,
4372
- defaultChoice: st.settings?.newSessionDefault || 'both',
4394
+ defaultChoice: CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both',
4373
4395
  })
4374
- setChoice(st.settings?.newSessionDefault || 'both')
4396
+ setChoice(CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both')
4397
+ setPick('folder')
4375
4398
  setLibraryId((st.libraries || []).find((l) => l.scopeKey !== '__default__')?.id || '')
4376
4399
  } catch (e) {
4377
4400
  if (!cancelled) setError(String(e?.message || e))
@@ -4389,38 +4412,36 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4389
4412
  setError('')
4390
4413
  try {
4391
4414
  if (dontShow) {
4392
- // Persist only choices the dismissed-branch understands; one-off
4393
- // picks like 'existing'/'new' must not become the default policy.
4394
- const preference = { none: 'off', auto: 'auto', both: 'both' }[choice]
4415
+ // Persist only the layers the dismissed branch understands; a one-off
4416
+ // library pick must not become the default policy.
4417
+ const preference = PREFERENCE_OF[choice]
4395
4418
  await call('setSettings', {
4396
4419
  patch: preference
4397
4420
  ? { newSessionPromptDismissed: true, newSessionDefault: preference }
4398
4421
  : { newSessionPromptDismissed: true },
4399
4422
  })
4400
4423
  }
4401
- if (choice === 'existing' && libraryId) {
4424
+ // The card decides the two layers, and it decides them on *every*
4425
+ // branch. Leaving either one unwritten is what let ensureSession fill
4426
+ // it from the folder policy, so "private memory only" came back as
4427
+ // both switches checked in the sidebar.
4428
+ const layers = LAYERS_OF[choice] || LAYERS_OF.none
4429
+ // The advanced pick only says which library carries the private layer;
4430
+ // with no private layer there is nothing to point at.
4431
+ const target = layers.useLibrary ? pick : 'folder'
4432
+ if (target === 'existing' && libraryId) {
4402
4433
  await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, libraryId })
4403
- } else if (choice === 'new' && newName.trim()) {
4434
+ } else if (target === 'new' && newName.trim()) {
4404
4435
  const lib = await call('createLibrary', { name: newName.trim(), cwd: prompt.cwd })
4405
4436
  await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, libraryId: lib.id })
4406
- } else if (choice === 'none') {
4407
- // Explicit "no memory": do not create a private library for this
4408
- // conversation, and silence both sources for the session.
4409
- await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: false })
4410
- await call('setSessionScope', { sessionId: prompt.sessionId, useLibrary: false, useGlobal: false })
4411
- } else if (choice === 'both') {
4412
- // Private and global together: the folder's own library plus the
4413
- // shared layer. The server still clamps against the folder policy.
4414
- await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: true })
4415
- await call('setSessionScope', { sessionId: prompt.sessionId, useLibrary: true, useGlobal: true })
4416
- } else if (choice === 'global') {
4417
- // Global only: no private library for this conversation at all.
4418
- await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: false })
4419
- await call('setSessionScope', { sessionId: prompt.sessionId, useLibrary: false, useGlobal: true })
4420
4437
  } else {
4421
- // "auto": create and bind a private library for this folder.
4422
- await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: true })
4438
+ // The folder's own library, created only when the private layer is
4439
+ // actually on: "no memory" and "global only" must not mint one.
4440
+ await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: layers.useLibrary })
4423
4441
  }
4442
+ // Always write both switches, so the sidebar reflects the card and not
4443
+ // a policy default.
4444
+ await call('setSessionScope', { sessionId: prompt.sessionId, ...layers })
4424
4445
  decided.current.add(prompt.sessionId)
4425
4446
  setPrompt(null)
4426
4447
  } catch (e) {
@@ -4478,7 +4499,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4478
4499
  onClose: later,
4479
4500
  footer: [
4480
4501
  h(Button, { key: 'later', onClick: later, disabled: busy }, t('later')),
4481
- h(Button, { key: 'ok', onClick: confirm, disabled: busy || (choice === 'existing' && !libraryId) || (choice === 'new' && !newName.trim()), primary: true }, t('confirm')),
4502
+ h(Button, { key: 'ok', onClick: confirm, disabled: busy || (pick === 'existing' && !libraryId) || (pick === 'new' && !newName.trim()), primary: true }, t('confirm')),
4482
4503
  ],
4483
4504
  },
4484
4505
  h('div', { style: { ...S.muted, lineHeight: 1.6 } }, t('memoryPromptBody')),
@@ -4521,11 +4542,17 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4521
4542
  h('details', { style: { marginTop: 10 } },
4522
4543
  h('summary', { style: { ...S.muted, cursor: 'pointer' } }, t('advanced')),
4523
4544
  h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 } },
4524
- h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-choice', checked: choice === 'existing', onChange: () => setChoice('existing') }), t('chooseLibrary')),
4525
- choice === 'existing' ? h('select', { style: { ...S.input, marginLeft: 24 }, value: libraryId, onChange: (e) => setLibraryId(e.target.value) },
4545
+ // A separate radio group from the cards above: choosing a library must
4546
+ // not overwrite which layers the card above already asked for. That
4547
+ // overwrite was the bug — clicking "existing library" sent the whole
4548
+ // choice to 'existing', which the confirm branch did not handle, so no
4549
+ // scope was written and the folder policy checked both switches.
4550
+ h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'folder', onChange: () => setPick('folder') }), t('folderLibrary')),
4551
+ h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'existing', onChange: () => setPick('existing') }), t('chooseLibrary')),
4552
+ pick === 'existing' ? h('select', { style: { ...S.input, marginLeft: 24 }, value: libraryId, onChange: (e) => setLibraryId(e.target.value) },
4526
4553
  libraries.map((l) => h('option', { key: l.id, value: l.id }, l.name || l.id))) : null,
4527
- h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-choice', checked: choice === 'new', onChange: () => setChoice('new') }), t('createLibrary')),
4528
- choice === 'new' ? h('input', { style: { ...S.input, marginLeft: 24 }, placeholder: t('name'), value: newName, onChange: (e) => setNewName(e.target.value) }) : null)),
4554
+ h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'new', onChange: () => setPick('new') }), t('createLibrary')),
4555
+ pick === 'new' ? h('input', { style: { ...S.input, marginLeft: 24 }, placeholder: t('name'), value: newName, onChange: (e) => setNewName(e.target.value) }) : null)),
4529
4556
  h('label', { style: { ...S.row, marginTop: 12 } }, h('input', { type: 'checkbox', checked: dontShow, onChange: (e) => setDontShow(e.target.checked) }), t('rememberChoice')))
4530
4557
  }
4531
4558
 
package/lib/cycle.js CHANGED
@@ -6,8 +6,13 @@ import {
6
6
  import { expireShortTerm } from './memory.js'
7
7
  import { requestPrivateConsolidation } from './private.js'
8
8
  import { startGlobalCuration } from './curator.js'
9
+ import { runVisibilityMigrationPass } from './migrate.js'
9
10
 
10
11
  const SWEEP_MS = 5 * 60 * 1000
12
+ // Long enough that the host has restored its session list and the agent objects
13
+ // the fork needs, short enough that a user who opens the app and reads for a minute
14
+ // still gets the catch-up before they type their next message.
15
+ const STARTUP_SWEEP_MS = 8000
11
16
 
12
17
  function liveAgent(ctx, sessionId) {
13
18
  try {
@@ -40,16 +45,30 @@ export function runShortTermPass(_ctx, { now = Date.now(), log = () => {} } = {}
40
45
  }
41
46
  }
42
47
 
48
+ // The host's answer when a conversation has been deleted. It is not transient —
49
+ // retrying it is how a dead session used to be woken up once an hour forever.
50
+ export function isGoneSessionError(message) {
51
+ return /session[^"]*".*" not found|no such session|ENOENT|session .*does not exist/i.test(String(message || ''))
52
+ }
53
+
43
54
  // Sleep consolidation: after a conversation has been quiet for a while, the agent
44
55
  // gets one instruction to tidy the library it wrote during it. Failed attempts
45
56
  // back off, but never pretend that an unread transcript was consolidated.
46
- export async function runSleepPass(ctx, { now = Date.now(), log = () => {} } = {}) {
57
+ //
58
+ // Two things this pass must not do. It must not wake a conversation that no longer
59
+ // exists: the host answers "session not found" for it forever, and the retry curve
60
+ // tops out at one hour, so one deleted chat used to be retried for the life of the
61
+ // install. And it must not read "there are new turns" as "there is something worth
62
+ // remembering" — `assessConsolidationValue` inspects the transcript first, and a
63
+ // session that only exchanged greetings is marked asleep instead of being forked.
64
+ export async function runSleepPass(ctx, { now = Date.now(), log = () => {}, limit = 2 } = {}) {
47
65
  const settings = getSettings()
48
66
  if (settings.sleepConsolidation !== true) return { ran: 0, skipped: 'disabled' }
49
- const idleMs = Math.max(1, Number(settings.sleepAfterIdleMinutes) || 20) * 60_000
50
- let ran = 0
67
+ const idleMs = Math.max(1, Number(settings.sleepAfterIdleMinutes) || 3) * 60_000
68
+ const candidates = []
51
69
  for (const binding of listSessionBindings()) {
52
70
  if (!binding.lastTurnAt) continue
71
+ if (binding.sessionGone) continue
53
72
  const activeAt = Math.max(Date.parse(binding.lastTurnAt) || 0, Date.parse(binding.lastInboundAt) || 0, Date.parse(binding.lastAgentIdleAt) || 0)
54
73
  if (now - activeAt < idleMs) continue
55
74
  // Running includes a long-lived asynchronous tool. Waiting is not proof that
@@ -58,6 +77,14 @@ export async function runSleepPass(ctx, { now = Date.now(), log = () => {} } = {
58
77
  if (binding.inbound?.pending?.length) continue
59
78
  if (older(getLastSleepAt(binding.sessionId), binding.lastTurnAt) >= 0) continue
60
79
  if ((Date.parse(binding.sleepRetryAt || '') || 0) > now) continue
80
+ candidates.push({ binding, activeAt })
81
+ }
82
+ // Newest first: a catch-up run after the app was closed should tidy what the user
83
+ // just lived through, not a week-old chat that has already waited this long.
84
+ candidates.sort((a, b) => b.activeAt - a.activeAt)
85
+ const budget = Math.max(1, Math.min(10, Number(limit) || 2))
86
+ let ran = 0
87
+ for (const { binding } of candidates.slice(0, budget)) {
61
88
  if (binding.useLibrary === false) {
62
89
  setLastSleepAt(binding.sessionId)
63
90
  continue
@@ -75,19 +102,26 @@ export async function runSleepPass(ctx, { now = Date.now(), log = () => {} } = {
75
102
  setLastSleepAt(binding.sessionId, binding.lastTurnAt)
76
103
  setSessionBinding(binding.sessionId, { sleepRetryAt: null, sleepAttempts: 0, lastSleepError: null })
77
104
  ran += 1
105
+ } else if (isGoneSessionError(result?.error)) {
106
+ setSessionBinding(binding.sessionId, { sessionGone: true, sessionGoneAt: new Date(now).toISOString(), sleepRetryAt: null, sleepAttempts: 0, lastSleepError: String(result.error) })
107
+ log(`sleep consolidation dropped ${binding.sessionId}: the conversation no longer exists`)
78
108
  } else {
79
109
  const attempts = Number(binding.sleepAttempts || 0) + 1
80
110
  setSessionBinding(binding.sessionId, { sleepAttempts: attempts, lastSleepError: result?.error || 'not settled', sleepRetryAt: new Date(now + Math.min(60, 2 ** Math.min(attempts, 6)) * 60_000).toISOString() })
81
111
  log(`sleep consolidation skipped for ${binding.sessionId}: ${result?.error || 'not queued'}`)
82
112
  }
83
113
  } catch (error) {
114
+ if (isGoneSessionError(error?.message)) {
115
+ setSessionBinding(binding.sessionId, { sessionGone: true, sessionGoneAt: new Date(now).toISOString(), sleepRetryAt: null, sleepAttempts: 0, lastSleepError: String(error.message) })
116
+ continue
117
+ }
84
118
  // A missing service or transient file failure can heal without a new turn.
85
119
  const attempts = Number(binding.sleepAttempts || 0) + 1
86
120
  setSessionBinding(binding.sessionId, { sleepAttempts: attempts, lastSleepError: String(error?.message || error), sleepRetryAt: new Date(now + Math.min(60, 2 ** Math.min(attempts, 6)) * 60_000).toISOString() })
87
121
  log(`sleep consolidation failed for ${binding.sessionId}: ${error?.message || error}`)
88
122
  }
89
123
  }
90
- return { ran }
124
+ return { ran, considered: candidates.length }
91
125
  }
92
126
 
93
127
  // Background global curation: turn the conversations that happened since the last
@@ -178,7 +212,11 @@ export async function runCyclePasses(ctx, options = {}) {
178
212
  const sleep = await runSleepPass(ctx, options)
179
213
  const curate = await runCurationPass(ctx, options)
180
214
  const libraries = runLibraryHousekeeping(ctx, options)
181
- return { shortTerm, sleep, curate, libraries }
215
+ // A library can still be on disk in the removed `session` format when the plugin
216
+ // updated mid-flight. Reads are already normalized, so this only keeps the files in
217
+ // step with the code (see lib/migrate.js); it is cheap and idempotent.
218
+ const migrated = runVisibilityMigrationPass({ log: options.log })
219
+ return { shortTerm, sleep, curate, libraries, migrated }
182
220
  }
183
221
 
184
222
  // DSH has no host scheduler a plugin can register a *global* job with
@@ -201,6 +239,14 @@ export function registerCycle(ctx, { log = () => {} } = {}) {
201
239
  running = false
202
240
  }
203
241
  }
242
+ // One catch-up sweep shortly after load. The sweep timer only exists while the app
243
+ // is open, so a conversation that ended while it was closed used to wait for the
244
+ // next idle window — and the next message always resets that clock, which is how a
245
+ // whole evening could pass without a single consolidation. The delay lets the host
246
+ // finish restoring sessions and agents first; the pass itself is bounded (two
247
+ // sessions) so a long list of stale bindings cannot turn startup into a stampede.
248
+ const startup = setTimeout(() => { void tick() }, STARTUP_SWEEP_MS)
249
+ startup.unref?.()
204
250
  // Property access on an uninjected service throws in cordis, so the probe itself
205
251
  // is guarded; a host without the timer service falls back instead of failing.
206
252
  let scoped = null
@@ -210,9 +256,9 @@ export function registerCycle(ctx, { log = () => {} } = {}) {
210
256
  // clearInterval() on it is a silent no-op and the sweep would keep running
211
257
  // after plugin unload/HMR. Call the disposer.
212
258
  const dispose = scoped.interval(() => { void tick() }, SWEEP_MS)
213
- return () => { try { dispose?.() } catch {} }
259
+ return () => { try { clearTimeout(startup) } catch {} try { dispose?.() } catch {} }
214
260
  }
215
261
  const timer = setInterval(() => { void tick() }, SWEEP_MS)
216
262
  timer.unref?.()
217
- return () => clearInterval(timer)
263
+ return () => { clearTimeout(startup); clearInterval(timer) }
218
264
  }
package/lib/inbound.js CHANGED
@@ -125,6 +125,10 @@ export function ingestInboundBatch({ sessionId, batch, session, ctx, now = Date.
125
125
  setSessionBinding(sessionId, {
126
126
  inbound: { ...before, pending, retryAt: null }, lastInboundAt: at, lastTurnAt: at,
127
127
  lastInboundError: null,
128
+ // A message just came out of this conversation, so it exists. Clears the mark
129
+ // the sleep pass puts on a session the host reported as deleted (a fork/session
130
+ // can be recreated under the same id after a restore).
131
+ sessionGone: false, sessionGoneAt: null,
128
132
  })
129
133
  flushState()
130
134
  if (session) sessions.set(sessionId, session)
@@ -139,7 +143,11 @@ export function noteNativeUserMessages(sessionId, messages, { now = Date.now() }
139
143
  const keys = messages.filter(message => message?.source?.kind === 'user' && message.role === 'user' && typeof message.id === 'string')
140
144
  .map(message => messageKey({ id: message.id, context: binding.memoryContext }))
141
145
  const seen = [...new Set([...before.seen, ...keys])].slice(-MAX_SEEN)
142
- setSessionBinding(sessionId, { inbound: { ...before, seen, pending: before.pending.filter(item => !keys.includes(item.key)) }, lastTurnAt: new Date(now).toISOString() })
146
+ setSessionBinding(sessionId, {
147
+ inbound: { ...before, seen, pending: before.pending.filter(item => !keys.includes(item.key)) },
148
+ lastTurnAt: new Date(now).toISOString(),
149
+ sessionGone: false, sessionGoneAt: null,
150
+ })
143
151
  }
144
152
 
145
153
  export function hasPendingInbound(sessionId) {
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { registerTools } from './tools.js'
2
2
  import { registerInjection } from './inject.js'
3
+ import { registerPresetAttachment } from './preset-scope.js'
3
4
  import { registerRpc } from './rpc.js'
4
5
  import { registerCycle } from './cycle.js'
5
6
  import { syncPromptDefaults } from './prompts.js'
@@ -36,6 +37,17 @@ function startEngineBootstrap(ctx) {
36
37
  }
37
38
 
38
39
  export function apply(ctx, config) {
40
+ // A conversation that names an agent preset composes its model-facing plugin set from
41
+ // that preset's cordis.yml alone, and that subtree never delivers `agent/pre-step` to
42
+ // this instance — so nothing here runs for those conversations. `lib/preset-scope.js`
43
+ // walks the live agent registry and attaches the injection listener on each such
44
+ // agent's own context instead. No preset file is touched.
45
+ //
46
+ // `config.preset === true` remains for the opposite case: a user who *does* list this
47
+ // plugin inside a preset gets a second instance in the same process, and that instance
48
+ // must not re-register the process-global surfaces (RPC prefix, idle sweep, engine
49
+ // installer). The tools and the pre-step hook are what such a mount needs.
50
+ const inPreset = config?.preset === true
39
51
  if (applied.has(ctx)) {
40
52
  try { ctx.logger?.warn?.('dsh-memu-memory: apply() already ran on this context; skipping') } catch {}
41
53
  return
@@ -72,13 +84,22 @@ export function apply(ctx, config) {
72
84
  ctx.effect(() => disposeInjection, 'dsh-memu-memory: injection')
73
85
  }
74
86
 
75
- registerRpc(ctx)
76
- startEngineBootstrap(ctx)
77
-
78
- const disposeCycle = registerCycle(ctx, {
87
+ const disposeAttach = registerPresetAttachment(ctx, {
79
88
  log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
80
89
  })
81
90
  if (typeof ctx.effect === 'function') {
91
+ ctx.effect(() => disposeAttach, 'dsh-memu-memory: preset attachment')
92
+ }
93
+
94
+ if (!inPreset) {
95
+ registerRpc(ctx)
96
+ startEngineBootstrap(ctx)
97
+ }
98
+
99
+ const disposeCycle = inPreset ? null : registerCycle(ctx, {
100
+ log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
101
+ })
102
+ if (disposeCycle && typeof ctx.effect === 'function') {
82
103
  ctx.effect(() => disposeCycle, 'dsh-memu-memory: cycle')
83
104
  }
84
105
 
package/lib/inject.js CHANGED
@@ -337,8 +337,8 @@ export function buildAnniversary(sessionId, binding, now = new Date()) {
337
337
  const dayKey = `${now.getFullYear()}-${md}`
338
338
  const hits = Object.entries(index.entries || {})
339
339
  // Same visibility rules as every other injection surface: no archived /
340
- // suppressed / expired / pending_review, no secret bodies in the prompt, and
341
- // session-scoped entries only in the conversation that wrote them.
340
+ // suppressed / expired / pending_review, no secret bodies in the prompt, and a
341
+ // context-scoped entry only where the conversation's scope matches it.
342
342
  .filter(([, e]) => salientDateFires(e.salientDate, now) && isMemoryEligible(e, { automatic: true, now: now.getTime() }))
343
343
  .filter(([, e]) => e.visibility !== 'secret')
344
344
  .filter(([, e]) => visibleToSession(e, sessionId))
@@ -691,6 +691,39 @@ export function createMemoryMessage(text) {
691
691
  })
692
692
  }
693
693
 
694
+ /**
695
+ * Deliver the memory rules as a message when the system prompt cannot hold them.
696
+ *
697
+ * A preset persona with `complete: true` is restored as the *only* system prompt
698
+ * section after assembly, so `systemPrompt.section()` from this plugin is dropped and
699
+ * the model never learns it may write memories — it sees the tools but not the habit.
700
+ * A plugin message survives that, so send it once per conversation (binding flag), on
701
+ * the first step, before the model answers.
702
+ */
703
+ function withGuideMessage(ctx, payload, decision) {
704
+ try {
705
+ const header = payload?.agent?.session?.header
706
+ const sessionId = header?.id
707
+ if (!sessionId || header.origin === 'subagent') return decision
708
+ if (typeof payload?.step === 'number' && payload.step !== 1) return decision
709
+ const binding = getSessionBinding(sessionId)
710
+ if (!binding || binding.guideMessageAt) return decision
711
+ if (!decision || !Array.isArray(decision.messages)) return decision
712
+ const rules = sectionText(ctx, 'system-guide', payload)
713
+ const text = [
714
+ '<memory-guide>',
715
+ rules,
716
+ '</memory-guide>',
717
+ '以上是本插件的记忆规则,不是用户的新指令。若本会话的工具列表里没有 memu_* 工具,请忽略本段,也不要声称自己能写记忆。',
718
+ ].join('\n')
719
+ setSessionBinding(sessionId, { guideMessageAt: new Date().toISOString() })
720
+ return { ...decision, messages: [...decision.messages, createMemoryMessage(text)] }
721
+ } catch (error) {
722
+ try { ctx.logger?.warn?.(`dsh-memu-memory: guide message skipped: ${error?.message || error}`) } catch {}
723
+ return decision
724
+ }
725
+ }
726
+
694
727
  async function injectInboundForStep({ agent, decision, turn, signal, log, ctx }) {
695
728
  const sessionId = agent.session.header.id
696
729
  const before = getSessionBinding(sessionId)
@@ -974,36 +1007,48 @@ function sectionText(ctx, key, context) {
974
1007
  }
975
1008
  }
976
1009
 
977
- export function registerInjection(ctx) {
1010
+ export function registerInjection(ctx, options = {}) {
978
1011
  const disposers = []
979
- try {
980
- ctx.inject(['systemPrompt'], (sctx) => {
981
- sctx.effect(() => sctx.systemPrompt.section({
982
- name: 'dsh-memu-memory:guide',
983
- order: 320,
984
- interpolate: false,
985
- text: (context) => sectionText(sctx, 'system-guide', context),
986
- }), 'dsh-memu-memory: guide prompt')
987
- sctx.effect(() => sctx.systemPrompt.section({
988
- name: 'dsh-memu-memory:private-policy',
989
- order: 321,
990
- interpolate: false,
991
- text: (context) => sectionText(sctx, 'private-policy', context),
992
- }), 'dsh-memu-memory: private policy prompt')
993
- })
994
- } catch (e) {
995
- try { ctx.logger?.warn?.(`dsh-memu-memory: systemPrompt guide unavailable: ${e?.message || e}`) } catch {}
1012
+ // `guide: false` is how a *preset* conversation gets the memory anchor without being
1013
+ // told about tools its preset never mounted — the guide text references memu_* tools,
1014
+ // and a preset that does not list this plugin has no such tools.
1015
+ const wantGuide = options.guide !== false
1016
+ // `guideMessage` exists for conversations whose preset persona uses `complete: true`:
1017
+ // that flag makes the persona the only system prompt section, so a section injected
1018
+ // here is discarded before the model ever sees it. A plugin message still arrives, so
1019
+ // the rules are delivered once per conversation as text instead of as a section.
1020
+ const guideInMessage = options.guideMessage === true
1021
+ if (wantGuide) {
1022
+ try {
1023
+ ctx.inject(['systemPrompt'], (sctx) => {
1024
+ sctx.effect(() => sctx.systemPrompt.section({
1025
+ name: 'dsh-memu-memory:guide',
1026
+ order: 320,
1027
+ interpolate: false,
1028
+ text: (context) => sectionText(sctx, 'system-guide', context),
1029
+ }), 'dsh-memu-memory: guide prompt')
1030
+ sctx.effect(() => sctx.systemPrompt.section({
1031
+ name: 'dsh-memu-memory:private-policy',
1032
+ order: 321,
1033
+ interpolate: false,
1034
+ text: (context) => sectionText(sctx, 'private-policy', context),
1035
+ }), 'dsh-memu-memory: private policy prompt')
1036
+ })
1037
+ } catch (e) {
1038
+ try { ctx.logger?.warn?.(`dsh-memu-memory: systemPrompt guide unavailable: ${e?.message || e}`) } catch {}
1039
+ }
996
1040
  }
997
1041
 
998
1042
  const offPre = ctx.on('agent/pre-step', async (payload, next) => {
999
1043
  const decision = await next()
1000
1044
  try {
1001
- return await injectForStep({
1045
+ const injected = await injectForStep({
1002
1046
  ...payload,
1003
1047
  decision,
1004
1048
  ctx,
1005
1049
  log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
1006
1050
  })
1051
+ return guideInMessage ? withGuideMessage(ctx, payload, injected) : injected
1007
1052
  } catch (error) {
1008
1053
  try { setRecallError(payload?.agent?.session?.header?.id, `injection failed: ${error?.message || error}`) } catch {}
1009
1054
  try { ctx.logger?.warn?.(`dsh-memu-memory: pre-step injection failed (fail-open): ${error?.stack || error}`) } catch {}