@jacklika/dsh-memory-curator 0.1.0-alpha.10 → 0.1.0-alpha.12

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,8 +4,8 @@ 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. Returns `{ query, hits, total }`, where `total` is the vault-wide match count passed through from `wiki_search` and can exceed `hits.length` when the search layer caps its results — 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.
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`.
9
9
 
10
10
  ## Note ids
11
11
 
package/README.zh.md CHANGED
@@ -4,8 +4,8 @@ DSH 记忆库的策展工具。本包是 `@jacklika/dsh-memory` Bundle 的成员
4
4
 
5
5
  ## 工具
6
6
 
7
- - `memory_recall(query)` — 在任务开始前搜索知识库中的相关笔记。返回 `{ query, hits, total }`,其中 `total` 是从 `wiki_search` 透传的全库命中数,在搜索层截断结果时可能大于 `hits.length`——调用方因此能区分“这就是全部命中”与“还有更多”。`memory-anchor` 写入的逐回合自动入库笔记会从命中列表中隐藏,并从 `total` 中扣除,记账内容不会挤占策展知识;匹配锚定在策展区命名空间上,仅名字前缀相同的策展笔记仍会返回。
8
- - `memory_capture(title, summary, ...)` — 在有意义的任务结束后持久化知识,支持冲突检测与可选的用户审批。返回 `{ written, id, mode, conflict, version, created?, updated?, committed? }`:`version` 是持久化笔记文本的 SHA-1 指纹;首次入库返回 `created`;追加写入返回 `updated`;挂载 `memory-git` 完成提交时还会返回 `committed`。
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`。
9
9
 
10
10
  ## 笔记 id
11
11
 
