firekeep-client 1.4.2__tar.gz → 1.5.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.
Files changed (156) hide show
  1. {firekeep_client-1.4.2/firekeep_client.egg-info → firekeep_client-1.5.2}/PKG-INFO +1 -1
  2. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/__init__.py +1 -1
  3. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/cli.py +59 -1
  4. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/contract/matrix.py +41 -5
  5. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/decision/server.py +17 -1
  6. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/gateway.py +59 -0
  7. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/__main__.py +80 -4
  8. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/prompt.py +9 -2
  9. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/promptrecall.py +22 -5
  10. {firekeep_client-1.4.2 → firekeep_client-1.5.2/firekeep_client.egg-info}/PKG-INFO +1 -1
  11. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client.egg-info/SOURCES.txt +5 -0
  12. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/pyproject.toml +1 -1
  13. firekeep_client-1.5.2/tests/test_cli_doctor_report.py +216 -0
  14. firekeep_client-1.5.2/tests/test_decision_poll_ceiling.py +105 -0
  15. firekeep_client-1.5.2/tests/test_gateway_schema_slimming.py +194 -0
  16. firekeep_client-1.5.2/tests/test_install_skill_contract.py +344 -0
  17. firekeep_client-1.5.2/tests/test_instruction_budget.py +113 -0
  18. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_package.py +21 -1
  19. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/LICENSE +0 -0
  20. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/NOTICE +0 -0
  21. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/README.md +0 -0
  22. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/adapters/__init__.py +0 -0
  23. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/adapters/base.py +0 -0
  24. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/adapters/claude.py +0 -0
  25. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/adapters/claude_desktop.py +0 -0
  26. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/adapters/codex.py +0 -0
  27. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/adapters/generic.py +0 -0
  28. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/adapters/kiro.py +0 -0
  29. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/adapters/opencode.py +0 -0
  30. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/autoupdate.py +0 -0
  31. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/backups.py +0 -0
  32. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/config_write.py +0 -0
  33. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/connect.py +0 -0
  34. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/contract/__init__.py +0 -0
  35. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/decision/__init__.py +0 -0
  36. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/decision/board.py +0 -0
  37. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/dexes.py +0 -0
  38. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/docdexsync.py +0 -0
  39. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooklog.py +0 -0
  40. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/__init__.py +0 -0
  41. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/_git.py +0 -0
  42. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/_mcp.py +0 -0
  43. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/destructive.py +0 -0
  44. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/post_tool.py +0 -0
  45. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/pre_tool.py +0 -0
  46. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/precompact.py +0 -0
  47. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/runbooks.py +0 -0
  48. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/session_end.py +0 -0
  49. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/session_start.py +0 -0
  50. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/hooks/stop.py +0 -0
  51. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/join.py +0 -0
  52. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/joincode.py +0 -0
  53. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/maildexsync.py +0 -0
  54. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/migrate.py +0 -0
  55. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/nightshift.py +0 -0
  56. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/pathenv.py +0 -0
  57. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/resolver.py +0 -0
  58. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/serverinit.py +0 -0
  59. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/shim.py +0 -0
  60. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/sidecar.py +0 -0
  61. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/signing.py +0 -0
  62. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/state.py +0 -0
  63. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/stdio.py +0 -0
  64. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/symdexindex.py +0 -0
  65. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/transport.py +0 -0
  66. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/updater.py +0 -0
  67. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/wizard.py +0 -0
  68. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client/worktree_snapshot.py +0 -0
  69. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client.egg-info/dependency_links.txt +0 -0
  70. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client.egg-info/entry_points.txt +0 -0
  71. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client.egg-info/requires.txt +0 -0
  72. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/firekeep_client.egg-info/top_level.txt +0 -0
  73. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/setup.cfg +0 -0
  74. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_attribution_headers.py +0 -0
  75. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_autoupdate.py +0 -0
  76. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_bash_layer_retired.py +0 -0
  77. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_bootstrap_docdex.py +0 -0
  78. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_bootstrap_maildex.py +0 -0
  79. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_bootstrap_ps1.py +0 -0
  80. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_bootstrap_reinstall.py +0 -0
  81. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_bootstrap_sh.py +0 -0
  82. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_bootstrap_symdex.py +0 -0
  83. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_bootstrap_venv_provisioning.py +0 -0
  84. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_ci_publishes_symdex.py +0 -0
  85. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli.py +0 -0
  86. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_backup.py +0 -0
  87. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_dex.py +0 -0
  88. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_docdex.py +0 -0
  89. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_doctor.py +0 -0
  90. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_doctor_generic.py +0 -0
  91. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_entry.py +0 -0
  92. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_generic_install.py +0 -0
  93. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_install.py +0 -0
  94. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_maildex.py +0 -0
  95. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_onboarding.py +0 -0
  96. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_personal.py +0 -0
  97. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_uninstall.py +0 -0
  98. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_uninstall_generic.py +0 -0
  99. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_cli_update.py +0 -0
  100. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_config_migration.py +0 -0
  101. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_connect.py +0 -0
  102. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_decision_board_html.py +0 -0
  103. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_decision_bypass.py +0 -0
  104. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_decision_registration.py +0 -0
  105. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_decision_server.py +0 -0
  106. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_dexes.py +0 -0
  107. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_docdexsync.py +0 -0
  108. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_docs_reference_client_kit.py +0 -0
  109. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_doctor_credential_expiry.py +0 -0
  110. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_doctor_embeddings.py +0 -0
  111. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_doctor_no_server.py +0 -0
  112. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_e2e_bootstrap.py +0 -0
  113. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_gateway.py +0 -0
  114. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_gateway_toolset.py +0 -0
  115. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_generic_config.py +0 -0
  116. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_hooklog.py +0 -0
  117. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_import_boundary.py +0 -0
  118. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_init_self_enroll.py +0 -0
  119. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_init_stale_bundle.py +0 -0
  120. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_join.py +0 -0
  121. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_join_config_write.py +0 -0
  122. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_joincode.py +0 -0
  123. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_kit_smoke.py +0 -0
  124. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_maildexsync.py +0 -0
  125. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_make_release.py +0 -0
  126. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_memory_instructions.py +0 -0
  127. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_migration_entrypoints.py +0 -0
  128. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_nightshift.py +0 -0
  129. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_pathenv.py +0 -0
  130. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_promptrecall.py +0 -0
  131. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_resolver.py +0 -0
  132. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_resolver_bypass.py +0 -0
  133. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_resolver_single_connection.py +0 -0
  134. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_runbook_match_parity.py +0 -0
  135. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_runbook_state.py +0 -0
  136. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_scratch_ttl.py +0 -0
  137. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_serverinit.py +0 -0
  138. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_shim_bridge.py +0 -0
  139. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_shim_bypass.py +0 -0
  140. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_shim_fail_loud.py +0 -0
  141. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_shim_identity.py +0 -0
  142. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_shim_recovery.py +0 -0
  143. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_shim_skeleton.py +0 -0
  144. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_sidecar.py +0 -0
  145. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_sidecar_robustness.py +0 -0
  146. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_signing.py +0 -0
  147. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_state.py +0 -0
  148. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_stdio_encoding.py +0 -0
  149. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_symdexindex.py +0 -0
  150. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_transport.py +0 -0
  151. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_transport_sse.py +0 -0
  152. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_twin_removed.py +0 -0
  153. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_updater.py +0 -0
  154. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_wizard.py +0 -0
  155. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/tests/test_wizard_generic.py +0 -0
  156. {firekeep_client-1.4.2 → firekeep_client-1.5.2}/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.4.2
