@y0shua1ee/dsh-auto-memory 0.3.0 → 0.3.3

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
@@ -53,14 +53,15 @@ metadata:
53
53
 
54
54
  - `metadata.type` 取 `user`(用户是谁)、`feedback`(对工作方式的纠正或认可)、`project`(仓库里没记录的进行中工作、目标、约束)、`reference`(外部资源指针)之一;也接受顶层 `type:`。
55
55
  - `feedback` 和 `project` 在正文后跟 **Why:** / **How to apply:** 两行,便于模型在边界情况自行判断。
56
- - `[[name]]` 指向 `/memories/<name>.md`,所以文件名应与 `name` 一致(kebab-case,与 DSH skill 名规则相同)。链接到尚不存在的记忆是允许的,表示“值得以后补写”。
57
- - frontmatter 不是强制的:缺字段时 `create` 照常写入,只在工具输出里列出缺什么。frontmatter 只解析扁平的 `key: value` 和一层 `metadata:`,不是完整 YAML。
56
+ - `[[name]]` 指向顶层 `/memories/<name>.md`,所以只有顶层文件的 `name` 能直接解析。子目录里的记忆(如 `sub/foo.md`)即使 `name: foo` 也会提示:`[[foo]]` 指向的是 `/memories/foo.md`,不是 `sub/foo.md`;需要互链时请把记忆放在顶层,或自己在正文里写完整路径。链接到尚不存在的记忆仍然允许,表示“值得以后补写”。
57
+ - frontmatter 不是强制的:缺字段、`name` 与文件名不一致、文件在子目录时照常写入,只在工具输出里列出问题。`create` 列出全部问题;`str_replace` / `insert` / `rename` 只列出这次改动新引入的问题**类型**,所以把一个非法值换成另一个非法值不会重复报。`name` 不是合法 kebab-case 时索引标题退回文件名;v0.3.0 遗留的非法标题(内容里的 `name` 仍是那个非法值时)会在下一次编辑或 rename 时按 legacy 标题识别并归一化。frontmatter 只解析扁平的 `key: value` 和一层 `metadata:`,不是完整 YAML。
58
+ - 记忆文件名允许空格等空白字符;索引链接会写成 `[标题](<my note.md>)`,维护逻辑按行首链接的 target 精确匹配。说明或标题里的其他链接、`- [ ]` 任务列表不会被当成索引项。
58
59
 
59
60
  项目目录名与 DSH 的 session 目录规则一致(`--` + 路径转义 + `--`),所以 `~/.dsh/sessions/` 和 `~/.dsh/auto-memory/` 可以对照着看。
60
61
 
61
62
  ## 安装到 DSH profile
62
63
 
63
- 本包是一个 **DSH bundle**:`package.json` 声明 `dsh.bundle.patch`,安装后自动加入 profile 的 `dsh.profile.bundles`,由包内的 `cordis.patch.yml` 挂载插件行,不需要再手写 `insert`。目标版本为 DSH `0.2.1-rc.3`。
64
+ 本包是一个 **DSH bundle**:`package.json` 声明 `dsh.bundle.patch`,安装后自动加入 profile 的 `dsh.profile.bundles`,由包内的 `cordis.patch.yml` 挂载插件行,不需要再手写 `insert`。目标版本为 DSH `0.2.1-rc.3` 及以上(已测到 `0.2.1-rc.5`);peer 范围是 `^0.2.1-rc.3`。
64
65
 
65
66
  ### 方式 A:Plugins 页面(推荐)
66
67
 
@@ -125,7 +126,7 @@ pnpm install --prefer-offline
125
126
 
126
127
  ### 生效与验证
127
128
 
128
- - 插件代码通过 `link:` 加载;**更新代码后需要重启 DeepSeek Harness**,配置文件变更在 `patchReload: live` 的 profile 中可以热重组。
129
+ - 插件代码在启动时加载:npm 安装的要先更新 profile 里的版本,`link:` 安装的直接读本地目录;两种情况**更新代码后都要重启 DeepSeek Harness**。配置文件变更在 `patchReload: live` 的 profile 中可以热重组。
129
130
  - 重启后在 **插件 → 已安装插件** 里应看到 `@y0shua1ee/dsh-auto-memory`,开关为开启状态。
130
131
  - 在会话里让 agent 调一次 `memory view /memories`,应返回空目录提示。
131
132
  - 让 agent 记住一条内容,检查 `~/.dsh/auto-memory/<projectKey>/MEMORY.md` 是否出现。
@@ -162,7 +163,7 @@ pnpm install --prefer-offline
162
163
  | `str_replace` | `path`、`old_str`、`new_str` | `old_str` 必须恰好出现一次 |
163
164
  | `insert` | `path`、`insert_line`、`insert_text` | `insert_line` 从 0 开始,0 表示插到第一行之前 |
