Skip to content

Commit 6c55dbd

Browse files
authored
feat(claude-code): add knowledge tools via Python MCP server + claude/claude-code harnesses (#1428)
Plugin changes (hindsight-integrations/claude-code/): - Add scripts/mcp_server.py — Python FastMCP stdio server exposing 7 agent_knowledge_* tools (list/get/create/update/delete pages, recall, ingest). Each tool accepts optional bank_id parameter. - Add scripts/inject_bank_id.py — PreToolUse hook that intercepts mcp__hindsight__agent_knowledge_* calls and injects bank_id from session context (cwd, agentName) via updatedInput. - Add .mcp.json — plugin MCP server config (stdio transport) - Add skills/agent-knowledge/SKILL.md - Add enableKnowledgeTools config flag (MCP server exits if disabled) - Make client.request() public (was _request) - Bump plugin to v0.5.0 CLI changes (hindsight-tools/self-driving-agents/): - Re-add --harness claude (Chat/Cowork skill zip generation, lost in hermes PR merge) - Add --harness claude-code (marketplace install, config, knowledge tools) - Add tests for both harnesses
1 parent bc23750 commit 6c55dbd

11 files changed

Lines changed: 890 additions & 10 deletions

File tree

hindsight-integrations/claude-code/.claude-plugin/plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "hindsight-memory",
3-
"description": "Automatic long-term memory for Claude Code via Hindsight. Recalls relevant memories before each prompt and retains conversation transcripts after each response.",
4-
"version": "0.4.0",
3+
"description": "Automatic long-term memory for Claude Code via Hindsight. Recalls relevant memories before each prompt, retains conversation transcripts, and provides knowledge page tools.",
4+
"version": "0.5.0",
55
"author": {"name": "Hindsight Team", "url": "https://vectorize.io/hindsight"},
66
"license": "MIT",
77
"keywords": ["memory", "hindsight", "recall", "retain"]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"mcpServers": {
3+
"hindsight": {
4+
"command": "python3",
5+
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/mcp_server.py"]
6+
}
7+
}
8+
}

