memcal 0.8.0__tar.gz

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 (145) hide show
  1. memcal-0.8.0/LICENSE +21 -0
  2. memcal-0.8.0/PKG-INFO +66 -0
  3. memcal-0.8.0/README.md +38 -0
  4. memcal-0.8.0/memcal/__init__.py +6 -0
  5. memcal-0.8.0/memcal/__main__.py +3 -0
  6. memcal-0.8.0/memcal/actions.py +230 -0
  7. memcal-0.8.0/memcal/activity.py +447 -0
  8. memcal-0.8.0/memcal/archive.py +540 -0
  9. memcal-0.8.0/memcal/brief.py +818 -0
  10. memcal-0.8.0/memcal/calls.py +247 -0
  11. memcal-0.8.0/memcal/cli.py +2547 -0
  12. memcal-0.8.0/memcal/config.py +271 -0
  13. memcal-0.8.0/memcal/dates.py +242 -0
  14. memcal-0.8.0/memcal/db.py +426 -0
  15. memcal-0.8.0/memcal/detail.py +343 -0
  16. memcal-0.8.0/memcal/dream/__init__.py +7 -0
  17. memcal-0.8.0/memcal/dream/affinity.py +282 -0
  18. memcal-0.8.0/memcal/dream/apply.py +1292 -0
  19. memcal-0.8.0/memcal/dream/bundle.py +366 -0
  20. memcal-0.8.0/memcal/dream/instructions.py +127 -0
  21. memcal-0.8.0/memcal/dream/merge.py +1128 -0
  22. memcal-0.8.0/memcal/dream/propose.py +1607 -0
  23. memcal-0.8.0/memcal/dream/resolve.py +7 -0
  24. memcal-0.8.0/memcal/dream/retry.py +60 -0
  25. memcal-0.8.0/memcal/dream/run.py +677 -0
  26. memcal-0.8.0/memcal/dream/stages.py +96 -0
  27. memcal-0.8.0/memcal/dream/sweep.py +180 -0
  28. memcal-0.8.0/memcal/dream/wakes.py +274 -0
  29. memcal-0.8.0/memcal/events.py +1492 -0
  30. memcal-0.8.0/memcal/gate.py +400 -0
  31. memcal-0.8.0/memcal/harness.py +163 -0
  32. memcal-0.8.0/memcal/identity.py +592 -0
  33. memcal-0.8.0/memcal/legacy.py +109 -0
  34. memcal-0.8.0/memcal/live.py +790 -0
  35. memcal-0.8.0/memcal/llm.py +1090 -0
  36. memcal-0.8.0/memcal/lock.py +99 -0
  37. memcal-0.8.0/memcal/macos/icon.png +0 -0
  38. memcal-0.8.0/memcal/macos/launcher.c +51 -0
  39. memcal-0.8.0/memcal/mcp_server.py +956 -0
  40. memcal-0.8.0/memcal/pending.py +232 -0
  41. memcal-0.8.0/memcal/presentation.py +55 -0
  42. memcal-0.8.0/memcal/questions.py +149 -0
  43. memcal-0.8.0/memcal/schedule.py +926 -0
  44. memcal-0.8.0/memcal/schema.sql +637 -0
  45. memcal-0.8.0/memcal/series.py +396 -0
  46. memcal-0.8.0/memcal/settings.py +594 -0
  47. memcal-0.8.0/memcal/sources/__init__.py +204 -0
  48. memcal-0.8.0/memcal/sources/base.py +389 -0
  49. memcal-0.8.0/memcal/sources/bluebubbles.py +196 -0
  50. memcal-0.8.0/memcal/sources/groupme.py +666 -0
  51. memcal-0.8.0/memcal/sources/ical.py +2544 -0
  52. memcal-0.8.0/memcal/sources/imessage.py +301 -0
  53. memcal-0.8.0/memcal/sources/polled.py +386 -0
  54. memcal-0.8.0/memcal/sources/proton.py +693 -0
  55. memcal-0.8.0/memcal/sources/providers/__init__.py +68 -0
  56. memcal-0.8.0/memcal/sources/providers/partiful.py +312 -0
  57. memcal-0.8.0/memcal/sources/signal.py +278 -0
  58. memcal-0.8.0/memcal/sources/slack.py +502 -0
  59. memcal-0.8.0/memcal/sources/spec.py +106 -0
  60. memcal-0.8.0/memcal/sources/telegram.py +314 -0
  61. memcal-0.8.0/memcal/sources/whatsapp.py +403 -0
  62. memcal-0.8.0/memcal/static/app.js +15 -0
  63. memcal-0.8.0/memcal/static/chats.js +139 -0
  64. memcal-0.8.0/memcal/static/core.js +134 -0
  65. memcal-0.8.0/memcal/static/dream.js +496 -0
  66. memcal-0.8.0/memcal/static/gate.js +268 -0
  67. memcal-0.8.0/memcal/static/icon.png +0 -0
  68. memcal-0.8.0/memcal/static/jobs.js +160 -0
  69. memcal-0.8.0/memcal/static/memory.js +527 -0
  70. memcal-0.8.0/memcal/static/runs.js +330 -0
  71. memcal-0.8.0/memcal/static/senders.js +76 -0
  72. memcal-0.8.0/memcal/static/settings.js +709 -0
  73. memcal-0.8.0/memcal/static/styles.css +737 -0
  74. memcal-0.8.0/memcal/static/trace.js +210 -0
  75. memcal-0.8.0/memcal/static/wiki.js +57 -0
  76. memcal-0.8.0/memcal/textclean.py +179 -0
  77. memcal-0.8.0/memcal/threads.py +642 -0
  78. memcal-0.8.0/memcal/todos.py +1047 -0
  79. memcal-0.8.0/memcal/trace.py +528 -0
  80. memcal-0.8.0/memcal/web.py +104 -0
  81. memcal-0.8.0/memcal/web_dream.py +205 -0
  82. memcal-0.8.0/memcal/web_jobs.py +447 -0
  83. memcal-0.8.0/memcal/web_memory.py +670 -0
  84. memcal-0.8.0/memcal/web_queue.py +516 -0
  85. memcal-0.8.0/memcal/web_server.py +455 -0
  86. memcal-0.8.0/memcal/web_settings.py +250 -0
  87. memcal-0.8.0/memcal/webui.html +333 -0
  88. memcal-0.8.0/memcal/whois.py +452 -0
  89. memcal-0.8.0/memcal/wiki.py +1002 -0
  90. memcal-0.8.0/memcal.egg-info/PKG-INFO +66 -0
  91. memcal-0.8.0/memcal.egg-info/SOURCES.txt +143 -0
  92. memcal-0.8.0/memcal.egg-info/dependency_links.txt +1 -0
  93. memcal-0.8.0/memcal.egg-info/entry_points.txt +2 -0
  94. memcal-0.8.0/memcal.egg-info/requires.txt +14 -0
  95. memcal-0.8.0/memcal.egg-info/top_level.txt +1 -0
  96. memcal-0.8.0/pyproject.toml +56 -0
  97. memcal-0.8.0/setup.cfg +4 -0
  98. memcal-0.8.0/tests/test_affinity.py +436 -0
  99. memcal-0.8.0/tests/test_benchmark.py +847 -0
  100. memcal-0.8.0/tests/test_bundle_shape.py +147 -0
  101. memcal-0.8.0/tests/test_cancellation_linking.py +553 -0
  102. memcal-0.8.0/tests/test_cli_lifecycle.py +120 -0
  103. memcal-0.8.0/tests/test_collection_jobs.py +806 -0
  104. memcal-0.8.0/tests/test_config_secret.py +55 -0
  105. memcal-0.8.0/tests/test_core.py +7649 -0
  106. memcal-0.8.0/tests/test_daytime_freshness_fixes.py +494 -0
  107. memcal-0.8.0/tests/test_daytime_part2.py +386 -0
  108. memcal-0.8.0/tests/test_daytime_part3.py +684 -0
  109. memcal-0.8.0/tests/test_daytime_part4.py +463 -0
  110. memcal-0.8.0/tests/test_daytime_part5.py +570 -0
  111. memcal-0.8.0/tests/test_daytime_part6.py +488 -0
  112. memcal-0.8.0/tests/test_daytime_review_completion.py +84 -0
  113. memcal-0.8.0/tests/test_daytime_stage1.py +615 -0
  114. memcal-0.8.0/tests/test_docs.py +45 -0
  115. memcal-0.8.0/tests/test_gate_chat_streams.py +92 -0
  116. memcal-0.8.0/tests/test_groupme.py +476 -0
  117. memcal-0.8.0/tests/test_hermes.py +636 -0
  118. memcal-0.8.0/tests/test_hermes_delivery.py +25 -0
  119. memcal-0.8.0/tests/test_ical_siri_dedup.py +142 -0
  120. memcal-0.8.0/tests/test_live_series_source.py +226 -0
  121. memcal-0.8.0/tests/test_live_write.py +713 -0
  122. memcal-0.8.0/tests/test_llm_providers.py +459 -0
  123. memcal-0.8.0/tests/test_merge.py +510 -0
  124. memcal-0.8.0/tests/test_openclaw.py +97 -0
  125. memcal-0.8.0/tests/test_operational_regressions.py +3694 -0
  126. memcal-0.8.0/tests/test_platform.py +996 -0
  127. memcal-0.8.0/tests/test_polled.py +349 -0
  128. memcal-0.8.0/tests/test_prefix_waves.py +102 -0
  129. memcal-0.8.0/tests/test_propose_breaker.py +292 -0
  130. memcal-0.8.0/tests/test_quality.py +462 -0
  131. memcal-0.8.0/tests/test_regressions.py +4084 -0
  132. memcal-0.8.0/tests/test_relevant_events.py +169 -0
  133. memcal-0.8.0/tests/test_reminder_delivery.py +293 -0
  134. memcal-0.8.0/tests/test_settings.py +503 -0
  135. memcal-0.8.0/tests/test_signal.py +213 -0
  136. memcal-0.8.0/tests/test_slack.py +320 -0
  137. memcal-0.8.0/tests/test_spam_boundaries.py +77 -0
  138. memcal-0.8.0/tests/test_stages.py +344 -0
  139. memcal-0.8.0/tests/test_telegram.py +207 -0
  140. memcal-0.8.0/tests/test_wakes.py +285 -0
  141. memcal-0.8.0/tests/test_web.py +1571 -0
  142. memcal-0.8.0/tests/test_web_security.py +111 -0
  143. memcal-0.8.0/tests/test_whois.py +302 -0
  144. memcal-0.8.0/tests/test_wiki_memory.py +548 -0
  145. memcal-0.8.0/tests/test_wiki_persistence.py +207 -0