package/lib/index.js CHANGED
@@ -123,17 +123,24 @@ function dedupeHits(hits) {
123
123
  * @param now - Beijing-time timestamp used for `created`/`updated`.
124
124
  * @returns the merged frontmatter to serialize.
125
125
  */
126
- async function overwriteFrontmatter(ctx, exec, id, title, tags, now) {
127
- let existing;
126
+ async function readFrontmatter(ctx, exec, id) {
128
127
  try {
129
128
  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
- }
129
+ return note !== null && typeof note === 'object' && note.frontmatter !== null && typeof note.frontmatter === 'object'
130
+ ? note.frontmatter
131
+ : {};
133
132
  }
134
- catch {
135
- // No readable note at this id: the ordinary first capture of a new note.
133
+ catch (error) {
134
+ // Only a missing note means a first capture: `wiki_read` reports it as
135
+ // "no such note". Any other failure (permission denied, a directory in the
136
+ // way) must surface instead of silently turning an update into a replace.
137
+ if (error.message.includes('no such note'))
138
+ return undefined;
139
+ throw error;
136
140
  }
141
+ }
142
+ async function overwriteFrontmatter(ctx, exec, id, title, tags, now) {
143
+ const existing = await readFrontmatter(ctx, exec, id);
137
144
  const frontmatter = { ...existing, title, tags };
138
145
  const created = existing?.created;
139
146
  frontmatter.created = typeof created === 'string' && created !== '' ? created : now;
@@ -156,7 +163,7 @@ export function apply(ctx, config) {
156
163
  const approval = ctx.get('approval');
157
164
  ctx.effect(() => ctx.tools.register(defineTool({
158
165
  name: 'memory_recall',
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, so a total above the returned hit count means the list was truncated.',
166
+ 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.',
160
167
  parameters: {
161
168
  query: {
162
169
  type: 'string',
@@ -197,16 +204,20 @@ export function apply(ctx, config) {
197
204
  // from the total so curated numbers stay truthful.
198
205
  const total = search.total - (search.hits.length - curated.length);
199
206
  ctx.logger?.debug(`memory-curator: recall query=${args.query} hits=${curated.length} total=${total}`);
207
+ const hits = curated.slice(0, 20);
200
208
  return {
201
209
  query: args.query,
202
- hits: curated.slice(0, 20),
210
+ hits,
203
211
  total,
212
+ // `wiki_search` reports its own cap; recall caps again at 20, so the
213
+ // page is incomplete whenever either cap bit.
214
+ truncated: search.truncated || curated.length > hits.length,
204
215
  };
205
216
  },
206
217
  })));
207
218
  ctx.effect(() => ctx.tools.register(defineTool({
208
219
  name: 'memory_capture',
209
- 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.',
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.',
210
221
  parameters: {
211
222
  title: {
212
223
  type: 'string',
@@ -246,6 +257,10 @@ export function apply(ctx, config) {
246
257
  enum: ['ask', 'skip'],
247
258
  description: 'Override the deployment conflict policy for this capture: ask for approval on conflict, or skip the write.',
248
259
  },
260
+ requireExisting: {
261
+ 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.',
263
+ },
249
264
  },
250
265
  output: {
251
266
  schema: { type: 'json' },
@@ -256,6 +271,7 @@ export function apply(ctx, config) {
256
271
  const notesDir = scope === 'shared' ? resolved.notesDir : '';
257
272
  const targetId = resolveNoteId(args.id, args.title, scope, notesDir);
258
273
  const mode = args.mode ?? 'append';
274
+ const requireExisting = args.requireExisting === true;
259
275
  const tags = Array.isArray(args.tags) ? args.tags.map(String) : [];
260
276
  const [titleSearch, idSearch] = await Promise.all([
261
277
  callTool(ctx, exec, 'wiki_search', { query: args.title }),
@@ -264,7 +280,11 @@ export function apply(ctx, config) {
264
280
  const conflicts = dedupeHits(findConflicts(targetId, args.title, [...titleSearch.hits, ...idSearch.hits]));
265
281
  const conflictExists = conflicts.length > 0;
266
282
  const finalId = conflicts[0]?.id ?? targetId;
267
- if (conflictExists) {
283
+ // A declared update-only capture targets an existing note by
284
+ // construction, so the approval that guards against accidentally
285
+ // clobbering one while creating is not required; `wiki_write` enforces
286
+ // the existence requirement itself, atomically with the write.
287
+ if (conflictExists && !requireExisting) {
268
288
  const policy = args.conflictPolicy ?? resolved.conflictPolicy;
269
289
  if (policy === 'skip') {
270
290
  return {
@@ -306,9 +326,16 @@ export function apply(ctx, config) {
306
326
  const now = formatBeijingTime(new Date());
307
327
  const detailsText = args.details ? `\n\n## Details\n\n${args.details}` : '';
308
328
  const appendContent = `${args.summary}${detailsText}\n`;
309
- const appending = conflictExists && mode === 'append';
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;
336
+ const appending = exists && mode === 'append';
310
337
  const writeMode = appending ? 'append' : 'overwrite';
311
- ctx.logger?.debug(`memory-curator: capture target=${finalId} conflicts=${conflicts.length} writeMode=${writeMode}`);
338
+ ctx.logger?.debug(`memory-curator: capture target=${finalId} conflicts=${conflicts.length} writeMode=${writeMode} requireExisting=${requireExisting}`);
312
339
  const frontmatter = appending ? undefined : await overwriteFrontmatter(ctx, exec, finalId, args.title, tags, now);
313
340
  const content = appending
314
341
  ? appendContent
@@ -317,12 +344,15 @@ export function apply(ctx, config) {
317
344
  id: finalId,
318
345
  content,
319
346
  mode: writeMode,
347
+ ...(requireExisting ? { requireExisting: true } : {}),
320
348
  });
321
349
  const response = {
322
350
  written: true,
323
351
  id: finalId,
324
352
  mode: writeMode,
325
- conflict: conflictExists,
353
+ // A declared update is not a conflict: the caller named the note it
354
+ // meant to change, so nothing required approval or a skip decision.
355
+ conflict: conflictExists && !requireExisting,
326
356
  version: writeResult.version,
327
357
  };
328
358
  if (typeof writeResult.created === 'string' && writeResult.created !== '')
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.10",
4
+ "version": "0.1.0-alpha.12",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -44,6 +44,6 @@
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.9"
47
+ "@jacklika/dsh-tool-memory-filesystem": "^0.1.3-alpha.12"
48
48
  }
49
49
  }