Skill: dgkim311/file-inbox (version 1.0.0)

--- name: file-inbox description: > Bidirectional file management system for OpenClaw workspaces. Organizes, indexes, and retrieves files exchanged with users via any channel. Use when: a user sends a file (PDF, image, CSV, code, dataset, etc.) and it should be saved; the agent generates a file (report, analysis, chart) to send; user asks to find, list, or search previously received/sent files; user asks "what files do I have?", "find the PDF I sent last week", or any file organization/retrieval request. NOT for: task tracking, calendar events, or non-file content like text messages. --- # File Inbox Manage all files exchanged with users in `inbox/` with a human-readable INDEX.md. Inbound = files received from user. Outbound = files generated by agent. ## Setup Run once to initialize the directory structure: ```bash bash skills/file-inbox/scripts/init_inbox.sh ``` Creates `inbox/`, subdirectories, `inbox/INDEX.md`, and `inbox/.meta.json`. Idempotent — safe to re-run. ## File Registration Workflow ### When the user sends a file (inbound) 1. Determine the file's current path in workspace. 2. Run the registration script: ```bash python3 skills/file-inbox/scripts/register_file.py \ --path <filepath> \ --direction in \ --sender "<name or channel>" \ --tags "#tag1 #tag2" \ --notes "Brief context" ``` 3. Script moves file to `inbox/inbound/YYYY-MM/` and updates INDEX.md. 4. Confirm the assigned ID to the user (e.g., "Saved as F-007"). ### When the agent generates a file (outbound) 1. Save the file to its destination first, then register: ```bash python3 skills/file-inbox/scripts/register_file.py \ --path <filepath> \ --direction out \ --dest "<recipient name or channel>" \ --tags "#tag1 #tag2" \ --notes "What this file contains" ``` 2. Script copies file to `inbox/outbound/YYYY-MM/` and updates INDEX.md. ### Auto-tagging Tags are auto-assigned from file extension before the script runs. You may append additional context tags. See [tagging-guide.md](references/tagging-guide.md). ## File Search & Retrieval ```bash # Search by tag python3 skills/file-inbox/scripts/search_inbox.py --tag research # Search by file type python3 skills/file-inbox/scripts/search_inbox.py --type pdf # Search by direction python3 skills/file-inbox/scripts/search_inbox.py --direction in # Search by date range python3 skills/file-inbox/scripts/search_inbox.py --date-from 2026-04-01 --date-to 2026-04-30 # Free-text search (matches ID, filename, notes, tags, sender) python3 skills/file-inbox/scripts/search_inbox.py --query "논문" # Combine filters python3 skills/file-inbox/scripts/search_inbox.py --direction in --type csv --query "실험" ``` For quick lookups, read `inbox/INDEX.md` directly — it's a markdown table, grep-friendly. ## INDEX.md Structure ```markdown # File Inbox > Total: N files | Inbound: N | Outbound: N > Last updated: YYYY-MM-DD ## Recent Files | ID | Direction | Filename | Type | Tags | Sender/Dest | Date | Notes | |----|-----------|----------|------|------|-------------|------|-------| | F-007 | ⬇️ in | report.pdf | pdf | #research #논문 | Kim | 2026-04-11 | IEEE revision | | F-006 | ⬆️ out | results.csv | csv | #experiment | → Kim | 2026-04-10 | Phase 2 | ``` - Rows are sorted newest-first. - Keep the 50 most recent rows inline; older entries remain in the archive note at the bottom. ## Inbox Statistics ```bash python3 skills/file-inbox/scripts/inbox_stats.py ``` Shows: total counts, breakdown by type and direction, recent activity, storage size. ## Maintenance - **Orphan check**: Files in `inbox/` not listed in INDEX.md are orphans. Register or remove them. - **Old file cleanup**: Files older than 90 days may be archived. Ask user before moving. - **Index rebuild**: If INDEX.md is corrupted, re-run `register_file.py` for each file in `inbox/`. ## Integration with Channels See [integration-guide.md](references/integration-guide.md) for: - Telegram file handling (document vs. photo messages) - How to extract sender name from channel context - Recommended AGENTS.md additions ## Best Practices 1. Register files immediately after receiving or generating them. 2. Always include `--notes` with enough context to identify the file months later. 3. Use Korean tags naturally — the index is bilingual-ready. 4. When the user asks for a file, search INDEX.md first before scanning the filesystem. 5. Never delete registered files without user confirmation; use notes to mark as obsolete.

