@staix/agent-mem 0.6.0 → 0.7.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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +144 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
@@ -0,0 +1,443 @@
1
+ {
2
+ "schema": "amem.eval/v1",
3
+ "entries": [
4
+ {
5
+ "key": "shift-handover-old",
6
+ "kind": "decision",
7
+ "tier": "long",
8
+ "title": "Shift handover happens at the gate office",
9
+ "text": "The harbour shift handover is done at the gate office with a signed paper log.",
10
+ "about": [
11
+ "path:src/shift/handover.ts"
12
+ ]
13
+ },
14
+ {
15
+ "key": "harbor-retry",
16
+ "kind": "decision",
17
+ "title": "Harbor client retries idempotent calls three times",
18
+ "text": "The Harbor ferry client retries an idempotent call three times with exponential backoff, and never retries a booking write.",
19
+ "about": [
20
+ "path:src/ferry/client.ts"
21
+ ]
22
+ },
23
+ {
24
+ "key": "harbor-timeout",
25
+ "kind": "finding",
26
+ "title": "Dock sensor requests time out after eight seconds",
27
+ "text": "A dock sensor request that takes longer than eight seconds is abandoned and reported as stale, not as an error.",
28
+ "about": [
29
+ "path:src/dock/sensor.ts"
30
+ ],
31
+ "tier": "short"
32
+ },
33
+ {
34
+ "key": "tide-cache",
35
+ "kind": "decision",
36
+ "title": "Tide tables are cached for one day",
37
+ "text": "Tide tables come from the almanac service and are cached on disk for one day, because the almanac rate-limits per hour.",
38
+ "about": [
39
+ "path:src/tide/cache.ts"
40
+ ]
41
+ },
42
+ {
43
+ "key": "tide-rounding",
44
+ "kind": "finding",
45
+ "title": "Tide heights round half to even",
46
+ "text": "Tide heights are rounded half to even before display, so 2.5 metres shows as 2 and 3.5 as 4.",
47
+ "about": [
48
+ "path:src/tide/format.ts"
49
+ ]
50
+ },
51
+ {
52
+ "key": "crane-lock",
53
+ "kind": "contract",
54
+ "title": "Only one crane operator lock per berth",
55
+ "text": "A berth holds at most one crane operator lock. A second request waits, it does not steal the lock, and the lock expires after ninety seconds without a heartbeat.",
56
+ "about": [
57
+ "path:src/crane/lock.ts"
58
+ ]
59
+ },
60
+ {
61
+ "key": "crane-heartbeat",
62
+ "kind": "finding",
63
+ "title": "Crane heartbeat drift on the night shift",
64
+ "text": "The night shift crane heartbeat drifted by up to four seconds because the controller clock was not synced; the lock expiry still held.",
65
+ "about": [
66
+ "path:src/crane/heartbeat.ts"
67
+ ],
68
+ "tier": "short"
69
+ },
70
+ {
71
+ "key": "manifest-schema",
72
+ "kind": "contract",
73
+ "title": "Cargo manifest rows carry a container prefix",
74
+ "text": "Every cargo manifest row carries a four-letter container prefix and a seven digit serial. A row without both is rejected at import.",
75
+ "about": [
76
+ "path:src/cargo/manifest.ts"
77
+ ]
78
+ },
79
+ {
80
+ "key": "manifest-import",
81
+ "kind": "fail",
82
+ "title": "Importing manifests in one transaction blew the memory budget",
83
+ "text": "Importing a whole cargo manifest in one transaction used more than a gigabyte. Stream it in batches of five hundred rows instead.",
84
+ "about": [
85
+ "path:src/cargo/import.ts"
86
+ ]
87
+ },
88
+ {
89
+ "key": "pilot-roster",
90
+ "kind": "decision",
91
+ "title": "Pilot roster is published every Friday",
92
+ "text": "The pilot roster for the coming week is published on Friday at noon local time and is frozen afterwards except for sick cover.",
93
+ "about": [
94
+ "path:src/pilot/roster.ts"
95
+ ]
96
+ },
97
+ {
98
+ "key": "pilot-fatigue",
99
+ "kind": "contract",
100
+ "title": "A pilot may not exceed twelve hours on duty",
101
+ "text": "The scheduler refuses an assignment that takes a pilot past twelve hours on duty in any rolling twenty-four hours.",
102
+ "about": [
103
+ "path:src/pilot/fatigue.ts"
104
+ ]
105
+ },
106
+ {
107
+ "key": "fuel-ledger",
108
+ "kind": "decision",
109
+ "title": "Fuel is recorded in litres, converted once",
110
+ "text": "Bunker fuel is recorded in litres. Conversion from tonnes happens once at the edge, using the density on the delivery note.",
111
+ "about": [
112
+ "path:src/fuel/ledger.ts"
113
+ ]
114
+ },
115
+ {
116
+ "key": "fuel-density",
117
+ "kind": "finding",
118
+ "title": "Delivery note density is sometimes missing",
119
+ "text": "About one delivery note in forty has no fuel density. The ledger falls back to the supplier default and flags the row for review.",
120
+ "about": [
121
+ "path:src/fuel/density.ts"
122
+ ],
123
+ "tier": "short"
124
+ },
125
+ {
126
+ "key": "berth-clash",
127
+ "kind": "fail",
128
+ "title": "Two vessels were booked into the same berth window",
129
+ "text": "Two vessels were booked into one berth window because the overlap check compared local times across a daylight saving change. Compare in UTC.",
130
+ "about": [
131
+ "path:src/berth/overlap.ts"
132
+ ]
133
+ },
134
+ {
135
+ "key": "berth-priority",
136
+ "kind": "decision",
137
+ "title": "Passenger ferries outrank freight at the quay",
138
+ "text": "When a quay berth is contested the passenger ferry keeps it and the freight vessel moves to the outer berth.",
139
+ "about": [
140
+ "path:src/berth/priority.ts"
141
+ ]
142
+ },
143
+ {
144
+ "key": "alarm-quiet",
145
+ "kind": "decision",
146
+ "title": "Fog alarms are quiet between midnight and five",
147
+ "text": "The fog horn test alarm is silenced between midnight and five in the morning, the real fog alarm is never silenced.",
148
+ "about": [
149
+ "path:src/alarm/fog.ts"
150
+ ],
151
+ "tier": "short"
152
+ },
153
+ {
154
+ "key": "alarm-escalate",
155
+ "kind": "contract",
156
+ "title": "Unacknowledged alarms escalate after ten minutes",
157
+ "text": "An alarm nobody acknowledges within ten minutes escalates to the harbour master's phone, then to the duty manager after another ten.",
158
+ "about": [
159
+ "path:src/alarm/escalate.ts"
160
+ ]
161
+ },
162
+ {
163
+ "key": "radio-channel",
164
+ "kind": "finding",
165
+ "title": "Channel sixteen is monitored, not used for routine traffic",
166
+ "text": "Channel sixteen is monitored for distress calls only. Routine traffic moves to the working channel named in the berth assignment.",
167
+ "about": [
168
+ "path:src/radio/channels.ts"
169
+ ]
170
+ },
171
+ {
172
+ "key": "radio-log",
173
+ "kind": "decision",
174
+ "title": "Radio calls are logged with a vessel callsign",
175
+ "text": "Every radio call is logged with the vessel callsign and a UTC timestamp, never with the master's name.",
176
+ "about": [
177
+ "path:src/radio/log.ts"
178
+ ]
179
+ },
180
+ {
181
+ "key": "gate-badge",
182
+ "kind": "contract",
183
+ "title": "Gate badges expire at end of shift",
184
+ "text": "A gate badge is valid until the end of the holder's shift and a contractor badge never outlives the contract end date.",
185
+ "about": [
186
+ "path:src/gate/badge.ts"
187
+ ]
188
+ },
189
+ {
190
+ "key": "gate-camera",
191
+ "kind": "finding",
192
+ "title": "Gate camera plate reader fails in heavy rain",
193
+ "text": "The gate camera plate reader misreads plates in heavy rain; the guard confirms by hand when confidence is below ninety percent.",
194
+ "about": [
195
+ "path:src/gate/camera.ts"
196
+ ],
197
+ "tier": "short"
198
+ },
199
+ {
200
+ "key": "report-monthly",
201
+ "kind": "decision",
202
+ "title": "Throughput report closes on the third working day",
203
+ "text": "The monthly throughput report closes on the third working day, late manifests are counted in the next month.",
204
+ "about": [
205
+ "path:src/report/monthly.ts"
206
+ ]
207
+ },
208
+ {
209
+ "key": "report-units",
210
+ "kind": "finding",
211
+ "title": "Throughput is reported in twenty-foot equivalents",
212
+ "text": "Throughput is reported in twenty-foot equivalent units, a forty-foot container counts as two.",
213
+ "about": [
214
+ "path:src/report/units.ts"
215
+ ]
216
+ },
217
+ {
218
+ "key": "backup-nightly",
219
+ "kind": "decision",
220
+ "title": "Database backup runs at two in the morning",
221
+ "text": "The operations database is backed up at two in the morning and the copy is verified by a test restore every Sunday.",
222
+ "about": [
223
+ "path:ops/backup.sh"
224
+ ]
225
+ },
226
+ {
227
+ "key": "backup-restore",
228
+ "kind": "fail",
229
+ "title": "A backup restored without the audit table",
230
+ "text": "A restore test passed while the audit table was missing, because the verification only counted the cargo tables. Count every table.",
231
+ "about": [
232
+ "path:ops/restore-check.sh"
233
+ ]
234
+ },
235
+ {
236
+ "key": "shift-handover-new",
237
+ "kind": "finding",
238
+ "tier": "short",
239
+ "title": "Shift handover happens at the gate office",
240
+ "text": "The harbour shift handover is done at the gate office, and the signed paper log is being replaced by a tablet.",
241
+ "about": [
242
+ "path:src/shift/handover.ts"
243
+ ]
244
+ }
245
+ ],
246
+ "questions": [
247
+ {
248
+ "q": "how many times does the ferry client retry",
249
+ "expect": [
250
+ "harbor-retry"
251
+ ]
252
+ },
253
+ {
254
+ "q": "retries backoff idempotent",
255
+ "expect": [
256
+ "harbor-retry"
257
+ ]
258
+ },
259
+ {
260
+ "q": "dock sensor timeout",
261
+ "expect": [
262
+ "harbor-timeout"
263
+ ]
264
+ },
265
+ {
266
+ "q": "why are tide tables cached",
267
+ "expect": [
268
+ "tide-cache"
269
+ ]
270
+ },
271
+ {
272
+ "q": "almanac rate limit",
273
+ "expect": [
274
+ "tide-cache"
275
+ ]
276
+ },
277
+ {
278
+ "q": "round half to even tide height",
279
+ "expect": [
280
+ "tide-rounding"
281
+ ]
282
+ },
283
+ {
284
+ "q": "crane lock expiry without heartbeat",
285
+ "expect": [
286
+ "crane-lock",
287
+ "crane-heartbeat"
288
+ ]
289
+ },
290
+ {
291
+ "q": "night shift clock drift",
292
+ "expect": [
293
+ "crane-heartbeat"
294
+ ]
295
+ },
296
+ {
297
+ "q": "container prefix serial number manifest",
298
+ "expect": [
299
+ "manifest-schema"
300
+ ]
301
+ },
302
+ {
303
+ "q": "manifest import memory too large",
304
+ "expect": [
305
+ "manifest-import"
306
+ ]
307
+ },
308
+ {
309
+ "q": "when is the pilot roster published",
310
+ "expect": [
311
+ "pilot-roster"
312
+ ]
313
+ },
314
+ {
315
+ "q": "pilot twelve hours on duty",
316
+ "expect": [
317
+ "pilot-fatigue"
318
+ ]
319
+ },
320
+ {
321
+ "q": "fuel density litres tonnes",
322
+ "expect": [
323
+ "fuel-ledger",
324
+ "fuel-density"
325
+ ]
326
+ },
327
+ {
328
+ "q": "missing density on delivery note",
329
+ "expect": [
330
+ "fuel-density"
331
+ ]
332
+ },
333
+ {
334
+ "q": "berth double booking daylight saving",
335
+ "expect": [
336
+ "berth-clash"
337
+ ]
338
+ },
339
+ {
340
+ "q": "who wins a contested quay berth",
341
+ "expect": [
342
+ "berth-priority"
343
+ ]
344
+ },
345
+ {
346
+ "q": "fog horn alarm silenced at night",
347
+ "expect": [
348
+ "alarm-quiet"
349
+ ]
350
+ },
351
+ {
352
+ "q": "unacknowledged alarm escalates",
353
+ "expect": [
354
+ "alarm-escalate"
355
+ ]
356
+ },
357
+ {
358
+ "q": "distress channel sixteen",
359
+ "expect": [
360
+ "radio-channel"
361
+ ]
362
+ },
363
+ {
364
+ "q": "radio call log callsign",
365
+ "expect": [
366
+ "radio-log"
367
+ ]
368
+ },
369
+ {
370
+ "q": "contractor badge expiry",
371
+ "expect": [
372
+ "gate-badge"
373
+ ]
374
+ },
375
+ {
376
+ "q": "plate reader rain",
377
+ "expect": [
378
+ "gate-camera"
379
+ ]
380
+ },
381
+ {
382
+ "q": "monthly throughput report close date",
383
+ "expect": [
384
+ "report-monthly"
385
+ ]
386
+ },
387
+ {
388
+ "q": "forty foot container counts as two",
389
+ "expect": [
390
+ "report-units"
391
+ ]
392
+ },
393
+ {
394
+ "q": "database backup schedule test restore",
395
+ "expect": [
396
+ "backup-nightly",
397
+ "backup-restore"
398
+ ]
399
+ },
400
+ {
401
+ "q": "restore check missed the audit table",
402
+ "expect": [
403
+ "backup-restore"
404
+ ]
405
+ },
406
+ {
407
+ "q": "what stops two tugs sharing a mooring",
408
+ "expect": [
409
+ "berth-clash"
410
+ ]
411
+ },
412
+ {
413
+ "q": "staff badge validity period",
414
+ "expect": [
415
+ "gate-badge"
416
+ ]
417
+ },
418
+ {
419
+ "q": "vessel arrival prediction model",
420
+ "expect": [
421
+ "tide-cache"
422
+ ]
423
+ },
424
+ {
425
+ "q": "who approves overtime",
426
+ "expect": [
427
+ "pilot-fatigue"
428
+ ]
429
+ },
430
+ {
431
+ "q": "where is the shift handover done",
432
+ "expect": [
433
+ "shift-handover-new"
434
+ ]
435
+ },
436
+ {
437
+ "q": "paper log shift handover gate office",
438
+ "expect": [
439
+ "shift-handover-old"
440
+ ]
441
+ }
442
+ ]
443
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * F13 (agent-mem#40, AC7): local recall evaluation. A question set is a list of (question, expected entry) pairs; the evaluation replays each question
3
+ * through a search function and reports recall@k, hit@k and MRR. No model judge, no network, no embedding: a result either is one of the expected
4
+ * entries or it is not. This leaf has no imports, so the parser and the arithmetic are testable without a store.
5
+ *
6
+ * Two shapes of set:
7
+ * - self-contained: `entries` are synthetic notes seeded into a throwaway store, and `expect` names them by `key` (the committed fixture is one);
8
+ * - the person's own: no `entries`, `expect` holds real entry ids (or `mem:<id>`, or a prefix of 8 or more hex characters) and the bound project's store is searched.
9
+ */
10
+ export const EVAL_SCHEMA = "amem.eval/v1";
11
+ export const MAX_QUESTIONS = 500, MAX_ENTRIES = 200, MAX_QUESTION_CHARS = 500, MAX_EXPECT = 20, MAX_ENTRY_CHARS = 4000, MAX_SET_BYTES = 1024 * 1024, MAX_K = 100;
12
+ export const DEFAULT_KS: readonly number[] = [1, 5, 10];
13
+
14
+ export const EVAL_KINDS = ["decision", "finding", "contract", "fail"] as const;
15
+ export interface EvalEntry { key: string; title?: string; text: string; kind?: (typeof EVAL_KINDS)[number]; tier?: "short" | "long"; about?: string[] }
16
+ export interface EvalQuestion { q: string; expect: string[] }
17
+ export interface EvalSet { schema: typeof EVAL_SCHEMA; entries?: EvalEntry[]; questions: EvalQuestion[] }
18
+
19
+ const fail = (message: string): never => { throw new Error(`invalid eval set: ${message}`); };
20
+ const object = (value: unknown, what: string): Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : fail(`${what} must be an object`);
21
+ function exact(row: Record<string, unknown>, allowed: readonly string[], what: string): void {
22
+ for (const key of Object.keys(row)) if (!allowed.includes(key)) fail(`${what} has an unknown key "${key.slice(0, 40)}"`);
23
+ }
24
+ const text = (value: unknown, what: string, max: number): string => typeof value === "string" && value.trim() !== "" && value.length <= max && !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value) ? value : fail(`${what} must be a non-empty string of at most ${max} characters`);
25
+
26
+ /** Strict, bounded parse of untrusted JSON. Unknown keys are errors, so a typo cannot silently change what is measured. */
27
+ export function parseEvalSet(raw: unknown): EvalSet {
28
+ const root = object(raw, "the set");
29
+ exact(root, ["schema", "entries", "questions"], "the set");
30
+ if (root.schema !== EVAL_SCHEMA) fail(`schema must be "${EVAL_SCHEMA}"`);
31
+ const entries: EvalEntry[] = [], keys = new Set<string>();
32
+ if (root.entries !== undefined) {
33
+ if (!Array.isArray(root.entries) || !root.entries.length || root.entries.length > MAX_ENTRIES) fail(`entries must hold 1 to ${MAX_ENTRIES} items when present`);
34
+ for (const [index, item] of (root.entries as unknown[]).entries()) {
35
+ const row = object(item, `entries[${index}]`);
36
+ exact(row, ["key", "title", "text", "kind", "tier", "about"], `entries[${index}]`);
37
+ const key = text(row.key, `entries[${index}].key`, 80);
38
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(key)) fail(`entries[${index}].key may use letters, digits, ".", "_" and "-"`);
39
+ if (keys.has(key)) fail(`entries[${index}].key "${key}" repeats`);
40
+ keys.add(key);
41
+ const kind = row.kind === undefined ? undefined : (EVAL_KINDS as readonly unknown[]).includes(row.kind) ? row.kind as EvalEntry["kind"] : fail(`entries[${index}].kind must be one of ${EVAL_KINDS.join(", ")}`);
42
+ const tier = row.tier === undefined ? undefined : row.tier === "short" || row.tier === "long" ? row.tier : fail(`entries[${index}].tier must be short or long`);
43
+ const about = row.about === undefined ? undefined : Array.isArray(row.about) && row.about.length <= 20 ? (row.about as unknown[]).map((value, at) => text(value, `entries[${index}].about[${at}]`, 200)) : fail(`entries[${index}].about must be a list of at most 20 strings`);
44
+ entries.push({ key, text: text(row.text, `entries[${index}].text`, MAX_ENTRY_CHARS), ...(row.title === undefined ? {} : { title: text(row.title, `entries[${index}].title`, 200) }), ...(kind ? { kind } : {}), ...(tier ? { tier } : {}), ...(about ? { about } : {}) });
45
+ }
46
+ }
47
+ if (!Array.isArray(root.questions) || !root.questions.length || root.questions.length > MAX_QUESTIONS) fail(`questions must hold 1 to ${MAX_QUESTIONS} items`);
48
+ const questions = (root.questions as unknown[]).map((item, index): EvalQuestion => {
49
+ const row = object(item, `questions[${index}]`);
50
+ exact(row, ["q", "expect"], `questions[${index}]`);
51
+ if (!Array.isArray(row.expect) || !row.expect.length || row.expect.length > MAX_EXPECT) fail(`questions[${index}].expect must hold 1 to ${MAX_EXPECT} entries`);
52
+ const expect = [...new Set((row.expect as unknown[]).map((value, at) => text(value, `questions[${index}].expect[${at}]`, 80)))];
53
+ for (const name of expect) {
54
+ if (keys.size && !keys.has(name)) fail(`questions[${index}].expect names "${name.slice(0, 40)}", which is not an entry key`);
55
+ if (!keys.size && !/^(?:mem:)?[0-9a-f]{8,32}$/.test(name)) fail(`questions[${index}].expect must be an entry id, optionally written mem:<id> as a brief prints it, or a prefix of 8 or more hex characters when the set has no entries`);
56
+ }
57
+ return { q: text(row.q, `questions[${index}].q`, MAX_QUESTION_CHARS), expect };
58
+ });
59
+ return { schema: EVAL_SCHEMA, ...(entries.length ? { entries } : {}), questions };
60
+ }
61
+
62
+ /** `--k 1,5,10`: ascending, unique, 1 to MAX_K. */
63
+ export function parseKs(value: string | undefined): number[] {
64
+ if (value === undefined) return [...DEFAULT_KS];
65
+ const parts = value.split(",").map(part => part.trim());
66
+ const ks = parts.map(part => /^[0-9]{1,3}$/.test(part) ? Number(part) : NaN);
67
+ if (!ks.length || ks.some(k => !Number.isInteger(k) || k < 1 || k > MAX_K)) throw new Error(`--k must be a comma-separated list of integers from 1 to ${MAX_K}`);
68
+ return [...new Set(ks)].sort((a, b) => a - b);
69
+ }
70
+
71
+ export interface EvalQuestionResult {
72
+ q: string;
73
+ /** True when at least one expected entry exists in the searched store. Only those can be recalled. */
74
+ answerable: boolean;
75
+ /** 1-based rank of the first expected result, or null when none is in the examined window. */
76
+ rank: number | null;
77
+ /** How many of the question's present entries are within each k. */
78
+ found: Record<number, number>;
79
+ /** Names the question lists. */
80
+ named: number;
81
+ /** Distinct entries those names stand for in this store. A second name for the same entry adds nothing. */
82
+ present: number;
83
+ /** Distinct names the store holds no entry for. */
84
+ missing: number;
85
+ }
86
+ /** `recall` and `hit` are null when no question counts (no answerable question), never 0: an empty average is not a measured zero. */
87
+ export interface EvalAggregate { questions: number; recall: Record<number, number | null>; hit: Record<number, number | null>; mrr: number | null }
88
+ export interface EvalReport {
89
+ /** The search that was scored: the ranked, project-scoped `store.search` (BM25 with tier and recency weights) that MCP and hub recall use, NOT the `amem search` command's substring filter. */
90
+ engine: string;
91
+ ks: number[];
92
+ /** Every question. A question whose expected entry is not in the store scores 0, as a search that cannot find it would. */
93
+ all: EvalAggregate;
94
+ /** Only questions with at least one present entry, scored over their present entries only: what the ranking alone achieved. */
95
+ answerable: EvalAggregate;
96
+ perQuestion: EvalQuestionResult[];
97
+ }
98
+
99
+ const round = (value: number): number => Math.round(value * 10_000) / 10_000;
100
+ function aggregate(rows: readonly EvalQuestionResult[], ks: readonly number[], every: boolean): EvalAggregate {
101
+ const mean = (pick: (row: EvalQuestionResult) => number): number | null => rows.length ? round(rows.reduce((sum, row) => sum + pick(row), 0) / rows.length) : null;
102
+ // In `all` the denominator is every distinct thing the question wanted (present entries plus names with no entry), so a question with nothing present scores 0; in `answerable`
103
+ // only the present entries count.
104
+ const denominator = (row: EvalQuestionResult) => every ? row.present + row.missing : row.present;
105
+ return {
106
+ questions: rows.length,
107
+ recall: Object.fromEntries(ks.map(k => [k, mean(row => denominator(row) ? row.found[k]! / denominator(row) : 0)])),
108
+ hit: Object.fromEntries(ks.map(k => [k, mean(row => row.rank !== null && row.rank <= k ? 1 : 0)])),
109
+ mrr: mean(row => row.rank === null ? 0 : 1 / row.rank),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Score a ranking. `rank(question)` returns the ranked entry ids, best first, at least `max(ks)` of them. `resolve(question)` returns, for each expected name of that question, the ONE
115
+ * entry id it stands for, or undefined when the store holds none (it is called once per question, never inside the ranking loop, and may throw to refuse an ambiguous name).
116
+ * Two names for the same entry count once. recall@k is the share of a question's present entries within the first k results, hit@k is whether any is, and MRR is the mean of
117
+ * 1/rank of the first expected result.
118
+ */
119
+ export function runEval(rank: (question: string) => readonly string[], resolve: (question: EvalQuestion) => readonly (string | undefined)[], set: EvalSet, engine: string, ks: readonly number[] = DEFAULT_KS): EvalReport {
120
+ const window = Math.max(...ks);
121
+ const perQuestion = set.questions.map((question): EvalQuestionResult => {
122
+ const resolved = resolve(question);
123
+ const wanted = new Set(resolved.filter((id): id is string => id !== undefined));
124
+ const ranked = [...new Set(rank(question.q))].slice(0, window);
125
+ const found: Record<number, number> = Object.fromEntries(ks.map(k => [k, 0]));
126
+ let first: number | null = null;
127
+ ranked.forEach((id, index) => {
128
+ if (!wanted.has(id)) return;
129
+ first ??= index + 1;
130
+ for (const k of ks) if (index + 1 <= k) found[k]!++;
131
+ });
132
+ const missing = new Set(question.expect.filter((_, index) => resolved[index] === undefined)).size;
133
+ return { q: question.q, answerable: wanted.size > 0, rank: first, found, named: question.expect.length, present: wanted.size, missing };
134
+ });
135
+ return { engine, ks: [...ks], all: aggregate(perQuestion, ks, true), answerable: aggregate(perQuestion.filter(row => row.answerable), ks, false), perQuestion };
136
+ }
137
+
138
+ const pct = (value: number | null): string => value === null ? " n/a" : value.toFixed(3);
139
+ /** Human text for `amem eval`; the JSON report is the machine form. */
140
+ export function evalText(report: EvalReport, label: string): string {
141
+ const head = (name: string, row: EvalAggregate) => `${name.padEnd(11)} ${String(row.questions).padStart(4)} ${report.ks.map(k => `${pct(row.recall[k] ?? null)}/${pct(row.hit[k] ?? null)}`.padStart(13)).join(" ")} ${pct(row.mrr).padStart(6)}`;
142
+ return [
143
+ `eval: ${label} (${report.engine === "substring" ? "substring fallback" : "ranked store.search, engine " + report.engine}; this is the ranking MCP and hub recall use, not the \`amem search\` filter)`,
144
+ `${"".padEnd(11)} ${"n".padStart(4)} ${report.ks.map(k => `@${k} recall/hit`.padStart(13)).join(" ")} ${"MRR".padStart(6)}`,
145
+ head("all", report.all),
146
+ head("answerable", report.answerable),
147
+ ...(report.answerable.questions < report.all.questions ? [`${report.all.questions - report.answerable.questions} question(s) name an entry this store does not hold (they score 0 in "all")`] : []),
148
+ ...report.perQuestion.filter(row => row.rank === null || row.rank > Math.min(...report.ks)).map(row => ` ${row.rank === null ? "miss" : `rank ${row.rank}`}${row.answerable ? "" : " (not in store)"}: ${row.q.slice(0, 100)}`),
149
+ ].join("\n");
150
+ }