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,399 @@
1
+ """Obsidian tools — the seam between the chat loop and a connected vault.
2
+
3
+ Nine tools auto-mounted only when an Obsidian vault is the selected KB (via
4
+ :class:`~deeptutor.capabilities.obsidian.capability.ObsidianCapability`, which
5
+ runs the turn exclusively on these tools). Six read the vault (navigate links,
6
+ search, read notes, list tags) and three write it additively (create / append /
7
+ set a property). Every tool is a thin wrapper over the pure
8
+ :mod:`deeptutor.capabilities.obsidian.vault` operations.
9
+
10
+ The vault root is injected server-side as ``_vault_path`` by the capability's
11
+ ``augment_kwargs`` (resolved from the selected KB's ``vault_path``); the model
12
+ never supplies or sees it, so it cannot read or write outside the vault.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import json
19
+ import logging
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from deeptutor.capabilities.obsidian import vault as vault_ops
24
+ from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolParameter, ToolResult
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Tool names mounted together when an Obsidian turn is active. Single source of
29
+ # truth so the mount policy and the registration list can't disagree.
30
+ OBSIDIAN_TOOL_NAMES: tuple[str, ...] = (
31
+ "obsidian_search",
32
+ "obsidian_read",
33
+ "obsidian_list",
34
+ "obsidian_backlinks",
35
+ "obsidian_links",
36
+ "obsidian_tags",
37
+ "obsidian_create_note",
38
+ "obsidian_append",
39
+ "obsidian_set_property",
40
+ )
41
+
42
+
43
+ def _vault_root(kwargs: dict[str, Any]) -> Path | None:
44
+ raw = str(kwargs.get("_vault_path") or "").strip()
45
+ if not raw:
46
+ return None
47
+ root = Path(raw)
48
+ return root if root.is_dir() else None
49
+
50
+
51
+ def _no_vault_result() -> ToolResult:
52
+ return ToolResult(
53
+ content="No Obsidian vault is connected on this turn; Obsidian tools are unavailable.",
54
+ success=False,
55
+ )
56
+
57
+
58
+ def _json_default(value: Any) -> str:
59
+ """Render a frontmatter scalar ``json`` has no encoder for.
60
+
61
+ YAML parses an unquoted ``created: 2026-08-11`` into a ``datetime.date``,
62
+ and Obsidian's own Templates plugin writes dates that way. Dates and times
63
+ serialise as ISO-8601 — ``str()`` alone renders a ``datetime`` as
64
+ ``2026-08-11 00:00:00``, which is not a form the model can feed back as a
65
+ date. Anything else degrades to its string form rather than failing the
66
+ whole tool call.
67
+ """
68
+ if isinstance(value, (datetime.date, datetime.time)):
69
+ return value.isoformat()
70
+ return str(value)
71
+
72
+
73
+ def _ok(payload: Any) -> ToolResult:
74
+ return ToolResult(
75
+ content=json.dumps(payload, ensure_ascii=False, default=_json_default),
76
+ success=True,
77
+ )
78
+
79
+
80
+ def _err(message: str) -> ToolResult:
81
+ return ToolResult(content=message, success=False)
82
+
83
+
84
+ class _ObsidianTool(BaseTool):
85
+ """Shared vault-root resolution + uniform error handling for Obsidian tools."""
86
+
87
+ async def execute(self, **kwargs: Any) -> ToolResult:
88
+ root = _vault_root(kwargs)
89
+ if root is None:
90
+ return _no_vault_result()
91
+ try:
92
+ return await self._run(root, kwargs)
93
+ except vault_ops.VaultError as exc:
94
+ return _err(str(exc))
95
+ except Exception as exc:
96
+ # A tool failure the model cannot see is one it cannot route around:
97
+ # an opaque "unknown error" sent it inventing workarounds for a
98
+ # cause it had no information about. Name the cause for the model
99
+ # and keep the traceback for the operator.
100
+ name = self.get_definition().name
101
+ logger.exception("Obsidian tool %s failed", name)
102
+ return _err(f"{name} failed: {type(exc).__name__}: {exc}")
103
+
104
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult: # pragma: no cover
105
+ raise NotImplementedError
106
+
107
+
108
+ class ObsidianSearchTool(_ObsidianTool):
109
+ """Full-text search across the vault's notes."""
110
+
111
+ def get_definition(self) -> ToolDefinition:
112
+ return ToolDefinition(
113
+ name="obsidian_search",
114
+ description=(
115
+ "Search the user's Obsidian vault for notes whose title or body "
116
+ "contains the query (case-insensitive). Returns matching note "
117
+ "paths with a short snippet. Use this first to find where "
118
+ "something lives, then obsidian_read the promising notes."
119
+ ),
120
+ parameters=[
121
+ ToolParameter(name="query", type="string", description="Text to search for."),
122
+ ToolParameter(
123
+ name="limit",
124
+ type="integer",
125
+ description="Max results (default 20).",
126
+ required=False,
127
+ ),
128
+ ],
129
+ )
130
+
131
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
132
+ query = str(kwargs.get("query") or "").strip()
133
+ if not query:
134
+ return _err("obsidian_search needs a non-empty 'query'.")
135
+ limit = _as_int(kwargs.get("limit"), default=20, lo=1, hi=100)
136
+ hits = vault_ops.search_notes(root, query, limit=limit)
137
+ return _ok({"query": query, "count": len(hits), "results": hits})
138
+
139
+
140
+ class ObsidianReadTool(_ObsidianTool):
141
+ """Read a note's frontmatter and body."""
142
+
143
+ def get_definition(self) -> ToolDefinition:
144
+ return ToolDefinition(
145
+ name="obsidian_read",
146
+ description=(
147
+ "Read a note from the vault. Accepts a bare note name (resolved "
148
+ "like a [[wikilink]]) or a vault-relative path. Returns the "
149
+ "note's frontmatter properties and Markdown body."
150
+ ),
151
+ parameters=[
152
+ ToolParameter(
153
+ name="note",
154
+ type="string",
155
+ description="Note name (e.g. 'Project Plan') or path (e.g. 'work/Plan.md').",
156
+ ),
157
+ ],
158
+ )
159
+
160
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
161
+ ref = str(kwargs.get("note") or "").strip()
162
+ if not ref:
163
+ return _err("obsidian_read needs a 'note' name or path.")
164
+ return _ok(vault_ops.read_note(root, ref))
165
+
166
+
167
+ class ObsidianListTool(_ObsidianTool):
168
+ """List notes in the vault or a folder."""
169
+
170
+ def get_definition(self) -> ToolDefinition:
171
+ return ToolDefinition(
172
+ name="obsidian_list",
173
+ description=(
174
+ "List note paths in the vault, optionally restricted to a folder. "
175
+ "Use to discover structure when you don't have a search term."
176
+ ),
177
+ parameters=[
178
+ ToolParameter(
179
+ name="folder",
180
+ type="string",
181
+ description="Vault-relative folder to list (empty = whole vault).",
182
+ required=False,
183
+ ),
184
+ ToolParameter(
185
+ name="limit",
186
+ type="integer",
187
+ description="Max paths (default 200).",
188
+ required=False,
189
+ ),
190
+ ],
191
+ )
192
+
193
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
194
+ folder = str(kwargs.get("folder") or "").strip()
195
+ limit = _as_int(kwargs.get("limit"), default=200, lo=1, hi=1000)
196
+ paths = vault_ops.list_notes(root, folder=folder, limit=limit)
197
+ return _ok({"folder": folder, "count": len(paths), "notes": paths})
198
+
199
+
200
+ class ObsidianBacklinksTool(_ObsidianTool):
201
+ """Find notes that link to a given note."""
202
+
203
+ def get_definition(self) -> ToolDefinition:
204
+ return ToolDefinition(
205
+ name="obsidian_backlinks",
206
+ description=(
207
+ "Find notes that link TO the given note via [[wikilink]]. The "
208
+ "backbone of vault navigation — follow backlinks to discover how "
209
+ "ideas connect."
210
+ ),
211
+ parameters=[
212
+ ToolParameter(name="note", type="string", description="Note name or path."),
213
+ ],
214
+ )
215
+
216
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
217
+ ref = str(kwargs.get("note") or "").strip()
218
+ if not ref:
219
+ return _err("obsidian_backlinks needs a 'note' name or path.")
220
+ links = vault_ops.backlinks(root, ref)
221
+ return _ok({"note": ref, "count": len(links), "backlinks": links})
222
+
223
+
224
+ class ObsidianLinksTool(_ObsidianTool):
225
+ """List the outgoing wikilinks of a note."""
226
+
227
+ def get_definition(self) -> ToolDefinition:
228
+ return ToolDefinition(
229
+ name="obsidian_links",
230
+ description=(
231
+ "List the notes a given note links OUT to via [[wikilink]], in "
232
+ "order. Pair with obsidian_backlinks to traverse the graph both ways."
233
+ ),
234
+ parameters=[
235
+ ToolParameter(name="note", type="string", description="Note name or path."),
236
+ ],
237
+ )
238
+
239
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
240
+ ref = str(kwargs.get("note") or "").strip()
241
+ if not ref:
242
+ return _err("obsidian_links needs a 'note' name or path.")
243
+ links = vault_ops.outgoing_links(root, ref)
244
+ return _ok({"note": ref, "count": len(links), "links": links})
245
+
246
+
247
+ class ObsidianTagsTool(_ObsidianTool):
248
+ """List the vault's tags by frequency."""
249
+
250
+ def get_definition(self) -> ToolDefinition:
251
+ return ToolDefinition(
252
+ name="obsidian_tags",
253
+ description=(
254
+ "List all tags used across the vault (inline #tags and frontmatter "
255
+ "tags), ranked by how many notes use each. Use to map the vault's "
256
+ "topics before drilling in."
257
+ ),
258
+ parameters=[
259
+ ToolParameter(
260
+ name="limit",
261
+ type="integer",
262
+ description="Max tags (default 200).",
263
+ required=False,
264
+ ),
265
+ ],
266
+ )
267
+
268
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
269
+ limit = _as_int(kwargs.get("limit"), default=200, lo=1, hi=1000)
270
+ tags = vault_ops.collect_tags(root, limit=limit)
271
+ return _ok({"count": len(tags), "tags": tags})
272
+
273
+
274
+ class ObsidianCreateNoteTool(_ObsidianTool):
275
+ """Create a new note (never overwrites)."""
276
+
277
+ def get_definition(self) -> ToolDefinition:
278
+ return ToolDefinition(
279
+ name="obsidian_create_note",
280
+ description=(
281
+ "Create a NEW note in the vault. Fails if the path already exists "
282
+ "(use obsidian_append for existing notes). Write valid Obsidian "
283
+ "Flavored Markdown: [[wikilinks]] for vault notes, > [!note] "
284
+ "callouts, ![[embeds]]. Pass structured metadata as 'properties' "
285
+ "(frontmatter), not inline."
286
+ ),
287
+ parameters=[
288
+ ToolParameter(
289
+ name="path",
290
+ type="string",
291
+ description="Vault-relative path, e.g. 'Summaries/Photosynthesis.md'.",
292
+ ),
293
+ ToolParameter(
294
+ name="content",
295
+ type="string",
296
+ description=(
297
+ "Markdown body. Must be non-empty — write the complete "
298
+ "note text in this call; extend an existing note later "
299
+ "with obsidian_append."
300
+ ),
301
+ ),
302
+ ToolParameter(
303
+ name="properties",
304
+ type="object",
305
+ description="Optional frontmatter properties (tags, aliases, …).",
306
+ required=False,
307
+ ),
308
+ ],
309
+ )
310
+
311
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
312
+ path = str(kwargs.get("path") or "").strip()
313
+ if not path:
314
+ return _err("obsidian_create_note needs a 'path'.")
315
+ properties = kwargs.get("properties")
316
+ frontmatter = properties if isinstance(properties, dict) else None
317
+ created = vault_ops.create_note(
318
+ root, path, str(kwargs.get("content") or ""), frontmatter=frontmatter
319
+ )
320
+ return _ok({"status": "created", "path": created})
321
+
322
+
323
+ class ObsidianAppendTool(_ObsidianTool):
324
+ """Append to an existing note."""
325
+
326
+ def get_definition(self) -> ToolDefinition:
327
+ return ToolDefinition(
328
+ name="obsidian_append",
329
+ description=(
330
+ "Append Markdown to the end of an existing note. Use for adding to "
331
+ "a daily note, log, or running document without rewriting it."
332
+ ),
333
+ parameters=[
334
+ ToolParameter(name="note", type="string", description="Note name or path."),
335
+ ToolParameter(name="content", type="string", description="Markdown to append."),
336
+ ],
337
+ )
338
+
339
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
340
+ ref = str(kwargs.get("note") or "").strip()
341
+ if not ref:
342
+ return _err("obsidian_append needs a 'note' name or path.")
343
+ updated = vault_ops.append_note(root, ref, str(kwargs.get("content") or ""))
344
+ return _ok({"status": "appended", "path": updated})
345
+
346
+
347
+ class ObsidianSetPropertyTool(_ObsidianTool):
348
+ """Set a frontmatter property on an existing note."""
349
+
350
+ def get_definition(self) -> ToolDefinition:
351
+ return ToolDefinition(
352
+ name="obsidian_set_property",
353
+ description=(
354
+ "Set a single frontmatter property (key/value) on an existing "
355
+ "note, e.g. status, due date, or a tag list. Creates the "
356
+ "frontmatter block if absent; leaves the body untouched."
357
+ ),
358
+ parameters=[
359
+ ToolParameter(name="note", type="string", description="Note name or path."),
360
+ ToolParameter(name="key", type="string", description="Property name."),
361
+ ToolParameter(
362
+ name="value",
363
+ type="string",
364
+ description="Property value (string, or a comma-free scalar).",
365
+ ),
366
+ ],
367
+ )
368
+
369
+ async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
370
+ ref = str(kwargs.get("note") or "").strip()
371
+ key = str(kwargs.get("key") or "").strip()
372
+ if not ref or not key:
373
+ return _err("obsidian_set_property needs 'note' and 'key'.")
374
+ updated = vault_ops.set_property(root, ref, key, kwargs.get("value"))
375
+ return _ok({"status": "updated", "path": updated, "key": key})
376
+
377
+
378
+ def _as_int(value: Any, *, default: int, lo: int, hi: int) -> int:
379
+ try:
380
+ out = int(value)
381
+ except (TypeError, ValueError):
382
+ return default
383
+ return max(lo, min(hi, out))
384
+
385
+
386
+ OBSIDIAN_TOOL_TYPES: tuple[type[BaseTool], ...] = (
387
+ ObsidianSearchTool,
388
+ ObsidianReadTool,
389
+ ObsidianListTool,
390
+ ObsidianBacklinksTool,
391
+ ObsidianLinksTool,
392
+ ObsidianTagsTool,
393
+ ObsidianCreateNoteTool,
394
+ ObsidianAppendTool,
395
+ ObsidianSetPropertyTool,
396
+ )
397
+
398
+
399
+ __all__ = ["OBSIDIAN_TOOL_NAMES", "OBSIDIAN_TOOL_TYPES"]