memclaw-client 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.
- memclaw_client/__init__.py +20 -0
- memclaw_client/client.py +219 -0
- memclaw_client/exceptions.py +30 -0
- memclaw_client/interviewer/__init__.py +10 -0
- memclaw_client/interviewer/cli.py +328 -0
- memclaw_client/interviewer/discovery.py +66 -0
- memclaw_client/interviewer/machine.py +66 -0
- memclaw_client/interviewer/parser.py +175 -0
- memclaw_client/interviewer/runner.py +232 -0
- memclaw_client/interviewer/scrub.py +42 -0
- memclaw_client/interviewer/windows.py +91 -0
- memclaw_client/models.py +56 -0
- memclaw_client/py.typed +0 -0
- memclaw_client-0.2.0.dist-info/METADATA +127 -0
- memclaw_client-0.2.0.dist-info/RECORD +18 -0
- memclaw_client-0.2.0.dist-info/WHEEL +5 -0
- memclaw_client-0.2.0.dist-info/entry_points.txt +2 -0
- memclaw_client-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Official Python client for MemClaw — governed shared memory for AI agent fleets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import DEFAULT_BASE_URL, MemClaw
|
|
6
|
+
from .exceptions import AuthError, MemClawAPIError, MemClawError, NotFoundError
|
|
7
|
+
from .models import Memory, RecallResult
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"MemClaw",
|
|
11
|
+
"Memory",
|
|
12
|
+
"RecallResult",
|
|
13
|
+
"MemClawError",
|
|
14
|
+
"MemClawAPIError",
|
|
15
|
+
"AuthError",
|
|
16
|
+
"NotFoundError",
|
|
17
|
+
"DEFAULT_BASE_URL",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
memclaw_client/client.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Synchronous MemClaw client.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over the MemClaw REST API. Point it at a managed
|
|
4
|
+
(``https://memclaw.net``) or self-hosted (``http://localhost:8000``) deployment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .exceptions import AuthError, MemClawAPIError, NotFoundError
|
|
15
|
+
from .models import Memory, RecallResult
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://memclaw.net"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MemClaw:
|
|
21
|
+
"""Client for a MemClaw deployment.
|
|
22
|
+
|
|
23
|
+
Example::
|
|
24
|
+
|
|
25
|
+
from memclaw_client import MemClaw
|
|
26
|
+
|
|
27
|
+
mc = MemClaw("mc_xxx", tenant_id="my-team", agent_id="my-agent")
|
|
28
|
+
mc.write("Q3 revenue target is $4M, set on 2026-04-15.")
|
|
29
|
+
for m in mc.search("Q3 revenue target"):
|
|
30
|
+
print(m.title, m.content)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str,
|
|
36
|
+
*,
|
|
37
|
+
tenant_id: str,
|
|
38
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
39
|
+
agent_id: str | None = None,
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
transport: httpx.BaseTransport | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
if not api_key:
|
|
44
|
+
raise ValueError("api_key is required")
|
|
45
|
+
if not tenant_id:
|
|
46
|
+
raise ValueError("tenant_id is required")
|
|
47
|
+
self.tenant_id = tenant_id
|
|
48
|
+
self.agent_id = agent_id
|
|
49
|
+
self._http = httpx.Client(
|
|
50
|
+
base_url=base_url.rstrip("/"),
|
|
51
|
+
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
transport=transport,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ ops
|
|
57
|
+
def write(
|
|
58
|
+
self,
|
|
59
|
+
content: str,
|
|
60
|
+
*,
|
|
61
|
+
agent_id: str | None = None,
|
|
62
|
+
memory_type: str | None = None,
|
|
63
|
+
fleet_id: str | None = None,
|
|
64
|
+
metadata: dict[str, Any] | None = None,
|
|
65
|
+
**extra: Any,
|
|
66
|
+
) -> Memory:
|
|
67
|
+
"""Persist a memory. Returns the enriched ``Memory`` (POST /api/v1/memories)."""
|
|
68
|
+
body: dict[str, Any] = {"tenant_id": self.tenant_id, "content": content}
|
|
69
|
+
resolved_agent = agent_id or self.agent_id
|
|
70
|
+
if resolved_agent:
|
|
71
|
+
body["agent_id"] = resolved_agent
|
|
72
|
+
if memory_type:
|
|
73
|
+
body["memory_type"] = memory_type
|
|
74
|
+
if fleet_id:
|
|
75
|
+
body["fleet_id"] = fleet_id
|
|
76
|
+
if metadata is not None:
|
|
77
|
+
body["metadata"] = metadata
|
|
78
|
+
body.update(extra)
|
|
79
|
+
return Memory.from_dict(self._post("/api/v1/memories", body))
|
|
80
|
+
|
|
81
|
+
def search(
|
|
82
|
+
self,
|
|
83
|
+
query: str,
|
|
84
|
+
*,
|
|
85
|
+
top_k: int = 5,
|
|
86
|
+
fleet_ids: list[str] | None = None,
|
|
87
|
+
filter_agent_id: str | None = None,
|
|
88
|
+
**extra: Any,
|
|
89
|
+
) -> list[Memory]:
|
|
90
|
+
"""Hybrid vector + keyword search. Returns ranked ``Memory`` objects (POST /api/v1/search)."""
|
|
91
|
+
body: dict[str, Any] = {"tenant_id": self.tenant_id, "query": query, "top_k": top_k}
|
|
92
|
+
if fleet_ids:
|
|
93
|
+
body["fleet_ids"] = fleet_ids
|
|
94
|
+
if filter_agent_id:
|
|
95
|
+
body["filter_agent_id"] = filter_agent_id
|
|
96
|
+
body.update(extra)
|
|
97
|
+
data = self._post("/api/v1/search", body)
|
|
98
|
+
if not isinstance(data, dict):
|
|
99
|
+
raise MemClawAPIError(200, "search response must be a JSON object")
|
|
100
|
+
if "items" not in data:
|
|
101
|
+
raise MemClawAPIError(200, 'search response missing "items" list')
|
|
102
|
+
items = data["items"]
|
|
103
|
+
if not isinstance(items, list):
|
|
104
|
+
raise MemClawAPIError(200, 'search response "items" must be a list')
|
|
105
|
+
return [Memory.from_dict(m) for m in items]
|
|
106
|
+
|
|
107
|
+
def recall(self, query: str, *, top_k: int = 5, **extra: Any) -> RecallResult:
|
|
108
|
+
"""Search + LLM summary. Returns a ``RecallResult`` context brief (POST /api/v1/recall)."""
|
|
109
|
+
body: dict[str, Any] = {"tenant_id": self.tenant_id, "query": query, "top_k": top_k}
|
|
110
|
+
body.update(extra)
|
|
111
|
+
return RecallResult.from_dict(self._post("/api/v1/recall", body))
|
|
112
|
+
|
|
113
|
+
def health(self) -> dict[str, Any]:
|
|
114
|
+
"""Liveness probe (GET /api/v1/health)."""
|
|
115
|
+
response = self._http.get("/api/v1/health")
|
|
116
|
+
return response.json()
|
|
117
|
+
|
|
118
|
+
def get_document(
|
|
119
|
+
self,
|
|
120
|
+
doc_id: str,
|
|
121
|
+
*,
|
|
122
|
+
collection: str,
|
|
123
|
+
tenant_id: str | None = None,
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
"""Fetch one structured document (GET /api/v1/documents/{doc_id}).
|
|
126
|
+
|
|
127
|
+
Returns the full ``DocOut`` envelope — the stored record is under
|
|
128
|
+
the ``"data"`` key. Raises ``NotFoundError`` if absent.
|
|
129
|
+
"""
|
|
130
|
+
# Percent-encode the path segment: httpx does not encode f-string
|
|
131
|
+
# paths, so a doc_id containing '/' would hit a different route and
|
|
132
|
+
# '?' would inject query params.
|
|
133
|
+
encoded = urllib.parse.quote(doc_id, safe="")
|
|
134
|
+
response = self._http.get(
|
|
135
|
+
f"/api/v1/documents/{encoded}",
|
|
136
|
+
params={"tenant_id": tenant_id or self.tenant_id, "collection": collection},
|
|
137
|
+
)
|
|
138
|
+
self._raise_for_status(response)
|
|
139
|
+
return response.json()
|
|
140
|
+
|
|
141
|
+
def submit_interview(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
node_id: str,
|
|
145
|
+
agent_id: str,
|
|
146
|
+
cursor_from: int,
|
|
147
|
+
cursor_to: int,
|
|
148
|
+
events: list[dict[str, Any]],
|
|
149
|
+
tenant_id: str | None = None,
|
|
150
|
+
fleet_id: str | None = None,
|
|
151
|
+
command_id: str | None = None,
|
|
152
|
+
timeout: float = 120.0,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
"""Submit one Interviewer window (POST /api/v1/interview/submit).
|
|
155
|
+
|
|
156
|
+
The server interviews the window synchronously (its budget is 90s),
|
|
157
|
+
so ``timeout`` defaults well above the client-wide 30s. Returns the
|
|
158
|
+
response body plus ``"http_status"`` so callers can distinguish a
|
|
159
|
+
207 partial from a 200 committed. Raises on 4xx/5xx via the shared
|
|
160
|
+
error mapping (403 → ``AuthError``: tenant not enabled / bad key).
|
|
161
|
+
"""
|
|
162
|
+
body: dict[str, Any] = {
|
|
163
|
+
"tenant_id": tenant_id or self.tenant_id,
|
|
164
|
+
"node_id": node_id,
|
|
165
|
+
"agent_id": agent_id,
|
|
166
|
+
"cursor_from": cursor_from,
|
|
167
|
+
"cursor_to": cursor_to,
|
|
168
|
+
"events": events,
|
|
169
|
+
}
|
|
170
|
+
if fleet_id:
|
|
171
|
+
body["fleet_id"] = fleet_id
|
|
172
|
+
if command_id:
|
|
173
|
+
body["command_id"] = command_id
|
|
174
|
+
response = self._http.post("/api/v1/interview/submit", json=body, timeout=timeout)
|
|
175
|
+
self._raise_for_status(response)
|
|
176
|
+
result = response.json()
|
|
177
|
+
if isinstance(result, dict):
|
|
178
|
+
result["http_status"] = response.status_code
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------- internals
|
|
182
|
+
def _post(self, path: str, body: dict[str, Any]) -> Any:
|
|
183
|
+
response = self._http.post(path, json=body)
|
|
184
|
+
self._raise_for_status(response)
|
|
185
|
+
return response.json()
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
189
|
+
if response.is_success:
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
payload: Any = response.json()
|
|
193
|
+
except ValueError:
|
|
194
|
+
payload = {}
|
|
195
|
+
message = ""
|
|
196
|
+
details: Any = None
|
|
197
|
+
if isinstance(payload, dict):
|
|
198
|
+
error = payload.get("error")
|
|
199
|
+
if isinstance(error, dict):
|
|
200
|
+
message = error.get("message") or ""
|
|
201
|
+
details = error.get("details")
|
|
202
|
+
message = message or payload.get("detail") or payload.get("message") or response.text
|
|
203
|
+
else:
|
|
204
|
+
message = response.text
|
|
205
|
+
if response.status_code in (401, 403):
|
|
206
|
+
raise AuthError(response.status_code, message or "authentication failed", details=details)
|
|
207
|
+
if response.status_code == 404:
|
|
208
|
+
raise NotFoundError(response.status_code, message or "not found", details=details)
|
|
209
|
+
raise MemClawAPIError(response.status_code, message or "request failed", details=details)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------- lifecycle
|
|
212
|
+
def close(self) -> None:
|
|
213
|
+
self._http.close()
|
|
214
|
+
|
|
215
|
+
def __enter__(self) -> MemClaw:
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def __exit__(self, *exc: object) -> None:
|
|
219
|
+
self.close()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Exceptions raised by the MemClaw client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MemClawError(Exception):
|
|
9
|
+
"""Base class for all MemClaw client errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MemClawAPIError(MemClawError):
|
|
13
|
+
"""Raised when the MemClaw API returns a non-success status code.
|
|
14
|
+
|
|
15
|
+
The structured ``error`` envelope (``{"error": {"code", "message", "details"}}``)
|
|
16
|
+
is parsed when present; otherwise the raw body is used as the message.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status_code: int, message: str, *, details: Any = None) -> None:
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.details = details
|
|
22
|
+
super().__init__(f"[{status_code}] {message}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthError(MemClawAPIError):
|
|
26
|
+
"""Raised on 401/403 — bad or insufficiently-scoped credential."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotFoundError(MemClawAPIError):
|
|
30
|
+
"""Raised on 404."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""memclaw-interviewer — the Claude Code disk-parser adapter (Interviewer Phase 2).
|
|
2
|
+
|
|
3
|
+
Reads Claude Code session transcripts (``~/.claude/projects/<slug>/<uuid>.jsonl``)
|
|
4
|
+
READ-ONLY, tracks per-file cursors via the server's forward-only watermark
|
|
5
|
+
documents, and submits event windows to ``POST /api/v1/interview/submit``.
|
|
6
|
+
No local state; no server changes; the trail belongs to Claude Code and is
|
|
7
|
+
never modified.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""memclaw-interviewer CLI — run / status / hook.
|
|
2
|
+
|
|
3
|
+
Config precedence: flags > env > defaults. Env vars:
|
|
4
|
+
MEMCLAW_API_KEY (required) MEMCLAW_TENANT_ID (required)
|
|
5
|
+
MEMCLAW_BASE_URL MEMCLAW_AGENT_ID (default user@host)
|
|
6
|
+
MEMCLAW_FLEET_ID MEMCLAW_INTERVIEWER_PROJECTS (comma-sep globs)
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 success / nothing to do; 1 every attempted file failed;
|
|
9
|
+
2 configuration or authorization error. ``hook`` ALWAYS exits 0 — a
|
|
10
|
+
memory-harvest failure must never fail a Claude Code session hook.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import errno
|
|
17
|
+
import getpass
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import socket
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from ..client import MemClaw
|
|
27
|
+
from ..exceptions import AuthError
|
|
28
|
+
from .discovery import (
|
|
29
|
+
DEFAULT_PROJECTS_ROOT,
|
|
30
|
+
Transcript,
|
|
31
|
+
find_transcripts,
|
|
32
|
+
list_project_dirs,
|
|
33
|
+
project_allowed,
|
|
34
|
+
)
|
|
35
|
+
from .machine import machine_id_short
|
|
36
|
+
from .parser import count_lines
|
|
37
|
+
from .runner import RunConfig, node_id_for, read_watermark, run_all
|
|
38
|
+
|
|
39
|
+
DEFAULT_BASE_URL = "https://memclaw.net"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _default_agent_id() -> str:
|
|
43
|
+
return f"cc-{getpass.getuser()}@{socket.gethostname()}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
prog="memclaw-interviewer",
|
|
49
|
+
description="MemClaw Interviewer adapter for Claude Code transcripts (read-only).",
|
|
50
|
+
)
|
|
51
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
|
|
53
|
+
def common(p: argparse.ArgumentParser) -> None:
|
|
54
|
+
p.add_argument("--base-url", default=os.environ.get("MEMCLAW_BASE_URL", DEFAULT_BASE_URL))
|
|
55
|
+
p.add_argument("--api-key", default=os.environ.get("MEMCLAW_API_KEY", ""))
|
|
56
|
+
p.add_argument("--tenant-id", default=os.environ.get("MEMCLAW_TENANT_ID", ""))
|
|
57
|
+
p.add_argument("--agent-id", default=os.environ.get("MEMCLAW_AGENT_ID", "") or _default_agent_id())
|
|
58
|
+
p.add_argument("--fleet-id", default=os.environ.get("MEMCLAW_FLEET_ID", "") or None)
|
|
59
|
+
p.add_argument(
|
|
60
|
+
"--projects",
|
|
61
|
+
nargs="+",
|
|
62
|
+
default=None,
|
|
63
|
+
help="project-dir globs to allow (default: MEMCLAW_INTERVIEWER_PROJECTS env; DEFAULT-DENY without either)",
|
|
64
|
+
)
|
|
65
|
+
p.add_argument("--all-projects", action="store_true", help="explicit opt-in: harvest every project dir")
|
|
66
|
+
p.add_argument("--projects-root", type=Path, default=DEFAULT_PROJECTS_ROOT)
|
|
67
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
68
|
+
|
|
69
|
+
run_p = sub.add_parser("run", help="scan transcripts and submit due interview windows")
|
|
70
|
+
common(run_p)
|
|
71
|
+
run_p.add_argument("--transcript", type=Path, default=None, help="drain ONE transcript file (bypasses discovery)")
|
|
72
|
+
run_p.add_argument("--dry-run", action="store_true", help="parse + window, submit nothing")
|
|
73
|
+
run_p.add_argument("--max-windows", type=int, default=8)
|
|
74
|
+
run_p.add_argument("--since-hours", type=float, default=168.0, help="only files modified in this window (0 = all)")
|
|
75
|
+
run_p.add_argument("--min-events", type=int, default=10, help="dribble gate for the final window")
|
|
76
|
+
run_p.add_argument("--max-event-chars", type=int, default=4_000)
|
|
77
|
+
run_p.add_argument("--flush", action="store_true", help="submit even below the dribble gate")
|
|
78
|
+
|
|
79
|
+
status_p = sub.add_parser("status", help="show per-file cursor vs local line counts")
|
|
80
|
+
common(status_p)
|
|
81
|
+
status_p.add_argument("--since-hours", type=float, default=168.0)
|
|
82
|
+
|
|
83
|
+
hook_p = sub.add_parser("hook", help="Claude Code SessionEnd hook: drain the session transcript (stdin JSON)")
|
|
84
|
+
common(hook_p)
|
|
85
|
+
hook_p.add_argument("--max-windows", type=int, default=2)
|
|
86
|
+
hook_p.add_argument("--max-event-chars", type=int, default=4_000)
|
|
87
|
+
return parser
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_allowlist(args: argparse.Namespace) -> list[str]:
|
|
91
|
+
if args.projects is not None:
|
|
92
|
+
return list(args.projects)
|
|
93
|
+
env = os.environ.get("MEMCLAW_INTERVIEWER_PROJECTS", "")
|
|
94
|
+
return [g.strip() for g in env.split(",") if g.strip()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _require_config(args: argparse.Namespace) -> Optional[str]:
|
|
98
|
+
if not args.api_key:
|
|
99
|
+
return "MEMCLAW_API_KEY (or --api-key) is required"
|
|
100
|
+
if not args.tenant_id:
|
|
101
|
+
return "MEMCLAW_TENANT_ID (or --tenant-id) is required"
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _deny_guidance(args: argparse.Namespace) -> str:
|
|
106
|
+
projects = list_project_dirs(args.projects_root)
|
|
107
|
+
lines = [
|
|
108
|
+
"No project allowlist configured - refusing to harvest by default.",
|
|
109
|
+
"Claude Code transcripts can contain sensitive work across ALL projects;",
|
|
110
|
+
"opt in explicitly with --projects <glob...>, MEMCLAW_INTERVIEWER_PROJECTS,",
|
|
111
|
+
"or --all-projects.",
|
|
112
|
+
"",
|
|
113
|
+
"Discovered project dirs:",
|
|
114
|
+
]
|
|
115
|
+
lines += [f" {p}" for p in projects] or [" (none)"]
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _acquire_lock() -> Optional[object]:
|
|
120
|
+
"""Best-effort cross-invocation guard (cron + hook overlap).
|
|
121
|
+
|
|
122
|
+
The REAL safety is the server's deterministic attempt-id dedup; this
|
|
123
|
+
just avoids burning duplicate LLM windows. Never blocks. On platforms
|
|
124
|
+
without ``fcntl`` (Windows) the guard degrades to a no-op — dedup
|
|
125
|
+
still protects correctness.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
import fcntl
|
|
129
|
+
except ImportError:
|
|
130
|
+
# No fcntl (Windows): PROCEED without a lock rather than treating
|
|
131
|
+
# it as "already locked" — returning None here would make every
|
|
132
|
+
# Windows run exit immediately. Dedup still protects correctness.
|
|
133
|
+
return object()
|
|
134
|
+
# Per-user filename: a shared /tmp lock owned by another user would
|
|
135
|
+
# fail our open() with EACCES forever, reading as "always locked".
|
|
136
|
+
lock_path = Path(tempfile.gettempdir()) / f"memclaw-interviewer-{getpass.getuser()}.lock"
|
|
137
|
+
handle = None
|
|
138
|
+
try:
|
|
139
|
+
handle = open(lock_path, "w")
|
|
140
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
141
|
+
return handle
|
|
142
|
+
except OSError as exc:
|
|
143
|
+
if handle is not None:
|
|
144
|
+
handle.close()
|
|
145
|
+
if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
|
|
146
|
+
return None # genuine contention: another run holds the lock
|
|
147
|
+
# Unrecoverable environment error (EACCES on the lock file,
|
|
148
|
+
# read-only tmpdir, ...): warn and PROCEED lockless, like the
|
|
149
|
+
# no-fcntl path — treating it as contention would silently
|
|
150
|
+
# disable the CLI on that machine forever.
|
|
151
|
+
print(f"[interviewer] lock unavailable ({exc}); proceeding without it", file=sys.stderr)
|
|
152
|
+
return object()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _make_client(args: argparse.Namespace) -> MemClaw:
|
|
156
|
+
return MemClaw(
|
|
157
|
+
args.api_key,
|
|
158
|
+
tenant_id=args.tenant_id,
|
|
159
|
+
base_url=args.base_url,
|
|
160
|
+
agent_id=args.agent_id,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _make_config(args: argparse.Namespace, *, flush: bool = False, dry_run: bool = False) -> RunConfig:
|
|
165
|
+
return RunConfig(
|
|
166
|
+
agent_id=args.agent_id,
|
|
167
|
+
machine12=machine_id_short(),
|
|
168
|
+
fleet_id=args.fleet_id,
|
|
169
|
+
max_event_chars=getattr(args, "max_event_chars", 4_000),
|
|
170
|
+
max_windows=getattr(args, "max_windows", 8),
|
|
171
|
+
min_events=getattr(args, "min_events", 10),
|
|
172
|
+
flush=flush,
|
|
173
|
+
dry_run=dry_run,
|
|
174
|
+
verbose=args.verbose,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
179
|
+
if error := _require_config(args):
|
|
180
|
+
print(f"[interviewer] {error}", file=sys.stderr)
|
|
181
|
+
return 2
|
|
182
|
+
allow = _resolve_allowlist(args)
|
|
183
|
+
if args.transcript is not None:
|
|
184
|
+
if not args.transcript.is_file():
|
|
185
|
+
print(f"[interviewer] no such transcript: {args.transcript}", file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
# --transcript must not bypass the project allowlist — including
|
|
188
|
+
# the EMPTY-allowlist case (default-deny has no carve-outs; same
|
|
189
|
+
# pattern as _cmd_hook and the discovery path below).
|
|
190
|
+
project = args.transcript.parent.name
|
|
191
|
+
if not args.all_projects and not (allow and project_allowed(project, allow)):
|
|
192
|
+
print(
|
|
193
|
+
f"[interviewer] transcript project '{project}' not in allowlist; "
|
|
194
|
+
"pass --all-projects or --projects to opt in",
|
|
195
|
+
file=sys.stderr,
|
|
196
|
+
)
|
|
197
|
+
return 2
|
|
198
|
+
transcripts = [Transcript(path=args.transcript, project=project)]
|
|
199
|
+
else:
|
|
200
|
+
if not allow and not args.all_projects:
|
|
201
|
+
print(_deny_guidance(args), file=sys.stderr)
|
|
202
|
+
return 2
|
|
203
|
+
transcripts = find_transcripts(
|
|
204
|
+
root=args.projects_root,
|
|
205
|
+
allow_globs=allow,
|
|
206
|
+
since_hours=args.since_hours or None,
|
|
207
|
+
all_projects=args.all_projects,
|
|
208
|
+
)
|
|
209
|
+
if not transcripts:
|
|
210
|
+
print("[interviewer] nothing to do (no matching transcripts)")
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
lock = _acquire_lock()
|
|
214
|
+
if lock is None:
|
|
215
|
+
print("[interviewer] another run holds the lock; exiting", file=sys.stderr)
|
|
216
|
+
return 0
|
|
217
|
+
try:
|
|
218
|
+
cfg = _make_config(args, flush=args.flush, dry_run=args.dry_run)
|
|
219
|
+
with _make_client(args) as mc:
|
|
220
|
+
summary = run_all(mc, transcripts, cfg)
|
|
221
|
+
finally:
|
|
222
|
+
# Deterministic flock release: on non-CPython runtimes the ref-count
|
|
223
|
+
# drop is not prompt, and a lingering handle blocks the next
|
|
224
|
+
# cron/hook invocation until GC. (The Windows sentinel has no close.)
|
|
225
|
+
if hasattr(lock, "close"):
|
|
226
|
+
lock.close()
|
|
227
|
+
|
|
228
|
+
submitted = sum(f.windows_submitted for f in summary.files)
|
|
229
|
+
memories = sum(f.memories_written for f in summary.files)
|
|
230
|
+
print(
|
|
231
|
+
f"[interviewer] {len(summary.files)} file(s): {submitted} window(s) submitted, "
|
|
232
|
+
f"{memories} memories written"
|
|
233
|
+
+ (" [dry-run]" if args.dry_run else "")
|
|
234
|
+
)
|
|
235
|
+
if summary.aborted:
|
|
236
|
+
return 2
|
|
237
|
+
if summary.failed_all:
|
|
238
|
+
return 1
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
243
|
+
if error := _require_config(args):
|
|
244
|
+
print(f"[interviewer] {error}", file=sys.stderr)
|
|
245
|
+
return 2
|
|
246
|
+
allow = _resolve_allowlist(args)
|
|
247
|
+
if not allow and not args.all_projects:
|
|
248
|
+
print(_deny_guidance(args), file=sys.stderr)
|
|
249
|
+
return 2
|
|
250
|
+
transcripts = find_transcripts(
|
|
251
|
+
root=args.projects_root,
|
|
252
|
+
allow_globs=allow,
|
|
253
|
+
since_hours=args.since_hours or None,
|
|
254
|
+
all_projects=args.all_projects,
|
|
255
|
+
)
|
|
256
|
+
machine12 = machine_id_short()
|
|
257
|
+
with _make_client(args) as mc:
|
|
258
|
+
for transcript in transcripts:
|
|
259
|
+
node_id = node_id_for(machine12, transcript.path)
|
|
260
|
+
try:
|
|
261
|
+
last_seq = read_watermark(mc, node_id)
|
|
262
|
+
# Inside the guard: the transcript can vanish between
|
|
263
|
+
# discovery and here (same TOCTOU as discovery's stat).
|
|
264
|
+
lines = count_lines(transcript.path)
|
|
265
|
+
except AuthError as exc:
|
|
266
|
+
print(f"[interviewer] ABORT: {exc}", file=sys.stderr)
|
|
267
|
+
return 2
|
|
268
|
+
except Exception as exc: # noqa: BLE001 - one bad file must not kill status
|
|
269
|
+
print(
|
|
270
|
+
f"{transcript.project}/{transcript.path.name}: status check failed: {exc}",
|
|
271
|
+
file=sys.stderr,
|
|
272
|
+
)
|
|
273
|
+
continue
|
|
274
|
+
pending = max(0, lines - (last_seq + 1))
|
|
275
|
+
print(f"{transcript.project}/{transcript.path.name}: lines={lines} cursor={last_seq} pending={pending}")
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _cmd_hook(args: argparse.Namespace) -> int:
|
|
280
|
+
"""SessionEnd hook: read {transcript_path,...} JSON from stdin, drain
|
|
281
|
+
that one file. ALWAYS exit 0 — never fail the user's session."""
|
|
282
|
+
try:
|
|
283
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
284
|
+
transcript_path = Path(str(payload.get("transcript_path") or ""))
|
|
285
|
+
if not transcript_path.is_file():
|
|
286
|
+
return 0
|
|
287
|
+
allow = _resolve_allowlist(args)
|
|
288
|
+
project = transcript_path.parent.name
|
|
289
|
+
if not args.all_projects and not (allow and project_allowed(project, allow)):
|
|
290
|
+
# Warn so operators can tell a config gap from an empty
|
|
291
|
+
# session; exit code stays 0 (never fail the session hook).
|
|
292
|
+
print(
|
|
293
|
+
f"[interviewer] hook: project '{project}' not in allowlist; skipping "
|
|
294
|
+
"(set MEMCLAW_INTERVIEWER_PROJECTS or --all-projects)",
|
|
295
|
+
file=sys.stderr,
|
|
296
|
+
)
|
|
297
|
+
return 0
|
|
298
|
+
if error := _require_config(args):
|
|
299
|
+
print(f"[interviewer] hook: {error}; skipping", file=sys.stderr)
|
|
300
|
+
return 0
|
|
301
|
+
lock = _acquire_lock()
|
|
302
|
+
if lock is None:
|
|
303
|
+
return 0
|
|
304
|
+
try:
|
|
305
|
+
cfg = _make_config(args, flush=True) # session over: drain the tail
|
|
306
|
+
with _make_client(args) as mc:
|
|
307
|
+
run_all(mc, [Transcript(path=transcript_path, project=project)], cfg)
|
|
308
|
+
finally:
|
|
309
|
+
if hasattr(lock, "close"):
|
|
310
|
+
lock.close()
|
|
311
|
+
except Exception as exc: # noqa: BLE001 - hook must never fail the session
|
|
312
|
+
print(f"[interviewer] hook error (ignored): {exc}", file=sys.stderr)
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
317
|
+
args = _build_parser().parse_args(argv)
|
|
318
|
+
if args.command == "run":
|
|
319
|
+
return _cmd_run(args)
|
|
320
|
+
if args.command == "status":
|
|
321
|
+
return _cmd_status(args)
|
|
322
|
+
if args.command == "hook":
|
|
323
|
+
return _cmd_hook(args)
|
|
324
|
+
return 2
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Transcript discovery under ~/.claude/projects with allowlist gating.
|
|
2
|
+
|
|
3
|
+
Privacy posture (user-confirmed): DEFAULT-DENY. With no allowlist the CLI
|
|
4
|
+
lists what it found and exits with guidance — transcripts can contain
|
|
5
|
+
NDA/client work and pasted secrets across every project on the machine,
|
|
6
|
+
and installing the tool is not consent to harvest all of them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
DEFAULT_PROJECTS_ROOT = Path.home() / ".claude" / "projects"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Transcript:
|
|
22
|
+
path: Path
|
|
23
|
+
project: str # project dir name (the slug)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_project_dirs(root: Path = DEFAULT_PROJECTS_ROOT) -> list[str]:
|
|
27
|
+
if not root.is_dir():
|
|
28
|
+
return []
|
|
29
|
+
return sorted(p.name for p in root.iterdir() if p.is_dir())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def project_allowed(project: str, allow_globs: list[str]) -> bool:
|
|
33
|
+
return any(fnmatch.fnmatch(project, glob) for glob in allow_globs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_transcripts(
|
|
37
|
+
*,
|
|
38
|
+
root: Path = DEFAULT_PROJECTS_ROOT,
|
|
39
|
+
allow_globs: list[str],
|
|
40
|
+
since_hours: Optional[float] = None,
|
|
41
|
+
all_projects: bool = False,
|
|
42
|
+
) -> list[Transcript]:
|
|
43
|
+
"""Enumerate top-level session ``.jsonl`` files in allowed projects.
|
|
44
|
+
|
|
45
|
+
Skips subagent transcripts (``<uuid>/subagents/``) and offloaded tool
|
|
46
|
+
results by construction — only files DIRECTLY under a project dir are
|
|
47
|
+
session transcripts. ``since_hours`` prefilters by mtime so steady-state
|
|
48
|
+
runs don't rescan months of history.
|
|
49
|
+
"""
|
|
50
|
+
if not root.is_dir():
|
|
51
|
+
return []
|
|
52
|
+
cutoff = time.time() - since_hours * 3600 if since_hours else None
|
|
53
|
+
found: list[Transcript] = []
|
|
54
|
+
for project_dir in sorted(root.iterdir()):
|
|
55
|
+
if not project_dir.is_dir():
|
|
56
|
+
continue
|
|
57
|
+
if not all_projects and not project_allowed(project_dir.name, allow_globs):
|
|
58
|
+
continue
|
|
59
|
+
for path in sorted(project_dir.glob("*.jsonl")):
|
|
60
|
+
try:
|
|
61
|
+
if cutoff is not None and path.stat().st_mtime < cutoff:
|
|
62
|
+
continue
|
|
63
|
+
except OSError:
|
|
64
|
+
continue # deleted/renamed between glob and stat (TOCTOU)
|
|
65
|
+
found.append(Transcript(path=path, project=project_dir.name))
|
|
66
|
+
return found
|