opsroom-console 0.3.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.
- opsroom/__init__.py +2 -0
- opsroom/cli.py +199 -0
- opsroom/collectors/__init__.py +81 -0
- opsroom/collectors/chat.py +126 -0
- opsroom/collectors/cli.py +165 -0
- opsroom/collectors/codex.py +132 -0
- opsroom/collectors/fs.py +54 -0
- opsroom/collectors/git.py +165 -0
- opsroom/collectors/notes.py +72 -0
- opsroom/config.py +131 -0
- opsroom/dashboard.py +542 -0
- opsroom/db.py +131 -0
- opsroom/demo.py +214 -0
- opsroom/enrich.py +304 -0
- opsroom/ops.py +205 -0
- opsroom/redact.py +77 -0
- opsroom/serve.py +257 -0
- opsroom/setup.py +84 -0
- opsroom/state.py +454 -0
- opsroom/ventures.py +82 -0
- opsroom/views.py +294 -0
- opsroom_console-0.3.0.dist-info/METADATA +144 -0
- opsroom_console-0.3.0.dist-info/RECORD +27 -0
- opsroom_console-0.3.0.dist-info/WHEEL +5 -0
- opsroom_console-0.3.0.dist-info/entry_points.txt +2 -0
- opsroom_console-0.3.0.dist-info/licenses/LICENSE +21 -0
- opsroom_console-0.3.0.dist-info/top_level.txt +1 -0
opsroom/__init__.py
ADDED
opsroom/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""opsroom — a local-first operator console. Read-only on all sources.
|
|
3
|
+
Commands: serve · init · sync · sitrep · dash · today · week · loops · drift · venture ·
|
|
4
|
+
search · daily · demo · purge · status"""
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from . import config, db, enrich, views
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_sync(args):
|
|
13
|
+
con = db.connect()
|
|
14
|
+
sources = args.source.split(",") if args.source else ["cli", "codex", "git", "fs", "notes", "chat"]
|
|
15
|
+
t0 = time.time()
|
|
16
|
+
results, degraded = {}, []
|
|
17
|
+
git_result, notes_result = {}, {}
|
|
18
|
+
from .collectors import (cli as c_cli, codex as c_codex, git as c_git, fs as c_fs,
|
|
19
|
+
notes as c_notes, chat as c_chat)
|
|
20
|
+
registry = {"cli": c_cli, "codex": c_codex, "git": c_git, "fs": c_fs,
|
|
21
|
+
"notes": c_notes, "chat": c_chat}
|
|
22
|
+
for name in sources:
|
|
23
|
+
mod = registry.get(name)
|
|
24
|
+
if not mod:
|
|
25
|
+
print(f"unknown source: {name}")
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
r = mod.collect(con, dry_run=args.dry_run)
|
|
29
|
+
results[name] = r
|
|
30
|
+
if name == "git":
|
|
31
|
+
git_result = r
|
|
32
|
+
if name == "notes":
|
|
33
|
+
notes_result = r
|
|
34
|
+
if r.get("degraded"):
|
|
35
|
+
degraded.append(f"notes: {r['degraded']}")
|
|
36
|
+
if not args.dry_run:
|
|
37
|
+
db.set_watermark(con, name, "degraded" if (name == "notes" and r.get("degraded")) else "ok",
|
|
38
|
+
last_ts=r.get("watermark"))
|
|
39
|
+
except Exception as e:
|
|
40
|
+
degraded.append(f"{name}: {type(e).__name__}: {e}")
|
|
41
|
+
if not args.dry_run:
|
|
42
|
+
db.set_watermark(con, name, "failed")
|
|
43
|
+
print(f" [{name}] DEGRADED: {e}", file=sys.stderr)
|
|
44
|
+
if not args.dry_run:
|
|
45
|
+
n_sessions = enrich.build_sessions(con)
|
|
46
|
+
loop_stats = enrich.detect_loops(con, git_result, notes_result)
|
|
47
|
+
con.commit()
|
|
48
|
+
db.enforce_perms()
|
|
49
|
+
else:
|
|
50
|
+
n_sessions, loop_stats = "-", {"open": "-"}
|
|
51
|
+
dt = time.time() - t0
|
|
52
|
+
print(f"\nopsroom sync {'(dry-run) ' if args.dry_run else ''}done in {dt:.1f}s")
|
|
53
|
+
for name, r in results.items():
|
|
54
|
+
print(f" {name:<6} new={r.get('events_new', 0):<6} seen={r.get('events_seen', 0):<7} "
|
|
55
|
+
f"dropped={r.get('dropped', 0)}")
|
|
56
|
+
print(f" sessions built: {n_sessions} · open loops: {loop_stats['open']}")
|
|
57
|
+
if degraded:
|
|
58
|
+
print(f" DEGRADED SOURCES: {degraded}")
|
|
59
|
+
if not args.dry_run:
|
|
60
|
+
row = con.execute("SELECT COUNT(*) c FROM events").fetchone()
|
|
61
|
+
print(f" events total: {row['c']}")
|
|
62
|
+
con.close()
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_purge(args):
|
|
67
|
+
con = db.connect()
|
|
68
|
+
if args.source:
|
|
69
|
+
for t in ("events", "sessions"):
|
|
70
|
+
con.execute(f"DELETE FROM {t} WHERE source=?", (args.source,))
|
|
71
|
+
con.execute("DELETE FROM loops WHERE signal LIKE ?", (f"%{args.source}%",))
|
|
72
|
+
con.execute("DELETE FROM watermarks WHERE source=? OR source LIKE ?",
|
|
73
|
+
(args.source, f"{args.source}:%"))
|
|
74
|
+
if args.source == "cli":
|
|
75
|
+
con.execute("DELETE FROM file_state WHERE path LIKE '%/.claude/%'")
|
|
76
|
+
if args.source == "codex":
|
|
77
|
+
con.execute("DELETE FROM file_state WHERE path LIKE '%/.codex/%'")
|
|
78
|
+
print(f"purged source={args.source}")
|
|
79
|
+
if args.before:
|
|
80
|
+
con.execute("DELETE FROM events WHERE ts < ?", (args.before,))
|
|
81
|
+
con.execute("DELETE FROM sessions WHERE ended_at < ?", (args.before,))
|
|
82
|
+
print(f"purged events before {args.before}")
|
|
83
|
+
con.commit()
|
|
84
|
+
con.execute("VACUUM")
|
|
85
|
+
db.enforce_perms()
|
|
86
|
+
con.close()
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_status(args):
|
|
91
|
+
con = db.connect()
|
|
92
|
+
print(f"\nconfig: {config.config_dir() / 'config.toml'}"
|
|
93
|
+
f"{'' if (config.config_dir() / 'config.toml').is_file() else ' (not found — opsroom init)'}")
|
|
94
|
+
print(f"data: {config.data_dir()}\n\nSOURCE STATUS")
|
|
95
|
+
for r in con.execute("SELECT * FROM watermarks WHERE source NOT LIKE 'git:%' ORDER BY source"):
|
|
96
|
+
print(f" {r['source']:<8} {r['status']:<10} last run {r['last_run'] or '-'}")
|
|
97
|
+
for r in con.execute("SELECT source, COUNT(*) c FROM events GROUP BY source"):
|
|
98
|
+
print(f" {r['source']:<8} {r['c']} events")
|
|
99
|
+
con.close()
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main():
|
|
104
|
+
p = argparse.ArgumentParser(prog="opsroom", description=__doc__)
|
|
105
|
+
sub = p.add_subparsers(dest="cmd") # no subcommand → `opsroom serve` (the app)
|
|
106
|
+
ip = sub.add_parser("init", help="interactive setup: ventures, goal, notes, trackers")
|
|
107
|
+
ip.add_argument("--yes", action="store_true", help="accept detected defaults, no prompts")
|
|
108
|
+
sp = sub.add_parser("sync", help="ingest all sources (read-only on sources)")
|
|
109
|
+
sp.add_argument("--source", help="comma-separated subset: cli,codex,git,fs,notes,chat")
|
|
110
|
+
sp.add_argument("--dry-run", action="store_true", help="parse + count, write nothing")
|
|
111
|
+
st = sub.add_parser("sitrep", help="operator SITREP: goal clock, cash, leads, pipeline, leak, one move")
|
|
112
|
+
st.add_argument("--write", action="store_true", help="append to the daily note (default: print only)")
|
|
113
|
+
sub.add_parser("dash", help="operator console (single local HTML file)").add_argument(
|
|
114
|
+
"--no-open", action="store_true")
|
|
115
|
+
vp2 = sub.add_parser("serve", help="the console as a live local app: buttons write back "
|
|
116
|
+
"(touches, follow-ups, cash, leads). Loopback only.")
|
|
117
|
+
vp2.add_argument("--port", type=int, default=7337)
|
|
118
|
+
vp2.add_argument("--no-open", action="store_true")
|
|
119
|
+
vp2.add_argument("--always-on", action="store_true",
|
|
120
|
+
help="macOS: install a launchd agent so the console survives reboots")
|
|
121
|
+
sub.add_parser("today", help="what did I actually do today").add_argument(
|
|
122
|
+
"--offset", type=int, default=0, help="days back")
|
|
123
|
+
sub.add_parser("week", help="7-day activity + drift")
|
|
124
|
+
lp = sub.add_parser("loops", help="open loops with evidence")
|
|
125
|
+
lp.add_argument("--all", action="store_true")
|
|
126
|
+
lp.add_argument("--dismiss", metavar="ID_PREFIX")
|
|
127
|
+
dp = sub.add_parser("drift", help="effort vs revenue for the week")
|
|
128
|
+
dp.add_argument("--weeks-ago", type=int, default=0)
|
|
129
|
+
sub.add_parser("venture", help="one venture's ledger").add_argument("name")
|
|
130
|
+
sub.add_parser("search", help="full-text search the ledger").add_argument("query", nargs="+")
|
|
131
|
+
vp = sub.add_parser("daily", help="append-only activity ledger into the daily note")
|
|
132
|
+
vp.add_argument("--write", action="store_true")
|
|
133
|
+
sub.add_parser("demo", help="spin up a fictional portfolio and open the console")
|
|
134
|
+
pp = sub.add_parser("purge", help="shrink the blast radius")
|
|
135
|
+
pp.add_argument("--source")
|
|
136
|
+
pp.add_argument("--before", metavar="ISO_DATE")
|
|
137
|
+
sub.add_parser("status", help="config + watermarks + row counts per source")
|
|
138
|
+
args = p.parse_args()
|
|
139
|
+
|
|
140
|
+
if args.cmd is None: # bare `opsroom` = the live console
|
|
141
|
+
from . import serve as _serve
|
|
142
|
+
return _serve.serve() or 0
|
|
143
|
+
if args.cmd == "init":
|
|
144
|
+
from . import setup
|
|
145
|
+
return setup.run(yes=args.yes)
|
|
146
|
+
if args.cmd == "demo":
|
|
147
|
+
from . import demo
|
|
148
|
+
return demo.run()
|
|
149
|
+
if args.cmd == "serve":
|
|
150
|
+
from . import serve as _serve
|
|
151
|
+
if args.always_on:
|
|
152
|
+
return 0 if _serve.install_always_on(port=args.port) else 1
|
|
153
|
+
return _serve.serve(port=args.port, open_browser=not args.no_open) or 0
|
|
154
|
+
if args.cmd == "sync":
|
|
155
|
+
return cmd_sync(args)
|
|
156
|
+
if args.cmd == "purge":
|
|
157
|
+
if not (args.source or args.before):
|
|
158
|
+
p.error("purge needs --source or --before")
|
|
159
|
+
return cmd_purge(args)
|
|
160
|
+
if args.cmd == "status":
|
|
161
|
+
return cmd_status(args)
|
|
162
|
+
|
|
163
|
+
con = db.connect()
|
|
164
|
+
try:
|
|
165
|
+
if args.cmd == "today":
|
|
166
|
+
views.today(con, args.offset)
|
|
167
|
+
elif args.cmd == "week":
|
|
168
|
+
views.week(con)
|
|
169
|
+
elif args.cmd == "loops":
|
|
170
|
+
if args.dismiss:
|
|
171
|
+
views.dismiss_loop(con, args.dismiss)
|
|
172
|
+
con.commit()
|
|
173
|
+
else:
|
|
174
|
+
views.loops(con, args.all)
|
|
175
|
+
elif args.cmd == "drift":
|
|
176
|
+
views.drift(con, args.weeks_ago)
|
|
177
|
+
elif args.cmd == "venture":
|
|
178
|
+
views.venture_view(con, args.name)
|
|
179
|
+
elif args.cmd == "search":
|
|
180
|
+
views.search(con, " ".join(args.query))
|
|
181
|
+
elif args.cmd == "dash":
|
|
182
|
+
path = views.dash(con)
|
|
183
|
+
if not args.no_open:
|
|
184
|
+
import subprocess
|
|
185
|
+
import sys as _s
|
|
186
|
+
opener = {"darwin": "open", "linux": "xdg-open"}.get(_s.platform)
|
|
187
|
+
if opener:
|
|
188
|
+
subprocess.run([opener, path], check=False)
|
|
189
|
+
elif args.cmd == "sitrep":
|
|
190
|
+
views.sitrep(con, write=args.write)
|
|
191
|
+
elif args.cmd == "daily":
|
|
192
|
+
views.daily_writeback(con, dry_run=not args.write)
|
|
193
|
+
finally:
|
|
194
|
+
con.close()
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
sys.exit(main())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Shared collector plumbing: redact-gated event emission, per-file watermarks."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from .. import redact as _redact
|
|
8
|
+
|
|
9
|
+
MAX_DETAIL = 4096
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def norm_ts(ts: str) -> str:
|
|
13
|
+
"""Normalize any ISO8601 timestamp (Z, ±hh:mm, or naive) to canonical UTC 'YYYY-MM-DDTHH:MM:SS.mmmZ'
|
|
14
|
+
so lexicographic SQL comparisons are correct across sources."""
|
|
15
|
+
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
16
|
+
if dt.tzinfo is None:
|
|
17
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
18
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Emitter:
|
|
22
|
+
"""Buffers redacted events and writes them idempotently. Fail-closed on redaction errors."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, con, dry_run: bool = False):
|
|
25
|
+
self.con = con
|
|
26
|
+
self.dry_run = dry_run
|
|
27
|
+
self.inserted = 0
|
|
28
|
+
self.seen = 0
|
|
29
|
+
self.dropped = 0
|
|
30
|
+
|
|
31
|
+
def emit(self, *, ts, source, kind, actor, summary, detail="", session_id=None,
|
|
32
|
+
venture=None, project=None, artifacts=None, raw_ref=None, is_sidechain=0):
|
|
33
|
+
self.seen += 1
|
|
34
|
+
try:
|
|
35
|
+
ts = norm_ts(ts)
|
|
36
|
+
except (ValueError, TypeError):
|
|
37
|
+
self.dropped += 1
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
# redact BEFORE truncating: a secret straddling the cut would otherwise be
|
|
41
|
+
# sliced so the pattern no longer matches, storing its unredacted head.
|
|
42
|
+
rs, h1 = _redact.redact(summary or "")
|
|
43
|
+
rd, h2 = _redact.redact(detail or "")
|
|
44
|
+
summary, detail = rs[:512], rd[:MAX_DETAIL]
|
|
45
|
+
redacted = 1 if (h1 + h2) else 0
|
|
46
|
+
except Exception as e: # fail closed: never write unredacted content
|
|
47
|
+
self.dropped += 1
|
|
48
|
+
print(f" [redactor] dropped event ({source}/{kind}): {type(e).__name__}", file=sys.stderr)
|
|
49
|
+
return
|
|
50
|
+
eid = hashlib.sha256(
|
|
51
|
+
f"{source}|{session_id}|{ts}|{kind}|{raw_ref}|{summary[:200]}".encode()).hexdigest()
|
|
52
|
+
if self.dry_run:
|
|
53
|
+
self.inserted += 1
|
|
54
|
+
return
|
|
55
|
+
cur = self.con.execute(
|
|
56
|
+
"""INSERT OR IGNORE INTO events
|
|
57
|
+
(id, ts, source, session_id, venture, project, kind, actor, summary, detail,
|
|
58
|
+
artifacts, raw_ref, is_sidechain, redacted)
|
|
59
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
60
|
+
(eid, ts, source, session_id, venture, project, kind, actor, summary, detail,
|
|
61
|
+
json.dumps(artifacts) if artifacts else None, raw_ref, is_sidechain, redacted))
|
|
62
|
+
self.inserted += cur.rowcount
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def file_changed(con, path: str, mtime: float, size: int):
|
|
66
|
+
"""Return (changed, lines_already_ingested). Reparse fully if the file shrank."""
|
|
67
|
+
row = con.execute("SELECT mtime, size, lines FROM file_state WHERE path=?", (path,)).fetchone()
|
|
68
|
+
if row is None:
|
|
69
|
+
return True, 0
|
|
70
|
+
if row["mtime"] == mtime and row["size"] == size:
|
|
71
|
+
return False, row["lines"]
|
|
72
|
+
if size < row["size"]:
|
|
73
|
+
return True, 0 # truncated/rewritten: reparse
|
|
74
|
+
return True, row["lines"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def record_file(con, path: str, mtime: float, size: int, lines: int):
|
|
78
|
+
con.execute(
|
|
79
|
+
"""INSERT INTO file_state(path, mtime, size, lines) VALUES (?,?,?,?)
|
|
80
|
+
ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime, size=excluded.size, lines=excluded.lines""",
|
|
81
|
+
(path, mtime, size, lines))
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Collector: Desktop/web chat via the vendor data-export flow (manual drop).
|
|
2
|
+
Drop conversations.json (or the export .zip) into the configured chat_drop_dir —
|
|
3
|
+
Anthropic (Claude) and OpenAI (ChatGPT) exports are both recognized by shape.
|
|
4
|
+
The zip is deleted after successful ingest (spec P0 #7); extracted JSON is kept out of
|
|
5
|
+
sync roots and removed too."""
|
|
6
|
+
import json
|
|
7
|
+
import zipfile
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .. import ventures
|
|
12
|
+
from . import Emitter
|
|
13
|
+
|
|
14
|
+
DROP_DIR = Path(ventures.CHAT_DROP_DIR)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ingest_conversations(em: Emitter, data: list, ref: str) -> int:
|
|
18
|
+
n = 0
|
|
19
|
+
for conv in data:
|
|
20
|
+
cid = conv.get("uuid") or conv.get("id") or "?"
|
|
21
|
+
name = conv.get("name") or "(untitled chat)"
|
|
22
|
+
venture = ventures.attribute_text(name)
|
|
23
|
+
for i, m in enumerate(conv.get("chat_messages", [])):
|
|
24
|
+
text = m.get("text")
|
|
25
|
+
if not text and isinstance(m.get("content"), list):
|
|
26
|
+
text = "\n".join(c.get("text", "") for c in m["content"]
|
|
27
|
+
if isinstance(c, dict) and c.get("type") == "text")
|
|
28
|
+
text = (text or "").strip()
|
|
29
|
+
if not text:
|
|
30
|
+
continue
|
|
31
|
+
sender = m.get("sender", "?")
|
|
32
|
+
ts = m.get("created_at") or conv.get("created_at") or datetime.now(timezone.utc).isoformat()
|
|
33
|
+
v = venture if venture != "unknown" else ventures.attribute_text(text[:500])
|
|
34
|
+
em.emit(ts=ts, source="chat", kind="prompt" if sender == "human" else "response",
|
|
35
|
+
actor="you" if sender == "human" else "assistant",
|
|
36
|
+
session_id=f"chat:{cid}", venture=v, project=name[:60],
|
|
37
|
+
summary=text.strip().split("\n")[0][:180], detail=text,
|
|
38
|
+
raw_ref=f"{ref}#conv={cid}&msg={i}")
|
|
39
|
+
n += 1
|
|
40
|
+
return n
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _openai_text(msg: dict) -> str:
|
|
44
|
+
content = msg.get("content") or {}
|
|
45
|
+
if content.get("content_type") not in (None, "text", "multimodal_text"):
|
|
46
|
+
return ""
|
|
47
|
+
parts = content.get("parts") or []
|
|
48
|
+
return "\n".join(p for p in parts if isinstance(p, str)).strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ingest_openai(em: Emitter, data: list, ref: str) -> int:
|
|
52
|
+
"""OpenAI ChatGPT export: each conversation is a mapping-tree of nodes."""
|
|
53
|
+
n = 0
|
|
54
|
+
for conv in data:
|
|
55
|
+
cid = conv.get("conversation_id") or conv.get("id") or "?"
|
|
56
|
+
name = conv.get("title") or "(untitled chat)"
|
|
57
|
+
venture = ventures.attribute_text(name)
|
|
58
|
+
nodes = conv.get("mapping") or {}
|
|
59
|
+
msgs = []
|
|
60
|
+
for node in nodes.values():
|
|
61
|
+
m = (node or {}).get("message")
|
|
62
|
+
if not isinstance(m, dict):
|
|
63
|
+
continue
|
|
64
|
+
role = ((m.get("author") or {}).get("role"))
|
|
65
|
+
if role not in ("user", "assistant"):
|
|
66
|
+
continue
|
|
67
|
+
text = _openai_text(m)
|
|
68
|
+
if not text:
|
|
69
|
+
continue
|
|
70
|
+
msgs.append((m.get("create_time") or conv.get("create_time") or 0, role, text, m.get("id", "?")))
|
|
71
|
+
for ct, role, text, mid in sorted(msgs):
|
|
72
|
+
ts = datetime.fromtimestamp(ct, timezone.utc).isoformat() if ct else \
|
|
73
|
+
datetime.now(timezone.utc).isoformat()
|
|
74
|
+
v = venture if venture != "unknown" else ventures.attribute_text(text[:500])
|
|
75
|
+
em.emit(ts=ts, source="chat", kind="prompt" if role == "user" else "response",
|
|
76
|
+
actor="you" if role == "user" else "chatgpt",
|
|
77
|
+
session_id=f"chatgpt:{cid}", venture=v, project=name[:60],
|
|
78
|
+
summary=text.split("\n")[0][:180], detail=text,
|
|
79
|
+
raw_ref=f"{ref}#conv={cid}&msg={mid}")
|
|
80
|
+
n += 1
|
|
81
|
+
return n
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ingest_any(em: Emitter, data, ref: str) -> int:
|
|
85
|
+
"""Sniff export format: Anthropic conversations carry chat_messages, OpenAI carry mapping."""
|
|
86
|
+
if not isinstance(data, list):
|
|
87
|
+
return 0
|
|
88
|
+
if any(isinstance(c, dict) and "mapping" in c for c in data):
|
|
89
|
+
return _ingest_openai(em, data, ref)
|
|
90
|
+
return _ingest_conversations(em, data, ref)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def collect(con, dry_run: bool = False) -> dict:
|
|
94
|
+
em = Emitter(con, dry_run)
|
|
95
|
+
if not DROP_DIR.is_dir():
|
|
96
|
+
return {"status": "no drop dir", "events_new": 0, "events_seen": 0, "dropped": 0}
|
|
97
|
+
ingested, cleanup = [], []
|
|
98
|
+
for f in sorted(DROP_DIR.iterdir()):
|
|
99
|
+
try:
|
|
100
|
+
if f.suffix == ".json" and "conversation" in f.name.lower():
|
|
101
|
+
data = json.loads(f.read_text(errors="replace"))
|
|
102
|
+
_ingest_any(em, data, str(f))
|
|
103
|
+
ingested.append(f.name)
|
|
104
|
+
cleanup.append(f)
|
|
105
|
+
elif f.suffix == ".zip":
|
|
106
|
+
with zipfile.ZipFile(f) as z:
|
|
107
|
+
for info in z.infolist():
|
|
108
|
+
if info.filename.endswith("conversations.json"):
|
|
109
|
+
if info.file_size > 500_000_000: # decompression-bomb cap
|
|
110
|
+
print(f" [chat] skipped {info.filename}: "
|
|
111
|
+
f"{info.file_size // 1_000_000}MB exceeds 500MB cap")
|
|
112
|
+
continue
|
|
113
|
+
data = json.loads(z.read(info).decode(errors="replace"))
|
|
114
|
+
_ingest_any(em, data, f"{f}!{info.filename}")
|
|
115
|
+
ingested.append(f"{f.name}!{info.filename}")
|
|
116
|
+
cleanup.append(f)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print(f" [chat] failed on {f.name}: {e}")
|
|
119
|
+
if not dry_run:
|
|
120
|
+
for f in cleanup:
|
|
121
|
+
try:
|
|
122
|
+
f.unlink() # spec: delete export after ingest to shrink blast radius
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
return {"ingested": ingested, "events_new": em.inserted, "events_seen": em.seen,
|
|
126
|
+
"dropped": em.dropped}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Collector: Claude Code CLI session logs (~/.claude/projects/**/*.jsonl) + task state
|
|
2
|
+
(~/.claude/tasks/<sessionId>/*.json). Strictly read-only on sources."""
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .. import ventures
|
|
8
|
+
from . import Emitter, file_changed, record_file
|
|
9
|
+
|
|
10
|
+
CLAUDE_DIR = Path.home() / ".claude"
|
|
11
|
+
FILE_TOOLS = {"Edit", "Write", "NotebookEdit", "Read"}
|
|
12
|
+
EDIT_TOOLS = {"Edit", "Write", "NotebookEdit"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _project_of(cwd: str) -> str:
|
|
16
|
+
if not cwd:
|
|
17
|
+
return "?"
|
|
18
|
+
home = str(Path.home())
|
|
19
|
+
rel = cwd[len(home):].strip("/") if cwd.startswith(home) else cwd
|
|
20
|
+
return rel or "~"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _first_line(s: str, n: int = 180) -> str:
|
|
24
|
+
return (s or "").strip().split("\n")[0][:n]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _blocks(msg) -> list:
|
|
28
|
+
c = (msg or {}).get("content")
|
|
29
|
+
if isinstance(c, str):
|
|
30
|
+
return [{"type": "text", "text": c}]
|
|
31
|
+
return c if isinstance(c, list) else []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _tool_paths(name: str, inp: dict) -> list:
|
|
35
|
+
paths = []
|
|
36
|
+
if isinstance(inp, dict):
|
|
37
|
+
for k in ("file_path", "path", "notebook_path"):
|
|
38
|
+
if isinstance(inp.get(k), str):
|
|
39
|
+
paths.append(inp[k])
|
|
40
|
+
return paths
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_line(em: Emitter, d: dict, raw_ref: str) -> None:
|
|
44
|
+
t = d.get("type")
|
|
45
|
+
if t not in ("user", "assistant") or d.get("isMeta"):
|
|
46
|
+
return
|
|
47
|
+
ts = d.get("timestamp")
|
|
48
|
+
if not ts:
|
|
49
|
+
return
|
|
50
|
+
base = dict(ts=ts, source="cli", session_id=d.get("sessionId"),
|
|
51
|
+
venture=ventures.attribute(d.get("cwd", "")),
|
|
52
|
+
project=_project_of(d.get("cwd", "")), raw_ref=raw_ref,
|
|
53
|
+
is_sidechain=1 if d.get("isSidechain") else 0)
|
|
54
|
+
msg = d.get("message") or {}
|
|
55
|
+
if t == "user":
|
|
56
|
+
texts, results = [], []
|
|
57
|
+
for b in _blocks(msg):
|
|
58
|
+
if not isinstance(b, dict):
|
|
59
|
+
continue
|
|
60
|
+
if b.get("type") == "text":
|
|
61
|
+
texts.append(b.get("text") or "")
|
|
62
|
+
elif b.get("type") == "tool_result":
|
|
63
|
+
results.append(b)
|
|
64
|
+
text = "\n".join(x for x in texts if x).strip()
|
|
65
|
+
if text and not text.startswith(("<system-reminder", "<command-name", "<local-command")):
|
|
66
|
+
em.emit(kind="prompt", actor="you", summary=_first_line(text), detail=text, **base)
|
|
67
|
+
for r in results:
|
|
68
|
+
content = r.get("content")
|
|
69
|
+
if isinstance(content, list):
|
|
70
|
+
content = "\n".join(str(x.get("text", "")) for x in content if isinstance(x, dict))
|
|
71
|
+
content = str(content or "")
|
|
72
|
+
failed = bool(r.get("is_error"))
|
|
73
|
+
em.emit(kind="error" if failed else "tool_result", actor="claude",
|
|
74
|
+
summary=("FAILED: " if failed else "") + _first_line(content),
|
|
75
|
+
detail=content, **base)
|
|
76
|
+
else: # assistant
|
|
77
|
+
if d.get("isApiErrorMessage"):
|
|
78
|
+
em.emit(kind="error", actor="claude",
|
|
79
|
+
summary=f"API error {d.get('apiErrorStatus', '?')}", **base)
|
|
80
|
+
return
|
|
81
|
+
for b in _blocks(msg):
|
|
82
|
+
if not isinstance(b, dict):
|
|
83
|
+
continue
|
|
84
|
+
if b.get("type") == "text" and (b.get("text") or "").strip():
|
|
85
|
+
em.emit(kind="response", actor="claude", summary=_first_line(b["text"]),
|
|
86
|
+
detail=b["text"], **base)
|
|
87
|
+
elif b.get("type") == "tool_use":
|
|
88
|
+
name = b.get("name", "?")
|
|
89
|
+
inp = b.get("input") or {}
|
|
90
|
+
paths = _tool_paths(name, inp)
|
|
91
|
+
kind = "file_edit" if name in EDIT_TOOLS else "tool_call"
|
|
92
|
+
hint = paths[0] if paths else _first_line(json.dumps(inp)[:150], 120)
|
|
93
|
+
em.emit(kind=kind, actor="claude", summary=f"{name}: {hint}",
|
|
94
|
+
artifacts=paths or None, **base)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _collect_tasks(em: Emitter, con, session_meta: dict) -> None:
|
|
98
|
+
tasks_dir = CLAUDE_DIR / "tasks"
|
|
99
|
+
if not tasks_dir.is_dir():
|
|
100
|
+
return
|
|
101
|
+
for f in tasks_dir.glob("*/*.json"):
|
|
102
|
+
st = f.stat()
|
|
103
|
+
changed, _ = file_changed(con, str(f), st.st_mtime, st.st_size)
|
|
104
|
+
if not changed and not em.dry_run:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
d = json.loads(f.read_text(errors="replace"))
|
|
108
|
+
except Exception:
|
|
109
|
+
continue
|
|
110
|
+
sid = f.parent.name
|
|
111
|
+
meta = session_meta.get(sid) or _session_meta_from_db(con, sid)
|
|
112
|
+
ts = datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat()
|
|
113
|
+
em.emit(ts=ts, source="cli", kind="task", actor="claude", session_id=sid,
|
|
114
|
+
venture=meta.get("venture", "unknown"), project=meta.get("project", "?"),
|
|
115
|
+
summary=f"[{d.get('status', '?')}] {d.get('subject', '?')}",
|
|
116
|
+
detail=d.get("description", ""), raw_ref=str(f))
|
|
117
|
+
if not em.dry_run:
|
|
118
|
+
record_file(con, str(f), st.st_mtime, st.st_size, 1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _session_meta_from_db(con, sid: str) -> dict:
|
|
122
|
+
row = con.execute(
|
|
123
|
+
"SELECT venture, project FROM events WHERE session_id=? AND venture!='unknown' LIMIT 1",
|
|
124
|
+
(sid,)).fetchone()
|
|
125
|
+
return dict(row) if row else {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def collect(con, dry_run: bool = False) -> dict:
|
|
129
|
+
em = Emitter(con, dry_run)
|
|
130
|
+
files = sorted((CLAUDE_DIR / "projects").glob("*/*.jsonl"))
|
|
131
|
+
parsed_files = 0
|
|
132
|
+
session_meta = {}
|
|
133
|
+
for f in files:
|
|
134
|
+
st = f.stat()
|
|
135
|
+
changed, start_line = file_changed(con, str(f), st.st_mtime, st.st_size)
|
|
136
|
+
if not changed:
|
|
137
|
+
continue
|
|
138
|
+
parsed_files += 1
|
|
139
|
+
lineno = 0
|
|
140
|
+
try:
|
|
141
|
+
with open(f, errors="replace") as fh:
|
|
142
|
+
for lineno, line in enumerate(fh, 1):
|
|
143
|
+
if lineno <= start_line:
|
|
144
|
+
continue
|
|
145
|
+
line = line.strip()
|
|
146
|
+
if not line:
|
|
147
|
+
continue
|
|
148
|
+
try:
|
|
149
|
+
d = json.loads(line)
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
continue
|
|
152
|
+
if d.get("sessionId") and d.get("cwd"):
|
|
153
|
+
session_meta[d["sessionId"]] = {
|
|
154
|
+
"venture": ventures.attribute(d["cwd"]),
|
|
155
|
+
"project": _project_of(d["cwd"])}
|
|
156
|
+
_parse_line(em, d, f"{f}:{lineno}")
|
|
157
|
+
except OSError as e:
|
|
158
|
+
print(f" [cli] unreadable {f}: {e}")
|
|
159
|
+
continue
|
|
160
|
+
if not dry_run:
|
|
161
|
+
record_file(con, str(f), st.st_mtime, st.st_size, lineno)
|
|
162
|
+
_collect_tasks(em, con, session_meta)
|
|
163
|
+
return {"files_scanned": len(files), "files_parsed": parsed_files,
|
|
164
|
+
"sessions_seen": len(session_meta),
|
|
165
|
+
"events_new": em.inserted, "events_seen": em.seen, "dropped": em.dropped}
|