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.
Files changed (80) hide show
  1. abstractnote/__init__.py +18 -0
  2. abstractnote/cli.py +240 -0
  3. abstractnote/flows/__init__.py +2 -0
  4. abstractnote/flows/build.py +970 -0
  5. abstractnote/flows/bundle.py +62 -0
  6. abstractnote/flows/contract.py +88 -0
  7. abstractnote/flows/graph.py +540 -0
  8. abstractnote/flows/json/note-capture.json +2280 -0
  9. abstractnote/flows/json/note-drain.json +2012 -0
  10. abstractnote/flows/json/note-mine.json +2392 -0
  11. abstractnote/flows/json/note-process.json +1853 -0
  12. abstractnote/flows/json/note-recall.json +1876 -0
  13. abstractnote/flows/json/note-reflect.json +2057 -0
  14. abstractnote/flows/prompts_fallback.py +119 -0
  15. abstractnote/flows/runner.py +320 -0
  16. abstractnote/flows/schemas_fallback.py +175 -0
  17. abstractnote/gateway/__init__.py +24 -0
  18. abstractnote/gateway/appspec.py +10 -0
  19. abstractnote/gateway/router.py +571 -0
  20. abstractnote/gateway/tools.py +74 -0
  21. abstractnote/ids.py +177 -0
  22. abstractnote/importers/__init__.py +142 -0
  23. abstractnote/importers/__main__.py +3 -0
  24. abstractnote/importers/agora.py +203 -0
  25. abstractnote/importers/claude_sessions.py +415 -0
  26. abstractnote/importers/cli.py +113 -0
  27. abstractnote/importers/files.py +72 -0
  28. abstractnote/importers/markdown.py +165 -0
  29. abstractnote/ingest/__init__.py +7 -0
  30. abstractnote/ingest/blobs.py +47 -0
  31. abstractnote/ingest/capture.py +265 -0
  32. abstractnote/ingest/extract.py +118 -0
  33. abstractnote/ingest/normalise.py +95 -0
  34. abstractnote/model.py +229 -0
  35. abstractnote/process/__init__.py +5 -0
  36. abstractnote/process/context.py +218 -0
  37. abstractnote/process/merge.py +60 -0
  38. abstractnote/process/prompts/__init__.py +39 -0
  39. abstractnote/process/prompts/extract.md +30 -0
  40. abstractnote/process/prompts/link.md +32 -0
  41. abstractnote/process/prompts/mine_dossier.md +13 -0
  42. abstractnote/process/prompts/mine_findings.md +13 -0
  43. abstractnote/process/prompts/mine_plan.md +13 -0
  44. abstractnote/process/prompts/quick_filing.md +23 -0
  45. abstractnote/process/prompts/reflect.md +10 -0
  46. abstractnote/process/prompts/synthesize.md +17 -0
  47. abstractnote/process/resolve.py +96 -0
  48. abstractnote/process/revise.py +410 -0
  49. abstractnote/process/schemas.py +377 -0
  50. abstractnote/process/vocabulary.py +68 -0
  51. abstractnote/recall/__init__.py +1 -0
  52. abstractnote/recall/changes.py +191 -0
  53. abstractnote/recall/expand.py +379 -0
  54. abstractnote/recall/insights.py +62 -0
  55. abstractnote/recall/reflect.py +202 -0
  56. abstractnote/recall/review.py +191 -0
  57. abstractnote/recall/search.py +205 -0
  58. abstractnote/recall/shapes.py +52 -0
  59. abstractnote/recall/then_now.py +100 -0
  60. abstractnote/store/__init__.py +12 -0
  61. abstractnote/store/archive.py +166 -0
  62. abstractnote/store/embeddings.py +275 -0
  63. abstractnote/store/kg.py +219 -0
  64. abstractnote/store/notebook.py +829 -0
  65. abstractnote/store/notes.py +1190 -0
  66. abstractnote/store/queue.py +159 -0
  67. abstractnote/store/schema.py +348 -0
  68. abstractnote/store/vector_index.py +88 -0
  69. abstractnote/tools/__init__.py +215 -0
  70. abstractnote/tools/capture.py +121 -0
  71. abstractnote/tools/context.py +19 -0
  72. abstractnote/tools/process.py +22 -0
  73. abstractnote/tools/queue.py +23 -0
  74. abstractnote/tools/recall.py +64 -0
  75. abstractnote-0.1.0.dist-info/METADATA +145 -0
  76. abstractnote-0.1.0.dist-info/RECORD +80 -0
  77. abstractnote-0.1.0.dist-info/WHEEL +5 -0
  78. abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
  79. abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
  80. 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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main(prog="python -m abstractnote.importers"))
@@ -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))