sage-mem 1.5.3 → 1.6.1

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
@@ -80,6 +80,15 @@ Most memory schemes take the "SQLite + a long-running worker" route: once memori
80
80
 
81
81
  The UI also carries four more capabilities (all of them optional): the memory management page, type and tag filtering, the memory star map, and audit & archiving.
82
82
 
83
+ The memory management page's file list also has an **Open memory folder** button: it opens the
84
+ memory directory in the system file manager (Explorer / Finder / the Linux file manager) through a
85
+ detached host-side process and returns without waiting for the window.
86
+
87
+ While the memory directory is empty, the empty state carries a **Create index skeleton** button: one
88
+ click writes a starter `MEMORY.md` (index format plus the four memory categories). When `MEMORY.md`
89
+ already exists it only says so and changes nothing; it uses the existing reserved-file read/write
90
+ calls rather than a new remote.
91
+
83
92
  ## Quick Start
84
93
 
85
94
  Requires **DSH `0.1.2-rc.1` or higher** (see [Compatibility](#compatibility)) and **Node.js `>= 18`**.
package/README.zh.md CHANGED
@@ -82,6 +82,13 @@ sage-mem 补上这一层,做法是:**把该记住的事写成一张张卡片
82
82
 
83
83
  界面上还有四块能力(都可以不用):记忆管理页、类型与标签筛选、记忆星图、体检与归档。
84
84
 
85
+ 记忆管理页的「文件列表」头部还有一个「打开记忆文件夹」按钮:用系统文件管理器打开当前记忆目录
86
+ (Windows Explorer / macOS Finder / Linux 文件管理器)。host 侧起一个 detached 进程就返回,不等窗口。
87
+
88
+ 记忆目录还是空的时候,空态里有一个「建立索引骨架」按钮:一键写入 `MEMORY.md` 的引导文件
89
+ (索引格式 + 四类记忆说明)。**已经存在 `MEMORY.md` 时只提示、不覆盖**;它不走新的 remote,
90
+ 用的是原有的保留名读取 / 写入接口。
91
+
85
92
  ## 快速开始
86
93
 
87
94
  需要 **DSH `0.1.2-rc.1` 或更高**(见[兼容性](#兼容性))、**Node.js `>= 18`**。
package/lib/client.js CHANGED
@@ -1727,6 +1727,16 @@ const StarmapRuntime = (function (require, _react) {
1727
1727
  result: { mode: "strict", typeSymbol: "sage-mem/types#AuditReport", schema: objCodec, create: () => objCodec },
1728
1728
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1729
1729
  },
1730
+ {
1731
+ id: "sage-mem#memory/openMemoryDir",
1732
+ service: "memory",
1733
+ namespace: "memory",
1734
+ method: "openMemoryDir",
1735
+ invocation: { kind: "direct" },
1736
+ parameters: [],
1737
+ result: { mode: "strict", typeSymbol: "sage-mem/types#OpenFolderResult", schema: objCodec, create: () => objCodec },
1738
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1739
+ },
1730
1740
  {
1731
1741
  id: "sage-mem#memory/checkUpdate",
1732
1742
  service: "memory",
@@ -2174,7 +2184,7 @@ const StarmapRuntime = (function (require, _react) {
2174
2184
  ".smem-empty{display:flex;flex-direction:column;align-items:flex-start;gap:8px;}",
2175
2185
  ".smem-tabs-sep{width:1px;align-self:stretch;margin:6px 2px;background:var(--dsw-alias-border-l2);}",
2176
2186
  // 窄窗口(≤420px):头部带与筛选条各让一行,卡片动作允许换行、分隔符隐藏。
2177
- "@media (max-width:420px){.smem-root{gap:10px;}.smem-head{gap:6px;}.smem-card-title{min-width:0;word-break:break-word;}.smem-actions{flex-wrap:wrap;}.smem-actions-sep{display:none;}.smem-num{width:56px;}.smem-search{min-width:100px;}}",
2187
+ "@media (max-width:420px){.smem-root{gap:10px;}.smem-head{gap:6px;}.smem-head-actions{flex-wrap:wrap;}.smem-card-title{min-width:0;word-break:break-word;}.smem-actions{flex-wrap:wrap;}.smem-actions-sep{display:none;}.smem-num{width:56px;}.smem-search{min-width:100px;}}",
2178
2188
  // ── 底部一行仓库链接(引流,不抢视线)──
2179
2189
  ".smem-star{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-tertiary,#8b93a1);padding:2px 2px 0;}",
2180
2190
  ".smem-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
@@ -3745,7 +3755,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3745
3755
  // 只负责「按状态/筛选把卡片摆出来 + 四种空态」;状态、筛选语义、动作回调全部由 Section 传。
3746
3756
  // 抽出的判据:它是 Section 里最大的一块纯渲染,而卡片组件(Card / ArchivedCard)本来就在 50 共享层。
3747
3757
  function FilesView(props) {
3748
- const { loading, totalCount, openAdd, statusFilter, setStatusFilter, items, archiveItems, filtered, setTypeFilter, setTagFilter, setQuery, busy, restoring, archDelete, archEdit, archLoad, archSaving, archText, setArchText, openArchEdit, saveArchEdit, cancelArchEdit, restoreFile, setArchDelete, setArchEdit, deleteArchivedFile, expanded, deleting, archiving, archiveReason, setArchiveReason, setArchiving, setDeleting, confirmArchive, toggleLock, openEdit, confirmDelete } = props;
3758
+ const { loading, totalCount, openAdd, createIndexSkeleton, statusFilter, setStatusFilter, items, archiveItems, filtered, setTypeFilter, setTagFilter, setQuery, busy, restoring, archDelete, archEdit, archLoad, archSaving, archText, setArchText, openArchEdit, saveArchEdit, cancelArchEdit, restoreFile, setArchDelete, setArchEdit, deleteArchivedFile, expanded, deleting, archiving, archiveReason, setArchiveReason, setArchiving, setDeleting, confirmArchive, toggleLock, openEdit, confirmDelete } = props;
3749
3759
  let filesView;
3750
3760
  if (loading && totalCount === 0) {
3751
3761
  filesView = h("div", { className: "smem-skeleton", key: "loading", "data-loading": "1" }, [0, 1, 2].map(function (i) {
@@ -3755,6 +3765,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3755
3765
  filesView = h("div", { className: "smem-empty", key: "empty" }, [
3756
3766
  h("div", { key: "t" }, "还没有记忆文件。对话中值得记住的事会写到这里,也可以手动写入。"),
3757
3767
  h("button", { type: "button", className: "smem-btn smem-btn-primary", key: "add", onClick: openAdd }, "+ 添加记忆"),
3768
+ h("button", { type: "button", className: "smem-btn", key: "idx", title: "写入一份 MEMORY.md 索引骨架(已有索引不会覆盖)", onClick: createIndexSkeleton }, "建立索引骨架"),
3758
3769
  ]);
3759
3770
  } else if (statusFilter === "archived" && archiveItems.length === 0) {
3760
3771
  filesView = h("div", { className: "smem-empty", key: "empty" }, [
@@ -4116,6 +4127,67 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4116
4127
  setFormFm(null); setFormBaseType(""); setFormTags("");
4117
4128
  }
4118
4129
 
4130
+ // 空记忆库时「建立索引骨架」写进去的引导文件。它只是给人看的起点:
4131
+ // 索引本来就是手写维护的,这里不猜用户有什么记忆、也不生成假条目。
4132
+ const INDEX_SKELETON = [
4133
+ "# 记忆索引",
4134
+ "",
4135
+ "> 这是 sage-mem 的记忆索引骨架:每条记忆一行,链接到同目录下的一个 Markdown 文件。",
4136
+ "> 索引由人(或 agent)手写维护,不是自动生成的;改写前建议先备份。",
4137
+ "",
4138
+ "## 活动记忆",
4139
+ "",
4140
+ "<!-- 一条记忆一行:「- [标题](文件名.md) — 一句话描述」 -->",
4141
+ "",
4142
+ "## 四类记忆",
4143
+ "",
4144
+ "- `user_*`:关于用户的事实与偏好",
4145
+ "- `feedback_*`:用户给的工作方式指导",
4146
+ "- `project_*`:进行中的工作与状态",
4147
+ "- `reference_*`:外部信息在哪找",
4148
+ "",
4149
+ ].join("\n");
4150
+
4151
+ function createIndexSkeleton() {
4152
+ setError(null);
4153
+ setNotice("");
4154
+ if (!remote || typeof remote.readRaw !== "function" || typeof remote.writeRaw !== "function") {
4155
+ setError("宿主侧还没有 readRaw / writeRaw 接口(插件版本较旧)");
4156
+ return;
4157
+ }
4158
+ setBusy(true);
4159
+ // 先读再写:**已有索引绝不覆盖**。readRaw 对不存在的文件会 reject,正好当探针。
4160
+ remote.readRaw("MEMORY.md").then(function () {
4161
+ setBusy(false);
4162
+ setNotice("MEMORY.md 已经存在,没有改动它。");
4163
+ }, function () {
4164
+ return remote.writeRaw("MEMORY.md", INDEX_SKELETON).then(function (res) {
4165
+ const out = unwrap(res);
4166
+ setBusy(false);
4167
+ if (out && out.ok === false) { setError(out.error || "建立索引骨架失败"); return; }
4168
+ setNotice("已建立 MEMORY.md 索引骨架。");
4169
+ load();
4170
+ });
4171
+ }).catch(function (e) {
4172
+ setBusy(false);
4173
+ setError(String((e && e.message) || e));
4174
+ });
4175
+ }
4176
+
4177
+ function openMemoryFolder() {
4178
+ setError(null);
4179
+ if (!remote || typeof remote.openMemoryDir !== "function") {
4180
+ setError("宿主侧还没有 openMemoryDir 接口(插件版本较旧)");
4181
+ return;
4182
+ }
4183
+ remote.openMemoryDir()
4184
+ .then(function (res) {
4185
+ const result = unwrap(res);
4186
+ if (result && result.ok === false) setError(result.error || "打开记忆目录失败");
4187
+ })
4188
+ .catch(function (e) { setError(String((e && e.message) || e)); });
4189
+ }
4190
+
4119
4191
  function openEdit(file) {
4120
4192
  setError(null);
4121
4193
  setBusy(true);
@@ -4573,7 +4645,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4573
4645
  // 「文件列表」这一屏:状态筛选决定看哪一批,灰色卡片与留痕块自带「退役」语义。
4574
4646
  // banner 只在**专门看归档**时出现,切到全部时不重复说一遍。
4575
4647
  const filesView = h(FilesView, {
4576
- loading: loading, totalCount: totalCount, openAdd: openAdd,
4648
+ loading: loading, totalCount: totalCount, openAdd: openAdd, createIndexSkeleton: createIndexSkeleton,
4577
4649
  statusFilter: statusFilter, setStatusFilter: setStatusFilter,
4578
4650
  items: items, archiveItems: archiveItems, filtered: filtered,
4579
4651
  setTypeFilter: setTypeFilter, setTagFilter: setTagFilter, setQuery: setQuery,
@@ -4632,6 +4704,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4632
4704
  // 也推到最右,看起来像"跑出格子了")。
4633
4705
  h("span", { className: "smem-head-spacer", key: "sp" }),
4634
4706
  h("span", { className: "smem-head-actions", key: "act" }, tab === "files" ? [
4707
+ h("button", { type: "button", className: "smem-btn", key: "open", disabled: busy || !dirOk, title: "在系统文件管理器里打开记忆目录", onClick: openMemoryFolder }, "打开记忆文件夹"),
4635
4708
  h("button", { type: "button", className: "smem-btn", key: "refresh", disabled: busy || loading, onClick: load }, "刷新"),
4636
4709
  h("button", { type: "button", className: "smem-btn smem-btn-primary", key: "add", onClick: openAdd }, "+ 添加记忆"),
4637
4710
  ] : null),
package/lib/index.js CHANGED
@@ -27,6 +27,7 @@ import { archiveMemory, deleteArchivedMemory, restoreMemory } from './memory/arc
27
27
  import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
28
28
  import { warnLog, writeFileAtomic } from './memory/util.js'
29
29
  import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
30
+ import { openMemoryFolder } from './memory/open-folder.js'
30
31
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
31
32
  import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
32
33
  import { join } from 'node:path'
@@ -360,6 +361,16 @@ export class MemoryGateway extends TypertRemoteService {
360
361
  return auditMemoryDir(MEMORY_DIR)
361
362
  }
362
363
 
364
+ /**
365
+ * 用系统文件管理器打开记忆目录(设置页「打开记忆文件夹」)。
366
+ *
367
+ * host 侧起一个 detached 进程就返回,不等待窗口出现;路径是插件自己的记忆目录,
368
+ * 不接受界面传入,避免把「打开任意路径」变成一个能力。
369
+ */
370
+ async openMemoryDir() {
371
+ return openMemoryFolder(MEMORY_DIR)
372
+ }
373
+
363
374
  /**
364
375
  * 自动归档候选 + 当前策略与阈值(面板「执行归档」前先看清单)。
365
376
  *
@@ -558,6 +569,7 @@ markRemote(MemoryGateway, 'readArchived', 'readArchived')
558
569
  markRemote(MemoryGateway, 'writeArchived', 'writeArchived')
559
570
  markRemote(MemoryGateway, 'deleteArchived', 'deleteArchived')
560
571
  markRemote(MemoryGateway, 'audit', 'audit')
572
+ markRemote(MemoryGateway, 'openMemoryDir', 'openMemoryDir')
561
573
  markRemote(MemoryGateway, 'checkUpdate', 'checkUpdate')
562
574
  markRemote(MemoryGateway, 'setLocked', 'setLocked')
563
575
  markRemote(MemoryGateway, 'archiveCandidates', 'archiveCandidates')
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 打开记忆目录(设置页「打开记忆文件夹」按钮的 host 侧实现)。
3
+ *
4
+ * 为什么放在 host 侧:浏览器不能起进程;而 DSH 的 `open-in-app` 路由属于界面内部接口,
5
+ * 老版本 DSH 不一定有。这里只依赖 `node:child_process`,三条命令覆盖三个平台:
6
+ * - Windows → `explorer.exe <dir>`
7
+ * - macOS → `open <dir>`
8
+ * - 其它 → `xdg-open <dir>`
9
+ *
10
+ * 进程是**一次性、detached** 的:不等待退出(Explorer / Finder 是常驻进程,等它等于
11
+ * 永不返回),也不持有 stdio。这条路径不改变插件「无 worker / 无端口 / 无常驻进程」的性质。
12
+ *
13
+ * 返回 `{ ok, error? }`,不抛:设置页按 ok 显示错误。`spawn` 的 'error' 事件是异步的,
14
+ * 调用方拿不到,所以这里只挂一个空处理器,避免「命令不存在」变成未捕获异常 ——
15
+ * 返回值表达的是「已发起」,不是「窗口已经出现」。
16
+ */
17
+ import { spawn } from 'node:child_process'
18
+
19
+ /**
20
+ * 平台 → 打开目录的命令与参数(纯函数,测试直接钉它)。
21
+ * @param {string} platform — `process.platform` 的取值
22
+ * @param {string} dir — 绝对路径
23
+ * @returns {{command: string, args: string[]}}
24
+ */
25
+ export function folderOpenCommand(platform, dir) {
26
+ if (typeof dir !== 'string' || dir.length === 0) throw new Error('sage-mem: memory directory is unknown')
27
+ if (platform === 'win32') return { command: 'explorer.exe', args: [dir] }
28
+ if (platform === 'darwin') return { command: 'open', args: [dir] }
29
+ return { command: 'xdg-open', args: [dir] }
30
+ }
31
+
32
+ /**
33
+ * 发起「打开目录」,立即返回。
34
+ * @param {string} dir — 记忆目录绝对路径
35
+ * @param {{platform?: string, spawn?: Function}} [options] — 测试注入点
36
+ * @returns {{ok: boolean, error?: string}}
37
+ */
38
+ export function openMemoryFolder(dir, options = {}) {
39
+ const platform = options.platform || process.platform
40
+ const spawnImpl = options.spawn || spawn
41
+
42
+ let plan
43
+ try {
44
+ plan = folderOpenCommand(platform, dir)
45
+ } catch (error) {
46
+ return { ok: false, error: String(error?.message ?? error) }
47
+ }
48
+
49
+ try {
50
+ const child = spawnImpl(plan.command, plan.args, { detached: true, stdio: 'ignore', windowsHide: true })
51
+ child.on?.('error', () => {})
52
+ child.unref?.()
53
+ return { ok: true }
54
+ } catch (error) {
55
+ return { ok: false, error: `sage-mem: cannot open ${dir}: ${error?.message ?? error}` }
56
+ }
57
+ }
@@ -181,6 +181,12 @@ const auditReportSchema = z.object({
181
181
  'problems': z.number().readonly(),
182
182
  }).readonly()
183
183
 
184
+ /** 「打开记忆目录」的结果:成功只有 ok,失败带原因。 */
185
+ const openFolderResultSchema = z.object({
186
+ 'ok': z.boolean().readonly(),
187
+ 'error': z.string().readonly().optional(),
188
+ }).readonly()
189
+
184
190
  const archiveCandidateSchema = z.object({
185
191
  'file': z.string().readonly(),
186
192
  'type': z.string().readonly(),
@@ -616,6 +622,16 @@ export const TYPERT = {
616
622
  result: { mode: 'strict', typeSymbol: 'sage-mem/types#AuditReport', schema: auditReportSchema, create: () => auditReportSchema },
617
623
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
618
624
  },
625
+ {
626
+ id: 'sage-mem#memory/openMemoryDir',
627
+ service: 'memory',
628
+ namespace: 'memory',
629
+ method: 'openMemoryDir',
630
+ invocation: { kind: 'direct' },
631
+ parameters: [],
632
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#OpenFolderResult', schema: openFolderResultSchema, create: () => openFolderResultSchema },
633
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
634
+ },
619
635
  {
620
636
  // 面板进入时静默查一次;只有「有新版本」才在界面上提醒。
621
637
  id: 'sage-mem#memory/checkUpdate',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "1.5.3",
4
+ "version": "1.6.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {