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.
- seren_hippocampus-0.5.0/PKG-INFO +26 -0
- seren_hippocampus-0.5.0/SerenHippocampus.py +1 -0
- seren_hippocampus-0.5.0/SerenHippocampus.pyproj +48 -0
- seren_hippocampus-0.5.0/pyproject.toml +69 -0
- seren_hippocampus-0.5.0/seren-hippocampus.yaml.sample +74 -0
- seren_hippocampus-0.5.0/seren_hippocampus/__init__.py +22 -0
- seren_hippocampus-0.5.0/seren_hippocampus/__main__.py +39 -0
- seren_hippocampus-0.5.0/seren_hippocampus/_version.py +24 -0
- seren_hippocampus-0.5.0/seren_hippocampus/app.py +204 -0
- seren_hippocampus-0.5.0/seren_hippocampus/config.py +275 -0
- seren_hippocampus-0.5.0/seren_hippocampus/memory_client.py +113 -0
- seren_hippocampus-0.5.0/seren_hippocampus/model_lifecycle.py +233 -0
- seren_hippocampus-0.5.0/seren_hippocampus/sleep.py +885 -0
- seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/body.html +35 -0
- seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/header_aside.html +5 -0
- seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/scripts.js +188 -0
- seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/styles.css +40 -0
- seren_hippocampus-0.5.0/seren_hippocampus/viewer/ui/tabs.html +6 -0
- seren_hippocampus-0.5.0/seren_hippocampus.egg-info/PKG-INFO +26 -0
- seren_hippocampus-0.5.0/seren_hippocampus.egg-info/SOURCES.txt +32 -0
- seren_hippocampus-0.5.0/seren_hippocampus.egg-info/dependency_links.txt +1 -0
- seren_hippocampus-0.5.0/seren_hippocampus.egg-info/entry_points.txt +2 -0
- seren_hippocampus-0.5.0/seren_hippocampus.egg-info/requires.txt +19 -0
- seren_hippocampus-0.5.0/seren_hippocampus.egg-info/scm_file_list.json +27 -0
- seren_hippocampus-0.5.0/seren_hippocampus.egg-info/scm_version.json +8 -0
- seren_hippocampus-0.5.0/seren_hippocampus.egg-info/top_level.txt +1 -0
- seren_hippocampus-0.5.0/setup.cfg +4 -0
- seren_hippocampus-0.5.0/tests/conftest.py +146 -0
- seren_hippocampus-0.5.0/tests/fake_model_server.py +47 -0
- seren_hippocampus-0.5.0/tests/test_bedtime.py +196 -0
- seren_hippocampus-0.5.0/tests/test_brief_steers.py +87 -0
- seren_hippocampus-0.5.0/tests/test_model_lifecycle.py +250 -0
- seren_hippocampus-0.5.0/tests/test_schedule_and_events.py +247 -0
- 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 @@
|
|
|
1
|
+
|
|
@@ -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
|