memu-cli 0.1.0__cp313-abi3-win_amd64.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 (196) hide show
  1. memu/__init__.py +9 -0
  2. memu/__main__.py +7 -0
  3. memu/_core.pyd +0 -0
  4. memu/_core.pyi +1 -0
  5. memu/app/__init__.py +37 -0
  6. memu/app/agentic.py +394 -0
  7. memu/app/client_pool.py +63 -0
  8. memu/app/crud.py +792 -0
  9. memu/app/memorize.py +1210 -0
  10. memu/app/memorize_workspace.py +834 -0
  11. memu/app/memory_files.py +101 -0
  12. memu/app/retrieve.py +1427 -0
  13. memu/app/retrieve_workspace.py +276 -0
  14. memu/app/service.py +495 -0
  15. memu/app/settings.py +594 -0
  16. memu/blob/__init__.py +0 -0
  17. memu/blob/document_text.py +65 -0
  18. memu/blob/folder.py +185 -0
  19. memu/blob/local_fs.py +97 -0
  20. memu/cli.py +246 -0
  21. memu/database/__init__.py +41 -0
  22. memu/database/factory.py +43 -0
  23. memu/database/inmemory/__init__.py +34 -0
  24. memu/database/inmemory/models.py +77 -0
  25. memu/database/inmemory/repo.py +85 -0
  26. memu/database/inmemory/repositories/__init__.py +33 -0
  27. memu/database/inmemory/repositories/filter.py +32 -0
  28. memu/database/inmemory/repositories/recall_entry_repo.py +263 -0
  29. memu/database/inmemory/repositories/recall_file_entry_repo.py +65 -0
  30. memu/database/inmemory/repositories/recall_file_repo.py +94 -0
  31. memu/database/inmemory/repositories/recall_file_resource_repo.py +67 -0
  32. memu/database/inmemory/repositories/recall_file_segment_repo.py +71 -0
  33. memu/database/inmemory/repositories/resource_repo.py +79 -0
  34. memu/database/inmemory/state.py +7 -0
  35. memu/database/inmemory/vector.py +13 -0
  36. memu/database/interfaces.py +50 -0
  37. memu/database/models.py +197 -0
  38. memu/database/postgres/__init__.py +36 -0
  39. memu/database/postgres/migration.py +72 -0
  40. memu/database/postgres/migrations/__init__.py +1 -0
  41. memu/database/postgres/migrations/env.py +96 -0
  42. memu/database/postgres/migrations/script.py.mako +28 -0
  43. memu/database/postgres/migrations/versions/20260703_0001_initial_schema.py +161 -0
  44. memu/database/postgres/models.py +219 -0
  45. memu/database/postgres/postgres.py +150 -0
  46. memu/database/postgres/repositories/__init__.py +15 -0
  47. memu/database/postgres/repositories/base.py +113 -0
  48. memu/database/postgres/repositories/recall_entry_repo.py +401 -0
  49. memu/database/postgres/repositories/recall_file_entry_repo.py +145 -0
  50. memu/database/postgres/repositories/recall_file_repo.py +167 -0
  51. memu/database/postgres/repositories/recall_file_resource_repo.py +149 -0
  52. memu/database/postgres/repositories/recall_file_segment_repo.py +133 -0
  53. memu/database/postgres/repositories/resource_repo.py +134 -0
  54. memu/database/postgres/schema.py +135 -0
  55. memu/database/postgres/session.py +34 -0
  56. memu/database/repositories/__init__.py +15 -0
  57. memu/database/repositories/recall_entry.py +60 -0
  58. memu/database/repositories/recall_file.py +39 -0
  59. memu/database/repositories/recall_file_entry.py +31 -0
  60. memu/database/repositories/recall_file_resource.py +33 -0
  61. memu/database/repositories/recall_file_segment.py +41 -0
  62. memu/database/repositories/resource.py +46 -0
  63. memu/database/sqlite/__init__.py +36 -0
  64. memu/database/sqlite/models.py +211 -0
  65. memu/database/sqlite/repositories/__init__.py +19 -0
  66. memu/database/sqlite/repositories/base.py +128 -0
  67. memu/database/sqlite/repositories/recall_entry_repo.py +544 -0
  68. memu/database/sqlite/repositories/recall_file_entry_repo.py +211 -0
  69. memu/database/sqlite/repositories/recall_file_repo.py +273 -0
  70. memu/database/sqlite/repositories/recall_file_resource_repo.py +215 -0
  71. memu/database/sqlite/repositories/recall_file_segment_repo.py +142 -0
  72. memu/database/sqlite/repositories/resource_repo.py +224 -0
  73. memu/database/sqlite/schema.py +126 -0
  74. memu/database/sqlite/session.py +48 -0
  75. memu/database/sqlite/sqlite.py +187 -0
  76. memu/database/state.py +25 -0
  77. memu/embedding/__init__.py +32 -0
  78. memu/embedding/backends/__init__.py +16 -0
  79. memu/embedding/backends/base.py +29 -0
  80. memu/embedding/backends/doubao.py +72 -0
  81. memu/embedding/backends/jina.py +23 -0
  82. memu/embedding/backends/openai.py +18 -0
  83. memu/embedding/backends/openrouter.py +22 -0
  84. memu/embedding/backends/voyage.py +23 -0
  85. memu/embedding/base.py +26 -0
  86. memu/embedding/defaults.py +30 -0
  87. memu/embedding/gateway.py +78 -0
  88. memu/embedding/http_client.py +159 -0
  89. memu/embedding/openai_sdk.py +47 -0
  90. memu/env.py +131 -0
  91. memu/hosts/__init__.py +23 -0
  92. memu/hosts/base.py +90 -0
  93. memu/hosts/bridging/__init__.py +13 -0
  94. memu/hosts/bridging/instructions.py +181 -0
  95. memu/hosts/bridging/layout.py +73 -0
  96. memu/hosts/bridging/manifest.py +52 -0
  97. memu/hosts/bridging/pipeline.py +90 -0
  98. memu/hosts/bridging/recall_files.py +51 -0
  99. memu/hosts/bridging/resources.py +127 -0
  100. memu/hosts/bridging/transcripts.py +87 -0
  101. memu/hosts/codex/BRIDGING_TASK.md +117 -0
  102. memu/hosts/codex/INSTALL.md +212 -0
  103. memu/hosts/codex/__init__.py +10 -0
  104. memu/hosts/codex/cli.py +167 -0
  105. memu/hosts/codex/sessions.py +52 -0
  106. memu/hosts/instruction.py +149 -0
  107. memu/hosts/retrieval.py +51 -0
  108. memu/integrations/__init__.py +3 -0
  109. memu/integrations/langgraph.py +163 -0
  110. memu/llm/anthropic_client.py +149 -0
  111. memu/llm/backends/__init__.py +21 -0
  112. memu/llm/backends/base.py +43 -0
  113. memu/llm/backends/claude.py +84 -0
  114. memu/llm/backends/deepseek.py +14 -0
  115. memu/llm/backends/doubao.py +69 -0
  116. memu/llm/backends/grok.py +11 -0
  117. memu/llm/backends/kimi.py +14 -0
  118. memu/llm/backends/minimax.py +14 -0
  119. memu/llm/backends/openai.py +70 -0
  120. memu/llm/backends/openrouter.py +70 -0
  121. memu/llm/gateway.py +90 -0
  122. memu/llm/http_client.py +232 -0
  123. memu/llm/lazyllm_client.py +159 -0
  124. memu/llm/openai_client.py +202 -0
  125. memu/llm/wrapper.py +804 -0
  126. memu/memory_fs/__init__.py +23 -0
  127. memu/memory_fs/exporter.py +489 -0
  128. memu/memory_fs/synthesizer.py +115 -0
  129. memu/preprocess/__init__.py +99 -0
  130. memu/preprocess/audio.py +83 -0
  131. memu/preprocess/base.py +84 -0
  132. memu/preprocess/conversation.py +42 -0
  133. memu/preprocess/document.py +36 -0
  134. memu/preprocess/image.py +34 -0
  135. memu/preprocess/video.py +60 -0
  136. memu/prompts/__init__.py +13 -0
  137. memu/prompts/category_patch/__init__.py +7 -0
  138. memu/prompts/category_patch/category.py +45 -0
  139. memu/prompts/category_summary/__init__.py +22 -0
  140. memu/prompts/category_summary/category.py +296 -0
  141. memu/prompts/category_summary/category_with_refs.py +140 -0
  142. memu/prompts/memory_fs/__init__.py +213 -0
  143. memu/prompts/memory_type/__init__.py +46 -0
  144. memu/prompts/memory_type/behavior.py +176 -0
  145. memu/prompts/memory_type/event.py +182 -0
  146. memu/prompts/memory_type/knowledge.py +172 -0
  147. memu/prompts/memory_type/profile.py +190 -0
  148. memu/prompts/memory_type/skill.py +580 -0
  149. memu/prompts/memory_type/tool.py +120 -0
  150. memu/prompts/preprocess/__init__.py +11 -0
  151. memu/prompts/preprocess/audio.py +62 -0
  152. memu/prompts/preprocess/conversation.py +43 -0
  153. memu/prompts/preprocess/document.py +35 -0
  154. memu/prompts/preprocess/image.py +34 -0
  155. memu/prompts/preprocess/video.py +35 -0
  156. memu/prompts/retrieve/__init__.py +0 -0
  157. memu/prompts/retrieve/judger.py +39 -0
  158. memu/prompts/retrieve/llm_category_ranker.py +35 -0
  159. memu/prompts/retrieve/llm_item_ranker.py +40 -0
  160. memu/prompts/retrieve/llm_resource_ranker.py +40 -0
  161. memu/prompts/retrieve/pre_retrieval_decision.py +53 -0
  162. memu/prompts/retrieve/query_rewriter.py +44 -0
  163. memu/prompts/retrieve/query_rewriter_judger.py +48 -0
  164. memu/utils/__init__.py +5 -0
  165. memu/utils/conversation.py +89 -0
  166. memu/utils/references.py +172 -0
  167. memu/utils/tool.py +102 -0
  168. memu/utils/video.py +271 -0
  169. memu/vector.py +145 -0
  170. memu/vlm/__init__.py +24 -0
  171. memu/vlm/anthropic_client.py +80 -0
  172. memu/vlm/backends/__init__.py +19 -0
  173. memu/vlm/backends/base.py +65 -0
  174. memu/vlm/backends/claude.py +69 -0
  175. memu/vlm/backends/doubao.py +10 -0
  176. memu/vlm/backends/grok.py +9 -0
  177. memu/vlm/backends/kimi.py +9 -0
  178. memu/vlm/backends/minimax.py +10 -0
  179. memu/vlm/backends/openai.py +79 -0
  180. memu/vlm/backends/openrouter.py +19 -0
  181. memu/vlm/base.py +84 -0
  182. memu/vlm/defaults.py +30 -0
  183. memu/vlm/gateway.py +71 -0
  184. memu/vlm/http_client.py +142 -0
  185. memu/vlm/openai_client.py +76 -0
  186. memu/workflow/__init__.py +29 -0
  187. memu/workflow/interceptor.py +218 -0
  188. memu/workflow/pipeline.py +170 -0
  189. memu/workflow/runner.py +81 -0
  190. memu/workflow/step.py +101 -0
  191. memu_cli-0.1.0.dist-info/METADATA +507 -0
  192. memu_cli-0.1.0.dist-info/RECORD +196 -0
  193. memu_cli-0.1.0.dist-info/WHEEL +4 -0
  194. memu_cli-0.1.0.dist-info/entry_points.txt +3 -0
  195. memu_cli-0.1.0.dist-info/licenses/LICENSE.txt +194 -0
  196. memu_cli-0.1.0.dist-info/sboms/memu.cyclonedx.json +733 -0
