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.
Files changed (65) hide show
  1. poppy/__init__.py +27 -0
  2. poppy/build_mcpb.py +91 -0
  3. poppy/capture/__init__.py +13 -0
  4. poppy/capture/_state.py +46 -0
  5. poppy/capture/banner.py +92 -0
  6. poppy/capture/cadence.py +71 -0
  7. poppy/capture/journal.py +126 -0
  8. poppy/capture/lock.py +79 -0
  9. poppy/capture/policy.py +163 -0
  10. poppy/capture/reconciler.py +169 -0
  11. poppy/capture/watermark.py +43 -0
  12. poppy/capture/window.py +134 -0
  13. poppy/cli/__init__.py +0 -0
  14. poppy/cli/hooks.py +753 -0
  15. poppy/cli/main.py +1539 -0
  16. poppy/config.py +229 -0
  17. poppy/conflict_detection.py +210 -0
  18. poppy/consolidation.py +565 -0
  19. poppy/db.py +43 -0
  20. poppy/engine/__init__.py +3 -0
  21. poppy/engine/_model_cache.py +55 -0
  22. poppy/engine/_st_loader.py +68 -0
  23. poppy/engine/bloom.py +588 -0
  24. poppy/engine/interface.py +60 -0
  25. poppy/engine/migration.py +273 -0
  26. poppy/engine/registry.py +102 -0
  27. poppy/engine/seed.py +321 -0
  28. poppy/engine/sprout.py +374 -0
  29. poppy/errors.py +14 -0
  30. poppy/integrations/claude_memory_import.py +145 -0
  31. poppy/integrations/hermes_memory_import.py +121 -0
  32. poppy/lifecycle.py +210 -0
  33. poppy/mcp_server/__init__.py +0 -0
  34. poppy/mcp_server/server.py +518 -0
  35. poppy/models.py +38 -0
  36. poppy/runtime.py +75 -0
  37. poppy/setup/__init__.py +0 -0
  38. poppy/setup/claude_code.py +439 -0
  39. poppy/setup/goose.py +184 -0
  40. poppy/setup/hermes.py +558 -0
  41. poppy/setup/trags.py +142 -0
  42. poppy/sources.py +86 -0
  43. poppy/sync/__init__.py +299 -0
  44. poppy/sync/auto.py +226 -0
  45. poppy/sync/client.py +143 -0
  46. poppy/sync/serializer.py +94 -0
  47. poppy/sync/state.py +76 -0
  48. poppy/telemetry.py +217 -0
  49. poppy/ui/__init__.py +6 -0
  50. poppy/ui/server.py +489 -0
  51. poppy/ui/static/app.js +944 -0
  52. poppy/ui/static/fonts/Fraunces-Italic-latin.woff2 +0 -0
  53. poppy/ui/static/fonts/Fraunces-latin.woff2 +0 -0
  54. poppy/ui/static/fonts/JetBrainsMono-Italic-latin.woff2 +0 -0
  55. poppy/ui/static/fonts/JetBrainsMono-latin.woff2 +0 -0
  56. poppy/ui/static/fonts/OFL-Fraunces.txt +93 -0
  57. poppy/ui/static/fonts/OFL-JetBrainsMono.txt +93 -0
  58. poppy/ui/static/index.html +101 -0
  59. poppy/ui/static/styles.css +809 -0
  60. poppy/ui/tombstones.py +147 -0
  61. poppy_memory-0.2.0.dist-info/METADATA +269 -0
  62. poppy_memory-0.2.0.dist-info/RECORD +65 -0
  63. poppy_memory-0.2.0.dist-info/WHEEL +4 -0
  64. poppy_memory-0.2.0.dist-info/entry_points.txt +2 -0
  65. 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
+ """
@@ -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)
@@ -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
@@ -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
@@ -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