path: references/integration-guide.md
content: # Channel Integration Guide

## How Files Arrive

OpenClaw receives files from messaging channels as attachments. The agent can access them as local file paths during the session.

### Telegram
- Documents: delivered as file path, original filename preserved
- Photos: delivered as image path, may have generic name (photo.jpg)
- Voice/video: delivered as media path

### Discord
- Attachments: delivered as file path with original filename
- Embeds: not file-deliverable — ignore

### Slack
- Files: delivered as file path with original filename

## Registration Flow

When a file arrives on any channel:

1. The file is available at a temporary or workspace path
2. Run `register_file.py --path <path> --direction in --sender "<user>" --tags "<tags>" --notes "<context>"`
3. The script moves it into `inbox/inbound/YYYY-MM/` with an ID prefix
4. Confirm to user: "Saved as F-NNN"

When the agent creates a file to send:

1. Generate the file (save to workspace or temp)
2. Run `register_file.py --path <path> --direction out --dest "<user>" --copy --tags "<tags>" --notes "<context>"`
3. Use `--copy` so the original stays available for sending
4. Send the file to the user via the channel
5. Confirm: "Sent and archived as F-NNN"

## AGENTS.md Addition (optional)

Add to your workspace AGENTS.md to remind the agent:

```markdown
## File Management
- When receiving files: register with `file-inbox` skill before processing
- When sending files: register with `file-inbox` skill after creating
- Use `inbox/INDEX.md` for file lookups before filesystem scans
```

## Sender Name Resolution

Extract sender from channel metadata:
- Telegram: `sender` field in conversation info
- Discord: `author` field
- Slack: `user` field

Use the display name (not ID) for readability in INDEX.md.


path: references/tagging-guide.md
content: # Auto-Tagging Guide

## Extension → Tag Mapping

| Extension | Auto Tags |
|-----------|-----------|
| .pdf, .doc, .docx | #document |
| .pptx, .ppt | #slides |
| .xlsx, .xls | #spreadsheet |
| .csv, .tsv, .json, .jsonl, .parquet | #data |
| .png, .jpg, .jpeg, .gif, .webp, .svg | #image |
| .py, .js, .ts, .sh, .r, .ipynb | #code |
| .zip, .tar, .gz, .7z | #archive |
| .mp4 | #video |
| .mp3, .wav | #audio |
| .md | #markdown |
| .yaml, .yml, .toml | #config |

## Context Tags (manually added)

Add context tags to make files easier to find later:

- **Research**: #research #논문 #paper #review #rebuttal
- **Experiment**: #experiment #results #model #training #data
- **Teaching**: #lecture #assignment #syllabus #grade
- **Admin**: #contract #budget #form #report
- **Student name**: #지훈 #우세형 #광훈 (or English names)
- **Project**: #portfolio-opt #timemixer #adversarial

## Best Practices

- Always add at least one context tag beyond the auto-tags
- Korean and English tags are both searchable
- Keep tags short — prefer `#논문` over `#논문-리비전-작업`
- Consistent tag names across files improve search quality


path: scripts/inbox_stats.py
content: #!/usr/bin/env python3
"""
inbox_stats.py — Show inbox statistics.

Usage:
  python3 inbox_stats.py [--inbox DIR]
"""

import argparse
import json
import os
from collections import Counter
from pathlib import Path


def find_workspace_root(start: Path) -> Path:
    current = start.resolve()
    for _ in range(10):
        if (current / "skills").is_dir() or (current / "inbox").is_dir():
            return current
        parent = current.parent
        if parent == current:
            break
        current = parent
    return Path.cwd()


def get_dir_size(path: Path) -> int:
    total = 0
    for f in path.rglob("*"):
        if f.is_file() and f.name != ".meta.json":
            total += f.stat().st_size
    return total


def human_size(size_bytes: int) -> str:
    for unit in ["B", "KB", "MB", "GB"]:
        if abs(size_bytes) < 1024:
            return f"{size_bytes:.1f} {unit}"
        size_bytes /= 1024
    return f"{size_bytes:.1f} TB"


