@jaguilar87/gaia-ops 4.4.0 → 4.7.2

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 (371) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +12 -3
  3. package/ARCHITECTURE.md +9 -8
  4. package/CHANGELOG.md +34 -0
  5. package/README.md +43 -11
  6. package/agents/terraform-architect.md +1 -1
  7. package/bin/README.md +2 -2
  8. package/bin/gaia-doctor.js +18 -5
  9. package/bin/gaia-history.js +0 -1
  10. package/bin/gaia-metrics.js +2 -2
  11. package/bin/gaia-scan.py +23 -1
  12. package/bin/gaia-update.js +346 -54
  13. package/bin/pre-publish-validate.js +33 -10
  14. package/commands/gaia.md +37 -0
  15. package/config/README.md +3 -9
  16. package/config/context-contracts.json +47 -15
  17. package/config/surface-routing.json +9 -1
  18. package/dist/gaia-ops/.claude-plugin/plugin.json +22 -0
  19. package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
  20. package/dist/gaia-ops/agents/devops-developer.md +57 -0
  21. package/dist/gaia-ops/agents/gaia-system.md +58 -0
  22. package/dist/gaia-ops/agents/gitops-operator.md +60 -0
  23. package/dist/gaia-ops/agents/speckit-planner.md +71 -0
  24. package/dist/gaia-ops/agents/terraform-architect.md +60 -0
  25. package/dist/gaia-ops/commands/gaia.md +37 -0
  26. package/dist/gaia-ops/config/README.md +58 -0
  27. package/dist/gaia-ops/config/cloud/aws.json +140 -0
  28. package/dist/gaia-ops/config/cloud/gcp.json +145 -0
  29. package/dist/gaia-ops/config/context-contracts.json +131 -0
  30. package/dist/gaia-ops/config/git_standards.json +72 -0
  31. package/dist/gaia-ops/config/surface-routing.json +197 -0
  32. package/dist/gaia-ops/config/universal-rules.json +10 -0
  33. package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
  34. package/dist/gaia-ops/hooks/adapters/base.py +219 -0
  35. package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
  36. package/dist/gaia-ops/hooks/adapters/claude_code.py +1477 -0
  37. package/dist/gaia-ops/hooks/adapters/types.py +194 -0
  38. package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
  39. package/dist/gaia-ops/hooks/hooks.json +126 -0
  40. package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
  41. package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
  42. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
  43. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
  44. package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +124 -0
  45. package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
  46. package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
  47. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
  48. package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
  49. package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
  50. package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
  51. package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
  52. package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +576 -0
  53. package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
  54. package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
  55. package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
  56. package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +215 -0
  57. package/dist/gaia-ops/hooks/modules/context/context_cache.py +129 -0
  58. package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
  59. package/dist/gaia-ops/hooks/modules/context/context_injector.py +427 -0
  60. package/dist/gaia-ops/hooks/modules/context/context_writer.py +518 -0
  61. package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
  62. package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
  63. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
  64. package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
  65. package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
  66. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +558 -0
  67. package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
  68. package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
  69. package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
  70. package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
  71. package/dist/gaia-ops/hooks/modules/identity/__init__.py +0 -0
  72. package/dist/gaia-ops/hooks/modules/identity/identity_provider.py +21 -0
  73. package/dist/gaia-ops/hooks/modules/identity/ops_identity.py +34 -0
  74. package/dist/gaia-ops/hooks/modules/identity/security_identity.py +10 -0
  75. package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
  76. package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +227 -0
  77. package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
  78. package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +128 -0
  79. package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
  80. package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
  81. package/dist/gaia-ops/hooks/modules/security/__init__.py +89 -0
  82. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
  83. package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
  84. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +912 -0
  85. package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
  86. package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +153 -0
  87. package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +584 -0
  88. package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +86 -0
  89. package/dist/gaia-ops/hooks/modules/security/command_semantics.py +130 -0
  90. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
  91. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +850 -0
  92. package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
  93. package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
  94. package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
  95. package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
  96. package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +158 -0
  97. package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
  98. package/dist/gaia-ops/hooks/modules/tools/__init__.py +25 -0
  99. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +708 -0
  100. package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +181 -0
  101. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
  102. package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
  103. package/dist/gaia-ops/hooks/modules/tools/task_validator.py +283 -0
  104. package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
  105. package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
  106. package/dist/gaia-ops/hooks/post_compact.py +43 -0
  107. package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
  108. package/dist/gaia-ops/hooks/pre_tool_use.py +383 -0
  109. package/dist/gaia-ops/hooks/session_start.py +69 -0
  110. package/dist/gaia-ops/hooks/stop_hook.py +69 -0
  111. package/dist/gaia-ops/hooks/subagent_start.py +71 -0
  112. package/dist/gaia-ops/hooks/subagent_stop.py +288 -0
  113. package/dist/gaia-ops/hooks/task_completed.py +70 -0
  114. package/dist/gaia-ops/hooks/user_prompt_submit.py +177 -0
  115. package/dist/gaia-ops/settings.json +72 -0
  116. package/dist/gaia-ops/skills/README.md +109 -0
  117. package/dist/gaia-ops/skills/agent-protocol/SKILL.md +105 -0
  118. package/dist/gaia-ops/skills/agent-protocol/examples.md +170 -0
  119. package/dist/gaia-ops/skills/agent-response/SKILL.md +53 -0
  120. package/dist/gaia-ops/skills/approval/SKILL.md +85 -0
  121. package/dist/gaia-ops/skills/approval/examples.md +140 -0
  122. package/dist/gaia-ops/skills/approval/reference.md +57 -0
  123. package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
  124. package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
  125. package/dist/gaia-ops/skills/context-updater/SKILL.md +76 -0
  126. package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
  127. package/dist/gaia-ops/skills/developer-patterns/SKILL.md +93 -0
  128. package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
  129. package/dist/gaia-ops/skills/execution/SKILL.md +66 -0
  130. package/dist/gaia-ops/skills/fast-queries/SKILL.md +47 -0
  131. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +92 -0
  132. package/dist/gaia-ops/skills/gaia-patterns/reference.md +22 -0
  133. package/dist/gaia-ops/skills/git-conventions/SKILL.md +48 -0
  134. package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +73 -0
  135. package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
  136. package/dist/gaia-ops/skills/investigation/SKILL.md +77 -0
  137. package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +64 -0
  138. package/dist/gaia-ops/skills/reference.md +134 -0
  139. package/dist/gaia-ops/skills/security-tiers/SKILL.md +61 -0
  140. package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
  141. package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
  142. package/dist/gaia-ops/skills/skill-creation/SKILL.md +119 -0
  143. package/dist/gaia-ops/skills/specification/SKILL.md +186 -0
  144. package/dist/gaia-ops/skills/speckit-workflow/SKILL.md +165 -0
  145. package/dist/gaia-ops/skills/speckit-workflow/reference.md +117 -0
  146. package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +63 -0
  147. package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
  148. package/dist/gaia-ops/speckit/README.md +516 -0
  149. package/dist/gaia-ops/speckit/scripts/.gitkeep +0 -0
  150. package/dist/gaia-ops/speckit/templates/adr-template.md +118 -0
  151. package/dist/gaia-ops/speckit/templates/agent-file-template.md +23 -0
  152. package/dist/gaia-ops/speckit/templates/plan-template.md +227 -0
  153. package/dist/gaia-ops/speckit/templates/spec-template.md +140 -0
  154. package/dist/gaia-ops/speckit/templates/tasks-template.md +257 -0
  155. package/dist/gaia-ops/tools/context/README.md +132 -0
  156. package/dist/gaia-ops/tools/context/__init__.py +42 -0
  157. package/dist/gaia-ops/tools/context/_paths.py +20 -0
  158. package/dist/gaia-ops/tools/context/context_provider.py +476 -0
  159. package/dist/gaia-ops/tools/context/context_section_reader.py +330 -0
  160. package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
  161. package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
  162. package/dist/gaia-ops/tools/context/surface_router.py +278 -0
  163. package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
  164. package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
  165. package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
  166. package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
  167. package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
  168. package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
  169. package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
  170. package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
  171. package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
  172. package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
  173. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
  174. package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
  175. package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
  176. package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
  177. package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +262 -0
  178. package/dist/gaia-ops/tools/memory/README.md +0 -0
  179. package/dist/gaia-ops/tools/memory/__init__.py +20 -0
  180. package/dist/gaia-ops/tools/memory/episodic.py +1196 -0
  181. package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
  182. package/dist/gaia-ops/tools/review/__init__.py +1 -0
  183. package/dist/gaia-ops/tools/review/review_engine.py +157 -0
  184. package/dist/gaia-ops/tools/scan/__init__.py +35 -0
  185. package/dist/gaia-ops/tools/scan/config.py +247 -0
  186. package/dist/gaia-ops/tools/scan/merge.py +212 -0
  187. package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
  188. package/dist/gaia-ops/tools/scan/registry.py +127 -0
  189. package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
  190. package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
  191. package/dist/gaia-ops/tools/scan/scanners/environment.py +324 -0
  192. package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
  193. package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
  194. package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
  195. package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
  196. package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
  197. package/dist/gaia-ops/tools/scan/setup.py +753 -0
  198. package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
  199. package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
  200. package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
  201. package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
  202. package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
  203. package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
  204. package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
  205. package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
  206. package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
  207. package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
  208. package/dist/gaia-ops/tools/scan/ui.py +624 -0
  209. package/dist/gaia-ops/tools/scan/verify.py +266 -0
  210. package/dist/gaia-ops/tools/scan/walk.py +118 -0
  211. package/dist/gaia-ops/tools/scan/workspace.py +85 -0
  212. package/dist/gaia-ops/tools/validation/README.md +244 -0
  213. package/dist/gaia-ops/tools/validation/__init__.py +17 -0
  214. package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
  215. package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
  216. package/dist/gaia-security/.claude-plugin/plugin.json +22 -0
  217. package/dist/gaia-security/config/universal-rules.json +10 -0
  218. package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
  219. package/dist/gaia-security/hooks/adapters/base.py +219 -0
  220. package/dist/gaia-security/hooks/adapters/channel.py +17 -0
  221. package/dist/gaia-security/hooks/adapters/claude_code.py +1477 -0
  222. package/dist/gaia-security/hooks/adapters/types.py +194 -0
  223. package/dist/gaia-security/hooks/adapters/utils.py +25 -0
  224. package/dist/gaia-security/hooks/hooks.json +57 -0
  225. package/dist/gaia-security/hooks/modules/__init__.py +15 -0
  226. package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
  227. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
  228. package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
  229. package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +124 -0
  230. package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
  231. package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
  232. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
  233. package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
  234. package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
  235. package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
  236. package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
  237. package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +576 -0
  238. package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
  239. package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
  240. package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
  241. package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +215 -0
  242. package/dist/gaia-security/hooks/modules/context/context_cache.py +129 -0
  243. package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
  244. package/dist/gaia-security/hooks/modules/context/context_injector.py +427 -0
  245. package/dist/gaia-security/hooks/modules/context/context_writer.py +518 -0
  246. package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
  247. package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
  248. package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
  249. package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
  250. package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
  251. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +558 -0
  252. package/dist/gaia-security/hooks/modules/core/state.py +179 -0
  253. package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
  254. package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
  255. package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
  256. package/dist/gaia-security/hooks/modules/identity/__init__.py +0 -0
  257. package/dist/gaia-security/hooks/modules/identity/identity_provider.py +21 -0
  258. package/dist/gaia-security/hooks/modules/identity/ops_identity.py +34 -0
  259. package/dist/gaia-security/hooks/modules/identity/security_identity.py +10 -0
  260. package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
  261. package/dist/gaia-security/hooks/modules/memory/episode_writer.py +227 -0
  262. package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
  263. package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +128 -0
  264. package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
  265. package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
  266. package/dist/gaia-security/hooks/modules/security/__init__.py +89 -0
  267. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
  268. package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
  269. package/dist/gaia-security/hooks/modules/security/approval_grants.py +912 -0
  270. package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
  271. package/dist/gaia-security/hooks/modules/security/approval_scopes.py +153 -0
  272. package/dist/gaia-security/hooks/modules/security/blocked_commands.py +584 -0
  273. package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +86 -0
  274. package/dist/gaia-security/hooks/modules/security/command_semantics.py +130 -0
  275. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
  276. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +850 -0
  277. package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
  278. package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
  279. package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
  280. package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
  281. package/dist/gaia-security/hooks/modules/session/session_event_injector.py +158 -0
  282. package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
  283. package/dist/gaia-security/hooks/modules/tools/__init__.py +25 -0
  284. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +708 -0
  285. package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +181 -0
  286. package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
  287. package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
  288. package/dist/gaia-security/hooks/modules/tools/task_validator.py +283 -0
  289. package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
  290. package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
  291. package/dist/gaia-security/hooks/post_tool_use.py +54 -0
  292. package/dist/gaia-security/hooks/pre_tool_use.py +383 -0
  293. package/dist/gaia-security/hooks/session_start.py +69 -0
  294. package/dist/gaia-security/hooks/stop_hook.py +69 -0
  295. package/dist/gaia-security/hooks/user_prompt_submit.py +177 -0
  296. package/dist/gaia-security/settings.json +58 -0
  297. package/git-hooks/commit-msg +41 -0
  298. package/hooks/README.md +8 -6
  299. package/hooks/adapters/channel.py +0 -25
  300. package/hooks/adapters/claude_code.py +364 -125
  301. package/hooks/elicitation_result.py +132 -0
  302. package/hooks/hooks.json +10 -1
  303. package/hooks/modules/README.md +3 -2
  304. package/hooks/modules/agents/contract_validator.py +3 -51
  305. package/hooks/modules/agents/response_contract.py +4 -8
  306. package/hooks/modules/agents/transcript_reader.py +4 -5
  307. package/hooks/modules/audit/__init__.py +4 -6
  308. package/hooks/modules/audit/event_detector.py +0 -2
  309. package/hooks/modules/audit/metrics.py +108 -187
  310. package/hooks/modules/audit/workflow_auditor.py +0 -4
  311. package/hooks/modules/audit/workflow_recorder.py +0 -5
  312. package/hooks/modules/context/compact_context_builder.py +1 -0
  313. package/hooks/modules/context/context_cache.py +129 -0
  314. package/hooks/modules/context/context_injector.py +18 -40
  315. package/hooks/modules/context/context_writer.py +1 -25
  316. package/hooks/modules/context/contracts_loader.py +7 -10
  317. package/hooks/modules/core/hook_entry.py +1 -0
  318. package/hooks/modules/core/paths.py +12 -13
  319. package/hooks/modules/core/plugin_mode.py +74 -4
  320. package/hooks/modules/core/plugin_setup.py +395 -23
  321. package/hooks/modules/events/__init__.py +1 -0
  322. package/hooks/modules/events/event_writer.py +210 -0
  323. package/hooks/modules/identity/ops_identity.py +18 -27
  324. package/hooks/modules/memory/episode_writer.py +1 -6
  325. package/hooks/modules/orchestrator/__init__.py +1 -0
  326. package/hooks/modules/orchestrator/delegate_mode.py +128 -0
  327. package/hooks/modules/security/__init__.py +2 -4
  328. package/hooks/modules/security/approval_constants.py +5 -1
  329. package/hooks/modules/security/approval_grants.py +189 -6
  330. package/hooks/modules/security/approval_messages.py +9 -21
  331. package/hooks/modules/security/blocked_commands.py +98 -34
  332. package/hooks/modules/security/command_semantics.py +0 -4
  333. package/hooks/modules/security/gitops_validator.py +1 -11
  334. package/hooks/modules/security/mutative_verbs.py +179 -38
  335. package/hooks/modules/security/tiers.py +1 -19
  336. package/hooks/modules/session/session_event_injector.py +1 -25
  337. package/hooks/modules/tools/bash_validator.py +310 -94
  338. package/hooks/modules/tools/shell_parser.py +0 -1
  339. package/hooks/modules/tools/task_validator.py +9 -29
  340. package/hooks/post_tool_use.py +0 -72
  341. package/hooks/pre_tool_use.py +42 -102
  342. package/hooks/session_start.py +4 -2
  343. package/hooks/subagent_start.py +6 -2
  344. package/hooks/subagent_stop.py +1 -13
  345. package/hooks/user_prompt_submit.py +119 -37
  346. package/index.js +1 -1
  347. package/package.json +5 -3
  348. package/skills/README.md +3 -5
  349. package/skills/agent-protocol/SKILL.md +17 -16
  350. package/skills/agent-protocol/examples.md +6 -6
  351. package/skills/agent-response/SKILL.md +11 -14
  352. package/skills/approval/SKILL.md +28 -13
  353. package/skills/approval/reference.md +2 -2
  354. package/skills/execution/SKILL.md +1 -1
  355. package/skills/gaia-patterns/SKILL.md +2 -3
  356. package/skills/orchestrator-approval/SKILL.md +22 -50
  357. package/skills/security-tiers/SKILL.md +1 -1
  358. package/templates/README.md +9 -9
  359. package/templates/managed-settings.template.json +43 -0
  360. package/tools/gaia_simulator/runner.py +34 -1
  361. package/tools/scan/orchestrator.py +13 -0
  362. package/tools/scan/scanners/base.py +8 -0
  363. package/tools/scan/scanners/git.py +78 -0
  364. package/tools/scan/scanners/infrastructure.py +65 -0
  365. package/tools/scan/scanners/stack.py +110 -0
  366. package/tools/scan/setup.py +120 -13
  367. package/tools/scan/workspace.py +85 -0
  368. package/config/context-contracts.aws.json +0 -42
  369. package/config/context-contracts.gcp.json +0 -39
  370. package/skills/project-dispatch/SKILL.md +0 -34
  371. package/templates/settings.template.json +0 -226
