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/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
|
|
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;
|
|
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',
|
|
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)
|
|
2964
|
-
|
|
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', '
|
|
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
|
-
//
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
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
|
|
4393
|
-
//
|
|
4394
|
-
const preference =
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
4422
|
-
|
|
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 || (
|
|
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
|
-
|
|
4525
|
-
|
|
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-
|
|
4528
|
-
|
|
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
|
-
|
|
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) ||
|
|
50
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
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 {}
|