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.
@@ -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"
@@ -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