orbit-crm 0.1.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 (34) hide show
  1. orbit_crm-0.1.0/LICENSE +21 -0
  2. orbit_crm-0.1.0/PKG-INFO +280 -0
  3. orbit_crm-0.1.0/README.md +242 -0
  4. orbit_crm-0.1.0/pyproject.toml +127 -0
  5. orbit_crm-0.1.0/pyproject.toml.orig +114 -0
  6. orbit_crm-0.1.0/src/orbit/__init__.py +5 -0
  7. orbit_crm-0.1.0/src/orbit/__main__.py +5 -0
  8. orbit_crm-0.1.0/src/orbit/bases.py +13 -0
  9. orbit_crm-0.1.0/src/orbit/cli.py +129 -0
  10. orbit_crm-0.1.0/src/orbit/config.py +120 -0
  11. orbit_crm-0.1.0/src/orbit/console.py +18 -0
  12. orbit_crm-0.1.0/src/orbit/crm.py +210 -0
  13. orbit_crm-0.1.0/src/orbit/edits.py +53 -0
  14. orbit_crm-0.1.0/src/orbit/entries.py +71 -0
  15. orbit_crm-0.1.0/src/orbit/errors.py +6 -0
  16. orbit_crm-0.1.0/src/orbit/frontmatter.py +251 -0
  17. orbit_crm-0.1.0/src/orbit/ids.py +31 -0
  18. orbit_crm-0.1.0/src/orbit/index.py +388 -0
  19. orbit_crm-0.1.0/src/orbit/lint.py +119 -0
  20. orbit_crm-0.1.0/src/orbit/names.py +193 -0
  21. orbit_crm-0.1.0/src/orbit/reads.py +201 -0
  22. orbit_crm-0.1.0/src/orbit/resolve.py +229 -0
  23. orbit_crm-0.1.0/src/orbit/reviews.py +54 -0
  24. orbit_crm-0.1.0/src/orbit/schema.py +339 -0
  25. orbit_crm-0.1.0/src/orbit/sections.py +93 -0
  26. orbit_crm-0.1.0/src/orbit/server.py +522 -0
  27. orbit_crm-0.1.0/src/orbit/settings.py +23 -0
  28. orbit_crm-0.1.0/src/orbit/templates/Follow-ups.base +53 -0
  29. orbit_crm-0.1.0/src/orbit/templates/People.base +61 -0
  30. orbit_crm-0.1.0/src/orbit/templates/Review.base +30 -0
  31. orbit_crm-0.1.0/src/orbit/upkeep.py +305 -0
  32. orbit_crm-0.1.0/src/orbit/vault.py +221 -0
  33. orbit_crm-0.1.0/src/orbit/watch.py +24 -0
  34. orbit_crm-0.1.0/src/orbit/writes.py +450 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tobias Würth
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.
@@ -0,0 +1,280 @@
1
+ Metadata-Version: 2.4
2
+ Name: orbit-crm
3
+ Version: 0.1.0
4
+ Summary: A personal CRM as a vault of Markdown notes, kept by a local MCP server for AI agents: people, organizations, interactions, tasks and topics, with duplicate checks and a source for every fact.
5
+ Keywords: crm,personal-crm,mcp,mcp-server,model-context-protocol,obsidian,markdown,knowledge-base,entity-resolution,ai-agents,local-first,sqlite
6
+ Author: Tobias Würth
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.15
22
+ Classifier: Natural Language :: English
23
+ Classifier: Topic :: Office/Business
24
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
25
+ Requires-Dist: mcp>=2.0
26
+ Requires-Dist: watchfiles>=1.0
27
+ Requires-Dist: filelock>=3.16
28
+ Requires-Dist: uvicorn>=0.31
29
+ Requires-Dist: anyio>=4.9
30
+ Requires-Dist: pydantic>=2.12
31
+ Requires-Python: >=3.11
32
+ Project-URL: Homepage, https://github.com/tobiaswuerth/orbit
33
+ Project-URL: Documentation, https://github.com/tobiaswuerth/orbit#readme
34
+ Project-URL: Source, https://github.com/tobiaswuerth/orbit
35
+ Project-URL: Issues, https://github.com/tobiaswuerth/orbit/issues
36
+ Project-URL: Changelog, https://github.com/tobiaswuerth/orbit/blob/main/CHANGELOG.md
37
+ Description-Content-Type: text/markdown
38
+
39
+ # orbit: a personal CRM as Markdown notes, kept by an MCP server
40
+
41
+ [![CI](https://github.com/tobiaswuerth/orbit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tobiaswuerth/orbit/actions/workflows/ci.yml)
42
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14%20%7C%203.15-blue?logo=python&logoColor=white)](https://github.com/tobiaswuerth/orbit/blob/main/pyproject.toml)
43
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/tobiaswuerth/orbit/actions/workflows/ci.yml)
44
+ [![License](https://img.shields.io/github/license/tobiaswuerth/orbit)](https://github.com/tobiaswuerth/orbit/blob/main/LICENSE)
45
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
46
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
47
+
48
+ orbit keeps a personal CRM as an Obsidian vault of Markdown notes: the people in your network, their organizations,
49
+ your interactions with them, the tasks that come out of those, and the topics that keep coming back. You read and
50
+ edit the notes in Obsidian or any editor; AI agents read and change them through orbit's MCP tools, which know what a
51
+ person, an organization or an interaction is, and keep the vault trustworthy:
52
+
53
+ - **One note per person, no duplicates.** Agents resolve before they write: `resolve_entity` finds candidates by email
54
+ address, name in any order or spelling (Müller, Mueller, "Müller, Hans", H. Müller), nickname, sound-alike surname
55
+ and company domain, each with its evidence. The agent decides; orbit refuses a "new" person whose address is known,
56
+ and unsure cases become questions you answer in Obsidian, where a duplicate decided `same` is merged.
57
+ - **No invented fields.** Every write is checked against the schema of its note type. Fields you add by hand are kept.
58
+ - **Every fact with its source.** Observations carry the words that state them and the interaction they come from;
59
+ field changes are dated, so an older email can't overwrite a newer fact.
60
+ - **The notes are the truth.** A SQLite index beside the vault makes lookups fast and guarantees uniqueness, but it is
61
+ derived from the notes alone: delete it, and orbit builds it again. Edits made in Obsidian are indexed within a
62
+ second, and on 50,000 interactions tools still answer in milliseconds ([benchmarks](docs/benchmarks.md)).
63
+ - **Upkeep that keeps links intact.** Merging two notes, renaming one, or creating the notes that links point to
64
+ rewrites every link that needs it, in front matter and text, as Obsidian does.
65
+
66
+ Everything runs locally: no LLM inside, no network access, no cloud.
67
+
68
+ ## Install
69
+
70
+ Requires Python 3.11 or newer. With [uv](https://docs.astral.sh/uv/):
71
+
72
+ ```bash
73
+ uv tool install git+https://github.com/tobiaswuerth/orbit # installs the `orbit` command
74
+ ```
75
+
76
+ Create a vault, with your own note, `Me.md`, and your addresses, so that agents never take you for a contact:
77
+
78
+ ```bash
79
+ orbit init ~/crm --name "Tobias" --email tobias@example.com
80
+ ```
81
+
82
+ Open the folder as a vault in Obsidian. `orbit init` also writes three [Bases](https://obsidian.md/help/bases) views
83
+ to `95_Bases/`, tables you can sort, filter and edit in place: **People** (everyone, by organization, not contacted
84
+ for a year, to confirm), **Follow-ups** (open tasks, tasks others owe you, people to get back to within two weeks)
85
+ and **Review** (questions waiting for your answer). Run `orbit init` on an existing vault to add what it lacks; it
86
+ never overwrites a file.
87
+
88
+ ## Setting up the MCP server
89
+
90
+ 1. **Start the server**, and leave it running:
91
+
92
+ ```bash
93
+ orbit serve ~/crm # prints: orbit MCP server on http://127.0.0.1:8775/mcp
94
+ ```
95
+
96
+ It brings the index up to date, then watches the vault, so edits made in Obsidian count at once. To have it
97
+ always there, start it at login with your system's scheduler (Task Scheduler, launchd, systemd).
98
+
99
+ 2. **Register it with your agent**, once. With Claude Code, for all your projects:
100
+
101
+ ```bash
102
+ claude mcp add --scope user --transport http orbit http://127.0.0.1:8775/mcp
103
+ ```
104
+
105
+ Keep `--scope user`: without it, the server is registered for the current folder only, and sessions started
106
+ anywhere else don't see it. Other MCP clients connect the same way, to the streamable HTTP endpoint
107
+ `http://127.0.0.1:8775/mcp`; a client configured with JSON typically takes
108
+ `{"mcpServers": {"orbit": {"type": "http", "url": "http://127.0.0.1:8775/mcp"}}}`.
109
+
110
+ 3. **Start a new agent session**: running sessions don't pick up new servers. `claude mcp list` should show orbit as
111
+ connected, and an agent asked who is in your CRM answers from it. Connected agents receive orbit's rules in the
112
+ server's instructions: resolve before writing, write only what a source states, and name the source.
113
+
114
+ One server serves any number of agents. Alternatively, a client can start orbit itself and speak to it over stdio:
115
+
116
+ ```bash
117
+ claude mcp add --scope user orbit -- orbit serve --stdio ~/crm
118
+ ```
119
+
120
+ But only one orbit process may open a vault at a time, so with stdio a second session's orbit is refused while the
121
+ first runs: use HTTP when several sessions work at once. Web connectors (in claude.ai, for example) can't use orbit:
122
+ they reach servers from the provider's cloud, which can't reach one on your computer.
123
+
124
+ ## Updating
125
+
126
+ ```bash
127
+ uv tool install --reinstall git+https://github.com/tobiaswuerth/orbit
128
+ orbit --version
129
+ ```
130
+
131
+ Then **restart `orbit serve`** (Ctrl+C, and start it again; a stdio server starts anew with each session), and
132
+ **start new agent sessions**: a client reads the tools and their parameters when a session starts, and keeps that
133
+ list, so a running session misses new tools and parameters. When a new version changes the index's layout, orbit
134
+ rebuilds the index from the notes on its first start, by itself. The [changelog](CHANGELOG.md) lists what changed.
135
+
136
+ ## The vault
137
+
138
+ ```
139
+ crm/
140
+ ├── 01_People/ Hans Müller.md, and Me.md for you
141
+ ├── 02_Organizations/ Acme AG.md
142
+ ├── 03_Interactions/
143
+ │ └── 2026/2026-10/ 2026-10-14 Email – AW Offerte Wartungsvertrag 2027.md
144
+ ├── 04_Tasks/
145
+ ├── 05_Topics/ projects, deals in progress, recurring themes
146
+ ├── 90_Review/ questions for you: possible duplicates, job changes
147
+ ├── 95_Bases/ Obsidian Bases views: People, Follow-ups, Review
148
+ └── 99_System/ orbit.toml, nicknames.txt, AGENTS.md
149
+ ```
150
+
151
+ A person, as orbit writes them:
152
+
153
+ ```markdown
154
+ ---
155
+ type: person
156
+ id: per_01M4WNN540CGMAHYXV1R90AA5V
157
+ name: Hans Müller
158
+ emails:
159
+ - hans.mueller@acme-ag.example
160
+ org: "[[Acme AG]]"
161
+ job_title: Leiter Einkauf
162
+ colleague_of:
163
+ - "[[Anna Keller]]"
164
+ last_contact: 2026-10-14
165
+ review_status: confirmed
166
+ created: 2026-10-14
167
+ updated: 2026-10-14
168
+ ---
169
+ ## Notes
170
+
171
+ ## Observations
172
+ - 2026-10-14 · Decides on the budget for the 2027 maintenance contract · «Das Budget für 2027 ist freigegeben.» · [[2026-10-14 Email – AW Offerte Wartungsvertrag 2027]] · medium
173
+
174
+ ## History
175
+ - 2026-10-14 · created: no candidate matched hans.mueller@acme-ag.example · [[2026-10-14 Email – AW Offerte Wartungsvertrag 2027]]
176
+ ```
177
+
178
+ The file name is the note's name and link target, unique across the vault; its identity is the `id`, which never
179
+ changes. Front matter is flat (`key: value` lines and block lists), so YAML parsers, Obsidian Bases and line-based
180
+ readers all read it the same way. `## Notes` is yours: orbit writes to it only to carry it over when two notes are
181
+ merged, and to keep its links pointing at renamed notes. An example vault with a note of every type is in [`tests/vaults/example`](tests/vaults/example);
182
+ the format is specified in [docs/vault-format.md](docs/vault-format.md).
183
+
184
+ ## Tools
185
+
186
+ | Tool | What it does |
187
+ |---|---|
188
+ | `resolve_entity` | Candidates for a person, organization or topic, ranked, with the evidence for each. |
189
+ | `get_entity_context` | A note's fields, observations, last field changes, links in and out, recent interactions, open tasks and pending reviews. |
190
+ | `search_entities` | Full-text and fuzzy search over names, aliases, addresses, domains, job titles and notes. |
191
+ | `list_entities` | Queries over front-matter fields: everyone at an organization, open tasks due before a date, people not contacted since. |
192
+ | `log_interaction` | An email, meeting, call or message, idempotent on its message id; updates `last_contact`. |
193
+ | `upsert_person`, `upsert_organization`, `upsert_topic` | Create a record, or add to one: lists merged, empty fields filled, conflicts reported. |
194
+ | `link_entities` | Set a relation from a fixed vocabulary: `org`, `reports_to`, `colleague_of` (both ways), an interaction's people, … |
195
+ | `link_document` | Record a document outside the vault: a URL, a path or an id. |
196
+ | `add_observation` | A fact with its evidence quote, source and confidence. |
197
+ | `set_fields` | Change fields with a dated history line; a change older than the stored value becomes an observation instead. |
198
+ | `create_task`, `update_task` | What you promised, what was asked of you, and what others promised you. |
199
+ | `propose_review` | A question for you, answered in Obsidian: a duplicate decided `same` is merged, one decided `different` is recorded on both notes. |
200
+ | `merge_entities` | Two notes about the same person, organization or topic become one; links follow, differing values go to review. |
201
+ | `rename_entity` | A new name and file name; every link to the note follows. |
202
+ | `create_missing_references` | Notes for links to notes that don't exist yet: mapped to an alias, or a stub of the type the link's field asks for. |
203
+ | `lint_vault` | Schema conformance, duplicate ids and addresses, clashing file names, broken links, unknown fields; `fix` repairs the safe cases. |
204
+
205
+ Every write names its `source`, returns the ids and absolute paths of the notes it changed, and does nothing when
206
+ called again with the same arguments, where it can. Errors say what to do next. Every parameter is described in the
207
+ tools' schemas, for the agents that read them.
208
+
209
+ ## Commands
210
+
211
+ | Command | What it does |
212
+ |---|---|
213
+ | `orbit init [VAULT]` | Create a vault, or add what one lacks: its folders, `Me.md`, `99_System` and the Bases views. |
214
+ | `orbit serve [VAULT]` | Serve the vault over MCP (`--stdio`, `--host`, `--port`), watching it for edits made elsewhere. |
215
+ | `orbit lint [VAULT]` | Check the vault (`--fix`, `--json`); exits with 1 if errors remain. |
216
+ | `orbit reindex [VAULT]` | Build the index again from the notes. |
217
+ | `orbit status [VAULT]` | Notes per type, pending reviews, clashes and the index, also while a server runs. |
218
+
219
+ `VAULT` defaults to the current folder. Results go to stdout; everything else orbit says goes to stderr.
220
+
221
+ ## Configuration
222
+
223
+ `99_System/orbit.toml`, written by `orbit init`:
224
+
225
+ | Setting | Default | |
226
+ |---|---|---|
227
+ | `me` | the id of `Me.md` | Your own note, which your addresses resolve to. |
228
+ | `server.host`, `server.port` | `127.0.0.1`, `8775` | Where `orbit serve` listens. It warns when told to listen beyond this machine. |
229
+ | `identity.free_mail_domains` | gmail.com, outlook.com, gmx.ch, bluewin.ch, … | Domains that say nothing about an employer. |
230
+ | `identity.role_addresses` | info, noreply, team, sales, support, … | Mailboxes that never belong to a person. |
231
+
232
+ `99_System/nicknames.txt` lists first names that stand for one another, one group per line: `Hans = Hansi =
233
+ Hansruedi`. The one environment variable, `ORBIT_HOME` (default `~/.orbit/`), says where the indexes live: outside
234
+ the vault, one database per vault, so sync tools never see a live database.
235
+
236
+ ## How it works
237
+
238
+ - **Reading and writing.** Anything may read the vault. Agents write through orbit's tools, which check the index,
239
+ read the note again (an edit made by hand may not be indexed yet), write it atomically (a temporary file, then a
240
+ rename), and update the index in the same call. Tools run one at a time, and one orbit process at a time may open a
241
+ vault, so nothing writes concurrently.
242
+ - **The index** holds ids, paths and front matter; addresses, domains, name keys and sound-alike codes; links both ways;
243
+ field changes from the History sections; and full-text search. Its unique indexes on ids, file names, people's
244
+ addresses and message ids make duplicates impossible through orbit; a hand edit that breaks one is recorded as a
245
+ clash, which `lint_vault` reports, instead of stopping the index.
246
+ - **Duplicates.** Names are compared without case, accents or word order (ä as ae, titles dropped), with initials,
247
+ nicknames and Kölner Phonetik; addresses exactly; organizations by domain, Swiss UID and name without legal form.
248
+ orbit ranks the candidates; the agent decides, or asks you through a review.
249
+
250
+ ## Scope
251
+
252
+ In scope: one owner's network, in Markdown files on their machine; any number of local agents; Linux, macOS and
253
+ Windows. Out of scope, by design: an LLM inside orbit, network access and cloud services, reading mailboxes or the
254
+ web (agents that do so use orbit's tools), authentication, and legal or compliance features.
255
+
256
+ ## Versioning
257
+
258
+ orbit is in early development (0.x): the vault format, the index, the tools and the Bases views work and are tested,
259
+ and minor versions may still change the tools and the format. From 1.0, it follows
260
+ [semantic versioning](https://semver.org/): within a major version, the vault format, the MCP tools and their
261
+ parameters, the commands and their JSON output, and `orbit.toml` stay compatible. The index is derived and rebuilt
262
+ when its layout changes, so it never needs migrating. The [changelog](CHANGELOG.md) lists every change.
263
+
264
+ ## Development
265
+
266
+ ```bash
267
+ git clone https://github.com/tobiaswuerth/orbit
268
+ cd orbit
269
+ uv sync # the virtual environment, with the development tools
270
+ uv run pre-commit install # lint, format and config checks on every commit
271
+ uv run pytest --cov # 100% line and branch coverage is required
272
+ ```
273
+
274
+ The tests use temporary vaults and a temporary `ORBIT_HOME`, and run the MCP server for real, in memory. See
275
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the code layout and how changes are made, and [SECURITY.md](SECURITY.md) for
276
+ reporting vulnerabilities.
277
+
278
+ ## License
279
+
280
+ [MIT](LICENSE)
@@ -0,0 +1,242 @@
1
+ # orbit: a personal CRM as Markdown notes, kept by an MCP server
2
+
3
+ [![CI](https://github.com/tobiaswuerth/orbit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tobiaswuerth/orbit/actions/workflows/ci.yml)
4
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14%20%7C%203.15-blue?logo=python&logoColor=white)](https://github.com/tobiaswuerth/orbit/blob/main/pyproject.toml)
5
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/tobiaswuerth/orbit/actions/workflows/ci.yml)
6
+ [![License](https://img.shields.io/github/license/tobiaswuerth/orbit)](https://github.com/tobiaswuerth/orbit/blob/main/LICENSE)
7
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
8
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
9
+
10
+ orbit keeps a personal CRM as an Obsidian vault of Markdown notes: the people in your network, their organizations,
11
+ your interactions with them, the tasks that come out of those, and the topics that keep coming back. You read and
12
+ edit the notes in Obsidian or any editor; AI agents read and change them through orbit's MCP tools, which know what a
13
+ person, an organization or an interaction is, and keep the vault trustworthy:
14
+
15
+ - **One note per person, no duplicates.** Agents resolve before they write: `resolve_entity` finds candidates by email
16
+ address, name in any order or spelling (Müller, Mueller, "Müller, Hans", H. Müller), nickname, sound-alike surname
17
+ and company domain, each with its evidence. The agent decides; orbit refuses a "new" person whose address is known,
18
+ and unsure cases become questions you answer in Obsidian, where a duplicate decided `same` is merged.
19
+ - **No invented fields.** Every write is checked against the schema of its note type. Fields you add by hand are kept.
20
+ - **Every fact with its source.** Observations carry the words that state them and the interaction they come from;
21
+ field changes are dated, so an older email can't overwrite a newer fact.
22
+ - **The notes are the truth.** A SQLite index beside the vault makes lookups fast and guarantees uniqueness, but it is
23
+ derived from the notes alone: delete it, and orbit builds it again. Edits made in Obsidian are indexed within a
24
+ second, and on 50,000 interactions tools still answer in milliseconds ([benchmarks](docs/benchmarks.md)).
25
+ - **Upkeep that keeps links intact.** Merging two notes, renaming one, or creating the notes that links point to
26
+ rewrites every link that needs it, in front matter and text, as Obsidian does.
27
+
28
+ Everything runs locally: no LLM inside, no network access, no cloud.
29
+
30
+ ## Install
31
+
32
+ Requires Python 3.11 or newer. With [uv](https://docs.astral.sh/uv/):
33
+
34
+ ```bash
35
+ uv tool install git+https://github.com/tobiaswuerth/orbit # installs the `orbit` command
36
+ ```
37
+
38
+ Create a vault, with your own note, `Me.md`, and your addresses, so that agents never take you for a contact:
39
+
40
+ ```bash
41
+ orbit init ~/crm --name "Tobias" --email tobias@example.com
42
+ ```
43
+
44
+ Open the folder as a vault in Obsidian. `orbit init` also writes three [Bases](https://obsidian.md/help/bases) views
45
+ to `95_Bases/`, tables you can sort, filter and edit in place: **People** (everyone, by organization, not contacted
46
+ for a year, to confirm), **Follow-ups** (open tasks, tasks others owe you, people to get back to within two weeks)
47
+ and **Review** (questions waiting for your answer). Run `orbit init` on an existing vault to add what it lacks; it
48
+ never overwrites a file.
49
+
50
+ ## Setting up the MCP server
51
+
52
+ 1. **Start the server**, and leave it running:
53
+
54
+ ```bash
55
+ orbit serve ~/crm # prints: orbit MCP server on http://127.0.0.1:8775/mcp
56
+ ```
57
+
58
+ It brings the index up to date, then watches the vault, so edits made in Obsidian count at once. To have it
59
+ always there, start it at login with your system's scheduler (Task Scheduler, launchd, systemd).
60
+
61
+ 2. **Register it with your agent**, once. With Claude Code, for all your projects:
62
+
63
+ ```bash
64
+ claude mcp add --scope user --transport http orbit http://127.0.0.1:8775/mcp
65
+ ```
66
+
67
+ Keep `--scope user`: without it, the server is registered for the current folder only, and sessions started
68
+ anywhere else don't see it. Other MCP clients connect the same way, to the streamable HTTP endpoint
69
+ `http://127.0.0.1:8775/mcp`; a client configured with JSON typically takes
70
+ `{"mcpServers": {"orbit": {"type": "http", "url": "http://127.0.0.1:8775/mcp"}}}`.
71
+
72
+ 3. **Start a new agent session**: running sessions don't pick up new servers. `claude mcp list` should show orbit as
73
+ connected, and an agent asked who is in your CRM answers from it. Connected agents receive orbit's rules in the
74
+ server's instructions: resolve before writing, write only what a source states, and name the source.
75
+
76
+ One server serves any number of agents. Alternatively, a client can start orbit itself and speak to it over stdio:
77
+
78
+ ```bash
79
+ claude mcp add --scope user orbit -- orbit serve --stdio ~/crm
80
+ ```
81
+
82
+ But only one orbit process may open a vault at a time, so with stdio a second session's orbit is refused while the
83
+ first runs: use HTTP when several sessions work at once. Web connectors (in claude.ai, for example) can't use orbit:
84
+ they reach servers from the provider's cloud, which can't reach one on your computer.
85
+
86
+ ## Updating
87
+
88
+ ```bash
89
+ uv tool install --reinstall git+https://github.com/tobiaswuerth/orbit
90
+ orbit --version
91
+ ```
92
+
93
+ Then **restart `orbit serve`** (Ctrl+C, and start it again; a stdio server starts anew with each session), and
94
+ **start new agent sessions**: a client reads the tools and their parameters when a session starts, and keeps that
95
+ list, so a running session misses new tools and parameters. When a new version changes the index's layout, orbit
96
+ rebuilds the index from the notes on its first start, by itself. The [changelog](CHANGELOG.md) lists what changed.
97
+
98
+ ## The vault
99
+
100
+ ```
101
+ crm/
102
+ ├── 01_People/ Hans Müller.md, and Me.md for you
103
+ ├── 02_Organizations/ Acme AG.md
104
+ ├── 03_Interactions/
105
+ │ └── 2026/2026-10/ 2026-10-14 Email – AW Offerte Wartungsvertrag 2027.md
106
+ ├── 04_Tasks/
107
+ ├── 05_Topics/ projects, deals in progress, recurring themes
108
+ ├── 90_Review/ questions for you: possible duplicates, job changes
109
+ ├── 95_Bases/ Obsidian Bases views: People, Follow-ups, Review
110
+ └── 99_System/ orbit.toml, nicknames.txt, AGENTS.md
111
+ ```
112
+
113
+ A person, as orbit writes them:
114
+
115
+ ```markdown
116
+ ---
117
+ type: person
118
+ id: per_01M4WNN540CGMAHYXV1R90AA5V
119
+ name: Hans Müller
120
+ emails:
121
+ - hans.mueller@acme-ag.example
122
+ org: "[[Acme AG]]"
123
+ job_title: Leiter Einkauf
124
+ colleague_of:
125
+ - "[[Anna Keller]]"
126
+ last_contact: 2026-10-14
127
+ review_status: confirmed
128
+ created: 2026-10-14
129
+ updated: 2026-10-14
130
+ ---
131
+ ## Notes
132
+
133
+ ## Observations
134
+ - 2026-10-14 · Decides on the budget for the 2027 maintenance contract · «Das Budget für 2027 ist freigegeben.» · [[2026-10-14 Email – AW Offerte Wartungsvertrag 2027]] · medium
135
+
136
+ ## History
137
+ - 2026-10-14 · created: no candidate matched hans.mueller@acme-ag.example · [[2026-10-14 Email – AW Offerte Wartungsvertrag 2027]]
138
+ ```
139
+
140
+ The file name is the note's name and link target, unique across the vault; its identity is the `id`, which never
141
+ changes. Front matter is flat (`key: value` lines and block lists), so YAML parsers, Obsidian Bases and line-based
142
+ readers all read it the same way. `## Notes` is yours: orbit writes to it only to carry it over when two notes are
143
+ merged, and to keep its links pointing at renamed notes. An example vault with a note of every type is in [`tests/vaults/example`](tests/vaults/example);
144
+ the format is specified in [docs/vault-format.md](docs/vault-format.md).
145
+
146
+ ## Tools
147
+
148
+ | Tool | What it does |
149
+ |---|---|
150
+ | `resolve_entity` | Candidates for a person, organization or topic, ranked, with the evidence for each. |
151
+ | `get_entity_context` | A note's fields, observations, last field changes, links in and out, recent interactions, open tasks and pending reviews. |
152
+ | `search_entities` | Full-text and fuzzy search over names, aliases, addresses, domains, job titles and notes. |
153
+ | `list_entities` | Queries over front-matter fields: everyone at an organization, open tasks due before a date, people not contacted since. |
154
+ | `log_interaction` | An email, meeting, call or message, idempotent on its message id; updates `last_contact`. |
155
+ | `upsert_person`, `upsert_organization`, `upsert_topic` | Create a record, or add to one: lists merged, empty fields filled, conflicts reported. |
156
+ | `link_entities` | Set a relation from a fixed vocabulary: `org`, `reports_to`, `colleague_of` (both ways), an interaction's people, … |
157
+ | `link_document` | Record a document outside the vault: a URL, a path or an id. |
158
+ | `add_observation` | A fact with its evidence quote, source and confidence. |
159
+ | `set_fields` | Change fields with a dated history line; a change older than the stored value becomes an observation instead. |
160
+ | `create_task`, `update_task` | What you promised, what was asked of you, and what others promised you. |
161
+ | `propose_review` | A question for you, answered in Obsidian: a duplicate decided `same` is merged, one decided `different` is recorded on both notes. |
162
+ | `merge_entities` | Two notes about the same person, organization or topic become one; links follow, differing values go to review. |
163
+ | `rename_entity` | A new name and file name; every link to the note follows. |
164
+ | `create_missing_references` | Notes for links to notes that don't exist yet: mapped to an alias, or a stub of the type the link's field asks for. |
165
+ | `lint_vault` | Schema conformance, duplicate ids and addresses, clashing file names, broken links, unknown fields; `fix` repairs the safe cases. |
166
+
167
+ Every write names its `source`, returns the ids and absolute paths of the notes it changed, and does nothing when
168
+ called again with the same arguments, where it can. Errors say what to do next. Every parameter is described in the
169
+ tools' schemas, for the agents that read them.
170
+
171
+ ## Commands
172
+
173
+ | Command | What it does |
174
+ |---|---|
175
+ | `orbit init [VAULT]` | Create a vault, or add what one lacks: its folders, `Me.md`, `99_System` and the Bases views. |
176
+ | `orbit serve [VAULT]` | Serve the vault over MCP (`--stdio`, `--host`, `--port`), watching it for edits made elsewhere. |
177
+ | `orbit lint [VAULT]` | Check the vault (`--fix`, `--json`); exits with 1 if errors remain. |
178
+ | `orbit reindex [VAULT]` | Build the index again from the notes. |
179
+ | `orbit status [VAULT]` | Notes per type, pending reviews, clashes and the index, also while a server runs. |
180
+
181
+ `VAULT` defaults to the current folder. Results go to stdout; everything else orbit says goes to stderr.
182
+
183
+ ## Configuration
184
+
185
+ `99_System/orbit.toml`, written by `orbit init`:
186
+
187
+ | Setting | Default | |
188
+ |---|---|---|
189
+ | `me` | the id of `Me.md` | Your own note, which your addresses resolve to. |
190
+ | `server.host`, `server.port` | `127.0.0.1`, `8775` | Where `orbit serve` listens. It warns when told to listen beyond this machine. |
191
+ | `identity.free_mail_domains` | gmail.com, outlook.com, gmx.ch, bluewin.ch, … | Domains that say nothing about an employer. |
192
+ | `identity.role_addresses` | info, noreply, team, sales, support, … | Mailboxes that never belong to a person. |
193
+
194
+ `99_System/nicknames.txt` lists first names that stand for one another, one group per line: `Hans = Hansi =
195
+ Hansruedi`. The one environment variable, `ORBIT_HOME` (default `~/.orbit/`), says where the indexes live: outside
196
+ the vault, one database per vault, so sync tools never see a live database.
197
+
198
+ ## How it works
199
+
200
+ - **Reading and writing.** Anything may read the vault. Agents write through orbit's tools, which check the index,
201
+ read the note again (an edit made by hand may not be indexed yet), write it atomically (a temporary file, then a
202
+ rename), and update the index in the same call. Tools run one at a time, and one orbit process at a time may open a
203
+ vault, so nothing writes concurrently.
204
+ - **The index** holds ids, paths and front matter; addresses, domains, name keys and sound-alike codes; links both ways;
205
+ field changes from the History sections; and full-text search. Its unique indexes on ids, file names, people's
206
+ addresses and message ids make duplicates impossible through orbit; a hand edit that breaks one is recorded as a
207
+ clash, which `lint_vault` reports, instead of stopping the index.
208
+ - **Duplicates.** Names are compared without case, accents or word order (ä as ae, titles dropped), with initials,
209
+ nicknames and Kölner Phonetik; addresses exactly; organizations by domain, Swiss UID and name without legal form.
210
+ orbit ranks the candidates; the agent decides, or asks you through a review.
211
+
212
+ ## Scope
213
+
214
+ In scope: one owner's network, in Markdown files on their machine; any number of local agents; Linux, macOS and
215
+ Windows. Out of scope, by design: an LLM inside orbit, network access and cloud services, reading mailboxes or the
216
+ web (agents that do so use orbit's tools), authentication, and legal or compliance features.
217
+
218
+ ## Versioning
219
+
220
+ orbit is in early development (0.x): the vault format, the index, the tools and the Bases views work and are tested,
221
+ and minor versions may still change the tools and the format. From 1.0, it follows
222
+ [semantic versioning](https://semver.org/): within a major version, the vault format, the MCP tools and their
223
+ parameters, the commands and their JSON output, and `orbit.toml` stay compatible. The index is derived and rebuilt
224
+ when its layout changes, so it never needs migrating. The [changelog](CHANGELOG.md) lists every change.
225
+
226
+ ## Development
227
+
228
+ ```bash
229
+ git clone https://github.com/tobiaswuerth/orbit
230
+ cd orbit
231
+ uv sync # the virtual environment, with the development tools
232
+ uv run pre-commit install # lint, format and config checks on every commit
233
+ uv run pytest --cov # 100% line and branch coverage is required
234
+ ```
235
+
236
+ The tests use temporary vaults and a temporary `ORBIT_HOME`, and run the MCP server for real, in memory. See
237
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the code layout and how changes are made, and [SECURITY.md](SECURITY.md) for
238
+ reporting vulnerabilities.
239
+
240
+ ## License
241
+
242
+ [MIT](LICENSE)
@@ -0,0 +1,127 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.6,<0.14"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "orbit-crm"
7
+ version = "0.1.0"
8
+ description = "A personal CRM as a vault of Markdown notes, kept by a local MCP server for AI agents: people, organizations, interactions, tasks and topics, with duplicate checks and a source for every fact."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ dependencies = [
14
+ "mcp>=2.0",
15
+ "watchfiles>=1.0",
16
+ "filelock>=3.16",
17
+ "uvicorn>=0.31",
18
+ "anyio>=4.9",
19
+ "pydantic>=2.12",
20
+ ]
21
+ keywords = [
22
+ "crm",
23
+ "personal-crm",
24
+ "mcp",
25
+ "mcp-server",
26
+ "model-context-protocol",
27
+ "obsidian",
28
+ "markdown",
29
+ "knowledge-base",
30
+ "entity-resolution",
31
+ "ai-agents",
32
+ "local-first",
33
+ "sqlite",
34
+ ]
35
+ classifiers = [
36
+ "Development Status :: 3 - Alpha",
37
+ "Environment :: Console",
38
+ "Intended Audience :: Developers",
39
+ "Intended Audience :: End Users/Desktop",
40
+ "Operating System :: MacOS",
41
+ "Operating System :: Microsoft :: Windows",
42
+ "Operating System :: POSIX :: Linux",
43
+ "Programming Language :: Python :: 3",
44
+ "Programming Language :: Python :: 3.11",
45
+ "Programming Language :: Python :: 3.12",
46
+ "Programming Language :: Python :: 3.13",
47
+ "Programming Language :: Python :: 3.14",
48
+ "Programming Language :: Python :: 3.15",
49
+ "Natural Language :: English",
50
+ "Topic :: Office/Business",
51
+ "Topic :: Text Processing :: Markup :: Markdown",
52
+ ]
53
+
54
+ [[project.authors]]
55
+ name = "Tobias Würth"
56
+
57
+ [project.urls]
58
+ Homepage = "https://github.com/tobiaswuerth/orbit"
59
+ Documentation = "https://github.com/tobiaswuerth/orbit#readme"
60
+ Source = "https://github.com/tobiaswuerth/orbit"
61
+ Issues = "https://github.com/tobiaswuerth/orbit/issues"
62
+ Changelog = "https://github.com/tobiaswuerth/orbit/blob/main/CHANGELOG.md"
63
+
64
+ [project.scripts]
65
+ orbit = "orbit.cli:main"
66
+ orbit-crm = "orbit.cli:main"
67
+
68
+ [dependency-groups]
69
+ dev = [
70
+ "pytest>=8",
71
+ "pytest-cov>=5",
72
+ "pytest-timeout>=2.3",
73
+ "pyyaml>=6",
74
+ "ruff>=0.15",
75
+ "pre-commit>=4",
76
+ ]
77
+
78
+ [tool.uv.build-backend]
79
+ module-name = "orbit"
80
+
81
+ [tool.ruff]
82
+ line-length = 120
83
+ target-version = "py311"
84
+
85
+ [tool.ruff.lint]
86
+ select = [
87
+ "E",
88
+ "F",
89
+ "W",
90
+ "I",
91
+ "B",
92
+ "UP",
93
+ "SIM",
94
+ "C4",
95
+ "PIE",
96
+ "PT",
97
+ "RUF",
98
+ "PERF",
99
+ ]
100
+ ignore = [
101
+ "B905",
102
+ "SIM905",
103
+ "RUF001",
104
+ "RUF002",
105
+ "RUF003",
106
+ ]
107
+
108
+ [tool.ruff.lint.flake8-pytest-style]
109
+ parametrize-names-type = "csv"
110
+
111
+ [tool.pytest.ini_options]
112
+ testpaths = ["tests"]
113
+ addopts = ["--strict-markers"]
114
+ filterwarnings = ["error"]
115
+ timeout = 60
116
+
117
+ [tool.coverage.run]
118
+ source = ["orbit"]
119
+ branch = true
120
+
121
+ [tool.coverage.report]
122
+ fail_under = 100
123
+ show_missing = true
124
+ skip_covered = true
125
+
126
+ [tool.codespell]
127
+ ignore-words-list = "ue,sie,ist,feld"