sage-mem 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -17,8 +17,15 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
17
17
  import { homedir } from 'node:os'
18
18
  import { readFile, readdir, writeFile, rename, unlink, stat } from 'node:fs/promises'
19
19
  import { join, basename } from 'node:path'
20
+ import { DreamEngine } from './dream.js'
20
21
 
21
22
  const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem', 'memory')
23
+ /**
24
+ * DSH 会话记录根目录。dream 的门控靠它数「上次整理之后有几个会话更新」,
25
+ * `memory+sessions` 输入源下也会在里面做定向搜索。取不到就是空串,
26
+ * 门控会退化成「会话数 0 → 不触发」,而不是报错。
27
+ */
28
+ const SESSIONS_ROOT = process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : ''
22
29
  const MAX_RESULTS = 5
23
30
  const MAX_CHARS_PER_FILE = 1500
24
31
  const MAX_BASELINE = 5
@@ -168,6 +175,33 @@ function selectRelevant(query, files) {
168
175
  .slice(0, MAX_RESULTS)
169
176
  }
170
177
 
178
+ /**
179
+ * 解析 frontmatter 的 `tags`(自定义标签,用于列表页筛选)。
180
+ *
181
+ * 三种写法都收 —— 手写记忆时这三种都会自然出现,只认一种就等于「标签时灵时不灵」:
182
+ * tags: [a, b] (YAML flow)
183
+ * tags:\n - a\n - b (YAML block)
184
+ * tags: a, b (松散的逗号串)
185
+ *
186
+ * 上限 20 个、每个 40 字符:标签是给人点着筛的,超过这个量级已经不是标签,
187
+ * 而是一句话被塞进了 tags 字段。
188
+ */
189
+ function parseTags(body) {
190
+ const clean = (arr) =>
191
+ arr
192
+ .map((s) => String(s).trim().replace(/^["']|["']$/g, '').trim())
193
+ .filter(Boolean)
194
+ .slice(0, 20)
195
+ .map((s) => s.slice(0, 40))
196
+ const flow = body.match(/^tags:[ \t]*\[([^\]]*)\]/m)
197
+ if (flow) return clean(flow[1].split(','))
198
+ const block = body.match(/^tags:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)+)/m)
199
+ if (block) return clean(block[1].split(/\r?\n/).map((l) => l.replace(/^[ \t]*-[ \t]*/, '')))
200
+ const inline = body.match(/^tags:[ \t]*(.+?)[ \t\r]*$/m)
201
+ if (inline && inline[1].trim()) return clean(inline[1].split(','))
202
+ return []
203
+ }
204
+
171
205
  /** 解析 frontmatter 的 name/description/type(兼容顶层与 metadata 嵌套两种 type)。 */
172
206
  function parseFrontmatter(content) {
173
207
  // 换行必须容忍 CRLF:任何用 Windows 编辑器碰过一次的文件都会被换成 \r\n,
@@ -175,7 +209,7 @@ function parseFrontmatter(content) {
175
209
  // 「baseline 走 parseBaseline 那条带 \r?\n 的正则照旧命中,但这条记忆永远检索不到」,
176
210
  // 症状极具欺骗性。:123 的 parseBaseline 与 :277 的 extractTitle 早已是 \r?\n。
177
211
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
178
- if (!m) return { name: '', description: '', type: '' }
212
+ if (!m) return { name: '', description: '', type: '', tags: [] }
179
213
  const body = m[1]
180
214
  const grab = (key) => {
181
215
  // [ \t] / [ \t\r] 而不是 \s:\s 会连 CRLF 的 \r 一起吃进捕获组,值尾留一个 \r
@@ -189,7 +223,7 @@ function parseFrontmatter(content) {
189
223
  const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
190
224
  if (nested) type = nested[1]
191
225
  }
192
- return { name: grab('name'), description: grab('description'), type }
226
+ return { name: grab('name'), description: grab('description'), type, tags: parseTags(body) }
193
227
  }
194
228
 
195
229
  /** 解析 frontmatter 的 `baseline` 标记:会话第一回合无条件注入。 */
@@ -290,6 +324,24 @@ export class MemoryGateway extends TypertRemoteService {
290
324
  // 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
291
325
  new StarmapGateway(ctx)
292
326
 
327
+ // dream(夜间记忆整理):引擎 + remote 面 + 自动触发的定时器。
328
+ //
329
+ // 整个包在 try 里:dream 起不来(比如 timer 服务缺失、状态目录没权限)
330
+ // 不该把 sage-mem 最核心的那件事——「记忆按问题注入」——一起拖死。
331
+ // 这是本插件唯一一处「降级而不是失败」的设计,理由就在这里。
332
+ try {
333
+ const engine = new DreamEngine(ctx, { memoryDir: MEMORY_DIR, sessionsRoot: SESSIONS_ROOT })
334
+ new DreamGateway(ctx, engine)
335
+ engine.start()
336
+ // 卸载时把定时器与锁收干净。effect 拿不到就交给 unref 兜底(不阻止进程退出)。
337
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => engine.dispose())
338
+ } catch (err) {
339
+ warnLog(ctx, `dream 初始化失败(记忆本体不受影响):${err?.message ?? err}`)
340
+ // 异常路径额外打一行到 stderr:warnLog 走 ctx.logger,某些启动阶段它还没接上,
341
+ // 而「dream 静默不工作」正是最难查的那种故障(2026-09-27 为此绕了一大圈)。
342
+ console.error('[sage-mem] dream init failed:', err && err.stack ? err.stack : err)
343
+ }
344
+
293
345
  // 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
294
346
  // 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
295
347
  const recallCache = new Map()
@@ -416,7 +468,7 @@ export class MemoryGateway extends TypertRemoteService {
416
468
  })
417
469
  }
