penfield 0.1.0__tar.gz

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.
penfield-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 geco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: penfield
3
+ Version: 0.1.0
4
+ Summary: Local-first visual browser for your MemPalace memory. Stdlib only.
5
+ Author: geco
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/geco/penfield
8
+ Project-URL: Repository, https://github.com/geco/penfield
9
+ Project-URL: Issues, https://github.com/geco/penfield/issues
10
+ Keywords: mempalace,memory,visualization,ai-agents,local-first
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Visualization
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # penfield
24
+
25
+ Local-first visual browser for your MemPalace memory. Read-only, always.
26
+
27
+ - One command: `penfield`, open `http://localhost:8766`
28
+ - Stdlib only, no build step, no telemetry
29
+ - Never takes the palace writer lock — safe to run while mines write
30
+ - Headless VPS: `ssh -L 8766:localhost:8766 <vps>`, browse from home
31
+
32
+ ## Install
33
+
34
+ Requires MemPalace installed (`pipx install mempalace` or `uv tool install mempalace`):
35
+
36
+ ```bash
37
+ pipx install penfield
38
+ penfield --palace ~/opencode-memory
39
+ ```
40
+
41
+ ## Status
42
+
43
+ v0.1.0 scaffold: health + taxonomy endpoints, wing/room overview page.
44
+ Next: timeline, knowledge-graph view, drawer inspector, semantic search.
@@ -0,0 +1,22 @@
1
+ # penfield
2
+
3
+ Local-first visual browser for your MemPalace memory. Read-only, always.
4
+
5
+ - One command: `penfield`, open `http://localhost:8766`
6
+ - Stdlib only, no build step, no telemetry
7
+ - Never takes the palace writer lock — safe to run while mines write
8
+ - Headless VPS: `ssh -L 8766:localhost:8766 <vps>`, browse from home
9
+
10
+ ## Install
11
+
12
+ Requires MemPalace installed (`pipx install mempalace` or `uv tool install mempalace`):
13
+
14
+ ```bash
15
+ pipx install penfield
16
+ penfield --palace ~/opencode-memory
17
+ ```
18
+
19
+ ## Status
20
+
21
+ v0.1.0 scaffold: health + taxonomy endpoints, wing/room overview page.
22
+ Next: timeline, knowledge-graph view, drawer inspector, semantic search.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: penfield
3
+ Version: 0.1.0
4
+ Summary: Local-first visual browser for your MemPalace memory. Stdlib only.
5
+ Author: geco
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/geco/penfield
8
+ Project-URL: Repository, https://github.com/geco/penfield
9
+ Project-URL: Issues, https://github.com/geco/penfield/issues
10
+ Keywords: mempalace,memory,visualization,ai-agents,local-first
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Visualization
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # penfield
24
+
25
+ Local-first visual browser for your MemPalace memory. Read-only, always.
26
+
27
+ - One command: `penfield`, open `http://localhost:8766`
28
+ - Stdlib only, no build step, no telemetry
29
+ - Never takes the palace writer lock — safe to run while mines write
30
+ - Headless VPS: `ssh -L 8766:localhost:8766 <vps>`, browse from home
31
+
32
+ ## Install
33
+
34
+ Requires MemPalace installed (`pipx install mempalace` or `uv tool install mempalace`):
35
+
36
+ ```bash
37
+ pipx install penfield
38
+ penfield --palace ~/opencode-memory
39
+ ```
40
+
41
+ ## Status
42
+
43
+ v0.1.0 scaffold: health + taxonomy endpoints, wing/room overview page.
44
+ Next: timeline, knowledge-graph view, drawer inspector, semantic search.
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ penfield.py
4
+ pyproject.toml
5
+ penfield.egg-info/PKG-INFO
6
+ penfield.egg-info/SOURCES.txt
7
+ penfield.egg-info/dependency_links.txt
8
+ penfield.egg-info/entry_points.txt
9
+ penfield.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ penfield = penfield:main
@@ -0,0 +1 @@
1
+ penfield
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python3
2
+ """penfield — local-first visual browser for your MemPalace memory.
3
+
4
+ Read-only by construction: opens collections with create=False, opens every
5
+ SQLite file with mode=ro, never takes the palace writer lock, never writes
6
+ anything anywhere. Safe to run while mines write.
7
+
8
+ Stdlib only. Needs the `mempalace` package importable: if this interpreter
9
+ cannot see it (pipx/uv keep it isolated), re-exec into the first venv python
10
+ that has it — same pattern as mp-write.py in opencode-mempalace-persistence.
11
+
12
+ Usage:
13
+ penfield [--palace PATH] [--port N] [--host ADDR]
14
+
15
+ Then open http://localhost:8766 in a browser. On a headless VPS, forward it:
16
+ ssh -L 8766:localhost:8766 <vps>
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import sys
23
+ from http.server import BaseHTTPRequestHandler, HTTPServer
24
+ from urllib.parse import urlparse, parse_qs
25
+
26
+ VERSION = "0.1.0"
27
+ DEFAULT_PORT = 8766
28
+
29
+
30
+ def ensure_mempalace() -> None:
31
+ try:
32
+ import mempalace # noqa: F401
33
+ return
34
+ except ImportError:
35
+ pass
36
+ home = os.path.expanduser("~")
37
+ candidates = []
38
+ env_py = os.environ.get("MEMPALACE_PYTHON", "").strip()
39
+ if env_py:
40
+ candidates.append(env_py)
41
+ candidates += [
42
+ os.path.join(home, ".local/share/pipx/venvs/mempalace/bin/python"),
43
+ os.path.join(home, ".local/share/uv/tools/mempalace/bin/python"),
44
+ ]
45
+ me = os.path.realpath(sys.argv[0])
46
+ for cand in candidates:
47
+ if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
48
+ os.execv(cand, [cand, me] + sys.argv[1:])
49
+ sys.stderr.write(
50
+ "penfield: mempalace package not importable (tried system python, "
51
+ "MEMPALACE_PYTHON and the pipx/uv venvs). Install mempalace first.\n"
52
+ )
53
+ sys.exit(2)
54
+
55
+
56
+ INDEX_HTML = """<!doctype html>
57
+ <html lang="en"><head><meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width,initial-scale=1">
59
+ <title>penfield</title>
60
+ <style>
61
+ body{font-family:system-ui,sans-serif;max-width:720px;margin:2em auto;padding:0 1em;color:#222}
62
+ h1{font-size:1.4em}h2{font-size:1.1em;margin-top:1.6em}
63
+ .wing{margin:.4em 0}.room{color:#666;font-size:.9em}
64
+ .ev{margin:.3em 0;font-size:.92em}.ev i{color:#666}
65
+ a{color:#06c;text-decoration:none}
66
+ </style></head><body>
67
+ <h1>&#x25c8; penfield <small id="v"></small></h1>
68
+ <p>Local-first MemPalace browser. Read-only, always.</p>
69
+ <h2>Knowledge graph</h2>
70
+ <div><label><input type="checkbox" id="kgcur" checked> only current</label>
71
+ <button id="kgload">load graph</button></div>
72
+ <canvas id="kg" width="680" height="420" style="border:1px solid #ccc;max-width:100%"></canvas>
73
+ <div id="kgfacts" style="font-size:.9em"></div>
74
+ <h2>Timeline</h2>
75
+ <div><label>wing: <select id="wing"><option value="">all</option></select></label></div>
76
+ <div id="tl">loading&hellip;</div>
77
+ <h2>Wings</h2>
78
+ <div id="wings">loading&hellip;</div>
79
+ <script>
80
+ const kindIcon = {drawer:"&#x25a3;", diary:"&#x270e;", fact:"&#x21d2;", "fact-ended":"&#x21d0;"};
81
+ // --- force-directed KG on canvas: fixed iterations, no dependencies ------
82
+ function drawKG(nodes, edges) {
83
+ const cv = document.getElementById("kg"), ctx = cv.getContext("2d");
84
+ const W = cv.width, H = cv.height, N = nodes.length;
85
+ nodes.forEach((n, i) => {
86
+ const a = (i / Math.max(1, N)) * 2 * Math.PI;
87
+ n.x = W / 2 + Math.cos(a) * W * 0.32; n.y = H / 2 + Math.sin(a) * H * 0.32;
88
+ n.vx = 0; n.vy = 0; n.r = 4 + Math.sqrt(n.count) * 2;
89
+ });
90
+ const idx = Object.fromEntries(nodes.map((n, i) => [n.id, i]));
91
+ for (let it = 0; it < 160; it++) {
92
+ for (let i = 0; i < N; i++) for (let j = i + 1; j < N; j++) {
93
+ const a = nodes[i], b = nodes[j];
94
+ let dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy + 40;
95
+ const f = 900 / d2, d = Math.sqrt(d2);
96
+ dx /= d; dy /= d; a.vx += dx * f; a.vy += dy * f; b.vx -= dx * f; b.vy -= dy * f;
97
+ }
98
+ edges.forEach(e => {
99
+ const a = nodes[idx[e.s]], b = nodes[idx[e.o]];
100
+ if (!a || !b) return;
101
+ const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1;
102
+ const f = (d - 70) * 0.02;
103
+ a.vx += dx / d * f; a.vy += dy / d * f; b.vx -= dx / d * f; b.vy -= dy / d * f;
104
+ });
105
+ nodes.forEach(n => {
106
+ n.vx *= 0.85; n.vy *= 0.85;
107
+ n.x = Math.min(W - 10, Math.max(10, n.x + n.vx));
108
+ n.y = Math.min(H - 10, Math.max(10, n.y + n.vy));
109
+ });
110
+ }
111
+ function paint(sel) {
112
+ ctx.clearRect(0, 0, W, H);
113
+ edges.forEach(e => {
114
+ const a = nodes[idx[e.s]], b = nodes[idx[e.o]];
115
+ if (!a || !b) return;
116
+ const hot = sel && (e.s === sel || e.o === sel);
117
+ ctx.strokeStyle = hot ? "#06c" : (e.current ? "#bbd" : "#ddd");
118
+ ctx.lineWidth = hot ? 2 : 1;
119
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
120
+ });
121
+ nodes.forEach(n => {
122
+ ctx.fillStyle = n.id === sel ? "#06c" : (n.current ? "#369" : "#999");
123
+ ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, 7); ctx.fill();
124
+ if (n.id === sel || n.count >= 3) {
125
+ ctx.fillStyle = "#222"; ctx.font = "11px system-ui";
126
+ ctx.fillText(n.id.slice(0, 24), n.x + n.r + 3, n.y + 4);
127
+ }
128
+ });
129
+ }
130
+ paint(null);
131
+ cv.onclick = ev => {
132
+ const r = cv.getBoundingClientRect();
133
+ const mx = (ev.clientX - r.left) * (W / r.width), my = (ev.clientY - r.top) * (H / r.height);
134
+ let best = null, bd = 1e9;
135
+ nodes.forEach(n => { const d = (n.x - mx) ** 2 + (n.y - my) ** 2; if (d < bd) { bd = d; best = n; } });
136
+ if (!best || bd > 900) return;
137
+ paint(best.id);
138
+ const facts = edges.filter(e => e.s === best.id || e.o === best.id);
139
+ document.getElementById("kgfacts").innerHTML =
140
+ `<b>${best.id}</b> (${best.count} facts)<br>` + facts.map(e =>
141
+ `${e.s} &rarr; <b>${e.p}</b> &rarr; ${e.o}` + (e.current ? "" : ` <i>(ended${e.to ? " " + e.to.slice(0, 10) : ""})</i>`)
142
+ ).join("<br>");
143
+ };
144
+ }
145
+ document.getElementById("kgload").onclick = () => {
146
+ const cur = document.getElementById("kgcur").checked;
147
+ fetch("api/kg?limit=500").then(r=>r.json()).then(g=>{
148
+ let edges = g.edges || [];
149
+ if (cur) edges = edges.filter(e => e.current);
150
+ const keep = new Set();
151
+ edges.forEach(e => { keep.add(e.s); keep.add(e.o); });
152
+ drawKG(g.nodes.filter(n => keep.has(n.id)), edges);
153
+ document.getElementById("kgfacts").textContent =
154
+ g.missing ? "no knowledge graph here." : `${edges.length} facts, click a node.`;
155
+ }).catch(e => { document.getElementById("kgfacts").textContent = "error: " + e; });
156
+ };
157
+ const kindIcon = {drawer:"&#x25a3;", diary:"&#x270e;", fact:"&#x21d2;", "fact-ended":"&#x21d0;"};
158
+ function loadTimeline(wing) {
159
+ fetch("api/timeline?limit=60" + (wing ? "&wing=" + encodeURIComponent(wing) : "")).then(r=>r.json()).then(t=>{
160
+ const el = document.getElementById("tl");
161
+ if (!t.events || !t.events.length) { el.textContent = "nothing here yet."; return; }
162
+ el.innerHTML = t.events.map(e =>
163
+ `<div class="ev"><span title="${e.kind}">${kindIcon[e.kind]||"&#x25a3;"}</span> ` +
164
+ `<b>${(e.t||"").slice(0,16).replace("T"," ")}</b> ` +
165
+ (e.wing ? `<i>${e.wing}${e.room ? "/" + e.room : ""}</i> ` : "") +
166
+ `${(e.text||"").slice(0,140)}</div>`).join("");
167
+ }).catch(e => { document.getElementById("tl").textContent = "error: " + e; });
168
+ }
169
+ fetch("api/taxonomy").then(r=>r.json()).then(t=>{
170
+ document.getElementById("v").textContent = "v" + t.version;
171
+ const sel = document.getElementById("wing");
172
+ t.wings.forEach(w => { const o = document.createElement("option"); o.value = o.textContent = w.name; sel.appendChild(o); });
173
+ sel.onchange = () => loadTimeline(sel.value);
174
+ const el = document.getElementById("wings");
175
+ el.innerHTML = t.wings.map(w =>
176
+ `<div class="wing"><b>${w.name}</b> — ${w.drawers} drawers` +
177
+ w.rooms.map(r => `<div class="room">&nbsp;&nbsp;${r.name}: ${r.drawers}</div>`).join("") +
178
+ `</div>`).join("");
179
+ loadTimeline("");
180
+ }).catch(e => { document.getElementById("wings").textContent = "error: " + e; });
181
+ </script></body></html>
182
+ """
183
+
184
+
185
+ class Handler(BaseHTTPRequestHandler):
186
+ server_version = "penfield/" + VERSION
187
+
188
+ def _json(self, obj: object, code: int = 200) -> None:
189
+ body = json.dumps(obj).encode("utf-8")
190
+ self.send_response(code)
191
+ self.send_header("Content-Type", "application/json")
192
+ self.send_header("Content-Length", str(len(body)))
193
+ self.end_headers()
194
+ self.wfile.write(body)
195
+
196
+ def _html(self, body: str) -> None:
197
+ raw = body.encode("utf-8")
198
+ self.send_response(200)
199
+ self.send_header("Content-Type", "text/html; charset=utf-8")
200
+ self.send_header("Content-Length", str(len(raw)))
201
+ self.end_headers()
202
+ self.wfile.write(raw)
203
+
204
+ def do_GET(self) -> None: # noqa: N802
205
+ parsed = urlparse(self.path)
206
+ if parsed.path in ("/", "/index.html"):
207
+ self._html(INDEX_HTML)
208
+ elif parsed.path == "/api/health":
209
+ self._json({"ok": True, "version": VERSION, "palace": self.server.palace_path}) # type: ignore[attr-defined]
210
+ elif parsed.path == "/api/taxonomy":
211
+ try:
212
+ self._json(taxonomy(self.server.palace_path)) # type: ignore[attr-defined]
213
+ except Exception as exc: # noqa: BLE001 — JSON error, never a traceback
214
+ self._json({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, 500)
215
+ elif parsed.path == "/api/timeline":
216
+ try:
217
+ qs = parse_qs(parsed.query or "")
218
+ self._json(
219
+ timeline(
220
+ self.server.palace_path, # type: ignore[attr-defined]
221
+ wing=(qs.get("wing") or [None])[0],
222
+ limit=min(int((qs.get("limit") or [200])[0]), 1000),
223
+ )
224
+ )
225
+ except Exception as exc: # noqa: BLE001
226
+ self._json({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, 500)
227
+ elif parsed.path == "/api/kg":
228
+ try:
229
+ qs = parse_qs(parsed.query or "")
230
+ self._json(
231
+ kg_graph(
232
+ self.server.palace_path, # type: ignore[attr-defined]
233
+ limit=min(int((qs.get("limit") or [500])[0]), 2000),
234
+ )
235
+ )
236
+ except Exception as exc: # noqa: BLE001
237
+ self._json({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, 500)
238
+ else:
239
+ self._json({"ok": False, "error": "not found"}, 404)
240
+
241
+ def log_message(self, *args: object) -> None:
242
+ pass # quiet: stdout stays clean for supervisors
243
+
244
+
245
+ def open_collection(palace_path: str):
246
+ """Read-only collection handle. create=False: never creates, never locks."""
247
+ from mempalace.palace import PalaceRef, get_backend_for_palace
248
+
249
+ ref = PalaceRef(id=palace_path, local_path=palace_path)
250
+ backend = get_backend_for_palace(palace_path)
251
+ return backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=False)
252
+
253
+
254
+ def taxonomy(palace_path: str) -> dict:
255
+ from collections import Counter
256
+
257
+ col = open_collection(palace_path)
258
+ total = col.count()
259
+ # Paginated metadata scan: no embeddings cross the wire, ever.
260
+ wings: Counter = Counter()
261
+ rooms: Counter = Counter()
262
+ offset = 0
263
+ step = 20000
264
+ while True:
265
+ res = col.get(limit=step, offset=offset, include=["metadatas"])
266
+ metas = res.get("metadatas") or []
267
+ if not metas:
268
+ break
269
+ for m in metas:
270
+ w = m.get("wing") or "?"
271
+ wings[w] += 1
272
+ rooms[(w, m.get("room") or "?")] += 1
273
+ offset += len(metas)
274
+ if len(metas) < step:
275
+ break
276
+ by_wing: dict = {}
277
+ for (w, r), n in sorted(rooms.items()):
278
+ by_wing.setdefault(w, []).append({"name": r, "drawers": n})
279
+ return {
280
+ "version": VERSION,
281
+ "palace": palace_path,
282
+ "drawers": total,
283
+ "wings": [
284
+ {"name": w, "drawers": wings[w], "rooms": by_wing.get(w, [])}
285
+ for w in sorted(wings)
286
+ ],
287
+ }
288
+
289
+
290
+ def kg_graph(palace_path: str, limit: int = 500) -> dict:
291
+ """Knowledge-graph nodes + edges for the graph view. A fact is current
292
+ while valid_to is NULL — same rule `kg_stats` reports. Read-only open;
293
+ empty (not error) when the KG is missing."""
294
+ import sqlite3
295
+
296
+ nodes: dict = {}
297
+ edges: list = []
298
+ try:
299
+ db = sqlite3.connect(f"file:{kg_path(palace_path)}?mode=ro", uri=True, timeout=5)
300
+ try:
301
+ rows = db.execute(
302
+ "SELECT subject,predicate,object,valid_from,valid_to FROM triples LIMIT ?",
303
+ (limit,),
304
+ ).fetchall()
305
+ finally:
306
+ db.close()
307
+ except Exception:
308
+ return {"ok": True, "nodes": [], "edges": [], "missing": True}
309
+ for s, p, o, vf, vt in rows:
310
+ current = vt is None
311
+ for name in (s, o):
312
+ n = nodes.setdefault(name, {"id": name, "count": 0, "current": False})
313
+ n["count"] += 1
314
+ n["current"] = n["current"] or current
315
+ edges.append({"s": s, "p": p, "o": o, "from": vf, "to": vt, "current": current})
316
+ return {"ok": True, "nodes": sorted(nodes.values(), key=lambda n: -n["count"]), "edges": edges}
317
+
318
+
319
+ def resolve_palace(explicit: str | None) -> str:
320
+ if explicit:
321
+ return os.path.abspath(os.path.expanduser(explicit))
322
+ env = os.environ.get("MEMPALACE_PALACE_PATH", "").strip()
323
+ if env:
324
+ return os.path.abspath(os.path.expanduser(env))
325
+ return os.path.abspath(os.path.expanduser("~/.mempalace/palace"))
326
+
327
+
328
+ def kg_path(palace_path: str) -> str:
329
+ """Same resolution mempalace itself uses: palace-local KG when the palace
330
+ was opened with an explicit path, else the default location."""
331
+ cand = os.path.join(palace_path, "knowledge_graph.sqlite3")
332
+ if os.path.isfile(cand):
333
+ return cand
334
+ return os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")
335
+
336
+
337
+ def timeline(palace_path: str, wing: str | None = None, limit: int = 200) -> dict:
338
+ """Merged timeline: drawer filings + KG fact lifecycles, newest first.
339
+
340
+ Drawers carry filed_at (fallback authored_at); KG triples carry
341
+ extracted_at for birth and valid_to for end. Everything is read with
342
+ read-only opens; the merge cap keeps slow VPS responses small.
343
+ """
344
+ import sqlite3
345
+
346
+ events: list = []
347
+ col = open_collection(palace_path)
348
+ where = {"wing": wing} if wing else None
349
+ got = 0
350
+ offset = 0
351
+ step = 5000
352
+ while got < limit:
353
+ res = col.get(where=where, limit=min(step, limit - got), offset=offset, include=["metadatas", "documents"])
354
+ metas = res.get("metadatas") or []
355
+ docs = res.get("documents") or []
356
+ if not metas:
357
+ break
358
+ for m, d in zip(metas, docs):
359
+ t = m.get("filed_at") or m.get("authored_at")
360
+ if not t:
361
+ continue
362
+ room = m.get("room") or "?"
363
+ events.append(
364
+ {
365
+ "t": t,
366
+ "kind": "diary" if room == "diary" else "drawer",
367
+ "wing": m.get("wing") or "?",
368
+ "room": room,
369
+ "text": (d or "")[:220],
370
+ "source": (m.get("source_file") or "").split("/")[-1] or None,
371
+ }
372
+ )
373
+ got += 1
374
+ offset += len(metas)
375
+ if len(metas) < step:
376
+ break
377
+ try:
378
+ db = sqlite3.connect(f"file:{kg_path(palace_path)}?mode=ro", uri=True, timeout=5)
379
+ try:
380
+ rows = db.execute(
381
+ "SELECT subject,predicate,object,valid_from,valid_to,extracted_at FROM triples LIMIT 5000"
382
+ ).fetchall()
383
+ finally:
384
+ db.close()
385
+ for s, p, o, vf, vt, ex in rows:
386
+ if ex:
387
+ events.append(
388
+ {"t": ex, "kind": "fact", "wing": None, "room": None,
389
+ "text": f"{s} → {p} → {o}", "source": None}
390
+ )
391
+ if vt:
392
+ events.append(
393
+ {"t": vt, "kind": "fact-ended", "wing": None, "room": None,
394
+ "text": f"{s} → {p} → {o}", "source": None}
395
+ )
396
+ except Exception:
397
+ pass # KG unreadable: timeline degrades to drawers, never fails
398
+ events.sort(key=lambda e: e["t"], reverse=True)
399
+ return {"ok": True, "wing": wing, "count": len(events[:limit]), "events": events[:limit]}
400
+
401
+
402
+ def main(argv: list | None = None) -> int:
403
+ ensure_mempalace()
404
+ ap = argparse.ArgumentParser(prog="penfield", description=__doc__.splitlines()[0])
405
+ ap.add_argument("--palace", default=None, help="palace dir (default: MEMPALACE_PALACE_PATH or ~/.mempalace/palace)")
406
+ ap.add_argument("--port", type=int, default=DEFAULT_PORT)
407
+ ap.add_argument("--host", default="127.0.0.1", help="bind address; keep 127.0.0.1 (use SSH forwarding remotely)")
408
+ ns = ap.parse_args(argv)
409
+ palace = resolve_palace(ns.palace)
410
+ if not os.path.isdir(palace):
411
+ sys.stderr.write(f"penfield: no palace at {palace} (pass --palace)\n")
412
+ return 2
413
+ server = HTTPServer((ns.host, ns.port), Handler)
414
+ server.palace_path = palace # type: ignore[attr-defined]
415
+ print(f"penfield v{VERSION} on http://{ns.host}:{ns.port} (read-only, palace {palace})")
416
+ try:
417
+ server.serve_forever()
418
+ except KeyboardInterrupt:
419
+ pass
420
+ return 0
421
+
422
+
423
+ if __name__ == "__main__":
424
+ sys.exit(main())
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "penfield"
7
+ version = "0.1.0"
8
+ description = "Local-first visual browser for your MemPalace memory. Stdlib only."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "geco" }]
13
+ keywords = ["mempalace", "memory", "visualization", "ai-agents", "local-first"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Web Environment",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Scientific/Engineering :: Visualization",
22
+ ]
23
+
24
+ [project.scripts]
25
+ penfield = "penfield:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/geco/penfield"
29
+ Repository = "https://github.com/geco/penfield"
30
+ Issues = "https://github.com/geco/penfield/issues"
31
+
32
+ [tool.setuptools]
33
+ py-modules = ["penfield"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+