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 ADDED
@@ -0,0 +1,3 @@
1
+ """Brainplug: turn your AI chat history into a 3D map of your mind."""
2
+
3
+ __version__ = "0.4.0"
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())