hindsight-integrations/claude-code/hooks/hooks.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@
2222
]
2323
}
2424
],
25+
"PreToolUse": [
26+
{
27+
"matcher": "mcp__hindsight__agent_knowledge_.*",
28+
"hooks": [
29+
{
30+
"type": "command",
31+
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/inject_bank_id.py\"",
32+
"timeout": 3
33+
}
34+
]
35+
}
36+
],
2537
"Stop": [
2638
{
2739
"hooks": [
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env python3
2+
"""PreToolUse hook: inject bank_id into agent_knowledge_* MCP tool calls.
3+
4+
Intercepts mcp__hindsight__agent_knowledge_* tool calls and injects
5+
the resolved bank_id into tool_input, using the same derivation logic
6+
as the recall/retain hooks (config chain + cwd context).
7+
8+
Exit codes:
9+
0 — always (allow the tool call to proceed)
10+
"""
11+
12+
import json
13+
import sys
14+
import os
15+
16+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17+
18+
from lib.bank import derive_bank_id
19+
from lib.config import debug_log, load_config
20+
21+
22+
def main():
23+
try:
24+
hook_input = json.load(sys.stdin)
25+
except (json.JSONDecodeError, EOFError):
26+
return
27+
28+
tool_input = hook_input.get("tool_input", {})
29+
30+
# Skip if bank_id already provided (explicit override)
31+
if tool_input.get("bank_id"):
32+
return
33+
34+
config = load_config()
35+
36+
if not config.get("enableKnowledgeTools"):
37+
return
38+
39+
bank_id = derive_bank_id(hook_input, config)
40+
debug_log(config, f"Injecting bank_id={bank_id} into {hook_input.get('tool_name')}")
41+
42+
# Return updatedInput with bank_id injected
43+
updated = dict(tool_input)
44+
updated["bank_id"] = bank_id
45+
46+
output = {
47+
"hookSpecificOutput": {
48+
"hookEventName": "PreToolUse",
49+
"permissionDecision": "allow",
50+
"updatedInput": updated,
51+
}
52+
}
53+
json.dump(output, sys.stdout)
54+
55+
56+
if __name__ == "__main__":
57+
try:
58+
main()
59+
except Exception as e:
60+
print(f"[Hindsight] inject_bank_id error: {e}", file=sys.stderr)
61+
sys.exit(0)

hindsight-integrations/claude-code/scripts/lib/client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def _headers(self) -> dict:
5757
headers["Authorization"] = f"Bearer {self.api_token}"
5858
return headers
5959

60-
def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
60+
def request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
6161
url = f"{self.api_url}{path}"
6262
data = json.dumps(body).encode() if body else None
6363
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
@@ -115,7 +115,7 @@ def recall(
115115
body["budget"] = budget
116116
if types:
117117
body["types"] = types
118-
return self._request("POST", path, body, timeout=timeout)
118+
return self.request("POST", path, body, timeout=timeout)
119119

120120
def retain(
121121
self,
@@ -147,7 +147,7 @@ def retain(
147147
"items": [item],
148148
"async": True,
149149
}
150-
return self._request("POST", path, body, timeout=timeout)
150+
return self.request("POST", path, body, timeout=timeout)
151151

152152
def set_bank_mission(
153153
self, bank_id: str, mission: str, retain_mission: Optional[str] = None, timeout: int = 15
@@ -161,4 +161,4 @@ def set_bank_mission(
161161
updates = {"reflect_mission": mission}
162162
if retain_mission:
163163
updates["retain_mission"] = retain_mission
164-
return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
164+
return self.request("PATCH", path, {"updates": updates}, timeout=timeout)
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python3
2+
"""Hindsight MCP server for Claude Code plugin.
3+
4+
Runs as a stdio subprocess managed by the plugin system.
5+
Exposes knowledge tools (list/get/create/update/delete pages, recall, ingest).
6+
Reuses the existing plugin config chain and client.
7+
8+
Each tool accepts an optional bank_id parameter. When omitted, falls back to the
9+
default bank derived from config at startup. The PreToolUse hook (inject_bank_id.py)
10+
injects bank_id from session context (cwd, agentName) before calls reach here.
11+
"""
12+
13+
import json
14+
import os
15+
import sys
16+
import urllib.parse
17+
18+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19+
20+
from mcp.server.fastmcp import FastMCP
21+
22+
from lib.client import HindsightClient
23+
from lib.config import debug_log, load_config
24+
from lib.daemon import get_api_url
25+
from lib.bank import derive_bank_id
26+
27+
# ── Server setup ────────────────────────────────────────
28+
29+
mcp = FastMCP("hindsight")
30+
31+
# Resolve config at startup
32+
_config = load_config()
33+
_dbg = lambda *a: debug_log(_config, *a)
34+
35+
if not _config.get("enableKnowledgeTools"):
36+
_dbg("Knowledge tools disabled (enableKnowledgeTools=false), MCP server exiting")
37+
sys.exit(0)
38+
39+
try:
40+
_api_url = get_api_url(_config, debug_fn=_dbg, allow_daemon_start=True)
41+
except Exception as e:
42+
print(f"[Hindsight MCP] Failed to resolve API URL: {e}", file=sys.stderr)
43+
sys.exit(1)
44+
45+
_hook_input = {"cwd": os.getcwd(), "session_id": ""}
46+
_default_bank_id = derive_bank_id(_hook_input, _config)
47+
_client = HindsightClient(_api_url, _config.get("hindsightApiToken"))
48+
49+
_dbg(f"MCP server starting — API: {_api_url}, default bank: {_default_bank_id}")
50+
51+
52+
def _bank(bank_id: str = "") -> str:
53+
"""Resolve bank ID: use explicit value if provided, else fall back to default."""
54+
return bank_id if bank_id else _default_bank_id
55+
56+
57+
def _encode_bank(bank_id: str) -> str:
58+
return urllib.parse.quote(bank_id, safe="")
59+
60+
61+
# ── Mental model defaults ───────────────────────────────
62+
63+
PAGE_DEFAULTS = {
64+
"mode": "delta",
65+
"refresh_after_consolidation": True,
66+
"fact_types": ["observation"],
67+
"exclude_mental_models": True,
68+
}
69+
70+
# ── Tools ───────────────────────────────────────────────
71+
72+
73+
@mcp.tool()
74+
def agent_knowledge_list_pages(bank_id: str = "") -> str:
75+
"""List all your knowledge pages (IDs and names only). Use agent_knowledge_get_page to read the full content of a specific page."""
76+
bid = _bank(bank_id)
77+
resp = _client.request("GET", f"/v1/default/banks/{_encode_bank(bid)}/mental-models", timeout=10)
78+
return json.dumps(resp, indent=2)
79+
80+
81+
@mcp.tool()
82+
def agent_knowledge_get_page(page_id: str, bank_id: str = "") -> str:
83+
"""Read a specific knowledge page by its ID. Returns the full synthesized content."""
84+
bid = _bank(bank_id)
85+
resp = _client.request(
86+
"GET", f"/v1/default/banks/{_encode_bank(bid)}/mental-models/{page_id}?detail=full", timeout=10
87+
)
88+
return json.dumps(resp, indent=2)
89+
90+
91+
@mcp.tool()
92+
def agent_knowledge_create_page(page_id: str, name: str, source_query: str, bank_id: str = "") -> str:
93+
"""Create a new knowledge page. The source_query is a question the system re-asks after each consolidation to rebuild the page from conversation observations. Pages auto-update as you have more conversations."""
94+
bid = _bank(bank_id)
95+
resp = _client.request(
96+
"POST",
97+
f"/v1/default/banks/{_encode_bank(bid)}/mental-models",
98+
body={
99+
"id": page_id,
100+
"name": name,
101+
"source_query": source_query,
102+
"max_tokens": 4096,
103+
"trigger": PAGE_DEFAULTS,
104+
},
105+
timeout=15,
106+
)
107+
return json.dumps(resp, indent=2)
108+
109+
110+
@mcp.tool()
111+
def agent_knowledge_update_page(page_id: str, name: str = "", source_query: str = "", bank_id: str = "") -> str:
112+
"""Update a page's name or source query. The content will re-synthesize on next consolidation."""
113+
body = {}
114+
if name:
115+
body["name"] = name
116+
if source_query:
117+
body["source_query"] = source_query
118+
if not body:
119+
return json.dumps({"error": "Provide name or source_query to update"})
120+
bid = _bank(bank_id)
121+
resp = _client.request(
122+
"PATCH", f"/v1/default/banks/{_encode_bank(bid)}/mental-models/{page_id}", body=body, timeout=10
123+
)
124+
return json.dumps(resp, indent=2)
125+
126+
127+
@mcp.tool()
128+
def agent_knowledge_delete_page(page_id: str, bank_id: str = "") -> str:
129+
"""Permanently delete a knowledge page."""
130+
bid = _bank(bank_id)
131+
resp = _client.request("DELETE", f"/v1/default/banks/{_encode_bank(bid)}/mental-models/{page_id}", timeout=10)
132+
return json.dumps(resp, indent=2)
133+
134+
135+
@mcp.tool()
136+
def agent_knowledge_recall(query: str, max_results: int = 10, bank_id: str = "") -> str:
137+
"""Search across all retained conversations and documents for specific facts, numbers, or details not covered by your knowledge pages."""
138+
bid = _bank(bank_id)
139+
resp = _client.recall(bank_id=bid, query=query, max_tokens=max_results, budget="mid", timeout=10)
140+
return json.dumps(resp, indent=2)
141+
142+
143+
@mcp.tool()
144+
def agent_knowledge_ingest(title: str, content: str, bank_id: str = "") -> str:
145+
"""Upload a document into your memory bank. Pass the full raw content — never summarize before ingesting. The title becomes the document ID (re-ingesting replaces it)."""
146+
bid = _bank(bank_id)
147+
doc_id = title.lower().replace(" ", "-")
148+
resp = _client.retain(bank_id=bid, content=content, document_id=doc_id, timeout=15)
149+
return json.dumps(resp, indent=2)
150+
151+
152+
# ── Entry point ─────────────────────────────────────────
153+
154+
if __name__ == "__main__":
155+
mcp.run(transport="stdio")

0 commit comments

Comments
 (0)