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/__init__.py
ADDED
brainplug/__main__.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
python -m brainplug app # drag-and-drop in your browser
|
|
4
|
+
python -m brainplug build export.zip ~/Vault Sent.mbox -o my_brain.html
|
|
5
|
+
python -m brainplug sync --init export.zip ~/Vault && python -m brainplug sync --watch
|
|
6
|
+
python -m brainplug brain ~/Vault # a memory your AI can read and write
|
|
7
|
+
python -m brainplug mcp --brain ~/Vault export.zip
|
|
8
|
+
python -m brainplug connect ~/Vault # add it to Claude, Cursor, Codex… on this computer
|
|
9
|
+
python -m brainplug doctor ~/Vault # check that everything works
|
|
10
|
+
python -m brainplug demo --open
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import webbrowser
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import __version__, analysis, demo
|
|
22
|
+
from .pipeline import Options, describe, load, local_tz, run # noqa: F401 (re-exported)
|
|
23
|
+
from .render import render
|
|
24
|
+
|
|
25
|
+
INPUT_HELP = ("any mix of: ChatGPT/Claude export (.zip or conversations.json), Gemini Takeout (.zip or "
|
|
26
|
+
"MyActivity.json), Obsidian vault or notes folder, Notion export .zip, Gmail .mbox, "
|
|
27
|
+
"WhatsApp chat (.txt or .zip), Word and PDF files, a Brainplug capture .json")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _renames(pairs: list[str] | None) -> dict[int, str]:
|
|
31
|
+
out = {}
|
|
32
|
+
for pair in pairs or []:
|
|
33
|
+
key, _, value = pair.partition("=")
|
|
34
|
+
if not key.strip().isdigit() or not value.strip():
|
|
35
|
+
raise SystemExit(f"--rename expects N=Name (got {pair!r})")
|
|
36
|
+
out[int(key) - 1] = value.strip() # people count topics from 1
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _options(args) -> Options:
|
|
41
|
+
return Options(
|
|
42
|
+
inputs=list(args.inputs), out=args.out, me=list(args.me or []), pdf=args.pdf, notion=args.notion,
|
|
43
|
+
sessions=args.sessions,
|
|
44
|
+
topics=args.topics, private=args.private, rename=_renames(args.rename), title=args.title,
|
|
45
|
+
lang=args.lang, tz=args.tz, vault=args.vault, json=args.json, dataset=args.dataset,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> int:
|
|
50
|
+
ap = argparse.ArgumentParser(
|
|
51
|
+
prog="brainplug",
|
|
52
|
+
description="A second brain for you and your AI assistants: a 3D map of everything you write and a memory they plug into.",
|
|
53
|
+
)
|
|
54
|
+
ap.add_argument("--version", action="version", version=f"brainplug {__version__}")
|
|
55
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
56
|
+
|
|
57
|
+
def common(p):
|
|
58
|
+
p.add_argument("-o", "--out", default="brainplug.html", help="output HTML file")
|
|
59
|
+
p.add_argument("--topics", type=int, help="number of topics (default: automatic)")
|
|
60
|
+
p.add_argument("--private", action="store_true",
|
|
61
|
+
help="leave titles and text out of the HTML (safe to share)")
|
|
62
|
+
p.add_argument("--rename", action="append", metavar="N=NAME",
|
|
63
|
+
help="rename topic N as listed in the terminal, e.g. --rename 2=Python")
|
|
64
|
+
p.add_argument("--title", default="My Brainplug", help="title shown on the page")
|
|
65
|
+
p.add_argument("--lang", choices=["en", "es"], default="en", help="page language")
|
|
66
|
+
p.add_argument("--tz", help="time zone, e.g. Europe/Madrid (default: this computer's)")
|
|
67
|
+
p.add_argument("--open", action="store_true", help="open the result in your browser")
|
|
68
|
+
|
|
69
|
+
def reading(p):
|
|
70
|
+
p.add_argument("--me", action="append", metavar="EMAIL_OR_NAME",
|
|
71
|
+
help="who you are: your email (Gmail) or your name as shown in WhatsApp; repeatable")
|
|
72
|
+
p.add_argument("--pdf", action="store_true",
|
|
73
|
+
help="also read PDFs found inside folders and ZIPs (PDFs you pass directly always count)")
|
|
74
|
+
p.add_argument("--notion", action="store_true",
|
|
75
|
+
help="also read Notion through its API, with real creation dates (set NOTION_TOKEN)")
|
|
76
|
+
p.add_argument("--sessions", action="store_true",
|
|
77
|
+
help="also read your Claude Code and Codex sessions on this computer (only what you typed)")
|
|
78
|
+
|
|
79
|
+
def extras(p):
|
|
80
|
+
p.add_argument("--vault", metavar="DIR",
|
|
81
|
+
help="also write Markdown notes (topics, chats, links) into this Obsidian vault")
|
|
82
|
+
p.add_argument("--json", metavar="FILE", help="also save the analysis as JSON")
|
|
83
|
+
p.add_argument("--dataset", metavar="FILE",
|
|
84
|
+
help="also save your words as a redacted JSON Lines dataset (read it before sharing)")
|
|
85
|
+
|
|
86
|
+
b = sub.add_parser("build", help="build from your own files")
|
|
87
|
+
b.add_argument("inputs", nargs="*", help=INPUT_HELP)
|
|
88
|
+
reading(b), extras(b), common(b)
|
|
89
|
+
|
|
90
|
+
d = sub.add_parser("demo", help="build from synthetic demo data")
|
|
91
|
+
d.add_argument("-n", type=int, default=600, help="number of fake items")
|
|
92
|
+
d.add_argument("--json", metavar="FILE", help="also save the analysis as JSON")
|
|
93
|
+
common(d)
|
|
94
|
+
|
|
95
|
+
a = sub.add_parser("app", help="open the drag-and-drop app in your browser")
|
|
96
|
+
a.add_argument("--port", type=int, default=8765)
|
|
97
|
+
a.add_argument("--no-browser", action="store_true", help="do not open the browser")
|
|
98
|
+
|
|
99
|
+
s = sub.add_parser("sync", help="rebuild from the inputs saved in brainplug.json; --watch keeps it up to date")
|
|
100
|
+
s.add_argument("inputs", nargs="*", help="with --init: " + INPUT_HELP)
|
|
101
|
+
s.add_argument("--init", action="store_true", help="save these inputs and options to brainplug.json")
|
|
102
|
+
s.add_argument("--config", default="brainplug.json", help="settings file (default: ./brainplug.json)")
|
|
103
|
+
s.add_argument("--watch", action="store_true", help="keep running and rebuild when something changes")
|
|
104
|
+
s.add_argument("--every", type=float, default=30, metavar="SECONDS", help="how often --watch looks for changes")
|
|
105
|
+
reading(s), extras(s), common(s)
|
|
106
|
+
|
|
107
|
+
g = sub.add_parser("brain", help="set up a memory your AI assistants can read and write, inside your vault")
|
|
108
|
+
g.add_argument("vault", help="your Obsidian vault, or any folder; a Brainplug/ folder is created inside")
|
|
109
|
+
g.add_argument("inputs", nargs="*", help="with --context: exports to include as well")
|
|
110
|
+
g.add_argument("--context", action="store_true", help="print the briefing, to paste into an assistant without MCP")
|
|
111
|
+
g.add_argument("--remember", metavar="TEXT", help="save a memory yourself, from the terminal")
|
|
112
|
+
g.add_argument("--kind", choices=["note", "fact", "preference", "decision", "project"], default="note")
|
|
113
|
+
g.add_argument("--pin", action="store_true", help="with --remember: make it part of the briefing")
|
|
114
|
+
g.add_argument("--review", action="store_true",
|
|
115
|
+
help="approve or archive the preferences assistants saved, which wait for your yes")
|
|
116
|
+
g.add_argument("--draft-about", action="store_true",
|
|
117
|
+
help='write a draft of your "About me" note from your writing (only while it is empty)')
|
|
118
|
+
g.add_argument("--lang", choices=["en", "es"], default="en", help='language of the "About me" template')
|
|
119
|
+
g.add_argument("--tz", help="time zone, e.g. Europe/Madrid (default: this computer's)")
|
|
120
|
+
reading(g)
|
|
121
|
+
|
|
122
|
+
m = sub.add_parser("mcp", help="serve your brain to your AI assistants (Model Context Protocol)")
|
|
123
|
+
m.add_argument("inputs", nargs="*", help=INPUT_HELP)
|
|
124
|
+
m.add_argument("--brain", metavar="DIR",
|
|
125
|
+
help="your vault: it is read, and assistants can save memories in DIR/Brainplug/Memory")
|
|
126
|
+
m.add_argument("--topics", type=int, help="number of topics (default: automatic)")
|
|
127
|
+
m.add_argument("--tz", help="time zone, e.g. Europe/Madrid (default: this computer's)")
|
|
128
|
+
m.add_argument("--no-cache", action="store_true",
|
|
129
|
+
help="do not keep the index in this computer's cache folder (slower starts)")
|
|
130
|
+
reading(m)
|
|
131
|
+
|
|
132
|
+
c = sub.add_parser("connect", help="add your brain to the AI assistants on this computer (Claude, Cursor, Codex…)")
|
|
133
|
+
c.add_argument("vault", nargs="?", help="your vault (default: the Obsidian vault opened most recently)")
|
|
134
|
+
c.add_argument("inputs", nargs="*", help="exports to include as well, e.g. chatgpt-export.zip")
|
|
135
|
+
c.add_argument("--only", help="comma-separated: claude-desktop, claude-code, cursor, codex, windsurf, gemini, vscode, lmstudio")
|
|
136
|
+
c.add_argument("--sessions", action="store_true", help="also read your Claude Code and Codex sessions")
|
|
137
|
+
c.add_argument("--print", dest="dry", action="store_true", help="show what would change, and change nothing")
|
|
138
|
+
|
|
139
|
+
dr = sub.add_parser("doctor", help="check that your brain answers and your assistants are connected")
|
|
140
|
+
dr.add_argument("vault", nargs="?", help="your vault (default: the Obsidian vault opened most recently)")
|
|
141
|
+
|
|
142
|
+
args = ap.parse_args(argv)
|
|
143
|
+
|
|
144
|
+
if args.cmd == "connect":
|
|
145
|
+
from .connect import connect
|
|
146
|
+
only = [s.strip() for s in (args.only or "").split(",") if s.strip()] or None
|
|
147
|
+
return connect(args.vault, args.inputs, only=only, sessions=args.sessions, dry=args.dry)
|
|
148
|
+
if args.cmd == "doctor":
|
|
149
|
+
from .connect import doctor
|
|
150
|
+
return doctor(args.vault)
|
|
151
|
+
|
|
152
|
+
if args.cmd == "app":
|
|
153
|
+
from .app import serve
|
|
154
|
+
return serve(args.port, open_browser=not args.no_browser)
|
|
155
|
+
|
|
156
|
+
if args.cmd == "demo":
|
|
157
|
+
items = demo.generate(n=args.n)
|
|
158
|
+
print(describe(items))
|
|
159
|
+
payload = analysis.analyse(items, tz=local_tz(args.tz), n_topics=args.topics or 9, private=args.private,
|
|
160
|
+
rename=_renames(args.rename),
|
|
161
|
+
title="Demo Brainplug" if args.title == "My Brainplug" else args.title)
|
|
162
|
+
print(f"Found {len(payload['topics'])} topics: " + ", ".join(t["name"] for t in payload["topics"]))
|
|
163
|
+
out = render(payload, args.out, lang=args.lang)
|
|
164
|
+
if args.json:
|
|
165
|
+
Path(args.json).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
166
|
+
print(f"Saved {out.resolve()}")
|
|
167
|
+
if args.open:
|
|
168
|
+
webbrowser.open(out.resolve().as_uri())
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
if args.cmd in ("mcp", "brain"):
|
|
172
|
+
from . import memory
|
|
173
|
+
from .agent import Brain, cache_file, make_server
|
|
174
|
+
vault = getattr(args, "brain", None) or getattr(args, "vault", None)
|
|
175
|
+
if args.cmd == "mcp" and not args.inputs and not args.notion and not vault and not args.sessions:
|
|
176
|
+
ap.error("mcp needs your vault or your files, e.g.: brainplug mcp --brain ~/Vault export.zip")
|
|
177
|
+
if args.cmd == "brain" and args.remember:
|
|
178
|
+
path = memory.remember(vault, args.remember, kind=args.kind, pin=args.pin, source="me")
|
|
179
|
+
print(f"Saved {path.resolve()}")
|
|
180
|
+
return 0
|
|
181
|
+
if args.cmd == "brain" and args.review:
|
|
182
|
+
return review(vault)
|
|
183
|
+
log = lambda text: print(text, file=sys.stderr) # noqa: E731 (stdout belongs to the protocol)
|
|
184
|
+
inputs = list(args.inputs)
|
|
185
|
+
if vault:
|
|
186
|
+
base = memory.init(vault, getattr(args, "lang", "en"))
|
|
187
|
+
vault = str(Path(vault).expanduser().resolve())
|
|
188
|
+
if vault not in [str(Path(p).expanduser().resolve()) for p in inputs]:
|
|
189
|
+
inputs.insert(0, vault)
|
|
190
|
+
if args.sessions: # watched like any other folder, so new sessions come in as you work
|
|
191
|
+
from .sources import session_folders
|
|
192
|
+
inputs += [str(p) for p in session_folders() if str(p) not in inputs]
|
|
193
|
+
missing = next((p for p in inputs if not Path(p).expanduser().exists()), None)
|
|
194
|
+
if missing:
|
|
195
|
+
ap.error(f"Not found: {missing}")
|
|
196
|
+
if args.cmd == "brain" and not args.context and not args.draft_about:
|
|
197
|
+
from .connect import server_command
|
|
198
|
+
command, server = server_command(vault)
|
|
199
|
+
print(f"Your brain lives in {base}\n"
|
|
200
|
+
f" About me.md a few lines about you; assistants read them at the start of a conversation\n"
|
|
201
|
+
f" Memory/ what assistants are asked to remember, one note each\n\n"
|
|
202
|
+
f"Connect your assistants (Claude Desktop, Claude Code, Cursor, Codex…) in one step:\n"
|
|
203
|
+
f" brainplug connect \"{vault}\"\n\n"
|
|
204
|
+
f"Or by hand. Claude Code:\n"
|
|
205
|
+
f" claude mcp add --scope user brainplug -- \"{command}\" {' '.join(server)}\n"
|
|
206
|
+
f"Claude Desktop (Settings > Developer > Edit Config):\n"
|
|
207
|
+
f" {json.dumps({'mcpServers': {'brainplug': {'command': command, 'args': server}}})}\n\n"
|
|
208
|
+
f"Add your exports after the folder to make them part of the brain:\n"
|
|
209
|
+
f" brainplug mcp --brain \"{vault}\" chatgpt-export.zip Sent.mbox\n\n"
|
|
210
|
+
f"For an assistant without MCP, print a briefing to paste:\n"
|
|
211
|
+
f" brainplug brain \"{vault}\" --context")
|
|
212
|
+
return 0
|
|
213
|
+
o = Options(inputs=inputs, me=list(args.me or []), pdf=args.pdf, notion=args.notion)
|
|
214
|
+
if args.cmd == "brain": # --context or --draft-about: read everything now
|
|
215
|
+
try:
|
|
216
|
+
items = load(o, log)
|
|
217
|
+
except (FileNotFoundError, ValueError) as err:
|
|
218
|
+
ap.error(str(err))
|
|
219
|
+
log(describe(items))
|
|
220
|
+
brain = Brain(items, tz=local_tz(args.tz), vault=vault)
|
|
221
|
+
if args.draft_about:
|
|
222
|
+
if brain.payload is None:
|
|
223
|
+
print("There is not enough written yet to draft it: add your notes or exports after the vault.")
|
|
224
|
+
return 1
|
|
225
|
+
path, draft, written = memory.draft_profile(vault, brain.payload)
|
|
226
|
+
print((f"Wrote a draft into {path}. Check it, and fill in the rest:\n" if written else
|
|
227
|
+
f"{path} is already filled in, so it was left as it is. A draft to compare:\n") + draft)
|
|
228
|
+
if args.context:
|
|
229
|
+
print(brain.context())
|
|
230
|
+
return 0
|
|
231
|
+
quiet = lambda text: None # noqa: E731
|
|
232
|
+
key = json.dumps({"inputs": [str(Path(p).expanduser().resolve()) for p in inputs], "me": o.me,
|
|
233
|
+
"pdf": o.pdf, "notion": o.notion, "topics": args.topics, "tz": args.tz}, sort_keys=True)
|
|
234
|
+
brain = Brain(tz=local_tz(args.tz), n_topics=args.topics, vault=vault, loader=lambda: load(o, quiet),
|
|
235
|
+
watch=inputs, cache=None if args.no_cache else cache_file(key), log=log).start()
|
|
236
|
+
log("Brainplug is answering; your files are read in the background"
|
|
237
|
+
+ (f", and memories go to {vault}" if vault else "") + ".")
|
|
238
|
+
make_server(brain).run()
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
if args.cmd == "sync":
|
|
243
|
+
from . import sync
|
|
244
|
+
if args.init:
|
|
245
|
+
if not args.inputs and not args.notion and not args.sessions:
|
|
246
|
+
ap.error("sync --init needs your files and folders")
|
|
247
|
+
path = sync.write_config(_options(args), args.config)
|
|
248
|
+
print(f"Saved {path.resolve()}. Now run: brainplug sync (or: brainplug sync --watch)")
|
|
249
|
+
return 0
|
|
250
|
+
if args.inputs:
|
|
251
|
+
ap.error("to change the inputs, run sync --init again (or edit brainplug.json)")
|
|
252
|
+
if args.watch:
|
|
253
|
+
print(f"Watching the inputs in {args.config} every {args.every:g} s. Press Ctrl+C to stop.")
|
|
254
|
+
try:
|
|
255
|
+
sync.watch(args.config, every=args.every)
|
|
256
|
+
except KeyboardInterrupt:
|
|
257
|
+
print("\nStopped.")
|
|
258
|
+
return 0
|
|
259
|
+
o = sync.read_config(args.config)
|
|
260
|
+
else:
|
|
261
|
+
if not args.inputs and not args.notion and not args.sessions:
|
|
262
|
+
ap.error("build needs at least one file or folder (or --notion, --sessions)")
|
|
263
|
+
o = _options(args)
|
|
264
|
+
run(o)
|
|
265
|
+
except (FileNotFoundError, ValueError) as err:
|
|
266
|
+
ap.error(str(err))
|
|
267
|
+
if args.open:
|
|
268
|
+
webbrowser.open(Path(o.out).resolve().as_uri())
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def review(vault: str) -> int:
|
|
273
|
+
"""Go through the preferences assistants saved, which wait for your approval."""
|
|
274
|
+
from . import memory
|
|
275
|
+
waiting = memory.pending(vault)
|
|
276
|
+
if not waiting:
|
|
277
|
+
print("Nothing waits for your approval.")
|
|
278
|
+
return 0
|
|
279
|
+
print(f"{len(waiting)} preference(s) saved by assistants wait for your approval. "
|
|
280
|
+
"Approved ones become part of every briefing.\n")
|
|
281
|
+
for m in waiting:
|
|
282
|
+
print(f"{m['created'][:10]} · saved by {m['by'] or 'an assistant'} · {m['kind']}\n "
|
|
283
|
+
+ " ".join(m["text"].split())[:400])
|
|
284
|
+
try:
|
|
285
|
+
answer = input("Approve it? [y]es / [n]o, archive it / anything else: later ").strip().lower()
|
|
286
|
+
except EOFError:
|
|
287
|
+
break
|
|
288
|
+
if answer.startswith(("y", "s")): # yes / sí
|
|
289
|
+
memory.approve(vault, m["name"])
|
|
290
|
+
print(" approved\n")
|
|
291
|
+
elif answer.startswith("n"):
|
|
292
|
+
memory.forget(vault, m["name"])
|
|
293
|
+
print(" archived (the note stays in your vault)\n")
|
|
294
|
+
else:
|
|
295
|
+
print(" left for later\n")
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if __name__ == "__main__":
|
|
300
|
+
sys.exit(main())
|