@fonlan/dsh-gitmemo 0.3.1 → 0.4.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/README.md CHANGED
@@ -16,6 +16,9 @@ dependency, and no manual memory commands are ever needed.
16
16
  - **Git-only** — no runtime dependency beyond the `git` CLI
17
17
  - **Token-efficient** — reuses prior conclusions via `mem_search`; subagents carry neither the workflow rules nor the tool schemas
18
18
  - **Immutable entries** — every write creates a new file; corrections use `mem_replace` (one commit deletes the old file and adds the new one), withdrawal uses `mem_delete`
19
+ - **Knowledge freshness** — `mem_replace` is not just for user corrections: work that overrules a stored conclusion actively replaces it; `mem_search` scores hits as distinct matched keywords + a mild recency bonus (≤ +0.5, linearly decaying to 0 over ~180 days), so conflicting stale conclusions lose to their replacements
20
+ - **Topic pages (MOC)** — a `kind: "topic"` entry aggregates a topic's "current truth": the summary states the currently-valid conclusions and the page evolves via `mem_replace` (kind is inherited), not per-task rewrites
21
+ - **Wiki links between entries** — content references other entries as `[[<commit-hash>]]`; `mem_read` with `expand: true` resolves those links one hop and automatically follows the `GitMemo-Replaces` chain to a replaced target's active version (dangling links come back with an error)
19
22
  - **Structured search** — commit messages carry `GitMemo-*` trailers (keywords, digest, search-text projections); search is `git log --grep --fixed-strings` over commit messages only — entry bodies are never scanned
20
23
  - **Auditable** — every memory action is a commit in `.mem`'s Git history; replaced/deleted entries stay readable by hash
21
24
  - **Crash-safe** — write/delete/replace run under a cross-process lock with a journal written before entry mutation; migration uses a sibling swap journal so an interrupted directory exchange is recovered before automatic initialization
@@ -25,11 +28,11 @@ dependency, and no manual memory commands are ever needed.
25
28
 
26
29
  | Piece | Description |
27
30
  | --- | --- |
28
- | `mem_search` | Search memories: `keywords` (array of 1–12, 中英文同义词), `skip` + `snapshot` (stable pagination). Returns up to 20 scored hits with `summary` / `keywords` / `matched_keywords` |
29
- | `mem_read` | Read one memory entry by create/replace commit hash (full markdown; historical hashes stay readable) |
30
- | `mem_write` | Store a task outcome: `title` + `summary` + `keywords` (2–12) + `content` (engine generates front matter), optional `related_branches` / `related_paths`. One ADD commit per immutable file |
31
+ | `mem_search` | Search memories: `keywords` (array of 1–12, 中英文同义词), `skip` + `snapshot` (stable pagination). Returns up to 20 scored hits with `summary` / `keywords` / `kind` / `matched_keywords` (score = matched keywords + recency bonus) |
32
+ | `mem_read` | Read one memory entry by create/replace commit hash (full markdown; historical hashes stay readable); returns `kind`; optional `expand: true` resolves `[[hash]]` links one hop |
33
+ | `mem_write` | Store a task outcome: `title` + `summary` + `keywords` (2–12) + `content` (engine generates front matter), optional `kind` (`"task"` default / `"topic"` aggregated page), `related_branches` / `related_paths`. One ADD commit per immutable file |
31
34
  | `mem_delete` | Withdraw an obsolete conclusion (requires `commit_hash` + `reason`) |
32
- | `mem_replace` | Correct an outdated conclusion in ONE atomic commit (D old + A new) — never delete-then-write |
35
+ | `mem_replace` | Correct an outdated conclusion in ONE atomic commit (D old + A new) — never delete-then-write. Triggered by user corrections AND by work that overrules a stored conclusion; `kind` defaults to the replaced entry's kind |
33
36
  | Scoped rules | The workflow rules + the five tools are registered into **root agents only** (`delegationDepth === 0`) at `agent/created`; subagents carry neither |
34
37
 
35
38
  ## Installation
@@ -83,7 +86,8 @@ The `.mem` repository lives at the **project root** of the calling session's wor
83
86
  `.gitignore`), and refuses to run when the parent repo already tracks `.mem` content.
84
87
  - Every write creates a brand-new file with an exclusive create (never overwrites), commits
85
88
  one ADD commit with structured trailers, and records the code branch/SHA/related paths in
86
- the entry front matter.
89
+ the entry front matter. Front matter always carries `kind` (`"task"` or `"topic"`); topic
90
+ entries additionally carry a `GitMemo-Kind: topic` commit trailer.
87
91
 
88
92
  The whole memory stays readable with plain `git`:
89
93
 
@@ -97,17 +101,27 @@ git -C .mem show <commit-hash>
97
101
  1. **Before work — search.** For repo-related tasks, extract 1–12 中英文关键词 → `mem_search`.
98
102
  Pure chat and general Q&A need no search.
99
103
  2. **Preselect results.** Based on `title` / `summary` / `keywords` / `score` /