def parse_index_rows(index_path: Path) -> list[dict]:
    if not index_path.exists():
        return []
    rows = []
    in_table = False
    for line in index_path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if stripped.startswith("| ID |"):
            in_table = True
            continue
        if in_table and stripped.startswith("|---"):
            continue
        if in_table and stripped.startswith("|"):
            parts = [p.strip() for p in stripped.strip("|").split("|")]
            if len(parts) >= 7:
                rows.append({
                    "direction": "in" if "in" in parts[1] else "out",
                    "type": parts[3],
                    "tags": parts[4],
                    "date": parts[6],
                })
        elif in_table and stripped and not stripped.startswith("|"):
            in_table = False
    return rows


def main():
    parser = argparse.ArgumentParser(description="Show inbox statistics")
    parser.add_argument("--inbox", default="inbox")
    args = parser.parse_args()

    workspace = find_workspace_root(Path(__file__))
    inbox_root = workspace / args.inbox

    if not inbox_root.exists():
        print("Inbox not initialized. Run init_inbox.sh first.")
        return

    meta_path = inbox_root / ".meta.json"
    index_path = inbox_root / "INDEX.md"

    # Meta stats
    if meta_path.exists():
        with open(meta_path) as f:
            meta = json.load(f)
    else:
        meta = {"total": 0, "inbound": 0, "outbound": 0, "lastUpdated": "N/A"}

    # Parse index for detailed stats
    rows = parse_index_rows(index_path)

    # Type breakdown
    type_counter = Counter(r["type"] for r in rows)
    tag_counter = Counter()
    for r in rows:
        for tag in r["tags"].split():
            tag = tag.strip()
            if tag:
                tag_counter[tag] += 1

    # Storage size
    inbound_size = get_dir_size(inbox_root / "inbound") if (inbox_root / "inbound").exists() else 0
    outbound_size = get_dir_size(inbox_root / "outbound") if (inbox_root / "outbound").exists() else 0
    total_size = inbound_size + outbound_size

    # Recent activity (last 7 entries)
    recent = rows[:7]

    # Output
    print("=" * 50)
    print("📁 File Inbox Statistics")
    print("=" * 50)
    print(f"Total files:    {meta['total']}")
    print(f"  Inbound:      {meta['inbound']}")
    print(f"  Outbound:     {meta['outbound']}")
    print(f"Last updated:   {meta.get('lastUpdated', 'N/A')}")
    print(f"Storage:        {human_size(total_size)}")
    print(f"  Inbound:      {human_size(inbound_size)}")
    print(f"  Outbound:     {human_size(outbound_size)}")

    if type_counter:
        print(f"\n📊 By File Type:")
        for ftype, count in type_counter.most_common(10):
            print(f"  {ftype:12s}  {count}")

    if tag_counter:
        print(f"\n🏷️  Top Tags:")
        for tag, count in tag_counter.most_common(10):
            print(f"  {tag:16s}  {count}")

    if recent:
        print(f"\n🕐 Recent Activity:")
        for r in recent:
            icon = "⬇️" if r["direction"] == "in" else "⬆️"
            print(f"  {icon} {r['date']}  {r['type']}")

    print()


if __name__ == "__main__":
    main()


path: scripts/init_inbox.sh
content: #!/usr/bin/env bash
# init_inbox.sh — Initialize the file-inbox directory structure
# Idempotent: safe to run multiple times

set -euo pipefail

INBOX_DIR="${INBOX_DIR:-inbox}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
INBOX_PATH="$WORKSPACE_ROOT/$INBOX_DIR"

echo "Initializing file inbox at: $INBOX_PATH"

# Create directories
mkdir -p "$INBOX_PATH/inbound"
mkdir -p "$INBOX_PATH/outbound"

# Create .meta.json if not exists
META_FILE="$INBOX_PATH/.meta.json"
if [ ! -f "$META_FILE" ]; then
  cat > "$META_FILE" <<'EOF'
{
  "nextId": 1,
  "total": 0,
  "inbound": 0,
  "outbound": 0,
  "lastUpdated": ""
}
EOF
  echo "Created .meta.json"
else
  echo ".meta.json already exists — skipping"
fi

# Create INDEX.md if not exists
INDEX_FILE="$INBOX_PATH/INDEX.md"
if [ ! -f "$INDEX_FILE" ]; then
  TODAY=$(date +%Y-%m-%d)
  cat > "$INDEX_FILE" <<EOF
# File Inbox

> Total: 0 files | Inbound: 0 | Outbound: 0
> Last updated: $TODAY

## Recent Files

| ID | Direction | Filename | Type | Tags | Sender/Dest | Date | Notes |
|----|-----------|----------|------|------|-------------|------|-------|

EOF
  echo "Created INDEX.md"
