m365-brain 1.2.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 (155) hide show
  1. m365_brain-1.2.0/.gitignore +26 -0
  2. m365_brain-1.2.0/LICENSE +21 -0
  3. m365_brain-1.2.0/PKG-INFO +324 -0
  4. m365_brain-1.2.0/README.md +265 -0
  5. m365_brain-1.2.0/m365_brain/__init__.py +1 -0
  6. m365_brain-1.2.0/m365_brain/atomic_json.py +55 -0
  7. m365_brain-1.2.0/m365_brain/cli.py +290 -0
  8. m365_brain-1.2.0/m365_brain/commands/__init__.py +25 -0
  9. m365_brain-1.2.0/m365_brain/commands/_catalog.py +290 -0
  10. m365_brain-1.2.0/m365_brain/commands/_context.py +253 -0
  11. m365_brain-1.2.0/m365_brain/commands/auth.py +98 -0
  12. m365_brain-1.2.0/m365_brain/commands/config.py +58 -0
  13. m365_brain-1.2.0/m365_brain/commands/files.py +117 -0
  14. m365_brain-1.2.0/m365_brain/commands/index.py +249 -0
  15. m365_brain-1.2.0/m365_brain/commands/ops.py +185 -0
  16. m365_brain-1.2.0/m365_brain/commands/outbox.py +143 -0
  17. m365_brain-1.2.0/m365_brain/commands/teams.py +91 -0
  18. m365_brain-1.2.0/m365_brain/commands/vault.py +63 -0
  19. m365_brain-1.2.0/m365_brain/config/__init__.py +143 -0
  20. m365_brain-1.2.0/m365_brain/config/access.py +16 -0
  21. m365_brain-1.2.0/m365_brain/config/base.py +12 -0
  22. m365_brain-1.2.0/m365_brain/config/errors.py +5 -0
  23. m365_brain-1.2.0/m365_brain/config/extractors.py +169 -0
  24. m365_brain-1.2.0/m365_brain/config/index.py +181 -0
  25. m365_brain-1.2.0/m365_brain/config/loader.py +164 -0
  26. m365_brain-1.2.0/m365_brain/config/ops.py +159 -0
  27. m365_brain-1.2.0/m365_brain/config/outbox.py +76 -0
  28. m365_brain-1.2.0/m365_brain/config/runtime.py +81 -0
  29. m365_brain-1.2.0/m365_brain/config/schema.py +210 -0
  30. m365_brain-1.2.0/m365_brain/config/vault.py +70 -0
  31. m365_brain-1.2.0/m365_brain/cycle.py +288 -0
  32. m365_brain-1.2.0/m365_brain/dry_run.py +94 -0
  33. m365_brain-1.2.0/m365_brain/hooks.py +125 -0
  34. m365_brain-1.2.0/m365_brain/index/__init__.py +49 -0
  35. m365_brain-1.2.0/m365_brain/index/backends/__init__.py +29 -0
  36. m365_brain-1.2.0/m365_brain/index/backends/base.py +181 -0
  37. m365_brain-1.2.0/m365_brain/index/backends/filters.py +111 -0
  38. m365_brain-1.2.0/m365_brain/index/backends/memory.py +299 -0
  39. m365_brain-1.2.0/m365_brain/index/backends/sqlite.py +182 -0
  40. m365_brain-1.2.0/m365_brain/index/backends/sqlite_catalog.py +176 -0
  41. m365_brain-1.2.0/m365_brain/index/backends/sqlite_read.py +238 -0
  42. m365_brain-1.2.0/m365_brain/index/backends/sqlite_schema.py +148 -0
  43. m365_brain-1.2.0/m365_brain/index/backends/sqlite_write.py +147 -0
  44. m365_brain-1.2.0/m365_brain/index/catalog.py +93 -0
  45. m365_brain-1.2.0/m365_brain/index/catalog_extract.py +138 -0
  46. m365_brain-1.2.0/m365_brain/index/catalog_storage.py +109 -0
  47. m365_brain-1.2.0/m365_brain/index/fts_query.py +243 -0
  48. m365_brain-1.2.0/m365_brain/index/fusion.py +59 -0
  49. m365_brain-1.2.0/m365_brain/index/graph.py +84 -0
  50. m365_brain-1.2.0/m365_brain/index/query.py +109 -0
  51. m365_brain-1.2.0/m365_brain/index/search.py +213 -0
  52. m365_brain-1.2.0/m365_brain/index/sync.py +174 -0
  53. m365_brain-1.2.0/m365_brain/index/vector/__init__.py +68 -0
  54. m365_brain-1.2.0/m365_brain/index/vector/base.py +91 -0
  55. m365_brain-1.2.0/m365_brain/index/vector/chunking.py +112 -0
  56. m365_brain-1.2.0/m365_brain/index/vector/fastembed_provider.py +64 -0
  57. m365_brain-1.2.0/m365_brain/index/vector/memory.py +114 -0
  58. m365_brain-1.2.0/m365_brain/index/vector/sqlite_vec_store.py +210 -0
  59. m365_brain-1.2.0/m365_brain/index/vector/sync.py +85 -0
  60. m365_brain-1.2.0/m365_brain/index_step.py +72 -0
  61. m365_brain-1.2.0/m365_brain/logging_config.py +77 -0
  62. m365_brain-1.2.0/m365_brain/m365/__init__.py +1 -0
  63. m365_brain-1.2.0/m365_brain/m365/auth/__init__.py +1 -0
  64. m365_brain-1.2.0/m365_brain/m365/auth/auth_code.py +67 -0
  65. m365_brain-1.2.0/m365_brain/m365/auth/device_code.py +108 -0
  66. m365_brain-1.2.0/m365_brain/m365/auth/profiles.py +154 -0
  67. m365_brain-1.2.0/m365_brain/m365/auth/token_provider.py +78 -0
  68. m365_brain-1.2.0/m365_brain/m365/client.py +300 -0
  69. m365_brain-1.2.0/m365_brain/m365/converters/__init__.py +1 -0
  70. m365_brain-1.2.0/m365_brain/m365/converters/document.py +35 -0
  71. m365_brain-1.2.0/m365_brain/m365/converters/html_to_md.py +24 -0
  72. m365_brain-1.2.0/m365_brain/m365/errors.py +42 -0
  73. m365_brain-1.2.0/m365_brain/m365/extractors/__init__.py +1 -0
  74. m365_brain-1.2.0/m365_brain/m365/extractors/_attachment_helpers.py +123 -0
  75. m365_brain-1.2.0/m365_brain/m365/extractors/_file_helpers.py +274 -0
  76. m365_brain-1.2.0/m365_brain/m365/extractors/_folder_helpers.py +160 -0
  77. m365_brain-1.2.0/m365_brain/m365/extractors/_message_helpers.py +70 -0
  78. m365_brain-1.2.0/m365_brain/m365/extractors/_message_renderer.py +131 -0
  79. m365_brain-1.2.0/m365_brain/m365/extractors/_message_store.py +107 -0
  80. m365_brain-1.2.0/m365_brain/m365/extractors/_teams_attachment_helpers.py +268 -0
  81. m365_brain-1.2.0/m365_brain/m365/extractors/_teams_channel_ingest.py +97 -0
  82. m365_brain-1.2.0/m365_brain/m365/extractors/_teams_channel_targets.py +51 -0
  83. m365_brain-1.2.0/m365_brain/m365/extractors/_teams_context.py +33 -0
  84. m365_brain-1.2.0/m365_brain/m365/extractors/_teams_hosted_content.py +113 -0
  85. m365_brain-1.2.0/m365_brain/m365/extractors/_teams_ingest.py +101 -0
  86. m365_brain-1.2.0/m365_brain/m365/extractors/base.py +53 -0
  87. m365_brain-1.2.0/m365_brain/m365/extractors/calendar.py +217 -0
  88. m365_brain-1.2.0/m365_brain/m365/extractors/contacts.py +240 -0
  89. m365_brain-1.2.0/m365_brain/m365/extractors/directory.py +219 -0
  90. m365_brain-1.2.0/m365_brain/m365/extractors/email.py +300 -0
  91. m365_brain-1.2.0/m365_brain/m365/extractors/errors.py +9 -0
  92. m365_brain-1.2.0/m365_brain/m365/extractors/onedrive.py +93 -0
  93. m365_brain-1.2.0/m365_brain/m365/extractors/sharepoint.py +176 -0
  94. m365_brain-1.2.0/m365_brain/m365/extractors/teams_channels.py +282 -0
  95. m365_brain-1.2.0/m365_brain/m365/extractors/teams_chats.py +276 -0
  96. m365_brain-1.2.0/m365_brain/m365/files.py +253 -0
  97. m365_brain-1.2.0/m365_brain/m365/frontmatter/__init__.py +71 -0
  98. m365_brain-1.2.0/m365_brain/m365/frontmatter/_tags.py +27 -0
  99. m365_brain-1.2.0/m365_brain/m365/frontmatter/calendar.py +101 -0
  100. m365_brain-1.2.0/m365_brain/m365/frontmatter/email.py +83 -0
  101. m365_brain-1.2.0/m365_brain/m365/frontmatter/files.py +120 -0
  102. m365_brain-1.2.0/m365_brain/m365/frontmatter/people.py +174 -0
  103. m365_brain-1.2.0/m365_brain/m365/frontmatter/teams.py +123 -0
  104. m365_brain-1.2.0/m365_brain/m365/graph_helpers.py +154 -0
  105. m365_brain-1.2.0/m365_brain/m365/markdown_writer.py +51 -0
  106. m365_brain-1.2.0/m365_brain/m365/outboxes/__init__.py +79 -0
  107. m365_brain-1.2.0/m365_brain/m365/outboxes/attachments.py +96 -0
  108. m365_brain-1.2.0/m365_brain/m365/outboxes/email.py +197 -0
  109. m365_brain-1.2.0/m365_brain/m365/outboxes/files.py +65 -0
  110. m365_brain-1.2.0/m365_brain/m365/outboxes/messages.py +183 -0
  111. m365_brain-1.2.0/m365_brain/m365/outboxes/rendering.py +83 -0
  112. m365_brain-1.2.0/m365_brain/m365/outboxes/teams.py +57 -0
  113. m365_brain-1.2.0/m365_brain/m365/pagination.py +125 -0
  114. m365_brain-1.2.0/m365_brain/m365/upload.py +69 -0
  115. m365_brain-1.2.0/m365_brain/manifest.py +285 -0
  116. m365_brain-1.2.0/m365_brain/model.py +231 -0
  117. m365_brain-1.2.0/m365_brain/models.py +57 -0
  118. m365_brain-1.2.0/m365_brain/ops/__init__.py +47 -0
  119. m365_brain-1.2.0/m365_brain/ops/links.py +146 -0
  120. m365_brain-1.2.0/m365_brain/ops/names.py +83 -0
  121. m365_brain-1.2.0/m365_brain/ops/tiers.py +200 -0
  122. m365_brain-1.2.0/m365_brain/ops/triage.py +213 -0
  123. m365_brain-1.2.0/m365_brain/outbox/__init__.py +49 -0
  124. m365_brain-1.2.0/m365_brain/outbox/authority.py +94 -0
  125. m365_brain-1.2.0/m365_brain/outbox/filesystem_store.py +129 -0
  126. m365_brain-1.2.0/m365_brain/outbox/reconcile.py +161 -0
  127. m365_brain-1.2.0/m365_brain/outbox/registry.py +137 -0
  128. m365_brain-1.2.0/m365_brain/outbox/runner.py +220 -0
  129. m365_brain-1.2.0/m365_brain/outbox/stores.py +169 -0
  130. m365_brain-1.2.0/m365_brain/parsers/__init__.py +26 -0
  131. m365_brain-1.2.0/m365_brain/parsers/document.py +148 -0
  132. m365_brain-1.2.0/m365_brain/parsers/frontmatter.py +82 -0
  133. m365_brain-1.2.0/m365_brain/parsers/observations.py +102 -0
  134. m365_brain-1.2.0/m365_brain/parsers/relations.py +97 -0
  135. m365_brain-1.2.0/m365_brain/parsers/text.py +43 -0
  136. m365_brain-1.2.0/m365_brain/schedule.py +145 -0
  137. m365_brain-1.2.0/m365_brain/state.py +139 -0
  138. m365_brain-1.2.0/m365_brain/storage/__init__.py +152 -0
  139. m365_brain-1.2.0/m365_brain/storage/azure_blob.py +88 -0
  140. m365_brain-1.2.0/m365_brain/storage/base.py +34 -0
  141. m365_brain-1.2.0/m365_brain/storage/exceptions.py +11 -0
  142. m365_brain-1.2.0/m365_brain/storage/local.py +65 -0
  143. m365_brain-1.2.0/m365_brain/sync.py +138 -0
  144. m365_brain-1.2.0/m365_brain/templates/m365-brain.yaml +431 -0
  145. m365_brain-1.2.0/m365_brain/validation.py +22 -0
  146. m365_brain-1.2.0/m365_brain/vault/__init__.py +6 -0
  147. m365_brain-1.2.0/m365_brain/vault/classify.py +87 -0
  148. m365_brain-1.2.0/m365_brain/vault/dispatch.py +124 -0
  149. m365_brain-1.2.0/m365_brain/vault/intent.py +126 -0
  150. m365_brain-1.2.0/m365_brain/vault/paths.py +213 -0
  151. m365_brain-1.2.0/m365_brain/vault/payloads.py +157 -0
  152. m365_brain-1.2.0/m365_brain/vault/removal.py +107 -0
  153. m365_brain-1.2.0/m365_brain/worker.py +289 -0
  154. m365_brain-1.2.0/m365_brain/workspace.py +138 -0
  155. m365_brain-1.2.0/pyproject.toml +97 -0
