memcal 1.0.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.
- memcal-1.0.0/LICENSE +21 -0
- memcal-1.0.0/PKG-INFO +104 -0
- memcal-1.0.0/README.md +73 -0
- memcal-1.0.0/memcal/__init__.py +6 -0
- memcal-1.0.0/memcal/__main__.py +3 -0
- memcal-1.0.0/memcal/actions.py +255 -0
- memcal-1.0.0/memcal/activity.py +716 -0
- memcal-1.0.0/memcal/archive.py +544 -0
- memcal-1.0.0/memcal/brief.py +1002 -0
- memcal-1.0.0/memcal/calls.py +280 -0
- memcal-1.0.0/memcal/cli.py +2837 -0
- memcal-1.0.0/memcal/config.py +323 -0
- memcal-1.0.0/memcal/dates.py +242 -0
- memcal-1.0.0/memcal/db.py +572 -0
- memcal-1.0.0/memcal/detail.py +352 -0
- memcal-1.0.0/memcal/dream/__init__.py +7 -0
- memcal-1.0.0/memcal/dream/affinity.py +282 -0
- memcal-1.0.0/memcal/dream/apply.py +1476 -0
- memcal-1.0.0/memcal/dream/bundle.py +532 -0
- memcal-1.0.0/memcal/dream/diagnostics.py +72 -0
- memcal-1.0.0/memcal/dream/event_context.py +167 -0
- memcal-1.0.0/memcal/dream/instructions.py +158 -0
- memcal-1.0.0/memcal/dream/live.py +148 -0
- memcal-1.0.0/memcal/dream/merge.py +1221 -0
- memcal-1.0.0/memcal/dream/propose.py +2139 -0
- memcal-1.0.0/memcal/dream/resolve.py +7 -0
- memcal-1.0.0/memcal/dream/retry.py +172 -0
- memcal-1.0.0/memcal/dream/run.py +789 -0
- memcal-1.0.0/memcal/dream/stages.py +96 -0
- memcal-1.0.0/memcal/dream/sweep.py +231 -0
- memcal-1.0.0/memcal/dream/wakes.py +274 -0
- memcal-1.0.0/memcal/entrypoint.py +19 -0
- memcal-1.0.0/memcal/events.py +1704 -0
- memcal-1.0.0/memcal/gate.py +400 -0
- memcal-1.0.0/memcal/harness.py +164 -0
- memcal-1.0.0/memcal/identity.py +665 -0
- memcal-1.0.0/memcal/legacy.py +109 -0
- memcal-1.0.0/memcal/live.py +1278 -0
- memcal-1.0.0/memcal/llm.py +1377 -0
- memcal-1.0.0/memcal/lock.py +104 -0
- memcal-1.0.0/memcal/macos/icon.png +0 -0
- memcal-1.0.0/memcal/macos/launcher.c +51 -0
- memcal-1.0.0/memcal/mcp_server.py +1028 -0
- memcal-1.0.0/memcal/pending.py +232 -0
- memcal-1.0.0/memcal/presentation.py +111 -0
- memcal-1.0.0/memcal/questions.py +149 -0
- memcal-1.0.0/memcal/schedule.py +1043 -0
- memcal-1.0.0/memcal/schema.sql +705 -0
- memcal-1.0.0/memcal/series.py +420 -0
- memcal-1.0.0/memcal/settings.py +685 -0
- memcal-1.0.0/memcal/setup_config.py +213 -0
- memcal-1.0.0/memcal/sources/__init__.py +232 -0
- memcal-1.0.0/memcal/sources/base.py +400 -0
- memcal-1.0.0/memcal/sources/bluebubbles.py +336 -0
- memcal-1.0.0/memcal/sources/groupme.py +672 -0
- memcal-1.0.0/memcal/sources/ical.py +2551 -0
- memcal-1.0.0/memcal/sources/imessage.py +331 -0
- memcal-1.0.0/memcal/sources/polled.py +400 -0
- memcal-1.0.0/memcal/sources/proton.py +760 -0
- memcal-1.0.0/memcal/sources/providers/__init__.py +68 -0
- memcal-1.0.0/memcal/sources/providers/partiful.py +312 -0
- memcal-1.0.0/memcal/sources/signal.py +284 -0
- memcal-1.0.0/memcal/sources/slack.py +507 -0
- memcal-1.0.0/memcal/sources/spec.py +106 -0
- memcal-1.0.0/memcal/sources/telegram.py +318 -0
- memcal-1.0.0/memcal/sources/whatsapp.py +405 -0
- memcal-1.0.0/memcal/static/app.js +15 -0
- memcal-1.0.0/memcal/static/chats.js +149 -0
- memcal-1.0.0/memcal/static/core.js +189 -0
- memcal-1.0.0/memcal/static/dream.js +633 -0
- memcal-1.0.0/memcal/static/gate.js +286 -0
- memcal-1.0.0/memcal/static/icon.png +0 -0
- memcal-1.0.0/memcal/static/jobs.js +160 -0
- memcal-1.0.0/memcal/static/memory.js +710 -0
- memcal-1.0.0/memcal/static/runs.js +347 -0
- memcal-1.0.0/memcal/static/senders.js +76 -0
- memcal-1.0.0/memcal/static/settings.js +803 -0
- memcal-1.0.0/memcal/static/styles.css +1024 -0
- memcal-1.0.0/memcal/static/trace.js +210 -0
- memcal-1.0.0/memcal/static/wiki.js +77 -0
- memcal-1.0.0/memcal/textclean.py +179 -0
- memcal-1.0.0/memcal/threads.py +876 -0
- memcal-1.0.0/memcal/todos.py +1047 -0
- memcal-1.0.0/memcal/trace.py +568 -0
- memcal-1.0.0/memcal/update.py +199 -0
- memcal-1.0.0/memcal/web.py +106 -0
- memcal-1.0.0/memcal/web_dream.py +325 -0
- memcal-1.0.0/memcal/web_jobs.py +464 -0
- memcal-1.0.0/memcal/web_memory.py +715 -0
- memcal-1.0.0/memcal/web_queue.py +619 -0
- memcal-1.0.0/memcal/web_server.py +534 -0
- memcal-1.0.0/memcal/web_settings.py +301 -0
- memcal-1.0.0/memcal/webui.html +318 -0
- memcal-1.0.0/memcal/whois.py +569 -0
- memcal-1.0.0/memcal/wiki.py +1195 -0
- memcal-1.0.0/memcal.egg-info/PKG-INFO +104 -0
- memcal-1.0.0/memcal.egg-info/SOURCES.txt +181 -0
- memcal-1.0.0/memcal.egg-info/dependency_links.txt +1 -0
- memcal-1.0.0/memcal.egg-info/entry_points.txt +2 -0
- memcal-1.0.0/memcal.egg-info/requires.txt +15 -0
- memcal-1.0.0/memcal.egg-info/top_level.txt +1 -0
- memcal-1.0.0/pyproject.toml +62 -0
- memcal-1.0.0/setup.cfg +4 -0
- memcal-1.0.0/tests/test_affinity.py +436 -0
- memcal-1.0.0/tests/test_attendee_updates.py +108 -0
- memcal-1.0.0/tests/test_brief_budget.py +116 -0
- memcal-1.0.0/tests/test_bundle_shape.py +147 -0
- memcal-1.0.0/tests/test_calendar_booking_lifecycle.py +210 -0
- memcal-1.0.0/tests/test_cancellation_linking.py +553 -0
- memcal-1.0.0/tests/test_cli_lifecycle.py +120 -0
- memcal-1.0.0/tests/test_clock_pin.py +59 -0
- memcal-1.0.0/tests/test_codex_model_setup.py +193 -0
- memcal-1.0.0/tests/test_collection_jobs.py +858 -0
- memcal-1.0.0/tests/test_config_secret.py +55 -0
- memcal-1.0.0/tests/test_core.py +7649 -0
- memcal-1.0.0/tests/test_daytime_freshness_fixes.py +644 -0
- memcal-1.0.0/tests/test_daytime_part2.py +387 -0
- memcal-1.0.0/tests/test_daytime_part3.py +684 -0
- memcal-1.0.0/tests/test_daytime_part4.py +466 -0
- memcal-1.0.0/tests/test_daytime_part5.py +570 -0
- memcal-1.0.0/tests/test_daytime_part6.py +491 -0
- memcal-1.0.0/tests/test_daytime_review_completion.py +85 -0
- memcal-1.0.0/tests/test_daytime_stage1.py +615 -0
- memcal-1.0.0/tests/test_doctor_access.py +117 -0
- memcal-1.0.0/tests/test_dream_diagnostics.py +142 -0
- memcal-1.0.0/tests/test_dream_live.py +228 -0
- memcal-1.0.0/tests/test_dream_naming.py +170 -0
- memcal-1.0.0/tests/test_dream_output.py +273 -0
- memcal-1.0.0/tests/test_dream_usability.py +191 -0
- memcal-1.0.0/tests/test_elapsed_plans.py +86 -0
- memcal-1.0.0/tests/test_email_source_controls.py +103 -0
- memcal-1.0.0/tests/test_ended_workshop_identity.py +134 -0
- memcal-1.0.0/tests/test_event_semantic_context.py +231 -0
- memcal-1.0.0/tests/test_field_evidence.py +482 -0
- memcal-1.0.0/tests/test_gate_chat_streams.py +92 -0
- memcal-1.0.0/tests/test_groupme.py +476 -0
- memcal-1.0.0/tests/test_hermes.py +718 -0
- memcal-1.0.0/tests/test_hermes_delivery.py +25 -0
- memcal-1.0.0/tests/test_ical_siri_dedup.py +142 -0
- memcal-1.0.0/tests/test_later_people.py +158 -0
- memcal-1.0.0/tests/test_live_series_source.py +226 -0
- memcal-1.0.0/tests/test_live_write.py +713 -0
- memcal-1.0.0/tests/test_llm_providers.py +765 -0
- memcal-1.0.0/tests/test_merge.py +510 -0
- memcal-1.0.0/tests/test_month_calendar.py +35 -0
- memcal-1.0.0/tests/test_nightly_evidence_order.py +57 -0
- memcal-1.0.0/tests/test_open_includes_activity.py +106 -0
- memcal-1.0.0/tests/test_openai_compatible.py +88 -0
- memcal-1.0.0/tests/test_openclaw.py +145 -0
- memcal-1.0.0/tests/test_operational_regressions.py +3949 -0
- memcal-1.0.0/tests/test_plan_reconciliation.py +232 -0
- memcal-1.0.0/tests/test_platform.py +1011 -0
- memcal-1.0.0/tests/test_polled.py +357 -0
- memcal-1.0.0/tests/test_prefix_waves.py +102 -0
- memcal-1.0.0/tests/test_prior_plan_context.py +91 -0
- memcal-1.0.0/tests/test_propose_breaker.py +292 -0
- memcal-1.0.0/tests/test_quality.py +389 -0
- memcal-1.0.0/tests/test_regressions.py +4168 -0
- memcal-1.0.0/tests/test_relevant_events.py +169 -0
- memcal-1.0.0/tests/test_reminder_delivery.py +293 -0
- memcal-1.0.0/tests/test_resume.py +591 -0
- memcal-1.0.0/tests/test_review_baselines.py +329 -0
- memcal-1.0.0/tests/test_review_fixes.py +364 -0
- memcal-1.0.0/tests/test_schedule_catchup.py +107 -0
- memcal-1.0.0/tests/test_separate_occasions.py +68 -0
- memcal-1.0.0/tests/test_settings.py +581 -0
- memcal-1.0.0/tests/test_setup_config.py +187 -0
- memcal-1.0.0/tests/test_signal.py +214 -0
- memcal-1.0.0/tests/test_slack.py +320 -0
- memcal-1.0.0/tests/test_source_preview.py +62 -0
- memcal-1.0.0/tests/test_spam_boundaries.py +77 -0
- memcal-1.0.0/tests/test_stages.py +344 -0
- memcal-1.0.0/tests/test_telegram.py +207 -0
- memcal-1.0.0/tests/test_ui_contracts.py +66 -0
- memcal-1.0.0/tests/test_update.py +239 -0
- memcal-1.0.0/tests/test_update_components.py +70 -0
- memcal-1.0.0/tests/test_wakes.py +285 -0
- memcal-1.0.0/tests/test_web.py +1866 -0
- memcal-1.0.0/tests/test_web_security.py +201 -0
- memcal-1.0.0/tests/test_whois.py +473 -0
- memcal-1.0.0/tests/test_wiki_citations.py +205 -0
- memcal-1.0.0/tests/test_wiki_memory.py +636 -0
- memcal-1.0.0/tests/test_wiki_persistence.py +207 -0
memcal-1.0.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-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memcal
|
|
3
|
+
Version: 1.0.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
|
+
Project-URL: Documentation, https://cuuush.github.io/memcal/
|
|
11
|
+
Keywords: mcp,calendar,memory,agents,openclaw,hermes,slack
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: pytypedstream>=0.1
|
|
20
|
+
Provides-Extra: docs
|
|
21
|
+
Requires-Dist: mkdocs>=1.6; extra == "docs"
|
|
22
|
+
Requires-Dist: mkdocs-material>=9.6; extra == "docs"
|
|
23
|
+
Provides-Extra: slack
|
|
24
|
+
Requires-Dist: slack_sdk>=3.27; extra == "slack"
|
|
25
|
+
Provides-Extra: telegram
|
|
26
|
+
Requires-Dist: telethon>=1.36; extra == "telegram"
|
|
27
|
+
Provides-Extra: chat
|
|
28
|
+
Requires-Dist: slack_sdk>=3.27; extra == "chat"
|
|
29
|
+
Requires-Dist: telethon>=1.36; extra == "chat"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# memcal
|
|
33
|
+
|
|
34
|
+
**Memory and calendar for AI agents**
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/memcal/)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](https://cuuush.github.io/memcal/)
|
|
40
|
+
|
|
41
|
+
Memcal gives your agent a memory of your plans, conversations, and commitments. It brings together sources such as iMessage, email, WhatsApp, and calendars, then uses a nightly **Dream** pass to update events and other memories as things change.
|
|
42
|
+
|
|
43
|
+
Ask “What's my weekend looking like?” and your agent can recall a possible dinner with a friend, a community event, or a family visit—even when those plans started in different conversations.
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
Memcal prepares a brief of the next 30 days so an agent can answer common questions without a separate lookup. The brief tells you if its token budget leaves events out. Events keep their history and evidence: a past appointment does not automatically mean you attended it.
|
|
48
|
+
|
|
49
|
+
## Get started
|
|
50
|
+
|
|
51
|
+
You'll need Python 3.11+, SQLite with FTS5, and a model backend. Codex is the default; Claude Code, Antigravity, Grok, OpenRouter, and OpenAI-compatible APIs are also supported.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install memcal
|
|
55
|
+
memcal setup
|
|
56
|
+
memcal doctor
|
|
57
|
+
memcal brief
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Setup walks you through your provider and sources. For Codex via OAuth, sign in with `codex login` first; setup lists available models without making an inference call. The default model is `gpt-6-luna` for Codex and `openai/gpt-6-luna` for OpenRouter. Your saved model choice takes precedence.
|
|
61
|
+
|
|
62
|
+
Memcal is built first for **macOS**, where it can use Messages, Calendar, and launchd scheduling. iMessage reads the local Messages database by default and requires Full Disk Access for the process running Memcal. See [iMessage setup](docs/sources/imessage.md) for BlueBubbles and permission details. Email, MCP, and optional chat integrations also work on Linux; install Slack support with `pip install 'memcal[slack]'`.
|
|
63
|
+
|
|
64
|
+
Memcal reads sensitive personal data and sends selected source text to your configured model provider. Read the [privacy notes](https://cuuush.github.io/memcal/privacy/) before connecting real accounts.
|
|
65
|
+
|
|
66
|
+
For a checkout instead of PyPI:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/cuuush/memcal.git
|
|
70
|
+
cd memcal
|
|
71
|
+
./install.sh
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
See the [quickstart](https://cuuush.github.io/memcal/quickstart/) for your first ingest and nightly schedule.
|
|
75
|
+
|
|
76
|
+
## Connect your agent
|
|
77
|
+
|
|
78
|
+
Memcal works with any MCP client and has native integrations for [Hermes](https://cuuush.github.io/memcal/integrations/hermes/) and [OpenClaw](https://cuuush.github.io/memcal/integrations/openclaw/). To start the MCP server:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python3 -m memcal.mcp_server
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
See the [MCP guide](https://cuuush.github.io/memcal/clients/mcp/) for client configuration. Agents can also look beyond the brief with tools such as `memcal_list_month(month="2026-10")` and `memcal_list_days(when="2026-01-01", days=365)`.
|
|
85
|
+
|
|
86
|
+
## Using Memcal
|
|
87
|
+
|
|
88
|
+
- `memcal dream` collects and processes updates, then shows a summary. Add `--verbose` for details or `--dry-run` for a cost estimate without model calls.
|
|
89
|
+
- `memcal review` shows recent changes and open questions. Answer a question using its displayed handle, for example `memcal answer Q12 "your answer"`.
|
|
90
|
+
- `memcal open E42` shows a record, its changes, and cited source messages. Use `memcal activity E42` to see newer or uncited conversation traffic.
|
|
91
|
+
- `memcal schedule install` installs the macOS scheduler; `memcal schedule status` checks it. Reinstall the schedule after changing runtime locations.
|
|
92
|
+
- `memcal update` updates a checkout or pip installation from GitHub, upgrades dependencies, migrates the store, and refreshes the brief and installed scheduler. Local edits or unpublished commits stop a checkout update. Restart running UI and agent sessions afterward.
|
|
93
|
+
|
|
94
|
+
Run `memcal setup --advanced` for model, brief, Dream, and publishing settings, or `memcal setup --section imessage` to revisit a section. See [configuration](docs/hosting/configuration.md) for provider and model options, including OpenAI-compatible endpoints.
|
|
95
|
+
|
|
96
|
+
## How it works
|
|
97
|
+
|
|
98
|
+
Memcal stores events, recurring series, todos, questions, and wiki entries in typed stores. It joins related information across sources, keeps older values in history, and applies updates using the time their evidence was said. The nightly Dream pass collects information, proposes and merges changes, and refreshes the brief. See [Memcal vs. retrieval](https://cuuush.github.io/memcal/architecture/memcal-vs-rag/) for the design.
|
|
99
|
+
|
|
100
|
+
## Learn more
|
|
101
|
+
|
|
102
|
+
- [Documentation](https://cuuush.github.io/memcal/) · [Quickstart](https://cuuush.github.io/memcal/quickstart/) · [Sources](https://cuuush.github.io/memcal/sources/)
|
|
103
|
+
- [CLI](https://cuuush.github.io/memcal/clients/cli/) · [MCP](https://cuuush.github.io/memcal/clients/mcp/) · [Integrations](https://cuuush.github.io/memcal/integrations/)
|
|
104
|
+
- [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
|
memcal-1.0.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# memcal
|
|
2
|
+
|
|
3
|
+
**Memory and calendar for AI agents**
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/memcal/)
|
|
6
|
+
[](https://www.python.org/downloads/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://cuuush.github.io/memcal/)
|
|
9
|
+
|
|
10
|
+
Memcal gives your agent a memory of your plans, conversations, and commitments. It brings together sources such as iMessage, email, WhatsApp, and calendars, then uses a nightly **Dream** pass to update events and other memories as things change.
|
|
11
|
+
|
|
12
|
+
Ask “What's my weekend looking like?” and your agent can recall a possible dinner with a friend, a community event, or a family visit—even when those plans started in different conversations.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
Memcal prepares a brief of the next 30 days so an agent can answer common questions without a separate lookup. The brief tells you if its token budget leaves events out. Events keep their history and evidence: a past appointment does not automatically mean you attended it.
|
|
17
|
+
|
|
18
|
+
## Get started
|
|
19
|
+
|
|
20
|
+
You'll need Python 3.11+, SQLite with FTS5, and a model backend. Codex is the default; Claude Code, Antigravity, Grok, OpenRouter, and OpenAI-compatible APIs are also supported.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install memcal
|
|
24
|
+
memcal setup
|
|
25
|
+
memcal doctor
|
|
26
|
+
memcal brief
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Setup walks you through your provider and sources. For Codex via OAuth, sign in with `codex login` first; setup lists available models without making an inference call. The default model is `gpt-6-luna` for Codex and `openai/gpt-6-luna` for OpenRouter. Your saved model choice takes precedence.
|
|
30
|
+
|
|
31
|
+
Memcal is built first for **macOS**, where it can use Messages, Calendar, and launchd scheduling. iMessage reads the local Messages database by default and requires Full Disk Access for the process running Memcal. See [iMessage setup](docs/sources/imessage.md) for BlueBubbles and permission details. Email, MCP, and optional chat integrations also work on Linux; install Slack support with `pip install 'memcal[slack]'`.
|
|
32
|
+
|
|
33
|
+
Memcal reads sensitive personal data and sends selected source text to your configured model provider. Read the [privacy notes](https://cuuush.github.io/memcal/privacy/) before connecting real accounts.
|
|
34
|
+
|
|
35
|
+
For a checkout instead of PyPI:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/cuuush/memcal.git
|
|
39
|
+
cd memcal
|
|
40
|
+
./install.sh
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
See the [quickstart](https://cuuush.github.io/memcal/quickstart/) for your first ingest and nightly schedule.
|
|
44
|
+
|
|
45
|
+
## Connect your agent
|
|
46
|
+
|
|
47
|
+
Memcal works with any MCP client and has native integrations for [Hermes](https://cuuush.github.io/memcal/integrations/hermes/) and [OpenClaw](https://cuuush.github.io/memcal/integrations/openclaw/). To start the MCP server:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3 -m memcal.mcp_server
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See the [MCP guide](https://cuuush.github.io/memcal/clients/mcp/) for client configuration. Agents can also look beyond the brief with tools such as `memcal_list_month(month="2026-10")` and `memcal_list_days(when="2026-01-01", days=365)`.
|
|
54
|
+
|
|
55
|
+
## Using Memcal
|
|
56
|
+
|
|
57
|
+
- `memcal dream` collects and processes updates, then shows a summary. Add `--verbose` for details or `--dry-run` for a cost estimate without model calls.
|
|
58
|
+
- `memcal review` shows recent changes and open questions. Answer a question using its displayed handle, for example `memcal answer Q12 "your answer"`.
|
|
59
|
+
- `memcal open E42` shows a record, its changes, and cited source messages. Use `memcal activity E42` to see newer or uncited conversation traffic.
|
|
60
|
+
- `memcal schedule install` installs the macOS scheduler; `memcal schedule status` checks it. Reinstall the schedule after changing runtime locations.
|
|
61
|
+
- `memcal update` updates a checkout or pip installation from GitHub, upgrades dependencies, migrates the store, and refreshes the brief and installed scheduler. Local edits or unpublished commits stop a checkout update. Restart running UI and agent sessions afterward.
|
|
62
|
+
|
|
63
|
+
Run `memcal setup --advanced` for model, brief, Dream, and publishing settings, or `memcal setup --section imessage` to revisit a section. See [configuration](docs/hosting/configuration.md) for provider and model options, including OpenAI-compatible endpoints.
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
Memcal stores events, recurring series, todos, questions, and wiki entries in typed stores. It joins related information across sources, keeps older values in history, and applies updates using the time their evidence was said. The nightly Dream pass collects information, proposes and merges changes, and refreshes the brief. See [Memcal vs. retrieval](https://cuuush.github.io/memcal/architecture/memcal-vs-rag/) for the design.
|
|
68
|
+
|
|
69
|
+
## Learn more
|
|
70
|
+
|
|
71
|
+
- [Documentation](https://cuuush.github.io/memcal/) · [Quickstart](https://cuuush.github.io/memcal/quickstart/) · [Sources](https://cuuush.github.io/memcal/sources/)
|
|
72
|
+
- [CLI](https://cuuush.github.io/memcal/clients/cli/) · [MCP](https://cuuush.github.io/memcal/clients/mcp/) · [Integrations](https://cuuush.github.io/memcal/integrations/)
|
|
73
|
+
- [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
field_sources: dict = field(default_factory=dict)
|
|
81
|
+
context_source_ids: list[int] = field(default_factory=list)
|
|
82
|
+
outcome: dict = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_row(cls, row: sqlite3.Row) -> "Action":
|
|
86
|
+
keys = set(row.keys())
|
|
87
|
+
return cls(
|
|
88
|
+
op_id=row["op_id"], kind=row["kind"], ref=row["ref"], verb=row["verb"],
|
|
89
|
+
surface=row["surface"], fields=db.jload(row["fields"], {}),
|
|
90
|
+
source_ids=db.jload(row["source_ids"], []),
|
|
91
|
+
source_note=row["source_note"] or "", based_on=row["based_on"] or "",
|
|
92
|
+
session=row["session"] or "", at=row["at"],
|
|
93
|
+
field_sources=db.jload(row["field_sources"], {})
|
|
94
|
+
if "field_sources" in keys else {},
|
|
95
|
+
context_source_ids=db.jload(row["context_source_ids"], [])
|
|
96
|
+
if "context_source_ids" in keys else [],
|
|
97
|
+
outcome=db.jload(row["outcome"], {}) if "outcome" in keys else {})
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def changed(self) -> list[str]:
|
|
101
|
+
return sorted(self.fields)
|
|
102
|
+
|
|
103
|
+
def one_line(self) -> str:
|
|
104
|
+
""""11:04 · moved date 2026-09-18 → 2026-09-19" — enough to recognise it."""
|
|
105
|
+
parts = []
|
|
106
|
+
for name in self.changed:
|
|
107
|
+
pair = self.fields.get(name)
|
|
108
|
+
if isinstance(pair, (list, tuple)) and len(pair) == 2:
|
|
109
|
+
old, new = pair
|
|
110
|
+
parts.append(f"{name} {old or '(none)'} → {new or '(none)'}"
|
|
111
|
+
if str(old) != str(new) else f"{name} {new}")
|
|
112
|
+
else:
|
|
113
|
+
parts.append(f"{name} {pair}")
|
|
114
|
+
detail = "; ".join(parts) if parts else "no field changed"
|
|
115
|
+
return f"{str(self.at)[11:16]} · {self.verb} · {detail}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def plan(*, kind: str, ref: str, verb: str, origin: Origin, request: dict,
|
|
119
|
+
at: str) -> str:
|
|
120
|
+
"""Identify an operation from its request before state changes.
|
|
121
|
+
|
|
122
|
+
A caller key wins. Otherwise the digest covers the target, verb, supplied fields,
|
|
123
|
+
and source turn—not mutable before-values—so retries keep one identity. With no
|
|
124
|
+
attributable turn, the timestamp keeps identical unknown calls distinct.
|
|
125
|
+
"""
|
|
126
|
+
if origin.op_id:
|
|
127
|
+
return str(origin.op_id)[:64]
|
|
128
|
+
seed = json.dumps({
|
|
129
|
+
"kind": kind, "ref": ref, "verb": verb,
|
|
130
|
+
"surface": origin.surface, "session": origin.session,
|
|
131
|
+
"sources": sorted(origin.archive_ids),
|
|
132
|
+
"cited": sorted(origin.cited),
|
|
133
|
+
"request": {k: request[k] for k in sorted(request)},
|
|
134
|
+
"at": "" if origin.sourced else at,
|
|
135
|
+
}, sort_keys=True, default=str)
|
|
136
|
+
return hashlib.sha1(seed.encode()).hexdigest()[:20]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def seen(conn: sqlite3.Connection, identifier: str) -> bool:
|
|
140
|
+
"""Has this exact operation already been carried out?
|
|
141
|
+
|
|
142
|
+
Called inside the transaction that is about to mutate, so the answer cannot go stale
|
|
143
|
+
between the check and the write.
|
|
144
|
+
"""
|
|
145
|
+
if not identifier:
|
|
146
|
+
return False
|
|
147
|
+
return conn.execute("SELECT 1 FROM actions WHERE op_id = ? LIMIT 1",
|
|
148
|
+
(identifier,)).fetchone() is not None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get(conn: sqlite3.Connection, op_id: str) -> Action | None:
|
|
152
|
+
"""Load one completed operation by its idempotency key."""
|
|
153
|
+
if not op_id:
|
|
154
|
+
return None
|
|
155
|
+
row = conn.execute("SELECT * FROM actions WHERE op_id = ?", (op_id,)).fetchone()
|
|
156
|
+
return Action.from_row(row) if row else None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def record(conn: sqlite3.Connection, *, kind: str, ref: str, verb: str,
|
|
160
|
+
origin: Origin = UNKNOWN, fields: dict | None = None,
|
|
161
|
+
based_on: str | None = None, at: str | None = None,
|
|
162
|
+
op_id: str = "", commit: bool = False,
|
|
163
|
+
field_sources: dict | None = None,
|
|
164
|
+
context_source_ids: list[int] | None = None,
|
|
165
|
+
outcome: dict | None = None) -> str:
|
|
166
|
+
"""Write one completed operation. Returns its id; a repeat is a no-op.
|
|
167
|
+
|
|
168
|
+
`op_id` is the identity `plan()` computed before the change was made. Deliberately
|
|
169
|
+
never commits by default: the caller commits the state change and this record
|
|
170
|
+
together, or neither happens.
|
|
171
|
+
"""
|
|
172
|
+
if not ref:
|
|
173
|
+
return ""
|
|
174
|
+
fields = {k: v for k, v in (fields or {}).items()}
|
|
175
|
+
stamp = at or db.now()
|
|
176
|
+
identifier = op_id or plan(kind=kind, ref=ref, verb=verb, origin=origin,
|
|
177
|
+
request=fields, at=stamp)
|
|
178
|
+
fs = field_sources or {}
|
|
179
|
+
ctx = list(context_source_ids or [])
|
|
180
|
+
out = outcome or {}
|
|
181
|
+
conn.execute(
|
|
182
|
+
"""INSERT INTO actions(op_id, kind, ref, verb, surface, session, fields,
|
|
183
|
+
source_ids, source_note, based_on, at,
|
|
184
|
+
field_sources, context_source_ids, outcome)
|
|
185
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(op_id) DO NOTHING""",
|
|
186
|
+
(identifier, kind, ref, verb, origin.surface, origin.session or None,
|
|
187
|
+
db.jdump(fields), db.jdump(list(origin.archive_ids)),
|
|
188
|
+
origin.note or (None if origin.sourced else NO_SOURCE_NOTE),
|
|
189
|
+
based_on or None, stamp,
|
|
190
|
+
db.jdump(fs), db.jdump(ctx), db.jdump(out)),
|
|
191
|
+
)
|
|
192
|
+
if commit:
|
|
193
|
+
conn.commit()
|
|
194
|
+
return identifier
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def for_refs(conn: sqlite3.Connection, kind: str, refs, *,
|
|
198
|
+
limit: int = 12) -> list[Action]:
|
|
199
|
+
"""Completed operations against these targets, newest last."""
|
|
200
|
+
keys = [str(ref) for ref in refs if ref]
|
|
201
|
+
if not keys:
|
|
202
|
+
return []
|
|
203
|
+
holes = ",".join("?" for _ in keys)
|
|
204
|
+
rows = conn.execute(
|
|
205
|
+
f"SELECT * FROM actions WHERE kind = ? AND ref IN ({holes})"
|
|
206
|
+
f" ORDER BY at DESC, id DESC LIMIT ?", (kind, *keys, limit)).fetchall()
|
|
207
|
+
return [Action.from_row(row) for row in reversed(rows)]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def for_archive(conn: sqlite3.Connection, archive_ids, *,
|
|
211
|
+
limit: int = 12) -> list[Action]:
|
|
212
|
+
"""Operations whose originating turn includes any of these archive lines.
|
|
213
|
+
|
|
214
|
+
This is the half that makes a *statement* recognisable as already acted on, rather
|
|
215
|
+
than only a row recognisable as already touched.
|
|
216
|
+
"""
|
|
217
|
+
wanted = {int(i) for i in (archive_ids or ()) if i}
|
|
218
|
+
if not wanted:
|
|
219
|
+
return []
|
|
220
|
+
out: list[Action] = []
|
|
221
|
+
for row in conn.execute(
|
|
222
|
+
"SELECT * FROM actions ORDER BY at DESC, id DESC LIMIT 400"):
|
|
223
|
+
action = Action.from_row(row)
|
|
224
|
+
if wanted & set(action.source_ids):
|
|
225
|
+
out.append(action)
|
|
226
|
+
if len(out) >= limit:
|
|
227
|
+
break
|
|
228
|
+
return list(reversed(out))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def latest_for(conn: sqlite3.Connection, kind: str, ref: str) -> Action | None:
|
|
232
|
+
row = conn.execute(
|
|
233
|
+
"SELECT * FROM actions WHERE kind = ? AND ref = ? ORDER BY at DESC, id DESC"
|
|
234
|
+
" LIMIT 1", (kind, ref)).fetchone()
|
|
235
|
+
return Action.from_row(row) if row else None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def render(actions: list[Action]) -> str:
|
|
239
|
+
"""The block a bundle shows the model. Says what was done and what it was reading."""
|
|
240
|
+
if not actions:
|
|
241
|
+
return ""
|
|
242
|
+
lines = [
|
|
243
|
+
"ALREADY DONE FOR THIS CONVERSATION BY THE ASSISTANT'S TYPED TOOLS.",
|
|
244
|
+
"These changes are already in the store. A line here that only restates one of",
|
|
245
|
+
"them is not news: do not propose it again, and do not create a second row for",
|
|
246
|
+
"it. The rest of the message may still contain facts nothing has acted on.",
|
|
247
|
+
]
|
|
248
|
+
for action in actions:
|
|
249
|
+
bits = [action.ref, action.one_line()]
|
|
250
|
+
if action.source_ids:
|
|
251
|
+
bits.append("from line(s) " + ", ".join(str(i) for i in action.source_ids))
|
|
252
|
+
elif action.source_note:
|
|
253
|
+
bits.append(f"({action.source_note})")
|
|
254
|
+
lines.append(" " + " | ".join(bits))
|
|
255
|
+
return "\n".join(lines)
|