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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- 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 ""
|
opscloud/memory/guard.py
ADDED
|
@@ -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 []
|
opscloud/memory/store.py
ADDED
|
@@ -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"
|