@jacklika/dsh-memory-curator 0.1.0-alpha.13 → 0.1.0-alpha.15

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
@@ -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: it updates a note that must already exist, appends when `mode` is `append` (the default), and fails instead of creating a missing note. 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 appends, 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 even when ranked search is truncated; ambiguous or incomplete 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`.
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
 
@@ -13,6 +15,6 @@ When `id` is omitted, `memory_capture` derives it from the title: Unicode letter
13
15
 
14
16
  ## Overwriting a capture
15
17
 
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.
18
+ `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.
17
19
 
18
20
  See the Bundle documentation in `docs/usage.md` for configuration and workflow.
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, ...)` — 在有意义的任务结束后持久化知识,支持冲突检测与可选的用户审批。设置 `requireExisting: true` 表示只更新、不新建:目标笔记必须已存在,`mode` 为默认的 `append` 时追加写入,笔记缺失时直接失败而不会创建。由于“更新这篇笔记”本身就是声明的意图,此类调用不会请求冲突审批。返回 `{ written, id, mode, conflict, version, created?, updated?, committed? }`:`version` 是持久化笔记文本的 SHA-1 指纹;首次入库返回 `created`;追加写入返回 `updated`;挂载 `memory-git` 完成提交时还会返回 `committed`。`conflict` 表示检测到的命中需要策略裁决,因此成功执行的只更新捕获会返回 `false`。
8
+ - `memory_capture(title, summary, ...)` — 在有意义的任务结束后持久化知识,支持冲突检测与可选的用户审批。即使排序搜索截断,也会直接检查目标笔记是否存在;未给出明确 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`。
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
 
@@ -13,6 +15,6 @@ DSH 记忆库的策展工具。本包是 `@jacklika/dsh-memory` Bundle 的成员
13
15
 
14
16
  ## 覆盖式入库
15
17
 
16
- `mode: 'overwrite'` 替换笔记正文,但会合并既有 frontmatter:其他写入方记录的字段与原始 `created` 都保留,`title` 与 `tags` 以本次调用为准,并写入 `updated`。首次入库只写 `created`。
18
+ `mode: 'overwrite'` 替换笔记正文,但会合并既有 frontmatter:其他写入方记录的字段与原始 `created` 都保留,`title` 与 `tags` 以本次调用为准,并写入 `updated`。如果从读取旧笔记到写入之间检测到其他修改,则拒绝覆盖该修改。首次入库只写 `created`。
17
19
 
18
20
  配置与工作流请参阅 Bundle 文档 `docs/usage.zh.md`。
package/lib/index.js CHANGED
@@ -115,20 +115,23 @@ 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
- * @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.
123
+ * Existing frontmatter fields are preserved by the subsequent merge.
124
+ * @returns the stored frontmatter and version for a guarded write.
125
125
  */
126
126
  async function readFrontmatter(ctx, exec, id) {
127
127
  try {
128
128
  const note = await callTool(ctx, exec, 'wiki_read', { id });
129
- return note !== null && typeof note === 'object' && note.frontmatter !== null && typeof note.frontmatter === 'object'
130
- ? note.frontmatter
131
- : {};
129
+ return {
130
+ frontmatter: note !== null && typeof note === 'object' && note.frontmatter !== null && typeof note.frontmatter === 'object'
131
+ ? note.frontmatter
132
+ : {},
133
+ version: note.version,
134
+ };
132
135
  }
133
136
  catch (error) {
134
137
  // Only a missing note means a first capture: `wiki_read` reports it as
@@ -139,10 +142,9 @@ async function readFrontmatter(ctx, exec, id) {
139
142
  throw error;
140
143
  }
141
144
  }
142
- async function overwriteFrontmatter(ctx, exec, id, title, tags, now) {
143
- const existing = await readFrontmatter(ctx, exec, id);
144
- const frontmatter = { ...existing, title, tags };
145
- const created = existing?.created;
145
+ function overwriteFrontmatter(existing, title, tags, now) {
146
+ const frontmatter = { ...existing?.frontmatter, title, tags };
147
+ const created = existing?.frontmatter.created;
146
148
  frontmatter.created = typeof created === 'string' && created !== '' ? created : now;
147
149
  if (existing !== undefined)
148
150
  frontmatter.updated = now;
@@ -217,7 +219,7 @@ export function apply(ctx, config) {
217
219
  })));