3
+ Version: 1.5.2
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
@@ -9,4 +9,4 @@ versions without a verdict (`_check_versions`) and judges staleness against the
9
9
  release manifest instead (`_check_client_version`).
10
10
  """
11
11
 
12
- __version__ = "1.4.2"
12
+ __version__ = "1.5.2"
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import argparse
9
9
  import contextlib
10
+ import http.client
10
11
  import os
11
12
  import re
12
13
  import shutil
@@ -41,7 +42,7 @@ from firekeep_client.adapters.base import (
41
42
  rendered_instructions_path,
42
43
  )
43
44
  from firekeep_client.adapters.codex import mcp_block_is_current
44
- from firekeep_client.transport import get_json, TransportError
45
+ from firekeep_client.transport import get_json, post_json, TransportError
45
46
 
46
47
 
47
48
  def _config_path() -> Path:
@@ -1701,6 +1702,51 @@ def run_doctor(cfg=None) -> list[tuple[str, str, str]]:
1701
1702
  return results
1702
1703
 
1703
1704
 
1705
+ # --- doctor --report: opt-in, per-invocation, redacted -----------------------
1706
+ #
1707
+ # Design record: firekeep.ai/privacy.html discloses this exact mechanism.
1708
+ # There is deliberately NO persisted config toggle (no [telemetry] section) —
1709
+ # every send is one explicit act (typing --report on this one command), never
1710
+ # a standing "always send" setting that could be flipped once and forgotten.
1711
+ # Nothing about doctor's automatic behavior changes; without the flag this
1712
+ # code path is never reached and no network call to firekeep.ai happens.
1713
+ DOCTOR_REPORT_URL = "https://firekeep.ai/doctor-report.php"
1714
+
1715
+
1716
+ def _redact_for_report(results: list[tuple[str, str, str]]) -> dict:
1717
+ """Check name + status ONLY. The third tuple element — `detail`, the
1718
+ human-readable message doctor prints locally — is dropped entirely and
1719
+ never touches this function's return value. `detail` is where paths,
1720
+ hostnames, and config contents live (see e.g. `_check_health`'s
1721
+ `f"{_ep_url(svc, cfg)}: {exc}"` and `_check_config_perms`'s literal path),
1722
+ so the redaction is structural — no field to forget to strip — rather
1723
+ than a scrub applied after the fact."""
1724
+ return {
1725
+ "client_version": __version__,
1726
+ "checks": [{"id": name, "status": status} for name, status, _detail in results],
1727
+ }
1728
+
1729
+
1730
+ def _send_doctor_report(results: list[tuple[str, str, str]]) -> str:
1731
+ """POST the redacted summary; return one line describing what happened.
1732
+ Never raises — a failed report must never affect doctor's own exit code
1733
+ or hide the check results already printed above it."""
1734
+ body = _redact_for_report(results)
1735
+ try:
1736
+ # No auth headers: this is an anonymous, unauthenticated report to a
1737
+ # public collection endpoint, not a call to the user's own server.
1738
+ post_json(DOCTOR_REPORT_URL, body, headers={})
1739
+ return "firekeep: anonymous report sent (check names + status only — see firekeep.ai/privacy.html)"
1740
+ except (TransportError, OSError, http.client.HTTPException) as exc:
1741
+ # http.client.HTTPException (IncompleteRead, BadStatusLine, ...) is
1742
+ # NOT an OSError -- it's raised deep inside urlopen()'s response read
1743
+ # (transport._request), and without this clause a truncated response
1744
+ # from a hostile proxy or captive portal would escape as an
1745
+ # unhandled traceback AFTER doctor's own results already printed,
1746
+ # discarding the exit code the fail rows above earned.
1747
+ return f"firekeep: report NOT sent ({exc}) — doctor results above are unaffected"
1748
+
1749
+
1704
1750
  def cmd_restore(args) -> int:
1705
1751
  """Browse or restore local snapshots of uncommitted work.
1706
1752
 
@@ -2480,6 +2526,8 @@ def cmd_doctor(args) -> int:
2480
2526
  hint = _generic_hint()
2481
2527
  if hint is not None:
2482
2528
  print(hint)
2529
+ if getattr(args, "report", False):
2530
+ print(_send_doctor_report(results))
2483
2531
  return rc
2484
2532
 
2485
2533
 
@@ -2909,6 +2957,16 @@ def _build_parser() -> argparse.ArgumentParser:
2909
2957
  "doctor", aliases=["status"],
2910
2958
  help="preflight health / skew / perm checks",
2911
2959
  )
