talkops-opscloud 0.3.0b1__py3-none-any.whl

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 (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
opscloud/mcp/trust.py ADDED
@@ -0,0 +1,105 @@
1
+ """SHA-256 trust store for project-level MCP configs.
2
+
3
+ Before loading a project-level ``.mcp.json``, the trust store checks
4
+ whether the file's SHA-256 fingerprint has been previously approved.
5
+ This prevents untrusted configs from spawning processes.
6
+
7
+ Usage::
8
+
9
+ from opscloud.mcp.trust import MCPTrustStore
10
+
11
+ trust = MCPTrustStore()
12
+ if trust.is_trusted(config_path):
13
+ # load and use
14
+ else:
15
+ # prompt user to trust
16
+ trust.trust(config_path)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ from pathlib import Path
24
+
25
+ from opscloud.config import paths
26
+ from opscloud.utils.logger import AgentLogger
27
+
28
+ logger = AgentLogger("MCPTrust")
29
+
30
+
31
+ class MCPTrustStore:
32
+ """SHA-256 trust gating for project-level MCP configuration files.
33
+
34
+ Trust state is persisted as a JSON file at
35
+ ``~/.opscloud/.state/mcp_trust.json``.
36
+ """
37
+
38
+ TRUST_FILE = paths.MCP_TRUST_PATH
39
+
40
+ def __init__(self, trust_file: Path | None = None) -> None:
41
+ """Initialize MCPTrustStore with trust persistence file.
42
+
43
+ Args:
44
+ trust_file: Optional explicit path to the JSON trust store file.
45
+ """
46
+ self._trust_file = trust_file or self.TRUST_FILE
47
+ self._trusted: dict[str, str] = self._load()
48
+
49
+ def _load(self) -> dict[str, str]:
50
+ """Load trusted fingerprints from disk."""
51
+ if self._trust_file.exists():
52
+ try:
53
+ data = json.loads(self._trust_file.read_text(encoding="utf-8"))
54
+ return data.get("trusted", {})
55
+ except Exception as e:
56
+ logger.warning(f"Failed to load MCP trust store at {self._trust_file}: {e}")
57
+ return {}
58
+
59
+ def _save(self) -> None:
60
+ """Persist trusted fingerprints to disk."""
61
+ self._trust_file.parent.mkdir(parents=True, exist_ok=True)
62
+ self._trust_file.write_text(
63
+ json.dumps({"trusted": self._trusted}, indent=2),
64
+ encoding="utf-8",
65
+ )
66
+
67
+ def _fingerprint(self, config_path: Path) -> str:
68
+ """Compute SHA-256 of a config file."""
69
+ return hashlib.sha256(config_path.read_bytes()).hexdigest()
70
+
71
+ def is_trusted(self, config_path: Path) -> bool:
72
+ """Check if a config file's current content is trusted.
73
+
74
+ Returns True if the file's SHA-256 matches a previously
75
+ trusted fingerprint.
76
+ """
77
+ if not config_path.exists():
78
+ return False
79
+
80
+ key = str(config_path.resolve())
81
+ current = self._fingerprint(config_path)
82
+ return self._trusted.get(key) == current
83
+
84
+ def trust(self, config_path: Path) -> None:
85
+ """Mark a config file's current content as trusted.
86
+
87
+ Stores the SHA-256 fingerprint so future ``is_trusted()``
88
+ checks pass.
89
+ """
90
+ if not config_path.exists():
91
+ logger.warning(f"Cannot trust non-existent file: {config_path}")
92
+ return
93
+
94
+ key = str(config_path.resolve())
95
+ self._trusted[key] = self._fingerprint(config_path)
96
+ self._save()
97
+ logger.info(f"Trusted MCP config: {config_path}")
98
+
99
+ def revoke(self, config_path: Path) -> None:
100
+ """Revoke trust for a config file."""
101
+ key = str(config_path.resolve())
102
+ if key in self._trusted:
103
+ del self._trusted[key]
104
+ self._save()
105
+ logger.info(f"Revoked trust for MCP config: {config_path}")
@@ -0,0 +1,27 @@
1
+ """Persistent memory, onboarding wizard, and storage for opscloud."""
2
+
3
+ from opscloud.memory.branch import BranchMemoryEntry, BranchMemoryStore
4
+ from opscloud.memory.guard import ManagedMemoryGuardMiddleware
5
+ from opscloud.memory.onboarding import (
6
+ ONBOARDING_NAME_MEMORY_END,
7
+ ONBOARDING_NAME_MEMORY_START,
8
+ extract_onboarding_name_block,
9
+ run_onboarding_if_needed,
10
+ strip_onboarding_name_markers,
11
+ )
12
+ from opscloud.memory.registry import MemoryRegistry
13
+ from opscloud.memory.store import MemoryEntry, MemoryStore
14
+
15
+ __all__ = [
16
+ "MemoryRegistry",
17
+ "ManagedMemoryGuardMiddleware",
18
+ "MemoryStore",
19
+ "MemoryEntry",
20
+ "BranchMemoryStore",
21
+ "BranchMemoryEntry",
22
+ "run_onboarding_if_needed",
23
+ "extract_onboarding_name_block",
24
+ "strip_onboarding_name_markers",
25
+ "ONBOARDING_NAME_MEMORY_START",
26
+ "ONBOARDING_NAME_MEMORY_END",
27
+ ]
@@ -0,0 +1,61 @@
1
+ """Subagent branch memory storage for isolated learning persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from opscloud.utils.logger import get_logger
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from opscloud.config.settings import settings
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BranchMemoryEntry:
18
+ run_id: str
19
+ subagent_name: str
20
+ content: str
21
+ path: Path
22
+ created_at: float
23
+
24
+
25
+ class BranchMemoryStore:
26
+ """Manages branch memory files under .opscloud/memories/{subagent_name}-{run_id}.md"""
27
+
28
+ def __init__(
29
+ self,
30
+ subagent_name: str,
31
+ *,
32
+ run_id: str | None = None,
33
+ project_root: Path | None = None,
34
+ ) -> None:
35
+ self.subagent_name = subagent_name
36
+ self.run_id = run_id or uuid.uuid4().hex[:8]
37
+ root = project_root or settings.project_root or Path.cwd()
38
+ self.memories_dir = root / ".opscloud" / "memories"
39
+ self.branch_file = self.memories_dir / f"{self.subagent_name}-{self.run_id}.md"
40
+
41
+ def write_observation(self, content: str) -> Path:
42
+ self.memories_dir.mkdir(parents=True, exist_ok=True)
43
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
44
+ entry_text = f"\n\n### [{timestamp}] Observation ({self.subagent_name})\n{content.strip()}"
45
+
46
+ if not self.branch_file.exists():
47
+ header = f"# Branch Memory: {self.subagent_name} (Run ID: {self.run_id})\nCreated at: {timestamp}"
48
+ self.branch_file.write_text(header + entry_text, encoding="utf-8")
49
+ else:
50
+ with self.branch_file.open("a", encoding="utf-8") as f:
51
+ f.write(entry_text)
52
+
53
+ return self.branch_file
54
+
55
+ def get_content(self) -> str:
56
+ if self.branch_file.is_file():
57
+ try:
58
+ return self.branch_file.read_text(encoding="utf-8")
59
+ except OSError:
60
+ return ""
61
+ return ""
@@ -0,0 +1,19 @@
1
+ """Protect machine-managed memory blocks from agent edits while allowing user memory updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from opscloud.middleware.memory_guard import (
6
+ ONBOARDING_NAME_MEMORY_END,
7
+ ONBOARDING_NAME_MEMORY_START,
8
+ ManagedMemoryGuardMiddleware,
9
+ extract_onboarding_name_block,
10
+ strip_onboarding_name_markers,
11
+ )
12
+
13
+ __all__ = [
14
+ "ManagedMemoryGuardMiddleware",
15
+ "ONBOARDING_NAME_MEMORY_START",
16
+ "ONBOARDING_NAME_MEMORY_END",
17
+ "extract_onboarding_name_block",
18
+ "strip_onboarding_name_markers",
19
+ ]
@@ -0,0 +1,120 @@
1
+ """First-run interactive onboarding wizard and memory preference management for opscloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ from opscloud.config.paths import DATA_DIR, ensure_agent_dir, user_agent_md
10
+ from opscloud.utils.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ ONBOARDING_NAME_MEMORY_START = "<!-- opscloud:onboarding-name:start -->"
15
+ ONBOARDING_NAME_MEMORY_END = "<!-- opscloud:onboarding-name:end -->"
16
+
17
+
18
+ def _normalize_memory_name(name: str) -> str:
19
+ return " ".join(name.split())
20
+
21
+
22
+ def _onboarding_name_memory_block(
23
+ name: str = "Cloud Platform & DevOps Engineer",
24
+ provider: str = "custom-model",
25
+ cloud: str = "AWS",
26
+ region: str = "us-east-1",
27
+ iac: str = "Terraform",
28
+ ) -> str:
29
+ """Format the machine-managed onboarding block with comment markers."""
30
+ return (
31
+ f"{ONBOARDING_NAME_MEMORY_START}\n"
32
+ f"- The user's role/name is \"{name}\".\n"
33
+ f"- Primary Cloud Provider: {cloud}\n"
34
+ f"- Default Region: {region}\n"
35
+ f"- IaC Tool Preference: {iac}\n"
36
+ f"- Primary Model Provider: {provider}\n"
37
+ f"{ONBOARDING_NAME_MEMORY_END}"
38
+ )
39
+
40
+
41
+ def _upsert_onboarding_name_memory(existing: str, block: str) -> str:
42
+ """Insert or replace the machine-managed onboarding block."""
43
+ start = existing.find(ONBOARDING_NAME_MEMORY_START)
44
+ end = existing.find(ONBOARDING_NAME_MEMORY_END)
45
+ if start != -1 and end != -1 and start < end:
46
+ end += len(ONBOARDING_NAME_MEMORY_END)
47
+ prefix = existing[:start].rstrip()
48
+ suffix = existing[end:].strip()
49
+ parts = [part for part in (prefix, block, suffix) if part]
50
+ return "\n\n".join(parts).rstrip() + "\n"
51
+
52
+ base = existing.rstrip()
53
+ if not base:
54
+ return f"## User Preferences & Defaults\n\n{block}\n"
55
+ if "## User Preferences" in base or "## User Preferences & Defaults" in base:
56
+ return f"{base}\n\n{block}\n"
57
+ return f"{base}\n\n## User Preferences & Defaults\n\n{block}\n"
58
+
59
+
60
+ def extract_onboarding_name_block(text: str) -> str | None:
61
+ """Extract the managed onboarding block (including markers) if present."""
62
+ start = text.find(ONBOARDING_NAME_MEMORY_START)
63
+ end = text.find(ONBOARDING_NAME_MEMORY_END)
64
+ if start == -1 or end == -1 or start >= end:
65
+ return None
66
+ return text[start : end + len(ONBOARDING_NAME_MEMORY_END)]
67
+
68
+
69
+ def strip_onboarding_name_markers(text: str) -> str:
70
+ """Remove onboarding markers from text while preserving inner content."""
71
+ return text.replace(ONBOARDING_NAME_MEMORY_START, "").replace(
72
+ ONBOARDING_NAME_MEMORY_END, ""
73
+ )
74
+
75
+
76
+ def run_onboarding_if_needed(agent_name: str = "opscloud") -> None:
77
+ """Run onboarding on interactive terminal if user AGENTS.md is missing or empty."""
78
+ user_md = user_agent_md(agent_name)
79
+ if user_md.is_file() and user_md.stat().st_size > 0:
80
+ return
81
+
82
+ # Check if standard input is interactive
83
+ if not sys.stdin.isatty():
84
+ logger.info("Non-interactive terminal detected; seeding default cloud preferences.")
85
+ block = _onboarding_name_memory_block()
86
+ try:
87
+ ensure_agent_dir(agent_name)
88
+ user_md.write_text(f"## User Preferences & Defaults\n\n{block}\n", encoding="utf-8")
89
+ except OSError as e:
90
+ logger.error("Failed to write default onboarding memory file: %s", e)
91
+ return
92
+
93
+ print("====================================================")
94
+ print(" Welcome to OpsCloud Setup & Preferences! ")
95
+ print("====================================================")
96
+ try:
97
+ name = input("Enter your preferred name or role (default: Cloud Platform & DevOps Engineer): ").strip() or "Cloud Platform & DevOps Engineer"
98
+ cloud = input("Primary Cloud Provider (AWS, GCP, Azure, Multi, default: AWS): ").strip() or "AWS"
99
+ region = input("Default Region (e.g. us-east-1, us-west-2, default: us-east-1): ").strip() or "us-east-1"
100
+ iac = input("IaC Tool Preference (Terraform, OpenTofu, Helm, None, default: Terraform): ").strip() or "Terraform"
101
+ provider = input("LLM Model / Provider (default: anthropic): ").strip() or "anthropic"
102
+ except (KeyboardInterrupt, EOFError):
103
+ print("\nOnboarding aborted. Using default configuration.")
104
+ name, cloud, region, iac, provider = (
105
+ "Cloud Platform & DevOps Engineer",
106
+ "AWS",
107
+ "us-east-1",
108
+ "Terraform",
109
+ "anthropic",
110
+ )
111
+
112
+ block = _onboarding_name_memory_block(
113
+ name=name, provider=provider, cloud=cloud, region=region, iac=iac
114
+ )
115
+ try:
116
+ ensure_agent_dir(agent_name)
117
+ user_md.write_text(f"## User Preferences & Defaults\n\n{block}\n", encoding="utf-8")
118
+ print(f"Setup complete! Preferences saved to {user_md}")
119
+ except OSError as e:
120
+ logger.error("Failed to write onboarding memory file: %s", e)
@@ -0,0 +1,134 @@
1
+ """Memory management and discovery across user and project scopes for opscloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import threading
7
+ from typing import Any
8
+
9
+ from langgraph.store.base import BaseStore
10
+
11
+ from opscloud.utils.logger import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class MemoryRegistry:
17
+ """Discovers and manages memory files across user and project scopes."""
18
+
19
+ _instance: MemoryRegistry | None = None
20
+ _lock = threading.Lock()
21
+
22
+ def __init__(self, store: BaseStore | None = None) -> None:
23
+ """Initialize MemoryRegistry.
24
+
25
+ Args:
26
+ store: Optional LangGraph BaseStore instance.
27
+ """
28
+ self._store = store
29
+
30
+ @classmethod
31
+ def get_instance(cls, store: BaseStore | None = None) -> MemoryRegistry:
32
+ """Retrieve the singleton instance of MemoryRegistry.
33
+
34
+ Args:
35
+ store: Optional store to configure if not already set.
36
+
37
+ Returns:
38
+ MemoryRegistry: The singleton registry instance.
39
+ """
40
+ if cls._instance is None:
41
+ with cls._lock:
42
+ if cls._instance is None:
43
+ cls._instance = cls(store=store)
44
+ elif store is not None and cls._instance._store is None:
45
+ cls._instance._store = store
46
+ return cls._instance
47
+
48
+ def get_memory_paths_for_scope(
49
+ self,
50
+ scope: str = "auto",
51
+ project_root: Path | None = None,
52
+ agent_id: str | None = None,
53
+ ) -> list[Path]:
54
+ """Resolve physical memory paths for a given scope ('user', 'project', 'auto')."""
55
+ paths: list[Path] = []
56
+ root = project_root or Path.cwd()
57
+
58
+ from opscloud.config import paths as app_paths
59
+
60
+ # User scope
61
+ if scope in ("user", "auto"):
62
+ primary_md = app_paths.ensure_user_agent_md(agent_id or app_paths.DEFAULT_AGENT_NAME)
63
+ paths.append(primary_md)
64
+ paths.append(app_paths.DATA_DIR / "AGENTS.md")
65
+ if agent_id and agent_id != app_paths.DEFAULT_AGENT_NAME:
66
+ paths.append(app_paths.user_agent_md(agent_id))
67
+
68
+ # Project scope
69
+ if scope in ("project", "auto"):
70
+ paths.append(app_paths.project_opscloud_dir(root) / "AGENTS.md")
71
+ paths.append(root / ".opscloud" / "AGENTS.md")
72
+ paths.append(root / "AGENTS.md")
73
+
74
+ resolved_paths: list[Path] = []
75
+ for p in paths:
76
+ try:
77
+ abs_p = p.expanduser().resolve()
78
+ if abs_p not in resolved_paths and abs_p.is_file():
79
+ resolved_paths.append(abs_p)
80
+ except Exception:
81
+ pass
82
+
83
+ return resolved_paths
84
+
85
+ def get_all_memory_sources(
86
+ self,
87
+ project_root: Path | None = None,
88
+ agent_id: str | None = None,
89
+ ) -> list[str]:
90
+ """Return a list of all existing AGENTS.md paths."""
91
+ if isinstance(project_root, str) and agent_id is None:
92
+ try:
93
+ p = Path(project_root)
94
+ if p.is_dir() or "/" in project_root or "\\" in project_root:
95
+ project_root = p
96
+ else:
97
+ agent_id = project_root
98
+ project_root = None
99
+ except Exception:
100
+ pass
101
+ paths = self.get_memory_paths_for_scope("auto", project_root=project_root, agent_id=agent_id)
102
+ return [str(p) for p in paths]
103
+
104
+ # ── Store-backed memory operations ─────────────────────────────────
105
+
106
+ async def save_memory(self, thread_id: str, key: str, value: str) -> None:
107
+ """Save a memory entry for a thread."""
108
+ if self._store:
109
+ await self._store.aput(
110
+ namespace=("memory", thread_id),
111
+ key=key,
112
+ value={"content": value},
113
+ )
114
+
115
+ async def get_memory(self, thread_id: str, key: str) -> str | None:
116
+ """Retrieve a memory entry."""
117
+ if self._store:
118
+ item = await self._store.aget(namespace=("memory", thread_id), key=key)
119
+ if item and isinstance(item.value, dict):
120
+ return item.value.get("content")
121
+ return None
122
+
123
+ async def list_memories(self, thread_id: str) -> list[dict[str, Any]]:
124
+ """List all memories for a thread."""
125
+ if self._store:
126
+ items = await self._store.asearch(("memory", thread_id))
127
+ return [
128
+ {
129
+ "key": item.key,
130
+ "content": (item.value.get("content", "") if isinstance(item.value, dict) else str(item.value)),
131
+ }
132
+ for item in items
133
+ ]
134
+ return []
@@ -0,0 +1,223 @@
1
+ """Memory storage — structured precedence-based memory management for opscloud.
2
+
3
+ Precedence for memory locations:
4
+ 1. **Project-scoped** (highest priority): ``{project_root}/.opscloud/memory/``
5
+ 2. **User-scoped** (fallback): ``~/.opscloud/memory/``
6
+
7
+ Each memory entry is a plain Markdown file. Memories are loaded at session start
8
+ and injected into the system prompt or queried via ``/memory`` commands.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ import logging
15
+ from pathlib import Path
16
+ import re
17
+ import time
18
+ from typing import Sequence
19
+
20
+ from opscloud.config.paths import DATA_DIR
21
+ from opscloud.utils.logger import get_logger
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ _MAX_MEMORY_LINE_COUNT = 250
26
+ """Hard cap on memory lines injected into context."""
27
+
28
+ _MAX_MEMORY_BYTES = 30_000
29
+ """Hard cap on total memory bytes injected into context."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class MemoryEntry:
34
+ """A single persisted memory entry."""
35
+
36
+ key: str
37
+ content: str
38
+ source: str # "project" | "user"
39
+ path: Path
40
+ modified_at: float
41
+
42
+
43
+ class MemoryStore:
44
+ """CRUD operations for Markdown-file-based persistent memories.
45
+
46
+ Precedence rules:
47
+ - Writes go to the project store if a project root is detected,
48
+ otherwise to the user store.
49
+ - Reads merge both stores, with project entries overriding user
50
+ entries of the same key.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ project_root: Path | None = None,
57
+ user_home: Path | None = None,
58
+ ) -> None:
59
+ self._project_dir = (
60
+ (project_root / ".opscloud" / "memory") if project_root else None
61
+ )
62
+ base_user = (user_home / ".opscloud") if user_home else DATA_DIR
63
+ self._user_dir = base_user / "memory"
64
+
65
+ # ── Write ────────────────────────────────────────────
66
+
67
+ def save(self, key: str, content: str, *, scope: str = "auto") -> Path:
68
+ """Persist a memory entry.
69
+
70
+ Args:
71
+ key: Filename stem (e.g. ``"prefer-terraform-fmt"``).
72
+ content: Markdown content to store.
73
+ scope: ``"project"``, ``"user"``, or ``"auto"`` (default).
74
+ Auto writes to project if available, else user.
75
+
76
+ Returns:
77
+ Path: Path to the written file.
78
+ """
79
+ target_dir = self._resolve_write_dir(scope)
80
+ target_dir.mkdir(parents=True, exist_ok=True)
81
+
82
+ safe_key = _sanitize_key(key)
83
+ target = target_dir / f"{safe_key}.md"
84
+ target.write_text(content, encoding="utf-8")
85
+ return target
86
+
87
+ def delete(self, key: str) -> bool:
88
+ """Delete a memory by key from both scopes.
89
+
90
+ Args:
91
+ key: Filename stem to delete.
92
+
93
+ Returns:
94
+ bool: True if at least one file was deleted, False otherwise.
95
+ """
96
+ safe_key = _sanitize_key(key)
97
+ deleted = False
98
+ for directory in self._all_dirs():
99
+ path = directory / f"{safe_key}.md"
100
+ if path.is_file():
101
+ try:
102
+ path.unlink()
103
+ deleted = True
104
+ except OSError as exc:
105
+ logger.warning("Failed to delete memory file %s: %s", path, exc)
106
+ return deleted
107
+
108
+ # ── Read ─────────────────────────────────────────────
109
+
110
+ def list_all(self) -> list[MemoryEntry]:
111
+ """List all memory entries, project overriding user for same key."""
112
+ entries: dict[str, MemoryEntry] = {}
113
+
114
+ # User first (lower priority)
115
+ for entry in self._scan_dir(self._user_dir, source="user"):
116
+ entries[entry.key] = entry
117
+
118
+ # Project second (higher priority, overwrites same key)
119
+ if self._project_dir:
120
+ for entry in self._scan_dir(self._project_dir, source="project"):
121
+ entries[entry.key] = entry
122
+
123
+ return sorted(entries.values(), key=lambda e: e.key)
124
+
125
+ def get(self, key: str) -> MemoryEntry | None:
126
+ """Get a single memory by key (project takes precedence)."""
127
+ safe_key = _sanitize_key(key)
128
+ # Check project first
129
+ if self._project_dir:
130
+ path = self._project_dir / f"{safe_key}.md"
131
+ if path.is_file():
132
+ return _read_entry(path, source="project")
133
+ # Then user
134
+ path = self._user_dir / f"{safe_key}.md"
135
+ if path.is_file():
136
+ return _read_entry(path, source="user")
137
+ return None
138
+
139
+ def load_context(self) -> str:
140
+ """Build the system-prompt memory injection string.
141
+
142
+ Returns:
143
+ str: Concatenated memory content capped at line/byte limits.
144
+ """
145
+ entries = self.list_all()
146
+ if not entries:
147
+ return ""
148
+
149
+ parts: list[str] = []
150
+ total_lines = 0
151
+ total_bytes = 0
152
+
153
+ for entry in entries:
154
+ lines = entry.content.splitlines()
155
+ if total_lines + len(lines) > _MAX_MEMORY_LINE_COUNT:
156
+ break
157
+ encoded = entry.content.encode("utf-8")
158
+ if total_bytes + len(encoded) > _MAX_MEMORY_BYTES:
159
+ break
160
+ parts.append(f"## Memory: {entry.key}\n{entry.content}")
161
+ total_lines += len(lines)
162
+ total_bytes += len(encoded)
163
+
164
+ if not parts:
165
+ return ""
166
+
167
+ return (
168
+ "# Persisted Memories\n"
169
+ "The following are learnings extracted from previous conversations.\n\n"
170
+ + "\n\n".join(parts)
171
+ )
172
+
173
+ # ── Private ──────────────────────────────────────────
174
+
175
+ def _resolve_write_dir(self, scope: str) -> Path:
176
+ if scope == "project" and self._project_dir:
177
+ return self._project_dir
178
+ if scope == "user":
179
+ return self._user_dir
180
+ if self._project_dir:
181
+ return self._project_dir
182
+ return self._user_dir
183
+
184
+ def _all_dirs(self) -> list[Path]:
185
+ dirs = [self._user_dir]
186
+ if self._project_dir:
187
+ dirs.append(self._project_dir)
188
+ return dirs
189
+
190
+ @staticmethod
191
+ def _scan_dir(directory: Path | None, *, source: str) -> list[MemoryEntry]:
192
+ if directory is None or not directory.is_dir():
193
+ return []
194
+ entries: list[MemoryEntry] = []
195
+ for path in sorted(directory.glob("*.md")):
196
+ entry = _read_entry(path, source=source)
197
+ if entry is not None:
198
+ entries.append(entry)
199
+ return entries
200
+
201
+
202
+ def _read_entry(path: Path, *, source: str) -> MemoryEntry | None:
203
+ try:
204
+ content = path.read_text(encoding="utf-8")
205
+ return MemoryEntry(
206
+ key=path.stem,
207
+ content=content,
208
+ source=source,
209
+ path=path,
210
+ modified_at=path.stat().st_mtime,
211
+ )
212
+ except (OSError, UnicodeDecodeError) as exc:
213
+ logger.warning("Could not read memory file %s: %s", path, exc)
214
+ return None
215
+
216
+
217
+ def _sanitize_key(key: str) -> str:
218
+ """Sanitize a memory key for use as a filename stem."""
219
+ key = key.strip().lower()
220
+ key = re.sub(r"[^\w\-]", "-", key)
221
+ key = re.sub(r"-{2,}", "-", key)
222
+ key = key.strip("-")
223
+ return key[:64] or "memory"