diff --git a/.agent/skills/_index.md b/.agent/skills/_index.md index df7c83b..fe33646 100644 --- a/.agent/skills/_index.md +++ b/.agent/skills/_index.md @@ -56,3 +56,13 @@ Constraints: prefer DESIGN.md tokens over invented values, do not modify DESIGN.md unless the user explicitly asks, preserve unknown sections when an edit IS authorised, validate with `npx @google/design.md lint DESIGN.md` when available. + +## tldraw +Draw, diagram, sketch, or lay out ideas on a live tldraw canvas. +Worthwhile drawings snapshot into this skill's local store +(`skills/tldraw/store.py`) for recall across sessions. +Triggers: "draw", "diagram", "sketch", "wireframe", "flowchart", +"mind-map", "visualize", "whiteboard" +Constraints: get_canvas before edits; max 200 shapes per create_shape call. +Requires: tldraw MCP server wired in the harness's MCP config; user has +http://localhost:3030 open. Opt-in via `.features.json` (`tldraw: true`). diff --git a/.agent/skills/_manifest.jsonl b/.agent/skills/_manifest.jsonl index ce60400..a208889 100644 --- a/.agent/skills/_manifest.jsonl +++ b/.agent/skills/_manifest.jsonl @@ -6,3 +6,4 @@ {"name":"data-layer","version":"2026-04-26","triggers":["data layer","dashboard","show me the dashboard","what did my agents do","agent analytics","agent status","resource usage","usage report","cron monitoring","daily report","tokens","terminal dashboard","TUI"],"tools":["bash","git"],"preconditions":[".agent exists"],"constraints":["local-only by default","no screenshot delivery without explicit user approval","do not commit private .agent/data-layer exports"],"category":"operations"} {"name":"data-flywheel","version":"2026-04-25","triggers":["data flywheel","trace to train","training traces","context cards","eval cases","approved runs","vertical intelligence"],"tools":["bash","git"],"preconditions":[".agent exists"],"constraints":["local-only by default","human-approved runs only","redaction required before trainable","do not train models"],"category":"operations"} {"name":"design-md","version":"2026-04-26","triggers":["DESIGN.md","design.md","Google Stitch","Stitch","design tokens","design system","visual design"],"tools":["bash","memory_reflect"],"preconditions":["DESIGN.md exists at project root"],"constraints":["prefer DESIGN.md tokens over invented values","do not modify DESIGN.md unless the user explicitly asks","preserve unknown sections when an edit IS authorised","validate when tooling is available"],"category":"design"} +{"name":"tldraw","version":"2026-04-21","triggers":["draw","diagram","sketch","wireframe","flowchart","mind-map","mind map","visualize","lay out","architecture diagram","whiteboard"],"tools":["mcp.tldraw.create_shape","mcp.tldraw.update_shape","mcp.tldraw.delete_shape","mcp.tldraw.get_canvas"],"preconditions":["tldraw MCP server reachable","user has http://localhost:3030 open"],"constraints":["call get_canvas before update_shape or delete_shape","at most 200 shapes per create_shape call","coordinates within 0..1600 x 0..900 unless told otherwise"],"category":"visualization","feature_flag":"tldraw"} diff --git a/.agent/skills/tldraw/SKILL.md b/.agent/skills/tldraw/SKILL.md new file mode 100644 index 0000000..fdf95d0 --- /dev/null +++ b/.agent/skills/tldraw/SKILL.md @@ -0,0 +1,117 @@ +--- +name: tldraw +version: 2026-04-21 +triggers: ["draw", "diagram", "sketch", "wireframe", "flowchart", "mind-map", "mind map", "visualize", "lay out", "architecture diagram", "whiteboard"] +tools: [mcp.tldraw.create_shape, mcp.tldraw.update_shape, mcp.tldraw.delete_shape, mcp.tldraw.get_canvas] +preconditions: ["tldraw MCP server reachable via the harness's MCP config", "user has http://localhost:3030 open"] +constraints: ["call get_canvas before update_shape or delete_shape to discover real ids", "at most 200 shapes per create_shape call", "coordinates within 0..1600 x 0..900 unless the user asks otherwise"] +category: visualization +--- + +# tldraw — draw on a live canvas + +The tldraw MCP server exposes a live canvas at `http://localhost:3030`. +You draw into it; the user watches it fill in. Worthwhile drawings can +be snapshotted to disk and recalled in future sessions via this skill's +local store (`store.py`). + +## When this skill loads + +Any time the user asks to visualize, diagram, sketch, lay out, or map +something graphically. If they are clearly asking for prose, do not draw. + +## Before drawing, once per session + +Tell the user: + +> Open `http://localhost:3030` to see the canvas. + +If any tool returns `No tldraw browser connected`, repeat the hint and +stop until they confirm. + +## Opt-in MCP setup + +This beta does not install MCP wiring during default adapter setup. After the +user enables `tldraw` in `.agent/memory/.features.json`, they must add the +server to their harness MCP config. Use this local block as the source of truth: + +```json +{ + "mcpServers": { + "tldraw": { + "command": "npx", + "args": ["-y", "@tldraw-mcp/server"] + } + } +} +``` + +For Claude Code and Antigravity this usually lives in `.mcp.json`; for Cursor +it usually lives in `.cursor/mcp.json`. If a config already exists, merge the +`tldraw` server entry rather than overwriting the file. + +## Tools + +| tool | purpose | +|---|---| +| `create_shape({ shapes: [...] })` | add new shapes | +| `update_shape({ updates: [{ id, props }] })` | change shapes by id | +| `delete_shape({ ids: [...] })` | remove shapes | +| `get_canvas()` | return all current shapes | + +Always `get_canvas` first when the user says "add to", "next to", +"modify", or refers to something already drawn — you need the real ids. + +## Coordinate system + +- Origin `(0, 0)` top-left. `+x` right, `+y` down. +- Stay inside `0 <= x <= 1600`, `0 <= y <= 900` unless told otherwise. +- Default sizes: boxes ~160x80, icons ~60x60. + +## Shape vocabulary + +| type | required | optional | +|---|---|---| +| `geo` | `x, y, w, h` | `geo` (rectangle/ellipse/triangle/diamond/star/...), `color`, `fill`, `text` | +| `text` | `x, y, text` | `color`, `size` (s/m/l/xl) | +| `arrow` | `x, y, end:{x,y}` | `color`, `text` (label) | +| `line` | `x, y, end:{x,y}` | `color` | +| `draw` | `x, y, points:[{x,y}]` | `color` (freehand, at least 2 points) | +| `note` | `x, y, text` | `color` (sticky note) | + +Colors: `black, grey, light-violet, violet, blue, light-blue, yellow, orange, green, light-green, red`. +Fills: `none, semi, solid, pattern`. + +## Persisting drawings + +When a drawing is worth keeping across sessions (architecture decisions, +recurring diagrams, reference material), snapshot it: + +```bash +# fetch current shapes via MCP get_canvas and pipe the JSON in +python3 .agent/skills/tldraw/store.py snapshot \ + --label "auth-flow-v1" --tags architecture,auth \ + --note "login + refresh token flow agreed 2026-04-21" +``` + +The store reads canvas JSON on stdin, writes a snapshot file under +`snapshots/`, appends metadata to `snapshots.jsonl`, and re-renders +`INDEX.md` — all within a single file lock. Later sessions recover a +drawing with `list` / `load`. Use `archive` to retire a snapshot; the +JSONL is append-only semantic, so archived records move to +`snapshots/archive/` rather than being deleted. + +## Pitfalls + +- `text` shapes need non-empty `text`. +- `arrow.end` is an absolute point, not a delta. +- Split large scenes into multiple `create_shape` calls (<= 200 each). +- Always `get_canvas` before an edit; never assume ids. + +## Self-rewrite hook + +After any failure, or every 5 uses: +1. Read the last N tldraw-tagged entries from `memory/episodic/AGENT_LEARNINGS.jsonl`. +2. If a constraint was violated (shape cap, id-before-edit rule), escalate + a candidate lesson to `semantic/LESSONS.md` via `tools/learn.py`. +3. Commit: `skill-update: tldraw, `. diff --git a/.agent/skills/tldraw/store.py b/.agent/skills/tldraw/store.py new file mode 100644 index 0000000..0459866 --- /dev/null +++ b/.agent/skills/tldraw/store.py @@ -0,0 +1,409 @@ +"""Snapshot store for the tldraw skill. + +The live tldraw canvas at http://localhost:3030 is ephemeral; this module +persists a canvas state into `snapshots/` next to the skill so a later +session can recall it. Source of truth is `snapshots.jsonl`; `INDEX.md` +is rendered from it and is gitignored (never hand-edited, never committed). + +Scope: this is skill-local storage, not a memory layer. It has no +lifecycle, no clustering, no dream-cycle integration, and is not read by +recall.py. Skills that need retrieval hooks should log to episodic memory +via tools/memory_reflect.py instead. + +Concurrency: JSONL mutations hold an advisory exclusive flock (same +pattern as memory/render_lessons.py) so concurrent append / archive +calls serialize instead of corrupting the file. Windows (no fcntl) falls +through without locking — safe for single-user repos. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import secrets +import shutil +import sys +import threading +import warnings +from contextlib import contextmanager +from datetime import datetime, timezone +from typing import Iterable, Optional + +HERE = os.path.dirname(os.path.abspath(__file__)) +SNAPSHOTS_DIR = os.path.join(HERE, "snapshots") +ARCHIVE_DIR = os.path.join(SNAPSHOTS_DIR, "archive") +JSONL_PATH = os.path.join(HERE, "snapshots.jsonl") +INDEX_PATH = os.path.join(HERE, "INDEX.md") + +_LABEL_RE = re.compile(r"[^a-zA-Z0-9._-]+") +_SID_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_MAX_RESAMPLE = 8 + +try: + import fcntl + _HAS_FLOCK = True +except ImportError: + _HAS_FLOCK = False + +# Process-local mutex used as a fallback when fcntl is unavailable +# (i.e. Windows). Covers the in-process / multi-threaded case; cross- +# process serialization on Windows is not attempted and would require +# msvcrt.locking against a sidecar lock file. +_THREAD_LOCK = threading.RLock() + +if not _HAS_FLOCK: + warnings.warn( + "fcntl unavailable; snapshots.jsonl is serialized by a process-" + "local threading lock only. Safe for single-process use; not " + "safe for concurrent writers across multiple OS processes.", + RuntimeWarning, stacklevel=2, + ) + + +# ── helpers ──────────────────────────────────────────────────────────── + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _make_id(when: Optional[datetime] = None) -> str: + """Time-sortable id: `YYYYMMDD-HHMMSS-<6-hex-random>`.""" + when = when or _now_utc() + return f"{when.strftime('%Y%m%d-%H%M%S')}-{secrets.token_hex(3)}" + + +def _sanitize_label(label: str) -> str: + cleaned = _LABEL_RE.sub("-", (label or "").strip()).strip("-") + return cleaned or "unlabeled" + + +def _parse_tags(raw) -> list[str]: + if raw is None: + return [] + items: Iterable[str] = raw if isinstance(raw, list) else (raw or "").split(",") + return [t.strip() for t in items if t and t.strip()] + + +def _require_valid_sid(sid: str) -> str: + """Refuse anything that isn't a plain id token. + + `os.path.join(dir, f"{sid}.json")` would happily resolve `../../etc` + and escape the snapshots dir. Constraining the character class blocks + traversal before it can reach the filesystem. + """ + if not isinstance(sid, str) or not _SID_RE.match(sid): + raise ValueError(f"invalid snapshot id: {sid!r}") + return sid + + +# ── filesystem primitives ───────────────────────────────────────────── + +def _ensure_dirs() -> None: + os.makedirs(SNAPSHOTS_DIR, exist_ok=True) + os.makedirs(ARCHIVE_DIR, exist_ok=True) + + +def _atomic_write(path: str, data: str) -> None: + # Unique tmp name per call — pid + thread id alone isn't enough, two + # threads writing the same target in the same millisecond would race + # to create the tmp file on Windows. 6 bytes of entropy eliminate that. + tmp = f"{path}.tmp.{os.getpid()}.{secrets.token_hex(6)}" + with open(tmp, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp, path) + + +@contextmanager +def _locked_jsonl(path: str): + """Hold an advisory exclusive flock for the scope of the block. + + Same pattern as memory/render_lessons.py. 'a+' mode creates the file + if missing and permits read. Callers that need to rewrite the file + must seek(0) and truncate() before writing. + """ + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + # Acquire process-local lock first; on Unix the OS flock adds + # cross-process safety on top. On Windows it's the only serialization. + _THREAD_LOCK.acquire() + f = open(path, "a+", encoding="utf-8") + try: + if _HAS_FLOCK: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + yield f + finally: + if _HAS_FLOCK: + try: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + except OSError: + pass + f.close() + _THREAD_LOCK.release() + + +def _read_jsonl_locked(f) -> list[dict]: + """Read all records from an already-open, already-locked file handle. + + Malformed lines are logged to stderr and skipped — silently swallowing + JSONDecodeError hides the very corruption this store exists to avoid. + """ + f.seek(0) + out = [] + for lineno, raw in enumerate(f, 1): + raw = raw.strip() + if not raw: + continue + try: + out.append(json.loads(raw)) + except json.JSONDecodeError as e: + print(f"[tldraw/store] skipping malformed {JSONL_PATH}:{lineno}: {e}", + file=sys.stderr) + return out + + +# ── shape-payload normalization ─────────────────────────────────────── + +def _coerce_shapes(payload) -> list: + shapes = payload["shapes"] if isinstance(payload, dict) and "shapes" in payload \ + else payload + if not isinstance(shapes, list): + raise ValueError("shapes must be a list or a {'shapes': [...]} envelope") + return shapes + + +# ── public API ──────────────────────────────────────────────────────── + +def snapshot(shapes_payload, label: str, tags=None, note: str = "", + when: Optional[datetime] = None) -> dict: + """Persist the current canvas state. Returns the metadata record.""" + _ensure_dirs() + shapes = _coerce_shapes(shapes_payload) + tags_list = _parse_tags(tags) + label_clean = _sanitize_label(label) + when = when or _now_utc() + + # Reserve a unique id + filename. Extremely unlikely to collide + # (24 bits of entropy per second), but bound the retry so a caller + # passing a fixed `when` under a degenerate RNG can't spin forever. + sid = None + shape_path = None + for _ in range(_MAX_RESAMPLE): + candidate = _make_id(when=when) + candidate_path = os.path.join(SNAPSHOTS_DIR, f"{candidate}.json") + if not os.path.exists(candidate_path): + sid, shape_path = candidate, candidate_path + break + if sid is None: + raise RuntimeError( + f"failed to allocate unique snapshot id after {_MAX_RESAMPLE} " + f"attempts; check for a clock/RNG anomaly" + ) + + full = { + "id": sid, "label": label_clean, "tags": tags_list, "note": note or "", + "created_at": when.isoformat(), "shape_count": len(shapes), + "shapes": shapes, + } + _atomic_write(shape_path, json.dumps(full, ensure_ascii=False, indent=2) + "\n") + + meta = {k: v for k, v in full.items() if k != "shapes"} + meta["status"] = "active" + + with _locked_jsonl(JSONL_PATH) as f: + f.seek(0, os.SEEK_END) + f.write(json.dumps(meta, ensure_ascii=False) + "\n") + f.flush() + records = _read_jsonl_locked(f) + _render_index(records) + return meta + + +def list_snapshots(tag: Optional[str] = None, + include_archived: bool = False) -> list[dict]: + if not os.path.exists(JSONL_PATH): + return [] + with _locked_jsonl(JSONL_PATH) as f: + records = _read_jsonl_locked(f) + out = [] + for r in records: + if not include_archived and r.get("status") == "archived": + continue + if tag and tag not in (r.get("tags") or []): + continue + out.append(r) + return out + + +def load_snapshot(sid: str) -> dict: + _require_valid_sid(sid) + for root in (SNAPSHOTS_DIR, ARCHIVE_DIR): + path = os.path.join(root, f"{sid}.json") + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + raise FileNotFoundError(f"no snapshot with id {sid}") + + +def archive_snapshot(sid: str) -> dict: + """Move snapshot file to archive/ and flip status in the jsonl.""" + _require_valid_sid(sid) + _ensure_dirs() + src = os.path.join(SNAPSHOTS_DIR, f"{sid}.json") + if not os.path.exists(src): + raise FileNotFoundError(f"no active snapshot with id {sid}") + shutil.move(src, os.path.join(ARCHIVE_DIR, f"{sid}.json")) + + with _locked_jsonl(JSONL_PATH) as f: + records = _read_jsonl_locked(f) + hit = None + for r in records: + if r.get("id") == sid: + r["status"] = "archived" + r["archived_at"] = _now_utc().isoformat() + hit = r + if hit is None: + raise RuntimeError( + f"snapshot {sid} has no metadata in snapshots.jsonl") + body = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records) + f.seek(0) + f.truncate() + f.write(body) + f.flush() + _render_index(records) + return hit + + +# ── INDEX.md renderer ───────────────────────────────────────────────── + +_INDEX_HEADER = """# tldraw snapshots + +Rendered from `snapshots.jsonl`. Do not hand-edit — re-render by calling +`store.py snapshot|archive` or the module's `_render_index`. This file +is gitignored; it exists only in installed projects. +""" + + +def _render_index(records: Optional[list[dict]] = None) -> None: + if records is None: + if not os.path.exists(JSONL_PATH): + records = [] + else: + with _locked_jsonl(JSONL_PATH) as f: + records = _read_jsonl_locked(f) + + lines = [_INDEX_HEADER.rstrip(), ""] + active = [r for r in records if r.get("status") != "archived"] + archived = [r for r in records if r.get("status") == "archived"] + + def _row(r: dict) -> str: + tags = ", ".join(r.get("tags") or []) or "-" + note = (r.get("note") or "").replace("|", "\\|").replace("\n", " ") + if len(note) > 80: + note = note[:77] + "..." + return (f"| `{r.get('id','')}` | {r.get('label','')} | {tags} | " + f"{r.get('shape_count', 0)} | {r.get('created_at','')} | " + f"{note or '-'} |") + + table_header = [ + "| id | label | tags | shapes | created | note |", + "|---|---|---|---|---|---|", + ] + if active: + lines += ["## Active", ""] + table_header + [_row(r) for r in active] + [""] + else: + lines += ["## Active", "", "_(no snapshots yet)_", ""] + if archived: + lines += ["## Archived", ""] + table_header + [_row(r) for r in archived] + [""] + + _atomic_write(INDEX_PATH, "\n".join(lines).rstrip() + "\n") + + +# ── CLI ─────────────────────────────────────────────────────────────── + +def _read_shapes_from_args(args) -> list: + if args.file: + with open(args.file, encoding="utf-8") as f: + data = json.load(f) + elif args.shapes_json: + data = json.loads(args.shapes_json) + else: + raw = sys.stdin.read() + if not raw.strip(): + raise SystemExit("error: no canvas JSON on stdin " + "(use --file or --shapes-json to supply it)") + data = json.loads(raw) + return _coerce_shapes(data) + + +def _cmd_snapshot(args) -> int: + shapes = _read_shapes_from_args(args) + meta = snapshot(shapes, label=args.label, tags=args.tags or [], + note=args.note or "") + print(json.dumps(meta, ensure_ascii=False, indent=2)) + return 0 + + +def _cmd_list(args) -> int: + rows = list_snapshots(tag=args.tag, include_archived=args.all) + if args.json: + print(json.dumps(rows, ensure_ascii=False, indent=2)) + return 0 + if not rows: + print("(no snapshots)") + return 0 + for r in rows: + tags = ",".join(r.get("tags") or []) or "-" + flag = " [archived]" if r.get("status") == "archived" else "" + print(f"{r.get('id')} {r.get('label')} tags={tags} " + f"shapes={r.get('shape_count', 0)}{flag}") + return 0 + + +def _cmd_load(args) -> int: + print(json.dumps(load_snapshot(args.id), ensure_ascii=False, indent=2)) + return 0 + + +def _cmd_archive(args) -> int: + print(json.dumps(archive_snapshot(args.id), ensure_ascii=False, indent=2)) + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="tldraw-store", + description="Snapshot store for the tldraw skill.") + sub = p.add_subparsers(dest="cmd", required=True) + + s = sub.add_parser("snapshot", help="persist a canvas state") + s.add_argument("--label", required=True) + s.add_argument("--tags", type=lambda v: _parse_tags(v)) + s.add_argument("--note", default="") + src = s.add_mutually_exclusive_group() + src.add_argument("--file", help="read shapes JSON from this path") + src.add_argument("--shapes-json", help="inline shapes JSON string") + s.set_defaults(func=_cmd_snapshot) + + ls = sub.add_parser("list", help="list stored snapshots") + ls.add_argument("--tag", help="filter by a single tag") + ls.add_argument("--all", action="store_true", help="include archived") + ls.add_argument("--json", action="store_true") + ls.set_defaults(func=_cmd_list) + + lo = sub.add_parser("load", help="print full snapshot JSON") + lo.add_argument("id") + lo.set_defaults(func=_cmd_load) + + ar = sub.add_parser("archive", help="archive a snapshot (no delete)") + ar.add_argument("id") + ar.set_defaults(func=_cmd_archive) + + return p + + +def main(argv=None) -> int: + args = _build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agent/tools/skill_loader.py b/.agent/tools/skill_loader.py index ce6551e..2995335 100644 --- a/.agent/tools/skill_loader.py +++ b/.agent/tools/skill_loader.py @@ -4,6 +4,7 @@ ROOT = os.path.join(os.path.dirname(__file__), "..") SKILLS_DIR = os.path.join(ROOT, "skills") MANIFEST = os.path.join(SKILLS_DIR, "_manifest.jsonl") +FEATURES_PATH = os.path.join(ROOT, "memory", ".features.json") def load_manifest(): @@ -41,6 +42,21 @@ def check_preconditions(skill): return True +def feature_enabled(key): + try: + with open(FEATURES_PATH, encoding="utf-8") as f: + features = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return False + entry = features.get(key) or {} + return bool(entry.get("enabled")) + + +def skill_enabled(skill): + feature_flag = skill.get("feature_flag") + return True if not feature_flag else feature_enabled(feature_flag) + + def load_skill_full(name): base = os.path.join(SKILLS_DIR, name) skill_md = os.path.join(base, "SKILL.md") @@ -58,6 +74,8 @@ def progressive_load(user_input): matches = match_triggers(user_input, manifest) loaded = [] for skill in matches: + if not skill_enabled(skill): + continue if not check_preconditions(skill): continue content = load_skill_full(skill["name"]) diff --git a/.gitignore b/.gitignore index c31a524..6ebdc45 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,17 @@ tests/ # Placed AFTER the negation above so the later rule wins. .agent/memory/.index/ .agent/memory/.index/** + +# ...and Python bytecode that lands anywhere under .agent/memory/ when +# modules there are imported. The negation above re-includes these by +# default; re-exclude explicitly. +.agent/memory/**/__pycache__/ +.agent/memory/**/*.py[cod] + +# Test scratch dir — the CLI round-trip test creates shim scripts here. +.tmp/ + +# tldraw skill runtime data — generated at use, not committed. +.agent/skills/tldraw/INDEX.md +.agent/skills/tldraw/snapshots/ +.agent/skills/tldraw/snapshots.jsonl diff --git a/README.md b/README.md index 8f242e8..7dacb59 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ Plus one **Optional features** step (opt-in, off by default): | Feature | Default | |---|---| | Enable FTS memory search `[BETA]` | `no` | +| Enable tldraw visual canvas `[BETA]` | `no` | **Flags:** @@ -227,7 +228,7 @@ See [`docs/architecture.md`](docs/architecture.md) for the full lifecycle. Every guide shows the folder structure. This repo gives you the folder structure **plus the files that actually go inside**: a working portable -brain with eight seed skills, four memory layers, enforced permissions, a +brain with nine seed skills, four memory layers, enforced permissions, a nightly staging cycle, host-agent review tools, and adapters for multiple harnesses. @@ -375,6 +376,8 @@ verify_codex_fixes.py # v0.8.0 regression checks (33 checks) daily reports across harnesses - **data-flywheel** — approved runs into context cards, evals, redacted traces, training-ready JSONL, and flywheel metrics +- **tldraw** — opt-in beta skill for live canvas diagrams with a local + snapshot store under `.agent/skills/tldraw/` ## How it compounds diff --git a/adapters/_shared/tldraw-mcp.json b/adapters/_shared/tldraw-mcp.json new file mode 100644 index 0000000..4b37a9c --- /dev/null +++ b/adapters/_shared/tldraw-mcp.json @@ -0,0 +1,9 @@ +{ + "_comment": "Canonical tldraw MCP server config. Adapters reference or copy this block. The tldraw MCP server is a Node package; `npx -y @tldraw-mcp/server` fetches and runs it on demand. See https://github.com/... for the server source.", + "mcpServers": { + "tldraw": { + "command": "npx", + "args": ["-y", "@tldraw-mcp/server"] + } + } +} diff --git a/adapters/antigravity/ANTIGRAVITY.md b/adapters/antigravity/ANTIGRAVITY.md index c7b5489..5f86e14 100644 --- a/adapters/antigravity/ANTIGRAVITY.md +++ b/adapters/antigravity/ANTIGRAVITY.md @@ -37,6 +37,13 @@ Skip it and the system is just files on disk. - Teach the agent a new rule in one shot: `python3 .agent/tools/learn.py "" --rationale ""`. +## Visual memory (tldraw, opt-in) +If `.agent/memory/.features.json` has `tldraw.enabled: true`, the `tldraw` +skill is available. It draws on a live canvas at `http://localhost:3030` +via the tldraw MCP server configured in `.mcp.json`. Worthwhile drawings +snapshot into the skill's local store and are recalled with +`python3 .agent/skills/tldraw/store.py list`. Off by default. + ## Rules that override defaults - Never force push to `main`, `production`, or `staging`. - Never delete episodic or semantic memory entries — archive them. diff --git a/onboard.py b/onboard.py index e90d44e..2ac0223 100644 --- a/onboard.py +++ b/onboard.py @@ -68,6 +68,10 @@ def _wizard(target, force): f"Enable FTS memory search {ORANGE}[BETA]{R}?", default=False, ) + a["feature_tldraw"] = ask_confirm( + f"Enable tldraw visual memory {ORANGE}[BETA]{R}?", + default=False, + ) return a @@ -85,6 +89,7 @@ def main(): # --yes defaults all optional beta features to off features_file = write_features(target, { "memory_search_fts": {"enabled": False, "beta": True}, + "tldraw": {"enabled": False, "beta": True}, }) print(f"{GREEN}◆{R} {WHITE}{B}PREFERENCES.md{R} written with defaults") print(f"{MUTED} {path}{R}") @@ -101,6 +106,10 @@ def main(): "enabled": bool(answers.get("feature_memory_search")), "beta": True, }, + "tldraw": { + "enabled": bool(answers.get("feature_tldraw")), + "beta": True, + }, } features_file = write_features(target, features) outro([ diff --git a/test_tldraw_visual_memory.py b/test_tldraw_visual_memory.py new file mode 100644 index 0000000..5a7ba2d --- /dev/null +++ b/test_tldraw_visual_memory.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Validation suite for the tldraw skill + local snapshot store. + +Run from the agentic-stack repo root: + + python3 test_tldraw_visual_memory.py + +Exit 0 = all tests passed. Non-zero = something is broken. + +Tests: + 1. SKILL.md exists and has valid YAML frontmatter with required fields + 2. _index.md references tldraw + 3. _manifest.jsonl has a valid tldraw entry + 4. store.py imports and the skill-local store is well-formed + 5. snapshot() writes a shape file, a jsonl record, and renders INDEX.md + 6. list_snapshots() surfaces the new record, filters by tag + 7. load_snapshot() round-trips shape data + 8. archive_snapshot() moves file + flips status, never deletes + 9. CLI: snapshot via stdin -> list -> archive roundtrip + 10. Feature flag: onboard_features.is_enabled('tldraw') respects the file + 11. adapter installs do not wire beta MCP by default + 12. skill_loader honors the tldraw feature flag +""" +from __future__ import annotations + +import importlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading + +HERE = os.path.dirname(os.path.abspath(__file__)) +AGENT = os.path.join(HERE, ".agent") +TLDRAW = os.path.join(AGENT, "skills", "tldraw") +TOOLS = os.path.join(AGENT, "tools") + +sys.path.insert(0, HERE) +sys.path.insert(0, TLDRAW) +sys.path.insert(0, TOOLS) + +PASS = "\033[32m+\033[0m" +FAIL = "\033[31mx\033[0m" + +_results: list[tuple[str, bool, str]] = [] + + +def _check(name: str, cond: bool, detail: str = "") -> None: + _results.append((name, bool(cond), detail)) + mark = PASS if cond else FAIL + suffix = f" - {detail}" if detail else "" + print(f" {mark} {name}{suffix}") + + +def _section(title: str) -> None: + print(f"\n{title}") + + +# ── 1. skill file ────────────────────────────────────────────────────── + +def test_skill_file() -> None: + _section("skill file") + skill_path = os.path.join(AGENT, "skills", "tldraw", "SKILL.md") + if not os.path.exists(skill_path): + _check("SKILL.md exists", False, skill_path) + return + _check("SKILL.md exists", True) + text = open(skill_path, encoding="utf-8").read() + _check("starts with YAML frontmatter", text.startswith("---\n")) + # Minimal parse: grab the first fenced block, split on colons. + _, _, rest = text.partition("---\n") + fm, _, _ = rest.partition("\n---") + fields = {} + for line in fm.splitlines(): + if ":" in line and not line.lstrip().startswith("#"): + k, _, v = line.partition(":") + fields[k.strip()] = v.strip() + for required in ("name", "version", "triggers", "tools", "constraints"): + _check(f"frontmatter has `{required}`", required in fields) + _check("name is tldraw", fields.get("name") == "tldraw") + _check("body mentions self-rewrite hook", + "Self-rewrite hook" in text or "self-rewrite hook" in text.lower()) + + +# ── 2. skill registry ────────────────────────────────────────────────── + +def test_registry() -> None: + _section("skill registry") + idx = os.path.join(AGENT, "skills", "_index.md") + man = os.path.join(AGENT, "skills", "_manifest.jsonl") + _check("_index.md mentions tldraw", + "tldraw" in open(idx, encoding="utf-8").read()) + + found = None + for line in open(man, encoding="utf-8"): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError as e: + _check("_manifest.jsonl lines parse", False, str(e)) + return + if row.get("name") == "tldraw": + found = row + _check("_manifest.jsonl has tldraw entry", found is not None) + if found: + _check("manifest triggers include 'draw'", "draw" in found.get("triggers", [])) + _check("manifest tools include get_canvas", + "mcp.tldraw.get_canvas" in found.get("tools", [])) + _check("manifest carries feature_flag=tldraw", + found.get("feature_flag") == "tldraw") + + +# ── 3. tldraw store module ───────────────────────────────────────────── + +SAMPLE_SHAPES = [ + {"type": "geo", "geo": "rectangle", "x": 100, "y": 100, + "w": 160, "h": 80, "text": "Start", "color": "blue"}, + {"type": "geo", "geo": "rectangle", "x": 400, "y": 100, + "w": 160, "h": 80, "text": "End", "color": "green"}, + {"type": "arrow", "x": 260, "y": 140, "end": {"x": 400, "y": 140}}, +] + + +def _isolated_visual_module(): + """Load store.py with its storage paths redirected into a tmp dir. + + We don't want tests writing into the real skill store. Rebinding + module-level path constants to the tmpdir is the cleanest sandbox. + """ + import store as vm # type: ignore + importlib.reload(vm) + tmp = tempfile.mkdtemp(prefix="tldraw-store-test-") + vm.SNAPSHOTS_DIR = os.path.join(tmp, "snapshots") + vm.ARCHIVE_DIR = os.path.join(vm.SNAPSHOTS_DIR, "archive") + vm.JSONL_PATH = os.path.join(tmp, "snapshots.jsonl") + vm.INDEX_PATH = os.path.join(tmp, "INDEX.md") + return vm, tmp + + +def test_visual_memory_api() -> None: + _section("tldraw store — python API") + _check("tldraw skill dir exists", os.path.isdir(TLDRAW)) + _check("store.py exists", os.path.exists(os.path.join(TLDRAW, "store.py"))) + + vm, tmp = _isolated_visual_module() + try: + meta = vm.snapshot(SAMPLE_SHAPES, label="auth flow", tags=["arch", "auth"], + note="test") + _check("snapshot returns metadata with id", bool(meta.get("id"))) + _check("snapshot label is sanitized", meta.get("label") == "auth-flow") + _check("shape_count matches input", meta.get("shape_count") == len(SAMPLE_SHAPES)) + + sid = meta["id"] + shape_file = os.path.join(vm.SNAPSHOTS_DIR, f"{sid}.json") + _check("shape file written", os.path.exists(shape_file)) + _check("INDEX.md rendered", + os.path.exists(vm.INDEX_PATH) + and sid in open(vm.INDEX_PATH, encoding="utf-8").read()) + + # envelope form also accepted + meta2 = vm.snapshot({"shapes": SAMPLE_SHAPES[:1]}, label="single") + _check("envelope form accepted", meta2["shape_count"] == 1) + + rows = vm.list_snapshots() + _check("list_snapshots returns both", len(rows) == 2) + tagged = vm.list_snapshots(tag="auth") + _check("list_snapshots filters by tag", len(tagged) == 1 and tagged[0]["id"] == sid) + + loaded = vm.load_snapshot(sid) + _check("load_snapshot returns shapes", loaded.get("shapes") == SAMPLE_SHAPES) + + vm.archive_snapshot(sid) + _check("archive moves file to archive/", + not os.path.exists(shape_file) + and os.path.exists(os.path.join(vm.ARCHIVE_DIR, f"{sid}.json"))) + active = vm.list_snapshots() + _check("archived snapshot hidden from default list", + all(r["id"] != sid for r in active)) + with_archived = vm.list_snapshots(include_archived=True) + _check("archived snapshot visible with --all", + any(r["id"] == sid and r.get("status") == "archived" + for r in with_archived)) + # archive preserves data + loaded_after = vm.load_snapshot(sid) + _check("archived snapshot still loadable", loaded_after.get("shapes") == SAMPLE_SHAPES) + + try: + vm.load_snapshot("does-not-exist") + _check("missing id raises", False, "no exception") + except FileNotFoundError: + _check("missing id raises", True) + + try: + vm.load_snapshot("../does-not-exist") + _check("path traversal id rejected", False, "no exception") + except ValueError: + _check("path traversal id rejected", True) + + try: + vm.snapshot("not a list", label="bad") + _check("non-list payload rejected", False, "no exception") + except ValueError: + _check("non-list payload rejected", True) + + # Regression: identical payloads captured in the same second must + # not collide. An earlier payload-hash id design silently overwrote + # the first snapshot's file while still appending a second metadata + # row — corrupting the layer. + from datetime import datetime, timezone + fixed = datetime(2026, 4, 21, 12, 0, 0, tzinfo=timezone.utc) + a = vm.snapshot(SAMPLE_SHAPES, label="dup", when=fixed) + b = vm.snapshot(SAMPLE_SHAPES, label="dup", when=fixed) + _check("same-second identical snapshots get distinct ids", + a["id"] != b["id"]) + _check("both snapshot files exist on disk", + os.path.exists(os.path.join(vm.SNAPSHOTS_DIR, f"{a['id']}.json")) + and os.path.exists(os.path.join(vm.SNAPSHOTS_DIR, f"{b['id']}.json"))) + + with open(vm.JSONL_PATH, "a", encoding="utf-8") as f: + f.write("{broken json\n") + rows = vm.list_snapshots(include_archived=True) + _check("malformed JSONL line is skipped", len(rows) >= 4) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def test_concurrent_snapshots_keep_index_complete() -> None: + _section("tldraw store — concurrency") + vm, tmp = _isolated_visual_module() + try: + created: list[str] = [] + lock = threading.Lock() + + def worker(i: int) -> None: + meta = vm.snapshot(SAMPLE_SHAPES, label=f"thread {i}", tags=["thread"]) + with lock: + created.append(meta["id"]) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(16)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + rows = vm.list_snapshots() + index_text = open(vm.INDEX_PATH, encoding="utf-8").read() + _check("all concurrent snapshots wrote JSONL rows", len(rows) == 16) + _check("all concurrent snapshot ids are unique", len(set(created)) == 16) + _check("INDEX.md includes every concurrent snapshot", + all(sid in index_text for sid in created)) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +# ── 4. CLI ───────────────────────────────────────────────────────────── + +def test_cli_roundtrip() -> None: + _section("tldraw store — CLI") + tmp = tempfile.mkdtemp(prefix="visual-cli-test-") + try: + env = os.environ.copy() + # Run the CLI in a scratch cwd so it picks up an isolated module load + # with redirected paths via a one-shot shim script. + shim = os.path.join(tmp, "shim.py") + with open(shim, "w", encoding="utf-8") as f: + f.write( + "import sys, os, json\n" + f"sys.path.insert(0, {repr(TLDRAW)})\n" + "import store as vm\n" + f"vm.SNAPSHOTS_DIR = {repr(os.path.join(tmp, 'snapshots'))}\n" + f"vm.ARCHIVE_DIR = {repr(os.path.join(tmp, 'snapshots', 'archive'))}\n" + f"vm.JSONL_PATH = {repr(os.path.join(tmp, 'snapshots.jsonl'))}\n" + f"vm.INDEX_PATH = {repr(os.path.join(tmp, 'INDEX.md'))}\n" + "sys.exit(vm.main(sys.argv[1:]))\n" + ) + + payload = json.dumps({"shapes": SAMPLE_SHAPES}) + r = subprocess.run( + [sys.executable, shim, "snapshot", "--label", "cli-test", + "--tags", "cli,smoke", "--note", "via stdin"], + input=payload, capture_output=True, text=True, env=env, timeout=20, + ) + _check("CLI snapshot exits 0", r.returncode == 0, + r.stderr.strip()[-200:] if r.returncode else "") + meta = json.loads(r.stdout) if r.returncode == 0 else {} + sid = meta.get("id", "") + _check("CLI snapshot returns id", bool(sid)) + + r = subprocess.run( + [sys.executable, shim, "list", "--json"], + capture_output=True, text=True, env=env, timeout=20, + ) + _check("CLI list exits 0", r.returncode == 0) + rows = json.loads(r.stdout) if r.returncode == 0 else [] + _check("CLI list shows the snapshot", + any(row.get("id") == sid for row in rows)) + + r = subprocess.run( + [sys.executable, shim, "archive", sid], + capture_output=True, text=True, env=env, timeout=20, + ) + _check("CLI archive exits 0", r.returncode == 0, + r.stderr.strip()[-200:] if r.returncode else "") + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +# ── 5. feature flag ──────────────────────────────────────────────────── + +def test_feature_flag() -> None: + _section("feature flag") + import onboard_features as of + tmp = tempfile.mkdtemp(prefix="feat-test-") + try: + _check("tldraw disabled by default", not of.is_enabled(tmp, "tldraw")) + of.write_features(tmp, {"tldraw": {"enabled": True, "beta": True}}) + _check("tldraw enabled after opt-in", of.is_enabled(tmp, "tldraw")) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +# ── 6. adapter MCP configs ───────────────────────────────────────────── + +def test_mcp_configs() -> None: + _section("adapter MCP wiring") + shared = os.path.join(HERE, "adapters", "_shared", "tldraw-mcp.json") + _check("shared mcp config exists", os.path.exists(shared), shared) + data = json.load(open(shared, encoding="utf-8")) + server = (data.get("mcpServers") or {}).get("tldraw") or {} + _check("shared mcp config registers tldraw", + isinstance(server, dict) and bool(server.get("command"))) + + for adapter in ("claude-code", "cursor", "antigravity"): + manifest_path = os.path.join(HERE, "adapters", adapter, "adapter.json") + manifest = json.load(open(manifest_path, encoding="utf-8")) + dsts = [entry.get("dst", "") for entry in manifest.get("files", [])] + _check(f"{adapter} does not install tldraw MCP by default", + not any("mcp" in dst.lower() for dst in dsts)) + + +def test_skill_loader_feature_flag() -> None: + _section("skill loader feature flag") + import skill_loader as sl # type: ignore + importlib.reload(sl) + tmp = tempfile.mkdtemp(prefix="skill-loader-feature-") + old_path = sl.FEATURES_PATH + try: + sl.FEATURES_PATH = os.path.join(tmp, ".features.json") + with open(sl.FEATURES_PATH, "w", encoding="utf-8") as f: + json.dump({"tldraw": {"enabled": False, "beta": True}}, f) + loaded = sl.progressive_load("draw an architecture diagram") + _check("tldraw skill disabled when feature flag is off", + all(row["name"] != "tldraw" for row in loaded)) + + with open(sl.FEATURES_PATH, "w", encoding="utf-8") as f: + json.dump({"tldraw": {"enabled": True, "beta": True}}, f) + loaded = sl.progressive_load("draw an architecture diagram") + _check("tldraw skill loads when feature flag is on", + any(row["name"] == "tldraw" for row in loaded)) + finally: + sl.FEATURES_PATH = old_path + shutil.rmtree(tmp, ignore_errors=True) + + +# ── main ─────────────────────────────────────────────────────────────── + +def main() -> int: + print("tldraw + visual memory validation") + test_skill_file() + test_registry() + test_visual_memory_api() + test_concurrent_snapshots_keep_index_complete() + test_cli_roundtrip() + test_feature_flag() + test_mcp_configs() + test_skill_loader_feature_flag() + + passed = sum(1 for _, ok, _ in _results if ok) + total = len(_results) + print(f"\n{passed}/{total} passed") + failing = [n for n, ok, _ in _results if not ok] + if failing: + for name in failing: + print(f" failing: {name}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())