brainplug 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.
- brainplug/__init__.py +3 -0
- brainplug/__main__.py +300 -0
- brainplug/agent.py +749 -0
- brainplug/analysis.py +453 -0
- brainplug/app.py +184 -0
- brainplug/brain.py +184 -0
- brainplug/connect.py +354 -0
- brainplug/demo.py +165 -0
- brainplug/export.py +142 -0
- brainplug/memory.py +356 -0
- brainplug/notion_api.py +135 -0
- brainplug/pipeline.py +95 -0
- brainplug/render.py +47 -0
- brainplug/sources.py +1193 -0
- brainplug/stopwords.py +33 -0
- brainplug/sync.py +109 -0
- brainplug/templates/app.html +212 -0
- brainplug/templates/viewer.html +1050 -0
- brainplug/vendor/INTER_LICENSE +93 -0
- brainplug/vendor/THREE_LICENSE +21 -0
- brainplug/vendor/inter-latin.woff2 +0 -0
- brainplug/vendor/three.module.min.js +6 -0
- brainplug-0.4.0.dist-info/METADATA +364 -0
- brainplug-0.4.0.dist-info/RECORD +28 -0
- brainplug-0.4.0.dist-info/WHEEL +5 -0
- brainplug-0.4.0.dist-info/entry_points.txt +2 -0
- brainplug-0.4.0.dist-info/licenses/LICENSE +21 -0
- brainplug-0.4.0.dist-info/top_level.txt +1 -0
brainplug/demo.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Synthetic history so anyone can try Brainplug without exporting anything.
|
|
2
|
+
|
|
3
|
+
Chats, notes, documents and messages are generated from templates: none of it is real.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
|
|
11
|
+
from .sources import Item
|
|
12
|
+
|
|
13
|
+
TOPICS = {
|
|
14
|
+
"python": {
|
|
15
|
+
"chat": ["Pandas groupby question", "Fix my Python loop", "Plot with matplotlib",
|
|
16
|
+
"Read CSV in pandas", "Merge two dataframes", "Pandas pivot table"],
|
|
17
|
+
"note": ["Python cheat sheet", "Pandas tricks", "Data cleaning checklist"],
|
|
18
|
+
"mail": ["Python assignment group work", "Notebook for the data project"],
|
|
19
|
+
"words": ["python", "pandas", "dataframe", "groupby", "csv", "matplotlib", "loop",
|
|
20
|
+
"function", "error", "column", "index", "merge", "plot", "numpy"],
|
|
21
|
+
},
|
|
22
|
+
"econometrics": {
|
|
23
|
+
"chat": ["OLS regression assumptions", "Interpret R squared", "Heteroskedasticity test",
|
|
24
|
+
"Dummy variables in regression", "Panel data basics"],
|
|
25
|
+
"note": ["Econometrics lecture notes", "Regression formulas", "Exam summary econometrics"],
|
|
26
|
+
"mail": ["Question about the regression exercise", "Econometrics exam review"],
|
|
27
|
+
"words": ["regression", "ols", "coefficient", "variance", "residuals", "hypothesis",
|
|
28
|
+
"significance", "estimator", "heteroskedasticity", "dummy", "model"],
|
|
29
|
+
},
|
|
30
|
+
"finance": {
|
|
31
|
+
"chat": ["How do index funds work", "Compound interest example", "ETF vs mutual fund",
|
|
32
|
+
"Read a balance sheet", "Bond yields explained"],
|
|
33
|
+
"note": ["Investing principles", "Portfolio ideas", "Valuation notes"],
|
|
34
|
+
"mail": ["Investment club meeting", "Stock pitch feedback"],
|
|
35
|
+
"words": ["etf", "index", "fund", "interest", "portfolio", "stocks", "bonds", "yield",
|
|
36
|
+
"balance", "sheet", "ebitda", "valuation", "returns", "risk"],
|
|
37
|
+
},
|
|
38
|
+
"printing": {
|
|
39
|
+
"chat": ["PLA vs PETG settings", "First layer not sticking", "Design a phone stand",
|
|
40
|
+
"Calibrate 3D printer", "Print supports removal"],
|
|
41
|
+
"note": ["3D printer settings log", "Print ideas list", "Filament comparison"],
|
|
42
|
+
"mail": ["Filament order", "3D printed part for the lab"],
|
|
43
|
+
"words": ["printer", "pla", "petg", "layer", "nozzle", "filament", "supports", "stl",
|
|
44
|
+
"slicer", "bed", "temperature", "infill", "print"],
|
|
45
|
+
},
|
|
46
|
+
"fitness": {
|
|
47
|
+
"chat": ["Push pull legs routine", "Protein intake per day", "Running plan 10k",
|
|
48
|
+
"Gym routine for beginners", "Creatine questions"],
|
|
49
|
+
"note": ["Workout log", "Running progress", "Meal and protein targets"],
|
|
50
|
+
"mail": ["Gym membership renewal", "10k race registration"],
|
|
51
|
+
"words": ["workout", "routine", "protein", "gym", "running", "muscle", "sets", "reps",
|
|
52
|
+
"training", "recovery", "squat", "cardio"],
|
|
53
|
+
},
|
|
54
|
+
"travel": {
|
|
55
|
+
"chat": ["3 days in Lisbon", "Greek islands itinerary", "Cheap flights tips",
|
|
56
|
+
"Weekend in Porto", "Interrail route ideas"],
|
|
57
|
+
"note": ["Trip packing list", "Places to visit", "Summer trip budget"],
|
|
58
|
+
"mail": ["Hotel booking confirmation question", "Summer trip plan with friends"],
|
|
59
|
+
"words": ["trip", "itinerary", "flight", "hotel", "beach", "islands", "museum",
|
|
60
|
+
"train", "budget", "days", "restaurants", "visit"],
|
|
61
|
+
},
|
|
62
|
+
"career": {
|
|
63
|
+
"chat": ["Improve my CV", "Internship cover letter", "LinkedIn headline ideas",
|
|
64
|
+
"Prepare interview questions", "Networking message draft"],
|
|
65
|
+
"note": ["Internship applications tracker", "Interview answers", "Career goals"],
|
|
66
|
+
"mail": ["Internship application", "Thank you after the interview", "Coffee chat request"],
|
|
67
|
+
"words": ["cv", "internship", "linkedin", "interview", "cover", "letter", "recruiter",
|
|
68
|
+
"application", "networking", "headline", "experience", "skills"],
|
|
69
|
+
},
|
|
70
|
+
"startup": {
|
|
71
|
+
"chat": ["Validate my startup idea", "Landing page copy", "Pricing strategy",
|
|
72
|
+
"Pitch deck structure", "Find first users"],
|
|
73
|
+
"note": ["Startup idea journal", "User interview notes", "Launch checklist"],
|
|
74
|
+
"mail": ["Feedback on the landing page", "Intro to a potential user"],
|
|
75
|
+
"words": ["startup", "users", "mvp", "pricing", "landing", "page", "pitch", "deck",
|
|
76
|
+
"customers", "growth", "idea", "market", "launch"],
|
|
77
|
+
},
|
|
78
|
+
"cooking": {
|
|
79
|
+
"chat": ["Easy pasta recipe", "Meal prep for the week", "Healthy breakfast ideas",
|
|
80
|
+
"How to cook rice", "Quick dinner ideas"],
|
|
81
|
+
"note": ["Favourite recipes", "Weekly meal plan", "Shopping list staples"],
|
|
82
|
+
"mail": ["Dinner on Saturday", "Recipe you asked for"],
|
|
83
|
+
"words": ["recipe", "pasta", "chicken", "rice", "cook", "oven", "dinner", "breakfast",
|
|
84
|
+
"ingredients", "meal", "prep", "minutes"],
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Relative weight of each topic over time (start -> end): some rise, some fade.
|
|
89
|
+
TRENDS = {
|
|
90
|
+
"python": (1, 5), "econometrics": (3, 2), "finance": (1, 3), "printing": (0, 4),
|
|
91
|
+
"fitness": (3, 0.4), "travel": (2, 2), "career": (1, 4), "startup": (0, 3), "cooking": (2.5, 0),
|
|
92
|
+
}
|
|
93
|
+
SOURCES = [("chatgpt", "chat", 30), ("claude", "chat", 20), ("gemini", "chat", 8),
|
|
94
|
+
("obsidian", "note", 16), ("notion", "note", 7), ("word", "note", 4), ("pdf", "note", 2),
|
|
95
|
+
("gmail", "message", 8), ("whatsapp", "message", 5)]
|
|
96
|
+
PEOPLE = ["Marta", "Study group", "Dani", "Family", "Lucía", "Football friends", "Pablo", "Flatmates"]
|
|
97
|
+
FILLERS = ["can you explain", "how do I", "what is the best way to", "I am trying to understand",
|
|
98
|
+
"help me with", "quick question about", "why does", "give me an example of"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def generate(n: int = 600, seed: int = 7, end: datetime | None = None,
|
|
102
|
+
months: int = 18) -> list[Item]:
|
|
103
|
+
rng = random.Random(seed)
|
|
104
|
+
end = end or datetime(2026, 11, 10, tzinfo=timezone.utc)
|
|
105
|
+
start = end - timedelta(days=30 * months)
|
|
106
|
+
span = (end - start).total_seconds()
|
|
107
|
+
names = list(TOPICS)
|
|
108
|
+
notes_by_topic: dict[str, list[str]] = {k: [] for k in names}
|
|
109
|
+
items: list[Item] = []
|
|
110
|
+
|
|
111
|
+
def sentence(spec, extra, lo=4, hi=8):
|
|
112
|
+
vocab = spec["words"] + extra
|
|
113
|
+
return " ".join(rng.sample(vocab, k=min(len(vocab), rng.randint(lo, hi))))
|
|
114
|
+
|
|
115
|
+
stamps = sorted(rng.random() ** 0.55 for _ in range(n)) # usage grows over time
|
|
116
|
+
for i, t in enumerate(stamps):
|
|
117
|
+
created = start + timedelta(seconds=t * span)
|
|
118
|
+
hour = rng.choice([8, 9, 10, 11, 12, 13, 16, 17, 18, 19, 21, 22, 22, 23, 23, 0])
|
|
119
|
+
created = created.replace(hour=hour, minute=rng.randrange(60))
|
|
120
|
+
if t > 0.6 and rng.random() < 0.5:
|
|
121
|
+
weights = [TRENDS[k][1] for k in names] # recent months follow the "end" mix
|
|
122
|
+
else:
|
|
123
|
+
weights = [TRENDS[k][0] + (TRENDS[k][1] - TRENDS[k][0]) * t + 0.15 for k in names]
|
|
124
|
+
topic = rng.choices(names, weights=weights)[0]
|
|
125
|
+
if topic == "cooking" and t > 0.68: # an interest that was dropped...
|
|
126
|
+
topic = "python"
|
|
127
|
+
if topic == "startup" and t < 0.74: # ...and one that only started recently
|
|
128
|
+
topic = "travel"
|
|
129
|
+
spec = TOPICS[topic]
|
|
130
|
+
other = rng.choice(names) if rng.random() < 0.12 else topic # some items mix two topics
|
|
131
|
+
if (other == "cooking" and t > 0.68) or (other == "startup" and t < 0.74):
|
|
132
|
+
other = topic
|
|
133
|
+
extra = TOPICS[other]["words"][:10] if other != topic else []
|
|
134
|
+
source, kind, _ = rng.choices(SOURCES, weights=[s[2] for s in SOURCES])[0]
|
|
135
|
+
|
|
136
|
+
if kind == "chat":
|
|
137
|
+
turns = rng.choice([1, 1, 2, 2, 3, 4, 6, 9])
|
|
138
|
+
parts, lines, when = [], [], created
|
|
139
|
+
for _ in range(turns):
|
|
140
|
+
lines.append(f"{rng.choice(FILLERS)} {sentence(spec, extra)}?")
|
|
141
|
+
parts.append(when)
|
|
142
|
+
when += timedelta(minutes=rng.randint(1, 15))
|
|
143
|
+
items.append(Item(f"demo-{i}", rng.choice(spec["chat"]), created, source, "chat",
|
|
144
|
+
"\n".join(lines), parts=parts, size=turns * 2))
|
|
145
|
+
elif kind == "note":
|
|
146
|
+
title = rng.choice(spec["note"])
|
|
147
|
+
links = []
|
|
148
|
+
if notes_by_topic[topic] and rng.random() < 0.7:
|
|
149
|
+
links.append(rng.choice(notes_by_topic[topic]))
|
|
150
|
+
if other != topic and notes_by_topic[other]:
|
|
151
|
+
links.append(rng.choice(notes_by_topic[other]))
|
|
152
|
+
text = ". ".join(sentence(spec, extra, 6, 11) for _ in range(rng.randint(2, 7)))
|
|
153
|
+
items.append(Item(f"demo-{i}", title, created, source, "note", text,
|
|
154
|
+
links=links, tags=[topic]))
|
|
155
|
+
notes_by_topic[topic].append(title)
|
|
156
|
+
else:
|
|
157
|
+
replies = rng.choice([1, 1, 2, 3])
|
|
158
|
+
parts = [created + timedelta(hours=6 * j) for j in range(replies)]
|
|
159
|
+
text = ". ".join(f"hi, about the {sentence(spec, extra, 5, 9)}" for _ in range(replies))
|
|
160
|
+
title = rng.choice(PEOPLE) if source == "whatsapp" else rng.choice(spec["mail"])
|
|
161
|
+
items.append(Item(f"demo-{i}", title, created, source, "message",
|
|
162
|
+
text, parts=parts, size=replies * (3 if source == "whatsapp" else 1)))
|
|
163
|
+
|
|
164
|
+
items.sort(key=lambda it: it.created)
|
|
165
|
+
return items
|
brainplug/export.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Write your record back as Markdown notes that Obsidian can open.
|
|
2
|
+
|
|
3
|
+
Creates a ``Brainplug/`` folder with:
|
|
4
|
+
|
|
5
|
+
* ``Brainplug.md`` an index of your topics
|
|
6
|
+
* ``Topics/<topic>.md`` one map-of-content per topic, grouped by month
|
|
7
|
+
* ``Items/<source>/…md`` one note per chat or email, with what you wrote
|
|
8
|
+
and ``[[links]]`` to related items
|
|
9
|
+
|
|
10
|
+
Notes that already live in your vault are linked by name, never copied.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .analysis import SOURCE_NAMES
|
|
20
|
+
from .sources import Item
|
|
21
|
+
|
|
22
|
+
MAX_TEXT = 6000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def safe_name(name: str, limit: int = 90) -> str:
|
|
26
|
+
name = re.sub(r'[\\/:*?"<>|#^\[\]]+', " ", name)
|
|
27
|
+
name = re.sub(r"\s+", " ", name).strip(" .")
|
|
28
|
+
return name[:limit].rstrip(" .") or "Untitled"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _slug(name: str) -> str:
|
|
32
|
+
return re.sub(r"[^\w]+", "-", name.lower(), flags=re.UNICODE).strip("-") or "topic"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def export_vault(items: list[Item], payload: dict, out_dir: str | Path) -> int:
|
|
36
|
+
base = Path(out_dir) / "Brainplug"
|
|
37
|
+
topics, meta = payload["topics"], payload["items"]
|
|
38
|
+
used: set[str] = set()
|
|
39
|
+
names: dict[int, str] = {}
|
|
40
|
+
own: set[int] = set() # items we write a note for
|
|
41
|
+
for i, item in enumerate(items):
|
|
42
|
+
if item.source in ("obsidian", "notes"):
|
|
43
|
+
names[i] = safe_name(item.title) # already a note in the vault
|
|
44
|
+
continue
|
|
45
|
+
name = safe_name(f"{item.title} ({meta[i]['d']})")
|
|
46
|
+
n = 2
|
|
47
|
+
while name.lower() in used:
|
|
48
|
+
name = safe_name(f"{item.title} ({meta[i]['d']}) {n}")
|
|
49
|
+
n += 1
|
|
50
|
+
used.add(name.lower())
|
|
51
|
+
names[i] = name
|
|
52
|
+
own.add(i)
|
|
53
|
+
|
|
54
|
+
related: dict[int, list[int]] = defaultdict(list)
|
|
55
|
+
for a, b, _w, _kind in payload["edges"]:
|
|
56
|
+
related[a].append(b)
|
|
57
|
+
related[b].append(a)
|
|
58
|
+
|
|
59
|
+
written = 0
|
|
60
|
+
for i in sorted(own):
|
|
61
|
+
item, topic = items[i], topics[meta[i]["k"]]
|
|
62
|
+
folder = base / "Items" / SOURCE_NAMES.get(item.source, item.source.title())
|
|
63
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
text = item.text[:MAX_TEXT] + (" […]" if len(item.text) > MAX_TEXT else "")
|
|
65
|
+
lines = [
|
|
66
|
+
"---", f"date: {meta[i]['d']}", f"source: {item.source}", f"type: {item.kind}",
|
|
67
|
+
f'topic: "{topic["name"]}"', f"tags: [brainplug, brainplug/{_slug(topic['name'])}]",
|
|
68
|
+
"---", "", f"# {item.title}", "", f"Topic: [[{safe_name(topic['name'])}]]", "",
|
|
69
|
+
"## What I wrote", "", text, "",
|
|
70
|
+
]
|
|
71
|
+
if related[i]:
|
|
72
|
+
lines += ["## Related", ""] + [f"- [[{names[j]}]]" for j in related[i][:8]] + [""]
|
|
73
|
+
(folder / f"{names[i]}.md").write_text("\n".join(lines), encoding="utf-8")
|
|
74
|
+
written += 1
|
|
75
|
+
|
|
76
|
+
(base / "Topics").mkdir(parents=True, exist_ok=True)
|
|
77
|
+
for topic in topics:
|
|
78
|
+
by_month: dict[str, list[int]] = defaultdict(list)
|
|
79
|
+
for i, m in enumerate(meta):
|
|
80
|
+
if m["k"] == topic["id"]:
|
|
81
|
+
by_month["No date" if m["u"] else m["d"][:7]].append(i)
|
|
82
|
+
lines = ["---", "type: topic", f"items: {topic['count']}", "tags: [brainplug]", "---", "",
|
|
83
|
+
f"# {topic['name']}", "", f"Key terms: {', '.join(topic['terms'])}", ""]
|
|
84
|
+
for month in sorted(by_month, reverse=True):
|
|
85
|
+
lines += [f"## {month}", ""]
|
|
86
|
+
lines += [f"- [[{names[i]}]] · {SOURCE_NAMES.get(items[i].source, items[i].source)}"
|
|
87
|
+
f" · {meta[i]['d']}" for i in reversed(by_month[month])]
|
|
88
|
+
lines.append("")
|
|
89
|
+
(base / "Topics" / f"{safe_name(topic['name'])}.md").write_text("\n".join(lines), encoding="utf-8")
|
|
90
|
+
written += 1
|
|
91
|
+
|
|
92
|
+
numbers = payload["numbers"]
|
|
93
|
+
index = ["# Brainplug", "", f"{numbers['items']} items · {numbers['first']} → {numbers['last']}", "",
|
|
94
|
+
"## Topics", ""]
|
|
95
|
+
index += [f"- [[{safe_name(t['name'])}]] · {t['count']} items ({round(t['share'] * 100)}%)" for t in topics]
|
|
96
|
+
(base / "Brainplug.md").write_text("\n".join(index) + "\n", encoding="utf-8")
|
|
97
|
+
return written + 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------- #
|
|
101
|
+
# research dataset
|
|
102
|
+
# --------------------------------------------------------------------------- #
|
|
103
|
+
_REDACT = [
|
|
104
|
+
(re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+"), "[email]"),
|
|
105
|
+
(re.compile(r"(?:https?://|www\.)\S+"), "[url]"),
|
|
106
|
+
(re.compile(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b"), "[iban]"),
|
|
107
|
+
# 9 digits or more, with the usual separators (a date such as 2025-03-04 has only 8)
|
|
108
|
+
(re.compile(r"(?<!\w)\+?\d[\d ().-]{7,}\d(?!\w)"),
|
|
109
|
+
lambda m: "[phone]" if sum(c.isdigit() for c in m.group()) >= 9 else m.group()),
|
|
110
|
+
(re.compile(r"\b\d{5,}\b"), "[number]"),
|
|
111
|
+
(re.compile(r"(?<!\w)@\w{2,}"), "[handle]"),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def redact(text: str) -> str:
|
|
116
|
+
"""Mask the obvious identifiers: emails, links, phone and account numbers,
|
|
117
|
+
long numbers and @handles. Names and anything else you wrote stay as they are."""
|
|
118
|
+
for pattern, label in _REDACT:
|
|
119
|
+
text = pattern.sub(label, text)
|
|
120
|
+
return text
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def export_dataset(items: list[Item], payload: dict, out: str | Path) -> int:
|
|
124
|
+
"""Write your own words as a JSON Lines file you could hand to a researcher.
|
|
125
|
+
|
|
126
|
+
One line per item: month, source, kind, topic, word count and the redacted
|
|
127
|
+
text. Titles, exact dates and ids are left out. This is **best-effort
|
|
128
|
+
redaction, not anonymisation**: read the file before you share it.
|
|
129
|
+
"""
|
|
130
|
+
import json
|
|
131
|
+
|
|
132
|
+
out = Path(out)
|
|
133
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
topics, meta = payload["topics"], payload["items"]
|
|
135
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
136
|
+
for i, item in enumerate(items):
|
|
137
|
+
fh.write(json.dumps({
|
|
138
|
+
"n": i + 1, "month": meta[i]["d"][:7] if not meta[i]["u"] else None,
|
|
139
|
+
"source": item.source, "kind": item.kind, "topic": topics[meta[i]["k"]]["name"],
|
|
140
|
+
"words": item.words, "text": redact(item.text[:MAX_TEXT * 4]),
|
|
141
|
+
}, ensure_ascii=False) + "\n")
|
|
142
|
+
return len(items)
|
brainplug/memory.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""A memory your AI can read and write: plain Markdown inside your vault.
|
|
2
|
+
|
|
3
|
+
<vault>/Brainplug/About me.md what every assistant should know about you (you write it)
|
|
4
|
+
<vault>/Brainplug/Memory/*.md one note per thing an assistant was asked to remember
|
|
5
|
+
|
|
6
|
+
<vault>/Brainplug/Excluded.md what you asked to leave out of the brain (one line each)
|
|
7
|
+
|
|
8
|
+
Every memory is an ordinary note: open it in Obsidian, edit it, move it, delete
|
|
9
|
+
it. Nothing is hidden and nothing leaves the folder. An assistant can add
|
|
10
|
+
memories and archive them; it never edits "About me" and never deletes a file.
|
|
11
|
+
|
|
12
|
+
What an assistant asks to keep as a standing preference (pinned, or of kind
|
|
13
|
+
"preference") waits for your approval (``pinned: pending``) before it becomes
|
|
14
|
+
part of every briefing, so a web page that tricked one assistant cannot plant
|
|
15
|
+
instructions for all of them.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
FOLDER = "Brainplug"
|
|
25
|
+
MEMORY = "Memory"
|
|
26
|
+
PROFILE = "About me.md"
|
|
27
|
+
EXCLUDED = "Excluded.md"
|
|
28
|
+
TOPIC_NAMES = "Topic names.md"
|
|
29
|
+
PENDING = "pending"
|
|
30
|
+
KINDS = ("note", "fact", "preference", "decision", "project")
|
|
31
|
+
MAX_TEXT = 8000
|
|
32
|
+
TEMPLATE_MARK = "brainplug: fill this in"
|
|
33
|
+
|
|
34
|
+
_TEMPLATES = {
|
|
35
|
+
"en": """---
|
|
36
|
+
created: {today}
|
|
37
|
+
---
|
|
38
|
+
# About me
|
|
39
|
+
|
|
40
|
+
<!-- {mark}. Assistants read this note at the start of a conversation, so keep it short. -->
|
|
41
|
+
|
|
42
|
+
- Name:
|
|
43
|
+
- What I do:
|
|
44
|
+
- What I am working on now:
|
|
45
|
+
- How I like answers (language, length, tone):
|
|
46
|
+
""",
|
|
47
|
+
"es": """---
|
|
48
|
+
created: {today}
|
|
49
|
+
---
|
|
50
|
+
# Sobre mí
|
|
51
|
+
|
|
52
|
+
<!-- {mark}. Los asistentes leen esta nota al empezar una conversación, así que mejor breve. -->
|
|
53
|
+
|
|
54
|
+
- Nombre:
|
|
55
|
+
- A qué me dedico:
|
|
56
|
+
- En qué estoy trabajando ahora:
|
|
57
|
+
- Cómo me gustan las respuestas (idioma, longitud, tono):
|
|
58
|
+
""",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def brain_dir(vault: str | Path) -> Path:
|
|
63
|
+
return Path(vault).expanduser() / FOLDER
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def init(vault: str | Path, lang: str = "en") -> Path:
|
|
67
|
+
"""Create the Brainplug folder in a vault (or any folder). Existing notes are left alone."""
|
|
68
|
+
base = brain_dir(vault)
|
|
69
|
+
(base / MEMORY).mkdir(parents=True, exist_ok=True)
|
|
70
|
+
profile = base / PROFILE
|
|
71
|
+
if not profile.exists():
|
|
72
|
+
profile.write_text(_TEMPLATES["es" if lang == "es" else "en"].format(
|
|
73
|
+
today=datetime.now().strftime("%Y-%m-%d"), mark=TEMPLATE_MARK), encoding="utf-8")
|
|
74
|
+
return base
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _split(text: str) -> tuple[dict, str]:
|
|
78
|
+
"""Frontmatter (flat ``key: value`` pairs) and body of a note."""
|
|
79
|
+
if not text.startswith("---"):
|
|
80
|
+
return {}, text
|
|
81
|
+
end = text.find("\n---", 3)
|
|
82
|
+
if end < 0:
|
|
83
|
+
return {}, text
|
|
84
|
+
meta = {}
|
|
85
|
+
for line in text[3:end].splitlines():
|
|
86
|
+
key, sep, value = line.partition(":")
|
|
87
|
+
if sep and key.strip() and not key[:1].isspace():
|
|
88
|
+
meta[key.strip().lower()] = value.strip()
|
|
89
|
+
return meta, text[end + 4:].lstrip("\n")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def profile(vault: str | Path) -> str:
|
|
93
|
+
"""Your "About me" note as plain text, or "" while it is still the empty template."""
|
|
94
|
+
path = brain_dir(vault) / PROFILE
|
|
95
|
+
if not path.exists():
|
|
96
|
+
return ""
|
|
97
|
+
_, body = _split(path.read_text(encoding="utf-8", errors="ignore"))
|
|
98
|
+
if TEMPLATE_MARK in body:
|
|
99
|
+
filled = [l for l in body.splitlines() if re.match(r"^- [^:]+:\s*\S", l)]
|
|
100
|
+
if not filled:
|
|
101
|
+
return ""
|
|
102
|
+
body = re.sub(r"<!--.*?-->", "", body, flags=re.S)
|
|
103
|
+
lines = [l.rstrip() for l in body.splitlines() if l.strip() and not re.match(r"^- [^:]+:\s*$", l)]
|
|
104
|
+
return "\n".join(lines).strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _safe(name: str, limit: int = 70) -> str:
|
|
108
|
+
name = re.sub(r'[\\/:*?"<>|#^\[\]\x00-\x1f]+', " ", name)
|
|
109
|
+
name = re.sub(r"\s+", " ", name).strip(" .")
|
|
110
|
+
return name[:limit].rstrip(" .")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _tag(tag: str) -> str:
|
|
114
|
+
return re.sub(r"[^\w/-]+", "-", tag.strip().lstrip("#"), flags=re.UNICODE).strip("-").lower()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def remember(vault: str | Path, text: str, title: str = "", kind: str = "note", tags: list[str] | tuple = (),
|
|
118
|
+
source: str = "", pin: bool = False, when: datetime | None = None, approved: bool = True,
|
|
119
|
+
replaces: str = "") -> Path:
|
|
120
|
+
"""Save one memory as a note in ``Brainplug/Memory`` and return its path.
|
|
121
|
+
|
|
122
|
+
``approved=False`` is for memories an assistant saves: a pinned one, or a
|
|
123
|
+
preference, is marked ``pinned: pending`` until the person approves it.
|
|
124
|
+
``replaces`` names the memory note this one corrects (see ``supersede``)."""
|
|
125
|
+
text = " ".join(str(text).split()) if "\n" not in str(text).strip() else str(text).strip()
|
|
126
|
+
if not text:
|
|
127
|
+
raise ValueError("There is nothing to remember: the text is empty.")
|
|
128
|
+
text = text[:MAX_TEXT]
|
|
129
|
+
kind = kind if kind in KINDS else "note"
|
|
130
|
+
when = when or datetime.now().astimezone()
|
|
131
|
+
folder = init(vault) / MEMORY
|
|
132
|
+
named = bool(_safe(title))
|
|
133
|
+
title = _safe(title) or _safe(" ".join(text.split()[:9])) or "Memory"
|
|
134
|
+
stem = f"{when:%Y-%m-%d} {title}"
|
|
135
|
+
labels = ["brainplug/memory", f"brainplug/{kind}", *[t for t in map(_tag, tags) if t]]
|
|
136
|
+
head = ["---", f"created: {when.isoformat(timespec='seconds')}", f"kind: {kind}"]
|
|
137
|
+
if _safe(source):
|
|
138
|
+
head.append(f"saved_by: {_safe(source, 40)}")
|
|
139
|
+
if (pin or kind == "preference") and not approved:
|
|
140
|
+
head.append(f"pinned: {PENDING}") # a standing preference waits for the person's yes
|
|
141
|
+
elif pin:
|
|
142
|
+
head.append("pinned: true")
|
|
143
|
+
if _safe(replaces, 200):
|
|
144
|
+
head.append(f"replaces: {_safe(replaces, 200)}")
|
|
145
|
+
head += [f"tags: [{', '.join(dict.fromkeys(labels))}]", "---", ""]
|
|
146
|
+
head += [f"# {title}", "", text, ""] if named else [text, ""] # an untitled memory is named after its own words
|
|
147
|
+
n = 1
|
|
148
|
+
while True: # never overwrite a note, even when two assistants save at the same moment
|
|
149
|
+
path = folder / (f"{stem}.md" if n == 1 else f"{stem} {n}.md")
|
|
150
|
+
try:
|
|
151
|
+
with open(path, "x", encoding="utf-8") as fh:
|
|
152
|
+
fh.write("\n".join(head))
|
|
153
|
+
return path
|
|
154
|
+
except FileExistsError:
|
|
155
|
+
n += 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def memories(vault: str | Path) -> list[dict]:
|
|
159
|
+
"""Every memory that has not been archived, newest first."""
|
|
160
|
+
folder = brain_dir(vault) / MEMORY
|
|
161
|
+
out = []
|
|
162
|
+
for path in sorted(folder.glob("*.md")) if folder.exists() else []:
|
|
163
|
+
meta, body = _split(path.read_text(encoding="utf-8", errors="ignore"))
|
|
164
|
+
if meta.get("archived", "").lower() == "true":
|
|
165
|
+
continue
|
|
166
|
+
body = re.sub(r"^# .*\n+", "", body, count=1).strip()
|
|
167
|
+
pinned = meta.get("pinned", "").lower()
|
|
168
|
+
out.append({"path": path, "name": path.stem, "created": meta.get("created", ""), "kind": meta.get("kind", "note"),
|
|
169
|
+
"pinned": pinned == "true", "pending": pinned == PENDING, "by": meta.get("saved_by", ""),
|
|
170
|
+
"text": body})
|
|
171
|
+
out.sort(key=lambda m: (m["created"], m["name"]), reverse=True)
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _find(vault: str | Path, name: str) -> Path | None:
|
|
176
|
+
folder = brain_dir(vault) / MEMORY
|
|
177
|
+
wanted = _safe(Path(str(name)).stem, 200).lower()
|
|
178
|
+
for path in sorted(folder.glob("*.md")) if folder.exists() and wanted else []:
|
|
179
|
+
if path.stem.lower() == wanted:
|
|
180
|
+
return path
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _update(path: Path, changes: dict[str, str | None]) -> None:
|
|
185
|
+
"""Set (or, with None, remove) flat frontmatter fields and leave the rest of the note as it is."""
|
|
186
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
187
|
+
end = text.find("\n---", 3) if text.startswith("---") else -1
|
|
188
|
+
head = text[3:end].strip("\n").split("\n") if end >= 0 else []
|
|
189
|
+
rest = text[end + 4:] if end >= 0 else "\n" + text
|
|
190
|
+
out, done = [], set()
|
|
191
|
+
for line in head:
|
|
192
|
+
key = line.partition(":")[0].strip().lower()
|
|
193
|
+
if key in changes and not line[:1].isspace():
|
|
194
|
+
if changes[key] is not None and key not in done:
|
|
195
|
+
out.append(f"{key}: {changes[key]}")
|
|
196
|
+
done.add(key)
|
|
197
|
+
continue
|
|
198
|
+
out.append(line)
|
|
199
|
+
out += [f"{k}: {v}" for k, v in changes.items() if v is not None and k not in done]
|
|
200
|
+
path.write_text("---\n" + "\n".join(line for line in out if line.strip()) + "\n---" + rest, encoding="utf-8")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def forget(vault: str | Path, name: str) -> Path | None:
|
|
204
|
+
"""Archive a memory by its note name. The note stays in the vault, marked ``archived: true``,
|
|
205
|
+
and is no longer read; delete the file yourself if you want it gone."""
|
|
206
|
+
path = _find(vault, name)
|
|
207
|
+
if path is not None:
|
|
208
|
+
_update(path, {"archived": "true"})
|
|
209
|
+
return path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def supersede(vault: str | Path, old: str, new: str) -> Path | None:
|
|
213
|
+
"""Archive memory ``old`` because memory ``new`` corrects it; each note names the other."""
|
|
214
|
+
path = _find(vault, old)
|
|
215
|
+
if path is not None:
|
|
216
|
+
_update(path, {"archived": "true", "superseded_by": _safe(new, 200)})
|
|
217
|
+
return path
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def pending(vault: str | Path) -> list[dict]:
|
|
221
|
+
"""Memories assistants saved as standing preferences that still wait for the person's approval."""
|
|
222
|
+
return [m for m in memories(vault) if m["pending"]]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def approve(vault: str | Path, name: str) -> Path | None:
|
|
226
|
+
"""The person agrees: the memory becomes part of every briefing."""
|
|
227
|
+
path = _find(vault, name)
|
|
228
|
+
if path is not None:
|
|
229
|
+
_update(path, {"pinned": "true"})
|
|
230
|
+
replaced = _split(path.read_text(encoding="utf-8", errors="ignore"))[0].get("replaces")
|
|
231
|
+
if replaced: # the memory it corrects is archived only now
|
|
232
|
+
supersede(vault, replaced, path.stem)
|
|
233
|
+
return path
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------- exclusions
|
|
237
|
+
_EXCLUDED_HEAD = """---
|
|
238
|
+
kind: brainplug-exclusions
|
|
239
|
+
---
|
|
240
|
+
# Left out of Brainplug
|
|
241
|
+
|
|
242
|
+
Everything listed here stays out of your brain: the map, search and what assistants read.
|
|
243
|
+
Delete a line to bring it back.
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
_EXCLUDED_LINE = re.compile(r"^\s*[-*]\s+(source:\s*)?`([^`]+)`")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def exclusions(vault: str | Path) -> tuple[set[str], set[str]]:
|
|
250
|
+
"""The item ids and the sources listed in ``Brainplug/Excluded.md``."""
|
|
251
|
+
path = brain_dir(vault) / EXCLUDED
|
|
252
|
+
ids: set[str] = set()
|
|
253
|
+
sources: set[str] = set()
|
|
254
|
+
if path.exists():
|
|
255
|
+
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
256
|
+
m = _EXCLUDED_LINE.match(line)
|
|
257
|
+
if m:
|
|
258
|
+
(sources if m.group(1) else ids).add(m.group(2).strip())
|
|
259
|
+
return ids, sources
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def exclude(vault: str | Path, item_id: str = "", title: str = "", source: str = "", by: str = "",
|
|
263
|
+
reason: str = "") -> Path:
|
|
264
|
+
"""Add one line to ``Brainplug/Excluded.md``: an item, or a whole source, to leave out."""
|
|
265
|
+
item_id, source = item_id.replace("`", ""), re.sub(r"[^a-z0-9]", "", source.lower())
|
|
266
|
+
if not item_id and not source:
|
|
267
|
+
raise ValueError("Say which item or which source to leave out.")
|
|
268
|
+
path = init(vault) / EXCLUDED
|
|
269
|
+
if not path.exists():
|
|
270
|
+
path.write_text(_EXCLUDED_HEAD, encoding="utf-8")
|
|
271
|
+
line = f"- source: `{source}`" if source else f"- `{item_id}` · {_safe(title, 80) or 'untitled'}"
|
|
272
|
+
line += f" · {datetime.now():%Y-%m-%d}" + (f" · by {_safe(by, 40)}" if _safe(by) else "")
|
|
273
|
+
line += f": {' '.join(str(reason).split())[:200]}" if str(reason).strip() else ""
|
|
274
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
275
|
+
fh.write(line + "\n")
|
|
276
|
+
return path
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --------------------------------------------------------------- topic names
|
|
280
|
+
_NAMES_HEAD = """---
|
|
281
|
+
kind: brainplug-topic-names
|
|
282
|
+
---
|
|
283
|
+
# Topic names
|
|
284
|
+
|
|
285
|
+
Brainplug names a topic after its strongest words. A name here replaces that, for the topic
|
|
286
|
+
whose words match. Edit or delete a line any time.
|
|
287
|
+
|
|
288
|
+
"""
|
|
289
|
+
_NAME_LINE = re.compile(r"^\s*[-*]\s+\*\*(.+?)\*\*\s+·\s+(.+?)\s*$")
|
|
290
|
+
_NAME_OK = re.compile(r"^[\w][\w\s&'’.,()/+-]*$", re.UNICODE)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def clean_topic_name(name: str) -> str:
|
|
294
|
+
"""A short label: up to 5 words and 40 characters of letters, digits and simple punctuation."""
|
|
295
|
+
name = " ".join(str(name).split())
|
|
296
|
+
if not name or len(name) > 40 or len(name.split()) > 5 or not _NAME_OK.match(name):
|
|
297
|
+
raise ValueError("A topic name is 1 to 5 words (40 characters at most), letters, digits and simple punctuation.")
|
|
298
|
+
return name
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def topic_names(vault: str | Path) -> list[tuple[str, list[str]]]:
|
|
302
|
+
"""The names in ``Brainplug/Topic names.md``, each with the words of the topic it names."""
|
|
303
|
+
path = brain_dir(vault) / TOPIC_NAMES
|
|
304
|
+
out = []
|
|
305
|
+
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines() if path.exists() else []:
|
|
306
|
+
m = _NAME_LINE.match(line)
|
|
307
|
+
if m:
|
|
308
|
+
out.append((m.group(1).strip(), [w.strip().lower() for w in m.group(2).split(",") if w.strip()]))
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def name_topic(vault: str | Path, name: str, terms: list[str]) -> Path:
|
|
313
|
+
"""Save a name for the topic with these words, replacing any name it had."""
|
|
314
|
+
name, words = clean_topic_name(name), [w.lower() for w in terms if w]
|
|
315
|
+
if not words:
|
|
316
|
+
raise ValueError("That topic has no words to remember its name by.")
|
|
317
|
+
kept = [(n, t) for n, t in topic_names(vault) if len(set(t) & set(words)) * 2 < max(1, len(t))]
|
|
318
|
+
path = init(vault) / TOPIC_NAMES
|
|
319
|
+
lines = [f"- **{n}** · {', '.join(t)}" for n, t in kept + [(name, words)]]
|
|
320
|
+
path.write_text(_NAMES_HEAD + "\n".join(lines) + "\n", encoding="utf-8")
|
|
321
|
+
return path
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ------------------------------------------------------------- About me draft
|
|
325
|
+
_DRAFT = {
|
|
326
|
+
"en": {"about": "What I write about most", "now": "What I am working on now", "where": "Where I write",
|
|
327
|
+
"when": "When I write", "at": "mostly on {day}s, around {hour}:00",
|
|
328
|
+
"days": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
|
|
329
|
+
"mark": "a draft from your writing, {d}: check it, fill in the rest and delete what is wrong"},
|
|
330
|
+
"es": {"about": "Sobre lo que más escribo", "now": "En qué estoy trabajando ahora", "where": "Dónde escribo",
|
|
331
|
+
"when": "Cuándo escribo", "at": "sobre todo los {day}, hacia las {hour}:00",
|
|
332
|
+
"days": ["lunes", "martes", "miércoles", "jueves", "viernes", "sábados", "domingos"],
|
|
333
|
+
"mark": "un borrador a partir de lo que escribes, {d}: revísalo, completa el resto y borra lo que no sea cierto"},
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def draft_profile(vault: str | Path, payload: dict) -> tuple[Path, str, bool]:
|
|
338
|
+
"""A draft of "About me" from what the analysis found. Written into the note only while it
|
|
339
|
+
is still the empty template; returns (path, draft, written)."""
|
|
340
|
+
base = init(vault)
|
|
341
|
+
path = base / PROFILE
|
|
342
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
343
|
+
t = _DRAFT["es" if "# Sobre mí" in text else "en"]
|
|
344
|
+
topics, n, review = payload["topics"], payload["numbers"], payload["review"]
|
|
345
|
+
rising = [topics[x["k"]]["name"] for x in review["rising"][:3]]
|
|
346
|
+
lines = [(t["about"], ", ".join(x["name"] for x in topics[:4])),
|
|
347
|
+
(t["now"], ", ".join(rising) or ", ".join(x["name"] for x in topics[:2])),
|
|
348
|
+
(t["where"], ", ".join(s["name"] for s in n["sources"][:4])),
|
|
349
|
+
(t["when"], t["at"].format(day=t["days"][n["busiest_weekday"]], hour=n["busiest_hour"]))]
|
|
350
|
+
draft = "\n".join(f"- {k}: {v}" for k, v in lines if v)
|
|
351
|
+
if profile(vault):
|
|
352
|
+
return path, draft, False # yours already: never overwritten
|
|
353
|
+
body = re.sub(rf"(?m)^- {re.escape(t['now'])}:[ \t]*\n?", "", text).rstrip("\n")
|
|
354
|
+
path.write_text(f"{body}\n\n<!-- {t['mark'].format(d=datetime.now().strftime('%Y-%m-%d'))} -->\n{draft}\n",
|
|
355
|
+
encoding="utf-8")
|
|
356
|
+
return path, draft, True
|