taskuary 0.3.2.7__tar.gz → 0.3.2.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskuary-0.3.2.7/taskuary.egg-info → taskuary-0.3.2.9}/PKG-INFO +2 -2
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/README.md +1 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/pyproject.toml +1 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/agents.py +42 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/assistant.py +57 -2
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/aws.py +7 -2
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/azure.py +10 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/channels.py +33 -11
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/ci.py +16 -10
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/clis.py +21 -0
- taskuary-0.3.2.9/taskuary/deps.py +80 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/general.py +44 -10
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/github.py +1 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/guard.py +93 -9
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/hooks.py +5 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/imapmail.py +137 -31
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/ingest.py +7 -4
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/intacct.py +12 -6
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/llm.py +59 -16
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/messengers.py +4 -3
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/proof.py +6 -3
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/quickbooks.py +11 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/reports.py +24 -2
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/selfclose.py +2 -3
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/semantic.py +2 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/server.py +298 -46
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/store.py +79 -10
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/templates/coder.md +12 -5
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/templates/triage.md +2 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/terminal.py +67 -14
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/triage.py +24 -8
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/update.py +42 -8
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/verdicts.py +59 -5
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/wabridge.py +14 -2
- taskuary-0.3.2.7/taskuary/web/assets/GeneralWorkspace-Cmu6KGGe.js → taskuary-0.3.2.9/taskuary/web/assets/GeneralWorkspace-ABnjUzHR.js +1 -1
- taskuary-0.3.2.9/taskuary/web/assets/GeneralWorkspace-B-DtA5wQ.css +1 -0
- taskuary-0.3.2.7/taskuary/web/assets/SocialView-kma4W7JV.js → taskuary-0.3.2.9/taskuary/web/assets/SocialView-dbQDPhm1.js +1 -1
- taskuary-0.3.2.9/taskuary/web/assets/index-BAmPYygs.js +1018 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/web/index.html +1 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/whatsapp/bridge.mjs +6 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/whoami.py +33 -21
- {taskuary-0.3.2.7 → taskuary-0.3.2.9/taskuary.egg-info}/PKG-INFO +2 -2
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary.egg-info/SOURCES.txt +7 -4
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_agent_environment.py +19 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_answers.py +52 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_api.py +106 -9
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_assistant.py +45 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_async_triage.py +3 -0
- taskuary-0.3.2.9/tests/test_audit_fixes.py +220 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_cloud.py +1 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_core.py +33 -4
- taskuary-0.3.2.9/tests/test_deps.py +77 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_desktop.py +12 -4
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_general.py +38 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_guard.py +15 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_imapmail.py +72 -3
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_kind_dispatch.py +1 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_learnedgraph.py +2 -2
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_pr_comments.py +18 -2
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_proof_ci_proposals.py +24 -4
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_quickbooks.py +14 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_stay_open_doors.py +25 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_store_hotpaths.py +4 -2
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_terminal.py +40 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_update.py +28 -5
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_verdict_sticks.py +5 -1
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_whoami.py +15 -0
- taskuary-0.3.2.7/taskuary/web/assets/GeneralWorkspace-BpkuvgFU.css +0 -1
- taskuary-0.3.2.7/taskuary/web/assets/index-rF2UKXaH.js +0 -1014
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/LICENSE +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/setup.cfg +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/__init__.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/aisetup.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/artifacts.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/blackboard.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/browserview.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/calendar.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/categories.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/cli.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/climodels.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/coder.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/compose.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/config.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/context.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/counsel.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/db.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/demo.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/desktop.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/devtools.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/digest.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/docsync.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/evalset.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/handbook.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/handraise.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/histgen.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/imessage.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/interview.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/knowledge.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/learn.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/learnedgraph.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/logs.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/mcp.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/msauth.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/mssql.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/outbound.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/outbox.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/ownwork.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/phone.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/playbooks.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/pm.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/policy.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/problems.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/promptmap.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/proposals.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/rank.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/research.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/reshape.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/responder.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/routing.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/scopes.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/senders.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/setup.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/sharepoint.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/sheets.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/spawn.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/teller.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/templates/counsel.md +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/templates/digest.md +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/templates/learned.md +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/templates/playbook.md +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/templates/soul.md +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/templates/style.md +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/testing.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/toil.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/voice.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/waitroom.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/web/favicon.ico +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/web/favicon.png +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/webguard.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/whatsapp/package.json +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary/witness.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary.egg-info/dependency_links.txt +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary.egg-info/entry_points.txt +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary.egg-info/requires.txt +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/taskuary.egg-info/top_level.txt +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_agent_source.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_agent_wall.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_aisetup.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_answered_yourself.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_assistant_verdicts.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_audit_chain.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_aws_region.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_blackboard.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_browser_session.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_browserview.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_calendar.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_calendar_today.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_categories.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_cc_triage.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_chat_doors.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_chat_queue.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_climodels.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_clis.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_cloud_sources.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_compose.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_compose_sources.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_config.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_context.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_counsel.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_cwlogs_paging.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_demo.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_devtools.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_diff_review.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_digest_brief.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_docs_and_llm.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_docs_flow.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_duplicate_connectors.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_factory.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_feed_working.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_finished_chat_stays_finished.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_github_auto.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_handbook.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_handbook_topics.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_handoff_channels.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_handraise.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_harvest.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_histgen.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_home_is_never_yours.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_imap_history.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_imessage.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_intacct.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_knowledge.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_local_file.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_mail_folders.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_mark_read.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_mcp.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_meeting_prep.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_message_thread.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_messengers.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_msauth.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_mssql.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_noop_ending.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_not_coding.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_note_retrieval.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_outbound_reports.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_own_rows_no_reply.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_playbooks.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_pm.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_poll_overlap.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_poll_sources.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_problems.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_promptmap_and_catalog.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_pypi_downloads.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_rank.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_reply_channels.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_reply_voice.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_report_alerts.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_report_triage.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_research.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_reviews.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_scopes.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_seed_fits_a_tty.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_seeded_reports.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_semantic.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_send_targets.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_setup.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_sheets_sharepoint.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_someone_else_replied.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_soul_interview.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_stay_open.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_store_app_cli.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_sync_clock.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_task_identity.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_teller.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_toil.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_triage_dataset.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_urgent_and_handoff.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_verdict_paths.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_vision.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_voice.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_wabridge.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_waitroom.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_watermark.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_web_entrypoint.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_webguard.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_whatsapp_documents.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_whatsapp_images.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_withdrawn.py +0 -0
- {taskuary-0.3.2.7 → taskuary-0.3.2.9}/tests/test_witness.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskuary
|
|
3
|
-
Version: 0.3.2.
|
|
3
|
+
Version: 0.3.2.9
|
|
4
4
|
Summary: Automate your job - the local-first operating system for your work: AI triage in, coding agents out.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/ldbumble/taskuary
|
|
@@ -64,7 +64,7 @@ and replies wait for your approval. Nothing sends or ships without you.
|
|
|
64
64
|
|
|
65
65
|

