handoff-mcp-server 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +42 -1
- package/Cargo.toml +3 -2
- package/README.md +163 -4
- package/package.json +1 -1
- package/scripts/cargo-env.sh +29 -0
- package/scripts/handoff-memory-hook.py +208 -0
- package/src/mcp/handlers/config.rs +63 -0
- package/src/mcp/handlers/init.rs +1 -1
- package/src/mcp/handlers/memory.rs +906 -0
- package/src/mcp/handlers/mod.rs +5 -0
- package/src/mcp/tools.rs +59 -0
- package/src/storage/config.rs +52 -0
- package/src/storage/memory/injected.rs +340 -0
- package/src/storage/memory/mod.rs +235 -0
- package/src/storage/memory/model.rs +96 -0
- package/src/storage/mod.rs +2 -0
package/Cargo.lock
CHANGED
|
@@ -144,10 +144,11 @@ dependencies = [
|
|
|
144
144
|
|
|
145
145
|
[[package]]
|
|
146
146
|
name = "handoff-mcp"
|
|
147
|
-
version = "0.
|
|
147
|
+
version = "0.13.0"
|
|
148
148
|
dependencies = [
|
|
149
149
|
"anyhow",
|
|
150
150
|
"chrono",
|
|
151
|
+
"lexsim",
|
|
151
152
|
"serde",
|
|
152
153
|
"serde_json",
|
|
153
154
|
"tempfile",
|
|
@@ -242,6 +243,16 @@ version = "0.1.0"
|
|
|
242
243
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
243
244
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|
244
245
|
|
|
246
|
+
[[package]]
|
|
247
|
+
name = "lexsim"
|
|
248
|
+
version = "0.1.0"
|
|
249
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
250
|
+
checksum = "579b9d7ab407540bead4688eaf1675410a198d77c237a30cda55597df98926e1"
|
|
251
|
+
dependencies = [
|
|
252
|
+
"unicode-normalization",
|
|
253
|
+
"unicode-segmentation",
|
|
254
|
+
]
|
|
255
|
+
|
|
245
256
|
[[package]]
|
|
246
257
|
name = "libc"
|
|
247
258
|
version = "0.2.186"
|
|
@@ -454,6 +465,21 @@ dependencies = [
|
|
|
454
465
|
"syn",
|
|
455
466
|
]
|
|
456
467
|
|
|
468
|
+
[[package]]
|
|
469
|
+
name = "tinyvec"
|
|
470
|
+
version = "1.11.0"
|
|
471
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
472
|
+
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
|
473
|
+
dependencies = [
|
|
474
|
+
"tinyvec_macros",
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
[[package]]
|
|
478
|
+
name = "tinyvec_macros"
|
|
479
|
+
version = "0.1.1"
|
|
480
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
481
|
+
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|
482
|
+
|
|
457
483
|
[[package]]
|
|
458
484
|
name = "toml"
|
|
459
485
|
version = "0.8.23"
|
|
@@ -501,6 +527,21 @@ version = "1.0.24"
|
|
|
501
527
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
502
528
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
503
529
|
|
|
530
|
+
[[package]]
|
|
531
|
+
name = "unicode-normalization"
|
|
532
|
+
version = "0.1.25"
|
|
533
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
534
|
+
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
|
535
|
+
dependencies = [
|
|
536
|
+
"tinyvec",
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
[[package]]
|
|
540
|
+
name = "unicode-segmentation"
|
|
541
|
+
version = "1.13.3"
|
|
542
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
543
|
+
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
|
544
|
+
|
|
504
545
|
[[package]]
|
|
505
546
|
name = "unicode-xid"
|
|
506
547
|
version = "0.2.6"
|
package/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "handoff-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.13.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "MCP server that gives AI coding agents persistent memory across sessions"
|
|
6
6
|
license = "MIT"
|
|
@@ -9,7 +9,7 @@ homepage = "https://github.com/alphaelements/handoff-mcp"
|
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = ["mcp", "ai", "claude", "handoff", "context"]
|
|
11
11
|
categories = ["command-line-utilities", "development-tools"]
|
|
12
|
-
rust-version = "1.
|
|
12
|
+
rust-version = "1.85"
|
|
13
13
|
exclude = ["lefthook.yml", "tmp/", "wiki/", "docs/", ".claude/", ".vscode/"]
|
|
14
14
|
|
|
15
15
|
[dependencies]
|
|
@@ -20,6 +20,7 @@ toml_edit = "0.22"
|
|
|
20
20
|
chrono = { version = "0.4", features = ["serde"] }
|
|
21
21
|
anyhow = "1"
|
|
22
22
|
thiserror = "2"
|
|
23
|
+
lexsim = "0.1.0"
|
|
23
24
|
|
|
24
25
|
[dev-dependencies]
|
|
25
26
|
tempfile = "3.27.0"
|
package/README.md
CHANGED
|
@@ -155,9 +155,21 @@ rename) so a concurrent reader never sees a partially-written file.
|
|
|
155
155
|
| `handoff_dashboard` | Overview of all handoff-enabled projects |
|
|
156
156
|
| `handoff_import_context` | Bulk import tasks and session data from documents |
|
|
157
157
|
| `handoff_refer` | Send a cross-project referral (bug, improvement, request) |
|
|
158
|
-
| `handoff_list_referrals` | List incoming referrals from other projects |
|
|
158
|
+
| `handoff_list_referrals` | List incoming referrals from other projects (summaries only) |
|
|
159
|
+
| `handoff_get_referral` | Fetch one incoming referral in full — details, suggested tasks, done_criteria, context |
|
|
159
160
|
| `handoff_update_referral` | Update referral status (open → acknowledged → resolved) |
|
|
160
161
|
|
|
162
|
+
### Project Memory
|
|
163
|
+
|
|
164
|
+
| Tool | Purpose |
|
|
165
|
+
|------|---------|
|
|
166
|
+
| `memory_save` | Save a durable project memory (lesson/rule/convention/gotcha); detects exact and near-duplicate memories and hands near-duplicates back for AI-driven merge |
|
|
167
|
+
| `memory_query` | Return the memories most relevant to the current prompt/file (BM25 + scope-path boost); with a `session_id`, suppresses repeats already injected this session |
|
|
168
|
+
| `memory_delete` | Delete a memory by id (full id or unique prefix) |
|
|
169
|
+
| `memory_cleanup` | Housekeeping (for SessionStart): silently merge exact duplicates, return near-duplicate/stale recommendations, gc old injection sidecars |
|
|
170
|
+
|
|
171
|
+
See [Project Memory](#project-memory-1) below for what it is and how to wire automatic injection.
|
|
172
|
+
|
|
161
173
|
### Task Data Model
|
|
162
174
|
|
|
163
175
|
Tasks are stored as a directory tree with status encoded in filenames:
|
|
@@ -223,9 +235,11 @@ name = "my-project"
|
|
|
223
235
|
description = "Project description"
|
|
224
236
|
|
|
225
237
|
[settings]
|
|
226
|
-
history_limit = 20
|
|
227
|
-
done_task_limit = 10
|
|
228
|
-
auto_git_summary = true
|
|
238
|
+
history_limit = 20 # Max closed sessions to keep
|
|
239
|
+
done_task_limit = 10 # Max completed tasks to show
|
|
240
|
+
auto_git_summary = true # Capture git state automatically
|
|
241
|
+
require_estimate_hours = true # Require estimate_hours on leaf tasks (default true)
|
|
242
|
+
ai_estimate_multiplier = 0.2 # Multiplier turning human estimates into AI-effort hours
|
|
229
243
|
|
|
230
244
|
[dashboard]
|
|
231
245
|
scan_dirs = ["~/pro/"] # Directories to scan for dashboard
|
|
@@ -263,6 +277,147 @@ mode = "compare" # plan, actual, compare
|
|
|
263
277
|
|
|
264
278
|
All configuration sections can be updated via `handoff_update_config` with dot-notation keys (e.g., `"calendar.work_hours_per_day": 7`).
|
|
265
279
|
|
|
280
|
+
### Estimates and AI effort
|
|
281
|
+
|
|
282
|
+
handoff-mcp distinguishes the **raw human-effort estimate** you record on a task
|
|
283
|
+
from the **AI-effort hours** used in scheduling and metrics:
|
|
284
|
+
|
|
285
|
+
- **`require_estimate_hours`** (default `true`) — `handoff_update_task` rejects
|
|
286
|
+
creating or updating a *leaf* task (in `todo` / `in_progress` / `review` /
|
|
287
|
+
`done`) without `schedule.estimate_hours > 0`. Parent tasks (with children) and
|
|
288
|
+
`blocked` / `skipped` tasks are exempt, and an estimate already on the task
|
|
289
|
+
satisfies the requirement. Set to `false` to opt out.
|
|
290
|
+
- **`ai_estimate_multiplier`** (default `0.2`) — the factor applied to raw
|
|
291
|
+
estimates to model how long the work takes when an AI agent does it. Always
|
|
292
|
+
record the *raw human-effort* estimate; the multiplier is applied at
|
|
293
|
+
aggregation time by `handoff_get_metrics` (`total_adjusted_estimate_hours` and
|
|
294
|
+
per-milestone `adjusted_estimate_hours`) and `handoff_get_capacity`. Raw values
|
|
295
|
+
are never overwritten.
|
|
296
|
+
|
|
297
|
+
## Project Memory
|
|
298
|
+
|
|
299
|
+
Sessions answer *"what was I doing last time?"*. **Memory** answers a longer-lived
|
|
300
|
+
question: *"what should every session in this project always know?"* — durable
|
|
301
|
+
lessons, rules, conventions, and gotchas that outlive any one session.
|
|
302
|
+
|
|
303
|
+
Memories live in `.handoff/memory/` (one JSON file per memory, plus per-session
|
|
304
|
+
`injected/` sidecars). A built-in multilingual similarity engine (Japanese /
|
|
305
|
+
English, dictionary-free) ranks relevance and detects duplicates, all in-memory
|
|
306
|
+
and sub-millisecond.
|
|
307
|
+
|
|
308
|
+
### Using it directly
|
|
309
|
+
|
|
310
|
+
The agent can call the memory tools at any time:
|
|
311
|
+
|
|
312
|
+
- `memory_save` — record a memory. An exact duplicate is reported (not
|
|
313
|
+
rewritten); a near-duplicate comes back as a `conflict` with both bodies so the
|
|
314
|
+
agent can merge them (`merge_into=<id>`, `absorb_ids=[…]`) or save separately
|
|
315
|
+
with `force=true`. **handoff-mcp never merges for you** — it surfaces both
|
|
316
|
+
bodies and lets the agent decide.
|
|
317
|
+
- `memory_query` — fetch the memories most relevant to some text and/or files.
|
|
318
|
+
- `memory_delete` / `memory_cleanup` — prune and de-duplicate the store.
|
|
319
|
+
|
|
320
|
+
### Automatic injection via hooks
|
|
321
|
+
|
|
322
|
+
MCP is request/response — the server cannot push a memory into the agent's
|
|
323
|
+
context on its own. **Claude Code hooks** close that gap: they fire regardless of
|
|
324
|
+
what the agent intends, call `memory_query`, and inject the matching memories as
|
|
325
|
+
`additionalContext`. A per-session diff (keyed on the hook `session_id`) ensures
|
|
326
|
+
the same memory is **not injected twice in one session** — and an *edited* memory
|
|
327
|
+
(new content hash) is re-injected.
|
|
328
|
+
|
|
329
|
+
| Event | Calls | Effect |
|
|
330
|
+
|-------|-------|--------|
|
|
331
|
+
| `UserPromptSubmit` | `memory_query` (prompt text) | Inject memories relevant to the prompt |
|
|
332
|
+
| `PreToolUse` (`Edit\|Write\|MultiEdit`) | `memory_query` (file path) | Inject memories scoped to the file being edited |
|
|
333
|
+
| `SessionStart` | `memory_cleanup` | Merge exact duplicates, gc old sidecars |
|
|
334
|
+
|
|
335
|
+
> **Wire hooks in your *user/global* settings, not in the repo.** Hooks are a
|
|
336
|
+
> personal workflow choice; the handoff-mcp repo does not ship a `.claude/`
|
|
337
|
+
> hooks config, and you should not commit one into a shared project. Put the
|
|
338
|
+
> config in `~/.claude/settings.json` (global) or your own
|
|
339
|
+
> `.claude/settings.local.json` (git-ignored).
|
|
340
|
+
|
|
341
|
+
**Native `mcp_tool` hook (preferred).** Recent Claude Code versions can call an
|
|
342
|
+
MCP tool from a hook directly, with no wrapper script. In
|
|
343
|
+
`~/.claude/settings.json`:
|
|
344
|
+
|
|
345
|
+
```json
|
|
346
|
+
{
|
|
347
|
+
"hooks": {
|
|
348
|
+
"UserPromptSubmit": [
|
|
349
|
+
{ "hooks": [ {
|
|
350
|
+
"type": "mcp_tool", "server": "handoff", "tool": "memory_query",
|
|
351
|
+
"input": { "project_dir": "${cwd}",
|
|
352
|
+
"session_id": "${session_id}", "text": "${prompt}" }
|
|
353
|
+
} ] }
|
|
354
|
+
],
|
|
355
|
+
"PreToolUse": [
|
|
356
|
+
{ "matcher": "Edit|Write|MultiEdit", "hooks": [ {
|
|
357
|
+
"type": "mcp_tool", "server": "handoff", "tool": "memory_query",
|
|
358
|
+
"input": { "project_dir": "${cwd}",
|
|
359
|
+
"session_id": "${session_id}", "tool_name": "${tool_name}",
|
|
360
|
+
"text": "${tool_input.file_path}",
|
|
361
|
+
"file_paths": ["${tool_input.file_path}"] }
|
|
362
|
+
} ] }
|
|
363
|
+
],
|
|
364
|
+
"SessionStart": [
|
|
365
|
+
{ "hooks": [ {
|
|
366
|
+
"type": "mcp_tool", "server": "handoff", "tool": "memory_cleanup",
|
|
367
|
+
"input": { "project_dir": "${cwd}" }
|
|
368
|
+
} ] }
|
|
369
|
+
]
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
(`server` must match the name you registered handoff-mcp under — `handoff` in the
|
|
375
|
+
[Setup](#setup) examples.)
|
|
376
|
+
|
|
377
|
+
**Wrapper script fallback.** If your Claude Code version doesn't support the
|
|
378
|
+
`mcp_tool` hook type, use the bundled `command` wrapper
|
|
379
|
+
[`scripts/handoff-memory-hook.py`](scripts/handoff-memory-hook.py). It reads the
|
|
380
|
+
hook JSON on stdin, calls the server over JSON-RPC, and emits
|
|
381
|
+
`additionalContext` — the memory tools return their payload as a JSON *string* so
|
|
382
|
+
both paths parse it identically. Point all three hooks at it:
|
|
383
|
+
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"hooks": {
|
|
387
|
+
"UserPromptSubmit": [
|
|
388
|
+
{ "hooks": [ { "type": "command",
|
|
389
|
+
"command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
|
|
390
|
+
],
|
|
391
|
+
"PreToolUse": [
|
|
392
|
+
{ "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command",
|
|
393
|
+
"command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
|
|
394
|
+
],
|
|
395
|
+
"SessionStart": [
|
|
396
|
+
{ "hooks": [ { "type": "command",
|
|
397
|
+
"command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
|
|
398
|
+
]
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The script resolves the `handoff-mcp` binary from `PATH` (override with
|
|
404
|
+
`HANDOFF_MCP_BIN`) and **fails safe**: on any error it prints nothing and exits
|
|
405
|
+
0, so a memory miss is silent and never blocks your prompt.
|
|
406
|
+
|
|
407
|
+
### Memory settings
|
|
408
|
+
|
|
409
|
+
All under `[settings]` in `.handoff/config.toml`, all with safe defaults
|
|
410
|
+
(existing projects need no change), all settable via `handoff_update_config`:
|
|
411
|
+
|
|
412
|
+
| Key | Default | Meaning |
|
|
413
|
+
|-----|---------|---------|
|
|
414
|
+
| `memory_enabled` | `true` | Master switch. When `false`, all four memory tools return a benign empty result and write nothing |
|
|
415
|
+
| `memory_dup_threshold` | `0.72` | Jaccard similarity at/above which a save is a near-duplicate conflict and cleanup groups a cluster |
|
|
416
|
+
| `memory_query_min_score` | `0.5` | BM25 relevance floor for `memory_query` results |
|
|
417
|
+
| `memory_query_limit` | `5` | Max memories returned per query |
|
|
418
|
+
| `memory_stale_days` | `60` | Days without a reference before a memory is flagged stale |
|
|
419
|
+
| `memory_injected_gc_days` | `14` | Age at which per-session injection sidecars are garbage-collected |
|
|
420
|
+
|
|
266
421
|
## MCP Resources
|
|
267
422
|
|
|
268
423
|
| URI | Description |
|
|
@@ -290,6 +445,10 @@ This project uses handoff-mcp for session continuity.
|
|
|
290
445
|
- **Decisions**: Record decisions with confidence levels as they are made,
|
|
291
446
|
not just at session end. Use `confirmed` for verified facts, `estimated`
|
|
292
447
|
for reasonable assumptions, `unverified` for unknowns.
|
|
448
|
+
- **Project memory**: Use `memory_save` to record durable lessons, rules,
|
|
449
|
+
conventions, and gotchas that every future session should know. Use
|
|
450
|
+
`memory_query` to retrieve relevant memories. Near-duplicate memories are
|
|
451
|
+
surfaced as conflicts for you to merge or force-save — never merged silently.
|
|
293
452
|
```
|
|
294
453
|
|
|
295
454
|
## Skill File (Optional)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "MCP server that gives AI coding agents persistent memory across sessions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# shellcheck shell=sh
|
|
2
|
+
# cargo-env.sh — make cargo commands work across heterogeneous dev environments.
|
|
3
|
+
#
|
|
4
|
+
# Some environments (e.g. a Docker image where CARGO_HOME=/usr/local/cargo is
|
|
5
|
+
# owned by root) expose a CARGO_HOME that the current user cannot write to.
|
|
6
|
+
# cargo then fails to download/cache crates or fetch the advisory-db, breaking
|
|
7
|
+
# the git hooks even though nothing is wrong with the code.
|
|
8
|
+
#
|
|
9
|
+
# This script detects an unwritable CARGO_HOME and transparently falls back to
|
|
10
|
+
# the per-user $HOME/.cargo, which every supported environment can write to.
|
|
11
|
+
# When CARGO_HOME is already writable (local macOS, a synced Mac, or simply
|
|
12
|
+
# unset so cargo uses its default ~/.cargo) it leaves the environment untouched.
|
|
13
|
+
#
|
|
14
|
+
# Usage (from a hook or shell):
|
|
15
|
+
# . scripts/cargo-env.sh && cargo <args>
|
|
16
|
+
|
|
17
|
+
# Resolve the directory cargo would use right now.
|
|
18
|
+
_cargo_home="${CARGO_HOME:-$HOME/.cargo}"
|
|
19
|
+
|
|
20
|
+
# Probe writability with a temp file; fall back to $HOME/.cargo if we can't write.
|
|
21
|
+
if ! ( mkdir -p "$_cargo_home" 2>/dev/null && touch "$_cargo_home/.cargo-env-wtest" 2>/dev/null ); then
|
|
22
|
+
export CARGO_HOME="$HOME/.cargo"
|
|
23
|
+
mkdir -p "$CARGO_HOME" 2>/dev/null || true
|
|
24
|
+
else
|
|
25
|
+
rm -f "$_cargo_home/.cargo-env-wtest" 2>/dev/null || true
|
|
26
|
+
export CARGO_HOME="$_cargo_home"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
unset _cargo_home
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Code hook wrapper for handoff-mcp project memory.
|
|
3
|
+
|
|
4
|
+
This is the **fallback path** for memory auto-injection. The preferred wiring is
|
|
5
|
+
a native ``mcp_tool`` hook that calls ``memory_query`` / ``memory_cleanup``
|
|
6
|
+
directly (see README "Project Memory"); this script exists for Claude Code
|
|
7
|
+
versions that lack the ``mcp_tool`` hook type, by translating a Claude Code hook
|
|
8
|
+
event into a single JSON-RPC ``tools/call`` against the handoff-mcp server and
|
|
9
|
+
emitting the result as ``hookSpecificOutput.additionalContext``.
|
|
10
|
+
|
|
11
|
+
It speaks the server's line-delimited JSON-RPC over stdio: one request object on
|
|
12
|
+
one line in, one response object on one line out. ``memory_query`` /
|
|
13
|
+
``memory_cleanup`` return their payload as a JSON *string* inside
|
|
14
|
+
``result.content[0].text`` (so both this wrapper and the native hook path parse
|
|
15
|
+
it identically), which this script re-parses.
|
|
16
|
+
|
|
17
|
+
Wiring (in ``~/.claude/settings.json`` — do NOT commit this into a repo):
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
"hooks": {
|
|
21
|
+
"UserPromptSubmit": [
|
|
22
|
+
{ "hooks": [ { "type": "command",
|
|
23
|
+
"command": "handoff-mcp-memory-hook" } ] }
|
|
24
|
+
],
|
|
25
|
+
"PreToolUse": [
|
|
26
|
+
{ "matcher": "Edit|Write|MultiEdit",
|
|
27
|
+
"hooks": [ { "type": "command",
|
|
28
|
+
"command": "handoff-mcp-memory-hook" } ] }
|
|
29
|
+
],
|
|
30
|
+
"SessionStart": [
|
|
31
|
+
{ "hooks": [ { "type": "command",
|
|
32
|
+
"command": "handoff-mcp-memory-hook" } ] }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
The same script handles all three events; it picks the tool from
|
|
38
|
+
``hook_event_name`` on stdin.
|
|
39
|
+
|
|
40
|
+
Environment:
|
|
41
|
+
- ``HANDOFF_MCP_BIN`` — path to the ``handoff-mcp`` binary (default: ``handoff-mcp``
|
|
42
|
+
resolved on ``PATH``).
|
|
43
|
+
|
|
44
|
+
Design contract: this script must **never break the session**. On any error
|
|
45
|
+
(binary missing, bad JSON, timeout) it prints nothing and exits 0 — a memory
|
|
46
|
+
miss is silent, never a blocked prompt.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# `X | None` annotations are evaluated lazily so this runs on Python 3.7+
|
|
50
|
+
# (a hook host may not have 3.10). The annotations are never evaluated at runtime.
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import shutil
|
|
56
|
+
import subprocess
|
|
57
|
+
import sys
|
|
58
|
+
|
|
59
|
+
# Map a Claude Code hook event to the memory tool it drives.
|
|
60
|
+
QUERY_EVENTS = {"UserPromptSubmit", "PreToolUse"}
|
|
61
|
+
CLEANUP_EVENTS = {"SessionStart"}
|
|
62
|
+
|
|
63
|
+
# How long to wait for the server to answer one request, in seconds. The query
|
|
64
|
+
# is in-memory and sub-millisecond; this is just a safety net so a hung binary
|
|
65
|
+
# can never stall the prompt.
|
|
66
|
+
TIMEOUT_SECONDS = 5.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _server_bin() -> str | None:
|
|
70
|
+
"""Resolve the handoff-mcp binary path, or None if it can't be found."""
|
|
71
|
+
explicit = os.environ.get("HANDOFF_MCP_BIN")
|
|
72
|
+
if explicit:
|
|
73
|
+
return explicit if os.path.exists(explicit) else None
|
|
74
|
+
return shutil.which("handoff-mcp")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _call(bin_path: str, tool: str, arguments: dict) -> dict | None:
|
|
78
|
+
"""Send one ``tools/call`` line and parse the inner JSON payload.
|
|
79
|
+
|
|
80
|
+
Returns the decoded inner object (e.g. ``{"memories": [...]}`), or None on
|
|
81
|
+
any transport / decode failure. The server is stateless per line, so no
|
|
82
|
+
``initialize`` handshake is needed — a bare ``tools/call`` is answered.
|
|
83
|
+
"""
|
|
84
|
+
request = {
|
|
85
|
+
"jsonrpc": "2.0",
|
|
86
|
+
"id": 1,
|
|
87
|
+
"method": "tools/call",
|
|
88
|
+
"params": {"name": tool, "arguments": arguments},
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
proc = subprocess.run(
|
|
92
|
+
[bin_path],
|
|
93
|
+
input=json.dumps(request) + "\n",
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=TIMEOUT_SECONDS,
|
|
97
|
+
)
|
|
98
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# The response is the first non-empty stdout line (stderr carries the
|
|
102
|
+
# startup banner and is ignored).
|
|
103
|
+
line = next((ln for ln in proc.stdout.splitlines() if ln.strip()), None)
|
|
104
|
+
if not line:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
envelope = json.loads(line)
|
|
108
|
+
text = envelope["result"]["content"][0]["text"]
|
|
109
|
+
return json.loads(text)
|
|
110
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _file_paths_from_tool_input(tool_input) -> list[str]:
|
|
115
|
+
"""Pull file paths out of a PreToolUse ``tool_input`` (Edit/Write/MultiEdit)."""
|
|
116
|
+
if not isinstance(tool_input, dict):
|
|
117
|
+
return []
|
|
118
|
+
paths = []
|
|
119
|
+
fp = tool_input.get("file_path")
|
|
120
|
+
if isinstance(fp, str) and fp:
|
|
121
|
+
paths.append(fp)
|
|
122
|
+
# MultiEdit and similar may carry an edits array; each can name a file.
|
|
123
|
+
for edit in tool_input.get("edits", []) or []:
|
|
124
|
+
if isinstance(edit, dict):
|
|
125
|
+
efp = edit.get("file_path")
|
|
126
|
+
if isinstance(efp, str) and efp:
|
|
127
|
+
paths.append(efp)
|
|
128
|
+
return paths
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _emit(event: str, context: str) -> None:
|
|
132
|
+
"""Print a Claude Code hook output object carrying additionalContext."""
|
|
133
|
+
if not context:
|
|
134
|
+
return
|
|
135
|
+
out = {
|
|
136
|
+
"hookSpecificOutput": {
|
|
137
|
+
"hookEventName": event,
|
|
138
|
+
"additionalContext": context,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
print(json.dumps(out))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _format_memories(payload: dict) -> str:
|
|
145
|
+
"""Turn a ``memory_query`` payload into an injectable context block."""
|
|
146
|
+
memories = payload.get("memories") or []
|
|
147
|
+
if not memories:
|
|
148
|
+
return ""
|
|
149
|
+
lines = ["Relevant project memories (handoff-mcp):"]
|
|
150
|
+
for m in memories:
|
|
151
|
+
text = (m.get("text") or "").strip()
|
|
152
|
+
if not text:
|
|
153
|
+
continue
|
|
154
|
+
kind = m.get("kind") or "memory"
|
|
155
|
+
lines.append(f"- [{kind}] {text}")
|
|
156
|
+
# Only a header with no bullets means nothing useful to inject.
|
|
157
|
+
return "\n".join(lines) if len(lines) > 1 else ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main() -> int:
|
|
161
|
+
raw = sys.stdin.read()
|
|
162
|
+
try:
|
|
163
|
+
hook = json.loads(raw) if raw.strip() else {}
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
return 0 # malformed hook input — stay silent, never block
|
|
166
|
+
|
|
167
|
+
event = hook.get("hook_event_name") or ""
|
|
168
|
+
bin_path = _server_bin()
|
|
169
|
+
if not bin_path:
|
|
170
|
+
return 0 # server not installed — nothing to inject
|
|
171
|
+
|
|
172
|
+
project_dir = hook.get("cwd") or os.getcwd()
|
|
173
|
+
session_id = hook.get("session_id")
|
|
174
|
+
|
|
175
|
+
if event in QUERY_EVENTS:
|
|
176
|
+
# UserPromptSubmit → match the prompt. PreToolUse → match the file(s).
|
|
177
|
+
prompt = hook.get("prompt") or ""
|
|
178
|
+
tool_name = hook.get("tool_name")
|
|
179
|
+
file_paths = _file_paths_from_tool_input(hook.get("tool_input"))
|
|
180
|
+
# Text fed to BM25: the prompt for UserPromptSubmit, else the file paths
|
|
181
|
+
# (the server also adds basenames + tool_name to the query).
|
|
182
|
+
text = prompt if prompt else " ".join(file_paths)
|
|
183
|
+
arguments = {"project_dir": project_dir, "text": text}
|
|
184
|
+
if session_id:
|
|
185
|
+
arguments["session_id"] = session_id
|
|
186
|
+
if tool_name:
|
|
187
|
+
arguments["tool_name"] = tool_name
|
|
188
|
+
if file_paths:
|
|
189
|
+
arguments["file_paths"] = file_paths
|
|
190
|
+
|
|
191
|
+
payload = _call(bin_path, "memory_query", arguments)
|
|
192
|
+
if payload:
|
|
193
|
+
_emit(event, _format_memories(payload))
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
if event in CLEANUP_EVENTS:
|
|
197
|
+
# Housekeeping only: merge exact duplicates and gc sidecars. We do not
|
|
198
|
+
# inject the cleanup recommendations as context (that is for an explicit
|
|
199
|
+
# AI-driven pass), so there is no additionalContext to emit here.
|
|
200
|
+
_call(bin_path, "memory_cleanup", {"project_dir": project_dir})
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
# Unknown event — nothing to do.
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(main())
|
|
@@ -47,6 +47,12 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
47
47
|
"settings.require_estimate_hours",
|
|
48
48
|
"settings.ai_estimate_multiplier",
|
|
49
49
|
"settings.context_files",
|
|
50
|
+
"settings.memory_enabled",
|
|
51
|
+
"settings.memory_dup_threshold",
|
|
52
|
+
"settings.memory_query_min_score",
|
|
53
|
+
"settings.memory_query_limit",
|
|
54
|
+
"settings.memory_stale_days",
|
|
55
|
+
"settings.memory_injected_gc_days",
|
|
50
56
|
"dashboard.scan_dirs",
|
|
51
57
|
"dashboard.exclude_patterns",
|
|
52
58
|
"project.name",
|
|
@@ -116,6 +122,63 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
116
122
|
));
|
|
117
123
|
}
|
|
118
124
|
}
|
|
125
|
+
"settings.memory_enabled" => {
|
|
126
|
+
let Some(b) = value.as_bool() else {
|
|
127
|
+
anyhow::bail!("settings.memory_enabled must be a boolean");
|
|
128
|
+
};
|
|
129
|
+
config.settings.memory_enabled = b;
|
|
130
|
+
applied.push(format!("settings.memory_enabled = {b}"));
|
|
131
|
+
}
|
|
132
|
+
"settings.memory_dup_threshold" => {
|
|
133
|
+
let Some(n) = value.as_f64() else {
|
|
134
|
+
anyhow::bail!("settings.memory_dup_threshold must be a number");
|
|
135
|
+
};
|
|
136
|
+
if !(0.0..=1.0).contains(&n) {
|
|
137
|
+
anyhow::bail!("settings.memory_dup_threshold must be between 0 and 1");
|
|
138
|
+
}
|
|
139
|
+
config.settings.memory_dup_threshold = n;
|
|
140
|
+
applied.push(format!("settings.memory_dup_threshold = {n}"));
|
|
141
|
+
}
|
|
142
|
+
"settings.memory_query_min_score" => {
|
|
143
|
+
let Some(n) = value.as_f64() else {
|
|
144
|
+
anyhow::bail!("settings.memory_query_min_score must be a number");
|
|
145
|
+
};
|
|
146
|
+
if n < 0.0 {
|
|
147
|
+
anyhow::bail!("settings.memory_query_min_score must be >= 0");
|
|
148
|
+
}
|
|
149
|
+
config.settings.memory_query_min_score = n;
|
|
150
|
+
applied.push(format!("settings.memory_query_min_score = {n}"));
|
|
151
|
+
}
|
|
152
|
+
"settings.memory_query_limit" => {
|
|
153
|
+
let Some(n) = value.as_u64() else {
|
|
154
|
+
anyhow::bail!("settings.memory_query_limit must be a non-negative integer");
|
|
155
|
+
};
|
|
156
|
+
if n == 0 {
|
|
157
|
+
anyhow::bail!("settings.memory_query_limit must be >= 1");
|
|
158
|
+
}
|
|
159
|
+
config.settings.memory_query_limit = n as u32;
|
|
160
|
+
applied.push(format!("settings.memory_query_limit = {n}"));
|
|
161
|
+
}
|
|
162
|
+
"settings.memory_stale_days" => {
|
|
163
|
+
let Some(n) = value.as_i64() else {
|
|
164
|
+
anyhow::bail!("settings.memory_stale_days must be an integer");
|
|
165
|
+
};
|
|
166
|
+
if n < 0 {
|
|
167
|
+
anyhow::bail!("settings.memory_stale_days must be >= 0");
|
|
168
|
+
}
|
|
169
|
+
config.settings.memory_stale_days = n;
|
|
170
|
+
applied.push(format!("settings.memory_stale_days = {n}"));
|
|
171
|
+
}
|
|
172
|
+
"settings.memory_injected_gc_days" => {
|
|
173
|
+
let Some(n) = value.as_i64() else {
|
|
174
|
+
anyhow::bail!("settings.memory_injected_gc_days must be an integer");
|
|
175
|
+
};
|
|
176
|
+
if n < 0 {
|
|
177
|
+
anyhow::bail!("settings.memory_injected_gc_days must be >= 0");
|
|
178
|
+
}
|
|
179
|
+
config.settings.memory_injected_gc_days = n;
|
|
180
|
+
applied.push(format!("settings.memory_injected_gc_days = {n}"));
|
|
181
|
+
}
|
|
119
182
|
"dashboard.scan_dirs" => {
|
|
120
183
|
if let Some(arr) = value.as_array() {
|
|
121
184
|
config.dashboard.scan_dirs = arr
|
package/src/mcp/handlers/init.rs
CHANGED
|
@@ -21,7 +21,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
21
21
|
|
|
22
22
|
Ok(format!(
|
|
23
23
|
"Initialized handoff tracking for '{}' at {}/.handoff/\n\
|
|
24
|
-
Created: config.toml, sessions/, tasks/",
|
|
24
|
+
Created: config.toml, sessions/, tasks/, memory/",
|
|
25
25
|
project_name,
|
|
26
26
|
project_dir.display()
|
|
27
27
|
))
|