handoff-mcp-server 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
|
@@ -1,961 +0,0 @@
|
|
|
1
|
-
//! MCP handlers for the memory feature (save / query / delete).
|
|
2
|
-
//!
|
|
3
|
-
//! All three return a **parseable JSON string** as their content text, so both
|
|
4
|
-
//! the wrapper path and the Claude Code `mcp_tool` hook path can consume them
|
|
5
|
-
//! with the same JSON parse. `memory_query` supports per-session diff injection
|
|
6
|
-
//! via the `injected/` sidecar (P2); `memory_cleanup` lands in P3.
|
|
7
|
-
|
|
8
|
-
use anyhow::Result;
|
|
9
|
-
use serde_json::{json, Value};
|
|
10
|
-
|
|
11
|
-
use std::path::Path;
|
|
12
|
-
|
|
13
|
-
use super::resolve_project_dir;
|
|
14
|
-
use crate::context::injection::{filter_already_injected, rank_by_bm25_and_scope, RankConfig};
|
|
15
|
-
use crate::storage::config::{read_config, SettingsConfig};
|
|
16
|
-
use crate::storage::ensure_handoff_exists;
|
|
17
|
-
use crate::storage::memory::{
|
|
18
|
-
delete_memory, gc_injected_sets, is_valid_memory_kind, new_memory_id, now_rfc3339,
|
|
19
|
-
read_all_memories, read_injected_set, read_memory_by_id, write_injected_set, write_memory,
|
|
20
|
-
MemoryEntry, VALID_MEMORY_KINDS,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/// Bonus added to a memory's BM25 score when one of its `scope_paths` is a
|
|
24
|
-
/// prefix of one of the query's `file_paths`. Ensures file-specific rules are
|
|
25
|
-
/// reliably surfaced even when the prompt text barely mentions them. Kept a
|
|
26
|
-
/// constant (not exposed in config) — it is an internal ranking weight, not a
|
|
27
|
-
/// user-facing threshold.
|
|
28
|
-
const SCOPE_PATH_BONUS: f64 = 2.0;
|
|
29
|
-
|
|
30
|
-
/// Load the memory tuning settings from the project's `config.toml`.
|
|
31
|
-
///
|
|
32
|
-
/// Missing `memory_*` keys (a pre-0.13.0 config) are filled by serde defaults at
|
|
33
|
-
/// parse time, so a legacy config still parses cleanly and yields the spec
|
|
34
|
-
/// defaults. Only a *genuinely corrupt* config.toml fails the parse — that is a
|
|
35
|
-
/// real error and is propagated, not silently swallowed. If the file is absent
|
|
36
|
-
/// altogether we fall back to defaults (the same as every other key's default).
|
|
37
|
-
fn memory_settings(handoff: &Path) -> Result<SettingsConfig> {
|
|
38
|
-
let path = handoff.join("config.toml");
|
|
39
|
-
if !path.exists() {
|
|
40
|
-
return Ok(SettingsConfig::default());
|
|
41
|
-
}
|
|
42
|
-
Ok(read_config(&path)?.settings)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/// The JSON payload every memory tool returns when `settings.memory_enabled` is
|
|
46
|
-
/// false: a benign no-op the hook paths can parse, never an error (a disabled
|
|
47
|
-
/// feature must not make automatic UserPromptSubmit/SessionStart hooks noisy).
|
|
48
|
-
/// Carries `disabled: true` plus empty equivalents of each tool's normal shape
|
|
49
|
-
/// so a caller that reads `memories` / `auto_merged_exact` still sees a valid
|
|
50
|
-
/// structure.
|
|
51
|
-
fn disabled_payload() -> String {
|
|
52
|
-
to_json(&json!({
|
|
53
|
-
"disabled": true,
|
|
54
|
-
"memories": [],
|
|
55
|
-
"injected_count": 0,
|
|
56
|
-
"auto_merged_exact": 0,
|
|
57
|
-
"cleanup_recommendations": { "similar_clusters": [], "stale": [] },
|
|
58
|
-
"injected_sidecars_removed": 0,
|
|
59
|
-
}))
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/// `memory_save` — persist a memory, with AI-driven dedup.
|
|
63
|
-
///
|
|
64
|
-
/// Resolution order (see spec C):
|
|
65
|
-
/// 1. `merge_into` → commit an AI merge (overwrite target, absorb others).
|
|
66
|
-
/// 2. exact content-hash match → `duplicate_exact` (no write).
|
|
67
|
-
/// 3. near-duplicate (Jaccard ≥ threshold) and not `force` → `conflict` (no
|
|
68
|
-
/// write; returns both bodies for the AI to merge).
|
|
69
|
-
/// 4. otherwise → new `saved`.
|
|
70
|
-
pub fn handle_save(arguments: &Value) -> Result<String> {
|
|
71
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
72
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
73
|
-
let settings = memory_settings(&handoff)?;
|
|
74
|
-
if !settings.memory_enabled {
|
|
75
|
-
return Ok(disabled_payload());
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let text = arguments
|
|
79
|
-
.get("text")
|
|
80
|
-
.and_then(|v| v.as_str())
|
|
81
|
-
.map(str::trim)
|
|
82
|
-
.filter(|s| !s.is_empty())
|
|
83
|
-
.ok_or_else(|| anyhow::anyhow!("'text' is required and must be non-empty"))?
|
|
84
|
-
.to_string();
|
|
85
|
-
|
|
86
|
-
let kind = arguments
|
|
87
|
-
.get("kind")
|
|
88
|
-
.and_then(|v| v.as_str())
|
|
89
|
-
.unwrap_or("lesson")
|
|
90
|
-
.to_string();
|
|
91
|
-
if !is_valid_memory_kind(&kind) {
|
|
92
|
-
anyhow::bail!(
|
|
93
|
-
"Invalid kind '{kind}'. Must be one of: {}",
|
|
94
|
-
VALID_MEMORY_KINDS.join(", ")
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
let tags = string_array(arguments, "tags");
|
|
99
|
-
let keywords = string_array(arguments, "keywords");
|
|
100
|
-
let scope_paths = string_array(arguments, "scope_paths");
|
|
101
|
-
let force = arguments
|
|
102
|
-
.get("force")
|
|
103
|
-
.and_then(|v| v.as_bool())
|
|
104
|
-
.unwrap_or(false);
|
|
105
|
-
|
|
106
|
-
// (1) Explicit merge commit.
|
|
107
|
-
if let Some(merge_into) = arguments.get("merge_into").and_then(|v| v.as_str()) {
|
|
108
|
-
let absorb_ids = string_array(arguments, "absorb_ids");
|
|
109
|
-
return commit_merge(
|
|
110
|
-
&handoff,
|
|
111
|
-
merge_into,
|
|
112
|
-
&absorb_ids,
|
|
113
|
-
text,
|
|
114
|
-
kind,
|
|
115
|
-
tags,
|
|
116
|
-
keywords,
|
|
117
|
-
scope_paths,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
let existing = read_all_memories(&handoff)?;
|
|
122
|
-
let new_hash = lexsim::content_hash(&text);
|
|
123
|
-
|
|
124
|
-
// (2) Exact duplicate (same canonical content).
|
|
125
|
-
if let Some(dup) = existing.iter().find(|m| m.content_hash == new_hash) {
|
|
126
|
-
return Ok(to_json(&json!({
|
|
127
|
-
"status": "duplicate_exact",
|
|
128
|
-
"existing_id": dup.id,
|
|
129
|
-
})));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// (3) Near-duplicate: hand both bodies back for AI-driven merge.
|
|
133
|
-
if !force {
|
|
134
|
-
let dup_threshold = settings.memory_dup_threshold;
|
|
135
|
-
// Build the new memory's index text (body + tags + keywords),
|
|
136
|
-
// matching MemoryEntry::index_text() for symmetric Jaccard comparison.
|
|
137
|
-
let mut new_index = text.clone();
|
|
138
|
-
if !tags.is_empty() {
|
|
139
|
-
new_index.push(' ');
|
|
140
|
-
new_index.push_str(&tags.join(" "));
|
|
141
|
-
}
|
|
142
|
-
if !keywords.is_empty() {
|
|
143
|
-
new_index.push(' ');
|
|
144
|
-
new_index.push_str(&keywords.join(" "));
|
|
145
|
-
}
|
|
146
|
-
let new_set = lexsim::token_set(&new_index);
|
|
147
|
-
let mut similar: Vec<Value> = Vec::new();
|
|
148
|
-
for m in &existing {
|
|
149
|
-
let score = lexsim::jaccard_sets(&new_set, &lexsim::token_set(&m.index_text()));
|
|
150
|
-
if score >= dup_threshold {
|
|
151
|
-
similar.push(json!({
|
|
152
|
-
"id": m.id,
|
|
153
|
-
"text": m.text,
|
|
154
|
-
"kind": m.kind,
|
|
155
|
-
"score": round2(score),
|
|
156
|
-
}));
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if !similar.is_empty() {
|
|
160
|
-
similar.sort_by(|a, b| {
|
|
161
|
-
b["score"]
|
|
162
|
-
.as_f64()
|
|
163
|
-
.partial_cmp(&a["score"].as_f64())
|
|
164
|
-
.unwrap_or(std::cmp::Ordering::Equal)
|
|
165
|
-
});
|
|
166
|
-
return Ok(to_json(&json!({
|
|
167
|
-
"status": "conflict",
|
|
168
|
-
"new": { "text": text, "kind": kind },
|
|
169
|
-
"similar": similar,
|
|
170
|
-
"instruction": "These are near-duplicates. Merge them and call memory_save again \
|
|
171
|
-
with merge_into=<id> and absorb_ids=[other ids], or pass force=true to save \
|
|
172
|
-
separately.",
|
|
173
|
-
})));
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// (4) New memory.
|
|
178
|
-
let id = new_memory_id();
|
|
179
|
-
let entry = MemoryEntry::new(
|
|
180
|
-
id.clone(),
|
|
181
|
-
text,
|
|
182
|
-
kind,
|
|
183
|
-
tags,
|
|
184
|
-
keywords,
|
|
185
|
-
scope_paths,
|
|
186
|
-
now_rfc3339(),
|
|
187
|
-
);
|
|
188
|
-
write_memory(&handoff, &entry)?;
|
|
189
|
-
Ok(to_json(&json!({ "status": "saved", "id": id })))
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/// Commit an AI-driven merge: overwrite `merge_into` with the merged text and
|
|
193
|
-
/// delete the absorbed memories, recording them in `superseded_ids`.
|
|
194
|
-
#[allow(clippy::too_many_arguments)]
|
|
195
|
-
fn commit_merge(
|
|
196
|
-
handoff: &std::path::Path,
|
|
197
|
-
merge_into: &str,
|
|
198
|
-
absorb_ids: &[String],
|
|
199
|
-
text: String,
|
|
200
|
-
kind: String,
|
|
201
|
-
tags: Vec<String>,
|
|
202
|
-
keywords: Vec<String>,
|
|
203
|
-
scope_paths: Vec<String>,
|
|
204
|
-
) -> Result<String> {
|
|
205
|
-
let mut target = read_memory_by_id(handoff, merge_into)?
|
|
206
|
-
.ok_or_else(|| anyhow::anyhow!("merge_into target not found: {merge_into}"))?;
|
|
207
|
-
|
|
208
|
-
let now = now_rfc3339();
|
|
209
|
-
target.text = text;
|
|
210
|
-
target.kind = kind;
|
|
211
|
-
if !tags.is_empty() {
|
|
212
|
-
target.tags = tags;
|
|
213
|
-
}
|
|
214
|
-
if !keywords.is_empty() {
|
|
215
|
-
target.keywords = keywords;
|
|
216
|
-
}
|
|
217
|
-
if !scope_paths.is_empty() {
|
|
218
|
-
target.scope_paths = scope_paths;
|
|
219
|
-
}
|
|
220
|
-
target.content_hash = lexsim::content_hash(&target.text);
|
|
221
|
-
target.updated_at = now;
|
|
222
|
-
|
|
223
|
-
let target_id = target.id.clone();
|
|
224
|
-
let mut absorbed: Vec<String> = Vec::new();
|
|
225
|
-
for raw in absorb_ids {
|
|
226
|
-
if raw == &target_id {
|
|
227
|
-
continue; // never absorb the target into itself
|
|
228
|
-
}
|
|
229
|
-
if let Some(m) = read_memory_by_id(handoff, raw)? {
|
|
230
|
-
// Fold the absorbed memory's metadata into the target before
|
|
231
|
-
// deleting, so keywords/tags/scope_paths are not lost.
|
|
232
|
-
for kw in &m.keywords {
|
|
233
|
-
if !target.keywords.contains(kw) {
|
|
234
|
-
target.keywords.push(kw.clone());
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
for t in &m.tags {
|
|
238
|
-
if !target.tags.contains(t) {
|
|
239
|
-
target.tags.push(t.clone());
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
for s in &m.scope_paths {
|
|
243
|
-
if !target.scope_paths.contains(s) {
|
|
244
|
-
target.scope_paths.push(s.clone());
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
if delete_memory(handoff, &m.id)? {
|
|
248
|
-
absorbed.push(m.id);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
for a in &absorbed {
|
|
253
|
-
if !target.superseded_ids.contains(a) {
|
|
254
|
-
target.superseded_ids.push(a.clone());
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
write_memory(handoff, &target)?;
|
|
259
|
-
Ok(to_json(&json!({
|
|
260
|
-
"status": "merged",
|
|
261
|
-
"id": target_id,
|
|
262
|
-
"absorbed_ids": absorbed,
|
|
263
|
-
})))
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/// `memory_query` — return memories relevant to the current prompt/file.
|
|
267
|
-
///
|
|
268
|
-
/// BM25 relevance + scope-path boosting, then **per-session diff injection**
|
|
269
|
-
/// (spec D): when `session_id` is given, memories already injected this session
|
|
270
|
-
/// with the same `content_hash` are filtered out, while an edited memory (new
|
|
271
|
-
/// hash) is re-injected. With `mark_injected` (default true) the survivors are
|
|
272
|
-
/// recorded in the session sidecar and their `hit_count` / `last_referenced_at`
|
|
273
|
-
/// are bumped. Without `session_id` this degrades to plain relevance ranking.
|
|
274
|
-
pub fn handle_query(arguments: &Value) -> Result<String> {
|
|
275
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
276
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
277
|
-
|
|
278
|
-
let text = arguments
|
|
279
|
-
.get("text")
|
|
280
|
-
.and_then(|v| v.as_str())
|
|
281
|
-
.unwrap_or("")
|
|
282
|
-
.to_string();
|
|
283
|
-
let settings = memory_settings(&handoff)?;
|
|
284
|
-
if !settings.memory_enabled {
|
|
285
|
-
return Ok(disabled_payload());
|
|
286
|
-
}
|
|
287
|
-
let tool_name = arguments.get("tool_name").and_then(|v| v.as_str());
|
|
288
|
-
let file_paths = string_array(arguments, "file_paths");
|
|
289
|
-
let limit = arguments
|
|
290
|
-
.get("limit")
|
|
291
|
-
.and_then(|v| v.as_u64())
|
|
292
|
-
.map(|n| n as usize)
|
|
293
|
-
.filter(|n| *n > 0)
|
|
294
|
-
.unwrap_or(settings.memory_query_limit as usize);
|
|
295
|
-
let session_id = arguments
|
|
296
|
-
.get("session_id")
|
|
297
|
-
.and_then(|v| v.as_str())
|
|
298
|
-
.map(str::trim)
|
|
299
|
-
.filter(|s| !s.is_empty());
|
|
300
|
-
let mark_injected = arguments
|
|
301
|
-
.get("mark_injected")
|
|
302
|
-
.and_then(|v| v.as_bool())
|
|
303
|
-
.unwrap_or(true);
|
|
304
|
-
|
|
305
|
-
let memories = read_all_memories(&handoff)?;
|
|
306
|
-
if memories.is_empty() {
|
|
307
|
-
return Ok(to_json(&json!({ "memories": [], "injected_count": 0 })));
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Build the weighted BM25 corpus from per-memory weighted tokens.
|
|
311
|
-
// Keywords get doc-side TF boost (weight 2.0) via build_weighted_tokens;
|
|
312
|
-
// stopwords and CL-CnG trigrams from stopword-only context are excluded.
|
|
313
|
-
let doc_tokens: Vec<Vec<lexsim::WeightedToken>> =
|
|
314
|
-
memories.iter().map(|m| m.index_weighted_tokens()).collect();
|
|
315
|
-
let corpus = lexsim::Corpus::build_weighted_tokens(&doc_tokens);
|
|
316
|
-
|
|
317
|
-
// Query = prompt text + tool name + file basenames (so a PreToolUse hook
|
|
318
|
-
// that only passes a file path still matches name-related memories).
|
|
319
|
-
let mut query_tokens = lexsim::tokenize_weighted(&text);
|
|
320
|
-
if let Some(tn) = tool_name {
|
|
321
|
-
query_tokens.extend(lexsim::tokenize_weighted(tn));
|
|
322
|
-
}
|
|
323
|
-
for p in &file_paths {
|
|
324
|
-
query_tokens.extend(lexsim::tokenize_weighted(&basename(p)));
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Score + scope-path bonus, then threshold and rank (shared with doc_query).
|
|
328
|
-
let scope_paths: Vec<Vec<String>> = memories.iter().map(|m| m.scope_paths.clone()).collect();
|
|
329
|
-
let rank_config = RankConfig {
|
|
330
|
-
min_score: settings.memory_query_min_score,
|
|
331
|
-
relative_threshold: settings.memory_query_relative_threshold,
|
|
332
|
-
scope_path_bonus: SCOPE_PATH_BONUS,
|
|
333
|
-
// Rank without truncating yet — the session diff below needs the full
|
|
334
|
-
// ranked order so it can backfill past already-injected memories.
|
|
335
|
-
limit: memories.len(),
|
|
336
|
-
};
|
|
337
|
-
let ranked = rank_by_bm25_and_scope(
|
|
338
|
-
&corpus,
|
|
339
|
-
&query_tokens,
|
|
340
|
-
&scope_paths,
|
|
341
|
-
&file_paths,
|
|
342
|
-
&rank_config,
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
// Per-session diff: drop memories already injected this session at the same
|
|
346
|
-
// hash. The `limit` is applied to the *fresh* set so the caller still gets up
|
|
347
|
-
// to `limit` new memories even when earlier prompts already consumed some.
|
|
348
|
-
let now = now_rfc3339();
|
|
349
|
-
let injected_set = session_id.map(|sid| read_injected_set(&handoff, sid, &now));
|
|
350
|
-
let already_injected = |i: usize| match &injected_set {
|
|
351
|
-
Some(set) => {
|
|
352
|
-
let m = &memories[i];
|
|
353
|
-
set.already_injected(&m.id, &m.content_hash)
|
|
354
|
-
}
|
|
355
|
-
None => false,
|
|
356
|
-
};
|
|
357
|
-
let fresh = filter_already_injected(ranked, already_injected, limit);
|
|
358
|
-
|
|
359
|
-
let out: Vec<Value> = fresh
|
|
360
|
-
.iter()
|
|
361
|
-
.map(|item| {
|
|
362
|
-
let m = &memories[item.index];
|
|
363
|
-
let mut entry = json!({
|
|
364
|
-
"id": m.id,
|
|
365
|
-
"text": m.text,
|
|
366
|
-
"kind": m.kind,
|
|
367
|
-
"score": round2(item.score),
|
|
368
|
-
});
|
|
369
|
-
if !m.keywords.is_empty() {
|
|
370
|
-
entry["keywords"] = json!(m.keywords);
|
|
371
|
-
}
|
|
372
|
-
entry
|
|
373
|
-
})
|
|
374
|
-
.collect();
|
|
375
|
-
|
|
376
|
-
// Warn about returned memories that have no keywords — these get weaker
|
|
377
|
-
// BM25 signal and would benefit from `memory_save` with explicit keywords.
|
|
378
|
-
let missing_kw: Vec<&str> = fresh
|
|
379
|
-
.iter()
|
|
380
|
-
.filter(|item| memories[item.index].keywords.is_empty())
|
|
381
|
-
.map(|item| memories[item.index].id.as_str())
|
|
382
|
-
.collect();
|
|
383
|
-
|
|
384
|
-
// Bookkeeping: record survivors in the sidecar and bump usage stats. Only
|
|
385
|
-
// when we have a session id and marking is enabled (the hook's normal path).
|
|
386
|
-
if mark_injected && !fresh.is_empty() {
|
|
387
|
-
if let Some(sid) = session_id {
|
|
388
|
-
mark_injected_memories(&handoff, sid, &memories, &fresh, &now)?;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
let mut result = json!({
|
|
393
|
-
"memories": out,
|
|
394
|
-
"injected_count": out.len(),
|
|
395
|
-
});
|
|
396
|
-
if !missing_kw.is_empty() {
|
|
397
|
-
result["warnings"] = json!([
|
|
398
|
-
format!(
|
|
399
|
-
"{} injected memories have no keywords — add keywords via memory_save(merge_into=<id>) to improve match precision: {}",
|
|
400
|
-
missing_kw.len(),
|
|
401
|
-
missing_kw.join(", ")
|
|
402
|
-
)
|
|
403
|
-
]);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
Ok(to_json(&result))
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/// Append the freshly-injected memories to the session sidecar and bump each
|
|
410
|
-
/// one's `hit_count` / `last_referenced_at`.
|
|
411
|
-
///
|
|
412
|
-
/// Ordering matters: the **sidecar is written first**, before the per-memory
|
|
413
|
-
/// stat bumps. The sidecar is what suppresses re-injection, so it must be
|
|
414
|
-
/// recorded even if a later stat write fails — otherwise a failed bump would
|
|
415
|
-
/// skip the sidecar and re-spam the session on the next query (and double-count
|
|
416
|
-
/// any memory whose stats were already persisted before the failure). The stat
|
|
417
|
-
/// bumps are therefore strictly best-effort: a failure on one memory must not
|
|
418
|
-
/// drop the sidecar record or abort the rest.
|
|
419
|
-
fn mark_injected_memories(
|
|
420
|
-
handoff: &std::path::Path,
|
|
421
|
-
session_id: &str,
|
|
422
|
-
memories: &[MemoryEntry],
|
|
423
|
-
fresh: &[crate::context::injection::RankItem],
|
|
424
|
-
now: &str,
|
|
425
|
-
) -> Result<()> {
|
|
426
|
-
let mut set = read_injected_set(handoff, session_id, now);
|
|
427
|
-
set.updated_at = now.to_string();
|
|
428
|
-
for item in fresh {
|
|
429
|
-
let m = &memories[item.index];
|
|
430
|
-
set.mark(&m.id, &m.content_hash);
|
|
431
|
-
}
|
|
432
|
-
// (1) Persist the suppression record first — this is the correctness-critical
|
|
433
|
-
// write. If it fails, surface the error (the session state is now unknown).
|
|
434
|
-
write_injected_set(handoff, &set)?;
|
|
435
|
-
|
|
436
|
-
// (2) Best-effort usage stats. Re-read each memory so we don't clobber a
|
|
437
|
-
// concurrent edit's other fields. A failure here is non-fatal: the sidecar
|
|
438
|
-
// already recorded the injection, so the worst case is a slightly stale
|
|
439
|
-
// hit_count — never a re-spam or a double count.
|
|
440
|
-
for item in fresh {
|
|
441
|
-
let m = &memories[item.index];
|
|
442
|
-
if let Ok(Some(mut entry)) = read_memory_by_id(handoff, &m.id) {
|
|
443
|
-
entry.hit_count = entry.hit_count.saturating_add(1);
|
|
444
|
-
entry.last_referenced_at = Some(now.to_string());
|
|
445
|
-
let _ = write_memory(handoff, &entry);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
Ok(())
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/// `memory_delete` — remove a memory by id (AI-driven stale cleanup / tests).
|
|
452
|
-
pub fn handle_delete(arguments: &Value) -> Result<String> {
|
|
453
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
454
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
455
|
-
if !memory_settings(&handoff)?.memory_enabled {
|
|
456
|
-
return Ok(disabled_payload());
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
let id = arguments
|
|
460
|
-
.get("id")
|
|
461
|
-
.and_then(|v| v.as_str())
|
|
462
|
-
.ok_or_else(|| anyhow::anyhow!("'id' is required"))?;
|
|
463
|
-
|
|
464
|
-
// Resolve prefixes to a concrete id for a friendly delete-by-prefix.
|
|
465
|
-
let resolved = read_memory_by_id(&handoff, id)?
|
|
466
|
-
.ok_or_else(|| anyhow::anyhow!("Memory not found: {id}"))?;
|
|
467
|
-
let deleted = delete_memory(&handoff, &resolved.id)?;
|
|
468
|
-
if !deleted {
|
|
469
|
-
anyhow::bail!("Memory not found: {id}");
|
|
470
|
-
}
|
|
471
|
-
Ok(to_json(&json!({ "status": "deleted", "id": resolved.id })))
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/// `memory_cleanup` — SessionStart housekeeping (spec C).
|
|
475
|
-
///
|
|
476
|
-
/// Three independent passes over the memory store:
|
|
477
|
-
///
|
|
478
|
-
/// 1. **Exact duplicates** (identical `content_hash`) are merged *silently and
|
|
479
|
-
/// losslessly*: the oldest memory in each hash group is kept, the others are
|
|
480
|
-
/// deleted and recorded in its `superseded_ids`. This is the only mutating
|
|
481
|
-
/// pass, gated by `apply_exact_merges` (default true).
|
|
482
|
-
/// 2. **Near-duplicate clusters** (Jaccard ≥ threshold, grouped by union-find)
|
|
483
|
-
/// and **stale** memories (`last_referenced_at` — or `created_at` if never
|
|
484
|
-
/// referenced — older than `stale_days`) are returned as *recommendations*
|
|
485
|
-
/// for the AI to act on with `memory_save(merge_into=…)` / `memory_delete`.
|
|
486
|
-
/// This pass never writes.
|
|
487
|
-
/// 3. The `injected/` sidecars older than the gc window are removed.
|
|
488
|
-
///
|
|
489
|
-
/// Returns a JSON string
|
|
490
|
-
/// `{"auto_merged_exact":n,"cleanup_recommendations":{"similar_clusters":[…],
|
|
491
|
-
/// "stale":[…]},"injected_sidecars_removed":k}`.
|
|
492
|
-
pub fn handle_cleanup(arguments: &Value) -> Result<String> {
|
|
493
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
494
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
495
|
-
|
|
496
|
-
let settings = memory_settings(&handoff)?;
|
|
497
|
-
if !settings.memory_enabled {
|
|
498
|
-
return Ok(disabled_payload());
|
|
499
|
-
}
|
|
500
|
-
let apply_exact_merges = arguments
|
|
501
|
-
.get("apply_exact_merges")
|
|
502
|
-
.and_then(|v| v.as_bool())
|
|
503
|
-
.unwrap_or(true);
|
|
504
|
-
let stale_days = arguments
|
|
505
|
-
.get("stale_days")
|
|
506
|
-
.and_then(|v| v.as_i64())
|
|
507
|
-
.filter(|n| *n >= 0)
|
|
508
|
-
.unwrap_or(settings.memory_stale_days);
|
|
509
|
-
|
|
510
|
-
let now = chrono::Utc::now();
|
|
511
|
-
let now_str = now.to_rfc3339();
|
|
512
|
-
|
|
513
|
-
// (1) Exact-duplicate auto-merge (lossless). Re-reads memories afterward so
|
|
514
|
-
// later passes see the merged store.
|
|
515
|
-
let auto_merged_exact = if apply_exact_merges {
|
|
516
|
-
merge_exact_duplicates(&handoff, &now_str)?
|
|
517
|
-
} else {
|
|
518
|
-
0
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
let memories = read_all_memories(&handoff)?;
|
|
522
|
-
|
|
523
|
-
// (2a) Near-duplicate clusters (recommendation only).
|
|
524
|
-
let similar_clusters = similar_clusters(&memories, settings.memory_dup_threshold);
|
|
525
|
-
|
|
526
|
-
// (2b) Stale memories (recommendation only).
|
|
527
|
-
let stale = stale_memories(&memories, stale_days, now);
|
|
528
|
-
|
|
529
|
-
// (3) Garbage-collect old per-session sidecars.
|
|
530
|
-
let injected_sidecars_removed =
|
|
531
|
-
gc_injected_sets(&handoff, settings.memory_injected_gc_days, now)?;
|
|
532
|
-
|
|
533
|
-
Ok(to_json(&json!({
|
|
534
|
-
"auto_merged_exact": auto_merged_exact,
|
|
535
|
-
"cleanup_recommendations": {
|
|
536
|
-
"similar_clusters": similar_clusters,
|
|
537
|
-
"stale": stale,
|
|
538
|
-
},
|
|
539
|
-
"injected_sidecars_removed": injected_sidecars_removed,
|
|
540
|
-
})))
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/// Merge memories that share an identical `content_hash`. The oldest entry in
|
|
544
|
-
/// each group (by parsed `created_at`, ties broken by id) is kept; the rest are
|
|
545
|
-
/// absorbed into it and their files deleted. Returns the number of memories
|
|
546
|
-
/// absorbed (files removed).
|
|
547
|
-
///
|
|
548
|
-
/// "Lossless" means **no signal is dropped**, not byte-identical text: the
|
|
549
|
-
/// canonical content is identical by construction (same hash over the tokenized
|
|
550
|
-
/// text), so the keeper's body already represents every absorbed memory's
|
|
551
|
-
/// meaning. To avoid losing the *other* fields, the keeper inherits the union of
|
|
552
|
-
/// every absorbed memory's `tags` / `scope_paths` / `superseded_ids`, the sum of
|
|
553
|
-
/// their `hit_count`, and the latest `last_referenced_at`.
|
|
554
|
-
///
|
|
555
|
-
/// Failure safety (mirrors `mark_injected_memories`): the keeper is rewritten
|
|
556
|
-
/// with the full merged metadata and `superseded_ids` **before** any duplicate
|
|
557
|
-
/// file is deleted, so the audit trail is durable even if a later delete fails.
|
|
558
|
-
/// Per-duplicate deletes are then best-effort — a failure on one leaves an
|
|
559
|
-
/// orphaned-but-superseded file (re-absorbed on the next run) rather than a lost
|
|
560
|
-
/// record.
|
|
561
|
-
fn merge_exact_duplicates(handoff: &std::path::Path, now: &str) -> Result<usize> {
|
|
562
|
-
use std::collections::BTreeMap;
|
|
563
|
-
|
|
564
|
-
let memories = read_all_memories(handoff)?;
|
|
565
|
-
// Group by content_hash, preserving discovery order within each group.
|
|
566
|
-
let mut groups: BTreeMap<String, Vec<MemoryEntry>> = BTreeMap::new();
|
|
567
|
-
for m in memories {
|
|
568
|
-
groups.entry(m.content_hash.clone()).or_default().push(m);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
let mut absorbed_total = 0usize;
|
|
572
|
-
for (_hash, mut group) in groups {
|
|
573
|
-
if group.len() < 2 {
|
|
574
|
-
continue;
|
|
575
|
-
}
|
|
576
|
-
// Keep the oldest as the canonical survivor. Parse created_at to an
|
|
577
|
-
// instant so differing RFC3339 offsets order by true time, not by string
|
|
578
|
-
// (ties — including unparseable stamps — broken by id for determinism).
|
|
579
|
-
group.sort_by(|a, b| {
|
|
580
|
-
parse_instant(&a.created_at)
|
|
581
|
-
.cmp(&parse_instant(&b.created_at))
|
|
582
|
-
.then_with(|| a.id.cmp(&b.id))
|
|
583
|
-
});
|
|
584
|
-
let mut keeper = group.remove(0);
|
|
585
|
-
|
|
586
|
-
// Fold every absorbed memory's signal into the keeper (union of tags /
|
|
587
|
-
// scope_paths / superseded_ids; summed hit_count; latest reference).
|
|
588
|
-
for dup in &group {
|
|
589
|
-
if !keeper.superseded_ids.contains(&dup.id) {
|
|
590
|
-
keeper.superseded_ids.push(dup.id.clone());
|
|
591
|
-
}
|
|
592
|
-
for sid in &dup.superseded_ids {
|
|
593
|
-
if sid != &keeper.id && !keeper.superseded_ids.contains(sid) {
|
|
594
|
-
keeper.superseded_ids.push(sid.clone());
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
for t in &dup.tags {
|
|
598
|
-
if !keeper.tags.contains(t) {
|
|
599
|
-
keeper.tags.push(t.clone());
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
for kw in &dup.keywords {
|
|
603
|
-
if !keeper.keywords.contains(kw) {
|
|
604
|
-
keeper.keywords.push(kw.clone());
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
for s in &dup.scope_paths {
|
|
608
|
-
if !keeper.scope_paths.contains(s) {
|
|
609
|
-
keeper.scope_paths.push(s.clone());
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
keeper.hit_count = keeper.hit_count.saturating_add(dup.hit_count);
|
|
613
|
-
keeper.last_referenced_at = latest_timestamp(
|
|
614
|
-
keeper.last_referenced_at.take(),
|
|
615
|
-
dup.last_referenced_at.clone(),
|
|
616
|
-
);
|
|
617
|
-
}
|
|
618
|
-
keeper.updated_at = now.to_string();
|
|
619
|
-
|
|
620
|
-
// (1) Persist the merged keeper FIRST — this is the durable audit record.
|
|
621
|
-
write_memory(handoff, &keeper)?;
|
|
622
|
-
|
|
623
|
-
// (2) Best-effort delete the absorbed files. A failure here is non-fatal:
|
|
624
|
-
// the keeper already records the absorption, so a surviving dup is simply
|
|
625
|
-
// re-absorbed next run.
|
|
626
|
-
for dup in &group {
|
|
627
|
-
if matches!(delete_memory(handoff, &dup.id), Ok(true)) {
|
|
628
|
-
absorbed_total += 1;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
Ok(absorbed_total)
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/// Parse an RFC3339 timestamp to a UTC instant for ordering. Unparseable stamps
|
|
636
|
-
/// sort as the epoch (treated as "oldest") so they don't crash ordering; the id
|
|
637
|
-
/// tie-break keeps the result deterministic.
|
|
638
|
-
fn parse_instant(s: &str) -> chrono::DateTime<chrono::Utc> {
|
|
639
|
-
chrono::DateTime::parse_from_rfc3339(s)
|
|
640
|
-
.map(|d| d.with_timezone(&chrono::Utc))
|
|
641
|
-
.unwrap_or_else(|_| chrono::DateTime::<chrono::Utc>::MIN_UTC)
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/// Return the later of two optional RFC3339 timestamps (an unparseable one loses;
|
|
645
|
-
/// `None` loses to `Some`).
|
|
646
|
-
fn latest_timestamp(a: Option<String>, b: Option<String>) -> Option<String> {
|
|
647
|
-
match (a, b) {
|
|
648
|
-
(Some(x), Some(y)) => {
|
|
649
|
-
if parse_instant(&y) > parse_instant(&x) {
|
|
650
|
-
Some(y)
|
|
651
|
-
} else {
|
|
652
|
-
Some(x)
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
(Some(x), None) => Some(x),
|
|
656
|
-
(None, b) => b,
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/// Group memories into near-duplicate clusters via Jaccard ≥ threshold using
|
|
661
|
-
/// union-find, and emit each multi-member cluster as a recommendation. Exact
|
|
662
|
-
/// duplicates have already been merged by the time this runs, so a cluster here
|
|
663
|
-
/// is genuinely "similar but not identical" — the AI decides whether to merge.
|
|
664
|
-
fn similar_clusters(memories: &[MemoryEntry], dup_threshold: f64) -> Vec<Value> {
|
|
665
|
-
let n = memories.len();
|
|
666
|
-
if n < 2 {
|
|
667
|
-
return Vec::new();
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Precompute each memory's token set once (O(n) tokenizations, not O(n²)).
|
|
671
|
-
let token_sets: Vec<_> = memories
|
|
672
|
-
.iter()
|
|
673
|
-
.map(|m| lexsim::token_set(&m.index_text()))
|
|
674
|
-
.collect();
|
|
675
|
-
|
|
676
|
-
let mut uf = UnionFind::new(n);
|
|
677
|
-
let mut pair_scores: std::collections::HashMap<(usize, usize), f64> =
|
|
678
|
-
std::collections::HashMap::new();
|
|
679
|
-
for i in 0..n {
|
|
680
|
-
for j in (i + 1)..n {
|
|
681
|
-
let score = lexsim::jaccard_sets(&token_sets[i], &token_sets[j]);
|
|
682
|
-
if score >= dup_threshold {
|
|
683
|
-
uf.union(i, j);
|
|
684
|
-
pair_scores.insert((i, j), score);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Bucket indices by their union-find root.
|
|
690
|
-
let mut clusters: std::collections::BTreeMap<usize, Vec<usize>> =
|
|
691
|
-
std::collections::BTreeMap::new();
|
|
692
|
-
for i in 0..n {
|
|
693
|
-
clusters.entry(uf.find(i)).or_default().push(i);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
let mut out: Vec<Value> = Vec::new();
|
|
697
|
-
for (_root, members) in clusters {
|
|
698
|
-
if members.len() < 2 {
|
|
699
|
-
continue;
|
|
700
|
-
}
|
|
701
|
-
// Representative pair score = the max similarity within the cluster, so
|
|
702
|
-
// the AI sees how tight it is.
|
|
703
|
-
let mut max_score = 0.0_f64;
|
|
704
|
-
for a in 0..members.len() {
|
|
705
|
-
for b in (a + 1)..members.len() {
|
|
706
|
-
let (lo, hi) = (members[a].min(members[b]), members[a].max(members[b]));
|
|
707
|
-
if let Some(s) = pair_scores.get(&(lo, hi)) {
|
|
708
|
-
if *s > max_score {
|
|
709
|
-
max_score = *s;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
let entries: Vec<Value> = members
|
|
715
|
-
.iter()
|
|
716
|
-
.map(|&i| {
|
|
717
|
-
let m = &memories[i];
|
|
718
|
-
json!({ "id": m.id, "text": m.text, "kind": m.kind })
|
|
719
|
-
})
|
|
720
|
-
.collect();
|
|
721
|
-
out.push(json!({
|
|
722
|
-
"max_score": round2(max_score),
|
|
723
|
-
"memories": entries,
|
|
724
|
-
}));
|
|
725
|
-
}
|
|
726
|
-
// Tightest clusters first.
|
|
727
|
-
out.sort_by(|a, b| {
|
|
728
|
-
b["max_score"]
|
|
729
|
-
.as_f64()
|
|
730
|
-
.partial_cmp(&a["max_score"].as_f64())
|
|
731
|
-
.unwrap_or(std::cmp::Ordering::Equal)
|
|
732
|
-
});
|
|
733
|
-
out
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/// Flag memories not referenced for `stale_days` (using `last_referenced_at`, or
|
|
737
|
-
/// `created_at` when never referenced). Recommendation only — the AI decides
|
|
738
|
-
/// whether a stale memory is obsolete or simply rarely relevant.
|
|
739
|
-
fn stale_memories(
|
|
740
|
-
memories: &[MemoryEntry],
|
|
741
|
-
stale_days: i64,
|
|
742
|
-
now: chrono::DateTime<chrono::Utc>,
|
|
743
|
-
) -> Vec<Value> {
|
|
744
|
-
let cutoff = now - chrono::Duration::days(stale_days);
|
|
745
|
-
let mut out: Vec<(chrono::DateTime<chrono::Utc>, Value)> = Vec::new();
|
|
746
|
-
for m in memories {
|
|
747
|
-
// The reference point: last injection if any, else creation time.
|
|
748
|
-
let stamp = m.last_referenced_at.as_deref().unwrap_or(&m.created_at);
|
|
749
|
-
let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(stamp) else {
|
|
750
|
-
continue; // unparseable timestamp → can't judge age, skip
|
|
751
|
-
};
|
|
752
|
-
let parsed = parsed.with_timezone(&chrono::Utc);
|
|
753
|
-
if parsed < cutoff {
|
|
754
|
-
out.push((
|
|
755
|
-
parsed,
|
|
756
|
-
json!({
|
|
757
|
-
"id": m.id,
|
|
758
|
-
"text": m.text,
|
|
759
|
-
"kind": m.kind,
|
|
760
|
-
"hit_count": m.hit_count,
|
|
761
|
-
"last_referenced_at": m.last_referenced_at,
|
|
762
|
-
"created_at": m.created_at,
|
|
763
|
-
}),
|
|
764
|
-
));
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
// Oldest (most stale) first.
|
|
768
|
-
out.sort_by_key(|(stamp, _)| *stamp);
|
|
769
|
-
out.into_iter().map(|(_, v)| v).collect()
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/// Minimal union-find (disjoint set) with path compression and union by size,
|
|
773
|
-
/// used to cluster near-duplicate memories.
|
|
774
|
-
struct UnionFind {
|
|
775
|
-
parent: Vec<usize>,
|
|
776
|
-
size: Vec<usize>,
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
impl UnionFind {
|
|
780
|
-
fn new(n: usize) -> Self {
|
|
781
|
-
UnionFind {
|
|
782
|
-
parent: (0..n).collect(),
|
|
783
|
-
size: vec![1; n],
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
fn find(&mut self, x: usize) -> usize {
|
|
788
|
-
let mut root = x;
|
|
789
|
-
while self.parent[root] != root {
|
|
790
|
-
root = self.parent[root];
|
|
791
|
-
}
|
|
792
|
-
// Path compression.
|
|
793
|
-
let mut cur = x;
|
|
794
|
-
while self.parent[cur] != root {
|
|
795
|
-
let next = self.parent[cur];
|
|
796
|
-
self.parent[cur] = root;
|
|
797
|
-
cur = next;
|
|
798
|
-
}
|
|
799
|
-
root
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
fn union(&mut self, a: usize, b: usize) {
|
|
803
|
-
let (ra, rb) = (self.find(a), self.find(b));
|
|
804
|
-
if ra == rb {
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
// Union by size: attach the smaller tree under the larger.
|
|
808
|
-
let (big, small) = if self.size[ra] >= self.size[rb] {
|
|
809
|
-
(ra, rb)
|
|
810
|
-
} else {
|
|
811
|
-
(rb, ra)
|
|
812
|
-
};
|
|
813
|
-
self.parent[small] = big;
|
|
814
|
-
self.size[big] += self.size[small];
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
/// Last path component of `p` (handles both `/` and `\` separators).
|
|
819
|
-
fn basename(p: &str) -> String {
|
|
820
|
-
p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
/// Read a `&[String]` from a JSON string-array argument (missing → empty).
|
|
824
|
-
fn string_array(arguments: &Value, key: &str) -> Vec<String> {
|
|
825
|
-
arguments
|
|
826
|
-
.get(key)
|
|
827
|
-
.and_then(|v| v.as_array())
|
|
828
|
-
.map(|arr| {
|
|
829
|
-
arr.iter()
|
|
830
|
-
.filter_map(|v| v.as_str())
|
|
831
|
-
.map(str::to_string)
|
|
832
|
-
.collect()
|
|
833
|
-
})
|
|
834
|
-
.unwrap_or_default()
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
fn round2(x: f64) -> f64 {
|
|
838
|
-
(x * 100.0).round() / 100.0
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
fn to_json(v: &Value) -> String {
|
|
842
|
-
// Pretty so a human reading the raw tool result can follow it; both hook and
|
|
843
|
-
// wrapper paths parse it the same way.
|
|
844
|
-
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
#[cfg(test)]
|
|
848
|
-
mod tests {
|
|
849
|
-
use super::*;
|
|
850
|
-
|
|
851
|
-
/// The spec-default Jaccard threshold, used to drive the pure clustering
|
|
852
|
-
/// helper in unit tests (the configurable value lives in `SettingsConfig`).
|
|
853
|
-
const DEFAULT_DUP_THRESHOLD: f64 = 0.72;
|
|
854
|
-
|
|
855
|
-
#[test]
|
|
856
|
-
fn basename_handles_separators() {
|
|
857
|
-
assert_eq!(basename("src/storage/mod.rs"), "mod.rs");
|
|
858
|
-
assert_eq!(basename("a\\b\\c.rs"), "c.rs");
|
|
859
|
-
assert_eq!(basename("plain.rs"), "plain.rs");
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
#[test]
|
|
863
|
-
fn string_array_parsing() {
|
|
864
|
-
let v = json!({ "tags": ["a", "b", 3, "c"] });
|
|
865
|
-
assert_eq!(string_array(&v, "tags"), vec!["a", "b", "c"]);
|
|
866
|
-
assert!(string_array(&v, "missing").is_empty());
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
#[test]
|
|
870
|
-
fn round2_works() {
|
|
871
|
-
assert_eq!(round2(1.23456), 1.23);
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
fn mem(id: &str, text: &str, created: &str, last_ref: Option<&str>) -> MemoryEntry {
|
|
875
|
-
let mut m = MemoryEntry::new(
|
|
876
|
-
id.to_string(),
|
|
877
|
-
text.to_string(),
|
|
878
|
-
"lesson".to_string(),
|
|
879
|
-
vec![],
|
|
880
|
-
vec![],
|
|
881
|
-
vec![],
|
|
882
|
-
created.to_string(),
|
|
883
|
-
);
|
|
884
|
-
m.last_referenced_at = last_ref.map(str::to_string);
|
|
885
|
-
m
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
#[test]
|
|
889
|
-
fn union_find_groups_transitively() {
|
|
890
|
-
let mut uf = UnionFind::new(5);
|
|
891
|
-
uf.union(0, 1);
|
|
892
|
-
uf.union(1, 2);
|
|
893
|
-
// {0,1,2} share a root; 3 and 4 are singletons.
|
|
894
|
-
assert_eq!(uf.find(0), uf.find(2));
|
|
895
|
-
assert_ne!(uf.find(0), uf.find(3));
|
|
896
|
-
assert_ne!(uf.find(3), uf.find(4));
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
#[test]
|
|
900
|
-
fn similar_clusters_groups_near_duplicates() {
|
|
901
|
-
let now = now_rfc3339();
|
|
902
|
-
let a = mem(
|
|
903
|
-
"m-a",
|
|
904
|
-
"always use atomic_write for handoff files",
|
|
905
|
-
&now,
|
|
906
|
-
None,
|
|
907
|
-
);
|
|
908
|
-
let b = mem(
|
|
909
|
-
"m-b",
|
|
910
|
-
"always use atomic_write for handoff files when saving",
|
|
911
|
-
&now,
|
|
912
|
-
None,
|
|
913
|
-
);
|
|
914
|
-
let c = mem(
|
|
915
|
-
"m-c",
|
|
916
|
-
"the gantt chart export is totally unrelated",
|
|
917
|
-
&now,
|
|
918
|
-
None,
|
|
919
|
-
);
|
|
920
|
-
let clusters = similar_clusters(&[a, b, c], DEFAULT_DUP_THRESHOLD);
|
|
921
|
-
assert_eq!(clusters.len(), 1, "only a+b cluster; c stands alone");
|
|
922
|
-
let members = clusters[0]["memories"].as_array().unwrap();
|
|
923
|
-
assert_eq!(members.len(), 2);
|
|
924
|
-
assert!(clusters[0]["max_score"].as_f64().unwrap() >= DEFAULT_DUP_THRESHOLD);
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
#[test]
|
|
928
|
-
fn similar_clusters_empty_when_all_distinct() {
|
|
929
|
-
let now = now_rfc3339();
|
|
930
|
-
let a = mem("m-a", "use atomic_write everywhere", &now, None);
|
|
931
|
-
let b = mem("m-b", "render the gantt chart schedule", &now, None);
|
|
932
|
-
assert!(similar_clusters(&[a, b], DEFAULT_DUP_THRESHOLD).is_empty());
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
#[test]
|
|
936
|
-
fn stale_uses_last_referenced_then_created() {
|
|
937
|
-
let now = chrono::DateTime::parse_from_rfc3339("2026-06-27T00:00:00Z")
|
|
938
|
-
.unwrap()
|
|
939
|
-
.with_timezone(&chrono::Utc);
|
|
940
|
-
// Never referenced, created 100 days ago → stale.
|
|
941
|
-
let old = mem("m-old", "ancient rule", "2026-03-19T00:00:00Z", None);
|
|
942
|
-
// Created long ago but referenced yesterday → NOT stale.
|
|
943
|
-
let touched = mem(
|
|
944
|
-
"m-fresh",
|
|
945
|
-
"recently used rule",
|
|
946
|
-
"2026-01-01T00:00:00Z",
|
|
947
|
-
Some("2026-06-26T00:00:00Z"),
|
|
948
|
-
);
|
|
949
|
-
let stale = stale_memories(&[old, touched], 60, now);
|
|
950
|
-
assert_eq!(stale.len(), 1);
|
|
951
|
-
assert_eq!(stale[0]["id"], "m-old");
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
#[test]
|
|
955
|
-
fn stale_skips_unparseable_timestamp() {
|
|
956
|
-
let now = chrono::Utc::now();
|
|
957
|
-
let mut bad = mem("m-bad", "x", "not-a-date", None);
|
|
958
|
-
bad.created_at = "not-a-date".to_string();
|
|
959
|
-
assert!(stale_memories(&[bad], 0, now).is_empty());
|
|
960
|
-
}
|
|
961
|
-
}
|