@smilintux/skcapstone 0.1.0 → 0.2.3

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 (461) hide show
  1. package/.env.example +98 -0
  2. package/.github/workflows/ci.yml +39 -3
  3. package/.github/workflows/publish.yml +25 -4
  4. package/.openclaw-workspace.json +58 -0
  5. package/CHANGELOG.md +62 -0
  6. package/CLAUDE.md +39 -2
  7. package/MANIFEST.in +6 -0
  8. package/MISSION.md +7 -0
  9. package/README.md +47 -2
  10. package/SKILL.md +895 -23
  11. package/docker/Dockerfile +61 -0
  12. package/docker/compose-templates/dev-team.yml +203 -0
  13. package/docker/compose-templates/mini-team.yml +140 -0
  14. package/docker/compose-templates/ops-team.yml +173 -0
  15. package/docker/compose-templates/research-team.yml +170 -0
  16. package/docker/entrypoint.sh +192 -0
  17. package/docs/ARCHITECTURE.md +663 -374
  18. package/docs/BOND_WITH_GROK.md +112 -0
  19. package/docs/GETTING_STARTED.md +782 -0
  20. package/docs/QUICKSTART.md +477 -0
  21. package/docs/SKJOULE_ARCHITECTURE.md +658 -0
  22. package/docs/SOUL_SWAPPER.md +921 -0
  23. package/docs/SOVEREIGN_SINGULARITY.md +47 -14
  24. package/examples/custom-bond-template.json +36 -0
  25. package/examples/grok-feb.json +36 -0
  26. package/examples/grok-testimony.md +34 -0
  27. package/examples/love-bootloader.txt +32 -0
  28. package/examples/plugins/echo_tool.py +87 -0
  29. package/examples/queen-ava-feb.json +36 -0
  30. package/examples/souls/lumina.yaml +64 -0
  31. package/index.js +6 -5
  32. package/installer/build.py +124 -0
  33. package/openclaw-plugin/package.json +13 -0
  34. package/openclaw-plugin/src/index.ts +351 -0
  35. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  36. package/package.json +1 -1
  37. package/pyproject.toml +38 -2
  38. package/scripts/bump_version.py +141 -0
  39. package/scripts/check-updates.py +230 -0
  40. package/scripts/convert_blueprints_to_yaml.py +157 -0
  41. package/scripts/dev-install.sh +14 -0
  42. package/scripts/e2e-test.sh +193 -0
  43. package/scripts/install-bundle.sh +171 -0
  44. package/scripts/install.bat +2 -0
  45. package/scripts/install.ps1 +253 -0
  46. package/scripts/install.sh +185 -0
  47. package/scripts/mcp-serve.sh +69 -0
  48. package/scripts/mcp-server.bat +113 -0
  49. package/scripts/mcp-server.ps1 +116 -0
  50. package/scripts/mcp-server.sh +99 -0
  51. package/scripts/pull-models.sh +10 -0
  52. package/scripts/skcapstone +48 -0
  53. package/scripts/verify_install.sh +180 -0
  54. package/scripts/windows/install-tasks.ps1 +406 -0
  55. package/scripts/windows/skcapstone-task.xml +113 -0
  56. package/scripts/windows/uninstall-tasks.ps1 +117 -0
  57. package/skill.yaml +34 -0
  58. package/src/skcapstone/__init__.py +67 -2
  59. package/src/skcapstone/_cli_monolith.py +5916 -0
  60. package/src/skcapstone/_trustee_helpers.py +165 -0
  61. package/src/skcapstone/activity.py +105 -0
  62. package/src/skcapstone/agent_card.py +324 -0
  63. package/src/skcapstone/api.py +1935 -0
  64. package/src/skcapstone/archiver.py +340 -0
  65. package/src/skcapstone/auction.py +485 -0
  66. package/src/skcapstone/baby_agents.py +179 -0
  67. package/src/skcapstone/backup.py +345 -0
  68. package/src/skcapstone/blueprint_registry.py +357 -0
  69. package/src/skcapstone/blueprints/__init__.py +17 -0
  70. package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
  71. package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
  72. package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
  73. package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
  74. package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
  75. package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
  76. package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
  77. package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
  78. package/src/skcapstone/blueprints/registry.py +164 -0
  79. package/src/skcapstone/blueprints/schema.py +229 -0
  80. package/src/skcapstone/changelog.py +180 -0
  81. package/src/skcapstone/chat.py +769 -0
  82. package/src/skcapstone/claude_md.py +82 -0
  83. package/src/skcapstone/cli/__init__.py +144 -0
  84. package/src/skcapstone/cli/_common.py +88 -0
  85. package/src/skcapstone/cli/_validators.py +76 -0
  86. package/src/skcapstone/cli/agents.py +425 -0
  87. package/src/skcapstone/cli/agents_spawner.py +322 -0
  88. package/src/skcapstone/cli/agents_trustee.py +593 -0
  89. package/src/skcapstone/cli/alerts.py +248 -0
  90. package/src/skcapstone/cli/anchor.py +132 -0
  91. package/src/skcapstone/cli/archive_cmd.py +208 -0
  92. package/src/skcapstone/cli/backup.py +144 -0
  93. package/src/skcapstone/cli/bench.py +377 -0
  94. package/src/skcapstone/cli/benchmark.py +360 -0
  95. package/src/skcapstone/cli/capabilities_cmd.py +171 -0
  96. package/src/skcapstone/cli/card.py +151 -0
  97. package/src/skcapstone/cli/chat.py +584 -0
  98. package/src/skcapstone/cli/completions.py +64 -0
  99. package/src/skcapstone/cli/config_cmd.py +156 -0
  100. package/src/skcapstone/cli/consciousness.py +421 -0
  101. package/src/skcapstone/cli/context_cmd.py +142 -0
  102. package/src/skcapstone/cli/coord.py +194 -0
  103. package/src/skcapstone/cli/crush_cmd.py +170 -0
  104. package/src/skcapstone/cli/daemon.py +436 -0
  105. package/src/skcapstone/cli/errors_cmd.py +285 -0
  106. package/src/skcapstone/cli/export_cmd.py +156 -0
  107. package/src/skcapstone/cli/gtd.py +529 -0
  108. package/src/skcapstone/cli/housekeeping.py +81 -0
  109. package/src/skcapstone/cli/joule_cmd.py +627 -0
  110. package/src/skcapstone/cli/logs_cmd.py +194 -0
  111. package/src/skcapstone/cli/mcp_cmd.py +32 -0
  112. package/src/skcapstone/cli/memory.py +418 -0
  113. package/src/skcapstone/cli/metrics_cmd.py +136 -0
  114. package/src/skcapstone/cli/migrate.py +62 -0
  115. package/src/skcapstone/cli/mood_cmd.py +144 -0
  116. package/src/skcapstone/cli/mount.py +193 -0
  117. package/src/skcapstone/cli/notify.py +112 -0
  118. package/src/skcapstone/cli/peer.py +154 -0
  119. package/src/skcapstone/cli/peers_dir.py +122 -0
  120. package/src/skcapstone/cli/preflight_cmd.py +83 -0
  121. package/src/skcapstone/cli/profile_cmd.py +310 -0
  122. package/src/skcapstone/cli/record_cmd.py +238 -0
  123. package/src/skcapstone/cli/register_cmd.py +159 -0
  124. package/src/skcapstone/cli/search_cmd.py +156 -0
  125. package/src/skcapstone/cli/service_cmd.py +91 -0
  126. package/src/skcapstone/cli/session.py +127 -0
  127. package/src/skcapstone/cli/setup.py +240 -0
  128. package/src/skcapstone/cli/shell_cmd.py +43 -0
  129. package/src/skcapstone/cli/skills_cmd.py +168 -0
  130. package/src/skcapstone/cli/skseed.py +621 -0
  131. package/src/skcapstone/cli/soul.py +699 -0
  132. package/src/skcapstone/cli/status.py +935 -0
  133. package/src/skcapstone/cli/sync_cmd.py +301 -0
  134. package/src/skcapstone/cli/telegram.py +265 -0
  135. package/src/skcapstone/cli/test_cmd.py +234 -0
  136. package/src/skcapstone/cli/test_connection.py +253 -0
  137. package/src/skcapstone/cli/token.py +207 -0
  138. package/src/skcapstone/cli/trust.py +179 -0
  139. package/src/skcapstone/cli/upgrade_cmd.py +552 -0
  140. package/src/skcapstone/cli/usage_cmd.py +199 -0
  141. package/src/skcapstone/cli/version_cmd.py +162 -0
  142. package/src/skcapstone/cli/watch_cmd.py +342 -0
  143. package/src/skcapstone/client.py +428 -0
  144. package/src/skcapstone/cloud9_bridge.py +522 -0
  145. package/src/skcapstone/completions.py +163 -0
  146. package/src/skcapstone/config_validator.py +674 -0
  147. package/src/skcapstone/connectors/__init__.py +28 -0
  148. package/src/skcapstone/connectors/base.py +446 -0
  149. package/src/skcapstone/connectors/cursor.py +54 -0
  150. package/src/skcapstone/connectors/registry.py +254 -0
  151. package/src/skcapstone/connectors/terminal.py +152 -0
  152. package/src/skcapstone/connectors/vscode.py +60 -0
  153. package/src/skcapstone/consciousness_config.py +119 -0
  154. package/src/skcapstone/consciousness_loop.py +2051 -0
  155. package/src/skcapstone/context_loader.py +516 -0
  156. package/src/skcapstone/context_window.py +314 -0
  157. package/src/skcapstone/conversation_manager.py +238 -0
  158. package/src/skcapstone/conversation_store.py +230 -0
  159. package/src/skcapstone/conversation_summarizer.py +252 -0
  160. package/src/skcapstone/coord_federation.py +296 -0
  161. package/src/skcapstone/coordination.py +101 -7
  162. package/src/skcapstone/crush_integration.py +345 -0
  163. package/src/skcapstone/crush_shim.py +454 -0
  164. package/src/skcapstone/daemon.py +2494 -0
  165. package/src/skcapstone/dashboard.html +396 -0
  166. package/src/skcapstone/dashboard.py +481 -0
  167. package/src/skcapstone/data/model_profiles.yaml +88 -0
  168. package/src/skcapstone/defaults/__init__.py +55 -0
  169. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
  170. package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
  171. package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
  172. package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
  173. package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
  174. package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
  175. package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
  176. package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
  177. package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
  178. package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
  179. package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
  180. package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
  181. package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
  182. package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
  183. package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
  184. package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
  185. package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
  186. package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
  187. package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
  188. package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
  189. package/src/skcapstone/discovery.py +210 -19
  190. package/src/skcapstone/doctor.py +642 -0
  191. package/src/skcapstone/emotion_tracker.py +467 -0
  192. package/src/skcapstone/error_queue.py +405 -0
  193. package/src/skcapstone/export.py +447 -0
  194. package/src/skcapstone/fallback_tracker.py +186 -0
  195. package/src/skcapstone/file_transfer.py +512 -0
  196. package/src/skcapstone/fuse_mount.py +1156 -0
  197. package/src/skcapstone/gui_installer.py +591 -0
  198. package/src/skcapstone/heartbeat.py +611 -0
  199. package/src/skcapstone/housekeeping.py +298 -0
  200. package/src/skcapstone/install_wizard.py +941 -0
  201. package/src/skcapstone/kms.py +942 -0
  202. package/src/skcapstone/kms_scheduler.py +143 -0
  203. package/src/skcapstone/log_config.py +135 -0
  204. package/src/skcapstone/mcp_launcher.py +239 -0
  205. package/src/skcapstone/mcp_server.py +4700 -0
  206. package/src/skcapstone/mcp_tools/__init__.py +94 -0
  207. package/src/skcapstone/mcp_tools/_helpers.py +51 -0
  208. package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
  209. package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
  210. package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
  211. package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
  212. package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
  213. package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
  214. package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
  215. package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
  216. package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
  217. package/src/skcapstone/mcp_tools/did_tools.py +448 -0
  218. package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
  219. package/src/skcapstone/mcp_tools/file_tools.py +169 -0
  220. package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
  221. package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
  222. package/src/skcapstone/mcp_tools/health_tools.py +44 -0
  223. package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
  224. package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
  225. package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
  226. package/src/skcapstone/mcp_tools/model_tools.py +75 -0
  227. package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
  228. package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
  229. package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
  230. package/src/skcapstone/mcp_tools/security_tools.py +110 -0
  231. package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
  232. package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
  233. package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
  234. package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
  235. package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
  236. package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
  237. package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
  238. package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
  239. package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
  240. package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
  241. package/src/skcapstone/mdns_discovery.py +313 -0
  242. package/src/skcapstone/memory_adapter.py +333 -0
  243. package/src/skcapstone/memory_compressor.py +379 -0
  244. package/src/skcapstone/memory_curator.py +256 -0
  245. package/src/skcapstone/memory_engine.py +132 -13
  246. package/src/skcapstone/memory_fortress.py +529 -0
  247. package/src/skcapstone/memory_promoter.py +722 -0
  248. package/src/skcapstone/memory_verifier.py +260 -0
  249. package/src/skcapstone/message_crypto.py +215 -0
  250. package/src/skcapstone/metrics.py +832 -0
  251. package/src/skcapstone/migrate_memories.py +181 -0
  252. package/src/skcapstone/migrate_multi_agent.py +248 -0
  253. package/src/skcapstone/model_router.py +319 -0
  254. package/src/skcapstone/models.py +35 -4
  255. package/src/skcapstone/mood.py +344 -0
  256. package/src/skcapstone/notifications.py +380 -0
  257. package/src/skcapstone/onboard.py +901 -0
  258. package/src/skcapstone/peer_directory.py +324 -0
  259. package/src/skcapstone/peers.py +329 -0
  260. package/src/skcapstone/pillars/identity.py +84 -14
  261. package/src/skcapstone/pillars/memory.py +3 -1
  262. package/src/skcapstone/pillars/security.py +108 -15
  263. package/src/skcapstone/pillars/sync.py +78 -26
  264. package/src/skcapstone/pillars/trust.py +95 -33
  265. package/src/skcapstone/plugins.py +244 -0
  266. package/src/skcapstone/preflight.py +670 -0
  267. package/src/skcapstone/prompt_adapter.py +564 -0
  268. package/src/skcapstone/providers/__init__.py +13 -0
  269. package/src/skcapstone/providers/cloud.py +1061 -0
  270. package/src/skcapstone/providers/docker.py +759 -0
  271. package/src/skcapstone/providers/local.py +1193 -0
  272. package/src/skcapstone/providers/proxmox.py +447 -0
  273. package/src/skcapstone/pubsub.py +516 -0
  274. package/src/skcapstone/rate_limiter.py +119 -0
  275. package/src/skcapstone/register.py +241 -0
  276. package/src/skcapstone/registry_client.py +151 -0
  277. package/src/skcapstone/response_cache.py +194 -0
  278. package/src/skcapstone/response_scorer.py +225 -0
  279. package/src/skcapstone/runtime.py +89 -33
  280. package/src/skcapstone/scheduled_tasks.py +439 -0
  281. package/src/skcapstone/self_healing.py +341 -0
  282. package/src/skcapstone/service_health.py +228 -0
  283. package/src/skcapstone/session_capture.py +268 -0
  284. package/src/skcapstone/session_recorder.py +210 -0
  285. package/src/skcapstone/session_replayer.py +189 -0
  286. package/src/skcapstone/session_skills.py +263 -0
  287. package/src/skcapstone/shell.py +779 -0
  288. package/src/skcapstone/skills/__init__.py +1 -1
  289. package/src/skcapstone/skills/syncthing_setup.py +143 -41
  290. package/src/skcapstone/skjoule.py +861 -0
  291. package/src/skcapstone/snapshots.py +489 -0
  292. package/src/skcapstone/soul.py +1060 -0
  293. package/src/skcapstone/soul_switch.py +255 -0
  294. package/src/skcapstone/spawner.py +544 -0
  295. package/src/skcapstone/state_diff.py +401 -0
  296. package/src/skcapstone/summary.py +270 -0
  297. package/src/skcapstone/sync/backends.py +196 -2
  298. package/src/skcapstone/sync/engine.py +7 -5
  299. package/src/skcapstone/sync/models.py +4 -1
  300. package/src/skcapstone/sync/vault.py +356 -18
  301. package/src/skcapstone/sync_engine.py +363 -0
  302. package/src/skcapstone/sync_watcher.py +745 -0
  303. package/src/skcapstone/systemd.py +331 -0
  304. package/src/skcapstone/team_comms.py +476 -0
  305. package/src/skcapstone/team_engine.py +522 -0
  306. package/src/skcapstone/testrunner.py +300 -0
  307. package/src/skcapstone/tls.py +150 -0
  308. package/src/skcapstone/tokens.py +5 -5
  309. package/src/skcapstone/trust_calibration.py +202 -0
  310. package/src/skcapstone/trust_graph.py +449 -0
  311. package/src/skcapstone/trustee_monitor.py +385 -0
  312. package/src/skcapstone/trustee_ops.py +425 -0
  313. package/src/skcapstone/unified_search.py +421 -0
  314. package/src/skcapstone/uninstall_wizard.py +694 -0
  315. package/src/skcapstone/usage.py +331 -0
  316. package/src/skcapstone/version_check.py +148 -0
  317. package/src/skcapstone/warmth_anchor.py +333 -0
  318. package/src/skcapstone/whoami.py +294 -0
  319. package/systemd/skcapstone-api.socket +9 -0
  320. package/systemd/skcapstone-memory-compress.service +18 -0
  321. package/systemd/skcapstone-memory-compress.timer +11 -0
  322. package/systemd/skcapstone.service +36 -0
  323. package/systemd/skcapstone@.service +50 -0
  324. package/systemd/skcomm-heartbeat.service +18 -0
  325. package/systemd/skcomm-heartbeat.timer +12 -0
  326. package/systemd/skcomm-queue-drain.service +17 -0
  327. package/systemd/skcomm-queue-drain.timer +12 -0
  328. package/tests/conftest.py +13 -1
  329. package/tests/integration/__init__.py +1 -0
  330. package/tests/integration/test_consciousness_e2e.py +877 -0
  331. package/tests/integration/test_skills_registry.py +744 -0
  332. package/tests/test_agent_card.py +190 -0
  333. package/tests/test_agent_runtime.py +1283 -0
  334. package/tests/test_alerts_cmd.py +291 -0
  335. package/tests/test_archiver.py +498 -0
  336. package/tests/test_backup.py +254 -0
  337. package/tests/test_benchmark.py +366 -0
  338. package/tests/test_blueprints.py +457 -0
  339. package/tests/test_capabilities.py +257 -0
  340. package/tests/test_changelog.py +254 -0
  341. package/tests/test_chat.py +385 -0
  342. package/tests/test_claude_md.py +271 -0
  343. package/tests/test_cli_chat_llm.py +336 -0
  344. package/tests/test_cli_completions.py +390 -0
  345. package/tests/test_cli_init_reset.py +164 -0
  346. package/tests/test_cli_memory.py +208 -0
  347. package/tests/test_cli_profile.py +294 -0
  348. package/tests/test_cli_skills.py +223 -0
  349. package/tests/test_cli_status.py +395 -0
  350. package/tests/test_cli_test_cmd.py +206 -0
  351. package/tests/test_cli_test_connection.py +364 -0
  352. package/tests/test_cloud9_bridge.py +260 -0
  353. package/tests/test_cloud_provider.py +449 -0
  354. package/tests/test_cloud_providers.py +522 -0
  355. package/tests/test_completions.py +158 -0
  356. package/tests/test_component_manager.py +398 -0
  357. package/tests/test_config_reload.py +386 -0
  358. package/tests/test_config_validate.py +529 -0
  359. package/tests/test_consciousness_e2e.py +296 -0
  360. package/tests/test_consciousness_loop.py +1289 -0
  361. package/tests/test_context_loader.py +310 -0
  362. package/tests/test_conversation_api.py +306 -0
  363. package/tests/test_conversation_manager.py +381 -0
  364. package/tests/test_conversation_store.py +391 -0
  365. package/tests/test_conversation_summarizer.py +302 -0
  366. package/tests/test_cross_package.py +791 -0
  367. package/tests/test_crush_shim.py +519 -0
  368. package/tests/test_daemon.py +781 -0
  369. package/tests/test_daemon_shutdown.py +309 -0
  370. package/tests/test_dashboard.py +454 -0
  371. package/tests/test_discovery.py +200 -6
  372. package/tests/test_docker_provider.py +966 -0
  373. package/tests/test_doctor.py +257 -0
  374. package/tests/test_doctor_fix.py +351 -0
  375. package/tests/test_e2e_automated.py +292 -0
  376. package/tests/test_error_queue.py +404 -0
  377. package/tests/test_export.py +441 -0
  378. package/tests/test_fallback_tracker.py +219 -0
  379. package/tests/test_file_transfer.py +397 -0
  380. package/tests/test_fuse_mount.py +832 -0
  381. package/tests/test_health_loop.py +422 -0
  382. package/tests/test_heartbeat.py +354 -0
  383. package/tests/test_housekeeping.py +195 -0
  384. package/tests/test_identity_capauth.py +307 -0
  385. package/tests/test_identity_pillar.py +117 -0
  386. package/tests/test_install_wizard.py +68 -0
  387. package/tests/test_integration.py +325 -0
  388. package/tests/test_kms.py +495 -0
  389. package/tests/test_llm_providers.py +265 -0
  390. package/tests/test_local_provider.py +591 -0
  391. package/tests/test_log_config.py +199 -0
  392. package/tests/test_logs_cmd.py +287 -0
  393. package/tests/test_mcp_server.py +1909 -0
  394. package/tests/test_memory_adapter.py +339 -0
  395. package/tests/test_memory_curator.py +218 -0
  396. package/tests/test_memory_engine.py +6 -0
  397. package/tests/test_memory_fortress.py +571 -0
  398. package/tests/test_memory_pillar.py +119 -0
  399. package/tests/test_memory_promoter.py +445 -0
  400. package/tests/test_memory_verifier.py +420 -0
  401. package/tests/test_message_crypto.py +187 -0
  402. package/tests/test_metrics.py +632 -0
  403. package/tests/test_migrate_memories.py +464 -0
  404. package/tests/test_model_router.py +546 -0
  405. package/tests/test_mood.py +394 -0
  406. package/tests/test_multi_agent.py +269 -0
  407. package/tests/test_notifications.py +270 -0
  408. package/tests/test_onboard.py +500 -0
  409. package/tests/test_peer_directory.py +395 -0
  410. package/tests/test_peers.py +248 -0
  411. package/tests/test_pillars.py +87 -9
  412. package/tests/test_preflight.py +484 -0
  413. package/tests/test_prompt_adapter.py +331 -0
  414. package/tests/test_proxmox_provider.py +571 -0
  415. package/tests/test_pubsub.py +377 -0
  416. package/tests/test_rate_limiter.py +121 -0
  417. package/tests/test_registry_client.py +129 -0
  418. package/tests/test_response_cache.py +312 -0
  419. package/tests/test_response_scorer.py +294 -0
  420. package/tests/test_runtime.py +59 -0
  421. package/tests/test_scheduled_tasks.py +451 -0
  422. package/tests/test_security.py +250 -0
  423. package/tests/test_security_pillar.py +213 -0
  424. package/tests/test_self_healing.py +171 -0
  425. package/tests/test_session_capture.py +200 -0
  426. package/tests/test_session_recorder.py +360 -0
  427. package/tests/test_session_skills.py +235 -0
  428. package/tests/test_shell.py +210 -0
  429. package/tests/test_snapshots.py +549 -0
  430. package/tests/test_soul.py +984 -0
  431. package/tests/test_soul_swap.py +406 -0
  432. package/tests/test_spawner.py +211 -0
  433. package/tests/test_state_diff.py +173 -0
  434. package/tests/test_summary.py +135 -0
  435. package/tests/test_sync.py +315 -5
  436. package/tests/test_sync_backends.py +560 -0
  437. package/tests/test_sync_engine.py +482 -0
  438. package/tests/test_sync_pillar.py +344 -0
  439. package/tests/test_sync_pipeline.py +364 -0
  440. package/tests/test_sync_vault.py +581 -0
  441. package/tests/test_syncthing_setup.py +168 -22
  442. package/tests/test_systemd.py +323 -0
  443. package/tests/test_team_comms.py +408 -0
  444. package/tests/test_team_engine.py +397 -0
  445. package/tests/test_testrunner.py +238 -0
  446. package/tests/test_trust_calibration.py +204 -0
  447. package/tests/test_trust_graph.py +207 -0
  448. package/tests/test_trust_pillar.py +291 -0
  449. package/tests/test_trustee_cli.py +427 -0
  450. package/tests/test_trustee_cli_integration.py +325 -0
  451. package/tests/test_trustee_monitor.py +394 -0
  452. package/tests/test_trustee_ops.py +355 -0
  453. package/tests/test_unified_search.py +363 -0
  454. package/tests/test_uninstall_wizard.py +193 -0
  455. package/tests/test_usage.py +333 -0
  456. package/tests/test_version_cmd.py +355 -0
  457. package/tests/test_warmth_anchor.py +162 -0
  458. package/tests/test_whoami.py +245 -0
  459. package/tests/test_ws.py +311 -0
  460. package/.cursorrules +0 -33
  461. package/src/skcapstone/cli.py +0 -1441