else
  echo "INDEX.md already exists — skipping"
fi

echo "Done. File inbox ready at: $INBOX_PATH"


path: scripts/register_file.py
content: #!/usr/bin/env python3
"""
register_file.py — Register a file in the inbox index.

Usage:
  python3 register_file.py --path <file> --direction in|out [options]

Options:
  --path PATH         Path to the file to register (required)
  --direction DIR     'in' (received from user) or 'out' (sent to user) (required)
  --sender NAME       Sender name or channel (for inbound files)
  --dest NAME         Destination name or channel (for outbound files)
  --tags TAGS         Space-separated tags with # prefix, e.g. "#research #논문"
  --notes TEXT        Brief description or context for the file
  --copy              Copy the file instead of moving it (default: move)
  --inbox DIR         Inbox root directory (default: inbox/)
"""

import argparse
import json
import mimetypes
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path


# ── Auto-tagging rules ────────────────────────────────────────────────────────

EXT_TAGS = {
    # Documents
    ".pdf": "#pdf #document",
    ".doc": "#doc #document",
    ".docx": "#docx #document",
    ".pptx": "#pptx #slides",
    ".ppt": "#ppt #slides",
    ".xlsx": "#xlsx #spreadsheet",
    ".xls": "#xls #spreadsheet",
    ".txt": "#text",
    ".md": "#markdown",
    ".rst": "#docs",
    # Data
    ".csv": "#csv #data",
    ".tsv": "#tsv #data",
    ".json": "#json #data",
    ".jsonl": "#jsonl #data",
    ".parquet": "#parquet #data",
    ".feather": "#feather #data",
    ".pkl": "#pickle #data",
    ".npy": "#numpy #data",
    ".npz": "#numpy #data",
    # Images
    ".png": "#image",
    ".jpg": "#image",
    ".jpeg": "#image",
    ".gif": "#image",
    ".svg": "#svg #image",
    ".webp": "#image",
    # Code
    ".py": "#python #code",
    ".js": "#javascript #code",
    ".ts": "#typescript #code",
    ".sh": "#shell #code",
    ".r": "#r #code",
    ".ipynb": "#notebook #code",
    # Archives
    ".zip": "#archive",
    ".tar": "#archive",
    ".gz": "#archive",
    ".7z": "#archive",
    # Other
    ".mp4": "#video",
    ".mp3": "#audio",
    ".wav": "#audio",
    ".xml": "#xml",
    ".yaml": "#yaml",
    ".yml": "#yaml",
    ".toml": "#toml",
}


def auto_tags(filepath: Path) -> str:
    ext = filepath.suffix.lower()
    return EXT_TAGS.get(ext, "")


# ── Helpers ───────────────────────────────────────────────────────────────────

def find_workspace_root(start: Path) -> Path:
    """Walk up from start to find the workspace root (contains inbox/ or skills/)."""
    current = start.resolve()
    for _ in range(10):
        if (current / "skills").is_dir() or (current / "inbox").is_dir():
            return current
        parent = current.parent
        if parent == current:
            break
        current = parent
    # Fallback: use cwd
    return Path.cwd()


def load_meta(meta_path: Path) -> dict:
    with open(meta_path) as f:
        return json.load(f)


def save_meta(meta_path: Path, meta: dict):
    with open(meta_path, "w") as f:
        json.dump(meta, f, indent=2, ensure_ascii=False)
        f.write("\n")


def merge_tags(auto: str, user: str) -> str:
    """Merge auto and user tags, deduplicate."""
    all_tags = []
    seen = set()
    for tag in (auto + " " + user).split():
        tag = tag.strip()
        if tag and tag not in seen:
            seen.add(tag)
            all_tags.append(tag)
    return " ".join(all_tags)


def sanitize_filename(name: str) -> str:
    """Remove characters unsafe for filenames."""
    return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)


