gobby 0.2.5__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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import sqlite3
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from gobby.memory.protocol import MediaAttachment
|
|
10
|
+
from gobby.storage.database import DatabaseProtocol
|
|
11
|
+
from gobby.utils.id import generate_prefixed_id
|
|
12
|
+
|
|
13
|
+
# Re-export MediaAttachment for consumers that import from this module
|
|
14
|
+
__all__ = ["Memory", "MemoryCrossRef", "LocalMemoryManager", "MediaAttachment"]
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Sentinel for distinguishing "not provided" from explicit None
|
|
19
|
+
_UNSET: Any = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MemoryCrossRef:
|
|
24
|
+
"""A link between two related memories with a similarity score."""
|
|
25
|
+
|
|
26
|
+
source_id: str
|
|
27
|
+
target_id: str
|
|
28
|
+
similarity: float
|
|
29
|
+
created_at: str
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_row(cls, row: sqlite3.Row) -> "MemoryCrossRef":
|
|
33
|
+
return cls(
|
|
34
|
+
source_id=row["source_id"],
|
|
35
|
+
target_id=row["target_id"],
|
|
36
|
+
similarity=row["similarity"],
|
|
37
|
+
created_at=row["created_at"],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"source_id": self.source_id,
|
|
43
|
+
"target_id": self.target_id,
|
|
44
|
+
"similarity": self.similarity,
|
|
45
|
+
"created_at": self.created_at,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Memory:
|
|
51
|
+
id: str
|
|
52
|
+
memory_type: Literal["fact", "preference", "pattern", "context"]
|
|
53
|
+
content: str
|
|
54
|
+
created_at: str
|
|
55
|
+
updated_at: str
|
|
56
|
+
project_id: str | None = None
|
|
57
|
+
source_type: Literal["user", "session", "inferred"] | None = None
|
|
58
|
+
source_session_id: str | None = None
|
|
59
|
+
importance: float = 0.5
|
|
60
|
+
access_count: int = 0
|
|
61
|
+
last_accessed_at: str | None = None
|
|
62
|
+
tags: list[str] | None = None
|
|
63
|
+
media: str | None = None # JSON-serialized MediaAttachment data
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_row(cls, row: sqlite3.Row) -> "Memory":
|
|
67
|
+
tags_json = row["tags"]
|
|
68
|
+
tags = json.loads(tags_json) if tags_json else []
|
|
69
|
+
|
|
70
|
+
# Coerce importance to float (handle legacy string values like "high")
|
|
71
|
+
importance_raw = row["importance"]
|
|
72
|
+
if isinstance(importance_raw, str):
|
|
73
|
+
importance_map = {"high": 0.9, "medium": 0.5, "low": 0.3}
|
|
74
|
+
importance = importance_map.get(importance_raw.lower(), 0.5)
|
|
75
|
+
else:
|
|
76
|
+
importance = float(importance_raw) if importance_raw is not None else 0.5
|
|
77
|
+
|
|
78
|
+
# Handle media column (may not exist in older databases)
|
|
79
|
+
media = row["media"] if "media" in row.keys() else None
|
|
80
|
+
|
|
81
|
+
return cls(
|
|
82
|
+
id=row["id"],
|
|
83
|
+
memory_type=row["memory_type"],
|
|
84
|
+
content=row["content"],
|
|
85
|
+
created_at=row["created_at"],
|
|
86
|
+
updated_at=row["updated_at"],
|
|
87
|
+
project_id=row["project_id"],
|
|
88
|
+
source_type=row["source_type"],
|
|
89
|
+
source_session_id=row["source_session_id"],
|
|
90
|
+
importance=importance,
|
|
91
|
+
access_count=row["access_count"],
|
|
92
|
+
last_accessed_at=row["last_accessed_at"],
|
|
93
|
+
tags=tags,
|
|
94
|
+
media=media,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"id": self.id,
|
|
100
|
+
"memory_type": self.memory_type,
|
|
101
|
+
"content": self.content,
|
|
102
|
+
"created_at": self.created_at,
|
|
103
|
+
"updated_at": self.updated_at,
|
|
104
|
+
"project_id": self.project_id,
|
|
105
|
+
"source_type": self.source_type,
|
|
106
|
+
"source_session_id": self.source_session_id,
|
|
107
|
+
"importance": self.importance,
|
|
108
|
+
"access_count": self.access_count,
|
|
109
|
+
"last_accessed_at": self.last_accessed_at,
|
|
110
|
+
"tags": self.tags,
|
|
111
|
+
"media": self.media,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class LocalMemoryManager:
|
|
116
|
+
def __init__(self, db: DatabaseProtocol):
|
|
117
|
+
self.db = db
|
|
118
|
+
self._change_listeners: list[Callable[[], Any]] = []
|
|
119
|
+
|
|
120
|
+
def add_change_listener(self, listener: Callable[[], Any]) -> None:
|
|
121
|
+
self._change_listeners.append(listener)
|
|
122
|
+
|
|
123
|
+
def _notify_listeners(self) -> None:
|
|
124
|
+
for listener in self._change_listeners:
|
|
125
|
+
try:
|
|
126
|
+
listener()
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"Error in memory change listener: {e}")
|
|
129
|
+
|
|
130
|
+
def create_memory(
|
|
131
|
+
self,
|
|
132
|
+
content: str,
|
|
133
|
+
memory_type: str = "fact",
|
|
134
|
+
project_id: str | None = None,
|
|
135
|
+
source_type: str = "user",
|
|
136
|
+
source_session_id: str | None = None,
|
|
137
|
+
importance: float = 0.5,
|
|
138
|
+
tags: list[str] | None = None,
|
|
139
|
+
media: str | None = None,
|
|
140
|
+
) -> Memory:
|
|
141
|
+
now = datetime.now(UTC).isoformat()
|
|
142
|
+
# Ensure consistent ID for same content/project to avoid dupes?
|
|
143
|
+
# Actually random/content-based might be better. Let's use content.
|
|
144
|
+
memory_id = generate_prefixed_id("mm", content + str(project_id))
|
|
145
|
+
|
|
146
|
+
# Check if memory already exists to avoid duplicate insert errors
|
|
147
|
+
existing_row = self.db.fetchone("SELECT * FROM memories WHERE id = ?", (memory_id,))
|
|
148
|
+
if existing_row:
|
|
149
|
+
return self.get_memory(memory_id)
|
|
150
|
+
|
|
151
|
+
tags_json = json.dumps(tags) if tags else None
|
|
152
|
+
|
|
153
|
+
with self.db.transaction() as conn:
|
|
154
|
+
conn.execute(
|
|
155
|
+
"""
|
|
156
|
+
INSERT INTO memories (
|
|
157
|
+
id, project_id, memory_type, content, source_type,
|
|
158
|
+
source_session_id, importance, access_count, tags,
|
|
159
|
+
media, created_at, updated_at
|
|
160
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
|
161
|
+
""",
|
|
162
|
+
(
|
|
163
|
+
memory_id,
|
|
164
|
+
project_id,
|
|
165
|
+
memory_type,
|
|
166
|
+
content,
|
|
167
|
+
source_type,
|
|
168
|
+
source_session_id,
|
|
169
|
+
importance,
|
|
170
|
+
tags_json,
|
|
171
|
+
media,
|
|
172
|
+
now,
|
|
173
|
+
now,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
self._notify_listeners()
|
|
178
|
+
return self.get_memory(memory_id)
|
|
179
|
+
|
|
180
|
+
def get_memory(self, memory_id: str) -> Memory:
|
|
181
|
+
row = self.db.fetchone("SELECT * FROM memories WHERE id = ?", (memory_id,))
|
|
182
|
+
if not row:
|
|
183
|
+
raise ValueError(f"Memory {memory_id} not found")
|
|
184
|
+
return Memory.from_row(row)
|
|
185
|
+
|
|
186
|
+
def memory_exists(self, memory_id: str) -> bool:
|
|
187
|
+
"""Check if a memory with the given ID exists."""
|
|
188
|
+
row = self.db.fetchone("SELECT 1 FROM memories WHERE id = ?", (memory_id,))
|
|
189
|
+
return row is not None
|
|
190
|
+
|
|
191
|
+
def content_exists(self, content: str, project_id: str | None = None) -> bool:
|
|
192
|
+
"""Check if a memory with identical content already exists."""
|
|
193
|
+
if project_id:
|
|
194
|
+
row = self.db.fetchone(
|
|
195
|
+
"SELECT 1 FROM memories WHERE content = ? AND project_id = ?",
|
|
196
|
+
(content, project_id),
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
row = self.db.fetchone(
|
|
200
|
+
"SELECT 1 FROM memories WHERE content = ? AND project_id IS NULL",
|
|
201
|
+
(content,),
|
|
202
|
+
)
|
|
203
|
+
return row is not None
|
|
204
|
+
|
|
205
|
+
def update_memory(
|
|
206
|
+
self,
|
|
207
|
+
memory_id: str,
|
|
208
|
+
content: str | None = None,
|
|
209
|
+
importance: float | None = None,
|
|
210
|
+
tags: list[str] | None = None,
|
|
211
|
+
media: Any = _UNSET, # Use sentinel to distinguish None from not-provided
|
|
212
|
+
) -> Memory:
|
|
213
|
+
updates = []
|
|
214
|
+
params: list[Any] = []
|
|
215
|
+
|
|
216
|
+
if content is not None:
|
|
217
|
+
updates.append("content = ?")
|
|
218
|
+
params.append(content)
|
|
219
|
+
if importance is not None:
|
|
220
|
+
updates.append("importance = ?")
|
|
221
|
+
params.append(importance)
|
|
222
|
+
if tags is not None:
|
|
223
|
+
updates.append("tags = ?")
|
|
224
|
+
params.append(json.dumps(tags))
|
|
225
|
+
if media is not _UNSET: # Allow explicit None to clear media
|
|
226
|
+
updates.append("media = ?")
|
|
227
|
+
params.append(media)
|
|
228
|
+
|
|
229
|
+
if not updates:
|
|
230
|
+
return self.get_memory(memory_id)
|
|
231
|
+
|
|
232
|
+
updates.append("updated_at = ?")
|
|
233
|
+
params.append(datetime.now(UTC).isoformat())
|
|
234
|
+
params.append(memory_id)
|
|
235
|
+
|
|
236
|
+
# nosec B608: SET clause built from hardcoded column names, values parameterized
|
|
237
|
+
sql = f"UPDATE memories SET {', '.join(updates)} WHERE id = ?" # nosec B608
|
|
238
|
+
|
|
239
|
+
with self.db.transaction() as conn:
|
|
240
|
+
cursor = conn.execute(sql, tuple(params))
|
|
241
|
+
if cursor.rowcount == 0:
|
|
242
|
+
raise ValueError(f"Memory {memory_id} not found")
|
|
243
|
+
|
|
244
|
+
self._notify_listeners()
|
|
245
|
+
return self.get_memory(memory_id)
|
|
246
|
+
|
|
247
|
+
def delete_memory(self, memory_id: str) -> bool:
|
|
248
|
+
with self.db.transaction() as conn:
|
|
249
|
+
cursor = conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
|
|
250
|
+
if cursor.rowcount == 0:
|
|
251
|
+
return False
|
|
252
|
+
self._notify_listeners()
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
def list_memories(
|
|
256
|
+
self,
|
|
257
|
+
project_id: str | None = None,
|
|
258
|
+
memory_type: str | None = None,
|
|
259
|
+
min_importance: float | None = None,
|
|
260
|
+
limit: int = 50,
|
|
261
|
+
offset: int = 0,
|
|
262
|
+
tags_all: list[str] | None = None,
|
|
263
|
+
tags_any: list[str] | None = None,
|
|
264
|
+
tags_none: list[str] | None = None,
|
|
265
|
+
) -> list[Memory]:
|
|
266
|
+
"""
|
|
267
|
+
List memories with optional filtering.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
project_id: Filter by project ID (or None for global)
|
|
271
|
+
memory_type: Filter by memory type
|
|
272
|
+
min_importance: Minimum importance threshold
|
|
273
|
+
limit: Maximum number of results
|
|
274
|
+
offset: Number of results to skip
|
|
275
|
+
tags_all: Memory must have ALL of these tags
|
|
276
|
+
tags_any: Memory must have at least ONE of these tags
|
|
277
|
+
tags_none: Memory must have NONE of these tags
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
List of matching memories
|
|
281
|
+
"""
|
|
282
|
+
query = "SELECT * FROM memories WHERE 1=1"
|
|
283
|
+
params: list[Any] = []
|
|
284
|
+
|
|
285
|
+
if project_id:
|
|
286
|
+
query += " AND (project_id = ? OR project_id IS NULL)"
|
|
287
|
+
params.append(project_id)
|
|
288
|
+
|
|
289
|
+
if memory_type:
|
|
290
|
+
query += " AND memory_type = ?"
|
|
291
|
+
params.append(memory_type)
|
|
292
|
+
|
|
293
|
+
if min_importance is not None:
|
|
294
|
+
query += " AND importance >= ?"
|
|
295
|
+
params.append(min_importance)
|
|
296
|
+
|
|
297
|
+
# Fetch more results to allow for tag filtering
|
|
298
|
+
fetch_limit = limit * 3 if (tags_all or tags_any or tags_none) else limit
|
|
299
|
+
query += " ORDER BY importance DESC, created_at DESC LIMIT ? OFFSET ?"
|
|
300
|
+
params.extend([fetch_limit, offset])
|
|
301
|
+
|
|
302
|
+
rows = self.db.fetchall(query, tuple(params))
|
|
303
|
+
memories = [Memory.from_row(row) for row in rows]
|
|
304
|
+
|
|
305
|
+
# Apply tag filters
|
|
306
|
+
if tags_all or tags_any or tags_none:
|
|
307
|
+
memories = self._filter_by_tags(memories, tags_all, tags_any, tags_none)
|
|
308
|
+
|
|
309
|
+
return memories[:limit]
|
|
310
|
+
|
|
311
|
+
def update_access_stats(self, memory_id: str, accessed_at: str) -> None:
|
|
312
|
+
"""
|
|
313
|
+
Update access count and last accessed timestamp for a memory.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
memory_id: Memory ID to update
|
|
317
|
+
accessed_at: ISO format timestamp of access
|
|
318
|
+
"""
|
|
319
|
+
with self.db.transaction() as conn:
|
|
320
|
+
conn.execute(
|
|
321
|
+
"""
|
|
322
|
+
UPDATE memories
|
|
323
|
+
SET access_count = access_count + 1,
|
|
324
|
+
last_accessed_at = ?
|
|
325
|
+
WHERE id = ?
|
|
326
|
+
""",
|
|
327
|
+
(accessed_at, memory_id),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def search_memories(
|
|
331
|
+
self,
|
|
332
|
+
query_text: str,
|
|
333
|
+
project_id: str | None = None,
|
|
334
|
+
limit: int = 20,
|
|
335
|
+
tags_all: list[str] | None = None,
|
|
336
|
+
tags_any: list[str] | None = None,
|
|
337
|
+
tags_none: list[str] | None = None,
|
|
338
|
+
) -> list[Memory]:
|
|
339
|
+
"""
|
|
340
|
+
Search memories by content with optional tag filtering.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
query_text: Text to search for in memory content
|
|
344
|
+
project_id: Optional project ID to filter by
|
|
345
|
+
limit: Maximum number of results
|
|
346
|
+
tags_all: Memory must have ALL of these tags
|
|
347
|
+
tags_any: Memory must have at least ONE of these tags
|
|
348
|
+
tags_none: Memory must have NONE of these tags
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
List of matching memories
|
|
352
|
+
"""
|
|
353
|
+
# Escape LIKE wildcards in query_text
|
|
354
|
+
escaped_query = query_text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
355
|
+
sql = "SELECT * FROM memories WHERE content LIKE ? ESCAPE '\\'"
|
|
356
|
+
params: list[Any] = [f"%{escaped_query}%"]
|
|
357
|
+
|
|
358
|
+
if project_id:
|
|
359
|
+
sql += " AND (project_id = ? OR project_id IS NULL)"
|
|
360
|
+
params.append(project_id)
|
|
361
|
+
|
|
362
|
+
# Fetch more results than needed to allow for tag filtering
|
|
363
|
+
fetch_limit = limit * 3 if (tags_all or tags_any or tags_none) else limit
|
|
364
|
+
sql += " ORDER BY importance DESC LIMIT ?"
|
|
365
|
+
params.append(fetch_limit)
|
|
366
|
+
|
|
367
|
+
rows = self.db.fetchall(sql, tuple(params))
|
|
368
|
+
memories = [Memory.from_row(row) for row in rows]
|
|
369
|
+
|
|
370
|
+
# Apply tag filters in Python
|
|
371
|
+
if tags_all or tags_any or tags_none:
|
|
372
|
+
memories = self._filter_by_tags(memories, tags_all, tags_any, tags_none)
|
|
373
|
+
|
|
374
|
+
return memories[:limit]
|
|
375
|
+
|
|
376
|
+
def _filter_by_tags(
|
|
377
|
+
self,
|
|
378
|
+
memories: list[Memory],
|
|
379
|
+
tags_all: list[str] | None = None,
|
|
380
|
+
tags_any: list[str] | None = None,
|
|
381
|
+
tags_none: list[str] | None = None,
|
|
382
|
+
) -> list[Memory]:
|
|
383
|
+
"""
|
|
384
|
+
Filter memories by tag criteria.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
memories: List of memories to filter
|
|
388
|
+
tags_all: Memory must have ALL of these tags
|
|
389
|
+
tags_any: Memory must have at least ONE of these tags
|
|
390
|
+
tags_none: Memory must have NONE of these tags
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Filtered list of memories
|
|
394
|
+
"""
|
|
395
|
+
result = []
|
|
396
|
+
for memory in memories:
|
|
397
|
+
memory_tags = set(memory.tags) if memory.tags else set()
|
|
398
|
+
|
|
399
|
+
# Check tags_all: memory must have ALL specified tags
|
|
400
|
+
if tags_all:
|
|
401
|
+
if not set(tags_all).issubset(memory_tags):
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
# Check tags_any: memory must have at least ONE specified tag
|
|
405
|
+
if tags_any:
|
|
406
|
+
if not memory_tags.intersection(tags_any):
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
# Check tags_none: memory must have NONE of the specified tags
|
|
410
|
+
if tags_none:
|
|
411
|
+
if memory_tags.intersection(tags_none):
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
result.append(memory)
|
|
415
|
+
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
# --- Cross-reference methods ---
|
|
419
|
+
|
|
420
|
+
def create_crossref(
|
|
421
|
+
self,
|
|
422
|
+
source_id: str,
|
|
423
|
+
target_id: str,
|
|
424
|
+
similarity: float,
|
|
425
|
+
) -> MemoryCrossRef:
|
|
426
|
+
"""
|
|
427
|
+
Create a cross-reference link between two memories.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
source_id: The source memory ID
|
|
431
|
+
target_id: The target memory ID
|
|
432
|
+
similarity: Similarity score (0.0 to 1.0)
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
The created MemoryCrossRef
|
|
436
|
+
|
|
437
|
+
Note:
|
|
438
|
+
If the crossref already exists, it will be updated with
|
|
439
|
+
the new similarity score.
|
|
440
|
+
"""
|
|
441
|
+
now = datetime.now(UTC).isoformat()
|
|
442
|
+
|
|
443
|
+
with self.db.transaction() as conn:
|
|
444
|
+
conn.execute(
|
|
445
|
+
"""
|
|
446
|
+
INSERT INTO memory_crossrefs (source_id, target_id, similarity, created_at)
|
|
447
|
+
VALUES (?, ?, ?, ?)
|
|
448
|
+
ON CONFLICT(source_id, target_id) DO UPDATE SET
|
|
449
|
+
similarity = excluded.similarity
|
|
450
|
+
""",
|
|
451
|
+
(source_id, target_id, similarity, now),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
return MemoryCrossRef(
|
|
455
|
+
source_id=source_id,
|
|
456
|
+
target_id=target_id,
|
|
457
|
+
similarity=similarity,
|
|
458
|
+
created_at=now,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def get_crossrefs(
|
|
462
|
+
self,
|
|
463
|
+
memory_id: str,
|
|
464
|
+
limit: int = 10,
|
|
465
|
+
min_similarity: float = 0.0,
|
|
466
|
+
) -> list[MemoryCrossRef]:
|
|
467
|
+
"""
|
|
468
|
+
Get cross-references for a memory (both as source and target).
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
memory_id: The memory ID to find links for
|
|
472
|
+
limit: Maximum number of results
|
|
473
|
+
min_similarity: Minimum similarity threshold
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
List of MemoryCrossRef objects, sorted by similarity descending
|
|
477
|
+
"""
|
|
478
|
+
# Get crossrefs where this memory is the source
|
|
479
|
+
rows = self.db.fetchall(
|
|
480
|
+
"""
|
|
481
|
+
SELECT source_id, target_id, similarity, created_at
|
|
482
|
+
FROM memory_crossrefs
|
|
483
|
+
WHERE source_id = ? AND similarity >= ?
|
|
484
|
+
UNION
|
|
485
|
+
SELECT source_id, target_id, similarity, created_at
|
|
486
|
+
FROM memory_crossrefs
|
|
487
|
+
WHERE target_id = ? AND similarity >= ?
|
|
488
|
+
ORDER BY similarity DESC
|
|
489
|
+
LIMIT ?
|
|
490
|
+
""",
|
|
491
|
+
(memory_id, min_similarity, memory_id, min_similarity, limit),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return [MemoryCrossRef.from_row(row) for row in rows]
|
|
495
|
+
|
|
496
|
+
def delete_crossrefs(self, memory_id: str) -> int:
|
|
497
|
+
"""
|
|
498
|
+
Delete all cross-references involving a memory.
|
|
499
|
+
|
|
500
|
+
Called automatically when a memory is deleted due to CASCADE,
|
|
501
|
+
but can be called manually for cleanup.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
memory_id: The memory ID to delete crossrefs for
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Number of crossrefs deleted
|
|
508
|
+
"""
|
|
509
|
+
with self.db.transaction() as conn:
|
|
510
|
+
cursor = conn.execute(
|
|
511
|
+
"""
|
|
512
|
+
DELETE FROM memory_crossrefs
|
|
513
|
+
WHERE source_id = ? OR target_id = ?
|
|
514
|
+
""",
|
|
515
|
+
(memory_id, memory_id),
|
|
516
|
+
)
|
|
517
|
+
return cursor.rowcount
|
|
518
|
+
|
|
519
|
+
def get_all_crossrefs(
|
|
520
|
+
self,
|
|
521
|
+
project_id: str | None = None,
|
|
522
|
+
limit: int = 1000,
|
|
523
|
+
) -> list[MemoryCrossRef]:
|
|
524
|
+
"""
|
|
525
|
+
Get all cross-references, optionally filtered by project.
|
|
526
|
+
|
|
527
|
+
Useful for building memory graphs.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
project_id: Filter to memories in this project
|
|
531
|
+
limit: Maximum number of results
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
List of MemoryCrossRef objects
|
|
535
|
+
"""
|
|
536
|
+
if project_id:
|
|
537
|
+
# Join with memories to filter by project
|
|
538
|
+
rows = self.db.fetchall(
|
|
539
|
+
"""
|
|
540
|
+
SELECT DISTINCT mc.source_id, mc.target_id, mc.similarity, mc.created_at
|
|
541
|
+
FROM memory_crossrefs mc
|
|
542
|
+
JOIN memories m1 ON mc.source_id = m1.id
|
|
543
|
+
JOIN memories m2 ON mc.target_id = m2.id
|
|
544
|
+
WHERE (m1.project_id = ? OR m1.project_id IS NULL)
|
|
545
|
+
AND (m2.project_id = ? OR m2.project_id IS NULL)
|
|
546
|
+
ORDER BY mc.similarity DESC
|
|
547
|
+
LIMIT ?
|
|
548
|
+
""",
|
|
549
|
+
(project_id, project_id, limit),
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
rows = self.db.fetchall(
|
|
553
|
+
"""
|
|
554
|
+
SELECT source_id, target_id, similarity, created_at
|
|
555
|
+
FROM memory_crossrefs
|
|
556
|
+
ORDER BY similarity DESC
|
|
557
|
+
LIMIT ?
|
|
558
|
+
""",
|
|
559
|
+
(limit,),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
return [MemoryCrossRef.from_row(row) for row in rows]
|