switchroom 0.12.27 → 0.12.29
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.
- package/dist/cli/switchroom.js +4 -2
- package/package.json +2 -1
- package/telegram-plugin/dist/gateway/gateway.js +113 -7
- package/telegram-plugin/gateway/gateway.ts +52 -9
- package/telegram-plugin/gateway/prefix-warmup.ts +123 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/prefix-warmup.test.ts +175 -0
- package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
- package/vendor/hindsight-memory/CHANGELOG.md +32 -0
- package/vendor/hindsight-memory/LICENSE +21 -0
- package/vendor/hindsight-memory/README.md +329 -0
- package/vendor/hindsight-memory/hooks/hooks.json +49 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
- package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
- package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
- package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
- package/vendor/hindsight-memory/scripts/recall.py +873 -0
- package/vendor/hindsight-memory/scripts/retain.py +286 -0
- package/vendor/hindsight-memory/scripts/session_end.py +122 -0
- package/vendor/hindsight-memory/scripts/session_start.py +76 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
- package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
- package/vendor/hindsight-memory/settings.json +37 -0
- package/vendor/hindsight-memory/skills/setup.md +24 -0
- package/vendor/hindsight-memory/tests/conftest.py +94 -0
- package/vendor/hindsight-memory/tests/test_bank.py +142 -0
- package/vendor/hindsight-memory/tests/test_client.py +232 -0
- package/vendor/hindsight-memory/tests/test_config.py +128 -0
- package/vendor/hindsight-memory/tests/test_content.py +471 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
- package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
- package/vendor/hindsight-memory/tests/test_pending.py +152 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
- 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)
|