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,340 +0,0 @@
1
- //! Per-session "already injected" sidecars under `.handoff/memory/injected/`.
2
- //!
3
- //! ```text
4
- //! .handoff/memory/injected/
5
- //! <hook_session_id>.json # { session_id, updated_at, injected: { id -> hash } }
6
- //! ```
7
- //!
8
- //! The sidecar is keyed by the **hook's `session_id`** (not the timestamped
9
- //! session file): the hook never sees a session filename, and the id is stable
10
- //! across compaction/resume but changes on `/clear` — which gives us a free
11
- //! per-conversation reset.
12
- //!
13
- //! A memory's value is its `content_hash`. `memory_query` skips a memory whose
14
- //! id is present with the *same* hash (already injected this session) but
15
- //! re-injects when the hash differs (the memory was edited since). All writes go
16
- //! through [`crate::storage::atomic_write`] so a concurrent reader never sees a
17
- //! torn file; reads are lenient (a corrupt sidecar is treated as empty).
18
-
19
- use std::collections::BTreeMap;
20
- use std::path::{Path, PathBuf};
21
-
22
- use anyhow::{Context, Result};
23
- use serde::{Deserialize, Serialize};
24
-
25
- use super::memory_dir;
26
-
27
- /// Current sidecar schema version.
28
- pub const INJECTED_SCHEMA_VERSION: u32 = 1;
29
-
30
- /// The set of memories already injected into one hook session.
31
- ///
32
- /// `injected` maps memory id → the `content_hash` that was injected, so an edited
33
- /// memory (new hash) is re-injected while an unchanged one is skipped.
34
- #[derive(Debug, Clone, Serialize, Deserialize)]
35
- pub struct InjectedSet {
36
- /// Schema version (= [`INJECTED_SCHEMA_VERSION`]).
37
- #[serde(default = "default_version")]
38
- pub version: u32,
39
- /// The hook session id this sidecar belongs to.
40
- pub session_id: String,
41
- /// RFC3339 timestamp of the last update.
42
- pub updated_at: String,
43
- /// memory id → injected `content_hash`.
44
- #[serde(default)]
45
- pub injected: BTreeMap<String, String>,
46
- }
47
-
48
- fn default_version() -> u32 {
49
- INJECTED_SCHEMA_VERSION
50
- }
51
-
52
- impl InjectedSet {
53
- /// A fresh, empty set for `session_id`.
54
- pub fn new(session_id: String, now: String) -> Self {
55
- InjectedSet {
56
- version: INJECTED_SCHEMA_VERSION,
57
- session_id,
58
- updated_at: now,
59
- injected: BTreeMap::new(),
60
- }
61
- }
62
-
63
- /// True if `id` was already injected this session with the *same* hash. A
64
- /// differing hash (edited memory) returns false so it is re-injected.
65
- pub fn already_injected(&self, id: &str, content_hash: &str) -> bool {
66
- self.injected.get(id).map(String::as_str) == Some(content_hash)
67
- }
68
-
69
- /// Record `id`→`content_hash` as injected. Returns true if this changed the
70
- /// set (new id or a different hash), false if it was already present.
71
- pub fn mark(&mut self, id: &str, content_hash: &str) -> bool {
72
- match self.injected.get(id) {
73
- Some(existing) if existing == content_hash => false,
74
- _ => {
75
- self.injected
76
- .insert(id.to_string(), content_hash.to_string());
77
- true
78
- }
79
- }
80
- }
81
- }
82
-
83
- /// Path to the `injected/` directory inside a `memory/` dir.
84
- pub fn injected_dir(handoff_dir: &Path) -> PathBuf {
85
- memory_dir(handoff_dir).join("injected")
86
- }
87
-
88
- /// Sanitize a hook session id into a safe, **collision-free** single-path
89
- /// file stem.
90
- ///
91
- /// Session ids are opaque strings from the hook environment; we must never let
92
- /// one escape `injected/` (path traversal) or collide with the temp-file naming
93
- /// in `atomic_write`. Two requirements:
94
- ///
95
- /// 1. **Safety**: the stem is a single path component — no `/`, `\`, `..`, or
96
- /// leading `.` can survive (each unsafe char becomes `_`, leading dots are
97
- /// dropped).
98
- /// 2. **Uniqueness**: the readable part alone is lossy (`a/b` and `a_b` would
99
- /// map to the same `a_b`), so we always append `-<fnv1a(raw id)>`. Distinct
100
- /// raw ids therefore never share a sidecar, while the stem stays
101
- /// human-recognizable. The hash is over the *raw* id, so it disambiguates
102
- /// even when the readable prefix is identical or empty.
103
- fn sanitize_session_id(session_id: &str) -> String {
104
- let mut out = String::with_capacity(session_id.len());
105
- for ch in session_id.chars() {
106
- if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') {
107
- out.push(ch);
108
- } else if ch == '.' {
109
- // Keep dots only when not leading (avoid hidden files / `..`).
110
- if !out.is_empty() {
111
- out.push('.');
112
- }
113
- } else {
114
- out.push('_');
115
- }
116
- }
117
- // Trim trailing dots so we never produce `name.`, and cap the readable part.
118
- let trimmed = out.trim_end_matches('.');
119
- let prefix: String = trimmed.chars().take(96).collect();
120
- let prefix = if prefix.is_empty() { "anon" } else { &prefix };
121
- // Always disambiguate with a hash of the RAW id (std-only FNV-1a).
122
- format!("{prefix}-{}", lexsim::fnv1a_hex(session_id.as_bytes()))
123
- }
124
-
125
- /// Path to one session's sidecar file.
126
- pub fn injected_path(handoff_dir: &Path, session_id: &str) -> PathBuf {
127
- injected_dir(handoff_dir).join(format!("{}.json", sanitize_session_id(session_id)))
128
- }
129
-
130
- /// Read the injected set for `session_id`. Returns a fresh empty set when the
131
- /// file does not exist or is unparseable (lenient: a corrupt sidecar must not
132
- /// break injection — at worst a memory is shown twice).
133
- pub fn read_injected_set(handoff_dir: &Path, session_id: &str, now: &str) -> InjectedSet {
134
- let path = injected_path(handoff_dir, session_id);
135
- match std::fs::read_to_string(&path) {
136
- Ok(content) => serde_json::from_str::<InjectedSet>(&content)
137
- .unwrap_or_else(|_| InjectedSet::new(session_id.to_string(), now.to_string())),
138
- Err(_) => InjectedSet::new(session_id.to_string(), now.to_string()),
139
- }
140
- }
141
-
142
- /// Write a session's injected set atomically, creating `injected/` lazily.
143
- pub fn write_injected_set(handoff_dir: &Path, set: &InjectedSet) -> Result<PathBuf> {
144
- let dir = injected_dir(handoff_dir);
145
- std::fs::create_dir_all(&dir)
146
- .with_context(|| format!("Failed to create injected dir: {}", dir.display()))?;
147
- let path = injected_path(handoff_dir, &set.session_id);
148
- let content = serde_json::to_string_pretty(set).context("Failed to serialize injected set")?;
149
- crate::storage::atomic_write(&path, content.as_bytes())
150
- .with_context(|| format!("Failed to write injected set: {}", path.display()))?;
151
- Ok(path)
152
- }
153
-
154
- /// Garbage-collect sidecars whose `updated_at` is older than `max_age_days`.
155
- /// Returns the number of files removed. A sidecar with an unparseable
156
- /// `updated_at` is left alone (we can't tell its age). `now` is supplied so the
157
- /// caller controls the clock (testable).
158
- pub fn gc_injected_sets(
159
- handoff_dir: &Path,
160
- max_age_days: i64,
161
- now: chrono::DateTime<chrono::Utc>,
162
- ) -> Result<usize> {
163
- let dir = injected_dir(handoff_dir);
164
- if !dir.exists() {
165
- return Ok(0);
166
- }
167
- let cutoff = now - chrono::Duration::days(max_age_days);
168
- let mut removed = 0usize;
169
- for entry in std::fs::read_dir(&dir)
170
- .with_context(|| format!("Failed to read injected dir: {}", dir.display()))?
171
- .filter_map(|e| e.ok())
172
- {
173
- let path = entry.path();
174
- if !path.is_file() {
175
- continue;
176
- }
177
- let name = entry.file_name().to_string_lossy().to_string();
178
- if !name.ends_with(".json") || name.starts_with('.') {
179
- continue;
180
- }
181
- let Ok(content) = std::fs::read_to_string(&path) else {
182
- continue;
183
- };
184
- let Ok(set) = serde_json::from_str::<InjectedSet>(&content) else {
185
- continue; // unparseable → leave it (can't determine age)
186
- };
187
- let Ok(updated) = chrono::DateTime::parse_from_rfc3339(&set.updated_at) else {
188
- continue; // bad timestamp → leave it
189
- };
190
- if updated.with_timezone(&chrono::Utc) < cutoff && std::fs::remove_file(&path).is_ok() {
191
- removed += 1;
192
- }
193
- }
194
- Ok(removed)
195
- }
196
-
197
- #[cfg(test)]
198
- mod tests {
199
- use super::*;
200
- use tempfile::TempDir;
201
-
202
- fn handoff(tmp: &TempDir) -> PathBuf {
203
- let dir = tmp.path().join(".handoff");
204
- std::fs::create_dir_all(&dir).unwrap();
205
- dir
206
- }
207
-
208
- #[test]
209
- fn read_missing_is_empty() {
210
- let tmp = TempDir::new().unwrap();
211
- let h = handoff(&tmp);
212
- let set = read_injected_set(&h, "sess-1", "2026-06-27T00:00:00Z");
213
- assert_eq!(set.session_id, "sess-1");
214
- assert!(set.injected.is_empty());
215
- }
216
-
217
- #[test]
218
- fn write_then_read_roundtrip() {
219
- let tmp = TempDir::new().unwrap();
220
- let h = handoff(&tmp);
221
- let mut set = InjectedSet::new("sess-1".to_string(), "2026-06-27T00:00:00Z".to_string());
222
- assert!(set.mark("m-1", "hashA"));
223
- write_injected_set(&h, &set).unwrap();
224
-
225
- let back = read_injected_set(&h, "sess-1", "now");
226
- assert_eq!(back.injected.get("m-1").map(String::as_str), Some("hashA"));
227
- }
228
-
229
- #[test]
230
- fn already_injected_same_hash_true_diff_hash_false() {
231
- let mut set = InjectedSet::new("s".to_string(), "now".to_string());
232
- set.mark("m-1", "hashA");
233
- assert!(set.already_injected("m-1", "hashA"));
234
- assert!(!set.already_injected("m-1", "hashB")); // edited → re-inject
235
- assert!(!set.already_injected("m-2", "hashA")); // never injected
236
- }
237
-
238
- #[test]
239
- fn mark_reports_change() {
240
- let mut set = InjectedSet::new("s".to_string(), "now".to_string());
241
- assert!(set.mark("m-1", "h1")); // new id
242
- assert!(!set.mark("m-1", "h1")); // unchanged
243
- assert!(set.mark("m-1", "h2")); // hash changed
244
- }
245
-
246
- #[test]
247
- fn sessions_are_isolated() {
248
- let tmp = TempDir::new().unwrap();
249
- let h = handoff(&tmp);
250
- let mut a = InjectedSet::new("A".to_string(), "now".to_string());
251
- a.mark("m-1", "h");
252
- write_injected_set(&h, &a).unwrap();
253
-
254
- let b = read_injected_set(&h, "B", "now");
255
- assert!(
256
- b.injected.is_empty(),
257
- "session B must not see session A's set"
258
- );
259
- }
260
-
261
- #[test]
262
- fn sanitize_blocks_traversal_and_separators() {
263
- // Path separators are neutralized so the result is always a single flat
264
- // filename component — no traversal possible.
265
- for evil in [
266
- "../../etc/passwd",
267
- "a/b\\c",
268
- "..",
269
- "../",
270
- "foo/../bar",
271
- ".hidden",
272
- "",
273
- "...",
274
- ] {
275
- let s = sanitize_session_id(evil);
276
- assert!(!s.contains('/'), "{evil:?} -> {s:?} still has /");
277
- assert!(!s.contains('\\'), "{evil:?} -> {s:?} still has \\");
278
- assert_ne!(s, "..", "{evil:?} -> {s:?} is parent ref");
279
- assert!(!s.starts_with('.'), "{evil:?} -> {s:?} is hidden");
280
- assert!(!s.is_empty(), "{evil:?} -> empty stem");
281
- }
282
- // The readable prefix is preserved; a hash suffix is always appended.
283
- assert!(sanitize_session_id("3f9a-1b2c-4d5e").starts_with("3f9a-1b2c-4d5e-"));
284
- assert!(sanitize_session_id(".hidden").starts_with("hidden-"));
285
- }
286
-
287
- #[test]
288
- fn sanitize_is_collision_free_for_distinct_ids() {
289
- // Lossy normalization alone would collide these; the raw-id hash suffix
290
- // keeps every distinct id on its own sidecar.
291
- let pairs = [("a/b", "a_b"), ("...", ""), ("a.b", "a_b"), ("X/Y", "X.Y")];
292
- for (x, y) in pairs {
293
- assert_ne!(
294
- sanitize_session_id(x),
295
- sanitize_session_id(y),
296
- "{x:?} and {y:?} must not share a sidecar"
297
- );
298
- }
299
- // Same id is stable (round-trips to the same file).
300
- assert_eq!(sanitize_session_id("sess-A"), sanitize_session_id("sess-A"));
301
- }
302
-
303
- #[test]
304
- fn lenient_read_treats_corrupt_as_empty() {
305
- let tmp = TempDir::new().unwrap();
306
- let h = handoff(&tmp);
307
- let dir = injected_dir(&h);
308
- std::fs::create_dir_all(&dir).unwrap();
309
- std::fs::write(injected_path(&h, "sess-1"), b"{not json").unwrap();
310
-
311
- let set = read_injected_set(&h, "sess-1", "now");
312
- assert!(set.injected.is_empty());
313
- }
314
-
315
- #[test]
316
- fn gc_removes_old_keeps_fresh() {
317
- let tmp = TempDir::new().unwrap();
318
- let h = handoff(&tmp);
319
- let old = InjectedSet::new("old".to_string(), "2026-01-01T00:00:00Z".to_string());
320
- let fresh = InjectedSet::new("fresh".to_string(), "2026-06-27T00:00:00Z".to_string());
321
- write_injected_set(&h, &old).unwrap();
322
- write_injected_set(&h, &fresh).unwrap();
323
-
324
- let now = chrono::DateTime::parse_from_rfc3339("2026-06-28T00:00:00Z")
325
- .unwrap()
326
- .with_timezone(&chrono::Utc);
327
- let removed = gc_injected_sets(&h, 14, now).unwrap();
328
- assert_eq!(removed, 1, "only the January sidecar is past 14 days");
329
- assert!(read_injected_set(&h, "old", "now").injected.is_empty());
330
- assert!(injected_path(&h, "fresh").exists());
331
- }
332
-
333
- #[test]
334
- fn gc_on_missing_dir_is_zero() {
335
- let tmp = TempDir::new().unwrap();
336
- let h = handoff(&tmp);
337
- let now = chrono::Utc::now();
338
- assert_eq!(gc_injected_sets(&h, 14, now).unwrap(), 0);
339
- }
340
- }
@@ -1,236 +0,0 @@
1
- //! Persistence for project memories under `.handoff/memory/`.
2
- //!
3
- //! Layout:
4
- //!
5
- //! ```text
6
- //! .handoff/memory/
7
- //! m-YYYYMMDD-HHMMSS-NNNNNN.json # one memory per file
8
- //! injected/
9
- //! <hook_session_id>.json # per-session "already injected" sidecar (P2)
10
- //! ```
11
- //!
12
- //! All writes go through [`crate::storage::atomic_write`] so the VSCode
13
- //! extension (which reads `.handoff/`) never observes a torn file. Reads are
14
- //! lenient: a single corrupt or unparseable file is skipped, not fatal, so one
15
- //! bad memory can't break the whole feature.
16
-
17
- pub mod injected;
18
- pub mod model;
19
-
20
- use std::path::{Path, PathBuf};
21
-
22
- use anyhow::{Context, Result};
23
-
24
- pub use injected::{
25
- gc_injected_sets, injected_path, read_injected_set, write_injected_set, InjectedSet,
26
- };
27
- pub use model::{is_valid_memory_kind, MemoryEntry, VALID_MEMORY_KINDS};
28
-
29
- /// Path to the `memory/` directory inside a `.handoff/` dir.
30
- pub fn memory_dir(handoff_dir: &Path) -> PathBuf {
31
- handoff_dir.join("memory")
32
- }
33
-
34
- /// Generate a fresh memory id (`m-YYYYMMDD-HHMMSS-NNNNNN`) from the current time.
35
- pub fn new_memory_id() -> String {
36
- let now = chrono::Utc::now();
37
- format!("m-{}", now.format("%Y%m%d-%H%M%S-%6f"))
38
- }
39
-
40
- /// Current time as an RFC3339 string (helper so handlers don't import chrono).
41
- pub fn now_rfc3339() -> String {
42
- chrono::Utc::now().to_rfc3339()
43
- }
44
-
45
- /// Ensure `memory/` exists, creating it lazily. This is what makes the feature
46
- /// backward-compatible with projects initialized before v0.13.0: they never had
47
- /// a `memory/` dir, and the first `memory_save` creates it.
48
- pub fn ensure_memory_dir(handoff_dir: &Path) -> Result<PathBuf> {
49
- let dir = memory_dir(handoff_dir);
50
- std::fs::create_dir_all(&dir)
51
- .with_context(|| format!("Failed to create memory dir: {}", dir.display()))?;
52
- Ok(dir)
53
- }
54
-
55
- /// Write a memory to `memory/<id>.json` atomically.
56
- pub fn write_memory(handoff_dir: &Path, entry: &MemoryEntry) -> Result<PathBuf> {
57
- let dir = ensure_memory_dir(handoff_dir)?;
58
- let file_path = dir.join(format!("{}.json", entry.id));
59
- let content = serde_json::to_string_pretty(entry).context("Failed to serialize memory")?;
60
- crate::storage::atomic_write(&file_path, content.as_bytes())
61
- .with_context(|| format!("Failed to write memory: {}", file_path.display()))?;
62
- Ok(file_path)
63
- }
64
-
65
- /// Read every memory in `memory/`, skipping any file that fails to parse. The
66
- /// `injected/` subdirectory and any non-`.json` files are ignored. Returns an
67
- /// empty vec when the directory does not exist (uninitialized projects).
68
- pub fn read_all_memories(handoff_dir: &Path) -> Result<Vec<MemoryEntry>> {
69
- let dir = memory_dir(handoff_dir);
70
- if !dir.exists() {
71
- return Ok(Vec::new());
72
- }
73
-
74
- let mut entries: Vec<_> = std::fs::read_dir(&dir)
75
- .with_context(|| format!("Failed to read memory dir: {}", dir.display()))?
76
- .filter_map(|e| e.ok())
77
- .collect();
78
- entries.sort_by_key(|e| e.file_name());
79
-
80
- let mut memories = Vec::new();
81
- for entry in entries {
82
- let path = entry.path();
83
- if !path.is_file() {
84
- continue;
85
- }
86
- let name = entry.file_name().to_string_lossy().to_string();
87
- if !name.ends_with(".json") || name.starts_with('.') {
88
- continue;
89
- }
90
- let content = match std::fs::read_to_string(&path) {
91
- Ok(c) => c,
92
- Err(_) => continue,
93
- };
94
- if let Ok(mem) = serde_json::from_str::<MemoryEntry>(&content) {
95
- memories.push(mem);
96
- }
97
- // Unparseable file: skip silently (lenient read).
98
- }
99
- Ok(memories)
100
- }
101
-
102
- /// Read one memory by id. Matches the full id first, then falls back to a unique
103
- /// prefix match (mirrors the referral lookup ergonomics). Returns `Ok(None)`
104
- /// when nothing matches, and errors on an ambiguous prefix.
105
- pub fn read_memory_by_id(handoff_dir: &Path, id: &str) -> Result<Option<MemoryEntry>> {
106
- let memories = read_all_memories(handoff_dir)?;
107
- let mut prefix_matches: Vec<MemoryEntry> = Vec::new();
108
- for mem in memories {
109
- if mem.id == id {
110
- return Ok(Some(mem));
111
- }
112
- if mem.id.starts_with(id) {
113
- prefix_matches.push(mem);
114
- }
115
- }
116
- match prefix_matches.len() {
117
- 0 => Ok(None),
118
- 1 => Ok(prefix_matches.into_iter().next()),
119
- n => {
120
- anyhow::bail!("Ambiguous memory id prefix '{id}' matches {n} memories; use the full id")
121
- }
122
- }
123
- }
124
-
125
- /// Delete a memory file by exact id. Returns `Ok(false)` when the file does not
126
- /// exist.
127
- pub fn delete_memory(handoff_dir: &Path, id: &str) -> Result<bool> {
128
- let path = memory_dir(handoff_dir).join(format!("{id}.json"));
129
- if !path.exists() {
130
- return Ok(false);
131
- }
132
- std::fs::remove_file(&path)
133
- .with_context(|| format!("Failed to delete memory: {}", path.display()))?;
134
- Ok(true)
135
- }
136
-
137
- #[cfg(test)]
138
- mod tests {
139
- use super::*;
140
- use tempfile::TempDir;
141
-
142
- fn handoff(tmp: &TempDir) -> PathBuf {
143
- let dir = tmp.path().join(".handoff");
144
- std::fs::create_dir_all(&dir).unwrap();
145
- dir
146
- }
147
-
148
- fn sample(id: &str, text: &str) -> MemoryEntry {
149
- MemoryEntry::new(
150
- id.to_string(),
151
- text.to_string(),
152
- "lesson".to_string(),
153
- vec![],
154
- vec![],
155
- vec![],
156
- now_rfc3339(),
157
- )
158
- }
159
-
160
- #[test]
161
- fn write_then_read_roundtrip() {
162
- let tmp = TempDir::new().unwrap();
163
- let h = handoff(&tmp);
164
- let mem = sample("m-1", "always use atomic_write");
165
- write_memory(&h, &mem).unwrap();
166
-
167
- let all = read_all_memories(&h).unwrap();
168
- assert_eq!(all.len(), 1);
169
- assert_eq!(all[0].id, "m-1");
170
- assert_eq!(all[0].text, "always use atomic_write");
171
- assert_eq!(all[0].content_hash, mem.content_hash);
172
- }
173
-
174
- #[test]
175
- fn read_missing_dir_is_empty() {
176
- let tmp = TempDir::new().unwrap();
177
- let h = tmp.path().join(".handoff");
178
- std::fs::create_dir_all(&h).unwrap();
179
- assert!(read_all_memories(&h).unwrap().is_empty());
180
- }
181
-
182
- #[test]
183
- fn lenient_read_skips_corrupt_file() {
184
- let tmp = TempDir::new().unwrap();
185
- let h = handoff(&tmp);
186
- write_memory(&h, &sample("m-1", "good")).unwrap();
187
- // Drop a non-JSON file into memory/.
188
- std::fs::write(memory_dir(&h).join("m-bad.json"), b"{not json").unwrap();
189
-
190
- let all = read_all_memories(&h).unwrap();
191
- assert_eq!(all.len(), 1, "corrupt file must be skipped, good one kept");
192
- }
193
-
194
- #[test]
195
- fn read_by_id_exact_and_prefix() {
196
- let tmp = TempDir::new().unwrap();
197
- let h = handoff(&tmp);
198
- write_memory(&h, &sample("m-20260627-aaa", "a")).unwrap();
199
- write_memory(&h, &sample("m-20260627-bbb", "b")).unwrap();
200
-
201
- assert!(read_memory_by_id(&h, "m-20260627-aaa").unwrap().is_some());
202
- // Unique prefix
203
- let p = read_memory_by_id(&h, "m-20260627-a").unwrap();
204
- assert_eq!(p.unwrap().id, "m-20260627-aaa");
205
- }
206
-
207
- #[test]
208
- fn read_by_id_ambiguous_prefix_errors() {
209
- let tmp = TempDir::new().unwrap();
210
- let h = handoff(&tmp);
211
- write_memory(&h, &sample("m-x1", "a")).unwrap();
212
- write_memory(&h, &sample("m-x2", "b")).unwrap();
213
- assert!(read_memory_by_id(&h, "m-x").is_err());
214
- }
215
-
216
- #[test]
217
- fn delete_removes_file() {
218
- let tmp = TempDir::new().unwrap();
219
- let h = handoff(&tmp);
220
- write_memory(&h, &sample("m-1", "a")).unwrap();
221
- assert!(delete_memory(&h, "m-1").unwrap());
222
- assert!(read_all_memories(&h).unwrap().is_empty());
223
- // Second delete reports not-found.
224
- assert!(!delete_memory(&h, "m-1").unwrap());
225
- }
226
-
227
- #[test]
228
- fn lazy_dir_creation() {
229
- let tmp = TempDir::new().unwrap();
230
- let h = tmp.path().join(".handoff");
231
- std::fs::create_dir_all(&h).unwrap();
232
- assert!(!memory_dir(&h).exists());
233
- write_memory(&h, &sample("m-1", "a")).unwrap();
234
- assert!(memory_dir(&h).exists());
235
- }
236
- }