handoff-mcp-server 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +43 -1
- package/Cargo.toml +4 -3
- package/README.md +208 -5
- package/package.json +1 -1
- package/scripts/cargo-env.sh +29 -0
- package/scripts/handoff-memory-hook.py +208 -0
- package/src/lib.rs +1 -0
- package/src/main.rs +47 -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/setup.rs +424 -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
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -15,6 +15,7 @@ pub mod list_sessions;
|
|
|
15
15
|
pub mod list_tasks;
|
|
16
16
|
pub mod load_context;
|
|
17
17
|
pub mod log_time;
|
|
18
|
+
pub mod memory;
|
|
18
19
|
pub mod metrics;
|
|
19
20
|
pub mod milestones;
|
|
20
21
|
pub mod refer;
|
|
@@ -74,6 +75,10 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
74
75
|
"handoff_update_calendar" => calendar::handle_update_calendar(arguments),
|
|
75
76
|
"handoff_update_labels" => calendar::handle_update_labels(arguments),
|
|
76
77
|
"handoff_start_project" => calendar::handle_start_project(arguments),
|
|
78
|
+
"handoff_memory_save" => memory::handle_save(arguments),
|
|
79
|
+
"handoff_memory_query" => memory::handle_query(arguments),
|
|
80
|
+
"handoff_memory_delete" => memory::handle_delete(arguments),
|
|
81
|
+
"handoff_memory_cleanup" => memory::handle_cleanup(arguments),
|
|
77
82
|
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
78
83
|
};
|
|
79
84
|
|
package/src/mcp/tools.rs
CHANGED
|
@@ -1023,6 +1023,65 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1023
1023
|
}
|
|
1024
1024
|
}),
|
|
1025
1025
|
},
|
|
1026
|
+
ToolDefinition {
|
|
1027
|
+
name: "handoff_memory_save".to_string(),
|
|
1028
|
+
description: "Save a long-lived project memory (lesson/rule/convention/gotcha) that future sessions should respect. Detects exact and near-duplicate memories: an exact match is reported (not rewritten), a near-duplicate is returned as a 'conflict' with both bodies for you to merge (call again with merge_into=<id> and absorb_ids=[…]) or save separately with force=true. Returns a JSON string.".to_string(),
|
|
1029
|
+
input_schema: json!({
|
|
1030
|
+
"type": "object",
|
|
1031
|
+
"properties": {
|
|
1032
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1033
|
+
"text": { "type": "string", "description": "The memory body (any language). Required, non-empty." },
|
|
1034
|
+
"kind": { "type": "string", "description": "Memory kind.", "enum": ["lesson", "rule", "convention", "gotcha"], "default": "lesson" },
|
|
1035
|
+
"tags": { "type": "array", "items": { "type": "string" }, "description": "Optional tags; also indexed for similarity." },
|
|
1036
|
+
"scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this memory applies to (e.g. 'src/storage/'). Boosts relevance when a query touches a matching file." },
|
|
1037
|
+
"merge_into": { "type": "string", "description": "Commit an AI merge: overwrite this memory id with `text` and absorb `absorb_ids`." },
|
|
1038
|
+
"absorb_ids": { "type": "array", "items": { "type": "string" }, "description": "Memory ids to delete and record as superseded when merging." },
|
|
1039
|
+
"force": { "type": "boolean", "description": "Save even if a near-duplicate exists (skip the conflict response).", "default": false }
|
|
1040
|
+
},
|
|
1041
|
+
"required": ["text"]
|
|
1042
|
+
}),
|
|
1043
|
+
},
|
|
1044
|
+
ToolDefinition {
|
|
1045
|
+
name: "handoff_memory_query".to_string(),
|
|
1046
|
+
description: "Return the project memories most relevant to the given text/file (BM25 + scope-path boosting). Intended for automatic injection via hooks, but callable directly. Returns a JSON string {\"memories\":[{id,text,kind,score}],\"injected_count\"}.".to_string(),
|
|
1047
|
+
input_schema: json!({
|
|
1048
|
+
"type": "object",
|
|
1049
|
+
"properties": {
|
|
1050
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1051
|
+
"session_id": { "type": "string", "description": "Hook session id. When given, memories already injected this session (same content hash) are filtered out; an edited memory is re-injected." },
|
|
1052
|
+
"text": { "type": "string", "description": "The current prompt or context text to match against." },
|
|
1053
|
+
"tool_name": { "type": "string", "description": "Name of the tool about to run (e.g. 'Edit'); added to the query." },
|
|
1054
|
+
"file_paths": { "type": "array", "items": { "type": "string" }, "description": "File paths in play; basenames are added to the query and scope_paths are matched against these." },
|
|
1055
|
+
"limit": { "type": "integer", "description": "Maximum memories to return.", "default": 5 },
|
|
1056
|
+
"mark_injected": { "type": "boolean", "description": "Record returned memories in the session sidecar and bump their hit_count/last_referenced_at. Requires session_id.", "default": true }
|
|
1057
|
+
},
|
|
1058
|
+
"required": ["text"]
|
|
1059
|
+
}),
|
|
1060
|
+
},
|
|
1061
|
+
ToolDefinition {
|
|
1062
|
+
name: "handoff_memory_delete".to_string(),
|
|
1063
|
+
description: "Delete a project memory by id (full id or unique prefix). Use for AI-driven cleanup of stale memories. Returns a JSON string {\"status\":\"deleted\",\"id\"}.".to_string(),
|
|
1064
|
+
input_schema: json!({
|
|
1065
|
+
"type": "object",
|
|
1066
|
+
"properties": {
|
|
1067
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1068
|
+
"id": { "type": "string", "description": "Memory id to delete (full id or unique prefix)." }
|
|
1069
|
+
},
|
|
1070
|
+
"required": ["id"]
|
|
1071
|
+
}),
|
|
1072
|
+
},
|
|
1073
|
+
ToolDefinition {
|
|
1074
|
+
name: "handoff_memory_cleanup".to_string(),
|
|
1075
|
+
description: "Housekeep the project memory store (intended for SessionStart). Silently merges exact duplicates (lossless), then returns recommendations the AI should act on: near-duplicate clusters (merge with memory_save merge_into=…) and stale memories (consider memory_delete). Also garbage-collects old per-session injection sidecars. Returns a JSON string {\"auto_merged_exact\":n,\"cleanup_recommendations\":{\"similar_clusters\":[…],\"stale\":[…]},\"injected_sidecars_removed\":k}.".to_string(),
|
|
1076
|
+
input_schema: json!({
|
|
1077
|
+
"type": "object",
|
|
1078
|
+
"properties": {
|
|
1079
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1080
|
+
"apply_exact_merges": { "type": "boolean", "description": "Auto-merge exact-duplicate memories (same content hash). Lossless and safe.", "default": true },
|
|
1081
|
+
"stale_days": { "type": "integer", "description": "Flag memories not referenced for this many days as stale recommendations.", "default": 60 }
|
|
1082
|
+
}
|
|
1083
|
+
}),
|
|
1084
|
+
},
|
|
1026
1085
|
]
|
|
1027
1086
|
}
|
|
1028
1087
|
|
package/src/setup.rs
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
use std::collections::BTreeMap;
|
|
2
|
+
use std::path::{Path, PathBuf};
|
|
3
|
+
|
|
4
|
+
use anyhow::{Context, Result};
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
|
|
7
|
+
const HOOK_SERVER: &str = "handoff";
|
|
8
|
+
const HOOK_TOOL_QUERY: &str = "handoff_memory_query";
|
|
9
|
+
const HOOK_TOOL_CLEANUP: &str = "handoff_memory_cleanup";
|
|
10
|
+
|
|
11
|
+
fn settings_path() -> Result<PathBuf> {
|
|
12
|
+
let home = std::env::var("HOME")
|
|
13
|
+
.or_else(|_| std::env::var("USERPROFILE"))
|
|
14
|
+
.context("cannot determine home directory (HOME / USERPROFILE not set)")?;
|
|
15
|
+
Ok(Path::new(&home).join(".claude").join("settings.json"))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
fn read_settings(path: &Path) -> Result<Value> {
|
|
19
|
+
if path.exists() {
|
|
20
|
+
let text = std::fs::read_to_string(path)
|
|
21
|
+
.with_context(|| format!("failed to read {}", path.display()))?;
|
|
22
|
+
serde_json::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
|
|
23
|
+
} else {
|
|
24
|
+
Ok(Value::Object(serde_json::Map::new()))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fn write_settings(path: &Path, value: &Value) -> Result<()> {
|
|
29
|
+
let parent = path
|
|
30
|
+
.parent()
|
|
31
|
+
.context("settings path has no parent directory")?;
|
|
32
|
+
std::fs::create_dir_all(parent)
|
|
33
|
+
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
34
|
+
|
|
35
|
+
let text = serde_json::to_string_pretty(value)?;
|
|
36
|
+
let tmp = parent.join(".settings.json.tmp");
|
|
37
|
+
std::fs::write(&tmp, text + "\n")
|
|
38
|
+
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
|
39
|
+
std::fs::rename(&tmp, path)
|
|
40
|
+
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn mcp_tool_hook(tool: &str, input: Value) -> Value {
|
|
44
|
+
serde_json::json!({
|
|
45
|
+
"type": "mcp_tool",
|
|
46
|
+
"server": HOOK_SERVER,
|
|
47
|
+
"tool": tool,
|
|
48
|
+
"input": input
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn build_hooks_config() -> BTreeMap<&'static str, Value> {
|
|
53
|
+
let mut hooks = BTreeMap::new();
|
|
54
|
+
|
|
55
|
+
hooks.insert(
|
|
56
|
+
"UserPromptSubmit",
|
|
57
|
+
serde_json::json!([{
|
|
58
|
+
"hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
|
|
59
|
+
"project_dir": "${cwd}",
|
|
60
|
+
"session_id": "${session_id}",
|
|
61
|
+
"text": "${prompt}"
|
|
62
|
+
}))]
|
|
63
|
+
}]),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
hooks.insert(
|
|
67
|
+
"PreToolUse",
|
|
68
|
+
serde_json::json!([{
|
|
69
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
70
|
+
"hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
|
|
71
|
+
"project_dir": "${cwd}",
|
|
72
|
+
"session_id": "${session_id}",
|
|
73
|
+
"tool_name": "${tool_name}",
|
|
74
|
+
"text": "${tool_input.file_path}",
|
|
75
|
+
"file_paths": ["${tool_input.file_path}"]
|
|
76
|
+
}))]
|
|
77
|
+
}]),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
hooks.insert(
|
|
81
|
+
"SessionStart",
|
|
82
|
+
serde_json::json!([{
|
|
83
|
+
"hooks": [mcp_tool_hook(HOOK_TOOL_CLEANUP, serde_json::json!({
|
|
84
|
+
"project_dir": "${cwd}"
|
|
85
|
+
}))]
|
|
86
|
+
}]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
hooks
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn has_handoff_hook(arr: &Value) -> bool {
|
|
93
|
+
let Some(entries) = arr.as_array() else {
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
for entry in entries {
|
|
97
|
+
let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
|
|
98
|
+
continue;
|
|
99
|
+
};
|
|
100
|
+
for hook in hooks {
|
|
101
|
+
if hook.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
|
|
110
|
+
anyhow::ensure!(
|
|
111
|
+
!(check_only && uninstall),
|
|
112
|
+
"--check and --uninstall cannot be used together"
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
let path = settings_path()?;
|
|
116
|
+
let mut settings = read_settings(&path)?;
|
|
117
|
+
|
|
118
|
+
if check_only {
|
|
119
|
+
return run_check(&settings, &path);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if uninstall {
|
|
123
|
+
return run_uninstall(&mut settings, &path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
run_install(&mut settings, &path)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fn run_check(settings: &Value, path: &Path) -> Result<()> {
|
|
130
|
+
println!("Settings file: {}", path.display());
|
|
131
|
+
|
|
132
|
+
let hooks_obj = settings.get("hooks");
|
|
133
|
+
let desired = build_hooks_config();
|
|
134
|
+
let mut all_ok = true;
|
|
135
|
+
|
|
136
|
+
for event in desired.keys() {
|
|
137
|
+
let installed = hooks_obj
|
|
138
|
+
.and_then(|h| h.get(*event))
|
|
139
|
+
.map(has_handoff_hook)
|
|
140
|
+
.unwrap_or(false);
|
|
141
|
+
|
|
142
|
+
if installed {
|
|
143
|
+
println!(" {event}: installed");
|
|
144
|
+
} else {
|
|
145
|
+
println!(" {event}: NOT installed");
|
|
146
|
+
all_ok = false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if all_ok {
|
|
151
|
+
println!("\nAll hooks are configured. Memory auto-injection is active.");
|
|
152
|
+
} else {
|
|
153
|
+
println!("\nSome hooks are missing. Run `handoff-mcp setup` to install them.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
Ok(())
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn run_install(settings: &mut Value, path: &Path) -> Result<()> {
|
|
160
|
+
let obj = settings
|
|
161
|
+
.as_object_mut()
|
|
162
|
+
.context("settings.json root is not an object")?;
|
|
163
|
+
|
|
164
|
+
let hooks_val = obj
|
|
165
|
+
.entry("hooks")
|
|
166
|
+
.or_insert_with(|| Value::Object(serde_json::Map::new()));
|
|
167
|
+
let hooks_obj = hooks_val
|
|
168
|
+
.as_object_mut()
|
|
169
|
+
.context("settings.json 'hooks' is not an object")?;
|
|
170
|
+
|
|
171
|
+
let desired = build_hooks_config();
|
|
172
|
+
let mut installed = 0u32;
|
|
173
|
+
let mut skipped = 0u32;
|
|
174
|
+
|
|
175
|
+
for (event, config) in desired {
|
|
176
|
+
let existing = hooks_obj.get(event);
|
|
177
|
+
if existing.map(has_handoff_hook).unwrap_or(false) {
|
|
178
|
+
println!(" {event}: already installed, skipping");
|
|
179
|
+
skipped += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
match existing {
|
|
184
|
+
Some(Value::Array(arr)) => {
|
|
185
|
+
let mut merged = arr.clone();
|
|
186
|
+
if let Some(new_entries) = config.as_array() {
|
|
187
|
+
merged.extend(new_entries.iter().cloned());
|
|
188
|
+
}
|
|
189
|
+
hooks_obj.insert(event.to_string(), Value::Array(merged));
|
|
190
|
+
println!(" {event}: merged with existing hooks");
|
|
191
|
+
}
|
|
192
|
+
_ => {
|
|
193
|
+
hooks_obj.insert(event.to_string(), config);
|
|
194
|
+
println!(" {event}: installed");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
installed += 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if installed > 0 {
|
|
201
|
+
write_settings(path, settings)?;
|
|
202
|
+
println!("\nWrote {path}", path = path.display());
|
|
203
|
+
println!("{installed} hook(s) installed, {skipped} already present.");
|
|
204
|
+
println!("\nRestart Claude Code for hooks to take effect.");
|
|
205
|
+
} else {
|
|
206
|
+
println!("\nAll hooks already installed. Nothing to do.");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
Ok(())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fn run_uninstall(settings: &mut Value, path: &Path) -> Result<()> {
|
|
213
|
+
let Some(hooks_obj) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
|
|
214
|
+
println!("No hooks configured. Nothing to remove.");
|
|
215
|
+
return Ok(());
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let events: Vec<String> = hooks_obj.keys().cloned().collect();
|
|
219
|
+
let mut removed = 0u32;
|
|
220
|
+
|
|
221
|
+
for event in &events {
|
|
222
|
+
let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
|
|
223
|
+
continue;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
let before = arr.len();
|
|
227
|
+
arr.retain(|entry| {
|
|
228
|
+
let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) else {
|
|
229
|
+
return true;
|
|
230
|
+
};
|
|
231
|
+
!hooks
|
|
232
|
+
.iter()
|
|
233
|
+
.any(|h| h.get("server").and_then(|v| v.as_str()) == Some(HOOK_SERVER))
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
let after = arr.len();
|
|
237
|
+
if before != after {
|
|
238
|
+
println!(" {event}: removed handoff hook(s)");
|
|
239
|
+
removed += 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if arr.is_empty() {
|
|
243
|
+
hooks_obj.remove(event);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if hooks_obj.is_empty() {
|
|
248
|
+
if let Some(obj) = settings.as_object_mut() {
|
|
249
|
+
obj.remove("hooks");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if removed > 0 {
|
|
254
|
+
write_settings(path, settings)?;
|
|
255
|
+
println!("\nWrote {path}", path = path.display());
|
|
256
|
+
println!("{removed} hook event(s) cleaned up.");
|
|
257
|
+
println!("\nRestart Claude Code for changes to take effect.");
|
|
258
|
+
} else {
|
|
259
|
+
println!("No handoff hooks found. Nothing to remove.");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
Ok(())
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[cfg(test)]
|
|
266
|
+
mod tests {
|
|
267
|
+
use super::*;
|
|
268
|
+
|
|
269
|
+
#[test]
|
|
270
|
+
fn build_hooks_has_three_events() {
|
|
271
|
+
let hooks = build_hooks_config();
|
|
272
|
+
assert_eq!(hooks.len(), 3);
|
|
273
|
+
assert!(hooks.contains_key("UserPromptSubmit"));
|
|
274
|
+
assert!(hooks.contains_key("PreToolUse"));
|
|
275
|
+
assert!(hooks.contains_key("SessionStart"));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#[test]
|
|
279
|
+
fn has_handoff_hook_detects_presence() {
|
|
280
|
+
let arr = serde_json::json!([{
|
|
281
|
+
"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]
|
|
282
|
+
}]);
|
|
283
|
+
assert!(has_handoff_hook(&arr));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[test]
|
|
287
|
+
fn has_handoff_hook_returns_false_for_other_server() {
|
|
288
|
+
let arr = serde_json::json!([{
|
|
289
|
+
"hooks": [{"type": "mcp_tool", "server": "other", "tool": "other_tool"}]
|
|
290
|
+
}]);
|
|
291
|
+
assert!(!has_handoff_hook(&arr));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#[test]
|
|
295
|
+
fn has_handoff_hook_returns_false_for_empty() {
|
|
296
|
+
assert!(!has_handoff_hook(&serde_json::json!([])));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[test]
|
|
300
|
+
fn install_into_empty_settings() {
|
|
301
|
+
let dir = tempfile::tempdir().unwrap();
|
|
302
|
+
let path = dir.path().join("settings.json");
|
|
303
|
+
|
|
304
|
+
let mut settings = Value::Object(serde_json::Map::new());
|
|
305
|
+
run_install(&mut settings, &path).unwrap();
|
|
306
|
+
|
|
307
|
+
let written: Value =
|
|
308
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
309
|
+
let hooks = written.get("hooks").unwrap().as_object().unwrap();
|
|
310
|
+
assert!(hooks.contains_key("UserPromptSubmit"));
|
|
311
|
+
assert!(hooks.contains_key("PreToolUse"));
|
|
312
|
+
assert!(hooks.contains_key("SessionStart"));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[test]
|
|
316
|
+
fn install_merges_with_existing_hooks() {
|
|
317
|
+
let dir = tempfile::tempdir().unwrap();
|
|
318
|
+
let path = dir.path().join("settings.json");
|
|
319
|
+
|
|
320
|
+
let mut settings = serde_json::json!({
|
|
321
|
+
"hooks": {
|
|
322
|
+
"UserPromptSubmit": [{
|
|
323
|
+
"hooks": [{"type": "command", "command": "my-other-hook"}]
|
|
324
|
+
}]
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
run_install(&mut settings, &path).unwrap();
|
|
329
|
+
|
|
330
|
+
let written: Value =
|
|
331
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
332
|
+
let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
|
|
333
|
+
assert_eq!(user_prompt.len(), 2);
|
|
334
|
+
assert!(has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn install_skips_if_already_present() {
|
|
339
|
+
let dir = tempfile::tempdir().unwrap();
|
|
340
|
+
let path = dir.path().join("settings.json");
|
|
341
|
+
|
|
342
|
+
let mut settings = Value::Object(serde_json::Map::new());
|
|
343
|
+
run_install(&mut settings, &path).unwrap();
|
|
344
|
+
|
|
345
|
+
let mut settings2 = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
346
|
+
run_install(&mut settings2, &path).unwrap();
|
|
347
|
+
|
|
348
|
+
let written: Value =
|
|
349
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
350
|
+
let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
|
|
351
|
+
assert_eq!(user_prompt.len(), 1);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#[test]
|
|
355
|
+
fn uninstall_removes_handoff_hooks() {
|
|
356
|
+
let dir = tempfile::tempdir().unwrap();
|
|
357
|
+
let path = dir.path().join("settings.json");
|
|
358
|
+
|
|
359
|
+
let mut settings = Value::Object(serde_json::Map::new());
|
|
360
|
+
run_install(&mut settings, &path).unwrap();
|
|
361
|
+
|
|
362
|
+
let mut settings2: Value =
|
|
363
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
364
|
+
run_uninstall(&mut settings2, &path).unwrap();
|
|
365
|
+
|
|
366
|
+
let written: Value =
|
|
367
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
368
|
+
assert!(written.get("hooks").is_none());
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#[test]
|
|
372
|
+
fn check_and_uninstall_conflict_is_rejected() {
|
|
373
|
+
assert!(run_setup(true, true).is_err());
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#[test]
|
|
377
|
+
fn install_preserves_key_order() {
|
|
378
|
+
let dir = tempfile::tempdir().unwrap();
|
|
379
|
+
let path = dir.path().join("settings.json");
|
|
380
|
+
|
|
381
|
+
let original = r#"{
|
|
382
|
+
"env": {"A": "1", "B": "2"},
|
|
383
|
+
"model": "opus",
|
|
384
|
+
"permissions": {"defaultMode": "auto"}
|
|
385
|
+
}
|
|
386
|
+
"#;
|
|
387
|
+
std::fs::write(&path, original).unwrap();
|
|
388
|
+
|
|
389
|
+
let mut settings: Value = serde_json::from_str(original).unwrap();
|
|
390
|
+
run_install(&mut settings, &path).unwrap();
|
|
391
|
+
|
|
392
|
+
let written = std::fs::read_to_string(&path).unwrap();
|
|
393
|
+
let env_pos = written.find("\"env\"").unwrap();
|
|
394
|
+
let hooks_pos = written.find("\"hooks\"").unwrap();
|
|
395
|
+
let model_pos = written.find("\"model\"").unwrap();
|
|
396
|
+
assert!(env_pos < model_pos, "env should come before model");
|
|
397
|
+
assert!(
|
|
398
|
+
hooks_pos > env_pos,
|
|
399
|
+
"hooks should be appended after existing keys"
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#[test]
|
|
404
|
+
fn uninstall_preserves_other_hooks() {
|
|
405
|
+
let dir = tempfile::tempdir().unwrap();
|
|
406
|
+
let path = dir.path().join("settings.json");
|
|
407
|
+
|
|
408
|
+
let mut settings = serde_json::json!({
|
|
409
|
+
"hooks": {
|
|
410
|
+
"UserPromptSubmit": [
|
|
411
|
+
{"hooks": [{"type": "command", "command": "my-hook"}]},
|
|
412
|
+
{"hooks": [{"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query"}]}
|
|
413
|
+
]
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
run_uninstall(&mut settings, &path).unwrap();
|
|
417
|
+
|
|
418
|
+
let written: Value =
|
|
419
|
+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
420
|
+
let user_prompt = written["hooks"]["UserPromptSubmit"].as_array().unwrap();
|
|
421
|
+
assert_eq!(user_prompt.len(), 1);
|
|
422
|
+
assert!(!has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
|
|
423
|
+
}
|
|
424
|
+
}
|
package/src/storage/config.rs
CHANGED
|
@@ -58,6 +58,28 @@ pub struct SettingsConfig {
|
|
|
58
58
|
pub ai_estimate_multiplier: f64,
|
|
59
59
|
#[serde(default)]
|
|
60
60
|
pub context_files: Vec<String>,
|
|
61
|
+
/// Master switch for the memory feature (save/query/cleanup). Default true.
|
|
62
|
+
#[serde(default = "default_memory_enabled")]
|
|
63
|
+
pub memory_enabled: bool,
|
|
64
|
+
/// Jaccard threshold above which `memory_save` treats a save as a
|
|
65
|
+
/// near-duplicate `conflict` for the AI to merge. Default 0.72.
|
|
66
|
+
#[serde(default = "default_memory_dup_threshold")]
|
|
67
|
+
pub memory_dup_threshold: f64,
|
|
68
|
+
/// BM25 relevance floor for `memory_query`; scores below are not returned.
|
|
69
|
+
/// Default 0.5.
|
|
70
|
+
#[serde(default = "default_memory_query_min_score")]
|
|
71
|
+
pub memory_query_min_score: f64,
|
|
72
|
+
/// Maximum number of memories `memory_query` returns per call. Default 5.
|
|
73
|
+
#[serde(default = "default_memory_query_limit")]
|
|
74
|
+
pub memory_query_limit: u32,
|
|
75
|
+
/// Days after which an un(re)referenced memory is flagged `stale` by
|
|
76
|
+
/// `memory_cleanup`. Default 60.
|
|
77
|
+
#[serde(default = "default_memory_stale_days")]
|
|
78
|
+
pub memory_stale_days: i64,
|
|
79
|
+
/// Age (days) past which `memory_cleanup` garbage-collects a per-session
|
|
80
|
+
/// `injected/` sidecar. Default 14.
|
|
81
|
+
#[serde(default = "default_memory_injected_gc_days")]
|
|
82
|
+
pub memory_injected_gc_days: i64,
|
|
61
83
|
#[serde(default)]
|
|
62
84
|
pub custom_fields: HashMap<String, toml::Value>,
|
|
63
85
|
}
|
|
@@ -205,6 +227,30 @@ fn default_ai_estimate_multiplier() -> f64 {
|
|
|
205
227
|
0.2
|
|
206
228
|
}
|
|
207
229
|
|
|
230
|
+
fn default_memory_enabled() -> bool {
|
|
231
|
+
true
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fn default_memory_dup_threshold() -> f64 {
|
|
235
|
+
0.72
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn default_memory_query_min_score() -> f64 {
|
|
239
|
+
0.5
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fn default_memory_query_limit() -> u32 {
|
|
243
|
+
5
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
fn default_memory_stale_days() -> i64 {
|
|
247
|
+
60
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn default_memory_injected_gc_days() -> i64 {
|
|
251
|
+
14
|
|
252
|
+
}
|
|
253
|
+
|
|
208
254
|
fn default_scan_dirs() -> Vec<String> {
|
|
209
255
|
vec!["~/pro/".to_string()]
|
|
210
256
|
}
|
|
@@ -218,6 +264,12 @@ impl Default for SettingsConfig {
|
|
|
218
264
|
require_estimate_hours: default_require_estimate_hours(),
|
|
219
265
|
ai_estimate_multiplier: default_ai_estimate_multiplier(),
|
|
220
266
|
context_files: Vec::new(),
|
|
267
|
+
memory_enabled: default_memory_enabled(),
|
|
268
|
+
memory_dup_threshold: default_memory_dup_threshold(),
|
|
269
|
+
memory_query_min_score: default_memory_query_min_score(),
|
|
270
|
+
memory_query_limit: default_memory_query_limit(),
|
|
271
|
+
memory_stale_days: default_memory_stale_days(),
|
|
272
|
+
memory_injected_gc_days: default_memory_injected_gc_days(),
|
|
221
273
|
custom_fields: HashMap::new(),
|
|
222
274
|
}
|
|
223
275
|
}
|