crossmem 0.1.0__py3-none-any.whl
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.
- crossmem/__init__.py +3 -0
- crossmem/cli.py +125 -0
- crossmem/graph.py +347 -0
- crossmem/ingest.py +160 -0
- crossmem/server.py +219 -0
- crossmem/store.py +234 -0
- crossmem/sync.py +200 -0
- crossmem-0.1.0.dist-info/METADATA +184 -0
- crossmem-0.1.0.dist-info/RECORD +12 -0
- crossmem-0.1.0.dist-info/WHEEL +4 -0
- crossmem-0.1.0.dist-info/entry_points.txt +4 -0
- crossmem-0.1.0.dist-info/licenses/LICENSE +21 -0
crossmem/__init__.py
ADDED
crossmem/cli.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""CLI interface for crossmem."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from crossmem.ingest import ingest_claude_memory, ingest_gemini_memory
|
|
6
|
+
from crossmem.store import DEFAULT_DB_PATH, MemoryStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
@click.version_option()
|
|
11
|
+
def main() -> None:
|
|
12
|
+
"""Cross-project memory for AI coding agents."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@main.command()
|
|
16
|
+
def ingest() -> None:
|
|
17
|
+
"""Ingest memory files from AI coding tools."""
|
|
18
|
+
store = MemoryStore()
|
|
19
|
+
try:
|
|
20
|
+
click.echo("Ingesting Claude Code memories...")
|
|
21
|
+
added = ingest_claude_memory(store)
|
|
22
|
+
click.echo("Ingesting Gemini CLI memories...")
|
|
23
|
+
added += ingest_gemini_memory(store)
|
|
24
|
+
total = store.count()
|
|
25
|
+
stats = store.stats()
|
|
26
|
+
|
|
27
|
+
click.echo(f"\nAdded {added} new memories ({total} total)")
|
|
28
|
+
click.echo(f"Database: {DEFAULT_DB_PATH}")
|
|
29
|
+
click.echo(f"\nProjects ({len(stats)}):")
|
|
30
|
+
for project, count in stats.items():
|
|
31
|
+
click.echo(f" {project}: {count} memories")
|
|
32
|
+
finally:
|
|
33
|
+
store.close()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command()
|
|
37
|
+
@click.argument("query")
|
|
38
|
+
@click.option("-p", "--project", default=None, help="Filter by project name")
|
|
39
|
+
@click.option("-n", "--limit", default=10, help="Max results")
|
|
40
|
+
def search(query: str, project: str | None, limit: int) -> None:
|
|
41
|
+
"""Search across all project memories."""
|
|
42
|
+
store = MemoryStore()
|
|
43
|
+
try:
|
|
44
|
+
results = store.search(query, limit=limit, project=project)
|
|
45
|
+
|
|
46
|
+
if not results:
|
|
47
|
+
click.echo(f'No results for "{query}"')
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
click.echo(f'Found {len(results)} results for "{query}":\n')
|
|
51
|
+
for i, result in enumerate(results, 1):
|
|
52
|
+
mem = result.memory
|
|
53
|
+
click.echo(f"[{i}] {mem.project} / {mem.section or '(root)'}")
|
|
54
|
+
click.echo(f" Source: {mem.source_file.split('/')[-1]}")
|
|
55
|
+
click.echo(f" {mem.snippet}")
|
|
56
|
+
click.echo()
|
|
57
|
+
finally:
|
|
58
|
+
store.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@main.command()
|
|
62
|
+
@click.option("--port", default=8765, help="Port for local server")
|
|
63
|
+
def graph(port: int) -> None:
|
|
64
|
+
"""Visualize the knowledge graph in your browser."""
|
|
65
|
+
from crossmem.graph import serve_graph
|
|
66
|
+
|
|
67
|
+
store = MemoryStore()
|
|
68
|
+
if store.count() == 0:
|
|
69
|
+
click.echo("No memories yet. Run: crossmem ingest")
|
|
70
|
+
store.close()
|
|
71
|
+
return
|
|
72
|
+
serve_graph(store, port=port) # closes store internally before serving
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@main.command()
|
|
76
|
+
@click.option("-p", "--project", default=None, help="Sync this project + shared patterns")
|
|
77
|
+
def sync(project: str | None) -> None:
|
|
78
|
+
"""Sync Claude Code memories → Gemini CLI (one-shot)."""
|
|
79
|
+
from crossmem.sync import sync_once
|
|
80
|
+
|
|
81
|
+
count, changed = sync_once(project=project)
|
|
82
|
+
if changed:
|
|
83
|
+
label = f"{project} + shared patterns" if project else "all"
|
|
84
|
+
click.echo(f"Synced {count} memories ({label}) → ~/.gemini/GEMINI.md")
|
|
85
|
+
else:
|
|
86
|
+
click.echo(f"Already in sync ({count} memories)")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@main.command(name="sync-watch")
|
|
90
|
+
@click.option("--interval", default=30, help="Poll interval in seconds")
|
|
91
|
+
@click.option("-p", "--project", default=None, help="Sync this project + shared patterns")
|
|
92
|
+
def sync_watch(interval: int, project: str | None) -> None:
|
|
93
|
+
"""Watch Claude memories and sync to Gemini on changes."""
|
|
94
|
+
from crossmem.sync import watch
|
|
95
|
+
|
|
96
|
+
watch(interval=interval, project=project)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@main.command()
|
|
100
|
+
def serve() -> None:
|
|
101
|
+
"""Start the MCP server (stdio transport)."""
|
|
102
|
+
from crossmem.server import main as serve_main
|
|
103
|
+
|
|
104
|
+
serve_main()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@main.command()
|
|
108
|
+
def stats() -> None:
|
|
109
|
+
"""Show memory statistics."""
|
|
110
|
+
store = MemoryStore()
|
|
111
|
+
try:
|
|
112
|
+
total = store.count()
|
|
113
|
+
projects = store.stats()
|
|
114
|
+
|
|
115
|
+
if total == 0:
|
|
116
|
+
click.echo("No memories yet. Run: crossmem ingest")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
click.echo(f"Total memories: {total}")
|
|
120
|
+
click.echo(f"Projects: {len(projects)}\n")
|
|
121
|
+
for project, count in projects.items():
|
|
122
|
+
click.echo(f" {project}: {count}")
|
|
123
|
+
click.echo(f"\nDatabase: {DEFAULT_DB_PATH}")
|
|
124
|
+
finally:
|
|
125
|
+
store.close()
|
crossmem/graph.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Knowledge graph visualization for crossmem."""
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import webbrowser
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
from crossmem.store import MemoryStore
|
|
10
|
+
|
|
11
|
+
_STOP_WORDS = {
|
|
12
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
|
13
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
14
|
+
"should", "may", "might", "can", "shall", "to", "of", "in", "for",
|
|
15
|
+
"on", "with", "at", "by", "from", "as", "into", "through", "during",
|
|
16
|
+
"before", "after", "above", "below", "between", "out", "off", "over",
|
|
17
|
+
"under", "again", "further", "then", "once", "and", "but", "or", "nor",
|
|
18
|
+
"not", "no", "so", "if", "that", "this", "these", "those", "it", "its",
|
|
19
|
+
"all", "each", "every", "both", "few", "more", "most", "other", "some",
|
|
20
|
+
"such", "only", "own", "same", "than", "too", "very", "just", "about",
|
|
21
|
+
"use", "used", "using", "also", "new", "one", "two", "first", "last",
|
|
22
|
+
"file", "files", "run", "set", "get", "add", "see", "e", "g",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_graph_data(store: MemoryStore) -> dict:
|
|
27
|
+
"""Build nodes and edges for the knowledge graph.
|
|
28
|
+
|
|
29
|
+
Nodes: projects and their sections.
|
|
30
|
+
Edges: project→section ownership + cross-project links via shared sections.
|
|
31
|
+
"""
|
|
32
|
+
rows = store.db.execute(
|
|
33
|
+
"SELECT id, content, source_file, project, section FROM memories"
|
|
34
|
+
).fetchall()
|
|
35
|
+
|
|
36
|
+
projects: dict[str, int] = defaultdict(int)
|
|
37
|
+
sections: dict[str, set[str]] = defaultdict(set) # section_name → {projects}
|
|
38
|
+
project_sections: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
39
|
+
|
|
40
|
+
for row in rows:
|
|
41
|
+
project = row["project"]
|
|
42
|
+
section = row["section"] or "(root)"
|
|
43
|
+
projects[project] += 1
|
|
44
|
+
sections[section].add(project)
|
|
45
|
+
project_sections[project][section] += 1
|
|
46
|
+
|
|
47
|
+
nodes = []
|
|
48
|
+
edges = []
|
|
49
|
+
node_ids: dict[str, int] = {}
|
|
50
|
+
|
|
51
|
+
# Project nodes
|
|
52
|
+
for i, (project, count) in enumerate(
|
|
53
|
+
sorted(projects.items(), key=lambda x: -x[1])
|
|
54
|
+
):
|
|
55
|
+
node_ids[f"proj:{project}"] = i
|
|
56
|
+
nodes.append({
|
|
57
|
+
"id": i,
|
|
58
|
+
"label": project,
|
|
59
|
+
"type": "project",
|
|
60
|
+
"size": count,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
# Section nodes (only sections that appear in 1+ project)
|
|
64
|
+
for section, projs in sorted(sections.items(), key=lambda x: -len(x[1])):
|
|
65
|
+
if section == "(root)" and len(projs) == 1:
|
|
66
|
+
continue
|
|
67
|
+
idx = len(nodes)
|
|
68
|
+
node_ids[f"sec:{section}"] = idx
|
|
69
|
+
nodes.append({
|
|
70
|
+
"id": idx,
|
|
71
|
+
"label": section,
|
|
72
|
+
"type": "shared_section" if len(projs) > 1 else "section",
|
|
73
|
+
"size": sum(project_sections[p][section] for p in projs),
|
|
74
|
+
"projects": sorted(projs),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
# Edges from section to each project
|
|
78
|
+
for proj in projs:
|
|
79
|
+
proj_key = f"proj:{proj}"
|
|
80
|
+
if proj_key in node_ids:
|
|
81
|
+
edges.append({
|
|
82
|
+
"source": node_ids[proj_key],
|
|
83
|
+
"target": idx,
|
|
84
|
+
"weight": project_sections[proj][section],
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
# Cross-project edges via shared keywords in content
|
|
88
|
+
project_keywords: dict[str, set[str]] = defaultdict(set)
|
|
89
|
+
stop_words = _STOP_WORDS
|
|
90
|
+
for row in rows:
|
|
91
|
+
words = set(
|
|
92
|
+
w.lower().strip("`*_-()[]{}.,;:!?\"'#/\\")
|
|
93
|
+
for w in row["content"].split()
|
|
94
|
+
if len(w) > 2
|
|
95
|
+
)
|
|
96
|
+
words -= stop_words
|
|
97
|
+
project_keywords[row["project"]].update(words)
|
|
98
|
+
|
|
99
|
+
# Find keyword overlap between project pairs
|
|
100
|
+
proj_list = sorted(projects.keys())
|
|
101
|
+
for i, p1 in enumerate(proj_list):
|
|
102
|
+
for p2 in proj_list[i + 1:]:
|
|
103
|
+
shared = project_keywords[p1] & project_keywords[p2]
|
|
104
|
+
if len(shared) > 5: # meaningful overlap
|
|
105
|
+
k1 = f"proj:{p1}"
|
|
106
|
+
k2 = f"proj:{p2}"
|
|
107
|
+
if k1 in node_ids and k2 in node_ids:
|
|
108
|
+
edges.append({
|
|
109
|
+
"source": node_ids[k1],
|
|
110
|
+
"target": node_ids[k2],
|
|
111
|
+
"weight": len(shared),
|
|
112
|
+
"type": "keyword_overlap",
|
|
113
|
+
"shared_keywords": sorted(shared)[:20],
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return {"nodes": nodes, "edges": edges}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
120
|
+
<html>
|
|
121
|
+
<head>
|
|
122
|
+
<meta charset="utf-8">
|
|
123
|
+
<title>crossmem — Knowledge Graph</title>
|
|
124
|
+
<style>
|
|
125
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
126
|
+
body {
|
|
127
|
+
background: #0a0a0f;
|
|
128
|
+
color: #e0e0e0;
|
|
129
|
+
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
|
130
|
+
overflow: hidden;
|
|
131
|
+
}
|
|
132
|
+
#header {
|
|
133
|
+
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
|
134
|
+
padding: 16px 24px;
|
|
135
|
+
background: linear-gradient(180deg, rgba(10,10,15,0.95) 0%, rgba(10,10,15,0) 100%);
|
|
136
|
+
display: flex; align-items: center; gap: 16px;
|
|
137
|
+
}
|
|
138
|
+
#header h1 { font-size: 18px; color: #7aa2f7; font-weight: 600; }
|
|
139
|
+
#header .stats { font-size: 13px; color: #565f89; }
|
|
140
|
+
#legend {
|
|
141
|
+
position: fixed; bottom: 20px; left: 20px; z-index: 10;
|
|
142
|
+
background: rgba(20, 20, 30, 0.9);
|
|
143
|
+
border: 1px solid #1a1b26;
|
|
144
|
+
border-radius: 8px; padding: 12px 16px;
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
}
|
|
147
|
+
.legend-item { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
|
|
148
|
+
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
|
149
|
+
#tooltip {
|
|
150
|
+
position: fixed; z-index: 20;
|
|
151
|
+
background: rgba(20, 20, 30, 0.95);
|
|
152
|
+
border: 1px solid #3d59a1;
|
|
153
|
+
border-radius: 8px; padding: 12px 16px;
|
|
154
|
+
font-size: 12px; max-width: 320px;
|
|
155
|
+
pointer-events: none; display: none;
|
|
156
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
|
157
|
+
}
|
|
158
|
+
#tooltip h3 { color: #7aa2f7; margin-bottom: 6px; font-size: 14px; }
|
|
159
|
+
#tooltip .detail { color: #9aa5ce; margin: 2px 0; }
|
|
160
|
+
#tooltip .keywords { color: #73daca; margin-top: 6px; font-size: 11px; word-break: break-word; }
|
|
161
|
+
svg { width: 100vw; height: 100vh; }
|
|
162
|
+
.link { stroke-opacity: 0.3; }
|
|
163
|
+
.link-keyword { stroke: #3d59a1; stroke-dasharray: 4,4; }
|
|
164
|
+
.link-section { stroke: #1a1b26; }
|
|
165
|
+
.node-label {
|
|
166
|
+
fill: #c0caf5; font-size: 11px;
|
|
167
|
+
text-anchor: middle; pointer-events: none;
|
|
168
|
+
text-shadow: 0 0 8px rgba(10,10,15,0.9), 0 0 4px rgba(10,10,15,0.9);
|
|
169
|
+
}
|
|
170
|
+
</style>
|
|
171
|
+
</head>
|
|
172
|
+
<body>
|
|
173
|
+
<div id="header">
|
|
174
|
+
<h1>crossmem</h1>
|
|
175
|
+
<span class="stats" id="stats"></span>
|
|
176
|
+
</div>
|
|
177
|
+
<div id="legend">
|
|
178
|
+
<div class="legend-item"><div class="legend-dot" style="background:#7aa2f7"></div> Project</div>
|
|
179
|
+
<div class="legend-item"><div class="legend-dot" style="background:#ff9e64"></div> Shared section (multi-project)</div>
|
|
180
|
+
<div class="legend-item"><div class="legend-dot" style="background:#565f89"></div> Section</div>
|
|
181
|
+
<div class="legend-item"><svg width="30" height="10"><line x1="0" y1="5" x2="30" y2="5" stroke="#3d59a1" stroke-dasharray="4,4" stroke-width="1.5"/></svg> Keyword overlap</div>
|
|
182
|
+
</div>
|
|
183
|
+
<div id="tooltip"></div>
|
|
184
|
+
<svg></svg>
|
|
185
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
186
|
+
<script>
|
|
187
|
+
const data = __GRAPH_DATA__;
|
|
188
|
+
|
|
189
|
+
const statsEl = document.getElementById('stats');
|
|
190
|
+
const projects = data.nodes.filter(n => n.type === 'project');
|
|
191
|
+
const totalMem = projects.reduce((s, n) => s + n.size, 0);
|
|
192
|
+
const shared = data.nodes.filter(n => n.type === 'shared_section').length;
|
|
193
|
+
statsEl.textContent = `${totalMem} memories | ${projects.length} projects | ${shared} shared sections`;
|
|
194
|
+
|
|
195
|
+
const svg = d3.select('svg');
|
|
196
|
+
const width = window.innerWidth;
|
|
197
|
+
const height = window.innerHeight;
|
|
198
|
+
|
|
199
|
+
const g = svg.append('g');
|
|
200
|
+
|
|
201
|
+
// Zoom
|
|
202
|
+
svg.call(d3.zoom()
|
|
203
|
+
.scaleExtent([0.2, 5])
|
|
204
|
+
.on('zoom', (e) => g.attr('transform', e.transform)));
|
|
205
|
+
|
|
206
|
+
const color = d => ({
|
|
207
|
+
project: '#7aa2f7',
|
|
208
|
+
shared_section: '#ff9e64',
|
|
209
|
+
section: '#565f89',
|
|
210
|
+
}[d.type] || '#565f89');
|
|
211
|
+
|
|
212
|
+
const radius = d => {
|
|
213
|
+
if (d.type === 'project') return Math.max(12, Math.sqrt(d.size) * 3);
|
|
214
|
+
if (d.type === 'shared_section') return Math.max(8, Math.sqrt(d.size) * 2.5);
|
|
215
|
+
return Math.max(5, Math.sqrt(d.size) * 2);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const sim = d3.forceSimulation(data.nodes)
|
|
219
|
+
.force('link', d3.forceLink(data.edges).id(d => d.id).distance(d =>
|
|
220
|
+
d.type === 'keyword_overlap' ? 150 : 60
|
|
221
|
+
).strength(d => d.type === 'keyword_overlap' ? 0.1 : 0.3))
|
|
222
|
+
.force('charge', d3.forceManyBody().strength(d =>
|
|
223
|
+
d.type === 'project' ? -300 : -80
|
|
224
|
+
))
|
|
225
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
226
|
+
.force('collision', d3.forceCollide().radius(d => radius(d) + 4));
|
|
227
|
+
|
|
228
|
+
const link = g.selectAll('.link')
|
|
229
|
+
.data(data.edges).enter().append('line')
|
|
230
|
+
.attr('class', d => 'link ' + (d.type === 'keyword_overlap' ? 'link-keyword' : 'link-section'))
|
|
231
|
+
.attr('stroke-width', d => d.type === 'keyword_overlap'
|
|
232
|
+
? Math.min(3, Math.sqrt(d.weight) * 0.3)
|
|
233
|
+
: Math.min(2, d.weight * 0.5));
|
|
234
|
+
|
|
235
|
+
const node = g.selectAll('.node')
|
|
236
|
+
.data(data.nodes).enter().append('circle')
|
|
237
|
+
.attr('r', radius)
|
|
238
|
+
.attr('fill', color)
|
|
239
|
+
.attr('stroke', d => d.type === 'project' ? '#7aa2f7' : 'none')
|
|
240
|
+
.attr('stroke-width', d => d.type === 'project' ? 2 : 0)
|
|
241
|
+
.attr('fill-opacity', d => d.type === 'project' ? 0.85 : 0.6)
|
|
242
|
+
.attr('cursor', 'pointer')
|
|
243
|
+
.call(d3.drag()
|
|
244
|
+
.on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
|
|
245
|
+
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
|
|
246
|
+
.on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
|
|
247
|
+
|
|
248
|
+
const label = g.selectAll('.node-label')
|
|
249
|
+
.data(data.nodes.filter(n => n.type === 'project' || n.type === 'shared_section'))
|
|
250
|
+
.enter().append('text')
|
|
251
|
+
.attr('class', 'node-label')
|
|
252
|
+
.attr('dy', d => radius(d) + 14)
|
|
253
|
+
.text(d => d.label);
|
|
254
|
+
|
|
255
|
+
// Tooltip
|
|
256
|
+
const tooltip = document.getElementById('tooltip');
|
|
257
|
+
|
|
258
|
+
node.on('mouseover', (e, d) => {
|
|
259
|
+
let html = `<h3>${d.label}</h3>`;
|
|
260
|
+
html += `<div class="detail">Type: ${d.type.replace('_', ' ')}</div>`;
|
|
261
|
+
html += `<div class="detail">Memories: ${d.size}</div>`;
|
|
262
|
+
if (d.projects) html += `<div class="detail">In: ${d.projects.join(', ')}</div>`;
|
|
263
|
+
|
|
264
|
+
// Find keyword overlaps for this node
|
|
265
|
+
const overlaps = data.edges.filter(e =>
|
|
266
|
+
e.type === 'keyword_overlap' &&
|
|
267
|
+
(e.source.id === d.id || e.target.id === d.id)
|
|
268
|
+
);
|
|
269
|
+
if (overlaps.length > 0) {
|
|
270
|
+
overlaps.forEach(o => {
|
|
271
|
+
const other = o.source.id === d.id ? o.target : o.source;
|
|
272
|
+
html += `<div class="keywords">${o.shared_keywords.slice(0, 10).join(', ')} (shared with ${other.label})</div>`;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
tooltip.innerHTML = html;
|
|
276
|
+
tooltip.style.display = 'block';
|
|
277
|
+
tooltip.style.left = (e.pageX + 12) + 'px';
|
|
278
|
+
tooltip.style.top = (e.pageY - 10) + 'px';
|
|
279
|
+
|
|
280
|
+
// Highlight connected
|
|
281
|
+
const connected = new Set();
|
|
282
|
+
data.edges.forEach(e => {
|
|
283
|
+
const sid = typeof e.source === 'object' ? e.source.id : e.source;
|
|
284
|
+
const tid = typeof e.target === 'object' ? e.target.id : e.target;
|
|
285
|
+
if (sid === d.id) connected.add(tid);
|
|
286
|
+
if (tid === d.id) connected.add(sid);
|
|
287
|
+
});
|
|
288
|
+
node.attr('fill-opacity', n => n.id === d.id || connected.has(n.id) ? 1 : 0.15);
|
|
289
|
+
link.attr('stroke-opacity', e => {
|
|
290
|
+
const sid = typeof e.source === 'object' ? e.source.id : e.source;
|
|
291
|
+
const tid = typeof e.target === 'object' ? e.target.id : e.target;
|
|
292
|
+
return sid === d.id || tid === d.id ? 0.8 : 0.05;
|
|
293
|
+
});
|
|
294
|
+
label.attr('fill-opacity', n => n.id === d.id || connected.has(n.id) ? 1 : 0.15);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
node.on('mousemove', (e) => {
|
|
298
|
+
tooltip.style.left = (e.pageX + 12) + 'px';
|
|
299
|
+
tooltip.style.top = (e.pageY - 10) + 'px';
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
node.on('mouseout', () => {
|
|
303
|
+
tooltip.style.display = 'none';
|
|
304
|
+
node.attr('fill-opacity', d => d.type === 'project' ? 0.85 : 0.6);
|
|
305
|
+
link.attr('stroke-opacity', 0.3);
|
|
306
|
+
label.attr('fill-opacity', 1);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
sim.on('tick', () => {
|
|
310
|
+
link
|
|
311
|
+
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
|
312
|
+
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
|
313
|
+
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
314
|
+
label.attr('x', d => d.x).attr('y', d => d.y);
|
|
315
|
+
});
|
|
316
|
+
</script>
|
|
317
|
+
</body>
|
|
318
|
+
</html>"""
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def serve_graph(store: MemoryStore, port: int = 8765) -> None:
|
|
322
|
+
"""Build graph data and serve the visualization."""
|
|
323
|
+
graph_data = build_graph_data(store)
|
|
324
|
+
store.close()
|
|
325
|
+
safe_json = json.dumps(graph_data).replace("</", r"<\/")
|
|
326
|
+
html = HTML_TEMPLATE.replace("__GRAPH_DATA__", safe_json)
|
|
327
|
+
|
|
328
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
329
|
+
def do_GET(self) -> None:
|
|
330
|
+
self.send_response(200)
|
|
331
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
332
|
+
self.end_headers()
|
|
333
|
+
self.wfile.write(html.encode())
|
|
334
|
+
|
|
335
|
+
def log_message(self, format, *args) -> None:
|
|
336
|
+
pass # suppress access logs
|
|
337
|
+
|
|
338
|
+
server = http.server.HTTPServer(("127.0.0.1", port), Handler)
|
|
339
|
+
url = f"http://127.0.0.1:{port}"
|
|
340
|
+
print(f"Knowledge graph: {url}")
|
|
341
|
+
print("Press Ctrl+C to stop.")
|
|
342
|
+
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
|
|
343
|
+
try:
|
|
344
|
+
server.serve_forever()
|
|
345
|
+
except KeyboardInterrupt:
|
|
346
|
+
print("\nStopped.")
|
|
347
|
+
server.server_close()
|
crossmem/ingest.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Ingest memory files from AI coding tools."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from crossmem.store import MemoryStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_project_name(path: Path) -> str:
|
|
10
|
+
"""Extract project name from Claude Code's path-encoded directory.
|
|
11
|
+
|
|
12
|
+
Uses the directory immediately before /memory/ as the project identifier,
|
|
13
|
+
then decodes Claude Code's path encoding (hyphens replace path separators).
|
|
14
|
+
Takes the last 1-2 meaningful segments as the project name.
|
|
15
|
+
|
|
16
|
+
~/.claude/projects/-Users-foo-Documents-myproject/memory/MEMORY.md → myproject
|
|
17
|
+
~/.claude/projects/-Users-foo-work-backend-api/memory/MEMORY.md → backend-api
|
|
18
|
+
"""
|
|
19
|
+
parts = path.parts
|
|
20
|
+
for i, part in enumerate(parts):
|
|
21
|
+
if part == "memory" and i > 0:
|
|
22
|
+
encoded = parts[i - 1]
|
|
23
|
+
# Split on the path-encoding pattern (leading hyphen + segments)
|
|
24
|
+
segments = [s for s in encoded.split("-") if s]
|
|
25
|
+
if not segments:
|
|
26
|
+
return encoded
|
|
27
|
+
# Take last 1-2 segments as project name (most specific)
|
|
28
|
+
# Skip if last segment looks like a hash or is too short
|
|
29
|
+
meaningful = segments[-2:] if len(segments) >= 2 else segments[-1:]
|
|
30
|
+
return "-".join(meaningful)
|
|
31
|
+
return path.parent.name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_markdown_sections(content: str) -> list[tuple[str, str]]:
|
|
35
|
+
"""Split markdown into (section_heading, section_content) pairs.
|
|
36
|
+
|
|
37
|
+
Each section becomes a separate memory for granular search.
|
|
38
|
+
"""
|
|
39
|
+
lines = content.split("\n")
|
|
40
|
+
sections: list[tuple[str, str]] = []
|
|
41
|
+
current_heading = ""
|
|
42
|
+
current_lines: list[str] = []
|
|
43
|
+
|
|
44
|
+
for line in lines:
|
|
45
|
+
if re.match(r"^#{1,3}\s+", line):
|
|
46
|
+
if current_lines:
|
|
47
|
+
text = "\n".join(current_lines).strip()
|
|
48
|
+
if text and len(text) > 20:
|
|
49
|
+
sections.append((current_heading, text))
|
|
50
|
+
current_heading = re.sub(r"^#{1,3}\s+", "", line).strip()
|
|
51
|
+
current_lines = []
|
|
52
|
+
else:
|
|
53
|
+
current_lines.append(line)
|
|
54
|
+
|
|
55
|
+
if current_lines:
|
|
56
|
+
text = "\n".join(current_lines).strip()
|
|
57
|
+
if text and len(text) > 20:
|
|
58
|
+
sections.append((current_heading, text))
|
|
59
|
+
|
|
60
|
+
return sections
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_gemini_project(text: str) -> str:
|
|
64
|
+
"""Extract project name from a Gemini memory bullet.
|
|
65
|
+
|
|
66
|
+
Gemini memories often contain "For the 'project-name' project" or
|
|
67
|
+
"project X" patterns. Falls back to "gemini" as the project name.
|
|
68
|
+
"""
|
|
69
|
+
match = re.search(r"[Ff]or the ['\"]?([^'\"]+?)['\"]? project", text)
|
|
70
|
+
if match:
|
|
71
|
+
return match.group(1).strip()
|
|
72
|
+
return "gemini"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ingest_gemini_memory(store: MemoryStore, base_path: Path | None = None) -> int:
|
|
76
|
+
"""Ingest Gemini CLI memory file into the store.
|
|
77
|
+
|
|
78
|
+
Gemini stores memories as bullet points in ~/.gemini/GEMINI.md.
|
|
79
|
+
Each bullet becomes a separate memory entry.
|
|
80
|
+
Returns the number of new memories added.
|
|
81
|
+
"""
|
|
82
|
+
if base_path is None:
|
|
83
|
+
base_path = Path.home() / ".gemini"
|
|
84
|
+
|
|
85
|
+
gemini_file = base_path / "GEMINI.md"
|
|
86
|
+
if not gemini_file.exists():
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
content = gemini_file.read_text(encoding="utf-8", errors="replace")
|
|
90
|
+
if not content.strip():
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
added = 0
|
|
94
|
+
for line in content.split("\n"):
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if not line.startswith("- "):
|
|
97
|
+
continue
|
|
98
|
+
bullet = line[2:].strip()
|
|
99
|
+
if len(bullet) < 20:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
project = extract_gemini_project(bullet)
|
|
103
|
+
result = store.add(
|
|
104
|
+
content=bullet,
|
|
105
|
+
source_file=str(gemini_file),
|
|
106
|
+
project=project,
|
|
107
|
+
section="Gemini Added Memories",
|
|
108
|
+
)
|
|
109
|
+
if result is not None:
|
|
110
|
+
added += 1
|
|
111
|
+
|
|
112
|
+
return added
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ingest_claude_memory(store: MemoryStore, base_path: Path | None = None) -> int:
|
|
116
|
+
"""Ingest all Claude Code memory files into the store.
|
|
117
|
+
|
|
118
|
+
Returns the number of new memories added.
|
|
119
|
+
"""
|
|
120
|
+
if base_path is None:
|
|
121
|
+
base_path = Path.home() / ".claude" / "projects"
|
|
122
|
+
|
|
123
|
+
if not base_path.exists():
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
added = 0
|
|
127
|
+
for md_file in sorted(base_path.rglob("*.md")):
|
|
128
|
+
if "memory" not in str(md_file):
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
project = extract_project_name(md_file)
|
|
132
|
+
content = md_file.read_text(encoding="utf-8", errors="replace")
|
|
133
|
+
|
|
134
|
+
if not content.strip():
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
sections = parse_markdown_sections(content)
|
|
138
|
+
|
|
139
|
+
if not sections:
|
|
140
|
+
# No sections found — store the whole file as one memory
|
|
141
|
+
result = store.add(
|
|
142
|
+
content=content.strip(),
|
|
143
|
+
source_file=str(md_file),
|
|
144
|
+
project=project,
|
|
145
|
+
section="",
|
|
146
|
+
)
|
|
147
|
+
if result is not None:
|
|
148
|
+
added += 1
|
|
149
|
+
else:
|
|
150
|
+
for heading, text in sections:
|
|
151
|
+
result = store.add(
|
|
152
|
+
content=text,
|
|
153
|
+
source_file=str(md_file),
|
|
154
|
+
project=project,
|
|
155
|
+
section=heading,
|
|
156
|
+
)
|
|
157
|
+
if result is not None:
|
|
158
|
+
added += 1
|
|
159
|
+
|
|
160
|
+
return added
|