100
- `matched_keywords`, `mem_read` at most 5 most relevant memories; paginate with `skip` +
104
+ `matched_keywords`, `mem_read` at most 5 most relevant memories (`kind: "topic"` entries
105
+ are a topic's aggregated entry point — prefer them); paginate with `skip` +
101
106
  the returned `snapshot`.
102
107
  3. **End-of-session checkpoint — the only write path.** When the conversation is ending,
103
108
  `mem_write` every completed repo-related task that still lacks a memory and whose outcome is
104
109
  **valuable/reusable** (or was explicitly asked to be remembered). Never duplicate an
105
110
  already-written entry. Never write for pure Q&A, incomplete tasks, non-repo work, or purely
106
- operational git actions.
107
- 4. **User correction.** If a stored conclusion is outdated and a replacement exists →
111
+ operational git actions. `keywords` should be task-related words NOT already present in
112
+ `title` / `summary` (中英文同义词 both fine) — words already there are searchable via
113
+ `title` / `summary` themselves, so repeating them does not improve recall.
114
+ 4. **Keep fresh.** If the user corrects a stored conclusion and a replacement exists →
108
115
  `mem_replace` (never delete-then-write). If it is obsolete with no replacement →
109
- `mem_delete` with a `reason`.
110
- 5. **Subagent results.** Only the root agent decides whether a session-level memory is written.
116
+ `mem_delete` with a `reason`. The same applies — without waiting for the user — when this
117
+ session's own work overrules a stored conclusion (implemented/refactored away). When search
118
+ returns conflicting old/new conclusions, trust the newer one (score already includes the
119
+ recency bonus).
120
+ 5. **Topic pages.** Once a topic has accumulated several memories — or needs a "current truth"
121
+ entry point — write one `kind: "topic"` aggregated entry: the summary states the currently
122
+ valid conclusions, and the content links the evidence entries as `[[<commit-hash>]]` with a
123
+ one-line status each; evolve topic pages via `mem_replace`.
124
+ 6. **Subagent results.** Only the root agent decides whether a session-level memory is written.
111
125
 
112
126
  ## Development
113
127
 
package/README.zh.md CHANGED
@@ -15,6 +15,9 @@
15
15
  - **仅依赖 Git** —— 除 `git` CLI 外无任何运行时依赖
16
16
  - **省 token** —— 通过 `mem_search` 复用既有结论;子代理既不携带工作流规则也不携带工具 schema
17
17
  - **条目不可变** —— 每次写入都创建新文件;更正用 `mem_replace`(一个提交同时删除旧文件、新增新文件),作废用 `mem_delete`
18
+ - **知识保鲜** —— `mem_replace` 不只用于用户更正:本次工作覆盖旧结论时同样主动替换;`mem_search` 评分为「命中关键词数 + 温和的 recency 加成(≤ +0.5,180 天线性衰减到 0)」,新旧结论冲突时自动偏向新条目
19
+ - **主题聚合页(MOC)** —— `kind: "topic"` 条目聚合一个主题的「当前真相」:summary 概括当下有效结论,靠 `mem_replace` 演进(kind 自动继承),不按任务重复新增
20
+ - **条目间 Wiki 链接** —— content 里用 `[[<commit-hash>]]` 引用其他条目;`mem_read` 传 `expand: true` 一跳展开链接目标,并自动跟随 `GitMemo-Replaces` 链到被替换条目的最新版本(悬空链接带 error 返回)
18
21
  - **结构化搜索** —— commit message 携带 `GitMemo-*` trailers(keywords、digest、search-text 投影);搜索只对 commit message 执行 `git log --grep --fixed-strings`,绝不扫描条目正文
19
22
  - **可审计** —— 每次记忆操作都是 `.mem` Git 历史中的一次提交;被替换/删除的条目仍可按哈希读取
20
23
  - **崩溃安全** —— write/delete/replace 在修改条目前先写事务 journal;迁移另用 sibling swap journal,目录交换中断后会在自动初始化前恢复
@@ -24,11 +27,11 @@
24
27
 
25
28
  | 内容 | 说明 |
26
29
  | --- | --- |
27
- | `mem_search` | 搜索记忆:`keywords`(1–12 个关键词数组,建议中英文同义词)、`skip` + `snapshot`(稳定分页)。每次最多返回 20 条带 `summary` / `keywords` / `matched_keywords` 的评分结果 |
28
- | `mem_read` | 按创建/替换提交哈希读取一条记忆的完整 markdown(历史哈希仍可读) |
29
- | `mem_write` | 存储任务结论:`title` + `summary` + `keywords`(2–12)+ `content`(front matter 由引擎生成),可选 `related_branches` / `related_paths`。每个不可变文件对应一个 ADD 提交 |
30
+ | `mem_search` | 搜索记忆:`keywords`(1–12 个关键词数组,建议中英文同义词)、`skip` + `snapshot`(稳定分页)。每次最多返回 20 条带 `summary` / `keywords` / `kind` / `matched_keywords` 的评分结果(score = 命中关键词数 + recency 加成) |
31
+ | `mem_read` | 按创建/替换提交哈希读取一条记忆的完整 markdown(历史哈希仍可读);返回 `kind`;可选 `expand: true` 一跳展开条目内的 `[[hash]]` 链接 |
32
+ | `mem_write` | 存储任务结论:`title` + `summary` + `keywords`(2–12)+ `content`(front matter 由引擎生成),可选 `kind`(`"task"` 缺省 / `"topic"` 聚合页)、`related_branches` / `related_paths`。每个不可变文件对应一个 ADD 提交 |
30
33
  | `mem_delete` | 作废无替代结论(需要 `commit_hash` + `reason`) |
31
- | `mem_replace` | 一个原子提交内更正旧结论(D 旧文件 + A 新文件)—— 禁止先删后写 |
34
+ | `mem_replace` | 一个原子提交内更正旧结论(D 旧文件 + A 新文件)—— 禁止先删后写。触发面不只是用户更正:本次工作覆盖旧结论时同样应主动 replace;`kind` 缺省继承被替换条目 |
32
35
  | 作用域规则 | 工作流规则与五个工具只在 `agent/created` 时注册进**根 Agent**(`delegationDepth === 0`);子代理两者皆无 |
33
36
 
34
37
  ## 安装
@@ -78,7 +81,8 @@ bundle patch 自带合理默认值,可在 profile 的 `cordis.patch.yml` 中
78
81
  - `.mem` 初始化在自己的 `main` 分支上;初始化时把 `.mem/` 与锁文件路径写入父仓库的
79
82
  `.git/info/exclude`(绝不写入受版本控制的 `.gitignore`),且当父仓库已跟踪 `.mem` 内容时拒绝初始化。
80
83
  - 每次写入都用独占创建生成全新文件(绝不覆盖),以带结构化 trailers 的 ADD 提交落库,并把代码
81
- 分支/SHA/相关路径记录在条目 front matter 中。
84
+ 分支/SHA/相关路径记录在条目 front matter 中。front matter 固定携带 `kind`(`"task"` 或 `"topic"`);
85
+ topic 条目的 commit message 额外带 `GitMemo-Kind: topic` trailer。
82
86
 
83
87
  整个记忆可以用普通 `git` 命令查看:
84
88
 
@@ -91,13 +95,14 @@ git -C .mem show <commit-hash>
91
95
 
92
96
  1. **开工前 —— 搜索。** 仓库相关任务开始前提取 1–12 个中英文关键词 → `mem_search`。纯闲聊和通用问答无需搜索。
93
97
  2. **结果预筛。** 根据 `title` / `summary` / `keywords` / `score` / `matched_keywords` 最多
94
- `mem_read` 5 条最相关记忆;用 `skip` + 返回的 `snapshot` 翻页。
98
+ `mem_read` 5 条最相关记忆(`kind: "topic"` 的条目是该主题的聚合入口,优先读);用 `skip` + 返回的
99
+ `snapshot` 翻页。
95
100
  3. **会话结束检查点 —— 唯一的写入路径。** 对话即将结束时,`mem_write` **每一条**已完成、与仓库
96
101
  相关、且结论**有价值/可复用**(或用户明确要求记住)但还没有记忆的任务。绝不重复写已存在的条目。
97
- 纯问答、未完成任务、与仓库无关的工作、纯操作性的 git 动作一律不写。
98
- 4. **用户更正。** 已存结论过时且有替代结论 → `mem_replace`(禁止先 delete 再 write);结论作废且无
99
- 替代 → 带 `reason` 的 `mem_delete`。
100
- 5. **子代理结果。** 是否形成一条会话级记忆,由根 Agent 汇总后决定。
102
+ 纯问答、未完成任务、与仓库无关的工作、纯操作性的 git 动作一律不写。`keywords` 应选用与任务相关但未出现在 `title` / `summary` 中的词(中英文同义词皆可)——`title` / `summary` 已有的词本身就能被检索到,重复它们不会提高召回率。
103
+ 4. **知识保鲜(KEEP FRESH)。** 用户更正旧结论:有替代用 `mem_replace`(禁止先 delete 再 write),作废无替代用带 `reason` 的 `mem_delete`;本次会话的工作覆盖/推翻某条已存结论时同样主动处理,不必等用户指出。检索结果中新旧冲突时优先采信更新的条目(score 已含 recency 加成)。
104
+ 5. **主题聚合页(TOPIC PAGE)。** 同一主题积累多条记忆、或需要「当前真相」入口时,写一条 `kind: "topic"` 聚合条目:summary 概括当下有效结论,content 用 `[[<commit-hash>]]` 链接证据条目并各配一句话状态;topic 条目靠 `mem_replace` 演进。
105
+ 6. **子代理结果。** 是否形成一条会话级记忆,由根 Agent 汇总后决定。
101
106
 
102
107
  ## 开发
103
108
 
package/lib/index.js CHANGED
@@ -70,7 +70,16 @@ const SEARCH_HIT = {
70
70
  items: { type: "string" },
71
71
  description: "Structured keywords stored with the entry."
72
72
  },
73
- score: { type: "number", required: true, description: "Number of distinct query keywords matched." },
73
+ kind: {
74
+ type: "string",
75
+ required: true,
76
+ description: "Entry kind: task (task-outcome record) or topic (aggregated topic page)."
77
+ },
78
+ score: {
79
+ type: "number",
80
+ required: true,
81
+ description: "Relevance: distinct matched keywords + a mild recency bonus (≤ +0.5, decaying to 0 by ~180 days)."
82
+ },
74
83
  matched_keywords: {
75
84
  type: "array",
76
85
  required: true,
@@ -79,6 +88,29 @@ const SEARCH_HIT = {
79
88
  }
80
89
  }
81
90
  };