def update_index(index_path: Path, row: dict, meta: dict):
    """Insert a new row at the top of the Recent Files table in INDEX.md."""
    content = index_path.read_text(encoding="utf-8") if index_path.exists() else ""

    direction_icon = "⬇️ in" if row["direction"] == "in" else "⬆️ out"
    sender_dest = row.get("sender") or f"→ {row.get('dest', '')}"
    notes = row.get("notes", "").replace("|", "\\|")
    tags = row.get("tags", "")

    new_row = (
        f"| {row['id']} | {direction_icon} | {row['filename']} | {row['filetype']} "
        f"| {tags} | {sender_dest} | {row['date']} | {notes} |"
    )

    # Find the table header separator line and insert after it
    table_header_re = re.compile(
        r"(\| ID \| Direction \|.*\n\|[-| ]+\|\n)", re.MULTILINE
    )
    match = table_header_re.search(content)
    if match:
        insert_pos = match.end()
        content = content[:insert_pos] + new_row + "\n" + content[insert_pos:]
    else:
        # No table yet — append
        content += f"\n{new_row}\n"

    # Update stats header
    total = meta["total"]
    inbound = meta["inbound"]
    outbound = meta["outbound"]
    today = row["date"]

    stats_line = f"> Total: {total} files | Inbound: {inbound} | Outbound: {outbound}"
    updated_line = f"> Last updated: {today}"

    content = re.sub(r"> Total:.*", stats_line, content)
    content = re.sub(r"> Last updated:.*", updated_line, content)

    index_path.write_text(content, encoding="utf-8")


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="Register a file in the inbox")
    parser.add_argument("--path", required=True, help="Path to the file")
    parser.add_argument("--direction", required=True, choices=["in", "out"])
    parser.add_argument("--sender", default="", help="Sender name/channel (inbound)")
    parser.add_argument("--dest", default="", help="Destination name/channel (outbound)")
    parser.add_argument("--tags", default="", help="Space-separated #tags")
    parser.add_argument("--notes", default="", help="Brief description")
    parser.add_argument("--copy", action="store_true", help="Copy instead of move")
    parser.add_argument("--inbox", default="inbox", help="Inbox root directory")
    args = parser.parse_args()

    src = Path(args.path).resolve()
    if not src.exists():
        print(f"Error: file not found: {src}", file=sys.stderr)
        sys.exit(1)

    workspace = find_workspace_root(src)
    inbox_root = workspace / args.inbox

    if not inbox_root.exists():
        print(f"Error: inbox not initialized at {inbox_root}. Run init_inbox.sh first.", file=sys.stderr)
        sys.exit(1)

    meta_path = inbox_root / ".meta.json"
    index_path = inbox_root / "INDEX.md"
    meta = load_meta(meta_path)

    # Assign ID
    file_id = f"F-{meta['nextId']:03d}"
    today = datetime.today().strftime("%Y-%m-%d")
    year_month = datetime.today().strftime("%Y-%m")

    # Determine destination subdir
    subdir_name = "inbound" if args.direction == "in" else "outbound"
    dest_dir = inbox_root / subdir_name / year_month
    dest_dir.mkdir(parents=True, exist_ok=True)

    # Build destination filename: F-NNN_original-name
    safe_name = sanitize_filename(src.name)
    dest_filename = f"{file_id}_{safe_name}"
    dest_path = dest_dir / dest_filename

    # Move or copy
    if args.copy:
        shutil.copy2(str(src), str(dest_path))
        action = "Copied"
    else:
        shutil.move(str(src), str(dest_path))
        action = "Moved"

    # Determine file type from extension
    filetype = src.suffix.lstrip(".").lower() or "file"

    # Merge tags
    tags = merge_tags(auto_tags(src), args.tags)

    # Update meta
    meta["nextId"] += 1
    meta["total"] += 1
    if args.direction == "in":
        meta["inbound"] += 1
    else:
        meta["outbound"] += 1
    meta["lastUpdated"] = today
    save_meta(meta_path, meta)

    # Build row data
    row = {
        "id": file_id,
        "direction": args.direction,
        "filename": dest_filename,
        "filetype": filetype,
        "tags": tags,
        "sender": args.sender,
        "dest": args.dest,
        "date": today,
        "notes": args.notes,
    }

    update_index(index_path, row, meta)

    print(f"{action} to: {dest_path}")
    print(f"Registered as {file_id} | {filetype} | {tags}")
    print(f"INDEX.md updated.")


if __name__ == "__main__":
    main()


path: scripts/search_inbox.py
content: #!/usr/bin/env python3
"""
search_inbox.py — Search files registered in the inbox.

Usage:
  python3 search_inbox.py [options]

Options:
  --tag TAG           Filter by tag (e.g. research, #논문)
  --type TYPE         Filter by file type (e.g. pdf, csv, png)
  --direction DIR     Filter by direction: in | out
  --date-from DATE    Start date (YYYY-MM-DD)
  --date-to DATE      End date (YYYY-MM-DD)
  --query TEXT        Free-text search across ID, filename, tags, sender, notes
  --inbox DIR         Inbox root directory (default: inbox/)
  --limit N           Max results to show (default: 20)
"""

