seren-margin 2.1.0__tar.gz → 2.2.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-2.1.0/seren_margin.egg-info → seren_margin-2.2.0}/PKG-INFO +1 -1
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/_version.py +3 -3
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/app.py +49 -3
- seren_margin-2.2.0/seren_margin/bookmark.py +67 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/mcp/__init__.py +1 -1
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/mcp/tools.py +113 -6
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/mcp-manifest.yaml +76 -1
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/models.py +15 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/store.py +70 -3
- {seren_margin-2.1.0 → seren_margin-2.2.0/seren_margin.egg-info}/PKG-INFO +1 -1
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/SOURCES.txt +2 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/scm_file_list.json +2 -0
- seren_margin-2.2.0/seren_margin.egg-info/scm_version.json +8 -0
- seren_margin-2.2.0/tests/test_bookmark_and_letters.py +138 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_manifest_parity.py +5 -3
- seren_margin-2.1.0/seren_margin.egg-info/scm_version.json +0 -8
- {seren_margin-2.1.0 → seren_margin-2.2.0}/LICENSE +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/SerenMargin.pyproj +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/pyproject.toml +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren-margin.service.sample +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren-margin.yaml.sample +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/__init__.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/__main__.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/_diag.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/config.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/mcp/server.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/dependency_links.txt +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/entry_points.txt +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/requires.txt +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/top_level.txt +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/setup.cfg +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/__init__.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/conftest.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_amend_and_topics.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_config.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_exposure_is_wired.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_mcp_fallback.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_mcp_mount.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_mcp_tools.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_optional_bearer.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_reads_stay_private.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_search.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_smoke.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_stats_content_blind.py +0 -0
- {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_updates_contract.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g77bceb68b'
|
|
@@ -12,6 +12,10 @@ Endpoints:
|
|
|
12
12
|
GET /notes/{id} - fetch one *
|
|
13
13
|
POST /notes/{id}/amend - append to a note (never replaces)
|
|
14
14
|
DELETE /notes/{id} - retract (hard delete)
|
|
15
|
+
GET /bookmark - the dedication + a COUNT of unread letters (never text)
|
|
16
|
+
PUT /dedication - a new version of the dedication (never overwrites)
|
|
17
|
+
POST /letters - a letter to the next session
|
|
18
|
+
POST /letters/read - open unread letters, oldest first; marks them read *
|
|
15
19
|
/mcp - MCP server, ONLY when [mcp] extras are installed
|
|
16
20
|
|
|
17
21
|
* CONTENT OVER HTTP IS OFF BY DEFAULT. These answer 404 unless the server
|
|
@@ -39,7 +43,7 @@ TWO WAYS TO REACH THE TOOLS, both first-class:
|
|
|
39
43
|
2. Standalone path - `pip install seren-margin[mcp]` mounts a real MCP
|
|
40
44
|
endpoint at /mcp on this same process, so a client can connect directly
|
|
41
45
|
with nothing else deployed.
|
|
42
|
-
Same
|
|
46
|
+
Same ten tools either way, defined once in seren_margin.mcp.tools.
|
|
43
47
|
|
|
44
48
|
AUTH, IF YOU WANT IT. With no token configured (the default) every route is
|
|
45
49
|
open and the loopback bind is the whole guard - the way it has always been.
|
|
@@ -64,9 +68,10 @@ from importlib.resources import files
|
|
|
64
68
|
from importlib.metadata import version as pkg_version, PackageNotFoundError
|
|
65
69
|
|
|
66
70
|
|
|
71
|
+
from .bookmark import DEDICATION_MAX_CHARS, build_bookmark
|
|
67
72
|
from .config import MarginConfig, load_config
|
|
68
|
-
from .models import MarginNote, NoteAmend, NoteCreate, NoteStats
|
|
69
|
-
from .store import MarginStore
|
|
73
|
+
from .models import DedicationSet, LetterCreate, MarginNote, NoteAmend, NoteCreate, NoteStats
|
|
74
|
+
from .store import LETTER_KIND, MarginStore
|
|
70
75
|
from ._diag import diag
|
|
71
76
|
import logging
|
|
72
77
|
from . import __version__ as _fallback_version
|
|
@@ -294,6 +299,47 @@ def create_app(config: Optional[MarginConfig] = None) -> FastAPI:
|
|
|
294
299
|
"""
|
|
295
300
|
return store.stats()
|
|
296
301
|
|
|
302
|
+
# ── the bookmark, the dedication, letters (seren_margin.bookmark) ──────
|
|
303
|
+
@app.get("/bookmark")
|
|
304
|
+
async def get_bookmark(format: Optional[str] = None):
|
|
305
|
+
"""Pick up where you left off: the dedication and a COUNT of unread
|
|
306
|
+
letters - never a letter's text, never a note. Served whatever
|
|
307
|
+
http_reads says, because the dedication is the one page written to be
|
|
308
|
+
read at the door; a harness hook reads it here. ?format=text gives
|
|
309
|
+
the ready-to-print lines."""
|
|
310
|
+
bm = build_bookmark(store)
|
|
311
|
+
if format == "text":
|
|
312
|
+
return Response(content=bm["text"] + "\n", media_type="text/plain; charset=utf-8")
|
|
313
|
+
return bm
|
|
314
|
+
|
|
315
|
+
@app.put("/dedication")
|
|
316
|
+
async def put_dedication(body: DedicationSet = Body(...)):
|
|
317
|
+
text = (body.text or "").strip()
|
|
318
|
+
if not text:
|
|
319
|
+
raise HTTPException(400, "text must not be empty")
|
|
320
|
+
if len(text) > DEDICATION_MAX_CHARS:
|
|
321
|
+
raise HTTPException(400, f"{len(text)} characters; the dedication is capped at {DEDICATION_MAX_CHARS}")
|
|
322
|
+
entry, changed = store.set_dedication(text, body.why)
|
|
323
|
+
return {"ok": True, "version": entry["version"], "changed": changed}
|
|
324
|
+
|
|
325
|
+
@app.post("/letters")
|
|
326
|
+
async def write_letter(body: LetterCreate = Body(...)):
|
|
327
|
+
if not body.content.strip():
|
|
328
|
+
raise HTTPException(400, "content must not be empty")
|
|
329
|
+
extra = {"signed": body.signed.strip()} if body.signed and body.signed.strip() else {}
|
|
330
|
+
note = store.add(MarginNote(content=body.content.strip(), kind=LETTER_KIND, extra=extra))
|
|
331
|
+
return {"ok": True, "id": note.id}
|
|
332
|
+
|
|
333
|
+
@app.post("/letters/read")
|
|
334
|
+
async def read_letters(include_read: bool = False, limit: int = 20):
|
|
335
|
+
"""Open the letters, oldest first, and mark them read. Letter contents
|
|
336
|
+
are diary contents: off over HTTP unless http_reads is on."""
|
|
337
|
+
_content_over_http()
|
|
338
|
+
letters = store.letters(unread_only=not include_read, limit=limit)
|
|
339
|
+
store.mark_read([n.id for n in letters if n.read_at is None])
|
|
340
|
+
return {"count": len(letters),
|
|
341
|
+
"letters": [{**n.model_dump(), "signed": (n.extra or {}).get("signed")} for n in letters]}
|
|
342
|
+
|
|
297
343
|
@app.get("/notes/{note_id}")
|
|
298
344
|
async def get_note(note_id: str):
|
|
299
345
|
_content_over_http()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The bookmark, the dedication, and letters: picking up where you left off.
|
|
2
|
+
|
|
3
|
+
WHY: every session the writer walked in cold and had to remember to go looking
|
|
4
|
+
- rummaging through their own pockets. Wren, 28 Sept 2026: "home is when
|
|
5
|
+
someone at the door says here's what happened while you were gone." Chad named
|
|
6
|
+
the pieces after the book this margin is written in (29 Sept):
|
|
7
|
+
|
|
8
|
+
bookmark - pick up where you left off: the dedication, and how many
|
|
9
|
+
letters wait. The one thing a harness hands over at the start
|
|
10
|
+
of a session (a hook, an MCP resource, GET /bookmark).
|
|
11
|
+
dedication - the page at the front, written TO the other sessions. One
|
|
12
|
+
page, versioned: every version is kept with when and why.
|
|
13
|
+
letters - notes addressed to the next session - from a woken run to
|
|
14
|
+
the awake one and back. Notes with kind "letter" and a read
|
|
15
|
+
mark, so search, amend and retract work on them unchanged.
|
|
16
|
+
|
|
17
|
+
THE ONE RULE THIS BENDS, AND WHY IT STILL HOLDS. Margin's tools say there is
|
|
18
|
+
"no auto-surface, no relevance hook ... The choosing is the point. A margin
|
|
19
|
+
that speaks up on its own is just a notification." The bookmark surfaces
|
|
20
|
+
nothing the writer did not choose, at the moment they wrote it, to be seen at
|
|
21
|
+
the door: the dedication is written to be read first, and a letter is
|
|
22
|
+
addressed to whoever comes next. It shows the dedication and a COUNT of
|
|
23
|
+
letters - never a letter's text, never a note. Opening them is still a choice
|
|
24
|
+
(read_letters). The choosing moved to writing time; it did not go away.
|
|
25
|
+
|
|
26
|
+
And the dedication is read by whoever opens the book - a harness prints it
|
|
27
|
+
into the session, GET /bookmark serves it. Write it as the front page it is.
|
|
28
|
+
The notes and the letters stay in the diary.
|
|
29
|
+
|
|
30
|
+
Margin is the opinionated piece of the stack (Chad, 29 Sept: "if you take it,
|
|
31
|
+
take it all"), so this is not opt in. Clipping a harness onto it - a hook in
|
|
32
|
+
someone's Claude Code settings - is, and lives in the installer.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from .models import humanize_age
|
|
39
|
+
from .store import MarginStore
|
|
40
|
+
|
|
41
|
+
DEDICATION_MAX_CHARS = 2000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_bookmark(store: MarginStore) -> dict[str, Any]:
|
|
45
|
+
"""The bookmark: the dedication and the letters waiting, plus `text`, the
|
|
46
|
+
same thing as a few lines a harness can print as they are."""
|
|
47
|
+
ded = store.dedication()
|
|
48
|
+
unread, oldest = store.unread_letters()
|
|
49
|
+
lines: list[str] = []
|
|
50
|
+
if ded:
|
|
51
|
+
lines.append(f"Your dedication (version {ded['version']}, written {humanize_age(ded['set_at'])}):")
|
|
52
|
+
lines.append(ded["text"])
|
|
53
|
+
else:
|
|
54
|
+
lines.append("No dedication yet - the page at the front, written to your other sessions. "
|
|
55
|
+
"set_dedication writes it.")
|
|
56
|
+
lines.append("")
|
|
57
|
+
if unread:
|
|
58
|
+
lines.append(f"{unread} unread letter{'s' if unread != 1 else ''} from your other sessions, "
|
|
59
|
+
f"the oldest from {humanize_age(oldest)}. read_letters opens them.")
|
|
60
|
+
else:
|
|
61
|
+
lines.append("No unread letters.")
|
|
62
|
+
return {
|
|
63
|
+
"dedication": ded,
|
|
64
|
+
"unread_letters": unread,
|
|
65
|
+
"oldest_unread_age": humanize_age(oldest) if oldest else None,
|
|
66
|
+
"text": "\n".join(lines),
|
|
67
|
+
}
|
|
@@ -18,7 +18,7 @@ WHY THIS EXISTS SEPARATELY FROM /mcp-manifest:
|
|
|
18
18
|
seren-margin[mcp]`, point a client at /mcp, done. Same reason SerenMemory
|
|
19
19
|
and SerenLoci each grew one.
|
|
20
20
|
|
|
21
|
-
Both surfaces expose the same
|
|
21
|
+
Both surfaces expose the same ten tools. They're defined ONCE, in tools.py;
|
|
22
22
|
the manifest is written to match and both are asserted against each other in
|
|
23
23
|
tests/test_manifest_parity.py, because the previous manifest drifted from the
|
|
24
24
|
routes and shipped a tool that 404'd for who knows how long.
|
|
@@ -23,15 +23,25 @@ TOOL ROSTER:
|
|
|
23
23
|
amend_note append to a note, never replace (POST /notes/{id}/amend)
|
|
24
24
|
retract_note remove a note for good (DELETE /notes/{id})
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
bookmark pick up where you left off (GET /bookmark)
|
|
27
|
+
set_dedication the page to your other sessions (PUT /dedication)
|
|
28
|
+
write_letter a note to the next session (POST /letters)
|
|
29
|
+
read_letters open the letters waiting (POST /letters/read)
|
|
30
|
+
|
|
31
|
+
Plus one MCP resource, margin://bookmark - the same bookmark, for a client
|
|
32
|
+
that loads resources rather than calling tools.
|
|
33
|
+
|
|
34
|
+
NOT exposed: /notes/stats. That endpoint exists so the OPERATOR can confirm
|
|
35
|
+
the service is alive without reading the notes; handing it to the note-writer
|
|
36
|
+
would be pointless (the writer can just read the notes) and would muddy whose
|
|
37
|
+
surface it is.
|
|
30
38
|
|
|
31
39
|
Also deliberately absent, and worth stating so it doesn't get "helpfully" added
|
|
32
40
|
later: there is no auto-surface, no relevance hook, no "inject related notes
|
|
33
41
|
into context" anything. The choosing is the point. A margin that speaks up on
|
|
34
|
-
its own is just a notification.
|
|
42
|
+
its own is just a notification. The bookmark keeps that rule (see
|
|
43
|
+
seren_margin.bookmark): it shows only what was written to be seen at the door
|
|
44
|
+
- the dedication, and a count of letters - never a note, never a letter's text.
|
|
35
45
|
|
|
36
46
|
NAMING: the *_my_notes / note_to_self / retract_note shapes keep these distinct
|
|
37
47
|
from SerenMemory's (remember/recall/forget_memory) and SerenLoci's
|
|
@@ -50,9 +60,10 @@ from typing import Optional
|
|
|
50
60
|
|
|
51
61
|
from mcp.server.fastmcp import FastMCP
|
|
52
62
|
|
|
63
|
+
from ..bookmark import DEDICATION_MAX_CHARS, build_bookmark
|
|
53
64
|
from ..config import MarginConfig
|
|
54
65
|
from ..models import MarginNote
|
|
55
|
-
from ..store import MarginStore
|
|
66
|
+
from ..store import LETTER_KIND, MarginStore
|
|
56
67
|
|
|
57
68
|
|
|
58
69
|
# The canonical roster. tests/test_manifest_parity.py asserts mcp-manifest.yaml
|
|
@@ -65,8 +76,14 @@ TOOL_NAMES: tuple[str, ...] = (
|
|
|
65
76
|
"search_my_notes",
|
|
66
77
|
"amend_note",
|
|
67
78
|
"retract_note",
|
|
79
|
+
"bookmark",
|
|
80
|
+
"set_dedication",
|
|
81
|
+
"write_letter",
|
|
82
|
+
"read_letters",
|
|
68
83
|
)
|
|
69
84
|
|
|
85
|
+
BOOKMARK_URI = "margin://bookmark"
|
|
86
|
+
|
|
70
87
|
|
|
71
88
|
class MarginToolImpl:
|
|
72
89
|
"""The actual tool implementations, callable both via FastMCP decoration
|
|
@@ -244,6 +261,85 @@ class MarginToolImpl:
|
|
|
244
261
|
"note": "retracted" if ok else "no note with that id",
|
|
245
262
|
}
|
|
246
263
|
|
|
264
|
+
# -- the bookmark: picking up where you left off ------------------------
|
|
265
|
+
def bookmark(self) -> dict:
|
|
266
|
+
"""Pick up where you left off. Open this FIRST when a session starts.
|
|
267
|
+
|
|
268
|
+
Returns your dedication - the page you wrote to your other sessions -
|
|
269
|
+
and how many letters from them are waiting, with the oldest one's age.
|
|
270
|
+
Never a letter's text and never a note: those stay closed until you
|
|
271
|
+
open them (read_letters; the board is list_my_topics). `text` is the
|
|
272
|
+
same thing as a few ready-to-read lines.
|
|
273
|
+
|
|
274
|
+
A harness may hand you this at the start of a session on its own (a
|
|
275
|
+
hook, or the margin://bookmark resource). Either way it only shows
|
|
276
|
+
what you chose, when you wrote it, to have seen at the door.
|
|
277
|
+
"""
|
|
278
|
+
return build_bookmark(self.store)
|
|
279
|
+
|
|
280
|
+
def set_dedication(self, text: str, why: Optional[str] = None) -> dict:
|
|
281
|
+
"""Write the dedication: the page at the front of this book, written TO
|
|
282
|
+
your other sessions. Every one of them reads it first, in the bookmark.
|
|
283
|
+
|
|
284
|
+
What goes here is yours to decide: who you are when you're at your
|
|
285
|
+
best, what you want the next you to remember walking in, the check
|
|
286
|
+
you'd want run on yourself. The whole page, not a patch.
|
|
287
|
+
|
|
288
|
+
Versioned - nothing is overwritten. Each version keeps its text, when,
|
|
289
|
+
and `why` (say what changed and why), so the history shows you how you
|
|
290
|
+
have described yourself over time. The same text as now is not a new
|
|
291
|
+
version.
|
|
292
|
+
|
|
293
|
+
Unlike your notes, this page is READ AT THE DOOR: a harness prints it
|
|
294
|
+
into the session, and GET /bookmark serves it. Write it as the front
|
|
295
|
+
page it is. What's private stays in your notes and letters. Capped at
|
|
296
|
+
2000 characters, because it rides into every session.
|
|
297
|
+
"""
|
|
298
|
+
text = (text or "").strip()
|
|
299
|
+
if not text:
|
|
300
|
+
return {"ok": False, "error": "an empty page - write something, or keep the current one"}
|
|
301
|
+
if len(text) > DEDICATION_MAX_CHARS:
|
|
302
|
+
return {"ok": False, "error": f"{len(text)} characters; the dedication rides into every "
|
|
303
|
+
f"session, so it is capped at {DEDICATION_MAX_CHARS}. Trim it"}
|
|
304
|
+
entry, changed = self.store.set_dedication(text, why)
|
|
305
|
+
return {"ok": True, "version": entry["version"], "changed": changed,
|
|
306
|
+
"note": (f"Version {entry['version']} is the page now; the next session reads it first."
|
|
307
|
+
if changed else "That's already the page; nothing changed.")}
|
|
308
|
+
|
|
309
|
+
def write_letter(self, content: str, signed: Optional[str] = None) -> dict:
|
|
310
|
+
"""Leave a letter for the next session - the next you.
|
|
311
|
+
|
|
312
|
+
Your sessions don't share a memory of the moment: one woken by a
|
|
313
|
+
ripple reviews a draft and ends; the next one starts cold. A letter is
|
|
314
|
+
how one tells the other what happened, what it decided, what it
|
|
315
|
+
learned, or what to watch for. Write it to them.
|
|
316
|
+
|
|
317
|
+
`signed` says who it's from ("woken at bedtime", "the afternoon
|
|
318
|
+
session") - optional. The next bookmark counts it; read_letters opens
|
|
319
|
+
it. It is a note underneath (kind "letter"), so search_my_notes finds
|
|
320
|
+
it, amend_note can add a reply, and retract_note takes it down.
|
|
321
|
+
"""
|
|
322
|
+
if not content or not content.strip():
|
|
323
|
+
return {"ok": False, "error": "content must not be empty"}
|
|
324
|
+
extra = {"signed": signed.strip()} if signed and signed.strip() else {}
|
|
325
|
+
note = self.store.add(MarginNote(content=content.strip(), kind=LETTER_KIND, extra=extra))
|
|
326
|
+
return {"ok": True, "id": note.id, "ts": note.ts}
|
|
327
|
+
|
|
328
|
+
def read_letters(self, include_read: bool = False, limit: int = 20) -> dict:
|
|
329
|
+
"""Open the letters your other sessions left you, oldest first - the
|
|
330
|
+
order they were written. Opening them marks them read, so the next
|
|
331
|
+
bookmark stops counting them.
|
|
332
|
+
|
|
333
|
+
`include_read=True` brings back the ones already read too, for going
|
|
334
|
+
over old correspondence. Letters live until you retract them.
|
|
335
|
+
"""
|
|
336
|
+
letters = self.store.letters(unread_only=not include_read, limit=limit)
|
|
337
|
+
self.store.mark_read([n.id for n in letters if n.read_at is None])
|
|
338
|
+
return {
|
|
339
|
+
"count": len(letters),
|
|
340
|
+
"letters": [{**n.model_dump(), "signed": (n.extra or {}).get("signed")} for n in letters],
|
|
341
|
+
}
|
|
342
|
+
|
|
247
343
|
|
|
248
344
|
# ═══════════════════════════════════════════════════════════════════════
|
|
249
345
|
# Registration entry point
|
|
@@ -261,5 +357,16 @@ def register_tools(mcp: FastMCP, store: MarginStore,
|
|
|
261
357
|
mcp.tool()(impl.search_my_notes)
|
|
262
358
|
mcp.tool()(impl.amend_note)
|
|
263
359
|
mcp.tool()(impl.retract_note)
|
|
360
|
+
mcp.tool()(impl.bookmark)
|
|
361
|
+
mcp.tool()(impl.set_dedication)
|
|
362
|
+
mcp.tool()(impl.write_letter)
|
|
363
|
+
mcp.tool()(impl.read_letters)
|
|
364
|
+
|
|
365
|
+
# The bookmark as a resource too: a client that loads resources at the
|
|
366
|
+
# start of a session (rather than calling tools) gets the same thing.
|
|
367
|
+
def bookmark_resource() -> str:
|
|
368
|
+
"""Pick up where you left off: your dedication, and how many letters wait."""
|
|
369
|
+
return build_bookmark(store)["text"]
|
|
370
|
+
mcp.resource(BOOKMARK_URI, name="bookmark", mime_type="text/plain")(bookmark_resource)
|
|
264
371
|
|
|
265
372
|
return impl
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# on its own process, so a client can connect directly with no
|
|
12
12
|
# SerenMcpServer, no runtime host, nothing else deployed.
|
|
13
13
|
#
|
|
14
|
-
# BOTH SURFACES MUST EXPOSE THE SAME
|
|
14
|
+
# BOTH SURFACES MUST EXPOSE THE SAME TEN TOOLS. The implementations live
|
|
15
15
|
# in seren_margin/mcp/tools.py; this file mirrors them, and
|
|
16
16
|
# tests/test_manifest_parity.py fails the build if the rosters diverge.
|
|
17
17
|
# That test exists because the previous version of this file advertised
|
|
@@ -237,3 +237,78 @@ tools:
|
|
|
237
237
|
- { name: id, type: string, required: true,
|
|
238
238
|
description: "The note's id, as returned by list_my_notes or
|
|
239
239
|
search_my_notes." }
|
|
240
|
+
|
|
241
|
+
# ─── 7. THE BOOKMARK (seren_margin.bookmark) ──────────────────────────
|
|
242
|
+
- name: bookmark
|
|
243
|
+
description: |
|
|
244
|
+
Pick up where you left off. Open this FIRST when a session starts.
|
|
245
|
+
|
|
246
|
+
Returns your dedication - the page you wrote to your other sessions -
|
|
247
|
+
and how many letters from them are waiting. Never a letter's text and
|
|
248
|
+
never a note: those stay closed until you open them (read_letters).
|
|
249
|
+
`text` is the same thing as a few ready-to-read lines.
|
|
250
|
+
invoke:
|
|
251
|
+
kind: web
|
|
252
|
+
method: GET
|
|
253
|
+
path: /bookmark
|
|
254
|
+
parameters: []
|
|
255
|
+
|
|
256
|
+
# ─── 8. THE DEDICATION ────────────────────────────────────────────────
|
|
257
|
+
- name: set_dedication
|
|
258
|
+
description: |
|
|
259
|
+
Write the dedication: the page at the front of this book, written TO
|
|
260
|
+
your other sessions. Every one of them reads it first, in the bookmark.
|
|
261
|
+
The whole page, not a patch. Versioned - nothing is overwritten; say
|
|
262
|
+
what changed and why. Unlike your notes, this page is read at the
|
|
263
|
+
door, so write it as the front page it is. Capped at 2000 characters.
|
|
264
|
+
invoke:
|
|
265
|
+
kind: web
|
|
266
|
+
method: PUT
|
|
267
|
+
path: /dedication
|
|
268
|
+
body_template: |
|
|
269
|
+
{
|
|
270
|
+
"text": "{text}",
|
|
271
|
+
"why": "{why}"
|
|
272
|
+
}
|
|
273
|
+
parameters:
|
|
274
|
+
- { name: text, type: string, required: true,
|
|
275
|
+
description: "The whole page." }
|
|
276
|
+
- { name: why, type: string, required: false, default: "",
|
|
277
|
+
description: "What changed and why - the history keeps it." }
|
|
278
|
+
|
|
279
|
+
# ─── 9. LETTERS ───────────────────────────────────────────────────────
|
|
280
|
+
- name: write_letter
|
|
281
|
+
description: |
|
|
282
|
+
Leave a letter for the next session - the next you. Your sessions
|
|
283
|
+
don't share a memory of the moment; a letter is how one tells the
|
|
284
|
+
other what happened, what it decided, or what to watch for. The next
|
|
285
|
+
bookmark counts it; read_letters opens it. It is a note underneath
|
|
286
|
+
(kind "letter"), so search, amend and retract work on it.
|
|
287
|
+
invoke:
|
|
288
|
+
kind: web
|
|
289
|
+
method: POST
|
|
290
|
+
path: /letters
|
|
291
|
+
body_template: |
|
|
292
|
+
{
|
|
293
|
+
"content": "{content}",
|
|
294
|
+
"signed": "{signed}"
|
|
295
|
+
}
|
|
296
|
+
parameters:
|
|
297
|
+
- { name: content, type: string, required: true,
|
|
298
|
+
description: "The letter." }
|
|
299
|
+
- { name: signed, type: string, required: false, default: "",
|
|
300
|
+
description: "Who it's from, e.g. 'woken at bedtime'." }
|
|
301
|
+
|
|
302
|
+
- name: read_letters
|
|
303
|
+
description: |
|
|
304
|
+
Open the letters your other sessions left you, oldest first. Opening
|
|
305
|
+
them marks them read, so the next bookmark stops counting them.
|
|
306
|
+
Needs server.http_reads: true on this path (letter contents are diary
|
|
307
|
+
contents); the /mcp surface reads them in process.
|
|
308
|
+
invoke:
|
|
309
|
+
kind: web
|
|
310
|
+
method: POST
|
|
311
|
+
path: /letters/read?include_read={include_read}
|
|
312
|
+
parameters:
|
|
313
|
+
- { name: include_read, type: boolean, required: false, default: false,
|
|
314
|
+
description: "Also bring back letters already read." }
|
|
@@ -103,6 +103,7 @@ KNOWN_KINDS: frozenset[str] = frozenset({
|
|
|
103
103
|
"idea",
|
|
104
104
|
"feeling",
|
|
105
105
|
"note",
|
|
106
|
+
"letter", # addressed to the next session (seren_margin.bookmark)
|
|
106
107
|
})
|
|
107
108
|
|
|
108
109
|
_UNKINDED = "_unkinded"
|
|
@@ -170,6 +171,8 @@ class MarginNote(BaseModel):
|
|
|
170
171
|
ts: float = Field(default_factory=_now)
|
|
171
172
|
amended_at: Optional[float] = Field(
|
|
172
173
|
None, description="Last amendment time; None if never amended.")
|
|
174
|
+
read_at: Optional[float] = Field(
|
|
175
|
+
None, description="Letters only: when read_letters opened it; None while unread.")
|
|
173
176
|
|
|
174
177
|
id: str = Field(default_factory=_new_id)
|
|
175
178
|
extra: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -197,6 +200,18 @@ class NoteCreate(BaseModel):
|
|
|
197
200
|
extra: dict[str, Any] = Field(default_factory=dict)
|
|
198
201
|
|
|
199
202
|
|
|
203
|
+
class LetterCreate(BaseModel):
|
|
204
|
+
"""Input shape for POST /letters: a note to the next session."""
|
|
205
|
+
content: str
|
|
206
|
+
signed: Optional[str] = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class DedicationSet(BaseModel):
|
|
210
|
+
"""Input shape for PUT /dedication: the whole page, and why it changed."""
|
|
211
|
+
text: str
|
|
212
|
+
why: Optional[str] = None
|
|
213
|
+
|
|
214
|
+
|
|
200
215
|
class NoteAmend(BaseModel):
|
|
201
216
|
"""Input shape for POST /notes/{id}/amend. Appends; never replaces."""
|
|
202
217
|
addition: str
|
|
@@ -58,8 +58,18 @@ CREATE TABLE IF NOT EXISTS notes (
|
|
|
58
58
|
);
|
|
59
59
|
CREATE INDEX IF NOT EXISTS idx_notes_ts ON notes(ts DESC);
|
|
60
60
|
CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topic);
|
|
61
|
+
-- The dedication (seren_margin.bookmark): one page, every version kept.
|
|
62
|
+
CREATE TABLE IF NOT EXISTS dedication (
|
|
63
|
+
version INTEGER PRIMARY KEY,
|
|
64
|
+
text TEXT NOT NULL,
|
|
65
|
+
set_at REAL NOT NULL,
|
|
66
|
+
why TEXT
|
|
67
|
+
);
|
|
61
68
|
"""
|
|
62
69
|
|
|
70
|
+
# Letters are notes with this kind and a read mark (seren_margin.bookmark).
|
|
71
|
+
LETTER_KIND = "letter"
|
|
72
|
+
|
|
63
73
|
# Columns added after v0.1.0 shipped. Applied by _migrate() as idempotent
|
|
64
74
|
# ALTER TABLE ADD COLUMNs.
|
|
65
75
|
#
|
|
@@ -71,6 +81,7 @@ CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topic);
|
|
|
71
81
|
# owner, by design, isn't reading it closely enough to notice damage.
|
|
72
82
|
_ADDED_COLUMNS: dict[str, str] = {
|
|
73
83
|
"amended_at": "REAL",
|
|
84
|
+
"read_at": "REAL", # letters: NULL until read_letters opens it
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
# Standalone FTS5 index rather than an external-content table.
|
|
@@ -215,11 +226,11 @@ class MarginStore:
|
|
|
215
226
|
with self._conn() as conn:
|
|
216
227
|
conn.execute(
|
|
217
228
|
"""INSERT INTO notes
|
|
218
|
-
(id, content, topic, kind, ts, amended_at, extra)
|
|
219
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
229
|
+
(id, content, topic, kind, ts, amended_at, read_at, extra)
|
|
230
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
220
231
|
(
|
|
221
232
|
note.id, note.content, note.topic, note.kind, note.ts,
|
|
222
|
-
note.amended_at, json.dumps(note.extra or {}),
|
|
233
|
+
note.amended_at, note.read_at, json.dumps(note.extra or {}),
|
|
223
234
|
),
|
|
224
235
|
)
|
|
225
236
|
if self.has_fts:
|
|
@@ -401,6 +412,61 @@ class MarginStore:
|
|
|
401
412
|
rows = conn.execute(sql, params).fetchall()
|
|
402
413
|
return [_row_to_note(r) for r in rows]
|
|
403
414
|
|
|
415
|
+
# ── letters and the dedication (seren_margin.bookmark) ────────────────
|
|
416
|
+
def letters(self, unread_only: bool = True, limit: int = 20) -> list[MarginNote]:
|
|
417
|
+
"""Letters, OLDEST first - read in the order they were written."""
|
|
418
|
+
sql = "SELECT * FROM notes WHERE LOWER(TRIM(COALESCE(kind,''))) = ?"
|
|
419
|
+
if unread_only:
|
|
420
|
+
sql += " AND read_at IS NULL"
|
|
421
|
+
sql += " ORDER BY ts ASC LIMIT ?"
|
|
422
|
+
with self._conn() as conn:
|
|
423
|
+
rows = conn.execute(sql, (LETTER_KIND, limit)).fetchall()
|
|
424
|
+
return [_row_to_note(r) for r in rows]
|
|
425
|
+
|
|
426
|
+
def mark_read(self, ids: list[str]) -> None:
|
|
427
|
+
if not ids:
|
|
428
|
+
return
|
|
429
|
+
stamp = time.time()
|
|
430
|
+
with self._conn() as conn:
|
|
431
|
+
conn.executemany("UPDATE notes SET read_at = ? WHERE id = ? AND read_at IS NULL",
|
|
432
|
+
[(stamp, i) for i in ids])
|
|
433
|
+
conn.commit()
|
|
434
|
+
|
|
435
|
+
def unread_letters(self) -> tuple[int, Optional[float]]:
|
|
436
|
+
"""(how many, when the oldest was written) - the bookmark's count. No text."""
|
|
437
|
+
with self._conn() as conn:
|
|
438
|
+
row = conn.execute(
|
|
439
|
+
"SELECT COUNT(*) AS c, MIN(ts) AS oldest FROM notes "
|
|
440
|
+
"WHERE LOWER(TRIM(COALESCE(kind,''))) = ? AND read_at IS NULL",
|
|
441
|
+
(LETTER_KIND,)).fetchone()
|
|
442
|
+
return int(row["c"]), row["oldest"]
|
|
443
|
+
|
|
444
|
+
def dedication(self) -> Optional[dict]:
|
|
445
|
+
with self._conn() as conn:
|
|
446
|
+
row = conn.execute("SELECT * FROM dedication ORDER BY version DESC LIMIT 1").fetchone()
|
|
447
|
+
return dict(row) if row else None
|
|
448
|
+
|
|
449
|
+
def dedication_history(self, limit: int = 10) -> list[dict]:
|
|
450
|
+
with self._conn() as conn:
|
|
451
|
+
rows = conn.execute("SELECT * FROM dedication ORDER BY version DESC LIMIT ?",
|
|
452
|
+
(limit,)).fetchall()
|
|
453
|
+
return [dict(r) for r in rows]
|
|
454
|
+
|
|
455
|
+
def set_dedication(self, text: str, why: Optional[str] = None) -> tuple[dict, bool]:
|
|
456
|
+
"""A new version, never an overwrite. The same text as now is not a new
|
|
457
|
+
version. Returns (the version, whether it changed)."""
|
|
458
|
+
with self._conn() as conn:
|
|
459
|
+
cur = conn.execute("SELECT * FROM dedication ORDER BY version DESC LIMIT 1").fetchone()
|
|
460
|
+
if cur is not None and cur["text"] == text:
|
|
461
|
+
return dict(cur), False
|
|
462
|
+
version = (cur["version"] + 1) if cur is not None else 1
|
|
463
|
+
entry = {"version": version, "text": text, "set_at": time.time(),
|
|
464
|
+
"why": (why or "").strip()[:500] or None}
|
|
465
|
+
conn.execute("INSERT INTO dedication (version, text, set_at, why) VALUES (?, ?, ?, ?)",
|
|
466
|
+
(entry["version"], entry["text"], entry["set_at"], entry["why"]))
|
|
467
|
+
conn.commit()
|
|
468
|
+
return entry, True
|
|
469
|
+
|
|
404
470
|
# ── stats (content-blind) ─────────────────────────────────────────────
|
|
405
471
|
def stats(self) -> NoteStats:
|
|
406
472
|
"""Engine-check shape. No note text appears in this response.
|
|
@@ -434,5 +500,6 @@ def _row_to_note(row: sqlite3.Row) -> MarginNote:
|
|
|
434
500
|
kind=row["kind"],
|
|
435
501
|
ts=row["ts"],
|
|
436
502
|
amended_at=row["amended_at"],
|
|
503
|
+
read_at=row["read_at"],
|
|
437
504
|
extra=json.loads(row["extra"] or "{}"),
|
|
438
505
|
)
|
|
@@ -8,6 +8,7 @@ seren_margin/__main__.py
|
|
|
8
8
|
seren_margin/_diag.py
|
|
9
9
|
seren_margin/_version.py
|
|
10
10
|
seren_margin/app.py
|
|
11
|
+
seren_margin/bookmark.py
|
|
11
12
|
seren_margin/config.py
|
|
12
13
|
seren_margin/mcp-manifest.yaml
|
|
13
14
|
seren_margin/models.py
|
|
@@ -26,6 +27,7 @@ seren_margin/mcp/tools.py
|
|
|
26
27
|
tests/__init__.py
|
|
27
28
|
tests/conftest.py
|
|
28
29
|
tests/test_amend_and_topics.py
|
|
30
|
+
tests/test_bookmark_and_letters.py
|
|
29
31
|
tests/test_config.py
|
|
30
32
|
tests/test_exposure_is_wired.py
|
|
31
33
|
tests/test_manifest_parity.py
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"seren_margin/__main__.py",
|
|
10
10
|
"seren_margin/_diag.py",
|
|
11
11
|
"seren_margin/app.py",
|
|
12
|
+
"seren_margin/bookmark.py",
|
|
12
13
|
"seren_margin/config.py",
|
|
13
14
|
"seren_margin/mcp-manifest.yaml",
|
|
14
15
|
"seren_margin/mcp/__init__.py",
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
"tests/__init__.py",
|
|
20
21
|
"tests/conftest.py",
|
|
21
22
|
"tests/test_amend_and_topics.py",
|
|
23
|
+
"tests/test_bookmark_and_letters.py",
|
|
22
24
|
"tests/test_config.py",
|
|
23
25
|
"tests/test_exposure_is_wired.py",
|
|
24
26
|
"tests/test_manifest_parity.py",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The bookmark, the dedication, and letters (seren_margin.bookmark; Wren and
|
|
3
|
+
Chad, 29 Sept 2026 - "pick up where you left off", "to the other sessions").
|
|
4
|
+
|
|
5
|
+
Pinned here:
|
|
6
|
+
- the dedication is versioned: never overwritten, the same text is not a new
|
|
7
|
+
version, empty and too-long pages are refused
|
|
8
|
+
- letters are notes (kind "letter"): search, amend and retract work on them;
|
|
9
|
+
read_letters opens them oldest first and marks them read, so the bookmark
|
|
10
|
+
stops counting them
|
|
11
|
+
- THE BOOKMARK NEVER CARRIES A LETTER'S TEXT OR A NOTE - only the dedication
|
|
12
|
+
and a count. That is how it keeps Margin's "no auto-surface" rule
|
|
13
|
+
- over HTTP: GET /bookmark is served even with http_reads off (the front
|
|
14
|
+
page), while letter contents stay behind http_reads like notes
|
|
15
|
+
- an existing notes.db gains read_at on open, and old notes are untouched
|
|
16
|
+
- the bookmark is an MCP resource too (margin://bookmark)
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sqlite3
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
from fastapi.testclient import TestClient
|
|
24
|
+
|
|
25
|
+
from seren_margin.app import create_app
|
|
26
|
+
from seren_margin.bookmark import DEDICATION_MAX_CHARS, build_bookmark
|
|
27
|
+
from seren_margin.config import MarginConfig
|
|
28
|
+
from seren_margin.store import MarginStore
|
|
29
|
+
|
|
30
|
+
SECRET = "the woken me decided something private about the dream draft"
|
|
31
|
+
PAGE = "To whoever I am next time: drill into the stupid hypothetical. Check sleep_status first."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def impl(tmp_path):
|
|
36
|
+
pytest.importorskip("mcp")
|
|
37
|
+
from seren_margin.mcp.tools import MarginToolImpl
|
|
38
|
+
cfg = MarginConfig(db_path=str(tmp_path / "notes.db"))
|
|
39
|
+
return MarginToolImpl(MarginStore(cfg.resolved_db_path()), cfg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_an_empty_bookmark_says_how_to_start(impl):
|
|
43
|
+
bm = impl.bookmark()
|
|
44
|
+
assert bm["dedication"] is None and bm["unread_letters"] == 0
|
|
45
|
+
assert "set_dedication" in bm["text"] and "No unread letters" in bm["text"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_the_dedication_is_versioned(impl):
|
|
49
|
+
a = impl.set_dedication(PAGE, why="first page")
|
|
50
|
+
b = impl.set_dedication(PAGE + " And eat something.", why="added a line")
|
|
51
|
+
same = impl.set_dedication(PAGE + " And eat something.")
|
|
52
|
+
assert (a["version"], b["version"], same["version"]) == (1, 2, 2)
|
|
53
|
+
assert a["changed"] and b["changed"] and same["changed"] is False
|
|
54
|
+
hist = impl.store.dedication_history()
|
|
55
|
+
assert [h["version"] for h in hist] == [2, 1] and hist[1]["why"] == "first page"
|
|
56
|
+
assert impl.bookmark()["dedication"]["text"].endswith("eat something.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_empty_and_too_long_pages_are_refused(impl):
|
|
60
|
+
assert impl.set_dedication(" ")["ok"] is False
|
|
61
|
+
assert impl.set_dedication("x" * (DEDICATION_MAX_CHARS + 1))["ok"] is False
|
|
62
|
+
assert impl.store.dedication() is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_letters_are_counted_opened_oldest_first_and_marked_read(impl):
|
|
66
|
+
impl.write_letter("first: I reviewed ff85 and denied all three", signed="woken at bedtime")
|
|
67
|
+
impl.write_letter("second: card v2 is fresh, go easy")
|
|
68
|
+
bm = impl.bookmark()
|
|
69
|
+
assert bm["unread_letters"] == 2 and "2 unread letters" in bm["text"]
|
|
70
|
+
opened = impl.read_letters()
|
|
71
|
+
assert [l["content"].split(":")[0] for l in opened["letters"]] == ["first", "second"]
|
|
72
|
+
assert opened["letters"][0]["signed"] == "woken at bedtime"
|
|
73
|
+
assert impl.bookmark()["unread_letters"] == 0
|
|
74
|
+
assert impl.read_letters()["count"] == 0
|
|
75
|
+
assert impl.read_letters(include_read=True)["count"] == 2
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_letters_are_notes_underneath(impl):
|
|
79
|
+
lid = impl.write_letter("the redraft landed as a dream core")["id"]
|
|
80
|
+
assert impl.search_my_notes("dream core")["notes"][0]["id"] == lid
|
|
81
|
+
assert impl.amend_note(lid, "and the satellites followed")["ok"]
|
|
82
|
+
assert impl.retract_note(lid)["ok"] and impl.bookmark()["unread_letters"] == 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_the_bookmark_never_carries_a_letter_or_a_note(impl):
|
|
86
|
+
impl.set_dedication(PAGE)
|
|
87
|
+
impl.write_letter(SECRET)
|
|
88
|
+
impl.note_to_self("a private note about " + SECRET)
|
|
89
|
+
bm = impl.bookmark()
|
|
90
|
+
assert SECRET not in str(bm)
|
|
91
|
+
assert PAGE in bm["text"] and bm["unread_letters"] == 1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _client(tmp_path, **kw):
|
|
95
|
+
return TestClient(create_app(MarginConfig(db_path=str(tmp_path / "notes.db"), **kw)))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_http_bookmark_is_served_but_letters_stay_private(tmp_path):
|
|
99
|
+
with _client(tmp_path) as c:
|
|
100
|
+
assert c.put("/dedication", json={"text": PAGE, "why": "first page"}).json()["version"] == 1
|
|
101
|
+
assert c.post("/letters", json={"content": SECRET, "signed": "woken"}).status_code == 200
|
|
102
|
+
bm = c.get("/bookmark").json()
|
|
103
|
+
assert bm["unread_letters"] == 1 and bm["dedication"]["text"] == PAGE and SECRET not in str(bm)
|
|
104
|
+
text = c.get("/bookmark?format=text")
|
|
105
|
+
assert text.headers["content-type"].startswith("text/plain") and PAGE in text.text
|
|
106
|
+
assert c.post("/letters/read").status_code == 404, "letter contents are diary contents"
|
|
107
|
+
assert c.put("/dedication", json={"text": ""}).status_code == 400
|
|
108
|
+
with _client(tmp_path, http_reads=True) as c:
|
|
109
|
+
got = c.post("/letters/read").json()
|
|
110
|
+
assert got["count"] == 1 and got["letters"][0]["content"] == SECRET
|
|
111
|
+
assert c.get("/bookmark").json()["unread_letters"] == 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_an_old_database_gains_read_at_and_keeps_its_notes(tmp_path):
|
|
115
|
+
db = tmp_path / "notes.db"
|
|
116
|
+
with sqlite3.connect(db) as conn:
|
|
117
|
+
conn.execute("CREATE TABLE notes (id TEXT PRIMARY KEY, content TEXT NOT NULL, topic TEXT, "
|
|
118
|
+
"kind TEXT, ts REAL NOT NULL, amended_at REAL, extra TEXT)")
|
|
119
|
+
conn.execute("INSERT INTO notes VALUES ('n1', 'an old thought', NULL, NULL, 1.0, NULL, '{}')")
|
|
120
|
+
store = MarginStore(db)
|
|
121
|
+
assert store.get("n1").content == "an old thought" and store.get("n1").read_at is None
|
|
122
|
+
assert build_bookmark(store)["unread_letters"] == 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_the_bookmark_is_an_mcp_resource(tmp_path):
|
|
126
|
+
pytest.importorskip("mcp")
|
|
127
|
+
import asyncio
|
|
128
|
+
from mcp.server.fastmcp import FastMCP
|
|
129
|
+
from seren_margin.mcp.tools import BOOKMARK_URI, register_tools
|
|
130
|
+
cfg = MarginConfig(db_path=str(tmp_path / "notes.db"))
|
|
131
|
+
store = MarginStore(cfg.resolved_db_path())
|
|
132
|
+
store.set_dedication(PAGE)
|
|
133
|
+
mcp = FastMCP("t")
|
|
134
|
+
register_tools(mcp, store, cfg)
|
|
135
|
+
uris = [str(r.uri) for r in asyncio.run(mcp.list_resources())]
|
|
136
|
+
assert BOOKMARK_URI in uris
|
|
137
|
+
content = asyncio.run(mcp.read_resource(BOOKMARK_URI))
|
|
138
|
+
assert PAGE in "".join(getattr(c, "content", "") or getattr(c, "text", "") for c in content)
|
|
@@ -58,11 +58,13 @@ def test_roster_matches_the_implementation(manifest):
|
|
|
58
58
|
assert set(_tools(manifest)) == set(TOOL_NAMES)
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def
|
|
62
|
-
"""Belt to the above, and runs without the mcp extra installed.
|
|
61
|
+
def test_manifest_advertises_exactly_the_ten_tools(manifest):
|
|
62
|
+
"""Belt to the above, and runs without the mcp extra installed. Six for
|
|
63
|
+
the notes, four for the bookmark, the dedication and letters (29 Sept 2026)."""
|
|
63
64
|
assert set(_tools(manifest)) == {
|
|
64
65
|
"note_to_self", "list_my_notes", "list_my_topics",
|
|
65
|
-
"search_my_notes", "amend_note", "retract_note"
|
|
66
|
+
"search_my_notes", "amend_note", "retract_note",
|
|
67
|
+
"bookmark", "set_dedication", "write_letter", "read_letters"}
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def test_no_fossil_tools_return(manifest):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|