brain-wiki 0.1.0.dev0__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.
Files changed (141) hide show
  1. brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
  2. brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
  3. brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
  4. brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
  5. brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  6. brainwiki/__init__.py +17 -0
  7. brainwiki/__main__.py +5 -0
  8. brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
  9. brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
  10. brainwiki/assets/plugin/.mcp.json +16 -0
  11. brainwiki/assets/plugin/mcp.json +17 -0
  12. brainwiki/assets/plugin/mcp_config.json +15 -0
  13. brainwiki/assets/plugin/plugin.json +37 -0
  14. brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
  15. brainwiki/assets/plugin/server.json +30 -0
  16. brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
  17. brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
  18. brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
  19. brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
  20. brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
  21. brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
  22. brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
  23. brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
  24. brainwiki/assets/prompts/consolidate.md +89 -0
  25. brainwiki/assets/prompts/distill.md +96 -0
  26. brainwiki/assets/prompts/harvest.md +142 -0
  27. brainwiki/bench/__init__.py +8 -0
  28. brainwiki/bench/_generate.py +440 -0
  29. brainwiki/bench/_report.py +166 -0
  30. brainwiki/bench/_suites.py +959 -0
  31. brainwiki/cli/__init__.py +7 -0
  32. brainwiki/cli/_cmd_bench.py +101 -0
  33. brainwiki/cli/_cmd_browse.py +281 -0
  34. brainwiki/cli/_cmd_consolidate.py +108 -0
  35. brainwiki/cli/_cmd_core.py +155 -0
  36. brainwiki/cli/_cmd_harvest.py +137 -0
  37. brainwiki/cli/_cmd_hosts.py +52 -0
  38. brainwiki/cli/_cmd_import.py +149 -0
  39. brainwiki/cli/_cmd_lint.py +117 -0
  40. brainwiki/cli/_cmd_read.py +131 -0
  41. brainwiki/cli/_cmd_runs.py +66 -0
  42. brainwiki/cli/_cmd_viz.py +57 -0
  43. brainwiki/cli/_cmd_write.py +122 -0
  44. brainwiki/cli/_main.py +511 -0
  45. brainwiki/cli/_output.py +19 -0
  46. brainwiki/config/__init__.py +56 -0
  47. brainwiki/config/_dirs.py +69 -0
  48. brainwiki/config/_manifest.py +307 -0
  49. brainwiki/config/_resolve.py +169 -0
  50. brainwiki/config/_user.py +183 -0
  51. brainwiki/consolidate/__init__.py +19 -0
  52. brainwiki/consolidate/_apply.py +408 -0
  53. brainwiki/consolidate/_packet.py +489 -0
  54. brainwiki/consolidate/_pipeline.py +215 -0
  55. brainwiki/consolidate/_presets.py +230 -0
  56. brainwiki/consolidate/_review.py +104 -0
  57. brainwiki/consolidate/_schema.py +153 -0
  58. brainwiki/consolidate/_validate.py +252 -0
  59. brainwiki/doctor.py +216 -0
  60. brainwiki/engine.py +80 -0
  61. brainwiki/errors.py +81 -0
  62. brainwiki/format/__init__.py +112 -0
  63. brainwiki/format/_agents_md.py +26 -0
  64. brainwiki/format/_claims.py +95 -0
  65. brainwiki/format/_document.py +203 -0
  66. brainwiki/format/_edit.py +94 -0
  67. brainwiki/format/_episode.py +67 -0
  68. brainwiki/format/_fields.py +350 -0
  69. brainwiki/format/_frontmatter.py +175 -0
  70. brainwiki/format/_links.py +72 -0
  71. brainwiki/format/_meta.py +67 -0
  72. brainwiki/format/_normalize.py +70 -0
  73. brainwiki/format/_relations.py +211 -0
  74. brainwiki/format/_rules.py +41 -0
  75. brainwiki/harvest.py +414 -0
  76. brainwiki/hosts/__init__.py +22 -0
  77. brainwiki/hosts/_antigravity.py +163 -0
  78. brainwiki/hosts/_claude_code.py +174 -0
  79. brainwiki/hosts/_codex.py +149 -0
  80. brainwiki/hosts/_connect.py +443 -0
  81. brainwiki/hosts/_detect.py +39 -0
  82. brainwiki/hosts/_instructions.py +220 -0
  83. brainwiki/hosts/_sessions.py +176 -0
  84. brainwiki/ids.py +69 -0
  85. brainwiki/importer/__init__.py +17 -0
  86. brainwiki/importer/_episodes.py +112 -0
  87. brainwiki/importer/_into.py +102 -0
  88. brainwiki/importer/_normalize.py +64 -0
  89. brainwiki/importer/_overlay.py +199 -0
  90. brainwiki/importer/_survey.py +167 -0
  91. brainwiki/index/__init__.py +44 -0
  92. brainwiki/index/_brains.py +119 -0
  93. brainwiki/index/_db.py +131 -0
  94. brainwiki/index/_guards.py +154 -0
  95. brainwiki/index/_neighbors.py +174 -0
  96. brainwiki/index/_orient.py +400 -0
  97. brainwiki/index/_read.py +285 -0
  98. brainwiki/index/_resolver.py +147 -0
  99. brainwiki/index/_scanner.py +665 -0
  100. brainwiki/index/_schema.py +164 -0
  101. brainwiki/index/_search.py +659 -0
  102. brainwiki/index/_topics.py +253 -0
  103. brainwiki/index/_written.py +29 -0
  104. brainwiki/lifecycle.py +158 -0
  105. brainwiki/lint/__init__.py +20 -0
  106. brainwiki/lint/_catalog.py +85 -0
  107. brainwiki/lint/_cross.py +649 -0
  108. brainwiki/lint/_files.py +483 -0
  109. brainwiki/lint/_fixes.py +87 -0
  110. brainwiki/lint/_hook.py +54 -0
  111. brainwiki/lint/_model.py +103 -0
  112. brainwiki/lint/_reports.py +242 -0
  113. brainwiki/lint/_runner.py +156 -0
  114. brainwiki/logs.py +34 -0
  115. brainwiki/mcp/__init__.py +7 -0
  116. brainwiki/mcp/_server.py +917 -0
  117. brainwiki/origin.py +97 -0
  118. brainwiki/runs/__init__.py +21 -0
  119. brainwiki/runs/_git.py +89 -0
  120. brainwiki/runs/_lock.py +42 -0
  121. brainwiki/runs/_record.py +145 -0
  122. brainwiki/runs/_transaction.py +441 -0
  123. brainwiki/runs/_undo.py +159 -0
  124. brainwiki/setup_wizard.py +143 -0
  125. brainwiki/store/__init__.py +7 -0
  126. brainwiki/store/_local.py +153 -0
  127. brainwiki/viz/__init__.py +9 -0
  128. brainwiki/viz/_export.py +414 -0
  129. brainwiki/viz/_lod.py +455 -0
  130. brainwiki/viz/_server.py +327 -0
  131. brainwiki/viz/app/BUILD_INFO.json +5 -0
  132. brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
  133. brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
  134. brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
  135. brainwiki/viz/app/index.html +14 -0
  136. brainwiki/write/__init__.py +23 -0
  137. brainwiki/write/_edits.py +730 -0
  138. brainwiki/write/_episode.py +195 -0
  139. brainwiki/write/_move.py +181 -0
  140. brainwiki/write/_rules.py +204 -0
  141. brainwiki/write/_secrets.py +108 -0
