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.
- brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
- brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
- brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
- brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
- brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- brainwiki/__init__.py +17 -0
- brainwiki/__main__.py +5 -0
- brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
- brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
- brainwiki/assets/plugin/.mcp.json +16 -0
- brainwiki/assets/plugin/mcp.json +17 -0
- brainwiki/assets/plugin/mcp_config.json +15 -0
- brainwiki/assets/plugin/plugin.json +37 -0
- brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
- brainwiki/assets/plugin/server.json +30 -0
- brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
- brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
- brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
- brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
- brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
- brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
- brainwiki/assets/prompts/consolidate.md +89 -0
- brainwiki/assets/prompts/distill.md +96 -0
- brainwiki/assets/prompts/harvest.md +142 -0
- brainwiki/bench/__init__.py +8 -0
- brainwiki/bench/_generate.py +440 -0
- brainwiki/bench/_report.py +166 -0
- brainwiki/bench/_suites.py +959 -0
- brainwiki/cli/__init__.py +7 -0
- brainwiki/cli/_cmd_bench.py +101 -0
- brainwiki/cli/_cmd_browse.py +281 -0
- brainwiki/cli/_cmd_consolidate.py +108 -0
- brainwiki/cli/_cmd_core.py +155 -0
- brainwiki/cli/_cmd_harvest.py +137 -0
- brainwiki/cli/_cmd_hosts.py +52 -0
- brainwiki/cli/_cmd_import.py +149 -0
- brainwiki/cli/_cmd_lint.py +117 -0
- brainwiki/cli/_cmd_read.py +131 -0
- brainwiki/cli/_cmd_runs.py +66 -0
- brainwiki/cli/_cmd_viz.py +57 -0
- brainwiki/cli/_cmd_write.py +122 -0
- brainwiki/cli/_main.py +511 -0
- brainwiki/cli/_output.py +19 -0
- brainwiki/config/__init__.py +56 -0
- brainwiki/config/_dirs.py +69 -0
- brainwiki/config/_manifest.py +307 -0
- brainwiki/config/_resolve.py +169 -0
- brainwiki/config/_user.py +183 -0
- brainwiki/consolidate/__init__.py +19 -0
- brainwiki/consolidate/_apply.py +408 -0
- brainwiki/consolidate/_packet.py +489 -0
- brainwiki/consolidate/_pipeline.py +215 -0
- brainwiki/consolidate/_presets.py +230 -0
- brainwiki/consolidate/_review.py +104 -0
- brainwiki/consolidate/_schema.py +153 -0
- brainwiki/consolidate/_validate.py +252 -0
- brainwiki/doctor.py +216 -0
- brainwiki/engine.py +80 -0
- brainwiki/errors.py +81 -0
- brainwiki/format/__init__.py +112 -0
- brainwiki/format/_agents_md.py +26 -0
- brainwiki/format/_claims.py +95 -0
- brainwiki/format/_document.py +203 -0
- brainwiki/format/_edit.py +94 -0
- brainwiki/format/_episode.py +67 -0
- brainwiki/format/_fields.py +350 -0
- brainwiki/format/_frontmatter.py +175 -0
- brainwiki/format/_links.py +72 -0
- brainwiki/format/_meta.py +67 -0
- brainwiki/format/_normalize.py +70 -0
- brainwiki/format/_relations.py +211 -0
- brainwiki/format/_rules.py +41 -0
- brainwiki/harvest.py +414 -0
- brainwiki/hosts/__init__.py +22 -0
- brainwiki/hosts/_antigravity.py +163 -0
- brainwiki/hosts/_claude_code.py +174 -0
- brainwiki/hosts/_codex.py +149 -0
- brainwiki/hosts/_connect.py +443 -0
- brainwiki/hosts/_detect.py +39 -0
- brainwiki/hosts/_instructions.py +220 -0
- brainwiki/hosts/_sessions.py +176 -0
- brainwiki/ids.py +69 -0
- brainwiki/importer/__init__.py +17 -0
- brainwiki/importer/_episodes.py +112 -0
- brainwiki/importer/_into.py +102 -0
- brainwiki/importer/_normalize.py +64 -0
- brainwiki/importer/_overlay.py +199 -0
- brainwiki/importer/_survey.py +167 -0
- brainwiki/index/__init__.py +44 -0
- brainwiki/index/_brains.py +119 -0
- brainwiki/index/_db.py +131 -0
- brainwiki/index/_guards.py +154 -0
- brainwiki/index/_neighbors.py +174 -0
- brainwiki/index/_orient.py +400 -0
- brainwiki/index/_read.py +285 -0
- brainwiki/index/_resolver.py +147 -0
- brainwiki/index/_scanner.py +665 -0
- brainwiki/index/_schema.py +164 -0
- brainwiki/index/_search.py +659 -0
- brainwiki/index/_topics.py +253 -0
- brainwiki/index/_written.py +29 -0
- brainwiki/lifecycle.py +158 -0
- brainwiki/lint/__init__.py +20 -0
- brainwiki/lint/_catalog.py +85 -0
- brainwiki/lint/_cross.py +649 -0
- brainwiki/lint/_files.py +483 -0
- brainwiki/lint/_fixes.py +87 -0
- brainwiki/lint/_hook.py +54 -0
- brainwiki/lint/_model.py +103 -0
- brainwiki/lint/_reports.py +242 -0
- brainwiki/lint/_runner.py +156 -0
- brainwiki/logs.py +34 -0
- brainwiki/mcp/__init__.py +7 -0
- brainwiki/mcp/_server.py +917 -0
- brainwiki/origin.py +97 -0
- brainwiki/runs/__init__.py +21 -0
- brainwiki/runs/_git.py +89 -0
- brainwiki/runs/_lock.py +42 -0
- brainwiki/runs/_record.py +145 -0
- brainwiki/runs/_transaction.py +441 -0
- brainwiki/runs/_undo.py +159 -0
- brainwiki/setup_wizard.py +143 -0
- brainwiki/store/__init__.py +7 -0
- brainwiki/store/_local.py +153 -0
- brainwiki/viz/__init__.py +9 -0
- brainwiki/viz/_export.py +414 -0
- brainwiki/viz/_lod.py +455 -0
- brainwiki/viz/_server.py +327 -0
- brainwiki/viz/app/BUILD_INFO.json +5 -0
- brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
- brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
- brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
- brainwiki/viz/app/index.html +14 -0
- brainwiki/write/__init__.py +23 -0
- brainwiki/write/_edits.py +730 -0
- brainwiki/write/_episode.py +195 -0
- brainwiki/write/_move.py +181 -0
- brainwiki/write/_rules.py +204 -0
- 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
|
+
]
|