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 ADDED
@@ -0,0 +1,3 @@
1
+ """Cross-project memory for AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
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