import argparse
import re
import sys
from pathlib import Path


def find_workspace_root(start: Path) -> Path:
    current = start.resolve()
    for _ in range(10):
        if (current / "skills").is_dir() or (current / "inbox").is_dir():
            return current
        parent = current.parent
        if parent == current:
            break
        current = parent
    return Path.cwd()


def parse_index(index_path: Path) -> list[dict]:
    """Parse INDEX.md table rows into dicts."""
    if not index_path.exists():
        return []

    content = index_path.read_text(encoding="utf-8")
    rows = []
    in_table = False

    for line in content.splitlines():
        stripped = line.strip()
        if stripped.startswith("| ID |"):
            in_table = True
            continue
        if in_table and stripped.startswith("|---"):
            continue
        if in_table and stripped.startswith("|"):
            parts = [p.strip() for p in stripped.strip("|").split("|")]
            if len(parts) >= 7:
                direction_raw = parts[1]
                direction = "in" if "in" in direction_raw else "out"
                rows.append({
                    "id": parts[0],
                    "direction": direction,
                    "filename": parts[2],
                    "type": parts[3],
                    "tags": parts[4],
                    "sender_dest": parts[5],
                    "date": parts[6],
                    "notes": parts[7] if len(parts) > 7 else "",
                    "_raw": stripped,
                })
        elif in_table and not stripped.startswith("|"):
            if stripped:  # non-empty non-table line ends the table
                in_table = False

    return rows


def matches(row: dict, args: argparse.Namespace) -> bool:
    # Direction filter
    if args.direction and row["direction"] != args.direction:
        return False

    # Type filter
    if args.type:
        ftype = args.type.lstrip(".")
        if row["type"].lower() != ftype.lower():
            return False

    # Tag filter
    if args.tag:
        tag = args.tag if args.tag.startswith("#") else f"#{args.tag}"
        if tag.lower() not in row["tags"].lower():
            return False

    # Date range filter
    if args.date_from and row["date"] < args.date_from:
        return False
    if args.date_to and row["date"] > args.date_to:
        return False

    # Free-text query (searches all fields)
    if args.query:
        query = args.query.lower()
        searchable = " ".join([
            row["id"], row["filename"], row["tags"],
            row["sender_dest"], row["notes"], row["type"]
        ]).lower()
        if query not in searchable:
            return False

    return True


def format_results(rows: list[dict]) -> str:
    if not rows:
        return "No files found."

    header = f"Found {len(rows)} file(s):\n"
    sep = "-" * 80
    lines = [header, sep]
    for r in rows:
        direction_icon = "⬇️ IN " if r["direction"] == "in" else "⬆️ OUT"
        lines.append(
            f"{r['id']}  {direction_icon}  {r['filename']}"
        )
        lines.append(
            f"     Type: {r['type']}  |  Date: {r['date']}  |  {r['sender_dest']}"
        )
        if r["tags"]:
            lines.append(f"     Tags: {r['tags']}")
        if r["notes"]:
            lines.append(f"     Notes: {r['notes']}")
        lines.append("")

    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Search the file inbox")
    parser.add_argument("--tag", default="", help="Filter by tag")
    parser.add_argument("--type", default="", help="Filter by file type")
    parser.add_argument("--direction", default="", choices=["", "in", "out"])
    parser.add_argument("--date-from", default="", dest="date_from")
    parser.add_argument("--date-to", default="", dest="date_to")
    parser.add_argument("--query", default="", help="Free-text search")
    parser.add_argument("--inbox", default="inbox", help="Inbox root directory")
    parser.add_argument("--limit", type=int, default=20)
    args = parser.parse_args()

    # At least one filter required
    if not any([args.tag, args.type, args.direction, args.date_from, args.date_to, args.query]):
        print("Please specify at least one filter. Use --help for options.")
        sys.exit(1)

    workspace = find_workspace_root(Path(__file__))
    inbox_root = workspace / args.inbox
    index_path = inbox_root / "INDEX.md"

    if not index_path.exists():
        print(f"Error: INDEX.md not found at {index_path}. Run init_inbox.sh first.")
        sys.exit(1)

    rows = parse_index(index_path)
    matched = [r for r in rows if matches(r, args)]
    matched = matched[:args.limit]

    print(format_results(matched))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)