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/ingest.py ADDED
@@ -0,0 +1,344 @@
1
+ """Ingest: anything -> the funnel. No vendor connectors baked in - push messages via the
2
+ HTTP API (POST /api/ingest/push) or your own plugin; report connections run on schedule.
3
+
4
+ Pipeline per message: dedup -> deterministic policy -> route to a task -> intent triage
5
+ (task / reply_only / fyi) -> file or create. Real tasks NEVER get an auto reply-draft:
6
+ answering is the responder's job (reply_only), doing is the coder's.
7
+ """
8
+ import json, re, threading
9
+ from loguru import logger
10
+ from .routing import route, draft_task_fields
11
+ from .policy import evaluate
12
+ from .triage import classify_intent, heuristic_intent
13
+ from .store import task_ref
14
+
15
+
16
+ # What the agent is TOLD about work from each kind of source. An email needs nothing -
17
+ # the mail is the prompt - but a pull request is a judgement call before it is a coding
18
+ # task, and the judging instructions should not depend on whoever typed the dispatch.
19
+ # Both are defaults: the GitHub card's prompt_pr / prompt_issue fields override them, and
20
+ # any other trigger connector can set task_prompt for its own items.
21
+ PR_RULES = (
22
+ 'This task came from a PULL REQUEST, possibly by an outside contributor. Judge it before '
23
+ 'touching anything: does it solve a real problem worth having? Is the change minimal, safe '
24
+ 'and in keeping with the codebase - no license or dependency swaps, nothing touching CI, '
25
+ 'release or security-sensitive files unless that is explicitly the point? Check out the PR '
26
+ 'branch, read the WHOLE diff, run the tests. Do NOT merge, close or push anything: end with '
27
+ 'a clear verdict - accept, request changes (say exactly which), or reject - and your reasons.')
28
+ ISSUE_RULES = (
29
+ 'This task came from a GITHUB ISSUE. Reproduce it first if you can. Judge whether it is a '
30
+ 'real defect or a feature worth building; fix it when the fix is contained and safe, '
31
+ 'otherwise report plainly what it would take and what the risks are.')
32
+
33
+
34
+ def source_rules(store, msg: dict) -> str:
35
+ """The standing instruction for work from this message's source, if its connector has one.
36
+ Resolution: the message's own source row names its connector (an email can be Outlook OR
37
+ Gmail); otherwise the channel's type-named connector. GitHub picks PR vs issue rules off
38
+ the ingest header and falls back to the shipped defaults above."""
39
+ ch = (msg or {}).get('Channel')
40
+ if not ch or ch == 'report': return ''
41
+ src = next((s for s in store.list_sources(active_only=False)
42
+ if s.get('Channel') == ch and s.get('Address') == msg.get('SourceName')), None)
43
+ c = (store.get_connector(src['ConnectorId']) if src and src.get('ConnectorId') else None) \
44
+ or store.get_connector_by_type(ch) or {}
45
+ try: cfg = json.loads(c.get('ConfigJson') or '{}')
46
+ except ValueError: cfg = {}
47
+ if ch == 'github':
48
+ is_pr = '[pull request by' in str(msg.get('BodyText') or '')[:200]
49
+ own = str((cfg.get('prompt_pr') if is_pr else cfg.get('prompt_issue')) or '').strip()
50
+ return own or (PR_RULES if is_pr else ISSUE_RULES)
51
+ return str(cfg.get('task_prompt') or '').strip()
52
+
53
+
54
+ def ingest_message(store, msg: dict, actor: str = 'router', llm=None, file_only: bool = False) -> dict:
55
+ """file_only = this connection is a FEED, not a trigger: the item is shown on the
56
+ timeline and nothing else happens to it - no triage, no AI call, no task. It is a
57
+ cheaper and quieter path than 'ignore', which is a verdict about the message."""
58
+ if store.message_exists(msg.get('external_id') or ''):
59
+ return {'status': 'duplicate', 'task_id': None, 'message_id': None}
60
+ if file_only:
61
+ mid = store.add_message({**_fields(msg, None), 'Status': 'feed'})
62
+ store.add_route(mid, None, 'feed', None,
63
+ 'shown for information - this connection is a feed, not a task trigger', [], 'feed')
64
+ return {'status': 'feed', 'task_id': None, 'message_id': mid}
65
+ cfg = store.get_settings()
66
+ pol = evaluate(msg, store.list_policies(), store.known_sender(msg.get('from_email')),
67
+ cfg.get('default_action', 'draft'))
68
+ if pol['action'] in ('skip', 'ignore'):
69
+ # skip = stored for dedupe but NEVER shown (flood senders); ignore = shown, no task
70
+ mid = store.add_message({**_fields(msg, None), 'Status': 'skipped' if pol['action'] == 'skip' else 'ignored'})
71
+ store.add_route(mid, None, pol['action'], None, f"policy '{pol['rule']}': {pol['reason']}", [], 'policy')
72
+ return {'status': pol['action'] + ('ped' if pol['action'] == 'skip' else 'd'), 'task_id': None, 'message_id': mid}
73
+
74
+ r = route(msg, store.snapshots(), float(cfg.get('attach_threshold', 0.42)))
75
+ new_rid = None # set when a fresh reply task opens a review below
76
+ if r['decision'] == 'attach':
77
+ tid = r['task_id']
78
+ mid = store.add_message(_fields(msg, tid))
79
+ store.add_comment(tid, actor, 'agent', f"New {msg.get('channel')} from {msg.get('from_email') or 'unknown'}: {msg.get('subject') or ''}")
80
+ # the classic round trip: the agent asked something, the hub asked the person, and
81
+ # THIS is their answer arriving on the same thread. With answer_to_agent=auto it is
82
+ # typed straight into the live session; 'ask' leaves the one-click offer in the
83
+ # panel; 'off' does neither. A dead session just means False - nothing breaks.
84
+ if cfg.get('answer_to_agent', 'ask') == 'auto':
85
+ try:
86
+ from . import terminal
87
+ terminal.say_to_task(store, tid, msg, actor)
88
+ except Exception as e:
89
+ logger.warning(f'answer_to_agent failed for task {tid}: {e}')
90
+ else:
91
+ # AI-gated triage: without an active AI connector, nothing becomes a task on its
92
+ # own - messages FILE onto the timeline (visible, promotable by hand) instead of
93
+ # heuristics spraying tasks for every automated notification. Heuristics still
94
+ # short-circuit the obvious fyi noise before spending an AI call.
95
+ if cfg.get('intent_classify_enabled', '1') == '1':
96
+ h = heuristic_intent(msg)
97
+ if h['intent'] == 'fyi': # obvious automated noise: no AI call needed
98
+ intent = h
99
+ elif llm is None:
100
+ mid = store.add_message({**_fields(msg, None), 'Status': 'filed'})
101
+ store.add_route(mid, None, 'file', None,
102
+ 'awaiting AI triage - connect an AI connector (Connectors → AI) to classify inbound automatically', [], 'triage')
103
+ logger.debug(f"ingest: filed (no AI connector) - {msg.get('subject') or ''}")
104
+ return {'status': 'filed', 'task_id': None, 'message_id': mid}
105
+ else:
106
+ fail = {}
107
+ def _guarded(sys_, usr_):
108
+ try:
109
+ return llm(sys_, usr_)
110
+ except Exception as e:
111
+ fail['err'] = str(e)[:200]
112
+ raise
113
+ from .learn import injectable
114
+ intent = classify_intent(msg, llm=_guarded, soul=store.doc('soul'),
115
+ learned=injectable(store.doc('learned') or ''),
116
+ notes=notes_for(store, msg), images=msg.get('images'),
117
+ system=store.doc('triage'))
118
+ if fail:
119
+ # the AI errored - filing beats the old default-to-task heuristic
120
+ mid = store.add_message({**_fields(msg, None), 'Status': 'filed'})
121
+ store.add_route(mid, None, 'file', None,
122
+ f"AI triage failed ({fail['err']}) - filed; fix the AI connector and it will classify new mail", [], 'triage')
123
+ logger.warning(f"ingest: AI triage failed, filed - {fail['err']}")
124
+ return {'status': 'filed', 'task_id': None, 'message_id': mid}
125
+ else:
126
+ intent = {'intent': 'task', 'why': ''}
127
+ if intent['intent'] == 'fyi':
128
+ mid = store.add_message({**_fields(msg, None), 'Status': 'filed'})
129
+ store.add_route(mid, None, 'file', None, f"triage: fyi - {intent.get('why') or 'informational'}", [], 'triage')
130
+ return {'status': 'filed', 'task_id': None, 'message_id': mid}
131
+ from .outbound import can_reply
132
+ if intent['intent'] == 'reply_only' and not can_reply(store, msg.get('channel')):
133
+ # a question on a channel replies are OFF for: filing beats opening a reply task
134
+ # whose draft could never be sent anywhere (see outbound.can_reply for who decides)
135
+ ch = msg.get('channel') or 'this channel'
136
+ why = ('GitHub replies are off (GitHub card)' if ch == 'github'
137
+ else f'replies are off for {ch} (Settings → Replies)')
138
+ mid = store.add_message({**_fields(msg, None), 'Status': 'filed'})
139
+ store.add_route(mid, None, 'file', None,
140
+ f"triage: reply_only - {intent.get('why') or 'a question'} · {why}, "
141
+ 'so it is filed instead of drafted', [], 'triage')
142
+ return {'status': 'filed', 'task_id': None, 'message_id': mid}
143
+ f = draft_task_fields(msg)
144
+ if intent['intent'] == 'reply_only': f['kind'] = 'reply'
145
+ tid = store.create_task({'Title': f['title'], 'Summary': f['summary'], 'Kind': f['kind'],
146
+ 'Priority': f['priority'], 'Source': msg.get('channel') or 'api',
147
+ 'SourceRef': msg.get('source_link')}, actor)
148
+ store.audit('task', tid, 'create', actor, 'agent', {'from': msg.get('from_email'), 'reason': r['reason']})
149
+ mid = store.add_message(_fields(msg, tid))
150
+ # the agents actually pick work up here:
151
+ # - reply tasks ALWAYS enter the review queue ("needs me"); auto_draft_enabled
152
+ # additionally has the responder write the draft in the background
153
+ # - CODING tasks auto-dispatch to the coder when coder_auto_enabled is on
154
+ # - anything else that is real work queues as needs-you, for you to route
155
+ if f['kind'] == 'reply':
156
+ new_rid = rid = store.add_review({'TaskId': tid, 'MessageId': mid, 'Kind': 'draft', 'Status': 'pending',
157
+ 'Reason': f"needs a reply: {intent.get('why') or 'question for you'}"})
158
+ if cfg.get('auto_draft_enabled') == '1':
159
+ _spawn(_auto_draft, store, tid, rid)
160
+ # KIND is the gate, not "anything that is not a reply". This used to dispatch a coding
161
+ # agent at every non-reply task, so a Teams message about someone's job scope - real
162
+ # work, no repository anywhere in it - opened a CLI session on a checkout and started
163
+ # editing code. A coding agent belongs on a coding task; the rest is yours to place.
164
+ elif f['kind'] == 'coding' and cfg.get('coder_auto_enabled') == '1' and not msg.get('no_auto'):
165
+ # no_auto = the channel opted out of self-dispatch (github items always do: an
166
+ # open repo would start an agent per drive-by PR) - the task queues as needs-you
167
+ _spawn(_auto_code, store, tid)
168
+ # the route row is the JUDGEMENT's record, and the timeline panel quotes it verbatim: the
169
+ # verdict leads (what the classifier decided and why), routing explains new-vs-attached,
170
+ # and the tail says what happened NEXT - "it's a task" without "and who is working it"
171
+ # answered a question nobody asked
172
+ reason = r['reason']
173
+ if r['decision'] != 'attach':
174
+ act = ('a reply draft goes to Review for you' if f['kind'] == 'reply'
175
+ else 'not auto-worked: github items queue for you to promote' if msg.get('no_auto')
176
+ # said per KIND, because the line is read as a promise about what just happened
177
+ else 'no code in it - it waits on your list, no agent dispatched' if f['kind'] != 'coding'
178
+ else 'sent to the coding agent' if cfg.get('coder_auto_enabled') == '1'
179
+ else 'auto-dispatch is off (Settings) - start the session from the task')
180
+ reason = (f"triage: {intent['intent']}" + (f" - {intent['why']}" if intent.get('why') else '')
181
+ + f" · {r['reason']} · {act}")
182
+ store.add_route(mid, tid, r['decision'], r['score'], reason, r['candidates'], actor)
183
+ logger.info(f"ingest: {r['decision']} -> {task_ref(tid)}")
184
+ # the timeline pushed INTO a chat: 'needs_me' pings only what is waiting on YOU - a question
185
+ # to answer, or a task nobody was dispatched at. A task an agent just started is being
186
+ # handled; the ping for those comes later, when its reply is drafted (coder.raise_reply).
187
+ lvl = cfg.get('notify_level') or 'needs_me'
188
+ # on an attach there was no fresh triage (`f` only exists on create) - the task itself knows
189
+ kind = f['kind'] if r['decision'] != 'attach' else (store.get_task(tid) or {}).get('Kind')
190
+ dispatched = kind != 'reply' and cfg.get('coder_auto_enabled') == '1'
191
+ if lvl == 'all' or (lvl == 'needs_me' and not dispatched):
192
+ _notify_new(store, msg, tid, mid,
193
+ 'a question for you' if kind == 'reply' else 'new task on your list', rid=new_rid)
194
+ return {'status': 'attached' if r['decision'] == 'attach' else 'created', 'task_id': tid, 'message_id': mid}
195
+
196
+
197
+ def _notify_new(store, msg: dict, tid, mid, why: str, rid=None):
198
+ """One short line to the notify channels. With phone approvals on, a question's ping
199
+ also carries the [rvN] tag so replying in the chat decides it (phone.py). Failure is a
200
+ log line, never a broken ingest."""
201
+ from .outbound import notify
202
+ from .store import task_ref
203
+ try:
204
+ who = msg.get('from_name') or msg.get('from_email') or msg.get('source_name') or 'someone'
205
+ body_head = str(msg.get('body') or '').strip().splitlines()
206
+ head = msg.get('subject') or (body_head[0][:80] if body_head else '(no subject)')
207
+ line = f"{task_ref(tid)} - {why}\n{head}\nfrom {who} on {msg.get('channel') or 'api'}"
208
+ if rid:
209
+ from .phone import ping_tail
210
+ line += ping_tail(store, rid, (store.get_review(rid) or {}).get('DraftText'))
211
+ notify(store, line, about={'Channel': msg.get('channel'), 'ConversationId': msg.get('conversation_id')})
212
+ except Exception as e:
213
+ logger.warning(f'notify failed for message {mid}: {e}')
214
+
215
+
216
+ def notes_for(store, msg: dict) -> list:
217
+ """The owner's standing notes that apply to this message - global ones plus anything
218
+ learned about this sender or their domain. Triage reads them, so a verdict given once
219
+ ("this kind of mail is not ours") applies to every message like it afterwards."""
220
+ em = (msg.get('from_email') or '').lower()
221
+ dom = em.rsplit('@', 1)[-1] if '@' in em else ''
222
+ return [n['Note'] for n in store.list_memories()
223
+ if n['Scope'] == 'global'
224
+ or (n['Scope'] == 'sender' and (n.get('ScopeKey') or '').lower() == em)
225
+ or (n['Scope'] == 'sender_domain' and (n.get('ScopeKey') or '').lower() == dom)]
226
+
227
+
228
+ def task_from_message(store, mid: int, actor: str = 'owner', kind: str = 'coding', assignee: str = None) -> int:
229
+ """Promote a filed/ignored/report message into a real task: to hand to an agent, or - with
230
+ `assignee` - to keep on your own list, because plenty of work is real work no agent can do
231
+ (go into some web app and click the thing). Already-routed messages keep the task they are on."""
232
+ m = store.get_message(mid)
233
+ if not m: raise ValueError(f'no message {mid}')
234
+ if m.get('TaskId'): return m['TaskId']
235
+ title = (m.get('Subject') or f"{m.get('FromName') or m.get('FromEmail') or m.get('Channel')} message")[:200]
236
+ tid = store.create_task({'Title': title, 'Summary': str(m.get('BodyText') or '')[:1000], 'Kind': kind,
237
+ 'Source': m.get('Channel') or 'api', 'SourceRef': m.get('SourceLink'),
238
+ **({'Assignee': assignee} if assignee else {})}, actor)
239
+ store.attach_message(mid, tid)
240
+ store.add_route(mid, tid, 'create', None,
241
+ f"promoted by the owner - {'theirs to do' if assignee else 'to hand it to an agent'}", [], actor)
242
+ store.audit('task', tid, 'create_from_message', actor, detail={'message_id': mid, 'subject': title})
243
+ return tid
244
+
245
+
246
+ GREETING = re.compile(r'^(hi|hello|hey|dear|good (morning|afternoon|evening))\b', re.I)
247
+
248
+ def ask_line(body: str) -> str:
249
+ """The line that carries the ask - never the greeting it opens with."""
250
+ lines = [l.strip() for l in (body or '').splitlines() if l.strip()]
251
+ real = [l for l in lines if not GREETING.match(l) and len(l) > 12]
252
+ return (real or lines or [''])[0][:120]
253
+
254
+
255
+ def split_message(store, mid: int, actor: str = 'owner', kind: str = None) -> int:
256
+ """Pull one message OUT of the task it was threaded onto and give it its own. Two asks
257
+ that arrived in the same chat are one conversation but two jobs - and an agent sent at
258
+ the task only ever gets the first one's prompt."""
259
+ m = store.get_message(mid)
260
+ if not m: raise ValueError(f'no message {mid}')
261
+ old = m.get('TaskId')
262
+ parent = store.get_task(old) if old else None
263
+ title = (m.get('Subject') or m.get('FromName') or 'message')[:200]
264
+ body = str(m.get('BodyText') or '')
265
+ # the ask itself is the title when the subject is just the chat's name every message
266
+ # shares - and the ask is never the greeting line it opens with
267
+ if parent and (parent.get('Title') or '').strip().lower() == title.strip().lower():
268
+ lines = [l.strip() for l in body.splitlines() if l.strip()]
269
+ greet = re.compile(r'^(hi|hello|hey|dear|good (morning|afternoon|evening))\b', re.I)
270
+ title = next((l for l in lines if not greet.match(l) and len(l) > 12), lines[0] if lines else title)[:120]
271
+ tid = store.create_task({'Title': title, 'Summary': body[:2000],
272
+ 'Kind': kind or (parent or {}).get('Kind') or 'coding',
273
+ 'Source': m.get('Channel') or 'api', 'SourceRef': m.get('SourceLink')}, actor)
274
+ store.attach_message(mid, tid)
275
+ store.add_route(mid, tid, 'create', None,
276
+ f'split off {task_ref(old)} - a separate ask in the same thread' if old else 'made its own task',
277
+ [], actor)
278
+ if old: store.add_comment(old, actor, 'human', f'Split "{title}" out into {task_ref(tid)} - unrelated ask.')
279
+ store.audit('task', tid, 'split_from_message', actor, detail={'message_id': mid, 'from_task': old})
280
+ return tid
281
+
282
+
283
+ def _spawn(fn, *args):
284
+ threading.Thread(target=fn, args=args, daemon=True).start()
285
+
286
+
287
+ AUTO_SESSIONS = 4 # unattended sessions to keep alive at once; past this it waits for you
288
+
289
+ def _auto_code(store, tid):
290
+ """Auto-dispatch puts the CLI on the task in a REAL session - the same one you see when
291
+ you open the task. Nothing runs where you cannot watch it, interrupt it or answer it.
292
+
293
+ A task LIKELY to collide with one already being worked in the same checkout queues behind
294
+ it instead of racing it (affinity routing - the first agent in has control), and a full
295
+ house queues for the next free slot. Both drain automatically as sessions end - the card
296
+ on the board says what it is waiting for."""
297
+ from . import terminal as term, blackboard as bb
298
+ agent = store.get_settings().get('default_agent') or 'coder'
299
+ # the note belongs INSIDE the worker: written before the thread started, a task could
300
+ # claim "auto-dispatched" with no session behind it whenever the process died first
301
+ if len([t for t in term.SESSIONS.values() if t.alive]) >= AUTO_SESSIONS:
302
+ store.enqueue_dispatch(tid, None, agent, f'{AUTO_SESSIONS} agent sessions are already live')
303
+ store.add_comment(tid, 'router', 'agent',
304
+ f'Queued: {AUTO_SESSIONS} agent sessions are already live - '
305
+ 'it starts by itself when one ends.')
306
+ return
307
+ try:
308
+ cwd = bb.target_cwd(store, tid, agent)
309
+ ps = bb.peers(store, cwd, exclude_tid=tid) if cwd else []
310
+ if ps:
311
+ hit, why = bb.likely_overlap(store, tid, ps)
312
+ if hit:
313
+ store.enqueue_dispatch(tid, hit['tid'], agent, why or 'likely to touch the same files')
314
+ store.add_comment(tid, 'router', 'agent',
315
+ f"Queued behind {hit['ref']} \"{hit['title'][:80]}\" - "
316
+ f"{why or 'likely to touch the same files'}. It starts by itself "
317
+ 'when that agent finishes.')
318
+ return
319
+ term.start_on_task(store, tid, agent, actor='router')
320
+ store.add_comment(tid, 'router', 'agent', 'auto-started a live coder session (coder_auto_enabled)'
321
+ + (f' - told it about the {len(ps)} agent(s) already in the checkout' if ps else ''))
322
+ except Exception as e:
323
+ logger.warning(f'auto dispatch failed for task {tid}: {e}')
324
+ store.add_comment(tid, 'router', 'agent', f'Auto-start failed: {str(e)[:200]}')
325
+
326
+
327
+ def _auto_draft(store, tid, rid):
328
+ """A reply needs an answer, not an agent: the MAIN AI writes it and it waits for approval.
329
+ A CLI agent named `responder` takes over only if the owner deliberately configured one."""
330
+ from . import responder
331
+ try: responder.write_draft(store, tid, rid, actor='auto-draft')
332
+ except Exception as e:
333
+ logger.warning(f'auto-draft failed for task {tid}: {e}')
334
+
335
+
336
+ def _fields(msg, task_id):
337
+ from .store import norm_stamp
338
+ return {'TaskId': task_id, 'ExternalId': msg.get('external_id'), 'ConversationId': msg.get('conversation_id'),
339
+ 'Channel': msg.get('channel') or 'api', 'SourceName': msg.get('source_name'),
340
+ 'Subject': (msg.get('subject') or '')[:500], 'FromName': msg.get('from_name'),
341
+ # normalized HERE, the one gate every channel funnels through: a UTC ISO stamp from
342
+ # any single path sorts the whole timeline out of order (see store.norm_stamp)
343
+ 'FromEmail': msg.get('from_email'), 'SentAt': norm_stamp(msg.get('sent_at')),
344
+ 'BodyText': msg.get('body'), 'SourceLink': msg.get('source_link'), 'Status': 'routed'}
taskuary/learn.py ADDED
@@ -0,0 +1,181 @@
1
+ """LEARNED.md - the profile the funnel infers from the owner's verdicts, written by itself.
2
+
3
+ SOUL.md is what the owner SAYS; LEARNED.md is what they DO. Every explicit correction - a
4
+ draft edited before sending, a reply rejected, a task reclassified, a filed message promoted
5
+ by hand - carries a general lesson about how this person works: what they are responsible
6
+ for, how they write, what deserves a task, who matters. Nobody types those in, so the system
7
+ distills them itself. The design follows where the experience-learning literature agrees:
8
+ ExpeL's counted insights (arxiv 2308.10144), PRELUDE's infer-the-preference-from-the-edit
9
+ (arxiv 2404.15269), Generative Agents' batched reflection with cited evidence (2304.03442).
10
+
11
+ Two write paths, deliberately different speeds:
12
+ - HOT (learn_from): one cheap LLM call per correction turns the single event into a
13
+ hypothesis, visible in the doc seconds after the verdict that taught it - explicit
14
+ corrections are the high-signal minority and deserve immediate weight;
15
+ - REFLECTION (reflect / reflect_if_due): batched and debounced, rewrites the whole doc -
16
+ only a batch can see cross-episode patterns, promote hypotheses that kept holding, and
17
+ kill the ones that did not. Implicit signals (drafts approved untouched) are counted
18
+ ONLY here: individually they are noise, in aggregate they are confirmation.
19
+
20
+ Hypotheses are never injected into prompts: a pattern seen once is a guess, and a guess in
21
+ a system prompt is a rule. Only promoted sections travel (injectable()) - and rules whose
22
+ effect is to HIDE mail never promote themselves at all: they wait in 'Proposed' for the
23
+ owner, because a wrong ignore-rule silences the very corrections that would revoke it.
24
+ """
25
+ import re
26
+ from datetime import datetime, timedelta
27
+ from loguru import logger
28
+
29
+ DOC = 'learned'
30
+ REFLECT_AT = 3 # corrections that trigger a reflection; fewer still reflect daily
31
+ DAYS = 14 # the fallback event window when no reflection has ever run
32
+ HYP_START, HYP_END = '<!-- hypotheses:start -->', '<!-- hypotheses:end -->'
33
+ PROP_START, PROP_END = '<!-- proposed:start -->', '<!-- proposed:end -->'
34
+ _GATED = re.compile(r'\n## [^\n]*\n+<!-- (hypotheses|proposed):start -->.*?<!-- \1:end -->\n?', re.S)
35
+
36
+ LESSON_SYSTEM = (
37
+ 'You maintain the Hypotheses section of LEARNED.md: patterns Taskuary is testing about how '
38
+ 'its owner works, distilled from their verdicts. You get the current section and ONE new '
39
+ 'event. Return the updated section: markdown bullets only - no headers, no fences, no preamble.\n'
40
+ '- Infer the GENERAL preference the event reveals: voice and style, what they are responsible '
41
+ 'for, who matters to them, what deserves a task. Never a one-sender rule (standing notes '
42
+ 'handle those) and never a mere restatement of the event.\n'
43
+ "- Every bullet ends with a tag: [s:N | ev: id,id | seen: date]. A new hypothesis starts at "
44
+ "s:2 with this event's id as ev. If a bullet already says the same thing, raise its s by 1, "
45
+ 'append the id, update seen - never duplicate. If the event contradicts one, lower its s by 1; '
46
+ 'delete any bullet at s:0.\n'
47
+ '- Refer to the owner as {{owner_first}} - a placeholder the app fills in.\n'
48
+ '- A routine event with nothing general in it: return the section unchanged.\n'
49
+ '- At most 20 bullets, each under 25 words before the tag; drop the weakest first.')
50
+
51
+ REFLECT_SYSTEM = (
52
+ 'You are the reflection pass over LEARNED.md, the profile Taskuary maintains of how its owner '
53
+ 'works, learned from their verdicts. Rewrite the WHOLE document and return nothing else - no '
54
+ 'fences, no commentary. Rules:\n'
55
+ '- Lines without a [s:...] tag were written by the owner: keep them byte-for-byte, where they are.\n'
56
+ '- Apply the evidence: a hypothesis the events confirm gains 1 strength per distinct episode '
57
+ '(append its ev id); a contradicted one loses 1; s:0 means delete the line.\n'
58
+ '- Promote a hypothesis into the matching section above only at s:4+ with evidence from 3+ '
59
+ 'episodes across 2+ different people or threads - one hot thread proves nothing general.\n'
60
+ '- EXCEPTION: a rule whose effect is to hide or auto-file things ("treat X as fyi", "never a '
61
+ 'task") promotes only into "Proposed rules" - hiding is the owner\'s call to approve.\n'
62
+ '- Add new hypotheses only for patterns 2+ episodes support; singles stay unwritten.\n'
63
+ '- Never contradict SOUL.md (it outranks this file); never invent facts beyond the events.\n'
64
+ '- Keep the section headers, all four <!-- --> marker lines, and every {{owner}}-style '
65
+ 'placeholder exactly as they are; keep the whole file under 120 lines.\n'
66
+ '- End with a footer: _last reflection: date - what changed in a few words_ (replace any old one).')
67
+
68
+
69
+ def _today(): return datetime.now().strftime('%Y-%m-%d')
70
+ def _block(doc, a, b): return doc.split(a, 1)[1].split(b, 1)[0].strip() if (a in doc and b in doc) else None
71
+ def _put_block(doc, a, b, body):
72
+ head, rest = doc.split(a, 1)
73
+ return f'{head}{a}\n{body.strip()}\n{b}' + rest.split(b, 1)[1]
74
+ def _unfence(s): return re.sub(r'^```\w*\s*$|^```\s*$', '', (s or '').strip(), flags=re.M).strip()
75
+
76
+
77
+ def injectable(text: str) -> str:
78
+ """The doc as prompts should read it: active sections only. Hypotheses and proposed rules
79
+ are gated out - a tested pattern is knowledge, an untested one is noise in a system prompt."""
80
+ if not text: return ''
81
+ out = _GATED.sub('\n', text)
82
+ for a, b in ((HYP_START, HYP_END), (PROP_START, PROP_END)): # blocks whose header was hand-edited away
83
+ if a in out and b in out:
84
+ head, rest = out.split(a, 1)
85
+ out = head + rest.split(b, 1)[1]
86
+ return out.strip()
87
+
88
+
89
+ def learn_from(store, event: str, llm=None):
90
+ """One explicit owner verdict -> the Hypotheses section, updated now. Never raises: a lost
91
+ lesson costs one observation, a broken decide endpoint costs trust in the whole funnel."""
92
+ try:
93
+ cfg = store.get_settings()
94
+ if cfg.get('learn_enabled', '1') != '1': return
95
+ try: n = int(cfg.get('learn_pending') or 0) + 1
96
+ except ValueError: n = 1
97
+ store.set_setting('learn_pending', str(n), 'learn') # ticks even with no AI: the first reflection catches up
98
+ from .llm import build_llm
99
+ llm = llm or build_llm(store)
100
+ doc = store.get_doc(DOC) or ''
101
+ hyp = _block(doc, HYP_START, HYP_END)
102
+ if llm is None or hyp is None: return
103
+ out = _unfence(llm(LESSON_SYSTEM, f'(today: {_today()})\n\nCURRENT HYPOTHESES:\n{hyp[:3000]}'
104
+ f'\n\nNEW EVENT:\n{event[:2000]}', max_tokens=700))
105
+ # a broken answer never lands in the doc - markers inside it would corrupt the block splice
106
+ if not out or '<!--' in out or len(out) > 6000: return
107
+ if out != hyp: store.save_doc(DOC, _put_block(doc, HYP_START, HYP_END, out), 'learn')
108
+ if n >= REFLECT_AT: reflect(store, llm)
109
+ except Exception as e:
110
+ logger.warning(f'learning skipped: {e}')
111
+
112
+
113
+ def gather(store, since: str) -> str:
114
+ """The verdict window, compact enough to hand an AI whole: review decisions with the
115
+ draft-vs-sent texts (the richest style signal there is), the standing notes written in the
116
+ window, and the owner-made corrections the audit trail carries."""
117
+ revs = [r for r in store.list_reviews() if str(r.get('DecidedAt') or '') >= since]
118
+ ok = [r for r in revs if r['Status'] == 'approved']
119
+ dec = [r for r in revs if r['Status'] in ('edited', 'rejected', 'no_reply')]
120
+ out = [f'DRAFT VERDICTS: {len(ok)} sent unchanged (each confirms the current voice), {len(dec)} corrected:']
121
+ for r in dec[:15]:
122
+ out.append(f" rv{r['ReviewId']} [{r['Status']}] \"{(r.get('Subject') or r.get('Title') or '')[:70]}\" "
123
+ f"from {r.get('FromEmail') or '?'}"
124
+ + (f" - owner's note: {str(r['DecideNote'])[:120]}" if r.get('DecideNote') else ''))
125
+ if r['Status'] == 'edited':
126
+ out += [f" DRAFT: {str(r.get('DraftText') or '')[:400]}",
127
+ f" SENT: {str(r.get('FinalText') or '')[:400]}"]
128
+ mems = [m for m in store.list_memories() if str(m.get('CreatedAt') or '') >= since and m.get('Source') == 'verdict']
129
+ if mems:
130
+ out.append('VERDICT NOTES WRITTEN (already durable - generalize ACROSS them, never copy them):')
131
+ out += [f" mem{m['MemoryId']} [{m['Scope']}:{m.get('ScopeKey') or '*'}] {str(m.get('Note') or '')[:140]}" for m in mems[:12]]
132
+ acts = ('not_a_task_delete', 'not_mine_delete', 'create_from_message', 'split', 'merge')
133
+ aud = [a for a in store.list_audit(limit=400)
134
+ if str(a.get('CreatedAt') or '') >= since and a['Action'] in acts and a.get('ActorType') != 'agent']
135
+ if aud:
136
+ out.append('OWNER CORRECTIONS (audit trail):')
137
+ out += [f" {a['Action']} {a['EntityType']}{a['EntityId']} {str(a.get('Detail') or '')[:120]}" for a in aud[:20]]
138
+ return '\n'.join(out)
139
+
140
+
141
+ def reflect(store, llm=None) -> bool:
142
+ """The consolidation: whole-doc rewrite against the event window. Modeled on digest.py's
143
+ gather -> synthesize -> save_doc, but with NO no-AI fallback - a mechanical rewrite of a
144
+ doc that feeds every prompt would poison them, and the old doc is always a valid answer."""
145
+ from .llm import build_llm
146
+ llm = llm or build_llm(store)
147
+ doc = store.get_doc(DOC) or '' # RAW doc: {{owner}} tokens must survive the rewrite
148
+ if not llm or not doc: return False
149
+ since = (store.get_settings().get('learn_last_reflect')
150
+ or (datetime.now() - timedelta(days=DAYS)).isoformat(sep=' ', timespec='seconds'))
151
+ try:
152
+ new = _unfence(llm(REFLECT_SYSTEM,
153
+ f'(today: {_today()})\n\nCURRENT LEARNED.md:\n{doc[:6000]}\n\n'
154
+ f"SOUL.md (context only - it outranks, never contradict it):\n{(store.doc('soul') or '')[:2500]}\n\n"
155
+ f'EVENTS SINCE {since}:\n{gather(store, since)[:5000]}', max_tokens=1800))
156
+ except Exception as e:
157
+ logger.warning(f'reflection failed: {e}'); return False
158
+ # the 120-line budget in the prompt is a request; this is the law. A doc past the cap is a
159
+ # model that ignored its instructions, and an ever-growing LEARNED.md would silently lose
160
+ # its tail to the injection caps - the old doc is always the better answer than a bloated one.
161
+ markers = (HYP_START, HYP_END, PROP_START, PROP_END)
162
+ if not (new.startswith('#') and 200 < len(new) <= 12_000 and new.count('\n') <= 160
163
+ and all(new.count(m) == 1 for m in markers)):
164
+ logger.warning('reflection produced an unusable doc - kept the old one'); return False
165
+ store.save_doc(DOC, new, 'reflect')
166
+ store.set_setting('learn_pending', '0', 'reflect')
167
+ store.set_setting('learn_last_reflect', datetime.now().isoformat(sep=' ', timespec='seconds'), 'reflect')
168
+ logger.info('LEARNED.md reflected')
169
+ return True
170
+
171
+
172
+ def reflect_if_due(store) -> bool:
173
+ """Startup hook, digest-style: reflect when enough corrections queued (REFLECT_AT triggers
174
+ it mid-day too, from learn_from), or once a day when at least one did - never on silence."""
175
+ cfg = store.get_settings()
176
+ if cfg.get('learn_enabled', '1') != '1': return False
177
+ try: n = int(cfg.get('learn_pending') or 0)
178
+ except ValueError: n = 0
179
+ if n <= 0: return False
180
+ if n < REFLECT_AT and (cfg.get('learn_last_reflect') or '').startswith(_today()): return False
181
+ return reflect(store)