brainwiki/viz/_lod.py ADDED
@@ -0,0 +1,455 @@
1
+ """Level-of-detail payloads for the visualizer: world, topic, health (design doc 12 §2, §6).
2
+
3
+ Pure functions over a `build_graph` dict; no database access.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import colorsys
9
+ from datetime import UTC, date, datetime, timedelta
10
+ from typing import Any
11
+
12
+ _SINGLE_MAX = 12
13
+ _GROUP_MIN = 4
14
+ _GROUP_MAX = 40
15
+ _RECENT_DAYS = 7
16
+ _MAX_EXTERNAL = 400
17
+ _FLOWS_PER_TOPIC = 8
18
+ _MAX_HEALTH = 200
19
+ MAX_SEARCH = 20
20
+ _PASSES = 50
21
+ _EPS = 1e-12
22
+
23
+ _Adjacency = dict[str, dict[str, float]]
24
+
25
+
26
+ # --- constellations -------------------------------------------------------
27
+
28
+
29
+ def topic_weights(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> _Adjacency:
30
+ """Undirected topic graph: weight = typed links between two different topics."""
31
+ topic_of = {n["id"]: n["topic"] for n in nodes}
32
+ adj: _Adjacency = {}
33
+ for link in links:
34
+ if not link["typed"]:
35
+ continue
36
+ a, b = topic_of.get(link["source"]), topic_of.get(link["target"])
37
+ if a is None or b is None or a == b:
38
+ continue
39
+ adj.setdefault(a, {})[b] = adj.setdefault(a, {}).get(b, 0) + 1
40
+ adj.setdefault(b, {})[a] = adj.setdefault(b, {}).get(a, 0) + 1
41
+ return adj
42
+
43
+
44
+ def _group_topics(slugs: list[str], adj: _Adjacency) -> list[list[str]]:
45
+ """Deterministic constellations: sorted slug lists, largest first."""
46
+ slugs = sorted(set(slugs))
47
+ if len(slugs) <= _SINGLE_MAX:
48
+ return [slugs] if slugs else []
49
+ present = set(slugs)
50
+ sub = {s: {t: w for t, w in adj.get(s, {}).items() if t in present} for s in slugs}
51
+ linked = [s for s in slugs if sub[s]]
52
+ unlinked = [s for s in slugs if not sub[s]]
53
+ groups = _merge_small(_cluster(linked, sub), sub) if linked else []
54
+ groups = [part for g in groups for part in _split(g, sub)]
55
+ groups += _chunks(unlinked)
56
+ return sorted(groups, key=lambda g: (-len(g), g[0]))
57
+
58
+
59
+ def tree_groups(
60
+ slugs: list[str], parents: dict[str, str | None], adj: _Adjacency
61
+ ) -> list[list[str]]:
62
+ """Constellations from the topic tree (02 §2): each top-level topic that has sub-topics, with
63
+ everything below it; the remaining topics are grouped by their links (`group_topics`)."""
64
+ present = sorted(set(slugs))
65
+ if len(present) <= _SINGLE_MAX:
66
+ return [present] if present else []
67
+
68
+ def root(slug: str) -> str:
69
+ seen = {slug}
70
+ while (parent := parents.get(slug)) in present and parent not in seen:
71
+ slug = parent
72
+ seen.add(slug)
73
+ return slug
74
+
75
+ by_root: dict[str, list[str]] = {}
76
+ for slug in present:
77
+ by_root.setdefault(root(slug), []).append(slug)
78
+ trees = [members for members in by_root.values() if len(members) > 1]
79
+ rest = [members[0] for members in by_root.values() if len(members) == 1]
80
+ groups = trees + (_group_topics(rest, adj) if rest else [])
81
+ return sorted(groups, key=lambda g: (-len(g), g[0]))
82
+
83
+
84
+ def _chunks(slugs: list[str]) -> list[list[str]]:
85
+ return [slugs[i : i + _GROUP_MAX] for i in range(0, len(slugs), _GROUP_MAX)]
86
+
87
+
88
+ def _split(group: list[str], adj: _Adjacency) -> list[list[str]]:
89
+ if len(group) <= _GROUP_MAX:
90
+ return [group]
91
+ parts = _merge_small(_cluster(group, adj), adj)
92
+ if len(parts) < 2:
93
+ return _chunks(group)
94
+ return [p for part in parts for p in _split(part, adj)]
95
+
96
+
97
+ def _cluster(members: list[str], adj: _Adjacency) -> list[list[str]]:
98
+ """Two levels of Louvain local moving over the subgraph induced by `members`."""
99
+ inside = set(members)
100
+ sub = {m: {t: w for t, w in adj.get(m, {}).items() if t in inside} for m in members}
101
+ degree = {m: float(sum(sub[m].values())) for m in members}
102
+ first = _local_moving(members, sub, degree)
103
+ level1 = _groups(members, first)
104
+
105
+ # Second level: each first-level community becomes one node.
106
+ names = [g[0] for g in level1]
107
+ owner = {m: g[0] for g in level1 for m in g}
108
+ agg: _Adjacency = {n: {} for n in names}
109
+ for m in members:
110
+ for t, w in sub[m].items():
111
+ a, b = owner[m], owner[t]
112
+ if a != b:
113
+ agg[a][b] = agg[a].get(b, 0) + w
114
+ agg_degree = {g[0]: sum(degree[m] for m in g) for g in level1}
115
+ second = _local_moving(names, agg, agg_degree)
116
+ merged = {m: second[owner[m]] for m in members}
117
+ return _groups(members, merged)
118
+
119
+
120
+ def _local_moving(order: list[str], adj: _Adjacency, degree: dict[str, float]) -> dict[str, int]:
121
+ comm = {n: i for i, n in enumerate(order)}
122
+ m2 = sum(degree.values())
123
+ if m2 <= 0:
124
+ return comm
125
+ tot = {i: degree[n] for n, i in comm.items()}
126
+ for _ in range(_PASSES):
127
+ moved = False
128
+ for n in order:
129
+ current = comm[n]
130
+ k = degree[n]
131
+ tot[current] -= k
132
+ weights: dict[int, float] = {}
133
+ for t, w in adj[n].items():
134
+ if t != n:
135
+ weights[comm[t]] = weights.get(comm[t], 0.0) + w
136
+ best = current
137
+ best_gain = weights.get(current, 0.0) - tot[current] * k / m2
138
+ for c in sorted(weights):
139
+ gain = weights[c] - tot[c] * k / m2
140
+ if gain > best_gain + _EPS or (abs(gain - best_gain) <= _EPS and c < best):
141
+ best, best_gain = c, gain
142
+ tot[best] += k
143
+ if best != current:
144
+ comm[n] = best
145
+ moved = True
146
+ if not moved:
147
+ break
148
+ return comm
149
+
150
+
151
+ def _groups(order: list[str], comm: dict[str, int]) -> list[list[str]]:
152
+ by: dict[int, list[str]] = {}
153
+ for n in order:
154
+ by.setdefault(comm[n], []).append(n)
155
+ return sorted((sorted(g) for g in by.values()), key=lambda g: g[0])
156
+
157
+
158
+ def _merge_small(groups: list[list[str]], adj: _Adjacency) -> list[list[str]]:
159
+ groups = [list(g) for g in groups]
160
+ while len(groups) > 1:
161
+ small = [g for g in groups if len(g) < _GROUP_MIN]
162
+ if not small:
163
+ break
164
+ group = min(small, key=lambda g: (len(g), g[0]))
165
+ members = set(group)
166
+ others = [g for g in groups if g is not group]
167
+ strength = [
168
+ sum(adj.get(a, {}).get(b, 0) for a in members for b in other) for other in others
169
+ ]
170
+ if max(strength) > 0:
171
+ target = min(
172
+ range(len(others)), key=lambda i: (-strength[i], len(others[i]), others[i][0])
173
+ )
174
+ else:
175
+ target = min(range(len(others)), key=lambda i: (len(others[i]), others[i][0]))
176
+ others[target] = sorted(others[target] + group)
177
+ groups = others
178
+ return sorted(groups, key=lambda g: g[0])
179
+
180
+
181
+ def _hex(hue: float, saturation: float, lightness: float) -> str:
182
+ r, g, b = colorsys.hls_to_rgb((hue % 360) / 360, lightness, saturation)
183
+ return f"#{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}"
184
+
185
+
186
+ def _frac(x: float) -> float:
187
+ return x - int(x)
188
+
189
+
190
+ def _constellation_hue(index: int) -> float:
191
+ return (210 + 137.508 * index) % 360
192
+
193
+
194
+ def _constellation_color(index: int) -> str:
195
+ return _hex(_constellation_hue(index), 0.70, 0.62)
196
+
197
+
198
+ def _topic_color(index: int, position: int) -> str:
199
+ """Constellation hue shifted by up to ±18°, lightness 55–72%, spread by golden ratio."""
200
+ offset = 36 * (_frac(0.5 + position * 0.618034) - 0.5)
201
+ lightness = 0.55 + 0.17 * _frac(0.5 + position * 0.381966)
202
+ return _hex(_constellation_hue(index) + offset, 0.70, lightness)
203
+
204
+
205
+ def topic_colors(groups: list[list[str]]) -> dict[str, str]:
206
+ return {slug: _topic_color(i, j) for i, g in enumerate(groups) for j, slug in enumerate(g)}
207
+
208
+
209
+ # --- payloads ---------------------------------------------------------------
210
+
211
+
212
+ class Atlas:
213
+ """Precomputed views of one graph for the visualizer's zoom levels."""
214
+
215
+ def __init__(self, graph: dict[str, Any], *, today: date | None = None):
216
+ self.graph = graph
217
+ self.today = today or datetime.now(UTC).date()
218
+ self.topics = {t["slug"]: t for t in graph["topics"]}
219
+ self.nodes = {n["id"]: n for n in graph["nodes"]}
220
+ self.notes = [
221
+ n for n in graph["nodes"] if n["kind"] in ("note", "hub") and n["topic"] in self.topics
222
+ ]
223
+ self._topic_of = {n["id"]: n["topic"] for n in self.notes}
224
+ self._adj = topic_weights(self.notes, graph["links"])
225
+ self._parents = {s: t.get("parent") for s, t in self.topics.items()}
226
+ self.groups = tree_groups(list(self.topics), self._parents, self._adj)
227
+ self.colors = topic_colors(self.groups)
228
+ self.constellation_of = {s: f"c{i + 1}" for i, g in enumerate(self.groups) for s in g}
229
+ self._inner: dict[str, list[dict[str, Any]]] = {}
230
+ self._outer: dict[str, list[dict[str, Any]]] = {}
231
+ for link in graph["links"]:
232
+ a, b = self._topic_of.get(link["source"]), self._topic_of.get(link["target"])
233
+ if a is not None and a == b:
234
+ self._inner.setdefault(a, []).append(link)
235
+ continue
236
+ for mine, other in ((a, b), (b, a)):
237
+ if mine is not None and other is not None:
238
+ self._outer.setdefault(mine, []).append(link)
239
+ self._world: dict[str, Any] | None = None
240
+
241
+ def note(self, node_id: str) -> dict[str, Any] | None:
242
+ """A note or hub in one of the map's topics."""
243
+ return self.nodes.get(node_id) if node_id in self._topic_of else None
244
+
245
+ def world(self) -> dict[str, Any]:
246
+ if self._world is None:
247
+ self._world = self._build_world()
248
+ return self._world
249
+
250
+ def _build_world(self) -> dict[str, Any]:
251
+ g = self.graph
252
+ flagged = [n for n in self.notes if n["flags"]]
253
+ typed = [link for link in g["links"] if link["typed"]]
254
+ constellations = []
255
+ for i, group in enumerate(self.groups):
256
+ ranked = sorted(group, key=lambda s: (-self.topics[s]["notes"], s))
257
+ hub = self._tree_root(group) or self._hub_topic(group)
258
+ if hub: # the map labels a constellation by its first topic
259
+ ranked = [hub, *(s for s in ranked if s != hub)]
260
+ titles = [self.topics[s]["title"] for s in ranked[:3]]
261
+ constellations.append(
262
+ {
263
+ "id": f"c{i + 1}",
264
+ "title": " · ".join(titles) + (" · …" if len(group) > 3 else ""),
265
+ "color": _constellation_color(i),
266
+ "topics": group,
267
+ }
268
+ )
269
+ return {
270
+ "brain": g["brain"],
271
+ "generated": g["generated"],
272
+ "profile": g["profile"],
273
+ "totals": {
274
+ "topics": len(self.topics),
275
+ "notes": sum(1 for n in self.notes if n["kind"] == "note"),
276
+ "hubs": sum(1 for n in self.notes if n["kind"] == "hub"),
277
+ "episodes": self._episode_count(),
278
+ "links": len(typed),
279
+ "flagged": len(flagged),
280
+ },
281
+ "constellations": constellations,
282
+ "topics": self._topic_summaries(),
283
+ "flows": self._flows(typed),
284
+ "episodes": {"count": self._episode_count()},
285
+ "runs": [self._run(r) for r in g["runs"]],
286
+ }
287
+
288
+ def _tree_root(self, group: list[str]) -> str | None:
289
+ """The topic every other topic in the group sits under, for a group from the tree."""
290
+ inside = set(group)
291
+ tops = [s for s in group if self._parents.get(s) not in inside]
292
+ return tops[0] if len(tops) == 1 and len(group) > 1 else None
293
+
294
+ def _hub_topic(self, group: list[str]) -> str | None:
295
+ """The topic typed-linked to at least half of the others in its group (a plugins topic
296
+ and each plugin, people and each person), or None when no topic holds the group together."""
297
+ if len(group) < 3:
298
+ return None
299
+ inside = set(group)
300
+ reach = {s: len(set(self._adj.get(s, {})) & inside - {s}) for s in group}
301
+ best = min(group, key=lambda s: (-reach[s], -self.topics[s]["notes"], s))
302
+ return best if reach[best] * 2 >= len(group) - 1 else None
303
+
304
+ def _episode_count(self) -> int:
305
+ return sum(1 for n in self.graph["nodes"] if n["kind"] == "episode")
306
+
307
+ def _topic_summaries(self) -> list[dict[str, Any]]:
308
+ cutoff = (self.today - timedelta(days=_RECENT_DAYS)).isoformat()
309
+ by_topic: dict[str, list[dict[str, Any]]] = {}
310
+ for node in self.notes:
311
+ by_topic.setdefault(node["topic"], []).append(node)
312
+ out = []
313
+ for group in self.groups:
314
+ for slug in group:
315
+ topic = self.topics[slug]
316
+ members = by_topic.get(slug, [])
317
+ flags: dict[str, int] = {}
318
+ for node in members:
319
+ for flag in node["flags"]:
320
+ flags[flag] = flags.get(flag, 0) + 1
321
+ dates = [n["updated"] for n in members if n["updated"]]
322
+ notes = [n for n in members if n["kind"] == "note"] # the hub is the index
323
+ out.append(
324
+ {
325
+ "slug": slug,
326
+ "title": topic["title"],
327
+ "description": topic.get("description") or "",
328
+ "parent": topic.get("parent"),
329
+ "color": self.colors[slug],
330
+ "constellation": self.constellation_of[slug],
331
+ "notes": len(notes),
332
+ "hub": topic.get("hub"),
333
+ "updated": max(dates) if dates else None,
334
+ "recent": sum(1 for n in notes if (n["updated"] or "") >= cutoff),
335
+ "flags": flags,
336
+ "flagged": sum(1 for n in members if n["flags"]),
337
+ }
338
+ )
339
+ return out
340
+
341
+ def _flows(self, typed: list[dict[str, Any]]) -> list[dict[str, Any]]:
342
+ flows: dict[tuple[str, str], dict[str, int]] = {}
343
+ for link in typed:
344
+ a, b = self._topic_of.get(link["source"]), self._topic_of.get(link["target"])
345
+ if a is None or b is None or a == b:
346
+ continue
347
+ rels = flows.setdefault((min(a, b), max(a, b)), {})
348
+ rels[link["rel"]] = rels.get(link["rel"], 0) + 1
349
+ ranked = sorted(
350
+ (
351
+ {"a": a, "b": b, "total": sum(rels.values()), "rels": dict(sorted(rels.items()))}
352
+ for (a, b), rels in flows.items()
353
+ ),
354
+ key=lambda f: (-f["total"], f["a"], f["b"]),
355
+ )
356
+ # Large brains link almost every pair of topics; each topic's strongest flows keep
357
+ # the world payload within budget (doc 12 §7).
358
+ kept: dict[str, int] = {}
359
+ out = []
360
+ for flow in ranked:
361
+ a, b = kept.get(flow["a"], 0), kept.get(flow["b"], 0)
362
+ kept[flow["a"]], kept[flow["b"]] = a + 1, b + 1
363
+ if min(a, b) < _FLOWS_PER_TOPIC:
364
+ out.append(flow)
365
+ return out
366
+
367
+ def _run(self, run: dict[str, Any]) -> dict[str, Any]:
368
+ topics: dict[str, int] = {}
369
+ for node_id in run["touched"]:
370
+ slug = self._topic_of.get(node_id)
371
+ if slug is not None:
372
+ topics[slug] = topics.get(slug, 0) + 1
373
+ return {
374
+ "id": run["id"],
375
+ "kind": run["kind"],
376
+ "at": run["at"],
377
+ "episodes": run["episodes"],
378
+ "topics": topics,
379
+ "touched": list(run["touched"]),
380
+ }
381
+
382
+ def topic(self, slug: str) -> dict[str, Any] | None:
383
+ topic = self.topics.get(slug)
384
+ if topic is None:
385
+ return None
386
+ external = []
387
+ outer = self._outer.get(slug, [])
388
+ for link in [x for x in outer if x["typed"]] + [x for x in outer if not x["typed"]]:
389
+ out = self._topic_of[link["source"]] == slug
390
+ other = self.nodes[link["target"] if out else link["source"]]
391
+ external.append(
392
+ {
393
+ "source": link["source"],
394
+ "target": link["target"],
395
+ "rel": link["rel"],
396
+ "topic": other["topic"],
397
+ "title": other["title"],
398
+ "dir": "out" if out else "in",
399
+ }
400
+ )
401
+ if len(external) >= _MAX_EXTERNAL:
402
+ break
403
+ return {
404
+ "slug": slug,
405
+ "title": topic["title"],
406
+ "color": self.colors[slug],
407
+ "hub": topic.get("hub"),
408
+ "path": topic.get("path"), # the map places notes by the folders below it
409
+ "nodes": [n for n in self.notes if n["topic"] == slug],
410
+ "links": list(self._inner.get(slug, [])),
411
+ "external": external,
412
+ }
413
+
414
+ def health(self) -> dict[str, Any]:
415
+ items: dict[str, list[dict[str, Any]]] = {}
416
+ for node in self.notes:
417
+ for flag in node["flags"]:
418
+ items.setdefault(flag, []).append(
419
+ {"id": node["id"], "title": node["title"], "topic": node["topic"]}
420
+ )
421
+ counts = {flag: len(rows) for flag, rows in sorted(items.items())}
422
+ return {
423
+ "counts": counts,
424
+ "items": {
425
+ flag: sorted(items[flag], key=lambda r: (r["topic"], r["title"], r["id"]))[
426
+ :_MAX_HEALTH
427
+ ]
428
+ for flag in counts
429
+ },
430
+ }
431
+
432
+ def search_titles(self, query: str, limit: int = MAX_SEARCH) -> list[dict[str, Any]]:
433
+ """Notes whose title or an alias contains `query` (case-insensitive), prefix matches first."""
434
+ q = query.strip().casefold()
435
+ if not q:
436
+ return []
437
+ hits = []
438
+ for node in self.notes:
439
+ names = [node["title"] or "", *node["aliases"]]
440
+ folded = [name.casefold() for name in names]
441
+ if not any(q in name for name in folded):
442
+ continue
443
+ prefix = any(name.startswith(q) for name in folded)
444
+ hits.append((not prefix, (node["title"] or "").casefold(), node["id"], node))
445
+ hits.sort(key=lambda h: h[:3])
446
+ return [
447
+ {
448
+ "id": n["id"],
449
+ "title": n["title"],
450
+ "topic": n["topic"],
451
+ "kind": n["kind"],
452
+ "snippet": None,
453
+ }
454
+ for *_, n in hits[:limit]
455
+ ]