2960
+ # Opt-in, per-invocation, no persisted setting — see cmd_doctor and
2961
+ # _redact_for_report. Off unless this exact flag is typed; doctor's own
2962
+ # output and exit code are unaffected either way.
2963
+ doc.add_argument(
2964
+ "--report", action="store_true",
2965
+ help="also send an ANONYMOUS report (check names + pass/warn/fail "
2966
+ "status and your client version only — never messages, paths, "
2967
+ "or hostnames) to firekeep.ai. Off by default; see "
2968
+ "firekeep.ai/privacy.html.",
2969
+ )
2912
2970
  doc.set_defaults(func=cmd_doctor)
2913
2971
 
2914
2972
  shift = sub.add_parser(
@@ -76,12 +76,47 @@ def _precompact_claude(hooks: tuple[tuple[str, str, str | None, int], ...]) -> s
76
76
  # (firekeep_client/adapters/opencode.py). VALIDATED live on opencode 1.14.22
77
77
  # (2026-07-18, docs/OPENCODE-VALIDATION.md): pre-edit block is a HARD gate (the
78
78
  # write tool aborted with the policy reason), prompt-core inbox surfaced, stop
79
- # fired on session.deleted. Caveats: session.created publishes before plugins
79
+ # fired on session.deleted. Caveat: session.created publishes before plugins
80
80
  # subscribe in `run` mode (bridge fires session_start from its first hook
81
- # instead), and briefing/inbox text lands in opencode's console log, NOT the
82
- # model context — opencode has no systemMessage channel.
81
+ # instead).
82
+ #
83
+ # CORRECTION (2026-08-21) — this comment used to say opencode's briefing lands
84
+ # in the console "NOT the model context" BECAUSE it has no systemMessage
85
+ # channel. That reasoning was wrong, and the rows below inherited the error.
86
+ #
87
+ # Firekeep's dict hook cores all return {"systemMessage": ...}. On Claude Code
88
+ # `systemMessage` is shown to the HUMAN; the channel that reaches the model is
89
+ # `hookSpecificOutput.additionalContext`, which appears nowhere in this client.
90
+ # Measured in one session by comparing every SessionStart hook attachment: the
91
+ # three emitting `additionalContext` were verbatim in the model's context, the
92
+ # two emitting `systemMessage` (Firekeep's pre-flight briefing, the symdex
93
+ # banner) were absent. Claude Code's own docs are explicit — "To surface a
94
+ # message to the user on any platform, return systemMessage" — while for
95
+ # SessionStart and UserPromptSubmit "Claude Code adds plain-text stdout as
96
+ # context that Claude can see and act on".
97
+ #
98
+ # FIXED in the same series: hooks/__main__.py now emits BOTH channels for the
99
+ # two events Claude Code documents as accepting model-facing context —
100
+ # session_start (SessionStart) and prompt (UserPromptSubmit). Verified on the
101
+ # real rendered command line: hookEventName SessionStart, 2,265 characters of
102
+ # briefing in additionalContext, systemMessage retained so the human still sees
103
+ # it. The claude cells below are therefore true again — but they were wrong for
104
+ # as long as this comment's first half describes, which is why the history
105
+ # stays here rather than being tidied away.
106
+ #
107
+ # Still human-only, deliberately: stop, precompact and session_end. Claude Code
108
+ # does not document a model-facing channel for those events and nobody has
109
+ # measured one; emitting a plausible-looking shape at an event that ignores it
110
+ # would put the text back where it started while looking fixed.
111
+ #
112
+ # kiro stays marked unverified: its channel is a different mechanism
113
+ # (agentSpawn), nobody has run the same measurement on it, and the fix above is
114
+ # scoped to the claude runtime for exactly that reason. Do not assume it shares
115
+ # Claude Code's semantics; measure before changing the cell.
83
116
  MATRIX: dict[str, dict[str, str]] = {
84
- "briefing": {"claude": "hook", "kiro": "agentSpawn hook", "codex": "manual/memory_recall",
117
+ "briefing": {"claude": "hook",
118
+ "kiro": "agentSpawn hook (delivery unverified)",
119
+ "codex": "manual/memory_recall",
85
120
  "opencode": "plugin (first event, console log only)",
86
121
  "claude-desktop": "none (MCP only)",
87
122
  "generic": "none (MCP only)"},
@@ -95,7 +130,8 @@ MATRIX: dict[str, dict[str, str]] = {
95
130
  # deciding whether opencode support is a wiring job or a protocol limit gets the
96
131
  # answer from the cell. For all four non-claude/kiro runtimes the session-start
97
132
  # briefing remains the only push.
98
- "proactive_recall": {"claude": "per-prompt push", "kiro": "per-prompt push",
133
+ "proactive_recall": {"claude": "per-prompt push",
134
+ "kiro": "per-prompt push (delivery unverified)",
99
135
  "codex": "none (no hooks)", "opencode": "none (no prompt text)",
100
136
  "claude-desktop": "none (no hooks)",
101
137
  "generic": "none (no hooks)"},
@@ -56,7 +56,23 @@ _DEFAULT_SYNTH_TIMEOUT = 30.0
56
56
  _INGEST_TIMEOUT_HEADROOM = 15.0
57
57
  # Bounded long-poll ceiling — kept under the MCP tool-call ceiling so a single
58
58
  # decision_board / decision_board_check call returns before the runtime times out.
59
- _DEFAULT_POLL_SECONDS = 24.0
59
+ #
60
+ # This is a TOKEN-COST setting as much as a latency one. Every expiry costs the
61
+ # agent one more model turn, and a model turn re-sends the whole conversation:
62
+ # measured 2026-08-21 across 5 sessions and 13 boards, 197 poll-only turns cost
63
+ # 93.5M raw token units / 9.8M price-weighted — ~49,800 weighted tokens per
64
+ # poll. Raising the ceiling is free when the human is responsive, because the
65
+ # loop below returns the instant an answer lands; it only ever bounds the
66
+ # UNANSWERED case.
67
+ #
68
+ # 50s, not more, because the ceiling has to survive the most constrained host
69
+ # rather than the one we happen to run on. Claude Code resolves a per-call
70
+ # timeout of ~27.8h for STDIO servers (the gateway's path) but 60s for REMOTE
71
+ # HTTP/SSE servers — which is the path deploy/chatgpt-tunnel/ takes. 60s is the
72
+ # tightest ceiling we can actually name, so this sits 10s under it.
73
+ # DECISION_POLL_SECONDS overrides for an operator who knows their host.
74
+ # Guarded by tests/test_decision_poll_ceiling.py.
75
+ _DEFAULT_POLL_SECONDS = 50.0
60
76
  # Poll granularity — MUST be a real ``await anyio.sleep`` (never a blocking wait),
61
77
  # so the event loop stays responsive.
62
78
  _POLL_INTERVAL = 0.5
@@ -98,6 +98,60 @@ def _active_toolset() -> tuple[str | None, frozenset[str] | None]:
98
98
  return preset, TOOLSET_PRESETS[preset]
99
99
 
100
100
 
101
+ def _slim_schema(schema: Any) -> Any:
102
+ """Collapse Pydantic's ``X | None`` rendering into JSON Schema's type array.
103
+
104
+ Pydantic emits an optional field as
105
+ ``{"anyOf": [{"type": "X"}, {"type": "null"}]}`` — 43 characters where
106
+ ``{"type": ["X", "null"]}`` is 24, for the same meaning. Measured on the
107
+ live gateway (2026-08-21): 66 such fields across 98 tools. Tool definitions
108
+ sit in the cached prompt prefix, so on a runtime WITHOUT tool deferral that
109
+ is bytes re-sent every turn for the life of the session.
110
+
111
+ Only the exact two-branch ``[T, null]`` shape collapses, and only into the
112
+ type-array form. Dropping the null branch outright would be shorter and is
113
+ deliberately NOT done: it would make an explicit ``null`` invalid where the
114
+ server accepts it, which is a behaviour change wearing a token saving's
115
+ clothes. ``tests/test_gateway_schema_slimming.py`` proves the equivalence
116
+ against a real validator rather than asserting it.
117
+
118
+ Never raises. A backend may serve any shape it likes, and a schema this
119
+ cannot parse must reach the model unmodified rather than take the surface
120
+ down — the same failure isolation the rest of the gateway is built on.
121
+ """
122
+ try:
123
+ return _slim(schema)
124
+ except Exception: # noqa: BLE001 - pass-through beats a broken tool surface
125
+ return schema
126
+
127
+
128
+ def _slim(node: Any) -> Any:
129
+ if isinstance(node, list):
130
+ return [_slim(item) for item in node]
131
+ if not isinstance(node, dict):
132
+ return node
133
+
134
+ branches = node.get("anyOf")
135
+ if isinstance(branches, list) and len(branches) == 2:
136
+ nulls = [b for b in branches if b == {"type": "null"}]
137
+ others = [b for b in branches if isinstance(b, dict) and b != {"type": "null"}]
138
+ # Exactly one null branch and one non-null branch whose own `type` is a
139
+ # plain string. Anything richer (a nested anyOf, a branch with no type,
140
+ # a $ref) is left alone: this collapse is only provably lossless for
141
+ # the simple shape, and "left alone" costs bytes, not correctness.
142
+ if len(nulls) == 1 and len(others) == 1 and isinstance(others[0].get("type"), str):
143
+ collapsed = {k: v for k, v in node.items() if k != "anyOf"}
144
+ # The branch's own keywords (items, additionalProperties, ...) come
145
+ # with it; the field's siblings (default, description) stay put.
146
+ for key, value in others[0].items():
147
+ if key != "type":
148
+ collapsed.setdefault(key, value)
149
+ collapsed["type"] = [others[0]["type"], "null"]
150
+ return {k: _slim(v) for k, v in collapsed.items()}
151
+
152
+ return {k: _slim(v) for k, v in node.items()}
153
+
154
+
101
155
  def _console_script(name: str) -> str:
102
156
  suffix = ".exe" if os.name == "nt" else ""
103
157
  filename = f"{name}{suffix}"
@@ -288,6 +342,11 @@ class Gateway:
288
342
  self.tools_filtered += 1
289
343
  continue
290
344
  self.routes[name] = backend
345
+ # Slim the ADVERTISED schema only. Routing, calls and the
346
+ # upstream's own validation are untouched: _slim_schema returns
347
+ # new objects and the backend's tool dict is left as served.
348
+ if isinstance(tool.get("inputSchema"), dict):
349
+ tool = {**tool, "inputSchema": _slim_schema(tool["inputSchema"])}
291
350
  tools.append(tool)
292
351
  return tools
293
352
 
@@ -70,6 +70,71 @@ _CORE_MODULES = {
70
70
  _INT_CORES = frozenset({"pre_tool", "post_tool"})
71
71
  _DICT_CORES = frozenset({"session_start", "stop", "session_end", "prompt", "precompact"})
72
72
 
73
+ # Cores whose text must reach the MODEL, and the Claude Code event name that
74
+ # carries it.
75
+ #
76
+ # `systemMessage` is shown to the human; the channel that reaches the model is
77
+ # `hookSpecificOutput.additionalContext`. Measured 2026-08-21 by comparing every
78
+ # SessionStart hook attachment in one session: the three hooks emitting
79
+ # additionalContext were verbatim in the model's context, the two emitting
80
+ # systemMessage (Firekeep's pre-flight briefing, the symdex banner) were absent.
81
+ # Claude Code's docs say the same thing from the other side — "To surface a
82
+ # message to the user on any platform, return systemMessage", against "for
83
+ # SessionStart and UserPromptSubmit Claude Code adds plain-text stdout as
84
+ # context that Claude can see and act on".
85
+ #
86
+ # The text was always written for the model: the briefing opens "You are
87
+ # agent-...", proactive recall pushes memories for the agent to act on, and the
88
+ # bypass notice says "you should NOT use firekeep_* tools" — an instruction a
89
+ # human reader cannot follow. All of it was landing in the terminal.
90
+ #
91
+ # ONLY these two events. `stop`, `precompact` and `session_end` are absent
92
+ # because nobody has verified those events accept model-facing context, and
93
+ # emitting a plausible-looking shape at an event that ignores it would leave the
94
+ # text exactly where it started while looking fixed — which is how this bug
95
+ # happened the first time.
96
+ _MODEL_CONTEXT_EVENTS = {
97
+ "session_start": "SessionStart",
98
+ "prompt": "UserPromptSubmit",
99
+ }
100
+
101
+ # ONLY the runtime whose channel has actually been measured. kiro, opencode and
102
+ # the rest use different mechanisms; handing them a Claude Code-shaped payload
103
+ # would be the same guess in a new place. An OLD rendered hook command carries
104
+ # no `--runtime` at all and must keep working unchanged.
105
+ _MODEL_CONTEXT_RUNTIMES = frozenset({"claude"})
106
+
107
+
108
+ def _with_model_context(result: dict, core_name: str, runtime: str | None) -> dict:
109
+ """Add the model-facing channel alongside the human-facing one.
110
+
111
+ Dual channel on purpose: only `additionalContext` enters the context window,
112
+ so keeping `systemMessage` costs no model tokens and is what lets the human
113
+ see what their agent was just handed.
114
+
115
+ Never raises — a hook that cannot decorate its output must still emit it.
116
+ """
117
+ try:
118
+ event = _MODEL_CONTEXT_EVENTS.get(core_name)
119
+ if not event or runtime not in _MODEL_CONTEXT_RUNTIMES:
120
+ return result
121
+ if not isinstance(result, dict) or result.get("hookSpecificOutput"):
122
+ return result
123
+ text = result.get("systemMessage")
124
+ # Only promote text destined for a reader. A core returning control
125
+ # fields (decision/reason) is not describing something to inject.
126
+ if not isinstance(text, str) or not text.strip():
127
+ return result
128
+ return {
129
+ **result,
130
+ "hookSpecificOutput": {
131
+ "hookEventName": event,
132
+ "additionalContext": text,
133
+ },
134
+ }
135
+ except Exception: # noqa: BLE001 — decoration must never break a hook
136
+ return result
137
+
73
138
  # Cores that must run even while personal mode is ON, because they self-handle
74
139
  # bypass and own end-of-session cleanup: `stop` clears the personal marker
75
140
  # itself, and `session_end` must be free to decline comms without the dispatcher
@@ -191,6 +256,7 @@ def main(argv: list[str] | None = None) -> int:
191
256
  # the resolver seam carries the X-Firekeep-* attribution headers
192
257
  # (resolver._runtime_attribution). Best-effort — attribution must never be
193
258
  # able to break a hook.
259
+ runtime: str | None = None
194
260
  try:
195
261
  runtime = _parse_runtime(argv[1:])
196
262
  if runtime:
@@ -198,6 +264,16 @@ def main(argv: list[str] | None = None) -> int:
198
264
  except Exception as e: # noqa: BLE001
199
265
  hooklog.log_failure(core_name, f"runtime flag parse failed: {e!r}")
200
266
 
267
+ def _emit(result: dict) -> None:
268
+ """The ONE place a dict core's output reaches stdout.
269
+
270
+ Routed through _with_model_context so every message a core produces —
271
+ briefing, recall push, personal-mode notice, migration refusal — takes
272
+ the same channel decision. Four separate print sites is how one of them
273
+ silently keeps the old behaviour.
274
+ """
275
+ print(json.dumps(_with_model_context(result, core_name, runtime)))
276
+
201
277
  # `/personal` as chat text: intercepted BEFORE the bypass gate below — while
202
278
  # personal mode is ON the prompt core is short-circuited, so an in-core
203
279
  # intercept could never toggle OFF again.
@@ -206,7 +282,7 @@ def main(argv: list[str] | None = None) -> int:
206
282
  payload = _read_payload(core_name)
207
283
  personal_msg = _personal_text_command(payload)
208
284
  if personal_msg is not None:
209
- print(json.dumps({"systemMessage": personal_msg}))
285
+ _emit({"systemMessage": personal_msg})
210
286
  return 0
211
287
 
212
288
  # Personal / bypass mode: Firekeep goes dormant for this session. Checked LIVE
@@ -219,7 +295,7 @@ def main(argv: list[str] | None = None) -> int:
219
295
  if core_name not in _BYPASS_EXEMPT and resolver.is_bypassed():
220
296
  if core_name in _INT_CORES:
221
297
  return 0 # allow the edit; no policy/gateway call reaches the server
222
- print(json.dumps({"systemMessage": _BYPASS_MSG})) # session_start / prompt
298
+ _emit({"systemMessage": _BYPASS_MSG}) # session_start / prompt
223
299
  return 0
224
300
  except Exception as e: # noqa: BLE001 — a gate failure must never break the session
225
301
  hooklog.log_failure(core_name, f"bypass check failed: {e!r}")
@@ -238,11 +314,11 @@ def main(argv: list[str] | None = None) -> int:
238
314
 
239
315
  # dict core (session_start/stop/prompt).
240
316
  if result:
241
- print(json.dumps(result))
317
+ _emit(result)
242
318
  return 0
243
319
  except resolver.ConfigMigrationConflict as e:
244
320
  hooklog.log_failure(core_name, f"config migration refused: {e}")
245
- print(json.dumps({"systemMessage": str(e)}))
321
+ _emit({"systemMessage": str(e)})
246
322
  return 0
247
323
  except Exception as e: # noqa: BLE001 — the dispatcher itself must never raise.
248
324
  hooklog.log_failure(core_name, f"dispatcher crashed: {e!r}")
@@ -57,7 +57,13 @@ def _dedup_lines(items: list[str]) -> list[str]:
57
57
 
58
58
 
59
59
  def _task_line(task: dict) -> str:
60
- title = str(task.get("title") or task.get("description") or task.get("id") or "task")
60
+ # The title is server-supplied free text with no length contract, so it is
61
+ # trimmed to promptrecall's shared line budget. The id and creator stay
62
+ # OUTSIDE the trim: a title over 200 chars is already a description, and the
63
+ # id is how the agent asks relay_task_list for the rest. Pointer, not payload.
64
+ title = promptrecall.trim_line(
65
+ task.get("title") or task.get("description") or task.get("id") or "task"
66
+ )
61
67
  tid = str(task.get("id") or "")
62
68
  creator = str(task.get("creator") or task.get("created_by") or "")
63
69
  line = f"- {title}"
@@ -122,7 +128,8 @@ def run(payload: dict) -> dict:
122
128
  if ts <= last_seen or str(m.get("sender") or "") == agent:
123
129
  continue
124
130
  sender = str(m.get("sender") or "?")
125
- content = " ".join(str(m.get("content") or "").split())
131
+ # Same contract as _task_line: trim the body, keep the sender.
132
+ content = promptrecall.trim_line(m.get("content"))
126
133
  fresh.append(f"- {content} — {sender}")
127
134
  if newest > last_seen:
128
135
  state.write_scratch(seen_key, repr(newest))
@@ -181,15 +181,32 @@ def _source_id(source: dict) -> str:
181
181
  return "sha:" + hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:16]
182
182
 
183
183
 
184
+ def trim_line(text: object) -> str:
185
+ """Collapse whitespace and cap at MAX_LINE_CHARS with a visible ellipsis.
186
+
187
+ Exported because the prompt hook's two older neighbours — relay task titles
188
+ and channel message bodies — render server-supplied strings into the SAME
189
+ systemMessage and had no cap at all: a hostile fixture drove that hook to
190
+ 28,418 characters from one task and one message (measured 2026-08-21). They
191
+ share this function rather than re-declaring 200, so the budget stays one
192
+ number in one place.
193
+
194
+ Callers keep identifiers (task id, sender) OUTSIDE the trim: the point is to
195
+ hand over a pointer, not to lose the handle that fetches the full text.
196
+ """
197
+ if not isinstance(text, str):
198
+ return ""
199
+ collapsed = " ".join(text.split())
200
+ if len(collapsed) > MAX_LINE_CHARS:
201
+ return collapsed[:MAX_LINE_CHARS - 3] + "..."
202
+ return collapsed
203
+
204
+
184
205
  def _line(source: dict, score: float) -> str:
185
206
  """One collapsed, trimmed line. The score shown is the one that was actually
186
207
  thresholded (`_relevance`), never the normalized rank — a displayed number the
187
208
  filter did not use would misdescribe why the memory is on screen."""
188
- content = source.get("content")
189
- text = " ".join(content.split()) if isinstance(content, str) else ""
190
- if len(text) > MAX_LINE_CHARS:
191
- text = text[:MAX_LINE_CHARS - 3] + "..."
192
- return f"- {text} (score {score:.2f})"
209
+ return f"- {trim_line(source.get('content'))} (score {score:.2f})"
193
210
 
194
211
 
195
212
  def select(sources, *, seen, floor: float) -> list[tuple[str, str]]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firekeep-client
3
- Version: 1.4.2
3
+ Version: 1.5.2
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
@@ -80,6 +80,7 @@ tests/test_cli_dex.py
80
80
  tests/test_cli_docdex.py
81
81
  tests/test_cli_doctor.py
82
82
  tests/test_cli_doctor_generic.py
83
+ tests/test_cli_doctor_report.py
83
84
  tests/test_cli_entry.py
84
85
  tests/test_cli_generic_install.py
85
86
  tests/test_cli_install.py
@@ -93,6 +94,7 @@ tests/test_config_migration.py
93
94
  tests/test_connect.py
94
95
  tests/test_decision_board_html.py
95
96
  tests/test_decision_bypass.py
97
+ tests/test_decision_poll_ceiling.py
96
98
  tests/test_decision_registration.py
97
99
  tests/test_decision_server.py
98
100
  tests/test_dexes.py
@@ -103,12 +105,15 @@ tests/test_doctor_embeddings.py
103
105
  tests/test_doctor_no_server.py
104
106
  tests/test_e2e_bootstrap.py
105
107
  tests/test_gateway.py
108
+ tests/test_gateway_schema_slimming.py
106
109
  tests/test_gateway_toolset.py
107
110
  tests/test_generic_config.py
108
111
  tests/test_hooklog.py
109
112
  tests/test_import_boundary.py
110
113
  tests/test_init_self_enroll.py
111
114
  tests/test_init_stale_bundle.py
115
+ tests/test_install_skill_contract.py
116
+ tests/test_instruction_budget.py
112
117
  tests/test_join.py
113
118
  tests/test_join_config_write.py
114
119
  tests/test_joincode.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "firekeep-client"
7
- version = "1.4.2"
7
+ version = "1.5.2"
8
8
  description = "Client for Firekeep, the self-hosted operating layer for connected AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"