firekeep-client 1.7.2__tar.gz → 1.8.1__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.
- {firekeep_client-1.7.2/firekeep_client.egg-info → firekeep_client-1.8.1}/PKG-INFO +4 -4
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/README.md +3 -3
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/__init__.py +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/codex.py +3 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/childenv.py +4 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/cli.py +70 -4
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/gateway.py +9 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/session_start.py +2 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/promptrecall.py +13 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/report.py +146 -10
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/shim.py +23 -7
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/state.py +85 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/updater.py +2 -2
- {firekeep_client-1.7.2 → firekeep_client-1.8.1/firekeep_client.egg-info}/PKG-INFO +4 -4
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/SOURCES.txt +3 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/pyproject.toml +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_consent_pty.py +20 -5
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_install.py +40 -2
- firekeep_client-1.8.1/tests/test_cli_whoami.py +101 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_e2e_bootstrap.py +2 -2
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_gateway_child_env.py +6 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_gateway_toolset.py +41 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_package.py +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_promptrecall.py +24 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_consent_surfaces.py +1 -1
- firekeep_client-1.8.1/tests/test_report_milestones.py +177 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_identity.py +124 -0
- firekeep_client-1.8.1/tests/test_state_session_report.py +106 -0
- firekeep_client-1.7.2/tests/test_ci_publishes_symdex.py +0 -30
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/LICENSE +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/NOTICE +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/__init__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/base.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/claude.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/claude_desktop.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/generic.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/kiro.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/opencode.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/pi.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/autoupdate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/background.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/backups.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/config_write.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/connect.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/contract/__init__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/contract/matrix.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/decision/__init__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/decision/board.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/decision/server.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/dexes.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/docdexsync.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooklog.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/__init__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/__main__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/_git.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/_mcp.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/destructive.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/post_tool.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/pre_tool.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/precompact.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/prompt.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/runbooks.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/session_end.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/stop.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/jobobject.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/join.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/joincode.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/maildexsync.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/migrate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/nightshift.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/nightshiftdrain.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/pathenv.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/resolver.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/serverinit.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/serverupdate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/sidecar.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/signing.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/stdio.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/symdexindex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/transport.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/wizard.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/worktree_snapshot.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/dependency_links.txt +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/entry_points.txt +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/requires.txt +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/top_level.txt +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/setup.cfg +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_attribution_headers.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_autoupdate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_background.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bash_layer_retired.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_docdex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_maildex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_ps1.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_reinstall.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_sh.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_symdex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_venv_provisioning.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_backup.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_dex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_docdex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_doctor.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_doctor_generic.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_doctor_report.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_entry.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_generic_install.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_hands.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_maildex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_onboarding.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_personal.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_uninstall.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_uninstall_generic.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_update.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_config_migration.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_connect.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_board_html.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_bypass.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_poll_ceiling.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_registration.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_server.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_dexes.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_docdexsync.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_docs_reference_client_kit.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_doctor_credential_expiry.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_doctor_embeddings.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_doctor_no_server.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_gateway.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_gateway_schema_slimming.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_generic_config.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_hooklog.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_import_boundary.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_init_self_enroll.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_init_stale_bundle.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_install_skill_contract.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_instruction_budget.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_jobobject.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_join.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_join_config_write.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_joincode.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_kit_smoke.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_maildexsync.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_make_release.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_memory_instructions.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_migration_entrypoints.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_nightshift.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_nightshiftdrain.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_pathenv.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_bootstrap_enums.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_capture.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_consent.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_flush_points.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_integration.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_schema.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_spool.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_resolver.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_resolver_bypass.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_resolver_single_connection.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_runbook_match_parity.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_runbook_state.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_scratch_ttl.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_serverinit.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_serverupdate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_bridge.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_bypass.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_eof_exit.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_fail_loud.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_recovery.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_skeleton.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_sidecar.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_sidecar_robustness.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_signing.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_state.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_stdio_encoding.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_symdexindex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_transport.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_transport_sse.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_twin_removed.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_updater.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_wizard.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_wizard_generic.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_worktree_snapshot.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: firekeep-client
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.1
|
|
4
4
|
Summary: Client for Firekeep, the self-hosted operating layer for connected AI agents.
|
|
5
5
|
License-Expression: LicenseRef-Firekeep-BUSL-1.1
|
|
6
6
|
Project-URL: Homepage, https://firekeep.ai
|
|
@@ -57,9 +57,9 @@ an absent registry is seeded with both (default-on), `firekeep dex remove` is
|
|
|
57
57
|
the off-switch, and removals stick across updates.
|
|
58
58
|
|
|
59
59
|
**Firekeep is self-hosted — this package needs a server to talk to.** A person
|
|
60
|
-
or team runs its own server, with per-key authentication.
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
or team runs its own server, with per-key authentication. Server images target
|
|
61
|
+
`linux/amd64` and, for releases after v1.6.2, `linux/arm64`: use an x86-64 or
|
|
62
|
+
ARM64 Linux host, or Docker Desktop on Windows or Mac. There is no hosted endpoint. Server install:
|
|
63
63
|
[firekeep.ai/docs.html](https://firekeep.ai/docs.html#server).
|
|
64
64
|
|
|
65
65
|
## Install
|
|
@@ -30,9 +30,9 @@ an absent registry is seeded with both (default-on), `firekeep dex remove` is
|
|
|
30
30
|
the off-switch, and removals stick across updates.
|
|
31
31
|
|
|
32
32
|
**Firekeep is self-hosted — this package needs a server to talk to.** A person
|
|
33
|
-
or team runs its own server, with per-key authentication.
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
or team runs its own server, with per-key authentication. Server images target
|
|
34
|
+
`linux/amd64` and, for releases after v1.6.2, `linux/arm64`: use an x86-64 or
|
|
35
|
+
ARM64 Linux host, or Docker Desktop on Windows or Mac. There is no hosted endpoint. Server install:
|
|
36
36
|
[firekeep.ai/docs.html](https://firekeep.ai/docs.html#server).
|
|
37
37
|
|
|
38
38
|
## Install
|
|
@@ -46,6 +46,9 @@ def _toml_block(venv_bin: Path) -> str:
|
|
|
46
46
|
# TOML literal (single-quoted) string: no escape processing -> Windows backslashes safe.
|
|
47
47
|
lines.append(f"command = '{cmd}'")
|
|
48
48
|
lines.append(f"args = [{arglist}]")
|
|
49
|
+
# Codex clears the env of stdio MCP servers; forward what Firekeep
|
|
50
|
+
# Studio sets per run (spec 2026-10-11 §7.2).
|
|
51
|
+
lines.append('env_vars = ["FIREKEEP_SESSION_REPORT_FILE"]')
|
|
49
52
|
lines.append("")
|
|
50
53
|
return "\n".join(lines).rstrip()
|
|
51
54
|
|
|
@@ -65,7 +65,7 @@ _KIT = frozenset({
|
|
|
65
65
|
"FIREKEEP_SHIM_DEBUG", "FIREKEEP_NO_JOB_OBJECT",
|
|
66
66
|
"FIREKEEP_REPORT_CONSENT", "FIREKEEP_REPORT_DIR",
|
|
67
67
|
"FIREKEEP_FAILURE_REPORT", "FIREKEEP_NO_FAILURE_REPORT",
|
|
68
|
-
"FIREKEEP_SESSION_ID", "NEXUS_AGENT_ID",
|
|
68
|
+
"FIREKEEP_SESSION_ID", "FIREKEEP_SESSION_REPORT_FILE", "NEXUS_AGENT_ID",
|
|
69
69
|
# hook-side knobs the client modules a child imports may consult
|
|
70
70
|
"FIREKEEP_AGENT_GOAL", "FIREKEEP_BRIEFING", "FIREKEEP_NO_AUTO_UPDATE",
|
|
71
71
|
"FIREKEEP_NO_AUTO_SYNC", "FIREKEEP_NO_AUTO_INDEX", "FIREKEEP_NO_AUTO_NIGHTSHIFT",
|
|
@@ -118,6 +118,9 @@ NOT_FORWARDED: frozenset[str] = frozenset({
|
|
|
118
118
|
"FIREKEEP_SIGNING_KEY",
|
|
119
119
|
# An enrolment code is a credential; only `firekeep join` reads it.
|
|
120
120
|
"FIREKEEP_JOIN",
|
|
121
|
+
# The bootstrap's fresh-install answer belongs to the install hand-off
|
|
122
|
+
# alone; a child seeing it could count a non-install as a new install.
|
|
123
|
+
"FIREKEEP_INSTALL_REPORT_CONSENT",
|
|
121
124
|
# Set by the gateway from ~/.firekeep/config for symdex alone; a parent's
|
|
122
125
|
# copy (stale shell export, another member's key) is never authoritative.
|
|
123
126
|
"FIREKEEP_CORTEX_URL", "FIREKEEP_CLIENT_API_KEY", "FIREKEEP_CORTEX_CA",
|
|
@@ -87,6 +87,44 @@ def cmd_version(args) -> int:
|
|
|
87
87
|
return 0
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
# --- whoami: identity facts for Studio 0.5 -----------------------------------
|
|
91
|
+
|
|
92
|
+
def cmd_whoami(args) -> int:
|
|
93
|
+
"""Identity facts a local client (Firekeep Studio) needs — never the API key.
|
|
94
|
+
|
|
95
|
+
Studio must not read ~/.firekeep/config itself because the file holds the
|
|
96
|
+
key, so this is the one place it learns the agent id (spec 2026-10-11 §7.1).
|
|
97
|
+
"""
|
|
98
|
+
cfg = None
|
|
99
|
+
try:
|
|
100
|
+
cfg = resolver.load_config(_config_path())
|
|
101
|
+
except resolver.ConfigMigrationConflict as exc:
|
|
102
|
+
print(f"firekeep: {exc}", file=sys.stderr)
|
|
103
|
+
return 3
|
|
104
|
+
except resolver.ConfigError:
|
|
105
|
+
cfg = None
|
|
106
|
+
agent: str | None = None
|
|
107
|
+
if cfg is not None:
|
|
108
|
+
try:
|
|
109
|
+
agent = resolver.agent_id(cfg)
|
|
110
|
+
except resolver.ConfigError:
|
|
111
|
+
agent = None
|
|
112
|
+
if agent == wizard.PLACEHOLDER_AGENT_ID:
|
|
113
|
+
agent = None # the installer skeleton value is "unconfigured"
|
|
114
|
+
facts = {
|
|
115
|
+
"agent_id": agent,
|
|
116
|
+
"kit_version": __version__,
|
|
117
|
+
"personal_mode": bool(resolver.is_bypassed()),
|
|
118
|
+
"server_configured": bool(cfg is not None and wizard._already_connected(cfg)),
|
|
119
|
+
}
|
|
120
|
+
if getattr(args, "json", False):
|
|
121
|
+
print(json.dumps(facts))
|
|
122
|
+
else:
|
|
123
|
+
for key, value in facts.items():
|
|
124
|
+
print(f"{key}: {value}")
|
|
125
|
+
return 0 if agent else 1
|
|
126
|
+
|
|
127
|
+
|
|
90
128
|
# --- install: venv, pip, ~/.firekeep bootstrap, adapter render -----------------
|
|
91
129
|
|
|
92
130
|
# `configured = false` is the sentinel that makes "never set up" a REPRESENTABLE
|
|
@@ -277,7 +315,18 @@ def _venv_has_pip(venv: Path) -> bool:
|
|
|
277
315
|
return True
|
|
278
316
|
|
|
279
317
|
|
|
280
|
-
def
|
|
318
|
+
def _is_live_venv(home: Path, venv: Path) -> bool:
|
|
319
|
+
"""True when the `current` link resolves to `venv` — the install every hook runs."""
|
|
320
|
+
current = home / CURRENT_LINK_NAME
|
|
321
|
+
if not os.path.lexists(current):
|
|
322
|
+
return False
|
|
323
|
+
try:
|
|
324
|
+
return os.path.normcase(os.path.realpath(current)) == os.path.normcase(os.path.realpath(venv))
|
|
325
|
+
except OSError:
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _create_venv(venv: Path, home: Path) -> None:
|
|
281
330
|
if _venv_bin(venv).exists():
|
|
282
331
|
if _venv_has_pip(venv):
|
|
283
332
|
return
|
|
@@ -285,6 +334,16 @@ def _create_venv(venv: Path) -> None:
|
|
|
285
334
|
# bootstrap that died between `uv venv` and `uv pip install` leaves exactly this.
|
|
286
335
|
# Short-circuiting here used to crash the checkout install later in _pip_install;
|
|
287
336
|
# rebuild in place instead (--clear reprovisions, pip included via ensurepip).
|
|
337
|
+
#
|
|
338
|
+
# Never the LIVE venv, though: a healthy bootstrap venv is pip-less too, and a
|
|
339
|
+
# checkout whose version matches the installed one lands here. On 2026-10-12 the
|
|
340
|
+
# --clear died on executables held by running sessions, leaving Scripts/ without
|
|
341
|
+
# a stdlib, and every hook on the machine failed until the venv was rebuilt.
|
|
342
|
+
if _is_live_venv(home, venv):
|
|
343
|
+
raise RuntimeError(
|
|
344
|
+
f"{venv} is the live install (`current` points at it) and has no pip; "
|
|
345
|
+
"rebuilding it would break every running session. Bump the checkout's "
|
|
346
|
+
"version, or run `firekeep update` to repair the installed release.")
|
|
288
347
|
_run([sys.executable, "-m", "venv", "--clear", str(venv)])
|
|
289
348
|
return
|
|
290
349
|
_run([sys.executable, "-m", "venv", str(venv)])
|
|
@@ -402,7 +461,7 @@ def _apply_flags(cfg, args) -> bool:
|
|
|
402
461
|
# overwrite an ALREADY-recorded answer — a re-render, an update's
|
|
403
462
|
# re-exec, or a non-interactive run relaying a pre-set env must not
|
|
404
463
|
# rewrite a "no" a human already gave.
|
|
405
|
-
report.record_consent(cfg, pre == "1")
|
|
464
|
+
report.record_consent(cfg, pre == "1", installs=report.installs_env_answer())
|
|
406
465
|
touched = True
|
|
407
466
|
return touched
|
|
408
467
|
|
|
@@ -555,7 +614,7 @@ def cmd_install(args) -> int:
|
|
|
555
614
|
# Checkout install: provision a fresh versioned venv at its FINAL path
|
|
556
615
|
# (never moved — venvs aren't relocatable) and flip `current` to it below.
|
|
557
616
|
venv = home / VENVS_DIR_NAME / _kit_version(kit)
|
|
558
|
-
_create_venv(venv)
|
|
617
|
+
_create_venv(venv, home)
|
|
559
618
|
else:
|
|
560
619
|
venv = Path(sys.executable).resolve().parent.parent
|
|
561
620
|
python = _venv_python(venv)
|
|
@@ -651,8 +710,9 @@ def cmd_install(args) -> int:
|
|
|
651
710
|
print(f"firekeep: install failed at '{step}': {exc}", file=sys.stderr)
|
|
652
711
|
return 1
|
|
653
712
|
|
|
713
|
+
report.milestone("installed")
|
|
654
714
|
print(f"firekeep: installed into {home}")
|
|
655
|
-
registered =
|
|
715
|
+
registered ="firekeep (gateway: memory, sessions, relay, monitoring, code, decisions)"
|
|
656
716
|
print(f"firekeep: registered MCP servers: {registered}")
|
|
657
717
|
print("firekeep: tip — `/personal` (Claude) or `firekeep personal` toggles a private "
|
|
658
718
|
"session where Firekeep is fully bypassed (nothing logged/recalled); it "
|
|
@@ -959,6 +1019,8 @@ def _check_health(cfg) -> list[tuple[str, str, str]]:
|
|
|
959
1019
|
else:
|
|
960
1020
|
for svc, category in failures:
|
|
961
1021
|
report.emit("connectivity", svc, error=category, cfg=cfg)
|
|
1022
|
+
if not failures:
|
|
1023
|
+
report.milestone("connected", cfg=cfg)
|
|
962
1024
|
return out
|
|
963
1025
|
|
|
964
1026
|
|
|
@@ -3147,6 +3209,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
3147
3209
|
ver = sub.add_parser("version", help="print client version + skew status")
|
|
3148
3210
|
ver.set_defaults(func=cmd_version)
|
|
3149
3211
|
|
|
3212
|
+
who = sub.add_parser("whoami", help="print this machine's Firekeep identity (never the key)")
|
|
3213
|
+
who.add_argument("--json", action="store_true", help="one line of JSON for programs")
|
|
3214
|
+
who.set_defaults(func=cmd_whoami)
|
|
3215
|
+
|
|
3150
3216
|
inst = sub.add_parser("install", help="install/refresh the client kit")
|
|
3151
3217
|
# An unset runtime prepares every shipped adapter. Explicit --runtime (or the
|
|
3152
3218
|
# bootstrap's FIREKEEP_RUNTIME) remains available for a targeted re-render.
|
|
@@ -66,6 +66,15 @@ TOOLSET_PRESETS: dict[str, frozenset[str]] = {
|
|
|
66
66
|
"ctx_abandon_session", "ctx_list_sessions", "ctx_resume_session",
|
|
67
67
|
"ctx_get_shadow",
|
|
68
68
|
}),
|
|
69
|
+
# Firekeep Studio's Keep panel and per-turn receipts (Studio 0.5, spec
|
|
70
|
+
# 2026-10-11 §7.1). Reads plus knowledge feedback only: Studio never
|
|
71
|
+
# starts, updates or completes a Keep session — the agent owns those.
|
|
72
|
+
"studio": frozenset({
|
|
73
|
+
"memory_recall", "memory_feedback",
|
|
74
|
+
"skill_list", "skill_recall",
|
|
75
|
+
"relay_who_is_online", "relay_read",
|
|
76
|
+
"ctx_list_sessions", "replay_timeline", "corpus_sources",
|
|
77
|
+
}),
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
# A preset that narrows the tool surface also narrows the handshake text: the
|
|
@@ -127,6 +127,8 @@ def run(payload: dict) -> dict:
|
|
|
127
127
|
)
|
|
128
128
|
if isinstance(data, dict) and data.get("rendered"):
|
|
129
129
|
rendered = data["rendered"]
|
|
130
|
+
# Once per machine; the flush below sends it.
|
|
131
|
+
report.milestone("connected", cfg=cfg, flush_now=False)
|
|
130
132
|
# Stash the server-minted briefing_id so the bridge shim can inject it
|
|
131
133
|
# into a ctx_start_session call the agent makes without it — closing the
|
|
132
134
|
# briefing_id -> session_id A/B join mechanically. (The stale-id clear
|
|
@@ -286,6 +286,15 @@ def remember(agent: str, ids: list[str]) -> None:
|
|
|
286
286
|
hooklog.log_failure(_HOOK, f"proactive recall dedupe write failed: {e}")
|
|
287
287
|
|
|
288
288
|
|
|
289
|
+
def _keep_session(cfg) -> str | None:
|
|
290
|
+
"""The agent's current Keep session, so the pushed recall is attributed to
|
|
291
|
+
it in replay (Studio 0.5 receipts, spec §7.2). Best-effort: None on any error."""
|
|
292
|
+
try:
|
|
293
|
+
return state.current_session_id(resolver.agent_id(cfg))
|
|
294
|
+
except Exception: # noqa: BLE001
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
289
298
|
def recall(cfg, prompt: str) -> list:
|
|
290
299
|
"""POST the prompt to cortex `/memory/recall` and return its `sources`.
|
|
291
300
|
|
|
@@ -309,12 +318,15 @@ def recall(cfg, prompt: str) -> list:
|
|
|
309
318
|
memory filed under `infrastructure` and friends — 146 of them on the live store,
|
|
310
319
|
which is the defect `docs/guides/cortex-design-decisions.md` records.
|
|
311
320
|
|
|
321
|
+
It carries the agent's current Keep session (`X-Session-Id`) so the recall shows
|
|
322
|
+
in that session's replay timeline.
|
|
323
|
+
|
|
312
324
|
Returns [] for a DEGRADED response (vector search down, results graph-only).
|
|
313
325
|
Same call as Bridge makes: the floor below is calibrated on cosine, and a
|
|
314
326
|
graph-only set is a different scale, so pushing it would be pushing noise the
|
|
315
327
|
threshold cannot judge.
|
|
316
328
|
"""
|
|
317
|
-
ep = resolver.resolve("cortex", cfg=cfg)
|
|
329
|
+
ep = resolver.resolve("cortex", cfg=cfg, session_id=_keep_session(cfg))
|
|
318
330
|
body = {
|
|
319
331
|
"task": prompt[:MAX_TASK_CHARS],
|
|
320
332
|
"top_k": TOP_K,
|
|
@@ -72,6 +72,15 @@ _STAGES_BY_KIND = {
|
|
|
72
72
|
"runtime": RUNTIME_STAGES,
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
# Install milestones: the success half of the funnel, so downloads can be read
|
|
76
|
+
# against installs that worked. NOT a failure kind — its own consent key
|
|
77
|
+
# ([report] installs), its own once-per-machine marker, no `error` field, and
|
|
78
|
+
# the collector logs it apart from failures (never mailed, never pulled into
|
|
79
|
+
# Sentinel). installed = `firekeep install` completed; connected = the first
|
|
80
|
+
# time this machine reached its Keep (doctor all-green or a served briefing).
|
|
81
|
+
MILESTONE_KIND = "milestone"
|
|
82
|
+
MILESTONE_STAGES = ("installed", "connected")
|
|
83
|
+
|
|
75
84
|
_ERRNO_MAP = {
|
|
76
85
|
_errno.EACCES: "permission-denied",
|
|
77
86
|
_errno.EPERM: "permission-denied",
|
|
@@ -184,15 +193,43 @@ def build_event(kind, stage, *, error=None, exc=None, exit_code=None,
|
|
|
184
193
|
return None
|
|
185
194
|
|
|
186
195
|
|
|
196
|
+
def build_milestone(stage) -> dict | None:
|
|
197
|
+
"""Validated milestone event, or None. Same closed fields as a failure
|
|
198
|
+
minus `error` and every tagged-union extra."""
|
|
199
|
+
try:
|
|
200
|
+
if stage not in MILESTONE_STAGES:
|
|
201
|
+
return None
|
|
202
|
+
os_family = detect_os()
|
|
203
|
+
if os_family not in OS_FAMILIES:
|
|
204
|
+
return None
|
|
205
|
+
from firekeep_client import __version__
|
|
206
|
+
return {
|
|
207
|
+
"id": uuid.uuid4().hex,
|
|
208
|
+
"kind": MILESTONE_KIND,
|
|
209
|
+
"stage": stage,
|
|
210
|
+
"os": os_family,
|
|
211
|
+
"arch": detect_arch(),
|
|
212
|
+
"client": __version__,
|
|
213
|
+
"py": py_bucket(),
|
|
214
|
+
}
|
|
215
|
+
except Exception: # noqa: BLE001
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
187
219
|
_FALSEY = ("", "0", "false", "no", "off")
|
|
188
220
|
|
|
221
|
+
# One question, two recorded answers ([report] failures + [report] installs).
|
|
222
|
+
# Machines enrolled under the earlier failures-only wording keep `installs`
|
|
223
|
+
# absent and so never send a milestone: their consent is not widened after
|
|
224
|
+
# the fact. The bootstraps (install.sh / install.ps1) carry the same scope.
|
|
189
225
|
CONSENT_PROMPT = (
|
|
190
|
-
"Send anonymous
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
226
|
+
"Send anonymous reports to firekeep.ai? Once when this install succeeds and\n"
|
|
227
|
+
"first reaches your Keep, and whenever an install step fails, a connection to\n"
|
|
228
|
+
"your own Keep fails, or a Firekeep background task errors, Firekeep sends\n"
|
|
229
|
+
"category codes only — what happened, the error class, OS family, versions.\n"
|
|
230
|
+
"Never paths, messages, addresses, or any persistent device, account or\n"
|
|
231
|
+
"session identifier. Ongoing until you turn it off\n"
|
|
232
|
+
"([report] failures = false, [report] installs = false). [Y/n] "
|
|
196
233
|
)
|
|
197
234
|
|
|
198
235
|
|
|
@@ -216,6 +253,23 @@ def is_enabled(cfg=None) -> bool:
|
|
|
216
253
|
return False
|
|
217
254
|
|
|
218
255
|
|
|
256
|
+
def milestones_enabled(cfg=None) -> bool:
|
|
257
|
+
"""Consent gate for install milestones. Stricter than is_enabled: only a
|
|
258
|
+
recorded `[report] installs = true` counts — there is deliberately no env
|
|
259
|
+
opt-in, so CI runs that set FIREKEEP_FAILURE_REPORT=1 never count as
|
|
260
|
+
installs. The failure opt-out and personal mode silence it too."""
|
|
261
|
+
try:
|
|
262
|
+
if os.environ.get("FIREKEEP_NO_FAILURE_REPORT", "").strip().lower() not in _FALSEY:
|
|
263
|
+
return False
|
|
264
|
+
if resolver.is_bypassed():
|
|
265
|
+
return False
|
|
266
|
+
if cfg is None:
|
|
267
|
+
cfg = resolver.load_config()
|
|
268
|
+
return cfg.get("report", "installs", fallback="").strip().lower() == "true"
|
|
269
|
+
except Exception: # noqa: BLE001 — any doubt means OFF (fail closed)
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
|
|
219
273
|
def has_answer(cfg) -> bool:
|
|
220
274
|
try:
|
|
221
275
|
return cfg.get("report", "failures", fallback="").strip() != ""
|
|
@@ -223,15 +277,38 @@ def has_answer(cfg) -> bool:
|
|
|
223
277
|
return False
|
|
224
278
|
|
|
225
279
|
|
|
226
|
-
def record_consent(cfg, value: bool) -> None:
|
|
280
|
+
def record_consent(cfg, value: bool, *, installs: bool | None = None) -> None:
|
|
281
|
+
"""Record the failures answer, and the installs answer only when the
|
|
282
|
+
human was shown wording that covers milestones (installs is not None)."""
|
|
227
283
|
try:
|
|
228
284
|
if not cfg.has_section("report"):
|
|
229
285
|
cfg.add_section("report")
|
|
230
286
|
cfg.set("report", "failures", "true" if value else "false")
|
|
287
|
+
if installs is not None:
|
|
288
|
+
cfg.set("report", "installs", "true" if installs else "false")
|
|
231
289
|
except Exception: # noqa: BLE001
|
|
232
290
|
pass
|
|
233
291
|
|
|
234
292
|
|
|
293
|
+
# Set when THIS process recorded an answer from a human at the prompt. With
|
|
294
|
+
# the bootstrap's relayed answer it defines a fresh install: the prompt only
|
|
295
|
+
# ever appears on a machine with no recorded answer, so an update, a re-render
|
|
296
|
+
# or a later opt-in never counts as a new install.
|
|
297
|
+
_ANSWERED_HERE = False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _fresh_install_consented() -> bool:
|
|
301
|
+
return installs_env_answer() is True or _ANSWERED_HERE
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def installs_env_answer() -> bool | None:
|
|
305
|
+
"""The bootstrap relays its prompt's answer in FIREKEEP_INSTALL_REPORT_CONSENT,
|
|
306
|
+
set ONLY when a human answered the milestone-covering wording — never by
|
|
307
|
+
FIREKEEP_FAILURE_REPORT or an update re-exec. None = not shown that prompt."""
|
|
308
|
+
pre = os.environ.get("FIREKEEP_INSTALL_REPORT_CONSENT", "").strip()
|
|
309
|
+
return (pre == "1") if pre in ("0", "1") else None
|
|
310
|
+
|
|
311
|
+
|
|
235
312
|
def ask_consent(cfg) -> bool:
|
|
236
313
|
"""Ask once; record only a real answer. EOF and Ctrl-C record NOTHING —
|
|
237
314
|
deliberately not wizard.console_ask, whose EOF-takes-the-default would
|
|
@@ -242,7 +319,7 @@ def ask_consent(cfg) -> bool:
|
|
|
242
319
|
return False
|
|
243
320
|
pre = os.environ.get("FIREKEEP_REPORT_CONSENT", "").strip()
|
|
244
321
|
if pre in ("0", "1"):
|
|
245
|
-
record_consent(cfg, pre == "1")
|
|
322
|
+
record_consent(cfg, pre == "1", installs=installs_env_answer())
|
|
246
323
|
return True
|
|
247
324
|
if not sys.stdin.isatty():
|
|
248
325
|
return False
|
|
@@ -256,7 +333,10 @@ def ask_consent(cfg) -> bool:
|
|
|
256
333
|
except (EOFError, KeyboardInterrupt):
|
|
257
334
|
print()
|
|
258
335
|
return False
|
|
259
|
-
|
|
336
|
+
yes = answer in ("", "y", "yes")
|
|
337
|
+
record_consent(cfg, yes, installs=yes)
|
|
338
|
+
global _ANSWERED_HERE
|
|
339
|
+
_ANSWERED_HERE = yes
|
|
260
340
|
return True
|
|
261
341
|
except Exception: # noqa: BLE001
|
|
262
342
|
return False
|
|
@@ -373,6 +453,56 @@ def emit(kind, stage, *, error=None, exc=None, exit_code=None,
|
|
|
373
453
|
pass
|
|
374
454
|
|
|
375
455
|
|
|
456
|
+
def _milestones_path() -> Path:
|
|
457
|
+
return _report_dir() / "report-milestones.json"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _claim_milestone(stage: str) -> bool:
|
|
461
|
+
"""Mark `stage` sent for this machine; True only for the caller that
|
|
462
|
+
newly marked it. The marker is written BEFORE the event is spooled and a
|
|
463
|
+
failed write means no event: a milestone may be lost, never repeated —
|
|
464
|
+
repeats would inflate exactly the count this exists to make honest.
|
|
465
|
+
|
|
466
|
+
The funnel is nested: `installed` only on a fresh install whose prompt was
|
|
467
|
+
answered yes in this run, `connected` only on a machine that already sent
|
|
468
|
+
`installed` — so an existing machine opting in later counts as neither."""
|
|
469
|
+
try:
|
|
470
|
+
path = _milestones_path()
|
|
471
|
+
seen = {}
|
|
472
|
+
if path.exists():
|
|
473
|
+
seen = json.loads(path.read_text(encoding="utf-8"))
|
|
474
|
+
if not isinstance(seen, dict):
|
|
475
|
+
return False # unreadable marker: assume sent
|
|
476
|
+
if stage in seen:
|
|
477
|
+
return False
|
|
478
|
+
if stage == "installed" and not _fresh_install_consented():
|
|
479
|
+
return False
|
|
480
|
+
if stage == "connected" and "installed" not in seen:
|
|
481
|
+
return False
|
|
482
|
+
seen[stage] = int(time.time())
|
|
483
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
path.write_text(json.dumps(seen), encoding="utf-8")
|
|
485
|
+
return True
|
|
486
|
+
except Exception: # noqa: BLE001
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def milestone(stage, *, cfg=None, flush_now: bool = True) -> None:
|
|
491
|
+
"""Record an install milestone once per machine. Never raises; same spool
|
|
492
|
+
and wire as emit(), so an offline machine sends it on a later flush."""
|
|
493
|
+
try:
|
|
494
|
+
if not milestones_enabled(cfg):
|
|
495
|
+
return
|
|
496
|
+
event = build_milestone(stage)
|
|
497
|
+
if event is None or not _claim_milestone(stage):
|
|
498
|
+
return
|
|
499
|
+
_append_spool(event)
|
|
500
|
+
if flush_now:
|
|
501
|
+
flush(cfg=cfg)
|
|
502
|
+
except Exception: # noqa: BLE001
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
|
|
376
506
|
def _read_events(path: Path) -> list[dict]:
|
|
377
507
|
out = []
|
|
378
508
|
try:
|
|
@@ -443,7 +573,8 @@ def flush(cfg=None, timeout=None) -> None:
|
|
|
443
573
|
if timeout is None:
|
|
444
574
|
timeout = FLUSH_TIMEOUT
|
|
445
575
|
try:
|
|
446
|
-
|
|
576
|
+
failures_on, milestones_on = is_enabled(cfg), milestones_enabled(cfg)
|
|
577
|
+
if not (failures_on or milestones_on):
|
|
447
578
|
return
|
|
448
579
|
claims = _adoptable_claims()
|
|
449
580
|
spool = _spool_path()
|
|
@@ -457,6 +588,11 @@ def flush(cfg=None, timeout=None) -> None:
|
|
|
457
588
|
claim_path.unlink()
|
|
458
589
|
except OSError:
|
|
459
590
|
pass
|
|
591
|
+
# Each event travels only under its own consent: a spooled failure
|
|
592
|
+
# from before `failures = false` is dropped, not sent under the
|
|
593
|
+
# installs answer (and vice versa).
|
|
594
|
+
events = [e for e in events
|
|
595
|
+
if (milestones_on if e.get("kind") == MILESTONE_KIND else failures_on)]
|
|
460
596
|
if not events:
|
|
461
597
|
continue
|
|
462
598
|
try:
|
|
@@ -159,8 +159,9 @@ class _StashSessionAuth(httpx.Auth):
|
|
|
159
159
|
# Tap-wired: what THIS process observed, or nothing.
|
|
160
160
|
sid = self._tap._session_id
|
|
161
161
|
else:
|
|
162
|
-
stash
|
|
163
|
-
|
|
162
|
+
# Fresh stash first; under Studio, the report file survives
|
|
163
|
+
# the stash clear a resumed runtime's session_start performs.
|
|
164
|
+
sid = state.current_session_id(self._agent)
|
|
164
165
|
if sid:
|
|
165
166
|
request.headers["X-Session-Id"] = sid
|
|
166
167
|
except Exception:
|
|
@@ -515,11 +516,15 @@ class _BridgeSessionTap:
|
|
|
515
516
|
return item
|
|
516
517
|
name = params.get("name")
|
|
517
518
|
rid = getattr(root, "id", None)
|
|
518
|
-
if rid is not None and
|
|
519
|
-
name in self._CAPTURE_TOOLS
|
|
520
|
-
or name in self._END_TOOLS
|
|
521
|
-
):
|
|
519
|
+
if rid is not None and name in self._CAPTURE_TOOLS:
|
|
522
520
|
self._pending[rid] = name
|
|
521
|
+
elif rid is not None and name in self._END_TOOLS:
|
|
522
|
+
# Remember WHICH session the call targets: an explicit id from
|
|
523
|
+
# the agent wins, else this process's captured id.
|
|
524
|
+
raw_args = params.get("arguments")
|
|
525
|
+
explicit = raw_args.get("session_id") if isinstance(raw_args, dict) else None
|
|
526
|
+
target = explicit if isinstance(explicit, str) and explicit else self._session_id
|
|
527
|
+
self._pending[rid] = (name, target)
|
|
523
528
|
if name in self._INJECT_TOOLS:
|
|
524
529
|
args = params.get("arguments")
|
|
525
530
|
if isinstance(args, dict):
|
|
@@ -546,8 +551,16 @@ class _BridgeSessionTap:
|
|
|
546
551
|
rid = getattr(root, "id", None)
|
|
547
552
|
if rid is None or rid not in self._pending:
|
|
548
553
|
return item
|
|
549
|
-
|
|
554
|
+
entry = self._pending.pop(rid)
|
|
555
|
+
target = None
|
|
556
|
+
if isinstance(entry, tuple):
|
|
557
|
+
name, target = entry
|
|
558
|
+
else:
|
|
559
|
+
name = entry
|
|
550
560
|
if name in self._END_TOOLS:
|
|
561
|
+
# Tombstone the session the call targeted before clearing.
|
|
562
|
+
if target:
|
|
563
|
+
state.end_session_report(target)
|
|
551
564
|
self._session_id = None
|
|
552
565
|
state.clear_session_stash(self._agent)
|
|
553
566
|
return item
|
|
@@ -557,6 +570,9 @@ class _BridgeSessionTap:
|
|
|
557
570
|
# Keep writing the agent-keyed disk stash: hook cores in other
|
|
558
571
|
# processes still read it, and it is the restart fallback.
|
|
559
572
|
state.write_session_stash(self._agent, session_id=sid)
|
|
573
|
+
# Studio 0.5: tell the launching Studio session which Keep
|
|
574
|
+
# session this agent is in (no-op unless the env var is set).
|
|
575
|
+
state.append_session_report(sid)
|
|
560
576
|
except Exception:
|
|
561
577
|
pass
|
|
562
578
|
return item
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
+
import re
|
|
9
10
|
import subprocess
|
|
10
11
|
import sys
|
|
11
12
|
import time
|
|
@@ -448,6 +449,90 @@ def clear_session_stash(agent_id: str) -> None:
|
|
|
448
449
|
pass
|
|
449
450
|
|
|
450
451
|
|
|
452
|
+
# Firekeep Studio 0.5 (spec 2026-10-11 §7.2): Studio launches each agent with
|
|
453
|
+
# this variable naming a per-Studio-session file. The bridge tap appends every
|
|
454
|
+
# Keep session id it captures, so Studio can build per-turn receipts from the
|
|
455
|
+
# sessions the AGENT started — Studio never starts one itself.
|
|
456
|
+
SESSION_REPORT_ENV = "FIREKEEP_SESSION_REPORT_FILE"
|
|
457
|
+
# Ids travel in an HTTP header (X-Session-Id): no whitespace, no newline, ever.
|
|
458
|
+
_SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _report_path() -> str | None:
|
|
462
|
+
raw = os.environ.get(SESSION_REPORT_ENV, "").strip()
|
|
463
|
+
return raw or None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def append_session_report(session_id: str) -> None:
|
|
467
|
+
"""Append a captured Keep session id to $FIREKEEP_SESSION_REPORT_FILE.
|
|
468
|
+
No-op when the variable is unset or the id is not header-safe. Never raises."""
|
|
469
|
+
try:
|
|
470
|
+
path = _report_path()
|
|
471
|
+
if not path or not isinstance(session_id, str) or not _SESSION_ID_RE.fullmatch(session_id):
|
|
472
|
+
return
|
|
473
|
+
with open(path, "a", encoding="utf-8", newline="\n") as fh:
|
|
474
|
+
fh.write(session_id + "\n")
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def end_session_report(session_id: str) -> None:
|
|
480
|
+
"""Mark a session as ended in $FIREKEEP_SESSION_REPORT_FILE by appending
|
|
481
|
+
'#ended <id>'. No-op when the variable is unset or the id is not header-safe.
|
|
482
|
+
Never raises."""
|
|
483
|
+
try:
|
|
484
|
+
path = _report_path()
|
|
485
|
+
if not path or not isinstance(session_id, str) or not _SESSION_ID_RE.fullmatch(session_id):
|
|
486
|
+
return
|
|
487
|
+
with open(path, "a", encoding="utf-8", newline="\n") as fh:
|
|
488
|
+
fh.write(f"#ended {session_id}\n")
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def reported_session_id() -> str | None:
|
|
494
|
+
"""The last active (not ended) header-safe id in $FIREKEEP_SESSION_REPORT_FILE.
|
|
495
|
+
A line '#ended <id>' marks an id as ended; scanning in order, the last active
|
|
496
|
+
id is returned, or None. A file whose mtime is older than the session stash
|
|
497
|
+
TTL is ignored (a stale file must not attribute a new day's work to a dead
|
|
498
|
+
session). Handles non-UTF-8 bytes gracefully."""
|
|
499
|
+
try:
|
|
500
|
+
path = _report_path()
|
|
501
|
+
if not path:
|
|
502
|
+
return None
|
|
503
|
+
if time.time() - os.path.getmtime(path) > _session_stash_ttl_seconds():
|
|
504
|
+
return None
|
|
505
|
+
last = None
|
|
506
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
507
|
+
for line in fh:
|
|
508
|
+
line = line.strip()
|
|
509
|
+
if line.startswith("#ended "):
|
|
510
|
+
# Extract the id after "#ended "
|
|
511
|
+
ended_id = line[7:] # len("#ended ") == 7
|
|
512
|
+
# Deliberately fail-closed: a tombstone clears only the
|
|
513
|
+
# most recent id, so "K1, K2, #ended K2" yields None even
|
|
514
|
+
# if K1 is still live. Tracking a set of ended ids would
|
|
515
|
+
# revive sessions that ended without writing a tombstone.
|
|
516
|
+
if ended_id == last:
|
|
517
|
+
last = None
|
|
518
|
+
elif _SESSION_ID_RE.fullmatch(line):
|
|
519
|
+
last = line
|
|
520
|
+
return last
|
|
521
|
+
except Exception:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def current_session_id(agent_id: str) -> str | None:
|
|
526
|
+
"""The Keep session this agent is working in: the fresh stash, else the
|
|
527
|
+
Studio report file (which survives the stash clear a resumed runtime's
|
|
528
|
+
session_start performs). Never raises."""
|
|
529
|
+
try:
|
|
530
|
+
stash = read_session_stash(agent_id) or {}
|
|
531
|
+
return stash.get("session_id") or reported_session_id()
|
|
532
|
+
except Exception:
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
|
|
451
536
|
# --- runbook bundle store (Enforced Runbooks Phase B) ------------------------
|
|
452
537
|
#
|
|
453
538
|
# Spec: docs/superpowers/specs/2026-08-15-enforced-runbooks-design.md ("Bundle").
|