@@ -0,0 +1,1196 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Episodic Memory System for GAIA-OPS
4
+
5
+ This module provides functionality to store, index, and search episodic memory
6
+ for the workflow system. Episodes capture user interactions, clarifications,
7
+ and enriched prompts for future reference and context enhancement.
8
+
9
+ Architecture:
10
+ - Episodes stored as individual JSON files with metadata
11
+ - JSONL index for fast keyword-based search
12
+ - Automatic directory creation and management
13
+ - Integration with workflow.py for context enhancement
14
+
15
+ P0 Enhancement: Outcome tracking (success/failure/partial, duration, commands)
16
+ P1 Enhancement: Simple relationships between episodes (SOLVES, CAUSES, etc.)
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ import uuid
23
+ from datetime import datetime, timezone, timedelta
24
+ from pathlib import Path
25
+ from typing import Dict, List, Any, Optional, Union
26
+ import re
27
+ from dataclasses import dataclass, asdict, field
28
+ import hashlib
29
+
30
+
31
+ # Valid relationship types for episode connections
32
+ RELATIONSHIP_TYPES = frozenset([
33
+ "SOLVES", # This episode solves another (problem -> solution)
34
+ "CAUSES", # This episode caused another (action -> consequence)
35
+ "DEPENDS_ON", # This episode depends on another
36
+ "VALIDATES", # This episode validates another
37
+ "SUPERSEDES", # This episode replaces another
38
+ "RELATED_TO", # Generic relation
39
+ ])
40
+
41
+ # Valid outcome values
42
+ OUTCOME_VALUES = frozenset(["success", "partial", "failed", "abandoned"])
43
+
44
+
45
+ @dataclass
46
+ class Episode:
47
+ """Represents a single episodic memory entry."""
48
+ episode_id: str
49
+ timestamp: str
50
+ keywords: List[str]
51
+ prompt: str
52
+ clarifications: Dict[str, Any]
53
+ enriched_prompt: str
54
+ context: Dict[str, Any]
55
+ tags: Optional[List[str]] = None
56
+ type: Optional[str] = None
57
+ title: Optional[str] = None
58
+ relevance_score: float = 1.0
59
+ # P0: Outcome tracking fields
60
+ outcome: Optional[str] = None # "success", "partial", "failed", "abandoned"
61
+ success: Optional[bool] = None
62
+ duration_seconds: Optional[float] = None
63
+ commands_executed: Optional[List[str]] = None
64
+ # P1: Simple relationships
65
+ related_episodes: Optional[List[Dict[str, str]]] = None # [{"id": "ep_xxx", "type": "SOLVES"}]
66
+
67
+ def to_dict(self) -> Dict[str, Any]:
68
+ """Convert episode to dictionary."""
69
+ data = asdict(self)
70
+ return {k: v for k, v in data.items() if v is not None}
71
+
72
+
73
+ class EpisodicMemory:
74
+ """
75
+ Manages episodic memory storage and retrieval.
76
+
77
+ This class provides methods to:
78
+ - Store new episodes with automatic indexing
79
+ - Search episodes by keywords and context
80
+ - Maintain an efficient index for fast retrieval
81
+ - Auto-create required directory structures
82
+ - Track outcomes and relationships between episodes (P0/P1)
83
+ """
84
+
85
+ def __init__(self, base_path: Optional[Union[str, Path]] = None):
86
+ """
87
+ Initialize EpisodicMemory with specified or default path.
88
+
89
+ Args:
90
+ base_path: Base directory for episodic memory storage.
91
+ Defaults to .claude/project-context/episodic-memory/
92
+ """
93
+ if base_path:
94
+ self.base_path = Path(base_path)
95
+ else:
96
+ # Try to find the best location
97
+ candidates = [
98
+ Path(".claude/project-context/episodic-memory"),
99
+ ]
100
+
101
+ # Use first existing or first candidate
102
+ for path in candidates:
103
+ if path.parent.exists():
104
+ self.base_path = path
105
+ break
106
+ else:
107
+ self.base_path = candidates[0]
108
+
109
+ self.episodes_dir = self.base_path / "episodes"
110
+ self.index_file = self.base_path / "index.json"
111
+ self.episodes_jsonl = self.base_path / "episodes.jsonl"
112
+
113
+ # Auto-create directories
114
+ self._ensure_directories()
115
+
116
+ def _ensure_directories(self):
117
+ """Create required directories if they don't exist."""
118
+ self.base_path.mkdir(parents=True, exist_ok=True)
119
+ self.episodes_dir.mkdir(parents=True, exist_ok=True)
120
+
121
+ if not self.index_file.exists():
122
+ self._save_index({
123
+ "episodes": [],
124
+ "relationships": [], # P1: Track relationships in index
125
+ "metadata": {"created": datetime.now(timezone.utc).isoformat()}
126
+ })
127
+
128
+ def _save_index(self, index_data: Dict[str, Any]):
129
+ """Save index to JSON file."""
130
+ with open(self.index_file, 'w') as f:
131
+ json.dump(index_data, f, indent=2)
132
+
133
+ def _load_index(self) -> Dict[str, Any]:
134
+ """Load index from JSON file."""
135
+ if not self.index_file.exists():
136
+ return {"episodes": [], "relationships": [], "metadata": {}}
137
+
138
+ try:
139
+ with open(self.index_file, 'r') as f:
140
+ index = json.load(f)
141
+ # Ensure relationships key exists for backward compatibility
142
+ if "relationships" not in index:
143
+ index["relationships"] = []
144
+ return index
145
+ except (json.JSONDecodeError, IOError):
146
+ # Return empty index if file is corrupted
147
+ return {"episodes": [], "relationships": [], "metadata": {}}
148
+
149
+ def _extract_keywords(self, text: str) -> List[str]:
150
+ """
151
+ Extract keywords from text for indexing.
152
+
153
+ Uses simple tokenization and filtering. Can be enhanced with NLP.
154
+
155
+ Args:
156
+ text: Text to extract keywords from
157
+
158
+ Returns:
159
+ List of keywords
160
+ """
161
+ words = re.findall(r'\b[a-z]+\b', text.lower())
162
+
163
+ stopwords = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
164
+ 'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'been', 'be',
165
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'should',
166
+ 'could', 'may', 'might', 'can', 'must', 'shall', 'need', 'dare'}
167
+
168
+ keywords = [w for w in words if w not in stopwords and len(w) > 2]
169
+
170
+ seen = set()
171
+ unique_keywords = []
172
+ for kw in keywords:
173
+ if kw not in seen:
174
+ seen.add(kw)
175
+ unique_keywords.append(kw)
176
+
177
+ return unique_keywords[:20] # Limit to 20 keywords
178
+
179
+ def _generate_title(self, prompt: str) -> str:
180
+ """Generate a short title from the prompt."""
181
+ # Take first 60 characters or first sentence
182
+ title = prompt.split('.')[0] if '.' in prompt else prompt
183
+ return title[:60] + ('...' if len(title) > 60 else '')
184
+
185
+ def _determine_type(self, prompt: str, context: Dict[str, Any]) -> str:
186
+ """Determine episode type based on prompt and context."""
187
+ prompt_lower = prompt.lower()
188
+
189
+ # Check for common operation types
190
+ if any(word in prompt_lower for word in ['deploy', 'apply', 'push', 'release']):
191
+ return 'deployment'
192
+ elif any(word in prompt_lower for word in ['fix', 'error', 'issue', 'problem', 'debug']):
193
+ return 'troubleshooting'
194
+ elif any(word in prompt_lower for word in ['create', 'add', 'new', 'setup', 'init']):
195
+ return 'creation'
196
+ elif any(word in prompt_lower for word in ['update', 'modify', 'change', 'edit']):
197
+ return 'modification'
198
+ elif any(word in prompt_lower for word in ['check', 'verify', 'test', 'validate']):
199
+ return 'validation'
200
+ elif any(word in prompt_lower for word in ['delete', 'remove', 'clean']):
201
+ return 'deletion'
202
+ else:
203
+ return 'general'
204
+
205
+ def store_episode(
206
+ self,
207
+ prompt: str,
208
+ clarifications: Optional[Dict[str, Any]] = None,
209
+ enriched_prompt: Optional[str] = None,
210
+ context: Optional[Dict[str, Any]] = None,
211
+ tags: Optional[List[str]] = None,
212
+ episode_id: Optional[str] = None,
213
+ # P0: Outcome tracking parameters
214
+ outcome: Optional[str] = None,
215
+ success: Optional[bool] = None,
216
+ duration_seconds: Optional[float] = None,
217
+ commands_executed: Optional[List[str]] = None,
218
+ # P1: Relationship parameters
219
+ related_episodes: Optional[List[Dict[str, str]]] = None,
220
+ # P3: Workflow metric fields for CLI compatibility
221
+ workflow_metrics: Optional[Dict] = None
222
+ ) -> str:
223
+ """
224
+ Store a new episode in memory.
225
+
226
+ Args:
227
+ prompt: Original user prompt
228
+ clarifications: Any clarifications made during processing
229
+ enriched_prompt: Enriched version of the prompt
230
+ context: Additional context information
231
+ tags: Optional tags for categorization
232
+ episode_id: Optional specific ID (auto-generated if not provided)
233
+ outcome: Episode outcome ("success", "partial", "failed", "abandoned")
234
+ success: Boolean indicating if episode was successful
235
+ duration_seconds: How long the episode took to complete
236
+ commands_executed: List of commands executed during episode
237
+ related_episodes: List of related episode references [{"id": "ep_xxx", "type": "SOLVES"}]
238
+ workflow_metrics: Optional workflow metrics dict (agent, session_id, task_id, etc.)
239
+
240
+ Returns:
241
+ Episode ID
242
+ """
243
+ if not episode_id:
244
+ episode_id = f"ep_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
245
+
246
+ if outcome is not None and outcome not in OUTCOME_VALUES:
247
+ print(f"Warning: Invalid outcome '{outcome}'. Must be one of {OUTCOME_VALUES}", file=sys.stderr)
248
+ outcome = None
249
+
250
+ validated_relationships = None
251
+ if related_episodes:
252
+ validated_relationships = []
253
+ for rel in related_episodes:
254
+ if isinstance(rel, dict) and "id" in rel and "type" in rel:
255
+ if rel["type"] in RELATIONSHIP_TYPES:
256
+ validated_relationships.append({"id": rel["id"], "type": rel["type"]})
257
+ else:
258
+ print(f"Warning: Invalid relationship type '{rel['type']}'. Skipping.", file=sys.stderr)
259
+ if not validated_relationships:
260
+ validated_relationships = None
261
+
262
+ all_text = prompt
263
+ if enriched_prompt:
264
+ all_text += " " + enriched_prompt
265
+ keywords = self._extract_keywords(all_text)
266
+
267
+ if tags:
268
+ keywords = list(set(keywords + [t.lower() for t in tags]))
269
+
270
+ episode_type = self._determine_type(prompt, context or {})
271
+ title = self._generate_title(enriched_prompt or prompt)
272
+
273
+ episode = Episode(
274
+ episode_id=episode_id,
275
+ timestamp=datetime.now(timezone.utc).isoformat(),
276
+ keywords=keywords,
277
+ prompt=prompt,
278
+ clarifications=clarifications or {},
279
+ enriched_prompt=enriched_prompt or prompt,
280
+ context=context or {},
281
+ tags=tags,
282
+ type=episode_type,
283
+ title=title,
284
+ relevance_score=1.0,
285
+ # P0: Outcome fields
286
+ outcome=outcome,
287
+ success=success,
288
+ duration_seconds=duration_seconds,
289
+ commands_executed=commands_executed,
290
+ # P1: Relationships
291
+ related_episodes=validated_relationships
292
+ )
293
+
294
+ episode_file = self.episodes_dir / f"episode-{episode_id}.json"
295
+ with open(episode_file, 'w') as f:
296
+ json.dump(episode.to_dict(), f, indent=2)
297
+
298
+ # Append to JSONL file (enriched with workflow metrics for consistency)
299
+ jsonl_entry = episode.to_dict()
300
+ if workflow_metrics:
301
+ jsonl_entry["agent"] = workflow_metrics.get("agent", "")
302
+ jsonl_entry["session_id"] = workflow_metrics.get("session_id", "")
303
+ jsonl_entry["task_id"] = workflow_metrics.get("task_id", "")
304
+ jsonl_entry["exit_code"] = workflow_metrics.get("exit_code", 0)
305
+ jsonl_entry["plan_status"] = workflow_metrics.get("plan_status", "")
306
+ jsonl_entry["output_length"] = workflow_metrics.get("output_length", 0)
307
+ jsonl_entry["output_tokens_approx"] = workflow_metrics.get("output_tokens_approx", 0)
308
+ jsonl_entry["wf_prompt"] = workflow_metrics.get("prompt", "")
309
+ with open(self.episodes_jsonl, 'a') as f:
310
+ f.write(json.dumps(jsonl_entry) + '\n')
311
+
312
+ index = self._load_index()
313
+ index_entry = {
314
+ "id": episode_id,
315
+ "timestamp": episode.timestamp,
316
+ "keywords": keywords[:10], # Store limited keywords in index
317
+ "tags": tags or [],
318
+ "type": episode_type,
319
+ "title": title,
320
+ "relevance_score": 1.0,
321
+ # P0: Include outcome summary in index
322
+ "outcome": outcome,
323
+ "success": success,
324
+ # P1: Include relationship count in index
325
+ "relationship_count": len(validated_relationships) if validated_relationships else 0,
326
+ # P3: Workflow metric fields for CLI compatibility
327
+ "agent": (workflow_metrics or {}).get("agent", ""),
328
+ "session_id": (workflow_metrics or {}).get("session_id", ""),
329
+ "task_id": (workflow_metrics or {}).get("task_id", ""),
330
+ "exit_code": (workflow_metrics or {}).get("exit_code", 0),
331
+ "plan_status": (workflow_metrics or {}).get("plan_status", ""),
332
+ "output_length": (workflow_metrics or {}).get("output_length", 0),
333
+ "output_tokens_approx": (workflow_metrics or {}).get("output_tokens_approx", 0),
334
+ "prompt": (workflow_metrics or {}).get("prompt", ""),
335
+ }
336
+ index["episodes"].append(index_entry)
337
+
338
+ # P1: Add relationships to index for fast lookup
339
+ if validated_relationships:
340
+ for rel in validated_relationships:
341
+ index["relationships"].append({
342
+ "source": episode_id,
343
+ "target": rel["id"],
344
+ "type": rel["type"],
345
+ "timestamp": episode.timestamp
346
+ })
347
+
348
+ # Keep only last 1000 episodes in index (for performance)
349
+ if len(index["episodes"]) > 1000:
350
+ index["episodes"] = index["episodes"][-1000:]
351
+
352
+ # Keep only last 5000 relationships in index
353
+ if len(index["relationships"]) > 5000:
354
+ index["relationships"] = index["relationships"][-5000:]
355
+
356
+ # Ensure metadata exists
357
+ if "metadata" not in index:
358
+ index["metadata"] = {}
359
+ index["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
360
+ self._save_index(index)
361
+
362
+ print(f"Stored episode: {episode_id} with {len(keywords)} keywords", file=sys.stderr)
363
+
364
+ return episode_id
365
+
366
+ def update_outcome(
367
+ self,
368
+ episode_id: str,
369
+ outcome: str,
370
+ success: bool,
371
+ duration_seconds: Optional[float] = None,
372
+ commands_executed: Optional[List[str]] = None
373
+ ) -> bool:
374
+ """
375
+ Update the outcome of an existing episode.
376
+
377
+ Args:
378
+ episode_id: Episode ID to update
379
+ outcome: New outcome ("success", "partial", "failed", "abandoned")
380
+ success: Boolean indicating success
381
+ duration_seconds: Optional duration in seconds
382
+ commands_executed: Optional list of commands that were executed
383
+
384
+ Returns:
385
+ True if updated successfully, False if episode not found or invalid outcome
386
+ """
387
+ if outcome not in OUTCOME_VALUES:
388
+ print(f"Error: Invalid outcome '{outcome}'. Must be one of {OUTCOME_VALUES}", file=sys.stderr)
389
+ return False
390
+
391
+ episode_file = self.episodes_dir / f"episode-{episode_id}.json"
392
+ if not episode_file.exists():
393
+ print(f"Error: Episode {episode_id} not found", file=sys.stderr)
394
+ return False
395
+
396
+ try:
397
+ with open(episode_file, 'r') as f:
398
+ episode_data = json.load(f)
399
+
400
+ episode_data["outcome"] = outcome
401
+ episode_data["success"] = success
402
+ if duration_seconds is not None:
403
+ episode_data["duration_seconds"] = duration_seconds
404
+ if commands_executed is not None:
405
+ episode_data["commands_executed"] = commands_executed
406
+
407
+ with open(episode_file, 'w') as f:
408
+ json.dump(episode_data, f, indent=2)
409
+
410
+ # Append outcome update to JSONL (as a separate event for audit trail)
411
+ with open(self.episodes_jsonl, 'a') as f:
412
+ outcome_event = {
413
+ "event_type": "outcome_update",
414
+ "episode_id": episode_id,
415
+ "timestamp": datetime.now(timezone.utc).isoformat(),
416
+ "outcome": outcome,
417
+ "success": success,
418
+ "duration_seconds": duration_seconds,
419
+ "commands_executed": commands_executed
420
+ }
421
+ f.write(json.dumps(outcome_event) + '\n')
422
+
423
+ index = self._load_index()
424
+ for ep in index["episodes"]:
425
+ if ep.get("id") == episode_id:
426
+ ep["outcome"] = outcome
427
+ ep["success"] = success
428
+ break
429
+ index["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
430
+ self._save_index(index)
431
+
432
+ print(f"Updated outcome for episode {episode_id}: {outcome} (success={success})", file=sys.stderr)
433
+ return True
434
+
435
+ except (json.JSONDecodeError, IOError) as e:
436
+ print(f"Error updating episode {episode_id}: {e}", file=sys.stderr)
437
+ return False
438
+
439
+ def add_relationship(
440
+ self,
441
+ source_episode_id: str,
442
+ target_episode_id: str,
443
+ relationship_type: str
444
+ ) -> bool:
445
+ """
446
+ Add a relationship between two episodes.
447
+
448
+ Args:
449
+ source_episode_id: The source episode ID
450
+ target_episode_id: The target episode ID
451
+ relationship_type: Type of relationship (SOLVES, CAUSES, DEPENDS_ON, etc.)
452
+
453
+ Returns:
454
+ True if relationship added successfully, False otherwise
455
+ """
456
+ if relationship_type not in RELATIONSHIP_TYPES:
457
+ print(f"Error: Invalid relationship type '{relationship_type}'. Must be one of {RELATIONSHIP_TYPES}", file=sys.stderr)
458
+ return False
459
+
460
+ source_file = self.episodes_dir / f"episode-{source_episode_id}.json"
461
+ if not source_file.exists():
462
+ print(f"Error: Source episode {source_episode_id} not found", file=sys.stderr)
463
+ return False
464
+
465
+ # Check target episode exists (optional - might reference external or future episode)
466
+ target_file = self.episodes_dir / f"episode-{target_episode_id}.json"
467
+ target_exists = target_file.exists()
468
+
469
+ try:
470
+ with open(source_file, 'r') as f:
471
+ source_data = json.load(f)
472
+
473
+ if "related_episodes" not in source_data or source_data["related_episodes"] is None:
474
+ source_data["related_episodes"] = []
475
+
476
+ for rel in source_data["related_episodes"]:
477
+ if rel.get("id") == target_episode_id and rel.get("type") == relationship_type:
478
+ print(f"Relationship already exists: {source_episode_id} --{relationship_type}--> {target_episode_id}", file=sys.stderr)
479
+ return True # Not an error, just already exists
480
+
481
+ source_data["related_episodes"].append({
482
+ "id": target_episode_id,
483
+ "type": relationship_type
484
+ })
485
+
486
+ with open(source_file, 'w') as f:
487
+ json.dump(source_data, f, indent=2)
488
+
489
+ with open(self.episodes_jsonl, 'a') as f:
490
+ rel_event = {
491
+ "event_type": "relationship_added",
492
+ "timestamp": datetime.now(timezone.utc).isoformat(),
493
+ "source": source_episode_id,
494
+ "target": target_episode_id,
495
+ "type": relationship_type,
496
+ "target_exists": target_exists
497
+ }
498
+ f.write(json.dumps(rel_event) + '\n')
499
+
500
+ index = self._load_index()
501
+ index["relationships"].append({
502
+ "source": source_episode_id,
503
+ "target": target_episode_id,
504
+ "type": relationship_type,
505
+ "timestamp": datetime.now(timezone.utc).isoformat()
506
+ })
507
+
508
+ for ep in index["episodes"]:
509
+ if ep.get("id") == source_episode_id:
510
+ ep["relationship_count"] = ep.get("relationship_count", 0) + 1
511
+ break
512
+
513
+ index["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
514
+ self._save_index(index)
515
+
516
+ print(f"Added relationship: {source_episode_id} --{relationship_type}--> {target_episode_id}", file=sys.stderr)
517
+ return True
518
+
519
+ except (json.JSONDecodeError, IOError) as e:
520
+ print(f"Error adding relationship: {e}", file=sys.stderr)
521
+ return False
522
+
523
+ def get_related_episodes(
524
+ self,
525
+ episode_id: str,
526
+ relationship_type: Optional[str] = None,
527
+ direction: str = "outgoing"
528
+ ) -> List[Dict[str, Any]]:
529
+ """
530
+ Get episodes related to the given episode.
531
+
532
+ Args:
533
+ episode_id: The episode to find relationships for
534
+ relationship_type: Optional filter by relationship type
535
+ direction: "outgoing" (this episode points to), "incoming" (points to this), or "both"
536
+
537
+ Returns:
538
+ List of related episodes with relationship info
539
+ """
540
+ results = []
541
+ index = self._load_index()
542
+
543
+ for rel in index.get("relationships", []):
544
+ match = False
545
+
546
+ if direction in ("outgoing", "both") and rel.get("source") == episode_id:
547
+ match = True
548
+ related_id = rel.get("target")
549
+ rel_direction = "outgoing"
550
+ elif direction in ("incoming", "both") and rel.get("target") == episode_id:
551
+ match = True
552
+ related_id = rel.get("source")
553
+ rel_direction = "incoming"
554
+
555
+ if not match:
556
+ continue
557
+
558
+ if relationship_type and rel.get("type") != relationship_type:
559
+ continue
560
+
561
+ related_episode = self.get_episode(related_id)
562
+ if related_episode:
563
+ results.append({
564
+ "episode": related_episode,
565
+ "relationship_type": rel.get("type"),
566
+ "direction": rel_direction,
567
+ "relationship_timestamp": rel.get("timestamp")
568
+ })
569
+
570
+ return results
571
+
572
+ def search_episodes(
573
+ self,
574
+ query: str,
575
+ max_results: int = 5,
576
+ min_score: float = 0.1,
577
+ include_relationships: bool = False
578
+ ) -> List[Dict[str, Any]]:
579
+ """
580
+ Search for relevant episodes based on query.
581
+
582
+ Args:
583
+ query: Search query
584
+ max_results: Maximum number of results to return
585
+ min_score: Minimum relevance score threshold
586
+ include_relationships: If True, include related episode summaries in results
587
+
588
+ Returns:
589
+ List of relevant episodes with match scores
590
+ """
591
+ index = self._load_index()
592
+ if not index.get("episodes"):
593
+ return []
594
+
595
+ query_lower = query.lower()
596
+ query_words = set(query_lower.split())
597
+
598
+ scored_episodes = []
599
+
600
+ for episode_meta in index["episodes"]:
601
+ score = 0.0
602
+
603
+ # Tag matching (highest weight)
604
+ for tag in episode_meta.get("tags", []):
605
+ if tag.lower() in query_lower:
606
+ score += 0.4
607
+
608
+ # Keyword matching
609
+ episode_keywords = set(episode_meta.get("keywords", []))
610
+ common_keywords = query_words & episode_keywords
611
+ if common_keywords:
612
+ score += 0.3 * (len(common_keywords) / max(len(episode_keywords), 1))
613
+
614
+ # Title matching
615
+ title_words = set(episode_meta.get("title", "").lower().split())
616
+ common_title = query_words & title_words
617
+ if common_title:
618
+ score += 0.2 * (len(common_title) / max(len(title_words), 1))
619
+
620
+ # Type matching
621
+ if episode_meta.get("type", "") in query_lower:
622
+ score += 0.1
623
+
624
+ # P0: Boost successful episodes slightly
625
+ if episode_meta.get("success") is True:
626
+ score *= 1.1
627
+ elif episode_meta.get("success") is False:
628
+ # Don't penalize failed episodes - they're valuable for troubleshooting
629
+ pass
630
+
631
+ # Apply time decay
632
+ try:
633
+ episode_date = datetime.fromisoformat(episode_meta["timestamp"])
634
+ if episode_date.tzinfo is None:
635
+ episode_date = episode_date.replace(tzinfo=timezone.utc)
636
+ age_days = (datetime.now(timezone.utc) - episode_date).days
637
+
638
+ if age_days < 7:
639
+ time_factor = 1.0
640
+ elif age_days < 30:
641
+ time_factor = 0.9
642
+ elif age_days < 90:
643
+ time_factor = 0.7
644
+ elif age_days < 180:
645
+ time_factor = 0.5
646
+ else:
647
+ time_factor = 0.3
648
+ except:
649
+ time_factor = 0.5
650
+
651
+ final_score = score * time_factor * episode_meta.get("relevance_score", 1.0)
652
+
653
+ if final_score >= min_score:
654
+ # Load full episode if score meets threshold
655
+ full_episode = self.get_episode(episode_meta["id"])
656
+ if full_episode:
657
+ full_episode["match_score"] = final_score
658
+
659
+ # P1: Include relationship summaries if requested
660
+ if include_relationships:
661
+ relationships = self.get_related_episodes(episode_meta["id"], direction="both")
662
+ if relationships:
663
+ full_episode["related_episodes_summary"] = [
664
+ {
665
+ "id": r["episode"].get("episode_id", r["episode"].get("id")),
666
+ "title": r["episode"].get("title", "Untitled"),
667
+ "type": r["relationship_type"],
668
+ "direction": r["direction"],
669
+ "outcome": r["episode"].get("outcome")
670
+ }
671
+ for r in relationships[:5] # Limit to 5 related episodes
672
+ ]
673
+
674
+ scored_episodes.append(full_episode)
675
+
676
+ # Sort by score and return top N
677
+ scored_episodes.sort(key=lambda x: x["match_score"], reverse=True)
678
+ top_episodes = scored_episodes[:max_results]
679
+
680
+ if top_episodes:
681
+ print(f"Found {len(top_episodes)} relevant episodes from {len(index['episodes'])} total", file=sys.stderr)
682
+
683
+ return top_episodes
684
+
685
+ def get_episode(self, episode_id: str) -> Optional[Dict[str, Any]]:
686
+ """
687
+ Retrieve a specific episode by ID.
688
+
689
+ Args:
690
+ episode_id: Episode ID to retrieve
691
+
692
+ Returns:
693
+ Episode dict or None if not found
694
+ """
695
+ episode_file = self.episodes_dir / f"episode-{episode_id}.json"
696
+ if episode_file.exists():
697
+ try:
698
+ with open(episode_file, 'r') as f:
699
+ return json.load(f)
700
+ except (json.JSONDecodeError, IOError):
701
+ pass
702
+
703
+ if self.episodes_jsonl.exists():
704
+ try:
705
+ with open(self.episodes_jsonl, 'r') as f:
706
+ for line in f:
707
+ try:
708
+ episode = json.loads(line)
709
+ if episode.get("episode_id") == episode_id or episode.get("id") == episode_id:
710
+ return episode
711
+ except json.JSONDecodeError:
712
+ continue
713
+ except IOError:
714
+ pass
715
+
716
+ return None
717
+
718
+ def list_episodes(self, limit: int = 10, offset: int = 0) -> List[Dict[str, Any]]:
719
+ """
720
+ List episodes with pagination.
721
+
722
+ Args:
723
+ limit: Maximum number of episodes to return
724
+ offset: Number of episodes to skip
725
+
726
+ Returns:
727
+ List of episode metadata
728
+ """
729
+ index = self._load_index()
730
+ episodes = index.get("episodes", [])
731
+
732
+ episodes.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
733
+
734
+ return episodes[offset:offset + limit]
735
+
736
+ def delete_episode(self, episode_id: str) -> bool:
737
+ """
738
+ Delete an episode from memory.
739
+
740
+ Args:
741
+ episode_id: Episode ID to delete
742
+
743
+ Returns:
744
+ True if deleted, False if not found
745
+ """
746
+ deleted = False
747
+
748
+ episode_file = self.episodes_dir / f"episode-{episode_id}.json"
749
+ if episode_file.exists():
750
+ episode_file.unlink()
751
+ deleted = True
752
+
753
+ index = self._load_index()
754
+ original_count = len(index.get("episodes", []))
755
+ index["episodes"] = [ep for ep in index.get("episodes", [])
756
+ if ep.get("id") != episode_id]
757
+
758
+ # Also remove relationships involving this episode
759
+ index["relationships"] = [
760
+ rel for rel in index.get("relationships", [])
761
+ if rel.get("source") != episode_id and rel.get("target") != episode_id
762
+ ]
763
+
764
+ if len(index["episodes"]) < original_count:
765
+ self._save_index(index)
766
+ deleted = True
767
+
768
+ # Note: We don't remove from JSONL as it's append-only for audit trail
769
+
770
+ return deleted
771
+
772
+ def cleanup_old_episodes(self, days: int = 180) -> int:
773
+ """
774
+ Remove episodes older than specified days.
775
+
776
+ Args:
777
+ days: Age threshold in days
778
+
779
+ Returns:
780
+ Number of episodes deleted
781
+ """
782
+ cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
783
+ deleted_count = 0
784
+
785
+ index = self._load_index()
786
+ episodes_to_keep = []
787
+ deleted_ids = set()
788
+
789
+ for episode_meta in index.get("episodes", []):
790
+ try:
791
+ episode_date = datetime.fromisoformat(episode_meta["timestamp"])
792
+ if episode_date.tzinfo is None:
793
+ episode_date = episode_date.replace(tzinfo=timezone.utc)
794
+
795
+ if episode_date > cutoff_date:
796
+ episodes_to_keep.append(episode_meta)
797
+ else:
798
+ # Delete old episode file
799
+ episode_file = self.episodes_dir / f"episode-{episode_meta['id']}.json"
800
+ if episode_file.exists():
801
+ episode_file.unlink()
802
+ deleted_ids.add(episode_meta['id'])
803
+ deleted_count += 1
804
+ except:
805
+ # Keep episodes with invalid timestamps
806
+ episodes_to_keep.append(episode_meta)
807
+
808
+ if deleted_count > 0:
809
+ index["episodes"] = episodes_to_keep
810
+ # Also clean up relationships involving deleted episodes
811
+ index["relationships"] = [
812
+ rel for rel in index.get("relationships", [])
813
+ if rel.get("source") not in deleted_ids and rel.get("target") not in deleted_ids
814
+ ]
815
+ index["metadata"]["last_cleanup"] = datetime.now(timezone.utc).isoformat()
816
+ self._save_index(index)
817
+
818
+ print(f"Cleaned up {deleted_count} episodes older than {days} days", file=sys.stderr)
819
+
820
+ return deleted_count
821
+
822
+ def get_statistics(self) -> Dict[str, Any]:
823
+ """
824
+ Get statistics about the episodic memory.
825
+
826
+ Returns:
827
+ Dict with statistics including outcome and relationship stats
828
+ """
829
+ index = self._load_index()
830
+ episodes = index.get("episodes", [])
831
+
832
+ if not episodes:
833
+ return {
834
+ "total_episodes": 0,
835
+ "types": {},
836
+ "outcomes": {},
837
+ "relationships": {},
838
+ "recent_episodes": []
839
+ }
840
+
841
+ type_counts = {}
842
+ for ep in episodes:
843
+ ep_type = ep.get("type", "unknown")
844
+ type_counts[ep_type] = type_counts.get(ep_type, 0) + 1
845
+
846
+ # P0: Count by outcome
847
+ outcome_counts = {"success": 0, "partial": 0, "failed": 0, "abandoned": 0, "unknown": 0}
848
+ for ep in episodes:
849
+ outcome = ep.get("outcome", "unknown")
850
+ if outcome in outcome_counts:
851
+ outcome_counts[outcome] += 1
852
+ else:
853
+ outcome_counts["unknown"] += 1
854
+
855
+ # P1: Count relationships by type
856
+ relationship_counts = {}
857
+ for rel in index.get("relationships", []):
858
+ rel_type = rel.get("type", "unknown")
859
+ relationship_counts[rel_type] = relationship_counts.get(rel_type, 0) + 1
860
+
861
+ recent = sorted(episodes, key=lambda x: x.get("timestamp", ""), reverse=True)[:5]
862
+
863
+ ages = []
864
+ now = datetime.now(timezone.utc)
865
+ for ep in episodes:
866
+ try:
867
+ ep_date = datetime.fromisoformat(ep["timestamp"])
868
+ if ep_date.tzinfo is None:
869
+ ep_date = ep_date.replace(tzinfo=timezone.utc)
870
+ ages.append((now - ep_date).days)
871
+ except:
872
+ pass
873
+
874
+ stats = {
875
+ "total_episodes": len(episodes),
876
+ "types": type_counts,
877
+ "outcomes": outcome_counts,
878
+ "total_relationships": len(index.get("relationships", [])),
879
+ "relationship_types": relationship_counts,
880
+ "recent_episodes": recent,
881
+ "storage_size_mb": self._calculate_storage_size() / (1024 * 1024),
882
+ "index_size_kb": self.index_file.stat().st_size / 1024 if self.index_file.exists() else 0
883
+ }
884
+
885
+ if ages:
886
+ stats["age_stats"] = {
887
+ "newest_days": min(ages),
888
+ "oldest_days": max(ages),
889
+ "average_days": sum(ages) / len(ages)
890
+ }
891
+
892
+ return stats
893
+
894
+ def capture_git_state(self, repo_path: Optional[Union[str, Path]] = None) -> Dict[str, Any]:
895
+ """
896
+ Capture current git state as part of episode context.
897
+
898
+ Migrated from session system to provide git context for episodes.
899
+
900
+ Args:
901
+ repo_path: Path to git repository. Defaults to current working directory.
902
+
903
+ Returns:
904
+ Dict with git state including:
905
+ - branch: Current branch name
906
+ - commit: Current commit hash
907
+ - status: List of modified files
908
+ - recent_commits: Last 5 commits (hash, message, timestamp)
909
+ """
910
+ import subprocess
911
+
912
+ repo_path = Path(repo_path) if repo_path else Path.cwd()
913
+ git_state = {
914
+ "branch": None,
915
+ "commit": None,
916
+ "status": [],
917
+ "recent_commits": [],
918
+ "is_git_repo": False
919
+ }
920
+
921
+ try:
922
+ # Check if it is a git repo
923
+ result = subprocess.run(
924
+ ["git", "rev-parse", "--git-dir"],
925
+ cwd=repo_path,
926
+ capture_output=True,
927
+ text=True,
928
+ timeout=5
929
+ )
930
+ if result.returncode != 0:
931
+ return git_state
932
+
933
+ git_state["is_git_repo"] = True
934
+
935
+ # Get current branch
936
+ result = subprocess.run(
937
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
938
+ cwd=repo_path,
939
+ capture_output=True,
940
+ text=True,
941
+ timeout=5
942
+ )
943
+ if result.returncode == 0:
944
+ git_state["branch"] = result.stdout.strip()
945
+
946
+ # Get current commit
947
+ result = subprocess.run(
948
+ ["git", "rev-parse", "HEAD"],
949
+ cwd=repo_path,
950
+ capture_output=True,
951
+ text=True,
952
+ timeout=5
953
+ )
954
+ if result.returncode == 0:
955
+ git_state["commit"] = result.stdout.strip()[:12]
956
+
957
+ # Get status (modified files)
958
+ result = subprocess.run(
959
+ ["git", "status", "--porcelain"],
960
+ cwd=repo_path,
961
+ capture_output=True,
962
+ text=True,
963
+ timeout=10
964
+ )
965
+ if result.returncode == 0 and result.stdout.strip():
966
+ git_state["status"] = result.stdout.strip().split("\n")
967
+
968
+ # Get recent commits
969
+ result = subprocess.run(
970
+ ["git", "log", "--oneline", "-5", "--pretty=format:%H|%s|%ai"],
971
+ cwd=repo_path,
972
+ capture_output=True,
973
+ text=True,
974
+ timeout=10
975
+ )
976
+ if result.returncode == 0 and result.stdout.strip():
977
+ for line in result.stdout.strip().split("\n"):
978
+ if line and "|" in line:
979
+ parts = line.split("|")
980
+ if len(parts) >= 3:
981
+ git_state["recent_commits"].append({
982
+ "hash": parts[0][:12],
983
+ "message": parts[1],
984
+ "timestamp": parts[2]
985
+ })
986
+
987
+ except subprocess.TimeoutExpired:
988
+ print("Warning: Git command timed out", file=sys.stderr)
989
+ except Exception as e:
990
+ print(f"Warning: Could not capture git state: {e}", file=sys.stderr)
991
+
992
+ return git_state
993
+
994
+ def _calculate_storage_size(self) -> float:
995
+ """Calculate total storage size used by episodic memory."""
996
+ total_size = 0
997
+
998
+ if self.index_file.exists():
999
+ total_size += self.index_file.stat().st_size
1000
+
1001
+ if self.episodes_jsonl.exists():
1002
+ total_size += self.episodes_jsonl.stat().st_size
1003
+
1004
+ if self.episodes_dir.exists():
1005
+ for episode_file in self.episodes_dir.glob("episode-*.json"):
1006
+ total_size += episode_file.stat().st_size
1007
+
1008
+ return total_size
1009
+
1010
+
1011
+ # Compatibility function for direct use in workflow.py
1012
+ def search_episodic_memory(user_prompt: str, max_results: int = 3) -> List[Dict[str, Any]]:
1013
+ """
1014
+ Compatibility function for workflow.py integration.
1015
+
1016
+ This function can be imported and used directly without instantiating EpisodicMemory.
1017
+
1018
+ Args:
1019
+ user_prompt: User's request to search for
1020
+ max_results: Maximum episodes to return
1021
+
1022
+ Returns:
1023
+ List of relevant episodes with match scores
1024
+ """
1025
+ try:
1026
+ memory = EpisodicMemory()
1027
+ return memory.search_episodes(user_prompt, max_results)
1028
+ except Exception as e:
1029
+ print(f"Warning: Could not search episodic memory: {e}", file=sys.stderr)
1030
+ return []
1031
+
1032
+
1033
+ # CLI interface for testing and management
1034
+ if __name__ == "__main__":
1035
+ import argparse
1036
+
1037
+ parser = argparse.ArgumentParser(description="Episodic Memory Management")
1038
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
1039
+
1040
+ # Store command
1041
+ store_parser = subparsers.add_parser("store", help="Store a new episode")
1042
+ store_parser.add_argument("prompt", help="User prompt")
1043
+ store_parser.add_argument("--enriched", help="Enriched prompt")
1044
+ store_parser.add_argument("--tags", nargs="+", help="Tags")
1045
+ store_parser.add_argument("--outcome", choices=["success", "partial", "failed", "abandoned"], help="Episode outcome")
1046
+ store_parser.add_argument("--duration", type=float, help="Duration in seconds")
1047
+
1048
+ # Search command
1049
+ search_parser = subparsers.add_parser("search", help="Search episodes")
1050
+ search_parser.add_argument("query", help="Search query")
1051
+ search_parser.add_argument("--limit", type=int, default=5, help="Max results")
1052
+ search_parser.add_argument("--include-relationships", action="store_true", help="Include related episodes")
1053
+
1054
+ # List command
1055
+ list_parser = subparsers.add_parser("list", help="List recent episodes")
1056
+ list_parser.add_argument("--limit", type=int, default=10, help="Number to show")
1057
+
1058
+ # Stats command
1059
+ stats_parser = subparsers.add_parser("stats", help="Show statistics")
1060
+
1061
+ # Cleanup command
1062
+ cleanup_parser = subparsers.add_parser("cleanup", help="Clean old episodes")
1063
+ cleanup_parser.add_argument("--days", type=int, default=180, help="Days to keep")
1064
+
1065
+ # Update outcome command
1066
+ outcome_parser = subparsers.add_parser("update-outcome", help="Update episode outcome")
1067
+ outcome_parser.add_argument("episode_id", help="Episode ID")
1068
+ outcome_parser.add_argument("outcome", choices=["success", "partial", "failed", "abandoned"], help="Outcome")
1069
+ outcome_parser.add_argument("--duration", type=float, help="Duration in seconds")
1070
+
1071
+ # Add relationship command
1072
+ rel_parser = subparsers.add_parser("add-relationship", help="Add relationship between episodes")
1073
+ rel_parser.add_argument("source", help="Source episode ID")
1074
+ rel_parser.add_argument("target", help="Target episode ID")
1075
+ rel_parser.add_argument("type", choices=list(RELATIONSHIP_TYPES), help="Relationship type")
1076
+
1077
+ # Get related command
1078
+ related_parser = subparsers.add_parser("get-related", help="Get related episodes")
1079
+ related_parser.add_argument("episode_id", help="Episode ID")
1080
+ related_parser.add_argument("--type", help="Filter by relationship type")
1081
+ related_parser.add_argument("--direction", choices=["outgoing", "incoming", "both"], default="both", help="Direction")
1082
+
1083
+ args = parser.parse_args()
1084
+
1085
+ memory = EpisodicMemory()
1086
+
1087
+ if args.command == "store":
1088
+ episode_id = memory.store_episode(
1089
+ prompt=args.prompt,
1090
+ enriched_prompt=args.enriched,
1091
+ tags=args.tags,
1092
+ outcome=args.outcome,
1093
+ success=args.outcome == "success" if args.outcome else None,
1094
+ duration_seconds=args.duration
1095
+ )
1096
+ print(f"Stored episode: {episode_id}")
1097
+
1098
+ elif args.command == "search":
1099
+ episodes = memory.search_episodes(
1100
+ args.query,
1101
+ max_results=args.limit,
1102
+ include_relationships=args.include_relationships
1103
+ )
1104
+ for i, ep in enumerate(episodes, 1):
1105
+ print(f"\n{i}. [{ep.get('match_score', 0):.2f}] {ep.get('title', 'Untitled')}")
1106
+ print(f" ID: {ep.get('episode_id', ep.get('id'))}")
1107
+ print(f" Type: {ep.get('type', 'unknown')}")
1108
+ print(f" Outcome: {ep.get('outcome', 'unknown')}")
1109
+ print(f" Timestamp: {ep.get('timestamp', 'unknown')}")
1110
+ if ep.get('related_episodes_summary'):
1111
+ print(f" Related: {len(ep['related_episodes_summary'])} episodes")
1112
+
1113
+ elif args.command == "list":
1114
+ episodes = memory.list_episodes(limit=args.limit)
1115
+ for i, ep in enumerate(episodes, 1):
1116
+ print(f"\n{i}. {ep.get('title', 'Untitled')}")
1117
+ print(f" ID: {ep.get('id')}")
1118
+ print(f" Type: {ep.get('type', 'unknown')}")
1119
+ print(f" Outcome: {ep.get('outcome', 'unknown')}")
1120
+ print(f" Timestamp: {ep.get('timestamp', 'unknown')}")
1121
+
1122
+ elif args.command == "stats":
1123
+ stats = memory.get_statistics()
1124
+ print(f"\nEpisodic Memory Statistics:")
1125
+ print(f" Total episodes: {stats['total_episodes']}")
1126
+ print(f" Storage size: {stats['storage_size_mb']:.2f} MB")
1127
+ print(f" Index size: {stats['index_size_kb']:.2f} KB")
1128
+
1129
+ if stats.get("types"):
1130
+ print(f"\n Episode types:")
1131
+ for ep_type, count in stats["types"].items():
1132
+ print(f" {ep_type}: {count}")
1133
+
1134
+ if stats.get("outcomes"):
1135
+ print(f"\n Outcomes:")
1136
+ for outcome, count in stats["outcomes"].items():
1137
+ if count > 0:
1138
+ print(f" {outcome}: {count}")
1139
+
1140
+ if stats.get("total_relationships"):
1141
+ print(f"\n Relationships: {stats['total_relationships']} total")
1142
+ for rel_type, count in stats.get("relationship_types", {}).items():
1143
+ print(f" {rel_type}: {count}")
1144
+
1145
+ if stats.get("age_stats"):
1146
+ print(f"\n Age statistics:")
1147
+ print(f" Newest: {stats['age_stats']['newest_days']} days")
1148
+ print(f" Oldest: {stats['age_stats']['oldest_days']} days")
1149
+ print(f" Average: {stats['age_stats']['average_days']:.1f} days")
1150
+
1151
+ elif args.command == "cleanup":
1152
+ count = memory.cleanup_old_episodes(days=args.days)
1153
+ print(f"Cleaned up {count} episodes older than {args.days} days")
1154
+
1155
+ elif args.command == "update-outcome":
1156
+ success = memory.update_outcome(
1157
+ episode_id=args.episode_id,
1158
+ outcome=args.outcome,
1159
+ success=args.outcome == "success",
1160
+ duration_seconds=args.duration
1161
+ )
1162
+ if success:
1163
+ print(f"Updated outcome for {args.episode_id}")
1164
+ else:
1165
+ print(f"Failed to update outcome")
1166
+
1167
+ elif args.command == "add-relationship":
1168
+ success = memory.add_relationship(
1169
+ source_episode_id=args.source,
1170
+ target_episode_id=args.target,
1171
+ relationship_type=args.type
1172
+ )
1173
+ if success:
1174
+ print(f"Added relationship: {args.source} --{args.type}--> {args.target}")
1175
+ else:
1176
+ print(f"Failed to add relationship")
1177
+
1178
+ elif args.command == "get-related":
1179
+ related = memory.get_related_episodes(
1180
+ episode_id=args.episode_id,
1181
+ relationship_type=args.type,
1182
+ direction=args.direction
1183
+ )
1184
+ if related:
1185
+ print(f"\nRelated episodes for {args.episode_id}:")
1186
+ for rel in related:
1187
+ ep = rel["episode"]
1188
+ print(f"\n --{rel['relationship_type']}--> ({rel['direction']})")
1189
+ print(f" ID: {ep.get('episode_id', ep.get('id'))}")
1190
+ print(f" Title: {ep.get('title', 'Untitled')}")
1191
+ print(f" Outcome: {ep.get('outcome', 'unknown')}")
1192
+ else:
1193
+ print(f"No related episodes found for {args.episode_id}")
1194
+
1195
+ else:
1196
+ parser.print_help()