taskuary 0.2.0__py3-none-any.whl

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 (62) hide show
  1. taskuary/__init__.py +2 -0
  2. taskuary/agents.py +218 -0
  3. taskuary/artifacts.py +209 -0
  4. taskuary/aws.py +226 -0
  5. taskuary/azure.py +334 -0
  6. taskuary/blackboard.py +150 -0
  7. taskuary/channels.py +768 -0
  8. taskuary/ci.py +237 -0
  9. taskuary/cli.py +47 -0
  10. taskuary/coder.py +132 -0
  11. taskuary/config.py +62 -0
  12. taskuary/db.py +63 -0
  13. taskuary/desktop.py +72 -0
  14. taskuary/devtools.py +352 -0
  15. taskuary/digest.py +104 -0
  16. taskuary/docsync.py +124 -0
  17. taskuary/github.py +136 -0
  18. taskuary/histgen.py +220 -0
  19. taskuary/imapmail.py +150 -0
  20. taskuary/ingest.py +344 -0
  21. taskuary/learn.py +181 -0
  22. taskuary/llm.py +175 -0
  23. taskuary/logs.py +17 -0
  24. taskuary/mcp.py +78 -0
  25. taskuary/messengers.py +205 -0
  26. taskuary/mssql.py +64 -0
  27. taskuary/outbound.py +221 -0
  28. taskuary/phone.py +88 -0
  29. taskuary/pm.py +310 -0
  30. taskuary/policy.py +59 -0
  31. taskuary/proof.py +194 -0
  32. taskuary/proposals.py +132 -0
  33. taskuary/reports.py +508 -0
  34. taskuary/reshape.py +201 -0
  35. taskuary/responder.py +199 -0
  36. taskuary/routing.py +115 -0
  37. taskuary/scopes.py +88 -0
  38. taskuary/server.py +1506 -0
  39. taskuary/store.py +711 -0
  40. taskuary/templates/coder.md +35 -0
  41. taskuary/templates/digest.md +5 -0
  42. taskuary/templates/learned.md +31 -0
  43. taskuary/templates/soul.md +44 -0
  44. taskuary/templates/style.md +14 -0
  45. taskuary/templates/triage.md +22 -0
  46. taskuary/terminal.py +971 -0
  47. taskuary/toil.py +57 -0
  48. taskuary/triage.py +123 -0
  49. taskuary/verdicts.py +73 -0
  50. taskuary/web/assets/index-6GBZ9nXN.css +32 -0
  51. taskuary/web/assets/index-Cjj87C2X.js +401 -0
  52. taskuary/web/favicon.ico +0 -0
  53. taskuary/web/favicon.png +0 -0
  54. taskuary/web/index.html +25 -0
  55. taskuary/whatsapp/bridge.mjs +101 -0
  56. taskuary/whatsapp/package.json +11 -0
  57. taskuary-0.2.0.dist-info/METADATA +424 -0
  58. taskuary-0.2.0.dist-info/RECORD +62 -0
  59. taskuary-0.2.0.dist-info/WHEEL +5 -0
  60. taskuary-0.2.0.dist-info/entry_points.txt +3 -0
  61. taskuary-0.2.0.dist-info/licenses/LICENSE +21 -0
  62. taskuary-0.2.0.dist-info/top_level.txt +1 -0
