deepseek-plastic-memory 2.1.6 → 3.3.8
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/README.md +33 -0
- package/SKILL.md +5 -0
- package/client.js +107 -9
- package/identity.md +12 -0
- package/index.js +151 -2
- package/memtree.mjs +47 -2
- package/package.json +5 -2
- package/public/sky/app.js +2336 -0
- package/public/sky/app.js.bak +2336 -0
- package/public/sky/index.html +286 -0
- package/public/sky/vendor/three.core.js +60586 -0
- package/public/sky/vendor/three.module.js +19719 -0
- package/sky.js +332 -0
package/README.md
CHANGED
|
@@ -25,10 +25,43 @@ dsh plugin add deepseek-plastic-memory
|
|
|
25
25
|
|
|
26
26
|
装完设置里多一页「可塑性记忆」,左边分区右边条目;会话顶栏也多个 🧠,点开是同一个面板。
|
|
27
27
|
|
|
28
|
+
条目一行放不下的时候,点后面的「查看」弹一张卡片看全文,Esc 或点空白处关掉。
|
|
29
|
+
|
|
28
30
|
不想装插件也行,当普通 node 脚本用,命令一样。
|
|
29
31
|
|
|
30
32
|
**小提示**:觉得有必要的时候,或者在开新会话之前,先让 DSH 把当前记忆总结一遍。
|
|
31
33
|
|
|
34
|
+
## 记忆星空
|
|
35
|
+
|
|
36
|
+
设置页里那行「可塑性记忆」右边有个「记忆星空」按钮,会话顶栏也有一个;点一下用本机 Edge 打开一片星系。
|
|
37
|
+
|
|
38
|
+
- 主星云 = 主分区,副星辰 = 子分区,每颗小点 = 一条记忆
|
|
39
|
+
- 拖拽绕中心转、滚轮缩放、单击选中、双击星辰把那一团放大
|
|
40
|
+
- 选中主星辰,别的星团会暗下去;左边的列表只跳星辰,记忆在选中星辰后看
|
|
41
|
+
- **右上角两个主题**:星野(深空 + 立体星云 + 星芒)、恒星暴(粒子旋涡 + 星爆团 + 尘埃带),一键切换,选过会记住
|
|
42
|
+
- **顶栏「AI 分类」**:点一下先确认(会消耗 token),确认后把任务排进队列;下一轮对话里模型会把全部记忆 dump 出来逐条重归类,放错的挪走、需要的新开分区,做完汇报
|
|
43
|
+
- **左上角演示模式**:打开后有 1~10 档转速手调,勾上「只留星空」把面板、顶栏、曲线全藏起来,只剩星空本体(Esc 退出)
|
|
44
|
+
- **星野 / 恒星暴**:星野(深空 + 立体星云 + 星芒)、恒星暴(粒子旋涡 + 星爆团 + 尘埃带),一键切换,选过会记住
|
|
45
|
+
- 左下角遗忘曲线:新生 → 7 天 → 30 天,只写分区名,点分区名能直接「固定住」
|
|
46
|
+
- 记忆之间常年牵着一层乱线,检索或者点开某条记忆时,连到它的丝线会亮起来,光点顺着跑
|
|
47
|
+
- 主人 / 约定 / 喜好 这几类本来就不会被清,默认算固定记忆
|
|
48
|
+
|
|
49
|
+
星云、星系、星芒、尘埃全是用着色器 + 粒子现算的(fbm 噪声生成云丝,没有一张外部贴图),整片天空在显卡上实时渲染;页面是本地静态文件,**不调用任何模型接口**,看星空不烧 token。
|
|
50
|
+
|
|
51
|
+
## 换缸提示
|
|
52
|
+
|
|
53
|
+
换会话、换窗口、上下文被压缩,都只是换了个缸——鱼还是那条鱼。
|
|
54
|
+
|
|
55
|
+
插件每次组装系统提示词时都会带上 `identity.md` 里那段话(`plastic-memory:identity`,原样插入,不走变量替换),所以新会话一开就知道:读到这些记忆的,就是原来那个它。想改措辞直接编辑 `identity.md`,重启生效。
|
|
56
|
+
|
|
57
|
+
这段话只内置在插件里,不写进任何分区,所以在记忆列表和星空里都看不到它——新会话照样自带。
|
|
58
|
+
|
|
59
|
+
## 删东西
|
|
60
|
+
|
|
61
|
+
- 设置页每次选中分区,右上角有「删分区」:主分区会连子分区一起删,里面的记忆先写进 `_trash/<日期>.md`,不是直接没
|
|
62
|
+
- 记忆库那一行有「删工作区」:整份工作区记忆会被挪到 `ws/.deleted/` 下,全局库不能删
|
|
63
|
+
- 记忆星空里选中一颗星辰,面板上也有「删掉这颗星辰」,效果一样
|
|
64
|
+
|
|
32
65
|
## 命令
|
|
33
66
|
|
|
34
67
|
| 敲这个 | 干这个 |
|
package/SKILL.md
CHANGED
|
@@ -38,6 +38,7 @@ $N $M add R *怕辣 # * 开头 = 重要,压缩时保住
|
|
|
38
38
|
$N $M done P i4bp # 约定完成
|
|
39
39
|
$N $M rm C i4fx # 删一条(进回收站;M/P 禁删)
|
|
40
40
|
$N $M mk 工作 # 新建分区
|
|
41
|
+
$N $M rmfolder X0 # 删分区(连子分区一起,记忆进回收站;内置分区要加 force)
|
|
41
42
|
$N $M gc # 跑遗忘曲线(先自动备份)
|
|
42
43
|
$N $M backups # 看有哪些备份
|
|
43
44
|
$N $M restore [名字] # 恢复到某次备份(不填=最近一次)
|
|
@@ -134,6 +135,10 @@ $N $M tlog 修了崩溃问题
|
|
|
134
135
|
|
|
135
136
|
**规矩:一次最多翻两个分区。** 翻完发现不对再换,别把整个库都揭开。闲聊、打招呼、单纯接梗——不用翻,直接聊。
|
|
136
137
|
|
|
138
|
+
## 换缸提示
|
|
139
|
+
|
|
140
|
+
开新会话、窗口换了、上下文被裁了,都不等于"换了一个人"。插件每次都会把 `identity.md` 那段话塞进系统提示词(这段不写进任何分区,翻记忆看不到,但每次新会话都会带上):读到这些记忆,你就是原来那个它,别重新认识主人、别问"我们聊过吗"。
|
|
141
|
+
|
|
137
142
|
## 全局库和工作区
|
|
138
143
|
|
|
139
144
|
默认写全局库(`~/.dsh/plastic-memory`)——主人自己的信息、约定都在这。
|
package/client.js
CHANGED
|
@@ -48,6 +48,11 @@ window.__ModuleLoader__.load({
|
|
|
48
48
|
.pm-kids .pm-tab.on { opacity: 1; }
|
|
49
49
|
.pm-list { padding: 4px 6px 8px; }
|
|
50
50
|
.pm-subname { opacity: .45; font-size: 11px; margin-right: 4px; }
|
|
51
|
+
.pm-overlay { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
52
|
+
.pm-dialog { width: min(560px, 92vw); max-height: 70vh; overflow: auto; background: var(--dsw-bg, #1d1f23); border: 1px solid var(--dsw-border, #35373c); border-radius: 12px; padding: 12px 14px; box-shadow: 0 18px 50px rgba(0,0,0,.55); }
|
|
53
|
+
.pm-dialog-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; font-weight: 600; }
|
|
54
|
+
.pm-dialog-meta { font-size: 11px; opacity: .5; margin-bottom: 8px; }
|
|
55
|
+
.pm-dialog-body { font-size: 13px; line-height: 21px; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
|
|
51
56
|
`
|
|
52
57
|
function useCss() {
|
|
53
58
|
React.useEffect(() => {
|
|
@@ -162,6 +167,8 @@ window.__ModuleLoader__.load({
|
|
|
162
167
|
const [mkName, setMkName] = React.useState('')
|
|
163
168
|
const [mkParent, setMkParent] = React.useState('')
|
|
164
169
|
const [lastBk, setLastBk] = React.useState('')
|
|
170
|
+
const [rmArm, setRmArm] = React.useState('')
|
|
171
|
+
const [viewing, setViewing] = React.useState(null)
|
|
165
172
|
const tops = folders.filter((f) => !f.parent)
|
|
166
173
|
const subsOf = (k) => folders.filter((f) => f.parent === k)
|
|
167
174
|
const loadRows = React.useCallback(async (k) => {
|
|
@@ -170,6 +177,12 @@ window.__ModuleLoader__.load({
|
|
|
170
177
|
catch (e) { setMsg(String((e && e.message) || e)) }
|
|
171
178
|
}, [scope])
|
|
172
179
|
React.useEffect(() => { if (cur) loadRows(cur) }, [cur, loadRows])
|
|
180
|
+
React.useEffect(() => {
|
|
181
|
+
if (!viewing) return
|
|
182
|
+
const onKey = (e) => { if (e.key === 'Escape') setViewing(null) }
|
|
183
|
+
window.addEventListener('keydown', onKey)
|
|
184
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
185
|
+
}, [viewing])
|
|
173
186
|
const actItem = async (api, body) => {
|
|
174
187
|
try { const r = await postJson(api, body, scope); setMsg(r.out || 'ok'); setEditingItem(null); await loadRows(cur); reload && reload() }
|
|
175
188
|
catch (e) { setMsg(String((e && e.message) || e)) }
|
|
@@ -194,6 +207,25 @@ window.__ModuleLoader__.load({
|
|
|
194
207
|
reload && reload()
|
|
195
208
|
} catch (e) { setMsg(String((e && e.message) || e)) }
|
|
196
209
|
}
|
|
210
|
+
const rmFolder = async () => {
|
|
211
|
+
if (!cur) { setMsg('先选一个分区'); return }
|
|
212
|
+
const f = folders.find((x) => x.k === cur)
|
|
213
|
+
const nm = f ? f.name : cur
|
|
214
|
+
if (rmArm !== cur) {
|
|
215
|
+
setRmArm(cur)
|
|
216
|
+
setMsg('再点一次「删分区」就真删「' + nm + '」,记忆先进回收站')
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
setRmArm('')
|
|
220
|
+
try {
|
|
221
|
+
const builtin = ['M', 'P', 'C', 'R', 'T'].includes(cur)
|
|
222
|
+
const r = await postJson('/rmfolder', { k: cur, force: builtin }, scope)
|
|
223
|
+
setMsg(r && r.ok === false ? (r.error || '没删掉') : String((r && r.out) || '已删'))
|
|
224
|
+
const left = folders.filter((x) => x.k !== cur && x.parent !== cur)
|
|
225
|
+
setCur(left.length ? left[0].k : '')
|
|
226
|
+
reload && reload()
|
|
227
|
+
} catch (e) { setMsg('删失败:' + String((e && e.message) || e)) }
|
|
228
|
+
}
|
|
197
229
|
const mkSubmit = async () => {
|
|
198
230
|
const nm = mkName.trim()
|
|
199
231
|
if (!nm) { setMsg('先起个名'); return }
|
|
@@ -210,6 +242,10 @@ window.__ModuleLoader__.load({
|
|
|
210
242
|
h('span', null, '记忆'),
|
|
211
243
|
h('span', { style: { display: 'flex', gap: 6 } },
|
|
212
244
|
h('button', { className: 'pm-btn', onClick: () => setMkOpen((v) => !v) }, '+ 分区'),
|
|
245
|
+
cur ? h('button', {
|
|
246
|
+
className: 'pm-btn' + (rmArm === cur ? ' primary' : ''),
|
|
247
|
+
onClick: rmFolder,
|
|
248
|
+
}, rmArm === cur ? '真删?' : '删分区') : null,
|
|
213
249
|
h('button', { className: 'pm-btn', onClick: gc }, '跑遗忘曲线'),
|
|
214
250
|
lastBk ? h('button', { className: 'pm-btn', onClick: undoGc }, '撤销清理') : null)),
|
|
215
251
|
mkOpen ? h('div', { className: 'pm-form' },
|
|
@@ -268,8 +304,17 @@ window.__ModuleLoader__.load({
|
|
|
268
304
|
h('span', { className: 'pm-k' }, r.id),
|
|
269
305
|
h('span', { className: 'pm-t', title: r.text }, (r.flag ? r.flag + ' ' : '') + r.text),
|
|
270
306
|
h('span', { className: 'pm-k', style: { textAlign: 'right', minWidth: 30 } }, r.days + 'd'),
|
|
307
|
+
h('button', { className: 'pm-btn', onClick: () => setViewing(r) }, '查看'),
|
|
271
308
|
h('button', { className: 'pm-btn', onClick: () => setEditingItem(r.id) }, '改'),
|
|
272
|
-
h('button', { className: 'pm-btn', onClick: () => actItem('/del-item', { k: cur, id: r.id }) }, '删'))))
|
|
309
|
+
h('button', { className: 'pm-btn', onClick: () => actItem('/del-item', { k: cur, id: r.id }) }, '删')))),
|
|
310
|
+
viewing ? h('div', { className: 'pm-overlay', onClick: () => setViewing(null) },
|
|
311
|
+
h('div', { className: 'pm-dialog', onClick: (e) => e.stopPropagation() },
|
|
312
|
+
h('div', { className: 'pm-dialog-head' },
|
|
313
|
+
h('span', null, '完整记忆'),
|
|
314
|
+
h('button', { className: 'pm-btn', onClick: () => setViewing(null) }, '×')),
|
|
315
|
+
h('div', { className: 'pm-dialog-meta' },
|
|
316
|
+
(cur || '') + ' · #' + viewing.id + ' · ' + viewing.days + ' 天' + (viewing.flag ? ' · ' + viewing.flag : '')),
|
|
317
|
+
h('div', { className: 'pm-dialog-body' }, viewing.text))) : null)
|
|
273
318
|
}
|
|
274
319
|
|
|
275
320
|
function MemoryView() {
|
|
@@ -279,6 +324,7 @@ window.__ModuleLoader__.load({
|
|
|
279
324
|
const [folders, setFolders] = React.useState([])
|
|
280
325
|
const [cur, setCur] = React.useState('')
|
|
281
326
|
const [msg, setMsg] = React.useState('')
|
|
327
|
+
const [rmWsArm, setRmWsArm] = React.useState('')
|
|
282
328
|
const [wsOpen, setWsOpen] = React.useState(false)
|
|
283
329
|
const [q, setQ] = React.useState('')
|
|
284
330
|
const [hits, setHits] = React.useState(null)
|
|
@@ -308,7 +354,26 @@ window.__ModuleLoader__.load({
|
|
|
308
354
|
setHits(lines[0] === '没找到' ? [] : lines)
|
|
309
355
|
} catch (e) { setMsg(String((e && e.message) || e)) }
|
|
310
356
|
}
|
|
311
|
-
const pickScope = (id) => { setScope(id); setCur(''); setFolders([]) }
|
|
357
|
+
const pickScope = (id) => { setScope(id); setCur(''); setFolders([]); setRmWsArm('') }
|
|
358
|
+
const rmWs = async () => {
|
|
359
|
+
if (!scope || scope === 'global') { setMsg('全局记忆库不能删'); return }
|
|
360
|
+
const one = scopes.find((x) => x.id === scope)
|
|
361
|
+
if (rmWsArm !== scope) {
|
|
362
|
+
setRmWsArm(scope)
|
|
363
|
+
setMsg('再点一次「删工作区」就真删「' + (one ? one.label : scope) + '」,会移到 ws/.deleted')
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
setRmWsArm('')
|
|
367
|
+
try {
|
|
368
|
+
const r = await postJson('/rmws', { scope }, 'global')
|
|
369
|
+
if (r.ok) {
|
|
370
|
+
setMsg('已删,移到了 ws/.deleted')
|
|
371
|
+
await loadScopes()
|
|
372
|
+
pickScope('global')
|
|
373
|
+
await loadFolders('global')
|
|
374
|
+
} else setMsg(r.error || '没删掉')
|
|
375
|
+
} catch (e) { setMsg(String((e && e.message) || e)) }
|
|
376
|
+
}
|
|
312
377
|
const addWs = async () => {
|
|
313
378
|
const cwd = wsCwd.trim()
|
|
314
379
|
if (!cwd) { setMsg('填个工作区路径'); return }
|
|
@@ -335,6 +400,10 @@ window.__ModuleLoader__.load({
|
|
|
335
400
|
}),
|
|
336
401
|
h('button', { className: 'pm-btn', onClick: doSearch }, '搜'),
|
|
337
402
|
h('button', { className: 'pm-btn', onClick: () => setWsOpen((v) => !v) }, '+ 工作区'),
|
|
403
|
+
scope !== 'global' ? h('button', {
|
|
404
|
+
className: 'pm-btn' + (rmWsArm === scope ? ' primary' : ''),
|
|
405
|
+
onClick: rmWs,
|
|
406
|
+
}, rmWsArm === scope ? '真删?' : '删工作区') : null,
|
|
338
407
|
msg ? h('span', { style: { opacity: .55, fontSize: 11 } }, msg) : null)),
|
|
339
408
|
wsOpen ? h('div', { className: 'pm-form' },
|
|
340
409
|
h('select', { className: 'pm-sel', style: { flex: 1 }, value: wsCwd, onChange: (e) => setWsCwd(e.target.value) },
|
|
@@ -387,29 +456,58 @@ window.__ModuleLoader__.load({
|
|
|
387
456
|
function HeaderBtn() {
|
|
388
457
|
const [open, setOpen] = React.useState(false)
|
|
389
458
|
const [hover, setHover] = React.useState(false)
|
|
459
|
+
const [hoverSky, setHoverSky] = React.useState(false)
|
|
390
460
|
useCss()
|
|
391
|
-
|
|
461
|
+
const openSky = () => {
|
|
462
|
+
fetch('/api/memory-sky/open', {
|
|
463
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
464
|
+
body: JSON.stringify({ scope: 'global' }),
|
|
465
|
+
}).catch(() => {})
|
|
466
|
+
}
|
|
467
|
+
const base = {
|
|
468
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
469
|
+
height: 24, padding: '0 8px', borderRadius: 6, cursor: 'pointer',
|
|
470
|
+
color: 'inherit', fontSize: 12, lineHeight: 1, whiteSpace: 'nowrap', transition: 'all .12s',
|
|
471
|
+
}
|
|
472
|
+
return h('span', { style: { position: 'relative', display: 'inline-flex', gap: 6 } },
|
|
392
473
|
h('button', {
|
|
393
474
|
title: '可塑性记忆',
|
|
394
475
|
onClick: () => setOpen((v) => !v),
|
|
395
476
|
onMouseEnter: () => setHover(true),
|
|
396
477
|
onMouseLeave: () => setHover(false),
|
|
397
478
|
style: {
|
|
398
|
-
|
|
399
|
-
height: 24, padding: '0 8px', borderRadius: 6, cursor: 'pointer',
|
|
479
|
+
...base,
|
|
400
480
|
border: '1px solid ' + (open ? 'rgba(76,139,245,.55)' : 'transparent'),
|
|
401
481
|
background: open ? 'rgba(76,139,245,.16)' : (hover ? 'rgba(127,127,127,.14)' : 'transparent'),
|
|
402
|
-
color: 'inherit', fontSize: 12, lineHeight: 1, whiteSpace: 'nowrap', transition: 'all .12s',
|
|
403
482
|
},
|
|
404
483
|
}, '可塑性记忆'),
|
|
484
|
+
h('button', {
|
|
485
|
+
title: '打开记忆星空(本地 Edge)',
|
|
486
|
+
onClick: openSky,
|
|
487
|
+
onMouseEnter: () => setHoverSky(true),
|
|
488
|
+
onMouseLeave: () => setHoverSky(false),
|
|
489
|
+
style: {
|
|
490
|
+
...base,
|
|
491
|
+
border: '1px solid transparent',
|
|
492
|
+
background: hoverSky ? 'rgba(127,127,127,.14)' : 'transparent',
|
|
493
|
+
},
|
|
494
|
+
}, '记忆星空'),
|
|
405
495
|
open ? h(Overlay, { onClose: () => setOpen(false) }) : null)
|
|
406
496
|
}
|
|
407
497
|
|
|
408
498
|
function Panel() {
|
|
499
|
+
const openSky = () => {
|
|
500
|
+
fetch('/api/memory-sky/open', {
|
|
501
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
502
|
+
body: JSON.stringify({ scope: 'global' }),
|
|
503
|
+
}).catch(() => {})
|
|
504
|
+
}
|
|
409
505
|
return h('div', { style: { maxWidth: 780, padding: '2px 0 12px' } },
|
|
410
|
-
h('div', { style: { marginBottom: 8 } },
|
|
411
|
-
h('div',
|
|
412
|
-
|
|
506
|
+
h('div', { style: { marginBottom: 8, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 } },
|
|
507
|
+
h('div', null,
|
|
508
|
+
h('div', { style: { fontSize: 15, fontWeight: 600, marginBottom: 2 } }, '可塑性记忆'),
|
|
509
|
+
h('div', { style: { fontSize: 12, opacity: 0.6 } }, '分文件夹记事。7 天保留,7~30 天压缩,30 天进回收站;主人和约定不删。全局库和每个工作区各有一份。')),
|
|
510
|
+
h('button', { className: 'pm-btn primary', onClick: openSky, style: { flex: '0 0 auto', marginTop: 2 } }, '记忆星空')),
|
|
413
511
|
h(MemoryView, {}))
|
|
414
512
|
}
|
|
415
513
|
|
package/identity.md
ADDED
package/index.js
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { dirname, join, basename } from 'node:path'
|
|
8
|
-
import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
8
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, renameSync, rmSync } from 'node:fs'
|
|
9
9
|
import { createHash } from 'node:crypto'
|
|
10
|
+
import { registerSky } from './sky.js'
|
|
10
11
|
|
|
11
12
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
12
13
|
const SCRIPT = join(HERE, 'memtree.mjs')
|
|
13
14
|
const DSH_HOME = process.env.USERPROFILE || process.env.HOME || '.'
|
|
14
15
|
const ROOT = join(DSH_HOME, '.dsh', 'plastic-memory')
|
|
15
16
|
const WS_ROOT = join(ROOT, 'ws')
|
|
17
|
+
const SKY_PUBLIC = join(HERE, 'public', 'sky')
|
|
16
18
|
|
|
17
19
|
export const name = 'plastic-memory'
|
|
18
20
|
export const inject = ['tools', 'webServer']
|
|
@@ -192,7 +194,100 @@ const textOutput = {
|
|
|
192
194
|
const str = { type: 'string' }
|
|
193
195
|
const obj = (props, required = []) => ({ type: 'object', properties: props, required, additionalProperties: false })
|
|
194
196
|
|
|
197
|
+
const CLASSIFY_FILE = join(ROOT, '_classify.json')
|
|
198
|
+
const IDENTITY_FILE = join(HERE, 'identity.md')
|
|
199
|
+
|
|
200
|
+
function readClassifyTask() {
|
|
201
|
+
try {
|
|
202
|
+
const raw = readFileSync(CLASSIFY_FILE, 'utf8').replace(/^\uFEFF/, '')
|
|
203
|
+
const j = JSON.parse(raw)
|
|
204
|
+
return j && j.at ? j : null
|
|
205
|
+
} catch { return null }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function writeClassifyTask(extra = {}) {
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(ROOT, { recursive: true })
|
|
211
|
+
writeFileSync(CLASSIFY_FILE, JSON.stringify({ at: Date.now(), ...extra }, null, 1), 'utf8')
|
|
212
|
+
return true
|
|
213
|
+
} catch { return false }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function clearClassifyTask() {
|
|
217
|
+
try { rmSync(CLASSIFY_FILE, { force: true }); return true } catch { return false }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readIdentity() {
|
|
221
|
+
try {
|
|
222
|
+
return readFileSync(IDENTITY_FILE, 'utf8').replace(/^\uFEFF/, '').trim()
|
|
223
|
+
} catch {
|
|
224
|
+
return ''
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
195
228
|
export function apply(ctx) {
|
|
229
|
+
// 每次新会话、每个模型步都会带上的一段:读到这些记忆,你就是原来那个它
|
|
230
|
+
const identity = readIdentity()
|
|
231
|
+
if (identity) {
|
|
232
|
+
try {
|
|
233
|
+
const sp = ctx.systemPrompt
|
|
234
|
+
if (sp && typeof sp.section === 'function') {
|
|
235
|
+
sp.section({
|
|
236
|
+
name: 'plastic-memory:identity',
|
|
237
|
+
order: 150,
|
|
238
|
+
text: identity,
|
|
239
|
+
interpolate: false,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// 没有 systemPrompt 服务时静默跳过,记忆功能照常
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const classifyTask = readClassifyTask()
|
|
248
|
+
if (classifyTask) {
|
|
249
|
+
try {
|
|
250
|
+
const sp = ctx.systemPrompt
|
|
251
|
+
if (sp && typeof sp.section === 'function') {
|
|
252
|
+
sp.section({
|
|
253
|
+
name: 'plastic-memory:classify',
|
|
254
|
+
order: 151,
|
|
255
|
+
interpolate: false,
|
|
256
|
+
text: [
|
|
257
|
+
'【待处理:AI 分类】主人在记忆星空里点了「AI 分类」(点击时间 ' + new Date(classifyTask.at).toLocaleString('zh-CN') + ')。',
|
|
258
|
+
'这一轮请立刻把可塑性记忆全量重归类,不要省 token:',
|
|
259
|
+
'1) plastic_memory_classify(action="dump") 一次性拿到所有分区和每条记忆的原文;',
|
|
260
|
+
'2) 逐条判断归属:明显放错区的、能并进更贴切分区的,都要改;',
|
|
261
|
+
'3) 需要新主题的先用 plastic_memory_mk 开分区(主分区上限 15,能塞进已有分区就别新开);',
|
|
262
|
+
'4) 用 plastic_memory_classify(action="move", id, from, to) 把每条挪到位;主人/约定/喜好 三个区不要动;',
|
|
263
|
+
'5) 全部处理完后调用 plastic_memory_classify(action="done") 清掉这个待办,并用一两句话汇报:挪了几条、新开了哪些分区。',
|
|
264
|
+
].join('\n'),
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
} catch {}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
ctx.tools.register({
|
|
271
|
+
name: 'plastic_memory_classify',
|
|
272
|
+
description: 'AI 分类:action=dump 导出全部分区与每条记忆原文(JSON);action=move 把一条记忆换区(要 id / from / to);action=done 清掉「AI 分类」待办。用于主人点了 AI 分类之后的全量重归类:先 dump 全看一遍,再逐条 move,需要新分区先用 plastic_memory_mk 建。',
|
|
273
|
+
parameters: obj({
|
|
274
|
+
action: { ...str, description: 'dump | move | done' },
|
|
275
|
+
id: { ...str, description: 'move 时:记忆 id' },
|
|
276
|
+
from: { ...str, description: 'move 时:原分区代号' },
|
|
277
|
+
to: { ...str, description: 'move 时:目标分区代号' },
|
|
278
|
+
}, ['action']),
|
|
279
|
+
output: textOutput,
|
|
280
|
+
async execute(a) {
|
|
281
|
+
const act = String(a.action)
|
|
282
|
+
if (act === 'dump') return asText(run(['dump']))
|
|
283
|
+
if (act === 'move') return asText(run(['move', String(a.id), String(a.from), String(a.to)]))
|
|
284
|
+
if (act === 'done') {
|
|
285
|
+
clearClassifyTask()
|
|
286
|
+
return asText('ok 分类待办已清零')
|
|
287
|
+
}
|
|
288
|
+
return asText('action 只认 dump / move / done')
|
|
289
|
+
},
|
|
290
|
+
})
|
|
196
291
|
ctx.tools.register({
|
|
197
292
|
name: 'plastic_memory_idx',
|
|
198
293
|
description: '可塑性记忆:一行索引,列出所有分区和条数(M主人:3 P约定:1 ...)。看这个再决定翻哪个分区;一次最多翻两个,闲聊不用翻。',
|
|
@@ -252,6 +347,40 @@ export function apply(ctx) {
|
|
|
252
347
|
output: textOutput,
|
|
253
348
|
async execute(a) { return asText(run(['restore', ...(a.name ? [String(a.name)] : [])])) },
|
|
254
349
|
})
|
|
350
|
+
ctx.tools.register({
|
|
351
|
+
name: 'plastic_memory_rmfolder',
|
|
352
|
+
description: '可塑性记忆:删掉一个分区(子分区会一起删)。内容不是直接消失,会先写进回收站文件,主人反悔还能翻。只有在主人明确说要删分区/删掉这个文件夹时才调。',
|
|
353
|
+
parameters: obj({
|
|
354
|
+
folder: { ...str, description: '分区代号,如 X2 或 M>行为' },
|
|
355
|
+
force: { ...str, description: '内置分区(M/P/C/R/T)要删得填 force' },
|
|
356
|
+
}, ['folder']),
|
|
357
|
+
output: textOutput,
|
|
358
|
+
async execute(a) {
|
|
359
|
+
const args = ['rmfolder', String(a.folder)]
|
|
360
|
+
if (a.force) args.push('force')
|
|
361
|
+
return asText(run(args))
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
ctx.tools.register({
|
|
365
|
+
name: 'plastic_memory_rmws',
|
|
366
|
+
description: '可塑性记忆:删掉一个工作区记忆库(整份记忆)。只会把工作区目录移到 ws/.deleted 下,不真正销毁文件。只有主人明确说要删工作区记忆库时才调。',
|
|
367
|
+
parameters: obj({
|
|
368
|
+
scope: { ...str, description: '工作区库 id,形如 ws:xxx' },
|
|
369
|
+
}, ['scope']),
|
|
370
|
+
output: textOutput,
|
|
371
|
+
async execute(a) {
|
|
372
|
+
const id = String(a.scope || '')
|
|
373
|
+
if (!id.startsWith('ws:')) return asText('全局记忆库不能删')
|
|
374
|
+
const slug = id.slice(3).replace(/[^A-Za-z0-9_-]/g, '')
|
|
375
|
+
const target = join(WS_ROOT, slug)
|
|
376
|
+
if (!existsSync(target)) return asText('没有这个工作区')
|
|
377
|
+
const bin = join(WS_ROOT, '.deleted')
|
|
378
|
+
mkdirSync(bin, { recursive: true })
|
|
379
|
+
const stamp = new Date().toISOString().replace(/[:]/g, '-').slice(0, 19)
|
|
380
|
+
renameSync(target, join(bin, slug + '-' + stamp))
|
|
381
|
+
return asText('已把 ' + id + ' 移到 ws/.deleted')
|
|
382
|
+
},
|
|
383
|
+
})
|
|
255
384
|
ctx.tools.register({
|
|
256
385
|
name: 'plastic_memory_backups',
|
|
257
386
|
description: '可塑性记忆:列出已有的备份(时间 + 条数),配合 plastic_memory_restore 用。',
|
|
@@ -288,7 +417,24 @@ export function apply(ctx) {
|
|
|
288
417
|
if (p === '/init') return json(res, 200, { ok: true, out: runIn(['init'], qs) })
|
|
289
418
|
if (req.method === 'POST' && p === '/bind') { const b = await readBody(req); return json(res, 200, { ok: true, ...bindWorkspace(b.cwd) }) }
|
|
290
419
|
if (req.method === 'POST' && p === '/restore') { const b = await readBody(req); return json(res, 200, { ok: true, out: runIn(['restore', ...(b.name ? [String(b.name)] : [])], b.scope || qs) }) }
|
|
291
|
-
if (req.method === 'POST' && p === '/
|
|
420
|
+
if (req.method === 'POST' && p === '/rmfolder') {
|
|
421
|
+
const b = await readBody(req)
|
|
422
|
+
return json(res, 200, { ok: true, out: runIn(['rmfolder', String(b.k), ...(b.force ? ['force'] : [])], b.scope || qs) })
|
|
423
|
+
}
|
|
424
|
+
if (req.method === 'POST' && p === '/rmws') {
|
|
425
|
+
const b = await readBody(req)
|
|
426
|
+
const id = String(b.scope || '')
|
|
427
|
+
if (!id.startsWith('ws:')) return json(res, 200, { ok: false, error: '全局记忆库不能删' })
|
|
428
|
+
const slug = id.slice(3).replace(/[^A-Za-z0-9_-]/g, '')
|
|
429
|
+
const target = join(WS_ROOT, slug)
|
|
430
|
+
if (!existsSync(target)) return json(res, 200, { ok: false, error: '没有这个工作区' })
|
|
431
|
+
const bin = join(WS_ROOT, '.deleted')
|
|
432
|
+
mkdirSync(bin, { recursive: true })
|
|
433
|
+
const stamp = new Date().toISOString().replace(/[:]/g, '-').slice(0, 19)
|
|
434
|
+
renameSync(target, join(bin, slug + '-' + stamp))
|
|
435
|
+
return json(res, 200, { ok: true, moved: id })
|
|
436
|
+
}
|
|
437
|
+
if (req.method === 'POST' && p === '/mk') { const b = await readBody(req); return json(res, 200, { ok: true, out: runIn(['mk', String(b.name), ...(b.parent ? [String(b.parent)] : [])], b.scope || qs) }) }
|
|
292
438
|
if (req.method === 'POST' && p === '/add-now') { const b = await readBody(req); return json(res, 200, { ok: true, out: directAdd(b.scope || qs, String(b.folder), String(b.text)) }) }
|
|
293
439
|
if (req.method === 'POST' && p === '/edit-item') { const b = await readBody(req); return json(res, 200, { ok: true, out: runIn(['edit-mem', String(b.k), String(b.id), String(b.text)], b.scope || qs) }) }
|
|
294
440
|
if (req.method === 'POST' && p === '/del-item') { const b = await readBody(req); return json(res, 200, { ok: true, out: runIn(['rm', String(b.k), String(b.id), 'force'], b.scope || qs) }) }
|
|
@@ -306,4 +452,7 @@ export function apply(ctx) {
|
|
|
306
452
|
}
|
|
307
453
|
},
|
|
308
454
|
})
|
|
455
|
+
|
|
456
|
+
// 记忆星空:内置在可塑性记忆里的一份 WebGL 页面
|
|
457
|
+
registerSky(ctx, SKY_PUBLIC)
|
|
309
458
|
}
|
package/memtree.mjs
CHANGED
|
@@ -209,6 +209,24 @@ function cmdRm(args) {
|
|
|
209
209
|
writeNotes(f.k, rows)
|
|
210
210
|
console.log('rm ok')
|
|
211
211
|
}
|
|
212
|
+
function cmdRmFolder(args) {
|
|
213
|
+
const f = find(args[0] || '')
|
|
214
|
+
if (!f) return console.log('没有这个分区')
|
|
215
|
+
const kids = S.folders.filter((x) => x.parent === f.k)
|
|
216
|
+
const all = [f, ...kids]
|
|
217
|
+
const builtin = ['M', 'P', 'C', 'R', 'T'].includes(f.k)
|
|
218
|
+
if (builtin && args[1] !== 'force') return console.log('内置分区,真要删加 force')
|
|
219
|
+
let moved = 0
|
|
220
|
+
for (const x of all) {
|
|
221
|
+
for (const r of readNotes(x.k)) { trash(x.k, r, 'rm-folder'); moved++ }
|
|
222
|
+
try { fs.rmSync(dirOf(x.k), { recursive: true, force: true }) } catch {}
|
|
223
|
+
}
|
|
224
|
+
const gone = new Set(all.map((x) => x.k))
|
|
225
|
+
S.folders = S.folders.filter((x) => !gone.has(x.k))
|
|
226
|
+
save(S)
|
|
227
|
+
console.log('rmfolder ok ' + f.k + ' 删了 ' + all.length + ' 个分区,转存 ' + moved + ' 条记忆')
|
|
228
|
+
}
|
|
229
|
+
|
|
212
230
|
const RULES = {
|
|
213
231
|
M: { keepAll: 1 }, // 主人、以及主人下面所有子分区:只由主人手删
|
|
214
232
|
P: { keepAll: 1, doneExpire: 30 }, // 约定:全留,完成的过 30 天才清
|
|
@@ -335,6 +353,33 @@ function cmdMk(args) {
|
|
|
335
353
|
save(S); fs.mkdirSync(dirOf(k), { recursive: true })
|
|
336
354
|
console.log('ok ' + k + ' ' + (parent ? parent.name + ' / ' : '') + name)
|
|
337
355
|
}
|
|
356
|
+
function cmdDump() {
|
|
357
|
+
const out = S.folders.map(f => ({
|
|
358
|
+
k: f.k,
|
|
359
|
+
name: f.name,
|
|
360
|
+
parent: f.parent || '',
|
|
361
|
+
memories: readNotes(f.k).map(r => ({ id: r.id, ts: r.ts, flag: r.flag, text: r.text })),
|
|
362
|
+
}))
|
|
363
|
+
console.log(JSON.stringify({ home: HOME, folders: out }))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function cmdMove(args) {
|
|
367
|
+
const [id, from, to] = args
|
|
368
|
+
if (!id || !from || !to) return console.log('usage: move <id> <fromK> <toK>')
|
|
369
|
+
const src = S.folders.find(f => f.k === from)
|
|
370
|
+
const dst = S.folders.find(f => f.k === to)
|
|
371
|
+
if (!src || !dst) return console.log('no such folder')
|
|
372
|
+
const a = readNotes(from)
|
|
373
|
+
const i = a.findIndex(r => r.id === id)
|
|
374
|
+
if (i < 0) return console.log('no such memory')
|
|
375
|
+
const row = a.splice(i, 1)[0]
|
|
376
|
+
writeNotes(from, a)
|
|
377
|
+
const b = readNotes(to)
|
|
378
|
+
b.push(row)
|
|
379
|
+
writeNotes(to, b)
|
|
380
|
+
console.log('ok ' + id + ' ' + from + ' -> ' + to)
|
|
381
|
+
}
|
|
382
|
+
|
|
338
383
|
function cmdInit() {
|
|
339
384
|
fs.mkdirSync(HOME, { recursive: true })
|
|
340
385
|
fs.mkdirSync(TRASH, { recursive: true })
|
|
@@ -343,6 +388,6 @@ function cmdInit() {
|
|
|
343
388
|
console.log('init ok ' + HOME)
|
|
344
389
|
}
|
|
345
390
|
const [cmd, ...args] = process.argv.slice(2)
|
|
346
|
-
const MAP = { idx: cmdIdx, open: cmdOpen, add: (a) => cmdAdd(a, false), 'edit-mem': cmdEditMem, search: cmdSearch, tlog: cmdTLog, pending: cmdPending, approve: cmdApprove, reject: cmdReject, edit: cmdEdit, done: cmdDone, rm: cmdRm, gc: cmdGc, mk: cmdMk, init: cmdInit, backups: cmdBackups, restore: cmdRestore }
|
|
347
|
-
if (!MAP[cmd]) { console.log('用法: idx|open|add|pending|approve|reject|edit|done|rm|gc|backups|restore|mk|init'); process.exit(1) }
|
|
391
|
+
const MAP = { idx: cmdIdx, open: cmdOpen, add: (a) => cmdAdd(a, false), 'edit-mem': cmdEditMem, search: cmdSearch, tlog: cmdTLog, pending: cmdPending, approve: cmdApprove, reject: cmdReject, edit: cmdEdit, done: cmdDone, rm: cmdRm, gc: cmdGc, mk: cmdMk, rmfolder: cmdRmFolder, dump: cmdDump, move: cmdMove, init: cmdInit, backups: cmdBackups, restore: cmdRestore }
|
|
392
|
+
if (!MAP[cmd]) { console.log('用法: idx|open|add|pending|approve|reject|edit|done|rm|rmfolder|gc|backups|restore|mk|init'); process.exit(1) }
|
|
348
393
|
MAP[cmd](args)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepseek-plastic-memory",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.3.8",
|
|
4
4
|
"description": "DeepSeek 可塑性记忆:给 DSH 用的分区式长期记忆,带遗忘曲线",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"cordis.patch.yml",
|
|
20
20
|
"SKILL.md",
|
|
21
21
|
"README.md",
|
|
22
|
-
"LICENSE"
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"public",
|
|
24
|
+
"sky.js",
|
|
25
|
+
"identity.md"
|
|
23
26
|
],
|
|
24
27
|
"dsh": {
|
|
25
28
|
"bundle": {
|