taskuary 0.2.1__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.1/taskuary.egg-info → taskuary-0.3.0}/PKG-INFO +34 -7
  2. {taskuary-0.2.1 → taskuary-0.3.0}/README.md +33 -6
  3. {taskuary-0.2.1 → taskuary-0.3.0}/pyproject.toml +1 -1
  4. taskuary-0.3.0/taskuary/__init__.py +27 -0
  5. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/agents.py +14 -6
  6. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/aws.py +76 -3
  7. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/blackboard.py +2 -2
  8. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/channels.py +32 -5
  9. {taskuary-0.2.1 → 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.1 → taskuary-0.3.0}/taskuary/config.py +33 -6
  13. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/github.py +8 -4
  14. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/histgen.py +27 -2
  15. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/imapmail.py +7 -0
  16. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/ingest.py +195 -18
  17. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/outbound.py +40 -0
  18. taskuary-0.3.0/taskuary/promptmap.py +134 -0
  19. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/proof.py +25 -6
  20. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/reports.py +165 -8
  21. taskuary-0.3.0/taskuary/research.py +113 -0
  22. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/responder.py +14 -4
  23. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/routing.py +14 -0
  24. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/scopes.py +3 -0
  25. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/server.py +153 -21
  26. taskuary-0.3.0/taskuary/setup.py +106 -0
  27. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/store.py +58 -16
  28. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/triage.md +4 -0
  29. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/terminal.py +7 -0
  30. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/triage.py +46 -4
  31. {taskuary-0.2.1 → 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.1 → taskuary-0.3.0}/taskuary/web/index.html +5 -5
  34. taskuary-0.3.0/taskuary/webguard.py +79 -0
  35. {taskuary-0.2.1 → taskuary-0.3.0/taskuary.egg-info}/PKG-INFO +34 -7
  36. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/SOURCES.txt +21 -2
  37. {taskuary-0.2.1 → 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.1 → 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.1 → 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.1 → 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.1/taskuary/__init__.py +0 -2
  57. taskuary-0.2.1/taskuary/cli.py +0 -47
  58. taskuary-0.2.1/taskuary/web/assets/index-Cjj87C2X.js +0 -401
  59. taskuary-0.2.1/tests/test_config.py +0 -32
  60. {taskuary-0.2.1 → taskuary-0.3.0}/LICENSE +0 -0
  61. {taskuary-0.2.1 → taskuary-0.3.0}/setup.cfg +0 -0
  62. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/artifacts.py +0 -0
  63. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/azure.py +0 -0
  64. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/coder.py +0 -0
  65. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/db.py +0 -0
  66. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/desktop.py +0 -0
  67. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/devtools.py +0 -0
  68. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/digest.py +0 -0
  69. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/docsync.py +0 -0
  70. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/learn.py +0 -0
  71. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/llm.py +0 -0
  72. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/logs.py +0 -0
  73. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/mcp.py +0 -0
  74. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/messengers.py +0 -0
  75. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/mssql.py +0 -0
  76. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/phone.py +0 -0
  77. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/pm.py +0 -0
  78. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/policy.py +0 -0
  79. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/proposals.py +0 -0
  80. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/reshape.py +0 -0
  81. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/coder.md +0 -0
  82. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/digest.md +0 -0
  83. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/learned.md +0 -0
  84. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/soul.md +0 -0
  85. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/style.md +0 -0
  86. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/toil.py +0 -0
  87. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
  88. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/web/favicon.ico +0 -0
  89. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/web/favicon.png +0 -0
  90. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/whatsapp/bridge.mjs +0 -0
  91. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/whatsapp/package.json +0 -0
  92. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/dependency_links.txt +0 -0
  93. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/entry_points.txt +0 -0
  94. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/requires.txt +0 -0
  95. {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/top_level.txt +0 -0
  96. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_answers.py +0 -0
  97. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_blackboard.py +0 -0
  98. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_cloud_sources.py +0 -0
  99. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_core.py +0 -0
  100. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_desktop.py +0 -0
  101. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_devtools.py +0 -0
  102. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_docs_and_llm.py +0 -0
  103. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_harvest.py +0 -0
  104. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_imapmail.py +0 -0
  105. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_mark_read.py +0 -0
  106. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_mcp.py +0 -0
  107. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_messengers.py +0 -0
  108. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_mssql.py +0 -0
  109. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_not_coding.py +0 -0
  110. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_pm.py +0 -0
  111. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_poll_overlap.py +0 -0
  112. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_poll_sources.py +0 -0
  113. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_proof_ci_proposals.py +0 -0
  114. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_reply_channels.py +0 -0
  115. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_reply_voice.py +0 -0
  116. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_reviews.py +0 -0
  117. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_scopes.py +0 -0
  118. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_sync_clock.py +0 -0
  119. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_task_identity.py +0 -0
  120. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_terminal.py +0 -0
  121. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_toil.py +0 -0
  122. {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_vision.py +0 -0
  123. {taskuary-0.2.1 → 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.1
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
@@ -65,7 +65,7 @@ you already use does it; you approve the result. Runs entirely on your machine.
65
65
 
66
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>
67
67
 
68
- **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
69
69
  sessions and the reports pipeline are all real and in daily use on my own inbox; the edges are
70
70
  still being knocked off, and breaking changes are possible before 1.0. Issues get answered.
71
71
  [Full status and roadmap ↓](#status--roadmap)
@@ -166,6 +166,18 @@ Prefer a desktop app? `pip install "taskuary[desktop]"`
166
166
  then `taskuary-desktop` — the same UI in a native window. A prebuilt single-file
167
167
  `Taskuary.exe` is attached to every CI run.
168
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
+
169
181
  ## The workspace
170
182
 
171
183
  One tab per question, two lines each; the details live in the app's own help text.
@@ -179,7 +191,8 @@ One tab per question, two lines each; the details live in the app's own help tex
179
191
  right now — a live session counts as working, and a session gone quiet moves its card to
180
192
  *waiting on you* with the question showing. Cards working now show a live peephole and
181
193
  the files their agent has modified so far; a queued card says whom it waits behind (see
182
- [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.
183
196
  - **Tasks** — **the page is a terminal**: your CLI in the task's repo, prompt typed in and
184
197
  sent, and you keep talking. Taskuary picks the checkout from the SOUL.md repo map (one
185
198
  click to override); the prompt carries the ask, the mail, the files and the rules, so the
@@ -214,6 +227,17 @@ Two principles hold everywhere: **nothing sends or ships without your approval**
214
227
  it works the mail (auto-dispatch + auto-draft, both switchable); triage is AI-gated, so
215
228
  with no AI connected messages file visibly instead of heuristics spraying tasks.
216
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
+
217
241
  ## Many agents, one repo — no stepping on each other
218
242
 
219
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)
@@ -355,14 +379,17 @@ pyinstaller taskuary.spec # dist/Taskuary.exe - single-file desktop build
355
379
  ```
356
380
 
357
381
  Data lives in `~/.taskuary/` (override with `TASKUARY_HOME`): `taskuary.db` (SQLite),
358
- `config.toml`, `taskuary.log`. For LAN use set `[server].token` in config and send it as
359
- the `X-Taskuary-Token` header. CI runs the test matrix on Windows / Linux / macOS ×
360
- 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
361
388
  exe is built on push to master.
362
389
 
363
390
  ## Status / roadmap
364
391
 
365
- 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
366
393
  out at the bottom.
367
394
 
368
395
  - [x] AI-gated triage, review queue, resumable agent sessions, hash-chained audit
@@ -17,7 +17,7 @@ you already use does it; you approve the result. Runs entirely on your machine.
17
17
 
18
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>
19
19
 
20
- **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
21
21
  sessions and the reports pipeline are all real and in daily use on my own inbox; the edges are
22
22
  still being knocked off, and breaking changes are possible before 1.0. Issues get answered.
23
23
  [Full status and roadmap ↓](#status--roadmap)
@@ -118,6 +118,18 @@ Prefer a desktop app? `pip install "taskuary[desktop]"`
118
118
  then `taskuary-desktop` — the same UI in a native window. A prebuilt single-file
119
119
  `Taskuary.exe` is attached to every CI run.
120
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
+
121
133
  ## The workspace
122
134
 
123
135
  One tab per question, two lines each; the details live in the app's own help text.
@@ -131,7 +143,8 @@ One tab per question, two lines each; the details live in the app's own help tex
131
143
  right now — a live session counts as working, and a session gone quiet moves its card to
132
144
  *waiting on you* with the question showing. Cards working now show a live peephole and
133
145
  the files their agent has modified so far; a queued card says whom it waits behind (see
134
- [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.
135
148
  - **Tasks** — **the page is a terminal**: your CLI in the task's repo, prompt typed in and
136
149
  sent, and you keep talking. Taskuary picks the checkout from the SOUL.md repo map (one
137
150
  click to override); the prompt carries the ask, the mail, the files and the rules, so the
@@ -166,6 +179,17 @@ Two principles hold everywhere: **nothing sends or ships without your approval**
166
179
  it works the mail (auto-dispatch + auto-draft, both switchable); triage is AI-gated, so
167
180
  with no AI connected messages file visibly instead of heuristics spraying tasks.
168
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
+
169
193
  ## Many agents, one repo — no stepping on each other
170
194
 
171
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)
@@ -307,14 +331,17 @@ pyinstaller taskuary.spec # dist/Taskuary.exe - single-file desktop build
307
331
  ```
308
332
 
309
333
  Data lives in `~/.taskuary/` (override with `TASKUARY_HOME`): `taskuary.db` (SQLite),
310
- `config.toml`, `taskuary.log`. For LAN use set `[server].token` in config and send it as
311
- the `X-Taskuary-Token` header. CI runs the test matrix on Windows / Linux / macOS ×
312
- 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
313
340
  exe is built on push to master.
314
341
 
315
342
  ## Status / roadmap
316
343
 
317
- 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
318
345
  out at the bottom.
319
346
 
320
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.1"
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()
@@ -197,16 +197,24 @@ def task_context(store, task_id: int) -> str:
197
197
  lines += [f"- [{m.get('SentAt')}] {m.get('FromName') or m.get('FromEmail')}: {m.get('Subject') or ''}",
198
198
  f" {str(m.get('BodyText') or '')[:1500]}"]
199
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]
200
202
  return '\n'.join(lines)
201
203
 
202
204
 
203
205
  def memory_block(store, messages: list) -> str:
204
- senders = {(m.get('FromEmail') or '').lower() for m in messages if m.get('FromEmail')}
205
- domains = {s.rsplit('@', 1)[-1] for s in senders if '@' in s}
206
- hits = [f"- {n['Note']}" for n in store.list_memories()
207
- if n['Scope'] == 'global' or (n['Scope'] == 'sender' and (n.get('ScopeKey') or '').lower() in senders)
208
- or (n['Scope'] == 'sender_domain' and (n.get('ScopeKey') or '').lower() in domains)]
209
- 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 ''))
210
218
 
211
219
 
212
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)
@@ -15,6 +15,7 @@ from datetime import datetime
15
15
  from loguru import logger
16
16
 
17
17
  PR_MARK = 'PULL REQUEST' # comment marker: 'PULL REQUEST {json}'
18
+ PRC_MARK = 'PR COMMENT' # a human's comment on that PR, kept on the task's own thread
18
19
  PUSH_MARK = 'PUSHED' # the same, for work landed straight on the default branch
19
20
  CI_MARK = 'CI FEEDBACK' # what we have already handed back, so it goes back ONCE
20
21
  BRANCH_SAFE = re.compile(r'[^a-zA-Z0-9._/-]+')
@@ -221,10 +222,64 @@ def check_task(store, task_id: int, llm=None) -> dict:
221
222
  return {'state': 'failure', 'fed': True, 'handed': handed, 'at': ref, 'kind': at['kind']}
222
223
 
223
224
 
225
+ def pull_comments(store, task_id: int, at: dict) -> int:
226
+ """Somebody commented on our pull request - put it on the TIMELINE, on this task.
227
+
228
+ github.pr_review_comments has existed since the CI watcher was written, its docstring
229
+ calling review comments "the other thing that should reach the agent", and nothing has ever
230
+ called it. So a reviewer could ask for a change and the only place it existed was GitHub:
231
+ the task sat there looking finished, and the owner found out by remembering to go and look.
232
+
233
+ Each comment becomes a message ON THE SAME TASK - it is the same piece of work, not a new
234
+ one - which is what puts the task back in front of the owner as needs-you. A live session
235
+ is told as well, because a reviewer's note is exactly what the agent should act on next."""
236
+ from . import github, terminal as hub_term
237
+ from .store import task_ref
238
+ if at.get('kind') != 'pr': return 0
239
+ try:
240
+ c = _conn(store)
241
+ comments = github.pr_review_comments(c['Secret'], at['repo'], at['number'])
242
+ except Exception as e:
243
+ logger.warning(f'reading PR comments for task {task_id} failed: {e}')
244
+ return 0
245
+ n, newest = 0, None
246
+ for cm in comments:
247
+ ext = f"ghc:{at['repo']}#{at['number']}:{cm.get('id')}"
248
+ if not cm.get('id') or store.message_exists(ext): continue
249
+ where = f" on {cm['path']}" if cm.get('path') else ''
250
+ body = f"{cm['who']} commented{where} on pull request #{at['number']}:\n\n{cm['body']}"
251
+ mid = store.add_message({
252
+ 'TaskId': task_id, 'ExternalId': ext, 'Channel': 'github',
253
+ 'ConversationId': f"pr:{at['repo']}#{at['number']}", 'SourceName': at['repo'],
254
+ 'Subject': f"PR #{at['number']} - {cm['kind']} comment from {cm['who']}",
255
+ 'FromName': cm['who'], 'FromEmail': f"{cm['who']}@users.noreply.github.com",
256
+ 'SentAt': str(cm.get('at') or '').replace('T', ' ').rstrip('Z'),
257
+ 'BodyText': body, 'SourceLink': cm.get('url') or at.get('url'), 'Status': 'routed'})
258
+ store.add_route(mid, task_id, 'attach', None,
259
+ f"a human commented on the pull request for {task_ref(task_id)} - "
260
+ 'time to look at it again', [], 'github')
261
+ newest, n = body, n + 1
262
+ if newest:
263
+ store.add_comment(task_id, 'github', 'agent', f'{PRC_MARK} {newest[:400]}')
264
+ # a live session gets it typed in; with nobody at the keyboard the task goes back to open,
265
+ # so the timeline row it just grew is not the only thing carrying the news
266
+ if not hub_term.say_to_task(store, task_id, {'FromName': 'GitHub', 'Channel': 'github',
267
+ 'BodyText': newest}, 'github'):
268
+ store.update_task(task_id, {'Status': 'open'}, 'github')
269
+ store.audit('task', task_id, 'pr_comments', 'github', detail={'new': n, 'pr': at.get('number')})
270
+ return n
271
+
272
+
224
273
  def poll(store, llm=None) -> int:
225
- """Every task whose work has landed - PR or direct push - checked once. Called from the
226
- same sync as everything else; off unless the owner turned ci_watch on."""
227
- if store.get_settings().get('ci_watch', 'off') == 'off': return 0
274
+ """Every task whose work has landed - PR or direct push - looked at once. Called from the
275
+ same sync as everything else.
276
+
277
+ Two different things, and only one of them is opt-in. A human commenting on our pull request
278
+ is INBOUND WORK arriving, which is the whole point of the app, so it lands on the timeline
279
+ whatever the settings say. Handing a RED BUILD to a running agent is the automation, and that
280
+ is what ci_watch gates - it ships off, and gating the comments behind it too would have meant
281
+ the default install never told anybody their PR had been reviewed."""
282
+ watch = store.get_settings().get('ci_watch', 'off') != 'off'
228
283
  n = 0
229
284
  for t in store.list_tasks():
230
285
  if t['Status'] in ('done', 'dropped'): continue
@@ -232,6 +287,11 @@ def poll(store, llm=None) -> int:
232
287
  # a merged/closed PR is finished business; a direct push is only ever checked while
233
288
  # its checks could still be running, which the state below settles
234
289
  if not at or at.get('state') == 'closed': continue
290
+ try:
291
+ n += pull_comments(store, t['TaskId'], at)
292
+ except Exception as e:
293
+ logger.warning(f'PR comments for task {t["TaskId"]} failed: {e}')
294
+ if not watch: continue
235
295
  out = check_task(store, t['TaskId'], llm)
236
296
  n += 1 if out.get('fed') else 0
237
297
  return n
@@ -0,0 +1,73 @@
1
+ """`taskuary` - start the local server and open the app. Everything lives in ~/.taskuary."""
2
+ import argparse, socket, threading, time, webbrowser
3
+ import uvicorn
4
+ from . import __version__, config
5
+
6
+
7
+ def public_url(host, port) -> str:
8
+ """0.0.0.0 / :: are bind addresses, not a place a browser can go."""
9
+ shown = '127.0.0.1' if host in ('0.0.0.0', '::') else host
10
+ return f'http://{shown}:{port}'
11
+
12
+ def _busy(host, port):
13
+ probe = '127.0.0.1' if host in ('0.0.0.0', '::') else host
14
+ with socket.socket() as s: return s.connect_ex((probe, port)) == 0
15
+
16
+ def _is_taskuary(url):
17
+ try:
18
+ import requests
19
+ r = requests.get(f'{url}/api/health', timeout=2)
20
+ if r.status_code == 200 and (r.json() or {}).get('ok') is True: return True
21
+ return requests.get(f'{url}/api/settings', timeout=2).status_code in (200, 401)
22
+ except Exception:
23
+ return False
24
+
25
+
26
+ def main():
27
+ ap = argparse.ArgumentParser(prog='taskuary', description='Automate your job - the local-first agent work hub.')
28
+ ap.add_argument('--host', help='override [server].host (0.0.0.0 to listen on all interfaces)')
29
+ ap.add_argument('--port', type=int, help='override [server].port')
30
+ ap.add_argument('--no-browser', action='store_true')
31
+ ap.add_argument('--debug', action='store_true', help='verbose console logging (requests, report runs, errors)')
32
+ ap.add_argument('--version', action='version', version=f'taskuary {__version__}')
33
+ # "what is actually in the prompt?" had no answer short of reading the code that builds it,
34
+ # which is not a reasonable thing to ask of the person whose judgement is being automated
35
+ ap.add_argument('--prompts', nargs='?', const='', metavar='MESSAGE_ID',
36
+ help='print the triage, reply and coding-agent prompts for a real item on '
37
+ 'this machine - every block labelled with the document or table it '
38
+ 'came from - then exit. Optionally for one message id.')
39
+ args = ap.parse_args()
40
+ if args.prompts is not None:
41
+ import sys
42
+ from .promptmap import render
43
+ from .store import SQLiteStore
44
+ # the Windows console is cp1252 and the operator documents are full of em dashes, so this
45
+ # would die on the owner's OWN text before it printed a line of it
46
+ try: sys.stdout.reconfigure(encoding='utf-8', errors='replace')
47
+ except (AttributeError, OSError): pass
48
+ mid = int(args.prompts) if str(args.prompts).strip().isdigit() else None
49
+ print(render(SQLiteStore(config.db_path()), message_id=mid))
50
+ return
51
+ from .logs import setup as setup_logs
52
+ setup_logs(args.debug)
53
+ cfg = config.load()
54
+ host, port = args.host or cfg['server']['host'], args.port or cfg['server']['port']
55
+ url = public_url(host, port)
56
+ if _busy(host, port):
57
+ if _is_taskuary(url):
58
+ # don't crash into 'address already in use' - reuse the running instance
59
+ print(f'Taskuary is already running at {url} - opening it.')
60
+ if not args.no_browser: webbrowser.open(url)
61
+ return
62
+ from .desktop import free_port
63
+ old, port = port, free_port(host)
64
+ url = public_url(host, port)
65
+ print(f'port {old} is in use by something else - using {port} instead')
66
+ print(f'Taskuary {__version__} - {url} (data: {config.db_path()})')
67
+ if not args.no_browser:
68
+ threading.Thread(target=lambda: (time.sleep(1.2), webbrowser.open(url)), daemon=True).start()
69
+ uvicorn.run('taskuary.server:app', host=host, port=port, log_level='warning')
70
+
71
+
72
+ if __name__ == '__main__':
73
+ main()