@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 +24 -10
- package/README.zh.md +15 -10
- package/lib/index.js +108 -16
- package/lib/mem.d.ts +75 -5
- package/lib/mem.js +182 -18
- package/lib/migrate.js +2 -1
- package/package.json +3 -3
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
+
`mem_read` 5 条最相关记忆(`kind: "topic"` 的条目是该主题的聚合入口,优先读);用 `skip` + 返回的
|
|
99
|
+
`snapshot` 翻页。
|
|
95
100
|
3. **会话结束检查点 —— 唯一的写入路径。** 对话即将结束时,`mem_write` **每一条**已完成、与仓库
|
|
96
101
|
相关、且结论**有价值/可复用**(或用户明确要求记住)但还没有记忆的任务。绝不重复写已存在的条目。
|
|
97
|
-
纯问答、未完成任务、与仓库无关的工作、纯操作性的 git
|
|
98
|
-
4.
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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 —
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
"-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
"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.
|
|
53
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
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
|
},
|