@fonlan/dsh-gitmemo 0.4.0 → 0.5.0

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/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
  }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * dsh-gitmemo — System-one recall gate.
3
+ *
4
+ * Optional narrowing stage for `mem_search`. After the engine produces a page
5
+ * of lexical candidates, a System-one model (TypeSafe Jev by default, or any
6
+ * endpoint speaking the same request/answer contract) is asked one typed
7
+ * question per candidate, in a single request. Candidates the model judges
8
+ * irrelevant are dropped from the page the root agent sees, which cuts the
9
+ * tokens injected into the conversation.
10
+ *
11
+ * Design rules (deliberate, see README):
12
+ * - FAIL-OPEN: the gate may only ever REMOVE noise. Any transport, HTTP,
13
+ * timeout, or parse failure keeps every candidate and reports `degraded`,
14
+ * so a broken or unconfigured endpoint can never hide a memory.
15
+ * - NEVER EMPTY: when the gate would drop every candidate on a page, the
16
+ * `minKeep` best-ranked candidates are retained. A filter that silently
17
+ * returns nothing is indistinguishable from "no memory exists", which is
18
+ * the one failure mode a recall path must not have.
19
+ * - AUDITABLE: per-candidate values and the keep/drop verdict are returned in
20
+ * the tool output's `gated` block, so the decision can be inspected rather
21
+ * than trusted.
22
+ *
23
+ * This module imports nothing from the harness and performs no I/O of its own
24
+ * beyond `fetch`, so it is unit-testable with a fake fetch implementation.
25
+ *
26
+ * @module dsh-gitmemo/systemone
27
+ */
28
+ /** Question types this gate can drive. */
29
+ export type SystemOneMode = "noul" | "score";
30
+ /** One candidate memory offered to the gate. */
31
+ export interface GateCandidate {
32
+ /** Commit hash — the identity used to keep/drop the candidate. */
33
+ hash: string;
34
+ /** Entry title (commit subject). */
35
+ title: string;
36
+ /** Entry summary (commit body). */
37
+ summary: string;
38
+ /** Structured keywords stored with the entry. */
39
+ keywords: string[];
40
+ /** Entry kind: `task` or `topic`. */
41
+ kind: string;
42
+ }
43
+ /** Resolved gate settings for one search. */
44
+ export interface GateOptions {
45
+ /** Absolute endpoint URL accepting the System-one request contract. */
46
+ endpoint: string;
47
+ /** Model identifier sent in the request body (e.g. `jev-latest`). */
48
+ model: string;
49
+ /** Question type used to judge each candidate. */
50
+ mode: SystemOneMode;
51
+ /** noul mode: keep a candidate when its probability is >= this value (0..1). */
52
+ threshold: number;
53
+ /** score mode: keep a candidate when its score is >= this value. */
54
+ scoreMin: number;
55
+ /** Retain at least this many candidates when the model rejects the whole page. */
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;
66
+ /** Never send more than this many candidates in one request. */
67
+ maxCandidates: number;
68
+ /** Truncate the task text to this many characters. */
69
+ maxTaskChars: number;
70
+ /** End-to-end request deadline in milliseconds. */
71
+ timeoutMs: number;
72
+ /** Literal API key, when one is configured in the settings section. */
73
+ apiKey?: string;
74
+ /** Late-bound credential lookup, used when no literal key is configured. */
75
+ resolveApiKey?: () => Promise<string | undefined>;
76
+ /** Injectable fetch, for tests. */
77
+ fetchImpl?: typeof fetch;
78
+ }
79
+ /**
80
+ * One candidate's verdict, retained for auditability.
81
+ *
82
+ * `value` is `null` when no readable answer came back for that candidate;
83
+ * `null` rather than `NaN` because the harness rejects non-finite numbers in
84
+ * session events and tool output must stay losslessly JSON-serializable.
85
+ */
86
+ export interface GateVerdict {
87
+ hash: string;
88
+ /** Probability (noul) or score (score) returned for this candidate, or null. */
89
+ value: number | null;
90
+ /** Whether the candidate survived the gate. */
91
+ keep: boolean;
92
+ }
93
+ /** Token accounting reported by the endpoint, when it reports any. */
94
+ export interface GateUsage {
95
+ /** Input tokens billed for the request (Jev bills input only). */
96
+ inputTokens?: number;
97
+ /** Output tokens; free on Jev, but reported. */
98
+ outputTokens?: number;
99
+ }
100
+ /** Outcome of one gate evaluation. */
101
+ export interface GateDecision {
102
+ /** Hashes retained, in their original order. */
103
+ kept: string[];
104
+ /** Hashes removed, in their original order. */
105
+ dropped: string[];
106
+ /** Per-candidate verdicts, in candidate order. */
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[];
118
+ /** Question type actually used. */
119
+ mode: SystemOneMode;
120
+ /** Model identifier the request named. */
121
+ model: string;
122
+ /** True when the gate failed open (every candidate retained as a result). */
123
+ degraded: boolean;
124
+ /**
125
+ * Why the gate degraded — or, on a `false` {@link degraded} page, that it
126
+ * floored: no candidate cleared the threshold and `minKeep` was applied.
127
+ */
128
+ reason?: string;
129
+ /**
130
+ * Candidates actually sent to the endpoint, i.e. `min(maxCandidates, page
131
+ * size)`. Reported separately from the page size so a truncated judgement is
132
+ * never mistaken for a full one.
133
+ */
134
+ judged: number;
135
+ /**
136
+ * Candidates past `maxCandidates`, which were never evaluated and therefore
137
+ * can never be dropped. Always `0` unless the page exceeded the cap.
138
+ */
139
+ untouched: number;
140
+ /** Endpoint-reported token usage, so the gate's own cost is observable. */
141
+ usage?: GateUsage;
142
+ }
143
+ /** Score levels offered to the model in `score` mode (ordered low to high). */
144
+ export declare const SCORE_LEVELS: readonly ["unrelated to the task", "same general area but not actionable for this task", "partially relevant background", "substantially relevant and worth reusing", "directly reusable conclusions for this exact task"];
145
+ /**
146
+ * The judgement asked of the model, verbatim. Kept in English on purpose:
147
+ * Jev's primary training language is English and the vendor documents lower
148
+ * accuracy for other scripts, so only the (possibly CJK) candidate text and
149
+ * task text are non-English.
150
+ */
151
+ export declare const NOUL_INSTRUCTION = "Does the `candidate` memory record contain conclusions, decisions, constraints, or reusable findings that are directly relevant to the `task`? Answer yes only if reading that memory would inform or change how the task is carried out. Answer no when it is merely on a related topic, is superseded, or concerns a different area.";
152
+ /**
153
+ * Clarifies what yes and no mean for {@link NOUL_INSTRUCTION}.
154
+ *
155
+ * A noul `criteria` is an object with exactly `true` and `false` keys — a bare
156
+ * string is rejected by the endpoint's validation, so this shape is required.
157
+ */
158
+ export declare const NOUL_CRITERIA: {
159
+ readonly true: "yes — the memory is directly reusable for this task";
160
+ readonly false: "no — unrelated, superseded, or only loosely related to this task";
161
+ };
162
+ /** The judgement asked of the model in `score` mode. */
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[];
181
+ /**
182
+ * Build the System-one request body: the task is the shared `state`, and each
183
+ * candidate travels inside its own question's structured `instructions`, so
184
+ * every candidate is judged against the same task in one request and no
185
+ * candidate payload is repeated across questions.
186
+ *
187
+ * @param task - the agent's current task, used as the shared state.
188
+ * @param candidates - candidates to judge, already capped by the caller.
189
+ * @param options - resolved gate settings.
190
+ * @returns a JSON-serializable request body.
191
+ */
192
+ export declare function buildRequest(task: string, candidates: readonly GateCandidate[], options: Pick<GateOptions, "mode" | "model" | "maxTaskChars">): {
193
+ state: {
194
+ task: string;
195
+ };
196
+ model: string;
197
+ questions: Record<string, unknown>;
198
+ };
199
+ /**
200
+ * Extract one numeric answer per question id from a System-one response.
201
+ *
202
+ * Tolerates both documented envelope shapes (a top-level map of answers, or
203
+ * the same map nested under `answers`) and both `noul`/`score` field spellings,
204
+ * so an endpoint that is merely contract-compatible still works.
205
+ *
206
+ * @param payload - parsed response JSON.
207
+ * @param mode - the question type that was asked.
208
+ * @param count - number of questions sent (ids are `m0`..`m<count-1>`).
209
+ * @returns the value per question index, or undefined where absent/unreadable.
210
+ */
211
+ export declare function parseAnswers(payload: unknown, mode: SystemOneMode, count: number): Array<number | undefined>;
212
+ /**
213
+ * Read the endpoint's token accounting.
214
+ *
215
+ * @param payload - parsed response JSON.
216
+ * @returns camel-cased usage, or undefined when the endpoint reported none.
217
+ */
218
+ export declare function parseUsage(payload: unknown): GateUsage | undefined;
219
+ /**
220
+ * Turn per-candidate values into a keep/drop decision.
221
+ *
222
+ * Applies the configured threshold, then the `minKeep` floor that guarantees a
223
+ * non-empty page whenever candidates existed. Candidates with a missing or
224
+ * unreadable answer are KEPT (fail-open at the per-candidate level).
225
+ *
226
+ * @param candidates - candidates in page order.
227
+ * @param values - value per candidate index (undefined when unreadable).
228
+ * @param options - resolved gate settings.
229
+ * @returns kept/dropped hash lists and per-candidate verdicts.
230
+ */
231
+ export declare function decide(candidates: readonly GateCandidate[], values: ReadonlyArray<number | undefined>, options: Pick<GateOptions, "mode" | "threshold" | "scoreMin" | "minKeep" | "policy" | "maxDropFraction">): {
232
+ kept: string[];
233
+ dropped: string[];
234
+ verdicts: GateVerdict[];
235
+ floored: boolean;
236
+ bounded: boolean;
237
+ };
238
+ /**
239
+ * Evaluate the gate for one search page.
240
+ *
241
+ * Never throws and never returns an empty `kept` list while candidates exist:
242
+ * every failure path degrades to "keep everything" with a reason.
243
+ *
244
+ * @param task - the agent's current task text (shared state).
245
+ * @param candidates - page candidates; only the first `maxCandidates` are judged.
246
+ * @param options - resolved gate settings.
247
+ * @param signal - caller cancellation.
248
+ * @returns the decision to apply to the page.
249
+ */
250
+ export declare function evaluateGate(task: string, candidates: readonly GateCandidate[], options: GateOptions, signal?: AbortSignal): Promise<GateDecision>;