@fonlan/dsh-gitmemo 0.4.1 → 0.5.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.d.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  * @module dsh-gitmemo
22
22
  */
23
23
  import z from "@deepseek-ai/schemastery";
24
+ import { type SearchScoring } from "./mem.js";
24
25
  /** Cordis plugin name. */
25
26
  declare const name = "dsh-gitmemo";
26
27
  /** Host services this plugin needs. */
@@ -31,6 +32,21 @@ declare const Config: z<Schemastery.ObjectS<NoInfer<{
31
32
  memDirName: z<string, string, "defined">;
32
33
  /** Max search hits per mem_search call (page size). Default 20. */
33
34
  searchLimit: z<number, number, "defined">;
35
+ /**
36
+ * How a matched entry is scored (search order only — matching itself is
37
+ * identical in both modes).
38
+ *
39
+ * - `weighted` (default): sum of per-keyword rarity weights within the
40
+ * recall page, so a distinctive match outranks several common ones.
41
+ * - `count`: the previous default behaviour (one point per matched keyword plus the
42
+ * recency bonus), kept for exact backwards compatibility.
43
+ *
44
+ * Measured motivation: on LongMemEval `_S` the engine reached
45
+ * recall_all@50 = 0.998 while ndcg@5 was only 0.686 — evidence was reachable
46
+ * but ranked poorly, because a saturated-free match count cannot tell a rare
47
+ * keyword from a ubiquitous one.
48
+ */
49
+ searchScoring: z<"count" | "weighted", "count" | "weighted", "defined">;
34
50
  /** @deprecated — `.mem` always stays on main; kept for one version, no effect. */
35
51
  branchAlign: z<boolean, boolean, "defined">;
36
52
  /** @deprecated — session-start recent seeding was removed; kept for one version, no effect. */
@@ -74,6 +90,25 @@ declare const Config: z<Schemastery.ObjectS<NoInfer<{
74
90
  scoreMin: z<number, number, "volatile-defined">;
75
91
  /** Retain at least this many candidates when the model rejects an entire page. */
76
92
  minKeep: z<number, number, "volatile-defined">;
93
+ /**
94
+ * What happens to a judged candidate.
95
+ *
96
+ * `rerank` (default) keeps every candidate and lets the judge's value order
97
+ * the page; `filter` drops those below the threshold, bounded by
98
+ * `maxDropFraction`. Measured end-to-end on LongMemEval `_S` with the same
99
+ * judge and the same per-candidate values: filtering at the default
100
+ * threshold scored 0.3800, ordering scored 0.8020. The judge's AUC is
101
+ * ~0.71-0.75, so no threshold is safe, but the ordering is useful. See
102
+ * evals/longmemeval/README.md.
103
+ */
104
+ policy: z<"filter" | "rerank", "filter" | "rerank", "volatile-defined">;
105
+ /**
106
+ * `filter` policy only: never drop more than this share of the judged page,
107
+ * lowest-scoring first. Caps the damage a mis-calibrated threshold can do —
108
+ * a memory dropped is gone, a memory kept only costs context. The offline
109
+ * sweep held recall_all@5 at 0.8875 with a 20 % cap against 0.6875 uncapped.
110
+ */
111
+ maxDropFraction: z<number, number, "volatile-defined">;
77
112
  /** Never judge more than this many candidates per request. */
78
113
  maxCandidates: z<number, number, "volatile-defined">;
79
114
  /** Truncate the task text to this many characters before sending it. */
@@ -101,6 +136,25 @@ declare const Config: z<Schemastery.ObjectS<NoInfer<{
101
136
  scoreMin: z<number, number, "volatile-defined">;
102
137
  /** Retain at least this many candidates when the model rejects an entire page. */
103
138
  minKeep: z<number, number, "volatile-defined">;
139
+ /**
140
+ * What happens to a judged candidate.
141
+ *
142
+ * `rerank` (default) keeps every candidate and lets the judge's value order
143
+ * the page; `filter` drops those below the threshold, bounded by
144
+ * `maxDropFraction`. Measured end-to-end on LongMemEval `_S` with the same
145
+ * judge and the same per-candidate values: filtering at the default
146
+ * threshold scored 0.3800, ordering scored 0.8020. The judge's AUC is
147
+ * ~0.71-0.75, so no threshold is safe, but the ordering is useful. See
148
+ * evals/longmemeval/README.md.
149
+ */
150
+ policy: z<"filter" | "rerank", "filter" | "rerank", "volatile-defined">;
151
+ /**
152
+ * `filter` policy only: never drop more than this share of the judged page,
153
+ * lowest-scoring first. Caps the damage a mis-calibrated threshold can do —
154
+ * a memory dropped is gone, a memory kept only costs context. The offline
155
+ * sweep held recall_all@5 at 0.8875 with a 20 % cap against 0.6875 uncapped.
156
+ */
157
+ maxDropFraction: z<number, number, "volatile-defined">;
104
158
  /** Never judge more than this many candidates per request. */
105
159
  maxCandidates: z<number, number, "volatile-defined">;
106
160
  /** Truncate the task text to this many characters before sending it. */
@@ -113,6 +167,21 @@ declare const Config: z<Schemastery.ObjectS<NoInfer<{
113
167
  memDirName: z<string, string, "defined">;
114
168
  /** Max search hits per mem_search call (page size). Default 20. */
115
169
  searchLimit: z<number, number, "defined">;
170
+ /**
171
+ * How a matched entry is scored (search order only — matching itself is
172
+ * identical in both modes).
173
+ *
174
+ * - `weighted` (default): sum of per-keyword rarity weights within the
175
+ * recall page, so a distinctive match outranks several common ones.
176
+ * - `count`: the previous default behaviour (one point per matched keyword plus the
177
+ * recency bonus), kept for exact backwards compatibility.
178
+ *
179
+ * Measured motivation: on LongMemEval `_S` the engine reached
180
+ * recall_all@50 = 0.998 while ndcg@5 was only 0.686 — evidence was reachable
181
+ * but ranked poorly, because a saturated-free match count cannot tell a rare
182
+ * keyword from a ubiquitous one.
183
+ */
184
+ searchScoring: z<"count" | "weighted", "count" | "weighted", "defined">;
116
185
  /** @deprecated — `.mem` always stays on main; kept for one version, no effect. */
117
186
  branchAlign: z<boolean, boolean, "defined">;
118
187
  /** @deprecated — session-start recent seeding was removed; kept for one version, no effect. */
@@ -156,6 +225,25 @@ declare const Config: z<Schemastery.ObjectS<NoInfer<{
156
225
  scoreMin: z<number, number, "volatile-defined">;
157
226
  /** Retain at least this many candidates when the model rejects an entire page. */
158
227
  minKeep: z<number, number, "volatile-defined">;
228
+ /**
229
+ * What happens to a judged candidate.
230
+ *
231
+ * `rerank` (default) keeps every candidate and lets the judge's value order
232
+ * the page; `filter` drops those below the threshold, bounded by
233
+ * `maxDropFraction`. Measured end-to-end on LongMemEval `_S` with the same
234
+ * judge and the same per-candidate values: filtering at the default
235
+ * threshold scored 0.3800, ordering scored 0.8020. The judge's AUC is
236
+ * ~0.71-0.75, so no threshold is safe, but the ordering is useful. See
237
+ * evals/longmemeval/README.md.
238
+ */
239
+ policy: z<"filter" | "rerank", "filter" | "rerank", "volatile-defined">;
240
+ /**
241
+ * `filter` policy only: never drop more than this share of the judged page,
242
+ * lowest-scoring first. Caps the damage a mis-calibrated threshold can do —
243
+ * a memory dropped is gone, a memory kept only costs context. The offline
244
+ * sweep held recall_all@5 at 0.8875 with a 20 % cap against 0.6875 uncapped.
245
+ */
246
+ maxDropFraction: z<number, number, "volatile-defined">;
159
247
  /** Never judge more than this many candidates per request. */
160
248
  maxCandidates: z<number, number, "volatile-defined">;
161
249
  /** Truncate the task text to this many characters before sending it. */
@@ -183,6 +271,25 @@ declare const Config: z<Schemastery.ObjectS<NoInfer<{
183
271
  scoreMin: z<number, number, "volatile-defined">;
184
272
  /** Retain at least this many candidates when the model rejects an entire page. */
185
273
  minKeep: z<number, number, "volatile-defined">;
274
+ /**
275
+ * What happens to a judged candidate.
276
+ *
277
+ * `rerank` (default) keeps every candidate and lets the judge's value order
278
+ * the page; `filter` drops those below the threshold, bounded by
279
+ * `maxDropFraction`. Measured end-to-end on LongMemEval `_S` with the same
280
+ * judge and the same per-candidate values: filtering at the default
281
+ * threshold scored 0.3800, ordering scored 0.8020. The judge's AUC is
282
+ * ~0.71-0.75, so no threshold is safe, but the ordering is useful. See
283
+ * evals/longmemeval/README.md.
284
+ */
285
+ policy: z<"filter" | "rerank", "filter" | "rerank", "volatile-defined">;
286
+ /**
287
+ * `filter` policy only: never drop more than this share of the judged page,
288
+ * lowest-scoring first. Caps the damage a mis-calibrated threshold can do —
289
+ * a memory dropped is gone, a memory kept only costs context. The offline
290
+ * sweep held recall_all@5 at 0.8875 with a 20 % cap against 0.6875 uncapped.
291
+ */
292
+ maxDropFraction: z<number, number, "volatile-defined">;
186
293
  /** Never judge more than this many candidates per request. */
187
294
  maxCandidates: z<number, number, "volatile-defined">;
188
295
  /** Truncate the task text to this many characters before sending it. */
@@ -231,6 +338,7 @@ declare function apply(ctx: {
231
338
  }, config?: Partial<{
232
339
  memDirName?: string;
233
340
  searchLimit?: number;
341
+ searchScoring?: SearchScoring;
234
342
  branchAlign?: boolean;
235
343
  recentContextLimit?: number;
236
344
  lockTimeoutMs?: number;
package/lib/index.js CHANGED
@@ -34,6 +34,21 @@ const Config = z.object({
34
34
  memDirName: z.string().default(".mem"),
35
35
  /** Max search hits per mem_search call (page size). Default 20. */
36
36
  searchLimit: z.number().default(20),
37
+ /**
38
+ * How a matched entry is scored (search order only — matching itself is
39
+ * identical in both modes).
40
+ *
41
+ * - `weighted` (default): sum of per-keyword rarity weights within the
42
+ * recall page, so a distinctive match outranks several common ones.
43
+ * - `count`: the previous default behaviour (one point per matched keyword plus the
44
+ * recency bonus), kept for exact backwards compatibility.
45
+ *
46
+ * Measured motivation: on LongMemEval `_S` the engine reached
47
+ * recall_all@50 = 0.998 while ndcg@5 was only 0.686 — evidence was reachable
48
+ * but ranked poorly, because a saturated-free match count cannot tell a rare
49
+ * keyword from a ubiquitous one.
50
+ */
51
+ searchScoring: z.union(["count", "weighted"]).default("weighted"),
37
52
  /** @deprecated — `.mem` always stays on main; kept for one version, no effect. */
38
53
  branchAlign: z.boolean().default(true),
39
54
  /** @deprecated — session-start recent seeding was removed; kept for one version, no effect. */
@@ -77,6 +92,25 @@ const Config = z.object({
77
92
  scoreMin: z.number().default(2).volatile(),
78
93
  /** Retain at least this many candidates when the model rejects an entire page. */
79
94
  minKeep: z.number().step(1).min(0).default(1).volatile(),
95
+ /**
96
+ * What happens to a judged candidate.
97
+ *
98
+ * `rerank` (default) keeps every candidate and lets the judge's value order
99
+ * the page; `filter` drops those below the threshold, bounded by
100
+ * `maxDropFraction`. Measured end-to-end on LongMemEval `_S` with the same
101
+ * judge and the same per-candidate values: filtering at the default
102
+ * threshold scored 0.3800, ordering scored 0.8020. The judge's AUC is
103
+ * ~0.71-0.75, so no threshold is safe, but the ordering is useful. See
104
+ * evals/longmemeval/README.md.
105
+ */
106
+ policy: z.union(["rerank", "filter"]).default("rerank").volatile(),
107
+ /**
108
+ * `filter` policy only: never drop more than this share of the judged page,
109
+ * lowest-scoring first. Caps the damage a mis-calibrated threshold can do —
110
+ * a memory dropped is gone, a memory kept only costs context. The offline
111
+ * sweep held recall_all@5 at 0.8875 with a 20 % cap against 0.6875 uncapped.
112
+ */
113
+ maxDropFraction: z.number().min(0).max(1).default(0.25).volatile(),
80
114
  /** Never judge more than this many candidates per request. */
81
115
  maxCandidates: z.number().step(1).min(1).default(20).volatile(),
82
116
  /** Truncate the task text to this many characters before sending it. */
@@ -120,6 +154,8 @@ function readSystemOne(config) {
120
154
  threshold: liveValue(section.threshold, 0.5),
121
155
  scoreMin: liveValue(section.scoreMin, 2),
122
156
  minKeep: liveValue(section.minKeep, 1),
157
+ policy: liveValue(section.policy, "rerank") === "filter" ? "filter" : "rerank",
158
+ maxDropFraction: liveValue(section.maxDropFraction, 0.25),
123
159
  maxCandidates: liveValue(section.maxCandidates, 20),
124
160
  maxTaskChars: liveValue(section.maxTaskChars, 2000),
125
161
  timeoutMs: liveValue(section.timeoutMs, 8000),
@@ -205,7 +241,8 @@ async function engineFor(exec, config) {
205
241
  const root = config.projectRoot ?? (await resolveProjectRoot(cwd));
206
242
  const engineConfig = {
207
243
  searchLimit: config.searchLimit,
208
- lockTimeoutMs: config.lockTimeoutMs
244
+ lockTimeoutMs: config.lockTimeoutMs,
245
+ searchScoring: config.searchScoring
209
246
  };
210
247
  return new GitMemo(root, engineConfig);
211
248
  }
@@ -231,7 +268,9 @@ const SEARCH_HIT = {
231
268
  score: {
232
269
  type: "number",
233
270
  required: true,
234
- description: "Relevance: distinct matched keywords + a mild recency bonus (≤ +0.5, decaying to 0 by ~180 days)."
271
+ description: "Relevance. In the default `weighted` scoring: the summed rarity of the matched keywords within this page " +
272
+ "(a distinctive match outweighs several common ones) plus a mild recency bonus (≤ +0.5, decaying to 0 by ~180 days). " +
273
+ "With `searchScoring: count`: distinct matched keywords + the same recency bonus."
235
274
  },
236
275
  matched_keywords: {
237
276
  type: "array",
@@ -264,10 +303,17 @@ const LINK_RESOLUTION = {
264
303
  error: { type: "string", description: "Present when the link target could not be resolved (dangling link)." }
265
304
  }
266
305
  };
306
+ /** Stored-entry keywords (mem_write / mem_replace): 2-12, mirrors MAX_WRITE_KEYWORDS. */
267
307
  const KEYWORDS_ARRAY = {
268
308
  type: "array",
269
309
  items: { type: "string" },
270
- description: "1-12 keywords (include 中英文 synonyms when useful)."
310
+ description: "2-12 keywords (include 中英文 synonyms when useful)."
311
+ };
312
+ /** Query keywords (mem_search): 1-15, mirrors MAX_SEARCH_KEYWORDS. */
313
+ const SEARCH_KEYWORDS_ARRAY = {
314
+ type: "array",
315
+ items: { type: "string" },
316
+ description: "1-15 keywords (include 中英文 synonyms when useful)."
271
317
  };
272
318
  const RELATED_BRANCHES = {
273
319
  type: "array",
@@ -342,9 +388,9 @@ function formatSearchResults(value) {
342
388
  function registerMemTools(ctx, config) {
343
389
  ctx.tools.register(defineTool({
344
390
  name: "mem_search",
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.",
391
+ description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting repo-related work: extract 1-15 中英文关键词 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.",
346
392
  parameters: {
347
- keywords: { ...KEYWORDS_ARRAY, required: true },
393
+ keywords: { ...SEARCH_KEYWORDS_ARRAY, required: true },
348
394
  skip: {
349
395
  type: "integer",
350
396
  description: "Pagination offset (default 0)."
@@ -454,8 +500,10 @@ function registerMemTools(ctx, config) {
454
500
  " candidates=" + candidates.length +
455
501
  " judged=" + decision.judged +
456
502
  (decision.untouched === 0 ? "" : " untouched=" + decision.untouched) +
503
+ " policy=" + decision.policy +
457
504
  " kept=" + decision.kept.length +
458
505
  " dropped=" + decision.dropped.length +
506
+ (decision.bounded ? " bounded=yes" : "") +
459
507
  (decision.reason === undefined ? "" : " reason=" + decision.reason) +
460
508
  (decision.usage?.inputTokens === undefined ? "" : " input_tokens=" + decision.usage.inputTokens) +
461
509
  " verdicts=" +
@@ -463,10 +511,20 @@ function registerMemTools(ctx, config) {
463
511
  .map((verdict) => verdict.hash.slice(0, 8) + ":" + (verdict.value ?? "na") + ":" + (verdict.keep ? "keep" : "drop"))
464
512
  .join(","));
465
513
  const kept = new Set(decision.kept);
514
+ // `rerank` reorders the page by the judge's value and drops nothing;
515
+ // `filter` keeps the shipped behaviour, already bounded by
516
+ // `maxDropFraction` inside `decide`. Both preserve page order for
517
+ // everything the gate left alone.
518
+ const surviving = result.results.filter((hit) => kept.has(hit.hash));
519
+ const reranked = decision.policy === "rerank"
520
+ ? [...surviving].sort((a, b) => decision.ordered.indexOf(a.hash) - decision.ordered.indexOf(b.hash))
521
+ : surviving;
466
522
  return {
467
523
  ...result,
468
- results: result.results.filter((hit) => kept.has(hit.hash)),
524
+ results: reranked,
469
525
  gated: {
526
+ policy: decision.policy,
527
+ ...(decision.bounded ? { bounded: true } : {}),
470
528
  mode: decision.mode,
471
529
  model: decision.model,
472
530
  candidates: candidates.length,
@@ -746,10 +804,10 @@ function registerPromptSection(ctx) {
746
804
  order: 150,
747
805
  text: [
748
806
  "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.",
749
- "- BEFORE WORK — search: 仓库相关任务开始前提取 1–12 个中英文关键词调用 mem_search(含中英文同义词);纯闲聊和通用问答无需搜索。",
807
+ "- BEFORE WORK — search: 仓库相关任务开始前提取 1–15 个中英文关键词调用 mem_search(含中英文同义词;搜索关键词上限高于写入上限,详见 KEYWORD SELECTION);纯闲聊和通用问答无需搜索。",
750
808
  "- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;kind 为 topic 的条目是该主题的聚合入口,优先读;无相关结果时用返回的 snapshot + skip 翻页。",
751
809
  "- END-OF-SESSION CHECKPOINT — the ONLY write path: 会话结束时回顾全程,仅当结论有价值、可复用或用户明确要求记住时 mem_write;纯问答、未完成任务、非仓库工作、纯操作类 git 动作(仅 commit/push)不写;避免语义重复。",
752
- "- KEYWORD SELECTION — mem_write/mem_replace 的 keywords 应选择与任务相关但未出现在 title/summary 中的词(中英文同义词皆可);title/summary 里已有的词本身就能被检索到,重复它们不会提高召回率。",
810
+ "- KEYWORD SELECTION — mem_write/mem_replace 的 keywords 应选择与任务相关但未出现在 title/summary 中的词(中英文同义词皆可,2–12 个);mem_search 的查询词上限更高(1–15 个),多给近义词/中英对照可提升召回。title/summary 里已有的词本身就能被检索到,重复它们不会提高召回率。",
753
811
  "- KEEP FRESH — 结论保鲜是写路径的一部分,不必等用户指出:用户更正旧结论时,有替代结论用 mem_replace(禁止先 delete 再 write),作废且无替代用带 reason 的 mem_delete;本次会话的工作覆盖/推翻了某条已存结论(被后续实现、重构取代)时,同样主动 mem_replace 或 mem_delete 对应旧条目;检索结果中新旧结论冲突时,优先采信 score 更高/日期更新的条目(score 已含温和的 recency 加成)。",
754
812
  "- TOPIC PAGE — 同一主题已积累多条记忆、或需要一个「当前真相」入口时,用 mem_write 写一条 kind:\"topic\" 聚合条目:summary 概括当下仍然有效的结论,content 用 [[<commit-hash>]] 链接各证据条目并各配一句话状态;topic 条目靠 mem_replace 演进(kind 自动继承),不是按任务新增。",
755
813
  "- WIKI LINKS — content 中引用其他记忆条目一律写 [[<commit-hash>]](用 mem_search/mem_read 返回的 hash);mem_read 传 expand:true 会把链接一跳展开到各条目的最新版本(悬空链接以 error 返回),读 topic 条目时建议展开。",
@@ -769,6 +827,7 @@ async function apply(ctx, config = {}) {
769
827
  };
770
828
  const resolved = {
771
829
  searchLimit: config.searchLimit ?? 20,
830
+ searchScoring: config.searchScoring ?? "weighted",
772
831
  lockTimeoutMs: config.lockTimeoutMs ?? 30000,
773
832
  projectRoot: config.projectRoot,
774
833
  log,
@@ -793,6 +852,8 @@ async function apply(ctx, config = {}) {
793
852
  threshold: section.threshold,
794
853
  scoreMin: section.scoreMin,
795
854
  minKeep: section.minKeep,
855
+ policy: section.policy,
856
+ maxDropFraction: section.maxDropFraction,
796
857
  maxCandidates: section.maxCandidates,
797
858
  maxTaskChars: section.maxTaskChars,
798
859
  timeoutMs: section.timeoutMs,
package/lib/mem.d.ts CHANGED
@@ -10,6 +10,15 @@ export declare const LOCK_FILE_NAME = ".mem.gitmemo.lock";
10
10
  export declare const MIGRATION_JOURNAL_NAME = ".mem.gitmemo-migration.json";
11
11
  /** Max entry content size. */
12
12
  export declare const MAX_CONTENT_BYTES: number;
13
+ /** Upper bound on stored entry keywords (mem_write / mem_replace). */
14
+ export declare const MAX_WRITE_KEYWORDS = 12;
15
+ /**
16
+ * Upper bound on query keywords for one mem_search call. Deliberately higher
17
+ * than the write bound: recall is a fixed-string OR over the query terms, so a
18
+ * wider (still human-curated) query only widens recall, while every matched
19
+ * term still contributes to the score and the write side keeps entries tight.
20
+ */
21
+ export declare const MAX_SEARCH_KEYWORDS = 15;
13
22
  /** Error type thrown by every gitmemo operation. */
14
23
  export declare class GitMemoError extends Error {
15
24
  }
@@ -21,6 +30,17 @@ export interface GitMemoConfig {
21
30
  gitTimeoutMs?: number;
22
31
  /** Cross-process lock wait timeout in milliseconds. Default 30000. */
23
32
  lockTimeoutMs?: number;
33
+ /**
34
+ * How `mem_search` scores a matched entry.
35
+ *
36
+ * - `weighted` (default): sum of per-keyword rarity weights over the recall
37
+ * page. A distinctive match outranks several common ones.
38
+ * - `count`: the previous default behaviour — one point per matched keyword plus the
39
+ * recency bonus. Kept for exact backwards compatibility.
40
+ *
41
+ * Both modes match the same entries; only the ordering differs.
42
+ */
43
+ searchScoring?: SearchScoring;
24
44
  /** @deprecated parsed for compatibility only — the memory dir is fixed at `<projectRoot>/.mem`. */
25
45
  memDirName?: string;
26
46
  /** @deprecated parsed for compatibility only — `.mem` always stays on main. */
@@ -28,6 +48,10 @@ export interface GitMemoConfig {
28
48
  /** @deprecated parsed for compatibility only — session-start recent seeding was removed. */
29
49
  recentContextLimit?: number;
30
50
  }
51
+ /** Search scoring mode; see {@link GitMemoConfig.searchScoring}. */
52
+ export type SearchScoring = "count" | "weighted";
53
+ /** Accepted `searchScoring` values, for config validation and tool schemas. */
54
+ export declare const SEARCH_SCORING_MODES: readonly SearchScoring[];
31
55
  /** One scored search hit. */
32
56
  export interface SearchHit {
33
57
  hash: string;
@@ -258,6 +282,18 @@ export declare function buildEntryMarkdown(input: ValidatedWriteInput, ctx: Code
258
282
  * matched keyword, so freshness only reorders equal-match entries.
259
283
  */
260
284
  export declare function recencyBonus(committerTime: number, nowMs?: number): number;
285
+ /**
286
+ * Rarity weight of a keyword matched by `df` of `n` candidates, in the
287
+ * BM25-style form already used across this project. A term matched by every
288
+ * candidate is worth almost nothing; a term matched by one candidate out of
289
+ * many is worth the most.
290
+ *
291
+ * Used by `searchScoring: "weighted"` so a distinctive match can outrank a
292
+ * pile of common ones. The weight is computed over the *recall page*, which
293
+ * costs no extra git call (the candidate commit messages are already in hand)
294
+ * and is the pool the ordering actually has to get right.
295
+ */
296
+ export declare function keywordRarity(df: number, n: number): number;
261
297
  /** ADD commit message (plan 4.1). */
262
298
  export declare function buildAddMessage(title: string, summary: string, keywords: string[], digest: string, legacy?: boolean, kind?: "task" | "topic"): string;
263
299
  /** DELETE commit message (plan 4.2). */
@@ -349,7 +385,7 @@ export declare class GitMemo {
349
385
  * messages only, active-entry filtering, field-level matching and scoring,
350
386
  * snapshot-stable pagination, in-process LRU.
351
387
  *
352
- * @param keywords - 1-12 keywords (legacy callers may pass a comma-separated string).
388
+ * @param keywords - 1-15 keywords (legacy callers may pass a comma-separated string).
353
389
  * @param options.skip - pagination offset.
354
390
  * @param options.snapshot - pass back the snapshot from the first page.
355
391
  */
package/lib/mem.js CHANGED
@@ -34,9 +34,20 @@ export const LOCK_FILE_NAME = ".mem.gitmemo.lock";
34
34
  export const MIGRATION_JOURNAL_NAME = ".mem.gitmemo-migration.json";
35
35
  /** Max entry content size. */
36
36
  export const MAX_CONTENT_BYTES = 1024 * 1024;
37
+ /** Upper bound on stored entry keywords (mem_write / mem_replace). */
38
+ export const MAX_WRITE_KEYWORDS = 12;
39
+ /**
40
+ * Upper bound on query keywords for one mem_search call. Deliberately higher
41
+ * than the write bound: recall is a fixed-string OR over the query terms, so a
42
+ * wider (still human-curated) query only widens recall, while every matched
43
+ * term still contributes to the score and the write side keeps entries tight.
44
+ */
45
+ export const MAX_SEARCH_KEYWORDS = 15;
37
46
  /** Error type thrown by every gitmemo operation. */
38
47
  export class GitMemoError extends Error {
39
48
  }
49
+ /** Accepted `searchScoring` values, for config validation and tool schemas. */
50
+ export const SEARCH_SCORING_MODES = ["count", "weighted"];
40
51
  const UNSAFE_CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/;
41
52
  const TRAILER_INJECT_RE = /^\s*GitMemo-[A-Za-z0-9-]+:/im;
42
53
  const COMMIT_RE = /^[0-9a-f]{40}$/;
@@ -256,8 +267,8 @@ export function validateWriteInput(input, legacy = false) {
256
267
  throw new GitMemoError("gitmemo: keywords must be an array");
257
268
  const rawKeywords = input.keywords.map((k) => (typeof k === "string" ? k.trim() : "")).filter((k) => k.length > 0);
258
269
  const minKeywords = legacy ? 0 : 2;
259
- if (rawKeywords.length < minKeywords || rawKeywords.length > 12) {
260
- throw new GitMemoError(`gitmemo: keywords must contain ${minKeywords === 0 ? "0-12" : "2-12"} entries, got ${rawKeywords.length}`);
270
+ if (rawKeywords.length < minKeywords || rawKeywords.length > MAX_WRITE_KEYWORDS) {
271
+ throw new GitMemoError(`gitmemo: keywords must contain ${minKeywords === 0 ? "0" : "2"}-${MAX_WRITE_KEYWORDS} entries, got ${rawKeywords.length}`);
261
272
  }
262
273
  const keywords = [];
263
274
  const seen = new Set();
@@ -271,7 +282,7 @@ export function validateWriteInput(input, legacy = false) {
271
282
  keywords.push(kw);
272
283
  }
273
284
  if (keywords.length < minKeywords) {
274
- throw new GitMemoError(`gitmemo: keywords must contain ${minKeywords === 0 ? "0-12" : "2-12"} distinct entries after normalization, got ${keywords.length}`);
285
+ throw new GitMemoError(`gitmemo: keywords must contain ${minKeywords === 0 ? "0" : "2"}-${MAX_WRITE_KEYWORDS} distinct entries after normalization, got ${keywords.length}`);
275
286
  }
276
287
  let related_branches = [];
277
288
  if (input.related_branches !== undefined) {
@@ -495,6 +506,23 @@ export function recencyBonus(committerTime, nowMs = Date.now()) {
495
506
  return 0;
496
507
  return Math.round(((0.5 * (180 - ageDays)) / 150) * 100) / 100;
497
508
  }
509
+ /**
510
+ * Rarity weight of a keyword matched by `df` of `n` candidates, in the
511
+ * BM25-style form already used across this project. A term matched by every
512
+ * candidate is worth almost nothing; a term matched by one candidate out of
513
+ * many is worth the most.
514
+ *
515
+ * Used by `searchScoring: "weighted"` so a distinctive match can outrank a
516
+ * pile of common ones. The weight is computed over the *recall page*, which
517
+ * costs no extra git call (the candidate commit messages are already in hand)
518
+ * and is the pool the ordering actually has to get right.
519
+ */
520
+ export function keywordRarity(df, n) {
521
+ if (n <= 0)
522
+ return 0;
523
+ const present = Math.min(Math.max(df, 0), n);
524
+ return Math.log(1 + (n - present + 0.5) / (present + 0.5));
525
+ }
498
526
  /** Commit trailer emitted only for topic pages (absence means "task"). */
499
527
  const KIND_TRAILER_TOPIC = "GitMemo-Kind: topic";
500
528
  /** ADD commit message (plan 4.1). */
@@ -1248,10 +1276,15 @@ export class GitMemo {
1248
1276
  }
1249
1277
  return value;
1250
1278
  };
1279
+ const scoring = config.searchScoring ?? "weighted";
1280
+ if (!SEARCH_SCORING_MODES.includes(scoring)) {
1281
+ throw new GitMemoError(`gitmemo: searchScoring must be one of ${SEARCH_SCORING_MODES.join(" | ")}, got ${JSON.stringify(config.searchScoring)}`);
1282
+ }
1251
1283
  this.config = {
1252
1284
  searchLimit: positiveInt(config.searchLimit, 20, "searchLimit"),
1253
1285
  gitTimeoutMs: positiveInt(config.gitTimeoutMs, 60000, "gitTimeoutMs"),
1254
- lockTimeoutMs: positiveInt(config.lockTimeoutMs, 30000, "lockTimeoutMs")
1286
+ lockTimeoutMs: positiveInt(config.lockTimeoutMs, 30000, "lockTimeoutMs"),
1287
+ searchScoring: scoring
1255
1288
  };
1256
1289
  this.memDir = join(this.root, ".mem");
1257
1290
  }
@@ -1299,7 +1332,7 @@ export class GitMemo {
1299
1332
  * messages only, active-entry filtering, field-level matching and scoring,
1300
1333
  * snapshot-stable pagination, in-process LRU.
1301
1334
  *
1302
- * @param keywords - 1-12 keywords (legacy callers may pass a comma-separated string).
1335
+ * @param keywords - 1-15 keywords (legacy callers may pass a comma-separated string).
1303
1336
  * @param options.skip - pagination offset.
1304
1337
  * @param options.snapshot - pass back the snapshot from the first page.
1305
1338
  */
@@ -1312,7 +1345,7 @@ export class GitMemo {
1312
1345
  .map((k) => k.trim())
1313
1346
  .filter((k) => k.length > 0)
1314
1347
  .map((k) => normalizeKeyword(k));
1315
- adapterWarning = "gitmemo: deprecation — mem_search.keywords as a comma-separated string; pass an array of 1-12 keywords";
1348
+ adapterWarning = `gitmemo: deprecation — mem_search.keywords as a comma-separated string; pass an array of 1-${MAX_SEARCH_KEYWORDS} keywords`;
1316
1349
  }
1317
1350
  else {
1318
1351
  if (!Array.isArray(keywords))
@@ -1324,8 +1357,8 @@ export class GitMemo {
1324
1357
  adapterWarning = (adapterWarning ? adapterWarning + " " : "") +
1325
1358
  "gitmemo: deprecation — mem_search.mode is gone; recall is always OR";
1326
1359
  }
1327
- if (normalized.length < 1 || normalized.length > 12) {
1328
- throw new GitMemoError("gitmemo: search requires 1-12 non-empty keywords");
1360
+ if (normalized.length < 1 || normalized.length > MAX_SEARCH_KEYWORDS) {
1361
+ throw new GitMemoError(`gitmemo: search requires 1-${MAX_SEARCH_KEYWORDS} non-empty keywords`);
1329
1362
  }
1330
1363
  const skip = options.skip ?? 0;
1331
1364
  if (!Number.isInteger(skip) || skip < 0)
@@ -1348,7 +1381,11 @@ export class GitMemo {
1348
1381
  : undefined;
1349
1382
  return { ...resolved, blockedNote: note };
1350
1383
  });
1351
- const cacheKey = this.memDir + "\x00" + snapshot + "\x00" + normalized.join("\x00");
1384
+ // The scoring mode is part of the key: two handles on the SAME repo with
1385
+ // different `searchScoring` must not share a cached ranking. (Omitting it
1386
+ // made an A/B of the two modes return identical results — the second
1387
+ // handle simply read the first one's cached computation.)
1388
+ const cacheKey = this.memDir + "\x00" + snapshot + "\x00" + this.config.searchScoring + "\x00" + normalized.join("\x00");
1352
1389
  let computation = searchCache.get(cacheKey);
1353
1390
  if (computation === undefined) {
1354
1391
  computation = await this.computeSearch(snapshot, normalized, legacy);
@@ -1383,6 +1420,15 @@ export class GitMemo {
1383
1420
  const diagnostics = [...(legacySnapshot?.diagnostics ?? [])];
1384
1421
  const seenFiles = new Set();
1385
1422
  const nowMs = Date.now();
1423
+ // Two passes: collect the matching candidates first, then (in weighted
1424
+ // mode) score each match by how rare its keyword is *within this recall
1425
+ // page*. Saturation-free counting treats a distinctive term like
1426
+ // "biscuit" exactly like a ubiquitous one, which is what the LongMemEval
1427
+ // measurements pinned as the engine's ranking defect: evidence was almost
1428
+ // always reachable (recall_all@50 = 0.998) but ranked poorly
1429
+ // (ndcg_any@5 = 0.686). Rarity re-weights the same matches without
1430
+ // changing which entries match at all, so recall is untouched by design.
1431
+ const scored = [];
1386
1432
  for (const record of candidates) {
1387
1433
  if (record.added.length !== 1) {
1388
1434
  if (record.added.length > 1)
@@ -1409,6 +1455,24 @@ export class GitMemo {
1409
1455
  }
1410
1456
  if (matched.length === 0)
1411
1457
  continue;
1458
+ scored.push({ record, matched });
1459
+ }
1460
+ let weights;
1461
+ if (this.config.searchScoring === "weighted" && scored.length > 0) {
1462
+ weights = new Map();
1463
+ for (const kw of kws) {
1464
+ let df = 0;
1465
+ for (const entry of scored) {
1466
+ if (entry.matched.includes(kw))
1467
+ df += 1;
1468
+ }
1469
+ weights.set(kw, keywordRarity(df, scored.length));
1470
+ }
1471
+ }
1472
+ for (const { record, matched } of scored) {
1473
+ const base = weights === undefined
1474
+ ? matched.length
1475
+ : matched.reduce((sum, kw) => sum + (weights.get(kw) ?? 0), 0);
1412
1476
  hits.push({
1413
1477
  hash: record.hash,
1414
1478
  title: record.message.subject,
@@ -1416,7 +1480,7 @@ export class GitMemo {
1416
1480
  summary: record.message.summary,
1417
1481
  keywords: record.message.keywords,
1418
1482
  kind: record.message.kind,
1419
- score: Math.round((matched.length + recencyBonus(record.committerTime, nowMs)) * 100) / 100,
1483
+ score: Math.round((base + recencyBonus(record.committerTime, nowMs)) * 100) / 100,
1420
1484
  matched_keywords: matched
1421
1485
  });
1422
1486
  }
@@ -54,6 +54,15 @@ export interface GateOptions {
54
54
  scoreMin: number;
55
55
  /** Retain at least this many candidates when the model rejects the whole page. */
56
56
  minKeep: number;
57
+ /** What the caller does with the values; see {@link GatePolicy}. */
58
+ policy?: GatePolicy;
59
+ /**
60
+ * `filter` policy only: never drop more than this fraction of the judged page,
61
+ * lowest-scoring first. Bounds the damage a mis-calibrated threshold can do —
62
+ * an offline sweep kept recall_all@5 at 0.8875 with a 20 % cap, against 0.6875
63
+ * uncapped. Default 0.25.
64
+ */
65
+ maxDropFraction?: number;
57
66
  /** Never send more than this many candidates in one request. */
58
67
  maxCandidates: number;
59
68
  /** Truncate the task text to this many characters. */
@@ -96,6 +105,16 @@ export interface GateDecision {
96
105
  dropped: string[];
97
106
  /** Per-candidate verdicts, in candidate order. */
98
107
  verdicts: GateVerdict[];
108
+ /** Policy actually applied; see {@link GatePolicy}. */
109
+ policy: GatePolicy;
110
+ /** True when `maxDropFraction` restored candidates a threshold would have removed. */
111
+ bounded: boolean;
112
+ /**
113
+ * Candidate hashes ordered by the judge's value, best first (nulls last and
114
+ * kept stable). This is what the `rerank` policy applies; in `filter` mode it
115
+ * is still reported so a caller can log or inspect the judge's ordering.
116
+ */
117
+ ordered: string[];
99
118
  /** Question type actually used. */
100
119
  mode: SystemOneMode;
101
120
  /** Model identifier the request named. */
@@ -142,6 +161,23 @@ export declare const NOUL_CRITERIA: {
142
161
  };
143
162
  /** The judgement asked of the model in `score` mode. */
144
163
  export declare const SCORE_INSTRUCTION = "How relevant is the `candidate` memory record to the `task`? Rate how reusable its conclusions, decisions, constraints, or findings are for this exact task.";
164
+ /**
165
+ * What the caller does with the judge's values.
166
+ *
167
+ * - `rerank` (default): keep every judged candidate and let the values reorder
168
+ * the page. Cannot lose a memory by construction.
169
+ * - `filter`: drop candidates below the threshold, then apply `maxDropFraction`.
170
+ *
171
+ * Measured on LongMemEval `_S` (500 questions, identical ingested memories): at
172
+ * threshold 0.5 the filter held 343 gold memories out of the prompt across 254
173
+ * instances and took LLM-as-Judge from 0.7840 to 0.3800, while the same values
174
+ * used as a reranker scored 0.8020 on the same context. The judge's AUC is only
175
+ * ~0.71-0.75, so no threshold can make a hard drop safe; the same signal is a
176
+ * fine ordering feature. See evals/longmemeval/README.md.
177
+ */
178
+ export type GatePolicy = "rerank" | "filter";
179
+ /** Accepted `policy` values, for config validation and tool schemas. */
180
+ export declare const GATE_POLICIES: readonly GatePolicy[];
145
181
  /**
146
182
  * Build the System-one request body: the task is the shared `state`, and each
147
183
  * candidate travels inside its own question's structured `instructions`, so
@@ -192,11 +228,12 @@ export declare function parseUsage(payload: unknown): GateUsage | undefined;
192
228
  * @param options - resolved gate settings.
193
229
  * @returns kept/dropped hash lists and per-candidate verdicts.
194
230
  */
195
- export declare function decide(candidates: readonly GateCandidate[], values: ReadonlyArray<number | undefined>, options: Pick<GateOptions, "mode" | "threshold" | "scoreMin" | "minKeep">): {
231
+ export declare function decide(candidates: readonly GateCandidate[], values: ReadonlyArray<number | undefined>, options: Pick<GateOptions, "mode" | "threshold" | "scoreMin" | "minKeep" | "policy" | "maxDropFraction">): {
196
232
  kept: string[];
197
233
  dropped: string[];
198
234
  verdicts: GateVerdict[];
199
235
  floored: boolean;
236
+ bounded: boolean;
200
237
  };
201
238
  /**
202
239
  * Evaluate the gate for one search page.