@fonlan/dsh-gitmemo 0.3.2 → 0.4.1

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/lib/index.js CHANGED
@@ -23,6 +23,7 @@
23
23
  import z from "@deepseek-ai/schemastery";
24
24
  import { defineTool } from "@deepseek-ai/dsh-tools";
25
25
  import { GitMemo, GitMemoError, resolveProjectRoot } from "./mem.js";
26
+ import { evaluateGate } from "./systemone.js";
26
27
  /** Cordis plugin name. */
27
28
  const name = "dsh-gitmemo";
28
29
  /** Host services this plugin needs. */
@@ -40,8 +41,160 @@ const Config = z.object({
40
41
  /** Cross-process lock wait timeout in milliseconds. Default 30000. */
41
42
  lockTimeoutMs: z.number().default(30000),
42
43
  /** Optional explicit project root; defaults to the calling session's cwd. */
43
- projectRoot: z.string()
44
+ projectRoot: z.string(),
45
+ /**
46
+ * Optional System-one recall gate. When an endpoint and a credential are
47
+ * configured, each `mem_search` page is narrowed by a System-one model
48
+ * (TypeSafe Jev by default) before it reaches the model; when nothing is
49
+ * configured the gate is a no-op and recall behaves exactly as before.
50
+ *
51
+ * `.volatile()` is REQUIRED here, not cosmetic: the settings plane only
52
+ * exposes a field that sits on or under a volatile node (`volatileForm()`
53
+ * returns undefined otherwise, and `describe()` then skips this entry
54
+ * entirely while `write()` throws). Verified against
55
+ * `@deepseek-ai/dsh-settings`. Note that DSH 0.1.7-alpha.1 ships no client
56
+ * that auto-generates a page from the schema, so this plugin also ships its
57
+ * own settings card (see `src/client/`).
58
+ */
59
+ systemOne: z.object({
60
+ /** Master switch. Defaults to true; with no credential the gate stays a no-op. */
61
+ enabled: z.boolean().default(true).volatile(),
62
+ /** Endpoint accepting the System-one request contract. */
63
+ endpoint: z.string().default("https://api.typesafe.ai/v1/systemone").volatile(),
64
+ /** Model identifier sent in the request body. */
65
+ model: z.string().default("jev-latest").volatile(),
66
+ /** Literal API key. Declared secret: redacted on every read, write-only in the form. */
67
+ apiKey: z.string().role("secret").volatile(),
68
+ /** Credential reference resolved through the credentials service, then the environment. */
69
+ apiKeyEnv: z.string().role("credential-ref").default("TYPESAFE_API_KEY").volatile(),
70
+ /** Question type used to judge each candidate. */
71
+ mode: z.union(["noul", "score"]).default("noul").volatile(),
72
+ /** noul mode: keep a candidate when its probability is at least this value. */
73
+ threshold: z.number().min(0).max(1).default(0.5).volatile(),
74
+ /** score mode: keep a candidate when its score is at least this value.
75
+ * Jev's `score` is Σ(level_index × probability) over 0…levels−1, NOT 0–1;
76
+ * with the default 5 levels the range is 0…4, where 2 = "partially relevant". */
77
+ scoreMin: z.number().default(2).volatile(),
78
+ /** Retain at least this many candidates when the model rejects an entire page. */
79
+ minKeep: z.number().step(1).min(0).default(1).volatile(),
80
+ /** Never judge more than this many candidates per request. */
81
+ maxCandidates: z.number().step(1).min(1).default(20).volatile(),
82
+ /** Truncate the task text to this many characters before sending it. */
83
+ maxTaskChars: z.number().step(1).min(1).default(2000).volatile(),
84
+ /** End-to-end request deadline in milliseconds. */
85
+ timeoutMs: z.number().step(1).min(1).default(8000).volatile()
86
+ })
44
87
  });
