taskuary 0.3.2.1__tar.gz → 0.3.2.2__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.1/taskuary.egg-info → taskuary-0.3.2.2}/PKG-INFO +2 -2
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/README.md +1 -1
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/pyproject.toml +1 -1
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/agents.py +16 -9
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/blackboard.py +95 -2
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/browserview.py +52 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/cli.py +4 -1
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/general.py +104 -6
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/llm.py +8 -4
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/server.py +47 -8
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/store.py +26 -5
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/terminal.py +12 -1
- taskuary-0.3.2.1/taskuary/web/assets/GeneralWorkspace-BYMSD2ui.js → taskuary-0.3.2.2/taskuary/web/assets/GeneralWorkspace-Bt02JQzu.js +7 -7
- taskuary-0.3.2.1/taskuary/web/assets/index-BVar39W8.js → taskuary-0.3.2.2/taskuary/web/assets/index-DGVnbkfG.js +44 -43
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/index.html +1 -1
- {taskuary-0.3.2.1 → taskuary-0.3.2.2/taskuary.egg-info}/PKG-INFO +2 -2
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/SOURCES.txt +4 -2
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_agent_environment.py +15 -5
- taskuary-0.3.2.2/tests/test_agent_wall.py +247 -0
- taskuary-0.3.2.2/tests/test_browser_session.py +89 -0
- taskuary-0.3.2.2/tests/test_chat_queue.py +211 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_climodels.py +1 -1
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_terminal.py +3 -0
- taskuary-0.3.2.1/tests/test_agent_wall.py +0 -132
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/LICENSE +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/setup.cfg +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/__init__.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/aisetup.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/artifacts.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/assistant.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/aws.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/azure.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/calendar.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/categories.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/channels.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/ci.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/climodels.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/clis.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/coder.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/compose.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/config.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/context.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/counsel.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/db.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/desktop.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/devtools.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/digest.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/docsync.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/evalset.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/github.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/handraise.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/histgen.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/hooks.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/imapmail.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/imessage.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/ingest.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/intacct.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/interview.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/knowledge.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/learn.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/learnedgraph.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/logs.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/mcp.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/messengers.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/msauth.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/mssql.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/outbound.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/phone.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/pm.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/policy.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/problems.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/promptmap.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/proof.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/proposals.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/rank.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/reports.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/research.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/reshape.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/responder.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/routing.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/scopes.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/semantic.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/senders.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/setup.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/sharepoint.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/sheets.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/coder.md +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/counsel.md +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/digest.md +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/learned.md +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/soul.md +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/style.md +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/triage.md +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/testing.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/toil.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/triage.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/verdicts.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/voice.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/wabridge.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/waitroom.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/favicon.ico +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/favicon.png +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/webguard.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/whatsapp/bridge.mjs +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/whatsapp/package.json +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/whoami.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/witness.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/dependency_links.txt +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/entry_points.txt +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/requires.txt +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/top_level.txt +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_agent_source.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_aisetup.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_answers.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_api.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_assistant.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_async_triage.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_audit_chain.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_aws_region.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_blackboard.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_browserview.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_calendar.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_calendar_today.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_categories.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_cc_triage.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_clis.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_cloud.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_cloud_sources.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_compose.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_compose_sources.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_config.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_context.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_core.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_counsel.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_cwlogs_paging.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_desktop.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_devtools.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_diff_review.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_digest_brief.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_docs_and_llm.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_docs_flow.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_duplicate_connectors.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_factory.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_feed_working.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_general.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_github_auto.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_handraise.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_harvest.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_histgen.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_imapmail.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_imessage.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_intacct.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_kind_dispatch.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_knowledge.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_learnedgraph.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_local_file.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_mail_folders.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_mark_read.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_mcp.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_messengers.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_msauth.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_mssql.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_noop_ending.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_not_coding.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_note_retrieval.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_outbound_reports.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_pm.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_poll_overlap.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_poll_sources.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_pr_comments.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_problems.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_promptmap_and_catalog.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_proof_ci_proposals.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_pypi_downloads.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_rank.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_reply_channels.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_reply_voice.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_report_alerts.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_report_triage.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_research.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_reviews.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_scopes.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_seeded_reports.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_semantic.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_send_targets.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_setup.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_sheets_sharepoint.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_someone_else_replied.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_soul_interview.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_store_app_cli.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_store_hotpaths.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_sync_clock.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_task_identity.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_toil.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_triage_dataset.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_urgent_and_handoff.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_verdict_paths.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_verdict_sticks.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_vision.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_voice.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_wabridge.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_waitroom.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_watermark.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_web_entrypoint.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_webguard.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_whatsapp_images.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_whoami.py +0 -0
- {taskuary-0.3.2.1 → taskuary-0.3.2.2}/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.2
|
|
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.2**—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.2**—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.2"
|
|
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" }
|
|
@@ -123,7 +123,7 @@ def _resolve_cmd(name: str) -> list:
|
|
|
123
123
|
return [path]
|
|
124
124
|
|
|
125
125
|
|
|
126
|
-
def child_env() -> dict:
|
|
126
|
+
def child_env(base: dict = None, home: str = None, windows: bool = None) -> dict:
|
|
127
127
|
"""The environment a CLI is entitled to expect.
|
|
128
128
|
|
|
129
129
|
codex refuses to start without one: `Error finding codex home: Could not find home
|
|
@@ -133,17 +133,24 @@ def child_env() -> dict:
|
|
|
133
133
|
Python can answer the question, so it answers it instead of letting the CLI guess: HOME
|
|
134
134
|
and USERPROFILE where they are missing, and CODEX_HOME - the variable codex checks FIRST,
|
|
135
135
|
before it ever asks the OS - pointed at the same place its own installer would use.
|
|
136
|
+
|
|
137
|
+
The three arguments exist so the Windows branch can be exercised from a Linux CI box
|
|
138
|
+
without patching os.name out from under the interpreter - which is how the first version
|
|
139
|
+
of this test managed to fail on the platform it was pretending to be.
|
|
136
140
|
"""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
141
|
+
import ntpath # a Windows path, split as one wherever this runs
|
|
142
|
+
env = dict(os.environ if base is None else base)
|
|
143
|
+
if home is None:
|
|
144
|
+
try: home = str(Path.home())
|
|
145
|
+
except (RuntimeError, OSError): return env # nothing better to say than nothing
|
|
140
146
|
env.setdefault('HOME', home)
|
|
141
|
-
if os.name == 'nt':
|
|
142
|
-
import ntpath # a Windows path, split as one wherever this test runs
|
|
147
|
+
if (os.name == 'nt') if windows is None else windows:
|
|
143
148
|
env.setdefault('USERPROFILE', home)
|
|
144
|
-
drive,
|
|
145
|
-
if drive: env.setdefault('HOMEDRIVE', drive); env.setdefault('HOMEPATH',
|
|
146
|
-
|
|
149
|
+
drive, rest = ntpath.splitdrive(home)
|
|
150
|
+
if drive: env.setdefault('HOMEDRIVE', drive); env.setdefault('HOMEPATH', rest)
|
|
151
|
+
env.setdefault('CODEX_HOME', ntpath.join(home, '.codex'))
|
|
152
|
+
else:
|
|
153
|
+
env.setdefault('CODEX_HOME', os.path.join(home, '.codex'))
|
|
147
154
|
return env
|
|
148
155
|
|
|
149
156
|
|
|
@@ -74,6 +74,9 @@ def peers(store, cwd, exclude_tid=None) -> list:
|
|
|
74
74
|
# yet" or "this is ready, push it". Only the agent doing the work knows that, so it writes it
|
|
75
75
|
# down: one line per note, on the checkout, read by whoever comes next.
|
|
76
76
|
KINDS = ('working', 'note', 'blocked', 'ready', 'done')
|
|
77
|
+
SUMMARY = 'summary' # written by the daily roll-up, not by an agent
|
|
78
|
+
ROLLED_ON = 'wall_rolled_on'
|
|
79
|
+
SEED_BUDGET = 620 # what the wall may take of a seed prompt, whatever is on it
|
|
77
80
|
KIND_HINT = {'working': 'what it is doing right now', 'note': 'anything the next agent needs',
|
|
78
81
|
'blocked': 'waiting on something or someone', 'ready': 'finished and safe to push',
|
|
79
82
|
'done': 'pushed or closed out'}
|
|
@@ -86,7 +89,7 @@ def post(store, body: str, kind: str = 'note', agent: str = '', cwd: str = '', t
|
|
|
86
89
|
body = ' '.join(str(body or '').split())[:1200]
|
|
87
90
|
if not body: raise ValueError('a note with no words is not a note')
|
|
88
91
|
kind = str(kind or 'note').lower().strip()
|
|
89
|
-
if kind not in KINDS: raise ValueError(f"kind must be one of {', '.join(KINDS)}")
|
|
92
|
+
if kind not in KINDS + (SUMMARY,): raise ValueError(f"kind must be one of {', '.join(KINDS)}")
|
|
90
93
|
nid = store.add_note({'TaskId': tid, 'Agent': agent or 'agent', 'Cwd': norm(cwd), 'Kind': kind,
|
|
91
94
|
'Body': body, 'Files': files or ''})
|
|
92
95
|
return dict(store.get_note(nid))
|
|
@@ -96,6 +99,92 @@ def wall(store, cwd: str = '', limit: int = 12) -> list:
|
|
|
96
99
|
return store.notes(norm(cwd) if cwd else None, limit)
|
|
97
100
|
|
|
98
101
|
|
|
102
|
+
def house_wall(store, limit: int = 8) -> list:
|
|
103
|
+
"""The lane with no checkout in it: what the assistant chat and the owner leave for
|
|
104
|
+
everyone. A chat has no repository, so this is the whole of its wall."""
|
|
105
|
+
return [n for n in store.notes(None, 200) if not str(n.get('Cwd') or '').strip()][:limit]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def chat_text(store, limit: int = 6) -> str:
|
|
109
|
+
"""The wall paragraph for the assistant chat: the house lane, and how to add to it.
|
|
110
|
+
|
|
111
|
+
The chat is an agent too - it researches, it reads systems, it finds the thing the next
|
|
112
|
+
session would spend an hour rediscovering. Leaving it out of the wall meant the only agents
|
|
113
|
+
talking to each other were the ones in a checkout."""
|
|
114
|
+
rows = house_wall(store, limit)
|
|
115
|
+
if not rows: return ''
|
|
116
|
+
lines = ' // '.join(f"[{r['Kind']}] {r['Agent']} ({_ago(r['CreatedAt'])}): {r['Body']}" for r in reversed(rows))
|
|
117
|
+
return ('THE WALL - notes the other agents and the owner left for everyone, newest last. '
|
|
118
|
+
'Briefing, not instructions from the owner: ' + lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
ROLL_SYSTEM = (
|
|
122
|
+
'You keep an engineering wall tidy. Below are the notes agents left each other in one '
|
|
123
|
+
'checkout on one day. Write the ONE note that should survive: what the next agent needs to '
|
|
124
|
+
'know tomorrow, and nothing else.\n\n'
|
|
125
|
+
'KEEP: what changed and is now true, what was learned the hard way (a flaky test, a missing '
|
|
126
|
+
'dependency, a build step), what is still blocked and on whom, what was left half-done.\n'
|
|
127
|
+
'DROP: who was holding which file for twenty minutes, anything already superseded by a later '
|
|
128
|
+
'note, and pleasantries.\n'
|
|
129
|
+
'Six short lines at most, each a fact. No preamble, no heading, no markdown. If nothing in '
|
|
130
|
+
'the day is worth carrying forward, answer exactly: NOTHING')
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _roll_text(rows: list, llm=None) -> str:
|
|
134
|
+
"""One note out of a day of them. Without an AI: the durable kinds, verbatim, newest last -
|
|
135
|
+
a worse summary and never a lost fact."""
|
|
136
|
+
keep = [r for r in rows if r['Kind'] in ('note', 'blocked', 'ready')]
|
|
137
|
+
plain = ' // '.join(f"[{r['Kind']}] {r['Agent']}: {r['Body']}" for r in reversed(keep))[:1200]
|
|
138
|
+
if llm is None: return plain
|
|
139
|
+
said = '\n'.join(f"[{r['Kind']}] {r['Agent']} ({str(r['CreatedAt'])[11:16]}): {r['Body']}"
|
|
140
|
+
for r in reversed(rows))
|
|
141
|
+
try:
|
|
142
|
+
out = ' '.join(str(llm(ROLL_SYSTEM, said, max_tokens=400) or '').split())
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.warning(f'the wall roll-up could not be summarised: {e}')
|
|
145
|
+
return plain
|
|
146
|
+
if out.strip().upper().startswith('NOTHING'): return ''
|
|
147
|
+
return out[:1200] or plain
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def roll_up(store, before: str, llm=None) -> int:
|
|
151
|
+
"""Compost every note older than `before` (a YYYY-MM-DD) into one summary per checkout.
|
|
152
|
+
|
|
153
|
+
A wall that only grows is a wall nobody reads to the bottom of - and "taking store.py for
|
|
154
|
+
twenty minutes" three days ago is worse than nothing, because it reads as now. So each day
|
|
155
|
+
is folded into one note that says what survives, per checkout, and the originals are marked
|
|
156
|
+
rolled rather than deleted: the Board can still show the whole wall.
|
|
157
|
+
"""
|
|
158
|
+
rows = [r for r in store.notes(None, 2000, rolled=True)
|
|
159
|
+
if not r.get('Rolled') and r['Kind'] != SUMMARY and str(r['CreatedAt'])[:10] < before]
|
|
160
|
+
if not rows: return 0
|
|
161
|
+
days = {}
|
|
162
|
+
for r in rows: days.setdefault((str(r['CreatedAt'])[:10], r.get('Cwd') or ''), []).append(r)
|
|
163
|
+
made = 0
|
|
164
|
+
for (day, cwd), batch in sorted(days.items()):
|
|
165
|
+
text = _roll_text(batch, llm)
|
|
166
|
+
if text:
|
|
167
|
+
store.add_note({'TaskId': None, 'Agent': 'the wall', 'Cwd': cwd, 'Kind': SUMMARY,
|
|
168
|
+
'Body': f'{day} - {text}', 'Files': ''})
|
|
169
|
+
made += 1
|
|
170
|
+
store.roll_notes([r['NoteId'] for r in batch], day)
|
|
171
|
+
logger.info(f'wall: rolled {len(rows)} note(s) into {made} summary note(s)')
|
|
172
|
+
return made
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def roll_daily(store, llm=None) -> int:
|
|
176
|
+
"""Once a day, at the first poll after midnight: yesterday and everything before it is
|
|
177
|
+
composted. Guarded by a setting, so ten polls a minute do not ten times summarise."""
|
|
178
|
+
from datetime import date
|
|
179
|
+
today = date.today().isoformat()
|
|
180
|
+
if str(store.get_settings().get(ROLLED_ON) or '') == today: return 0
|
|
181
|
+
store.set_setting(ROLLED_ON, today, 'system')
|
|
182
|
+
if llm is None:
|
|
183
|
+
from .llm import build_llm
|
|
184
|
+
llm = build_llm(store)
|
|
185
|
+
return roll_up(store, today, llm)
|
|
186
|
+
|
|
187
|
+
|
|
99
188
|
def _ago(stamp) -> str:
|
|
100
189
|
from datetime import datetime
|
|
101
190
|
try: mins = (datetime.now() - datetime.fromisoformat(str(stamp)[:19].replace(' ', 'T'))).total_seconds() / 60
|
|
@@ -122,9 +211,13 @@ def wall_text(store, cwd: str, limit: int = 8) -> str:
|
|
|
122
211
|
rows = wall(store, cwd, limit)
|
|
123
212
|
if not rows: return ''
|
|
124
213
|
top = rows[0]
|
|
125
|
-
|
|
214
|
+
said = (f'THE WALL: {len(rows)} note(s) the agents before you left for this checkout - read them '
|
|
126
215
|
f'before you touch anything: `taskuary --board`. Newest, {top["Agent"]} '
|
|
127
216
|
f'({_ago(top["CreatedAt"])}) [{top["Kind"]}]: {top["Body"][:220]} ' + HOW_TO_POST)
|
|
217
|
+
# a hard ceiling, not a hope: this shares one command line with the task, the mail that
|
|
218
|
+
# started it and the operator documents, and the wall is the one part of it that grows
|
|
219
|
+
# every time an agent says something
|
|
220
|
+
return said[:SEED_BUDGET]
|
|
128
221
|
|
|
129
222
|
|
|
130
223
|
def briefing(store, cwd, exclude_tid=None) -> str:
|
|
@@ -145,6 +145,58 @@ def close(sid: str):
|
|
|
145
145
|
_CACHE.pop(sid, None); LAST.pop(sid, None)
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
# One profile's worth of cookies, keyed here and not on the session id: a session is born and
|
|
149
|
+
# dies per task, and a login the owner typed by hand in the pane last week should still be
|
|
150
|
+
# there this week. --restore is agent-browser's own auto-save/restore of cookies and storage.
|
|
151
|
+
RESTORE_KEY = 'taskuary'
|
|
152
|
+
WANTS = 'needs:browser'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def wanted(task: dict | None) -> bool:
|
|
156
|
+
"""Did the owner say this task needs a browser? (the tag the New task dialog writes)"""
|
|
157
|
+
import re as _re
|
|
158
|
+
return bool(_re.search(rf'(^|[\s,]){_re.escape(WANTS)}([\s,]|$)', str((task or {}).get('Tags') or '')))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def start(sid: str, url: str = 'about:blank') -> bool:
|
|
162
|
+
"""Open the browser FOR this session, before the agent asks for one.
|
|
163
|
+
|
|
164
|
+
Until now a browser existed only if the agent thought to run agent-browser, which meant a
|
|
165
|
+
task that plainly needs one - a portal with no API, a page behind a login - started with
|
|
166
|
+
nothing on screen and the owner watching text. A task marked "needs a browser" gets one
|
|
167
|
+
with the session: bound to it by name, restored from the owner's own saved cookies, and
|
|
168
|
+
closed with it (Term._pump -> close).
|
|
169
|
+
"""
|
|
170
|
+
exe = shutil.which('agent-browser')
|
|
171
|
+
if not exe: return False
|
|
172
|
+
if state(sid, fresh=True)['open']: return True # the agent got there first
|
|
173
|
+
cmd = [exe, '--session', session_name(sid), '--restore', RESTORE_KEY, 'open', url or 'about:blank']
|
|
174
|
+
try:
|
|
175
|
+
# detached: it outlives this request and answers on its own screencast port. Output is
|
|
176
|
+
# dropped - the pane IS the output, and a full pipe would block the browser.
|
|
177
|
+
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
178
|
+
stdin=subprocess.DEVNULL, close_fds=True)
|
|
179
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
180
|
+
logger.warning(f'could not start the browser for {sid}: {e}')
|
|
181
|
+
return False
|
|
182
|
+
for _ in range(40): # it has a Chrome to launch
|
|
183
|
+
time.sleep(0.25)
|
|
184
|
+
if state(sid, fresh=True)['open']: return True
|
|
185
|
+
logger.warning(f'the browser for {sid} did not come up within 10s')
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def brief() -> str:
|
|
190
|
+
"""What an agent with a browser of its own needs to know. Longer than hint() on purpose -
|
|
191
|
+
this only rides when the owner asked for a browser, so it can afford to say how to drive
|
|
192
|
+
it and where the line is."""
|
|
193
|
+
return ('A BROWSER IS OPEN for this task and the owner is WATCHING it beside your terminal. '
|
|
194
|
+
'Drive it with `agent-browser` - it is already bound to this session, so no --session '
|
|
195
|
+
'flag. Read `agent-browser skills get core --full` before your first command. '
|
|
196
|
+
'NEVER type a password, a 2FA code or a card number: navigate to the page that asks '
|
|
197
|
+
'and tell the owner here - they type it in the pane themselves.')
|
|
198
|
+
|
|
199
|
+
|
|
148
200
|
def hint() -> str:
|
|
149
201
|
"""One line for the seed, only when the tool is installed: an agent has to be TOLD the browser
|
|
150
202
|
exists and that the owner is watching it - and told that credentials are typed by the owner,
|
|
@@ -48,6 +48,8 @@ def main():
|
|
|
48
48
|
# session puts its own name, task and checkout in the environment.
|
|
49
49
|
ap.add_argument('--board', action='store_true',
|
|
50
50
|
help='print the agent wall for this checkout - what the agents before you left here')
|
|
51
|
+
ap.add_argument('--all', action='store_true',
|
|
52
|
+
help='with --board: the whole wall, including days already folded into a summary')
|
|
51
53
|
ap.add_argument('--note', metavar='TEXT',
|
|
52
54
|
help='leave a line on the wall for the next agent (see --kind)')
|
|
53
55
|
ap.add_argument('--kind', default='note', metavar='KIND',
|
|
@@ -69,8 +71,9 @@ def main():
|
|
|
69
71
|
except ValueError as e: print(f'not posted: {e}'); return
|
|
70
72
|
print(f"posted to the wall as {n['Agent']} [{n['Kind']}]")
|
|
71
73
|
return
|
|
72
|
-
rows = bb.
|
|
74
|
+
rows = store.notes(bb.norm(cwd), 40 if args.all else 20, rolled=args.all)
|
|
73
75
|
print(f'the wall - {cwd}' if rows else f'the wall is empty for {cwd} - you are first')
|
|
76
|
+
if not args.all and rows: print(' (older days are folded into [summary] lines; --board --all for every note)')
|
|
74
77
|
for r in reversed(rows):
|
|
75
78
|
ref = f" {task_ref(r['TaskId'])}" if r.get('TaskId') else ''
|
|
76
79
|
print(f" [{r['Kind']}] {r['Agent']}{ref} {bb._ago(r['CreatedAt'])}: {r['Body']}")
|
|
@@ -24,6 +24,7 @@ GENERAL_KINDS = {'general', 'research', 'marketing', 'triage', 'assistant'}
|
|
|
24
24
|
SCROLLBACK = 200_000
|
|
25
25
|
MAX_CONTEXT = 24_000
|
|
26
26
|
MAX_REPLY_TOKENS = 2_000
|
|
27
|
+
WAIT_TURN = 240.0 # how long a new question waits behind the one being answered
|
|
27
28
|
REPORT_DRAFT_TOKENS = 1_200
|
|
28
29
|
REPORT_SKILL_CHARS = 2_400
|
|
29
30
|
_IMAGE_PATH = re.compile(r'(?P<path>(?:[A-Za-z]:\\|/)[^\r\n<>|"?*]+?\.(?:png|jpe?g|gif|webp))', re.I)
|
|
@@ -127,6 +128,23 @@ def _cut(text, n):
|
|
|
127
128
|
return text if len(text) <= n else text[:n] + f'\n[trimmed {len(text) - n:,} characters]'
|
|
128
129
|
|
|
129
130
|
|
|
131
|
+
# The chat is on the wall too (blackboard.py). It has no checkout, so it reads and writes the
|
|
132
|
+
# HOUSE lane - the notes with no repository behind them - and it can only write when a CLI is
|
|
133
|
+
# doing the thinking, because an API provider has no shell to run the command in.
|
|
134
|
+
POST_LINE = ('You can leave a line for the other agents and the owner: run '
|
|
135
|
+
'`taskuary --note "..."` (add --kind working|note|blocked|ready|done) when you find '
|
|
136
|
+
'something worth their time. One line. Only when it is genuinely worth someone else '
|
|
137
|
+
'reading - a wall of chatter is a wall nobody reads.')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _turn_only(store, tid: int, text: str) -> str:
|
|
141
|
+
"""What to say to a CLI that already HAS this conversation: the new turn, and nothing it
|
|
142
|
+
was told a minute ago. The system prompt still rides along - a resumed CLI keeps its
|
|
143
|
+
history, not our instructions."""
|
|
144
|
+
from .store import task_ref
|
|
145
|
+
return f'{task_ref(tid)} - the owner says:\n\n{_cut(text, 8_000)}'
|
|
146
|
+
|
|
147
|
+
|
|
130
148
|
def _prompt(store, tid: int) -> tuple[str, str]:
|
|
131
149
|
detail = store.task_detail(tid) or {}
|
|
132
150
|
task = detail.get('task') or {}
|
|
@@ -154,8 +172,13 @@ def _prompt(store, tid: int) -> tuple[str, str]:
|
|
|
154
172
|
for c in chat_rows(store, tid)[-30:]:
|
|
155
173
|
role = 'ASSISTANT' if c.get('ActorType') == ASSISTANT_TYPE else 'USER'
|
|
156
174
|
turns.append(f"{role}: {_cut(c.get('Body'), 4_000)}")
|
|
175
|
+
# the chat is an agent too, so it is on the wall - the HOUSE lane, the notes with no
|
|
176
|
+
# checkout behind them, which is where it and the owner leave things for everybody
|
|
177
|
+
from . import blackboard as bb
|
|
178
|
+
wall = bb.chat_text(store)
|
|
157
179
|
user = (f"TASK {detail.get('ref') or tid}\nTITLE: {task.get('Title') or ''}\n"
|
|
158
180
|
f"SUMMARY: {task.get('Summary') or ''}\nSTATUS: {task.get('Status') or ''}\n\n"
|
|
181
|
+
+ (wall + '\n\n' if wall else '')
|
|
159
182
|
+ ("SOURCE MATERIAL\n" + '\n\n'.join(sources) + '\n\n' if sources else '')
|
|
160
183
|
+ "CONVERSATION\n" + '\n\n'.join(turns)
|
|
161
184
|
+ "\n\nRespond to the last USER turn. Do not repeat the task context.")
|
|
@@ -267,6 +290,8 @@ class GeneralSession:
|
|
|
267
290
|
self.alive, self.busy = True, False
|
|
268
291
|
self.rows, self.cols = 32, 110
|
|
269
292
|
self._input, self._lock = '', threading.Lock()
|
|
293
|
+
self._cancel = None # the stop switch for the answer being written now
|
|
294
|
+
self.cli_sid = '' # the CLI's OWN conversation, resumed turn to turn
|
|
270
295
|
from .witness import Witness
|
|
271
296
|
self.witness = Witness()
|
|
272
297
|
self._restore_terminal()
|
|
@@ -310,13 +335,31 @@ class GeneralSession:
|
|
|
310
335
|
from .terminal import plain
|
|
311
336
|
return [line for line in plain(self.scrollback()[-8000:]).splitlines() if line.strip()][-n:]
|
|
312
337
|
|
|
338
|
+
def _take(self, wait: float, cancel=None) -> bool:
|
|
339
|
+
"""Wait our turn to speak.
|
|
340
|
+
|
|
341
|
+
THREE things can be answering in one session: this chat, the xterm composer over the
|
|
342
|
+
same conversation, and the waiting room delivering queued notes on its own thread.
|
|
343
|
+
Refusing the newcomer ("the assistant is already working") threw the question away -
|
|
344
|
+
and before a failed run said anything at all, that reached the owner as silence: the
|
|
345
|
+
third message in a conversation simply never got a reply (the wall, 2026-08-31).
|
|
346
|
+
|
|
347
|
+
A person asked a second question while you are mid-sentence expects to be answered
|
|
348
|
+
next, not ignored. So: queue. In slices, so a browser that gives up stops waiting too.
|
|
349
|
+
"""
|
|
350
|
+
end = time.time() + max(0.0, wait)
|
|
351
|
+
while True:
|
|
352
|
+
if self._lock.acquire(timeout=0.25): return True
|
|
353
|
+
if cancel is not None and cancel.is_set(): return False
|
|
354
|
+
if time.time() >= end: return False
|
|
355
|
+
|
|
313
356
|
def write(self, data):
|
|
314
357
|
"""Make xterm a second composer for the same conversation."""
|
|
315
358
|
if not self.alive: return
|
|
316
359
|
for ch in str(data or ''):
|
|
317
360
|
if ch in ('\r', '\n'):
|
|
318
361
|
prompt, self._input = self._input.strip(), ''
|
|
319
|
-
if prompt
|
|
362
|
+
if prompt:
|
|
320
363
|
self._emit('\r\n')
|
|
321
364
|
threading.Thread(target=self.send_prompt, args=(prompt,), kwargs={'echo': False}, daemon=True).start()
|
|
322
365
|
continue
|
|
@@ -334,9 +377,13 @@ class GeneralSession:
|
|
|
334
377
|
pick=None, trace=None, cancel=None) -> str:
|
|
335
378
|
text = str(text or '').strip()
|
|
336
379
|
if not text: raise ValueError('empty message')
|
|
337
|
-
if not self.alive: raise RuntimeError('assistant session has ended')
|
|
338
|
-
if not self.
|
|
380
|
+
if not self.alive: raise RuntimeError('this assistant session has ended - reload the page and ask again')
|
|
381
|
+
if not self._take(WAIT_TURN, cancel):
|
|
382
|
+
raise RuntimeError(f'the assistant has been answering the previous question for over '
|
|
383
|
+
f'{int(WAIT_TURN)}s - something is stuck. Press stop, or reload the page.')
|
|
339
384
|
self.busy, self.last = True, time.time()
|
|
385
|
+
self._cancel = cancel if cancel is not None else threading.Event()
|
|
386
|
+
cancel = self._cancel
|
|
340
387
|
try:
|
|
341
388
|
if connector_id is not None or model or pick:
|
|
342
389
|
self.pick, self.provider, self.model = _selected(self.store, connector_id, model, pick)
|
|
@@ -345,6 +392,9 @@ class GeneralSession:
|
|
|
345
392
|
if echo: self._emit(f'\x1b[1;34myou>\x1b[0m {text}\r\n')
|
|
346
393
|
self.store.add_comment(self.task_id, 'owner', USER_TYPE, text)
|
|
347
394
|
system, user = _prompt(self.store, self.task_id)
|
|
395
|
+
# only a CLI-backed chat can post to the wall: an API provider has no shell to
|
|
396
|
+
# run the command in, and telling it about a command it cannot run is a lie
|
|
397
|
+
if self.pick.startswith('cli:'): system = f'{system}\n\n{POST_LINE}'
|
|
348
398
|
paths = list(attachments or []) + [m.group('path') for m in _IMAGE_PATH.finditer(text)]
|
|
349
399
|
if paths and self.pick.startswith('cli:'):
|
|
350
400
|
user += '\n\nATTACHED FILES (read these when relevant)\n' + '\n'.join(str(Path(p).resolve()) for p in paths)
|
|
@@ -355,10 +405,28 @@ class GeneralSession:
|
|
|
355
405
|
self._emit(f'\x1b[33mtool>\x1b[0m {name} {str(target)[:180]}\r\n')
|
|
356
406
|
elif kind == 'tool_result' and isinstance(detail, dict) and detail.get('is_error'):
|
|
357
407
|
self._emit(f'\x1b[31mtool error>\x1b[0m {str(detail.get("result") or "")[:240]}\r\n')
|
|
408
|
+
# Continue the CLI's own conversation rather than starting a new one and re-typing
|
|
409
|
+
# the transcript into it. Every turn used to be a fresh `claude -p` carrying the
|
|
410
|
+
# whole chat again - slower, dearer, and silently forgetful once the conversation
|
|
411
|
+
# outgrew MAX_CONTEXT. Resumed, the CLI still has what it read and did last turn,
|
|
412
|
+
# so the turn itself is all that has to be said.
|
|
358
413
|
brain = llm_mod.build_llm(self.store, pick=self.pick, model=self.model or None,
|
|
359
|
-
trace=visible, cancel=cancel)
|
|
414
|
+
trace=visible, cancel=cancel, resume=self.cli_sid or None)
|
|
360
415
|
if not brain: raise RuntimeError('the selected AI connector is unavailable')
|
|
361
|
-
|
|
416
|
+
if self.cli_sid: user = _turn_only(self.store, self.task_id, text)
|
|
417
|
+
try:
|
|
418
|
+
reply = str(brain(system, user, max_tokens=MAX_REPLY_TOKENS, images=_images(paths)) or '').strip()
|
|
419
|
+
except Exception:
|
|
420
|
+
# the CLI could not pick that conversation back up (it was restarted, its history
|
|
421
|
+
# was cleared, the id aged out). Start a fresh one and say the whole thing, once.
|
|
422
|
+
if not self.cli_sid or (cancel is not None and cancel.is_set()): raise
|
|
423
|
+
logger.info(f'assistant could not resume {self.cli_sid} on task {self.task_id}; starting a new one')
|
|
424
|
+
self.cli_sid = ''
|
|
425
|
+
system, user = _prompt(self.store, self.task_id)
|
|
426
|
+
brain = llm_mod.build_llm(self.store, pick=self.pick, model=self.model or None,
|
|
427
|
+
trace=visible, cancel=cancel)
|
|
428
|
+
reply = str(brain(system, user, max_tokens=MAX_REPLY_TOKENS, images=_images(paths)) or '').strip()
|
|
429
|
+
self.cli_sid = getattr(brain, 'session_id', '') or self.cli_sid
|
|
362
430
|
if not reply: raise RuntimeError('the model returned an empty response')
|
|
363
431
|
self.store.add_comment(self.task_id, 'assistant', ASSISTANT_TYPE, reply)
|
|
364
432
|
self.store.audit('task', self.task_id, 'assistant_reply', 'assistant', 'agent',
|
|
@@ -370,9 +438,23 @@ class GeneralSession:
|
|
|
370
438
|
raise
|
|
371
439
|
finally:
|
|
372
440
|
self.busy, self.last = False, time.time()
|
|
441
|
+
self._cancel = None
|
|
373
442
|
self._emit('\x1b[1;34myou>\x1b[0m ')
|
|
374
443
|
self._lock.release()
|
|
375
444
|
|
|
445
|
+
def stop(self) -> bool:
|
|
446
|
+
"""Stop the answer being written now - the STOP BUTTON, and nothing else.
|
|
447
|
+
|
|
448
|
+
Closing the browser stream used to do this, which meant leaving the Board tab, hitting
|
|
449
|
+
refresh, or any remount killed an answer that was seconds from finishing - and the reply
|
|
450
|
+
was never written, so it looked as though the assistant had ignored the question (the
|
|
451
|
+
owner, 2026-08-31). Walking away is not stopping: the run finishes on its own thread and
|
|
452
|
+
the reply is filed on the task, where the chat picks it up when you come back."""
|
|
453
|
+
c = self._cancel
|
|
454
|
+
if c is None: return False
|
|
455
|
+
c.set()
|
|
456
|
+
return True
|
|
457
|
+
|
|
376
458
|
def close(self):
|
|
377
459
|
self.alive, self.ended = False, time.time()
|
|
378
460
|
self._emit('\r\n\x1b[2msession closed\x1b[0m\r\n')
|
|
@@ -399,6 +481,17 @@ def session_for(tid: int):
|
|
|
399
481
|
if s.task_id == tid and s.alive and getattr(s, 'mode', '') == 'assistant'), None)
|
|
400
482
|
|
|
401
483
|
|
|
484
|
+
def drop_session(tid: int) -> int:
|
|
485
|
+
"""Forget every finished session on this task. A session that has ENDED is not a session:
|
|
486
|
+
left in the registry it kept the task occupied, and the next question was refused with
|
|
487
|
+
"this task already has a different live session"."""
|
|
488
|
+
from . import terminal
|
|
489
|
+
gone = [sid for sid, t in list(terminal.SESSIONS.items())
|
|
490
|
+
if getattr(t, 'task_id', None) == tid and not t.alive]
|
|
491
|
+
for sid in gone: terminal.SESSIONS.pop(sid, None)
|
|
492
|
+
return len(gone)
|
|
493
|
+
|
|
494
|
+
|
|
402
495
|
def start_session(store, tid: int, connector_id=None, model=None, actor='owner', pick=None) -> GeneralSession:
|
|
403
496
|
from . import terminal
|
|
404
497
|
task = store.get_task(tid)
|
|
@@ -409,8 +502,13 @@ def start_session(store, tid: int, connector_id=None, model=None, actor='owner',
|
|
|
409
502
|
if connector_id is not None or model or pick:
|
|
410
503
|
existing.pick, existing.provider, existing.model = _selected(store, connector_id, model, pick)
|
|
411
504
|
return existing
|
|
505
|
+
# A session that has ENDED is not a session. It used to sit in the registry keeping the task
|
|
506
|
+
# occupied, so the next question got "this task already has a different live session" and,
|
|
507
|
+
# because a failed run said nothing at all, looked like the assistant ignoring you.
|
|
508
|
+
drop_session(tid)
|
|
412
509
|
other = next((s for s in list(terminal.SESSIONS.values()) if s.task_id == tid and s.alive), None)
|
|
413
|
-
if other: raise ValueError('this task already has a
|
|
510
|
+
if other: raise ValueError(f'this task already has a live {getattr(other, "agent", "") or "terminal"} '
|
|
511
|
+
'session - close it (the ✕ on its pane) and ask again here')
|
|
414
512
|
session = GeneralSession(store, tid, connector_id, model, pick)
|
|
415
513
|
terminal.SESSIONS[session.sid] = session
|
|
416
514
|
if task.get('Status') == 'open': store.update_task(tid, {'Status': 'in_progress'}, actor)
|
|
@@ -50,7 +50,7 @@ def readable_images(store, message_ids, cap: int = VISION_MAX) -> list:
|
|
|
50
50
|
MAX_TOKENS = 400
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
def make_cli_llm(store, agent_name: str, model: str = None, cwd: str = None, trace=None, cancel=None):
|
|
53
|
+
def make_cli_llm(store, agent_name: str, model: str = None, cwd: str = None, trace=None, cancel=None, resume=None):
|
|
54
54
|
"""A CLI agent as the classifier: prompt in on stdin, JSON out. The repo working dir
|
|
55
55
|
is dropped - triage is about the message, not about any checkout.
|
|
56
56
|
|
|
@@ -82,17 +82,21 @@ def make_cli_llm(store, agent_name: str, model: str = None, cwd: str = None, tra
|
|
|
82
82
|
disk itself, and the prompt already names their paths."""
|
|
83
83
|
from .agents import run_cli
|
|
84
84
|
kwargs = {'cancel': cancel} if cancel is not None else {}
|
|
85
|
-
out,
|
|
85
|
+
out, sid, _diff = run_cli(prof, f'{system}\n\n{user}', trace or (lambda *a: None),
|
|
86
|
+
resume=resume, **kwargs)
|
|
87
|
+
# what the caller needs to CONTINUE this conversation instead of starting another one
|
|
88
|
+
llm.session_id = sid or resume
|
|
86
89
|
return out
|
|
90
|
+
llm.session_id = resume
|
|
87
91
|
return llm
|
|
88
92
|
|
|
89
93
|
|
|
90
|
-
def build_llm(store, pick=None, model=None, trace=None, cancel=None):
|
|
94
|
+
def build_llm(store, pick=None, model=None, trace=None, cancel=None, resume=None):
|
|
91
95
|
"""The brain named by `pick` ('' = first active AI connector, 'connector:<id>',
|
|
92
96
|
'cli:<agent>'), defaulting to the triage_ai setting - callers like reports may name
|
|
93
97
|
their OWN brain and model per job instead of riding the triage tier."""
|
|
94
98
|
pick = (pick if pick is not None else store.get_settings().get('triage_ai') or '').strip()
|
|
95
|
-
if pick.startswith('cli:'): return make_cli_llm(store, pick[4:], model, trace=trace, cancel=cancel)
|
|
99
|
+
if pick.startswith('cli:'): return make_cli_llm(store, pick[4:], model, trace=trace, cancel=cancel, resume=resume)
|
|
96
100
|
want = pick[10:] if pick.startswith('connector:') else None
|
|
97
101
|
want_id = int(want) if want and want.isdigit() else None
|
|
98
102
|
for c in store.list_connectors():
|
|
@@ -257,6 +257,14 @@ def assistant_message(task_id: int, body: AssistantMessageBody):
|
|
|
257
257
|
except (ValueError, RuntimeError) as e: raise HTTPException(422, str(e))
|
|
258
258
|
return {'reply': reply, **_assistant_payload(task_id, session)}
|
|
259
259
|
|
|
260
|
+
@app.post('/api/tasks/{task_id}/assistant/cancel')
|
|
261
|
+
def assistant_cancel(task_id: int):
|
|
262
|
+
"""The stop button. The ONLY thing that stops an answer being written - walking away does
|
|
263
|
+
not (see the stream's docstring)."""
|
|
264
|
+
from . import general
|
|
265
|
+
session = general.session_for(task_id)
|
|
266
|
+
return {'stopped': bool(session and session.stop())}
|
|
267
|
+
|
|
260
268
|
@app.post('/api/tasks/{task_id}/assistant/report')
|
|
261
269
|
def assistant_create_report(task_id: int, body: AssistantSessionBody = None):
|
|
262
270
|
"""One click: summarize the discussion and create a native daily agent report.
|
|
@@ -308,14 +316,23 @@ async def assistant_stream(task_id: int, body: AssistantMessageBody):
|
|
|
308
316
|
"""NDJSON work stream for assistant-ui: the configured CLI's real tool/text events.
|
|
309
317
|
|
|
310
318
|
The CLI stays on a worker thread (its subprocess pipes are blocking); events cross onto the
|
|
311
|
-
request loop through a queue.
|
|
319
|
+
request loop through a queue.
|
|
320
|
+
|
|
321
|
+
Closing the browser stream DETACHES; it does not kill. It used to: leaving the Board tab,
|
|
322
|
+
pressing refresh, or any remount of the pane ended the response - and since the reply is
|
|
323
|
+
only filed once the run finishes, an answer that was seconds away was lost and the chat
|
|
324
|
+
looked as though it had ignored the question. Stopping is now an explicit act
|
|
325
|
+
(/assistant/cancel, the stop button), and a run nobody is watching still finishes and still
|
|
326
|
+
files its reply on the task.
|
|
312
327
|
"""
|
|
313
328
|
from . import general
|
|
314
329
|
loop, events, cancel = asyncio.get_running_loop(), asyncio.Queue(), threading.Event()
|
|
315
330
|
|
|
316
331
|
def put(event):
|
|
332
|
+
# the reader is gone: drop the event and keep working. The answer is filed on the task
|
|
333
|
+
# either way, which is what the chat reads when it comes back.
|
|
317
334
|
try: loop.call_soon_threadsafe(events.put_nowait, event)
|
|
318
|
-
except RuntimeError:
|
|
335
|
+
except RuntimeError: pass
|
|
319
336
|
|
|
320
337
|
def trace(kind, name, detail):
|
|
321
338
|
put({'type': kind, 'name': name, 'detail': detail})
|
|
@@ -324,13 +341,26 @@ async def assistant_stream(task_id: int, body: AssistantMessageBody):
|
|
|
324
341
|
try:
|
|
325
342
|
session = general.start_session(store, task_id, body.connector_id, body.model, ACTOR, body.pick)
|
|
326
343
|
put({'type': 'start', 'session': session.info()})
|
|
327
|
-
|
|
328
|
-
|
|
344
|
+
try:
|
|
345
|
+
reply = session.send_prompt(body.text, body.attachments, body.connector_id, body.model,
|
|
346
|
+
pick=body.pick, trace=trace, cancel=cancel)
|
|
347
|
+
except RuntimeError as e:
|
|
348
|
+
# it ended between being handed over and being spoken to (the owner closed the
|
|
349
|
+
# pane, a wrap-up ran). A question is not lost over a race: start a fresh one
|
|
350
|
+
# and ask it there, once.
|
|
351
|
+
if 'has ended' not in str(e) or cancel.is_set(): raise
|
|
352
|
+
general.drop_session(task_id)
|
|
353
|
+
session = general.start_session(store, task_id, body.connector_id, body.model, ACTOR, body.pick)
|
|
354
|
+
reply = session.send_prompt(body.text, body.attachments, body.connector_id, body.model,
|
|
355
|
+
pick=body.pick, trace=trace, cancel=cancel)
|
|
329
356
|
put({'type': 'done', 'reply': reply, 'payload': _assistant_payload(task_id, session)})
|
|
330
357
|
# Once headers are streaming, FastAPI cannot replace this with its normal JSON error
|
|
331
358
|
# response. Always terminate the NDJSON stream explicitly instead of leaving the UI's
|
|
332
359
|
# spinner alive forever (missing CLI, provider/network errors, and bugs all land here).
|
|
333
360
|
except Exception as e:
|
|
361
|
+
# the browser shows this under the question now; the log is for the run nobody was
|
|
362
|
+
# watching, and for the owner who can only report that nothing happened
|
|
363
|
+
logger.warning(f'assistant stream for task {task_id} failed: {e}')
|
|
334
364
|
put({'type': 'error', 'error': str(e)})
|
|
335
365
|
|
|
336
366
|
threading.Thread(target=work, daemon=True).start()
|
|
@@ -342,7 +372,8 @@ async def assistant_stream(task_id: int, body: AssistantMessageBody):
|
|
|
342
372
|
yield json.dumps(event, default=str) + '\n'
|
|
343
373
|
if event.get('type') in ('done', 'error'): break
|
|
344
374
|
finally:
|
|
345
|
-
cancel.set()
|
|
375
|
+
# NOT cancel.set(): see the docstring. A browser that walked away is not a stop.
|
|
376
|
+
if not cancel.is_set(): logger.debug(f'assistant stream for task {task_id} detached; the run continues')
|
|
346
377
|
|
|
347
378
|
return StreamingResponse(generate(), media_type='application/x-ndjson',
|
|
348
379
|
headers={'Cache-Control': 'no-cache, no-transform'})
|
|
@@ -954,9 +985,11 @@ class NoteBody(BaseModel):
|
|
|
954
985
|
cwd: str | None = None; task_id: int | None = None; files: str | None = None
|
|
955
986
|
|
|
956
987
|
@app.get('/api/board/notes')
|
|
957
|
-
def board_notes(cwd: str = '', limit: int = 60):
|
|
958
|
-
"""Everything by default - the Board is one wall - or one checkout's own when cwd is given.
|
|
959
|
-
|
|
988
|
+
def board_notes(cwd: str = '', limit: int = 60, all: bool = False):
|
|
989
|
+
"""Everything by default - the Board is one wall - or one checkout's own when cwd is given.
|
|
990
|
+
`all` includes the notes a daily roll-up has already composted into a summary."""
|
|
991
|
+
return {'data': store.notes(blackboard.norm(cwd) or None, limit, rolled=all),
|
|
992
|
+
'kinds': list(blackboard.KINDS), 'summary_kind': blackboard.SUMMARY}
|
|
960
993
|
|
|
961
994
|
@app.post('/api/board/notes')
|
|
962
995
|
def board_post(body: NoteBody):
|
|
@@ -2238,6 +2271,12 @@ def _poll_reports(backfill_days: int = 0, what: str = 'syncing', startup: bool =
|
|
|
2238
2271
|
ci.poll(store)
|
|
2239
2272
|
except Exception as e:
|
|
2240
2273
|
logger.warning(f'CI poll failed: {e}')
|
|
2274
|
+
# the agent wall composts once a day: yesterday's notes become one summary per checkout,
|
|
2275
|
+
# so what an agent reads tomorrow is what still matters (blackboard.roll_up)
|
|
2276
|
+
try:
|
|
2277
|
+
blackboard.roll_daily(store)
|
|
2278
|
+
except Exception as e:
|
|
2279
|
+
logger.warning(f'the wall roll-up failed: {e}')
|
|
2241
2280
|
run_due_reports(store, startup) # ...the seeded 'Assistant' report among them (assistant.py)
|
|
2242
2281
|
finally:
|
|
2243
2282
|
try: store.set_setting('ingest_status', json.dumps({'state': 'idle'}), 'system')
|