interlatch 0.22.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.
- chronicle/__init__.py +17 -0
- chronicle/__main__.py +3 -0
- chronicle/access.py +93 -0
- chronicle/agents.py +23 -0
- chronicle/analyze.py +498 -0
- chronicle/antigravity_parser.py +311 -0
- chronicle/artifacts.py +621 -0
- chronicle/bob_parser.py +259 -0
- chronicle/cases.py +75 -0
- chronicle/chat_import.py +325 -0
- chronicle/chatgpt_export.py +127 -0
- chronicle/claude_export.py +129 -0
- chronicle/cli.py +2980 -0
- chronicle/codex_cloud.py +138 -0
- chronicle/codex_parser.py +753 -0
- chronicle/config.py +627 -0
- chronicle/connectors.py +666 -0
- chronicle/container.py +202 -0
- chronicle/copilot_parser.py +494 -0
- chronicle/db.py +598 -0
- chronicle/desktop.py +543 -0
- chronicle/diagram.py +488 -0
- chronicle/digest.py +194 -0
- chronicle/export_md.py +210 -0
- chronicle/friction.py +841 -0
- chronicle/glossary.py +522 -0
- chronicle/groups.py +181 -0
- chronicle/hooks.py +169 -0
- chronicle/hub.py +2291 -0
- chronicle/i18n.py +37 -0
- chronicle/i18n_ja.py +421 -0
- chronicle/ingest.py +1190 -0
- chronicle/install.py +590 -0
- chronicle/instructions.py +699 -0
- chronicle/ladder.py +244 -0
- chronicle/learning.py +170 -0
- chronicle/llm.py +570 -0
- chronicle/mcp_server.py +403 -0
- chronicle/menubar.py +771 -0
- chronicle/migrate.py +580 -0
- chronicle/mirror.py +453 -0
- chronicle/news.py +110 -0
- chronicle/notify.py +92 -0
- chronicle/parser.py +908 -0
- chronicle/people.py +536 -0
- chronicle/pg.py +122 -0
- chronicle/pricing.py +168 -0
- chronicle/providers.py +591 -0
- chronicle/redact.py +50 -0
- chronicle/reviews.py +251 -0
- chronicle/screen.py +308 -0
- chronicle/search.py +238 -0
- chronicle/server.py +3009 -0
- chronicle/session_export.py +178 -0
- chronicle/statusline.py +258 -0
- chronicle/suggest.py +643 -0
- chronicle/synthesize.py +365 -0
- chronicle/systems.py +1265 -0
- chronicle/tailnet.py +97 -0
- chronicle/team_store.py +450 -0
- chronicle/update.py +404 -0
- chronicle/util.py +217 -0
- chronicle/views.py +249 -0
- chronicle/web/PLEX-OFL.txt +93 -0
- chronicle/web/app.css +2350 -0
- chronicle/web/app.js +7587 -0
- chronicle/web/art-book.webp +0 -0
- chronicle/web/art-camera.webp +0 -0
- chronicle/web/art-chip.webp +0 -0
- chronicle/web/art-clock.webp +0 -0
- chronicle/web/art-cloud.webp +0 -0
- chronicle/web/art-coins.webp +0 -0
- chronicle/web/art-container.webp +0 -0
- chronicle/web/art-dashboard.webp +0 -0
- chronicle/web/art-dex.webp +0 -0
- chronicle/web/art-document.webp +0 -0
- chronicle/web/art-flag.webp +0 -0
- chronicle/web/art-folder.webp +0 -0
- chronicle/web/art-gateway.webp +0 -0
- chronicle/web/art-gears.webp +0 -0
- chronicle/web/art-glitch.webp +0 -0
- chronicle/web/art-globe.webp +0 -0
- chronicle/web/art-laptop.webp +0 -0
- chronicle/web/art-log.webp +0 -0
- chronicle/web/art-lumi.webp +0 -0
- chronicle/web/art-magnifier.webp +0 -0
- chronicle/web/art-pip.webp +0 -0
- chronicle/web/art-plug.webp +0 -0
- chronicle/web/art-rack.webp +0 -0
- chronicle/web/art-tink.webp +0 -0
- chronicle/web/boot.js +25 -0
- chronicle/web/favicon.png +0 -0
- chronicle/web/i18n.js +50 -0
- chronicle/web/icon-180.png +0 -0
- chronicle/web/icon-192.png +0 -0
- chronicle/web/icon-512.png +0 -0
- chronicle/web/icon.png +0 -0
- chronicle/web/index.html +92 -0
- chronicle/web/ja.js +1880 -0
- chronicle/web/learning.js +438 -0
- chronicle/web/manifest.webmanifest +15 -0
- chronicle/web/panel.css +175 -0
- chronicle/web/panel.html +14 -0
- chronicle/web/panel.js +241 -0
- chronicle/web/plex-mono-400.woff2 +0 -0
- chronicle/web/plex-mono-500.woff2 +0 -0
- chronicle/web/plex-mono-600.woff2 +0 -0
- chronicle/web/plex-sans-400.woff2 +0 -0
- chronicle/web/plex-sans-500.woff2 +0 -0
- chronicle/web/plex-sans-600.woff2 +0 -0
- chronicle/web/plex-sans-700.woff2 +0 -0
- chronicle/web/rough.js +2 -0
- chronicle/worker.py +390 -0
- interlatch-0.22.0.dist-info/METADATA +221 -0
- interlatch-0.22.0.dist-info/RECORD +118 -0
- interlatch-0.22.0.dist-info/WHEEL +4 -0
- interlatch-0.22.0.dist-info/entry_points.txt +3 -0
- interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
chronicle/bob_parser.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Parse IBM Bob tasks (~/.bob/db/bob.db) into the same model as Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
bob.db holds `tasks` (directory, title, git info, costs JSON, status) and `messages` (one chat message per row in
|
|
4
|
+
`data`). Tool calls come in two shapes: OpenAI-style `tool_calls[].function` answered by a `tool_call_id`, and Bob's
|
|
5
|
+
own `toolCalls[]` ({id, name, arguments}) answered by a tool message whose `toolUsage.signature` names the call.
|
|
6
|
+
A subagent's whole conversation rides inside its spawn_subagent result (`messages`, `_meta.subagentId`). Each
|
|
7
|
+
assistant message reports its own spend in `_meta.spend` (cost, contextTokens). The database also stores login
|
|
8
|
+
state elsewhere in ~/.bob; only these two tables are read, read-only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import posixpath
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .copilot_parser import _Builder, _args, estimate_cost, finish_session, ms_to_iso, uri_path
|
|
21
|
+
from .parser import ApiCall, FileStat, ParsedSession, Subagent
|
|
22
|
+
from .pricing import normalize_model
|
|
23
|
+
from .util import one_line, safe_text
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("chronicle.bob")
|
|
26
|
+
|
|
27
|
+
BOB_PARSER_VERSION = 2 # 2: Bob's own toolCalls shape, subagents, per-message spend, relative paths
|
|
28
|
+
_ENV_RE = re.compile(r"<environment_details>.*?</environment_details>", re.S)
|
|
29
|
+
_TASK_RE = re.compile(r"^\s*<(?:task|user_message)>(.*?)</(?:task|user_message)>\s*$", re.S)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bob_db(home: Path) -> Path:
|
|
33
|
+
return home / "db" / "bob.db"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_tasks(home: Path) -> list[dict]:
|
|
37
|
+
"""Tasks with their messages, oldest message first. Read-only; returns [] if the database is unreadable."""
|
|
38
|
+
db = bob_db(home)
|
|
39
|
+
if not db.exists():
|
|
40
|
+
return []
|
|
41
|
+
try:
|
|
42
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
|
|
43
|
+
conn.row_factory = sqlite3.Row
|
|
44
|
+
tasks = [dict(r) for r in conn.execute("SELECT * FROM tasks ORDER BY created_at")]
|
|
45
|
+
msgs: dict[str, list[dict]] = {}
|
|
46
|
+
for r in conn.execute("SELECT id, task_id, role, data, created_at FROM messages ORDER BY created_at, id"):
|
|
47
|
+
msgs.setdefault(r["task_id"], []).append(dict(r))
|
|
48
|
+
conn.close()
|
|
49
|
+
except sqlite3.Error as exc:
|
|
50
|
+
log.warning("bob.db unreadable: %s", exc)
|
|
51
|
+
return []
|
|
52
|
+
for t in tasks:
|
|
53
|
+
t["messages"] = msgs.get(t["id"], [])
|
|
54
|
+
return tasks
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def task_folder(task: dict) -> str | None:
|
|
58
|
+
"""The folder a task ran in: its directory, else its project (a file: URI)."""
|
|
59
|
+
return uri_path(task.get("directory") or task.get("project_id") or "") or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def task_signature(task: dict) -> str:
|
|
63
|
+
last = task["messages"][-1] if task["messages"] else {}
|
|
64
|
+
return f"{task.get('updated_at')}:{len(task['messages'])}:{last.get('id')}:{task.get('status')}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _text(content) -> str:
|
|
68
|
+
if isinstance(content, str):
|
|
69
|
+
return content
|
|
70
|
+
if isinstance(content, list):
|
|
71
|
+
return "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") in ("text", "input_text", "output_text"))
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _user_text(content) -> str:
|
|
76
|
+
text = _ENV_RE.sub("", _text(content)).strip()
|
|
77
|
+
m = _TASK_RE.match(text)
|
|
78
|
+
return m.group(1).strip() if m else text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _calls(d: dict) -> list[tuple[str | None, str, dict]]:
|
|
82
|
+
"""(call id, tool name, arguments) for each tool call in an assistant message, in either shape."""
|
|
83
|
+
out = []
|
|
84
|
+
for tc in d.get("tool_calls") or []:
|
|
85
|
+
fn = tc.get("function") if isinstance(tc, dict) else None
|
|
86
|
+
if isinstance(fn, dict):
|
|
87
|
+
out.append((tc.get("id"), fn.get("name") or "tool", _args(fn.get("arguments"))))
|
|
88
|
+
for tc in d.get("toolCalls") or []:
|
|
89
|
+
if isinstance(tc, dict):
|
|
90
|
+
out.append((tc.get("id"), tc.get("name") or "tool", _args(tc.get("arguments"))))
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _signature(d: dict) -> dict:
|
|
95
|
+
usage = d.get("toolUsage")
|
|
96
|
+
sig = usage.get("signature") if isinstance(usage, dict) else None
|
|
97
|
+
return sig if isinstance(sig, dict) else {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _meta(d: dict) -> dict:
|
|
101
|
+
return d["_meta"] if isinstance(d.get("_meta"), dict) else {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _absolute(args: dict, cwd: str | None) -> dict:
|
|
105
|
+
"""Bob passes workspace-relative paths (docs/x.html); resolve them against the task's directory."""
|
|
106
|
+
if not cwd or not cwd.startswith("/"):
|
|
107
|
+
return args
|
|
108
|
+
out = dict(args)
|
|
109
|
+
for key in ("path", "file_path", "filePath"):
|
|
110
|
+
v = out.get(key)
|
|
111
|
+
if isinstance(v, str) and v and not v.startswith(("/", "~")) and ":" not in v:
|
|
112
|
+
out[key] = posixpath.normpath(posixpath.join(cwd, v))
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _patch_lines(patch: str) -> tuple[int, int]:
|
|
117
|
+
"""Lines added and removed in a unified diff (the ---/+++ file headers before the first hunk aren't lines)."""
|
|
118
|
+
added = removed = 0
|
|
119
|
+
in_hunk = False
|
|
120
|
+
for line in patch.splitlines():
|
|
121
|
+
if line.startswith("@@"):
|
|
122
|
+
in_hunk = True
|
|
123
|
+
elif in_hunk and line.startswith("+"):
|
|
124
|
+
added += 1
|
|
125
|
+
elif in_hunk and line.startswith("-"):
|
|
126
|
+
removed += 1
|
|
127
|
+
return added, removed
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _count_changes(ps: ParsedSession, changes: dict, counted: str | None) -> None:
|
|
131
|
+
"""A tool result's `_meta.changes` ({file URI: {before, after, patch}}): what the call did to each file on disk."""
|
|
132
|
+
for uri, change in changes.items():
|
|
133
|
+
path = uri_path(uri)
|
|
134
|
+
if not path or not isinstance(change, dict):
|
|
135
|
+
continue
|
|
136
|
+
fs = ps.files.setdefault(path, FileStat())
|
|
137
|
+
if path != counted: # the call's own file was already counted as a write or an edit
|
|
138
|
+
fs.edits += 1
|
|
139
|
+
if isinstance(change.get("patch"), str):
|
|
140
|
+
added, removed = _patch_lines(change["patch"])
|
|
141
|
+
fs.lines_added += added
|
|
142
|
+
fs.lines_removed += removed
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _feed(b: _Builder, msgs: list[tuple[str | None, dict]], cwd: str | None) -> None:
|
|
146
|
+
"""Replay one thread's messages (the task's, or a subagent's) into `b`."""
|
|
147
|
+
ps = b.ps
|
|
148
|
+
results: dict[str, dict] = {}
|
|
149
|
+
for _, d in msgs:
|
|
150
|
+
if d.get("role") != "tool":
|
|
151
|
+
continue
|
|
152
|
+
sig = _signature(d)
|
|
153
|
+
cid = d.get("tool_call_id") or sig.get("id")
|
|
154
|
+
if cid:
|
|
155
|
+
results[cid] = d
|
|
156
|
+
for ts, d in msgs:
|
|
157
|
+
role = d.get("role")
|
|
158
|
+
if role == "system":
|
|
159
|
+
b.event(ts, "system", "meta", f"[system prompt, {len(_text(d.get('content'))):,} chars]")
|
|
160
|
+
elif role == "user":
|
|
161
|
+
if b.agent_id: # a subagent's brief, not a prompt the user typed
|
|
162
|
+
b.event(ts, "user", "prompt", _user_text(d.get("content")))
|
|
163
|
+
else:
|
|
164
|
+
b.prompt(ts, _user_text(d.get("content")))
|
|
165
|
+
elif role == "assistant":
|
|
166
|
+
reasoning = d.get("reasoning") or d.get("reasoning_content")
|
|
167
|
+
if isinstance(reasoning, str) and reasoning.strip():
|
|
168
|
+
b.event(ts, "assistant", "thinking", reasoning)
|
|
169
|
+
text = _text(d.get("content"))
|
|
170
|
+
if text.strip():
|
|
171
|
+
b.event(ts, "assistant", "text", text)
|
|
172
|
+
spend = _meta(d).get("spend")
|
|
173
|
+
if isinstance(spend, dict) and (spend.get("cost") or spend.get("contextTokens")):
|
|
174
|
+
# Bob reports what each call cost and how big its context was, but not the token split:
|
|
175
|
+
# the context counts as input, and the cost is Bob's own figure rather than an estimate
|
|
176
|
+
ps.api_calls.append(ApiCall(b.agent_id, str(d.get("id") or ""), ts, None,
|
|
177
|
+
input_tokens=int(spend.get("contextTokens") or 0),
|
|
178
|
+
cost_usd=float(spend.get("cost") or 0)))
|
|
179
|
+
for cid, name, args in _calls(d):
|
|
180
|
+
res = results.get(cid) or {}
|
|
181
|
+
text = _text(res.get("content")) if res else None
|
|
182
|
+
sig = _signature(res)
|
|
183
|
+
is_error = bool(sig["isError"]) if "isError" in sig else bool(text) and text.lower().startswith(("error", "failed"))
|
|
184
|
+
rmeta = _meta(res)
|
|
185
|
+
call = b.tool(ts, cid, name, _absolute(args, cwd), result=text, is_error=is_error,
|
|
186
|
+
duration_ms=rmeta.get("durationMs") if isinstance(rmeta.get("durationMs"), int) else None)
|
|
187
|
+
if not is_error and isinstance(rmeta.get("changes"), dict):
|
|
188
|
+
_count_changes(ps, rmeta["changes"], call.file_path)
|
|
189
|
+
sub = res.get("messages")
|
|
190
|
+
if rmeta.get("subagentId") and isinstance(sub, list):
|
|
191
|
+
_subagent(b, str(rmeta["subagentId"]), rmeta, args, cid, sub, cwd)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _thread(rows: list[tuple[int | None, object]]) -> list[tuple[str | None, dict]]:
|
|
195
|
+
"""[(created_at ms, raw data)] -> [(iso timestamp, message dict)], skipping rows that aren't JSON objects."""
|
|
196
|
+
out = []
|
|
197
|
+
for created_at, raw in rows:
|
|
198
|
+
try:
|
|
199
|
+
d = json.loads(raw) if isinstance(raw, str) else raw
|
|
200
|
+
except (TypeError, ValueError):
|
|
201
|
+
continue
|
|
202
|
+
if isinstance(d, dict):
|
|
203
|
+
out.append((ms_to_iso(_meta(d).get("timestamp") or created_at), d))
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _subagent(parent: _Builder, aid: str, meta: dict, args: dict, call_id, msgs: list, cwd: str | None) -> None:
|
|
208
|
+
ps = parent.ps
|
|
209
|
+
sub = ps.subagents.setdefault(aid, Subagent(aid))
|
|
210
|
+
sub.agent_type = meta.get("agentType") or args.get("name") or None
|
|
211
|
+
sub.description = one_line(safe_text(str(args.get("description") or "")), 120) or None
|
|
212
|
+
sub.tool_use_id = call_id
|
|
213
|
+
b = _Builder(ps, agent_id=aid)
|
|
214
|
+
_feed(b, _thread([(None, m) for m in msgs]), cwd)
|
|
215
|
+
parent.stamps.extend(b.stamps)
|
|
216
|
+
stamps = sorted(s for s in b.stamps if s)
|
|
217
|
+
if stamps:
|
|
218
|
+
sub.started_at, sub.ended_at = stamps[0], stamps[-1]
|
|
219
|
+
calls = [c for c in ps.tool_calls if c.agent_id == aid]
|
|
220
|
+
sub.n_tool_calls, sub.n_tool_errors = len(calls), sum(c.is_error for c in calls)
|
|
221
|
+
for c in ps.api_calls:
|
|
222
|
+
if c.agent_id == aid:
|
|
223
|
+
sub.input_tokens += c.input_tokens
|
|
224
|
+
sub.est_cost_usd += c.cost_usd
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def parse_bob_task(task: dict) -> ParsedSession | None:
|
|
228
|
+
msgs = task.get("messages") or []
|
|
229
|
+
if not any(m["role"] == "user" for m in msgs):
|
|
230
|
+
return None
|
|
231
|
+
ps = ParsedSession(id=task["id"])
|
|
232
|
+
ps.project_path = task_folder(task)
|
|
233
|
+
ps.git_branch = task.get("git_branch") or None
|
|
234
|
+
ps.cc_version = task.get("version") or None
|
|
235
|
+
ps.entrypoint = f"bob:{task.get('task_type') or 'normal'}"
|
|
236
|
+
ps.custom_title = task.get("title") or None
|
|
237
|
+
b = _Builder(ps)
|
|
238
|
+
for ms in (task.get("created_at"), task.get("updated_at")):
|
|
239
|
+
if ms_to_iso(ms):
|
|
240
|
+
b.stamps.append(ms_to_iso(ms))
|
|
241
|
+
thread = _thread([(m.get("created_at"), m["data"]) for m in msgs])
|
|
242
|
+
ps.parse_errors += len(msgs) - len(thread)
|
|
243
|
+
_feed(b, thread, ps.project_path)
|
|
244
|
+
if task.get("status") == "error" and task.get("last_error"):
|
|
245
|
+
ps.n_api_errors += 1
|
|
246
|
+
b.event(b.stamps[-1] if b.stamps else None, "system", "api_error", safe_text(str(task["last_error"]))[:300])
|
|
247
|
+
try:
|
|
248
|
+
costs = json.loads(task.get("costs") or "{}")
|
|
249
|
+
except ValueError:
|
|
250
|
+
costs = {}
|
|
251
|
+
if isinstance(costs, dict) and not ps.api_calls: # per-message spend, when Bob wrote it, already covers the task
|
|
252
|
+
model = normalize_model(costs.get("model"))
|
|
253
|
+
inp, out, cr, cw = (int(costs.get(k) or 0) for k in ("input", "output", "cacheRead", "cacheWrite"))
|
|
254
|
+
cost = float(costs.get("cost") or 0)
|
|
255
|
+
if inp or out or cr or cw or cost:
|
|
256
|
+
ps.api_calls.append(ApiCall("", "task-total", ms_to_iso(task.get("updated_at")), model, input_tokens=inp,
|
|
257
|
+
output_tokens=out, cache_read_tokens=cr, cache_write_tokens=cw,
|
|
258
|
+
cost_usd=cost or estimate_cost(model, inp, cr, cw, out)))
|
|
259
|
+
return finish_session(ps, b.stamps)
|
chronicle/cases.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""A lesson's teaching material beyond its case file (analyze.case_of): principle, checks, clues, topics and a diagram.
|
|
2
|
+
Grounded in the session; personal learning history stays in the browser."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .redact import redact
|
|
9
|
+
from .learning import topic_ids
|
|
10
|
+
|
|
11
|
+
LESSON_KINDS = ("fix", "gotcha", "decision", "learning", "pattern")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
VISUAL_SCHEMA = {
|
|
15
|
+
"type": "object", "additionalProperties": False,
|
|
16
|
+
"required": ["type", "title", "nodes", "edges"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"type": {"type": "string", "enum": ["flow", "relationship", "comparison"]},
|
|
19
|
+
"title": {"type": "string", "description": "Short title of the concept being illustrated"},
|
|
20
|
+
"nodes": {"type": "array", "items": {
|
|
21
|
+
"type": "object", "additionalProperties": False, "required": ["id", "label", "detail"],
|
|
22
|
+
"properties": {"id": {"type": "string", "description": "Unique short ASCII slug"},
|
|
23
|
+
"label": {"type": "string", "description": "1-4 words, using actual session terminology"},
|
|
24
|
+
"detail": {"type": "string", "description": "One sentence explaining this part or tradeoff"}}}},
|
|
25
|
+
"edges": {"type": "array", "items": {
|
|
26
|
+
"type": "object", "additionalProperties": False, "required": ["from", "to", "label"],
|
|
27
|
+
"properties": {"from": {"type": "string"}, "to": {"type": "string"},
|
|
28
|
+
"label": {"type": "string", "description": "Short, established relationship or action"}}}},
|
|
29
|
+
},
|
|
30
|
+
"description": "Small explanatory diagram: 2-4 parts, up to 5 connections, grounded in the lesson. "
|
|
31
|
+
"A flow shows order, a relationship shows connections, a comparison shows real alternatives. "
|
|
32
|
+
"Empty title, nodes and edges when unsupported. Never invent components or relationships.",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def visual_of(raw) -> dict | None:
|
|
37
|
+
if not isinstance(raw, dict) or raw.get("type") not in ("flow", "relationship", "comparison"):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def text(value, limit):
|
|
41
|
+
return redact(value.strip())[:limit] if isinstance(value, str) else ""
|
|
42
|
+
|
|
43
|
+
nodes = {}
|
|
44
|
+
for node in raw.get("nodes") if isinstance(raw.get("nodes"), list) else []:
|
|
45
|
+
if not isinstance(node, dict):
|
|
46
|
+
continue
|
|
47
|
+
nid, label = text(node.get("id"), 32), text(node.get("label"), 80)
|
|
48
|
+
if re.fullmatch(r"[A-Za-z0-9_-]+", nid) and label and nid not in nodes and len(nodes) < 4:
|
|
49
|
+
nodes[nid] = {"id": nid, "label": label, "detail": text(node.get("detail"), 600)}
|
|
50
|
+
edges, seen = [], set()
|
|
51
|
+
for edge in raw.get("edges") if isinstance(raw.get("edges"), list) else []:
|
|
52
|
+
if not isinstance(edge, dict):
|
|
53
|
+
continue
|
|
54
|
+
a, b = edge.get("from"), edge.get("to")
|
|
55
|
+
if not isinstance(a, str) or not isinstance(b, str) or a not in nodes or b not in nodes or a == b or (a, b) in seen:
|
|
56
|
+
continue
|
|
57
|
+
seen.add((a, b))
|
|
58
|
+
edges.append({"from": a, "to": b, "label": text(edge.get("label"), 40)})
|
|
59
|
+
if len(nodes) < 2 or not edges and raw["type"] != "comparison":
|
|
60
|
+
return None
|
|
61
|
+
return {"type": raw["type"], "title": text(raw.get("title"), 100), "nodes": list(nodes.values()), "edges": edges[:5]}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def lesson_of(raw: dict) -> dict:
|
|
65
|
+
"""The teaching fields the analysis supported, redacted; empty ones are left out, nothing is invented."""
|
|
66
|
+
def text(value) -> str:
|
|
67
|
+
return redact(value.strip())[:6000] if isinstance(value, str) else ""
|
|
68
|
+
|
|
69
|
+
def texts(value, limit: int) -> list[str]:
|
|
70
|
+
return [text(x) for x in value if isinstance(x, str) and text(x)][:limit] if isinstance(value, list) else []
|
|
71
|
+
|
|
72
|
+
out = {"clues": texts(raw.get("clues"), 6), "principle": text(raw.get("principle")),
|
|
73
|
+
"checklist": texts(raw.get("checklist"), 4), "topics": topic_ids(raw.get("topics")),
|
|
74
|
+
"visual": visual_of(raw.get("visual"))}
|
|
75
|
+
return {k: v for k, v in out.items() if v}
|
chronicle/chat_import.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Import chats from a claude.ai or ChatGPT data export as sessions (`interlatch import`, Sources › Import export…).
|
|
2
|
+
|
|
3
|
+
Both exports are a .zip with conversations.json; the format is recognized from its contents. Only the chats
|
|
4
|
+
(conversations.json, and claude.ai's projects.json for project names) are read and archived; the account files
|
|
5
|
+
(users.json, user.json) and ChatGPT's chat.html are never opened. Imports are repeatable: a newer export updates the
|
|
6
|
+
chats that changed and adds new ones.
|
|
7
|
+
|
|
8
|
+
Imported chats are not analyzed automatically (years of chats would use up the Claude plan's limits in one go):
|
|
9
|
+
screen them (screen.py) to queue the ones worth it, analyze one with **Analyze now**, or import with `--analyze` to
|
|
10
|
+
queue them all.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import gzip
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
import struct
|
|
21
|
+
import zipfile
|
|
22
|
+
import zlib
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import chatgpt_export, claude_export
|
|
27
|
+
from .util import utcnow_iso
|
|
28
|
+
from .views import NOT_ANALYZED_CHAT
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("chronicle.chat_import")
|
|
31
|
+
|
|
32
|
+
_CONVERSATIONS_RE = re.compile(r"^conversations(?:-\d+)?\.json$") # large exports may be split
|
|
33
|
+
_LOCAL_HEADER = struct.Struct("<4s5H3I2H") # signature, version, flags, method, time, date, crc, sizes, name/extra lengths
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Format:
|
|
38
|
+
key: str # kv and archive name
|
|
39
|
+
label: str
|
|
40
|
+
module: object
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def agent(self) -> str:
|
|
44
|
+
return self.module.AGENT
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def source(self) -> str:
|
|
48
|
+
return self.module.SOURCE
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def last_import_key(self) -> str:
|
|
52
|
+
return f"{self.key}:last-import"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def not_analyzed(self) -> str:
|
|
56
|
+
return NOT_ANALYZED_CHAT.format(label=self.label)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
CLAUDE_AI = Format("claude-ai", "claude.ai", claude_export)
|
|
60
|
+
CHATGPT = Format("chatgpt", "ChatGPT", chatgpt_export)
|
|
61
|
+
FORMATS = (CLAUDE_AI, CHATGPT)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ExportError(Exception):
|
|
65
|
+
"""The file is not an export Interlatch can read."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Export:
|
|
70
|
+
format: Format
|
|
71
|
+
conversations: list[dict]
|
|
72
|
+
files: dict[str, bytes] # what gets archived: the conversations file(s), claude.ai's projects.json
|
|
73
|
+
note: str | None = None # read from a zip cut off after its chats
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _wanted(name: str) -> bool:
|
|
77
|
+
return bool(_CONVERSATIONS_RE.match(name)) or name == "projects.json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _files(path: Path, name: str) -> tuple[dict[str, bytes], str | None]:
|
|
81
|
+
"""The chat files of an export .zip, its unpacked folder, or a conversations.json, and a note when the zip was cut off."""
|
|
82
|
+
path = Path(path).expanduser()
|
|
83
|
+
if zipfile.is_zipfile(path):
|
|
84
|
+
with zipfile.ZipFile(path) as z:
|
|
85
|
+
names = {Path(n).name: n for n in z.namelist() if not n.startswith("__MACOSX/")}
|
|
86
|
+
return {n: z.read(full) for n, full in sorted(names.items()) if _wanted(n)}, None
|
|
87
|
+
if path.is_dir():
|
|
88
|
+
return {p.name: p.read_bytes() for p in sorted(path.iterdir()) if p.is_file() and _wanted(p.name)}, None
|
|
89
|
+
if path.suffix == ".json" and path.is_file():
|
|
90
|
+
sibling = path.with_name("projects.json")
|
|
91
|
+
return {"conversations.json": path.read_bytes(), **({"projects.json": sibling.read_bytes()} if sibling.is_file() else {})}, None
|
|
92
|
+
if path.is_file():
|
|
93
|
+
with open(path, "rb") as f:
|
|
94
|
+
if f.read(4) == b"PK\x03\x04":
|
|
95
|
+
return _cut_off_zip(path, name)
|
|
96
|
+
if path.exists():
|
|
97
|
+
raise ExportError(f"{name} is not a chat export: give the .zip, its unpacked folder, or conversations.json")
|
|
98
|
+
raise ExportError(f"{path} not found")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _zip64_size(extra: bytes) -> int:
|
|
102
|
+
"""The compressed size from a local header's zip64 extra field (original size first, then compressed)."""
|
|
103
|
+
i = 0
|
|
104
|
+
while i + 4 <= len(extra):
|
|
105
|
+
tag, n = struct.unpack_from("<HH", extra, i)
|
|
106
|
+
if tag == 1 and n >= 16:
|
|
107
|
+
return struct.unpack_from("<Q", extra, i + 12)[0]
|
|
108
|
+
i += 4 + n
|
|
109
|
+
return 0xFFFFFFFF
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cut_off_zip(path: Path, name: str) -> tuple[dict[str, bytes], str]:
|
|
113
|
+
"""The chat files of a zip without its end, as an interrupted download leaves it, read entry by entry.
|
|
114
|
+
|
|
115
|
+
`zipfile` needs the central directory, the last thing in a zip, so a partial download looks like no zip at all
|
|
116
|
+
even when every chat is there: ChatGPT puts conversations-*.json near the start and gigabytes of attachments
|
|
117
|
+
after them. The chats are accepted only when the cut falls in a later file Interlatch does not read.
|
|
118
|
+
"""
|
|
119
|
+
size = path.stat().st_size
|
|
120
|
+
files: dict[str, bytes] = {}
|
|
121
|
+
cut_in = None # the entry the file ends in
|
|
122
|
+
redownload = f"{name} is an incomplete download ({size / 1e9:.2f} GB, the end of the zip is missing)"
|
|
123
|
+
with open(path, "rb") as f:
|
|
124
|
+
while True:
|
|
125
|
+
head = f.read(_LOCAL_HEADER.size)
|
|
126
|
+
if head[:4] == b"PK\x01\x02": # the central directory: every entry was read, only the zip's end is damaged
|
|
127
|
+
cut_in = ""
|
|
128
|
+
break
|
|
129
|
+
if len(head) < _LOCAL_HEADER.size or head[:4] != b"PK\x03\x04":
|
|
130
|
+
break
|
|
131
|
+
_, _, flags, method, _, _, crc, csize, _, nlen, xlen = _LOCAL_HEADER.unpack(head)
|
|
132
|
+
raw, extra = f.read(nlen), f.read(xlen)
|
|
133
|
+
entry = Path(raw.decode("utf-8", "replace")).name
|
|
134
|
+
if len(raw) < nlen or len(extra) < xlen:
|
|
135
|
+
cut_in = entry
|
|
136
|
+
break
|
|
137
|
+
if flags & 0x9: # encrypted, or the size only follows the data: the next entry cannot be found
|
|
138
|
+
break
|
|
139
|
+
if csize == 0xFFFFFFFF:
|
|
140
|
+
csize = _zip64_size(extra)
|
|
141
|
+
if f.tell() + csize > size:
|
|
142
|
+
cut_in = entry
|
|
143
|
+
break
|
|
144
|
+
if not (_wanted(entry) and not raw.startswith(b"__MACOSX/")):
|
|
145
|
+
f.seek(csize, 1)
|
|
146
|
+
continue
|
|
147
|
+
data = f.read(csize)
|
|
148
|
+
try:
|
|
149
|
+
data = data if method == 0 else zlib.decompress(data, -15) if method == 8 else None
|
|
150
|
+
except zlib.error:
|
|
151
|
+
data = None
|
|
152
|
+
if data is None or zlib.crc32(data) != crc:
|
|
153
|
+
raise ExportError(f"{redownload}, and {entry} in it is damaged: download the export again")
|
|
154
|
+
files[entry] = data
|
|
155
|
+
shards = [n for n in files if _CONVERSATIONS_RE.match(n)]
|
|
156
|
+
if cut_in is None or not shards or _wanted(cut_in):
|
|
157
|
+
where = f"inside {cut_in}" if cut_in and _wanted(cut_in) else "before all its chats could be read"
|
|
158
|
+
raise ExportError(f"{redownload}: it is cut off {where}. Download the export again")
|
|
159
|
+
note = ("the end of the zip is damaged, but every chat was read" if not cut_in else
|
|
160
|
+
f"incomplete download, cut off at {size / 1e9:.2f} GB in {cut_in}, after the chats: all {len(shards)} "
|
|
161
|
+
f"conversations file{'s' if len(shards) > 1 else ''} read")
|
|
162
|
+
log.warning("%s: %s", name, note)
|
|
163
|
+
return files, note
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _folders(path: Path) -> set[str]:
|
|
167
|
+
try:
|
|
168
|
+
with zipfile.ZipFile(path) as z:
|
|
169
|
+
return {n.split("/")[0] for n in z.namelist() if "/" in n}
|
|
170
|
+
except (OSError, zipfile.BadZipFile):
|
|
171
|
+
return set()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def read_export(path: Path, name: str | None = None) -> Export:
|
|
175
|
+
name = name or Path(path).name
|
|
176
|
+
files, note = _files(path, name)
|
|
177
|
+
shards = [n for n in files if _CONVERSATIONS_RE.match(n)]
|
|
178
|
+
if not shards and "projects" in _folders(path):
|
|
179
|
+
raise ExportError(f"{name} holds your claude.ai projects, not chats, and the export does not say which project a "
|
|
180
|
+
"chat belongs to: there is nothing to import from it. The chats are in conversations-000.zip.")
|
|
181
|
+
if not shards:
|
|
182
|
+
raise ExportError(f"no conversations.json in {name}: is it a claude.ai or ChatGPT data export? "
|
|
183
|
+
"(claude.ai sends several zips; the chats are in conversations-000.zip)")
|
|
184
|
+
conversations: list[dict] = []
|
|
185
|
+
for name in shards:
|
|
186
|
+
try:
|
|
187
|
+
data = json.loads(files[name])
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
raise ExportError(f"{name} is not valid JSON") from exc
|
|
190
|
+
if isinstance(data, dict):
|
|
191
|
+
data = data.get("conversations") or []
|
|
192
|
+
if not isinstance(data, list):
|
|
193
|
+
raise ExportError(f"{name} is not a list of conversations")
|
|
194
|
+
conversations += [c for c in data if isinstance(c, dict)]
|
|
195
|
+
fmt = CHATGPT if chatgpt_export.is_chatgpt(conversations) else CLAUDE_AI
|
|
196
|
+
if fmt is CLAUDE_AI and conversations and not claude_export.is_claude(conversations):
|
|
197
|
+
raise ExportError("conversations.json is neither a claude.ai nor a ChatGPT export")
|
|
198
|
+
if fmt is CHATGPT:
|
|
199
|
+
files.pop("projects.json", None)
|
|
200
|
+
return Export(fmt, conversations, files, note)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def import_export(cfg, conn, path: Path, *, analyze: bool = False, progress=None, name: str | None = None) -> dict:
|
|
204
|
+
"""Import (or re-import) an export. `name` is the file's name when `path` is an upload's temporary copy."""
|
|
205
|
+
from .db import kv_set
|
|
206
|
+
from .ingest import _unchanged, forgotten_ids, store_parsed
|
|
207
|
+
from .util import file_lock
|
|
208
|
+
|
|
209
|
+
name = name or Path(path).name
|
|
210
|
+
export = read_export(Path(path), name)
|
|
211
|
+
fmt, mod = export.format, export.format.module
|
|
212
|
+
projects = claude_export.project_names(export.files.get("projects.json")) if fmt is CLAUDE_AI else {}
|
|
213
|
+
digest = hashlib.sha1(b"".join(export.files[k] for k in sorted(export.files))).hexdigest()[:12]
|
|
214
|
+
dst = cfg.archive_dir / fmt.key / digest
|
|
215
|
+
shard = next(n for n in export.files if _CONVERSATIONS_RE.match(n))
|
|
216
|
+
archive = dst / f"{shard}.gz"
|
|
217
|
+
if not archive.exists(): # the export's chats, kept for good
|
|
218
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
for fname, raw in export.files.items():
|
|
220
|
+
with gzip.open(dst / f"{fname}.gz", "wb") as f:
|
|
221
|
+
f.write(raw)
|
|
222
|
+
counts = {"format": fmt.label, "conversations": len(export.conversations), "new": 0, "updated": 0, "unchanged": 0,
|
|
223
|
+
"empty": 0, "excluded": 0, **({"note": export.note} if export.note else {})}
|
|
224
|
+
with file_lock(cfg.locks_dir / "ingest.lock", timeout=900) as got:
|
|
225
|
+
if not got:
|
|
226
|
+
raise ExportError("a sync is running; try again when it finishes")
|
|
227
|
+
skip = forgotten_ids(conn)
|
|
228
|
+
for i, conv in enumerate(export.conversations):
|
|
229
|
+
if progress and i % 50 == 0:
|
|
230
|
+
progress(f"importing {fmt.label} chats… {i:,}/{len(export.conversations):,}")
|
|
231
|
+
sid, sig = mod.conversation_id(conv), mod.conversation_signature(conv)
|
|
232
|
+
if not sid:
|
|
233
|
+
counts["empty"] += 1
|
|
234
|
+
continue
|
|
235
|
+
if sid in skip or _unchanged(conn, sid, sig, False):
|
|
236
|
+
counts["unchanged"] += 1
|
|
237
|
+
continue
|
|
238
|
+
ps = mod.parse_conversation(conv, projects)
|
|
239
|
+
if ps is None:
|
|
240
|
+
counts["empty"] += 1
|
|
241
|
+
continue
|
|
242
|
+
if cfg.is_excluded(ps.project_path):
|
|
243
|
+
counts["excluded"] += 1
|
|
244
|
+
continue
|
|
245
|
+
queued = conn.execute("SELECT 1 FROM sessions WHERE id = ? AND analysis_status = 'pending'", (sid,)).fetchone()
|
|
246
|
+
result = store_parsed(conn, cfg, ps, claude_dir=dst, project_dir=None, transcript_path=archive,
|
|
247
|
+
archive_path=archive, files_sig=sig, agent=fmt.agent, source=fmt.source)
|
|
248
|
+
if not analyze and not queued: # a chat already queued for analysis (screen --queue) stays queued
|
|
249
|
+
conn.execute("UPDATE sessions SET analysis_status = 'skipped', analysis_reason = ? "
|
|
250
|
+
"WHERE id = ? AND analysis_status = 'pending'", (fmt.not_analyzed, sid))
|
|
251
|
+
counts["new" if result == "new" else "updated"] += 1
|
|
252
|
+
conn.commit()
|
|
253
|
+
kv_set(conn, fmt.last_import_key, json.dumps({"at": utcnow_iso(), "file": name, **counts}))
|
|
254
|
+
conn.commit()
|
|
255
|
+
log.info("%s import from %s: %s", fmt.label, name, counts)
|
|
256
|
+
return counts
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def reparse_archived(cfg, conn, skip: set[str] = frozenset()) -> int:
|
|
260
|
+
"""Re-read, from the export each was last imported from (kept in the archive), the chats an older parser stored,
|
|
261
|
+
after a parser upgrade; each keeps its analysis state. Returns how many chats were re-parsed."""
|
|
262
|
+
from .ingest import store_parsed
|
|
263
|
+
|
|
264
|
+
done = 0
|
|
265
|
+
for fmt in FORMATS:
|
|
266
|
+
mod = fmt.module
|
|
267
|
+
tag = "|" + mod.conversation_signature({}).rsplit("|", 1)[-1] # every signature ends with the parser version
|
|
268
|
+
by_dir: dict[Path, set[str]] = {}
|
|
269
|
+
for sid, sig, archive in conn.execute("SELECT id, files_sig, archive_path FROM sessions WHERE source = ?", (fmt.source,)):
|
|
270
|
+
if sid not in skip and archive and not (sig or "").endswith(tag):
|
|
271
|
+
by_dir.setdefault(Path(archive).parent, set()).add(sid)
|
|
272
|
+
for d, stale in by_dir.items():
|
|
273
|
+
projects_gz = d / "projects.json.gz"
|
|
274
|
+
try:
|
|
275
|
+
projects = claude_export.project_names(gzip.decompress(projects_gz.read_bytes())) \
|
|
276
|
+
if fmt is CLAUDE_AI and projects_gz.exists() else {}
|
|
277
|
+
except OSError:
|
|
278
|
+
projects = {}
|
|
279
|
+
for shard in sorted(d.glob("conversations*.json.gz")):
|
|
280
|
+
try:
|
|
281
|
+
data = json.loads(gzip.decompress(shard.read_bytes()))
|
|
282
|
+
except (OSError, ValueError):
|
|
283
|
+
log.warning("could not read archived export %s", shard)
|
|
284
|
+
continue
|
|
285
|
+
data = data.get("conversations") or [] if isinstance(data, dict) else data
|
|
286
|
+
for conv in data if isinstance(data, list) else []:
|
|
287
|
+
sid = mod.conversation_id(conv) if isinstance(conv, dict) else None
|
|
288
|
+
if sid not in stale:
|
|
289
|
+
continue
|
|
290
|
+
stale.discard(sid)
|
|
291
|
+
ps = mod.parse_conversation(conv, projects)
|
|
292
|
+
if ps is None or cfg.is_excluded(ps.project_path):
|
|
293
|
+
continue
|
|
294
|
+
store_parsed(conn, cfg, ps, claude_dir=d, project_dir=None, transcript_path=shard, archive_path=shard,
|
|
295
|
+
files_sig=mod.conversation_signature(conv), agent=fmt.agent, source=fmt.source)
|
|
296
|
+
conn.commit()
|
|
297
|
+
done += 1
|
|
298
|
+
return done
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def summary(counts: dict) -> str:
|
|
302
|
+
parts = [f"{counts['new']} new", f"{counts['updated']} updated", f"{counts['unchanged']} unchanged"]
|
|
303
|
+
if counts.get("empty"):
|
|
304
|
+
parts.append(f"{counts['empty']} empty")
|
|
305
|
+
if counts.get("excluded"):
|
|
306
|
+
parts.append(f"{counts['excluded']} excluded")
|
|
307
|
+
note = f" ({counts['note']})" if counts.get("note") else ""
|
|
308
|
+
return f"{counts['format']} export: {counts['conversations']} chats · " + ", ".join(parts) + note
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def import_status(conn) -> dict:
|
|
312
|
+
"""Per format: chats imported and analyzed, how screening sorted them, and the last import."""
|
|
313
|
+
from .db import kv_get
|
|
314
|
+
from .screen import screen_status
|
|
315
|
+
|
|
316
|
+
out = {}
|
|
317
|
+
for fmt in FORMATS:
|
|
318
|
+
r = conn.execute("SELECT COUNT(*), SUM(analysis_status = 'done') FROM sessions WHERE source = ?", (fmt.source,)).fetchone()
|
|
319
|
+
try:
|
|
320
|
+
last = json.loads(kv_get(conn, fmt.last_import_key) or "null")
|
|
321
|
+
except ValueError:
|
|
322
|
+
last = None
|
|
323
|
+
out[fmt.key] = {"label": fmt.label, "agent": fmt.agent, "sessions": r[0] or 0, "analyzed": r[1] or 0, "last_import": last,
|
|
324
|
+
"screen": screen_status(conn, fmt.source)}
|
|
325
|
+
return out
|