handoff-mcp-server 0.12.0 → 0.13.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.
@@ -0,0 +1,906 @@
1
+ //! MCP handlers for the memory feature (save / query / delete).
2
+ //!
3
+ //! All three return a **parseable JSON string** as their content text, so both
4
+ //! the wrapper path and the Claude Code `mcp_tool` hook path can consume them
5
+ //! with the same JSON parse. `memory_query` supports per-session diff injection
6
+ //! via the `injected/` sidecar (P2); `memory_cleanup` lands in P3.
7
+
8
+ use anyhow::Result;
9
+ use serde_json::{json, Value};
10
+
11
+ use std::path::Path;
12
+
13
+ use super::resolve_project_dir;
14
+ use crate::storage::config::{read_config, SettingsConfig};
15
+ use crate::storage::ensure_handoff_exists;
16
+ use crate::storage::memory::{
17
+ delete_memory, gc_injected_sets, is_valid_memory_kind, new_memory_id, now_rfc3339,
18
+ read_all_memories, read_injected_set, read_memory_by_id, write_injected_set, write_memory,
19
+ MemoryEntry, VALID_MEMORY_KINDS,
20
+ };
21
+
22
+ /// Bonus added to a memory's BM25 score when one of its `scope_paths` is a
23
+ /// prefix of one of the query's `file_paths`. Ensures file-specific rules are
24
+ /// reliably surfaced even when the prompt text barely mentions them. Kept a
25
+ /// constant (not exposed in config) — it is an internal ranking weight, not a
26
+ /// user-facing threshold.
27
+ const SCOPE_PATH_BONUS: f64 = 2.0;
28
+
29
+ /// Load the memory tuning settings from the project's `config.toml`.
30
+ ///
31
+ /// Missing `memory_*` keys (a pre-0.13.0 config) are filled by serde defaults at
32
+ /// parse time, so a legacy config still parses cleanly and yields the spec
33
+ /// defaults. Only a *genuinely corrupt* config.toml fails the parse — that is a
34
+ /// real error and is propagated, not silently swallowed. If the file is absent
35
+ /// altogether we fall back to defaults (the same as every other key's default).
36
+ fn memory_settings(handoff: &Path) -> Result<SettingsConfig> {
37
+ let path = handoff.join("config.toml");
38
+ if !path.exists() {
39
+ return Ok(SettingsConfig::default());
40
+ }
41
+ Ok(read_config(&path)?.settings)
42
+ }
43
+
44
+ /// The JSON payload every memory tool returns when `settings.memory_enabled` is
45
+ /// false: a benign no-op the hook paths can parse, never an error (a disabled
46
+ /// feature must not make automatic UserPromptSubmit/SessionStart hooks noisy).
47
+ /// Carries `disabled: true` plus empty equivalents of each tool's normal shape
48
+ /// so a caller that reads `memories` / `auto_merged_exact` still sees a valid
49
+ /// structure.
50
+ fn disabled_payload() -> String {
51
+ to_json(&json!({
52
+ "disabled": true,
53
+ "memories": [],
54
+ "injected_count": 0,
55
+ "auto_merged_exact": 0,
56
+ "cleanup_recommendations": { "similar_clusters": [], "stale": [] },
57
+ "injected_sidecars_removed": 0,
58
+ }))
59
+ }
60
+
61
+ /// `memory_save` — persist a memory, with AI-driven dedup.
62
+ ///
63
+ /// Resolution order (see spec C):
64
+ /// 1. `merge_into` → commit an AI merge (overwrite target, absorb others).
65
+ /// 2. exact content-hash match → `duplicate_exact` (no write).
66
+ /// 3. near-duplicate (Jaccard ≥ threshold) and not `force` → `conflict` (no
67
+ /// write; returns both bodies for the AI to merge).
68
+ /// 4. otherwise → new `saved`.
69
+ pub fn handle_save(arguments: &Value) -> Result<String> {
70
+ let project_dir = resolve_project_dir(arguments)?;
71
+ let handoff = ensure_handoff_exists(&project_dir)?;
72
+ let settings = memory_settings(&handoff)?;
73
+ if !settings.memory_enabled {
74
+ return Ok(disabled_payload());
75
+ }
76
+
77
+ let text = arguments
78
+ .get("text")
79
+ .and_then(|v| v.as_str())
80
+ .map(str::trim)
81
+ .filter(|s| !s.is_empty())
82
+ .ok_or_else(|| anyhow::anyhow!("'text' is required and must be non-empty"))?
83
+ .to_string();
84
+
85
+ let kind = arguments
86
+ .get("kind")
87
+ .and_then(|v| v.as_str())
88
+ .unwrap_or("lesson")
89
+ .to_string();
90
+ if !is_valid_memory_kind(&kind) {
91
+ anyhow::bail!(
92
+ "Invalid kind '{kind}'. Must be one of: {}",
93
+ VALID_MEMORY_KINDS.join(", ")
94
+ );
95
+ }
96
+
97
+ let tags = string_array(arguments, "tags");
98
+ let scope_paths = string_array(arguments, "scope_paths");
99
+ let force = arguments
100
+ .get("force")
101
+ .and_then(|v| v.as_bool())
102
+ .unwrap_or(false);
103
+
104
+ // (1) Explicit merge commit.
105
+ if let Some(merge_into) = arguments.get("merge_into").and_then(|v| v.as_str()) {
106
+ let absorb_ids = string_array(arguments, "absorb_ids");
107
+ return commit_merge(
108
+ &handoff,
109
+ merge_into,
110
+ &absorb_ids,
111
+ text,
112
+ kind,
113
+ tags,
114
+ scope_paths,
115
+ );
116
+ }
117
+
118
+ let existing = read_all_memories(&handoff)?;
119
+ let new_hash = lexsim::content_hash(&text);
120
+
121
+ // (2) Exact duplicate (same canonical content).
122
+ if let Some(dup) = existing.iter().find(|m| m.content_hash == new_hash) {
123
+ return Ok(to_json(&json!({
124
+ "status": "duplicate_exact",
125
+ "existing_id": dup.id,
126
+ })));
127
+ }
128
+
129
+ // (3) Near-duplicate: hand both bodies back for AI-driven merge.
130
+ if !force {
131
+ let dup_threshold = settings.memory_dup_threshold;
132
+ let new_set = lexsim::token_set(&text);
133
+ let mut similar: Vec<Value> = Vec::new();
134
+ for m in &existing {
135
+ let score = lexsim::jaccard_sets(&new_set, &lexsim::token_set(&m.index_text()));
136
+ if score >= dup_threshold {
137
+ similar.push(json!({
138
+ "id": m.id,
139
+ "text": m.text,
140
+ "kind": m.kind,
141
+ "score": round2(score),
142
+ }));
143
+ }
144
+ }
145
+ if !similar.is_empty() {
146
+ similar.sort_by(|a, b| {
147
+ b["score"]
148
+ .as_f64()
149
+ .partial_cmp(&a["score"].as_f64())
150
+ .unwrap_or(std::cmp::Ordering::Equal)
151
+ });
152
+ return Ok(to_json(&json!({
153
+ "status": "conflict",
154
+ "new": { "text": text, "kind": kind },
155
+ "similar": similar,
156
+ "instruction": "These are near-duplicates. Merge them and call memory_save again \
157
+ with merge_into=<id> and absorb_ids=[other ids], or pass force=true to save \
158
+ separately.",
159
+ })));
160
+ }
161
+ }
162
+
163
+ // (4) New memory.
164
+ let id = new_memory_id();
165
+ let entry = MemoryEntry::new(id.clone(), text, kind, tags, scope_paths, now_rfc3339());
166
+ write_memory(&handoff, &entry)?;
167
+ Ok(to_json(&json!({ "status": "saved", "id": id })))
168
+ }
169
+
170
+ /// Commit an AI-driven merge: overwrite `merge_into` with the merged text and
171
+ /// delete the absorbed memories, recording them in `superseded_ids`.
172
+ fn commit_merge(
173
+ handoff: &std::path::Path,
174
+ merge_into: &str,
175
+ absorb_ids: &[String],
176
+ text: String,
177
+ kind: String,
178
+ tags: Vec<String>,
179
+ scope_paths: Vec<String>,
180
+ ) -> Result<String> {
181
+ let mut target = read_memory_by_id(handoff, merge_into)?
182
+ .ok_or_else(|| anyhow::anyhow!("merge_into target not found: {merge_into}"))?;
183
+
184
+ let now = now_rfc3339();
185
+ target.text = text;
186
+ target.kind = kind;
187
+ if !tags.is_empty() {
188
+ target.tags = tags;
189
+ }
190
+ if !scope_paths.is_empty() {
191
+ target.scope_paths = scope_paths;
192
+ }
193
+ target.content_hash = lexsim::content_hash(&target.text);
194
+ target.updated_at = now;
195
+
196
+ let target_id = target.id.clone();
197
+ let mut absorbed: Vec<String> = Vec::new();
198
+ for raw in absorb_ids {
199
+ if raw == &target_id {
200
+ continue; // never absorb the target into itself
201
+ }
202
+ // Resolve to a concrete id (supports prefixes), then delete it.
203
+ if let Some(m) = read_memory_by_id(handoff, raw)? {
204
+ if delete_memory(handoff, &m.id)? {
205
+ absorbed.push(m.id);
206
+ }
207
+ }
208
+ }
209
+ for a in &absorbed {
210
+ if !target.superseded_ids.contains(a) {
211
+ target.superseded_ids.push(a.clone());
212
+ }
213
+ }
214
+
215
+ write_memory(handoff, &target)?;
216
+ Ok(to_json(&json!({
217
+ "status": "merged",
218
+ "id": target_id,
219
+ "absorbed_ids": absorbed,
220
+ })))
221
+ }
222
+
223
+ /// `memory_query` — return memories relevant to the current prompt/file.
224
+ ///
225
+ /// BM25 relevance + scope-path boosting, then **per-session diff injection**
226
+ /// (spec D): when `session_id` is given, memories already injected this session
227
+ /// with the same `content_hash` are filtered out, while an edited memory (new
228
+ /// hash) is re-injected. With `mark_injected` (default true) the survivors are
229
+ /// recorded in the session sidecar and their `hit_count` / `last_referenced_at`
230
+ /// are bumped. Without `session_id` this degrades to plain relevance ranking.
231
+ pub fn handle_query(arguments: &Value) -> Result<String> {
232
+ let project_dir = resolve_project_dir(arguments)?;
233
+ let handoff = ensure_handoff_exists(&project_dir)?;
234
+
235
+ let text = arguments
236
+ .get("text")
237
+ .and_then(|v| v.as_str())
238
+ .unwrap_or("")
239
+ .to_string();
240
+ let settings = memory_settings(&handoff)?;
241
+ if !settings.memory_enabled {
242
+ return Ok(disabled_payload());
243
+ }
244
+ let tool_name = arguments.get("tool_name").and_then(|v| v.as_str());
245
+ let file_paths = string_array(arguments, "file_paths");
246
+ let limit = arguments
247
+ .get("limit")
248
+ .and_then(|v| v.as_u64())
249
+ .map(|n| n as usize)
250
+ .filter(|n| *n > 0)
251
+ .unwrap_or(settings.memory_query_limit as usize);
252
+ let session_id = arguments
253
+ .get("session_id")
254
+ .and_then(|v| v.as_str())
255
+ .map(str::trim)
256
+ .filter(|s| !s.is_empty());
257
+ let mark_injected = arguments
258
+ .get("mark_injected")
259
+ .and_then(|v| v.as_bool())
260
+ .unwrap_or(true);
261
+
262
+ let memories = read_all_memories(&handoff)?;
263
+ if memories.is_empty() {
264
+ return Ok(to_json(&json!({ "memories": [], "injected_count": 0 })));
265
+ }
266
+
267
+ // Build the BM25 corpus over each memory's index text (body + tags).
268
+ let docs: Vec<String> = memories.iter().map(|m| m.index_text()).collect();
269
+ let corpus = lexsim::Corpus::build(&docs);
270
+
271
+ // Query = prompt text + tool name + file basenames (so a PreToolUse hook
272
+ // that only passes a file path still matches name-related memories).
273
+ let mut query_tokens = lexsim::tokenize(&text);
274
+ if let Some(tn) = tool_name {
275
+ query_tokens.extend(lexsim::tokenize(tn));
276
+ }
277
+ for p in &file_paths {
278
+ query_tokens.extend(lexsim::tokenize(&basename(p)));
279
+ }
280
+ let scores = corpus.bm25_scores_tokens(&query_tokens);
281
+
282
+ // Score + scope-path bonus, then threshold and rank.
283
+ let mut ranked: Vec<(usize, f64)> = memories
284
+ .iter()
285
+ .enumerate()
286
+ .map(|(i, m)| {
287
+ let mut s = scores[i];
288
+ if scope_matches(&m.scope_paths, &file_paths) {
289
+ s += SCOPE_PATH_BONUS;
290
+ }
291
+ (i, s)
292
+ })
293
+ .filter(|(_, s)| *s >= settings.memory_query_min_score)
294
+ .collect();
295
+ ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
296
+
297
+ // Per-session diff: drop memories already injected this session at the same
298
+ // hash. The `limit` is applied to the *fresh* set so the caller still gets up
299
+ // to `limit` new memories even when earlier prompts already consumed some.
300
+ let now = now_rfc3339();
301
+ let injected_set = session_id.map(|sid| read_injected_set(&handoff, sid, &now));
302
+ let fresh: Vec<(usize, f64)> = ranked
303
+ .into_iter()
304
+ .filter(|(i, _)| match &injected_set {
305
+ Some(set) => {
306
+ let m = &memories[*i];
307
+ !set.already_injected(&m.id, &m.content_hash)
308
+ }
309
+ None => true,
310
+ })
311
+ .take(limit)
312
+ .collect();
313
+
314
+ let out: Vec<Value> = fresh
315
+ .iter()
316
+ .map(|(i, s)| {
317
+ let m = &memories[*i];
318
+ json!({
319
+ "id": m.id,
320
+ "text": m.text,
321
+ "kind": m.kind,
322
+ "score": round2(*s),
323
+ })
324
+ })
325
+ .collect();
326
+
327
+ // Bookkeeping: record survivors in the sidecar and bump usage stats. Only
328
+ // when we have a session id and marking is enabled (the hook's normal path).
329
+ if mark_injected && !fresh.is_empty() {
330
+ if let Some(sid) = session_id {
331
+ mark_injected_memories(&handoff, sid, &memories, &fresh, &now)?;
332
+ }
333
+ }
334
+
335
+ Ok(to_json(&json!({
336
+ "memories": out,
337
+ "injected_count": out.len(),
338
+ })))
339
+ }
340
+
341
+ /// Append the freshly-injected memories to the session sidecar and bump each
342
+ /// one's `hit_count` / `last_referenced_at`.
343
+ ///
344
+ /// Ordering matters: the **sidecar is written first**, before the per-memory
345
+ /// stat bumps. The sidecar is what suppresses re-injection, so it must be
346
+ /// recorded even if a later stat write fails — otherwise a failed bump would
347
+ /// skip the sidecar and re-spam the session on the next query (and double-count
348
+ /// any memory whose stats were already persisted before the failure). The stat
349
+ /// bumps are therefore strictly best-effort: a failure on one memory must not
350
+ /// drop the sidecar record or abort the rest.
351
+ fn mark_injected_memories(
352
+ handoff: &std::path::Path,
353
+ session_id: &str,
354
+ memories: &[MemoryEntry],
355
+ fresh: &[(usize, f64)],
356
+ now: &str,
357
+ ) -> Result<()> {
358
+ let mut set = read_injected_set(handoff, session_id, now);
359
+ set.updated_at = now.to_string();
360
+ for (i, _) in fresh {
361
+ let m = &memories[*i];
362
+ set.mark(&m.id, &m.content_hash);
363
+ }
364
+ // (1) Persist the suppression record first — this is the correctness-critical
365
+ // write. If it fails, surface the error (the session state is now unknown).
366
+ write_injected_set(handoff, &set)?;
367
+
368
+ // (2) Best-effort usage stats. Re-read each memory so we don't clobber a
369
+ // concurrent edit's other fields. A failure here is non-fatal: the sidecar
370
+ // already recorded the injection, so the worst case is a slightly stale
371
+ // hit_count — never a re-spam or a double count.
372
+ for (i, _) in fresh {
373
+ let m = &memories[*i];
374
+ if let Ok(Some(mut entry)) = read_memory_by_id(handoff, &m.id) {
375
+ entry.hit_count = entry.hit_count.saturating_add(1);
376
+ entry.last_referenced_at = Some(now.to_string());
377
+ let _ = write_memory(handoff, &entry);
378
+ }
379
+ }
380
+ Ok(())
381
+ }
382
+
383
+ /// `memory_delete` — remove a memory by id (AI-driven stale cleanup / tests).
384
+ pub fn handle_delete(arguments: &Value) -> Result<String> {
385
+ let project_dir = resolve_project_dir(arguments)?;
386
+ let handoff = ensure_handoff_exists(&project_dir)?;
387
+ if !memory_settings(&handoff)?.memory_enabled {
388
+ return Ok(disabled_payload());
389
+ }
390
+
391
+ let id = arguments
392
+ .get("id")
393
+ .and_then(|v| v.as_str())
394
+ .ok_or_else(|| anyhow::anyhow!("'id' is required"))?;
395
+
396
+ // Resolve prefixes to a concrete id for a friendly delete-by-prefix.
397
+ let resolved = read_memory_by_id(&handoff, id)?
398
+ .ok_or_else(|| anyhow::anyhow!("Memory not found: {id}"))?;
399
+ let deleted = delete_memory(&handoff, &resolved.id)?;
400
+ if !deleted {
401
+ anyhow::bail!("Memory not found: {id}");
402
+ }
403
+ Ok(to_json(&json!({ "status": "deleted", "id": resolved.id })))
404
+ }
405
+
406
+ /// `memory_cleanup` — SessionStart housekeeping (spec C).
407
+ ///
408
+ /// Three independent passes over the memory store:
409
+ ///
410
+ /// 1. **Exact duplicates** (identical `content_hash`) are merged *silently and
411
+ /// losslessly*: the oldest memory in each hash group is kept, the others are
412
+ /// deleted and recorded in its `superseded_ids`. This is the only mutating
413
+ /// pass, gated by `apply_exact_merges` (default true).
414
+ /// 2. **Near-duplicate clusters** (Jaccard ≥ threshold, grouped by union-find)
415
+ /// and **stale** memories (`last_referenced_at` — or `created_at` if never
416
+ /// referenced — older than `stale_days`) are returned as *recommendations*
417
+ /// for the AI to act on with `memory_save(merge_into=…)` / `memory_delete`.
418
+ /// This pass never writes.
419
+ /// 3. The `injected/` sidecars older than the gc window are removed.
420
+ ///
421
+ /// Returns a JSON string
422
+ /// `{"auto_merged_exact":n,"cleanup_recommendations":{"similar_clusters":[…],
423
+ /// "stale":[…]},"injected_sidecars_removed":k}`.
424
+ pub fn handle_cleanup(arguments: &Value) -> Result<String> {
425
+ let project_dir = resolve_project_dir(arguments)?;
426
+ let handoff = ensure_handoff_exists(&project_dir)?;
427
+
428
+ let settings = memory_settings(&handoff)?;
429
+ if !settings.memory_enabled {
430
+ return Ok(disabled_payload());
431
+ }
432
+ let apply_exact_merges = arguments
433
+ .get("apply_exact_merges")
434
+ .and_then(|v| v.as_bool())
435
+ .unwrap_or(true);
436
+ let stale_days = arguments
437
+ .get("stale_days")
438
+ .and_then(|v| v.as_i64())
439
+ .filter(|n| *n >= 0)
440
+ .unwrap_or(settings.memory_stale_days);
441
+
442
+ let now = chrono::Utc::now();
443
+ let now_str = now.to_rfc3339();
444
+
445
+ // (1) Exact-duplicate auto-merge (lossless). Re-reads memories afterward so
446
+ // later passes see the merged store.
447
+ let auto_merged_exact = if apply_exact_merges {
448
+ merge_exact_duplicates(&handoff, &now_str)?
449
+ } else {
450
+ 0
451
+ };
452
+
453
+ let memories = read_all_memories(&handoff)?;
454
+
455
+ // (2a) Near-duplicate clusters (recommendation only).
456
+ let similar_clusters = similar_clusters(&memories, settings.memory_dup_threshold);
457
+
458
+ // (2b) Stale memories (recommendation only).
459
+ let stale = stale_memories(&memories, stale_days, now);
460
+
461
+ // (3) Garbage-collect old per-session sidecars.
462
+ let injected_sidecars_removed =
463
+ gc_injected_sets(&handoff, settings.memory_injected_gc_days, now)?;
464
+
465
+ Ok(to_json(&json!({
466
+ "auto_merged_exact": auto_merged_exact,
467
+ "cleanup_recommendations": {
468
+ "similar_clusters": similar_clusters,
469
+ "stale": stale,
470
+ },
471
+ "injected_sidecars_removed": injected_sidecars_removed,
472
+ })))
473
+ }
474
+
475
+ /// Merge memories that share an identical `content_hash`. The oldest entry in
476
+ /// each group (by parsed `created_at`, ties broken by id) is kept; the rest are
477
+ /// absorbed into it and their files deleted. Returns the number of memories
478
+ /// absorbed (files removed).
479
+ ///
480
+ /// "Lossless" means **no signal is dropped**, not byte-identical text: the
481
+ /// canonical content is identical by construction (same hash over the tokenized
482
+ /// text), so the keeper's body already represents every absorbed memory's
483
+ /// meaning. To avoid losing the *other* fields, the keeper inherits the union of
484
+ /// every absorbed memory's `tags` / `scope_paths` / `superseded_ids`, the sum of
485
+ /// their `hit_count`, and the latest `last_referenced_at`.
486
+ ///
487
+ /// Failure safety (mirrors `mark_injected_memories`): the keeper is rewritten
488
+ /// with the full merged metadata and `superseded_ids` **before** any duplicate
489
+ /// file is deleted, so the audit trail is durable even if a later delete fails.
490
+ /// Per-duplicate deletes are then best-effort — a failure on one leaves an
491
+ /// orphaned-but-superseded file (re-absorbed on the next run) rather than a lost
492
+ /// record.
493
+ fn merge_exact_duplicates(handoff: &std::path::Path, now: &str) -> Result<usize> {
494
+ use std::collections::BTreeMap;
495
+
496
+ let memories = read_all_memories(handoff)?;
497
+ // Group by content_hash, preserving discovery order within each group.
498
+ let mut groups: BTreeMap<String, Vec<MemoryEntry>> = BTreeMap::new();
499
+ for m in memories {
500
+ groups.entry(m.content_hash.clone()).or_default().push(m);
501
+ }
502
+
503
+ let mut absorbed_total = 0usize;
504
+ for (_hash, mut group) in groups {
505
+ if group.len() < 2 {
506
+ continue;
507
+ }
508
+ // Keep the oldest as the canonical survivor. Parse created_at to an
509
+ // instant so differing RFC3339 offsets order by true time, not by string
510
+ // (ties — including unparseable stamps — broken by id for determinism).
511
+ group.sort_by(|a, b| {
512
+ parse_instant(&a.created_at)
513
+ .cmp(&parse_instant(&b.created_at))
514
+ .then_with(|| a.id.cmp(&b.id))
515
+ });
516
+ let mut keeper = group.remove(0);
517
+
518
+ // Fold every absorbed memory's signal into the keeper (union of tags /
519
+ // scope_paths / superseded_ids; summed hit_count; latest reference).
520
+ for dup in &group {
521
+ if !keeper.superseded_ids.contains(&dup.id) {
522
+ keeper.superseded_ids.push(dup.id.clone());
523
+ }
524
+ for sid in &dup.superseded_ids {
525
+ if sid != &keeper.id && !keeper.superseded_ids.contains(sid) {
526
+ keeper.superseded_ids.push(sid.clone());
527
+ }
528
+ }
529
+ for t in &dup.tags {
530
+ if !keeper.tags.contains(t) {
531
+ keeper.tags.push(t.clone());
532
+ }
533
+ }
534
+ for s in &dup.scope_paths {
535
+ if !keeper.scope_paths.contains(s) {
536
+ keeper.scope_paths.push(s.clone());
537
+ }
538
+ }
539
+ keeper.hit_count = keeper.hit_count.saturating_add(dup.hit_count);
540
+ keeper.last_referenced_at = latest_timestamp(
541
+ keeper.last_referenced_at.take(),
542
+ dup.last_referenced_at.clone(),
543
+ );
544
+ }
545
+ keeper.updated_at = now.to_string();
546
+
547
+ // (1) Persist the merged keeper FIRST — this is the durable audit record.
548
+ write_memory(handoff, &keeper)?;
549
+
550
+ // (2) Best-effort delete the absorbed files. A failure here is non-fatal:
551
+ // the keeper already records the absorption, so a surviving dup is simply
552
+ // re-absorbed next run.
553
+ for dup in &group {
554
+ if matches!(delete_memory(handoff, &dup.id), Ok(true)) {
555
+ absorbed_total += 1;
556
+ }
557
+ }
558
+ }
559
+ Ok(absorbed_total)
560
+ }
561
+
562
+ /// Parse an RFC3339 timestamp to a UTC instant for ordering. Unparseable stamps
563
+ /// sort as the epoch (treated as "oldest") so they don't crash ordering; the id
564
+ /// tie-break keeps the result deterministic.
565
+ fn parse_instant(s: &str) -> chrono::DateTime<chrono::Utc> {
566
+ chrono::DateTime::parse_from_rfc3339(s)
567
+ .map(|d| d.with_timezone(&chrono::Utc))
568
+ .unwrap_or_else(|_| chrono::DateTime::<chrono::Utc>::MIN_UTC)
569
+ }
570
+
571
+ /// Return the later of two optional RFC3339 timestamps (an unparseable one loses;
572
+ /// `None` loses to `Some`).
573
+ fn latest_timestamp(a: Option<String>, b: Option<String>) -> Option<String> {
574
+ match (a, b) {
575
+ (Some(x), Some(y)) => {
576
+ if parse_instant(&y) > parse_instant(&x) {
577
+ Some(y)
578
+ } else {
579
+ Some(x)
580
+ }
581
+ }
582
+ (Some(x), None) => Some(x),
583
+ (None, b) => b,
584
+ }
585
+ }
586
+
587
+ /// Group memories into near-duplicate clusters via Jaccard ≥ threshold using
588
+ /// union-find, and emit each multi-member cluster as a recommendation. Exact
589
+ /// duplicates have already been merged by the time this runs, so a cluster here
590
+ /// is genuinely "similar but not identical" — the AI decides whether to merge.
591
+ fn similar_clusters(memories: &[MemoryEntry], dup_threshold: f64) -> Vec<Value> {
592
+ let n = memories.len();
593
+ if n < 2 {
594
+ return Vec::new();
595
+ }
596
+
597
+ // Precompute each memory's token set once (O(n) tokenizations, not O(n²)).
598
+ let token_sets: Vec<_> = memories
599
+ .iter()
600
+ .map(|m| lexsim::token_set(&m.index_text()))
601
+ .collect();
602
+
603
+ let mut uf = UnionFind::new(n);
604
+ let mut pair_scores: std::collections::HashMap<(usize, usize), f64> =
605
+ std::collections::HashMap::new();
606
+ for i in 0..n {
607
+ for j in (i + 1)..n {
608
+ let score = lexsim::jaccard_sets(&token_sets[i], &token_sets[j]);
609
+ if score >= dup_threshold {
610
+ uf.union(i, j);
611
+ pair_scores.insert((i, j), score);
612
+ }
613
+ }
614
+ }
615
+
616
+ // Bucket indices by their union-find root.
617
+ let mut clusters: std::collections::BTreeMap<usize, Vec<usize>> =
618
+ std::collections::BTreeMap::new();
619
+ for i in 0..n {
620
+ clusters.entry(uf.find(i)).or_default().push(i);
621
+ }
622
+
623
+ let mut out: Vec<Value> = Vec::new();
624
+ for (_root, members) in clusters {
625
+ if members.len() < 2 {
626
+ continue;
627
+ }
628
+ // Representative pair score = the max similarity within the cluster, so
629
+ // the AI sees how tight it is.
630
+ let mut max_score = 0.0_f64;
631
+ for a in 0..members.len() {
632
+ for b in (a + 1)..members.len() {
633
+ let (lo, hi) = (members[a].min(members[b]), members[a].max(members[b]));
634
+ if let Some(s) = pair_scores.get(&(lo, hi)) {
635
+ if *s > max_score {
636
+ max_score = *s;
637
+ }
638
+ }
639
+ }
640
+ }
641
+ let entries: Vec<Value> = members
642
+ .iter()
643
+ .map(|&i| {
644
+ let m = &memories[i];
645
+ json!({ "id": m.id, "text": m.text, "kind": m.kind })
646
+ })
647
+ .collect();
648
+ out.push(json!({
649
+ "max_score": round2(max_score),
650
+ "memories": entries,
651
+ }));
652
+ }
653
+ // Tightest clusters first.
654
+ out.sort_by(|a, b| {
655
+ b["max_score"]
656
+ .as_f64()
657
+ .partial_cmp(&a["max_score"].as_f64())
658
+ .unwrap_or(std::cmp::Ordering::Equal)
659
+ });
660
+ out
661
+ }
662
+
663
+ /// Flag memories not referenced for `stale_days` (using `last_referenced_at`, or
664
+ /// `created_at` when never referenced). Recommendation only — the AI decides
665
+ /// whether a stale memory is obsolete or simply rarely relevant.
666
+ fn stale_memories(
667
+ memories: &[MemoryEntry],
668
+ stale_days: i64,
669
+ now: chrono::DateTime<chrono::Utc>,
670
+ ) -> Vec<Value> {
671
+ let cutoff = now - chrono::Duration::days(stale_days);
672
+ let mut out: Vec<(chrono::DateTime<chrono::Utc>, Value)> = Vec::new();
673
+ for m in memories {
674
+ // The reference point: last injection if any, else creation time.
675
+ let stamp = m.last_referenced_at.as_deref().unwrap_or(&m.created_at);
676
+ let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(stamp) else {
677
+ continue; // unparseable timestamp → can't judge age, skip
678
+ };
679
+ let parsed = parsed.with_timezone(&chrono::Utc);
680
+ if parsed < cutoff {
681
+ out.push((
682
+ parsed,
683
+ json!({
684
+ "id": m.id,
685
+ "text": m.text,
686
+ "kind": m.kind,
687
+ "hit_count": m.hit_count,
688
+ "last_referenced_at": m.last_referenced_at,
689
+ "created_at": m.created_at,
690
+ }),
691
+ ));
692
+ }
693
+ }
694
+ // Oldest (most stale) first.
695
+ out.sort_by_key(|(stamp, _)| *stamp);
696
+ out.into_iter().map(|(_, v)| v).collect()
697
+ }
698
+
699
+ /// Minimal union-find (disjoint set) with path compression and union by size,
700
+ /// used to cluster near-duplicate memories.
701
+ struct UnionFind {
702
+ parent: Vec<usize>,
703
+ size: Vec<usize>,
704
+ }
705
+
706
+ impl UnionFind {
707
+ fn new(n: usize) -> Self {
708
+ UnionFind {
709
+ parent: (0..n).collect(),
710
+ size: vec![1; n],
711
+ }
712
+ }
713
+
714
+ fn find(&mut self, x: usize) -> usize {
715
+ let mut root = x;
716
+ while self.parent[root] != root {
717
+ root = self.parent[root];
718
+ }
719
+ // Path compression.
720
+ let mut cur = x;
721
+ while self.parent[cur] != root {
722
+ let next = self.parent[cur];
723
+ self.parent[cur] = root;
724
+ cur = next;
725
+ }
726
+ root
727
+ }
728
+
729
+ fn union(&mut self, a: usize, b: usize) {
730
+ let (ra, rb) = (self.find(a), self.find(b));
731
+ if ra == rb {
732
+ return;
733
+ }
734
+ // Union by size: attach the smaller tree under the larger.
735
+ let (big, small) = if self.size[ra] >= self.size[rb] {
736
+ (ra, rb)
737
+ } else {
738
+ (rb, ra)
739
+ };
740
+ self.parent[small] = big;
741
+ self.size[big] += self.size[small];
742
+ }
743
+ }
744
+
745
+ /// True if any `scope` prefix matches any `file` path.
746
+ fn scope_matches(scopes: &[String], files: &[String]) -> bool {
747
+ if scopes.is_empty() || files.is_empty() {
748
+ return false;
749
+ }
750
+ scopes
751
+ .iter()
752
+ .any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
753
+ }
754
+
755
+ /// Last path component of `p` (handles both `/` and `\` separators).
756
+ fn basename(p: &str) -> String {
757
+ p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
758
+ }
759
+
760
+ /// Read a `&[String]` from a JSON string-array argument (missing → empty).
761
+ fn string_array(arguments: &Value, key: &str) -> Vec<String> {
762
+ arguments
763
+ .get(key)
764
+ .and_then(|v| v.as_array())
765
+ .map(|arr| {
766
+ arr.iter()
767
+ .filter_map(|v| v.as_str())
768
+ .map(str::to_string)
769
+ .collect()
770
+ })
771
+ .unwrap_or_default()
772
+ }
773
+
774
+ fn round2(x: f64) -> f64 {
775
+ (x * 100.0).round() / 100.0
776
+ }
777
+
778
+ fn to_json(v: &Value) -> String {
779
+ // Pretty so a human reading the raw tool result can follow it; both hook and
780
+ // wrapper paths parse it the same way.
781
+ serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
782
+ }
783
+
784
+ #[cfg(test)]
785
+ mod tests {
786
+ use super::*;
787
+
788
+ /// The spec-default Jaccard threshold, used to drive the pure clustering
789
+ /// helper in unit tests (the configurable value lives in `SettingsConfig`).
790
+ const DEFAULT_DUP_THRESHOLD: f64 = 0.72;
791
+
792
+ #[test]
793
+ fn basename_handles_separators() {
794
+ assert_eq!(basename("src/storage/mod.rs"), "mod.rs");
795
+ assert_eq!(basename("a\\b\\c.rs"), "c.rs");
796
+ assert_eq!(basename("plain.rs"), "plain.rs");
797
+ }
798
+
799
+ #[test]
800
+ fn scope_matches_prefix() {
801
+ let scopes = vec!["src/storage/".to_string()];
802
+ let files = vec!["/repo/src/storage/mod.rs".to_string()];
803
+ assert!(scope_matches(&scopes, &files));
804
+ let files2 = vec!["/repo/src/mcp/mod.rs".to_string()];
805
+ assert!(!scope_matches(&scopes, &files2));
806
+ }
807
+
808
+ #[test]
809
+ fn string_array_parsing() {
810
+ let v = json!({ "tags": ["a", "b", 3, "c"] });
811
+ assert_eq!(string_array(&v, "tags"), vec!["a", "b", "c"]);
812
+ assert!(string_array(&v, "missing").is_empty());
813
+ }
814
+
815
+ #[test]
816
+ fn round2_works() {
817
+ assert_eq!(round2(1.23456), 1.23);
818
+ }
819
+
820
+ fn mem(id: &str, text: &str, created: &str, last_ref: Option<&str>) -> MemoryEntry {
821
+ let mut m = MemoryEntry::new(
822
+ id.to_string(),
823
+ text.to_string(),
824
+ "lesson".to_string(),
825
+ vec![],
826
+ vec![],
827
+ created.to_string(),
828
+ );
829
+ m.last_referenced_at = last_ref.map(str::to_string);
830
+ m
831
+ }
832
+
833
+ #[test]
834
+ fn union_find_groups_transitively() {
835
+ let mut uf = UnionFind::new(5);
836
+ uf.union(0, 1);
837
+ uf.union(1, 2);
838
+ // {0,1,2} share a root; 3 and 4 are singletons.
839
+ assert_eq!(uf.find(0), uf.find(2));
840
+ assert_ne!(uf.find(0), uf.find(3));
841
+ assert_ne!(uf.find(3), uf.find(4));
842
+ }
843
+
844
+ #[test]
845
+ fn similar_clusters_groups_near_duplicates() {
846
+ let now = now_rfc3339();
847
+ let a = mem(
848
+ "m-a",
849
+ "always use atomic_write for handoff files",
850
+ &now,
851
+ None,
852
+ );
853
+ let b = mem(
854
+ "m-b",
855
+ "always use atomic_write for handoff files when saving",
856
+ &now,
857
+ None,
858
+ );
859
+ let c = mem(
860
+ "m-c",
861
+ "the gantt chart export is totally unrelated",
862
+ &now,
863
+ None,
864
+ );
865
+ let clusters = similar_clusters(&[a, b, c], DEFAULT_DUP_THRESHOLD);
866
+ assert_eq!(clusters.len(), 1, "only a+b cluster; c stands alone");
867
+ let members = clusters[0]["memories"].as_array().unwrap();
868
+ assert_eq!(members.len(), 2);
869
+ assert!(clusters[0]["max_score"].as_f64().unwrap() >= DEFAULT_DUP_THRESHOLD);
870
+ }
871
+
872
+ #[test]
873
+ fn similar_clusters_empty_when_all_distinct() {
874
+ let now = now_rfc3339();
875
+ let a = mem("m-a", "use atomic_write everywhere", &now, None);
876
+ let b = mem("m-b", "render the gantt chart schedule", &now, None);
877
+ assert!(similar_clusters(&[a, b], DEFAULT_DUP_THRESHOLD).is_empty());
878
+ }
879
+
880
+ #[test]
881
+ fn stale_uses_last_referenced_then_created() {
882
+ let now = chrono::DateTime::parse_from_rfc3339("2026-06-27T00:00:00Z")
883
+ .unwrap()
884
+ .with_timezone(&chrono::Utc);
885
+ // Never referenced, created 100 days ago → stale.
886
+ let old = mem("m-old", "ancient rule", "2026-03-19T00:00:00Z", None);
887
+ // Created long ago but referenced yesterday → NOT stale.
888
+ let touched = mem(
889
+ "m-fresh",
890
+ "recently used rule",
891
+ "2026-01-01T00:00:00Z",
892
+ Some("2026-06-26T00:00:00Z"),
893
+ );
894
+ let stale = stale_memories(&[old, touched], 60, now);
895
+ assert_eq!(stale.len(), 1);
896
+ assert_eq!(stale[0]["id"], "m-old");
897
+ }
898
+
899
+ #[test]
900
+ fn stale_skips_unparseable_timestamp() {
901
+ let now = chrono::Utc::now();
902
+ let mut bad = mem("m-bad", "x", "not-a-date", None);
903
+ bad.created_at = "not-a-date".to_string();
904
+ assert!(stale_memories(&[bad], 0, now).is_empty());
905
+ }
906
+ }