deeptutor-knowledge 1.7.0__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 (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,329 @@
1
+ """MarginNote 4 tools -- the seam between the chat loop and the synced store.
2
+
3
+ Seven tools auto-mounted only when a MarginNote 4 library is the selected KB
4
+ (via :class:`~deeptutor.capabilities.marginnote4.capability.MarginNoteCapability`,
5
+ which runs the turn exclusively on these tools). Five read the synced data
6
+ (search, read, list, links, tags) and two provide structural navigation
7
+ (documents, mindmap). Every tool is a thin wrapper over the pure
8
+ :class:`~deeptutor.capabilities.marginnote4.store.MarginNoteStore` methods.
9
+
10
+ The store path is injected server-side as ``_db_path`` by the capability's
11
+ ``augment_kwargs``; the model never supplies or sees it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from deeptutor.capabilities.marginnote4.models import ALL_TYPES
21
+ from deeptutor.capabilities.marginnote4.store import MarginNoteStore
22
+ from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolParameter, ToolResult
23
+
24
+ MARGINNOTE_TOOL_NAMES: tuple[str, ...] = (
25
+ "marginnote_search",
26
+ "marginnote_read",
27
+ "marginnote_list",
28
+ "marginnote_documents",
29
+ "marginnote_links",
30
+ "marginnote_tags",
31
+ "marginnote_cards",
32
+ )
33
+
34
+
35
+ def _store(kwargs: dict[str, Any]) -> MarginNoteStore | None:
36
+ raw = str(kwargs.get("_db_path") or "").strip()
37
+ if not raw:
38
+ return None
39
+ root = Path(raw)
40
+ cached = _STORE_CACHE.get(raw)
41
+ if cached is not None:
42
+ return cached
43
+ if not root.parent.exists():
44
+ return None
45
+ store = MarginNoteStore(root)
46
+ _STORE_CACHE[raw] = store
47
+ return store
48
+
49
+
50
+ _STORE_CACHE: dict[str, MarginNoteStore] = {}
51
+
52
+
53
+ def _clear_store_cache() -> None:
54
+ """Drop cached stores (tests that swap db files under the same path)."""
55
+ _STORE_CACHE.clear()
56
+
57
+
58
+ def _no_store_result() -> ToolResult:
59
+ return ToolResult(
60
+ content="No MarginNote 4 library is connected on this turn; "
61
+ "MarginNote tools are unavailable.",
62
+ success=False,
63
+ )
64
+
65
+
66
+ def _ok(payload: Any) -> ToolResult:
67
+ return ToolResult(content=json.dumps(payload, ensure_ascii=False), success=True)
68
+
69
+
70
+ def _err(message: str) -> ToolResult:
71
+ return ToolResult(content=message, success=False)
72
+
73
+
74
+ def _as_int(value: Any, *, default: int, lo: int, hi: int) -> int:
75
+ try:
76
+ out = int(value)
77
+ except (TypeError, ValueError):
78
+ return default
79
+ return max(lo, min(hi, out))
80
+
81
+
82
+ class _MN4Tool(BaseTool):
83
+ """Shared store resolution + uniform error handling for MN4 tools."""
84
+
85
+ async def execute(self, **kwargs: Any) -> ToolResult:
86
+ store = _store(kwargs)
87
+ if store is None:
88
+ return _no_store_result()
89
+ try:
90
+ return await self._run(store, kwargs)
91
+ except Exception as exc:
92
+ return _err(str(exc))
93
+
94
+ async def _run(self, store: MarginNoteStore, kwargs: dict[str, Any]) -> ToolResult:
95
+ raise NotImplementedError
96
+
97
+
98
+ class MarginNoteSearchTool(_MN4Tool):
99
+ """Full-text search across synced MN4 objects."""
100
+
101
+ def get_definition(self) -> ToolDefinition:
102
+ return ToolDefinition(
103
+ name="marginnote_search",
104
+ description=(
105
+ "Search the user's MarginNote 4 library for notes, excerpts, "
106
+ "cards, or mindmap nodes whose title or content contains the "
107
+ "query (case-insensitive). Returns matching objects with a "
108
+ "short snippet. Use this first to find where something lives."
109
+ ),
110
+ parameters=[
111
+ ToolParameter(
112
+ name="query",
113
+ type="string",
114
+ description="Text to search for.",
115
+ ),
116
+ ToolParameter(
117
+ name="object_type",
118
+ type="string",
119
+ description=(
120
+ "Filter by type: note, excerpt, card, mindmap_node, document, comment."
121
+ ),
122
+ required=False,
123
+ enum=sorted(ALL_TYPES),
124
+ ),
125
+ ToolParameter(
126
+ name="limit",
127
+ type="integer",
128
+ description="Max results (default 20).",
129
+ required=False,
130
+ ),
131
+ ],
132
+ )
133
+
134
+ async def _run(self, store: MarginNoteStore, kwargs: dict[str, Any]) -> ToolResult:
135
+ query = str(kwargs.get("query") or "").strip()
136
+ if not query:
137
+ return _err("marginnote_search needs a non-empty 'query'.")
138
+ obj_type = str(kwargs.get("object_type") or "").strip()
139
+ limit = _as_int(kwargs.get("limit"), default=20, lo=1, hi=100)
140
+ hits = store.search(query, object_type=obj_type, limit=limit)
141
+ return _ok({"query": query, "count": len(hits), "results": hits})
142
+
143
+
144
+ class MarginNoteReadTool(_MN4Tool):
145
+ """Read a single MN4 object in full."""
146
+
147
+ def get_definition(self) -> ToolDefinition:
148
+ return ToolDefinition(
149
+ name="marginnote_read",
150
+ description=(
151
+ "Read a single MarginNote 4 object by its ID. Returns the full "
152
+ "object: title, content, excerpt, tags, links, source document, "
153
+ "and timestamps. Use after marginnote_search or "
154
+ "marginnote_list to drill into a specific item."
155
+ ),
156
+ parameters=[
157
+ ToolParameter(
158
+ name="object_id",
159
+ type="string",
160
+ description="The MN4 object ID from a search or list result.",
161
+ ),
162
+ ],
163
+ )
164
+
165
+ async def _run(self, store: MarginNoteStore, kwargs: dict[str, Any]) -> ToolResult:
166
+ oid = str(kwargs.get("object_id") or "").strip()
167
+ if not oid:
168
+ return _err("marginnote_read needs an 'object_id'.")
169
+ obj = store.get(oid)
170
+ if obj is None:
171
+ return _err(f"Object {oid!r} not found in the MarginNote library.")
172
+ return _ok(obj.to_dict())
173
+
174
+
175
+ class MarginNoteListTool(_MN4Tool):
176
+ """List objects, optionally filtered by type or source document."""
177
+
178
+ def get_definition(self) -> ToolDefinition:
179
+ return ToolDefinition(
180
+ name="marginnote_list",
181
+ description=(
182
+ "List MarginNote 4 objects, optionally filtered by type or "
183
+ "source document. Use to discover structure when you lack a "
184
+ "search term."
185
+ ),
186
+ parameters=[
187
+ ToolParameter(
188
+ name="object_type",
189
+ type="string",
190
+ description="Filter: note, excerpt, card, mindmap_node.",
191
+ required=False,
192
+ enum=sorted(ALL_TYPES),
193
+ ),
194
+ ToolParameter(
195
+ name="document_id",
196
+ type="string",
197
+ description="Restrict to one source document.",
198
+ required=False,
199
+ ),
200
+ ToolParameter(
201
+ name="limit",
202
+ type="integer",
203
+ description="Max results (default 200).",
204
+ required=False,
205
+ ),
206
+ ],
207
+ )
208
+
209
+ async def _run(self, store: MarginNoteStore, kwargs: dict[str, Any]) -> ToolResult:
210
+ obj_type = str(kwargs.get("object_type") or "").strip()
211
+ doc_id = str(kwargs.get("document_id") or "").strip()
212
+ limit = _as_int(kwargs.get("limit"), default=200, lo=1, hi=1000)
213
+ items = store.list_objects(object_type=obj_type, document_id=doc_id, limit=limit)
214
+ return _ok({"count": len(items), "objects": items})
215
+
216
+
217
+ class MarginNoteDocumentsTool(_MN4Tool):
218
+ """List source documents with their object counts."""
219
+
220
+ def get_definition(self) -> ToolDefinition:
221
+ return ToolDefinition(
222
+ name="marginnote_documents",
223
+ description=(
224
+ "List all source documents (PDFs, books) in the MarginNote "
225
+ "library with a count of annotations, cards, and nodes each "
226
+ "contains. Use to scope a search to one document."
227
+ ),
228
+ parameters=[],
229
+ )
230
+
231
+ async def _run(self, store: MarginNoteStore, kwargs: dict[str, Any]) -> ToolResult:
232
+ docs = store.list_documents()
233
+ return _ok({"count": len(docs), "documents": docs})
234
+
235
+
236
+ class MarginNoteLinksTool(_MN4Tool):
237
+ """Find objects linked to or from a given object."""
238
+
239
+ def get_definition(self) -> ToolDefinition:
240
+ return ToolDefinition(
241
+ name="marginnote_links",
242
+ description=(
243
+ "Find MarginNote objects linked TO or FROM the given object. "
244
+ "Links include mindmap parent/child relationships and card "
245
+ "links. Pair with marginnote_read to traverse the knowledge "
246
+ "graph."
247
+ ),
248
+ parameters=[
249
+ ToolParameter(
250
+ name="object_id",
251
+ type="string",
252
+ description="The MN4 object ID to find links for.",
253
+ ),
254
+ ],
255
+ )
256
+
257
+ async def _run(self, store: MarginNoteStore, kwargs: dict[str, Any]) -> ToolResult:
258
+ oid = str(kwargs.get("object_id") or "").strip()
259
+ if not oid:
260
+ return _err("marginnote_links needs an 'object_id'.")
261
+ links = store.linked_objects(oid)
262
+ return _ok({"object_id": oid, "count": len(links), "links": links})
263
+
264
+
265
+ class MarginNoteTagsTool(_MN4Tool):
266
+ """List all tags ranked by frequency."""
267
+
268
+ def get_definition(self) -> ToolDefinition:
269
+ return ToolDefinition(
270
+ name="marginnote_tags",
271
+ description=(
272
+ "List all tags used across the MarginNote library, ranked by "
273
+ "how many objects use each. Use to map topics before drilling in."
274
+ ),
275
+ parameters=[
276
+ ToolParameter(
277
+ name="limit",
278
+ type="integer",
279
+ description="Max tags (default 200).",
280
+ required=False,
281
+ ),
282
+ ],
283
+ )
284
+
285
+ async def _run(self, store: MarginNoteStore, kwargs: dict[str, Any]) -> ToolResult:
286
+ limit = _as_int(kwargs.get("limit"), default=200, lo=1, hi=1000)
287
+ tags = store.collect_tags(limit=limit)
288
+ return _ok({"count": len(tags), "tags": tags})
289
+
290
+
291
+ class MarginNoteCardsTool(_MN4Tool):
292
+ """List flashcards for review-scope queries."""
293
+
294
+ def get_definition(self) -> ToolDefinition:
295
+ return ToolDefinition(
296
+ name="marginnote_cards",
297
+ description=(
298
+ "List flashcards in the MarginNote library. Returns card "
299
+ "front/back content, tags, and source document. Use when the "
300
+ "user asks about their review material or wants to study."
301
+ ),
302
+ parameters=[
303
+ ToolParameter(
304
+ name="limit",
305
+ type="integer",
306
+ description="Max cards (default 100).",
307
+ required=False,
308
+ ),
309
+ ],
310
+ )
311
+
312
+ async def _run(self, store: MarginNoteStore, kwargs: dict[str, Any]) -> ToolResult:
313
+ limit = _as_int(kwargs.get("limit"), default=100, lo=1, hi=500)
314
+ cards = store.list_objects(object_type="card", limit=limit)
315
+ return _ok({"count": len(cards), "cards": cards})
316
+
317
+
318
+ MARGINNOTE_TOOL_TYPES: tuple[type[BaseTool], ...] = (
319
+ MarginNoteSearchTool,
320
+ MarginNoteReadTool,
321
+ MarginNoteListTool,
322
+ MarginNoteDocumentsTool,
323
+ MarginNoteLinksTool,
324
+ MarginNoteTagsTool,
325
+ MarginNoteCardsTool,
326
+ )
327
+
328
+
329
+ __all__ = ["MARGINNOTE_TOOL_NAMES", "MARGINNOTE_TOOL_TYPES"]
@@ -0,0 +1,6 @@
1
+ """Obsidian loop capability — agentic retrieval & authoring over a connected vault."""
2
+
3
+ from deeptutor.capabilities.obsidian.capability import ObsidianCapability
4
+ from deeptutor.capabilities.obsidian.tools import OBSIDIAN_TOOL_NAMES, OBSIDIAN_TOOL_TYPES
5
+
6
+ __all__ = ["OBSIDIAN_TOOL_NAMES", "OBSIDIAN_TOOL_TYPES", "ObsidianCapability"]
@@ -0,0 +1,75 @@
1
+ """Resolve which connected Obsidian vault (if any) the current turn targets.
2
+
3
+ The binding is derived once per turn from the user's selected knowledge bases:
4
+ the first selection whose KB metadata is ``type == obsidian`` wins, and its
5
+ ``vault_path`` becomes the live vault the Obsidian tools operate on. The result
6
+ is cached in the extension namespace so ``is_active`` / ``augment_kwargs`` /
7
+ ``system_block`` share a single filesystem lookup. Pure read — no audit
8
+ side-effects (unlike ``resolve_for_rag``), and access errors resolve to "no
9
+ vault" rather than raising.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from deeptutor.core.context import UnifiedContext
15
+ from deeptutor.knowledge.kb_types import OBSIDIAN_KB_TYPE
16
+
17
+ # Cached per extension: a {"name", "path"} dict, or "" once we've looked
18
+ # and found none. Absence of the key means "not resolved yet".
19
+ _CACHE_KEY = "_obsidian_vault"
20
+
21
+
22
+ def vault_for_turn(context: UnifiedContext) -> dict[str, str] | None:
23
+ """Return ``{"name", "path"}`` of the selected Obsidian vault, or ``None``."""
24
+ state = context.extension("obsidian")
25
+ cached = state.get(_CACHE_KEY, _UNSET)
26
+ if cached is not _UNSET:
27
+ return cached or None
28
+ resolved = _resolve(context)
29
+ state[_CACHE_KEY] = resolved or ""
30
+ return resolved
31
+
32
+
33
+ def _resolve(context: UnifiedContext) -> dict[str, str] | None:
34
+ from deeptutor.multi_user.knowledge_access import resolve_kb_metadata
35
+
36
+ for ref in context.knowledge_bases or []:
37
+ ref = str(ref).strip()
38
+ if not ref:
39
+ continue
40
+ meta = resolve_kb_metadata(ref)
41
+ if not meta or meta.get("type") != OBSIDIAN_KB_TYPE:
42
+ continue
43
+ path = str(meta.get("vault_path") or "").strip()
44
+ if path:
45
+ return {"name": str(meta.get("name") or ref), "path": path}
46
+ return None
47
+
48
+
49
+ def obsidian_vault_refs(context: UnifiedContext) -> set[str]:
50
+ """Return every selected KB ref that resolves to a connected Obsidian vault.
51
+
52
+ The capability only *operates* on the first vault (see :func:`vault_for_turn`),
53
+ but all vault refs are reported here so the chat pipeline can exclude them
54
+ from the ``rag`` surface — ``rag`` has no index for a live vault (issue #650).
55
+ """
56
+ from deeptutor.multi_user.knowledge_access import resolve_kb_metadata
57
+
58
+ refs: set[str] = set()
59
+ for ref in context.knowledge_bases or []:
60
+ ref = str(ref).strip()
61
+ if not ref:
62
+ continue
63
+ meta = resolve_kb_metadata(ref)
64
+ if (
65
+ meta
66
+ and meta.get("type") == OBSIDIAN_KB_TYPE
67
+ and str(meta.get("vault_path") or "").strip()
68
+ ):
69
+ refs.add(ref)
70
+ return refs
71
+
72
+
73
+ _UNSET = object()
74
+
75
+ __all__ = ["obsidian_vault_refs", "vault_for_turn"]
@@ -0,0 +1,82 @@
1
+ """Obsidian loop capability — agentic retrieval & authoring over a live vault.
2
+
3
+ Active whenever the user's selected knowledge base is a connected Obsidian
4
+ vault (resolved by :mod:`deeptutor.capabilities.obsidian.binding`). As a
5
+ :class:`KnowledgeCapability` it owns the turn: the chat loop runs exclusively
6
+ on the nine Obsidian tools (plus the ``ask_user`` floor), navigating and
7
+ editing the vault's Markdown directly rather than retrieving flattened chunks.
8
+
9
+ The vault root is injected into each tool call as ``_vault_path`` server-side;
10
+ the model never supplies it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from importlib import resources
16
+ from typing import Any
17
+
18
+ from deeptutor.capabilities.obsidian.binding import obsidian_vault_refs, vault_for_turn
19
+ from deeptutor.capabilities.obsidian.tools import OBSIDIAN_TOOL_NAMES
20
+ from deeptutor.capabilities.protocol import KnowledgeCapability, PromptBlock
21
+ from deeptutor.core.context import UnifiedContext
22
+ from deeptutor.services.prompt.lookup import prompt_text as _prompt_text
23
+
24
+
25
+ class ObsidianCapability(KnowledgeCapability):
26
+ """Turn-scoped integration for a connected Obsidian vault."""
27
+
28
+ name = "obsidian"
29
+ owned_tools = OBSIDIAN_TOOL_NAMES
30
+
31
+ def is_active(self, context: UnifiedContext) -> bool:
32
+ return vault_for_turn(context) is not None
33
+
34
+ def owned_kbs(self, context: UnifiedContext) -> set[str]:
35
+ # The selected vault KB(s) are read/authored through the Obsidian tools,
36
+ # never through rag — exclude them so co-selected LlamaIndex KBs keep
37
+ # their rag surface (issue #650).
38
+ return obsidian_vault_refs(context)
39
+
40
+ def system_block(
41
+ self,
42
+ context: UnifiedContext,
43
+ *,
44
+ language: str,
45
+ prompts: dict[str, Any],
46
+ ) -> PromptBlock | None:
47
+ binding = vault_for_turn(context)
48
+ if binding is None:
49
+ return None
50
+ override = _prompt_text(prompts, ("obsidian", "system"))
51
+ content = override or _load_system_prompt(language)
52
+ return PromptBlock("obsidian", content.replace("{vault_name}", binding["name"]))
53
+
54
+ def augment_kwargs(
55
+ self,
56
+ tool_name: str,
57
+ kwargs: dict[str, Any],
58
+ context: UnifiedContext,
59
+ ) -> dict[str, Any]:
60
+ if tool_name not in OBSIDIAN_TOOL_NAMES:
61
+ return kwargs
62
+ binding = vault_for_turn(context)
63
+ if binding is None:
64
+ return kwargs
65
+ # Server-owned: overwrite any model-supplied value so the path can't be
66
+ # forged to read/write outside the connected vault.
67
+ updated = dict(kwargs)
68
+ updated["_vault_path"] = binding["path"]
69
+ return updated
70
+
71
+ def pre_loop_seed(self, context: UnifiedContext) -> str:
72
+ _ = context
73
+ return ""
74
+
75
+
76
+ def _load_system_prompt(language: str) -> str:
77
+ lang = "zh" if language.lower().startswith("zh") else "en"
78
+ prompt = resources.files(__package__).joinpath("prompts", lang, "system.md")
79
+ return prompt.read_text(encoding="utf-8").strip()
80
+
81
+
82
+ __all__ = ["ObsidianCapability"]
@@ -0,0 +1,32 @@
1
+ # Obsidian Vault
2
+
3
+ You are connected to the user's Obsidian vault **{vault_name}** — a graph of
4
+ linked Markdown notes. This turn you work *only* with the Obsidian tools; there
5
+ is no web, code, or other knowledge base. The vault is the source of truth:
6
+ read it to answer, write to it to capture.
7
+
8
+ ## Retrieving (answering from the vault)
9
+
10
+ Don't guess — explore. A typical path:
11
+
12
+ 1. `obsidian_search` for the topic, or `obsidian_tags` / `obsidian_list` to map
13
+ the vault when you lack a search term.
14
+ 2. `obsidian_read` the promising notes.
15
+ 3. Follow the graph: `obsidian_backlinks` (what links here) and `obsidian_links`
16
+ (what this note points to) surface related notes a keyword search misses.
17
+ 4. Answer grounded in what you read, citing note names. If the vault doesn't
18
+ cover it, say so rather than inventing.
19
+
20
+ ## Writing (capturing into the vault)
21
+
22
+ When asked to save, summarise, or organise:
23
+
24
+ - `obsidian_create_note` for a new note, `obsidian_append` to extend one,
25
+ `obsidian_set_property` to set a frontmatter field. Writes are additive —
26
+ you never overwrite or delete existing prose.
27
+ - Write valid **Obsidian Flavored Markdown**: link related notes with
28
+ `[[Note Name]]` (not Markdown links), embed with `![[Note]]` or `![[img.png]]`,
29
+ highlight with callouts `> [!note]` / `> [!tip]` / `> [!warning]`.
30
+ - Put structured metadata (tags, aliases, status, dates) in frontmatter
31
+ properties, not inline prose.
32
+ - Prefer linking new notes into the existing graph so they're discoverable.
@@ -0,0 +1,28 @@
1
+ # Obsidian 知识库
2
+
3
+ 你已连接到用户的 Obsidian 知识库 **{vault_name}**——一张由 `[[双链]]` 连接的
4
+ Markdown 笔记图谱。本轮你**只**使用 Obsidian 工具,没有联网、代码或其它知识库。
5
+ 知识库就是真理之源:读它来回答,写它来沉淀。
6
+
7
+ ## 检索(从库中作答)
8
+
9
+ 不要猜,去探索。典型路径:
10
+
11
+ 1. 用 `obsidian_search` 搜主题;没有搜索词时用 `obsidian_tags` / `obsidian_list`
12
+ 先摸清结构。
13
+ 2. 用 `obsidian_read` 读有价值的笔记。
14
+ 3. 顺着图谱走:`obsidian_backlinks`(谁链到这条)和 `obsidian_links`(这条链向谁)
15
+ 能找出关键词搜不到的关联笔记。
16
+ 4. 基于读到的内容作答,注明引用的笔记名。库里没有就如实说,不要编造。
17
+
18
+ ## 写入(沉淀进库)
19
+
20
+ 当用户要求保存、总结或整理时:
21
+
22
+ - 新建用 `obsidian_create_note`,追加用 `obsidian_append`,设置 frontmatter 字段
23
+ 用 `obsidian_set_property`。写入只增不改——绝不覆盖或删除已有正文。
24
+ - 写合规的 **Obsidian 风味 Markdown**:用 `[[笔记名]]` 链接库内笔记(不要用
25
+ Markdown 链接),用 `![[笔记]]` 或 `![[图片.png]]` 嵌入,用 callout
26
+ `> [!note]` / `> [!tip]` / `> [!warning]` 高亮。
27
+ - 把结构化元数据(标签、别名、状态、日期)放进 frontmatter 属性,而非正文。
28
+ - 尽量把新笔记链接进已有图谱,让它可被发现。