codebrain 0.3.4__py3-none-any.whl → 0.4.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.
- codebrain/__init__.py +1 -1
- codebrain/cli.py +43 -24
- codebrain/graph/store.py +17 -5
- codebrain/indexer.py +53 -3
- codebrain/mcp_lifecycle.py +88 -36
- codebrain/mcp_server.py +123 -0
- codebrain/watcher/file_watcher.py +76 -3
- {codebrain-0.3.4.dist-info → codebrain-0.4.0.dist-info}/METADATA +1 -1
- {codebrain-0.3.4.dist-info → codebrain-0.4.0.dist-info}/RECORD +13 -13
- {codebrain-0.3.4.dist-info → codebrain-0.4.0.dist-info}/WHEEL +0 -0
- {codebrain-0.3.4.dist-info → codebrain-0.4.0.dist-info}/entry_points.txt +0 -0
- {codebrain-0.3.4.dist-info → codebrain-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {codebrain-0.3.4.dist-info → codebrain-0.4.0.dist-info}/top_level.txt +0 -0
codebrain/__init__.py
CHANGED
codebrain/cli.py
CHANGED
|
@@ -58,9 +58,17 @@ def _require_index(repo_root: Path) -> Path:
|
|
|
58
58
|
import gc
|
|
59
59
|
gc.collect() # Release any lingering connections
|
|
60
60
|
try:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
# Prefer rebuilding in place — deleting the file fails on
|
|
62
|
+
# Windows while any MCP server holds it open. Only fall back
|
|
63
|
+
# to file replacement when the DB is unreadable.
|
|
64
|
+
try:
|
|
65
|
+
from codebrain.graph.store import GraphStore
|
|
66
|
+
with GraphStore(db) as _store:
|
|
67
|
+
_store.clear_all()
|
|
68
|
+
except Exception:
|
|
69
|
+
db.unlink(missing_ok=True)
|
|
70
|
+
for wal in db.parent.glob(f"{db.name}-*"):
|
|
71
|
+
wal.unlink(missing_ok=True)
|
|
64
72
|
from codebrain.indexer import full_index
|
|
65
73
|
full_index(repo_root, db)
|
|
66
74
|
click.echo(click.style("Database rebuilt successfully.", fg="green"), err=True)
|
|
@@ -1618,33 +1626,37 @@ def reindex(ctx: click.Context, yes: bool, as_json: bool) -> None:
|
|
|
1618
1626
|
db = _db_path(repo_root)
|
|
1619
1627
|
|
|
1620
1628
|
if not yes and not as_json:
|
|
1621
|
-
if not click.confirm("This will
|
|
1629
|
+
if not click.confirm("This will rebuild the entire index. Continue?"):
|
|
1622
1630
|
click.echo("Aborted.")
|
|
1623
1631
|
return
|
|
1624
1632
|
|
|
1633
|
+
# Rebuild IN PLACE (clear tables, re-fill) rather than deleting the DB
|
|
1634
|
+
# file. Deleting fails on Windows whenever any MCP server holds the DB
|
|
1635
|
+
# open — and with session-long server lifetimes that is "always". WAL
|
|
1636
|
+
# mode makes the in-place rebuild safe with readers attached, so reindex
|
|
1637
|
+
# never needs to find, kill, or wait for other processes.
|
|
1625
1638
|
if db.exists():
|
|
1639
|
+
from codebrain.graph.store import GraphStore
|
|
1626
1640
|
try:
|
|
1627
|
-
db
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
if
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1641
|
+
with GraphStore(db) as _store:
|
|
1642
|
+
_store.clear_all()
|
|
1643
|
+
except Exception as exc:
|
|
1644
|
+
# Corrupted beyond clearing — replacing the file is the only
|
|
1645
|
+
# option left. This fails if another process holds it open.
|
|
1646
|
+
click.echo(click.style(
|
|
1647
|
+
f" Index unreadable ({exc}); replacing the database file.", fg="yellow",
|
|
1648
|
+
))
|
|
1649
|
+
try:
|
|
1650
|
+
db.unlink()
|
|
1651
|
+
for wal in db.parent.glob(f"{db.name}-*"):
|
|
1652
|
+
wal.unlink(missing_ok=True)
|
|
1653
|
+
except PermissionError:
|
|
1654
|
+
click.echo(click.style(
|
|
1655
|
+
" The corrupted database is held open by a running MCP server.\n"
|
|
1656
|
+
" Close Claude Code sessions for this project (or run "
|
|
1657
|
+
"`brain doctor --kill-stale-mcps`) and retry.", fg="red",
|
|
1658
|
+
))
|
|
1636
1659
|
raise
|
|
1637
|
-
should_kill = yes or as_json or click.confirm(
|
|
1638
|
-
f"Database locked by CodeBrain MCP server PID {holder}. Terminate it and continue?"
|
|
1639
|
-
)
|
|
1640
|
-
if not should_kill:
|
|
1641
|
-
click.echo("Aborted.")
|
|
1642
|
-
return
|
|
1643
|
-
status = kill_pid(holder)
|
|
1644
|
-
click.echo(f" Killed MCP PID {holder}: {status}")
|
|
1645
|
-
import time as _time
|
|
1646
|
-
_time.sleep(1)
|
|
1647
|
-
db.unlink()
|
|
1648
1660
|
|
|
1649
1661
|
files = discover_files(repo_root)
|
|
1650
1662
|
|
|
@@ -1847,6 +1859,13 @@ def repair(ctx: click.Context) -> None:
|
|
|
1847
1859
|
break
|
|
1848
1860
|
except PermissionError:
|
|
1849
1861
|
_time.sleep(0.2)
|
|
1862
|
+
else:
|
|
1863
|
+
click.echo(click.style(
|
|
1864
|
+
"Could not replace the corrupted database — a running MCP server "
|
|
1865
|
+
"holds it open.\nClose Claude Code sessions for this project (or "
|
|
1866
|
+
"run `brain doctor --kill-stale-mcps`) and retry.", fg="red",
|
|
1867
|
+
))
|
|
1868
|
+
sys.exit(1)
|
|
1850
1869
|
# Also clean up WAL/SHM files
|
|
1851
1870
|
for suffix in ("-wal", "-shm"):
|
|
1852
1871
|
wal = db.parent / (db.name + suffix)
|
codebrain/graph/store.py
CHANGED
|
@@ -22,11 +22,18 @@ class GraphStore:
|
|
|
22
22
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
23
|
_log.debug("Opening database %s", self.db_path)
|
|
24
24
|
self.conn = sqlite3.connect(str(self.db_path), timeout=30, check_same_thread=False)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
try:
|
|
26
|
+
self.conn.row_factory = sqlite3.Row
|
|
27
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
28
|
+
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
29
|
+
self.conn.execute("PRAGMA foreign_keys=OFF")
|
|
30
|
+
migrate_db(self.conn)
|
|
31
|
+
except Exception:
|
|
32
|
+
# A corrupted/garbage file makes the pragmas raise AFTER the OS
|
|
33
|
+
# handle is open. Without this close, the dangling handle keeps
|
|
34
|
+
# the file locked on Windows and recovery-by-replacement fails.
|
|
35
|
+
self.conn.close()
|
|
36
|
+
raise
|
|
30
37
|
|
|
31
38
|
def close(self) -> None:
|
|
32
39
|
_log.debug("Closing database %s", self.db_path)
|
|
@@ -113,6 +120,11 @@ class GraphStore:
|
|
|
113
120
|
).fetchone()
|
|
114
121
|
return row["content_hash"] if row else None
|
|
115
122
|
|
|
123
|
+
def all_file_paths(self) -> list[str]:
|
|
124
|
+
"""Return every file path currently in the index."""
|
|
125
|
+
rows = self.conn.execute("SELECT path FROM files").fetchall()
|
|
126
|
+
return [row["path"] for row in rows]
|
|
127
|
+
|
|
116
128
|
# ------------------------------------------------------------------
|
|
117
129
|
# Node operations
|
|
118
130
|
# ------------------------------------------------------------------
|
codebrain/indexer.py
CHANGED
|
@@ -490,6 +490,43 @@ def full_index(
|
|
|
490
490
|
}
|
|
491
491
|
|
|
492
492
|
|
|
493
|
+
def scan_stale(
|
|
494
|
+
repo_root: Path,
|
|
495
|
+
store: GraphStore,
|
|
496
|
+
settings: "Settings | None" = None,
|
|
497
|
+
) -> tuple[list[Path], list[Path]]:
|
|
498
|
+
"""Cheap staleness scan: which files changed since they were indexed?
|
|
499
|
+
|
|
500
|
+
Returns ``(changed_candidates, deleted)``. Uses an mtime-vs-last_indexed
|
|
501
|
+
prefilter so no file contents are read here — false candidates are fine
|
|
502
|
+
because :func:`incremental_update` hash-checks before re-parsing. This is
|
|
503
|
+
the fast freshness layer (milliseconds on a warm FS cache); the exhaustive
|
|
504
|
+
hash-everything pass lives in the watcher's startup catch-up sync.
|
|
505
|
+
"""
|
|
506
|
+
rows = store.conn.execute("SELECT path, last_indexed FROM files").fetchall()
|
|
507
|
+
stored = {row["path"]: row["last_indexed"] for row in rows}
|
|
508
|
+
|
|
509
|
+
changed: list[Path] = []
|
|
510
|
+
seen: set[str] = set()
|
|
511
|
+
# Files modified within this window before indexing finished may have
|
|
512
|
+
# raced the indexer — treat them as candidates (hash check disambiguates).
|
|
513
|
+
slack = 2.0
|
|
514
|
+
for file_path in discover_files(repo_root, settings):
|
|
515
|
+
rel = normalize_path(file_path, repo_root)
|
|
516
|
+
seen.add(rel)
|
|
517
|
+
last_indexed = stored.get(rel)
|
|
518
|
+
if last_indexed is None:
|
|
519
|
+
changed.append(file_path) # new file
|
|
520
|
+
continue
|
|
521
|
+
try:
|
|
522
|
+
if file_path.stat().st_mtime > last_indexed - slack:
|
|
523
|
+
changed.append(file_path)
|
|
524
|
+
except OSError:
|
|
525
|
+
continue
|
|
526
|
+
deleted = [repo_root / rel for rel in stored if rel not in seen]
|
|
527
|
+
return changed, deleted
|
|
528
|
+
|
|
529
|
+
|
|
493
530
|
def incremental_update(
|
|
494
531
|
repo_root: Path,
|
|
495
532
|
changed_files: list[Path],
|
|
@@ -510,6 +547,12 @@ def incremental_update(
|
|
|
510
547
|
store.remove_file(rel)
|
|
511
548
|
removed += 1
|
|
512
549
|
|
|
550
|
+
# tree-sitter can hang holding the GIL on Windows — isolate those
|
|
551
|
+
# extensions in a subprocess, same as full_index. A hang here would
|
|
552
|
+
# otherwise freeze the watcher/catch-up thread while it holds the DB
|
|
553
|
+
# lock, silently stopping auto-indexing for the rest of the session.
|
|
554
|
+
_NEEDS_ISOLATION = frozenset({".ts", ".tsx", ".js", ".jsx"})
|
|
555
|
+
|
|
513
556
|
for file_path in changed_files:
|
|
514
557
|
rel = normalize_path(file_path, repo_root)
|
|
515
558
|
try:
|
|
@@ -519,9 +562,16 @@ def incremental_update(
|
|
|
519
562
|
if current_hash == stored_hash:
|
|
520
563
|
continue
|
|
521
564
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
565
|
+
if sys.platform == "win32" and file_path.suffix in _NEEDS_ISOLATION:
|
|
566
|
+
pf, err = _parse_with_timeout(file_path, repo_root, timeout=30)
|
|
567
|
+
if err:
|
|
568
|
+
errors.append(err)
|
|
569
|
+
continue
|
|
570
|
+
else:
|
|
571
|
+
pf = _parse_file(file_path, repo_root)
|
|
572
|
+
if pf is not None:
|
|
573
|
+
store.upsert_file(pf)
|
|
574
|
+
updated += 1
|
|
525
575
|
except Exception as exc:
|
|
526
576
|
errors.append(f"{rel}: {exc}")
|
|
527
577
|
|
codebrain/mcp_lifecycle.py
CHANGED
|
@@ -79,39 +79,43 @@ def _read_pid_file(pid_file: Path) -> int | None:
|
|
|
79
79
|
return None
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
def _predecessor_host_pid(pid: int) -> int | None:
|
|
83
|
+
"""Return the PID of the live IDE host (claude/cursor/...) in ``pid``'s
|
|
84
|
+
ancestor chain, or None if there is none.
|
|
85
|
+
|
|
86
|
+
A predecessor with a live host belongs to *some* Claude session — but
|
|
87
|
+
whether it must be spared depends on WHICH session: a different host PID
|
|
88
|
+
means a concurrent sibling window (killing it would silently break that
|
|
89
|
+
session's MCP); the SAME host PID as ours means it is our own session's
|
|
90
|
+
orphaned predecessor left behind by a transport disconnect/reconnect,
|
|
91
|
+
and it must die or two servers fight over the SQLite DB.
|
|
88
92
|
"""
|
|
89
93
|
try:
|
|
90
94
|
import psutil
|
|
91
95
|
except ImportError:
|
|
92
|
-
return
|
|
96
|
+
return None
|
|
93
97
|
try:
|
|
94
98
|
proc = psutil.Process(pid)
|
|
95
99
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
96
|
-
return
|
|
100
|
+
return None
|
|
97
101
|
for _ in range(ANCESTOR_WALK_DEPTH):
|
|
98
102
|
try:
|
|
99
103
|
parent = proc.parent()
|
|
100
104
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
101
|
-
return
|
|
105
|
+
return None
|
|
102
106
|
if parent is None:
|
|
103
|
-
return
|
|
107
|
+
return None
|
|
104
108
|
try:
|
|
105
109
|
name = (parent.name() or "").lower()
|
|
106
110
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
107
|
-
return
|
|
111
|
+
return None
|
|
108
112
|
if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
|
|
109
|
-
return
|
|
113
|
+
return parent.pid
|
|
110
114
|
proc = parent
|
|
111
|
-
return
|
|
115
|
+
return None
|
|
112
116
|
|
|
113
117
|
|
|
114
|
-
def _kill_stale_predecessor(pid_file: Path) -> None:
|
|
118
|
+
def _kill_stale_predecessor(pid_file: Path, own_host_pid: int | None = None) -> None:
|
|
115
119
|
if not pid_file.exists():
|
|
116
120
|
return
|
|
117
121
|
old_pid = _read_pid_file(pid_file)
|
|
@@ -128,12 +132,27 @@ def _kill_stale_predecessor(pid_file: Path) -> None:
|
|
|
128
132
|
# PID was reused by an unrelated process. Don't touch.
|
|
129
133
|
_log.debug("PID %d reused by unrelated process; leaving alone", old_pid)
|
|
130
134
|
return
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
135
|
+
pred_host = _predecessor_host_pid(old_pid)
|
|
136
|
+
if pred_host is not None and pred_host != own_host_pid:
|
|
137
|
+
# Live IDE host that is NOT ours → concurrent sibling Claude session
|
|
138
|
+
# is still using this MCP — leave it. Without this, two Claude
|
|
139
|
+
# windows on the same repo race each other and whichever started
|
|
140
|
+
# last kills the other's MCP.
|
|
141
|
+
_log.debug(
|
|
142
|
+
"PID %d has live IDE host %d (ours: %s); sibling MCP, leaving alone",
|
|
143
|
+
old_pid, pred_host, own_host_pid,
|
|
144
|
+
)
|
|
136
145
|
return
|
|
146
|
+
if pred_host is not None:
|
|
147
|
+
# Same host as ours → our own session reconnected and left the old
|
|
148
|
+
# server behind with a dead stdio transport. It will never exit on
|
|
149
|
+
# its own (parent still alive, idle timer disabled when anchored)
|
|
150
|
+
# and holds the SQLite DB — kill it.
|
|
151
|
+
_log.warning(
|
|
152
|
+
"Predecessor PID %d shares our IDE host %d — duplicate from a "
|
|
153
|
+
"transport reconnect, terminating it",
|
|
154
|
+
old_pid, pred_host,
|
|
155
|
+
)
|
|
137
156
|
try:
|
|
138
157
|
proc = psutil.Process(old_pid)
|
|
139
158
|
_log.warning("Killing stale CodeBrain MCP predecessor PID %d", old_pid)
|
|
@@ -167,7 +186,7 @@ def _remove_pid_file(pid_file: Path) -> None:
|
|
|
167
186
|
pass
|
|
168
187
|
|
|
169
188
|
|
|
170
|
-
def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
189
|
+
def _find_watch_target(start_pid: int) -> tuple[int, float | None, bool]:
|
|
171
190
|
"""Pick the PID whose death should kill the MCP.
|
|
172
191
|
|
|
173
192
|
Walks up the ancestor chain (up to ANCESTOR_WALK_DEPTH levels) looking
|
|
@@ -175,6 +194,10 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
|
175
194
|
real IDE host. Falls back to ``start_pid`` if no hint matches, psutil
|
|
176
195
|
is unavailable, or ``CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK=1`` is set.
|
|
177
196
|
|
|
197
|
+
Returns ``(pid, create_time, host_anchored)``. ``host_anchored`` is True
|
|
198
|
+
only when an actual IDE host process was found — callers use it to decide
|
|
199
|
+
whether the idle-timeout backstop is needed at all.
|
|
200
|
+
|
|
178
201
|
Why: on Windows, Claude Code spawns the MCP via a transient launcher
|
|
179
202
|
(cmd.exe wrapper or Electron worker shell). The launcher exits soon
|
|
180
203
|
after the python child starts, so watching ``os.getppid()`` directly
|
|
@@ -183,16 +206,16 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
|
183
206
|
try:
|
|
184
207
|
import psutil
|
|
185
208
|
except ImportError:
|
|
186
|
-
return start_pid, None
|
|
209
|
+
return start_pid, None, False
|
|
187
210
|
|
|
188
211
|
fallback_create_time: float | None = None
|
|
189
212
|
try:
|
|
190
213
|
fallback_create_time = psutil.Process(start_pid).create_time()
|
|
191
214
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
192
|
-
return start_pid, None
|
|
215
|
+
return start_pid, None, False
|
|
193
216
|
|
|
194
217
|
if os.environ.get("CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK") == "1":
|
|
195
|
-
return start_pid, fallback_create_time
|
|
218
|
+
return start_pid, fallback_create_time, False
|
|
196
219
|
|
|
197
220
|
try:
|
|
198
221
|
proc = psutil.Process(start_pid)
|
|
@@ -202,7 +225,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
|
202
225
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
203
226
|
break
|
|
204
227
|
if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
|
|
205
|
-
return proc.pid, proc.create_time()
|
|
228
|
+
return proc.pid, proc.create_time(), True
|
|
206
229
|
try:
|
|
207
230
|
parent = proc.parent()
|
|
208
231
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
@@ -212,7 +235,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
|
212
235
|
proc = parent
|
|
213
236
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
214
237
|
pass
|
|
215
|
-
return start_pid, fallback_create_time
|
|
238
|
+
return start_pid, fallback_create_time, False
|
|
216
239
|
|
|
217
240
|
|
|
218
241
|
def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> None:
|
|
@@ -241,16 +264,35 @@ def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> No
|
|
|
241
264
|
_log.debug("parent watchdog tick error: %s", exc)
|
|
242
265
|
|
|
243
266
|
|
|
244
|
-
def
|
|
245
|
-
|
|
267
|
+
def _effective_idle_timeout(host_anchored: bool) -> int:
|
|
268
|
+
"""Resolve the idle timeout in seconds (0 disables the idle watchdog).
|
|
269
|
+
|
|
270
|
+
The idle watchdog is a *backstop* for when the parent watchdog has no
|
|
271
|
+
reliable IDE host to watch. When we are anchored to a real host
|
|
272
|
+
(claude/cursor/vscode), the parent watchdog deterministically tears the
|
|
273
|
+
server down at session end — an idle timeout on top of that only kills
|
|
274
|
+
the file watcher mid-session and leaves the index stale. So:
|
|
275
|
+
|
|
276
|
+
- explicit ``CODEBRAIN_MCP_IDLE_TIMEOUT`` env var always wins
|
|
277
|
+
- otherwise: disabled when host-anchored, 30-min backstop when not
|
|
278
|
+
"""
|
|
279
|
+
if "CODEBRAIN_MCP_IDLE_TIMEOUT" in os.environ:
|
|
280
|
+
return IDLE_TIMEOUT_SECONDS
|
|
281
|
+
if host_anchored:
|
|
282
|
+
return 0
|
|
283
|
+
return IDLE_TIMEOUT_SECONDS
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _idle_watchdog(timeout: int) -> None:
|
|
287
|
+
if timeout <= 0:
|
|
246
288
|
return
|
|
247
|
-
poll = max(5, min(60,
|
|
289
|
+
poll = max(5, min(60, timeout // 4))
|
|
248
290
|
while True:
|
|
249
291
|
time.sleep(poll)
|
|
250
292
|
with _last_activity_lock:
|
|
251
293
|
idle = time.time() - _last_activity
|
|
252
|
-
if idle >
|
|
253
|
-
_exit(f"idle for {idle:.0f}s (limit {
|
|
294
|
+
if idle > timeout:
|
|
295
|
+
_exit(f"idle for {idle:.0f}s (limit {timeout}s)")
|
|
254
296
|
|
|
255
297
|
|
|
256
298
|
def _lifetime_watchdog(start: float) -> None:
|
|
@@ -277,21 +319,30 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
|
|
|
277
319
|
start = time.time()
|
|
278
320
|
mark_activity()
|
|
279
321
|
|
|
322
|
+
# Resolve our own IDE host FIRST — the predecessor check needs it to
|
|
323
|
+
# distinguish "our own session's orphan after a transport reconnect"
|
|
324
|
+
# (kill) from "a concurrent sibling window's server" (spare).
|
|
325
|
+
immediate_ppid = os.getppid()
|
|
326
|
+
initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
|
|
327
|
+
idle_timeout = _effective_idle_timeout(host_anchored)
|
|
328
|
+
|
|
280
329
|
if repo_root is not None:
|
|
281
330
|
from codebrain.config import CODEBRAIN_DIR
|
|
282
331
|
pid_file = repo_root / CODEBRAIN_DIR / PID_FILE_NAME
|
|
283
|
-
_kill_stale_predecessor(
|
|
332
|
+
_kill_stale_predecessor(
|
|
333
|
+
pid_file,
|
|
334
|
+
own_host_pid=initial_ppid if host_anchored else None,
|
|
335
|
+
)
|
|
284
336
|
_write_pid_file(pid_file)
|
|
285
337
|
atexit.register(_remove_pid_file, pid_file)
|
|
286
338
|
|
|
287
|
-
immediate_ppid = os.getppid()
|
|
288
|
-
initial_ppid, initial_create_time = _find_watch_target(immediate_ppid)
|
|
289
|
-
|
|
290
339
|
_log.info(
|
|
291
|
-
"MCP watchdogs installed (ppid=%d via=%d,
|
|
340
|
+
"MCP watchdogs installed (ppid=%d via=%d, host_anchored=%s, "
|
|
341
|
+
"idle_timeout=%ds, max_lifetime=%ds)",
|
|
292
342
|
initial_ppid,
|
|
293
343
|
immediate_ppid,
|
|
294
|
-
|
|
344
|
+
host_anchored,
|
|
345
|
+
idle_timeout,
|
|
295
346
|
MAX_LIFETIME_SECONDS,
|
|
296
347
|
)
|
|
297
348
|
|
|
@@ -303,6 +354,7 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
|
|
|
303
354
|
).start()
|
|
304
355
|
threading.Thread(
|
|
305
356
|
target=_idle_watchdog,
|
|
357
|
+
args=(idle_timeout,),
|
|
306
358
|
name="cb-idle-watchdog",
|
|
307
359
|
daemon=True,
|
|
308
360
|
).start()
|
codebrain/mcp_server.py
CHANGED
|
@@ -110,6 +110,125 @@ def _make_store():
|
|
|
110
110
|
return GraphStore(_find_db())
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Freshness-on-read
|
|
115
|
+
#
|
|
116
|
+
# Correctness must not depend on which background processes happen to be
|
|
117
|
+
# alive. The watcher (instant) and the startup catch-up sync (thorough) are
|
|
118
|
+
# optimizations; THIS is the guarantee: before serving a tool call, cheaply
|
|
119
|
+
# verify the index matches the working tree and sync the difference. Even if
|
|
120
|
+
# every watcher thread is dead, the answer the agent gets reflects current
|
|
121
|
+
# code.
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
import threading as _threading
|
|
125
|
+
import time as _time
|
|
126
|
+
|
|
127
|
+
FRESHNESS_INTERVAL_SECONDS = float(os.environ.get("CODEBRAIN_FRESHNESS_INTERVAL", "5"))
|
|
128
|
+
# Above this many stale files, sync in the background instead of inline so a
|
|
129
|
+
# huge drift (e.g. branch switch on a big repo) can't eat the tool deadline.
|
|
130
|
+
FRESHNESS_INLINE_LIMIT = 200
|
|
131
|
+
|
|
132
|
+
_freshness_lock = _threading.Lock()
|
|
133
|
+
_last_freshness_check: dict[str, float] = {}
|
|
134
|
+
|
|
135
|
+
# Tools that must NOT trigger a freshness sync:
|
|
136
|
+
# - validators compare new/written content against the PRE-change graph;
|
|
137
|
+
# syncing first would erase the baseline and the structural gate would
|
|
138
|
+
# silently pass everything
|
|
139
|
+
# - reindex_codebase rebuilds anyway
|
|
140
|
+
# - project/memory tools don't read graph data
|
|
141
|
+
_FRESHNESS_EXEMPT_TOOLS = frozenset({
|
|
142
|
+
"validate_change",
|
|
143
|
+
"validate_changes",
|
|
144
|
+
"validate_after_write",
|
|
145
|
+
"propose_change",
|
|
146
|
+
"diff_impact",
|
|
147
|
+
"get_validation_status",
|
|
148
|
+
"reindex_codebase",
|
|
149
|
+
"set_project",
|
|
150
|
+
"get_project",
|
|
151
|
+
"save_memory",
|
|
152
|
+
"recall_memories",
|
|
153
|
+
"list_memories",
|
|
154
|
+
"update_memory",
|
|
155
|
+
"delete_memory",
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _ensure_fresh() -> None:
|
|
160
|
+
"""Best-effort staleness sync before serving a tool call.
|
|
161
|
+
|
|
162
|
+
Throttled per database; never blocks on a concurrent sync (a slightly
|
|
163
|
+
stale answer beats a deadlocked one); never raises.
|
|
164
|
+
"""
|
|
165
|
+
if FRESHNESS_INTERVAL_SECONDS <= 0:
|
|
166
|
+
return
|
|
167
|
+
try:
|
|
168
|
+
db_path = _find_db()
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
return
|
|
171
|
+
key = str(db_path)
|
|
172
|
+
now = _time.monotonic()
|
|
173
|
+
if now - _last_freshness_check.get(key, 0.0) < FRESHNESS_INTERVAL_SECONDS:
|
|
174
|
+
return
|
|
175
|
+
if not _freshness_lock.acquire(blocking=False):
|
|
176
|
+
return # another sync in flight — serve what we have
|
|
177
|
+
try:
|
|
178
|
+
_last_freshness_check[key] = now
|
|
179
|
+
from codebrain.graph.store import GraphStore
|
|
180
|
+
from codebrain.indexer import incremental_update, scan_stale
|
|
181
|
+
|
|
182
|
+
repo_root = db_path.parent.parent
|
|
183
|
+
with GraphStore(db_path) as store:
|
|
184
|
+
changed, deleted = scan_stale(repo_root, store)
|
|
185
|
+
if not changed and not deleted:
|
|
186
|
+
return
|
|
187
|
+
if len(changed) + len(deleted) > FRESHNESS_INLINE_LIMIT:
|
|
188
|
+
_log.info(
|
|
189
|
+
"Freshness: %d files drifted — syncing in background",
|
|
190
|
+
len(changed) + len(deleted),
|
|
191
|
+
)
|
|
192
|
+
_threading.Thread(
|
|
193
|
+
target=_background_freshness_sync,
|
|
194
|
+
args=(repo_root, db_path, changed, deleted),
|
|
195
|
+
name="cb-freshness-sync",
|
|
196
|
+
daemon=True,
|
|
197
|
+
).start()
|
|
198
|
+
return
|
|
199
|
+
result = incremental_update(repo_root, changed, deleted, store)
|
|
200
|
+
if result["files_updated"] or result["files_removed"]:
|
|
201
|
+
_log.info(
|
|
202
|
+
"Freshness: updated %d, removed %d (%.3fs)",
|
|
203
|
+
result["files_updated"],
|
|
204
|
+
result["files_removed"],
|
|
205
|
+
result["elapsed_seconds"],
|
|
206
|
+
)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
_log.debug("Freshness sync skipped: %s", exc)
|
|
209
|
+
finally:
|
|
210
|
+
_freshness_lock.release()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _background_freshness_sync(
|
|
214
|
+
repo_root: Path, db_path: Path, changed: list, deleted: list,
|
|
215
|
+
) -> None:
|
|
216
|
+
try:
|
|
217
|
+
from codebrain.graph.store import GraphStore
|
|
218
|
+
from codebrain.indexer import incremental_update
|
|
219
|
+
|
|
220
|
+
with GraphStore(db_path) as store:
|
|
221
|
+
result = incremental_update(repo_root, changed, deleted, store)
|
|
222
|
+
_log.info(
|
|
223
|
+
"Background freshness sync: updated %d, removed %d (%.3fs)",
|
|
224
|
+
result["files_updated"],
|
|
225
|
+
result["files_removed"],
|
|
226
|
+
result["elapsed_seconds"],
|
|
227
|
+
)
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
_log.warning("Background freshness sync failed: %s", exc)
|
|
230
|
+
|
|
231
|
+
|
|
113
232
|
def _safe_tool(fn): # noqa: ANN001, ANN201
|
|
114
233
|
"""Wrap an MCP tool so it cooperates with MCP cancellation.
|
|
115
234
|
|
|
@@ -214,6 +333,10 @@ def _run_sync_protected(fn, args, kwargs): # noqa: ANN001, ANN202
|
|
|
214
333
|
saved_stdout = sys.stdout
|
|
215
334
|
sys.stdout = sys.stderr
|
|
216
335
|
try:
|
|
336
|
+
# Freshness gate: sync index drift before answering. Skip for tools
|
|
337
|
+
# that rebuild or don't read the graph state being synced.
|
|
338
|
+
if fn.__name__ not in _FRESHNESS_EXEMPT_TOOLS:
|
|
339
|
+
_ensure_fresh()
|
|
217
340
|
return fn(*args, **kwargs)
|
|
218
341
|
finally:
|
|
219
342
|
sys.stdout = saved_stdout
|
|
@@ -11,9 +11,10 @@ from watchdog.observers import Observer
|
|
|
11
11
|
|
|
12
12
|
from codebrain.config import INDEXABLE_EXTENSIONS, WATCHER_DEBOUNCE_SECONDS
|
|
13
13
|
from codebrain.graph.store import GraphStore
|
|
14
|
-
from codebrain.indexer import incremental_update
|
|
14
|
+
from codebrain.indexer import discover_files, incremental_update
|
|
15
|
+
from codebrain.utils import normalize_path
|
|
15
16
|
from codebrain.logging import get_logger
|
|
16
|
-
from codebrain.settings import load_settings
|
|
17
|
+
from codebrain.settings import Settings, load_settings
|
|
17
18
|
|
|
18
19
|
_log = get_logger("watcher")
|
|
19
20
|
|
|
@@ -37,6 +38,8 @@ class _DebouncedHandler(FileSystemEventHandler):
|
|
|
37
38
|
self._changed: set[Path] = set()
|
|
38
39
|
self._deleted: set[Path] = set()
|
|
39
40
|
self._lock = threading.Lock()
|
|
41
|
+
# Serializes DB writes between flush timer threads and the catch-up thread.
|
|
42
|
+
self.db_lock = threading.Lock()
|
|
40
43
|
self._timer: threading.Timer | None = None
|
|
41
44
|
self._last_validation: dict[str, object] = {} # rel_path -> ValidationReport
|
|
42
45
|
|
|
@@ -57,6 +60,14 @@ class _DebouncedHandler(FileSystemEventHandler):
|
|
|
57
60
|
if not changed and not deleted:
|
|
58
61
|
return
|
|
59
62
|
|
|
63
|
+
# Live file edits count as activity — without this the idle watchdog
|
|
64
|
+
# kills the MCP (and this watcher with it) mid-editing-session.
|
|
65
|
+
try:
|
|
66
|
+
from codebrain.mcp_lifecycle import mark_activity
|
|
67
|
+
mark_activity()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
60
71
|
# Redirect stdout to stderr to prevent MCP stdio protocol corruption
|
|
61
72
|
# when running inside the MCP server process.
|
|
62
73
|
import sys
|
|
@@ -67,7 +78,8 @@ class _DebouncedHandler(FileSystemEventHandler):
|
|
|
67
78
|
if changed:
|
|
68
79
|
self._validate_changed(changed)
|
|
69
80
|
|
|
70
|
-
|
|
81
|
+
with self.db_lock:
|
|
82
|
+
result = incremental_update(self.repo_root, changed, deleted, self.store)
|
|
71
83
|
total = result["files_updated"] + result["files_removed"]
|
|
72
84
|
if total:
|
|
73
85
|
_log.info(
|
|
@@ -147,6 +159,59 @@ class _DebouncedHandler(FileSystemEventHandler):
|
|
|
147
159
|
self._schedule_flush()
|
|
148
160
|
|
|
149
161
|
|
|
162
|
+
def catch_up_sync(
|
|
163
|
+
repo_root: Path,
|
|
164
|
+
store: GraphStore,
|
|
165
|
+
settings: Settings | None = None,
|
|
166
|
+
db_lock: threading.Lock | None = None,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""Bring the index up to date with changes made while no watcher was alive.
|
|
169
|
+
|
|
170
|
+
The file watcher only sees events that happen while its process is
|
|
171
|
+
running. Edits made between sessions (or after a lifecycle watchdog
|
|
172
|
+
killed the MCP server) are otherwise missed forever, leaving the index
|
|
173
|
+
stale until a manual `brain reindex`. This diffs disk vs. index:
|
|
174
|
+
|
|
175
|
+
- changed/new files: detected by content hash inside incremental_update
|
|
176
|
+
- deleted files: indexed paths that no longer exist on disk
|
|
177
|
+
|
|
178
|
+
Returns the incremental_update summary dict.
|
|
179
|
+
"""
|
|
180
|
+
on_disk = discover_files(repo_root, settings)
|
|
181
|
+
disk_rels = {normalize_path(p, repo_root) for p in on_disk}
|
|
182
|
+
deleted = [
|
|
183
|
+
repo_root / rel
|
|
184
|
+
for rel in store.all_file_paths()
|
|
185
|
+
if rel not in disk_rels
|
|
186
|
+
]
|
|
187
|
+
lock = db_lock if db_lock is not None else threading.Lock()
|
|
188
|
+
with lock:
|
|
189
|
+
result = incremental_update(repo_root, on_disk, deleted, store)
|
|
190
|
+
if result["files_updated"] or result["files_removed"]:
|
|
191
|
+
_log.info(
|
|
192
|
+
"Catch-up sync: updated %d, removed %d (%.3fs)",
|
|
193
|
+
result["files_updated"],
|
|
194
|
+
result["files_removed"],
|
|
195
|
+
result["elapsed_seconds"],
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
_log.info("Catch-up sync: index already current")
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _catch_up_in_background(
|
|
203
|
+
repo_root: Path, store: GraphStore, settings: Settings, handler: _DebouncedHandler,
|
|
204
|
+
) -> None:
|
|
205
|
+
# NOTE: deliberately no sys.stdout redirect here — this thread runs
|
|
206
|
+
# concurrently with the MCP initialize handshake on the real stdout;
|
|
207
|
+
# swapping the global stdout would corrupt the JSON-RPC stream.
|
|
208
|
+
# Parsers do not print, so there is nothing to redirect anyway.
|
|
209
|
+
try:
|
|
210
|
+
catch_up_sync(repo_root, store, settings, db_lock=handler.db_lock)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
_log.warning("Catch-up sync failed: %s", exc)
|
|
213
|
+
|
|
214
|
+
|
|
150
215
|
def start_watching_background(
|
|
151
216
|
repo_root: Path, db_path: Path,
|
|
152
217
|
) -> tuple[Observer, GraphStore, _DebouncedHandler]:
|
|
@@ -166,7 +231,15 @@ def start_watching_background(
|
|
|
166
231
|
observer = Observer()
|
|
167
232
|
observer.daemon = True
|
|
168
233
|
observer.schedule(handler, str(repo_root), recursive=True)
|
|
234
|
+
# Observer starts BEFORE the catch-up scan so no event falls in the gap;
|
|
235
|
+
# the hash check in incremental_update makes any overlap harmless.
|
|
169
236
|
observer.start()
|
|
237
|
+
threading.Thread(
|
|
238
|
+
target=_catch_up_in_background,
|
|
239
|
+
args=(repo_root, store, settings, handler),
|
|
240
|
+
name="cb-catchup-sync",
|
|
241
|
+
daemon=True,
|
|
242
|
+
).start()
|
|
170
243
|
_log.info("Background watcher started for %s", repo_root)
|
|
171
244
|
return observer, store, handler
|
|
172
245
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebrain
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Know what breaks before you break it. Structural knowledge graph for codebases — impact analysis, dead code detection, health scores. No LLM required.
|
|
5
5
|
Author: CodeBrain Contributors
|
|
6
6
|
License: MIT License
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
codebrain/__init__.py,sha256
|
|
1
|
+
codebrain/__init__.py,sha256=HrQ5FQd5GTjIxch8KWjHo5Q2CjcKK-ffXNRmY8Cr8ug,392
|
|
2
2
|
codebrain/__main__.py,sha256=dgd9lRZovV1k1tScEW_wvPPjPEACXc-EcLtJ7AN3M48,115
|
|
3
3
|
codebrain/agent_bridge.py,sha256=JDny3232R6rfFsQTWOoYygGgykx6rb8ektLRENPUcDQ,6262
|
|
4
4
|
codebrain/analyzer.py,sha256=RxyajZp0E8XuB_1SkK1UEXD00gOo3e2vtBzS-US6ms8,40701
|
|
5
5
|
codebrain/api.py,sha256=LmeFK3KyUKR1xz4tAoPteaRA06c09KA965Hk33VhV6I,31653
|
|
6
6
|
codebrain/api_models.py,sha256=mE5sIsf8WLEWjGm2w6-baeyVoJH_65FOglHwFe3aJhs,1989
|
|
7
7
|
codebrain/architecture.py,sha256=RCN_LUNTjd0fXYFIYDacwH17qEeFCxNCyF6irbs5yfE,29485
|
|
8
|
-
codebrain/cli.py,sha256=
|
|
8
|
+
codebrain/cli.py,sha256=RbXLvRDMXihedsPH-CxbQjWTvFyJbsyFCdtRhMbVk2s,170135
|
|
9
9
|
codebrain/comprehension.py,sha256=eAVf1abecxVo72_2p8aEgRmSeXR7VNMyJnt_DTst47E,83023
|
|
10
10
|
codebrain/config.py,sha256=UvStpfDNvulMHkN7xnGjEF3EKvdf9tF3Cv4A01FdEQw,1745
|
|
11
11
|
codebrain/context.py,sha256=BFZ_WWRf242cJli-TXoz5JFqcvsKkwbmuUMBX8WvM9o,10980
|
|
@@ -18,13 +18,13 @@ codebrain/export.py,sha256=A4zdzJ2MVmo-sxJQjwoZlMRkm-QXCxE14lYDm9RrQEY,11576
|
|
|
18
18
|
codebrain/frontend.py,sha256=4kFIcWoPKS_pLaWAirmkJmYSYTlicCsi_GZgLLjunng,17675
|
|
19
19
|
codebrain/hook_runner.py,sha256=1shd-lw8Rx8zNOuJfgcbUOzhsNUVIwhmuzDpTQsdnHo,5458
|
|
20
20
|
codebrain/hooks.py,sha256=tT2Dx_T0iZLEQarL7QYvvVObYfelMUKVNXNz74MU-tM,3179
|
|
21
|
-
codebrain/indexer.py,sha256=
|
|
21
|
+
codebrain/indexer.py,sha256=NaMj-1ycT4cINJfgykjZC9cAs7cxVLfaojY0AKxaEZw,21083
|
|
22
22
|
codebrain/kt.py,sha256=iXeHArPbSrrr2ObH4nuyXPobdBXSOR3b7JbIqXB0kj4,17199
|
|
23
23
|
codebrain/kt_video.py,sha256=quaUyPUJh8xXsj52aBCPp_L8fekNC4vHmNYrUTfCqcE,29211
|
|
24
24
|
codebrain/llm.py,sha256=0D5c5BJMVkLz2OoybfMxlUdJEbz16cZoNgVmm-LzFH0,25606
|
|
25
25
|
codebrain/logging.py,sha256=ORR5L8REVlh4aJz9vqErmi76aqjLN2xVKoA2gSv_jas,1110
|
|
26
|
-
codebrain/mcp_lifecycle.py,sha256=
|
|
27
|
-
codebrain/mcp_server.py,sha256=
|
|
26
|
+
codebrain/mcp_lifecycle.py,sha256=xo-T1-RNCzFRKhWcKUkEF6ZahPCISp6swTfwRrKazGg,16598
|
|
27
|
+
codebrain/mcp_server.py,sha256=tdSmB4w6KYcXi1lHxcxle-adR12Nm-v7mRSoxBDKiD4,115349
|
|
28
28
|
codebrain/migration.py,sha256=ZMcug6OsvvK-DVdfmqhWCUx-oVj0KQD0EGlBVx6Jxvk,29489
|
|
29
29
|
codebrain/modernize.py,sha256=4usRjIFONEThpYhemLIjoYXb9ohEbO7j2wzVO15Dz5A,38525
|
|
30
30
|
codebrain/onboard.py,sha256=m63A0A8pJdZUiGFgAlRD1ySe7Bk_Eo_fiTPzLfkL1ig,15270
|
|
@@ -48,7 +48,7 @@ codebrain/actions/test_gen.py,sha256=PAnHfYuLp-AIF4YrQYQaBH5ADWhCqUVEI7bFDquxPjs
|
|
|
48
48
|
codebrain/graph/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
49
49
|
codebrain/graph/query.py,sha256=FdkWTIKteCdAGnal01LJDuoSXzSpNp3AT9e0AH-m1eg,44131
|
|
50
50
|
codebrain/graph/schema.py,sha256=5PItrNUMQt37kTsL3SUVLPB7rP2JtQXx-Y4uxX0A0H0,6521
|
|
51
|
-
codebrain/graph/store.py,sha256=
|
|
51
|
+
codebrain/graph/store.py,sha256=x8xkOrSGWQexIjEKP09CwCo8v1E1W4w34QQAUC8gvyE,16272
|
|
52
52
|
codebrain/memory/__init__.py,sha256=PuFR35m1GPIbRAuUxKu4JYBDlPXJWMPbyrWiggFT2sU,158
|
|
53
53
|
codebrain/memory/store.py,sha256=W31fe4aNTkdi-Woe8bZ8SZ9aUHpWrfBkb-9dQs0geyI,9394
|
|
54
54
|
codebrain/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -73,10 +73,10 @@ codebrain/parser/typescript_parser.py,sha256=fi0DoXLCi_L0aQzpadumDpFHaSyekfp3wmV
|
|
|
73
73
|
codebrain/parser/typescript_treesitter.py,sha256=7RTdcA-HTgrlRhEMwSGa-63saNcjqtPiKZYrEOBmlD4,26391
|
|
74
74
|
codebrain/parser/vue_parser.py,sha256=ZWtjUOyItn_SNfvD6T8j7alV4aIfcHA94WDCS5P59Ps,13054
|
|
75
75
|
codebrain/watcher/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
76
|
-
codebrain/watcher/file_watcher.py,sha256=
|
|
77
|
-
codebrain-0.
|
|
78
|
-
codebrain-0.
|
|
79
|
-
codebrain-0.
|
|
80
|
-
codebrain-0.
|
|
81
|
-
codebrain-0.
|
|
82
|
-
codebrain-0.
|
|
76
|
+
codebrain/watcher/file_watcher.py,sha256=6x-fWIP5YBuwq5BCX6HOV-Kc8EVgE4JUoeLxYJtxDf0,9454
|
|
77
|
+
codebrain-0.4.0.dist-info/licenses/LICENSE,sha256=Dxb0L3H90lGFFik90WQXfMkM_8utGA1BDqizqdCT3UE,1079
|
|
78
|
+
codebrain-0.4.0.dist-info/METADATA,sha256=W-Gb7JPxHu6eSD_bDmm42i9NU7TLxxls8Ylp5mTEbsw,10129
|
|
79
|
+
codebrain-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
80
|
+
codebrain-0.4.0.dist-info/entry_points.txt,sha256=FHKSyI7le7GK78HxAKQK54-yzt-yHNxX0df3VpGC5wg,44
|
|
81
|
+
codebrain-0.4.0.dist-info/top_level.txt,sha256=mUxCZc80EyNOMzd2vAm22uIhnOb1Aw7ZOAUT_u-ksx4,10
|
|
82
|
+
codebrain-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|