taskuary/devtools.py ADDED
@@ -0,0 +1,352 @@
1
+ """Developer-tool inboxes: GitLab, Azure DevOps, Linear, Trello, Notion, Discord, Sentry
2
+ and PagerDuty as trigger channels - same shape as pm.py. The item that matters is the one
3
+ POINTED AT YOU (assigned issue/MR/work item/card, a new unresolved error, an open incident,
4
+ a message in a watched channel); it lands on the Timeline and goes through the same triage
5
+ as mail. Each polls its own API with the owner's token; only Discord can carry a reply back
6
+ (a bot may post) - everything else is read-only by nature.
7
+ """
8
+ import json
9
+ from datetime import datetime
10
+ import requests
11
+ from loguru import logger
12
+
13
+ from .pm import CAP, _cfg, _seed_source, _stamp
14
+
15
+
16
+ def _die(r, who, hint):
17
+ if r.status_code in (401, 403): raise RuntimeError(f'{who} said {r.status_code} - {hint}')
18
+ r.raise_for_status()
19
+
20
+
21
+ def _new(since): return since.strftime('%Y-%m-%d %H:%M:%S') # local wall-clock floor
22
+
23
+
24
+ # ── GitLab ───────────────────────────────────────────────────────────────────────────────
25
+ def _gitlab(c, path, **params):
26
+ base = (_cfg(c).get('base_url') or 'https://gitlab.com').strip().rstrip('/')
27
+ r = requests.get(f'{base}/api/v4{path}', params=params, timeout=30,
28
+ headers={'PRIVATE-TOKEN': c.get('Secret') or ''})
29
+ _die(r, 'GitLab', 'check the Personal Access Token (scope: read_api)')
30
+ return r.json()
31
+
32
+
33
+ def test_gitlab(store, c) -> str:
34
+ if not c.get('Secret'): raise RuntimeError('no token saved - GitLab: avatar → Preferences → Access tokens (scope read_api)')
35
+ me = _gitlab(c, '/user')
36
+ host = (_cfg(c).get('base_url') or 'https://gitlab.com').split('//', 1)[-1].rstrip('/')
37
+ _seed_source(store, c, host)
38
+ return f"authenticated as {me.get('name') or me.get('username')} on {host} - issues and MRs assigned to you flow in on the next sync"
39
+
40
+
41
+ def poll_gitlab(store, c, since, llm=None, file_only=False) -> int:
42
+ from .ingest import ingest_message
43
+ after, n = since.astimezone().isoformat(), 0
44
+ items = ([('issue', i) for i in _gitlab(c, '/issues', scope='assigned_to_me', updated_after=after, per_page=CAP)]
45
+ + [('merge request', m) for m in _gitlab(c, '/merge_requests', scope='assigned_to_me', updated_after=after, per_page=CAP)])
46
+ for kind, i in items:
47
+ who = (i.get('author') or {})
48
+ proj = '/'.join(str(i.get('web_url') or '').split('/')[3:5]) or 'GitLab'
49
+ out = ingest_message(store, file_only=file_only, msg={
50
+ 'external_id': f"gitlab:{kind[0]}{i['id']}", 'channel': 'gitlab',
51
+ 'subject': f"{'!' if kind[0] == 'm' else '#'}{i.get('iid')} {i.get('title') or ''}".strip(),
52
+ 'body': f"[GitLab {kind} in {proj} - state {i.get('state')} - assigned to you]\n"
53
+ f"{str(i.get('description') or '(no description)')[:20000]}",
54
+ 'from_name': who.get('name') or 'GitLab', 'conversation_id': f"gitlab:{kind[0]}{i['id']}",
55
+ 'sent_at': _stamp(i.get('updated_at')), 'source_link': i.get('web_url'), 'source_name': proj}, llm=llm)
56
+ n += out['status'] != 'duplicate'
57
+ return n
58
+
59
+
60
+ # ── Azure DevOps ─────────────────────────────────────────────────────────────────────────
61
+ def _azdo_base(c) -> str:
62
+ org = (_cfg(c).get('org_url') or '').strip().rstrip('/')
63
+ if not org: raise RuntimeError('no organization URL set - enter https://dev.azure.com/yourorg under Credentials')
64
+ return org if org.startswith('http') else f'https://dev.azure.com/{org}'
65
+
66
+
67
+ def _azdo(c, method, path, **kw):
68
+ r = requests.request(method, f'{_azdo_base(c)}{path}', timeout=30, auth=('', c.get('Secret') or ''), **kw)
69
+ _die(r, 'Azure DevOps', 'check the PAT (scope: Work Items read)')
70
+ return r.json()
71
+
72
+
73
+ def test_azdo(store, c) -> str:
74
+ if not c.get('Secret'): raise RuntimeError('no PAT saved - Azure DevOps: User settings → Personal access tokens (Work Items: Read)')
75
+ j = _azdo(c, 'get', '/_apis/projects?api-version=7.0')
76
+ _seed_source(store, c, _azdo_base(c).split('//', 1)[-1])
77
+ return f"authenticated - {j.get('count', 0)} project(s) visible - work items assigned to you flow in on the next sync"
78
+
79
+
80
+ def poll_azdo(store, c, since, llm=None, file_only=False) -> int:
81
+ from .ingest import ingest_message
82
+ # WIQL dates are day-precision, so the query over-fetches and the watermark filters below
83
+ wiql = ("Select [System.Id] From WorkItems Where [System.AssignedTo] = @Me "
84
+ f"And [System.ChangedDate] >= '{since.strftime('%Y-%m-%d')}' Order By [System.ChangedDate] Asc")
85
+ ids = [str(w['id']) for w in _azdo(c, 'post', '/_apis/wit/wiql?api-version=7.0', json={'query': wiql}).get('workItems') or []][:CAP]
86
+ if not ids: return 0
87
+ j = _azdo(c, 'get', f"/_apis/wit/workitems?ids={','.join(ids)}&api-version=7.0")
88
+ base, n = _azdo_base(c), 0
89
+ for w in j.get('value') or []:
90
+ f = w.get('fields') or {}
91
+ if _stamp(f.get('System.ChangedDate')) < _new(since): continue
92
+ proj = f.get('System.TeamProject') or ''
93
+ out = ingest_message(store, file_only=file_only, msg={
94
+ 'external_id': f"azdo:{w['id']}", 'channel': 'azdo',
95
+ 'subject': f"#{w['id']} {f.get('System.Title') or ''}".strip(),
96
+ 'body': f"[Azure DevOps {f.get('System.WorkItemType') or 'work item'} in {proj} - "
97
+ f"state {f.get('System.State')} - assigned to you]\n"
98
+ f"{str(f.get('System.Description') or '(no description)')[:20000]}",
99
+ 'from_name': ((f.get('System.CreatedBy') or {}).get('displayName')
100
+ if isinstance(f.get('System.CreatedBy'), dict) else f.get('System.CreatedBy')) or 'Azure DevOps',
101
+ 'conversation_id': f"azdo:{w['id']}", 'sent_at': _stamp(f.get('System.ChangedDate')),
102
+ 'source_link': f'{base}/{proj}/_workitems/edit/{w["id"]}', 'source_name': proj or 'Azure DevOps'}, llm=llm)
103
+ n += out['status'] != 'duplicate'
104
+ return n
105
+
106
+
107
+ # ── Linear ───────────────────────────────────────────────────────────────────────────────
108
+ def _linear(c, query: str):
109
+ r = requests.post('https://api.linear.app/graphql', json={'query': query}, timeout=30,
110
+ headers={'Authorization': c.get('Secret') or ''})
111
+ _die(r, 'Linear', 'check the API key (Settings → Security & access → Personal API keys)')
112
+ j = r.json()
113
+ if j.get('errors'): raise RuntimeError(f"linear: {j['errors'][0].get('message') or j['errors']}")
114
+ return j['data']
115
+
116
+
117
+ def test_linear(store, c) -> str:
118
+ if not c.get('Secret'): raise RuntimeError('no API key saved - Linear: Settings → Security & access → Personal API keys')
119
+ me = _linear(c, '{ viewer { name email } }')['viewer']
120
+ _seed_source(store, c, me.get('name') or 'Linear')
121
+ return f"authenticated as {me.get('name')} - issues assigned to you flow in on the next sync"
122
+
123
+
124
+ def poll_linear(store, c, since, llm=None, file_only=False) -> int:
125
+ from .ingest import ingest_message
126
+ iso = since.astimezone().isoformat()
127
+ data = _linear(c, '{ issues(filter: {assignee: {isMe: {eq: true}}, updatedAt: {gt: "%s"}}, first: %d) '
128
+ '{ nodes { identifier title description url updatedAt state { name } '
129
+ 'creator { name } project { name } } } }' % (iso, CAP))
130
+ n = 0
131
+ for i in (data.get('issues') or {}).get('nodes') or []:
132
+ proj = (i.get('project') or {}).get('name')
133
+ out = ingest_message(store, file_only=file_only, msg={
134
+ 'external_id': f"linear:{i['identifier']}", 'channel': 'linear',
135
+ 'subject': f"{i['identifier']} {i.get('title') or ''}".strip(),
136
+ 'body': f"[Linear issue{f' in {proj}' if proj else ''} - state {((i.get('state') or {}).get('name'))} - assigned to you]\n"
137
+ f"{str(i.get('description') or '(no description)')[:20000]}",
138
+ 'from_name': ((i.get('creator') or {}).get('name')) or 'Linear',
139
+ 'conversation_id': f"linear:{i['identifier']}", 'sent_at': _stamp(i.get('updatedAt')),
140
+ 'source_link': i.get('url'), 'source_name': proj or 'Linear'}, llm=llm)
141
+ n += out['status'] != 'duplicate'
142
+ return n
143
+
144
+
145
+ # ── Trello ───────────────────────────────────────────────────────────────────────────────
146
+ def _trello(c, path, **params):
147
+ r = requests.get(f'https://api.trello.com/1{path}', timeout=30,
148
+ params={**params, 'key': _cfg(c).get('api_key') or '', 'token': c.get('Secret') or ''})
149
+ _die(r, 'Trello', 'check the API key + token (trello.com/power-ups/admin → API key → Token)')
150
+ return r.json()
151
+
152
+
153
+ def test_trello(store, c) -> str:
154
+ if not (_cfg(c).get('api_key') and c.get('Secret')):
155
+ raise RuntimeError('need the API key (under Credentials) AND the token (write-only) - trello.com/power-ups/admin')
156
+ me = _trello(c, '/members/me', fields='fullName,username')
157
+ _seed_source(store, c, me.get('fullName') or me.get('username') or 'Trello')
158
+ return f"authenticated as {me.get('fullName') or me.get('username')} - cards assigned to you flow in on the next sync"
159
+
160
+
161
+ def poll_trello(store, c, since, llm=None, file_only=False) -> int:
162
+ from .ingest import ingest_message
163
+ cards = _trello(c, '/members/me/cards', filter='open',
164
+ fields='name,desc,dateLastActivity,url,idBoard', boards='open', board_fields='name')
165
+ n = 0
166
+ for t in cards[:200]:
167
+ if _stamp(t.get('dateLastActivity')) < _new(since): continue
168
+ board = ((t.get('board') or {}).get('name')) or 'Trello'
169
+ out = ingest_message(store, file_only=file_only, msg={
170
+ 'external_id': f"trello:{t['id']}", 'channel': 'trello',
171
+ 'subject': (t.get('name') or '').strip() or 'Trello card',
172
+ 'body': f"[Trello card on \"{board}\" - assigned to you]\n{str(t.get('desc') or '(no description)')[:20000]}",
173
+ 'from_name': 'Trello', 'conversation_id': f"trello:{t['id']}",
174
+ 'sent_at': _stamp(t.get('dateLastActivity')), 'source_link': t.get('url'), 'source_name': board}, llm=llm)
175
+ n += out['status'] != 'duplicate'
176
+ return n
177
+
178
+
179
+ # ── Notion ───────────────────────────────────────────────────────────────────────────────
180
+ def _notion(c, method, path, **kw):
181
+ r = requests.request(method, f'https://api.notion.com/v1{path}', timeout=30,
182
+ headers={'Authorization': f"Bearer {c.get('Secret') or ''}", 'Notion-Version': '2022-06-28'}, **kw)
183
+ _die(r, 'Notion', 'check the integration secret (notion.so/my-integrations) - and share the pages with the integration')
184
+ return r.json()
185
+
186
+
187
+ def _notion_title(page) -> str:
188
+ for p in (page.get('properties') or {}).values():
189
+ if p.get('type') == 'title':
190
+ return ''.join(t.get('plain_text') or '' for t in p.get('title') or []) or 'Untitled'
191
+ return 'Untitled'
192
+
193
+
194
+ def test_notion(store, c) -> str:
195
+ if not c.get('Secret'): raise RuntimeError('no integration secret saved - create one at notion.so/my-integrations, then share pages with it')
196
+ me = _notion(c, 'get', '/users/me')
197
+ _seed_source(store, c, me.get('name') or 'Notion')
198
+ return (f"authenticated as {me.get('name') or 'the integration'} - pages shared with it show on the "
199
+ 'Timeline as they change (a feed: Notion edits are information, not assignments)')
200
+
201
+
202
+ def poll_notion(store, c, since, llm=None, file_only=False) -> int:
203
+ from .ingest import ingest_message
204
+ j = _notion(c, 'post', '/search', json={'filter': {'property': 'object', 'value': 'page'},
205
+ 'sort': {'direction': 'descending', 'timestamp': 'last_edited_time'},
206
+ 'page_size': CAP})
207
+ n = 0
208
+ for p in j.get('results') or []:
209
+ at = _stamp(p.get('last_edited_time'))
210
+ if at < _new(since): continue
211
+ out = ingest_message(store, file_only=file_only, msg={
212
+ 'external_id': f"notion:{p['id']}:{str(p.get('last_edited_time') or '')[:16]}", 'channel': 'notion',
213
+ 'subject': _notion_title(p), 'body': f'[Notion page edited]\n{p.get("url") or ""}',
214
+ 'from_name': 'Notion', 'conversation_id': f"notion:{p['id']}",
215
+ 'sent_at': at, 'source_link': p.get('url'), 'source_name': 'Notion'}, llm=llm)
216
+ n += out['status'] != 'duplicate'
217
+ return n
218
+
219
+
220
+ # ── Discord ──────────────────────────────────────────────────────────────────────────────
221
+ DISCORD = 'https://discord.com/api/v10'
222
+
223
+ def _discord(c, method, path, **kw):
224
+ r = requests.request(method, f'{DISCORD}{path}', timeout=30,
225
+ headers={'Authorization': f"Bot {c.get('Secret') or ''}"}, **kw)
226
+ _die(r, 'Discord', 'check the bot token - and the bot needs the Message Content intent plus access to the channel')
227
+ return r.json()
228
+
229
+
230
+ def test_discord(store, c) -> str:
231
+ if not c.get('Secret'): raise RuntimeError('no bot token saved - discord.com/developers → your app → Bot → Reset Token')
232
+ me = _discord(c, 'get', '/users/@me')
233
+ return (f"authenticated as {me.get('username')} - add channel IDs under Sources "
234
+ '(right-click a channel → Copy Channel ID, with Developer Mode on)')
235
+
236
+
237
+ def poll_discord(store, c, src, since, llm=None, file_only=False) -> int:
238
+ """Per-source, like Slack: each watched channel id is one source."""
239
+ from .ingest import ingest_message
240
+ msgs = _discord(c, 'get', f"/channels/{src['Address']}/messages", params={'limit': CAP})
241
+ n = 0
242
+ for m in reversed(msgs if isinstance(msgs, list) else []):
243
+ if (m.get('author') or {}).get('bot'): continue
244
+ at = _stamp(m.get('timestamp'))
245
+ if at < _new(since) or not (m.get('content') or '').strip(): continue
246
+ who = m.get('author') or {}
247
+ out = ingest_message(store, file_only=file_only, msg={
248
+ 'external_id': f"discord:{m['id']}", 'channel': 'discord',
249
+ 'subject': None, 'body': m.get('content'),
250
+ 'from_name': who.get('global_name') or who.get('username') or 'Discord',
251
+ 'conversation_id': f"discord:{src['Address']}", 'sent_at': at,
252
+ 'source_name': src['Address']}, llm=llm)
253
+ n += out['status'] != 'duplicate'
254
+ return n
255
+
256
+
257
+ def discord_send(store, channel_id: str, body: str) -> dict:
258
+ c = store.get_connector_by_type('discord', with_secret=True)
259
+ if not (c and c.get('Secret')): raise RuntimeError('no Discord bot token saved')
260
+ _discord(c, 'post', f'/channels/{channel_id}/messages', json={'content': body[:2000]})
261
+ return {'channel': 'discord', 'chat': channel_id}
262
+
263
+
264
+ # ── Sentry ───────────────────────────────────────────────────────────────────────────────
265
+ def _sentry(c, path, **params):
266
+ base = (_cfg(c).get('base_url') or 'https://sentry.io').strip().rstrip('/')
267
+ r = requests.get(f'{base}{path}', params=params, timeout=30,
268
+ headers={'Authorization': f"Bearer {c.get('Secret') or ''}"})
269
+ _die(r, 'Sentry', 'check the auth token (Settings → Auth Tokens; scopes org:read + event:read)')
270
+ return r.json()
271
+
272
+
273
+ def _sentry_org(c) -> str:
274
+ org = (_cfg(c).get('org') or '').strip()
275
+ if not org: raise RuntimeError('no organization slug set - it is the first path segment of your Sentry URLs')
276
+ return org
277
+
278
+
279
+ def test_sentry(store, c) -> str:
280
+ if not c.get('Secret'): raise RuntimeError('no auth token saved - Sentry: Settings → Auth Tokens')
281
+ j = _sentry(c, f'/api/0/organizations/{_sentry_org(c)}/')
282
+ _seed_source(store, c, j.get('slug') or _sentry_org(c))
283
+ return f"authenticated to {j.get('name') or j.get('slug')} - new unresolved errors flow in on the next sync"
284
+
285
+
286
+ def poll_sentry(store, c, since, llm=None, file_only=False) -> int:
287
+ from .ingest import ingest_message
288
+ rows = _sentry(c, f'/api/0/organizations/{_sentry_org(c)}/issues/',
289
+ query='is:unresolved', sort='date', limit=CAP, statsPeriod='14d')
290
+ n = 0
291
+ for i in rows if isinstance(rows, list) else []:
292
+ at = _stamp(i.get('lastSeen'))
293
+ if at < _new(since): continue
294
+ proj = ((i.get('project') or {}).get('slug')) or 'sentry'
295
+ out = ingest_message(store, file_only=file_only, msg={
296
+ 'external_id': f"sentry:{i['id']}:{at[:16]}", 'channel': 'sentry',
297
+ 'subject': f"{i.get('shortId')} {i.get('title') or ''}".strip(),
298
+ 'body': f"[Sentry {i.get('level') or 'error'} in {proj} - seen {i.get('count')}x, "
299
+ f"{i.get('userCount') or 0} user(s)]\n{i.get('culprit') or ''}",
300
+ 'from_name': 'Sentry', 'conversation_id': f"sentry:{i['id']}",
301
+ 'sent_at': at, 'source_link': i.get('permalink'), 'source_name': proj}, llm=llm)
302
+ n += out['status'] != 'duplicate'
303
+ return n
304
+
305
+
306
+ # ── PagerDuty ────────────────────────────────────────────────────────────────────────────
307
+ def _pagerduty(c, path, **params):
308
+ r = requests.get(f'https://api.pagerduty.com{path}', params=params, timeout=30,
309
+ headers={'Authorization': f"Token token={c.get('Secret') or ''}",
310
+ 'Content-Type': 'application/json'})
311
+ _die(r, 'PagerDuty', 'check the API token (Integrations → API Access Keys, or My Profile → User Settings)')
312
+ return r.json()
313
+
314
+
315
+ def test_pagerduty(store, c) -> str:
316
+ if not c.get('Secret'): raise RuntimeError('no API token saved - PagerDuty: Integrations → API Access Keys')
317
+ _pagerduty(c, '/incidents', limit=1)
318
+ _seed_source(store, c, 'PagerDuty')
319
+ return 'authenticated - open (triggered/acknowledged) incidents flow in on the next sync'
320
+
321
+
322
+ def poll_pagerduty(store, c, since, llm=None, file_only=False) -> int:
323
+ from .ingest import ingest_message
324
+ j = _pagerduty(c, '/incidents', since=since.astimezone().isoformat(), limit=CAP,
325
+ **{'sort_by': 'created_at:asc', 'statuses[]': ['triggered', 'acknowledged']})
326
+ n = 0
327
+ for i in j.get('incidents') or []:
328
+ svc = ((i.get('service') or {}).get('summary')) or 'PagerDuty'
329
+ out = ingest_message(store, file_only=file_only, msg={
330
+ 'external_id': f"pagerduty:{i['id']}", 'channel': 'pagerduty',
331
+ 'subject': f"#{i.get('incident_number')} {i.get('title') or ''}".strip(),
332
+ 'body': f"[PagerDuty incident on {svc} - {i.get('status')} - urgency {i.get('urgency')}]\n"
333
+ f"{str(((i.get('body') or {}).get('details')) or i.get('summary') or '')[:20000]}",
334
+ 'from_name': svc, 'conversation_id': f"pagerduty:{i['id']}",
335
+ 'sent_at': _stamp(i.get('created_at')), 'source_link': i.get('html_url'), 'source_name': svc}, llm=llm)
336
+ n += out['status'] != 'duplicate'
337
+ return n
338
+
339
+
340
+ TESTS = {'gitlab': test_gitlab, 'azdo': test_azdo, 'linear': test_linear, 'trello': test_trello,
341
+ 'notion': test_notion, 'discord': test_discord, 'sentry': test_sentry, 'pagerduty': test_pagerduty}
342
+ POLLS = {'gitlab': poll_gitlab, 'azdo': poll_azdo, 'linear': poll_linear, 'trello': poll_trello,
343
+ 'notion': poll_notion, 'sentry': poll_sentry, 'pagerduty': poll_pagerduty}
344
+ TYPES = tuple(TESTS)
345
+
346
+ def test(store, c) -> str: return TESTS[c['Type']](store, c)
347
+
348
+ def poll(store, c, since, llm=None, file_only=False) -> int:
349
+ try:
350
+ return POLLS[c['Type']](store, c, since, llm, file_only)
351
+ except requests.RequestException as e:
352
+ raise RuntimeError(f"{c['Type']} poll failed: {e}") from e
taskuary/digest.py ADDED
@@ -0,0 +1,104 @@
1
+ """The morning brief's raw material - and the Morning digest REPORT that delivers it.
2
+
3
+ The digest used to be its own once-a-day machinery writing only the DIGEST.md doc, which
4
+ nobody opened: a brief nobody reads is not a brief. It is a REPORT now (reports.run_digest,
5
+ seeded on first run): the summary lands on the Timeline where mornings actually start, the
6
+ prompt below is edited like any report's on the Reports tab, deleting the source turns the
7
+ whole thing off - and the same run keeps DIGEST.md fresh for the Docs tab.
8
+ """
9
+ from datetime import datetime, timedelta
10
+
11
+ DAYS = 3 # the window the synthesis reads - matches the startup catch-up
12
+
13
+ # The seeded report's editable instruction - "configure what goes in there" IS this text,
14
+ # on the Reports tab. reports.AI_SYSTEM wraps it; this is only the ask.
15
+ #
16
+ # The two rules at the end are not decoration. A digest once announced "TQ-0032 pending
17
+ # review: approve/send draft 'Re: Resident Refund Request'" when TQ-0032 was a Docker issue
18
+ # and NOTHING was pending: it took a mail subject out of the verdicts block (a "not our
19
+ # task" the owner had already given) and pinned it to a TQ-ref from the open-work list.
20
+ # A brief that invents work waiting on you is worse than no brief, so a ref must arrive
21
+ # wearing the title the data gave it, and an empty section must stay empty.
22
+ PROMPT = (
23
+ 'Write what the owner, half awake, should hold in mind TODAY, under 350 words, grouped into '
24
+ 'sections. Each section is its emoji header on its own line, then tight "- " bullets under it '
25
+ '(one fact per bullet, tasks named by their TQ-refs), then a blank line. Use exactly these '
26
+ 'sections in this order, and OMIT any with nothing to say:\n'
27
+ '\U0001f680 In flight — what is being worked and who is waiting on whom\n'
28
+ '⏳ Waiting on you — questions still unanswered, replies still unapproved\n'
29
+ '\U0001f4cc Keep honoring — verdicts you gave recently that should keep applying\n'
30
+ '\U0001f4c8 Patterns — heads-ups (a sender getting louder, the same system failing twice)\n'
31
+ 'Every TQ-ref you write must appear in the data, described with the SAME title it has there - '
32
+ 'never restate a task under another subject, and never carry a subject across from one block '
33
+ 'to another. A block whose data reads "(none)" has nothing to say: omit its section entirely '
34
+ 'rather than filling it.\n'
35
+ 'Never invent facts; no preamble, no sign-off, nothing outside the sections.')
36
+
37
+ # every prompt ever SHIPPED, so store.__init__ can tell "still the stock text" (upgrade it)
38
+ # from "the owner wrote this" (never touch) - same deal the template docs get
39
+ OLD_PROMPTS = (
40
+ 'Write what the owner, half awake, should hold in mind TODAY, in plain bullets under 350 words:\n'
41
+ '- what is in flight and who is waiting on whom (name tasks by their TQ-refs)\n'
42
+ '- questions still unanswered, replies still unapproved\n'
43
+ '- verdicts the owner gave recently that should keep being honored\n'
44
+ '- patterns worth a heads-up (a sender getting louder, the same system failing twice)\n'
45
+ 'Never invent facts; omit sections with nothing to say; no preamble, no sign-off.',
46
+
47
+ 'Write what the owner, half awake, should hold in mind TODAY, under 350 words, grouped into '
48
+ 'sections. Each section is its emoji header on its own line, then tight "- " bullets under it '
49
+ '(one fact per bullet, tasks named by their TQ-refs), then a blank line. Use exactly these '
50
+ 'sections in this order, and OMIT any with nothing to say:\n'
51
+ '\U0001f680 In flight — what is being worked and who is waiting on whom\n'
52
+ '⏳ Waiting on you — questions still unanswered, replies still unapproved\n'
53
+ '\U0001f4cc Keep honoring — verdicts you gave recently that should keep applying\n'
54
+ '\U0001f4c8 Patterns — heads-ups (a sender getting louder, the same system failing twice)\n'
55
+ 'Never invent facts; no preamble, no sign-off, nothing outside the sections.',
56
+ )
57
+
58
+ HEADER = ('# DIGEST.md — your morning brief\n\n'
59
+ '_Written by the Morning digest report (Reports tab - its prompt decides what goes in\n'
60
+ "here; deleting the report turns it off). For YOUR eyes - agents get their task's own\n"
61
+ 'context instead. Durable rules belong in Agent memory (Settings) or SOUL.md._\n\n')
62
+
63
+
64
+ def _block(out, head, lines):
65
+ """A header with NOTHING under it is an invitation to fill the silence - the model reads
66
+ the next block's lines as if they belonged to this one. Say "(none)" out loud instead."""
67
+ out.append(head)
68
+ out += list(lines) or [' (none)']
69
+
70
+
71
+ def gather(store, days: int = DAYS) -> str:
72
+ """The raw material, compact enough to hand an AI whole."""
73
+ since = (datetime.now() - timedelta(days=days)).isoformat(sep=' ', timespec='seconds')
74
+ out = []
75
+ tasks = store.list_tasks()
76
+ live = [t for t in tasks if t.get('Status') in ('open', 'in_progress', 'waiting')]
77
+ _block(out, 'OPEN WORK:', (f" TQ-{t['TaskId']:04d} [{t['Status']}] {t.get('Title') or ''} "
78
+ f"(kind {t.get('Kind')}, created {t.get('CreatedAt')})" for t in live[:25]))
79
+ done = [t for t in tasks if t.get('Status') == 'done' and str(t.get('UpdatedAt') or '') >= since]
80
+ _block(out, 'FINISHED THIS WINDOW:', (f" TQ-{t['TaskId']:04d} {t.get('Title') or ''}" for t in done[:20]))
81
+ # the ref carries its task's TITLE here too (list_reviews already joins it): a line
82
+ # holding only the attached MAIL SUBJECT let the subject and the ref drift apart, and a
83
+ # brief that renames a task is a lie in the one place the owner trusts by default
84
+ _block(out, 'WAITING ON THE OWNER (pending reviews):',
85
+ (f" TQ-{r['TaskId']:04d} ({(r.get('Title') or '?')[:60]}) {r.get('Kind')} "
86
+ f"on mail: {(r.get('Subject') or '(no subject)')[:90]}" for r in store.list_reviews('pending')[:15]))
87
+ notes = [m for m in store.list_memories() if str(m.get('CreatedAt') or '') >= since]
88
+ # these quote mail subjects, which is exactly what got recycled into a fake pending
89
+ # review once - the header says plainly that nothing here is live work
90
+ _block(out, 'VERDICTS GIVEN THIS WINDOW (already durable in memory - standing rules, NOT open work,\n'
91
+ 'and the mail they quote is already decided):',
92
+ (f" [{m.get('Scope')}:{m.get('ScopeKey') or '*'}] {(m.get('Note') or '')[:110]}" for m in notes[:12]))
93
+ senders = {}
94
+ for m in store.feed(limit=120, days=days):
95
+ who = m.get('FromName') or m.get('FromEmail') or m.get('SourceName') or '?'
96
+ senders[who] = senders.get(who, 0) + 1
97
+ loud = sorted(senders.items(), key=lambda kv: -kv[1])[:8]
98
+ _block(out, 'WHO WROTE, HOW OFTEN:', (f' {who}: {n}' for who, n in loud))
99
+ return '\n'.join(out)
100
+
101
+
102
+ # build_digest / refresh_if_stale used to live here - their whole job (schedule, AI pass,
103
+ # no-AI fallback, filing) is what the reports pipeline already does, so the Morning digest
104
+ # report replaced them: reports.run_digest is the executor, run_report_source keeps the doc.
taskuary/docsync.py ADDED
@@ -0,0 +1,124 @@
1
+ """Operator-doc automation: the docs are the agents' constitution, so connector changes
2
+ write themselves in. Two mechanisms, both non-destructive to hand-written prose:
3
+ - a marker-fenced 'Connected systems' block in SOUL.md, rebuilt on every connector/source
4
+ change (only the fenced block is touched);
5
+ - the GitHub repository map: discovery only ADDS lines for
6
+ repos missing from the doc, so per-repo notes the owner wrote are preserved.
7
+ """
8
+ import json
9
+
10
+ CONN_START, CONN_END = '<!-- connections:start -->', '<!-- connections:end -->'
11
+ REPO_MAP_HEADER = '## Repository map'
12
+ # The poller's own map, not a second copy of it. This used to be a hand-kept duplicate that
13
+ # stopped at monday, so every connector added after it (gitlab, azdo, linear, trello, notion,
14
+ # sentry, pagerduty, aws, azure, and now clickup/todoist) was invisible to the agents: their
15
+ # sources never made it into the SOUL.md 'Connected systems' block.
16
+ from .channels import CH2SRC
17
+
18
+
19
+ # The tool blurb used to say "create/update things in it as the work needs" - and an agent
20
+ # handed that read it as licence to open a GitHub issue for every task it worked, duplicating
21
+ # a tracker that already exists. The licence now follows the agent_issues_enabled setting.
22
+ ROLE_TEXT = {'trigger': 'inbound trigger — new items land on the timeline and go through triage',
23
+ 'report': 'scheduled report source'}
24
+ TOOL_TEXT = {'0': 'yours to read, and to create/update things in ONLY when the task explicitly '
25
+ 'asks for it — never issues or tracker items for your own work: the task is the record',
26
+ '1': 'yours to use — read from it and create/update things in it as the work needs'}
27
+
28
+ def role_text(store, role):
29
+ if role == 'tool': return TOOL_TEXT['1' if store.github_permissions()[0] else '0']
30
+ return ROLE_TEXT.get(role)
31
+
32
+
33
+ def sync_connections(store, actor='system'):
34
+ from .store import roles_of
35
+ doc = store.get_doc('soul') or ''
36
+ if CONN_START not in doc or CONN_END not in doc: return
37
+ srcs = store.list_sources()
38
+ lines = []
39
+ for c in store.list_connectors():
40
+ if not c['Active']: continue
41
+ mine = [s['Address'] for s in srcs
42
+ if s['Channel'] == CH2SRC.get(c['Type']) and s['Active']
43
+ and (s.get('ConnectorId') in (None, c['ConnectorId']))]
44
+ # what each connection IS to the agents, not just that it exists
45
+ what = '; '.join(role_text(store, r) for r in ('trigger', 'tool', 'report') if r in roles_of(c))
46
+ if mine: lines.append(f"- {c['Name']}: {', '.join(sorted(mine)[:12])}" + (f" — {what}" if what else ''))
47
+ elif what: lines.append(f"- {c['Name']} — {what}")
48
+ for s in srcs:
49
+ if s['Channel'] != 'report' or not s['Active']: continue
50
+ cfg = json.loads(s.get('ConfigJson') or '{}')
51
+ sched = ('on startup' if cfg.get('on_startup')
52
+ else f"cron {cfg['cron']}" if cfg.get('cron')
53
+ else f"every {cfg['every_minutes']}m" if cfg.get('every_minutes')
54
+ else f"daily {cfg.get('daily_at', '')}".strip())
55
+ lines.append(f"- Report \"{cfg.get('title') or s['Address']}\" ({cfg.get('type', 'rest')}, {sched})")
56
+ # the tool role is only real if the agents know how to reach it - spell out the call
57
+ if any('tool' in roles_of(c) for c in store.list_connectors() if c['Active']):
58
+ from . import config
59
+ srv = config.load().get('server') or {}
60
+ auth = ' (header X-Taskuary-Token)' if srv.get('token') else ''
61
+ lines.append(f"- To USE one of the systems above, POST http://{srv.get('host', '127.0.0.1')}:{srv.get('port', 7787)}"
62
+ '/api/tools/run{auth} with {"type": "mssql|database|aws|s3_object|cloudwatch_logs|'
63
+ 'azure|azure_blob|azure_logs|winrm|mcp|rest|sqlite|rss", ...} — '
64
+ 'saved credentials are filled in for you; the raw output comes back.'.replace('{auth}', auth))
65
+ block = '\n'.join(lines) or '_(no connections yet — add them in the Connectors tab)_'
66
+ head, rest = doc.split(CONN_START, 1)
67
+ _, tail = rest.split(CONN_END, 1)
68
+ new = f'{head}{CONN_START}\n{block}\n{CONN_END}{tail}'
69
+ if new != doc: store.save_doc('soul', new, actor)
70
+
71
+
72
+ def _readme_blurb(tok, repo, llm) -> str:
73
+ """No GitHub description? Read the repo's README instead - AI one-liner when an AI
74
+ connector is up, else the first real prose line."""
75
+ try:
76
+ from .github import readme_text
77
+ txt = readme_text(tok, repo)
78
+ except Exception:
79
+ txt = ''
80
+ if not txt.strip(): return ''
81
+ if llm:
82
+ try:
83
+ return (llm('You describe codebases for a routing table. ONE plain sentence, <25 words, '
84
+ 'no markdown: what the repository is and does.',
85
+ f'Repository {repo} README:\n\n{txt[:5000]}') or '').strip().splitlines()[0][:200]
86
+ except Exception:
87
+ pass
88
+ lines = [l.strip() for l in txt.splitlines()
89
+ if l.strip() and not l.startswith(('#', '!', '[', '<', '|', '-', '='))]
90
+ return lines[0][:160] if lines else ''
91
+
92
+
93
+ def update_repo_map(store, repos: list, actor='github', tok=None, llm=None):
94
+ """repos: [{full_name, description, archived}] - append unknown repos under the map
95
+ header in SOUL.md so EVERY agent knows which repo owns what. Repos without a GitHub
96
+ description get summarized from their README (AI one-liner when available)."""
97
+ doc = store.get_doc('soul') or ''
98
+ have = doc.lower()
99
+ PLACEHOLDER = 'no description on GitHub - fill me in'
100
+ def _desc(r):
101
+ return ((r.get('description') or '').strip()
102
+ or (tok and _readme_blurb(tok, r['full_name'], llm))
103
+ or PLACEHOLDER)
104
+ # re-discovery heals earlier placeholder lines once a README summary is available
105
+ healed = False
106
+ for r in repos:
107
+ old = f"- **{r['full_name']}**: {PLACEHOLDER}"
108
+ if old in doc:
109
+ d = _desc(r)
110
+ if d != PLACEHOLDER: doc, healed = doc.replace(old, f"- **{r['full_name']}**: {d}"), True
111
+ adds = [f"- **{r['full_name']}**: {_desc(r)}"
112
+ + (' (archived - do not touch)' if r.get('archived') else '')
113
+ for r in repos if r['full_name'].lower() not in have]
114
+ if not adds:
115
+ if healed: store.save_doc('soul', doc, actor)
116
+ return
117
+ if REPO_MAP_HEADER in doc:
118
+ head, rest = doc.split(REPO_MAP_HEADER, 1)
119
+ doc = head + REPO_MAP_HEADER + rest.rstrip() + '\n' + '\n'.join(adds) + '\n'
120
+ else:
121
+ doc = (doc.rstrip() + f'\n\n{REPO_MAP_HEADER}\n'
122
+ 'Route each coding task to the repo whose purpose matches; when unsure, escalate.\n'
123
+ + '\n'.join(adds) + '\n')
124
+ store.save_doc('soul', doc, actor)