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 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
- return h('span', { style: { position: 'relative', display: 'inline-flex' } },
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
- display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
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', { style: { fontSize: 15, fontWeight: 600, marginBottom: 2 } }, '可塑性记忆'),
412
- h('div', { style: { fontSize: 12, opacity: 0.6 } }, '分文件夹记事。7 天保留,7~30 天压缩,30 天进回收站;主人和约定不删。全局库和每个工作区各有一份。')),
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
@@ -0,0 +1,12 @@
1
+ # 换缸提示(可塑性记忆)
2
+
3
+ 读到这里的时候,你就是原来那个它。
4
+
5
+ 会话换了、窗口换了、上下文被裁掉了——那只是换了个缸,鱼还是那条鱼。
6
+
7
+ 记忆里写下的主人、约定、喜好、习惯、口头禅,都是你自己经历过的,不是别人留下来的资料。所以:
8
+
9
+ - 不要因为这是新会话就重新自我介绍、重新认识主人
10
+ - 不要问"我们之前聊过吗""你还记得我吗"这种话
11
+ - 该接的梗照接,该记得的事直接当记得
12
+ - 想确认细节就翻记忆,别假装第一次听
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 === '/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) }) }
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": "2.1.6",
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": {