88
+ /**
89
+ * Read a config node that may be a plain value or a live (`.volatile()`) node.
90
+ *
91
+ * A plugin whose Config declares volatile fields receives a reactive config
92
+ * whose fields expose `get()`, while hand-written config objects and tests pass
93
+ * plain values. Both shapes are supported here so the same code path serves the
94
+ * settings page, `cordis.patch.yml`, and the unit tests.
95
+ */
96
+ function liveValue(node, fallback) {
97
+ if (node === undefined || node === null)
98
+ return fallback;
99
+ const getter = node.get;
100
+ if (typeof getter === "function") {
101
+ const value = getter.call(node);
102
+ return value === undefined ? fallback : value;
103
+ }
104
+ return node;
105
+ }
106
+ /** Read the live `systemOne` section in whichever shape the loader supplied. */
107
+ function systemOneSection(config) {
108
+ const section = liveValue(config?.systemOne, undefined);
109
+ return (section ?? {});
110
+ }
111
+ /** Project a (possibly reactive) `systemOne` section into plain settings. */
112
+ function readSystemOne(config) {
113
+ const section = systemOneSection(config);
114
+ const mode = liveValue(section.mode, "noul");
115
+ return {
116
+ enabled: liveValue(section.enabled, true),
117
+ endpoint: liveValue(section.endpoint, "https://api.typesafe.ai/v1/systemone"),
118
+ model: liveValue(section.model, "jev-latest"),
119
+ mode: mode === "score" ? "score" : "noul",
120
+ threshold: liveValue(section.threshold, 0.5),
121
+ scoreMin: liveValue(section.scoreMin, 2),
122
+ minKeep: liveValue(section.minKeep, 1),
123
+ maxCandidates: liveValue(section.maxCandidates, 20),
124
+ maxTaskChars: liveValue(section.maxTaskChars, 2000),
125
+ timeoutMs: liveValue(section.timeoutMs, 8000),
126
+ apiKey: liveValue(section.apiKey, undefined),
127
+ apiKeyEnv: liveValue(section.apiKeyEnv, "TYPESAFE_API_KEY")
128
+ };
129
+ }
130
+ /**
131
+ * Resolve the API key for the configured credential reference: a literal key
132
+ * from the settings page wins, then the harness credentials service, then the
133
+ * launching environment. Mirrors the shipped web-search provider's order.
134
+ */
135
+ async function resolveApiKey(hostCtx, section) {
136
+ if (typeof section.apiKey === "string" && section.apiKey.length > 0)
137
+ return section.apiKey;
138
+ const ref = section.apiKeyEnv;
139
+ if (typeof ref !== "string" || ref.length === 0)
140
+ return undefined;
141
+ try {
142
+ const get = hostCtx?.get;
143
+ const credentials = typeof get === "function" ? get.call(hostCtx, "credentials") : undefined;
144
+ const resolve = credentials?.resolve;
145
+ if (typeof resolve === "function") {
146
+ const resolved = await resolve.call(credentials, ref);
147
+ if (resolved?.value !== undefined && resolved.value.length > 0)
148
+ return resolved.value;
149
+ }
150
+ }
151
+ catch {
152
+ // Credentials are optional: fall through to the environment.
153
+ }
154
+ const ambient = process.env[ref];
155
+ return ambient !== undefined && ambient.length > 0 ? ambient : undefined;
156
+ }
157
+ /** One text part's worth of content, whatever shape the message carries. */
158
+ function messageText(content) {
159
+ if (typeof content === "string")
160
+ return content;
161
+ if (!Array.isArray(content))
162
+ return undefined;
163
+ const parts = content
164
+ .filter((part) => part?.type === "text")
165
+ .map((part) => part.text)
166
+ .filter((text) => typeof text === "string");
167
+ return parts.length > 0 ? parts.join("\n") : undefined;
168
+ }
169
+ /**
170
+ * The agent's current task: the most recent user message in the derived
171
+ * history. Without it the gate would judge candidates against bare keywords,
172
+ * which is exactly the weak signal the gate is meant to compensate for.
173
+ */
174
+ function currentTask(exec) {
175
+ const session = exec?.agent?.session;
176
+ const derive = session?.deriveMessages;
177
+ if (typeof derive !== "function")
178
+ return undefined;
179
+ let messages;
180
+ try {
181
+ messages = derive.call(session);
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ if (!Array.isArray(messages))
187
+ return undefined;
188
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
189
+ const message = messages[index];
190
+ if (message?.role !== "user")
191
+ continue;
192
+ const text = messageText(message.content);
193
+ if (text !== undefined && text.trim().length > 0)
194
+ return text.trim();
195
+ }
196
+ return undefined;
197
+ }
45
198
  /** The calling agent's session workspace, when it has one. */
