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.
- seren_margin-1.0.0/PKG-INFO +16 -0
- seren_margin-1.0.0/SerenMargin.pyproj +52 -0
- seren_margin-1.0.0/pyproject.toml +55 -0
- seren_margin-1.0.0/seren-margin.service.sample +55 -0
- seren_margin-1.0.0/seren-margin.yaml.sample +52 -0
- seren_margin-1.0.0/seren_margin/__init__.py +19 -0
- seren_margin-1.0.0/seren_margin/__main__.py +36 -0
- seren_margin-1.0.0/seren_margin/_version.py +24 -0
- seren_margin-1.0.0/seren_margin/app.py +144 -0
- seren_margin-1.0.0/seren_margin/config.py +187 -0
- seren_margin-1.0.0/seren_margin/mcp-manifest.yaml +94 -0
- seren_margin-1.0.0/seren_margin/models.py +78 -0
- seren_margin-1.0.0/seren_margin/store.py +111 -0
- seren_margin-1.0.0/seren_margin.egg-info/PKG-INFO +16 -0
- seren_margin-1.0.0/seren_margin.egg-info/SOURCES.txt +21 -0
- seren_margin-1.0.0/seren_margin.egg-info/dependency_links.txt +1 -0
- seren_margin-1.0.0/seren_margin.egg-info/entry_points.txt +2 -0
- seren_margin-1.0.0/seren_margin.egg-info/requires.txt +8 -0
- seren_margin-1.0.0/seren_margin.egg-info/top_level.txt +1 -0
- seren_margin-1.0.0/setup.cfg +4 -0
- seren_margin-1.0.0/tests/__init__.py +0 -0
- seren_margin-1.0.0/tests/test_config.py +169 -0
- seren_margin-1.0.0/tests/test_smoke.py +118 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
seren_margin
|
|
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"
|