@jacklika/dsh-memory-curator 0.1.0-alpha.14 → 0.1.0-alpha.16
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 +3 -1
- package/README.zh.md +3 -1
- package/lib/index.js +72 -17
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@ Curator tools for the DSH memory vault. This package is a member of the `@jackli
|
|
|
5
5
|
## Tools
|
|
6
6
|
|
|
7
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. 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`.
|
|
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`. The returned `id` is the actual on-disk id, including the agent namespace when private scope rewrites it.
|
|
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. 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.
|
|
9
11
|
|
|
10
12
|
## Note ids
|
|
11
13
|
|
package/README.zh.md
CHANGED
|
@@ -5,7 +5,9 @@ DSH 记忆库的策展工具。本包是 `@jacklika/dsh-memory` Bundle 的成员
|
|
|
5
5
|
## 工具
|
|
6
6
|
|
|
7
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, ...)` —
|
|
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`。返回的 `id` 为实际落盘 id,私有作用域改写时包含 agent 命名空间。
|
|
9
|
+
|
|
10
|
+
- `memory_correct(id, baseVersion, summary, details?, tags?)` — 先 `wiki_read` 目标笔记,再传入其精确 `id`、返回的 `version` 与完整替换正文。它不追加、不按标题猜选目标、不创建缺失笔记;版本过期时拒绝写入。省略 tags 时保留原值,其他 frontmatter 字段也保留。返回字段与 `memory_capture` 写入结果一致,`mode: 'overwrite'`、`conflict: false`;DSH 写入仍经过既有 queue 与 git 审计路径。
|
|
9
11
|
|
|
10
12
|
## 笔记 id
|
|
11
13
|
|
package/lib/index.js
CHANGED
|
@@ -115,12 +115,12 @@ function dedupeHits(hits) {
|
|
|
115
115
|
* replaces the whole file, so the note's existing fields have to be carried
|
|
116
116
|
* over here or an update silently drops whatever another writer recorded and
|
|
117
117
|
* resets `created` to the update time.
|
|
118
|
+
* Capture the note version before approval so any later change makes the
|
|
119
|
+
* guarded write fail. Never treat a non-ENOENT read error as a missing note.
|
|
118
120
|
* @param ctx - registrant context carrying the tool registry.
|
|
119
121
|
* @param exec - the run context the nested read is dispatched under.
|
|
120
122
|
* @param id - vault-relative note id about to be overwritten.
|
|
121
|
-
*
|
|
122
|
-
* @param tags - tags from this capture; they win over any stored ones.
|
|
123
|
-
* @param now - Beijing-time timestamp used for `created`/`updated`.
|
|
123
|
+
* Existing frontmatter fields are preserved by the subsequent merge.
|
|
124
124
|
* @returns the stored frontmatter and version for a guarded write.
|
|
125
125
|
*/
|
|
126
126
|
async function readFrontmatter(ctx, exec, id) {
|
|
@@ -142,14 +142,13 @@ async function readFrontmatter(ctx, exec, id) {
|
|
|
142
142
|
throw error;
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
-
|
|
146
|
-
const existing = await readFrontmatter(ctx, exec, id);
|
|
145
|
+
function overwriteFrontmatter(existing, title, tags, now) {
|
|
147
146
|
const frontmatter = { ...existing?.frontmatter, title, tags };
|
|
148
147
|
const created = existing?.frontmatter.created;
|
|
149
148
|
frontmatter.created = typeof created === 'string' && created !== '' ? created : now;
|
|
150
149
|
if (existing !== undefined)
|
|
151
150
|
frontmatter.updated = now;
|
|
152
|
-
return
|
|
151
|
+
return frontmatter;
|
|
153
152
|
}
|
|
154
153
|
/**
|
|
155
154
|
* Register `memory_recall` and `memory_capture` on the tool registry.
|
|
@@ -274,15 +273,27 @@ export function apply(ctx, config) {
|
|
|
274
273
|
const notesDir = scope === 'shared' ? resolved.notesDir : '';
|
|
275
274
|
const targetId = resolveNoteId(args.id, args.title, scope, notesDir);
|
|
276
275
|
const requireExisting = args.requireExisting === true;
|
|
276
|
+
const explicitId = typeof args.id === 'string' && args.id !== '';
|
|
277
277
|
const mode = args.mode ?? (requireExisting ? 'overwrite' : 'append');
|
|
278
278
|
const tags = Array.isArray(args.tags) ? args.tags.map(String) : [];
|
|
279
|
-
const [titleSearch,
|
|
280
|
-
callTool(ctx, exec, 'wiki_search', { query: args.title }),
|
|
281
|
-
|
|
279
|
+
const [titleSearch, targetNote] = await Promise.all([
|
|
280
|
+
callTool(ctx, exec, 'wiki_search', { query: args.title, exactTitle: args.title }),
|
|
281
|
+
readFrontmatter(ctx, exec, targetId),
|
|
282
282
|
]);
|
|
283
|
-
const
|
|
283
|
+
const targetHit = targetNote === undefined ? [] : [{ id: targetId, title: args.title, score: 0, backlinks: [] }];
|
|
284
|
+
const conflicts = dedupeHits(findConflicts(targetId, args.title, [...targetHit, ...titleSearch.hits]));
|
|
284
285
|
const conflictExists = conflicts.length > 0;
|
|
285
|
-
const finalId = requireExisting ? targetId : conflicts[0]?.id ?? targetId;
|
|
286
|
+
const finalId = requireExisting || explicitId || targetNote !== undefined ? targetId : conflicts[0]?.id ?? targetId;
|
|
287
|
+
if (!requireExisting && !explicitId && targetNote === undefined && (titleSearch.truncated || conflicts.length > 1)) {
|
|
288
|
+
return {
|
|
289
|
+
written: false,
|
|
290
|
+
id: targetId,
|
|
291
|
+
conflict: true,
|
|
292
|
+
reason: 'ambiguous or truncated conflict search; provide the exact existing note id before writing',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const selectedNote = finalId === targetId ? targetNote : await readFrontmatter(ctx, exec, finalId);
|
|
296
|
+
const approvedVersion = selectedNote?.version;
|
|
286
297
|
// A declared update-only capture targets an existing note by
|
|
287
298
|
// construction, so the approval that guards against accidentally
|
|
288
299
|
// clobbering one while creating is not required; `wiki_write` enforces
|
|
@@ -335,24 +346,24 @@ export function apply(ctx, config) {
|
|
|
335
346
|
// and create new notes. Overwrites carry the read version to wiki_write
|
|
336
347
|
// so an intervening change is refused rather than silently replaced.
|
|
337
348
|
// Append remains available when the caller requests additive material.
|
|
338
|
-
const exists = requireExisting ?
|
|
349
|
+
const exists = requireExisting ? targetNote !== undefined : conflictExists;
|
|
339
350
|
const appending = exists && mode === 'append';
|
|
340
351
|
const writeMode = appending ? 'append' : 'overwrite';
|
|
341
352
|
ctx.logger?.debug(`memory-curator: capture target=${finalId} conflicts=${conflicts.length} writeMode=${writeMode} requireExisting=${requireExisting}`);
|
|
342
|
-
const overwrite = appending ? undefined :
|
|
353
|
+
const overwrite = appending ? undefined : overwriteFrontmatter(selectedNote, args.title, tags, now);
|
|
343
354
|
const content = appending
|
|
344
355
|
? appendContent
|
|
345
|
-
: `---\n${yaml.dump(overwrite
|
|
356
|
+
: `---\n${yaml.dump(overwrite).trim()}\n---\n\n# ${args.title}\n\n${appendContent}`;
|
|
346
357
|
const writeResult = await callTool(ctx, exec, 'wiki_write', {
|
|
347
358
|
id: finalId,
|
|
348
359
|
content,
|
|
349
360
|
mode: writeMode,
|
|
350
|
-
|
|
351
|
-
...(requireExisting ? { requireExisting: true } : {}),
|
|
361
|
+
baseVersion: approvedVersion ?? createHash('sha1').update('').digest('hex'),
|
|
362
|
+
...(requireExisting || (conflictExists && !explicitId) ? { requireExisting: true } : {}),
|
|
352
363
|
});
|
|
353
364
|
const response = {
|
|
354
365
|
written: true,
|
|
355
|
-
id:
|
|
366
|
+
id: writeResult.id,
|
|
356
367
|
mode: writeMode,
|
|
357
368
|
// A declared update is not a conflict: the caller named the note it
|
|
358
369
|
// meant to change, so nothing required approval or a skip decision.
|
|
@@ -368,5 +379,49 @@ export function apply(ctx, config) {
|
|
|
368
379
|
return response;
|
|
369
380
|
},
|
|
370
381
|
})));
|
|
382
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
383
|
+
name: 'memory_correct',
|
|
384
|
+
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.',
|
|
385
|
+
parameters: {
|
|
386
|
+
id: { type: 'string', required: true, description: 'Exact vault-relative id returned by wiki_read; never inferred from a title.' },
|
|
387
|
+
baseVersion: { type: 'string', required: true, description: 'Version from wiki_read. A concurrent change refuses the correction.' },
|
|
388
|
+
summary: { type: 'string', required: true, description: 'Complete current conclusion in Markdown, replacing the old body.' },
|
|
389
|
+
details: { type: 'string', description: 'Optional additional context included in the new current body.' },
|
|
390
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional replacement tags; omitted tags retain existing tags.' },
|
|
391
|
+
},
|
|
392
|
+
output: {
|
|
393
|
+
schema: { type: 'json' },
|
|
394
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
395
|
+
},
|
|
396
|
+
async execute(args, exec) {
|
|
397
|
+
if (!isSafeNoteId(args.id) || !args.id.endsWith('.md'))
|
|
398
|
+
throw new Error('memory-curator: correction requires a safe vault-relative .md note id');
|
|
399
|
+
const note = await callTool(ctx, exec, 'wiki_read', { id: args.id });
|
|
400
|
+
if (note.version !== args.baseVersion)
|
|
401
|
+
throw new Error(`memory-curator: note ${args.id} changed since it was read; re-read it before correcting`);
|
|
402
|
+
const now = formatBeijingTime(new Date());
|
|
403
|
+
const existing = note.frontmatter ?? {};
|
|
404
|
+
const heading = /^#\s+(.+)$/m.exec(note.body)?.[1];
|
|
405
|
+
const title = typeof existing.title === 'string' && existing.title !== '' ? existing.title : heading ?? args.id.replace(/\.md$/, '');
|
|
406
|
+
const frontmatter = {
|
|
407
|
+
...existing,
|
|
408
|
+
title,
|
|
409
|
+
...(args.tags !== undefined ? { tags: args.tags.map(String) } : {}),
|
|
410
|
+
created: typeof existing.created === 'string' && existing.created !== '' ? existing.created : now,
|
|
411
|
+
updated: now,
|
|
412
|
+
};
|
|
413
|
+
const details = args.details ? `\n\n## Details\n\n${args.details}` : '';
|
|
414
|
+
const content = `---\n${yaml.dump(frontmatter).trim()}\n---\n\n# ${title}\n\n${args.summary}${details}\n`;
|
|
415
|
+
const written = await callTool(ctx, exec, 'wiki_write', {
|
|
416
|
+
id: args.id, content, mode: 'overwrite', requireExisting: true, baseVersion: args.baseVersion,
|
|
417
|
+
});
|
|
418
|
+
return {
|
|
419
|
+
written: true, id: written.id, mode: 'overwrite', conflict: false, version: written.version,
|
|
420
|
+
...(written.created !== undefined ? { created: written.created } : {}),
|
|
421
|
+
...(written.updated !== undefined ? { updated: written.updated } : {}),
|
|
422
|
+
...(written.committed !== undefined ? { committed: written.committed } : {}),
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
})));
|
|
371
426
|
}
|
|
372
427
|
//# sourceMappingURL=index.js.map
|
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.16",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"@deepseek-ai/dsh-util-values": ">=0.1.7-rc.1 <0.3.0-0 || >=0.2.0-rc.1 <0.3.0-0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@jacklika/dsh-
|
|
47
|
+
"@jacklika/dsh-memory-scope": "^0.1.0-alpha.3",
|
|
48
|
+
"@jacklika/dsh-tool-memory-filesystem": "^0.1.3-alpha.14"
|
|
48
49
|
}
|
|
49
50
|
}
|