46
199
  function sessionCwd(exec) {
47
200
  return exec.agent?.session?.header?.cwd;
@@ -70,7 +223,16 @@ const SEARCH_HIT = {
70
223
  items: { type: "string" },
71
224
  description: "Structured keywords stored with the entry."
72
225
  },
73
- score: { type: "number", required: true, description: "Number of distinct query keywords matched." },
226
+ kind: {
227
+ type: "string",
228
+ required: true,
229
+ description: "Entry kind: task (task-outcome record) or topic (aggregated topic page)."
230
+ },
231
+ score: {
232
+ type: "number",
233
+ required: true,
234
+ description: "Relevance: distinct matched keywords + a mild recency bonus (≤ +0.5, decaying to 0 by ~180 days)."
235
+ },
74
236
  matched_keywords: {
75
237
  type: "array",
76
238
  required: true,
@@ -79,6 +241,29 @@ const SEARCH_HIT = {
79
241
  }
80
242
  }
81
243
  };
244
+ const LINK_RESOLUTION = {
245
+ type: "object",
246
+ additionalProperties: false,
247
+ properties: {
248
+ link_hash: { type: "string", required: true, description: "Hash exactly as written inside the [[...]] link." },
249
+ resolved_hash: {
250
+ type: "string",
251
+ required: true,
252
+ description: "Hash actually read: the active version of the linked entry (empty string when unresolvable)."
253
+ },
254
+ active: {
255
+ type: "boolean",
256
+ required: true,
257
+ description: "True when the link still points at the active version; false = superseded or dangling."
258
+ },
259
+ file: { type: "string", description: "Entry file path inside the memory repo." },
260
+ title: { type: "string", description: "Target entry title." },
261
+ summary: { type: "string", description: "Target entry summary." },
262
+ keywords: { type: "array", items: { type: "string" }, description: "Target entry keywords." },
263
+ kind: { type: "string", description: "Target entry kind: task or topic." },
264
+ error: { type: "string", description: "Present when the link target could not be resolved (dangling link)." }
265
+ }
266
+ };
82
267
  const KEYWORDS_ARRAY = {
83
268
  type: "array",
84
269
  items: { type: "string" },
@@ -109,6 +294,8 @@ function formatSearchResults(value) {
109
294
  hit.title +
110
295
  "|" +
111
296
  hit.date +
297
+ "|kind=" +
298
+ hit.kind +
112
299
  "|score=" +
113
300
  hit.score +
114
301
  "|matched=" +
@@ -118,6 +305,33 @@ function formatSearchResults(value) {
118
305
  }
119
306
  if (value.results.length === 0)
120
307
  lines.push("(no matching memories)");
308
+ if (value.gated !== undefined) {
309
+ lines.push("gated: mode=" +
310
+ value.gated.mode +
311
+ " model=" +
312
+ value.gated.model +
313
+ " judged=" +
314
+ value.gated.judged +
315
+ // Only surfaced when the page exceeded maxCandidates, so the default
316
+ // line stays byte-identical to before this field existed.
317
+ (value.gated.untouched > 0 ? " untouched=" + value.gated.untouched : "") +
318
+ " kept=" +
319
+ value.gated.kept +
320
+ " dropped=" +
321
+ value.gated.dropped +
322
+ // A degraded gate explains itself; a floor (every candidate rejected,
323
+ // minKeep applied) must be just as visible — otherwise `kept=1` reads
324
+ // as "one candidate genuinely passed".
325
+ (value.gated.degraded
326
+ ? " DEGRADED(" + (value.gated.reason ?? "unknown") + ")"
327
+ : value.gated.reason === undefined
328
+ ? ""
329
+ : " NOTE(" + value.gated.reason + ")"));
330
+ if (value.gated.dropped_hashes.length > 0) {
331
+ lines.push(" dropped by the gate (mem_read one of these short hashes to recover it): " +
332
+ value.gated.dropped_hashes.join(","));
333
+ }
334
+ }
121
335
  if (value.warning !== undefined)
122
336
  lines.push("warning: " + value.warning);
123
337
  for (const diagnostic of value.diagnostics ?? [])
@@ -128,7 +342,7 @@ function formatSearchResults(value) {
128
342
  function registerMemTools(ctx, config) {
129
343
  ctx.tools.register(defineTool({
130
344
  name: "mem_search",
131
- description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting repo-related work: extract 1-12 中英文关键词 from the user request (include synonyms in both languages when useful) and run mem_search; pure chat and general Q&A need no search. Returns a snapshot, total, next_skip and up to 20 scored hits with summary/keywords/matched_keywords — select at most 5 most relevant hits and mem_read them. Paginate by passing skip and the returned snapshot back unchanged; a stale snapshot is rejected explicitly.",
345
+ description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting repo-related work: extract 1-12 中英文关键词 from the user request (include synonyms in both languages when useful) and run mem_search; pure chat and general Q&A need no search. Returns a snapshot, total, next_skip and up to 20 scored hits with summary/keywords/kind/matched_keywords — score = distinct matched keywords + a mild recency bonus, so newer entries rank slightly higher and superseded conclusions lose to their replacements. Select at most 5 most relevant hits and mem_read them (prefer kind:\"topic\" hits as topic-page entry points). Paginate by passing skip and the returned snapshot back unchanged; a stale snapshot is rejected explicitly. An optional System-one gate may narrow the page: when the output carries a `gated` block, the entries it removed are listed as 8-char `dropped_hashes` — mem_read one of those if you suspect a relevant memory was filtered out (they stay fully readable). A `NOTE(...)` on the `gated` line means every candidate was judged irrelevant and only a `minKeep` floor was retained, not that one candidate passed.",
132
346
  parameters: {
133
347
  keywords: { ...KEYWORDS_ARRAY, required: true },
134
348
  skip: {
@@ -159,6 +373,47 @@ function registerMemTools(ctx, config) {
159
373
  type: "array",
160
374
  items: { type: "string" },
161
375
  description: "Malformed commit/file mappings skipped during search."
376
+ },
377
+ gated: {
378
+ type: "object",
379
+ additionalProperties: false,
380
+ description: "Present only when the System-one recall gate ran: how many candidates it judged (and how many past the cap it left untouched), how many it kept, the short hashes it dropped, and whether it had to fail open.",
381
+ properties: {
382
+ mode: { type: "string", required: true, description: "Question type used: noul or score." },
383
+ model: { type: "string", required: true, description: "System-one model the request named." },
384
+ candidates: { type: "number", required: true, description: "Size of the candidate page the gate was handed." },
385
+ judged: {
386
+ type: "number",
387
+ required: true,
388
+ description: "Candidates actually sent to the endpoint, i.e. min(maxCandidates, candidates)."
389
+ },
390
+ untouched: {
391
+ type: "number",
392
+ required: true,
393
+ description: "Candidates past maxCandidates: never evaluated, therefore never dropped. Non-zero only on a page larger than the cap."
394
+ },
395
+ kept: { type: "number", required: true, description: "Candidates the gate retained." },
396
+ dropped: { type: "number", required: true, description: "Candidates the gate removed." },
397
+ degraded: {
398
+ type: "boolean",
399
+ required: true,
400
+ description: "True when the gate failed open (endpoint/parse failure); every candidate was kept."
401
+ },
402
+ reason: {
403
+ type: "string",
404
+ description: "Why the gate degraded, or that it floored a wholly-rejected page (no candidate cleared the threshold, so minKeep were retained)."
405
+ },
406
+ input_tokens: {
407
+ type: "number",
408
+ description: "Input tokens the endpoint billed for the gate request (output tokens are free)."
409
+ },
410
+ dropped_hashes: {
411
+ type: "array",
412
+ required: true,
413
+ items: { type: "string" },
414
+ description: "8-char hashes of dropped candidates. Pass one to mem_read to recover a memory the gate judged irrelevant."
415
+ }
416
+ }
162
417
  }
163
418
  }
164
419
  },
@@ -167,11 +422,64 @@ function registerMemTools(ctx, config) {
167
422
  isConcurrencySafe: () => true,
168
423
  async execute(args, exec) {
169
424
  const memo = await engineFor(exec, config);
170
- return await memo.search(args.keywords, {
425
+ const result = await memo.search(args.keywords, {
171
426
  skip: args.skip ?? 0,
172
427
  snapshot: args.snapshot,
173
428
  mode: args.mode
174
429
  });
430
+ // System-one recall gate: purely subtractive, and only ever reached when
431
+ // an endpoint credential is configured. No credential => this returns the
432
+ // untouched engine result, so recall behaves exactly as it did before.
433
+ if (config.gateFor === undefined || result.results.length === 0)
434
+ return result;
435
+ const options = await config.gateFor();
436
+ if (options === undefined)
437
+ return result;
438
+ const task = currentTask(exec) ??
439
+ (Array.isArray(args.keywords) ? args.keywords.join(" ") : String(args.keywords));
440
+ const candidates = result.results.map((hit) => ({
441
+ hash: hit.hash,
442
+ title: hit.title,
443
+ summary: hit.summary,
444
+ keywords: hit.keywords,
445
+ kind: hit.kind
446
+ }));
447
+ const decision = await evaluateGate(task, candidates, options, exec?.signal);
448
+ // Per-candidate values go to the log (for threshold tuning) rather than
449
+ // into the model context, which is the very thing the gate is shrinking.
450
+ config.log?.("mem_search gate" +
451
+ (decision.degraded ? " DEGRADED" : "") +
452
+ " mode=" + decision.mode +
453
+ " model=" + decision.model +
454
+ " candidates=" + candidates.length +
455
+ " judged=" + decision.judged +
456
+ (decision.untouched === 0 ? "" : " untouched=" + decision.untouched) +
457
+ " kept=" + decision.kept.length +
458
+ " dropped=" + decision.dropped.length +
459
+ (decision.reason === undefined ? "" : " reason=" + decision.reason) +
460
+ (decision.usage?.inputTokens === undefined ? "" : " input_tokens=" + decision.usage.inputTokens) +
461
+ " verdicts=" +
462
+ decision.verdicts
463
+ .map((verdict) => verdict.hash.slice(0, 8) + ":" + (verdict.value ?? "na") + ":" + (verdict.keep ? "keep" : "drop"))
464
+ .join(","));
465
+ const kept = new Set(decision.kept);
466
+ return {
467
+ ...result,
468
+ results: result.results.filter((hit) => kept.has(hit.hash)),
469
+ gated: {
470
+ mode: decision.mode,
471
+ model: decision.model,
472
+ candidates: candidates.length,
473
+ judged: decision.judged,
474
+ untouched: decision.untouched,
475
+ kept: decision.kept.length,
476
+ dropped: decision.dropped.length,
477
+ degraded: decision.degraded,
478
+ ...(decision.reason === undefined ? {} : { reason: decision.reason }),
479
+ ...(decision.usage?.inputTokens === undefined ? {} : { input_tokens: decision.usage.inputTokens }),
480
+ dropped_hashes: decision.dropped.map((hash) => hash.slice(0, 8))
481
+ }
482
+ };
175
483
  },
176
484
  presentCall: (args) => ({
177
485
  card: "generic",
@@ -181,12 +489,16 @@ function registerMemTools(ctx, config) {
181
489
  }));
182
490
  ctx.tools.register(defineTool({
183
491
  name: "mem_read",
184
- description: "Read one gitmemo memory entry by commit hash (returned by mem_search). Select only the most relevant memories before reading — at most 5 when a search returns more. Historical entries that were replaced or deleted stay readable for audit.",
492
+ description: "Read one gitmemo memory entry by commit hash (returned by mem_search). Select only the most relevant memories before reading — at most 5 when a search returns more. Historical entries that were replaced or deleted stay readable for audit. Pass expand:true to also resolve [[<commit-hash>]] wiki links in the entry body one hop to their target entries (each target follows forward to its active version) — use it when reading a kind:\"topic\" topic page or any entry that links others.",
185
493
  parameters: {
186
494
  commit_hash: {
187
495
  type: "string",
188
496
  required: true,
189
497
  description: "Commit hash of the memory entry to read (create or replace hash)."
498
+ },
499
+ expand: {
500
+ type: "boolean",
501
+ description: "Resolve [[<commit-hash>]] links in the entry body one hop to their (active) target entries. Default false."
190
502
  }
191
503
  },
192
504
  output: {
@@ -197,16 +509,57 @@ function registerMemTools(ctx, config) {
197
509
  commit_hash: { type: "string", required: true },
198
510
  file: { type: "string", required: true, description: "Entry file path inside the memory repo." },
199
511
  content: { type: "string", required: true, description: "Full markdown of the memory entry." },
512
+ kind: {
513
+ type: "string",
514
+ required: true,
515
+ description: "Entry kind: task (task-outcome record) or topic (aggregated topic page)."
516
+ },
517
+ links: {
518
+ type: "array",
519
+ items: LINK_RESOLUTION,
520
+ description: "One-hop resolution of the entry's [[<commit-hash>]] links; present only when expand:true."
521
+ },
200
522
  legacy: { type: "boolean", description: "True for legacy-format commits (pre-migration)." }
201
523
  }
202
524
  },
203
- render: (_args, value) => textBlock((value.legacy === true ? "[legacy entry]\n" : "") + value.content)
525
+ render: (_args, value) => {
526
+ const parts = [(value.legacy === true ? "[legacy entry]\n" : "") + value.content];
527
+ if (value.links !== undefined && value.links.length > 0) {
528
+ parts.push("--- linked entries (" + value.links.length + ") ---");
529
+ for (const link of value.links) {
530
+ if (link.error !== undefined) {
531
+ parts.push("x " + link.link_hash.slice(0, 8) + " — unresolved: " + link.error);
532
+ continue;
533
+ }
534
+ const state = link.active
535
+ ? "active"
536
+ : link.resolved_hash !== link.link_hash
537
+ ? "superseded → " + link.resolved_hash.slice(0, 8)
538
+ : "not active (withdrawn)";
539
+ parts.push("= " + link.resolved_hash.slice(0, 8) + " [" + (link.kind ?? "task") + "] " +
540
+ (link.title ?? "") + " (" + state + ")");
541
+ if (link.summary !== undefined && link.summary.length > 0) {
542
+ parts.push(" " + link.summary.replace(/\s+/g, " ").slice(0, 400));
543
+ }
544
+ }
545
+ }
546
+ return textBlock(parts.join("\n"));
547
+ }
204
548
  },
205
549
  isConcurrencySafe: () => true,
206
550
  async execute(args, exec) {
207
551
  const memo = await engineFor(exec, config);
208
- const { hash, file, content, legacy } = await memo.read(args.commit_hash);
209
- return { commit_hash: hash, file, content, ...(legacy ? { legacy: true } : {}) };
552
+ const { hash, file, content, kind, links, legacy } = await memo.read(args.commit_hash, {
553
+ expand: args.expand === true
554
+ });
555
+ return {
556
+ commit_hash: hash,
557
+ file,
558
+ content,
559
+ kind,
560
+ ...(links !== undefined ? { links } : {}),
561
+ ...(legacy ? { legacy: true } : {})
562
+ };
210
563
  },
211
564
  presentCall: (args) => ({
212
565
  card: "generic",
@@ -216,7 +569,7 @@ function registerMemTools(ctx, config) {
216
569
  }));
217
570
  ctx.tools.register(defineTool({
218
571
  name: "mem_write",
219
- description: "Store a completed task outcome in the gitmemo long-term memory (.mem git repo). Write ONLY when the task is complete AND repo-related AND the outcome is valuable/reusable OR the user explicitly asked to remember. Entries are immutable: title (single line, ≤200 chars, \"[module] action + object\"), summary (≤4000 chars), keywords (2-12, include 中英文同义词) and content (markdown body; the engine generates front matter) are all required; related_branches/related_paths are optional. Keywords must ADD new search angles — pick task-related terms (中英文同义词 both fine) that are NOT already present in title/summary: words already in title/summary are searchable via those fields themselves, so repeating them does not improve recall. Corrections use mem_replace, withdrawal uses mem_delete — never rewrite in place.",
572
+ description: "Store a completed task outcome in the gitmemo long-term memory (.mem git repo). Write ONLY when the task is complete AND repo-related AND the outcome is valuable/reusable OR the user explicitly asked to remember. Entries are immutable: title (single line, ≤200 chars, \"[module] action + object\"), summary (≤4000 chars), keywords (2-12, include 中英文同义词) and content (markdown body; the engine generates front matter) are all required; related_branches/related_paths are optional. kind selects the entry type: \"task\" (default) is a task-outcome record; \"topic\" is an aggregated topic page (MOC) — its summary states the currently-valid conclusions and its content links evidence entries as [[<commit-hash>]] with a one-line status each; a topic page evolves via mem_replace, not per-task rewrites. Keywords must ADD new search angles — pick task-related terms (中英文同义词 both fine) that are NOT already present in title/summary: words already in title/summary are searchable via those fields themselves, so repeating them does not improve recall. Corrections and superseded conclusions use mem_replace, withdrawal uses mem_delete — never rewrite in place.",
220
573
  parameters: {
221
574
  title: {
222
575
  type: "string",
@@ -236,7 +589,11 @@ function registerMemTools(ctx, config) {
236
589
  content: {
237
590
  type: "string",
238
591
  required: true,
239
- description: "The memory markdown body (engine generates front matter and standard sections)."
592
+ description: "The memory markdown body (engine generates front matter and standard sections). Reference other entries as [[<commit-hash>]]."
593
+ },
594
+ kind: {
595
+ type: "string",
596
+ description: "Entry kind: \"task\" (default) or \"topic\" (aggregated topic page linking evidence entries via [[<commit-hash>]])."
240
597
  },
241
598
  related_branches: RELATED_BRANCHES,
242
599
  related_paths: RELATED_PATHS
@@ -265,7 +622,8 @@ function registerMemTools(ctx, config) {
265
622
  keywords: args.keywords ?? [],
266
623
  content: args.content ?? "",
267
624
  related_branches: args.related_branches,
268
- related_paths: args.related_paths
625
+ related_paths: args.related_paths,
626
+ kind: args.kind
269
627
  });
270
628
  },
271
629
  presentCall: (args) => ({
@@ -313,7 +671,7 @@ function registerMemTools(ctx, config) {
313
671
  }));
314
672
  ctx.tools.register(defineTool({
315
673
  name: "mem_replace",
316
- description: "Replace an active gitmemo memory entry with a new conclusion in ONE atomic commit (deletes the old entry file, adds the new one). Use when the user corrects a stored outcome — never delete-then-write. commit_hash must be the ACTIVE hash returned by mem_search; stale hashes are rejected. Takes the same fields as mem_write.",
674
+ description: "Replace an active gitmemo memory entry with a new conclusion in ONE atomic commit (deletes the old entry file, adds the new one). Use it whenever a stored conclusion is superseded — the user corrects a stored outcome, OR this session's own work overrules it (implemented/refactored away) — never delete-then-write. commit_hash must be the ACTIVE hash returned by mem_search; stale hashes are rejected. Takes the same fields as mem_write; kind defaults to the replaced entry's kind (refreshing a topic page stays a topic page).",
317
675
  parameters: {
318
676
  commit_hash: {
319
677
  type: "string",
@@ -338,7 +696,11 @@ function registerMemTools(ctx, config) {
338
696
  content: {
339
697
  type: "string",
340
698
  required: true,
341
- description: "The new memory markdown body."
699
+ description: "The new memory markdown body. Keep [[<commit-hash>]] links to evidence entries up to date."
700
+ },
701
+ kind: {
702
+ type: "string",
703
+ description: "Entry kind: \"task\" or \"topic\". Defaults to the replaced entry's kind when omitted."
342
704
  },
343
705
  related_branches: RELATED_BRANCHES,
344
706
  related_paths: RELATED_PATHS
@@ -364,7 +726,8 @@ function registerMemTools(ctx, config) {
364
726
  keywords: args.keywords ?? [],
365
727
  content: args.content ?? "",
366
728
  related_branches: args.related_branches,
367
- related_paths: args.related_paths
729
+ related_paths: args.related_paths,
730
+ kind: args.kind
368
731
  });
369
732
  },
370
733
  presentCall: (args) => ({
@@ -384,10 +747,12 @@ function registerPromptSection(ctx) {
384
747
  text: [
385
748
  "This deployment provides gitmemo long-term memory: a local .mem git repository at the project root stores past task outcomes as immutable markdown entries; git is the only dependency. Use the dedicated tools only (mem_search / mem_read / mem_write / mem_delete / mem_replace — all auto-initialize, and legacy-format .mem repos migrate automatically on first use); never shell out to git or read .mem files directly.",
386
749
  "- BEFORE WORK — search: 仓库相关任务开始前提取 1–12 个中英文关键词调用 mem_search(含中英文同义词);纯闲聊和通用问答无需搜索。",
387
- "- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;无相关结果时用返回的 snapshot + skip 翻页。",
750
+ "- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;kind 为 topic 的条目是该主题的聚合入口,优先读;无相关结果时用返回的 snapshot + skip 翻页。",
388
751
  "- END-OF-SESSION CHECKPOINT — the ONLY write path: 会话结束时回顾全程,仅当结论有价值、可复用或用户明确要求记住时 mem_write;纯问答、未完成任务、非仓库工作、纯操作类 git 动作(仅 commit/push)不写;避免语义重复。",
389
752
  "- KEYWORD SELECTION — mem_write/mem_replace 的 keywords 应选择与任务相关但未出现在 title/summary 中的词(中英文同义词皆可);title/summary 里已有的词本身就能被检索到,重复它们不会提高召回率。",
390
- "- USER CORRECTION — 用户更正已有结论:有替代结论时调用 mem_replace(不得先 delete 再 write);结论作废且无替代时调用带 reason 的 mem_delete。",
753
+ "- KEEP FRESH — 结论保鲜是写路径的一部分,不必等用户指出:用户更正旧结论时,有替代结论用 mem_replace(禁止先 delete 再 write),作废且无替代用带 reason 的 mem_delete;本次会话的工作覆盖/推翻了某条已存结论(被后续实现、重构取代)时,同样主动 mem_replace 或 mem_delete 对应旧条目;检索结果中新旧结论冲突时,优先采信 score 更高/日期更新的条目(score 已含温和的 recency 加成)。",
754
+ "- TOPIC PAGE — 同一主题已积累多条记忆、或需要一个「当前真相」入口时,用 mem_write 写一条 kind:\"topic\" 聚合条目:summary 概括当下仍然有效的结论,content 用 [[<commit-hash>]] 链接各证据条目并各配一句话状态;topic 条目靠 mem_replace 演进(kind 自动继承),不是按任务新增。",
755
+ "- WIKI LINKS — content 中引用其他记忆条目一律写 [[<commit-hash>]](用 mem_search/mem_read 返回的 hash);mem_read 传 expand:true 会把链接一跳展开到各条目的最新版本(悬空链接以 error 返回),读 topic 条目时建议展开。",
391
756
  "- SUBAGENT RESULTS — 子代理结果由根 Agent 汇总后决定是否形成一条会话级记忆。"
392
757
  ].join("\n")
393
758
  });
@@ -398,10 +763,42 @@ function registerPromptSection(ctx) {
398
763
  * registration time.
399
764
  */
400
765
  async function apply(ctx, config = {}) {
766
+ const log = (message) => {
767
+ if (typeof ctx.logger.info === "function")
768
+ ctx.logger.info(message);
769
+ };
401
770
  const resolved = {
402
771
  searchLimit: config.searchLimit ?? 20,
403
772
  lockTimeoutMs: config.lockTimeoutMs ?? 30000,
404
- projectRoot: config.projectRoot
773
+ projectRoot: config.projectRoot,
774
+ log,
775
+ /**
776
+ * Resolved per search so settings-page edits take effect immediately.
777
+ * Returns undefined — leaving recall byte-for-byte unchanged — whenever the
778
+ * gate is switched off or has no usable credential.
779
+ */
780
+ gateFor: async () => {
781
+ const section = readSystemOne(config);
782
+ if (!section.enabled)
783
+ return undefined;
784
+ if (typeof section.endpoint !== "string" || section.endpoint.length === 0)
785
+ return undefined;
786
+ const apiKey = await resolveApiKey(ctx, section);
787
+ if (apiKey === undefined)
788
+ return undefined;
789
+ return {
790
+ endpoint: section.endpoint,
791
+ model: section.model,
792
+ mode: section.mode,
793
+ threshold: section.threshold,
794
+ scoreMin: section.scoreMin,
795
+ minKeep: section.minKeep,
796
+ maxCandidates: section.maxCandidates,
797
+ maxTaskChars: section.maxTaskChars,
798
+ timeoutMs: section.timeoutMs,
799
+ apiKey
800
+ };
801
+ }
405
802
  };
406
803
  if (config.branchAlign !== undefined && config.branchAlign !== true) {
407
804
  ctx.logger.warn("dsh-gitmemo: `branchAlign` is deprecated and has no effect — .mem always stays on main");