mirror of
https://github.com/andrewyng/openworker.git
synced 2026-08-30 22:53:41 +00:00
Merge 813160bcd9 into 9e145d9ceb
This commit is contained in:
@@ -54,11 +54,21 @@ ABOUT: dict[str, str] = {
|
||||
"asana": "Keep up with your Asana work — search and read tasks and "
|
||||
"projects, create tasks, and comment. Connects with a personal access "
|
||||
"token from the Asana developer console.",
|
||||
"obsidian": "Work with your Obsidian vault — search and read notes, follow "
|
||||
"wikilinks and backlinks, pull up daily notes, and (with approval) write "
|
||||
"notes. The vault is read directly from disk: no account, no keys, no "
|
||||
"plugin, and nothing ever leaves this Mac. A hand-off tool can open any "
|
||||
"note in the Obsidian app itself.",
|
||||
}
|
||||
|
||||
# What connecting actually grants, as short honest bullets. Write powers always
|
||||
# name themselves; reads state their boundary ("…your account can see").
|
||||
ACCESS: dict[str, list[str]] = {
|
||||
"obsidian": [
|
||||
"Reads notes in the vault folder you pick — never outside it",
|
||||
"Creates or edits notes only with your approval",
|
||||
"Runs entirely on this Mac; no account or network access",
|
||||
],
|
||||
"telegram": [
|
||||
"Reads messages sent to your bot — never your personal chats.",
|
||||
"Sends messages as the bot.",
|
||||
|
||||
@@ -21,6 +21,9 @@ class Field:
|
||||
required: bool = True
|
||||
help: str = ""
|
||||
placeholder: str = ""
|
||||
# Rendering hint: "" = text input; "folder" = the GUI offers the native folder
|
||||
# picker (desktop) and fills the field with the chosen path.
|
||||
kind: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -29,6 +32,7 @@ class Field:
|
||||
"secret": self.secret,
|
||||
"required": self.required,
|
||||
"help": self.help,
|
||||
"kind": self.kind,
|
||||
"placeholder": self.placeholder,
|
||||
}
|
||||
|
||||
@@ -166,6 +170,26 @@ def _validate_whoami(
|
||||
return ValidationResult(False, error="unexpected response from API")
|
||||
|
||||
|
||||
def _validate_obsidian(creds: dict) -> ValidationResult:
|
||||
"""Local check, no network: the folder exists and is an Obsidian vault (has the
|
||||
.obsidian config dir the app creates). Identity = the vault's folder name."""
|
||||
from pathlib import Path
|
||||
|
||||
raw = str(creds.get("vault_path") or "").strip()
|
||||
if not raw:
|
||||
return ValidationResult(False, error="pick your vault folder")
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_dir():
|
||||
return ValidationResult(False, error=f"folder not found: {path}")
|
||||
if not (path / ".obsidian").is_dir():
|
||||
return ValidationResult(
|
||||
False,
|
||||
error="that folder isn't an Obsidian vault — pick the folder that holds "
|
||||
"your notes (it contains a hidden .obsidian folder)",
|
||||
)
|
||||
return ValidationResult(True, identity=path.name)
|
||||
|
||||
|
||||
def _validate_notion(creds: dict) -> ValidationResult:
|
||||
return _validate_whoami(
|
||||
"GET",
|
||||
@@ -1269,6 +1293,35 @@ DESCRIPTORS: list[ConnectorDescriptor] = [
|
||||
# validator's workspace name.
|
||||
account_field="account_id",
|
||||
),
|
||||
ConnectorDescriptor(
|
||||
name="obsidian",
|
||||
title="Obsidian",
|
||||
icon="◈",
|
||||
blurb="Search, read, and write notes in your local vault — no account needed.",
|
||||
# "folder": the credential is a local directory grant, not a secret. Connected
|
||||
# = a vault_path is stored (auth="none" would read as always-connected).
|
||||
auth="folder",
|
||||
two_way=False,
|
||||
fields=[
|
||||
Field(
|
||||
"vault_path",
|
||||
"Vault folder",
|
||||
secret=False,
|
||||
kind="folder",
|
||||
help="The folder holding your notes — it contains a hidden .obsidian "
|
||||
"folder. Everything stays on this Mac.",
|
||||
placeholder="~/Documents/MyVault",
|
||||
),
|
||||
],
|
||||
instructions=[
|
||||
"Pick your vault folder — no account, no keys.",
|
||||
"Notes are read directly from disk; nothing leaves this Mac.",
|
||||
],
|
||||
validate=_validate_obsidian,
|
||||
brand_color="#7c3aed",
|
||||
logo="obsidian",
|
||||
aliases=("notes", "markdown", "vault", "pkm", "second brain", "knowledge base"),
|
||||
),
|
||||
ConnectorDescriptor(
|
||||
name="attio",
|
||||
title="Attio",
|
||||
|
||||
@@ -3278,6 +3278,190 @@ def make_integration_tools(
|
||||
)
|
||||
)
|
||||
|
||||
# -- obsidian (local vault — plain files, no network; obsidian_tools.py) --
|
||||
|
||||
def _obsidian_vault():
|
||||
from pathlib import Path
|
||||
|
||||
profile, err = _profile(secrets, "obsidian", "vault_path")
|
||||
if err:
|
||||
return None, err
|
||||
vault = Path(profile["vault_path"]).expanduser()
|
||||
if not (vault / ".obsidian").is_dir():
|
||||
return None, {
|
||||
"error": "vault folder is missing or moved — reconnect Obsidian"
|
||||
}
|
||||
return vault, None
|
||||
|
||||
def obsidian_search_notes(
|
||||
query: str, tag: str = "", max_results: int = 10
|
||||
) -> dict[str, Any]:
|
||||
vault, err = _obsidian_vault()
|
||||
if err:
|
||||
return err
|
||||
from . import obsidian_tools
|
||||
|
||||
return obsidian_tools.search_notes(
|
||||
vault, query, tag=tag, max_results=max_results
|
||||
)
|
||||
|
||||
obsidian_search_notes.__name__ = "obsidian_search_notes"
|
||||
tools.append(
|
||||
_attach(
|
||||
obsidian_search_notes,
|
||||
_schema(
|
||||
"obsidian_search_notes",
|
||||
"Search Obsidian vault notes by title, tag, or content.",
|
||||
{
|
||||
"query": {"type": "string"},
|
||||
"tag": {"type": "string"},
|
||||
"max_results": {"type": "integer"},
|
||||
},
|
||||
["query"],
|
||||
),
|
||||
caps=["obsidian", "read"],
|
||||
)
|
||||
)
|
||||
|
||||
def obsidian_read_note(note: str) -> dict[str, Any]:
|
||||
vault, err = _obsidian_vault()
|
||||
if err:
|
||||
return err
|
||||
from . import obsidian_tools
|
||||
|
||||
return obsidian_tools.read_note(vault, note)
|
||||
|
||||
obsidian_read_note.__name__ = "obsidian_read_note"
|
||||
tools.append(
|
||||
_attach(
|
||||
obsidian_read_note,
|
||||
_schema(
|
||||
"obsidian_read_note",
|
||||
"Read one note by title, relative path, or [[wikilink]].",
|
||||
{"note": {"type": "string"}},
|
||||
["note"],
|
||||
),
|
||||
caps=["obsidian", "read"],
|
||||
)
|
||||
)
|
||||
|
||||
def obsidian_list_notes(folder: str = "", max_results: int = 30) -> dict[str, Any]:
|
||||
vault, err = _obsidian_vault()
|
||||
if err:
|
||||
return err
|
||||
from . import obsidian_tools
|
||||
|
||||
return obsidian_tools.list_notes(vault, folder=folder, max_results=max_results)
|
||||
|
||||
obsidian_list_notes.__name__ = "obsidian_list_notes"
|
||||
tools.append(
|
||||
_attach(
|
||||
obsidian_list_notes,
|
||||
_schema(
|
||||
"obsidian_list_notes",
|
||||
"List recently modified notes, optionally within one folder.",
|
||||
{"folder": {"type": "string"}, "max_results": {"type": "integer"}},
|
||||
[],
|
||||
),
|
||||
caps=["obsidian", "read"],
|
||||
)
|
||||
)
|
||||
|
||||
def obsidian_backlinks(note: str) -> dict[str, Any]:
|
||||
vault, err = _obsidian_vault()
|
||||
if err:
|
||||
return err
|
||||
from . import obsidian_tools
|
||||
|
||||
return obsidian_tools.backlinks(vault, note)
|
||||
|
||||
obsidian_backlinks.__name__ = "obsidian_backlinks"
|
||||
tools.append(
|
||||
_attach(
|
||||
obsidian_backlinks,
|
||||
_schema(
|
||||
"obsidian_backlinks",
|
||||
"List the notes that [[wikilink]] to a given note.",
|
||||
{"note": {"type": "string"}},
|
||||
["note"],
|
||||
),
|
||||
caps=["obsidian", "read"],
|
||||
)
|
||||
)
|
||||
|
||||
def obsidian_daily_note(date: str = "") -> dict[str, Any]:
|
||||
vault, err = _obsidian_vault()
|
||||
if err:
|
||||
return err
|
||||
from . import obsidian_tools
|
||||
|
||||
return obsidian_tools.daily_note(vault, date=date)
|
||||
|
||||
obsidian_daily_note.__name__ = "obsidian_daily_note"
|
||||
tools.append(
|
||||
_attach(
|
||||
obsidian_daily_note,
|
||||
_schema(
|
||||
"obsidian_daily_note",
|
||||
"Read the daily note for today, or for a YYYY-MM-DD date.",
|
||||
{"date": {"type": "string"}},
|
||||
[],
|
||||
),
|
||||
caps=["obsidian", "read"],
|
||||
)
|
||||
)
|
||||
|
||||
def obsidian_write_note(
|
||||
note: str, content: str, mode: str = "append"
|
||||
) -> dict[str, Any]:
|
||||
vault, err = _obsidian_vault()
|
||||
if err:
|
||||
return err
|
||||
from . import obsidian_tools
|
||||
|
||||
return obsidian_tools.write_note(vault, note, content, mode=mode)
|
||||
|
||||
obsidian_write_note.__name__ = "obsidian_write_note"
|
||||
tools.append(
|
||||
_attach(
|
||||
obsidian_write_note,
|
||||
_schema(
|
||||
"obsidian_write_note",
|
||||
"Append to, create, or overwrite a vault note (mode: append | create | overwrite).",
|
||||
{
|
||||
"note": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"mode": {"type": "string"},
|
||||
},
|
||||
["note", "content"],
|
||||
),
|
||||
approval=True,
|
||||
caps=["obsidian", "write"],
|
||||
)
|
||||
)
|
||||
|
||||
def open_in_obsidian(note: str) -> dict[str, Any]:
|
||||
vault, err = _obsidian_vault()
|
||||
if err:
|
||||
return err
|
||||
from . import obsidian_tools
|
||||
|
||||
return obsidian_tools.open_in_obsidian(vault, note)
|
||||
|
||||
open_in_obsidian.__name__ = "open_in_obsidian"
|
||||
tools.append(
|
||||
_attach(
|
||||
open_in_obsidian,
|
||||
_schema(
|
||||
"open_in_obsidian",
|
||||
"Open a note in the user's Obsidian app (obsidian:// hand-off).",
|
||||
{"note": {"type": "string"}},
|
||||
["note"],
|
||||
),
|
||||
caps=["obsidian", "read"],
|
||||
)
|
||||
)
|
||||
|
||||
# -- attio (managed OAuth or API key, multi-workspace) --
|
||||
|
||||
def attio_list_objects(account: str = "") -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Obsidian vault operations — local-first, dependency-free.
|
||||
|
||||
A vault is a plain folder of Markdown files (the `.obsidian/` dir inside is the app's
|
||||
own config), so the connector needs no plugin, no server, no keys: tools read and
|
||||
write the files directly and work whether or not Obsidian is running. Everything is
|
||||
sandboxed to the connected vault — every resolved path is checked back against the
|
||||
vault root, mirroring the workspace guard in the files toolkit.
|
||||
|
||||
`open_in_obsidian` is the one hand-off point: it launches the user's own app on a
|
||||
note via the `obsidian://` URL scheme (never required for anything else to work).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
MAX_NOTE_CHARS = 60_000 # per read; large notes are truncated with a marker
|
||||
MAX_RESULTS = 25
|
||||
_SKIP_DIRS = {".obsidian", ".trash", ".git"}
|
||||
|
||||
_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)")
|
||||
_TAG_RE = re.compile(r"(?:^|\s)#([A-Za-z0-9_/-]+)")
|
||||
|
||||
|
||||
def _is_note(path: Path) -> bool:
|
||||
return path.suffix.lower() == ".md" and not any(
|
||||
part in _SKIP_DIRS for part in path.parts
|
||||
)
|
||||
|
||||
|
||||
def iter_notes(vault: Path) -> list[Path]:
|
||||
return sorted(p for p in vault.rglob("*.md") if _is_note(p.relative_to(vault)))
|
||||
|
||||
|
||||
def _inside(vault: Path, path: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(vault.resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_note(vault: Path, ref: str) -> Optional[Path]:
|
||||
"""A note by relative path, bare title, or wikilink (`[[Title]]`). Title matches
|
||||
are case-insensitive on the filename stem; ties break to the shortest path
|
||||
(Obsidian's own link-resolution habit)."""
|
||||
ref = ref.strip().strip("[]").split("|")[0].split("#")[0].strip()
|
||||
if not ref:
|
||||
return None
|
||||
candidate = (
|
||||
(vault / ref).with_suffix(".md") if not ref.endswith(".md") else vault / ref
|
||||
)
|
||||
if (
|
||||
candidate.is_file()
|
||||
and _inside(vault, candidate)
|
||||
and _is_note(candidate.relative_to(vault))
|
||||
):
|
||||
return candidate
|
||||
stem = ref.lower().removesuffix(".md")
|
||||
matches = [p for p in iter_notes(vault) if p.stem.lower() == stem]
|
||||
if not matches:
|
||||
return None
|
||||
return min(matches, key=lambda p: len(str(p)))
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""YAML frontmatter (if any) + body. Malformed frontmatter degrades to {}."""
|
||||
if not text.startswith("---\n"):
|
||||
return {}, text
|
||||
end = text.find("\n---", 4)
|
||||
if end < 0:
|
||||
return {}, text
|
||||
try:
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(text[4:end]) or {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
data = {}
|
||||
return data, text[end + 4 :].lstrip("\n")
|
||||
|
||||
|
||||
def note_tags(frontmatter: dict[str, Any], body: str) -> list[str]:
|
||||
tags: list[str] = []
|
||||
raw = frontmatter.get("tags")
|
||||
if isinstance(raw, str):
|
||||
tags += [t.strip().lstrip("#") for t in raw.split(",") if t.strip()]
|
||||
elif isinstance(raw, list):
|
||||
tags += [str(t).lstrip("#") for t in raw]
|
||||
tags += _TAG_RE.findall(body)
|
||||
return sorted({t for t in tags if t})
|
||||
|
||||
|
||||
def _title(vault: Path, path: Path) -> str:
|
||||
return path.stem
|
||||
|
||||
|
||||
def _rel(vault: Path, path: Path) -> str:
|
||||
return str(path.relative_to(vault))
|
||||
|
||||
|
||||
def _preview(body: str, query: str) -> str:
|
||||
"""A ~200-char window around the first hit (or the note's start)."""
|
||||
low = body.lower()
|
||||
at = low.find(query.lower()) if query else -1
|
||||
start = max(0, at - 60) if at >= 0 else 0
|
||||
snippet = body[start : start + 200].strip().replace("\n", " ")
|
||||
return ("…" if start > 0 else "") + snippet
|
||||
|
||||
|
||||
def search_notes(
|
||||
vault: Path, query: str, tag: str = "", max_results: int = 10
|
||||
) -> dict[str, Any]:
|
||||
"""Title/tag/content search, title hits first. `tag` narrows to notes carrying it."""
|
||||
query = (query or "").strip()
|
||||
tag = (tag or "").lstrip("#").strip()
|
||||
limit = max(1, min(int(max_results or 10), MAX_RESULTS))
|
||||
hits: list[tuple[int, dict[str, Any]]] = []
|
||||
for path in iter_notes(vault):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
frontmatter, body = parse_frontmatter(text)
|
||||
tags = note_tags(frontmatter, body)
|
||||
if tag and tag not in tags:
|
||||
continue
|
||||
title = _title(vault, path)
|
||||
score = 0
|
||||
if query:
|
||||
if query.lower() in title.lower():
|
||||
score = 3
|
||||
elif any(query.lower() == t.lower() for t in tags):
|
||||
score = 2
|
||||
elif query.lower() in body.lower():
|
||||
score = 1
|
||||
if score == 0:
|
||||
continue
|
||||
hits.append(
|
||||
(
|
||||
score,
|
||||
{
|
||||
"title": title,
|
||||
"path": _rel(vault, path),
|
||||
"tags": tags[:12],
|
||||
"preview": _preview(body, query),
|
||||
},
|
||||
)
|
||||
)
|
||||
hits.sort(key=lambda h: (-h[0], h[1]["path"]))
|
||||
return {"count": len(hits), "notes": [h[1] for h in hits[:limit]]}
|
||||
|
||||
|
||||
def read_note(vault: Path, ref: str) -> dict[str, Any]:
|
||||
path = resolve_note(vault, ref)
|
||||
if path is None:
|
||||
return {"error": f"note not found: {ref}"}
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
frontmatter, body = parse_frontmatter(text)
|
||||
truncated = len(body) > MAX_NOTE_CHARS
|
||||
return {
|
||||
"title": _title(vault, path),
|
||||
"path": _rel(vault, path),
|
||||
"tags": note_tags(frontmatter, body),
|
||||
"frontmatter": frontmatter,
|
||||
"links": sorted({m.strip() for m in _WIKILINK_RE.findall(body)})[:50],
|
||||
"content": body[:MAX_NOTE_CHARS] + ("\n…[truncated]" if truncated else ""),
|
||||
}
|
||||
|
||||
|
||||
def list_notes(vault: Path, folder: str = "", max_results: int = 30) -> dict[str, Any]:
|
||||
"""Most recently modified first; `folder` narrows to a subfolder."""
|
||||
limit = max(1, min(int(max_results or 30), 100))
|
||||
base = vault / folder.strip("/") if folder.strip() else vault
|
||||
if not (base.is_dir() and _inside(vault, base)):
|
||||
return {"error": f"folder not found: {folder}"}
|
||||
notes = [p for p in iter_notes(vault) if base in p.parents or base == vault]
|
||||
notes.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return {
|
||||
"count": len(notes),
|
||||
"notes": [
|
||||
{
|
||||
"title": _title(vault, p),
|
||||
"path": _rel(vault, p),
|
||||
"modified": datetime.datetime.fromtimestamp(p.stat().st_mtime).strftime(
|
||||
"%Y-%m-%d %H:%M"
|
||||
),
|
||||
}
|
||||
for p in notes[:limit]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def backlinks(vault: Path, ref: str) -> dict[str, Any]:
|
||||
"""Notes that wikilink to this one."""
|
||||
target = resolve_note(vault, ref)
|
||||
if target is None:
|
||||
return {"error": f"note not found: {ref}"}
|
||||
stem = target.stem.lower()
|
||||
linking = []
|
||||
for path in iter_notes(vault):
|
||||
if path == target:
|
||||
continue
|
||||
try:
|
||||
body = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if any(m.strip().lower() == stem for m in _WIKILINK_RE.findall(body)):
|
||||
linking.append({"title": _title(vault, path), "path": _rel(vault, path)})
|
||||
return {"note": _rel(vault, target), "count": len(linking), "backlinks": linking}
|
||||
|
||||
|
||||
def _daily_config(vault: Path) -> tuple[str, str]:
|
||||
"""(folder, filename-format) from .obsidian/daily-notes.json; Obsidian's defaults
|
||||
otherwise. Only the common YYYY/MM/DD moment tokens are translated."""
|
||||
folder, fmt = "", "YYYY-MM-DD"
|
||||
try:
|
||||
raw = json.loads((vault / ".obsidian" / "daily-notes.json").read_text())
|
||||
folder = str(raw.get("folder") or "").strip("/")
|
||||
fmt = str(raw.get("format") or fmt)
|
||||
except Exception:
|
||||
pass
|
||||
return folder, fmt
|
||||
|
||||
|
||||
def daily_note(vault: Path, date: str = "") -> dict[str, Any]:
|
||||
"""Today's (or a given YYYY-MM-DD day's) daily note, honoring the vault's
|
||||
daily-notes folder/format config."""
|
||||
try:
|
||||
day = (
|
||||
datetime.date.fromisoformat(date) if date.strip() else datetime.date.today()
|
||||
)
|
||||
except ValueError:
|
||||
return {"error": f"invalid date (want YYYY-MM-DD): {date}"}
|
||||
folder, fmt = _daily_config(vault)
|
||||
name = (
|
||||
fmt.replace("YYYY", f"{day.year:04d}")
|
||||
.replace("MM", f"{day.month:02d}")
|
||||
.replace("DD", f"{day.day:02d}")
|
||||
)
|
||||
rel = f"{folder}/{name}.md" if folder else f"{name}.md"
|
||||
path = vault / rel
|
||||
if not path.is_file():
|
||||
return {"error": f"no daily note for {day.isoformat()} (looked at {rel})"}
|
||||
return read_note(vault, rel)
|
||||
|
||||
|
||||
def write_note(
|
||||
vault: Path, ref: str, content: str, mode: str = "append"
|
||||
) -> dict[str, Any]:
|
||||
"""append (default) | create (fails if it exists) | overwrite. New notes may name
|
||||
folders that don't exist yet; everything must land inside the vault."""
|
||||
if mode not in ("append", "create", "overwrite"):
|
||||
return {"error": "mode must be append, create, or overwrite"}
|
||||
existing = resolve_note(vault, ref)
|
||||
if existing is None:
|
||||
rel = ref.strip().strip("[]")
|
||||
target = (
|
||||
(vault / rel).with_suffix(".md") if not rel.endswith(".md") else vault / rel
|
||||
)
|
||||
if not _inside(vault, target):
|
||||
return {"error": "path escapes the vault"}
|
||||
if mode == "append":
|
||||
mode = "create" # appending to a note that doesn't exist creates it
|
||||
else:
|
||||
target = existing
|
||||
if mode == "create":
|
||||
return {"error": f"note already exists: {_rel(vault, existing)}"}
|
||||
if not _inside(vault, target):
|
||||
return {"error": "path escapes the vault"}
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mode == "append" and target.is_file():
|
||||
base = target.read_text(encoding="utf-8", errors="replace")
|
||||
joiner = "" if (not base or base.endswith("\n")) else "\n"
|
||||
target.write_text(base + joiner + content, encoding="utf-8")
|
||||
else:
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return {"ok": True, "path": _rel(vault, target), "mode": mode}
|
||||
|
||||
|
||||
def open_in_obsidian(vault: Path, ref: str) -> dict[str, Any]:
|
||||
"""Open the note in the Obsidian app via its obsidian:// URL scheme."""
|
||||
path = resolve_note(vault, ref)
|
||||
if path is None:
|
||||
return {"error": f"note not found: {ref}"}
|
||||
rel = str(path.relative_to(vault))[: -len(".md")]
|
||||
url = (
|
||||
"obsidian://open?vault="
|
||||
+ urllib.parse.quote(vault.name)
|
||||
+ "&file="
|
||||
+ urllib.parse.quote(rel)
|
||||
)
|
||||
error = _launch(url)
|
||||
if error:
|
||||
return {"error": f"could not open Obsidian: {error}", "url": url}
|
||||
return {"ok": True, "opened": rel, "url": url}
|
||||
|
||||
|
||||
def _launch(url: str) -> Optional[str]:
|
||||
"""OS-native URL open (same pattern as reveal_artifact). Returns an error string."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
subprocess.run(["open", url], check=True, capture_output=True, timeout=10)
|
||||
elif sys.platform == "win32":
|
||||
import os
|
||||
|
||||
os.startfile(url) # type: ignore[attr-defined]
|
||||
else:
|
||||
subprocess.run(
|
||||
["xdg-open", url], check=True, capture_output=True, timeout=10
|
||||
)
|
||||
return None
|
||||
except Exception as exc:
|
||||
return str(exc) or exc.__class__.__name__
|
||||
@@ -759,6 +759,55 @@ TOOL_DEFS: tuple[ConnectorToolDef, ...] = (
|
||||
"write",
|
||||
"Create a page under a parent page.",
|
||||
),
|
||||
ConnectorToolDef(
|
||||
"obsidian",
|
||||
"obsidian_search_notes",
|
||||
"Search notes",
|
||||
"read",
|
||||
"Search vault notes by title, tag, or content.",
|
||||
),
|
||||
ConnectorToolDef(
|
||||
"obsidian",
|
||||
"obsidian_read_note",
|
||||
"Read note",
|
||||
"read",
|
||||
"Read a note by title, path, or [[wikilink]].",
|
||||
),
|
||||
ConnectorToolDef(
|
||||
"obsidian",
|
||||
"obsidian_list_notes",
|
||||
"List notes",
|
||||
"read",
|
||||
"List recently modified notes, optionally in one folder.",
|
||||
),
|
||||
ConnectorToolDef(
|
||||
"obsidian",
|
||||
"obsidian_backlinks",
|
||||
"Backlinks",
|
||||
"read",
|
||||
"Notes that link to a given note.",
|
||||
),
|
||||
ConnectorToolDef(
|
||||
"obsidian",
|
||||
"obsidian_daily_note",
|
||||
"Daily note",
|
||||
"read",
|
||||
"Read today's (or a given day's) daily note.",
|
||||
),
|
||||
ConnectorToolDef(
|
||||
"obsidian",
|
||||
"obsidian_write_note",
|
||||
"Write note",
|
||||
"write",
|
||||
"Append to, create, or overwrite a note.",
|
||||
),
|
||||
ConnectorToolDef(
|
||||
"obsidian",
|
||||
"open_in_obsidian",
|
||||
"Open in Obsidian",
|
||||
"read",
|
||||
"Open a note in the Obsidian app.",
|
||||
),
|
||||
ConnectorToolDef(
|
||||
"attio",
|
||||
"attio_list_objects",
|
||||
|
||||
@@ -52,7 +52,7 @@ test("+ Add a source: full catalog on focus, filter as you type → connect-in-c
|
||||
const search = page.getByTestId("access-add-search");
|
||||
await expect(search).toBeFocused();
|
||||
const rows = page.locator('[data-testid^="access-add-"]:not([data-testid="access-add-search"])');
|
||||
await expect(rows).toHaveCount(9); // 12 in the catalog − browser/slack/github (connected)
|
||||
await expect(rows).toHaveCount(10); // 13 in the catalog − browser/slack/github (connected)
|
||||
await expect(page.getByTestId("access-add-notion")).toBeVisible();
|
||||
|
||||
// Already-connected sources don't match (Slack and GitHub are connected in fixtures)…
|
||||
@@ -63,7 +63,7 @@ test("+ Add a source: full catalog on focus, filter as you type → connect-in-c
|
||||
|
||||
// …and clearing the query restores the full list ("filter as you type", not search-only).
|
||||
await search.fill("");
|
||||
await expect(rows).toHaveCount(9);
|
||||
await expect(rows).toHaveCount(10);
|
||||
|
||||
// Capability aliases match too: "calendar" surfaces Outlook (title alone never would).
|
||||
await search.fill("calendar");
|
||||
|
||||
@@ -187,6 +187,7 @@ const CONNECTORS = {
|
||||
// (two-mode modal). Neither needs cloud sign-in.
|
||||
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this computer."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
|
||||
{ name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
|
||||
{ name: "obsidian", title: "Obsidian", icon: "\u25c8", blurb: "Search, read, and write notes in your local vault \u2014 no account needed.", auth: "folder", two_way: false, channels: false, available: true, brand_color: "#7c3aed", logo: "obsidian", fields: [{ key: "vault_path", label: "Vault folder", secret: false, required: true, help: "The folder holding your notes.", placeholder: "~/Documents/MyVault", kind: "folder" }], instructions: ["Pick your vault folder \u2014 no account, no keys."], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1792,6 +1793,14 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
hubspotState.hidden_fields = b.hidden_fields.map((f: string) => f.trim().toLowerCase());
|
||||
return json({ ok: true, hidden_fields: [...hubspotState.hidden_fields] });
|
||||
}
|
||||
if (p.endsWith("/v1/connectors/obsidian/connect") && m === "POST") {
|
||||
const path = String(req.postDataJSON()?.fields?.vault_path || "");
|
||||
if (!path) return json({ ok: false, error: "pick your vault folder" });
|
||||
if (!path.includes("Vault")) return json({ ok: false, error: "that folder isn't an Obsidian vault" });
|
||||
const row = CONNECTORS.connectors.find((c: any) => c.name === "obsidian");
|
||||
if (row) { row.connected = true; row.enabled = true; row.account = "MyVault"; }
|
||||
return json({ ok: true, account: "MyVault" });
|
||||
}
|
||||
if (p.endsWith("/v1/connectors"))
|
||||
return json({
|
||||
connectors: [
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Obsidian connector (local vault, auth="folder"): appears in the available list,
|
||||
// the add modal renders the vault-folder field (text input in browser; the native
|
||||
// picker button is desktop-only), a wrong folder shows the vault error, and a real
|
||||
// vault path connects with the vault name as the account identity.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
|
||||
async function openConnectors(page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Connectors", exact: true }).click();
|
||||
}
|
||||
|
||||
test("obsidian: folder field, vault validation error, then connects", async ({ page }) => {
|
||||
await openConnectors(page);
|
||||
|
||||
// Obsidian sits at the catalog's tail — expand past the truncation fold first.
|
||||
await page.getByRole("button", { name: "show all" }).click();
|
||||
const card = page.getByTestId("connector-obsidian");
|
||||
await expect(card).toContainText("Obsidian");
|
||||
await card.getByRole("button", { name: "Connect" }).click();
|
||||
|
||||
// Vault-folder field renders with the descriptor's help copy.
|
||||
await expect(page.getByText("Vault folder", { exact: true })).toBeVisible();
|
||||
const input = page.getByPlaceholder("~/Documents/MyVault");
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
// A non-vault folder surfaces the honest error.
|
||||
await input.fill("/tmp/not-a-real-one");
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).last().click();
|
||||
await expect(page.getByText("isn't an Obsidian vault")).toBeVisible();
|
||||
|
||||
// A vault path connects; identity = vault folder name.
|
||||
await input.fill("/Users/me/Documents/MyVault");
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).last().click();
|
||||
await expect(page.getByTestId("connector-obsidian")).toContainText("MyVault");
|
||||
});
|
||||
@@ -531,6 +531,8 @@ export interface ConnectorField {
|
||||
required: boolean;
|
||||
help: string;
|
||||
placeholder: string;
|
||||
// Rendering hint: "" = text input; "folder" = offer the native folder picker (desktop).
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
// A message from a sender not (yet) on the allow-list — parked instead of dropped (§19).
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ProviderInfo,
|
||||
} from "../api";
|
||||
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
|
||||
import { isTauri, pickFolder } from "../tauri";
|
||||
import { ModelChecklist } from "./ModelChecklist";
|
||||
import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup";
|
||||
|
||||
@@ -579,13 +580,41 @@ export function ConnectSetup({
|
||||
{f.label}
|
||||
{!f.required && <em> ({t("manage.optional")})</em>}
|
||||
</span>
|
||||
<input
|
||||
type={f.secret ? "password" : "text"}
|
||||
placeholder={f.placeholder}
|
||||
value={values[f.key] || ""}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
{f.kind === "folder" ? (
|
||||
/* Local-folder credential (obsidian): native picker on desktop, and the
|
||||
text input stays for browser dev / hand-typed paths. */
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 min-w-0"
|
||||
type="text"
|
||||
placeholder={f.placeholder}
|
||||
value={values[f.key] || ""}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
{isTauri() && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_BORDERED}
|
||||
data-testid={`pick-${f.key}`}
|
||||
onClick={async () => {
|
||||
const path = await pickFolder();
|
||||
if (path) setValues({ ...values, [f.key]: path });
|
||||
}}
|
||||
>
|
||||
Choose…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={f.secret ? "password" : "text"}
|
||||
placeholder={f.placeholder}
|
||||
value={values[f.key] || ""}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
{f.help && <span className="conn-field-help">{f.help}</span>}
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Obsidian connector: vault validation, note resolution (paths/titles/wikilinks),
|
||||
search/tags/frontmatter, backlinks, daily notes, sandboxed writes, and the
|
||||
obsidian:// hand-off. All against a real tmp vault — no Obsidian needed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.connectors import obsidian_tools as ob
|
||||
from coworker.connectors.descriptors import get_descriptor
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def vault(tmp_path):
|
||||
v = tmp_path / "MyVault"
|
||||
(v / ".obsidian").mkdir(parents=True)
|
||||
(v / "Projects").mkdir()
|
||||
(v / "Daily").mkdir()
|
||||
(v / "Launch Plan.md").write_text(
|
||||
"---\ntags: [launch, planning]\n---\n"
|
||||
"# Launch Plan\nShip [[Pricing]] before the keynote. #urgent\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(v / "Projects" / "Pricing.md").write_text(
|
||||
"Tiered pricing draft. See [[Launch Plan]].\n", encoding="utf-8"
|
||||
)
|
||||
(v / "Daily" / "2026-07-20.md").write_text("- stood up\n", encoding="utf-8")
|
||||
(v / ".obsidian" / "daily-notes.json").write_text(
|
||||
json.dumps({"folder": "Daily", "format": "YYYY-MM-DD"}), encoding="utf-8"
|
||||
)
|
||||
(v / ".obsidian" / "hidden.md").write_text("never index me", encoding="utf-8")
|
||||
return v
|
||||
|
||||
|
||||
def test_descriptor_validates_real_vault(vault, tmp_path):
|
||||
d = get_descriptor("obsidian")
|
||||
assert d is not None and d.auth == "folder"
|
||||
ok = d.validate({"vault_path": str(vault)})
|
||||
assert ok.ok and ok.identity == "MyVault"
|
||||
assert not d.validate({"vault_path": str(tmp_path / "nope")}).ok
|
||||
plain = tmp_path / "plain"
|
||||
plain.mkdir()
|
||||
res = d.validate({"vault_path": str(plain)})
|
||||
assert not res.ok and ".obsidian" in (res.error or "")
|
||||
|
||||
|
||||
def test_resolve_by_path_title_and_wikilink(vault):
|
||||
by_path = ob.resolve_note(vault, "Projects/Pricing.md")
|
||||
by_title = ob.resolve_note(vault, "pricing")
|
||||
by_link = ob.resolve_note(vault, "[[Pricing|the pricing note]]")
|
||||
assert by_path == by_title == by_link
|
||||
assert ob.resolve_note(vault, "No Such Note") is None
|
||||
|
||||
|
||||
def test_search_scores_title_tag_content(vault):
|
||||
top = ob.search_notes(vault, "pricing")["notes"][0]
|
||||
assert top["title"] == "Pricing" # title hit outranks the content mention
|
||||
tagged = ob.search_notes(vault, "launch", tag="urgent")
|
||||
assert [n["title"] for n in tagged["notes"]] == ["Launch Plan"]
|
||||
assert ob.search_notes(vault, "hidden")["count"] == 0 # .obsidian never indexed
|
||||
|
||||
|
||||
def test_read_note_frontmatter_tags_links(vault):
|
||||
note = ob.read_note(vault, "Launch Plan")
|
||||
assert note["frontmatter"]["tags"] == ["launch", "planning"]
|
||||
assert set(note["tags"]) == {"launch", "planning", "urgent"}
|
||||
assert note["links"] == ["Pricing"]
|
||||
assert "keynote" in note["content"]
|
||||
|
||||
|
||||
def test_backlinks(vault):
|
||||
result = ob.backlinks(vault, "Pricing")
|
||||
assert result["count"] == 1 and result["backlinks"][0]["title"] == "Launch Plan"
|
||||
|
||||
|
||||
def test_daily_note_honors_vault_config(vault):
|
||||
assert "stood up" in ob.daily_note(vault, "2026-07-20")["content"]
|
||||
missing = ob.daily_note(vault, "2026-07-19")
|
||||
assert (
|
||||
"no daily note" in missing["error"]
|
||||
and "Daily/2026-07-19.md" in missing["error"]
|
||||
)
|
||||
assert "invalid date" in ob.daily_note(vault, "today")["error"]
|
||||
|
||||
|
||||
def test_write_modes_and_sandbox(vault):
|
||||
appended = ob.write_note(vault, "Launch Plan", "New line.")
|
||||
assert appended["ok"] and appended["mode"] == "append"
|
||||
assert (vault / "Launch Plan.md").read_text().endswith("New line.")
|
||||
|
||||
created = ob.write_note(vault, "Inbox/Idea", "A thought.", mode="append")
|
||||
assert created["ok"] and created["path"] == "Inbox/Idea.md" # append→create
|
||||
|
||||
dup = ob.write_note(vault, "Pricing", "x", mode="create")
|
||||
assert "already exists" in dup["error"]
|
||||
|
||||
escape = ob.write_note(vault, "../outside", "nope")
|
||||
assert "escapes the vault" in escape["error"]
|
||||
|
||||
|
||||
def test_open_in_obsidian_builds_url(vault, monkeypatch):
|
||||
seen = {}
|
||||
monkeypatch.setattr(ob, "_launch", lambda url: seen.setdefault("url", url) and None)
|
||||
result = ob.open_in_obsidian(vault, "Pricing")
|
||||
assert result["ok"] and result["opened"] == "Projects/Pricing"
|
||||
assert seen["url"] == "obsidian://open?vault=MyVault&file=Projects/Pricing"
|
||||
assert "not found" in ob.open_in_obsidian(vault, "ghost")["error"]
|
||||
|
||||
|
||||
def test_open_in_obsidian_launch_failure_is_reported(vault, monkeypatch):
|
||||
monkeypatch.setattr(ob, "_launch", lambda url: "no handler for obsidian://")
|
||||
result = ob.open_in_obsidian(vault, "Pricing")
|
||||
assert "could not open Obsidian" in result["error"] and result["url"]
|
||||
|
||||
|
||||
def test_integration_tools_wire_and_guard(vault, tmp_path, monkeypatch):
|
||||
from coworker.connectors.integration_tools import make_integration_tools
|
||||
from coworker.secrets import SecretStore
|
||||
|
||||
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
||||
secrets = SecretStore()
|
||||
tools = {t.__name__: t for t in make_integration_tools(secrets)}
|
||||
for name in (
|
||||
"obsidian_search_notes",
|
||||
"obsidian_read_note",
|
||||
"obsidian_list_notes",
|
||||
"obsidian_backlinks",
|
||||
"obsidian_daily_note",
|
||||
"obsidian_write_note",
|
||||
"open_in_obsidian",
|
||||
):
|
||||
assert name in tools, name
|
||||
|
||||
# Not connected → a visible error, never a crash.
|
||||
assert "error" in tools["obsidian_search_notes"]("pricing")
|
||||
|
||||
secrets.put("obsidian:default", {"vault_path": str(vault), "enabled": True})
|
||||
hits = tools["obsidian_search_notes"]("pricing")
|
||||
assert hits["notes"][0]["title"] == "Pricing"
|
||||
|
||||
# Vault moved after connect → reconnect hint, not a stack trace.
|
||||
secrets.put("obsidian:default", {"vault_path": str(tmp_path / "gone")})
|
||||
assert "reconnect" in tools["obsidian_read_note"]("Pricing")["error"]
|
||||
Reference in New Issue
Block a user