thread-archive 0.0.2__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 (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,173 @@
1
+ """SQLite engine + session factory for the archive store.
2
+
3
+ One backend: SQLite. There is no PostgreSQL branch, no connection pool, and no DDL
4
+ guard — on the embedded store there is no alembic, so ``create_all`` / ``reindex``
5
+ *must* be able to issue DDL.
6
+
7
+ PRAGMAs: WAL so concurrent readers don't block the single writer; ``foreign_keys``
8
+ ON (SQLite defaults them off) to match the schema's FK intent; a ``busy_timeout``
9
+ so a brief writer lock waits instead of erroring.
10
+
11
+ The ``use_engine`` ContextVar override is preserved verbatim: it's the seam the
12
+ serverless search relies on to point the whole query path at one index file for a
13
+ single call, with no global mutation and no cross-request interference. It is
14
+ carried into search-federation worker threads via ``copy_context``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextvars
20
+ from collections.abc import Generator
21
+ from contextlib import contextmanager
22
+ from typing import Any, cast
23
+
24
+ from sqlalchemy import create_engine, event
25
+ from sqlalchemy.engine import CursorResult, Engine
26
+ from sqlalchemy.orm import DeclarativeBase, Session
27
+
28
+
29
+ class Base(DeclarativeBase):
30
+ """SQLAlchemy 2.0 declarative base for all archive conversation-memory models."""
31
+
32
+ pass
33
+
34
+
35
+ _engine: Engine | None = None
36
+
37
+ # Context-scoped engine override (see module docstring). Default None = use the
38
+ # module-global `_engine`. Per-call and concurrency-safe (a ContextVar, not a
39
+ # global swap).
40
+ _engine_override: contextvars.ContextVar = contextvars.ContextVar(
41
+ "thread_archive_engine_override", default=None
42
+ )
43
+
44
+
45
+ def get_dsn() -> str:
46
+ """Default DSN: the resolved ``index.db`` for this archive instance."""
47
+ from .._config import resolve_paths
48
+
49
+ return resolve_paths().ensure().sqlalchemy_url
50
+
51
+
52
+ def _attach_sqlite_pragmas(engine: Engine, *, enforce_fk: bool = True) -> None:
53
+ """Per-connection PRAGMAs for the embedded SQLite backend.
54
+
55
+ ``enforce_fk=False`` is for bulk replication paths (e.g. reindex from JSONL)
56
+ that copy already-validated rows and full-snapshot parent tables, where FK
57
+ enforcement would only fight dependency-agnostic bulk load — standard ETL
58
+ practice.
59
+ """
60
+
61
+ @event.listens_for(engine, "connect")
62
+ def _set_pragmas(dbapi_conn, _record): # noqa: ANN001
63
+ cur = dbapi_conn.cursor()
64
+ cur.execute("PRAGMA journal_mode=WAL")
65
+ cur.execute("PRAGMA foreign_keys=ON" if enforce_fk else "PRAGMA foreign_keys=OFF")
66
+ cur.execute("PRAGMA busy_timeout=5000")
67
+ cur.close()
68
+
69
+
70
+ def build_engine(dsn: str, *, enforce_fk: bool = True) -> Engine:
71
+ """Construct a SQLite engine for a DSN.
72
+
73
+ No server-style pool; cross-thread access is allowed (``check_same_thread``
74
+ off) since the read path may run on a threadpool. WAL + busy_timeout PRAGMAs
75
+ are attached per connection.
76
+ """
77
+ engine = create_engine(
78
+ dsn,
79
+ connect_args={"check_same_thread": False},
80
+ pool_pre_ping=True,
81
+ )
82
+ _attach_sqlite_pragmas(engine, enforce_fk=enforce_fk)
83
+ return engine
84
+
85
+
86
+ def init_engine(dsn: str | None = None) -> None:
87
+ """Initialize (or rebind) the module-global SQLAlchemy engine.
88
+
89
+ Rebuilds when the target DSN differs from the current engine's — so opening a
90
+ different archive home actually repoints the engine instead of silently keeping
91
+ the old one (which would split writes between two stores).
92
+ """
93
+ global _engine
94
+ target = dsn or get_dsn()
95
+ if _engine is not None:
96
+ if str(_engine.url) == target:
97
+ return
98
+ _engine.dispose()
99
+ _engine = build_engine(target)
100
+
101
+
102
+ def active_dsn() -> str | None:
103
+ """The DSN the module-global engine is bound to, or None if uninitialized."""
104
+ return str(_engine.url) if _engine is not None else None
105
+
106
+
107
+ def get_engine() -> Engine:
108
+ """Get the engine, initializing if needed.
109
+
110
+ Honors a context-scoped override (``use_engine``) when one is active, so a
111
+ library call can run the whole query path against a different store without
112
+ touching — or initializing — the module-global engine.
113
+ """
114
+ override = _engine_override.get()
115
+ if override is not None:
116
+ return override
117
+ if _engine is None:
118
+ init_engine()
119
+ assert _engine is not None, "init_engine() failed to set _engine"
120
+ return _engine
121
+
122
+
123
+ @contextmanager
124
+ def get_session() -> Generator[Session, None, None]:
125
+ """Context manager yielding a Session bound to the active engine.
126
+
127
+ Does not commit on exit — callers commit explicitly.
128
+ """
129
+ with Session(get_engine()) as session:
130
+ yield session
131
+
132
+
133
+ @contextmanager
134
+ def use_session(session: Session | None = None) -> Generator[Session, None, None]:
135
+ """Use an existing session or create a fresh one.
136
+
137
+ Lets functions accept an optional session for testability while defaulting to
138
+ a new session when none is provided.
139
+ """
140
+ if session:
141
+ yield session
142
+ else:
143
+ with get_session() as s:
144
+ yield s
145
+
146
+
147
+ @contextmanager
148
+ def use_engine(engine: Engine) -> Generator[Engine, None, None]:
149
+ """Point ``get_engine()`` / ``get_session()`` at ``engine`` for the block.
150
+
151
+ The seam that makes the embedded search a library: wrap a call in
152
+ ``use_engine(index_engine)`` and every ``get_engine()`` inside it resolves to
153
+ that engine. The previous binding is restored on exit, even on error.
154
+ """
155
+ token = _engine_override.set(engine)
156
+ try:
157
+ yield engine
158
+ finally:
159
+ _engine_override.reset(token)
160
+
161
+
162
+ def dml_rowcount(session: Session, statement: Any) -> int:
163
+ """Execute a DML statement and return its rowcount."""
164
+ result = cast(CursorResult, session.execute(statement))
165
+ return result.rowcount
166
+
167
+
168
+ def close_engine() -> None:
169
+ """Dispose the module-global engine. Called at shutdown."""
170
+ global _engine
171
+ if _engine:
172
+ _engine.dispose()
173
+ _engine = None
@@ -0,0 +1,57 @@
1
+ """SQLite-native server defaults for the archive ORM.
2
+
3
+ These compile to their SQLite forms only (there are no postgres
4
+ ``@compiles(..., "postgresql")`` variants):
5
+
6
+ now_default() -> CURRENT_TIMESTAMP
7
+ text_default("x") -> 'x'
8
+ empty_text_array() -> '[]' (a JSON empty array)
9
+
10
+ Use via ``mapped_column(..., server_default=now_default())``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from sqlalchemy.ext.compiler import compiles
16
+ from sqlalchemy.sql import expression
17
+
18
+
19
+ class now_default(expression.FunctionElement):
20
+ """Current timestamp: ``CURRENT_TIMESTAMP``."""
21
+
22
+ inherit_cache = True
23
+
24
+
25
+ @compiles(now_default)
26
+ def _now_default(element, compiler, **kw):
27
+ return "CURRENT_TIMESTAMP"
28
+
29
+
30
+ class text_default(expression.FunctionElement):
31
+ """A text-literal default: ``'value'``."""
32
+
33
+ # Carries per-instance state, so it is not safe to share a cached compilation.
34
+ inherit_cache = False
35
+
36
+ def __init__(self, value: str, cast: str = "text"):
37
+ self.value = value
38
+ # `cast` is accepted for call-site parity with a `::text`-cast spelling;
39
+ # unused on SQLite.
40
+ self.cast = cast
41
+ super().__init__()
42
+
43
+
44
+ @compiles(text_default)
45
+ def _text_default(element, compiler, **kw):
46
+ return f"'{element.value}'"
47
+
48
+
49
+ class empty_text_array(expression.FunctionElement):
50
+ """Empty-array default: ``'[]'`` (a JSON array)."""
51
+
52
+ inherit_cache = True
53
+
54
+
55
+ @compiles(empty_text_array)
56
+ def _empty_text_array(element, compiler, **kw):
57
+ return "'[]'"
@@ -0,0 +1,38 @@
1
+ """SQLite-native column types for the archive ORM.
2
+
3
+ thread-archive has a single backend — SQLite — so these map ``JSONB`` / ``ARRAY`` /
4
+ ``REAL`` straight to their SQLite forms; there is no postgres-dialect import (the
5
+ import-ratchet forbids it). The public names are kept so model definitions read
6
+ cleanly:
7
+
8
+ JSONB -> JSON (SQLite's JSON1; dict/list round-trip)
9
+ ARRAY -> JSON (a JSON array — SQLite has no native array type)
10
+ REAL -> Float
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from sqlalchemy import JSON, BigInteger, Float, Integer
16
+ from sqlalchemy.types import TypeEngine
17
+
18
+ # Shared instances are safe — SQLAlchemy type objects are immutable descriptors,
19
+ # reusable across every column site (`mapped_column(JSONB, ...)`).
20
+ JSONB: TypeEngine = JSON()
21
+ REAL: TypeEngine = Float()
22
+
23
+ # A big-integer PRIMARY KEY rendered as SQLite's ``INTEGER PRIMARY KEY`` (a 64-bit
24
+ # rowid alias). `BIGINT PRIMARY KEY` is *not* a rowid alias, but `INTEGER PRIMARY
25
+ # KEY` is — and SQLite's rowid is already 64-bit, so nothing is lost. Declared as
26
+ # ``BigInteger`` to keep the 64-bit intent legible; the variant compiles it to the
27
+ # rowid form here. NOTE: a bare rowid only ever yields ``max(rowid)+1``, which a
28
+ # ``DELETE``/reload (reindex) can lower and so recycle ids; the tables whose ids are
29
+ # minted on insert (Thread/Event/KgEvent) therefore set ``sqlite_autoincrement=True``
30
+ # in ``__table_args__`` to keep a persistent high-water that DELETE never resets.
31
+ BigIntPK: TypeEngine = BigInteger().with_variant(Integer(), "sqlite")
32
+
33
+
34
+ def ARRAY(item_type: TypeEngine) -> TypeEngine:
35
+ """A JSON array on SQLite. ``item_type`` is accepted for call-site parity with
36
+ the ``ARRAY(item_type)`` spelling, but SQLite has no native array type, so the
37
+ value is stored as a JSON array regardless."""
38
+ return JSON()
@@ -0,0 +1,370 @@
1
+ """The conversation schema: Thread, Event, EventFts, ImportState.
2
+
3
+ The event log is the spine. ``Event`` is the append-only source of truth — a
4
+ conversation's content lives in ``Event.payload`` (JSON); everything a reader sees
5
+ is reconstructed from events. ``EventFts`` is the lexical search shadow (the FTS5
6
+ virtual table is built over it). ``ImportState`` carries per-source
7
+ watermarks so incremental import is idempotent.
8
+
9
+ Column types and server defaults use their SQLite-native forms (``_types``,
10
+ ``_defaults``). ``thoughts`` / ``content_blocks`` / ``tool_calls`` /
11
+ ``api_requests`` — the chat-harness materialization (live cognition) — are
12
+ deliberately *not* modeled here: the archive imports transcripts into events, it
13
+ does not run the chat loop.
14
+
15
+ Index ``__table_args__`` carry ``postgresql_where`` / ``postgresql_using`` kwargs;
16
+ SQLAlchemy ignores dialect-prefixed index options on SQLite, so they are inert (a
17
+ partial/GIN index degrades to a plain index).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime
23
+
24
+ from sqlalchemy import (
25
+ BigInteger,
26
+ DateTime,
27
+ ForeignKey,
28
+ Index,
29
+ Integer,
30
+ String,
31
+ Text,
32
+ UniqueConstraint,
33
+ text,
34
+ )
35
+ from sqlalchemy.orm import Mapped, mapped_column
36
+
37
+ from ._base import Base
38
+ from ._defaults import now_default, text_default
39
+ from ._types import ARRAY, JSONB, REAL, BigIntPK
40
+
41
+
42
+ class Thread(Base):
43
+ """An imported conversation, or a curated topic.
44
+
45
+ ``thread_type`` is one of just two kinds: 'conversation' (a chat session — the
46
+ default, what every importer creates) or 'topic' (a curated knowledge node in the
47
+ topic graph; see :mod:`thread_archive._knowledge`). Modeling a topic *as* a thread is
48
+ deliberate, not leftover polymorphism: it gives topics thread ids, so the graph's
49
+ edges and citations reference a single id space. ``source`` tracks origin
50
+ ('claude-code', 'cursor', 'codex', ...). ``source_metadata`` (JSON) carries branching
51
+ info (branched_from, branch_event_id, quoted_event_id, ...).
52
+ """
53
+
54
+ __tablename__ = "threads"
55
+
56
+ id: Mapped[int] = mapped_column(primary_key=True)
57
+ name: Mapped[str] = mapped_column(Text, unique=True)
58
+ title: Mapped[str | None] = mapped_column(Text, default=None)
59
+ thread_type: Mapped[str] = mapped_column(
60
+ Text, default="conversation", server_default=text_default("conversation")
61
+ )
62
+ description: Mapped[str | None] = mapped_column(Text, default=None)
63
+ search_description: Mapped[str | None] = mapped_column(Text, default=None)
64
+ summary: Mapped[str | None] = mapped_column(Text, default=None)
65
+ indexed_summary: Mapped[str | None] = mapped_column(Text, default=None)
66
+
67
+ source: Mapped[str | None] = mapped_column(Text, default=None)
68
+ source_id: Mapped[str | None] = mapped_column(Text, default=None)
69
+ source_metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
70
+
71
+ thought_count: Mapped[int] = mapped_column(default=0, server_default=text("0"))
72
+
73
+ user_id: Mapped[str | None] = mapped_column(Text, default=None)
74
+ experiment_id: Mapped[str | None] = mapped_column(Text, default=None)
75
+
76
+ archived: Mapped[bool] = mapped_column(default=False, server_default=text("false"))
77
+
78
+ # Per-thread search blacklist: when true, the thread's events are dropped from
79
+ # search results (filtered post-federation). Independent of `archived`; the
80
+ # thread stays reachable by id and title. A discovery filter, not access control.
81
+ exclude_from_search: Mapped[bool] = mapped_column(
82
+ default=False, server_default=text("false")
83
+ )
84
+
85
+ # Workspace isolation: 'current' = live data; 'legacy' = frozen, read-only.
86
+ workspace: Mapped[str] = mapped_column(
87
+ "workspace", String(16), nullable=False, server_default=text("'current'")
88
+ )
89
+
90
+ # Topic kind + epistemological type — knowledge-layer fields kept as plain
91
+ # nullable columns here; the vocabularies/validators live in the knowledge layer.
92
+ topic_kind: Mapped[str | None] = mapped_column(default=None)
93
+ epistemological_type: Mapped[str | None] = mapped_column(default=None)
94
+
95
+ inserted_at: Mapped[datetime] = mapped_column(
96
+ "inserted_at", DateTime(timezone=True), nullable=False, server_default=now_default()
97
+ )
98
+ updated_at: Mapped[datetime] = mapped_column(
99
+ "updated_at", DateTime(timezone=True), nullable=False, server_default=now_default()
100
+ )
101
+
102
+ __table_args__ = (
103
+ Index("idx_threads_source", "source", "source_id"),
104
+ Index("idx_threads_type", "thread_type"),
105
+ Index("idx_threads_updated", text("updated_at DESC")),
106
+ Index("idx_threads_user", "user_id"),
107
+ Index("idx_threads_experiment", "experiment_id"),
108
+ Index("idx_threads_archived", "archived"),
109
+ Index(
110
+ "idx_threads_exclude_from_search",
111
+ "exclude_from_search",
112
+ postgresql_where=text("exclude_from_search = true"),
113
+ ),
114
+ Index("idx_threads_source_metadata", "source_metadata", postgresql_using="gin"),
115
+ Index("idx_threads_workspace_type", "workspace", "thread_type"),
116
+ Index("idx_threads_epistemic_type", "epistemological_type"),
117
+ Index("idx_threads_topic_kind", "topic_kind"),
118
+ # AUTOINCREMENT: keep a persistent id high-water (sqlite_sequence) that DELETE
119
+ # does NOT reset. reindex clears + reloads with explicit ids; without this a
120
+ # concurrent writer mid-reindex gets a low rowid (max+1 of the partially loaded
121
+ # table) that collides with a not-yet-reloaded historical row. With it, new ids
122
+ # always continue past the high-water and are never reused. See
123
+ # truth.jsonl_log.reindex.
124
+ {"sqlite_autoincrement": True},
125
+ )
126
+
127
+
128
+ class Event(Base):
129
+ """A single event in the append-only event log — the source of truth.
130
+
131
+ ``id`` is the global monotonic sequence number. ``payload`` (JSON) holds
132
+ event-type-specific content (text deltas, tool calls, message bodies, ...).
133
+ ``dedup_key`` is a deterministic, timestamp-free natural identity (see the
134
+ importer's ``compute_dedup_key``); NULL when an event lacks a stable identity.
135
+ It is bare — dedup is thread-scoped by the importer's ``WHERE thread_id = ...``
136
+ clause, so the key carries no ``{thread_id}:`` prefix. A partial UNIQUE index
137
+ on ``(thread_id, dedup_key)`` enforces that identity at the DB level: the
138
+ importer's membership check is advisory (it reads only what SQLite holds, so
139
+ a truth append whose commit was lost re-imports the same content under a
140
+ fresh id), and the reindex loader's INSERT OR REPLACE collapses such
141
+ same-content twins to one row instead of materializing both.
142
+ """
143
+
144
+ __tablename__ = "events"
145
+
146
+ id: Mapped[int] = mapped_column(BigIntPK, primary_key=True)
147
+ thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id", ondelete="RESTRICT"))
148
+ stream_id: Mapped[str] = mapped_column(Text)
149
+ api_call_id: Mapped[str | None] = mapped_column(Text, default=None)
150
+ event_type: Mapped[str] = mapped_column(Text)
151
+ payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
152
+ occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
153
+ recorded_at: Mapped[datetime] = mapped_column(
154
+ "recorded_at", DateTime(timezone=True), nullable=False, server_default=now_default()
155
+ )
156
+ caused_by_event_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
157
+ correlation_id: Mapped[str | None] = mapped_column(Text, default=None)
158
+ dedup_key: Mapped[str | None] = mapped_column(Text, default=None)
159
+
160
+ __table_args__ = (
161
+ Index("idx_events_thread_id", "thread_id", "id"),
162
+ Index("idx_events_type", "event_type"),
163
+ Index("idx_events_type_thread", "event_type", "thread_id"),
164
+ Index("idx_events_occurred", "occurred_at"),
165
+ Index("idx_events_caused_by", "caused_by_event_id"),
166
+ Index("idx_events_api_call_id", "api_call_id"),
167
+ Index("idx_events_api_call", "api_call_id", postgresql_where=text("(api_call_id IS NOT NULL)")),
168
+ Index("idx_events_correlation", "correlation_id", postgresql_where=text("(correlation_id IS NOT NULL)")),
169
+ Index("idx_events_stream_seq", "stream_id", "id"),
170
+ # DB-level dedup: one row per (thread, natural identity). Partial — NULL
171
+ # dedup_keys (events with no stable identity) are exempt. On a live index
172
+ # created before this index existed, enforcement arrives with the next
173
+ # reindex (create_all does not retrofit indexes onto existing tables).
174
+ Index(
175
+ "uq_events_thread_dedup",
176
+ "thread_id",
177
+ "dedup_key",
178
+ unique=True,
179
+ sqlite_where=text("dedup_key IS NOT NULL"),
180
+ postgresql_where=text("(dedup_key IS NOT NULL)"),
181
+ ),
182
+ # Persistent id high-water across DELETE — see the Thread note. This is the
183
+ # column that bit us: the live watcher mints event ids on insert, so a reindex
184
+ # running against a live watcher must not be able to recycle a historical id.
185
+ {"sqlite_autoincrement": True},
186
+ )
187
+
188
+
189
+ class EventFts(Base):
190
+ """Full-text search shadow for events.
191
+
192
+ Populated by application code as searchable events land; the FTS5 virtual table
193
+ (``event_search``) is built over these rows. ``event_id`` references
194
+ ``events.id`` (a soft reference, no FK by design).
195
+ """
196
+
197
+ __tablename__ = "events_fts"
198
+
199
+ id: Mapped[int] = mapped_column(primary_key=True)
200
+ event_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
201
+ thread_id: Mapped[int] = mapped_column(Integer)
202
+ event_type: Mapped[str] = mapped_column(Text)
203
+ content: Mapped[str] = mapped_column(Text)
204
+ content_type: Mapped[str | None] = mapped_column(Text, default=None)
205
+ tool_name: Mapped[str | None] = mapped_column(Text, default=None)
206
+
207
+ __table_args__ = (
208
+ Index("idx_events_fts_event_id", "event_id"),
209
+ Index("idx_events_fts_thread_id", "thread_id"),
210
+ Index("idx_events_fts_tool_name", "tool_name"),
211
+ )
212
+
213
+
214
+ class ImportState(Base):
215
+ """Per-source import watermark so incremental import is idempotent.
216
+
217
+ Each ``(source, source_id)`` pair tracks how far into a session file we have
218
+ imported (line count, file size, last message uuid) so duplicate polls do not
219
+ re-import unchanged content.
220
+
221
+ ``last_content_hash`` is what makes the cursor a *proof* rather than an
222
+ observation: the sha256 of the exact file bytes the line cursor was computed
223
+ over. A poll re-hashes the current file's first ``last_file_size`` bytes and
224
+ resumes only if they still match — otherwise the source was rewritten under the
225
+ cursor and the file re-imports from line 0 (see :mod:`.._importers._cursor`).
226
+ NULL on the DB-backed sources, whose cursor is a row count, not a file offset.
227
+ """
228
+
229
+ __tablename__ = "import_state"
230
+
231
+ id: Mapped[int] = mapped_column(primary_key=True)
232
+ source: Mapped[str] = mapped_column(Text)
233
+ source_id: Mapped[str] = mapped_column(Text)
234
+ thread_id: Mapped[int | None] = mapped_column(
235
+ ForeignKey("threads.id", ondelete="RESTRICT"), default=None
236
+ )
237
+ last_line_count: Mapped[int] = mapped_column(default=0, server_default=text("0"))
238
+ last_file_size: Mapped[int] = mapped_column(default=0, server_default=text("0"))
239
+ last_content_hash: Mapped[str | None] = mapped_column(Text, default=None)
240
+ last_message_uuid: Mapped[str | None] = mapped_column(Text, default=None)
241
+ last_import_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
242
+ created_at: Mapped[datetime] = mapped_column(
243
+ "created_at", DateTime(timezone=True), server_default=now_default()
244
+ )
245
+
246
+ __table_args__ = (
247
+ UniqueConstraint("source", "source_id"),
248
+ Index("idx_import_state_lookup", "source", "source_id"),
249
+ Index("idx_import_state_thread", "thread_id"),
250
+ )
251
+
252
+
253
+ # ── Knowledge layer ──────────────────────────────────────────────────────────
254
+ # Topics are threads (``thread_type='topic'``). ThreadLink is the topic→topic
255
+ # edge set the networkx graph projects over; TopicMessage is message→topic
256
+ # evidence. Both are now **projections of the curatorial event log** (``KgEvent`` /
257
+ # ``kg_events.jsonl``): a librarian write appends an event and folds it into these
258
+ # tables (see :mod:`thread_archive._knowledge.materialize`). A legacy snapshot of the
259
+ # tables may still exist as a reindex seed, which the event replay reconciles on top.
260
+
261
+
262
+ class ThreadLink(Base):
263
+ """A directed link between two topics (or topic↔conversation). The edge set
264
+ the in-process topic graph projects over. ``strength`` (0.0–1.0) is confidence;
265
+ ``link_type`` drives the community projection's edge weighting."""
266
+
267
+ __tablename__ = "thread_links"
268
+
269
+ id: Mapped[int] = mapped_column(primary_key=True)
270
+ source_thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id", ondelete="RESTRICT"))
271
+ target_thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id", ondelete="RESTRICT"))
272
+ link_type: Mapped[str] = mapped_column(Text, default="related", server_default=text_default("related"))
273
+ strength: Mapped[float] = mapped_column(REAL, default=1.0, server_default=text("1.0"))
274
+ created_by: Mapped[str] = mapped_column(Text, default="auto", server_default=text_default("auto"))
275
+ created_by_thread_id: Mapped[int | None] = mapped_column(
276
+ ForeignKey("threads.id", ondelete="SET NULL"), default=None
277
+ )
278
+ evidence: Mapped[str | None] = mapped_column(Text, default=None)
279
+ observation_ids: Mapped[list[int] | None] = mapped_column("observation_ids", ARRAY(Integer), nullable=True)
280
+ created_at: Mapped[datetime] = mapped_column(
281
+ "created_at", DateTime(timezone=True), nullable=False, server_default=now_default()
282
+ )
283
+ updated_at: Mapped[datetime] = mapped_column(
284
+ "updated_at", DateTime(timezone=True), nullable=False, server_default=now_default()
285
+ )
286
+
287
+ __table_args__ = (
288
+ UniqueConstraint("source_thread_id", "target_thread_id", "link_type"),
289
+ Index("idx_thread_links_source", "source_thread_id"),
290
+ Index("idx_thread_links_target", "target_thread_id"),
291
+ Index("idx_thread_links_type", "link_type"),
292
+ Index("idx_thread_links_strength", text("strength DESC")),
293
+ Index("idx_thread_links_created_by_thread", "created_by_thread_id"),
294
+ )
295
+
296
+
297
+ class TopicMessage(Base):
298
+ """Evidence that a topic appears in a conversation. ``topic_id`` is the topic
299
+ thread; ``thread_id`` is the conversation; ``event_id`` is the message."""
300
+
301
+ __tablename__ = "topic_messages"
302
+
303
+ id: Mapped[int] = mapped_column(primary_key=True)
304
+ topic_id: Mapped[int] = mapped_column(ForeignKey("threads.id", ondelete="RESTRICT"))
305
+ event_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
306
+ thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id", ondelete="RESTRICT"))
307
+ quote: Mapped[str] = mapped_column(Text)
308
+ created_by_thread_id: Mapped[int | None] = mapped_column(default=None)
309
+ actor: Mapped[str] = mapped_column("actor", String, nullable=False, server_default=text("'unknown'"))
310
+ archived_at: Mapped[datetime | None] = mapped_column("archived_at", DateTime(timezone=True), nullable=True)
311
+ created_at: Mapped[datetime] = mapped_column(
312
+ "created_at", DateTime(timezone=True), nullable=False, server_default=now_default()
313
+ )
314
+
315
+ __table_args__ = (
316
+ UniqueConstraint("topic_id", "event_id"),
317
+ Index("idx_topic_messages_topic", "topic_id"),
318
+ Index("idx_topic_messages_thread", "thread_id"),
319
+ Index("idx_topic_messages_event", "event_id"),
320
+ Index("idx_topic_messages_created_by", "created_by_thread_id"),
321
+ )
322
+
323
+
324
+ class KgEvent(Base):
325
+ """Append-only curatorial event — the event-sourced spine of the topic graph.
326
+
327
+ Every topic/link/evidence mutation is recorded here *first* (durable truth:
328
+ ``kg_events.jsonl``) and then folded into the ``thread_links`` / ``topic_messages``
329
+ projections by :mod:`thread_archive._knowledge.materialize`. The log is the source
330
+ of truth for curation; the projection tables are rebuildable from it (replayed in
331
+ ``id`` order on reindex). This is what restores event-sourcing to the knowledge
332
+ layer: an unlink/archive is a tombstone event, never a silent overwrite, so the
333
+ full operation history — when a link was made, edited, removed, by whom — survives.
334
+
335
+ ``entity_type`` is 'topic' | 'link' | 'topic_message'; ``entity_id`` is that
336
+ entity's natural key as a string (``str(topic_id)``, ``"src:tgt:link_type"``,
337
+ ``"topic_id:event_id"``) so the log is queryable by entity. ``payload`` (JSON)
338
+ carries the event-type-specific fields the materializer reads.
339
+ """
340
+
341
+ __tablename__ = "kg_events"
342
+
343
+ id: Mapped[int] = mapped_column(BigIntPK, primary_key=True)
344
+ event_type: Mapped[str] = mapped_column(Text)
345
+ entity_type: Mapped[str] = mapped_column(Text)
346
+ entity_id: Mapped[str | None] = mapped_column(Text, default=None)
347
+ payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
348
+ actor: Mapped[str] = mapped_column(
349
+ Text, default="librarian", server_default=text_default("librarian")
350
+ )
351
+ actor_thread_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
352
+ caused_by_event_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
353
+ correlation_id: Mapped[str | None] = mapped_column(Text, default=None)
354
+ occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
355
+ recorded_at: Mapped[datetime] = mapped_column(
356
+ "recorded_at", DateTime(timezone=True), nullable=False, server_default=now_default()
357
+ )
358
+
359
+ __table_args__ = (
360
+ Index("idx_kg_events_type", "event_type"),
361
+ Index("idx_kg_events_entity", "entity_type", "entity_id"),
362
+ Index("idx_kg_events_occurred", "occurred_at"),
363
+ Index("idx_kg_events_actor_thread", "actor_thread_id"),
364
+ Index("idx_kg_events_correlation", "correlation_id", postgresql_where=text("(correlation_id IS NOT NULL)")),
365
+ # Persistent id high-water across DELETE — see the Thread note. The librarian
366
+ # mints kg-event ids on insert; without this, a reindex that emptied the table
367
+ # let the next curation write restart ids from 1 and collide (the kg_events
368
+ # id=1 dup that aborted reindex).
369
+ {"sqlite_autoincrement": True},
370
+ )