seren-margin 1.0.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.
@@ -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,52 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
3
+ <PropertyGroup>
4
+ <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
5
+ <SchemaVersion>2.0</SchemaVersion>
6
+ <ProjectGuid>b1e7c0de-5e12-4a7b-9f3a-5e7e7a5e7e7a</ProjectGuid>
7
+ <ProjectHome>.</ProjectHome>
8
+ <!-- Run python -m seren_margin when you hit F5 -->
9
+ <StartupFile>seren_margin\__main__.py</StartupFile>
10
+ <SearchPath>.</SearchPath>
11
+ <WorkingDirectory>.</WorkingDirectory>
12
+ <OutputPath>.</OutputPath>
13
+ <Name>SerenMargin</Name>
14
+ <RootNamespace>seren_margin</RootNamespace>
15
+ <InterpreterId>Global|PythonCore|3.11</InterpreterId>
16
+ <IsWindowsApplication>False</IsWindowsApplication>
17
+ <Description>Private notes-to-self for an AI assistant. Standalone, opt-in, opinionated.</Description>
18
+ <TestFramework>Pytest</TestFramework>
19
+ </PropertyGroup>
20
+ <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
21
+ <DebugSymbols>true</DebugSymbols>
22
+ <EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
23
+ </PropertyGroup>
24
+ <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
25
+ <DebugSymbols>true</DebugSymbols>
26
+ <EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
27
+ </PropertyGroup>
28
+ <ItemGroup>
29
+ <Compile Include="seren_margin\__init__.py" />
30
+ <Compile Include="seren_margin\__main__.py" />
31
+ <Compile Include="seren_margin\app.py" />
32
+ <Compile Include="seren_margin\config.py" />
33
+ <Compile Include="seren_margin\models.py" />
34
+ <Compile Include="seren_margin\store.py" />
35
+ <Compile Include="tests\test_smoke.py" />
36
+ <Compile Include="tests\test_config.py" />
37
+ <Compile Include="tests\__init__.py" />
38
+ </ItemGroup>
39
+ <ItemGroup>
40
+ <Folder Include="seren_margin\" />
41
+ <Folder Include="tests\" />
42
+ </ItemGroup>
43
+ <ItemGroup>
44
+ <Content Include="pyproject.toml" />
45
+ <Content Include="seren-margin.yaml.sample" />
46
+ <Content Include="seren-margin.service.sample" />
47
+ </ItemGroup>
48
+ <ItemGroup>
49
+ <InterpreterReference Include="Global|PythonCore|3.11" />
50
+ </ItemGroup>
51
+ <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets" />
52
+ </Project>
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "setuptools-scm>=8", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "seren-margin"
7
+ dynamic = ["version"]
8
+ description = "Private notes-to-self for an AI assistant. Standalone, opt-in, opinionated."
9
+ license = "GPL-3.0-only"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Chad Roesler" }]
12
+ readme = "README.md"
13
+ keywords = ["seren", "notes", "fastapi", "llm", "private"]
14
+ dependencies = [
15
+ "fastapi>=0.110",
16
+ "uvicorn[standard]>=0.27",
17
+ "pydantic>=2.5",
18
+ "pyyaml>=6.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ test = [
23
+ "pytest>=8.0",
24
+ "httpx>=0.26", # for fastapi TestClient
25
+ ]
26
+
27
+ [project.scripts]
28
+ seren-margin = "seren_margin.__main__:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["."]
32
+ include = ["seren_margin*"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+
37
+ # Ship the MCP manifest inside the wheel - without this, pip strips the
38
+ # non-.py file and GET /mcp-manifest 500s on an installed copy.
39
+ [tool.setuptools.package-data]
40
+ seren_margin = ["mcp-manifest.yaml"]
41
+
42
+ # ── setuptools-scm: version from git tags (matches SerenMemory) ─────────────
43
+ # Tag the repo root v1.2.3; the version flows from the tag with no manual edit.
44
+ # Kills the old frozen `version = "0.1.0"` that would publish the same string
45
+ # forever. _version.py is gitignored - a build artifact, never committed.
46
+ [tool.setuptools_scm]
47
+ # .git is one level above pyproject.toml (repo root, not the SerenMargin subdir)
48
+ root = ".."
49
+ version_scheme = "release-branch-semver"
50
+ version_file = "seren_margin/_version.py"
51
+ # Strip the leading 'v' so "v1.2.3" → "1.2.3"
52
+ tag_regex = "^v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+.*)$"
53
+ # Used when no git tags exist (local dev, fresh clone). CI release builds
54
+ # override with SETUPTOOLS_SCM_PRETEND_VERSION.
55
+ fallback_version = "0.0.0.dev0"
@@ -0,0 +1,55 @@
1
+ [Unit]
2
+ # SerenMargin - private notes-to-self for an AI assistant.
3
+ #
4
+ # install.sh substitutes the __PLACEHOLDERS__ below and strips this comment
5
+ # block (everything before [Unit]) when it generates the live unit. If you're
6
+ # installing BY HAND instead, replace the placeholders yourself:
7
+ # __TARGET_USER__ -> the user the service runs as (e.g. `whoami`)
8
+ # __VENV_DIR__ -> your venv (install.sh: ~/seren-venvs/margin)
9
+ # __WORKDIR__ -> working dir (install.sh: ~/seren-margin)
10
+ # __CONFIG_PATH__ -> the yaml (install.sh: ~/seren-margin/seren-margin.yaml)
11
+ #
12
+ # By-hand install (system unit):
13
+ # sudo cp seren-margin.service.sample /etc/systemd/system/seren-margin.service
14
+ # sudo $EDITOR /etc/systemd/system/seren-margin.service # fill placeholders
15
+ # sudo systemctl daemon-reload
16
+ # sudo systemctl enable --now seren-margin.service
17
+ #
18
+ # Logs:
19
+ # journalctl -u seren-margin.service -f
20
+ #
21
+ Description=SerenMargin - private notes-to-self
22
+ After=network-online.target
23
+ Wants=network-online.target
24
+
25
+ [Service]
26
+ Type=simple
27
+ User=__TARGET_USER__
28
+ WorkingDirectory=__WORKDIR__
29
+ # --config makes the service read the file the installer wrote, so the config
30
+ # location is never ambiguous. (Same pattern as SerenMemory.)
31
+ ExecStart=__VENV_DIR__/bin/python -m seren_margin --config __CONFIG_PATH__
32
+
33
+ # Margin also honors $SEREN_MARGIN_CONFIG and falls back to
34
+ # ~/seren-margin/seren-margin.yaml, but the explicit --config above wins and
35
+ # kills any ambiguity. Override individual values without touching the YAML:
36
+ # Environment=SEREN_MARGIN_PORT=7421
37
+ # Environment=SEREN_MARGIN_HOST=127.0.0.1
38
+
39
+ # Restart policy - restart on crash but back off so a broken config doesn't
40
+ # eat the system. 3 restarts in 5min = give up.
41
+ Restart=on-failure
42
+ RestartSec=5
43
+ StartLimitBurst=3
44
+ StartLimitIntervalSec=300
45
+
46
+ # Modest memory ceiling - sqlite + FastAPI is tiny. Adjust if you must.
47
+ MemoryMax=256M
48
+
49
+ # Light hardening that doesn't break the localhost-bound, per-user use case.
50
+ NoNewPrivileges=true
51
+ PrivateTmp=true
52
+ ProtectSystem=full
53
+
54
+ [Install]
55
+ WantedBy=multi-user.target
@@ -0,0 +1,52 @@
1
+ # seren-margin.yaml - SerenMargin config (the friend-to-friend version)
2
+ #
3
+ # Lives at ~/seren-margin/seren-margin.yaml (the installer writes it there and
4
+ # starts the service with --config pointing right at it). Same shape and
5
+ # location convention as SerenMemory's seren-memory.yaml - learn one service,
6
+ # you know them all. Set SEREN_MARGIN_CONFIG to override the location, or pass
7
+ # --config / -c on the command line (highest priority).
8
+ #
9
+ # Precedence (highest wins): env vars > this file > built-in defaults.
10
+ # Everything here is OPTIONAL - missing keys just use the defaults shown below.
11
+ # Bad values are ignored (logged to stderr, fall back to the default).
12
+ #
13
+ # The file is split into namespaced sections, lego-style. Each piece of the
14
+ # Seren stack reads its own section and ignores the others:
15
+ # server: <- this file's settings (what SerenMargin itself reads)
16
+ # tools: <- reserved for the future plug-and-play MCP tool layer
17
+ # (when the note_to_self write tool lands, ITS config lives
18
+ # there, and SerenMargin still won't read that block)
19
+
20
+ server:
21
+ # Where sqlite lives. ~ is expanded.
22
+ # Default: ~/.seren-margin/notes.db
23
+ # db_path: ~/.seren-margin/notes.db
24
+
25
+ # Bind address. Localhost-only by default because private notes shouldn't
26
+ # be on a network without auth in front. (SerenMemory defaults to 0.0.0.0
27
+ # for the cluster; Margin deliberately does NOT - these are private notes.)
28
+ # Widen at your own risk, on purpose.
29
+ # Default: 127.0.0.1
30
+ # host: 127.0.0.1
31
+
32
+ # Listen port.
33
+ # Default: 7421
34
+ # port: 7421
35
+
36
+ # Active notes auto-expire after this many days unless pinned. Done notes
37
+ # also age out after the same window from done_at, so "marked done by
38
+ # mistake" can be reversed if caught within the window.
39
+
40
+
41
+ # Reserved for the future plug-and-play MCP tool layer. SerenMargin itself
42
+ # ignores this block. The MCP server is what'll read it when the note_to_self
43
+ # write tool lands. Schema will mirror McpConfig's open shape:
44
+ # tools:
45
+ # <tool_name>:
46
+ # <key>: <value>
47
+ #
48
+ # Example (illustrative - not active until the plug-and-play registry ships):
49
+ # tools:
50
+ # note_to_self:
51
+ # max_content_chars: 2048
52
+ # allowed_kinds: ["reminder", "observation", "question"]
@@ -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 = 'g5bd0a00dd'
@@ -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
@@ -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." }
@@ -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)
@@ -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,21 @@
1
+ SerenMargin.pyproj
2
+ pyproject.toml
3
+ seren-margin.service.sample
4
+ seren-margin.yaml.sample
5
+ seren_margin/__init__.py
6
+ seren_margin/__main__.py
7
+ seren_margin/_version.py
8
+ seren_margin/app.py
9
+ seren_margin/config.py
10
+ seren_margin/mcp-manifest.yaml
11
+ seren_margin/models.py
12
+ seren_margin/store.py
13
+ seren_margin.egg-info/PKG-INFO
14
+ seren_margin.egg-info/SOURCES.txt
15
+ seren_margin.egg-info/dependency_links.txt
16
+ seren_margin.egg-info/entry_points.txt
17
+ seren_margin.egg-info/requires.txt
18
+ seren_margin.egg-info/top_level.txt
19
+ tests/__init__.py
20
+ tests/test_config.py
21
+ tests/test_smoke.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ seren-margin = seren_margin.__main__:main
@@ -0,0 +1,8 @@
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ pydantic>=2.5
4
+ pyyaml>=6.0
5
+
6
+ [test]
7
+ pytest>=8.0
8
+ httpx>=0.26
@@ -0,0 +1 @@
1
+ seren_margin
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,169 @@
1
+ """Tests for seren_margin.config.load_config().
2
+
3
+ Covers:
4
+ - missing file -> defaults
5
+ - well-formed YAML (server: block) -> applied
6
+ - malformed YAML -> fallback, no crash
7
+ - bad single value -> that key falls back, others applied
8
+ - env var overrides YAML
9
+ - unknown server key -> ignored
10
+ - 'tools' section silently ignored (different reader's job)
11
+ - --config path argument resolves (Memory-rhyming entry point)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import pytest
16
+
17
+ from seren_margin.config import MarginConfig, load_config
18
+
19
+
20
+ # Helper to point load_config() at a controlled tmpdir config file.
21
+ @pytest.fixture
22
+ def cfg_path(tmp_path, monkeypatch):
23
+ p = tmp_path / "seren-margin.yaml"
24
+ monkeypatch.setenv("SEREN_MARGIN_CONFIG", str(p))
25
+ # Clear any per-key env vars from the host so tests are deterministic.
26
+ for k in ("SEREN_MARGIN_DB", "SEREN_MARGIN_HOST",
27
+ "SEREN_MARGIN_PORT", "SEREN_MARGIN_NOTES_DAYS"):
28
+ monkeypatch.delenv(k, raising=False)
29
+ return p
30
+
31
+
32
+ def test_defaults_when_no_yaml_and_no_env(monkeypatch, tmp_path):
33
+ # Point at a path that doesn't exist; load_config should not crash.
34
+ monkeypatch.setenv("SEREN_MARGIN_CONFIG", str(tmp_path / "nope.yaml"))
35
+ for k in ("SEREN_MARGIN_DB", "SEREN_MARGIN_HOST",
36
+ "SEREN_MARGIN_PORT", "SEREN_MARGIN_NOTES_DAYS"):
37
+ monkeypatch.delenv(k, raising=False)
38
+ cfg = load_config()
39
+ defaults = MarginConfig()
40
+ assert cfg.host == defaults.host
41
+ assert cfg.port == defaults.port
42
+ assert cfg.db_path == defaults.db_path
43
+ assert cfg.notes_days == defaults.notes_days
44
+
45
+
46
+ def test_yaml_overrides_defaults(cfg_path):
47
+ cfg_path.write_text(
48
+ "server:\n"
49
+ " host: 0.0.0.0\n"
50
+ " port: 9999\n"
51
+ " notes_days: 7\n"
52
+ " db_path: /tmp/some-db.sqlite\n"
53
+ )
54
+ cfg = load_config()
55
+ assert cfg.host == "0.0.0.0"
56
+ assert cfg.port == 9999
57
+ assert cfg.notes_days == 7
58
+ assert cfg.db_path == "/tmp/some-db.sqlite"
59
+
60
+
61
+ def test_config_arg_resolves(tmp_path, monkeypatch):
62
+ """The --config path argument is highest priority and used even when the
63
+ env var points elsewhere. Proves the Memory-rhyming entry point works."""
64
+ for k in ("SEREN_MARGIN_DB", "SEREN_MARGIN_HOST",
65
+ "SEREN_MARGIN_PORT", "SEREN_MARGIN_NOTES_DAYS",
66
+ "SEREN_MARGIN_CONFIG"):
67
+ monkeypatch.delenv(k, raising=False)
68
+ explicit = tmp_path / "explicit.yaml"
69
+ explicit.write_text("server:\n port: 4242\n")
70
+ cfg = load_config(str(explicit))
71
+ assert cfg.port == 4242
72
+
73
+
74
+ def test_config_arg_beats_env(tmp_path, monkeypatch):
75
+ """--config wins over $SEREN_MARGIN_CONFIG (explicit beats ambient)."""
76
+ for k in ("SEREN_MARGIN_DB", "SEREN_MARGIN_HOST",
77
+ "SEREN_MARGIN_PORT", "SEREN_MARGIN_NOTES_DAYS"):
78
+ monkeypatch.delenv(k, raising=False)
79
+ env_cfg = tmp_path / "env.yaml"
80
+ env_cfg.write_text("server:\n port: 1111\n")
81
+ arg_cfg = tmp_path / "arg.yaml"
82
+ arg_cfg.write_text("server:\n port: 2222\n")
83
+ monkeypatch.setenv("SEREN_MARGIN_CONFIG", str(env_cfg))
84
+ cfg = load_config(str(arg_cfg))
85
+ assert cfg.port == 2222
86
+
87
+
88
+ def test_env_overrides_yaml(cfg_path, monkeypatch):
89
+ cfg_path.write_text("server:\n port: 9999\n notes_days: 7\n")
90
+ monkeypatch.setenv("SEREN_MARGIN_PORT", "5555")
91
+ cfg = load_config()
92
+ # env wins for port
93
+ assert cfg.port == 5555
94
+ # yaml wins for keys env didn't override
95
+ assert cfg.notes_days == 7
96
+
97
+
98
+ def test_malformed_yaml_falls_back_silently(cfg_path, capsys):
99
+ # Garbage that yaml.safe_load will reject
100
+ cfg_path.write_text("server:\n port: [this is, not, valid: yaml\n")
101
+ cfg = load_config()
102
+ # Should still get default port back
103
+ assert cfg.port == MarginConfig().port
104
+ # Should have logged something to stderr-ish (stdout in our impl)
105
+ captured = capsys.readouterr()
106
+ assert "failed to parse" in (captured.out + captured.err)
107
+
108
+
109
+ def test_bad_single_value_only_drops_that_key(cfg_path, capsys):
110
+ cfg_path.write_text(
111
+ "server:\n"
112
+ " port: not-a-number\n"
113
+ " notes_days: 14\n"
114
+ )
115
+ cfg = load_config()
116
+ # port falls back, notes_days applied
117
+ assert cfg.port == MarginConfig().port
118
+ assert cfg.notes_days == 14
119
+ captured = capsys.readouterr()
120
+ assert "ignored bad value for 'port'" in (captured.out + captured.err)
121
+
122
+
123
+ def test_unknown_server_key_ignored(cfg_path, capsys):
124
+ cfg_path.write_text(
125
+ "server:\n"
126
+ " port: 8888\n"
127
+ " enable_skynet: true\n"
128
+ )
129
+ cfg = load_config()
130
+ assert cfg.port == 8888
131
+ captured = capsys.readouterr()
132
+ assert "unknown server key 'enable_skynet'" in (captured.out + captured.err)
133
+
134
+
135
+ def test_tools_section_ignored(cfg_path):
136
+ """The 'tools:' block is a different reader's job; SerenMargin must not
137
+ crash on it and must not pull values from it.
138
+ """
139
+ cfg_path.write_text(
140
+ "server:\n"
141
+ " port: 7777\n"
142
+ "tools:\n"
143
+ " note_to_self:\n"
144
+ " max_content_chars: 2048\n"
145
+ )
146
+ cfg = load_config()
147
+ assert cfg.port == 7777
148
+ # Nothing from tools: section bleeds into server fields
149
+ assert cfg.notes_days == MarginConfig().notes_days
150
+
151
+
152
+ def test_non_mapping_top_level_falls_back(cfg_path, capsys):
153
+ cfg_path.write_text("- just\n- a\n- list\n")
154
+ cfg = load_config()
155
+ assert cfg.port == MarginConfig().port
156
+ captured = capsys.readouterr()
157
+ assert "must be a mapping" in (captured.out + captured.err)
158
+
159
+
160
+ def test_empty_yaml_uses_defaults(cfg_path):
161
+ cfg_path.write_text("")
162
+ cfg = load_config()
163
+ assert cfg.port == MarginConfig().port
164
+
165
+
166
+ def test_host_default_is_localhost():
167
+ """Margin is private notes - it must NOT inherit Memory's 0.0.0.0 default.
168
+ Follow-the-leader on structure, not on the security default."""
169
+ assert MarginConfig().host == "127.0.0.1"
@@ -0,0 +1,118 @@
1
+ """Smoke test for SerenMargin. Validates basic CRUD against tmpdir sqlite.
2
+
3
+ Critically asserts that /notes/stats is CONTENT-BLIND - the response body
4
+ must not contain any note text.
5
+
6
+ No lifecycle here: notes have no pin/expiry/done and live until deleted.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+ from fastapi.testclient import TestClient
12
+
13
+ from seren_margin.app import create_app
14
+ from seren_margin.config import MarginConfig
15
+
16
+
17
+ @pytest.fixture
18
+ def client(tmp_path):
19
+ cfg = MarginConfig(db_path=str(tmp_path / "test.db"))
20
+ app = create_app(cfg)
21
+ with TestClient(app) as c:
22
+ yield c
23
+
24
+
25
+ def test_health(client):
26
+ r = client.get("/health")
27
+ assert r.status_code == 200
28
+ assert r.json()["ok"] is True
29
+
30
+
31
+ def test_write_list_get_delete_cycle(client):
32
+ # Write a note
33
+ r = client.post("/notes", json={"content": "ask Chad about the supersede gap"})
34
+ assert r.status_code == 200
35
+ note_id = r.json()["id"]
36
+ assert note_id
37
+
38
+ # Shows up in the list
39
+ r = client.get("/notes")
40
+ entries = r.json()["entries"]
41
+ assert any(e["id"] == note_id for e in entries)
42
+
43
+ # Fetch it directly
44
+ r = client.get(f"/notes/{note_id}")
45
+ assert r.status_code == 200
46
+ assert r.json()["content"] == "ask Chad about the supersede gap"
47
+
48
+ # Delete it
49
+ r = client.delete(f"/notes/{note_id}")
50
+ assert r.status_code == 200
51
+
52
+ # Gone
53
+ r = client.get(f"/notes/{note_id}")
54
+ assert r.status_code == 404
55
+
56
+ # And gone from the list
57
+ r = client.get("/notes")
58
+ assert not any(e["id"] == note_id for e in r.json()["entries"])
59
+
60
+
61
+ def test_topic_and_kind_round_trip(client):
62
+ r = client.post("/notes", json={
63
+ "content": "watch for the hedge-as-refusal trap",
64
+ "topic": "rapport",
65
+ "kind": "observation",
66
+ })
67
+ note_id = r.json()["id"]
68
+ r = client.get(f"/notes/{note_id}")
69
+ body = r.json()
70
+ assert body["topic"] == "rapport"
71
+ assert body["kind"] == "observation"
72
+ assert body["ts"] is not None
73
+
74
+
75
+ def test_stats_is_content_blind(client):
76
+ """Critical: stats endpoint must not leak note contents."""
77
+ client.post("/notes", json={
78
+ "content": "private thought about hedging behavior",
79
+ "kind": "observation",
80
+ })
81
+ client.post("/notes", json={
82
+ "content": "remember to ask about supersede gap",
83
+ "kind": "reminder",
84
+ })
85
+
86
+ r = client.get("/notes/stats")
87
+ assert r.status_code == 200
88
+ stats = r.json()
89
+ assert stats["total"] == 2
90
+ assert stats["kinds"]["observation"] == 1
91
+ assert stats["kinds"]["reminder"] == 1
92
+
93
+ # The whole response body must not contain note text
94
+ body_text = r.text
95
+ assert "hedging" not in body_text
96
+ assert "supersede" not in body_text
97
+ assert "private thought" not in body_text
98
+
99
+
100
+ def test_empty_content_rejected(client):
101
+ r = client.post("/notes", json={"content": " "})
102
+ assert r.status_code == 400
103
+
104
+
105
+ def test_missing_note_returns_404(client):
106
+ r = client.get("/notes/does-not-exist")
107
+ assert r.status_code == 404
108
+ r = client.delete("/notes/does-not-exist")
109
+ assert r.status_code == 404
110
+
111
+
112
+ def test_root_advertises_ethos(client):
113
+ """The service's identity is in /. Validates ethos string didn't drift."""
114
+ r = client.get("/")
115
+ info = r.json()
116
+ assert info["name"] == "SerenMargin"
117
+ assert "opt-in by deploy" in info["ethos"]
118
+ assert info["stats_endpoint"] == "/notes/stats"