switchroom 0.12.26 → 0.12.28

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 (57) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/drive-write-pretool.mjs +10 -10
  4. package/dist/cli/skill-validate-pretool.mjs +72 -72
  5. package/dist/cli/switchroom.js +359 -357
  6. package/dist/host-control/main.js +99 -99
  7. package/dist/vault/approvals/kernel-server.js +82 -82
  8. package/dist/vault/broker/server.js +83 -83
  9. package/package.json +2 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  11. package/telegram-plugin/dist/gateway/gateway.js +368 -209
  12. package/telegram-plugin/dist/server.js +160 -160
  13. package/telegram-plugin/gateway/gateway.ts +55 -40
  14. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
  15. package/telegram-plugin/stderr-timestamps.ts +106 -0
  16. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
  17. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  18. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  19. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  20. package/vendor/hindsight-memory/LICENSE +21 -0
  21. package/vendor/hindsight-memory/README.md +329 -0
  22. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  23. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  24. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  25. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  26. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  27. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  28. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  29. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  30. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  31. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  32. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  33. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  34. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  36. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  37. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  38. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  39. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  40. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  44. package/vendor/hindsight-memory/settings.json +37 -0
  45. package/vendor/hindsight-memory/skills/setup.md +24 -0
  46. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  47. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  48. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  49. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  50. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  51. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  52. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  53. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  54. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  55. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  56. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  57. package/vendor/hindsight-memory/tests/test_state.py +125 -0
