handoff-mcp-server 0.12.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/src/mcp/handlers/config.rs +63 -0
- package/src/mcp/handlers/init.rs +1 -1
- package/src/mcp/handlers/memory.rs +906 -0
- package/src/mcp/handlers/mod.rs +5 -0
- package/src/mcp/tools.rs +59 -0
- package/src/storage/config.rs +52 -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
|
@@ -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)?;
|