@@ -0,0 +1,26 @@
1
+ .states
2
+ *.py[cod]
3
+ .web
4
+ .pixi/
5
+ .hypothesis/
6
+ __pycache__/
7
+ *.pyc
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .coverage
14
+ site/
15
+ state/
16
+ # the runtime vault at the repo root only -- anchored so it cannot swallow
17
+ # m365_brain/vault/ (the path resolver) or tests/unit/vault/
18
+ /vault/
19
+ *.db
20
+ .env
21
+ .env.*
22
+ !.env.example
23
+ .web/
24
+ .states/
25
+ assets/external/
26
+ .claude/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthias Christenson
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,324 @@
1
+ Metadata-Version: 2.5
2
+ Name: m365-brain
3
+ Version: 1.2.0
4
+ Summary: Microsoft 365 data extraction to Obsidian-compatible markdown via Graph API
5
+ Project-URL: Documentation, https://neuralsignal.github.io/m365-brain/
6
+ Project-URL: Repository, https://github.com/neuralsignal/m365-brain
7
+ Author: Matthias Christenson
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: calendar,email,extraction,graph-api,markdown,microsoft-365,obsidian,onedrive,sharepoint,sync,teams
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications :: Email
17
+ Classifier: Topic :: Office/Business
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: beautifulsoup4<5,>=4.12
20
+ Requires-Dist: click<9,>=8.3.3
21
+ Requires-Dist: cryptography<51,>=50.0.0
22
+ Requires-Dist: email-validator<3,>=2.2
23
+ Requires-Dist: httpx<1,>=0.28
24
+ Requires-Dist: markdown<4,>=3.7
25
+ Requires-Dist: markdownify<1,>=0.14
26
+ Requires-Dist: msal<2,>=1.34
27
+ Requires-Dist: pydantic<3,>=2.12
28
+ Requires-Dist: pyopenssl<27,>=26.0
29
+ Requires-Dist: python-dotenv<2,>=1.0
30
+ Requires-Dist: python-frontmatter<2,>=1.1
31
+ Requires-Dist: pyyaml<7,>=6.0
32
+ Requires-Dist: structlog<26,>=25.0
33
+ Provides-Extra: admin
34
+ Requires-Dist: psycopg2-binary<3,>=2.9; extra == 'admin'
35
+ Requires-Dist: reflex<0.9,>=0.8.28; extra == 'admin'
36
+ Requires-Dist: sqlmodel<1,>=0.0.22; extra == 'admin'
37
+ Provides-Extra: all
38
+ Requires-Dist: azure-storage-blob<13,>=12.24; extra == 'all'
39
+ Requires-Dist: fastembed<0.9,>=0.8; extra == 'all'
40
+ Requires-Dist: obsidian-import[docling,markitdown]<2,>=1.2.0; extra == 'all'
41
+ Requires-Dist: psycopg2-binary<3,>=2.9; extra == 'all'
42
+ Requires-Dist: reflex<0.9,>=0.8.28; extra == 'all'
43
+ Requires-Dist: sqlite-vec<0.2,>=0.1.6; extra == 'all'
44
+ Requires-Dist: sqlmodel<1,>=0.0.22; extra == 'all'
45
+ Provides-Extra: azure
46
+ Requires-Dist: azure-storage-blob<13,>=12.24; extra == 'azure'
47
+ Provides-Extra: convert
48
+ Requires-Dist: obsidian-import[docling,markitdown]<2,>=1.2.0; extra == 'convert'
49
+ Provides-Extra: dev
50
+ Requires-Dist: hypothesis<7,>=6.100; extra == 'dev'
51
+ Requires-Dist: pytest-cov<6,>=5.0; extra == 'dev'
52
+ Requires-Dist: pytest-httpx<1,>=0.35; extra == 'dev'
53
+ Requires-Dist: pytest<10,>=9.0.3; extra == 'dev'
54
+ Requires-Dist: respx<1,>=0.22; extra == 'dev'
55
+ Provides-Extra: vector
56
+ Requires-Dist: fastembed<0.9,>=0.8; extra == 'vector'
57
+ Requires-Dist: sqlite-vec<0.2,>=0.1.6; extra == 'vector'
58
+ Description-Content-Type: text/markdown
59
+
60
+ # m365-brain
61
+
62
+ [![CI](https://github.com/neuralsignal/m365-brain/actions/workflows/ci.yml/badge.svg)](https://github.com/neuralsignal/m365-brain/actions/workflows/ci.yml)
63
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
64
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
65
+
66
+ Sync Microsoft 365 data to Obsidian-compatible markdown via the Graph API.
67
+
68
+ ## Features
69
+
70
+ - **8 extractors**: Email, Calendar, Teams Chats, Teams Channels, OneDrive, SharePoint, Contacts, Directory
71
+ - **Delta sync** with pagination, exponential backoff retry, and rate limiting
72
+ - **2 storage backends**: local filesystem and Azure Blob Storage
73
+ - **Document conversion** via [obsidian-import](https://pypi.org/project/obsidian-import/) (PDF, DOCX, PPTX, XLSX to markdown)
74
+ - **MSAL device code authentication** with persistent token caching, and named auth profiles so several Entra apps coexist
75
+ - **Markdown index** — FTS5 full-text, vector and hybrid search, entity/relation traversal, and a catalog of the binary files it found
76
+ - **Write-back outbox** — typed intents gated by a per-outbox authority, dispatched and then reconciled against what Graph actually did
77
+ - **Strict Pydantic config** with no defaults, and environment variable expansion
78
+ - **CLI**: `init`, `auth login`, `run`, `extract`, `index`, `outbox`, `files`, `teams`, `vault`, `ops`, `status`
79
+ - **Bicep IaC** for Azure Storage (dev/prod parameter files)
80
+ - **Docker** + Docker Compose with Azurite profile for local development
81
+
82
+ ## Installation
83
+
84
+ ```bash
85
+ pip install m365-brain
86
+ ```
87
+
88
+ Optional extras:
89
+
90
+ ```bash
91
+ pip install m365-brain[azure] # Azure Blob Storage backend
92
+ pip install m365-brain[convert] # Document conversion (obsidian-import)
93
+ pip install m365-brain[admin] # Reflex admin dashboard
94
+ pip install m365-brain[all] # Everything
95
+ ```
96
+
97
+ ## Quick Start
98
+
99
+ ### Write a config file and create the vault
100
+
101
+ ```bash
102
+ m365-brain init config.yaml --vault ./vault
103
+ ```
104
+
105
+ `init` writes the complete, commented configuration file and creates the vault directories. It refuses to overwrite an existing file. Every path it writes is absolute.
106
+
107
+ ### Authenticate
108
+
109
+ ```bash
110
+ m365-brain --config config.yaml auth login --profile mail
111
+ m365-brain --config config.yaml auth status --json
112
+ ```
113
+
114
+ `--profile` names one of `auth.profiles` in the config; the shipped template defines `mail`, `chat` and `files`. Login opens a device code flow in your browser, and each profile caches its own token at the `token_cache_path` it names.
115
+
116
+ ### Run one cycle
117
+
118
+ ```bash
119
+ m365-brain --config config.yaml run --once
120
+ ```
121
+
122
+ A cycle is extract → index → post-cycle hooks. `--once` runs every enabled unit whether or not its `poll_interval_minutes` says it is due; without it, `run` loops and honours the schedule.
123
+
124
+ ### Run continuously
125
+
126
+ ```bash
127
+ m365-brain --config config.yaml run
128
+ ```
129
+
130
+ Each unit runs on its own `poll_interval_minutes`; the loop wakes every `service.continuous_poll_seconds`.
131
+
132
+ ### Filter to some units
133
+
134
+ ```bash
135
+ m365-brain --config config.yaml run --once --only email,calendar
136
+ ```
137
+
138
+ ### Search what was synced
139
+
140
+ ```bash
141
+ m365-brain --config config.yaml index search "quarterly review" --json
142
+ m365-brain --config config.yaml index recent --timeframe 7d --json
143
+ ```
144
+
145
+ Results go to **stdout**, logs to **stderr**, so `--json` output parses without being separated from log noise first. Any verb taking a `--limit` reports `total`, `returned` and `limit`, so a truncated answer is visible as one.
146
+
147
+ ## Configuration
148
+
149
+ All configuration lives in one YAML file — or several, comma-separated and deep-merged left to right. Environment variables are expanded at load time using `${VAR_NAME}` syntax, and **a missing variable raises** rather than expanding to an empty string.
150
+
151
+ `m365-brain init` writes the reference configuration, whose comments *are* the documentation for every key. It is packaged at `m365_brain/templates/m365-brain.yaml`; the `config/` directory in this repo holds the split fragments the Docker images merge. Rather than restate it here — a copy that rots the first time a key moves — read the file `init` produced:
152
+
153
+ ```bash
154
+ m365-brain --config config.yaml config validate
155
+ m365-brain --config config.yaml config show --json
156
+ ```
157
+
158
+ `config validate` also resolves the configured hooks, which makes it a preflight rather than a syntax check. `config show` prints the effective merged config with secrets redacted.
159
+
160
+ Every section is strict: an unknown key is rejected, and no field anywhere has a default. A value the package needs is a value the config states.
161
+
162
+ ### Environment variables
163
+
164
+ The config loader expands `${VAR_NAME}` references at load time. Required variables:
165
+
166
+ | Variable | Purpose |
167
+ |----------|---------|
168
+ | `MSAL_CLIENT_ID` | Azure AD app registration client ID |
169
+ | `MSAL_TENANT_ID` | Azure AD tenant ID |
170
+ | `AZURE_STORAGE_CONNECTION_STRING` | Connection string (Azure Blob backend only) |
171
+ | `AZURE_STORAGE_CONTAINER` | Container name (Azure Blob backend only) |
172
+ | `AZURE_STORAGE_PREFIX` | Blob prefix / virtual directory (Azure Blob backend only) |
173
+
174
+ ## Azure Blob Storage
175
+
176
+ To use Azure Blob Storage instead of local filesystem, set `storage.backend: "azure_blob"` in your config. See `config/storage/azure_blob.yaml` for a complete example:
177
+
178
+ ```yaml
179
+ storage:
180
+ backend: "azure_blob"
181
+ azure_blob:
182
+ connection_string: "${AZURE_STORAGE_CONNECTION_STRING}"
183
+ container_name: "${AZURE_STORAGE_CONTAINER}"
184
+ prefix: "${AZURE_STORAGE_PREFIX}"
185
+ ```
186
+
187
+ ### Azurite (local development)
188
+
189
+ Start the Azurite emulator for local blob storage testing:
190
+
191
+ ```bash
192
+ docker compose --profile azurite up -d
193
+ ```
194
+
195
+ Then set the connection string to Azurite's default:
196
+
197
+ ```bash
198
+ export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
199
+ export AZURE_STORAGE_CONTAINER="m365-vaults"
200
+ export AZURE_STORAGE_PREFIX="dev"
201
+ ```
202
+
203
+ ## Infrastructure
204
+
205
+ Bicep templates in `infra/` provision an Azure Storage account with a private blob container. Parameter files for dev and prod are included.
206
+
207
+ ### Deploy
208
+
209
+ ```bash
210
+ # Dev
211
+ az deployment group create \
212
+ --resource-group rg-m365extract-dev \
213
+ --template-file infra/main.bicep \
214
+ --parameters infra/params.dev.bicepparam
215
+
216
+ # Prod
217
+ az deployment group create \
218
+ --resource-group rg-m365extract-prod \
219
+ --template-file infra/main.bicep \
220
+ --parameters infra/params.prod.bicepparam
221
+ ```
222
+
223
+ The template creates:
224
+
225
+ - Storage account (`stm365ext{environment}`) in Switzerland North
226
+ - TLS 1.2 minimum, HTTPS only, no public blob access
227
+ - A single blob container (`m365-vaults` by default)
228
+
229
+ ## Docker
230
+
231
+ ### Full stack (local dev)
232
+
233
+ ```bash
234
+ docker compose up --build # web + postgres (daemon runs inside web)
235
+ docker compose --profile azurite up # include Azurite blob emulator
236
+ ```
237
+
238
+ ## Development
239
+
240
+ ```bash
241
+ git clone https://github.com/neuralsignal/m365-brain.git
242
+ cd m365-brain
243
+ pixi install
244
+ pixi run test # unit tests (excludes integration + azurite markers)
245
+ pixi run test-cov # unit tests with coverage
246
+ pixi run test-azurite # tests requiring Azurite emulator
247
+ pixi run lint # ruff check
248
+ pixi run format # ruff format
249
+ pixi run pre-commit-install # install git hooks
250
+ pixi run docs-serve # local MkDocs dev server
251
+ ```
252
+
253
+ ### Project structure
254
+
255
+ ```
256
+ m365-brain/
257
+ m365_brain/
258
+ config/ # Strict Pydantic config: loading, merge, env expansion
259
+ model.py # Entity / Observation / Relation and the query types
260
+ parsers/ # Markdown and frontmatter into the model
261
+ storage/ # StorageBackend protocol, local filesystem, Azure Blob
262
+ state.py # StateStore protocol; delta tokens, cursors, cycle history
263
+ vault/ # Every path in the vault, plus the intent envelope
264
+ outbox/ # Vendor-agnostic write-back: authorities, runner, reconcile
265
+ index/ # The knowledge half -- backends, search, vectors, catalog
266
+ m365/ # The Microsoft half -- Graph client, auth, extractors, outboxes
267
+ cycle.py # One cycle: extract, index, hooks
268
+ cli.py # Click CLI -- the whole operating surface
269
+ commands/ # One module per command group
270
+ workspace.py # The library facade: a config path in, a working handle out
271
+ m365_admin/ # Reflex admin dashboard (optional extra)
272
+ skills/ # Bundled agent skills, thin wrappers over the CLI
273
+ config/ # Config fragments the Docker images merge
274
+ tests/ # pytest + hypothesis, mirroring the source layout
275
+ infra/ # Bicep IaC for Azure Storage
276
+ ```
277
+
278
+ `index/` never imports `m365/` and the two are peers by construction, so the knowledge layer works end to end on ordinary markdown with no Microsoft 365 present. That rule, the allowed directory list, the 300-line module cap, and the test-presence map are enforced by `scripts/check_structure.py` rather than by review.
279
+
280
+ ## Architecture
281
+
282
+ ```mermaid
283
+ graph LR
284
+ A[MSAL Auth] --> B[Graph Client]
285
+ B --> C[Extractors]
286
+ C --> D{Convert?}
287
+ D -->|yes| E[obsidian-import]
288
+ D -->|no| F[Markdown Writer]
289
+ E --> G[Storage Backend]
290
+ F --> G
291
+ G --> H[Local FS]
292
+ G --> I[Azure Blob]
293
+ ```
294
+
295
+ **Graph Client** (`m365_brain/m365/client.py`) wraps `httpx` with automatic token refresh, exponential backoff on 429/5xx, and paginated response iteration. Each **extractor** under `m365_brain/m365/extractors/` calls Graph endpoints for its data source, renders markdown with YAML frontmatter through the builders in `m365_brain/m365/frontmatter/`, and persists through the **Storage Backend** interface. OneDrive and SharePoint extractors optionally route binary files through **obsidian-import** for document-to-markdown conversion.
296
+
297
+ **Sync state** tracks delta links and timestamps per unit through the `StateStore` protocol, written as JSON under the vault's meta directory. It is bookkeeping, not data: deleting it forces a full re-pull, never a data loss.
298
+
299
+ ## Graph API Scopes
300
+
301
+ | Scope | Used by |
302
+ |-------|---------|
303
+ | `User.Read` | Token validation |
304
+ | `Mail.Read` | Email extractor |
305
+ | `Calendars.Read` | Calendar extractor |
306
+ | `Chat.Read` | Teams chats extractor |
307
+ | `ChannelMessage.Read.All` | Teams channels extractor |
308
+ | `Team.ReadBasic.All` + `Channel.ReadBasic.All` | Teams channels extractor — discovery mode only (`channels: null`); not needed with an explicit `channels` list |
309
+ | `Files.Read.All` | OneDrive + SharePoint extractors |
310
+ | `Sites.Read.All` | SharePoint extractor |
311
+ | `Contacts.Read` | Contacts extractor |
312
+ | `User.Read.All` | Directory extractor |
313
+ | `Directory.Read.All` | Directory extractor (manager chain, direct reports) |
314
+ | `offline_access` | Persistent token refresh |
315
+
316
+ All scopes use delegated (user) permissions via the device code flow. No application-level permissions are required.
317
+
318
+ ## Releases
319
+
320
+ This project uses [Release Please](https://github.com/googleapis/release-please) for automated versioning and changelog generation. Commits following [Conventional Commits](https://www.conventionalcommits.org/) are parsed to determine version bumps.
321
+
322
+ ## License
323
+
324
+ [MIT](LICENSE)
@@ -0,0 +1,265 @@
1
+ # m365-brain
2
+
3
+ [![CI](https://github.com/neuralsignal/m365-brain/actions/workflows/ci.yml/badge.svg)](https://github.com/neuralsignal/m365-brain/actions/workflows/ci.yml)
4
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Sync Microsoft 365 data to Obsidian-compatible markdown via the Graph API.
8
+
9
+ ## Features
10
+
11
+ - **8 extractors**: Email, Calendar, Teams Chats, Teams Channels, OneDrive, SharePoint, Contacts, Directory
12
+ - **Delta sync** with pagination, exponential backoff retry, and rate limiting
13
+ - **2 storage backends**: local filesystem and Azure Blob Storage
14
+ - **Document conversion** via [obsidian-import](https://pypi.org/project/obsidian-import/) (PDF, DOCX, PPTX, XLSX to markdown)
15
+ - **MSAL device code authentication** with persistent token caching, and named auth profiles so several Entra apps coexist
16
+ - **Markdown index** — FTS5 full-text, vector and hybrid search, entity/relation traversal, and a catalog of the binary files it found
17
+ - **Write-back outbox** — typed intents gated by a per-outbox authority, dispatched and then reconciled against what Graph actually did
18
+ - **Strict Pydantic config** with no defaults, and environment variable expansion
19
+ - **CLI**: `init`, `auth login`, `run`, `extract`, `index`, `outbox`, `files`, `teams`, `vault`, `ops`, `status`
20
+ - **Bicep IaC** for Azure Storage (dev/prod parameter files)
21
+ - **Docker** + Docker Compose with Azurite profile for local development
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install m365-brain
27
+ ```
28
+
29
+ Optional extras:
30
+
31
+ ```bash
32
+ pip install m365-brain[azure] # Azure Blob Storage backend
33
+ pip install m365-brain[convert] # Document conversion (obsidian-import)
34
+ pip install m365-brain[admin] # Reflex admin dashboard
35
+ pip install m365-brain[all] # Everything
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ### Write a config file and create the vault
41
+
42
+ ```bash
43
+ m365-brain init config.yaml --vault ./vault
44
+ ```
45
+
46
+ `init` writes the complete, commented configuration file and creates the vault directories. It refuses to overwrite an existing file. Every path it writes is absolute.
47
+
48
+ ### Authenticate
49
+
50
+ ```bash
51
+ m365-brain --config config.yaml auth login --profile mail
52
+ m365-brain --config config.yaml auth status --json
53
+ ```
54
+
55
+ `--profile` names one of `auth.profiles` in the config; the shipped template defines `mail`, `chat` and `files`. Login opens a device code flow in your browser, and each profile caches its own token at the `token_cache_path` it names.
56
+
57
+ ### Run one cycle
58
+
59
+ ```bash
60
+ m365-brain --config config.yaml run --once
61
+ ```
62
+
63
+ A cycle is extract → index → post-cycle hooks. `--once` runs every enabled unit whether or not its `poll_interval_minutes` says it is due; without it, `run` loops and honours the schedule.
64
+
65
+ ### Run continuously
66
+
67
+ ```bash
68
+ m365-brain --config config.yaml run
69
+ ```
70
+
71
+ Each unit runs on its own `poll_interval_minutes`; the loop wakes every `service.continuous_poll_seconds`.
72
+
73
+ ### Filter to some units
74
+
75
+ ```bash
76
+ m365-brain --config config.yaml run --once --only email,calendar
77
+ ```
78
+
79
+ ### Search what was synced
80
+
81
+ ```bash
82
+ m365-brain --config config.yaml index search "quarterly review" --json
83
+ m365-brain --config config.yaml index recent --timeframe 7d --json
84
+ ```
85
+
86
+ Results go to **stdout**, logs to **stderr**, so `--json` output parses without being separated from log noise first. Any verb taking a `--limit` reports `total`, `returned` and `limit`, so a truncated answer is visible as one.
87
+
88
+ ## Configuration
89
+
90
+ All configuration lives in one YAML file — or several, comma-separated and deep-merged left to right. Environment variables are expanded at load time using `${VAR_NAME}` syntax, and **a missing variable raises** rather than expanding to an empty string.
91
+
92
+ `m365-brain init` writes the reference configuration, whose comments *are* the documentation for every key. It is packaged at `m365_brain/templates/m365-brain.yaml`; the `config/` directory in this repo holds the split fragments the Docker images merge. Rather than restate it here — a copy that rots the first time a key moves — read the file `init` produced:
93
+
94
+ ```bash
95
+ m365-brain --config config.yaml config validate
96
+ m365-brain --config config.yaml config show --json
97
+ ```
98
+
99
+ `config validate` also resolves the configured hooks, which makes it a preflight rather than a syntax check. `config show` prints the effective merged config with secrets redacted.
100
+
101
+ Every section is strict: an unknown key is rejected, and no field anywhere has a default. A value the package needs is a value the config states.
102
+
103
+ ### Environment variables
104
+
105
+ The config loader expands `${VAR_NAME}` references at load time. Required variables:
106
+
107
+ | Variable | Purpose |
108
+ |----------|---------|
109
+ | `MSAL_CLIENT_ID` | Azure AD app registration client ID |
110
+ | `MSAL_TENANT_ID` | Azure AD tenant ID |
111
+ | `AZURE_STORAGE_CONNECTION_STRING` | Connection string (Azure Blob backend only) |
112
+ | `AZURE_STORAGE_CONTAINER` | Container name (Azure Blob backend only) |
113
+ | `AZURE_STORAGE_PREFIX` | Blob prefix / virtual directory (Azure Blob backend only) |
114
+
115
+ ## Azure Blob Storage
116
+
117
+ To use Azure Blob Storage instead of local filesystem, set `storage.backend: "azure_blob"` in your config. See `config/storage/azure_blob.yaml` for a complete example:
118
+
119
+ ```yaml
120
+ storage:
121
+ backend: "azure_blob"
122
+ azure_blob:
123
+ connection_string: "${AZURE_STORAGE_CONNECTION_STRING}"
124
+ container_name: "${AZURE_STORAGE_CONTAINER}"
125
+ prefix: "${AZURE_STORAGE_PREFIX}"
126
+ ```
127
+
128
+ ### Azurite (local development)
129
+
130
+ Start the Azurite emulator for local blob storage testing:
131
+
132
+ ```bash
133
+ docker compose --profile azurite up -d
134
+ ```
135
+
136
+ Then set the connection string to Azurite's default:
137
+
138
+ ```bash
139
+ export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
140
+ export AZURE_STORAGE_CONTAINER="m365-vaults"
141
+ export AZURE_STORAGE_PREFIX="dev"
142
+ ```
143
+
144
+ ## Infrastructure
145
+
146
+ Bicep templates in `infra/` provision an Azure Storage account with a private blob container. Parameter files for dev and prod are included.
147
+
148
+ ### Deploy
149
+
150
+ ```bash
151
+ # Dev
152
+ az deployment group create \
153
+ --resource-group rg-m365extract-dev \
154
+ --template-file infra/main.bicep \
155
+ --parameters infra/params.dev.bicepparam
156
+
157
+ # Prod
158
+ az deployment group create \
159
+ --resource-group rg-m365extract-prod \
160
+ --template-file infra/main.bicep \
161
+ --parameters infra/params.prod.bicepparam
162
+ ```
163
+
164
+ The template creates:
165
+
166
+ - Storage account (`stm365ext{environment}`) in Switzerland North
167
+ - TLS 1.2 minimum, HTTPS only, no public blob access
168
+ - A single blob container (`m365-vaults` by default)
169
+
170
+ ## Docker
171
+
172
+ ### Full stack (local dev)
173
+
174
+ ```bash
175
+ docker compose up --build # web + postgres (daemon runs inside web)
176
+ docker compose --profile azurite up # include Azurite blob emulator
177
+ ```
178
+
179
+ ## Development
180
+
181
+ ```bash
182
+ git clone https://github.com/neuralsignal/m365-brain.git
183
+ cd m365-brain
184
+ pixi install
185
+ pixi run test # unit tests (excludes integration + azurite markers)
186
+ pixi run test-cov # unit tests with coverage
187
+ pixi run test-azurite # tests requiring Azurite emulator
188
+ pixi run lint # ruff check
189
+ pixi run format # ruff format
190
+ pixi run pre-commit-install # install git hooks
191
+ pixi run docs-serve # local MkDocs dev server
192
+ ```
193
+
194
+ ### Project structure
195
+
196
+ ```
197
+ m365-brain/
198
+ m365_brain/
199
+ config/ # Strict Pydantic config: loading, merge, env expansion
200
+ model.py # Entity / Observation / Relation and the query types
201
+ parsers/ # Markdown and frontmatter into the model
202
+ storage/ # StorageBackend protocol, local filesystem, Azure Blob
203
+ state.py # StateStore protocol; delta tokens, cursors, cycle history
204
+ vault/ # Every path in the vault, plus the intent envelope
205
+ outbox/ # Vendor-agnostic write-back: authorities, runner, reconcile
206
+ index/ # The knowledge half -- backends, search, vectors, catalog
207
+ m365/ # The Microsoft half -- Graph client, auth, extractors, outboxes
208
+ cycle.py # One cycle: extract, index, hooks
209
+ cli.py # Click CLI -- the whole operating surface
210
+ commands/ # One module per command group
211
+ workspace.py # The library facade: a config path in, a working handle out
212
+ m365_admin/ # Reflex admin dashboard (optional extra)
213
+ skills/ # Bundled agent skills, thin wrappers over the CLI
214
+ config/ # Config fragments the Docker images merge
215
+ tests/ # pytest + hypothesis, mirroring the source layout
216
+ infra/ # Bicep IaC for Azure Storage
217
+ ```
218
+
219
+ `index/` never imports `m365/` and the two are peers by construction, so the knowledge layer works end to end on ordinary markdown with no Microsoft 365 present. That rule, the allowed directory list, the 300-line module cap, and the test-presence map are enforced by `scripts/check_structure.py` rather than by review.
220
+
221
+ ## Architecture
222
+
223
+ ```mermaid
224
+ graph LR
225
+ A[MSAL Auth] --> B[Graph Client]
226
+ B --> C[Extractors]
227
+ C --> D{Convert?}
228
+ D -->|yes| E[obsidian-import]
229
+ D -->|no| F[Markdown Writer]
230
+ E --> G[Storage Backend]
231
+ F --> G
232
+ G --> H[Local FS]
233
+ G --> I[Azure Blob]
234
+ ```
235
+
236
+ **Graph Client** (`m365_brain/m365/client.py`) wraps `httpx` with automatic token refresh, exponential backoff on 429/5xx, and paginated response iteration. Each **extractor** under `m365_brain/m365/extractors/` calls Graph endpoints for its data source, renders markdown with YAML frontmatter through the builders in `m365_brain/m365/frontmatter/`, and persists through the **Storage Backend** interface. OneDrive and SharePoint extractors optionally route binary files through **obsidian-import** for document-to-markdown conversion.
237
+
238
+ **Sync state** tracks delta links and timestamps per unit through the `StateStore` protocol, written as JSON under the vault's meta directory. It is bookkeeping, not data: deleting it forces a full re-pull, never a data loss.
239
+
240
+ ## Graph API Scopes
241
+
242
+ | Scope | Used by |
243
+ |-------|---------|
244
+ | `User.Read` | Token validation |
245
+ | `Mail.Read` | Email extractor |
246
+ | `Calendars.Read` | Calendar extractor |
247
+ | `Chat.Read` | Teams chats extractor |
248
+ | `ChannelMessage.Read.All` | Teams channels extractor |
249
+ | `Team.ReadBasic.All` + `Channel.ReadBasic.All` | Teams channels extractor — discovery mode only (`channels: null`); not needed with an explicit `channels` list |
250
+ | `Files.Read.All` | OneDrive + SharePoint extractors |
251
+ | `Sites.Read.All` | SharePoint extractor |
252
+ | `Contacts.Read` | Contacts extractor |
253
+ | `User.Read.All` | Directory extractor |
254
+ | `Directory.Read.All` | Directory extractor (manager chain, direct reports) |
255
+ | `offline_access` | Persistent token refresh |
256
+
257
+ All scopes use delegated (user) permissions via the device code flow. No application-level permissions are required.
258
+
259
+ ## Releases
260
+
261
+ This project uses [Release Please](https://github.com/googleapis/release-please) for automated versioning and changelog generation. Commits following [Conventional Commits](https://www.conventionalcommits.org/) are parsed to determine version bumps.
262
+
263
+ ## License
264
+
265
+ [MIT](LICENSE)
@@ -0,0 +1 @@
1
+ """m365-brain: Sync Microsoft 365 data to Obsidian-compatible markdown via Graph API."""