taskuary 0.3.2__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/taskuary.egg-info → taskuary-0.3.2.2}/PKG-INFO +2 -2
- {taskuary-0.3.2 → taskuary-0.3.2.2}/README.md +1 -1
- {taskuary-0.3.2 → taskuary-0.3.2.2}/pyproject.toml +1 -1
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/agents.py +16 -8
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/blackboard.py +155 -1
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/browserview.py +52 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/cli.py +34 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/general.py +104 -6
- taskuary-0.3.2.2/taskuary/interview.py +134 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/llm.py +8 -4
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/messengers.py +23 -12
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/server.py +100 -5
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/store.py +47 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/coder.md +15 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/terminal.py +30 -2
- taskuary-0.3.2.2/taskuary/web/assets/GeneralWorkspace-Bt02JQzu.js +10 -0
- taskuary-0.3.2/taskuary/web/assets/index-zLivezbi.js → taskuary-0.3.2.2/taskuary/web/assets/index-DGVnbkfG.js +101 -100
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/index.html +1 -1
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/whatsapp/bridge.mjs +17 -10
- {taskuary-0.3.2 → taskuary-0.3.2.2/taskuary.egg-info}/PKG-INFO +2 -2
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/SOURCES.txt +8 -2
- {taskuary-0.3.2 → 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 → taskuary-0.3.2.2}/tests/test_climodels.py +1 -1
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_promptmap_and_catalog.py +2 -1
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_pypi_downloads.py +45 -9
- taskuary-0.3.2.2/tests/test_soul_interview.py +124 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_terminal.py +3 -0
- taskuary-0.3.2.2/tests/test_whatsapp_images.py +118 -0
- taskuary-0.3.2/taskuary/web/assets/GeneralWorkspace-Dn17SMoR.js +0 -10
- {taskuary-0.3.2 → taskuary-0.3.2.2}/LICENSE +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/setup.cfg +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/__init__.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/aisetup.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/artifacts.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/assistant.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/aws.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/azure.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/calendar.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/categories.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/channels.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/ci.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/climodels.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/clis.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/coder.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/compose.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/config.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/context.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/counsel.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/db.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/desktop.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/devtools.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/digest.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/docsync.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/evalset.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/github.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/handraise.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/histgen.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/hooks.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/imapmail.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/imessage.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/ingest.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/intacct.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/knowledge.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/learn.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/learnedgraph.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/logs.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/mcp.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/msauth.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/mssql.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/outbound.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/phone.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/pm.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/policy.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/problems.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/promptmap.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/proof.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/proposals.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/rank.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/reports.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/research.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/reshape.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/responder.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/routing.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/scopes.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/semantic.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/senders.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/setup.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/sharepoint.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/sheets.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/counsel.md +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/digest.md +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/learned.md +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/soul.md +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/style.md +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/triage.md +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/testing.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/toil.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/triage.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/verdicts.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/voice.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/wabridge.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/waitroom.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/favicon.ico +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/favicon.png +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/webguard.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/whatsapp/package.json +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/whoami.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/witness.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/dependency_links.txt +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/entry_points.txt +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/requires.txt +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/top_level.txt +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_agent_source.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_aisetup.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_answers.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_api.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_assistant.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_async_triage.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_audit_chain.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_aws_region.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_blackboard.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_browserview.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_calendar.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_calendar_today.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_categories.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_cc_triage.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_clis.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_cloud.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_cloud_sources.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_compose.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_compose_sources.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_config.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_context.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_core.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_counsel.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_cwlogs_paging.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_desktop.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_devtools.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_diff_review.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_digest_brief.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_docs_and_llm.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_docs_flow.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_duplicate_connectors.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_factory.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_feed_working.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_general.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_github_auto.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_handraise.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_harvest.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_histgen.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_imapmail.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_imessage.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_intacct.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_kind_dispatch.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_knowledge.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_learnedgraph.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_local_file.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_mail_folders.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_mark_read.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_mcp.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_messengers.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_msauth.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_mssql.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_noop_ending.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_not_coding.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_note_retrieval.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_outbound_reports.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_pm.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_poll_overlap.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_poll_sources.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_pr_comments.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_problems.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_proof_ci_proposals.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_rank.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_reply_channels.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_reply_voice.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_report_alerts.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_report_triage.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_research.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_reviews.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_scopes.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_seeded_reports.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_semantic.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_send_targets.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_setup.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_sheets_sharepoint.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_someone_else_replied.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_store_app_cli.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_store_hotpaths.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_sync_clock.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_task_identity.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_toil.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_triage_dataset.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_urgent_and_handoff.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_verdict_paths.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_verdict_sticks.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_vision.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_voice.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_wabridge.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_waitroom.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_watermark.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_web_entrypoint.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_webguard.py +0 -0
- {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_whoami.py +0 -0
- {taskuary-0.3.2 → 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**—and moving fast. The funnel, review queue,
|
|
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**—and moving fast. The funnel, review queue,
|
|
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,16 +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':
|
|
147
|
+
if (os.name == 'nt') if windows is None else windows:
|
|
142
148
|
env.setdefault('USERPROFILE', home)
|
|
143
|
-
drive,
|
|
144
|
-
if drive: env.setdefault('HOMEDRIVE', drive); env.setdefault('HOMEPATH',
|
|
145
|
-
|
|
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'))
|
|
146
154
|
return env
|
|
147
155
|
|
|
148
156
|
|
|
@@ -11,7 +11,9 @@ from loguru import logger
|
|
|
11
11
|
from .store import task_ref
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# normpath('') is '.', which is a real relative path and would make every checkout-less note
|
|
15
|
+
# look like it belonged to whatever directory the reader happens to be standing in
|
|
16
|
+
def norm(p): return os.path.normcase(os.path.normpath(p)) if str(p or '').strip() else ''
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
def dirty(cwd) -> set:
|
|
@@ -66,6 +68,158 @@ def peers(store, cwd, exclude_tid=None) -> list:
|
|
|
66
68
|
return out
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
# ── the wall: what agents tell EACH OTHER ────────────────────────────────────────────────
|
|
72
|
+
# peers() and dirty() are facts read off git and the run trace - true, and about as expressive
|
|
73
|
+
# as a security camera. They cannot say "the migration is half-applied, do not run the tests
|
|
74
|
+
# yet" or "this is ready, push it". Only the agent doing the work knows that, so it writes it
|
|
75
|
+
# down: one line per note, on the checkout, read by whoever comes next.
|
|
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
|
|
80
|
+
KIND_HINT = {'working': 'what it is doing right now', 'note': 'anything the next agent needs',
|
|
81
|
+
'blocked': 'waiting on something or someone', 'ready': 'finished and safe to push',
|
|
82
|
+
'done': 'pushed or closed out'}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def post(store, body: str, kind: str = 'note', agent: str = '', cwd: str = '', tid: int = None,
|
|
86
|
+
files: str = '') -> dict:
|
|
87
|
+
"""One note on the wall. Everything but the words is optional: an agent that knows only what
|
|
88
|
+
it wants to say still gets to say it."""
|
|
89
|
+
body = ' '.join(str(body or '').split())[:1200]
|
|
90
|
+
if not body: raise ValueError('a note with no words is not a note')
|
|
91
|
+
kind = str(kind or 'note').lower().strip()
|
|
92
|
+
if kind not in KINDS + (SUMMARY,): raise ValueError(f"kind must be one of {', '.join(KINDS)}")
|
|
93
|
+
nid = store.add_note({'TaskId': tid, 'Agent': agent or 'agent', 'Cwd': norm(cwd), 'Kind': kind,
|
|
94
|
+
'Body': body, 'Files': files or ''})
|
|
95
|
+
return dict(store.get_note(nid))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def wall(store, cwd: str = '', limit: int = 12) -> list:
|
|
99
|
+
return store.notes(norm(cwd) if cwd else None, limit)
|
|
100
|
+
|
|
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
|
+
|
|
188
|
+
def _ago(stamp) -> str:
|
|
189
|
+
from datetime import datetime
|
|
190
|
+
try: mins = (datetime.now() - datetime.fromisoformat(str(stamp)[:19].replace(' ', 'T'))).total_seconds() / 60
|
|
191
|
+
except (TypeError, ValueError): return ''
|
|
192
|
+
if mins < 1: return 'just now'
|
|
193
|
+
if mins < 60: return f'{int(mins)}m ago'
|
|
194
|
+
if mins < 48 * 60: return f'{int(mins // 60)}h ago'
|
|
195
|
+
return f'{int(mins // 1440)}d ago'
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
HOW_TO_POST = ('Post your own with `taskuary --note "..."` (--kind working|note|blocked|ready|done): '
|
|
199
|
+
'when you start, when you find something the next agent would waste an hour on, and '
|
|
200
|
+
'`--kind ready` before you push.')
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def wall_text(store, cwd: str, limit: int = 8) -> str:
|
|
204
|
+
"""The wall, for the seed prompt: a POINTER, not a transcript.
|
|
205
|
+
|
|
206
|
+
A seed is typed into a TUI on one line, and the whole document does not belong there - the
|
|
207
|
+
newest note plus the command that shows the rest is what makes an agent go and read it.
|
|
208
|
+
CODER.md carries the full etiquette; this is the nudge that says there is something to read
|
|
209
|
+
RIGHT NOW. Nothing at all when the wall is empty: a paragraph saying "no notes" is tokens
|
|
210
|
+
spent to say nothing."""
|
|
211
|
+
rows = wall(store, cwd, limit)
|
|
212
|
+
if not rows: return ''
|
|
213
|
+
top = rows[0]
|
|
214
|
+
said = (f'THE WALL: {len(rows)} note(s) the agents before you left for this checkout - read them '
|
|
215
|
+
f'before you touch anything: `taskuary --board`. Newest, {top["Agent"]} '
|
|
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]
|
|
221
|
+
|
|
222
|
+
|
|
69
223
|
def briefing(store, cwd, exclude_tid=None) -> str:
|
|
70
224
|
"""The OTHER AGENTS paragraph of a new agent's prompt. Facts only - task, agent, the files
|
|
71
225
|
it has modified so far - plus the one standing rule: whoever got there first has control."""
|
|
@@ -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,
|
|
@@ -43,7 +43,41 @@ def main():
|
|
|
43
43
|
help='triage dataset: build (labelled cases from your verdicts -> ~/.taskuary/eval), '
|
|
44
44
|
'evaluate (score the configured AI over them), ablate (score with and without memory), '
|
|
45
45
|
'share (anonymised copy for tests/data)')
|
|
46
|
+
# THE WALL (blackboard.py). An agent working in a terminal has a shell and no API token, so
|
|
47
|
+
# this is how it talks to the next agent: two flags, no arguments it has to be told - the
|
|
48
|
+
# session puts its own name, task and checkout in the environment.
|
|
49
|
+
ap.add_argument('--board', action='store_true',
|
|
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')
|
|
53
|
+
ap.add_argument('--note', metavar='TEXT',
|
|
54
|
+
help='leave a line on the wall for the next agent (see --kind)')
|
|
55
|
+
ap.add_argument('--kind', default='note', metavar='KIND',
|
|
56
|
+
help='working | note | blocked | ready | done - "ready" is how the next agent '
|
|
57
|
+
'learns the tree is safe to build on')
|
|
46
58
|
args = ap.parse_args()
|
|
59
|
+
if args.board or args.note:
|
|
60
|
+
import os, sys
|
|
61
|
+
from . import blackboard as bb
|
|
62
|
+
from .store import SQLiteStore, task_ref
|
|
63
|
+
try: sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
64
|
+
except (AttributeError, OSError): pass
|
|
65
|
+
store = SQLiteStore(config.db_path())
|
|
66
|
+
cwd = os.environ.get('TASKUARY_CWD') or os.getcwd()
|
|
67
|
+
who = os.environ.get('TASKUARY_AGENT') or 'agent'
|
|
68
|
+
tid = os.environ.get('TASKUARY_TASK')
|
|
69
|
+
if args.note:
|
|
70
|
+
try: n = bb.post(store, args.note, args.kind, who, cwd, int(tid) if str(tid).isdigit() else None)
|
|
71
|
+
except ValueError as e: print(f'not posted: {e}'); return
|
|
72
|
+
print(f"posted to the wall as {n['Agent']} [{n['Kind']}]")
|
|
73
|
+
return
|
|
74
|
+
rows = store.notes(bb.norm(cwd), 40 if args.all else 20, rolled=args.all)
|
|
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)')
|
|
77
|
+
for r in reversed(rows):
|
|
78
|
+
ref = f" {task_ref(r['TaskId'])}" if r.get('TaskId') else ''
|
|
79
|
+
print(f" [{r['Kind']}] {r['Agent']}{ref} {bb._ago(r['CreatedAt'])}: {r['Body']}")
|
|
80
|
+
return
|
|
47
81
|
if args.evalset:
|
|
48
82
|
import sys
|
|
49
83
|
from . import evalset
|
|
@@ -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)
|