91
+ const LINK_RESOLUTION = {
92
+ type: "object",
93
+ additionalProperties: false,
94
+ properties: {
95
+ link_hash: { type: "string", required: true, description: "Hash exactly as written inside the [[...]] link." },
96
+ resolved_hash: {
97
+ type: "string",
98
+ required: true,
99
+ description: "Hash actually read: the active version of the linked entry (empty string when unresolvable)."
100
+ },
101
+ active: {
102
+ type: "boolean",
103
+ required: true,
104
+ description: "True when the link still points at the active version; false = superseded or dangling."
105
+ },
106
+ file: { type: "string", description: "Entry file path inside the memory repo." },
107
+ title: { type: "string", description: "Target entry title." },
108
+ summary: { type: "string", description: "Target entry summary." },
109
+ keywords: { type: "array", items: { type: "string" }, description: "Target entry keywords." },
110
+ kind: { type: "string", description: "Target entry kind: task or topic." },
111
+ error: { type: "string", description: "Present when the link target could not be resolved (dangling link)." }
112
+ }
113
+ };
82
114
  const KEYWORDS_ARRAY = {
83
115
  type: "array",
84
116
  items: { type: "string" },
@@ -109,6 +141,8 @@ function formatSearchResults(value) {
109
141
  hit.title +
110
142
  "|" +
111
143
  hit.date +
144
+ "|kind=" +
145
+ hit.kind +
112
146
  "|score=" +
113
147
  hit.score +
114
148
  "|matched=" +
@@ -128,7 +162,7 @@ function formatSearchResults(value) {
128
162
  function registerMemTools(ctx, config) {
129
163
  ctx.tools.register(defineTool({
130
164
  name: "mem_search",
131
- description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting repo-related work: extract 1-12 中英文关键词 from the user request (include synonyms in both languages when useful) and run mem_search; pure chat and general Q&A need no search. Returns a snapshot, total, next_skip and up to 20 scored hits with summary/keywords/matched_keywords — select at most 5 most relevant hits and mem_read them. Paginate by passing skip and the returned snapshot back unchanged; a stale snapshot is rejected explicitly.",
165
+ description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting repo-related work: extract 1-12 中英文关键词 from the user request (include synonyms in both languages when useful) and run mem_search; pure chat and general Q&A need no search. Returns a snapshot, total, next_skip and up to 20 scored hits with summary/keywords/kind/matched_keywords — score = distinct matched keywords + a mild recency bonus, so newer entries rank slightly higher and superseded conclusions lose to their replacements. Select at most 5 most relevant hits and mem_read them (prefer kind:\"topic\" hits as topic-page entry points). Paginate by passing skip and the returned snapshot back unchanged; a stale snapshot is rejected explicitly.",
132
166
  parameters: {
133
167
  keywords: { ...KEYWORDS_ARRAY, required: true },
134
168
  skip: {
@@ -181,12 +215,16 @@ function registerMemTools(ctx, config) {
181
215
  }));
182
216
  ctx.tools.register(defineTool({
183
217
  name: "mem_read",
184
- description: "Read one gitmemo memory entry by commit hash (returned by mem_search). Select only the most relevant memories before reading — at most 5 when a search returns more. Historical entries that were replaced or deleted stay readable for audit.",
218
+ description: "Read one gitmemo memory entry by commit hash (returned by mem_search). Select only the most relevant memories before reading — at most 5 when a search returns more. Historical entries that were replaced or deleted stay readable for audit. Pass expand:true to also resolve [[<commit-hash>]] wiki links in the entry body one hop to their target entries (each target follows forward to its active version) — use it when reading a kind:\"topic\" topic page or any entry that links others.",
185
219
  parameters: {
186
220
  commit_hash: {
187
221
  type: "string",
188
222
  required: true,
189
223
  description: "Commit hash of the memory entry to read (create or replace hash)."
224
+ },
225
+ expand: {
226
+ type: "boolean",
227
+ description: "Resolve [[<commit-hash>]] links in the entry body one hop to their (active) target entries. Default false."
190
228
  }
191
229
  },
192
230
  output: {
@@ -197,16 +235,57 @@ function registerMemTools(ctx, config) {
197
235
  commit_hash: { type: "string", required: true },
198
236
  file: { type: "string", required: true, description: "Entry file path inside the memory repo." },
199
237
  content: { type: "string", required: true, description: "Full markdown of the memory entry." },
238
+ kind: {
239
+ type: "string",
240
+ required: true,
241
+ description: "Entry kind: task (task-outcome record) or topic (aggregated topic page)."
242
+ },
243
+ links: {
244
+ type: "array",
245
+ items: LINK_RESOLUTION,
246
+ description: "One-hop resolution of the entry's [[<commit-hash>]] links; present only when expand:true."
247
+ },
200
248
  legacy: { type: "boolean", description: "True for legacy-format commits (pre-migration)." }
201
249
  }
202
250
  },
203
- render: (_args, value) => textBlock((value.legacy === true ? "[legacy entry]\n" : "") + value.content)
251
+ render: (_args, value) => {
252
+ const parts = [(value.legacy === true ? "[legacy entry]\n" : "") + value.content];
253
+ if (value.links !== undefined && value.links.length > 0) {
254
+ parts.push("--- linked entries (" + value.links.length + ") ---");
255
+ for (const link of value.links) {
256
+ if (link.error !== undefined) {
257
+ parts.push("x " + link.link_hash.slice(0, 8) + " — unresolved: " + link.error);
258
+ continue;
259
+ }
260
+ const state = link.active
261
+ ? "active"
262
+ : link.resolved_hash !== link.link_hash
263
+ ? "superseded → " + link.resolved_hash.slice(0, 8)
264
+ : "not active (withdrawn)";
265
+ parts.push("= " + link.resolved_hash.slice(0, 8) + " [" + (link.kind ?? "task") + "] " +
266
+ (link.title ?? "") + " (" + state + ")");
267
+ if (link.summary !== undefined && link.summary.length > 0) {
268
+ parts.push(" " + link.summary.replace(/\s+/g, " ").slice(0, 400));
269
+ }
270
+ }
271
+ }
272
+ return textBlock(parts.join("\n"));
273
+ }
204
274
  },
205
275
  isConcurrencySafe: () => true,
206
276
  async execute(args, exec) {
207
277
  const memo = await engineFor(exec, config);
208
- const { hash, file, content, legacy } = await memo.read(args.commit_hash);
209
- return { commit_hash: hash, file, content, ...(legacy ? { legacy: true } : {}) };
278
+ const { hash, file, content, kind, links, legacy } = await memo.read(args.commit_hash, {
279
+ expand: args.expand === true
280
+ });
281
+ return {
282
+ commit_hash: hash,
283
+ file,
284
+ content,
285
+ kind,
286
+ ...(links !== undefined ? { links } : {}),
287
+ ...(legacy ? { legacy: true } : {})
288
+ };
210
289
  },
211
290
  presentCall: (args) => ({
212
291
  card: "generic",
@@ -216,7 +295,7 @@ function registerMemTools(ctx, config) {
216
295
  }));
217
296
  ctx.tools.register(defineTool({
218
297
  name: "mem_write",
219
- description: "Store a completed task outcome in the gitmemo long-term memory (.mem git repo). Write ONLY when the task is complete AND repo-related AND the outcome is valuable/reusable OR the user explicitly asked to remember. Entries are immutable: title (single line, ≤200 chars, \"[module] action + object\"), summary (≤4000 chars), keywords (2-12, include 中英文同义词) and content (markdown body; the engine generates front matter) are all required; related_branches/related_paths are optional. Corrections use mem_replace, withdrawal uses mem_delete — never rewrite in place.",
298
+ description: "Store a completed task outcome in the gitmemo long-term memory (.mem git repo). Write ONLY when the task is complete AND repo-related AND the outcome is valuable/reusable OR the user explicitly asked to remember. Entries are immutable: title (single line, ≤200 chars, \"[module] action + object\"), summary (≤4000 chars), keywords (2-12, include 中英文同义词) and content (markdown body; the engine generates front matter) are all required; related_branches/related_paths are optional. kind selects the entry type: \"task\" (default) is a task-outcome record; \"topic\" is an aggregated topic page (MOC) — its summary states the currently-valid conclusions and its content links evidence entries as [[<commit-hash>]] with a one-line status each; a topic page evolves via mem_replace, not per-task rewrites. Keywords must ADD new search angles — pick task-related terms (中英文同义词 both fine) that are NOT already present in title/summary: words already in title/summary are searchable via those fields themselves, so repeating them does not improve recall. Corrections and superseded conclusions use mem_replace, withdrawal uses mem_delete — never rewrite in place.",
220
299
  parameters: {
221
300
  title: {
222
301
  type: "string",
@@ -231,12 +310,16 @@ function registerMemTools(ctx, config) {
231
310
  keywords: {
232
311
  ...KEYWORDS_ARRAY,
233
312
  required: true,
234
- description: "2-12 keywords (1-64 chars each); include 中英文同义词, e.g. [\"auth\", \"rate-limit\", \"登录\", \"限流\"]."
313
+ description: "2-12 keywords (1-64 chars each); task-related words NOT already present in title/summary (重复 title/summary 已有的词不会提高召回率); include 中英文同义词, e.g. [\"auth\", \"rate-limit\", \"登录\", \"限流\"]."
235
314
  },
236
315
  content: {
237
316
  type: "string",
238
317
  required: true,
239
- description: "The memory markdown body (engine generates front matter and standard sections)."
318
+ description: "The memory markdown body (engine generates front matter and standard sections). Reference other entries as [[<commit-hash>]]."
319
+ },
320
+ kind: {
321
+ type: "string",
322
+ description: "Entry kind: \"task\" (default) or \"topic\" (aggregated topic page linking evidence entries via [[<commit-hash>]])."
240
323
  },
241
324
  related_branches: RELATED_BRANCHES,
242
325
  related_paths: RELATED_PATHS
@@ -265,7 +348,8 @@ function registerMemTools(ctx, config) {
265
348
  keywords: args.keywords ?? [],
266
349
  content: args.content ?? "",
267
350
  related_branches: args.related_branches,
268
- related_paths: args.related_paths
351
+ related_paths: args.related_paths,
352
+ kind: args.kind
269
353
  });
270
354
  },
271
355
  presentCall: (args) => ({
@@ -313,7 +397,7 @@ function registerMemTools(ctx, config) {
313
397
  }));
314
398
  ctx.tools.register(defineTool({
315
399
  name: "mem_replace",
316
- description: "Replace an active gitmemo memory entry with a new conclusion in ONE atomic commit (deletes the old entry file, adds the new one). Use when the user corrects a stored outcome — never delete-then-write. commit_hash must be the ACTIVE hash returned by mem_search; stale hashes are rejected. Takes the same fields as mem_write.",
400
+ description: "Replace an active gitmemo memory entry with a new conclusion in ONE atomic commit (deletes the old entry file, adds the new one). Use it whenever a stored conclusion is superseded — the user corrects a stored outcome, OR this session's own work overrules it (implemented/refactored away) — never delete-then-write. commit_hash must be the ACTIVE hash returned by mem_search; stale hashes are rejected. Takes the same fields as mem_write; kind defaults to the replaced entry's kind (refreshing a topic page stays a topic page).",
317
401
  parameters: {
318
402
  commit_hash: {
319
403
  type: "string",
@@ -333,12 +417,16 @@ function registerMemTools(ctx, config) {
333
417
  keywords: {
334
418
  ...KEYWORDS_ARRAY,
335
419
  required: true,
336
- description: "2-12 keywords (1-64 chars each); include 中英文同义词."
420
+ description: "2-12 keywords (1-64 chars each); task-related words NOT already present in title/summary; include 中英文同义词."
337
421
  },
338
422
  content: {
339
423
  type: "string",
340
424
  required: true,
341
- description: "The new memory markdown body."
425
+ description: "The new memory markdown body. Keep [[<commit-hash>]] links to evidence entries up to date."
426
+ },
427
+ kind: {
428
+ type: "string",
429
+ description: "Entry kind: \"task\" or \"topic\". Defaults to the replaced entry's kind when omitted."
342
430
  },
343
431
  related_branches: RELATED_BRANCHES,
344
432
  related_paths: RELATED_PATHS
@@ -364,7 +452,8 @@ function registerMemTools(ctx, config) {
364
452
  keywords: args.keywords ?? [],
365
453
  content: args.content ?? "",
366
454
  related_branches: args.related_branches,
367
- related_paths: args.related_paths
455
+ related_paths: args.related_paths,
456
+ kind: args.kind
368
457
  });
369
458
  },
370
459
  presentCall: (args) => ({
@@ -384,9 +473,12 @@ function registerPromptSection(ctx) {
384
473
  text: [
385
474
  "This deployment provides gitmemo long-term memory: a local .mem git repository at the project root stores past task outcomes as immutable markdown entries; git is the only dependency. Use the dedicated tools only (mem_search / mem_read / mem_write / mem_delete / mem_replace — all auto-initialize, and legacy-format .mem repos migrate automatically on first use); never shell out to git or read .mem files directly.",
386
475
  "- BEFORE WORK — search: 仓库相关任务开始前提取 1–12 个中英文关键词调用 mem_search(含中英文同义词);纯闲聊和通用问答无需搜索。",
387
- "- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;无相关结果时用返回的 snapshot + skip 翻页。",
476
+ "- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;kind 为 topic 的条目是该主题的聚合入口,优先读;无相关结果时用返回的 snapshot + skip 翻页。",
388
477
  "- END-OF-SESSION CHECKPOINT — the ONLY write path: 会话结束时回顾全程,仅当结论有价值、可复用或用户明确要求记住时 mem_write;纯问答、未完成任务、非仓库工作、纯操作类 git 动作(仅 commit/push)不写;避免语义重复。",
389
- "- USER CORRECTION — 用户更正已有结论:有替代结论时调用 mem_replace(不得先 delete 再 write);结论作废且无替代时调用带 reason 的 mem_delete。",
478
+ "- KEYWORD SELECTION — mem_write/mem_replace 的 keywords 应选择与任务相关但未出现在 title/summary 中的词(中英文同义词皆可);title/summary 里已有的词本身就能被检索到,重复它们不会提高召回率。",
479
+ "- KEEP FRESH — 结论保鲜是写路径的一部分,不必等用户指出:用户更正旧结论时,有替代结论用 mem_replace(禁止先 delete 再 write),作废且无替代用带 reason 的 mem_delete;本次会话的工作覆盖/推翻了某条已存结论(被后续实现、重构取代)时,同样主动 mem_replace 或 mem_delete 对应旧条目;检索结果中新旧结论冲突时,优先采信 score 更高/日期更新的条目(score 已含温和的 recency 加成)。",
480
+ "- TOPIC PAGE — 同一主题已积累多条记忆、或需要一个「当前真相」入口时,用 mem_write 写一条 kind:\"topic\" 聚合条目:summary 概括当下仍然有效的结论,content 用 [[<commit-hash>]] 链接各证据条目并各配一句话状态;topic 条目靠 mem_replace 演进(kind 自动继承),不是按任务新增。",
481
+ "- WIKI LINKS — content 中引用其他记忆条目一律写 [[<commit-hash>]](用 mem_search/mem_read 返回的 hash);mem_read 传 expand:true 会把链接一跳展开到各条目的最新版本(悬空链接以 error 返回),读 topic 条目时建议展开。",
390
482
  "- SUBAGENT RESULTS — 子代理结果由根 Agent 汇总后决定是否形成一条会话级记忆。"
391
483
  ].join("\n")
392
484
  });
package/lib/mem.d.ts CHANGED
@@ -35,6 +35,14 @@ export interface SearchHit {
35
35
  date: string;
36
36
  summary: string;
37
37
  keywords: string[];
38
+ /** Entry kind: `task` (task-outcome record) or `topic` (aggregated topic page). */
39
+ kind: "task" | "topic";
40
+ /**
41
+ * Relevance = distinct matched keywords + a mild recency bonus (at most
42
+ * +0.5, linearly decaying to 0 between 30 and 180 days of age). The bonus
43
+ * never outweighs an extra matched keyword, so it only reorders entries
44
+ * with equal match counts.
45
+ */
38
46
  score: number;
39
47
  matched_keywords: string[];
40
48
  }
@@ -65,6 +73,12 @@ export interface WriteInput {
65
73
  content: string;
66
74
  related_branches?: string[];
67
75
  related_paths?: string[];
76
+ /**
77
+ * Entry kind. `task` (default) is a task-outcome record; `topic` is an
78
+ * aggregated topic page (MOC) whose content links evidence entries via
79
+ * `[[<commit-hash>]]` and is evolved with mem_replace, not rewritten per task.
80
+ */
81
+ kind?: "task" | "topic";
68
82
  }
69
83
  /** One-version compatibility shape accepted by the engine, not model-facing tools. */
70
84
  export interface LegacyWriteInput {
@@ -85,11 +99,41 @@ export interface WriteResult {
85
99
  file: string;
86
100
  legacy?: boolean;
87
101
  }
102
+ /** Options for {@link GitMemo.read}. */
103
+ export interface ReadOptions {
104
+ /**
105
+ * Resolve `[[<commit-hash>]]` links found in the entry content one hop to
106
+ * their target entries. Link targets automatically follow forward to the
107
+ * ACTIVE version of the linked entry file, so a topic page link survives
108
+ * its targets being replaced; the original link hash stays recorded.
109
+ */
110
+ expand?: boolean;
111
+ }
112
+ /** One-hop resolution of a `[[<commit-hash>]]` link inside an entry. */
113
+ export interface LinkedEntryResolution {
114
+ /** Hash exactly as written inside the [[...]] link (lowercased). */
115
+ link_hash: string;
116
+ /** Hash actually read: the active version of the linked entry file ("" when unresolvable). */
117
+ resolved_hash: string;
118
+ /** True when the link hash still IS the active version (false = superseded or dangling). */
119
+ active: boolean;
120
+ file?: string;
121
+ title?: string;
122
+ summary?: string;
123
+ keywords?: string[];
124
+ kind?: "task" | "topic";
125
+ /** Present instead of title/summary when the link target cannot be resolved. */
126
+ error?: string;
127
+ }
88
128
  export interface ReadResult {
89
129
  hash: string;
90
130
  file: string;
91
131
  content: string;
132
+ /** Entry kind parsed from the create/replace commit (legacy entries read as "task"). */
133
+ kind: "task" | "topic";
92
134
  legacy?: boolean;
135
+ /** Present only when the read was requested with `expand: true`. */
136
+ links?: LinkedEntryResolution[];
93
137
  }
94
138
  export interface DeleteInput {
95
139
  commit_hash: string;
@@ -104,6 +148,8 @@ export interface ParsedCommitMessage {
104
148
  summary: string;
105
149
  type: "add" | "replace" | "delete" | "unknown";
106
150
  keywords: string[];
151
+ /** Entry kind from the `GitMemo-Kind` trailer; absence means "task". */
152
+ kind: "task" | "topic";
107
153
  digest?: string;
108
154
  deletes?: string;
109
155
  replaces?: string;
@@ -176,6 +222,7 @@ export interface ValidatedWriteInput {
176
222
  content: string;
177
223
  related_branches: string[];
178
224
  related_paths: string[];
225
+ kind: "task" | "topic";
179
226
  }
180
227
  /**
181
228
  * Validate a write/replace payload (plan 5.3). `legacy` relaxes the keyword
@@ -190,7 +237,7 @@ export declare function validateWriteInput(input: WriteInput, legacy?: boolean):
190
237
  * an exact duplicate because of when/where it was written, and a withdrawn
191
238
  * conclusion can be re-written later.
192
239
  */
193
- export declare function computeDigest(title: string, summary: string, keywords: string[], content: string): string;
240
+ export declare function computeDigest(title: string, summary: string, keywords: string[], content: string, kind?: "task" | "topic"): string;
194
241
  /** UTC millisecond timestamp for filenames: `20260823T113612.123Z`. */
195
242
  export declare function utcMs(date: Date): string;
196
243
  /** ISO date for a committer timestamp; falls back to the epoch for out-of-range values. */
@@ -205,18 +252,30 @@ export declare function createEntryFile(memDir: string, baseName: string, conten
205
252
  export declare function buildEntryMarkdown(input: ValidatedWriteInput, ctx: CodeContext & {
206
253
  relatedBranches: string[];
207
254
  }, digest: string, date: Date): string;
255
+ /**
256
+ * Recency bonus for search scoring: +0.5 for entries at most 30 days old,
257
+ * linearly decaying to 0 at 180 days, 0 beyond. Bounded below one whole
258
+ * matched keyword, so freshness only reorders equal-match entries.
259
+ */
260
+ export declare function recencyBonus(committerTime: number, nowMs?: number): number;
208
261
  /** ADD commit message (plan 4.1). */
209
- export declare function buildAddMessage(title: string, summary: string, keywords: string[], digest: string, legacy?: boolean): string;
262
+ export declare function buildAddMessage(title: string, summary: string, keywords: string[], digest: string, legacy?: boolean, kind?: "task" | "topic"): string;
210
263
  /** DELETE commit message (plan 4.2). */
211
264
  export declare function buildDeleteMessage(entryName: string, reason: string, deletesHash: string): string;
212
265
  /** REPLACE commit message (plan 4.3). */
213
- export declare function buildReplaceMessage(title: string, summary: string, keywords: string[], digest: string, replacesHash: string): string;
266
+ export declare function buildReplaceMessage(title: string, summary: string, keywords: string[], digest: string, replacesHash: string, kind?: "task" | "topic"): string;
214
267
  /**
215
268
  * Parse a commit message into logical search fields. The trailer block is the
216
269
  * contiguous run at the end; everything between subject and the block is the
217
270
  * summary. Malformed input degrades to `type: "unknown"` (legacy commits).
218
271
  */
219
272
  export declare function parseCommitMessage(raw: string): ParsedCommitMessage;
273
+ /**
274
+ * Extract unique `[[<commit-hash>]]` wiki links from an entry body, in order
275
+ * of first appearance, capped at 32 per entry. Anything that is not a plain
276
+ * hex hash inside double brackets is ignored.
277
+ */
278
+ export declare function extractEntryLinks(content: string): string[];
220
279
  /**
221
280
  * Parse the NUL-delimited log stream
222
281
  * (`--format=%x00%H%x00%ct%x00%s%x00%B%x00 --name-status --no-renames`).
@@ -299,9 +358,20 @@ export declare class GitMemo {
299
358
  /**
300
359
  * Read one memory entry by create/replace commit hash. Historical entries
301
360
  * (deleted or replaced) stay readable for audit; `mem_read` accepts legacy
302
- * hashes too.
361
+ * hashes too. With `options.expand`, `[[<commit-hash>]]` links in the entry
362
+ * body are resolved one hop to their target entries (each target follows
363
+ * forward to the active version of its entry file).
364
+ */
365
+ read(commitHash: string, options?: ReadOptions): Promise<ReadResult>;
366
+ /**
367
+ * One-hop resolution of `[[<commit-hash>]]` links (caller must hold the
368
+ * operation lock). A link whose entry was later REPLACED is followed
369
+ * forward along the `GitMemo-Replaces` chain to the active version, so a
370
+ * topic page's links survive their targets being refreshed; withdrawn or
371
+ * dangling links come back with `active: false` (plus an `error` field
372
+ * when the link hash cannot be resolved at all).
303
373
  */
304
- read(commitHash: string): Promise<ReadResult>;
374
+ private resolveEntryLinks;
305
375
  /**
306
376
  * Write a memory entry (plan 5.3). Entries are immutable: each write
307
377
  * creates a brand-new file `entries/<utc-ms>-<digest-prefix>-<slug>.md`
package/lib/mem.js CHANGED
@@ -8,8 +8,8 @@
8
8
  * ADD commit; corrections use `replace` (one commit deleting the old file
9
9
  * and adding the new one), withdrawal uses `delete`.
10
10
  * - Commit messages are structured: subject = title, body = summary, then a
11
- * contiguous trailer block (`GitMemo-Type/Keyword/Digest/Deletes/Replaces/
12
- * Search-Text/Legacy`). Search only reads commit messages via
11
+ * contiguous trailer block (`GitMemo-Type/Kind/Keyword/Digest/Deletes/
12
+ * Replaces/Search-Text/Legacy`). Search only reads commit messages via
13
13
  * `git log --grep --fixed-strings`; entry bodies are never scanned.
14
14
  * - All state transitions (init/write/delete/replace/migrate) run under a
15
15
  * cross-process lock (sibling lock file + in-process mutex) with a short
@@ -306,7 +306,10 @@ export function validateWriteInput(input, legacy = false) {
306
306
  }
307
307
  related_paths = dedupe(related_paths);
308
308
  }
309
- return { title, summary, keywords, content, related_branches, related_paths };
309
+ if (input.kind !== undefined && input.kind !== "task" && input.kind !== "topic") {
310
+ throw new GitMemoError('gitmemo: kind must be "task" or "topic"');
311
+ }
312
+ return { title, summary, keywords, content, related_branches, related_paths, kind: input.kind ?? "task" };
310
313
  }
311
314
  async function adaptLegacyWriteInput(input) {
312
315
  const legacyInput = input;
@@ -342,9 +345,9 @@ async function adaptLegacyWriteInput(input) {
342
345
  * an exact duplicate because of when/where it was written, and a withdrawn
343
346
  * conclusion can be re-written later.
344
347
  */
345
- export function computeDigest(title, summary, keywords, content) {
348
+ export function computeDigest(title, summary, keywords, content, kind = "task") {
346
349
  const normalizedKeywords = [...new Set(keywords.map((k) => normalizeKeyword(k)))].sort();
347
- const payload = JSON.stringify({ title, summary, keywords: normalizedKeywords, content });
350
+ const payload = JSON.stringify({ title, summary, keywords: normalizedKeywords, content, kind });
348
351
  return createHash("sha256").update(payload, "utf8").digest("hex");
349
352
  }
350
353
  /** UTC millisecond timestamp for filenames: `20260823T113612.123Z`. */
@@ -455,6 +458,7 @@ export function buildEntryMarkdown(input, ctx, digest, date) {
455
458
  "---",
456
459
  "gitmemo_version: " + yamlJson(GITMEMO_FORMAT_VERSION),
457
460
  "date: " + yamlJson(date.toISOString()),
461
+ "kind: " + yamlJson(input.kind),
458
462
  "code_branch: " + yamlJson(ctx.branch),
459
463
  "code_commit: " + yamlJson(ctx.commit),
460
464
  "related_branches: " + yamlArray(ctx.relatedBranches),
@@ -478,9 +482,26 @@ export function buildEntryMarkdown(input, ctx, digest, date) {
478
482
  function searchTexts(title, summary, keywords) {
479
483
  return [normalizeText(title), normalizeText(summary.replace(/\s+/g, " ")), ...keywords.map((k) => normalizeText(k))];
480
484
  }
485
+ /**
486
+ * Recency bonus for search scoring: +0.5 for entries at most 30 days old,
487
+ * linearly decaying to 0 at 180 days, 0 beyond. Bounded below one whole
488
+ * matched keyword, so freshness only reorders equal-match entries.
489
+ */
490
+ export function recencyBonus(committerTime, nowMs = Date.now()) {
491
+ const ageDays = Math.max(0, (nowMs / 1000 - committerTime) / 86400);
492
+ if (ageDays <= 30)
493
+ return 0.5;
494
+ if (ageDays >= 180)
495
+ return 0;
496
+ return Math.round(((0.5 * (180 - ageDays)) / 150) * 100) / 100;
497
+ }
498
+ /** Commit trailer emitted only for topic pages (absence means "task"). */
499
+ const KIND_TRAILER_TOPIC = "GitMemo-Kind: topic";
481
500
  /** ADD commit message (plan 4.1). */
482
- export function buildAddMessage(title, summary, keywords, digest, legacy = false) {
501
+ export function buildAddMessage(title, summary, keywords, digest, legacy = false, kind = "task") {
483
502
  const lines = [title, "", summary, "", "GitMemo-Type: add"];
503
+ if (kind === "topic")
504
+ lines.push(KIND_TRAILER_TOPIC);
484
505
  if (legacy)
485
506
  lines.push("GitMemo-Legacy: true");
486
507
  for (const kw of keywords)
@@ -502,8 +523,10 @@ export function buildDeleteMessage(entryName, reason, deletesHash) {
502
523
  ].join("\n") + "\n";
503
524
  }
504
525
  /** REPLACE commit message (plan 4.3). */
505
- export function buildReplaceMessage(title, summary, keywords, digest, replacesHash) {
526
+ export function buildReplaceMessage(title, summary, keywords, digest, replacesHash, kind = "task") {
506
527
  const lines = [title, "", summary, "", "GitMemo-Type: replace", "GitMemo-Replaces: " + replacesHash];
528
+ if (kind === "topic")
529
+ lines.push(KIND_TRAILER_TOPIC);
507
530
  for (const kw of keywords)
508
531
  lines.push("GitMemo-Keyword: " + kw);
509
532
  lines.push("GitMemo-Digest: sha256:" + digest);
@@ -553,12 +576,32 @@ export function parseCommitMessage(raw) {
553
576
  summary,
554
577
  type,
555
578
  keywords: trailers.get("Keyword") ?? [],
579
+ kind: trailers.get("Kind")?.[0] === "topic" ? "topic" : "task",
556
580
  digest: trailers.get("Digest")?.[0]?.replace(/^sha256:/, "") || undefined,
557
581
  deletes: trailers.get("Deletes")?.[0] || undefined,
558
582
  replaces: trailers.get("Replaces")?.[0] || undefined,
559
583
  legacy: trailers.get("Legacy")?.[0] === "true"
560
584
  };
561
585
  }
586
+ /**
587
+ * Extract unique `[[<commit-hash>]]` wiki links from an entry body, in order
588
+ * of first appearance, capped at 32 per entry. Anything that is not a plain
589
+ * hex hash inside double brackets is ignored.
590
+ */
591
+ export function extractEntryLinks(content) {
592
+ const links = [];
593
+ const seen = new Set();
594
+ for (const match of content.matchAll(/\[\[([0-9a-fA-F]{7,64})\]\]/g)) {
595
+ const hash = match[1].toLowerCase();
596
+ if (seen.has(hash))
597
+ continue;
598
+ seen.add(hash);
599
+ links.push(hash);
600
+ if (links.length >= 32)
601
+ break;
602
+ }
603
+ return links;
604
+ }
562
605
  // ---------------------------------------------------------------------------
563
606
  // git log parsing
564
607
  // ---------------------------------------------------------------------------
@@ -1339,6 +1382,7 @@ export class GitMemo {
1339
1382
  const hits = [];
1340
1383
  const diagnostics = [...(legacySnapshot?.diagnostics ?? [])];
1341
1384
  const seenFiles = new Set();
1385
+ const nowMs = Date.now();
1342
1386
  for (const record of candidates) {
1343
1387
  if (record.added.length !== 1) {
1344
1388
  if (record.added.length > 1)
@@ -1371,20 +1415,24 @@ export class GitMemo {
1371
1415
  date: safeIsoDate(record.committerTime),
1372
1416
  summary: record.message.summary,
1373
1417
  keywords: record.message.keywords,
1374
- score: matched.length,
1418
+ kind: record.message.kind,
1419
+ score: Math.round((matched.length + recencyBonus(record.committerTime, nowMs)) * 100) / 100,
1375
1420
  matched_keywords: matched
1376
1421
  });
1377
1422
  }
1378
- // plan 6.6: score desc, committer time desc, full hash desc (deterministic)
1423
+ // plan 6.6: score desc (matches + mild recency bonus), committer time desc,
1424
+ // full hash desc (deterministic)
1379
1425
  hits.sort((a, b) => b.score - a.score || b.date.localeCompare(a.date) || (a.hash < b.hash ? 1 : a.hash > b.hash ? -1 : 0));
1380
1426
  return { hits, diagnostics };
1381
1427
  }
1382
1428
  /**
1383
1429
  * Read one memory entry by create/replace commit hash. Historical entries
1384
1430
  * (deleted or replaced) stay readable for audit; `mem_read` accepts legacy
1385
- * hashes too.
1431
+ * hashes too. With `options.expand`, `[[<commit-hash>]]` links in the entry
1432
+ * body are resolved one hop to their target entries (each target follows
1433
+ * forward to the active version of its entry file).
1386
1434
  */
1387
- async read(commitHash) {
1435
+ async read(commitHash, options = {}) {
1388
1436
  if (!/^[0-9a-f]{7,64}$/.test(commitHash))
1389
1437
  throw new GitMemoError("gitmemo: read requires a commit hash");
1390
1438
  return withLock(this.root, this.config, "read", async () => {
@@ -1401,9 +1449,123 @@ export class GitMemo {
1401
1449
  const body = await git(this.memDir, ["log", "-1", "--format=%B", full], this.config.gitTimeoutMs);
1402
1450
  const message = parseCommitMessage(body);
1403
1451
  const legacy = message.type === "unknown";
1404
- return { hash: full, file: added[0], content, ...(legacy ? { legacy: true } : {}) };
1452
+ const result = {
1453
+ hash: full,
1454
+ file: added[0],
1455
+ content,
1456
+ kind: message.kind,
1457
+ ...(legacy ? { legacy: true } : {})
1458
+ };
1459
+ if (options.expand === true) {
1460
+ result.links = await this.resolveEntryLinks(content);
1461
+ }
1462
+ return result;
1405
1463
  });
1406
1464
  }
1465
+ /**
1466
+ * One-hop resolution of `[[<commit-hash>]]` links (caller must hold the
1467
+ * operation lock). A link whose entry was later REPLACED is followed
1468
+ * forward along the `GitMemo-Replaces` chain to the active version, so a
1469
+ * topic page's links survive their targets being refreshed; withdrawn or
1470
+ * dangling links come back with `active: false` (plus an `error` field
1471
+ * when the link hash cannot be resolved at all).
1472
+ */
1473
+ async resolveEntryLinks(content) {
1474
+ const linkHashes = extractEntryLinks(content);
1475
+ if (linkHashes.length === 0)
1476
+ return [];
1477
+ let snapshot = "";
1478
+ try {
1479
+ snapshot = (await git(this.memDir, ["rev-parse", "--verify", "refs/heads/main^{commit}"], this.config.gitTimeoutMs)).trim();
1480
+ }
1481
+ catch {
1482
+ try {
1483
+ snapshot = (await git(this.memDir, ["rev-parse", "--verify", "HEAD^{commit}"], this.config.gitTimeoutMs)).trim();
1484
+ }
1485
+ catch {
1486
+ snapshot = "";
1487
+ }
1488
+ }
1489
+ const resolutions = [];
1490
+ for (const link of linkHashes) {
1491
+ try {
1492
+ const full = await resolveCommit(this.memDir, link, this.config.gitTimeoutMs);
1493
+ const added = await addedFilesOf(this.memDir, full, this.config.gitTimeoutMs);
1494
+ if (added.length !== 1) {
1495
+ resolutions.push({
1496
+ link_hash: link,
1497
+ resolved_hash: full,
1498
+ active: false,
1499
+ error: `commit ${link} maps to ${added.length} entry files — cannot resolve a single entry`
1500
+ });
1501
+ continue;
1502
+ }
1503
+ const activeHash = snapshot.length > 0
1504
+ ? (await git(this.memDir, ["log", "-1", "--format=%H", snapshot, "--", added[0]], this.config.gitTimeoutMs)).trim()
1505
+ : "";
1506
+ let resolved = full;
1507
+ let resolvedFile = added[0];
1508
+ let isActive = activeHash === full;
1509
+ if (!isActive) {
1510
+ // The linked entry is not the active version of its file — either
1511
+ // replaced (the last commit touching the old file is its replace
1512
+ // commit, or the file is gone from the active tree entirely) or
1513
+ // withdrawn. Follow the GitMemo-Replaces chain forward to the
1514
+ // active successor; when there is none the chain ends at the link.
1515
+ for (let hop = 0; hop < 16; hop += 1) {
1516
+ const successorOut = await git(this.memDir, [
1517
+ "log",
1518
+ ...(snapshot.length > 0 ? [snapshot] : []),
1519
+ "--fixed-strings",
1520
+ "--grep=GitMemo-Replaces: " + resolved,
1521
+ "--format=%H"
1522
+ ], this.config.gitTimeoutMs).catch(() => "");
1523
+ const successors = successorOut.split("\n").map((h) => h.trim()).filter((h) => COMMIT_RE.test(h));
1524
+ let next = null;
1525
+ for (const candidate of successors) {
1526
+ const candidateFiles = await addedFilesOf(this.memDir, candidate, this.config.gitTimeoutMs);
1527
+ if (candidateFiles.length !== 1)
1528
+ continue;
1529
+ const candidateActive = snapshot.length > 0
1530
+ ? (await git(this.memDir, ["log", "-1", "--format=%H", snapshot, "--", candidateFiles[0]], this.config.gitTimeoutMs)).trim()
1531
+ : "";
1532
+ if (candidateActive === candidate) {
1533
+ next = { hash: candidate, file: candidateFiles[0] };
1534
+ break;
1535
+ }
1536
+ }
1537
+ if (next === null)
1538
+ break;
1539
+ resolved = next.hash;
1540
+ resolvedFile = next.file;
1541
+ // isActive stays false: the LINK hash is not the active version —
1542
+ // only resolved_hash carries it (the "superseded" case).
1543
+ }
1544
+ }
1545
+ const body = await git(this.memDir, ["log", "-1", "--format=%B", resolved], this.config.gitTimeoutMs);
1546
+ const message = parseCommitMessage(body);
1547
+ resolutions.push({
1548
+ link_hash: link,
1549
+ resolved_hash: resolved,
1550
+ active: isActive,
1551
+ file: resolvedFile,
1552
+ title: message.subject,
1553
+ summary: message.summary,
1554
+ keywords: message.keywords,
1555
+ kind: message.kind
1556
+ });
1557
+ }
1558
+ catch (error) {
1559
+ resolutions.push({
1560
+ link_hash: link,
1561
+ resolved_hash: "",
1562
+ active: false,
1563
+ error: String(error instanceof GitMemoError ? error.message : error).replace(/^gitmemo: /, "")
1564
+ });
1565
+ }
1566
+ }
1567
+ return resolutions;
1568
+ }
1407
1569
  /**
1408
1570
  * Write a memory entry (plan 5.3). Entries are immutable: each write
1409
1571
  * creates a brand-new file `entries/<utc-ms>-<digest-prefix>-<slug>.md`
@@ -1413,7 +1575,7 @@ export class GitMemo {
1413
1575
  async write(input, legacy = false) {
1414
1576
  const adapted = legacy ? await adaptLegacyWriteInput(input) : input;
1415
1577
  const validated = validateWriteInput(adapted, legacy);
1416
- const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
1578
+ const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content, validated.kind);
1417
1579
  return withLock(this.root, this.config, "write", async () => {
1418
1580
  await this.ensureWritable();
1419
1581
  await recoverJournal(this.memDir, this.config.gitTimeoutMs);
@@ -1445,7 +1607,7 @@ export class GitMemo {
1445
1607
  try {
1446
1608
  await git(this.memDir, ["add", "--", file], this.config.gitTimeoutMs);
1447
1609
  await assertStagedExactly(this.memDir, [{ status: "A", path: file }], this.config.gitTimeoutMs);
1448
- const hash = await commitWithIdentity(this.memDir, buildAddMessage(validated.title, validated.summary, validated.keywords, digest, legacy), this.config.gitTimeoutMs);
1610
+ const hash = await commitWithIdentity(this.memDir, buildAddMessage(validated.title, validated.summary, validated.keywords, digest, legacy, validated.kind), this.config.gitTimeoutMs);
1449
1611
  await rm(journalPath(this.memDir), { force: true });
1450
1612
  return { hash, file, ...(legacy ? { legacy: true } : {}) };
1451
1613
  }
@@ -1519,8 +1681,6 @@ export class GitMemo {
1519
1681
  * legal; colliding with any OTHER active entry is rejected.
1520
1682
  */
1521
1683
  async replace(input) {
1522
- const validated = validateWriteInput(input);
1523
- const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
1524
1684
  return withLock(this.root, this.config, "replace", async () => {
1525
1685
  await this.ensureWritable();
1526
1686
  await recoverJournal(this.memDir, this.config.gitTimeoutMs);
@@ -1529,7 +1689,11 @@ export class GitMemo {
1529
1689
  const ctx = await codeContext(this.root, this.config.gitTimeoutMs);
1530
1690
  const baseHead = (await git(this.memDir, ["rev-parse", "HEAD"], this.config.gitTimeoutMs)).trim();
1531
1691
  const full = await resolveCommit(this.memDir, input.commit_hash, this.config.gitTimeoutMs);
1532
- const { file: oldFile } = await activeEntryForHash(this.memDir, full, baseHead, this.config.gitTimeoutMs);
1692
+ const { file: oldFile, message: oldMessage } = await activeEntryForHash(this.memDir, full, baseHead, this.config.gitTimeoutMs);
1693
+ // kind inherits the replaced entry's kind unless explicitly overridden,
1694
+ // so refreshing a topic page never silently demotes it to a task record.
1695
+ const validated = validateWriteInput({ ...input, kind: input.kind ?? oldMessage.kind });
1696
+ const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content, validated.kind);
1533
1697
  const { active, records, claimed } = await activeSnapshot(this.memDir, baseHead, this.config.gitTimeoutMs);
1534
1698
  void active;
1535
1699
  for (const [file, entry] of claimed) {
@@ -1560,7 +1724,7 @@ export class GitMemo {
1560
1724
  { status: "D", path: oldFile },
1561
1725
  { status: "A", path: newFile }
1562
1726
  ], this.config.gitTimeoutMs);
1563
- const hash = await commitWithIdentity(this.memDir, buildReplaceMessage(validated.title, validated.summary, validated.keywords, digest, full), this.config.gitTimeoutMs);
1727
+ const hash = await commitWithIdentity(this.memDir, buildReplaceMessage(validated.title, validated.summary, validated.keywords, digest, full, validated.kind), this.config.gitTimeoutMs);
1564
1728
  await rm(journalPath(this.memDir), { force: true });
1565
1729
  return { hash, file: newFile };
1566
1730
  }
package/lib/migrate.js CHANGED
@@ -490,7 +490,8 @@ async function migrateApplyUnlocked(root, baseline, options = {}) {
490
490
  keywords: entry.keywords,
491
491
  content: entry.content,
492
492
  related_branches: entry.relatedBranches,
493
- related_paths: entry.relatedPaths
493
+ related_paths: entry.relatedPaths,
494
+ kind: "task"
494
495
  }, { branch: entry.codeBranch, commit: entry.codeCommit, relatedBranches: entry.relatedBranches }, entry.digest, entryDate);
495
496
  const file = await createEntryFile(tmp, baseName, markdown);
496
497
  await git(tmp, ["add", "--", file], gitTimeoutMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-gitmemo",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Git-backed long-term memory for DeepSeek Harness: a local .mem git repo (single main branch, immutable entries, structured commit messages) with mem_search / mem_read / mem_write / mem_delete / mem_replace tools and fully automatic legacy migration (no CLI needed; the CLI remains for explicit control), auto-managed by the root agent with zero manual steps.",
5
5
  "author": "fonlan",
6
6
  "publishConfig": {
@@ -49,8 +49,8 @@
49
49
  "release:major": "npm version major && git push && git push --tags"
50
50
  },
51
51
  "devDependencies": {
52
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
53
- "@deepseek-ai/schemastery": "^3.18.1",
52
+ "@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
53
+ "@deepseek-ai/schemastery": "^3.18.2",
54
54
  "@types/node": "^26.2.0",
55
55
  "typescript": "^5.5.0"
56
56
  },