@jacklika/dsh-memory-curator 0.1.0-alpha.17 → 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 CHANGED
@@ -5,13 +5,13 @@ 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, 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.
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
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.
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
11
 
12
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
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.
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
15
 
16
16
  ## Note ids
17
17
 
package/README.zh.md CHANGED
@@ -5,13 +5,13 @@ 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, ...)` — 在有意义的任务结束后持久化知识,支持冲突检测与可选的用户审批。直接检查目标笔记是否存在;冲突搜索使用全 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 命名空间。
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
9
 
10
- - `memory_correct(id, baseVersion, summary, details?, tags?)` — 先 `wiki_read` 目标笔记,再传入其精确 `id`、返回的 `version` 与完整替换正文。它不追加、不按标题猜选目标、不创建缺失笔记;版本过期时拒绝写入。省略 tags 时保留原值,其他 frontmatter 字段也保留。返回字段与 `memory_capture` 写入结果一致,`mode: 'overwrite'`、`conflict: false`;DSH 写入仍经过既有 queue 与 git 审计路径。
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
11
 
12
12
  - `memory_forget(id, baseVersion)` — 将策展笔记移入归档区(`archive/<原相对路径>`)。笔记未被物理删除,仍可显式 `wiki_read` 读取,但 `wiki_search` 与 `wiki_graph` 默认不再返回它。必须传入 `wiki_read` 得到的当前 `version`;过期版本会失败关闭。若配置了 `memory-git`,此次移动会被提交。
13
13
 
14
- - `memory_compact()` — 归档旧的逐回合自动捕获笔记,只保留最近 N 个会话。默认关闭;在 curator 配置中通过 `autoCaptureCompact: { enabled: true, keepSessions: N }` 开启。该 pass 是幂等的:重复执行不会产生重复归档文件。它与其他变更工具使用同一跨进程锁车道。
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
15
 
16
16
  ## 笔记 id
17
17
 
package/lib/index.js CHANGED
@@ -177,6 +177,15 @@ export function apply(ctx, config) {
177
177
  }
178
178
  const resolved = config;
179
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');
180
189
  ctx.effect(() => ctx.tools.register(defineTool({
181
190
  name: 'memory_recall',
182
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.',
@@ -310,11 +319,21 @@ export function apply(ctx, config) {
310
319
  const conflicts = dedupeHits(findConflicts(targetId, args.title, [...targetHit, ...titleSearch.hits]));
311
320
  const conflictExists = conflicts.length > 0;
312
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
+ });
313
331
  if (!requireExisting && !explicitId && targetNote === undefined && (titleSearch.truncated || conflicts.length > 1)) {
314
332
  return {
315
333
  written: false,
316
334
  id: targetId,
317
335
  conflict: true,
336
+ ...(conflicts.length > 0 ? { suggested: correctionHint() } : {}),
318
337
  reason: 'ambiguous or truncated conflict search; provide the exact existing note id before writing',
319
338
  };
320
339
  }
@@ -325,12 +344,13 @@ export function apply(ctx, config) {
325
344
  // clobbering one while creating is not required; `wiki_write` enforces
326
345
  // the existence requirement itself, atomically with the write.
327
346
  if (conflictExists && !requireExisting) {
328
- const policy = args.conflictPolicy ?? resolved.conflictPolicy;
347
+ const policy = args.conflictPolicy ?? defaultConflictPolicy;
329
348
  if (policy === 'skip') {
330
349
  return {
331
350
  written: false,
332
351
  id: finalId,
333
352
  conflict: true,
353
+ suggested: correctionHint(approvedVersion),
334
354
  reason: 'skipped by conflictPolicy=skip',
335
355
  };
336
356
  }
@@ -339,6 +359,7 @@ export function apply(ctx, config) {
339
359
  written: false,
340
360
  id: finalId,
341
361
  conflict: true,
362
+ suggested: correctionHint(approvedVersion),
342
363
  reason: 'approval service or agent unavailable; set conflictPolicy=skip to allow silent skip in headless mode',
343
364
  };
344
365
  }
@@ -358,6 +379,7 @@ export function apply(ctx, config) {
358
379
  written: false,
359
380
  id: finalId,
360
381
  conflict: true,
382
+ suggested: correctionHint(approvedVersion),
361
383
  approval: outcome,
362
384
  reason: 'user did not approve the conflict',
363
385
  };
@@ -378,9 +400,10 @@ export function apply(ctx, config) {
378
400
  const provenance = pickProvenance(args);
379
401
  ctx.logger?.debug(`memory-curator: capture target=${finalId} conflicts=${conflicts.length} writeMode=${writeMode} requireExisting=${requireExisting}`);
380
402
  const overwrite = appending ? undefined : overwriteFrontmatter(selectedNote, args.title, tags, now, provenance);
403
+ const headingPrefix = args.summary.trimStart().startsWith('#') ? '' : `# ${args.title}\n\n`;
381
404
  const content = appending
382
405
  ? appendContent
383
- : `---\n${yaml.dump(overwrite).trim()}\n---\n\n# ${args.title}\n\n${appendContent}`;
406
+ : `---\n${yaml.dump(overwrite).trim()}\n---\n\n${headingPrefix}${appendContent}`;
384
407
  const writeResult = await callTool(ctx, exec, 'wiki_write', {
385
408
  id: finalId,
386
409
  content,
@@ -443,7 +466,8 @@ export function apply(ctx, config) {
443
466
  ...correctionProvenance,
444
467
  };
445
468
  const details = args.details ? `\n\n## Details\n\n${args.details}` : '';
446
- const content = `---\n${yaml.dump(frontmatter).trim()}\n---\n\n# ${title}\n\n${args.summary}${details}\n`;
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`;
447
471
  const written = await callTool(ctx, exec, 'wiki_write', {
448
472
  id: args.id, content, mode: 'overwrite', requireExisting: true, baseVersion: args.baseVersion,
449
473
  });
@@ -457,21 +481,17 @@ export function apply(ctx, config) {
457
481
  })));
458
482
  ctx.effect(() => ctx.tools.register(defineTool({
459
483
  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: {},
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
+ },
462
490
  output: {
463
491
  schema: { type: 'json' },
464
492
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
465
493
  },
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
- }
494
+ async execute(args, exec) {
475
495
  const notesPrefix = `${resolved.notesDir}/`;
476
496
  const isAutoCapture = (id) => {
477
497
  if (!id.startsWith(notesPrefix))
@@ -481,6 +501,66 @@ export function apply(ctx, config) {
481
501
  return true;
482
502
  return /^auto-capture-[A-Za-z0-9_-]+\.md$/.test(rest);
483
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
+ }
484
564
  const search = await callTool(ctx, exec, 'wiki_search', { query: 'auto-capture', maxResults: 10000 });
485
565
  const captures = dedupeHits(search.hits.filter(hit => isAutoCapture(hit.id)))
486
566
  .sort((a, b) => b.id.localeCompare(a.id));
@@ -488,6 +568,16 @@ export function apply(ctx, config) {
488
568
  return { compacted: true, kept: captures.length, archived: 0, archivedIds: [] };
489
569
  }
490
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
+ }
491
581
  const archivedIds = [];
492
582
  for (const hit of toArchive) {
493
583
  const note = await callTool(ctx, exec, 'wiki_read', { id: hit.id });
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.17",
4
+ "version": "0.1.0-alpha.20",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -44,8 +44,9 @@
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-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"
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"
50
51
  }
51
52
  }