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.
Files changed (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. 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]