cortex-claude 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- cortex_claude-0.3.0/.claude/hooks/post-tool-use.sh +117 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/PKG-INFO +42 -11
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/README.md +41 -10
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/pyproject.toml +1 -1
- cortex_claude-0.3.0/src/cortex_claude/__init__.py +1 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/__main__.py +7 -3
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/core/engine.py +6 -0
- cortex_claude-0.3.0/src/cortex_claude/core/privacy.py +21 -0
- cortex_claude-0.3.0/src/cortex_claude/setup.py +418 -0
- cortex_claude-0.3.0/tests/integration/test_privacy.py +48 -0
- cortex_claude-0.3.0/tests/unit/test_privacy.py +57 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/uv.lock +1 -1
- cortex_claude-0.2.0/.claude/hooks/post-tool-use.sh +0 -61
- cortex_claude-0.2.0/src/cortex_claude/__init__.py +0 -1
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/.claude/hooks/session-start.sh +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/.github/workflows/ci.yml +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/.github/workflows/publish.yml +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/.gitignore +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/ARCHITECTURE.md +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/CONTRIBUTING.md +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/LICENSE +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/docs/logo.png +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/examples/config.json +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/examples/mcp-global.json +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/examples/mcp-project.json +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/scripts/benchmark.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/scripts/demo.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/core/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/core/decay.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/core/scope_manager.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/core/token_budget.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/daemon.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/embeddings/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/embeddings/engine.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/embeddings/tokenizer.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/facts/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/facts/claude_extract.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/facts/extractor.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/facts/normalizer.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/facts/patterns.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/facts/spacy_extract.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/models/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/models/fact.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/models/memory.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/models/scope.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/pipeline/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/app.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/config.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/tools/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/tools/facts.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/tools/forget.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/tools/recall.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/tools/save.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/tools/scopes.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/tools/status.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/server/tools/traverse.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/storage/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/storage/database.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/storage/fact_repo.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/storage/memory_repo.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/storage/migrations.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/summarizer/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/summarizer/extractive.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/src/cortex_claude/summarizer/scoring.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/conftest.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/integration/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/integration/test_forget_scopes_status.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/integration/test_fts_and_decay.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/integration/test_graph_traversal.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/integration/test_progressive_recall.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/integration/test_save_recall.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/integration/test_storage.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/__init__.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_claude_extract.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_config.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_decay.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_facts.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_normalizer.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_patterns.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_scope_manager.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_summarizer.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_token_budget.py +0 -0
- {cortex_claude-0.2.0 → cortex_claude-0.3.0}/tests/unit/test_tokenizer.py +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Cortex Claude — PostToolUse hook
|
|
3
|
+
# Auto-captures all tool results to Cortex memory via daemon.
|
|
4
|
+
|
|
5
|
+
CORTEX_HOME="${CORTEX_HOME:-$HOME/.cortex-claude}"
|
|
6
|
+
SOCKET="$CORTEX_HOME/cortex.sock"
|
|
7
|
+
|
|
8
|
+
[ ! -S "$SOCKET" ] && exit 0
|
|
9
|
+
|
|
10
|
+
# Read stdin BEFORE backgrounding
|
|
11
|
+
INPUT=$(cat)
|
|
12
|
+
|
|
13
|
+
# Background the processing
|
|
14
|
+
(echo "$INPUT" | python3 -c "
|
|
15
|
+
import json, socket, sys, os
|
|
16
|
+
|
|
17
|
+
data = json.load(sys.stdin)
|
|
18
|
+
tool = data.get('tool_name', '')
|
|
19
|
+
|
|
20
|
+
if 'cortex' in tool.lower():
|
|
21
|
+
sys.exit(0)
|
|
22
|
+
|
|
23
|
+
ti = data.get('tool_input', {})
|
|
24
|
+
tr = str(data.get('tool_response', ''))[:1000]
|
|
25
|
+
cwd = data.get('cwd', '.')
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
def strip_private(t):
|
|
29
|
+
t = re.sub(r'<private>.*?</private>', '', t, flags=re.DOTALL|re.IGNORECASE)
|
|
30
|
+
return re.sub(r'<private\s*/?>', '', t, flags=re.IGNORECASE).strip()
|
|
31
|
+
|
|
32
|
+
tr = strip_private(tr)
|
|
33
|
+
for k, v in list(ti.items()):
|
|
34
|
+
if isinstance(v, str):
|
|
35
|
+
ti[k] = strip_private(v)
|
|
36
|
+
|
|
37
|
+
if len(tr) < 20:
|
|
38
|
+
sys.exit(0)
|
|
39
|
+
|
|
40
|
+
content = None
|
|
41
|
+
tags = ['auto-capture']
|
|
42
|
+
|
|
43
|
+
if tool == 'Bash':
|
|
44
|
+
cmd = str(ti.get('command', ''))[:150]
|
|
45
|
+
noise = ('ls', 'cd ', 'pwd', 'echo ', 'which ', 'cat ', 'head ', 'tail ', 'mkdir', 'touch ', 'chmod', 'true', 'false', 'test ')
|
|
46
|
+
if any(cmd.startswith(n) for n in noise) or not cmd:
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
content = f'Command: {cmd}\nResult: {tr[:500]}'
|
|
49
|
+
tags.append('bash')
|
|
50
|
+
|
|
51
|
+
elif tool == 'Read':
|
|
52
|
+
path = ti.get('file_path', '')
|
|
53
|
+
content = f'Read file: {path}\nPreview: {tr[:300]}'
|
|
54
|
+
tags.append('file-read')
|
|
55
|
+
|
|
56
|
+
elif tool in ('Edit', 'Write'):
|
|
57
|
+
path = ti.get('file_path', '')
|
|
58
|
+
old = str(ti.get('old_string', ''))[:100]
|
|
59
|
+
new = str(ti.get('new_string', ''))[:100]
|
|
60
|
+
if old and new:
|
|
61
|
+
content = f'Edited {path}: replaced \"{old}\" with \"{new}\"'
|
|
62
|
+
elif tool == 'Write':
|
|
63
|
+
content = f'Created file: {path}'
|
|
64
|
+
else:
|
|
65
|
+
content = f'Modified file: {path}'
|
|
66
|
+
tags.append('file-change')
|
|
67
|
+
|
|
68
|
+
elif tool == 'Grep':
|
|
69
|
+
pattern = ti.get('pattern', '')
|
|
70
|
+
path = ti.get('path', '')
|
|
71
|
+
content = f'Search \"{pattern}\" in {path}\nResults: {tr[:500]}'
|
|
72
|
+
tags.append('search')
|
|
73
|
+
|
|
74
|
+
elif tool == 'Glob':
|
|
75
|
+
pattern = ti.get('pattern', '')
|
|
76
|
+
content = f'File search: {pattern}\nFound: {tr[:500]}'
|
|
77
|
+
tags.append('search')
|
|
78
|
+
|
|
79
|
+
elif tool == 'Agent':
|
|
80
|
+
prompt = str(ti.get('prompt', ''))[:200]
|
|
81
|
+
content = f'Agent task: {prompt}\nResult: {tr[:500]}'
|
|
82
|
+
tags.append('agent')
|
|
83
|
+
|
|
84
|
+
elif tool.startswith('mcp__'):
|
|
85
|
+
parts = tool.split('__')
|
|
86
|
+
server = parts[1] if len(parts) > 1 else 'unknown'
|
|
87
|
+
method = parts[2] if len(parts) > 2 else 'unknown'
|
|
88
|
+
content = f'MCP {server}.{method}\nInput: {json.dumps(ti)[:200]}\nResult: {tr[:400]}'
|
|
89
|
+
tags.append('mcp')
|
|
90
|
+
tags.append(server)
|
|
91
|
+
|
|
92
|
+
elif tool in ('WebSearch', 'WebFetch'):
|
|
93
|
+
query = ti.get('query', ti.get('url', ''))
|
|
94
|
+
content = f'{tool}: {query}\nResult: {tr[:500]}'
|
|
95
|
+
tags.append('web')
|
|
96
|
+
|
|
97
|
+
else:
|
|
98
|
+
content = f'{tool}: {json.dumps(ti)[:150]}\nResult: {tr[:400]}'
|
|
99
|
+
tags.append(tool.lower())
|
|
100
|
+
|
|
101
|
+
if not content:
|
|
102
|
+
sys.exit(0)
|
|
103
|
+
|
|
104
|
+
sock_path = os.path.expanduser('$SOCKET')
|
|
105
|
+
try:
|
|
106
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
107
|
+
s.settimeout(3)
|
|
108
|
+
s.connect(sock_path)
|
|
109
|
+
s.sendall(json.dumps({'action':'save','content':content,'tags':tags,'cwd':cwd}).encode())
|
|
110
|
+
s.shutdown(socket.SHUT_WR)
|
|
111
|
+
s.recv(512)
|
|
112
|
+
s.close()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
") &
|
|
116
|
+
|
|
117
|
+
exit 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortex-claude
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Local-first, token-efficient memory system for Claude Code via MCP
|
|
5
5
|
Project-URL: Homepage, https://github.com/rafaelaugusto/cortex-claude
|
|
6
6
|
Project-URL: Repository, https://github.com/rafaelaugusto/cortex-claude
|
|
@@ -51,7 +51,7 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License">
|
|
52
52
|
</a>
|
|
53
53
|
<a href="pyproject.toml">
|
|
54
|
-
<img src="https://img.shields.io/badge/version-0.
|
|
54
|
+
<img src="https://img.shields.io/badge/version-0.3.0-green.svg" alt="Version">
|
|
55
55
|
</a>
|
|
56
56
|
<a href="pyproject.toml">
|
|
57
57
|
<img src="https://img.shields.io/badge/python-%3E%3D3.11-brightgreen.svg" alt="Python">
|
|
@@ -113,39 +113,50 @@ The system stops at the cheapest layer that answers the question. **66% fewer to
|
|
|
113
113
|
- **Background daemon** — pre-loaded model for instant saves via Unix socket (~0.3s vs ~5s cold start)
|
|
114
114
|
- **Multi-language** — EN, PT (auto-detected). ES, DE, FR with additional spaCy models
|
|
115
115
|
- **Local-first** — SQLite + local embeddings + local NLP. Zero API calls, zero network, zero cost
|
|
116
|
+
- **Privacy tags** — wrap sensitive content in `<private>...</private>` to exclude it from memory
|
|
116
117
|
- **Fully configurable** — all thresholds, ratios, and behaviors via config.json
|
|
117
118
|
|
|
118
119
|
---
|
|
119
120
|
|
|
120
121
|
## Quick Start
|
|
121
122
|
|
|
122
|
-
### Install
|
|
123
|
+
### One-Command Install
|
|
123
124
|
|
|
124
125
|
```bash
|
|
125
|
-
pip install cortex-claude
|
|
126
|
+
pip install cortex-claude && cortex-claude setup
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
That's it. The setup command:
|
|
130
|
+
- Configures the MCP server globally for Claude Code
|
|
131
|
+
- Installs auto-capture hooks (SessionStart + PostToolUse)
|
|
132
|
+
- Creates `~/.claude/CLAUDE.md` with instructions for Claude to use Cortex automatically
|
|
133
|
+
- Downloads the embedding model (~80MB) and spaCy model (~12MB)
|
|
134
|
+
- Starts the background daemon for instant saves
|
|
135
|
+
|
|
136
|
+
Restart Claude Code and it works in **every project**, no per-project config needed. Claude will automatically consult Cortex before saying "I don't know" and save important context to memory.
|
|
137
|
+
|
|
138
|
+
### With Claude-assisted extraction (optional)
|
|
126
139
|
|
|
127
|
-
|
|
140
|
+
```bash
|
|
128
141
|
pip install cortex-claude[claude]
|
|
129
142
|
```
|
|
130
143
|
|
|
131
|
-
###
|
|
144
|
+
### Manual Setup (alternative)
|
|
132
145
|
|
|
133
|
-
|
|
146
|
+
If you prefer manual configuration, add a `.mcp.json` to your project root:
|
|
134
147
|
|
|
135
148
|
```json
|
|
136
149
|
{
|
|
137
150
|
"mcpServers": {
|
|
138
151
|
"cortex": {
|
|
139
152
|
"type": "stdio",
|
|
140
|
-
"command": "
|
|
153
|
+
"command": "python3",
|
|
141
154
|
"args": ["-m", "cortex_claude"]
|
|
142
155
|
}
|
|
143
156
|
}
|
|
144
157
|
}
|
|
145
158
|
```
|
|
146
159
|
|
|
147
|
-
First run downloads the embedding model (~80MB) and spaCy model (~12MB) automatically.
|
|
148
|
-
|
|
149
160
|
### Use
|
|
150
161
|
|
|
151
162
|
Just talk to Claude naturally. Cortex works in the background:
|
|
@@ -229,6 +240,26 @@ score = e^(-lambda * days) * (1 + log(access_count))
|
|
|
229
240
|
|
|
230
241
|
Recalculated on server startup. Frequently accessed memories get boosted. Stale ones fade.
|
|
231
242
|
|
|
243
|
+
### Auto-Capture & Hooks
|
|
244
|
+
|
|
245
|
+
Cortex uses Claude Code hooks to work automatically:
|
|
246
|
+
|
|
247
|
+
- **SessionStart** — injects memory stats and known facts when you open a session. Claude knows it has memory and consults it before saying "I don't know".
|
|
248
|
+
- **PostToolUse** — captures results from **all tools** in background: Bash, Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch, and all third-party MCP tools. No manual save needed.
|
|
249
|
+
- **Background Daemon** — keeps the embedding model pre-loaded via Unix socket. First save after boot: ~5s (model load). Subsequent saves: **~0.3s**.
|
|
250
|
+
|
|
251
|
+
All hooks are installed globally by `cortex-claude setup`. No per-project config needed.
|
|
252
|
+
|
|
253
|
+
### Privacy
|
|
254
|
+
|
|
255
|
+
Wrap sensitive content in `<private>` tags to exclude it from memory:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
The API uses JWT for auth. <private>API_KEY=sk-abc123secret</private> Tokens expire in 24h.
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Cortex strips everything between `<private>...</private>` before saving. Works in both manual saves and auto-capture. If the entire content is private, nothing is saved.
|
|
262
|
+
|
|
232
263
|
### Hybrid Search
|
|
233
264
|
|
|
234
265
|
Combines **vector similarity** (semantic meaning) with **FTS5** (exact keyword match) for best recall. FTS5 synced automatically via SQLite triggers.
|
|
@@ -250,7 +281,7 @@ Most memory solutions for AI assistants follow the same pattern: capture observa
|
|
|
250
281
|
| **Graph navigation** | None | Multi-hop traversal (A → B → C) |
|
|
251
282
|
| **Staleness** | No decay | Unused memories lose relevance over time |
|
|
252
283
|
| **Duplicates** | Can accumulate | Auto-merged (cosine similarity > 0.92) |
|
|
253
|
-
| **Auto-capture** | All tool calls via hooks | Bash, Read, Grep,
|
|
284
|
+
| **Auto-capture** | All tool calls via hooks | All tools (Bash, Read, Edit, Write, Grep, Glob, Agent, MCP, Web) via hooks + daemon |
|
|
254
285
|
| **Session injection** | Full context dump on start | Lightweight facts injection via direct SQLite (<1s) |
|
|
255
286
|
| **Dependencies** | Node.js, Bun, Chroma vector DB | Python + SQLite only (zero external services) |
|
|
256
287
|
| **Web UI** | Viewer on localhost:37777 | CLI only (planned) |
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License">
|
|
14
14
|
</a>
|
|
15
15
|
<a href="pyproject.toml">
|
|
16
|
-
<img src="https://img.shields.io/badge/version-0.
|
|
16
|
+
<img src="https://img.shields.io/badge/version-0.3.0-green.svg" alt="Version">
|
|
17
17
|
</a>
|
|
18
18
|
<a href="pyproject.toml">
|
|
19
19
|
<img src="https://img.shields.io/badge/python-%3E%3D3.11-brightgreen.svg" alt="Python">
|
|
@@ -75,39 +75,50 @@ The system stops at the cheapest layer that answers the question. **66% fewer to
|
|
|
75
75
|
- **Background daemon** — pre-loaded model for instant saves via Unix socket (~0.3s vs ~5s cold start)
|
|
76
76
|
- **Multi-language** — EN, PT (auto-detected). ES, DE, FR with additional spaCy models
|
|
77
77
|
- **Local-first** — SQLite + local embeddings + local NLP. Zero API calls, zero network, zero cost
|
|
78
|
+
- **Privacy tags** — wrap sensitive content in `<private>...</private>` to exclude it from memory
|
|
78
79
|
- **Fully configurable** — all thresholds, ratios, and behaviors via config.json
|
|
79
80
|
|
|
80
81
|
---
|
|
81
82
|
|
|
82
83
|
## Quick Start
|
|
83
84
|
|
|
84
|
-
### Install
|
|
85
|
+
### One-Command Install
|
|
85
86
|
|
|
86
87
|
```bash
|
|
87
|
-
pip install cortex-claude
|
|
88
|
+
pip install cortex-claude && cortex-claude setup
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
That's it. The setup command:
|
|
92
|
+
- Configures the MCP server globally for Claude Code
|
|
93
|
+
- Installs auto-capture hooks (SessionStart + PostToolUse)
|
|
94
|
+
- Creates `~/.claude/CLAUDE.md` with instructions for Claude to use Cortex automatically
|
|
95
|
+
- Downloads the embedding model (~80MB) and spaCy model (~12MB)
|
|
96
|
+
- Starts the background daemon for instant saves
|
|
97
|
+
|
|
98
|
+
Restart Claude Code and it works in **every project**, no per-project config needed. Claude will automatically consult Cortex before saying "I don't know" and save important context to memory.
|
|
99
|
+
|
|
100
|
+
### With Claude-assisted extraction (optional)
|
|
88
101
|
|
|
89
|
-
|
|
102
|
+
```bash
|
|
90
103
|
pip install cortex-claude[claude]
|
|
91
104
|
```
|
|
92
105
|
|
|
93
|
-
###
|
|
106
|
+
### Manual Setup (alternative)
|
|
94
107
|
|
|
95
|
-
|
|
108
|
+
If you prefer manual configuration, add a `.mcp.json` to your project root:
|
|
96
109
|
|
|
97
110
|
```json
|
|
98
111
|
{
|
|
99
112
|
"mcpServers": {
|
|
100
113
|
"cortex": {
|
|
101
114
|
"type": "stdio",
|
|
102
|
-
"command": "
|
|
115
|
+
"command": "python3",
|
|
103
116
|
"args": ["-m", "cortex_claude"]
|
|
104
117
|
}
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
```
|
|
108
121
|
|
|
109
|
-
First run downloads the embedding model (~80MB) and spaCy model (~12MB) automatically.
|
|
110
|
-
|
|
111
122
|
### Use
|
|
112
123
|
|
|
113
124
|
Just talk to Claude naturally. Cortex works in the background:
|
|
@@ -191,6 +202,26 @@ score = e^(-lambda * days) * (1 + log(access_count))
|
|
|
191
202
|
|
|
192
203
|
Recalculated on server startup. Frequently accessed memories get boosted. Stale ones fade.
|
|
193
204
|
|
|
205
|
+
### Auto-Capture & Hooks
|
|
206
|
+
|
|
207
|
+
Cortex uses Claude Code hooks to work automatically:
|
|
208
|
+
|
|
209
|
+
- **SessionStart** — injects memory stats and known facts when you open a session. Claude knows it has memory and consults it before saying "I don't know".
|
|
210
|
+
- **PostToolUse** — captures results from **all tools** in background: Bash, Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch, and all third-party MCP tools. No manual save needed.
|
|
211
|
+
- **Background Daemon** — keeps the embedding model pre-loaded via Unix socket. First save after boot: ~5s (model load). Subsequent saves: **~0.3s**.
|
|
212
|
+
|
|
213
|
+
All hooks are installed globally by `cortex-claude setup`. No per-project config needed.
|
|
214
|
+
|
|
215
|
+
### Privacy
|
|
216
|
+
|
|
217
|
+
Wrap sensitive content in `<private>` tags to exclude it from memory:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
The API uses JWT for auth. <private>API_KEY=sk-abc123secret</private> Tokens expire in 24h.
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Cortex strips everything between `<private>...</private>` before saving. Works in both manual saves and auto-capture. If the entire content is private, nothing is saved.
|
|
224
|
+
|
|
194
225
|
### Hybrid Search
|
|
195
226
|
|
|
196
227
|
Combines **vector similarity** (semantic meaning) with **FTS5** (exact keyword match) for best recall. FTS5 synced automatically via SQLite triggers.
|
|
@@ -212,7 +243,7 @@ Most memory solutions for AI assistants follow the same pattern: capture observa
|
|
|
212
243
|
| **Graph navigation** | None | Multi-hop traversal (A → B → C) |
|
|
213
244
|
| **Staleness** | No decay | Unused memories lose relevance over time |
|
|
214
245
|
| **Duplicates** | Can accumulate | Auto-merged (cosine similarity > 0.92) |
|
|
215
|
-
| **Auto-capture** | All tool calls via hooks | Bash, Read, Grep,
|
|
246
|
+
| **Auto-capture** | All tool calls via hooks | All tools (Bash, Read, Edit, Write, Grep, Glob, Agent, MCP, Web) via hooks + daemon |
|
|
216
247
|
| **Session injection** | Full context dump on start | Lightweight facts injection via direct SQLite (<1s) |
|
|
217
248
|
| **Dependencies** | Node.js, Bun, Chroma vector DB | Python + SQLite only (zero external services) |
|
|
218
249
|
| **Web UI** | Viewer on localhost:37777 | CLI only (planned) |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
|
|
3
|
-
from cortex_claude.server.app import mcp, _init_engine
|
|
4
|
-
|
|
5
3
|
|
|
6
4
|
def main() -> None:
|
|
7
|
-
if len(sys.argv) > 1
|
|
5
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
6
|
+
|
|
7
|
+
if cmd == "setup":
|
|
8
|
+
from cortex_claude.setup import run_setup
|
|
9
|
+
run_setup()
|
|
10
|
+
elif cmd == "daemon":
|
|
8
11
|
_run_daemon()
|
|
9
12
|
else:
|
|
13
|
+
from cortex_claude.server.app import mcp, _init_engine
|
|
10
14
|
_init_engine()
|
|
11
15
|
mcp.run(transport="stdio")
|
|
12
16
|
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Literal
|
|
7
7
|
|
|
8
8
|
from cortex_claude.core.decay import recalculate_decay_scores
|
|
9
|
+
from cortex_claude.core.privacy import is_fully_private, strip_private
|
|
9
10
|
from cortex_claude.core.scope_manager import ScopeManager
|
|
10
11
|
from cortex_claude.core.token_budget import TokenBudget
|
|
11
12
|
from cortex_claude.embeddings import EmbeddingEngine, count_tokens
|
|
@@ -58,6 +59,11 @@ class CortexEngine:
|
|
|
58
59
|
scope: str | None = None,
|
|
59
60
|
cwd: str = ".",
|
|
60
61
|
) -> SaveResult:
|
|
62
|
+
if is_fully_private(content):
|
|
63
|
+
return SaveResult(memory_id="", scope="", tokens_stored=0)
|
|
64
|
+
|
|
65
|
+
content = strip_private(content)
|
|
66
|
+
|
|
61
67
|
write_scope = scope or self._scope_manager.get_write_scope(cwd)
|
|
62
68
|
conn = self._storage.get_database(write_scope)
|
|
63
69
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
PRIVATE_PATTERN = re.compile(r"<private>.*?</private>", re.DOTALL | re.IGNORECASE)
|
|
6
|
+
PRIVATE_BLOCK_PATTERN = re.compile(r"<private\s*/?>", re.IGNORECASE)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def strip_private(text: str) -> str:
|
|
10
|
+
text = PRIVATE_PATTERN.sub("", text)
|
|
11
|
+
text = PRIVATE_BLOCK_PATTERN.sub("", text)
|
|
12
|
+
return text.strip()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def contains_private(text: str) -> bool:
|
|
16
|
+
return bool(PRIVATE_PATTERN.search(text) or PRIVATE_BLOCK_PATTERN.search(text))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_fully_private(text: str) -> bool:
|
|
20
|
+
stripped = strip_private(text)
|
|
21
|
+
return len(stripped) == 0
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
CORTEX_HOME = Path(os.environ.get("CORTEX_HOME", str(Path.home() / ".cortex-claude")))
|
|
12
|
+
CLAUDE_CONFIG = Path.home() / ".claude.json"
|
|
13
|
+
CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
14
|
+
CLAUDE_MD = Path.home() / ".claude" / "CLAUDE.md"
|
|
15
|
+
HOOKS_DIR = CORTEX_HOME / "hooks"
|
|
16
|
+
|
|
17
|
+
CLAUDE_MD_CONTENT = """# Cortex Memory
|
|
18
|
+
|
|
19
|
+
You have access to **Cortex**, a persistent memory system with a knowledge graph. Use it.
|
|
20
|
+
|
|
21
|
+
## Rules
|
|
22
|
+
|
|
23
|
+
1. **Before saying "I don't know"** — always check Cortex first with `cortex_recall` or `cortex_facts`.
|
|
24
|
+
2. **Save important context** — when you learn something significant about the project, user preferences, architecture decisions, or bugs, save it with `cortex_save`.
|
|
25
|
+
3. **Use facts for quick lookups** — `cortex_facts` returns structured triplets at minimal token cost. Use it for "what does X use?" type questions.
|
|
26
|
+
4. **Use traverse for exploration** — `cortex_traverse` follows entity connections. Use it to discover related information you didn't know to ask about.
|
|
27
|
+
5. **Respect privacy** — never save content wrapped in `<private>...</private>` tags. Cortex strips these automatically, but be mindful of sensitive data.
|
|
28
|
+
|
|
29
|
+
## Available Tools
|
|
30
|
+
|
|
31
|
+
| Tool | When to use |
|
|
32
|
+
|------|------------|
|
|
33
|
+
| `cortex_save` | Save information worth remembering across sessions |
|
|
34
|
+
| `cortex_recall` | Search memories by meaning (semantic + keyword) |
|
|
35
|
+
| `cortex_facts` | Quick structured lookup from knowledge graph |
|
|
36
|
+
| `cortex_traverse` | Explore connections: start from entity, follow links |
|
|
37
|
+
| `cortex_forget` | Delete outdated or wrong memories (dry-run first) |
|
|
38
|
+
| `cortex_scopes` | Manage memory scopes (list, create, link) |
|
|
39
|
+
| `cortex_status` | Check memory stats (count, size, scopes) |
|
|
40
|
+
|
|
41
|
+
## What to Save
|
|
42
|
+
|
|
43
|
+
- Architecture decisions and their rationale
|
|
44
|
+
- User preferences and conventions
|
|
45
|
+
- Bug context and root causes
|
|
46
|
+
- Project-specific terminology
|
|
47
|
+
- Environment and deployment details
|
|
48
|
+
- API contracts and integrations
|
|
49
|
+
|
|
50
|
+
## What NOT to Save
|
|
51
|
+
|
|
52
|
+
- Code that's already in files (it's in the repo)
|
|
53
|
+
- Temporary debugging output
|
|
54
|
+
- Content marked with `<private>` tags
|
|
55
|
+
- Information that changes every commit (use git for that)
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
SESSION_START_HOOK = '''#!/bin/bash
|
|
59
|
+
CORTEX_HOME="${CORTEX_HOME:-$HOME/.cortex-claude}"
|
|
60
|
+
GLOBAL_DB="$CORTEX_HOME/global.db"
|
|
61
|
+
SOCKET="$CORTEX_HOME/cortex.sock"
|
|
62
|
+
|
|
63
|
+
if [ ! -S "$SOCKET" ]; then
|
|
64
|
+
python3 -c "
|
|
65
|
+
import sys
|
|
66
|
+
sys.path.insert(0, '.')
|
|
67
|
+
try:
|
|
68
|
+
from cortex_claude.daemon import ensure_running
|
|
69
|
+
ensure_running()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
" 2>/dev/null &
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
[ ! -f "$GLOBAL_DB" ] && exit 0
|
|
76
|
+
|
|
77
|
+
CONTEXT=$(python3 -c "
|
|
78
|
+
import sqlite3, os, glob
|
|
79
|
+
|
|
80
|
+
cortex_home = os.path.expanduser('${CORTEX_HOME:-~/.cortex-claude}')
|
|
81
|
+
dbs = []
|
|
82
|
+
|
|
83
|
+
global_db = os.path.join(cortex_home, 'global.db')
|
|
84
|
+
if os.path.exists(global_db):
|
|
85
|
+
dbs.append(('global', global_db))
|
|
86
|
+
|
|
87
|
+
scopes_dir = os.path.join(cortex_home, 'scopes')
|
|
88
|
+
if os.path.isdir(scopes_dir):
|
|
89
|
+
for f in glob.glob(os.path.join(scopes_dir, '*.db')):
|
|
90
|
+
name = os.path.basename(f).replace('.db','').replace('__',':')
|
|
91
|
+
dbs.append((name, f))
|
|
92
|
+
|
|
93
|
+
if not dbs:
|
|
94
|
+
exit(0)
|
|
95
|
+
|
|
96
|
+
total_memories = 0
|
|
97
|
+
total_facts = 0
|
|
98
|
+
recent_facts = []
|
|
99
|
+
|
|
100
|
+
for scope_name, db_path in dbs:
|
|
101
|
+
try:
|
|
102
|
+
conn = sqlite3.connect(db_path)
|
|
103
|
+
conn.row_factory = sqlite3.Row
|
|
104
|
+
row = conn.execute('SELECT COUNT(*) as c FROM memories').fetchone()
|
|
105
|
+
total_memories += row[0]
|
|
106
|
+
row = conn.execute('SELECT COUNT(*) as c FROM facts').fetchone()
|
|
107
|
+
total_facts += row[0]
|
|
108
|
+
rows = conn.execute(
|
|
109
|
+
'SELECT subject, relation, object FROM facts ORDER BY confidence DESC, created_at DESC LIMIT 15'
|
|
110
|
+
).fetchall()
|
|
111
|
+
for r in rows:
|
|
112
|
+
recent_facts.append(f'{r[0]} -> {r[1]} -> {r[2]}')
|
|
113
|
+
conn.close()
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
if total_memories == 0:
|
|
118
|
+
exit(0)
|
|
119
|
+
|
|
120
|
+
lines = [
|
|
121
|
+
'You have access to Cortex persistent memory (cortex_save, cortex_recall, cortex_facts, cortex_traverse, cortex_forget, cortex_scopes, cortex_status).',
|
|
122
|
+
'IMPORTANT: Always use cortex_recall or cortex_facts BEFORE saying you do not know something.',
|
|
123
|
+
f'Memory: {total_memories} memories, {total_facts} facts, {len(dbs)} scope(s).',
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
if recent_facts:
|
|
127
|
+
lines.append('')
|
|
128
|
+
lines.append('Known facts:')
|
|
129
|
+
for f in recent_facts[:10]:
|
|
130
|
+
lines.append(f' {f}')
|
|
131
|
+
|
|
132
|
+
print(chr(10).join(lines))
|
|
133
|
+
" 2>/dev/null)
|
|
134
|
+
|
|
135
|
+
if [ -n "$CONTEXT" ]; then
|
|
136
|
+
jq -n --arg ctx "$CONTEXT" '{
|
|
137
|
+
hookSpecificOutput: {
|
|
138
|
+
hookEventName: "SessionStart",
|
|
139
|
+
additionalContext: $ctx
|
|
140
|
+
}
|
|
141
|
+
}'
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
exit 0
|
|
145
|
+
'''
|
|
146
|
+
|
|
147
|
+
POST_TOOL_USE_HOOK = '''#!/bin/bash
|
|
148
|
+
CORTEX_HOME="${CORTEX_HOME:-$HOME/.cortex-claude}"
|
|
149
|
+
SOCKET="$CORTEX_HOME/cortex.sock"
|
|
150
|
+
|
|
151
|
+
[ ! -S "$SOCKET" ] && exit 0
|
|
152
|
+
|
|
153
|
+
INPUT=$(cat)
|
|
154
|
+
|
|
155
|
+
(echo "$INPUT" | python3 -c "
|
|
156
|
+
import json, socket, sys, os
|
|
157
|
+
|
|
158
|
+
data = json.load(sys.stdin)
|
|
159
|
+
tool = data.get('tool_name', '')
|
|
160
|
+
|
|
161
|
+
if 'cortex' in tool.lower():
|
|
162
|
+
sys.exit(0)
|
|
163
|
+
|
|
164
|
+
ti = data.get('tool_input', {})
|
|
165
|
+
tr = str(data.get('tool_response', ''))[:1000]
|
|
166
|
+
cwd = data.get('cwd', '.')
|
|
167
|
+
|
|
168
|
+
import re
|
|
169
|
+
def strip_private(t):
|
|
170
|
+
t = re.sub(r'<private>.*?</private>', '', t, flags=re.DOTALL|re.IGNORECASE)
|
|
171
|
+
return re.sub(r'<private\\s*/?>', '', t, flags=re.IGNORECASE).strip()
|
|
172
|
+
|
|
173
|
+
tr = strip_private(tr)
|
|
174
|
+
for k, v in list(ti.items()):
|
|
175
|
+
if isinstance(v, str):
|
|
176
|
+
ti[k] = strip_private(v)
|
|
177
|
+
|
|
178
|
+
if len(tr) < 20:
|
|
179
|
+
sys.exit(0)
|
|
180
|
+
|
|
181
|
+
content = None
|
|
182
|
+
tags = ['auto-capture']
|
|
183
|
+
|
|
184
|
+
if tool == 'Bash':
|
|
185
|
+
cmd = str(ti.get('command', ''))[:150]
|
|
186
|
+
noise = ('ls', 'cd ', 'pwd', 'echo ', 'which ', 'cat ', 'head ', 'tail ', 'mkdir', 'touch ', 'chmod', 'true', 'false', 'test ')
|
|
187
|
+
if any(cmd.startswith(n) for n in noise) or not cmd:
|
|
188
|
+
sys.exit(0)
|
|
189
|
+
content = f'Command: {cmd}' + chr(10) + f'Result: {tr[:500]}'
|
|
190
|
+
tags.append('bash')
|
|
191
|
+
elif tool == 'Read':
|
|
192
|
+
path = ti.get('file_path', '')
|
|
193
|
+
content = f'Read file: {path}' + chr(10) + f'Preview: {tr[:300]}'
|
|
194
|
+
tags.append('file-read')
|
|
195
|
+
elif tool in ('Edit', 'Write'):
|
|
196
|
+
path = ti.get('file_path', '')
|
|
197
|
+
old = str(ti.get('old_string', ''))[:100]
|
|
198
|
+
new = str(ti.get('new_string', ''))[:100]
|
|
199
|
+
if old and new:
|
|
200
|
+
content = f'Edited {path}: replaced \\\"{old}\\\" with \\\"{new}\\\"'
|
|
201
|
+
elif tool == 'Write':
|
|
202
|
+
content = f'Created file: {path}'
|
|
203
|
+
else:
|
|
204
|
+
content = f'Modified file: {path}'
|
|
205
|
+
tags.append('file-change')
|
|
206
|
+
elif tool == 'Grep':
|
|
207
|
+
pattern = ti.get('pattern', '')
|
|
208
|
+
path = ti.get('path', '')
|
|
209
|
+
content = f'Search \\\"{pattern}\\\" in {path}' + chr(10) + f'Results: {tr[:500]}'
|
|
210
|
+
tags.append('search')
|
|
211
|
+
elif tool == 'Glob':
|
|
212
|
+
pattern = ti.get('pattern', '')
|
|
213
|
+
content = f'File search: {pattern}' + chr(10) + f'Found: {tr[:500]}'
|
|
214
|
+
tags.append('search')
|
|
215
|
+
elif tool == 'Agent':
|
|
216
|
+
prompt = str(ti.get('prompt', ''))[:200]
|
|
217
|
+
content = f'Agent task: {prompt}' + chr(10) + f'Result: {tr[:500]}'
|
|
218
|
+
tags.append('agent')
|
|
219
|
+
elif tool.startswith('mcp__'):
|
|
220
|
+
parts = tool.split('__')
|
|
221
|
+
server = parts[1] if len(parts) > 1 else 'unknown'
|
|
222
|
+
method = parts[2] if len(parts) > 2 else 'unknown'
|
|
223
|
+
content = f'MCP {server}.{method}' + chr(10) + f'Input: {json.dumps(ti)[:200]}' + chr(10) + f'Result: {tr[:400]}'
|
|
224
|
+
tags.append('mcp')
|
|
225
|
+
tags.append(server)
|
|
226
|
+
elif tool in ('WebSearch', 'WebFetch'):
|
|
227
|
+
query = ti.get('query', ti.get('url', ''))
|
|
228
|
+
content = f'{tool}: {query}' + chr(10) + f'Result: {tr[:500]}'
|
|
229
|
+
tags.append('web')
|
|
230
|
+
else:
|
|
231
|
+
content = f'{tool}: {json.dumps(ti)[:150]}' + chr(10) + f'Result: {tr[:400]}'
|
|
232
|
+
tags.append(tool.lower())
|
|
233
|
+
|
|
234
|
+
if not content:
|
|
235
|
+
sys.exit(0)
|
|
236
|
+
|
|
237
|
+
sock_path = os.path.expanduser('$SOCKET')
|
|
238
|
+
try:
|
|
239
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
240
|
+
s.settimeout(3)
|
|
241
|
+
s.connect(sock_path)
|
|
242
|
+
s.sendall(json.dumps({'action':'save','content':content,'tags':tags,'cwd':cwd}).encode())
|
|
243
|
+
s.shutdown(socket.SHUT_WR)
|
|
244
|
+
s.recv(512)
|
|
245
|
+
s.close()
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
") &
|
|
249
|
+
|
|
250
|
+
exit 0
|
|
251
|
+
'''
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _print(msg: str) -> None:
|
|
255
|
+
print(f" {msg}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _ok(msg: str) -> None:
|
|
259
|
+
print(f" [ok] {msg}")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _skip(msg: str) -> None:
|
|
263
|
+
print(f" [skip] {msg}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def run_setup() -> None:
|
|
267
|
+
print()
|
|
268
|
+
print(" Cortex Claude Setup")
|
|
269
|
+
print(" ====================")
|
|
270
|
+
print()
|
|
271
|
+
|
|
272
|
+
# 1. Create data directory
|
|
273
|
+
CORTEX_HOME.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
(CORTEX_HOME / "scopes").mkdir(exist_ok=True)
|
|
275
|
+
_ok(f"Data directory: {CORTEX_HOME}")
|
|
276
|
+
|
|
277
|
+
# 2. Install hooks
|
|
278
|
+
HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
|
|
280
|
+
session_start = HOOKS_DIR / "session-start.sh"
|
|
281
|
+
session_start.write_text(SESSION_START_HOOK)
|
|
282
|
+
session_start.chmod(session_start.stat().st_mode | stat.S_IEXEC)
|
|
283
|
+
|
|
284
|
+
post_tool_use = HOOKS_DIR / "post-tool-use.sh"
|
|
285
|
+
post_tool_use.write_text(POST_TOOL_USE_HOOK)
|
|
286
|
+
post_tool_use.chmod(post_tool_use.stat().st_mode | stat.S_IEXEC)
|
|
287
|
+
|
|
288
|
+
_ok(f"Hooks installed: {HOOKS_DIR}")
|
|
289
|
+
|
|
290
|
+
# 3. Configure Claude Code MCP (global)
|
|
291
|
+
mcp_config = {}
|
|
292
|
+
if CLAUDE_CONFIG.exists():
|
|
293
|
+
try:
|
|
294
|
+
with open(CLAUDE_CONFIG) as f:
|
|
295
|
+
mcp_config = json.load(f)
|
|
296
|
+
except json.JSONDecodeError:
|
|
297
|
+
mcp_config = {}
|
|
298
|
+
|
|
299
|
+
mcp_config.setdefault("mcpServers", {})["cortex"] = {
|
|
300
|
+
"type": "stdio",
|
|
301
|
+
"command": "python3",
|
|
302
|
+
"args": ["-m", "cortex_claude"],
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
with open(CLAUDE_CONFIG, "w") as f:
|
|
306
|
+
json.dump(mcp_config, f, indent=2)
|
|
307
|
+
|
|
308
|
+
_ok(f"MCP server configured: {CLAUDE_CONFIG}")
|
|
309
|
+
|
|
310
|
+
# 4. Configure hooks in Claude settings (global)
|
|
311
|
+
CLAUDE_SETTINGS.parent.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
|
|
313
|
+
settings = {}
|
|
314
|
+
if CLAUDE_SETTINGS.exists():
|
|
315
|
+
try:
|
|
316
|
+
with open(CLAUDE_SETTINGS) as f:
|
|
317
|
+
settings = json.load(f)
|
|
318
|
+
except json.JSONDecodeError:
|
|
319
|
+
settings = {}
|
|
320
|
+
|
|
321
|
+
hooks = settings.setdefault("hooks", {})
|
|
322
|
+
|
|
323
|
+
hooks["SessionStart"] = [
|
|
324
|
+
{
|
|
325
|
+
"matcher": "",
|
|
326
|
+
"hooks": [
|
|
327
|
+
{
|
|
328
|
+
"type": "command",
|
|
329
|
+
"command": str(session_start),
|
|
330
|
+
"timeout": 10,
|
|
331
|
+
}
|
|
332
|
+
],
|
|
333
|
+
}
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
hooks["PostToolUse"] = [
|
|
337
|
+
{
|
|
338
|
+
"matcher": "",
|
|
339
|
+
"hooks": [
|
|
340
|
+
{
|
|
341
|
+
"type": "command",
|
|
342
|
+
"command": str(post_tool_use),
|
|
343
|
+
"async": True,
|
|
344
|
+
"timeout": 30,
|
|
345
|
+
}
|
|
346
|
+
],
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
with open(CLAUDE_SETTINGS, "w") as f:
|
|
351
|
+
json.dump(settings, f, indent=2)
|
|
352
|
+
|
|
353
|
+
_ok(f"Hooks configured: {CLAUDE_SETTINGS}")
|
|
354
|
+
|
|
355
|
+
# 5. Create/update CLAUDE.md
|
|
356
|
+
CLAUDE_MD.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
|
|
358
|
+
cortex_marker = "# Cortex Memory"
|
|
359
|
+
if CLAUDE_MD.exists():
|
|
360
|
+
existing = CLAUDE_MD.read_text()
|
|
361
|
+
if cortex_marker in existing:
|
|
362
|
+
_skip(f"CLAUDE.md already has Cortex instructions")
|
|
363
|
+
else:
|
|
364
|
+
with open(CLAUDE_MD, "a") as f:
|
|
365
|
+
f.write("\n\n" + CLAUDE_MD_CONTENT)
|
|
366
|
+
_ok(f"Cortex instructions appended to {CLAUDE_MD}")
|
|
367
|
+
else:
|
|
368
|
+
CLAUDE_MD.write_text(CLAUDE_MD_CONTENT)
|
|
369
|
+
_ok(f"CLAUDE.md created: {CLAUDE_MD}")
|
|
370
|
+
|
|
371
|
+
# 7. Download spaCy model
|
|
372
|
+
_print("Downloading spaCy model (en_core_web_sm)...")
|
|
373
|
+
try:
|
|
374
|
+
import spacy
|
|
375
|
+
try:
|
|
376
|
+
spacy.load("en_core_web_sm")
|
|
377
|
+
_skip("spaCy model already installed")
|
|
378
|
+
except OSError:
|
|
379
|
+
subprocess.run(
|
|
380
|
+
[sys.executable, "-m", "spacy", "download", "en_core_web_sm"],
|
|
381
|
+
capture_output=True,
|
|
382
|
+
)
|
|
383
|
+
_ok("spaCy model downloaded")
|
|
384
|
+
except ImportError:
|
|
385
|
+
_print("spaCy not installed, skipping model download")
|
|
386
|
+
|
|
387
|
+
# 8. Pre-warm embedding model
|
|
388
|
+
_print("Downloading embedding model (all-MiniLM-L6-v2)...")
|
|
389
|
+
try:
|
|
390
|
+
from sentence_transformers import SentenceTransformer
|
|
391
|
+
SentenceTransformer("all-MiniLM-L6-v2")
|
|
392
|
+
_ok("Embedding model ready")
|
|
393
|
+
except Exception as e:
|
|
394
|
+
_print(f"Could not pre-load embedding model: {e}")
|
|
395
|
+
|
|
396
|
+
# 9. Start daemon
|
|
397
|
+
_print("Starting Cortex daemon...")
|
|
398
|
+
try:
|
|
399
|
+
from cortex_claude.daemon import ensure_running, is_running
|
|
400
|
+
ensure_running()
|
|
401
|
+
if is_running():
|
|
402
|
+
_ok("Daemon running")
|
|
403
|
+
else:
|
|
404
|
+
_print("Daemon may take a few seconds to start")
|
|
405
|
+
except Exception as e:
|
|
406
|
+
_print(f"Could not start daemon: {e}")
|
|
407
|
+
|
|
408
|
+
# Done
|
|
409
|
+
print()
|
|
410
|
+
print(" Setup complete!")
|
|
411
|
+
print()
|
|
412
|
+
print(" Cortex is now configured globally for Claude Code.")
|
|
413
|
+
print(" Restart Claude Code to activate.")
|
|
414
|
+
print()
|
|
415
|
+
print(" Usage:")
|
|
416
|
+
print(" Just talk to Claude — memories are captured automatically.")
|
|
417
|
+
print(" Use 'cortex_recall', 'cortex_facts', 'cortex_traverse' to query.")
|
|
418
|
+
print()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from cortex_claude.core.engine import CortexEngine
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.asyncio
|
|
9
|
+
async def test_save_strips_private_content(engine: CortexEngine):
|
|
10
|
+
await engine.save(
|
|
11
|
+
content="The API uses JWT. <private>API_KEY=sk-abc123</private> Auth works well."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
result = await engine.recall(query="API JWT auth", depth="full", max_tokens=500)
|
|
15
|
+
for m in result.memories:
|
|
16
|
+
assert "sk-abc123" not in m.content
|
|
17
|
+
assert "API_KEY" not in m.content
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.asyncio
|
|
21
|
+
async def test_save_fully_private_is_skipped(engine: CortexEngine):
|
|
22
|
+
result = await engine.save(content="<private>Everything is secret</private>")
|
|
23
|
+
assert result.memory_id == ""
|
|
24
|
+
assert result.tokens_stored == 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_save_preserves_public_content(engine: CortexEngine):
|
|
29
|
+
await engine.save(
|
|
30
|
+
content="Database is PostgreSQL. <private>password=hunter2</private> Uses port 5432."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
result = await engine.recall(query="database", depth="full", max_tokens=500)
|
|
34
|
+
assert len(result.memories) > 0
|
|
35
|
+
has_postgres = any("PostgreSQL" in m.content for m in result.memories)
|
|
36
|
+
assert has_postgres
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_facts_dont_contain_private(engine: CortexEngine):
|
|
41
|
+
await engine.save(
|
|
42
|
+
content="The service uses Redis. <private>REDIS_URL=redis://secret:6379</private>"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
facts = await engine.get_facts(topic="redis")
|
|
46
|
+
for f in facts:
|
|
47
|
+
assert "secret" not in f.subject
|
|
48
|
+
assert "secret" not in f.object
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from cortex_claude.core.privacy import contains_private, is_fully_private, strip_private
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_strip_private_inline():
|
|
5
|
+
text = "The API key is <private>sk-abc123xyz</private> and it works."
|
|
6
|
+
result = strip_private(text)
|
|
7
|
+
assert "sk-abc123xyz" not in result
|
|
8
|
+
assert "API key" in result
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_strip_private_multiline():
|
|
12
|
+
text = "Public info.\n<private>\nSECRET_KEY=abc\nDB_PASSWORD=xyz\n</private>\nMore public."
|
|
13
|
+
result = strip_private(text)
|
|
14
|
+
assert "SECRET_KEY" not in result
|
|
15
|
+
assert "DB_PASSWORD" not in result
|
|
16
|
+
assert "Public info" in result
|
|
17
|
+
assert "More public" in result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_strip_private_multiple():
|
|
21
|
+
text = "A <private>secret1</private> B <private>secret2</private> C"
|
|
22
|
+
result = strip_private(text)
|
|
23
|
+
assert "secret1" not in result
|
|
24
|
+
assert "secret2" not in result
|
|
25
|
+
assert "A" in result
|
|
26
|
+
assert "B" in result
|
|
27
|
+
assert "C" in result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_strip_private_self_closing():
|
|
31
|
+
text = "Before <private/> After"
|
|
32
|
+
result = strip_private(text)
|
|
33
|
+
assert "Before" in result
|
|
34
|
+
assert "After" in result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_strip_private_case_insensitive():
|
|
38
|
+
text = "Data <PRIVATE>hidden</PRIVATE> visible"
|
|
39
|
+
result = strip_private(text)
|
|
40
|
+
assert "hidden" not in result
|
|
41
|
+
assert "visible" in result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_contains_private():
|
|
45
|
+
assert contains_private("Has <private>secret</private> here") is True
|
|
46
|
+
assert contains_private("No secrets here") is False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_is_fully_private():
|
|
50
|
+
assert is_fully_private("<private>all secret</private>") is True
|
|
51
|
+
assert is_fully_private("public <private>secret</private>") is False
|
|
52
|
+
assert is_fully_private("no tags at all") is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_strip_private_no_tags():
|
|
56
|
+
text = "Normal text without any tags"
|
|
57
|
+
assert strip_private(text) == text
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Cortex Claude — PostToolUse hook
|
|
3
|
-
# Sends tool results to daemon via Unix socket.
|
|
4
|
-
# Uses only stdlib Python — no cortex_claude imports, instant startup.
|
|
5
|
-
|
|
6
|
-
CORTEX_HOME="${CORTEX_HOME:-$HOME/.cortex-claude}"
|
|
7
|
-
SOCKET="$CORTEX_HOME/cortex.sock"
|
|
8
|
-
|
|
9
|
-
# No daemon = no save
|
|
10
|
-
[ ! -S "$SOCKET" ] && exit 0
|
|
11
|
-
|
|
12
|
-
# Pipe stdin to a lightweight Python script in background
|
|
13
|
-
python3 -c "
|
|
14
|
-
import json, socket, sys, os
|
|
15
|
-
|
|
16
|
-
data = json.load(sys.stdin)
|
|
17
|
-
tool = data.get('tool_name', '')
|
|
18
|
-
|
|
19
|
-
if tool not in ('Bash', 'Read', 'Grep', 'Edit', 'Write'):
|
|
20
|
-
sys.exit(0)
|
|
21
|
-
if tool.startswith('mcp__cortex') or tool.startswith('cortex_'):
|
|
22
|
-
sys.exit(0)
|
|
23
|
-
|
|
24
|
-
ti = data.get('tool_input', {})
|
|
25
|
-
tr = str(data.get('tool_response', ''))[:800]
|
|
26
|
-
cwd = data.get('cwd', '.')
|
|
27
|
-
|
|
28
|
-
if len(tr) < 30:
|
|
29
|
-
sys.exit(0)
|
|
30
|
-
|
|
31
|
-
if tool == 'Bash':
|
|
32
|
-
cmd = str(ti.get('command', ''))[:100]
|
|
33
|
-
if cmd.split()[0] in ('ls','cd','pwd','echo','which','cat','head','tail') if cmd else True:
|
|
34
|
-
sys.exit(0)
|
|
35
|
-
content = 'Command: ' + cmd + '\nResult: ' + tr[:400]
|
|
36
|
-
tags = ['auto-capture', 'bash']
|
|
37
|
-
elif tool == 'Read':
|
|
38
|
-
content = 'Read file: ' + ti.get('file_path', '')
|
|
39
|
-
tags = ['auto-capture', 'file-read']
|
|
40
|
-
elif tool in ('Edit', 'Write'):
|
|
41
|
-
content = 'Modified file: ' + ti.get('file_path', '')
|
|
42
|
-
tags = ['auto-capture', 'file-change']
|
|
43
|
-
elif tool == 'Grep':
|
|
44
|
-
content = 'Search: ' + ti.get('pattern', '') + '\nResults: ' + tr[:400]
|
|
45
|
-
tags = ['auto-capture', 'search']
|
|
46
|
-
else:
|
|
47
|
-
sys.exit(0)
|
|
48
|
-
|
|
49
|
-
try:
|
|
50
|
-
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
51
|
-
s.settimeout(3)
|
|
52
|
-
s.connect(os.path.expanduser('$SOCKET'))
|
|
53
|
-
s.sendall(json.dumps({'action':'save','content':content,'tags':tags,'cwd':cwd}).encode())
|
|
54
|
-
s.shutdown(socket.SHUT_WR)
|
|
55
|
-
s.recv(512)
|
|
56
|
-
s.close()
|
|
57
|
-
except Exception:
|
|
58
|
-
pass
|
|
59
|
-
" &
|
|
60
|
-
|
|
61
|
-
exit 0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|