218
220
  ctx.effect(() => ctx.tools.register(defineTool({
219
221
  name: 'memory_capture',
220
- 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 declare an update: the capture then only modifies a note that already exists and needs no conflict approval.',
222
+ 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.',
221
223
  parameters: {
222
224
  title: {
223
225
  type: 'string',
@@ -227,7 +229,7 @@ export function apply(ctx, config) {
227
229
  summary: {
228
230
  type: 'string',
229
231
  required: true,
230
- description: 'The durable knowledge to record: the decision, API, convention, or fix in concise Markdown.',
232
+ description: 'The durable knowledge to record in concise Markdown. For a correction using requireExisting, provide the complete current conclusion, not only the changed fragment.',
231
233
  },
232
234
  details: {
233
235
  type: 'string',
@@ -250,7 +252,7 @@ export function apply(ctx, config) {
250
252
  mode: {
251
253
  type: 'string',
252
254
  enum: ['append', 'overwrite'],
253
- description: 'When a conflict is approved or the note exists, append adds a timestamped section; overwrite replaces the whole body. Defaults to append.',
255
+ 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.',
254
256
  },
255
257
  conflictPolicy: {
256
258
  type: 'string',
@@ -259,7 +261,7 @@ export function apply(ctx, config) {
259
261
  },
260
262
  requireExisting: {
261
263
  type: 'boolean',
262
- description: 'Update-only capture: the note must already exist, and a conflict needs no approval because updating that note is the declared intent. A missing note fails instead of creating one.',
264
+ 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.',
263
265
  },
264
266
  },
265
267
  output: {
@@ -270,16 +272,29 @@ export function apply(ctx, config) {
270
272
  const scope = args.scope ?? resolved.defaultScope;
271
273
  const notesDir = scope === 'shared' ? resolved.notesDir : '';
272
274
  const targetId = resolveNoteId(args.id, args.title, scope, notesDir);
273
- const mode = args.mode ?? 'append';
274
275
  const requireExisting = args.requireExisting === true;
276
+ const explicitId = typeof args.id === 'string' && args.id !== '';
277
+ const mode = args.mode ?? (requireExisting ? 'overwrite' : 'append');
275
278
  const tags = Array.isArray(args.tags) ? args.tags.map(String) : [];
276
- const [titleSearch, idSearch] = await Promise.all([
279
+ const [titleSearch, idSearch, targetNote] = await Promise.all([
277
280
  callTool(ctx, exec, 'wiki_search', { query: args.title }),
278
281
  callTool(ctx, exec, 'wiki_search', { query: targetId }),
282
+ readFrontmatter(ctx, exec, targetId),
279
283
  ]);
280
- const conflicts = dedupeHits(findConflicts(targetId, args.title, [...titleSearch.hits, ...idSearch.hits]));
284
+ const targetHit = targetNote === undefined ? [] : [{ id: targetId, title: args.title, score: 0, backlinks: [] }];
285
+ const conflicts = dedupeHits(findConflicts(targetId, args.title, [...targetHit, ...titleSearch.hits, ...idSearch.hits]));
281
286
  const conflictExists = conflicts.length > 0;
282
- const finalId = conflicts[0]?.id ?? targetId;
287
+ const finalId = requireExisting || explicitId || targetNote !== undefined ? targetId : conflicts[0]?.id ?? targetId;
288
+ if (!requireExisting && !explicitId && targetNote === undefined && (titleSearch.truncated || idSearch.truncated || conflicts.length > 1)) {
289
+ return {
290
+ written: false,
291
+ id: targetId,
292
+ conflict: true,
293
+ reason: 'ambiguous or truncated conflict search; provide the exact existing note id before writing',
294
+ };
295
+ }
296
+ const selectedNote = finalId === targetId ? targetNote : await readFrontmatter(ctx, exec, finalId);
297
+ const approvedVersion = selectedNote?.version;
283
298
  // A declared update-only capture targets an existing note by
284
299
  // construction, so the approval that guards against accidentally
285
300
  // clobbering one while creating is not required; `wiki_write` enforces
@@ -307,9 +322,9 @@ export function apply(ctx, config) {
307
322
  agent: exec.agent,
308
323
  toolName: 'memory_capture',
309
324
  callId: exec.callId,
310
- reason: `memory_capture conflicts with existing note(s):\n${conflictList}\nProposed action: ${mode === 'append' ? 'append a new section to' : 'overwrite'} "${finalId}".`,
325
+ 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}".`,
311
326
  displayReason: {
312
- en: `Memory capture conflicts with existing note(s):\n${conflictList}\nAllow ${mode} to "${finalId}"?`,
327
+ 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.'}`,
313
328
  },
314
329
  signal: exec.signal,
315
330
  });
@@ -326,25 +341,26 @@ export function apply(ctx, config) {
326
341
  const now = formatBeijingTime(new Date());
327
342
  const detailsText = args.details ? `\n\n## Details\n\n${args.details}` : '';
328
343
  const appendContent = `${args.summary}${detailsText}\n`;
329
- // A conflict already proves the note exists, so the write appends. An
330
- // update-only capture without a detected conflict has to ask the vault
331
- // instead of the search: a note missed by the ranking must be appended
332
- // to, and one that is genuinely absent fails the write rather than
333
- // replacing it. The other path stays conflict-driven so an ordinary new
334
- // capture keeps creating its note.
335
- const exists = requireExisting ? (await readFrontmatter(ctx, exec, finalId)) !== undefined : conflictExists;
344
+ // A conflict suggests the note exists, but update-only writes use the
345
+ // exact target id and ask the vault instead of relying on search ranking.
346
+ // A missing note fails the write; other captures stay conflict-driven
347
+ // and create new notes. Overwrites carry the read version to wiki_write
348
+ // so an intervening change is refused rather than silently replaced.
349
+ // Append remains available when the caller requests additive material.
350
+ const exists = requireExisting ? targetNote !== undefined : conflictExists;
336
351
  const appending = exists && mode === 'append';
337
352
  const writeMode = appending ? 'append' : 'overwrite';
338
353
  ctx.logger?.debug(`memory-curator: capture target=${finalId} conflicts=${conflicts.length} writeMode=${writeMode} requireExisting=${requireExisting}`);
339
- const frontmatter = appending ? undefined : await overwriteFrontmatter(ctx, exec, finalId, args.title, tags, now);
354
+ const overwrite = appending ? undefined : overwriteFrontmatter(selectedNote, args.title, tags, now);
340
355
  const content = appending
341
356
  ? appendContent
342
- : `---\n${yaml.dump(frontmatter).trim()}\n---\n\n# ${args.title}\n\n${appendContent}`;
357
+ : `---\n${yaml.dump(overwrite).trim()}\n---\n\n# ${args.title}\n\n${appendContent}`;
343
358
  const writeResult = await callTool(ctx, exec, 'wiki_write', {
344
359
  id: finalId,
345
360
  content,
346
361
  mode: writeMode,
347
- ...(requireExisting ? { requireExisting: true } : {}),
362
+ baseVersion: approvedVersion ?? createHash('sha1').update('').digest('hex'),
363
+ ...(requireExisting || (conflictExists && !explicitId) ? { requireExisting: true } : {}),
348
364
  });
349
365
  const response = {
350
366
  written: true,
@@ -364,5 +380,49 @@ export function apply(ctx, config) {
364
380
  return response;
365
381
  },
366
382
  })));
383
+ ctx.effect(() => ctx.tools.register(defineTool({
384
+ name: 'memory_correct',
385
+ 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.',
386
+ parameters: {
387
+ id: { type: 'string', required: true, description: 'Exact vault-relative id returned by wiki_read; never inferred from a title.' },
388
+ baseVersion: { type: 'string', required: true, description: 'Version from wiki_read. A concurrent change refuses the correction.' },
389
+ summary: { type: 'string', required: true, description: 'Complete current conclusion in Markdown, replacing the old body.' },
390
+ details: { type: 'string', description: 'Optional additional context included in the new current body.' },
391
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional replacement tags; omitted tags retain existing tags.' },
392
+ },
393
+ output: {
394
+ schema: { type: 'json' },
395
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
396
+ },
397
+ async execute(args, exec) {
398
+ if (!isSafeNoteId(args.id) || !args.id.endsWith('.md'))
399
+ throw new Error('memory-curator: correction requires a safe vault-relative .md note id');
400
+ const note = await callTool(ctx, exec, 'wiki_read', { id: args.id });
401
+ if (note.version !== args.baseVersion)
402
+ throw new Error(`memory-curator: note ${args.id} changed since it was read; re-read it before correcting`);
403
+ const now = formatBeijingTime(new Date());
404
+ const existing = note.frontmatter ?? {};
405
+ const heading = /^#\s+(.+)$/m.exec(note.body)?.[1];
406
+ const title = typeof existing.title === 'string' && existing.title !== '' ? existing.title : heading ?? args.id.replace(/\.md$/, '');
407
+ const frontmatter = {
408
+ ...existing,
409
+ title,
410
+ ...(args.tags !== undefined ? { tags: args.tags.map(String) } : {}),
411
+ created: typeof existing.created === 'string' && existing.created !== '' ? existing.created : now,
412
+ updated: now,
413
+ };
414
+ const details = args.details ? `\n\n## Details\n\n${args.details}` : '';
415
+ const content = `---\n${yaml.dump(frontmatter).trim()}\n---\n\n# ${title}\n\n${args.summary}${details}\n`;
416
+ const written = await callTool(ctx, exec, 'wiki_write', {
417
+ id: args.id, content, mode: 'overwrite', requireExisting: true, baseVersion: args.baseVersion,
418
+ });
419
+ return {
420
+ written: true, id: written.id, mode: 'overwrite', conflict: false, version: written.version,
421
+ ...(written.created !== undefined ? { created: written.created } : {}),
422
+ ...(written.updated !== undefined ? { updated: written.updated } : {}),
423
+ ...(written.committed !== undefined ? { committed: written.committed } : {}),
424
+ };
425
+ },
426
+ })));
367
427
  }
368
428
  //# 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.13",
4
+ "version": "0.1.0-alpha.15",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },