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