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.
- {taskuary-0.2.0/taskuary.egg-info → taskuary-0.3.0}/PKG-INFO +41 -13
- {taskuary-0.2.0 → taskuary-0.3.0}/README.md +40 -12
- {taskuary-0.2.0 → taskuary-0.3.0}/pyproject.toml +1 -1
- taskuary-0.3.0/taskuary/__init__.py +27 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/agents.py +18 -7
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/aws.py +76 -3
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/blackboard.py +2 -2
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/channels.py +32 -5
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/ci.py +63 -3
- taskuary-0.3.0/taskuary/cli.py +73 -0
- taskuary-0.3.0/taskuary/clis.py +49 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/config.py +33 -6
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/github.py +8 -4
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/histgen.py +27 -2
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/imapmail.py +7 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/ingest.py +195 -18
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/outbound.py +40 -0
- taskuary-0.3.0/taskuary/promptmap.py +134 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/proof.py +25 -6
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/reports.py +165 -8
- taskuary-0.3.0/taskuary/research.py +113 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/responder.py +14 -4
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/routing.py +14 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/scopes.py +3 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/server.py +153 -21
- taskuary-0.3.0/taskuary/setup.py +106 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/store.py +58 -16
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/triage.md +4 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/terminal.py +22 -3
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/triage.py +46 -4
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/verdicts.py +23 -0
- taskuary-0.3.0/taskuary/web/assets/index-QtBkB3cc.js +405 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/web/index.html +5 -5
- taskuary-0.3.0/taskuary/webguard.py +79 -0
- {taskuary-0.2.0 → taskuary-0.3.0/taskuary.egg-info}/PKG-INFO +41 -13
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/SOURCES.txt +21 -2
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_api.py +40 -2
- taskuary-0.3.0/tests/test_audit_chain.py +83 -0
- taskuary-0.3.0/tests/test_aws_region.py +101 -0
- taskuary-0.3.0/tests/test_cc_triage.py +149 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_cloud.py +2 -1
- taskuary-0.3.0/tests/test_config.py +91 -0
- taskuary-0.3.0/tests/test_cwlogs_paging.py +103 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_diff_review.py +68 -2
- taskuary-0.3.0/tests/test_docs_flow.py +121 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_histgen.py +47 -0
- taskuary-0.3.0/tests/test_local_file.py +123 -0
- taskuary-0.3.0/tests/test_note_retrieval.py +137 -0
- taskuary-0.3.0/tests/test_outbound_reports.py +143 -0
- taskuary-0.3.0/tests/test_pr_comments.py +115 -0
- taskuary-0.3.0/tests/test_promptmap_and_catalog.py +138 -0
- taskuary-0.3.0/tests/test_research.py +151 -0
- taskuary-0.3.0/tests/test_setup.py +283 -0
- taskuary-0.3.0/tests/test_verdict_sticks.py +269 -0
- taskuary-0.3.0/tests/test_webguard.py +119 -0
- taskuary-0.2.0/taskuary/__init__.py +0 -2
- taskuary-0.2.0/taskuary/cli.py +0 -47
- taskuary-0.2.0/taskuary/web/assets/index-Cjj87C2X.js +0 -401
- taskuary-0.2.0/tests/test_config.py +0 -32
- {taskuary-0.2.0 → taskuary-0.3.0}/LICENSE +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/setup.cfg +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/artifacts.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/azure.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/coder.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/db.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/desktop.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/devtools.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/digest.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/docsync.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/learn.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/llm.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/logs.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/mcp.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/messengers.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/mssql.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/phone.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/pm.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/policy.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/proposals.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/reshape.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/coder.md +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/digest.md +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/learned.md +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/soul.md +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/templates/style.md +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/toil.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/web/favicon.ico +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/web/favicon.png +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/whatsapp/bridge.mjs +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary/whatsapp/package.json +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/dependency_links.txt +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/entry_points.txt +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/requires.txt +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/taskuary.egg-info/top_level.txt +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_answers.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_blackboard.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_cloud_sources.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_core.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_desktop.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_devtools.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_docs_and_llm.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_harvest.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_imapmail.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_mark_read.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_mcp.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_messengers.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_mssql.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_not_coding.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_pm.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_poll_overlap.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_poll_sources.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_proof_ci_proposals.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_reply_channels.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_reply_voice.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_reviews.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_scopes.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_sync_clock.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_task_identity.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_terminal.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_toil.py +0 -0
- {taskuary-0.2.0 → taskuary-0.3.0}/tests/test_vision.py +0 -0
- {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.
|
|
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
|
[](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml)
|
|
52
52
|
[](LICENSE)
|
|
53
53
|
[](https://github.com/ldbumble/taskuary)
|
|
54
|
+
[](https://pypi.org/project/taskuary/)
|
|
54
55
|
[](CONTRIBUTING.md)
|
|
55
56
|
[](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
|
-

|
|
64
|
+

|
|
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.
|
|
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
|
-

|
|
96
|
+

|
|
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
|
|
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]
|
|
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
|
+

|
|
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
|
-

|
|
243
|
+

|
|
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
|
-

|
|
293
|
+

|
|
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`.
|
|
358
|
-
|
|
359
|
-
|
|
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.
|
|
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
|
[](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://github.com/ldbumble/taskuary)
|
|
6
|
+
[](https://pypi.org/project/taskuary/)
|
|
6
7
|
[](CONTRIBUTING.md)
|
|
7
8
|
[](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
|
-

|
|
16
|
+

|
|
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.
|
|
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
|
-

|
|
48
|
+

|
|
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
|
|
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]
|
|
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
|
+

|
|
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
|
-

|
|
195
|
+

|
|
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
|
-

|
|
245
|
+

|
|
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`.
|
|
310
|
-
|
|
311
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]) >=
|
|
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
|
-
|
|
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)
|