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/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