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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- 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
|
+
)
|