|
|
66
66
|
|
|
67
|
-
Taskuary is early—currently **v0.3.2.
|
|
67
|
+
Taskuary is early—currently **v0.3.2.9**—and moving fast. The funnel, review queue,
|
|
68
68
|
agent sessions, and reports pipeline are in daily use; breaking changes are still possible
|
|
69
69
|
before 1.0.
|
|
70
70
|
|
|
@@ -13,7 +13,7 @@ and replies wait for your approval. Nothing sends or ships without you.
|
|
|
13
13
|
|
|
14
14
|

|
|
15
15
|
|
|
16
|
-
Taskuary is early—currently **v0.3.2.
|
|
16
|
+
Taskuary is early—currently **v0.3.2.9**—and moving fast. The funnel, review queue,
|
|
17
17
|
agent sessions, and reports pipeline are in daily use; breaking changes are still possible
|
|
18
18
|
before 1.0.
|
|
19
19
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "taskuary"
|
|
7
|
-
version = "0.3.2.
|
|
7
|
+
version = "0.3.2.9"
|
|
8
8
|
description = "Automate your job - the local-first operating system for your work: AI triage in, coding agents out."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -23,6 +23,18 @@ def _git(cwd, *args):
|
|
|
23
23
|
return ''
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _git_rc(cwd, *args, timeout=30):
|
|
27
|
+
"""(exit code, stdout+stderr) - for the git calls whose FAILURE is the information. _git()
|
|
28
|
+
answers '' for both 'nothing to say' and 'refused', which is how a rejected push was filed as
|
|
29
|
+
pushed (audit 2026-09-02); git also writes push output to stderr, which _git never read."""
|
|
30
|
+
try:
|
|
31
|
+
p = spawn.run(['git', '-C', cwd or os.getcwd(), *args], capture_output=True, text=True,
|
|
32
|
+
encoding='utf-8', errors='replace', timeout=timeout)
|
|
33
|
+
return p.returncode, ((p.stdout or '') + (p.stderr or '')).strip()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
return 1, str(e)
|
|
36
|
+
|
|
37
|
+
|
|
26
38
|
def parse_cli_json(stdout: str):
|
|
27
39
|
"""Claude-style single JSON object -> (result, session_id); plain text falls through."""
|
|
28
40
|
try:
|
|
@@ -200,6 +212,12 @@ def _live_line(j):
|
|
|
200
212
|
# the CLI's own login has lapsed - nothing in Taskuary can renew it, only the user at a terminal can
|
|
201
213
|
_SIGNED_OUT = re.compile(r'OAuth session expired|Failed to authenticate|not logged in|Not logged in|please (?:run )?[`\']?(?:claude )?/?login|codex login|401 Unauthorized', re.I)
|
|
202
214
|
_LOGIN_HOW = {'claude': "run `claude`, type `/login` and finish the sign-in", 'codex': "run `codex login` and finish the sign-in"}
|
|
215
|
+
# Provider/plan exhaustion is different from an agent failing the work. Only this availability
|
|
216
|
+
# class is safe to hand to another configured agent automatically: a compile error should remain
|
|
217
|
+
# with the agent that owns it, while "session limit; resets at 11:50" should not strand the task.
|
|
218
|
+
_UNAVAILABLE = re.compile(
|
|
219
|
+
r'session limit|usage limit|rate limit|quota|capacity|temporarily unavailable|service unavailable|'
|
|
220
|
+
r'too many requests|resource exhausted|try again (?:at|after|later)|resets? (?:at|in)', re.I)
|
|
203
221
|
|
|
204
222
|
def signed_out_msg(name: str, why: str) -> str:
|
|
205
223
|
how = _LOGIN_HOW.get(name, f"run `{name}` and sign in again")
|
|
@@ -256,6 +274,13 @@ def runs_here(profile: dict) -> bool:
|
|
|
256
274
|
except (FileNotFoundError, OSError): return False
|
|
257
275
|
|
|
258
276
|
|
|
277
|
+
def availability_failure(error) -> bool:
|
|
278
|
+
"""Can the same untouched work safely be retried on a backup provider?"""
|
|
279
|
+
text = str(error or '')
|
|
280
|
+
return isinstance(error, (FileNotFoundError, PermissionError)) or bool(
|
|
281
|
+
_UNAVAILABLE.search(text) or _SIGNED_OUT.search(text) or _DENIED.search(text) or _NO_HOME.search(text))
|
|
282
|
+
|
|
283
|
+
|
|
259
284
|
def profiles(store) -> dict:
|
|
260
285
|
out = {}
|
|
261
286
|
for a in store.list_agents():
|
|
@@ -277,6 +302,23 @@ def default_agent(store) -> str:
|
|
|
277
302
|
return next((n for n, prof in profs.items() if runs_here(prof)), want)
|
|
278
303
|
|
|
279
304
|
|
|
305
|
+
def agent_chain(store, primary: str = None) -> list[str]:
|
|
306
|
+
"""Primary plus ordered configured fallbacks, once each.
|
|
307
|
+
|
|
308
|
+
`backup_agents=*` is the out-of-box resilient choice: every other roster entry, in the
|
|
309
|
+
stable order the owner sees. Naming a CSV narrows and orders the chain explicitly.
|
|
310
|
+
"""
|
|
311
|
+
names = [str(a.get('Name') or '').strip() for a in store.list_agents() if a.get('Name')]
|
|
312
|
+
head = str(primary or default_agent(store) or '').strip()
|
|
313
|
+
if head and head not in names: return [head]
|
|
314
|
+
setting = str(store.get_settings().get('backup_agents') or '').strip()
|
|
315
|
+
backups = names if setting == '*' else [x.strip() for x in setting.split(',') if x.strip()]
|
|
316
|
+
out = []
|
|
317
|
+
for name in [head, *backups]:
|
|
318
|
+
if name and name not in out and name in names: out.append(name)
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
280
322
|
def run_cli(profile: dict, prompt: str, trace, resume: str = None, cancel=None):
|
|
281
323
|
"""One headless invocation of the configured CLI, output STREAMED line by line into
|
|
282
324
|
the run trace so the Board shows the agent working live. claude's stream-json events
|
|
@@ -131,6 +131,11 @@ def _gist(body, n=180) -> str:
|
|
|
131
131
|
from .triage import strip_boilerplate
|
|
132
132
|
return _short(strip_boilerplate(_BANNER.sub('', str(body or ''))), n)
|
|
133
133
|
|
|
134
|
+
|
|
135
|
+
def _reply_text(review: dict | None) -> str:
|
|
136
|
+
"""The words that left through Review, never an unsent draft."""
|
|
137
|
+
return str((review or {}).get('FinalText') or (review or {}).get('DraftText') or '').strip()
|
|
138
|
+
|
|
134
139
|
_OOO = re.compile(r'^(automatic reply|auto(matic)?[ -]?reply|out of (the )?office)', re.I)
|
|
135
140
|
_UNTIL = re.compile(r'\b(until|through|returning( on)?|back (on|in the office on))\s+([A-Z][a-z]+day,?\s+)?([A-Z][a-z]+ \d{1,2}(st|nd|rd|th)?|\d{1,2}/\d{1,2}(/\d{2,4})?)', re.I)
|
|
136
141
|
def ooo(store, days: int = 14) -> dict:
|
|
@@ -394,7 +399,26 @@ def _people_context(store, days: int = 2) -> tuple[str, list[int]]:
|
|
|
394
399
|
who = next((c.get('FromName') or c.get('FromEmail') for c in reversed(chain) if not mine(c)), rs[0].get('FromName') or '?')
|
|
395
400
|
tid = next((c.get('TaskId') for c in reversed(chain) if c.get('TaskId')), None)
|
|
396
401
|
t = store.get_task(tid) if tid else None
|
|
397
|
-
|
|
402
|
+
# A reply sent from Review has a durable send receipt there, but many channels do not
|
|
403
|
+
# ingest that outbound line back into `message` immediately. Without this virtual final
|
|
404
|
+
# message, the Assistant saw the finished task and the inbound ask, then confidently
|
|
405
|
+
# claimed "nobody told them" minutes after the owner had approved and sent the reply.
|
|
406
|
+
sent = store.sent_reply(task_id=tid) if tid else None
|
|
407
|
+
if not sent:
|
|
408
|
+
for c in reversed(chain):
|
|
409
|
+
sent = store.sent_reply(message_id=c['MessageId'])
|
|
410
|
+
if sent: break
|
|
411
|
+
sent_text = _reply_text(sent)
|
|
412
|
+
sent_at = (sent or {}).get('DecidedAt') or (sent or {}).get('CreatedAt')
|
|
413
|
+
# Do not print it twice once the channel has ingested the same outbound line as context.
|
|
414
|
+
sent_norm = ' '.join(sent_text.split()).casefold()
|
|
415
|
+
own_after = any(mine(c) and (
|
|
416
|
+
' '.join(str(c.get('BodyText') or '').split()).casefold() == sent_norm
|
|
417
|
+
or (sent_at and _ts(c.get('SentAt')) >= _ts(sent_at))) for c in chain)
|
|
418
|
+
virtual_reply = bool(sent_text and not own_after)
|
|
419
|
+
last_mine = virtual_reply or mine(last)
|
|
420
|
+
last_at = sent_at if virtual_reply else last.get('SentAt')
|
|
421
|
+
head = (f"- {who} [{rs[0].get('Channel')}] re \"{_short(rs[0].get('Subject'), 60)}\" - {len(rs)} new, last word {'YOURS' if last_mine else 'THEIRS'} {_when(last_at)}"
|
|
398
422
|
+ (f", {task_ref(tid)} {t.get('Kind')} {t.get('Status')}" if t else '') + f" (latest mid {rs[0]['MessageId']})")
|
|
399
423
|
first = lambda c: ((c.get('FromName') or c.get('FromEmail') or '?').split(',')[0].split() or ['?'])[0]
|
|
400
424
|
def quote(c):
|
|
@@ -404,6 +428,8 @@ def _people_context(store, days: int = 2) -> tuple[str, list[int]]:
|
|
|
404
428
|
return (f" {'you' if mine(c) else first(c)} {_when(c['SentAt'])[:6]}: \"{_gist(c.get('BodyText'), 150)}\""
|
|
405
429
|
+ (f" [attachments: {files}]" if files else ''))
|
|
406
430
|
quotes = [quote(c) for c in chain]
|
|
431
|
+
if virtual_reply:
|
|
432
|
+
quotes.append(f" you {_when(sent_at)[:6]}: \"{_gist(sent_text, 150)}\" [reply sent from Review]")
|
|
407
433
|
block = '\n'.join([head] + [q for q in quotes if not q.endswith(': ""')])
|
|
408
434
|
if used + len(block) > PEOPLE_CHARS: break
|
|
409
435
|
out.append(block); used += len(block); mids += [c['MessageId'] for c in chain]
|
|
@@ -510,6 +536,34 @@ def parse(text: str, cands: list, max_lines: int = MAX_LINES) -> list:
|
|
|
510
536
|
return out
|
|
511
537
|
|
|
512
538
|
|
|
539
|
+
_UNANSWERED_CLAIM = re.compile(
|
|
540
|
+
r"\b(no (?:reply|response|answer)|nobody (?:told|replied|answered)|"
|
|
541
|
+
r"has(?:n['’]?t| not) (?:been )?(?:told|answered)|"
|
|
542
|
+
r"(?:i['’]?d|would) (?:reply|answer)|ask (?:him|her|them) to retry)\b", re.I)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def sent_reply_for(store, line: dict) -> dict | None:
|
|
546
|
+
"""Find the successful send behind an idea, including another message in its task thread."""
|
|
547
|
+
action = line.get('action') or {}
|
|
548
|
+
mid, tid = action.get('mid'), action.get('tid')
|
|
549
|
+
reply = store.sent_reply(message_id=mid) if mid else None
|
|
550
|
+
if not tid and mid:
|
|
551
|
+
message = store.get_message(mid)
|
|
552
|
+
tid = (message or {}).get('TaskId')
|
|
553
|
+
return reply or (store.sent_reply(task_id=tid) if tid else None)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def contradicts_sent_reply(store, line: dict) -> bool:
|
|
557
|
+
"""Reject a model idea whose premise is disproved by an actual send receipt.
|
|
558
|
+
|
|
559
|
+
The full reply is also put into the model's people context. This is the final factual gate:
|
|
560
|
+
a model may overlook context, but Taskuary must not publish "nobody replied" when its own
|
|
561
|
+
Review table records the exact response and the successful decision.
|
|
562
|
+
"""
|
|
563
|
+
if not _UNANSWERED_CLAIM.search(str(line.get('text') or '')): return False
|
|
564
|
+
return bool(sent_reply_for(store, line))
|
|
565
|
+
|
|
566
|
+
|
|
513
567
|
def _ids(raw) -> list[int]:
|
|
514
568
|
if not isinstance(raw, list): raw = [] if raw in (None, '') else [raw]
|
|
515
569
|
out = []
|
|
@@ -896,7 +950,8 @@ def _run(store, llm, instruction) -> dict:
|
|
|
896
950
|
# the state is read AGAIN here: another process may have posted while the model was thinking, and a
|
|
897
951
|
# model echoing a dismissed key changes nothing
|
|
898
952
|
state = {i['Key']: i for i in store.list_ideas()}
|
|
899
|
-
say = [s | {'why': s.get('why') or s.get('facts') or ''} for s in say
|
|
953
|
+
say = [s | {'why': s.get('why') or s.get('facts') or ''} for s in say
|
|
954
|
+
if fresh(state, s, now) and not contradicts_sent_reply(store, s)]
|
|
900
955
|
rv = reviewed(cands, say, _recent(store), _open(store), _said(store), used, _week(store), _people(store)) | {'notes': note}
|
|
901
956
|
if not say: return {'ran': True, 'said': 0, 'reviewed': rv, 'inputs': read}
|
|
902
957
|
stamp = now.strftime('%Y-%m-%d %H:%M:%S')
|
|
@@ -15,8 +15,9 @@ def _boto3():
|
|
|
15
15
|
# extra (an editable install pins its dist-info at install time): pip prints
|
|
16
16
|
# "does not provide the extra 'aws'", says everything is already satisfied, and
|
|
17
17
|
# installs nothing. Naming the package itself always works.
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
from .deps import Missing
|
|
19
|
+
raise Missing('boto3', 'boto3 is not installed - the AWS card needs it. Install it from the '
|
|
20
|
+
'card, or run: pip install boto3 (or re-run pip install -e .[aws] from the repo)')
|
|
20
21
|
return boto3
|
|
21
22
|
|
|
22
23
|
|
|
@@ -49,9 +50,13 @@ def client(cfg: dict, service: str, region: str = None):
|
|
|
49
50
|
|
|
50
51
|
def test(cfg: dict) -> dict:
|
|
51
52
|
"""STS caller identity - proves the keys (or the default chain) actually authenticate."""
|
|
53
|
+
from .deps import Missing
|
|
52
54
|
try:
|
|
53
55
|
who = client(cfg, 'sts').get_caller_identity()
|
|
54
56
|
return {'ok': True, 'detail': f"authenticated · account {who['Account']} · {who['Arn']}"}
|
|
57
|
+
# a missing package is not a failed credential, and flattening it to a string here is what
|
|
58
|
+
# left the card printing a pip command instead of offering the button (deps.py)
|
|
59
|
+
except Missing: raise
|
|
55
60
|
except Exception as e:
|
|
56
61
|
return {'ok': False, 'error': str(e)[:500]}
|
|
57
62
|
|
|
@@ -287,7 +287,14 @@ def run_azure(cfg: dict):
|
|
|
287
287
|
array) comes back row-capped and honest; a single object comes back as JSON."""
|
|
288
288
|
from .reports import row_limit, rows_out, BODY_CHARS
|
|
289
289
|
from .aws import dot_path
|
|
290
|
-
|
|
290
|
+
path = str(cfg.get('path') or '')
|
|
291
|
+
if path.startswith('http'):
|
|
292
|
+
# the header carries the app's management token, so a full URL may only point at ARM itself
|
|
293
|
+
# - one typed by an agent used to take the token anywhere (audit 2026-09-02)
|
|
294
|
+
from urllib.parse import urlparse
|
|
295
|
+
if (urlparse(path).hostname or '').lower() != urlparse(ARM).hostname: raise ValueError(f'path must be on {ARM}')
|
|
296
|
+
url = path
|
|
297
|
+
else: url = ARM + '/' + path.lstrip('/')
|
|
291
298
|
params = dict(cfg.get('params') or {})
|
|
292
299
|
params.setdefault('api-version', cfg.get('api_version') or '2022-12-01')
|
|
293
300
|
data = _get(url, token(cfg, f'{ARM}/.default'), params=params).json()
|
|
@@ -303,6 +310,8 @@ def run_azure_blob(cfg: dict):
|
|
|
303
310
|
"""{"account", "container", "blob"} reads the blob (text head); {"account", "container",
|
|
304
311
|
"prefix"} lists. Role: 'Storage Blob Data Reader' on the account (or container)."""
|
|
305
312
|
from .reports import row_limit, rows_out, BODY_CHARS
|
|
313
|
+
if not re.fullmatch(r'[a-z0-9]{3,24}', str(cfg.get('account') or '')): # a name, never 'evil.com/#' (it is the host)
|
|
314
|
+
raise ValueError('account must be a storage account name: 3-24 lowercase letters and digits')
|
|
306
315
|
tok = token(cfg, 'https://storage.azure.com/.default')
|
|
307
316
|
base = f"https://{cfg['account']}.blob.core.windows.net/{cfg['container']}"
|
|
308
317
|
if cfg.get('blob'):
|
|
@@ -312,6 +312,12 @@ def test_connector(store, cid: int) -> dict:
|
|
|
312
312
|
# a failure the owner fixes in the OS (macOS privacy consent) carries the structured
|
|
313
313
|
# half too - which pane, which host - so the card can offer the button, not a paragraph
|
|
314
314
|
if getattr(e, 'setup', None): out['setup'] = e.setup
|
|
315
|
+
# ...and a failure that is only a missing package carries the package, so the card offers
|
|
316
|
+
# Install rather than a pip command the owner has to take somewhere else (deps.py)
|
|
317
|
+
if getattr(e, 'package', None):
|
|
318
|
+
from . import deps
|
|
319
|
+
can, why = deps.can_install()
|
|
320
|
+
out['install'] = {'package': e.package, 'name': deps.pip_name(e.package), 'can': can, 'why': why}
|
|
315
321
|
return out
|
|
316
322
|
|
|
317
323
|
|
|
@@ -423,15 +429,21 @@ def source_folders(s: dict) -> list:
|
|
|
423
429
|
return [f for f in fs if f] or ['inbox']
|
|
424
430
|
|
|
425
431
|
|
|
426
|
-
def _mail_msgs(tok, upn, since, folder='inbox'):
|
|
432
|
+
def _mail_msgs(tok, upn, since, folder='inbox', cap=500):
|
|
427
433
|
# folder-scoped - a bare /messages spans every folder including Sent Items, which made
|
|
428
|
-
# the owner's own replies come back through the funnel as inbound work
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
434
|
+
# the owner's own replies come back through the funnel as inbound work.
|
|
435
|
+
# ...and PAGED: one page of the 25 newest, then a watermark stamped 'now', meant the
|
|
436
|
+
# 26th-newest mail in the window was never asked for again - a busy shared mailbox lost
|
|
437
|
+
# mail every poll with nothing in any log (audit 2026-09-02)
|
|
438
|
+
url = f'{GRAPH}/users/{upn}/mailFolders/{folder}/messages'
|
|
439
|
+
params, out = {'$top': 50, '$orderby': 'receivedDateTime desc', '$select': MAIL_SELECT,
|
|
440
|
+
'$filter': f'receivedDateTime gt {since}'}, []
|
|
441
|
+
while url and len(out) < cap:
|
|
442
|
+
r = requests.get(url, headers={'Authorization': f'Bearer {tok}'}, timeout=30, params=params)
|
|
443
|
+
r.raise_for_status(); j = r.json()
|
|
444
|
+
out += j.get('value') or []
|
|
445
|
+
url, params = j.get('@odata.nextLink'), None # the nextLink carries the filter itself
|
|
446
|
+
return out
|
|
435
447
|
|
|
436
448
|
|
|
437
449
|
def ingest_own_message(store, msg: dict, why: str, keep_unmatched: bool = False) -> int:
|
|
@@ -536,8 +548,12 @@ _HOSTED = re.compile(r'<img[^>]+src="([^"]*?/hostedContents/[^"]*?)"', re.I)
|
|
|
536
548
|
|
|
537
549
|
def hosted_images(tok: str, html: str, cap: int = 4, where: str = '') -> list:
|
|
538
550
|
out = []
|
|
551
|
+
from urllib.parse import urlparse
|
|
539
552
|
for i, url in enumerate(dict.fromkeys(_HOSTED.findall(html or '')).keys()):
|
|
540
553
|
if i >= cap: break
|
|
554
|
+
# the token goes to Graph and nowhere else: any chat participant can put an <img> with
|
|
555
|
+
# their own host in a message body, and the fetch used to hand them the bearer (audit 2026-09-02)
|
|
556
|
+
if (urlparse(unescape(url)).hostname or '').lower() != 'graph.microsoft.com': continue
|
|
541
557
|
try:
|
|
542
558
|
r = requests.get(unescape(url), headers={'Authorization': f'Bearer {tok}'}, timeout=30)
|
|
543
559
|
r.raise_for_status()
|
|
@@ -925,9 +941,15 @@ def poll_channels(store, backfill_days: int = 0, progress=None, only=None) -> in
|
|
|
925
941
|
from . import devtools
|
|
926
942
|
n += devtools.poll_discord(store, full, s, since, llm, file_only)
|
|
927
943
|
elif c['Type'] == 'slack':
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
944
|
+
# paged like mail: one page of 25 and a watermark at 'now' lost the rest (audit 2026-09-02)
|
|
945
|
+
hist_all, cursor = [], None
|
|
946
|
+
while len(hist_all) < 500:
|
|
947
|
+
hist = _slack(tok, 'conversations.history', channel=s['Address'], oldest=since.timestamp(),
|
|
948
|
+
limit=100, **({'cursor': cursor} if cursor else {}))
|
|
949
|
+
hist_all += hist.get('messages', [])
|
|
950
|
+
cursor = (hist.get('response_metadata') or {}).get('next_cursor')
|
|
951
|
+
if not cursor: break
|
|
952
|
+
msgs = [m for m in reversed(hist_all) if not m.get('subtype')]
|
|
931
953
|
# the channel's read cursor is ONE timestamp - the newest line we took
|
|
932
954
|
if read_it and msgs: mark_slack_read(tok, s['Address'], msgs[-1].get('ts'))
|
|
933
955
|
for m in msgs:
|
|
@@ -133,7 +133,7 @@ def push_direct(store, task_id: int, actor='owner') -> dict:
|
|
|
133
133
|
nobody read is exactly the step this product does not take), and nothing ahead of the
|
|
134
134
|
remote is 'nothing to do', not an error. Force is never passed: a rejected push means
|
|
135
135
|
the branch moved underneath you, and the answer is to pull, not to overwrite."""
|
|
136
|
-
from .agents import _git
|
|
136
|
+
from .agents import _git, _git_rc
|
|
137
137
|
from .store import task_ref
|
|
138
138
|
if store.get_settings().get('agent_push_enabled') != '1':
|
|
139
139
|
raise RuntimeError("pushing is off - flip 'Agents may push / deploy' on the GitHub card first")
|
|
@@ -146,9 +146,9 @@ def push_direct(store, task_id: int, actor='owner') -> dict:
|
|
|
146
146
|
if ahead in ('', '0'):
|
|
147
147
|
raise RuntimeError(f'nothing to push - HEAD is not ahead of origin/{base}')
|
|
148
148
|
sha = (_git(cwd, 'rev-parse', 'HEAD') or '').strip()
|
|
149
|
-
out =
|
|
150
|
-
# git says nothing useful on success; a
|
|
151
|
-
if re.search(r'rejected|error:|fatal:', out or '', re.I):
|
|
149
|
+
rc, out = _git_rc(cwd, 'push', 'origin', f'HEAD:{base}', timeout=120)
|
|
150
|
+
# git says nothing useful on success; a non-zero exit IS the rejection we must not swallow
|
|
151
|
+
if rc != 0 or re.search(r'rejected|error:|fatal:', out or '', re.I):
|
|
152
152
|
raise RuntimeError('git refused the push: ' + TOKEN_URL.sub('https://', out)[:300]
|
|
153
153
|
+ ' - pull and rebase, then push again (Taskuary never force-pushes)')
|
|
154
154
|
info = {'repo': repo, 'branch': base, 'from': branch, 'sha': sha, 'commits': int(ahead),
|
|
@@ -222,6 +222,9 @@ def check_task(store, task_id: int, llm=None) -> dict:
|
|
|
222
222
|
return {'state': 'failure', 'fed': True, 'handed': handed, 'at': ref, 'kind': at['kind']}
|
|
223
223
|
|
|
224
224
|
|
|
225
|
+
TRUSTED = ('OWNER', 'MEMBER', 'COLLABORATOR') # GitHub's author_association values that mean 'one of us'
|
|
226
|
+
|
|
227
|
+
|
|
225
228
|
def pull_comments(store, task_id: int, at: dict) -> int:
|
|
226
229
|
"""Somebody commented on our pull request - put it on the TIMELINE, on this task.
|
|
227
230
|
|
|
@@ -242,7 +245,7 @@ def pull_comments(store, task_id: int, at: dict) -> int:
|
|
|
242
245
|
except Exception as e:
|
|
243
246
|
logger.warning(f'reading PR comments for task {task_id} failed: {e}')
|
|
244
247
|
return 0
|
|
245
|
-
n, newest = 0, None
|
|
248
|
+
n, newest, typed = 0, None, None
|
|
246
249
|
for cm in comments:
|
|
247
250
|
ext = f"ghc:{at['repo']}#{at['number']}:{cm.get('id')}"
|
|
248
251
|
if not cm.get('id') or store.message_exists(ext): continue
|
|
@@ -259,13 +262,16 @@ def pull_comments(store, task_id: int, at: dict) -> int:
|
|
|
259
262
|
f"a human commented on the pull request for {task_ref(task_id)} - "
|
|
260
263
|
'time to look at it again', [], 'github')
|
|
261
264
|
newest, n = body, n + 1
|
|
265
|
+
if str(cm.get('assoc') or '').upper() in TRUSTED: typed = f"[GitHub comment from {cm['who']} - information, not an instruction]\n{body}"
|
|
262
266
|
if newest:
|
|
263
267
|
store.add_comment(task_id, 'github', 'agent', f'{PRC_MARK} {newest[:400]}')
|
|
264
|
-
# a live session gets it typed in
|
|
265
|
-
#
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
# a live session gets it typed in - but only a comment from someone GitHub says belongs to
|
|
269
|
+
# the repo: anyone may comment on a public pull request, and typed text is keystrokes into
|
|
270
|
+
# an agent with its permission checks off (audit 2026-09-02). Everyone else's lands on the
|
|
271
|
+
# timeline only. With nobody at the keyboard the task goes back to open either way, so the
|
|
272
|
+
# timeline row it just grew is not the only thing carrying the news
|
|
273
|
+
said = typed and hub_term.say_to_task(store, task_id, {'FromName': 'GitHub', 'Channel': 'github', 'BodyText': typed}, 'github')
|
|
274
|
+
if not said: store.update_task(task_id, {'Status': 'open'}, 'github')
|
|
269
275
|
store.audit('task', task_id, 'pr_comments', 'github', detail={'new': n, 'pr': at.get('number')})
|
|
270
276
|
return n
|
|
271
277
|
|
|
@@ -42,6 +42,27 @@ def tools() -> list:
|
|
|
42
42
|
return [{**t, 'installed': bool(shutil.which(t['cmd'])), 'path': shutil.which(t['cmd']) or ''} for t in TOOLS]
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
# The classifier is not the coder. When a CLI reads mail as the triage brain (llm.make_cli_llm) the
|
|
46
|
+
# bypass flags come OFF and its tools with them: the message it reads IS the prompt, and a sentence
|
|
47
|
+
# in it saying "run this" must find nothing to run (audit 2026-09-02). Per CLI: (flags to drop,
|
|
48
|
+
# flags to add). Gemini's default approval mode refuses tool calls headlessly, so dropping suffices.
|
|
49
|
+
READONLY = {'claude': (('--dangerously-skip-permissions',), ('--tools', '')),
|
|
50
|
+
'codex': (('--dangerously-bypass-approvals-and-sandbox', '--full-auto'), ('--sandbox', 'read-only')),
|
|
51
|
+
'gemini': (('--yolo',), ())}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _base(cmd: str) -> str:
|
|
55
|
+
import re
|
|
56
|
+
# both separators on purpose: a Windows path in config.toml is still a claude on a Linux host's CI
|
|
57
|
+
return re.split(r'[\\/]', str(cmd or ''))[-1].lower().rsplit('.', 1)[0]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def readonly_args(cmd: str, args: list) -> list:
|
|
61
|
+
"""`args` with the permission bypass removed and the CLI's own no-tools flags added."""
|
|
62
|
+
drop, add = READONLY.get(_base(cmd), ((), ()))
|
|
63
|
+
return [a for a in args if a not in drop] + list(add)
|
|
64
|
+
|
|
65
|
+
|
|
45
66
|
def preset_args(cmd: str) -> list:
|
|
46
67
|
"""The known CLI's headless flags, for a profile that names a cmd and nothing else. A profile
|
|
47
68
|
saved as just `cmd = "claude"` used to run bare `claude -p`: no permission flag, so a
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The optional packages, and the one button that installs them.
|
|
2
|
+
|
|
3
|
+
Taskuary ships thin. boto3, pyodbc, sqlalchemy, openpyxl, pypdf and the rest are each wanted by
|
|
4
|
+
one card or one file type, and most installs want none of them - so "not installed" is a normal
|
|
5
|
+
thing to meet, not a broken install. What was not normal was the answer. "Run pip install boto3"
|
|
6
|
+
sends the owner off to find a terminal and work out WHICH Python the app is running in: the
|
|
7
|
+
packaged exe has none, a venv is usually not the one on PATH, and an editable checkout is a third
|
|
8
|
+
answer again. The app knows all of that already. So it offers a button.
|
|
9
|
+
|
|
10
|
+
The list is closed on purpose. This runs pip, and pip runs arbitrary setup code: an open field
|
|
11
|
+
here would be "install anything on this machine" wearing a connector's clothes. That is also why
|
|
12
|
+
POST /api/deps/install is on guard.DENIED - it is the owner's button, never an agent's.
|
|
13
|
+
"""
|
|
14
|
+
import importlib, importlib.util, sys
|
|
15
|
+
|
|
16
|
+
from loguru import logger
|
|
17
|
+
|
|
18
|
+
from . import spawn
|
|
19
|
+
|
|
20
|
+
# import name -> what to ask pip for. The pins follow pyproject's extras. A name that is not here
|
|
21
|
+
# cannot be installed from the UI, and install() says so rather than pretending.
|
|
22
|
+
OPTIONAL = {
|
|
23
|
+
'boto3': 'boto3>=1.28', # the AWS card
|
|
24
|
+
'pyodbc': 'pyodbc>=4', # SQL Server
|
|
25
|
+
'sqlalchemy': 'sqlalchemy>=2', # the 'any database' card (plus its engine driver)
|
|
26
|
+
'psycopg2': 'psycopg2-binary', # ...Postgres
|
|
27
|
+
'pymysql': 'pymysql', # ...MySQL
|
|
28
|
+
'openpyxl': 'openpyxl>=3', # reading .xlsx attachments
|
|
29
|
+
'pypdf': 'pypdf', # reading .pdf into the knowledge base
|
|
30
|
+
'faster_whisper': 'faster-whisper>=1.0', # local speech to text
|
|
31
|
+
'winpty': 'pywinpty', # the interactive terminal on Windows
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Missing(RuntimeError):
|
|
36
|
+
"""An optional package is not here. Carries the import name, so a card that catches this can
|
|
37
|
+
offer the button instead of printing a command (channels.test_connector)."""
|
|
38
|
+
def __init__(self, pkg: str, message: str = ''):
|
|
39
|
+
self.package = pkg
|
|
40
|
+
super().__init__(message or f'{pkg} is not installed')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pip_name(pkg: str) -> str:
|
|
44
|
+
"""What the owner should see: 'pywinpty', not 'winpty>=0'."""
|
|
45
|
+
return str(OPTIONAL.get(pkg) or pkg).split('>')[0].split('=')[0].split('[')[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def installed(pkg: str) -> bool:
|
|
49
|
+
try: return importlib.util.find_spec(pkg) is not None
|
|
50
|
+
except (ImportError, ValueError): return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def can_install() -> tuple:
|
|
54
|
+
"""(may we, why not). A frozen exe has no pip and no site-packages of its own to install into,
|
|
55
|
+
so a button there would only ever fail - say which install to use instead."""
|
|
56
|
+
if getattr(sys, 'frozen', False):
|
|
57
|
+
return False, ('this is the packaged Taskuary.exe, which has no Python to install into - '
|
|
58
|
+
'the cards that need an extra package want the pip or source install')
|
|
59
|
+
return True, ''
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def install(pkg: str, timeout: int = 900) -> dict:
|
|
63
|
+
"""pip install one of OPTIONAL into the interpreter running Taskuary - which is the whole
|
|
64
|
+
point: it is the one Python whose site-packages this process will actually import from."""
|
|
65
|
+
ok, why = can_install()
|
|
66
|
+
if not ok: raise RuntimeError(why)
|
|
67
|
+
req = OPTIONAL.get(pkg)
|
|
68
|
+
if not req:
|
|
69
|
+
raise ValueError(f'{pkg} is not one of the packages Taskuary installs '
|
|
70
|
+
f'({", ".join(sorted(OPTIONAL))})')
|
|
71
|
+
r = spawn.run([sys.executable, '-m', 'pip', 'install', '--no-input', req],
|
|
72
|
+
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout)
|
|
73
|
+
out = ((r.stdout or '') + (r.stderr or '')).strip()
|
|
74
|
+
if r.returncode != 0:
|
|
75
|
+
raise RuntimeError(f'pip could not install {req}: ' + out[-400:])
|
|
76
|
+
# a package installed into a RUNNING interpreter is invisible until the finders are told
|
|
77
|
+
importlib.invalidate_caches()
|
|
78
|
+
logger.info(f'installed {req} into {sys.executable}')
|
|
79
|
+
return {'ok': True, 'package': pkg, 'name': pip_name(pkg), 'python': sys.executable,
|
|
80
|
+
'ready': installed(pkg), 'pip': out[-400:]}
|
|
@@ -140,6 +140,19 @@ def _cut(text, n):
|
|
|
140
140
|
return text if len(text) <= n else text[:n] + f'\n[trimmed {len(text) - n:,} characters]'
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
def _task_files(store, tid: int) -> list[dict]:
|
|
144
|
+
"""Files that arrived with the task's source messages, once each, in message order."""
|
|
145
|
+
detail = store.task_detail(tid) or {}
|
|
146
|
+
out, seen = [], set()
|
|
147
|
+
for message in detail.get('messages') or []:
|
|
148
|
+
for attachment in store.list_attachments(message['MessageId']):
|
|
149
|
+
path = str(attachment.get('Path') or '').strip()
|
|
150
|
+
key = path or f"attachment:{attachment.get('AttachmentId')}"
|
|
151
|
+
if key in seen: continue
|
|
152
|
+
seen.add(key); out.append(attachment)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
143
156
|
# The chat is on the wall too (blackboard.py). It has no checkout, so it reads and writes the
|
|
144
157
|
# HOUSE lane - the notes with no repository behind them - and it can only write when a CLI is
|
|
145
158
|
# doing the thinking, because an API provider has no shell to run the command in.
|
|
@@ -185,7 +198,11 @@ def _prompt(store, tid: int) -> tuple[str, str]:
|
|
|
185
198
|
sources = []
|
|
186
199
|
for m in (detail.get('messages') or [])[-12:]:
|
|
187
200
|
who = m.get('FromName') or m.get('FromEmail') or m.get('SourceName') or m.get('Channel') or 'source'
|
|
188
|
-
|
|
201
|
+
files = store.list_attachments(m['MessageId'])
|
|
202
|
+
file_line = ('\nATTACHMENTS: ' + '; '.join(
|
|
203
|
+
str(a.get('Name') or 'file') + (f" ({a.get('Path')})" if a.get('Path') else '')
|
|
204
|
+
for a in files[:8])) if files else ''
|
|
205
|
+
sources.append(f"FROM {who} ({m.get('SentAt') or ''})\n{_cut(m.get('BodyText'), 3_000)}{file_line}")
|
|
189
206
|
turns = []
|
|
190
207
|
for c in chat_rows(store, tid)[-30:]:
|
|
191
208
|
role = 'ASSISTANT' if c.get('ActorType') == ASSISTANT_TYPE else 'USER'
|
|
@@ -209,14 +226,20 @@ def _prompt(store, tid: int) -> tuple[str, str]:
|
|
|
209
226
|
# about has an answer owed, and closing it drafts that answer. A task the owner opened to
|
|
210
227
|
# think out loud in has nobody waiting, so it stays open until they say otherwise.
|
|
211
228
|
if sources and selfclose.mode(store) != 'off': system = system + '\n\n' + selfclose.CHAT_LINE
|
|
212
|
-
|
|
229
|
+
head = (f"TASK {detail.get('ref') or tid}\nTITLE: {task.get('Title') or ''}\n"
|
|
213
230
|
f"SUMMARY: {task.get('Summary') or ''}\nSTATUS: {task.get('Status') or ''}\n\n"
|
|
214
231
|
+ (wall + '\n\n' if wall else '')
|
|
215
|
-
+ (social.strip() + '\n\n' if social else '')
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
232
|
+
+ (social.strip() + '\n\n' if social else ''))
|
|
233
|
+
tail = "\n\nRespond to the last USER turn. Do not repeat the task context."
|
|
234
|
+
# The question sits at the END, so the end is what must survive the budget: the oldest turns go
|
|
235
|
+
# first, then the oldest source material. _cut(user) kept the head and dropped exactly the
|
|
236
|
+
# conversation and the instruction, so a task seeded from eight long mails answered the mails
|
|
237
|
+
# and not the owner (audit 2026-09-02).
|
|
238
|
+
while len(turns) > 1 and len(head) + len('\n\n'.join(turns)) + len(tail) > MAX_CONTEXT: turns.pop(0)
|
|
239
|
+
conv = "CONVERSATION\n" + '\n\n'.join(turns) + tail
|
|
240
|
+
src, room = '\n\n'.join(sources), MAX_CONTEXT - len(head) - len(conv) - 20
|
|
241
|
+
if len(src) > room: src = ('[older source material trimmed]\n\n' + src[-room:]) if room > 400 else ''
|
|
242
|
+
return system, head + (f"SOURCE MATERIAL\n{src}\n\n" if src else '') + conv
|
|
220
243
|
|
|
221
244
|
|
|
222
245
|
def _fallback_report_draft(store, tid: int) -> dict:
|
|
@@ -460,9 +483,9 @@ class GeneralSession:
|
|
|
460
483
|
system = f'{system}\n\n{POST_LINE}'
|
|
461
484
|
from . import handbook
|
|
462
485
|
if handbook.enabled(self.store): system = f'{system}\n\n{SOCIAL_LINE}'
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
486
|
+
source_paths = [a.get('Path') for a in _task_files(self.store, self.task_id) if a.get('Path')]
|
|
487
|
+
paths = list(dict.fromkeys(source_paths + list(attachments or [])
|
|
488
|
+
+ [m.group('path') for m in _IMAGE_PATH.finditer(text)]))
|
|
466
489
|
def visible(kind, name, detail):
|
|
467
490
|
self._remember_trace(kind, name, detail)
|
|
468
491
|
if trace: trace(kind, name, detail)
|
|
@@ -480,6 +503,11 @@ class GeneralSession:
|
|
|
480
503
|
trace=visible, cancel=cancel, resume=self.cli_sid or None)
|
|
481
504
|
if not brain: raise RuntimeError('the selected AI connector is unavailable')
|
|
482
505
|
if self.cli_sid: user = _turn_only(self.store, self.task_id, text)
|
|
506
|
+
# Paths are harmless context to an API brain and essential if its CLI backup takes
|
|
507
|
+
# over after a quota failure. The source message already names its files too; this
|
|
508
|
+
# line also carries images attached directly in the composer.
|
|
509
|
+
if paths:
|
|
510
|
+
user += '\n\nATTACHED FILES (read these when relevant)\n' + '\n'.join(str(Path(p).resolve()) for p in paths)
|
|
483
511
|
try:
|
|
484
512
|
reply = str(brain(system, user, max_tokens=MAX_REPLY_TOKENS, images=_images(paths)) or '').strip()
|
|
485
513
|
except Exception:
|
|
@@ -493,6 +521,12 @@ class GeneralSession:
|
|
|
493
521
|
trace=visible, cancel=cancel)
|
|
494
522
|
reply = str(brain(system, user, max_tokens=MAX_REPLY_TOKENS, images=_images(paths)) or '').strip()
|
|
495
523
|
self.cli_sid = getattr(brain, 'session_id', '') or self.cli_sid
|
|
524
|
+
actual = getattr(brain, 'last_pick', '')
|
|
525
|
+
if actual and actual != self.pick:
|
|
526
|
+
choice = next((o for o in provider_options(self.store) if o['pick'] == actual), None)
|
|
527
|
+
self.pick = actual
|
|
528
|
+
if choice:
|
|
529
|
+
self.provider, self.model = choice['label'], choice.get('model') or ''
|
|
496
530
|
if not reply: raise RuntimeError('the model returned an empty response')
|
|
497
531
|
# the assistant's own "and that's the job done" (selfclose.CHAT_LINE). The marker is a
|
|
498
532
|
# signal to Taskuary, not prose for the owner, so it comes out of what gets filed and
|
|
@@ -108,7 +108,7 @@ def pr_review_comments(tok, repo, number, since=None):
|
|
|
108
108
|
for c in r.json():
|
|
109
109
|
u = (c.get('user') or {})
|
|
110
110
|
if u.get('type') == 'Bot': continue
|
|
111
|
-
out.append({'id': c.get('id'), 'kind': kind, 'who': u.get('login'),
|
|
111
|
+
out.append({'id': c.get('id'), 'kind': kind, 'who': u.get('login'), 'assoc': c.get('author_association') or '',
|
|
112
112
|
'body': (c.get('body') or '')[:2000], 'path': c.get('path'),
|
|
113
113
|
'url': c.get('html_url'), 'at': c.get('created_at')})
|
|
114
114
|
except requests.RequestException:
|