418
470
 
419
- /** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小)。 */
471
+ /** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签)。 */
420
472
  async listFiles() {
421
473
  const files = await scanMemoryFiles(this.ctx, '')
422
474
  return files.map(f => {
@@ -426,6 +478,7 @@ export class MemoryGateway extends TypertRemoteService {
426
478
  type: meta.type || 'reference',
427
479
  description: meta.description || '',
428
480
  size: f.content.length,
481
+ tags: meta.tags,
429
482
  }
430
483
  })
431
484
  }
@@ -555,4 +608,71 @@ export class StarmapGateway extends TypertRemoteService {
555
608
  markRemote(StarmapGateway, 'listStars', 'listStars')
556
609
  markRemote(StarmapGateway, 'readFile', 'readFile')
557
610
 
611
+ /**
612
+ * dream 的 remote 面:设置页「梦境」标签页唯一的数据通道。
613
+ *
614
+ * 这一层刻意很薄——全部判断(门控、模式、白名单)都在 DreamEngine 里,
615
+ * 这里只负责把 ctx 里的引擎接到 typert 的调用表上。想看行为就读 dream.js。
616
+ */
617
+ export class DreamGateway extends TypertRemoteService {
618
+ constructor(ctx, engine) {
619
+ super(ctx, 'dream')
620
+ this.engine = engine
621
+ }
622
+
623
+ /** 配置 + 状态 + 路径 + 默认值(设置面板一次拿全)。 */
624
+ async getConfig() {
625
+ return this.engine.getConfig()
626
+ }
627
+
628
+ /** 改配置(补丁式)。 */
629
+ async setConfig(patch) {
630
+ return this.engine.setConfig(patch)
631
+ }
632
+
633
+ /** 轮询用:是否在跑、跑到哪一步、上次结果。 */
634
+ async status() {
635
+ return this.engine.status()
636
+ }
637
+
638
+ /**
639
+ * 手动跑一趟。设置页的「立即整理」走这里。
640
+ *
641
+ * force 默认为 true —— 人点了按钮,就不该再被「还没到 24 小时」挡回来。
642
+ *
643
+ * **故意不 await**:一趟 dream 可能跑几分钟(多轮模型调用 + 工具),
644
+ * remote 调用挂在那儿等会直接撞上前端超时,界面上表现为「点了没反应」。
645
+ * 所以这里只负责点火,进度与结果都从 status() 轮询 —— run() 自己会把
646
+ * lastResult 落盘,界面刷新几次就能看到。
647
+ */
648
+ async runNow(force) {
649
+ if (this.engine.running) return { ok: false, error: '已有一趟 dream 在跑' }
650
+ this.engine.run({ reason: '设置页手动触发', force: force !== false }).catch(() => {})
651
+ return { ok: true }
652
+ }
653
+
654
+ /** 历史报告列表。 */
655
+ async listReports() {
656
+ return this.engine.listReports()
657
+ }
658
+
659
+ /** 读一份报告全文。 */
660
+ async readReport(name) {
661
+ return this.engine.readReport(name)
662
+ }
663
+
664
+ /** 已有快照(面板显示「可回退到哪一份」)。 */
665
+ async listSnapshots() {
666
+ return this.engine.listSnapshots()
667
+ }
668
+ }
669
+
670
+ markRemote(DreamGateway, 'getConfig', 'getConfig')
671
+ markRemote(DreamGateway, 'setConfig', 'setConfig')
672
+ markRemote(DreamGateway, 'status', 'status')
673
+ markRemote(DreamGateway, 'runNow', 'runNow')
674
+ markRemote(DreamGateway, 'listReports', 'listReports')
675
+ markRemote(DreamGateway, 'readReport', 'readReport')
676
+ markRemote(DreamGateway, 'listSnapshots', 'listSnapshots')
677
+
558
678
  export default MemoryGateway
@@ -35,6 +35,10 @@ const fileSchema = z.object({
35
35
  'type': z.string().readonly(),
36
36
  'description': z.string().readonly(),
37
37
  'size': z.number().readonly(),
38
+ // 自定义标签:来自 frontmatter 的 tags(flow / block / 逗号三种写法都收)。
39
+ // strict 模式下 host 多返回的字段会被剥掉,所以「列表页要能按标签筛」这件事
40
+ // 必须在这里显式声明,否则前端永远拿到 undefined。
41
+ 'tags': z.array(z.string()).readonly(),
38
42
  }).readonly()
39
43
 
40
44
  const listResultSchema = z.array(fileSchema)
@@ -54,6 +58,117 @@ const deleteResultSchema = z.object({
54
58
  'error': z.string().readonly().optional(),
55
59
  }).readonly()
56
60
 
61
+ // ── dream(夜间记忆整理)───────────────────────────────────────────────
62
+ // 配置的每一项都可写;lastResult 是一个「成功/失败两条分支共用」的结构,
63
+ // 所以除了三个必有字段外全是 optional —— 用 strict 校验两种形态的并集,
64
+ // 比给成功与失败各来一套 schema 少一半维护面。
65
+ const dreamConfigSchema = z.object({
66
+ 'enabled': z.boolean().readonly(),
67
+ 'trigger': z.string().readonly(),
68
+ 'apply': z.boolean().readonly(),
69
+ 'source': z.string().readonly(),
70
+ 'minHours': z.number().readonly(),
71
+ 'minSessions': z.number().readonly(),
72
+ 'provider': z.string().readonly(),
73
+ 'model': z.string().readonly(),
74
+ 'maxSteps': z.number().readonly(),
75
+ 'maxSnapshotKeep': z.number().readonly(),
76
+ }).readonly()
77
+
78
+ const dreamPathsSchema = z.object({
79
+ 'memoryDir': z.string().readonly(),
80
+ 'configPath': z.string().readonly(),
81
+ 'stateRoot': z.string().readonly(),
82
+ }).readonly()
83
+
84
+ const dreamRouteSchema = z.object({
85
+ 'provider': z.string().readonly(),
86
+ 'model': z.string().readonly(),
87
+ 'fromDefault': z.boolean().readonly(),
88
+ }).readonly()
89
+
90
+ const dreamLastResultSchema = z.object({
91
+ 'at': z.number().readonly(),
92
+ 'atHuman': z.string().readonly(),
93
+ 'ok': z.boolean().readonly(),
94
+ 'apply': z.boolean().readonly().optional(),
95
+ 'error': z.string().readonly().optional(),
96
+ 'touched': z.array(z.string()).readonly().optional(),
97
+ 'problemsBefore': z.number().readonly().optional(),
98
+ 'problemsAfter': z.number().readonly().optional(),
99
+ 'tokensIn': z.number().readonly().optional(),
100
+ 'tokensOut': z.number().readonly().optional(),
101
+ 'report': z.string().readonly().optional(),
102
+ }).readonly()
103
+
104
+ const dreamConfigResultSchema = z.object({
105
+ 'config': dreamConfigSchema,
106
+ 'route': dreamRouteSchema.nullable(),
107
+ 'lastRunAt': z.number().readonly(),
108
+ 'lastResult': dreamLastResultSchema.nullable(),
109
+ 'paths': dreamPathsSchema,
110
+ 'defaults': dreamConfigSchema,
111
+ }).readonly()
112
+
113
+ const dreamStepSchema = z.object({
114
+ 'step': z.number().readonly(),
115
+ 'tools': z.array(z.string()).readonly(),
116
+ 'chars': z.number().readonly(),
117
+ 'at': z.number().readonly(),
118
+ }).readonly()
119
+
120
+ const dreamStatusSchema = z.object({
121
+ 'running': z.boolean().readonly(),
122
+ 'phase': z.string().readonly(),
123
+ 'startedAt': z.number().readonly(),
124
+ 'steps': z.array(dreamStepSchema).readonly(),
125
+ 'config': dreamConfigSchema,
126
+ 'route': dreamRouteSchema.nullable(),
127
+ 'lastRunAt': z.number().readonly(),
128
+ 'hoursSince': z.number().readonly().nullable(),
129
+ 'lastResult': dreamLastResultSchema.nullable(),
130
+ 'retryAfter': z.number().readonly(),
131
+ 'gate': z.object({ 'minHours': z.number().readonly(), 'minSessions': z.number().readonly() }).readonly().nullable(),
132
+ 'paths': dreamPathsSchema,
133
+ }).readonly()
134
+
135
+ const dreamRunResultSchema = z.object({
136
+ 'ok': z.boolean().readonly(),
137
+ 'error': z.string().readonly().optional(),
138
+ 'report': z.string().readonly().optional(),
139
+ 'touched': z.array(z.string()).readonly().optional(),
140
+ }).readonly()
141
+
142
+ // setConfig 的补丁:全部 optional —— 设置面板每次只改动一项,传整个对象反而
143
+ // 容易把没碰过的字段用旧值覆盖回去。
144
+ const dreamPatchSchema = z.object({
145
+ 'enabled': z.boolean().optional(),
146
+ 'trigger': z.string().optional(),
147
+ 'apply': z.boolean().optional(),
148
+ 'source': z.string().optional(),
149
+ 'minHours': z.number().optional(),
150
+ 'minSessions': z.number().optional(),
151
+ 'provider': z.string().optional(),
152
+ 'model': z.string().optional(),
153
+ 'maxSteps': z.number().optional(),
154
+ 'maxSnapshotKeep': z.number().optional(),
155
+ }).readonly()
156
+
157
+ const reportListSchema = z.array(z.object({
158
+ 'name': z.string().readonly(),
159
+ 'bytes': z.number().readonly(),
160
+ 'at': z.number().readonly(),
161
+ 'atHuman': z.string().readonly(),
162
+ }).readonly()).readonly()
163
+
164
+ const snapshotListSchema = z.array(z.object({
165
+ 'name': z.string().readonly(),
166
+ 'at': z.number().readonly(),
167
+ 'atHuman': z.string().readonly(),
168
+ 'files': z.number().readonly(),
169
+ 'path': z.string().readonly(),
170
+ }).readonly()).readonly()
171
+
57
172
  export const TYPERT = {
58
173
  package: 'sage-mem',
59
174
  face: 'host',
@@ -128,6 +243,87 @@ export const TYPERT = {
128
243
  result: { mode: 'strict', typeSymbol: 'sage-mem/types#StarmapRead', schema: starmapReadSchema, create: () => starmapReadSchema },
129
244
  sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
130
245
  },
246
+ // ── dream 的七个方法 ──
247
+ // 全部挂在 package `sage-mem` 下(和 memory 同一份 manifest),
248
+ // 所以前端直接 ctx.get('remote.dream') 就能用,不需要像 starmap 那样
249
+ // 在 client 侧再 $mount 一次 —— 那个 mount 是因为 starmap 当年是独立包
250
+ // (package: 'sage-starmap'),合并进 sage-mem 后一直没改回来。
251
+ {
252
+ id: 'sage-mem#dream/getConfig',
253
+ service: 'dream',
254
+ namespace: 'dream',
255
+ method: 'getConfig',
256
+ invocation: { kind: 'direct' },
257
+ parameters: [],
258
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#DreamConfigResult', schema: dreamConfigResultSchema, create: () => dreamConfigResultSchema },
259
+ sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
260
+ },
261
+ {
262
+ id: 'sage-mem#dream/setConfig',
263
+ service: 'dream',
264
+ namespace: 'dream',
265
+ method: 'setConfig',
266
+ invocation: { kind: 'direct' },
267
+ parameters: [
268
+ { name: 'patch', wire: 'patch', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#DreamPatch', schema: dreamPatchSchema, create: () => dreamPatchSchema } },
269
+ ],
270
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#DreamConfigResult', schema: dreamConfigResultSchema, create: () => dreamConfigResultSchema },
271
+ sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
272
+ },
273
+ {
274
+ id: 'sage-mem#dream/status',
275
+ service: 'dream',
276
+ namespace: 'dream',
277
+ method: 'status',
278
+ invocation: { kind: 'direct' },
279
+ parameters: [],
280
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#DreamStatus', schema: dreamStatusSchema, create: () => dreamStatusSchema },
281
+ sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
282
+ },
283
+ {
284
+ id: 'sage-mem#dream/runNow',
285
+ service: 'dream',
286
+ namespace: 'dream',
287
+ method: 'runNow',
288
+ invocation: { kind: 'direct' },
289
+ parameters: [
290
+ { name: 'force', wire: 'force', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#Boolean', schema: z.boolean(), create: () => z.boolean() } },
291
+ ],
292
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#DreamRunResult', schema: dreamRunResultSchema, create: () => dreamRunResultSchema },
293
+ sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
294
+ },
295
+ {
296
+ id: 'sage-mem#dream/listReports',
297
+ service: 'dream',
298
+ namespace: 'dream',
299
+ method: 'listReports',
300
+ invocation: { kind: 'direct' },
301
+ parameters: [],
302
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#DreamReportList', schema: reportListSchema, create: () => reportListSchema },
303
+ sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
304
+ },
305
+ {
306
+ id: 'sage-mem#dream/readReport',
307
+ service: 'dream',
308
+ namespace: 'dream',
309
+ method: 'readReport',
310
+ invocation: { kind: 'direct' },
311
+ parameters: [
312
+ { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
313
+ ],
314
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#FileContent', schema: readResultSchema, create: () => readResultSchema },
315
+ sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
316
+ },
317
+ {
318
+ id: 'sage-mem#dream/listSnapshots',
319
+ service: 'dream',
320
+ namespace: 'dream',
321
+ method: 'listSnapshots',
322
+ invocation: { kind: 'direct' },
323
+ parameters: [],
324
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#DreamSnapshotList', schema: snapshotListSchema, create: () => snapshotListSchema },
325
+ sourceLocation: { "file": "workspace/sage-mem/lib/index.js", "line": 1, "column": 1 },
326
+ },
131
327
  ],
132
328
  model: {
133
329
  "services": [],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
- "description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in transparent markdown files, with a memory star-map view",
4
- "version": "0.5.4",
3
+ "description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in transparent markdown files, with a memory star-map view, type/tag filtering, and an optional nightly dream pass that consolidates memories",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -11,9 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "files": [
14
- "lib/index.js",
15
- "lib/client.js",
16
- "lib/typert.host.js",
14
+ "lib/",
17
15
  "cordis.patch.yml",
18
16
  "NOTICE"
19
17
  ],
@@ -48,6 +46,9 @@
48
46
  "dependencies": {
49
47
  "zod": "^4.4.3"
50
48
  },
49
+ "devDependencies": {
50
+ "react": "^18.0.0"
51
+ },
51
52
  "peerDependencies": {
52
53
  "@deepseek-ai/cordis": "^4.0.2",
53
54
  "@deepseek-ai/dsh-typert-protocol": "^0.1.2-rc.1",
@@ -58,13 +59,25 @@
58
59
  "type": "git",
59
60
  "url": "git+https://github.com/gezi-wen/sage-mem.git"
60
61
  },
62
+ "homepage": "https://github.com/gezi-wen/sage-mem#readme",
63
+ "bugs": {
64
+ "url": "https://github.com/gezi-wen/sage-mem/issues"
65
+ },
61
66
  "keywords": [
62
67
  "memory",
63
68
  "ai-memory",
69
+ "agent-memory",
64
70
  "long-term-memory",
71
+ "cross-session-memory",
72
+ "context-management",
73
+ "knowledge-management",
65
74
  "markdown",
66
75
  "dsh",
76
+ "dsh-plugin",
77
+ "deepseek",
67
78
  "deepseek-harness",
79
+ "cordis",
80
+ "typert",
68
81
  "plugin",
69
82
  "ai-agent",
70
83
  "agent",