memu/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from memu._core import hello_from_bin
2
+ from memu.app.service import MemoryService
3
+
4
+ # Public alias used in documentation examples
5
+ MemUService = MemoryService
6
+
7
+
8
+ def _rust_entry() -> str:
9
+ return hello_from_bin()
memu/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m memu`` to invoke the CLI (used by the npm launcher)."""
2
+
3
+ import sys
4
+
5
+ from memu.cli import main
6
+
7
+ sys.exit(main())
memu/_core.pyd ADDED
Binary file
memu/_core.pyi ADDED
@@ -0,0 +1 @@
1
+ def hello_from_bin() -> str: ...
memu/app/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ from memu.app.service import MemoryService
2
+ from memu.app.settings import (
3
+ BlobConfig,
4
+ DatabaseConfig,
5
+ DefaultUserModel,
6
+ EmbeddingConfig,
7
+ EmbeddingProfilesConfig,
8
+ LLMConfig,
9
+ LLMProfilesConfig,
10
+ MemorizeConfig,
11
+ RetrieveConfig,
12
+ UserConfig,
13
+ )
14
+ from memu.workflow.runner import (
15
+ LocalWorkflowRunner,
16
+ WorkflowRunner,
17
+ register_workflow_runner,
18
+ resolve_workflow_runner,
19
+ )
20
+
21
+ __all__ = [
22
+ "BlobConfig",
23
+ "DatabaseConfig",
24
+ "DefaultUserModel",
25
+ "EmbeddingConfig",
26
+ "EmbeddingProfilesConfig",
27
+ "LLMConfig",
28
+ "LLMProfilesConfig",
29
+ "LocalWorkflowRunner",
30
+ "MemorizeConfig",
31
+ "MemoryService",
32
+ "RetrieveConfig",
33
+ "UserConfig",
34
+ "WorkflowRunner",
35
+ "register_workflow_runner",
36
+ "resolve_workflow_runner",
37
+ ]
memu/app/agentic.py ADDED
@@ -0,0 +1,394 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from memu.blob.folder import infer_modality
9
+ from memu.vector import cosine_topk
10
+
11
+ if TYPE_CHECKING:
12
+ from memu.app.service import Context
13
+ from memu.app.settings import RetrieveWorkspaceConfig
14
+ from memu.database.interfaces import Database
15
+ from memu.database.models import RecallFile, Resource
16
+
17
+
18
+ class AgenticMixin:
19
+ if TYPE_CHECKING:
20
+ _get_context: Callable[[], Context]
21
+ _get_database: Callable[[], Database]
22
+ _normalize_where: Callable[[Mapping[str, Any] | None], dict[str, Any]]
23
+ _model_dump_without_embeddings: Callable[[BaseModel], dict[str, Any]]
24
+ _ensure_categories_ready: Callable[[Context, Database, Mapping[str, Any] | None], Awaitable[None]]
25
+ _get_embedding_client: Callable[..., Any]
26
+ retrieve_workspace_config: RetrieveWorkspaceConfig
27
+ user_model: type[BaseModel]
28
+
29
+ async def list_all_recall_files(
30
+ self,
31
+ where: dict[str, Any] | None = None,
32
+ ) -> dict[str, Any]:
33
+ """List RecallFiles across every track without workflow orchestration.
34
+
35
+ Unlike :meth:`CRUDMixin.list_recall_files`, this does not force the
36
+ ``track="memory"`` filter (ADR 0006), so skill-track files are included,
37
+ and it queries the repository directly instead of running a workflow.
38
+ """
39
+ store = self._get_database()
40
+ where_filters = self._normalize_where(where)
41
+ categories = store.recall_file_repo.list_categories(where_filters)
42
+ categories_list = [self._model_dump_without_embeddings(category) for category in categories.values()]
43
+ return {"categories": categories_list}
44
+
45
+ async def progressive_retrieve(
46
+ self,
47
+ query: str,
48
+ where: dict[str, Any] | None = None,
49
+ ) -> dict[str, Any]:
50
+ """Single-shot, LLM-free retrieval over the segment/file/resource layers.
51
+
52
+ This mirrors :meth:`RetrieveWorkspaceMixin.retrieve_workspace` but runs no
53
+ workflow — the four stages are executed sequentially in-line, the same way
54
+ :meth:`commit_results` inverts ``memorize_workspace``. The query is embedded
55
+ once and used to rank two layers by vector similarity; no intention routing,
56
+ sufficiency checks, or summarization:
57
+
58
+ * ``segments``: :class:`RecallFileSegment` slices ranked by embedding,
59
+ ``file.top_k`` of them.
60
+ * ``files``: the :class:`RecallFile`\\ s pointed to by those segments — not
61
+ a ranked search, just a roll-up. Each file's score is the max score of
62
+ the segments that point to it.
63
+ * ``resources``: workspace-track resources ranked by embedding,
64
+ ``resource.top_k`` of them.
65
+
66
+ Returns ``segments``, ``files``, and ``resources``.
67
+ """
68
+ if not query or not query.strip():
69
+ raise ValueError("empty_query")
70
+ store = self._get_database()
71
+ where_filters = self._normalize_where(where)
72
+ config = self.retrieve_workspace_config
73
+ embed_client = self._get_embedding_client("embedding")
74
+ query_vector = (await embed_client.embed([query]))[0]
75
+
76
+ segment_hits, segment_pool = self._recall_segments(
77
+ store=store, where_filters=where_filters, query_vector=query_vector, enabled=config.file.enabled
78
+ )
79
+ file_hits, file_pool, file_resource_urls = self._collect_files(
80
+ store=store, where_filters=where_filters, segment_hits=segment_hits, segment_pool=segment_pool
81
+ )
82
+ resource_hits, resource_pool = self._recall_resources(
83
+ store=store, where_filters=where_filters, query_vector=query_vector, enabled=config.resource.enabled
84
+ )
85
+
86
+ files = self._materialize_hits(file_hits, file_pool)
87
+ for file in files:
88
+ file["resource_urls"] = file_resource_urls.get(file["id"], [])
89
+ return {
90
+ "segments": self._materialize_hits(segment_hits, segment_pool),
91
+ "files": files,
92
+ "resources": self._materialize_hits(resource_hits, resource_pool),
93
+ }
94
+
95
+ def _recall_segments(
96
+ self,
97
+ *,
98
+ store: Database,
99
+ where_filters: dict[str, Any],
100
+ query_vector: list[float],
101
+ enabled: bool,
102
+ ) -> tuple[list[tuple[str, float]], dict[str, Any]]:
103
+ """Rank :class:`RecallFileSegment` slices by embedding similarity.
104
+
105
+ Mirrors :meth:`RetrieveWorkspaceMixin._ws_recall_segments`. The segment repo
106
+ has no vector search, so the stored segment embeddings are ranked directly,
107
+ optionally scoped to the configured tracks via the denormalized ``track``.
108
+ """
109
+ if not enabled:
110
+ return [], {}
111
+
112
+ segment_where = dict(where_filters)
113
+ tracks = self.retrieve_workspace_config.file.tracks
114
+ if tracks:
115
+ segment_where["track__in"] = list(tracks)
116
+ segment_pool = {seg.id: seg for seg in store.recall_file_segment_repo.list_segments(segment_where)}
117
+ segment_hits = cosine_topk(
118
+ query_vector,
119
+ [(sid, seg.embedding) for sid, seg in segment_pool.items()],
120
+ k=self.retrieve_workspace_config.file.top_k,
121
+ )
122
+ return segment_hits, segment_pool
123
+
124
+ def _collect_files(
125
+ self,
126
+ *,
127
+ store: Database,
128
+ where_filters: dict[str, Any],
129
+ segment_hits: list[tuple[str, float]],
130
+ segment_pool: dict[str, Any],
131
+ ) -> tuple[list[tuple[str, float]], dict[str, Any], dict[str, list[str]]]:
132
+ """Roll the ranked segments up to their files (no ranked file search).
133
+
134
+ Mirrors :meth:`RetrieveWorkspaceMixin._ws_collect_files`. Every file pointed
135
+ to by a top segment is returned; a file's score is the max score across the
136
+ segments that point to it.
137
+ """
138
+ file_pool = store.recall_file_repo.list_categories(where_filters)
139
+
140
+ file_scores: dict[str, float] = {}
141
+ for seg_id, score in segment_hits:
142
+ seg = segment_pool.get(seg_id)
143
+ if seg is None:
144
+ continue
145
+ fid = seg.recall_file_id
146
+ if fid not in file_pool:
147
+ continue
148
+ score = float(score)
149
+ if fid not in file_scores or score > file_scores[fid]:
150
+ file_scores[fid] = score
151
+
152
+ # Preserve descending-score order so the response reads best-first.
153
+ file_hits = sorted(file_scores.items(), key=lambda kv: kv[1], reverse=True)
154
+ file_resource_urls = self._collect_file_resource_urls(store, where_filters, file_pool)
155
+ return file_hits, file_pool, file_resource_urls
156
+
157
+ @staticmethod
158
+ def _collect_file_resource_urls(
159
+ store: Database, where_filters: dict[str, Any], file_pool: dict[str, Any]
160
+ ) -> dict[str, list[str]]:
161
+ """Map each file id to the URLs of the resources linked to it.
162
+
163
+ Mirrors :meth:`RetrieveWorkspaceMixin._ws_collect_file_resource_urls`. Resolves
164
+ the ``RecallFileResource`` link table (file -> resource) and the resource
165
+ records (resource -> url) within the current scope, surfacing url strings only.
166
+ """
167
+ resources = store.resource_repo.list_resources(where_filters)
168
+ file_resource_urls: dict[str, list[str]] = {}
169
+ for rel in store.recall_file_resource_repo.list_relations(where_filters):
170
+ if rel.file_id not in file_pool:
171
+ continue
172
+ resource = resources.get(rel.resource_id)
173
+ if resource is None:
174
+ continue
175
+ file_resource_urls.setdefault(rel.file_id, []).append(resource.url)
176
+ return file_resource_urls
177
+
178
+ def _recall_resources(
179
+ self,
180
+ *,
181
+ store: Database,
182
+ where_filters: dict[str, Any],
183
+ query_vector: list[float],
184
+ enabled: bool,
185
+ ) -> tuple[list[tuple[str, float]], dict[str, Any]]:
186
+ """Rank workspace-track resources by embedding similarity.
187
+
188
+ Mirrors :meth:`RetrieveWorkspaceMixin._ws_recall_resources`. Only resources
189
+ ingested by ``memorize_workspace`` (``track="workspace"``) are surfaced; other
190
+ tracks are excluded.
191
+ """
192
+ if not enabled:
193
+ return [], {}
194
+
195
+ resource_where = {**where_filters, "track": "workspace"}
196
+ resource_pool = store.resource_repo.list_resources(resource_where)
197
+ resource_hits = store.resource_repo.vector_search_resources(
198
+ query_vector, self.retrieve_workspace_config.resource.top_k, where=resource_where
199
+ )
200
+ return resource_hits, resource_pool
201
+
202
+ def _materialize_hits(self, hits: Sequence[tuple[str, float]], pool: dict[str, Any]) -> list[dict[str, Any]]:
203
+ """Expand ``(id, score)`` hits into scored, embedding-free dicts.
204
+
205
+ Mirrors :meth:`RetrieveWorkspaceMixin._materialize_hits`.
206
+ """
207
+ out = []
208
+ for _id, score in hits:
209
+ obj = pool.get(_id)
210
+ if not obj:
211
+ continue
212
+ data = self._model_dump_without_embeddings(obj)
213
+ data["score"] = float(score)
214
+ out.append(data)
215
+ return out
216
+
217
+ async def commit_results(
218
+ self,
219
+ *,
220
+ recall_files: list[dict[str, Any]] | None = None,
221
+ resource: list[dict[str, Any]] | None = None,
222
+ user: dict[str, Any] | None = None,
223
+ ) -> dict[str, Any]:
224
+ """Persist externally-prepared resources and recall files into the store.
225
+
226
+ This mirrors the persistence half of :meth:`MemorizeWorkspaceMixin.memorize_workspace`
227
+ but takes items that were already preprocessed/synthesized off-service (see
228
+ :mod:`memu.hosts.bridging.pipeline`), so it runs no ingest/preprocess/LLM steps and
229
+ no workflow — just create-or-update straight into storage:
230
+
231
+ - ``resource`` — a list of ``{path, description}`` records. Each is a
232
+ :class:`Resource` keyed by ``url`` (``= path``); the description becomes the
233
+ embedded caption used for INDEX/resource recall.
234
+ - ``recall_files`` — a list of ``{name, track, description, content}`` records. Each
235
+ is a :class:`RecallFile` keyed by ``name`` within its ``track`` (``memory``/``skill``),
236
+ with the same track-specific segment (re)generation as the workspace path.
237
+ """
238
+ store = self._get_database()
239
+ user_scope = self.user_model(**user).model_dump() if user is not None else None
240
+ embed_client = self._get_embedding_client("embedding")
241
+
242
+ committed_resources = await self._commit_resources(
243
+ resource or [], store=store, user_scope=user_scope, embed_client=embed_client
244
+ )
245
+ committed_files = await self._commit_recall_files(
246
+ recall_files or [], store=store, user_scope=user_scope, embed_client=embed_client
247
+ )
248
+ return {
249
+ "resources": [self._model_dump_without_embeddings(r) for r in committed_resources],
250
+ "recall_files": [self._model_dump_without_embeddings(f) for f in committed_files],
251
+ }
252
+
253
+ async def _commit_resources(
254
+ self,
255
+ resources: list[dict[str, Any]],
256
+ *,
257
+ store: Database,
258
+ user_scope: dict[str, Any] | None,
259
+ embed_client: Any,
260
+ ) -> list[Resource]:
261
+ """Create-or-update each ``{path, description}`` as a ``Resource`` keyed by url.
262
+
263
+ ``ResourceRepo`` has no in-place update, so an "update" is a delete-then-create:
264
+ any existing resource sharing this url (and its file provenance links) is dropped
265
+ before the fresh record is created. Mirrors
266
+ :meth:`MemorizeWorkspaceMixin._create_resource_with_caption`.
267
+ """
268
+ where = user_scope or None
269
+ committed: list[Resource] = []
270
+ for item in resources:
271
+ url = (item.get("path") or "").strip()
272
+ if not url:
273
+ continue
274
+ caption = (item.get("description") or "").strip() or None
275
+
276
+ # Create-or-update keyed by url: drop any prior resource for this url first.
277
+ stale = [res for res in store.resource_repo.list_resources(where=where).values() if res.url == url]
278
+ for res in stale:
279
+ store.recall_file_resource_repo.unlink_resource(res.id)
280
+ store.resource_repo.delete_resource(res.id)
281
+
282
+ caption_embedding = (await embed_client.embed([caption]))[0] if caption else None
283
+ res = store.resource_repo.create_resource(
284
+ url=url,
285
+ modality=infer_modality(url) or "document",
286
+ local_path=url,
287
+ caption=caption,
288
+ embedding=caption_embedding,
289
+ user_data=dict(user_scope or {}),
290
+ )
291
+ committed.append(res)
292
+ return committed
293
+
294
+ async def _commit_recall_files(
295
+ self,
296
+ recall_files: list[dict[str, Any]],
297
+ *,
298
+ store: Database,
299
+ user_scope: dict[str, Any] | None,
300
+ embed_client: Any,
301
+ ) -> list[RecallFile]:
302
+ """Create-or-update each ``{name, track, description, content}`` as a ``RecallFile``.
303
+
304
+ Keyed by ``name`` within the record's ``track`` (``memory``/``skill``). New files embed
305
+ their ``name: description`` for file-level recall; existing files keep their embedding
306
+ and only take the new content. Segments are then reconciled per track. Mirrors the
307
+ persist path of :meth:`MemorizeWorkspaceMixin._synthesize_file_ops`.
308
+ """
309
+ user_data = dict(user_scope or {})
310
+ committed: list[RecallFile] = []
311
+ for item in recall_files:
312
+ name = (item.get("name") or "").strip()
313
+ if not name:
314
+ continue
315
+ file_track = item.get("track") or "memory"
316
+ description = (item.get("description") or "").strip()
317
+ content = (item.get("content") or "").strip()
318
+
319
+ existing = store.recall_file_repo.list_categories(where={**user_data, "track": file_track})
320
+ file = {f.name: f for f in existing.values()}.get(name)
321
+ if file is None:
322
+ emb_text = f"{name}: {description}" if description else name
323
+ embedding = (await embed_client.embed([emb_text]))[0]
324
+ file = store.recall_file_repo.get_or_create_category(
325
+ name=name,
326
+ description=description,
327
+ embedding=embedding,
328
+ user_data=user_data,
329
+ track=file_track,
330
+ )
331
+ file = store.recall_file_repo.update_category(category_id=file.id, content=content)
332
+ await self._commit_sync_file_segments(
333
+ file=file,
334
+ file_track=file_track,
335
+ store=store,
336
+ user_scope=user_data,
337
+ embed_client=embed_client,
338
+ )
339
+ committed.append(file)
340
+ return committed
341
+
342
+ @staticmethod
343
+ def _commit_segment_texts_for_file(file: RecallFile, file_track: str) -> list[str]:
344
+ """Compute a file's searchable segment texts (ADR 0007 L2 items), track-specific.
345
+
346
+ Mirrors :meth:`MemorizeWorkspaceMixin._segment_texts_for_file`:
347
+
348
+ - ``skill``: a single ``name: ...\\ndescription: ...`` segment for the whole skill.
349
+ - ``memory``: one segment per content line, skipping blank lines and markdown
350
+ headings, de-duplicated while preserving order.
351
+ """
352
+ if file_track == "skill":
353
+ return [f"name: {file.name}\ndescription: {file.description}"]
354
+
355
+ texts: list[str] = []
356
+ for line in (file.content or "").split("\n"):
357
+ stripped = line.strip()
358
+ if not stripped or stripped.startswith("#"):
359
+ continue
360
+ texts.append(stripped)
361
+ return list(dict.fromkeys(texts))
362
+
363
+ async def _commit_sync_file_segments(
364
+ self,
365
+ *,
366
+ file: RecallFile,
367
+ file_track: str,
368
+ store: Database,
369
+ user_scope: dict[str, Any],
370
+ embed_client: Any,
371
+ ) -> None:
372
+ """Reconcile a file's stored segments with its freshly computed segment texts.
373
+
374
+ Drop-and-add on the difference only: segments whose text disappeared are deleted and
375
+ only genuinely new texts are embedded and inserted, so unchanged lines keep their
376
+ embedding. Mirrors :meth:`MemorizeWorkspaceMixin._sync_file_segments` (single file).
377
+ """
378
+ new_texts = self._commit_segment_texts_for_file(file, file_track)
379
+ existing = store.recall_file_segment_repo.list_segments_for_file(file.id)
380
+ existing_texts = {seg.text for seg in existing}
381
+ new_set = set(new_texts)
382
+
383
+ for seg in existing:
384
+ if seg.text not in new_set:
385
+ store.recall_file_segment_repo.delete_segment(seg.id)
386
+
387
+ to_add = [text for text in new_texts if text not in existing_texts]
388
+ if not to_add:
389
+ return
390
+ vecs = await embed_client.embed(to_add)
391
+ for text, vec in zip(to_add, vecs, strict=True):
392
+ store.recall_file_segment_repo.create_segment(
393
+ recall_file_id=file.id, track=file_track, text=text, embedding=vec, user_data=dict(user_scope)
394
+ )
@@ -0,0 +1,63 @@
1
+ """Generic lazy client pool keyed by profile name.
2
+
3
+ ``MemoryService`` needs three structurally identical caches: one each for the
4
+ chat (LLM), vision (VLM) and embedding clients. Each maps a profile name to a
5
+ lazily-built, cached client. This pool factors out that duplicated bookkeeping
6
+ so adding a fourth capability (e.g. rerank/STT) is a single instantiation rather
7
+ than another copy of the get/cache/build dance.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable, Mapping
13
+
14
+
15
+ class ClientPool[TConfig, TClient]:
16
+ """Lazily build and cache one client per named profile.
17
+
18
+ Args:
19
+ profiles: Mapping of profile name -> config object.
20
+ builder: Factory turning a config into a concrete client.
21
+ label: Human-readable capability name used in error messages
22
+ (e.g. ``"llm"``, ``"vlm"``, ``"embedding"``).
23
+ default_profile: Profile name used when ``get()`` is called with ``None``.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ profiles: Mapping[str, TConfig],
30
+ builder: Callable[[TConfig], TClient],
31
+ label: str,
32
+ default_profile: str = "default",
33
+ ) -> None:
34
+ self._profiles = profiles
35
+ self._builder = builder
36
+ self._label = label
37
+ self._default_profile = default_profile
38
+ self._cache: dict[str, TClient] = {}
39
+
40
+ def config(self, profile: str | None = None) -> TConfig | None:
41
+ """Return the config for ``profile`` (or the default), if present."""
42
+ return self._profiles.get(profile or self._default_profile)
43
+
44
+ def get(self, profile: str | None = None) -> TClient:
45
+ """Return the cached client for ``profile``, building it on first use.
46
+
47
+ Raises:
48
+ KeyError: if no profile with that name is configured.
49
+ """
50
+ name = profile or self._default_profile
51
+ cached = self._cache.get(name)
52
+ if cached is not None:
53
+ return cached
54
+ cfg = self._profiles.get(name)
55
+ if cfg is None:
56
+ msg = f"Unknown {self._label} profile '{name}'"
57
+ raise KeyError(msg)
58
+ client = self._builder(cfg)
59
+ self._cache[name] = client
60
+ return client
61
+
62
+
63
+ __all__ = ["ClientPool"]