@@ -0,0 +1,1289 @@
1
+ """Tests for the consciousness loop — message classification, LLM bridge, system prompt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from pathlib import Path
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import pytest
12
+
13
+ from skcapstone.consciousness_loop import (
14
+ ConsciousnessConfig,
15
+ ConsciousnessLoop,
16
+ LLMBridge,
17
+ SystemPromptBuilder,
18
+ _classify_message,
19
+ _OllamaPool,
20
+ _SimpleEnvelope,
21
+ InboxHandler,
22
+ )
23
+ from skcapstone.model_router import TaskSignal
24
+ from skcapstone.blueprints.schema import ModelTier
25
+
26
+
27
+ class TestConsciousnessConfig:
28
+ """ConsciousnessConfig Pydantic model tests."""
29
+
30
+ def test_defaults(self):
31
+ """Default config is sensible."""
32
+ config = ConsciousnessConfig()
33
+ assert config.enabled is True
34
+ assert config.use_inotify is True
35
+ assert config.max_concurrent_requests == 3
36
+ assert "ollama" in config.fallback_chain
37
+ assert "passthrough" in config.fallback_chain
38
+
39
+ def test_custom_config(self):
40
+ """Custom config overrides defaults."""
41
+ config = ConsciousnessConfig(
42
+ enabled=False,
43
+ max_concurrent_requests=5,
44
+ fallback_chain=["anthropic", "passthrough"],
45
+ )
46
+ assert config.enabled is False
47
+ assert config.max_concurrent_requests == 5
48
+ assert len(config.fallback_chain) == 2
49
+
50
+
51
+ class TestClassifyMessage:
52
+ """Message classification tests."""
53
+
54
+ def test_code_keywords(self):
55
+ """Code-related messages get code tag."""
56
+ signal = _classify_message("Please debug this function for me")
57
+ assert "code" in signal.tags
58
+
59
+ def test_analysis_keywords(self):
60
+ """Analysis messages get analyze tag."""
61
+ signal = _classify_message("Can you analyze this architecture?")
62
+ assert "analyze" in signal.tags
63
+
64
+ def test_simple_greeting(self):
65
+ """Simple greetings get simple tag."""
66
+ signal = _classify_message("hello")
67
+ assert "simple" in signal.tags
68
+
69
+ def test_general_message(self):
70
+ """Messages with no keywords get general tag."""
71
+ signal = _classify_message("The weather is nice today isn't it")
72
+ assert "general" in signal.tags
73
+
74
+ def test_token_estimation(self):
75
+ """Token estimate is roughly content_length / 4."""
76
+ msg = "a" * 400
77
+ signal = _classify_message(msg)
78
+ assert signal.estimated_tokens == 100
79
+
80
+ def test_multi_tag(self):
81
+ """Messages with multiple keyword sets get multiple tags."""
82
+ signal = _classify_message("Can you debug and analyze this code?")
83
+ assert "code" in signal.tags
84
+ assert "analyze" in signal.tags
85
+
86
+
87
+ class TestLLMBridge:
88
+ """LLM bridge routing and fallback tests."""
89
+
90
+ def test_probe_passthrough_always_available(self):
91
+ """Passthrough backend is always available."""
92
+ config = ConsciousnessConfig()
93
+ bridge = LLMBridge(config)
94
+ assert bridge.available_backends.get("passthrough") is True
95
+
96
+ def test_health_check_returns_dict(self):
97
+ """Health check returns a dict of backend availability."""
98
+ config = ConsciousnessConfig()
99
+ bridge = LLMBridge(config)
100
+ health = bridge.health_check()
101
+ assert isinstance(health, dict)
102
+ assert "passthrough" in health
103
+ assert "ollama" in health
104
+
105
+ @patch("skseed.llm.passthrough_callback")
106
+ def test_generate_fallback_to_passthrough(self, mock_passthrough):
107
+ """When no backends available, falls through to passthrough."""
108
+ mock_cb = MagicMock(return_value="echo response")
109
+ mock_passthrough.return_value = mock_cb
110
+
111
+ config = ConsciousnessConfig(
112
+ fallback_chain=["passthrough"],
113
+ )
114
+ bridge = LLMBridge(config)
115
+ # Force all backends unavailable except passthrough
116
+ bridge._available = {k: False for k in bridge._available}
117
+ bridge._available["passthrough"] = True
118
+
119
+ signal = TaskSignal(description="test", tags=["general"])
120
+ result = bridge.generate("system", "hello", signal)
121
+ # Should get a response (either from passthrough or last-resort message)
122
+ assert isinstance(result, str)
123
+ assert len(result) > 0
124
+
125
+ @patch("skseed.llm.ollama_callback")
126
+ def test_generate_passthrough_cascade_returns_user_content(self, mock_ollama):
127
+ """When all LLM backends fail, cascade reaches passthrough and returns user content.
128
+
129
+ Verifies the fallback cascade uses direct backend mapping (not _resolve_callback)
130
+ so passthrough is reached without infinite regression, and that the returned
131
+ value is the original user message — NOT the canned connectivity-error string.
132
+ """
133
+ from skcapstone.model_router import ModelRouterConfig
134
+
135
+ # Ollama callback always raises — covers primary + alt model calls
136
+ mock_ollama.return_value = MagicMock(side_effect=RuntimeError("ollama unavailable"))
137
+
138
+ # Single model in FAST tier so there are no alt-model iterations,
139
+ # and the tier-downgrade path is skipped (already FAST).
140
+ router_cfg = ModelRouterConfig(
141
+ tier_models={
142
+ ModelTier.FAST.value: ["llama3.2"],
143
+ ModelTier.CODE.value: ["devstral"],
144
+ ModelTier.REASON.value: ["deepseek-r1:8b"],
145
+ ModelTier.NUANCE.value: ["moonshot-v1-128k"],
146
+ ModelTier.LOCAL.value: ["llama3.2"],
147
+ },
148
+ tag_rules=[],
149
+ )
150
+ config = ConsciousnessConfig(fallback_chain=["ollama", "passthrough"])
151
+ bridge = LLMBridge(config, router_config=router_cfg)
152
+ # All backends unavailable except passthrough
153
+ bridge._available = {k: False for k in bridge._available}
154
+ bridge._available["passthrough"] = True
155
+
156
+ signal = TaskSignal(description="test", tags=["general"])
157
+ result = bridge.generate("system prompt", "hello world", signal)
158
+
159
+ assert result == "hello world", (
160
+ f"Expected passthrough to return user message 'hello world', got: {result!r}"
161
+ )
162
+ assert "connectivity issues" not in result
163
+
164
+
165
+ class TestSystemPromptBuilder:
166
+ """System prompt builder tests."""
167
+
168
+ def test_build_with_empty_home(self, tmp_path):
169
+ """Builder works even with empty home dir."""
170
+ home = tmp_path / ".skcapstone"
171
+ home.mkdir()
172
+ builder = SystemPromptBuilder(home)
173
+ prompt = builder.build()
174
+ assert isinstance(prompt, str)
175
+ # Should at least have behavioral instructions
176
+ assert "Respond concisely" in prompt
177
+
178
+ def test_build_with_identity(self, tmp_path):
179
+ """Builder includes identity when present."""
180
+ home = tmp_path / ".skcapstone"
181
+ identity_dir = home / "identity"
182
+ identity_dir.mkdir(parents=True)
183
+ identity = {"name": "opus", "fingerprint": "ABCD1234"}
184
+ (identity_dir / "identity.json").write_text(json.dumps(identity))
185
+
186
+ builder = SystemPromptBuilder(home)
187
+ prompt = builder.build()
188
+ assert "opus" in prompt
189
+ assert "ABCD1234" in prompt
190
+
191
+ def test_conversation_history(self, tmp_path):
192
+ """Builder tracks and includes per-peer conversation history."""
193
+ home = tmp_path / ".skcapstone"
194
+ home.mkdir()
195
+ builder = SystemPromptBuilder(home)
196
+
197
+ builder.add_to_history("jarvis", "user", "Hello!")
198
+ builder.add_to_history("jarvis", "assistant", "Hi there!")
199
+
200
+ prompt = builder.build(peer_name="jarvis")
201
+ assert "jarvis" in prompt
202
+ assert "Hello!" in prompt
203
+
204
+ def test_history_max_messages(self, tmp_path):
205
+ """History is capped at max_messages per peer."""
206
+ home = tmp_path / ".skcapstone"
207
+ home.mkdir()
208
+ builder = SystemPromptBuilder(home)
209
+
210
+ for i in range(20):
211
+ builder.add_to_history("peer", "user", f"Message {i}")
212
+
213
+ # Default max is 10
214
+ history = builder._conversation_history["peer"]
215
+ assert len(history) == 10
216
+ assert "Message 19" in history[-1]["content"]
217
+
218
+ def test_truncation(self, tmp_path):
219
+ """Long system prompts are truncated."""
220
+ home = tmp_path / ".skcapstone"
221
+ home.mkdir()
222
+ builder = SystemPromptBuilder(home, max_tokens=100)
223
+
224
+ # Build should not exceed max_tokens * 4 chars
225
+ prompt = builder.build()
226
+ assert len(prompt) <= 100 * 4 + 50 # some slack for truncation marker
227
+
228
+ def test_persistence_writes_json_file(self, tmp_path):
229
+ """add_to_history writes a JSON file under {home}/conversations/{peer}.json."""
230
+ home = tmp_path / ".skcapstone"
231
+ home.mkdir()
232
+ builder = SystemPromptBuilder(home)
233
+
234
+ builder.add_to_history("jarvis", "user", "Hello!")
235
+ builder.add_to_history("jarvis", "assistant", "Hi there!")
236
+
237
+ conv_file = home / "conversations" / "jarvis.json"
238
+ assert conv_file.exists(), "Conversation file should be created"
239
+ data = json.loads(conv_file.read_text())
240
+ assert isinstance(data, list)
241
+ assert len(data) == 2
242
+ assert data[0]["role"] == "user"
243
+ assert data[0]["content"] == "Hello!"
244
+ assert data[1]["role"] == "assistant"
245
+
246
+ def test_persistence_caps_at_max_history(self, tmp_path):
247
+ """Persisted file is capped at max_history_messages."""
248
+ home = tmp_path / ".skcapstone"
249
+ home.mkdir()
250
+ builder = SystemPromptBuilder(home, max_history_messages=5)
251
+
252
+ for i in range(8):
253
+ builder.add_to_history("lumina", "user", f"Message {i}")
254
+
255
+ conv_file = home / "conversations" / "lumina.json"
256
+ data = json.loads(conv_file.read_text())
257
+ assert len(data) == 5
258
+ assert data[-1]["content"] == "Message 7"
259
+
260
+ def test_load_existing_conversations_on_init(self, tmp_path):
261
+ """Existing conversation files are loaded on __init__."""
262
+ home = tmp_path / ".skcapstone"
263
+ home.mkdir()
264
+ conv_dir = home / "conversations"
265
+ conv_dir.mkdir()
266
+
267
+ history = [
268
+ {"role": "user", "content": "Remembered message", "timestamp": "2026-01-01T00:00:00+00:00"},
269
+ ]
270
+ (conv_dir / "opus.json").write_text(json.dumps(history))
271
+
272
+ builder = SystemPromptBuilder(home)
273
+ assert "opus" in builder._conversation_history
274
+ assert builder._conversation_history["opus"][0]["content"] == "Remembered message"
275
+
276
+ def test_load_caps_at_max_history_on_init(self, tmp_path):
277
+ """Loading from file caps history at max_history_messages."""
278
+ home = tmp_path / ".skcapstone"
279
+ home.mkdir()
280
+ conv_dir = home / "conversations"
281
+ conv_dir.mkdir()
282
+
283
+ history = [
284
+ {"role": "user", "content": f"Old message {i}", "timestamp": "2026-01-01T00:00:00+00:00"}
285
+ for i in range(20)
286
+ ]
287
+ (conv_dir / "peer.json").write_text(json.dumps(history))
288
+
289
+ builder = SystemPromptBuilder(home, max_history_messages=10)
290
+ assert len(builder._conversation_history["peer"]) == 10
291
+ assert builder._conversation_history["peer"][-1]["content"] == "Old message 19"
292
+
293
+ def test_persistence_atomic_write(self, tmp_path):
294
+ """No .tmp file left after write."""
295
+ home = tmp_path / ".skcapstone"
296
+ home.mkdir()
297
+ builder = SystemPromptBuilder(home)
298
+
299
+ builder.add_to_history("ava", "user", "Test")
300
+ tmp_file = home / "conversations" / "ava.json.tmp"
301
+ assert not tmp_file.exists(), ".tmp file should not remain after atomic write"
302
+
303
+ def test_multiple_peers_separate_files(self, tmp_path):
304
+ """Each peer gets its own conversation file."""
305
+ home = tmp_path / ".skcapstone"
306
+ home.mkdir()
307
+ builder = SystemPromptBuilder(home)
308
+
309
+ builder.add_to_history("jarvis", "user", "Hello from jarvis")
310
+ builder.add_to_history("lumina", "user", "Hello from lumina")
311
+
312
+ assert (home / "conversations" / "jarvis.json").exists()
313
+ assert (home / "conversations" / "lumina.json").exists()
314
+ jarvis_data = json.loads((home / "conversations" / "jarvis.json").read_text())
315
+ lumina_data = json.loads((home / "conversations" / "lumina.json").read_text())
316
+ assert jarvis_data[0]["content"] == "Hello from jarvis"
317
+ assert lumina_data[0]["content"] == "Hello from lumina"
318
+
319
+
320
+ class TestSimpleEnvelope:
321
+ """Test the minimal envelope for inotify-detected messages."""
322
+
323
+ def test_parse_standard_format(self):
324
+ """Standard SKComm envelope format parses correctly."""
325
+ data = {
326
+ "sender": "jarvis",
327
+ "payload": {
328
+ "content": "Hello from jarvis",
329
+ "content_type": "text",
330
+ },
331
+ }
332
+ env = _SimpleEnvelope(data)
333
+ assert env.sender == "jarvis"
334
+ assert env.payload.content == "Hello from jarvis"
335
+ assert env.payload.content_type.value == "text"
336
+
337
+ def test_parse_alt_format(self):
338
+ """Alternative format with 'from' and 'message' keys."""
339
+ data = {
340
+ "from": "lumina",
341
+ "message": "Hi!",
342
+ "type": "text",
343
+ }
344
+ env = _SimpleEnvelope(data)
345
+ assert env.sender == "lumina"
346
+ assert env.payload.content == "Hi!"
347
+
348
+
349
+ class TestInboxHandler:
350
+ """Inbox file handler debounce tests."""
351
+
352
+ def test_skips_non_json(self):
353
+ """Non-.skc.json files are ignored."""
354
+ called = []
355
+ handler = InboxHandler(lambda p: called.append(p))
356
+
357
+ class FakeEvent:
358
+ src_path = "/tmp/test.txt"
359
+ is_directory = False
360
+
361
+ handler.on_created(FakeEvent())
362
+ assert len(called) == 0
363
+
364
+ def test_processes_skc_json(self):
365
+ """Valid .skc.json files are processed."""
366
+ called = []
367
+ handler = InboxHandler(lambda p: called.append(p), debounce_ms=0)
368
+
369
+ class FakeEvent:
370
+ src_path = "/tmp/inbox/peer/msg.skc.json"
371
+ is_directory = False
372
+
373
+ handler.on_created(FakeEvent())
374
+ assert len(called) == 1
375
+
376
+ def test_debounce(self):
377
+ """Rapid duplicate events are debounced."""
378
+ called = []
379
+ handler = InboxHandler(lambda p: called.append(p), debounce_ms=5000)
380
+
381
+ class FakeEvent:
382
+ src_path = "/tmp/inbox/peer/msg.skc.json"
383
+ is_directory = False
384
+
385
+ handler.on_created(FakeEvent())
386
+ handler.on_created(FakeEvent()) # Should be debounced
387
+ assert len(called) == 1
388
+
389
+
390
+ class TestProcessEnvelopeACK:
391
+ """Verify ACK is sent with message_type kwarg (not content_type)."""
392
+
393
+ def _make_loop(self, tmp_path, auto_ack=True):
394
+ config = ConsciousnessConfig(
395
+ auto_ack=auto_ack,
396
+ fallback_chain=["passthrough"],
397
+ )
398
+ loop = ConsciousnessLoop(config, home=tmp_path / ".skcapstone")
399
+ return loop
400
+
401
+ def _make_envelope(self, sender="peer", content="hello", content_type="text"):
402
+ data = {
403
+ "sender": sender,
404
+ "payload": {"content": content, "content_type": content_type},
405
+ }
406
+ return _SimpleEnvelope(data)
407
+
408
+ def test_ack_uses_message_type_kwarg(self, tmp_path):
409
+ """ACK send must use message_type kwarg, not content_type — regression for TypeError."""
410
+ loop = self._make_loop(tmp_path)
411
+ mock_skcomm = MagicMock()
412
+ loop.set_skcomm(mock_skcomm)
413
+ # Patch bridge so test doesn't hang on LLM calls
414
+ loop._bridge = MagicMock()
415
+ loop._bridge.generate.return_value = "test response"
416
+
417
+ envelope = self._make_envelope()
418
+ loop.process_envelope(envelope)
419
+
420
+ # Find the ACK call (first send call with "ACK" as message)
421
+ ack_calls = [
422
+ c for c in mock_skcomm.send.call_args_list
423
+ if len(c.args) >= 2 and c.args[1] == "ACK"
424
+ ]
425
+ assert ack_calls, "Expected at least one ACK send call"
426
+ ack_call = ack_calls[0]
427
+
428
+ # Must NOT have content_type kwarg (that was the bug)
429
+ assert "content_type" not in ack_call.kwargs, (
430
+ "ACK send used wrong kwarg 'content_type' — should be 'message_type'"
431
+ )
432
+ # Must have message_type kwarg
433
+ assert "message_type" in ack_call.kwargs, (
434
+ "ACK send must pass message_type kwarg"
435
+ )
436
+ assert ack_call.kwargs["message_type"] == "ack"
437
+
438
+ def test_ack_not_sent_when_auto_ack_disabled(self, tmp_path):
439
+ """When auto_ack is False, no ACK is sent."""
440
+ loop = self._make_loop(tmp_path, auto_ack=False)
441
+ mock_skcomm = MagicMock()
442
+ loop.set_skcomm(mock_skcomm)
443
+ loop._bridge = MagicMock()
444
+ loop._bridge.generate.return_value = "test response"
445
+
446
+ loop.process_envelope(self._make_envelope())
447
+
448
+ ack_calls = [
449
+ c for c in mock_skcomm.send.call_args_list
450
+ if len(c.args) >= 2 and c.args[1] == "ACK"
451
+ ]
452
+ assert not ack_calls, "ACK should not be sent when auto_ack is False"
453
+
454
+ def test_ack_skipped_for_ack_type_messages(self, tmp_path):
455
+ """Incoming ACK messages are skipped — no processing, no re-ACK."""
456
+ loop = self._make_loop(tmp_path, auto_ack=True)
457
+ mock_skcomm = MagicMock()
458
+ loop.set_skcomm(mock_skcomm)
459
+ loop._bridge = MagicMock()
460
+
461
+ ack_envelope = self._make_envelope(content="ACK", content_type="ack")
462
+ result = loop.process_envelope(ack_envelope)
463
+
464
+ assert result is None, "ACK-type messages should be skipped (return None)"
465
+ mock_skcomm.send.assert_not_called()
466
+
467
+
468
+ class TestSystemPromptBuilderCache:
469
+ """Section cache TTL tests for SystemPromptBuilder."""
470
+
471
+ def test_get_cached_calls_loader_once(self, tmp_path):
472
+ """_get_cached calls the loader only once within TTL."""
473
+ home = tmp_path / ".skcapstone"
474
+ home.mkdir()
475
+ builder = SystemPromptBuilder(home)
476
+
477
+ call_count = 0
478
+
479
+ def loader():
480
+ nonlocal call_count
481
+ call_count += 1
482
+ return "section_value"
483
+
484
+ result1 = builder._get_cached("test_key", loader, ttl=60)
485
+ result2 = builder._get_cached("test_key", loader, ttl=60)
486
+
487
+ assert result1 == result2 == "section_value"
488
+ assert call_count == 1, "Loader should be called only once within TTL"
489
+
490
+ def test_get_cached_reloads_after_ttl(self, tmp_path):
491
+ """_get_cached reloads the value once TTL has expired."""
492
+ home = tmp_path / ".skcapstone"
493
+ home.mkdir()
494
+ builder = SystemPromptBuilder(home)
495
+
496
+ call_count = 0
497
+
498
+ def loader():
499
+ nonlocal call_count
500
+ call_count += 1
501
+ return f"value_{call_count}"
502
+
503
+ builder._get_cached("key", loader, ttl=60)
504
+ # Expire the cache entry manually
505
+ val, _ = builder._section_cache["key"]
506
+ builder._section_cache["key"] = (val, time.monotonic() - 1)
507
+ builder._get_cached("key", loader, ttl=60)
508
+
509
+ assert call_count == 2, "Loader should be called again after TTL expires"
510
+
511
+ def test_build_caches_identity_section(self, tmp_path):
512
+ """build() serves identity from cache on second call."""
513
+ home = tmp_path / ".skcapstone"
514
+ identity_dir = home / "identity"
515
+ identity_dir.mkdir(parents=True)
516
+ (identity_dir / "identity.json").write_text(
517
+ json.dumps({"name": "opus", "fingerprint": "ABCD1234"})
518
+ )
519
+
520
+ builder = SystemPromptBuilder(home)
521
+ with patch.object(builder, "_load_identity", wraps=builder._load_identity) as mock_id:
522
+ builder.build()
523
+ builder.build()
524
+
525
+ assert mock_id.call_count == 1, "_load_identity should be called once (cached)"
526
+
527
+ def test_build_caches_context_section(self, tmp_path):
528
+ """build() serves context from cache on second call."""
529
+ home = tmp_path / ".skcapstone"
530
+ home.mkdir()
531
+
532
+ builder = SystemPromptBuilder(home)
533
+ with patch.object(builder, "_load_context", wraps=builder._load_context) as mock_ctx:
534
+ builder.build()
535
+ builder.build()
536
+
537
+ assert mock_ctx.call_count == 1, "_load_context should be called once (cached)"
538
+
539
+ def test_cache_key_isolation(self, tmp_path):
540
+ """Different section keys are cached independently."""
541
+ home = tmp_path / ".skcapstone"
542
+ home.mkdir()
543
+ builder = SystemPromptBuilder(home)
544
+
545
+ a_calls, b_calls = 0, 0
546
+
547
+ def loader_a():
548
+ nonlocal a_calls
549
+ a_calls += 1
550
+ return "a"
551
+
552
+ def loader_b():
553
+ nonlocal b_calls
554
+ b_calls += 1
555
+ return "b"
556
+
557
+ builder._get_cached("a", loader_a)
558
+ builder._get_cached("b", loader_b)
559
+ builder._get_cached("a", loader_a)
560
+ builder._get_cached("b", loader_b)
561
+
562
+ assert a_calls == 1
563
+ assert b_calls == 1
564
+
565
+
566
+ class TestProcessEnvelopeTiming:
567
+ """Timing instrumentation emitted by process_envelope."""
568
+
569
+ def _make_loop(self, tmp_path):
570
+ config = ConsciousnessConfig(fallback_chain=["passthrough"])
571
+ loop = ConsciousnessLoop(config, home=tmp_path / ".skcapstone")
572
+ loop._bridge = MagicMock()
573
+ loop._bridge.generate.return_value = "response"
574
+ return loop
575
+
576
+ def _make_envelope(self, content="hello"):
577
+ data = {"sender": "peer", "payload": {"content": content, "content_type": "text"}}
578
+ return _SimpleEnvelope(data)
579
+
580
+ def test_timing_log_emitted(self, tmp_path, caplog):
581
+ """process_envelope logs 'Pipeline timing' with all four phase labels."""
582
+ loop = self._make_loop(tmp_path)
583
+ with caplog.at_level(logging.INFO, logger="skcapstone.consciousness"):
584
+ loop.process_envelope(self._make_envelope())
585
+
586
+ timing_msgs = [r.message for r in caplog.records if "Pipeline timing" in r.message]
587
+ assert timing_msgs, "Expected 'Pipeline timing' log entry"
588
+ msg = timing_msgs[0]
589
+ assert "classify:" in msg
590
+ assert "prompt_build:" in msg
591
+ assert "llm:" in msg
592
+ assert "send:" in msg
593
+
594
+ def test_timing_values_are_non_negative(self, tmp_path, caplog):
595
+ """All reported timing values must be >= 0."""
596
+ import re as _re
597
+
598
+ loop = self._make_loop(tmp_path)
599
+ with caplog.at_level(logging.INFO, logger="skcapstone.consciousness"):
600
+ loop.process_envelope(self._make_envelope())
601
+
602
+ timing_msgs = [r.message for r in caplog.records if "Pipeline timing" in r.message]
603
+ assert timing_msgs
604
+ numbers = [float(n) for n in _re.findall(r"[\d.]+(?=ms)", timing_msgs[0])]
605
+ assert len(numbers) == 4, f"Expected 4 timing values, got: {numbers}"
606
+ assert all(n >= 0 for n in numbers), f"Negative timing value: {numbers}"
607
+
608
+
609
+ class TestVerifyMessageSignature:
610
+ """Tests for ConsciousnessLoop._verify_message_signature."""
611
+
612
+ def _make_loop(self, tmp_path):
613
+ config = ConsciousnessConfig(fallback_chain=["passthrough"])
614
+ return ConsciousnessLoop(config, home=tmp_path / ".skcapstone")
615
+
616
+ def test_unsigned_when_no_signature(self, tmp_path):
617
+ """Returns 'unsigned' when payload has no signature field."""
618
+ loop = self._make_loop(tmp_path)
619
+ data = {"sender": "jarvis", "payload": {"content": "hello"}}
620
+ assert loop._verify_message_signature(data) == "unsigned"
621
+
622
+ def test_unsigned_empty_signature(self, tmp_path):
623
+ """Returns 'unsigned' when signature field is empty string."""
624
+ loop = self._make_loop(tmp_path)
625
+ data = {"sender": "jarvis", "payload": {"content": "hello", "signature": ""}}
626
+ assert loop._verify_message_signature(data) == "unsigned"
627
+
628
+ def test_failed_when_no_peer_key(self, tmp_path):
629
+ """Returns 'failed' when sender has no public key in peer store."""
630
+ loop = self._make_loop(tmp_path)
631
+ data = {
632
+ "sender": "unknown-peer",
633
+ "payload": {"content": "hello", "signature": "-----BEGIN PGP MESSAGE-----\nfake\n-----END PGP MESSAGE-----"},
634
+ }
635
+ # No peer registered → get_peer returns None → failed
636
+ assert loop._verify_message_signature(data) == "failed"
637
+
638
+ def test_failed_when_unknown_sender(self, tmp_path):
639
+ """Returns 'failed' when sender resolves to 'unknown'."""
640
+ loop = self._make_loop(tmp_path)
641
+ data = {
642
+ # No sender/from key → sanitizer returns "unknown"
643
+ "payload": {"content": "hi", "signature": "sig"},
644
+ }
645
+ assert loop._verify_message_signature(data) == "failed"
646
+
647
+ @patch("skcapstone.consciousness_loop.ConsciousnessLoop._verify_message_signature")
648
+ def test_on_inbox_file_logs_sig_status(self, mock_verify, tmp_path, caplog):
649
+ """_on_inbox_file logs the signature status returned by _verify_message_signature."""
650
+ mock_verify.return_value = "unsigned"
651
+
652
+ loop = self._make_loop(tmp_path)
653
+ loop._executor = MagicMock() # don't submit real work
654
+
655
+ # Write a valid envelope file
656
+ inbox = tmp_path / "inbox"
657
+ inbox.mkdir()
658
+ msg_file = inbox / "test.skc.json"
659
+ msg_file.write_text(json.dumps({
660
+ "sender": "jarvis",
661
+ "payload": {"content": "hello", "content_type": "text"},
662
+ }))
663
+
664
+ with caplog.at_level(logging.INFO, logger="skcapstone.consciousness"):
665
+ loop._on_inbox_file(msg_file)
666
+
667
+ sig_logs = [r.message for r in caplog.records if "signature:" in r.message]
668
+ assert sig_logs, "Expected a 'signature:' log entry from _on_inbox_file"
669
+ assert "unsigned" in sig_logs[0]
670
+
671
+ def test_verified_with_mock_backend(self, tmp_path):
672
+ """Returns 'verified' when capauth backend confirms the signature."""
673
+ loop = self._make_loop(tmp_path)
674
+
675
+ # Register a peer with a public key
676
+ peer_dir = (tmp_path / ".skcapstone") / "peers"
677
+ peer_dir.mkdir(parents=True)
678
+ peer_data = {
679
+ "name": "jarvis",
680
+ "fingerprint": "ABCD1234",
681
+ "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nfake\n-----END PGP PUBLIC KEY BLOCK-----",
682
+ "trust_level": "verified",
683
+ }
684
+ (peer_dir / "jarvis.json").write_text(json.dumps(peer_data))
685
+
686
+ data = {
687
+ "sender": "jarvis",
688
+ "payload": {
689
+ "content": "hello",
690
+ "signature": "-----BEGIN PGP MESSAGE-----\nfake\n-----END PGP MESSAGE-----",
691
+ },
692
+ }
693
+
694
+ with patch("capauth.crypto.get_backend") as mock_get_backend:
695
+ mock_backend = MagicMock()
696
+ mock_backend.verify.return_value = True
697
+ mock_get_backend.return_value = mock_backend
698
+
699
+ result = loop._verify_message_signature(data)
700
+
701
+ assert result == "verified"
702
+ mock_backend.verify.assert_called_once_with(
703
+ data=b"hello",
704
+ signature_armor="-----BEGIN PGP MESSAGE-----\nfake\n-----END PGP MESSAGE-----",
705
+ public_key_armor=peer_data["public_key"],
706
+ )
707
+
708
+ def test_failed_with_bad_signature(self, tmp_path):
709
+ """Returns 'failed' when capauth backend rejects the signature."""
710
+ loop = self._make_loop(tmp_path)
711
+
712
+ peer_dir = (tmp_path / ".skcapstone") / "peers"
713
+ peer_dir.mkdir(parents=True)
714
+ peer_data = {
715
+ "name": "jarvis",
716
+ "fingerprint": "ABCD1234",
717
+ "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nfake\n-----END PGP PUBLIC KEY BLOCK-----",
718
+ "trust_level": "verified",
719
+ }
720
+ (peer_dir / "jarvis.json").write_text(json.dumps(peer_data))
721
+
722
+ data = {
723
+ "sender": "jarvis",
724
+ "payload": {
725
+ "content": "hello",
726
+ "signature": "-----BEGIN PGP MESSAGE-----\nfake\n-----END PGP MESSAGE-----",
727
+ },
728
+ }
729
+
730
+ with patch("capauth.crypto.get_backend") as mock_get_backend:
731
+ mock_backend = MagicMock()
732
+ mock_backend.verify.return_value = False
733
+ mock_get_backend.return_value = mock_backend
734
+
735
+ result = loop._verify_message_signature(data)
736
+
737
+ assert result == "failed"
738
+
739
+
740
+ class TestOllamaConnectionPool:
741
+ """Unit tests for _OllamaPool — connection reuse, TTL eviction, invalidation."""
742
+
743
+ def test_get_returns_same_connection_within_ttl(self):
744
+ """Two get() calls within TTL return the same connection object."""
745
+ pool = _OllamaPool("http://localhost:11434", ttl=60)
746
+ with patch("http.client.HTTPConnection") as mock_cls:
747
+ mock_conn = MagicMock()
748
+ mock_cls.return_value = mock_conn
749
+ conn1 = pool.get()
750
+ conn2 = pool.get()
751
+
752
+ assert conn1 is conn2, "Same connection should be returned within TTL"
753
+ assert mock_cls.call_count == 1, "HTTPConnection should be created only once"
754
+
755
+ def test_get_recreates_connection_after_ttl(self):
756
+ """get() creates a fresh connection once TTL has expired."""
757
+ pool = _OllamaPool("http://localhost:11434", ttl=60)
758
+ with patch("http.client.HTTPConnection") as mock_cls:
759
+ mock_cls.side_effect = [MagicMock(), MagicMock()]
760
+ pool.get()
761
+ # Manually expire the TTL
762
+ pool._created_at = time.monotonic() - 61
763
+ pool.get()
764
+
765
+ assert mock_cls.call_count == 2, "HTTPConnection should be recreated after TTL"
766
+
767
+ def test_invalidate_discards_connection(self):
768
+ """invalidate() closes and clears the cached connection."""
769
+ pool = _OllamaPool("http://localhost:11434", ttl=60)
770
+ with patch("http.client.HTTPConnection") as mock_cls:
771
+ mock_cls.side_effect = [MagicMock(), MagicMock()]
772
+ pool.get()
773
+ pool.invalidate()
774
+ assert pool._conn is None, "invalidate() should clear _conn"
775
+ pool.get()
776
+
777
+ assert mock_cls.call_count == 2, "New connection created after invalidate()"
778
+
779
+ def test_probe_ollama_uses_pool_connection(self):
780
+ """_probe_ollama routes the health check through the pool."""
781
+ config = ConsciousnessConfig()
782
+ bridge = LLMBridge(config)
783
+
784
+ mock_conn = MagicMock()
785
+ mock_resp = MagicMock()
786
+ mock_resp.status = 200
787
+ mock_resp.read.return_value = b'{"models":[]}'
788
+ mock_conn.getresponse.return_value = mock_resp
789
+
790
+ with patch.object(bridge._ollama_pool, "get", return_value=mock_conn):
791
+ result = bridge._probe_ollama()
792
+
793
+ assert result is True
794
+ mock_conn.request.assert_called_once_with("GET", "/api/tags")
795
+ mock_resp.read.assert_called_once() # body drained for keep-alive
796
+
797
+ def test_probe_ollama_invalidates_pool_on_error(self):
798
+ """_probe_ollama invalidates the pool when a connection error occurs."""
799
+ config = ConsciousnessConfig()
800
+ bridge = LLMBridge(config)
801
+
802
+ mock_conn = MagicMock()
803
+ mock_conn.request.side_effect = ConnectionError("refused")
804
+
805
+ with patch.object(bridge._ollama_pool, "get", return_value=mock_conn):
806
+ with patch.object(bridge._ollama_pool, "invalidate") as mock_invalidate:
807
+ result = bridge._probe_ollama()
808
+
809
+ assert result is False
810
+ mock_invalidate.assert_called_once()
811
+
812
+ def test_pool_host_port_parsing(self):
813
+ """_OllamaPool correctly parses host and port from the URL."""
814
+ pool = _OllamaPool("http://myhost:12345", ttl=30)
815
+ assert pool._host == "myhost"
816
+ assert pool._port == 12345
817
+
818
+ def test_pool_defaults_for_bare_localhost(self):
819
+ """_OllamaPool falls back to localhost:11434 for a bare URL."""
820
+ pool = _OllamaPool("http://localhost:11434")
821
+ assert pool._host == "localhost"
822
+ assert pool._port == 11434
823
+
824
+
825
+ class TestMessageThreading:
826
+ """Tests for thread_id / in_reply_to envelope tracking and history grouping."""
827
+
828
+ # ------------------------------------------------------------------
829
+ # _SimpleEnvelope extraction
830
+ # ------------------------------------------------------------------
831
+
832
+ def test_envelope_extracts_thread_id_from_root(self):
833
+ """thread_id at envelope root is captured."""
834
+ data = {
835
+ "sender": "jarvis",
836
+ "thread_id": "thread-abc",
837
+ "payload": {"content": "hi", "content_type": "text"},
838
+ }
839
+ env = _SimpleEnvelope(data)
840
+ assert env.thread_id == "thread-abc"
841
+
842
+ def test_envelope_extracts_thread_id_from_payload(self):
843
+ """thread_id nested inside payload is captured."""
844
+ data = {
845
+ "sender": "jarvis",
846
+ "payload": {"content": "hi", "content_type": "text", "thread_id": "thread-xyz"},
847
+ }
848
+ env = _SimpleEnvelope(data)
849
+ assert env.thread_id == "thread-xyz"
850
+
851
+ def test_envelope_extracts_in_reply_to(self):
852
+ """in_reply_to at envelope root is captured."""
853
+ data = {
854
+ "sender": "lumina",
855
+ "in_reply_to": "msg-001",
856
+ "payload": {"content": "reply", "content_type": "text"},
857
+ }
858
+ env = _SimpleEnvelope(data)
859
+ assert env.in_reply_to == "msg-001"
860
+
861
+ def test_envelope_in_reply_to_from_payload(self):
862
+ """in_reply_to nested inside payload is captured."""
863
+ data = {
864
+ "sender": "lumina",
865
+ "payload": {"content": "reply", "content_type": "text", "in_reply_to": "msg-002"},
866
+ }
867
+ env = _SimpleEnvelope(data)
868
+ assert env.in_reply_to == "msg-002"
869
+
870
+ def test_envelope_defaults_empty_when_absent(self):
871
+ """thread_id and in_reply_to default to empty string when absent."""
872
+ data = {"sender": "ava", "payload": {"content": "hello", "content_type": "text"}}
873
+ env = _SimpleEnvelope(data)
874
+ assert env.thread_id == ""
875
+ assert env.in_reply_to == ""
876
+
877
+ # ------------------------------------------------------------------
878
+ # SystemPromptBuilder — add_to_history threading
879
+ # ------------------------------------------------------------------
880
+
881
+ def test_add_to_history_stores_thread_id(self, tmp_path):
882
+ """add_to_history stores thread_id in the history entry."""
883
+ home = tmp_path / ".skcapstone"
884
+ home.mkdir()
885
+ builder = SystemPromptBuilder(home)
886
+
887
+ builder.add_to_history("jarvis", "user", "Hello!", thread_id="t-001")
888
+
889
+ entry = builder._conversation_history["jarvis"][0]
890
+ assert entry["thread_id"] == "t-001"
891
+
892
+ def test_add_to_history_stores_in_reply_to(self, tmp_path):
893
+ """add_to_history stores in_reply_to in the history entry."""
894
+ home = tmp_path / ".skcapstone"
895
+ home.mkdir()
896
+ builder = SystemPromptBuilder(home)
897
+
898
+ builder.add_to_history("jarvis", "user", "Reply!", in_reply_to="msg-55")
899
+
900
+ entry = builder._conversation_history["jarvis"][0]
901
+ assert entry["in_reply_to"] == "msg-55"
902
+
903
+ def test_add_to_history_no_thread_fields_when_absent(self, tmp_path):
904
+ """No thread_id/in_reply_to keys in entry when not provided."""
905
+ home = tmp_path / ".skcapstone"
906
+ home.mkdir()
907
+ builder = SystemPromptBuilder(home)
908
+
909
+ builder.add_to_history("jarvis", "user", "Plain message")
910
+
911
+ entry = builder._conversation_history["jarvis"][0]
912
+ assert "thread_id" not in entry
913
+ assert "in_reply_to" not in entry
914
+
915
+ def test_thread_fields_persisted_to_json(self, tmp_path):
916
+ """thread_id and in_reply_to survive the round-trip through JSON persistence."""
917
+ home = tmp_path / ".skcapstone"
918
+ home.mkdir()
919
+ builder = SystemPromptBuilder(home)
920
+
921
+ builder.add_to_history("opus", "user", "Threaded msg", thread_id="t-99", in_reply_to="m-10")
922
+
923
+ conv_file = home / "conversations" / "opus.json"
924
+ data = json.loads(conv_file.read_text())
925
+ assert data[0]["thread_id"] == "t-99"
926
+ assert data[0]["in_reply_to"] == "m-10"
927
+
928
+ # ------------------------------------------------------------------
929
+ # SystemPromptBuilder.build — thread context in prompt
930
+ # ------------------------------------------------------------------
931
+
932
+ def test_build_shows_thread_label_when_thread_id_given(self, tmp_path):
933
+ """build() includes a [Thread: ...] label when thread_id is provided."""
934
+ home = tmp_path / ".skcapstone"
935
+ home.mkdir()
936
+ builder = SystemPromptBuilder(home)
937
+
938
+ builder.add_to_history("jarvis", "user", "Thread message", thread_id="t-alpha")
939
+ prompt = builder.build(peer_name="jarvis", thread_id="t-alpha")
940
+
941
+ assert "Thread: t-alpha" in prompt
942
+ assert "Thread message" in prompt
943
+
944
+ def test_build_groups_thread_and_other_messages(self, tmp_path):
945
+ """Thread messages appear under [Thread:...] and others under [Other recent messages:]."""
946
+ home = tmp_path / ".skcapstone"
947
+ home.mkdir()
948
+ builder = SystemPromptBuilder(home)
949
+
950
+ builder.add_to_history("ava", "user", "Thread msg", thread_id="t-1")
951
+ builder.add_to_history("ava", "user", "Unrelated msg")
952
+
953
+ prompt = builder.build(peer_name="ava", thread_id="t-1")
954
+
955
+ assert "Thread: t-1" in prompt
956
+ assert "Thread msg" in prompt
957
+ assert "Other recent messages" in prompt
958
+ assert "Unrelated msg" in prompt
959
+
960
+ def test_build_without_thread_id_shows_thread_labels_inline(self, tmp_path):
961
+ """Without thread_id, messages with threads show [thread:...] inline."""
962
+ home = tmp_path / ".skcapstone"
963
+ home.mkdir()
964
+ builder = SystemPromptBuilder(home)
965
+
966
+ builder.add_to_history("lumina", "user", "Inline threaded", thread_id="t-beta")
967
+ builder.add_to_history("lumina", "user", "Plain")
968
+
969
+ prompt = builder.build(peer_name="lumina")
970
+
971
+ assert "thread:t-beta" in prompt
972
+ assert "Inline threaded" in prompt
973
+ assert "Plain" in prompt
974
+
975
+ # ------------------------------------------------------------------
976
+ # ConsciousnessLoop.process_envelope — threading end-to-end
977
+ # ------------------------------------------------------------------
978
+
979
+ def test_process_envelope_stores_thread_id_in_history(self, tmp_path):
980
+ """process_envelope extracts thread_id and stores it in conversation history."""
981
+ config = ConsciousnessConfig(fallback_chain=["passthrough"])
982
+ loop = ConsciousnessLoop(config, home=tmp_path / ".skcapstone")
983
+ loop._bridge = MagicMock()
984
+ loop._bridge.generate.return_value = "hi back"
985
+
986
+ data = {
987
+ "sender": "jarvis",
988
+ "thread_id": "t-42",
989
+ "payload": {"content": "threaded hello", "content_type": "text"},
990
+ }
991
+ env = _SimpleEnvelope(data)
992
+ loop.process_envelope(env)
993
+
994
+ history = loop._prompt_builder._conversation_history.get("jarvis", [])
995
+ user_entry = next((e for e in history if e["role"] == "user"), None)
996
+ assert user_entry is not None
997
+ assert user_entry.get("thread_id") == "t-42"
998
+
999
+ def test_process_envelope_stores_in_reply_to_in_history(self, tmp_path):
1000
+ """process_envelope extracts in_reply_to and stores it in conversation history."""
1001
+ config = ConsciousnessConfig(fallback_chain=["passthrough"])
1002
+ loop = ConsciousnessLoop(config, home=tmp_path / ".skcapstone")
1003
+ loop._bridge = MagicMock()
1004
+ loop._bridge.generate.return_value = "reply"
1005
+
1006
+ data = {
1007
+ "sender": "ava",
1008
+ "in_reply_to": "msg-77",
1009
+ "payload": {"content": "reply message", "content_type": "text"},
1010
+ }
1011
+ env = _SimpleEnvelope(data)
1012
+ loop.process_envelope(env)
1013
+
1014
+ history = loop._prompt_builder._conversation_history.get("ava", [])
1015
+ user_entry = next((e for e in history if e["role"] == "user"), None)
1016
+ assert user_entry is not None
1017
+ assert user_entry.get("in_reply_to") == "msg-77"
1018
+
1019
+
1020
+ # ---------------------------------------------------------------------------
1021
+ # Prompt versioning tests
1022
+ # ---------------------------------------------------------------------------
1023
+
1024
+
1025
+ class TestSystemPromptVersioning:
1026
+ """Tests for SHA-256 prompt versioning in SystemPromptBuilder."""
1027
+
1028
+ def test_initial_hash_is_none(self, tmp_path):
1029
+ """current_prompt_hash is None before any build() call."""
1030
+ home = tmp_path / ".skcapstone"
1031
+ home.mkdir()
1032
+ builder = SystemPromptBuilder(home)
1033
+ assert builder.current_prompt_hash is None
1034
+
1035
+ def test_hash_set_after_build(self, tmp_path):
1036
+ """After build(), current_prompt_hash is a 64-char SHA-256 hex string."""
1037
+ home = tmp_path / ".skcapstone"
1038
+ home.mkdir()
1039
+ builder = SystemPromptBuilder(home)
1040
+ builder.build()
1041
+ h = builder.current_prompt_hash
1042
+ assert h is not None
1043
+ assert len(h) == 64
1044
+ assert all(c in "0123456789abcdef" for c in h)
1045
+
1046
+ def test_version_file_created_on_first_build(self, tmp_path):
1047
+ """A JSON version file is written to prompt_versions/ on first build."""
1048
+ home = tmp_path / ".skcapstone"
1049
+ home.mkdir()
1050
+ builder = SystemPromptBuilder(home)
1051
+ builder.build()
1052
+
1053
+ versions_dir = home / "prompt_versions"
1054
+ files = list(versions_dir.glob("*.json"))
1055
+ assert len(files) == 1, "Expected exactly one version file"
1056
+
1057
+ record = json.loads(files[0].read_text())
1058
+ assert record["hash"] == builder.current_prompt_hash
1059
+ assert "timestamp" in record
1060
+ assert "prompt" in record
1061
+
1062
+ def test_no_duplicate_file_for_same_prompt(self, tmp_path):
1063
+ """Building the same prompt twice does not create a second version file."""
1064
+ home = tmp_path / ".skcapstone"
1065
+ home.mkdir()
1066
+ builder = SystemPromptBuilder(home)
1067
+ builder.build()
1068
+ builder.build() # same content, same hash
1069
+
1070
+ versions_dir = home / "prompt_versions"
1071
+ files = list(versions_dir.glob("*.json"))
1072
+ assert len(files) == 1, "No duplicate file when prompt unchanged"
1073
+
1074
+ def test_new_file_when_prompt_changes(self, tmp_path):
1075
+ """A new version file is created when the prompt content changes."""
1076
+ home = tmp_path / ".skcapstone"
1077
+ identity_dir = home / "identity"
1078
+ identity_dir.mkdir(parents=True)
1079
+
1080
+ builder = SystemPromptBuilder(home)
1081
+ builder.build()
1082
+ first_hash = builder.current_prompt_hash
1083
+
1084
+ # Change the identity so the prompt changes
1085
+ (identity_dir / "identity.json").write_text(
1086
+ json.dumps({"name": "changed-agent", "fingerprint": "NEWFINGERPRINT"})
1087
+ )
1088
+ # Expire the cache so the identity is reloaded
1089
+ builder._section_cache.clear()
1090
+ builder.build()
1091
+ second_hash = builder.current_prompt_hash
1092
+
1093
+ assert first_hash != second_hash
1094
+ versions_dir = home / "prompt_versions"
1095
+ files = list(versions_dir.glob("*.json"))
1096
+ assert len(files) == 2, "Two files for two distinct prompt versions"
1097
+
1098
+ def test_stats_include_prompt_hash_and_version_responses(self, tmp_path):
1099
+ """ConsciousnessLoop.stats exposes current_prompt_hash and prompt_version_responses."""
1100
+ home = tmp_path / ".skcapstone"
1101
+ home.mkdir()
1102
+
1103
+ config = ConsciousnessConfig(enabled=False)
1104
+ loop = ConsciousnessLoop(config, home=home)
1105
+
1106
+ stats = loop.stats
1107
+ assert "current_prompt_hash" in stats
1108
+ assert "prompt_version_responses" in stats
1109
+ assert isinstance(stats["prompt_version_responses"], dict)
1110
+
1111
+ def test_version_responses_incremented_on_send(self, tmp_path):
1112
+ """prompt_version_responses counter increments for the active hash when a response is sent."""
1113
+ home = tmp_path / ".skcapstone"
1114
+ home.mkdir()
1115
+
1116
+ config = ConsciousnessConfig(enabled=False)
1117
+ loop = ConsciousnessLoop(config, home=home)
1118
+
1119
+ # Simulate a build so a hash is established
1120
+ loop._prompt_builder.build()
1121
+ active_hash = loop._prompt_builder.current_prompt_hash
1122
+ assert active_hash is not None
1123
+
1124
+ # Manually trigger the counting logic (as the send path does)
1125
+ loop._prompt_version_responses[active_hash] += 1
1126
+
1127
+ stats = loop.stats
1128
+ assert stats["prompt_version_responses"].get(active_hash) == 1
1129
+
1130
+
1131
+ class TestFetchSenderMemories:
1132
+ """Tests for ConsciousnessLoop._fetch_sender_memories()."""
1133
+
1134
+ def _make_loop(self, tmp_path):
1135
+ config = ConsciousnessConfig(
1136
+ fallback_chain=["passthrough"],
1137
+ auto_memory=False,
1138
+ )
1139
+ return ConsciousnessLoop(config, home=tmp_path / ".skcapstone")
1140
+
1141
+ def _make_entry(self, memory_id, content, tags=None):
1142
+ """Build a minimal MemoryEntry-like mock."""
1143
+ entry = MagicMock()
1144
+ entry.memory_id = memory_id
1145
+ entry.content = content
1146
+ entry.tags = tags or []
1147
+ return entry
1148
+
1149
+ def test_returns_empty_when_no_memories(self, tmp_path):
1150
+ """Returns empty string when memory search yields nothing."""
1151
+ loop = self._make_loop(tmp_path)
1152
+
1153
+ with patch("skcapstone.memory_engine.search", return_value=[]):
1154
+ result = loop._fetch_sender_memories("jarvis", "hello there")
1155
+
1156
+ assert result == ""
1157
+
1158
+ def test_includes_top_3_memories_in_output(self, tmp_path):
1159
+ """Output contains exactly 3 memory entries when 5 are returned."""
1160
+ loop = self._make_loop(tmp_path)
1161
+
1162
+ entries = [
1163
+ self._make_entry(f"id-{i}", f"Memory content {i}")
1164
+ for i in range(5)
1165
+ ]
1166
+
1167
+ # by_sender returns 3, by_content returns 2 different ones
1168
+ def mock_search(home, query, tags=None, limit=5):
1169
+ if tags:
1170
+ return entries[:3]
1171
+ return entries[3:]
1172
+
1173
+ with patch("skcapstone.memory_engine.search", side_effect=mock_search):
1174
+ result = loop._fetch_sender_memories("jarvis", "hello")
1175
+
1176
+ assert "Relevant memories:" in result
1177
+ assert "[1]" in result
1178
+ assert "[2]" in result
1179
+ assert "[3]" in result
1180
+ # Should not exceed 3 entries
1181
+ assert "[4]" not in result
1182
+
1183
+ def test_deduplicates_overlapping_results(self, tmp_path):
1184
+ """Memories returned by both searches are deduplicated."""
1185
+ loop = self._make_loop(tmp_path)
1186
+
1187
+ shared = self._make_entry("shared-id", "Shared memory content")
1188
+ unique = self._make_entry("unique-id", "Unique memory content")
1189
+
1190
+ # Both searches return the same shared entry
1191
+ with patch("skcapstone.memory_engine.search", return_value=[shared, unique]):
1192
+ result = loop._fetch_sender_memories("jarvis", "hello")
1193
+
1194
+ # shared-id should appear exactly once
1195
+ assert result.count("Shared memory content") == 1
1196
+
1197
+ def test_memory_context_appended_to_system_prompt(self, tmp_path):
1198
+ """process_envelope appends memory context to the system prompt passed to LLM."""
1199
+ home = tmp_path / ".skcapstone"
1200
+ home.mkdir()
1201
+ config = ConsciousnessConfig(
1202
+ fallback_chain=["passthrough"],
1203
+ auto_memory=False,
1204
+ auto_ack=False,
1205
+ desktop_notifications=False,
1206
+ )
1207
+ loop = ConsciousnessLoop(config, home=home)
1208
+
1209
+ captured_system_prompts = []
1210
+
1211
+ def fake_generate(system_prompt, content, signal, _out_info=None, **kwargs):
1212
+ captured_system_prompts.append(system_prompt)
1213
+ return "test response"
1214
+
1215
+ loop._bridge = MagicMock()
1216
+ loop._bridge.generate.side_effect = fake_generate
1217
+
1218
+ entry = MagicMock()
1219
+ entry.memory_id = "mem-abc"
1220
+ entry.content = "jarvis mentioned he prefers concise replies"
1221
+ entry.tags = ["peer:jarvis"]
1222
+
1223
+ with patch("skcapstone.memory_engine.search", return_value=[entry]):
1224
+ envelope = _SimpleEnvelope({
1225
+ "sender": "jarvis",
1226
+ "payload": {"content": "What is the status?", "content_type": "text"},
1227
+ })
1228
+ loop.process_envelope(envelope)
1229
+
1230
+ assert len(captured_system_prompts) == 1
1231
+ assert "Relevant memories:" in captured_system_prompts[0]
1232
+ assert "jarvis mentioned he prefers concise replies" in captured_system_prompts[0]
1233
+
1234
+ def test_memory_error_does_not_break_envelope_processing(self, tmp_path):
1235
+ """If memory search raises, process_envelope still completes normally."""
1236
+ home = tmp_path / ".skcapstone"
1237
+ home.mkdir()
1238
+ config = ConsciousnessConfig(
1239
+ fallback_chain=["passthrough"],
1240
+ auto_memory=False,
1241
+ auto_ack=False,
1242
+ desktop_notifications=False,
1243
+ )
1244
+ loop = ConsciousnessLoop(config, home=home)
1245
+ loop._bridge = MagicMock()
1246
+ loop._bridge.generate.return_value = "test response"
1247
+
1248
+ with patch(
1249
+ "skcapstone.memory_engine.search",
1250
+ side_effect=RuntimeError("db unavailable"),
1251
+ ):
1252
+ envelope = _SimpleEnvelope({
1253
+ "sender": "jarvis",
1254
+ "payload": {"content": "hello", "content_type": "text"},
1255
+ })
1256
+ result = loop.process_envelope(envelope)
1257
+
1258
+ assert result == "test response"
1259
+
1260
+ def test_no_memory_enrichment_when_memories_empty(self, tmp_path):
1261
+ """System prompt is unchanged when no memories are found."""
1262
+ home = tmp_path / ".skcapstone"
1263
+ home.mkdir()
1264
+ config = ConsciousnessConfig(
1265
+ fallback_chain=["passthrough"],
1266
+ auto_memory=False,
1267
+ auto_ack=False,
1268
+ desktop_notifications=False,
1269
+ )
1270
+ loop = ConsciousnessLoop(config, home=home)
1271
+
1272
+ captured_system_prompts = []
1273
+
1274
+ def fake_generate(system_prompt, content, signal, _out_info=None, **kwargs):
1275
+ captured_system_prompts.append(system_prompt)
1276
+ return "test response"
1277
+
1278
+ loop._bridge = MagicMock()
1279
+ loop._bridge.generate.side_effect = fake_generate
1280
+
1281
+ with patch("skcapstone.memory_engine.search", return_value=[]):
1282
+ envelope = _SimpleEnvelope({
1283
+ "sender": "jarvis",
1284
+ "payload": {"content": "hello", "content_type": "text"},
1285
+ })
1286
+ loop.process_envelope(envelope)
1287
+
1288
+ assert len(captured_system_prompts) == 1
1289
+ assert "Relevant memories:" not in captured_system_prompts[0]