164
165
  | `delete` | `path` | 删除文件,并清掉索引里的对应行 |
165
- | `rename` | `path`、`new_path` | 重命名,并同步索引链接;不允许改名 `MEMORY.md` |
166
+ | `rename` | `path`、`new_path` | 重命名,并同步索引链接;仍由旧文件名生成的标题随之更新;不允许改名 `MEMORY.md` |
166
167
 
167
168
  示例:
168
169
 
@@ -176,7 +177,7 @@ pnpm install --prefer-offline
176
177
  - [prefer-pnpm](prefer-pnpm.md) — 这个仓库只用 pnpm,不用 npm
177
178
  ```
178
179
 
179
- 之后用 `str_replace` / `insert` 改了 `name` 或 `description`,索引行会跟着更新;但若这一行的标题或说明已被手改(不再等于旧值),那一部分保持不动。
180
+ 之后用 `str_replace` / `insert` 改了 `name` 或 `description`,索引行会跟着更新;但若这一行的标题或说明已被手改(不再等于旧值),那一部分保持不动。索引维护只解析行首的 `[标题](文件)` 链接,target 精确匹配;含空白的 target 写成 `<...>`,标题里的嵌套链接按括号配对处理,任务列表会被忽略。CRLF 索引在 create / edit / delete / rename 下都保留原有行尾。
180
181
 
181
182
  ## Rules(兼容 Claude Code)
182
183
 
@@ -251,7 +252,7 @@ paths:
251
252
 
252
253
  ```bash
253
254
  cd dsh-auto-memory
