cerebro-code-memory 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.
- cerebro/__init__.py +3 -0
- cerebro/callgraph.py +38 -0
- cerebro/cli.py +348 -0
- cerebro/config.py +136 -0
- cerebro/db.py +245 -0
- cerebro/docaudit.py +174 -0
- cerebro/embeddings.py +175 -0
- cerebro/gitsync.py +124 -0
- cerebro/graph.py +77 -0
- cerebro/indexer.py +854 -0
- cerebro/insights.py +217 -0
- cerebro/notes.py +70 -0
- cerebro/server.py +382 -0
- cerebro/summaries.py +66 -0
- cerebro/summarizer.py +109 -0
- cerebro/tsconfig.py +159 -0
- cerebro/views.py +52 -0
- cerebro/viz.py +374 -0
- cerebro_code_memory-0.1.0.dist-info/METADATA +160 -0
- cerebro_code_memory-0.1.0.dist-info/RECORD +23 -0
- cerebro_code_memory-0.1.0.dist-info/WHEEL +4 -0
- cerebro_code_memory-0.1.0.dist-info/entry_points.txt +11 -0
- cerebro_code_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
cerebro/viz.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Visualizations built from the brain: a self-contained interactive HTML
|
|
2
|
+
dependency graph, and an Obsidian vault (one note per file, imports as [[links]]).
|
|
3
|
+
|
|
4
|
+
Both read only the existing index — no new analysis. The HTML graph defaults to the
|
|
5
|
+
most central files so it stays responsive on large repos; the Obsidian export writes
|
|
6
|
+
every indexed file so its graph view is complete.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import config as cfg
|
|
14
|
+
from . import db
|
|
15
|
+
from . import graph as graphmod
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _select(conn, limit: int | None, prefix: str | None):
|
|
19
|
+
from . import insights
|
|
20
|
+
|
|
21
|
+
ranked = graphmod.rank(conn) # [(path, score)] desc
|
|
22
|
+
scores = dict(ranked)
|
|
23
|
+
in_scope = [p for p, _ in ranked if not prefix or p.startswith(prefix)]
|
|
24
|
+
files = in_scope[:limit] if limit else list(in_scope)
|
|
25
|
+
keep = set(files)
|
|
26
|
+
# Orphans (in_degree 0) rank lowest by centrality, so the top-N cut drops every
|
|
27
|
+
# one of them — leaving the "Orphans" overlay with nothing to highlight. Pull
|
|
28
|
+
# the overlay nodes (orphans + cycle members, within scope) in explicitly so
|
|
29
|
+
# both buttons actually do something.
|
|
30
|
+
scope = set(in_scope)
|
|
31
|
+
highlight = (set(insights.orphans(conn, prefix)["dead"]) | insights.cycle_members(conn)) & scope
|
|
32
|
+
files += [f for f in in_scope if f in highlight and f not in keep]
|
|
33
|
+
keep = set(files)
|
|
34
|
+
edges = [
|
|
35
|
+
(r["src_path"], r["dst_path"])
|
|
36
|
+
for r in conn.execute("SELECT src_path, dst_path FROM edges")
|
|
37
|
+
if r["src_path"] in keep and r["dst_path"] in keep
|
|
38
|
+
]
|
|
39
|
+
return files, scores, edges
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def graph_html(conn, limit: int = 400, prefix: str | None = None) -> str:
|
|
43
|
+
from . import insights
|
|
44
|
+
|
|
45
|
+
files, scores, edges = _select(conn, limit, prefix)
|
|
46
|
+
summ = {
|
|
47
|
+
r["path"]: r["summary_en"]
|
|
48
|
+
for r in conn.execute("SELECT path, summary_en FROM summaries")
|
|
49
|
+
}
|
|
50
|
+
orphan_set = set(insights.orphans(conn).get("dead", []))
|
|
51
|
+
cycle_set = insights.cycle_members(conn)
|
|
52
|
+
nodes = []
|
|
53
|
+
for p in files:
|
|
54
|
+
nodes.append(
|
|
55
|
+
{
|
|
56
|
+
"id": p,
|
|
57
|
+
"label": p.split("/")[-1],
|
|
58
|
+
"group": p.split("/", 1)[0],
|
|
59
|
+
"value": round(scores.get(p, 0.0) * 1000, 3) + 1,
|
|
60
|
+
"score": round(scores.get(p, 0.0), 5),
|
|
61
|
+
"summary": summ.get(p, ""),
|
|
62
|
+
"orphan": p in orphan_set,
|
|
63
|
+
"cycle": p in cycle_set,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
data = {"nodes": nodes, "edges": [{"from": s, "to": d} for s, d in edges]}
|
|
67
|
+
total = conn.execute("SELECT COUNT(*) AS n FROM files WHERE lang IS NOT NULL").fetchone()["n"]
|
|
68
|
+
meta = {
|
|
69
|
+
"shown": len(files),
|
|
70
|
+
"total": total,
|
|
71
|
+
"prefix": prefix or "",
|
|
72
|
+
"orphans": sum(1 for p in files if p in orphan_set),
|
|
73
|
+
"cycles": sum(1 for p in files if p in cycle_set),
|
|
74
|
+
}
|
|
75
|
+
return _TEMPLATE.replace("__DATA__", json.dumps(data)).replace(
|
|
76
|
+
"__META__", json.dumps(meta)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def export_obsidian(config, conn, out_dir: Path) -> dict:
|
|
81
|
+
out = Path(out_dir)
|
|
82
|
+
ranked = dict(graphmod.rank(conn))
|
|
83
|
+
summ = {
|
|
84
|
+
r["path"]: r["summary_en"]
|
|
85
|
+
for r in conn.execute("SELECT path, summary_en FROM summaries")
|
|
86
|
+
}
|
|
87
|
+
deps_by: dict[str, list[str]] = {}
|
|
88
|
+
for r in conn.execute("SELECT src_path, dst_path FROM edges"):
|
|
89
|
+
deps_by.setdefault(r["src_path"], []).append(r["dst_path"])
|
|
90
|
+
|
|
91
|
+
files = [
|
|
92
|
+
r["path"] for r in conn.execute("SELECT path FROM files WHERE lang IS NOT NULL")
|
|
93
|
+
]
|
|
94
|
+
count = 0
|
|
95
|
+
for p in files:
|
|
96
|
+
pkg = p.split("/", 1)[0]
|
|
97
|
+
lang = config.lang_for(p) or "other"
|
|
98
|
+
lines = [
|
|
99
|
+
"---",
|
|
100
|
+
f"tags: [{pkg}, {lang}]",
|
|
101
|
+
f"centrality: {ranked.get(p, 0.0):.4f}",
|
|
102
|
+
"---",
|
|
103
|
+
"",
|
|
104
|
+
f"`{p}`",
|
|
105
|
+
"",
|
|
106
|
+
summ.get(p) or "_No summary yet — run cerebro-summarize._",
|
|
107
|
+
"",
|
|
108
|
+
]
|
|
109
|
+
syms = db.symbols_for(conn, p)
|
|
110
|
+
if syms:
|
|
111
|
+
lines.append("## Symbols")
|
|
112
|
+
lines += [f"- L{s['line']} {s['kind']} `{s['name']}`" for s in syms[:60]]
|
|
113
|
+
lines.append("")
|
|
114
|
+
deps = sorted(set(deps_by.get(p, [])))
|
|
115
|
+
if deps:
|
|
116
|
+
lines.append("## Imports (depends on)")
|
|
117
|
+
lines += [f"- [[{d}]]" for d in deps]
|
|
118
|
+
note = out / (p + ".md")
|
|
119
|
+
note.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
note.write_text("\n".join(lines), encoding="utf-8")
|
|
121
|
+
count += 1
|
|
122
|
+
return {"notes": count, "vault": str(out)}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- CLI entry points --------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def graph_main():
|
|
128
|
+
import argparse
|
|
129
|
+
|
|
130
|
+
ap = argparse.ArgumentParser(description="Write an interactive dependency graph HTML")
|
|
131
|
+
ap.add_argument("--limit", type=int, default=400, help="max nodes (by centrality)")
|
|
132
|
+
ap.add_argument("--prefix", default=None, help="only files under this path prefix")
|
|
133
|
+
ap.add_argument("-o", "--out", default=None)
|
|
134
|
+
args = ap.parse_args()
|
|
135
|
+
config = cfg.Config.load()
|
|
136
|
+
conn = db.connect(config.db_path)
|
|
137
|
+
out = Path(args.out) if args.out else config.db_path.parent / "cerebro-graph.html"
|
|
138
|
+
out.write_text(graph_html(conn, args.limit, args.prefix), encoding="utf-8")
|
|
139
|
+
print(json.dumps({"html": str(out), "open_with": f"open '{out}'"}))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def obsidian_main():
|
|
143
|
+
import argparse
|
|
144
|
+
|
|
145
|
+
ap = argparse.ArgumentParser(description="Export an Obsidian vault of the codebase")
|
|
146
|
+
ap.add_argument("-o", "--out", default=None)
|
|
147
|
+
args = ap.parse_args()
|
|
148
|
+
config = cfg.Config.load()
|
|
149
|
+
conn = db.connect(config.db_path)
|
|
150
|
+
out = Path(args.out) if args.out else config.db_path.parent / "vault"
|
|
151
|
+
result = export_obsidian(config, conn, out)
|
|
152
|
+
result["open"] = "Open this folder as a vault in Obsidian"
|
|
153
|
+
print(json.dumps(result))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_TEMPLATE = r"""<!doctype html>
|
|
157
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
158
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
159
|
+
<title>Cerebro · dependency graph</title>
|
|
160
|
+
<script src="https://unpkg.com/force-graph"></script>
|
|
161
|
+
<style>
|
|
162
|
+
:root{
|
|
163
|
+
--bg:#0d1117; --surface:#161b22; --surface2:#1c2230; --border:#30363d;
|
|
164
|
+
--text:#e6edf3; --muted:#7d8590; --accent:#4c8dff; --radius:10px;
|
|
165
|
+
--font: ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
166
|
+
--mono: ui-monospace,SFMono-Regular,"SF Mono",Menlo,monospace;
|
|
167
|
+
}
|
|
168
|
+
*{box-sizing:border-box}
|
|
169
|
+
html,body{margin:0;height:100%}
|
|
170
|
+
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:13px;overflow:hidden}
|
|
171
|
+
#app{display:flex;height:100vh}
|
|
172
|
+
#side{width:330px;flex:none;background:var(--surface);border-right:1px solid var(--border);
|
|
173
|
+
display:flex;flex-direction:column;overflow:hidden}
|
|
174
|
+
.brand{display:flex;align-items:baseline;gap:8px;padding:16px 16px 4px}
|
|
175
|
+
.brand b{font-size:15px;font-weight:650;letter-spacing:.2px}
|
|
176
|
+
.brand span{color:var(--muted);font-size:11px}
|
|
177
|
+
#meta{padding:0 16px 12px;color:var(--muted);font-size:11.5px}
|
|
178
|
+
.sec{padding:12px 16px;border-top:1px solid var(--border)}
|
|
179
|
+
.sec.grow{flex:1;overflow:auto}
|
|
180
|
+
.lbl{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px}
|
|
181
|
+
#search{width:100%;padding:9px 11px;border-radius:var(--radius);border:1px solid var(--border);
|
|
182
|
+
background:var(--bg);color:var(--text);font-size:12.5px;outline:none}
|
|
183
|
+
#search:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(76,141,255,.15)}
|
|
184
|
+
#hits{color:var(--muted);font-size:11px;margin-top:6px;min-height:14px}
|
|
185
|
+
#legend{display:flex;flex-wrap:wrap;gap:6px}
|
|
186
|
+
.chip{display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:999px;
|
|
187
|
+
border:1px solid var(--border);background:var(--bg);cursor:pointer;font-size:11px;
|
|
188
|
+
user-select:none;transition:opacity .15s,border-color .15s}
|
|
189
|
+
.chip.off{opacity:.35}
|
|
190
|
+
.chip .dot{width:9px;height:9px;border-radius:50%}
|
|
191
|
+
#info .empty{color:var(--muted);line-height:1.5}
|
|
192
|
+
#info .path{font-family:var(--mono);font-size:11px;color:#9fb4ff;word-break:break-all;margin-bottom:8px}
|
|
193
|
+
#info .row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
|
194
|
+
.badge{font-size:10.5px;padding:2px 8px;border-radius:999px;color:#0d1117;font-weight:600}
|
|
195
|
+
.stat{font-size:11px;color:var(--muted)}
|
|
196
|
+
.stat b{color:var(--text);font-weight:600}
|
|
197
|
+
#info .sum{font-size:12.5px;line-height:1.5;margin:4px 0 6px;color:#cdd6e3}
|
|
198
|
+
#info h4{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:14px 0 6px}
|
|
199
|
+
#info ul{list-style:none;margin:0;padding:0}
|
|
200
|
+
#info li{font-family:var(--mono);font-size:11px;padding:4px 8px;border-radius:7px;cursor:pointer;
|
|
201
|
+
color:#c9d1d9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
202
|
+
#info li:hover{background:var(--surface2);color:#fff}
|
|
203
|
+
#info li .pkg{color:var(--muted)}
|
|
204
|
+
#main{position:relative;flex:1}
|
|
205
|
+
#graph{position:absolute;inset:0}
|
|
206
|
+
#bar{position:absolute;top:14px;right:14px;display:flex;gap:6px;z-index:5}
|
|
207
|
+
.btn{background:rgba(22,27,34,.82);backdrop-filter:blur(6px);border:1px solid var(--border);color:var(--text);
|
|
208
|
+
padding:7px 12px;border-radius:8px;font-size:12px;cursor:pointer;font-family:var(--font);
|
|
209
|
+
transition:background .15s,border-color .15s}
|
|
210
|
+
.btn:hover{background:var(--surface2);border-color:#3d4654}
|
|
211
|
+
.btn.active{border-color:var(--accent);color:var(--accent)}
|
|
212
|
+
#load{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
|
213
|
+
flex-direction:column;gap:14px;background:var(--bg);z-index:8;transition:opacity .6s}
|
|
214
|
+
#load.hidden{opacity:0;pointer-events:none}
|
|
215
|
+
.spin{width:30px;height:30px;border-radius:50%;border:3px solid var(--border);
|
|
216
|
+
border-top-color:var(--accent);animation:spin .8s linear infinite}
|
|
217
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
218
|
+
#load span{color:var(--muted);font-size:12px}
|
|
219
|
+
::-webkit-scrollbar{width:9px} ::-webkit-scrollbar-thumb{background:#2b3340;border-radius:9px}
|
|
220
|
+
</style></head>
|
|
221
|
+
<body><div id="app">
|
|
222
|
+
<aside id="side">
|
|
223
|
+
<div class="brand"><b>🧠 cerebro</b><span>dependency graph</span></div>
|
|
224
|
+
<div id="meta"></div>
|
|
225
|
+
<div class="sec">
|
|
226
|
+
<input id="search" placeholder="Search a file…" autocomplete="off" spellcheck="false">
|
|
227
|
+
<div id="hits"></div>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="sec"><div class="lbl">Packages</div><div id="legend"></div></div>
|
|
230
|
+
<div class="sec grow"><div class="lbl">Selection</div><div id="info">
|
|
231
|
+
<div class="empty">Hover a node to light up what it connects to. Drag a node and the web
|
|
232
|
+
follows. Click to pin its details. Size = centrality (PageRank).</div></div></div>
|
|
233
|
+
</aside>
|
|
234
|
+
<div id="main">
|
|
235
|
+
<div id="bar">
|
|
236
|
+
<button class="btn" id="cyc">⚠ Cycles</button>
|
|
237
|
+
<button class="btn" id="orph">○ Orphans</button>
|
|
238
|
+
<button class="btn" id="fit">Fit</button>
|
|
239
|
+
<button class="btn active" id="freeze">❚❚ Pause</button>
|
|
240
|
+
</div>
|
|
241
|
+
<div id="graph"></div>
|
|
242
|
+
<div id="load"><div class="spin"></div><span>simulating layout…</span></div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
<script>
|
|
246
|
+
const DATA = __DATA__, META = __META__;
|
|
247
|
+
const PALETTE = ["#4c8dff","#3fb950","#db6d28","#e3597b","#a371f7","#1f9ce4",
|
|
248
|
+
"#f0a02c","#39c5bb","#e2c044","#ec6a5e","#57ab5a","#bc8cff"];
|
|
249
|
+
const groups=[...new Set(DATA.nodes.map(n=>n.group))].sort();
|
|
250
|
+
const color={}; groups.forEach((g,i)=>color[g]=PALETTE[i%PALETTE.length]);
|
|
251
|
+
const byId={}; DATA.nodes.forEach(n=>byId[n.id]=n);
|
|
252
|
+
|
|
253
|
+
const nbr={}, outM={}, inM={};
|
|
254
|
+
DATA.nodes.forEach(n=>nbr[n.id]=new Set());
|
|
255
|
+
DATA.edges.forEach(e=>{(outM[e.from]=outM[e.from]||[]).push(e.to);(inM[e.to]=inM[e.to]||[]).push(e.from);
|
|
256
|
+
nbr[e.from].add(e.to); nbr[e.to].add(e.from);});
|
|
257
|
+
|
|
258
|
+
document.getElementById('meta').innerHTML =
|
|
259
|
+
`<b style="color:var(--text)">${META.shown}</b> of ${META.total} files`+
|
|
260
|
+
`${META.prefix?` · <span style="color:#9fb4ff">${META.prefix}</span>`:''} · ${DATA.edges.length} imports`+
|
|
261
|
+
`${META.cycles?` · <span style="color:#e2c044">⚠ ${META.cycles} in cycles</span>`:''}`+
|
|
262
|
+
`${META.orphans?` · <span style="color:#ec6a5e">○ ${META.orphans} orphans</span>`:''}`;
|
|
263
|
+
|
|
264
|
+
const hidden=new Set();
|
|
265
|
+
const legend=document.getElementById('legend');
|
|
266
|
+
groups.forEach(g=>{
|
|
267
|
+
const c=document.createElement('div'); c.className='chip'; c.dataset.g=g;
|
|
268
|
+
c.innerHTML=`<i class="dot" style="background:${color[g]}"></i>${g}`;
|
|
269
|
+
c.onclick=()=>{ c.classList.toggle('off'); hidden.has(g)?hidden.delete(g):hidden.add(g); };
|
|
270
|
+
legend.appendChild(c);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
let hl=new Set(), focusId=null, pinned=null, overlay=null;
|
|
274
|
+
const lid=x=>typeof x==='object'?x.id:x;
|
|
275
|
+
const overlaySet=k=>new Set(DATA.nodes.filter(n=>k==='cycle'?n.cycle:n.orphan).map(n=>n.id));
|
|
276
|
+
const REL=3.4;
|
|
277
|
+
const el=document.getElementById('graph');
|
|
278
|
+
|
|
279
|
+
const Graph = ForceGraph()(el)
|
|
280
|
+
.backgroundColor('#0d1117')
|
|
281
|
+
.graphData({nodes:DATA.nodes.map(n=>Object.assign({},n)),
|
|
282
|
+
links:DATA.edges.map(e=>({source:e.from,target:e.to}))})
|
|
283
|
+
.nodeId('id').nodeVal(n=>n.value)
|
|
284
|
+
.nodeRelSize(REL)
|
|
285
|
+
.autoPauseRedraw(false)
|
|
286
|
+
.cooldownTicks(Infinity).d3VelocityDecay(0.30).d3AlphaMin(0)
|
|
287
|
+
.linkDirectionalArrowLength(2).linkDirectionalArrowRelPos(1).linkCurvature(0)
|
|
288
|
+
.linkColor(l=>{const s=lid(l.source),t=lid(l.target);
|
|
289
|
+
if(hidden.has(byId[s].group)||hidden.has(byId[t].group))return 'rgba(0,0,0,0)';
|
|
290
|
+
if(focusId)return (s===focusId||t===focusId)?'rgba(76,141,255,.85)':'rgba(48,54,61,.12)';
|
|
291
|
+
return 'rgba(70,80,95,.28)';})
|
|
292
|
+
.linkWidth(l=>{const s=lid(l.source),t=lid(l.target);return focusId&&(s===focusId||t===focusId)?1.4:0.5;})
|
|
293
|
+
.linkDirectionalParticles(l=>{const s=lid(l.source),t=lid(l.target);return focusId&&(s===focusId||t===focusId)?3:0;})
|
|
294
|
+
.linkDirectionalParticleWidth(2.2).linkDirectionalParticleColor(()=>'#7fb0ff').linkDirectionalParticleSpeed(0.012)
|
|
295
|
+
.nodeCanvasObject((node,ctx,scale)=>{
|
|
296
|
+
if(hidden.has(node.group))return;
|
|
297
|
+
const r=Math.sqrt(node.value)*REL, dim=hl.size&&!hl.has(node.id);
|
|
298
|
+
if(node.id===focusId){ctx.shadowColor=color[node.group];ctx.shadowBlur=22;}
|
|
299
|
+
ctx.beginPath(); ctx.arc(node.x,node.y,r,0,6.2832);
|
|
300
|
+
ctx.globalAlpha=dim?0.45:1; ctx.fillStyle=dim?'#6e7681':color[node.group]; ctx.fill();
|
|
301
|
+
ctx.shadowBlur=0;
|
|
302
|
+
if(node.id===pinned){ctx.lineWidth=2/scale;ctx.strokeStyle='#fff';ctx.stroke();}
|
|
303
|
+
if(!dim&&node.cycle){ctx.strokeStyle='rgba(226,192,68,.9)';ctx.lineWidth=1.6/scale;
|
|
304
|
+
ctx.beginPath();ctx.arc(node.x,node.y,r+2.5/scale,0,6.2832);ctx.stroke();}
|
|
305
|
+
if(!dim&&node.orphan){ctx.strokeStyle='rgba(236,106,94,.95)';ctx.lineWidth=1.4/scale;
|
|
306
|
+
ctx.setLineDash([3/scale,2.5/scale]);ctx.beginPath();
|
|
307
|
+
ctx.arc(node.x,node.y,r+(node.cycle?5:2.5)/scale,0,6.2832);ctx.stroke();ctx.setLineDash([]);}
|
|
308
|
+
ctx.globalAlpha=1;
|
|
309
|
+
if(!dim&&(node.value>9||scale>1.8||hl.has(node.id))){
|
|
310
|
+
ctx.font=`${11/scale}px ${getComputedStyle(document.body).fontFamily}`;
|
|
311
|
+
ctx.textAlign='center'; ctx.textBaseline='top';
|
|
312
|
+
ctx.fillStyle=hl.size&&!hl.has(node.id)?'rgba(230,237,243,.2)':'rgba(230,237,243,.92)';
|
|
313
|
+
ctx.fillText(node.label, node.x, node.y+r+2/scale);
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
.nodePointerAreaPaint((node,col,ctx)=>{
|
|
317
|
+
if(hidden.has(node.group))return;
|
|
318
|
+
ctx.fillStyle=col; ctx.beginPath();
|
|
319
|
+
ctx.arc(node.x,node.y,Math.sqrt(node.value)*REL+2,0,6.2832); ctx.fill();
|
|
320
|
+
})
|
|
321
|
+
.onNodeHover(node=>{ el.style.cursor=node?'pointer':'default';
|
|
322
|
+
if(node) setHL(node);
|
|
323
|
+
else if(pinned) setHL(live[pinned]||{id:pinned});
|
|
324
|
+
else setHL(null); })
|
|
325
|
+
.onNodeClick(node=>{ pinned=node.id; setHL(node); panel(node.id);
|
|
326
|
+
Graph.centerAt(node.x,node.y,600); Graph.zoom(Math.max(Graph.zoom(),2.4),600); })
|
|
327
|
+
.onBackgroundClick(()=>{ pinned=null; setHL(null);
|
|
328
|
+
document.getElementById('info').innerHTML='<div class="empty">Hover a node to light up what it connects to. Drag a node and the web follows. Click to pin its details.</div>'; });
|
|
329
|
+
|
|
330
|
+
Graph.d3Force('charge').strength(-260).distanceMax(900);
|
|
331
|
+
Graph.d3Force('link').distance(42).strength(.45);
|
|
332
|
+
|
|
333
|
+
const live={}; Graph.graphData().nodes.forEach(n=>live[n.id]=n);
|
|
334
|
+
function setHL(node){ if(node){focusId=node.id; hl=new Set([node.id]); nbr[node.id].forEach(x=>hl.add(x));}
|
|
335
|
+
else if(overlay){focusId=null; hl=overlaySet(overlay);}
|
|
336
|
+
else {focusId=null; hl=new Set();} }
|
|
337
|
+
const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
|
|
338
|
+
function li(id){const parts=id.split('/');const base=parts.pop();
|
|
339
|
+
return `<li data-id="${esc(id)}" title="${esc(id)}"><span class="pkg">${esc(parts[0]||'')}/…/</span>${esc(base)}</li>`;}
|
|
340
|
+
function panel(id){const n=byId[id];if(!n)return;const o=(outM[id]||[]),inn=(inM[id]||[]);
|
|
341
|
+
document.getElementById('info').innerHTML=
|
|
342
|
+
`<div class="path">${esc(id)}</div>`+
|
|
343
|
+
`<div class="row"><span class="badge" style="background:${color[n.group]}">${esc(n.group)}</span>`+
|
|
344
|
+
`<span class="stat">PageRank <b>${n.score}</b></span></div>`+
|
|
345
|
+
`<div class="sum">${esc(n.summary)||'<span style="color:var(--muted)">No summary yet — run cerebro-summarize.</span>'}</div>`+
|
|
346
|
+
`<h4>Imports · ${o.length}</h4><ul>${o.map(li).join('')||'<li style="color:var(--muted);cursor:default">none</li>'}</ul>`+
|
|
347
|
+
`<h4>Imported by · ${inn.length}</h4><ul>${inn.map(li).join('')||'<li style="color:var(--muted);cursor:default">none</li>'}</ul>`;}
|
|
348
|
+
function focusNode(id){const node=live[id];if(!node)return;pinned=id;setHL({id});panel(id);
|
|
349
|
+
Graph.centerAt(node.x,node.y,600);Graph.zoom(Math.max(Graph.zoom(),2.4),600);}
|
|
350
|
+
document.getElementById('info').addEventListener('click',e=>{const li=e.target.closest('li[data-id]');if(li)focusNode(li.dataset.id);});
|
|
351
|
+
|
|
352
|
+
const search=document.getElementById('search'),hits=document.getElementById('hits');
|
|
353
|
+
search.addEventListener('input',()=>{const q=search.value.toLowerCase().trim();
|
|
354
|
+
hits.textContent=q?`${DATA.nodes.filter(n=>n.id.toLowerCase().includes(q)).length} match`:'';});
|
|
355
|
+
search.addEventListener('keydown',e=>{if(e.key!=='Enter')return;const q=search.value.toLowerCase().trim();
|
|
356
|
+
const m=DATA.nodes.find(n=>n.id.toLowerCase().includes(q));if(m)focusNode(m.id);});
|
|
357
|
+
|
|
358
|
+
const cycB=document.getElementById('cyc'),orphB=document.getElementById('orph');
|
|
359
|
+
if(META.cycles)cycB.textContent='⚠ Cycles '+META.cycles; if(META.orphans)orphB.textContent='○ Orphans '+META.orphans;
|
|
360
|
+
function setOverlay(k){overlay=overlay===k?null:k;
|
|
361
|
+
cycB.classList.toggle('active',overlay==='cycle'); orphB.classList.toggle('active',overlay==='orphan');
|
|
362
|
+
pinned=null; setHL(null);}
|
|
363
|
+
cycB.onclick=()=>setOverlay('cycle'); orphB.onclick=()=>setOverlay('orphan');
|
|
364
|
+
|
|
365
|
+
document.getElementById('fit').onclick=()=>Graph.zoomToFit(600,60);
|
|
366
|
+
let running=true;const fb=document.getElementById('freeze');
|
|
367
|
+
fb.onclick=()=>{running=!running; running?Graph.resumeAnimation():Graph.pauseAnimation();
|
|
368
|
+
fb.classList.toggle('active',running); fb.textContent=running?'❚❚ Pause':'▶ Resume';};
|
|
369
|
+
|
|
370
|
+
function size(){Graph.width(el.clientWidth).height(el.clientHeight);}
|
|
371
|
+
size(); window.addEventListener('resize',size);
|
|
372
|
+
setTimeout(()=>Graph.zoomToFit(700,70),1200);
|
|
373
|
+
setTimeout(()=>document.getElementById('load').classList.add('hidden'),900);
|
|
374
|
+
</script></body></html>"""
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cerebro-code-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Persistent code-knowledge memory across AI chat sessions (MCP server)
|
|
5
|
+
Project-URL: Homepage, https://github.com/marcodavidd020/cerebro-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/marcodavidd020/cerebro-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/marcodavidd020/cerebro-mcp/issues
|
|
8
|
+
Author-email: Marco Toledo <huancacori@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: claude,code-intelligence,mcp,semantic-search,sqlite,tree-sitter
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: mcp>=1.2.0
|
|
14
|
+
Requires-Dist: networkx>=3.2
|
|
15
|
+
Requires-Dist: pathspec>=0.12
|
|
16
|
+
Requires-Dist: tree-sitter-language-pack>=0.7
|
|
17
|
+
Requires-Dist: tree-sitter>=0.23
|
|
18
|
+
Provides-Extra: semantic
|
|
19
|
+
Requires-Dist: model2vec>=0.3; extra == 'semantic'
|
|
20
|
+
Requires-Dist: numpy>=1.26; extra == 'semantic'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Cerebro 🧠
|
|
24
|
+
|
|
25
|
+
**Persistent code-knowledge memory across AI chat sessions.**
|
|
26
|
+
|
|
27
|
+
Every new chat re-analyzes your project's folders from scratch to understand it,
|
|
28
|
+
burning tokens re-discovering what a previous chat already learned. Cerebro caches
|
|
29
|
+
the *understanding* — not just the files — in a small SQLite "brain" that lives
|
|
30
|
+
**outside** the chat. New sessions **query** it instead of re-reading folders.
|
|
31
|
+
|
|
32
|
+
Instead of reading 50 files (~100k tokens) to understand a project, the model makes
|
|
33
|
+
one `cerebro_map()` call (~2-3k tokens) plus a few targeted lookups.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
Three layers of "traces", cheapest first:
|
|
38
|
+
|
|
39
|
+
1. **Structural map** (free, no LLM) — `tree-sitter` extracts symbols + imports and
|
|
40
|
+
builds a dependency graph. Imports resolve both relative paths and **tsconfig /
|
|
41
|
+
jsconfig path aliases** (`@/...`), so Next.js / NestJS monorepos get real edges.
|
|
42
|
+
**PageRank** ranks the most important modules. Each file is hashed so changes
|
|
43
|
+
are detectable. Languages: Python, JavaScript / TypeScript (incl. JSX/TSX)
|
|
44
|
+
and Dart / Flutter.
|
|
45
|
+
2. **Cached summaries** (the big saver) — as a chat understands a file, it calls
|
|
46
|
+
`cerebro_record(path, summary)` to store a 1-3 sentence **English** summary
|
|
47
|
+
(English tokenizes ~15-30% cheaper than Spanish). Future sessions reuse it.
|
|
48
|
+
3. **Freshness** — each summary is tied to the file's hash. If the file changed,
|
|
49
|
+
the trace is flagged **stale** so only that file gets re-read.
|
|
50
|
+
|
|
51
|
+
> Why not Dijkstra? Code knowledge is a *relevance* problem, not a shortest-path one.
|
|
52
|
+
> The useful algorithms are graph traversal (BFS/DFS, for impact) and centrality
|
|
53
|
+
> (PageRank, for ranking) — not weighted routing.
|
|
54
|
+
|
|
55
|
+
## MCP tools
|
|
56
|
+
|
|
57
|
+
| Tool | Purpose |
|
|
58
|
+
|------|---------|
|
|
59
|
+
| `cerebro_map(top=30)` | Cheap project overview, modules ranked by centrality. **Call first.** |
|
|
60
|
+
| `cerebro_get(path)` | Summary + symbols + dependencies of a file, without reading it. |
|
|
61
|
+
| `cerebro_search(query)` | Hybrid semantic + keyword search; semantic hits resolve to the exact symbol (`path:line`), not just the file. |
|
|
62
|
+
| `cerebro_record(path, summary)` | Leave a trace: store your understanding of a file. |
|
|
63
|
+
| `cerebro_note(content, topic?)` | Record a decision / domain rule / gotcha (the *why*). |
|
|
64
|
+
| `cerebro_recall(query?)` | Recall decisions recorded by past sessions. |
|
|
65
|
+
| `cerebro_stale()` | Files changed since last index + stale summaries. |
|
|
66
|
+
| `cerebro_sync()` | Catch branch switch / git pull / external edits and reindex them. |
|
|
67
|
+
| `cerebro_reindex(paths?)` | Refresh the structural index (only changed files). |
|
|
68
|
+
| `cerebro_impact(path)` | Transitive blast radius: everything that (in)directly imports a file. |
|
|
69
|
+
| `cerebro_cycles()` | Circular-import groups (architecture smell). |
|
|
70
|
+
| `cerebro_orphans(prefix?)` | Code files nothing imports — dead-code candidates (file-level). |
|
|
71
|
+
| `cerebro_dead_symbols(prefix?)` | Unused-export candidates: functions/classes/methods referenced nowhere *in their own project*, inside files that *are* imported (symbol-level dead code). |
|
|
72
|
+
| `cerebro_callers(name)` | Call sites of a symbol (who calls it, with enclosing fn + line). |
|
|
73
|
+
| `cerebro_calls(path)` | Internal functions a file calls (outgoing call edges). |
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
One command onboards any repo — it indexes and prints the exact registration line:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv tool install --from . cerebro # installs the `cerebro` command globally
|
|
81
|
+
cd /path/to/your/repo
|
|
82
|
+
cerebro setup --summarize --embed # index (+ warm summaries / semantic index), then prints next steps
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`cerebro setup` is idempotent. Then run the `claude mcp add …` line it prints, reload
|
|
86
|
+
your editor, and the `cerebro_*` tools are available in chat.
|
|
87
|
+
|
|
88
|
+
## Unified CLI
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
cerebro # no args -> MCP server (stdio); this is what the registration runs
|
|
92
|
+
cerebro setup # index this repo + print MCP registration
|
|
93
|
+
cerebro index [--force] # build/refresh the index
|
|
94
|
+
cerebro search <query> # hybrid semantic + keyword search
|
|
95
|
+
cerebro map # project overview
|
|
96
|
+
cerebro graph # interactive dependency-graph HTML
|
|
97
|
+
cerebro obsidian # export an Obsidian vault
|
|
98
|
+
cerebro summarize / embed
|
|
99
|
+
cerebro impact / cycles / orphans / callers / calls / recall
|
|
100
|
+
cerebro doc-audit <vault> # living docs: flag knowledge notes whose referenced code changed
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Living documentation (`doc-audit`)
|
|
104
|
+
|
|
105
|
+
`cerebro doc-audit <markdown-vault>` cross-checks a curated knowledge vault against
|
|
106
|
+
the code index: it parses each note's code references (`path:line`, backticked
|
|
107
|
+
symbols) and the note's `ultima_verificacion`/`fecha`, then flags notes whose
|
|
108
|
+
referenced files **changed after** they were verified, **moved/were deleted**, or
|
|
109
|
+
mention a **symbol that no longer exists**. `--aliases` maps wiki app names to repo
|
|
110
|
+
dirs (`backend_app=fenix-store-backend,…`); `--fix` patches stale notes' frontmatter
|
|
111
|
+
to `estado: revisar`. This is the bridge between an auto-fresh code index and a
|
|
112
|
+
human-curated wiki — documentation that can't silently rot.
|
|
113
|
+
|
|
114
|
+
`cerebro doc-refresh <note>` closes the loop: it re-audits one stale note against
|
|
115
|
+
the *live* code and prints a briefing — current symbols, summary and dependents for
|
|
116
|
+
each reference, plus the new location of any moved file — exactly the context an
|
|
117
|
+
agent needs to propose the update (self-healing docs, human-reviewed).
|
|
118
|
+
|
|
119
|
+
Without a global install, prefix any command with `uv run` (e.g. `uv run cerebro setup`).
|
|
120
|
+
|
|
121
|
+
Point Cerebro at a specific repo with `CEREBRO_ROOT=/path/to/repo`. It honors
|
|
122
|
+
`.gitignore` plus an optional **`.cerebroignore`** (same syntax) for excluding
|
|
123
|
+
heavy non-source dirs (`backup/`, `**/uploads/`, …) without touching your VCS
|
|
124
|
+
config. `node_modules/`, `.next/`, `dist/`, `build/` are ignored by default.
|
|
125
|
+
|
|
126
|
+
Works on monorepos: index the whole thing at once (a single brain at the root,
|
|
127
|
+
with cross-package alias resolution) or per sub-app (`CEREBRO_ROOT` per package).
|
|
128
|
+
|
|
129
|
+
### Register with Claude Code
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
claude mcp add cerebro -- uv --directory /path/to/cerebro run cerebro
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Set `CEREBRO_ROOT` to the project you want the brain to cover. See `plugin/` for the
|
|
136
|
+
optional Claude Code plugin that auto-injects the map at session start and flags
|
|
137
|
+
edited files as stale.
|
|
138
|
+
|
|
139
|
+
## Scope (MVP)
|
|
140
|
+
|
|
141
|
+
In: structural map, cached summaries, freshness, keyword search, tsconfig/jsconfig
|
|
142
|
+
alias resolution, `.cerebroignore`, batch summary warming (`cerebro-summarize`, via
|
|
143
|
+
headless `claude -p` — no API key), a decision log (`cerebro_note` /
|
|
144
|
+
`cerebro_recall`, surfaced at session start), and git-aware freshness
|
|
145
|
+
(`cerebro_sync` catches branch switch / pull / external edits across nested repos),
|
|
146
|
+
and optional local semantic search (`cerebro-embed` + `--extra semantic`: model2vec
|
|
147
|
+
embeddings — one vector per symbol — no torch, no API key, nothing leaves the
|
|
148
|
+
machine — `cerebro_search` becomes hybrid semantic + keyword and lands on the exact
|
|
149
|
+
symbol (`path:line`), not just the file), and visualizations (`cerebro-graph` →
|
|
150
|
+
self-contained interactive HTML dependency graph; `cerebro-export-obsidian` → an
|
|
151
|
+
Obsidian vault where imports are `[[links]]`), and architecture insights
|
|
152
|
+
(`cerebro_impact` transitive blast radius, `cerebro_cycles` circular imports,
|
|
153
|
+
`cerebro_orphans` dead-code candidates), and a symbol-level call graph
|
|
154
|
+
(`cerebro_callers` / `cerebro_calls`, tree-sitter name-resolved).
|
|
155
|
+
Deferred to v2: a live file watcher, and LSP-backed call graph for type-precise
|
|
156
|
+
resolution (the current call graph resolves by name).
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT © 2026 Marco Toledo — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
cerebro/__init__.py,sha256=eEEQ_sWw07H2kZ4wTCirZ26kRZG-NhHdvOQ1UqEMCnU,99
|
|
2
|
+
cerebro/callgraph.py,sha256=Uzto-AltX1EIYsNCz4GCGTkK5I4aOykb1uTy6oaHEII,1538
|
|
3
|
+
cerebro/cli.py,sha256=I1TcHfDiR_u6qETwEkV3hdG2cKXpHevVqX3sp3O-b4Y,14102
|
|
4
|
+
cerebro/config.py,sha256=fCgkyyCAAY7bCrTiNakQBajYf1blGJflCyaXeNojC1Y,4555
|
|
5
|
+
cerebro/db.py,sha256=nZbtrBMPFmFmpt9wXz7-Xvk3EEbyu0sw7oHYQBYN25w,8933
|
|
6
|
+
cerebro/docaudit.py,sha256=zZn-t3_ql4RG_w_7g-wmcQJKP_cK22EBi9mBpvEN240,8036
|
|
7
|
+
cerebro/embeddings.py,sha256=yZAVlDqvuE9ussAUIcKmJlUfZB7oEF_1nSfJdxfJL5E,5987
|
|
8
|
+
cerebro/gitsync.py,sha256=83S-Mz3LX22v6DLuwJFfkyNNDiXfqllKuNLjmNV5W7g,3957
|
|
9
|
+
cerebro/graph.py,sha256=DWsZ82Vdtej4HX3YpNhPgBx7VbjTeOW8Q1hOUcjgNoE,2821
|
|
10
|
+
cerebro/indexer.py,sha256=uETgCOxU5sk9IBF_UpqLDkDKgVxohE0oewURlVTyL-Q,35385
|
|
11
|
+
cerebro/insights.py,sha256=fBUzRJ-t0bcVXaiW9JEHxr5U6jpOAn7GM9dwfOp2Gls,9167
|
|
12
|
+
cerebro/notes.py,sha256=Xek2NYQFzxs-Sl51aQLtIskuZ3iRNDbqth39mFOp9wA,2456
|
|
13
|
+
cerebro/server.py,sha256=YcKQ6cnAZgYHbRpr8Cb1SHYlJT4QNG8N58XVQOKOvmc,14992
|
|
14
|
+
cerebro/summaries.py,sha256=idmrzSnNtcT8WpqMHpEcS7yVYujSzeE7bMXBP8w0nyY,2607
|
|
15
|
+
cerebro/summarizer.py,sha256=axDVlKoSjjsg6flhxvmNJd-YjvIic3-fl26zxhZVpBE,4008
|
|
16
|
+
cerebro/tsconfig.py,sha256=LxlJyzrtDOobcPwps7f1HxnRvt6UOn7FSdTPR5mpWQc,5708
|
|
17
|
+
cerebro/views.py,sha256=Z7_13r3ys0YM5lNPT7SXBUic-su9kmmcjC70Fz5h3p4,2149
|
|
18
|
+
cerebro/viz.py,sha256=nrcp2yvfm5fECWVmTXMrYz64srvoomXazW6MoY_Blag,18911
|
|
19
|
+
cerebro_code_memory-0.1.0.dist-info/METADATA,sha256=0ThTvg8kCv0XLp2p1ZJjfmxp4PK6-H2pH8ngHkfPf9s,8353
|
|
20
|
+
cerebro_code_memory-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
21
|
+
cerebro_code_memory-0.1.0.dist-info/entry_points.txt,sha256=OJahPgB6TbsPizn_dibpKP8dOfd2ZPcMfcXjZ43eSnQ,417
|
|
22
|
+
cerebro_code_memory-0.1.0.dist-info/licenses/LICENSE,sha256=yiho2YjFT755EnCviLP8pTWu64JpvwqqK9ZNF5aG1_k,1069
|
|
23
|
+
cerebro_code_memory-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[console_scripts]
|
|
2
|
+
cerebro = cerebro.cli:main
|
|
3
|
+
cerebro-code-memory = cerebro.server:main
|
|
4
|
+
cerebro-embed = cerebro.embeddings:main
|
|
5
|
+
cerebro-export-obsidian = cerebro.viz:obsidian_main
|
|
6
|
+
cerebro-graph = cerebro.viz:graph_main
|
|
7
|
+
cerebro-index = cerebro.indexer:main
|
|
8
|
+
cerebro-map = cerebro.server:map_main
|
|
9
|
+
cerebro-recall = cerebro.server:recall_main
|
|
10
|
+
cerebro-summarize = cerebro.summarizer:main
|
|
11
|
+
cerebro-sync = cerebro.gitsync:main
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marco Toledo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|