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

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,10 +5,14 @@ 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. 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`.
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
9
 
10
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.
11
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()` — Archive 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. The pass is idempotent: repeated runs produce no duplicate archives. It respects the same cross-process lock lane as other mutation tools.
15
+
12
16
  ## Note ids
13
17
 
14
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.
@@ -17,4 +21,8 @@ When `id` is omitted, `memory_capture` derives it from the title: Unicode letter
17
21
 
18
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.
19
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.
27
+
20
28
  See the Bundle documentation in `docs/usage.md` for configuration and workflow.
package/README.zh.md CHANGED
@@ -5,10 +5,14 @@ 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, ...)` — 在有意义的任务结束后持久化知识,支持冲突检测与可选的用户审批。即使排序搜索截断,也会直接检查目标笔记是否存在;未给出明确 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`。
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
9
 
10
10
  - `memory_correct(id, baseVersion, summary, details?, tags?)` — 先 `wiki_read` 目标笔记,再传入其精确 `id`、返回的 `version` 与完整替换正文。它不追加、不按标题猜选目标、不创建缺失笔记;版本过期时拒绝写入。省略 tags 时保留原值,其他 frontmatter 字段也保留。返回字段与 `memory_capture` 写入结果一致,`mode: 'overwrite'`、`conflict: false`;DSH 写入仍经过既有 queue 与 git 审计路径。
11
11
 
12
+ - `memory_forget(id, baseVersion)` — 将策展笔记移入归档区(`archive/<原相对路径>`)。笔记未被物理删除,仍可显式 `wiki_read` 读取,但 `wiki_search` 与 `wiki_graph` 默认不再返回它。必须传入 `wiki_read` 得到的当前 `version`;过期版本会失败关闭。若配置了 `memory-git`,此次移动会被提交。
13
+
14
+ - `memory_compact()` — 归档旧的逐回合自动捕获笔记,只保留最近 N 个会话。默认关闭;在 curator 配置中通过 `autoCaptureCompact: { enabled: true, keepSessions: N }` 开启。该 pass 是幂等的:重复执行不会产生重复归档文件。它与其他变更工具使用同一跨进程锁车道。
15
+
12
16
  ## 笔记 id
13
17
 
14
18
  省略 `id` 时,`memory_capture` 由标题推导笔记 id:保留 Unicode 字母与数字(先做 NFC 归一化并转小写),其余连续字符统一折叠为单个 `-`,因此中日韩等非拉丁标题依然可读且互不相同。完全不含字母与数字的标题退化为 `note-<标题 sha256 的前 12 位十六进制>`,纯符号标题的写入不会坍缩到同一个文件。
@@ -17,4 +21,8 @@ DSH 记忆库的策展工具。本包是 `@jacklika/dsh-memory` Bundle 的成员
17
21
 
18
22
  `mode: 'overwrite'` 替换笔记正文,但会合并既有 frontmatter:其他写入方记录的字段与原始 `created` 都保留,`title` 与 `tags` 以本次调用为准,并写入 `updated`。如果从读取旧笔记到写入之间检测到其他修改,则拒绝覆盖该修改。首次入库只写 `created`。
19
23
 
24
+ ## 来源字段
25
+
26
+ `memory_capture` 与 `memory_correct` 接受可选的 `source_session`、`commit`、`verified` 参数。传入后会被写入笔记 frontmatter,作为后续检索加权与审计的一等公民字段。当前版本暂不改变搜索排序。
27
+
20
28
  配置与工作流请参阅 Bundle 文档 `docs/usage.zh.md`。
package/lib/index.js CHANGED
@@ -22,6 +22,10 @@ export const Config = z.object({
22
22
  conflictPolicy: z.union(['ask', 'skip']).default('ask'),
23
23
  defaultScope: z.union(['shared', 'private']).default('shared'),
24
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 }),
25
29
  });
