feat: add artifacts module — give Timmy hands to create notes, decisions, and issues
Adds timmy.artifacts with three async functions: - save_note(): write markdown to ~/.timmy/notes/ - append_decision(): append to ~/.timmy/decisions.md - create_issue(): file Gitea issues with fallback to local notes Fixes #326 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
118
src/timmy/artifacts.py
Normal file
118
src/timmy/artifacts.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Artifact creation — gives Timmy hands to produce tangible output.
|
||||
|
||||
Timmy can create three kinds of artifacts during conversation:
|
||||
|
||||
1. **Notes** — markdown files saved to ``~/.timmy/notes/``
|
||||
2. **Decision log** — append-only log at ``~/.timmy/decisions.md``
|
||||
3. **Gitea issues** — filed via the existing MCP integration
|
||||
|
||||
Usage::
|
||||
|
||||
from timmy.artifacts import save_note, append_decision, create_issue
|
||||
|
||||
path = await save_note("api-design", "# API Design\\n...")
|
||||
await append_decision("Chose REST over GraphQL for simplicity")
|
||||
result = await create_issue("Add rate limiting", "We need...")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMMY_HOME = Path.home() / ".timmy"
|
||||
NOTES_DIR = TIMMY_HOME / "notes"
|
||||
DECISION_LOG = TIMMY_HOME / "decisions.md"
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Turn a title into a filesystem-safe slug."""
|
||||
slug = text.lower().strip()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||
return slug.strip("-")[:80] or "untitled"
|
||||
|
||||
|
||||
async def save_note(title: str, content: str) -> Path:
|
||||
"""Save a markdown note to ``~/.timmy/notes/<slug>.md``.
|
||||
|
||||
Args:
|
||||
title: Human-readable title (slugified for filename).
|
||||
content: Markdown body.
|
||||
|
||||
Returns:
|
||||
Path to the written file.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
NOTES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
slug = _slugify(title)
|
||||
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
path = NOTES_DIR / f"{ts}-{slug}.md"
|
||||
|
||||
header = f"# {title}\n\n_Saved by Timmy at {ts}_\n\n"
|
||||
full = header + content
|
||||
|
||||
await asyncio.to_thread(path.write_text, full, encoding="utf-8")
|
||||
logger.info("Note saved: %s", path)
|
||||
return path
|
||||
|
||||
|
||||
async def append_decision(text: str) -> Path:
|
||||
"""Append an entry to the running decision log.
|
||||
|
||||
Args:
|
||||
text: Decision text (one or more lines).
|
||||
|
||||
Returns:
|
||||
Path to the decision log file.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
TIMMY_HOME.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
|
||||
entry = f"\n## {ts}\n\n{text}\n"
|
||||
|
||||
def _append():
|
||||
if not DECISION_LOG.exists():
|
||||
DECISION_LOG.write_text(
|
||||
"# Timmy Decision Log\n\n_Architectural decisions captured during conversation._\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with DECISION_LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(entry)
|
||||
|
||||
await asyncio.to_thread(_append)
|
||||
logger.info("Decision logged: %.60s", text)
|
||||
return DECISION_LOG
|
||||
|
||||
|
||||
async def create_issue(title: str, body: str = "", labels: str = "") -> str:
|
||||
"""Create a Gitea issue from conversation.
|
||||
|
||||
Thin wrapper around :func:`timmy.mcp_tools.create_gitea_issue_via_mcp`
|
||||
with graceful degradation when Gitea is unavailable.
|
||||
|
||||
Returns:
|
||||
Confirmation string or error explanation.
|
||||
"""
|
||||
if not settings.gitea_enabled or not settings.gitea_token:
|
||||
logger.warning("Gitea not configured — saving issue as note instead")
|
||||
fallback = f"## Issue: {title}\n\n{body}"
|
||||
path = await save_note(f"issue-{title}", fallback)
|
||||
return f"Gitea unavailable — saved as note: {path}"
|
||||
|
||||
try:
|
||||
from timmy.mcp_tools import create_gitea_issue_via_mcp
|
||||
|
||||
return await create_gitea_issue_via_mcp(title, body, labels)
|
||||
except Exception as exc:
|
||||
logger.warning("Issue creation failed, saving as note: %s", exc)
|
||||
fallback = f"## Issue: {title}\n\n{body}\n\n_Filed locally — Gitea error: {exc}_"
|
||||
path = await save_note(f"issue-{title}", fallback)
|
||||
return f"Gitea error — saved as note: {path}"
|
||||
121
tests/timmy/test_artifacts.py
Normal file
121
tests/timmy/test_artifacts.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Tests for timmy.artifacts — note saving, decision log, issue creation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _temp_timmy_home(tmp_path, monkeypatch):
|
||||
"""Redirect ~/.timmy to a temp directory for all tests."""
|
||||
monkeypatch.setattr("timmy.artifacts.TIMMY_HOME", tmp_path)
|
||||
monkeypatch.setattr("timmy.artifacts.NOTES_DIR", tmp_path / "notes")
|
||||
monkeypatch.setattr("timmy.artifacts.DECISION_LOG", tmp_path / "decisions.md")
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic(self):
|
||||
from timmy.artifacts import _slugify
|
||||
|
||||
assert _slugify("API Design Notes") == "api-design-notes"
|
||||
|
||||
def test_special_chars(self):
|
||||
from timmy.artifacts import _slugify
|
||||
|
||||
assert _slugify("foo/bar: baz!") == "foo-bar-baz"
|
||||
|
||||
def test_empty(self):
|
||||
from timmy.artifacts import _slugify
|
||||
|
||||
assert _slugify("") == "untitled"
|
||||
|
||||
def test_truncation(self):
|
||||
from timmy.artifacts import _slugify
|
||||
|
||||
result = _slugify("a" * 200)
|
||||
assert len(result) <= 80
|
||||
|
||||
|
||||
class TestSaveNote:
|
||||
async def test_creates_file(self, tmp_path):
|
||||
from timmy.artifacts import save_note
|
||||
|
||||
path = await save_note("Test Note", "Hello world")
|
||||
|
||||
assert path.exists()
|
||||
text = path.read_text()
|
||||
assert "# Test Note" in text
|
||||
assert "Hello world" in text
|
||||
assert path.parent == tmp_path / "notes"
|
||||
|
||||
async def test_filename_has_slug(self, tmp_path):
|
||||
from timmy.artifacts import save_note
|
||||
|
||||
path = await save_note("My Cool Idea", "content")
|
||||
assert "my-cool-idea" in path.name
|
||||
assert path.suffix == ".md"
|
||||
|
||||
|
||||
class TestAppendDecision:
|
||||
async def test_creates_log_if_missing(self, tmp_path):
|
||||
from timmy.artifacts import append_decision
|
||||
|
||||
path = await append_decision("Use REST over GraphQL")
|
||||
|
||||
assert path.exists()
|
||||
text = path.read_text()
|
||||
assert "# Timmy Decision Log" in text
|
||||
assert "Use REST over GraphQL" in text
|
||||
|
||||
async def test_appends_to_existing(self, tmp_path):
|
||||
from timmy.artifacts import append_decision
|
||||
|
||||
await append_decision("First decision")
|
||||
await append_decision("Second decision")
|
||||
|
||||
text = (tmp_path / "decisions.md").read_text()
|
||||
assert "First decision" in text
|
||||
assert "Second decision" in text
|
||||
|
||||
|
||||
class TestCreateIssue:
|
||||
async def test_fallback_when_gitea_disabled(self, tmp_path, monkeypatch):
|
||||
from timmy.artifacts import create_issue
|
||||
|
||||
monkeypatch.setattr("timmy.artifacts.settings.gitea_enabled", False)
|
||||
|
||||
result = await create_issue("Bug title", "Bug body")
|
||||
|
||||
assert "saved as note" in result.lower()
|
||||
notes = list((tmp_path / "notes").glob("*.md"))
|
||||
assert len(notes) == 1
|
||||
assert "Bug title" in notes[0].read_text()
|
||||
|
||||
async def test_delegates_to_mcp(self, monkeypatch):
|
||||
from timmy.artifacts import create_issue
|
||||
|
||||
monkeypatch.setattr("timmy.artifacts.settings.gitea_enabled", True)
|
||||
monkeypatch.setattr("timmy.artifacts.settings.gitea_token", "fake-token")
|
||||
|
||||
mock_mcp = AsyncMock(return_value="Created issue: Test")
|
||||
with patch("timmy.mcp_tools.create_gitea_issue_via_mcp", mock_mcp):
|
||||
result = await create_issue("Test", "body", "bug")
|
||||
|
||||
assert "Created issue" in result
|
||||
mock_mcp.assert_awaited_once_with("Test", "body", "bug")
|
||||
|
||||
async def test_mcp_error_falls_back_to_note(self, tmp_path, monkeypatch):
|
||||
from timmy.artifacts import create_issue
|
||||
|
||||
monkeypatch.setattr("timmy.artifacts.settings.gitea_enabled", True)
|
||||
monkeypatch.setattr("timmy.artifacts.settings.gitea_token", "fake-token")
|
||||
|
||||
mock_mcp = AsyncMock(side_effect=RuntimeError("connection refused"))
|
||||
with patch("timmy.mcp_tools.create_gitea_issue_via_mcp", mock_mcp):
|
||||
result = await create_issue("Broken thing", "details")
|
||||
|
||||
assert "saved as note" in result.lower()
|
||||
notes = list((tmp_path / "notes").glob("*.md"))
|
||||
assert len(notes) == 1
|
||||
Reference in New Issue
Block a user