getlogbook 0.0.1__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.
getlogbook/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """logbook: commit-anchored shared memory for AI-assisted repos."""
2
+ __version__ = "0.0.1"
getlogbook/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ import sys
2
+
3
+ if len(sys.argv) > 1 and sys.argv[1] in ("capture", "post-commit"):
4
+ # hook fast path: no argparse, no CLI imports (saves ~100 ms per invocation)
5
+ from .hookmain import run
6
+
7
+ raise SystemExit(run(sys.argv[1:]))
8
+
9
+ from .cli import main
10
+
11
+ raise SystemExit(main())
getlogbook/capture.py ADDED
@@ -0,0 +1,372 @@
1
+ """Capture: hook entry point, spool, reconcile, git post-commit floor, hook installers.
2
+
3
+ Design rules (from the review panels):
4
+ * A hook only writes a small spool file and (maybe) spawns a detached worker. No model, no network, < 100 ms.
5
+ * A hook never fails the agent: everything is caught, logged to errors.log, exit code 0.
6
+ * `reconcile()` is the one sweep: spool -> transcript slices -> drafts; crash recovery; commits with no entry.
7
+ * Drafts built without a model are provenance=inferred and are never injected (no 'why').
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from . import git as g
20
+ from .entry import BODY_FIELDS, Entry, from_json, new_id
21
+ from .store import Repo, atomic_write
22
+
23
+ MARK_BEGIN, MARK_END = "# >>> logbook >>>", "# <<< logbook <<<"
24
+ CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "PreCompact", "SessionEnd"]
25
+
26
+
27
+ def now():
28
+ return time.strftime("%Y-%m-%dT%H:%M:%S")
29
+
30
+
31
+ # ---- entries made by people/agents ---------------------------------------------------------------------------
32
+ def make_entry(repo, kind, topic, body, paths=None, provenance="agent-written", via="cli", pending=False, supersedes=()):
33
+ from . import tree
34
+ body = {k: str(v)[:2000] for k, v in body.items() if k in BODY_FIELDS and v}
35
+ e = Entry(id=new_id(), kind=kind, topic=topic[:200], body=body, provenance=provenance, captured_via=via,
36
+ supersedes=list(supersedes), created=now(),
37
+ display=repo.cfg().get("display", "") if repo.attribution() == "named" else "")
38
+ if pending: # change entries are written before the commit exists; post-commit attaches the sha
39
+ e.anchor = {"paths": list(paths or [])}
40
+ atomic_write(repo.cdir / "pending" / f"{e.id}.json", e.to_json())
41
+ else:
42
+ e.anchor = tree.anchor_for(repo, paths=paths)
43
+ repo.add_private(e)
44
+ return e
45
+
46
+
47
+ def post_commit(repo):
48
+ """Attach pending change entries to the commit that was just made. Infers nothing here (reconcile does)."""
49
+ from . import tree
50
+ n = 0
51
+ pdir = repo.cdir / "pending"
52
+ for p in sorted(pdir.glob("*.json")) if pdir.exists() else []:
53
+ try:
54
+ e = from_json(p.read_text(encoding="utf-8"))
55
+ e.anchor = tree.anchor_for(repo) # paths come from the commit itself
56
+ repo.add_private(e)
57
+ p.unlink()
58
+ n += 1
59
+ except (OSError, ValueError) as ex:
60
+ repo.error(f"post-commit {p.name}: {ex}")
61
+ return n
62
+
63
+
64
+ # ---- spool + detached worker ---------------------------------------------------------------------------------
65
+ @contextlib.contextmanager
66
+ def _lock(path, timeout=1.0):
67
+ """Tiny cross-platform lock (O_EXCL file, stale after 10 s). Yields False if it could not be taken."""
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ end, fd = time.time() + timeout, None
70
+ while time.time() < end:
71
+ try:
72
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
73
+ break
74
+ except FileExistsError:
75
+ try:
76
+ if time.time() - path.stat().st_mtime > 10:
77
+ path.unlink()
78
+ except OSError:
79
+ pass
80
+ time.sleep(0.02)
81
+ try:
82
+ yield fd is not None
83
+ finally:
84
+ if fd is not None:
85
+ os.close(fd)
86
+ try:
87
+ path.unlink()
88
+ except OSError:
89
+ pass
90
+
91
+
92
+ def spawn_detached(args, cwd):
93
+ if os.environ.get("LOGBOOK_NO_SPAWN"): # tests
94
+ return
95
+ kw = dict(stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, cwd=str(cwd))
96
+ if os.name == "nt":
97
+ flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
98
+ try: # escape the agent's job object if allowed, otherwise fall back
99
+ subprocess.Popen(args, creationflags=flags | 0x01000000, **kw) # CREATE_BREAKAWAY_FROM_JOB
100
+ except OSError:
101
+ subprocess.Popen(args, creationflags=flags, **kw)
102
+ else:
103
+ subprocess.Popen(args, start_new_session=True, **kw)
104
+
105
+
106
+ def spool_event(repo, event, agent, payload):
107
+ head, branch, paths, _ = g.state(repo.root) # one subprocess
108
+ item = {"event": event, "agent": agent, "t": time.time(), "head": head or "", "branch": branch,
109
+ "paths": paths[:40], "session": str(payload.get("session_id", ""))[:80], "transcript": str(payload.get("transcript_path", "")),
110
+ "prompt": str(payload.get("prompt", ""))[:2000], "last": str(payload.get("last_assistant_message", ""))[:4000]}
111
+ atomic_write(repo.cdir / "spool" / f"{int(time.time() * 1000):013d}-{os.urandom(3).hex()}.json", json.dumps(item))
112
+ if item["session"] and item["transcript"]: # copy the new bytes now: agents prune old transcripts
113
+ checkpoint(repo, item["session"], item["transcript"])
114
+
115
+
116
+ def handle_hook(event, agent, raw):
117
+ """Entry point for `logbook capture`. Returns text for stdout (may be ''). Never raises."""
118
+ repo = None
119
+ try:
120
+ payload = json.loads(raw) if raw and raw.strip() else {}
121
+ if not isinstance(payload, dict):
122
+ payload = {}
123
+ repo = Repo(payload.get("cwd") or ".")
124
+ cfg = repo.cfg()
125
+ if not cfg.get("user"):
126
+ return ""
127
+ out = ""
128
+ if event in ("SessionStart", "UserPromptSubmit", "PreToolUse"):
129
+ from . import recall # only the prompt-time hooks need retrieval; Stop/SessionEnd stay lean
130
+ files = []
131
+ ti = payload.get("tool_input")
132
+ if event == "PreToolUse" and isinstance(ti, dict) and ti.get("file_path"):
133
+ try:
134
+ files = [Path(ti["file_path"]).resolve().relative_to(repo.root.resolve()).as_posix()]
135
+ except (ValueError, OSError):
136
+ files = []
137
+ out = recall.hook_json(event, recall.digest(repo, event, payload.get("prompt", ""), files, str(payload.get("session_id", ""))))
138
+ if event in ("Stop", "PreCompact", "SessionEnd"): # prompt-time hooks stay read-only and fast
139
+ spool_event(repo, event, agent, payload)
140
+ if event in ("SessionStart", "PreCompact", "SessionEnd"):
141
+ spawn_detached([cfg.get("interpreter") or sys.executable, "-m", "getlogbook", "reconcile", "--quiet"], repo.root)
142
+ return out
143
+ except Exception as ex: # a hook must never break the agent
144
+ try:
145
+ (repo.error if repo else (lambda m: None))(f"capture {event}: {ex!r}")
146
+ except Exception:
147
+ pass
148
+ return ""
149
+
150
+
151
+ # ---- reconcile -----------------------------------------------------------------------------------------------
152
+ def _msg(obj):
153
+ m = obj.get("message") if isinstance(obj.get("message"), dict) else obj
154
+ role = m.get("role") or obj.get("type") or ""
155
+ c = m.get("content")
156
+ if isinstance(c, list):
157
+ c = " ".join(p.get("text", "") for p in c if isinstance(p, dict) and p.get("type") == "text")
158
+ return role, c.strip() if isinstance(c, str) else ""
159
+
160
+
161
+ def _sid(sid):
162
+ return "".join(c for c in sid if c.isalnum() or c in "-_")[:80] or "nosession"
163
+
164
+
165
+ def _load(d):
166
+ try:
167
+ st = json.loads((d / "state.json").read_text(encoding="utf-8"))
168
+ except (OSError, ValueError):
169
+ st = {}
170
+ st.setdefault("offset", 0)
171
+ st.setdefault("paths", [])
172
+ st.setdefault("summarized", 0)
173
+ return st
174
+
175
+
176
+ def _copy_slice(d, st, transcript):
177
+ if transcript and Path(transcript).is_file():
178
+ size = Path(transcript).stat().st_size
179
+ if size < st["offset"]:
180
+ st["offset"] = 0
181
+ if size > st["offset"]:
182
+ with open(transcript, "rb") as f:
183
+ f.seek(st["offset"])
184
+ data = f.read()
185
+ d.mkdir(parents=True, exist_ok=True)
186
+ (d / f"slice-{st['offset']:012d}.jsonl").write_bytes(data)
187
+ st["offset"] = size
188
+
189
+
190
+ def checkpoint(repo, sid, transcript, paths=(), meta=None):
191
+ """Copy the transcript bytes added since the last checkpoint. Cheap; safe to call from a hook."""
192
+ d = repo.cdir / "sessions" / _sid(sid)
193
+ with _lock(d / ".lock") as got:
194
+ if not got:
195
+ return None
196
+ st = _load(d)
197
+ _copy_slice(d, st, transcript)
198
+ st["paths"] = sorted(set(st["paths"]) | set(paths))[:200]
199
+ st["last"] = time.time()
200
+ st.update(meta or {})
201
+ atomic_write(d / "state.json", json.dumps(st))
202
+ return d, st
203
+
204
+
205
+ def _finalize(repo, sid, d, st, agent, head, branch):
206
+ from . import tree
207
+ texts = []
208
+ for sl in sorted(d.glob("slice-*.jsonl")):
209
+ if int(sl.stem.split("-")[1]) < st["summarized"]:
210
+ continue
211
+ for line in sl.read_text(encoding="utf-8", errors="replace").splitlines():
212
+ try:
213
+ role, text = _msg(json.loads(line))
214
+ except ValueError:
215
+ continue
216
+ if text:
217
+ texts.append((role, text))
218
+ assistant = [t for r, t in texts if r == "assistant"]
219
+ user = [t for r, t in texts if r == "user"]
220
+ if not assistant:
221
+ return None
222
+ st["summarized"] = st["offset"]
223
+ first_line = lambda s, n: (s.splitlines() or [""])[0][:n]
224
+ e = Entry(id=new_id(), kind="finding", topic=(first_line(user[0], 80) if user else f"session {sid[:8]}"),
225
+ body={"summary": assistant[-1][:300], "what": "\n".join("- " + first_line(a, 120) for a in assistant[:5])},
226
+ provenance="inferred", captured_via=agent or "agent", created=now())
227
+ a = tree.anchor_for(repo, sha=head or None, paths=st["paths"][:40], dirty=bool(st["paths"]))
228
+ a["branch"] = branch or a["branch"]
229
+ e.anchor = a
230
+ repo.add_private(e)
231
+ return e
232
+
233
+
234
+ def reconcile(repo, quiet=True):
235
+ """Process spooled hook events, recover crashed sessions, and record commits that have no entry."""
236
+ done = {"drafts": 0, "floor": 0, "pending": 0}
237
+ sessions = {}
238
+ spool = repo.cdir / "spool"
239
+ for p in sorted(spool.glob("*.json")) if spool.exists() else []:
240
+ try:
241
+ it = json.loads(p.read_text(encoding="utf-8"))
242
+ except (OSError, ValueError):
243
+ p.unlink(missing_ok=True)
244
+ continue
245
+ sid = it.get("session") or "nosession"
246
+ s = sessions.setdefault(sid, {"agent": it.get("agent", ""), "transcript": "", "final": False, "head": "", "branch": "", "paths": set()})
247
+ s["transcript"] = it.get("transcript") or s["transcript"]
248
+ s["head"], s["branch"] = it.get("head") or s["head"], it.get("branch") or s["branch"]
249
+ s["paths"] |= set(it.get("paths", []))
250
+ s["final"] |= it.get("event") in ("PreCompact", "SessionEnd")
251
+ p.unlink(missing_ok=True)
252
+ for sid, s in sessions.items():
253
+ safe = _sid(sid)
254
+ got = checkpoint(repo, safe, s["transcript"], s["paths"], {"agent": s["agent"], "head": s["head"], "branch": s["branch"]})
255
+ if got and s["final"]:
256
+ d = got[0]
257
+ with _lock(d / ".lock") as ok:
258
+ st = _load(d)
259
+ if ok and _finalize(repo, safe, d, st, s["agent"], s["head"], s["branch"]):
260
+ done["drafts"] += 1
261
+ st["paths"] = []
262
+ if ok:
263
+ atomic_write(d / "state.json", json.dumps(st))
264
+ # crash recovery: sessions with unsummarised bytes and no activity for 30 minutes
265
+ sdir = repo.cdir / "sessions"
266
+ for d in sdir.iterdir() if sdir.exists() else []:
267
+ if not d.is_dir():
268
+ continue
269
+ with _lock(d / ".lock") as ok:
270
+ st = _load(d)
271
+ if ok and st["offset"] > st["summarized"] and time.time() - st.get("last", 0) > 1800:
272
+ if _finalize(repo, d.name, d, st, st.get("agent", ""), st.get("head", ""), st.get("branch", "")):
273
+ done["drafts"] += 1
274
+ atomic_write(d / "state.json", json.dumps(st))
275
+ done["pending"] = post_commit(repo) if (repo.cdir / "pending").exists() else 0
276
+ done["floor"] = _commit_floor(repo)
277
+ return done
278
+
279
+
280
+ def _commit_floor(repo):
281
+ """Inferred entries for my own commits since init that have no entry yet (covers web-UI/hook-less commits)."""
282
+ from . import tree
283
+ base = repo.cfg().get("floor_base")
284
+ if not base or not g.commit_exists(repo.root, base):
285
+ return 0
286
+ email = g.config_get(repo.root, "user.email")
287
+ rc, out, _ = g.try_run(["log", "--first-parent", "-n50", f"--author={email}", "--format=%H", f"{base}..HEAD"], repo.root)
288
+ covered = {l.entry.anchor.get("sha") for l in repo.all_entries()}
289
+ n = 0
290
+ for sha in (out.split() if rc == 0 else []):
291
+ if sha in covered:
292
+ continue
293
+ info = g.commit_info(repo.root, sha) or {}
294
+ paths = g.changed_paths(repo.root, sha)
295
+ body = g.try_run(["show", "-s", "--format=%b", sha], repo.root)[1].strip()
296
+ layers = sorted({l for l in (tree.layer_of(p) for p in paths) if l})
297
+ e = Entry(id=new_id(), kind="change", topic=info.get("subject", "")[:200],
298
+ body={"summary": info.get("subject", ""), "what": (body + f"\nfiles: {len(paths)}" +
299
+ (f" ({', '.join(layers)})" if layers else "")).strip()},
300
+ provenance="inferred", captured_via="reconcile", created=now())
301
+ e.anchor = tree.anchor_for(repo, sha=sha, paths=paths)
302
+ repo.add_private(e)
303
+ n += 1
304
+ return n
305
+
306
+
307
+ # ---- installers ---------------------------------------------------------------------------------------------
308
+ def _py(repo):
309
+ return (repo.cfg().get("interpreter") or sys.executable).replace("\\", "/")
310
+
311
+
312
+ def install_git_hook(repo):
313
+ hooks = Path(g.run(["rev-parse", "--path-format=absolute", "--git-path", "hooks"], repo.root).strip())
314
+ hooks.mkdir(parents=True, exist_ok=True)
315
+ f = hooks / "post-commit"
316
+ block = f'{MARK_BEGIN}\n"{_py(repo)}" -m getlogbook post-commit >/dev/null 2>&1 || true\n{MARK_END}\n'
317
+ text = f.read_text(encoding="utf-8") if f.exists() else "#!/bin/sh\n"
318
+ if MARK_BEGIN not in text: # chain, never overwrite
319
+ f.write_text(text.rstrip("\n") + "\n" + block, encoding="utf-8", newline="\n")
320
+ try:
321
+ f.chmod(f.stat().st_mode | 0o111)
322
+ except OSError:
323
+ pass
324
+ c = repo.cfg()
325
+ c.setdefault("floor_base", g.head_sha(repo.root) or "")
326
+ repo.save_cfg(c)
327
+ return f
328
+
329
+
330
+ def uninstall_git_hook(repo):
331
+ hooks = Path(g.run(["rev-parse", "--path-format=absolute", "--git-path", "hooks"], repo.root).strip())
332
+ f = hooks / "post-commit"
333
+ if f.exists() and MARK_BEGIN in f.read_text(encoding="utf-8"):
334
+ t = f.read_text(encoding="utf-8")
335
+ a, _, rest = t.partition(MARK_BEGIN)
336
+ _, _, b = rest.partition(MARK_END)
337
+ f.write_text((a + b.lstrip("\n")).rstrip("\n") + "\n", encoding="utf-8", newline="\n")
338
+ return f
339
+
340
+
341
+ def claude_snippet(repo, pretool=False):
342
+ py = _py(repo)
343
+ mk = lambda ev: [{"hooks": [{"type": "command", "command": f'"{py}" -m getlogbook capture --event {ev} --agent claude-code'}]}]
344
+ hooks = {ev: mk(ev) for ev in CLAUDE_EVENTS}
345
+ if pretool:
346
+ hooks["PreToolUse"] = [{"matcher": "Edit|Write|MultiEdit", **mk("PreToolUse")[0]}]
347
+ return {"hooks": hooks}
348
+
349
+
350
+ def install_claude(repo, path=None, pretool=False, write=False, remove=False):
351
+ path = Path(path or repo.root / ".claude" / "settings.local.json") # local: it holds a machine-specific interpreter path
352
+ try:
353
+ cur = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
354
+ except ValueError:
355
+ raise SystemExit(f"logbook: {path} is not valid JSON; refusing to touch it")
356
+ hooks = cur.setdefault("hooks", {})
357
+ snippet = claude_snippet(repo, pretool)["hooks"]
358
+ for ev, entries in list(hooks.items()): # always drop our previous entries so install is idempotent
359
+ keep = [e for e in entries if "getlogbook capture" not in json.dumps(e)]
360
+ if keep:
361
+ hooks[ev] = keep
362
+ else:
363
+ del hooks[ev]
364
+ if not remove:
365
+ for ev, entries in snippet.items():
366
+ hooks.setdefault(ev, []).extend(entries)
367
+ if not hooks:
368
+ cur.pop("hooks")
369
+ if write:
370
+ path.parent.mkdir(parents=True, exist_ok=True)
371
+ atomic_write(path, json.dumps(cur, indent=2))
372
+ return path, cur