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 +21 -0
- penfield-0.1.0/PKG-INFO +44 -0
- penfield-0.1.0/README.md +22 -0
- penfield-0.1.0/penfield.egg-info/PKG-INFO +44 -0
- penfield-0.1.0/penfield.egg-info/SOURCES.txt +9 -0
- penfield-0.1.0/penfield.egg-info/dependency_links.txt +1 -0
- penfield-0.1.0/penfield.egg-info/entry_points.txt +2 -0
- penfield-0.1.0/penfield.egg-info/top_level.txt +1 -0
- penfield-0.1.0/penfield.py +424 -0
- penfield-0.1.0/pyproject.toml +33 -0
- penfield-0.1.0/setup.cfg +4 -0
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.
|
penfield-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
penfield-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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>◈ 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…</div>
|
|
77
|
+
<h2>Wings</h2>
|
|
78
|
+
<div id="wings">loading…</div>
|
|
79
|
+
<script>
|
|
80
|
+
const kindIcon = {drawer:"▣", diary:"✎", fact:"⇒", "fact-ended":"⇐"};
|
|
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} → <b>${e.p}</b> → ${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:"▣", diary:"✎", fact:"⇒", "fact-ended":"⇐"};
|
|
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]||"▣"}</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"> ${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"]
|
penfield-0.1.0/setup.cfg
ADDED