pythia-knowledge 1.0.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.
- pythia/__init__.py +3 -0
- pythia/cli.py +246 -0
- pythia/connectors/__init__.py +0 -0
- pythia/connectors/claude_code.py +467 -0
- pythia/connectors/common.py +99 -0
- pythia/connectors/confluence.py +803 -0
- pythia/connectors/github.py +444 -0
- pythia/connectors/incident_io.py +365 -0
- pythia/connectors/slack.py +674 -0
- pythia/consumer.py +268 -0
- pythia/daemon.py +650 -0
- pythia/db.py +80 -0
- pythia/docker-compose.yml +15 -0
- pythia/embed.py +39 -0
- pythia/events.py +339 -0
- pythia/evolution.py +392 -0
- pythia/ids.py +12 -0
- pythia/jobs.py +103 -0
- pythia/knowledge.py +494 -0
- pythia/llm/__init__.py +0 -0
- pythia/llm/client.py +328 -0
- pythia/mcp_server.py +73 -0
- pythia/migrations/001_schema.sql +536 -0
- pythia/migrations/__init__.py +0 -0
- pythia/pipeline/__init__.py +0 -0
- pythia/pipeline/adjudicate.py +297 -0
- pythia/pipeline/batch.py +254 -0
- pythia/pipeline/correct.py +227 -0
- pythia/pipeline/derive.py +317 -0
- pythia/pipeline/live.py +149 -0
- pythia/pipeline/process.py +472 -0
- pythia/pipeline/prompts.py +260 -0
- pythia/pipeline/reconcile.py +168 -0
- pythia/pipeline/schemas.py +154 -0
- pythia/pipeline/sweep.py +155 -0
- pythia/quality.py +359 -0
- pythia/reexplain.py +248 -0
- pythia/retrieval.py +137 -0
- pythia/services.py +41 -0
- pythia/settings.py +194 -0
- pythia/usage.py +135 -0
- pythia/value.py +265 -0
- pythia/vault.py +31 -0
- pythia/web/__init__.py +0 -0
- pythia/web/app.py +779 -0
- pythia/web/graph.py +167 -0
- pythia/web/processing.py +131 -0
- pythia/web/static/explore.js +242 -0
- pythia/web/static/style.css +120 -0
- pythia/web/templates/_now.html +25 -0
- pythia/web/templates/base.html +30 -0
- pythia/web/templates/config.html +231 -0
- pythia/web/templates/costs.html +30 -0
- pythia/web/templates/evolution.html +153 -0
- pythia/web/templates/explore.html +36 -0
- pythia/web/templates/label.html +22 -0
- pythia/web/templates/quality.html +141 -0
- pythia/web/templates/sources.html +249 -0
- pythia/web/templates/usage.html +33 -0
- pythia_knowledge-1.0.0.dist-info/METADATA +197 -0
- pythia_knowledge-1.0.0.dist-info/RECORD +64 -0
- pythia_knowledge-1.0.0.dist-info/WHEEL +4 -0
- pythia_knowledge-1.0.0.dist-info/entry_points.txt +2 -0
- pythia_knowledge-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Connector for Claude Code sessions (local JSONL transcripts, append-only).
|
|
2
|
+
|
|
3
|
+
Each session file is a stream with a cursor (byte offset). A stream is
|
|
4
|
+
*settled* when it grew past the cursor and has not changed for `debounce_minutes`
|
|
5
|
+
(each LLM reply restarts the clock). The delta becomes a unit; each
|
|
6
|
+
message in the delta becomes a piece of evidence.
|
|
7
|
+
|
|
8
|
+
Everything here is deterministic plumbing: read, split, strip harness
|
|
9
|
+
markup and store. No interpretation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Iterable
|
|
22
|
+
|
|
23
|
+
from psycopg.types.json import Jsonb
|
|
24
|
+
|
|
25
|
+
from ..db import Database
|
|
26
|
+
from ..ids import new_id, sha
|
|
27
|
+
from ..jobs import enqueue
|
|
28
|
+
|
|
29
|
+
KIND = "claude_code"
|
|
30
|
+
MAX_UNIT_CHARS = 60_000
|
|
31
|
+
_HARNESS_TAGS = re.compile(r"<system-reminder>.*?</system-reminder>", re.DOTALL)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def projects_root(settings_value: dict) -> Path:
|
|
35
|
+
return Path(os.path.expanduser(settings_value.get("projects_root", "~/.claude/projects")))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def source_id_for(project_dir: Path) -> str:
|
|
39
|
+
"""Source for a whole project folder (chats are the usual unit; see `chat_source_id`)."""
|
|
40
|
+
return f"{KIND}:{project_dir}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
PROFILE_SOURCE = f"{KIND}:profile" # owner profile from ALL chats (only his messages)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def profile_files(chats: list[dict], enabled_chat_ids: set[str], excluded_titles: list[str]) -> list[str]:
|
|
47
|
+
"""Files of chats that are neither fully enabled nor excluded (the "Pythia" chat stays out)."""
|
|
48
|
+
out = []
|
|
49
|
+
for c in chats:
|
|
50
|
+
title = (c.get("title") or "").strip().lower()
|
|
51
|
+
if c["id"] in enabled_chat_ids or any(title.startswith(x.strip().lower()) for x in excluded_titles if x.strip()):
|
|
52
|
+
continue
|
|
53
|
+
out.extend(c["files"])
|
|
54
|
+
return sorted(set(out))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def chat_source_id(chat_id: str) -> str:
|
|
58
|
+
return f"{KIND}:chat:{chat_id}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def app_sessions_root(settings_value: dict) -> Path:
|
|
62
|
+
return Path(os.path.expanduser(settings_value.get(
|
|
63
|
+
"app_sessions_root", "~/Library/Application Support/Claude/claude-code-sessions")))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_HEAD_CACHE: dict[str, tuple[float, dict]] = {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _file_info(path: Path) -> dict:
|
|
70
|
+
"""First message ID (identity of the resume chain), last title, cwd and mtime.
|
|
71
|
+
Cached in memory by mtime: the file is only re-read when it changes."""
|
|
72
|
+
st = path.stat()
|
|
73
|
+
cached = _HEAD_CACHE.get(str(path))
|
|
74
|
+
if cached and cached[0] == st.st_mtime:
|
|
75
|
+
return cached[1]
|
|
76
|
+
info = {"first": None, "title": None, "cwd": None, "mtime": st.st_mtime}
|
|
77
|
+
with open(path, "rb") as f:
|
|
78
|
+
for raw in f:
|
|
79
|
+
try:
|
|
80
|
+
d = json.loads(raw)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
continue
|
|
83
|
+
if d.get("type") == "custom-title":
|
|
84
|
+
info["title"] = d.get("customTitle") or info["title"]
|
|
85
|
+
elif d.get("type") in ("user", "assistant"):
|
|
86
|
+
info["first"] = info["first"] or d.get("uuid")
|
|
87
|
+
info["cwd"] = info["cwd"] or d.get("cwd")
|
|
88
|
+
_HEAD_CACHE[str(path)] = (st.st_mtime, info)
|
|
89
|
+
return info
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def discover_chats(projects: Path, app_root: Path) -> list[dict]:
|
|
93
|
+
"""Chats for the allowlist. The chat identity is the Claude app's (fixed ID, current title,
|
|
94
|
+
current and previous files): renaming changes nothing and resuming continues the same chat. A chat without that
|
|
95
|
+
record (terminal) is the chain of files that start with the same message (each resume copies the
|
|
96
|
+
history with the same IDs). App-internal format: if it cannot be read, falls back to the chain."""
|
|
97
|
+
files = {p.stem: p for p in projects.glob("*/*.jsonl")} if projects.is_dir() else {}
|
|
98
|
+
chats: dict[str, dict] = {}
|
|
99
|
+
covered: set[str] = set()
|
|
100
|
+
if app_root.is_dir():
|
|
101
|
+
for f in app_root.glob("*/*/*.json"):
|
|
102
|
+
try:
|
|
103
|
+
d = json.loads(f.read_text())
|
|
104
|
+
except (OSError, json.JSONDecodeError):
|
|
105
|
+
continue
|
|
106
|
+
stems = [x for x in [d.get("cliSessionId"), *(d.get("priorCliSessionIds") or [])] if x and x in files]
|
|
107
|
+
if not stems or not d.get("sessionId"):
|
|
108
|
+
continue
|
|
109
|
+
last = d.get("lastActivityAt")
|
|
110
|
+
chats[d["sessionId"]] = {
|
|
111
|
+
"id": d["sessionId"], "title": d.get("title") or "(untitled)", "files": stems,
|
|
112
|
+
"cwd": d.get("cwd"), "archived": bool(d.get("isArchived")), "origin": "app",
|
|
113
|
+
"last_activity": datetime.fromtimestamp(last / 1000, tz=timezone.utc) if last else
|
|
114
|
+
datetime.fromtimestamp(max(files[x].stat().st_mtime for x in stems), tz=timezone.utc)}
|
|
115
|
+
covered |= set(stems)
|
|
116
|
+
chains: dict[str, list[tuple[str, dict]]] = {}
|
|
117
|
+
for stem, path in files.items():
|
|
118
|
+
if stem in covered:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
info = _file_info(path)
|
|
122
|
+
except OSError:
|
|
123
|
+
continue
|
|
124
|
+
chains.setdefault(info["first"] or stem, []).append((stem, info))
|
|
125
|
+
for members in chains.values():
|
|
126
|
+
members.sort(key=lambda m: m[1]["mtime"])
|
|
127
|
+
cid = f"cli-{members[0][0]}"
|
|
128
|
+
latest = members[-1][1]
|
|
129
|
+
chats[cid] = {"id": cid, "title": latest["title"] or next((m[1]["title"] for m in reversed(members) if m[1]["title"]), None)
|
|
130
|
+
or "(untitled)", "files": [m[0] for m in members], "cwd": latest["cwd"], "archived": False,
|
|
131
|
+
"origin": "terminal", "last_activity": datetime.fromtimestamp(latest["mtime"], tz=timezone.utc)}
|
|
132
|
+
return sorted(chats.values(), key=lambda c: c["last_activity"], reverse=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def session_paths(projects: Path) -> dict[str, Path]:
|
|
136
|
+
return {p.stem: p for p in projects.glob("*/*.jsonl")} if projects.is_dir() else {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def discover_projects(root: Path) -> list[dict]:
|
|
140
|
+
"""Lists the project directories available for the allowlist."""
|
|
141
|
+
out = []
|
|
142
|
+
if not root.is_dir():
|
|
143
|
+
return out
|
|
144
|
+
for d in sorted(root.iterdir()):
|
|
145
|
+
if not d.is_dir():
|
|
146
|
+
continue
|
|
147
|
+
files = list(d.glob("*.jsonl"))
|
|
148
|
+
if not files:
|
|
149
|
+
continue
|
|
150
|
+
last = max(f.stat().st_mtime for f in files)
|
|
151
|
+
out.append({
|
|
152
|
+
"path": str(d),
|
|
153
|
+
"name": d.name,
|
|
154
|
+
"sessions": len(files),
|
|
155
|
+
"last_activity": datetime.fromtimestamp(last, tz=timezone.utc),
|
|
156
|
+
})
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------- reading
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class Message:
|
|
165
|
+
external_id: str
|
|
166
|
+
role: str
|
|
167
|
+
text: str
|
|
168
|
+
occurred_at: datetime
|
|
169
|
+
meta: dict = field(default_factory=dict)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class Delta:
|
|
174
|
+
messages: list[Message]
|
|
175
|
+
title: str | None
|
|
176
|
+
session_id: str | None
|
|
177
|
+
cwd: str | None
|
|
178
|
+
branch: str | None
|
|
179
|
+
end_offset: int
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _ts(value: str | None) -> datetime:
|
|
183
|
+
if not value:
|
|
184
|
+
return datetime.now(timezone.utc)
|
|
185
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _clean(text: str) -> str:
|
|
189
|
+
return _HARNESS_TAGS.sub("", text).strip()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _summarize_tool(block: dict) -> str:
|
|
193
|
+
name = block.get("name", "?")
|
|
194
|
+
inp = block.get("input") or {}
|
|
195
|
+
detail = inp.get("description") or inp.get("command") or inp.get("query") or inp.get("file_path") \
|
|
196
|
+
or inp.get("prompt") or inp.get("pattern") or ""
|
|
197
|
+
detail = str(detail).replace("\n", " ")[:200]
|
|
198
|
+
return f"[tool {name}: {detail}]" if detail else f"[tool {name}]"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def read_delta(path: Path, offset: int, include_sidechains: bool = False) -> Delta:
|
|
202
|
+
"""Reads complete lines from `offset` and groups them into messages."""
|
|
203
|
+
with open(path, "rb") as f:
|
|
204
|
+
f.seek(offset)
|
|
205
|
+
data = f.read()
|
|
206
|
+
last_newline = data.rfind(b"\n")
|
|
207
|
+
if last_newline < 0:
|
|
208
|
+
return Delta([], None, None, None, None, offset)
|
|
209
|
+
data = data[: last_newline + 1]
|
|
210
|
+
end_offset = offset + len(data)
|
|
211
|
+
|
|
212
|
+
messages: list[Message] = []
|
|
213
|
+
by_assistant_id: dict[str, Message] = {}
|
|
214
|
+
title = session_id = cwd = branch = None
|
|
215
|
+
for raw in data.splitlines():
|
|
216
|
+
try:
|
|
217
|
+
line = json.loads(raw)
|
|
218
|
+
except json.JSONDecodeError:
|
|
219
|
+
continue
|
|
220
|
+
kind = line.get("type")
|
|
221
|
+
if kind == "custom-title":
|
|
222
|
+
title = line.get("customTitle") or title
|
|
223
|
+
continue
|
|
224
|
+
if kind not in ("user", "assistant"):
|
|
225
|
+
continue
|
|
226
|
+
if line.get("isSidechain") and not include_sidechains:
|
|
227
|
+
continue
|
|
228
|
+
if line.get("isMeta") or line.get("isCompactSummary"):
|
|
229
|
+
continue # compaction summary: written by the AI, recorded as if from the user
|
|
230
|
+
session_id = line.get("sessionId") or session_id
|
|
231
|
+
cwd = line.get("cwd") or cwd
|
|
232
|
+
branch = line.get("gitBranch") or branch
|
|
233
|
+
msg = line.get("message") or {}
|
|
234
|
+
content = msg.get("content")
|
|
235
|
+
when = _ts(line.get("timestamp"))
|
|
236
|
+
meta = {"cwd": line.get("cwd"), "branch": line.get("gitBranch")}
|
|
237
|
+
if kind == "user":
|
|
238
|
+
if isinstance(content, str):
|
|
239
|
+
text = _clean(content)
|
|
240
|
+
else:
|
|
241
|
+
text = _clean("\n".join(b.get("text", "") for b in content or [] if b.get("type") == "text"))
|
|
242
|
+
if text:
|
|
243
|
+
messages.append(Message(line.get("uuid") or sha(text)[:16], "user", text, when, meta))
|
|
244
|
+
continue
|
|
245
|
+
# assistant: blocks of the same message arrive on separate lines
|
|
246
|
+
parts = []
|
|
247
|
+
for b in content or []:
|
|
248
|
+
if b.get("type") == "text" and b.get("text"):
|
|
249
|
+
parts.append(b["text"].strip())
|
|
250
|
+
elif b.get("type") == "tool_use":
|
|
251
|
+
parts.append(_summarize_tool(b))
|
|
252
|
+
if not parts:
|
|
253
|
+
continue
|
|
254
|
+
mid = msg.get("id") or line.get("uuid")
|
|
255
|
+
existing = by_assistant_id.get(mid)
|
|
256
|
+
if existing:
|
|
257
|
+
existing.text += "\n" + "\n".join(parts)
|
|
258
|
+
else:
|
|
259
|
+
m = Message(mid, "assistant", "\n".join(parts), when, meta)
|
|
260
|
+
by_assistant_id[mid] = m
|
|
261
|
+
messages.append(m)
|
|
262
|
+
return Delta(messages, title, session_id, cwd, branch, end_offset)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def split_messages(messages: list[Message], limit: int = MAX_UNIT_CHARS) -> list[list[Message]]:
|
|
266
|
+
"""Splits into units on message boundaries, without truncating content."""
|
|
267
|
+
chunks: list[list[Message]] = []
|
|
268
|
+
current: list[Message] = []
|
|
269
|
+
size = 0
|
|
270
|
+
for m in messages:
|
|
271
|
+
if current and size + len(m.text) > limit:
|
|
272
|
+
chunks.append(current)
|
|
273
|
+
current, size = [], 0
|
|
274
|
+
current.append(m)
|
|
275
|
+
size += len(m.text)
|
|
276
|
+
if current:
|
|
277
|
+
chunks.append(current)
|
|
278
|
+
return chunks
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------- scan
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class ClaudeCodeConnector:
|
|
285
|
+
def __init__(self, db: Database, owner_name: str):
|
|
286
|
+
self.db = db
|
|
287
|
+
self.owner_name = owner_name
|
|
288
|
+
|
|
289
|
+
def scan(self, source: dict, debounce_s: float, include_sidechains: bool = False,
|
|
290
|
+
now: float | None = None, paths: dict[str, Path] | None = None) -> dict:
|
|
291
|
+
"""Scans a chat (its session files). Returns counters. Idempotent.
|
|
292
|
+
|
|
293
|
+
A chat file that vanished from disk becomes a tombstone only if its folder still exists (the
|
|
294
|
+
folder listing is complete); a missing folder (unmounted disk, changed root) deletes nothing."""
|
|
295
|
+
now = now if now is not None else time.time()
|
|
296
|
+
stats = {"streams": 0, "units": 0, "deleted": 0}
|
|
297
|
+
if "path" in source["config"]: # source pointing at a project folder
|
|
298
|
+
project = Path(source["config"]["path"])
|
|
299
|
+
try:
|
|
300
|
+
files = {str(p): p for p in project.glob("*.jsonl")}
|
|
301
|
+
except OSError:
|
|
302
|
+
return stats
|
|
303
|
+
else:
|
|
304
|
+
paths = paths or {}
|
|
305
|
+
files = {str(paths[stem]): paths[stem] for stem in source["config"].get("files") or [] if stem in paths}
|
|
306
|
+
with self.db.tx() as conn:
|
|
307
|
+
known = {r["locator"]: r for r in conn.execute(
|
|
308
|
+
"SELECT * FROM streams WHERE source_id = %s", (source["id"],)).fetchall()}
|
|
309
|
+
for locator, path in files.items():
|
|
310
|
+
stats["streams"] += 1
|
|
311
|
+
try:
|
|
312
|
+
st = path.stat()
|
|
313
|
+
except FileNotFoundError:
|
|
314
|
+
continue
|
|
315
|
+
stream = known.get(locator)
|
|
316
|
+
profile = bool(source["config"].get("profile_only"))
|
|
317
|
+
stream_id = f"{PROFILE_SOURCE}:{path.stem}" if profile else f"{KIND}:{path.stem}"
|
|
318
|
+
if stream is None:
|
|
319
|
+
with self.db.tx() as conn:
|
|
320
|
+
conn.execute(
|
|
321
|
+
"""INSERT INTO streams (id, source_id, locator, observed_size, observed_mtime, last_change_at)
|
|
322
|
+
VALUES (%s, %s, %s, %s, %s, now()) ON CONFLICT (id) DO NOTHING""",
|
|
323
|
+
(stream_id, source["id"], locator, st.st_size, st.st_mtime),
|
|
324
|
+
)
|
|
325
|
+
stream = {"id": stream_id, "cursor": {}, "observed_size": st.st_size,
|
|
326
|
+
"observed_mtime": st.st_mtime, "status": "active"}
|
|
327
|
+
elif st.st_size != stream["observed_size"]:
|
|
328
|
+
with self.db.tx() as conn:
|
|
329
|
+
conn.execute(
|
|
330
|
+
"""UPDATE streams SET observed_size = %s, observed_mtime = %s, last_change_at = now(),
|
|
331
|
+
status = 'active', updated_at = now() WHERE id = %s""",
|
|
332
|
+
(st.st_size, st.st_mtime, stream["id"]),
|
|
333
|
+
)
|
|
334
|
+
offset = int((stream.get("cursor") or {}).get("offset", 0))
|
|
335
|
+
if st.st_size < offset:
|
|
336
|
+
# File rewritten to a smaller size: the old content no longer exists at the source.
|
|
337
|
+
with self.db.tx() as conn:
|
|
338
|
+
enqueue(conn, "tombstone_stream", "cognition", {"stream_id": stream["id"], "reset": True},
|
|
339
|
+
priority=10, dedup_key=f"tombstone:{stream['id']}")
|
|
340
|
+
continue
|
|
341
|
+
settled = st.st_size > offset and (now - st.st_mtime) >= debounce_s
|
|
342
|
+
if settled:
|
|
343
|
+
stats["units"] += self._settle(source, stream["id"], path, offset, st.st_mtime, include_sidechains)
|
|
344
|
+
for locator, stream in known.items():
|
|
345
|
+
gone = locator not in files and not Path(locator).exists() and Path(locator).parent.is_dir()
|
|
346
|
+
if gone and stream["status"] == "active":
|
|
347
|
+
stats["deleted"] += 1
|
|
348
|
+
with self.db.tx() as conn:
|
|
349
|
+
conn.execute("UPDATE streams SET status = 'deleted', updated_at = now() WHERE id = %s",
|
|
350
|
+
(stream["id"],))
|
|
351
|
+
enqueue(conn, "tombstone_stream", "cognition", {"stream_id": stream["id"]},
|
|
352
|
+
priority=10, dedup_key=f"tombstone:{stream['id']}")
|
|
353
|
+
return stats
|
|
354
|
+
|
|
355
|
+
def _settle(self, source: dict, stream_id: str, path: Path, offset: int, mtime: float,
|
|
356
|
+
include_sidechains: bool) -> int:
|
|
357
|
+
delta = read_delta(path, offset, include_sidechains)
|
|
358
|
+
created = 0
|
|
359
|
+
with self.db.tx() as conn:
|
|
360
|
+
if delta.title:
|
|
361
|
+
conn.execute("UPDATE streams SET title = %s WHERE id = %s", (delta.title, stream_id))
|
|
362
|
+
title = delta.title or conn.execute("SELECT title FROM streams WHERE id = %s",
|
|
363
|
+
(stream_id,)).fetchone()["title"]
|
|
364
|
+
profile = bool(source["config"].get("profile_only"))
|
|
365
|
+
# Profile from all chats: only the owner's messages become evidence; the AI reply he reacts to is
|
|
366
|
+
# read from the transcript at processing time, as context, and is never stored.
|
|
367
|
+
messages = [m for m in delta.messages if m.role == "user"] if profile else delta.messages
|
|
368
|
+
for chunk in split_messages(messages):
|
|
369
|
+
fresh = []
|
|
370
|
+
for m in chunk:
|
|
371
|
+
# IDs are global within the source: a resumed session repeats another one's history.
|
|
372
|
+
# Also across chats: dedup holds for all Claude Code sessions. The profile source
|
|
373
|
+
# does not count for enabled chats (if a chat is enabled later, it reads everything).
|
|
374
|
+
exists = conn.execute(
|
|
375
|
+
"SELECT 1 FROM evidence WHERE source_id LIKE %s AND external_id = %s"
|
|
376
|
+
+ ("" if profile else " AND source_id <> %s"),
|
|
377
|
+
(f"{KIND}:%", m.external_id) + (() if profile else (PROFILE_SOURCE,))).fetchone()
|
|
378
|
+
if not exists:
|
|
379
|
+
fresh.append(m)
|
|
380
|
+
if not fresh:
|
|
381
|
+
continue
|
|
382
|
+
first_user = next((m.text for m in fresh if m.role == "user"), fresh[0].text)
|
|
383
|
+
unit_title = title or first_user[:120]
|
|
384
|
+
# Priority: most recent first (backfill from new to old).
|
|
385
|
+
recency = int(fresh[-1].occurred_at.timestamp())
|
|
386
|
+
unit = conn.execute(
|
|
387
|
+
"""INSERT INTO units (source_id, stream_id, kind, title, occurred_at, meta)
|
|
388
|
+
VALUES (%s, %s, 'session_delta', %s, %s, %s) RETURNING id""",
|
|
389
|
+
(source["id"], stream_id, unit_title, fresh[-1].occurred_at,
|
|
390
|
+
Jsonb({"session_id": delta.session_id, "cwd": delta.cwd, "branch": delta.branch,
|
|
391
|
+
"transcript": str(path), "offset_from": offset, "offset_to": delta.end_offset,
|
|
392
|
+
**({"profile_only": True} if profile else {})})),
|
|
393
|
+
).fetchone()
|
|
394
|
+
for m in fresh:
|
|
395
|
+
conn.execute(
|
|
396
|
+
"""INSERT INTO evidence (id, unit_id, source_id, stream_id, external_id, author, role,
|
|
397
|
+
occurred_at, content, content_hash, meta)
|
|
398
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
|
399
|
+
(new_id("ev"), unit["id"], source["id"], stream_id, m.external_id,
|
|
400
|
+
self.owner_name if m.role == "user" else "assistant", m.role, m.occurred_at,
|
|
401
|
+
m.text, sha(m.text), Jsonb(m.meta)),
|
|
402
|
+
)
|
|
403
|
+
enqueue(conn, "process_unit", "cognition", {"unit_id": unit["id"]},
|
|
404
|
+
priority=_recency_priority(recency), dedup_key=f"unit:{unit['id']}")
|
|
405
|
+
created += 1
|
|
406
|
+
conn.execute(
|
|
407
|
+
"""UPDATE streams SET cursor = %s, last_settled_at = now(), updated_at = now() WHERE id = %s""",
|
|
408
|
+
(Jsonb({"offset": delta.end_offset}), stream_id),
|
|
409
|
+
)
|
|
410
|
+
return created
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _recency_priority(epoch: int) -> int:
|
|
414
|
+
"""More recent = lower number = processed sooner. A window of ~10 years in days."""
|
|
415
|
+
days_ago = max(0, int((time.time() - epoch) // 86400))
|
|
416
|
+
return 1000 + min(days_ago, 4000)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
CONTEXT_CHARS = 20_000
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def unit_context(conn, unit: dict, evidence: list[dict]) -> list[dict]:
|
|
423
|
+
"""what came right before the delta in the same chat (the assistant reply the owner reacts to), so
|
|
424
|
+
extraction understands short reactions like "shorten this". Context only: it neither becomes evidence nor is cited.
|
|
425
|
+
Looks up by chat (the source), not by file, because resuming the session opens another file. The message
|
|
426
|
+
immediately before always goes in whole; the others, up to `CONTEXT_CHARS`."""
|
|
427
|
+
if not evidence:
|
|
428
|
+
return []
|
|
429
|
+
first = min(e["occurred_at"] for e in evidence)
|
|
430
|
+
before = conn.execute(
|
|
431
|
+
"""SELECT * FROM evidence WHERE source_id = %s AND status = 'active' AND NOT (id = ANY(%s))
|
|
432
|
+
AND occurred_at < %s ORDER BY occurred_at DESC LIMIT 20""",
|
|
433
|
+
(unit["source_id"], [e["id"] for e in evidence], first)).fetchall()
|
|
434
|
+
picked, size = [], 0
|
|
435
|
+
for e in before:
|
|
436
|
+
if picked and size + len(e["content"]) > CONTEXT_CHARS:
|
|
437
|
+
break
|
|
438
|
+
picked.append(e)
|
|
439
|
+
size += len(e["content"])
|
|
440
|
+
return list(reversed(picked))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
PROFILE_REPLY_CHARS = 1500 # the end of the reply the owner reacts to (what he usually comments on)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def profile_context(unit: dict, evidence: list[dict]) -> list[dict]:
|
|
447
|
+
"""Profile from all chats: for each owner message, the AI reply that came right before it, read from the
|
|
448
|
+
transcript now (not stored). File gone = no context."""
|
|
449
|
+
meta = unit.get("meta") or {}
|
|
450
|
+
path = Path(meta.get("transcript") or "")
|
|
451
|
+
if not evidence or not path.is_file():
|
|
452
|
+
return []
|
|
453
|
+
try:
|
|
454
|
+
msgs = read_delta(path, 0).messages
|
|
455
|
+
except OSError:
|
|
456
|
+
return []
|
|
457
|
+
wanted = {e["external_id"] for e in evidence}
|
|
458
|
+
out, last_reply = [], None
|
|
459
|
+
for m in msgs:
|
|
460
|
+
if m.role == "assistant":
|
|
461
|
+
last_reply = m
|
|
462
|
+
elif m.external_id in wanted and last_reply is not None:
|
|
463
|
+
text = last_reply.text
|
|
464
|
+
out.append({"occurred_at": last_reply.occurred_at, "role": "assistant",
|
|
465
|
+
"content": ("…" + text[-PROFILE_REPLY_CHARS:]) if len(text) > PROFILE_REPLY_CHARS else text})
|
|
466
|
+
last_reply = None
|
|
467
|
+
return out
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Plumbing shared by the connectors: syncing a stream's evidence and creating units.
|
|
2
|
+
|
|
3
|
+
Rules of §6.4: new item → new evidence; content changed → the old version is
|
|
4
|
+
retracted (tombstone) and the new one comes in; gone from a COMPLETE listing → tombstone.
|
|
5
|
+
Never deletes based on an incomplete listing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from psycopg.types.json import Jsonb
|
|
15
|
+
|
|
16
|
+
from .. import knowledge as K
|
|
17
|
+
from ..ids import new_id, sha
|
|
18
|
+
from ..jobs import enqueue
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SourceUnavailable(Exception):
|
|
22
|
+
"""Source down, without credentials or rate limited: try later."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFound(SourceUnavailable):
|
|
26
|
+
"""The source answered that the item does not exist (or is not visible): it is an answer, not an access failure."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Item:
|
|
31
|
+
external_id: str
|
|
32
|
+
author: str
|
|
33
|
+
role: str
|
|
34
|
+
occurred_at: datetime
|
|
35
|
+
content: str
|
|
36
|
+
meta: dict = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def content_hash(it: Item) -> str:
|
|
40
|
+
"""Hash that decides whether the item changed at the source. Connectors that format the text (names, links) pass
|
|
41
|
+
`raw_hash` over the raw fields: renaming a person does not turn into an "edit" of each of their messages."""
|
|
42
|
+
return it.meta.get("raw_hash") or sha(it.content)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sync_stream(conn, *, source_id: str, stream_id: str, items: list[Item], complete: bool, reason: str) -> list[Item]:
|
|
46
|
+
"""Compares the source's current items with the stream's evidence. Returns the items that become new evidence."""
|
|
47
|
+
existing = {r["external_id"]: r for r in conn.execute(
|
|
48
|
+
"""SELECT id, external_id, content_hash FROM evidence
|
|
49
|
+
WHERE source_id = %s AND stream_id = %s AND status = 'active' AND external_id IS NOT NULL""",
|
|
50
|
+
(source_id, stream_id)).fetchall()}
|
|
51
|
+
fresh: list[Item] = []
|
|
52
|
+
to_tombstone: list[str] = []
|
|
53
|
+
seen = set()
|
|
54
|
+
for it in items:
|
|
55
|
+
seen.add(it.external_id)
|
|
56
|
+
old = existing.get(it.external_id)
|
|
57
|
+
if old is None:
|
|
58
|
+
# It may also already exist in another stream of the same source (ids are global within the source).
|
|
59
|
+
if conn.execute("SELECT 1 FROM evidence WHERE source_id = %s AND external_id = %s",
|
|
60
|
+
(source_id, it.external_id)).fetchone():
|
|
61
|
+
continue
|
|
62
|
+
fresh.append(it)
|
|
63
|
+
elif old["content_hash"] != content_hash(it):
|
|
64
|
+
to_tombstone.append(old["id"]) # edit = retraction of the previous version
|
|
65
|
+
fresh.append(it)
|
|
66
|
+
if complete:
|
|
67
|
+
to_tombstone.extend(r["id"] for ext, r in existing.items() if ext not in seen)
|
|
68
|
+
if to_tombstone:
|
|
69
|
+
K.tombstone_evidence(conn, "connector", to_tombstone, reason)
|
|
70
|
+
conn.execute("UPDATE evidence SET external_id = NULL WHERE id = ANY(%s)", (to_tombstone,))
|
|
71
|
+
return fresh
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_unit(conn, *, source_id: str, stream_id: str | None, kind: str, title: str | None, items: list[Item],
|
|
75
|
+
meta: dict, priority: int | None = None) -> int | None:
|
|
76
|
+
if not items:
|
|
77
|
+
return None
|
|
78
|
+
occurred = max(it.occurred_at for it in items)
|
|
79
|
+
unit = conn.execute(
|
|
80
|
+
"""INSERT INTO units (source_id, stream_id, kind, title, occurred_at, meta)
|
|
81
|
+
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id""",
|
|
82
|
+
(source_id, stream_id, kind, title, occurred, Jsonb(meta))).fetchone()
|
|
83
|
+
for it in sorted(items, key=lambda i: i.occurred_at):
|
|
84
|
+
conn.execute(
|
|
85
|
+
"""INSERT INTO evidence (id, unit_id, source_id, stream_id, external_id, author, role, occurred_at,
|
|
86
|
+
content, content_hash, meta)
|
|
87
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
|
88
|
+
(new_id("ev"), unit["id"], source_id, stream_id, it.external_id, it.author, it.role, it.occurred_at,
|
|
89
|
+
it.content, content_hash(it), Jsonb(it.meta)))
|
|
90
|
+
enqueue(conn, "process_unit", "cognition", {"unit_id": unit["id"]},
|
|
91
|
+
priority=priority if priority is not None else recency_priority(occurred.timestamp()),
|
|
92
|
+
dedup_key=f"unit:{unit['id']}")
|
|
93
|
+
return unit["id"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def recency_priority(epoch: float) -> int:
|
|
97
|
+
"""More recent = lower number = processed sooner (backfill from new to old)."""
|
|
98
|
+
days_ago = max(0, int((time.time() - epoch) // 86400))
|
|
99
|
+
return 1000 + min(days_ago, 4000)
|