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.
- memu/__init__.py +9 -0
- memu/__main__.py +7 -0
- memu/_core.pyd +0 -0
- memu/_core.pyi +1 -0
- memu/app/__init__.py +37 -0
- memu/app/agentic.py +394 -0
- memu/app/client_pool.py +63 -0
- memu/app/crud.py +792 -0
- memu/app/memorize.py +1210 -0
- memu/app/memorize_workspace.py +834 -0
- memu/app/memory_files.py +101 -0
- memu/app/retrieve.py +1427 -0
- memu/app/retrieve_workspace.py +276 -0
- memu/app/service.py +495 -0
- memu/app/settings.py +594 -0
- memu/blob/__init__.py +0 -0
- memu/blob/document_text.py +65 -0
- memu/blob/folder.py +185 -0
- memu/blob/local_fs.py +97 -0
- memu/cli.py +246 -0
- memu/database/__init__.py +41 -0
- memu/database/factory.py +43 -0
- memu/database/inmemory/__init__.py +34 -0
- memu/database/inmemory/models.py +77 -0
- memu/database/inmemory/repo.py +85 -0
- memu/database/inmemory/repositories/__init__.py +33 -0
- memu/database/inmemory/repositories/filter.py +32 -0
- memu/database/inmemory/repositories/recall_entry_repo.py +263 -0
- memu/database/inmemory/repositories/recall_file_entry_repo.py +65 -0
- memu/database/inmemory/repositories/recall_file_repo.py +94 -0
- memu/database/inmemory/repositories/recall_file_resource_repo.py +67 -0
- memu/database/inmemory/repositories/recall_file_segment_repo.py +71 -0
- memu/database/inmemory/repositories/resource_repo.py +79 -0
- memu/database/inmemory/state.py +7 -0
- memu/database/inmemory/vector.py +13 -0
- memu/database/interfaces.py +50 -0
- memu/database/models.py +197 -0
- memu/database/postgres/__init__.py +36 -0
- memu/database/postgres/migration.py +72 -0
- memu/database/postgres/migrations/__init__.py +1 -0
- memu/database/postgres/migrations/env.py +96 -0
- memu/database/postgres/migrations/script.py.mako +28 -0
- memu/database/postgres/migrations/versions/20260703_0001_initial_schema.py +161 -0
- memu/database/postgres/models.py +219 -0
- memu/database/postgres/postgres.py +150 -0
- memu/database/postgres/repositories/__init__.py +15 -0
- memu/database/postgres/repositories/base.py +113 -0
- memu/database/postgres/repositories/recall_entry_repo.py +401 -0
- memu/database/postgres/repositories/recall_file_entry_repo.py +145 -0
- memu/database/postgres/repositories/recall_file_repo.py +167 -0
- memu/database/postgres/repositories/recall_file_resource_repo.py +149 -0
- memu/database/postgres/repositories/recall_file_segment_repo.py +133 -0
- memu/database/postgres/repositories/resource_repo.py +134 -0
- memu/database/postgres/schema.py +135 -0
- memu/database/postgres/session.py +34 -0
- memu/database/repositories/__init__.py +15 -0
- memu/database/repositories/recall_entry.py +60 -0
- memu/database/repositories/recall_file.py +39 -0
- memu/database/repositories/recall_file_entry.py +31 -0
- memu/database/repositories/recall_file_resource.py +33 -0
- memu/database/repositories/recall_file_segment.py +41 -0
- memu/database/repositories/resource.py +46 -0
- memu/database/sqlite/__init__.py +36 -0
- memu/database/sqlite/models.py +211 -0
- memu/database/sqlite/repositories/__init__.py +19 -0
- memu/database/sqlite/repositories/base.py +128 -0
- memu/database/sqlite/repositories/recall_entry_repo.py +544 -0
- memu/database/sqlite/repositories/recall_file_entry_repo.py +211 -0
- memu/database/sqlite/repositories/recall_file_repo.py +273 -0
- memu/database/sqlite/repositories/recall_file_resource_repo.py +215 -0
- memu/database/sqlite/repositories/recall_file_segment_repo.py +142 -0
- memu/database/sqlite/repositories/resource_repo.py +224 -0
- memu/database/sqlite/schema.py +126 -0
- memu/database/sqlite/session.py +48 -0
- memu/database/sqlite/sqlite.py +187 -0
- memu/database/state.py +25 -0
- memu/embedding/__init__.py +32 -0
- memu/embedding/backends/__init__.py +16 -0
- memu/embedding/backends/base.py +29 -0
- memu/embedding/backends/doubao.py +72 -0
- memu/embedding/backends/jina.py +23 -0
- memu/embedding/backends/openai.py +18 -0
- memu/embedding/backends/openrouter.py +22 -0
- memu/embedding/backends/voyage.py +23 -0
- memu/embedding/base.py +26 -0
- memu/embedding/defaults.py +30 -0
- memu/embedding/gateway.py +78 -0
- memu/embedding/http_client.py +159 -0
- memu/embedding/openai_sdk.py +47 -0
- memu/env.py +131 -0
- memu/hosts/__init__.py +23 -0
- memu/hosts/base.py +90 -0
- memu/hosts/bridging/__init__.py +13 -0
- memu/hosts/bridging/instructions.py +181 -0
- memu/hosts/bridging/layout.py +73 -0
- memu/hosts/bridging/manifest.py +52 -0
- memu/hosts/bridging/pipeline.py +90 -0
- memu/hosts/bridging/recall_files.py +51 -0
- memu/hosts/bridging/resources.py +127 -0
- memu/hosts/bridging/transcripts.py +87 -0
- memu/hosts/codex/BRIDGING_TASK.md +117 -0
- memu/hosts/codex/INSTALL.md +212 -0
- memu/hosts/codex/__init__.py +10 -0
- memu/hosts/codex/cli.py +167 -0
- memu/hosts/codex/sessions.py +52 -0
- memu/hosts/instruction.py +149 -0
- memu/hosts/retrieval.py +51 -0
- memu/integrations/__init__.py +3 -0
- memu/integrations/langgraph.py +163 -0
- memu/llm/anthropic_client.py +149 -0
- memu/llm/backends/__init__.py +21 -0
- memu/llm/backends/base.py +43 -0
- memu/llm/backends/claude.py +84 -0
- memu/llm/backends/deepseek.py +14 -0
- memu/llm/backends/doubao.py +69 -0
- memu/llm/backends/grok.py +11 -0
- memu/llm/backends/kimi.py +14 -0
- memu/llm/backends/minimax.py +14 -0
- memu/llm/backends/openai.py +70 -0
- memu/llm/backends/openrouter.py +70 -0
- memu/llm/gateway.py +90 -0
- memu/llm/http_client.py +232 -0
- memu/llm/lazyllm_client.py +159 -0
- memu/llm/openai_client.py +202 -0
- memu/llm/wrapper.py +804 -0
- memu/memory_fs/__init__.py +23 -0
- memu/memory_fs/exporter.py +489 -0
- memu/memory_fs/synthesizer.py +115 -0
- memu/preprocess/__init__.py +99 -0
- memu/preprocess/audio.py +83 -0
- memu/preprocess/base.py +84 -0
- memu/preprocess/conversation.py +42 -0
- memu/preprocess/document.py +36 -0
- memu/preprocess/image.py +34 -0
- memu/preprocess/video.py +60 -0
- memu/prompts/__init__.py +13 -0
- memu/prompts/category_patch/__init__.py +7 -0
- memu/prompts/category_patch/category.py +45 -0
- memu/prompts/category_summary/__init__.py +22 -0
- memu/prompts/category_summary/category.py +296 -0
- memu/prompts/category_summary/category_with_refs.py +140 -0
- memu/prompts/memory_fs/__init__.py +213 -0
- memu/prompts/memory_type/__init__.py +46 -0
- memu/prompts/memory_type/behavior.py +176 -0
- memu/prompts/memory_type/event.py +182 -0
- memu/prompts/memory_type/knowledge.py +172 -0
- memu/prompts/memory_type/profile.py +190 -0
- memu/prompts/memory_type/skill.py +580 -0
- memu/prompts/memory_type/tool.py +120 -0
- memu/prompts/preprocess/__init__.py +11 -0
- memu/prompts/preprocess/audio.py +62 -0
- memu/prompts/preprocess/conversation.py +43 -0
- memu/prompts/preprocess/document.py +35 -0
- memu/prompts/preprocess/image.py +34 -0
- memu/prompts/preprocess/video.py +35 -0
- memu/prompts/retrieve/__init__.py +0 -0
- memu/prompts/retrieve/judger.py +39 -0
- memu/prompts/retrieve/llm_category_ranker.py +35 -0
- memu/prompts/retrieve/llm_item_ranker.py +40 -0
- memu/prompts/retrieve/llm_resource_ranker.py +40 -0
- memu/prompts/retrieve/pre_retrieval_decision.py +53 -0
- memu/prompts/retrieve/query_rewriter.py +44 -0
- memu/prompts/retrieve/query_rewriter_judger.py +48 -0
- memu/utils/__init__.py +5 -0
- memu/utils/conversation.py +89 -0
- memu/utils/references.py +172 -0
- memu/utils/tool.py +102 -0
- memu/utils/video.py +271 -0
- memu/vector.py +145 -0
- memu/vlm/__init__.py +24 -0
- memu/vlm/anthropic_client.py +80 -0
- memu/vlm/backends/__init__.py +19 -0
- memu/vlm/backends/base.py +65 -0
- memu/vlm/backends/claude.py +69 -0
- memu/vlm/backends/doubao.py +10 -0
- memu/vlm/backends/grok.py +9 -0
- memu/vlm/backends/kimi.py +9 -0
- memu/vlm/backends/minimax.py +10 -0
- memu/vlm/backends/openai.py +79 -0
- memu/vlm/backends/openrouter.py +19 -0
- memu/vlm/base.py +84 -0
- memu/vlm/defaults.py +30 -0
- memu/vlm/gateway.py +71 -0
- memu/vlm/http_client.py +142 -0
- memu/vlm/openai_client.py +76 -0
- memu/workflow/__init__.py +29 -0
- memu/workflow/interceptor.py +218 -0
- memu/workflow/pipeline.py +170 -0
- memu/workflow/runner.py +81 -0
- memu/workflow/step.py +101 -0
- memu_cli-0.1.0.dist-info/METADATA +507 -0
- memu_cli-0.1.0.dist-info/RECORD +196 -0
- memu_cli-0.1.0.dist-info/WHEEL +4 -0
- memu_cli-0.1.0.dist-info/entry_points.txt +3 -0
- memu_cli-0.1.0.dist-info/licenses/LICENSE.txt +194 -0
- memu_cli-0.1.0.dist-info/sboms/memu.cyclonedx.json +733 -0
memu/__init__.py
ADDED
memu/__main__.py
ADDED
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
|
+
)
|
memu/app/client_pool.py
ADDED
|
@@ -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"]
|