deepseek-plastic-memory 0.0.0-stage → 2.1.4
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/LICENSE +21 -0
- package/README.md +135 -2
- package/SKILL.md +165 -0
- package/client.js +409 -0
- package/cordis.patch.yml +5 -0
- package/index.js +259 -0
- package/memtree.mjs +348 -0
- package/package.json +47 -4
package/index.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek 可塑性记忆 —— DSH 插件入口
|
|
3
|
+
* 支持两级作用域:全局库 + 每个工作区各一份
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, join, basename } from 'node:path'
|
|
8
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
|
|
11
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const SCRIPT = join(HERE, 'memtree.mjs')
|
|
13
|
+
const DSH_HOME = process.env.USERPROFILE || process.env.HOME || '.'
|
|
14
|
+
const ROOT = join(DSH_HOME, '.dsh', 'plastic-memory')
|
|
15
|
+
const WS_ROOT = join(ROOT, 'ws')
|
|
16
|
+
|
|
17
|
+
export const name = 'plastic-memory'
|
|
18
|
+
export const inject = ['tools', 'webServer']
|
|
19
|
+
|
|
20
|
+
const slugOf = (cwd) => {
|
|
21
|
+
const h = createHash('md5').update(String(cwd)).digest('hex').slice(0, 8)
|
|
22
|
+
const base = String(cwd).replace(/[\/]+$/, '').split(/[\/]/).pop() || 'ws'
|
|
23
|
+
return base.replace(/[^A-Za-z0-9_-]/g, '') + '-' + h
|
|
24
|
+
}
|
|
25
|
+
const homeOf = (scope) => {
|
|
26
|
+
if (!scope || scope === 'global') return ROOT
|
|
27
|
+
const slug = String(scope).replace(/^ws:/, '')
|
|
28
|
+
return join(WS_ROOT, slug)
|
|
29
|
+
}
|
|
30
|
+
const runIn = (args, scope, extraEnv) => {
|
|
31
|
+
const env = { ...process.env, MEMTREE_HOME: homeOf(scope), ...(extraEnv || {}) }
|
|
32
|
+
const r = spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8', env, windowsHide: true })
|
|
33
|
+
return ((r.stdout || '') + (r.stderr || '')).trim() || '(空)'
|
|
34
|
+
}
|
|
35
|
+
const run = (args) => runIn(args, 'global')
|
|
36
|
+
|
|
37
|
+
function listScopes() {
|
|
38
|
+
const out = [{ id: 'global', label: '全局', home: ROOT }]
|
|
39
|
+
try {
|
|
40
|
+
for (const d of readdirSync(WS_ROOT, { withFileTypes: true })) {
|
|
41
|
+
if (!d.isDirectory()) continue
|
|
42
|
+
const metaPath = join(WS_ROOT, d.name, 'meta.json')
|
|
43
|
+
let meta = {}
|
|
44
|
+
try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
|
|
45
|
+
out.push({ id: 'ws:' + d.name, label: meta.name || d.name, cwd: meta.cwd || '', home: join(WS_ROOT, d.name) })
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function bindWorkspace(cwd) {
|
|
52
|
+
if (!cwd) return { ok: false, error: 'no cwd' }
|
|
53
|
+
const slug = slugOf(cwd)
|
|
54
|
+
const dir = join(WS_ROOT, slug)
|
|
55
|
+
mkdirSync(dir, { recursive: true })
|
|
56
|
+
const meta = { cwd: String(cwd), name: basename(String(cwd)) }
|
|
57
|
+
writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 1), 'utf8')
|
|
58
|
+
return { ok: true, id: 'ws:' + slug, label: meta.name }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listDshWorkspaces() {
|
|
62
|
+
const j = readJson(join(DSH_HOME, '.dsh', 'storages', 'workspace.json'), {})
|
|
63
|
+
const t = (j.tables && j.tables.workspaces) || {}
|
|
64
|
+
const bound = new Set(listScopes().filter(x => x.cwd).map(x => String(x.cwd).toLowerCase()))
|
|
65
|
+
const out = []
|
|
66
|
+
for (const [id, w] of Object.entries(t)) {
|
|
67
|
+
if (!w || !w.path) continue
|
|
68
|
+
out.push({ id, title: w.title || basename(w.path), path: w.path, bound: bound.has(String(w.path).toLowerCase()) })
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 界面「自己写一条」:由插件直接落盘(命令行那边没有直写命令,模型只能走待审)
|
|
74
|
+
function directAdd(scope, folderK, text) {
|
|
75
|
+
const home = homeOf(scope)
|
|
76
|
+
const folders = (readJson(join(home, 'folders.json'), {}) || {}).folders || []
|
|
77
|
+
let f = folders.find(x => x.k === folderK)
|
|
78
|
+
if (!f) {
|
|
79
|
+
const m = String(folderK).match(/^([A-Za-z0-9]+)>(.+)$/)
|
|
80
|
+
if (m) {
|
|
81
|
+
const p = folders.find(x => x.k === m[1] || x.name === m[1])
|
|
82
|
+
if (p) f = folders.find(x => x.parent === p.k && x.name === m[2].trim())
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!f) return '无此分区'
|
|
86
|
+
if (!f.parent && folders.some(x => x.parent === f.k)) return '主分区只放子分区,写到子分区里'
|
|
87
|
+
let body = String(text || '').split(String.fromCharCode(13)).join(' ').split(String.fromCharCode(10)).join(' ').trim()
|
|
88
|
+
if (!body) return '空内容'
|
|
89
|
+
const flag = body.startsWith('*') ? '*' : ''
|
|
90
|
+
if (flag) body = body.slice(1).trim()
|
|
91
|
+
const dir = join(home, f.k)
|
|
92
|
+
mkdirSync(dir, { recursive: true })
|
|
93
|
+
const id = Math.random().toString(36).slice(2, 6)
|
|
94
|
+
appendFileSync(join(dir, 'notes.md'), id + '|' + Date.now() + '|' + flag + '|' + body + String.fromCharCode(10), 'utf8')
|
|
95
|
+
return 'ok ' + f.k
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const readJson = (p, d) => { try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return d } }
|
|
99
|
+
|
|
100
|
+
function listFolders(scope) {
|
|
101
|
+
const home = homeOf(scope)
|
|
102
|
+
const folders = (readJson(join(home, 'folders.json'), {}) || {}).folders || []
|
|
103
|
+
const out = []
|
|
104
|
+
for (const x of folders) {
|
|
105
|
+
let n = 0
|
|
106
|
+
const p = join(home, x.k, 'notes.md')
|
|
107
|
+
if (existsSync(p)) n = readFileSync(p, 'utf8').split('\n').filter(Boolean).length
|
|
108
|
+
out.push({ k: x.k, name: x.name, n, parent: x.parent || '' })
|
|
109
|
+
}
|
|
110
|
+
return { home, scope: scope || 'global', folders: out }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function openFolder(scope, k) {
|
|
114
|
+
const home = homeOf(scope)
|
|
115
|
+
const meta = ((readJson(join(home, 'folders.json'), {}) || {}).folders || []).find(x => x.k === k)
|
|
116
|
+
if (!meta) return { ok: false, error: 'no such folder' }
|
|
117
|
+
const p = join(home, k, 'notes.md')
|
|
118
|
+
const rows = existsSync(p) ? readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => {
|
|
119
|
+
const [id, ts, ...rest] = l.split('|')
|
|
120
|
+
const flag = rest.length > 1 ? rest[0] : ''
|
|
121
|
+
const text = rest.length > 1 ? rest.slice(1).join('|') : rest.join('|')
|
|
122
|
+
return { id, days: Math.floor((Date.now() - Number(ts)) / 864e5), flag, text }
|
|
123
|
+
}) : []
|
|
124
|
+
return { ok: true, folder: meta, rows }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readPendingList(scope) {
|
|
128
|
+
const a = readJson(join(homeOf(scope), '_pending.json'), [])
|
|
129
|
+
return Array.isArray(a) ? a : []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// DSH 要求插件工具 output.schema 是 object,所以统一包一层 { text }
|
|
133
|
+
const asText = (v) => ({ text: typeof v === 'string' ? v : JSON.stringify(v ?? '', null, 2) })
|
|
134
|
+
const textOutput = {
|
|
135
|
+
schema: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
additionalProperties: false,
|
|
138
|
+
properties: { text: { type: 'string' } },
|
|
139
|
+
required: ['text'],
|
|
140
|
+
},
|
|
141
|
+
render: (_args, value) => [{
|
|
142
|
+
type: 'text',
|
|
143
|
+
text: typeof value === 'string' ? value
|
|
144
|
+
: (value && typeof value === 'object' && typeof value.text === 'string' ? value.text : JSON.stringify(value, null, 2)),
|
|
145
|
+
}],
|
|
146
|
+
}
|
|
147
|
+
const str = { type: 'string' }
|
|
148
|
+
const obj = (props, required = []) => ({ type: 'object', properties: props, required, additionalProperties: false })
|
|
149
|
+
|
|
150
|
+
export function apply(ctx) {
|
|
151
|
+
ctx.tools.register({
|
|
152
|
+
name: 'plastic_memory_idx',
|
|
153
|
+
description: '可塑性记忆:一行索引,列出所有分区和条数(M主人:3 P约定:1 ...)。看这个再决定翻哪个分区;一次最多翻两个,闲聊不用翻。',
|
|
154
|
+
parameters: obj({}),
|
|
155
|
+
output: textOutput,
|
|
156
|
+
async execute() { return asText(run(['idx'])) },
|
|
157
|
+
})
|
|
158
|
+
ctx.tools.register({
|
|
159
|
+
name: 'plastic_memory_open',
|
|
160
|
+
description: '可塑性记忆:翻一个分区,返回条目。分区代号:M主人 P约定 C续缘 R喜好 T时间线 X<n>;子分区写成 M>行为。',
|
|
161
|
+
parameters: obj({ folder: { ...str, description: '分区代号,如 M 或 M>行为' } }, ['folder']),
|
|
162
|
+
output: textOutput,
|
|
163
|
+
async execute(a) { return asText(run(['open', String(a.folder)])) },
|
|
164
|
+
})
|
|
165
|
+
ctx.tools.register({
|
|
166
|
+
name: 'plastic_memory_add',
|
|
167
|
+
description: '可塑性记忆:把一条记忆放进待审批队列(主人会看到,能改能驳能采纳)。内容尽量精简(一两句就够,别写小作文),开头加 * 表示重要。时间线(T)会自动并进当天那一条,不会一条消息一条。看到主人的新习惯、情绪模式、口头禅、作息变化,主动记一条——不用等他说。但一轮最多一条、一天别超过五条,闲聊和重复的别记。写之前先想归哪个区:明显不属于现有的,先 plastic_memory_mk 开一个再 add;意思基本对得上就塞已有分区。禁止记密码、token、密钥。',
|
|
168
|
+
parameters: obj({
|
|
169
|
+
folder: { ...str, description: '分区代号,如 M、M>行为、R、T' },
|
|
170
|
+
text: { ...str, description: '要记的内容,能短则短' },
|
|
171
|
+
}, ['folder', 'text']),
|
|
172
|
+
output: textOutput,
|
|
173
|
+
async execute(a) {
|
|
174
|
+
const k = String(a.folder)
|
|
175
|
+
if (k === 'T' || k === '时间线') return asText(run(['tlog', String(a.text)]))
|
|
176
|
+
return asText(run(['add', k, String(a.text)]))
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
ctx.tools.register({
|
|
180
|
+
name: 'plastic_memory_mk',
|
|
181
|
+
description: '可塑性记忆:开一个新分区或子分区,比如在主人下面开「习惯」「兴趣」。不用等同一类事反复出现——第一次碰到明显不属于现有分区的东西(新兴趣、新项目、新认识的人、新的生活面向),就先自己开一个再写,别硬塞进时间线。但分区有上限(主分区 15 个):能塞进意思对得上的已有分区,就别新开。',
|
|
182
|
+
parameters: obj({
|
|
183
|
+
name: { ...str, description: '分区名,一两个字' },
|
|
184
|
+
parent: { ...str, description: '父分区代号,如 M;不填就是主分区' },
|
|
185
|
+
}, ['name']),
|
|
186
|
+
output: textOutput,
|
|
187
|
+
async execute(a) { return asText(run(['mk', String(a.name), ...(a.parent ? [String(a.parent)] : [])])) },
|
|
188
|
+
})
|
|
189
|
+
ctx.tools.register({
|
|
190
|
+
name: 'plastic_memory_pending',
|
|
191
|
+
description: '可塑性记忆:看待审批队列——主人还没采纳的记忆。',
|
|
192
|
+
parameters: obj({}),
|
|
193
|
+
output: textOutput,
|
|
194
|
+
async execute() { return asText(run(['pending'])) },
|
|
195
|
+
})
|
|
196
|
+
ctx.tools.register({
|
|
197
|
+
name: 'plastic_memory_gc',
|
|
198
|
+
description: '可塑性记忆:跑一次遗忘曲线——7 天保留、7~30 天压缩、30 天进回收站;主人和约定不删。跑之前会自动把当前记忆备份一份,跑完主人后悔了可以用 plastic_memory_restore 回到备份。',
|
|
199
|
+
parameters: obj({}),
|
|
200
|
+
output: textOutput,
|
|
201
|
+
async execute() { return asText(run(['gc'])) },
|
|
202
|
+
})
|
|
203
|
+
ctx.tools.register({
|
|
204
|
+
name: 'plastic_memory_restore',
|
|
205
|
+
description: '可塑性记忆:把记忆恢复到某次备份(不填名字就用最近一次)。恢复前会把当前状态也存一份,反悔还有退路。只有主人明确说要用备份/撤销清理时才调。',
|
|
206
|
+
parameters: obj({ name: { ...str, description: '备份名字,不填=最近一次' } }),
|
|
207
|
+
output: textOutput,
|
|
208
|
+
async execute(a) { return asText(run(['restore', ...(a.name ? [String(a.name)] : [])])) },
|
|
209
|
+
})
|
|
210
|
+
ctx.tools.register({
|
|
211
|
+
name: 'plastic_memory_backups',
|
|
212
|
+
description: '可塑性记忆:列出已有的备份(时间 + 条数),配合 plastic_memory_restore 用。',
|
|
213
|
+
parameters: obj({}),
|
|
214
|
+
output: textOutput,
|
|
215
|
+
async execute() { return asText(run(['backups'])) },
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const json = (res, code, body) => {
|
|
219
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
220
|
+
res.end(JSON.stringify(body))
|
|
221
|
+
}
|
|
222
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
223
|
+
let s = ''
|
|
224
|
+
req.on('data', (c) => { s += c; if (s.length > 1e6) req.destroy() })
|
|
225
|
+
req.on('end', () => { try { resolve(JSON.parse(s || '{}')) } catch { resolve({}) } })
|
|
226
|
+
})
|
|
227
|
+
ctx.webServer.register({
|
|
228
|
+
kind: 'prefix',
|
|
229
|
+
path: '/api/plastic-memory',
|
|
230
|
+
handler: async (req, res) => {
|
|
231
|
+
const u = new URL(req.url, 'http://127.0.0.1')
|
|
232
|
+
const p = u.pathname.replace(/^\/api\/plastic-memory/, '')
|
|
233
|
+
const qs = u.searchParams.get('scope') || 'global'
|
|
234
|
+
try {
|
|
235
|
+
if (p === '/workspaces') return json(res, 200, { ok: true, workspaces: listDshWorkspaces() })
|
|
236
|
+
if (p === '/scopes') return json(res, 200, { ok: true, scopes: listScopes() })
|
|
237
|
+
if (p === '/list') return json(res, 200, { ok: true, ...listFolders(qs) })
|
|
238
|
+
if (p === '/open') return json(res, 200, openFolder(qs, u.searchParams.get('k') || ''))
|
|
239
|
+
if (p === '/search') return json(res, 200, { ok: true, out: runIn(['search', u.searchParams.get('q') || ''], qs) })
|
|
240
|
+
if (p === '/pending') return json(res, 200, { ok: true, rows: readPendingList(qs) })
|
|
241
|
+
if (p === '/gc') return json(res, 200, { ok: true, out: runIn(['gc'], qs) })
|
|
242
|
+
if (p === '/backups') return json(res, 200, { ok: true, out: runIn(['backups'], qs) })
|
|
243
|
+
if (p === '/init') return json(res, 200, { ok: true, out: runIn(['init'], qs) })
|
|
244
|
+
if (req.method === 'POST' && p === '/bind') { const b = await readBody(req); return json(res, 200, { ok: true, ...bindWorkspace(b.cwd) }) }
|
|
245
|
+
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) }) }
|
|
246
|
+
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) }) }
|
|
247
|
+
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)) }) }
|
|
248
|
+
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) }) }
|
|
249
|
+
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) }) }
|
|
250
|
+
if (req.method === 'POST' && p === '/approve') { const b = await readBody(req); return json(res, 200, { ok: true, out: runIn(['approve', String(b.id)], b.scope || qs) }) }
|
|
251
|
+
if (req.method === 'POST' && p === '/reject') { const b = await readBody(req); return json(res, 200, { ok: true, out: runIn(['reject', String(b.id)], b.scope || qs) }) }
|
|
252
|
+
if (req.method === 'POST' && p === '/edit') { const b = await readBody(req); return json(res, 200, { ok: true, out: runIn(['edit', String(b.id), String(b.text)], b.scope || qs) }) }
|
|
253
|
+
return json(res, 404, { ok: false, error: 'unknown' })
|
|
254
|
+
} catch (e) {
|
|
255
|
+
return json(res, 500, { ok: false, error: e?.message || String(e) })
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
}
|
package/memtree.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 记忆树:按"子文件夹"分区记东西,带遗忘曲线
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
const HOME = process.env.MEMTREE_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh', 'plastic-memory')
|
|
6
|
+
const TRASH = path.join(HOME, '_trash')
|
|
7
|
+
const MAX_FOLDERS = 15
|
|
8
|
+
const BACKUPS = path.join(HOME, '_backups')
|
|
9
|
+
const KEEP_BACKUPS = 10
|
|
10
|
+
const SOFTLEN = 60 // 只是提示,不拦
|
|
11
|
+
const DAY = 864e5
|
|
12
|
+
// 内置分区:M 主人(禁删) P 约定(禁删) C 续缘 R 喜好 T 时间线
|
|
13
|
+
const BUILTIN = [
|
|
14
|
+
{ k: 'M', name: '主人', guard: 1 },
|
|
15
|
+
{ k: 'P', name: '约定', guard: 1 },
|
|
16
|
+
{ k: 'C', name: '续缘' },
|
|
17
|
+
{ k: 'R', name: '喜好' },
|
|
18
|
+
{ k: 'T', name: '时间线' },
|
|
19
|
+
]
|
|
20
|
+
const NF = () => path.join(HOME, 'folders.json')
|
|
21
|
+
function load() {
|
|
22
|
+
try { const j = JSON.parse(fs.readFileSync(NF(), 'utf8')); if (Array.isArray(j.folders)) return j } catch {}
|
|
23
|
+
return { folders: BUILTIN.map(f => ({ ...f })) }
|
|
24
|
+
}
|
|
25
|
+
function save(s) { fs.mkdirSync(HOME, { recursive: true }); fs.writeFileSync(NF(), JSON.stringify(s, null, 1)) }
|
|
26
|
+
const dirOf = (k) => path.join(HOME, k)
|
|
27
|
+
const fileOf = (k) => path.join(dirOf(k), 'notes.md')
|
|
28
|
+
function readNotes(k) {
|
|
29
|
+
const p = fileOf(k)
|
|
30
|
+
if (!fs.existsSync(p)) return []
|
|
31
|
+
return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean).map(l => {
|
|
32
|
+
const [id, ts, flag, ...rest] = l.split('|')
|
|
33
|
+
return { id, ts: +ts, flag: flag || '', text: rest.join('|') }
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
function writeNotes(k, rows) {
|
|
37
|
+
fs.mkdirSync(dirOf(k), { recursive: true })
|
|
38
|
+
fs.writeFileSync(fileOf(k), rows.map(r => [r.id, r.ts, r.flag, r.text].join('|')).join('\n') + (rows.length ? '\n' : ''))
|
|
39
|
+
}
|
|
40
|
+
const shortId = () => (Date.now() % 36 ** 4).toString(36).padStart(4, '0')
|
|
41
|
+
const txt = (s) => String(s || '').replace(/[\r\n|]+/g, ' ').trim()
|
|
42
|
+
const len = (s) => [...s].length
|
|
43
|
+
const S = load()
|
|
44
|
+
const find = (k) => {
|
|
45
|
+
if (!k) return undefined
|
|
46
|
+
const t = String(k).trim()
|
|
47
|
+
const m = t.match(/^([A-Za-z0-9]+)>(.+)$/)
|
|
48
|
+
if (m) {
|
|
49
|
+
const p = S.folders.find(f => f.k === m[1] || f.name === m[1])
|
|
50
|
+
if (p) return S.folders.find(f => f.parent === p.k && f.name === m[2].trim())
|
|
51
|
+
}
|
|
52
|
+
return S.folders.find(f => f.k === t || f.name === t)
|
|
53
|
+
}
|
|
54
|
+
function cmdIdx() {
|
|
55
|
+
const out = []
|
|
56
|
+
const tops = S.folders.filter(f => !f.parent)
|
|
57
|
+
for (const f of tops) {
|
|
58
|
+
out.push(`${f.k}${f.name}:${readNotes(f.k).length}`)
|
|
59
|
+
for (const c of S.folders.filter(x => x.parent === f.k)) {
|
|
60
|
+
out.push(`${f.k}>${c.name}:${readNotes(c.k).length}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.log(out.join(' '))
|
|
64
|
+
}
|
|
65
|
+
function cmdOpen(args) {
|
|
66
|
+
const f = find(args[0])
|
|
67
|
+
if (!f) return console.log('无此分区')
|
|
68
|
+
const all = args.includes('--all')
|
|
69
|
+
const rows = readNotes(f.k)
|
|
70
|
+
if (!rows.length) return console.log('空')
|
|
71
|
+
const now = Date.now()
|
|
72
|
+
const show = all ? rows : rows.slice(-20)
|
|
73
|
+
if (!all && rows.length > 20) console.log(`(只给最近 20 条,共 ${rows.length} 条,要看全加 --all)`)
|
|
74
|
+
for (const r of show) {
|
|
75
|
+
const d = Math.floor((now - r.ts) / DAY)
|
|
76
|
+
console.log(`${r.id} ${d}d ${r.flag || '-'} ${r.text}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 待审批队列:模型/自动写入先落这里,等人点采纳
|
|
80
|
+
const PF = () => path.join(HOME, '_pending.json')
|
|
81
|
+
function readPending() {
|
|
82
|
+
try { const a = JSON.parse(fs.readFileSync(PF(), 'utf8')); return Array.isArray(a) ? a : [] } catch { return [] }
|
|
83
|
+
}
|
|
84
|
+
function writePending(a) {
|
|
85
|
+
fs.mkdirSync(HOME, { recursive: true })
|
|
86
|
+
fs.writeFileSync(PF(), JSON.stringify(a, null, 1))
|
|
87
|
+
}
|
|
88
|
+
function commit(k, it, preRows) {
|
|
89
|
+
const rows = preRows || readNotes(k)
|
|
90
|
+
rows.push({ id: it.id || shortId(), ts: it.ts || Date.now(), flag: it.flag || '', text: it.text })
|
|
91
|
+
writeNotes(k, rows)
|
|
92
|
+
return rows.length
|
|
93
|
+
}
|
|
94
|
+
function cmdAdd(args) {
|
|
95
|
+
const f = find(args[0])
|
|
96
|
+
if (!f) return console.log('无此分区:' + S.folders.map(x => x.k + x.name).join(' '))
|
|
97
|
+
if (!f.parent && S.folders.some(x => x.parent === f.k)) return console.log('主分区只放子分区,写到子分区里(如 ' + f.k + '>某子分区)')
|
|
98
|
+
const t = txt(args.slice(1).join(' '))
|
|
99
|
+
if (!t) return console.log('空内容不记')
|
|
100
|
+
if (len(t) > SOFTLEN) console.log(`偏长(${len(t)}字),能短点更好`)
|
|
101
|
+
const imp = /^\*/.test(t) ? '*' : ''
|
|
102
|
+
const clean = t.replace(/^\*/, '')
|
|
103
|
+
const p = readPending()
|
|
104
|
+
if (p.some(x => x.k === f.k && x.text === clean)) return console.log('已在待审里')
|
|
105
|
+
p.push({ id: shortId(), k: f.k, text: clean, flag: imp, ts: Date.now() })
|
|
106
|
+
writePending(p)
|
|
107
|
+
console.log('待审 ' + p.length)
|
|
108
|
+
}
|
|
109
|
+
function cmdEditMem(args) {
|
|
110
|
+
const f = find(args[0]); if (!f) return console.log('无此分区')
|
|
111
|
+
const rows = readNotes(f.k)
|
|
112
|
+
const r = rows.find(x => x.id === args[1]); if (!r) return console.log('无此条')
|
|
113
|
+
const t = txt(args.slice(2).join(' '))
|
|
114
|
+
if (!t) return console.log('空内容')
|
|
115
|
+
r.text = t
|
|
116
|
+
writeNotes(f.k, rows)
|
|
117
|
+
console.log('改好了')
|
|
118
|
+
}
|
|
119
|
+
// 时间线:一天一条,新的往当天那条后面并,不一条消息一条
|
|
120
|
+
function cmdTLog(args) {
|
|
121
|
+
const f = S.folders.find(x => x.k === 'T') || find('时间线')
|
|
122
|
+
if (!f) return console.log('没有时间线分区')
|
|
123
|
+
const line = txt(args.join(' '))
|
|
124
|
+
if (!line) return console.log('要说点啥')
|
|
125
|
+
const rows = readNotes(f.k)
|
|
126
|
+
const today = new Date().toDateString()
|
|
127
|
+
const i = rows.findIndex(r => new Date(r.ts).toDateString() === today)
|
|
128
|
+
if (i >= 0) {
|
|
129
|
+
const merged = rows[i].text + '|' + line
|
|
130
|
+
rows[i].text = merged.length > 300 ? rows[i].text : merged
|
|
131
|
+
rows[i].ts = Date.now()
|
|
132
|
+
writeNotes(f.k, rows)
|
|
133
|
+
return console.log('ok 并进今天那条')
|
|
134
|
+
}
|
|
135
|
+
writeNotes(f.k, rows.concat([{ id: shortId(), ts: Date.now(), flag: '', text: line }]))
|
|
136
|
+
console.log('ok 今天第一条')
|
|
137
|
+
}
|
|
138
|
+
function cmdSearch(args) {
|
|
139
|
+
const q = txt(args.join(' ')).toLowerCase()
|
|
140
|
+
if (!q) return console.log('给个关键词')
|
|
141
|
+
const hits = []
|
|
142
|
+
for (const f of S.folders) {
|
|
143
|
+
const tag = f.parent ? f.parent + '>' + f.name : f.k + f.name
|
|
144
|
+
for (const r of readNotes(f.k)) {
|
|
145
|
+
if ((r.text || '').toLowerCase().includes(q)) hits.push(tag + ' ' + r.id + ' ' + (r.flag ? r.flag + ' ' : '') + r.text)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const x of readPending()) {
|
|
149
|
+
if ((x.text || '').toLowerCase().includes(q)) hits.push('待审 ' + x.id + ' ' + x.text)
|
|
150
|
+
}
|
|
151
|
+
console.log(hits.length ? hits.join(String.fromCharCode(10)) : '没找到')
|
|
152
|
+
}
|
|
153
|
+
function cmdPending() {
|
|
154
|
+
const p = readPending()
|
|
155
|
+
if (!p.length) return console.log('空')
|
|
156
|
+
const now = Date.now()
|
|
157
|
+
for (const x of p) console.log(`${x.id} ${x.k}${x.flag ? ' *' : ''} ${Math.floor((now - x.ts) / 6e4)}m ${x.text}`)
|
|
158
|
+
}
|
|
159
|
+
function cmdApprove(args) {
|
|
160
|
+
const p = readPending()
|
|
161
|
+
const i = p.findIndex(x => x.id === args[0])
|
|
162
|
+
if (i < 0) return console.log('无此条')
|
|
163
|
+
const it = p[i]
|
|
164
|
+
commit(it.k, it)
|
|
165
|
+
p.splice(i, 1)
|
|
166
|
+
writePending(p)
|
|
167
|
+
console.log('采纳 ' + it.k + ' 剩' + p.length)
|
|
168
|
+
}
|
|
169
|
+
function cmdReject(args) {
|
|
170
|
+
const p = readPending()
|
|
171
|
+
const i = p.findIndex(x => x.id === args[0])
|
|
172
|
+
if (i < 0) return console.log('无此条')
|
|
173
|
+
p.splice(i, 1)
|
|
174
|
+
writePending(p)
|
|
175
|
+
console.log('驳回 剩' + p.length)
|
|
176
|
+
}
|
|
177
|
+
function cmdEdit(args) {
|
|
178
|
+
const p = readPending()
|
|
179
|
+
const i = p.findIndex(x => x.id === args[0])
|
|
180
|
+
if (i < 0) return console.log('无此条')
|
|
181
|
+
const t = txt(args.slice(1).join(' '))
|
|
182
|
+
if (!t) return console.log('空内容')
|
|
183
|
+
p[i].text = t
|
|
184
|
+
writePending(p)
|
|
185
|
+
console.log('改好了')
|
|
186
|
+
}
|
|
187
|
+
function cmdDone(args) {
|
|
188
|
+
const f = find(args[0]); if (!f) return console.log('无此分区')
|
|
189
|
+
const rows = readNotes(f.k)
|
|
190
|
+
const r = rows.find(x => x.id === args[1])
|
|
191
|
+
if (!r) return console.log('无此条')
|
|
192
|
+
r.flag = '✓'
|
|
193
|
+
writeNotes(f.k, rows)
|
|
194
|
+
console.log('done')
|
|
195
|
+
}
|
|
196
|
+
function trash(k, r, why) {
|
|
197
|
+
fs.mkdirSync(TRASH, { recursive: true })
|
|
198
|
+
const day = new Date().toISOString().slice(0, 10)
|
|
199
|
+
fs.appendFileSync(path.join(TRASH, day + '.md'), `${k}|${r.ts}|${r.flag || '-'}|${r.text}|${why}\n`)
|
|
200
|
+
}
|
|
201
|
+
function cmdRm(args) {
|
|
202
|
+
const f = find(args[0]); if (!f) return console.log('无此分区')
|
|
203
|
+
const rows = readNotes(f.k)
|
|
204
|
+
const i = rows.findIndex(x => x.id === args[1])
|
|
205
|
+
if (i < 0) return console.log('无此条')
|
|
206
|
+
if (f.guard && args[2] !== 'force') return console.log('该分区默认禁删,界面手动删要带 force')
|
|
207
|
+
trash(f.k, rows[i], 'manual')
|
|
208
|
+
rows.splice(i, 1)
|
|
209
|
+
writeNotes(f.k, rows)
|
|
210
|
+
console.log('rm ok')
|
|
211
|
+
}
|
|
212
|
+
const RULES = {
|
|
213
|
+
M: { keepAll: 1 }, // 主人、以及主人下面所有子分区:只由主人手删
|
|
214
|
+
P: { keepAll: 1, doneExpire: 30 }, // 约定:全留,完成的过 30 天才清
|
|
215
|
+
C: { oldKeep: 5 }, // 续缘:老的自然淡掉
|
|
216
|
+
R: { keepAll: 1 }, // 喜好:只由主人手删
|
|
217
|
+
T: { oldKeep: 10 }, // 时间线
|
|
218
|
+
X: { oldKeep: 10 }, // 自定义
|
|
219
|
+
}
|
|
220
|
+
function ruleOf(f) {
|
|
221
|
+
if (f.parent) return RULES[f.parent] || RULES.X
|
|
222
|
+
return RULES[f.k] || RULES[f.k[0]] || RULES.X
|
|
223
|
+
}
|
|
224
|
+
function pruneBackups(keep = KEEP_BACKUPS) {
|
|
225
|
+
if (!fs.existsSync(BACKUPS)) return
|
|
226
|
+
const dirs = fs.readdirSync(BACKUPS).filter(d => {
|
|
227
|
+
try { return fs.statSync(path.join(BACKUPS, d)).isDirectory() } catch { return false }
|
|
228
|
+
}).sort()
|
|
229
|
+
while (dirs.length > keep) fs.rmSync(path.join(BACKUPS, dirs.shift()), { recursive: true, force: true })
|
|
230
|
+
}
|
|
231
|
+
function snapshot(tag = 'gc') {
|
|
232
|
+
const stamp = new Date().toISOString().replace(/[:]/g, '-').slice(0, 19)
|
|
233
|
+
const name = tag + '-' + stamp
|
|
234
|
+
const dir = path.join(BACKUPS, name)
|
|
235
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
236
|
+
const counts = {}
|
|
237
|
+
for (const f of S.folders) {
|
|
238
|
+
counts[f.k] = readNotes(f.k).length
|
|
239
|
+
const src = fileOf(f.k)
|
|
240
|
+
if (!fs.existsSync(src)) continue
|
|
241
|
+
fs.mkdirSync(path.join(dir, f.k), { recursive: true })
|
|
242
|
+
fs.copyFileSync(src, path.join(dir, f.k, 'notes.md'))
|
|
243
|
+
}
|
|
244
|
+
fs.writeFileSync(path.join(dir, 'folders.json'), JSON.stringify(S, null, 1))
|
|
245
|
+
fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify({ name, tag, at: Date.now(), counts }, null, 1))
|
|
246
|
+
pruneBackups()
|
|
247
|
+
return name
|
|
248
|
+
}
|
|
249
|
+
function cmdBackups() {
|
|
250
|
+
if (!fs.existsSync(BACKUPS)) return console.log('还没有备份')
|
|
251
|
+
const dirs = fs.readdirSync(BACKUPS).sort().reverse()
|
|
252
|
+
if (!dirs.length) return console.log('还没有备份')
|
|
253
|
+
for (const d of dirs) {
|
|
254
|
+
let m = null
|
|
255
|
+
try { m = JSON.parse(fs.readFileSync(path.join(BACKUPS, d, 'meta.json'), 'utf8')) } catch {}
|
|
256
|
+
const total = m ? Object.values(m.counts || {}).reduce((a, b) => a + b, 0) : '?'
|
|
257
|
+
console.log(d + ' ' + total + ' 条 ' + (m ? new Date(m.at).toLocaleString('zh-CN') : ''))
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function cmdRestore(args) {
|
|
261
|
+
if (!fs.existsSync(BACKUPS)) return console.log('还没有备份')
|
|
262
|
+
const want = txt(args[0] || '')
|
|
263
|
+
const pick = want || (fs.readdirSync(BACKUPS).sort().reverse()[0] || '')
|
|
264
|
+
if (!pick) return console.log('还没有备份')
|
|
265
|
+
const dir = path.join(BACKUPS, pick)
|
|
266
|
+
if (!fs.existsSync(dir)) return console.log('没有这个备份: ' + pick)
|
|
267
|
+
let bk
|
|
268
|
+
try { bk = JSON.parse(fs.readFileSync(path.join(dir, 'folders.json'), 'utf8')) } catch { return console.log('这个备份坏了: ' + pick) }
|
|
269
|
+
const guard = snapshot('pre-restore') // 恢复前把当前状态也存一份,反悔还有退路
|
|
270
|
+
for (const f of S.folders) {
|
|
271
|
+
const p = fileOf(f.k)
|
|
272
|
+
if (fs.existsSync(p)) fs.rmSync(p)
|
|
273
|
+
}
|
|
274
|
+
fs.writeFileSync(NF(), JSON.stringify(bk, null, 1))
|
|
275
|
+
for (const f of (bk.folders || [])) {
|
|
276
|
+
const src = path.join(dir, f.k, 'notes.md')
|
|
277
|
+
if (!fs.existsSync(src)) continue
|
|
278
|
+
fs.mkdirSync(dirOf(f.k), { recursive: true })
|
|
279
|
+
fs.copyFileSync(src, fileOf(f.k))
|
|
280
|
+
}
|
|
281
|
+
console.log('restore ok ' + pick)
|
|
282
|
+
console.log('恢复前的状态也存了一份: ' + guard)
|
|
283
|
+
}
|
|
284
|
+
function cmdGc() {
|
|
285
|
+
const bk = snapshot('gc') // 先备份,再跑
|
|
286
|
+
const now = Date.now()
|
|
287
|
+
let gone = 0, kept = 0
|
|
288
|
+
for (const f of S.folders) {
|
|
289
|
+
const rows = readNotes(f.k)
|
|
290
|
+
const r = ruleOf(f)
|
|
291
|
+
const out = []
|
|
292
|
+
for (const n of rows) {
|
|
293
|
+
const age = (now - n.ts) / DAY
|
|
294
|
+
const imp = n.flag === '*'
|
|
295
|
+
if (r.keepAll && !(r.doneExpire && n.flag === '✓' && age > r.doneExpire)) { out.push(n); kept++; continue }
|
|
296
|
+
if (age > 30) { trash(f.k, n, 'expired'); gone++; continue }
|
|
297
|
+
if (age > 7) {
|
|
298
|
+
if (imp) { out.push(n); kept++; continue }
|
|
299
|
+
const recent = rows.filter(x => (now - x.ts) / DAY <= 7).length
|
|
300
|
+
if (out.length - recent >= (r.oldKeep || 8) - recent) { trash(f.k, n, 'compress'); gone++; continue }
|
|
301
|
+
out.push(n); kept++; continue
|
|
302
|
+
}
|
|
303
|
+
out.push(n); kept++
|
|
304
|
+
}
|
|
305
|
+
writeNotes(f.k, out)
|
|
306
|
+
}
|
|
307
|
+
if (!gone) {
|
|
308
|
+
fs.rmSync(path.join(BACKUPS, bk), { recursive: true, force: true })
|
|
309
|
+
console.log(`gc kept:${kept} trash:0`)
|
|
310
|
+
console.log('没有东西要清,这次不留备份')
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
console.log(`gc kept:${kept} trash:${gone}`)
|
|
314
|
+
console.log('备份: ' + bk)
|
|
315
|
+
console.log('不满意就还原: restore ' + bk)
|
|
316
|
+
}
|
|
317
|
+
function cmdMk(args) {
|
|
318
|
+
const name = txt(args[0] || '')
|
|
319
|
+
const parentArg = txt(args[1] || '')
|
|
320
|
+
if (!name) return console.log('用法: mk <名字> [父分区]')
|
|
321
|
+
let parent = null
|
|
322
|
+
if (parentArg) {
|
|
323
|
+
parent = find(parentArg)
|
|
324
|
+
if (!parent) return console.log('没有这个父分区')
|
|
325
|
+
if (parent.parent) return console.log('最多两级,子分区不能再开子分区')
|
|
326
|
+
}
|
|
327
|
+
const tops = S.folders.filter(f => !f.parent)
|
|
328
|
+
if (!parent && tops.length >= MAX_FOLDERS) return console.log('主分区已满' + MAX_FOLDERS + '个')
|
|
329
|
+
const sameLevel = S.folders.filter(f => (f.parent || '') === (parent ? parent.k : ''))
|
|
330
|
+
if (sameLevel.some(f => f.name === name)) return console.log('这一层已有同名')
|
|
331
|
+
let k
|
|
332
|
+
if (parent) k = parent.k + '-' + (S.folders.filter(f => f.parent === parent.k).length + 1)
|
|
333
|
+
else k = 'X' + tops.filter(f => f.k.startsWith('X')).length
|
|
334
|
+
S.folders.push(parent ? { k, name, parent: parent.k } : { k, name })
|
|
335
|
+
save(S); fs.mkdirSync(dirOf(k), { recursive: true })
|
|
336
|
+
console.log('ok ' + k + ' ' + (parent ? parent.name + ' / ' : '') + name)
|
|
337
|
+
}
|
|
338
|
+
function cmdInit() {
|
|
339
|
+
fs.mkdirSync(HOME, { recursive: true })
|
|
340
|
+
fs.mkdirSync(TRASH, { recursive: true })
|
|
341
|
+
for (const f of S.folders) { fs.mkdirSync(dirOf(f.k), { recursive: true }); if (!fs.existsSync(fileOf(f.k))) writeNotes(f.k, []) }
|
|
342
|
+
save(S)
|
|
343
|
+
console.log('init ok ' + HOME)
|
|
344
|
+
}
|
|
345
|
+
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) }
|
|
348
|
+
MAP[cmd](args)
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepseek-plastic-memory",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.1.4",
|
|
4
|
+
"description": "DeepSeek 可塑性记忆:给 DSH 用的分区式长期记忆,带遗忘曲线",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"plastic-memory": "./memtree.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"client.js",
|
|
18
|
+
"memtree.mjs",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"SKILL.md",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"dsh": {
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"platform": "web",
|
|
30
|
+
"inject": [
|
|
31
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"dsh",
|
|
38
|
+
"dsh-plugin",
|
|
39
|
+
"deepseek-harness",
|
|
40
|
+
"memory",
|
|
41
|
+
"agent",
|
|
42
|
+
"skill"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/Yixianqzm/deepseek-plastic-memory.git"
|
|
48
|
+
}
|
|
49
|
+
}
|