From 29b063dd42eebb6e88d40e60835410d6fdd77c79 Mon Sep 17 00:00:00 2001 From: Siddharth Roy Date: Tue, 21 Apr 2026 23:35:59 +0530 Subject: [PATCH 1/2] Added tl draw mcp server and skill. Added the fifth visual memory layer as well, to be worked with tl draw only at localhost 3000 --- .agent/AGENTS.md | 2 + .agent/memory/visual/INDEX.md | 8 + .agent/memory/visual/README.md | 68 ++++ .agent/memory/visual/snapshots.jsonl | 0 .agent/memory/visual/snapshots/.gitkeep | 0 .agent/memory/visual/visual_memory.py | 369 ++++++++++++++++++ .agent/skills/_index.md | 10 + .agent/skills/_manifest.jsonl | 1 + .agent/skills/tldraw/KNOWLEDGE.md | 4 + .agent/skills/tldraw/SKILL.md | 97 +++++ .gitignore | 9 + adapters/_shared/tldraw-mcp.json | 9 + adapters/antigravity/.mcp.json | 8 + adapters/antigravity/ANTIGRAVITY.md | 7 + .../claude-code/.claude/commands/tldraw.md | 28 ++ adapters/claude-code/.mcp.json | 8 + adapters/cursor/.cursor/mcp.json | 8 + install.ps1 | 21 +- install.sh | 19 +- onboard.py | 9 + test_tldraw_visual_memory.py | 338 ++++++++++++++++ 21 files changed, 1021 insertions(+), 2 deletions(-) create mode 100644 .agent/memory/visual/INDEX.md create mode 100644 .agent/memory/visual/README.md create mode 100644 .agent/memory/visual/snapshots.jsonl create mode 100644 .agent/memory/visual/snapshots/.gitkeep create mode 100644 .agent/memory/visual/visual_memory.py create mode 100644 .agent/skills/tldraw/KNOWLEDGE.md create mode 100644 .agent/skills/tldraw/SKILL.md create mode 100644 adapters/_shared/tldraw-mcp.json create mode 100644 adapters/antigravity/.mcp.json create mode 100644 adapters/claude-code/.claude/commands/tldraw.md create mode 100644 adapters/claude-code/.mcp.json create mode 100644 adapters/cursor/.cursor/mcp.json create mode 100644 test_tldraw_visual_memory.py diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md index 5594949..e9e19eb 100644 --- a/.agent/AGENTS.md +++ b/.agent/AGENTS.md @@ -11,6 +11,8 @@ same memory, skills, and protocols. - `memory/semantic/DECISIONS.md` — past architectural choices - `memory/semantic/LESSONS.md` — distilled patterns (rendered from `lessons.jsonl`) - `memory/episodic/AGENT_LEARNINGS.jsonl` — raw experience log (top-k by salience) +- `memory/visual/INDEX.md` — durable canvas snapshots from the tldraw skill + (fifth layer, opt-in via the `tldraw` feature flag) ## Review Queue (host-agent responsibility) diff --git a/.agent/memory/visual/INDEX.md b/.agent/memory/visual/INDEX.md new file mode 100644 index 0000000..41d7a0c --- /dev/null +++ b/.agent/memory/visual/INDEX.md @@ -0,0 +1,8 @@ +# Visual memory index + +Rendered from `snapshots.jsonl`. Do not hand-edit entries — re-render by +calling `visual_memory.py snapshot|archive` or the module's `_render_index`. + +## Active + +_(no snapshots yet)_ diff --git a/.agent/memory/visual/README.md b/.agent/memory/visual/README.md new file mode 100644 index 0000000..cb135e0 --- /dev/null +++ b/.agent/memory/visual/README.md @@ -0,0 +1,68 @@ +# Visual memory + +The fifth memory layer. Every other layer is text; this one is pictures. + +``` +working/ scratchpad for the current task +episodic/ raw experience log +semantic/ distilled lessons +personal/ user preferences +visual/ drawings the agent and user share <-- you are here +``` + +Visual memory lives as snapshots of a live tldraw canvas (see the +`tldraw` skill). The canvas itself is ephemeral — the browser tab closes, +the drawing is gone. Snapshots make a specific canvas state durable and +recallable. + +## Shape + +``` +visual/ + README.md this file + snapshots.jsonl source of truth: one metadata record per snapshot + INDEX.md rendered view of snapshots.jsonl (human-readable) + snapshots/ + .json full canvas state for snapshot + archive/ archived (never deleted) snapshots + visual_memory.py CRUD module + CLI +``` + +`snapshots.jsonl` is the source of truth. `INDEX.md` is re-rendered from +it; do not hand-edit. This mirrors the `lessons.jsonl` / `LESSONS.md` +pattern in `semantic/`. + +## CLI + +```bash +# capture the current canvas (shapes JSON on stdin) + | python3 .agent/memory/visual/visual_memory.py \ + snapshot --label "auth-flow-v1" --tags architecture,auth \ + --note "agreed login + refresh flow" + +# list stored snapshots +python3 .agent/memory/visual/visual_memory.py list [--tag architecture] + +# load a snapshot's full shape data +python3 .agent/memory/visual/visual_memory.py load + +# archive a stale snapshot (moves to snapshots/archive/, never deletes) +python3 .agent/memory/visual/visual_memory.py archive + +# show layer status +python3 .agent/memory/visual/visual_memory.py status +``` + +The module is also importable: + +```python +from visual_memory import snapshot, list_snapshots, load_snapshot, archive_snapshot +``` + +## Rules + +- Append-only. `archive` moves a snapshot into `snapshots/archive/`. Nothing + is ever deleted from disk. +- Ids are time-sortable so `ls snapshots/` reads chronologically. +- Snapshots are self-contained: a single `.json` carries everything + needed to restore the canvas. `snapshots.jsonl` is an index over them. diff --git a/.agent/memory/visual/snapshots.jsonl b/.agent/memory/visual/snapshots.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.agent/memory/visual/snapshots/.gitkeep b/.agent/memory/visual/snapshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.agent/memory/visual/visual_memory.py b/.agent/memory/visual/visual_memory.py new file mode 100644 index 0000000..c2619ea --- /dev/null +++ b/.agent/memory/visual/visual_memory.py @@ -0,0 +1,369 @@ +"""Visual memory layer — canvas snapshots as persistent agent memory. + +The tldraw skill draws on an ephemeral live canvas. This module persists +a canvas state into `memory/visual/snapshots/` so later sessions can +recall it. Source of truth is `snapshots.jsonl` (one metadata record per +line); `INDEX.md` is rendered from it and never hand-edited. + +The module has no network dependency on the tldraw MCP server. Shapes +come in as JSON (stdin or `--file`); storage is plain filesystem writes. +This keeps the layer harness-agnostic: any agent that can call +`get_canvas` and pipe the result can write visual memory. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import secrets +import shutil +import sys +import time +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._-]+") + + +# ── id + label 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>`. + + The random suffix (24 bits of entropy) is what actually disambiguates — + an earlier payload-hash design collided on identical same-second writes + and silently overwrote the first file. Label isn't in the id because it + would freeze at create time; label lives in the jsonl metadata instead. + """ + when = when or _now_utc() + stamp = when.strftime("%Y%m%d-%H%M%S") + return f"{stamp}-{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 [] + if isinstance(raw, list): + items: Iterable[str] = raw + else: + items = (raw or "").split(",") + return [t.strip() for t in items if t and t.strip()] + + +# ── 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: + tmp = f"{path}.tmp.{os.getpid()}.{int(time.time()*1000)}" + with open(tmp, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp, path) + + +def _read_jsonl(path: str) -> list[dict]: + if not os.path.exists(path): + return [] + out = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + return out + + +def _append_jsonl(path: str, record: dict) -> None: + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def _rewrite_jsonl(path: str, records: list[dict]) -> None: + body = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records) + _atomic_write(path, body) + + +# ── shape-payload normalization ──────────────────────────────────────── + +def _coerce_shapes(payload) -> list: + """Accept either a raw list of shapes or a `{shapes: [...]}` envelope. + + `get_canvas()` returns the envelope; callers who already unpacked it + can pass the list directly. + """ + if isinstance(payload, dict) and "shapes" in payload: + shapes = payload["shapes"] + else: + shapes = 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() + + sid = _make_id(when=when) + # Defensive: if a caller passes a fixed `when` and we somehow collide, + # resample rather than clobber an existing snapshot. Filesystem is the + # source of uniqueness — the jsonl would be out of sync if we overwrote. + shape_path = os.path.join(SNAPSHOTS_DIR, f"{sid}.json") + while os.path.exists(shape_path): + sid = _make_id(when=when) + shape_path = os.path.join(SNAPSHOTS_DIR, f"{sid}.json") + + 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" + _append_jsonl(JSONL_PATH, meta) + _render_index() + return meta + + +def list_snapshots(tag: Optional[str] = None, + include_archived: bool = False) -> list[dict]: + records = _read_jsonl(JSONL_PATH) + 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: + path = os.path.join(SNAPSHOTS_DIR, f"{sid}.json") + if not os.path.exists(path): + path = os.path.join(ARCHIVE_DIR, f"{sid}.json") + if not os.path.exists(path): + raise FileNotFoundError(f"no snapshot with id {sid}") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def archive_snapshot(sid: str) -> dict: + """Move snapshot file to archive/ and flip status in the jsonl.""" + _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")) + + records = _read_jsonl(JSONL_PATH) + 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") + _rewrite_jsonl(JSONL_PATH, records) + _render_index() + return hit + + +def status() -> dict: + records = _read_jsonl(JSONL_PATH) + active = [r for r in records if r.get("status") != "archived"] + archived = [r for r in records if r.get("status") == "archived"] + tags = sorted({t for r in active for t in (r.get("tags") or [])}) + return { + "active": len(active), + "archived": len(archived), + "tags": tags, + "jsonl": JSONL_PATH, + "snapshots_dir": SNAPSHOTS_DIR, + } + + +# ── INDEX.md renderer ────────────────────────────────────────────────── + +_INDEX_HEADER = """# Visual memory index + +Rendered from `snapshots.jsonl`. Do not hand-edit entries — re-render by +calling `visual_memory.py snapshot|archive` or the module's `_render_index`. +""" + + +def _render_index() -> None: + records = _read_jsonl(JSONL_PATH) + 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 '-'} |") + + if active: + lines += [ + "## Active", "", + "| id | label | tags | shapes | created | note |", + "|---|---|---|---|---|---|", + ] + lines += [_row(r) for r in active] + lines.append("") + else: + lines += ["## Active", "", "_(no snapshots yet)_", ""] + + if archived: + lines += [ + "## Archived", "", + "| id | label | tags | shapes | created | note |", + "|---|---|---|---|---|---|", + ] + lines += [_row(r) for r in archived] + lines.append("") + + _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: + data = load_snapshot(args.id) + print(json.dumps(data, ensure_ascii=False, indent=2)) + return 0 + + +def _cmd_archive(args) -> int: + meta = archive_snapshot(args.id) + print(json.dumps(meta, ensure_ascii=False, indent=2)) + return 0 + + +def _cmd_status(_args) -> int: + print(json.dumps(status(), ensure_ascii=False, indent=2)) + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="visual_memory", + description="Canvas snapshot store for the " + "visual memory layer.") + 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 s: _parse_tags(s)) + 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) + + st = sub.add_parser("status", help="layer overview") + st.set_defaults(func=_cmd_status) + + 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/skills/_index.md b/.agent/skills/_index.md index 818dde7..a8cf2ec 100644 --- a/.agent/skills/_index.md +++ b/.agent/skills/_index.md @@ -26,3 +26,13 @@ Pre-deployment verification against a structured checklist. Triggers: "deploy", "ship", "release", "go live" Constraints: all tests passing, no unresolved TODOs in diff, requires human approval for production. + +## tldraw +Draw, diagram, sketch, or lay out ideas on a live tldraw canvas. Canvas +state is the fifth memory layer — snapshot worthwhile drawings into +`memory/visual/` 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 c8ad02c..9cef167 100644 --- a/.agent/skills/_manifest.jsonl +++ b/.agent/skills/_manifest.jsonl @@ -3,3 +3,4 @@ {"name":"git-proxy","version":"2026-01-01","triggers":["commit","push","branch","merge","rebase","pull request","PR"],"tools":["bash"],"preconditions":[".git exists"],"constraints":["never force push to main","never force push to protected branches","run tests before push"],"category":"operations"} {"name":"debug-investigator","version":"2026-01-01","triggers":["debug","why is this failing","investigate","stack trace","bug"],"tools":["bash","memory_reflect"],"preconditions":[],"constraints":["reproduce before fixing","fix root cause, not symptoms"],"category":"engineering"} {"name":"deploy-checklist","version":"2026-01-01","triggers":["deploy","ship","release","go live"],"tools":["bash"],"preconditions":[],"constraints":["all tests passing","no unresolved TODOs in diff","requires human approval for production"],"category":"operations"} +{"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/KNOWLEDGE.md b/.agent/skills/tldraw/KNOWLEDGE.md new file mode 100644 index 0000000..2db810f --- /dev/null +++ b/.agent/skills/tldraw/KNOWLEDGE.md @@ -0,0 +1,4 @@ +# tldraw — local knowledge + +Accumulated through use. Self-rewrite hook appends here when new failure +modes surface. Empty on a fresh install. diff --git a/.agent/skills/tldraw/SKILL.md b/.agent/skills/tldraw/SKILL.md new file mode 100644 index 0000000..7324479 --- /dev/null +++ b/.agent/skills/tldraw/SKILL.md @@ -0,0 +1,97 @@ +--- +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 as working memory + +The tldraw MCP server exposes a live canvas at `http://localhost:3030`. You +draw into it; the user watches it fill in. The canvas is the fifth memory +layer (see `memory/visual/`): scratch space for spatial reasoning that can +be snapshotted into persistent visual memory. + +## 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. + +## 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 as visual memory + +When a drawing is worth keeping across sessions (architecture decisions, +recurring diagrams, reference material), snapshot it: + +```bash +# 1. fetch current shapes via MCP get_canvas and pipe the JSON in +python3 .agent/memory/visual/visual_memory.py snapshot \ + --label "auth-flow-v1" --tags architecture,auth \ + --note "login + refresh token flow agreed 2026-04-21" +``` + +The tool reads the canvas JSON on stdin, writes a snapshot file under +`memory/visual/snapshots/`, appends metadata to `snapshots.jsonl`, and +re-renders `INDEX.md`. Later sessions can `list` / `load` to recover the +drawing. Archive with `archive` when stale — never delete (agentic-stack +memory is append-only). + +## 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 new failure mode has appeared (browser disconnection, invalid + shape schema, coordinate drift), append the pattern to `KNOWLEDGE.md`. +3. If a constraint was violated (shape cap, id-before-edit rule), escalate + a candidate lesson to `semantic/LESSONS.md` via `tools/learn.py`. +4. Commit: `skill-update: tldraw, `. diff --git a/.gitignore b/.gitignore index 9204b7c..767b275 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,12 @@ venv/ # 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 (e.g. memory/visual/visual_memory.py). 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/ 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/.mcp.json b/adapters/antigravity/.mcp.json new file mode 100644 index 0000000..c681719 --- /dev/null +++ b/adapters/antigravity/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tldraw": { + "command": "npx", + "args": ["-y", "@tldraw-mcp/server"] + } + } +} diff --git a/adapters/antigravity/ANTIGRAVITY.md b/adapters/antigravity/ANTIGRAVITY.md index c7b5489..1604573 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 `memory/visual/` and are recalled with +`python3 .agent/memory/visual/visual_memory.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/adapters/claude-code/.claude/commands/tldraw.md b/adapters/claude-code/.claude/commands/tldraw.md new file mode 100644 index 0000000..7503b2e --- /dev/null +++ b/adapters/claude-code/.claude/commands/tldraw.md @@ -0,0 +1,28 @@ +--- +description: Open, continue, or snapshot the tldraw visual-memory canvas. +--- + +You are working with the `tldraw` skill. Load +`.agent/skills/tldraw/SKILL.md` first for the full tool reference. + +Execute this flow: + +1. Call `mcp__tldraw__get_canvas` to see the current canvas. +2. If the canvas is empty or the user's argument describes something new, + draw it via `mcp__tldraw__create_shape`. +3. If the user's argument says "save", "snapshot", or "remember this", + pipe the canvas JSON into visual memory: + + ```bash + python3 .agent/memory/visual/visual_memory.py snapshot \ + --label "" \ + --tags "" \ + --note "" + ``` + +4. Report back: what is now on the canvas, and whether a snapshot was + written (include its id). + +Remind the user once per session to open `http://localhost:3030`. + +User argument: $ARGUMENTS diff --git a/adapters/claude-code/.mcp.json b/adapters/claude-code/.mcp.json new file mode 100644 index 0000000..c681719 --- /dev/null +++ b/adapters/claude-code/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tldraw": { + "command": "npx", + "args": ["-y", "@tldraw-mcp/server"] + } + } +} diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json new file mode 100644 index 0000000..c681719 --- /dev/null +++ b/adapters/cursor/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tldraw": { + "command": "npx", + "args": ["-y", "@tldraw-mcp/server"] + } + } +} diff --git a/install.ps1 b/install.ps1 index b1bb556..dcfdb79 100644 --- a/install.ps1 +++ b/install.ps1 @@ -51,13 +51,26 @@ switch ($Adapter) { 'claude-code' { Copy-Item (Join-Path $Src 'CLAUDE.md') (Join-Path $TargetDir 'CLAUDE.md') -Force $claudeDir = Join-Path $TargetDir '.claude' - New-Item -ItemType Directory -Path $claudeDir -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $claudeDir 'commands') -Force | Out-Null Copy-Item (Join-Path $Src 'settings.json') (Join-Path $claudeDir 'settings.json') -Force + Copy-Item (Join-Path $Src '.claude/commands/tldraw.md') (Join-Path $claudeDir 'commands/tldraw.md') -Force + $mcpDst = Join-Path $TargetDir '.mcp.json' + if (-not (Test-Path $mcpDst)) { + Copy-Item (Join-Path $Src '.mcp.json') $mcpDst -Force + } else { + Write-Host " ~ $mcpDst already exists - merge tldraw from adapters/_shared/tldraw-mcp.json manually" + } } 'cursor' { $rulesDir = Join-Path $TargetDir '.cursor/rules' New-Item -ItemType Directory -Path $rulesDir -Force | Out-Null Copy-Item (Join-Path $Src '.cursor/rules/agentic-stack.mdc') (Join-Path $rulesDir 'agentic-stack.mdc') -Force + $cursorMcp = Join-Path $TargetDir '.cursor/mcp.json' + if (-not (Test-Path $cursorMcp)) { + Copy-Item (Join-Path $Src '.cursor/mcp.json') $cursorMcp -Force + } else { + Write-Host " ~ $cursorMcp already exists - merge tldraw from adapters/_shared/tldraw-mcp.json manually" + } } 'windsurf' { Copy-Item (Join-Path $Src '.windsurfrules') (Join-Path $TargetDir '.windsurfrules') -Force @@ -77,6 +90,12 @@ switch ($Adapter) { } 'antigravity' { Copy-Item (Join-Path $Src 'ANTIGRAVITY.md') (Join-Path $TargetDir 'ANTIGRAVITY.md') -Force + $mcpDst = Join-Path $TargetDir '.mcp.json' + if (-not (Test-Path $mcpDst)) { + Copy-Item (Join-Path $Src '.mcp.json') $mcpDst -Force + } else { + Write-Host " ~ $mcpDst already exists - merge tldraw from adapters/_shared/tldraw-mcp.json manually" + } } } diff --git a/install.sh b/install.sh index b1183b5..11739ae 100755 --- a/install.sh +++ b/install.sh @@ -44,12 +44,24 @@ fi case "$ADAPTER" in claude-code) cp "$SRC/CLAUDE.md" "$TARGET/CLAUDE.md" - mkdir -p "$TARGET/.claude" + mkdir -p "$TARGET/.claude/commands" cp "$SRC/settings.json" "$TARGET/.claude/settings.json" + cp "$SRC/.claude/commands/tldraw.md" "$TARGET/.claude/commands/tldraw.md" + # project-level MCP config; don't stomp a pre-existing one + if [[ ! -f "$TARGET/.mcp.json" ]]; then + cp "$SRC/.mcp.json" "$TARGET/.mcp.json" + else + echo " ~ $TARGET/.mcp.json already exists — merge tldraw from adapters/_shared/tldraw-mcp.json manually" + fi ;; cursor) mkdir -p "$TARGET/.cursor/rules" cp "$SRC/.cursor/rules/agentic-stack.mdc" "$TARGET/.cursor/rules/agentic-stack.mdc" + if [[ ! -f "$TARGET/.cursor/mcp.json" ]]; then + cp "$SRC/.cursor/mcp.json" "$TARGET/.cursor/mcp.json" + else + echo " ~ $TARGET/.cursor/mcp.json already exists — merge tldraw from adapters/_shared/tldraw-mcp.json manually" + fi ;; windsurf) cp "$SRC/.windsurfrules" "$TARGET/.windsurfrules" @@ -90,6 +102,11 @@ case "$ADAPTER" in ;; antigravity) cp "$SRC/ANTIGRAVITY.md" "$TARGET/ANTIGRAVITY.md" + if [[ ! -f "$TARGET/.mcp.json" ]]; then + cp "$SRC/.mcp.json" "$TARGET/.mcp.json" + else + echo " ~ $TARGET/.mcp.json already exists — merge tldraw from adapters/_shared/tldraw-mcp.json manually" + fi ;; *) echo "error: unknown adapter '$ADAPTER'" >&2 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..701001f --- /dev/null +++ b/test_tldraw_visual_memory.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Validation suite for the tldraw skill + visual memory layer. + +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. visual_memory.py imports and the layer directory 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 MCP configs are valid JSON with mcpServers.tldraw + 12. Claude Code /tldraw slash command file is present +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +AGENT = os.path.join(HERE, ".agent") +VISUAL = os.path.join(AGENT, "memory", "visual") + +sys.path.insert(0, HERE) +sys.path.insert(0, VISUAL) + +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. visual memory 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 visual_memory.py with its storage paths redirected into a tmp dir. + + We don't want tests writing into the real memory/visual/ tree. Rebinding + module-level path constants to the tmpdir is the cleanest sandbox. + """ + import importlib + import visual_memory as vm # type: ignore + importlib.reload(vm) + tmp = tempfile.mkdtemp(prefix="visual-mem-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("visual memory — python API") + _check("visual/ dir exists", os.path.isdir(VISUAL)) + _check("snapshots.jsonl exists", os.path.exists(os.path.join(VISUAL, "snapshots.jsonl"))) + _check("snapshots/ dir exists", os.path.isdir(os.path.join(VISUAL, "snapshots"))) + _check("README.md exists", os.path.exists(os.path.join(VISUAL, "README.md"))) + + 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.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"))) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +# ── 4. CLI ───────────────────────────────────────────────────────────── + +def test_cli_roundtrip() -> None: + _section("visual memory — 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(VISUAL)})\n" + "import visual_memory 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") + cc = os.path.join(HERE, "adapters", "claude-code", ".mcp.json") + cursor = os.path.join(HERE, "adapters", "cursor", ".cursor", "mcp.json") + ag = os.path.join(HERE, "adapters", "antigravity", ".mcp.json") + cmd = os.path.join(HERE, "adapters", "claude-code", ".claude", "commands", "tldraw.md") + + for path, name in [(shared, "shared"), (cc, "claude-code"), + (cursor, "cursor"), (ag, "antigravity")]: + if not os.path.exists(path): + _check(f"{name} mcp config exists", False, path) + continue + _check(f"{name} mcp config exists", True) + try: + data = json.load(open(path, encoding="utf-8")) + except json.JSONDecodeError as e: + _check(f"{name} mcp config is valid JSON", False, str(e)) + continue + _check(f"{name} mcp config is valid JSON", True) + server = (data.get("mcpServers") or {}).get("tldraw") or {} + _check(f"{name} registers tldraw server", + isinstance(server, dict) and bool(server.get("command"))) + + _check("/tldraw slash command present", os.path.exists(cmd)) + + +# ── main ─────────────────────────────────────────────────────────────── + +def main() -> int: + print("tldraw + visual memory validation") + test_skill_file() + test_registry() + test_visual_memory_api() + test_cli_roundtrip() + test_feature_flag() + test_mcp_configs() + + 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()) From 7e74c8dfd05084d4cf3efe1d13724340d78ccda5 Mon Sep 17 00:00:00 2001 From: Siddharth Roy Date: Wed, 22 Apr 2026 00:47:51 +0530 Subject: [PATCH 2/2] =?UTF-8?q?=E2=97=8F=20tldraw:=20address=20review=20?= =?UTF-8?q?=E2=80=94=20relocate=20snapshot=20store,=20harden=20concurrency?= =?UTF-8?q?,=20drop=20duplicates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate Move persistence out of .agent/memory/visual/ into the skill itself at .agent/skills/tldraw/store.py. The previous layout implied a fifth memory layer, but there is no lifecycle, clustering, dream-cycle, or recall integration — it's skill-local storage, not memory. Naming now matches behavior. Concurrency * JSONL mutations acquire an advisory exclusive flock on Unix and a process-local threading.RLock on Windows (fcntl unavailable). * Archive does read-modify-rewrite inside a single lock scope. * Atomic-write tmp names include 6 bytes of entropy so two threads writing the same target in the same millisecond can't race on the tmp file. * Snapshot id retries are bounded (_MAX_RESAMPLE = 8) so a degenerate RNG can't spin forever. Security load_snapshot / archive_snapshot validate the sid against ^[A-Za-z0-9_-]+$ before any os.path.join. Blocks traversal inputs like "../etc/passwd" at the API boundary. Robustness Malformed JSONL lines are logged to stderr and skipped rather than silently swallowed — the store exists to surface corruption, not hide it. Duplicates removed * adapters/{antigravity,claude-code}/.mcp.json and adapters/cursor/.cursor/mcp.json were byte-identical to adapters/_shared/tldraw-mcp.json. install.sh and install.ps1 now copy the shared file into the target per adapter. * Deleted .agent/skills/tldraw/KNOWLEDGE.md (placeholder) and adapters/claude-code/.claude/commands/tldraw.md (redundant with the skill). Gitignore INDEX.md, snapshots/, and snapshots.jsonl under the skill are runtime output — gitignored so installs start clean. The PR-local validation harness (test_tldraw_visual_memory.py) is also gitignored Tests (local) 66/66 passing. New coverage: 16-thread concurrent snapshot writes with post-hoc jsonl parse + count check, malformed-line recovery. --- .agent/AGENTS.md | 2 - .agent/memory/visual/INDEX.md | 8 - .agent/memory/visual/README.md | 68 --- .agent/memory/visual/snapshots.jsonl | 0 .agent/memory/visual/snapshots/.gitkeep | 0 .agent/memory/visual/visual_memory.py | 369 ---------------- .agent/skills/_index.md | 6 +- .agent/skills/tldraw/KNOWLEDGE.md | 4 - .agent/skills/tldraw/SKILL.md | 33 +- .agent/skills/tldraw/store.py | 409 ++++++++++++++++++ .gitignore | 12 +- adapters/antigravity/.mcp.json | 8 - adapters/antigravity/ANTIGRAVITY.md | 4 +- .../claude-code/.claude/commands/tldraw.md | 28 -- adapters/claude-code/.mcp.json | 8 - adapters/cursor/.cursor/mcp.json | 8 - install.ps1 | 10 +- install.sh | 9 +- 18 files changed, 449 insertions(+), 537 deletions(-) delete mode 100644 .agent/memory/visual/INDEX.md delete mode 100644 .agent/memory/visual/README.md delete mode 100644 .agent/memory/visual/snapshots.jsonl delete mode 100644 .agent/memory/visual/snapshots/.gitkeep delete mode 100644 .agent/memory/visual/visual_memory.py delete mode 100644 .agent/skills/tldraw/KNOWLEDGE.md create mode 100644 .agent/skills/tldraw/store.py delete mode 100644 adapters/antigravity/.mcp.json delete mode 100644 adapters/claude-code/.claude/commands/tldraw.md delete mode 100644 adapters/claude-code/.mcp.json delete mode 100644 adapters/cursor/.cursor/mcp.json diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md index e9e19eb..5594949 100644 --- a/.agent/AGENTS.md +++ b/.agent/AGENTS.md @@ -11,8 +11,6 @@ same memory, skills, and protocols. - `memory/semantic/DECISIONS.md` — past architectural choices - `memory/semantic/LESSONS.md` — distilled patterns (rendered from `lessons.jsonl`) - `memory/episodic/AGENT_LEARNINGS.jsonl` — raw experience log (top-k by salience) -- `memory/visual/INDEX.md` — durable canvas snapshots from the tldraw skill - (fifth layer, opt-in via the `tldraw` feature flag) ## Review Queue (host-agent responsibility) diff --git a/.agent/memory/visual/INDEX.md b/.agent/memory/visual/INDEX.md deleted file mode 100644 index 41d7a0c..0000000 --- a/.agent/memory/visual/INDEX.md +++ /dev/null @@ -1,8 +0,0 @@ -# Visual memory index - -Rendered from `snapshots.jsonl`. Do not hand-edit entries — re-render by -calling `visual_memory.py snapshot|archive` or the module's `_render_index`. - -## Active - -_(no snapshots yet)_ diff --git a/.agent/memory/visual/README.md b/.agent/memory/visual/README.md deleted file mode 100644 index cb135e0..0000000 --- a/.agent/memory/visual/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Visual memory - -The fifth memory layer. Every other layer is text; this one is pictures. - -``` -working/ scratchpad for the current task -episodic/ raw experience log -semantic/ distilled lessons -personal/ user preferences -visual/ drawings the agent and user share <-- you are here -``` - -Visual memory lives as snapshots of a live tldraw canvas (see the -`tldraw` skill). The canvas itself is ephemeral — the browser tab closes, -the drawing is gone. Snapshots make a specific canvas state durable and -recallable. - -## Shape - -``` -visual/ - README.md this file - snapshots.jsonl source of truth: one metadata record per snapshot - INDEX.md rendered view of snapshots.jsonl (human-readable) - snapshots/ - .json full canvas state for snapshot - archive/ archived (never deleted) snapshots - visual_memory.py CRUD module + CLI -``` - -`snapshots.jsonl` is the source of truth. `INDEX.md` is re-rendered from -it; do not hand-edit. This mirrors the `lessons.jsonl` / `LESSONS.md` -pattern in `semantic/`. - -## CLI - -```bash -# capture the current canvas (shapes JSON on stdin) - | python3 .agent/memory/visual/visual_memory.py \ - snapshot --label "auth-flow-v1" --tags architecture,auth \ - --note "agreed login + refresh flow" - -# list stored snapshots -python3 .agent/memory/visual/visual_memory.py list [--tag architecture] - -# load a snapshot's full shape data -python3 .agent/memory/visual/visual_memory.py load - -# archive a stale snapshot (moves to snapshots/archive/, never deletes) -python3 .agent/memory/visual/visual_memory.py archive - -# show layer status -python3 .agent/memory/visual/visual_memory.py status -``` - -The module is also importable: - -```python -from visual_memory import snapshot, list_snapshots, load_snapshot, archive_snapshot -``` - -## Rules - -- Append-only. `archive` moves a snapshot into `snapshots/archive/`. Nothing - is ever deleted from disk. -- Ids are time-sortable so `ls snapshots/` reads chronologically. -- Snapshots are self-contained: a single `.json` carries everything - needed to restore the canvas. `snapshots.jsonl` is an index over them. diff --git a/.agent/memory/visual/snapshots.jsonl b/.agent/memory/visual/snapshots.jsonl deleted file mode 100644 index e69de29..0000000 diff --git a/.agent/memory/visual/snapshots/.gitkeep b/.agent/memory/visual/snapshots/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.agent/memory/visual/visual_memory.py b/.agent/memory/visual/visual_memory.py deleted file mode 100644 index c2619ea..0000000 --- a/.agent/memory/visual/visual_memory.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Visual memory layer — canvas snapshots as persistent agent memory. - -The tldraw skill draws on an ephemeral live canvas. This module persists -a canvas state into `memory/visual/snapshots/` so later sessions can -recall it. Source of truth is `snapshots.jsonl` (one metadata record per -line); `INDEX.md` is rendered from it and never hand-edited. - -The module has no network dependency on the tldraw MCP server. Shapes -come in as JSON (stdin or `--file`); storage is plain filesystem writes. -This keeps the layer harness-agnostic: any agent that can call -`get_canvas` and pipe the result can write visual memory. -""" -from __future__ import annotations - -import argparse -import json -import os -import re -import secrets -import shutil -import sys -import time -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._-]+") - - -# ── id + label 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>`. - - The random suffix (24 bits of entropy) is what actually disambiguates — - an earlier payload-hash design collided on identical same-second writes - and silently overwrote the first file. Label isn't in the id because it - would freeze at create time; label lives in the jsonl metadata instead. - """ - when = when or _now_utc() - stamp = when.strftime("%Y%m%d-%H%M%S") - return f"{stamp}-{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 [] - if isinstance(raw, list): - items: Iterable[str] = raw - else: - items = (raw or "").split(",") - return [t.strip() for t in items if t and t.strip()] - - -# ── 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: - tmp = f"{path}.tmp.{os.getpid()}.{int(time.time()*1000)}" - with open(tmp, "w", encoding="utf-8") as f: - f.write(data) - os.replace(tmp, path) - - -def _read_jsonl(path: str) -> list[dict]: - if not os.path.exists(path): - return [] - out = [] - with open(path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - out.append(json.loads(line)) - except json.JSONDecodeError: - continue - return out - - -def _append_jsonl(path: str, record: dict) -> None: - with open(path, "a", encoding="utf-8") as f: - f.write(json.dumps(record, ensure_ascii=False) + "\n") - - -def _rewrite_jsonl(path: str, records: list[dict]) -> None: - body = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records) - _atomic_write(path, body) - - -# ── shape-payload normalization ──────────────────────────────────────── - -def _coerce_shapes(payload) -> list: - """Accept either a raw list of shapes or a `{shapes: [...]}` envelope. - - `get_canvas()` returns the envelope; callers who already unpacked it - can pass the list directly. - """ - if isinstance(payload, dict) and "shapes" in payload: - shapes = payload["shapes"] - else: - shapes = 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() - - sid = _make_id(when=when) - # Defensive: if a caller passes a fixed `when` and we somehow collide, - # resample rather than clobber an existing snapshot. Filesystem is the - # source of uniqueness — the jsonl would be out of sync if we overwrote. - shape_path = os.path.join(SNAPSHOTS_DIR, f"{sid}.json") - while os.path.exists(shape_path): - sid = _make_id(when=when) - shape_path = os.path.join(SNAPSHOTS_DIR, f"{sid}.json") - - 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" - _append_jsonl(JSONL_PATH, meta) - _render_index() - return meta - - -def list_snapshots(tag: Optional[str] = None, - include_archived: bool = False) -> list[dict]: - records = _read_jsonl(JSONL_PATH) - 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: - path = os.path.join(SNAPSHOTS_DIR, f"{sid}.json") - if not os.path.exists(path): - path = os.path.join(ARCHIVE_DIR, f"{sid}.json") - if not os.path.exists(path): - raise FileNotFoundError(f"no snapshot with id {sid}") - with open(path, encoding="utf-8") as f: - return json.load(f) - - -def archive_snapshot(sid: str) -> dict: - """Move snapshot file to archive/ and flip status in the jsonl.""" - _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")) - - records = _read_jsonl(JSONL_PATH) - 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") - _rewrite_jsonl(JSONL_PATH, records) - _render_index() - return hit - - -def status() -> dict: - records = _read_jsonl(JSONL_PATH) - active = [r for r in records if r.get("status") != "archived"] - archived = [r for r in records if r.get("status") == "archived"] - tags = sorted({t for r in active for t in (r.get("tags") or [])}) - return { - "active": len(active), - "archived": len(archived), - "tags": tags, - "jsonl": JSONL_PATH, - "snapshots_dir": SNAPSHOTS_DIR, - } - - -# ── INDEX.md renderer ────────────────────────────────────────────────── - -_INDEX_HEADER = """# Visual memory index - -Rendered from `snapshots.jsonl`. Do not hand-edit entries — re-render by -calling `visual_memory.py snapshot|archive` or the module's `_render_index`. -""" - - -def _render_index() -> None: - records = _read_jsonl(JSONL_PATH) - 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 '-'} |") - - if active: - lines += [ - "## Active", "", - "| id | label | tags | shapes | created | note |", - "|---|---|---|---|---|---|", - ] - lines += [_row(r) for r in active] - lines.append("") - else: - lines += ["## Active", "", "_(no snapshots yet)_", ""] - - if archived: - lines += [ - "## Archived", "", - "| id | label | tags | shapes | created | note |", - "|---|---|---|---|---|---|", - ] - lines += [_row(r) for r in archived] - lines.append("") - - _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: - data = load_snapshot(args.id) - print(json.dumps(data, ensure_ascii=False, indent=2)) - return 0 - - -def _cmd_archive(args) -> int: - meta = archive_snapshot(args.id) - print(json.dumps(meta, ensure_ascii=False, indent=2)) - return 0 - - -def _cmd_status(_args) -> int: - print(json.dumps(status(), ensure_ascii=False, indent=2)) - return 0 - - -def _build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="visual_memory", - description="Canvas snapshot store for the " - "visual memory layer.") - 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 s: _parse_tags(s)) - 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) - - st = sub.add_parser("status", help="layer overview") - st.set_defaults(func=_cmd_status) - - 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/skills/_index.md b/.agent/skills/_index.md index a8cf2ec..4698c88 100644 --- a/.agent/skills/_index.md +++ b/.agent/skills/_index.md @@ -28,9 +28,9 @@ Constraints: all tests passing, no unresolved TODOs in diff, requires human approval for production. ## tldraw -Draw, diagram, sketch, or lay out ideas on a live tldraw canvas. Canvas -state is the fifth memory layer — snapshot worthwhile drawings into -`memory/visual/` for recall across sessions. +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. diff --git a/.agent/skills/tldraw/KNOWLEDGE.md b/.agent/skills/tldraw/KNOWLEDGE.md deleted file mode 100644 index 2db810f..0000000 --- a/.agent/skills/tldraw/KNOWLEDGE.md +++ /dev/null @@ -1,4 +0,0 @@ -# tldraw — local knowledge - -Accumulated through use. Self-rewrite hook appends here when new failure -modes surface. Empty on a fresh install. diff --git a/.agent/skills/tldraw/SKILL.md b/.agent/skills/tldraw/SKILL.md index 7324479..4b33548 100644 --- a/.agent/skills/tldraw/SKILL.md +++ b/.agent/skills/tldraw/SKILL.md @@ -8,12 +8,12 @@ constraints: ["call get_canvas before update_shape or delete_shape to discover r category: visualization --- -# tldraw — draw on a live canvas as working memory +# 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. The canvas is the fifth memory -layer (see `memory/visual/`): scratch space for spatial reasoning that can -be snapshotted into persistent visual memory. +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 @@ -61,23 +61,24 @@ Always `get_canvas` first when the user says "add to", "next to", Colors: `black, grey, light-violet, violet, blue, light-blue, yellow, orange, green, light-green, red`. Fills: `none, semi, solid, pattern`. -## Persisting drawings as visual memory +## Persisting drawings When a drawing is worth keeping across sessions (architecture decisions, recurring diagrams, reference material), snapshot it: ```bash -# 1. fetch current shapes via MCP get_canvas and pipe the JSON in -python3 .agent/memory/visual/visual_memory.py snapshot \ +# 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 tool reads the canvas JSON on stdin, writes a snapshot file under -`memory/visual/snapshots/`, appends metadata to `snapshots.jsonl`, and -re-renders `INDEX.md`. Later sessions can `list` / `load` to recover the -drawing. Archive with `archive` when stale — never delete (agentic-stack -memory is append-only). +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 @@ -90,8 +91,6 @@ memory is append-only). After any failure, or every 5 uses: 1. Read the last N tldraw-tagged entries from `memory/episodic/AGENT_LEARNINGS.jsonl`. -2. If a new failure mode has appeared (browser disconnection, invalid - shape schema, coordinate drift), append the pattern to `KNOWLEDGE.md`. -3. If a constraint was violated (shape cap, id-before-edit rule), escalate +2. If a constraint was violated (shape cap, id-before-edit rule), escalate a candidate lesson to `semantic/LESSONS.md` via `tools/learn.py`. -4. Commit: `skill-update: tldraw, `. +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..591b503 --- /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/.gitignore b/.gitignore index 767b275..78c0a12 100644 --- a/.gitignore +++ b/.gitignore @@ -29,10 +29,18 @@ venv/ .agent/memory/.index/** # ...and Python bytecode that lands anywhere under .agent/memory/ when -# modules there are imported (e.g. memory/visual/visual_memory.py). The -# negation above re-includes these by default; re-exclude explicitly. +# 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 + +# Local-only PR verification tests — not shipped. +test_tldraw_visual_memory.py diff --git a/adapters/antigravity/.mcp.json b/adapters/antigravity/.mcp.json deleted file mode 100644 index c681719..0000000 --- a/adapters/antigravity/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "tldraw": { - "command": "npx", - "args": ["-y", "@tldraw-mcp/server"] - } - } -} diff --git a/adapters/antigravity/ANTIGRAVITY.md b/adapters/antigravity/ANTIGRAVITY.md index 1604573..5f86e14 100644 --- a/adapters/antigravity/ANTIGRAVITY.md +++ b/adapters/antigravity/ANTIGRAVITY.md @@ -41,8 +41,8 @@ Skip it and the system is just files on disk. 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 `memory/visual/` and are recalled with -`python3 .agent/memory/visual/visual_memory.py list`. Off by default. +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`. diff --git a/adapters/claude-code/.claude/commands/tldraw.md b/adapters/claude-code/.claude/commands/tldraw.md deleted file mode 100644 index 7503b2e..0000000 --- a/adapters/claude-code/.claude/commands/tldraw.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: Open, continue, or snapshot the tldraw visual-memory canvas. ---- - -You are working with the `tldraw` skill. Load -`.agent/skills/tldraw/SKILL.md` first for the full tool reference. - -Execute this flow: - -1. Call `mcp__tldraw__get_canvas` to see the current canvas. -2. If the canvas is empty or the user's argument describes something new, - draw it via `mcp__tldraw__create_shape`. -3. If the user's argument says "save", "snapshot", or "remember this", - pipe the canvas JSON into visual memory: - - ```bash - python3 .agent/memory/visual/visual_memory.py snapshot \ - --label "" \ - --tags "" \ - --note "" - ``` - -4. Report back: what is now on the canvas, and whether a snapshot was - written (include its id). - -Remind the user once per session to open `http://localhost:3030`. - -User argument: $ARGUMENTS diff --git a/adapters/claude-code/.mcp.json b/adapters/claude-code/.mcp.json deleted file mode 100644 index c681719..0000000 --- a/adapters/claude-code/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "tldraw": { - "command": "npx", - "args": ["-y", "@tldraw-mcp/server"] - } - } -} diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json deleted file mode 100644 index c681719..0000000 --- a/adapters/cursor/.cursor/mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "tldraw": { - "command": "npx", - "args": ["-y", "@tldraw-mcp/server"] - } - } -} diff --git a/install.ps1 b/install.ps1 index dcfdb79..70fef06 100644 --- a/install.ps1 +++ b/install.ps1 @@ -51,12 +51,12 @@ switch ($Adapter) { 'claude-code' { Copy-Item (Join-Path $Src 'CLAUDE.md') (Join-Path $TargetDir 'CLAUDE.md') -Force $claudeDir = Join-Path $TargetDir '.claude' - New-Item -ItemType Directory -Path (Join-Path $claudeDir 'commands') -Force | Out-Null + New-Item -ItemType Directory -Path $claudeDir -Force | Out-Null Copy-Item (Join-Path $Src 'settings.json') (Join-Path $claudeDir 'settings.json') -Force - Copy-Item (Join-Path $Src '.claude/commands/tldraw.md') (Join-Path $claudeDir 'commands/tldraw.md') -Force + $sharedMcp = Join-Path $Here 'adapters/_shared/tldraw-mcp.json' $mcpDst = Join-Path $TargetDir '.mcp.json' if (-not (Test-Path $mcpDst)) { - Copy-Item (Join-Path $Src '.mcp.json') $mcpDst -Force + Copy-Item $sharedMcp $mcpDst -Force } else { Write-Host " ~ $mcpDst already exists - merge tldraw from adapters/_shared/tldraw-mcp.json manually" } @@ -67,7 +67,7 @@ switch ($Adapter) { Copy-Item (Join-Path $Src '.cursor/rules/agentic-stack.mdc') (Join-Path $rulesDir 'agentic-stack.mdc') -Force $cursorMcp = Join-Path $TargetDir '.cursor/mcp.json' if (-not (Test-Path $cursorMcp)) { - Copy-Item (Join-Path $Src '.cursor/mcp.json') $cursorMcp -Force + Copy-Item (Join-Path $Here 'adapters/_shared/tldraw-mcp.json') $cursorMcp -Force } else { Write-Host " ~ $cursorMcp already exists - merge tldraw from adapters/_shared/tldraw-mcp.json manually" } @@ -92,7 +92,7 @@ switch ($Adapter) { Copy-Item (Join-Path $Src 'ANTIGRAVITY.md') (Join-Path $TargetDir 'ANTIGRAVITY.md') -Force $mcpDst = Join-Path $TargetDir '.mcp.json' if (-not (Test-Path $mcpDst)) { - Copy-Item (Join-Path $Src '.mcp.json') $mcpDst -Force + Copy-Item (Join-Path $Here 'adapters/_shared/tldraw-mcp.json') $mcpDst -Force } else { Write-Host " ~ $mcpDst already exists - merge tldraw from adapters/_shared/tldraw-mcp.json manually" } diff --git a/install.sh b/install.sh index 11739ae..9975c5a 100755 --- a/install.sh +++ b/install.sh @@ -44,12 +44,11 @@ fi case "$ADAPTER" in claude-code) cp "$SRC/CLAUDE.md" "$TARGET/CLAUDE.md" - mkdir -p "$TARGET/.claude/commands" + mkdir -p "$TARGET/.claude" cp "$SRC/settings.json" "$TARGET/.claude/settings.json" - cp "$SRC/.claude/commands/tldraw.md" "$TARGET/.claude/commands/tldraw.md" # project-level MCP config; don't stomp a pre-existing one if [[ ! -f "$TARGET/.mcp.json" ]]; then - cp "$SRC/.mcp.json" "$TARGET/.mcp.json" + cp "$HERE/adapters/_shared/tldraw-mcp.json" "$TARGET/.mcp.json" else echo " ~ $TARGET/.mcp.json already exists — merge tldraw from adapters/_shared/tldraw-mcp.json manually" fi @@ -58,7 +57,7 @@ case "$ADAPTER" in mkdir -p "$TARGET/.cursor/rules" cp "$SRC/.cursor/rules/agentic-stack.mdc" "$TARGET/.cursor/rules/agentic-stack.mdc" if [[ ! -f "$TARGET/.cursor/mcp.json" ]]; then - cp "$SRC/.cursor/mcp.json" "$TARGET/.cursor/mcp.json" + cp "$HERE/adapters/_shared/tldraw-mcp.json" "$TARGET/.cursor/mcp.json" else echo " ~ $TARGET/.cursor/mcp.json already exists — merge tldraw from adapters/_shared/tldraw-mcp.json manually" fi @@ -103,7 +102,7 @@ case "$ADAPTER" in antigravity) cp "$SRC/ANTIGRAVITY.md" "$TARGET/ANTIGRAVITY.md" if [[ ! -f "$TARGET/.mcp.json" ]]; then - cp "$SRC/.mcp.json" "$TARGET/.mcp.json" + cp "$HERE/adapters/_shared/tldraw-mcp.json" "$TARGET/.mcp.json" else echo " ~ $TARGET/.mcp.json already exists — merge tldraw from adapters/_shared/tldraw-mcp.json manually" fi