@jacklika/dsh-memory-curator 0.1.0-alpha.1 → 0.1.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,15 @@ 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. 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 appends, and `committed` is present when `memory-git` completed the commit.
9
+
10
+ ## Note ids
11
+
12
+ 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.
13
+
14
+ ## Overwriting a capture
15
+
16
+ `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. A first capture writes `created` only.
9
17
 
10
18
  See the Bundle documentation in `docs/usage.md` for configuration and workflow.
package/README.zh.md CHANGED
@@ -4,7 +4,15 @@ 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, ...)` — 在有意义的任务结束后持久化知识,支持冲突检测与可选的用户审批。返回 `{ written, id, mode, conflict, version, created?, updated?, committed? }`:`version` 是持久化笔记文本的 SHA-1 指纹;首次入库返回 `created`;追加写入返回 `updated`;挂载 `memory-git` 完成提交时还会返回 `committed`。
9
+
10
+ ## 笔记 id
11
+
12
+ 省略 `id` 时,`memory_capture` 由标题推导笔记 id:保留 Unicode 字母与数字(先做 NFC 归一化并转小写),其余连续字符统一折叠为单个 `-`,因此中日韩等非拉丁标题依然可读且互不相同。完全不含字母与数字的标题退化为 `note-<标题 sha256 的前 12 位十六进制>`,纯符号标题的写入不会坍缩到同一个文件。
13
+
14
+ ## 覆盖式入库
15
+
16
+ `mode: 'overwrite'` 替换笔记正文,但会合并既有 frontmatter:其他写入方记录的字段与原始 `created` 都保留,`title` 与 `tags` 以本次调用为准,并写入 `updated`。首次入库只写 `created`。
9
17
 
10
18
  配置与工作流请参阅 Bundle 文档 `docs/usage.zh.md`。
package/lib/index.js CHANGED
@@ -7,10 +7,11 @@
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
  /** Cordis plugin name used by loader diagnostics. */
16
17
  export const name = 'memory-curator';
@@ -22,18 +23,49 @@ export const Config = z.object({
22
23
  defaultScope: z.union(['shared', 'private']).default('shared'),
23
24
  notesDir: z.string().default('shared/notes'),
24
25
  });
