Return artifacts from Mealie, Obsidian and search tools
Convert mealie_get_recipe, mealie_meal_plans, mealie_todays_meals, mealie_shopping_lists, obsidian_read_note, journal_get_today, obsidian_search_notes and simba_search to content_and_artifact. The text the LLM sees is unchanged; the artifact is a JSON-safe ToolArtifact dict per the Cowork UI contract (mealie.recipe, mealie.meal_plan, mealie.shopping_lists, obsidian.note, search.documents). Error, empty and not-configured paths return no artifact. simba_search's artifact changes from a list of Documents (unused) to a search.documents dict. New optional env vars: MEALIE_GROUP_SLUG (default "home") for Mealie v2 recipe links, OBSIDIAN_VAULT_NAME for obsidian://open links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,19 @@ from langchain_core.runnables import RunnableConfig, RunnableLambda
|
||||
from langchain_openai import ChatOpenAI
|
||||
from tavily import AsyncTavilyClient
|
||||
|
||||
from blueprints.conversation.artifacts_util import (
|
||||
NOTE_MAX_CHARS,
|
||||
format_date_range,
|
||||
format_day,
|
||||
json_safe,
|
||||
make_artifact,
|
||||
mealie_source_ref,
|
||||
note_title,
|
||||
obsidian_source_ref,
|
||||
search_documents_artifact,
|
||||
truncate,
|
||||
unique,
|
||||
)
|
||||
from blueprints.conversation.memory import save_memory
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
from blueprints.rag.logic import query_vector_store
|
||||
@@ -131,20 +144,36 @@ async def simba_search(query: str):
|
||||
print(f"[SIMBA SEARCH] Found {len(docs)} documents")
|
||||
print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}")
|
||||
print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}")
|
||||
return serialized, docs
|
||||
return serialized, search_documents_artifact(query, "paperless", docs)
|
||||
|
||||
|
||||
@tool
|
||||
async def mealie_todays_meals() -> str:
|
||||
MEALIE_NOT_CONFIGURED = "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
|
||||
|
||||
def _meal_entry(meal: dict) -> dict:
|
||||
"""Meal plan entry in the `mealie.meal_plan` artifact shape."""
|
||||
entry = {
|
||||
"entry_type": meal["entry_type"],
|
||||
"name": meal["recipe_name"] or meal["title"] or "Untitled",
|
||||
}
|
||||
if meal.get("recipe_slug"):
|
||||
entry["recipe_slug"] = meal["recipe_slug"]
|
||||
if meal.get("note"):
|
||||
entry["note"] = meal["note"]
|
||||
return entry
|
||||
|
||||
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def mealie_todays_meals():
|
||||
"""Get today's planned meals from Mealie."""
|
||||
if not mealie_enabled:
|
||||
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
return MEALIE_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
result = await mealie_service.get_todays_meals()
|
||||
|
||||
if result["total_meals"] == 0:
|
||||
return f"No meals planned for today ({result['today']})."
|
||||
return f"No meals planned for today ({result['today']}).", None
|
||||
|
||||
lines = [f"Meals planned for today ({result['today']}):"]
|
||||
for meal in result["meals"]:
|
||||
@@ -152,13 +181,29 @@ async def mealie_todays_meals() -> str:
|
||||
lines.append(f"- {meal['entry_type'].capitalize()}: {name}")
|
||||
if meal["note"]:
|
||||
lines.append(f" Note: {meal['note']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
today = result["today"]
|
||||
artifact = make_artifact(
|
||||
"mealie.meal_plan",
|
||||
f"Today's meals · {format_day(today)}",
|
||||
{
|
||||
"start_date": today,
|
||||
"end_date": today,
|
||||
"days": [
|
||||
{
|
||||
"date": today,
|
||||
"meals": [_meal_entry(m) for m in result["meals"]],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
except Exception as e:
|
||||
return f"Error fetching today's meals: {str(e)}"
|
||||
return f"Error fetching today's meals: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def mealie_meal_plans(start_date: str = "", end_date: str = ""):
|
||||
"""Get Mealie meal plans for a date range.
|
||||
|
||||
Args:
|
||||
@@ -166,7 +211,7 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||
end_date: YYYY-MM-DD (default: 7 days out)
|
||||
"""
|
||||
if not mealie_enabled:
|
||||
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
return MEALIE_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
result = await mealie_service.get_meal_plans(
|
||||
@@ -175,11 +220,15 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||
)
|
||||
|
||||
if result["total_plans"] == 0:
|
||||
return f"No meal plans found from {result['start_date']} to {result['end_date']}."
|
||||
return (
|
||||
f"No meal plans found from {result['start_date']} to {result['end_date']}.",
|
||||
None,
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"Meal plans from {result['start_date']} to {result['end_date']} ({result['total_plans']} meals):"
|
||||
]
|
||||
days = []
|
||||
for date, plans in sorted(result["plans_by_date"].items()):
|
||||
lines.append(f"\n{date}:")
|
||||
for plan in plans:
|
||||
@@ -187,20 +236,31 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||
lines.append(f" - {plan['entry_type'].capitalize()}: {name}")
|
||||
if plan["note"]:
|
||||
lines.append(f" Note: {plan['note']}")
|
||||
return "\n".join(lines)
|
||||
days.append({"date": date, "meals": [_meal_entry(p) for p in plans]})
|
||||
|
||||
artifact = make_artifact(
|
||||
"mealie.meal_plan",
|
||||
f"Meal plan · {format_date_range(result['start_date'], result['end_date'])}",
|
||||
{
|
||||
"start_date": result["start_date"],
|
||||
"end_date": result["end_date"],
|
||||
"days": days,
|
||||
},
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
except Exception as e:
|
||||
return f"Error fetching meal plans: {str(e)}"
|
||||
return f"Error fetching meal plans: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
async def mealie_get_recipe(recipe_slug: str) -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def mealie_get_recipe(recipe_slug: str):
|
||||
"""Get full recipe details (ingredients, instructions, times) from Mealie.
|
||||
|
||||
Args:
|
||||
recipe_slug: Recipe slug from meal plan data
|
||||
"""
|
||||
if not mealie_enabled:
|
||||
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
return MEALIE_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
recipe = await mealie_service.get_recipe(recipe_slug)
|
||||
@@ -236,22 +296,49 @@ async def mealie_get_recipe(recipe_slug: str) -> str:
|
||||
if recipe["tags"]:
|
||||
lines.append(f"\nTags: {', '.join(recipe['tags'])}")
|
||||
|
||||
return "\n".join(lines)
|
||||
instructions = []
|
||||
for step in recipe["instructions"]:
|
||||
instruction = {"text": step.get("text", "")}
|
||||
if step.get("title"):
|
||||
instruction["title"] = step["title"]
|
||||
instructions.append(instruction)
|
||||
|
||||
slug = recipe.get("slug") or recipe_slug
|
||||
artifact = make_artifact(
|
||||
"mealie.recipe",
|
||||
recipe["name"],
|
||||
json_safe(
|
||||
{
|
||||
"slug": slug,
|
||||
"name": recipe["name"],
|
||||
"description": recipe["description"],
|
||||
"servings": recipe["servings"],
|
||||
"prep_time": recipe["prep_time"],
|
||||
"cook_time": recipe["cook_time"],
|
||||
"total_time": recipe["total_time"],
|
||||
"ingredients": [ing["display"] for ing in recipe["ingredients"]],
|
||||
"instructions": instructions,
|
||||
"tags": recipe["tags"],
|
||||
}
|
||||
),
|
||||
mealie_source_ref(slug),
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
except Exception as e:
|
||||
return f"Error fetching recipe: {str(e)}"
|
||||
return f"Error fetching recipe: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
async def mealie_shopping_lists() -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def mealie_shopping_lists():
|
||||
"""Get Mealie shopping lists with needed and checked-off items."""
|
||||
if not mealie_enabled:
|
||||
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||
return MEALIE_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
result = await mealie_service.get_shopping_lists()
|
||||
|
||||
if result["total_lists"] == 0:
|
||||
return "No shopping lists found."
|
||||
return "No shopping lists found.", None
|
||||
|
||||
lines = [f"Shopping lists ({result['total_unchecked_items']} items needed):"]
|
||||
for sl in result["shopping_lists"]:
|
||||
@@ -270,9 +357,37 @@ async def mealie_shopping_lists() -> str:
|
||||
lines.append(
|
||||
f" ({len(sl['checked_items'])} items already checked off)"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lists = [
|
||||
{
|
||||
"name": sl["name"],
|
||||
"items": [
|
||||
{
|
||||
"name": item["name"],
|
||||
"quantity": item["quantity"],
|
||||
"checked": False,
|
||||
}
|
||||
for item in sl["unchecked_items"]
|
||||
]
|
||||
+ [
|
||||
{
|
||||
"name": item["name"],
|
||||
"quantity": item["quantity"],
|
||||
"checked": True,
|
||||
}
|
||||
for item in sl["checked_items"]
|
||||
],
|
||||
}
|
||||
for sl in result["shopping_lists"]
|
||||
]
|
||||
artifact = make_artifact(
|
||||
"mealie.shopping_lists",
|
||||
"Shopping lists",
|
||||
json_safe({"lists": lists}),
|
||||
)
|
||||
return "\n".join(lines), artifact
|
||||
except Exception as e:
|
||||
return f"Error fetching shopping lists: {str(e)}"
|
||||
return f"Error fetching shopping lists: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
@@ -309,34 +424,55 @@ async def mealie_create_meal_plan(
|
||||
return f"Error creating meal plan: {str(e)}"
|
||||
|
||||
|
||||
@tool
|
||||
async def obsidian_search_notes(query: str) -> str:
|
||||
OBSIDIAN_NOT_CONFIGURED = "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||
|
||||
|
||||
def _note_artifact(path: str, parsed: dict, title: str | None = None) -> dict:
|
||||
"""`obsidian.note` artifact from ObsidianService.parse_markdown output."""
|
||||
frontmatter = json_safe(parsed["metadata"])
|
||||
title = title or note_title(path, frontmatter)
|
||||
return make_artifact(
|
||||
"obsidian.note",
|
||||
title,
|
||||
{
|
||||
"path": path,
|
||||
"title": title,
|
||||
"markdown": truncate(parsed["content"], NOTE_MAX_CHARS),
|
||||
"tags": unique(parsed["tags"]),
|
||||
"frontmatter": frontmatter,
|
||||
},
|
||||
obsidian_source_ref(path),
|
||||
)
|
||||
|
||||
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def obsidian_search_notes(query: str):
|
||||
"""Search personal Obsidian vault notes.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
"""
|
||||
if not obsidian_enabled:
|
||||
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||
return OBSIDIAN_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
# Query vector store filtered to obsidian source only
|
||||
serialized, docs = await query_vector_store(query=query, source="obsidian")
|
||||
return serialized
|
||||
return serialized, search_documents_artifact(query, "obsidian", docs)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error searching Obsidian notes: {str(e)}"
|
||||
return f"Error searching Obsidian notes: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
async def obsidian_read_note(relative_path: str) -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def obsidian_read_note(relative_path: str):
|
||||
"""Read the full content of a specific Obsidian note by path.
|
||||
|
||||
Args:
|
||||
relative_path: Path relative to vault root (e.g., "notes/my-note.md")
|
||||
"""
|
||||
if not obsidian_enabled:
|
||||
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||
return OBSIDIAN_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
note = obsidian_service.read_note(relative_path)
|
||||
@@ -348,12 +484,15 @@ async def obsidian_read_note(relative_path: str) -> str:
|
||||
result += f"Tags: {', '.join(content_data['tags'])}\n"
|
||||
result += f"Contains {len(content_data['wikilinks'])} wikilinks and {len(content_data['embeds'])} embeds"
|
||||
|
||||
return result
|
||||
return result, _note_artifact(note["path"], content_data)
|
||||
|
||||
except FileNotFoundError:
|
||||
return f"Note not found at '{relative_path}'. Please check the path is correct."
|
||||
return (
|
||||
f"Note not found at '{relative_path}'. Please check the path is correct.",
|
||||
None,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error reading note: {str(e)}"
|
||||
return f"Error reading note: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
@@ -391,19 +530,27 @@ async def obsidian_create_note(
|
||||
return f"Error creating note: {str(e)}"
|
||||
|
||||
|
||||
@tool
|
||||
def journal_get_today() -> str:
|
||||
@tool(response_format="content_and_artifact")
|
||||
def journal_get_today():
|
||||
"""Get today's full daily journal note including tasks and log entries."""
|
||||
if not obsidian_enabled:
|
||||
return "Obsidian integration is not configured."
|
||||
return "Obsidian integration is not configured.", None
|
||||
|
||||
try:
|
||||
note = obsidian_service.get_daily_note()
|
||||
if not note["found"]:
|
||||
return f"No daily note found for {note['date']}. Use journal_add_task to create one."
|
||||
return f"Daily note for {note['date']}:\n\n{note['content']}"
|
||||
return (
|
||||
f"No daily note found for {note['date']}. Use journal_add_task to create one.",
|
||||
None,
|
||||
)
|
||||
artifact = _note_artifact(
|
||||
note["path"],
|
||||
obsidian_service.parse_markdown(note["content"]),
|
||||
title=f"Daily note · {format_day(note['date'])}",
|
||||
)
|
||||
return f"Daily note for {note['date']}:\n\n{note['content']}", artifact
|
||||
except Exception as e:
|
||||
return f"Error reading daily note: {str(e)}"
|
||||
return f"Error reading daily note: {str(e)}", None
|
||||
|
||||
|
||||
@tool
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Helpers for building ToolArtifact dicts returned by agent tools.
|
||||
|
||||
See docs/cowork-ui-plan.md ("Artifacts") for the contract. Artifacts must be
|
||||
plain JSON-serialisable dicts: they ride along in ToolMessage.artifact and are
|
||||
stored in the LangGraph checkpoint.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
SNIPPET_MAX_CHARS = 400
|
||||
NOTE_MAX_CHARS = 50_000
|
||||
ELLIPSIS = "…"
|
||||
|
||||
|
||||
def make_artifact(
|
||||
kind: str, title: str, data: dict, source_ref: dict | None = None
|
||||
) -> dict:
|
||||
artifact = {"kind": kind, "title": title, "data": data}
|
||||
if source_ref:
|
||||
artifact["source_ref"] = source_ref
|
||||
return artifact
|
||||
|
||||
|
||||
def truncate(text: str, limit: int) -> str:
|
||||
"""Cap text at `limit` chars, ending in an ellipsis when cut."""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - len(ELLIPSIS)].rstrip() + ELLIPSIS
|
||||
|
||||
|
||||
def snippet(text: str, limit: int = SNIPPET_MAX_CHARS) -> str:
|
||||
"""Collapse whitespace and cap at the snippet limit."""
|
||||
return truncate(" ".join((text or "").split()), limit)
|
||||
|
||||
|
||||
def json_safe(value):
|
||||
"""Convert a value (e.g. parsed YAML frontmatter) to JSON-safe types."""
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
return value
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [json_safe(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): json_safe(v) for k, v in value.items()}
|
||||
return str(value)
|
||||
|
||||
|
||||
def unique(items) -> list:
|
||||
"""Deduplicate while keeping first-seen order."""
|
||||
return list(dict.fromkeys(items))
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date | None:
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def format_day(value: str) -> str:
|
||||
"""'2026-10-12' -> 'Oct 12'. Unparseable input is returned unchanged."""
|
||||
d = _parse_date(value)
|
||||
return f"{d:%b} {d.day}" if d else str(value)
|
||||
|
||||
|
||||
def format_date_range(start: str, end: str) -> str:
|
||||
"""Human date range: 'Oct 12–19', 'Oct 28–Nov 3', 'Dec 28, 2026–Jan 3, 2027'."""
|
||||
s, e = _parse_date(start), _parse_date(end)
|
||||
if not s or not e:
|
||||
return f"{start}–{end}"
|
||||
if s == e:
|
||||
return f"{s:%b} {s.day}"
|
||||
if s.year != e.year:
|
||||
return f"{s:%b} {s.day}, {s.year}–{e:%b} {e.day}, {e.year}"
|
||||
if s.month == e.month:
|
||||
return f"{s:%b} {s.day}–{e.day}"
|
||||
return f"{s:%b} {s.day}–{e:%b} {e.day}"
|
||||
|
||||
|
||||
def epoch_to_date(value) -> str | None:
|
||||
"""Epoch seconds -> 'YYYY-MM-DD' (None if not a number)."""
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d")
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def mealie_recipe_url(slug: str) -> str | None:
|
||||
"""Recipe page URL for Mealie v2 (`/g/<group-slug>/r/<slug>`)."""
|
||||
base_url = os.getenv("MEALIE_BASE_URL", "").rstrip("/")
|
||||
if not base_url:
|
||||
return None
|
||||
group = os.getenv("MEALIE_GROUP_SLUG", "") or "home"
|
||||
return f"{base_url}/g/{quote(group)}/r/{quote(slug)}"
|
||||
|
||||
|
||||
def mealie_source_ref(slug: str) -> dict:
|
||||
ref = {"system": "mealie", "id": slug}
|
||||
url = mealie_recipe_url(slug)
|
||||
if url:
|
||||
ref["url"] = url
|
||||
return ref
|
||||
|
||||
|
||||
def obsidian_source_ref(path: str) -> dict:
|
||||
"""Obsidian ref; the obsidian:// URL needs OBSIDIAN_VAULT_NAME."""
|
||||
ref = {"system": "obsidian", "path": path}
|
||||
vault = os.getenv("OBSIDIAN_VAULT_NAME", "")
|
||||
if vault:
|
||||
ref["url"] = "obsidian://open?" + urlencode(
|
||||
{"vault": vault, "file": path}, quote_via=quote
|
||||
)
|
||||
return ref
|
||||
|
||||
|
||||
def note_title(path: str, frontmatter: dict | None = None) -> str:
|
||||
"""Frontmatter `title` if present, else the filename without extension."""
|
||||
title = (frontmatter or {}).get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
return title.strip()
|
||||
return Path(path).stem or path
|
||||
|
||||
|
||||
def vault_relative_path(filepath: str) -> str:
|
||||
"""Strip OBSIDIAN_VAULT_PATH from an absolute indexed filepath."""
|
||||
vault = os.getenv("OBSIDIAN_VAULT_PATH", "/app/data/obsidian")
|
||||
try:
|
||||
return str(Path(filepath).relative_to(vault))
|
||||
except ValueError:
|
||||
return filepath
|
||||
|
||||
|
||||
def search_documents_artifact(query: str, source: str, docs) -> dict | None:
|
||||
"""Build a `search.documents` artifact from vector-store Documents."""
|
||||
if not docs:
|
||||
return None
|
||||
results = []
|
||||
for doc in docs:
|
||||
meta = doc.metadata or {}
|
||||
result = {"snippet": snippet(doc.page_content)}
|
||||
if source == "obsidian":
|
||||
filepath = meta.get("filepath") or ""
|
||||
path = vault_relative_path(filepath) if filepath else ""
|
||||
result["title"] = note_title(path, meta) if path else "Untitled note"
|
||||
if path:
|
||||
result["path"] = path
|
||||
created = meta.get("created_at")
|
||||
if isinstance(created, str) and created:
|
||||
result["date"] = created[:10]
|
||||
else:
|
||||
filename = meta.get("filename") or ""
|
||||
result["title"] = Path(filename).stem or filename or "Document"
|
||||
if filename:
|
||||
result["filename"] = filename
|
||||
if meta.get("document_type"):
|
||||
result["document_type"] = str(meta["document_type"])
|
||||
created = epoch_to_date(meta.get("created_date"))
|
||||
if created:
|
||||
result["date"] = created
|
||||
results.append(result)
|
||||
return make_artifact(
|
||||
"search.documents",
|
||||
f"Search: {query}",
|
||||
{"query": query, "source": source, "results": results},
|
||||
)
|
||||
Reference in New Issue
Block a user