poppy-memory 0.2.0__py3-none-any.whl
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.
- poppy/__init__.py +27 -0
- poppy/build_mcpb.py +91 -0
- poppy/capture/__init__.py +13 -0
- poppy/capture/_state.py +46 -0
- poppy/capture/banner.py +92 -0
- poppy/capture/cadence.py +71 -0
- poppy/capture/journal.py +126 -0
- poppy/capture/lock.py +79 -0
- poppy/capture/policy.py +163 -0
- poppy/capture/reconciler.py +169 -0
- poppy/capture/watermark.py +43 -0
- poppy/capture/window.py +134 -0
- poppy/cli/__init__.py +0 -0
- poppy/cli/hooks.py +753 -0
- poppy/cli/main.py +1539 -0
- poppy/config.py +229 -0
- poppy/conflict_detection.py +210 -0
- poppy/consolidation.py +565 -0
- poppy/db.py +43 -0
- poppy/engine/__init__.py +3 -0
- poppy/engine/_model_cache.py +55 -0
- poppy/engine/_st_loader.py +68 -0
- poppy/engine/bloom.py +588 -0
- poppy/engine/interface.py +60 -0
- poppy/engine/migration.py +273 -0
- poppy/engine/registry.py +102 -0
- poppy/engine/seed.py +321 -0
- poppy/engine/sprout.py +374 -0
- poppy/errors.py +14 -0
- poppy/integrations/claude_memory_import.py +145 -0
- poppy/integrations/hermes_memory_import.py +121 -0
- poppy/lifecycle.py +210 -0
- poppy/mcp_server/__init__.py +0 -0
- poppy/mcp_server/server.py +518 -0
- poppy/models.py +38 -0
- poppy/runtime.py +75 -0
- poppy/setup/__init__.py +0 -0
- poppy/setup/claude_code.py +439 -0
- poppy/setup/goose.py +184 -0
- poppy/setup/hermes.py +558 -0
- poppy/setup/trags.py +142 -0
- poppy/sources.py +86 -0
- poppy/sync/__init__.py +299 -0
- poppy/sync/auto.py +226 -0
- poppy/sync/client.py +143 -0
- poppy/sync/serializer.py +94 -0
- poppy/sync/state.py +76 -0
- poppy/telemetry.py +217 -0
- poppy/ui/__init__.py +6 -0
- poppy/ui/server.py +489 -0
- poppy/ui/static/app.js +944 -0
- poppy/ui/static/fonts/Fraunces-Italic-latin.woff2 +0 -0
- poppy/ui/static/fonts/Fraunces-latin.woff2 +0 -0
- poppy/ui/static/fonts/JetBrainsMono-Italic-latin.woff2 +0 -0
- poppy/ui/static/fonts/JetBrainsMono-latin.woff2 +0 -0
- poppy/ui/static/fonts/OFL-Fraunces.txt +93 -0
- poppy/ui/static/fonts/OFL-JetBrainsMono.txt +93 -0
- poppy/ui/static/index.html +101 -0
- poppy/ui/static/styles.css +809 -0
- poppy/ui/tombstones.py +147 -0
- poppy_memory-0.2.0.dist-info/METADATA +269 -0
- poppy_memory-0.2.0.dist-info/RECORD +65 -0
- poppy_memory-0.2.0.dist-info/WHEEL +4 -0
- poppy_memory-0.2.0.dist-info/entry_points.txt +2 -0
- poppy_memory-0.2.0.dist-info/licenses/LICENSE +662 -0
poppy/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Poppy package metadata.
|
|
2
|
+
|
|
3
|
+
The version is single-sourced from pyproject.toml: installed distributions
|
|
4
|
+
resolve it via importlib.metadata; bare source checkouts (no dist-info on
|
|
5
|
+
sys.path) fall back to reading pyproject.toml next to the package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib import metadata as _metadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve_version() -> str:
|
|
12
|
+
try:
|
|
13
|
+
return _metadata.version("poppy-memory")
|
|
14
|
+
except _metadata.PackageNotFoundError:
|
|
15
|
+
pass
|
|
16
|
+
try:
|
|
17
|
+
import tomllib
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
21
|
+
with pyproject.open("rb") as f:
|
|
22
|
+
return tomllib.load(f)["project"]["version"]
|
|
23
|
+
except Exception:
|
|
24
|
+
return "0.0.0+unknown"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__version__ = _resolve_version()
|
poppy/build_mcpb.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Build a `.mcpb` (MCP Bundle) from the Poppy source tree.
|
|
2
|
+
|
|
3
|
+
The bundle is what Claude Desktop installs with a double-click.
|
|
4
|
+
We assemble a minimal staging directory (manifest, server entry shim,
|
|
5
|
+
pyproject.toml, src/poppy) and shell out to the `mcpb` CLI to pack it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Files copied verbatim from the repo root into the staging dir.
|
|
16
|
+
ROOT_FILES = ("pyproject.toml", "README.md", "LICENSE", "uv.lock")
|
|
17
|
+
|
|
18
|
+
# Subdirectories to ignore when copying src/poppy.
|
|
19
|
+
SRC_IGNORES = ("__pycache__", ".pytest_cache", ".ruff_cache")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _copy_source(repo_root: Path, stage: Path) -> None:
|
|
23
|
+
src_dst = stage / "src" / "poppy"
|
|
24
|
+
shutil.copytree(
|
|
25
|
+
repo_root / "src" / "poppy",
|
|
26
|
+
src_dst,
|
|
27
|
+
ignore=shutil.ignore_patterns(*SRC_IGNORES),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
for name in ROOT_FILES:
|
|
31
|
+
f = repo_root / name
|
|
32
|
+
if f.exists():
|
|
33
|
+
shutil.copy2(f, stage / name)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _copy_mcpb_inputs(repo_root: Path, stage: Path) -> None:
|
|
37
|
+
mcpb_src = repo_root / "mcpb"
|
|
38
|
+
shutil.copy2(mcpb_src / "manifest.json", stage / "manifest.json")
|
|
39
|
+
shutil.copy2(mcpb_src / "icon.png", stage / "icon.png")
|
|
40
|
+
shutil.copytree(mcpb_src / "server", stage / "server")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_version(repo_root: Path) -> str:
|
|
44
|
+
pyproject = (repo_root / "pyproject.toml").read_text()
|
|
45
|
+
for line in pyproject.splitlines():
|
|
46
|
+
if line.startswith("version") and "=" in line:
|
|
47
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
48
|
+
raise RuntimeError("version not found in pyproject.toml")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sync_manifest_version(stage: Path, version: str) -> None:
|
|
52
|
+
manifest_path = stage / "manifest.json"
|
|
53
|
+
manifest = json.loads(manifest_path.read_text())
|
|
54
|
+
if manifest.get("version") != version:
|
|
55
|
+
manifest["version"] = version
|
|
56
|
+
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_mcpb(repo_root: Path, output_dir: Path) -> Path:
|
|
60
|
+
"""Build a .mcpb bundle and return the path to the produced file.
|
|
61
|
+
|
|
62
|
+
Raises RuntimeError if the `mcpb` CLI is missing or pack fails.
|
|
63
|
+
"""
|
|
64
|
+
if shutil.which("mcpb") is None:
|
|
65
|
+
raise RuntimeError("`mcpb` CLI not found — install with `npm install -g @anthropic-ai/mcpb`")
|
|
66
|
+
|
|
67
|
+
version = _read_version(repo_root)
|
|
68
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
stage = output_dir / f"poppy-memory-{version}-stage"
|
|
70
|
+
if stage.exists():
|
|
71
|
+
shutil.rmtree(stage)
|
|
72
|
+
stage.mkdir(parents=True)
|
|
73
|
+
|
|
74
|
+
_copy_mcpb_inputs(repo_root, stage)
|
|
75
|
+
_copy_source(repo_root, stage)
|
|
76
|
+
_sync_manifest_version(stage, version)
|
|
77
|
+
|
|
78
|
+
output_path = output_dir / f"poppy-memory-{version}.mcpb"
|
|
79
|
+
if output_path.exists():
|
|
80
|
+
output_path.unlink()
|
|
81
|
+
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
["mcpb", "pack", str(stage), str(output_path)],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
)
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
raise RuntimeError(f"mcpb pack failed:\n{result.stderr}\n{result.stdout}")
|
|
89
|
+
|
|
90
|
+
shutil.rmtree(stage)
|
|
91
|
+
return output_path
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Auto-capture deep modules (ADR-0001/0002/0003).
|
|
2
|
+
|
|
3
|
+
Each module here is a small, independently testable unit that the capture
|
|
4
|
+
orchestration composes:
|
|
5
|
+
|
|
6
|
+
* ``reconciler`` — dedup-on-capture: ADD / SUPERSEDE / SKIP before ingest (ADR-0003).
|
|
7
|
+
* ``window`` — incremental transcript window over ``(watermark, now]`` (ADR-0001).
|
|
8
|
+
* ``watermark`` — per-session capture watermark + turn cadence (ADR-0001).
|
|
9
|
+
|
|
10
|
+
The SessionEnd / PostCompact backstops and the mid-session loop all route their
|
|
11
|
+
extracted candidates through ``reconciler.reconcile_and_ingest`` so the store
|
|
12
|
+
stays clean no matter what triggered the capture.
|
|
13
|
+
"""
|
poppy/capture/_state.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Per-session capture state file (ADR-0001).
|
|
2
|
+
|
|
3
|
+
A single JSON file under the Poppy data directory holds the per-session capture
|
|
4
|
+
state — the watermark and, later, the turn cadence counters.
|
|
5
|
+
It is keyed by session id so concurrent sessions never corrupt each other's
|
|
6
|
+
progress.
|
|
7
|
+
|
|
8
|
+
Writes go through ``save`` which does an atomic ``os.replace`` so a crash mid-write
|
|
9
|
+
can never leave a torn file. Cross-process races within one session are prevented
|
|
10
|
+
by the mid-session single-flight lock; SessionStart / SessionEnd do not
|
|
11
|
+
run concurrently with a mid-session worker in practice.
|
|
12
|
+
|
|
13
|
+
This file is a per-device cache of capture progress — it is NOT synced (the sync
|
|
14
|
+
boundary keeps the capture journal / state local).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
STATE_FILENAME = "capture_state.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def state_path(poppy_dir: Path) -> Path:
|
|
27
|
+
return poppy_dir / STATE_FILENAME
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load(poppy_dir: Path) -> dict:
|
|
31
|
+
path = state_path(poppy_dir)
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return {}
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(path.read_text())
|
|
36
|
+
except (json.JSONDecodeError, OSError):
|
|
37
|
+
return {}
|
|
38
|
+
return data if isinstance(data, dict) else {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save(poppy_dir: Path, data: dict) -> None:
|
|
42
|
+
poppy_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
path = state_path(poppy_dir)
|
|
44
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
45
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
46
|
+
os.replace(tmp, path)
|
poppy/capture/banner.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""SessionStart status banner.
|
|
2
|
+
|
|
3
|
+
A one-line banner prepended to the SessionStart context so the developer can see
|
|
4
|
+
whether auto-capture is working. It reads the capture status (ConsolidationPolicy,
|
|
5
|
+
ADR-0002), the project memory count (engine), and the last-session capture count
|
|
6
|
+
(CaptureJournal) — the three signals that make silent background capture
|
|
7
|
+
observable rather than invisible.
|
|
8
|
+
|
|
9
|
+
The render is a pure function so the active / INACTIVE / consent-pending wording is
|
|
10
|
+
testable without a live agent session. Three rules drive it:
|
|
11
|
+
|
|
12
|
+
* **Active** (capture is running): a reassuring line with the memory count and how
|
|
13
|
+
many memories the last session captured — proof the loop runs.
|
|
14
|
+
* **INACTIVE** (consented but broken — no backend, remote-only, or the engine
|
|
15
|
+
failed to load): a *loud* line, because this is the silent-breakage case the
|
|
16
|
+
banner exists to catch (a partially-installed Poppy that quietly captures
|
|
17
|
+
nothing for weeks).
|
|
18
|
+
* **Consent pending**: a nudge pointing at ``poppy consent --enable`` — never a
|
|
19
|
+
misleading "0 captured", since nothing is captured until consent is recorded.
|
|
20
|
+
|
|
21
|
+
A deliberate off-state (explicit opt-out or a ``POPPY_CONSOLIDATE=0`` override) is
|
|
22
|
+
silent: the developer made that choice, so the banner does not nag every session.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from poppy.capture.policy import CaptureStatus
|
|
28
|
+
|
|
29
|
+
# Statuses that mean "the developer turned this off on purpose" — no banner.
|
|
30
|
+
_SILENT_STATUSES = frozenset({CaptureStatus.DISABLED_OPT_OUT, CaptureStatus.DISABLED_ENV})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _scope(project: str | None) -> str:
|
|
34
|
+
return "this project" if project else "all projects"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _memory_clause(memory_count: int, project: str | None) -> str:
|
|
38
|
+
noun = "memory" if memory_count == 1 else "memories"
|
|
39
|
+
return f"{memory_count} {noun} for {_scope(project)}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_banner(
|
|
43
|
+
status: CaptureStatus,
|
|
44
|
+
*,
|
|
45
|
+
project: str | None,
|
|
46
|
+
memory_count: int,
|
|
47
|
+
last_session_count: int | None,
|
|
48
|
+
engine_ok: bool = True,
|
|
49
|
+
) -> str | None:
|
|
50
|
+
"""Render the SessionStart banner line, or ``None`` when nothing should show.
|
|
51
|
+
|
|
52
|
+
``last_session_count`` is the journal's total for the most recent session that
|
|
53
|
+
captured anything (``None`` when nothing has ever been captured).
|
|
54
|
+
"""
|
|
55
|
+
# A broken engine means recall itself is down — the loudest INACTIVE case,
|
|
56
|
+
# independent of capture consent/backend.
|
|
57
|
+
if not engine_ok:
|
|
58
|
+
return (
|
|
59
|
+
"## Poppy: INACTIVE\n"
|
|
60
|
+
"Poppy could not load its memory engine, so recall and capture are both off. "
|
|
61
|
+
"Run `poppy doctor` to diagnose."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if status in _SILENT_STATUSES:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
if status is CaptureStatus.INERT_PENDING:
|
|
68
|
+
mem = _memory_clause(memory_count, project)
|
|
69
|
+
return (
|
|
70
|
+
"## Poppy\n"
|
|
71
|
+
f"Automatic capture is pending your consent; nothing is captured yet ({mem}). "
|
|
72
|
+
"Run `poppy consent --enable` to turn it on, or `poppy consent --disable` to dismiss."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if status in (CaptureStatus.WARN_REMOTE_ONLY, CaptureStatus.DISABLED_NO_BACKEND):
|
|
76
|
+
if status is CaptureStatus.WARN_REMOTE_ONLY:
|
|
77
|
+
why = "only a paid remote backend is configured, so capture will not auto-spend"
|
|
78
|
+
else:
|
|
79
|
+
why = "no extraction backend was found"
|
|
80
|
+
return (
|
|
81
|
+
"## Poppy: INACTIVE\n"
|
|
82
|
+
f"Auto-capture is on but {why}, so nothing is being captured. "
|
|
83
|
+
"Install a host CLI (claude / codex / gemini) for free local capture, then `poppy doctor` to verify."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ACTIVE / FORCED_ENV — capture is running.
|
|
87
|
+
line = f"## Poppy: active\nRemembering as you work · {_memory_clause(memory_count, project)}"
|
|
88
|
+
if last_session_count is not None:
|
|
89
|
+
noun = "memory" if last_session_count == 1 else "memories"
|
|
90
|
+
line += f" · {last_session_count} {noun} captured last session"
|
|
91
|
+
line += "."
|
|
92
|
+
return line
|
poppy/capture/cadence.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""TurnCadence — per-session turn counter + cadence gate (ADR-0001).
|
|
2
|
+
|
|
3
|
+
The mid-session loop fires a capture every Nth user turn. This module owns the
|
|
4
|
+
per-session turn counter and the cadence / soft-cap gates. State lives in the
|
|
5
|
+
shared per-session file (``_state``); the counters reset at SessionStart via
|
|
6
|
+
``watermark.reset_session`` so cadence and coverage are predictable per session
|
|
7
|
+
and concurrent sessions never interfere.
|
|
8
|
+
|
|
9
|
+
Defaults (N / K) are sane starting points; ADR-0003's autoresearch pass tunes
|
|
10
|
+
them later.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from poppy.capture import _state
|
|
18
|
+
|
|
19
|
+
# Fire a capture every Nth user turn.
|
|
20
|
+
DEFAULT_CADENCE_N = 3
|
|
21
|
+
|
|
22
|
+
# Per-session soft cap: after K mid-session captures, stop firing and let the
|
|
23
|
+
# SessionEnd backstop flush the remainder (defers, never drops).
|
|
24
|
+
DEFAULT_SOFT_CAP_K = 20
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_turn(poppy_dir: Path, session_id: str) -> int:
|
|
28
|
+
"""Increment and return this session's turn count."""
|
|
29
|
+
data = _state.load(poppy_dir)
|
|
30
|
+
entry = data.setdefault(session_id, {})
|
|
31
|
+
try:
|
|
32
|
+
turns = int(entry.get("turns", 0))
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
turns = 0
|
|
35
|
+
turns += 1
|
|
36
|
+
entry["turns"] = turns
|
|
37
|
+
_state.save(poppy_dir, data)
|
|
38
|
+
return turns
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def should_capture(count: int, *, n: int = DEFAULT_CADENCE_N) -> bool:
|
|
42
|
+
"""True on every Nth turn (3, 6, 9, ... for n=3)."""
|
|
43
|
+
return count > 0 and n > 0 and count % n == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def capture_count(poppy_dir: Path, session_id: str) -> int:
|
|
47
|
+
"""How many mid-session captures have fired for this session."""
|
|
48
|
+
entry = _state.load(poppy_dir).get(session_id, {})
|
|
49
|
+
try:
|
|
50
|
+
return int(entry.get("captures", 0))
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def soft_cap_reached(poppy_dir: Path, session_id: str, *, k: int = DEFAULT_SOFT_CAP_K) -> bool:
|
|
56
|
+
"""True once the per-session soft cap of mid-session captures is hit."""
|
|
57
|
+
return capture_count(poppy_dir, session_id) >= k
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def record_capture(poppy_dir: Path, session_id: str) -> int:
|
|
61
|
+
"""Count one completed mid-session capture; returns the new total."""
|
|
62
|
+
data = _state.load(poppy_dir)
|
|
63
|
+
entry = data.setdefault(session_id, {})
|
|
64
|
+
try:
|
|
65
|
+
captures = int(entry.get("captures", 0))
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
captures = 0
|
|
68
|
+
captures += 1
|
|
69
|
+
entry["captures"] = captures
|
|
70
|
+
_state.save(poppy_dir, data)
|
|
71
|
+
return captures
|
poppy/capture/journal.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""CaptureJournal — local record of what each capture stored.
|
|
2
|
+
|
|
3
|
+
One local source of truth for the SessionStart banner, ``poppy doctor``, and the
|
|
4
|
+
dashboard: each capture appends a record with the session, project, count, and a
|
|
5
|
+
short preview of each stored item. It is a per-device cache of already-synced
|
|
6
|
+
data, so it stays local (it is NOT synced — see the sync boundary).
|
|
7
|
+
|
|
8
|
+
Banner/doctor surfaces that read this are out of scope for the capture slices;
|
|
9
|
+
this module exists so the capture worker has one place to record provenance the
|
|
10
|
+
later surfaces will read.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
JOURNAL_FILENAME = "capture_journal.jsonl"
|
|
22
|
+
PREVIEW_CHARS = 80
|
|
23
|
+
MAX_ENTRIES = 200
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CaptureRecord:
|
|
28
|
+
ts: str
|
|
29
|
+
session_id: str
|
|
30
|
+
project: str | None
|
|
31
|
+
count: int
|
|
32
|
+
items: list[dict[str, str]] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _journal_path(poppy_dir: Path) -> Path:
|
|
36
|
+
return poppy_dir / JOURNAL_FILENAME
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _preview(text: str) -> str:
|
|
40
|
+
text = " ".join((text or "").split())
|
|
41
|
+
return text[:PREVIEW_CHARS]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def record(
|
|
45
|
+
poppy_dir: Path,
|
|
46
|
+
*,
|
|
47
|
+
session_id: str,
|
|
48
|
+
project: str | None,
|
|
49
|
+
count: int,
|
|
50
|
+
items: list[Any],
|
|
51
|
+
max_entries: int = MAX_ENTRIES,
|
|
52
|
+
) -> CaptureRecord:
|
|
53
|
+
"""Append a capture record. ``items`` are objects with ``.memory_type`` and
|
|
54
|
+
``.content`` (Memory) — only the type and a short preview are stored."""
|
|
55
|
+
rec = CaptureRecord(
|
|
56
|
+
ts=datetime.datetime.now(datetime.UTC).isoformat(),
|
|
57
|
+
session_id=session_id,
|
|
58
|
+
project=project,
|
|
59
|
+
count=count,
|
|
60
|
+
items=[
|
|
61
|
+
{"type": getattr(it, "memory_type", "fact"), "preview": _preview(getattr(it, "content", ""))}
|
|
62
|
+
for it in items
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
poppy_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
path = _journal_path(poppy_dir)
|
|
68
|
+
existing: list[str] = []
|
|
69
|
+
if path.exists():
|
|
70
|
+
existing = [ln for ln in path.read_text().splitlines() if ln.strip()]
|
|
71
|
+
existing.append(json.dumps(rec.__dict__))
|
|
72
|
+
existing = existing[-max_entries:]
|
|
73
|
+
path.write_text("\n".join(existing) + "\n")
|
|
74
|
+
return rec
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_all(poppy_dir: Path) -> list[dict[str, Any]]:
|
|
78
|
+
"""Every journal record as raw dicts, oldest first. Skips unparseable lines.
|
|
79
|
+
|
|
80
|
+
Public accessor for surfaces that need the whole journal (e.g. the doctor's
|
|
81
|
+
journal-count line); most callers want :func:`read_last` instead.
|
|
82
|
+
"""
|
|
83
|
+
path = _journal_path(poppy_dir)
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return []
|
|
86
|
+
out: list[dict[str, Any]] = []
|
|
87
|
+
for ln in path.read_text().splitlines():
|
|
88
|
+
ln = ln.strip()
|
|
89
|
+
if not ln:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
out.append(json.loads(ln))
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
continue
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_last(poppy_dir: Path) -> CaptureRecord | None:
|
|
99
|
+
"""The most recent capture record, or None if nothing captured yet."""
|
|
100
|
+
rows = read_all(poppy_dir)
|
|
101
|
+
if not rows:
|
|
102
|
+
return None
|
|
103
|
+
data = rows[-1]
|
|
104
|
+
return CaptureRecord(
|
|
105
|
+
ts=data.get("ts", ""),
|
|
106
|
+
session_id=data.get("session_id", ""),
|
|
107
|
+
project=data.get("project"),
|
|
108
|
+
count=int(data.get("count", 0)),
|
|
109
|
+
items=data.get("items", []),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def last_session_count(poppy_dir: Path) -> int | None:
|
|
114
|
+
"""Total memories captured in the most recent session that captured anything.
|
|
115
|
+
|
|
116
|
+
A session can produce several capture records (each mid-session fire plus the
|
|
117
|
+
SessionEnd backstop), so the banner's "M captured last session" sums the counts
|
|
118
|
+
of every record sharing the latest record's ``session_id``. Returns ``None``
|
|
119
|
+
when nothing has been captured yet, so the banner can omit the clause rather
|
|
120
|
+
than print a misleading "0 captured".
|
|
121
|
+
"""
|
|
122
|
+
rows = read_all(poppy_dir)
|
|
123
|
+
if not rows:
|
|
124
|
+
return None
|
|
125
|
+
last_session = rows[-1].get("session_id")
|
|
126
|
+
return sum(int(r.get("count", 0)) for r in rows if r.get("session_id") == last_session)
|
poppy/capture/lock.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Single-flight capture lock.
|
|
2
|
+
|
|
3
|
+
Never two capture workers for the same session at once: a fast typer can stack
|
|
4
|
+
UserPromptSubmit fires, and overlapping extractions would double-read turns and
|
|
5
|
+
race on the host CLI's auth/lock state (a known cause of silent 0-memory
|
|
6
|
+
extractions). Each capture worker acquires a per-session lock for its whole run;
|
|
7
|
+
a worker that cannot acquire it skips the fire — the watermark catches up next
|
|
8
|
+
time.
|
|
9
|
+
|
|
10
|
+
The lock is an ``O_CREAT | O_EXCL`` lock file under the Poppy directory. A lock
|
|
11
|
+
older than ``LOCK_TTL_S`` is treated as stale (a crashed worker) and stolen, so a
|
|
12
|
+
crash can never wedge capture permanently.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import errno
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# A held lock older than this (seconds) is assumed to belong to a crashed worker
|
|
26
|
+
# and is stolen. Comfortably longer than a capture's host-CLI timeout (120s).
|
|
27
|
+
LOCK_TTL_S = 300
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lock_path(poppy_dir: Path, session_id: str) -> Path:
|
|
31
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session_id) or "session"
|
|
32
|
+
return poppy_dir / f"capture-{safe}.lock"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _try_open(path: Path) -> int | None:
|
|
36
|
+
try:
|
|
37
|
+
return os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
if exc.errno != errno.EEXIST:
|
|
40
|
+
return None
|
|
41
|
+
# Lock exists — steal it only if it is stale (crashed worker).
|
|
42
|
+
try:
|
|
43
|
+
age = time.time() - path.stat().st_mtime
|
|
44
|
+
except OSError:
|
|
45
|
+
return None
|
|
46
|
+
if age <= LOCK_TTL_S:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
path.unlink()
|
|
50
|
+
except OSError:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
54
|
+
except OSError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def single_flight(poppy_dir: Path, session_id: str) -> Iterator[bool]:
|
|
60
|
+
"""Hold the per-session capture lock for the duration of the block.
|
|
61
|
+
|
|
62
|
+
Yields ``True`` if the lock was acquired (caller should do the capture) or
|
|
63
|
+
``False`` if another worker holds it (caller should skip). Always releases a
|
|
64
|
+
lock it acquired, even on error.
|
|
65
|
+
"""
|
|
66
|
+
poppy_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
path = _lock_path(poppy_dir, session_id)
|
|
68
|
+
fd = _try_open(path)
|
|
69
|
+
acquired = fd is not None
|
|
70
|
+
if fd is not None:
|
|
71
|
+
os.close(fd)
|
|
72
|
+
try:
|
|
73
|
+
yield acquired
|
|
74
|
+
finally:
|
|
75
|
+
if acquired:
|
|
76
|
+
try:
|
|
77
|
+
path.unlink()
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|