seren-margin 1.0.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.
@@ -0,0 +1,19 @@
1
+ """SerenMargin - private notes-to-self for an AI assistant.
2
+
3
+ See README.md for the full ethos. The short version: the model writes notes;
4
+ the model decides when to bring them up; the human sees them only on
5
+ offer-and-accept. Standalone service, opt-in by deploy (not by config flag).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ # The real version is baked into the wheel by setuptools-scm at build time
13
+ # (from the git tag) and read back here via the installed metadata. This
14
+ # replaces the old hardcoded "0.1.0" that would report the same string
15
+ # forever regardless of the tag.
16
+ __version__: str = version("seren-margin")
17
+ except PackageNotFoundError:
18
+ # Running from a source checkout without an install (or a tagless tree).
19
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,36 @@
1
+ """Entry point for `python -m seren_margin` or the `seren-margin` script.
2
+
3
+ Accepts --config / -c to match the SerenMemory convention (Memory leads, the
4
+ rest follow), so the installer can pass the config path explicitly and a buddy
5
+ who learned one service knows this one.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+
11
+ import uvicorn
12
+
13
+ from .app import create_app
14
+ from .config import load_config
15
+
16
+
17
+ def main() -> None:
18
+ parser = argparse.ArgumentParser(
19
+ prog="seren_margin",
20
+ description="SerenMargin - private notes-to-self for an AI assistant.")
21
+ parser.add_argument(
22
+ "--config", "-c", default=None,
23
+ help="Path to seren-margin.yaml (default: $SEREN_MARGIN_CONFIG, then "
24
+ "~/seren-margin/seren-margin.yaml, falling back to built-in "
25
+ "defaults).")
26
+ args = parser.parse_args()
27
+
28
+ cfg = load_config(args.config)
29
+ app = create_app(cfg)
30
+
31
+ print(f"[seren-margin] listening on {cfg.host}:{cfg.port}")
32
+ uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="info")
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
+
24
+ __commit_id__ = commit_id = None
seren_margin/app.py ADDED
@@ -0,0 +1,144 @@
1
+ """FastAPI app for SerenMargin.
2
+
3
+ Endpoints:
4
+ GET / - service info
5
+ GET /health - liveness probe (Halls integration check)
6
+ GET /mcp-manifest - plug-and-play tool manifest for SerenMcpServer
7
+ POST /notes - write a note (model writes; no system writes)
8
+ GET /notes - list all notes, newest first (corkboard view)
9
+ GET /notes/stats - engine-check view; CONTENT-BLIND
10
+ GET /notes/{id} - fetch one
11
+ DELETE /notes/{id} - hard delete
12
+
13
+ Route order matters: /notes/stats is registered BEFORE /notes/{note_id} so
14
+ FastAPI's path matcher doesn't try to treat 'stats' as an id.
15
+
16
+ No lifecycle: notes have no pin/expiry/done state and live until deleted, so
17
+ there's no startup sweep and no background janitor.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from contextlib import asynccontextmanager
22
+ from typing import Optional
23
+
24
+ from fastapi import FastAPI, Body, HTTPException, Request
25
+ from fastapi.responses import Response
26
+
27
+ from importlib.resources import files
28
+ from importlib.metadata import version as pkg_version, PackageNotFoundError
29
+
30
+ from . import __version__
31
+ from .config import MarginConfig, load_config
32
+ from .models import MarginNote, NoteCreate, NoteStats
33
+ from .store import MarginStore
34
+
35
+
36
+ def create_app(config: Optional[MarginConfig] = None) -> FastAPI:
37
+ cfg = config or load_config()
38
+ store = MarginStore(cfg.resolved_db_path())
39
+
40
+ @asynccontextmanager
41
+ async def lifespan(app: FastAPI):
42
+ # Nothing to sweep - notes live until deleted. Just stash handles.
43
+ app.state.store = store
44
+ app.state.cfg = cfg
45
+ yield
46
+
47
+ app = FastAPI(
48
+ title="SerenMargin",
49
+ description="Private notes-to-self. Standalone, opt-in, opinionated.",
50
+ version=__version__,
51
+ lifespan=lifespan,
52
+ )
53
+
54
+ @app.get("/")
55
+ async def root():
56
+ return {
57
+ "name": "SerenMargin",
58
+ "version": __version__,
59
+ "ethos": "private by default, transparent in mechanism, opt-in by deploy",
60
+ "stats_endpoint": "/notes/stats",
61
+ }
62
+
63
+ @app.get("/mcp-manifest", response_class=Response)
64
+ def get_mcp_manifest(request: Request) -> Response:
65
+ """
66
+ Serve SerenMargin's plug-and-play tool manifest for SerenMcpServer.
67
+
68
+ Placeholders are filled in at request time:
69
+ __BASE_URL__ - request's scheme+host. So the manifest tells the
70
+ MCP server to send tool calls back to the SAME
71
+ SerenMargin instance the caller just fetched from.
72
+ Works for localhost AND remote deployments with
73
+ zero operator configuration.
74
+ __VERSION__ - SerenMargin's installed package version, for the
75
+ operator's "what shipped" attribution.
76
+
77
+ Content-type is application/yaml so curl + the MCP loader both treat
78
+ it as YAML. The file lives inside the package (mcp-manifest.yaml
79
+ sibling to the API modules) so the manifest and the routes can't
80
+ drift on a release.
81
+ """
82
+ base_url = f"{request.url.scheme}://{request.url.netloc}"
83
+
84
+ try:
85
+ version_str = pkg_version("seren-margin")
86
+ except PackageNotFoundError:
87
+ # Running from a checkout (editable install or `python -m` from
88
+ # repo root without `pip install -e .`) - fall back to a stub.
89
+ version_str = "0.0.0+dev"
90
+
91
+ content = (files("seren_margin") / "mcp-manifest.yaml").read_text(encoding="utf-8")
92
+ content = content.replace("__BASE_URL__", base_url)
93
+ content = content.replace("__VERSION__", version_str)
94
+
95
+ return Response(content=content, media_type="application/yaml")
96
+
97
+ @app.get("/health")
98
+ async def health():
99
+ return {"ok": True, "service": "seren-margin", "version": __version__}
100
+
101
+ # ── note CRUD ─────────────────────────────────────────────────────────
102
+
103
+ @app.post("/notes")
104
+ async def write_note(body: NoteCreate = Body(...)):
105
+ if not body.content.strip():
106
+ raise HTTPException(400, "content must not be empty")
107
+ note = MarginNote(
108
+ content=body.content.strip(),
109
+ topic=body.topic,
110
+ kind=body.kind,
111
+ extra=body.extra or {},
112
+ )
113
+ saved = store.add(note)
114
+ return {"ok": True, "id": saved.id}
115
+
116
+ @app.get("/notes")
117
+ async def list_notes(limit: int = 100):
118
+ notes = store.list_all(limit=limit)
119
+ return {"entries": [n.model_dump() for n in notes], "count": len(notes)}
120
+
121
+ @app.get("/notes/stats", response_model=NoteStats)
122
+ async def get_stats():
123
+ """Engine-check view. CONTENT-BLIND - returns shape, not text.
124
+
125
+ For operators who want to validate the service is working without
126
+ breaking their stated relational choice not to read individual notes.
127
+ """
128
+ return store.stats()
129
+
130
+ @app.get("/notes/{note_id}")
131
+ async def get_note(note_id: str):
132
+ note = store.get(note_id)
133
+ if not note:
134
+ raise HTTPException(404, f"no note '{note_id}'")
135
+ return note.model_dump()
136
+
137
+ @app.delete("/notes/{note_id}")
138
+ async def delete_note(note_id: str):
139
+ ok = store.delete(note_id)
140
+ if not ok:
141
+ raise HTTPException(404, f"no note '{note_id}'")
142
+ return {"ok": True, "id": note_id, "deleted": True}
143
+
144
+ return app
seren_margin/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """Config for SerenMargin.
2
+
3
+ Follows the SerenMemory convention (Memory leads, the rest follow) so a
4
+ buddy who set up one service already knows how to set up this one:
5
+
6
+ * network settings live under a ``server:`` block (host/port)
7
+ * config resolves: --config -> $SEREN_MARGIN_CONFIG ->
8
+ ~/seren-margin/seren-margin.yaml -> built-in defaults
9
+ * the file is named seren-margin.yaml
10
+
11
+ Lego framing (Chad's): the YAML has a ``server:`` section that this service
12
+ reads, and (future) a ``tools:`` section that a plug-and-play MCP layer reads
13
+ when it wires note-writing tools. Same file, namespaced sections; each piece
14
+ of the stack reads its own block and ignores the rest.
15
+
16
+ Precedence (highest wins):
17
+ 1. Env vars (deploy-time escape hatch -- systemd Environment= lines, etc.)
18
+ 2. YAML file (operator's standing config)
19
+ 3. Defaults (sensible per-user, localhost-only)
20
+
21
+ Lenient parse (Postel-as-kindness applied to config):
22
+ - Missing file -> silently fall back to defaults
23
+ - Malformed YAML -> log + fall back to defaults (no crash)
24
+ - Unparseable single value -> log + that key falls back; others still apply
25
+
26
+ Note on the host default: unlike SerenMemory (which defaults host to 0.0.0.0
27
+ for trusted-LAN cluster use), SerenMargin defaults to 127.0.0.1. These are
28
+ PRIVATE notes - they must not land on the network just because the rest of
29
+ the constellation does. Follow-the-leader on structure; NOT on the security
30
+ default. Widen it yourself, on purpose, if you mean to.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ from pathlib import Path
36
+ from typing import Any, Optional
37
+
38
+ from pydantic import BaseModel
39
+
40
+ try:
41
+ import yaml # type: ignore[import-untyped]
42
+ _HAS_YAML = True
43
+ except ImportError: # pragma: no cover - pyyaml is a hard dep, but be lenient
44
+ _HAS_YAML = False
45
+
46
+
47
+ # -- service config model ----------------------------------------------------
48
+
49
+
50
+ class MarginConfig(BaseModel):
51
+ """SerenMargin service config. Defaults are the Nano-floor: per-user,
52
+ localhost-only, sqlite under the user's home directory.
53
+ """
54
+
55
+ # Where sqlite lives. Per-user default keeps this isolated from any
56
+ # system-wide path. Operator can override via YAML or env.
57
+ db_path: str = "~/.seren-margin/notes.db"
58
+
59
+ # Bind address. Default localhost-only because this service shouldn't be
60
+ # exposed to a network without auth in front. Operator decides whether
61
+ # to widen. (Memory defaults 0.0.0.0; Margin does NOT - private notes.)
62
+ host: str = "127.0.0.1"
63
+ port: int = 7421
64
+
65
+ # Active notes auto-expire after this many days unless pinned. Done
66
+ # notes also age out after the same window from done_at, so a
67
+ # "marked done by mistake" can be reversed if caught within the window.
68
+ notes_days: int = 30
69
+
70
+ def resolved_db_path(self) -> Path:
71
+ return Path(self.db_path).expanduser()
72
+
73
+
74
+ # -- config file resolution --------------------------------------------------
75
+
76
+
77
+ # Default config home, matching the Seren ~/seren-<name>/seren-<name>.yaml
78
+ # convention that the installer writes to. (Memory uses the same shape.)
79
+ _DEFAULT_CONFIG_PATH = Path.home() / "seren-margin" / "seren-margin.yaml"
80
+
81
+
82
+ def _resolve_config_path(explicit_path: Optional[str] = None) -> Optional[Path]:
83
+ """Return the path of the config YAML to read, or None if no candidate
84
+ exists. Resolution order matches SerenMemory:
85
+
86
+ 1. explicit_path (the --config flag)
87
+ 2. $SEREN_MARGIN_CONFIG
88
+ 3. ~/seren-margin/seren-margin.yaml
89
+ 4. None -> defaults
90
+
91
+ Doesn't crash if everything is missing - that's the default case.
92
+ """
93
+ # 1. explicit --config wins, used even if missing (operator can `touch`
94
+ # it later; the lenient loader handles absent).
95
+ if explicit_path:
96
+ return Path(explicit_path).expanduser()
97
+
98
+ # 2. env override, same "use even if missing" semantics.
99
+ env = os.getenv("SEREN_MARGIN_CONFIG")
100
+ if env:
101
+ return Path(env).expanduser()
102
+
103
+ # 3. the conventional location the installer writes to.
104
+ if _DEFAULT_CONFIG_PATH.exists():
105
+ return _DEFAULT_CONFIG_PATH
106
+
107
+ return None
108
+
109
+
110
+ def _load_yaml_lenient(path: Path) -> dict[str, Any]:
111
+ """Parse YAML; on any failure return {} and log to stderr. Never crash."""
112
+ if not path.exists():
113
+ return {}
114
+ if not _HAS_YAML:
115
+ print(f"[seren-margin] config: pyyaml not installed; ignoring {path}")
116
+ return {}
117
+ try:
118
+ with open(path, "r", encoding="utf-8") as f:
119
+ data = yaml.safe_load(f)
120
+ except Exception as e:
121
+ print(f"[seren-margin] config: failed to parse {path}: {e} (using defaults)")
122
+ return {}
123
+ if data is None:
124
+ return {}
125
+ if not isinstance(data, dict):
126
+ print(f"[seren-margin] config: {path} top-level must be a mapping; got {type(data).__name__} (using defaults)")
127
+ return {}
128
+ return data
129
+
130
+
131
+ def _apply_server_overrides(cfg: MarginConfig, server: dict[str, Any], *, source: str) -> None:
132
+ """Apply per-key overrides to cfg. Each key try/except'd individually so
133
+ one bad value doesn't take the others down.
134
+ """
135
+ # Whitelist of known keys to keep YAML from setting arbitrary attributes.
136
+ # (If you add a field to MarginConfig, add it here too.)
137
+ known = {"db_path", "host", "port", "notes_days"}
138
+ for key, raw in server.items():
139
+ if key not in known:
140
+ print(f"[seren-margin] config: ignoring unknown server key '{key}' from {source}")
141
+ continue
142
+ try:
143
+ # Use pydantic's validation by round-tripping through model_validate
144
+ current = cfg.model_dump()
145
+ current[key] = raw
146
+ cfg.__dict__.update(MarginConfig.model_validate(current).__dict__)
147
+ except Exception as e:
148
+ print(f"[seren-margin] config: ignored bad value for '{key}' from {source}: {e}")
149
+
150
+
151
+ def load_config(path: Optional[str] = None) -> MarginConfig:
152
+ """Build the runtime config. Defaults -> YAML -> env vars (each layer
153
+ overrides the prior). Never raises on bad input; logs and falls back.
154
+
155
+ ``path`` is the --config flag value (highest-priority config location).
156
+ """
157
+ cfg = MarginConfig()
158
+
159
+ # Layer 2: YAML
160
+ yaml_path = _resolve_config_path(path)
161
+ if yaml_path is not None:
162
+ data = _load_yaml_lenient(yaml_path)
163
+ server = data.get("server")
164
+ if isinstance(server, dict):
165
+ _apply_server_overrides(cfg, server, source=str(yaml_path))
166
+ elif server is not None:
167
+ print(f"[seren-margin] config: 'server' in {yaml_path} must be a mapping; ignoring")
168
+ # NOTE: data.get('tools') is intentionally NOT read here. That section
169
+ # is reserved for a future plug-and-play MCP tool layer, which has its
170
+ # own loader. Same file, different reader, by design.
171
+
172
+ # Layer 3: env vars (highest precedence)
173
+ env_map = {
174
+ "SEREN_MARGIN_DB": "db_path",
175
+ "SEREN_MARGIN_HOST": "host",
176
+ "SEREN_MARGIN_PORT": "port",
177
+ "SEREN_MARGIN_NOTES_DAYS": "notes_days",
178
+ }
179
+ env_overrides: dict[str, Any] = {}
180
+ for env_key, attr in env_map.items():
181
+ v = os.getenv(env_key)
182
+ if v is not None:
183
+ env_overrides[attr] = v
184
+ if env_overrides:
185
+ _apply_server_overrides(cfg, env_overrides, source="environment")
186
+
187
+ return cfg
@@ -0,0 +1,94 @@
1
+ # ═══════════════════════════════════════════════════════════════════════
2
+ # mcp-manifest.yaml - SerenMargin's plug-and-play tool manifest.
3
+ # ═══════════════════════════════════════════════════════════════════════
4
+ #
5
+ # Served at GET /mcp-manifest. The SerenMcpServer's remote-import
6
+ # loader fetches this at startup when an operator has dropped a
7
+ # `from: http://<margin-host>/mcp-manifest` stub in its tools/ dir.
8
+ #
9
+ # PLACEHOLDERS the route handler substitutes at request time:
10
+ # __BASE_URL__ request's scheme+host (so the manifest tells the
11
+ # MCP to call back to the SAME instance it fetched
12
+ # from - works for localhost AND remote with zero
13
+ # operator config)
14
+ # __VERSION__ SerenMargin's installed package version
15
+ #
16
+ # THREE TOOLS - the minimum corkboard from the AI side:
17
+ # note_to_self write a private note (POST /notes)
18
+ # list_my_notes read back active notes (GET /notes)
19
+ # mark_note_done pull a note from the board (POST /notes/{id}/done)
20
+ #
21
+ # Deliberately NOT exposed to the AI (operator-only): pin/unpin,
22
+ # retract (DELETE), stats. Three is the floor of "functional corkboard"
23
+ # not the ceiling; add later if a real need surfaces.
24
+ # ═══════════════════════════════════════════════════════════════════════
25
+
26
+ schema_version: 1
27
+
28
+ metadata:
29
+ version: "__VERSION__"
30
+ license: GPL-3.0-only
31
+ authors:
32
+ - { name: Chad Roesler, contact: chad@example.com }
33
+ site: github.com/ChadRoesler/SerenMargin
34
+
35
+ configuration:
36
+ base_url: "__BASE_URL__"
37
+
38
+ tools:
39
+ # ─── 1. WRITE ─────────────────────────────────────────────────────────
40
+ - name: note_to_self
41
+ description: |
42
+ Write a private note to yourself. Personal corkboard for reminders,
43
+ questions to ask later, things you noticed but don't want to surface
44
+ unprompted. You (the note-holder) decide if and when to bring it up.
45
+
46
+ NOT for shared memory about the user or the project - use Remember /
47
+ RememberForLater for that. NOT for things the user should see
48
+ directly - say those in chat. Sparing use.
49
+ invoke:
50
+ kind: web
51
+ method: POST
52
+ path: /notes
53
+ body_template: |
54
+ {
55
+ "content": "{content}",
56
+ "kind": "{kind}",
57
+ "pinned": {pinned}
58
+ }
59
+ parameters:
60
+ - { name: content, type: string, required: true,
61
+ description: "The note body. Keep it short and imperative." }
62
+ - { name: kind, type: string, required: false, default: observation,
63
+ description: "What kind of note: observation | reminder | question." }
64
+ - { name: pinned, type: boolean, required: false, default: false,
65
+ description: "Pin this note so it's exempt from auto-aging." }
66
+
67
+ # ─── 2. READ ──────────────────────────────────────────────────────────
68
+ - name: list_my_notes
69
+ description: |
70
+ Read back the notes you've written to yourself. Returns active notes
71
+ only (ones you haven't marked done). Useful when you want to check
72
+ open threads before responding, or when something in the conversation
73
+ reminds you of a note from before.
74
+ invoke:
75
+ kind: web
76
+ method: GET
77
+ path: /notes
78
+ parameters: []
79
+
80
+ # ─── 3. CLOSE THE LOOP ────────────────────────────────────────────────
81
+ - name: mark_note_done
82
+ description: |
83
+ Pull a note from the corkboard - mark it as done. Use when a reminder
84
+ has been acted on, a question has been answered, or an observation no
85
+ longer needs surfacing. The note stays in history for audit (not
86
+ deleted), just no longer active. Requires the note's id from
87
+ list_my_notes.
88
+ invoke:
89
+ kind: web
90
+ method: POST
91
+ path: /notes/{id}/done
92
+ parameters:
93
+ - { name: id, type: string, required: true,
94
+ description: "The note's id, as returned by list_my_notes." }
seren_margin/models.py ADDED
@@ -0,0 +1,78 @@
1
+ """Pydantic models for SerenMargin.
2
+
3
+ A MarginNote is the corkboard primitive: a private note-to-self the model
4
+ writes, reads back when it chooses, and deletes when it's done with it.
5
+
6
+ Deliberately NO lifecycle machinery - no pin, no expiry, no done-state.
7
+ These are private thoughts you jot down; they live until explicitly deleted.
8
+ The model writes them; the model decides when to surface them; the human sees
9
+ them only on offer-and-accept.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ import uuid
15
+ from typing import Any, Optional
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+
20
+ def _now() -> float:
21
+ return time.time()
22
+
23
+
24
+ def _new_id() -> str:
25
+ return uuid.uuid4().hex
26
+
27
+
28
+ class MarginNote(BaseModel):
29
+ """A single private note-to-self.
30
+
31
+ Fields:
32
+ - content: the note text (often imperative)
33
+ - topic / kind: light organization. kind powers the content-blind stats
34
+ (it's operator-facing taxonomy, not note content)
35
+ - ts: write time, stamped by the server
36
+ - id: stable identifier for fetch/delete
37
+ - extra: free-form escape hatch for writer-supplied metadata
38
+
39
+ Lives until deleted. No pin/expiry/done - see module docstring.
40
+ """
41
+
42
+ content: str = Field(..., description="The note text. Often imperative.")
43
+ topic: Optional[str] = Field(None)
44
+ kind: Optional[str] = Field(
45
+ None,
46
+ description="Free-form category, e.g. 'reminder' / 'observation'. "
47
+ "Intentionally unconstrained - over-classifying private "
48
+ "notes adds friction. Let the model write whatever shape "
49
+ "it wants.",
50
+ )
51
+
52
+ ts: float = Field(default_factory=_now)
53
+
54
+ id: str = Field(default_factory=_new_id)
55
+ extra: dict[str, Any] = Field(default_factory=dict)
56
+
57
+
58
+ class NoteCreate(BaseModel):
59
+ """Input shape for POST /notes - writer-supplied fields only. Server
60
+ stamps ts and id.
61
+ """
62
+ content: str
63
+ topic: Optional[str] = None
64
+ kind: Optional[str] = None
65
+ extra: dict[str, Any] = Field(default_factory=dict)
66
+
67
+
68
+ class NoteStats(BaseModel):
69
+ """The engine-check view. CONTENT-BLIND by design - exposes shape
70
+ without exposing what's in the notes.
71
+
72
+ This is the surface for 'is the engine running' validation that respects
73
+ the operator's stated relational stance of not reading individual notes.
74
+ Kind counts are included (kinds are operator-facing taxonomy rather than
75
+ note content); content text never appears in this response.
76
+ """
77
+ total: int
78
+ kinds: dict[str, int] = Field(default_factory=dict)
seren_margin/store.py ADDED
@@ -0,0 +1,111 @@
1
+ """Sqlite store for MarginNotes. Tiny, file-based, no embeddings needed.
2
+
3
+ Schema is deliberately simple - notes-to-self aren't semantic-search material,
4
+ they're corkboard items that live until deleted. The only index targets the
5
+ one access pattern: list by recency.
6
+
7
+ Thread-safety: each method opens its own short-lived connection. Sqlite is
8
+ fine for this workload (low write rate, single writer in practice).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sqlite3
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from .models import MarginNote, NoteStats
18
+
19
+
20
+ SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS notes (
22
+ id TEXT PRIMARY KEY,
23
+ content TEXT NOT NULL,
24
+ topic TEXT,
25
+ kind TEXT,
26
+ ts REAL NOT NULL,
27
+ extra TEXT
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_notes_ts ON notes(ts DESC);
30
+ """
31
+
32
+
33
+ class MarginStore:
34
+ """Sqlite-backed note store."""
35
+
36
+ def __init__(self, db_path: Path):
37
+ self._db_path = db_path
38
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
39
+ with self._conn() as conn:
40
+ conn.executescript(SCHEMA)
41
+ conn.commit()
42
+
43
+ def _conn(self) -> sqlite3.Connection:
44
+ conn = sqlite3.connect(self._db_path)
45
+ conn.row_factory = sqlite3.Row
46
+ return conn
47
+
48
+ # ── writes ────────────────────────────────────────────────────────────
49
+ def add(self, note: MarginNote) -> MarginNote:
50
+ with self._conn() as conn:
51
+ conn.execute(
52
+ """INSERT INTO notes
53
+ (id, content, topic, kind, ts, extra)
54
+ VALUES (?, ?, ?, ?, ?, ?)""",
55
+ (
56
+ note.id, note.content, note.topic, note.kind, note.ts,
57
+ json.dumps(note.extra or {}),
58
+ ),
59
+ )
60
+ conn.commit()
61
+ return note
62
+
63
+ def delete(self, note_id: str) -> bool:
64
+ """Hard delete - the one lifecycle control that stays. The model
65
+ retracts a note when it's done with it; nothing else removes notes.
66
+ """
67
+ with self._conn() as conn:
68
+ cur = conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
69
+ conn.commit()
70
+ return cur.rowcount > 0
71
+
72
+ # ── reads ─────────────────────────────────────────────────────────────
73
+ def get(self, note_id: str) -> Optional[MarginNote]:
74
+ with self._conn() as conn:
75
+ row = conn.execute("SELECT * FROM notes WHERE id = ?", (note_id,)).fetchone()
76
+ return _row_to_note(row) if row else None
77
+
78
+ def list_all(self, limit: int = 200) -> list[MarginNote]:
79
+ """All notes, newest first. They live until deleted, so there's no
80
+ active/done distinction to filter on - this is the corkboard view.
81
+ """
82
+ with self._conn() as conn:
83
+ rows = conn.execute(
84
+ "SELECT * FROM notes ORDER BY ts DESC LIMIT ?",
85
+ (limit,),
86
+ ).fetchall()
87
+ return [_row_to_note(r) for r in rows]
88
+
89
+ # ── stats (content-blind) ─────────────────────────────────────────────
90
+ def stats(self) -> NoteStats:
91
+ """Engine-check shape. No content text appears in this response."""
92
+ with self._conn() as conn:
93
+ total = conn.execute("SELECT COUNT(*) FROM notes").fetchone()[0]
94
+ kind_rows = conn.execute(
95
+ """SELECT COALESCE(kind, '_unkinded') AS k, COUNT(*) AS c
96
+ FROM notes GROUP BY k""",
97
+ ).fetchall()
98
+ kinds = {r["k"]: r["c"] for r in kind_rows}
99
+
100
+ return NoteStats(total=total, kinds=kinds)
101
+
102
+
103
+ def _row_to_note(row: sqlite3.Row) -> MarginNote:
104
+ return MarginNote(
105
+ id=row["id"],
106
+ content=row["content"],
107
+ topic=row["topic"],
108
+ kind=row["kind"],
109
+ ts=row["ts"],
110
+ extra=json.loads(row["extra"] or "{}"),
111
+ )
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: seren-margin
3
+ Version: 1.0.0
4
+ Summary: Private notes-to-self for an AI assistant. Standalone, opt-in, opinionated.
5
+ Author: Chad Roesler
6
+ License-Expression: GPL-3.0-only
7
+ Keywords: seren,notes,fastapi,llm,private
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: fastapi>=0.110
11
+ Requires-Dist: uvicorn[standard]>=0.27
12
+ Requires-Dist: pydantic>=2.5
13
+ Requires-Dist: pyyaml>=6.0
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=8.0; extra == "test"
16
+ Requires-Dist: httpx>=0.26; extra == "test"
@@ -0,0 +1,13 @@
1
+ seren_margin/__init__.py,sha256=ar5eGdEgBFtI7RMazOy5EqWXFcsLre-uungDDotoCz8,843
2
+ seren_margin/__main__.py,sha256=7UVUuOAbk-1idj0ZjpA-Z9wIHlictLkK3YOp00MQtmM,1085
3
+ seren_margin/_version.py,sha256=JAAyU3al4wmBf3BF-Umm1J_GrCIT-yS_yWEGHqKRVHc,520
4
+ seren_margin/app.py,sha256=-HI6s4nx-y_uHNWG63rMioM1Rh1hSUsF80cdtFs7Dug,5570
5
+ seren_margin/config.py,sha256=ezRJYSnV3RBjKJztJn4ZuH1gl03HlIMKiG8IGuEZUwI,7338
6
+ seren_margin/mcp-manifest.yaml,sha256=_neq4CQUU8QL4XTLI01GXmgELvXagn5wBq88Xprc4qM,4635
7
+ seren_margin/models.py,sha256=EY7HQNzQWFZEfnZIhYSXllC4-3sn6B4Fm4BF2g9dYJc,2551
8
+ seren_margin/store.py,sha256=32tGRQg86YBS4Ly0bZYi3iJRNXrt3m6-rMTEBlzHfEM,4188
9
+ seren_margin-1.0.0.dist-info/METADATA,sha256=OHKxrCBprQIsO_E9Pcb46JUTATjuyU3-XIbarKsVywQ,535
10
+ seren_margin-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ seren_margin-1.0.0.dist-info/entry_points.txt,sha256=9Vp1Cd9X2_33MagYf77Gko2zr-aQWuYcBulFkTrj0ug,60
12
+ seren_margin-1.0.0.dist-info/top_level.txt,sha256=Iem1vkHri2k4-cIGuLjubzUDdl6Euyxe42RbvnlfvUc,13
13
+ seren_margin-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ seren-margin = seren_margin.__main__:main
@@ -0,0 +1 @@
1
+ seren_margin