@@ -0,0 +1,146 @@
1
+ """LLM provider detection for Hindsight's fact extraction.
2
+
3
+ Port of: detectLLMConfig() in index.js
4
+
5
+ When running hindsight-embed locally (daemon mode), it needs an LLM to
6
+ extract facts from retained conversations. This module detects the LLM
7
+ config using the same priority chain as Openclaw:
8
+
9
+ 1. HINDSIGHT_API_LLM_* environment variables (highest priority)
10
+ 2. Plugin config (llmProvider, llmModel, llmApiKeyEnv)
11
+ 3. Auto-detect from standard provider env vars
12
+ 4. External API mode (server-side LLM, no local config needed)
13
+ """
14
+
15
+ import os
16
+
17
+ # Provider detection table — same order as Openclaw
18
+ PROVIDER_DETECTION = [
19
+ {"name": "openai", "key_env": "OPENAI_API_KEY"},
20
+ {"name": "anthropic", "key_env": "ANTHROPIC_API_KEY"},
21
+ {"name": "gemini", "key_env": "GEMINI_API_KEY"},
22
+ {"name": "groq", "key_env": "GROQ_API_KEY"},
23
+ {"name": "ollama", "key_env": ""},
24
+ {"name": "openai-codex", "key_env": ""},
25
+ {"name": "claude-code", "key_env": ""},
26
+ ]
27
+
28
+ # Providers that don't require an API key
29
+ NO_KEY_REQUIRED = {"ollama", "openai-codex", "claude-code"}
30
+
31
+
32
+ def _find_provider(name):
33
+ """Find a provider entry by name."""
34
+ for p in PROVIDER_DETECTION:
35
+ if p["name"] == name:
36
+ return p
37
+ return None
38
+
39
+
40
+ def detect_llm_config(config: dict) -> dict:
41
+ """Detect LLM configuration.
42
+
43
+ Returns dict with: provider, api_key, model, base_url, source.
44
+ Returns None values for external API mode (server handles LLM).
45
+ Raises RuntimeError if no configuration found and not in external API mode.
46
+ """
47
+ override_provider = os.environ.get("HINDSIGHT_API_LLM_PROVIDER")
48
+ override_model = os.environ.get("HINDSIGHT_API_LLM_MODEL")
49
+ override_key = os.environ.get("HINDSIGHT_API_LLM_API_KEY")
50
+ override_base_url = os.environ.get("HINDSIGHT_API_LLM_BASE_URL")
51
+
52
+ # Priority 1: HINDSIGHT_API_LLM_PROVIDER env var
53
+ if override_provider:
54
+ if not override_key and override_provider not in NO_KEY_REQUIRED:
55
+ raise RuntimeError(
56
+ f'HINDSIGHT_API_LLM_PROVIDER is set to "{override_provider}" but HINDSIGHT_API_LLM_API_KEY is not set.'
57
+ )
58
+ pinfo = _find_provider(override_provider)
59
+ return {
60
+ "provider": override_provider,
61
+ "api_key": override_key or "",
62
+ "model": override_model,
63
+ "base_url": override_base_url,
64
+ "source": "HINDSIGHT_API_LLM_PROVIDER override",
65
+ }
66
+
67
+ # Priority 2: Plugin config llmProvider/llmModel
68
+ cfg_provider = config.get("llmProvider")
69
+ if cfg_provider:
70
+ pinfo = _find_provider(cfg_provider)
71
+ api_key = ""
72
+ key_env_name = config.get("llmApiKeyEnv")
73
+ if key_env_name:
74
+ api_key = os.environ.get(key_env_name, "")
75
+ elif pinfo and pinfo["key_env"]:
76
+ api_key = os.environ.get(pinfo["key_env"], "")
77
+
78
+ if not api_key and cfg_provider not in NO_KEY_REQUIRED:
79
+ key_source = key_env_name or (pinfo["key_env"] if pinfo else "unknown")
80
+ raise RuntimeError(
81
+ f'Plugin config llmProvider is "{cfg_provider}" but no API key found. Expected env var: {key_source}'
82
+ )
83
+ return {
84
+ "provider": cfg_provider,
85
+ "api_key": api_key,
86
+ "model": config.get("llmModel") or override_model,
87
+ "base_url": override_base_url,
88
+ "source": "plugin config",
89
+ }
90
+
91
+ # Priority 3: Auto-detect from standard provider env vars
92
+ for pinfo in PROVIDER_DETECTION:
93
+ if pinfo["name"] in NO_KEY_REQUIRED:
94
+ continue # Must be explicitly requested
95
+ if not pinfo["key_env"]:
96
+ continue
97
+ api_key = os.environ.get(pinfo["key_env"], "")
98
+ if api_key:
99
+ return {
100
+ "provider": pinfo["name"],
101
+ "api_key": api_key,
102
+ "model": override_model,
103
+ "base_url": override_base_url,
104
+ "source": f"auto-detected from {pinfo['key_env']}",
105
+ }
106
+
107
+ # Priority 4: External API mode — server handles LLM
108
+ if config.get("hindsightApiUrl"):
109
+ return {
110
+ "provider": None,
111
+ "api_key": None,
112
+ "model": None,
113
+ "base_url": None,
114
+ "source": "external-api-mode-no-llm",
115
+ }
116
+
117
+ raise RuntimeError(
118
+ "No LLM configuration found for Hindsight.\n\n"
119
+ "Option 1: Set a standard provider API key (auto-detect):\n"
120
+ " export OPENAI_API_KEY=sk-your-key\n"
121
+ " export ANTHROPIC_API_KEY=your-key\n\n"
122
+ "Option 2: Override with Hindsight-specific env vars:\n"
123
+ " export HINDSIGHT_API_LLM_PROVIDER=openai\n"
124
+ " export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n\n"
125
+ "Option 3: Use an external Hindsight API (server-side LLM):\n"
126
+ " Set hindsightApiUrl in settings.json or HINDSIGHT_API_URL env var\n\n"
127
+ "The model will be selected automatically by Hindsight. To override: export HINDSIGHT_API_LLM_MODEL=your-model"
128
+ )
129
+
130
+
131
+ def get_llm_env_vars(llm_config: dict) -> dict:
132
+ """Build environment variables for hindsight-embed daemon from LLM config.
133
+
134
+ These are passed to the daemon subprocess so it knows which LLM to use
135
+ for fact extraction.
136
+ """
137
+ env = {}
138
+ if llm_config.get("provider"):
139
+ env["HINDSIGHT_API_LLM_PROVIDER"] = llm_config["provider"]
140
+ if llm_config.get("api_key"):
141
+ env["HINDSIGHT_API_LLM_API_KEY"] = llm_config["api_key"]
142
+ if llm_config.get("model"):
143
+ env["HINDSIGHT_API_LLM_MODEL"] = llm_config["model"]
144
+ if llm_config.get("base_url"):
145
+ env["HINDSIGHT_API_LLM_BASE_URL"] = llm_config["base_url"]
146
+ return env
@@ -0,0 +1,218 @@
1
+ """Persistent queue for failed retain payloads.
2
+
3
+ When a SessionEnd retain fails, the only on-disk record of the turn's
4
+ memory is the just-closed transcript — and the agent thinks it was
5
+ persisted. To prevent silent data loss (#1071), session_end.py
6
+ serializes the *exact retain payload* it would have POSTed into
7
+ ``~/.hindsight/pending-retains/<unix-ms>-<short-uuid>.json``. The next
8
+ SessionStart drains the directory: oldest first, success deletes,
9
+ failure bumps an attempt counter (up to MAX_ATTEMPTS) and leaves the
10
+ entry for the run after that.
11
+
12
+ Layout
13
+ ------
14
+ ``~/.hindsight/pending-retains/`` (mode 0700, may contain sensitive
15
+ memory payloads).
16
+
17
+ Inside a Switchroom docker agent, ``$HOME`` is the agent UID's home
18
+ inside the container, which is NOT a bind-mounted volume. The queue
19
+ therefore survives session-to-session within a container's lifetime
20
+ (the common case: claude session ends → container keeps running → next
21
+ session drains) but NOT container recreate. That's deliberate: this is
22
+ a rescue queue for transient retain failures, not a long-term DLQ.
23
+ If the upstream is broken long enough that the agent container gets
24
+ recreated, the operator has bigger problems and ``switchroom doctor``
25
+ already surfaced the backlog.
26
+ Each entry is a JSON file ``<unix-ms>-<short-uuid>.json`` containing::
27
+
28
+ {
29
+ "schema": 1,
30
+ "api_url": "<resolved Hindsight URL at time of failure>",
31
+ "api_token": "<bearer token or null>",
32
+ "bank_id": "<derived bank id>",
33
+ "document_id": "<retain document id>",
34
+ "content": "<formatted transcript>",
35
+ "context": "<retainContext>",
36
+ "metadata": {...},
37
+ "tags": [...] or null,
38
+ "failed_at": "<ISO-8601 UTC>",
39
+ "error_class": "<exception class name>",
40
+ "error_message": "<str(e)>",
41
+ "attempt_count": 1
42
+ }
43
+
44
+ The file is written via ``write tmp + rename`` so concurrent agents
45
+ sharing ``$HOME`` (legacy installs) never observe a half-written entry.
46
+
47
+ Bounded directory
48
+ -----------------
49
+ ``MAX_ENTRIES`` (1000) caps the queue. When full, ``enqueue()`` refuses
50
+ the entry and returns ``None`` — the caller logs loudly and the operator
51
+ is expected to drain manually. A chronically full queue means upstream
52
+ is broken for a long time; piling on more entries doesn't help.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ import os
59
+ import time
60
+ import uuid
61
+ from typing import Optional
62
+
63
+
64
+ SCHEMA = 1
65
+ MAX_ENTRIES = 1000
66
+ MAX_ATTEMPTS = 5
67
+
68
+
69
+ def pending_dir() -> str:
70
+ """Return the pending-retains directory path.
71
+
72
+ Override with ``HINDSIGHT_PENDING_DIR`` for tests. Default:
73
+ ``$HOME/.hindsight/pending-retains/``.
74
+ """
75
+ override = os.environ.get("HINDSIGHT_PENDING_DIR")
76
+ if override:
77
+ return override
78
+ return os.path.join(os.path.expanduser("~"), ".hindsight", "pending-retains")
79
+
80
+
81
+ def _ensure_dir() -> str:
82
+ """Create the queue dir with mode 0700 if missing. Return its path."""
83
+ d = pending_dir()
84
+ if not os.path.isdir(d):
85
+ os.makedirs(d, mode=0o700, exist_ok=True)
86
+ else:
87
+ # Tighten perms if a previous run created it with looser bits.
88
+ try:
89
+ mode = os.stat(d).st_mode & 0o777
90
+ if mode != 0o700:
91
+ os.chmod(d, 0o700)
92
+ except OSError:
93
+ pass
94
+ return d
95
+
96
+
97
+ def _list_entries(d: str) -> list[str]:
98
+ """Return sorted filenames (oldest first by lexicographic order on
99
+ the ``<unix-ms>-<uuid>.json`` filename pattern).
100
+ """
101
+ try:
102
+ names = [n for n in os.listdir(d) if n.endswith(".json")]
103
+ except FileNotFoundError:
104
+ return []
105
+ names.sort()
106
+ return names
107
+
108
+
109
+ def count() -> int:
110
+ """Number of pending entries. Safe to call when dir doesn't exist."""
111
+ d = pending_dir()
112
+ return len(_list_entries(d))
113
+
114
+
115
+ def enqueue(payload: dict, error: BaseException) -> Optional[str]:
116
+ """Persist a failed retain payload.
117
+
118
+ ``payload`` carries the exact arguments that would have gone to
119
+ ``client.retain()`` plus connection info (``api_url``, ``api_token``)
120
+ so the drainer can rebuild the client without re-resolving config.
121
+
122
+ Returns the absolute path of the written entry, or ``None`` if the
123
+ queue is full (``MAX_ENTRIES`` reached). Atomic: writes ``<name>.tmp``
124
+ then renames to ``<name>``.
125
+ """
126
+ d = _ensure_dir()
127
+ if len(_list_entries(d)) >= MAX_ENTRIES:
128
+ return None
129
+
130
+ entry = dict(payload)
131
+ entry["schema"] = SCHEMA
132
+ entry["failed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
133
+ entry["error_class"] = type(error).__name__
134
+ entry["error_message"] = str(error)
135
+ entry.setdefault("attempt_count", 1)
136
+
137
+ ts_ms = int(time.time() * 1000)
138
+ short_uuid = uuid.uuid4().hex[:12]
139
+ name = f"{ts_ms}-{short_uuid}.json"
140
+ final = os.path.join(d, name)
141
+ tmp = final + ".tmp"
142
+
143
+ with open(tmp, "w", encoding="utf-8") as f:
144
+ json.dump(entry, f, ensure_ascii=False)
145
+ os.chmod(tmp, 0o600)
146
+ os.rename(tmp, final)
147
+ return final
148
+
149
+
150
+ def iter_entries() -> list[tuple[str, dict]]:
151
+ """Return ``[(path, entry_dict), ...]`` oldest first.
152
+
153
+ Unreadable / malformed files are skipped silently — the drainer
154
+ handles its own logging. We never crash the SessionStart hook on
155
+ a corrupt entry.
156
+ """
157
+ d = pending_dir()
158
+ out: list[tuple[str, dict]] = []
159
+ for name in _list_entries(d):
160
+ p = os.path.join(d, name)
161
+ try:
162
+ with open(p, encoding="utf-8") as f:
163
+ out.append((p, json.load(f)))
164
+ except (OSError, json.JSONDecodeError):
165
+ continue
166
+ return out
167
+
168
+
169
+ def delete_entry(path: str) -> bool:
170
+ """Remove a queue entry. Returns True on success, False otherwise."""
171
+ try:
172
+ os.remove(path)
173
+ return True
174
+ except OSError:
175
+ return False
176
+
177
+
178
+ def update_attempt(path: str, entry: dict, error: BaseException) -> bool:
179
+ """Persist an updated attempt count + error info back to ``path``.
180
+
181
+ Atomic: writes ``<path>.tmp`` then renames. Returns True on success.
182
+ """
183
+ entry["attempt_count"] = int(entry.get("attempt_count", 1)) + 1
184
+ entry["last_attempt_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
185
+ entry["error_class"] = type(error).__name__
186
+ entry["error_message"] = str(error)
187
+ try:
188
+ tmp = path + ".tmp"
189
+ with open(tmp, "w", encoding="utf-8") as f:
190
+ json.dump(entry, f, ensure_ascii=False)
191
+ os.chmod(tmp, 0o600)
192
+ os.rename(tmp, path)
193
+ return True
194
+ except OSError:
195
+ return False
196
+
197
+
198
+ def mark_dead(path: str, entry: dict) -> Optional[str]:
199
+ """Convert an entry that exceeded ``MAX_ATTEMPTS`` into a permanent
200
+ failure marker. Renames ``<path>`` to ``<path>.dead`` so the queue
201
+ no longer drains it but operators can still inspect.
202
+
203
+ Returns the marker path, or ``None`` if the rename failed.
204
+ """
205
+ entry["dead_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
206
+ dead_path = path + ".dead"
207
+ try:
208
+ # Best-effort: write the final state first so the marker shows
209
+ # the death timestamp + last error.
210
+ tmp = path + ".tmp"
211
+ with open(tmp, "w", encoding="utf-8") as f:
212
+ json.dump(entry, f, ensure_ascii=False)
213
+ os.chmod(tmp, 0o600)
214
+ os.rename(tmp, path)
215
+ os.rename(path, dead_path)
216
+ return dead_path
217
+ except OSError:
218
+ return None
@@ -0,0 +1,196 @@
1
+ """File-based state persistence.
2
+
3
+ Claude Code hooks are ephemeral processes — state must be persisted to files.
4
+ Uses $CLAUDE_PLUGIN_DATA/state/ as the storage directory.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+
12
+ # fcntl is Unix-only; import conditionally so the module loads on Windows
13
+ if sys.platform != "win32":
14
+ import fcntl
15
+ else:
16
+ fcntl = None
17
+
18
+
19
+ def _state_dir() -> str:
20
+ """Get the state directory, creating it if needed."""
21
+ plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA", "")
22
+ if not plugin_data:
23
+ # Fallback to a temp location for testing
24
+ plugin_data = os.path.join(os.path.expanduser("~"), ".claude", "plugins", "data", "hindsight-memory")
25
+ state_dir = os.path.join(plugin_data, "state")
26
+ os.makedirs(state_dir, exist_ok=True)
27
+ return state_dir
28
+
29
+
30
+ def _safe_filename(name: str) -> str:
31
+ """Sanitize a filename to prevent path traversal.
32
+
33
+ Strips path separators, .., and control characters. Mirrors Openclaw's
34
+ sanitizeFilename().
35
+ """
36
+ # Replace path separators and dangerous patterns
37
+ name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", name)
38
+ # Collapse .. to prevent traversal
39
+ name = name.replace("..", "_")
40
+ # Limit length
41
+ name = name[:200]
42
+ return name or "state"
43
+
44
+
45
+ def _state_file(name: str) -> str:
46
+ """Get path for a state file. Name is sanitized to prevent traversal."""
47
+ safe = _safe_filename(name)
48
+ path = os.path.join(_state_dir(), safe)
49
+ # Final guard: resolved path must be inside state_dir
50
+ resolved = os.path.realpath(path)
51
+ expected_dir = os.path.realpath(_state_dir())
52
+ if not resolved.startswith(expected_dir + os.sep) and resolved != expected_dir:
53
+ raise ValueError(f"State file path escapes state directory: {name!r}")
54
+ return path
55
+
56
+
57
+ def read_state(name: str, default=None):
58
+ """Read a JSON state file. Returns default if not found."""
59
+ path = _state_file(name)
60
+ if not os.path.exists(path):
61
+ return default
62
+ try:
63
+ with open(path) as f:
64
+ return json.load(f)
65
+ except (json.JSONDecodeError, OSError):
66
+ return default
67
+
68
+
69
+ def write_state(name: str, data):
70
+ """Write data to a JSON state file atomically."""
71
+ path = _state_file(name)
72
+ tmp_path = path + ".tmp"
73
+ try:
74
+ with open(tmp_path, "w") as f:
75
+ json.dump(data, f)
76
+ os.replace(tmp_path, path)
77
+ except OSError:
78
+ # Best-effort cleanup
79
+ try:
80
+ os.unlink(tmp_path)
81
+ except OSError:
82
+ pass
83
+
84
+
85
+ def get_turn_count(session_id: str) -> int:
86
+ """Get the current turn count for a session."""
87
+ turns = read_state("turns.json", {})
88
+ return turns.get(session_id, 0)
89
+
90
+
91
+ def increment_turn_count(session_id: str) -> int:
92
+ """Increment and return the turn count for a session.
93
+
94
+ Uses flock on Unix to prevent race conditions between concurrent hook
95
+ processes (e.g. async Stop + new UserPromptSubmit). On Windows, flock is
96
+ unavailable so we proceed without a lock — minor races here are harmless.
97
+ """
98
+ lock_path = _state_file("turns.lock")
99
+ if fcntl is not None:
100
+ try:
101
+ lock_fd = open(lock_path, "w")
102
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
103
+ try:
104
+ turns = read_state("turns.json", {})
105
+ turns[session_id] = turns.get(session_id, 0) + 1
106
+ # Cap tracked sessions to prevent unbounded growth
107
+ if len(turns) > 10000:
108
+ sorted_keys = sorted(turns.keys())
109
+ for k in sorted_keys[: len(sorted_keys) // 2]:
110
+ del turns[k]
111
+ write_state("turns.json", turns)
112
+ return turns[session_id]
113
+ finally:
114
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
115
+ lock_fd.close()
116
+ except OSError:
117
+ pass
118
+
119
+ # Fallback: proceed without lock (Windows or lock acquisition failed)
120
+ turns = read_state("turns.json", {})
121
+ turns[session_id] = turns.get(session_id, 0) + 1
122
+ # Cap tracked sessions to prevent unbounded growth
123
+ if len(turns) > 10000:
124
+ sorted_keys = sorted(turns.keys())
125
+ for k in sorted_keys[: len(sorted_keys) // 2]:
126
+ del turns[k]
127
+ write_state("turns.json", turns)
128
+ return turns[session_id]
129
+
130
+
131
+ def _locked_read_modify_write(state_name: str, lock_name: str, modify_fn):
132
+ """Read-modify-write a state file under flock.
133
+
134
+ modify_fn receives the current state dict and returns (updated_dict, result).
135
+ Returns the result from modify_fn.
136
+ """
137
+ lock_path = _state_file(lock_name)
138
+ if fcntl is not None:
139
+ try:
140
+ lock_fd = open(lock_path, "w")
141
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
142
+ try:
143
+ data = read_state(state_name, {})
144
+ data, result = modify_fn(data)
145
+ write_state(state_name, data)
146
+ return result
147
+ finally:
148
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
149
+ lock_fd.close()
150
+ except OSError:
151
+ pass
152
+
153
+ # Fallback without lock
154
+ data = read_state(state_name, {})
155
+ data, result = modify_fn(data)
156
+ write_state(state_name, data)
157
+ return result
158
+
159
+
160
+ def track_retention(session_id: str, message_count: int) -> tuple:
161
+ """Track retention state and detect compaction.
162
+
163
+ Compares the current message count against the last retained count for this
164
+ session. When the transcript shrinks (compaction), increments a chunk counter
165
+ so the caller can use a distinct document_id, preserving the pre-compaction
166
+ document.
167
+
168
+ Returns:
169
+ (chunk_index, compacted) — chunk_index for building document_id,
170
+ compacted is True if compaction was detected this call.
171
+ """
172
+
173
+ def _update(data):
174
+ entry = data.get(session_id, {"message_count": 0, "chunk": 0})
175
+ last_count = entry["message_count"]
176
+ chunk = entry["chunk"]
177
+ compacted = False
178
+
179
+ if message_count < last_count:
180
+ # Transcript shrank — compaction happened
181
+ chunk += 1
182
+ compacted = True
183
+
184
+ entry["message_count"] = message_count
185
+ entry["chunk"] = chunk
186
+ data[session_id] = entry
187
+
188
+ # Cap tracked sessions
189
+ if len(data) > 10000:
190
+ sorted_keys = sorted(data.keys())
191
+ for k in sorted_keys[: len(sorted_keys) // 2]:
192
+ del data[k]
193
+
194
+ return data, (chunk, compacted)
195
+
196
+ return _locked_read_modify_write("retention_tracking.json", "retention_tracking.lock", _update)