26
30
  /**
27
31
  * Fold a title to its comparison form: NFC, case-insensitive, every run of
@@ -142,8 +146,18 @@ async function readFrontmatter(ctx, exec, id) {
142
146
  throw error;
143
147
  }
144
148
  }
145
- function overwriteFrontmatter(existing, title, tags, now) {
146
- const frontmatter = { ...existing?.frontmatter, title, tags };
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 };
147
161
  const created = existing?.frontmatter.created;
148
162
  frontmatter.created = typeof created === 'string' && created !== '' ? created : now;
149
163
  if (existing !== undefined)
@@ -263,6 +277,18 @@ export function apply(ctx, config) {
263
277
  type: 'boolean',
264
278
  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.',
265
279
  },
280
+ source_session: {
281
+ type: 'string',
282
+ description: 'Optional session id that produced this capture, stored in frontmatter for provenance.',
283
+ },
284
+ commit: {
285
+ type: 'string',
286
+ description: 'Optional upstream commit hash or reference this capture relates to, stored in frontmatter for provenance.',
287
+ },
288
+ verified: {
289
+ type: 'boolean',
290
+ description: 'Optional boolean marking the captured knowledge as verified, stored in frontmatter for provenance.',
291
+ },
266
292
  },
267
293
  output: {
268
294
  schema: { type: 'json' },
@@ -276,16 +302,15 @@ export function apply(ctx, config) {
276
302
  const explicitId = typeof args.id === 'string' && args.id !== '';
277
303
  const mode = args.mode ?? (requireExisting ? 'overwrite' : 'append');
278
304
  const tags = Array.isArray(args.tags) ? args.tags.map(String) : [];
279
- const [titleSearch, idSearch, targetNote] = await Promise.all([
280
- callTool(ctx, exec, 'wiki_search', { query: args.title }),
281
- callTool(ctx, exec, 'wiki_search', { query: targetId }),
305
+ const [titleSearch, targetNote] = await Promise.all([
306
+ callTool(ctx, exec, 'wiki_search', { query: args.title, exactTitle: args.title }),
282
307
  readFrontmatter(ctx, exec, targetId),
283
308
  ]);
284
309
  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]));
310
+ const conflicts = dedupeHits(findConflicts(targetId, args.title, [...targetHit, ...titleSearch.hits]));
286
311
  const conflictExists = conflicts.length > 0;
287
312
  const finalId = requireExisting || explicitId || targetNote !== undefined ? targetId : conflicts[0]?.id ?? targetId;
288
- if (!requireExisting && !explicitId && targetNote === undefined && (titleSearch.truncated || idSearch.truncated || conflicts.length > 1)) {
313
+ if (!requireExisting && !explicitId && targetNote === undefined && (titleSearch.truncated || conflicts.length > 1)) {
289
314
  return {
290
315
  written: false,
291
316
  id: targetId,
@@ -350,8 +375,9 @@ export function apply(ctx, config) {
350
375
  const exists = requireExisting ? targetNote !== undefined : conflictExists;
351
376
  const appending = exists && mode === 'append';
352
377
  const writeMode = appending ? 'append' : 'overwrite';
378
+ const provenance = pickProvenance(args);
353
379
  ctx.logger?.debug(`memory-curator: capture target=${finalId} conflicts=${conflicts.length} writeMode=${writeMode} requireExisting=${requireExisting}`);
354
- const overwrite = appending ? undefined : overwriteFrontmatter(selectedNote, args.title, tags, now);
380
+ const overwrite = appending ? undefined : overwriteFrontmatter(selectedNote, args.title, tags, now, provenance);
355
381
  const content = appending
356
382
  ? appendContent
357
383
  : `---\n${yaml.dump(overwrite).trim()}\n---\n\n# ${args.title}\n\n${appendContent}`;
@@ -364,7 +390,7 @@ export function apply(ctx, config) {
364
390
  });
365
391
  const response = {
366
392
  written: true,
367
- id: finalId,
393
+ id: writeResult.id,
368
394
  mode: writeMode,
369
395
  // A declared update is not a conflict: the caller named the note it
370
396
  // meant to change, so nothing required approval or a skip decision.
@@ -389,6 +415,9 @@ export function apply(ctx, config) {
389
415
  summary: { type: 'string', required: true, description: 'Complete current conclusion in Markdown, replacing the old body.' },
390
416
  details: { type: 'string', description: 'Optional additional context included in the new current body.' },
391
417
  tags: { type: 'array', items: { type: 'string' }, description: 'Optional replacement tags; omitted tags retain existing tags.' },
418
+ source_session: { type: 'string', description: 'Optional session id that produced this correction, stored in frontmatter for provenance.' },
419
+ commit: { type: 'string', description: 'Optional upstream commit hash or reference this correction relates to, stored in frontmatter for provenance.' },
420
+ verified: { type: 'boolean', description: 'Optional boolean marking the corrected knowledge as verified, stored in frontmatter for provenance.' },
392
421
  },
393
422
  output: {
394
423
  schema: { type: 'json' },
@@ -404,12 +433,14 @@ export function apply(ctx, config) {
404
433
  const existing = note.frontmatter ?? {};
405
434
  const heading = /^#\s+(.+)$/m.exec(note.body)?.[1];
406
435
  const title = typeof existing.title === 'string' && existing.title !== '' ? existing.title : heading ?? args.id.replace(/\.md$/, '');
436
+ const correctionProvenance = pickProvenance(args);
407
437
  const frontmatter = {
408
438
  ...existing,
409
439
  title,
410
440
  ...(args.tags !== undefined ? { tags: args.tags.map(String) } : {}),
411
441
  created: typeof existing.created === 'string' && existing.created !== '' ? existing.created : now,
412
442
  updated: now,
443
+ ...correctionProvenance,
413
444
  };
414
445
  const details = args.details ? `\n\n## Details\n\n${args.details}` : '';
415
446
  const content = `---\n${yaml.dump(frontmatter).trim()}\n---\n\n# ${title}\n\n${args.summary}${details}\n`;
@@ -424,5 +455,53 @@ export function apply(ctx, config) {
424
455
  };
425
456
  },
426
457
  })));
458
+ ctx.effect(() => ctx.tools.register(defineTool({
459
+ name: 'memory_compact',
460
+ 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.',
461
+ parameters: {},
462
+ output: {
463
+ schema: { type: 'json' },
464
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
465
+ },
466
+ async execute(_args, exec) {
467
+ const compact = resolved.autoCaptureCompact;
468
+ if (!compact.enabled) {
469
+ return { compacted: false, reason: 'auto-capture compaction is disabled in curator config' };
470
+ }
471
+ const keep = typeof compact.keepSessions === 'number' && compact.keepSessions >= 1 ? Math.floor(compact.keepSessions) : 0;
472
+ if (keep < 1) {
473
+ return { compacted: false, reason: 'autoCaptureCompact.keepSessions must be >= 1' };
474
+ }
475
+ const notesPrefix = `${resolved.notesDir}/`;
476
+ const isAutoCapture = (id) => {
477
+ if (!id.startsWith(notesPrefix))
478
+ return false;
479
+ const rest = id.slice(notesPrefix.length);
480
+ if (rest.startsWith('auto-capture/'))
481
+ return true;
482
+ return /^auto-capture-[A-Za-z0-9_-]+\.md$/.test(rest);
483
+ };
484
+ const search = await callTool(ctx, exec, 'wiki_search', { query: 'auto-capture', maxResults: 10000 });
485
+ const captures = dedupeHits(search.hits.filter(hit => isAutoCapture(hit.id)))
486
+ .sort((a, b) => b.id.localeCompare(a.id));
487
+ if (captures.length <= keep) {
488
+ return { compacted: true, kept: captures.length, archived: 0, archivedIds: [] };
489
+ }
490
+ const toArchive = captures.slice(keep);
491
+ const archivedIds = [];
492
+ for (const hit of toArchive) {
493
+ const note = await callTool(ctx, exec, 'wiki_read', { id: hit.id });
494
+ const result = await callTool(ctx, exec, 'memory_forget', { id: hit.id, baseVersion: note.version });
495
+ if (result.forgotten)
496
+ archivedIds.push(result.id);
497
+ }
498
+ return {
499
+ compacted: true,
500
+ kept: keep,
501
+ archived: archivedIds.length,
502
+ archivedIds,
503
+ };
504
+ },
505
+ })));
427
506
  }
428
507
  //# sourceMappingURL=index.js.map
@@ -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.15",
4
+ "version": "0.1.0-alpha.17",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -44,6 +44,8 @@
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-tool-memory-filesystem": "^0.1.3-alpha.13"
47
+ "@jacklika/dsh-memory-git": "^0.1.0-alpha.7",
48
+ "@jacklika/dsh-memory-scope": "^0.1.0-alpha.3",
49
+ "@jacklika/dsh-tool-memory-filesystem": "^0.1.3-alpha.15"
48
50
  }
49
51
  }