handoff-mcp-server 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +42 -1
- package/Cargo.toml +3 -2
- package/README.md +163 -4
- package/package.json +1 -1
- package/scripts/cargo-env.sh +29 -0
- package/scripts/handoff-memory-hook.py +208 -0
- package/skills/handoff/SKILL.md +8 -1
- package/src/mcp/handlers/capacity.rs +21 -6
- package/src/mcp/handlers/config.rs +80 -0
- package/src/mcp/handlers/init.rs +1 -1
- package/src/mcp/handlers/memory.rs +906 -0
- package/src/mcp/handlers/metrics.rs +11 -0
- package/src/mcp/handlers/mod.rs +6 -0
- package/src/mcp/handlers/referrals.rs +20 -1
- package/src/mcp/handlers/update_task.rs +53 -4
- package/src/mcp/tools.rs +77 -0
- package/src/storage/config.rs +69 -0
- package/src/storage/memory/injected.rs +340 -0
- package/src/storage/memory/mod.rs +235 -0
- package/src/storage/memory/model.rs +96 -0
- package/src/storage/mod.rs +2 -0
- package/src/storage/referrals.rs +38 -0
- package/src/storage/tasks.rs +55 -0
|
@@ -0,0 +1,340 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
now_rfc3339(),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[test]
|
|
160
|
+
fn write_then_read_roundtrip() {
|
|
161
|
+
let tmp = TempDir::new().unwrap();
|
|
162
|
+
let h = handoff(&tmp);
|
|
163
|
+
let mem = sample("m-1", "always use atomic_write");
|
|
164
|
+
write_memory(&h, &mem).unwrap();
|
|
165
|
+
|
|
166
|
+
let all = read_all_memories(&h).unwrap();
|
|
167
|
+
assert_eq!(all.len(), 1);
|
|
168
|
+
assert_eq!(all[0].id, "m-1");
|
|
169
|
+
assert_eq!(all[0].text, "always use atomic_write");
|
|
170
|
+
assert_eq!(all[0].content_hash, mem.content_hash);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[test]
|
|
174
|
+
fn read_missing_dir_is_empty() {
|
|
175
|
+
let tmp = TempDir::new().unwrap();
|
|
176
|
+
let h = tmp.path().join(".handoff");
|
|
177
|
+
std::fs::create_dir_all(&h).unwrap();
|
|
178
|
+
assert!(read_all_memories(&h).unwrap().is_empty());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#[test]
|
|
182
|
+
fn lenient_read_skips_corrupt_file() {
|
|
183
|
+
let tmp = TempDir::new().unwrap();
|
|
184
|
+
let h = handoff(&tmp);
|
|
185
|
+
write_memory(&h, &sample("m-1", "good")).unwrap();
|
|
186
|
+
// Drop a non-JSON file into memory/.
|
|
187
|
+
std::fs::write(memory_dir(&h).join("m-bad.json"), b"{not json").unwrap();
|
|
188
|
+
|
|
189
|
+
let all = read_all_memories(&h).unwrap();
|
|
190
|
+
assert_eq!(all.len(), 1, "corrupt file must be skipped, good one kept");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#[test]
|
|
194
|
+
fn read_by_id_exact_and_prefix() {
|
|
195
|
+
let tmp = TempDir::new().unwrap();
|
|
196
|
+
let h = handoff(&tmp);
|
|
197
|
+
write_memory(&h, &sample("m-20260627-aaa", "a")).unwrap();
|
|
198
|
+
write_memory(&h, &sample("m-20260627-bbb", "b")).unwrap();
|
|
199
|
+
|
|
200
|
+
assert!(read_memory_by_id(&h, "m-20260627-aaa").unwrap().is_some());
|
|
201
|
+
// Unique prefix
|
|
202
|
+
let p = read_memory_by_id(&h, "m-20260627-a").unwrap();
|
|
203
|
+
assert_eq!(p.unwrap().id, "m-20260627-aaa");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[test]
|
|
207
|
+
fn read_by_id_ambiguous_prefix_errors() {
|
|
208
|
+
let tmp = TempDir::new().unwrap();
|
|
209
|
+
let h = handoff(&tmp);
|
|
210
|
+
write_memory(&h, &sample("m-x1", "a")).unwrap();
|
|
211
|
+
write_memory(&h, &sample("m-x2", "b")).unwrap();
|
|
212
|
+
assert!(read_memory_by_id(&h, "m-x").is_err());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#[test]
|
|
216
|
+
fn delete_removes_file() {
|
|
217
|
+
let tmp = TempDir::new().unwrap();
|
|
218
|
+
let h = handoff(&tmp);
|
|
219
|
+
write_memory(&h, &sample("m-1", "a")).unwrap();
|
|
220
|
+
assert!(delete_memory(&h, "m-1").unwrap());
|
|
221
|
+
assert!(read_all_memories(&h).unwrap().is_empty());
|
|
222
|
+
// Second delete reports not-found.
|
|
223
|
+
assert!(!delete_memory(&h, "m-1").unwrap());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#[test]
|
|
227
|
+
fn lazy_dir_creation() {
|
|
228
|
+
let tmp = TempDir::new().unwrap();
|
|
229
|
+
let h = tmp.path().join(".handoff");
|
|
230
|
+
std::fs::create_dir_all(&h).unwrap();
|
|
231
|
+
assert!(!memory_dir(&h).exists());
|
|
232
|
+
write_memory(&h, &sample("m-1", "a")).unwrap();
|
|
233
|
+
assert!(memory_dir(&h).exists());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
|
|
3
|
+
/// Current memory schema version. Bump when `MemoryEntry` changes shape in a way
|
|
4
|
+
/// that needs migration handling on read.
|
|
5
|
+
pub const MEMORY_SCHEMA_VERSION: u32 = 1;
|
|
6
|
+
|
|
7
|
+
/// Valid `kind` values for a memory. Free-form text is rejected so the field
|
|
8
|
+
/// stays a small, queryable enumeration.
|
|
9
|
+
pub const VALID_MEMORY_KINDS: &[&str] = &["lesson", "rule", "convention", "gotcha"];
|
|
10
|
+
|
|
11
|
+
/// Returns true if `kind` is one of the accepted memory kinds.
|
|
12
|
+
pub fn is_valid_memory_kind(kind: &str) -> bool {
|
|
13
|
+
VALID_MEMORY_KINDS.contains(&kind)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// A single persisted memory: a long-lived, cross-session piece of project
|
|
17
|
+
/// knowledge ("lesson / rule / convention / gotcha"). One file per memory under
|
|
18
|
+
/// `.handoff/memory/`.
|
|
19
|
+
///
|
|
20
|
+
/// Token sets are intentionally **not** stored — they are recomputed from `text`
|
|
21
|
+
/// on every read so the index can never drift from the body.
|
|
22
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
23
|
+
pub struct MemoryEntry {
|
|
24
|
+
/// Schema version (= [`MEMORY_SCHEMA_VERSION`]).
|
|
25
|
+
pub version: u32,
|
|
26
|
+
/// Stable id: `m-YYYYMMDD-HHMMSS-NNNNNN`.
|
|
27
|
+
pub id: String,
|
|
28
|
+
/// The memory body (multilingual).
|
|
29
|
+
pub text: String,
|
|
30
|
+
/// One of [`VALID_MEMORY_KINDS`].
|
|
31
|
+
pub kind: String,
|
|
32
|
+
/// Free-form tags; also fed into the similarity index.
|
|
33
|
+
#[serde(default)]
|
|
34
|
+
pub tags: Vec<String>,
|
|
35
|
+
/// Path prefixes this memory applies to (e.g. `src/storage/`). A query whose
|
|
36
|
+
/// file paths start with one of these gets a relevance boost.
|
|
37
|
+
#[serde(default)]
|
|
38
|
+
pub scope_paths: Vec<String>,
|
|
39
|
+
/// FNV-1a hash of the canonical (tokenized) text. Drives exact-duplicate
|
|
40
|
+
/// detection and per-session re-injection tracking.
|
|
41
|
+
pub content_hash: String,
|
|
42
|
+
/// RFC3339 creation timestamp.
|
|
43
|
+
pub created_at: String,
|
|
44
|
+
/// RFC3339 last-update timestamp.
|
|
45
|
+
pub updated_at: String,
|
|
46
|
+
/// RFC3339 timestamp of the last time this memory was injected into a
|
|
47
|
+
/// session, if ever.
|
|
48
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
49
|
+
pub last_referenced_at: Option<String>,
|
|
50
|
+
/// Number of times this memory has been injected.
|
|
51
|
+
#[serde(default)]
|
|
52
|
+
pub hit_count: u64,
|
|
53
|
+
/// Ids of memories merged into this one (audit trail for AI-driven merges).
|
|
54
|
+
#[serde(default)]
|
|
55
|
+
pub superseded_ids: Vec<String>,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl MemoryEntry {
|
|
59
|
+
/// Build a new entry with timestamps and content hash filled in. `now` is an
|
|
60
|
+
/// RFC3339 timestamp supplied by the caller (keeps this module clock-free
|
|
61
|
+
/// and testable).
|
|
62
|
+
pub fn new(
|
|
63
|
+
id: String,
|
|
64
|
+
text: String,
|
|
65
|
+
kind: String,
|
|
66
|
+
tags: Vec<String>,
|
|
67
|
+
scope_paths: Vec<String>,
|
|
68
|
+
now: String,
|
|
69
|
+
) -> Self {
|
|
70
|
+
let content_hash = lexsim::content_hash(&text);
|
|
71
|
+
MemoryEntry {
|
|
72
|
+
version: MEMORY_SCHEMA_VERSION,
|
|
73
|
+
id,
|
|
74
|
+
text,
|
|
75
|
+
kind,
|
|
76
|
+
tags,
|
|
77
|
+
scope_paths,
|
|
78
|
+
content_hash,
|
|
79
|
+
created_at: now.clone(),
|
|
80
|
+
updated_at: now,
|
|
81
|
+
last_referenced_at: None,
|
|
82
|
+
hit_count: 0,
|
|
83
|
+
superseded_ids: Vec::new(),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// The text used for similarity: body plus tags (tags carry intent that may
|
|
88
|
+
/// not appear verbatim in the body).
|
|
89
|
+
pub fn index_text(&self) -> String {
|
|
90
|
+
if self.tags.is_empty() {
|
|
91
|
+
self.text.clone()
|
|
92
|
+
} else {
|
|
93
|
+
format!("{} {}", self.text, self.tags.join(" "))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/storage/mod.rs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
pub mod config;
|
|
2
2
|
pub mod git;
|
|
3
|
+
pub mod memory;
|
|
3
4
|
pub mod referrals;
|
|
4
5
|
pub mod sessions;
|
|
5
6
|
pub mod tasks;
|
|
@@ -85,6 +86,7 @@ pub fn init_handoff(project_dir: &Path, project_name: &str, description: &str) -
|
|
|
85
86
|
|
|
86
87
|
std::fs::create_dir_all(dir.join("sessions")).context("Failed to create .handoff/sessions/")?;
|
|
87
88
|
std::fs::create_dir_all(dir.join("tasks")).context("Failed to create .handoff/tasks/")?;
|
|
89
|
+
std::fs::create_dir_all(dir.join("memory")).context("Failed to create .handoff/memory/")?;
|
|
88
90
|
|
|
89
91
|
let config = config::Config::new(project_name, description);
|
|
90
92
|
config::write_config(&dir.join("config.toml"), &config)?;
|