memcal-0.8.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Cush
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
memcal-0.8.0/PKG-INFO ADDED
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: memcal
3
+ Version: 0.8.0
4
+ Summary: A calendar and a to-do list that live in context, fed by the streams you already get.
5
+ Author-email: Chris Cush <chris@cuuush.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/cuuush/memcal
8
+ Project-URL: Repository, https://github.com/cuuush/memcal
9
+ Project-URL: Issues, https://github.com/cuuush/memcal/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Environment :: Console
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: docs
18
+ Requires-Dist: mkdocs>=1.6; extra == "docs"
19
+ Requires-Dist: mkdocs-material>=9.6; extra == "docs"
20
+ Provides-Extra: slack
21
+ Requires-Dist: slack_sdk>=3.27; extra == "slack"
22
+ Provides-Extra: telegram
23
+ Requires-Dist: telethon>=1.36; extra == "telegram"
24
+ Provides-Extra: chat
25
+ Requires-Dist: slack_sdk>=3.27; extra == "chat"
26
+ Requires-Dist: telethon>=1.36; extra == "chat"
27
+ Dynamic: license-file
28
+
29
+ # memcal
30
+
31
+ **Memory + Calendar for AI agents**
32
+
33
+ Most memory systems for agents like OpenClaw or Hermes (Mem0, Hindsight) are good at saving things like preferences and observations, but fail at personal assistant tasks, like remembering you have a dentist appointment next Friday. Memcal attempts to bridge this gap by enabling an agent to maintain its own internal calendar of your life. On top of that, a nightly fact-gathering stage will scan sources like iMessage, email, WhatsApp, iCal, and more to automatically update the agent calendar.
34
+
35
+ With Memcal, you can ask an agent, “What’s my weekend looking like?” and it will remember that your friend is free for dinner Saturday night, that the nonprofit you follow is having a member day, or even that your family is coming into town...
36
+
37
+ ## Getting started
38
+
39
+ Requires Python 3.11+, SQLite with FTS5, and one model backend (Codex by
40
+ default; Claude Code, Antigravity, or OpenRouter also work).
41
+
42
+ ```bash
43
+ git clone https://github.com/cuuush/memcal.git
44
+ cd memcal
45
+ ./install.sh
46
+ memcal setup
47
+ memcal doctor
48
+ memcal brief
49
+ ```
50
+
51
+ Memcal reads sensitive personal data and sends selected source text to the
52
+ model provider you configure — read the [privacy notes](https://cuuush.github.io/memcal/privacy/)
53
+ before connecting real accounts.
54
+
55
+ ## Learn more
56
+
57
+ Full guides live at **https://cuuush.github.io/memcal/**:
58
+
59
+ - [Quickstart](https://cuuush.github.io/memcal/quickstart/) — install, first ingest, nightly job
60
+ - [Sources](https://cuuush.github.io/memcal/sources/) — iMessage, WhatsApp, Slack, Telegram, Signal, email, calendar
61
+ - [CLI](https://cuuush.github.io/memcal/clients/cli/) and [CLI reference](https://cuuush.github.io/memcal/api/cli/) — every command
62
+ - [Integrations](https://cuuush.github.io/memcal/integrations/) — Hermes, OpenClaw, any MCP harness
63
+ - [Hosting](https://cuuush.github.io/memcal/hosting/installation/) — configuration, scheduling, publishing
64
+ - [Evaluation](https://cuuush.github.io/memcal/evaluation/) — how memory quality is measured
65
+
66
+ Contributing: [CONTRIBUTING.md](CONTRIBUTING.md). Notable changes: [CHANGELOG.md](CHANGELOG.md).
memcal-0.8.0/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # memcal
2
+
3
+ **Memory + Calendar for AI agents**
4
+
5
+ Most memory systems for agents like OpenClaw or Hermes (Mem0, Hindsight) are good at saving things like preferences and observations, but fail at personal assistant tasks, like remembering you have a dentist appointment next Friday. Memcal attempts to bridge this gap by enabling an agent to maintain its own internal calendar of your life. On top of that, a nightly fact-gathering stage will scan sources like iMessage, email, WhatsApp, iCal, and more to automatically update the agent calendar.
6
+
7
+ With Memcal, you can ask an agent, “What’s my weekend looking like?” and it will remember that your friend is free for dinner Saturday night, that the nonprofit you follow is having a member day, or even that your family is coming into town...
8
+
9
+ ## Getting started
10
+
11
+ Requires Python 3.11+, SQLite with FTS5, and one model backend (Codex by
12
+ default; Claude Code, Antigravity, or OpenRouter also work).
13
+
14
+ ```bash
15
+ git clone https://github.com/cuuush/memcal.git
16
+ cd memcal
17
+ ./install.sh
18
+ memcal setup
19
+ memcal doctor
20
+ memcal brief
21
+ ```
22
+
23
+ Memcal reads sensitive personal data and sends selected source text to the
24
+ model provider you configure — read the [privacy notes](https://cuuush.github.io/memcal/privacy/)
25
+ before connecting real accounts.
26
+
27
+ ## Learn more
28
+
29
+ Full guides live at **https://cuuush.github.io/memcal/**:
30
+
31
+ - [Quickstart](https://cuuush.github.io/memcal/quickstart/) — install, first ingest, nightly job
32
+ - [Sources](https://cuuush.github.io/memcal/sources/) — iMessage, WhatsApp, Slack, Telegram, Signal, email, calendar
33
+ - [CLI](https://cuuush.github.io/memcal/clients/cli/) and [CLI reference](https://cuuush.github.io/memcal/api/cli/) — every command
34
+ - [Integrations](https://cuuush.github.io/memcal/integrations/) — Hermes, OpenClaw, any MCP harness
35
+ - [Hosting](https://cuuush.github.io/memcal/hosting/installation/) — configuration, scheduling, publishing
36
+ - [Evaluation](https://cuuush.github.io/memcal/evaluation/) — how memory quality is measured
37
+
38
+ Contributing: [CONTRIBUTING.md](CONTRIBUTING.md). Notable changes: [CHANGELOG.md](CHANGELOG.md).
@@ -0,0 +1,6 @@
1
+ """memcal — a calendar and a to-do list that live in context.
2
+
3
+ Entry point: memcal.cli:main.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,230 @@
1
+ """Completed typed actions for replay safety and nightly context.
2
+
3
+ Each record identifies the operation, its stable target, changed fields, source message
4
+ ids, and the state version it acted on. This lets a retry remain a no-op and prevents a
5
+ nightly reread from duplicating or undoing a daytime correction. Surfaces pass `Origin`
6
+ explicitly because concurrent sessions make global "latest message" state ambiguous.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import sqlite3
14
+ from dataclasses import dataclass, field
15
+
16
+ from . import db
17
+
18
+ #: Recorded when a caller supplies no originating turn. The operation still happens; the
19
+ #: limitation is written down rather than left for a reader to mistake for "no cause".
20
+ NO_SOURCE_NOTE = "caller supplied no originating turn"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Origin:
25
+ """Where a typed write came from, and what evidence it considered.
26
+
27
+ `archive_ids` is authorship: the turn behind this call. `cited` is review:
28
+ activity lines the caller opened and is answering with. The two ride
29
+ together for provenance but only `cited` acknowledges reviewed
30
+ observations — a newer user question must never mark older unread source
31
+ messages as considered.
32
+ """
33
+
34
+ surface: str = "unknown"
35
+ #: Archive ids of the turn behind this call. Empty is allowed and recorded.
36
+ archive_ids: tuple[int, ...] = ()
37
+ #: Archive ids of opened evidence this write answers with. Validated and
38
+ #: reviewed at the write; unknown ids are dropped, never stored.
39
+ cited: tuple[int, ...] = ()
40
+ session: str = ""
41
+ note: str = ""
42
+ #: The caller's own idempotency key for this operation, when it has one. A client
43
+ #: retrying a call whose answer it never saw supplies the same key, and the retry
44
+ #: becomes a no-op instead of a second instruction.
45
+ op_id: str = ""
46
+
47
+ @classmethod
48
+ def of(cls, surface: str, archive_ids=None, *, cited=None, session: str = "",
49
+ note: str = "", op_id: str = "") -> "Origin":
50
+ ids = tuple(int(i) for i in (archive_ids or ()) if i)
51
+ cites = tuple(int(i) for i in (cited or ()) if i)
52
+ return cls(surface=surface or "unknown", archive_ids=ids, cited=cites,
53
+ session=session, op_id=op_id,
54
+ note=note or ("" if (ids or cites) else NO_SOURCE_NOTE))
55
+
56
+ @property
57
+ def sourced(self) -> bool:
58
+ return bool(self.archive_ids or self.cited)
59
+
60
+
61
+ #: The default for a caller that has not been taught to supply context yet. Callers
62
+ #: without source context must still work; this is what "and record that limitation"
63
+ #: looks like in practice.
64
+ UNKNOWN = Origin.of("unknown")
65
+
66
+
67
+ @dataclass
68
+ class Action:
69
+ op_id: str
70
+ kind: str
71
+ ref: str
72
+ verb: str
73
+ surface: str
74
+ fields: dict = field(default_factory=dict)
75
+ source_ids: list[int] = field(default_factory=list)
76
+ source_note: str = ""
77
+ based_on: str = ""
78
+ session: str = ""
79
+ at: str = ""
80
+
81
+ @classmethod
82
+ def from_row(cls, row: sqlite3.Row) -> "Action":
83
+ return cls(
84
+ op_id=row["op_id"], kind=row["kind"], ref=row["ref"], verb=row["verb"],
85
+ surface=row["surface"], fields=db.jload(row["fields"], {}),
86
+ source_ids=db.jload(row["source_ids"], []),
87
+ source_note=row["source_note"] or "", based_on=row["based_on"] or "",
88
+ session=row["session"] or "", at=row["at"])
89
+
90
+ @property
91
+ def changed(self) -> list[str]:
92
+ return sorted(self.fields)
93
+
94
+ def one_line(self) -> str:
95
+ """"11:04 · moved date 2026-09-18 → 2026-09-19" — enough to recognise it."""
96
+ parts = []
97
+ for name in self.changed:
98
+ pair = self.fields.get(name)
99
+ if isinstance(pair, (list, tuple)) and len(pair) == 2:
100
+ old, new = pair
101
+ parts.append(f"{name} {old or '(none)'} → {new or '(none)'}"
102
+ if str(old) != str(new) else f"{name} {new}")
103
+ else:
104
+ parts.append(f"{name} {pair}")
105
+ detail = "; ".join(parts) if parts else "no field changed"
106
+ return f"{str(self.at)[11:16]} · {self.verb} · {detail}"
107
+
108
+
109
+ def plan(*, kind: str, ref: str, verb: str, origin: Origin, request: dict,
110
+ at: str) -> str:
111
+ """Identify an operation from its request before state changes.
112
+
113
+ A caller key wins. Otherwise the digest covers the target, verb, supplied fields,
114
+ and source turn—not mutable before-values—so retries keep one identity. With no
115
+ attributable turn, the timestamp keeps identical unknown calls distinct.
116
+ """
117
+ if origin.op_id:
118
+ return str(origin.op_id)[:64]
119
+ seed = json.dumps({
120
+ "kind": kind, "ref": ref, "verb": verb,
121
+ "surface": origin.surface, "session": origin.session,
122
+ "sources": sorted(origin.archive_ids),
123
+ "cited": sorted(origin.cited),
124
+ "request": {k: request[k] for k in sorted(request)},
125
+ "at": "" if origin.sourced else at,
126
+ }, sort_keys=True, default=str)
127
+ return hashlib.sha1(seed.encode()).hexdigest()[:20]
128
+
129
+
130
+ def seen(conn: sqlite3.Connection, identifier: str) -> bool:
131
+ """Has this exact operation already been carried out?
132
+
133
+ Called inside the transaction that is about to mutate, so the answer cannot go stale
134
+ between the check and the write.
135
+ """
136
+ if not identifier:
137
+ return False
138
+ return conn.execute("SELECT 1 FROM actions WHERE op_id = ? LIMIT 1",
139
+ (identifier,)).fetchone() is not None
140
+
141
+
142
+ def record(conn: sqlite3.Connection, *, kind: str, ref: str, verb: str,
143
+ origin: Origin = UNKNOWN, fields: dict | None = None,
144
+ based_on: str | None = None, at: str | None = None,
145
+ op_id: str = "", commit: bool = False) -> str:
146
+ """Write one completed operation. Returns its id; a repeat is a no-op.
147
+
148
+ `op_id` is the identity `plan()` computed before the change was made. Deliberately
149
+ never commits by default: the caller commits the state change and this record
150
+ together, or neither happens.
151
+ """
152
+ if not ref:
153
+ return ""
154
+ fields = {k: v for k, v in (fields or {}).items()}
155
+ stamp = at or db.now()
156
+ identifier = op_id or plan(kind=kind, ref=ref, verb=verb, origin=origin,
157
+ request=fields, at=stamp)
158
+ conn.execute(
159
+ """INSERT INTO actions(op_id, kind, ref, verb, surface, session, fields,
160
+ source_ids, source_note, based_on, at)
161
+ VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(op_id) DO NOTHING""",
162
+ (identifier, kind, ref, verb, origin.surface, origin.session or None,
163
+ db.jdump(fields), db.jdump(list(origin.archive_ids)),
164
+ origin.note or (None if origin.sourced else NO_SOURCE_NOTE),
165
+ based_on or None, stamp),
166
+ )
167
+ if commit:
168
+ conn.commit()
169
+ return identifier
170
+
171
+
172
+ def for_refs(conn: sqlite3.Connection, kind: str, refs, *,
173
+ limit: int = 12) -> list[Action]:
174
+ """Completed operations against these targets, newest last."""
175
+ keys = [str(ref) for ref in refs if ref]
176
+ if not keys:
177
+ return []
178
+ holes = ",".join("?" for _ in keys)
179
+ rows = conn.execute(
180
+ f"SELECT * FROM actions WHERE kind = ? AND ref IN ({holes})"
181
+ f" ORDER BY at DESC, id DESC LIMIT ?", (kind, *keys, limit)).fetchall()
182
+ return [Action.from_row(row) for row in reversed(rows)]
183
+
184
+
185
+ def for_archive(conn: sqlite3.Connection, archive_ids, *,
186
+ limit: int = 12) -> list[Action]:
187
+ """Operations whose originating turn includes any of these archive lines.
188
+
189
+ This is the half that makes a *statement* recognisable as already acted on, rather
190
+ than only a row recognisable as already touched.
191
+ """
192
+ wanted = {int(i) for i in (archive_ids or ()) if i}
193
+ if not wanted:
194
+ return []
195
+ out: list[Action] = []
196
+ for row in conn.execute(
197
+ "SELECT * FROM actions ORDER BY at DESC, id DESC LIMIT 400"):
198
+ action = Action.from_row(row)
199
+ if wanted & set(action.source_ids):
200
+ out.append(action)
201
+ if len(out) >= limit:
202
+ break
203
+ return list(reversed(out))
204
+
205
+
206
+ def latest_for(conn: sqlite3.Connection, kind: str, ref: str) -> Action | None:
207
+ row = conn.execute(
208
+ "SELECT * FROM actions WHERE kind = ? AND ref = ? ORDER BY at DESC, id DESC"
209
+ " LIMIT 1", (kind, ref)).fetchone()
210
+ return Action.from_row(row) if row else None
211
+
212
+
213
+ def render(actions: list[Action]) -> str:
214
+ """The block a bundle shows the model. Says what was done and what it was reading."""
215
+ if not actions:
216
+ return ""
217
+ lines = [
218
+ "ALREADY DONE FOR THIS CONVERSATION BY THE ASSISTANT'S TYPED TOOLS.",
219
+ "These changes are already in the store. A line here that only restates one of",
220
+ "them is not news: do not propose it again, and do not create a second row for",
221
+ "it. The rest of the message may still contain facts nothing has acted on.",
222
+ ]
223
+ for action in actions:
224
+ bits = [action.ref, action.one_line()]
225
+ if action.source_ids:
226
+ bits.append("from line(s) " + ", ".join(str(i) for i in action.source_ids))
227
+ elif action.source_note:
228
+ bits.append(f"({action.source_note})")
229
+ lines.append(" " + " | ".join(bits))
230
+ return "\n".join(lines)