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/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Taskuary - Automate your job: AI triage in, coding agents out, you in charge."""
2
+ __version__ = '0.2.0'
taskuary/agents.py ADDED
@@ -0,0 +1,218 @@
1
+ """Agent execution: any CLI is an agent. A profile ({cmd, args, resume_args, timeout, cwd,
2
+ cwd_map}) turns Claude Code, Codex, or your own wrapper into a Taskuary teammate: prompt
3
+ over STDIN (argv length limits are real on Windows), JSON output parsed when available
4
+ (Claude-style {result, session_id} -> resumable sessions), git diff captured around the
5
+ run so code changes are first-class, every run traced + audited.
6
+ """
7
+ import json, os, re, shutil, subprocess, threading, time
8
+ from datetime import datetime
9
+ from loguru import logger
10
+
11
+ from .store import task_ref
12
+
13
+
14
+ def _git(cwd, *args):
15
+ try:
16
+ p = subprocess.run(['git', '-C', cwd or os.getcwd(), *args], capture_output=True, text=True,
17
+ encoding='utf-8', errors='replace', timeout=30)
18
+ return p.stdout.strip() if p.returncode == 0 else ''
19
+ except Exception:
20
+ return ''
21
+
22
+
23
+ def parse_cli_json(stdout: str):
24
+ """Claude-style single JSON object -> (result, session_id); plain text falls through."""
25
+ try:
26
+ j = json.loads((stdout or '').strip())
27
+ return (j.get('result') or '').strip(), j.get('session_id')
28
+ except (ValueError, AttributeError):
29
+ return (stdout or '').strip(), None
30
+
31
+
32
+ def _fresh_path() -> str:
33
+ """PATH as it is NOW, not as it was when Taskuary started. A process keeps the environment
34
+ it was born with, so a CLI installed while the app was running said "command not found"
35
+ until a restart - the one thing the error told you to do that you should not have to.
36
+ Windows keeps the live value in the registry; elsewhere the inherited PATH is all there is."""
37
+ if os.name != 'nt': return os.environ.get('PATH', '')
38
+ import winreg
39
+ parts = [os.environ.get('PATH', '')]
40
+ for hive, key in ((winreg.HKEY_CURRENT_USER, 'Environment'),
41
+ (winreg.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Session Manager\Environment')):
42
+ try:
43
+ with winreg.OpenKey(hive, key) as k:
44
+ parts.append(os.path.expandvars(winreg.QueryValueEx(k, 'Path')[0]))
45
+ except OSError:
46
+ pass
47
+ return os.pathsep.join(p for p in parts if p)
48
+
49
+
50
+ def _shim_target(path: str) -> list:
51
+ """What an npm .CMD shim actually runs. The shim is four lines of batch around one real
52
+ program - claude.CMD ends with "%dp0%\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %* -
53
+ and going through cmd /c to reach it is what costs us the prompt: cmd.exe owns & | < > and
54
+ stray quotes, so the first prompt cannot be passed as an ARGUMENT and has to be TYPED into
55
+ the TUI instead, in 160-char bites that a busy input loop drops. Spawn the target directly
56
+ and the prompt travels as argv - atomically, or not at all. [] = could not tell, use cmd."""
57
+ try: txt = open(path, encoding='utf-8', errors='replace').read()
58
+ except OSError: return []
59
+ here = os.path.dirname(path)
60
+ found = []
61
+ for tok in re.findall(r'"([^"]+)"', txt):
62
+ real = os.path.normpath(tok.replace('%dp0%', here).replace('%~dp0', here))
63
+ if os.path.isfile(real) and real.lower().endswith(('.exe', '.js')): found.append(real)
64
+ exe = next((f for f in found if f.lower().endswith('.exe')), None)
65
+ js = next((f for f in found if f.lower().endswith('.js')), None)
66
+ if exe and js: return [exe, js] # node.exe + the cli script
67
+ if exe: return [exe]
68
+ if js:
69
+ node = shutil.which('node')
70
+ return [node, js] if node else []
71
+ return []
72
+
73
+
74
+ def _resolve_cmd(name: str) -> list:
75
+ """Windows can't CreateProcess a bare 'claude': npm installs it as claude.cmd, which only
76
+ PATH-resolves via which(). Reaching THROUGH the shim beats running it under cmd /c - see
77
+ _shim_target for why that difference decides whether a prompt arrives whole."""
78
+ path = shutil.which(name) or shutil.which(name, path=_fresh_path())
79
+ if not path:
80
+ raise FileNotFoundError(f"'{name}' not found on PATH - is the CLI installed?")
81
+ if os.name == 'nt' and path.lower().endswith(('.cmd', '.bat')):
82
+ return _shim_target(path) or ['cmd', '/c', path]
83
+ return [path]
84
+
85
+
86
+ def _fmt_input(inp) -> str:
87
+ """The one field a human wants to see per tool call - command, path, pattern…"""
88
+ if not isinstance(inp, dict): return str(inp)[:140]
89
+ for k in ('command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt'):
90
+ if inp.get(k): return str(inp[k])[:140]
91
+ return json.dumps(inp)[:140]
92
+
93
+
94
+ def _result_text(c) -> str:
95
+ """A tool_result's content is a string or a list of {type: text} blocks."""
96
+ v = c.get('content')
97
+ if isinstance(v, list): v = ' '.join(str(b.get('text') or '') for b in v if isinstance(b, dict))
98
+ return re.sub(r'\s*\n\s*', ' ⏎ ', str(v or '').strip())
99
+
100
+
101
+ def _live_line(j):
102
+ """One readable console line per claude stream-json event; None = not worth showing.
103
+ Tool RESULTS stream too (trimmed), so the console reads like the terminal you'd see
104
+ if you ran the CLI yourself - not just the commands it fired."""
105
+ t = j.get('type')
106
+ if t == 'system':
107
+ # only the real session init is news; the other system events (hooks, compaction,
108
+ # subagent starts) repeated 'session started' forever and said nothing
109
+ if j.get('subtype') not in (None, 'init'): return None
110
+ m = j.get('model') or (j.get('modelInfo') or {}).get('name') or ''
111
+ return 'session started' + (f' · model {m}' if m else '')
112
+ if t == 'assistant':
113
+ out = []
114
+ for c in (j.get('message') or {}).get('content') or []:
115
+ if c.get('type') == 'tool_use': out.append(f"→ {c.get('name')}: {_fmt_input(c.get('input'))}")
116
+ elif c.get('type') == 'text' and (c.get('text') or '').strip(): out.append(c['text'].strip()[:300])
117
+ return '\n'.join(out) or None
118
+ if t == 'user':
119
+ res = [c for c in (j.get('message') or {}).get('content') or []
120
+ if isinstance(c, dict) and c.get('type') == 'tool_result']
121
+ if not res: return None
122
+ if any(c.get('is_error') for c in res): return f"✗ {_result_text(next(c for c in res if c.get('is_error')))[:300]}"
123
+ txt = _result_text(res[0])
124
+ return f'· {txt[:240]}' if txt else None
125
+ return None
126
+
127
+
128
+ def run_cli(profile: dict, prompt: str, trace, resume: str = None):
129
+ """One headless invocation of the configured CLI, output STREAMED line by line into
130
+ the run trace so the Board shows the agent working live. claude's stream-json events
131
+ render as readable tool/text lines; any other CLI's plain stdout streams as-is.
132
+ Returns (result, session_id, diff)."""
133
+ name = profile.get('cmd', 'claude')
134
+ cmd = _resolve_cmd(name) + list(profile.get('args') or ['-p'])
135
+ # which model works it: profile default, or a per-run override from the UI. The flag
136
+ # name is configurable because every CLI spells it differently (claude/codex: --model).
137
+ if profile.get('model'): cmd += [profile.get('model_arg') or '--model', str(profile['model'])]
138
+ if resume and profile.get('resume_args'): cmd += list(profile['resume_args']) + [resume]
139
+ cwd = profile.get('cwd')
140
+ head0 = _git(cwd, 'rev-parse', 'HEAD')
141
+ trace('prompt', 'prompt_sent_to_agent', prompt)
142
+ trace('tool', 'cli', f'{name} cwd={cwd or os.getcwd()}' + (f' resume={resume}' if resume else ''))
143
+ p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
144
+ text=True, encoding='utf-8', errors='replace', cwd=cwd, shell=False)
145
+ timed = threading.Event()
146
+ killer = threading.Timer(profile.get('timeout', 1200), lambda: (timed.set(), p.kill()))
147
+ killer.start()
148
+ err_buf = []
149
+ threading.Thread(target=lambda: err_buf.append(p.stderr.read()), daemon=True).start()
150
+ # stdin feed on its own thread: writing a big prompt while the child is already
151
+ # emitting output can deadlock both pipes otherwise
152
+ def _feed():
153
+ try: p.stdin.write(prompt); p.stdin.close()
154
+ except Exception: pass
155
+ threading.Thread(target=_feed, daemon=True).start()
156
+ raw, final = [], None
157
+ try:
158
+ for line in p.stdout:
159
+ line = line.rstrip('\n')
160
+ if not line.strip(): continue
161
+ raw.append(line)
162
+ try: j = json.loads(line)
163
+ except ValueError: trace('live', name, line[:400]); continue
164
+ if isinstance(j, dict) and (j.get('type') == 'result' or ('result' in j and 'type' not in j)):
165
+ final = j; continue
166
+ shown = _live_line(j) if isinstance(j, dict) else None
167
+ if shown: trace('live', name, shown)
168
+ p.wait()
169
+ finally:
170
+ killer.cancel()
171
+ if p.returncode != 0:
172
+ why = f'timed out after {profile.get("timeout", 1200)}s' if timed.is_set() else \
173
+ ((err_buf[0] if err_buf else '') or '\n'.join(raw[-5:]) or 'no output')[:500]
174
+ raise RuntimeError(f'{name} exit {p.returncode}: {why}')
175
+ if final is not None: out, sid = str(final.get('result') or '').strip(), final.get('session_id')
176
+ else: out, sid = parse_cli_json('\n'.join(raw))
177
+ trace('output', name, out[-1000:])
178
+ diff = ''
179
+ if head0:
180
+ head1 = _git(cwd, 'rev-parse', 'HEAD')
181
+ if head1 and head1 != head0: diff = _git(cwd, 'diff', f'{head0}..{head1}')
182
+ unc = _git(cwd, 'diff', 'HEAD')
183
+ if unc: diff = f'{diff}\n{unc}'.strip()
184
+ if diff: trace('tool', 'code_changes', f'{len(diff.splitlines())} diff lines captured')
185
+ return out, sid, (diff[:150000] or None)
186
+
187
+
188
+ def task_context(store, task_id: int) -> str:
189
+ d = store.task_detail(task_id)
190
+ t = d['task']
191
+ lines = [f"Task {d['ref']}: {t.get('Title')}", f"Kind: {t.get('Kind')} Status: {t.get('Status')}",
192
+ f"Summary: {t.get('Summary') or ''}", '', 'Messages:']
193
+ for m in d['messages']:
194
+ lines += [f"- [{m.get('SentAt')}] {m.get('FromName') or m.get('FromEmail')}: {m.get('Subject') or ''}",
195
+ f" {str(m.get('BodyText') or '')[:1500]}"]
196
+ lines += ['', 'Thread:'] + [f"- {c.get('Actor')}: {str(c.get('Body'))[:300]}" for c in d['comments']]
197
+ return '\n'.join(lines)
198
+
199
+
200
+ def memory_block(store, messages: list) -> str:
201
+ senders = {(m.get('FromEmail') or '').lower() for m in messages if m.get('FromEmail')}
202
+ domains = {s.rsplit('@', 1)[-1] for s in senders if '@' in s}
203
+ hits = [f"- {n['Note']}" for n in store.list_memories()
204
+ if n['Scope'] == 'global' or (n['Scope'] == 'sender' and (n.get('ScopeKey') or '').lower() in senders)
205
+ or (n['Scope'] == 'sender_domain' and (n.get('ScopeKey') or '').lower() in domains)]
206
+ return ('Standing notes (learned from the owner - FOLLOW these):\n' + '\n'.join(hits)) if hits else ''
207
+
208
+
209
+ # dispatch() lived here: one open->close HEADLESS run on a task, the CLI working and closing
210
+ # where nobody could watch it, interrupt it or answer it. That is precisely the thing this app
211
+ # exists to replace, and every road that used it now opens a REAL session instead
212
+ # (terminal.start_on_task) or, for a two-sentence reply, asks the main AI directly
213
+ # (responder.write_draft). It is deleted rather than left dormant: a headless runner sitting
214
+ # in the module is a headless runner somebody wires back up.
215
+ #
216
+ # run_cli above STAYS, and is not the same thing: it is a one-shot "ask this CLI a question"
217
+ # used as a cheap BRAIN (llm.make_cli_llm - triage, drafts, summaries on an agent's light
218
+ # model) and by the connector test. No task, no run row, no work performed.
taskuary/artifacts.py ADDED
@@ -0,0 +1,209 @@
1
+ """Report output you can actually use: the spreadsheet and the chart, not just the prose.
2
+
3
+ A report already produces ROWS - the message body is one JSON object per line - so the same
4
+ run can hand back an .xlsx to open in Excel and an .svg chart to look at in the panel. Both
5
+ are written as attachments on the report message, which means the timeline and the task page
6
+ already know how to show them (images inline, files as chips).
7
+
8
+ Both writers are hand-rolled on the standard library on purpose: xlsx is a zip of four small
9
+ XML parts, and a bar chart is a few dozen rects. Neither is worth a dependency that has to be
10
+ frozen into the one-file exe.
11
+ """
12
+ import json, re, zipfile
13
+ from datetime import datetime
14
+ from xml.sax.saxutils import escape
15
+
16
+ MAX_SHEET_ROWS, MAX_COLS, MAX_BARS = 5000, 40, 24
17
+ _SAFE = re.compile(r'[^A-Za-z0-9._ -]+')
18
+
19
+
20
+ def attachment_dir(mid: int):
21
+ """Where a message's files live. One folder per message, so deleting a message's bytes is
22
+ deleting a folder."""
23
+ from . import config
24
+ p = config.home() / 'attachments' / str(int(mid))
25
+ p.mkdir(parents=True, exist_ok=True)
26
+ return p
27
+
28
+
29
+ def rows_from_body(text: str) -> list:
30
+ """The rows back out of a report body (one JSON object per line). Anything that is not a
31
+ JSON object is prose - a summary, an error - and is not data."""
32
+ out = []
33
+ for l in (text or '').splitlines():
34
+ l = l.strip()
35
+ if not l.startswith('{'): continue
36
+ try:
37
+ d = json.loads(l)
38
+ if isinstance(d, dict): out.append(d)
39
+ except ValueError:
40
+ pass
41
+ return out[:MAX_SHEET_ROWS]
42
+
43
+
44
+ def columns(rows: list) -> list:
45
+ """Every key any row has, in the order they first appear - SQL rows are uniform, REST rows
46
+ are not, and a missing key must not shift a column."""
47
+ cols = []
48
+ for r in rows:
49
+ for k in r:
50
+ if k not in cols: cols.append(k)
51
+ return cols[:MAX_COLS]
52
+
53
+
54
+ def _cell(ref: str, v) -> str:
55
+ # a missing value writes NO cell (refs are explicit, so nothing shifts): an empty inline
56
+ # string is a value, and Excel treats "" and blank differently in counts and filters
57
+ if v is None or v == '': return ''
58
+ if isinstance(v, bool): v = str(v)
59
+ if isinstance(v, (int, float)):
60
+ return f'<c r="{ref}"><v>{v}</v></c>'
61
+ s = json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v)
62
+ return f'<c r="{ref}" t="inlineStr"><is><t xml:space="preserve">{escape(s)}</t></is></c>'
63
+
64
+
65
+ def _col_name(i: int) -> str:
66
+ s = ''
67
+ while True:
68
+ s, i = chr(65 + i % 26) + s, i // 26 - 1
69
+ if i < 0: return s
70
+
71
+
72
+ def to_xlsx(rows: list, path, sheet: str = 'Report') -> bool:
73
+ """Minimal but real xlsx: header row bold-free, inline strings, numbers as numbers so Excel
74
+ sums them. Returns False when there is nothing tabular to write."""
75
+ if not rows: return False
76
+ cols = columns(rows)
77
+ xml = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
78
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>']
79
+ xml.append('<row r="1">' + ''.join(_cell(f'{_col_name(i)}1', c) for i, c in enumerate(cols)) + '</row>')
80
+ for n, r in enumerate(rows, start=2):
81
+ xml.append(f'<row r="{n}">' + ''.join(_cell(f'{_col_name(i)}{n}', r.get(c)) for i, c in enumerate(cols)) + '</row>')
82
+ xml.append('</sheetData></worksheet>')
83
+ name = escape(_SAFE.sub('', str(sheet))[:31] or 'Report')
84
+ with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
85
+ z.writestr('[Content_Types].xml',
86
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
87
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
88
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
89
+ '<Default Extension="xml" ContentType="application/xml"/>'
90
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
91
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
92
+ '</Types>')
93
+ z.writestr('_rels/.rels',
94
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
95
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
96
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
97
+ '</Relationships>')
98
+ z.writestr('xl/workbook.xml',
99
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
100
+ '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
101
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
102
+ f'<sheets><sheet name="{name}" sheetId="1" r:id="rId1"/></sheets></workbook>')
103
+ z.writestr('xl/_rels/workbook.xml.rels',
104
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
105
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
106
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
107
+ '</Relationships>')
108
+ z.writestr('xl/worksheets/sheet1.xml', ''.join(xml))
109
+ return True
110
+
111
+
112
+ def _num(v):
113
+ if isinstance(v, bool) or v is None: return None
114
+ if isinstance(v, (int, float)): return float(v)
115
+ try: return float(str(v).replace(',', '').replace('$', '').strip())
116
+ except ValueError: return None
117
+
118
+
119
+ _CHART_LINE = re.compile(r'^[ \t]*CHART:[ \t]*([^|\r\n]+?)[ \t]*(?:\|[ \t]*([^\r\n]*))?$', re.M | re.I)
120
+
121
+ def chart_directive(text: str) -> tuple:
122
+ """(value column, label column, title) if the summary asked for a particular chart. The AI
123
+ has just read every row, so it knows which column is the measure and which is the name -
124
+ better than a heuristic scanning for "all numeric". Format: `CHART: amount | vendor | Spend
125
+ by vendor`. Absent, or naming a column that is not there, falls back to the guess."""
126
+ mt = _CHART_LINE.search(text or '')
127
+ if not mt: return None, None, ''
128
+ bits = [b.strip() for b in ((mt.group(1) or '') + '|' + (mt.group(2) or '')).split('|')]
129
+ return (bits[0] or None), (bits[1] if len(bits) > 1 and bits[1] else None), (bits[2] if len(bits) > 2 else '')
130
+
131
+
132
+ def strip_directive(text: str) -> str:
133
+ """The CHART: line is an instruction to Taskuary, not prose for the reader."""
134
+ return _CHART_LINE.sub('', text or '').strip()
135
+
136
+
137
+ def chart_columns(rows: list) -> tuple:
138
+ """(label column, value column) - the first column that reads as a name, and the first that
139
+ reads as a number in every row. No numbers means no chart; a table of ids and strings is not
140
+ a chart just because we can draw axes."""
141
+ cols = columns(rows)
142
+ vals = [c for c in cols if all(_num(r.get(c)) is not None for r in rows if c in r)
143
+ and any(_num(r.get(c)) for r in rows)]
144
+ labels = [c for c in cols if c not in vals]
145
+ return ((labels[0] if labels else None), (vals[0] if vals else None))
146
+
147
+
148
+ # Taskuary's own indigo, and a chart that reads in both the panel and a screenshot of it.
149
+ def to_svg_chart(rows: list, path, title: str = '', want_val: str = None, want_lab: str = None) -> str:
150
+ """Horizontal bars: label, bar, value. Horizontal because report labels are names and dates,
151
+ which do not fit under a vertical bar without turning sideways. Returns the caption, or ''
152
+ when the rows carry nothing to plot. `want_val`/`want_lab` are the AI's pick, honoured only
153
+ when the column is really in the rows - a hallucinated column must not lose the chart."""
154
+ cols = columns(rows)
155
+ lab, val = chart_columns(rows)
156
+ if want_val in cols and any(_num(r.get(want_val)) is not None for r in rows): val = want_val
157
+ if want_lab in cols: lab = want_lab
158
+ if not val: return ''
159
+ pts = [(str(r.get(lab, ''))[:42] if lab else f'#{i + 1}', _num(r.get(val)) or 0.0)
160
+ for i, r in enumerate(rows)][:MAX_BARS]
161
+ if not pts: return ''
162
+ top = max(abs(v) for _l, v in pts) or 1.0
163
+ W, LW, RH, PAD = 760, 210, 22, 18
164
+ H = PAD * 2 + 26 + RH * len(pts)
165
+ out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}" '
166
+ f'font-family="Segoe UI, Helvetica, Arial, sans-serif">',
167
+ f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
168
+ f'<text x="{PAD}" y="{PAD + 12}" font-size="13" font-weight="700" fill="#1f2430">'
169
+ f'{escape(title or val)}</text>',
170
+ f'<text x="{W - PAD}" y="{PAD + 12}" font-size="10" fill="#8a94a6" text-anchor="end">'
171
+ f'{escape(val)} by {escape(lab or "row")} · {len(pts)} of {len(rows)}</text>']
172
+ bar_w = W - LW - PAD * 2 - 60
173
+ for i, (l, v) in enumerate(pts):
174
+ y = PAD + 26 + i * RH
175
+ w = max(1, int(bar_w * abs(v) / top))
176
+ out += [f'<text x="{LW}" y="{y + 14}" font-size="11" fill="#697386" text-anchor="end">{escape(l)}</text>',
177
+ f'<rect x="{LW + 8}" y="{y + 4}" width="{w}" height="{RH - 9}" rx="2" fill="#4f46e5" fill-opacity="0.85"/>',
178
+ f'<text x="{LW + 14 + w}" y="{y + 14}" font-size="10.5" fill="#1f2430">'
179
+ f'{escape(f"{v:,.2f}".rstrip("0").rstrip("."))}</text>']
180
+ out.append('</svg>')
181
+ svg = ''.join(out)
182
+ if path is not None: path.write_text(svg, encoding='utf-8')
183
+ return svg if path is None else f'{val} by {lab or "row"}'
184
+
185
+
186
+ XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
187
+
188
+ def attach_report_output(store, mid: int, title: str, body: str) -> list:
189
+ """Turn one report's rows into files on its message: the spreadsheet, and the chart when the
190
+ data has a measure in it. Prose-only reports (an AI summary, a failure) produce neither, and
191
+ `report_images_enabled` = 0 turns the chart off without losing the spreadsheet."""
192
+ rows = rows_from_body(body)
193
+ if not rows: return []
194
+ want_val, want_lab, want_title = chart_directive(body)
195
+ charts_on = str(store.get_settings().get('report_images_enabled') or '1') == '1'
196
+ stem = _SAFE.sub('', title or 'report').strip()[:60] or 'report'
197
+ day = datetime.now().strftime('%Y-%m-%d')
198
+ made = []
199
+ d = attachment_dir(mid)
200
+ x = d / f'{stem} {day}.xlsx'
201
+ if to_xlsx(rows, x, stem[:31]):
202
+ made.append(store.add_attachment({'MessageId': mid, 'ExternalId': f'report:{mid}:xlsx', 'Name': x.name,
203
+ 'ContentType': XLSX_TYPE, 'Size': x.stat().st_size, 'Path': str(x)}))
204
+ c = d / f'{stem} {day}.svg'
205
+ if charts_on and to_svg_chart(rows, c, want_title or stem, want_val, want_lab):
206
+ made.append(store.add_attachment({'MessageId': mid, 'ExternalId': f'report:{mid}:chart', 'Name': c.name,
207
+ 'ContentType': 'image/svg+xml', 'Size': c.stat().st_size,
208
+ 'Inline': 1, 'Path': str(c)}))
209
+ return made