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.
@@ -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
+ }
@@ -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
  }