@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +12 -3
- package/ARCHITECTURE.md +9 -8
- package/CHANGELOG.md +34 -0
- package/README.md +43 -11
- package/agents/terraform-architect.md +1 -1
- package/bin/README.md +2 -2
- package/bin/gaia-doctor.js +18 -5
- package/bin/gaia-history.js +0 -1
- package/bin/gaia-metrics.js +2 -2
- package/bin/gaia-scan.py +23 -1
- package/bin/gaia-update.js +346 -54
- package/bin/pre-publish-validate.js +33 -10
- package/commands/gaia.md +37 -0
- package/config/README.md +3 -9
- package/config/context-contracts.json +47 -15
- package/config/surface-routing.json +9 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +22 -0
- package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
- package/dist/gaia-ops/agents/devops-developer.md +57 -0
- package/dist/gaia-ops/agents/gaia-system.md +58 -0
- package/dist/gaia-ops/agents/gitops-operator.md +60 -0
- package/dist/gaia-ops/agents/speckit-planner.md +71 -0
- package/dist/gaia-ops/agents/terraform-architect.md +60 -0
- package/dist/gaia-ops/commands/gaia.md +37 -0
- package/dist/gaia-ops/config/README.md +58 -0
- package/dist/gaia-ops/config/cloud/aws.json +140 -0
- package/dist/gaia-ops/config/cloud/gcp.json +145 -0
- package/dist/gaia-ops/config/context-contracts.json +131 -0
- package/dist/gaia-ops/config/git_standards.json +72 -0
- package/dist/gaia-ops/config/surface-routing.json +197 -0
- package/dist/gaia-ops/config/universal-rules.json +10 -0
- package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-ops/hooks/adapters/base.py +219 -0
- package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
- package/dist/gaia-ops/hooks/adapters/claude_code.py +1477 -0
- package/dist/gaia-ops/hooks/adapters/types.py +194 -0
- package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
- package/dist/gaia-ops/hooks/hooks.json +126 -0
- package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
- package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +124 -0
- package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +576 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +215 -0
- package/dist/gaia-ops/hooks/modules/context/context_cache.py +129 -0
- package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +427 -0
- package/dist/gaia-ops/hooks/modules/context/context_writer.py +518 -0
- package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +558 -0
- package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
- package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-ops/hooks/modules/identity/__init__.py +0 -0
- package/dist/gaia-ops/hooks/modules/identity/identity_provider.py +21 -0
- package/dist/gaia-ops/hooks/modules/identity/ops_identity.py +34 -0
- package/dist/gaia-ops/hooks/modules/identity/security_identity.py +10 -0
- package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +227 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-ops/hooks/modules/security/__init__.py +89 -0
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +912 -0
- package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +153 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +584 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +86 -0
- package/dist/gaia-ops/hooks/modules/security/command_semantics.py +130 -0
- package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +850 -0
- package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +158 -0
- package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-ops/hooks/modules/tools/__init__.py +25 -0
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +708 -0
- package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +181 -0
- package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-ops/hooks/modules/tools/task_validator.py +283 -0
- package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-ops/hooks/post_compact.py +43 -0
- package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
- package/dist/gaia-ops/hooks/pre_tool_use.py +383 -0
- package/dist/gaia-ops/hooks/session_start.py +69 -0
- package/dist/gaia-ops/hooks/stop_hook.py +69 -0
- package/dist/gaia-ops/hooks/subagent_start.py +71 -0
- package/dist/gaia-ops/hooks/subagent_stop.py +288 -0
- package/dist/gaia-ops/hooks/task_completed.py +70 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +177 -0
- package/dist/gaia-ops/settings.json +72 -0
- package/dist/gaia-ops/skills/README.md +109 -0
- package/dist/gaia-ops/skills/agent-protocol/SKILL.md +105 -0
- package/dist/gaia-ops/skills/agent-protocol/examples.md +170 -0
- package/dist/gaia-ops/skills/agent-response/SKILL.md +53 -0
- package/dist/gaia-ops/skills/approval/SKILL.md +85 -0
- package/dist/gaia-ops/skills/approval/examples.md +140 -0
- package/dist/gaia-ops/skills/approval/reference.md +57 -0
- package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
- package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
- package/dist/gaia-ops/skills/context-updater/SKILL.md +76 -0
- package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
- package/dist/gaia-ops/skills/developer-patterns/SKILL.md +93 -0
- package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
- package/dist/gaia-ops/skills/execution/SKILL.md +66 -0
- package/dist/gaia-ops/skills/fast-queries/SKILL.md +47 -0
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +92 -0
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +22 -0
- package/dist/gaia-ops/skills/git-conventions/SKILL.md +48 -0
- package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +73 -0
- package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
- package/dist/gaia-ops/skills/investigation/SKILL.md +77 -0
- package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +64 -0
- package/dist/gaia-ops/skills/reference.md +134 -0
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +61 -0
- package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
- package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
- package/dist/gaia-ops/skills/skill-creation/SKILL.md +119 -0
- package/dist/gaia-ops/skills/specification/SKILL.md +186 -0
- package/dist/gaia-ops/skills/speckit-workflow/SKILL.md +165 -0
- package/dist/gaia-ops/skills/speckit-workflow/reference.md +117 -0
- package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +63 -0
- package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
- package/dist/gaia-ops/speckit/README.md +516 -0
- package/dist/gaia-ops/speckit/scripts/.gitkeep +0 -0
- package/dist/gaia-ops/speckit/templates/adr-template.md +118 -0
- package/dist/gaia-ops/speckit/templates/agent-file-template.md +23 -0
- package/dist/gaia-ops/speckit/templates/plan-template.md +227 -0
- package/dist/gaia-ops/speckit/templates/spec-template.md +140 -0
- package/dist/gaia-ops/speckit/templates/tasks-template.md +257 -0
- package/dist/gaia-ops/tools/context/README.md +132 -0
- package/dist/gaia-ops/tools/context/__init__.py +42 -0
- package/dist/gaia-ops/tools/context/_paths.py +20 -0
- package/dist/gaia-ops/tools/context/context_provider.py +476 -0
- package/dist/gaia-ops/tools/context/context_section_reader.py +330 -0
- package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
- package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
- package/dist/gaia-ops/tools/context/surface_router.py +278 -0
- package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
- package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
- package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
- package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
- package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
- package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
- package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
- package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
- package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
- package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
- package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
- package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
- package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +262 -0
- package/dist/gaia-ops/tools/memory/README.md +0 -0
- package/dist/gaia-ops/tools/memory/__init__.py +20 -0
- package/dist/gaia-ops/tools/memory/episodic.py +1196 -0
- package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
- package/dist/gaia-ops/tools/review/__init__.py +1 -0
- package/dist/gaia-ops/tools/review/review_engine.py +157 -0
- package/dist/gaia-ops/tools/scan/__init__.py +35 -0
- package/dist/gaia-ops/tools/scan/config.py +247 -0
- package/dist/gaia-ops/tools/scan/merge.py +212 -0
- package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
- package/dist/gaia-ops/tools/scan/registry.py +127 -0
- package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
- package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
- package/dist/gaia-ops/tools/scan/scanners/environment.py +324 -0
- package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
- package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
- package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
- package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
- package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
- package/dist/gaia-ops/tools/scan/setup.py +753 -0
- package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
- package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
- package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
- package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
- package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
- package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
- package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
- package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
- package/dist/gaia-ops/tools/scan/ui.py +624 -0
- package/dist/gaia-ops/tools/scan/verify.py +266 -0
- package/dist/gaia-ops/tools/scan/walk.py +118 -0
- package/dist/gaia-ops/tools/scan/workspace.py +85 -0
- package/dist/gaia-ops/tools/validation/README.md +244 -0
- package/dist/gaia-ops/tools/validation/__init__.py +17 -0
- package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
- package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
- package/dist/gaia-security/.claude-plugin/plugin.json +22 -0
- package/dist/gaia-security/config/universal-rules.json +10 -0
- package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-security/hooks/adapters/base.py +219 -0
- package/dist/gaia-security/hooks/adapters/channel.py +17 -0
- package/dist/gaia-security/hooks/adapters/claude_code.py +1477 -0
- package/dist/gaia-security/hooks/adapters/types.py +194 -0
- package/dist/gaia-security/hooks/adapters/utils.py +25 -0
- package/dist/gaia-security/hooks/hooks.json +57 -0
- package/dist/gaia-security/hooks/modules/__init__.py +15 -0
- package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +124 -0
- package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +576 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +215 -0
- package/dist/gaia-security/hooks/modules/context/context_cache.py +129 -0
- package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-security/hooks/modules/context/context_injector.py +427 -0
- package/dist/gaia-security/hooks/modules/context/context_writer.py +518 -0
- package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-security/hooks/modules/core/plugin_setup.py +558 -0
- package/dist/gaia-security/hooks/modules/core/state.py +179 -0
- package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-security/hooks/modules/identity/__init__.py +0 -0
- package/dist/gaia-security/hooks/modules/identity/identity_provider.py +21 -0
- package/dist/gaia-security/hooks/modules/identity/ops_identity.py +34 -0
- package/dist/gaia-security/hooks/modules/identity/security_identity.py +10 -0
- package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/memory/episode_writer.py +227 -0
- package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-security/hooks/modules/security/__init__.py +89 -0
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +912 -0
- package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-security/hooks/modules/security/approval_scopes.py +153 -0
- package/dist/gaia-security/hooks/modules/security/blocked_commands.py +584 -0
- package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +86 -0
- package/dist/gaia-security/hooks/modules/security/command_semantics.py +130 -0
- package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +850 -0
- package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-security/hooks/modules/session/session_event_injector.py +158 -0
- package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-security/hooks/modules/tools/__init__.py +25 -0
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +708 -0
- package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +181 -0
- package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-security/hooks/modules/tools/task_validator.py +283 -0
- package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-security/hooks/post_tool_use.py +54 -0
- package/dist/gaia-security/hooks/pre_tool_use.py +383 -0
- package/dist/gaia-security/hooks/session_start.py +69 -0
- package/dist/gaia-security/hooks/stop_hook.py +69 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +177 -0
- package/dist/gaia-security/settings.json +58 -0
- package/git-hooks/commit-msg +41 -0
- package/hooks/README.md +8 -6
- package/hooks/adapters/channel.py +0 -25
- package/hooks/adapters/claude_code.py +364 -125
- package/hooks/elicitation_result.py +132 -0
- package/hooks/hooks.json +10 -1
- package/hooks/modules/README.md +3 -2
- package/hooks/modules/agents/contract_validator.py +3 -51
- package/hooks/modules/agents/response_contract.py +4 -8
- package/hooks/modules/agents/transcript_reader.py +4 -5
- package/hooks/modules/audit/__init__.py +4 -6
- package/hooks/modules/audit/event_detector.py +0 -2
- package/hooks/modules/audit/metrics.py +108 -187
- package/hooks/modules/audit/workflow_auditor.py +0 -4
- package/hooks/modules/audit/workflow_recorder.py +0 -5
- package/hooks/modules/context/compact_context_builder.py +1 -0
- package/hooks/modules/context/context_cache.py +129 -0
- package/hooks/modules/context/context_injector.py +18 -40
- package/hooks/modules/context/context_writer.py +1 -25
- package/hooks/modules/context/contracts_loader.py +7 -10
- package/hooks/modules/core/hook_entry.py +1 -0
- package/hooks/modules/core/paths.py +12 -13
- package/hooks/modules/core/plugin_mode.py +74 -4
- package/hooks/modules/core/plugin_setup.py +395 -23
- package/hooks/modules/events/__init__.py +1 -0
- package/hooks/modules/events/event_writer.py +210 -0
- package/hooks/modules/identity/ops_identity.py +18 -27
- package/hooks/modules/memory/episode_writer.py +1 -6
- package/hooks/modules/orchestrator/__init__.py +1 -0
- package/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/hooks/modules/security/__init__.py +2 -4
- package/hooks/modules/security/approval_constants.py +5 -1
- package/hooks/modules/security/approval_grants.py +189 -6
- package/hooks/modules/security/approval_messages.py +9 -21
- package/hooks/modules/security/blocked_commands.py +98 -34
- package/hooks/modules/security/command_semantics.py +0 -4
- package/hooks/modules/security/gitops_validator.py +1 -11
- package/hooks/modules/security/mutative_verbs.py +179 -38
- package/hooks/modules/security/tiers.py +1 -19
- package/hooks/modules/session/session_event_injector.py +1 -25
- package/hooks/modules/tools/bash_validator.py +310 -94
- package/hooks/modules/tools/shell_parser.py +0 -1
- package/hooks/modules/tools/task_validator.py +9 -29
- package/hooks/post_tool_use.py +0 -72
- package/hooks/pre_tool_use.py +42 -102
- package/hooks/session_start.py +4 -2
- package/hooks/subagent_start.py +6 -2
- package/hooks/subagent_stop.py +1 -13
- package/hooks/user_prompt_submit.py +119 -37
- package/index.js +1 -1
- package/package.json +5 -3
- package/skills/README.md +3 -5
- package/skills/agent-protocol/SKILL.md +17 -16
- package/skills/agent-protocol/examples.md +6 -6
- package/skills/agent-response/SKILL.md +11 -14
- package/skills/approval/SKILL.md +28 -13
- package/skills/approval/reference.md +2 -2
- package/skills/execution/SKILL.md +1 -1
- package/skills/gaia-patterns/SKILL.md +2 -3
- package/skills/orchestrator-approval/SKILL.md +22 -50
- package/skills/security-tiers/SKILL.md +1 -1
- package/templates/README.md +9 -9
- package/templates/managed-settings.template.json +43 -0
- package/tools/gaia_simulator/runner.py +34 -1
- package/tools/scan/orchestrator.py +13 -0
- package/tools/scan/scanners/base.py +8 -0
- package/tools/scan/scanners/git.py +78 -0
- package/tools/scan/scanners/infrastructure.py +65 -0
- package/tools/scan/scanners/stack.py +110 -0
- package/tools/scan/setup.py +120 -13
- package/tools/scan/workspace.py +85 -0
- package/config/context-contracts.aws.json +0 -42
- package/config/context-contracts.gcp.json +0 -39
- package/skills/project-dispatch/SKILL.md +0 -34
- 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()
|