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.
Files changed (45) hide show
  1. {seren_margin-2.1.0/seren_margin.egg-info → seren_margin-2.2.0}/PKG-INFO +1 -1
  2. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/_version.py +3 -3
  3. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/app.py +49 -3
  4. seren_margin-2.2.0/seren_margin/bookmark.py +67 -0
  5. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/mcp/__init__.py +1 -1
  6. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/mcp/tools.py +113 -6
  7. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/mcp-manifest.yaml +76 -1
  8. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/models.py +15 -0
  9. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/store.py +70 -3
  10. {seren_margin-2.1.0 → seren_margin-2.2.0/seren_margin.egg-info}/PKG-INFO +1 -1
  11. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/SOURCES.txt +2 -0
  12. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/scm_file_list.json +2 -0
  13. seren_margin-2.2.0/seren_margin.egg-info/scm_version.json +8 -0
  14. seren_margin-2.2.0/tests/test_bookmark_and_letters.py +138 -0
  15. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_manifest_parity.py +5 -3
  16. seren_margin-2.1.0/seren_margin.egg-info/scm_version.json +0 -8
  17. {seren_margin-2.1.0 → seren_margin-2.2.0}/LICENSE +0 -0
  18. {seren_margin-2.1.0 → seren_margin-2.2.0}/SerenMargin.pyproj +0 -0
  19. {seren_margin-2.1.0 → seren_margin-2.2.0}/pyproject.toml +0 -0
  20. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren-margin.service.sample +0 -0
  21. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren-margin.yaml.sample +0 -0
  22. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/__init__.py +0 -0
  23. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/__main__.py +0 -0
  24. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/_diag.py +0 -0
  25. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/config.py +0 -0
  26. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin/mcp/server.py +0 -0
  27. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/dependency_links.txt +0 -0
  28. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/entry_points.txt +0 -0
  29. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/requires.txt +0 -0
  30. {seren_margin-2.1.0 → seren_margin-2.2.0}/seren_margin.egg-info/top_level.txt +0 -0
  31. {seren_margin-2.1.0 → seren_margin-2.2.0}/setup.cfg +0 -0
  32. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/__init__.py +0 -0
  33. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/conftest.py +0 -0
  34. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_amend_and_topics.py +0 -0
  35. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_config.py +0 -0
  36. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_exposure_is_wired.py +0 -0
  37. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_mcp_fallback.py +0 -0
  38. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_mcp_mount.py +0 -0
  39. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_mcp_tools.py +0 -0
  40. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_optional_bearer.py +0 -0
  41. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_reads_stay_private.py +0 -0
  42. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_search.py +0 -0
  43. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_smoke.py +0 -0
  44. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_stats_content_blind.py +0 -0
  45. {seren_margin-2.1.0 → seren_margin-2.2.0}/tests/test_updates_contract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seren-margin
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Private notes-to-self for an AI assistant. Standalone, opt-in, opinionated.
5
5
  Author: Chad Roesler
6
6
  License-Expression: AGPL-3.0-or-later
@@ -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.1.0'
22
- __version_tuple__ = version_tuple = (2, 1, 0)
21
+ __version__ = version = '2.2.0'
22
+ __version_tuple__ = version_tuple = (2, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g5b51c6bf2'
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 six tools either way, defined once in seren_margin.mcp.tools.
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 six tools. They're defined ONCE, in tools.py;
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
- Six, and deliberately no more. NOT exposed: /notes/stats. That endpoint exists
27
- so the OPERATOR can confirm the service is alive without reading the notes;
28
- handing it to the note-writer would be pointless (the writer can just read the
29
- notes) and would muddy whose surface it is.
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 SIX TOOLS. The implementations live
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
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seren-margin
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Private notes-to-self for an AI assistant. Standalone, opt-in, opinionated.
5
5
  Author: Chad Roesler
6
6
  License-Expression: AGPL-3.0-or-later
@@ -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,8 @@
1
+ {
2
+ "tag": "2.2.0",
3
+ "distance": 0,
4
+ "node": "g77bceb68b538cab8ef07fa4d356711b2d2181733",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-30"
8
+ }
@@ -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 test_manifest_advertises_exactly_the_six_tools(manifest):
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):
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.1.0",
3
- "distance": 0,
4
- "node": "g5b51c6bf295aed4cadc4825a102204c216a7e4e7",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-09-28"
8
- }
File without changes
File without changes