feat: add artifacts module — give Timmy hands to create notes, decisions, and issues
All checks were successful
Tests / lint (pull_request) Successful in 6s
Tests / test (pull_request) Successful in 1m8s

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:
kimi
2026-03-18 20:18:18 -04:00
parent bfd924fe74
commit 80b6e40db0
2 changed files with 239 additions and 0 deletions

118
src/timmy/artifacts.py Normal file
View 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}"

View 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