taskuary 0.2.0__tar.gz → 0.3.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 (123) hide show
  1. {taskuary-0.2.0/taskuary.egg-info → taskuary-0.3.0}/PKG-INFO +41 -13
  2. {taskuary-0.2.0 → taskuary-0.3.0}/README.md +40 -12
  3. {taskuary-0.2.0 → taskuary-0.3.0}/pyproject.toml +1 -1
  4. taskuary-0.3.0/taskuary/__init__.py +27 -0
  5. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/agents.py +18 -7
  6. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/aws.py +76 -3
  7. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/blackboard.py +2 -2
  8. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/channels.py +32 -5
  9. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/ci.py +63 -3
  10. taskuary-0.3.0/taskuary/cli.py +73 -0
  11. taskuary-0.3.0/taskuary/clis.py +49 -0
  12. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/config.py +33 -6
  13. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/github.py +8 -4
  14. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/histgen.py +27 -2
  15. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/imapmail.py +7 -0
  16. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/ingest.py +195 -18
  17. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/outbound.py +40 -0
  18. taskuary-0.3.0/taskuary/promptmap.py +134 -0
  19. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/proof.py +25 -6
  20. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/reports.py +165 -8
  21. taskuary-0.3.0/taskuary/research.py +113 -0
  22. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/responder.py +14 -4
  23. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/routing.py +14 -0
  24. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/scopes.py +3 -0
  25. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/server.py +153 -21
  26. taskuary-0.3.0/taskuary/setup.py +106 -0
  27. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/store.py +58 -16
  28. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/triage.md +4 -0
  29. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/terminal.py +22 -3
  30. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/triage.py +46 -4
  31. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/verdicts.py +23 -0
  32. taskuary-0.3.0/taskuary/web/assets/index-QtBkB3cc.js +405 -0
  33. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/web/index.html +5 -5
  34. taskuary-0.3.0/taskuary/webguard.py +79 -0
  35. {taskuary-0.2.0 → taskuary-0.3.0/taskuary.egg-info}/PKG-INFO +41 -13
  36. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/SOURCES.txt +21 -2
  37. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_api.py +40 -2
  38. taskuary-0.3.0/tests/test_audit_chain.py +83 -0
  39. taskuary-0.3.0/tests/test_aws_region.py +101 -0
  40. taskuary-0.3.0/tests/test_cc_triage.py +149 -0
  41. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_cloud.py +2 -1
  42. taskuary-0.3.0/tests/test_config.py +91 -0
  43. taskuary-0.3.0/tests/test_cwlogs_paging.py +103 -0
  44. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_diff_review.py +68 -2
  45. taskuary-0.3.0/tests/test_docs_flow.py +121 -0
  46. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_histgen.py +47 -0
  47. taskuary-0.3.0/tests/test_local_file.py +123 -0
  48. taskuary-0.3.0/tests/test_note_retrieval.py +137 -0
  49. taskuary-0.3.0/tests/test_outbound_reports.py +143 -0
  50. taskuary-0.3.0/tests/test_pr_comments.py +115 -0
  51. taskuary-0.3.0/tests/test_promptmap_and_catalog.py +138 -0
  52. taskuary-0.3.0/tests/test_research.py +151 -0
  53. taskuary-0.3.0/tests/test_setup.py +283 -0
  54. taskuary-0.3.0/tests/test_verdict_sticks.py +269 -0
  55. taskuary-0.3.0/tests/test_webguard.py +119 -0
  56. taskuary-0.2.0/taskuary/__init__.py +0 -2
  57. taskuary-0.2.0/taskuary/cli.py +0 -47
  58. taskuary-0.2.0/taskuary/web/assets/index-Cjj87C2X.js +0 -401
  59. taskuary-0.2.0/tests/test_config.py +0 -32
  60. {taskuary-0.2.0 → taskuary-0.3.0}/LICENSE +0 -0
  61. {taskuary-0.2.0 → taskuary-0.3.0}/setup.cfg +0 -0
  62. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/artifacts.py +0 -0
  63. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/azure.py +0 -0
  64. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/coder.py +0 -0
  65. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/db.py +0 -0
  66. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/desktop.py +0 -0
  67. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/devtools.py +0 -0
  68. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/digest.py +0 -0
  69. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/docsync.py +0 -0
  70. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/learn.py +0 -0
  71. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/llm.py +0 -0
  72. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/logs.py +0 -0
  73. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/mcp.py +0 -0
  74. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/messengers.py +0 -0
  75. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/mssql.py +0 -0
  76. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/phone.py +0 -0
  77. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/pm.py +0 -0
  78. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/policy.py +0 -0
  79. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/proposals.py +0 -0
  80. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/reshape.py +0 -0
  81. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/coder.md +0 -0
  82. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/digest.md +0 -0
  83. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/learned.md +0 -0
  84. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/soul.md +0 -0
  85. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/style.md +0 -0
  86. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/toil.py +0 -0
  87. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
  88. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/web/favicon.ico +0 -0
  89. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/web/favicon.png +0 -0
  90. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/whatsapp/bridge.mjs +0 -0
  91. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/whatsapp/package.json +0 -0
  92. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/dependency_links.txt +0 -0
  93. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/entry_points.txt +0 -0
  94. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/requires.txt +0 -0
  95. {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/top_level.txt +0 -0
  96. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_answers.py +0 -0
  97. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_blackboard.py +0 -0
  98. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_cloud_sources.py +0 -0
  99. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_core.py +0 -0
  100. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_desktop.py +0 -0
  101. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_devtools.py +0 -0
  102. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_docs_and_llm.py +0 -0
  103. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_harvest.py +0 -0
  104. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_imapmail.py +0 -0
  105. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_mark_read.py +0 -0
  106. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_mcp.py +0 -0
  107. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_messengers.py +0 -0
  108. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_mssql.py +0 -0
  109. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_not_coding.py +0 -0
  110. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_pm.py +0 -0
  111. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_poll_overlap.py +0 -0
  112. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_poll_sources.py +0 -0
  113. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_proof_ci_proposals.py +0 -0
  114. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_reply_channels.py +0 -0
  115. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_reply_voice.py +0 -0
  116. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_reviews.py +0 -0
  117. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_scopes.py +0 -0
  118. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_sync_clock.py +0 -0
  119. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_task_identity.py +0 -0
  120. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_terminal.py +0 -0
  121. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_toil.py +0 -0
  122. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_vision.py +0 -0
  123. {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_watermark.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskuary
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Automate your job - the local-first operating system for your work: AI triage in, coding agents out.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/ldbumble/taskuary
@@ -51,6 +51,7 @@ Dynamic: license-file
51
51
  [![CI](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml/badge.svg)](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml)
52
52
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
53
53
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776ab.svg)](https://github.com/ldbumble/taskuary)
54
+ [![PyPI](https://img.shields.io/pypi/v/taskuary.svg)](https://pypi.org/project/taskuary/)
54
55
  [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
55
56
  [![GitHub stars](https://img.shields.io/github/stars/ldbumble/taskuary?style=social)](https://github.com/ldbumble/taskuary/stargazers)
56
57
 
@@ -60,11 +61,11 @@ Dynamic: license-file
60
61
  scheduled reports land on one timeline; AI triage says what is real work; the coding CLI
61
62
  you already use does it; you approve the result. Runs entirely on your machine.
62
63
 
63
- ![Taskuary in use: the timeline of email, chats and scheduled reports; a mail opened to show why triage made it a task and the AI reply drafted for approval; a scheduled report landing with its bar chart and spreadsheet; the Reports pipeline list; and the agent board with live sessions and the note one agent left the next](docs/hero.gif)
64
+ ![Taskuary in use: the timeline of email, chats and scheduled reports; a mail opened to show why triage made it a task and the AI reply drafted for approval; a scheduled report landing with its bar chart and spreadsheet; the Reports pipeline list; and the agent board with live sessions and the note one agent left the next](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/hero.gif)
64
65
 
65
66
  <sub>Real app, real data — a mail triaged and its reply drafted for your approval, then a scheduled SQL report landing on the same rail with its chart and spreadsheet. Nothing sends without you.</sub>
66
67
 
67
- **Where it is:** early — **v0.2.0**, and moving fast. The funnel, the review queue, the agent
68
+ **Where it is:** early — **v0.3.0**, and moving fast. The funnel, the review queue, the agent
68
69
  sessions and the reports pipeline are all real and in daily use on my own inbox; the edges are
69
70
  still being knocked off, and breaking changes are possible before 1.0. Issues get answered.
70
71
  [Full status and roadmap ↓](#status--roadmap)
@@ -92,7 +93,7 @@ sender (yours to review under Settings → Agent memory).
92
93
 
93
94
  The general lessons take a stricter road, so one odd Tuesday never becomes a rule:
94
95
 
95
- ![Your verdicts become a hypothesis with score s:2; agreeing verdicts add a point and contradictions remove one; at s:4 with proof from two or more people it is promoted into LEARNED.md, which then rides into every triage, draft and agent run](docs/learning-loop.svg)
96
+ ![Your verdicts become a hypothesis with score s:2; agreeing verdicts add a point and contradictions remove one; at s:4 with proof from two or more people it is promoted into LEARNED.md, which then rides into every triage, draft and agent run](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/learning-loop.svg)
96
97
 
97
98
  **How the memory works, concretely.** Each lesson is one line in `LEARNED.md` (Docs tab) —
98
99
  a guess with a score. Say you strip the greeting off three drafts this week; the file soon
@@ -129,7 +130,7 @@ markers always survive. What each one feeds from then on:
129
130
  ## Get started
130
131
 
131
132
  ```bash
132
- pip install git+https://github.com/ldbumble/taskuary
133
+ pip install taskuary
133
134
  taskuary # opens http://127.0.0.1:7787
134
135
  ```
135
136
 
@@ -161,10 +162,22 @@ No cloud key at all? Set **Settings → Triage & routing → Triage brain** to y
161
162
  and skip step 1 — one brain does everything, slower and pricier per message. See
162
163
  [One brain or two](#one-brain-or-two).
163
164
 
164
- Prefer a desktop app? `pip install "taskuary[desktop] @ git+https://github.com/ldbumble/taskuary"`
165
+ Prefer a desktop app? `pip install "taskuary[desktop]"`
165
166
  then `taskuary-desktop` — the same UI in a native window. A prebuilt single-file
166
167
  `Taskuary.exe` is attached to every CI run.
167
168
 
169
+ Prefer Docker? No Python install on the machine:
170
+
171
+ ```bash
172
+ git clone https://github.com/ldbumble/taskuary && cd taskuary
173
+ docker compose up
174
+ # http://127.0.0.1:7787 — data lives in the taskuary-data volume
175
+ ```
176
+
177
+ The container is the web app (Timeline, Review, Reports, Connectors). Coding CLIs
178
+ (Claude Code, Codex, …) and the WhatsApp bridge stay on the host — they are programs on
179
+ *your* machine. Publish the port past localhost only with `TASKUARY_TOKEN` set.
180
+
168
181
  ## The workspace
169
182
 
170
183
  One tab per question, two lines each; the details live in the app's own help text.
@@ -178,7 +191,8 @@ One tab per question, two lines each; the details live in the app's own help tex
178
191
  right now — a live session counts as working, and a session gone quiet moves its card to
179
192
  *waiting on you* with the question showing. Cards working now show a live peephole and
180
193
  the files their agent has modified so far; a queued card says whom it waits behind (see
181
- [Many agents, one repo](#many-agents-one-repo--no-stepping-on-each-other)).
194
+ [Many agents, one repo](#many-agents-one-repo--no-stepping-on-each-other)). Flip it to
195
+ **Studio** for the same board as a floor you can walk around — below.
182
196
  - **Tasks** — **the page is a terminal**: your CLI in the task's repo, prompt typed in and
183
197
  sent, and you keep talking. Taskuary picks the checkout from the SOUL.md repo map (one
184
198
  click to override); the prompt carries the ask, the mail, the files and the rules, so the
@@ -213,9 +227,20 @@ Two principles hold everywhere: **nothing sends or ships without your approval**
213
227
  it works the mail (auto-dispatch + auto-draft, both switchable); triage is AI-gated, so
214
228
  with no AI connected messages file visibly instead of heuristics spraying tasks.
215
229
 
230
+ ## The floor — capacity you can see
231
+
232
+ ![The Studio view: an isometric office where each desk is a task and the agent at it is drawn doing what it is actually doing — hunched at a lit screen for a coding session, a pen on a form for a task with no code in it, hand up when it has stopped and is waiting on you. An empty desk is capacity going unused.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/screenshot-floor.png)
233
+
234
+ "How much can run at once" is a number in Settings — **Agents at once**, four by default —
235
+ and on the Board's **Studio** toggle it stops being a number. One desk per slot: a desk with
236
+ somebody at it is a task being worked, an empty desk is capacity you are not using, and the
237
+ posture says which kind of work it is rather than making you read a chip. Drag to turn the
238
+ room, scroll to zoom, click a desk to fly to it; raise the limit in Settings and the floor
239
+ widens rather than crowding.
240
+
216
241
  ## Many agents, one repo — no stepping on each other
217
242
 
218
- ![Two agents share one checkout: each working card shows the files ITS agent has modified (claude in the theme files, codex in the report code and its tests), and a third task waits in Queued with the reason written on the card — waiting on TQ-0009, both would modify ReportsView.jsx, starts by itself when it can](docs/screenshot-board.png)
243
+ ![Two agents share one checkout: each working card shows the files ITS agent has modified (claude in the theme files, codex in the report code and its tests), and a third task waits in Queued with the reason written on the card — waiting on TQ-0009, both would modify ReportsView.jsx, starts by itself when it can](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/screenshot-board.png)
219
244
 
220
245
  Auto-dispatch can put several CLIs to work at once — and the board keeps them out of each
221
246
  other's way with three light moves. No locks, no worktrees, no manager agent:
@@ -265,7 +290,7 @@ Plain markdown, all on the Docs tab, all yours to edit. Three you write, two wri
265
290
  themselves, and two can **bootstrap themselves from your mail history** (`TRIAGE.md` and
266
291
  `STYLE.md` — the Generate from history button). Each feeds exactly the calls it belongs in.
267
292
 
268
- ![TRIAGE.md, STYLE.md, SOUL.md and LEARNED.md feed triage and replies on the cheap model; SOUL.md, CODER.md and LEARNED.md feed coding agents on your CLI; DIGEST.md is your own morning read — and TRIAGE.md and STYLE.md can be generated from three months of your own mail](docs/six-docs.svg)
293
+ ![TRIAGE.md, STYLE.md, SOUL.md and LEARNED.md feed triage and replies on the cheap model; SOUL.md, CODER.md and LEARNED.md feed coding agents on your CLI; DIGEST.md is your own morning read — and TRIAGE.md and STYLE.md can be generated from three months of your own mail](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/six-docs.svg)
269
294
 
270
295
  | document | what it is | who reads it |
271
296
  |---|---|---|
@@ -354,14 +379,17 @@ pyinstaller taskuary.spec # dist/Taskuary.exe - single-file desktop build
354
379
  ```
355
380
 
356
381
  Data lives in `~/.taskuary/` (override with `TASKUARY_HOME`): `taskuary.db` (SQLite),
357
- `config.toml`, `taskuary.log`. For LAN use set `[server].token` in config and send it as
358
- the `X-Taskuary-Token` header. CI runs the test matrix on Windows / Linux / macOS ×
359
- py3.10 / 3.12 on every push and pull request, plus the web build. The single-file
382
+ `config.toml`, `taskuary.log`. Docker uses `/data` inside the container for the same
383
+ files (`TASKUARY_HOST` / `TASKUARY_PORT` / `TASKUARY_TOKEN` overlay `[server]` at
384
+ runtime only — they are never written back). For LAN use set
385
+ `[server].token` in config (or `TASKUARY_TOKEN`) and send it as the `X-Taskuary-Token`
386
+ header. CI runs the test matrix on Windows / Linux / macOS × py3.10 / 3.12 on every
387
+ push and pull request, plus the web build and a Docker image smoke. The single-file
360
388
  exe is built on push to master.
361
389
 
362
390
  ## Status / roadmap
363
391
 
364
- Early (v0.2.0) and moving fast — said up top too, because it should not be something you find
392
+ Early (v0.3.0) and moving fast — said up top too, because it should not be something you find
365
393
  out at the bottom.
366
394
 
367
395
  - [x] AI-gated triage, review queue, resumable agent sessions, hash-chained audit
@@ -3,6 +3,7 @@
3
3
  [![CI](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml/badge.svg)](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776ab.svg)](https://github.com/ldbumble/taskuary)
6
+ [![PyPI](https://img.shields.io/pypi/v/taskuary.svg)](https://pypi.org/project/taskuary/)
6
7
  [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
7
8
  [![GitHub stars](https://img.shields.io/github/stars/ldbumble/taskuary?style=social)](https://github.com/ldbumble/taskuary/stargazers)
8
9
 
@@ -12,11 +13,11 @@
12
13
  scheduled reports land on one timeline; AI triage says what is real work; the coding CLI
13
14
  you already use does it; you approve the result. Runs entirely on your machine.
14
15
 
15
- ![Taskuary in use: the timeline of email, chats and scheduled reports; a mail opened to show why triage made it a task and the AI reply drafted for approval; a scheduled report landing with its bar chart and spreadsheet; the Reports pipeline list; and the agent board with live sessions and the note one agent left the next](docs/hero.gif)
16
+ ![Taskuary in use: the timeline of email, chats and scheduled reports; a mail opened to show why triage made it a task and the AI reply drafted for approval; a scheduled report landing with its bar chart and spreadsheet; the Reports pipeline list; and the agent board with live sessions and the note one agent left the next](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/hero.gif)
16
17
 
17
18
  <sub>Real app, real data — a mail triaged and its reply drafted for your approval, then a scheduled SQL report landing on the same rail with its chart and spreadsheet. Nothing sends without you.</sub>
18
19
 
19
- **Where it is:** early — **v0.2.0**, and moving fast. The funnel, the review queue, the agent
20
+ **Where it is:** early — **v0.3.0**, and moving fast. The funnel, the review queue, the agent
20
21
  sessions and the reports pipeline are all real and in daily use on my own inbox; the edges are
21
22
  still being knocked off, and breaking changes are possible before 1.0. Issues get answered.
22
23
  [Full status and roadmap ↓](#status--roadmap)
@@ -44,7 +45,7 @@ sender (yours to review under Settings → Agent memory).
44
45
 
45
46
  The general lessons take a stricter road, so one odd Tuesday never becomes a rule:
46
47
 
47
- ![Your verdicts become a hypothesis with score s:2; agreeing verdicts add a point and contradictions remove one; at s:4 with proof from two or more people it is promoted into LEARNED.md, which then rides into every triage, draft and agent run](docs/learning-loop.svg)
48
+ ![Your verdicts become a hypothesis with score s:2; agreeing verdicts add a point and contradictions remove one; at s:4 with proof from two or more people it is promoted into LEARNED.md, which then rides into every triage, draft and agent run](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/learning-loop.svg)
48
49
 
49
50
  **How the memory works, concretely.** Each lesson is one line in `LEARNED.md` (Docs tab) —
50
51
  a guess with a score. Say you strip the greeting off three drafts this week; the file soon
@@ -81,7 +82,7 @@ markers always survive. What each one feeds from then on:
81
82
  ## Get started
82
83
 
83
84
  ```bash
84
- pip install git+https://github.com/ldbumble/taskuary
85
+ pip install taskuary
85
86
  taskuary # opens http://127.0.0.1:7787
86
87
  ```
87
88
 
@@ -113,10 +114,22 @@ No cloud key at all? Set **Settings → Triage & routing → Triage brain** to y
113
114
  and skip step 1 — one brain does everything, slower and pricier per message. See
114
115
  [One brain or two](#one-brain-or-two).
115
116
 
116
- Prefer a desktop app? `pip install "taskuary[desktop] @ git+https://github.com/ldbumble/taskuary"`
117
+ Prefer a desktop app? `pip install "taskuary[desktop]"`
117
118
  then `taskuary-desktop` — the same UI in a native window. A prebuilt single-file
118
119
  `Taskuary.exe` is attached to every CI run.
119
120
 
121
+ Prefer Docker? No Python install on the machine:
122
+
123
+ ```bash
124
+ git clone https://github.com/ldbumble/taskuary && cd taskuary
125
+ docker compose up
126
+ # http://127.0.0.1:7787 — data lives in the taskuary-data volume
127
+ ```
128
+
129
+ The container is the web app (Timeline, Review, Reports, Connectors). Coding CLIs
130
+ (Claude Code, Codex, …) and the WhatsApp bridge stay on the host — they are programs on
131
+ *your* machine. Publish the port past localhost only with `TASKUARY_TOKEN` set.
132
+
120
133
  ## The workspace
121
134
 
122
135
  One tab per question, two lines each; the details live in the app's own help text.
@@ -130,7 +143,8 @@ One tab per question, two lines each; the details live in the app's own help tex
130
143
  right now — a live session counts as working, and a session gone quiet moves its card to
131
144
  *waiting on you* with the question showing. Cards working now show a live peephole and
132
145
  the files their agent has modified so far; a queued card says whom it waits behind (see
133
- [Many agents, one repo](#many-agents-one-repo--no-stepping-on-each-other)).
146
+ [Many agents, one repo](#many-agents-one-repo--no-stepping-on-each-other)). Flip it to
147
+ **Studio** for the same board as a floor you can walk around — below.
134
148
  - **Tasks** — **the page is a terminal**: your CLI in the task's repo, prompt typed in and
135
149
  sent, and you keep talking. Taskuary picks the checkout from the SOUL.md repo map (one
136
150
  click to override); the prompt carries the ask, the mail, the files and the rules, so the
@@ -165,9 +179,20 @@ Two principles hold everywhere: **nothing sends or ships without your approval**
165
179
  it works the mail (auto-dispatch + auto-draft, both switchable); triage is AI-gated, so
166
180
  with no AI connected messages file visibly instead of heuristics spraying tasks.
167
181
 
182
+ ## The floor — capacity you can see
183
+
184
+ ![The Studio view: an isometric office where each desk is a task and the agent at it is drawn doing what it is actually doing — hunched at a lit screen for a coding session, a pen on a form for a task with no code in it, hand up when it has stopped and is waiting on you. An empty desk is capacity going unused.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/screenshot-floor.png)
185
+
186
+ "How much can run at once" is a number in Settings — **Agents at once**, four by default —
187
+ and on the Board's **Studio** toggle it stops being a number. One desk per slot: a desk with
188
+ somebody at it is a task being worked, an empty desk is capacity you are not using, and the
189
+ posture says which kind of work it is rather than making you read a chip. Drag to turn the
190
+ room, scroll to zoom, click a desk to fly to it; raise the limit in Settings and the floor
191
+ widens rather than crowding.
192
+
168
193
  ## Many agents, one repo — no stepping on each other
169
194
 
170
- ![Two agents share one checkout: each working card shows the files ITS agent has modified (claude in the theme files, codex in the report code and its tests), and a third task waits in Queued with the reason written on the card — waiting on TQ-0009, both would modify ReportsView.jsx, starts by itself when it can](docs/screenshot-board.png)
195
+ ![Two agents share one checkout: each working card shows the files ITS agent has modified (claude in the theme files, codex in the report code and its tests), and a third task waits in Queued with the reason written on the card — waiting on TQ-0009, both would modify ReportsView.jsx, starts by itself when it can](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/screenshot-board.png)
171
196
 
172
197
  Auto-dispatch can put several CLIs to work at once — and the board keeps them out of each
173
198
  other's way with three light moves. No locks, no worktrees, no manager agent:
@@ -217,7 +242,7 @@ Plain markdown, all on the Docs tab, all yours to edit. Three you write, two wri
217
242
  themselves, and two can **bootstrap themselves from your mail history** (`TRIAGE.md` and
218
243
  `STYLE.md` — the Generate from history button). Each feeds exactly the calls it belongs in.
219
244
 
220
- ![TRIAGE.md, STYLE.md, SOUL.md and LEARNED.md feed triage and replies on the cheap model; SOUL.md, CODER.md and LEARNED.md feed coding agents on your CLI; DIGEST.md is your own morning read — and TRIAGE.md and STYLE.md can be generated from three months of your own mail](docs/six-docs.svg)
245
+ ![TRIAGE.md, STYLE.md, SOUL.md and LEARNED.md feed triage and replies on the cheap model; SOUL.md, CODER.md and LEARNED.md feed coding agents on your CLI; DIGEST.md is your own morning read — and TRIAGE.md and STYLE.md can be generated from three months of your own mail](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/six-docs.svg)
221
246
 
222
247
  | document | what it is | who reads it |
223
248
  |---|---|---|
@@ -306,14 +331,17 @@ pyinstaller taskuary.spec # dist/Taskuary.exe - single-file desktop build
306
331
  ```
307
332
 
308
333
  Data lives in `~/.taskuary/` (override with `TASKUARY_HOME`): `taskuary.db` (SQLite),
309
- `config.toml`, `taskuary.log`. For LAN use set `[server].token` in config and send it as
310
- the `X-Taskuary-Token` header. CI runs the test matrix on Windows / Linux / macOS ×
311
- py3.10 / 3.12 on every push and pull request, plus the web build. The single-file
334
+ `config.toml`, `taskuary.log`. Docker uses `/data` inside the container for the same
335
+ files (`TASKUARY_HOST` / `TASKUARY_PORT` / `TASKUARY_TOKEN` overlay `[server]` at
336
+ runtime only — they are never written back). For LAN use set
337
+ `[server].token` in config (or `TASKUARY_TOKEN`) and send it as the `X-Taskuary-Token`
338
+ header. CI runs the test matrix on Windows / Linux / macOS × py3.10 / 3.12 on every
339
+ push and pull request, plus the web build and a Docker image smoke. The single-file
312
340
  exe is built on push to master.
313
341
 
314
342
  ## Status / roadmap
315
343
 
316
- Early (v0.2.0) and moving fast — said up top too, because it should not be something you find
344
+ Early (v0.3.0) and moving fast — said up top too, because it should not be something you find
317
345
  out at the bottom.
318
346
 
319
347
  - [x] AI-gated triage, review queue, resumable agent sessions, hash-chained audit
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "taskuary"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Automate your job - the local-first operating system for your work: AI triage in, coding agents out."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,27 @@
1
+ """Taskuary - Automate your job: AI triage in, coding agents out, you in charge."""
2
+ # ONE place holds the version and it is pyproject.toml, because that is the one the git tag has
3
+ # to match (RELEASING.md). A second copy here said 0.2.0 through the whole of 0.2.1: the CLI
4
+ # banner, the API and the header pill all reported it, and the header's own tooltip told the
5
+ # owner to restart - which could never have helped, because restarting reloads the same
6
+ # hardcoded string.
7
+ from pathlib import Path
8
+
9
+
10
+ def _version() -> str:
11
+ # pyproject FIRST, and only a source checkout has one sitting next to the package. It is the
12
+ # file the number lives in, so it is right the moment you pull - whereas installed metadata
13
+ # is frozen at `pip install` time: an editable install made at 0.2.0 keeps reporting 0.2.0
14
+ # through every later pull, which is the exact confusion this whole function exists to end.
15
+ try:
16
+ for line in (Path(__file__).parent.parent / 'pyproject.toml').read_text(encoding='utf-8').splitlines():
17
+ if line.startswith('version'): return line.split('=', 1)[1].strip().strip('"\'')
18
+ except OSError: pass
19
+ try: # a real wheel: no pyproject beside it, metadata is the truth
20
+ from importlib.metadata import PackageNotFoundError, version
21
+ try: return version('taskuary')
22
+ except PackageNotFoundError: pass
23
+ except ImportError: pass
24
+ return '0.0.0+unknown' # never a stale number pretending to be a real one
25
+
26
+
27
+ __version__ = _version()
@@ -59,7 +59,10 @@ def _shim_target(path: str) -> list:
59
59
  here = os.path.dirname(path)
60
60
  found = []
61
61
  for tok in re.findall(r'"([^"]+)"', txt):
62
- real = os.path.normpath(tok.replace('%dp0%', here).replace('%~dp0', here))
62
+ # a batch file always writes \ - a separator on Windows, an ordinary character
63
+ # everywhere else. Translating it means this parser can be exercised by CI on
64
+ # Linux and macOS too, rather than only on the platform that has the bug.
65
+ real = os.path.normpath(tok.replace('%dp0%', here).replace('%~dp0', here).replace(chr(92), os.sep))
63
66
  if os.path.isfile(real) and real.lower().endswith(('.exe', '.js')): found.append(real)
64
67
  exe = next((f for f in found if f.lower().endswith('.exe')), None)
65
68
  js = next((f for f in found if f.lower().endswith('.js')), None)
@@ -194,16 +197,24 @@ def task_context(store, task_id: int) -> str:
194
197
  lines += [f"- [{m.get('SentAt')}] {m.get('FromName') or m.get('FromEmail')}: {m.get('Subject') or ''}",
195
198
  f" {str(m.get('BodyText') or '')[:1500]}"]
196
199
  lines += ['', 'Thread:'] + [f"- {c.get('Actor')}: {str(c.get('Body'))[:300]}" for c in d['comments']]
200
+ mem = memory_block(store, d['messages'])
201
+ if mem: lines += ['', mem]
197
202
  return '\n'.join(lines)
198
203
 
199
204
 
200
205
  def memory_block(store, messages: list) -> str:
201
- senders = {(m.get('FromEmail') or '').lower() for m in messages if m.get('FromEmail')}
202
- domains = {s.rsplit('@', 1)[-1] for s in senders if '@' in s}
203
- hits = [f"- {n['Note']}" for n in store.list_memories()
204
- if n['Scope'] == 'global' or (n['Scope'] == 'sender' and (n.get('ScopeKey') or '').lower() in senders)
205
- or (n['Scope'] == 'sender_domain' and (n.get('ScopeKey') or '').lower() in domains)]
206
- return ('Standing notes (learned from the owner - FOLLOW these):\n' + '\n'.join(hits)) if hits else ''
206
+ """The standing notes an agent working this thread has to follow - the ones that bear on
207
+ THIS thread, not every note on file. It used to be every match with no cap at all, which
208
+ grows without limit as the owner keeps giving verdicts; ranking by what the thread actually
209
+ says puts the ones that matter at the top and says how many were left out."""
210
+ from .ingest import relevant_notes
211
+ text = ' '.join(f"{m.get('Subject') or ''} {m.get('BodyText') or ''}" for m in messages)[:4000]
212
+ notes, left = relevant_notes(store, [(m.get('FromEmail') or '') for m in messages], text)
213
+ if not notes: return ''
214
+ return ('Standing notes (learned from the owner - FOLLOW these):\n'
215
+ + '\n'.join(f'- {n}' for n in notes)
216
+ + (f'\n({left} more apply to this thread but did not fit - ask before assuming '
217
+ 'nothing else was said.)' if left else ''))
207
218
 
208
219
 
209
220
  # dispatch() lived here: one open->close HEADLESS run on a task, the CLI working and closing
@@ -129,6 +129,44 @@ def discover(store, cfg: dict, connector_id: int, actor: str = 'owner') -> dict:
129
129
  return out
130
130
 
131
131
 
132
+ def catalog(store, service: str = None) -> dict:
133
+ """What is callable here, WITHOUT calling AWS: botocore ships the service and operation
134
+ models on disk, so both lists are local metadata - no credentials, no region, no request.
135
+
136
+ Typing `service` and `operation` by hand meant knowing boto3's naming from memory and
137
+ finding out you had it wrong only when a scheduled report failed. `seen` is what discovery
138
+ actually found in this account, first in the list, because 400-odd alphabetical service
139
+ names is a haystack rather than a choice. `read` marks the operations that only look:
140
+ a report is a thing you read, and describe_/list_/get_ is nearly always what you want.
141
+ """
142
+ import botocore.session
143
+ from botocore import xform_name
144
+ sess = botocore.session.get_session()
145
+ # the objects discovery actually found, each WITH THE REGION it was found in. Handing back a
146
+ # bare name was a real bug: a log group is (name, region) - /aws/lambda/x in us-east-2 and the
147
+ # one in us-east-1 are unrelated groups - so a name picked from a list and run against the
148
+ # card's FIRST region answers "The specified log group does not exist", which is true and
149
+ # completely misleading. The region travels with the pick now.
150
+ objs = [(s['Address'] or '', json.loads(s.get('ConfigJson') or '{}').get('region') or '')
151
+ for s in store.list_sources(active_only=False) if s['Channel'] == 'aws']
152
+ groups = sorted(({'name': a[len('logs://'):], 'region': r} for a, r in objs if a.startswith('logs://')),
153
+ key=lambda g: (g['region'], g['name']))
154
+ buckets = sorted(({'name': a[len('s3://'):], 'region': r} for a, r in objs if a.startswith('s3://')),
155
+ key=lambda b: b['name'])
156
+ seen = sorted({'logs' for _ in groups[:1]} | {'s3' for _ in buckets[:1]})
157
+ out = {'seen': seen, 'services': sorted(sess.get_available_services()),
158
+ 'log_groups': groups, 'buckets': buckets}
159
+ if service:
160
+ try:
161
+ names = [xform_name(o) for o in sess.get_service_model(service).operation_names]
162
+ except Exception as e:
163
+ return {**out, 'service': service, 'operations': [], 'error': str(e)[:200]}
164
+ read = [n for n in names if n.startswith(('list_', 'describe_', 'get_', 'head_', 'query', 'scan', 'select_'))]
165
+ out.update({'service': service, 'operations': sorted(read) + sorted(set(names) - set(read)),
166
+ 'read': sorted(read)})
167
+ return out
168
+
169
+
132
170
  def poll_source(store, cfg: dict, src: dict, since, llm=None, file_only=False) -> int:
133
171
  """One discovered object in tasks/feed mode. s3://bucket -> a Timeline item per NEW
134
172
  object; logs://group -> ONE batched item of the new matching events (default pattern:
@@ -212,15 +250,50 @@ def run_s3_object(cfg: dict):
212
250
  return rows_out(rows, lim, unit='objects', mine=mine)
213
251
 
214
252
 
253
+ LOG_PAGE_CAP = 12 # one report is worth this many FilterLogEvents calls, not unbounded
254
+
255
+
215
256
  def run_cloudwatch_logs(cfg: dict):
216
257
  """{"log_group", "pattern", "hours": 24} - recent events from a CloudWatch log group.
217
258
  Pattern uses CloudWatch filter syntax: '?ERROR ?Exception' means any of these words."""
218
259
  from .reports import row_limit, rows_out
219
260
  lim, mine = row_limit(cfg)
220
261
  start = int((datetime.now() - timedelta(hours=float(cfg.get('hours') or 24))).timestamp() * 1000)
221
- kw = {'logGroupName': cfg['log_group'], 'startTime': start, 'limit': min(lim + 1, 10000)}
262
+ hours = float(cfg.get('hours') or 24)
263
+ kw = {'logGroupName': cfg['log_group'], 'startTime': start, 'limit': min(max(lim + 1, 1000), 10000)}
222
264
  if cfg.get('pattern'): kw['filterPattern'] = cfg['pattern']
223
- ev = client(cfg, 'logs').filter_log_events(**kw).get('events') or []
265
+ where = regions(cfg)[0]
266
+ try:
267
+ cl = client(cfg, 'logs', where)
268
+ # FilterLogEvents PAGES, and its pages are not full ones. It scans log streams and hands
269
+ # back whatever that pass found, with a nextToken - so a lambda (one stream per
270
+ # invocation) answered a single call with nine events while the rest of the day sat behind
271
+ # the token. "9 events" then read as "a quiet day", which for a report asked to find
272
+ # ERRORS is the worst way to be wrong. Page until the cap is covered or the token runs
273
+ # out; PAGE_CAP stops a busy group from turning one report into a thousand calls.
274
+ ev, tok, pages = [], None, 0
275
+ while pages < LOG_PAGE_CAP:
276
+ r = cl.filter_log_events(**kw, **({'nextToken': tok} if tok else {}))
277
+ ev += r.get('events') or []
278
+ tok, pages = r.get('nextToken'), pages + 1
279
+ if not tok or len(ev) > lim: break
280
+ except Exception as e:
281
+ # "The specified log group does not exist" is true and useless: it does exist, in another
282
+ # region. AWS cannot say that because it only knows the region it was asked about - but we
283
+ # know which one we asked, so we say it.
284
+ if 'ResourceNotFoundException' not in type(e).__name__ and 'does not exist' not in str(e): raise
285
+ raise RuntimeError(f"no log group {cfg['log_group']} in {where or 'the default region'} - a log "
286
+ 'group belongs to ONE region, so the same name in another region is a '
287
+ 'different group. Pick it from the list again (each entry says its region), '
288
+ "or set this source's region to the one it lives in.") from None
289
+ # newest first: when the cap bites, the recent end of the window is the half worth keeping,
290
+ # and AWS hands these back oldest-first
291
+ ev.sort(key=lambda e: e.get('timestamp') or 0, reverse=True)
224
292
  rows = [{'at': datetime.fromtimestamp(e['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M:%S'),
225
293
  'stream': e.get('logStreamName'), 'message': (e.get('message') or '').strip()[:500]} for e in ev]
226
- return rows_out(rows, lim, unit='events', mine=mine)
294
+ hrs = f'{hours:g}h'
295
+ head, body = rows_out(rows, lim, unit=f'events in the last {hrs}', mine=mine)
296
+ # a scan that stopped at the page bound is not "all of it", and only this code knows
297
+ if tok and len(ev) <= lim:
298
+ head += f' — stopped after {LOG_PAGE_CAP} pages, so there may be more; narrow it with a filter pattern'
299
+ return head, body
@@ -116,11 +116,11 @@ def drain(store):
116
116
  """A session ended (or a slot freed up): start what was queued, in arrival order. Anything
117
117
  whose blocker is still working stays put; anything whose task moved on is just cleared."""
118
118
  from . import terminal as term
119
- from .ingest import AUTO_SESSIONS
119
+ from .ingest import auto_sessions
120
120
  if not _DRAINING.acquire(blocking=False): return
121
121
  try:
122
122
  for q in store.queued_dispatches():
123
- if len([t for t in term.SESSIONS.values() if t.alive]) >= AUTO_SESSIONS: return
123
+ if len([t for t in term.SESSIONS.values() if t.alive]) >= auto_sessions(store): return
124
124
  b = q.get('BehindTaskId')
125
125
  if b and (term.for_task(b) or any(r['TaskId'] == b for r in store.running_runs())): continue
126
126
  t = store.get_task(q['TaskId']) or {}
@@ -14,12 +14,18 @@ from .github import _h as gh_headers, list_accessible_repos
14
14
  from .ingest import ingest_message
15
15
 
16
16
  GRAPH = 'https://graph.microsoft.com/v1.0'
17
- MAIL_SELECT = 'id,subject,from,receivedDateTime,sentDateTime,bodyPreview,body,conversationId,webLink,hasAttachments,isRead'
17
+ MAIL_SELECT = 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,body,conversationId,webLink,hasAttachments,isRead'
18
18
 
19
19
 
20
20
  def _cfg(c): return json.loads(c.get('ConfigJson') or '{}')
21
21
 
22
22
 
23
+ def _addrs(rows) -> list:
24
+ """The addresses off a Graph recipient list. Triage needs to know whether the mailbox is
25
+ on the To line or merely in Cc - being copied on other people's work is not an assignment."""
26
+ return [a for a in ((r.get('emailAddress') or {}).get('address') or '' for r in rows or []) if a]
27
+
28
+
23
29
  def graph_creds(store, c):
24
30
  """Effective Graph credentials for a connector: its own, else the Outlook connector's
25
31
  saved app (Teams shares it by design), else the AZURE_* env vars (in graph_token).
@@ -132,6 +138,14 @@ def test_connector(store, cid: int) -> dict:
132
138
  elif c['Type'] == 'whatsapp':
133
139
  from .messengers import wa_test
134
140
  detail = wa_test(store, store.get_connector(c['ConnectorId'], with_secret=True))
141
+ elif c['Type'] in ('exa', 'tavily', 'firecrawl', 'reader'):
142
+ # a real call, not a key-shape check: these all fail the same way (401) and the
143
+ # owner should find that out here rather than from an empty report on Monday
144
+ from .reports import REGISTRY, resolve_cfg
145
+ probe = ({'url': 'https://example.com'} if c['Type'] in ('firecrawl', 'reader')
146
+ else {'query': 'taskuary local-first ai task hub', 'num': 1})
147
+ head, _body = REGISTRY[c['Type']](resolve_cfg(store, {**probe, 'type': c['Type'], 'max_rows': 1}))
148
+ detail = f'{c["Type"]} answered: {head}'
135
149
  elif c['Type'] in ('jira', 'asana', 'monday', 'clickup', 'todoist'):
136
150
  from . import pm
137
151
  detail = pm.test(store, store.get_connector(c['ConnectorId'], with_secret=True))
@@ -411,7 +425,7 @@ def _graph_user(tok, oid, cache):
411
425
  _HOSTED = re.compile(r'<img[^>]+src="([^"]*?/hostedContents/[^"]*?)"', re.I)
412
426
 
413
427
 
414
- def hosted_images(tok: str, html: str, cap: int = 4) -> list:
428
+ def hosted_images(tok: str, html: str, cap: int = 4, where: str = '') -> list:
415
429
  out = []
416
430
  for i, url in enumerate(dict.fromkeys(_HOSTED.findall(html or '')).keys()):
417
431
  if i >= cap: break
@@ -419,7 +433,14 @@ def hosted_images(tok: str, html: str, cap: int = 4) -> list:
419
433
  r = requests.get(unescape(url), headers={'Authorization': f'Bearer {tok}'}, timeout=30)
420
434
  r.raise_for_status()
421
435
  except Exception as e:
422
- logger.warning(f'teams hosted image fetch failed: {e}')
436
+ # one readable line, not the signed hostedContents URL twice over: it is 900
437
+ # characters of query string, it says nothing the status code does not, and two
438
+ # of them per message buried the rest of the sync log. 403 here is the membership
439
+ # check described below - expected, not a fault to hunt.
440
+ code = getattr(getattr(e, 'response', None), 'status_code', None)
441
+ why = ('Microsoft refused it for this chat (403 - the app is not a member)' if code == 403
442
+ else f'HTTP {code}' if code else str(e)[:120])
443
+ logger.warning(f'teams image {i + 1} not readable{f" in {where}" if where else ""}: {why}')
423
444
  continue
424
445
  ct = (r.headers.get('Content-Type') or 'image/png').split(';')[0].strip()
425
446
  out.append({'id': hashlib.sha1(url.encode()).hexdigest()[:16],
@@ -453,7 +474,7 @@ def ingest_teams_chats(store, upn: str, tok: str, since, llm=None, file_only=Fal
453
474
  # fetched BEFORE triage, like the mail path: the classifier has to see the screenshot
454
475
  # to judge it, and afterwards is too late
455
476
  raw_html = (m.get('body') or {}).get('content') or ''
456
- atts = hosted_images(tok, raw_html)
477
+ atts = hosted_images(tok, raw_html, where=topic or f'chat with {name}')
457
478
  # a picture we could not FETCH must not vanish silently the way it used to. Graph
458
479
  # refuses hostedContents unless the app registration carries ChatMessage.Read.All, and
459
480
  # "the screenshot was the whole ask" is exactly the message you cannot afford to read
@@ -629,7 +650,7 @@ def _since(s, backfill_days: int = 0):
629
650
  return min(last, datetime.now() - timedelta(days=backfill_days)) if backfill_days else last
630
651
 
631
652
 
632
- def poll_channels(store, backfill_days: int = 0) -> int:
653
+ def poll_channels(store, backfill_days: int = 0, progress=None) -> int:
633
654
  """Ingest new items for every connection the owner marked as a TRIGGER, through the
634
655
  same triage funnel (incl. the configured AI, if any). A connection without the trigger
635
656
  role is still usable by agents and reports - it just never creates work on its own.
@@ -651,6 +672,11 @@ def poll_channels(store, backfill_days: int = 0) -> int:
651
672
  if (not roles & {'trigger', 'feed'} and not (c['Type'] == 'github' and _gh_explicit(store))
652
673
  and not (c['Type'] in CLOUD and _cloud_explicit(store, CH2SRC[c['Type']]))): continue
653
674
  file_only = 'trigger' not in roles
675
+ # said as it happens, not at the end: the timeline is refreshing while this runs, so
676
+ # "reading Outlook" beside rows that are already arriving beats a spinner and a wait
677
+ if progress:
678
+ try: progress(c['Type'], n)
679
+ except Exception: pass
654
680
  full = store.get_connector(c['ConnectorId'], with_secret=True)
655
681
  try:
656
682
  if c['Type'] in ('outlook', 'teams'):
@@ -708,6 +734,7 @@ def poll_channels(store, backfill_days: int = 0) -> int:
708
734
  'external_id': f"graph:{m['id']}", 'channel': 'email',
709
735
  'subject': m.get('subject'), 'body': _body(m),
710
736
  'from_name': frm.get('name'), 'from_email': frm.get('address'),
737
+ 'to': _addrs(m.get('toRecipients')), 'cc': _addrs(m.get('ccRecipients')),
711
738
  'conversation_id': m.get('conversationId'), 'sent_at': _local(m.get('receivedDateTime') or ''),
712
739
  'source_link': m.get('webLink'), 'source_name': s['Address'],
713
740
  'images': images_for_triage(store, atts)}, llm=llm)