seren-hippocampus 0.5.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 (34) hide show
  1. seren_hippocampus-0.5.0/PKG-INFO +26 -0
  2. seren_hippocampus-0.5.0/SerenHippocampus.py +1 -0
  3. seren_hippocampus-0.5.0/SerenHippocampus.pyproj +48 -0
  4. seren_hippocampus-0.5.0/pyproject.toml +69 -0
  5. seren_hippocampus-0.5.0/seren-hippocampus.yaml.sample +74 -0
  6. seren_hippocampus-0.5.0/seren_hippocampus/__init__.py +22 -0
  7. seren_hippocampus-0.5.0/seren_hippocampus/__main__.py +39 -0
  8. seren_hippocampus-0.5.0/seren_hippocampus/_version.py +24 -0
  9. seren_hippocampus-0.5.0/seren_hippocampus/app.py +204 -0
  10. seren_hippocampus-0.5.0/seren_hippocampus/config.py +275 -0
  11. seren_hippocampus-0.5.0/seren_hippocampus/memory_client.py +113 -0
  12. seren_hippocampus-0.5.0/seren_hippocampus/model_lifecycle.py +233 -0
  13. seren_hippocampus-0.5.0/seren_hippocampus/sleep.py +885 -0
  14. seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/body.html +35 -0
  15. seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/header_aside.html +5 -0
  16. seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/scripts.js +188 -0
  17. seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/styles.css +40 -0
  18. seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/tabs.html +6 -0
  19. seren_hippocampus-0.5.0/seren_hippocampus.egg-info/PKG-INFO +26 -0
  20. seren_hippocampus-0.5.0/seren_hippocampus.egg-info/SOURCES.txt +32 -0
  21. seren_hippocampus-0.5.0/seren_hippocampus.egg-info/dependency_links.txt +1 -0
  22. seren_hippocampus-0.5.0/seren_hippocampus.egg-info/entry_points.txt +2 -0
  23. seren_hippocampus-0.5.0/seren_hippocampus.egg-info/requires.txt +19 -0
  24. seren_hippocampus-0.5.0/seren_hippocampus.egg-info/scm_file_list.json +27 -0
  25. seren_hippocampus-0.5.0/seren_hippocampus.egg-info/scm_version.json +8 -0
  26. seren_hippocampus-0.5.0/seren_hippocampus.egg-info/top_level.txt +1 -0
  27. seren_hippocampus-0.5.0/setup.cfg +4 -0
  28. seren_hippocampus-0.5.0/tests/conftest.py +146 -0
  29. seren_hippocampus-0.5.0/tests/fake_model_server.py +47 -0
  30. seren_hippocampus-0.5.0/tests/test_bedtime.py +196 -0
  31. seren_hippocampus-0.5.0/tests/test_brief_steers.py +87 -0
  32. seren_hippocampus-0.5.0/tests/test_model_lifecycle.py +250 -0
  33. seren_hippocampus-0.5.0/tests/test_schedule_and_events.py +247 -0
  34. seren_hippocampus-0.5.0/tests/test_sleep.py +308 -0
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: seren-hippocampus
3
+ Version: 0.5.0
4
+ Summary: The sleep cycle for SerenMemory: drafts the docket, resubmits on critique, purges what was flagged. The consolidator, split out.
5
+ Author: Chad Roesler
6
+ License-Expression: GPL-3.0-only
7
+ Project-URL: Homepage, https://github.com/ChadRoesler/SerenHippocampus
8
+ Keywords: seren,memory,consolidation,llm,fastapi
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: fastapi>=0.110
12
+ Requires-Dist: uvicorn[standard]>=0.27
13
+ Requires-Dist: pydantic>=2.5
14
+ Requires-Dist: pyyaml>=6.0
15
+ Requires-Dist: httpx>=0.26
16
+ Requires-Dist: seren-meninges<3,>=2.4.0
17
+ Requires-Dist: seren-sinew<2,>=1.0.0
18
+ Provides-Extra: mcp
19
+ Requires-Dist: mcp<2,>=1.0; extra == "mcp"
20
+ Provides-Extra: corp
21
+ Requires-Dist: truststore>=0.9; extra == "corp"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
25
+ Requires-Dist: httpx>=0.26; extra == "dev"
26
+ Requires-Dist: seren-memory>=4.0.0; extra == "dev"
@@ -0,0 +1,48 @@
1
+ <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
2
+ <PropertyGroup>
3
+ <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
4
+ <SchemaVersion>2.0</SchemaVersion>
5
+ <ProjectGuid>eb418210-7b45-47a8-99e2-65c9d922580e</ProjectGuid>
6
+ <ProjectHome>.</ProjectHome>
7
+ <StartupFile>SerenHippocampus.py</StartupFile>
8
+ <SearchPath>
9
+ </SearchPath>
10
+ <WorkingDirectory>.</WorkingDirectory>
11
+ <OutputPath>.</OutputPath>
12
+ <Name>SerenHippocampus</Name>
13
+ <RootNamespace>SerenHippocampus</RootNamespace>
14
+ </PropertyGroup>
15
+ <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
16
+ <DebugSymbols>true</DebugSymbols>
17
+ <EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
18
+ </PropertyGroup>
19
+ <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
20
+ <DebugSymbols>true</DebugSymbols>
21
+ <EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
22
+ </PropertyGroup>
23
+ <ItemGroup>
24
+ <Compile Include="SerenHippocampus.py" />
25
+ <Compile Include="seren_hippocampus\app.py" />
26
+ <Compile Include="seren_hippocampus\config.py" />
27
+ <Compile Include="seren_hippocampus\memory_client.py" />
28
+ <Compile Include="seren_hippocampus\sleep.py" />
29
+ <Compile Include="seren_hippocampus\_version.py" />
30
+ <Compile Include="seren_hippocampus\__init__.py" />
31
+ <Compile Include="seren_hippocampus\__main__.py" />
32
+ <Compile Include="tests\conftest.py" />
33
+ <Compile Include="tests\test_sleep.py" />
34
+ </ItemGroup>
35
+ <ItemGroup>
36
+ <Folder Include="seren_hippocampus\" />
37
+ <Folder Include="tests\" />
38
+ </ItemGroup>
39
+ <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets" />
40
+ <!-- Uncomment the CoreCompile target to enable the Build command in
41
+ Visual Studio and specify your pre- and post-build commands in
42
+ the BeforeBuild and AfterBuild targets below. -->
43
+ <!--<Target Name="CoreCompile" />-->
44
+ <Target Name="BeforeBuild">
45
+ </Target>
46
+ <Target Name="AfterBuild">
47
+ </Target>
48
+ </Project>
@@ -0,0 +1,69 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "setuptools-scm>=8", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "seren-hippocampus"
7
+ dynamic = ["version"]
8
+ description = "The sleep cycle for SerenMemory: drafts the docket, resubmits on critique, purges what was flagged. The consolidator, split out."
9
+ license = "GPL-3.0-only"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Chad Roesler" }]
12
+ readme = "README.md"
13
+ keywords = ["seren", "memory", "consolidation", "llm", "fastapi"]
14
+
15
+ # Deliberately no chromadb and no store: the hippocampus holds nothing. It
16
+ # talks to SerenMemory over HTTP and to a small model over /v1/chat/completions.
17
+ dependencies = [
18
+ "fastapi>=0.110",
19
+ "uvicorn[standard]>=0.27",
20
+ "pydantic>=2.5",
21
+ "pyyaml>=6.0",
22
+ "httpx>=0.26",
23
+ "seren-meninges>=2.4.0,<3",
24
+ "seren-sinew>=1.0.0,<2",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ mcp = [
29
+ # THE <2 CAP IS LOAD-BEARING: mcp 2.0.0 removed FastMCP (see SerenMemory).
30
+ "mcp>=1.0,<2",
31
+ ]
32
+ corp = [
33
+ "truststore>=0.9",
34
+ ]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ "pytest-asyncio>=0.23",
38
+ "httpx>=0.26",
39
+ # The tests run the REAL SerenMemory app in-process behind an ASGI
40
+ # transport, so the contract between the two is exercised, not mocked.
41
+ # 4.0.0 is the first Memory with /drafts (the docket rename) and without
42
+ # the in-process consolidator: release Memory first, then this.
43
+ "seren-memory>=4.0.0",
44
+ ]
45
+
46
+ [project.scripts]
47
+ seren-hippocampus = "seren_hippocampus.__main__:main"
48
+
49
+ [project.urls]
50
+ Homepage = "https://github.com/ChadRoesler/SerenHippocampus"
51
+
52
+ [tool.setuptools.packages.find]
53
+ include = ["seren_hippocampus*"]
54
+
55
+ # The viewer bricks ride inside the wheel; without this pip strips non-.py
56
+ # files and GET /viewer renders an empty shell on an installed copy.
57
+ [tool.setuptools.package-data]
58
+ seren_hippocampus = ["viewer/ui/*.html", "viewer/ui/*.css", "viewer/ui/*.js"]
59
+
60
+ [tool.setuptools_scm]
61
+ # .git is one level above pyproject.toml (repo root)
62
+ root = ".."
63
+ version_scheme = "release-branch-semver"
64
+ version_file = "seren_hippocampus/_version.py"
65
+ tag_regex = "^v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+.*)$"
66
+ fallback_version = "0.0.0.dev0"
67
+
68
+ [tool.pytest.ini_options]
69
+ asyncio_mode = "auto"
@@ -0,0 +1,74 @@
1
+ # SerenHippocampus config - copy to ~/seren-hippocampus/seren-hippocampus.yaml
2
+ #
3
+ # The hippocampus is SerenMemory's sleep cycle, split out. It holds no store:
4
+ # it reads short-terms from Memory, writes drafts to Memory, and purges what
5
+ # was flagged. Two loops: sleep (~20h) and tend (every few minutes, picks up
6
+ # denied operations and resubmits them).
7
+
8
+ server:
9
+ host: 127.0.0.1 # local-only. 0.0.0.0 = reachable on the LAN - set a bearer first
10
+ port: 7424 # the last slot in the brain band
11
+ # The bearer THIS service requires of callers (its /sleep, /tend, /status).
12
+ # bearer_token: "" # inline literal - escape hatch
13
+ # bearer_token_env: SEREN_HIPPOCAMPUS_TOKEN # NAME of an env var (recommended)
14
+ # bearer_token_keyring: "seren-hippocampus/bearer"
15
+
16
+ memory:
17
+ url: http://127.0.0.1:7420
18
+ # The bearer the hippocampus presents TO Memory. A Memory installed with
19
+ # --gen-token answers 401 to everything without one. Same three pointers.
20
+ # bearer_token_env: SEREN_MEMORY_TOKEN
21
+ # bearer_token_keyring: "seren-memory/bearer"
22
+ # bearer_token: ""
23
+ timeout_seconds: 30
24
+
25
+ model:
26
+ # The small model - the Inside Out workers. OpenAI-compatible
27
+ # /v1/chat/completions; a 2B-4B model is plenty. Empty url = mechanical
28
+ # mode: clusters over the threshold become new cores by the longest entry,
29
+ # verbatim flags are honoured, and no attach / supersede is proposed,
30
+ # because those are judgement calls.
31
+ url: http://localhost:8090/v1
32
+ name: default
33
+ timeout_seconds: 120
34
+ max_tokens: 900
35
+ # Start the model only when a sleep or a redraft needs it, stop it after.
36
+ # For a Nano floor or a small card: the drafting model is not loaded all day.
37
+ lifecycle:
38
+ manage: false
39
+ # start: "C:\\llama\\llama-server.exe -m C:\\models\\qwen3-1.7b-q4.gguf --port 8090 -ngl 99"
40
+ # stop: "" # blank = stop the process the hippocampus started
41
+ # observatory_url: http://127.0.0.1:7777 # or: a service the node's Observatory manages
42
+ # observatory_service: seren-llama
43
+ # observatory_token_env: SEREN_OBSERVATORY_TOKEN
44
+ # health_url: "" # blank = the model url without /v1, plus /health
45
+ ready_timeout_seconds: 240
46
+ keep_warm_seconds: 300 # stays up this long after its last call, then stops on the next tick
47
+ retry_after_seconds: 900 # after a failed start, wait this long before trying again
48
+
49
+ sleep:
50
+ mode: thread # thread = loops in-process; external = POST /sleep and /tend yourself
51
+ interval_seconds: 72000 # bedtime: ~20h after the last sleep (not 24, on purpose: it drifts through the day)
52
+ # at: "03:30" # or a wall-clock time (local HH:MM) instead of the interval
53
+ warmup_seconds: 300 # a process that boots overdue sleeps this soon, not a whole interval later
54
+ gap_grace_intervals: 3 # a sleep after a gap this long (x interval) drafts but does not age out or sweep
55
+ # The brief is the gate. Every tend_interval the hippocampus checks Memory for an open brief and
56
+ # sleeps on it. Past bedtime (interval_seconds / at) with none, it counts the misses:
57
+ brief_request: true # ...and every brief_check_misses of them asks the main model for one
58
+ brief_check_misses: 3 # (a near-term intent in Memory + a brief_requested event; a nudge after that)
59
+ brief_pull: false # headless (no main model): let the small model pull a brief from the fragments
60
+ tend_interval_seconds: 300
61
+ max_entries_per_run: 500
62
+ promote_min_evidence: 3 # short-terms a topic needs before it is proposed (hints, pins, verbatim override)
63
+ max_attempts: 3 # drafts per chain before the last one is submitted terminal
64
+ candidate_cores: 5 # existing cores shown to the model per cluster
65
+ state_path: ~/.seren-hippocampus/state.json
66
+
67
+ notify:
68
+ # Where to say that something happened. Events are always kept on the
69
+ # service (GET /events and the viewer); a webhook gets each one POSTed as
70
+ # JSON - Lodestar, Symposium, or a messaging bridge sits at the other end.
71
+ # webhook_url: http://127.0.0.1:6361/hooks/hippocampus
72
+ # bearer_token_env: SEREN_HIPPOCAMPUS_WEBHOOK_TOKEN
73
+ events: [draft_submitted, sleep_failed, chain_ended, purged]
74
+ timeout_seconds: 10
@@ -0,0 +1,22 @@
1
+ """
2
+ SerenHippocampus - the sleep cycle for SerenMemory, split out.
3
+
4
+ The small model (the Inside Out workers) reads the short-terms the daily
5
+ brief steered it to and writes a DOCKET of operations on long-term - new
6
+ cores, evidence attached to existing cores, supersessions, verbatim keeps -
7
+ which the main model reviews per operation through SerenMemory. Denied
8
+ operations come back here with a critique; the tend loop redrafts and
9
+ resubmits. Flagged memories are purged at sleep, with a tombstone.
10
+
11
+ This service holds no store. Everything durable lives in SerenMemory; the
12
+ hippocampus is judgement and scheduling, reachable over HTTP and (with the
13
+ [mcp] extra) as MCP tools.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ try:
18
+ from ._version import __version__
19
+ except Exception: # noqa: BLE001 - source checkout without a generated _version.py
20
+ __version__ = "0.0.0.dev0"
21
+
22
+ __all__ = ["__version__"]
@@ -0,0 +1,39 @@
1
+ """Entry point for `python -m seren_hippocampus` / the `seren-hippocampus` script."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+
7
+ import uvicorn
8
+ from seren_meninges.exposure import enforce_server
9
+
10
+ from .app import create_app
11
+ from .config import ENV_PREFIX, load_config
12
+
13
+
14
+ def _force_utf8_stdio() -> None:
15
+ for stream in (sys.stdout, sys.stderr):
16
+ try:
17
+ stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
18
+ except (AttributeError, ValueError):
19
+ pass
20
+
21
+
22
+ def main() -> None:
23
+ _force_utf8_stdio()
24
+ parser = argparse.ArgumentParser(
25
+ prog="seren_hippocampus",
26
+ description="SerenHippocampus - the sleep cycle for SerenMemory.")
27
+ parser.add_argument("--config", "-c", default=None,
28
+ help="Path to seren-hippocampus.yaml (default: $SEREN_HIPPOCAMPUS_CONFIG, "
29
+ "then ~/seren-hippocampus/seren-hippocampus.yaml, then defaults).")
30
+ args = parser.parse_args()
31
+ cfg = load_config(args.config)
32
+ enforce_server(cfg.server, service="seren-hippocampus", env_prefix=ENV_PREFIX,
33
+ log=lambda m: print(m, file=sys.stderr))
34
+ app = create_app(cfg)
35
+ uvicorn.run(app, host=cfg.server.host, port=cfg.server.port, log_level="info")
36
+
37
+
38
+ if __name__ == "__main__":
39
+ 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 = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
+
24
+ __commit_id__ = commit_id = 'gd0647fc30'
@@ -0,0 +1,204 @@
1
+ """
2
+ The service: a small FastAPI around the sleep.
3
+
4
+ GET / service info
5
+ GET /health liveness (+ whether Memory answers)
6
+ GET /status last sleep, last tend, mode, intervals
7
+ POST /sleep run a sleep now (409 if one is running)
8
+ POST /tend pick up denied operations now
9
+
10
+ Bearer + request logging come from the family (Meninges, Sinew). In
11
+ sleep.mode=thread the two loops run here; in external, something else
12
+ POSTs on a schedule.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import time
18
+ from contextlib import asynccontextmanager
19
+ from typing import Optional
20
+
21
+ from fastapi import FastAPI, HTTPException, Request
22
+ from seren_meninges.auth import bearer_auth_middleware
23
+ from seren_meninges.updates import updates_payload
24
+ from seren_sinew.request_log import RequestLoggingMiddleware
25
+
26
+ from . import __version__
27
+ from .config import HippocampusConfig, load_config
28
+ from .memory_client import MemoryClient, MemoryError
29
+ from .sleep import Busy, Hippocampus
30
+
31
+
32
+ def create_app(config: Optional[HippocampusConfig] = None,
33
+ memory_client: Optional[MemoryClient] = None) -> FastAPI:
34
+ cfg = config or load_config()
35
+ bearer = cfg.server.resolve_bearer()
36
+
37
+ @asynccontextmanager
38
+ async def lifespan(app: FastAPI):
39
+ app.state.config = cfg
40
+ mem = memory_client or MemoryClient(cfg.memory.url, bearer=cfg.memory.resolve_bearer(),
41
+ timeout=cfg.memory.timeout_seconds)
42
+ app.state.memory = mem
43
+ app.state.hippocampus = Hippocampus(cfg, mem)
44
+ # "is there a newer seren-hippocampus" - core in Meninges, always built.
45
+ from seren_meninges.updates import UpdateChecker
46
+ app.state.updates = UpdateChecker(
47
+ "seren-hippocampus",
48
+ enabled=cfg.updates.enabled,
49
+ index_url=cfg.updates.index_url,
50
+ ttl_seconds=cfg.updates.check_interval_hours * 3600,
51
+ allow_prerelease=cfg.updates.allow_prerelease,
52
+ )
53
+ tasks: list[asyncio.Task] = []
54
+ app.state.next_at: dict[str, Optional[float]] = {"sleep": None, "tend": None}
55
+ if cfg.sleep.mode == "thread":
56
+ h: Hippocampus = app.state.hippocampus
57
+
58
+ async def tick_loop():
59
+ interval = cfg.sleep.tend_interval_seconds
60
+ print(f"[seren-hippocampus] loop active: every {interval}s tend the open chains and check "
61
+ "for a brief; bedtime " + (f"daily at {cfg.sleep.at}" if cfg.sleep.at.strip()
62
+ else f"every {cfg.sleep.interval_seconds}s") + ")")
63
+ while True:
64
+ app.state.next_at["tick"] = time.time() + interval
65
+ await asyncio.sleep(interval)
66
+ try:
67
+ await asyncio.to_thread(h.tick)
68
+ except Busy:
69
+ pass
70
+ except Exception as e: # noqa: BLE001
71
+ print(f"[seren-hippocampus] tick error: {e}")
72
+
73
+ tasks.append(asyncio.create_task(tick_loop()))
74
+ try:
75
+ yield
76
+ finally:
77
+ for t in tasks:
78
+ t.cancel()
79
+ try:
80
+ app.state.hippocampus.model.shutdown()
81
+ except Exception as e: # noqa: BLE001
82
+ print(f"[seren-hippocampus] model shutdown: {e}")
83
+ if memory_client is None:
84
+ mem.close()
85
+
86
+ app = FastAPI(title="SerenHippocampus", version=__version__, lifespan=lifespan,
87
+ description="The sleep cycle for SerenMemory: drafts what should be kept, resubmits on critique, purges what was flagged.")
88
+
89
+ @app.get("/")
90
+ async def root(request: Request):
91
+ return {"service": "seren-hippocampus", "version": __version__,
92
+ "memory": cfg.memory.url, "mode": cfg.sleep.mode,
93
+ "model": "configured" if cfg.model.url.strip() else "mechanical",
94
+ "updates": await updates_payload(
95
+ getattr(request.app.state, "updates", None),
96
+ distribution="seren-hippocampus",
97
+ installed=__version__)}
98
+
99
+ @app.get("/health")
100
+ async def health(request: Request):
101
+ mem_ok = True
102
+ try:
103
+ await asyncio.to_thread(request.app.state.memory.health)
104
+ except MemoryError:
105
+ mem_ok = False
106
+ return {"ok": True, "memory_reachable": mem_ok}
107
+
108
+ @app.get("/status")
109
+ async def status(request: Request):
110
+ h: Hippocampus = request.app.state.hippocampus
111
+ nxt = getattr(request.app.state, "next_at", {}) or {}
112
+ return {"mode": cfg.sleep.mode, "interval_seconds": cfg.sleep.interval_seconds,
113
+ "tend_interval_seconds": cfg.sleep.tend_interval_seconds,
114
+ "model_configured": h.model_configured,
115
+ "model_lifecycle": h.model.snapshot(),
116
+ "next_sleep_at": None, # no timer: the brief is the gate
117
+ "next_tick_at": nxt.get("tick") if cfg.sleep.mode == "thread" else None,
118
+ "next_tend_at": nxt.get("tick") if cfg.sleep.mode == "thread" else None,
119
+ "bedtime_at": h.next_sleep_due(),
120
+ "brief_misses": int(h.state.get("brief_misses") or 0),
121
+ "last_check": h.state.get("last_check"),
122
+ "sleep_at": cfg.sleep.at or None,
123
+ "catch_up_next": h.is_catch_up(),
124
+ "sleep_due_at": h.next_sleep_due(),
125
+ "brief_wanted": h.brief_wanted() is not None,
126
+ "brief_requested_at": (h.brief_wanted() or {}).get("asked_at"),
127
+ "webhook": bool(cfg.notify.webhook_url),
128
+ "last_sleep": h.state.get("last_sleep"), "last_tend": h.state.get("last_tend")}
129
+
130
+ @app.post("/check")
131
+ async def check(request: Request):
132
+ """One check by hand: is there a brief? Sleeps on it if so."""
133
+ h: Hippocampus = request.app.state.hippocampus
134
+ try:
135
+ return await asyncio.to_thread(h.check)
136
+ except Busy as e:
137
+ raise HTTPException(409, str(e))
138
+
139
+ @app.get("/events")
140
+ async def events(request: Request):
141
+ """What happened, newest first: drafts submitted, sleeps failed,
142
+ chains ended, purges. The same events go to notify.webhook_url when
143
+ one is configured; each says whether that delivery worked."""
144
+ h: Hippocampus = request.app.state.hippocampus
145
+ rows = list(h.state.get("events") or [])
146
+ return {"count": len(rows), "webhook": bool(cfg.notify.webhook_url),
147
+ "entries": list(reversed(rows))}
148
+
149
+ @app.get("/history")
150
+ async def history(request: Request):
151
+ """The last sleeps and tends this service ran, newest first."""
152
+ h: Hippocampus = request.app.state.hippocampus
153
+ rows = list(h.state.get("history") or [])
154
+ return {"count": len(rows), "entries": list(reversed(rows))}
155
+
156
+ @app.get("/queue")
157
+ async def queue(request: Request):
158
+ """What is waiting for review, read from SerenMemory. The queue lives
159
+ there; this is a window onto it so the viewer can show it without a
160
+ second token. A Memory that does not answer is reported, not raised."""
161
+ mem: MemoryClient = request.app.state.memory
162
+ try:
163
+ rows = await asyncio.to_thread(mem.drafts, "pending", 50)
164
+ except MemoryError as e:
165
+ return {"count": 0, "drafts": [], "error": str(e)}
166
+ return {"count": len(rows), "drafts": rows}
167
+
168
+ @app.get("/viewer")
169
+ async def viewer():
170
+ """The window for whoever runs this and did not build it: did the sleep
171
+ run, what is waiting for review, and when it broke, what broke. Public
172
+ route; its API calls carry the token via the shell's 🔑 modal."""
173
+ from pathlib import Path
174
+ from fastapi.responses import HTMLResponse
175
+ from seren_meninges.viewer import render_from_dir
176
+ html = render_from_dir(
177
+ Path(__file__).resolve().parent / "viewer" / "ui",
178
+ title="SerenHippocampus",
179
+ brand="Seren<b>Hippocampus</b> · the sleep",
180
+ subtitle=f"v{__version__} · drafts what should be kept, resubmits on critique, purges what was flagged",
181
+ accent="#c9a0dc",
182
+ )
183
+ return HTMLResponse(html)
184
+
185
+ @app.post("/sleep")
186
+ async def sleep_now(request: Request):
187
+ h: Hippocampus = request.app.state.hippocampus
188
+ try:
189
+ return await asyncio.to_thread(h.sleep)
190
+ except Busy as e:
191
+ raise HTTPException(409, str(e))
192
+
193
+ @app.post("/tend")
194
+ async def tend_now(request: Request):
195
+ h: Hippocampus = request.app.state.hippocampus
196
+ try:
197
+ return await asyncio.to_thread(h.tend)
198
+ except Busy as e:
199
+ raise HTTPException(409, str(e))
200
+
201
+ app.add_middleware(bearer_auth_middleware(bearer)) # inner
202
+ app.add_middleware(RequestLoggingMiddleware, service_name="seren-hippocampus", # outer
203
+ env_prefix="SEREN_HIPPOCAMPUS")
204
+ return app