254
- node --test test/store.test.mjs test/render-paths.test.mjs test/rules.test.mjs
255
+ node --test test/store.test.mjs test/memory-frontmatter.test.mjs test/render-paths.test.mjs test/rules.test.mjs
255
256
  ```
256
257
 
257
258
  插件装配与注入的集成测试需要一份 DSH 运行时。先解出安装包(默认写到 `/tmp/dsh-app`,原生模块会从 `app.asar.unpacked` 合并):
@@ -271,19 +272,16 @@ DSH_APP_NODE_MODULES=/tmp/dsh-app/dsh/node_modules \
271
272
  - 没有后台 LLM 提炼:记忆完全由模型在会话中主动写入,和 Claude Code 的 auto memory 一致。
272
273
  - Rules 只实现 `paths` 一个 frontmatter 字段,没有完整 YAML、`@import`、托管策略或 `/rules` 面板。
273
274
  - `paths` 目前只由 `read` / `write` / `edit` 触发;用 `bash cat/head` 读文件不会触发。
274
- - 不是 bundle:不会出现在 Web 的 Plugins 市场页,只能按上面的 row 方式挂载。
275
275
  - 一个 profile 一个 root;跨项目共享要自己写 `AGENTS.md` 或另开配置。
276
- - 依赖 DSH `0.2.1-rc.3` 的内部接口(`agent/pre-step`、`agent.inbox`、`defineTool`、`systemPrompt.section`)。DSH 升级后若接口变动,需要跟着改。
276
+ - 依赖 DSH `0.2.1-rc.3` 到 `0.2.1-rc.5` 的内部接口(`agent/pre-step`、`agent.inbox`、`defineTool`、`systemPrompt.section`);peer 范围是 `^0.2.1-rc.3`。DSH 升级后若接口变动,需要跟着改。
277
277
  - `MEMORY.md` 超过注入预算时只注入前 N 行/字节;完整内容要用 `memory view`。
278
278
 
279
279
  ## 卸载
280
280
 
281
- 1. 从 `cordis.patch.yml` 删掉 `auto-memory` 的 insert 行。
282
- 2. 从 profile 的 `package.json` 删掉 `@y0shua1ee/dsh-auto-memory` 依赖,执行 `pnpm install`。
281
+ 1. 在 **插件 → 已安装插件** 里移除 `@y0shua1ee/dsh-auto-memory`;或手动从 profile 的 `package.json` 删掉该依赖和 `dsh.profile.bundles` 里的同名条目,再执行 `pnpm install`。
282
+ 2. 如果在 profile 的 `cordis.patch.yml` 里覆盖过 `dsh-auto-memory` 的配置,一并删掉。
283
283
  3. 可选:删除 `~/.dsh/auto-memory/`(记忆文件不会自动删除)。
284
284
 
285
- 代码目录本身可以保留,删除 profile 里的引用后不会加载。
286
-
287
285
  ## 设计来源
288
286
 
289
287
  - Claude Code 的 auto memory:`MEMORY.md` 索引 + 一条一文件 + `name` / `description` / `type` frontmatter + `[[name]]` 互链 + 模型主动写。
@@ -75,21 +75,67 @@ export function parseMemory(content) {
75
75
  return result;
76
76
  }
77
77
 
78
+ /**
79
+ * Describe each frontmatter problem as a stable code plus a readable message.
80
+ *
81
+ * Codes stay stable when only the offending value changes, so callers can tell
82
+ * "the same problem still exists" from "this edit introduced a new problem".
83
+ *
84
+ * @param {ReturnType<typeof parseMemory>} parsed - parsed memory file.
85
+ * @param {string} [fileStem] - file name without `.md`; `[[name]]` resolves to it.
86
+ * @param {string} [filePath] - project-relative path, used to detect subdirectories.
87
+ * @returns {Array<{code: string, message: string}>} problems; empty when well-formed.
88
+ */
89
+ export function memoryFrontmatterIssues(parsed, fileStem, filePath) {
90
+ if (!parsed.frontmatter) {
91
+ return [{ code: "frontmatter-missing", message: "no frontmatter (name, description, metadata.type)" }];
92
+ }
93
+
94
+ const issues = [];
95
+ if (parsed.name === undefined) {
96
+ issues.push({ code: "name-missing", message: "missing name" });
97
+ } else if (!MEMORY_NAME.test(parsed.name)) {
98
+ issues.push({
99
+ code: "name-invalid",
100
+ message: `name ${JSON.stringify(parsed.name)} is not kebab-case`
101
+ });
102
+ } else if (fileStem !== undefined && parsed.name !== fileStem) {
103
+ const file = filePath ?? `${fileStem}.md`;
104
+ issues.push({
105
+ code: "name-unmatched",
106
+ message: `name ${JSON.stringify(parsed.name)} does not match the file name ${file}, so [[${parsed.name}]] will not resolve`
107
+ });
108
+ } else if (filePath !== undefined && filePath.includes("/")) {
109
+ issues.push({
110
+ code: "name-nested",
111
+ message:
112
+ `name ${JSON.stringify(parsed.name)} matches the file name, but the file is in a subdirectory; ` +
113
+ `[[${parsed.name}]] resolves to /memories/${parsed.name}.md, not this file`
114
+ });
115
+ }
116
+
117
+ if (parsed.description === undefined) {
118
+ issues.push({ code: "description-missing", message: "missing description" });
119
+ }
120
+ if (parsed.type === undefined) {
121
+ issues.push({ code: "type-missing", message: "missing metadata.type" });
122
+ } else if (!MEMORY_TYPES.includes(parsed.type)) {
123
+ issues.push({
124
+ code: "type-invalid",
125
+ message: `metadata.type ${JSON.stringify(parsed.type)} is not one of ${MEMORY_TYPES.join(", ")}`
126
+ });
127
+ }
128
+ return issues;
129
+ }
130
+
78
131
  /**
79
132
  * List what a topic file's frontmatter is missing, for a non-blocking hint.
80
133
  *
81
134
  * @param {ReturnType<typeof parseMemory>} parsed - parsed memory file.
135
+ * @param {string} [fileStem] - file name without `.md`; `[[name]]` resolves to it.
136
+ * @param {string} [filePath] - project-relative path, used to detect subdirectories.
82
137
  * @returns {string[]} human-readable problems; empty when the file is well-formed.
83
138
  */
84
- export function memoryFrontmatterProblems(parsed) {
85
- if (!parsed.frontmatter) return ["no frontmatter (name, description, metadata.type)"];
86
- const problems = [];
87
- if (parsed.name === undefined) problems.push("missing name");
88
- else if (!MEMORY_NAME.test(parsed.name)) problems.push(`name ${JSON.stringify(parsed.name)} is not kebab-case`);
89
- if (parsed.description === undefined) problems.push("missing description");
90
- if (parsed.type === undefined) problems.push("missing metadata.type");
91
- else if (!MEMORY_TYPES.includes(parsed.type)) {
92
- problems.push(`metadata.type ${JSON.stringify(parsed.type)} is not one of ${MEMORY_TYPES.join(", ")}`);
93
- }
94
- return problems;
139
+ export function memoryFrontmatterProblems(parsed, fileStem, filePath) {
140
+ return memoryFrontmatterIssues(parsed, fileStem, filePath).map((issue) => issue.message);
95
141
  }
package/lib/store.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  writeFile
10
10
  } from "node:fs/promises";
11
11
  import { basename, dirname, extname, join, resolve, sep } from "node:path";
12
- import { memoryFrontmatterProblems, parseMemory } from "./memory-frontmatter.js";
12
+ import { MEMORY_NAME, memoryFrontmatterIssues, parseMemory } from "./memory-frontmatter.js";
13
13
 
14
14
  /** Name of the index file loaded into every session. */
15
15
  export const INDEX_FILE = "MEMORY.md";
@@ -27,6 +27,8 @@ const MAX_PATH_SEGMENTS = 4;
27
27
  const MAX_RELATIVE_PATH_LENGTH = 200;
28
28
  const MAX_LIST_DEPTH = MAX_PATH_SEGMENTS;
29
29
  const SAFE_SEGMENT = /^[^\\/\u0000-\u001f\u007f:*?"<>|#%\[\]()]+$/u;
30
+ const INDEX_BULLET = /^(\s*[-*]\s*)/u;
31
+ const CHECKBOX = /^\s*(?:[xX])?\s*$/u;
30
32
 
31
33
  /** A memory operation failed with a model-actionable reason. */
32
34
  export class MemoryError extends Error {
@@ -49,6 +51,142 @@ function lineCount(value) {
49
51
  return value.length === 0 ? 0 : value.split("\n").length;
50
52
  }
51
53
 
54
+ function needsAngle(target) {
55
+ return /\s/u.test(target);
56
+ }
57
+
58
+ /**
59
+ * Read a Markdown link destination. A destination with whitespace is written
60
+ * as `<target>`; the bare form is kept for older index lines and for targets
61
+ * without whitespace.
62
+ *
63
+ * @param {string} line - index line without its trailing `\r`.
64
+ * @param {number} start - index of the first destination character or `<`.
65
+ * @returns {{target: string, end: number} | undefined} target and the index
66
+ * after the closing `)`, or undefined when the link is malformed.
67
+ */
68
+ function readTarget(line, start) {
69
+ if (line[start] === "<") {
70
+ const close = line.indexOf(">", start + 1);
71
+ if (close === -1 || line[close + 1] !== ")") return undefined;
72
+ return { target: line.slice(start + 1, close), end: close + 2 };
73
+ }
74
+ const close = line.indexOf(")", start);
75
+ if (close === -1) return undefined;
76
+ return { target: line.slice(start, close), end: close + 1 };
77
+ }
78
+
79
+ /**
80
+ * Find the title of the first link, matching nested brackets. This keeps a
81
+ * link embedded in a title from becoming the entry target.
82
+ *
83
+ * @param {string} line - index line without its trailing `\r`.
84
+ * @param {number} open - index of the opening `[`.
85
+ * @returns {{title: string, targetStart: number} | undefined} the title and
86
+ * the index after `](`, or undefined when the bracket is not followed by a
87
+ * destination.
88
+ */
89
+ function leadingLink(line, open) {
90
+ let depth = 0;
91
+ for (let index = open; index < line.length; index += 1) {
92
+ const char = line[index];
93
+ if (char === "\\") {
94
+ index += 1;
95
+ continue;
96
+ }
97
+ if (char === "[") {
98
+ depth += 1;
99
+ } else if (char === "]") {
100
+ depth -= 1;
101
+ if (depth === 0) {
102
+ if (line[index + 1] !== "(") return undefined;
103
+ return { title: line.slice(open + 1, index), targetStart: index + 2 };
104
+ }
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * Parse one index line that links `target`. Only the leading link counts, so
112
+ * links inside a hook never identify the line. A line whose title contains
113
+ * nested brackets is handled by bracket matching; v0.3.0 wrote unescaped
114
+ * names as titles, so a malformed legacy title can still be recognized by the
115
+ * first link after the opening `[`.
116
+ *
117
+ * @param {string} line - one `MEMORY.md` line, possibly ending in `\r`.
118
+ * @param {string} target - exact project-relative path to match.
119
+ * @returns {{bullet: string, title: string, target: string, rest: string, cr: string} | undefined}
120
+ */
121
+ function parseIndexLine(line, target) {
122
+ const cr = line.endsWith("\r") ? "\r" : "";
123
+ const text = cr.length > 0 ? line.slice(0, -1) : line;
124
+ const bullet = INDEX_BULLET.exec(text);
125
+ if (bullet === null) return undefined;
126
+ const prefix = bullet[1];
127
+ const open = prefix.length;
128
+ if (text[open] !== "[") return undefined;
129
+
130
+ const leading = leadingLink(text, open);
131
+ if (leading !== undefined) {
132
+ const destination = readTarget(text, leading.targetStart);
133
+ if (destination === undefined || destination.target !== target) return undefined;
134
+ return {
135
+ bullet: prefix,
136
+ title: leading.title,
137
+ target: destination.target,
138
+ rest: text.slice(destination.end),
139
+ cr
140
+ };
141
+ }
142
+
143
+ // `- [ ] [name](target)` is a task list, not an index line.
144
+ const firstClose = text.indexOf("]", open + 1);
145
+ if (firstClose !== -1) {
146
+ const box = text.slice(open + 1, firstClose);
147
+ if (CHECKBOX.test(box) && text.indexOf("[", firstClose + 1) !== -1) return undefined;
148
+ if (text.slice(firstClose + 1).trimStart().startsWith("[")) return undefined;
149
+ }
150
+
151
+ // A legacy title such as `- [a]b](target)` lacks a bracket before the
152
+ // destination; fall back to the first link, which is still the entry link.
153
+ const firstLink = text.indexOf("](", open + 1);
154
+ if (firstLink === -1) return undefined;
155
+ const hookAt = text.indexOf(" — ", open + 1);
156
+ if (hookAt !== -1 && firstLink > hookAt) return undefined;
157
+ const destination = readTarget(text, firstLink + 2);
158
+ if (destination === undefined || destination.target !== target) return undefined;
159
+ return {
160
+ bullet: prefix,
161
+ title: text.slice(open + 1, firstLink),
162
+ target: destination.target,
163
+ rest: text.slice(destination.end),
164
+ cr
165
+ };
166
+ }
167
+
168
+ function formatIndexLine({ bullet, title, target, rest, cr }) {
169
+ const destination = needsAngle(target) ? `<${target}>` : target;
170
+ return `${bullet}[${title}](${destination})${rest}${cr}`;
171
+ }
172
+
173
+ function stem(relativePath) {
174
+ return basename(relativePath, extname(relativePath));
175
+ }
176
+
177
+ function frontmatterIssues(relativePath, content) {
178
+ return memoryFrontmatterIssues(parseMemory(content), stem(relativePath), relativePath);
179
+ }
180
+
181
+ /** Frontmatter problems present after an edit or rename that were not present before it. */
182
+ function introducedProblems(relativePath, before, after, previousPath = relativePath) {
183
+ if (relativePath === INDEX_FILE) return [];
184
+ const known = new Set(frontmatterIssues(previousPath, before).map((issue) => issue.code));
185
+ return frontmatterIssues(relativePath, after)
186
+ .filter((issue) => !known.has(issue.code))
187
+ .map((issue) => issue.message);
188
+ }
189
+
52
190
  function virtual(relativePath) {
53
191
  return relativePath.length === 0 ? VIRTUAL_ROOT : `${VIRTUAL_ROOT}/${relativePath}`;
54
192
  }
@@ -387,7 +525,7 @@ export class MemoryStore {
387
525
  let indexed = false;
388
526
  let frontmatterProblems = [];
389
527
  if (relativePath !== INDEX_FILE) {
390
- frontmatterProblems = memoryFrontmatterProblems(parseMemory(next));
528
+ frontmatterProblems = frontmatterIssues(relativePath, next).map((issue) => issue.message);
391
529
  try {
392
530
  indexed = (await this.#linkIndex(relativePath, next)).updated;
393
531
  } catch (error) {
@@ -430,7 +568,12 @@ export class MemoryStore {
430
568
  const next = current.content.replace(oldStr, () => newStr);
431
569
  this.#assertWithinFileBudget(relativePath, next);
432
570
  const written = await this.#atomicWrite(relativePath, next);
433
- return { path: virtual(relativePath), ...written, ...(await this.#syncIndex(relativePath, current.content, next)) };
571
+ return {
572
+ path: virtual(relativePath),
573
+ ...written,
574
+ ...(await this.#syncIndex(relativePath, current.content, next)),
575
+ frontmatterProblems: introducedProblems(relativePath, current.content, next)
576
+ };
434
577
  });
435
578
  }
436
579
 
@@ -463,7 +606,8 @@ export class MemoryStore {
463
606
  path: virtual(relativePath),
464
607
  ...written,
465
608
  insertedAt: at,
466
- ...(await this.#syncIndex(relativePath, current.content, next))
609
+ ...(await this.#syncIndex(relativePath, current.content, next)),
610
+ frontmatterProblems: introducedProblems(relativePath, current.content, next)
467
611
  };
468
612
  });
469
613
  }
@@ -504,7 +648,8 @@ export class MemoryStore {
504
648
  }
505
649
 
506
650
  /**
507
- * Rename one file. Links in the index follow the file.
651
+ * Rename one file. Links in the index follow the file, and a title still
652
+ * generated from the old file name follows the new one.
508
653
  *
509
654
  * @param {string} oldPath - existing file path.
510
655
  * @param {string} newPath - new file path.
@@ -534,22 +679,43 @@ export class MemoryStore {
534
679
  if (await this.#lstat(absoluteNew)) {
535
680
  throw new MemoryError(`${virtual(newPath)} already exists`, "MEMORY_ALREADY_EXISTS");
536
681
  }
682
+ const content = await this.#readText(oldPath).then(
683
+ (read) => read.content,
684
+ () => undefined
685
+ );
537
686
  await mkdir(dirname(absoluteNew), { recursive: true });
538
687
  await renameFile(absoluteOld, absoluteNew);
539
688
  let indexWarning;
540
689
  let indexed = false;
690
+ const previousFields = this.#indexFields(oldPath, content);
691
+ const nextFields = this.#indexFields(newPath, content);
541
692
  try {
542
- indexed = await this.#relinkIndex(oldPath, newPath);
693
+ indexed = await this.#rewriteIndex(oldPath, (entry) => ({
694
+ ...entry,
695
+ target: newPath,
696
+ title:
697
+ entry.title === previousFields.title || entry.title === previousFields.legacyTitle
698
+ ? nextFields.title
699
+ : entry.title
700
+ }));
543
701
  } catch (error) {
544
702
  indexWarning = `renamed the file, but could not update MEMORY.md: ${error.message}`;
545
703
  }
546
- return { path: virtual(newPath), previousPath: virtual(oldPath), changed: true, indexed, indexWarning };
704
+ const frontmatterProblems = content === undefined ? [] : introducedProblems(newPath, content, content, oldPath);
705
+ return {
706
+ path: virtual(newPath),
707
+ previousPath: virtual(oldPath),
708
+ changed: true,
709
+ indexed,
710
+ indexWarning,
711
+ frontmatterProblems
712
+ };
547
713
  });
548
714
  }
549
715
 
550
716
  #indexFields(relativePath, text) {
551
717
  const parsed = parseMemory(text ?? "");
552
- const title = parsed.name ?? basename(relativePath, extname(relativePath));
718
+ const title = parsed.name !== undefined && MEMORY_NAME.test(parsed.name) ? parsed.name : stem(relativePath);
553
719
  const firstLine = parsed.body
554
720
  .split("\n")
555
721
  .map((line) => line.trim())
@@ -557,51 +723,83 @@ export class MemoryStore {
557
723
  const hook = (parsed.description ?? firstLine ?? "")
558
724
  .replace(/^#+\s*/u, "")
559
725
  .replace(/\s+/gu, " ")
726
+ .trim()
560
727
  .slice(0, 110);
561
- return { title, hook };
728
+ return { title, hook, legacyTitle: parsed.name };
562
729
  }
563
730
 
564
731
  #indexEntry(relativePath, text) {
565
732
  const { title, hook } = this.#indexFields(relativePath, text);
566
- return `- [${title}](${relativePath})${hook.length > 0 ? ` — ${hook}` : ""}`;
733
+ return formatIndexLine({
734
+ bullet: "- ",
735
+ title,
736
+ target: relativePath,
737
+ rest: hook.length > 0 ? ` — ${hook}` : "",
738
+ cr: ""
739
+ });
567
740
  }
568
741
 
569
742
  /**
570
- * Refresh the index line of an edited topic file. A title or hook is
571
- * replaced only while it still equals what the old content would have
572
- * generated, so a line the model rewrote by hand is left alone.
743
+ * Rewrite the index lines that link `relativePath`.
744
+ *
745
+ * @param {string} relativePath - link target to match.
746
+ * @param {(entry: object) => object | null | undefined} update - returns the
747
+ * replacement entry, `null` to drop the line, or `undefined` to keep it.
748
+ * @returns {Promise<boolean>} whether the index changed.
749
+ */
750
+ async #rewriteIndex(relativePath, update) {
751
+ const index = await this.index();
752
+ if (!index.exists) return false;
753
+ let changed = false;
754
+ const lines = [];
755
+ for (const line of index.content.split("\n")) {
756
+ const entry = parseIndexLine(line, relativePath);
757
+ const next = entry === undefined ? undefined : update(entry);
758
+ if (next === undefined) {
759
+ lines.push(line);
760
+ } else if (next !== null) {
761
+ const formatted = formatIndexLine(next);
762
+ changed ||= formatted !== line;
763
+ lines.push(formatted);
764
+ } else {
765
+ changed = true;
766
+ }
767
+ }
768
+ if (!changed) return false;
769
+ const content = lines.join("\n");
770
+ if (byteLength(content) > this.maxFileBytes) {
771
+ throw new MemoryError("MEMORY.md is full; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
772
+ }
773
+ await this.#atomicWrite(INDEX_FILE, content);
774
+ return true;
775
+ }
776
+
777
+ /**
778
+ * Refresh the index line of an edited topic file. A title or hook is replaced
779
+ * only while it still equals what the old content would have generated, so a
780
+ * line the model rewrote by hand is left alone. An invalid name from v0.3.0
781
+ * was used as the title then; it is recognized as the legacy generated title
782
+ * and normalized to the current fallback.
573
783
  */
574
784
  async #syncIndex(relativePath, before, after) {
575
785
  if (relativePath === INDEX_FILE) return {};
576
786
  const previous = this.#indexFields(relativePath, before);
577
787
  const current = this.#indexFields(relativePath, after);
578
- if (previous.title === current.title && previous.hook === current.hook) return {};
788
+ const legacyStale =
789
+ previous.legacyTitle !== undefined && previous.legacyTitle !== previous.title;
790
+ if (previous.title === current.title && previous.hook === current.hook && !legacyStale) return {};
579
791
  try {
580
- const index = await this.index();
581
- if (!index.exists) return { indexed: false };
582
- const link = `](${relativePath})`;
583
- let changed = false;
584
- const lines = index.content.split("\n").map((line) => {
585
- const at = line.indexOf(link);
586
- const open = line.lastIndexOf("[", at);
587
- if (at === -1 || open === -1 || !/^\s*-\s*$/u.test(line.slice(0, open))) return line;
588
- const title = line.slice(open + 1, at);
589
- const rest = line.slice(at + link.length);
590
- const hook = rest.startsWith(" — ") ? rest.slice(3) : rest.length === 0 ? "" : undefined;
591
- if (hook === undefined) return line;
592
- const nextTitle = title === previous.title ? current.title : title;
792
+ const indexed = await this.#rewriteIndex(relativePath, (entry) => {
793
+ const hook = entry.rest.startsWith(" — ") ? entry.rest.slice(3) : entry.rest.length === 0 ? "" : undefined;
794
+ if (hook === undefined) return undefined;
795
+ const title =
796
+ entry.title === previous.title || entry.title === previous.legacyTitle
797
+ ? current.title
798
+ : entry.title;
593
799
  const nextHook = hook === previous.hook ? current.hook : hook;
594
- if (nextTitle === title && nextHook === hook) return line;
595
- changed = true;
596
- return `${line.slice(0, open)}[${nextTitle}${link}${nextHook.length > 0 ? ` — ${nextHook}` : ""}`;
800
+ return { ...entry, title, rest: nextHook.length > 0 ? ` — ${nextHook}` : "" };
597
801
  });
598
- if (!changed) return { indexed: false };
599
- const next = lines.join("\n");
600
- if (byteLength(next) > this.maxFileBytes) {
601
- throw new MemoryError("MEMORY.md is full; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
602
- }
603
- await this.#atomicWrite(INDEX_FILE, next);
604
- return { indexed: true };
802
+ return { indexed };
605
803
  } catch (error) {
606
804
  return { indexed: false, indexWarning: `edited the file, but could not update MEMORY.md: ${error.message}` };
607
805
  }
@@ -609,12 +807,23 @@ export class MemoryStore {
609
807
 
610
808
  async #linkIndex(relativePath, text) {
611
809
  const index = await this.index();
612
- const link = `](${relativePath})`;
613
- if (index.exists && index.content.includes(link)) return { updated: false };
810
+ if (
811
+ index.exists &&
812
+ index.content.split("\n").some((line) => parseIndexLine(line, relativePath) !== undefined)
813
+ ) {
814
+ return { updated: false };
815
+ }
614
816
  const entry = this.#indexEntry(relativePath, text);
615
- const next = index.exists
616
- ? `${index.content.replace(/\n*$/u, "")}\n${entry}\n`
617
- : `${INDEX_HEADER.replace(/\n*$/u, "")}\n\n${entry}\n`;
817
+ let next;
818
+ if (!index.exists || index.content.trim().length === 0) {
819
+ next = `${INDEX_HEADER.replace(/\n*$/u, "")}\n\n${entry}\n`;
820
+ } else {
821
+ const content = index.content.replace(/[ \t]+$/u, "");
822
+ const eol = content.endsWith("\r\n") ? "\r\n" : content.endsWith("\r") ? "\r" : "\n";
823
+ const separator =
824
+ content.length === 0 || content.endsWith("\n") || content.endsWith("\r") ? "" : eol;
825
+ next = `${content}${separator}${entry}${eol}`;
826
+ }
618
827
  if (byteLength(next) > this.maxFileBytes) {
619
828
  throw new MemoryError("MEMORY.md is full; remove or merge entries before adding more", "MEMORY_INDEX_FULL");
620
829
  }
@@ -623,24 +832,6 @@ export class MemoryStore {
623
832
  }
624
833
 
625
834
  async #unlinkIndex(relativePath) {
626
- const index = await this.index();
627
- if (!index.exists) return false;
628
- const needle = `](${relativePath})`;
629
- const lines = index.content.split("\n");
630
- const kept = lines.filter((line) => !line.includes(needle));
631
- if (kept.length === lines.length) return false;
632
- const next = kept.join("\n");
633
- await this.#atomicWrite(INDEX_FILE, next);
634
- return true;
635
- }
636
-
637
- async #relinkIndex(oldPath, newPath) {
638
- const index = await this.index();
639
- if (!index.exists) return false;
640
- const needle = `](${oldPath})`;
641
- if (!index.content.includes(needle)) return false;
642
- const next = index.content.split(needle).join(`](${newPath})`);
643
- await this.#atomicWrite(INDEX_FILE, next);
644
- return true;
835
+ return this.#rewriteIndex(relativePath, () => null);
645
836
  }
646
837
  }
package/lib/tool.js CHANGED
@@ -38,6 +38,12 @@ function indexNote({ indexed, indexWarning, action }) {
38
38
  return `MEMORY.md already links this file (${action}).`;
39
39
  }
40
40
 
41
+ function frontmatterNote(problems) {
42
+ return problems !== undefined && problems.length > 0
43
+ ? [`Frontmatter: ${problems.join("; ")}. Fix it with str_replace, or rename the file when the name and path disagree.`]
44
+ : [];
45
+ }
46
+
41
47
  function editIndexNote({ indexed, indexWarning }) {
42
48
  if (indexWarning !== undefined) return [indexWarning];
43
49
  return indexed ? ["Updated its MEMORY.md line to match the new frontmatter."] : [];
@@ -183,9 +189,7 @@ export function buildMemoryToolOptions({ projectFor }) {
183
189
  (relativePath === INDEX_FILE
184
190
  ? "This is the index injected into future sessions."
185
191
  : indexNote({ ...result, action: "created" })),
186
- ...(result.frontmatterProblems.length > 0
187
- ? [`Frontmatter: ${result.frontmatterProblems.join("; ")}. Fix it with str_replace.`]
188
- : [])
192
+ ...frontmatterNote(result.frontmatterProblems)
189
193
  ].join("\n");
190
194
  return { ok: true, output, path: result.path, changed: true, bytes: result.bytes, lines: result.lines };
191
195
  }
@@ -197,7 +201,8 @@ export function buildMemoryToolOptions({ projectFor }) {
197
201
  ok: true,
198
202
  output: [
199
203
  `Edited ${result.path} (${formatBytes(result.bytes)}, ${result.lines} lines).`,
200
- ...editIndexNote(result)
204
+ ...editIndexNote(result),
205
+ ...frontmatterNote(result.frontmatterProblems)
201
206
  ].join("\n"),
202
207
  path: result.path,
203
208
  changed: true,
@@ -215,7 +220,8 @@ export function buildMemoryToolOptions({ projectFor }) {
215
220
  ok: true,
216
221
  output: [
217
222
  `Inserted text at line ${result.insertedAt} in ${result.path} (${formatBytes(result.bytes)}, ${result.lines} lines).`,
218
- ...editIndexNote(result)
223
+ ...editIndexNote(result),
224
+ ...frontmatterNote(result.frontmatterProblems)
219
225
  ].join("\n"),
220
226
  path: result.path,
221
227
  changed: true,
@@ -236,7 +242,8 @@ export function buildMemoryToolOptions({ projectFor }) {
236
242
  const result = await store.rename(relativePath, MemoryStore.parseVirtualPath(newPath));
237
243
  const output = [
238
244
  `Renamed ${result.previousPath} to ${result.path}.`,
239
- result.indexWarning ?? (result.indexed ? "Updated its MEMORY.md link." : "No MEMORY.md link referenced it.")
245
+ result.indexWarning ?? (result.indexed ? "Updated its MEMORY.md link." : "No MEMORY.md link referenced it."),
246
+ ...frontmatterNote(result.frontmatterProblems)
240
247
  ].join("\n");
241
248
  return { ok: true, output, path: result.path, changed: true };
242
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@y0shua1ee/dsh-auto-memory",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "Persistent project memory and Claude Code-compatible rules for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -29,9 +29,9 @@
29
29
  "agent"
30
30
  ],
31
31
  "peerDependencies": {
32
- "@deepseek-ai/dsh-home-paths": "0.2.1-rc.3",
33
- "@deepseek-ai/dsh-llm": "0.2.1-rc.3",
34
- "@deepseek-ai/dsh-tools": "0.2.1-rc.3",
32
+ "@deepseek-ai/dsh-home-paths": "^0.2.1-rc.3",
33
+ "@deepseek-ai/dsh-llm": "^0.2.1-rc.3",
34
+ "@deepseek-ai/dsh-tools": "^0.2.1-rc.3",
35
35
  "@deepseek-ai/schemastery": "~3.18.5"
36
36
  },
37
37
  "peerDependenciesMeta": {
@@ -48,6 +48,11 @@
48
48
  "optional": true
49
49
  }
50
50
  },
51
+ "scripts": {
52
+ "test": "node --test test/*.test.mjs",
53
+ "test:integration": "node --import ./test/register.mjs --test test/*.test.mjs",
54
+ "extract-runtime": "node scripts/extract-runtime.mjs"
55
+ },
51
56
  "author": "Ares Lee",
52
57
  "homepage": "https://github.com/y0shua1ee/dsh-auto-memory#readme",
53
58
  "repository": {
@@ -68,10 +73,5 @@
68
73
  },
69
74
  "publishConfig": {
70
75
  "access": "public"
71
- },
72
- "scripts": {
73
- "test": "node --test test/*.test.mjs",
74
- "test:integration": "node --import ./test/register.mjs --test test/*.test.mjs",
75
- "extract-runtime": "node scripts/extract-runtime.mjs"
76
76
  }
77
- }
77
+ }