25
- function normalizeTitle(title) {
26
- return title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
26
+ /**
27
+ * Fold a title to its comparison form: NFC, case-insensitive, every run of
28
+ * characters that are neither Unicode letters nor digits collapsed to one
29
+ * space. Keeping `\p{L}` means CJK and other non-Latin titles stay distinct
30
+ * instead of folding to the empty string they would all share.
31
+ */
32
+ function foldTitle(title) {
33
+ return title.normalize('NFC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
34
+ }
35
+ const MAX_SLUG_LENGTH = 100;
36
+ function hashTail(title, length = 12) {
37
+ return createHash('sha256').update(title.normalize('NFC')).digest('hex').slice(0, length);
27
38
  }
28
39
  function slugify(title) {
29
- return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
40
+ let slug = foldTitle(title).replace(/ /g, '-');
41
+ // Emoji-only and punctuation-only titles fold to nothing; a stable content
42
+ // hash keeps them addressable and separate rather than sharing a bare ".md".
43
+ if (slug === '')
44
+ return `note-${hashTail(title)}`;
45
+ // Keep generated filenames bounded on all platforms. A long CJK title would
46
+ // otherwise create a very long filename; truncate and append a hash so the
47
+ // id remains deterministic and collision-resistant.
48
+ if (slug.length > MAX_SLUG_LENGTH) {
49
+ slug = `${slug.slice(0, 64)}-${hashTail(title)}`;
50
+ }
51
+ return slug;
30
52
  }
31
53
  function ensureMdExtension(id) {
32
54
  return id.endsWith('.md') ? id : `${id}.md`;
33
55
  }
56
+ function isSafeNoteId(id) {
57
+ if (id === '' || id.startsWith('/') || id.startsWith('\\') || /^[A-Za-z]:/.test(id))
58
+ return false;
59
+ if (id.includes('\\'))
60
+ return false;
61
+ return !id.split('/').some(segment => segment === '..' || segment === '.');
62
+ }
34
63
  function resolveNoteId(id, title, scope, notesDir) {
35
64
  if (id !== undefined && id !== '') {
36
65
  const withExt = ensureMdExtension(id);
66
+ if (!isSafeNoteId(withExt)) {
67
+ throw new Error(`memory-curator: invalid note id ${JSON.stringify(withExt)}: ids must be vault-relative paths without '..', '.', backslashes, leading slashes, or drive letters`);
68
+ }
37
69
  if (scope === 'private')
38
70
  return withExt;
39
71
  return withExt.startsWith('shared/') ? withExt : `${notesDir}/${withExt.replace(/^\//, '')}`;
@@ -65,16 +97,50 @@ async function callTool(ctx, exec, toolName, args) {
65
97
  return JSON.parse(textFromContent(result.content) || 'null');
66
98
  }
67
99
  function findConflicts(targetId, title, hits) {
68
- const normalized = normalizeTitle(title);
100
+ const normalized = foldTitle(title);
101
+ const target = targetId.normalize('NFC').toLowerCase();
69
102
  return hits.filter(hit => {
70
- if (hit.id.toLowerCase() === targetId.toLowerCase())
103
+ if (hit.id.normalize('NFC').toLowerCase() === target)
71
104
  return true;
72
- return normalizeTitle(hit.title) === normalized;
105
+ // A title that folds to nothing carries no identity; matching it would make
106
+ // every untitled note a conflict with every other one.
107
+ return normalized !== '' && foldTitle(hit.title) === normalized;
73
108
  });
74
109
  }
75
110
  function dedupeHits(hits) {
76
111
  return [...new Map(hits.map(hit => [hit.id, hit])).values()];
77
112
  }
113
+ /**
114
+ * Frontmatter for a capture that overwrites `id`. `wiki_write` overwrite
115
+ * replaces the whole file, so the note's existing fields have to be carried
116
+ * over here or an update silently drops whatever another writer recorded and
117
+ * resets `created` to the update time.
118
+ * @param ctx - registrant context carrying the tool registry.
119
+ * @param exec - the run context the nested read is dispatched under.
120
+ * @param id - vault-relative note id about to be overwritten.
121
+ * @param title - title from this capture; it wins over any stored one.
122
+ * @param tags - tags from this capture; they win over any stored ones.
123
+ * @param now - Beijing-time timestamp used for `created`/`updated`.
124
+ * @returns the merged frontmatter to serialize.
125
+ */
126
+ async function overwriteFrontmatter(ctx, exec, id, title, tags, now) {
127
+ let existing;
128
+ try {
129
+ const note = await callTool(ctx, exec, 'wiki_read', { id });
130
+ if (note !== null && typeof note === 'object' && note.frontmatter !== null && typeof note.frontmatter === 'object') {
131
+ existing = note.frontmatter;
132
+ }
133
+ }
134
+ catch {
135
+ // No readable note at this id: the ordinary first capture of a new note.
136
+ }
137
+ const frontmatter = { ...existing, title, tags };
138
+ const created = existing?.created;
139
+ frontmatter.created = typeof created === 'string' && created !== '' ? created : now;
140
+ if (existing !== undefined)
141
+ frontmatter.updated = now;
142
+ return frontmatter;
143
+ }
78
144
  /**
79
145
  * Register `memory_recall` and `memory_capture` on the tool registry.
80
146
  * @param ctx - registrant context carrying the tool registry.
@@ -90,7 +156,7 @@ export function apply(ctx, config) {
90
156
  const approval = ctx.get('approval');
91
157
  ctx.effect(() => ctx.tools.register(defineTool({
92
158
  name: 'memory_recall',
93
- 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 backlink counts.',
159
+ 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.',
94
160
  parameters: {
95
161
  query: {
96
162
  type: 'string',
@@ -103,11 +169,42 @@ export function apply(ctx, config) {
103
169
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
104
170
  },
105
171
  async execute(args, exec) {
106
- const hits = await callTool(ctx, exec, 'wiki_search', { query: args.query });
172
+ const search = await callTool(ctx, exec, 'wiki_search', { query: args.query });
173
+ // Mechanical per-turn summaries are useful for session replay, but they
174
+ // are not curated knowledge. Keep them out of proactive recall unless a
175
+ // caller explicitly searches the wiki. Two shapes exist under the notes
176
+ // dir: the current subdir `auto-capture/<session>.md` and the legacy flat
177
+ // `auto-capture-<session>.md`. A bare prefix or substring test would also
178
+ // swallow unrelated notes that merely share the text, such as
179
+ // `auto-capture-release-0.1.0-alpha.1.md`, so the flat form is matched by
180
+ // the exact charset memory-anchor can emit.
181
+ const notesPrefix = `${resolved.notesDir}/`;
182
+ const isAutoCapture = (id) => {
183
+ if (!id.startsWith(notesPrefix))
184
+ return false;
185
+ const rest = id.slice(notesPrefix.length);
186
+ // The dir form is confined to the subdir, so any nested path is a capture.
187
+ if (rest.startsWith('auto-capture/'))
188
+ return true;
189
+ // memory-anchor's sessionNoteId sanitizes the session id to [A-Za-z0-9_-]+,
190
+ // so a legacy flat capture never carries another dot in its file name;
191
+ // `release-0.1.0-alpha.1` and other dot-bearing names stay in recall.
192
+ return /^auto-capture-[A-Za-z0-9_-]+\.md$/.test(rest);
193
+ };
194
+ const curated = search.hits.filter(hit => !isAutoCapture(hit.id));
195
+ // `search.total` is the vault-wide count before the search-layer cap. If
196
+ // any auto-capture hits made it into the capped `hits` list, remove them
197
+ // from the total so curated numbers stay truthful.
198
+ const total = search.total - (search.hits.length - curated.length);
199
+ ctx.logger?.debug(`memory-curator: recall query=${args.query} hits=${curated.length} total=${total}`);
200
+ const hits = curated.slice(0, 20);
107
201
  return {
108
202
  query: args.query,
109
- hits: hits.slice(0, 20),
110
- total: hits.length,
203
+ hits,
204
+ total,
205
+ // `wiki_search` reports its own cap; recall caps again at 20, so the
206
+ // page is incomplete whenever either cap bit.
207
+ truncated: search.truncated || curated.length > hits.length,
111
208
  };
112
209
  },
113
210
  })));
@@ -164,11 +261,11 @@ export function apply(ctx, config) {
164
261
  const targetId = resolveNoteId(args.id, args.title, scope, notesDir);
165
262
  const mode = args.mode ?? 'append';
166
263
  const tags = Array.isArray(args.tags) ? args.tags.map(String) : [];
167
- const [titleHits, idHits] = await Promise.all([
264
+ const [titleSearch, idSearch] = await Promise.all([
168
265
  callTool(ctx, exec, 'wiki_search', { query: args.title }),
169
266
  callTool(ctx, exec, 'wiki_search', { query: targetId }),
170
267
  ]);
171
- const conflicts = dedupeHits(findConflicts(targetId, args.title, [...titleHits, ...idHits]));
268
+ const conflicts = dedupeHits(findConflicts(targetId, args.title, [...titleSearch.hits, ...idSearch.hits]));
172
269
  const conflictExists = conflicts.length > 0;
173
270
  const finalId = conflicts[0]?.id ?? targetId;
174
271
  if (conflictExists) {
@@ -210,29 +307,35 @@ export function apply(ctx, config) {
210
307
  };
211
308
  }
212
309
  }
213
- const now = new Date().toISOString();
310
+ const now = formatBeijingTime(new Date());
214
311
  const detailsText = args.details ? `\n\n## Details\n\n${args.details}` : '';
215
312
  const appendContent = `${args.summary}${detailsText}\n`;
216
- const frontmatter = {
217
- title: args.title,
218
- tags,
219
- created: now,
220
- };
221
- const newNoteContent = `---\n${yaml.dump(frontmatter).trim()}\n---\n\n# ${args.title}\n\n${appendContent}`;
222
313
  const appending = conflictExists && mode === 'append';
223
- const content = appending ? appendContent : newNoteContent;
224
314
  const writeMode = appending ? 'append' : 'overwrite';
225
- await callTool(ctx, exec, 'wiki_write', {
315
+ ctx.logger?.debug(`memory-curator: capture target=${finalId} conflicts=${conflicts.length} writeMode=${writeMode}`);
316
+ const frontmatter = appending ? undefined : await overwriteFrontmatter(ctx, exec, finalId, args.title, tags, now);
317
+ const content = appending
318
+ ? appendContent
319
+ : `---\n${yaml.dump(frontmatter).trim()}\n---\n\n# ${args.title}\n\n${appendContent}`;
320
+ const writeResult = await callTool(ctx, exec, 'wiki_write', {
226
321
  id: finalId,
227
322
  content,
228
323
  mode: writeMode,
229
324
  });
230
- return {
325
+ const response = {
231
326
  written: true,
232
327
  id: finalId,
233
328
  mode: writeMode,
234
329
  conflict: conflictExists,
330
+ version: writeResult.version,
235
331
  };
332
+ if (typeof writeResult.created === 'string' && writeResult.created !== '')
333
+ response.created = writeResult.created;
334
+ if (typeof writeResult.updated === 'string' && writeResult.updated !== '')
335
+ response.updated = writeResult.updated;
336
+ if (typeof writeResult.committed === 'boolean')
337
+ response.committed = writeResult.committed;
338
+ return response;
236
339
  },
237
340
  })));
238
341
  }
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.1",
4
+ "version": "0.1.0-alpha.11",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,17 +32,18 @@
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-tool-memory-filesystem": "0.1.3-alpha.2"
47
+ "@jacklika/dsh-tool-memory-filesystem": "^0.1.3-alpha.10"
47
48
  }
48
49
  }