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.
Files changed (64) hide show
  1. pythia/__init__.py +3 -0
  2. pythia/cli.py +246 -0
  3. pythia/connectors/__init__.py +0 -0
  4. pythia/connectors/claude_code.py +467 -0
  5. pythia/connectors/common.py +99 -0
  6. pythia/connectors/confluence.py +803 -0
  7. pythia/connectors/github.py +444 -0
  8. pythia/connectors/incident_io.py +365 -0
  9. pythia/connectors/slack.py +674 -0
  10. pythia/consumer.py +268 -0
  11. pythia/daemon.py +650 -0
  12. pythia/db.py +80 -0
  13. pythia/docker-compose.yml +15 -0
  14. pythia/embed.py +39 -0
  15. pythia/events.py +339 -0
  16. pythia/evolution.py +392 -0
  17. pythia/ids.py +12 -0
  18. pythia/jobs.py +103 -0
  19. pythia/knowledge.py +494 -0
  20. pythia/llm/__init__.py +0 -0
  21. pythia/llm/client.py +328 -0
  22. pythia/mcp_server.py +73 -0
  23. pythia/migrations/001_schema.sql +536 -0
  24. pythia/migrations/__init__.py +0 -0
  25. pythia/pipeline/__init__.py +0 -0
  26. pythia/pipeline/adjudicate.py +297 -0
  27. pythia/pipeline/batch.py +254 -0
  28. pythia/pipeline/correct.py +227 -0
  29. pythia/pipeline/derive.py +317 -0
  30. pythia/pipeline/live.py +149 -0
  31. pythia/pipeline/process.py +472 -0
  32. pythia/pipeline/prompts.py +260 -0
  33. pythia/pipeline/reconcile.py +168 -0
  34. pythia/pipeline/schemas.py +154 -0
  35. pythia/pipeline/sweep.py +155 -0
  36. pythia/quality.py +359 -0
  37. pythia/reexplain.py +248 -0
  38. pythia/retrieval.py +137 -0
  39. pythia/services.py +41 -0
  40. pythia/settings.py +194 -0
  41. pythia/usage.py +135 -0
  42. pythia/value.py +265 -0
  43. pythia/vault.py +31 -0
  44. pythia/web/__init__.py +0 -0
  45. pythia/web/app.py +779 -0
  46. pythia/web/graph.py +167 -0
  47. pythia/web/processing.py +131 -0
  48. pythia/web/static/explore.js +242 -0
  49. pythia/web/static/style.css +120 -0
  50. pythia/web/templates/_now.html +25 -0
  51. pythia/web/templates/base.html +30 -0
  52. pythia/web/templates/config.html +231 -0
  53. pythia/web/templates/costs.html +30 -0
  54. pythia/web/templates/evolution.html +153 -0
  55. pythia/web/templates/explore.html +36 -0
  56. pythia/web/templates/label.html +22 -0
  57. pythia/web/templates/quality.html +141 -0
  58. pythia/web/templates/sources.html +249 -0
  59. pythia/web/templates/usage.html +33 -0
  60. pythia_knowledge-1.0.0.dist-info/METADATA +197 -0
  61. pythia_knowledge-1.0.0.dist-info/RECORD +64 -0
  62. pythia_knowledge-1.0.0.dist-info/WHEEL +4 -0
  63. pythia_knowledge-1.0.0.dist-info/entry_points.txt +2 -0
  64. 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)