abstractnote 0.1.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.
- abstractnote/__init__.py +18 -0
- abstractnote/cli.py +240 -0
- abstractnote/flows/__init__.py +2 -0
- abstractnote/flows/build.py +970 -0
- abstractnote/flows/bundle.py +62 -0
- abstractnote/flows/contract.py +88 -0
- abstractnote/flows/graph.py +540 -0
- abstractnote/flows/json/note-capture.json +2280 -0
- abstractnote/flows/json/note-drain.json +2012 -0
- abstractnote/flows/json/note-mine.json +2392 -0
- abstractnote/flows/json/note-process.json +1853 -0
- abstractnote/flows/json/note-recall.json +1876 -0
- abstractnote/flows/json/note-reflect.json +2057 -0
- abstractnote/flows/prompts_fallback.py +119 -0
- abstractnote/flows/runner.py +320 -0
- abstractnote/flows/schemas_fallback.py +175 -0
- abstractnote/gateway/__init__.py +24 -0
- abstractnote/gateway/appspec.py +10 -0
- abstractnote/gateway/router.py +571 -0
- abstractnote/gateway/tools.py +74 -0
- abstractnote/ids.py +177 -0
- abstractnote/importers/__init__.py +142 -0
- abstractnote/importers/__main__.py +3 -0
- abstractnote/importers/agora.py +203 -0
- abstractnote/importers/claude_sessions.py +415 -0
- abstractnote/importers/cli.py +113 -0
- abstractnote/importers/files.py +72 -0
- abstractnote/importers/markdown.py +165 -0
- abstractnote/ingest/__init__.py +7 -0
- abstractnote/ingest/blobs.py +47 -0
- abstractnote/ingest/capture.py +265 -0
- abstractnote/ingest/extract.py +118 -0
- abstractnote/ingest/normalise.py +95 -0
- abstractnote/model.py +229 -0
- abstractnote/process/__init__.py +5 -0
- abstractnote/process/context.py +218 -0
- abstractnote/process/merge.py +60 -0
- abstractnote/process/prompts/__init__.py +39 -0
- abstractnote/process/prompts/extract.md +30 -0
- abstractnote/process/prompts/link.md +32 -0
- abstractnote/process/prompts/mine_dossier.md +13 -0
- abstractnote/process/prompts/mine_findings.md +13 -0
- abstractnote/process/prompts/mine_plan.md +13 -0
- abstractnote/process/prompts/quick_filing.md +23 -0
- abstractnote/process/prompts/reflect.md +10 -0
- abstractnote/process/prompts/synthesize.md +17 -0
- abstractnote/process/resolve.py +96 -0
- abstractnote/process/revise.py +410 -0
- abstractnote/process/schemas.py +377 -0
- abstractnote/process/vocabulary.py +68 -0
- abstractnote/recall/__init__.py +1 -0
- abstractnote/recall/changes.py +191 -0
- abstractnote/recall/expand.py +379 -0
- abstractnote/recall/insights.py +62 -0
- abstractnote/recall/reflect.py +202 -0
- abstractnote/recall/review.py +191 -0
- abstractnote/recall/search.py +205 -0
- abstractnote/recall/shapes.py +52 -0
- abstractnote/recall/then_now.py +100 -0
- abstractnote/store/__init__.py +12 -0
- abstractnote/store/archive.py +166 -0
- abstractnote/store/embeddings.py +275 -0
- abstractnote/store/kg.py +219 -0
- abstractnote/store/notebook.py +829 -0
- abstractnote/store/notes.py +1190 -0
- abstractnote/store/queue.py +159 -0
- abstractnote/store/schema.py +348 -0
- abstractnote/store/vector_index.py +88 -0
- abstractnote/tools/__init__.py +215 -0
- abstractnote/tools/capture.py +121 -0
- abstractnote/tools/context.py +19 -0
- abstractnote/tools/process.py +22 -0
- abstractnote/tools/queue.py +23 -0
- abstractnote/tools/recall.py +64 -0
- abstractnote-0.1.0.dist-info/METADATA +145 -0
- abstractnote-0.1.0.dist-info/RECORD +80 -0
- abstractnote-0.1.0.dist-info/WHEEL +5 -0
- abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
- abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
- abstractnote-0.1.0.dist-info/top_level.txt +1 -0
abstractnote/ids.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Deterministic identifiers and time normalisation (docs/data-model.md).
|
|
2
|
+
|
|
3
|
+
Every id is derived from its documented inputs with sha256, so replays,
|
|
4
|
+
retries and rebuilds produce the same ids. Prefixes: ``nt_`` notes, ``at_``
|
|
5
|
+
attachments, ``cl_`` claims, ``rv_`` revisions, ``tk_`` tasks, ``ka_`` the
|
|
6
|
+
non-claim AbstractMemory assertions (names, aliases, merges). Entity ids follow
|
|
7
|
+
AbstractFramework ADR 0025: ``ex:<kind>-<slug>``.
|
|
8
|
+
|
|
9
|
+
All stored times are ISO 8601 UTC with second precision and a ``Z`` suffix
|
|
10
|
+
(``2026-10-19T09:12:00Z``) so that string comparison is time comparison, in
|
|
11
|
+
the note index and in AbstractMemory alike.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import re
|
|
19
|
+
import unicodedata
|
|
20
|
+
from datetime import date, datetime, timezone
|
|
21
|
+
from typing import Any, Iterable, Optional
|
|
22
|
+
|
|
23
|
+
_SEP = "\x1f"
|
|
24
|
+
|
|
25
|
+
ENTITY_KINDS = ("person", "org", "project", "concept", "place", "event", "artifact", "software", "thing")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def digest(*parts: Any) -> str:
|
|
29
|
+
"""sha256 hex of the parts joined by an ASCII unit separator."""
|
|
30
|
+
h = hashlib.sha256(_SEP.join("" if p is None else str(p) for p in parts).encode("utf-8"))
|
|
31
|
+
return h.hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _b32(*parts: Any, length: int = 26) -> str:
|
|
35
|
+
raw = hashlib.sha256(_SEP.join("" if p is None else str(p) for p in parts).encode("utf-8")).digest()
|
|
36
|
+
return base64.b32encode(raw).decode("ascii").rstrip("=").lower()[:length]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sha256_bytes(data: bytes) -> str:
|
|
40
|
+
return hashlib.sha256(data).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sha256_text(text: str) -> str:
|
|
44
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------- time
|
|
48
|
+
|
|
49
|
+
def utc_now() -> str:
|
|
50
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_time(value: Any, *, field: str = "time") -> str:
|
|
54
|
+
"""Normalise an ISO 8601 value (or datetime/date) to ``YYYY-MM-DDTHH:MM:SSZ`` UTC.
|
|
55
|
+
|
|
56
|
+
A date alone means midnight UTC; a naive datetime is taken as UTC.
|
|
57
|
+
Raises ValueError with a plain sentence on anything else.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(value, datetime):
|
|
60
|
+
dt = value
|
|
61
|
+
elif isinstance(value, date):
|
|
62
|
+
dt = datetime(value.year, value.month, value.day, tzinfo=timezone.utc)
|
|
63
|
+
elif isinstance(value, str) and value.strip():
|
|
64
|
+
raw = value.strip()
|
|
65
|
+
if raw.endswith(("Z", "z")):
|
|
66
|
+
raw = raw[:-1] + "+00:00"
|
|
67
|
+
try:
|
|
68
|
+
dt = datetime.fromisoformat(raw)
|
|
69
|
+
except ValueError:
|
|
70
|
+
try:
|
|
71
|
+
d = date.fromisoformat(raw)
|
|
72
|
+
except ValueError:
|
|
73
|
+
raise ValueError(f"The {field} {value!r} is not an ISO 8601 date or time.") from None
|
|
74
|
+
dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError(f"The {field} must be an ISO 8601 date or time.")
|
|
77
|
+
if dt.tzinfo is None:
|
|
78
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
79
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def optional_time(value: Any, *, field: str = "time") -> Optional[str]:
|
|
83
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
84
|
+
return None
|
|
85
|
+
return normalize_time(value, field=field)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_time(value: str) -> datetime:
|
|
89
|
+
return datetime.strptime(normalize_time(value), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_minute(value: str) -> str:
|
|
93
|
+
"""``occurred_at`` truncated to the minute (the capture fallback key)."""
|
|
94
|
+
return normalize_time(value)[:16]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------- notes
|
|
98
|
+
|
|
99
|
+
def note_id_for_import(importer: str, source_key: str) -> str:
|
|
100
|
+
return "nt_" + _b32("import", importer, source_key)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def note_id_for_key(idempotency_key: str) -> str:
|
|
104
|
+
return "nt_" + _b32("key", idempotency_key)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def note_id_fallback(text_norm: str, attachment_hashes: Iterable[str], occurred_at: str, client: str) -> str:
|
|
108
|
+
return "nt_" + _b32("capture", text_norm, ",".join(sorted(attachment_hashes)), to_minute(occurred_at), client)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def note_id_for_insight(kind: str, title: str, body: str, evidence_note_ids: Iterable[str]) -> str:
|
|
112
|
+
return "nt_" + _b32("insight", kind, title, body, ",".join(sorted(evidence_note_ids)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def attachment_id(note_id: str, sha256: str) -> str:
|
|
116
|
+
return "at_" + _b32(note_id, sha256)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def claim_id(note_id: str, subject: str, predicate: str, obj: str) -> str:
|
|
120
|
+
return "cl_" + _b32(note_id, subject, predicate, obj)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def revision_id_for_note(note_id: str, attempt: int) -> str:
|
|
124
|
+
return "rv_" + _b32("note", note_id, int(attempt))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def revision_id_for_action(action: str, target: str, occurred_at: str) -> str:
|
|
128
|
+
return "rv_" + _b32("action", action, target, occurred_at)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def task_id(note_id: str, text_norm: str) -> str:
|
|
132
|
+
return "tk_" + _b32(note_id, text_norm)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def kg_assertion_id(subject: str, predicate: str, obj: str) -> str:
|
|
136
|
+
"""Ids of the non-claim assertions (schema:name, skos:altLabel, schema:sameAs)."""
|
|
137
|
+
return "ka_" + _b32(subject, predicate, obj)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def closure_id(revision_id: str, assertion_id: str, kind: str) -> str:
|
|
141
|
+
return "cz_" + _b32(revision_id, assertion_id, kind)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --------------------------------------------------------------------------- entities
|
|
145
|
+
|
|
146
|
+
def slugify(name: str, *, max_len: int = 48) -> str:
|
|
147
|
+
"""ASCII slug of a model-chosen name (formatting only; identity is decided by the model)."""
|
|
148
|
+
folded = unicodedata.normalize("NFKD", str(name or "")).encode("ascii", "ignore").decode("ascii")
|
|
149
|
+
slug = re.sub(r"[^a-z0-9]+", "-", folded.lower()).strip("-")
|
|
150
|
+
if len(slug) > max_len:
|
|
151
|
+
slug = slug[:max_len].rstrip("-")
|
|
152
|
+
return slug or "unnamed"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def entity_id_base(kind: str, name: str) -> str:
|
|
156
|
+
if kind not in ENTITY_KINDS:
|
|
157
|
+
raise ValueError(f"The entity kind {kind!r} is not one of {', '.join(ENTITY_KINDS)}.")
|
|
158
|
+
return f"ex:{kind}-{slugify(name)}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def mint_entity_id(kind: str, name: str, taken: "set[str] | frozenset[str] | Any") -> str:
|
|
162
|
+
"""``ex:<kind>-<slug>``, with ``-2``, ``-3`` ... when the id is already taken.
|
|
163
|
+
|
|
164
|
+
``taken`` is any container answering ``in`` (ids are never reused).
|
|
165
|
+
"""
|
|
166
|
+
base = entity_id_base(kind, name)
|
|
167
|
+
if base not in taken:
|
|
168
|
+
return base
|
|
169
|
+
n = 2
|
|
170
|
+
while f"{base}-{n}" in taken:
|
|
171
|
+
n += 1
|
|
172
|
+
return f"{base}-{n}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def fold(text: str) -> str:
|
|
176
|
+
"""Case-folded, whitespace-collapsed form used for exact alias matching."""
|
|
177
|
+
return " ".join(str(text or "").casefold().split())
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Importers: existing corpora into a notebook with faithful history time and provenance (docs/importers.md).
|
|
2
|
+
|
|
3
|
+
Each importer yields ``NoteDraft`` records; ``write_drafts`` captures them through the core lane's
|
|
4
|
+
``Notebook.capture`` at bulk priority (``0``) with ``importer`` + ``source_key`` (note id =
|
|
5
|
+
``sha256(importer, source_key)``), so a re-run creates nothing new and live notes always go first.
|
|
6
|
+
Processing happens later through the queue (the ``note-drain`` flow), never here.
|
|
7
|
+
|
|
8
|
+
Noise filtering is structural: record types, flags and the harness's own envelope markers decide
|
|
9
|
+
what is machine output (tool results, hook output, system reminders, alerts), never the meaning of
|
|
10
|
+
the words (ADR 0004).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional
|
|
18
|
+
|
|
19
|
+
BULK_PRIORITY = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class NoteDraft:
|
|
24
|
+
"""One note an importer wants to write."""
|
|
25
|
+
|
|
26
|
+
importer: str
|
|
27
|
+
source_key: str
|
|
28
|
+
text: str
|
|
29
|
+
occurred_at: str
|
|
30
|
+
kind: str # what it is in the source (prompt, summary, subagent, memory, decision, thread, ...), for counts
|
|
31
|
+
title: Optional[str] = None
|
|
32
|
+
source: Dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
attachments: List[Dict[str, Any]] = field(default_factory=list) # {"data": bytes, "mime"?, "filename"?}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ImportReport:
|
|
38
|
+
importer: str
|
|
39
|
+
dry_run: bool = False
|
|
40
|
+
created: int = 0
|
|
41
|
+
duplicates: int = 0
|
|
42
|
+
skipped: Dict[str, int] = field(default_factory=dict) # noise and filtered records, by reason
|
|
43
|
+
attachments: int = 0
|
|
44
|
+
by_kind: Dict[str, int] = field(default_factory=dict)
|
|
45
|
+
earliest: Optional[str] = None
|
|
46
|
+
latest: Optional[str] = None
|
|
47
|
+
errors: List[Dict[str, str]] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
def skip(self, reason: str, n: int = 1) -> None:
|
|
50
|
+
self.skipped[reason] = self.skipped.get(reason, 0) + n
|
|
51
|
+
|
|
52
|
+
def seen(self, draft: NoteDraft) -> None:
|
|
53
|
+
self.by_kind[draft.kind] = self.by_kind.get(draft.kind, 0) + 1
|
|
54
|
+
if self.earliest is None or draft.occurred_at < self.earliest:
|
|
55
|
+
self.earliest = draft.occurred_at
|
|
56
|
+
if self.latest is None or draft.occurred_at > self.latest:
|
|
57
|
+
self.latest = draft.occurred_at
|
|
58
|
+
|
|
59
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
60
|
+
return {
|
|
61
|
+
"importer": self.importer, "dry_run": self.dry_run, "created": self.created,
|
|
62
|
+
"duplicates": self.duplicates, "attachments": self.attachments, "by_kind": dict(sorted(self.by_kind.items())),
|
|
63
|
+
"skipped": dict(sorted(self.skipped.items())), "range": {"earliest": self.earliest, "latest": self.latest},
|
|
64
|
+
"errors": list(self.errors),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def iso_from_epoch(value: Any) -> str:
|
|
69
|
+
return _dt.datetime.fromtimestamp(float(value), tz=_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def iso_utc(value: str) -> str:
|
|
73
|
+
"""An ISO 8601 timestamp (any offset, ``Z`` accepted) as UTC ``YYYY-MM-DDTHH:MM:SSZ``."""
|
|
74
|
+
text = str(value).strip().replace("Z", "+00:00")
|
|
75
|
+
dt = _dt.datetime.fromisoformat(text)
|
|
76
|
+
if dt.tzinfo is None:
|
|
77
|
+
dt = dt.replace(tzinfo=_dt.timezone.utc)
|
|
78
|
+
return dt.astimezone(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def in_window(occurred_at: str, since: Optional[str], until: Optional[str]) -> bool:
|
|
82
|
+
if since and occurred_at < iso_utc(since):
|
|
83
|
+
return False
|
|
84
|
+
if until and occurred_at >= iso_utc(until):
|
|
85
|
+
return False
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_drafts(nb: Any, drafts: Iterable[NoteDraft], report: ImportReport, *, dry_run: bool = False,
|
|
90
|
+
exists: Optional[Callable[[str, str], bool]] = None) -> ImportReport:
|
|
91
|
+
"""Capture every draft (bulk priority) or, with ``dry_run``, only count what would happen.
|
|
92
|
+
|
|
93
|
+
``nb`` may be None for a dry run on a notebook that does not exist yet (everything counts as new).
|
|
94
|
+
"""
|
|
95
|
+
from abstractnote import ids # core lane
|
|
96
|
+
|
|
97
|
+
report.dry_run = dry_run
|
|
98
|
+
for draft in drafts:
|
|
99
|
+
report.seen(draft)
|
|
100
|
+
if dry_run:
|
|
101
|
+
note_id = ids.note_id_for_import(draft.importer, draft.source_key)
|
|
102
|
+
known = nb is not None and (exists(draft.importer, draft.source_key) if exists else nb.note(note_id) is not None)
|
|
103
|
+
if known:
|
|
104
|
+
report.duplicates += 1
|
|
105
|
+
else:
|
|
106
|
+
report.created += 1
|
|
107
|
+
report.attachments += len(draft.attachments)
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
result = nb.capture(
|
|
111
|
+
draft.text, title=draft.title, occurred_at=draft.occurred_at, source=draft.source,
|
|
112
|
+
priority=BULK_PRIORITY, attachments=draft.attachments, importer=draft.importer,
|
|
113
|
+
source_key=draft.source_key,
|
|
114
|
+
)
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
report.errors.append({"source_key": draft.source_key, "error": str(e)})
|
|
117
|
+
continue
|
|
118
|
+
if getattr(result, "status", None) == "duplicate":
|
|
119
|
+
report.duplicates += 1
|
|
120
|
+
else:
|
|
121
|
+
report.created += 1
|
|
122
|
+
report.attachments += len(draft.attachments)
|
|
123
|
+
return report
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run(importer: str, nb: Any, *, dry_run: bool = False, **options: Any) -> ImportReport:
|
|
127
|
+
"""Run one importer by name (``claude-sessions``, ``agora``, ``markdown``, ``files``)."""
|
|
128
|
+
from . import agora, claude_sessions, files, markdown
|
|
129
|
+
|
|
130
|
+
makers: Dict[str, Callable[..., Iterator[NoteDraft]]] = {
|
|
131
|
+
claude_sessions.IMPORTER: claude_sessions.drafts,
|
|
132
|
+
agora.IMPORTER: agora.drafts,
|
|
133
|
+
markdown.IMPORTER: markdown.drafts,
|
|
134
|
+
files.IMPORTER: files.drafts,
|
|
135
|
+
}
|
|
136
|
+
if importer not in makers:
|
|
137
|
+
raise ValueError(f"Unknown importer {importer!r}; choose one of {', '.join(sorted(makers))}.")
|
|
138
|
+
report = ImportReport(importer=importer)
|
|
139
|
+
return write_drafts(nb, makers[importer](report=report, **options), report, dry_run=dry_run)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
__all__ = ["BULK_PRIORITY", "ImportReport", "NoteDraft", "in_window", "iso_from_epoch", "iso_utc", "run", "write_drafts"]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""An agora hub database -> notes (docs/importers.md, recon data-sources.md section B).
|
|
2
|
+
|
|
3
|
+
One note per recorded decision (``store`` key ``decision:*``), per claim and work entry
|
|
4
|
+
(``claim:*``, ``work:*``), per resolved or answered thread (the root message plus its replies, in
|
|
5
|
+
order), and per operator message that is not already part of an imported thread.
|
|
6
|
+
``occurred_at`` is the message's ``created_at`` (a store entry takes its message's time when it
|
|
7
|
+
names one, else its ``updated_at``). The database is opened read-only.
|
|
8
|
+
|
|
9
|
+
Skipped as noise: system and file-change messages (``kind`` system/fs), the ``hub-alerts`` channel,
|
|
10
|
+
silence alerts, retracted messages. ``--channels`` keeps only the named channels.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, Iterator, List, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
from . import ImportReport, NoteDraft, in_window, iso_from_epoch
|
|
21
|
+
|
|
22
|
+
IMPORTER = "agora"
|
|
23
|
+
DEFAULT_OPERATORS = ("laurent", "dl-owner")
|
|
24
|
+
NOISE_CHANNELS = ("hub-alerts",)
|
|
25
|
+
STORE_PREFIXES = {"decision:": "decision", "claim:": "claim", "work:": "work"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _connect(db: Path) -> sqlite3.Connection:
|
|
29
|
+
if not db.is_file():
|
|
30
|
+
raise ValueError(f"There is no agora database at {db}.")
|
|
31
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
32
|
+
conn.row_factory = sqlite3.Row
|
|
33
|
+
return conn
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _data(raw: Any) -> Dict[str, Any]:
|
|
37
|
+
if not raw:
|
|
38
|
+
return {}
|
|
39
|
+
try:
|
|
40
|
+
value = json.loads(raw)
|
|
41
|
+
except (TypeError, json.JSONDecodeError):
|
|
42
|
+
return {}
|
|
43
|
+
return value if isinstance(value, dict) else {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _source(row: sqlite3.Row, *, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
47
|
+
origin = {"message_id": row["id"], "channel": row["channel"], "seq": row["seq"], "sender": row["sender"],
|
|
48
|
+
"reply_to": row["reply_to"], "status": row["status"], "hash": row["hash"]}
|
|
49
|
+
origin.update(extra or {})
|
|
50
|
+
return {"client": "importer", "importer": IMPORTER, "author": row["sender"],
|
|
51
|
+
"origin": {k: v for k, v in origin.items() if v is not None}}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _message_text(row: sqlite3.Row) -> str:
|
|
55
|
+
title = str(row["title"] or "").strip()
|
|
56
|
+
body = str(row["body"] or "").strip()
|
|
57
|
+
return f"# {title}\n\n{body}" if title and body else (title or body)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _attachments(conn: sqlite3.Connection, row: sqlite3.Row) -> List[Dict[str, Any]]:
|
|
61
|
+
out = []
|
|
62
|
+
for att in _data(row["data"]).get("attachments") or []:
|
|
63
|
+
if not isinstance(att, dict) or not att.get("id"):
|
|
64
|
+
continue
|
|
65
|
+
blob = conn.execute("SELECT bytes, filename, content_type FROM blobs WHERE id=? LIMIT 1", (att["id"],)).fetchone()
|
|
66
|
+
if blob is None or blob["bytes"] is None:
|
|
67
|
+
continue
|
|
68
|
+
out.append({"data": bytes(blob["bytes"]), "mime": att.get("content_type") or blob["content_type"],
|
|
69
|
+
"filename": att.get("filename") or blob["filename"]})
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_noise(row: sqlite3.Row, report: ImportReport, *, skip_system: bool) -> bool:
|
|
74
|
+
if row["retracted_at"] is not None:
|
|
75
|
+
report.skip("retracted")
|
|
76
|
+
return True
|
|
77
|
+
if skip_system and row["kind"] in ("system", "fs"):
|
|
78
|
+
report.skip(f"kind_{row['kind']}")
|
|
79
|
+
return True
|
|
80
|
+
if skip_system and row["channel"] in NOISE_CHANNELS:
|
|
81
|
+
report.skip("hub_alerts")
|
|
82
|
+
return True
|
|
83
|
+
if skip_system and _data(row["data"]).get("silence_alert"):
|
|
84
|
+
report.skip("silence_alert")
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def drafts(*, report: ImportReport, path: str | Path, channels: Optional[Sequence[str]] = None,
|
|
90
|
+
skip_system: bool = True, operators: Sequence[str] = DEFAULT_OPERATORS,
|
|
91
|
+
since: Optional[str] = None, until: Optional[str] = None) -> Iterator[NoteDraft]:
|
|
92
|
+
conn = _connect(Path(path).expanduser())
|
|
93
|
+
keep = {c.strip() for c in (channels or []) if c and c.strip()} or None
|
|
94
|
+
try:
|
|
95
|
+
yield from _store_drafts(conn, report, keep, since, until)
|
|
96
|
+
yield from _thread_and_operator_drafts(conn, report, keep, skip_system, set(operators), since, until)
|
|
97
|
+
finally:
|
|
98
|
+
conn.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _store_drafts(conn: sqlite3.Connection, report: ImportReport, keep: Optional[set], since: Optional[str],
|
|
102
|
+
until: Optional[str]) -> Iterator[NoteDraft]:
|
|
103
|
+
rows = conn.execute(
|
|
104
|
+
"SELECT channel, key, value, version, updated_by, updated_at FROM store "
|
|
105
|
+
"WHERE key LIKE 'decision:%' OR key LIKE 'claim:%' OR key LIKE 'work:%' ORDER BY updated_at")
|
|
106
|
+
for row in rows:
|
|
107
|
+
if keep is not None and row["channel"] not in keep:
|
|
108
|
+
report.skip("channel_filtered")
|
|
109
|
+
continue
|
|
110
|
+
kind = next(v for p, v in STORE_PREFIXES.items() if row["key"].startswith(p))
|
|
111
|
+
try:
|
|
112
|
+
value = json.loads(row["value"])
|
|
113
|
+
except (TypeError, json.JSONDecodeError):
|
|
114
|
+
value = row["value"]
|
|
115
|
+
message = None
|
|
116
|
+
if isinstance(value, dict) and value.get("message_id"):
|
|
117
|
+
message = conn.execute("SELECT * FROM messages WHERE id=?", (value["message_id"],)).fetchone()
|
|
118
|
+
if isinstance(value, dict):
|
|
119
|
+
summary = str(value.get("summary") or value.get("text") or value.get("title") or "").strip()
|
|
120
|
+
details = json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
|
121
|
+
else:
|
|
122
|
+
summary, details = str(value or "").strip(), ""
|
|
123
|
+
parts = [summary] if summary else []
|
|
124
|
+
if message is not None:
|
|
125
|
+
parts.append("## From the message\n\n" + _message_text(message))
|
|
126
|
+
if details and not summary:
|
|
127
|
+
parts.append(details)
|
|
128
|
+
text = "\n\n".join(p for p in parts if p).strip()
|
|
129
|
+
if not text:
|
|
130
|
+
report.skip("empty")
|
|
131
|
+
continue
|
|
132
|
+
ts = iso_from_epoch(message["created_at"]) if message is not None else iso_from_epoch(row["updated_at"])
|
|
133
|
+
if not in_window(ts, since, until):
|
|
134
|
+
report.skip("outside_window")
|
|
135
|
+
continue
|
|
136
|
+
origin = {"channel": row["channel"], "store_key": row["key"], "version": row["version"],
|
|
137
|
+
"updated_by": row["updated_by"], "message_id": message["id"] if message is not None else None}
|
|
138
|
+
source = {"client": "importer", "importer": IMPORTER, "author": row["updated_by"],
|
|
139
|
+
"origin": {k: v for k, v in origin.items() if v is not None}}
|
|
140
|
+
yield NoteDraft(IMPORTER, f"store:{row['channel']}:{row['key']}:{row['version']}", text, ts, kind,
|
|
141
|
+
row["key"].split(":", 1)[1], source)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _thread_and_operator_drafts(conn: sqlite3.Connection, report: ImportReport, keep: Optional[set],
|
|
145
|
+
skip_system: bool, operators: set, since: Optional[str], until: Optional[str]
|
|
146
|
+
) -> Iterator[NoteDraft]:
|
|
147
|
+
rows = conn.execute("SELECT * FROM messages ORDER BY created_at, seq").fetchall()
|
|
148
|
+
by_id = {r["id"]: r for r in rows}
|
|
149
|
+
|
|
150
|
+
def root_of(r: sqlite3.Row) -> sqlite3.Row:
|
|
151
|
+
seen = set()
|
|
152
|
+
while r["reply_to"] and r["reply_to"] in by_id and r["id"] not in seen:
|
|
153
|
+
seen.add(r["id"])
|
|
154
|
+
r = by_id[r["reply_to"]]
|
|
155
|
+
return r
|
|
156
|
+
|
|
157
|
+
replies: Dict[str, List[sqlite3.Row]] = {}
|
|
158
|
+
kept: List[sqlite3.Row] = []
|
|
159
|
+
for r in rows:
|
|
160
|
+
if keep is not None and r["channel"] not in keep:
|
|
161
|
+
report.skip("channel_filtered")
|
|
162
|
+
continue
|
|
163
|
+
if _is_noise(r, report, skip_system=skip_system):
|
|
164
|
+
continue
|
|
165
|
+
kept.append(r)
|
|
166
|
+
if r["reply_to"]:
|
|
167
|
+
replies.setdefault(root_of(r)["id"], []).append(r)
|
|
168
|
+
in_thread: set = set()
|
|
169
|
+
for r in kept:
|
|
170
|
+
if r["reply_to"]:
|
|
171
|
+
continue
|
|
172
|
+
thread = replies.get(r["id"]) or []
|
|
173
|
+
if not thread and r["status"] != "resolved":
|
|
174
|
+
continue
|
|
175
|
+
ts = iso_from_epoch(r["created_at"])
|
|
176
|
+
in_thread.add(r["id"])
|
|
177
|
+
in_thread.update(x["id"] for x in thread)
|
|
178
|
+
if not in_window(ts, since, until):
|
|
179
|
+
report.skip("outside_window")
|
|
180
|
+
continue
|
|
181
|
+
lines = [_message_text(r)]
|
|
182
|
+
for x in thread:
|
|
183
|
+
lines.append(f"## {x['sender']} ({iso_from_epoch(x['created_at'])})\n\n{_message_text(x)}")
|
|
184
|
+
atts = _attachments(conn, r)
|
|
185
|
+
for x in thread:
|
|
186
|
+
atts.extend(_attachments(conn, x))
|
|
187
|
+
yield NoteDraft(IMPORTER, f"thread:{r['id']}", "\n\n".join(lines), ts,
|
|
188
|
+
"resolved_thread" if r["status"] == "resolved" else "answered_thread",
|
|
189
|
+
str(r["title"] or "").strip() or None,
|
|
190
|
+
_source(r, extra={"replies": len(thread)}), atts)
|
|
191
|
+
for r in kept:
|
|
192
|
+
if r["sender"] not in operators or r["id"] in in_thread:
|
|
193
|
+
continue
|
|
194
|
+
text = _message_text(r)
|
|
195
|
+
if not text:
|
|
196
|
+
report.skip("empty")
|
|
197
|
+
continue
|
|
198
|
+
ts = iso_from_epoch(r["created_at"])
|
|
199
|
+
if not in_window(ts, since, until):
|
|
200
|
+
report.skip("outside_window")
|
|
201
|
+
continue
|
|
202
|
+
yield NoteDraft(IMPORTER, f"message:{r['id']}", text, ts, "operator_message",
|
|
203
|
+
str(r["title"] or "").strip() or None, _source(r), _attachments(conn, r))
|