@jacklika/dsh-memory-curator 0.1.0-alpha.2 → 0.1.0-alpha.20
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 +20 -2
- package/README.zh.md +20 -2
- package/lib/index.js +394 -60
- package/lib/types/index.d.ts +21 -0
- package/package.json +12 -8
package/README.md
CHANGED
|
@@ -4,7 +4,25 @@ Curator tools for the DSH memory vault. This package is a member of the `@jackli
|
|
|
4
4
|
|
|
5
5
|
## Tools
|
|
6
6
|
|
|
7
|
-
- `memory_recall(query)` — Search the vault for prior notes before starting a task.
|
|
8
|
-
- `memory_capture(title, summary, ...)` — Persist durable knowledge after significant work, with conflict detection and optional user approval.
|
|
7
|
+
- `memory_recall(query)` — Search the vault for prior notes before starting a task. Returns `{ query, hits, total, truncated }`, where `total` is the vault-wide match count passed through from `wiki_search` and can exceed `hits.length` when either layer caps its results, and `truncated` is `true` whenever it did — recall reports its own 20-hit presentation cap, not only the search layer's — so a caller can tell "these are all the matches" from "there are more". Per-turn auto-capture notes from `memory-anchor` are hidden from the hit list and subtracted from `total`, so bookkeeping never crowds out curated knowledge; the match anchors on the notes namespace, so a curated note whose name merely shares the prefix is still returned.
|
|
8
|
+
- `memory_capture(title, summary, ...)` — Persist durable knowledge after significant work, with conflict detection and optional user approval. Exact target existence is checked directly, while conflicts use full-vault exact-title matching instead of capped broad keyword hits. Multiple exact-title matches without an explicit id fail closed instead of choosing a note. An approved write refuses a note changed during approval. Set `requireExisting: true` for an update-only capture targeting the exact derived or explicit `id` (title matches do not redirect it): it replaces the body of an existing note by default (provide the complete current summary), preserves `created` and other frontmatter fields, and fails instead of creating a missing note. Pass `mode: 'append'` explicitly only to add genuinely new material; old text remains visible. Without `requireExisting`, approved conflicts still default to append. Because changing that note is the declared intent, no conflict approval is requested. Returns `{ written, id, mode, conflict, version, created?, updated?, committed? }`: `version` is the SHA-1 of the persisted note text, `created` is present on first capture, `updated` is present on updates, and `committed` is present when `memory-git` completed the commit. `conflict` reports a detected match that needed a policy decision, so a successful update-only capture reports `false`. When a capture is refused for conflict reasons, the response also carries `suggested: { tool: 'memory_correct', id, baseVersion? }` naming the collided note (and its version when already read) so the caller can correct the existing note instead of retrying an append. The returned `id` is the actual on-disk id, including the agent namespace when private scope rewrites it. When `summary` itself begins with a Markdown heading, that heading is used as-is and no `# <title>` heading is prepended, so a caller-supplied H1 never produces duplicates. For headless runs without an approval service, the environment variable `DSH_MEMORY_CONFLICT_POLICY=skip` relaxes the default `ask` policy to `skip` — it can never tighten a configured `skip` back to `ask`, and a per-call `conflictPolicy` argument still wins; any other value fails plugin load.
|
|
9
|
+
|
|
10
|
+
- `memory_correct(id, baseVersion, summary, details?, tags?)` — Read an existing note with `wiki_read`, then submit its exact `id`, returned `version`, and complete replacement summary. It never appends, chooses by title, or creates a missing note. A stale version fails without writing; omitted tags and other frontmatter fields survive. A `summary` that begins with a Markdown heading is used as-is without a prepended `# <title>` heading. Returns the same write fields as `memory_capture`, with `mode: 'overwrite'` and `conflict: false`. DSH writes still follow the configured queue and git audit path.
|
|
11
|
+
|
|
12
|
+
- `memory_forget(id, baseVersion)` — Move a curated note into the vault archive area (`archive/<original-relative-path>`). The note is not deleted; it can still be read directly, but `wiki_search` and `wiki_graph` exclude it by default. Requires the current `version` from `wiki_read`; a stale version fails closed. The move is committed by `memory-git` when configured.
|
|
13
|
+
|
|
14
|
+
- `memory_compact(scope?, maxAgeDays?, dryRun?)` — Archive notes. With the default `scope: 'auto-capture'` it archives old per-turn auto-capture notes, keeping only the most recent sessions (disabled by default; enable with `autoCaptureCompact: { enabled: true, keepSessions: N }` in the curator config). With `scope: 'stale'` it archives any note that is both older than `maxAgeDays` (required, ≥ 1) and has no inbound `[[links]]` — link presence is derived from the active `wiki_graph` link edges, so the graph tool must be installed for stale passes. Stale compaction needs no curator config beyond the parameters. The pass is idempotent: repeated runs produce no duplicate archives. It respects the same cross-process lock lane as other mutation tools. Pass `dryRun: true` to preview the selection — it returns `{ compacted: true, dryRun: true, kept, archived: 0, archivedIds: [], wouldArchive }` and archives nothing.
|
|
15
|
+
|
|
16
|
+
## Note ids
|
|
17
|
+
|
|
18
|
+
When `id` is omitted, `memory_capture` derives it from the title: Unicode letters and digits are kept (NFC-normalized, lowercased) and every other run of characters becomes a single `-`, so CJK and other non-Latin titles stay readable and distinct. A title with no letters or digits at all falls back to `note-<12 hex chars of its sha256>`, so symbol-only captures never collapse into one shared file.
|
|
19
|
+
|
|
20
|
+
## Overwriting a capture
|
|
21
|
+
|
|
22
|
+
`mode: 'overwrite'` replaces the note body but merges its frontmatter: fields another writer recorded and the original `created` survive, `title` and `tags` come from this call, and `updated` is stamped. An intervening change detected between reading the old note and writing it fails instead of replacing that change. A first capture writes `created` only.
|
|
23
|
+
|
|
24
|
+
## Provenance fields
|
|
25
|
+
|
|
26
|
+
`memory_capture` and `memory_correct` accept optional `source_session`, `commit`, and `verified` arguments. When supplied, they are written into the note frontmatter as first-class fields for later retrieval weighting and audit. They do not change search ranking in this release.
|
|
9
27
|
|
|
10
28
|
See the Bundle documentation in `docs/usage.md` for configuration and workflow.
|
package/README.zh.md
CHANGED
|
@@ -4,7 +4,25 @@ DSH 记忆库的策展工具。本包是 `@jacklika/dsh-memory` Bundle 的成员
|
|
|
4
4
|
|
|
5
5
|
## 工具
|
|
6
6
|
|
|
7
|
-
- `memory_recall(query)` —
|
|
8
|
-
- `memory_capture(title, summary, ...)` —
|
|
7
|
+
- `memory_recall(query)` — 在任务开始前搜索知识库中的相关笔记。返回 `{ query, hits, total, truncated }`,其中 `total` 是从 `wiki_search` 透传的全库命中数;任一层截断(搜索层上限或 recall 自身的 20 条展示上限)都会使 `total` 大于 `hits.length` 且 `truncated` 为 `true`——调用方因此能区分“这就是全部命中”与“还有更多”。`memory-anchor` 写入的逐回合自动入库笔记会从命中列表中隐藏,并从 `total` 中扣除,记账内容不会挤占策展知识;匹配锚定在策展区命名空间上,仅名字前缀相同的策展笔记仍会返回。
|
|
8
|
+
- `memory_capture(title, summary, ...)` — 在有意义的任务结束后持久化知识,支持冲突检测与可选的用户审批。直接检查目标笔记是否存在;冲突搜索使用全 Vault 精确标题匹配,不受宽泛关键词搜索截断影响。未给出明确 id 且多个笔记同标题时拒绝猜选。审批期间笔记发生变化则拒绝写入。设置 `requireExisting: true` 表示只更新、不新建,严格使用标题推导或显式给出的目标 `id`(不会重定向到同标题的其他笔记):默认覆盖现有笔记的正文(`summary` 应包含完整的当前结论),保留 `created` 等既有 frontmatter 字段;笔记缺失时直接失败而不会创建。只有新增材料才显式传 `mode: 'append'`,此时旧正文仍可见。不设置 `requireExisting` 时,经批准的冲突写入仍默认追加。由于“更新这篇笔记”本身就是声明的意图,此类调用不会请求冲突审批。返回 `{ written, id, mode, conflict, version, created?, updated?, committed? }`:`version` 是持久化笔记文本的 SHA-1 指纹;首次入库返回 `created`;更新写入返回 `updated`;挂载 `memory-git` 完成提交时还会返回 `committed`。`conflict` 表示检测到的命中需要策略裁决,因此成功执行的只更新捕获会返回 `false`。当捕获因冲突被拒绝时,响应还会携带 `suggested: { tool: 'memory_correct', id, baseVersion? }`,指明发生冲突的笔记(已读取时附版本),让调用方改为修正既有笔记而不是重试追加。返回的 `id` 为实际落盘 id,私有作用域改写时包含 agent 命名空间。当 `summary` 自身以 Markdown 标题开头时,该标题按原样使用,不再前置 `# <title>`,调用方自带 H1 不会产生重复标题。无审批服务的 headless 场景下,环境变量 `DSH_MEMORY_CONFLICT_POLICY=skip` 可把默认 `ask` 策略放宽为 `skip`——它永远不能将已配置的 `skip` 收紧回 `ask`,且逐次调用的 `conflictPolicy` 参数仍优先;其他取值会导致插件加载失败。
|
|
9
|
+
|
|
10
|
+
- `memory_correct(id, baseVersion, summary, details?, tags?)` — 先 `wiki_read` 目标笔记,再传入其精确 `id`、返回的 `version` 与完整替换正文。它不追加、不按标题猜选目标、不创建缺失笔记;版本过期时拒绝写入。省略 tags 时保留原值,其他 frontmatter 字段也保留。以 Markdown 标题开头的 `summary` 按原样使用,不前置 `# <title>`。返回字段与 `memory_capture` 写入结果一致,`mode: 'overwrite'`、`conflict: false`;DSH 写入仍经过既有 queue 与 git 审计路径。
|
|
11
|
+
|
|
12
|
+
- `memory_forget(id, baseVersion)` — 将策展笔记移入归档区(`archive/<原相对路径>`)。笔记未被物理删除,仍可显式 `wiki_read` 读取,但 `wiki_search` 与 `wiki_graph` 默认不再返回它。必须传入 `wiki_read` 得到的当前 `version`;过期版本会失败关闭。若配置了 `memory-git`,此次移动会被提交。
|
|
13
|
+
|
|
14
|
+
- `memory_compact(scope?, maxAgeDays?, dryRun?)` — 归档笔记。默认 `scope: 'auto-capture'` 归档旧的逐回合自动捕获笔记,只保留最近 N 个会话(默认关闭;在 curator 配置中通过 `autoCaptureCompact: { enabled: true, keepSessions: N }` 开启)。`scope: 'stale'` 归档同时满足「mtime 早于 `maxAgeDays`(必填,≥ 1)」且「无入链 `[[links]]`」的笔记——链接关系取自 `wiki_graph` 的 link 边,因此 stale pass 需要安装 graph 工具;除参数外无需额外 curator 配置。该 pass 是幂等的:重复执行不会产生重复归档文件。它与其他变更工具使用同一跨进程锁车道。传 `dryRun: true` 可预览选中项——返回 `{ compacted: true, dryRun: true, kept, archived: 0, archivedIds: [], wouldArchive }`,不归档任何内容。
|
|
15
|
+
|
|
16
|
+
## 笔记 id
|
|
17
|
+
|
|
18
|
+
省略 `id` 时,`memory_capture` 由标题推导笔记 id:保留 Unicode 字母与数字(先做 NFC 归一化并转小写),其余连续字符统一折叠为单个 `-`,因此中日韩等非拉丁标题依然可读且互不相同。完全不含字母与数字的标题退化为 `note-<标题 sha256 的前 12 位十六进制>`,纯符号标题的写入不会坍缩到同一个文件。
|
|
19
|
+
|
|
20
|
+
## 覆盖式入库
|
|
21
|
+
|
|
22
|
+
`mode: 'overwrite'` 替换笔记正文,但会合并既有 frontmatter:其他写入方记录的字段与原始 `created` 都保留,`title` 与 `tags` 以本次调用为准,并写入 `updated`。如果从读取旧笔记到写入之间检测到其他修改,则拒绝覆盖该修改。首次入库只写 `created`。
|
|
23
|
+
|
|
24
|
+
## 来源字段
|
|
25
|
+
|
|
26
|
+
`memory_capture` 与 `memory_correct` 接受可选的 `source_session`、`commit`、`verified` 参数。传入后会被写入笔记 frontmatter,作为后续检索加权与审计的一等公民字段。当前版本暂不改变搜索排序。
|
|
9
27
|
|
|
10
28
|
配置与工作流请参阅 Bundle 文档 `docs/usage.zh.md`。
|
package/lib/index.js
CHANGED
|
@@ -7,35 +7,12 @@
|
|
|
7
7
|
* shared curated zone.
|
|
8
8
|
* @module @jacklika/dsh-memory-curator
|
|
9
9
|
*/
|
|
10
|
-
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
11
|
import z from '@deepseek-ai/schemastery';
|
|
12
12
|
import { ToolCallId } from '@deepseek-ai/dsh-llm';
|
|
13
13
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
14
|
+
import { formatBeijingTime } from '@jacklika/dsh-memory-time';
|
|
14
15
|
import yaml from 'js-yaml';
|
|
15
|
-
/**
|
|
16
|
-
* Format a Date as an ISO-like string in Asia/Shanghai (+08:00).
|
|
17
|
-
* The project convention is to store all vault timestamps in Beijing time.
|
|
18
|
-
*/
|
|
19
|
-
function formatBeijingTime(date) {
|
|
20
|
-
const parts = new Intl.DateTimeFormat('sv-SE', {
|
|
21
|
-
timeZone: 'Asia/Shanghai',
|
|
22
|
-
year: 'numeric',
|
|
23
|
-
month: '2-digit',
|
|
24
|
-
day: '2-digit',
|
|
25
|
-
hour: '2-digit',
|
|
26
|
-
minute: '2-digit',
|
|
27
|
-
second: '2-digit',
|
|
28
|
-
hour12: false,
|
|
29
|
-
}).formatToParts(date);
|
|
30
|
-
const get = (type) => parts.find((p) => p.type === type)?.value ?? '00';
|
|
31
|
-
const yyyy = get('year');
|
|
32
|
-
const MM = get('month');
|
|
33
|
-
const dd = get('day');
|
|
34
|
-
const HH = get('hour');
|
|
35
|
-
const mm = get('minute');
|
|
36
|
-
const ss = get('second');
|
|
37
|
-
return `${yyyy}-${MM}-${dd}T${HH}:${mm}:${ss}+08:00`;
|
|
38
|
-
}
|
|
39
16
|
/** Cordis plugin name used by loader diagnostics. */
|
|
40
17
|
export const name = 'memory-curator';
|
|
41
18
|
/** Services required by the curator tools. */
|
|
@@ -45,19 +22,54 @@ export const Config = z.object({
|
|
|
45
22
|
conflictPolicy: z.union(['ask', 'skip']).default('ask'),
|
|
46
23
|
defaultScope: z.union(['shared', 'private']).default('shared'),
|
|
47
24
|
notesDir: z.string().default('shared/notes'),
|
|
25
|
+
autoCaptureCompact: z.object({
|
|
26
|
+
enabled: z.boolean().default(false),
|
|
27
|
+
keepSessions: z.number().default(0),
|
|
28
|
+
}).default({ enabled: false, keepSessions: 0 }),
|
|
48
29
|
});
|
|
49
|
-
|
|
50
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Fold a title to its comparison form: NFC, case-insensitive, every run of
|
|
32
|
+
* characters that are neither Unicode letters nor digits collapsed to one
|
|
33
|
+
* space. Keeping `\p{L}` means CJK and other non-Latin titles stay distinct
|
|
34
|
+
* instead of folding to the empty string they would all share.
|
|
35
|
+
*/
|
|
36
|
+
function foldTitle(title) {
|
|
37
|
+
return title.normalize('NFC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
38
|
+
}
|
|
39
|
+
const MAX_SLUG_LENGTH = 100;
|
|
40
|
+
function hashTail(title, length = 12) {
|
|
41
|
+
return createHash('sha256').update(title.normalize('NFC')).digest('hex').slice(0, length);
|
|
51
42
|
}
|
|
52
43
|
function slugify(title) {
|
|
53
|
-
|
|
44
|
+
let slug = foldTitle(title).replace(/ /g, '-');
|
|
45
|
+
// Emoji-only and punctuation-only titles fold to nothing; a stable content
|
|
46
|
+
// hash keeps them addressable and separate rather than sharing a bare ".md".
|
|
47
|
+
if (slug === '')
|
|
48
|
+
return `note-${hashTail(title)}`;
|
|
49
|
+
// Keep generated filenames bounded on all platforms. A long CJK title would
|
|
50
|
+
// otherwise create a very long filename; truncate and append a hash so the
|
|
51
|
+
// id remains deterministic and collision-resistant.
|
|
52
|
+
if (slug.length > MAX_SLUG_LENGTH) {
|
|
53
|
+
slug = `${slug.slice(0, 64)}-${hashTail(title)}`;
|
|
54
|
+
}
|
|
55
|
+
return slug;
|
|
54
56
|
}
|
|
55
57
|
function ensureMdExtension(id) {
|
|
56
58
|
return id.endsWith('.md') ? id : `${id}.md`;
|
|
57
59
|
}
|
|
60
|
+
function isSafeNoteId(id) {
|
|
61
|
+
if (id === '' || id.startsWith('/') || id.startsWith('\\') || /^[A-Za-z]:/.test(id))
|
|
62
|
+
return false;
|
|
63
|
+
if (id.includes('\\'))
|
|
64
|
+
return false;
|
|
65
|
+
return !id.split('/').some(segment => segment === '..' || segment === '.');
|
|
66
|
+
}
|
|
58
67
|
function resolveNoteId(id, title, scope, notesDir) {
|
|
59
68
|
if (id !== undefined && id !== '') {
|
|
60
69
|
const withExt = ensureMdExtension(id);
|
|
70
|
+
if (!isSafeNoteId(withExt)) {
|
|
71
|
+
throw new Error(`memory-curator: invalid note id ${JSON.stringify(withExt)}: ids must be vault-relative paths without '..', '.', backslashes, leading slashes, or drive letters`);
|
|
72
|
+
}
|
|
61
73
|
if (scope === 'private')
|
|
62
74
|
return withExt;
|
|
63
75
|
return withExt.startsWith('shared/') ? withExt : `${notesDir}/${withExt.replace(/^\//, '')}`;
|
|
@@ -89,16 +101,69 @@ async function callTool(ctx, exec, toolName, args) {
|
|
|
89
101
|
return JSON.parse(textFromContent(result.content) || 'null');
|
|
90
102
|
}
|
|
91
103
|
function findConflicts(targetId, title, hits) {
|
|
92
|
-
const normalized =
|
|
104
|
+
const normalized = foldTitle(title);
|
|
105
|
+
const target = targetId.normalize('NFC').toLowerCase();
|
|
93
106
|
return hits.filter(hit => {
|
|
94
|
-
if (hit.id.toLowerCase() ===
|
|
107
|
+
if (hit.id.normalize('NFC').toLowerCase() === target)
|
|
95
108
|
return true;
|
|
96
|
-
|
|
109
|
+
// A title that folds to nothing carries no identity; matching it would make
|
|
110
|
+
// every untitled note a conflict with every other one.
|
|
111
|
+
return normalized !== '' && foldTitle(hit.title) === normalized;
|
|
97
112
|
});
|
|
98
113
|
}
|
|
99
114
|
function dedupeHits(hits) {
|
|
100
115
|
return [...new Map(hits.map(hit => [hit.id, hit])).values()];
|
|
101
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Frontmatter for a capture that overwrites `id`. `wiki_write` overwrite
|
|
119
|
+
* replaces the whole file, so the note's existing fields have to be carried
|
|
120
|
+
* over here or an update silently drops whatever another writer recorded and
|
|
121
|
+
* resets `created` to the update time.
|
|
122
|
+
* Capture the note version before approval so any later change makes the
|
|
123
|
+
* guarded write fail. Never treat a non-ENOENT read error as a missing note.
|
|
124
|
+
* @param ctx - registrant context carrying the tool registry.
|
|
125
|
+
* @param exec - the run context the nested read is dispatched under.
|
|
126
|
+
* @param id - vault-relative note id about to be overwritten.
|
|
127
|
+
* Existing frontmatter fields are preserved by the subsequent merge.
|
|
128
|
+
* @returns the stored frontmatter and version for a guarded write.
|
|
129
|
+
*/
|
|
130
|
+
async function readFrontmatter(ctx, exec, id) {
|
|
131
|
+
try {
|
|
132
|
+
const note = await callTool(ctx, exec, 'wiki_read', { id });
|
|
133
|
+
return {
|
|
134
|
+
frontmatter: note !== null && typeof note === 'object' && note.frontmatter !== null && typeof note.frontmatter === 'object'
|
|
135
|
+
? note.frontmatter
|
|
136
|
+
: {},
|
|
137
|
+
version: note.version,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
// Only a missing note means a first capture: `wiki_read` reports it as
|
|
142
|
+
// "no such note". Any other failure (permission denied, a directory in the
|
|
143
|
+
// way) must surface instead of silently turning an update into a replace.
|
|
144
|
+
if (error.message.includes('no such note'))
|
|
145
|
+
return undefined;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function pickProvenance(args) {
|
|
150
|
+
const picked = {};
|
|
151
|
+
if (typeof args.source_session === 'string' && args.source_session !== '')
|
|
152
|
+
picked.source_session = args.source_session;
|
|
153
|
+
if (typeof args.commit === 'string' && args.commit !== '')
|
|
154
|
+
picked.commit = args.commit;
|
|
155
|
+
if (typeof args.verified === 'boolean')
|
|
156
|
+
picked.verified = args.verified;
|
|
157
|
+
return picked;
|
|
158
|
+
}
|
|
159
|
+
function overwriteFrontmatter(existing, title, tags, now, provenance) {
|
|
160
|
+
const frontmatter = { ...existing?.frontmatter, title, tags, ...provenance };
|
|
161
|
+
const created = existing?.frontmatter.created;
|
|
162
|
+
frontmatter.created = typeof created === 'string' && created !== '' ? created : now;
|
|
163
|
+
if (existing !== undefined)
|
|
164
|
+
frontmatter.updated = now;
|
|
165
|
+
return frontmatter;
|
|
166
|
+
}
|
|
102
167
|
/**
|
|
103
168
|
* Register `memory_recall` and `memory_capture` on the tool registry.
|
|
104
169
|
* @param ctx - registrant context carrying the tool registry.
|
|
@@ -112,9 +177,18 @@ export function apply(ctx, config) {
|
|
|
112
177
|
}
|
|
113
178
|
const resolved = config;
|
|
114
179
|
const approval = ctx.get('approval');
|
|
180
|
+
// `DSH_MEMORY_CONFLICT_POLICY` is a headless escape hatch: it can only
|
|
181
|
+
// relax the policy, never tighten it — an explicit config or per-call
|
|
182
|
+
// `conflictPolicy: 'skip'`/`'ask'` still wins.
|
|
183
|
+
const envPolicyRaw = process.env.DSH_MEMORY_CONFLICT_POLICY;
|
|
184
|
+
if (envPolicyRaw !== undefined && envPolicyRaw !== 'ask' && envPolicyRaw !== 'skip') {
|
|
185
|
+
throw new Error(`memory-curator: DSH_MEMORY_CONFLICT_POLICY must be "ask" or "skip", received ${JSON.stringify(envPolicyRaw)}`);
|
|
186
|
+
}
|
|
187
|
+
const envPolicy = envPolicyRaw === 'ask' || envPolicyRaw === 'skip' ? envPolicyRaw : undefined;
|
|
188
|
+
const defaultConflictPolicy = resolved.conflictPolicy === 'skip' ? 'skip' : (envPolicy ?? 'ask');
|
|
115
189
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
116
190
|
name: 'memory_recall',
|
|
117
|
-
description: 'Search the memory vault for prior notes related to the current task or topic. Call this at the start of a development task to avoid asking the user to repeat context that is already captured. Returns matching note ids, titles, and
|
|
191
|
+
description: 'Search the memory vault for prior notes related to the current task or topic. Call this at the start of a development task to avoid asking the user to repeat context that is already captured. Query terms are OR-matched and ranked by field-weighted relevance, so partial matches still return. Returns the matching note ids, titles, scores, and backlinks, plus total — how many notes matched before the result cap — and truncated, which is true when hits omits matches. A total above the returned hit count also means the list was truncated.',
|
|
118
192
|
parameters: {
|
|
119
193
|
query: {
|
|
120
194
|
type: 'string',
|
|
@@ -127,17 +201,48 @@ export function apply(ctx, config) {
|
|
|
127
201
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
128
202
|
},
|
|
129
203
|
async execute(args, exec) {
|
|
130
|
-
const
|
|
204
|
+
const search = await callTool(ctx, exec, 'wiki_search', { query: args.query });
|
|
205
|
+
// Mechanical per-turn summaries are useful for session replay, but they
|
|
206
|
+
// are not curated knowledge. Keep them out of proactive recall unless a
|
|
207
|
+
// caller explicitly searches the wiki. Two shapes exist under the notes
|
|
208
|
+
// dir: the current subdir `auto-capture/<session>.md` and the legacy flat
|
|
209
|
+
// `auto-capture-<session>.md`. A bare prefix or substring test would also
|
|
210
|
+
// swallow unrelated notes that merely share the text, such as
|
|
211
|
+
// `auto-capture-release-0.1.0-alpha.1.md`, so the flat form is matched by
|
|
212
|
+
// the exact charset memory-anchor can emit.
|
|
213
|
+
const notesPrefix = `${resolved.notesDir}/`;
|
|
214
|
+
const isAutoCapture = (id) => {
|
|
215
|
+
if (!id.startsWith(notesPrefix))
|
|
216
|
+
return false;
|
|
217
|
+
const rest = id.slice(notesPrefix.length);
|
|
218
|
+
// The dir form is confined to the subdir, so any nested path is a capture.
|
|
219
|
+
if (rest.startsWith('auto-capture/'))
|
|
220
|
+
return true;
|
|
221
|
+
// memory-anchor's sessionNoteId sanitizes the session id to [A-Za-z0-9_-]+,
|
|
222
|
+
// so a legacy flat capture never carries another dot in its file name;
|
|
223
|
+
// `release-0.1.0-alpha.1` and other dot-bearing names stay in recall.
|
|
224
|
+
return /^auto-capture-[A-Za-z0-9_-]+\.md$/.test(rest);
|
|
225
|
+
};
|
|
226
|
+
const curated = search.hits.filter(hit => !isAutoCapture(hit.id));
|
|
227
|
+
// `search.total` is the vault-wide count before the search-layer cap. If
|
|
228
|
+
// any auto-capture hits made it into the capped `hits` list, remove them
|
|
229
|
+
// from the total so curated numbers stay truthful.
|
|
230
|
+
const total = search.total - (search.hits.length - curated.length);
|
|
231
|
+
ctx.logger?.debug(`memory-curator: recall query=${args.query} hits=${curated.length} total=${total}`);
|
|
232
|
+
const hits = curated.slice(0, 20);
|
|
131
233
|
return {
|
|
132
234
|
query: args.query,
|
|
133
|
-
hits
|
|
134
|
-
total
|
|
235
|
+
hits,
|
|
236
|
+
total,
|
|
237
|
+
// `wiki_search` reports its own cap; recall caps again at 20, so the
|
|
238
|
+
// page is incomplete whenever either cap bit.
|
|
239
|
+
truncated: search.truncated || curated.length > hits.length,
|
|
135
240
|
};
|
|
136
241
|
},
|
|
137
242
|
})));
|
|
138
243
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
139
244
|
name: 'memory_capture',
|
|
140
|
-
description: 'Persist durable knowledge to the memory vault. Call this at the end of a significant task to capture decisions, discovered APIs, project conventions, fixes, or user preferences. It checks for conflicting notes, asks for approval when a conflict is found, and writes the captured note to the shared curated zone by default.',
|
|
245
|
+
description: 'Persist durable knowledge to the memory vault. Call this at the end of a significant task to capture decisions, discovered APIs, project conventions, fixes, or user preferences. It checks for conflicting notes, asks for approval when a conflict is found, and writes the captured note to the shared curated zone by default. Set requireExisting to replace an existing note with one current version without conflict approval; use mode: append only for genuinely additive material.',
|
|
141
246
|
parameters: {
|
|
142
247
|
title: {
|
|
143
248
|
type: 'string',
|
|
@@ -147,7 +252,7 @@ export function apply(ctx, config) {
|
|
|
147
252
|
summary: {
|
|
148
253
|
type: 'string',
|
|
149
254
|
required: true,
|
|
150
|
-
description: 'The durable knowledge to record
|
|
255
|
+
description: 'The durable knowledge to record in concise Markdown. For a correction using requireExisting, provide the complete current conclusion, not only the changed fragment.',
|
|
151
256
|
},
|
|
152
257
|
details: {
|
|
153
258
|
type: 'string',
|
|
@@ -170,13 +275,29 @@ export function apply(ctx, config) {
|
|
|
170
275
|
mode: {
|
|
171
276
|
type: 'string',
|
|
172
277
|
enum: ['append', 'overwrite'],
|
|
173
|
-
description: '
|
|
278
|
+
description: 'Use overwrite when the new summary supersedes an old conclusion; it replaces the whole body. Use append only for genuinely additive material: it adds a timestamped section and leaves the old text visible. Defaults to overwrite with requireExisting, otherwise append.',
|
|
174
279
|
},
|
|
175
280
|
conflictPolicy: {
|
|
176
281
|
type: 'string',
|
|
177
282
|
enum: ['ask', 'skip'],
|
|
178
283
|
description: 'Override the deployment conflict policy for this capture: ask for approval on conflict, or skip the write.',
|
|
179
284
|
},
|
|
285
|
+
requireExisting: {
|
|
286
|
+
type: 'boolean',
|
|
287
|
+
description: 'Update-only capture: the note must exist and needs no conflict approval. Defaults to overwrite with a complete replacement summary; pass mode: append explicitly for additive material. A missing note fails instead of creating one.',
|
|
288
|
+
},
|
|
289
|
+
source_session: {
|
|
290
|
+
type: 'string',
|
|
291
|
+
description: 'Optional session id that produced this capture, stored in frontmatter for provenance.',
|
|
292
|
+
},
|
|
293
|
+
commit: {
|
|
294
|
+
type: 'string',
|
|
295
|
+
description: 'Optional upstream commit hash or reference this capture relates to, stored in frontmatter for provenance.',
|
|
296
|
+
},
|
|
297
|
+
verified: {
|
|
298
|
+
type: 'boolean',
|
|
299
|
+
description: 'Optional boolean marking the captured knowledge as verified, stored in frontmatter for provenance.',
|
|
300
|
+
},
|
|
180
301
|
},
|
|
181
302
|
output: {
|
|
182
303
|
schema: { type: 'json' },
|
|
@@ -186,22 +307,50 @@ export function apply(ctx, config) {
|
|
|
186
307
|
const scope = args.scope ?? resolved.defaultScope;
|
|
187
308
|
const notesDir = scope === 'shared' ? resolved.notesDir : '';
|
|
188
309
|
const targetId = resolveNoteId(args.id, args.title, scope, notesDir);
|
|
189
|
-
const
|
|
310
|
+
const requireExisting = args.requireExisting === true;
|
|
311
|
+
const explicitId = typeof args.id === 'string' && args.id !== '';
|
|
312
|
+
const mode = args.mode ?? (requireExisting ? 'overwrite' : 'append');
|
|
190
313
|
const tags = Array.isArray(args.tags) ? args.tags.map(String) : [];
|
|
191
|
-
const [
|
|
192
|
-
callTool(ctx, exec, 'wiki_search', { query: args.title }),
|
|
193
|
-
|
|
314
|
+
const [titleSearch, targetNote] = await Promise.all([
|
|
315
|
+
callTool(ctx, exec, 'wiki_search', { query: args.title, exactTitle: args.title }),
|
|
316
|
+
readFrontmatter(ctx, exec, targetId),
|
|
194
317
|
]);
|
|
195
|
-
const
|
|
318
|
+
const targetHit = targetNote === undefined ? [] : [{ id: targetId, title: args.title, score: 0, backlinks: [] }];
|
|
319
|
+
const conflicts = dedupeHits(findConflicts(targetId, args.title, [...targetHit, ...titleSearch.hits]));
|
|
196
320
|
const conflictExists = conflicts.length > 0;
|
|
197
|
-
const finalId = conflicts[0]?.id ?? targetId;
|
|
198
|
-
|
|
199
|
-
|
|
321
|
+
const finalId = requireExisting || explicitId || targetNote !== undefined ? targetId : conflicts[0]?.id ?? targetId;
|
|
322
|
+
// A conflict means the knowledge probably already has a home: point the
|
|
323
|
+
// caller at the correction tool for the note it collided with instead of
|
|
324
|
+
// letting a retry append a second, contradictory copy. The suggested
|
|
325
|
+
// baseVersion is included whenever the note was already read.
|
|
326
|
+
const correctionHint = (version) => ({
|
|
327
|
+
tool: 'memory_correct',
|
|
328
|
+
id: finalId,
|
|
329
|
+
...(version !== undefined ? { baseVersion: version } : {}),
|
|
330
|
+
});
|
|
331
|
+
if (!requireExisting && !explicitId && targetNote === undefined && (titleSearch.truncated || conflicts.length > 1)) {
|
|
332
|
+
return {
|
|
333
|
+
written: false,
|
|
334
|
+
id: targetId,
|
|
335
|
+
conflict: true,
|
|
336
|
+
...(conflicts.length > 0 ? { suggested: correctionHint() } : {}),
|
|
337
|
+
reason: 'ambiguous or truncated conflict search; provide the exact existing note id before writing',
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const selectedNote = finalId === targetId ? targetNote : await readFrontmatter(ctx, exec, finalId);
|
|
341
|
+
const approvedVersion = selectedNote?.version;
|
|
342
|
+
// A declared update-only capture targets an existing note by
|
|
343
|
+
// construction, so the approval that guards against accidentally
|
|
344
|
+
// clobbering one while creating is not required; `wiki_write` enforces
|
|
345
|
+
// the existence requirement itself, atomically with the write.
|
|
346
|
+
if (conflictExists && !requireExisting) {
|
|
347
|
+
const policy = args.conflictPolicy ?? defaultConflictPolicy;
|
|
200
348
|
if (policy === 'skip') {
|
|
201
349
|
return {
|
|
202
350
|
written: false,
|
|
203
351
|
id: finalId,
|
|
204
352
|
conflict: true,
|
|
353
|
+
suggested: correctionHint(approvedVersion),
|
|
205
354
|
reason: 'skipped by conflictPolicy=skip',
|
|
206
355
|
};
|
|
207
356
|
}
|
|
@@ -210,6 +359,7 @@ export function apply(ctx, config) {
|
|
|
210
359
|
written: false,
|
|
211
360
|
id: finalId,
|
|
212
361
|
conflict: true,
|
|
362
|
+
suggested: correctionHint(approvedVersion),
|
|
213
363
|
reason: 'approval service or agent unavailable; set conflictPolicy=skip to allow silent skip in headless mode',
|
|
214
364
|
};
|
|
215
365
|
}
|
|
@@ -218,9 +368,9 @@ export function apply(ctx, config) {
|
|
|
218
368
|
agent: exec.agent,
|
|
219
369
|
toolName: 'memory_capture',
|
|
220
370
|
callId: exec.callId,
|
|
221
|
-
reason: `memory_capture conflicts with existing note(s):\n${conflictList}\nProposed action: ${mode === 'append' ? 'append a new section to' : '
|
|
371
|
+
reason: `memory_capture conflicts with existing note(s):\n${conflictList}\nProposed action: ${mode === 'append' ? 'append a new section (old text remains visible) to' : 'replace the entire body of'} "${finalId}".`,
|
|
222
372
|
displayReason: {
|
|
223
|
-
en: `Memory capture conflicts with existing note(s):\n${conflictList}\nAllow ${mode} to "${finalId}"
|
|
373
|
+
en: `Memory capture conflicts with existing note(s):\n${conflictList}\nAllow ${mode} to "${finalId}"? ${mode === 'append' ? 'Old conclusions remain visible.' : 'The old body is replaced.'}`,
|
|
224
374
|
},
|
|
225
375
|
signal: exec.signal,
|
|
226
376
|
});
|
|
@@ -229,6 +379,7 @@ export function apply(ctx, config) {
|
|
|
229
379
|
written: false,
|
|
230
380
|
id: finalId,
|
|
231
381
|
conflict: true,
|
|
382
|
+
suggested: correctionHint(approvedVersion),
|
|
232
383
|
approval: outcome,
|
|
233
384
|
reason: 'user did not approve the conflict',
|
|
234
385
|
};
|
|
@@ -237,25 +388,208 @@ export function apply(ctx, config) {
|
|
|
237
388
|
const now = formatBeijingTime(new Date());
|
|
238
389
|
const detailsText = args.details ? `\n\n## Details\n\n${args.details}` : '';
|
|
239
390
|
const appendContent = `${args.summary}${detailsText}\n`;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const
|
|
391
|
+
// A conflict suggests the note exists, but update-only writes use the
|
|
392
|
+
// exact target id and ask the vault instead of relying on search ranking.
|
|
393
|
+
// A missing note fails the write; other captures stay conflict-driven
|
|
394
|
+
// and create new notes. Overwrites carry the read version to wiki_write
|
|
395
|
+
// so an intervening change is refused rather than silently replaced.
|
|
396
|
+
// Append remains available when the caller requests additive material.
|
|
397
|
+
const exists = requireExisting ? targetNote !== undefined : conflictExists;
|
|
398
|
+
const appending = exists && mode === 'append';
|
|
248
399
|
const writeMode = appending ? 'append' : 'overwrite';
|
|
249
|
-
|
|
400
|
+
const provenance = pickProvenance(args);
|
|
401
|
+
ctx.logger?.debug(`memory-curator: capture target=${finalId} conflicts=${conflicts.length} writeMode=${writeMode} requireExisting=${requireExisting}`);
|
|
402
|
+
const overwrite = appending ? undefined : overwriteFrontmatter(selectedNote, args.title, tags, now, provenance);
|
|
403
|
+
const headingPrefix = args.summary.trimStart().startsWith('#') ? '' : `# ${args.title}\n\n`;
|
|
404
|
+
const content = appending
|
|
405
|
+
? appendContent
|
|
406
|
+
: `---\n${yaml.dump(overwrite).trim()}\n---\n\n${headingPrefix}${appendContent}`;
|
|
407
|
+
const writeResult = await callTool(ctx, exec, 'wiki_write', {
|
|
250
408
|
id: finalId,
|
|
251
409
|
content,
|
|
252
410
|
mode: writeMode,
|
|
411
|
+
baseVersion: approvedVersion ?? createHash('sha1').update('').digest('hex'),
|
|
412
|
+
...(requireExisting || (conflictExists && !explicitId) ? { requireExisting: true } : {}),
|
|
253
413
|
});
|
|
254
|
-
|
|
414
|
+
const response = {
|
|
255
415
|
written: true,
|
|
256
|
-
id:
|
|
416
|
+
id: writeResult.id,
|
|
257
417
|
mode: writeMode,
|
|
258
|
-
conflict:
|
|
418
|
+
// A declared update is not a conflict: the caller named the note it
|
|
419
|
+
// meant to change, so nothing required approval or a skip decision.
|
|
420
|
+
conflict: conflictExists && !requireExisting,
|
|
421
|
+
version: writeResult.version,
|
|
422
|
+
};
|
|
423
|
+
if (typeof writeResult.created === 'string' && writeResult.created !== '')
|
|
424
|
+
response.created = writeResult.created;
|
|
425
|
+
if (typeof writeResult.updated === 'string' && writeResult.updated !== '')
|
|
426
|
+
response.updated = writeResult.updated;
|
|
427
|
+
if (typeof writeResult.committed === 'boolean')
|
|
428
|
+
response.committed = writeResult.committed;
|
|
429
|
+
return response;
|
|
430
|
+
},
|
|
431
|
+
})));
|
|
432
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
433
|
+
name: 'memory_correct',
|
|
434
|
+
description: 'Replace one existing curated note with a single complete current conclusion. Read the note with wiki_read first, then provide its exact id and version as baseVersion. Refuses a missing or changed note; never appends or selects a note by title.',
|
|
435
|
+
parameters: {
|
|
436
|
+
id: { type: 'string', required: true, description: 'Exact vault-relative id returned by wiki_read; never inferred from a title.' },
|
|
437
|
+
baseVersion: { type: 'string', required: true, description: 'Version from wiki_read. A concurrent change refuses the correction.' },
|
|
438
|
+
summary: { type: 'string', required: true, description: 'Complete current conclusion in Markdown, replacing the old body.' },
|
|
439
|
+
details: { type: 'string', description: 'Optional additional context included in the new current body.' },
|
|
440
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional replacement tags; omitted tags retain existing tags.' },
|
|
441
|
+
source_session: { type: 'string', description: 'Optional session id that produced this correction, stored in frontmatter for provenance.' },
|
|
442
|
+
commit: { type: 'string', description: 'Optional upstream commit hash or reference this correction relates to, stored in frontmatter for provenance.' },
|
|
443
|
+
verified: { type: 'boolean', description: 'Optional boolean marking the corrected knowledge as verified, stored in frontmatter for provenance.' },
|
|
444
|
+
},
|
|
445
|
+
output: {
|
|
446
|
+
schema: { type: 'json' },
|
|
447
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
448
|
+
},
|
|
449
|
+
async execute(args, exec) {
|
|
450
|
+
if (!isSafeNoteId(args.id) || !args.id.endsWith('.md'))
|
|
451
|
+
throw new Error('memory-curator: correction requires a safe vault-relative .md note id');
|
|
452
|
+
const note = await callTool(ctx, exec, 'wiki_read', { id: args.id });
|
|
453
|
+
if (note.version !== args.baseVersion)
|
|
454
|
+
throw new Error(`memory-curator: note ${args.id} changed since it was read; re-read it before correcting`);
|
|
455
|
+
const now = formatBeijingTime(new Date());
|
|
456
|
+
const existing = note.frontmatter ?? {};
|
|
457
|
+
const heading = /^#\s+(.+)$/m.exec(note.body)?.[1];
|
|
458
|
+
const title = typeof existing.title === 'string' && existing.title !== '' ? existing.title : heading ?? args.id.replace(/\.md$/, '');
|
|
459
|
+
const correctionProvenance = pickProvenance(args);
|
|
460
|
+
const frontmatter = {
|
|
461
|
+
...existing,
|
|
462
|
+
title,
|
|
463
|
+
...(args.tags !== undefined ? { tags: args.tags.map(String) } : {}),
|
|
464
|
+
created: typeof existing.created === 'string' && existing.created !== '' ? existing.created : now,
|
|
465
|
+
updated: now,
|
|
466
|
+
...correctionProvenance,
|
|
467
|
+
};
|
|
468
|
+
const details = args.details ? `\n\n## Details\n\n${args.details}` : '';
|
|
469
|
+
const headingPrefix = args.summary.trimStart().startsWith('#') ? '' : `# ${title}\n\n`;
|
|
470
|
+
const content = `---\n${yaml.dump(frontmatter).trim()}\n---\n\n${headingPrefix}${args.summary}${details}\n`;
|
|
471
|
+
const written = await callTool(ctx, exec, 'wiki_write', {
|
|
472
|
+
id: args.id, content, mode: 'overwrite', requireExisting: true, baseVersion: args.baseVersion,
|
|
473
|
+
});
|
|
474
|
+
return {
|
|
475
|
+
written: true, id: written.id, mode: 'overwrite', conflict: false, version: written.version,
|
|
476
|
+
...(written.created !== undefined ? { created: written.created } : {}),
|
|
477
|
+
...(written.updated !== undefined ? { updated: written.updated } : {}),
|
|
478
|
+
...(written.committed !== undefined ? { committed: written.committed } : {}),
|
|
479
|
+
};
|
|
480
|
+
},
|
|
481
|
+
})));
|
|
482
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
483
|
+
name: 'memory_compact',
|
|
484
|
+
description: 'Archive old per-turn auto-capture notes, keeping only the most recent sessions. This reduces search noise while preserving every capture in git history. Disabled by default; enable via autoCaptureCompact in the curator config. Returns { compacted, kept, archived, archivedIds } when active, or { compacted: false, reason } when disabled. Pass dryRun: true to preview the same selection without archiving anything. With scope: \'stale\', archives indexed notes whose last modification is older than maxAgeDays and that have no inbound explicit links; dryRun previews that selection too.',
|
|
485
|
+
parameters: {
|
|
486
|
+
dryRun: { type: 'boolean', description: 'Preview which notes would be archived without writing. Defaults to false.' },
|
|
487
|
+
scope: { type: 'string', enum: ['auto-capture', 'stale'], description: "What to compact: 'auto-capture' (default) prunes old capture sessions; 'stale' archives notes older than maxAgeDays with no inbound explicit links." },
|
|
488
|
+
maxAgeDays: { type: 'number', description: "Required when scope is 'stale': notes whose recorded mtime is older than this many days are candidates." },
|
|
489
|
+
},
|
|
490
|
+
output: {
|
|
491
|
+
schema: { type: 'json' },
|
|
492
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
493
|
+
},
|
|
494
|
+
async execute(args, exec) {
|
|
495
|
+
const notesPrefix = `${resolved.notesDir}/`;
|
|
496
|
+
const isAutoCapture = (id) => {
|
|
497
|
+
if (!id.startsWith(notesPrefix))
|
|
498
|
+
return false;
|
|
499
|
+
const rest = id.slice(notesPrefix.length);
|
|
500
|
+
if (rest.startsWith('auto-capture/'))
|
|
501
|
+
return true;
|
|
502
|
+
return /^auto-capture-[A-Za-z0-9_-]+\.md$/.test(rest);
|
|
503
|
+
};
|
|
504
|
+
if (args.scope === 'stale') {
|
|
505
|
+
const days = typeof args.maxAgeDays === 'number' && args.maxAgeDays > 0 ? args.maxAgeDays : undefined;
|
|
506
|
+
if (days === undefined) {
|
|
507
|
+
return { compacted: false, reason: "scope 'stale' requires maxAgeDays > 0" };
|
|
508
|
+
}
|
|
509
|
+
const graph = await callTool(ctx, exec, 'wiki_graph', { maxNodes: 2000, maxEdges: 20000 });
|
|
510
|
+
// A capped view cannot prove "no inbound links" — archiving on a
|
|
511
|
+
// partial graph would hide notes that are actually referenced.
|
|
512
|
+
if (graph.truncated) {
|
|
513
|
+
return { compacted: false, reason: 'vault graph exceeds the node cap; stale compaction refuses a partial view' };
|
|
514
|
+
}
|
|
515
|
+
const inbound = new Set();
|
|
516
|
+
for (const edge of graph.edges) {
|
|
517
|
+
if (edge.type !== 'keyword')
|
|
518
|
+
inbound.add(edge.to);
|
|
519
|
+
}
|
|
520
|
+
const cutoff = Date.now() - days * 86_400_000;
|
|
521
|
+
const candidates = [];
|
|
522
|
+
for (const node of graph.nodes) {
|
|
523
|
+
if (inbound.has(node.id) || isAutoCapture(node.id))
|
|
524
|
+
continue;
|
|
525
|
+
const note = await callTool(ctx, exec, 'wiki_read', { id: node.id });
|
|
526
|
+
const mtime = typeof note.mtime === 'string' ? Date.parse(note.mtime) : NaN;
|
|
527
|
+
if (Number.isFinite(mtime) && mtime < cutoff)
|
|
528
|
+
candidates.push({ id: node.id, version: note.version });
|
|
529
|
+
}
|
|
530
|
+
candidates.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
531
|
+
if (args.dryRun === true) {
|
|
532
|
+
return {
|
|
533
|
+
compacted: true,
|
|
534
|
+
dryRun: true,
|
|
535
|
+
scope: 'stale',
|
|
536
|
+
maxAgeDays: days,
|
|
537
|
+
archived: 0,
|
|
538
|
+
archivedIds: [],
|
|
539
|
+
wouldArchive: candidates.map(c => c.id),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const archivedIds = [];
|
|
543
|
+
for (const candidate of candidates) {
|
|
544
|
+
const result = await callTool(ctx, exec, 'memory_forget', { id: candidate.id, baseVersion: candidate.version });
|
|
545
|
+
if (result.forgotten)
|
|
546
|
+
archivedIds.push(result.id);
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
compacted: true,
|
|
550
|
+
scope: 'stale',
|
|
551
|
+
maxAgeDays: days,
|
|
552
|
+
archived: archivedIds.length,
|
|
553
|
+
archivedIds,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const compact = resolved.autoCaptureCompact;
|
|
557
|
+
if (!compact.enabled) {
|
|
558
|
+
return { compacted: false, reason: 'auto-capture compaction is disabled in curator config' };
|
|
559
|
+
}
|
|
560
|
+
const keep = typeof compact.keepSessions === 'number' && compact.keepSessions >= 1 ? Math.floor(compact.keepSessions) : 0;
|
|
561
|
+
if (keep < 1) {
|
|
562
|
+
return { compacted: false, reason: 'autoCaptureCompact.keepSessions must be >= 1' };
|
|
563
|
+
}
|
|
564
|
+
const search = await callTool(ctx, exec, 'wiki_search', { query: 'auto-capture', maxResults: 10000 });
|
|
565
|
+
const captures = dedupeHits(search.hits.filter(hit => isAutoCapture(hit.id)))
|
|
566
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
567
|
+
if (captures.length <= keep) {
|
|
568
|
+
return { compacted: true, kept: captures.length, archived: 0, archivedIds: [] };
|
|
569
|
+
}
|
|
570
|
+
const toArchive = captures.slice(keep);
|
|
571
|
+
if (args.dryRun === true) {
|
|
572
|
+
return {
|
|
573
|
+
compacted: true,
|
|
574
|
+
dryRun: true,
|
|
575
|
+
kept: keep,
|
|
576
|
+
archived: 0,
|
|
577
|
+
archivedIds: [],
|
|
578
|
+
wouldArchive: toArchive.map(hit => hit.id),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const archivedIds = [];
|
|
582
|
+
for (const hit of toArchive) {
|
|
583
|
+
const note = await callTool(ctx, exec, 'wiki_read', { id: hit.id });
|
|
584
|
+
const result = await callTool(ctx, exec, 'memory_forget', { id: hit.id, baseVersion: note.version });
|
|
585
|
+
if (result.forgotten)
|
|
586
|
+
archivedIds.push(result.id);
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
compacted: true,
|
|
590
|
+
kept: keep,
|
|
591
|
+
archived: archivedIds.length,
|
|
592
|
+
archivedIds,
|
|
259
593
|
};
|
|
260
594
|
},
|
|
261
595
|
})));
|
package/lib/types/index.d.ts
CHANGED
|
@@ -30,6 +30,27 @@ export interface Config {
|
|
|
30
30
|
defaultScope?: 'shared' | 'private';
|
|
31
31
|
/** Vault-relative directory where shared curated notes live. */
|
|
32
32
|
notesDir?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Optional compaction of per-turn auto-capture notes. Disabled by default;
|
|
35
|
+
* when enabled, old session captures are archived and no longer appear in
|
|
36
|
+
* default search results.
|
|
37
|
+
*/
|
|
38
|
+
autoCaptureCompact?: AutoCaptureCompactConfig;
|
|
39
|
+
}
|
|
40
|
+
/** Configuration for the optional auto-capture compaction pass. */
|
|
41
|
+
export interface AutoCaptureCompactConfig {
|
|
42
|
+
/**
|
|
43
|
+
* Whether the compaction pass is active. Default false: without explicit
|
|
44
|
+
* opt-in, `memory_compact` is a no-op so a caller cannot accidentally archive
|
|
45
|
+
* human-written notes.
|
|
46
|
+
*/
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* How many recent auto-capture sessions to keep in the active vault. Older
|
|
50
|
+
* captures are moved to `archive/` by `memory_forget`. Must be >= 1 when
|
|
51
|
+
* enabled.
|
|
52
|
+
*/
|
|
53
|
+
keepSessions?: number;
|
|
33
54
|
}
|
|
34
55
|
/** Schemastery configuration for the memory curator consumer. */
|
|
35
56
|
export declare const Config: z<Config>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacklika/dsh-memory-curator",
|
|
3
3
|
"description": "Curator tools for the DSH memory vault: memory_recall before acting and memory_capture after significant work, with conflict detection and optional user approval.",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.20",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -32,17 +32,21 @@
|
|
|
32
32
|
"license": "MIT",
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@deepseek-ai/schemastery": "3.18.4",
|
|
35
|
-
"js-yaml": "^4.2.0"
|
|
35
|
+
"js-yaml": "^4.2.0",
|
|
36
|
+
"@jacklika/dsh-memory-time": "^0.1.0-alpha.0"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
39
|
"@deepseek-ai/cordis": "4.0.4",
|
|
39
|
-
"@deepseek-ai/dsh-agent": ">=0.1.7-rc.1 <0.3.0-0",
|
|
40
|
-
"@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.3.0-0",
|
|
41
|
-
"@deepseek-ai/dsh-tools": ">=0.1.7-rc.1 <0.3.0-0",
|
|
42
|
-
"@deepseek-ai/dsh-user-approval": ">=0.1.7-rc.1 <0.3.0-0",
|
|
43
|
-
"@deepseek-ai/dsh-util-values": ">=0.1.7-rc.1 <0.3.0-0"
|
|
40
|
+
"@deepseek-ai/dsh-agent": ">=0.1.7-rc.1 <0.3.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
41
|
+
"@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.3.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
42
|
+
"@deepseek-ai/dsh-tools": ">=0.1.7-rc.1 <0.3.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
43
|
+
"@deepseek-ai/dsh-user-approval": ">=0.1.7-rc.1 <0.3.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
44
|
+
"@deepseek-ai/dsh-util-values": ">=0.1.7-rc.1 <0.3.0-0 || >=0.2.0-rc.1 <0.3.0-0"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
|
-
"@jacklika/dsh-
|
|
47
|
+
"@jacklika/dsh-memory-git": "^0.1.0-alpha.9",
|
|
48
|
+
"@jacklika/dsh-tool-memory-filesystem": "^0.1.3-alpha.19",
|
|
49
|
+
"@jacklika/dsh-memory-scope": "^0.1.0-alpha.4",
|
|
50
|
+
"@jacklika/dsh-tool-memory-graph": "^0.1.0-alpha.13"
|
|
47
51
|
}
|
|
48
52
|
}
|